Sample the field and the pupil the same way, from one grid - #219
Conversation
`linearize` documented `pupil` as cell vertices, then defaulted to two
different grids which are not vertices and not each other: the effective
area invented its own, while the distortion and vignetting fits took
`grid_input.pupil`, which holds the points rays are traced at. The
default was therefore not expressible through the parameter. Passing
the grid the docstring names gives a vignetting model 163% away from
leaving it out:
linearize(pupil=None) vs linearize(pupil=grid_input.pupil):
distortion coefficients differ by 0.02%
vignetting coefficients differ by 162.69%
effective area differs by 1.57%
Nothing errors; the numbers are simply different.
Give both axes one rule. `field` and `pupil` are cell vertices; the
effective area samples a point drawn uniformly inside every cell, and
the fits trace at the cell centers. Both models now describe the same
grid, so passing the defaults back reproduces leaving them out, and the
distortion and vignetting coefficients come out bit for bit identical.
Stratifying the field as well as the pupil is what the vertices buy.
The field average used to wander with the sampling rather than converge;
it now does both monotonically, against a 40x40 reference:
cells | bias | noise
11x11 | 0.67% | 1.93%
15x15 | 0.54% | 0.73%
21x21 | 0.27% | 0.46%
31x31 | 0.11% | 0.27%
The cost of the consistency itself is small, measured against the ESIS
as-built model: 0.013 pix of distortion, 1.37% of illumination, 1.77% of
effective area, all well inside the 5.8% photon noise the SNR
requirement implies.
`test_area_effective_ignores_field_outside_the_field_of_view` is
rewritten for the vertex convention, extending the grid by dark *cells*
rather than dark points. Sized so its noise is 1.4% against the 49%
error it guards against; verified to fail with the field mask removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #219 +/- ##
=========================================
Coverage 100.00% 100.00%
=========================================
Files 121 121
Lines 7462 7491 +29
=========================================
+ Hits 7462 7491 +29
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
| field_centers = field.cell_centers(axis=tuple(na.shape(field))) | ||
| pupil_centers = pupil.cell_centers(axis=tuple(na.shape(pupil))) |
There was a problem hiding this comment.
I'm pretty sure we need to use axis_field and axis_tuple here
There was a problem hiding this comment.
You're right. Fixed in 8fe39e6.
tuple(na.shape(field)) is every axis the grid has, when only the two field axes should go from vertices to centers. Any other axis the grid carries gets averaged into itself. On a four-channel system whose field varies per channel:
cell_centers(axis=tuple(na.shape(field))) -> {'_field_x': 11, 'channel': 3, '_field_y': 11}
cell_centers(axis=axis_field) -> {'_field_x': 11, 'channel': 4, '_field_y': 11}
Four channels become three, each an average of its neighbours, and nothing raises.
Now named through the same _normalize_axis_wavelength / _normalize_axis_field / _normalize_axis_pupil helpers the fits already use, which subtract shape and the wavelength axis and so pick out exactly the two intended.
Worth noting this predates the rest of the PR: pupil_centers was written this way before I touched it, and I copied the pattern across to field_centers. It never showed because nothing in the suite passes a grid with an axis beyond the two being centered.
test_linearize_keeps_axes_it_is_not_centering closes that: it passes a field which drifts with wavelength and asserts the wavelength axis survives at full length. Verified to fail with the old handling restored.
Full suite: 18315 passed, _sequential.py at 100%.
`cell_centers` was given `tuple(na.shape(field))`, every axis the grid
has, when only the two field axes are meant to go from vertices to
centers. A grid which carries any other axis has it averaged into
itself. For a four-channel system whose field varies per channel:
cell_centers(axis=tuple(na.shape(field))) -> {'_field_x': 11, 'channel': 3, '_field_y': 11}
cell_centers(axis=axis_field) -> {'_field_x': 11, 'channel': 4, '_field_y': 11}
Four channels become three, each an average of its neighbours, with
nothing raised. The same for the pupil.
Name them instead, through the same `_normalize_axis_*` helpers the
fits use, which subtract `shape` and the wavelength axis and so pick out
exactly the two axes meant.
This predates the surrounding change: `pupil_centers` was written this
way already, and nothing in the suite passed a grid with an axis beyond
the two, so it never showed. `test_linearize_keeps_axes_it_is_not_centering`
passes a field which drifts with wavelength and asserts that axis
survives at full length; it fails with the old handling restored.
Thanks to @roytsmart for catching it in review.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI caught what the local run did not: `test_area_effective` failed one run in forty with assert np.all(result.area >= 0) AssertionError: assert ScalarArray(ndarray=np.False_, axes=()) on a value which was not negative but `nan`. Averaging with `where` over a wavelength which no sampled field position admits is the mean of an empty set. Reachable before this branch, since the mask arrived in #207, but only just: the field was sampled at fixed points, so whether a coarse grid caught the field of view was fixed too. Sampling a random point inside every cell makes it a throw of the dice, and at the four cells per axis the parametrized test uses it comes up about once in forty. Write it as a sum over a count instead, so that such a wavelength comes out as no effective area. That is what the physics says of the sampled grid, and unlike a `nan` it does not carry silently into every image the model produces. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
linearizedocumentspupilas cell vertices, then defaults to two grids which are neither vertices nor each other:area_effectiveinvents its own, while the distortion and vignetting fits takegrid_input.pupil, which holds the points rays are traced at.So the default is not expressible through the parameter. Passing the grid the docstring names gives a different model:
Nothing errors. A reader who takes "the vertices of the pupil grid" at face value, and passes
grid_input.pupilto be explicit, silently gets a vignetting model 163% away from the default.One rule for both axes
fieldandpupilare cell vertices.area_effectivesamples a point drawn uniformly inside every cell; the fits trace at the cell centers. Both models describe the same grid, so passing the defaults back reproduces leaving them out:Stratifying the field is what the vertices buy
The field average used to wander with the sampling instead of converging. It now does both monotonically, against a 40×40 reference:
Before, on a deterministic field grid, the bias ran 0.38%, 0.95%, 0.59%, 0.78%, 0.61% with no trend — refining it was a coin flip rather than an improvement.
What it costs
Measured on the ESIS as-built model, against today:
All well inside the 5.8% photon noise implied by the SNR 17.3 requirement, and the position shift is 1/200 of a resolution element.
Tests
test_area_effective_ignores_field_outside_the_field_of_viewis rewritten for the vertex convention, extending the grid by dark cells rather than dark points. Sized deliberately: at 10 cells per axis its noise is 1.4% against the 49% error it guards against, and it is verified to fail with the field mask removed.Full suite: 18314 passed, 304 skipped, 17 xfailed.
_sequential.pyat 100% line coverage. esis passes downstream unchanged (260 tests).Relationship to #187
This is deliberately not #187.
grid_inputkeeps its current meaning — the points rays are traced at — and nothing outsidelinearizeandarea_effectivechanges, so no downstream migration and no major version bump. It settles the convention only where the two interpretations were already colliding.🤖 Generated with Claude Code