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,455 @@
1
+ r"""Demonstrating convergence, or refusing to claim it.
2
+
3
+ Every :class:`~gratinglab.result.Provenance` this project has ever produced
4
+ carries ``converged=None`` -- "not checked". That is honest but useless: it
5
+ means no number here has been shown correct, only computed. This module is
6
+ what turns that field into evidence.
7
+
8
+ The idea is the ordinary one. A solver declares its accuracy knob
9
+ (:attr:`~gratinglab.solvers.base.Capabilities.accuracy_knob` -- ``truncation``
10
+ for RCWA, ``boundary_points`` for the integral method, ``quadrature_points``
11
+ for scalar quadrature). Sweep it upward, watch the answer stop moving, and
12
+ report where it stopped.
13
+
14
+ Why a *plateau*, not the first pair that agrees
15
+ ===============================================
16
+
17
+ The tempting rule -- "converged once two consecutive refinements agree within
18
+ tolerance" -- is not safe, and this project has the measurement to prove it
19
+ rather than the principle. Scalar quadrature on the reference blazed grating,
20
+ maximum change in efficiency per doubling:
21
+
22
+ ====== ========== ===================
23
+ ``n`` change vs. previous change
24
+ ====== ========== ===================
25
+ 512 2.265e-04 0.44x
26
+ 1024 9.645e-06 **23x smaller**
27
+ 2048 2.426e-05 **2.5x LARGER**
28
+ 4096 4.835e-06 5.0x smaller
29
+ 8192 5.964e-07 8.1x smaller
30
+ ====== ========== ===================
31
+
32
+ At a tolerance of 1e-5 the naive rule stops at ``n=512`` on the strength of
33
+ that 9.6e-6 -- and the very next refinement then moves the answer by 2.4e-5,
34
+ more than the tolerance just certified. The cause is geometric, not numerical
35
+ noise: the blazed profile has a slope kink at :math:`t = 0.833`, and doubling
36
+ ``n`` changes how near a quadrature node falls to it, so the error is not
37
+ monotone in ``n``. Any method with a corner, a resonance, or an anomaly can do
38
+ this.
39
+
40
+ So agreement must persist. :data:`DEFAULT_PLATEAU` consecutive differences
41
+ must all fall below tolerance -- three knob values in a row that agree -- and
42
+ the accidental single agreement above is rejected by exactly one more solve.
43
+ Raise ``plateau`` for a method known to be worse behaved; there is no way to
44
+ lower it below 1, because a single agreement is the thing this exists to
45
+ distrust.
46
+
47
+ What is compared
48
+ ================
49
+
50
+ Absolute difference in efficiency, maximised over every wavelength and order.
51
+ Absolute rather than relative because efficiencies live in :math:`[0, 1]` and
52
+ a relative error on an order carrying 1e-12 of the power is noise being
53
+ promoted to a headline. The order grid and the propagating mask must match
54
+ between rungs; if they do not, this raises rather than comparing misaligned
55
+ arrays, which is the failure mode the whole project is arranged against.
56
+
57
+ What it does *not* do
58
+ =====================
59
+
60
+ Prove correctness. A converged wrong model is still wrong -- scalar theory
61
+ converges beautifully to an answer that neglects polarization entirely. This
62
+ measures numerical self-consistency, and :mod:`gratinglab.checks` measures the
63
+ physics. They are different questions and both are needed.
64
+ """
65
+
66
+ from __future__ import annotations
67
+
68
+ import dataclasses
69
+ import inspect
70
+ from dataclasses import dataclass
71
+
72
+ import numpy as np
73
+ from numpy.typing import ArrayLike
74
+
75
+ from .illumination import Illumination
76
+ from .problem import Problem
77
+ from .result import EfficiencyScan
78
+ from .solvers.base import Progress, UnsupportedConfiguration
79
+
80
+ __all__ = [
81
+ "ConvergenceReport",
82
+ "DEFAULT_PLATEAU",
83
+ "DEFAULT_TOLERANCE",
84
+ "check_convergence",
85
+ "doubling_ladder",
86
+ ]
87
+
88
+ #: Consecutive agreements required. Two differences below tolerance means
89
+ #: three knob values in a row agree. See the module docstring for the measured
90
+ #: case that makes one insufficient.
91
+ DEFAULT_PLATEAU = 2
92
+
93
+ #: Comfortably below any experimental precision this would be compared against,
94
+ #: and reachable: the reference blazed case gets there by ``n = 4096``.
95
+ DEFAULT_TOLERANCE = 1e-6
96
+
97
+ #: How many rungs below the solver's own default :func:`doubling_ladder` starts,
98
+ #: when no explicit ladder is given. Starting *below* the default is the point
99
+ #: -- the actionable output is how coarse a setting would have done, which for
100
+ #: RCWA at N ~ 200-400 is the difference between a run and an afternoon.
101
+ _RUNGS_BELOW_DEFAULT = 3
102
+
103
+ #: Total rungs in the default ladder. Generous because the sweep stops as soon
104
+ #: as it finds a plateau, so a well-behaved case never pays for the tail.
105
+ _DEFAULT_RUNGS = 10
106
+
107
+
108
+ @dataclass(frozen=True, slots=True)
109
+ class ConvergenceReport:
110
+ """What a sweep found, including when it found nothing.
111
+
112
+ Attributes
113
+ ----------
114
+ knob
115
+ Name of the swept keyword, from the solver's declared capabilities.
116
+ values
117
+ Knob values actually solved, ascending.
118
+ differences
119
+ ``differences[i]`` is the largest change in efficiency between
120
+ ``values[i]`` and ``values[i + 1]``. One shorter than ``values``.
121
+ tolerance, plateau
122
+ The criterion applied.
123
+ converged_at
124
+ Coarsest knob value demonstrated adequate, or ``None`` if the sweep
125
+ ended without a plateau. **This is the actionable number**: it is what
126
+ a production run should use, not the finest value tried.
127
+ scan
128
+ The finest scan computed, with its provenance stamped -- ``converged``
129
+ set to a real boolean at last, and ``notes["convergence"]`` carrying
130
+ this evidence so a result can defend itself away from this object.
131
+ skipped
132
+ ``(value, reason)`` for rungs the solver refused, e.g. a quadrature
133
+ too coarse to resolve the highest order. Recorded, not silently
134
+ dropped: a ladder that mostly did not run is a different situation
135
+ from one that ran and disagreed.
136
+ """
137
+
138
+ knob: str
139
+ values: tuple[int, ...]
140
+ differences: tuple[float, ...]
141
+ tolerance: float
142
+ plateau: int
143
+ converged_at: int | None
144
+ scan: EfficiencyScan
145
+ skipped: tuple[tuple[int, str], ...] = ()
146
+
147
+ @property
148
+ def passed(self) -> bool:
149
+ """True only if a plateau was actually found."""
150
+ return self.converged_at is not None
151
+
152
+ @property
153
+ def final_difference(self) -> float | None:
154
+ """The last change measured -- the tightest bound the sweep supports."""
155
+ return self.differences[-1] if self.differences else None
156
+
157
+ def as_notes(self) -> dict[str, object]:
158
+ """The evidence, in a form that survives in ``Provenance.notes``."""
159
+ return {
160
+ "knob": self.knob,
161
+ "values": list(self.values),
162
+ "differences": [float(d) for d in self.differences],
163
+ "tolerance": self.tolerance,
164
+ "plateau": self.plateau,
165
+ "converged_at": self.converged_at,
166
+ }
167
+
168
+ def __str__(self) -> str: # pragma: no cover - cosmetic
169
+ if self.passed:
170
+ return (
171
+ f"convergence: demonstrated at {self.knob}={self.converged_at} "
172
+ f"({self.plateau} consecutive changes below {self.tolerance:g}; "
173
+ f"swept to {self.values[-1]})"
174
+ )
175
+ last = self.final_difference
176
+ detail = f"best change {last:.3g}" if last is not None else "nothing to compare"
177
+ return (
178
+ f"convergence: NOT demonstrated -- swept {self.knob} to "
179
+ f"{self.values[-1] if self.values else '?'}, {detail}, "
180
+ f"tolerance {self.tolerance:g}"
181
+ )
182
+
183
+
184
+ def doubling_ladder(start: int, rungs: int) -> tuple[int, ...]:
185
+ """``start``, ``2*start``, ``4*start``, ... -- ``rungs`` values.
186
+
187
+ Doubling because discretisation error typically falls geometrically, so a
188
+ linear ladder spends most of its solves learning nothing while a geometric
189
+ one brackets the plateau in a handful.
190
+ """
191
+ if start < 1:
192
+ raise ValueError(f"start must be positive, got {start}")
193
+ if rungs < 2:
194
+ raise ValueError(f"a sweep needs at least two rungs to compare, got {rungs}")
195
+ return tuple(start * 2**i for i in range(rungs))
196
+
197
+
198
+ def default_ladder(solver) -> tuple[int, ...]:
199
+ """A ladder straddling the solver's own default for its accuracy knob.
200
+
201
+ Read from the signature rather than hardcoded, so a backend that changes
202
+ its default does not silently leave this sweeping the wrong range.
203
+ """
204
+ knob = _knob_of(solver)
205
+ default = inspect.signature(solver.solve).parameters[knob].default
206
+ if not isinstance(default, int) or default < 1:
207
+ raise ValueError(
208
+ f"{solver.capabilities.name}.solve has no usable default for "
209
+ f"{knob!r} ({default!r}), so a ladder cannot be inferred -- pass "
210
+ "`values` explicitly"
211
+ )
212
+ return doubling_ladder(max(1, default // 2**_RUNGS_BELOW_DEFAULT), _DEFAULT_RUNGS)
213
+
214
+
215
+ def check_convergence(
216
+ solver,
217
+ problem: Problem,
218
+ illumination: Illumination,
219
+ wavelengths: ArrayLike,
220
+ *,
221
+ values: "tuple[int, ...] | None" = None,
222
+ tolerance: float = DEFAULT_TOLERANCE,
223
+ plateau: int = DEFAULT_PLATEAU,
224
+ progress: "Progress | None" = None,
225
+ **options,
226
+ ) -> ConvergenceReport:
227
+ """Sweep a solver's accuracy knob until the answer stops moving.
228
+
229
+ Stops at the first plateau, so a well-behaved case costs only the rungs it
230
+ needed. A case that never plateaus runs the whole ladder and says so --
231
+ :attr:`ConvergenceReport.passed` is False and
232
+ ``scan.provenance.converged`` is ``False``, which is a stronger and more
233
+ useful statement than the ``None`` it started as.
234
+
235
+ Parameters
236
+ ----------
237
+ values
238
+ Explicit ladder, ascending. Defaults to :func:`default_ladder`.
239
+ tolerance
240
+ Largest efficiency change treated as "stopped moving".
241
+ plateau
242
+ Consecutive differences that must fall below ``tolerance``. See the
243
+ module docstring; below 1 is refused.
244
+ progress
245
+ Called ``(rungs_done, len(ladder))``.
246
+
247
+ Two granularities, deliberately different. The callback is **invoked
248
+ once per wavelength**, so a
249
+ :class:`~gratinglab.solvers.base.SolveCancelled` raised from it lands
250
+ within milliseconds rather than at the end of a rung -- and a rung is
251
+ the expensive unit here, up to a quarter of the whole sweep. But the
252
+ numbers it *reports* are per rung, because they are the only ones that
253
+ are both monotone and meaningful: a per-wavelength counter would
254
+ restart on every rung and drive a bar backwards, and composing one
255
+ across rungs would misrepresent progress anyway, since rungs double in
256
+ cost and the last is half the total work.
257
+
258
+ So a bar sits still within a rung and steps once per rung. That is the
259
+ honest shape of this computation.
260
+
261
+ Unlike the :class:`~gratinglab.solvers.base.Solver` contract, this
262
+ does **not** end at ``(total, total)``: the sweep stops at the first
263
+ plateau, so a well-behaved case genuinely never runs the tail of the
264
+ ladder. Finishing early is the good outcome, and running the counter
265
+ up to the end to make a bar look tidy would claim work that was never
266
+ done. A caller learns the sweep is over by it returning.
267
+ **options
268
+ Passed to every solve, so a sweep runs at the same settings as the
269
+ production run it is certifying. The knob itself may not appear here.
270
+
271
+ Raises
272
+ ------
273
+ SolveCancelled
274
+ Propagated untouched from ``progress``. **No partial report is
275
+ returned**: a sweep that was stopped has not demonstrated anything,
276
+ and handing back a report that implied otherwise would be the mistake
277
+ this module exists to prevent.
278
+ """
279
+ knob = _knob_of(solver)
280
+ if plateau < 1:
281
+ raise ValueError(
282
+ f"plateau must be at least 1, got {plateau}; a sweep that requires "
283
+ "no agreement at all demonstrates nothing"
284
+ )
285
+ if knob in options:
286
+ raise ValueError(
287
+ f"{knob!r} is the knob being swept and cannot also be pinned in "
288
+ "options -- pass `values` to control the ladder"
289
+ )
290
+
291
+ ladder = tuple(values) if values is not None else default_ladder(solver)
292
+ if len(ladder) < 2:
293
+ raise ValueError("a sweep needs at least two knob values to compare")
294
+ if list(ladder) != sorted(set(ladder)):
295
+ raise ValueError(f"values must be ascending and distinct, got {ladder}")
296
+
297
+ solved: list[int] = []
298
+ scans: list[EfficiencyScan] = []
299
+ differences: list[float] = []
300
+ skipped: list[tuple[int, str]] = []
301
+ converged_at: int | None = None
302
+
303
+ for rung, value in enumerate(ladder):
304
+ forwarded = _rung_progress(solver, progress, rung, len(ladder))
305
+ try:
306
+ scan = solver.solve(problem, illumination, wavelengths, **{knob: value},
307
+ **forwarded, **options)
308
+ except (ValueError, UnsupportedConfiguration) as exc:
309
+ # Narrow on purpose. A coarse rung below a solver's own floor
310
+ # raises ValueError (scalar's Nyquist guard) or refuses outright;
311
+ # a TypeError or AttributeError is a bug in the call and must not
312
+ # be quietly logged as a skipped rung.
313
+ if solved:
314
+ # A refusal *after* a success is not a coarse-grid floor; the
315
+ # ladder walked into something real and hiding it would leave
316
+ # a sweep that looks complete and is not.
317
+ raise
318
+ skipped.append((value, str(exc)))
319
+ continue
320
+
321
+ if scans:
322
+ _require_same_grid(scans[-1], scan, solved[-1], value, knob)
323
+ differences.append(_largest_change(scans[-1], scan))
324
+ solved.append(value)
325
+ scans.append(scan)
326
+
327
+ if len(differences) >= plateau and all(
328
+ d <= tolerance for d in differences[-plateau:]
329
+ ):
330
+ # The coarsest value whose successors all agree -- what a
331
+ # production run should use, and cheaper than what we just solved.
332
+ converged_at = solved[-(plateau + 1)]
333
+ break
334
+
335
+ if not scans:
336
+ raise RuntimeError(
337
+ f"no rung of the ladder {ladder} could be solved; refusals: "
338
+ + "; ".join(f"{v}: {why}" for v, why in skipped)
339
+ )
340
+ if len(scans) < 2:
341
+ raise RuntimeError(
342
+ f"only one rung of the ladder {ladder} could be solved "
343
+ f"({solved[0]}), so nothing could be compared"
344
+ )
345
+
346
+ report = ConvergenceReport(
347
+ knob=knob,
348
+ values=tuple(solved),
349
+ differences=tuple(differences),
350
+ tolerance=tolerance,
351
+ plateau=plateau,
352
+ converged_at=converged_at,
353
+ scan=scans[-1],
354
+ skipped=tuple(skipped),
355
+ )
356
+ return dataclasses.replace(report, scan=_stamp(scans[-1], report))
357
+
358
+
359
+ def _rung_progress(solver, progress: "Progress | None", rung: int, rungs: int) -> dict:
360
+ """``{"progress": ...}`` reporting rungs while being called per wavelength.
361
+
362
+ The wavelength counter is discarded on purpose -- see
363
+ :func:`check_convergence`. What it buys is the call *frequency*: every
364
+ wavelength is a chance for the caller to raise, so cancelling a sweep does
365
+ not have to wait out the rung it is in.
366
+ """
367
+ if progress is None or not solver.capabilities.reports_progress:
368
+ return {}
369
+ return {"progress": lambda _done, _total: progress(rung, rungs)}
370
+
371
+
372
+ def _knob_of(solver) -> str:
373
+ knob = solver.capabilities.accuracy_knob
374
+ if knob is None:
375
+ raise ValueError(
376
+ f"{solver.capabilities.name} declares no accuracy_knob, so there is "
377
+ "nothing to sweep. A closed-form method may well be exact, but that "
378
+ "claim belongs to whoever can defend it, not to a sweep of a "
379
+ "parameter that does not exist."
380
+ )
381
+ return knob
382
+
383
+
384
+ def _require_same_grid(
385
+ before: EfficiencyScan, after: EfficiencyScan, coarse: int, fine: int, knob: str
386
+ ) -> None:
387
+ """Refuse to compare scans that are not about the same orders.
388
+
389
+ The knob controls accuracy, not geometry, so this should never fire -- the
390
+ order grid comes from the grating equation. It exists because a silent
391
+ broadcast between differently-shaped arrays would produce a number that
392
+ looks like a convergence measure and is not.
393
+ """
394
+ if not np.array_equal(before.orders, after.orders):
395
+ raise ValueError(
396
+ f"{knob}={coarse} and {knob}={fine} returned different order grids "
397
+ f"({before.orders} vs {after.orders}); the accuracy knob must not "
398
+ "change which orders exist"
399
+ )
400
+ if not np.array_equal(before.wavelengths, after.wavelengths):
401
+ raise ValueError(
402
+ f"{knob}={coarse} and {knob}={fine} returned different wavelength "
403
+ "grids"
404
+ )
405
+ if not np.array_equal(before.propagating, after.propagating):
406
+ raise ValueError(
407
+ f"{knob}={coarse} and {knob}={fine} disagree about which orders "
408
+ "propagate, which is geometry and cannot depend on accuracy"
409
+ )
410
+
411
+
412
+ def _largest_change(before: EfficiencyScan, after: EfficiencyScan) -> float:
413
+ """Largest absolute efficiency change, over every wavelength and order."""
414
+ return float(np.abs(np.asarray(after.efficiency) - np.asarray(before.efficiency)).max())
415
+
416
+
417
+ def _stamp(scan: EfficiencyScan, report: ConvergenceReport) -> EfficiencyScan:
418
+ """A copy of ``scan`` whose provenance carries the verdict and its evidence.
419
+
420
+ ``converged`` becomes a real boolean either way. False is a result, not a
421
+ failure to produce one: it says the sweep ran and the answer was still
422
+ moving, which is exactly what a reader needs in order not to quote the
423
+ number.
424
+ """
425
+ notes = dict(scan.provenance.notes)
426
+ notes["convergence"] = report.as_notes()
427
+ provenance = dataclasses.replace(
428
+ scan.provenance,
429
+ converged=report.passed,
430
+ truncation=report.values[-1],
431
+ notes=notes,
432
+ )
433
+ if not report.passed:
434
+ provenance = provenance.with_warning(
435
+ f"convergence not demonstrated: {report.knob} swept to "
436
+ f"{report.values[-1]} and the largest change was still "
437
+ f"{report.final_difference:.3g}, above the {report.tolerance:g} "
438
+ "tolerance. Treat this efficiency as indicative, not defensible."
439
+ )
440
+ return dataclasses.replace(scan, provenance=provenance)
441
+
442
+
443
+ def converged_scan(
444
+ solver,
445
+ problem: Problem,
446
+ illumination: Illumination,
447
+ wavelengths: ArrayLike,
448
+ **kwargs,
449
+ ) -> EfficiencyScan:
450
+ """:func:`check_convergence`, keeping only the stamped scan.
451
+
452
+ For callers that want a defensible result rather than the study behind it.
453
+ The study is still on ``scan.provenance.notes["convergence"]``.
454
+ """
455
+ return check_convergence(solver, problem, illumination, wavelengths, **kwargs).scan