Skip to content

Sample the field and the pupil the same way, from one grid - #219

Merged
roytsmart merged 3 commits into
mainfrom
feature/consistent-grid-sampling
Sep 1, 2026
Merged

Sample the field and the pupil the same way, from one grid#219
roytsmart merged 3 commits into
mainfrom
feature/consistent-grid-sampling

Conversation

@roytsmart

Copy link
Copy Markdown
Collaborator

linearize documents pupil as cell vertices, then defaults to two grids which are neither vertices nor each other: area_effective invents its own, while the distortion and vignetting fits take grid_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:

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. A reader who takes "the vertices of the pupil grid" at face value, and passes grid_input.pupil to be explicit, silently gets a vignetting model 163% away from the default.

One rule for both axes

field and pupil are cell vertices. area_effective samples 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:

linearize()  vs  linearize(field=<default>, pupil=<default>):
  distortion  0.00e+00   (bit-identical: True)
  vignetting  0.00e+00   (bit-identical: True)
  area        0.73%      <- jitter; two default runs differ by 1.96%

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:

   cells |    bias |   noise
 11x11   |  0.67% |  1.93%
 15x15   |  0.54% |  0.73%
 21x21   |  0.27% |  0.46%
 25x25   |  0.23% |  0.41%
 31x31   |  0.11% |  0.27%

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:

  distorted position moves by 0.013 pix   (resolution element is 2.81 pix)
  illumination moves by       1.37%
  effective area moves by     1.77%

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_view is 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.py at 100% line coverage. esis passes downstream unchanged (260 tests).

Relationship to #187

This is deliberately not #187. grid_input keeps its current meaning — the points rays are traced at — and nothing outside linearize and area_effective changes, 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

`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

codecov Bot commented Aug 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (f2dc2fb) to head (05640a9).

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

Comment thread optika/systems/_sequential.py Outdated
Comment on lines +1839 to +1840
field_centers = field.cell_centers(axis=tuple(na.shape(field)))
pupil_centers = pupil.cell_centers(axis=tuple(na.shape(pupil)))

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm pretty sure we need to use axis_field and axis_tuple here

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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%.

roytsmart and others added 2 commits August 31, 2026 19:58
`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>
@roytsmart
roytsmart merged commit 60a7344 into main Sep 1, 2026
18 checks passed
@roytsmart
roytsmart deleted the feature/consistent-grid-sampling branch September 1, 2026 12:50
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