Skip to content

Check that linearizing a system conserves flux - #208

Merged
roytsmart merged 1 commit into
mainfrom
test/flat-field-flux
Aug 27, 2026
Merged

Check that linearizing a system conserves flux#208
roytsmart merged 1 commit into
mainfrom
test/flat-field-flux

Conversation

@roytsmart

@roytsmart roytsmart commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Depends on #207, and must merge after it (the test fails without that fix).

This targets main rather than #207's branch on purpose, so that deleting that branch on merge cannot close this PR. Until #207 lands, the diff below shows both changes; once it does, this branch is rebased and the diff narrows to the test alone.

Why

Nothing tied SequentialSystem and the LinearSystem it produces together:

  • test_linearize asserts the types of the three fitted models and nothing about their values
  • _linear_test.py builds its systems from hand-written _distortion() / _vignetting() / _area_effective() fixtures, never from a real system
  • test_image asserts each system puts a nonzero number of electrons on its sensor

So the distortion, vignetting, and effective-area models were each checked alone and never against each other, which is how #207's bug survived: it is entirely a disagreement between two of them.

The test

Trace a flat field through a system and through its linearization; compare total electrons.

The interesting part is that the system has to be chosen carefully. The normalized field grid is the bounding box of the field of view, so a field stop shaped like the sensor fills that box and leaves nothing vignetted. On the system the other tests use, 99% of the grid is illuminated, the vignetting model has no work to do, and the test would pass with or without #207. A round field stop leaves the corners dark, which is the shape ESIS actually has:

  radius  0.96 mm: illuminated fraction = 0.5702

With that, on a scene covering the inner half of the field of view:

 scene FOV |   sequential |       linear |     now |  before #207
       0.5 |    8.523e+12 |    8.404e+12 |  0.9860 |  0.5622
       0.8 |    1.381e+13 |    1.465e+13 |  1.0606 |  0.6048
       1.0 |    1.583e+13 |     1.47e+13 |  0.9286 |  0.5295

Tolerance

rtol=0.15. The two discretize differently and area_effective traces at randomly placed pupil cell centers, so exact agreement isn't available. Measured spread over six rebuilds of the linear system:

   [1.015  1.0169 1.0099 1.0167 1.0133 1.013 ]
   mean 1.0141  std 0.0024  min 1.0099  max 1.0169

Verified to fail with #207's change reverted. Runs in 0.75 s. Five consecutive runs pass.

Found while writing this, not addressed here

The polynomial fits are unusable above degree 2 and fail silently. Fitted illumination over the scene, on an 11×11 field grid with 3 wavelengths:

 field  deg | linear/seq | illumination over the scene
    11    2 |     1.0685 | min=    1.034  max=     1.654  mean=    1.412
    11    3 |     0.3554 | min=-3.07e+13  max= 3.07e+13   mean=    1.404
    21    4 |     9.3235 | min=-2.01e+14  max=-7.39e+12   mean=-1.04e+14

Almost certainly rank deficiency: a degree-3 fit needs at least 4 samples along the wavelength axis and the grid has 3. It produces no error and no warning, just ±10¹⁴ and negative illumination. Worth its own issue, and a reason this test pins degree=2.

_grid_input in this module spans [0, 1], one quadrant of the normalized field, rather than [-1, 1]. Fitting there and evaluating over the full field of view gave 0.58 of the true flux. Presumably deliberate for the other tests, but it makes anything built on linearize misleading.

🤖 Generated with Claude Code

@codecov

codecov Bot commented Aug 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (c32ab6d) to head (00ca26a).

Additional details and impacted files
@@            Coverage Diff            @@
##              main      #208   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          121       121           
  Lines         7303      7317   +14     
=========================================
+ Hits          7303      7317   +14     
Flag Coverage Δ
unittests 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@roytsmart
roytsmart changed the base branch from fix/area-effective-field-mean to main August 27, 2026 03:34
Nothing tied `SequentialSystem` and the `LinearSystem` it produces
together.  `test_linearize` asserts the types of the three fitted models,
`_linear_test.py` builds its systems from hand-written fixtures rather
than from a real one, and `test_image` only asserts that each system
puts a nonzero number of electrons on its sensor.  The distortion,
vignetting, and effective-area models were each checked alone, and never
against each other.

Trace a flat field through a system and through its linearization, and
compare the total electrons.

The system needs a field stop which is not the sensor.  The normalized
field grid is the bounding box of the field of view, so a field stop
shaped like the sensor fills it and leaves nothing vignetted: on the
system the other tests use, 99% of the grid is illuminated and the
vignetting model has no work to do.  A round field stop leaves the
corners dark, 57% illuminated here, which is the shape a system like
ESIS actually has.

The two agree to about 1.4%, and are stable to 0.24% over repeated
builds of the linear system, so the tolerance is 15%.  Averaging the
effective area over a different set of field positions than the
vignetting model is normalized over puts the ratio at 0.56, which fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@roytsmart
roytsmart force-pushed the test/flat-field-flux branch from 870dbcf to 00ca26a Compare August 27, 2026 03:37
@roytsmart
roytsmart merged commit 6d12eb0 into main Aug 27, 2026
12 checks passed
@roytsmart
roytsmart deleted the test/flat-field-flux branch August 27, 2026 03:54
@roytsmart

Copy link
Copy Markdown
Collaborator Author

Correction to the "not addressed here" note above

The claim that the pupil quadrature "does not settle" is wrong, and the sequence quoted for it (1.294 at 4×4 through 1.449 at 80×80) is the midpoint rule, which is not what area_effective does. It traces cell_centers(..., random=True), and that is proper stratified sampling: one independent uniform per cell, per axis.

Measured properly, 40 draws per grid, effective area at field centre:

  cells |  midpoint |  stratified mean |      std | std/mean
  5x5   |   1.44385 |          1.46686 |  0.24708 |   16.84%
 10x10  |   1.44454 |          1.43212 |  0.08611 |    6.01%
 20x20  |   1.49684 |          1.46356 |  0.03407 |    2.33%
 40x40  |   1.49715 |          1.44933 |  0.01478 |    1.02%

   5 -> 10: std x0.349   implied exponent -1.52
  10 -> 20: std x0.396   implied exponent -1.34
  20 -> 40: std x0.434   implied exponent -1.20

That is the n^-1.5 expected for stratified sampling across a hard aperture edge, and the means agree at about 1.45 on every grid. It is the midpoint values at 20×20 and 40×40 that sit ~3% high. area_effective is well behaved: 6% per-draw noise at a single field position on the default grid, and about 0.7% once averaged over the field, which is what it returns.

What is actually wrong is vignetting

It never goes through cell_centers at all. It traces grid_input.pupil as raw sample points, so it gets none of the stratification. Unvignetted fraction at field centre:

  cells | points (vignetting) |  midpoint |   stratified mean +- std
  5x5   |             0.20000 |   0.28000 |     0.27500 +-  0.04809
 10x10  |             0.24000 |   0.28000 |     0.27950 +-  0.01724
 20x20  |             0.24000 |   0.29000 |     0.28169 +-  0.00573
 40x40  |             0.26500 |   0.29000 |     0.28086 +-  0.00238
 80x80  |             0.27719 |   0.28063 |     0.28143 +-  0.00084

Point sampling converges from below at roughly 1/n, and is 15% low at the default 11×11 grid where stratified sampling is already within 1%.

A uniform scale error would cancel, since the model normalizes. This one does not, because the bias varies across the field. Against a converged stratified reference, over the 25 illuminated field positions of a 7×7 grid:

`vignetting` v / converged v :  min 0.561  max 0.998  mean 0.759
normalized illumination error:  min -25.3%  max +33.0%  rms 14.8%

So the relative illumination the polynomial is fit to is off by up to a third at individual field positions, from pupil sampling alone. The fit smooths some of that out, but it is fitting biased data. The fix is to give vignetting the same stratified cell centres area_effective already uses.

This does not affect the test added here, which passes at rtol=0.15 and is measuring the composition of the models rather than their sampling.

@roytsmart

Copy link
Copy Markdown
Collaborator Author

Retraction of the correction above

The previous comment's claim that vignetting's pupil sampling is 15% low, and that its normalized illumination is off by −25% to +33% (rms 14.8%), is wrong. I measured a grid the code does not use.

I built the comparison from na.linspace(-1, 1, num=11), which includes the endpoints, and labelled it "what vignetting uses". It isn't. grid_input is built with centers=True:

pupil=na.Cartesian2dVectorLinearSpace(
    ...
    centers=True,
)

so the samples land at ±0.909 … 0 — strictly interior. The boundary-sampling mechanism I described, where a third of the samples sit where nothing gets through, never happens. I constructed the defect and then measured it.

Unvignetted fraction at field centre, against a converged value of ~0.281:

   n | ESIS grid (centers=True) |  linspace with endpoints
   5 |                  0.28000 |                  0.20000
  11 |                  0.28926 |                  0.21488
  21 |                  0.29932 |                  0.24490
  41 |                  0.27722 |                  0.26591
  81 |                  0.28319 |                  0.27755

The left column is what the code does: within 3% at n=11, bouncing either side of the answer as midpoint quadrature does. The right column, creeping up from below at ~1/n, is the grid I invented.

Re-measured properly, taking the pupil grid straight from system.grid_input.pupil rather than reconstructing it, over the 97 illuminated field positions of the 11×11 field grid:

  raw v ratio to converged :  min 1.020  max 1.143  mean 1.074
  normalized illumination  :  min -5.1%  max +6.4%  rms 1.9%

So the effect is rms 1.9%, not 14.8%, and the raw bias runs high rather than low. That is a modest quadrature error of the kind you would expect from a midpoint rule at this resolution, not a radiometric defect.

What survives:

  • vignetting genuinely does not call cell_centers or use jitter, unlike area_effective. That part was accurate; it just matters far less than stated, because grid_input already arrives centered.
  • The real difference between the two methods is midpoint versus stratified quadrature, and at this resolution those agree to well under a percent.
  • Interpret grid_input as cell vertices and trace rays at cell centers #187's knife-edge rationale concerns grids built without centers=True. It remains a worthwhile convention fix, but it is not repairing an ESIS radiometry bug.

Apologies for the noise. The error was measuring a reconstruction of the input instead of the input itself.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant