pysie2d 0.2.0__tar.gz

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.
pysie2d-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,150 @@
1
+ Metadata-Version: 2.3
2
+ Name: pysie2d
3
+ Version: 0.2.0
4
+ Summary: 2-D boundary-integral (Müller BIE) solver for electromagnetic scattering from a cylinder in a homogeneous background.
5
+ Author: Claudio Silvestre Castro
6
+ Author-email: Claudio Silvestre Castro <claudio.silvestre.castro@gmail.com>
7
+ License: MIT
8
+ Requires-Dist: numpy>=1.26
9
+ Requires-Dist: scipy>=1.11
10
+ Requires-Python: >=3.12
11
+ Description-Content-Type: text/markdown
12
+
13
+ # pysie2d
14
+
15
+ A 2-D surface-integral-equation solver for time-harmonic
16
+ electromagnetic scattering from a single smooth cylinder — circular or
17
+ Gielis-superformula cross-section, embedded in a homogeneous background. Validated
18
+ against analytic Mie theory, with a typed public API and CI.
19
+
20
+ ## Scope and non-goals
21
+
22
+ This package is distilled from a larger private research code; it deliberately
23
+ covers only the **homogeneous-background, single-particle core** — the part
24
+ that can be validated end-to-end against a closed-form reference. Potential extensions
25
+ in the mid/long-term include slab waveguide backgrounds, multiple-particle simulations,
26
+ and quasinormal-mode searches based on the surface-integral matrix operator.
27
+
28
+ ## Figures
29
+
30
+ Relative error of the scattering efficiency `Q_sca` versus the number of
31
+ boundary points, converging toward analytic Mie theory (both polarisations):
32
+
33
+ ![Convergence to Mie theory](figures/convergence_study.png)
34
+
35
+ Near field of a Gielis `m = 6` star under plane-wave illumination (scattered
36
+ field outside the boundary, internal field inside):
37
+
38
+ ![Near-field map](figures/nearfield_map.png)
39
+
40
+ Relative local density of states (Purcell map) around the same Gielis `m = 6`
41
+ star, at one of its `qsca` resonances: a line-dipole emitter placed in a red
42
+ lobe decays faster than in free space (`1 + 4·Im S > 1`), while blue regions
43
+ suppress it. The six-fold pattern mirrors the particle's symmetry. The drive
44
+ *and* the decay rate of an embedded emitter both come from this map — it is the
45
+ entry point of quantum-dynamics calculations downstream:
46
+
47
+ ![Purcell map](figures/purcell_map.png)
48
+
49
+ Regenerate them with:
50
+
51
+ ```bash
52
+ uv run python examples/convergence_study.py
53
+ uv run python examples/nearfield_map.py
54
+ uv run python examples/purcell_map.py
55
+ ```
56
+
57
+ ## Formulation (summary)
58
+
59
+ - The cylinder is invariant along its axis, so Maxwell reduces to a scalar
60
+ Helmholtz problem for one field component (`E_y` for TE, `H_y` for TM).
61
+ - The self-consistent field solution is given **everywhere** in terms of the surface field and its normal derivative;
62
+ matching across the interface gives a Fredholm integral equation of the second kind.
63
+ - Discretising the boundary with `nn` quadrature points yields a dense
64
+ `2nn × 2nn` complex system `M(λ)·ei = rhs`, solved directly.
65
+ - The logarithmic Green-function singularity is handled analytically in the
66
+ diagonal terms; complex wavenumbers are supported throughout.
67
+ - Lengths are in nm, the time convention is `exp(-iωt)`, and outgoing waves are
68
+ `H_n^{(1)}`.
69
+
70
+ Full details and every sign/layout convention are in
71
+ [docs/conventions.md](docs/conventions.md). The analytic reference is Bohren &
72
+ Huffman, *Absorption and Scattering of Light by Small Particles*, ch. 8; the
73
+ surface-integral formulation follows [Valencia et al's formulation](https://doi.org/10.1364/JOSAB.20.002150).
74
+
75
+ ## Validation
76
+
77
+ The physics test suite compares the solver against analytic Mie theory for a
78
+ circular cylinder: scattering / extinction / absorption efficiencies, the
79
+ optical theorem on a lossy particle, energy conservation on a lossless one, the
80
+ convergence rate, and the 2-D `1/√(kr)` far-field decay. At `nn = 300` the
81
+ efficiencies agree with Mie to a few parts in `10³`; the error decreases with
82
+ `nn` until it reaches the fixed angular-quadrature floor of the far-field
83
+ integrator. See `tests/` for the exact tolerances and the reasoning behind them.
84
+
85
+ The line-dipole / self-Green machinery (v0.2) is validated the same way:
86
+ reciprocity of the scattered field (to `10⁻⁶`), the free-space limit
87
+ (`LDOS → 1` far from the particle), LDOS positivity, and — the strong anchor —
88
+ the self-Green function of a circular cylinder against its closed-form
89
+ Graf-addition-theorem sum on both `Re S` and `Im S`. That near-field anchor
90
+ converges at first order in `nn`, so it is run at `nn = 1000` to reach `1 %`;
91
+ the resolved scattered-field sign convention is recorded in
92
+ [docs/conventions.md](docs/conventions.md).
93
+
94
+ ## Install / run / test
95
+
96
+ Requires Python 3.12 and [uv](https://docs.astral.sh/uv/).
97
+
98
+ ```bash
99
+ uv sync # create the environment
100
+ uv run pytest # run the validation suite
101
+ uv run ruff format --check . # formatting
102
+ uv run ruff check . # lint
103
+ ```
104
+
105
+ Minimal use:
106
+
107
+ ```python
108
+ from pysie2d import BIESolver, Geometry, Material
109
+
110
+ geom = Geometry.gielis(rad=200, n_pts=300, m=0) # circular cylinder, nm
111
+ mat = Material(n_core=1.5, n_clad=1.0, pol=2) # TE
112
+ result = BIESolver(geom, mat).scatter(wavelength=600.0)
113
+
114
+ print(result.efficiencies()) # {'qsca', 'qext', 'qabs'}
115
+ ```
116
+
117
+ Line-dipole emitter and Purcell effect:
118
+
119
+ ```python
120
+ from pysie2d import BIESolver, Geometry, Material, relative_ldos
121
+
122
+ geom = Geometry.gielis(rad=200, n_pts=300, m=6, n1=6, n2=12, n3=12) # Gielis star
123
+ solver = BIESolver(geom, Material(n_core=2.0))
124
+ print(relative_ldos(solver, wavelength=540.0, x_s=430.0, z_s=0.0)) # LDOS vs free space
125
+ ```
126
+
127
+ ## Performance
128
+
129
+ The system is a dense `2nn × 2nn` complex matrix; at `nn = 300` (a `600 × 600`
130
+ solve) a single wavelength takes below one second in a modern computer, so wavelength
131
+ sweeps are cheap serial `for` loops — no parallelism required.
132
+
133
+ For a Purcell map, every grid point is a different source position, hence a
134
+ different right-hand side — but the matrix `M(λ)` is the same for all of them.
135
+ `relative_ldos_map` therefore factorises `M` **once** with
136
+ `scipy.linalg.lu_factor` and reuses it across all sources (`lu_solve`), turning
137
+ what would be an hour-long sweep into a few seconds.
138
+
139
+ ## Roadmap
140
+
141
+ - **v0.1.0** — core scattering: plane-wave excitation, near/far fields,
142
+ cross-section efficiencies, Mie validation, convergence study, CI.
143
+ - **v0.2.0** — line-dipole (point-source) excitation and the self-Green
144
+ function → relative LDOS / Purcell maps. _(this release)_
145
+ - **v0.3.0** — quasi-normal-mode extraction via Beyn's contour method,
146
+ validated against analytic Mie resonances.
147
+
148
+ ## License
149
+
150
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,138 @@
1
+ # pysie2d
2
+
3
+ A 2-D surface-integral-equation solver for time-harmonic
4
+ electromagnetic scattering from a single smooth cylinder — circular or
5
+ Gielis-superformula cross-section, embedded in a homogeneous background. Validated
6
+ against analytic Mie theory, with a typed public API and CI.
7
+
8
+ ## Scope and non-goals
9
+
10
+ This package is distilled from a larger private research code; it deliberately
11
+ covers only the **homogeneous-background, single-particle core** — the part
12
+ that can be validated end-to-end against a closed-form reference. Potential extensions
13
+ in the mid/long-term include slab waveguide backgrounds, multiple-particle simulations,
14
+ and quasinormal-mode searches based on the surface-integral matrix operator.
15
+
16
+ ## Figures
17
+
18
+ Relative error of the scattering efficiency `Q_sca` versus the number of
19
+ boundary points, converging toward analytic Mie theory (both polarisations):
20
+
21
+ ![Convergence to Mie theory](figures/convergence_study.png)
22
+
23
+ Near field of a Gielis `m = 6` star under plane-wave illumination (scattered
24
+ field outside the boundary, internal field inside):
25
+
26
+ ![Near-field map](figures/nearfield_map.png)
27
+
28
+ Relative local density of states (Purcell map) around the same Gielis `m = 6`
29
+ star, at one of its `qsca` resonances: a line-dipole emitter placed in a red
30
+ lobe decays faster than in free space (`1 + 4·Im S > 1`), while blue regions
31
+ suppress it. The six-fold pattern mirrors the particle's symmetry. The drive
32
+ *and* the decay rate of an embedded emitter both come from this map — it is the
33
+ entry point of quantum-dynamics calculations downstream:
34
+
35
+ ![Purcell map](figures/purcell_map.png)
36
+
37
+ Regenerate them with:
38
+
39
+ ```bash
40
+ uv run python examples/convergence_study.py
41
+ uv run python examples/nearfield_map.py
42
+ uv run python examples/purcell_map.py
43
+ ```
44
+
45
+ ## Formulation (summary)
46
+
47
+ - The cylinder is invariant along its axis, so Maxwell reduces to a scalar
48
+ Helmholtz problem for one field component (`E_y` for TE, `H_y` for TM).
49
+ - The self-consistent field solution is given **everywhere** in terms of the surface field and its normal derivative;
50
+ matching across the interface gives a Fredholm integral equation of the second kind.
51
+ - Discretising the boundary with `nn` quadrature points yields a dense
52
+ `2nn × 2nn` complex system `M(λ)·ei = rhs`, solved directly.
53
+ - The logarithmic Green-function singularity is handled analytically in the
54
+ diagonal terms; complex wavenumbers are supported throughout.
55
+ - Lengths are in nm, the time convention is `exp(-iωt)`, and outgoing waves are
56
+ `H_n^{(1)}`.
57
+
58
+ Full details and every sign/layout convention are in
59
+ [docs/conventions.md](docs/conventions.md). The analytic reference is Bohren &
60
+ Huffman, *Absorption and Scattering of Light by Small Particles*, ch. 8; the
61
+ surface-integral formulation follows [Valencia et al's formulation](https://doi.org/10.1364/JOSAB.20.002150).
62
+
63
+ ## Validation
64
+
65
+ The physics test suite compares the solver against analytic Mie theory for a
66
+ circular cylinder: scattering / extinction / absorption efficiencies, the
67
+ optical theorem on a lossy particle, energy conservation on a lossless one, the
68
+ convergence rate, and the 2-D `1/√(kr)` far-field decay. At `nn = 300` the
69
+ efficiencies agree with Mie to a few parts in `10³`; the error decreases with
70
+ `nn` until it reaches the fixed angular-quadrature floor of the far-field
71
+ integrator. See `tests/` for the exact tolerances and the reasoning behind them.
72
+
73
+ The line-dipole / self-Green machinery (v0.2) is validated the same way:
74
+ reciprocity of the scattered field (to `10⁻⁶`), the free-space limit
75
+ (`LDOS → 1` far from the particle), LDOS positivity, and — the strong anchor —
76
+ the self-Green function of a circular cylinder against its closed-form
77
+ Graf-addition-theorem sum on both `Re S` and `Im S`. That near-field anchor
78
+ converges at first order in `nn`, so it is run at `nn = 1000` to reach `1 %`;
79
+ the resolved scattered-field sign convention is recorded in
80
+ [docs/conventions.md](docs/conventions.md).
81
+
82
+ ## Install / run / test
83
+
84
+ Requires Python 3.12 and [uv](https://docs.astral.sh/uv/).
85
+
86
+ ```bash
87
+ uv sync # create the environment
88
+ uv run pytest # run the validation suite
89
+ uv run ruff format --check . # formatting
90
+ uv run ruff check . # lint
91
+ ```
92
+
93
+ Minimal use:
94
+
95
+ ```python
96
+ from pysie2d import BIESolver, Geometry, Material
97
+
98
+ geom = Geometry.gielis(rad=200, n_pts=300, m=0) # circular cylinder, nm
99
+ mat = Material(n_core=1.5, n_clad=1.0, pol=2) # TE
100
+ result = BIESolver(geom, mat).scatter(wavelength=600.0)
101
+
102
+ print(result.efficiencies()) # {'qsca', 'qext', 'qabs'}
103
+ ```
104
+
105
+ Line-dipole emitter and Purcell effect:
106
+
107
+ ```python
108
+ from pysie2d import BIESolver, Geometry, Material, relative_ldos
109
+
110
+ geom = Geometry.gielis(rad=200, n_pts=300, m=6, n1=6, n2=12, n3=12) # Gielis star
111
+ solver = BIESolver(geom, Material(n_core=2.0))
112
+ print(relative_ldos(solver, wavelength=540.0, x_s=430.0, z_s=0.0)) # LDOS vs free space
113
+ ```
114
+
115
+ ## Performance
116
+
117
+ The system is a dense `2nn × 2nn` complex matrix; at `nn = 300` (a `600 × 600`
118
+ solve) a single wavelength takes below one second in a modern computer, so wavelength
119
+ sweeps are cheap serial `for` loops — no parallelism required.
120
+
121
+ For a Purcell map, every grid point is a different source position, hence a
122
+ different right-hand side — but the matrix `M(λ)` is the same for all of them.
123
+ `relative_ldos_map` therefore factorises `M` **once** with
124
+ `scipy.linalg.lu_factor` and reuses it across all sources (`lu_solve`), turning
125
+ what would be an hour-long sweep into a few seconds.
126
+
127
+ ## Roadmap
128
+
129
+ - **v0.1.0** — core scattering: plane-wave excitation, near/far fields,
130
+ cross-section efficiencies, Mie validation, convergence study, CI.
131
+ - **v0.2.0** — line-dipole (point-source) excitation and the self-Green
132
+ function → relative LDOS / Purcell maps. _(this release)_
133
+ - **v0.3.0** — quasi-normal-mode extraction via Beyn's contour method,
134
+ validated against analytic Mie resonances.
135
+
136
+ ## License
137
+
138
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,67 @@
1
+ [project]
2
+ name = "pysie2d"
3
+ version = "0.2.0"
4
+ description = "2-D boundary-integral (Müller BIE) solver for electromagnetic scattering from a cylinder in a homogeneous background."
5
+ readme = "README.md"
6
+ license = { text = "MIT" }
7
+ authors = [
8
+ { name = "Claudio Silvestre Castro", email = "claudio.silvestre.castro@gmail.com" },
9
+ ]
10
+ requires-python = ">=3.12"
11
+ dependencies = ["numpy>=1.26", "scipy>=1.11"]
12
+
13
+ [build-system]
14
+ requires = ["uv_build>=0.10.12,<0.11.0"]
15
+ build-backend = "uv_build"
16
+
17
+ [dependency-groups]
18
+ dev = ["pytest>=8", "ruff>=0.8", "matplotlib>=3.9"]
19
+
20
+ [tool.ruff]
21
+ line-length = 88
22
+ src = ["src", "tests"]
23
+
24
+ [tool.ruff.lint]
25
+ select = ["E", "F", "D", "N", "B", "I", "C4", "A"]
26
+ ignore = [
27
+ "D203", "D204", "D213", "D215", "D400", "D401",
28
+ "D404", "D406", "D407", "D408", "D409", "D413",
29
+ ]
30
+
31
+ [tool.ruff.lint.pydocstyle]
32
+ convention = "google"
33
+
34
+ [tool.ruff.lint.per-file-ignores]
35
+ "tests/*" = ["D"] # test names are the documentation
36
+ # Keep the Bessel/Hankel math notation (J_n, H_n) that maps directly onto the
37
+ # cited Bohren & Huffman equations; snake_case would obscure the reference.
38
+ "src/pysie2d/reference/mie.py" = ["N806"]
39
+
40
+ [tool.pytest.ini_options]
41
+ testpaths = ["tests"]
42
+
43
+ [tool.semantic_release]
44
+ version_toml = ["pyproject.toml:project.version"]
45
+ commit_parser = "conventional"
46
+ commit_parser_options = { ignore_merge_commits = true }
47
+ major_on_zero = false
48
+ build_command = "uv build"
49
+
50
+ [tool.semantic_release.branches.main]
51
+ match = "main"
52
+ prerelease = false
53
+
54
+ [tool.semantic_release.changelog]
55
+ changelog_file = "CHANGELOG.md"
56
+ exclude_commit_patterns = [
57
+ '''chore(?:\([^)]*?\))?: .+''',
58
+ '''ci(?:\([^)]*?\))?: .+''',
59
+ '''test(?:\([^)]*?\))?: .+''',
60
+ '''Merged? .*''',
61
+ ]
62
+
63
+ [tool.semantic_release.remote]
64
+ type = "github"
65
+
66
+ [tool.semantic_release.publish]
67
+ upload_to_vcs_release = true
@@ -0,0 +1,40 @@
1
+ """pysie2d — 2-D boundary-integral scattering solver (homogeneous background).
2
+
3
+ Public API:
4
+ Geometry: Gielis-superformula boundary parameterisation.
5
+ Material: optical properties of the scatterer.
6
+ BIESolver: solver façade; call ``scatter``/``scatter_dipole`` to obtain a
7
+ ``ScatterResult``.
8
+ ScatterResult: carries the solution and exposes far/near-field analysis.
9
+ assemble_matrix: the vectorised BIE system-matrix assembly.
10
+ plane_wave_rhs, line_dipole_rhs: excitation right-hand sides.
11
+ eval_field, far_field: field-evaluation primitives.
12
+ self_green, relative_ldos, relative_ldos_map: self-Green function and
13
+ LDOS / Purcell-effect analysis (line-dipole excitation).
14
+ """
15
+
16
+ from .fields import eval_field, far_field
17
+ from .geometry import Geometry
18
+ from .green import relative_ldos, relative_ldos_map, self_green
19
+ from .kernels import assemble_matrix, assemble_matrix_reference
20
+ from .material import Material
21
+ from .solver import BIESolver, ScatterResult
22
+ from .sources import line_dipole_rhs, plane_wave_rhs
23
+
24
+ __version__ = "0.2.0"
25
+
26
+ __all__ = [
27
+ "BIESolver",
28
+ "Geometry",
29
+ "Material",
30
+ "ScatterResult",
31
+ "assemble_matrix",
32
+ "assemble_matrix_reference",
33
+ "eval_field",
34
+ "far_field",
35
+ "line_dipole_rhs",
36
+ "plane_wave_rhs",
37
+ "relative_ldos",
38
+ "relative_ldos_map",
39
+ "self_green",
40
+ ]
@@ -0,0 +1,166 @@
1
+ """Field evaluation: far-field amplitude and arbitrary-point near fields."""
2
+
3
+ import numpy as np
4
+
5
+ from .kernels import hank0, hank1
6
+
7
+ PI = np.pi
8
+
9
+
10
+ # ---------------------------------------------------------------------------
11
+ # Far-field amplitude (subroutine efi)
12
+ # ---------------------------------------------------------------------------
13
+
14
+
15
+ def far_field(
16
+ nn: int,
17
+ nff: int,
18
+ lambd: float,
19
+ f: np.ndarray,
20
+ g: np.ndarray,
21
+ df: np.ndarray,
22
+ dg: np.ndarray,
23
+ delt: float | np.ndarray,
24
+ ei: np.ndarray,
25
+ ) -> tuple[np.ndarray, np.ndarray]:
26
+ """Compute the 2-D far-field scattering amplitude.
27
+
28
+ Args:
29
+ nn: Number of boundary points.
30
+ nff: Number of far-field angles.
31
+ lambd: Wavelength (nm).
32
+ f: (nn,) boundary x coordinates (nm).
33
+ g: (nn,) boundary z coordinates (nm).
34
+ df: (nn,) first derivative of f w.r.t. θ.
35
+ dg: (nn,) first derivative of g w.r.t. θ.
36
+ delt: Quadrature θ-step (scalar or per-point array).
37
+ ei: complex (2nn,) BIE solution vector.
38
+
39
+ Returns:
40
+ amp: complex (nff,) far-field amplitude.
41
+ angles: float (nff,) observation angles (rad), from −π to π.
42
+ """
43
+ wnum = 2.0 * PI / lambd
44
+ angles = -PI + np.arange(nff) * 2.0 * PI / (nff - 1.0)
45
+ se = np.sin(angles)
46
+ co = np.cos(angles)
47
+
48
+ # shape (nn, nff)
49
+ arg = -1j * wnum * (f[:, None] * se + g[:, None] * co)
50
+ phi_j = ei[:nn, None]
51
+ chi_j = ei[nn:, None]
52
+ puto = 1j * wnum * (dg[:, None] * se - df[:, None] * co) * phi_j - chi_j
53
+ # delt may be a scalar or a per-point (nn,) array
54
+ delt_col = np.reshape(delt, (-1, 1)) if np.ndim(delt) > 0 else delt
55
+ amp = np.sum(np.exp(arg) * puto * delt_col, axis=0)
56
+
57
+ return amp, angles
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Inside/outside test (subroutine eicero)
62
+ # ---------------------------------------------------------------------------
63
+
64
+
65
+ def _is_outside(
66
+ x1: float,
67
+ x3: float,
68
+ f: np.ndarray,
69
+ g: np.ndarray,
70
+ df: np.ndarray,
71
+ dg: np.ndarray,
72
+ ) -> bool:
73
+ """Return True if (x1, x3) is outside the particle.
74
+
75
+ Uses the sign of the dot product between the displacement vector and the
76
+ outward normal at the nearest boundary point. This nearest-neighbour
77
+ heuristic is reliable for convex and mildly star-shaped boundaries, but
78
+ unreliable for extreme concave superformula shapes.
79
+
80
+ Args:
81
+ x1: Observation x-coordinate (nm).
82
+ x3: Observation z-coordinate (nm).
83
+ f: (nn,) boundary x coordinates (nm).
84
+ g: (nn,) boundary z coordinates (nm).
85
+ df: (nn,) first derivative of f w.r.t. θ.
86
+ dg: (nn,) first derivative of g w.r.t. θ.
87
+
88
+ Returns:
89
+ True if the point lies outside the particle.
90
+ """
91
+ imin = np.argmin((x1 - f) ** 2 + (x3 - g) ** 2)
92
+ norm = -(x1 - f[imin]) * dg[imin] + (x3 - g[imin]) * df[imin]
93
+ return norm > 0.0
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Scattered field at arbitrary points
98
+ # ---------------------------------------------------------------------------
99
+
100
+
101
+ def eval_field(
102
+ ei: np.ndarray,
103
+ nn: int,
104
+ f: np.ndarray,
105
+ df: np.ndarray,
106
+ g: np.ndarray,
107
+ dg: np.ndarray,
108
+ delt: float | np.ndarray,
109
+ wnum: complex,
110
+ x_pts: np.ndarray,
111
+ z_pts: np.ndarray,
112
+ ri: complex | None = None,
113
+ ) -> np.ndarray:
114
+ """Evaluate the field at arbitrary (x, z) points.
115
+
116
+ Uses the BIE representation formula (Huygens principle) for exterior points.
117
+ For interior points, the same formula is used with the particle wavenumber
118
+ wn1 = ri * wnum when ri is provided; otherwise interior points return 0+0j.
119
+
120
+ Never evaluate on or very near the boundary: the representation-formula
121
+ integrand is near-singular there and degrades within roughly 2–3 boundary-
122
+ point spacings (~2π·rad/nn) of the surface. Keep observation points at
123
+ least ~5 spacings away.
124
+
125
+ Args:
126
+ ei: complex (2nn,) BIE solution vector (φ = ei[:nn], χ = ei[nn:]).
127
+ nn: Number of boundary points.
128
+ f: (nn,) boundary x coordinates (nm).
129
+ df: (nn,) first derivative of f w.r.t. θ.
130
+ g: (nn,) boundary z coordinates (nm).
131
+ dg: (nn,) first derivative of g w.r.t. θ.
132
+ delt: Quadrature θ-step.
133
+ wnum: Background wavenumber 2π/λ.
134
+ x_pts: (M,) observation x-coordinates (nm).
135
+ z_pts: (M,) observation z-coordinates (nm).
136
+ ri: Particle refractive index (relative to background). When provided,
137
+ interior points are evaluated using wn1 = ri * wnum. When None
138
+ (default), interior points return 0+0j.
139
+
140
+ Returns:
141
+ field: complex (M,) field E_y at each observation point (exterior
142
+ scattered field outside, interior field inside when ri is given).
143
+ """
144
+ x_pts = np.asarray(x_pts)
145
+ z_pts = np.asarray(z_pts)
146
+ n_pts = len(x_pts)
147
+ field = np.zeros(n_pts, dtype=complex)
148
+
149
+ wn1 = ri * wnum if ri is not None else None
150
+
151
+ for j in range(n_pts):
152
+ outside = _is_outside(x_pts[j], z_pts[j], f, g, df, dg)
153
+ if not outside and wn1 is None:
154
+ continue
155
+ xmf = x_pts[j] - f
156
+ zmg = z_pts[j] - g
157
+ dist = np.sqrt(xmf**2 + zmg**2)
158
+ arg2 = -dg * xmf + df * zmg
159
+ k = wnum if outside else wn1
160
+ arg1 = k * dist
161
+ sum_h = np.sum(
162
+ (k**2 * arg2 * hank1(arg1) / arg1 * ei[:nn] - hank0(arg1) * ei[nn:]) * delt
163
+ )
164
+ field[j] = (1j / 4.0) * sum_h
165
+
166
+ return field