Skip to content

Add optika.zernikes and ZernikeSag#172

Open
jacobdparker wants to merge 6 commits into
fix/polygonal-aperture-boundsfrom
feature/zernike-sag
Open

Add optika.zernikes and ZernikeSag#172
jacobdparker wants to merge 6 commits into
fix/polygonal-aperture-boundsfrom
feature/zernike-sag

Conversation

@jacobdparker

Copy link
Copy Markdown
Contributor

Summary

Stacked on #171 (will retarget to main once it merges; the branches are independent in content).

  • optika.zernikes: Noll-indexed, RMS-normalized Zernike polynomials (noll(), zernike()) and their analytic gradients (zernike_gradient()), matching the convention of :cite:t:Noll1976 (reference added to refs.bib). The gradient uses an origin-safe series for R_n^m(rho)/rho, so it is exact at the center of the pupil.
  • optika.sags.ZernikeSag: a sag profile that perturbs a base sag with a sum of Zernike polynomials, for modeling measured or analytic figure error. Element i of coefficients is the Noll-j = i + 1 coefficient over the disk of the given radius. The analytic normal() combines the base gradient with the Zernike gradients; the ray intercept falls back to the inherited numeric solver.

Because the figure error perturbs the surface, the same object is seen consistently by both the geometric raytrace and (in an upcoming PR) the physical-optics propagation - a single source of truth for tolerancing.

Tests

  • noll() against the published index table; closed forms for j = 1, 2, 3, 4, 11.
  • Monte-Carlo orthonormality of the basis over the unit disk.
  • Gradients vs central differences.
  • ZernikeSag through the standard AbstractTestAbstractSag battery, plus a geometric check that Noll Z4 shifts the focus of a paraboloid by the analytic amount.

🤖 Generated with Claude Code

@codecov

codecov Bot commented Jun 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.37%. Comparing base (8247ef6) to head (6340326).
⚠️ Report is 2 commits behind head on fix/polygonal-aperture-bounds.

Additional details and impacted files
@@                        Coverage Diff                        @@
##           fix/polygonal-aperture-bounds     #172      +/-   ##
=================================================================
+ Coverage                          99.35%   99.37%   +0.02%     
=================================================================
  Files                                116      120       +4     
  Lines                               6003     6277     +274     
=================================================================
+ Hits                                5964     6238     +274     
  Misses                                39       39              
Flag Coverage Δ
unittests 99.37% <100.00%> (+0.02%) ⬆️

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/sags/_zernike.py Outdated
plt.legend()
"""

sag: None | AbstractSag = None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Kinda unfortunate this is called sag as well, if you refer to it, it has stutter: sag.sag. Is there a better word like base or sag_base or something that captures it without using the exact same word?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good call — renamed the attribute to base, so it now reads sag.base. Done in 8455e8d.

Comment thread optika/sags/_zernike.py
plt.figure()
na.plt.plot(position.y, z, axis="y", label="perturbed")
na.plt.plot(position.y, z_base, axis="y", label="base")
plt.legend()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I don't see any difference between the two curves in this plot.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You're right, and it was a real bug in the example: the perturbation is coma (Noll index 8), which varies as cos(phi) and is therefore identically zero along the y axis — exactly the slice the plot used, so the curves landed on top of each other. Switched the slice to vary along x, where the coma term is nonzero (~2.8 mm peak difference). Fixed in 8455e8d.

Comment thread optika/zernikes.py

def zernike(
j: int,
position: na.AbstractCartesian2dVectorArray,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I feel like position should be first, is it standard for this function to put j first?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed — there's no strong convention for j-first, and putting position first matches the other field-evaluation functions. Flipped both zernike() and zernike_gradient() to (position, j) and updated the call sites. Done in 8455e8d.

@jacobdparker jacobdparker force-pushed the fix/polygonal-aperture-bounds branch from 1759e2b to 9479771 Compare June 15, 2026 17:58
@jacobdparker jacobdparker force-pushed the feature/zernike-sag branch from 4f496ec to 8455e8d Compare June 15, 2026 17:58
@jacobdparker jacobdparker force-pushed the fix/polygonal-aperture-bounds branch from 9479771 to 9fd2c2e Compare July 2, 2026 23:29
jacobdparker and others added 5 commits July 2, 2026 17:30
AbstractPolygonalAperture.bound_lower/bound_upper were computed from the
raw vertices, while __call__ and wire() apply the aperture's internal
transformation. For apertures with a nonzero internal transformation
(e.g. a decentered grating aperture), any consumer of the bounding box
(stratified wavefield sampling, sensor pixel grids, pupil
normalization) saw the wrong region of the surface.

EllipticalAperture had the related defect of transforming only the
bounding-box corner points, which is incorrect under rotation; its
bounds are now derived from the wire, matching CircularSectorAperture.

RectangularAperture.__call__ expressed the rectangle in terms of
bound_lower/bound_upper while also inverse-transforming the position,
which double-counted the internal transformation once the bounds
included it; it now tests against half_width directly.

The generic bound tests now assert that the bounds enclose the wire and
that the wire centroid is accepted by active, non-inverted apertures
(nominal values, with a small tolerance for interpolation round-off,
since uncertain parameters may be redrawn between evaluations).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Address review feedback: deriving EllipticalAperture.bound_lower/upper from
wire().min()/max() sampled the boundary at a finite number of points, so the
true extent between samples was underestimated whenever the internal
transformation rotated the ellipse off the coordinate axes.

Replace the wire sampling with a closed form. A boundary point is
p(t) = c + a*e_a*cos(t) + b*e_b*sin(t), where c is the transformed center and
e_a, e_b are the transformed local axes, so the extent along any world
component is sqrt((a*e_a)^2 + (b*e_b)^2). This is exact under rotation and
reduces to the axis-aligned (radius.x, radius.y) box when untransformed.

CircularAperture bounds are already analytic and rotation-invariant, so they
are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…adients

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…profile

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Rename the ZernikeSag base-profile attribute from `sag` to `base` to avoid
  the `sag.sag` stutter when referring to it.
- Fix the ZernikeSag docstring example: the coma term (Noll index 8) varies as
  cos(phi) and is identically zero along the y axis, so the original y-slice
  showed the perturbed and base curves on top of each other. Slice along x
  instead, where the perturbation is visible.
- Put `position` first in the signatures of `zernike()` and
  `zernike_gradient()`, matching the convention of the other field-evaluation
  functions; update call sites and tests accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jacobdparker jacobdparker force-pushed the feature/zernike-sag branch from 8455e8d to ed9a198 Compare July 2, 2026 23:30
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

2 participants