Tutorial: long-slit reduction (solar, stellar)¶
Take a raw 2-D detector frame and walk it all the way to a calibrated 1-D spectrum on a uniform Å grid, ready for line fitting or BeSS submission. This tutorial shows how the long-slit bricks compose into a complete reduction. The worked example is a solar spectrum (the Sun fills the slit, so no trace and no arc lamp are needed: the Fraunhofer lines are the wavelength reference). A stellar variant with an arc lamp follows.
If you only need 1-D analysis on an already-reduced spectrum, see Analyse a spectrum instead.
Pipeline overview¶
The long-slit bricks form three groups, with one entry point each:
| Group | Bricks | When to use |
|---|---|---|
| Geometry | correct_tilt_affine, correct_slant_affine, correct_smile_polynomial |
Once per instrument: align the slit with the columns, rectify monochromatic lines, undo the smile of a concave grating. measure_arc_geometry measures the three values from an arc frame. |
| 2-D denoising | outlier_rejection_mad_adaptive, denoise_gaussian_2d, denoise_median_2d |
After cosmic-ray clipping, before extraction. CMOS random-telegraph noise, hot pixels. |
| Wavelength | wavelength_calibration_solar (Fraunhofer lines, no lamp) or match_lamp_lines + wavelength_calibrate_polynomial (arc lamp) or wavelength_calibration_in_situ (sky lines) |
Pick the reference your setup provides. |
Two postprocessing helpers and the BeSS exporter round out the set:
| Group | Bricks |
|---|---|
| Postprocessing 1-D | combine_spectra_arithmetic (response / standard-star division), normalize_to_region (normalise to a chosen continuum band) |
| Export | export_fits_bess (BeSS / ARAS-compliant FITS with full header, JD/MJD, canonical filename) |
A complete solar preset¶
The kernel's preset format strings the bricks together. The Sun is an extended source: it illuminates the whole slit, so the extraction is a plain sum over a band of rows and the wavelength solution comes from the solar spectrum itself.
The recipe below separates the science (what is measured and how) from the instrument (its geometry): the geometric values are variables, and a profile file supplies them. See Recipes for the idea.
# presets/solar_long_slit.yaml
name: solar_long_slit
version: 1.0.0
requires: ">=0.7"
description: |
Long-slit solar reduction: geometry -> denoising -> band extraction ->
Fraunhofer-line wavelength calibration -> continuum normalisation -> BeSS.
metadata:
kind: reduction
status: draft
variables:
tilt_deg: {type: float, default: 0.0, description: "slit tilt (deg); 0 = none"}
slant_deg: {type: float, default: 0.0, description: "line slant (deg); 0 = none"}
smile_radius: {type: float, default: 0.0, description: "smile radius (px); 0 = none"}
reference_row: {type: int, required: true, description: "row the geometry is measured on"}
observer: {type: str, required: true}
site: {type: str, required: true}
date_obs_utc: {type: str, required: true, description: "ISO-8601 start of exposure"}
exposure_seconds: {type: float, required: true}
steps:
# 1. Geometric rectification: the values come from the profile; zero means
# "no correction", so the same recipe also runs on a clean setup.
- algorithm: correct_tilt_affine
params: {tilt_deg: "${tilt_deg}"}
- algorithm: correct_slant_affine
params: {slant_deg: "${slant_deg}", pivot_row: "${reference_row}"}
- algorithm: correct_smile_polynomial
params: {reference_row: "${reference_row}", smile_radius: "${smile_radius}", polynomial_order: 4}
# 2. Hot-pixel rejection (rank-conditional median, keeps narrow lines).
- algorithm: outlier_rejection_mad_adaptive
params: {kernel_size: 3, threshold: 3.0}
# 3. Band extraction: the brightest row +/- half_width, summed. For a
# uniformly lit slit any band works; keep it inside the illuminated area.
- algorithm: extract_spectrum_sum
params: {dispersion_axis: 1, half_width: 40}
# 4. Wavelength calibration on the Fraunhofer lines (built-in catalogue).
# Two instrument values are enough: approximate lambda of pixel 0 and
# the dispersion.
- algorithm: wavelength_calibration_solar
params:
approx_wavelength_min_angstrom: 3800.0
approx_dispersion_angstrom_per_pixel: 1.0
poly_order: 3
min_lines_for_fit: 6
# 5. Continuum normalisation to a line-free band.
- algorithm: normalize_to_region
params: {wave_lo: 6400.0, wave_hi: 6500.0}
# 6. BeSS-compliant FITS for sharing (observer and site from the profile).
- algorithm: export_fits_bess
params:
object_name: "Sun"
instrument: "Alpy-600"
site: "${site}"
observer: "${observer}"
date_obs_utc: "${date_obs_utc}"
exposure_seconds: "${exposure_seconds}"
vhelio_kms: 0.0
The profile holds what is true of your setup and this exposure:
# alpy600_toulouse.yaml
tilt_deg: 0.4
slant_deg: 0.6
smile_radius: 1500.0
reference_row: 120
observer: my-name
site: Toulouse
date_obs_utc: "2026-06-09T11:13:45"
exposure_seconds: 0.5
Check the pair, then run it from Python (the spectro pipeline command loads
1-D spectra only; a 2-D reduction starts from an ImageFrame):
import yaml
from astropy.io import fits
from spectro_kernel import ImageFrame, WorkContext
from spectro_kernel.pipeline import PipelineBuilder
profile = yaml.safe_load(open("alpy600_toulouse.yaml"))
ctx = WorkContext(image=ImageFrame(data=fits.getdata("sun_raw.fits").astype(float)))
pipeline = PipelineBuilder().from_preset("presets/solar_long_slit.yaml", profile).build()
result = pipeline.execute(ctx)
print(result.success, result.failed_at) # every step ok ? index of the first failure
print(result.history[-1]) # pipeline:solar_long_slit v1.0.0 + the bound variables
print(ctx.metrics["fit_rms_angstrom"]) # wavelength solution residual
bess_blob = ctx.exports["fits_bess"] # bytes of the BeSS FITS
from_preset accepts a recipe name or a path to your own YAML; the MCP
server exposes the same thing as the run_preset tool, with the same
variables.
Stellar variant (arc-lamp calibration)¶
A star is a point source on the slit: the extraction follows a trace, the sky is subtracted from the rows on either side of it, and the wavelength solution comes from an arc lamp taken with the same setup. Two changes to the solar preset:
- sky subtraction before extraction, with
subtract_sky_2d; - wavelength solution from lamp identifications instead of the
Fraunhofer lines, with
wavelength_calibrate_polynomial.
The identifications come from the lamp frame, reduced once with the same geometry, then handed over as parameters:
from astropy.io import fits
from spectro_kernel import ImageFrame, WorkContext, run_algorithm
# a) The arc lamp: same geometry, band extraction, automatic line matching.
lamp = WorkContext(image=ImageFrame(data=fits.getdata("near_lamp.fits").astype(float)))
for name, params in [
("correct_tilt_affine", {"tilt_deg": 0.4}),
("correct_slant_affine", {"slant_deg": 0.6, "pivot_row": 120}),
("correct_smile_polynomial", {"reference_row": 120, "smile_radius": 1500.0, "polynomial_order": 4}),
("extract_spectrum_sum", {"dispersion_axis": 1, "half_width": 6}),
]:
run_algorithm(name, lamp, params)
run_algorithm("match_lamp_lines", lamp, {"lamp": "NeAr", "dispersion_hint": 1.0, "source_key": ""})
identifications = lamp.extras["lamp_identifications"] # pixel_positions + wavelengths_angstrom
Night after night, reidentify_arc_features re-anchors those identifications
on a fresh lamp exposure without re-matching from scratch.
# presets/stellar_long_slit.yaml
name: stellar_long_slit
version: 1.0.0
requires: ">=0.7"
description: |
Long-slit stellar reduction: geometry -> denoising -> sky subtraction ->
trace extraction -> arc-lamp wavelength calibration -> normalisation -> BeSS.
metadata:
kind: reduction
status: draft
variables:
tilt_deg: {type: float, default: 0.0}
slant_deg: {type: float, default: 0.0}
smile_radius: {type: float, default: 0.0}
reference_row: {type: int, required: true, description: "row of the stellar trace"}
lamp_pixels: {type: list, required: true, description: "pixel_positions from match_lamp_lines"}
lamp_wavelengths: {type: list, required: true, description: "wavelengths_angstrom from match_lamp_lines"}
object_name: {type: str, required: true}
observer: {type: str, required: true}
site: {type: str, required: true}
date_obs_utc: {type: str, required: true}
exposure_seconds: {type: float, required: true}
steps:
- algorithm: correct_tilt_affine
params: {tilt_deg: "${tilt_deg}"}
- algorithm: correct_slant_affine
params: {slant_deg: "${slant_deg}", pivot_row: "${reference_row}"}
- algorithm: correct_smile_polynomial
params: {reference_row: "${reference_row}", smile_radius: "${smile_radius}", polynomial_order: 4}
- algorithm: outlier_rejection_mad_adaptive
params: {kernel_size: 3, threshold: 3.0}
# Sky from the rows on both sides of the trace, fitted column by column.
- algorithm: subtract_sky_2d
params: {trace_row: "${reference_row}", trace_half_width: 6, sky_offset: 4, sky_half_width: 10}
- algorithm: extract_spectrum_sum
params: {dispersion_axis: 1, half_width: 6}
# Lamp identifications come from the profile (see the Python block above:
# `lamp.extras["lamp_identifications"]` has exactly these two lists).
- algorithm: wavelength_calibrate_polynomial
params:
pixel_positions: "${lamp_pixels}"
wavelengths_angstrom: "${lamp_wavelengths}"
order: 3
- algorithm: normalize_to_region
params: {wave_lo: 6400.0, wave_hi: 6500.0}
- algorithm: export_fits_bess
params:
object_name: "${object_name}"
instrument: "Alpy-600"
site: "${site}"
observer: "${observer}"
date_obs_utc: "${date_obs_utc}"
exposure_seconds: "${exposure_seconds}"
vhelio_kms: 0.0
A stellar profile adds the lamp identifications to the geometry:
# alpy600_toulouse_night.yaml
tilt_deg: 0.4
slant_deg: 0.6
smile_radius: 1500.0
reference_row: 120
lamp_pixels: [212.4, 587.9, 903.1, 1188.6, 1455.0, 1790.3]
lamp_wavelengths: [5852.49, 6266.50, 6598.95, 6929.47, 7245.17, 7635.11]
object_name: alf Cyg
observer: my-name
site: Toulouse
date_obs_utc: "2026-06-09T22:13:45"
exposure_seconds: 300
For a faint star, extract_spectrum_optimal (Horne 1986: inverse-variance
weights, cosmic-ray rejection during extraction) replaces extract_spectrum_sum
in the preset; it traces the star itself.
Response correction with a standard star¶
After the normalisation step (or in place of it), divide by the
instrumental response derived with response_from_standard:
- algorithm: combine_spectra_arithmetic
params:
operation: div
reference_path: "/path/to/response.fits"
min_denominator: 1.0e-5
The reference can also live in ctx.extras["reference_spectrum"] if
your pipeline derives it on the fly - same idiom as remove_telluric_division.
What the bricks share¶
Every algorithm above follows the same kernel contract:
- No new context fields: 2-D frames go through
ctx.image, the science spectrum throughctx.spectrum, second spectra (lamp, sky, response, standard) throughctx.extras[...]. - Literature references on every class (
references = [...]). - No-op when zero: geometric corrections and the Gaussian denoiser pass through unchanged when their amplitude parameter is zero, so a single preset can run on instruments with and without those distortions.
- Provenance: every step writes a fingerprint of what it did into
ctx.image.meta(for 2-D) orctx.spectrum.meta(for 1-D), and the per-stepmetricsare JSON-serialisable for later auditing. - One pixel convention: every extractor and every lamp identifier is 0-based, so identifications and calibrators can be mixed freely.
See the Algorithm catalogue for the full parameter and reference list of each brick.