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,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
|