gratinglab 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 (107) hide show
  1. gratinglab/__init__.py +35 -0
  2. gratinglab/checks.py +263 -0
  3. gratinglab/compare.py +233 -0
  4. gratinglab/convergence.py +455 -0
  5. gratinglab/geometry.py +483 -0
  6. gratinglab/gui/__init__.py +0 -0
  7. gratinglab/gui/app.py +80 -0
  8. gratinglab/gui/diagram.py +699 -0
  9. gratinglab/gui/diagram3d.py +609 -0
  10. gratinglab/gui/docs.py +189 -0
  11. gratinglab/gui/icon.py +101 -0
  12. gratinglab/gui/integral_options.py +219 -0
  13. gratinglab/gui/mathtext.py +209 -0
  14. gratinglab/gui/orders.py +152 -0
  15. gratinglab/gui/provenance.py +273 -0
  16. gratinglab/gui/qt/__init__.py +20 -0
  17. gratinglab/gui/qt/geometry_panel.py +239 -0
  18. gratinglab/gui/qt/geometry_tab.py +387 -0
  19. gratinglab/gui/qt/integral_tab.py +447 -0
  20. gratinglab/gui/qt/main_window.py +514 -0
  21. gratinglab/gui/qt/profile_plot_panel.py +67 -0
  22. gratinglab/gui/qt/scalar_tab.py +498 -0
  23. gratinglab/gui/qt/scene3d_panel.py +347 -0
  24. gratinglab/gui/qt/setup_tab.py +215 -0
  25. gratinglab/gui/qt/sizing.py +25 -0
  26. gratinglab/gui/qt/theory_viewer.py +89 -0
  27. gratinglab/gui/qt/worker.py +203 -0
  28. gratinglab/gui/richtext.py +463 -0
  29. gratinglab/gui/scalar_options.py +139 -0
  30. gratinglab/gui/state.py +379 -0
  31. gratinglab/illumination.py +212 -0
  32. gratinglab/io/__init__.py +0 -0
  33. gratinglab/io/efficiency_table.py +382 -0
  34. gratinglab/io/ggp.py +248 -0
  35. gratinglab/materials/__init__.py +218 -0
  36. gratinglab/materials/data/SOURCES.md +53 -0
  37. gratinglab/materials/fresnel.py +282 -0
  38. gratinglab/materials/optical.py +319 -0
  39. gratinglab/materials/refractiveindex.py +306 -0
  40. gratinglab/metrology/__init__.py +121 -0
  41. gratinglab/metrology/analyzer.py +875 -0
  42. gratinglab/metrology/boundary/__init__.py +27 -0
  43. gratinglab/metrology/boundary/average.py +143 -0
  44. gratinglab/metrology/boundary/pipeline.py +211 -0
  45. gratinglab/metrology/cli.py +52 -0
  46. gratinglab/metrology/config.py +170 -0
  47. gratinglab/metrology/core/__init__.py +23 -0
  48. gratinglab/metrology/core/analysis.py +315 -0
  49. gratinglab/metrology/core/image_flatten.py +131 -0
  50. gratinglab/metrology/core/processing.py +344 -0
  51. gratinglab/metrology/core/tip.py +233 -0
  52. gratinglab/metrology/data/__init__.py +16 -0
  53. gratinglab/metrology/data/aggregation.py +193 -0
  54. gratinglab/metrology/gui/__init__.py +56 -0
  55. gratinglab/metrology/gui/icon.py +136 -0
  56. gratinglab/metrology/gui/qt/__init__.py +19 -0
  57. gratinglab/metrology/gui/qt/app.py +28 -0
  58. gratinglab/metrology/gui/qt/boundary_view.py +324 -0
  59. gratinglab/metrology/gui/qt/canvas.py +151 -0
  60. gratinglab/metrology/gui/qt/efficiency_dialog.py +273 -0
  61. gratinglab/metrology/gui/qt/import_view.py +380 -0
  62. gratinglab/metrology/gui/qt/main_window.py +398 -0
  63. gratinglab/metrology/gui/qt/wiki_view.py +99 -0
  64. gratinglab/metrology/gui/qt/worker.py +59 -0
  65. gratinglab/metrology/gui/state.py +168 -0
  66. gratinglab/metrology/io/__init__.py +8 -0
  67. gratinglab/metrology/io/file_io.py +253 -0
  68. gratinglab/metrology/io/metrics.py +38 -0
  69. gratinglab/metrology/io/spm.py +210 -0
  70. gratinglab/metrology/settings.py +216 -0
  71. gratinglab/metrology/stats/__init__.py +16 -0
  72. gratinglab/metrology/stats/analysis.py +329 -0
  73. gratinglab/metrology/stats/icc.py +128 -0
  74. gratinglab/metrology/visualization/__init__.py +16 -0
  75. gratinglab/metrology/visualization/diagnostics.py +111 -0
  76. gratinglab/metrology/visualization/profiles.py +234 -0
  77. gratinglab/metrology/visualization/statistics.py +412 -0
  78. gratinglab/metrology/wiki/__init__.py +91 -0
  79. gratinglab/metrology/wiki/facet-fitting.md +95 -0
  80. gratinglab/metrology/wiki/file-formats.md +116 -0
  81. gratinglab/metrology/wiki/flattening.md +105 -0
  82. gratinglab/metrology/wiki/icc-correction.md +111 -0
  83. gratinglab/metrology/wiki/overview.md +51 -0
  84. gratinglab/metrology/wiki/reading-outputs.md +154 -0
  85. gratinglab/metrology/wiki/row-groups.md +75 -0
  86. gratinglab/metrology/wiki/tip-correction.md +101 -0
  87. gratinglab/metrology/workflows.py +383 -0
  88. gratinglab/problem.py +79 -0
  89. gratinglab/profiles.py +531 -0
  90. gratinglab/resolution.py +198 -0
  91. gratinglab/result.py +268 -0
  92. gratinglab/solvers/__init__.py +34 -0
  93. gratinglab/solvers/base.py +207 -0
  94. gratinglab/solvers/integral/__init__.py +406 -0
  95. gratinglab/solvers/integral/_boundary.py +50 -0
  96. gratinglab/solvers/integral/_core.py +126 -0
  97. gratinglab/solvers/integral/_finite.py +396 -0
  98. gratinglab/solvers/integral/_greens.py +234 -0
  99. gratinglab/solvers/integral/_kernels.py +308 -0
  100. gratinglab/solvers/integral/_nystrom.py +474 -0
  101. gratinglab/solvers/scalar.py +1144 -0
  102. gratinglab-0.1.0.dist-info/METADATA +319 -0
  103. gratinglab-0.1.0.dist-info/RECORD +107 -0
  104. gratinglab-0.1.0.dist-info/WHEEL +5 -0
  105. gratinglab-0.1.0.dist-info/entry_points.txt +4 -0
  106. gratinglab-0.1.0.dist-info/licenses/LICENSE +28 -0
  107. gratinglab-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,207 @@
1
+ """The solver interface, and the registry that lets a backend live anywhere.
2
+
3
+ Two rules make the comparison harness trustworthy:
4
+
5
+ 1. **A solver declares what it can do.** :class:`Capabilities` says whether the
6
+ backend handles conical mounts, which polarizations it supports, and what
7
+ its accuracy knob is called. The convergence harness reads that last field,
8
+ which is why it exists from the very first solver even though scalar theory
9
+ has no knob to turn.
10
+
11
+ 2. **A solver refuses what it cannot do.** Silently approximating an
12
+ unsupported configuration is the failure mode that makes cross-method plots
13
+ lie -- a C-method quietly smoothing a vertical facet returns a plausible,
14
+ wrong number. Raise :class:`UnsupportedConfiguration` instead.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+ from importlib.metadata import entry_points
21
+ from typing import Callable, Protocol, runtime_checkable
22
+
23
+ from numpy.typing import ArrayLike
24
+
25
+ from ..illumination import Illumination, Polarization
26
+ from ..problem import Problem
27
+ from ..result import EfficiencyScan
28
+
29
+ __all__ = [
30
+ "Capabilities",
31
+ "Progress",
32
+ "Solver",
33
+ "SolveCancelled",
34
+ "UnsupportedConfiguration",
35
+ "register",
36
+ "get_solver",
37
+ "available_solvers",
38
+ ]
39
+
40
+ #: ``(done, total)``, in units the solver chooses -- wavelengths, for every
41
+ #: backend so far. See :class:`Solver` for the full contract.
42
+ Progress = Callable[[int, int], None]
43
+
44
+
45
+ class UnsupportedConfiguration(NotImplementedError):
46
+ """A solver was asked for something outside its declared capabilities."""
47
+
48
+
49
+ class SolveCancelled(Exception):
50
+ """Raised **by a caller's progress callback** to abandon a solve.
51
+
52
+ A Python thread cannot be killed and a NumPy-bound call has no check
53
+ point, so a caller that wants to interrupt a solve has to be given a
54
+ moment in which to do it. The progress callback is that moment: raise this
55
+ from inside it and the solver unwinds.
56
+
57
+ A solver must let it through. Wrapping the callback in ``except
58
+ Exception`` would swallow it and quietly restore the old behaviour, in
59
+ which cancelling only stops the *waiting*.
60
+
61
+ Deriving from ``Exception`` rather than ``BaseException`` is deliberate,
62
+ and it is the weaker of the two against accidental swallowing. The
63
+ alternative slips past every ordinary handler -- including the broad one
64
+ in ``gui/qt/worker.py`` that exists to keep a solver failure from aborting
65
+ the process -- and an unhandled ``BaseException`` on a worker thread is a
66
+ worse failure than the one being guarded against. The contract is stated
67
+ and tested instead.
68
+ """
69
+
70
+
71
+ @dataclass(frozen=True, slots=True)
72
+ class Capabilities:
73
+ """What a backend can actually do, declared rather than discovered.
74
+
75
+ Attributes
76
+ ----------
77
+ name
78
+ Registry key, e.g. ``"scalar"``, ``"rcwa"``, ``"integral"``.
79
+ conical
80
+ Handles off-plane mounts (``gamma != 90``). A backend that only does
81
+ in-plane must say so; the off-plane X-ray case is the primary
82
+ application here, and a solver silently treating it as in-plane would
83
+ be wrong by a factor of ``sin(gamma)`` in the phase.
84
+ polarizations
85
+ Which polarizations the backend resolves. Scalar theory neglects
86
+ polarization entirely, so it advertises only ``"unpolarized"``.
87
+ accuracy_knob
88
+ Name of the keyword controlling numerical accuracy -- ``"truncation"``
89
+ for RCWA, ``"boundary_points"`` for the integral method. ``None`` for a
90
+ closed-form method. The convergence harness sweeps this parameter.
91
+ rigorous
92
+ False for approximate theories. Marks results that should never be
93
+ presented as reference values.
94
+ handles_undercut
95
+ True only for methods that parametrise the boundary rather than
96
+ assuming a height function -- in practice, the integral method.
97
+ reports_progress
98
+ Accepts a ``progress`` keyword and honours the contract in
99
+ :class:`Solver`. Declared rather than assumed because a backend that
100
+ has not been updated -- including a third-party one arriving through
101
+ the ``gratinglab.solvers`` entry point -- has an explicit signature
102
+ that would raise ``TypeError`` on an unexpected keyword. A caller
103
+ checks this before passing one.
104
+
105
+ A solver declaring ``False`` is simply not cancellable. That is a fact
106
+ about the backend, and a UI should say so rather than offer a Cancel
107
+ that does nothing.
108
+ """
109
+
110
+ name: str
111
+ conical: bool = True
112
+ polarizations: tuple[Polarization, ...] = ("TE", "TM", "unpolarized")
113
+ accuracy_knob: str | None = None
114
+ rigorous: bool = True
115
+ handles_undercut: bool = False
116
+ reports_progress: bool = False
117
+
118
+ def check(self, problem: Problem, illumination: Illumination) -> None:
119
+ """Raise :class:`UnsupportedConfiguration` if this case is out of scope."""
120
+ if not self.conical and not illumination.is_in_plane:
121
+ raise UnsupportedConfiguration(
122
+ f"{self.name} handles in-plane mounts only, but gamma is "
123
+ f"{illumination.gamma_deg}deg. Treating an off-plane mount as "
124
+ "in-plane would drop a factor of sin(gamma) from the phase."
125
+ )
126
+ if illumination.polarization not in self.polarizations:
127
+ raise UnsupportedConfiguration(
128
+ f"{self.name} does not resolve {illumination.polarization} "
129
+ f"polarization; it supports {', '.join(self.polarizations)}"
130
+ )
131
+ if not self.handles_undercut and not problem.profile.is_single_valued():
132
+ raise UnsupportedConfiguration(
133
+ f"{self.name} cannot represent an undercut profile. Only a "
134
+ "method that parametrises the boundary (integral) can."
135
+ )
136
+
137
+
138
+ @runtime_checkable
139
+ class Solver(Protocol):
140
+ """What every backend provides.
141
+
142
+ **The progress contract**, for a backend declaring
143
+ ``Capabilities.reports_progress``. ``solve`` takes a keyword-only
144
+ ``progress: Progress | None = None`` and:
145
+
146
+ 1. calls it once with ``(0, total)`` **before doing any work**, then once
147
+ after each unit completes, ending exactly at ``(total, total)``;
148
+ 2. keeps ``done`` monotone non-decreasing and ``total`` constant;
149
+ 3. lets any exception the callback raises **propagate unchanged** -- this
150
+ is what makes :class:`SolveCancelled` work, and a solver that catches
151
+ it silently removes the caller's only way out;
152
+ 4. costs nothing measurable when ``progress`` is ``None``.
153
+
154
+ The leading ``(0, total)`` is not decoration. For RCWA a single wavelength
155
+ can take a minute, so a cancellation point *before* the first one is the
156
+ difference between stopping now and stopping in a minute.
157
+ """
158
+
159
+ capabilities: Capabilities
160
+
161
+ def solve(
162
+ self,
163
+ problem: Problem,
164
+ illumination: Illumination,
165
+ wavelengths: ArrayLike,
166
+ **options,
167
+ ) -> EfficiencyScan:
168
+ """Efficiency over a wavelength scan at fixed geometry."""
169
+
170
+
171
+ _REGISTRY: dict[str, Solver] = {}
172
+
173
+
174
+ def register(solver: Solver) -> Solver:
175
+ """Register a solver under its declared name. Usable as a decorator."""
176
+ name = solver.capabilities.name
177
+ if name in _REGISTRY and _REGISTRY[name] is not solver:
178
+ raise ValueError(f"a different solver is already registered as {name!r}")
179
+ _REGISTRY[name] = solver
180
+ return solver
181
+
182
+
183
+ def _load_entry_points() -> None:
184
+ """Pull in backends published by other distributions.
185
+
186
+ A backend can live in its own package and still appear here, which is the
187
+ point of keeping the problem spec free of solver fields.
188
+ """
189
+ for entry in entry_points(group="gratinglab.solvers"):
190
+ if entry.name not in _REGISTRY:
191
+ register(entry.load())
192
+
193
+
194
+ def get_solver(name: str) -> Solver:
195
+ """Look up a registered solver by name."""
196
+ if name not in _REGISTRY:
197
+ _load_entry_points()
198
+ if name not in _REGISTRY:
199
+ known = ", ".join(sorted(_REGISTRY)) or "none"
200
+ raise KeyError(f"no solver registered as {name!r}; available: {known}")
201
+ return _REGISTRY[name]
202
+
203
+
204
+ def available_solvers() -> tuple[str, ...]:
205
+ """Every registered solver name, sorted."""
206
+ _load_entry_points()
207
+ return tuple(sorted(_REGISTRY))
@@ -0,0 +1,406 @@
1
+ r"""The integral method: rigorous efficiency from a boundary integral equation.
2
+
3
+ Two boundary conditions, one solver. With ``conductivity="perfect"`` (the
4
+ default) the grating is a **perfect conductor** in any mount: the conical
5
+ (off-plane) problem decouples exactly into an in-plane problem at the
6
+ reduced wavelength ``lambda / sin(gamma)`` (M&P eq. 4.65; thesis
7
+ Chapter-2.tex:931), TE stays a Dirichlet problem, TM a Neumann problem,
8
+ cross-polarization conversion is identically zero, and efficiencies are
9
+ **relative to a perfect reflector** -- the sum over orders is exactly 1 in
10
+ the continuum (M&P eqs. 4.34/4.42; thesis eq:prop_order_unity). This
11
+ matches the perfect-conductivity PCGrate reference tables the solver is
12
+ validated against; a named coating is ignored, with a provenance warning.
13
+
14
+ With ``conductivity="tabulated"`` the grating is one interface into a
15
+ semi-infinite material whose complex index is read per wavelength from the
16
+ named ``coating`` (falling back to ``substrate``) through
17
+ ``materials.lookup`` -- the coupled conical system of Goray & Schmidt
18
+ (JOSA A 27, 585, 2010), assembled in ``_finite``. Efficiencies are
19
+ **absolute**: TE and TM couple through the cone angle, and each solve also
20
+ returns the absorbed fraction from an independent boundary integral, so
21
+ the recorded scan satisfies ``R + A = 1`` as a theorem (the successor of
22
+ the perfect-conductor sum rule, and what
23
+ :func:`gratinglab.checks.check_energy_balance` then checks). Folding a
24
+ Fresnel reflectivity onto the perfect-conductivity result is **not**
25
+ equivalent and remains the error catalogued in ``docs/conventions.md``
26
+ section 10 item 5.
27
+
28
+ Known limitation, measured and documented rather than hidden: profile
29
+ corners make the TM solve (and with finite conductivity, both
30
+ polarizations) converge at first order -- the Meixner edge condition meets
31
+ a plain equal-arc mesh -- so energy balance on cornered profiles carries a
32
+ percent-level deficit at moderate ``boundary_points``. The deviation is
33
+ reported in provenance; a graded corner mesh is the known follow-up.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import time
39
+ from typing import TYPE_CHECKING, Literal
40
+
41
+ import numpy as np
42
+ from numpy.typing import ArrayLike
43
+
44
+ from ...geometry import cos_beta, is_propagating, order_range, sin_beta
45
+ from ...illumination import Illumination
46
+ from ...problem import Problem
47
+ from ...result import EfficiencyScan, Provenance
48
+ from ..base import Capabilities, Progress, UnsupportedConfiguration, register
49
+ from ._boundary import physical_boundary
50
+ from ._core import solve_transverse
51
+ from ._finite import solve_finite_states
52
+ from ._nystrom import OperatorAssembler
53
+
54
+ if TYPE_CHECKING: # pragma: no cover - import cycle guard for annotations
55
+ from ...materials import OpticalConstants
56
+
57
+ __all__ = ["IntegralSolver", "integral"]
58
+
59
+ #: Fewer nodes than this per transverse wavelength along the boundary cannot
60
+ #: resolve the field's oscillation; the solve is refused rather than let a
61
+ #: coarse rung return a plausible wrong number. With finite conductivity the
62
+ #: same floor applies to the metal-side transverse wavelength, which is what
63
+ #: makes visible-light metals (|n| of a few) cost more nodes than X-rays.
64
+ _POINTS_PER_WAVELENGTH = 6.0
65
+
66
+ #: Deviation of the conserved quantity from unity -- the sum alone under the
67
+ #: perfect boundary, the sum plus absorption under the tabulated one -- above
68
+ #: which the discretisation is worth naming in provenance. Reported, never
69
+ #: rescaled: the theorem says 1, and how close the mesh got is the error
70
+ #: estimate.
71
+ _ENERGY_DEVIATION_WARN = 0.005
72
+
73
+ #: ``min |cos beta_m|`` below which an order is passing off and the kernel's
74
+ #: ``1/gamma_m`` makes the solve numerically delicate (a Rayleigh anomaly).
75
+ _ANOMALY_COS = 1e-3
76
+
77
+
78
+ def _resolve_index(problem: Problem) -> "tuple[str, OpticalConstants]":
79
+ """Resolve the material the tabulated boundary condition reads.
80
+
81
+ ``coating`` first, else ``substrate`` -- the model is a single interface
82
+ into a semi-infinite material, so whichever is named is that material.
83
+ Naming neither is a refusal, not a default: there is no rigorous answer
84
+ without an index. An unknown name raises ``UnknownMaterial`` listing
85
+ what is available (same policy as ``scalar._resolve_coating``).
86
+ """
87
+ from ...materials import lookup
88
+
89
+ name = problem.coating or problem.substrate
90
+ if name is None:
91
+ raise UnsupportedConfiguration(
92
+ 'conductivity="tabulated" needs a material: name one on '
93
+ "Problem.coating (or substrate). The finite-conductivity "
94
+ "boundary condition reads its complex index per wavelength; "
95
+ "without a material there is nothing rigorous to compute."
96
+ )
97
+ return name, lookup(name)
98
+
99
+
100
+ class IntegralSolver:
101
+ """Boundary-integral (Nystrom) solver for gratings in any mount.
102
+
103
+ Perfectly conducting by default; ``conductivity="tabulated"`` switches
104
+ to the finite-conductivity coupled system of Goray & Schmidt (2010)
105
+ with the material read from the problem's coating.
106
+ """
107
+
108
+ capabilities = Capabilities(
109
+ name="integral",
110
+ conical=True,
111
+ polarizations=("TE", "TM", "unpolarized"),
112
+ accuracy_knob="boundary_points",
113
+ rigorous=True,
114
+ handles_undercut=True,
115
+ reports_progress=True,
116
+ )
117
+
118
+ def solve(
119
+ self,
120
+ problem: Problem,
121
+ illumination: Illumination,
122
+ wavelengths: ArrayLike,
123
+ *,
124
+ boundary_points: int = 400,
125
+ spectral_terms: int | None = None,
126
+ conductivity: Literal["perfect", "tabulated"] = "perfect",
127
+ progress: "Progress | None" = None,
128
+ ) -> EfficiencyScan:
129
+ """Efficiency over a wavelength scan at fixed geometry.
130
+
131
+ Parameters
132
+ ----------
133
+ boundary_points
134
+ Number of equal-arc-length nodes on one groove period -- the
135
+ accuracy knob the convergence harness sweeps. Kernels are
136
+ assembled as separable BLAS products (``_kernels``): a few
137
+ ``(P, 2M+1)`` GEMMs per wavelength plus the dense solve, after
138
+ a one-time ``O(P^2 M)`` geometry pass amortised over the scan.
139
+ The tabulated boundary condition costs ~2x the perfect one per
140
+ wavelength (more operators, sharing kernel evaluations per
141
+ medium, and a doubled system off-plane).
142
+ spectral_terms
143
+ Truncation of the kernel's spectral sums. Defaults to
144
+ ``max(boundary_points // 2, ...)`` so the single knob converges
145
+ both discretisations together (M&P Table 4.1 converges at
146
+ ``P ~ 2.2 M``), extended past the metal-side propagating cone
147
+ for the tabulated condition; override only for truncation
148
+ studies.
149
+ conductivity
150
+ ``"perfect"`` -- perfectly conducting boundary, efficiencies
151
+ relative to a perfect reflector. ``"tabulated"`` -- the
152
+ finite-conductivity system of Goray & Schmidt (2010) with the
153
+ complex index read from ``problem.coating`` (or ``substrate``)
154
+ per wavelength; efficiencies absolute, absorption recorded on
155
+ the scan. Anything else raises
156
+ :class:`UnsupportedConfiguration`.
157
+ progress
158
+ Per-wavelength callback under the contract in
159
+ :class:`~gratinglab.solvers.base.Solver`.
160
+ """
161
+ self.capabilities.check(problem, illumination)
162
+ if conductivity not in ("perfect", "tabulated"):
163
+ raise UnsupportedConfiguration(
164
+ f"conductivity={conductivity!r} is not implemented: "
165
+ '"perfect" is the perfectly conducting boundary and '
166
+ '"tabulated" the finite-conductivity system of Goray & '
167
+ "Schmidt (2010). Intermediate models (e.g. a Leontovich "
168
+ "impedance condition) are not offered -- they are invalid "
169
+ "in the soft-X-ray regime this solver targets."
170
+ )
171
+
172
+ wavelengths = np.atleast_1d(np.asarray(wavelengths, dtype=np.float64))
173
+ if wavelengths.ndim != 1:
174
+ raise ValueError("wavelengths must be one-dimensional")
175
+ if (wavelengths <= 0).any():
176
+ raise ValueError("wavelengths must be positive")
177
+ if boundary_points < 32:
178
+ raise ValueError(
179
+ f"boundary_points must be at least 32, got {boundary_points}"
180
+ )
181
+
182
+ started = time.perf_counter()
183
+ sin_alpha = illumination.sin_alpha
184
+ sin_gamma = illumination.sin_gamma
185
+ # Exact zero in-plane (sin_gamma == 1), so the coupled system's
186
+ # cross blocks vanish identically rather than to rounding.
187
+ cos_gamma = float(np.sqrt(1.0 - sin_gamma**2))
188
+
189
+ boundary = physical_boundary(problem, boundary_points)
190
+
191
+ # The field oscillates along the boundary at the *reduced* wavelength;
192
+ # a mesh that cannot resolve it would not fail loudly on its own.
193
+ reduced_min = float(wavelengths.min()) / sin_gamma
194
+ needed = int(
195
+ np.ceil(_POINTS_PER_WAVELENGTH * boundary.arc_length / reduced_min)
196
+ )
197
+ if boundary_points < needed:
198
+ raise ValueError(
199
+ f"boundary_points={boundary_points} gives fewer than "
200
+ f"{_POINTS_PER_WAVELENGTH:g} nodes per transverse wavelength "
201
+ f"({reduced_min:.4g} nm) along the {boundary.arc_length:.4g} nm "
202
+ f"boundary; need at least {needed}"
203
+ )
204
+
205
+ material: str | None = None
206
+ indices = None
207
+ metal_cone = 0
208
+ if conductivity == "tabulated":
209
+ material, constants = _resolve_index(problem)
210
+ indices = np.asarray(constants.n(wavelengths), dtype=np.complex128)
211
+ if (indices.imag < 0.0).any():
212
+ raise ValueError(
213
+ f"material {material!r} tabulates a negative absorption "
214
+ "(gain); the transmission problem loses uniqueness there "
215
+ "(Goray & Schmidt 2010, section 2.B)"
216
+ )
217
+ # The same resolution floor on the metal side: the transverse
218
+ # wavelength inside the material is lambda / |sqrt(n^2 -
219
+ # cos^2 gamma)|, short for visible-light metals.
220
+ factors = np.abs(np.sqrt(indices**2 - cos_gamma**2))
221
+ metal_reduced_min = float((wavelengths / factors).min())
222
+ needed_metal = int(
223
+ np.ceil(
224
+ _POINTS_PER_WAVELENGTH
225
+ * boundary.arc_length
226
+ / metal_reduced_min
227
+ )
228
+ )
229
+ if boundary_points < needed_metal:
230
+ raise ValueError(
231
+ f"boundary_points={boundary_points} gives fewer than "
232
+ f"{_POINTS_PER_WAVELENGTH:g} nodes per *metal-side* "
233
+ f"transverse wavelength ({metal_reduced_min:.4g} nm for "
234
+ f"{material}); need at least {needed_metal}. The field "
235
+ "inside a conductive material oscillates and decays on "
236
+ "the scale lambda/|n|, and the mesh must resolve it"
237
+ )
238
+ # The kernel's spectral sum must also clear the metal-side
239
+ # propagating cone |k_t^-| d / 2 pi.
240
+ metal_cone = int(
241
+ np.ceil(
242
+ (factors / wavelengths).max() * problem.period
243
+ )
244
+ )
245
+
246
+ all_orders = order_range(
247
+ float(wavelengths.min()), problem.period, sin_alpha, sin_gamma
248
+ )
249
+ max_order = int(np.abs(all_orders).max())
250
+ terms = (
251
+ spectral_terms
252
+ if spectral_terms is not None
253
+ else max(boundary_points // 2, max_order + 8, metal_cone + 8)
254
+ )
255
+ if terms <= max_order or terms <= metal_cone:
256
+ raise ValueError(
257
+ f"spectral_terms={terms} cannot represent order "
258
+ f"{max(max_order, metal_cone)}; the kernel's spectral sum "
259
+ "must extend past every propagating order on both sides of "
260
+ "the boundary"
261
+ )
262
+
263
+ polarizations = (
264
+ ("TE", "TM")
265
+ if illumination.polarization == "unpolarized"
266
+ else (illumination.polarization,)
267
+ )
268
+ incidents = {"TE": (1.0 + 0j, 0.0 + 0j), "TM": (0.0 + 0j, 1.0 + 0j)}
269
+
270
+ efficiency = np.zeros((len(wavelengths), len(all_orders)))
271
+ absorption = np.zeros(len(wavelengths))
272
+ propagating = np.zeros_like(efficiency, dtype=bool)
273
+ warnings: list[str] = []
274
+ anomalous: list[float] = []
275
+
276
+ # One kernel assembly for the whole scan: the geometry-only half
277
+ # (including the cached Kummer tails) is what every wavelength and
278
+ # both media share; the per-wavelength memo is cleared each row.
279
+ assembler = OperatorAssembler(
280
+ boundary, period=problem.period, terms=terms
281
+ )
282
+
283
+ for row, wavelength in enumerate(wavelengths):
284
+ if progress is not None:
285
+ # Top of the row: (0, n) before any work, and a cancellation
286
+ # point ahead of every wavelength (see scalar.py).
287
+ progress(row, len(wavelengths))
288
+ assembler.clear()
289
+
290
+ sines = sin_beta(
291
+ all_orders, wavelength, problem.period, sin_alpha, sin_gamma
292
+ )
293
+ live = is_propagating(sines)
294
+ if not live.any():
295
+ continue
296
+ if np.abs(cos_beta(sines[live])).min() < _ANOMALY_COS:
297
+ anomalous.append(float(wavelength))
298
+
299
+ values = np.zeros(int(live.sum()))
300
+ if conductivity == "perfect":
301
+ reduced = float(wavelength) / sin_gamma
302
+ for polarization in polarizations:
303
+ solution = solve_transverse(
304
+ boundary,
305
+ wavelength=reduced,
306
+ period=problem.period,
307
+ sin_alpha=sin_alpha,
308
+ polarization=polarization,
309
+ terms=terms,
310
+ assembler=assembler,
311
+ )
312
+ columns = np.searchsorted(all_orders[live], solution.orders)
313
+ values[columns] += solution.efficiencies / len(polarizations)
314
+ else:
315
+ solutions = solve_finite_states(
316
+ boundary,
317
+ wavelength=float(wavelength),
318
+ period=problem.period,
319
+ sin_alpha=sin_alpha,
320
+ index=complex(indices[row]),
321
+ cos_gamma=cos_gamma,
322
+ incidents=[incidents[p] for p in polarizations],
323
+ terms=terms,
324
+ assembler=assembler,
325
+ )
326
+ for solution in solutions:
327
+ columns = np.searchsorted(all_orders[live], solution.orders)
328
+ values[columns] += solution.efficiencies / len(polarizations)
329
+ absorption[row] += solution.absorption / len(polarizations)
330
+
331
+ efficiency[row, live] = values
332
+ propagating[row, live] = True
333
+
334
+ if progress is not None:
335
+ progress(len(wavelengths), len(wavelengths))
336
+
337
+ if anomalous:
338
+ span = f"{min(anomalous):.6g}-{max(anomalous):.6g} nm"
339
+ warnings.append(
340
+ f"an order passes off within |cos beta| < {_ANOMALY_COS:g} at "
341
+ f"{len(anomalous)} wavelength(s) in {span} (Rayleigh anomaly); "
342
+ "the kernel's 1/gamma_m is nearly singular there and those "
343
+ "rows are numerically delicate"
344
+ )
345
+ if conductivity == "perfect" and problem.coating is not None:
346
+ warnings.append(
347
+ f"coating {problem.coating!r} is not consulted: the perfectly "
348
+ "conducting boundary condition has no material in it, and the "
349
+ "sum over orders is relative to a perfect reflector "
350
+ "(docs/conventions.md section 10 item 5)"
351
+ )
352
+
353
+ # Report, never rescale: the theorem says 1 -- for the sum alone
354
+ # (perfect) or the sum plus absorption (tabulated) -- and the
355
+ # discretisation says how close it got. Corners push the solve off
356
+ # at first order.
357
+ totals = np.where(propagating, efficiency, 0.0).sum(axis=1)
358
+ if conductivity == "tabulated":
359
+ totals = totals + absorption
360
+ computed = totals[propagating.any(axis=1)]
361
+ deviation = float(np.abs(computed - 1.0).max()) if computed.size else 0.0
362
+ if deviation > _ENERGY_DEVIATION_WARN:
363
+ warnings.append(
364
+ f"summed efficiency{' plus absorption' if conductivity == 'tabulated' else ''} "
365
+ f"strays from unity by up to {100 * deviation:.2f}% "
366
+ "(theorem: exactly 1). Profile corners limit convergence to "
367
+ "first order on an equal-arc mesh; raise boundary_points, and "
368
+ "read the deviation as the discretisation error estimate"
369
+ )
370
+
371
+ from ... import __version__
372
+
373
+ notes: dict = {
374
+ "spectral_terms": terms,
375
+ "energy_balance_deviation": deviation,
376
+ }
377
+ if conductivity == "perfect":
378
+ notes["boundary_condition"] = "perfectly-conducting"
379
+ notes["normalization"] = "relative to a perfect reflector"
380
+ else:
381
+ notes["boundary_condition"] = (
382
+ "finite-conductivity (Goray & Schmidt 2010)"
383
+ )
384
+ notes["normalization"] = "absolute"
385
+ notes["material"] = material
386
+
387
+ return EfficiencyScan(
388
+ wavelengths=wavelengths,
389
+ orders=all_orders,
390
+ efficiency=efficiency,
391
+ propagating=propagating,
392
+ provenance=Provenance(
393
+ method="integral",
394
+ version=__version__,
395
+ truncation=boundary_points,
396
+ converged=None, # the convergence harness sets this
397
+ wall_time_s=time.perf_counter() - started,
398
+ warnings=tuple(warnings),
399
+ notes=notes,
400
+ ),
401
+ absorption=absorption if conductivity == "tabulated" else None,
402
+ )
403
+
404
+
405
+ #: The registered singleton.
406
+ integral = register(IntegralSolver())
@@ -0,0 +1,50 @@
1
+ """The boundary curve in physical units, once.
2
+
3
+ Mirrors ``Problem.height_nm``: the normalised profile is converted to nm in
4
+ exactly one place, so no other integral-method module multiplies by the
5
+ period.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+ import numpy as np
13
+ from numpy.typing import NDArray
14
+
15
+ from ...problem import Problem
16
+
17
+ __all__ = ["PhysicalBoundary", "physical_boundary"]
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class PhysicalBoundary:
22
+ """Equal-arc-length boundary samples of one groove period, in nm.
23
+
24
+ ``spacing`` is the quadrature weight of the rectangular rule,
25
+ ``arc_length / len(x)`` -- the nodes are equally spaced in arc length by
26
+ construction (see :meth:`gratinglab.profiles.Profile.boundary`).
27
+ """
28
+
29
+ x: NDArray[np.float64]
30
+ y: NDArray[np.float64]
31
+ nx: NDArray[np.float64]
32
+ ny: NDArray[np.float64]
33
+ arc_length: float
34
+
35
+ @property
36
+ def spacing(self) -> float:
37
+ return self.arc_length / len(self.x)
38
+
39
+
40
+ def physical_boundary(problem: Problem, n: int) -> PhysicalBoundary:
41
+ """Sample ``problem.profile`` at ``n`` boundary points, in nm."""
42
+ curve = problem.profile.boundary(n)
43
+ period = problem.period
44
+ return PhysicalBoundary(
45
+ x=np.asarray(curve.t, dtype=np.float64) * period,
46
+ y=np.asarray(curve.y, dtype=np.float64) * period,
47
+ nx=np.asarray(curve.nx, dtype=np.float64),
48
+ ny=np.asarray(curve.ny, dtype=np.float64),
49
+ arc_length=float(curve.arc_length) * period,
50
+ )