-
Notifications
You must be signed in to change notification settings - Fork 0
findings
Empirical results established while building this, each with the evidence that established it. Recorded so they are not rediscovered, and so a claim can be checked rather than taken on trust.
Findings that constrain how code must behave are also encoded as tests; those are cross-referenced.
Refining a blazed-grating quadrature can make the answer worse, and a convergence rule that trusts a single agreement will certify a tolerance the next refinement violates.
Scalar solver, reference geometry (period 315.15 nm, δ = 29.5°, anti 70.5°,
α = 25°, γ = 1.5°), λ ∈ [1, 5] nm. Largest change in efficiency per doubling of
quadrature_points:
| n | change vs n/2 | ratio to previous change |
|---|---|---|
| 256 | 5.613e-04 | 22.8× smaller |
| 512 | 2.265e-04 | 2.5× smaller |
| 1024 | 9.645e-06 | 23.5× smaller |
| 2048 | 2.426e-05 | 2.5× LARGER |
| 4096 | 4.835e-06 | 5.0× smaller |
| 8192 | 5.964e-07 | 8.1× smaller |
| 16384 | 1.567e-07 | 3.8× smaller |
| 32768 | 3.527e-08 | 4.4× smaller |
Past ~8192 the ratio settles near 4, i.e. second-order convergence — the expected rate for a rule integrating a function with a slope discontinuity. Below that it swings between 0.4 and 23.
The cause is geometric, not numerical noise. The blazed profile has a kink
at t = 1/(1 + tan δ / tan δ') = 0.8331. That is not a dyadic rational, so
doubling n changes how near the closest node falls to the kink, and the local
error at the kink dominates. A sinusoid, being analytic, shows none of this: it
is at machine precision (~1.7e-16) from n = 64 upward and never moves again.
A lamellar profile — a jump, not a kink — converges cleanly at exactly 4× per
doubling with no excursions.
Consequence, encoded in code. gratinglab.convergence requires a plateau:
DEFAULT_PLATEAU = 2 consecutive differences below tolerance, so three knob
values in a row must agree. At a tolerance of 1e-5 the naive one-agreement rule
stops at n = 512 on the strength of that 9.6e-6, and the next refinement then
moves the answer by 2.4e-5. One extra solve rejects it.
This is why the harness reports the whole ladder rather than a boolean, and why
converged_at is the coarsest value of the plateau rather than the first value
that happened to agree.
Pinned by tests/test_convergence.py::TestTheMeasurementBehindTheRule, which
fails if refinement ever becomes monotone here — at which point this entry and
the plateau argument would both need revisiting — and by
TestThePlateauRule::test_and_plateau_1_would_have_stopped_at_the_dip, which
shows the naive rule failing on a scripted sequence with the same shape.
CXRO_to_n_k in panter1.py hardcodes skip_header=3 against a file with
two header lines, so it silently discards the first data row.
[0] ' Au Density=19.32'
[1] ' Energy(eV), Delta, Beta'
[2] ' 200. 0.0112813823 0.00951793324' <- dropped
[3] ' 200.925018 0.0111386664 0.00957826339'
The lost row is the lowest energy, which is the longest wavelength — the
end of the range a grazing-incidence soft X-ray scan is most likely to want.
For the Au table it truncates 6.199 nm to 6.171 nm, a 0.029 nm shortfall, and
the .ari files in the corpus carry it because that code wrote them.
Nothing downstream ever noticed, for the same reason nothing noticed t̂ = −d̂:
no consumer looked at the endpoint. PCGrate was handed the table and
interpolated inside it.
Consequence. materials.optical.read_cxro detects the header instead of
counting it — a line is data when its first three whitespace-separated fields
all parse as floats. That is robust to the two- and three-header-line variants
both, and to an export that grows a line, and it cannot drop a row for being at
an end of the table, which is exactly where the range guard is most sensitive.
Pinned by tests/test_materials.py::TestTheCxroReader::test_the_longest_wavelength_row_survives,
with a companion measuring the size of the truncation so "a real amount, not
rounding" is a number rather than a claim.
The general lesson, and the reason this is written down: porting faithfully is not the same as porting correctly. The plan for this milestone called the reader "a port, not new code", which was right about the effort and wrong about the care — a port inherits the bugs unless someone checks the input against the assumption. The check took one command.
The exported efficiency tables record efficiencies but not the geometry that
produced them, and the vendor project files are binary. strings on them
yields only a build stamp and a path to an optical-constants file.
The geometry is nonetheless recoverable, exactly. Which orders propagate is fixed by the grating equation, and the constraint factors:
Validation. For the TASTE run the period is independently known — 315.15 nm, hard-coded in the group's own plotting script. Feeding that in gives
Both clean round numbers, which a mis-derived method would not produce.
A test asserts that our own grating equation, driven by the recovered geometry, regenerates the exact propagating-order set of the reference file at every sampled wavelength. It does.
Results live in benchmarks/corpus.toml. The
panter1 period remains unresolved — see roadmap.
Sign trap. The first pass assigned
$A$ and$B$ the other way round and got α = −19.99°. It looked plausible. What caught it was running the scalar solver at both signs: mean summed efficiency was 0.23 at −19.99° versus 0.55 at +19.99°. A sign error in a recovered quantity does not announce itself.
panter1_fix_efftable_finite.txt — a finite-conductivity run — has summed
efficiency reaching 3.6. Verified against the raw file, so it is a defect in
the run, not in the parser. The companion perfect-conductivity run of the same
problem conserves energy to four decimal places, so the setup is sound; it is
the finite-conductivity solve that destabilises.
79 of 561 points (14 %) violate, in five clusters:
| λ range (nm) | points | orders | peak Σℰ |
|---|---|---|---|
| 0.60–0.89 | 30 | 23–33 | 2.94 |
| 0.97–1.03 | 7 | 19–20 | 1.23 |
| 1.11–1.12 | 2 | 18 | 3.60 |
| 1.44–1.49 | 6 | 14 | 2.03 |
| 4.16–4.49 | 34 | 5 | 3.56 |
The cause is not propagating-order count. The last cluster carries only five orders. What predicts the violations is order passing-off (Rayleigh anomalies): violating points sit a median 0.020 nm from a wavelength where the propagating-order count changes, against 0.125 nm for clean points, and that last cluster terminates exactly at the 5→4 passing-off wavelength. The gold optical constants are smooth across all five bands, so no material edge is involved.
Do not use this file as reference data at those wavelengths. Encoded as
test_known_defect_finite_conductivity_run_is_unphysical; if the run is ever
regenerated correctly that test fails and should be deleted.
No scalar formulation satisfies all three of the properties below. This is
structural, not incidental: reciprocity requires the phase to be symmetric under
| Property | order-dependent |
|
|---|---|---|
| Energy, |
✗ up to 1.61 | ✓ 1.0000 exactly (Parseval) |
| Reciprocity |
✓ |
✗ 0.44 violation |
| Blaze direction | ✓ exact at all |
✓ at Littrow only; 7.6° off at 10°, evanescent past ~15° |
Physical optics is reciprocal but not energy-conserving; the pure transmittance picture is the reverse. Reciprocity is what the solver keeps. The energy-conserving variant is not offered as an option, because silently trading a well-understood energy defect for a reciprocity violation is a footgun.
Referencing the phase to the blaze direction
In the shallow-groove limit the sum is exactly unity, degrading smoothly with depth:
| depth / period | |
|---|---|
| 0.0001 | 1.00000 |
| 0.10 | 0.92902 |
The deviation scales with phase excursion across the groove — depth relative
to wavelength, hence working order — not with
Both numbers were remeasured in M16-C, after the symmetric flux obliquity landed. It is exactly 1 at
$m=0$ , where all the power sits in the shallow limit, so the top row is unchanged — which is the point of the check.
The thesis carries
The conclusion originally drawn here went one step too far. This section used to continue: "for
$N \to \infty$ grooves the efficiency is the norm-squared Fourier coefficient and nothing else". That is wrong by up to 64% away from Littrow, and the symmetric obliquity$4\cos\alpha\cos\beta_m/(\cos\alpha+\cos\beta_m)^2$ is what repairs it without touching reciprocity. See "The scalar solver was never checked against a theory it did not share", below. ISSI §2.1's groove function$f(x) = \exp{ik[n(k)-1]y(x/p)}$ carries no flux factor either, and is the one place this project's normative reference is the one in error.
The renormalisation has no such rehabilitation — forcing
Full derivation: theory/scalar.md §5. The deviation is reported as a provenance warning and never rescaled.
Deliberately corrupting the physics and checking whether the suite noticed. First sweep: seven mutations, six caught, one survivor.
Inverting
That is exactly the thesis-versus-ISSI discrepancy recorded in
conventions.md §5 — a place where being wrong was plausible rather than
hypothetical. It was pinned to the exact ratio, with a second test proving the
two ratios are distinguishable for the geometry used, so the assertion could not
go vacuous.
Since superseded. The obliquity option was removed entirely once the factor was established to be an error rather than a convention — see Scalar theory cannot be reciprocal and conserve energy above. The lesson stands and is the reason that section could be written with confidence: a test that only asserts something changed verifies nothing.
Breaking
Coverage at the time was 9 off-plane tests against 13 in-plane. Every closed-form check now runs in both mounts.
After both fixes, all 12 mutations are caught.
The hand-rolled sweep was seven mutations chosen by someone who knew where to
look. mutmut generated 19 for geometry.beta alone, and four survived.
Two were real:
-
np.pi - b→np.pi + bfor transmitted orders.beta(..., transmitted=True)had no test at all — onlycos_betadid. The mutant negates (\sin\beta) while leaving (\cos\beta) negative, which is precisely the thingconventions.md§4 forbids ("we do not flip the sign of (\sin\beta_m) for transmission"). Now pinned by asserting both halves of that sentence in one test. -
|s| <= 1.0→|s| < 1.0. At exactly (|\sin\beta_m| = 1) — the passing-off boundary — the mutant returns NaN.is_propagatinguses<=, so an order it counts as propagating would have had no direction to be drawn or summed at. The two functions now have to agree at the boundary, with a companion showing the boundary is a boundary.
The other two are equivalent mutants, verified rather than assumed:
-
<= 1.0→<= 2.0is unobservable, becausearcsinof anything outside ([-1, 1]) is NaN anyway anderrstate(invalid="ignore")suppresses the warning either way. The guard is doing less than it looks. - Dropping
dtype=np.float64fromnp.asarraychanges nothing NumPy does not already do by promotion.
No test can kill either, and chasing them would mean writing assertions about nothing. Recorded here so the next reader does not try.
The lesson, sharpened. The earlier sweep's conclusion was "a test that only
asserts something changed verifies nothing". This one adds: a branch with no
test at all is invisible to code coverage when another function exercises the
same line. cos_beta(0.5, transmitted=True) covered the concept; nothing
covered beta's version of it.
796 mutants across the physics core, 763 killed:
| Module | Mutants | Survivors |
|---|---|---|
solvers/scalar.py |
228 | 3 |
convergence.py |
182 | 1 |
compare.py |
136 | 7 |
geometry.py |
128 | 2 (both equivalent) |
checks.py |
96 | 18 |
solvers/base.py |
18 | 2 |
result.py |
8 | 0 |
Every survivor in checks.py is in check_reciprocity — 18 of the 33
total, from a function that is 12% of the mutated surface. Its own score is
81%, against 98–100% everywhere else.
That is worth stating plainly, because this project calls reciprocity "the sharpest available check" and leans on it hard: it is the reason M9 could choose reciprocity over energy conservation with confidence, and it is the check that constrains the model rather than the arithmetic. The check that validates everything else is the least validated thing here.
Three kinds of gap, from reading the diffs:
-
The order-selection strategy is unverified.
np.argsort(strength)→np.argsort(None)survives. The function deliberately tests the strongest orders; nothing asserts that it does, so the selection could be arbitrary and every test would still pass — reciprocity holds for whichever orders get picked. -
Boundaries again, the same shape as the
betafinding:>→>=on both the order cap and the grazing margin. -
The no-pairs branch returns a report nobody checks.
max_violation=0.0→1.0survives, so the early return for "nothing could be tested" is untested. A report claiming a measurement it never made is exactly whatProvenanceexists to prevent elsewhere.
Closed. All 18 are killed and checks.py is now at 96/96. What it took is
the part worth recording: the strategy could not be tested against the real
solver at all, because the selection happens inside check_reciprocity and
leaves no trace on the report. It needed a recording stand-in — a solver
reciprocal by construction (efficiency depends on the order index, never on
the incidence azimuth), whose per-order strength the test dictates, and which
remembers every illumination it was asked about. The orders it chose are then
recovered by inverting the reversed azimuth back through sin_beta.
That also reached a gap no scalar test could. The reversed illumination must
carry the original polarization; dropping it leaves the reverse solve on
Illumination's default of TE, so the check compares two different physical
problems and blames the solver. Scalar neglects polarization, so that mutant
survives against it and always would — catching it needs a backend that
resolves polarization, which a stand-in can be made to do. Precisely the class
of bug the first contributed RCWA will meet.
Two mistakes made while writing these, both worth the warning:
- The first stand-in returned a fixed order window rather than the geometry's
real propagating set.
check_reciprocityre-derives that set independently and skips anything that disagrees, so every dictated order was silently dropped andpairs_testedcame back 0. - The first grazing test assumed a near-grazing order existed at a convenient
geometry. None did — the margin is half a degree, so the geometry has to be
solved for.
sin(beta_1) = sin(89.7°)givesalpha = -34.8489°, which puts order +1 inside the margin with four others clear of it. Without that, "the skip happens" passes for free.
Lorentz reciprocity requires
| max violation | |
|---|---|
| as implemented | 5 × 10⁻¹⁸ |
| with the α↔β symmetry of Φ broken | 4 × 10⁻¹ |
Sixteen orders of magnitude, requiring no reference data and no closed form.
This matters because the closed-form tests cannot do it. They compare the solver against a formula derived the same way the solver computes it, so they validate the quadrature. Reciprocity constrains the structure of the phase function.
But it is blind to normalisation, and that blindness cost something — see
the next two findings. Any factor symmetric in
Every analytic check in the suite until M16 — sawtooth sinc², the binary
grating, the sinusoid's J_m² — descends from the same transmittance-function
picture the solver evaluates. They are strong tests of the quadrature and
structurally incapable of testing the model. Reciprocity constrains the phase
but not the scale (above). Nothing pinned the absolute normalisation.
First-order Rayleigh perturbation theory does. It comes from expanding the field
in plane waves and imposing the boundary condition on the corrugated surface —
no Kirchhoff assumption anywhere. Kirchhoff needs gentle slopes, perturbation
theory needs shallow grooves, and a grating that is both must be described
correctly by both. In that overlap,
| Mount | scalar / exact | |||
|---|---|---|---|---|
| in-plane, α=10° | 0.80 | +1 | 38.8° | 1.0137 |
| in-plane, α=10° | 0.80 | −1 | −76.8° | 1.6375 |
| off-plane, α=20°, γ=1.25° | 0.0127 | +1 | 13.9° | 1.0003 |
| off-plane, α=20°, γ=1.25° | 0.0127 | −1 | −67.5° | 1.2156 |
The discrepancy is exactly
The repair, geometry.flux_obliquity, is symmetric, so reciprocity survives;
Encoded as tests/test_perturbation.py. Two
traps found while writing it, both worth remembering:
-
np.allclosehasatol=1e-8. A shallow grating's first-order efficiency is ~1e-9, so the default tolerance passed the comparison against anything, including zero. The first draft of the file passed cleanly against the bug it was written to find.atol=0is load-bearing. -
A near-Littrow mount tests nothing. The first draft also used α=10° at
λ/p=0.36 and α=25° at λ/p=0.01, where the factor is 0.2% from unity.
test_the_mounts_actually_discriminatenow asserts each mount reaches an order where the two theories are ≥15% apart.
check_reciprocity was only ever pointed at bare problems. Pointed at a coated
one, on master before M16:
| max violation | |
|---|---|
| no coating, Blazed / Sinusoidal | 1 × 10⁻¹⁶ / 2 × 10⁻¹⁷ |
coating="Au", Blazed |
1.6 × 10⁻³ |
coating="Au", Sinusoidal |
3.5 × 10⁻² |
The cause is plain once looked at: M15-D evaluated
The fix is not to drop the reflectivity but to make it symmetric — weighting
each facet by both the angle it receives at and the angle it emits at,
test_the_facet_model_breaks_reciprocity_which_is_why_it_is_not_default,
because "the old model is kept for reproducibility" has to include reproducing
what was wrong with it.
docs/theory/scalar.md justified a single per-wavelength
On the project's own reference geometry — Blazed 29.5°/70.5°, γ=1.25°,
α=19.99°, Au — the anti-blaze facet is 16.68% of the period and its local
graze is negative: it faces away from the beam entirely. The M15 model applied
the active facet's
Three details that had to be right:
-
The sign of the facet tilt.
$\tan\delta = +dy/dt$ . The opposite sign gives 0.8119° where the right one gives 1.2328°, and both look like angles. The check that settles it is that an ideal sawtooth must return$\delta$ equal to its own blaze angle, reproducingfacet_grazeexactly. See also "The profile parameter runs backwards", which is what makes the wrong sign tempting. -
Two square roots, not one.
$\sqrt{r_i r_d}$ takes a principal branch of the product and adds a discontinuity of its own; on Au at 4 nm$\arg r_s$ reaches −3.124, within 0.017 of the cut.$\sqrt{r_i}\sqrt{r_d}$ leaves only the discontinuities$r$ actually has. -
Brewster is the one that remains. Where
$r_p$ passes through zero its phase jumps by π and the geometric mean cannot carry it. Reachable only with steep grooves near normal incidence; reported as a provenance warning rather than silently returned.
What it costs. The visibility mask puts a jump in the integrand at the
shadow boundary, so convergence drops from theory/scalar.md §8.
Still unvalidated against a rigorous method — and the corpus cannot fix that; see below.
Run against OGRE/tastetest_perf_wavescan.txt (TASTE, period 315.15 nm,
γ=1.25°, α=19.99°), comparing relative scalar against PCGrate:
| Profile | Σ, M16 | Σ, pre-M16 | per-order RMS, M16 / pre-M16 |
|---|---|---|---|
| Blazed 29.5°/70.5° | 0.5431 | 0.5514 | 0.11661 / 0.11671 |
| Blazed 29.5° ideal | 0.6301 | 0.6380 | 0.13634 / 0.13704 |
AFM_real_echelle.ggp |
0.6138 | 0.6427 | 0.19639 / 0.19921 |
The flux obliquity improves agreement in every case, and only slightly. That is
consistent rather than disappointing: at this mount the blaze order leaves near
39° against a 19.99° incidence, so .ggp this run used is still unconfirmed, and corpus.toml marks the entry
confirmed = false. It is not evidence about the normalisation either way.
The corpus says nothing at all about M16-D. Every usable reference run is
perfect conductivity — the TASTE table sums to 1.0005, i.e. R ≡ 1, so there
is no reflectivity in it to compare a reflectivity model against. The one
finite-conductivity run in the collection is panter1_finite, already marked
usable = false for summing to 3.6 at the Rayleigh anomalies.
So the groove-resolved reflectivity model rests on: reciprocity (which it restores and the alternative breaks), an exact closed-form reduction on a single-facet profile, and correct limiting behaviour. It has no external validation, and acquiring some needs either a finite-conductivity PCGrate run or the RCWA backend. Worth stating plainly, because the model changes individual orders by up to 51% and is now the default.
The first thing the metrology merge (M17) made testable, and it went the opposite way to the prediction.
docs/roadmap.md has long held that the TASTE residual — scalar's total near
0.55 against a PCGrate total of 1.0005 — is dominated by profile mismatch,
because the comparison runs on an idealised Blazed(29.5°, 70.5°) while the
.ggp the reference run actually used cannot be found. With the metrology
package absorbed, an AFM scan of what is plausibly that grating became
available to the same code (TASTE_ALS_A205_Ti_Pt_flatten.txt — A205, Ti/Pt),
so the idealised sawtooth can be replaced with a measured groove and the
hypothesis tested directly.
The scan is not in the repository — it is the group's measurement data, held
under GRATINGLAB_AFM_DIR (default ~/Documents/afm_scans/) like the PCGrate
corpus. Everything below is reproducible from it; nothing below is reproducible
from a bare clone, which is the cost of not committing measurements and is
stated here rather than discovered.
Run at the corpus geometry (period 315.15 nm, γ=1.25°, α=19.99°, perfect conductivity, 8192 quadrature points), mean total over the reference grid:
| Profile | mean Σ | vs PCGrate |
|---|---|---|
| PCGrate (integral method) | 1.0005 | — |
idealised Blazed(29.5°, 70.5°)
|
0.5420 | 0.542 |
| measured AFM groove, p = 315.15 nm | 0.3362 | 0.336 |
| measured AFM groove, p = 314.09 nm (its own) | 0.3354 | 0.335 |
The measured profile is worse, by a lot. Substituting real geometry for idealised geometry moved the total away from unity. The period makes almost no difference — 315.15 and 314.09 nm agree to 0.2% — so this is shape, not scale.
| Quantity | Value |
|---|---|
Blaze angle from the facet fit (extract_blaze_angle, 100 grooves) |
27.91° ± 2.13° |
| Blaze angle implied by the measured depth, at a 70.5° anti-blaze | 20.33° |
| depth/period, measured | 0.3275 |
depth/period, sharp Blazed(27.91°, 70.5°)
|
0.4460 |
On a sharp two-facet sawtooth these blaze angles are the same number: depth and facet angle are locked together. Here they differ by 7.6°. Something is taking depth out of the groove while leaving the mid-facet slope intact.
Do not read that as 3.6σ. An earlier version of this finding quoted the disagreement in units of the fit's own scatter, which sounds decisive and is not. The synthetic control (
tests/metrology/fixtures/synthetic_blazed_scan.txt, an ideal 30° sawtooth) recovers 29.90° ± 0.09° fitted against 29.56° implied — a 0.33° gap that is pure discretisation, and 3.8σ, because the scatter on ideal data is tiny. Sigma measures the noise, not the disagreement. The quantity that separates the two cases is the absolute gap: 0.33° on a sharp groove against 7.6° here, more than twenty times larger.
A flat land. Real blazed gratings frequently have an unfaceted flat within
the period, and a land of fraction f gives
depth = (1 − f)/(cot δ + cot δ′) — a depth deficit with no metrology artefact
required. This is the conventional explanation and has to be excluded first.
Excluded. The observed depth needs f ≈ 25–30% of the period flat:
| Facet angles assumed | sharp depth | implied land |
|---|---|---|
| 27.91° / 70.5° | 0.4460 | 26.6% |
| 29.5° / 70.5° | 0.4713 | 30.5% |
| 27.91° / 67.45° | 0.4342 | 24.6% |
The profile does not have one. Only 2.9% of the period lies within 2° of flat, 7.2% within 5°, and 12.2% within 10° — well short of 25% at any reasonable threshold. A land of that size would also put a sharp spike at 0° in the slope distribution, and there is none: the histogram is broad and continuous, spreading from −50° to 0° on the blaze side and 0° to +70° on the anti-blaze side, with no mode at zero. A sharp faceted groove with a land would be tri-modal. This one has no modes at all.
Averaging across grooves. The pipeline averages 5 grooves whose periods differ by ±2.7% (314.33 ± 8.50 nm) and whose facet angles differ by ±2.13°, which would blur an apex all by itself — a defect of the measurement pipeline rather than of the grating or the tip.
Excluded. The individual grooves are 0.3304, 0.3274, 0.3268, 0.3289, 0.3312 — mean 0.3289 ± 0.0017. Averaging costs 0.4% of the depth. Every groove is individually shallow; the average is not hiding sharp ones.
Rounding. What remains, and what the slope histogram positively supports: the groove has no facets in the sense the fit assumes. The line fit lands on the steepest part of a continuously curving flank and reports it as "the facet angle", while the apex and trough are rounded off, removing depth.
Whether the grating is rounded or the tip rounded it is not determinable from this scan. A tip too blunt to reach the trough produces this signature; so does a genuinely rounded groove from fabrication. Distinguishing them needs either a tip characterisation, a scan of the same grating with a sharper tip, or a cross-section. Nothing here settles it, and the earlier version of this finding asserted tip convolution without excluding either alternative above.
Narrowed since (M22): a tip in nominal condition is now excluded — erosion with the specified probe recovers no depth from this scan, and reproducing the deficit forward requires an apex worn to ~80 nm. See "A nominal tip does not explain the rounded groove" below.
The scalar phase term is (2π/λ)·height·sinγ. It depends on depth, not on
facet angle. So this groove diffracts like the shallower blaze its depth implies —
about 20° — regardless of the 27.9° its facets report. Whichever mechanism is
responsible, the facet fit is the number a person would quote for this grating
and the depth is the number the solver uses, and they disagree by 3.6σ.
- It falsifies, for this profile, the claim that swapping in a measured groove would close the TASTE gap. It does the reverse.
- It does not show the profile-mismatch hypothesis is wrong in general — a rounded AFM boundary is not the true grating either, if the rounding is the tip's.
- It does not confirm this scan is the TASTE reference grating. The evidence
is a name match, a period agreeing within uncertainty (314.33 ± 8.50 nm
measured against 315.15 nm known independently from
OGRE/pcgratewavscan.py), and a facet angle consistent with 29.5°. Suggestive, not decisive. - Scalar theory does not conserve energy by construction (
theory/scalar.md§5), so part of the gap to 1.0005 was never attributable to the profile at all.
Consequence: an absolute efficiency from a raw AFM boundary is not currently
defensible, and nothing in the pipeline says so. The cheapest available warning
is a diagnostic comparing the depth-implied blaze angle against the fitted one —
they agree on a sharp groove and diverge here, and the divergence is exactly the
quantity that matters. It must be thresholded in degrees, not sigma, for the
reason above. Recorded in theory/metrology.md §1.
The control now exists as a committed fixture, so the "they agree on a sharp
groove" half is a test rather than an assertion:
test_depth_and_facet_fit_agree_on_a_sharp_groove.
Both found while building test_perturbation.py, both applicable well beyond it.
np.allclose carries atol=1e-8. A shallow grating's first-order
efficiency is ~1e-9, three orders of magnitude below that floor, so the default
tolerance passed the comparison against anything — including a solver
returning zero. The first draft of the file passed cleanly against the very bug
it was written to find. Any comparison of small absolute quantities needs
atol=0 and a relative tolerance.
A test geometry can be degenerate without looking it. The same first draft
used α=10° at λ/p=0.36 and α=25° at λ/p=0.013 — respectable-looking mounts where
the quantity under test sits 0.2% from unity, so agreement was guaranteed
whatever the solver did. The fix is an assertion about the test setup rather
than the result: test_the_mounts_actually_discriminate requires each mount to
reach an order where the two theories are at least 15% apart.
The pattern behind both: a passing test is evidence only if it could have failed, and neither of these could. Non-vacuity assertions are cheap and the suite already uses them elsewhere — these are two more places they were needed.
fresnel.reflectivity multiplied
Nothing in the suite could see it. Bounded by 1, monotone in σ, exactly 1 at
σ=0, "Debye–Waller over-damps near θ_c" — every property tested was satisfied by
both forms. The limit that separates them is
| graze | NC (before) | DW | NC² |
|---|---|---|---|
| 5° | 0.963213 | 0.927771 | 0.927780 |
| 30° | 0.291214 | 0.084805 | 0.084806 |
| 85° | 0.007467 | 0.000056 | 0.000056 |
Roughened reflectivities were therefore too high — 0.732 where 0.536 was right, at 15° graze with σ=0.5 nm at λ=2 nm, a 37% error in the factor.
It also falsifies a claim recorded in M15-E. "Debye–Waller over-damps by ~1e-2 near/above θ_c, with no reliable sign far below" was measuring this bug. With the intensity form the picture is coherent and the sign is reliable in both regimes: DW over-damps near and above θ_c, under-damps deep below it by ~2e-3, and the two converge to 1e-5 far above θ_c — not below, which is where the old test looked.
Three individually true statements in conventions.md §3, whose conjunction is
contradictory under a directed reading:
| Where | Statement |
|---|---|
| frame table | ĝ = -ẑ |
| wave vectors | every k carries +\cos\gamma\,\hat{z}
|
| angles | γ is "the polar angle measured from the groove axis" |
Measured directly at the reference geometry (α = 25°, γ = 1.5°): the angle from +ẑ is
1.5000°, and the angle from ĝ is 178.5000°. Every propagating order sits at
exactly 1.5000° from +ẑ with an identical k_z = 0.999657325 — they genuinely lie on
one cone, and that cone opens along -ĝ.
so ĝ = -ẑ is forced by the right-handedness relation — which comes from Heilmann et al.
(2024) §2.1 — and is not a free choice. What was loose was the wording of the γ bullet:
γ is the acute angle to the groove-axis line, i.e. the polar angle about -ĝ.
Nothing was ever wrong. Every consumer of γ — facet_graze, sin_beta,
blaze_wavelength, Illumination — takes it as the scalars sin γ / cos γ, where the
direction of ĝ cannot enter. Illumination.direction_cosines gets the sign of its z
component right, but nothing asserted it against ĝ. No code had ever needed a
directed groove-axis vector.
Same shape as the t̂ = -d̂ finding:
invisible until something drew it. The first thing to need a directed ĝ is a 3D view of
the diffraction cone, and getting it backwards would open the cone into the grating
instead of away from it — every ray 180° out.
Recorded rather than "fixed" by flipping ĝ to +ẑ, which would break d̂ × ĝ = n̂ and
diverge from the reference. Pinned by
tests/test_illumination.py::TestTheConeOpensAwayFromTheGrooveAxis — placed where the bug
would have lived, not where it would have been noticed.
geometry.blaze_direction returns
But Blazed.slope() returns apex — the active facet rises
with
Nothing was ever wrong. The scalar solver's blaze peak lands where
blaze_wavelength predicts:
| order | predicted |
solver peak | ratio |
|---|---|---|---|
| 2 | 4.0498 nm | 3.9970 nm | 0.987 |
| 3 | 2.6999 nm | 2.6863 nm | 0.995 |
(The residual is the sinc envelope peaking between discrete orders, not a sign error.)
scalar.py computes
What made it invisible: no code had ever drawn the groove profile and the diffracted
rays in the same frame. ProfilePlotPanel plots height against
Recorded in conventions.md §3 rather than repaired by flipping
scalar.py's signs. That flip is equally modulus-preserving and every test would still
pass, but it would move the module's headline formula away from the form transcribed from
the ISSI chapter and Appendix D. Neither reference states a handedness — both quote
only
CI failed on all three matrix jobs while the same commit passed locally.
The reciprocity check tracked its worst violation with a running maximum seeded at 0.0, updated only on a strictly greater value. A solver that is bitwise reciprocal therefore never recorded which order the measurement came from. Locally, floating-point noise gave ~5 × 10⁻¹⁸ and the field was set; on CI the BLAS returned exact zero and the report came back empty.
The better the solver, the worse the report — and it only appeared on a different platform. Fixed by collecting violations and taking the maximum at the end. The regression test constructs a deliberately exactly-reciprocal solver so the zero path is exercised everywhere rather than depending on rounding.
-
OGRE/AFM_test.ggpline 164 has a missing x value — a truncated write. The reader refuses it rather than silently dropping the point, which would shift the profile and change every efficiency computed from it. -
OGRE/pcgratetest3.txthas no efficiency columns at all — 20,308 rows of scan geometry and no data.
Both rejections are correct behaviour, and both are encoded as tests so the rejections are not mistaken for parser bugs.
| Header | Vendor accepts | Seen in |
|---|---|---|
3 0 - Polygonal type + Period:
|
yes | most files |
# 3 0 - Polygonal type (hashed) |
no | *_fix6/7.ggp |
# X(normalized...) only |
no | *_fix{,2,3,4}.ggp |
np.savetxt(header=...) is what produces the hashed variants. We read all
three — they are real files someone needs to load — and write only the first,
flagging the others via GgpFile.format_valid.
Cross-checking the two primary references: the grating equation, blaze direction
Three differences change what the code computes, and four are transcription
errors. All seven are tabulated in conventions.md §10.
The two that matter most:
- The thesis sawtooth phase omits
sin γand is inconsistent with its own Φ_b twelve lines later. The ISSI form is general and is what we implement. - The thesis carries an obliquity factor and a Σ-renormalization that ISSI does not. Neither is a matter of notation, and neither survived: both are errors, and both are gone.
The first rigorous-vs-rigorous comparison the project has produced: the new
perfectly-conducting integral solver against the PCGrate TASTE wavescan
(OGRE/tastetest_perf_wavescan.txt, 541 wavelengths, 0.6–6.0 nm, γ = 1.25°,
α = +19.99°). Orderwise, across every propagating order of the full scan:
| Profile fed to our solver | max abs orderwise difference |
|---|---|
OGRE/AFM_test.ggp (162-point AFM groove, depth/period 0.292) |
7.8e-4 |
ideal Blazed(29.5, 70.5) design sawtooth |
0.29 |
OGRE/AFM_real_echelle.ggp |
0.65 |
| any candidate, mirrored | worse still |
Three things fall out at once:
-
The unidentified
.ggpis identified. The roadmap's "which.ggpgoes with which run" question is answered for TASTE: the run was made fromAFM_test.ggp, not the design sawtooth. A 5e-4 match against a 0.29 nearest-alternative is identification, not tolerance slack — encoded as a positive test and a negative control intests/test_integral_corpus.py. - The solver stack is validated end to end — conical reduction, kernels, quadrature, amplitude extraction — against an independent implementation of the same method on a measured profile. Our energy balance on that scan: 1.1e-8; PCGrate's own totals sit at 1.0005, so a fair share of the remaining 7.8e-4 is theirs.
-
The polarization mapping is pinned. PCGrate's exported
Eff.TEcolumn corresponds to this project's groove-referenced TE at this mount. Each PCGrate table carries exactly one polarization — the earlier scalar-era comparisons never had to notice.
Two caveats for honesty: AFM_test.ggp still fails read_ggp (the
truncated-write defect above), so the corpus test parses around the damaged
tail with the defect documented in place; and the scalar-vs-PCGrate deficit
reported earlier in this file ("the corpus can test the diffraction but not
the reflectivity") was computed on the idealised sawtooth — the profile that
this comparison now shows PCGrate never ran.
The λ/period guard was testing the wrong ratio, and the flagship off-plane regime passed a check it should have failed by a factor of 46.
For a perfect conductor the conical problem decouples exactly into an in-plane problem at the reduced wavelength λ/sin γ — the fact the integral solver is built on. So "structure ≫ wavelength", the smallness Kirchhoff theory needs, must be judged on λ/(p·sin γ). The old guard used λ/p, which at the reference geometry (period 315.15 nm, γ = 1.25°, λ = 2.226 nm) reads a comfortable 0.007 while the effective ratio is 0.32.
Measured, scalar vs the integral solver, both relative, TE, Sinusoidal(0.05),
λ/p held fixed at 0.001 while the cone closes:
| γ | λ/(p·sin γ) | max abs scalar-vs-integral |
|---|---|---|
| 10° | 0.0058 | 1.6e-3 |
| 5° | 0.0115 | 2.4e-3 |
| 2.5° | 0.0229 | 3.4e-3 |
| 1.25° | 0.0458 | 4.5e-3 |
Monotone growth with 1/sin γ at fixed λ/p — the naive ratio never moved. At the reference geometry itself (Blazed 29.5°/70.5°, reduced ratio 0.32) the disagreement is orderwise catastrophic: the integral solver puts 0.42 in m=+2 and 0.21 in m=+3 where bare scalar puts 0.69 in m=+3.
Two consequences. The guard now tests the reduced ratio (in-plane unchanged), and it fires on the project's own flagship regime for λ ≳ 0.7 nm — that is the honest answer, not collateral damage: the long-standing scalar-vs-PCGrate per-order RMS of ~0.12 ("the corpus can test the diffraction but not the reflectivity", above) stops being a mystery and becomes a regime statement. Scalar remains useful there for order positions and the blaze envelope; per-order numbers need the integral method.
Alongside the γ ladder, the committed cross-method suite
(tests/test_cross_method.py — the first scalar-vs-integral tests needing no
external corpus) measures the other two failure axes on Sinusoidal profiles:
depth_fraction {0.01, 0.02, 0.05, 0.2} at λ/p = 0.04 gives max abs differences
{3.6e-4, 8.4e-4, 4.4e-3, 1.0e-1} — depth is the dominant axis, ×285 over a
×20 depth range — and λ/p {0.05, 0.1, 0.4} at depth 0.02 gives {8.4e-4,
2.2e-3, 4.1e-3}. In the overlap regime (shallow, small reduced ratio) the two
solvers agree to a few 1e-4 absolute in both mounts, with the integral
solver's own energy-balance deviation below 1e-7 so none of the gap is mesh
error.
The visibility masks tested only whether a point's own facet faces the ray. The shadow the apex casts across the trough was invisible to them — a fraction of a percent of the period toward the incident beam, but 10–50% of it toward individual diffracted directions, and −32% on the blaze order.
Geometry first: for a groove-invariant surface, 3D occlusion reduces exactly to a 2D test in the transverse plane, and γ drops out — the ray's groove-parallel component slides along the invariant direction and can neither create nor remove an occlusion. So the scan runs at the azimuth alone: α for incidence, β_m per order. On an ideal sawtooth (blaze slope s_b, anti-blaze slope s_a, apex at t_a) the cast shadow past the trough has the closed form
Δ = (1 − t_a)(s_a − s_r)/(s_b + s_r), s_r = cot θ
zero until the ray dips below the anti-blaze slope (θ > 19.5° for the 29.5°/70.5° reference groove). Measured on the reference geometry, extra period masked beyond the facet-normal test:
| direction | θ | extra masked |
|---|---|---|
| incident | 19.99° | 0.38% |
| exit m=+3, λ=2.226 | 39.0° | 14.7% |
| exit m=+2, λ=3 | 32.1° | 9.5% |
| exit m=+2, λ=4 | 55.3° | 28.3% |
The incident side is nearly captured already — α = 19.99° sits just past the
19.5° threshold — which is presumably why the omission survived: the
shadowed_fraction a person would check barely moves. The exit directions are
much steeper, and there the blind spot is enormous. Full-pipeline impact
(visibility="horizon", Au, local model): the blaze order at its blaze
wavelength moves 0.514 → 0.348 (−32%) — the same order of magnitude as the
M16 groove-resolution change.
Reciprocity survives by construction (occlusion along a straight ray reads the same from either end; measured 1e-16 coated and uncoated), and the horizon subsumes the orientation test outright: a back-facing point has a falling ray-adapted height u = g + p·cot θ·t, which puts it under the running horizon.
Two sign traps, each of which fails silently — reporting "no cast shadow"
rather than erroring — are pinned by tests
(tests/test_geometry.py::TestHorizonVisible derives the sliver trig
independently): the profile parameter runs against the dispersion direction
("The profile parameter runs backwards", above), so a ray with α > 0 travels
toward +t and its occluders sit at smaller t; and the scan must run on
problem.height_nm, not the period-normalised profile.height, because the
ray's run-to-drop ratio compares height against period directly.
Cast shadowing moved the blaze order from 0.69 toward the rigorous 0.21 of the (out-of-regime) perfect-conductor comparison — the right direction, but that is suggestion, not validation. Like the geometric-mean weight, the horizon ships opt-in until a finite-conductivity A/B exists.
Every scalar reflectivity model averages facet-local s and p 50/50 because the groove-TE/TM → facet-s/p rotation is deliberately unowned. Measured, that waiver costs under half a percent in the working regime — so the mapping stays unimplemented, and the solver now prices the waiver instead of assuming it.
Fractional intensity split |R_s − R_p|/R_s on the vendored Au table:
| λ (nm) | ζ = 0.5° | 1.25° | 2° | 5° |
|---|---|---|---|---|
| 0.62 | 1.2e-4 | 3.9e-4 | 2.2e-3 | 2.8e-2 |
| 1.0 | 3.3e-4 | 8.9e-4 | 1.7e-3 | 2.4e-2 |
| 3.0 | 1.9e-3 | 4.9e-3 | 7.9e-3 | 2.4e-2 |
| 6.0 | 2.2e-3 | 5.5e-3 | 8.9e-3 | 2.5e-2 |
At amplitude level |r_s − r_p|/|r_s| ≤ 9e-3 and arg(r_p/r_s) ≤ 8e-3 rad at ζ ≤ 2°. Since the unpolarized mean is already computed, any rotation of the polarization basis can move the result by at most half the split: < 0.5% at every graze the reference groove presents (worst local graze 1.94°). The mapping only starts to matter above ~5° facet graze — steep grooves near normal incidence, where the Brewster warning already fires.
Implementing the rotation would therefore be false precision here; what was missing was the price tag. The provenance now evaluates the split at the guarded graze and the scan's wavelength extremes, and warns above 2% — so a steep-groove near-normal run (measured split: 40%) says out loud that its unpolarized average is a modelling decision, not a freebie.
Binary visibility masks cost the masked models O(1/n) convergence — ~5e-4 at the default 2048 points on the coated blazed reference. The scheme that fixed it places every boundary geometrically and reaches 2e-7 at the same 2048 points. The two schemes that failed are recorded because each failure looks like a reasonable design until measured.
-
Interpolating the mask function across the straddling pair (the obvious
design) mis-handles discontinuities: at a profile corner the orientation
function
sin ζ(t)jumps rather than crosses, the linear estimate is systematically biased, and on the reference sawtooth it handed 0.8 of a cell of unit-amplitude weight to a back-facing sample. Measured: worse than binary on blazed profiles (1.5e-3 vs 5.0e-4 at n = 2048). - Extrapolating the horizon deficit from the shadow side, sample-level fixes the reappearance boundary but not the entry: a shadow's level was the occluding apex's height at a sample, and the true corner between samples sits up to one cell higher, shifting both boundaries of the stretch by O(h). Measured: lit-fraction errors of 2e-4–9e-4 that plateau across n before dropping — the signature of a boundary mislocated by a fixed physical length.
-
The surviving scheme (
geometry.horizon_weights): the entry corner is recovered by intersecting the secants through the two samples on each side of it — and the lit-side pair must be (i−2, i−1), because the last discretely-lit sample can already sit past the corner. Every shadow stretch is occluded by its own entry crest (a lit point is at its running maximum), so the exit is where u climbs back to that corner's height, one period-drop lower if the stretch wraps t = 0. On a polygon — Blazed, Lamellar, every measured groove — the lit fraction is then exact to machine precision at any n (asserted at 1e-13 intests/test_geometry.py::TestHorizonWeights).
One composition rule mattered as much as the boundaries: incident and exit masks combine by minimum, not product. Every direction's shadow starts at the same apex corner, so the two masks share boundary cells, and multiplying their sub-cell weights counts the shared boundary twice — measured as the entire remaining error, sitting on the blaze order (its integrand is nearly constant, so its efficiency is the lit overlap squared).
Final measured ladder, coated blazed reference, visibility="horizon",
against a 131072-point run: 4.4e-6 (n=1024), 2.0e-7 (2048), 7.8e-7
(4096), 1.0e-5 (8192), 1.8e-7 (16384) — erratic small residuals, every rung
below the binary scheme's n=2048 error by 30× or more. facet-normal keeps
binary masks deliberately: its boundary function jumps at corners where no
crossing estimate is sharp, and bit-for-bit reproducibility is that mode's
purpose. Orders away from the blaze converge spectrally (1e-8 at n=2048) —
the shadow boundaries were the only thing standing between the masked models
and the pure phase integral's convergence.
The M20 coupled conical system (Goray & Schmidt 2010) was derived independently in project conventions rather than transcribed — their normal points into the metal and their potentials carry a factor 2, exactly the kind of mapping where a lifted sign survives every syntax check. The pinning strategy that replaced transcription trust, with measured outcomes:
| Check | Measured |
|---|---|
Flat interface, in-plane: TE amplitude vs fresnel.amplitude r_s, TM vs r_p, phase included |
agrees to <1e-4, all of gold/X-ray/glass indices, two angles |
| Flat interface, conical (cos γ = 0.8): full 2×2 (E_z,B_z) reflection matrix vs the rotated-Fresnel closed form | 6.2e-5, cross-coupling terms included |
| R + A = 1 with A from the independent boundary integral (their eq. 26, re-derived; reduces exactly to 1− | r |
| R + T = 1, lossless substrate; boundary integral equals the transmitted Rayleigh sum | few 1e-4; A−T ≤ 9e-5 |
| Perfect-conductor limit, n = 100i, in-plane and conical | 1.3e-3 (TE) / 7.4e-4 (TM) worst order — the residual is the ~1 nm field penetration, i.e. physics, not error; absorption at numerical zero |
| G&S Table 3 (dielectric sinusoid, conical, pure E_z; cross-method against Li's CM) | within 6e-5 of every tabulated order, reflected and transmitted, at half their collocation count |
| Lorentz reciprocity, lossy conical Au at X-ray graze | ~5e-5, through check_reciprocity unchanged |
Two regime facts worth remembering:
- Below the critical angle the metal side sets the mesh. The field decays into the metal on the λ/√(2δ) scale — for Au at 2 nm and 1.5° graze that is ~21 nm against a 76 nm vacuum reduced wavelength, so the metal-side floor (~210 points on the 600 nm test sinusoid) is the binding one. The solver guards it with the same 6-nodes-per-wavelength refusal.
-
The absorbed fraction is large where it matters. Au at 2 nm, 1.5°
graze on the test sinusoid: R = 0.70, A = 0.30 — the quantity the
perfect-conductivity mode could not produce, now recorded per wavelength
on the scan and consumed by
check_energy_balanceautomatically.
Noticed while transcribing validation data: in the published PDF, Table 4 ("dielectric sine grating for E_z = 0") prints exactly the same efficiency and polarization-angle values as Table 3 ("for B_z = 0") — all twelve rows, all four digit columns — with only the footnote polarization state differing (δ = 8.499°, ψ = 180° vs δ = 81.501°, ψ = 0). Confirmed against the rendered page, not just text extraction.
Physically the two incident states cannot give identical efficiencies, and
our solver settles it: the true E_z = 0 case differs decisively (specular
1.07% against the printed 10.33%; T₊₁ 16.7% against 9.9%) while conserving
R + T = 1 to 2e-5. A test in tests/test_integral_finite.py pins the
disagreement so the finding survives refactors; our computed E_z = 0 values
stand in the test file as the project's record of that case.
Decoding note for anyone returning to their Tables 1–2: the footnote angles follow Li's convention with tan δ = |E_s/E_p| and ψ the relative phase — recoverable because the Table 3/4 footnotes bracket the pure-z states (81.501° + 8.499° = 90°, and the same split reproduces from our incident basis rotation). Table 5's δ = 0 and Table 6's δ = 90° at θ = 0 are exactly the pure E_z and pure B_z states.
Eroding the TASTE scan with the nominal probe changes nothing it measures, and burying an ideal facet to the measured depth takes a tip forty times blunter than nominal. The rounded-groove finding above left "grating or tip?" open; the tip-deconvolution milestone (M22) narrows it: a tip in anything like its specified condition is excluded. What remains is a severely worn tip, or a grating that really is rounded.
Same scan as the finding above (TASTE_ALS_A205_Ti_Pt_flatten.txt, 512 px
over 2 µm — 3.91 nm/px), same pipeline, with tip_correction='erosion' and
the probe modelled as a cone of 18° half-angle capped by a spherical apex
(core/tip.py, Villarrubia 1997). The nominal probe is R ≈ 1 nm ("2 nm
wide"); wear only increases R.
Reconstruction direction — erode the measured image, which can only deepen a groove the tip failed to reach into:
| apex radius R | depth/period | implied blaze | pixels certain |
|---|---|---|---|
| uncorrected | 0.3275 | 20.33° | — |
| 1 nm (nominal) | 0.3275 | 20.33° | 97.7% |
| 2 nm | 0.3275 | 20.33° | 95.9% |
| 5 nm | 0.3275 | 20.33° | 81.8% |
| 10 nm | 0.3274 | 20.32° | 74.8% |
The correction recovers nothing — the measured surface is already reachable by all of these tips, so there is no tip-hidden depth for erosion to return. A surface whose troughs had been rounded by the tip would come back deeper; this one does not move at the reported precision.
Forward direction — dilate an ideal sharp Blazed(27.91°, 70.5°) groove
(the fitted facet angle, at the measured 314.1 nm period, sampled at the
scan's own pitch) with the same tip family, and ask what depth the image
shows:
| apex radius R | image depth/period | implied blaze |
|---|---|---|
| sharp truth | 0.4460 | 27.91° |
| 1 nm (nominal) | 0.4439 | 27.77° |
| 2 nm | 0.4433 | 27.74° |
| 10 nm | 0.4316 | 27.00° |
| 20 nm | 0.4176 | 26.11° |
| 40 nm | 0.3909 | 24.40° |
| 80 nm | 0.3362 | 20.89° |
The nominal tip costs 0.14° of implied blaze — the observed gap is 7.6°. Reproducing the measured depth needs R ≈ 80 nm, a probe worn to forty to eighty times its specified apex. The 18° flank is not the culprit either: at 72° from the surface it is steeper than the 70.5° anti-blaze facet, so the facet remains reachable at any radius — the deficit is all apex.
What would settle the remainder: the probe's actual condition at scan time (an 80 nm apex is far outside spec and often visible in the vendor's tip check), a re-scan with a fresh tip, or a cross-section. Until one of those exists, the rounding should be treated as plausibly real — and the absolute efficiency consequence of the finding above stands unchanged, because erosion with any defensible tip model does not sharpen this groove.
Pinned by tests/metrology/test_tip.py::TestTheRealScan, which skips without
the group's data and otherwise fails if nominal-tip erosion ever starts
moving the measured depth — either the scan changed or the erosion started
inventing surface.