Coverage for colour/plotting/phenomena.py: 96%

282 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-16 23:01 +1300

1""" 

2Optical Phenomenon Plotting 

3=========================== 

4 

5Define the optical phenomena plotting objects. 

6 

7- :func:`colour.plotting.plot_single_sd_rayleigh_scattering` 

8- :func:`colour.plotting.plot_the_blue_sky` 

9""" 

10 

11from __future__ import annotations 

12 

13import typing 

14 

15import matplotlib.pyplot as plt 

16import numpy as np 

17 

18if typing.TYPE_CHECKING: 

19 from matplotlib.figure import Figure 

20 from matplotlib.axes import Axes 

21 

22from colour.algebra import normalise_maximum 

23from colour.colorimetry import ( 

24 SPECTRAL_SHAPE_DEFAULT, 

25 MultiSpectralDistributions, 

26 SpectralDistribution, 

27 SpectralShape, 

28 msds_to_XYZ, 

29 sd_to_XYZ, 

30) 

31 

32if typing.TYPE_CHECKING: 

33 from colour.hints import Any, ArrayLike, Dict, Literal, Sequence, Tuple 

34 

35from colour.hints import cast 

36from colour.phenomena import sd_rayleigh_scattering 

37from colour.phenomena.interference import ( 

38 multilayer_tmm, 

39 thin_film_tmm, 

40) 

41from colour.phenomena.rayleigh import ( 

42 CONSTANT_AVERAGE_PRESSURE_MEAN_SEA_LEVEL, 

43 CONSTANT_DEFAULT_ALTITUDE, 

44 CONSTANT_DEFAULT_LATITUDE, 

45 CONSTANT_STANDARD_AIR_TEMPERATURE, 

46 CONSTANT_STANDARD_CO2_CONCENTRATION, 

47) 

48from colour.phenomena.tmm import matrix_transfer_tmm 

49from colour.plotting import ( 

50 CONSTANTS_COLOUR_STYLE, 

51 SD_ASTMG173_ETR, 

52 ColourSwatch, 

53 XYZ_to_plotting_colourspace, 

54 artist, 

55 colour_cycle, 

56 filter_cmfs, 

57 filter_illuminants, 

58 override_style, 

59 plot_ray, 

60 plot_single_colour_swatch, 

61 plot_single_sd, 

62 render, 

63) 

64from colour.utilities import ( 

65 as_complex_array, 

66 as_float_array, 

67 as_float_scalar, 

68 first_item, 

69 optional, 

70 validate_method, 

71) 

72 

73__author__ = "Colour Developers" 

74__copyright__ = "Copyright 2013 Colour Developers" 

75__license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause" 

76__maintainer__ = "Colour Developers" 

77__email__ = "colour-developers@colour-science.org" 

78__status__ = "Production" 

79 

80__all__ = [ 

81 "plot_single_sd_rayleigh_scattering", 

82 "plot_the_blue_sky", 

83 "plot_single_layer_thin_film", 

84 "plot_multi_layer_thin_film", 

85 "plot_thin_film_comparison", 

86 "plot_thin_film_spectrum", 

87 "plot_thin_film_iridescence", 

88 "plot_thin_film_reflectance_map", 

89 "plot_multi_layer_stack", 

90] 

91 

92 

93@override_style() 

94def plot_single_sd_rayleigh_scattering( 

95 CO2_concentration: ArrayLike = CONSTANT_STANDARD_CO2_CONCENTRATION, 

96 temperature: ArrayLike = CONSTANT_STANDARD_AIR_TEMPERATURE, 

97 pressure: ArrayLike = CONSTANT_AVERAGE_PRESSURE_MEAN_SEA_LEVEL, 

98 latitude: ArrayLike = CONSTANT_DEFAULT_LATITUDE, 

99 altitude: ArrayLike = CONSTANT_DEFAULT_ALTITUDE, 

100 cmfs: ( 

101 MultiSpectralDistributions | str | Sequence[MultiSpectralDistributions | str] 

102 ) = "CIE 1931 2 Degree Standard Observer", 

103 **kwargs: Any, 

104) -> Tuple[Figure, Axes]: 

105 """ 

106 Plot a single *Rayleigh* scattering spectral distribution. 

107 

108 Parameters 

109 ---------- 

110 CO2_concentration 

111 :math:`CO_2` concentration in parts per million (ppm). 

112 temperature 

113 Air temperature :math:`T[K]` in kelvin degrees. 

114 pressure 

115 Surface pressure :math:`P` at the measurement site. 

116 latitude 

117 Latitude of the site in degrees. 

118 altitude 

119 Altitude of the site in meters. 

120 cmfs 

121 Standard observer colour matching functions used for computing 

122 the spectrum domain and colours. ``cmfs`` can be of any type or 

123 form supported by the :func:`colour.plotting.common.filter_cmfs` 

124 definition. 

125 

126 Other Parameters 

127 ---------------- 

128 kwargs 

129 {:func:`colour.plotting.artist`, 

130 :func:`colour.plotting.plot_single_sd`, 

131 :func:`colour.plotting.render`}, 

132 See the documentation of the previously listed definitions. 

133 

134 Returns 

135 ------- 

136 :class:`tuple` 

137 Current figure and axes. 

138 

139 Examples 

140 -------- 

141 >>> plot_single_sd_rayleigh_scattering() # doctest: +ELLIPSIS 

142 (<Figure size ... with 1 Axes>, <...Axes...>) 

143 

144 .. image:: ../_static/Plotting_Plot_Single_SD_Rayleigh_Scattering.png 

145 :align: center 

146 :alt: plot_single_sd_rayleigh_scattering 

147 """ 

148 

149 title = "Rayleigh Scattering" 

150 

151 cmfs = cast("MultiSpectralDistributions", first_item(filter_cmfs(cmfs).values())) 

152 

153 settings: Dict[str, Any] = {"title": title, "y_label": "Optical Depth"} 

154 settings.update(kwargs) 

155 

156 sd = sd_rayleigh_scattering( 

157 cmfs.shape, 

158 CO2_concentration, 

159 temperature, 

160 pressure, 

161 latitude, 

162 altitude, 

163 ) 

164 

165 return plot_single_sd(sd, **settings) 

166 

167 

168@override_style() 

169def plot_the_blue_sky( 

170 cmfs: ( 

171 MultiSpectralDistributions | str | Sequence[MultiSpectralDistributions | str] 

172 ) = "CIE 1931 2 Degree Standard Observer", 

173 **kwargs: Any, 

174) -> Tuple[Figure, Axes]: 

175 """ 

176 Plot the blue sky spectral radiance distribution. 

177 

178 Parameters 

179 ---------- 

180 cmfs 

181 Standard observer colour matching functions used for computing the 

182 spectrum domain and colours. ``cmfs`` can be of any type or form 

183 supported by the :func:`colour.plotting.common.filter_cmfs` 

184 definition. 

185 

186 Other Parameters 

187 ---------------- 

188 kwargs 

189 {:func:`colour.plotting.artist`, 

190 :func:`colour.plotting.plot_single_sd`, 

191 :func:`colour.plotting.plot_multi_colour_swatches`, 

192 :func:`colour.plotting.render`}, 

193 See the documentation of the previously listed definitions. 

194 

195 Returns 

196 ------- 

197 :class:`tuple` 

198 Current figure and axes. 

199 

200 Examples 

201 -------- 

202 >>> plot_the_blue_sky() # doctest: +ELLIPSIS 

203 (<Figure size ... with 2 Axes>, <...Axes...>) 

204 

205 .. image:: ../_static/Plotting_Plot_The_Blue_Sky.png 

206 :align: center 

207 :alt: plot_the_blue_sky 

208 """ 

209 

210 figure = plt.figure() 

211 

212 figure.subplots_adjust(hspace=CONSTANTS_COLOUR_STYLE.geometry.short / 2) 

213 

214 cmfs = cast("MultiSpectralDistributions", first_item(filter_cmfs(cmfs).values())) 

215 

216 ASTMG173_sd = SD_ASTMG173_ETR.copy() 

217 rayleigh_sd = sd_rayleigh_scattering() 

218 ASTMG173_sd.align(rayleigh_sd.shape) 

219 

220 sd = rayleigh_sd * ASTMG173_sd 

221 

222 axes = figure.add_subplot(211) 

223 

224 settings: Dict[str, Any] = { 

225 "axes": axes, 

226 "title": "The Blue Sky - Synthetic Spectral Distribution", 

227 "y_label": "W / m-2 / nm-1", 

228 } 

229 settings.update(kwargs) 

230 settings["show"] = False 

231 

232 plot_single_sd(sd, cmfs, **settings) 

233 

234 axes = figure.add_subplot(212) 

235 

236 x_label = ( 

237 "The sky is blue because molecules in the atmosphere " 

238 "scatter shorter wavelengths more than longer ones.\n" 

239 "The synthetic spectral distribution is computed as " 

240 "follows: " 

241 "(ASTM G-173 ETR * Standard Air Rayleigh Scattering)." 

242 ) 

243 

244 settings = { 

245 "axes": axes, 

246 "aspect": None, 

247 "title": "The Blue Sky - Colour", 

248 "x_label": x_label, 

249 "y_label": "", 

250 "x_ticker": False, 

251 "y_ticker": False, 

252 } 

253 settings.update(kwargs) 

254 settings["show"] = False 

255 

256 blue_sky_color = XYZ_to_plotting_colourspace(sd_to_XYZ(sd)) 

257 

258 figure, axes = plot_single_colour_swatch( 

259 ColourSwatch(normalise_maximum(blue_sky_color)), **settings 

260 ) 

261 

262 settings = {"axes": axes, "show": True} 

263 settings.update(kwargs) 

264 

265 return render(**settings) 

266 

267 

268@override_style() 

269def plot_single_layer_thin_film( 

270 n: ArrayLike, 

271 t: ArrayLike, 

272 theta: ArrayLike = 0, 

273 shape: SpectralShape = SPECTRAL_SHAPE_DEFAULT, 

274 polarisation: Literal["S", "P", "Both"] | str = "Both", 

275 method: Literal["Reflectance", "Transmittance", "Both"] | str = "Reflectance", 

276 **kwargs: Any, 

277) -> Tuple[Figure, Axes]: 

278 """ 

279 Plot reflectance and/or transmittance of a single-layer thin film. 

280 

281 Parameters 

282 ---------- 

283 n 

284 Complete refractive index stack :math:`n_j` for single-layer film. 

285 Shape: (3,) or (3, wavelengths_count). The array should contain 

286 [n_incident, n_film, n_substrate]. 

287 t 

288 Thickness :math:`t` of the film in nanometers. 

289 theta 

290 Incident angle :math:`\\theta` in degrees. Default is 0 (normal incidence). 

291 shape 

292 Spectral shape for wavelength sampling. 

293 polarisation 

294 Polarisation to plot: 'S', 'P', or 'Both' (case-insensitive). 

295 method 

296 Optical property to plot: 'Reflectance', 'Transmittance', or 'Both' 

297 (case-insensitive). Default is 'Reflectance'. 

298 

299 Other Parameters 

300 ---------------- 

301 kwargs 

302 {:func:`colour.plotting.artist`, 

303 :func:`colour.plotting.plot_multi_layer_thin_film`, 

304 :func:`colour.plotting.render`}, 

305 See the documentation of the previously listed definitions. 

306 

307 Returns 

308 ------- 

309 :class:`tuple` 

310 Current figure and axes. 

311 

312 Examples 

313 -------- 

314 >>> plot_single_layer_thin_film([1.0, 1.46, 1.5], 100) # doctest: +ELLIPSIS 

315 (<Figure size ... with 1 Axes>, <...Axes...>) 

316 

317 .. image:: ../_static/Plotting_Plot_Single_Layer_Thin_Film.png 

318 :align: center 

319 :alt: plot_single_layer_thin_film 

320 """ 

321 

322 n = as_complex_array(n) 

323 t_array = as_float_array(t) 

324 n_layer = n[1] if n.ndim == 1 else n[1, 0] 

325 t_scalar = t_array if t_array.ndim == 0 else t_array[0] 

326 title = ( 

327 f"Single Layer Thin Film (n={np.real(n_layer):.2f}, " 

328 f"d={t_scalar:.0f} nm, θ={theta}°)" 

329 ) 

330 

331 settings: Dict[str, Any] = {"title": title} 

332 settings.update(kwargs) 

333 

334 return plot_multi_layer_thin_film( 

335 n, np.atleast_1d(t_array), theta, shape, polarisation, method, **settings 

336 ) 

337 

338 

339@override_style() 

340def plot_multi_layer_thin_film( 

341 n: ArrayLike, 

342 t: ArrayLike, 

343 theta: ArrayLike = 0, 

344 shape: SpectralShape = SPECTRAL_SHAPE_DEFAULT, 

345 polarisation: Literal["S", "P", "Both"] | str = "Both", 

346 method: Literal["Reflectance", "Transmittance", "Both"] | str = "Reflectance", 

347 **kwargs: Any, 

348) -> Tuple[Figure, Axes]: 

349 """ 

350 Plot reflectance and/or transmittance of a multi-layer thin film stack. 

351 

352 Parameters 

353 ---------- 

354 n 

355 Complete refractive index stack :math:`n_j` including incident medium, 

356 layers, and substrate. Shape: (media_count,) or 

357 (media_count, wavelengths_count). The array should contain 

358 [n_incident, n_layer_1, ..., n_layer_n, n_substrate]. 

359 t 

360 Thicknesses :math:`t_j` of the layers in nanometers (excluding incident 

361 and substrate). Shape: (layers_count,). 

362 theta 

363 Incident angle :math:`\\theta` in degrees. Default is 0 (normal incidence). 

364 shape 

365 Spectral shape for wavelength sampling. 

366 polarisation 

367 Polarisation to plot: 'S', 'P', or 'Both' (case-insensitive). 

368 method 

369 Optical property to plot: 'Reflectance', 'Transmittance', or 'Both' 

370 (case-insensitive). Default is 'Reflectance'. 

371 

372 Other Parameters 

373 ---------------- 

374 kwargs 

375 {:func:`colour.plotting.artist`, 

376 :func:`colour.plotting.render`}, 

377 See the documentation of the previously listed definitions. 

378 

379 Returns 

380 ------- 

381 :class:`tuple` 

382 Current figure and axes. 

383 

384 Examples 

385 -------- 

386 >>> plot_multi_layer_thin_film( 

387 ... [1.0, 1.46, 2.4, 1.5], [100, 50] 

388 ... ) # doctest: +ELLIPSIS 

389 (<Figure size ... with 1 Axes>, <...Axes...>) 

390 

391 .. image:: ../_static/Plotting_Plot_Multi_Layer_Thin_Film.png 

392 :align: center 

393 :alt: plot_multi_layer_thin_film 

394 """ 

395 

396 n = as_complex_array(n) 

397 t = as_float_array(t) 

398 

399 _figure, axes = artist(**kwargs) 

400 

401 wavelengths = shape.wavelengths 

402 

403 polarisation = validate_method(polarisation, ("S", "P", "Both")) 

404 method = validate_method(method, ("Reflectance", "Transmittance", "Both")) 

405 

406 R, T = multilayer_tmm(n, t, wavelengths, theta) 

407 

408 if method in ["reflectance", "both"]: 

409 if polarisation in ["s", "both"]: 

410 axes.plot(wavelengths, R[:, 0, 0, 0], "b-", label="R (s-pol)", linewidth=2) 

411 

412 if polarisation in ["p", "both"]: 

413 axes.plot(wavelengths, R[:, 0, 0, 1], "r--", label="R (p-pol)", linewidth=2) 

414 

415 if method in ["transmittance", "both"]: 

416 if polarisation in ["s", "both"]: 

417 axes.plot( 

418 wavelengths, 

419 T[:, 0, 0, 0], 

420 "b:", 

421 label="T (s-pol)", 

422 linewidth=2, 

423 alpha=0.7, 

424 ) 

425 if polarisation in ["p", "both"]: 

426 axes.plot( 

427 wavelengths, 

428 T[:, 0, 0, 1], 

429 "r:", 

430 label="T (p-pol)", 

431 linewidth=2, 

432 alpha=0.7, 

433 ) 

434 

435 # Extract layer indices (exclude incident and substrate) 

436 n_layers = n[1:-1] if n.ndim == 1 else n[1:-1, 0] 

437 layer_description = ", ".join( 

438 [ 

439 f"n={np.real(n_val):.2f} d={d:.0f}nm" 

440 for n_val, d in zip(n_layers, t, strict=False) 

441 ] 

442 ) 

443 

444 if method == "reflectance": 

445 y_label = "Reflectance" 

446 elif method == "transmittance": 

447 y_label = "Transmittance" 

448 else: # both 

449 y_label = "Reflectance / Transmittance" 

450 

451 settings: Dict[str, Any] = { 

452 "axes": axes, 

453 "bounding_box": (np.min(wavelengths), np.max(wavelengths), 0, 1), 

454 "title": f"Multilayer Thin Film Stack ({layer_description}, θ={theta}°)", 

455 "x_label": "Wavelength (nm)", 

456 "y_label": y_label, 

457 "legend": True, 

458 "show": True, 

459 } 

460 settings.update(kwargs) 

461 

462 return render(**settings) 

463 

464 

465@override_style() 

466def plot_thin_film_comparison( 

467 configurations: Sequence[Dict[str, Any]], 

468 shape: SpectralShape = SPECTRAL_SHAPE_DEFAULT, 

469 polarisation: Literal["S", "P", "Both"] | str = "Both", 

470 **kwargs: Any, 

471) -> Tuple[Figure, Axes]: 

472 """ 

473 Plot comparison of multiple thin film configurations. 

474 

475 Parameters 

476 ---------- 

477 configurations 

478 List of dictionaries, each containing parameters for a thin film configuration. 

479 

480 - Single layer: ``{'type': 'single', 'n_film': float, 't': float, 

481 'n_substrate': float, 'label': str}`` 

482 - Multilayer: ``{'type': 'multilayer', 'refractive_indices': array, 

483 't': array, 'n_substrate': float, 'label': str}`` 

484 shape 

485 Spectral shape for wavelength sampling. 

486 polarisation 

487 Polarisation to plot: 'S', 'P', or 'Both' (case-insensitive). 

488 

489 Other Parameters 

490 ---------------- 

491 kwargs 

492 {:func:`colour.plotting.artist`, 

493 :func:`colour.plotting.render`}, 

494 See the documentation of the previously listed definitions. 

495 

496 Returns 

497 ------- 

498 :class:`tuple` 

499 Current figure and axes. 

500 

501 Examples 

502 -------- 

503 >>> configurations = [ 

504 ... { 

505 ... "type": "single", 

506 ... "n_film": 1.46, 

507 ... "t": 100, 

508 ... "n_substrate": 1.5, 

509 ... "label": "MgF2 100nm", 

510 ... }, 

511 ... { 

512 ... "type": "single", 

513 ... "n_film": 2.4, 

514 ... "t": 25, 

515 ... "n_substrate": 1.5, 

516 ... "label": "TiO2 25nm", 

517 ... }, 

518 ... ] 

519 >>> plot_thin_film_comparison(configurations) # doctest: +ELLIPSIS 

520 (<Figure size ... with 1 Axes>, <...Axes...>) 

521 

522 .. image:: ../_static/Plotting_Plot_Thin_Film_Comparison.png 

523 :align: center 

524 :alt: plot_thin_film_comparison 

525 """ 

526 

527 wavelengths = shape.wavelengths 

528 

529 polarisation = validate_method(polarisation, ("S", "P", "Both")) 

530 

531 _figure, axes = artist(**kwargs) 

532 

533 cycle = colour_cycle(**kwargs) 

534 

535 for i, configuration in enumerate(configurations): 

536 theta = configuration.get("theta", 0) 

537 label = configuration.get("label", f"Config {i + 1}") 

538 color = next(cycle)[:3] # Get RGB values from colour cycle 

539 

540 if configuration["type"] == "single": 

541 # Build unified n array: [incident, film, substrate] 

542 n = [1.0, configuration["n_film"], configuration.get("n_substrate", 1.5)] 

543 R, _ = thin_film_tmm(n, configuration["t"], wavelengths, theta) 

544 elif configuration["type"] == "multilayer": 

545 # Build unified n array: [incident, layers..., substrate] 

546 n = np.concatenate( 

547 [ 

548 [1.0], 

549 configuration["refractive_indices"], 

550 [configuration.get("n_substrate", 1.5)], 

551 ] 

552 ) 

553 R, _ = multilayer_tmm(n, configuration["t"], wavelengths, theta) 

554 else: 

555 continue 

556 

557 if polarisation in ["s", "both"]: 

558 axes.plot( 

559 wavelengths, 

560 R[:, 0, 0, 0], 

561 color=color, 

562 linestyle="-", 

563 label=f"{label} (s-pol)", 

564 linewidth=2, 

565 ) 

566 

567 if polarisation in ["p", "both"]: 

568 axes.plot( 

569 wavelengths, 

570 R[:, 0, 0, 1], 

571 color=color, 

572 linestyle="--", 

573 label=f"{label} (p-pol)", 

574 linewidth=2, 

575 ) 

576 

577 settings: Dict[str, Any] = { 

578 "axes": axes, 

579 "bounding_box": (np.min(wavelengths), np.max(wavelengths), 0, 1), 

580 "title": "Thin Film Comparison", 

581 "x_label": "Wavelength (nm)", 

582 "y_label": "Reflectance", 

583 "legend": True, 

584 "show": True, 

585 } 

586 settings.update(kwargs) 

587 

588 return render(**settings) 

589 

590 

591@override_style() 

592def plot_thin_film_spectrum( 

593 n: ArrayLike, 

594 t: ArrayLike, 

595 theta: ArrayLike = 0, 

596 shape: SpectralShape = SPECTRAL_SHAPE_DEFAULT, 

597 **kwargs: Any, 

598) -> Tuple[Figure, Axes]: 

599 """ 

600 Plot reflectance spectrum of thin film using *Transfer Matrix Method*. 

601 

602 Shows the characteristic oscillating reflectance spectra seen in soap films 

603 and other thin film interference phenomena. 

604 

605 Parameters 

606 ---------- 

607 n 

608 Complete refractive index stack :math:`n_j` for single-layer film. 

609 Shape: (3,) or (3, wavelengths_count). The array should contain 

610 [n_incident, n_film, n_substrate]. 

611 t 

612 Film thickness :math:`t` in nanometers. 

613 theta 

614 Incident angle :math:`\\theta` in degrees. Default is 0 (normal incidence). 

615 shape 

616 Spectral shape for wavelength sampling. 

617 

618 Other Parameters 

619 ---------------- 

620 kwargs 

621 {:func:`colour.plotting.artist`, 

622 :func:`colour.plotting.render`}, 

623 See the documentation of the previously listed definitions. 

624 

625 Returns 

626 ------- 

627 :class:`tuple` 

628 Current figure and axes. 

629 

630 Examples 

631 -------- 

632 >>> plot_thin_film_spectrum([1.0, 1.33, 1.0], 200) # doctest: +ELLIPSIS 

633 (<Figure size ... with 1 Axes>, <...Axes...>) 

634 

635 .. image:: ../_static/Plotting_Plot_Thin_Film_Spectrum.png 

636 :align: center 

637 :alt: plot_thin_film_spectrum 

638 """ 

639 

640 n = as_complex_array(n) 

641 

642 _figure, axes = artist(**kwargs) 

643 

644 wavelengths = shape.wavelengths 

645 

646 # Calculate reflectance using *Transfer Matrix Method* 

647 # R has shape (W, A, T, 2) for (wavelength, angle, thickness, polarisation) 

648 R, _ = thin_film_tmm(n, t, wavelengths, theta) 

649 # Average s and p polarisations for unpolarized light: R[:, 0, 0, :] -> (W,) 

650 reflectance = np.mean(R[:, 0, 0, :], axis=1) 

651 

652 axes.plot(wavelengths, reflectance, "b-", linewidth=2) 

653 

654 n_layer = n[1] if n.ndim == 1 else n[1, 0] 

655 title = ( 

656 f"Thin Film Interference (n={np.real(n_layer):.2f}, d={t:.0f}nm, θ={theta}°)" 

657 ) 

658 

659 settings: Dict[str, Any] = { 

660 "axes": axes, 

661 "bounding_box": (np.min(wavelengths), np.max(wavelengths), 0, 1), 

662 "title": title, 

663 "x_label": "Wavelength (nm)", 

664 "y_label": "Reflectance", 

665 "show": True, 

666 } 

667 settings.update(kwargs) 

668 

669 return render(**settings) 

670 

671 

672@override_style() 

673def plot_thin_film_iridescence( 

674 n: ArrayLike, 

675 t: ArrayLike | None = None, 

676 theta: ArrayLike = 0, 

677 shape: SpectralShape = SPECTRAL_SHAPE_DEFAULT, 

678 illuminant: SpectralDistribution | str = "D65", 

679 **kwargs: Any, 

680) -> Tuple[Figure, Axes]: 

681 """ 

682 Plot thin film iridescence colours. 

683 

684 Creates a colour strip showing how thin film interference produces 

685 iridescent colours, similar to soap films, oil slicks, or soap bubbles. 

686 

687 Parameters 

688 ---------- 

689 n 

690 Complete refractive index stack :math:`n_j` for single-layer film. 

691 Shape: (3,) or (3, wavelengths_count). The array should contain 

692 [n_incident, n_film, n_substrate]. Supports wavelength-dependent 

693 refractive index for dispersion. 

694 t 

695 Array of thicknesses :math:`t` in nanometers. If None, uses 0-1000 nm. 

696 theta 

697 Incident angle :math:`\\theta` in degrees. Default is 0 (normal incidence). 

698 shape 

699 Spectral shape for wavelength sampling. 

700 illuminant 

701 Illuminant used for color calculation. Can be either a string (e.g., "D65") 

702 or a :class:`colour.SpectralDistribution` class instance. 

703 

704 Other Parameters 

705 ---------------- 

706 kwargs 

707 {:func:`colour.plotting.artist`, 

708 :func:`colour.plotting.render`}, 

709 See the documentation of the previously listed definitions. 

710 

711 Returns 

712 ------- 

713 :class:`tuple` 

714 Current figure and axes. 

715 

716 Examples 

717 -------- 

718 >>> plot_thin_film_iridescence([1.0, 1.33, 1.0]) # doctest: +ELLIPSIS 

719 (<Figure size ... with 1 Axes>, <...Axes...>) 

720 

721 .. image:: ../_static/Plotting_Plot_Thin_Film_Iridescence.png 

722 :align: center 

723 :alt: plot_thin_film_iridescence 

724 """ 

725 

726 n = as_complex_array(n) 

727 t = as_float_array(optional(t, np.arange(0, 1000, 1))) 

728 

729 _figure, axes = artist(**kwargs) 

730 

731 wavelengths = shape.wavelengths 

732 

733 sd_illuminant = cast( 

734 "SpectralDistribution", 

735 first_item(filter_illuminants(illuminant).values()), 

736 ) 

737 sd_illuminant = sd_illuminant.copy().align(shape) 

738 

739 # R has shape (W, A, T, 2) for (wavelength, angle, thickness, polarisation) 

740 R, _ = thin_film_tmm(n, t, wavelengths, theta) 

741 # Extract single angle and average over polarisations: R[:, 0, :, :] -> (W, T, 2) 

742 # Average over polarisations (axis=-1): (W, T, 2) -> (W, T) 

743 msds = MultiSpectralDistributions(np.mean(R[:, 0, :, :], axis=-1), shape) 

744 XYZ = msds_to_XYZ(msds, illuminant=sd_illuminant, method="Integration") / 100 

745 RGB = XYZ_to_plotting_colourspace(XYZ) 

746 RGB = np.clip(normalise_maximum(RGB), 0, 1) 

747 

748 axes.bar( 

749 x=t, 

750 height=1, 

751 width=np.min(np.diff(t)) if len(t) > 1 else 1, 

752 color=RGB, 

753 align="edge", 

754 zorder=CONSTANTS_COLOUR_STYLE.zorder.background_polygon, 

755 ) 

756 

757 x_min, x_max = t[0], t[-1] 

758 

759 n_layer = n[1] if n.ndim == 1 else n[1, 0] 

760 title = f"Thin Film Iridescence (n={np.real(n_layer):.2f}, θ={theta}°)" 

761 

762 settings: Dict[str, Any] = { 

763 "axes": axes, 

764 "bounding_box": (x_min, x_max, 0, 1), 

765 "title": title, 

766 "x_label": "Thickness (nm)", 

767 "y_label": "", 

768 "show": True, 

769 } 

770 settings.update(kwargs) 

771 

772 return render(**settings) 

773 

774 

775@override_style() 

776def plot_thin_film_reflectance_map( 

777 n: ArrayLike, 

778 t: ArrayLike | None = None, 

779 theta: ArrayLike | None = None, 

780 shape: SpectralShape = SPECTRAL_SHAPE_DEFAULT, 

781 polarisation: Literal["Average", "S", "P"] | str = "Average", 

782 method: Literal["Angle", "Thickness"] | str = "Thickness", 

783 **kwargs: Any, 

784) -> Tuple[Figure, Axes]: 

785 """ 

786 Plot thin film reflectance as a 2D pseudocolor map. 

787 

788 Creates a 2D visualization showing reflectance as a function of wavelength 

789 (x-axis) and either film thickness or incident angle (y-axis). 

790 

791 Parameters 

792 ---------- 

793 n 

794 Complete refractive index stack :math:`n_j`. Shape: (media_count,) or 

795 (media_count, wavelengths_count). The array should contain: 

796 

797 - **Single layer**: [n_incident, n_film, n_substrate] (length 3) 

798 - **Multi-layer**: [n_incident, n_layer_1, ..., n_layer_n, n_substrate] 

799 (length > 3) 

800 

801 Supports wavelength-dependent refractive index for dispersion. 

802 t 

803 Thickness :math:`t` in nanometers. Behavior depends on the method: 

804 

805 - **Thickness mode (single layer)**: Array of thicknesses or None 

806 (default: ``np.linspace(0, 1000, 250)``). Sweeps film thickness 

807 across the range. 

808 - **Thickness mode (multi-layer)**: Array of thicknesses or None. 

809 Sweeps all layers simultaneously with the same thickness value. 

810 For example, ``np.linspace(50, 500, 250)`` sweeps all layers from 

811 50nm to 500nm together. 

812 - **Angle mode (single layer)**: Scalar thickness (e.g., ``300``). 

813 Fixed thickness while varying angle. 

814 - **Angle mode (multi-layer)**: Array of layer thicknesses 

815 (e.g., ``[100, 50]`` for 2 layers). All layers kept at fixed 

816 thickness while varying angle. 

817 theta 

818 Incident angle :math:`\\theta` in degrees. Behavior depends on the method: 

819 

820 - **Thickness mode**: Scalar angle or None (default: 0°). 

821 Fixed angle while varying thickness. 

822 - **Angle mode**: Array of angles (e.g., ``np.linspace(0, 90, 250)``). 

823 Sweeps angle across the range. 

824 shape 

825 Spectral shape for wavelength sampling. 

826 polarisation 

827 Polarisation to plot: 'S', 'P', or 'Average' (case-insensitive). 

828 Default is 'Average' (mean of s and p polarisations for unpolarized light). 

829 method 

830 Plotting method, one of (case-insensitive): 

831 

832 - 'Thickness': Plot reflectance vs wavelength and thickness (y-axis) 

833 - 'Angle': Plot reflectance vs wavelength and angle (y-axis) 

834 

835 Other Parameters 

836 ---------------- 

837 kwargs 

838 {:func:`colour.plotting.artist`, 

839 :func:`colour.plotting.render`}, 

840 See the documentation of the previously listed definitions. 

841 

842 Returns 

843 ------- 

844 :class:`tuple` 

845 Current figure and axes. 

846 

847 Examples 

848 -------- 

849 >>> plot_thin_film_reflectance_map( 

850 ... [1.0, 1.33, 1.0], method="Thickness" 

851 ... ) # doctest: +ELLIPSIS 

852 (<Figure size ... with 2 Axes>, <...Axes...>) 

853 

854 .. image:: ../_static/Plotting_Plot_Thin_Film_Reflectance_Map.png 

855 :align: center 

856 :alt: plot_thin_film_reflectance_map 

857 """ 

858 

859 n = np.asarray(n) 

860 

861 _figure, axes = artist(**kwargs) 

862 

863 wavelengths = shape.wavelengths 

864 

865 method = validate_method(method, ("Angle", "Thickness")) 

866 polarisation = validate_method(polarisation, ("Average", "S", "P")) 

867 

868 if method == "angle": 

869 if t is None: 

870 error = "In angle method, thickness 't' must be specified." 

871 

872 raise ValueError(error) 

873 if theta is None: 

874 error = "In angle method, 'theta' must be specified as an array of angles." 

875 

876 raise ValueError(error) 

877 

878 theta = np.atleast_1d(np.asarray(theta)) 

879 

880 if len(theta) == 1: 

881 error = ( 

882 "In angle method, 'theta' must be an array with multiple angles. " 

883 "For a single angle, use method='thickness'." 

884 ) 

885 

886 raise ValueError(error) 

887 

888 t_array = np.atleast_1d(np.asarray(t)) 

889 R, _ = multilayer_tmm(n, t_array, wavelengths, theta) 

890 

891 if len(t_array) == 1: 

892 title_thickness_info = f"d={t_array[0]:.0f} nm" 

893 else: 

894 layer_thicknesses = ", ".join([f"{d:.0f}" for d in t_array]) 

895 title_thickness_info = f"d=[{layer_thicknesses}] nm" 

896 

897 if polarisation == "average": 

898 R_data = np.transpose(np.mean(R[:, :, 0, :], axis=-1)) 

899 pol_label = "Unpolarized" 

900 elif polarisation == "s": 

901 R_data = np.transpose(R[:, :, 0, 0]) 

902 pol_label = "s-pol" 

903 elif polarisation == "p": 

904 R_data = np.transpose(R[:, :, 0, 1]) 

905 pol_label = "p-pol" 

906 

907 W, Y = np.meshgrid(wavelengths, theta) 

908 y_data = theta 

909 y_label = "Angle (deg)" 

910 title_suffix = title_thickness_info 

911 

912 elif method == "thickness": 

913 t = as_float_array(optional(t, np.arange(0, 1000, 1))) 

914 

915 t_array = np.atleast_1d(np.asarray(t)) 

916 theta_scalar = as_float_scalar(theta if theta is not None else 0) 

917 

918 # Determine number of layers (excluding incident and substrate) 

919 n_media = len(n) if n.ndim == 1 else n.shape[0] 

920 n_layers = n_media - 2 

921 

922 # Create 2D array: (thickness_count, layers_count) where all layers 

923 # are swept simultaneously with the same thickness value 

924 t_layers_2d = np.tile(t_array[:, None], (1, n_layers)) # (T, L) 

925 

926 # Calculate reflectance for all thicknesses at once 

927 # R has shape (W, 1, T, 2) for (wavelength, angle, thickness, polarisation) 

928 R, _ = multilayer_tmm(n, t_layers_2d, wavelengths, theta_scalar) 

929 

930 if polarisation == "average": 

931 R_data = np.transpose(np.mean(R[:, 0, :, :], axis=-1)) 

932 pol_label = "Unpolarized" 

933 elif polarisation == "s": 

934 R_data = np.transpose(R[:, 0, :, 0]) 

935 pol_label = "s-pol" 

936 elif polarisation == "p": 

937 R_data = np.transpose(R[:, 0, :, 1]) 

938 pol_label = "p-pol" 

939 

940 W, Y = np.meshgrid(wavelengths, t_array) 

941 y_data = t_array 

942 y_label = "Thickness (nm)" 

943 title_suffix = f"θ={theta_scalar:.0f}°" 

944 

945 n_media = len(n) if n.ndim == 1 else n.shape[0] 

946 if n_media == 3: 

947 n_layer = n[1] if n.ndim == 1 else n[1, 0] 

948 title_prefix = f"n={np.real(n_layer):.2f}" 

949 else: 

950 n_layers = n_media - 2 # Exclude incident and substrate 

951 title_prefix = f"{n_layers} layers" 

952 

953 pcolormesh = axes.pcolormesh( 

954 W, 

955 Y, 

956 R_data, 

957 shading="auto", 

958 cmap=CONSTANTS_COLOUR_STYLE.colour.cmap, 

959 vmin=0, 

960 vmax=float(np.max(R_data)), 

961 ) 

962 

963 plt.colorbar(pcolormesh, ax=axes, label="Reflectance") 

964 

965 title = f"Thin Film Reflectance ({title_prefix}, {title_suffix}, {pol_label})" 

966 

967 settings: Dict[str, Any] = { 

968 "axes": axes, 

969 "bounding_box": ( 

970 np.min(wavelengths), 

971 np.max(wavelengths), 

972 np.min(y_data), 

973 np.max(y_data), 

974 ), 

975 "title": title, 

976 "x_label": "Wavelength (nm)", 

977 "y_label": y_label, 

978 "show": True, 

979 } 

980 settings.update(kwargs) 

981 

982 return render(**settings) 

983 

984 

985@override_style() 

986def plot_multi_layer_stack( 

987 configurations: Sequence[Dict[str, Any]], 

988 theta: ArrayLike | None = None, 

989 wavelength: ArrayLike = 555, 

990 **kwargs: Any, 

991) -> Tuple[Figure, Axes]: 

992 """ 

993 Plot a multilayer stack as a stacked horizontal bar chart with optional ray paths. 

994 

995 Creates a visualization showing the layer structure of a multilayer thin film 

996 or any other multilayer system. Each layer is represented as a horizontal bar 

997 with height proportional to its thickness, stacked vertically. If an incident 

998 angle is provided, the function also draws ray paths showing refraction through 

999 each layer using Snell's law. 

1000 

1001 Parameters 

1002 ---------- 

1003 configurations 

1004 Sequence of dictionaries, each containing layer configuration: 

1005 {'t': float, 'n': float, 'color': str, 'label': str} 

1006 

1007 - 't': Layer thickness in nanometers or any other unit (required) 

1008 - 'n': Refractive index (required) 

1009 - 'color': Layer color (optional, automatically assigned from the 

1010 default colour cycle if not provided) 

1011 - 'label': Layer label (optional, defaults to "Layer N (n=value)") 

1012 theta 

1013 Incident angle :math:`\\theta` in degrees. If provided, ray paths will be 

1014 drawn showing refraction through each layer using Snell's law. Default is 

1015 None (no ray paths). 

1016 wavelength 

1017 Wavelength in nanometers used for transfer matrix calculations when theta 

1018 is provided. Default is 555 nm. 

1019 

1020 Other Parameters 

1021 ---------------- 

1022 kwargs 

1023 {:func:`colour.plotting.artist`, 

1024 :func:`colour.plotting.render`}, 

1025 See the documentation of the previously listed definitions. 

1026 

1027 Returns 

1028 ------- 

1029 :class:`tuple` 

1030 Current figure and axes. 

1031 

1032 Examples 

1033 -------- 

1034 >>> configurations = [ 

1035 ... {"t": 100, "n": 1.46}, 

1036 ... {"t": 200, "n": 2.4}, 

1037 ... {"t": 80, "n": 1.46}, 

1038 ... {"t": 150, "n": 2.4}, 

1039 ... ] 

1040 >>> plot_multi_layer_stack(configurations, theta=45) # doctest: +ELLIPSIS 

1041 (<Figure size ... with 1 Axes>, <...Axes...>) 

1042 

1043 .. image:: ../_static/Plotting_Plot_Multi_Layer_Stack.png 

1044 :align: center 

1045 :alt: plot_multi_layer_stack 

1046 """ 

1047 

1048 if not configurations: 

1049 error = "At least one layer configuration is required" 

1050 raise ValueError(error) 

1051 

1052 _figure, axes = artist(**kwargs) 

1053 

1054 cycle = colour_cycle(**kwargs) 

1055 

1056 t_a = [configuration["t"] for configuration in configurations] 

1057 t_total = np.sum(t_a) 

1058 

1059 # Add space for ray entry and exit - 20% of total thickness if theta provided 

1060 ray_space = ( 

1061 t_total * CONSTANTS_COLOUR_STYLE.geometry.x_short / 2.5 

1062 if theta is not None 

1063 else 0 

1064 ) 

1065 height = t_total + 2 * ray_space 

1066 width = height 

1067 

1068 if theta is not None: 

1069 # Calculate refraction angles using TMM and refractive index array: 

1070 # [n_incident=1.0, n_layers..., n_substrate=1.0] 

1071 result = matrix_transfer_tmm( 

1072 n=[1.0] + [config["n"] for config in configurations] + [1.0], 

1073 t=t_a, 

1074 theta=theta, 

1075 wavelength=wavelength, 

1076 ) 

1077 

1078 # Get angles for each interface 

1079 # result.theta has shape (angles_count, media_count) 

1080 theta_interface = result.theta[0, :] # Take first (and only) angle 

1081 theta_entry = theta_interface[0] 

1082 x_center = width / 2 

1083 

1084 # Build transmitted ray path coordinates 

1085 # Incident origin 

1086 transmitted_x = [x_center - (ray_space * np.tan(np.radians(theta_entry)))] 

1087 transmitted_y = [height] 

1088 transmitted_x.append(x_center) # Entry point 

1089 transmitted_y.append(height - ray_space) 

1090 

1091 # Traverse layers from top to bottom and build coordinates 

1092 x_position = x_center 

1093 y_position = height - ray_space 

1094 

1095 for i in range(len(t_a) - 1, -1, -1): 

1096 # Get angle in this layer 

1097 # angles_at_interfaces: [incident, layer_0, ..., layer_n-1, substrate] 

1098 angle = theta_interface[i + 1] # +1 to skip incident angle 

1099 thickness = t_a[i] 

1100 

1101 # Travel through this layer to reach bottom interface 

1102 y_position -= thickness 

1103 x_position += thickness * np.tan(np.radians(angle)) 

1104 

1105 transmitted_x.append(x_position) 

1106 transmitted_y.append(y_position) 

1107 

1108 # Exit ray from bottom of stack 

1109 x_position += ray_space * np.tan(np.radians(theta_interface[-1])) 

1110 transmitted_x.append(x_position) 

1111 transmitted_y.append(0) 

1112 

1113 # Start from top and work downward 

1114 # Layers are indexed 0 to n-1 from bottom to top physically, 

1115 # but we draw them from top to bottom on screen 

1116 t_cumulative = height - ray_space # Start at top of stack 

1117 

1118 # Iterate through configurations in REVERSE order (top layer first) 

1119 for i in range(len(configurations) - 1, -1, -1): 

1120 configuration = configurations[i] 

1121 t = configuration["t"] 

1122 n = configuration["n"] 

1123 

1124 # Build label with refractive index 

1125 # Layer numbering: bottom layer is 1, top layer is n 

1126 label = configuration.get("label", f"Layer {i + 1} (n={n:.3f})") 

1127 

1128 axes.barh( 

1129 t_cumulative - t / 2, # Center of the bar (going downward) 

1130 width, 

1131 height=t, 

1132 color=configuration.get("color", next(cycle)[:3]), 

1133 edgecolor="black", 

1134 linewidth=CONSTANTS_COLOUR_STYLE.geometry.x_short, 

1135 label=label, 

1136 zorder=CONSTANTS_COLOUR_STYLE.zorder.background_polygon, 

1137 ) 

1138 t_cumulative -= t # Move down for next layer 

1139 

1140 # Draw ray paths if theta was provided 

1141 if theta is not None: 

1142 # Plot incident ray 

1143 plot_ray( 

1144 axes, 

1145 transmitted_x[:2], 

1146 transmitted_y[:2], 

1147 style="solid", 

1148 label=f"Incident (θ={theta}°)", 

1149 show_arrow=True, 

1150 show_dots=False, 

1151 ) 

1152 

1153 # Plot transmitted rays through stack 

1154 # (black solid line with arrows and dots at interfaces) 

1155 plot_ray( 

1156 axes, 

1157 transmitted_x[1:], 

1158 transmitted_y[1:], 

1159 style="solid", 

1160 label="Transmitted", 

1161 show_arrow=True, 

1162 show_dots=True, 

1163 ) 

1164 

1165 # Plot reflected rays at each interface (black dashed) 

1166 # Build list of all reflections: [(start_x, start_y, end_x, end_y), ...] 

1167 reflections = [] 

1168 

1169 # Reflection at entry point (index 1 in transmitted arrays) 

1170 x_incident_origin = transmitted_x[0] 

1171 reflections.append( 

1172 ( 

1173 x_center, 

1174 height - ray_space, 

1175 x_center + (x_center - x_incident_origin), # Mirror incident ray 

1176 height, 

1177 ) 

1178 ) 

1179 

1180 # Internal reflections (indices 2 to -2 in transmitted arrays) 

1181 for idx in range(2, len(transmitted_x) - 1): 

1182 x_refl_start = transmitted_x[idx] 

1183 y_refl_start = transmitted_y[idx] 

1184 y_refl_end = transmitted_y[idx - 1] # Next interface above 

1185 

1186 # Get angle in layer above this interface 

1187 layer_idx = len(t_a) - (idx - 1) 

1188 if layer_idx >= len(t_a): 

1189 continue 

1190 

1191 angle_in_layer = theta_interface[layer_idx + 1] 

1192 

1193 # Calculate reflected ray endpoint 

1194 distance = y_refl_start - y_refl_end 

1195 x_refl_end = x_refl_start - distance * np.tan(np.radians(angle_in_layer)) 

1196 

1197 reflections.append((x_refl_start, y_refl_start, x_refl_end, y_refl_end)) 

1198 

1199 # Draw all reflected rays using plot_ray 

1200 for i, (x1, y1, x2, y2) in enumerate(reflections): 

1201 plot_ray( 

1202 axes, 

1203 [x1, x2], 

1204 [y1, y2], 

1205 style="dashed", 

1206 label="Reflected" if i == 0 else None, 

1207 show_arrow=True, 

1208 show_dots=False, 

1209 ) 

1210 

1211 axes.legend( 

1212 loc="center left", 

1213 bbox_to_anchor=( 

1214 CONSTANTS_COLOUR_STYLE.geometry.short * 1.05, 

1215 CONSTANTS_COLOUR_STYLE.geometry.x_short, 

1216 ), 

1217 frameon=True, 

1218 fontsize=CONSTANTS_COLOUR_STYLE.font.size 

1219 * CONSTANTS_COLOUR_STYLE.font.scaling.small, 

1220 ) 

1221 

1222 settings: Dict[str, Any] = { 

1223 "axes": axes, 

1224 "aspect": "equal", 

1225 "bounding_box": (0, height, 0, height), 

1226 "title": "Multi-layer Stack", 

1227 "x_label": "", 

1228 "y_label": "Thickness [nm]", 

1229 "x_ticker": theta is not None, 

1230 } 

1231 settings.update(kwargs) 

1232 

1233 return render(**settings)