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
gratinglab/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""gratinglab -- an open comparison platform for grating efficiency analysis.
|
|
2
|
+
|
|
3
|
+
The organising idea is that the physical problem and the numerical method are
|
|
4
|
+
separate objects, and the problem spec is serializable. One ``Problem`` goes to
|
|
5
|
+
every solver, so disagreement between methods becomes visible instead of hidden
|
|
6
|
+
behind incompatible conventions.
|
|
7
|
+
|
|
8
|
+
``docs/conventions.md`` is normative. In short: time dependence ``exp(-iwt)``,
|
|
9
|
+
lossy ``n = n' + ik``, grating equation
|
|
10
|
+
``sin(alpha) + sin(beta_m) = m*lambda/(p*sin(gamma))``, efficiencies absolute,
|
|
11
|
+
lengths in nm, angles in degrees at the API boundary.
|
|
12
|
+
|
|
13
|
+
>>> from gratinglab import Illumination
|
|
14
|
+
>>> Illumination.offplane(graze=1.5, azimuth=0.97).gamma_deg
|
|
15
|
+
1.5
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from .illumination import Illumination, Polarization
|
|
21
|
+
from .resolution import ResolvingPower, resolving_power
|
|
22
|
+
from .result import EfficiencyScan, OrderEfficiency, Provenance
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"Illumination",
|
|
28
|
+
"Polarization",
|
|
29
|
+
"EfficiencyScan",
|
|
30
|
+
"OrderEfficiency",
|
|
31
|
+
"Provenance",
|
|
32
|
+
"ResolvingPower",
|
|
33
|
+
"resolving_power",
|
|
34
|
+
"__version__",
|
|
35
|
+
]
|
gratinglab/checks.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
r"""Physics self-checks: validate the *model*, not just the arithmetic.
|
|
2
|
+
|
|
3
|
+
The closed-form tests in ``tests/test_scalar.py`` confirm that a Fourier
|
|
4
|
+
integral is evaluated correctly. They cannot confirm that the integrand is the
|
|
5
|
+
right one, because the expected values are derived from the same formula the
|
|
6
|
+
solver implements. Two checks here close that gap, and neither needs reference
|
|
7
|
+
data or a closed form:
|
|
8
|
+
|
|
9
|
+
**Reciprocity** (:func:`check_reciprocity`) follows from Lorentz reciprocity and
|
|
10
|
+
constrains the *structure* of the phase function. It is sharp: on the scalar
|
|
11
|
+
solver it holds to ~1e-17, and breaking the :math:`\alpha \leftrightarrow \beta`
|
|
12
|
+
symmetry of :math:`\Phi` pushes the violation to ~4e-1 -- sixteen orders of
|
|
13
|
+
magnitude of discrimination.
|
|
14
|
+
|
|
15
|
+
**Energy balance** (:func:`check_energy_balance`) catches the other class of
|
|
16
|
+
error. Summed efficiency exceeding unity is unphysical for any passive
|
|
17
|
+
structure, whatever method produced it. This is the check that found the
|
|
18
|
+
unphysical finite-conductivity run in the reference corpus.
|
|
19
|
+
|
|
20
|
+
Both take a solver rather than a scan where possible, so every backend added
|
|
21
|
+
later inherits them without new code.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
from numpy.typing import ArrayLike, NDArray
|
|
30
|
+
|
|
31
|
+
from .geometry import beta as beta_from_sin
|
|
32
|
+
from .geometry import is_propagating, sin_beta
|
|
33
|
+
from .illumination import Illumination
|
|
34
|
+
from .problem import Problem
|
|
35
|
+
from .result import EfficiencyScan
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"ReciprocityReport",
|
|
39
|
+
"EnergyReport",
|
|
40
|
+
"check_reciprocity",
|
|
41
|
+
"check_energy_balance",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
#: Illumination is undefined at exactly grazing, so reciprocal geometries
|
|
45
|
+
#: within this margin of +/-90 degrees are skipped rather than clamped.
|
|
46
|
+
_GRAZING_MARGIN_DEG = 0.5
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class ReciprocityReport:
|
|
51
|
+
"""Outcome of a reciprocity check."""
|
|
52
|
+
|
|
53
|
+
max_violation: float
|
|
54
|
+
worst_order: int | None
|
|
55
|
+
worst_wavelength: float | None
|
|
56
|
+
pairs_tested: int
|
|
57
|
+
tolerance: float
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def passed(self) -> bool:
|
|
61
|
+
return self.pairs_tested > 0 and self.max_violation <= self.tolerance
|
|
62
|
+
|
|
63
|
+
def __str__(self) -> str: # pragma: no cover - cosmetic
|
|
64
|
+
if not self.pairs_tested:
|
|
65
|
+
return "reciprocity: no testable pairs"
|
|
66
|
+
verdict = "pass" if self.passed else "FAIL"
|
|
67
|
+
return (
|
|
68
|
+
f"reciprocity: {verdict} -- worst {self.max_violation:.3e} "
|
|
69
|
+
f"at order {self.worst_order}, lambda={self.worst_wavelength:g} nm "
|
|
70
|
+
f"({self.pairs_tested} pairs, tol {self.tolerance:.1e})"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, slots=True)
|
|
75
|
+
class EnergyReport:
|
|
76
|
+
"""Outcome of an energy-balance check."""
|
|
77
|
+
|
|
78
|
+
total: NDArray[np.float64]
|
|
79
|
+
wavelengths: NDArray[np.float64]
|
|
80
|
+
tolerance: float
|
|
81
|
+
lossless: bool
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def max_excess(self) -> float:
|
|
85
|
+
"""How far above unity the summed efficiency ever goes.
|
|
86
|
+
|
|
87
|
+
Positive means unphysical: a passive grating cannot return more power
|
|
88
|
+
than it receives, by any method.
|
|
89
|
+
"""
|
|
90
|
+
return float(self.total.max() - 1.0)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def max_deficit(self) -> float:
|
|
94
|
+
"""How far below unity. Expected for absorbing or non-rigorous methods."""
|
|
95
|
+
return float(1.0 - self.total.min())
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def unphysical(self) -> NDArray[np.bool_]:
|
|
99
|
+
"""Wavelengths where efficiency exceeds unity beyond tolerance."""
|
|
100
|
+
return self.total > 1.0 + self.tolerance
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def passed(self) -> bool:
|
|
104
|
+
if self.lossless:
|
|
105
|
+
return bool(np.abs(self.total - 1.0).max() <= self.tolerance)
|
|
106
|
+
return not bool(self.unphysical.any())
|
|
107
|
+
|
|
108
|
+
def __str__(self) -> str: # pragma: no cover - cosmetic
|
|
109
|
+
verdict = "pass" if self.passed else "FAIL"
|
|
110
|
+
return (
|
|
111
|
+
f"energy balance: {verdict} -- total in "
|
|
112
|
+
f"[{self.total.min():.4f}, {self.total.max():.4f}], "
|
|
113
|
+
f"{int(self.unphysical.sum())} of {len(self.total)} points above unity"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def check_reciprocity(
|
|
118
|
+
solver,
|
|
119
|
+
problem: Problem,
|
|
120
|
+
illumination: Illumination,
|
|
121
|
+
wavelengths: ArrayLike,
|
|
122
|
+
*,
|
|
123
|
+
tolerance: float = 1e-9,
|
|
124
|
+
max_orders: int | None = 12,
|
|
125
|
+
**options,
|
|
126
|
+
) -> ReciprocityReport:
|
|
127
|
+
r"""Verify Lorentz reciprocity for a solver.
|
|
128
|
+
|
|
129
|
+
Reversing the diffracted ray of order :math:`m` gives an incidence azimuth
|
|
130
|
+
:math:`\alpha' = \beta_m`, and order :math:`m` then exits along
|
|
131
|
+
:math:`\alpha`. Reciprocity requires
|
|
132
|
+
|
|
133
|
+
.. math::
|
|
134
|
+
\mathscr{E}_m(\alpha) = \mathscr{E}_m(\beta_m)
|
|
135
|
+
|
|
136
|
+
This constrains the phase function to be symmetric under
|
|
137
|
+
:math:`\alpha \leftrightarrow \beta_m`, which is exactly the structure
|
|
138
|
+
:math:`\cos\alpha + \cos\beta_m` in the scalar model. A solver that used
|
|
139
|
+
:math:`2\cos\alpha`, or subtracted rather than added, fails immediately.
|
|
140
|
+
|
|
141
|
+
Parameters
|
|
142
|
+
----------
|
|
143
|
+
max_orders
|
|
144
|
+
Cap on how many orders to test per wavelength, since each one costs an
|
|
145
|
+
extra solve. ``None`` tests all of them.
|
|
146
|
+
|
|
147
|
+
Notes
|
|
148
|
+
-----
|
|
149
|
+
Orders diffracting within half a degree of grazing are skipped: the
|
|
150
|
+
reciprocal illumination is undefined there, and clamping it would compare
|
|
151
|
+
two different geometries.
|
|
152
|
+
"""
|
|
153
|
+
wavelengths = np.atleast_1d(np.asarray(wavelengths, dtype=np.float64))
|
|
154
|
+
|
|
155
|
+
# Collected rather than tracked with a running maximum. A running max
|
|
156
|
+
# seeded at 0.0 never updates when a solver is *exactly* reciprocal, which
|
|
157
|
+
# left the report with worst_order=None on platforms whose BLAS returns a
|
|
158
|
+
# bitwise-zero difference -- the better the solver, the worse the report.
|
|
159
|
+
violations: list[tuple[float, int, float]] = []
|
|
160
|
+
|
|
161
|
+
for wavelength in wavelengths:
|
|
162
|
+
forward = solver.solve(problem, illumination, [wavelength], **options).at(
|
|
163
|
+
wavelength
|
|
164
|
+
)
|
|
165
|
+
orders = forward.propagating_orders
|
|
166
|
+
if max_orders is not None and len(orders) > max_orders:
|
|
167
|
+
# Keep the strongest orders: a violation there matters most, and
|
|
168
|
+
# near-zero orders make the comparison meaningless.
|
|
169
|
+
strength = np.array([forward[int(m)] for m in orders])
|
|
170
|
+
orders = orders[np.argsort(strength)[::-1][:max_orders]]
|
|
171
|
+
|
|
172
|
+
for order in orders:
|
|
173
|
+
sine = sin_beta(
|
|
174
|
+
order,
|
|
175
|
+
wavelength,
|
|
176
|
+
problem.period,
|
|
177
|
+
illumination.sin_alpha,
|
|
178
|
+
illumination.sin_gamma,
|
|
179
|
+
)
|
|
180
|
+
if not is_propagating(sine):
|
|
181
|
+
continue
|
|
182
|
+
beta_deg = float(np.degrees(beta_from_sin(sine)))
|
|
183
|
+
if abs(beta_deg) > 90.0 - _GRAZING_MARGIN_DEG:
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
reversed_illumination = Illumination(
|
|
187
|
+
alpha_deg=beta_deg,
|
|
188
|
+
gamma_deg=illumination.gamma_deg,
|
|
189
|
+
polarization=illumination.polarization,
|
|
190
|
+
)
|
|
191
|
+
backward = solver.solve(
|
|
192
|
+
problem, reversed_illumination, [wavelength], **options
|
|
193
|
+
).at(wavelength)
|
|
194
|
+
|
|
195
|
+
violations.append(
|
|
196
|
+
(
|
|
197
|
+
abs(forward[int(order)] - backward[int(order)]),
|
|
198
|
+
int(order),
|
|
199
|
+
float(wavelength),
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
if not violations:
|
|
204
|
+
return ReciprocityReport(
|
|
205
|
+
max_violation=0.0,
|
|
206
|
+
worst_order=None,
|
|
207
|
+
worst_wavelength=None,
|
|
208
|
+
pairs_tested=0,
|
|
209
|
+
tolerance=tolerance,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
worst, worst_order, worst_wavelength = max(violations, key=lambda item: item[0])
|
|
213
|
+
return ReciprocityReport(
|
|
214
|
+
max_violation=worst,
|
|
215
|
+
worst_order=worst_order,
|
|
216
|
+
worst_wavelength=worst_wavelength,
|
|
217
|
+
pairs_tested=len(violations),
|
|
218
|
+
tolerance=tolerance,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def check_energy_balance(
|
|
223
|
+
scan: EfficiencyScan,
|
|
224
|
+
*,
|
|
225
|
+
tolerance: float = 1e-6,
|
|
226
|
+
lossless: bool = False,
|
|
227
|
+
) -> EnergyReport:
|
|
228
|
+
"""Verify that summed efficiency is physically admissible.
|
|
229
|
+
|
|
230
|
+
Parameters
|
|
231
|
+
----------
|
|
232
|
+
lossless
|
|
233
|
+
Require ``sum == 1`` exactly (within tolerance). Use for a perfectly
|
|
234
|
+
conducting or otherwise lossless structure, where the sum rule is a
|
|
235
|
+
theorem. Default ``False`` only requires ``sum <= 1``, which holds for
|
|
236
|
+
**any** passive grating by **any** method -- a weaker claim, but one
|
|
237
|
+
that never has a legitimate exception.
|
|
238
|
+
|
|
239
|
+
Notes
|
|
240
|
+
-----
|
|
241
|
+
When the scan records an absorbed fraction (``scan.absorption``, from a
|
|
242
|
+
solver that computes one independently), the conserved quantity is the
|
|
243
|
+
sum *plus* the absorption and that is what gets checked: ``R + A = 1``
|
|
244
|
+
is a theorem for a one-interface absorbing grating, so ``lossless=True``
|
|
245
|
+
is again the right mode for the finite-conductivity integral method --
|
|
246
|
+
and it is a genuine two-sided check, because the absorption comes from a
|
|
247
|
+
boundary integral of the densities, not from ``1 - R``.
|
|
248
|
+
|
|
249
|
+
The default direction matters. A deficit is ordinary: power goes into
|
|
250
|
+
absorption, into evanescent orders, or is missed by an approximate method.
|
|
251
|
+
An *excess* is not, and this is the check that identified the unphysical
|
|
252
|
+
finite-conductivity run in the reference corpus, where summed efficiency
|
|
253
|
+
reached 3.6.
|
|
254
|
+
"""
|
|
255
|
+
total = np.asarray(scan.total, dtype=np.float64)
|
|
256
|
+
if scan.absorption is not None:
|
|
257
|
+
total = total + scan.absorption
|
|
258
|
+
return EnergyReport(
|
|
259
|
+
total=total,
|
|
260
|
+
wavelengths=scan.wavelengths,
|
|
261
|
+
tolerance=tolerance,
|
|
262
|
+
lossless=lossless,
|
|
263
|
+
)
|
gratinglab/compare.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Run one problem through several methods and line the answers up.
|
|
2
|
+
|
|
3
|
+
This is the point of the project. A comparison is only honest if every method
|
|
4
|
+
saw the same physics, so :func:`sweep` takes one :class:`~gratinglab.problem.Problem`
|
|
5
|
+
and one :class:`~gratinglab.illumination.Illumination` and hands them to each
|
|
6
|
+
backend unchanged.
|
|
7
|
+
|
|
8
|
+
Precomputed results are first-class participants. An imported PCGrate table is
|
|
9
|
+
passed in exactly like a live solver, so scalar-versus-integral-method plots are
|
|
10
|
+
available without a PCGrate licence.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any, Iterable, Sequence
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
from numpy.typing import ArrayLike, NDArray
|
|
20
|
+
|
|
21
|
+
from .illumination import Illumination
|
|
22
|
+
from .problem import Problem
|
|
23
|
+
from .result import EfficiencyScan
|
|
24
|
+
from .solvers.base import Progress, get_solver
|
|
25
|
+
|
|
26
|
+
__all__ = ["sweep", "align", "Comparison", "records"]
|
|
27
|
+
|
|
28
|
+
#: A method is either a registered solver name or an already-computed scan.
|
|
29
|
+
Method = str | EfficiencyScan
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def sweep(
|
|
33
|
+
problem: Problem,
|
|
34
|
+
illumination: Illumination,
|
|
35
|
+
wavelengths: ArrayLike,
|
|
36
|
+
methods: Sequence[Method],
|
|
37
|
+
options: dict[str, dict[str, Any]] | None = None,
|
|
38
|
+
*,
|
|
39
|
+
progress: "Progress | None" = None,
|
|
40
|
+
) -> list[EfficiencyScan]:
|
|
41
|
+
"""Solve ``problem`` with each method on a common wavelength grid.
|
|
42
|
+
|
|
43
|
+
Parameters
|
|
44
|
+
----------
|
|
45
|
+
methods
|
|
46
|
+
Registered solver names (``"scalar"``), or :class:`EfficiencyScan`
|
|
47
|
+
objects for precomputed data such as an imported PCGrate table. A
|
|
48
|
+
precomputed scan is **resampled onto** ``wavelengths`` -- see
|
|
49
|
+
:func:`align` for how, and for the caveat.
|
|
50
|
+
options
|
|
51
|
+
Per-method keyword arguments, keyed by method name, e.g.
|
|
52
|
+
``{"scalar": {"quadrature_points": 8192}}``.
|
|
53
|
+
progress
|
|
54
|
+
Called ``(done, total)`` over the **whole sweep**, not per method, so
|
|
55
|
+
a bar driven by it never restarts at a method boundary. Units are
|
|
56
|
+
wavelengths, and ``total`` is ``len(methods) * len(wavelengths)``.
|
|
57
|
+
|
|
58
|
+
Raising from it aborts the sweep -- see
|
|
59
|
+
:class:`~gratinglab.solvers.base.SolveCancelled`. A keyword of its own
|
|
60
|
+
rather than an entry in ``options``, so it can never collide with a
|
|
61
|
+
solver's own parameter name.
|
|
62
|
+
"""
|
|
63
|
+
wavelengths = np.atleast_1d(np.asarray(wavelengths, dtype=np.float64))
|
|
64
|
+
options = options or {}
|
|
65
|
+
per_method = len(wavelengths)
|
|
66
|
+
total = len(methods) * per_method
|
|
67
|
+
|
|
68
|
+
scans: list[EfficiencyScan] = []
|
|
69
|
+
for index, method in enumerate(methods):
|
|
70
|
+
finished = index * per_method
|
|
71
|
+
|
|
72
|
+
if isinstance(method, EfficiencyScan):
|
|
73
|
+
# Resampling is instant, so this block completes at once. Stepping
|
|
74
|
+
# the counter by the whole block is honest; pretending a
|
|
75
|
+
# precomputed table has wavelengths still to compute is not.
|
|
76
|
+
scans.append(_resample(method, wavelengths))
|
|
77
|
+
if progress is not None:
|
|
78
|
+
progress(finished + per_method, total)
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
solver = get_solver(method)
|
|
82
|
+
forwarded = _progress_option(solver, progress, finished, total)
|
|
83
|
+
scans.append(
|
|
84
|
+
solver.solve(
|
|
85
|
+
problem, illumination, wavelengths,
|
|
86
|
+
**forwarded, **options.get(method, {}),
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
if progress is not None and not forwarded:
|
|
90
|
+
# Only for a solver that did *not* report: this is what stops the
|
|
91
|
+
# bar stalling at a method boundary. One that did report already
|
|
92
|
+
# ended its block exactly here, and repeating the call would emit
|
|
93
|
+
# a duplicate for no reason.
|
|
94
|
+
progress(finished + per_method, total)
|
|
95
|
+
return scans
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _progress_option(
|
|
99
|
+
solver, progress: "Progress | None", finished: int, total: int
|
|
100
|
+
) -> dict[str, Any]:
|
|
101
|
+
"""``{"progress": ...}`` for a solver that declared it, else ``{}``.
|
|
102
|
+
|
|
103
|
+
A backend that has not been updated -- including a third-party one
|
|
104
|
+
arriving through the ``gratinglab.solvers`` entry point -- has an explicit
|
|
105
|
+
signature that would raise ``TypeError`` on an unexpected keyword, which
|
|
106
|
+
is why :attr:`~gratinglab.solvers.base.Capabilities.reports_progress` is
|
|
107
|
+
declared rather than assumed.
|
|
108
|
+
"""
|
|
109
|
+
if progress is None or not solver.capabilities.reports_progress:
|
|
110
|
+
return {}
|
|
111
|
+
return {"progress": lambda done, _per_method: progress(finished + done, total)}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _resample(scan: EfficiencyScan, wavelengths: NDArray[np.float64]) -> EfficiencyScan:
|
|
115
|
+
"""Put a precomputed scan on ``wavelengths`` by nearest-neighbour lookup.
|
|
116
|
+
|
|
117
|
+
Nearest-neighbour rather than interpolation, deliberately: efficiency curves
|
|
118
|
+
have sharp features at order passing-off, and interpolating across one
|
|
119
|
+
invents a value that no method produced. Points outside the source range
|
|
120
|
+
are marked non-propagating rather than extrapolated.
|
|
121
|
+
"""
|
|
122
|
+
source = scan.wavelengths
|
|
123
|
+
index = np.abs(wavelengths[:, None] - source[None, :]).argmin(axis=1)
|
|
124
|
+
inside = (wavelengths >= source.min()) & (wavelengths <= source.max())
|
|
125
|
+
|
|
126
|
+
efficiency = scan.efficiency[index]
|
|
127
|
+
propagating = scan.propagating[index]
|
|
128
|
+
efficiency = np.where(inside[:, None], efficiency, 0.0)
|
|
129
|
+
propagating = propagating & inside[:, None]
|
|
130
|
+
|
|
131
|
+
return EfficiencyScan(
|
|
132
|
+
wavelengths=wavelengths,
|
|
133
|
+
orders=scan.orders,
|
|
134
|
+
efficiency=efficiency,
|
|
135
|
+
propagating=propagating,
|
|
136
|
+
provenance=scan.provenance,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True, slots=True)
|
|
141
|
+
class Comparison:
|
|
142
|
+
"""Several methods on one wavelength grid and one order set."""
|
|
143
|
+
|
|
144
|
+
wavelengths: NDArray[np.float64]
|
|
145
|
+
orders: NDArray[np.int64]
|
|
146
|
+
methods: tuple[str, ...]
|
|
147
|
+
#: ``(n_methods, n_wavelengths, n_orders)``
|
|
148
|
+
efficiency: NDArray[np.float64]
|
|
149
|
+
scans: tuple[EfficiencyScan, ...]
|
|
150
|
+
|
|
151
|
+
def order(self, m: int) -> dict[str, NDArray[np.float64]]:
|
|
152
|
+
"""Efficiency in order ``m`` versus wavelength, per method."""
|
|
153
|
+
column = int(np.flatnonzero(self.orders == m)[0])
|
|
154
|
+
return {
|
|
155
|
+
name: self.efficiency[i, :, column]
|
|
156
|
+
for i, name in enumerate(self.methods)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
def difference(self, a: str, b: str) -> NDArray[np.float64]:
|
|
160
|
+
"""Signed ``a - b`` over the full grid."""
|
|
161
|
+
return self.efficiency[self.methods.index(a)] - self.efficiency[
|
|
162
|
+
self.methods.index(b)
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
def max_abs_difference(self, a: str, b: str) -> float:
|
|
166
|
+
return float(np.abs(self.difference(a, b)).max())
|
|
167
|
+
|
|
168
|
+
def summary(self, a: str, b: str) -> dict[str, float]:
|
|
169
|
+
"""Where and how much two methods disagree.
|
|
170
|
+
|
|
171
|
+
The headline number of the whole project: not "do they agree" but
|
|
172
|
+
"by how much, and where".
|
|
173
|
+
"""
|
|
174
|
+
delta = self.difference(a, b)
|
|
175
|
+
flat = int(np.abs(delta).argmax())
|
|
176
|
+
row, column = np.unravel_index(flat, delta.shape)
|
|
177
|
+
return {
|
|
178
|
+
"max_abs_difference": float(np.abs(delta).max()),
|
|
179
|
+
"rms_difference": float(np.sqrt(np.mean(delta**2))),
|
|
180
|
+
"at_wavelength": float(self.wavelengths[row]),
|
|
181
|
+
"at_order": int(self.orders[column]),
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def align(scans: Iterable[EfficiencyScan]) -> Comparison:
|
|
186
|
+
"""Put several scans on a shared order set.
|
|
187
|
+
|
|
188
|
+
All scans must already share a wavelength grid -- :func:`sweep` arranges
|
|
189
|
+
that. Orders are unioned, so a method that resolves fewer orders reports
|
|
190
|
+
zero for the rest rather than being silently truncated to the smallest
|
|
191
|
+
common set.
|
|
192
|
+
"""
|
|
193
|
+
scans = tuple(scans)
|
|
194
|
+
if not scans:
|
|
195
|
+
raise ValueError("nothing to compare")
|
|
196
|
+
|
|
197
|
+
reference = scans[0].wavelengths
|
|
198
|
+
for scan in scans[1:]:
|
|
199
|
+
if not np.allclose(scan.wavelengths, reference):
|
|
200
|
+
raise ValueError(
|
|
201
|
+
"scans are on different wavelength grids; use sweep() so every "
|
|
202
|
+
"method is evaluated on the same one"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
orders = np.unique(np.concatenate([scan.orders for scan in scans]))
|
|
206
|
+
stacked = np.zeros((len(scans), len(reference), len(orders)))
|
|
207
|
+
for i, scan in enumerate(scans):
|
|
208
|
+
column = np.searchsorted(orders, scan.orders)
|
|
209
|
+
stacked[i][:, column] = scan.efficiency
|
|
210
|
+
|
|
211
|
+
names = _unique_names(scans)
|
|
212
|
+
return Comparison(
|
|
213
|
+
wavelengths=reference,
|
|
214
|
+
orders=orders,
|
|
215
|
+
methods=names,
|
|
216
|
+
efficiency=stacked,
|
|
217
|
+
scans=scans,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _unique_names(scans: Sequence[EfficiencyScan]) -> tuple[str, ...]:
|
|
222
|
+
"""Method labels, disambiguated if two scans share a method name."""
|
|
223
|
+
names, seen = [], {}
|
|
224
|
+
for scan in scans:
|
|
225
|
+
base = scan.provenance.method
|
|
226
|
+
seen[base] = seen.get(base, 0) + 1
|
|
227
|
+
names.append(base if seen[base] == 1 else f"{base}#{seen[base]}")
|
|
228
|
+
return tuple(names)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def records(scans: Iterable[EfficiencyScan]) -> list[dict[str, Any]]:
|
|
232
|
+
"""Tidy long-form rows across several scans, ready for a DataFrame."""
|
|
233
|
+
return [row for scan in scans for row in scan.to_records()]
|