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.
pystratify/decay.py ADDED
@@ -0,0 +1,754 @@
1
+ """Decay rates of an electric or magnetic dipole near or inside a multilayered sphere.
2
+
3
+ Theory: Moroz, Ann. Phys. 315, 352 (2005); Rasskazov, Carney & Moroz, OSA
4
+ Continuum 3, 2290 (2020), Eqs. (28)-(34), with the corrections listed in
5
+ AUDIT.md (normalisation, spherical Hankel functions in the absorption
6
+ integrals, tolerance-controlled sums).
7
+
8
+ In the emitter's shell d (argument x = k_d r, real) let u_in = psi + R xi be
9
+ the regular and u_out = S psi + xi the outgoing solution of
10
+ :func:`~pystratify.solve`. With rho = R xi/psi, sigma = S psi/xi (both O(1))
11
+ and Delta = 1 - R S, the Green's function gives
12
+
13
+ * total rate (LDOS): Re[u_in u_out / Delta]
14
+ * radiated amplitude: u_in / (B_out,d Delta), B_out,d the outgoing amplitude
15
+ normalised to B = 1 in the host
16
+ * field in an absorbing shell a: the regular (a < d) or outgoing (a > d)
17
+ solution continued to shell a, times u_out(x)/Delta or u_in(x)/Delta.
18
+
19
+ Losses are Im(eps)|E|^2 + Im(mu)|H|^2 in every lossy shell. The total rate
20
+ is computed independently of the radiative and loss parts,
21
+ and ``balance_error = |total - rad - nonrad| / total`` checks energy
22
+ conservation.
23
+
24
+ By default (``route='auto'``) the total and radiative rates and the frequency
25
+ shift come from the normalized formulation (:mod:`pystratify.normalized`), whose
26
+ per-order terms keep full precision at any distance from an interface; the loss
27
+ integrals stay on the logarithmic route above, so the energy balance compares two
28
+ independent computations. ``route='log'`` takes everything from the logarithmic
29
+ route, which within ~1 nm of a lossless interface loses ~1e-8 in the total rate.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import warnings
35
+ from dataclasses import dataclass, field
36
+
37
+ import numpy as np
38
+
39
+ from .chiral import _log_add
40
+ from .convergence import orders_needed as _orders_needed
41
+ from .convergence import tail_estimate as _tail_estimate
42
+ from .convergence import truncation_order
43
+ from .energy import WEAK_LOSS, gauss_legendre
44
+ from .riccati import log_riccati
45
+ from .solver import TE, TM, Solution, solve
46
+
47
+ __all__ = ["DecayRates", "decay_rates", "locate_shell"]
48
+
49
+
50
+ def locate_shell(radii, r):
51
+ """Shell index (0 = core, N = host) of radial position(s) r; interfaces belong outward."""
52
+ return np.searchsorted(np.asarray(radii), np.asarray(r), side="right")
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class DecayRates:
57
+ """Normalised decay rates, arrays of shape ``(len(r), 2)``.
58
+
59
+ Column 0: radial (perpendicular) dipole; column 1: tangential (parallel).
60
+ ``shift`` is the frequency shift (omega - omega_0) in units of the same free
61
+ rate as the rates (exp(-i omega t); the free self-energy is part of omega_0),
62
+ NaN on the logarithmic route; ``route`` names the route of ``total``,
63
+ ``radiative`` and ``shift``: ``'normalized'`` or ``'log'``.
64
+ """
65
+
66
+ r: np.ndarray
67
+ radiative: np.ndarray
68
+ nonradiative: np.ndarray
69
+ total: np.ndarray
70
+ shell: np.ndarray
71
+ orders_used: np.ndarray
72
+ converged: np.ndarray
73
+ normalization: str
74
+ dipole: str
75
+ notes: tuple = field(default_factory=tuple)
76
+ shift: np.ndarray | None = None
77
+ route: str = "log"
78
+
79
+ @property
80
+ def balance_error(self) -> np.ndarray:
81
+ """|total - (radiative + nonradiative)| / total per position and orientation."""
82
+ return np.abs(self.total - self.radiative - self.nonradiative) / np.abs(self.total)
83
+
84
+ @staticmethod
85
+ def averaged(rates: np.ndarray) -> np.ndarray:
86
+ """Orientation average (perpendicular + 2 parallel) / 3."""
87
+ return (rates[:, 0] + 2 * rates[:, 1]) / 3
88
+
89
+ def quantum_yield(self, intrinsic: float = 1.0) -> np.ndarray:
90
+ """Orientation-averaged quantum yield for an emitter of intrinsic yield ``intrinsic``."""
91
+ rad, nonrad = self.averaged(self.radiative), self.averaged(self.nonradiative)
92
+ return rad / (rad + nonrad + (1 - intrinsic) / intrinsic)
93
+
94
+
95
+ def _normalization(n, mu, d, normalization, dipole):
96
+ """(radiative, nonradiative, total) factors relative to the free-space rate.
97
+
98
+ The shell factors follow from the Green's function; normalising to the
99
+ host multiplies by Gamma_0(shell) / Gamma_0(host), which is n mu for an
100
+ electric and n eps for a magnetic dipole.
101
+ """
102
+ nd, nh, md, mh = n[d], n[-1], mu[d], mu[-1]
103
+ radiative = ((nd * md) / (nh * mh)).real
104
+ nonradiative = (md / nd**2).real
105
+ if normalization == "shell":
106
+ ratio = 1.0
107
+ elif dipole == "electric":
108
+ ratio = ((nd * md) / (nh * mh)).real
109
+ else:
110
+ ratio = ((nd**3 / md) / (nh**3 / mh)).real
111
+ return radiative * ratio, nonradiative * ratio, ratio
112
+
113
+
114
+ def _log(a):
115
+ with np.errstate(divide="ignore", invalid="ignore"):
116
+ return np.log(a)
117
+
118
+
119
+ #: closed-form absorption integrals are used while they lose fewer digits than this
120
+ _LOMMEL_MAX_CANCELLATION = 1e5
121
+ #: a boundary term is taken from its direct formula while that loses fewer digits than this
122
+ _DIRECT_MAX_CANCELLATION = 1e3
123
+ #: extra orders above the top one, so the damped downward recurrence forgets its start
124
+ _RECURRENCE_HEADROOM = 16
125
+
126
+
127
+ def _pick(direct, cancellation, recurred):
128
+ return direct if cancellation < _DIRECT_MAX_CANCELLATION else recurred
129
+
130
+
131
+ def _lommel_boundary(k, radius, count):
132
+ """Logs of the Lommel boundary terms at r = ``radius`` for orders nu = 0..count + 1.
133
+
134
+ With j = j_nu(k r), h = h_nu(k r) and complex k (Im k^2 > 0):
135
+
136
+ int |j|^2 r^2 dr = [ r^2 Im(k* j_{nu-1}* j_nu) / Im(k^2) ] = [ G_jj ]
137
+ int |h|^2 r^2 dr = [ r^2 Im(k* h_{nu-1}* h_nu) / Im(k^2) ] = [ G_hh ]
138
+ int j h* r^2 dr = [ r^2 (k* j_nu h_{nu-1}* - k j_{nu-1} h_nu*) / (k^2 - k*^2) ] = [ G_jh ]
139
+
140
+ Past the turning point k* j_{nu-1}* j_nu is real to leading order, so its
141
+ imaginary part is a small difference (it would lose ~log10(2 nu^2/Im x^2)
142
+ digits). Instead, with rho = j_nu/j_{nu-1}, sigma = h_{nu-1}/h_nu and
143
+ J = Im(k* rho), K = Im(k sigma), the three-term recurrence gives
144
+
145
+ J_nu = |k|^2 r^2 (J_{nu+1} + 2 Im k Re rho_{nu+1}) / |2 nu + 1 - x rho_{nu+1}|^2 (downward)
146
+ K_nu = r (Im k^2 Re E + Re k^2 r K_{nu-1}) / |E|^2, E = 2 nu - 1 - x sigma_{nu-1} (upward)
147
+
148
+ both sums of positive terms at high order and damped in their direction.
149
+ Each order takes the direct value where that is well conditioned.
150
+ Returns (log G_jj, log(-G_hh), log G_jh), G_jj > 0 > G_hh.
151
+ """
152
+ x = k * radius
153
+ top = count + 1
154
+ log_psi, log_xi = log_riccati(np.array([x]), top + _RECURRENCE_HEADROOM + 1)
155
+ with np.errstate(all="ignore"):
156
+ m = abs(x.imag) # psi_{-1} = cos x, xi_{-1} = exp(i x)
157
+ log_psi = np.r_[m + np.log((np.exp(1j * x - m) + np.exp(-1j * x - m)) / 2), log_psi[0]]
158
+ log_xi = np.r_[1j * x, log_xi[0]]
159
+ lj, lh = log_psi - np.log(x), log_xi - np.log(x) # index nu + 1
160
+ with np.errstate(all="ignore"):
161
+ rho = np.exp(lj[1:] - lj[:-1]) # j_nu / j_{nu-1}, nu = 0..
162
+ sigma = np.exp(lh[:-1] - lh[1:]) # h_{nu-1} / h_nu
163
+ nu = np.arange(rho.size)
164
+ k2 = k * k
165
+ direct_j, direct_h = np.imag(np.conj(k) * rho), np.imag(k * sigma)
166
+ with np.errstate(all="ignore"):
167
+ cond_j = np.abs(k * rho) / np.abs(direct_j)
168
+ cond_h = np.abs(k * sigma) / np.abs(direct_h)
169
+ j_rec = np.empty(nu.size)
170
+ j_rec[-1] = direct_j[-1]
171
+ for i in range(nu.size - 2, -1, -1):
172
+ prev = _pick(direct_j[i + 1], cond_j[i + 1], j_rec[i + 1])
173
+ d = 2 * nu[i] + 1 - x * rho[i + 1]
174
+ j_rec[i] = abs(k) ** 2 * radius**2 * (prev + 2 * k.imag * rho[i + 1].real) / abs(d) ** 2
175
+ h_rec = np.empty(nu.size)
176
+ h_rec[0] = direct_h[0] # sigma_0 = i exactly
177
+ for i in range(1, nu.size):
178
+ prev = _pick(direct_h[i - 1], cond_h[i - 1], h_rec[i - 1])
179
+ e = 2 * nu[i] - 1 - x * sigma[i - 1]
180
+ h_rec[i] = radius * (k2.imag * e.real + k2.real * radius * prev) / abs(e) ** 2
181
+ big_j = np.where(cond_j < _DIRECT_MAX_CANCELLATION, direct_j, j_rec)[: top + 1]
182
+ big_k = np.where(cond_h < _DIRECT_MAX_CANCELLATION, direct_h, h_rec)[: top + 1]
183
+ n = slice(0, top + 1)
184
+ log_r2 = 2 * np.log(radius)
185
+ with np.errstate(all="ignore"):
186
+ log_gjj = log_r2 + 2 * lj[:-1][n].real + np.log(big_j) - np.log(k2.imag)
187
+ log_ghh = log_r2 + 2 * lh[1:][n].real + np.log(big_k) - np.log(k2.imag)
188
+ cross = np.conj(k) * rho[n] * np.conj(sigma[n]) - k
189
+ log_gjh = log_r2 + lj[:-1][n] + np.conj(lh[1:][n]) + np.log(cross) - np.log(2j * k2.imag)
190
+ return log_gjj, log_ghh, log_gjh
191
+
192
+
193
+ def _log_lommel_integrals(sol: Solution, a: int, count: int, log_amp_psi, log_amp_xi, boundary):
194
+ """The absorption integrals of :func:`_log_absorption_integrals` in closed form.
195
+
196
+ |A j + B h|^2 = |A|^2 |j|^2 + |B|^2 |h|^2 + 2 Re(A B* j h*), each integrated
197
+ with the boundary terms of :func:`_lommel_boundary` (``boundary[i]`` for
198
+ the outer and, for a shell, inner radius), and the TM integrand is
199
+ ((l+1)|f_{l-1}|^2 + l |f_{l+1}|^2)/(2l+1). O(L) instead of the O(L^2) of
200
+ quadrature. Returns (log TM, log TE, cancellation), the last being the
201
+ worst ratio of the summed magnitudes to the result.
202
+ """
203
+ l = sol.orders[:count]
204
+ la, lb = log_amp_psi[None, :], log_amp_xi[None, :]
205
+ nu = l[None, :] + np.array([-1, 0, 1])[:, None] # orders l-1, l, l+1
206
+ pieces = []
207
+ for sign, (gjj, ghh, gjh) in zip((1.0, -1.0), boundary):
208
+ pieces += [
209
+ (sign, 2 * la.real + gjj[nu]),
210
+ (-sign, 2 * lb.real + ghh[nu]),
211
+ (2 * sign, la + np.conj(lb) + gjh[nu]),
212
+ ]
213
+ with np.errstate(all="ignore"):
214
+ shift = np.max([np.real(t) for _, t in pieces], axis=(0, 1))
215
+ shift = np.where(np.isfinite(shift), shift, 0.0)
216
+ values = [c * np.real(np.exp(t - shift)) for c, t in pieces]
217
+ integral = sum(values)
218
+ scale = sum(np.abs(v) for v in values)
219
+ cancellation = float(np.max(np.where(scale > 0, scale / np.abs(integral), 1.0)))
220
+ tm = ((l + 1) * integral[0] + l * integral[2]) / (2 * l + 1)
221
+ return np.log(tm) + shift, np.log(integral[1]) + shift, cancellation
222
+
223
+
224
+ #: special functions of a quadrature rule are cached up to this many (node, order) values
225
+ _RULE_CACHE_SIZE = 4_000_000
226
+
227
+
228
+ def _quadrature_rule(sol: Solution, a: int, count: int, nodes=None):
229
+ """Weights, k r and (when small enough to keep) the special functions for shell ``a``."""
230
+ k = sol.k[0, a]
231
+ r_in = sol.radii[a - 1] if a else 0.0
232
+ r_out = sol.radii[a]
233
+ nodes = nodes or int(max(64, count + 48, 6 * abs(k) * (r_out - r_in)))
234
+ t, wt = gauss_legendre(nodes)
235
+ half = 0.5 * (r_out - r_in)
236
+ x = k * (half * t + 0.5 * (r_out + r_in))
237
+ logs = log_riccati(x, count) if nodes * count <= _RULE_CACHE_SIZE else None
238
+ return half * wt, x, logs
239
+
240
+
241
+ def _log_absorption_integrals(sol: Solution, a: int, rule, log_amp_psi, log_amp_xi):
242
+ """Logs of the absorption integrals of shell ``a`` for the field A psi_l + B xi_l:
243
+
244
+ TE: int |A j_l + B h_l|^2 r^2 dr
245
+ TM: ( l(l+1) int |A j_l + B h_l|^2 dr + int |A psi_l' + B xi_l'|^2 dr ) / |k|^2
246
+
247
+ by Gauss-Legendre quadrature with a per-order logarithmic shift, so any
248
+ order is representable. Large rules are processed in chunks of nodes
249
+ with a running shift, so memory stays bounded.
250
+ """
251
+ wt, x, logs = rule
252
+ L = log_amp_psi.size
253
+ l = sol.orders[:L]
254
+ k2 = abs(sol.k[0, a]) ** 2
255
+ step = max(1, _RULE_CACHE_SIZE // max(L, 1))
256
+ shift = np.full(L, -np.inf)
257
+ te = np.zeros(L)
258
+ tm = np.zeros(L)
259
+ for start in range(0, x.size, step):
260
+ part = slice(start, start + step)
261
+ log_psi, log_xi = (logs[0][part], logs[1][part]) if logs is not None else log_riccati(x[part], L)
262
+ xs, ws = x[part], wt[part]
263
+ tp = log_amp_psi + log_psi[:, l]
264
+ tx = log_amp_xi + log_xi[:, l]
265
+ tp_prev = log_amp_psi + log_psi[:, l - 1]
266
+ tx_prev = log_amp_xi + log_xi[:, l - 1]
267
+ with np.errstate(all="ignore"):
268
+ local = np.maximum(np.maximum(tp.real, tx.real), np.maximum(tp_prev.real, tx_prev.real)).max(axis=0)
269
+ new = np.fmax(shift, local)
270
+ new = np.where(np.isfinite(new), new, 0.0)
271
+ rescale = np.where(np.isfinite(shift), np.exp(2 * (shift - new)), 0.0)
272
+ u = np.exp(tp - new) + np.exp(tx - new)
273
+ du = np.exp(tp_prev - new) + np.exp(tx_prev - new) - l * u / xs[:, None]
274
+ u2 = u.real**2 + u.imag**2
275
+ te = te * rescale + (ws @ u2) / k2
276
+ tm = (
277
+ tm * rescale
278
+ + (l * (l + 1) * (ws @ (u2 / (xs.real**2 + xs.imag**2)[:, None])) + ws @ (du.real**2 + du.imag**2)) / k2
279
+ )
280
+ shift = new
281
+ with np.errstate(divide="ignore"):
282
+ return np.log(tm) + 2 * shift, np.log(te) + 2 * shift
283
+
284
+
285
+ def _converged_sum(terms, tol):
286
+ """Sum over orders (axis 1 of (P, L, K)) with a remainder-based convergence test.
287
+
288
+ A position whose terms turn non-finite is summed up to that order and
289
+ flagged unconverged.
290
+ """
291
+ P, L, K = terms.shape
292
+ finite = np.all(np.isfinite(terms), axis=2)
293
+ stop = np.where(finite.all(axis=1), L, np.argmin(finite, axis=1))
294
+ upto = np.arange(L)[None, :] < stop[:, None]
295
+ total = np.where(upto[..., None], terms, 0).sum(axis=1)
296
+ last = np.clip(stop[:, None] - 4 + np.arange(4)[None, :], 0, L - 1)
297
+ tail = _tail_estimate(np.moveaxis(np.take_along_axis(terms, last[..., None], axis=1), 1, 0))
298
+ scale = np.maximum(np.abs(total), np.finfo(float).tiny)
299
+ converged = (stop == L) & np.all(tail <= tol * scale, axis=1)
300
+ total[stop == 0] = np.nan
301
+ return total, np.where(stop >= 4, stop, 0), converged
302
+
303
+
304
+ def _emitter_side(sol, d, x, pol):
305
+ """Logs of the regular/outgoing solutions and their derivatives at the emitter."""
306
+ l = sol.orders
307
+ log_psi, log_xi = log_riccati(x.astype(complex), l.size)
308
+ lp, lx = log_psi[:, l], log_xi[:, l]
309
+ log_r, log_s = sol.log_r[pol, d, 0], sol.log_s[pol, d, 0]
310
+ with np.errstate(all="ignore"):
311
+ rho = np.exp(log_r + lx - lp)
312
+ sigma = np.exp(log_s + lp - lx)
313
+ d_psi = np.exp(log_psi[:, l - 1] - lp) - l / x[:, None]
314
+ d_xi = np.exp(log_xi[:, l - 1] - lx) - l / x[:, None]
315
+ return {
316
+ "u_in": lp + _log(1 + rho),
317
+ "du_in": lp + _log(d_psi + rho * d_xi),
318
+ "u_out": lx + _log(1 + sigma),
319
+ "du_out": lx + _log(sigma * d_psi + d_xi),
320
+ "log_delta": np.log(1 - np.exp(log_r + log_s)),
321
+ "log_b_out": sol.log_b_out[pol, d, 0],
322
+ "log_a": sol.log_a[pol, d, 0],
323
+ "log_r": log_r,
324
+ "log_s": log_s,
325
+ "lp": lp,
326
+ "lx": lx,
327
+ "lp_d": lp + _log(d_psi),
328
+ "lx_d": lx + _log(d_xi),
329
+ }
330
+
331
+
332
+ def _ldos(side, derivative):
333
+ """Re[u_in u_out / Delta] written so every term keeps full relative accuracy:
334
+ psi xi + (R xi^2 + S psi^2 + 2 R S psi xi) / (1 - R S), with Re(psi xi) = psi^2
335
+ for real x (Re(psi xi) itself is ~1e-600 at high l while Im(psi xi) ~ 1/l)."""
336
+ p, x = (side["lp_d"], side["lx_d"]) if derivative else (side["lp"], side["lx"])
337
+ lr, ls = side["log_r"], side["log_s"]
338
+ with np.errstate(all="ignore"):
339
+ bracket = np.exp(lr + 2 * x) + np.exp(ls + 2 * p) + 2 * np.exp(lr + ls + p + x)
340
+ return np.exp(2 * p).real + (bracket * np.exp(-side["log_delta"])).real
341
+
342
+
343
+ class _ShellLosses:
344
+ """Loss integrals of the absorbing shells of a single-wavelength Solution, cached.
345
+
346
+ ``log_loss(a, side, pol, count)`` is the log of int (Im eps |E|^2 + Im mu |H|^2) over shell a
347
+ of the regular (``side='below'``, plane-wave normalisation) or outgoing (``'above'``, B = 1 in
348
+ the host) solution of polarisation ``pol``, orders 1..count (-inf beyond), from closed-form
349
+ Lommel boundary terms or, for weak loss or a closed form that cancels, Gauss-Legendre quadrature.
350
+ """
351
+
352
+ def __init__(self, sol: Solution, quadrature_nodes=None):
353
+ self.sol, self.nodes = sol, quadrature_nodes
354
+ n, mu = sol.n[0], sol.mu[0]
355
+ eps = n**2 / mu
356
+ # Ohmic loss Im(eps)|E|^2 and magnetic loss Im(mu)|H|^2; in shell a, H = -i (n/mu) x (E of the
357
+ # other polarisation's radial form), so the magnetic loss uses the other integral kind, times |eps/mu|
358
+ self.loss_e = np.where(eps.imag > 0, eps.imag, 0.0)
359
+ self.loss_m = np.where(mu.imag > 0, mu.imag * np.abs(eps / mu), 0.0)
360
+ self.absorbing = [a for a in range(sol.n_shells) if self.loss_e[a] > 0 or self.loss_m[a] > 0]
361
+ # interfaces with a response (sheets, d-parameters) absorb through the jump of the radial flux
362
+ self.surfaces = []
363
+ if sol.responses is not None:
364
+ on = sum(np.abs(v[0]) + np.abs(v[1]) + np.abs(v[3]) for v in sol.responses.values()) # (W, N, L)
365
+ self.surfaces = [j for j in range(sol.radii.size) if np.any(on[0, j] != 0)]
366
+ self.cache, self.rules, self.boundaries = {}, {}, {}
367
+
368
+ def integral(self, a, side, pol, count, kind):
369
+ sol, L = self.sol, self.sol.orders.size
370
+ k = sol.k[0]
371
+ key = (a, side, pol, count, kind)
372
+ if key not in self.cache:
373
+ if side == "below": # regular solution in shell a, plane-wave normalisation
374
+ la, lb = sol.log_a[pol, a, 0], sol.log_b[pol, a, 0]
375
+ else: # outgoing solution in shell a, B = 1 in the host
376
+ lb = sol.log_b_out[pol, a, 0]
377
+ la = lb + sol.log_s[pol, a, 0]
378
+ la, lb = la[:count], lb[:count]
379
+ result, cancellation = None, np.inf
380
+ if self.nodes is None and abs(k[a].imag) >= WEAK_LOSS * abs(k[a]):
381
+ if (a, count) not in self.boundaries:
382
+ radii = [sol.radii[a]] + ([sol.radii[a - 1]] if a else [])
383
+ self.boundaries[a, count] = [_lommel_boundary(k[a], radius, count) for radius in radii]
384
+ *result, cancellation = _log_lommel_integrals(sol, a, count, la, lb, self.boundaries[a, count])
385
+ if cancellation > _LOMMEL_MAX_CANCELLATION: # weak loss, or a closed form that cancels
386
+ if (a, count) not in self.rules:
387
+ self.rules[a, count] = _quadrature_rule(sol, a, count, self.nodes)
388
+ result = _log_absorption_integrals(sol, a, self.rules[a, count], la, lb)
389
+ for which in (0, 1):
390
+ v = np.full(L, -np.inf, dtype=complex)
391
+ v[:count] = result[which]
392
+ self.cache[a, side, pol, count, which] = v
393
+ return self.cache[key]
394
+
395
+ def log_loss(self, a, side, pol, count):
396
+ """log of the loss integral of shell a for polarisation pol, electric plus magnetic."""
397
+ kind = {TM: 0, TE: 1}[pol]
398
+ loss_e, loss_m = self.loss_e[a], self.loss_m[a]
399
+ with np.errstate(divide="ignore"):
400
+ electric = np.log(loss_e) + self.integral(a, side, pol, count, kind).real if loss_e > 0 else None
401
+ magnetic = np.log(loss_m) + self.integral(a, side, pol, count, 1 - kind).real if loss_m > 0 else None
402
+ if electric is None:
403
+ return magnetic
404
+ return electric if magnetic is None else np.logaddexp(electric, magnetic)
405
+
406
+ def log_surface_loss(self, j, side, pol, count):
407
+ """log of the power absorbed at interface j (a sheet or d-parameters) by the regular (``'below'``)
408
+ or outgoing (``'above'``) solution of polarisation ``pol``, in the units of :meth:`log_loss`: the
409
+ jump of the radial flux, Phi = Re(i u u'* c)/k0 with c = 1/(k mu*) (TE) or 1/(k* mu) (TM), so that
410
+ a shell absorbs [Phi]/k0 between its boundaries. The matching without the response conserves the
411
+ flux exactly, so the jump is written with the response terms alone (no cancellation); complex
412
+ (a negative jump, from an active response, has imaginary part pi)."""
413
+ key = ("surface", j, side, pol, count)
414
+ if key not in self.cache:
415
+ sol = self.sol
416
+ L, l = sol.orders.size, sol.orders
417
+ k, mu = sol.k[0], sol.mu[0]
418
+ k0 = 2 * np.pi / sol.wavelength[0]
419
+ if side == "below":
420
+ la, lb = sol.log_a[pol, j, 0], sol.log_b[pol, j, 0]
421
+ else:
422
+ lb = sol.log_b_out[pol, j, 0]
423
+ la = lb + sol.log_s[pol, j, 0]
424
+ x = k[j] * sol.radii[j]
425
+ lps, lxs = log_riccati(np.array([x]), L + 1)
426
+ lp, lx = lps[0, l], lxs[0, l]
427
+ with np.errstate(all="ignore"):
428
+ d1 = np.exp(lps[0, l - 1] - lp) - l / x # psi'/psi
429
+ d3 = np.exp(lxs[0, l - 1] - lx) - l / x # xi'/xi
430
+ log_u = _log_add(la + lp, lb + lx)
431
+ w1, w3 = np.exp(la + lp - log_u), np.exp(lb + lx - log_u)
432
+ w1, w3 = np.where(np.isfinite(w1), w1, 0), np.where(np.isfinite(w3), w3, 0)
433
+ D = w1 * d1 + w3 * d3 # u'/u
434
+ tv, td, tau, tau_m1 = (v[0, j] for v in sol.responses[pol])
435
+ eta, mr = sol.n[0, j] / sol.n[0, j + 1], mu[j] / mu[j + 1]
436
+ c_v, c_d = (mr, eta) if pol == TM else (eta, mr)
437
+ plain = (1 / c_v, D / c_d)
438
+ delta = ((tau_m1 + tau * tv * D) / c_v, (tau_m1 * D - tau * td) / c_d)
439
+ c = 1 / (k[j + 1] * np.conj(mu[j + 1])) if pol == TE else 1 / (np.conj(k[j + 1]) * mu[j + 1])
440
+ form = plain[0] * np.conj(delta[1]) + delta[0] * np.conj(plain[1]) + delta[0] * np.conj(delta[1])
441
+ q = -np.real(1j * c * form) / k0**2
442
+ v = np.full(L, -np.inf, dtype=complex)
443
+ v[:count] = (2 * log_u.real + np.log(q + 0j))[:count]
444
+ self.cache[key] = v
445
+ return self.cache[key]
446
+
447
+ def orders(self, r, tol, L):
448
+ """Orders the loss series need: geometric in (r_< / r_>)^(2l) at the absorbing shells' nearest boundary."""
449
+ sol = self.sol
450
+ bounds = [b for a in self.absorbing for b in ((sol.radii[a - 1],) if a else ()) + (sol.radii[a],)]
451
+ bounds = np.array(bounds + [sol.radii[j] for j in self.surfaces])
452
+ if not bounds.size:
453
+ return L
454
+ q = np.max(np.minimum(bounds[None, :] / r[:, None], r[:, None] / bounds[None, :]))
455
+ return int(min(L, max(32, _orders_needed(q, tol, L))))
456
+
457
+
458
+ def _rates_one(sol: Solution, r, shells, normalization, dipole, tol, quadrature_nodes, parts="all"):
459
+ """Terms of the six l-sums for a single-wavelength Solution; returns the converged sums.
460
+
461
+ ``parts='nonradiative'`` computes only the loss sums (the others stay 0)."""
462
+ l = sol.orders
463
+ L = l.size
464
+ n, mu = sol.n[0], sol.mu[0]
465
+ k = sol.k[0]
466
+ radial_pol, other_pol = (TM, TE) if dipole == "electric" else (TE, TM)
467
+ losses = _ShellLosses(sol, quadrature_nodes)
468
+ absorbing = losses.absorbing
469
+ # The Ohmic-loss series converges like (r_< / r_>)^(2l) at the absorbing shell's
470
+ # nearest boundary - usually far sooner than the LDOS series, which also feels
471
+ # nearby dielectric interfaces. Integrate only that many orders (checked below).
472
+ l_abs = losses.orders(r, tol, L)
473
+ log_loss = losses.log_loss
474
+
475
+ c_radial = l * (l + 1) * (2 * l + 1)
476
+ c_tangential = 2 * l + 1
477
+ terms = np.zeros((r.size, L, 6))
478
+ for d in np.unique(shells):
479
+ idx = np.flatnonzero(shells == d)
480
+ x = (k[d] * r[idx]).real # the emitter's shell is lossless
481
+ f_rad, f_nonrad, f_tot = _normalization(n, mu, d, normalization, dipole)
482
+ e = _emitter_side(sol, d, x, radial_pol)
483
+ m = _emitter_side(sol, d, x, other_pol)
484
+ xx = x[:, None]
485
+
486
+ def mag2(v):
487
+ with np.errstate(under="ignore", over="ignore"):
488
+ return np.exp(2 * np.real(v))
489
+
490
+ if parts == "all":
491
+ # radiative: F = u_in / (B_out,d Delta)
492
+ f_e = e["u_in"] - e["log_b_out"] - e["log_delta"]
493
+ df_e = e["du_in"] - e["log_b_out"] - e["log_delta"]
494
+ f_m = m["u_in"] - m["log_b_out"] - m["log_delta"]
495
+ terms[idx, :, 0] = 1.5 / xx**4 * f_rad * c_radial * mag2(f_e)
496
+ terms[idx, :, 1] = 0.75 / xx**2 * f_rad * c_tangential * (mag2(f_m) + mag2(df_e))
497
+
498
+ # total, from the local density of states
499
+ terms[idx, :, 4] = 1.5 / xx**4 * f_tot * c_radial * _ldos(e, False)
500
+ terms[idx, :, 5] = 0.75 / xx**2 * f_tot * c_tangential * (_ldos(m, False) + _ldos(e, True))
501
+
502
+ def nonradiative(count):
503
+ out = np.zeros((idx.size, L, 2))
504
+ kd3 = k[d].real ** 3
505
+ for a in absorbing:
506
+ pre = kd3 * f_nonrad
507
+ if a < d: # regular solution in a, amplitude u_out(x)/Delta / A_d
508
+ side, amp, ref = "below", "u_out", "log_a"
509
+ else: # outgoing solution in a, amplitude u_in(x)/Delta / B_out,d
510
+ side, amp, ref = "above", "u_in", "log_b_out"
511
+ c_e = e[amp] - e["log_delta"] - e[ref]
512
+ dc_e = e["d" + amp] - e["log_delta"] - e[ref]
513
+ c_m = m[amp] - m["log_delta"] - m[ref]
514
+ i_e = log_loss(a, side, radial_pol, count)
515
+ i_m = log_loss(a, side, other_pol, count)
516
+ with np.errstate(under="ignore"):
517
+ out[:, :, 0] += 1.5 / xx**4 * pre * c_radial * np.exp(i_e + 2 * c_e.real)
518
+ out[:, :, 1] += (
519
+ 0.75 / xx**2 * pre * c_tangential * (np.exp(i_m + 2 * c_m.real) + np.exp(i_e + 2 * dc_e.real))
520
+ )
521
+ return out
522
+
523
+ nonrad = nonradiative(l_abs)
524
+ if l_abs < L:
525
+ total = np.abs(nonrad.sum(axis=1))
526
+ tail = _tail_estimate(np.moveaxis(nonrad[:, :l_abs], 1, 0))
527
+ if not np.all(tail <= 0.1 * tol * np.maximum(total, np.finfo(float).tiny)):
528
+ nonrad = nonradiative(L)
529
+ terms[idx, :, 2:4] = nonrad
530
+ return _converged_sum(terms, tol)
531
+
532
+
533
+ def _rates_normalized(radii, n, mu, wavelength, r, shells, l_max, tol, normalization, dipole, nodes, l_cap):
534
+ """Total, radiative and shift from the normalized formulation, losses from the logarithmic route.
535
+
536
+ Positions are summed until the complex series (rate and shift) and the loss sums meet ``tol``;
537
+ those that do not are recomputed with twice the orders (up to ``l_cap``). Returns the rates,
538
+ the orders used and convergence per position, all in the requested normalization."""
539
+ from .normalized import _dipole_sums, _starting_order, _Sweep
540
+
541
+ k = 2 * np.pi * n / wavelength
542
+ eps = n**2 / mu
543
+ absorbing = bool(np.any((eps[:-1].imag != 0) | (mu[:-1].imag != 0)))
544
+ P = r.size
545
+ g = np.zeros((P, 2), complex)
546
+ rad = np.zeros((P, 2))
547
+ nonrad = np.zeros((P, 2))
548
+ used = np.zeros(P, dtype=int)
549
+ ok = np.zeros(P, dtype=bool)
550
+ todo = np.arange(P)
551
+ L = int(l_max) if l_max is not None else _starting_order(radii, n, wavelength, r, tol, l_cap)
552
+ while todo.size:
553
+ g[todo], rad[todo], ok_core = _dipole_sums(_Sweep(radii, n, mu, k, L), r[todo], dipole, tol)
554
+ ok_loss = np.ones(todo.size, dtype=bool)
555
+ if absorbing:
556
+ sol = solve(radii, n, wavelength, mu, L)
557
+ values, _, ok_loss = _rates_one(sol, r[todo], shells[todo], "shell", dipole, tol, nodes, "nonradiative")
558
+ nonrad[todo] = values[:, 2:4]
559
+ used[todo] = L
560
+ ok[todo] = ok_core & ok_loss
561
+ if l_max is not None or L >= l_cap:
562
+ break
563
+ todo = todo[~ok[todo]]
564
+ L = min(2 * L, l_cap)
565
+ ratio = np.array([_normalization(n, mu, d, normalization, dipole)[2] for d in shells])[:, None]
566
+ return ratio * (1 + g.real), ratio * rad, ratio * nonrad, ratio * g.imag / 2, used, ok
567
+
568
+
569
+ def _rates_with_sheets(
570
+ radii, n, wavelength, r, mu, l_max, tol, normalization, dipole, nodes, l_cap, warn, sheets, route="auto"
571
+ ):
572
+ """Radial and tangential dipoles through :func:`~pystratify.emission_rates` (normalized by default)."""
573
+ from .rates import emission_rates
574
+
575
+ positions = np.column_stack([np.zeros_like(r), np.zeros_like(r), r])
576
+ runs = [
577
+ emission_rates(
578
+ radii,
579
+ n,
580
+ wavelength,
581
+ positions,
582
+ moment,
583
+ mu=mu,
584
+ dipole=dipole,
585
+ l_max=l_max,
586
+ tol=tol,
587
+ l_cap=l_cap,
588
+ quadrature_nodes=nodes,
589
+ warn=False,
590
+ sheets=sheets,
591
+ normalization="layer" if normalization == "shell" else "host",
592
+ route=route,
593
+ ) # fmt: skip
594
+ for moment in ([0, 0, 1.0], [1.0, 0, 0])
595
+ ]
596
+ ok = runs[0].converged & runs[1].converged
597
+ notes = tuple(dict.fromkeys(note for run in runs for note in run.notes))
598
+ if warn and not ok.all():
599
+ warnings.warn(notes[-1] if notes else "l-sum not converged", RuntimeWarning, stacklevel=3)
600
+ return DecayRates(
601
+ r=r,
602
+ radiative=np.stack([run.radiative for run in runs], axis=1),
603
+ nonradiative=np.stack([run.nonradiative for run in runs], axis=1),
604
+ total=np.stack([run.total for run in runs], axis=1),
605
+ shell=runs[0].shell,
606
+ orders_used=np.full(r.size, max(run.orders_used for run in runs)),
607
+ converged=ok,
608
+ normalization=normalization,
609
+ dipole=dipole,
610
+ notes=notes,
611
+ shift=np.stack([run.shift for run in runs], axis=1),
612
+ route=runs[0].route,
613
+ )
614
+
615
+
616
+ def decay_rates(
617
+ radii,
618
+ n,
619
+ wavelength,
620
+ r,
621
+ mu=None,
622
+ l_max=None,
623
+ tol=1e-6,
624
+ normalization="host",
625
+ dipole="electric",
626
+ quadrature_nodes=None,
627
+ l_cap=20000,
628
+ warn=True,
629
+ sheets=None,
630
+ route="auto",
631
+ ) -> DecayRates:
632
+ """Radiative, nonradiative and total decay rates and the frequency shift of a dipole emitter.
633
+
634
+ Parameters
635
+ ----------
636
+ radii, n, wavelength, mu : geometry and materials at one wavelength (see :func:`~pystratify.solve`).
637
+ r : emitter distance(s) from the centre; not in an absorbing or gain
638
+ shell, and the host must be lossless.
639
+ l_max : truncation; ``None`` starts from a geometric estimate and doubles
640
+ until every sum meets ``tol`` (up to ``l_cap``).
641
+ tol : relative accuracy of every l-sum (remainder estimated from the
642
+ geometric decay of the last terms).
643
+ normalization : ``'host'`` or ``'shell'`` - free-space rate in the host or
644
+ in the emitter's own shell.
645
+ dipole : ``'electric'`` or ``'magnetic'``.
646
+ quadrature_nodes : ``None`` integrates the Ohmic loss from closed-form
647
+ (Lommel) boundary terms, falling back to Gauss-Legendre quadrature for
648
+ weakly lossy shells; a number forces quadrature with that many nodes.
649
+ l_cap : most orders tried; the cost is linear in the order, so 20000
650
+ (an emitter 1 nm from a 1.3-um sphere at tol = 1e-9) takes ~1 s.
651
+ sheets : 2D materials on interfaces (see :mod:`pystratify.sheets`); their
652
+ absorption is part of the nonradiative rate. Computed by
653
+ :func:`~pystratify.emission_rates` (same ``route``).
654
+ route : ``'auto'`` (default) or ``'normalized'`` - total, radiative and shift
655
+ from the normalized formulation, the loss integrals from the logarithmic
656
+ route - or ``'log'`` - everything from the logarithmic route, without the
657
+ shift. Limitation: the loss integrals (``nonradiative``) use the amplitudes
658
+ of the logarithmic solver on every route, accurate to ~1e-13 relative 1 nm
659
+ from an absorbing layer, ~1e-12 at 0.1 nm and ~1e-11 at 0.05 nm (where 10^3 to
660
+ 10^4 orders are summed); total, radiative and shift are not affected, and
661
+ ``balance_error`` shows the difference.
662
+ """
663
+ radii = np.atleast_1d(np.asarray(radii, dtype=float))
664
+ n = np.atleast_1d(np.asarray(n, dtype=complex))
665
+ mu = np.ones(n.size, dtype=complex) if mu is None else np.atleast_1d(np.asarray(mu, dtype=complex))
666
+ r = np.atleast_1d(np.asarray(r, dtype=float))
667
+ if dipole not in ("electric", "magnetic"):
668
+ raise ValueError("dipole must be 'electric' or 'magnetic'")
669
+ if normalization not in ("host", "shell"):
670
+ raise ValueError("normalization must be 'host' or 'shell'")
671
+ if np.ndim(wavelength) != 0:
672
+ raise ValueError("decay_rates takes a single wavelength")
673
+ if n.shape != (radii.size + 1,) or mu.shape != n.shape:
674
+ raise ValueError(f"n and mu need shape ({radii.size + 1},), host last")
675
+ eps = n**2 / mu
676
+ if eps[-1].imag != 0 or n[-1].imag != 0:
677
+ raise ValueError("the host must be lossless for decay rates to be defined")
678
+ if np.any(~np.isfinite(r)) or np.any(r <= 0):
679
+ raise ValueError("emitter positions must be positive and finite")
680
+ shells = locate_shell(radii, r)
681
+ if np.any(eps[shells].imag != 0) or np.any(n[shells].imag != 0):
682
+ raise ValueError("emitter inside an absorbing or gain shell: the rates are undefined")
683
+ if route not in ("auto", "normalized", "log"):
684
+ raise ValueError("route must be 'auto', 'normalized' or 'log'")
685
+ if sheets:
686
+ return _rates_with_sheets(
687
+ radii, n, wavelength, r, mu, l_max, tol, normalization, dipole, quadrature_nodes, l_cap, warn, sheets,
688
+ route,
689
+ ) # fmt: skip
690
+ if route != "log":
691
+ total, radiative, nonradiative, shift, used, ok = _rates_normalized(
692
+ radii, n, mu, wavelength, r, shells, l_max, tol, normalization, dipole, quadrature_nodes, l_cap
693
+ )
694
+ notes = ()
695
+ if not ok.all():
696
+ notes = (
697
+ f"l-sum not converged to tol={tol:g} at {np.count_nonzero(~ok)} of {ok.size} position(s) "
698
+ f"with l_max={used.max()}; the emitter is very close to an interface",
699
+ )
700
+ if warn:
701
+ warnings.warn(notes[0], RuntimeWarning, stacklevel=2)
702
+ return DecayRates(
703
+ r=r,
704
+ radiative=radiative,
705
+ nonradiative=nonradiative,
706
+ total=total,
707
+ shell=shells,
708
+ orders_used=used,
709
+ converged=ok,
710
+ normalization=normalization,
711
+ dipole=dipole,
712
+ notes=notes,
713
+ shift=shift,
714
+ route="normalized",
715
+ )
716
+
717
+ if l_max is not None:
718
+ sol = solve(radii, n, wavelength, mu, int(l_max))
719
+ values, used, ok = _rates_one(sol, r, shells, normalization, dipole, tol, quadrature_nodes)
720
+ else:
721
+ # the slowest series decays like (r_< / r_>)^(2l) at the interface nearest the
722
+ # emitter: start where that reaches tol, then double while not converged
723
+ q = np.max(np.minimum(radii[None, :] / r[:, None], r[:, None] / radii[None, :]))
724
+ L = int(
725
+ min(l_cap, max(32, truncation_order(radii[-1], n[-1], wavelength, "near"), _orders_needed(q, tol, l_cap)))
726
+ )
727
+ while True:
728
+ sol = solve(radii, n, wavelength, mu, L)
729
+ values, used, ok = _rates_one(sol, r, shells, normalization, dipole, tol, quadrature_nodes)
730
+ if ok.all() or L >= l_cap:
731
+ break
732
+ L = min(2 * L, l_cap)
733
+ notes = ()
734
+ if not ok.all():
735
+ notes = (
736
+ f"l-sum not converged to tol={tol:g} at {np.count_nonzero(~ok)} of {ok.size} position(s) "
737
+ f"with l_max={sol.orders.size}; the emitter is very close to an interface",
738
+ )
739
+ if warn:
740
+ warnings.warn(notes[0], RuntimeWarning, stacklevel=2)
741
+ return DecayRates(
742
+ r=r,
743
+ radiative=values[:, 0:2],
744
+ nonradiative=values[:, 2:4],
745
+ total=values[:, 4:6],
746
+ shell=shells,
747
+ orders_used=used,
748
+ converged=ok,
749
+ normalization=normalization,
750
+ dipole=dipole,
751
+ notes=notes,
752
+ shift=np.full((r.size, 2), np.nan),
753
+ route="log",
754
+ )