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
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Multipole truncation order for a sphere of given size."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
__all__ = ["truncation_order", "tail_estimate", "orders_needed"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def truncation_order(radius, n_host, wavelength, regime="far") -> int:
|
|
11
|
+
"""Number of multipole orders needed for a sphere of outer radius ``radius``.
|
|
12
|
+
|
|
13
|
+
``regime='far'``: cross sections and far field, Wiscombe, Appl. Opt. 19,
|
|
14
|
+
1505 (1980). ``regime='near'``: fields at the surface, Allardice & Le Ru,
|
|
15
|
+
Appl. Opt. 53, 7224 (2014). ``wavelength`` may be an array (the shortest
|
|
16
|
+
is used) and ``n_host`` complex (its modulus is used).
|
|
17
|
+
"""
|
|
18
|
+
x = 2 * np.pi * float(radius) * abs(complex(n_host)) / float(np.min(wavelength))
|
|
19
|
+
if not np.isfinite(x) or x <= 0:
|
|
20
|
+
raise ValueError("radius, n_host and wavelength must give a positive, finite size parameter")
|
|
21
|
+
if regime == "near":
|
|
22
|
+
order = x + 11 * x ** (1 / 3) + 1
|
|
23
|
+
elif regime == "far":
|
|
24
|
+
order = x + 4 * x ** (1 / 3) + (1 if x <= 8 else 2)
|
|
25
|
+
else:
|
|
26
|
+
raise ValueError("regime must be 'far' or 'near'")
|
|
27
|
+
return max(1, int(order))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def tail_estimate(terms):
|
|
31
|
+
"""Remainder of a series estimated from its last terms (axis 0 = order).
|
|
32
|
+
|
|
33
|
+
The decay-rate series are asymptotically geometric in l, ratio
|
|
34
|
+
(r_< / r_>)^2 times a polynomial, so the neglected tail is ~ t_L q/(1 - q)
|
|
35
|
+
with q the worst recent ratio: far larger than t_L when q -> 1. +inf
|
|
36
|
+
where the terms are not decreasing.
|
|
37
|
+
"""
|
|
38
|
+
a = np.abs(terms[-4:])
|
|
39
|
+
with np.errstate(all="ignore"):
|
|
40
|
+
q = np.max(a[1:] / a[:-1], axis=0)
|
|
41
|
+
return np.where(a[-1] == 0, 0.0, np.where(q < 1, a[-1] * q / (1 - q), np.inf))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def orders_needed(q, tol, l_cap):
|
|
45
|
+
"""Orders for a geometric series of ratio q^2 to leave a tail below tol."""
|
|
46
|
+
if q >= 1:
|
|
47
|
+
return l_cap
|
|
48
|
+
return 1.25 * np.log(tol * (1 - q * q)) / (2 * np.log(q)) + 16
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Three-dimensional point dipoles: cylindrical orders and continuous axial spectrum."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, replace
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from scipy.optimize import minimize_scalar, brentq
|
|
7
|
+
|
|
8
|
+
from .cylindrical import solve_cylinder, vectors
|
|
9
|
+
from .special import cylinder_pair
|
|
10
|
+
from .integration import integrate
|
|
11
|
+
from .response import source_coefficients
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class CylinderRates:
|
|
16
|
+
total: np.ndarray
|
|
17
|
+
escape: np.ndarray
|
|
18
|
+
guided: np.ndarray
|
|
19
|
+
absorbed: np.ndarray
|
|
20
|
+
error: float
|
|
21
|
+
evaluations: int
|
|
22
|
+
orders: int
|
|
23
|
+
converged: bool
|
|
24
|
+
balance_error: np.ndarray
|
|
25
|
+
poles: tuple
|
|
26
|
+
grazing_error: float = 0.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def axis_vectors(beta, k, q, orders):
|
|
30
|
+
M, N = np.zeros((len(orders), 3), complex), np.zeros((len(orders), 3), complex)
|
|
31
|
+
for i, m in enumerate(orders):
|
|
32
|
+
if m == 0:
|
|
33
|
+
N[i, 2] = q / k
|
|
34
|
+
elif m == 1:
|
|
35
|
+
M[i, :2], N[i, :2] = (1j / 2, -.5), (1j * beta / (2 * k), -beta / (2 * k))
|
|
36
|
+
elif m == -1:
|
|
37
|
+
M[i, :2], N[i, :2] = (1j / 2, .5), (-1j * beta / (2 * k), -beta / (2 * k))
|
|
38
|
+
return M, N
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class CylinderSource:
|
|
42
|
+
def __init__(self, radii, n, wavelength, radius, dipole="electric", m_max=None, tolerance=1e-6):
|
|
43
|
+
self.radii, self.n = np.asarray(radii, float), np.asarray(n, complex)
|
|
44
|
+
self.wavelength, self.radius = float(wavelength), float(radius)
|
|
45
|
+
self.layer = int(np.searchsorted(self.radii, radius))
|
|
46
|
+
if radius < 0 or not np.isfinite(radius) or wavelength <= 0:
|
|
47
|
+
raise ValueError("source radius must be nonnegative and wavelength positive")
|
|
48
|
+
if dipole not in ("electric", "magnetic"):
|
|
49
|
+
raise ValueError("dipole must be electric or magnetic")
|
|
50
|
+
if any(self.n[j].imag != 0 or self.n[j].real <= 0 for j in (self.layer, len(n) - 1)):
|
|
51
|
+
raise ValueError("source medium and exterior must be lossless with positive index")
|
|
52
|
+
self.mu = np.ones_like(self.n) if dipole == "electric" else self.n ** 2
|
|
53
|
+
self.k0 = 2 * np.pi / wavelength
|
|
54
|
+
self.ks = self.k0 * self.n[self.layer].real
|
|
55
|
+
self.tolerance = tolerance
|
|
56
|
+
gap = np.min(np.abs(self.radii - radius))
|
|
57
|
+
if gap == 0:
|
|
58
|
+
raise ValueError("source cannot lie on an interface")
|
|
59
|
+
self.maximum = max(2.0, np.max(np.abs(self.n)) / self.n[self.layer].real + 16 / (self.ks * gap))
|
|
60
|
+
qratio = max(np.minimum(self.radii / max(radius, 1e-300), radius / self.radii)) if radius else 0
|
|
61
|
+
size = self.k0 * max(abs(self.n)) * self.radii[-1]
|
|
62
|
+
needed = max(8, int(np.ceil(size + 4 * size ** (1 / 3) + 8)), int(np.ceil(-np.log(tolerance) / max(1e-8, -2 * np.log(max(qratio, 1e-300))))) + 10)
|
|
63
|
+
self.m_max = int(m_max) if m_max is not None else (1 if radius == 0 else min(1500, needed))
|
|
64
|
+
self.explicit_order = m_max is not None
|
|
65
|
+
|
|
66
|
+
def solution(self, b, maximum=None):
|
|
67
|
+
return solve_cylinder(self.radii, self.n, self.wavelength, mu=self.mu, beta=self.ks * b, m_max=self.m_max if maximum is None else maximum)
|
|
68
|
+
|
|
69
|
+
def coefficients(self, solution):
|
|
70
|
+
d, radius, orders = self.layer, self.radius, solution.orders
|
|
71
|
+
k, beta, q = self.ks, solution.beta, solution.q[d]
|
|
72
|
+
if radius == 0:
|
|
73
|
+
M, N = axis_vectors(beta, k, q, orders)
|
|
74
|
+
Mr, Nr = axis_vectors(-beta, k, q, -orders)
|
|
75
|
+
parity = np.where(np.abs(orders) % 2, -1, 1)[:, None]
|
|
76
|
+
Mr, Nr = parity * Mr, parity * Nr
|
|
77
|
+
direct = np.stack((Nr, Mr), axis=-2)
|
|
78
|
+
S = np.exp(solution.log_s[0])
|
|
79
|
+
reflected = S @ direct
|
|
80
|
+
field = np.einsum("mip,mij->mpj", np.stack((N, M), axis=-2), reflected)
|
|
81
|
+
reflected_work = np.einsum("mii->mi", field)
|
|
82
|
+
return reflected_work, direct, None, None
|
|
83
|
+
d, pairs, R, S = solution.reflection_at(radius)
|
|
84
|
+
Mj, Nj = vectors(q, k, beta, radius, orders, pairs[0])
|
|
85
|
+
Mh, Nh = vectors(q, k, beta, radius, orders, pairs[1])
|
|
86
|
+
Mjr, Njr = vectors(q, k, -beta, radius, -orders, pairs[0])
|
|
87
|
+
Mhr, Nhr = vectors(q, k, -beta, radius, -orders, pairs[1])
|
|
88
|
+
factor = np.exp(pairs[0][2] + pairs[1][2])[:, None, None]
|
|
89
|
+
direct_regular = factor * np.stack((Nhr, Mhr), axis=-2)
|
|
90
|
+
direct_outgoing = factor * np.stack((Njr, Mjr), axis=-2)
|
|
91
|
+
A, B = source_coefficients(R, S, direct_regular, direct_outgoing, blocks=True)
|
|
92
|
+
field = np.einsum("mip,mij->mpj", np.stack((Nj, Mj), axis=-2), A) + np.einsum("mip,mij->mpj", np.stack((Nh, Mh), axis=-2), B)
|
|
93
|
+
reflected_work = np.einsum("mii->mi", field)
|
|
94
|
+
return reflected_work, direct_outgoing + B, direct_regular + A, pairs
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def inverse_reference(log_matrix, function_log=0):
|
|
98
|
+
reference = log_matrix + (function_log[:, None, None] if np.ndim(function_log) else function_log)
|
|
99
|
+
scale = np.max(reference.real, axis=(-2, -1))
|
|
100
|
+
inverse = np.linalg.inv(np.exp(reference - scale[:, None, None]))
|
|
101
|
+
return inverse, scale
|
|
102
|
+
|
|
103
|
+
def amplitudes(self, solution, outward, inward, pairs):
|
|
104
|
+
d = self.layer
|
|
105
|
+
lh = pairs[1][2] if pairs is not None else 0
|
|
106
|
+
inverse_out, scale_out = self.inverse_reference(solution.log_b_out[d], lh)
|
|
107
|
+
transformed_out = inverse_out @ outward
|
|
108
|
+
if inward is not None:
|
|
109
|
+
inverse_in, scale_in = self.inverse_reference(solution.log_a[d], pairs[0][2])
|
|
110
|
+
transformed_in = inverse_in @ inward
|
|
111
|
+
else:
|
|
112
|
+
transformed_in, scale_in = None, None
|
|
113
|
+
return transformed_out, scale_out, transformed_in, scale_in
|
|
114
|
+
|
|
115
|
+
def field_at(self, solution, amplitudes, layer, radius):
|
|
116
|
+
out, scale_out, inward, scale_in = amplitudes
|
|
117
|
+
pairs = cylinder_pair(solution.q[layer], radius, solution.orders)
|
|
118
|
+
if layer >= self.layer:
|
|
119
|
+
B = np.exp(solution.log_b_out[layer] + pairs[1][2][:, None, None] - scale_out[:, None, None]) @ out
|
|
120
|
+
A = np.exp(solution.log_s[layer] + (pairs[0][2] - pairs[1][2])[:, None, None]) @ B
|
|
121
|
+
else:
|
|
122
|
+
A = np.exp(solution.log_a[layer] + pairs[0][2][:, None, None] - scale_in[:, None, None]) @ inward
|
|
123
|
+
B = np.exp(solution.log_b[layer] + pairs[1][2][:, None, None] - scale_in[:, None, None]) @ inward
|
|
124
|
+
k = self.k0 * self.n[layer]
|
|
125
|
+
Mj, Nj = vectors(solution.q[layer], k, solution.beta, radius, solution.orders, pairs[0])
|
|
126
|
+
Mh, Nh = vectors(solution.q[layer], k, solution.beta, radius, solution.orders, pairs[1])
|
|
127
|
+
E = np.einsum("mip,mij->mpj", np.stack((Nj, Mj), axis=-2), A) + np.einsum("mip,mij->mpj", np.stack((Nh, Mh), axis=-2), B)
|
|
128
|
+
H = -1j * self.n[layer] / self.mu[layer] * (np.einsum("mip,mij->mpj", np.stack((Mj, Nj), axis=-2), A) + np.einsum("mip,mij->mpj", np.stack((Mh, Nh), axis=-2), B))
|
|
129
|
+
return E, H
|
|
130
|
+
|
|
131
|
+
def spectral(self, b, maximum=None, powers=True):
|
|
132
|
+
solution = self.solution(b, maximum)
|
|
133
|
+
work, outward, inward, pairs = self.coefficients(solution)
|
|
134
|
+
green = 1.5 * np.sum(work, axis=0)
|
|
135
|
+
escape, absorbed = np.zeros(3), np.zeros(3)
|
|
136
|
+
if powers:
|
|
137
|
+
amplitudes = self.amplitudes(solution, outward, inward, pairs)
|
|
138
|
+
coefficient = np.exp(-amplitudes[1])[:, None, None] * amplitudes[0]
|
|
139
|
+
if self.ks * b < self.k0 * self.n[-1].real:
|
|
140
|
+
escape = 1.5 * (self.mu[self.layer] / self.mu[-1]).real * np.sum(np.abs(coefficient) ** 2, axis=(0, 1))
|
|
141
|
+
for j in range(len(self.radii)):
|
|
142
|
+
if self.n[j].imag == 0:
|
|
143
|
+
continue
|
|
144
|
+
flux = []
|
|
145
|
+
for r in (0 if j == 0 else self.radii[j - 1], self.radii[j]):
|
|
146
|
+
if r == 0:
|
|
147
|
+
flux.append(np.zeros(3))
|
|
148
|
+
continue
|
|
149
|
+
E, H = self.field_at(solution, amplitudes, j, r)
|
|
150
|
+
flux.append(r * np.sum(np.real(E[:, 1] * H[:, 2].conj() - E[:, 2] * H[:, 1].conj()), axis=0))
|
|
151
|
+
absorbed += .75 * np.pi * self.mu[self.layer].real * self.k0 * (flux[0] - flux[1])
|
|
152
|
+
tail = 0.0 if self.radius == 0 else float(np.max(np.sum(np.abs(work[np.abs(solution.orders) >= max(1, solution.orders[-1] - 3)]), axis=0)))
|
|
153
|
+
return green, escape, absorbed, tail
|
|
154
|
+
|
|
155
|
+
def guided_poles(self):
|
|
156
|
+
if np.any(self.n.imag):
|
|
157
|
+
return []
|
|
158
|
+
low, high = self.n[-1].real / self.n[self.layer].real, max(self.n.real) / self.n[self.layer].real
|
|
159
|
+
if high <= low:
|
|
160
|
+
return []
|
|
161
|
+
count = min(4000, max(160, int(30 * self.k0 * self.radii[-1] * max(self.n.real))))
|
|
162
|
+
grid = np.linspace(low + 1e-8, high - 1e-8, count)
|
|
163
|
+
positive = np.arange(self.m_max, 2 * self.m_max + 1)
|
|
164
|
+
def determinant(b):
|
|
165
|
+
sol = solve_cylinder(self.radii, self.n, self.wavelength, mu=self.mu, beta=self.ks * b, m_max=self.m_max, _response_only=True)
|
|
166
|
+
C = sol.transfers[-1]
|
|
167
|
+
D = C[:, :2, :2] + C[:, :2, 2:] @ sol.response.regular_in[-1]
|
|
168
|
+
return np.linalg.det(D)[positive] / np.maximum(1e-300, np.sum(np.abs(D[positive]) ** 2, axis=(-2, -1)))
|
|
169
|
+
values = np.abs(np.array([determinant(b) for b in grid]))
|
|
170
|
+
poles = []
|
|
171
|
+
for m in range(values.shape[1]):
|
|
172
|
+
indices = np.flatnonzero((values[1:-1, m] < values[:-2, m]) & (values[1:-1, m] < values[2:, m])) + 1
|
|
173
|
+
for j in indices:
|
|
174
|
+
fit = minimize_scalar(lambda b: abs(determinant(b)[m]), bounds=(grid[j - 1], grid[j + 1]), method="bounded", options={"xatol":1e-13})
|
|
175
|
+
if fit.fun < 1e-7 and not any(abs(fit.x - p) < 2e-7 for p in poles):
|
|
176
|
+
h = min(1e-5, (grid[j + 1] - grid[j - 1]) / 10)
|
|
177
|
+
phase = np.exp(-1j * np.angle(determinant(fit.x + h)[m] - determinant(fit.x - h)[m]))
|
|
178
|
+
root = brentq(lambda b: np.real(phase * determinant(b)[m]), fit.x - h, fit.x + h, xtol=2e-13)
|
|
179
|
+
poles.append(root)
|
|
180
|
+
return sorted(poles)
|
|
181
|
+
|
|
182
|
+
def pattern(self, theta, phi=0):
|
|
183
|
+
theta, phi = np.broadcast_arrays(theta, phi)
|
|
184
|
+
density = np.zeros(theta.shape + (3,))
|
|
185
|
+
prefactor = 3 / (8 * np.pi) * (self.mu[self.layer] * self.n[-1] / (self.mu[-1] * self.n[self.layer])).real
|
|
186
|
+
beta = self.n[-1].real / self.n[self.layer].real * np.cos(np.clip(theta, 1e-6, np.pi - 1e-6))
|
|
187
|
+
for b in np.unique(beta):
|
|
188
|
+
solution = self.solution(b)
|
|
189
|
+
_, outward, inward, pairs = self.coefficients(solution)
|
|
190
|
+
transformed, scale, _, _ = self.amplitudes(solution, outward, inward, pairs)
|
|
191
|
+
coefficient = np.exp(-scale)[:, None, None] * transformed
|
|
192
|
+
selected = beta == b
|
|
193
|
+
phase = np.exp(1j * (phi[selected, None] - np.pi / 2) * solution.orders)
|
|
194
|
+
far = np.einsum("pm,mij->pij", phase, coefficient)
|
|
195
|
+
density[selected] = prefactor * np.sum(abs(far) ** 2, axis=1)
|
|
196
|
+
return density
|
|
197
|
+
|
|
198
|
+
def _rates_once(self, tolerance=1e-6, max_evaluations=20000):
|
|
199
|
+
poles = self.guided_poles()
|
|
200
|
+
guided, regular_parts, residue_error = np.zeros(3), [], 0.0
|
|
201
|
+
for root in poles:
|
|
202
|
+
step = 1e-5 * max(1, root)
|
|
203
|
+
def residue(h):
|
|
204
|
+
return h * (self.spectral(root + h, powers=False)[0] - self.spectral(root - h, powers=False)[0]) / 2
|
|
205
|
+
r1, r2 = residue(step), residue(step / 2)
|
|
206
|
+
r2 = (4 * r2 - r1) / 3
|
|
207
|
+
residue_error += np.pi * float(np.max(np.abs(r1 - r2)))
|
|
208
|
+
guided -= np.pi * r2.imag
|
|
209
|
+
left = self.spectral(root - step, powers=False)[0].real + r2.real / step
|
|
210
|
+
right = self.spectral(root + step, powers=False)[0].real - r2.real / step
|
|
211
|
+
regular_parts.append((root, step, left, right))
|
|
212
|
+
def sample(b):
|
|
213
|
+
green, escape, absorbed, tail = self.spectral(b)
|
|
214
|
+
return np.r_[green.real, escape, absorbed, tail]
|
|
215
|
+
caps, grazing_error, cap_evaluations = [], 0.0, 0
|
|
216
|
+
if np.any(self.n.imag):
|
|
217
|
+
lines = np.unique(self.n[self.n.imag == 0].real / self.n[self.layer].real)
|
|
218
|
+
for line in lines:
|
|
219
|
+
neighbours = [abs(line - v) for v in lines if v != line]
|
|
220
|
+
width = min(1e-5 * max(1, line), line / 4, min(neighbours, default=np.inf) / 4)
|
|
221
|
+
if width <= 0:
|
|
222
|
+
continue
|
|
223
|
+
for side in (-1, 1):
|
|
224
|
+
edge, outer = line + side * width, line + side * 2 * width
|
|
225
|
+
if not 0 < outer < self.maximum:
|
|
226
|
+
continue
|
|
227
|
+
if cap_evaluations + 2 >= max_evaluations:
|
|
228
|
+
raise ArithmeticError("adaptive integration evaluation budget exceeded")
|
|
229
|
+
value, away = sample(edge), sample(outer)
|
|
230
|
+
cap_evaluations += 2
|
|
231
|
+
# The vector basis loses digits at grazing incidence. Continue
|
|
232
|
+
# only the narrow end cap and charge its variation to the error
|
|
233
|
+
# budget; never relax the requested convergence tolerance.
|
|
234
|
+
grazing_error += 4 * width * float(np.max(abs(value - away)))
|
|
235
|
+
caps.append((line, side, width, value))
|
|
236
|
+
def integrand(b):
|
|
237
|
+
for root, step, left, right in regular_parts:
|
|
238
|
+
if abs(b - root) < step:
|
|
239
|
+
f = (b - root + step) / (2 * step)
|
|
240
|
+
return np.r_[left * (1 - f) + right * f, np.zeros(7)]
|
|
241
|
+
for line, side, width, value in caps:
|
|
242
|
+
if 0 <= side * (b - line) < width:
|
|
243
|
+
return value
|
|
244
|
+
return sample(b)
|
|
245
|
+
breaks = [0, *[v / self.n[self.layer].real for v in self.n.real if v > 0], self.maximum]
|
|
246
|
+
breaks += [p - 1e-5 * max(1, p) for p in poles] + [p + 1e-5 * max(1, p) for p in poles]
|
|
247
|
+
integral = integrate(integrand, [b for b in breaks if 0 <= b <= self.maximum], tolerance, max_evaluations - cap_evaluations)
|
|
248
|
+
total, escape, absorbed = 1 + integral.value[:3] + guided, integral.value[3:6], integral.value[6:9]
|
|
249
|
+
balance = np.abs(total - escape - guided - absorbed) / np.maximum(1, abs(total))
|
|
250
|
+
tail_ok = self.radius == 0 or integral.value[-1] <= tolerance * max(1, np.max(abs(total)))
|
|
251
|
+
error = integral.error + residue_error + grazing_error
|
|
252
|
+
converged = integral.converged and error <= tolerance * max(1, np.max(abs(total))) and tail_ok and np.max(balance) <= tolerance and residue_error <= tolerance * max(1, np.max(abs(total)))
|
|
253
|
+
return CylinderRates(total, escape, guided, absorbed, error, integral.evaluations + cap_evaluations, self.m_max, bool(converged), balance, tuple(poles), grazing_error)
|
|
254
|
+
|
|
255
|
+
def rates(self, tolerance=1e-6, max_evaluations=20000):
|
|
256
|
+
"""Refine automatic angular order within one total quadrature budget."""
|
|
257
|
+
spent = 0
|
|
258
|
+
for attempt in range(4):
|
|
259
|
+
result = self._rates_once(tolerance, max_evaluations - spent)
|
|
260
|
+
spent += result.evaluations
|
|
261
|
+
if result.converged or self.explicit_order or self.radius == 0 or self.m_max == 1500 or max_evaluations - spent < 200:
|
|
262
|
+
return replace(result, evaluations=spent)
|
|
263
|
+
self.m_max = min(1500, int(np.ceil(1.5 * self.m_max)) + 4)
|
|
264
|
+
return replace(result, evaluations=spent)
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Vector cylindrical basis for concentric, infinite circular cylinders."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from .response import block_sweep, log_value, log_add
|
|
8
|
+
from .special import cylinder_pair
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def outgoing_root(value):
|
|
12
|
+
q = np.sqrt(np.asarray(value, complex))
|
|
13
|
+
return np.where((q.imag < 0) | ((q.imag == 0) & (q.real < 0)), -q, q)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def log_apply(matrix, logarithms):
|
|
17
|
+
terms = log_value(matrix)[..., :, :, None] + logarithms[..., None, :, :]
|
|
18
|
+
result = terms[..., 0, :]
|
|
19
|
+
for j in range(1, terms.shape[-2]):
|
|
20
|
+
result = log_add(result, terms[..., j, :])
|
|
21
|
+
return result
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def vectors(q, k, beta, radius, orders, pair):
|
|
25
|
+
f, df, _ = pair
|
|
26
|
+
m = np.asarray(orders)
|
|
27
|
+
M = np.stack((1j * m * f / (q * radius), -df, np.zeros_like(f)), axis=-1)
|
|
28
|
+
N = np.stack((1j * beta * df / k, -beta * m * f / (k * q * radius), q * f / k), axis=-1)
|
|
29
|
+
return M, N
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def traces(q, k, y, beta, radius, orders, pair):
|
|
33
|
+
M, N = vectors(q, k, beta, radius, orders, pair)
|
|
34
|
+
return np.stack((
|
|
35
|
+
np.stack((N[..., 2], np.zeros_like(N[..., 2])), axis=-1),
|
|
36
|
+
np.stack((np.zeros_like(N[..., 2]), -1j * y * N[..., 2]), axis=-1),
|
|
37
|
+
np.stack((N[..., 1], M[..., 1]), axis=-1),
|
|
38
|
+
np.stack((-1j * y * M[..., 1], -1j * y * N[..., 1]), axis=-1),
|
|
39
|
+
), axis=-2)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class CylinderSolution:
|
|
44
|
+
radii: np.ndarray
|
|
45
|
+
n: np.ndarray
|
|
46
|
+
mu: np.ndarray
|
|
47
|
+
wavelength: float
|
|
48
|
+
beta: complex
|
|
49
|
+
orders: np.ndarray
|
|
50
|
+
q: np.ndarray
|
|
51
|
+
response: object
|
|
52
|
+
transfers: np.ndarray
|
|
53
|
+
inner_pairs: list
|
|
54
|
+
outer_pairs: list
|
|
55
|
+
log_a: np.ndarray
|
|
56
|
+
log_b: np.ndarray
|
|
57
|
+
log_b_out: np.ndarray
|
|
58
|
+
log_s: np.ndarray
|
|
59
|
+
t: np.ndarray
|
|
60
|
+
|
|
61
|
+
def reflection_at(self, radius):
|
|
62
|
+
layer = int(np.searchsorted(self.radii, radius))
|
|
63
|
+
pairs = cylinder_pair(self.q[layer], radius, self.orders)
|
|
64
|
+
shape = (len(self.orders), 2, 2)
|
|
65
|
+
regular, outgoing = np.zeros(shape, complex), np.zeros(shape, complex)
|
|
66
|
+
jlog, hlog = pairs[0][2], pairs[1][2]
|
|
67
|
+
if layer:
|
|
68
|
+
old_j, old_h = self.outer_pairs[layer - 1][0][2], self.outer_pairs[layer - 1][1][2]
|
|
69
|
+
regular = self.response.regular_out[layer - 1] * np.exp(old_j - old_h + hlog - jlog)[:, None, None]
|
|
70
|
+
if layer < len(self.radii):
|
|
71
|
+
old_j, old_h = self.inner_pairs[layer][0][2], self.inner_pairs[layer][1][2]
|
|
72
|
+
outgoing = self.response.outgoing_in[layer] * np.exp(jlog - hlog + old_h - old_j)[:, None, None]
|
|
73
|
+
return layer, pairs, regular, outgoing
|
|
74
|
+
|
|
75
|
+
def field(self, points, polarization="axial-electric"):
|
|
76
|
+
points = np.asarray(points, float)
|
|
77
|
+
rho, phi, z = np.hypot(points[..., 0], points[..., 1]), np.arctan2(points[..., 1], points[..., 0]), points[..., 2]
|
|
78
|
+
out_e, out_h = np.zeros(points.shape, complex), np.zeros(points.shape, complex)
|
|
79
|
+
incident = np.array([1, 0], complex) if polarization == "axial-electric" else np.array([0, 1j], complex)
|
|
80
|
+
for idx in np.ndindex(rho.shape):
|
|
81
|
+
radius = max(rho[idx], 1e-12 * self.wavelength)
|
|
82
|
+
layer = int(np.searchsorted(self.radii, radius))
|
|
83
|
+
pairs = cylinder_pair(self.q[layer], radius, self.orders)
|
|
84
|
+
E, H = np.zeros(3, complex), np.zeros(3, complex)
|
|
85
|
+
k = 2 * np.pi * self.n[layer] / self.wavelength
|
|
86
|
+
for b, logs in enumerate((self.log_a[layer], self.log_b[layer])):
|
|
87
|
+
M, N = vectors(self.q[layer], k, self.beta, radius, self.orders, pairs[b])
|
|
88
|
+
coefficient = np.exp(logs + pairs[b][2][:, None, None]) @ incident
|
|
89
|
+
phase = (1j ** self.orders) * np.exp(1j * self.orders * phi[idx] + 1j * self.beta * z[idx])
|
|
90
|
+
E += np.sum(phase[:, None] * (coefficient[:, :1] * N + coefficient[:, 1:] * M), axis=0)
|
|
91
|
+
H += np.sum(-1j * self.n[layer] / self.mu[layer] * phase[:, None] * (coefficient[:, :1] * M + coefficient[:, 1:] * N), axis=0)
|
|
92
|
+
c, s = np.cos(phi[idx]), np.sin(phi[idx])
|
|
93
|
+
out_e[idx] = c * E[0] - s * E[1], s * E[0] + c * E[1], E[2]
|
|
94
|
+
out_h[idx] = c * H[0] - s * H[1], s * H[0] + c * H[1], H[2]
|
|
95
|
+
return out_e, out_h
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def solve_cylinder(radii, n, wavelength, *, mu=None, beta=0, m_max=None, _response_only=False):
|
|
99
|
+
radii, n = np.asarray(radii, float), np.asarray(n, complex)
|
|
100
|
+
mu = np.ones_like(n) if mu is None else np.asarray(mu, complex)
|
|
101
|
+
if radii.ndim != 1 or not len(radii) or radii[0] <= 0 or np.any(np.diff(radii) <= 0):
|
|
102
|
+
raise ValueError("radii must be positive and strictly increasing")
|
|
103
|
+
if n.shape != (len(radii) + 1,) or mu.shape != n.shape or not np.all(np.isfinite(n)) or np.any(n == 0):
|
|
104
|
+
raise ValueError("one finite nonzero index per radial region, host last, is required")
|
|
105
|
+
if not np.isfinite(wavelength) or wavelength <= 0:
|
|
106
|
+
raise ValueError("wavelength must be positive and finite")
|
|
107
|
+
k = 2 * np.pi * n / wavelength
|
|
108
|
+
q = outgoing_root(k * k - beta * beta)
|
|
109
|
+
if np.any(np.abs(q) < 1e-13 / wavelength):
|
|
110
|
+
raise ValueError("exact axial incidence/light-line evaluation needs its limiting form")
|
|
111
|
+
if m_max is None:
|
|
112
|
+
size = max(abs(q * radii[-1]))
|
|
113
|
+
m_max = max(8, int(np.ceil(size + 4 * size ** (1 / 3) + 8)))
|
|
114
|
+
if int(m_max) != m_max or not 1 <= m_max <= 1500:
|
|
115
|
+
raise ValueError("m_max must be an integer in 1..1500")
|
|
116
|
+
orders = np.arange(-int(m_max), int(m_max) + 1)
|
|
117
|
+
inner = [cylinder_pair(q[j], r, orders) for j, r in enumerate(radii)]
|
|
118
|
+
outer = [cylinder_pair(q[j + 1], r, orders) for j, r in enumerate(radii)]
|
|
119
|
+
transforms = []
|
|
120
|
+
for j, radius in enumerate(radii):
|
|
121
|
+
ti = np.concatenate([traces(q[j], k[j], n[j] / mu[j], beta, radius, orders, v) for v in inner[j]], axis=-1)
|
|
122
|
+
to = np.concatenate([traces(q[j + 1], k[j + 1], n[j + 1] / mu[j + 1], beta, radius, orders, v) for v in outer[j]], axis=-1)
|
|
123
|
+
transforms.append(np.linalg.solve(to, ti))
|
|
124
|
+
transfers = np.array(transforms)
|
|
125
|
+
propagation = np.array([
|
|
126
|
+
np.exp(outer[j][0][2] - outer[j][1][2] + inner[j + 1][1][2] - inner[j + 1][0][2])[:, None, None]
|
|
127
|
+
for j in range(len(radii) - 1)
|
|
128
|
+
]).reshape(len(radii) - 1, len(orders), 1, 1)
|
|
129
|
+
response = block_sweep(transfers, propagation)
|
|
130
|
+
if _response_only:
|
|
131
|
+
return CylinderSolution(radii, n, mu, float(wavelength), beta, orders, q, response, transfers, inner, outer, None, None, None, None, None)
|
|
132
|
+
log_a = np.full((len(n), len(orders), 2, 2), -np.inf + 0j)
|
|
133
|
+
log_a[-1, :, 0, 0] = log_a[-1, :, 1, 1] = 0
|
|
134
|
+
for j in range(len(radii) - 1, -1, -1):
|
|
135
|
+
C = transfers[j]
|
|
136
|
+
inverse = np.linalg.inv(C[:, :2, :2] + C[:, :2, 2:] @ response.regular_in[j])
|
|
137
|
+
log_a[j] = log_apply(inverse, log_a[j + 1]) + (outer[j][0][2] - inner[j][0][2])[:, None, None]
|
|
138
|
+
log_b = np.full_like(log_a, -np.inf)
|
|
139
|
+
for j in range(1, len(n)):
|
|
140
|
+
reflection = response.regular_out[j - 1]
|
|
141
|
+
offset = outer[j - 1][0][2] - outer[j - 1][1][2]
|
|
142
|
+
log_b[j] = log_apply(reflection, log_a[j]) + offset[:, None, None]
|
|
143
|
+
t = np.exp(log_b[-1])
|
|
144
|
+
if not np.all(np.isfinite(t)):
|
|
145
|
+
raise ArithmeticError("cylindrical response exceeded numerical precision")
|
|
146
|
+
log_bo = np.full_like(log_a, -np.inf)
|
|
147
|
+
log_so = np.full_like(log_a, -np.inf)
|
|
148
|
+
log_bo[-1, :, 0, 0] = log_bo[-1, :, 1, 1] = 0
|
|
149
|
+
for j in range(len(radii) - 1, -1, -1):
|
|
150
|
+
C = transfers[j]
|
|
151
|
+
inverse = np.linalg.inv(C[:, 2:, :2] @ response.outgoing_in[j] + C[:, 2:, 2:])
|
|
152
|
+
log_bo[j] = log_apply(inverse, log_bo[j + 1]) + (outer[j][1][2] - inner[j][1][2])[:, None, None]
|
|
153
|
+
log_so[j] = log_value(response.outgoing_in[j]) + (inner[j][1][2] - inner[j][0][2])[:, None, None]
|
|
154
|
+
return CylinderSolution(radii, n, mu, float(wavelength), beta, orders, q, response, transfers, inner, outer, log_a, log_b, log_bo, log_so, t)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def cross_widths(solution, polarization="unpolarized"):
|
|
158
|
+
if solution.n[-1].imag != 0 or abs(solution.beta.imag) > 0:
|
|
159
|
+
raise ValueError("cross widths require a lossless exterior and real incidence")
|
|
160
|
+
a = np.eye(2, dtype=complex)
|
|
161
|
+
columns = [0, 1] if polarization == "unpolarized" else [0 if polarization == "axial-electric" else 1]
|
|
162
|
+
power = np.sum(np.abs(solution.t[..., columns]) ** 2, axis=(0, 1))
|
|
163
|
+
extinction = -np.real(np.sum(solution.t[:, [0, 1], [0, 1]], axis=0))[columns]
|
|
164
|
+
factor = 4 / (2 * np.pi * solution.n[-1].real / solution.wavelength)
|
|
165
|
+
sca, ext = factor * np.mean(power), factor * np.mean(extinction)
|
|
166
|
+
return dict(extinction=ext, scattering=sca, absorption=ext - sca,
|
|
167
|
+
efficiency_extinction=ext / (2 * solution.radii[-1]),
|
|
168
|
+
efficiency_scattering=sca / (2 * solution.radii[-1]),
|
|
169
|
+
efficiency_absorption=(ext - sca) / (2 * solution.radii[-1]))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def cylinder_pattern(solution, phi, polarization="unpolarized"):
|
|
173
|
+
phase = np.exp(1j * np.asarray(phi)[:, None] * solution.orders[None])
|
|
174
|
+
amplitudes = np.einsum("pm,mij->pij", phase, solution.t)
|
|
175
|
+
power = np.sum(np.abs(amplitudes) ** 2, axis=1)
|
|
176
|
+
columns = [0, 1] if polarization == "unpolarized" else [0 if polarization == "axial-electric" else 1]
|
|
177
|
+
return 2 / (np.pi * (2 * np.pi * solution.n[-1].real / solution.wavelength)) * np.mean(power[:, columns], axis=-1)
|