Skip to content

Centre the oversampled model grid on the detector pixels - #161

Merged
almilder merged 9 commits into
mainfrom
fix/pixel-centered-model-grid
Oct 13, 2026
Merged

almilder merged 9 commits into
mainfrom
fix/pixel-centered-model-grid

Conversation

@almilder

@almilder almilder commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Replaces #160.

Summary

With points_per_pixel > 1, the model was evaluated on a fine wavelength grid that spanned the first and last detector pixel centres and was then binned in groups of points_per_pixel. Those groups are not centred on the pixels, so the binned model was shifted relative to the data by up to half a pixel.

The fine grid is now built from the detector pixel edges: each pixel is split into points_per_pixel equal cells and sampled at the cell midpoints, so every group is centred on its pixel.

All wavelength grids now come from one module, tsadar/utils/detector_grid.py. The oversampled uniform grid, the ARTS detector-bin quadrature and the data-prep resolution units all start from the same pixel edges.

This is a fix, not a breaking change: the shift is at most half a pixel, so refitting gives values very close to an earlier fit.

Changes

Grid construction

  • New tsadar/utils/detector_grid.py holds detector_edges_from_centers and grouped_detector_edges (moved from calibration.py, which still re-exports them), plus two new functions:
    • oversampled_axis(centers, points_per_pixel) builds the fine grid.
    • electron_spectrum_is_detector_binned(config) decides between the detector-bin quadrature and the uniform grid. FitModel and prepare_data both call it.
  • The module is numpy-only and lives in utils so that importing tsadar.data.calibration does not load tsadar.core, JAX and the forward model.
  • FormFactor builds its axis with oversampled_axis and raises if npts is not a multiple of points_per_pixel.
  • _ensure_angular_detector_edges and irf.py use the shared edge function instead of their own copies.

Pixel counts

  • prepare_data sizes the fine grid from the length of the wavelength axis instead of CCDsize[1].
  • calc_series.py sizes it from CCDsize[0] for every spectype. loadData returns every data type as [wavelength, time/space/angle], so CCDsize[0] is the wavelength pixel count; the old CCDsize[1] for non-angular decks only worked because the CCD is square.
  • The spectrometer IRF bins onto npts // points_per_pixel pixels, the same grouping the grid uses, instead of CCDsize[0].

Angular data with a partial final wavelength unit

When lam_res_unit does not divide the number of wavelength pixels:

  • With the detector-bin quadrature (2D distribution function, resonance_quadrature enabled), the leftover pixels are kept as a narrower final unit and integrated over its true width, as before (Integrate unresolved ARTS2D resonances into detector bins #143).
  • On the uniform-grid path, the leftover pixels are dropped so every unit has the same width, and a warning names the pixels and wavelength range removed. Example: lam_res_unit: 5 on 1024 pixels gives 204 units instead of 205.
  • On the uniform-grid path, a lam_res_unit larger than the axis raises a ValueError.

Instrument response

  • add_electron_IRF and add_ion_IRF bin the returned wavelength axis whenever they bin the spectrum. Before, with detector_specs.norm > 0 they returned a pixel-length spectrum with a fine-length axis.
  • The covariance throughput correction in loss_function.py uses the binned model axis directly instead of rebuilding it with linspace.

Behaviour notes

  • points_per_pixel: 1 gives the same axis to about 1e-13 nm. It is no longer bit-identical, because the axis is now computed from edges.
  • The wavelength range and the angular axis must be strictly increasing; the shared edge function raises otherwise.
  • The ARTS CCDsize layout is not changed here. After data prep it is [angle, wavelength] for ARTS only, and six angular tests build their dummy data as [wavelength, angle], which matches only because the fixtures are square. That is left for the ARTS work.

Tests

  • New in test_irf_area.py: oversampled_axis on uniform and non-uniform axes and with invalid points_per_pixel; prepare_data keeping the partial unit for a 2D distribution function and dropping it for 1D.
  • New in test_mcmc.py: test_binned_model_axis_matches_the_detector_pixel_centers (points_per_pixel 1, 2, 5).
  • test_1d and test_arts1d_forward_pass compare against references generated on the old grid (ThryE-1d.npy, ThryE-arts1v.npy). Each test shrinks its wavelength range so the new grid lands on the old points. The references are unchanged.
  • Tests that set npts from CCDsize[1] now use CCDsize[0]. The fixtures are square, so the values are the same.
  • Local run (Windows, CPU), affected tests only: test_irf_area.py, forward test_1d and test_angular_1v, test_sinogram, test_arts2d_spectral_terms, test_spherical_harmonics, test_unresolved_arts2d, test_noncollective, and the test_mcmc.py axis and throughput tests all pass.
  • test_arts2d_marginal.py has two failures locally that are test_arts2d_marginal normalization checks fail on Windows CPU: 2e-15 absolute tolerance is at machine precision #157 (a 2e-15 tolerance on Windows CPU); they fail the same way without this PR.
  • Not run locally: the inverse fit tests, the GPU-only angular fits and the full suite. CI covers the full suite.

Release

No label: merging cuts a patch release (v0.4.1).

🤖 Generated with Claude Code

Avi and others added 6 commits October 8, 2026 16:00
With points_per_pixel > 1 the fine grid spanned the pixel centres, so binning
it in groups of points_per_pixel shifted the model by up to half a pixel.
Extend the grid by the half-pixel overhang so each group is centred on its pixel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nal ARTS wavelength unit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…are handling

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The oversampled model grid, the forward ARTS detector edges and the data-prep
resolution-unit edges now all come from tsadar/core/instrument/detector_grid.py.
The fine grid is the midpoints of points_per_pixel equal cells inside each
pixel, which reproduces the pixel-centred grid to roundoff.

- FormFactor builds its axis with oversampled_axis and rejects an npts that is
  not a multiple of points_per_pixel.
- The spectrometer IRF bins onto npts // points_per_pixel pixels, the same
  grouping the grid uses, instead of CCDsize[0].
- prepare_data drops a partial final wavelength unit only on the uniform-grid
  path; the detector-bin quadrature keeps it at its true width. A lam_res_unit
  larger than the axis raises on the grid path.
- The covariance throughput correction uses the binned model axis directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
loadData returns every data type as [wavelength, time/space/angle], so
CCDsize[0] is the wavelength pixel count. The non-angular forward path sized
npts from CCDsize[1], which only matched because the CCD is square.

- calc_series uses CCDsize[0] for every spectype.
- Tests that size npts the same way use CCDsize[0]; the fixtures are square,
  so the values are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@ergodic-reviewer ergodic-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

One blocking regression: the production-recovery configurations have not been migrated to the new sampling contract and now fail during diagnostic construction. See the inline finding.

Postprocessing: the existing forward plotting/NetCDF path and inverse fit/diagnostic artifact path remain reachable and consume the corresponding model or prepared-data axes; this grid correction does not require a separate analysis pipeline.

Static review only; I did not execute tests or postprocessing. The latest inspected CPU check was still running; the GPU check was skipped.

h1 = 1024 # 1024
lamAxis = jnp.linspace(lambda_range[0], lambda_range[1], npts)
# lambda_range is the first and last pixel centers; each pixel holds points_per_pixel grid points
if npts % points_per_pixel:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P2] Migrate the production-recovery decks to the new sampling contract

tests/test_physics/test_production_recovery.py::_recovery_config inherits points_per_pixel: 10 from tests/configs/1d-defaults.yaml, then sets npts to 1024 or 2048 without updating that field. Both EPW/IAW recovery cases therefore hit this new ValueError while constructing ThomsonScatteringDiagnostic, before reaching the recovery assertions. These cases are included in the PR's pytest -m "not slow" tests/ workflow. The slow ARTS recovery deck in tests/test_physics/test_arts_recovery.py::_arts_config similarly inherits 10 and sets npts=64.

Please migrate these callers along with the constructor change, keeping npts, points_per_pixel, and the intended detector-bin count consistent. For the 1D recovery test, preserve its 128 detector pixels while refining the samples within each pixel. Removing only this check is insufficient: _irfs_from_config now also derives the output pixel count from npts // points_per_pixel.

…e decision

- detector_grid moves to tsadar/utils so the data layer can import it without
  loading tsadar.core, JAX and the forward model.
- electron_spectrum_is_detector_binned(config) is one helper used by both
  FitModel and prepare_data, replacing the copy of the condition in data prep.
- prepare_data prints a warning naming the wavelength pixels it drops when
  lam_res_unit does not divide the axis on the uniform-grid path.
- add_electron_IRF and add_ion_IRF bin the returned wavelength axis whenever
  they bin the spectrum, so the axis is the detector pixel centers for any
  detector_specs.norm.
- irf.py uses detector_edges_from_centers instead of its own edge function.
- test_1d shrinks its wavelength range onto the grid ThryE-1d.npy was
  generated on, as test_arts1d_forward_pass does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@ergodic-reviewer ergodic-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Request changes: the shared detector-edge refactor introduces a JIT failure in the angular IRF (inline finding).

The previous sampling-contract finding also remains at this head: the production-recovery decks still inherit points_per_pixel: 10 while setting npts to 1024/2048 (1D) or 64 (ARTS), triggering the new constructor check before recovery can run. Please migrate these callers while preserving their intended detector-bin counts.

Postprocessing: existing forward plots/NetCDF output and inverse fit plots, checkpoints, and angular diagnostic artifacts cover the changed spectra and axes; no separate analysis pipeline is needed. The compiled angular workflow is blocked upstream by the IRF regression.

Static review only; I did not execute tests or postprocessing. The latest inspected CPU check was still running; the GPU check was skipped.

Comment thread tsadar/core/instrument/irf.py Outdated

edges = _bin_edges_from_centers(axis)
# the axis is a static detector coordinate, so the edges are built once in numpy
edges = jnp.asarray(detector_edges_from_centers(np.asarray(axis)), dtype=axis.dtype)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P1] Keep detector-edge construction outside the traced angular IRF

_gaussian_bin_response first applies jnp.asarray/jnp.ravel to axis. When add_ATS_IRF runs inside a JIT-compiled diagnostic or loss, that result is a tracer even though the original irf.ang_axis is a fixed NumPy calibration. The new np.asarray(axis) therefore raises TracerArrayConversionError for multi-angle spectra. The production LossFunction JIT-compiles its loss/value-and-gradient methods, and tests/test_inverse/test_angular_1v.py::test_arts1d_inverse explicitly compiles the diagnostic's loss with eqx.filter_jit, so angular fitting now stops on its first compiled evaluation. This also affects ARTS2D with resonance quadrature: disabling spectral blur does not skip this angular response.

Please either retain JAX-compatible edge arithmetic here or infer/cache the edges from the original NumPy calibration before entering the traced path. Add a small CPU JIT regression with more than one angular bin; the eager IRF tests and single-angle identity case do not exercise this failure.

The recovery decks inherited points_per_pixel: 10 from 1d-defaults.yaml while
setting npts by hand for a 128-pixel (or 64-bin) detector, which FormFactor now
rejects. They set points_per_pixel to match their npts: 8 and 16 for the 1D
recovery decks, 1 for the ARTS recovery deck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@ergodic-reviewer ergodic-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Request changes: the existing P1 angular-IRF JIT finding remains unchanged at this head. In tsadar/core/instrument/irf.py:54–59, jnp.asarray/jnp.ravel turn the axis into a tracer during JIT, so the subsequent np.asarray(axis) raises TracerArrayConversionError for multi-angle spectra. This blocks compiled angular fitting, including the eqx.filter_jit(truth_diagnostic) call in tests/test_physics/test_arts_recovery.py. Disabling spectral blur for resonance quadrature does not bypass the angular response. Keep this arithmetic JAX-compatible or compute the edges from the original NumPy calibration before tracing, and cover it with a small multi-angle CPU JIT regression. Referencing the existing inline thread rather than duplicating it.

The updated points_per_pixel settings in both recovery configurations address the previous sampling-contract finding.

Postprocessing: the existing forward plots/NetCDF output and inverse fit plots, checkpoints, and angular diagnostic artifacts cover the changed spectra and axes; no separate analysis path is needed. The angular workflow remains blocked upstream by the JIT regression.

Static review only; I did not execute tests or postprocessing.

The angular IRF converted its axis to numpy after a JAX operation, which
raises TracerArrayConversionError under jit. The edges are now built in numpy
first, and a test compiles add_ATS_IRF with jit.

- prepare.py and the tests import the edge helpers from
  tsadar.utils.detector_grid; calibration.py no longer re-exports them.
- The dropped-pixel notice in prepare_data is informational, since the default
  ARTS-1D deck triggers it on every fit.
- Docstrings for the uniform spectral response and oversampled_axis describe
  the uniform grid they rely on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@ergodic-reviewer ergodic-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No remaining material findings. The previously reported sampling-contract and angular-IRF JIT defects are addressed: the recovery decks now specify consistent sampling, and _gaussian_bin_response constructs edges from the static NumPy axis before traced operations. A multi-angle JIT regression has been added.

Postprocessing: the existing forward plots/NetCDF output and inverse fit plots, checkpoints, and angular diagnostic artifacts remain reachable and use the corresponding model or prepared-data axes. This grid correction does not need a separate analysis path; standalone postprocessing remains available.

Static review only; I did not execute tests or postprocessing. At the final check, CPU CI was still running and GPU CI was skipped.

@almilder
almilder merged commit eabb443 into main Oct 13, 2026
2 checks passed
@almilder
almilder deleted the fix/pixel-centered-model-grid branch October 13, 2026 17:19
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