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
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()]