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.
Files changed (262) hide show
  1. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/CHANGELOG.md +80 -0
  2. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/PKG-INFO +1 -1
  3. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/tutorials/long-slit-reduction.md +5 -2
  4. spectro_kernel-0.2.1/src/spectro_kernel/algorithms/extraction/boxcar.py +335 -0
  5. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/_easyspec_apply.py +39 -3
  6. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/version.py +1 -1
  7. spectro_kernel-0.2.1/tests/unit/test_easyspec_apply_staging.py +156 -0
  8. spectro_kernel-0.2.1/tests/unit/test_extract_boxcar.py +171 -0
  9. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/.gitignore +0 -0
  10. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/LICENSE +0 -0
  11. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/README.md +0 -0
  12. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/concepts/algorithms.md +0 -0
  13. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/concepts/architecture.md +0 -0
  14. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/concepts/data-types.md +0 -0
  15. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/concepts/pipelines.md +0 -0
  16. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/contributing.md +0 -0
  17. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/cookbook/bess-dashboard.md +0 -0
  18. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/cookbook/multi-star-viewer.md +0 -0
  19. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/cookbook/web-playground.md +0 -0
  20. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/gen_catalogue.py +0 -0
  21. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/getting-started.md +0 -0
  22. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_cyg_ew_timeseries.png +0 -0
  23. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_cyg_halpha_phase_stack.png +0 -0
  24. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_cyg_halpha_timeseries.png +0 -0
  25. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_cyg_overlay.png +0 -0
  26. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_cyg_pc1_vs_phase.png +0 -0
  27. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_cyg_pca_2d.png +0 -0
  28. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_cyg_pca_3d.png +0 -0
  29. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_cyg_periodogram.png +0 -0
  30. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_cyg_phase_folded.png +0 -0
  31. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_cyg_rv_timeseries.png +0 -0
  32. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_cyg_similarity.png +0 -0
  33. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_cyg_similarity_date.png +0 -0
  34. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_dra_halpha_phase_stack.png +0 -0
  35. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_dra_pc1_vs_phase.png +0 -0
  36. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_dra_pca_2d.png +0 -0
  37. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_dra_pca_3d.png +0 -0
  38. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_dra_periodogram.png +0 -0
  39. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_dra_phase_folded.png +0 -0
  40. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_dra_rv_timeseries.png +0 -0
  41. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_dra_similarity_date.png +0 -0
  42. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/images/notebooks/alpha_dra_similarity_phase.png +0 -0
  43. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/index.md +0 -0
  44. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/notebooks/alpha-cyg-time-series.md +0 -0
  45. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/notebooks/alpha-dra-binary-period.md +0 -0
  46. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/notebooks/aurora-line-monitor.md +0 -0
  47. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/notebooks/be-star-variability.md +0 -0
  48. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/notebooks/claude-mcp-end-to-end.md +0 -0
  49. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/notebooks/exoplanet-transit-rv.md +0 -0
  50. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/notebooks/full-reduction-walkthrough.md +0 -0
  51. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/notebooks/native-vs-easyspec-showdown.md +0 -0
  52. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/notebooks/your-first-sb2.md +0 -0
  53. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/reference.md +0 -0
  54. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/tutorials/add-an-algorithm.md +0 -0
  55. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/tutorials/analyse-a-spectrum.md +0 -0
  56. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/tutorials/discover-the-catalogue.md +0 -0
  57. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/tutorials/first-spectrum.md +0 -0
  58. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/usage/cli.md +0 -0
  59. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/usage/library.md +0 -0
  60. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/usage/mcp.md +0 -0
  61. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/docs/why.md +0 -0
  62. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/playground/README.md +0 -0
  63. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/pyproject.toml +0 -0
  64. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/__init__.py +0 -0
  65. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/adapters/__init__.py +0 -0
  66. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/adapters/easyspec.py +0 -0
  67. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/__init__.py +0 -0
  68. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/_common.py +0 -0
  69. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/advanced/__init__.py +0 -0
  70. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/advanced/aperture_photometry.py +0 -0
  71. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/advanced/disentangle_sb2.py +0 -0
  72. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/catalogs/__init__.py +0 -0
  73. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/catalogs/gaia.py +0 -0
  74. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/catalogs/simbad.py +0 -0
  75. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/catalogs/vizier.py +0 -0
  76. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/continuum/__init__.py +0 -0
  77. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/continuum/compare_normalisations.py +0 -0
  78. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/continuum/normalize_edges.py +0 -0
  79. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/continuum/normalize_max.py +0 -0
  80. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/continuum/normalize_percentile.py +0 -0
  81. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/continuum/normalize_polynomial.py +0 -0
  82. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/continuum/normalize_region.py +0 -0
  83. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/continuum/subtract_continuum.py +0 -0
  84. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/corrections/__init__.py +0 -0
  85. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/corrections/air_vacuum.py +0 -0
  86. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/corrections/barycentric.py +0 -0
  87. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/corrections/doppler_shift.py +0 -0
  88. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/corrections/extinction_correct_easyspec.py +0 -0
  89. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/corrections/fit_telluric_scaling.py +0 -0
  90. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/corrections/flux_calibrate_easyspec.py +0 -0
  91. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/corrections/remove_telluric.py +0 -0
  92. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/corrections/synth_telluric.py +0 -0
  93. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/embedding/__init__.py +0 -0
  94. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/embedding/embed_band_power.py +0 -0
  95. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/embedding/embed_continuum_subtracted.py +0 -0
  96. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/embedding/embed_lick_indices.py +0 -0
  97. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/embedding/embed_log_lambda.py +0 -0
  98. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/embedding/embed_pretrained.py +0 -0
  99. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/embedding/embed_remote.py +0 -0
  100. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/embedding/embed_spectrum.py +0 -0
  101. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/embedding/embed_wavelets.py +0 -0
  102. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/exports/__init__.py +0 -0
  103. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/exports/export_csv.py +0 -0
  104. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/exports/export_fits.py +0 -0
  105. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/exports/export_fits_bess.py +0 -0
  106. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/exports/export_hdf5.py +0 -0
  107. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/exports/export_votable.py +0 -0
  108. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/extraction/__init__.py +0 -0
  109. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/extraction/easyspec_extract.py +0 -0
  110. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/extraction/sky_lateral_bands.py +0 -0
  111. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/io/__init__.py +0 -0
  112. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/io/read_ascii.py +0 -0
  113. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/io/read_echelle.py +0 -0
  114. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/io/read_fits.py +0 -0
  115. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/io/read_sdss.py +0 -0
  116. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/io/read_votable.py +0 -0
  117. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/lines/__init__.py +0 -0
  118. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/lines/_profiles.py +0 -0
  119. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/lines/catalogs.py +0 -0
  120. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/lines/compare_line_fits.py +0 -0
  121. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/lines/detect.py +0 -0
  122. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/lines/equivalent_width.py +0 -0
  123. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/lines/fit_gaussian.py +0 -0
  124. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/lines/fit_lorentzian.py +0 -0
  125. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/lines/fit_voigt.py +0 -0
  126. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/quality/__init__.py +0 -0
  127. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/quality/compare_snr_methods.py +0 -0
  128. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/quality/snr_der.py +0 -0
  129. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/quality/snr_edge.py +0 -0
  130. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/quality/snr_linear_fit.py +0 -0
  131. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/__init__.py +0 -0
  132. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/_easyspec_helpers.py +0 -0
  133. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/bias_combine.py +0 -0
  134. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/clip_cosmic_rays.py +0 -0
  135. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/dark_subtract.py +0 -0
  136. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/denoise_2d.py +0 -0
  137. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/easyspec_bias.py +0 -0
  138. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/easyspec_cosmic_ray.py +0 -0
  139. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/easyspec_dark.py +0 -0
  140. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/easyspec_flat.py +0 -0
  141. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/easyspec_flat_normalize.py +0 -0
  142. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/easyspec_subtract_bias.py +0 -0
  143. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/easyspec_subtract_dark.py +0 -0
  144. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/extract_spectrum_sum.py +0 -0
  145. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/flat_normalize.py +0 -0
  146. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/geometry.py +0 -0
  147. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/subtract_sky_2d.py +0 -0
  148. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/reduction/wavelength_calibrate.py +0 -0
  149. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/rv/__init__.py +0 -0
  150. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/rv/cross_correlate.py +0 -0
  151. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/rv/fit_keplerian_orbit.py +0 -0
  152. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/rv/measure.py +0 -0
  153. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/rv/precision_bouchy.py +0 -0
  154. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/smoothing/__init__.py +0 -0
  155. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/smoothing/compare_smoothings.py +0 -0
  156. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/smoothing/smooth_gaussian.py +0 -0
  157. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/smoothing/smooth_savgol.py +0 -0
  158. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/stacking/__init__.py +0 -0
  159. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/stacking/merge_echelle_orders.py +0 -0
  160. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/stacking/stack.py +0 -0
  161. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/timeseries/__init__.py +0 -0
  162. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/timeseries/lomb_scargle.py +0 -0
  163. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/timeseries/phase_fold.py +0 -0
  164. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/transforms/__init__.py +0 -0
  165. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/transforms/clip_sigma.py +0 -0
  166. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/transforms/combine_arithmetic.py +0 -0
  167. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/transforms/extract_region.py +0 -0
  168. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/transforms/mask_range.py +0 -0
  169. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/transforms/resample.py +0 -0
  170. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/transforms/resample_flux_conserving.py +0 -0
  171. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/viz/__init__.py +0 -0
  172. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/viz/plot_3d_surface.py +0 -0
  173. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/viz/plot_animation.py +0 -0
  174. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/viz/plot_dynamic_spectrum.py +0 -0
  175. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/viz/plot_plotly.py +0 -0
  176. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/wavelength_calibration/__init__.py +0 -0
  177. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/wavelength_calibration/easyspec_wavelength.py +0 -0
  178. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/wavelength_calibration/in_situ.py +0 -0
  179. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/algorithms/wavelength_calibration/solar.py +0 -0
  180. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/base.py +0 -0
  181. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/cli.py +0 -0
  182. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/embeddings.py +0 -0
  183. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/errors.py +0 -0
  184. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/io/__init__.py +0 -0
  185. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/io/ascii.py +0 -0
  186. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/io/fits.py +0 -0
  187. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/io/votable.py +0 -0
  188. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/pipeline.py +0 -0
  189. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/presets/__init__.py +0 -0
  190. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/presets/catalog/analysis/balmer_quick.yaml +0 -0
  191. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/presets/catalog/analysis/embed_quick.yaml +0 -0
  192. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/presets/catalog/analysis/quality_report.yaml +0 -0
  193. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/presets/catalog/analysis/rv_quick.yaml +0 -0
  194. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/presets/catalog/analysis/snr_check.yaml +0 -0
  195. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/presets/catalog/analysis/time_series_overview.yaml +0 -0
  196. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/presets/catalog/reduction/full_reduction_easyspec.yaml +0 -0
  197. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/presets/loader.py +0 -0
  198. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/py.typed +0 -0
  199. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/registry.py +0 -0
  200. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/types/__init__.py +0 -0
  201. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/types/catalog.py +0 -0
  202. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/types/context.py +0 -0
  203. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/types/enums.py +0 -0
  204. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/types/history.py +0 -0
  205. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/types/image.py +0 -0
  206. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/types/line.py +0 -0
  207. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/types/spectrum.py +0 -0
  208. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_kernel/types/timeseries.py +0 -0
  209. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_mcp/__init__.py +0 -0
  210. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_mcp/__main__.py +0 -0
  211. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_mcp/auth.py +0 -0
  212. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_mcp/auto_tools.py +0 -0
  213. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_mcp/observability.py +0 -0
  214. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_mcp/py.typed +0 -0
  215. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_mcp/server.py +0 -0
  216. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_mcp/session.py +0 -0
  217. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/src/spectro_mcp/url_safety.py +0 -0
  218. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/conftest.py +0 -0
  219. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/reference/conftest.py +0 -0
  220. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/reference/data/.gitkeep +0 -0
  221. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/reference/data/README.md +0 -0
  222. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/reference/data/sun/sun_reference_stis_002.fits +0 -0
  223. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/reference/data/vega/alpha_lyr_stis_011.fits +0 -0
  224. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/reference/test_known_answers.py +0 -0
  225. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/reference/test_sun.py +0 -0
  226. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/reference/test_vega.py +0 -0
  227. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_base.py +0 -0
  228. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_cli.py +0 -0
  229. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_combine.py +0 -0
  230. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_combine_arithmetic.py +0 -0
  231. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_continuum.py +0 -0
  232. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_corrections.py +0 -0
  233. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_denoise_2d.py +0 -0
  234. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_easyspec_wrappers.py +0 -0
  235. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_embedding.py +0 -0
  236. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_embedding_pretrained.py +0 -0
  237. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_embedding_remote_lick.py +0 -0
  238. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_embedding_tier1.py +0 -0
  239. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_export_fits_bess.py +0 -0
  240. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_geometry_corrections.py +0 -0
  241. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_io.py +0 -0
  242. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_line_profiles.py +0 -0
  243. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_lines.py +0 -0
  244. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_mcp.py +0 -0
  245. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_mcp_production.py +0 -0
  246. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_mcp_security.py +0 -0
  247. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_misc_algorithms.py +0 -0
  248. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_no_circular_imports.py +0 -0
  249. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_normalize_to_region.py +0 -0
  250. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_pipeline.py +0 -0
  251. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_quality.py +0 -0
  252. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_read_sdss.py +0 -0
  253. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_registry.py +0 -0
  254. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_sky_lateral_bands.py +0 -0
  255. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_smoothing.py +0 -0
  256. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_timeseries.py +0 -0
  257. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_transforms.py +0 -0
  258. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_types.py +0 -0
  259. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_viz.py +0 -0
  260. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_wavelength_calibration_in_situ.py +0 -0
  261. {spectro_kernel-0.2.0 → spectro_kernel-0.2.1}/tests/unit/test_wavelength_calibration_solar.py +0 -0
  262. {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.0
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
- - algorithm: extract_spectrum_sum
67
- params: {trace_row: 120, half_width: 6}
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 = dict(fits.getheader(header_source))
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)
@@ -1,3 +1,3 @@
1
1
  """Single source of truth for the package version."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.2.1"
@@ -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)