Coverage for plotting/phenomena.py: 82%

1"""

2 Optical Phenomenon Plotting

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

4

5 Define 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

11 from __future__ import annotations

12

13 import typing

14

15 import matplotlib.pyplot as plt

16 import numpy as np

17

18 if typing.TYPE_CHECKING:

19 from matplotlib.figure import Figure

20 from matplotlib.axes import Axes

21

22 from colour.algebra import normalise_maximum

23 from colour.colorimetry import (

24 SPECTRAL_SHAPE_DEFAULT,

25 MultiSpectralDistributions,

26 SpectralDistribution,

27 SpectralShape,

28 msds_to_XYZ,

29 sd_to_XYZ,

30)

31

32 if typing.TYPE_CHECKING:

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

34

35 from colour.hints import cast

36 from colour.phenomena import sd_rayleigh_scattering

37 from colour.phenomena.interference import (

38 multilayer_tmm,

39 thin_film_tmm,

40)

41 from 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)

48 from colour.phenomena.tmm import matrix_transfer_tmm

49 from 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)

64 from 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()

94 def 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()

169 def 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()

269 def 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()

340 def 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()

466 def 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()

592 def 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()

673 def 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()

776 def 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()

986 def 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)