lightsmith 0.1.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.
Files changed (98) hide show
  1. lightsmith/__init__.py +44 -0
  2. lightsmith/api.py +110 -0
  3. lightsmith/backends/__init__.py +21 -0
  4. lightsmith/backends/_eme/__init__.py +290 -0
  5. lightsmith/backends/_fde/__init__.py +245 -0
  6. lightsmith/backends/_fde/grid.py +148 -0
  7. lightsmith/backends/_fde/modes.py +314 -0
  8. lightsmith/backends/_fde/section.py +201 -0
  9. lightsmith/backends/_fdfd/__init__.py +20 -0
  10. lightsmith/backends/_fdfd/engine.py +215 -0
  11. lightsmith/backends/_fdfd/grid.py +116 -0
  12. lightsmith/backends/_fdfd/linsolve.py +208 -0
  13. lightsmith/backends/_fdfd/modes.py +156 -0
  14. lightsmith/backends/_fdfd/operators.py +100 -0
  15. lightsmith/backends/_fdfd/ports.py +116 -0
  16. lightsmith/backends/_fdtd/__init__.py +7 -0
  17. lightsmith/backends/_fdtd/engine.py +450 -0
  18. lightsmith/backends/_fdtd/media.py +129 -0
  19. lightsmith/backends/_fdtd/options.py +42 -0
  20. lightsmith/backends/_fdtd/periodic.py +399 -0
  21. lightsmith/backends/_fdtd/scattering.py +235 -0
  22. lightsmith/backends/_fdtd/setup.py +251 -0
  23. lightsmith/backends/_fdtd/tfsf.py +191 -0
  24. lightsmith/backends/_pwe/__init__.py +7 -0
  25. lightsmith/backends/_pwe/engine.py +165 -0
  26. lightsmith/backends/_pwe/geometry.py +346 -0
  27. lightsmith/backends/_pwe/solvers.py +386 -0
  28. lightsmith/backends/_rcwa/__init__.py +8 -0
  29. lightsmith/backends/_rcwa/core.py +174 -0
  30. lightsmith/backends/_rcwa/fourier.py +302 -0
  31. lightsmith/backends/_rcwa/linalg.py +129 -0
  32. lightsmith/backends/base.py +109 -0
  33. lightsmith/backends/circuit.py +104 -0
  34. lightsmith/backends/dda.py +448 -0
  35. lightsmith/backends/eme.py +252 -0
  36. lightsmith/backends/fde.py +227 -0
  37. lightsmith/backends/fdfd.py +159 -0
  38. lightsmith/backends/fdtd.py +105 -0
  39. lightsmith/backends/mie.py +63 -0
  40. lightsmith/backends/mie_native.py +540 -0
  41. lightsmith/backends/pwe.py +417 -0
  42. lightsmith/backends/quasistatic.py +215 -0
  43. lightsmith/backends/rcwa.py +459 -0
  44. lightsmith/backends/tmatrix_treams.py +473 -0
  45. lightsmith/backends/tmm.py +299 -0
  46. lightsmith/circuits/__init__.py +40 -0
  47. lightsmith/circuits/engine.py +339 -0
  48. lightsmith/circuits/models.py +594 -0
  49. lightsmith/circuits/touchstone.py +183 -0
  50. lightsmith/cli.py +81 -0
  51. lightsmith/data/materials/Ag/Johnson.yml +62 -0
  52. lightsmith/data/materials/Ag/McPeak.yml +154 -0
  53. lightsmith/data/materials/Ag/Rakic-LD.yml +214 -0
  54. lightsmith/data/materials/Al/McPeak.yml +310 -0
  55. lightsmith/data/materials/Al/Rakic-LD.yml +1014 -0
  56. lightsmith/data/materials/Al/Rakic.yml +219 -0
  57. lightsmith/data/materials/Au/Johnson.yml +62 -0
  58. lightsmith/data/materials/Au/McPeak.yml +154 -0
  59. lightsmith/data/materials/Au/Rakic-LD.yml +214 -0
  60. lightsmith/data/materials/Cu/Johnson.yml +62 -0
  61. lightsmith/data/materials/Cu/McPeak.yml +154 -0
  62. lightsmith/data/materials/Cu/Rakic-LD.yml +214 -0
  63. lightsmith/data/materials/H2O/Daimon-24.0C.yml +16 -0
  64. lightsmith/data/materials/H2O/Hale.yml +185 -0
  65. lightsmith/data/materials/Si/Aspnes.yml +59 -0
  66. lightsmith/data/materials/Si/Green-2008.yml +136 -0
  67. lightsmith/data/materials/Si/Schinke.yml +139 -0
  68. lightsmith/data/materials/SiO2/Lemarchand.yml +466 -0
  69. lightsmith/data/materials/SiO2/Malitson.yml +20 -0
  70. lightsmith/data/materials/TiO2/Devore-o.yml +16 -0
  71. lightsmith/data/materials/TiO2/Sarkar.yml +991 -0
  72. lightsmith/data/materials/TiO2/Siefke.yml +1226 -0
  73. lightsmith/data/materials/manifest.json +541 -0
  74. lightsmith/device.py +138 -0
  75. lightsmith/inverse/__init__.py +95 -0
  76. lightsmith/inverse/backend.py +62 -0
  77. lightsmith/inverse/export.py +177 -0
  78. lightsmith/inverse/objectives.py +82 -0
  79. lightsmith/inverse/optimizers.py +116 -0
  80. lightsmith/inverse/parametrization.py +160 -0
  81. lightsmith/inverse/spec.py +123 -0
  82. lightsmith/inverse/topopt.py +368 -0
  83. lightsmith/layout.py +649 -0
  84. lightsmith/materials.py +567 -0
  85. lightsmith/materials_fit.py +321 -0
  86. lightsmith/mcp_server.py +63 -0
  87. lightsmith/optics/__init__.py +5 -0
  88. lightsmith/optics/propagation.py +84 -0
  89. lightsmith/problems.py +306 -0
  90. lightsmith/py.typed +0 -0
  91. lightsmith/raster.py +133 -0
  92. lightsmith/results.py +189 -0
  93. lightsmith/spec.py +269 -0
  94. lightsmith-0.1.0.dist-info/METADATA +137 -0
  95. lightsmith-0.1.0.dist-info/RECORD +98 -0
  96. lightsmith-0.1.0.dist-info/WHEEL +4 -0
  97. lightsmith-0.1.0.dist-info/entry_points.txt +3 -0
  98. lightsmith-0.1.0.dist-info/licenses/LICENSE +21 -0
lightsmith/__init__.py ADDED
@@ -0,0 +1,44 @@
1
+ """lightsmith: one problem spec, many electromagnetic scattering solvers.
2
+
3
+ Quick start::
4
+
5
+ import lightsmith as ls
6
+
7
+ sim = ls.Simulation(
8
+ particles=[ls.Particle(shape=ls.Sphere(radius_nm=40),
9
+ material=ls.Drude(eps_inf=9.5, wp_ev=9.0, gamma_ev=0.07))],
10
+ medium_index=1.33,
11
+ wavelengths=ls.WavelengthRange(start_nm=400, stop_nm=800, num=81),
12
+ )
13
+ ls.estimate(sim, method="dda") # size/memory before running
14
+ res = ls.solve(sim) # method="auto" -> Mie for a sphere
15
+ cmp = ls.compare(sim, ["mie", "dda"])
16
+ """
17
+
18
+ from .api import choose_method, compare, estimate, solve
19
+ from .backends import all_backends, get_backend
20
+ from .device import doctor, resolve_device
21
+ from .materials import (ConstantIndex, ConstantPermittivity, Drude, DrudeLorentz, LibraryMaterial, LorentzPole,
22
+ Tabulated, fetch_refractiveindex, list_materials, load_material)
23
+ from .results import FarFieldData, NearFieldData, Result, relative_difference
24
+ from .spec import (Cylinder, FarFieldRequest, LayeredSphere, NearFieldRequest, Outputs, Particle, PlaneWave, Shell,
25
+ Simulation, Sphere, Spheroid, Substrate, WavelengthRange, json_schema)
26
+
27
+ __version__ = "0.1.0"
28
+
29
+
30
+ def __getattr__(name): # lazy: materials_fit pulls in scipy.optimize
31
+ if name in ("fit_drude_lorentz", "fit_quality"):
32
+ from . import materials_fit
33
+
34
+ return getattr(materials_fit, name)
35
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
36
+
37
+ __all__ = [
38
+ "ConstantIndex", "ConstantPermittivity", "Cylinder", "Drude", "DrudeLorentz", "FarFieldData", "FarFieldRequest",
39
+ "LayeredSphere", "LibraryMaterial", "LorentzPole", "NearFieldData", "NearFieldRequest", "Outputs", "Particle",
40
+ "PlaneWave", "Result", "Shell", "Simulation", "Sphere", "Spheroid", "Substrate", "Tabulated", "WavelengthRange",
41
+ "all_backends", "choose_method", "compare", "doctor", "estimate", "fetch_refractiveindex", "fit_drude_lorentz",
42
+ "fit_quality", "get_backend",
43
+ "json_schema", "list_materials", "load_material", "relative_difference", "resolve_device", "solve",
44
+ ]
lightsmith/api.py ADDED
@@ -0,0 +1,110 @@
1
+ """Top-level entry points: solve, estimate, compare, choose_method.
2
+
3
+ All accept any problem kind (see ``lightsmith.problems``) as a model, dict or
4
+ JSON string.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from pydantic import BaseModel
12
+
13
+ from .backends import Backend, all_backends, get_backend
14
+ from .device import resolve_device
15
+ from .problems import parse_problem
16
+ from .results import Result, relative_difference
17
+
18
+ # Preference order per problem kind when method="auto": exact/cheap first, then more general.
19
+ AUTO_ORDER: dict[str, tuple[str, ...]] = {
20
+ "scattering": ("mie", "tmatrix", "dda", "fdtd"),
21
+ "layer_stack": ("tmm", "rcwa", "fdtd"),
22
+ "periodic": ("rcwa", "fdfd", "fdtd"),
23
+ "mode": ("fde",),
24
+ "device": ("eme", "fdfd", "fdtd"),
25
+ "bands": ("pwe", "fdfd"),
26
+ "circuit": ("circuit",),
27
+ "optimization": ("inverse",),
28
+ }
29
+
30
+
31
+ def _kind(problem: BaseModel) -> str:
32
+ return getattr(problem, "kind", "scattering")
33
+
34
+
35
+ def choose_method(problem: BaseModel | dict | str) -> tuple[type[Backend], list[str]]:
36
+ """Pick the preferred available backend for `problem`; also return why others were skipped."""
37
+ problem = parse_problem(problem)
38
+ kind = _kind(problem)
39
+ skipped = []
40
+ by_method: dict[str, list[type[Backend]]] = {}
41
+ for b in all_backends():
42
+ if kind in b.problem_kinds:
43
+ by_method.setdefault(b.method, []).append(b)
44
+ order = AUTO_ORDER.get(kind, ())
45
+ for method in order + tuple(m for m in by_method if m not in order):
46
+ for b in by_method.get(method, []):
47
+ ok, why = b.available()
48
+ if ok:
49
+ ok, why = b.supports(problem)
50
+ if ok:
51
+ return b, skipped
52
+ skipped.append(f"{b.name}: {why}")
53
+ if not by_method:
54
+ raise ValueError(f"no backend is installed for problem kind {kind!r}")
55
+ raise ValueError("no available backend supports this problem: " + "; ".join(skipped))
56
+
57
+
58
+ def _resolve(problem: BaseModel, method: str) -> type[Backend]:
59
+ if method == "auto":
60
+ return choose_method(problem)[0]
61
+ b = get_backend(method, kind=_kind(problem))
62
+ if _kind(problem) not in b.problem_kinds:
63
+ raise ValueError(f"backend {b.name!r} solves {list(b.problem_kinds)}, not {_kind(problem)!r}")
64
+ for ok, why in (b.available(), b.supports(problem)):
65
+ if not ok:
66
+ raise ValueError(f"backend {b.name!r} cannot run this problem: {why}")
67
+ return b
68
+
69
+
70
+ def solve(problem: BaseModel | dict | str, method: str = "auto", device: str = "auto") -> BaseModel:
71
+ """Solve `problem` with one method (or backend name). `device`: auto | cpu | gpu | cuda:N | torch-cpu."""
72
+ problem = parse_problem(problem)
73
+ b = _resolve(problem, method)
74
+ dev = resolve_device(device if b.gpu else "cpu")
75
+ return b.solve(problem, dev)
76
+
77
+
78
+ def estimate(problem: BaseModel | dict | str, method: str = "auto", device: str = "auto") -> dict[str, Any]:
79
+ """Pre-flight: which backend would run, on what device, and roughly how big. Never solves."""
80
+ problem = parse_problem(problem)
81
+ b = _resolve(problem, method)
82
+ dev = resolve_device(device if b.gpu else "cpu")
83
+ return {"backend": b.name, "method": b.method, **dev.describe(), **b.estimate(problem, dev)}
84
+
85
+
86
+ def compare(problem: BaseModel | dict | str, methods: list[str] | None = None, device: str = "auto",
87
+ reference: str | None = None) -> dict[str, Any]:
88
+ """Solve with several methods and report relative differences against a reference.
89
+
90
+ By default runs every available backend that supports `problem`, using Mie as the
91
+ reference for scattering when it applies (otherwise the first result).
92
+ """
93
+ problem = parse_problem(problem)
94
+ kind = _kind(problem)
95
+ if methods is None:
96
+ methods = [b.name for b in all_backends()
97
+ if kind in b.problem_kinds and b.available()[0] and b.supports(problem)[0]]
98
+ results = {m: solve(problem, m, device) for m in methods}
99
+ if reference is None:
100
+ reference = next((m for m, r in results.items() if getattr(r, "method", None) == "mie"), methods[0])
101
+ ref = results[reference]
102
+ diffs: dict[str, Any] = {}
103
+ for m, r in results.items():
104
+ if m == reference:
105
+ continue
106
+ if isinstance(r, Result) and isinstance(ref, Result):
107
+ diffs[m] = {q: relative_difference(r, ref, q) for q in ("c_ext_nm2", "c_sca_nm2", "c_abs_nm2")}
108
+ elif hasattr(r, "difference"):
109
+ diffs[m] = r.difference(ref)
110
+ return {"reference": reference, "results": results, "relative_difference": diffs}
@@ -0,0 +1,21 @@
1
+ """Built-in backends. Every public module in this package is imported so its
2
+ ``@register`` decorators run; a module that fails to import (e.g. a broken
3
+ optional dependency) is skipped with a warning instead of breaking the package.
4
+ Backends must import optional third-party libraries lazily (inside methods).
5
+ """
6
+
7
+ import importlib
8
+ import pkgutil
9
+ import warnings
10
+
11
+ from .base import Backend, all_backends, get_backend, register, unsupported_outputs
12
+
13
+ for _m in pkgutil.iter_modules(__path__):
14
+ if _m.name.startswith("_") or _m.name == "base":
15
+ continue
16
+ try:
17
+ importlib.import_module(f"{__name__}.{_m.name}")
18
+ except Exception as e: # pragma: no cover
19
+ warnings.warn(f"lightsmith backend module {_m.name!r} failed to import: {e}", stacklevel=2)
20
+
21
+ __all__ = ["Backend", "all_backends", "get_backend", "register", "unsupported_outputs"]
@@ -0,0 +1,290 @@
1
+ """Eigenmode expansion (EME) engine.
2
+
3
+ The device between the two port planes is cut into cells along the
4
+ propagation axis. Inside each cell the cross-section is uniform, so the
5
+ field is a sum of the cell's eigenmodes (from the FDE engine), each picking
6
+ up exp(i k0 n L). At the interface between cells, continuity of tangential
7
+ E (tested with the right cell's H) and of tangential H (tested with the left
8
+ cell's E) gives, with O_ik = <E_i^L, h_k^R> and <E, H> = ∫ (E x H)·z dA,
9
+
10
+ R_L = (I + O O^T)^-1 (I - O O^T), T_LR = O^T (I + R_L),
11
+ R_R = -(I + O^T O)^-1 (I - O^T O), T_RL = O (I - R_R) = T_LR^T,
12
+
13
+ with modes normalised to <E_i, H_i> = 1 (unconjugated, so that lossy and
14
+ evanescent modes stay biorthogonal). Cells and interfaces are cascaded with
15
+ Redheffer star products (stable: only decaying exponentials appear). Port
16
+ modes, solved in each port's window at the port plane, couple to the end
17
+ cells' modes through the symmetrised overlap ½(<E_p, h_k> + <e_k, H_p>),
18
+ which is the same for launching and for measuring.
19
+
20
+ References: Bienstman & Baets, Opt. Quantum Electron. 33, 327 (2001);
21
+ Gallagher & Felici, Proc. SPIE 4987, 69 (2003).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import math
27
+ from collections import OrderedDict
28
+ from dataclasses import dataclass, field
29
+
30
+ import numpy as np
31
+
32
+ from .._fde import CrossSection, Grid, Mode, ModeSolver, OwnerMaps, plane_axes, sub_grid
33
+
34
+ # --- S-matrix algebra ----------------------------------------------------------------
35
+
36
+
37
+ @dataclass
38
+ class SMatrix:
39
+ """Two-sided scattering matrix in modal amplitudes: S11/S22 reflections, S21 (1->2) and S12 transmissions."""
40
+
41
+ s11: np.ndarray
42
+ s12: np.ndarray
43
+ s21: np.ndarray
44
+ s22: np.ndarray
45
+
46
+ @classmethod
47
+ def propagation(cls, neff: np.ndarray, length_nm: float, wavelength_nm: float) -> "SMatrix":
48
+ k0 = 2 * math.pi / wavelength_nm
49
+ p = np.diag(np.exp(1j * k0 * neff * length_nm))
50
+ z = np.zeros_like(p)
51
+ return cls(z, p, p.copy(), z.copy())
52
+
53
+ def star(self, b: "SMatrix") -> "SMatrix":
54
+ """Redheffer product: self (sides 1|2) followed by b (sides 2|3)."""
55
+ a = self
56
+ i1 = np.eye(a.s22.shape[0])
57
+ i2 = np.eye(b.s11.shape[0])
58
+ x = np.linalg.solve(i2 - b.s11 @ a.s22, np.hstack([b.s11 @ a.s21, b.s12])) # (I - B11 A22)^-1 [...]
59
+ y = np.linalg.solve(i1 - a.s22 @ b.s11, np.hstack([a.s21, a.s22 @ b.s12])) # (I - A22 B11)^-1 [...]
60
+ n1 = a.s21.shape[1]
61
+ return SMatrix(s11=a.s11 + a.s12 @ x[:, :n1], s12=a.s12 @ x[:, n1:],
62
+ s21=b.s21 @ y[:, :n1], s22=b.s22 + b.s21 @ y[:, n1:])
63
+
64
+
65
+ @dataclass
66
+ class ModeSet:
67
+ """Modes of one cross-section, stacked for fast overlaps: rows are modes."""
68
+
69
+ neff: np.ndarray
70
+ ex: np.ndarray # (M, N) weighted where needed
71
+ ey: np.ndarray
72
+ hx: np.ndarray
73
+ hy: np.ndarray
74
+ wa: np.ndarray # weights at Ex/Hy, (N,)
75
+ wb: np.ndarray # weights at Ey/Hx
76
+ modes: list = field(default_factory=list, repr=False)
77
+
78
+ @classmethod
79
+ def from_modes(cls, modes: list[Mode], grid: Grid, window: tuple[slice, slice] | None = None) -> "ModeSet":
80
+ """Stack modes; modes on a sub-window of `grid` are zero-padded into it."""
81
+ def full(a):
82
+ if window is None:
83
+ return a.ravel()
84
+ out = np.zeros(grid.shape, dtype=complex)
85
+ out[window] = a
86
+ return out.ravel()
87
+
88
+ ref = Mode(0j, 0.0, grid, None, None, None)
89
+ wa, wb, _, _ = ref.weights()
90
+ return cls(neff=np.array([m.neff for m in modes]),
91
+ ex=np.array([full(m.E[0]) for m in modes]), ey=np.array([full(m.E[1]) for m in modes]),
92
+ hx=np.array([full(m.H[0]) for m in modes]), hy=np.array([full(m.H[1]) for m in modes]),
93
+ wa=wa.ravel(), wb=wb.ravel(), modes=modes)
94
+
95
+ def truncate(self, m: int) -> "ModeSet":
96
+ return ModeSet(self.neff[:m], self.ex[:m], self.ey[:m], self.hx[:m], self.hy[:m], self.wa, self.wb,
97
+ self.modes[:m])
98
+
99
+ def cross(self, other: "ModeSet") -> np.ndarray:
100
+ """O[i, j] = <E_i(self), H_j(other)>."""
101
+ return (self.ex * self.wa) @ other.hy.T - (self.ey * self.wb) @ other.hx.T
102
+
103
+ def nbytes(self) -> int:
104
+ return sum(a.nbytes for a in (self.ex, self.ey, self.hx, self.hy))
105
+
106
+
107
+ def interface(left: ModeSet, right: ModeSet) -> SMatrix:
108
+ """Mode matching with E continuity tested by the right H and H continuity by the left E.
109
+
110
+ With O = <E^L, h^R> only: R_L = (I + O O^T)^-1 (I - O O^T), T_LR = O^T (I + R_L),
111
+ R_R = -(I + O^T O)^-1 (I - O^T O), T_RL = O (I - R_R). Reciprocal (T_RL = T_LR^T) for any
112
+ truncation, and passive in the single-mode limit (T = 2o/(1+o^2) <= 1), unlike projecting both
113
+ conditions on one side, which gives T = 1/o.
114
+ """
115
+ O = left.cross(right) # O[i, k] = <E_i^L, h_k^R>
116
+ il, ir = np.eye(O.shape[0]), np.eye(O.shape[1])
117
+ g = O @ O.T
118
+ r_l = np.linalg.solve(il + g, il - g)
119
+ t_lr = O.T @ (il + r_l)
120
+ gp = O.T @ O
121
+ r_r = -np.linalg.solve(ir + gp, ir - gp)
122
+ t_rl = O @ (ir - r_r)
123
+ return SMatrix(r_l, t_rl, t_lr, r_r)
124
+
125
+
126
+ def coupling(ports: ModeSet, cells: ModeSet) -> np.ndarray:
127
+ """C[p, k] = ½(<E_p, h_k> + <e_k, H_p>): port mode p in terms of cell mode k (either direction)."""
128
+ return 0.5 * (ports.cross(cells) + cells.cross(ports).T)
129
+
130
+
131
+ # --- slicing along the propagation axis -------------------------------------------------
132
+
133
+
134
+ def _extent_along(geometry, axis: int) -> list[float]:
135
+ """Coordinates along `axis` where the cross-section of `geometry` may change."""
136
+ kind = geometry.kind
137
+ if kind == "box":
138
+ c, s = geometry.center_nm[axis], geometry.size_nm[axis]
139
+ return [c - s / 2, c + s / 2]
140
+ if kind == "ball":
141
+ c, r = geometry.center_nm[axis], geometry.radius_nm
142
+ return [c - r, c + r]
143
+ if kind == "prism":
144
+ if axis == 2:
145
+ return [geometry.z_min_nm, geometry.z_max_nm]
146
+ p = geometry.profile
147
+ if p.kind == "rectangle":
148
+ return [p.center_nm[axis] - p.size_nm[axis] / 2, p.center_nm[axis] + p.size_nm[axis] / 2]
149
+ if p.kind == "circle":
150
+ return [p.center_nm[axis] - p.radius_nm, p.center_nm[axis] + p.radius_nm]
151
+ if p.kind == "ellipse":
152
+ r = max(p.semi_axes_nm)
153
+ return [p.center_nm[axis] - r, p.center_nm[axis] + r]
154
+ return [v[axis] for v in p.vertices_nm]
155
+ return []
156
+
157
+
158
+ def _auto_cells(a: OwnerMaps, b: OwnerMaps, grid: Grid, cells_per_step: float = 3.0,
159
+ lo: int = 10, hi: int = 60) -> int:
160
+ """Cells for a varying segment from how far its outline moves: about `cells_per_step` grid cells per step.
161
+
162
+ `a` and `b` are the cross-sections at 1/4 and 3/4 of the segment (half its length apart).
163
+ """
164
+ da, db = a.comps[2].base, b.comps[2].base
165
+ diff = da != db
166
+ shift = diff.sum(axis=0).max() * grid.dx
167
+ if not grid.invariant_y:
168
+ shift = max(shift, diff.sum(axis=1).max() * grid.dy)
169
+ d = grid.dx if grid.invariant_y else min(grid.dx, grid.dy)
170
+ return int(np.clip(math.ceil(2 * shift / (cells_per_step * d)), lo, hi))
171
+
172
+
173
+ @dataclass
174
+ class Cell:
175
+ start: float
176
+ stop: float
177
+ section: CrossSection
178
+ owners: OwnerMaps
179
+ key: str
180
+
181
+ @property
182
+ def length(self) -> float:
183
+ return self.stop - self.start
184
+
185
+
186
+ def slice_device(structures, background, axis: int, lo: float, hi: float, grid: Grid, dimension: int,
187
+ plane_z_nm: float, taper_cells: int, max_cell_length_nm: float | None, subsamples: int,
188
+ subpixel: str) -> list[Cell]:
189
+ """Piecewise-uniform cells covering [lo, hi]; continuously varying segments are staircased."""
190
+ fine = 1 if subpixel == "none" else subsamples
191
+
192
+ def at(pos):
193
+ sec = CrossSection.from_structures(structures, background, axis, pos, dimension, plane_z_nm)
194
+ own = OwnerMaps.build(sec, grid, fine, 16 * fine)
195
+ return sec, own
196
+
197
+ bps = {lo, hi}
198
+ for s in structures:
199
+ bps.update(b for b in _extent_along(s.geometry, axis) if lo < b < hi)
200
+ bps = sorted(bps)
201
+ # Merge breakpoints closer than a nanometre-scale tolerance.
202
+ tol = 1e-6 * max(1.0, hi - lo)
203
+ pts = [bps[0]]
204
+ for b in bps[1:]:
205
+ if b - pts[-1] > tol:
206
+ pts.append(b)
207
+ pts[-1] = hi
208
+ cells: list[Cell] = []
209
+ for a, b in zip(pts[:-1], pts[1:]):
210
+ probes = [at(a + f * (b - a)) for f in (0.25, 0.5, 0.75)]
211
+ uniform = probes[0][1].same_as(probes[1][1]) and probes[1][1].same_as(probes[2][1])
212
+ n = 1
213
+ if not uniform:
214
+ n = taper_cells or _auto_cells(probes[0][1], probes[2][1], grid)
215
+ if max_cell_length_nm:
216
+ n = max(n, math.ceil((b - a) / max_cell_length_nm))
217
+ edges = np.linspace(a, b, n + 1)
218
+ for k in range(n):
219
+ if n == 1 and uniform:
220
+ sec, own = probes[1]
221
+ else:
222
+ sec, own = at(0.5 * (edges[k] + edges[k + 1]))
223
+ key = own.digest() + "|" + sec.material_key()
224
+ if cells and cells[-1].key == key:
225
+ cells[-1].stop = float(edges[k + 1])
226
+ else:
227
+ cells.append(Cell(float(edges[k]), float(edges[k + 1]), sec, own, key))
228
+ return cells
229
+
230
+
231
+ # --- mode cache ------------------------------------------------------------------------
232
+
233
+
234
+ class _ModeCache:
235
+ """Byte-limited LRU of cell mode sets, shared across solves (e.g. length sweeps)."""
236
+
237
+ def __init__(self, max_bytes: int = 256 * 2**20):
238
+ self.max_bytes = max_bytes
239
+ self._d: OrderedDict = OrderedDict()
240
+ self._bytes = 0
241
+
242
+ def get(self, key):
243
+ v = self._d.get(key)
244
+ if v is not None:
245
+ self._d.move_to_end(key)
246
+ return v
247
+
248
+ def put(self, key, value: ModeSet):
249
+ if key in self._d:
250
+ return
251
+ self._d[key] = value
252
+ self._bytes += value.nbytes()
253
+ while self._bytes > self.max_bytes and len(self._d) > 1:
254
+ _, old = self._d.popitem(last=False)
255
+ self._bytes -= old.nbytes()
256
+
257
+ def clear(self):
258
+ self._d.clear()
259
+ self._bytes = 0
260
+
261
+
262
+ MODE_CACHE = _ModeCache()
263
+
264
+
265
+ def cell_modes(cell: Cell, grid: Grid, wavelength_nm: float, num_modes: int, polarization, subpixel: str,
266
+ pml_reflection: float, use_cache: bool = True) -> ModeSet:
267
+ key = (cell.key, repr(grid), float(wavelength_nm), num_modes, polarization, subpixel, pml_reflection)
268
+ if use_cache:
269
+ hit = MODE_CACHE.get(key)
270
+ if hit is not None:
271
+ return hit
272
+ solver = ModeSolver(cell.section, grid, subpixel, pml_reflection=pml_reflection, owners=cell.owners)
273
+ modes = solver.solve(wavelength_nm, num_modes, None, polarization, group_index="none", extra_modes=0)
274
+ ms = ModeSet.from_modes(modes, grid)
275
+ if use_cache:
276
+ MODE_CACHE.put(key, ms)
277
+ return ms
278
+
279
+
280
+ def port_mode_set(section: CrossSection, grid: Grid, lo: tuple[float, float], hi: tuple[float, float],
281
+ wavelength_nm: float, num_modes: int, polarization, subpixel: str, subsamples: int) -> ModeSet:
282
+ """Modes of a port window (snapped to `grid`), zero-padded onto the full cross-section grid."""
283
+ g, window = sub_grid(grid, lo, hi)
284
+ solver = ModeSolver(section, g, subpixel, subsamples)
285
+ modes = solver.solve(wavelength_nm, num_modes, None, polarization, group_index="none", extra_modes=2)
286
+ return ModeSet.from_modes(modes, grid, window)
287
+
288
+
289
+ __all__ = ["Cell", "MODE_CACHE", "ModeSet", "SMatrix", "cell_modes", "coupling", "interface", "plane_axes",
290
+ "port_mode_set", "slice_device"]