miepython 3.2.0__py3-none-any.whl → 3.3.1__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, _check_m_x, 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,138 @@ 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
+ _check_m_x(m_rel, x) # before wiscombe_terms, which cannot take a NaN x
291
+ if n_pole == 0:
292
+ # Wiscombe's criterion truncates the scattered series, which converges
293
+ # faster than the field evaluated right at the surface. Now that an_bn
294
+ # builds psi_n stably, extra orders converge instead of diverging: the
295
+ # tangential boundary mismatch falls from 1.5e-5 at the criterion to
296
+ # 3.6e-6 with one extra order and 3.2e-6 with two, then flattens out, so
297
+ # two is where it stops being worth more terms.
298
+ n_pole = wiscombe_terms(x) + 2
258
299
  a, b, c, d = coefficients(m_rel, x, n_pole=n_pole, internal=True)
259
300
  return np.array([a, b, c, d])
260
301
 
261
302
 
262
- def _vectorized_field_eval(evaluator, r, theta, phi):
263
- """Evaluate a spherical-field callback on scalar or broadcasted inputs.
303
+ def _near_fields(abcd, lambda0, d_sphere, m_sphere, n_env, r, theta, phi, include_incident, want_e, want_h):
304
+ """Evaluate the near fields at every requested point.
264
305
 
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.
306
+ The points are split once into those inside the sphere and those outside, and each
307
+ group is evaluated as a batch. That grouping is what makes the work vectorizable:
308
+ the two sides use different radial functions and different media, but within a
309
+ side every point does the same arithmetic, so the multipole series becomes a sum
310
+ over the last axis of a ``(n_points, n_terms)`` array.
288
311
 
289
312
  Args:
290
- evaluator (callable): Callable that accepts scalar ``(r, theta, phi)``
291
- and returns a tuple ``(E, H)``, each a complex 3-vector.
313
+ abcd (ndarray): Mie coefficients ``[a, b, c, d]``.
314
+ lambda0 (float): Vacuum wavelength.
315
+ d_sphere (float): Sphere diameter.
316
+ m_sphere (complex): Sphere refractive index.
317
+ n_env (float): Refractive index of the surrounding medium.
292
318
  r (float or ndarray): Radial coordinate(s).
293
319
  theta (float or ndarray): Polar angle(s) in radians.
294
320
  phi (float or ndarray): Azimuth angle(s) in radians.
321
+ include_incident (bool): Include incident field outside the sphere.
322
+ want_e (bool): Compute the electric field.
323
+ want_h (bool): Compute the magnetic field.
295
324
 
296
325
  Returns:
297
- tuple[ndarray, ndarray]: Tuple ``(E, H)`` with each array shaped
298
- ``(3, ...)``.
326
+ tuple[ndarray or None, ndarray or None]: ``(E, H)`` in spherical components,
327
+ each shaped ``(3,) + broadcast_shape`` or None when not requested.
299
328
  """
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.
329
+ a, b, c, d = abcd
316
330
 
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).
331
+ n_terms = len(a)
332
+ nn = np.arange(1, n_terms + 1)
333
+ scale = 1j**nn * (2 * nn + 1) / ((nn + 1) * nn)
321
334
 
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
335
+ rr, tt, pp = np.broadcast_arrays(
336
+ np.asarray(r, dtype=float),
337
+ np.asarray(theta, dtype=float),
338
+ np.asarray(phi, dtype=float),
339
+ )
340
+ shape = rr.shape
341
+ r_flat = rr.reshape(-1)
342
+ theta_flat = tt.reshape(-1)
343
+ phi_flat = pp.reshape(-1)
344
+
345
+ e_out = np.empty((3, r_flat.size), dtype=complex) if want_e else None
346
+ h_out = np.empty((3, r_flat.size), dtype=complex) if want_h else None
347
+
348
+ is_inside = r_flat < d_sphere / 2
349
+ for inside in (True, False):
350
+ group = is_inside if inside else ~is_inside
351
+ if not group.any():
352
+ continue
353
+
354
+ # miepython coefficients follow the n-ik convention and are conjugated
355
+ # internally; use conjugated sphere index so internal fields are consistent.
356
+ m_index = np.conjugate(m_sphere) if inside else n_env
357
+ M_the_base, M_phi_base, N_r_base, N_the_base, N_phi_base = _vsh_components_base(
358
+ n_terms, lambda0, m_index, r_flat[group], theta_flat[group], inside
359
+ )
360
+
361
+ cos_phi = np.cos(phi_flat[group])[:, np.newaxis]
362
+ sin_phi = np.sin(phi_flat[group])[:, np.newaxis]
363
+ zero = np.zeros_like(M_the_base, dtype=np.complex128)
364
+
365
+ if want_e:
366
+ # the electric field pairs the odd magnetic modes with the even electric ones
367
+ M_odd_rad, M_odd_the, M_odd_phi = zero, cos_phi * M_the_base, -sin_phi * M_phi_base
368
+ N_even_rad, N_even_the, N_even_phi = (
369
+ cos_phi * N_r_base,
370
+ cos_phi * N_the_base,
371
+ -sin_phi * N_phi_base,
372
+ )
373
+ if inside:
374
+ e_rad = _sum_two_scaled_terms(scale, c, M_odd_rad, 1.0 + 0.0j, d, N_even_rad, -1.0j)
375
+ e_the = _sum_two_scaled_terms(scale, c, M_odd_the, 1.0 + 0.0j, d, N_even_the, -1.0j)
376
+ e_phi = _sum_two_scaled_terms(scale, c, M_odd_phi, 1.0 + 0.0j, d, N_even_phi, -1.0j)
377
+ else:
378
+ e_rad = _sum_two_scaled_terms(scale, a, N_even_rad, 1.0j, b, M_odd_rad, -1.0 + 0.0j)
379
+ e_the = _sum_two_scaled_terms(scale, a, N_even_the, 1.0j, b, M_odd_the, -1.0 + 0.0j)
380
+ e_phi = _sum_two_scaled_terms(scale, a, N_even_phi, 1.0j, b, M_odd_phi, -1.0 + 0.0j)
381
+ if include_incident:
382
+ e_i = _incident_e_spherical(lambda0, n_env, r_flat[group], theta_flat[group], phi_flat[group])
383
+ e_rad += e_i[0]
384
+ e_the += e_i[1]
385
+ e_phi += e_i[2]
386
+ e_out[:, group] = np.array([e_rad, e_the, e_phi])
387
+
388
+ if want_h:
389
+ # and the magnetic field the other way round
390
+ M_even_rad, M_even_the, M_even_phi = zero, -sin_phi * M_the_base, -cos_phi * M_phi_base
391
+ N_odd_rad, N_odd_the, N_odd_phi = (
392
+ sin_phi * N_r_base,
393
+ sin_phi * N_the_base,
394
+ cos_phi * N_phi_base,
395
+ )
396
+ if inside:
397
+ m_rel = np.conjugate(m_sphere / n_env)
398
+ h_rad = m_rel * _sum_two_scaled_terms(scale, d, M_even_rad, -1.0 + 0.0j, c, N_odd_rad, -1.0j)
399
+ h_the = m_rel * _sum_two_scaled_terms(scale, d, M_even_the, -1.0 + 0.0j, c, N_odd_the, -1.0j)
400
+ h_phi = m_rel * _sum_two_scaled_terms(scale, d, M_even_phi, -1.0 + 0.0j, c, N_odd_phi, -1.0j)
401
+ else:
402
+ h_rad = _sum_two_scaled_terms(scale, b, N_odd_rad, 1.0j, a, M_even_rad, 1.0 + 0.0j)
403
+ h_the = _sum_two_scaled_terms(scale, b, N_odd_the, 1.0j, a, M_even_the, 1.0 + 0.0j)
404
+ h_phi = _sum_two_scaled_terms(scale, b, N_odd_phi, 1.0j, a, M_even_phi, 1.0 + 0.0j)
405
+ if include_incident:
406
+ h_i = _incident_h_spherical(lambda0, n_env, r_flat[group], theta_flat[group], phi_flat[group])
407
+ h_rad += h_i[0]
408
+ h_the += h_i[1]
409
+ h_phi += h_i[2]
410
+ h_out[:, group] = np.array([h_rad, h_the, h_phi])
411
+
412
+ e_final = e_out.reshape((3,) + shape) if want_e else None
413
+ h_final = h_out.reshape((3,) + shape) if want_h else None
414
+ return e_final, h_final
333
415
 
334
416
 
335
417
  def _spherical_components_to_cartesian(field_sph, r, theta, phi):
@@ -348,181 +430,6 @@ def _spherical_components_to_cartesian(field_sph, r, theta, phi):
348
430
  return np.array([fx, fy, fz])
349
431
 
350
432
 
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
433
  def e_near(lambda0, d_sphere, m_sphere, n_env, r, theta, phi, include_incident=True, n_pole=0, abcd=None):
527
434
  """Calculate the electric field in and around a sphere.
528
435
 
@@ -535,7 +442,9 @@ def e_near(lambda0, d_sphere, m_sphere, n_env, r, theta, phi, include_incident=T
535
442
  theta (float or ndarray): Polar angle(s) in radians.
536
443
  phi (float or ndarray): Azimuth angle(s) in radians.
537
444
  include_incident (bool): Include incident field for points outside sphere.
538
- n_pole (int): Requested multipole order. ``0`` means automatic truncation.
445
+ n_pole (int): Number of multipole terms to keep. ``0`` (the default) keeps
446
+ two more than Wiscombe's criterion, which converges the near field at
447
+ the sphere surface roughly five times closer than the criterion alone.
539
448
  abcd (ndarray or None): Optional precomputed coefficients ``[a, b, c, d]``.
540
449
  If provided, ``n_pole`` is ignored.
541
450
 
@@ -546,10 +455,19 @@ def e_near(lambda0, d_sphere, m_sphere, n_env, r, theta, phi, include_incident=T
546
455
  if abcd is None:
547
456
  abcd = _coefficients_abcd(lambda0, d_sphere, m_sphere, n_env, n_pole)
548
457
 
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)
458
+ return _near_fields(
459
+ abcd,
460
+ lambda0,
461
+ d_sphere,
462
+ m_sphere,
463
+ n_env,
464
+ r,
465
+ theta,
466
+ phi,
467
+ include_incident,
468
+ want_e=True,
469
+ want_h=False,
470
+ )[0]
553
471
 
554
472
 
555
473
  def h_near(lambda0, d_sphere, m_sphere, n_env, r, theta, phi, include_incident=True, n_pole=0, abcd=None):
@@ -564,7 +482,9 @@ def h_near(lambda0, d_sphere, m_sphere, n_env, r, theta, phi, include_incident=T
564
482
  theta (float or ndarray): Polar angle(s) in radians.
565
483
  phi (float or ndarray): Azimuth angle(s) in radians.
566
484
  include_incident (bool): Include incident field for points outside sphere.
567
- n_pole (int): Requested multipole order. ``0`` means automatic truncation.
485
+ n_pole (int): Number of multipole terms to keep. ``0`` (the default) keeps
486
+ two more than Wiscombe's criterion, which converges the near field at
487
+ the sphere surface roughly five times closer than the criterion alone.
568
488
  abcd (ndarray or None): Optional precomputed coefficients ``[a, b, c, d]``.
569
489
  If provided, ``n_pole`` is ignored.
570
490
 
@@ -575,10 +495,19 @@ def h_near(lambda0, d_sphere, m_sphere, n_env, r, theta, phi, include_incident=T
575
495
  if abcd is None:
576
496
  abcd = _coefficients_abcd(lambda0, d_sphere, m_sphere, n_env, n_pole)
577
497
 
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)
498
+ return _near_fields(
499
+ abcd,
500
+ lambda0,
501
+ d_sphere,
502
+ m_sphere,
503
+ n_env,
504
+ r,
505
+ theta,
506
+ phi,
507
+ include_incident,
508
+ want_e=False,
509
+ want_h=True,
510
+ )[1]
582
511
 
583
512
 
584
513
  def eh_near(
@@ -604,7 +533,9 @@ def eh_near(
604
533
  theta (float or ndarray): Polar angle(s) in radians.
605
534
  phi (float or ndarray): Azimuth angle(s) in radians.
606
535
  include_incident (bool): Include incident field for points outside sphere.
607
- n_pole (int): Requested multipole order. ``0`` means automatic truncation.
536
+ n_pole (int): Number of multipole terms to keep. ``0`` (the default) keeps
537
+ two more than Wiscombe's criterion, which converges the near field at
538
+ the sphere surface roughly five times closer than the criterion alone.
608
539
  abcd (ndarray or None): Optional precomputed coefficients ``[a, b, c, d]``.
609
540
  If provided, ``n_pole`` is ignored.
610
541
 
@@ -615,10 +546,19 @@ def eh_near(
615
546
  if abcd is None:
616
547
  abcd = _coefficients_abcd(lambda0, d_sphere, m_sphere, n_env, n_pole)
617
548
 
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)
549
+ return _near_fields(
550
+ abcd,
551
+ lambda0,
552
+ d_sphere,
553
+ m_sphere,
554
+ n_env,
555
+ r,
556
+ theta,
557
+ phi,
558
+ include_incident,
559
+ want_e=True,
560
+ want_h=True,
561
+ )
622
562
 
623
563
 
624
564
  def e_near_cartesian(
@@ -644,13 +584,15 @@ def e_near_cartesian(
644
584
  y (float or ndarray): Cartesian y coordinate(s).
645
585
  z (float or ndarray): Cartesian z coordinate(s).
646
586
  include_incident (bool): Include incident field for points outside sphere.
647
- n_pole (int): Requested multipole order. ``0`` means automatic truncation.
587
+ n_pole (int): Number of multipole terms to keep. ``0`` (the default) keeps
588
+ two more than Wiscombe's criterion, which converges the near field at
589
+ the sphere surface roughly five times closer than the criterion alone.
648
590
  abcd (ndarray or None): Optional precomputed coefficients ``[a, b, c, d]``.
649
591
 
650
592
  Returns:
651
593
  ndarray: Cartesian electric components ``[E_x, E_y, E_z]``.
652
594
  """
653
- r, theta, phi = _cartesian_to_spherical_safe(x, y, z)
595
+ r, theta, phi = cartesian_to_spherical(x, y, z)
654
596
  e_sph = e_near(lambda0, d_sphere, m_sphere, n_env, r, theta, phi, include_incident, n_pole, abcd)
655
597
  return _spherical_components_to_cartesian(e_sph, r, theta, phi)
656
598
 
@@ -678,13 +620,15 @@ def h_near_cartesian(
678
620
  y (float or ndarray): Cartesian y coordinate(s).
679
621
  z (float or ndarray): Cartesian z coordinate(s).
680
622
  include_incident (bool): Include incident field for points outside sphere.
681
- n_pole (int): Requested multipole order. ``0`` means automatic truncation.
623
+ n_pole (int): Number of multipole terms to keep. ``0`` (the default) keeps
624
+ two more than Wiscombe's criterion, which converges the near field at
625
+ the sphere surface roughly five times closer than the criterion alone.
682
626
  abcd (ndarray or None): Optional precomputed coefficients ``[a, b, c, d]``.
683
627
 
684
628
  Returns:
685
629
  ndarray: Cartesian magnetic components ``[H_x, H_y, H_z]``.
686
630
  """
687
- r, theta, phi = _cartesian_to_spherical_safe(x, y, z)
631
+ r, theta, phi = cartesian_to_spherical(x, y, z)
688
632
  h_sph = h_near(lambda0, d_sphere, m_sphere, n_env, r, theta, phi, include_incident, n_pole, abcd)
689
633
  return _spherical_components_to_cartesian(h_sph, r, theta, phi)
690
634
 
@@ -712,14 +656,16 @@ def eh_near_cartesian(
712
656
  y (float or ndarray): Cartesian y coordinate(s).
713
657
  z (float or ndarray): Cartesian z coordinate(s).
714
658
  include_incident (bool): Include incident field for points outside sphere.
715
- n_pole (int): Requested multipole order. ``0`` means automatic truncation.
659
+ n_pole (int): Number of multipole terms to keep. ``0`` (the default) keeps
660
+ two more than Wiscombe's criterion, which converges the near field at
661
+ the sphere surface roughly five times closer than the criterion alone.
716
662
  abcd (ndarray or None): Optional precomputed coefficients ``[a, b, c, d]``.
717
663
 
718
664
  Returns:
719
665
  tuple[ndarray, ndarray]: Tuple ``(E_xyz, H_xyz)`` where each array is
720
666
  ``[x, y, z]`` components.
721
667
  """
722
- r, theta, phi = _cartesian_to_spherical_safe(x, y, z)
668
+ r, theta, phi = cartesian_to_spherical(x, y, z)
723
669
  e_sph, h_sph = eh_near(lambda0, d_sphere, m_sphere, n_env, r, theta, phi, include_incident, n_pole, abcd)
724
670
  e_xyz = _spherical_components_to_cartesian(e_sph, r, theta, phi)
725
671
  h_xyz = _spherical_components_to_cartesian(h_sph, r, theta, phi)