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/farfield.py ADDED
@@ -0,0 +1,408 @@
1
+ """Far field under plane-wave illumination, vectorised over wavelengths.
2
+
3
+ Cross sections, scattering amplitudes, the full amplitude and Mueller
4
+ matrices, helicity-resolved cross sections, and angle-resolved scattering
5
+ patterns with directivity for any incident polarisation.
6
+
7
+ Conventions (Bohren & Huffman, chs. 3-4): the incident wave travels along +z
8
+ with Jones vector (E_x, E_y) and time dependence exp(-i omega t). In the far
9
+ zone E_sca = exp(ikr) / (-ikr) X(theta, phi), with
10
+
11
+ (X_par, X_perp) = [[S2, S3], [S4, S1]] (E_par, E_perp),
12
+ E_par = E_x cos(phi) + E_y sin(phi), E_perp = E_x sin(phi) - E_y cos(phi),
13
+ X_theta = X_par, X_phi = -X_perp,
14
+
15
+ so that dsigma/dOmega = |X|^2 / k^2. Everything here is computed from the
16
+ T-matrix blocks ``sol.t_matrix`` (W, L, 2, 2) in the (TM, TE) basis: diagonal
17
+ for achiral spheres (S3 = S4 = 0); chiral shells couple TM and TE and give
18
+ S3 = -S4.
19
+
20
+ Helicity: +1 is the field (x + iy)/sqrt(2) exp(ikz) - left-circular in the
21
+ optics convention (Jackson, sec. 7.2) - and -1 is (x - iy)/sqrt(2) exp(ikz).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass
27
+
28
+ import numpy as np
29
+
30
+ from .solver import TE, TM, Solution, _partial_waves
31
+
32
+ __all__ = [
33
+ "CrossSections",
34
+ "cross_sections",
35
+ "scattering_amplitudes",
36
+ "angular_functions",
37
+ "amplitude_matrix",
38
+ "mueller_matrix",
39
+ "HelicityCrossSections",
40
+ "helicity_cross_sections",
41
+ "ScatteringPattern",
42
+ "scattering_pattern",
43
+ "HELICITY",
44
+ ]
45
+
46
+ #: helicity of index 0 and 1 of every helicity axis
47
+ HELICITY = (+1, -1)
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class CrossSections:
52
+ """Cross sections (length^2) for every wavelength of the batch.
53
+
54
+ ``*_by_order`` have shape ``(W, 2, L)``: axis 1 is the polarisation
55
+ (``TM`` = electric multipoles, ``TE`` = magnetic). Totals have shape
56
+ ``(W,)``; ``q_*`` are efficiencies, normalised to pi R^2 of the outer radius.
57
+ """
58
+
59
+ orders: np.ndarray
60
+ sca_by_order: np.ndarray
61
+ ext_by_order: np.ndarray
62
+ abs_by_order: np.ndarray
63
+ geometric: float
64
+
65
+ @property
66
+ def sca(self) -> np.ndarray:
67
+ return self.sca_by_order.sum(axis=(-2, -1))
68
+
69
+ @property
70
+ def ext(self) -> np.ndarray:
71
+ return self.ext_by_order.sum(axis=(-2, -1))
72
+
73
+ @property
74
+ def abs(self) -> np.ndarray:
75
+ return self.abs_by_order.sum(axis=(-2, -1))
76
+
77
+ @property
78
+ def q_sca(self) -> np.ndarray:
79
+ return self.sca / self.geometric
80
+
81
+ @property
82
+ def q_ext(self) -> np.ndarray:
83
+ return self.ext / self.geometric
84
+
85
+ @property
86
+ def q_abs(self) -> np.ndarray:
87
+ return self.abs / self.geometric
88
+
89
+
90
+ def _achiral(sol, name):
91
+ if not isinstance(sol, Solution):
92
+ raise TypeError(f"{name} needs an achiral Solution; for chiral spheres use helicity_cross_sections")
93
+
94
+
95
+ def cross_sections(sol: Solution) -> CrossSections:
96
+ """Scattering, extinction and absorption cross sections.
97
+
98
+ The real part of the host wavenumber is used, so an absorbing host is
99
+ handled only approximately.
100
+ """
101
+ _achiral(sol, "cross_sections")
102
+ l = sol.orders
103
+ k = sol.k[:, -1][:, None, None]
104
+ t = np.moveaxis(sol.t, 0, 1) # (W, 2, L)
105
+ weight = 2 * np.pi * (2 * l + 1) / k.real
106
+ sca = weight / k.real * np.abs(t) ** 2
107
+ ext = -weight * (t / k).real
108
+ return CrossSections(
109
+ orders=l,
110
+ sca_by_order=sca,
111
+ ext_by_order=ext,
112
+ abs_by_order=ext - sca,
113
+ geometric=float(np.pi * sol.radii[-1] ** 2),
114
+ )
115
+
116
+
117
+ def angular_functions(orders, theta):
118
+ """Bohren-Huffman pi_l = P_l^1 / sin(theta) and tau_l = dP_l^1 / d(theta).
119
+
120
+ Upward recurrence in cos(theta), regular at theta = 0 and pi. Returns two
121
+ arrays of shape ``(len(orders), len(theta))``.
122
+ """
123
+ orders = np.asarray(orders)
124
+ c = np.cos(np.atleast_1d(np.asarray(theta, dtype=float)))
125
+ top = int(orders.max())
126
+ pi = np.zeros((top + 1, c.size))
127
+ tau = np.zeros((top + 1, c.size))
128
+ pi[1] = 1.0
129
+ tau[1] = c
130
+ for n in range(2, top + 1):
131
+ pi[n] = ((2 * n - 1) * c * pi[n - 1] - n * pi[n - 2]) / (n - 1)
132
+ tau[n] = n * c * pi[n] - (n + 1) * pi[n - 1]
133
+ return pi[orders], tau[orders]
134
+
135
+
136
+ def scattering_amplitudes(sol: Solution, theta, orders=None, polarisations=(TM, TE)):
137
+ """Scattering amplitudes (S_par, S_per), each of shape ``(W, len(theta))``.
138
+
139
+ S_par = -S2 and S_per = -S1 in Bohren & Huffman's notation, the convention
140
+ of Rasskazov, Carney & Moroz (2020); intensities and Stokes parameters are
141
+ unaffected by the sign. :func:`amplitude_matrix` gives S1..S4 themselves.
142
+
143
+ ``orders`` and ``polarisations`` (:data:`TM`, the electric multipoles a_l;
144
+ :data:`TE`, the magnetic b_l) keep only those partial waves, as in
145
+ :func:`near_field`: the far field of disjoint selections adds up amplitude
146
+ by amplitude, not in |S|^2, which carries their interference.
147
+ """
148
+ _achiral(sol, "scattering_amplitudes")
149
+ l = sol.orders
150
+ pi, tau = angular_functions(l, theta)
151
+ weight = (2 * l + 1) / (l * (l + 1))
152
+ kept = _partial_waves(l, orders, polarisations)
153
+ tm, te = (1.0, 1.0) if kept is None else (kept[TM], kept[TE])
154
+ a, b = sol.a * weight * tm, sol.b * weight * te
155
+ return -(a @ tau + b @ pi), -(a @ pi + b @ tau)
156
+
157
+
158
+ def _host_k(sol) -> np.ndarray:
159
+ """Real host wavenumber per wavelength (as :func:`cross_sections`)."""
160
+ return sol.k[:, -1].real
161
+
162
+
163
+ def amplitude_matrix(sol, theta):
164
+ """Bohren-Huffman amplitude scattering matrix (S1, S2, S3, S4).
165
+
166
+ ``sol``: :class:`~pystratify.Solution` or :class:`~pystratify.ChiralSolution`.
167
+ Each element has shape ``(W, len(theta))``.
168
+ """
169
+ l = sol.orders
170
+ theta = np.atleast_1d(np.asarray(theta, dtype=float))
171
+ unique, index = np.unique(theta, return_inverse=True)
172
+ pi, tau = angular_functions(l, unique)
173
+ w = (2 * l + 1) / (l * (l + 1))
174
+ t = sol.t_matrix * w[:, None, None] # (W, L, 2, 2)
175
+ tm_tm, tm_te, te_tm, te_te = t[..., 0, 0], t[..., 0, 1], t[..., 1, 0], t[..., 1, 1]
176
+ s1 = -(tm_tm @ pi + te_te @ tau)
177
+ s2 = -(tm_tm @ tau + te_te @ pi)
178
+ s3 = -1j * (tm_te @ tau + te_tm @ pi)
179
+ s4 = 1j * (tm_te @ pi + te_tm @ tau)
180
+ return tuple(s[:, index] for s in (s1, s2, s3, s4))
181
+
182
+
183
+ #: Stokes vector (I, Q, U, V) from the coherency vector (E_par E_par*, E_par E_perp*, E_perp E_par*, E_perp E_perp*)
184
+ _STOKES = np.array([[1, 0, 0, 1], [1, 0, 0, -1], [0, 1, 1, 0], [0, 1j, -1j, 0]])
185
+
186
+
187
+ def mueller_matrix(sol, theta):
188
+ """Mueller matrix, shape ``(W, len(theta), 4, 4)``, in Bohren & Huffman's
189
+ convention (their Eq. 3.16 and Stokes vector, prefactor 1/(k r)^2 omitted):
190
+ for an achiral sphere M11 = (|S1|^2 + |S2|^2)/2, M12 = (|S2|^2 - |S1|^2)/2,
191
+ M33 = Re(S1 S2*), M34 = Im(S2 S1*).
192
+ """
193
+ s1, s2, s3, s4 = amplitude_matrix(sol, theta)
194
+ jones = np.stack([np.stack([s2, s3], -1), np.stack([s4, s1], -1)], -2) # (W, T, 2, 2)
195
+ coherency = np.einsum("...ik,...jl->...ijkl", jones, np.conj(jones)).reshape(jones.shape[:-2] + (4, 4))
196
+ return np.real(_STOKES @ coherency @ np.linalg.inv(_STOKES))
197
+
198
+
199
+ @dataclass(frozen=True)
200
+ class HelicityCrossSections:
201
+ """Cross sections for circularly polarised illumination.
202
+
203
+ ``*_by_order`` have shape ``(W, 2, L)`` with axis 1 the helicity
204
+ (index 0: +1, left-circular in the optics convention; index 1: -1).
205
+ Totals ``ext``, ``sca``, ``abs`` have shape ``(W, 2)``; linear or
206
+ unpolarised light gives their mean. ``cd_*`` = (+1) - (-1) and
207
+ ``g_*`` = 2 cd / ((+1) + (-1)) are the circular dichroism and the
208
+ dissymmetry factor of extinction, scattering and absorption.
209
+ """
210
+
211
+ orders: np.ndarray
212
+ sca_by_order: np.ndarray
213
+ ext_by_order: np.ndarray
214
+ abs_by_order: np.ndarray
215
+ geometric: float
216
+
217
+ @property
218
+ def sca(self) -> np.ndarray:
219
+ return self.sca_by_order.sum(axis=-1)
220
+
221
+ @property
222
+ def ext(self) -> np.ndarray:
223
+ return self.ext_by_order.sum(axis=-1)
224
+
225
+ @property
226
+ def abs(self) -> np.ndarray:
227
+ return self.abs_by_order.sum(axis=-1)
228
+
229
+ @property
230
+ def q_sca(self) -> np.ndarray:
231
+ return self.sca / self.geometric
232
+
233
+ @property
234
+ def q_ext(self) -> np.ndarray:
235
+ return self.ext / self.geometric
236
+
237
+ @property
238
+ def q_abs(self) -> np.ndarray:
239
+ return self.abs / self.geometric
240
+
241
+ @staticmethod
242
+ def _cd(v):
243
+ return v[..., 0] - v[..., 1]
244
+
245
+ @staticmethod
246
+ def _g(v):
247
+ return 2 * (v[..., 0] - v[..., 1]) / (v[..., 0] + v[..., 1])
248
+
249
+ @property
250
+ def cd_ext(self) -> np.ndarray:
251
+ return self._cd(self.ext)
252
+
253
+ @property
254
+ def cd_sca(self) -> np.ndarray:
255
+ return self._cd(self.sca)
256
+
257
+ @property
258
+ def cd_abs(self) -> np.ndarray:
259
+ return self._cd(self.abs)
260
+
261
+ @property
262
+ def g_ext(self) -> np.ndarray:
263
+ return self._g(self.ext)
264
+
265
+ @property
266
+ def g_sca(self) -> np.ndarray:
267
+ return self._g(self.sca)
268
+
269
+ @property
270
+ def g_abs(self) -> np.ndarray:
271
+ return self._g(self.abs)
272
+
273
+
274
+ #: (TM, TE) amplitudes of the multipoles in a plane wave of helicity +1 and -1 (up to a common factor)
275
+ _HELICITY_VECTORS = np.array([[1.0, 1.0], [1.0, -1.0]])
276
+
277
+
278
+ def helicity_cross_sections(sol) -> HelicityCrossSections:
279
+ """Extinction, scattering and absorption for both circular polarisations.
280
+
281
+ Works for achiral (identical helicities) and chiral spheres. A plane
282
+ wave of helicity s excites only m = s multipoles with (TM, TE) amplitudes
283
+ proportional to (1, s), so with T the (TM, TE) T-matrix block of order l
284
+
285
+ C_ext = -(2 pi / k^2) sum (2l + 1) Re[v^T T v],
286
+ C_sca = (2 pi / k^2) sum (2l + 1) |T v|^2, v = (1, s).
287
+ """
288
+ l = sol.orders
289
+ k = _host_k(sol)[:, None, None]
290
+ t = sol.t_matrix # (W, L, 2, 2)
291
+ tv = np.einsum("wlij,hj->whli", t, _HELICITY_VECTORS) # (W, 2, L, 2)
292
+ weight = 2 * np.pi * (2 * l + 1) / k**2
293
+ ext = -weight * np.einsum("hi,whli->whl", _HELICITY_VECTORS, tv).real
294
+ sca = weight * np.sum(np.abs(tv) ** 2, axis=-1)
295
+ return HelicityCrossSections(
296
+ orders=l,
297
+ sca_by_order=sca,
298
+ ext_by_order=ext,
299
+ abs_by_order=ext - sca,
300
+ geometric=float(np.pi * sol.radii[-1] ** 2),
301
+ )
302
+
303
+
304
+ class FarFieldPolarization:
305
+ """Polarisation analysis shared by far-field patterns (fields ``e_theta``, ``e_phi``)."""
306
+
307
+ e_theta: np.ndarray
308
+ e_phi: np.ndarray
309
+
310
+ @property
311
+ def intensity(self) -> np.ndarray:
312
+ """|e_theta|^2 + |e_phi|^2."""
313
+ return np.abs(self.e_theta) ** 2 + np.abs(self.e_phi) ** 2
314
+
315
+ @property
316
+ def helicity(self) -> tuple:
317
+ """Amplitudes (e_+, e_-) on (theta_hat +- i phi_hat)/sqrt(2): helicity +1 and -1 along the outgoing direction."""
318
+ return (self.e_theta - 1j * self.e_phi) / np.sqrt(2), (self.e_theta + 1j * self.e_phi) / np.sqrt(2)
319
+
320
+ @property
321
+ def circular_polarization(self) -> np.ndarray:
322
+ """Degree of circular polarisation (|e_+|^2 - |e_-|^2) / (|e_+|^2 + |e_-|^2), +1 for pure helicity +1."""
323
+ with np.errstate(invalid="ignore", divide="ignore"):
324
+ return -2 * np.imag(self.e_theta * np.conj(self.e_phi)) / self.intensity
325
+
326
+ def stokes(self) -> np.ndarray:
327
+ """Stokes parameters (I, Q, U, V) stacked on the last axis, in Bohren &
328
+ Huffman's convention (par = theta_hat, perp = -phi_hat, as :func:`mueller_matrix`);
329
+ note their V = -I x ``circular_polarization``."""
330
+ par, perp = self.e_theta, -self.e_phi
331
+ return np.stack(
332
+ [
333
+ np.abs(par) ** 2 + np.abs(perp) ** 2,
334
+ np.abs(par) ** 2 - np.abs(perp) ** 2,
335
+ 2 * np.real(par * np.conj(perp)),
336
+ -2 * np.imag(par * np.conj(perp)),
337
+ ],
338
+ axis=-1,
339
+ )
340
+
341
+
342
+ @dataclass(frozen=True)
343
+ class ScatteringPattern(FarFieldPolarization):
344
+ """Scattered far field for one incident polarisation.
345
+
346
+ ``e_theta``, ``e_phi``: components of the vector amplitude X (E_sca =
347
+ exp(ikr)/(-ikr) X for a unit incident field), shape ``(W,) + S`` with S
348
+ the broadcast shape of ``theta`` and ``phi``. ``sca``: scattering cross
349
+ section for this polarisation, ``(W,)``, from the multipole coefficients.
350
+ """
351
+
352
+ theta: np.ndarray
353
+ phi: np.ndarray
354
+ e_theta: np.ndarray
355
+ e_phi: np.ndarray
356
+ k: np.ndarray
357
+ sca: np.ndarray
358
+ polarization: np.ndarray
359
+
360
+ def _per_wavelength(self, v):
361
+ return v.reshape(v.shape + (1,) * self.theta.ndim)
362
+
363
+ @property
364
+ def differential_cross_section(self) -> np.ndarray:
365
+ """dsigma/dOmega = |X|^2 / k^2 (length^2 per steradian)."""
366
+ return self.intensity / self._per_wavelength(self.k) ** 2
367
+
368
+ @property
369
+ def directivity(self) -> np.ndarray:
370
+ """4 pi (dsigma/dOmega) / sigma_sca: 1 for isotropic scattering, integrates to 4 pi."""
371
+ return 4 * np.pi * self.differential_cross_section / self._per_wavelength(self.sca)
372
+
373
+ @property
374
+ def phase_function(self) -> np.ndarray:
375
+ """(dsigma/dOmega) / sigma_sca, normalised to 1 over the sphere."""
376
+ return self.directivity / (4 * np.pi)
377
+
378
+
379
+ def _jones(polarization):
380
+ p = np.asarray(polarization, dtype=complex).ravel()
381
+ norm = np.linalg.norm(p)
382
+ if p.shape != (2,) or not np.isfinite(norm) or norm == 0:
383
+ raise ValueError("polarization must be a nonzero Jones vector (E_x, E_y)")
384
+ return p / norm
385
+
386
+
387
+ def scattering_pattern(sol, theta, phi=0.0, polarization=(1.0, 0.0)) -> ScatteringPattern:
388
+ """Angle-resolved scattered far field, differential cross section and directivity.
389
+
390
+ ``sol``: :class:`~pystratify.Solution` or :class:`~pystratify.ChiralSolution`.
391
+ ``theta``, ``phi``: scattering directions (broadcast together; theta from
392
+ the propagation direction +z). ``polarization``: incident Jones vector
393
+ (E_x, E_y), normalised internally; e.g. (1, 1j) for helicity +1.
394
+ """
395
+ jones = _jones(polarization)
396
+ theta, phi = np.broadcast_arrays(np.asarray(theta, dtype=float), np.asarray(phi, dtype=float))
397
+ s1, s2, s3, s4 = amplitude_matrix(sol, theta.ravel())
398
+ cp, sp = np.cos(phi.ravel()), np.sin(phi.ravel())
399
+ e_par = jones[0] * cp + jones[1] * sp
400
+ e_perp = jones[0] * sp - jones[1] * cp
401
+ shape = (s1.shape[0],) + theta.shape
402
+ x_theta = (s2 * e_par + s3 * e_perp).reshape(shape)
403
+ x_phi = -(s4 * e_par + s1 * e_perp).reshape(shape)
404
+ helicity = np.array([jones[0] - 1j * jones[1], jones[0] + 1j * jones[1]]) / np.sqrt(2)
405
+ sca = helicity_cross_sections(sol).sca @ np.abs(helicity) ** 2
406
+ return ScatteringPattern(
407
+ theta=theta, phi=phi, e_theta=x_theta, e_phi=x_phi, k=_host_k(sol), sca=sca, polarization=jones
408
+ )