miepython 3.2.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
@@ -81,9 +81,9 @@ Conventions
81
81
  import numpy as np
82
82
  from scipy.special import factorial2, spherical_jn
83
83
  from ._backend import D_calc, pi_tau
84
- from .bessel import d_riccati_bessel_h1, spherical_h1
85
- from .core import S1_S2, coefficients
86
- from .util import spherical_vector_to_cartesian
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
87
87
 
88
88
  __all__ = (
89
89
  "e_near",
@@ -97,62 +97,94 @@ __all__ = (
97
97
 
98
98
 
99
99
  def _sum_two_scaled_terms(scale, coeff1, values1, scale1, coeff2, values2, scale2):
100
- """Return ``sum(scale * (scale1*coeff1*values1 + scale2*coeff2*values2))``."""
101
- 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)
102
107
 
103
108
 
104
- def _vsh_components_base(n_terms, lambda0, d_sphere, m_index, r, theta):
105
- """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.
106
115
 
107
116
  Args:
108
117
  n_terms (int): Number of multipole terms.
109
118
  lambda0 (float): Vacuum wavelength.
110
- d_sphere (float): Sphere diameter.
111
- m_index (complex): Refractive index at the evaluation point.
112
- r (float): Radial coordinate.
113
- 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.
114
123
 
115
124
  Returns:
116
125
  tuple[ndarray, ndarray, ndarray, ndarray, ndarray]:
117
- ``(M_theta_base, M_phi_base, N_r_base, N_theta_base, N_phi_base)``
118
- 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``.
119
128
  """
120
- mu = float(np.cos(theta))
121
- if mu >= 1.0:
122
- mu = 0.999999
123
- elif mu <= -1.0:
124
- mu = -0.999999
125
-
126
- pi = np.empty(n_terms)
127
- tau = np.empty(n_terms)
128
- 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])
129
142
 
130
143
  n_int = np.arange(1, n_terms + 1, dtype=np.int64)
131
144
  n_arr = n_int.astype(np.float64)
132
- rho = 2 * np.pi * m_index * r / lambda0
133
- kr = 2 * np.pi * r / lambda0
134
- 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
135
148
 
136
149
  if inside:
137
150
  jn = spherical_jn(n_int, rho)
138
151
  m_factor = jn
139
152
 
140
- if np.abs(rho) < 0.01:
141
- 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)
142
161
  denom = factorial2(2 * n_int + 1)
143
- n_factor1 = rho_pow / denom
144
- n_factor2 = (n_arr + 1.0) * rho_pow / denom
145
- else:
146
- d_vals = D_calc(np.complex128(m_index), float(kr), n_terms + 1)[:n_terms]
147
- n_factor1 = jn / rho
148
- 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
149
174
  else:
150
- 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]
151
180
  m_factor = h1
152
181
  n_factor1 = h1 / rho
153
- 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
154
186
 
155
- sin_theta = np.sin(theta)
187
+ sin_theta = np.sin(theta)[:, np.newaxis]
156
188
  M_theta_base = pi * m_factor
157
189
  M_phi_base = tau * m_factor
158
190
  N_r_base = n_arr * (n_arr + 1.0) * sin_theta * pi * n_factor1
@@ -248,88 +280,137 @@ def _coefficients_abcd(lambda0, d_sphere, m_sphere, n_env, n_pole):
248
280
  d_sphere (float): Sphere diameter.
249
281
  m_sphere (complex): Sphere refractive index.
250
282
  n_env (float): Refractive index of the surrounding medium.
251
- n_pole (int): Requested multipole order. ``0`` means automatic truncation.
283
+ n_pole (int): Number of terms to keep. ``0`` means automatic truncation.
252
284
 
253
285
  Returns:
254
286
  ndarray: Coefficients packed as ``[a, b, c, d]``.
255
287
  """
256
288
  x = np.pi * d_sphere * n_env / lambda0
257
289
  m_rel = m_sphere / n_env
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
258
298
  a, b, c, d = coefficients(m_rel, x, n_pole=n_pole, internal=True)
259
299
  return np.array([a, b, c, d])
260
300
 
261
301
 
262
- def _vectorized_field_eval(evaluator, r, theta, phi):
263
- """Evaluate a spherical-field callback on scalar or broadcasted inputs.
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.
264
304
 
265
- Args:
266
- evaluator (callable): Callable that accepts scalar ``(r, theta, phi)``
267
- and returns a complex 3-vector.
268
- r (float or ndarray): Radial coordinate(s).
269
- theta (float or ndarray): Polar angle(s) in radians.
270
- phi (float or ndarray): Azimuth angle(s) in radians.
271
-
272
- Returns:
273
- ndarray: Complex array with shape ``(3, ...)``.
274
- """
275
- rr, tt, pp = np.broadcast_arrays(np.asarray(r), np.asarray(theta), np.asarray(phi))
276
-
277
- if rr.ndim == 0:
278
- return evaluator(float(rr), float(tt), float(pp))
279
-
280
- out = np.empty((3,) + rr.shape, dtype=complex)
281
- for idx in np.ndindex(rr.shape):
282
- out[(slice(None),) + idx] = evaluator(float(rr[idx]), float(tt[idx]), float(pp[idx]))
283
- return out
284
-
285
-
286
- def _vectorized_field_pair_eval(evaluator, r, theta, phi):
287
- """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.
288
310
 
289
311
  Args:
290
- evaluator (callable): Callable that accepts scalar ``(r, theta, phi)``
291
- 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.
292
317
  r (float or ndarray): Radial coordinate(s).
293
318
  theta (float or ndarray): Polar angle(s) in radians.
294
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.
295
323
 
296
324
  Returns:
297
- tuple[ndarray, ndarray]: Tuple ``(E, H)`` with each array shaped
298
- ``(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.
299
327
  """
300
- rr, tt, pp = np.broadcast_arrays(np.asarray(r), np.asarray(theta), np.asarray(phi))
301
-
302
- if rr.ndim == 0:
303
- return evaluator(float(rr), float(tt), float(pp))
304
-
305
- e_out = np.empty((3,) + rr.shape, dtype=complex)
306
- h_out = np.empty((3,) + rr.shape, dtype=complex)
307
- for idx in np.ndindex(rr.shape):
308
- e_val, h_val = evaluator(float(rr[idx]), float(tt[idx]), float(pp[idx]))
309
- e_out[(slice(None),) + idx] = e_val
310
- h_out[(slice(None),) + idx] = h_val
311
- return e_out, h_out
312
-
313
-
314
- def _cartesian_to_spherical_safe(x, y, z):
315
- """Convert Cartesian coordinates to spherical coordinates safely.
328
+ a, b, c, d = abcd
316
329
 
317
- Args:
318
- x (float or ndarray): Cartesian x coordinate(s).
319
- y (float or ndarray): Cartesian y coordinate(s).
320
- 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)
321
333
 
322
- Returns:
323
- tuple[ndarray, ndarray, ndarray]: ``(r, theta, phi)`` arrays after
324
- broadcasting the inputs.
325
- """
326
- xx, yy, zz = np.broadcast_arrays(np.asarray(x), np.asarray(y), np.asarray(z))
327
- rr = np.sqrt(xx**2 + yy**2 + zz**2)
328
- with np.errstate(divide="ignore", invalid="ignore"):
329
- cos_theta = np.where(rr == 0, 1.0, np.clip(zz / rr, -1.0, 1.0))
330
- theta = np.arccos(cos_theta)
331
- phi = np.arctan2(yy, xx)
332
- 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
333
414
 
334
415
 
335
416
  def _spherical_components_to_cartesian(field_sph, r, theta, phi):
@@ -348,181 +429,6 @@ def _spherical_components_to_cartesian(field_sph, r, theta, phi):
348
429
  return np.array([fx, fy, fz])
349
430
 
350
431
 
351
- def _e_near_abcd(abcd, lambda0, d_sphere, m_sphere, n_env, r, theta, phi, include_incident):
352
- """Evaluate near-field electric components with precomputed coefficients.
353
-
354
- Args:
355
- abcd (ndarray): Mie coefficients ``[a, b, c, d]``.
356
- lambda0 (float): Vacuum wavelength.
357
- d_sphere (float): Sphere diameter.
358
- m_sphere (complex): Sphere refractive index.
359
- n_env (float): Refractive index of the surrounding medium.
360
- r (float): Radial coordinate.
361
- theta (float): Polar angle in radians.
362
- phi (float): Azimuth angle in radians.
363
- include_incident (bool): Include incident field outside the sphere.
364
-
365
- Returns:
366
- ndarray: Spherical electric components ``[E_r, E_theta, E_phi]``.
367
- """
368
- a, b, c, d = abcd
369
-
370
- N = len(a)
371
- nn = np.arange(1, N + 1)
372
- scale = 1j**nn * (2 * nn + 1) / ((nn + 1) * nn)
373
-
374
- inside = r < d_sphere / 2
375
- # miepython coefficients follow the n-ik convention and are conjugated
376
- # internally; use conjugated sphere index so internal fields are consistent.
377
- m_index = np.conjugate(m_sphere) if inside else n_env
378
-
379
- M_the_base, M_phi_base, N_r_base, N_the_base, N_phi_base = _vsh_components_base(
380
- N, lambda0, d_sphere, m_index, r, theta
381
- )
382
- cos_phi = np.cos(phi)
383
- sin_phi = np.sin(phi)
384
- M_rad = np.zeros(N, dtype=np.complex128)
385
- M_the = cos_phi * M_the_base
386
- M_phi = -sin_phi * M_phi_base
387
- N_rad = cos_phi * N_r_base
388
- N_the = cos_phi * N_the_base
389
- N_phi = -sin_phi * N_phi_base
390
-
391
- if inside:
392
- E_rad = _sum_two_scaled_terms(scale, c, M_rad, 1.0 + 0.0j, d, N_rad, -1.0j)
393
- E_the = _sum_two_scaled_terms(scale, c, M_the, 1.0 + 0.0j, d, N_the, -1.0j)
394
- E_phi = _sum_two_scaled_terms(scale, c, M_phi, 1.0 + 0.0j, d, N_phi, -1.0j)
395
- else:
396
- E_rad = _sum_two_scaled_terms(scale, a, N_rad, 1.0j, b, M_rad, -1.0 + 0.0j)
397
- E_the = _sum_two_scaled_terms(scale, a, N_the, 1.0j, b, M_the, -1.0 + 0.0j)
398
- E_phi = _sum_two_scaled_terms(scale, a, N_phi, 1.0j, b, M_phi, -1.0 + 0.0j)
399
-
400
- if include_incident:
401
- Ei_rad, Ei_the, Ei_phi = _incident_e_spherical(lambda0, n_env, r, theta, phi)
402
- E_rad += Ei_rad
403
- E_the += Ei_the
404
- E_phi += Ei_phi
405
-
406
- return np.array([E_rad, E_the, E_phi])
407
-
408
-
409
- def _h_near_abcd(abcd, lambda0, d_sphere, m_sphere, n_env, r, theta, phi, include_incident):
410
- """Evaluate near-field magnetic components with precomputed coefficients.
411
-
412
- Args:
413
- abcd (ndarray): Mie coefficients ``[a, b, c, d]``.
414
- lambda0 (float): Vacuum wavelength.
415
- d_sphere (float): Sphere diameter.
416
- m_sphere (complex): Sphere refractive index.
417
- n_env (float): Refractive index of the surrounding medium.
418
- r (float): Radial coordinate.
419
- theta (float): Polar angle in radians.
420
- phi (float): Azimuth angle in radians.
421
- include_incident (bool): Include incident field outside the sphere.
422
-
423
- Returns:
424
- ndarray: Spherical magnetic components ``[H_r, H_theta, H_phi]``.
425
- """
426
- a, b, c, d = abcd
427
-
428
- N = len(a)
429
- nn = np.arange(1, N + 1)
430
- scale = 1j**nn * (2 * nn + 1) / ((nn + 1) * nn)
431
-
432
- inside = r < d_sphere / 2
433
- m_index = np.conjugate(m_sphere) if inside else n_env
434
-
435
- M_the_base, M_phi_base, N_r_base, N_the_base, N_phi_base = _vsh_components_base(
436
- N, lambda0, d_sphere, m_index, r, theta
437
- )
438
- cos_phi = np.cos(phi)
439
- sin_phi = np.sin(phi)
440
- M_rad = np.zeros(N, dtype=np.complex128)
441
- M_the = -sin_phi * M_the_base
442
- M_phi = -cos_phi * M_phi_base
443
- N_rad = sin_phi * N_r_base
444
- N_the = sin_phi * N_the_base
445
- N_phi = cos_phi * N_phi_base
446
-
447
- if inside:
448
- m_rel = np.conjugate(m_sphere / n_env)
449
- H_rad = m_rel * _sum_two_scaled_terms(scale, d, M_rad, -1.0 + 0.0j, c, N_rad, -1.0j)
450
- H_the = m_rel * _sum_two_scaled_terms(scale, d, M_the, -1.0 + 0.0j, c, N_the, -1.0j)
451
- H_phi = m_rel * _sum_two_scaled_terms(scale, d, M_phi, -1.0 + 0.0j, c, N_phi, -1.0j)
452
- else:
453
- H_rad = _sum_two_scaled_terms(scale, b, N_rad, 1.0j, a, M_rad, 1.0 + 0.0j)
454
- H_the = _sum_two_scaled_terms(scale, b, N_the, 1.0j, a, M_the, 1.0 + 0.0j)
455
- H_phi = _sum_two_scaled_terms(scale, b, N_phi, 1.0j, a, M_phi, 1.0 + 0.0j)
456
-
457
- if include_incident:
458
- Hi_rad, Hi_the, Hi_phi = _incident_h_spherical(lambda0, n_env, r, theta, phi)
459
- H_rad += Hi_rad
460
- H_the += Hi_the
461
- H_phi += Hi_phi
462
-
463
- return np.array([H_rad, H_the, H_phi])
464
-
465
-
466
- def _eh_near_abcd(abcd, lambda0, d_sphere, m_sphere, n_env, r, theta, phi, include_incident):
467
- """Evaluate electric and magnetic near fields with shared VSH work."""
468
- a, b, c, d = abcd
469
-
470
- N = len(a)
471
- nn = np.arange(1, N + 1)
472
- scale = 1j**nn * (2 * nn + 1) / ((nn + 1) * nn)
473
-
474
- inside = r < d_sphere / 2
475
- m_index = np.conjugate(m_sphere) if inside else n_env
476
- M_the_base, M_phi_base, N_r_base, N_the_base, N_phi_base = _vsh_components_base(
477
- N, lambda0, d_sphere, m_index, r, theta
478
- )
479
- cos_phi = np.cos(phi)
480
- sin_phi = np.sin(phi)
481
-
482
- M_odd_rad = np.zeros(N, dtype=np.complex128)
483
- M_odd_the = cos_phi * M_the_base
484
- M_odd_phi = -sin_phi * M_phi_base
485
- N_even_rad = cos_phi * N_r_base
486
- N_even_the = cos_phi * N_the_base
487
- N_even_phi = -sin_phi * N_phi_base
488
-
489
- M_even_rad = np.zeros(N, dtype=np.complex128)
490
- M_even_the = -sin_phi * M_the_base
491
- M_even_phi = -cos_phi * M_phi_base
492
- N_odd_rad = sin_phi * N_r_base
493
- N_odd_the = sin_phi * N_the_base
494
- N_odd_phi = cos_phi * N_phi_base
495
-
496
- if inside:
497
- e_rad = _sum_two_scaled_terms(scale, c, M_odd_rad, 1.0 + 0.0j, d, N_even_rad, -1.0j)
498
- e_the = _sum_two_scaled_terms(scale, c, M_odd_the, 1.0 + 0.0j, d, N_even_the, -1.0j)
499
- e_phi = _sum_two_scaled_terms(scale, c, M_odd_phi, 1.0 + 0.0j, d, N_even_phi, -1.0j)
500
-
501
- m_rel = np.conjugate(m_sphere / n_env)
502
- h_rad = m_rel * _sum_two_scaled_terms(scale, d, M_even_rad, -1.0 + 0.0j, c, N_odd_rad, -1.0j)
503
- h_the = m_rel * _sum_two_scaled_terms(scale, d, M_even_the, -1.0 + 0.0j, c, N_odd_the, -1.0j)
504
- h_phi = m_rel * _sum_two_scaled_terms(scale, d, M_even_phi, -1.0 + 0.0j, c, N_odd_phi, -1.0j)
505
- else:
506
- e_rad = _sum_two_scaled_terms(scale, a, N_even_rad, 1.0j, b, M_odd_rad, -1.0 + 0.0j)
507
- e_the = _sum_two_scaled_terms(scale, a, N_even_the, 1.0j, b, M_odd_the, -1.0 + 0.0j)
508
- e_phi = _sum_two_scaled_terms(scale, a, N_even_phi, 1.0j, b, M_odd_phi, -1.0 + 0.0j)
509
- h_rad = _sum_two_scaled_terms(scale, b, N_odd_rad, 1.0j, a, M_even_rad, 1.0 + 0.0j)
510
- h_the = _sum_two_scaled_terms(scale, b, N_odd_the, 1.0j, a, M_even_the, 1.0 + 0.0j)
511
- h_phi = _sum_two_scaled_terms(scale, b, N_odd_phi, 1.0j, a, M_even_phi, 1.0 + 0.0j)
512
-
513
- if include_incident:
514
- e_i = _incident_e_spherical(lambda0, n_env, r, theta, phi)
515
- h_i = _incident_h_spherical(lambda0, n_env, r, theta, phi)
516
- e_rad += e_i[0]
517
- e_the += e_i[1]
518
- e_phi += e_i[2]
519
- h_rad += h_i[0]
520
- h_the += h_i[1]
521
- h_phi += h_i[2]
522
-
523
- return np.array([e_rad, e_the, e_phi]), np.array([h_rad, h_the, h_phi])
524
-
525
-
526
432
  def e_near(lambda0, d_sphere, m_sphere, n_env, r, theta, phi, include_incident=True, n_pole=0, abcd=None):
527
433
  """Calculate the electric field in and around a sphere.
528
434
 
@@ -535,7 +441,9 @@ def e_near(lambda0, d_sphere, m_sphere, n_env, r, theta, phi, include_incident=T
535
441
  theta (float or ndarray): Polar angle(s) in radians.
536
442
  phi (float or ndarray): Azimuth angle(s) in radians.
537
443
  include_incident (bool): Include incident field for points outside sphere.
538
- 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.
539
447
  abcd (ndarray or None): Optional precomputed coefficients ``[a, b, c, d]``.
540
448
  If provided, ``n_pole`` is ignored.
541
449
 
@@ -546,10 +454,19 @@ def e_near(lambda0, d_sphere, m_sphere, n_env, r, theta, phi, include_incident=T
546
454
  if abcd is None:
547
455
  abcd = _coefficients_abcd(lambda0, d_sphere, m_sphere, n_env, n_pole)
548
456
 
549
- def evaluator(rr, tt, pp):
550
- return _e_near_abcd(abcd, lambda0, d_sphere, m_sphere, n_env, rr, tt, pp, include_incident)
551
-
552
- 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]
553
470
 
554
471
 
555
472
  def h_near(lambda0, d_sphere, m_sphere, n_env, r, theta, phi, include_incident=True, n_pole=0, abcd=None):
@@ -564,7 +481,9 @@ def h_near(lambda0, d_sphere, m_sphere, n_env, r, theta, phi, include_incident=T
564
481
  theta (float or ndarray): Polar angle(s) in radians.
565
482
  phi (float or ndarray): Azimuth angle(s) in radians.
566
483
  include_incident (bool): Include incident field for points outside sphere.
567
- 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.
568
487
  abcd (ndarray or None): Optional precomputed coefficients ``[a, b, c, d]``.
569
488
  If provided, ``n_pole`` is ignored.
570
489
 
@@ -575,10 +494,19 @@ def h_near(lambda0, d_sphere, m_sphere, n_env, r, theta, phi, include_incident=T
575
494
  if abcd is None:
576
495
  abcd = _coefficients_abcd(lambda0, d_sphere, m_sphere, n_env, n_pole)
577
496
 
578
- def evaluator(rr, tt, pp):
579
- return _h_near_abcd(abcd, lambda0, d_sphere, m_sphere, n_env, rr, tt, pp, include_incident)
580
-
581
- 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]
582
510
 
583
511
 
584
512
  def eh_near(
@@ -604,7 +532,9 @@ def eh_near(
604
532
  theta (float or ndarray): Polar angle(s) in radians.
605
533
  phi (float or ndarray): Azimuth angle(s) in radians.
606
534
  include_incident (bool): Include incident field for points outside sphere.
607
- 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.
608
538
  abcd (ndarray or None): Optional precomputed coefficients ``[a, b, c, d]``.
609
539
  If provided, ``n_pole`` is ignored.
610
540
 
@@ -615,10 +545,19 @@ def eh_near(
615
545
  if abcd is None:
616
546
  abcd = _coefficients_abcd(lambda0, d_sphere, m_sphere, n_env, n_pole)
617
547
 
618
- def evaluator(rr, tt, pp):
619
- return _eh_near_abcd(abcd, lambda0, d_sphere, m_sphere, n_env, rr, tt, pp, include_incident)
620
-
621
- return _vectorized_field_pair_eval(evaluator, r, theta, phi)
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,
560
+ )
622
561
 
623
562
 
624
563
  def e_near_cartesian(
@@ -644,13 +583,15 @@ def e_near_cartesian(
644
583
  y (float or ndarray): Cartesian y coordinate(s).
645
584
  z (float or ndarray): Cartesian z coordinate(s).
646
585
  include_incident (bool): Include incident field for points outside sphere.
647
- 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.
648
589
  abcd (ndarray or None): Optional precomputed coefficients ``[a, b, c, d]``.
649
590
 
650
591
  Returns:
651
592
  ndarray: Cartesian electric components ``[E_x, E_y, E_z]``.
652
593
  """
653
- r, theta, phi = _cartesian_to_spherical_safe(x, y, z)
594
+ r, theta, phi = cartesian_to_spherical(x, y, z)
654
595
  e_sph = e_near(lambda0, d_sphere, m_sphere, n_env, r, theta, phi, include_incident, n_pole, abcd)
655
596
  return _spherical_components_to_cartesian(e_sph, r, theta, phi)
656
597
 
@@ -678,13 +619,15 @@ def h_near_cartesian(
678
619
  y (float or ndarray): Cartesian y coordinate(s).
679
620
  z (float or ndarray): Cartesian z coordinate(s).
680
621
  include_incident (bool): Include incident field for points outside sphere.
681
- 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.
682
625
  abcd (ndarray or None): Optional precomputed coefficients ``[a, b, c, d]``.
683
626
 
684
627
  Returns:
685
628
  ndarray: Cartesian magnetic components ``[H_x, H_y, H_z]``.
686
629
  """
687
- r, theta, phi = _cartesian_to_spherical_safe(x, y, z)
630
+ r, theta, phi = cartesian_to_spherical(x, y, z)
688
631
  h_sph = h_near(lambda0, d_sphere, m_sphere, n_env, r, theta, phi, include_incident, n_pole, abcd)
689
632
  return _spherical_components_to_cartesian(h_sph, r, theta, phi)
690
633
 
@@ -712,14 +655,16 @@ def eh_near_cartesian(
712
655
  y (float or ndarray): Cartesian y coordinate(s).
713
656
  z (float or ndarray): Cartesian z coordinate(s).
714
657
  include_incident (bool): Include incident field for points outside sphere.
715
- 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.
716
661
  abcd (ndarray or None): Optional precomputed coefficients ``[a, b, c, d]``.
717
662
 
718
663
  Returns:
719
664
  tuple[ndarray, ndarray]: Tuple ``(E_xyz, H_xyz)`` where each array is
720
665
  ``[x, y, z]`` components.
721
666
  """
722
- r, theta, phi = _cartesian_to_spherical_safe(x, y, z)
667
+ r, theta, phi = cartesian_to_spherical(x, y, z)
723
668
  e_sph, h_sph = eh_near(lambda0, d_sphere, m_sphere, n_env, r, theta, phi, include_incident, n_pole, abcd)
724
669
  e_xyz = _spherical_components_to_cartesian(e_sph, r, theta, phi)
725
670
  h_xyz = _spherical_components_to_cartesian(h_sph, r, theta, phi)