miepython 3.1.0__py3-none-any.whl → 3.3.0__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.
miepython/field.py CHANGED
@@ -79,10 +79,11 @@ Conventions
79
79
  """
80
80
 
81
81
  import numpy as np
82
- import miepython as mie
83
- from scipy.special import spherical_jn, factorial2
84
- from miepython.bessel import spherical_h1, d_riccati_bessel_h1
85
- from miepython.util import spherical_vector_to_cartesian
82
+ from scipy.special import factorial2, spherical_jn
83
+ from ._backend import D_calc, pi_tau
84
+ from .bessel import spherical_h1
85
+ from .core import S1_S2, coefficients, wiscombe_terms
86
+ from .util import cartesian_to_spherical, spherical_vector_to_cartesian
86
87
 
87
88
  __all__ = (
88
89
  "e_near",
@@ -96,62 +97,94 @@ __all__ = (
96
97
 
97
98
 
98
99
  def _sum_two_scaled_terms(scale, coeff1, values1, scale1, coeff2, values2, scale2):
99
- """Return ``sum(scale * (scale1*coeff1*values1 + scale2*coeff2*values2))``."""
100
- return np.sum(scale * (scale1 * coeff1 * values1 + scale2 * coeff2 * values2))
100
+ """Sum ``scale * (scale1*coeff1*values1 + scale2*coeff2*values2)`` over multipoles.
101
+
102
+ ``values1`` and ``values2`` are shaped ``(n_points, n_terms)`` and the other
103
+ arguments broadcast along the order axis, so the multipole series is summed away
104
+ and one value per point comes back.
105
+ """
106
+ return np.sum(scale * (scale1 * coeff1 * values1 + scale2 * coeff2 * values2), axis=-1)
101
107
 
102
108
 
103
- def _vsh_components_base(n_terms, lambda0, d_sphere, m_index, r, theta):
104
- """Compute shared VSH base components for one spatial point.
109
+ def _vsh_components_base(n_terms, lambda0, m_index, r, theta, inside):
110
+ """Compute shared VSH base components for a batch of points.
111
+
112
+ Every point in one call must lie on the same side of the sphere surface: a single
113
+ ``inside`` flag chooses the radial function and a single ``m_index`` the medium
114
+ for the whole batch. ``_near_fields`` groups the points that way before calling.
105
115
 
106
116
  Args:
107
117
  n_terms (int): Number of multipole terms.
108
118
  lambda0 (float): Vacuum wavelength.
109
- d_sphere (float): Sphere diameter.
110
- m_index (complex): Refractive index at the evaluation point.
111
- r (float): Radial coordinate.
112
- theta (float): Polar angle in radians.
119
+ m_index (complex): Refractive index at the evaluation points.
120
+ r (ndarray): Radial coordinates, shape ``(n_points,)``.
121
+ theta (ndarray): Polar angles in radians, shape ``(n_points,)``.
122
+ inside (bool): True when the points lie inside the sphere.
113
123
 
114
124
  Returns:
115
125
  tuple[ndarray, ndarray, ndarray, ndarray, ndarray]:
116
- ``(M_theta_base, M_phi_base, N_r_base, N_theta_base, N_phi_base)``
117
- for multipole orders ``1..n_terms``.
126
+ ``(M_theta_base, M_phi_base, N_r_base, N_theta_base, N_phi_base)``, each
127
+ shaped ``(n_points, n_terms)`` for multipole orders ``1..n_terms``.
118
128
  """
119
- mu = float(np.cos(theta))
120
- if mu >= 1.0:
121
- mu = 0.999999
122
- elif mu <= -1.0:
123
- mu = -0.999999
124
-
125
- pi = np.empty(n_terms)
126
- tau = np.empty(n_terms)
127
- mie._pi_tau(mu, pi, tau)
129
+ mu = np.cos(theta)
130
+ # the Legendre recurrence is 0/0 exactly at the poles, so nudge mu off them --
131
+ # and only there, leaving every other value untouched
132
+ mu = np.where(mu >= 1.0, 0.999999, mu)
133
+ mu = np.where(mu <= -1.0, -0.999999, mu)
134
+
135
+ n_points = r.size
136
+ pi = np.empty((n_points, n_terms))
137
+ tau = np.empty((n_points, n_terms))
138
+ for k in range(n_points):
139
+ # the kernel writes one point at a time; rows of a C-ordered array are the
140
+ # contiguous 1-D buffers it expects
141
+ pi_tau(float(mu[k]), pi[k], tau[k])
128
142
 
129
143
  n_int = np.arange(1, n_terms + 1, dtype=np.int64)
130
144
  n_arr = n_int.astype(np.float64)
131
- rho = 2 * np.pi * m_index * r / lambda0
132
- kr = 2 * np.pi * r / lambda0
133
- inside = r < d_sphere / 2
145
+ r_col = r[:, np.newaxis]
146
+ rho = 2 * np.pi * m_index * r_col / lambda0
147
+ kr = 2 * np.pi * r_col / lambda0
134
148
 
135
149
  if inside:
136
150
  jn = spherical_jn(n_int, rho)
137
151
  m_factor = jn
138
152
 
139
- if np.abs(rho) < 0.01:
140
- rho_pow = rho ** np.arange(0, n_terms)
153
+ n_factor1 = np.empty((n_points, n_terms), dtype=np.complex128)
154
+ n_factor2 = np.empty((n_points, n_terms), dtype=np.complex128)
155
+
156
+ # near the origin jn/rho and D_n both lose their meaning, so those points
157
+ # take the leading-order series instead; the split is per point
158
+ small = np.abs(rho[:, 0]) < 0.01
159
+ if small.any():
160
+ rho_pow = rho[small] ** np.arange(0, n_terms)
141
161
  denom = factorial2(2 * n_int + 1)
142
- n_factor1 = rho_pow / denom
143
- n_factor2 = (n_arr + 1.0) * rho_pow / denom
144
- else:
145
- d_vals = mie._D_calc(np.complex128(m_index), float(kr), n_terms + 1)[:n_terms]
146
- n_factor1 = jn / rho
147
- n_factor2 = jn * d_vals
162
+ n_factor1[small] = rho_pow / denom
163
+ n_factor2[small] = (n_arr + 1.0) * rho_pow / denom
164
+
165
+ large = ~small
166
+ if large.any():
167
+ # D_calc is a scalar kernel, so the logarithmic derivative is still one
168
+ # call per point; only points inside the sphere need it at all
169
+ d_vals = np.empty((int(np.count_nonzero(large)), n_terms), dtype=np.complex128)
170
+ for k, kr_k in enumerate(kr[large, 0]):
171
+ d_vals[k] = D_calc(np.complex128(m_index), float(kr_k), n_terms + 1)[:n_terms]
172
+ n_factor1[large] = jn[large] / rho[large]
173
+ n_factor2[large] = jn[large] * d_vals
148
174
  else:
149
- h1 = spherical_h1(n_int, rho)
175
+ # xi'_n needs h1 at orders n-1, n and n+1, so evaluating orders 0..n_terms+1
176
+ # in one call and slicing costs a quarter of what four separate calls do --
177
+ # and h1 at order n is then shared instead of computed twice.
178
+ h_all = spherical_h1(np.arange(0, n_terms + 2, dtype=np.int64), rho)
179
+ h1 = h_all[:, 1 : n_terms + 1]
150
180
  m_factor = h1
151
181
  n_factor1 = h1 / rho
152
- n_factor2 = d_riccati_bessel_h1(n_int, rho) / rho
182
+ # d_riccati_bessel_h1 written out on the cached values; identical arithmetic,
183
+ # and tests/test_field.py pins it against that function so the two cannot drift
184
+ d_xi = 0.5 * (rho * h_all[:, 0:n_terms] + h1 - rho * h_all[:, 2 : n_terms + 2])
185
+ n_factor2 = d_xi / rho
153
186
 
154
- sin_theta = np.sin(theta)
187
+ sin_theta = np.sin(theta)[:, np.newaxis]
155
188
  M_theta_base = pi * m_factor
156
189
  M_phi_base = tau * m_factor
157
190
  N_r_base = n_arr * (n_arr + 1.0) * sin_theta * pi * n_factor1
@@ -181,7 +214,7 @@ def e_far(lambda0, d_sphere, m_sphere, n_env, r, theta, phi):
181
214
  jkr = 1j * 2 * np.pi * n_env * r / lambda0
182
215
  amp = np.exp(jkr) / (-jkr)
183
216
 
184
- S1, S2 = mie.S1_S2(m_rel, x, np.cos(theta), norm="wiscombe")
217
+ S1, S2 = S1_S2(m_rel, x, np.cos(theta), norm="wiscombe")
185
218
 
186
219
  E_r = np.zeros_like(S1, dtype=complex)
187
220
  E_theta = S2 * amp * np.cos(phi)
@@ -247,88 +280,137 @@ def _coefficients_abcd(lambda0, d_sphere, m_sphere, n_env, n_pole):
247
280
  d_sphere (float): Sphere diameter.
248
281
  m_sphere (complex): Sphere refractive index.
249
282
  n_env (float): Refractive index of the surrounding medium.
250
- n_pole (int): Requested multipole order. ``0`` means automatic truncation.
283
+ n_pole (int): Number of terms to keep. ``0`` means automatic truncation.
251
284
 
252
285
  Returns:
253
286
  ndarray: Coefficients packed as ``[a, b, c, d]``.
254
287
  """
255
288
  x = np.pi * d_sphere * n_env / lambda0
256
289
  m_rel = m_sphere / n_env
257
- a, b, c, d = mie.coefficients(m_rel, x, n_pole=n_pole, internal=True)
290
+ if n_pole == 0:
291
+ # Wiscombe's criterion truncates the scattered series, which converges
292
+ # faster than the field evaluated right at the surface. Now that an_bn
293
+ # builds psi_n stably, extra orders converge instead of diverging: the
294
+ # tangential boundary mismatch falls from 1.5e-5 at the criterion to
295
+ # 3.6e-6 with one extra order and 3.2e-6 with two, then flattens out, so
296
+ # two is where it stops being worth more terms.
297
+ n_pole = wiscombe_terms(x) + 2
298
+ a, b, c, d = coefficients(m_rel, x, n_pole=n_pole, internal=True)
258
299
  return np.array([a, b, c, d])
259
300
 
260
301
 
261
- def _vectorized_field_eval(evaluator, r, theta, phi):
262
- """Evaluate a spherical-field callback on scalar or broadcasted inputs.
263
-
264
- Args:
265
- evaluator (callable): Callable that accepts scalar ``(r, theta, phi)``
266
- and returns a complex 3-vector.
267
- r (float or ndarray): Radial coordinate(s).
268
- theta (float or ndarray): Polar angle(s) in radians.
269
- phi (float or ndarray): Azimuth angle(s) in radians.
270
-
271
- Returns:
272
- ndarray: Complex array with shape ``(3, ...)``.
273
- """
274
- rr, tt, pp = np.broadcast_arrays(np.asarray(r), np.asarray(theta), np.asarray(phi))
275
-
276
- if rr.ndim == 0:
277
- return evaluator(float(rr), float(tt), float(pp))
302
+ def _near_fields(abcd, lambda0, d_sphere, m_sphere, n_env, r, theta, phi, include_incident, want_e, want_h):
303
+ """Evaluate the near fields at every requested point.
278
304
 
279
- out = np.empty((3,) + rr.shape, dtype=complex)
280
- for idx in np.ndindex(rr.shape):
281
- out[(slice(None),) + idx] = evaluator(float(rr[idx]), float(tt[idx]), float(pp[idx]))
282
- return out
283
-
284
-
285
- def _vectorized_field_pair_eval(evaluator, r, theta, phi):
286
- """Evaluate an ``(E, H)`` callback on scalar or broadcasted inputs.
305
+ The points are split once into those inside the sphere and those outside, and each
306
+ group is evaluated as a batch. That grouping is what makes the work vectorizable:
307
+ the two sides use different radial functions and different media, but within a
308
+ side every point does the same arithmetic, so the multipole series becomes a sum
309
+ over the last axis of a ``(n_points, n_terms)`` array.
287
310
 
288
311
  Args:
289
- evaluator (callable): Callable that accepts scalar ``(r, theta, phi)``
290
- and returns a tuple ``(E, H)``, each a complex 3-vector.
312
+ abcd (ndarray): Mie coefficients ``[a, b, c, d]``.
313
+ lambda0 (float): Vacuum wavelength.
314
+ d_sphere (float): Sphere diameter.
315
+ m_sphere (complex): Sphere refractive index.
316
+ n_env (float): Refractive index of the surrounding medium.
291
317
  r (float or ndarray): Radial coordinate(s).
292
318
  theta (float or ndarray): Polar angle(s) in radians.
293
319
  phi (float or ndarray): Azimuth angle(s) in radians.
320
+ include_incident (bool): Include incident field outside the sphere.
321
+ want_e (bool): Compute the electric field.
322
+ want_h (bool): Compute the magnetic field.
294
323
 
295
324
  Returns:
296
- tuple[ndarray, ndarray]: Tuple ``(E, H)`` with each array shaped
297
- ``(3, ...)``.
325
+ tuple[ndarray or None, ndarray or None]: ``(E, H)`` in spherical components,
326
+ each shaped ``(3,) + broadcast_shape`` or None when not requested.
298
327
  """
299
- rr, tt, pp = np.broadcast_arrays(np.asarray(r), np.asarray(theta), np.asarray(phi))
300
-
301
- if rr.ndim == 0:
302
- return evaluator(float(rr), float(tt), float(pp))
303
-
304
- e_out = np.empty((3,) + rr.shape, dtype=complex)
305
- h_out = np.empty((3,) + rr.shape, dtype=complex)
306
- for idx in np.ndindex(rr.shape):
307
- e_val, h_val = evaluator(float(rr[idx]), float(tt[idx]), float(pp[idx]))
308
- e_out[(slice(None),) + idx] = e_val
309
- h_out[(slice(None),) + idx] = h_val
310
- return e_out, h_out
311
-
312
-
313
- def _cartesian_to_spherical_safe(x, y, z):
314
- """Convert Cartesian coordinates to spherical coordinates safely.
328
+ a, b, c, d = abcd
315
329
 
316
- Args:
317
- x (float or ndarray): Cartesian x coordinate(s).
318
- y (float or ndarray): Cartesian y coordinate(s).
319
- z (float or ndarray): Cartesian z coordinate(s).
330
+ n_terms = len(a)
331
+ nn = np.arange(1, n_terms + 1)
332
+ scale = 1j**nn * (2 * nn + 1) / ((nn + 1) * nn)
320
333
 
321
- Returns:
322
- tuple[ndarray, ndarray, ndarray]: ``(r, theta, phi)`` arrays after
323
- broadcasting the inputs.
324
- """
325
- xx, yy, zz = np.broadcast_arrays(np.asarray(x), np.asarray(y), np.asarray(z))
326
- rr = np.sqrt(xx**2 + yy**2 + zz**2)
327
- with np.errstate(divide="ignore", invalid="ignore"):
328
- cos_theta = np.where(rr == 0, 1.0, np.clip(zz / rr, -1.0, 1.0))
329
- theta = np.arccos(cos_theta)
330
- phi = np.arctan2(yy, xx)
331
- return rr, theta, phi
334
+ rr, tt, pp = np.broadcast_arrays(
335
+ np.asarray(r, dtype=float),
336
+ np.asarray(theta, dtype=float),
337
+ np.asarray(phi, dtype=float),
338
+ )
339
+ shape = rr.shape
340
+ r_flat = rr.reshape(-1)
341
+ theta_flat = tt.reshape(-1)
342
+ phi_flat = pp.reshape(-1)
343
+
344
+ e_out = np.empty((3, r_flat.size), dtype=complex) if want_e else None
345
+ h_out = np.empty((3, r_flat.size), dtype=complex) if want_h else None
346
+
347
+ is_inside = r_flat < d_sphere / 2
348
+ for inside in (True, False):
349
+ group = is_inside if inside else ~is_inside
350
+ if not group.any():
351
+ continue
352
+
353
+ # miepython coefficients follow the n-ik convention and are conjugated
354
+ # internally; use conjugated sphere index so internal fields are consistent.
355
+ m_index = np.conjugate(m_sphere) if inside else n_env
356
+ M_the_base, M_phi_base, N_r_base, N_the_base, N_phi_base = _vsh_components_base(
357
+ n_terms, lambda0, m_index, r_flat[group], theta_flat[group], inside
358
+ )
359
+
360
+ cos_phi = np.cos(phi_flat[group])[:, np.newaxis]
361
+ sin_phi = np.sin(phi_flat[group])[:, np.newaxis]
362
+ zero = np.zeros_like(M_the_base, dtype=np.complex128)
363
+
364
+ if want_e:
365
+ # the electric field pairs the odd magnetic modes with the even electric ones
366
+ M_odd_rad, M_odd_the, M_odd_phi = zero, cos_phi * M_the_base, -sin_phi * M_phi_base
367
+ N_even_rad, N_even_the, N_even_phi = (
368
+ cos_phi * N_r_base,
369
+ cos_phi * N_the_base,
370
+ -sin_phi * N_phi_base,
371
+ )
372
+ if inside:
373
+ e_rad = _sum_two_scaled_terms(scale, c, M_odd_rad, 1.0 + 0.0j, d, N_even_rad, -1.0j)
374
+ e_the = _sum_two_scaled_terms(scale, c, M_odd_the, 1.0 + 0.0j, d, N_even_the, -1.0j)
375
+ e_phi = _sum_two_scaled_terms(scale, c, M_odd_phi, 1.0 + 0.0j, d, N_even_phi, -1.0j)
376
+ else:
377
+ e_rad = _sum_two_scaled_terms(scale, a, N_even_rad, 1.0j, b, M_odd_rad, -1.0 + 0.0j)
378
+ e_the = _sum_two_scaled_terms(scale, a, N_even_the, 1.0j, b, M_odd_the, -1.0 + 0.0j)
379
+ e_phi = _sum_two_scaled_terms(scale, a, N_even_phi, 1.0j, b, M_odd_phi, -1.0 + 0.0j)
380
+ if include_incident:
381
+ e_i = _incident_e_spherical(lambda0, n_env, r_flat[group], theta_flat[group], phi_flat[group])
382
+ e_rad += e_i[0]
383
+ e_the += e_i[1]
384
+ e_phi += e_i[2]
385
+ e_out[:, group] = np.array([e_rad, e_the, e_phi])
386
+
387
+ if want_h:
388
+ # and the magnetic field the other way round
389
+ M_even_rad, M_even_the, M_even_phi = zero, -sin_phi * M_the_base, -cos_phi * M_phi_base
390
+ N_odd_rad, N_odd_the, N_odd_phi = (
391
+ sin_phi * N_r_base,
392
+ sin_phi * N_the_base,
393
+ cos_phi * N_phi_base,
394
+ )
395
+ if inside:
396
+ m_rel = np.conjugate(m_sphere / n_env)
397
+ h_rad = m_rel * _sum_two_scaled_terms(scale, d, M_even_rad, -1.0 + 0.0j, c, N_odd_rad, -1.0j)
398
+ h_the = m_rel * _sum_two_scaled_terms(scale, d, M_even_the, -1.0 + 0.0j, c, N_odd_the, -1.0j)
399
+ h_phi = m_rel * _sum_two_scaled_terms(scale, d, M_even_phi, -1.0 + 0.0j, c, N_odd_phi, -1.0j)
400
+ else:
401
+ h_rad = _sum_two_scaled_terms(scale, b, N_odd_rad, 1.0j, a, M_even_rad, 1.0 + 0.0j)
402
+ h_the = _sum_two_scaled_terms(scale, b, N_odd_the, 1.0j, a, M_even_the, 1.0 + 0.0j)
403
+ h_phi = _sum_two_scaled_terms(scale, b, N_odd_phi, 1.0j, a, M_even_phi, 1.0 + 0.0j)
404
+ if include_incident:
405
+ h_i = _incident_h_spherical(lambda0, n_env, r_flat[group], theta_flat[group], phi_flat[group])
406
+ h_rad += h_i[0]
407
+ h_the += h_i[1]
408
+ h_phi += h_i[2]
409
+ h_out[:, group] = np.array([h_rad, h_the, h_phi])
410
+
411
+ e_final = e_out.reshape((3,) + shape) if want_e else None
412
+ h_final = h_out.reshape((3,) + shape) if want_h else None
413
+ return e_final, h_final
332
414
 
333
415
 
334
416
  def _spherical_components_to_cartesian(field_sph, r, theta, phi):
@@ -347,181 +429,6 @@ def _spherical_components_to_cartesian(field_sph, r, theta, phi):
347
429
  return np.array([fx, fy, fz])
348
430
 
349
431
 
350
- def _e_near_abcd(abcd, lambda0, d_sphere, m_sphere, n_env, r, theta, phi, include_incident):
351
- """Evaluate near-field electric components with precomputed coefficients.
352
-
353
- Args:
354
- abcd (ndarray): Mie coefficients ``[a, b, c, d]``.
355
- lambda0 (float): Vacuum wavelength.
356
- d_sphere (float): Sphere diameter.
357
- m_sphere (complex): Sphere refractive index.
358
- n_env (float): Refractive index of the surrounding medium.
359
- r (float): Radial coordinate.
360
- theta (float): Polar angle in radians.
361
- phi (float): Azimuth angle in radians.
362
- include_incident (bool): Include incident field outside the sphere.
363
-
364
- Returns:
365
- ndarray: Spherical electric components ``[E_r, E_theta, E_phi]``.
366
- """
367
- a, b, c, d = abcd
368
-
369
- N = len(a)
370
- nn = np.arange(1, N + 1)
371
- scale = 1j**nn * (2 * nn + 1) / ((nn + 1) * nn)
372
-
373
- inside = r < d_sphere / 2
374
- # miepython coefficients follow the n-ik convention and are conjugated
375
- # internally; use conjugated sphere index so internal fields are consistent.
376
- m_index = np.conjugate(m_sphere) if inside else n_env
377
-
378
- M_the_base, M_phi_base, N_r_base, N_the_base, N_phi_base = _vsh_components_base(
379
- N, lambda0, d_sphere, m_index, r, theta
380
- )
381
- cos_phi = np.cos(phi)
382
- sin_phi = np.sin(phi)
383
- M_rad = np.zeros(N, dtype=np.complex128)
384
- M_the = cos_phi * M_the_base
385
- M_phi = -sin_phi * M_phi_base
386
- N_rad = cos_phi * N_r_base
387
- N_the = cos_phi * N_the_base
388
- N_phi = -sin_phi * N_phi_base
389
-
390
- if inside:
391
- E_rad = _sum_two_scaled_terms(scale, c, M_rad, 1.0 + 0.0j, d, N_rad, -1.0j)
392
- E_the = _sum_two_scaled_terms(scale, c, M_the, 1.0 + 0.0j, d, N_the, -1.0j)
393
- E_phi = _sum_two_scaled_terms(scale, c, M_phi, 1.0 + 0.0j, d, N_phi, -1.0j)
394
- else:
395
- E_rad = _sum_two_scaled_terms(scale, a, N_rad, 1.0j, b, M_rad, -1.0 + 0.0j)
396
- E_the = _sum_two_scaled_terms(scale, a, N_the, 1.0j, b, M_the, -1.0 + 0.0j)
397
- E_phi = _sum_two_scaled_terms(scale, a, N_phi, 1.0j, b, M_phi, -1.0 + 0.0j)
398
-
399
- if include_incident:
400
- Ei_rad, Ei_the, Ei_phi = _incident_e_spherical(lambda0, n_env, r, theta, phi)
401
- E_rad += Ei_rad
402
- E_the += Ei_the
403
- E_phi += Ei_phi
404
-
405
- return np.array([E_rad, E_the, -E_phi])
406
-
407
-
408
- def _h_near_abcd(abcd, lambda0, d_sphere, m_sphere, n_env, r, theta, phi, include_incident):
409
- """Evaluate near-field magnetic components with precomputed coefficients.
410
-
411
- Args:
412
- abcd (ndarray): Mie coefficients ``[a, b, c, d]``.
413
- lambda0 (float): Vacuum wavelength.
414
- d_sphere (float): Sphere diameter.
415
- m_sphere (complex): Sphere refractive index.
416
- n_env (float): Refractive index of the surrounding medium.
417
- r (float): Radial coordinate.
418
- theta (float): Polar angle in radians.
419
- phi (float): Azimuth angle in radians.
420
- include_incident (bool): Include incident field outside the sphere.
421
-
422
- Returns:
423
- ndarray: Spherical magnetic components ``[H_r, H_theta, H_phi]``.
424
- """
425
- a, b, c, d = abcd
426
-
427
- N = len(a)
428
- nn = np.arange(1, N + 1)
429
- scale = 1j**nn * (2 * nn + 1) / ((nn + 1) * nn)
430
-
431
- inside = r < d_sphere / 2
432
- m_index = np.conjugate(m_sphere) if inside else n_env
433
-
434
- M_the_base, M_phi_base, N_r_base, N_the_base, N_phi_base = _vsh_components_base(
435
- N, lambda0, d_sphere, m_index, r, theta
436
- )
437
- cos_phi = np.cos(phi)
438
- sin_phi = np.sin(phi)
439
- M_rad = np.zeros(N, dtype=np.complex128)
440
- M_the = -sin_phi * M_the_base
441
- M_phi = -cos_phi * M_phi_base
442
- N_rad = sin_phi * N_r_base
443
- N_the = sin_phi * N_the_base
444
- N_phi = cos_phi * N_phi_base
445
-
446
- if inside:
447
- m_rel = np.conjugate(m_sphere / n_env)
448
- H_rad = m_rel * _sum_two_scaled_terms(scale, d, M_rad, -1.0 + 0.0j, c, N_rad, -1.0j)
449
- H_the = m_rel * _sum_two_scaled_terms(scale, d, M_the, -1.0 + 0.0j, c, N_the, -1.0j)
450
- H_phi = m_rel * _sum_two_scaled_terms(scale, d, M_phi, -1.0 + 0.0j, c, N_phi, -1.0j)
451
- else:
452
- H_rad = _sum_two_scaled_terms(scale, b, N_rad, 1.0j, a, M_rad, 1.0 + 0.0j)
453
- H_the = _sum_two_scaled_terms(scale, b, N_the, 1.0j, a, M_the, 1.0 + 0.0j)
454
- H_phi = _sum_two_scaled_terms(scale, b, N_phi, 1.0j, a, M_phi, 1.0 + 0.0j)
455
-
456
- if include_incident:
457
- Hi_rad, Hi_the, Hi_phi = _incident_h_spherical(lambda0, n_env, r, theta, phi)
458
- H_rad += Hi_rad
459
- H_the += Hi_the
460
- H_phi += Hi_phi
461
-
462
- return np.array([H_rad, H_the, -H_phi])
463
-
464
-
465
- def _eh_near_abcd(abcd, lambda0, d_sphere, m_sphere, n_env, r, theta, phi, include_incident):
466
- """Evaluate electric and magnetic near fields with shared VSH work."""
467
- a, b, c, d = abcd
468
-
469
- N = len(a)
470
- nn = np.arange(1, N + 1)
471
- scale = 1j**nn * (2 * nn + 1) / ((nn + 1) * nn)
472
-
473
- inside = r < d_sphere / 2
474
- m_index = np.conjugate(m_sphere) if inside else n_env
475
- M_the_base, M_phi_base, N_r_base, N_the_base, N_phi_base = _vsh_components_base(
476
- N, lambda0, d_sphere, m_index, r, theta
477
- )
478
- cos_phi = np.cos(phi)
479
- sin_phi = np.sin(phi)
480
-
481
- M_odd_rad = np.zeros(N, dtype=np.complex128)
482
- M_odd_the = cos_phi * M_the_base
483
- M_odd_phi = -sin_phi * M_phi_base
484
- N_even_rad = cos_phi * N_r_base
485
- N_even_the = cos_phi * N_the_base
486
- N_even_phi = -sin_phi * N_phi_base
487
-
488
- M_even_rad = np.zeros(N, dtype=np.complex128)
489
- M_even_the = -sin_phi * M_the_base
490
- M_even_phi = -cos_phi * M_phi_base
491
- N_odd_rad = sin_phi * N_r_base
492
- N_odd_the = sin_phi * N_the_base
493
- N_odd_phi = cos_phi * N_phi_base
494
-
495
- if inside:
496
- e_rad = _sum_two_scaled_terms(scale, c, M_odd_rad, 1.0 + 0.0j, d, N_even_rad, -1.0j)
497
- e_the = _sum_two_scaled_terms(scale, c, M_odd_the, 1.0 + 0.0j, d, N_even_the, -1.0j)
498
- e_phi = _sum_two_scaled_terms(scale, c, M_odd_phi, 1.0 + 0.0j, d, N_even_phi, -1.0j)
499
-
500
- m_rel = np.conjugate(m_sphere / n_env)
501
- h_rad = m_rel * _sum_two_scaled_terms(scale, d, M_even_rad, -1.0 + 0.0j, c, N_odd_rad, -1.0j)
502
- h_the = m_rel * _sum_two_scaled_terms(scale, d, M_even_the, -1.0 + 0.0j, c, N_odd_the, -1.0j)
503
- h_phi = m_rel * _sum_two_scaled_terms(scale, d, M_even_phi, -1.0 + 0.0j, c, N_odd_phi, -1.0j)
504
- else:
505
- e_rad = _sum_two_scaled_terms(scale, a, N_even_rad, 1.0j, b, M_odd_rad, -1.0 + 0.0j)
506
- e_the = _sum_two_scaled_terms(scale, a, N_even_the, 1.0j, b, M_odd_the, -1.0 + 0.0j)
507
- e_phi = _sum_two_scaled_terms(scale, a, N_even_phi, 1.0j, b, M_odd_phi, -1.0 + 0.0j)
508
- h_rad = _sum_two_scaled_terms(scale, b, N_odd_rad, 1.0j, a, M_even_rad, 1.0 + 0.0j)
509
- h_the = _sum_two_scaled_terms(scale, b, N_odd_the, 1.0j, a, M_even_the, 1.0 + 0.0j)
510
- h_phi = _sum_two_scaled_terms(scale, b, N_odd_phi, 1.0j, a, M_even_phi, 1.0 + 0.0j)
511
-
512
- if include_incident:
513
- e_i = _incident_e_spherical(lambda0, n_env, r, theta, phi)
514
- h_i = _incident_h_spherical(lambda0, n_env, r, theta, phi)
515
- e_rad += e_i[0]
516
- e_the += e_i[1]
517
- e_phi += e_i[2]
518
- h_rad += h_i[0]
519
- h_the += h_i[1]
520
- h_phi += h_i[2]
521
-
522
- return np.array([e_rad, e_the, -e_phi]), np.array([h_rad, h_the, -h_phi])
523
-
524
-
525
432
  def e_near(lambda0, d_sphere, m_sphere, n_env, r, theta, phi, include_incident=True, n_pole=0, abcd=None):
526
433
  """Calculate the electric field in and around a sphere.
527
434
 
@@ -534,7 +441,9 @@ def e_near(lambda0, d_sphere, m_sphere, n_env, r, theta, phi, include_incident=T
534
441
  theta (float or ndarray): Polar angle(s) in radians.
535
442
  phi (float or ndarray): Azimuth angle(s) in radians.
536
443
  include_incident (bool): Include incident field for points outside sphere.
537
- n_pole (int): Requested multipole order. ``0`` means automatic truncation.
444
+ n_pole (int): Number of multipole terms to keep. ``0`` (the default) keeps
445
+ two more than Wiscombe's criterion, which converges the near field at
446
+ the sphere surface roughly five times closer than the criterion alone.
538
447
  abcd (ndarray or None): Optional precomputed coefficients ``[a, b, c, d]``.
539
448
  If provided, ``n_pole`` is ignored.
540
449
 
@@ -545,10 +454,19 @@ def e_near(lambda0, d_sphere, m_sphere, n_env, r, theta, phi, include_incident=T
545
454
  if abcd is None:
546
455
  abcd = _coefficients_abcd(lambda0, d_sphere, m_sphere, n_env, n_pole)
547
456
 
548
- evaluator = lambda rr, tt, pp: _e_near_abcd( # noqa: E731
549
- abcd, lambda0, d_sphere, m_sphere, n_env, rr, tt, pp, include_incident
550
- )
551
- return _vectorized_field_eval(evaluator, r, theta, phi)
457
+ return _near_fields(
458
+ abcd,
459
+ lambda0,
460
+ d_sphere,
461
+ m_sphere,
462
+ n_env,
463
+ r,
464
+ theta,
465
+ phi,
466
+ include_incident,
467
+ want_e=True,
468
+ want_h=False,
469
+ )[0]
552
470
 
553
471
 
554
472
  def h_near(lambda0, d_sphere, m_sphere, n_env, r, theta, phi, include_incident=True, n_pole=0, abcd=None):
@@ -563,7 +481,9 @@ def h_near(lambda0, d_sphere, m_sphere, n_env, r, theta, phi, include_incident=T
563
481
  theta (float or ndarray): Polar angle(s) in radians.
564
482
  phi (float or ndarray): Azimuth angle(s) in radians.
565
483
  include_incident (bool): Include incident field for points outside sphere.
566
- n_pole (int): Requested multipole order. ``0`` means automatic truncation.
484
+ n_pole (int): Number of multipole terms to keep. ``0`` (the default) keeps
485
+ two more than Wiscombe's criterion, which converges the near field at
486
+ the sphere surface roughly five times closer than the criterion alone.
567
487
  abcd (ndarray or None): Optional precomputed coefficients ``[a, b, c, d]``.
568
488
  If provided, ``n_pole`` is ignored.
569
489
 
@@ -574,10 +494,19 @@ def h_near(lambda0, d_sphere, m_sphere, n_env, r, theta, phi, include_incident=T
574
494
  if abcd is None:
575
495
  abcd = _coefficients_abcd(lambda0, d_sphere, m_sphere, n_env, n_pole)
576
496
 
577
- evaluator = lambda rr, tt, pp: _h_near_abcd( # noqa: E731
578
- abcd, lambda0, d_sphere, m_sphere, n_env, rr, tt, pp, include_incident
579
- )
580
- return _vectorized_field_eval(evaluator, r, theta, phi)
497
+ return _near_fields(
498
+ abcd,
499
+ lambda0,
500
+ d_sphere,
501
+ m_sphere,
502
+ n_env,
503
+ r,
504
+ theta,
505
+ phi,
506
+ include_incident,
507
+ want_e=False,
508
+ want_h=True,
509
+ )[1]
581
510
 
582
511
 
583
512
  def eh_near(
@@ -603,7 +532,9 @@ def eh_near(
603
532
  theta (float or ndarray): Polar angle(s) in radians.
604
533
  phi (float or ndarray): Azimuth angle(s) in radians.
605
534
  include_incident (bool): Include incident field for points outside sphere.
606
- n_pole (int): Requested multipole order. ``0`` means automatic truncation.
535
+ n_pole (int): Number of multipole terms to keep. ``0`` (the default) keeps
536
+ two more than Wiscombe's criterion, which converges the near field at
537
+ the sphere surface roughly five times closer than the criterion alone.
607
538
  abcd (ndarray or None): Optional precomputed coefficients ``[a, b, c, d]``.
608
539
  If provided, ``n_pole`` is ignored.
609
540
 
@@ -614,10 +545,19 @@ def eh_near(
614
545
  if abcd is None:
615
546
  abcd = _coefficients_abcd(lambda0, d_sphere, m_sphere, n_env, n_pole)
616
547
 
617
- evaluator = lambda rr, tt, pp: _eh_near_abcd( # noqa: E731
618
- abcd, lambda0, d_sphere, m_sphere, n_env, rr, tt, pp, include_incident
548
+ return _near_fields(
549
+ abcd,
550
+ lambda0,
551
+ d_sphere,
552
+ m_sphere,
553
+ n_env,
554
+ r,
555
+ theta,
556
+ phi,
557
+ include_incident,
558
+ want_e=True,
559
+ want_h=True,
619
560
  )
620
- return _vectorized_field_pair_eval(evaluator, r, theta, phi)
621
561
 
622
562
 
623
563
  def e_near_cartesian(
@@ -643,13 +583,15 @@ def e_near_cartesian(
643
583
  y (float or ndarray): Cartesian y coordinate(s).
644
584
  z (float or ndarray): Cartesian z coordinate(s).
645
585
  include_incident (bool): Include incident field for points outside sphere.
646
- n_pole (int): Requested multipole order. ``0`` means automatic truncation.
586
+ n_pole (int): Number of multipole terms to keep. ``0`` (the default) keeps
587
+ two more than Wiscombe's criterion, which converges the near field at
588
+ the sphere surface roughly five times closer than the criterion alone.
647
589
  abcd (ndarray or None): Optional precomputed coefficients ``[a, b, c, d]``.
648
590
 
649
591
  Returns:
650
592
  ndarray: Cartesian electric components ``[E_x, E_y, E_z]``.
651
593
  """
652
- r, theta, phi = _cartesian_to_spherical_safe(x, y, z)
594
+ r, theta, phi = cartesian_to_spherical(x, y, z)
653
595
  e_sph = e_near(lambda0, d_sphere, m_sphere, n_env, r, theta, phi, include_incident, n_pole, abcd)
654
596
  return _spherical_components_to_cartesian(e_sph, r, theta, phi)
655
597
 
@@ -677,13 +619,15 @@ def h_near_cartesian(
677
619
  y (float or ndarray): Cartesian y coordinate(s).
678
620
  z (float or ndarray): Cartesian z coordinate(s).
679
621
  include_incident (bool): Include incident field for points outside sphere.
680
- n_pole (int): Requested multipole order. ``0`` means automatic truncation.
622
+ n_pole (int): Number of multipole terms to keep. ``0`` (the default) keeps
623
+ two more than Wiscombe's criterion, which converges the near field at
624
+ the sphere surface roughly five times closer than the criterion alone.
681
625
  abcd (ndarray or None): Optional precomputed coefficients ``[a, b, c, d]``.
682
626
 
683
627
  Returns:
684
628
  ndarray: Cartesian magnetic components ``[H_x, H_y, H_z]``.
685
629
  """
686
- r, theta, phi = _cartesian_to_spherical_safe(x, y, z)
630
+ r, theta, phi = cartesian_to_spherical(x, y, z)
687
631
  h_sph = h_near(lambda0, d_sphere, m_sphere, n_env, r, theta, phi, include_incident, n_pole, abcd)
688
632
  return _spherical_components_to_cartesian(h_sph, r, theta, phi)
689
633
 
@@ -711,14 +655,16 @@ def eh_near_cartesian(
711
655
  y (float or ndarray): Cartesian y coordinate(s).
712
656
  z (float or ndarray): Cartesian z coordinate(s).
713
657
  include_incident (bool): Include incident field for points outside sphere.
714
- n_pole (int): Requested multipole order. ``0`` means automatic truncation.
658
+ n_pole (int): Number of multipole terms to keep. ``0`` (the default) keeps
659
+ two more than Wiscombe's criterion, which converges the near field at
660
+ the sphere surface roughly five times closer than the criterion alone.
715
661
  abcd (ndarray or None): Optional precomputed coefficients ``[a, b, c, d]``.
716
662
 
717
663
  Returns:
718
664
  tuple[ndarray, ndarray]: Tuple ``(E_xyz, H_xyz)`` where each array is
719
665
  ``[x, y, z]`` components.
720
666
  """
721
- r, theta, phi = _cartesian_to_spherical_safe(x, y, z)
667
+ r, theta, phi = cartesian_to_spherical(x, y, z)
722
668
  e_sph, h_sph = eh_near(lambda0, d_sphere, m_sphere, n_env, r, theta, phi, include_incident, n_pole, abcd)
723
669
  e_xyz = _spherical_components_to_cartesian(e_sph, r, theta, phi)
724
670
  h_xyz = _spherical_components_to_cartesian(h_sph, r, theta, phi)