Skip to content

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):

spectro preset validate presets/solar_long_slit.yaml --profile alpy600_toulouse.yaml
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 through ctx.spectrum, second spectra (lamp, sky, response, standard) through ctx.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) or ctx.spectrum.meta (for 1-D), and the per-step metrics are 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.