pystratify 0.10.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ Copyright (C) 2012-2024 Steven Byrnes
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
pystratify/__init__.py ADDED
@@ -0,0 +1,125 @@
1
+ """PyStratify: light scattering by multilayered (stratified) spheres.
2
+
3
+ Quick start::
4
+
5
+ import numpy as np
6
+ import pystratify as ps
7
+
8
+ wavelength = np.linspace(500, 900, 401) # nm
9
+ radii = [50, 55] # nm: SiO2 core, Au shell
10
+ n_au = ... # (401,) complex, e.g. from refractiveindex.info
11
+ n = np.stack([np.full(401, 1.45), n_au, np.full(401, 1.33)], axis=1) # host last
12
+ sol = ps.solve(radii, n, wavelength)
13
+ ps.cross_sections(sol).q_ext # (401,)
14
+ """
15
+
16
+ from .casimir import CasimirPolder, casimir_polder
17
+ from .chiral import ChiralSolution, solve_chiral
18
+ from .convergence import truncation_order
19
+ from .decay import DecayRates, decay_rates, locate_shell
20
+ from .drude import DRUDE, DrudeModel, free_path_correction, surface_damping_wavelength
21
+ from .emission import EmissionPattern, dipole_far_field, source_covariance
22
+ from .ensemble import ShellAverage, SpectralDensity, shell_average, spectral_density
23
+ from .energy import (
24
+ EnergyDensity,
25
+ ShellEnergy,
26
+ electric_prefactor,
27
+ energy_density,
28
+ energy_prefactors,
29
+ shell_energy,
30
+ )
31
+ from .farfield import (
32
+ CrossSections,
33
+ HelicityCrossSections,
34
+ ScatteringPattern,
35
+ amplitude_matrix,
36
+ angular_functions,
37
+ cross_sections,
38
+ helicity_cross_sections,
39
+ mueller_matrix,
40
+ scattering_amplitudes,
41
+ scattering_pattern,
42
+ )
43
+ from .nearfield import NearField, near_field
44
+ from .pairs import GreenDyadic, green_dyadic
45
+ from .normalized import NormalizedRates, NormalizedTerms, normalized_decay_rates, normalized_terms
46
+ from .rates import EmissionRates, emission_rates
47
+ from .sheets import Feibelman, Sheet, graphene_conductivity
48
+ from .riccati import log_riccati
49
+ from .solver import TE, TM, Solution, solve
50
+ from .problem import Problem, PlaneWave, PointDipole, FocusedBeam
51
+ from .observables import solve_problem, solve_focused
52
+ from .focused import (
53
+ FocusedField,
54
+ FocusedResult,
55
+ focal_field,
56
+ focused_cylinders,
57
+ focused_field_cylinders,
58
+ focused_field_films,
59
+ focused_field_spheres,
60
+ focused_films,
61
+ focused_spheres,
62
+ )
63
+ from .cylindrical import solve_cylinder, CylinderSolution, cross_widths, cylinder_pattern
64
+
65
+ __version__ = "0.10.2"
66
+
67
+ __all__ = [
68
+ "Problem", "PlaneWave", "PointDipole", "FocusedBeam", "solve_problem", "solve_focused",
69
+ "focused_films", "focused_spheres", "focused_cylinders", "FocusedResult",
70
+ "focused_field_films", "focused_field_spheres", "focused_field_cylinders", "FocusedField", "focal_field",
71
+ "solve_cylinder", "CylinderSolution", "cross_widths", "cylinder_pattern",
72
+ "solve",
73
+ "Solution",
74
+ "solve_chiral",
75
+ "ChiralSolution",
76
+ "TM",
77
+ "TE",
78
+ "truncation_order",
79
+ "cross_sections",
80
+ "CrossSections",
81
+ "helicity_cross_sections",
82
+ "HelicityCrossSections",
83
+ "scattering_amplitudes",
84
+ "amplitude_matrix",
85
+ "mueller_matrix",
86
+ "scattering_pattern",
87
+ "ScatteringPattern",
88
+ "dipole_far_field",
89
+ "EmissionPattern",
90
+ "source_covariance",
91
+ "angular_functions",
92
+ "near_field",
93
+ "NearField",
94
+ "energy_density",
95
+ "EnergyDensity",
96
+ "shell_energy",
97
+ "ShellEnergy",
98
+ "electric_prefactor",
99
+ "energy_prefactors",
100
+ "decay_rates",
101
+ "DecayRates",
102
+ "emission_rates",
103
+ "EmissionRates",
104
+ "locate_shell",
105
+ "Feibelman",
106
+ "Sheet",
107
+ "graphene_conductivity",
108
+ "DRUDE",
109
+ "DrudeModel",
110
+ "free_path_correction",
111
+ "surface_damping_wavelength",
112
+ "log_riccati",
113
+ "normalized_decay_rates",
114
+ "casimir_polder",
115
+ "CasimirPolder",
116
+ "green_dyadic",
117
+ "GreenDyadic",
118
+ "shell_average",
119
+ "ShellAverage",
120
+ "spectral_density",
121
+ "SpectralDensity",
122
+ "normalized_terms",
123
+ "NormalizedTerms",
124
+ "NormalizedRates",
125
+ ]
pystratify/casimir.py ADDED
@@ -0,0 +1,97 @@
1
+ """Casimir-Polder potential of a ground-state atom in or near a multilayered sphere.
2
+
3
+ For an isotropic electric polarizability alpha(i xi) (Buhmann & Welsch, Prog. Quantum Electron. 31,
4
+ 51 (2007), Eq. (110)),
5
+
6
+ U(r) = (hbar mu_0 / 2 pi) int_0^inf dxi xi^2 alpha(i xi) Tr G_s(r, r, i xi),
7
+
8
+ with the scattered Green's dyadic at imaginary frequency. The trace is the dipole series of the
9
+ normalized formulation at k = i xi n(i xi)/c, Tr G_s = (i k / 6 pi)(g_perp + 2 g_par) (shell
10
+ normalization, as for the decay rates and the shift), and with alpha = 4 pi eps_0 alpha_V in volume
11
+ units
12
+
13
+ U(r) = -(hbar / 3 pi c^3) int_0^inf dxi xi^3 n_d(i xi) alpha_V(i xi) [g_perp + 2 g_par](i xi).
14
+
15
+ At imaginary frequency every auxiliary function is real up to a fixed phase (P real, A and B
16
+ imaginary, jbar real), the Hankel-type solution decays like exp(-kappa r), and the normalized
17
+ series converge geometrically at the emitter as for real frequencies; nothing can overflow.
18
+ The integral is done by Gauss-Legendre quadrature in ln xi.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass
24
+
25
+ import numpy as np
26
+
27
+ from .energy import gauss_legendre
28
+ from .normalized import L_CAP, _complex_converged, _dipole_series, _Sweep
29
+ from .convergence import orders_needed
30
+
31
+ __all__ = ["CasimirPolder", "casimir_polder"]
32
+
33
+ HBAR = 1.054571817e-34 # J s
34
+ C_NM = 2.99792458e17 # speed of light, nm/s
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class CasimirPolder:
39
+ """Casimir-Polder potential ``energy`` (J) at the radii ``r``; ``xi`` and ``integrand`` (P, X) are the
40
+ quadrature nodes (rad/s) and xi^3 n_d alpha_V [g_perp + 2 g_par], ``orders`` the most multipoles used."""
41
+
42
+ r: np.ndarray
43
+ energy: np.ndarray
44
+ xi: np.ndarray
45
+ integrand: np.ndarray
46
+ orders: int
47
+ converged: np.ndarray
48
+
49
+ @property
50
+ def frequency(self) -> np.ndarray:
51
+ """U / h in Hz."""
52
+ return self.energy / (2 * np.pi * HBAR)
53
+
54
+
55
+ def casimir_polder(radii, eps, r, polarizability, mu=None, xi_range=(1e9, 1e19), nodes=96, tol=1e-10, l_cap=L_CAP):
56
+ """Casimir-Polder potential of an atom at radius (radii) ``r`` (nm), in any layer.
57
+
58
+ ``eps(xi)`` and ``mu(xi)`` (optional) return the (N + 1,) permittivities and permeabilities of the
59
+ layers at imaginary frequency i xi (xi in rad/s; real and >= 1 for passive media), host last.
60
+ ``polarizability(xi)`` is the atom's isotropic polarizability alpha(i xi) / (4 pi eps_0) in nm^3.
61
+ The integral over xi runs over ``xi_range`` with ``nodes`` Gauss-Legendre nodes in ln xi; every
62
+ order sum meets ``tol`` (automatic truncation, at most ``l_cap`` orders). Lengths in nm.
63
+ """
64
+ radii = np.atleast_1d(np.asarray(radii, dtype=float))
65
+ r = np.atleast_1d(np.asarray(r, dtype=float))
66
+ if np.any(~(r > 0)) or np.any(np.isin(r, radii)):
67
+ raise ValueError("positions must be positive and off the interfaces")
68
+ shells = np.searchsorted(radii, r, side="right")
69
+ t, w = gauss_legendre(nodes)
70
+ lo, hi = np.log(xi_range[0]), np.log(xi_range[1])
71
+ ln_xi = 0.5 * (hi - lo) * t + 0.5 * (hi + lo)
72
+ xi, w = np.exp(ln_xi), 0.5 * (hi - lo) * w * np.exp(ln_xi) # d xi = xi d ln xi
73
+ q = np.max(np.minimum(radii[None, :] / r[:, None], r[:, None] / radii[None, :]))
74
+ integrand = np.zeros((r.size, xi.size))
75
+ ok = np.ones(r.size, dtype=bool)
76
+ used = 0
77
+ for i, x in enumerate(xi):
78
+ e = np.atleast_1d(np.asarray(eps(x), dtype=complex))
79
+ m = np.ones_like(e) if mu is None else np.atleast_1d(np.asarray(mu(x), dtype=complex))
80
+ if e.shape != (radii.size + 1,) or m.shape != e.shape:
81
+ raise ValueError(f"eps and mu must return ({radii.size + 1},) arrays, host last")
82
+ n = np.sqrt(e * m).real + 0j # real at imaginary frequency for passive media
83
+ k = 1j * (x / C_NM) * n
84
+ L = int(min(l_cap, max(32, orders_needed(q, tol, l_cap))))
85
+ while True:
86
+ terms = _Sweep(radii, n, m, k, L).at(r)
87
+ g, _ = _dipole_series(terms, "electric")
88
+ good = _complex_converged(g, tol)
89
+ if good.all() or L >= l_cap:
90
+ break
91
+ L = min(2 * L, l_cap)
92
+ used = max(used, L)
93
+ ok &= good
94
+ trace = (g[:, :, 0] + 2 * g[:, :, 1]).sum(axis=1).real
95
+ integrand[:, i] = x**3 * n[shells].real * polarizability(x) * trace
96
+ energy = -HBAR / (3 * np.pi * C_NM**3) * integrand @ w
97
+ return CasimirPolder(r=r, energy=energy, xi=xi, integrand=integrand, orders=used, converged=ok)
pystratify/chiral.py ADDED
@@ -0,0 +1,426 @@
1
+ """Multilayered spheres with chiral (Pasteur) layers.
2
+
3
+ Constitutive relations (Gaussian units, time dependence exp(-i omega t)):
4
+
5
+ D = eps E + i kappa H, B = mu H - i kappa E,
6
+
7
+ the convention of treams (Beutel et al., Comput. Phys. Commun. 297, 109076
8
+ (2024)) and of Lindell et al., *Electromagnetic Waves in Chiral and
9
+ Bi-Isotropic Media* (1994) after exp(jwt) -> exp(-iwt). In every layer the
10
+ field splits into the Beltrami fields Q_s = E + s i Z H (s = +-1,
11
+ Z = sqrt(mu/eps) = mu/n), which obey curl Q_s = s k_s Q_s with
12
+ k_s = k0 (n + s kappa): kappa > 0 slows helicity +1 (left-circular in the
13
+ optics convention). The host must be achiral.
14
+
15
+ Per multipole order the fields of a layer are W_s = M + s N with wavenumber
16
+ k_s, and E_tan, H_tan continuous at an interface gives, with channel values
17
+ v_s = u_s/x_s and derivatives d_s = u_s'/x_s (u = A psi + B xi),
18
+
19
+ v' = C_v v, d' = C_d d, C_v = [[1+z, 1-z], [1-z, 1+z]]/2,
20
+ C_d = [[1+z, z-1], [z-1, 1+z]]/2, z = Z_{j+1} / Z_j,
21
+
22
+ so helicity mixes only through the impedance contrast (a dual, impedance-
23
+ matched particle conserves helicity). The regular solution is swept
24
+ outwards as the 2x2 matrix rho = Xi R Psi^-1 (B = R A, scaled at the
25
+ interface as in the achiral solver): with D1 = psi'/psi, D3 = xi'/xi,
26
+
27
+ rho' = Lambda^-1 (N0 + N1 rho) (D0 + D1m rho)^-1 Lambda,
28
+ N0 = D1' C_v - C_d D1, N1 = D1' C_v - C_d D3,
29
+ D0 = C_d D1 - D3' C_v, D1m = C_d D3 - D3' C_v, Lambda = D1' - D3',
30
+
31
+ the matrix form of the achiral Moebius step. As there, the large parts
32
+ (l+1)/x of D1 and l/x of D3 are combined analytically, so N0 and D1m - which
33
+ vanish for identical media - are built from the material contrasts
34
+ directly, and rho is carried as element-wise logarithms so it may fall far
35
+ below the double range across thick absorbing shells.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ from dataclasses import dataclass
41
+
42
+ import numpy as np
43
+
44
+ from .convergence import truncation_order
45
+ from .riccati import log_riccati
46
+ from .sheets import _feibelman_arrays, _sheet_arrays, _sheet_terms
47
+ from .solver import _batch, _side
48
+
49
+ __all__ = ["ChiralSolution", "solve_chiral", "HELICITY_TO_TMTE", "log_matmul"]
50
+
51
+ #: (TM, TE) = (N, M) amplitudes of W_+ = M + N and W_- = M - N, as columns
52
+ HELICITY_TO_TMTE = np.array([[1.0, -1.0], [1.0, 1.0]])
53
+ _TMTE_TO_HELICITY = np.linalg.inv(HELICITY_TO_TMTE)
54
+ _SIGN = np.array([1.0, -1.0])
55
+ _SAFE_LOG = 600.0
56
+
57
+
58
+ def _inv2(m):
59
+ a, b, c, d = m[..., 0, 0], m[..., 0, 1], m[..., 1, 0], m[..., 1, 1]
60
+ out = np.empty_like(m)
61
+ det = a * d - b * c
62
+ out[..., 0, 0], out[..., 0, 1], out[..., 1, 0], out[..., 1, 1] = d / det, -b / det, -c / det, a / det
63
+ return out
64
+
65
+
66
+ def _log(a):
67
+ with np.errstate(divide="ignore", invalid="ignore"):
68
+ return np.log(a)
69
+
70
+
71
+ def _log_add(log_x, log_y):
72
+ with np.errstate(all="ignore"):
73
+ m = np.maximum(log_x.real, log_y.real)
74
+ m = np.where(np.isfinite(m), m, 0.0)
75
+ return m + np.log(np.exp(log_x - m) + np.exp(log_y - m))
76
+
77
+
78
+ def log_matmul(log_a, log_b):
79
+ """log(A @ B) for stacks of 2x2 matrices given as element-wise complex logarithms."""
80
+ return _log_add(
81
+ log_a[..., :, 0, None] + log_b[..., None, 0, :],
82
+ log_a[..., :, 1, None] + log_b[..., None, 1, :],
83
+ )
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class ChiralSolution:
88
+ """T-matrix of a multilayered sphere with chiral layers, for a batch of wavelengths.
89
+
90
+ ``log_t_helicity`` has shape ``(W, L, 2, 2)``: log of the host T-matrix
91
+ block in the helicity basis [out, in] (index 0 = +1, 1 = -1) for the
92
+ waves W_s = M + s N; ``t_matrix`` is the same block in the (TM, TE)
93
+ basis used by the far-field functions.
94
+
95
+ Internal fields, shape ``(W, N + 1, L, 2, 2)`` [layer, ..., channel, incident
96
+ helicity]: in layer j the regular solution excited by a unit incident wave
97
+ W_h in the host is sum_c (alpha_ch psi_l(k_jc r) + beta_ch xi_l(k_jc r)) W_c,
98
+ with ``log_alpha`` = log alpha (the identity in the host) and ``log_r`` =
99
+ log R, beta = R alpha (zero in the core, T in the host). The outgoing
100
+ solution, a unit outgoing wave W_h in the host and nothing incoming, is
101
+ ``log_beta_out`` = log beta and ``log_s`` = log S, alpha = S beta (zero in the host).
102
+
103
+ Interface maps, shape ``(W, N, L, 2, 2)``: ``log_in[:, j]`` carries the
104
+ psi amplitudes of any regular solution from layer j + 1 to layer j
105
+ (alpha_j = M alpha_{j+1}), ``log_out[:, j]`` the xi amplitudes of any
106
+ outgoing solution from layer j to layer j + 1 (beta_{j+1} = M beta_j).
107
+ Chained, they continue a field found in one layer (e.g. of a source) to
108
+ the others without inverting ``log_alpha`` or ``log_beta_out``.
109
+ """
110
+
111
+ radii: np.ndarray
112
+ n: np.ndarray # (W, N + 1)
113
+ mu: np.ndarray # (W, N + 1)
114
+ kappa: np.ndarray # (W, N + 1)
115
+ wavelength: np.ndarray # (W,)
116
+ orders: np.ndarray # (L,)
117
+ log_t_helicity: np.ndarray # (W, L, 2, 2)
118
+ log_alpha: np.ndarray # (W, N + 1, L, 2, 2)
119
+ log_r: np.ndarray # (W, N + 1, L, 2, 2)
120
+ log_beta_out: np.ndarray # (W, N + 1, L, 2, 2)
121
+ log_s: np.ndarray # (W, N + 1, L, 2, 2)
122
+ log_in: np.ndarray # (W, N, L, 2, 2)
123
+ log_out: np.ndarray # (W, N, L, 2, 2)
124
+ sheet_sigma: np.ndarray | None = None # (W, N): 2D sheets at the interfaces (see pystratify.sheets)
125
+ sheet_zeta: np.ndarray | None = None # (W, N)
126
+
127
+ @property
128
+ def has_sheets(self) -> bool:
129
+ return self.sheet_sigma is not None and bool(np.any(self.sheet_sigma) or np.any(self.sheet_zeta))
130
+
131
+ @property
132
+ def n_shells(self) -> int:
133
+ return self.radii.size
134
+
135
+ @property
136
+ def k(self) -> np.ndarray:
137
+ """Mean wavenumbers k0 n, shape (W, N + 1) (the host's is exact)."""
138
+ return 2 * np.pi * self.n / self.wavelength[:, None]
139
+
140
+ @property
141
+ def k_helicity(self) -> np.ndarray:
142
+ """Wavenumbers k0 (n + s kappa) of helicity s = +1, -1: shape (W, N + 1, 2)."""
143
+ return 2 * np.pi * (self.n[..., None] + _SIGN * self.kappa[..., None]) / self.wavelength[:, None, None]
144
+
145
+ @property
146
+ def t_helicity(self) -> np.ndarray:
147
+ with np.errstate(under="ignore"):
148
+ return np.exp(self.log_t_helicity)
149
+
150
+ @property
151
+ def t_matrix(self) -> np.ndarray:
152
+ """T-matrix blocks (W, L, 2, 2) in the (TM, TE) basis [out, in]."""
153
+ return HELICITY_TO_TMTE @ self.t_helicity @ _TMTE_TO_HELICITY
154
+
155
+
156
+ def _interface_matrices(l, x_in, x_out, diff, side_in, side_out, delta):
157
+ """N0, N1, D0, D1m (W, L, 2, 2) of one interface.
158
+
159
+ ``x_*``, ``diff`` = x_in - x_out (from the contrasts): (W, 2); ``side_*`` =
160
+ (r, X) = (psi_{l+1}/psi_l, xi_{l-1}/xi_l), each (W, 2, L); ``delta`` = 1 - z.
161
+ """
162
+ (r_i, big_i), (r_o, big_o) = side_in, side_out
163
+ xi, xo, diff = x_in[..., None], x_out[..., None], diff[..., None] # (W, 2, 1)
164
+ h = (1 - delta / 2)[:, None, None]
165
+ half = (delta / 2)[:, None, None]
166
+ shape = r_i.shape[:1] + (l.size, 2, 2)
167
+ n0, n1, d0, d1 = (np.empty(shape, dtype=complex) for _ in range(4))
168
+
169
+ def put(target, c, e, value):
170
+ target[..., c, e] = value
171
+
172
+ for c in (0, 1):
173
+ e = 1 - c
174
+ # diagonal: the large parts combine into contrasts
175
+ put(n0, c, c, h[:, 0] * ((l + 1) * diff[:, c] / (xi[:, c] * xo[:, c]) + r_i[:, c] - r_o[:, c]))
176
+ put(n1, c, c, h[:, 0] * ((l + 1) / xo[:, c] - r_o[:, c] - big_i[:, c] + l / xi[:, c]))
177
+ put(d0, c, c, h[:, 0] * ((l + 1) / xi[:, c] - r_i[:, c] - big_o[:, c] + l / xo[:, c]))
178
+ put(d1, c, c, h[:, 0] * (big_i[:, c] - big_o[:, c] + l * diff[:, c] / (xi[:, c] * xo[:, c])))
179
+ # off-diagonal: proportional to the impedance contrast
180
+ put(n0, c, e, half[:, 0] * ((l + 1) * (1 / xo[:, c] + 1 / xi[:, e]) - r_o[:, c] - r_i[:, e]))
181
+ put(n1, c, e, half[:, 0] * ((l + 1) / xo[:, c] - r_o[:, c] + big_i[:, e] - l / xi[:, e]))
182
+ put(d0, c, e, -half[:, 0] * ((l + 1) / xi[:, e] - r_i[:, e] + big_o[:, c] - l / xo[:, c]))
183
+ put(d1, c, e, -half[:, 0] * (big_i[:, e] + big_o[:, c] - l / xi[:, e] - l / xo[:, c]))
184
+ return n0, n1, d0, d1
185
+
186
+
187
+ def _sheet_corrections(d1_in, d3_in, d1_out, d3_out, z_in, z_out, sigma, a, tau, p):
188
+ """Additions to N0, N1, D0, D1m (W, L, 2, 2) from a 2D sheet on the interface (pystratify.sheets).
189
+
190
+ With the sheet, v' = C_v^ v + S_v d and d' = C_d^ d + S_d v, where (J = [[1, -1], [-1, 1]],
191
+ O = [[1, 1], [1, 1]], g = tau / p): C_v^ = C_v + z (tau - 1) J / 2, C_d^ = C_d + (tau - 1) J / 2,
192
+ S_v = g i Z' sigma J / 2, S_d = -i Z' sigma O / 2 - g a J / (2 i Z). ``d*``: (W, L, 2) per
193
+ channel; ``z_*``, ``sigma``: (W,); ``a``, ``tau``, ``p``: (W, L).
194
+ """
195
+ J = np.array([[1.0, -1.0], [-1.0, 1.0]])
196
+ O = np.ones((2, 2))
197
+ zeta = (z_out / z_in)[:, None, None, None]
198
+ tau, a, gain = tau[..., None, None], a[..., None, None], (tau / p)[..., None, None]
199
+ sig = sigma[:, None, None, None]
200
+ dcv = zeta * (tau - 1) / 2 * J
201
+ dcd = (tau - 1) / 2 * J
202
+ s_v = gain * 1j * z_out[:, None, None, None] * sig / 2 * J
203
+ s_d = -1j * z_out[:, None, None, None] * sig / 2 * O - gain * a / (2j * z_in[:, None, None, None]) * J
204
+
205
+ def left(d, m): # diag(d) @ m
206
+ return d[..., :, None] * m
207
+
208
+ def right(m, d): # m @ diag(d)
209
+ return m * d[..., None, :]
210
+
211
+ n0 = left(d1_out, dcv) - right(dcd, d1_in) + right(left(d1_out, s_v), d1_in) - s_d
212
+ n1 = left(d1_out, dcv) - right(dcd, d3_in) + right(left(d1_out, s_v), d3_in) - s_d
213
+ d0 = right(dcd, d1_in) - left(d3_out, dcv) + s_d - right(left(d3_out, s_v), d1_in)
214
+ d1 = right(dcd, d3_in) - left(d3_out, dcv) + s_d - right(left(d3_out, s_v), d3_in)
215
+ return n0, n1, d0, d1
216
+
217
+
218
+ def _log_moebius(n0, n1, d0, d1, log_rho):
219
+ """log[(N0 + N1 rho)(D0 + D1m rho)^-1] element-wise, rho = exp(log_rho), for any magnitude of rho,
220
+ and log[(D0 + D1m rho)^-1] (the inverse amplitude transfer up to Lambda).
221
+
222
+ Where every element of rho is below e^-600, rho = e^s rho_hat and the result
223
+ is N0 D0^-1 + e^s N1 rho_hat D0^-1: the neglected terms are e^-600 relative to
224
+ one of these two (the N0 D0^-1 D1m rho D0^-1 cross term matters only if N0 is
225
+ itself below e^-600). Above e^600, rho is scaled down to e^600, which changes
226
+ the ratio only by e^-600 relative.
227
+ """
228
+ with np.errstate(invalid="ignore"):
229
+ s = np.max(log_rho.real, axis=(-2, -1)) # (W, L)
230
+ tiny = np.isfinite(s) & (s <= -_SAFE_LOG)
231
+ shift = np.where(np.isfinite(s) & (s > _SAFE_LOG), s - _SAFE_LOG, 0.0)[..., None, None]
232
+ with np.errstate(all="ignore"):
233
+ rho = np.exp(np.where(tiny[..., None, None], -np.inf, log_rho - shift))
234
+ den_inv = _inv2(d0 + d1 @ rho)
235
+ out = _log((n0 + n1 @ rho) @ den_inv)
236
+ log_den_inv = _log(den_inv) - shift
237
+ if tiny.any():
238
+ rho_hat = np.exp(log_rho[tiny] - s[tiny][:, None, None])
239
+ inv = _inv2(d0[tiny])
240
+ out[tiny] = _log_add(_log(n0[tiny] @ inv), s[tiny][:, None, None] + _log(n1[tiny] @ rho_hat @ inv))
241
+ return out, log_den_inv
242
+
243
+
244
+ def _log_moebius_inward(n0, n1, d0, d1, log_sig):
245
+ """Outgoing solution across an interface, outer to inner:
246
+ log[(sig N0 - D0)^-1 (D1m - sig N1)] element-wise for sig = exp(log_sig) (already
247
+ similarity-transformed by Lambda), any magnitude; below e^-600 to first order in sig."""
248
+ with np.errstate(invalid="ignore"):
249
+ s = np.max(log_sig.real, axis=(-2, -1))
250
+ tiny = np.isfinite(s) & (s <= -_SAFE_LOG)
251
+ shift = np.where(np.isfinite(s) & (s > _SAFE_LOG), s - _SAFE_LOG, 0.0)[..., None, None]
252
+ with np.errstate(all="ignore"):
253
+ sig = np.exp(np.where(tiny[..., None, None], -np.inf, log_sig - shift))
254
+ out = _log(_inv2(sig @ n0 - d0) @ (d1 - sig @ n1))
255
+ if tiny.any():
256
+ sig_hat = np.exp(log_sig[tiny] - s[tiny][:, None, None])
257
+ inv = _inv2(d0[tiny])
258
+ first = inv @ sig_hat @ (n1[tiny] - n0[tiny] @ inv @ d1[tiny])
259
+ out[tiny] = _log_add(_log(-inv @ d1[tiny]), s[tiny][:, None, None] + _log(first))
260
+ return out
261
+
262
+
263
+ def solve_chiral(radii, n, kappa, wavelength, mu=None, l_max=None, sheets=None) -> ChiralSolution:
264
+ """Solve a multilayered sphere whose shells may be chiral.
265
+
266
+ Parameters
267
+ ----------
268
+ radii : (N,) outer radii of the core and shells, strictly increasing.
269
+ n : (N + 1,) or (W, N + 1) refractive indices n = sqrt(eps mu), host last.
270
+ kappa : like ``n``, Pasteur chirality parameters (dimensionless, complex
271
+ for a lossy chiral response); the host's must be 0, and n +- kappa must
272
+ not be zero or negative real.
273
+ wavelength : scalar or (W,) vacuum wavelength(s), same unit as ``radii``.
274
+ mu : like ``n``, relative permeabilities (default 1).
275
+ l_max : truncation order; default :func:`truncation_order` (Wiscombe) for
276
+ the shortest wavelength, as :func:`~pystratify.solve`.
277
+ sheets : 2D materials at interfaces, ``{j: Sheet(...)}`` or ``{j: conductivity}``
278
+ (j = 0: surface of the core), as :func:`~pystratify.solve`; see :mod:`pystratify.sheets`.
279
+
280
+ With ``kappa = 0`` everywhere the result equals :func:`~pystratify.solve`'s
281
+ (``t_matrix`` then is diagonal).
282
+ """
283
+ radii = np.atleast_1d(np.asarray(radii, dtype=float))
284
+ if radii.ndim != 1 or radii.size == 0 or not np.all(np.isfinite(radii)):
285
+ raise ValueError("radii must be a non-empty 1-D array of finite values")
286
+ if radii[0] <= 0 or np.any(np.diff(radii) <= 0):
287
+ raise ValueError("radii must be positive and strictly increasing")
288
+ N = radii.size
289
+ n, mu, wavelength = _batch(n, mu, wavelength, N + 1)
290
+ try:
291
+ kappa = np.ascontiguousarray(np.broadcast_to(np.asarray(kappa, dtype=complex), n.shape))
292
+ except ValueError:
293
+ raise ValueError(f"kappa needs shape ({N + 1},) or ({wavelength.size}, {N + 1}), host last") from None
294
+ if not np.all(np.isfinite(kappa)):
295
+ raise ValueError("chirality parameters must be finite")
296
+ if np.any(kappa[:, -1] != 0):
297
+ raise ValueError("the host must be achiral (kappa = 0 in the last entry)")
298
+ helicity_n = n[..., None] + _SIGN * kappa[..., None]
299
+ if np.any(helicity_n == 0) or np.any((helicity_n.imag == 0) & (helicity_n.real < 0)):
300
+ raise ValueError(
301
+ "n +- kappa must not be zero or negative real (a lossless backward-wave helicity is not supported)"
302
+ )
303
+ if l_max is None:
304
+ l_max = max(truncation_order(radii[-1], abs(v), lam) for v, lam in zip(n[:, -1], wavelength))
305
+ l_max = int(l_max)
306
+ if l_max < 1:
307
+ raise ValueError("l_max must be >= 1")
308
+ l = np.arange(1, l_max + 1)
309
+ W = wavelength.size
310
+
311
+ k0 = 2 * np.pi / wavelength
312
+ kc = k0[:, None, None] * helicity_n # (W, N + 1, 2)
313
+ x_in = kc[:, :N] * radii[:, None] # (W, N, 2): k_{j,s} R_j
314
+ x_out = kc[:, 1:] * radii[:, None] # (W, N, 2): k_{j+1,s} R_j
315
+ lp_in, lx_in, r_in, big_in, d1_in, d3_in = _side(x_in, *log_riccati(x_in, l_max + 1), l)
316
+ lp_out, lx_out, r_out, big_out, d1_out, d3_out = _side(x_out, *log_riccati(x_out, l_max + 1), l)
317
+ # 1 - Z_{j+1}/Z_j from the contrasts, Z = mu/n
318
+ n_i, n_o, mu_i, mu_o = n[:, :N], n[:, 1:], mu[:, :N], mu[:, 1:]
319
+ delta = (mu_i * (n_o - n_i) + n_i * (mu_i - mu_o)) / (mu_i * n_o) # (W, N)
320
+ # x - x' per helicity from the contrasts, not by subtraction of the products
321
+ diff_n = (n_i - n_o)[..., None] + _SIGN * (kappa[:, :N] - kappa[:, 1:])[..., None] # (W, N, 2)
322
+
323
+ if _feibelman_arrays(sheets, N, W) is not None:
324
+ raise NotImplementedError("d-parameters (Feibelman) are not part of the chiral solver: use solve()")
325
+ sigma, zeta = _sheet_arrays(sheets, N, W)
326
+ sheet_a, sheet_p, sheet_tau = _sheet_terms(sigma, zeta, k0, radii, l)
327
+
328
+ log_rho = np.full((W, l_max, 2, 2), -np.inf, dtype=complex)
329
+ log_rho_out = np.empty((N, W, l_max, 2, 2), dtype=complex)
330
+ log_transfer_inv = np.empty((N, W, l_max, 2, 2), dtype=complex) # a = K^-1 a' at interface j
331
+ interface = []
332
+ for j in range(N):
333
+ if j: # carry rho across shell j: rho_ce *= [xi_c(o)/xi_c(i)] [psi_e(i)/psi_e(o)]
334
+ grow_xi = np.moveaxis(lx_in[:, j] - lx_out[:, j - 1], 1, -1) # (W, L, 2)
335
+ fall_psi = np.moveaxis(lp_out[:, j - 1] - lp_in[:, j], 1, -1)
336
+ log_rho = log_rho + grow_xi[..., :, None] + fall_psi[..., None, :]
337
+ mats = _interface_matrices(
338
+ l,
339
+ x_in[:, j],
340
+ x_out[:, j],
341
+ k0[:, None] * radii[j] * diff_n[:, j],
342
+ (r_in[:, j], big_in[:, j]),
343
+ (r_out[:, j], big_out[:, j]),
344
+ delta[:, j],
345
+ )
346
+ if np.any(sigma[:, j]) or np.any(zeta[:, j]):
347
+ extra = _sheet_corrections(
348
+ *(np.moveaxis(v[:, j], 1, -1) for v in (d1_in, d3_in, d1_out, d3_out)),
349
+ mu[:, j] / n[:, j],
350
+ mu[:, j + 1] / n[:, j + 1],
351
+ sigma[:, j],
352
+ sheet_a[:, j],
353
+ sheet_tau[:, j],
354
+ sheet_p[:, j],
355
+ )
356
+ mats = tuple(m + e for m, e in zip(mats, extra))
357
+ interface.append(mats)
358
+ log_m, log_den_inv = _log_moebius(*mats, log_rho)
359
+ # similarity with Lambda = diag(-i / (psi xi)) at the outer arguments
360
+ lam = np.moveaxis(lp_out[:, j] + lx_out[:, j], 1, -1) # (W, L, 2): log(psi xi)
361
+ log_rho = log_m + lam[..., :, None] - lam[..., None, :]
362
+ log_rho_out[j] = log_rho
363
+ log_transfer_inv[j] = log_den_inv + (np.log(-1j) - lam)[..., None, :] # (D0 + D1m rho)^-1 Lambda
364
+
365
+ # amplitudes from the host inwards: scaled a = diag(psi/x) alpha, a_inner = K^-1 a_outer
366
+ log_x_in, log_x_out = np.log(x_in), np.log(x_out) # (W, N, 2)
367
+ log_alpha = np.empty((W, N + 1, l_max, 2, 2), dtype=complex)
368
+ log_r = np.full((W, N + 1, l_max, 2, 2), -np.inf, dtype=complex)
369
+ log_in = np.empty((W, N, l_max, 2, 2), dtype=complex)
370
+ log_alpha[:, N] = _log(np.eye(2) + 0j)
371
+ for j in range(N - 1, -1, -1):
372
+ outer = np.moveaxis(lp_out[:, j], 1, -1) - log_x_out[:, j, None, :] # (W, L, 2): log(psi/x)
373
+ inner = np.moveaxis(lp_in[:, j], 1, -1) - log_x_in[:, j, None, :]
374
+ # alpha_j = diag(x/psi)_in K^-1 diag(psi/x)_out alpha_{j+1}
375
+ log_in[:, j] = log_transfer_inv[j] + outer[..., None, :] - inner[..., :, None]
376
+ scaled = log_matmul(log_transfer_inv[j], log_alpha[:, j + 1] + outer[..., :, None])
377
+ log_alpha[:, j] = scaled - inner[..., :, None]
378
+ # R of layer j + 1 from rho at its inner boundary: R_ce = rho_ce (x_c / xi_c) (psi_e / x_e)
379
+ to_r_rows = log_x_out[:, j, None, :] - np.moveaxis(lx_out[:, j], 1, -1)
380
+ log_r[:, j + 1] = log_rho_out[j] + to_r_rows[..., :, None] + outer[..., None, :]
381
+ # outgoing solution from the host inwards: scaled b = diag(xi/x) beta, sigma = (psi/x) S (x/xi),
382
+ # sigma = (sig' N0 - D0)^-1 (D1m - sig' N1) and b = (N0 sigma + N1)^-1 Lambda b'
383
+ log_beta_out = np.empty((W, N + 1, l_max, 2, 2), dtype=complex)
384
+ log_s = np.full((W, N + 1, l_max, 2, 2), -np.inf, dtype=complex)
385
+ log_out = np.empty((W, N, l_max, 2, 2), dtype=complex)
386
+ log_beta_out[:, N] = _log(np.eye(2) + 0j)
387
+ log_sig = np.full((W, l_max, 2, 2), -np.inf, dtype=complex) # outer side of interface N - 1
388
+ for j in range(N - 1, -1, -1):
389
+ n0, n1, d0, d1 = interface[j]
390
+ lam = np.moveaxis(lp_out[:, j] + lx_out[:, j], 1, -1)
391
+ # sig~' = Lambda sig' Lambda^-1, Lambda = diag(-i / (psi xi)): element factor (psi xi)_e / (psi xi)_c
392
+ log_sig_in = _log_moebius_inward(n0, n1, d0, d1, log_sig - lam[..., :, None] + lam[..., None, :])
393
+ with np.errstate(under="ignore", over="ignore"):
394
+ forward = n0 @ np.exp(log_sig_in) + n1
395
+ transfer = _inv2(forward) # (N0 sigma + N1)^-1, then Lambda
396
+ outer_xi = np.moveaxis(lx_out[:, j], 1, -1) - log_x_out[:, j, None, :] # log(xi/x), outer side
397
+ inner_xi = np.moveaxis(lx_in[:, j], 1, -1) - log_x_in[:, j, None, :]
398
+ # beta_{j+1} = diag(x/xi)_out Lambda^-1 (N0 sigma + N1) diag(xi/x)_in beta_j, Lambda^-1 = diag(i psi xi)
399
+ log_out[:, j] = _log(forward) + (np.log(1j) + lam - outer_xi)[..., :, None] + inner_xi[..., None, :]
400
+ log_transfer = _log(transfer) + (np.log(-1j) - lam)[..., None, :]
401
+ scaled = log_matmul(log_transfer, log_beta_out[:, j + 1] + outer_xi[..., :, None])
402
+ log_beta_out[:, j] = scaled - inner_xi[..., :, None]
403
+ # S of layer j from sigma at its outer boundary: S_ce = sigma_ce (x_c / psi_c) (xi_e / x_e)
404
+ inner_psi = np.moveaxis(lp_in[:, j], 1, -1) - log_x_in[:, j, None, :]
405
+ log_s[:, j] = log_sig_in - inner_psi[..., :, None] + inner_xi[..., None, :]
406
+ if j: # carry sigma across layer j to the outer side of interface j - 1
407
+ fall_psi = np.moveaxis(lp_out[:, j - 1] - lp_in[:, j], 1, -1)
408
+ grow_xi = np.moveaxis(lx_in[:, j] - lx_out[:, j - 1], 1, -1)
409
+ log_sig = log_sig_in + fall_psi[..., :, None] + grow_xi[..., None, :]
410
+ return ChiralSolution(
411
+ radii=radii,
412
+ n=n,
413
+ mu=mu,
414
+ kappa=kappa,
415
+ wavelength=wavelength,
416
+ orders=l,
417
+ log_t_helicity=log_r[:, N],
418
+ log_alpha=log_alpha,
419
+ log_r=log_r,
420
+ log_beta_out=log_beta_out,
421
+ log_s=log_s,
422
+ log_in=log_in,
423
+ log_out=log_out,
424
+ sheet_sigma=sigma,
425
+ sheet_zeta=zeta,
426
+ )