pystratify 0.10.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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)