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/PLANAR-LICENSE.txt +7 -0
- pystratify/__init__.py +125 -0
- pystratify/casimir.py +97 -0
- pystratify/chiral.py +426 -0
- pystratify/convergence.py +48 -0
- pystratify/cylinder_emission.py +264 -0
- pystratify/cylindrical.py +177 -0
- pystratify/decay.py +754 -0
- pystratify/drude.py +82 -0
- pystratify/emission.py +617 -0
- pystratify/energy.py +274 -0
- pystratify/ensemble.py +180 -0
- pystratify/farfield.py +408 -0
- pystratify/focused.py +1287 -0
- pystratify/integration.py +43 -0
- pystratify/nearfield.py +192 -0
- pystratify/normalized.py +798 -0
- pystratify/observables.py +192 -0
- pystratify/pairs.py +208 -0
- pystratify/planar.py +1812 -0
- pystratify/planar_emission.py +232 -0
- pystratify/problem.py +152 -0
- pystratify/rates.py +1334 -0
- pystratify/references.py +527 -0
- pystratify/response.py +116 -0
- pystratify/riccati.py +171 -0
- pystratify/sheets.py +239 -0
- pystratify/solver.py +324 -0
- pystratify/special.py +50 -0
- pystratify-0.10.2.dist-info/METADATA +616 -0
- pystratify-0.10.2.dist-info/RECORD +34 -0
- pystratify-0.10.2.dist-info/WHEEL +5 -0
- pystratify-0.10.2.dist-info/licenses/LICENSE +674 -0
- pystratify-0.10.2.dist-info/top_level.txt +1 -0
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
|
+
)
|