Skip to content

v3.0.0

Choose a tag to compare

@roytsmart roytsmart released this 08 Oct 16:15
f01b04d

optika 3.0 changes how a SequentialSystem samples itself to build its models, so the value of every model it fits changes, and it removes a released keyword argument. It also requires Python 3.12 and named-arrays 2.14.

Upgrading from 2.11

  • Python 3.12 and named-arrays 2.14 are required (#249). On Python 3.11, pip keeps installing optika 2.11.1.
  • area_effective(seed=...) is gone. area_effective(), distortion(), vignetting() and linearize() all take seed_field and seed_pupil instead. Both default to 0, so every call is reproducible, where area_effective() used to draw afresh unless it was given a seed and linearize() always did. None now takes the centers of the cells rather than a fresh draw.
  • distortion() and vignetting() read field and pupil as the vertices of cells, as area_effective() and linearize() already did, so a grid of n points is n - 1 cells. Left out, they default to 12 × 12 normalized vertices (11 × 11 cells) rather than to grid_input.
  • PolynomialVignettingModel and PolynomialDistortionModel hold the corners of cells. coordinates_scene needs one more point along each field axis than there are measurements. Where inside each cell a measurement was made is the new coordinates_sample, which defaults to the cell centers.
  • linearize() carries the field stop. Images change wherever the scene extends beyond the field of view. linearize(field_stop=False) restores the old behavior.
  • Too few lit wavelengths raise ValueError. distortion(), vignetting() and linearize() fit a polynomial of degree n in wavelength only if at least n + 1 wavelengths bring some of the sampled field onto the sensor. With fewer, the fit was unconstrained and extrapolated wildly, even at the wavelengths that did have light.
  • Subclasses of AbstractImagingSensor must define clip_rays. ImagingSensor has it as a keyword-only field, True by default.
  • rayfunction_stops samples the edge of the field stop at 81 points rather than 21.
  • The values of the distortion, vignetting and effective-area models change, as described below.

Every model is fit to one stratified raytrace (#235 and #236, closes #234)

linearize() used to trace the system twice. The distortion and vignetting were fit to rays through the centers of the cells, with one pupil lattice shared by every field position, and the effective area was fit to rays drawn at random inside each cell. A fixed lattice has a bias set by where the edge of an aperture falls across it. In a dispersive system that is nearly a function of one field coordinate, so every field position in a column inherited the same bias, and the vignetting model came out in vertical bands.

Now a single trace draws one ray at a random point inside every field cell and every pupil cell, and draws the pupil afresh at every field position. All three models are fit to it. Given the same grids, degree and seeds, distortion(), vignetting() and area_effective() return exactly the models linearize() carries.

On the ESIS flight model (esis.flights.f1.optics.design_single), linearized at He I 584.33 Å, Mg X 609.79 Å and O V 629.73 Å with degree 1, on 21 × 21 field cells and 41 × 41 pupil cells:

v2.11.1 v3.0.0
vignetting residual, mean / max 0.0126 / 0.0343 0.0049 / 0.0210
share of the residual's variance which is constant down each column of the field 99.8% 16%
linearize() 10.1 s 6.6 s

The distortion is more accurate off the cell centers, not less. At degree 2 on 11 × 11 field and pupil cells, it is within 0.0072 pix of a fit by this version on 41 × 41 field cells and 81 × 81 pupil cells (0.0020 pix RMS), where v2.11.1's was within 0.017 pix (0.0051 pix RMS).

Also new:

  • linearize(degree_distortion=..., degree_vignetting=...), each falling back to degree, so that the vignetting can be fit at a lower degree than the distortion.
  • The models carry their cells. coordinates_scene holds the corners of the cells and coordinates_sample where they were sampled. The fits read the samples, and the plots mesh on the corners, since samples drawn at random are not a mesh. PolynomialVignettingModel.plot() leaves blank the cells the model was not fit over, as plot_residual() already did.

The trace leaves the field stop and the sensor open (#246, merged into #235)

The field stop and the sensor sit at or near an image of the object, where each cuts across the field in a step that no polynomial can follow. With the field drawn at random inside its cells, a cell drawn inside that step would be fit with whatever sliver of its beam got through. So the trace behind every model opens both apertures. The field stop's aperture is traced with active=False, and the sensor with the new ImagingSensor.clip_rays=False. Each keeps its shape but vignettes nothing. Then:

  • Fit domain: the models are fit over the field cells whose light lands on the sensor's pixels and whose center lies inside the half-light outline of the field stop (below).
  • Normalization: the vignetting is normalized, and the effective area averaged, over every field position inside that outline which passes any light, whether or not that light lands on the sensor. Every wavelength is therefore normalized over the same part of the field.

On ESIS, the effective area at He I 584 Å, whose image runs partly off the detector, rises 3.0%. It changes by +0.1% at Mg X and +0.3% at O V. These are the means over four seeds of each version, which spread by 0.1 to 0.5%.

The linearized system carries the field of view (#242 by @jacobdparker, merged into #235)

LinearSystem now blocks every scene cell outside the field of view before it sums over wavelength. Without that, the vignetting polynomial was extrapolated past the edge of the field, which in a slitless spectrograph can let one line's light into another line's window.

  • SequentialSystem.field_stop_polygon(wavelength=None) outlines the field of view from the stop rays already solved, so it costs no extra trace. Each vertex is the average of the rays through one point on the edge of the field stop, taken from around the edge of the pupil stop. That makes the polygon the half-light outline of the field stop, where half of the pupil clears it. A measured edge sits there too: esis measures a window edge as the midpoint of a fitted error-function step. With the edge sampled at 81 points, a round field stop's polygon falls 0.1% short of its area. At 21 points it fell 1.6% short, which on FURST, whose field stop is the solar disk, cut 12 arcsec inside the limb.
  • optika.radiometry.PolynomialFieldStopModel fits each vertex as a polynomial in wavelength, since a field stop behind a dispersive element sees a different part of the scene at each wavelength. LinearSystem evaluates it at each scene cell's own wavelength. ApertureFieldStopModel wraps a plain aperture, and AbstractFieldStopModel is the base of both.
  • LinearSystem.field_stop accepts an aperture, as before, or a field-stop model. LinearSystem.field_stop_ gives either one as a model.
  • LinearSystem.footprint(wavelength, num=None) maps the outline of the field stop through the distortion: where each wavelength's window lands on the detector.

Blocking at the half-light outline keeps the total flux right across a soft edge, to first order, but images that soft edge as a hard one. On ESIS the soft edge is about a pixel wide.

Seeds and reproducibility (#235)

  • seed_field and seed_pupil seed two separate streams, so either grid can be redrawn while the other stays where it was. None takes the centers of that grid's cells, which lays the field out on a regular grid for a model that is going to be drawn rather than integrated. seed_pupil=None reuses one pupil lattice at every field position, which brings back the banding.
  • The order of the field and pupil axes no longer depends on the hash seed. It came from subtracting one set from another, so the axes could come back transposed in another process, and a seeded area_effective() could draw different points from one run to the next. They now follow the order of the grid.

Other fixes

  • Aperture wires close (#235). wire() of a polygonal or circular-sector aperture always returns num samples and ends where it began. In v2.11.1, floating-point rounding dropped the closing sample for some combinations of sides and samples, such as a heptagon at 61 samples. A wire with no more samples than sides never closed.
  • DXF files of uncertain objects draw their nominal value (#243). to_dxf() takes the nominal value of the object before anything is computed, so the rays of an uncertain system are traced only for its nominal value. Before, every sample was traced and then discarded, and which rays were drawn depended on how named-arrays reduced an uncertain mask.
  • Uncertain baffles linearize (#250). A baffle of uncertain size clips a different part of the beam in each sample without moving the stops. Linearizing such a system failed before named-arrays 2.14, and is now tested. LinearSystem.image() and backproject() still fail for an uncertain linear system, since na.regridding has no uncertain implementation yet (sun-data/named-arrays#267).

Requirements (#249)

named-arrays 2.14 tests each point against its own polygon without copying the vertices for every point (sun-data/named-arrays#265), so the workarounds #235 needed for that are gone. named-arrays has required Python 3.12 since 2.11, so optika now requires it too, and CI tests Python 3.12 and 3.13.

Citation (#248)

CITATION.cff carries the concept DOI, 10.5281/zenodo.23074621, which always resolves to the latest version. The README has a DOI badge, and the Citation section of the README and the docs explains the difference between the concept DOI and the DOIs of single versions.

Full Changelog: v2.11.1...v3.0.0