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.
- gratinglab/__init__.py +35 -0
- gratinglab/checks.py +263 -0
- gratinglab/compare.py +233 -0
- gratinglab/convergence.py +455 -0
- gratinglab/geometry.py +483 -0
- gratinglab/gui/__init__.py +0 -0
- gratinglab/gui/app.py +80 -0
- gratinglab/gui/diagram.py +699 -0
- gratinglab/gui/diagram3d.py +609 -0
- gratinglab/gui/docs.py +189 -0
- gratinglab/gui/icon.py +101 -0
- gratinglab/gui/integral_options.py +219 -0
- gratinglab/gui/mathtext.py +209 -0
- gratinglab/gui/orders.py +152 -0
- gratinglab/gui/provenance.py +273 -0
- gratinglab/gui/qt/__init__.py +20 -0
- gratinglab/gui/qt/geometry_panel.py +239 -0
- gratinglab/gui/qt/geometry_tab.py +387 -0
- gratinglab/gui/qt/integral_tab.py +447 -0
- gratinglab/gui/qt/main_window.py +514 -0
- gratinglab/gui/qt/profile_plot_panel.py +67 -0
- gratinglab/gui/qt/scalar_tab.py +498 -0
- gratinglab/gui/qt/scene3d_panel.py +347 -0
- gratinglab/gui/qt/setup_tab.py +215 -0
- gratinglab/gui/qt/sizing.py +25 -0
- gratinglab/gui/qt/theory_viewer.py +89 -0
- gratinglab/gui/qt/worker.py +203 -0
- gratinglab/gui/richtext.py +463 -0
- gratinglab/gui/scalar_options.py +139 -0
- gratinglab/gui/state.py +379 -0
- gratinglab/illumination.py +212 -0
- gratinglab/io/__init__.py +0 -0
- gratinglab/io/efficiency_table.py +382 -0
- gratinglab/io/ggp.py +248 -0
- gratinglab/materials/__init__.py +218 -0
- gratinglab/materials/data/SOURCES.md +53 -0
- gratinglab/materials/fresnel.py +282 -0
- gratinglab/materials/optical.py +319 -0
- gratinglab/materials/refractiveindex.py +306 -0
- gratinglab/metrology/__init__.py +121 -0
- gratinglab/metrology/analyzer.py +875 -0
- gratinglab/metrology/boundary/__init__.py +27 -0
- gratinglab/metrology/boundary/average.py +143 -0
- gratinglab/metrology/boundary/pipeline.py +211 -0
- gratinglab/metrology/cli.py +52 -0
- gratinglab/metrology/config.py +170 -0
- gratinglab/metrology/core/__init__.py +23 -0
- gratinglab/metrology/core/analysis.py +315 -0
- gratinglab/metrology/core/image_flatten.py +131 -0
- gratinglab/metrology/core/processing.py +344 -0
- gratinglab/metrology/core/tip.py +233 -0
- gratinglab/metrology/data/__init__.py +16 -0
- gratinglab/metrology/data/aggregation.py +193 -0
- gratinglab/metrology/gui/__init__.py +56 -0
- gratinglab/metrology/gui/icon.py +136 -0
- gratinglab/metrology/gui/qt/__init__.py +19 -0
- gratinglab/metrology/gui/qt/app.py +28 -0
- gratinglab/metrology/gui/qt/boundary_view.py +324 -0
- gratinglab/metrology/gui/qt/canvas.py +151 -0
- gratinglab/metrology/gui/qt/efficiency_dialog.py +273 -0
- gratinglab/metrology/gui/qt/import_view.py +380 -0
- gratinglab/metrology/gui/qt/main_window.py +398 -0
- gratinglab/metrology/gui/qt/wiki_view.py +99 -0
- gratinglab/metrology/gui/qt/worker.py +59 -0
- gratinglab/metrology/gui/state.py +168 -0
- gratinglab/metrology/io/__init__.py +8 -0
- gratinglab/metrology/io/file_io.py +253 -0
- gratinglab/metrology/io/metrics.py +38 -0
- gratinglab/metrology/io/spm.py +210 -0
- gratinglab/metrology/settings.py +216 -0
- gratinglab/metrology/stats/__init__.py +16 -0
- gratinglab/metrology/stats/analysis.py +329 -0
- gratinglab/metrology/stats/icc.py +128 -0
- gratinglab/metrology/visualization/__init__.py +16 -0
- gratinglab/metrology/visualization/diagnostics.py +111 -0
- gratinglab/metrology/visualization/profiles.py +234 -0
- gratinglab/metrology/visualization/statistics.py +412 -0
- gratinglab/metrology/wiki/__init__.py +91 -0
- gratinglab/metrology/wiki/facet-fitting.md +95 -0
- gratinglab/metrology/wiki/file-formats.md +116 -0
- gratinglab/metrology/wiki/flattening.md +105 -0
- gratinglab/metrology/wiki/icc-correction.md +111 -0
- gratinglab/metrology/wiki/overview.md +51 -0
- gratinglab/metrology/wiki/reading-outputs.md +154 -0
- gratinglab/metrology/wiki/row-groups.md +75 -0
- gratinglab/metrology/wiki/tip-correction.md +101 -0
- gratinglab/metrology/workflows.py +383 -0
- gratinglab/problem.py +79 -0
- gratinglab/profiles.py +531 -0
- gratinglab/resolution.py +198 -0
- gratinglab/result.py +268 -0
- gratinglab/solvers/__init__.py +34 -0
- gratinglab/solvers/base.py +207 -0
- gratinglab/solvers/integral/__init__.py +406 -0
- gratinglab/solvers/integral/_boundary.py +50 -0
- gratinglab/solvers/integral/_core.py +126 -0
- gratinglab/solvers/integral/_finite.py +396 -0
- gratinglab/solvers/integral/_greens.py +234 -0
- gratinglab/solvers/integral/_kernels.py +308 -0
- gratinglab/solvers/integral/_nystrom.py +474 -0
- gratinglab/solvers/scalar.py +1144 -0
- gratinglab-0.1.0.dist-info/METADATA +319 -0
- gratinglab-0.1.0.dist-info/RECORD +107 -0
- gratinglab-0.1.0.dist-info/WHEEL +5 -0
- gratinglab-0.1.0.dist-info/entry_points.txt +4 -0
- gratinglab-0.1.0.dist-info/licenses/LICENSE +28 -0
- 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
|
+
)
|