Skip to content

Solve for the stops once when linearizing, not three times - #211

Merged
roytsmart merged 2 commits into
mainfrom
refactor/share-stop-solve
Aug 28, 2026
Merged

Solve for the stops once when linearizing, not three times#211
roytsmart merged 2 commits into
mainfrom
refactor/share-stop-solve

Conversation

@roytsmart

@roytsmart roytsmart commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

_denormalize_grid does two unrelated things: it solves for where the stops put the field and pupil, and it applies an affine map. The solve costs 0.13 s and depends on nothing but the wavelengths. The map is free.

linearize denormalizes three grids on the same wavelengths, one per fit, so it solved three times for the same answer, plus a fourth for rayfunction_default:

a cold linearize(wavelength=<9 points>):
   solve 1: wavelength {}                  0.19 s
   solve 2: wavelength {'wavelength': 9}   0.13 s
   solve 3: wavelength {'wavelength': 9}   0.13 s
   solve 4: wavelength {'wavelength': 9}   0.13 s
   4 solves, 0.59 s of 3.98 s (15%)

There is a cache for this, rayfunction_stops, and it works. But it is keyed on grid_input.wavelength, so the moment a caller supplies its own wavelengths it never hits. Nor was the cost amortised across calls: a second linearize on the same wavelengths ran all four again.

What this does

Splits the two halves apart so a caller with several grids can solve once:

_calc_rayfunction_stops_denormalize the solve
_denormalize_grid_from_rays the affine map
_denormalize_grid both, exactly as before

linearize then solves once, denormalizes both of its grids, and passes each fit a grid that is already physical, so their own _denormalize_grid hits its early return and never solves.

   2 solves, 0.29 s of 3.59 s (8%)

The remaining one is rayfunction_default's, which is on the default grid rather than the one being linearized. That is a separate question about where direction should come from.

Nothing changes

Every deterministic product of linearize, compared against main in a worktree on esis.flights.f1.optics.design_single() over nine wavelengths. All 37 arrays equal bit for bit:

distortion      20 arrays   IDENTICAL
vignetting      10 arrays   IDENTICAL
direction        1 arrays   IDENTICAL
scene            3 arrays   IDENTICAL     # what the distortion is fit to
sensor           2 arrays   IDENTICAL
illumination     1 arrays   IDENTICAL     # what the vignetting is fit to

This is exact rather than approximate because the two stop solves it replaces were already producing identical results, which I verified directly, and because linearize keeps the existing order of operations. In particular it still takes pupil cell centers from the normalized vertices, before denormalizing, as it does today. Denormalizing first would be equivalent in exact arithmetic, since an affine map commutes with an average, but differs by an ulp in floating point:

  pupil.x: bit-identical False   max|d| 1.421e-14  rel 1.749e-16   (ulp ~ 1.4e-14)

Not worth giving up a provable claim for.

One wrinkle

The fits can no longer be handed a grid of None, so linearize resolves the defaults itself. The pupil grid area_effective invents when given none moves out of the method body into _pupil_vertices_default, so both can reach it.

The two fits keep falling back to grid_input.pupil exactly as before. Those two defaults differ because area_effective needs cell vertices, to weight each ray by the area of its pupil cell, while the fits need sample points. Reconciling them is #187's job, and doing it here would silently change results in a PR that is otherwise provably inert.

(Edited: this paragraph previously claimed #187 would also fix a knife-edge sampling error making vignetting's illumination 15% low. That was wrong — I measured a grid the code does not use. grid_input is built with centers=True, so its samples are already interior, and the real effect is rms 1.9% on the normalized illumination. Retracted in detail at #208 (comment).)

Tests

Full suite: 17774 passed, 304 skipped, 17 xfailed. _sequential.py at 100% line coverage.

🤖 Generated with Claude Code

Denormalizing a grid does two unrelated things: it solves for where the
stops put the field and pupil, and it applies an affine map.  The solve
costs 0.13 s and depends on nothing but the wavelengths; the map is free.

`linearize` denormalizes three grids on the same wavelengths, one per
fit, so it solved three times for the same answer, and a fourth for
`rayfunction_default`.  Split the two halves apart, so that a caller
with several grids can solve once:

  _calc_rayfunction_stops_denormalize  the solve
  _denormalize_grid_from_rays          the affine map
  _denormalize_grid                    both, as before

and have `linearize` solve once, denormalize both of its grids, and pass
each fit a grid which is already physical, so their own denormalization
is a no-op.

  before: 4 stop solves, 0.59 s of 3.98 s
  after:  2 stop solves, 0.29 s of 3.59 s

The one which remains is `rayfunction_default`'s, on the default grid
rather than the one being linearized.

Since the fits can no longer be handed a grid of `None`, `linearize`
resolves the defaults itself, and the pupil grid `area_effective`
invents when given none becomes `_pupil_vertices_default` so that both
can reach it.  The two fits keep falling back to `grid_input.pupil` as
before: those grids differ because one wants cell vertices and the other
wants sample points, and reconciling them is #187's job, not this one's.

Every product of `linearize` is unchanged bit for bit: all twenty
distortion coefficients, all ten vignetting coefficients, the direction,
the scene and sensor coordinates the distortion is fit to, and the
illumination the vignetting is fit to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@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 (b8415e1) to head (0e0f0f1).

Additional details and impacted files
@@            Coverage Diff            @@
##              main      #211   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          121       121           
  Lines         7384      7402   +18     
=========================================
+ Hits          7384      7402   +18     
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
y=na.linspace(-1, 1, axis="_pupil_y", num=11),
)

def _calc_rayfunction_stops_denormalize(

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.

Why is this function necessary?

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.

It isn't. Removed in 0e0f0f1.

The wrapper only existed to fill in four arguments that every caller passes identically, which was papering over a problem rather than fixing it: the defaults on _calc_rayfunction_stops were simply wrong. Its two axis parameters had no defaults at all, even though both callers pass the class constants _axis_pupil_stop and _axis_field_stop, and its sample counts defaulted to 101, which nothing in the package has ever used — every call site passes 21.

Giving it the values its callers actually want removes the wrapper and the same boilerplate from rayfunction_stops, which was repeating all four arguments too:

return self._calc_rayfunction_stops(self.grid_input.wavelength)

Net 27 lines fewer than the previous commit, and one fewer way to get a stop rayfunction rather than one more.

Still bit-identical to main across all 37 deterministic products of linearize, and still 2 stop solves instead of 4.

`_calc_rayfunction_stops_denormalize` existed only to fill in four
arguments which every caller passes identically.  It is not needed: the
defaults on `_calc_rayfunction_stops` were simply wrong.  Its axes had
no defaults at all even though both callers pass the class constants,
and its sample counts defaulted to 101, which nothing has ever used.

Give it the values its callers actually want, and the wrapper and the
boilerplate in `rayfunction_stops` both go away.

Net 27 lines fewer, and the products of `linearize` remain identical to
main across all 37 arrays.
@roytsmart
roytsmart merged commit 772540a into main Aug 28, 2026
12 checks passed
@roytsmart
roytsmart deleted the refactor/share-stop-solve branch August 28, 2026 15:29
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