spectro-kernel 0.2.0__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/CHANGELOG.md +80 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/PKG-INFO +1 -1
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/tutorials/long-slit-reduction.md +5 -2
- spectro_kernel-0.2.1/src/spectro_kernel/algorithms/extraction/boxcar.py +335 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/_easyspec_apply.py +39 -3
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/version.py +1 -1
- spectro_kernel-0.2.1/tests/unit/test_easyspec_apply_staging.py +156 -0
- spectro_kernel-0.2.1/tests/unit/test_extract_boxcar.py +171 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/.gitignore +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/LICENSE +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/README.md +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/concepts/algorithms.md +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/concepts/architecture.md +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/concepts/data-types.md +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/concepts/pipelines.md +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/contributing.md +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/cookbook/bess-dashboard.md +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/cookbook/multi-star-viewer.md +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/cookbook/web-playground.md +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/gen_catalogue.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/getting-started.md +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_cyg_ew_timeseries.png +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_cyg_halpha_phase_stack.png +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_cyg_halpha_timeseries.png +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_cyg_overlay.png +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_cyg_pc1_vs_phase.png +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_cyg_pca_2d.png +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_cyg_pca_3d.png +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_cyg_periodogram.png +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_cyg_phase_folded.png +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_cyg_rv_timeseries.png +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_cyg_similarity.png +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_cyg_similarity_date.png +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_dra_halpha_phase_stack.png +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_dra_pc1_vs_phase.png +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_dra_pca_2d.png +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_dra_pca_3d.png +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_dra_periodogram.png +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_dra_phase_folded.png +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_dra_rv_timeseries.png +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_dra_similarity_date.png +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_dra_similarity_phase.png +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/index.md +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/notebooks/alpha-cyg-time-series.md +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/notebooks/alpha-dra-binary-period.md +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/notebooks/aurora-line-monitor.md +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/notebooks/be-star-variability.md +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/notebooks/claude-mcp-end-to-end.md +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/notebooks/exoplanet-transit-rv.md +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/notebooks/full-reduction-walkthrough.md +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/notebooks/native-vs-easyspec-showdown.md +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/notebooks/your-first-sb2.md +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/reference.md +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/tutorials/add-an-algorithm.md +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/tutorials/analyse-a-spectrum.md +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/tutorials/discover-the-catalogue.md +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/tutorials/first-spectrum.md +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/usage/cli.md +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/usage/library.md +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/usage/mcp.md +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/why.md +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/playground/README.md +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/pyproject.toml +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/__init__.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/adapters/__init__.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/adapters/easyspec.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/__init__.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/_common.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/advanced/__init__.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/advanced/aperture_photometry.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/advanced/disentangle_sb2.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/catalogs/__init__.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/catalogs/gaia.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/catalogs/simbad.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/catalogs/vizier.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/continuum/__init__.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/continuum/compare_normalisations.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/continuum/normalize_edges.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/continuum/normalize_max.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/continuum/normalize_percentile.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/continuum/normalize_polynomial.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/continuum/normalize_region.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/continuum/subtract_continuum.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/corrections/__init__.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/corrections/air_vacuum.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/corrections/barycentric.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/corrections/doppler_shift.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/corrections/extinction_correct_easyspec.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/corrections/fit_telluric_scaling.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/corrections/flux_calibrate_easyspec.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/corrections/remove_telluric.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/corrections/synth_telluric.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/embedding/__init__.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/embedding/embed_band_power.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/embedding/embed_continuum_subtracted.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/embedding/embed_lick_indices.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/embedding/embed_log_lambda.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/embedding/embed_pretrained.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/embedding/embed_remote.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/embedding/embed_spectrum.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/embedding/embed_wavelets.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/exports/__init__.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/exports/export_csv.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/exports/export_fits.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/exports/export_fits_bess.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/exports/export_hdf5.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/exports/export_votable.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/extraction/__init__.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/extraction/easyspec_extract.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/extraction/sky_lateral_bands.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/io/__init__.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/io/read_ascii.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/io/read_echelle.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/io/read_fits.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/io/read_sdss.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/io/read_votable.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/lines/__init__.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/lines/_profiles.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/lines/catalogs.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/lines/compare_line_fits.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/lines/detect.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/lines/equivalent_width.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/lines/fit_gaussian.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/lines/fit_lorentzian.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/lines/fit_voigt.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/quality/__init__.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/quality/compare_snr_methods.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/quality/snr_der.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/quality/snr_edge.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/quality/snr_linear_fit.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/__init__.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/_easyspec_helpers.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/bias_combine.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/clip_cosmic_rays.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/dark_subtract.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/denoise_2d.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/easyspec_bias.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/easyspec_cosmic_ray.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/easyspec_dark.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/easyspec_flat.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/easyspec_flat_normalize.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/easyspec_subtract_bias.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/easyspec_subtract_dark.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/extract_spectrum_sum.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/flat_normalize.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/geometry.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/subtract_sky_2d.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/wavelength_calibrate.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/rv/__init__.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/rv/cross_correlate.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/rv/fit_keplerian_orbit.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/rv/measure.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/rv/precision_bouchy.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/smoothing/__init__.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/smoothing/compare_smoothings.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/smoothing/smooth_gaussian.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/smoothing/smooth_savgol.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/stacking/__init__.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/stacking/merge_echelle_orders.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/stacking/stack.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/timeseries/__init__.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/timeseries/lomb_scargle.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/timeseries/phase_fold.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/transforms/__init__.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/transforms/clip_sigma.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/transforms/combine_arithmetic.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/transforms/extract_region.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/transforms/mask_range.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/transforms/resample.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/transforms/resample_flux_conserving.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/viz/__init__.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/viz/plot_3d_surface.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/viz/plot_animation.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/viz/plot_dynamic_spectrum.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/viz/plot_plotly.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/wavelength_calibration/__init__.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/wavelength_calibration/easyspec_wavelength.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/wavelength_calibration/in_situ.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/wavelength_calibration/solar.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/base.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/cli.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/embeddings.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/errors.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/io/__init__.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/io/ascii.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/io/fits.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/io/votable.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/pipeline.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/presets/__init__.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/presets/catalog/analysis/balmer_quick.yaml +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/presets/catalog/analysis/embed_quick.yaml +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/presets/catalog/analysis/quality_report.yaml +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/presets/catalog/analysis/rv_quick.yaml +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/presets/catalog/analysis/snr_check.yaml +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/presets/catalog/analysis/time_series_overview.yaml +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/presets/catalog/reduction/full_reduction_easyspec.yaml +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/presets/loader.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/py.typed +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/registry.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/types/__init__.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/types/catalog.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/types/context.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/types/enums.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/types/history.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/types/image.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/types/line.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/types/spectrum.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/types/timeseries.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_mcp/__init__.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_mcp/__main__.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_mcp/auth.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_mcp/auto_tools.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_mcp/observability.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_mcp/py.typed +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_mcp/server.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_mcp/session.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_mcp/url_safety.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/conftest.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/reference/conftest.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/reference/data/.gitkeep +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/reference/data/README.md +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/reference/data/sun/sun_reference_stis_002.fits +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/reference/data/vega/alpha_lyr_stis_011.fits +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/reference/test_known_answers.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/reference/test_sun.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/reference/test_vega.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_base.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_cli.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_combine.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_combine_arithmetic.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_continuum.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_corrections.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_denoise_2d.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_easyspec_wrappers.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_embedding.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_embedding_pretrained.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_embedding_remote_lick.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_embedding_tier1.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_export_fits_bess.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_geometry_corrections.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_io.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_line_profiles.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_lines.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_mcp.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_mcp_production.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_mcp_security.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_misc_algorithms.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_no_circular_imports.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_normalize_to_region.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_pipeline.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_quality.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_read_sdss.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_registry.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_sky_lateral_bands.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_smoothing.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_timeseries.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_transforms.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_types.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_viz.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_wavelength_calibration_in_situ.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_wavelength_calibration_solar.py +0 -0
- {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/website/README.md +0 -0
|
@@ -6,6 +6,86 @@ Until `1.0.0` the public API may change between minor versions.
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.1] — 2026-06-09
|
|
10
|
+
|
|
11
|
+
End-to-end aurora-parity patch. Diagnosed and validated bit-exactly on
|
|
12
|
+
real M42 long-slit data: with these two changes a downstream aurora
|
|
13
|
+
reduction pipeline running entirely on spectro-kernel matches its
|
|
14
|
+
science-core baseline at **Pearson 0.99996** (median |Δ| 0.82 %),
|
|
15
|
+
where v0.2.0 alone scored 0.107 (output silently shifted by +32768 ADU
|
|
16
|
+
on every step).
|
|
17
|
+
|
|
18
|
+
### Fixed — uint16 ``BZERO``/``BSCALE`` leak in the easyspec staging helpers (BUG)
|
|
19
|
+
|
|
20
|
+
The shared helper ``stage_target`` in
|
|
21
|
+
[`reduction/_easyspec_apply.py`](src/spectro_kernel/algorithms/reduction/_easyspec_apply.py)
|
|
22
|
+
wrote ``ctx.image.data`` as float64 on a new ``PrimaryHDU`` and copied
|
|
23
|
+
the source header verbatim. When the source frame originated from a
|
|
24
|
+
**uint16 raw file** (``BITPIX=16``, ``BZERO=32768``, ``BSCALE=1`` —
|
|
25
|
+
astropy's convention), the scaling keywords landed on the float64 HDU
|
|
26
|
+
and astropy then re-applied them on read-back, **silently adding 32768
|
|
27
|
+
ADU to every pixel**. The companion ``build_corrected_imageframe``
|
|
28
|
+
re-attached the same scaling block to the corrected ``ImageFrame``, so
|
|
29
|
+
the next pipeline step that re-staged it inherited the leak.
|
|
30
|
+
|
|
31
|
+
The fix strips a small set of reserved / scaling keywords —
|
|
32
|
+
``SIMPLE, BITPIX, EXTEND, BZERO, BSCALE, BLANK, END, NAXIS*, PCOUNT,
|
|
33
|
+
GCOUNT, XTENSION`` — before they reach either the staged HDU or the
|
|
34
|
+
header re-attached downstream. astropy re-derives the structural keys
|
|
35
|
+
from the data itself; the non-structural metadata (``EXPTIME``,
|
|
36
|
+
``OBSERVER``, etc.) survives.
|
|
37
|
+
|
|
38
|
+
Affects all four easyspec apply wrappers in one shot:
|
|
39
|
+
``subtract_bias_easyspec``, ``subtract_dark_easyspec``,
|
|
40
|
+
``flat_normalize_easyspec``, ``extract_spectrum_easyspec``. Native-numpy
|
|
41
|
+
algorithms (``dark_subtract``, ``flat_normalize``, ``subtract_sky_2d``,
|
|
42
|
+
the v0.2.0 geometry / denoising suite) were never affected — they
|
|
43
|
+
operate directly on ``ctx.image.data`` without round-tripping through a
|
|
44
|
+
FITS file.
|
|
45
|
+
|
|
46
|
+
5 regression tests added in
|
|
47
|
+
[`tests/unit/test_easyspec_apply_staging.py`](tests/unit/test_easyspec_apply_staging.py).
|
|
48
|
+
|
|
49
|
+
### Added — ``extract_spectrum_boxcar`` (literature aperture extraction)
|
|
50
|
+
|
|
51
|
+
New algorithm in
|
|
52
|
+
[`algorithms/extraction/boxcar.py`](src/spectro_kernel/algorithms/extraction/boxcar.py).
|
|
53
|
+
Same easyspec tracing step as ``extract_spectrum_easyspec``, but
|
|
54
|
+
extraction is a **pure-numpy aperture sum** (clamped to the detector
|
|
55
|
+
bounds on every column).
|
|
56
|
+
|
|
57
|
+
Why a new brick: ``extract_spectrum_easyspec`` delegates the extraction
|
|
58
|
+
to ``easyspec.extracting``, whose per-column internal sky window
|
|
59
|
+
crashes with ``ValueError: broadcast (W,) vs (S,)`` when the aperture
|
|
60
|
+
half-width is wide (≥ a few hundred px) and the trace sits near a
|
|
61
|
+
detector edge — the typical aurora / extended-source geometry. The new
|
|
62
|
+
brick keeps easyspec's argmax tracing (the trace itself is fine) but
|
|
63
|
+
performs the aperture sum in numpy, with ``max(0, …)`` / ``min(rows,
|
|
64
|
+
…)`` clipping on every column so it is **edge-safe by construction**.
|
|
65
|
+
|
|
66
|
+
Supports two weightings:
|
|
67
|
+
|
|
68
|
+
- ``"tophat"`` — unweighted box sum (standard IRAF ``apall``).
|
|
69
|
+
- ``"gaussian"`` — profile-weighted Horne 1986 estimator (the average
|
|
70
|
+
column profile is fit with ``astropy.modeling`` to derive the
|
|
71
|
+
weights).
|
|
72
|
+
|
|
73
|
+
``shift_y_pixels = 0`` disables the background subtraction (pure
|
|
74
|
+
column sum); a positive value samples sky from two symmetric flanking
|
|
75
|
+
windows. References: **Horne 1986, PASP 98, 609** ; **Tody 1986, Proc.
|
|
76
|
+
SPIE 627, 733** (IRAF ``apall`` / ``aptrace``). easyspec is imported
|
|
77
|
+
lazily inside ``run()`` (v0.1.5+ pattern).
|
|
78
|
+
|
|
79
|
+
6 tests in
|
|
80
|
+
[`tests/unit/test_extract_boxcar.py`](tests/unit/test_extract_boxcar.py),
|
|
81
|
+
including the wide-aperture edge case that motivated the brick.
|
|
82
|
+
|
|
83
|
+
### Numbers
|
|
84
|
+
|
|
85
|
+
**98 algorithms** registered (was 97). 254 tests pass (11 new), 3
|
|
86
|
+
skipped. ``ruff check`` clean, ``mkdocs build --strict`` clean,
|
|
87
|
+
``twine check --strict`` clean.
|
|
88
|
+
|
|
9
89
|
## [0.2.0] — 2026-06-09
|
|
10
90
|
|
|
11
91
|
A library expansion focused on long-slit reduction (aurora, stellar,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: spectro-kernel
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: A shared catalogue of astronomical spectroscopy algorithms, composable into reproducible pipelines, usable as a Python library, a CLI, or an MCP server.
|
|
5
5
|
Project-URL: Homepage, https://github.com/matthieulel/spectro-kernel
|
|
6
6
|
Project-URL: Documentation, https://matthieulel.github.io/spectro-kernel/
|
|
@@ -63,8 +63,11 @@ steps:
|
|
|
63
63
|
band_below_hi: 160
|
|
64
64
|
- algorithm: subtract_sky_2d
|
|
65
65
|
params: {trace_row: 120, trace_half_width: 6, sky_offset: 4, sky_half_width: 10}
|
|
66
|
-
-
|
|
67
|
-
|
|
66
|
+
# extract_spectrum_boxcar is the edge-safe boxcar for extended sources
|
|
67
|
+
# (aurora, nebulae). For a stellar point source, extract_spectrum_sum
|
|
68
|
+
# or extract_spectrum_easyspec are also valid choices.
|
|
69
|
+
- algorithm: extract_spectrum_boxcar
|
|
70
|
+
params: {trace_method: "argmax", trace_half_width: 6, extraction_weights: "tophat", shift_y_pixels: 0}
|
|
68
71
|
|
|
69
72
|
# ── 4. In-situ wavelength calibration from the simultaneously-acquired
|
|
70
73
|
# sky reference (no arc lamp needed).
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Algorithm: aperture (boxcar / optimal) extraction of a 1D spectrum.
|
|
2
|
+
|
|
3
|
+
Pairs with :class:`ExtractSpectrumEasyspec` — same tracing step, different
|
|
4
|
+
**extraction**. Where ``extract_spectrum_easyspec`` delegates the
|
|
5
|
+
extraction to ``easyspec.extracting`` (whose internal per-column sky
|
|
6
|
+
window crashes with ``ValueError: broadcast (W,) vs (S,)`` for wide
|
|
7
|
+
apertures near a detector edge), this brick keeps easyspec's argmax
|
|
8
|
+
tracing but extracts with a pure-numpy aperture sum that is **edge-safe
|
|
9
|
+
by construction** (``max(0, …)`` / ``min(rows, …)`` clipping on every
|
|
10
|
+
column).
|
|
11
|
+
|
|
12
|
+
The right tool for **extended sources filling the slit** — aurora,
|
|
13
|
+
nebulae, the Sun's photospheric profile in a daylight slit shot —
|
|
14
|
+
where the aperture half-width is large (≥ 100 px) and the trace sits
|
|
15
|
+
within a few hundred rows of either edge.
|
|
16
|
+
|
|
17
|
+
The wavelength axis is the pixel index (calibrate downstream with
|
|
18
|
+
``wavelength_calibrate_polynomial`` /
|
|
19
|
+
``wavelength_calibration_in_situ`` / ``wavelength_calibration_solar``).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import contextlib
|
|
25
|
+
import io
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
|
|
30
|
+
from ...base import AlgorithmOutput, BaseAlgorithm
|
|
31
|
+
from ...errors import InvalidParameterError
|
|
32
|
+
from ...registry import register_algorithm
|
|
33
|
+
from ...types import AlgorithmCategory, Spectrum1D, WorkContext
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _boxcar_extract(
|
|
37
|
+
image: np.ndarray,
|
|
38
|
+
trace_coefficients: np.ndarray,
|
|
39
|
+
trace_half_width: int,
|
|
40
|
+
extraction_weights: str,
|
|
41
|
+
shift_y_pixels: int,
|
|
42
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
43
|
+
"""Aperture (boxcar / Horne) extraction around a per-column polynomial trace.
|
|
44
|
+
|
|
45
|
+
Pure-numpy and edge-safe: every per-column slice is clamped to
|
|
46
|
+
``[0, image.shape[0]]``, so apertures wider than the available row
|
|
47
|
+
span do not raise — they just integrate fewer rows there.
|
|
48
|
+
|
|
49
|
+
``shift_y_pixels == 0`` disables the background subtraction (the
|
|
50
|
+
flanking windows collapse to length 0 and contribute zero).
|
|
51
|
+
|
|
52
|
+
Parameters
|
|
53
|
+
----------
|
|
54
|
+
image
|
|
55
|
+
2D detector frame, rows × columns.
|
|
56
|
+
trace_coefficients
|
|
57
|
+
Polynomial coefficients of the trace y(x), highest order first
|
|
58
|
+
(``numpy.poly1d`` convention).
|
|
59
|
+
trace_half_width
|
|
60
|
+
Aperture half-width in rows.
|
|
61
|
+
extraction_weights
|
|
62
|
+
``"tophat"`` for the unweighted box sum (the standard IRAF
|
|
63
|
+
``apall`` recipe) or ``"gaussian"`` for the profile-weighted
|
|
64
|
+
Horne 1986 estimator.
|
|
65
|
+
shift_y_pixels
|
|
66
|
+
Total height of the sky window flanking the aperture (above and
|
|
67
|
+
below). ``0`` disables sky subtraction.
|
|
68
|
+
|
|
69
|
+
Returns
|
|
70
|
+
-------
|
|
71
|
+
(flux, flux_error, sky)
|
|
72
|
+
Three 1D arrays of length ``image.shape[1]``.
|
|
73
|
+
|
|
74
|
+
References
|
|
75
|
+
----------
|
|
76
|
+
- Horne 1986, PASP 98, 609 — optimal extraction (the tophat is the
|
|
77
|
+
standard unweighted variant).
|
|
78
|
+
- IRAF ``apall`` / ``aptrace`` (Tody 1986, SPIE 627, 733).
|
|
79
|
+
"""
|
|
80
|
+
from astropy.modeling.fitting import LevMarLSQFitter # noqa: PLC0415
|
|
81
|
+
from astropy.modeling.models import Gaussian1D # noqa: PLC0415
|
|
82
|
+
|
|
83
|
+
nx = image.shape[1]
|
|
84
|
+
nrows = image.shape[0]
|
|
85
|
+
xvals = np.arange(nx)
|
|
86
|
+
trace_center = np.polyval(trace_coefficients, xvals)
|
|
87
|
+
median_trace = np.median(trace_center)
|
|
88
|
+
ymin = int(median_trace - shift_y_pixels)
|
|
89
|
+
ymax = int(median_trace + shift_y_pixels)
|
|
90
|
+
|
|
91
|
+
# Per-column background from the two windows flanking the aperture.
|
|
92
|
+
background1 = np.zeros(nx, dtype=np.float64)
|
|
93
|
+
background2 = np.zeros(nx, dtype=np.float64)
|
|
94
|
+
for i in range(nx):
|
|
95
|
+
y_trace = int(trace_center[i])
|
|
96
|
+
# Edge-safe slices: clamp to detector bounds.
|
|
97
|
+
upper_lo = max(0, min(nrows, y_trace + trace_half_width))
|
|
98
|
+
upper_hi = max(0, min(nrows, ymax))
|
|
99
|
+
lower_lo = max(0, min(nrows, ymin))
|
|
100
|
+
lower_hi = max(0, min(nrows, y_trace - trace_half_width))
|
|
101
|
+
bg1 = image[upper_lo:upper_hi, i]
|
|
102
|
+
bg2 = image[lower_lo:lower_hi, i]
|
|
103
|
+
background1[i] = float(np.median(bg1)) if bg1.size > 0 else 0.0
|
|
104
|
+
background2[i] = float(np.median(bg2)) if bg2.size > 0 else 0.0
|
|
105
|
+
background = 0.5 * (background1 + background2)
|
|
106
|
+
|
|
107
|
+
full_height = 2 * trace_half_width
|
|
108
|
+
if extraction_weights == "gaussian":
|
|
109
|
+
# Build the average column profile from cutouts that span the
|
|
110
|
+
# full ±trace_half_width window (drop edge cuts).
|
|
111
|
+
full_cutouts: list[np.ndarray] = []
|
|
112
|
+
for i, y_trace_f in enumerate(trace_center):
|
|
113
|
+
y_trace = int(y_trace_f)
|
|
114
|
+
y_lo = y_trace - trace_half_width
|
|
115
|
+
y_hi = y_trace + trace_half_width
|
|
116
|
+
if 0 <= y_lo and y_hi <= nrows:
|
|
117
|
+
full_cutouts.append(image[y_lo:y_hi, i] - background[i])
|
|
118
|
+
if not full_cutouts:
|
|
119
|
+
raise InvalidParameterError(
|
|
120
|
+
"extraction_weights='gaussian' needs at least one column where "
|
|
121
|
+
"the full ±trace_half_width window fits inside the detector."
|
|
122
|
+
)
|
|
123
|
+
mean_profile = np.mean(np.asarray(full_cutouts), axis=0)
|
|
124
|
+
profile_x = np.arange(-trace_half_width, trace_half_width, dtype=np.float64)
|
|
125
|
+
fitter = LevMarLSQFitter(calc_uncertainties=True)
|
|
126
|
+
guess = Gaussian1D(
|
|
127
|
+
amplitude=float(np.max(mean_profile)),
|
|
128
|
+
mean=0.0,
|
|
129
|
+
stddev=float(trace_half_width) / 2.0,
|
|
130
|
+
)
|
|
131
|
+
with contextlib.suppress(Exception):
|
|
132
|
+
guess = fitter(guess, profile_x, mean_profile)
|
|
133
|
+
weights = np.asarray(guess(profile_x), dtype=np.float64)
|
|
134
|
+
weights_sum = float(np.sum(weights))
|
|
135
|
+
if weights_sum > 0.0:
|
|
136
|
+
weights = weights / weights_sum
|
|
137
|
+
else:
|
|
138
|
+
# Degenerate fit → fall back to tophat for robustness.
|
|
139
|
+
weights = np.ones(full_height, dtype=np.float64) / float(full_height)
|
|
140
|
+
elif extraction_weights == "tophat":
|
|
141
|
+
weights = np.ones(full_height, dtype=np.float64) / float(full_height)
|
|
142
|
+
else:
|
|
143
|
+
raise InvalidParameterError(
|
|
144
|
+
f"extraction_weights must be 'tophat' or 'gaussian', got "
|
|
145
|
+
f"{extraction_weights!r}."
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
flux = np.zeros(nx, dtype=np.float64)
|
|
149
|
+
flux_error = np.zeros(nx, dtype=np.float64)
|
|
150
|
+
weights_sq_sum = float(np.sum(weights ** 2)) or 1.0
|
|
151
|
+
for i, y_trace_f in enumerate(trace_center):
|
|
152
|
+
y_trace = int(y_trace_f)
|
|
153
|
+
y_lo = max(0, y_trace - trace_half_width)
|
|
154
|
+
y_hi = min(nrows, y_trace + trace_half_width)
|
|
155
|
+
cutout = image[y_lo:y_hi, i]
|
|
156
|
+
cutout_nobg = cutout - background[i]
|
|
157
|
+
if cutout_nobg.size == weights.size:
|
|
158
|
+
flux[i] = float(np.sum(cutout_nobg * weights) / weights_sq_sum)
|
|
159
|
+
flux_error[i] = float(
|
|
160
|
+
np.sqrt(np.sum(np.abs(cutout) * weights ** 2)) / weights_sq_sum
|
|
161
|
+
)
|
|
162
|
+
else:
|
|
163
|
+
# Edge column: fall back to an unweighted box sum on the
|
|
164
|
+
# available rows. Reports plain Poisson-like error.
|
|
165
|
+
flux[i] = float(np.sum(cutout_nobg))
|
|
166
|
+
flux_error[i] = float(np.sqrt(np.sum(np.abs(cutout))))
|
|
167
|
+
return flux, flux_error, background
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@register_algorithm(
|
|
171
|
+
"extract_spectrum_boxcar",
|
|
172
|
+
category=AlgorithmCategory.EXTRACTION,
|
|
173
|
+
version="1.0.0",
|
|
174
|
+
)
|
|
175
|
+
class ExtractSpectrumBoxcar(BaseAlgorithm):
|
|
176
|
+
"""Trace (easyspec) + pure-numpy aperture extraction on ``ctx.image``.
|
|
177
|
+
|
|
178
|
+
Tracing reuses ``easyspec.extraction.extraction().tracing`` (argmax
|
|
179
|
+
or moments per column, polynomial fit across columns) — the trace is
|
|
180
|
+
not the issue. **Extraction** is an edge-safe numpy aperture sum
|
|
181
|
+
around ``±trace_half_width`` of the per-column trace position, with
|
|
182
|
+
optional background subtraction from two flanking windows.
|
|
183
|
+
|
|
184
|
+
Choose this over ``extract_spectrum_easyspec`` when the source fills
|
|
185
|
+
much of the slit (aurora, nebulae) — easyspec's internal sky window
|
|
186
|
+
crashes for wide apertures near a detector edge. With
|
|
187
|
+
``shift_y_pixels = 0`` the background is zero and the algorithm
|
|
188
|
+
reduces to a pure column sum (the standard IRAF ``apall`` recipe).
|
|
189
|
+
|
|
190
|
+
The wavelength axis is the pixel index; calibrate downstream.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
backend = "easyspec"
|
|
194
|
+
references = [
|
|
195
|
+
"Horne 1986, PASP 98, 609 — optimal aperture extraction "
|
|
196
|
+
"(tophat is the standard unweighted variant).",
|
|
197
|
+
"Tody 1986, Proc. SPIE 627, 733 — IRAF apall / aptrace heritage.",
|
|
198
|
+
"easyspec.extraction.extraction.tracing — argmax / moments trace fit.",
|
|
199
|
+
]
|
|
200
|
+
long_description = (
|
|
201
|
+
"Tracing uses easyspec.tracing (argmax → polynomial). Extraction "
|
|
202
|
+
"is a numpy aperture sum over ±trace_half_width around the "
|
|
203
|
+
"per-column trace position, fully edge-safe (any partial slice "
|
|
204
|
+
"outside the detector contributes zero). tophat ⇒ standard "
|
|
205
|
+
"boxcar; gaussian ⇒ profile-weighted Horne 1986. easyspec is "
|
|
206
|
+
"imported lazily inside run()."
|
|
207
|
+
)
|
|
208
|
+
default_params: dict[str, Any] = {
|
|
209
|
+
"trace_method": "argmax",
|
|
210
|
+
"trace_poly_order": 2,
|
|
211
|
+
"trace_y_pixel_range": 15,
|
|
212
|
+
"trace_peak_height": 100.0,
|
|
213
|
+
"trace_peak_distance": 50,
|
|
214
|
+
"trace_half_width": 7,
|
|
215
|
+
"extraction_weights": "tophat",
|
|
216
|
+
"shift_y_pixels": 0,
|
|
217
|
+
}
|
|
218
|
+
param_descriptions = {
|
|
219
|
+
"trace_method": "easyspec trace method: 'argmax', 'moments' or 'multi'.",
|
|
220
|
+
"trace_poly_order": "Polynomial order of the trace fit.",
|
|
221
|
+
"trace_y_pixel_range": "Half-window (rows) for the trace search.",
|
|
222
|
+
"trace_peak_height": "Minimum peak height when locating the trace.",
|
|
223
|
+
"trace_peak_distance": "Minimum separation between traces (multi mode).",
|
|
224
|
+
"trace_half_width": "Aperture half-width (rows) for both trace and extraction.",
|
|
225
|
+
"extraction_weights": "'tophat' (boxcar sum) or 'gaussian' (Horne-weighted).",
|
|
226
|
+
"shift_y_pixels": (
|
|
227
|
+
"Half-height of the sky window flanking the aperture (rows). "
|
|
228
|
+
"0 disables background subtraction."
|
|
229
|
+
),
|
|
230
|
+
}
|
|
231
|
+
input_requirements = ["image"]
|
|
232
|
+
output_produces = ["spectrum"]
|
|
233
|
+
|
|
234
|
+
def run(self, ctx: WorkContext, params: dict[str, Any]) -> AlgorithmOutput:
|
|
235
|
+
if ctx.image is None:
|
|
236
|
+
raise InvalidParameterError("extract_spectrum_boxcar needs ctx.image.")
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
import matplotlib # noqa: PLC0415
|
|
240
|
+
|
|
241
|
+
matplotlib.use("Agg")
|
|
242
|
+
except ImportError: # pragma: no cover
|
|
243
|
+
pass
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
from easyspec.extraction import extraction as _EasyExtraction # noqa: PLC0415
|
|
247
|
+
except ImportError:
|
|
248
|
+
return AlgorithmOutput.fail(
|
|
249
|
+
"extract_spectrum_boxcar needs easyspec for the tracing step. "
|
|
250
|
+
"Install with: pip install 'spectro-kernel[reduction]'."
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
image = np.asarray(ctx.image.data, dtype=np.float64)
|
|
254
|
+
if image.ndim != 2:
|
|
255
|
+
raise InvalidParameterError(
|
|
256
|
+
"extract_spectrum_boxcar requires a 2D detector frame."
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
thw = int(params["trace_half_width"])
|
|
260
|
+
if thw <= 0:
|
|
261
|
+
raise InvalidParameterError("trace_half_width must be a positive integer.")
|
|
262
|
+
poly_order = int(params["trace_poly_order"])
|
|
263
|
+
if poly_order < 1:
|
|
264
|
+
raise InvalidParameterError("trace_poly_order must be ≥ 1.")
|
|
265
|
+
ew = str(params["extraction_weights"])
|
|
266
|
+
if ew not in ("tophat", "gaussian"):
|
|
267
|
+
raise InvalidParameterError("extraction_weights must be 'tophat' or 'gaussian'.")
|
|
268
|
+
|
|
269
|
+
# ── Tracing (easyspec) ──────────────────────────────────────────
|
|
270
|
+
extractor = _EasyExtraction()
|
|
271
|
+
extractor.aspect_ratio = image.shape[0] / image.shape[1]
|
|
272
|
+
with contextlib.redirect_stdout(io.StringIO()):
|
|
273
|
+
polymodels = extractor.tracing(
|
|
274
|
+
target_spec_data=image,
|
|
275
|
+
method=str(params["trace_method"]),
|
|
276
|
+
y_pixel_range=int(params["trace_y_pixel_range"]),
|
|
277
|
+
xlims=None,
|
|
278
|
+
poly_order=poly_order,
|
|
279
|
+
trace_half_width=thw,
|
|
280
|
+
peak_height=float(params["trace_peak_height"]),
|
|
281
|
+
distance=int(params["trace_peak_distance"]),
|
|
282
|
+
main_plot=False,
|
|
283
|
+
plot_residuals=False,
|
|
284
|
+
)
|
|
285
|
+
if not polymodels:
|
|
286
|
+
return AlgorithmOutput.fail(
|
|
287
|
+
"No spectral trace detected — lower trace_peak_height or "
|
|
288
|
+
"check the image is properly oriented (spectral axis along columns)."
|
|
289
|
+
)
|
|
290
|
+
# easyspec returns coefficients lowest-order first as
|
|
291
|
+
# polymodel.cN attributes; numpy.poly1d wants highest first.
|
|
292
|
+
coeffs = np.asarray(
|
|
293
|
+
[getattr(polymodels[0], f"c{i}").value for i in range(poly_order + 1)][::-1],
|
|
294
|
+
dtype=np.float64,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# ── Extraction (numpy, edge-safe) ──────────────────────────────
|
|
298
|
+
flux, flux_error, sky = _boxcar_extract(
|
|
299
|
+
image,
|
|
300
|
+
coeffs,
|
|
301
|
+
thw,
|
|
302
|
+
ew,
|
|
303
|
+
int(params["shift_y_pixels"]),
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
ctx.spectrum = Spectrum1D(
|
|
307
|
+
wavelength=np.arange(flux.size, dtype=np.float64),
|
|
308
|
+
flux=flux,
|
|
309
|
+
uncertainty=flux_error,
|
|
310
|
+
wavelength_unit="pixel",
|
|
311
|
+
flux_unit="counts",
|
|
312
|
+
meta={
|
|
313
|
+
**ctx.image.meta,
|
|
314
|
+
"extraction": "boxcar",
|
|
315
|
+
"extraction_weights": ew,
|
|
316
|
+
"aperture_half_width": thw,
|
|
317
|
+
"sky_median": float(np.median(sky)),
|
|
318
|
+
"needs_wavelength_calibration": True,
|
|
319
|
+
},
|
|
320
|
+
header=dict(ctx.image.header),
|
|
321
|
+
)
|
|
322
|
+
return AlgorithmOutput.ok(
|
|
323
|
+
metrics={
|
|
324
|
+
"npix": float(flux.size),
|
|
325
|
+
"flux_median": float(np.median(flux)),
|
|
326
|
+
"sky_median": float(np.median(sky)),
|
|
327
|
+
},
|
|
328
|
+
message=(
|
|
329
|
+
f"Boxcar-extracted {flux.size}-pixel spectrum "
|
|
330
|
+
f"({ew} weights, aperture ±{thw} px)."
|
|
331
|
+
),
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
__all__ = ["ExtractSpectrumBoxcar", "_boxcar_extract"]
|
|
@@ -19,6 +19,30 @@ from astropy.io import fits
|
|
|
19
19
|
from ...errors import InvalidParameterError
|
|
20
20
|
from ...types import ImageFrame, WorkContext
|
|
21
21
|
|
|
22
|
+
# Reserved / structural FITS keywords that astropy derives from the data
|
|
23
|
+
# itself when serialising a PrimaryHDU. Copying them off an integer source
|
|
24
|
+
# header onto a float64 HDU corrupts the round-trip: in particular,
|
|
25
|
+
# ``BZERO=32768, BSCALE=1`` from a uint16 raw frame triggers astropy to
|
|
26
|
+
# re-apply the +32768 offset on read-back, which silently shifts every
|
|
27
|
+
# pixel by 32768 ADU. The same applies to any header re-attached to the
|
|
28
|
+
# corrected ImageFrame downstream — strip there too so the next step's
|
|
29
|
+
# stage_target does not inherit the leak.
|
|
30
|
+
_STRUCTURAL_KEYWORDS: frozenset[str] = frozenset(
|
|
31
|
+
{
|
|
32
|
+
"SIMPLE", "BITPIX", "EXTEND", "BZERO", "BSCALE", "BLANK", "END",
|
|
33
|
+
"NAXIS", "NAXIS1", "NAXIS2", "NAXIS3", "PCOUNT", "GCOUNT", "XTENSION",
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _filter_structural(header: dict | fits.Header) -> dict:
|
|
39
|
+
"""Return a copy of *header* with structural / scaling keywords stripped."""
|
|
40
|
+
return {
|
|
41
|
+
str(key): value
|
|
42
|
+
for key, value in dict(header).items()
|
|
43
|
+
if str(key).upper() not in _STRUCTURAL_KEYWORDS
|
|
44
|
+
}
|
|
45
|
+
|
|
22
46
|
|
|
23
47
|
def stage_target(stage: str, target_path: str | None, ctx_image: ImageFrame | None) -> str:
|
|
24
48
|
"""Return a directory containing the single target FITS to process.
|
|
@@ -27,6 +51,11 @@ def stage_target(stage: str, target_path: str | None, ctx_image: ImageFrame | No
|
|
|
27
51
|
no copy). Otherwise the in-memory ``ctx.image`` is written to a temporary
|
|
28
52
|
FITS in the same directory. Either way the returned directory is what
|
|
29
53
|
EasySpec's ``data_paths(targets=...)`` expects.
|
|
54
|
+
|
|
55
|
+
Scaling/structural keywords (``BZERO``, ``BSCALE``, ``BITPIX``, …) from
|
|
56
|
+
the source header are stripped before being attached to the staged
|
|
57
|
+
float64 HDU — otherwise astropy re-applies the uint16 convention on
|
|
58
|
+
read-back and silently adds 32768 to every pixel.
|
|
30
59
|
"""
|
|
31
60
|
target_dir = Path(stage) / "targets"
|
|
32
61
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -40,7 +69,7 @@ def stage_target(stage: str, target_path: str | None, ctx_image: ImageFrame | No
|
|
|
40
69
|
raise InvalidParameterError("Provide either target_path or have ctx.image loaded.")
|
|
41
70
|
hdu = fits.PrimaryHDU(np.asarray(ctx_image.data, dtype=np.float64))
|
|
42
71
|
if ctx_image.header:
|
|
43
|
-
for key, value in ctx_image.header.items():
|
|
72
|
+
for key, value in _filter_structural(ctx_image.header).items():
|
|
44
73
|
with contextlib.suppress(Exception):
|
|
45
74
|
hdu.header[str(key)[:8]] = value
|
|
46
75
|
hdu.writeto(target_dir / "target.fits", overwrite=True)
|
|
@@ -82,12 +111,19 @@ def first_value(result_dict: dict) -> np.ndarray:
|
|
|
82
111
|
def build_corrected_imageframe(
|
|
83
112
|
array: np.ndarray, header_source: str, step: str, extra_meta: dict | None = None
|
|
84
113
|
) -> ImageFrame:
|
|
85
|
-
"""Wrap an easyspec-returned array back into an :class:`ImageFrame`.
|
|
114
|
+
"""Wrap an easyspec-returned array back into an :class:`ImageFrame`.
|
|
115
|
+
|
|
116
|
+
The header read back from disk is stripped of structural/scaling
|
|
117
|
+
keywords (``BZERO``, ``BSCALE``, ``BITPIX``, …) before being attached
|
|
118
|
+
to the new ImageFrame. Otherwise the next pipeline step that calls
|
|
119
|
+
``stage_target`` on this frame would inherit the leak and re-apply
|
|
120
|
+
the uint16 offset.
|
|
121
|
+
"""
|
|
86
122
|
meta = {"step": step, "backend": "easyspec"}
|
|
87
123
|
if extra_meta:
|
|
88
124
|
meta.update(extra_meta)
|
|
89
125
|
try:
|
|
90
|
-
header =
|
|
126
|
+
header = _filter_structural(fits.getheader(header_source))
|
|
91
127
|
except Exception: # noqa: BLE001 - missing header isn't fatal
|
|
92
128
|
header = {}
|
|
93
129
|
return ImageFrame(data=array, header=header, frame_type="science", meta=meta)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Regression tests for the easyspec staging helpers.
|
|
2
|
+
|
|
3
|
+
Guards against the v0.2.0 uint16 BZERO/BSCALE leak (fix #1 of the
|
|
4
|
+
2026-06-09 aurora-parity audit). The bug:
|
|
5
|
+
|
|
6
|
+
stage_target writes ctx.image.data as float64 on a PrimaryHDU but
|
|
7
|
+
copies the source header verbatim — including BZERO=32768/BSCALE=1
|
|
8
|
+
from a uint16 raw frame. astropy then re-applies that scaling on the
|
|
9
|
+
next read, silently adding 32768 ADU to every pixel.
|
|
10
|
+
|
|
11
|
+
The fix strips structural/scaling keywords before they touch the HDU.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
from astropy.io import fits
|
|
20
|
+
|
|
21
|
+
from spectro_kernel.algorithms.reduction._easyspec_apply import (
|
|
22
|
+
_STRUCTURAL_KEYWORDS,
|
|
23
|
+
_filter_structural,
|
|
24
|
+
build_corrected_imageframe,
|
|
25
|
+
stage_target,
|
|
26
|
+
)
|
|
27
|
+
from spectro_kernel.types import ImageFrame
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _staged_fits(tmp_path: Path) -> Path:
|
|
31
|
+
"""First .fits file under <tmp_path>/targets — what stage_target produces."""
|
|
32
|
+
target_dir = Path(tmp_path) / "targets"
|
|
33
|
+
files = list(target_dir.glob("*.fits"))
|
|
34
|
+
assert files, f"stage_target produced no FITS in {target_dir}"
|
|
35
|
+
return files[0]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_filter_structural_drops_scaling_and_structural_keys():
|
|
39
|
+
raw = {
|
|
40
|
+
"BZERO": 32768,
|
|
41
|
+
"BSCALE": 1,
|
|
42
|
+
"BITPIX": 16,
|
|
43
|
+
"NAXIS": 2,
|
|
44
|
+
"NAXIS1": 100,
|
|
45
|
+
"NAXIS2": 50,
|
|
46
|
+
"EXPTIME": 60.0,
|
|
47
|
+
"OBSERVER": "Skibotn",
|
|
48
|
+
}
|
|
49
|
+
cleaned = _filter_structural(raw)
|
|
50
|
+
assert "EXPTIME" in cleaned and cleaned["EXPTIME"] == 60.0
|
|
51
|
+
assert "OBSERVER" in cleaned
|
|
52
|
+
for key in _STRUCTURAL_KEYWORDS & raw.keys():
|
|
53
|
+
assert key not in cleaned
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_stage_target_strips_uint16_bzero(tmp_path):
|
|
57
|
+
"""The headline regression: a uint16-style header must not leak +32768."""
|
|
58
|
+
truth = np.full((8, 16), 170.0, dtype=np.float64)
|
|
59
|
+
img = ImageFrame(
|
|
60
|
+
data=truth,
|
|
61
|
+
header={
|
|
62
|
+
# Exactly what astropy attaches when reading a uint16 raw frame.
|
|
63
|
+
"BZERO": 32768,
|
|
64
|
+
"BSCALE": 1,
|
|
65
|
+
"BITPIX": 16,
|
|
66
|
+
"NAXIS": 2,
|
|
67
|
+
"EXPTIME": 60.0,
|
|
68
|
+
"OBSERVER": "test",
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
stage_target(str(tmp_path), None, img)
|
|
72
|
+
staged = _staged_fits(tmp_path)
|
|
73
|
+
with fits.open(staged, memmap=False) as hdul:
|
|
74
|
+
back = np.asarray(hdul[0].data, dtype=np.float64)
|
|
75
|
+
staged_hdr = dict(hdul[0].header)
|
|
76
|
+
|
|
77
|
+
# Bit-exact round-trip — was truth + 32768 before the fix.
|
|
78
|
+
np.testing.assert_array_equal(back, truth)
|
|
79
|
+
# The staged HDU is float64; astropy does not attach BZERO/BSCALE
|
|
80
|
+
# for float arrays. Either the keys are absent, or they take the
|
|
81
|
+
# neutral defaults — never the leaked +32768/1 combo.
|
|
82
|
+
assert staged_hdr.get("BZERO", 0.0) == 0.0
|
|
83
|
+
assert staged_hdr.get("BSCALE", 1.0) == 1.0
|
|
84
|
+
assert staged_hdr.get("BITPIX") == -64 # float64
|
|
85
|
+
# Non-structural keys survive the filter.
|
|
86
|
+
assert staged_hdr.get("EXPTIME") == 60.0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_stage_target_with_explicit_path_symlinks_unchanged(tmp_path):
|
|
90
|
+
"""When target_path is supplied, the file is symlinked unchanged."""
|
|
91
|
+
src = tmp_path / "raw.fits"
|
|
92
|
+
truth = np.full((4, 4), 7.5, dtype=np.float32)
|
|
93
|
+
fits.PrimaryHDU(truth).writeto(src)
|
|
94
|
+
stage_target(str(tmp_path), str(src), ctx_image=None)
|
|
95
|
+
target_dir = tmp_path / "targets"
|
|
96
|
+
linked = target_dir / "raw.fits"
|
|
97
|
+
assert linked.is_symlink()
|
|
98
|
+
np.testing.assert_array_equal(
|
|
99
|
+
np.asarray(fits.getdata(linked)), truth.astype(np.float64),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_build_corrected_imageframe_strips_structural(tmp_path):
|
|
104
|
+
"""The ImageFrame re-attached after an easyspec step must not carry BZERO."""
|
|
105
|
+
src = tmp_path / "after_easyspec.fits"
|
|
106
|
+
data = np.full((4, 4), 100.0, dtype=np.float64)
|
|
107
|
+
hdu = fits.PrimaryHDU(data)
|
|
108
|
+
# Manually inject a uint16-style scaling block + a benign keyword.
|
|
109
|
+
hdu.header["BZERO"] = 32768
|
|
110
|
+
hdu.header["BSCALE"] = 1
|
|
111
|
+
hdu.header["OBSERVER"] = "Skibotn"
|
|
112
|
+
hdu.writeto(src)
|
|
113
|
+
frame = build_corrected_imageframe(
|
|
114
|
+
np.full((4, 4), 170.0, dtype=np.float64),
|
|
115
|
+
header_source=str(src),
|
|
116
|
+
step="subtract_bias",
|
|
117
|
+
extra_meta={"backend_extra": "ok"},
|
|
118
|
+
)
|
|
119
|
+
# Header on the returned frame is filtered.
|
|
120
|
+
assert "BZERO" not in frame.header
|
|
121
|
+
assert "BSCALE" not in frame.header
|
|
122
|
+
assert frame.header.get("OBSERVER") == "Skibotn"
|
|
123
|
+
# Meta carries the step + extra info.
|
|
124
|
+
assert frame.meta["step"] == "subtract_bias"
|
|
125
|
+
assert frame.meta["backend"] == "easyspec"
|
|
126
|
+
assert frame.meta["backend_extra"] == "ok"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_stage_then_corrected_chain_is_leak_free(tmp_path):
|
|
130
|
+
"""End-to-end: a raw uint16-headered frame goes through stage→correct→stage
|
|
131
|
+
and the second staging must NOT re-introduce the +32768 offset."""
|
|
132
|
+
truth = np.full((6, 6), 170.0, dtype=np.float64)
|
|
133
|
+
raw_header = {"BZERO": 32768, "BSCALE": 1, "BITPIX": 16, "OBSERVER": "loop"}
|
|
134
|
+
img = ImageFrame(data=truth, header=raw_header)
|
|
135
|
+
|
|
136
|
+
# Step 1: stage the raw frame.
|
|
137
|
+
stage_target(str(tmp_path), None, img)
|
|
138
|
+
staged1 = _staged_fits(tmp_path)
|
|
139
|
+
|
|
140
|
+
# Step 2: build a corrected ImageFrame from the staged file (this is
|
|
141
|
+
# what build_corrected_imageframe does after an easyspec call).
|
|
142
|
+
corrected = build_corrected_imageframe(
|
|
143
|
+
np.asarray(fits.getdata(staged1), dtype=np.float64),
|
|
144
|
+
header_source=str(staged1),
|
|
145
|
+
step="round-trip",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Step 3: stage the corrected frame again — simulates the next pipeline
|
|
149
|
+
# step. Header must NOT carry BZERO/BSCALE so the next round-trip stays
|
|
150
|
+
# leak-free.
|
|
151
|
+
stage_root_2 = tmp_path / "second"
|
|
152
|
+
stage_root_2.mkdir()
|
|
153
|
+
stage_target(str(stage_root_2), None, corrected)
|
|
154
|
+
staged2 = _staged_fits(stage_root_2)
|
|
155
|
+
back = np.asarray(fits.getdata(staged2), dtype=np.float64)
|
|
156
|
+
np.testing.assert_array_equal(back, truth)
|