Coverage for plotting/phenomena.py: 82%
282 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-16 22:49 +1300
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-16 22:49 +1300
1"""
2Optical Phenomenon Plotting
3===========================
5Define the optical phenomena plotting objects.
7- :func:`colour.plotting.plot_single_sd_rayleigh_scattering`
8- :func:`colour.plotting.plot_the_blue_sky`
9"""
11from __future__ import annotations
13import typing
15import matplotlib.pyplot as plt
16import numpy as np
18if typing.TYPE_CHECKING:
19 from matplotlib.figure import Figure
20 from matplotlib.axes import Axes
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)
32if typing.TYPE_CHECKING:
33 from colour.hints import Any, ArrayLike, Dict, Literal, Sequence, Tuple
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)
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"
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]
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.
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.
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.
134 Returns
135 -------
136 :class:`tuple`
137 Current figure and axes.
139 Examples
140 --------
141 >>> plot_single_sd_rayleigh_scattering() # doctest: +ELLIPSIS
142 (<Figure size ... with 1 Axes>, <...Axes...>)
144 .. image:: ../_static/Plotting_Plot_Single_SD_Rayleigh_Scattering.png
145 :align: center
146 :alt: plot_single_sd_rayleigh_scattering
147 """
149 title = "Rayleigh Scattering"
151 cmfs = cast("MultiSpectralDistributions", first_item(filter_cmfs(cmfs).values()))
153 settings: Dict[str, Any] = {"title": title, "y_label": "Optical Depth"}
154 settings.update(kwargs)
156 sd = sd_rayleigh_scattering(
157 cmfs.shape,
158 CO2_concentration,
159 temperature,
160 pressure,
161 latitude,
162 altitude,
163 )
165 return plot_single_sd(sd, **settings)
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.
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.
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.
195 Returns
196 -------
197 :class:`tuple`
198 Current figure and axes.
200 Examples
201 --------
202 >>> plot_the_blue_sky() # doctest: +ELLIPSIS
203 (<Figure size ... with 2 Axes>, <...Axes...>)
205 .. image:: ../_static/Plotting_Plot_The_Blue_Sky.png
206 :align: center
207 :alt: plot_the_blue_sky
208 """
210 figure = plt.figure()
212 figure.subplots_adjust(hspace=CONSTANTS_COLOUR_STYLE.geometry.short / 2)
214 cmfs = cast("MultiSpectralDistributions", first_item(filter_cmfs(cmfs).values()))
216 ASTMG173_sd = SD_ASTMG173_ETR.copy()
217 rayleigh_sd = sd_rayleigh_scattering()
218 ASTMG173_sd.align(rayleigh_sd.shape)
220 sd = rayleigh_sd * ASTMG173_sd
222 axes = figure.add_subplot(211)
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
232 plot_single_sd(sd, cmfs, **settings)
234 axes = figure.add_subplot(212)
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 )
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
256 blue_sky_color = XYZ_to_plotting_colourspace(sd_to_XYZ(sd))
258 figure, axes = plot_single_colour_swatch(
259 ColourSwatch(normalise_maximum(blue_sky_color)), **settings
260 )
262 settings = {"axes": axes, "show": True}
263 settings.update(kwargs)
265 return render(**settings)
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.
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'.
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.
307 Returns
308 -------
309 :class:`tuple`
310 Current figure and axes.
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...>)
317 .. image:: ../_static/Plotting_Plot_Single_Layer_Thin_Film.png
318 :align: center
319 :alt: plot_single_layer_thin_film
320 """
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 )
331 settings: Dict[str, Any] = {"title": title}
332 settings.update(kwargs)
334 return plot_multi_layer_thin_film(
335 n, np.atleast_1d(t_array), theta, shape, polarisation, method, **settings
336 )
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.
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'.
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.
379 Returns
380 -------
381 :class:`tuple`
382 Current figure and axes.
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...>)
391 .. image:: ../_static/Plotting_Plot_Multi_Layer_Thin_Film.png
392 :align: center
393 :alt: plot_multi_layer_thin_film
394 """
396 n = as_complex_array(n)
397 t = as_float_array(t)
399 _figure, axes = artist(**kwargs)
401 wavelengths = shape.wavelengths
403 polarisation = validate_method(polarisation, ("S", "P", "Both"))
404 method = validate_method(method, ("Reflectance", "Transmittance", "Both"))
406 R, T = multilayer_tmm(n, t, wavelengths, theta)
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)
412 if polarisation in ["p", "both"]:
413 axes.plot(wavelengths, R[:, 0, 0, 1], "r--", label="R (p-pol)", linewidth=2)
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 )
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 )
444 if method == "reflectance":
445 y_label = "Reflectance"
446 elif method == "transmittance":
447 y_label = "Transmittance"
448 else: # both
449 y_label = "Reflectance / Transmittance"
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)
462 return render(**settings)
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.
475 Parameters
476 ----------
477 configurations
478 List of dictionaries, each containing parameters for a thin film configuration.
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).
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.
496 Returns
497 -------
498 :class:`tuple`
499 Current figure and axes.
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...>)
522 .. image:: ../_static/Plotting_Plot_Thin_Film_Comparison.png
523 :align: center
524 :alt: plot_thin_film_comparison
525 """
527 wavelengths = shape.wavelengths
529 polarisation = validate_method(polarisation, ("S", "P", "Both"))
531 _figure, axes = artist(**kwargs)
533 cycle = colour_cycle(**kwargs)
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
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
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 )
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 )
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)
588 return render(**settings)
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*.
602 Shows the characteristic oscillating reflectance spectra seen in soap films
603 and other thin film interference phenomena.
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.
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.
625 Returns
626 -------
627 :class:`tuple`
628 Current figure and axes.
630 Examples
631 --------
632 >>> plot_thin_film_spectrum([1.0, 1.33, 1.0], 200) # doctest: +ELLIPSIS
633 (<Figure size ... with 1 Axes>, <...Axes...>)
635 .. image:: ../_static/Plotting_Plot_Thin_Film_Spectrum.png
636 :align: center
637 :alt: plot_thin_film_spectrum
638 """
640 n = as_complex_array(n)
642 _figure, axes = artist(**kwargs)
644 wavelengths = shape.wavelengths
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)
652 axes.plot(wavelengths, reflectance, "b-", linewidth=2)
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 )
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)
669 return render(**settings)
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.
684 Creates a colour strip showing how thin film interference produces
685 iridescent colours, similar to soap films, oil slicks, or soap bubbles.
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.
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.
711 Returns
712 -------
713 :class:`tuple`
714 Current figure and axes.
716 Examples
717 --------
718 >>> plot_thin_film_iridescence([1.0, 1.33, 1.0]) # doctest: +ELLIPSIS
719 (<Figure size ... with 1 Axes>, <...Axes...>)
721 .. image:: ../_static/Plotting_Plot_Thin_Film_Iridescence.png
722 :align: center
723 :alt: plot_thin_film_iridescence
724 """
726 n = as_complex_array(n)
727 t = as_float_array(optional(t, np.arange(0, 1000, 1)))
729 _figure, axes = artist(**kwargs)
731 wavelengths = shape.wavelengths
733 sd_illuminant = cast(
734 "SpectralDistribution",
735 first_item(filter_illuminants(illuminant).values()),
736 )
737 sd_illuminant = sd_illuminant.copy().align(shape)
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)
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 )
757 x_min, x_max = t[0], t[-1]
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}°)"
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)
772 return render(**settings)
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.
788 Creates a 2D visualization showing reflectance as a function of wavelength
789 (x-axis) and either film thickness or incident angle (y-axis).
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:
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)
801 Supports wavelength-dependent refractive index for dispersion.
802 t
803 Thickness :math:`t` in nanometers. Behavior depends on the method:
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:
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):
832 - 'Thickness': Plot reflectance vs wavelength and thickness (y-axis)
833 - 'Angle': Plot reflectance vs wavelength and angle (y-axis)
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.
842 Returns
843 -------
844 :class:`tuple`
845 Current figure and axes.
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...>)
854 .. image:: ../_static/Plotting_Plot_Thin_Film_Reflectance_Map.png
855 :align: center
856 :alt: plot_thin_film_reflectance_map
857 """
859 n = np.asarray(n)
861 _figure, axes = artist(**kwargs)
863 wavelengths = shape.wavelengths
865 method = validate_method(method, ("Angle", "Thickness"))
866 polarisation = validate_method(polarisation, ("Average", "S", "P"))
868 if method == "angle":
869 if t is None:
870 error = "In angle method, thickness 't' must be specified."
872 raise ValueError(error)
873 if theta is None:
874 error = "In angle method, 'theta' must be specified as an array of angles."
876 raise ValueError(error)
878 theta = np.atleast_1d(np.asarray(theta))
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 )
886 raise ValueError(error)
888 t_array = np.atleast_1d(np.asarray(t))
889 R, _ = multilayer_tmm(n, t_array, wavelengths, theta)
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"
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"
907 W, Y = np.meshgrid(wavelengths, theta)
908 y_data = theta
909 y_label = "Angle (deg)"
910 title_suffix = title_thickness_info
912 elif method == "thickness":
913 t = as_float_array(optional(t, np.arange(0, 1000, 1)))
915 t_array = np.atleast_1d(np.asarray(t))
916 theta_scalar = as_float_scalar(theta if theta is not None else 0)
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
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)
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)
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"
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}°"
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"
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 )
963 plt.colorbar(pcolormesh, ax=axes, label="Reflectance")
965 title = f"Thin Film Reflectance ({title_prefix}, {title_suffix}, {pol_label})"
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)
982 return render(**settings)
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.
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.
1001 Parameters
1002 ----------
1003 configurations
1004 Sequence of dictionaries, each containing layer configuration:
1005 {'t': float, 'n': float, 'color': str, 'label': str}
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.
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.
1027 Returns
1028 -------
1029 :class:`tuple`
1030 Current figure and axes.
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...>)
1043 .. image:: ../_static/Plotting_Plot_Multi_Layer_Stack.png
1044 :align: center
1045 :alt: plot_multi_layer_stack
1046 """
1048 if not configurations:
1049 error = "At least one layer configuration is required"
1050 raise ValueError(error)
1052 _figure, axes = artist(**kwargs)
1054 cycle = colour_cycle(**kwargs)
1056 t_a = [configuration["t"] for configuration in configurations]
1057 t_total = np.sum(t_a)
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
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 )
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
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)
1091 # Traverse layers from top to bottom and build coordinates
1092 x_position = x_center
1093 y_position = height - ray_space
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]
1101 # Travel through this layer to reach bottom interface
1102 y_position -= thickness
1103 x_position += thickness * np.tan(np.radians(angle))
1105 transmitted_x.append(x_position)
1106 transmitted_y.append(y_position)
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)
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
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"]
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})")
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
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 )
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 )
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 = []
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 )
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
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
1191 angle_in_layer = theta_interface[layer_idx + 1]
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))
1197 reflections.append((x_refl_start, y_refl_start, x_refl_end, y_refl_end))
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 )
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 )
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)
1233 return render(**settings)