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.
- lightsmith/__init__.py +44 -0
- lightsmith/api.py +110 -0
- lightsmith/backends/__init__.py +21 -0
- lightsmith/backends/_eme/__init__.py +290 -0
- lightsmith/backends/_fde/__init__.py +245 -0
- lightsmith/backends/_fde/grid.py +148 -0
- lightsmith/backends/_fde/modes.py +314 -0
- lightsmith/backends/_fde/section.py +201 -0
- lightsmith/backends/_fdfd/__init__.py +20 -0
- lightsmith/backends/_fdfd/engine.py +215 -0
- lightsmith/backends/_fdfd/grid.py +116 -0
- lightsmith/backends/_fdfd/linsolve.py +208 -0
- lightsmith/backends/_fdfd/modes.py +156 -0
- lightsmith/backends/_fdfd/operators.py +100 -0
- lightsmith/backends/_fdfd/ports.py +116 -0
- lightsmith/backends/_fdtd/__init__.py +7 -0
- lightsmith/backends/_fdtd/engine.py +450 -0
- lightsmith/backends/_fdtd/media.py +129 -0
- lightsmith/backends/_fdtd/options.py +42 -0
- lightsmith/backends/_fdtd/periodic.py +399 -0
- lightsmith/backends/_fdtd/scattering.py +235 -0
- lightsmith/backends/_fdtd/setup.py +251 -0
- lightsmith/backends/_fdtd/tfsf.py +191 -0
- lightsmith/backends/_pwe/__init__.py +7 -0
- lightsmith/backends/_pwe/engine.py +165 -0
- lightsmith/backends/_pwe/geometry.py +346 -0
- lightsmith/backends/_pwe/solvers.py +386 -0
- lightsmith/backends/_rcwa/__init__.py +8 -0
- lightsmith/backends/_rcwa/core.py +174 -0
- lightsmith/backends/_rcwa/fourier.py +302 -0
- lightsmith/backends/_rcwa/linalg.py +129 -0
- lightsmith/backends/base.py +109 -0
- lightsmith/backends/circuit.py +104 -0
- lightsmith/backends/dda.py +448 -0
- lightsmith/backends/eme.py +252 -0
- lightsmith/backends/fde.py +227 -0
- lightsmith/backends/fdfd.py +159 -0
- lightsmith/backends/fdtd.py +105 -0
- lightsmith/backends/mie.py +63 -0
- lightsmith/backends/mie_native.py +540 -0
- lightsmith/backends/pwe.py +417 -0
- lightsmith/backends/quasistatic.py +215 -0
- lightsmith/backends/rcwa.py +459 -0
- lightsmith/backends/tmatrix_treams.py +473 -0
- lightsmith/backends/tmm.py +299 -0
- lightsmith/circuits/__init__.py +40 -0
- lightsmith/circuits/engine.py +339 -0
- lightsmith/circuits/models.py +594 -0
- lightsmith/circuits/touchstone.py +183 -0
- lightsmith/cli.py +81 -0
- lightsmith/data/materials/Ag/Johnson.yml +62 -0
- lightsmith/data/materials/Ag/McPeak.yml +154 -0
- lightsmith/data/materials/Ag/Rakic-LD.yml +214 -0
- lightsmith/data/materials/Al/McPeak.yml +310 -0
- lightsmith/data/materials/Al/Rakic-LD.yml +1014 -0
- lightsmith/data/materials/Al/Rakic.yml +219 -0
- lightsmith/data/materials/Au/Johnson.yml +62 -0
- lightsmith/data/materials/Au/McPeak.yml +154 -0
- lightsmith/data/materials/Au/Rakic-LD.yml +214 -0
- lightsmith/data/materials/Cu/Johnson.yml +62 -0
- lightsmith/data/materials/Cu/McPeak.yml +154 -0
- lightsmith/data/materials/Cu/Rakic-LD.yml +214 -0
- lightsmith/data/materials/H2O/Daimon-24.0C.yml +16 -0
- lightsmith/data/materials/H2O/Hale.yml +185 -0
- lightsmith/data/materials/Si/Aspnes.yml +59 -0
- lightsmith/data/materials/Si/Green-2008.yml +136 -0
- lightsmith/data/materials/Si/Schinke.yml +139 -0
- lightsmith/data/materials/SiO2/Lemarchand.yml +466 -0
- lightsmith/data/materials/SiO2/Malitson.yml +20 -0
- lightsmith/data/materials/TiO2/Devore-o.yml +16 -0
- lightsmith/data/materials/TiO2/Sarkar.yml +991 -0
- lightsmith/data/materials/TiO2/Siefke.yml +1226 -0
- lightsmith/data/materials/manifest.json +541 -0
- lightsmith/device.py +138 -0
- lightsmith/inverse/__init__.py +95 -0
- lightsmith/inverse/backend.py +62 -0
- lightsmith/inverse/export.py +177 -0
- lightsmith/inverse/objectives.py +82 -0
- lightsmith/inverse/optimizers.py +116 -0
- lightsmith/inverse/parametrization.py +160 -0
- lightsmith/inverse/spec.py +123 -0
- lightsmith/inverse/topopt.py +368 -0
- lightsmith/layout.py +649 -0
- lightsmith/materials.py +567 -0
- lightsmith/materials_fit.py +321 -0
- lightsmith/mcp_server.py +63 -0
- lightsmith/optics/__init__.py +5 -0
- lightsmith/optics/propagation.py +84 -0
- lightsmith/problems.py +306 -0
- lightsmith/py.typed +0 -0
- lightsmith/raster.py +133 -0
- lightsmith/results.py +189 -0
- lightsmith/spec.py +269 -0
- lightsmith-0.1.0.dist-info/METADATA +137 -0
- lightsmith-0.1.0.dist-info/RECORD +98 -0
- lightsmith-0.1.0.dist-info/WHEEL +4 -0
- lightsmith-0.1.0.dist-info/entry_points.txt +3 -0
- 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"]
|