{
  "schema_version": "1",
  "spectrokernel_version": "0.6.2",
  "site_url": "https://docs.spectrokernel.io/",
  "count": 115,
  "categories": {
    "io": "Input / output",
    "master_creation": "Master frames",
    "preprocessing": "Preprocessing (2-D image)",
    "cosmic_ray": "Cosmic-ray rejection",
    "extraction": "Extraction (2-D to 1-D)",
    "wavelength_calibration": "Wavelength calibration",
    "flux_calibration": "Flux calibration",
    "continuum": "Continuum",
    "smoothing": "Smoothing",
    "resampling": "Resampling",
    "transform": "Transforms",
    "quality": "Quality / SNR",
    "line_detection": "Line detection",
    "line_fitting": "Line fitting",
    "correction": "Corrections",
    "radial_velocity": "Radial velocity",
    "timeseries": "Time series",
    "stacking": "Stacking",
    "embedding": "Embeddings",
    "nebular": "Nebular diagnostics",
    "kinematics": "Kinematics",
    "classification": "Spectral classification",
    "catalog": "External catalogues",
    "visualization": "Visualisation",
    "export": "Export",
    "advanced": "Advanced"
  },
  "algorithms": [
    {
      "id": "aperture_photometry",
      "name": "aperture_photometry",
      "summary": "Differential aperture photometry on ``ctx.image`` (photutils).",
      "description": "Provide the target's (x, y) pixel coordinates and one or more comparison stars. The differential magnitude for each comparison is -2.5 * log10(target_flux / comp_flux); positive means the target is fainter than the comparison.",
      "category": "advanced",
      "category_title": "Advanced",
      "version": "1.0.0",
      "backend": "photutils",
      "inputs": [
        "image"
      ],
      "outputs": [
        "extras.photometry",
        "metrics.target_flux"
      ],
      "parameters": [
        {
          "name": "target_xy",
          "default": null,
          "required": true,
          "description": "Target (x, y) pixel coordinates (length-2 list)."
        },
        {
          "name": "comparison_xy",
          "default": null,
          "required": true,
          "description": "List of (x, y) coordinates for comparison stars."
        },
        {
          "name": "aperture_radius",
          "default": 5.0,
          "required": false,
          "description": "Aperture radius (pixels) for source extraction."
        },
        {
          "name": "annulus_in_radius",
          "default": 8.0,
          "required": false,
          "description": "Inner radius of the sky annulus (pixels)."
        },
        {
          "name": "annulus_out_radius",
          "default": 12.0,
          "required": false,
          "description": "Outer radius of the sky annulus (pixels)."
        }
      ],
      "references": [
        "Bradley et al. — Astropy Photutils (https://photutils.readthedocs.io/).",
        "AAVSO Guide to CCD Photometry."
      ],
      "related": [
        "disentangle_sb2",
        "doppler_tomogram"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"aperture_photometry\", ctx, params)"
        },
        "cli": {
          "available": false,
          "command": "spectro run aperture_photometry --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "aperture_photometry",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/advanced/aperture_photometry/"
    },
    {
      "id": "disentangle_sb2",
      "name": "disentangle_sb2",
      "summary": "Separate the spectra of the two components of an SB2 spectroscopic binary.",
      "description": "Implements the iterative alternating subtraction of Bagnuolo & Gies 1991 (not the SVD of Simon & Sturm 1994 nor the Fourier method of Hadrava 1995, cited as background). All observations are resampled onto a common log-wavelength grid so that a Doppler shift is a constant number of samples ; at each iteration the current estimate of one component is removed from every observation in the other component's rest frame, and the new estimate is the average of the residuals. Stores ``primary_spectrum`` and ``secondary_spectrum`` in ``ctx.extras``. CAVEAT — additive continuum degeneracy: only the SUM of the two continua is constrained by the data, so the split of the continuum level between the components is arbitrary (the secondary typically comes out near a continuum of ~0 and the primary near the total) ; the line profiles are correct but a light-ratio prior is needed to renormalise each component to its own continuum. ``metrics['disentangle_residual_rms']`` (v1.1.0) is the RMS of observation − (shifted primary + shifted secondary) after the last iteration, over the finite samples. Descending wavelength axes and non-finite samples are handled before resampling.",
      "category": "advanced",
      "category_title": "Advanced",
      "version": "1.1.0",
      "backend": "numpy",
      "inputs": [
        "spectra"
      ],
      "outputs": [
        "extras.primary_spectrum",
        "extras.secondary_spectrum",
        "metrics.disentangle_residual_rms"
      ],
      "parameters": [
        {
          "name": "v1_kms",
          "default": null,
          "required": true,
          "description": "List of per-spectrum primary velocities (km/s, length = len(ctx.spectra))."
        },
        {
          "name": "v2_kms",
          "default": null,
          "required": true,
          "description": "List of per-spectrum secondary velocities (km/s, same length)."
        },
        {
          "name": "n_iter",
          "default": 30,
          "required": false,
          "description": "Number of iterations of the alternating subtraction."
        },
        {
          "name": "n_grid",
          "default": 4096,
          "required": false,
          "description": "Number of log-wavelength samples on the common grid."
        }
      ],
      "references": [
        "Bagnuolo & Gies 1991, ApJ 376, 266 — iterative alternating-subtraction ('tomographic') separation of composite spectra: the method implemented here.",
        "Simon & Sturm 1994, A&A 281, 286 — wavelength-domain spectral separation by singular-value decomposition (background).",
        "Hadrava 1995, A&AS 114, 393 — Fourier-domain disentangling (background)."
      ],
      "related": [
        "aperture_photometry",
        "doppler_tomogram"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"disentangle_sb2\", ctx, params)"
        },
        "cli": {
          "available": false,
          "command": "spectro run disentangle_sb2 --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "disentangle_sb2",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/advanced/disentangle_sb2/"
    },
    {
      "id": "doppler_tomogram",
      "name": "doppler_tomogram",
      "summary": "Doppler tomogram of a binary from N phase-resolved spectra.",
      "description": "Back-projection of N phase-resolved spectra onto the (V_x, V_y) plane. Pixels are averaged over the phases that contribute (NaN-aware mean) so the phase-coverage footprint does not bleed into the brightness map. JD_mid is read from each spectrum's meta['jd_mid'], falling back to meta['dateobs'] + meta['exptime_s'] via astropy.time.Time. Descending wavelength axes are sorted before interpolation. v2.0.0: the projection now follows Marsh & Horne 1988 Eq. 1, v = γ − Vx·cos(2πφ) + Vy·sin(2πφ) ; v1.x used γ − Vx·sin(2πφ) + Vy·cos(2πφ), so a spot with RV curve γ + K·sin(2πφ) (the secondary) landed at (−K, 0) instead of (0, +K). Maps produced by v1.x are rotated by −90° (Vx, Vy) → (Vy, −Vx) relative to v2 / the literature convention.",
      "category": "advanced",
      "category_title": "Advanced",
      "version": "2.0.0",
      "backend": "numpy",
      "inputs": [],
      "outputs": [
        "extras.tomography_result",
        "metrics.n_spectra",
        "metrics.period_days",
        "metrics.epoch_hjd",
        "metrics.gamma_kms",
        "metrics.line_center_aa"
      ],
      "parameters": [
        {
          "name": "period_days",
          "default": null,
          "required": true,
          "description": "Orbital period of the binary (days)."
        },
        {
          "name": "epoch_hjd",
          "default": null,
          "required": true,
          "description": "Reference epoch HJD of phase 0 (days)."
        },
        {
          "name": "gamma_kms",
          "default": 0.0,
          "required": false,
          "description": "Systemic velocity (km/s) added to the back-projection."
        },
        {
          "name": "line_center_aa",
          "default": 6562.82,
          "required": false,
          "description": "Rest wavelength (Å) of the line to tomogram."
        },
        {
          "name": "velocity_window_kms",
          "default": 1000.0,
          "required": false,
          "description": "Half-extent (km/s) of the V_x/V_y axes."
        },
        {
          "name": "n_velocity",
          "default": 121,
          "required": false,
          "description": "Number of velocity samples per axis (odd, 11–401)."
        }
      ],
      "references": [
        "Marsh & Horne 1988, MNRAS 235, 269 — Doppler tomography by back-projection (§2).",
        "Horne 1985, MNRAS 213, 129 — MEM tomographic reconstruction (not implemented; cited for completeness).",
        "Marsh 2001, Lecture Notes in Physics 573 — astrotomography review."
      ],
      "related": [
        "aperture_photometry",
        "disentangle_sb2"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"doppler_tomogram\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run doppler_tomogram --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "doppler_tomogram",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/advanced/doppler_tomogram/"
    },
    {
      "id": "gaia_query",
      "name": "gaia_query",
      "summary": "Cone-search the Gaia archive around ICRS coordinates.",
      "description": "Requires network access and the optional 'catalogs' extra. By default queries the latest release available via astroquery.gaia. Use `data_release` to pin a specific release such as 'DR3' or 'DR2'.",
      "category": "catalog",
      "category_title": "External catalogues",
      "version": "1.0.0",
      "backend": "astroquery",
      "inputs": [],
      "outputs": [
        "catalog_lookups"
      ],
      "parameters": [
        {
          "name": "ra_deg",
          "default": null,
          "required": true,
          "description": "Right ascension (ICRS) in degrees."
        },
        {
          "name": "dec_deg",
          "default": null,
          "required": true,
          "description": "Declination (ICRS) in degrees."
        },
        {
          "name": "radius_arcsec",
          "default": 5.0,
          "required": false,
          "description": "Cone-search radius around the position."
        },
        {
          "name": "data_release",
          "default": null,
          "required": false,
          "description": "Gaia release table to query (e.g. 'gaiadr3.gaia_source')."
        }
      ],
      "references": [
        "Gaia Collaboration 2016, A&A 595, A1 — the Gaia mission",
        "Salgado et al. 2017 — Gaia archive TAP+ access",
        "Ginsburg et al. 2019, AJ 157, 98 — astroquery"
      ],
      "related": [
        "simbad_query",
        "vizier_query"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"gaia_query\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run gaia_query --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "gaia_query",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/catalog/gaia_query/"
    },
    {
      "id": "simbad_query",
      "name": "simbad_query",
      "summary": "Resolve an object name against SIMBAD and store the record in the context.",
      "description": "Requires network access and the optional 'catalogs' extra. Results are not cached by the algorithm itself; wrap it in a caching layer for batch use.",
      "category": "catalog",
      "category_title": "External catalogues",
      "version": "1.0.0",
      "backend": "astroquery",
      "inputs": [],
      "outputs": [
        "catalog_lookups"
      ],
      "parameters": [
        {
          "name": "object_name",
          "default": null,
          "required": true,
          "description": "Object identifier to resolve (e.g. 'Vega')."
        }
      ],
      "references": [
        "Wenger et al. 2000, A&AS 143, 9 — the SIMBAD astronomical database",
        "Ginsburg et al. 2019, AJ 157, 98 — astroquery"
      ],
      "related": [
        "gaia_query",
        "vizier_query"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"simbad_query\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run simbad_query --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "simbad_query",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/catalog/simbad_query/"
    },
    {
      "id": "vizier_query",
      "name": "vizier_query",
      "summary": "Look up an object in VizieR — the CDS table service.",
      "description": "Requires network access and the optional 'catalogs' extra. By default the row count per catalogue is capped at 5; set `row_limit` to change it.",
      "category": "catalog",
      "category_title": "External catalogues",
      "version": "1.0.0",
      "backend": "astroquery",
      "inputs": [],
      "outputs": [
        "catalog_lookups"
      ],
      "parameters": [
        {
          "name": "object_name",
          "default": null,
          "required": true,
          "description": "Object identifier (e.g. 'HD 209458')."
        },
        {
          "name": "catalog",
          "default": null,
          "required": false,
          "description": "Optional VizieR catalogue ID to restrict to (e.g. 'I/350/gaiaedr3')."
        },
        {
          "name": "row_limit",
          "default": 5,
          "required": false,
          "description": "Maximum number of rows to fetch per catalogue."
        },
        {
          "name": "radius_arcsec",
          "default": 5.0,
          "required": false,
          "description": "Cone-search radius around the resolved position."
        }
      ],
      "references": [
        "Ochsenbein, Bauer & Marcout 2000, A&AS 143, 23 — VizieR",
        "Ginsburg et al. 2019, AJ 157, 98 — astroquery"
      ],
      "related": [
        "gaia_query",
        "simbad_query"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"vizier_query\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run vizier_query --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "vizier_query",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/catalog/vizier_query/"
    },
    {
      "id": "classify_template_chi2",
      "name": "classify_template_chi2",
      "summary": "Spectral classification by χ² against a Pickles-style template atlas.",
      "description": "Common wavelength grid (default 3800–7500 Å at 2 Å step, matching pickles_atlas.canonical_grid()), running-median continuum normalisation (window 80 samples), unweighted χ² over the samples shared by observed + template. Templates with useable overlap < min_overlap are skipped. Confidence flags : ambiguous (χ²[1]/χ²[0] < 1.20) and teff_unstable (top-3 ΔT_eff > 500 K). Supply either `atlas` (an Atlas object, Python API) or `atlas_dir` (path to the Pickles .dat files, loaded with load_atlas_from_dir and cached per directory) ; `atlas` wins when both are given. The grid is always the atlas's own grid_aa : grid_lo / grid_hi / grid_step are accepted for backwards compatibility but currently UNUSED. Normalised templates are cached on Atlas.norm_cache (identical numbers, ~100× faster repeat calls). A descending observed wavelength axis is sorted before resampling.",
      "category": "classification",
      "category_title": "Spectral classification",
      "version": "1.1.0",
      "backend": "scipy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "extras.classification_report",
        "metrics.best_chi2",
        "metrics.best_coverage",
        "metrics.ambiguous",
        "metrics.teff_unstable"
      ],
      "parameters": [
        {
          "name": "atlas",
          "default": null,
          "required": false,
          "description": "An Atlas instance (see classification.pickles_atlas.Atlas). Build with load_atlas_from_dir(path) or supply a custom {template_id → flux_on_grid} mapping plus meta. Python API only ; CLI/MCP callers use atlas_dir."
        },
        {
          "name": "atlas_dir",
          "default": null,
          "required": false,
          "description": "Directory holding the Pickles uk*.dat templates ; loaded with load_atlas_from_dir onto the canonical grid (cached). Ignored when atlas is given."
        },
        {
          "name": "top_n",
          "default": 5,
          "required": false,
          "description": "Number of best-fit matches to report (≥ 1)."
        },
        {
          "name": "min_overlap",
          "default": 0.6,
          "required": false,
          "description": "Reject the run if observed-vs-grid overlap drops below this fraction."
        },
        {
          "name": "continuum_window",
          "default": 80,
          "required": false,
          "description": "Running-median window (samples) for continuum normalisation."
        },
        {
          "name": "grid_lo",
          "default": 3800.0,
          "required": false,
          "description": "Reserved (currently unused): the atlas's own grid_aa is used."
        },
        {
          "name": "grid_hi",
          "default": 7500.0,
          "required": false,
          "description": "Reserved (currently unused): the atlas's own grid_aa is used."
        },
        {
          "name": "grid_step",
          "default": 2.0,
          "required": false,
          "description": "Reserved (currently unused): the atlas's own grid_aa is used."
        }
      ],
      "references": [
        "Pickles 1998, PASP 110, 863 — UVKLIB stellar template atlas.",
        "Cappellari 2017, MNRAS 466, 798 — pPXF full-spectrum fitting.",
        "Koleva et al. 2009, A&A 501, 1269 — ULySS."
      ],
      "related": [],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"classify_template_chi2\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run classify_template_chi2 --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "classify_template_chi2",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/classification/classify_template_chi2/"
    },
    {
      "id": "compare_normalisations",
      "name": "compare_normalisations",
      "summary": "Run every continuum-normalisation method on ``ctx.spectrum`` and collect them.",
      "description": "The Spectrum1D ``ctx.spectrum`` you pass in is *not* modified — only ``ctx.extras['normalisations']`` is populated. To then apply one of the results, copy it back into ``ctx.spectrum`` yourself.",
      "category": "continuum",
      "category_title": "Continuum",
      "version": "1.0.1",
      "backend": "numpy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "extras.normalisations",
        "metrics.normalisations_rms"
      ],
      "parameters": [
        {
          "name": "methods",
          "default": [
            "normalize_polynomial",
            "normalize_percentile",
            "normalize_max",
            "normalize_edges"
          ],
          "required": false,
          "description": "Names of normalisation algorithms to run (defaults to the 4 native ones)."
        },
        {
          "name": "per_method_params",
          "default": {},
          "required": false,
          "description": "Optional dict {method_name: {param: value}} for non-default params."
        }
      ],
      "references": [
        "Catalogue normalise_* algorithms; this wrapper is composition only."
      ],
      "related": [
        "normalize_edges",
        "normalize_max",
        "normalize_percentile",
        "normalize_polynomial",
        "normalize_to_region",
        "subtract_continuum"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"compare_normalisations\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run compare_normalisations --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "compare_normalisations",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/continuum/compare_normalisations/"
    },
    {
      "id": "normalize_edges",
      "name": "normalize_edges",
      "summary": "Normalise a spectrum using a continuum fitted only on its line-free edges.",
      "description": "Polynomial of degree `order` fitted through the outer `edge_fraction` of samples at each end, evaluated over the full axis and divided out. Non-finite (NaN/inf) edge samples are masked before the fit ; the brick fails when fewer than order + 2 finite edge samples remain. Uncertainties are divided by |continuum| like the flux.",
      "category": "continuum",
      "category_title": "Continuum",
      "version": "1.0.1",
      "backend": "numpy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "spectrum",
        "metrics.continuum_median"
      ],
      "parameters": [
        {
          "name": "edge_fraction",
          "default": 0.15,
          "required": false,
          "description": "Fraction of the spectrum, at each end, used to fit the continuum."
        },
        {
          "name": "order",
          "default": 1,
          "required": false,
          "description": "Polynomial order of the edge continuum fit (1 = linear)."
        }
      ],
      "references": [
        "Tody 1986, Proc. SPIE 627, 733 — the IRAF data reduction and analysis system (continuum fitting with sample windows, onedspec.continuum).",
        "Gray 2005, The Observation and Analysis of Stellar Photospheres, 3rd ed., Cambridge UP — ch. 12, continuum placement for line measurement."
      ],
      "related": [
        "compare_normalisations",
        "normalize_max",
        "normalize_percentile",
        "normalize_polynomial",
        "normalize_to_region",
        "subtract_continuum"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"normalize_edges\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run normalize_edges --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "normalize_edges",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/continuum/normalize_edges/"
    },
    {
      "id": "normalize_max",
      "name": "normalize_max",
      "summary": "Normalise a spectrum by dividing the flux by its maximum value.",
      "description": "",
      "category": "continuum",
      "category_title": "Continuum",
      "version": "1.0.1",
      "backend": "numpy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "spectrum",
        "metrics.max_flux"
      ],
      "parameters": [],
      "references": [
        "Gray 2005, The Observation and Analysis of Stellar Photospheres, 3rd ed., Cambridge UP — ch. 12, continuum placement (the peak flux as the crudest continuum proxy).",
        "Tody 1986, Proc. SPIE 627, 733 — the IRAF data reduction and analysis system (reference implementation of spectrum normalisation tasks)."
      ],
      "related": [
        "compare_normalisations",
        "normalize_edges",
        "normalize_percentile",
        "normalize_polynomial",
        "normalize_to_region",
        "subtract_continuum"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"normalize_max\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run normalize_max --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "normalize_max",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/continuum/normalize_max/"
    },
    {
      "id": "normalize_percentile",
      "name": "normalize_percentile",
      "summary": "Normalise a spectrum by dividing the flux by a high percentile of itself.",
      "description": "",
      "category": "continuum",
      "category_title": "Continuum",
      "version": "1.0.1",
      "backend": "numpy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "spectrum",
        "metrics.continuum_level"
      ],
      "parameters": [
        {
          "name": "percentile",
          "default": 95.0,
          "required": false,
          "description": "Flux percentile (0-100) taken as the continuum level."
        }
      ],
      "references": [
        "Gray 2005, The Observation and Analysis of Stellar Photospheres, 3rd ed., Cambridge UP — ch. 12, continuum placement through the highest flux points of an absorption-line spectrum.",
        "Tody 1986, Proc. SPIE 627, 733 — the IRAF data reduction and analysis system (reference implementation of spectrum normalisation tasks)."
      ],
      "related": [
        "compare_normalisations",
        "normalize_edges",
        "normalize_max",
        "normalize_polynomial",
        "normalize_to_region",
        "subtract_continuum"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"normalize_percentile\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run normalize_percentile --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "normalize_percentile",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/continuum/normalize_percentile/"
    },
    {
      "id": "normalize_polynomial",
      "name": "normalize_polynomial",
      "summary": "Normalise the continuum to unity with a sigma-clipped polynomial fit.",
      "description": "Use a low order (2-4) for a slowly varying continuum; higher orders risk absorbing real spectral features. For absorption-line-rich sources set sigma_low=1.5, sigma_high=3.",
      "category": "continuum",
      "category_title": "Continuum",
      "version": "1.1.0",
      "backend": "numpy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "spectrum",
        "metrics.continuum_median"
      ],
      "parameters": [
        {
          "name": "order",
          "default": 3,
          "required": false,
          "description": "Polynomial degree of the continuum fit."
        },
        {
          "name": "sigma_clip",
          "default": 3.0,
          "required": false,
          "description": "Symmetric clip threshold (default 3.0). Used as a fallback for sigma_low / sigma_high when those are left at None."
        },
        {
          "name": "sigma_low",
          "default": null,
          "required": false,
          "description": "Asymmetric clip threshold on the LOW side (rejects absorption lines). None ⇒ falls back to sigma_clip."
        },
        {
          "name": "sigma_high",
          "default": null,
          "required": false,
          "description": "Asymmetric clip threshold on the HIGH side (rejects emission lines / cosmics). None ⇒ falls back to sigma_clip."
        },
        {
          "name": "iterations",
          "default": 3,
          "required": false,
          "description": "Number of sigma-clipping iterations."
        },
        {
          "name": "windows",
          "default": null,
          "required": false,
          "description": "Optional list of (λ_lo, λ_hi) wavelength intervals ; when given, the polynomial is fitted only on samples inside at least one window (IRAF 'sample' parameter)."
        }
      ],
      "references": [
        "Tody 1986, Proc. SPIE 627, 733 — IRAF continuum task (low_reject / high_reject / sample heritage)."
      ],
      "related": [
        "compare_normalisations",
        "normalize_edges",
        "normalize_max",
        "normalize_percentile",
        "normalize_to_region",
        "subtract_continuum"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"normalize_polynomial\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run normalize_polynomial --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "normalize_polynomial",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/continuum/normalize_polynomial/"
    },
    {
      "id": "normalize_to_region",
      "name": "normalize_to_region",
      "summary": "Divide the flux by its NaN-safe mean over ``[wave_lo, wave_hi]``.",
      "description": "out = flux / nanmean(flux[wave_lo:wave_hi]). The window is closed at both ends and clamped to the spectrum's wavelength range. Uncertainty (if present) is scaled by |region_mean|. If the region mean is zero or non-finite the algorithm fails with a clear message rather than emitting NaN.",
      "category": "continuum",
      "category_title": "Continuum",
      "version": "1.0.0",
      "backend": "numpy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "spectrum",
        "metrics.region_mean"
      ],
      "parameters": [
        {
          "name": "wave_lo",
          "default": 0.0,
          "required": true,
          "description": "Lower bound of the reference window (same unit as the wavelength axis)."
        },
        {
          "name": "wave_hi",
          "default": 0.0,
          "required": true,
          "description": "Upper bound of the reference window."
        }
      ],
      "references": [
        "Tody 1986, Proc. SPIE 627, 733 — IRAF continuum heritage."
      ],
      "related": [
        "compare_normalisations",
        "normalize_edges",
        "normalize_max",
        "normalize_percentile",
        "normalize_polynomial",
        "subtract_continuum"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"normalize_to_region\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run normalize_to_region --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "normalize_to_region",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/continuum/normalize_to_region/"
    },
    {
      "id": "subtract_continuum",
      "name": "subtract_continuum",
      "summary": "Subtract a sigma-clipped polynomial continuum, leaving the line residual.",
      "description": "",
      "category": "continuum",
      "category_title": "Continuum",
      "version": "1.0.1",
      "backend": "numpy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "spectrum",
        "metrics.continuum_median"
      ],
      "parameters": [
        {
          "name": "order",
          "default": 3,
          "required": false,
          "description": "Polynomial degree of the continuum fit."
        },
        {
          "name": "sigma_clip",
          "default": 3.0,
          "required": false,
          "description": "Reject samples beyond this many standard deviations per iteration."
        },
        {
          "name": "iterations",
          "default": 3,
          "required": false,
          "description": "Number of sigma-clipping iterations."
        }
      ],
      "references": [
        "Tody 1986, Proc. SPIE 627, 733 — the IRAF data reduction and analysis system ; onedspec.continuum fits an iteratively sigma-clipped function and offers the 'difference' output type implemented here.",
        "Gray 2005, The Observation and Analysis of Stellar Photospheres, 3rd ed., Cambridge UP — ch. 12, continuum placement for line measurement."
      ],
      "related": [
        "compare_normalisations",
        "normalize_edges",
        "normalize_max",
        "normalize_percentile",
        "normalize_polynomial",
        "normalize_to_region"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"subtract_continuum\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run subtract_continuum --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "subtract_continuum",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/continuum/subtract_continuum/"
    },
    {
      "id": "air_to_vacuum",
      "name": "air_to_vacuum",
      "summary": "Convert the wavelength axis from air to vacuum wavelengths.",
      "description": "λ_vac = λ_air · n(λ_air) with the VALD3 closed-form inverse of Morton 2000 (coefficients 8.336624212083e-5, 2.408926869968e-2/(130.1065924522 - s²), 1.599740894897e-4/(38.92568793293 - s²), s = 1e4/λ[Å]). Acceptance: Hα 6562.79 → 6564.603 Å. Valid above ~2000 Å.",
      "category": "correction",
      "category_title": "Corrections",
      "version": "1.0.1",
      "backend": "numpy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "spectrum"
      ],
      "parameters": [],
      "references": [
        "Piskunov N., VALD3 'Air to vacuum conversion' (Uppsala VALD wiki, https://www.astro.uu.se/valdwiki/Air-to-vacuum%20conversion) — closed-form inverse fit n(λ_air) of the Morton 2000 relation.",
        "Morton 2000, ApJS 130, 403 — air/vacuum dispersion relation n(λ_vac)."
      ],
      "related": [
        "barycentric_correction",
        "doppler_shift",
        "extinction_correct_easyspec",
        "fit_telluric_scaling",
        "remove_telluric_division",
        "synth_telluric",
        "vacuum_to_air"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"air_to_vacuum\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run air_to_vacuum --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "air_to_vacuum",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/correction/air_to_vacuum/"
    },
    {
      "id": "barycentric_correction",
      "name": "barycentric_correction",
      "summary": "Compute the barycentric (or heliocentric) correction and Julian date, and shift the spectrum.",
      "description": "The observer location defaults to the geocentre; set latitude/longitude/elevation for the diurnal term (up to ~0.5 km/s). Uses astropy's SkyCoord.radial_velocity_correction and Time.light_travel_time. kind='barycentric' (default) emits metrics.bjd_tdb and a BJD_TDB header card; kind='heliocentric' emits metrics.hjd_utc, an HJD card and the BeSS BSS_VHEL card (BSS_VHEL is heliocentric *by definition* — the kernel refuses to fill it from a barycentric velocity, the two differ by up to ~13 m/s). BSS_VHEL carries the applied velocity when apply_shift is true, else 0 (BeSS convention for an uncorrected product). The Julian date is computed at MID-exposure: start time + exposure_seconds/2, falling back to the FITS EXPTIME when the parameter is null, and to 0 (start = mid) when neither exists.",
      "category": "correction",
      "category_title": "Corrections",
      "version": "1.1.0",
      "backend": "astropy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "metrics.barycentric_velocity_kms",
        "metrics.bjd_tdb",
        "spectrum"
      ],
      "parameters": [
        {
          "name": "ra_deg",
          "default": null,
          "required": false,
          "description": "Target right ascension (deg); falls back to the FITS RA keyword."
        },
        {
          "name": "dec_deg",
          "default": null,
          "required": false,
          "description": "Target declination (deg); falls back to the FITS DEC keyword."
        },
        {
          "name": "obstime",
          "default": null,
          "required": false,
          "description": "Start-of-exposure time (ISO-8601); falls back to FITS DATE-OBS."
        },
        {
          "name": "latitude_deg",
          "default": 0.0,
          "required": false,
          "description": "Observer geodetic latitude (deg)."
        },
        {
          "name": "longitude_deg",
          "default": 0.0,
          "required": false,
          "description": "Observer geodetic longitude (deg, east positive)."
        },
        {
          "name": "elevation_m",
          "default": 0.0,
          "required": false,
          "description": "Observer elevation above the ellipsoid (m)."
        },
        {
          "name": "apply_shift",
          "default": true,
          "required": false,
          "description": "If true, Doppler-shift the wavelength axis to the chosen frame."
        },
        {
          "name": "kind",
          "default": "barycentric",
          "required": false,
          "description": "'barycentric' (default; emits BJD_TDB) or 'heliocentric' (emits HJD and the BeSS BSS_VHEL card)."
        },
        {
          "name": "exposure_seconds",
          "default": null,
          "required": false,
          "description": "Exposure duration (s), used to compute the Julian date at mid-exposure; null falls back to the FITS EXPTIME keyword, then to 0."
        }
      ],
      "references": [
        "astropy.coordinates SkyCoord.radial_velocity_correction",
        "Wright & Eastman 2014, PASP 126, 838 — barycentric correction precision",
        "Eastman, Siverd & Gaudi 2010, PASP 122, 935 — BJD_TDB as the unambiguous time standard (HJD/UTC ambiguities reach the minute)."
      ],
      "related": [
        "air_to_vacuum",
        "doppler_shift",
        "extinction_correct_easyspec",
        "fit_telluric_scaling",
        "remove_telluric_division",
        "synth_telluric",
        "vacuum_to_air"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"barycentric_correction\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run barycentric_correction --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "barycentric_correction",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/correction/barycentric_correction/"
    },
    {
      "id": "doppler_shift",
      "name": "doppler_shift",
      "summary": "Doppler-shift the wavelength axis by a radial velocity.",
      "description": "",
      "category": "correction",
      "category_title": "Corrections",
      "version": "1.0.0",
      "backend": "astropy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "spectrum",
        "metrics.applied_velocity_kms"
      ],
      "parameters": [
        {
          "name": "velocity_kms",
          "default": null,
          "required": true,
          "description": "Radial velocity in km/s (positive = redshift)."
        }
      ],
      "references": [
        "Classical (non-relativistic) Doppler relation: lambda' = lambda * (1 + v/c)",
        "Speed of light from astropy.constants"
      ],
      "related": [
        "air_to_vacuum",
        "barycentric_correction",
        "extinction_correct_easyspec",
        "fit_telluric_scaling",
        "remove_telluric_division",
        "synth_telluric",
        "vacuum_to_air"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"doppler_shift\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run doppler_shift --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "doppler_shift",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/correction/doppler_shift/"
    },
    {
      "id": "extinction_correct_easyspec",
      "name": "extinction_correct_easyspec",
      "summary": "Apply atmospheric extinction correction to ``ctx.spectrum`` via easyspec.",
      "description": "Supported observatory keys depend on the installed easyspec version. Common entries include 'lapalma', 'cerropachon', 'paranal', 'kpno', 'lasilla'; pass a 2-column ASCII file via ``custom_observatory_path`` if your site is not bundled.",
      "category": "correction",
      "category_title": "Corrections",
      "version": "1.0.1",
      "backend": "easyspec",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "spectrum"
      ],
      "parameters": [
        {
          "name": "observatory",
          "default": "lapalma",
          "required": false,
          "description": "Observatory key for the bundled extinction curve."
        },
        {
          "name": "data_type",
          "default": "target",
          "required": false,
          "description": "easyspec data_type label (target / standard_star)."
        },
        {
          "name": "airmass",
          "default": null,
          "required": true,
          "description": "Airmass of the observation (mandatory — extinction scales with it)."
        },
        {
          "name": "custom_observatory_path",
          "default": null,
          "required": false,
          "description": "Optional 2-col file (wavelength, mag/airmass) for a custom site."
        },
        {
          "name": "spline_order",
          "default": 1,
          "required": false,
          "description": "Spline order used to interpolate the extinction curve onto the spectrum."
        }
      ],
      "references": [
        "easyspec.extraction.extraction.extinction_correction."
      ],
      "related": [
        "air_to_vacuum",
        "barycentric_correction",
        "doppler_shift",
        "fit_telluric_scaling",
        "remove_telluric_division",
        "synth_telluric",
        "vacuum_to_air"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"extinction_correct_easyspec\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run extinction_correct_easyspec --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "extinction_correct_easyspec",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/correction/extinction_correct_easyspec/"
    },
    {
      "id": "fit_telluric_scaling",
      "name": "fit_telluric_scaling",
      "summary": "Fit the airmass that best matches a telluric template to ``ctx.spectrum``.",
      "description": "Only wavelengths inside the telluric bands carry information for this fit. The algorithm restricts the residual to the regions where the template transmission drops below ``band_threshold`` (default 0.9 — i.e. pixels where the template absorbs at least 10%). Outside those regions the spectrum is dominated by continuum / stellar lines and would only bias the fit. The rescaled template is written to ctx.extras['telluric_template_scaled'] (on the observed wavelength grid), which remove_telluric_division picks up by default.",
      "category": "correction",
      "category_title": "Corrections",
      "version": "1.0.1",
      "backend": "scipy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "extras.telluric_fit",
        "extras.telluric_template_scaled",
        "metrics.telluric_airmass_fit"
      ],
      "parameters": [
        {
          "name": "template_key",
          "default": "telluric_template",
          "required": false,
          "description": "Where to read the template from ``ctx.extras`` (output of synth_telluric)."
        },
        {
          "name": "band_threshold",
          "default": 0.9,
          "required": false,
          "description": "Pixels of the template where T < threshold count as 'inside a band'."
        },
        {
          "name": "airmass_min",
          "default": 0.5,
          "required": false,
          "description": "Lower bound of the airmass search interval."
        },
        {
          "name": "airmass_max",
          "default": 4.0,
          "required": false,
          "description": "Upper bound of the airmass search interval."
        }
      ],
      "references": [
        "Beer-Lambert atmospheric transmission scaling: T(airmass) = T(1)^airmass.",
        "scipy.optimize.minimize_scalar."
      ],
      "related": [
        "air_to_vacuum",
        "barycentric_correction",
        "doppler_shift",
        "extinction_correct_easyspec",
        "remove_telluric_division",
        "synth_telluric",
        "vacuum_to_air"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"fit_telluric_scaling\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run fit_telluric_scaling --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "fit_telluric_scaling",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/correction/fit_telluric_scaling/"
    },
    {
      "id": "remove_telluric_division",
      "name": "remove_telluric_division",
      "summary": "Remove telluric absorption by dividing the science spectrum by a reference.",
      "description": "Provide the telluric template either via `template_path` (a FITS file) or by storing a Spectrum1D in `ctx.extras['telluric_spectrum']`. Since v1.1.0, when template_key is left at its default 'telluric_spectrum' and that key is absent, the brick falls back to ctx.extras['telluric_template_scaled'] (written by fit_telluric_scaling) and then ctx.extras['telluric_template'] (written by synth_telluric), so the chain synth_telluric → fit_telluric_scaling → remove_telluric_division works with all defaults. The key actually used is recorded in meta['telluric_template_key']. The template's wavelength axis may be in any order (sorted internally).",
      "category": "correction",
      "category_title": "Corrections",
      "version": "1.1.0",
      "backend": "numpy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "spectrum"
      ],
      "parameters": [
        {
          "name": "template_path",
          "default": null,
          "required": false,
          "description": "Path / URL to the telluric standard FITS spectrum."
        },
        {
          "name": "template_key",
          "default": "telluric_spectrum",
          "required": false,
          "description": "Key into ctx.extras holding a Spectrum1D template. With the default 'telluric_spectrum' absent, 'telluric_template_scaled' then 'telluric_template' are tried."
        },
        {
          "name": "min_transmission",
          "default": 0.05,
          "required": false,
          "description": "Below this normalised value the spectrum is masked, not divided."
        }
      ],
      "references": [
        "Vacca, Cushing & Rayner 2003, PASP 115, 389 — telluric correction methodology"
      ],
      "related": [
        "air_to_vacuum",
        "barycentric_correction",
        "doppler_shift",
        "extinction_correct_easyspec",
        "fit_telluric_scaling",
        "synth_telluric",
        "vacuum_to_air"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"remove_telluric_division\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run remove_telluric_division --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "remove_telluric_division",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/correction/remove_telluric_division/"
    },
    {
      "id": "synth_telluric",
      "name": "synth_telluric",
      "summary": "Generate a synthetic telluric transmission spectrum.",
      "description": "Each band is modelled as an inverted Gaussian (1 - depth * G) with hand-curated centre / depth / FWHM — a quick-look approximation, not a line-by-line radiative-transfer model (for that use molecfit, Smette et al. 2015, A&A 576, A77, or an observed telluric standard). Depths and widths are unit-airmass values; the ``airmass`` parameter scales them via T = T0**airmass, the Bouguer / Beer-Lambert relation. The output spectrum is stored in ``ctx.extras['telluric_template']`` and, when ``store_as_spectrum=True`` (default False), also on ``ctx.spectrum``. remove_telluric_division falls back to this key (after 'telluric_template_scaled') when its default key is absent.",
      "category": "correction",
      "category_title": "Corrections",
      "version": "1.0.1",
      "backend": "numpy",
      "inputs": [],
      "outputs": [
        "extras.telluric_template",
        "spectrum"
      ],
      "parameters": [
        {
          "name": "wavelength_min",
          "default": 5800.0,
          "required": false,
          "description": "Lower bound of the synthetic axis (Å)."
        },
        {
          "name": "wavelength_max",
          "default": 9500.0,
          "required": false,
          "description": "Upper bound of the synthetic axis (Å)."
        },
        {
          "name": "n_points",
          "default": 4000,
          "required": false,
          "description": "Number of samples in the synthetic axis."
        },
        {
          "name": "airmass",
          "default": 1.0,
          "required": false,
          "description": "Atmospheric path length (1.0 = zenith)."
        },
        {
          "name": "store_as_spectrum",
          "default": false,
          "required": false,
          "description": "If true, also write the template to ctx.spectrum."
        }
      ],
      "references": [
        "Hinkle, Wallace & Livingston 2003, BAAS 35, 1260 — Kitt Peak atmospheric transmission atlas 0.5-5.5 µm (positions of the O2 γ/B/A bands and the H2O bands used here).",
        "Hardie 1962, in Astronomical Techniques (Stars and Stellar Systems II, ed. Hiltner), Univ. Chicago Press, p. 178 — Bouguer / Beer-Lambert extinction law: transmission scales as T = T(1)**airmass."
      ],
      "related": [
        "air_to_vacuum",
        "barycentric_correction",
        "doppler_shift",
        "extinction_correct_easyspec",
        "fit_telluric_scaling",
        "remove_telluric_division",
        "vacuum_to_air"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"synth_telluric\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run synth_telluric --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "synth_telluric",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/correction/synth_telluric/"
    },
    {
      "id": "vacuum_to_air",
      "name": "vacuum_to_air",
      "summary": "Convert the wavelength axis from vacuum to air wavelengths.",
      "description": "n(λ_vac) = 1 + 8.34254e-5 + 2.406147e-2/(130 - s²) + 1.5998e-4/(38.9 - s²), s = 1e4/λ[Å] (Morton 2000). Valid above ~2000 Å.",
      "category": "correction",
      "category_title": "Corrections",
      "version": "1.0.1",
      "backend": "numpy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "spectrum"
      ],
      "parameters": [],
      "references": [
        "Morton 2000, ApJS 130, 403 — air/vacuum dispersion relation n(λ_vac)."
      ],
      "related": [
        "air_to_vacuum",
        "barycentric_correction",
        "doppler_shift",
        "extinction_correct_easyspec",
        "fit_telluric_scaling",
        "remove_telluric_division",
        "synth_telluric"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"vacuum_to_air\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run vacuum_to_air --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "vacuum_to_air",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/correction/vacuum_to_air/"
    },
    {
      "id": "clip_cosmic_rays",
      "name": "clip_cosmic_rays",
      "summary": "Detect and replace cosmic-ray hits on ``ctx.image``.",
      "description": "L.A.Cosmic detects cosmic-ray cores by Laplacian edge detection and replaces the affected pixels with a local median. The fallback path uses a 3x3 median filter as the reference: any pixel whose excess over the median exceeds `sigma` standard deviations is replaced by that local median.",
      "category": "cosmic_ray",
      "category_title": "Cosmic-ray rejection",
      "version": "1.0.0",
      "backend": "scipy",
      "inputs": [
        "image"
      ],
      "outputs": [
        "image",
        "metrics.n_cosmic_rays"
      ],
      "parameters": [
        {
          "name": "sigma",
          "default": 5.0,
          "required": false,
          "description": "Detection threshold in noise units."
        },
        {
          "name": "gain",
          "default": 1.0,
          "required": false,
          "description": "Detector gain in electrons/ADU (used by L.A.Cosmic)."
        },
        {
          "name": "readnoise",
          "default": 6.0,
          "required": false,
          "description": "Detector read noise in electrons (used by L.A.Cosmic)."
        }
      ],
      "references": [
        "van Dokkum 2001, PASP 113, 1420 — L.A.Cosmic",
        "McCully et al. — astroscrappy (https://github.com/astropy/astroscrappy)"
      ],
      "related": [
        "cosmic_ray_remove_easyspec"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"clip_cosmic_rays\", ctx, params)"
        },
        "cli": {
          "available": false,
          "command": "spectro run clip_cosmic_rays --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "clip_cosmic_rays",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/cosmic_ray/clip_cosmic_rays/"
    },
    {
      "id": "cosmic_ray_remove_easyspec",
      "name": "cosmic_ray_remove_easyspec",
      "summary": "Cosmic-ray (and optional gain) correction via ``CR_and_gain_corrections``.",
      "description": "",
      "category": "cosmic_ray",
      "category_title": "Cosmic-ray rejection",
      "version": "1.0.1",
      "backend": "easyspec",
      "inputs": [],
      "outputs": [
        "image"
      ],
      "parameters": [
        {
          "name": "target_path",
          "default": null,
          "required": false,
          "description": "Science FITS; falls back to ctx.image."
        },
        {
          "name": "gain",
          "default": null,
          "required": false,
          "description": "Detector gain (e-/ADU); null reads it from the FITS header."
        },
        {
          "name": "gain_header_entry",
          "default": "GAIN",
          "required": false,
          "description": "Header keyword for the gain when 'gain' is null."
        },
        {
          "name": "readnoise",
          "default": null,
          "required": false,
          "description": "Read noise (electrons); null reads it from the FITS header."
        },
        {
          "name": "readnoise_header_entry",
          "default": "RDNOISE",
          "required": false,
          "description": "Header keyword for the read noise when 'readnoise' is null."
        },
        {
          "name": "sigclip",
          "default": 5.0,
          "required": false,
          "description": "Sigma threshold for cosmic-ray detection (easyspec default 5)."
        }
      ],
      "references": [
        "easyspec.cleaning.cleaning.CR_and_gain_corrections.",
        "van Dokkum 2001, PASP 113, 1420 — L.A.Cosmic."
      ],
      "related": [
        "clip_cosmic_rays"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"cosmic_ray_remove_easyspec\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run cosmic_ray_remove_easyspec --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "cosmic_ray_remove_easyspec",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/cosmic_ray/cosmic_ray_remove_easyspec/"
    },
    {
      "id": "embed_band_power",
      "name": "embed_band_power",
      "summary": "Embed a spectrum as the integrated flux in N adjacent wavelength bands.",
      "description": "Equivalent to converting a spectrum into a 'virtual photometric catalogue' with N synthetic broad bands. Very cheap and well-suited to indexing very heterogeneous datasets (different dispersions, resolutions, telescopes). Non-finite (NaN/inf) samples are dropped before integration — the trapezoidal band integral uses the actual wavelengths, so this is sampling-independent — and a descending wavelength axis is sorted first (band edges are taken from the finite samples).",
      "category": "embedding",
      "category_title": "Embeddings",
      "version": "2.0.1",
      "backend": "numpy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "embedding",
        "extras.embedding_provenance",
        "metrics.embedding_dim",
        "metrics.embedding_norm"
      ],
      "parameters": [
        {
          "name": "n_bands",
          "default": 16,
          "required": false,
          "description": "Number of bands; also the output dim. Typically 8–32."
        },
        {
          "name": "spacing",
          "default": "log",
          "required": false,
          "description": "Band edges in wavelength: 'linear' or 'log'."
        },
        {
          "name": "log_flux",
          "default": true,
          "required": false,
          "description": "Take log10(integrated_flux + epsilon) before L2 norm."
        },
        {
          "name": "epsilon",
          "default": 1e-12,
          "required": false,
          "description": "Small floor added before log_flux to keep zero bands finite."
        }
      ],
      "references": [
        "Connolly et al. 1995, AJ, 110, 1071 — photometric SED classification.",
        "Bolzonella et al. 2000, A&A, 363, 476 — band-power feature vectors for spectro-photometric classification."
      ],
      "related": [
        "embed_continuum_subtracted",
        "embed_lick_indices",
        "embed_log_lambda",
        "embed_pretrained",
        "embed_remote",
        "embed_spectrum",
        "embed_wavelets"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"embed_band_power\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run embed_band_power --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "embed_band_power",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/embedding/embed_band_power/"
    },
    {
      "id": "embed_continuum_subtracted",
      "name": "embed_continuum_subtracted",
      "summary": "Subtract the polynomial continuum, then embed the line residual.",
      "description": "Uses the symmetric sigma-clipped polynomial continuum helper from ``algorithms._common``; the residual (flux - continuum) is then passed to one of the standard embedding recipes. Set ``norm_method='none'`` to skip a second normalisation step — the residual already has zero mean. Non-finite samples (NaN/inf) make the brick fail cleanly: the DCT/resampling recipes act on the sample index, so bad pixels cannot be dropped — interpolate or mask them first.",
      "category": "embedding",
      "category_title": "Embeddings",
      "version": "1.0.1",
      "backend": "scipy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "embedding",
        "extras.embedding_provenance",
        "metrics.embedding_dim",
        "metrics.embedding_norm",
        "metrics.continuum_rms"
      ],
      "parameters": [
        {
          "name": "dim",
          "default": 256,
          "required": false,
          "description": "Output vector length (positive integer)."
        },
        {
          "name": "strategy",
          "default": "dct",
          "required": false,
          "description": "Recipe applied to the residual: one of ('naive', 'dct', 'multiscale_dct')."
        },
        {
          "name": "continuum_order",
          "default": 3,
          "required": false,
          "description": "Polynomial degree for the continuum fit (2-5 typical)."
        },
        {
          "name": "sigma_clip",
          "default": 3.0,
          "required": false,
          "description": "Sigma threshold for the continuum-fit clip (default 3.0)."
        },
        {
          "name": "norm_method",
          "default": "none",
          "required": false,
          "description": "Pre-embedding flux normalisation: one of ('none', 'min_max', 'z_score', 'continuum'). Defaults to 'none' because the residual is already zero-centred."
        }
      ],
      "references": [
        "Sousa et al. 2007, A&A, 469, 783 — continuum-normalised spectra for stellar parameter retrieval.",
        "Worthey et al. 1994, ApJS, 94, 687 — line-strength indices on continuum-flattened spectra."
      ],
      "related": [
        "embed_band_power",
        "embed_lick_indices",
        "embed_log_lambda",
        "embed_pretrained",
        "embed_remote",
        "embed_spectrum",
        "embed_wavelets"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"embed_continuum_subtracted\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run embed_continuum_subtracted --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "embed_continuum_subtracted",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/embedding/embed_continuum_subtracted/"
    },
    {
      "id": "embed_lick_indices",
      "name": "embed_lick_indices",
      "summary": "Embed a spectrum as the canonical Lick/IDS line-strength indices.",
      "description": "Each index is the equivalent width (or magnitude, for molecular indices) of a feature passband measured against a pseudo-continuum interpolated between two sidebands. The fixed-order vector and the human-readable index names (in extras.embedding_provenance.names) make this the most interpretable embedding the kernel exposes. Non-finite (NaN/inf) samples are dropped before the passband integrals (trapezoidal on the actual wavelengths, hence sampling-independent) and a descending wavelength axis is sorted first.",
      "category": "embedding",
      "category_title": "Embeddings",
      "version": "2.0.1",
      "backend": "numpy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "embedding",
        "extras.embedding_provenance",
        "metrics.embedding_dim",
        "metrics.embedding_norm",
        "metrics.n_valid_indices"
      ],
      "parameters": [
        {
          "name": "l2_normalise",
          "default": false,
          "required": false,
          "description": "L2-normalise the output vector. False by default to preserve the physical units (Å / magnitudes). Set True for similarity search."
        },
        {
          "name": "nan_fill",
          "default": 0.0,
          "required": false,
          "description": "Value to substitute for indices that cannot be measured (passband outside the spectrum's wavelength range)."
        }
      ],
      "references": [
        "Worthey et al. 1994, ApJS, 94, 687 — definition of the Lick/IDS index system.",
        "Worthey & Ottaviani 1997, ApJS, 111, 377 — revised passband definitions and atmospheric corrections.",
        "Trager et al. 1998, ApJS, 116, 1 — extended index list for old stellar populations."
      ],
      "related": [
        "embed_band_power",
        "embed_continuum_subtracted",
        "embed_log_lambda",
        "embed_pretrained",
        "embed_remote",
        "embed_spectrum",
        "embed_wavelets"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"embed_lick_indices\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run embed_lick_indices --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "embed_lick_indices",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/embedding/embed_lick_indices/"
    },
    {
      "id": "embed_log_lambda",
      "name": "embed_log_lambda",
      "summary": "Resample to a uniform log-λ grid, then embed.",
      "description": "The grid spacing is set so the output length is exactly ``dim``. Wavelengths must be strictly positive (the log of a non-positive value is undefined); the algorithm fails fast with a clear error otherwise. Non-finite (NaN/inf) samples are dropped before the resampling — the log-λ interpolation is sampling-independent, so this is safe — and a descending wavelength axis is sorted first.",
      "category": "embedding",
      "category_title": "Embeddings",
      "version": "1.0.1",
      "backend": "scipy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "embedding",
        "extras.embedding_provenance",
        "metrics.embedding_dim",
        "metrics.embedding_norm"
      ],
      "parameters": [
        {
          "name": "dim",
          "default": 256,
          "required": false,
          "description": "Output vector length AND number of log-λ samples (positive integer)."
        },
        {
          "name": "strategy",
          "default": "naive",
          "required": false,
          "description": "Recipe applied to the resampled flux: one of ('naive', 'dct', 'multiscale_dct')."
        },
        {
          "name": "norm_method",
          "default": "min_max",
          "required": false,
          "description": "Pre-embedding flux normalisation: one of ('none', 'min_max', 'z_score', 'continuum')."
        }
      ],
      "references": [
        "Tonry & Davis 1979, AJ, 84, 1511 — radial velocities from cross-correlation in log-λ space.",
        "Baldry et al. 1999, ApJ, 521, 167 — Doppler invariance of log-wavelength representations."
      ],
      "related": [
        "embed_band_power",
        "embed_continuum_subtracted",
        "embed_lick_indices",
        "embed_pretrained",
        "embed_remote",
        "embed_spectrum",
        "embed_wavelets"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"embed_log_lambda\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run embed_log_lambda --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "embed_log_lambda",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/embedding/embed_log_lambda/"
    },
    {
      "id": "embed_pretrained",
      "name": "embed_pretrained",
      "summary": "Embed a spectrum with a local pre-trained PyTorch model.",
      "description": "On the first call for a given model_path the file's SHA-256 is computed and recorded in the provenance dict; subsequent calls reuse the loaded model object (cached on the algorithm instance). Non-finite samples (NaN/inf) make the brick fail cleanly — interpolate or mask bad pixels before inference.",
      "category": "embedding",
      "category_title": "Embeddings",
      "version": "1.0.1",
      "backend": "pytorch",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "embedding",
        "extras.embedding_provenance",
        "metrics.embedding_dim",
        "metrics.embedding_norm"
      ],
      "parameters": [
        {
          "name": "model_path",
          "default": null,
          "required": true,
          "description": "Filesystem path to the PyTorch model file (.pt / .pth) (REQUIRED). The user is responsible for downloading or training the model; the kernel only loads what's already on disk."
        },
        {
          "name": "model_id",
          "default": "user_model",
          "required": false,
          "description": "Human-readable identifier captured in the provenance dict alongside the SHA-256 hash; lets the similarity layer track which model version produced which vector."
        },
        {
          "name": "input_length",
          "default": 1024,
          "required": false,
          "description": "Number of pixels the model expects. The spectrum is resampled to this length before being fed to the model."
        },
        {
          "name": "device",
          "default": "cpu",
          "required": false,
          "description": "Torch device ('cpu' or 'cuda:0' if a GPU is present)."
        }
      ],
      "references": [
        "Bishop 2006, 'Pattern Recognition and Machine Learning' — embedding via learned representations.",
        "Naul et al. 2018, Nat. Astron. 2, 151 — RNN autoencoders for irregular astronomical time series (analogous architecture)."
      ],
      "related": [
        "embed_band_power",
        "embed_continuum_subtracted",
        "embed_lick_indices",
        "embed_log_lambda",
        "embed_remote",
        "embed_spectrum",
        "embed_wavelets"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"embed_pretrained\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run embed_pretrained --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "embed_pretrained",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/embedding/embed_pretrained/"
    },
    {
      "id": "embed_remote",
      "name": "embed_remote",
      "summary": "Embed a spectrum via a remote HTTPS inference endpoint.",
      "description": "The endpoint contract is documented in the module docstring. Any service that respects it (including a self-hosted spectro-kernel MCP cloud running embed_pretrained server-side) can be plugged in. The user is responsible for the endpoint URL and (optionally) an API key; the kernel doesn't ship any defaults.",
      "category": "embedding",
      "category_title": "Embeddings",
      "version": "1.0.1",
      "backend": "httpx",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "embedding",
        "extras.embedding_provenance",
        "metrics.embedding_dim",
        "metrics.embedding_norm"
      ],
      "parameters": [
        {
          "name": "endpoint",
          "default": null,
          "required": true,
          "description": "Full HTTPS URL of the inference endpoint (REQUIRED)."
        },
        {
          "name": "model",
          "default": "default",
          "required": false,
          "description": "Model identifier passed in the request body."
        },
        {
          "name": "api_key",
          "default": null,
          "required": false,
          "description": "Bearer token for the endpoint. Falls back to the SPECTRO_EMBED_API_KEY environment variable when null (on a shared server only for hosts listed in SPECTRO_EMBED_ENDPOINT_ALLOW)."
        },
        {
          "name": "dim",
          "default": 256,
          "required": false,
          "description": "Requested output dimension (the server may ignore this hint)."
        },
        {
          "name": "timeout_s",
          "default": 30.0,
          "required": false,
          "description": "HTTP request timeout in seconds (default 30)."
        }
      ],
      "references": [
        "MCP-style stateless inference endpoints — see e.g. HuggingFace Inference API, OpenAI embedding endpoints, Replicate."
      ],
      "related": [
        "embed_band_power",
        "embed_continuum_subtracted",
        "embed_lick_indices",
        "embed_log_lambda",
        "embed_pretrained",
        "embed_spectrum",
        "embed_wavelets"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"embed_remote\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run embed_remote --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "embed_remote",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/embedding/embed_remote/"
    },
    {
      "id": "embed_spectrum",
      "name": "embed_spectrum",
      "summary": "Compute a fixed-length, L2-normalised embedding of a spectrum.",
      "description": "The actual maths lives in ``spectro_kernel.embeddings`` so a downstream service can batch-embed thousands of spectra without paying the per-call audit-trail overhead — but go through this algorithm if you want the ProcessingStep + content-hash record alongside the vector. Older strategy names ('pca', 'autoencoder') still work but emit a DeprecationWarning. Non-finite samples (NaN/inf) make the brick fail cleanly: the recipes act on the sample index, so bad pixels cannot be dropped — interpolate or mask them first.",
      "category": "embedding",
      "category_title": "Embeddings",
      "version": "1.2.1",
      "backend": "scipy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "embedding",
        "extras.embedding_provenance",
        "metrics.embedding_dim",
        "metrics.embedding_norm"
      ],
      "parameters": [
        {
          "name": "dim",
          "default": 256,
          "required": false,
          "description": "Output vector length (positive integer)."
        },
        {
          "name": "strategy",
          "default": "naive",
          "required": false,
          "description": "Embedding recipe: one of ('naive', 'dct', 'multiscale_dct'). Older names 'pca' and 'autoencoder' are accepted as aliases with a DeprecationWarning."
        },
        {
          "name": "norm_method",
          "default": "min_max",
          "required": false,
          "description": "Pre-embedding flux normalisation: one of ('none', 'min_max', 'z_score', 'continuum'). ``none`` skips normalisation (useful when the spectrum is already normalised by an upstream pipeline step)."
        }
      ],
      "references": [
        "Ahmed, Natarajan & Rao 1974, 'Discrete Cosine Transform', IEEE Trans. Computers, C-23, 90.",
        "Bu et al. 2014, ApJS, 211, 28 — z-score standardisation for spectra.",
        "Sharma et al. 2020, MNRAS, 491, 2280 — min-max rescaling of stellar spectra.",
        "Sanchez-Saez et al. 2021, AJ, 162, 206 — spectral representations for anomaly detection."
      ],
      "related": [
        "embed_band_power",
        "embed_continuum_subtracted",
        "embed_lick_indices",
        "embed_log_lambda",
        "embed_pretrained",
        "embed_remote",
        "embed_wavelets"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"embed_spectrum\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run embed_spectrum --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "embed_spectrum",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/embedding/embed_spectrum/"
    },
    {
      "id": "embed_wavelets",
      "name": "embed_wavelets",
      "summary": "Embed a spectrum via a truncated discrete wavelet transform.",
      "description": "Returns the first ``dim`` coefficients of the concatenated multi-level decomposition (approximation + level-L details + level-(L-1) details + … + level-1 details), zero-padded when the decomposition is shorter than dim, then L2-normalised. The ordering is stable: each output dimension always represents the same wavelet coefficient across all spectra, so cosine similarity is meaningful. Non-finite samples (NaN/inf) make the brick fail cleanly — the transform acts on the sample index, so bad pixels cannot be dropped ; interpolate or mask them first.",
      "category": "embedding",
      "category_title": "Embeddings",
      "version": "2.0.1",
      "backend": "pywavelets",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "embedding",
        "extras.embedding_provenance",
        "metrics.embedding_dim",
        "metrics.embedding_norm"
      ],
      "parameters": [
        {
          "name": "dim",
          "default": 256,
          "required": false,
          "description": "Output vector length (positive integer)."
        },
        {
          "name": "wavelet",
          "default": "db4",
          "required": false,
          "description": "PyWavelets family name. Sensible choices: 'db1' (Haar - sharpest), 'db4' (default, smooth), 'sym8', 'coif5'."
        },
        {
          "name": "level",
          "default": 5,
          "required": false,
          "description": "Number of wavelet decomposition levels. Higher = coarser scales captured; bounded by log2(npix)."
        }
      ],
      "references": [
        "Daubechies 1992, 'Ten Lectures on Wavelets', SIAM.",
        "Mallat 2008, 'A Wavelet Tour of Signal Processing'.",
        "Starck & Murtagh 2002, 'Astronomical Image and Data Analysis' — wavelet methods on astronomical signals."
      ],
      "related": [
        "embed_band_power",
        "embed_continuum_subtracted",
        "embed_lick_indices",
        "embed_log_lambda",
        "embed_pretrained",
        "embed_remote",
        "embed_spectrum"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"embed_wavelets\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run embed_wavelets --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "embed_wavelets",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/embedding/embed_wavelets/"
    },
    {
      "id": "export_csv",
      "name": "export_csv",
      "summary": "Export ``ctx.spectrum`` to a CSV file.",
      "description": "",
      "category": "export",
      "category_title": "Export",
      "version": "1.0.0",
      "backend": "numpy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "exports.csv"
      ],
      "parameters": [
        {
          "name": "path",
          "default": null,
          "required": false,
          "description": "Destination file path; null keeps the result in memory only."
        },
        {
          "name": "delimiter",
          "default": ",",
          "required": false,
          "description": "Column delimiter."
        }
      ],
      "references": [],
      "related": [
        "export_fits",
        "export_fits_bess",
        "export_hdf5",
        "export_votable"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"export_csv\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run export_csv --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "export_csv",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/export/export_csv/"
    },
    {
      "id": "export_fits",
      "name": "export_fits",
      "summary": "Export ``ctx.spectrum`` to a FITS file.",
      "description": "",
      "category": "export",
      "category_title": "Export",
      "version": "1.0.0",
      "backend": "astropy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "exports.fits"
      ],
      "parameters": [
        {
          "name": "path",
          "default": null,
          "required": false,
          "description": "Destination file path; null keeps the result in memory only."
        }
      ],
      "references": [
        "astropy.io.fits",
        "FITS Standard 4.0 — Pence et al. 2010, A&A 524, A42"
      ],
      "related": [
        "export_csv",
        "export_fits_bess",
        "export_hdf5",
        "export_votable"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"export_fits\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run export_fits --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "export_fits",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/export/export_fits/"
    },
    {
      "id": "export_fits_bess",
      "name": "export_fits_bess",
      "summary": "Export ``ctx.spectrum`` as a BeSS / ARAS-compliant FITS image.",
      "description": "Writes the linear-grid Spectrum1D as a 1D image (BITPIX=-32) with linear WCS (CRVAL1, CDELT1, CRPIX1=1) and the BeSS observation keywords (OBJNAME, BSS_INST, BSS_SITE, OBSERVER, DATE-OBS, MJD-OBS, JD-OBS, EXPTIME, BSS_VHEL, BSS_ESRC). Pixels marked invalid (by the spectrum's mask) are replaced with the BeSS sentinel -32000 before write. The output bytes are always stored in ctx.exports['fits_bess']; a canonical filename (_<object>_<date>_<time>_<observer>.fits) is generated when path is null.",
      "category": "export",
      "category_title": "Export",
      "version": "1.0.0",
      "backend": "astropy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "exports.fits_bess"
      ],
      "parameters": [
        {
          "name": "path",
          "default": null,
          "required": false,
          "description": "Destination file path; null ⇒ canonical filename in a temp dir, bytes only."
        },
        {
          "name": "object_name",
          "default": "UNKNOWN",
          "required": false,
          "description": "Target identifier (BeSS OBJNAME)."
        },
        {
          "name": "instrument",
          "default": "UNKNOWN",
          "required": false,
          "description": "Instrument identifier (BeSS BSS_INST, also INSTRUME)."
        },
        {
          "name": "site",
          "default": "UNKNOWN",
          "required": false,
          "description": "Observing site identifier (BeSS BSS_SITE)."
        },
        {
          "name": "observer",
          "default": "UNKNOWN",
          "required": false,
          "description": "Observer name or initials (BeSS OBSERVER)."
        },
        {
          "name": "date_obs_utc",
          "default": "",
          "required": true,
          "description": "Start-of-exposure UTC timestamp (ISO-8601, e.g. '2026-06-09T22:13:45'). MJD-OBS and JD-OBS are computed from this."
        },
        {
          "name": "exposure_seconds",
          "default": 0.0,
          "required": false,
          "description": "Exposure time in seconds (BeSS EXPTIME)."
        },
        {
          "name": "vhelio_kms",
          "default": 0.0,
          "required": false,
          "description": "Heliocentric velocity correction already applied (BeSS BSS_VHEL)."
        },
        {
          "name": "spectrum_source",
          "default": "obs",
          "required": false,
          "description": "BeSS BSS_ESRC: 'obs' (raw observation), 'cor' (calibrated), 'pro' (processed)."
        },
        {
          "name": "telescope",
          "default": null,
          "required": false,
          "description": "Optional telescope identifier (TELESCOP); null leaves it out."
        },
        {
          "name": "n_combined",
          "default": 1,
          "required": false,
          "description": "Number of co-added frames (BeSS BSS_NCMB)."
        }
      ],
      "references": [
        "Teyssier 2015, A&A Pro-Am collaboration — BeSS/ARAS submission protocol.",
        "Buil 2012, ARAS Observation Guide — BeSS FITS header convention.",
        "FITS Standard 4.0 — Pence et al. 2010, A&A 524, A42."
      ],
      "related": [
        "export_csv",
        "export_fits",
        "export_hdf5",
        "export_votable"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"export_fits_bess\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run export_fits_bess --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "export_fits_bess",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/export/export_fits_bess/"
    },
    {
      "id": "export_hdf5",
      "name": "export_hdf5",
      "summary": "Export ``ctx.spectrum`` to an HDF5 file.",
      "description": "",
      "category": "export",
      "category_title": "Export",
      "version": "1.0.1",
      "backend": "numpy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "exports.hdf5"
      ],
      "parameters": [
        {
          "name": "path",
          "default": null,
          "required": false,
          "description": "Destination file path; null keeps the result in memory only."
        }
      ],
      "references": [
        "HDF5 — The HDF Group",
        "h5py — https://www.h5py.org/"
      ],
      "related": [
        "export_csv",
        "export_fits",
        "export_fits_bess",
        "export_votable"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"export_hdf5\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run export_hdf5 --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "export_hdf5",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/export/export_hdf5/"
    },
    {
      "id": "export_votable",
      "name": "export_votable",
      "summary": "Export ``ctx.spectrum`` to a VOTable file (IVOA exchange format).",
      "description": "",
      "category": "export",
      "category_title": "Export",
      "version": "1.0.0",
      "backend": "astropy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "exports.votable"
      ],
      "parameters": [
        {
          "name": "path",
          "default": null,
          "required": false,
          "description": "Destination file path; null keeps the result in memory only."
        }
      ],
      "references": [
        "astropy.io.votable",
        "IVOA VOTable 1.4 — Ochsenbein et al. 2019"
      ],
      "related": [
        "export_csv",
        "export_fits",
        "export_fits_bess",
        "export_hdf5"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"export_votable\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run export_votable --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "export_votable",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/export/export_votable/"
    },
    {
      "id": "detect_trace",
      "name": "detect_trace",
      "summary": "Detect the spectral trace on ``ctx.image`` and expose it on ``ctx.extras[\"trace\"]``.",
      "description": "Sibling of the trace step inside extract_spectrum_boxcar / extract_spectrum_optimal — but standalone, pure-numpy, and exposing the result on ctx.extras so the geometry and sky bricks (subtract_sky_2d, correct_smile_polynomial, correct_slant_affine) can resolve their hand-tuned row parameters from a measured value rather than a config file. Required at the head of an 'automatic' reduction preset; the manual mode that pre-fills row parameters from a config remains valid.",
      "category": "extraction",
      "category_title": "Extraction (2-D to 1-D)",
      "version": "1.0.1",
      "backend": "scipy",
      "inputs": [
        "image"
      ],
      "outputs": [
        "extras.trace",
        "metrics.trace_center_row",
        "metrics.trace_fwhm_px",
        "metrics.trace_snr"
      ],
      "parameters": [
        {
          "name": "method",
          "default": "argmax",
          "required": false,
          "description": "Per-slice centroid method: 'argmax' (integer pixel) or 'centroid' (sub-pixel)."
        },
        {
          "name": "poly_order",
          "default": 2,
          "required": false,
          "description": "Polynomial order of the fitted trace y(x)."
        },
        {
          "name": "search_half_width",
          "default": 40,
          "required": false,
          "description": "Half-window (rows) around the global peak in which each slice searches for its local maximum."
        },
        {
          "name": "n_slices",
          "default": 20,
          "required": false,
          "description": "Number of column slices sampled across the dispersion axis."
        },
        {
          "name": "min_snr",
          "default": 5.0,
          "required": false,
          "description": "Minimum trace SNR (peak above background / off-trace std). The algorithm fails rather than silently writing a bad trace."
        },
        {
          "name": "output_key",
          "default": "trace",
          "required": false,
          "description": "ctx.extras key that receives the trace dict."
        }
      ],
      "references": [
        "Tody 1986, Proc. SPIE 627, 733 — IRAF apall / aptrace heritage.",
        "scipy.signal.find_peaks — global peak of the collapsed spatial profile."
      ],
      "related": [
        "extract_sky_lateral_bands",
        "extract_spectrum_boxcar",
        "extract_spectrum_easyspec",
        "extract_spectrum_optimal",
        "extract_spectrum_sum"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"detect_trace\", ctx, params)"
        },
        "cli": {
          "available": false,
          "command": "spectro run detect_trace --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "detect_trace",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/extraction/detect_trace/"
    },
    {
      "id": "extract_sky_lateral_bands",
      "name": "extract_sky_lateral_bands",
      "summary": "Median-combine two off-trace sky bands into a 1D pixel-axis reference.",
      "description": "Band rows are instrument- and exposure-specific; set them from the preset that describes the slit geometry. The bands should be far enough from the trace to avoid wing contamination.",
      "category": "extraction",
      "category_title": "Extraction (2-D to 1-D)",
      "version": "1.0.0",
      "backend": "numpy",
      "inputs": [
        "image"
      ],
      "outputs": [
        "extras.sky_spectrum",
        "metrics.median_sky_counts"
      ],
      "parameters": [
        {
          "name": "band_above_lo",
          "default": 0,
          "required": true,
          "description": "First row (inclusive) of the band above the trace."
        },
        {
          "name": "band_above_hi",
          "default": 0,
          "required": true,
          "description": "Last row (exclusive) of the band above the trace."
        },
        {
          "name": "band_below_lo",
          "default": 0,
          "required": true,
          "description": "First row (inclusive) of the band below the trace."
        },
        {
          "name": "band_below_hi",
          "default": 0,
          "required": true,
          "description": "Last row (exclusive) of the band below the trace."
        },
        {
          "name": "combine",
          "default": "mean",
          "required": false,
          "description": "'mean' or 'median' to combine the two band profiles."
        },
        {
          "name": "extras_key",
          "default": "sky_spectrum",
          "required": false,
          "description": "ctx.extras key under which the sky spectrum is stored."
        }
      ],
      "references": [
        "Hanuschik 2003, A&A 407, 1157 — UVES optical sky emission atlas.",
        "Osterbrock & Martel 1992, PASP 104, 76 — night-sky emission lines.",
        "Stoughton et al. 2002, AJ 123, 485 — SDSS in-situ sky-line wavelength strategy."
      ],
      "related": [
        "detect_trace",
        "extract_spectrum_boxcar",
        "extract_spectrum_easyspec",
        "extract_spectrum_optimal",
        "extract_spectrum_sum"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"extract_sky_lateral_bands\", ctx, params)"
        },
        "cli": {
          "available": false,
          "command": "spectro run extract_sky_lateral_bands --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "extract_sky_lateral_bands",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/extraction/extract_sky_lateral_bands/"
    },
    {
      "id": "extract_spectrum_boxcar",
      "name": "extract_spectrum_boxcar",
      "summary": "Trace (easyspec) + pure-numpy aperture extraction on ``ctx.image``.",
      "description": "Tracing uses easyspec.tracing (argmax → polynomial). Extraction is a numpy aperture sum over ±trace_half_width around the per-column trace position, fully edge-safe (any partial slice outside the detector contributes zero). tophat ⇒ standard boxcar; gaussian ⇒ profile-weighted (Gaussian fit on the mean column profile). easyspec is imported lazily inside run(). Note: the gaussian mode here is profile-weighted but NOT variance-weighted and does NOT propagate per-pixel uncertainty or reject cosmics during extraction. For the full Horne 1986 estimator (inverse-variance weighting, empirical profile, iterative cosmic rejection, uncertainty propagation) see ``extract_spectrum_optimal`` — choose it for faint, read-noise-limited sources or when downstream code needs Spectrum1D.uncertainty. Sky geometry: with shift_y_pixels > 0 the two flanking windows run from the aperture edge (trace ± trace_half_width) out to median_trace ± shift_y_pixels, so each is about shift_y_pixels − trace_half_width rows high; shift_y_pixels must exceed trace_half_width (refused otherwise). v2.0.0: the per-column sky is the mean of the NON-empty windows — v1 averaged a 0 in for an empty window, so a tilted trace (which empties one window over many columns because the bounds follow the median trace) had its sky halved there (audit case: 162/400 columns at half sky). Columns where both windows are empty get sky = 0 and are counted in the additive metric boxcar_columns_without_sky. The pixel axis is 0-based (numpy.arange(npix)).",
      "category": "extraction",
      "category_title": "Extraction (2-D to 1-D)",
      "version": "2.0.0",
      "backend": "easyspec",
      "inputs": [
        "image"
      ],
      "outputs": [
        "spectrum",
        "metrics.boxcar_columns_without_sky"
      ],
      "parameters": [
        {
          "name": "trace_method",
          "default": "argmax",
          "required": false,
          "description": "easyspec trace method: 'argmax', 'moments' or 'multi'."
        },
        {
          "name": "trace_poly_order",
          "default": 2,
          "required": false,
          "description": "Polynomial order of the trace fit."
        },
        {
          "name": "trace_y_pixel_range",
          "default": 15,
          "required": false,
          "description": "Half-window (rows) for the trace search."
        },
        {
          "name": "trace_peak_height",
          "default": 100.0,
          "required": false,
          "description": "Minimum peak height when locating the trace."
        },
        {
          "name": "trace_peak_distance",
          "default": 50,
          "required": false,
          "description": "Minimum separation between traces (multi mode)."
        },
        {
          "name": "trace_half_width",
          "default": 7,
          "required": false,
          "description": "Aperture half-width (rows) for both trace and extraction."
        },
        {
          "name": "extraction_weights",
          "default": "tophat",
          "required": false,
          "description": "'tophat' (boxcar sum) or 'gaussian' (Horne-weighted)."
        },
        {
          "name": "shift_y_pixels",
          "default": 0,
          "required": false,
          "description": "Half-height (rows) of the sky region centred on the median trace row: each flanking sky window spans from the aperture edge (trace ± trace_half_width) out to median_trace ± shift_y_pixels, i.e. ~ shift_y_pixels - trace_half_width rows. Must exceed trace_half_width; 0 disables background subtraction."
        }
      ],
      "references": [
        "Horne 1986, PASP 98, 609 — optimal aperture extraction (tophat is the standard unweighted variant).",
        "Tody 1986, Proc. SPIE 627, 733 — IRAF apall / aptrace heritage.",
        "easyspec.extraction.extraction.tracing — argmax / moments trace fit."
      ],
      "related": [
        "detect_trace",
        "extract_sky_lateral_bands",
        "extract_spectrum_easyspec",
        "extract_spectrum_optimal",
        "extract_spectrum_sum"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"extract_spectrum_boxcar\", ctx, params)"
        },
        "cli": {
          "available": false,
          "command": "spectro run extract_spectrum_boxcar --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "extract_spectrum_boxcar",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/extraction/extract_spectrum_boxcar/"
    },
    {
      "id": "extract_spectrum_easyspec",
      "name": "extract_spectrum_easyspec",
      "summary": "Trace and extract a 1D spectrum from a 2D frame via easyspec.",
      "description": "Gaussian-weighted aperture extraction with Monte-Carlo error estimation. Pair the resulting pixel-index spectrum with ``wavelength_calibrate_easyspec`` (or the native polynomial calibrator) to get a proper wavelength solution. The pixel axis is 0-based (numpy.arange(npix)), like every other extractor. v2.0.0: when ctx.image is staged to disk, BZERO/BSCALE/BITPIX are no longer copied onto the float64 staging HDU — a uint16-headered frame ([100, 200, …] with BZERO=32768) used to be read back by easyspec as [32868, 32968, …]; the extracted flux is now correct for such inputs. easyspec sessions are serialised behind a process-wide lock (they chdir into a temporary directory).",
      "category": "extraction",
      "category_title": "Extraction (2-D to 1-D)",
      "version": "2.0.0",
      "backend": "easyspec",
      "inputs": [],
      "outputs": [
        "spectrum"
      ],
      "parameters": [
        {
          "name": "target_path",
          "default": null,
          "required": false,
          "description": "2D science FITS to extract; falls back to ctx.image."
        },
        {
          "name": "target_name",
          "default": "target",
          "required": false,
          "description": "Label used internally by easyspec for diagnostics."
        },
        {
          "name": "exposure_seconds",
          "default": null,
          "required": false,
          "description": "Exposure time (seconds); null reads it from the FITS header."
        },
        {
          "name": "airmass",
          "default": null,
          "required": false,
          "description": "Airmass; null reads it from the FITS header."
        },
        {
          "name": "exposure_header_entry",
          "default": "AVEXP",
          "required": false,
          "description": "Header keyword for exposure when 'exposure_seconds' is null."
        },
        {
          "name": "airmass_header_entry",
          "default": "AVAIRMAS",
          "required": false,
          "description": "Header keyword for airmass when 'airmass' is null."
        },
        {
          "name": "trace_method",
          "default": "argmax",
          "required": false,
          "description": "Trace-finding method (easyspec: 'argmax' or 'fit')."
        },
        {
          "name": "trace_poly_order",
          "default": 2,
          "required": false,
          "description": "Polynomial order of the trace fit."
        },
        {
          "name": "trace_half_width",
          "default": 7,
          "required": false,
          "description": "Half-width of the aperture (pixels)."
        },
        {
          "name": "trace_y_pixel_range",
          "default": 15,
          "required": false,
          "description": "Half-window for trace centroiding (pixels)."
        },
        {
          "name": "trace_n_slices",
          "default": 20,
          "required": false,
          "description": "Number of detector columns sampled to fit the trace."
        },
        {
          "name": "trace_peak_height",
          "default": 100,
          "required": false,
          "description": "Minimum peak height when searching for the trace."
        },
        {
          "name": "trace_peak_distance",
          "default": 50,
          "required": false,
          "description": "Minimum separation between peaks (pixels)."
        },
        {
          "name": "mc_steps",
          "default": 25,
          "required": false,
          "description": "Monte-Carlo realisations for the per-pixel flux error."
        },
        {
          "name": "extraction_weights",
          "default": "gaussian",
          "required": false,
          "description": "Aperture weighting: 'gaussian' or 'uniform'."
        },
        {
          "name": "shift_y_pixels",
          "default": 30,
          "required": false,
          "description": "Allowed shift between tracing and extracting (pixels)."
        }
      ],
      "references": [
        "easyspec.extraction.extraction.{import_data, tracing, extracting}."
      ],
      "related": [
        "detect_trace",
        "extract_sky_lateral_bands",
        "extract_spectrum_boxcar",
        "extract_spectrum_optimal",
        "extract_spectrum_sum"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"extract_spectrum_easyspec\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run extract_spectrum_easyspec --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "extract_spectrum_easyspec",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/extraction/extract_spectrum_easyspec/"
    },
    {
      "id": "extract_spectrum_optimal",
      "name": "extract_spectrum_optimal",
      "summary": "Optimal (Horne 1986) aperture extraction of ``ctx.image``.",
      "description": "Empirical profile built from per-column cutouts then smoothed along the dispersion axis with a median filter (robust to residual cosmics, profile = 1 / N at edge columns where the full aperture doesn't fit). Iterative inverse-variance extraction with σ-clip rejection of deviant pixels — the standard Horne recipe. Output uncertainty is the propagated 1-σ. When to choose this vs the lighter alternatives: ``extract_spectrum_sum`` is the simplest pure sum (no trace fit); ``extract_spectrum_boxcar`` is the edge-safe boxcar with optional Gaussian profile weighting but no variance model, no uncertainty propagation and no cosmic rejection — pick it for high-SNR sources or extended emission. Pick ``extract_spectrum_optimal`` when SNR matters most (faint, read-noise-limited targets), when residual cosmics may remain after ``clip_cosmic_rays``, or when the downstream pipeline needs per-pixel ``Spectrum1D.uncertainty`` (e.g. radial-velocity error propagation).",
      "category": "extraction",
      "category_title": "Extraction (2-D to 1-D)",
      "version": "1.0.0",
      "backend": "scipy",
      "inputs": [
        "image"
      ],
      "outputs": [
        "spectrum"
      ],
      "parameters": [
        {
          "name": "trace_method",
          "default": "argmax",
          "required": false,
          "description": "easyspec trace method ('argmax' / 'moments' / 'multi')."
        },
        {
          "name": "trace_poly_order",
          "default": 2,
          "required": false,
          "description": "Polynomial order of the trace fit."
        },
        {
          "name": "trace_y_pixel_range",
          "default": 15,
          "required": false,
          "description": "Half-window (rows) for the trace search."
        },
        {
          "name": "trace_peak_height",
          "default": 100.0,
          "required": false,
          "description": "Minimum peak height when locating the trace."
        },
        {
          "name": "trace_peak_distance",
          "default": 50,
          "required": false,
          "description": "Minimum separation between traces (multi mode)."
        },
        {
          "name": "trace_half_width",
          "default": 12,
          "required": false,
          "description": "Aperture half-width (rows) for trace + extraction."
        },
        {
          "name": "gain",
          "default": 1.0,
          "required": false,
          "description": "Detector gain in e⁻/ADU (variance model)."
        },
        {
          "name": "readnoise",
          "default": 6.0,
          "required": false,
          "description": "Detector read noise in e⁻ (variance model)."
        },
        {
          "name": "profile_smooth_columns",
          "default": 21,
          "required": false,
          "description": "Window (columns) of the median smoothing applied to the empirical profile. Odd; 1 disables smoothing. Larger ⇒ more robust but loses spatial-profile variations along the dispersion axis (curved/tilted traces)."
        },
        {
          "name": "reject_sigma",
          "default": 5.0,
          "required": false,
          "description": "σ threshold for outlier rejection during extraction."
        },
        {
          "name": "iterations",
          "default": 3,
          "required": false,
          "description": "Maximum extract→reject→re-extract cycles."
        }
      ],
      "references": [
        "Horne 1986, PASP 98, 609 — optimal extraction algorithm for CCD spectroscopy.",
        "Marsh 1989, PASP 101, 1032 — empirical profile for tilted traces.",
        "Tody 1986, SPIE 627, 733 — IRAF apall heritage."
      ],
      "related": [
        "detect_trace",
        "extract_sky_lateral_bands",
        "extract_spectrum_boxcar",
        "extract_spectrum_easyspec",
        "extract_spectrum_sum"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"extract_spectrum_optimal\", ctx, params)"
        },
        "cli": {
          "available": false,
          "command": "spectro run extract_spectrum_optimal --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "extract_spectrum_optimal",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/extraction/extract_spectrum_optimal/"
    },
    {
      "id": "extract_spectrum_sum",
      "name": "extract_spectrum_sum",
      "summary": "Extract a 1D spectrum from ``ctx.image`` by summing across the trace.",
      "description": "This is the simple unweighted sum: useful for bright targets and as a baseline. The resulting wavelength axis is in **pixel indices** — pass the spectrum through `wavelength_calibrate_polynomial` to convert to Ångström. Pixel convention: the axis is 0-based (numpy.arange(npix), first column = pixel 0), the same convention as extract_spectrum_boxcar / extract_spectrum_optimal / extract_spectrum_easyspec / extract_sky_lateral_bands and as the pixel positions returned by match_lamp_lines / reidentify_arc_features. v2.0.0: v1 produced a 1-based axis (arange + 1), so chaining it into match_lamp_lines → wavelength_calibrate_polynomial shifted the solution by one pixel (~ +0.9 Å at 0.9 Å/px); the axis is now 0-based like every other extractor.",
      "category": "extraction",
      "category_title": "Extraction (2-D to 1-D)",
      "version": "2.0.0",
      "backend": "numpy",
      "inputs": [
        "image"
      ],
      "outputs": [
        "spectrum"
      ],
      "parameters": [
        {
          "name": "dispersion_axis",
          "default": 1,
          "required": false,
          "description": "Axis along which the spectrum disperses (1 = horizontal rows)."
        },
        {
          "name": "half_width",
          "default": 5,
          "required": false,
          "description": "Half-width of the extraction window in spatial pixels."
        }
      ],
      "references": [
        "Horne 1986, PASP 98, 609 — optimal extraction (variance-weighted variant)."
      ],
      "related": [
        "detect_trace",
        "extract_sky_lateral_bands",
        "extract_spectrum_boxcar",
        "extract_spectrum_easyspec",
        "extract_spectrum_optimal"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"extract_spectrum_sum\", ctx, params)"
        },
        "cli": {
          "available": false,
          "command": "spectro run extract_spectrum_sum --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "extract_spectrum_sum",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/extraction/extract_spectrum_sum/"
    },
    {
      "id": "flux_calibrate_easyspec",
      "name": "flux_calibrate_easyspec",
      "summary": "Flux-calibrate ``ctx.spectrum`` via a standard-star observation.",
      "description": "Use ``list_available_standards()`` from easyspec.extraction to find the archive datasets it bundles (calspec, oke1990, irscal, …). The target spectrum on ``ctx.spectrum`` must be wavelength-calibrated *and* extinction-corrected before this step.",
      "category": "flux_calibration",
      "category_title": "Flux calibration",
      "version": "1.0.1",
      "backend": "easyspec",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "spectrum"
      ],
      "parameters": [
        {
          "name": "std_star_wavelength_angstrom",
          "default": null,
          "required": true,
          "description": "Wavelength axis (Å) of the standard star's extinction-corrected spectrum."
        },
        {
          "name": "std_star_flux_extinction_corrected",
          "default": null,
          "required": true,
          "description": "Flux of the standard star, already corrected for atmospheric extinction."
        },
        {
          "name": "std_star_dataset",
          "default": "calspec",
          "required": false,
          "description": "Archive name bundled with easyspec (e.g. calspec, oke1990)."
        },
        {
          "name": "std_star_archive_file",
          "default": null,
          "required": true,
          "description": "Reference file inside the chosen dataset (e.g. alpha_lyr_stis_011.dat)."
        },
        {
          "name": "exposure_target_seconds",
          "default": null,
          "required": true,
          "description": "Exposure time of the target observation (s)."
        },
        {
          "name": "exposure_std_star_seconds",
          "default": null,
          "required": true,
          "description": "Exposure time of the standard-star observation (s)."
        },
        {
          "name": "smooth_window",
          "default": 101,
          "required": false,
          "description": "Smoothing window for the observed/archive ratio."
        },
        {
          "name": "smooth_window_archive",
          "default": 11,
          "required": false,
          "description": "Smoothing window for the archive spectrum before division."
        },
        {
          "name": "reddening_ebv",
          "default": null,
          "required": false,
          "description": "Optional interstellar E(B-V) to apply (CCM/F99 inside easyspec)."
        },
        {
          "name": "rv_extinction",
          "default": null,
          "required": false,
          "description": "R_V to pair with reddening_ebv (default ~3.1 if unset)."
        },
        {
          "name": "save_output",
          "default": false,
          "required": false,
          "description": "If true, let easyspec write its diagnostic FITS to the temp dir."
        }
      ],
      "references": [
        "easyspec.extraction.extraction.std_star_normalization.",
        "easyspec.extraction.extraction.target_flux_calibration."
      ],
      "related": [
        "response_from_standard"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"flux_calibrate_easyspec\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run flux_calibrate_easyspec --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "flux_calibrate_easyspec",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/flux_calibration/flux_calibrate_easyspec/"
    },
    {
      "id": "response_from_standard",
      "name": "response_from_standard",
      "summary": "Derive the instrumental response curve from a standard-star observation.",
      "description": "Computes observed / catalog (after resampling the catalogue to the observed grid), masks Balmer + telluric windows, σ-clips, fits a low-frequency spline (default) or polynomial. spline_knots does NOT place a fixed number of knots: it sets the scipy UnivariateSpline smoothing factor s = var(ratio)·N / spline_knots (N = samples kept), which scipy converts into a knot count — larger spline_knots ⇒ smaller s ⇒ more knots ⇒ tighter fit (risks carving real features in), smaller ⇒ smoother. Sensible default : 20 for the full visible. No extinction / airmass step is performed here (correct upstream), and no exposure-time normalisation: the response absorbs the standard's exposure time unless the observed flux is already per second. Catalogue spectrum lives in ctx.extras['catalog_spectrum'], consistent with the second-spectrum idiom used by remove_telluric_division and combine_spectra_arithmetic.",
      "category": "flux_calibration",
      "category_title": "Flux calibration",
      "version": "1.0.1",
      "backend": "scipy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "extras.reference_spectrum",
        "metrics.response_rms"
      ],
      "parameters": [
        {
          "name": "fit",
          "default": "spline",
          "required": false,
          "description": "'spline' (default low-frequency UnivariateSpline) or 'polynomial'."
        },
        {
          "name": "spline_knots",
          "default": 20,
          "required": false,
          "description": "Smoothness control for fit='spline': sets the UnivariateSpline smoothing factor s = var(ratio)·N / spline_knots (roughly the number of knots scipy ends up placing). Larger ⇒ tighter fit (risks eating real features) ; smaller ⇒ smoother."
        },
        {
          "name": "poly_order",
          "default": 5,
          "required": false,
          "description": "Polynomial order when fit='polynomial'."
        },
        {
          "name": "exclude_regions",
          "default": null,
          "required": false,
          "description": "List of (wave_lo, wave_hi) windows (Å) to exclude from the fit. None ⇒ a default set covering Balmer + visible telluric bands."
        },
        {
          "name": "sigma_clip",
          "default": 3.0,
          "required": false,
          "description": "σ threshold for residual clipping during the fit (≥ 1)."
        },
        {
          "name": "max_iter",
          "default": 3,
          "required": false,
          "description": "Maximum σ-clip iterations."
        },
        {
          "name": "catalog_key",
          "default": "catalog_spectrum",
          "required": false,
          "description": "ctx.extras key holding the catalogue Spectrum1D."
        },
        {
          "name": "output_key",
          "default": "reference_spectrum",
          "required": false,
          "description": "ctx.extras key receiving the fitted response curve."
        }
      ],
      "references": [
        "Oke 1990, AJ 99, 1621 — flux calibration with secondary standards.",
        "Bessell 1999, PASP 111, 1426 — UBVRI flux standards review.",
        "Hamuy et al. 1992 PASP 104, 533 + 1994 PASP 106, 566 — Southern spectrophotometric standards.",
        "Bohlin et al. 2014, PASP 126, 711 — CALSPEC HST standard stars."
      ],
      "related": [
        "flux_calibrate_easyspec"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"response_from_standard\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run response_from_standard --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "response_from_standard",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/flux_calibration/response_from_standard/"
    },
    {
      "id": "read_ascii_spectrum",
      "name": "read_ascii_spectrum",
      "summary": "Read a 1D spectrum from a two- or three-column text file.",
      "description": "",
      "category": "io",
      "category_title": "Input / output",
      "version": "1.0.0",
      "backend": "numpy",
      "inputs": [],
      "outputs": [
        "spectrum"
      ],
      "parameters": [
        {
          "name": "path",
          "default": null,
          "required": true,
          "description": "Local path of the text file to read."
        },
        {
          "name": "delimiter",
          "default": null,
          "required": false,
          "description": "Column delimiter; null auto-detects comma/semicolon/tab/whitespace."
        },
        {
          "name": "flux_unit",
          "default": "ADU",
          "required": false,
          "description": "Label recorded as the spectrum's flux unit."
        }
      ],
      "references": [],
      "related": [
        "read_echelle_fits",
        "read_fits",
        "read_sdss_spectrum",
        "read_votable_spectrum"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"read_ascii_spectrum\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run read_ascii_spectrum --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "read_ascii_spectrum",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/io/read_ascii_spectrum/"
    },
    {
      "id": "read_echelle_fits",
      "name": "read_echelle_fits",
      "summary": "Read a multi-order échelle FITS file into ``ctx.spectra``.",
      "description": "Each order is loaded with full sample count; no resampling happens here. Set ``flux_column`` and ``wavelength_column`` to override the column-name auto-detection when needed.",
      "category": "io",
      "category_title": "Input / output",
      "version": "1.0.0",
      "backend": "astropy",
      "inputs": [],
      "outputs": [
        "spectra"
      ],
      "parameters": [
        {
          "name": "path",
          "default": null,
          "required": true,
          "description": "FITS file path (local)."
        },
        {
          "name": "flux_column",
          "default": null,
          "required": false,
          "description": "Name of the flux column for the table layout (auto-detected when null)."
        },
        {
          "name": "wavelength_column",
          "default": null,
          "required": false,
          "description": "Name of the wavelength column (auto-detected when null)."
        },
        {
          "name": "wavelength_unit",
          "default": "Angstrom",
          "required": false,
          "description": "Unit label written on each Spectrum1D."
        }
      ],
      "references": [
        "astropy.io.fits.",
        "Common amateur échelle FITS layouts (eShel, Lhires-III echelle reductions)."
      ],
      "related": [
        "read_ascii_spectrum",
        "read_fits",
        "read_sdss_spectrum",
        "read_votable_spectrum"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"read_echelle_fits\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run read_echelle_fits --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "read_echelle_fits",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/io/read_echelle_fits/"
    },
    {
      "id": "read_fits",
      "name": "read_fits",
      "summary": "Read a 1D spectrum from a FITS file (local path or http(s) URL).",
      "description": "Linear WCS: λ = CRVAL1 + (p − CRPIX1)·CDELT1 with 1-based CRPIX1. Logarithmic axes: IRAF DC-FLAG = 1 means CRVAL1/CDELT1 are log10(λ) (axis = 10**(…)); a CTYPE1 ending in '-LOG' (WAVE-LOG, AWAV-LOG) is a WCS Paper III logarithmic axis, λ = CRVAL1·exp((p − CRPIX1)·CDELT1 / CRVAL1). v2.0.0: the '-LOG' case is now decoded with the Paper III formula — v1 applied 10** to it, which returned inf for any CRVAL1 in Å (CTYPE1='WAVE-LOG', CRVAL1=5000, CDELT1=1, CRPIX1=1: v1 [inf, inf, inf], v2 [5000, 5001.0001, 5002.0004], matching astropy.wcs). The IRAF DC-FLAG branch is unchanged.",
      "category": "io",
      "category_title": "Input / output",
      "version": "2.0.0",
      "backend": "astropy",
      "inputs": [],
      "outputs": [
        "spectrum"
      ],
      "parameters": [
        {
          "name": "path",
          "default": null,
          "required": true,
          "description": "Local path or http(s) URL of the FITS file to read."
        }
      ],
      "references": [
        "FITS Standard 4.0 — Pence et al. 2010, A&A 524, A42",
        "astropy.io.fits — Astropy Collaboration 2022, ApJ 935, 167",
        "Greisen, Calabretta, Valdes & Allen 2006, A&A 446, 747 — WCS Paper III (spectral coordinates; '-LOG' axes)."
      ],
      "related": [
        "read_ascii_spectrum",
        "read_echelle_fits",
        "read_sdss_spectrum",
        "read_votable_spectrum"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"read_fits\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run read_fits --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "read_fits",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/io/read_fits/"
    },
    {
      "id": "read_sdss_spectrum",
      "name": "read_sdss_spectrum",
      "summary": "Read an SDSS-format spectrum (``spec-*.fits``) into ``ctx.spectrum``.",
      "description": "Reading ``loglam``-based spectra is the one big gap of the generic FITS reader — this algorithm closes it. Compatible with SDSS DR9 through the current DR (the ``COADD`` HDU layout has been stable since DR9).",
      "category": "io",
      "category_title": "Input / output",
      "version": "1.1.0",
      "backend": "astropy",
      "inputs": [],
      "outputs": [
        "spectrum"
      ],
      "parameters": [
        {
          "name": "path",
          "default": null,
          "required": true,
          "description": "Local path or http(s) URL of an SDSS spec-*.fits file."
        }
      ],
      "references": [
        "York et al. 2000, AJ, 120, 1579 — Sloan Digital Sky Survey overview.",
        "Smee et al. 2013, AJ, 146, 32 — SDSS BOSS spectrograph and data format.",
        "Bolton et al. 2012, AJ, 144, 144 — DR9 spectroscopic data release documenting the spec-*.fits layout."
      ],
      "related": [
        "read_ascii_spectrum",
        "read_echelle_fits",
        "read_fits",
        "read_votable_spectrum"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"read_sdss_spectrum\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run read_sdss_spectrum --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "read_sdss_spectrum",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/io/read_sdss_spectrum/"
    },
    {
      "id": "read_votable_spectrum",
      "name": "read_votable_spectrum",
      "summary": "Read a 1D spectrum from a VOTable file (the IVOA Virtual Observatory format).",
      "description": "",
      "category": "io",
      "category_title": "Input / output",
      "version": "1.0.0",
      "backend": "astropy",
      "inputs": [],
      "outputs": [
        "spectrum"
      ],
      "parameters": [
        {
          "name": "path",
          "default": null,
          "required": true,
          "description": "Local path of the VOTable file to read."
        }
      ],
      "references": [
        "IVOA VOTable 1.4 — Ochsenbein et al. 2019",
        "astropy.io.votable"
      ],
      "related": [
        "read_ascii_spectrum",
        "read_echelle_fits",
        "read_fits",
        "read_sdss_spectrum"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"read_votable_spectrum\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run read_votable_spectrum --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "read_votable_spectrum",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/io/read_votable_spectrum/"
    },
    {
      "id": "rotation_curve",
      "name": "rotation_curve",
      "summary": "Projected long-slit rotation curve v_los(r) from Hα per slit offset.",
      "description": "Per-spectrum Hα Gaussian fit ⇒ v_los = c·(μ−λ_obs_rest)/λ_obs_rest with λ_obs_rest = λ_rest·(1+z_galaxy). Systemic velocity = median(v_los_i). Slit offsets are read from each spectrum's ``meta['slit_offset_arcsec']`` (preferred) or from params['slit_offsets']={index: arcsec}. Points sorted by offset.",
      "category": "kinematics",
      "category_title": "Kinematics",
      "version": "2.0.1",
      "backend": "scipy",
      "inputs": [],
      "outputs": [
        "metrics.v_systemic_km_s",
        "metrics.n_points",
        "metrics.n_skipped_no_offset",
        "metrics.n_skipped_no_fit",
        "extras.points",
        "extras.method"
      ],
      "parameters": [
        {
          "name": "z_galaxy",
          "default": 0.0,
          "required": false,
          "description": "Galaxy redshift used to compute λ_obs_rest = λ_rest·(1+z)."
        },
        {
          "name": "lambda_rest",
          "default": 6562.82,
          "required": false,
          "description": "Rest wavelength (Å) of the line fit per spectrum (default Hα)."
        },
        {
          "name": "fit_window",
          "default": 15.0,
          "required": false,
          "description": "Half-window (Å) of the Hα Gaussian fit."
        },
        {
          "name": "slit_offsets",
          "default": {},
          "required": false,
          "description": "Optional fallback mapping {spectrum_index: arcsec_offset} for spectra whose meta does not carry 'slit_offset_arcsec'."
        }
      ],
      "references": [
        "Rubin et al. 1980, ApJ 238, 471 — long-slit optical rotation curves of Sa-Sc spirals.",
        "Sofue & Rubin 2001, ARA&A 39, 137 — disc rotation curves review.",
        "Kramida et al., NIST ASD — Hα air rest wavelength 6562.82 Å."
      ],
      "related": [],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"rotation_curve\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run rotation_curve --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "rotation_curve",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/kinematics/rotation_curve/"
    },
    {
      "id": "detect_lines",
      "name": "detect_lines",
      "summary": "Detect emission/absorption peaks, optionally matched to a named catalogue.",
      "description": "Blind mode (catalog=None) returns continuum, signed amplitude, SNR (|amp| / noise), FWHM (from scipy.signal.peak_widths at half-maximum), and the MAD-robust noise level used for the threshold. Catalogue mode preserves the v1.1.0 behaviour: each detection is labelled with the nearest catalogue line within tolerance_angstrom.",
      "category": "line_detection",
      "category_title": "Line detection",
      "version": "1.2.1",
      "backend": "scipy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "extras.detected_lines",
        "metrics.n_lines_detected",
        "metrics.n_lines_matched",
        "metrics.n_emission",
        "metrics.n_absorption",
        "metrics.noise_level",
        "metrics.continuum_window",
        "metrics.wavelength_range_lo",
        "metrics.wavelength_range_hi"
      ],
      "parameters": [
        {
          "name": "catalog",
          "default": "balmer",
          "required": false,
          "description": "Reference catalogue: 'balmer', 'telluric', 'nebular', 'aurorae', or None for blind mode (no identification)."
        },
        {
          "name": "kind",
          "default": "both",
          "required": false,
          "description": "Which features to look for: emission, absorption or both."
        },
        {
          "name": "prominence_sigma",
          "default": 5.0,
          "required": false,
          "description": "Peak prominence threshold, in units of the noise level."
        },
        {
          "name": "tolerance_angstrom",
          "default": 5.0,
          "required": false,
          "description": "Maximum detection-to-catalogue separation for a match (Å)."
        },
        {
          "name": "continuum_window",
          "default": 101,
          "required": false,
          "description": "Median-filter window (samples, odd ≥ 3) used to estimate the continuum before MAD noise computation in blind mode."
        },
        {
          "name": "min_separation_angstrom",
          "default": 2.0,
          "required": false,
          "description": "Minimum spacing between detections (Å). Forwarded to find_peaks(distance=…) in blind mode."
        }
      ],
      "references": [
        "scipy.signal.find_peaks — prominence-thresholded peak detection.",
        "Press, Teukolsky, Vetterling & Flannery 2007, Numerical Recipes, 3rd ed., Cambridge UP — §10.2, parabolic interpolation through three points (vertex refinement of the peak position).",
        "Hampel 1974, J. Am. Stat. Assoc. 69, 383 — Median Absolute Deviation (blind-mode noise level: 1.4826 · MAD)."
      ],
      "related": [],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"detect_lines\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run detect_lines --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "detect_lines",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/line_detection/detect_lines/"
    },
    {
      "id": "compare_line_fits",
      "name": "compare_line_fits",
      "summary": "Fit one spectral line with every profile in turn and pick the best one.",
      "description": "Useful when you do not know whether a line is pressure-broadened (Lorentzian wings dominate), thermally / instrumentally broadened (Gaussian is enough), or genuinely mixed (Voigt). The wrapper picks no winner itself — it leaves all three fits on the context for you to compare.",
      "category": "line_fitting",
      "category_title": "Line fitting",
      "version": "1.0.0",
      "backend": "scipy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "line_fits",
        "extras.line_fit_comparison"
      ],
      "parameters": [
        {
          "name": "line_center_angstrom",
          "default": null,
          "required": true,
          "description": "Approximate line centre in Å (required)."
        },
        {
          "name": "window_angstrom",
          "default": 20.0,
          "required": false,
          "description": "Half-width of the fit window on each side (Å)."
        },
        {
          "name": "profiles",
          "default": [
            "fit_gaussian_line",
            "fit_lorentzian_line",
            "fit_voigt_line"
          ],
          "required": false,
          "description": "Names of fit_*_line algorithms to run."
        },
        {
          "name": "label_prefix",
          "default": "",
          "required": false,
          "description": "Prefix for the keys written to ctx.line_fits (defaults to '')."
        }
      ],
      "references": [
        "Catalogue fit_*_line algorithms; this wrapper is composition only."
      ],
      "related": [
        "equivalent_width",
        "fit_gaussian_line",
        "fit_lorentzian_line",
        "fit_voigt_line",
        "vr_ratio"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"compare_line_fits\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run compare_line_fits --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "compare_line_fits",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/line_fitting/compare_line_fits/"
    },
    {
      "id": "equivalent_width",
      "name": "equivalent_width",
      "summary": "Measure a line's equivalent width without assuming a profile shape.",
      "description": "EW = ∫(1 − F/Fc) dλ over [centre − window, centre + window] with Fc a straight line through the outer `continuum_fraction` of the window on each side (trapezoidal integration on the actual wavelengths). metrics['equivalent_width_angstrom'] is kept as the flat key (last call wins) ; v1.1.0 also writes metrics['equivalent_width_angstrom[<label>]'] with label defaulting to the centre rounded to 0.1 Å, like the fit_*_line bricks. When the spectrum carries an uncertainty array, metrics['equivalent_width_error_angstrom'] (and its per-label key) is the Vollmann & Eversberg 2006 Eq. 7 error with Δλ the actual window width, F̄ the mean flux over the window, F̄c the mean fitted continuum and S/N = mean(Fc) / RMS(uncertainty) over the edge bands. A descending wavelength axis is sorted before integration (v1.0.0 returned a sign-flipped, truncated value on such input).",
      "category": "line_fitting",
      "category_title": "Line fitting",
      "version": "1.1.0",
      "backend": "numpy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "metrics.equivalent_width_angstrom",
        "metrics.equivalent_width_error_angstrom",
        "metrics.equivalent_width_angstrom[label]",
        "metrics.equivalent_width_error_angstrom[label]"
      ],
      "parameters": [
        {
          "name": "line_center_angstrom",
          "default": null,
          "required": true,
          "description": "Line centre in Å (required)."
        },
        {
          "name": "window_angstrom",
          "default": 20.0,
          "required": false,
          "description": "Half-width of the integration window on each side (Å)."
        },
        {
          "name": "continuum_fraction",
          "default": 0.3,
          "required": false,
          "description": "Fraction of each window edge used to anchor the continuum."
        },
        {
          "name": "label",
          "default": "",
          "required": false,
          "description": "Suffix for the per-label metric keys equivalent_width_angstrom[<label>]; defaults to the centre rounded to 0.1 Å."
        }
      ],
      "references": [
        "Vollmann & Eversberg 2006, Astron. Nachr. 327, 862 — statistical error of an equivalent width, Eq. 7: σ(EW) = √(1 + F̄c/F̄)·(Δλ − EW)/(S/N).",
        "Gray 2005, The Observation and Analysis of Stellar Photospheres, 3rd ed., Cambridge UP — ch. 12, equivalent widths and continuum placement."
      ],
      "related": [
        "compare_line_fits",
        "fit_gaussian_line",
        "fit_lorentzian_line",
        "fit_voigt_line",
        "vr_ratio"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"equivalent_width\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run equivalent_width --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "equivalent_width",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/line_fitting/equivalent_width/"
    },
    {
      "id": "fit_gaussian_line",
      "name": "fit_gaussian_line",
      "summary": "Fit a single Gaussian (plus a linear continuum) to one spectral line.",
      "description": "Equivalent width follows the convention EW > 0 for absorption and EW < 0 for emission. The Gaussian is the right choice for instrument- or thermally-broadened lines.",
      "category": "line_fitting",
      "category_title": "Line fitting",
      "version": "1.1.0",
      "backend": "scipy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "line_fits",
        "metrics.fwhm_angstrom"
      ],
      "parameters": [
        {
          "name": "line_center_angstrom",
          "default": null,
          "required": true,
          "description": "Approximate line centre in Å (required)."
        },
        {
          "name": "window_angstrom",
          "default": 20.0,
          "required": false,
          "description": "Half-width of the fit window on each side of the line (Å)."
        },
        {
          "name": "label",
          "default": "",
          "required": false,
          "description": "Key under which to store the result; defaults to the rounded centre."
        }
      ],
      "references": [
        "scipy.optimize.curve_fit — Levenberg-Marquardt / Trust Region Reflective"
      ],
      "related": [
        "compare_line_fits",
        "equivalent_width",
        "fit_lorentzian_line",
        "fit_voigt_line",
        "vr_ratio"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"fit_gaussian_line\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run fit_gaussian_line --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "fit_gaussian_line",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/line_fitting/fit_gaussian_line/"
    },
    {
      "id": "fit_lorentzian_line",
      "name": "fit_lorentzian_line",
      "summary": "Fit a single Lorentzian (plus a linear continuum) to one spectral line.",
      "description": "The fitted width parameter is the half-width at half-maximum (gamma); the reported FWHM is 2*gamma.",
      "category": "line_fitting",
      "category_title": "Line fitting",
      "version": "1.0.0",
      "backend": "scipy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "line_fits",
        "metrics.fwhm_angstrom"
      ],
      "parameters": [
        {
          "name": "line_center_angstrom",
          "default": null,
          "required": true,
          "description": "Approximate line centre in Å (required)."
        },
        {
          "name": "window_angstrom",
          "default": 20.0,
          "required": false,
          "description": "Half-width of the fit window on each side of the line (Å)."
        },
        {
          "name": "label",
          "default": "",
          "required": false,
          "description": "Key under which to store the result; defaults to the rounded centre."
        }
      ],
      "references": [
        "scipy.optimize.curve_fit — Levenberg-Marquardt / Trust Region Reflective"
      ],
      "related": [
        "compare_line_fits",
        "equivalent_width",
        "fit_gaussian_line",
        "fit_voigt_line",
        "vr_ratio"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"fit_lorentzian_line\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run fit_lorentzian_line --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "fit_lorentzian_line",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/line_fitting/fit_lorentzian_line/"
    },
    {
      "id": "fit_voigt_line",
      "name": "fit_voigt_line",
      "summary": "Fit a single Voigt profile (plus a linear continuum) to one spectral line.",
      "description": "Two width parameters are fitted: the Gaussian sigma and the Lorentzian gamma. Both are recorded in the result's meta.",
      "category": "line_fitting",
      "category_title": "Line fitting",
      "version": "1.0.0",
      "backend": "scipy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "line_fits",
        "metrics.fwhm_angstrom"
      ],
      "parameters": [
        {
          "name": "line_center_angstrom",
          "default": null,
          "required": true,
          "description": "Approximate line centre in Å (required)."
        },
        {
          "name": "window_angstrom",
          "default": 20.0,
          "required": false,
          "description": "Half-width of the fit window on each side of the line (Å)."
        },
        {
          "name": "label",
          "default": "",
          "required": false,
          "description": "Key under which to store the result; defaults to the rounded centre."
        }
      ],
      "references": [
        "scipy.special.voigt_profile",
        "Olivero & Longbothum 1977, JQSRT 17, 233 — Voigt FWHM approximation",
        "scipy.optimize.curve_fit"
      ],
      "related": [
        "compare_line_fits",
        "equivalent_width",
        "fit_gaussian_line",
        "fit_lorentzian_line",
        "vr_ratio"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"fit_voigt_line\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run fit_voigt_line --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "fit_voigt_line",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/line_fitting/fit_voigt_line/"
    },
    {
      "id": "vr_ratio",
      "name": "vr_ratio",
      "summary": "Violet/Red intensity ratio of a double-peaked emission line.",
      "description": "Continuum from window-edge medians (mean of the two edge medians, edge = max(3, 0.1 · n)). Detection on the normalised flux with scipy.signal.find_peaks(prominence=min_prominence), restricted to ±0.7 · window around the centre. Returns single_peaked=True with vr_ratio=None when only one side has a peak.",
      "category": "line_fitting",
      "category_title": "Line fitting",
      "version": "1.0.1",
      "backend": "scipy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "metrics.vr_ratio",
        "metrics.vr_ratio_minus1",
        "metrics.v_velocity_kms",
        "metrics.r_velocity_kms",
        "metrics.peak_separation_kms",
        "metrics.central_depth",
        "metrics.fwhm_aa",
        "metrics.single_peaked",
        "metrics.v_intensity",
        "metrics.r_intensity",
        "metrics.line_center_aa",
        "extras.vr_result"
      ],
      "parameters": [
        {
          "name": "line_center_aa",
          "default": 6562.82,
          "required": false,
          "description": "Rest wavelength (Å) of the line centre."
        },
        {
          "name": "window_half_width_aa",
          "default": 15.0,
          "required": false,
          "description": "Half-width (Å) of the analysis window around the centre."
        },
        {
          "name": "min_prominence",
          "default": 0.05,
          "required": false,
          "description": "Peak-detection prominence on the normalised flux (find_peaks)."
        }
      ],
      "references": [
        "Okazaki 1991, PASJ 43, 75 — long-term V/R variations of Be stars from global one-armed oscillations of the disc.",
        "Hummel & Vrancken 2000, A&A 359, 1075 — Be-star V/R variability.",
        "scipy.signal.find_peaks — prominence-thresholded peak detection."
      ],
      "related": [
        "compare_line_fits",
        "equivalent_width",
        "fit_gaussian_line",
        "fit_lorentzian_line",
        "fit_voigt_line"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"vr_ratio\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run vr_ratio --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "vr_ratio",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/line_fitting/vr_ratio/"
    },
    {
      "id": "bias_combine",
      "name": "bias_combine",
      "summary": "Combine a stack of bias frames into one master bias.",
      "description": "",
      "category": "master_creation",
      "category_title": "Master frames",
      "version": "1.0.0",
      "backend": "numpy",
      "inputs": [
        "images"
      ],
      "outputs": [
        "extras.master_bias"
      ],
      "parameters": [
        {
          "name": "method",
          "default": "median",
          "required": false,
          "description": "Combination method: 'median' (robust) or 'mean'."
        }
      ],
      "references": [
        "Howell 2006 — Handbook of CCD Astronomy, ch. 4 (standard CCD reduction)."
      ],
      "related": [
        "bias_combine_easyspec",
        "dark_combine",
        "dark_combine_easyspec",
        "flat_combine",
        "flat_combine_easyspec"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"bias_combine\", ctx, params)"
        },
        "cli": {
          "available": false,
          "command": "spectro run bias_combine --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "bias_combine",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/master_creation/bias_combine/"
    },
    {
      "id": "bias_combine_easyspec",
      "name": "bias_combine_easyspec",
      "summary": "Combine bias frames into a master bias via ``easyspec.cleaning.master``.",
      "description": "Requires the optional `reduction` extra. The matplotlib backend is forced to 'Agg' at import time so easyspec's plotting defaults never open a window in a server context.",
      "category": "master_creation",
      "category_title": "Master frames",
      "version": "1.1.1",
      "backend": "easyspec",
      "inputs": [],
      "outputs": [
        "extras.master_bias"
      ],
      "parameters": [
        {
          "name": "bias_dir",
          "default": null,
          "required": false,
          "description": "Directory containing the bias FITS files."
        },
        {
          "name": "bias_paths",
          "default": null,
          "required": false,
          "description": "List of bias FITS file paths (symlinked into a temp dir)."
        },
        {
          "name": "method",
          "default": "median",
          "required": false,
          "description": "Stacking method passed to easyspec: median, mean, or mode."
        },
        {
          "name": "header_hdu_entry",
          "default": 0,
          "required": false,
          "description": "HDU extension where the FITS header lives."
        }
      ],
      "references": [
        "easyspec — Lobão et al. (https://pypi.org/project/easyspec/).",
        "easyspec.cleaning.cleaning.master."
      ],
      "related": [
        "bias_combine",
        "dark_combine",
        "dark_combine_easyspec",
        "flat_combine",
        "flat_combine_easyspec"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"bias_combine_easyspec\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run bias_combine_easyspec --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "bias_combine_easyspec",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/master_creation/bias_combine_easyspec/"
    },
    {
      "id": "dark_combine",
      "name": "dark_combine",
      "summary": "Combine a stack of dark frames into a master dark.",
      "description": "Pixel-wise median (robust default) or σ-clipped mean over the stack. require_uniform_exptime=True (the default) ensures every input has the same EXPTIME — otherwise a non-uniform mix would render the master un-scalable by exposure time. Pass bias_key to subtract a master bias from each frame before combining. v2.0.0 (method='mean' with sigma_clip only): the σ-clip scale is 1.4826·MAD about the per-pixel median; when the MAD is 0 (integer-ADU darks with ~1 ADU read noise) it now falls back to 1.2533 × the mean absolute deviation about the median, and keeps all values when that is 0 too. v1 floored the scale at machine epsilon, which rejected every value ≠ median and turned the clipped mean into a median ([10,10,10,11,12], σ=3: v1 → 10.0, v2 → 10.6; a cosmic ray [10,10,10,11,1000] is still rejected → 10.25). method='median' is unchanged.",
      "category": "master_creation",
      "category_title": "Master frames",
      "version": "2.0.0",
      "backend": "numpy",
      "inputs": [
        "images"
      ],
      "outputs": [
        "extras.master_dark"
      ],
      "parameters": [
        {
          "name": "method",
          "default": "median",
          "required": false,
          "description": "'median' (robust default) or 'mean' (with optional sigma_clip)."
        },
        {
          "name": "bias_key",
          "default": null,
          "required": false,
          "description": "ctx.extras key for a master bias to subtract from each frame before combining. None = no pre-subtraction."
        },
        {
          "name": "require_uniform_exptime",
          "default": true,
          "required": false,
          "description": "When True, refuse to combine frames with non-uniform EXPTIME (otherwise a scale_by_exptime downstream would be undefined)."
        },
        {
          "name": "sigma_clip",
          "default": null,
          "required": false,
          "description": "σ threshold for σ-clipped mean (method='mean' only). None or ≤0 disables clipping."
        }
      ],
      "references": [
        "Howell 2006 — Handbook of CCD Astronomy, ch. 4 (CCD reduction)."
      ],
      "related": [
        "bias_combine",
        "bias_combine_easyspec",
        "dark_combine_easyspec",
        "flat_combine",
        "flat_combine_easyspec"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"dark_combine\", ctx, params)"
        },
        "cli": {
          "available": false,
          "command": "spectro run dark_combine --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "dark_combine",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/master_creation/dark_combine/"
    },
    {
      "id": "dark_combine_easyspec",
      "name": "dark_combine_easyspec",
      "summary": "Combine dark frames into a master dark via ``easyspec.cleaning.master``.",
      "description": "",
      "category": "master_creation",
      "category_title": "Master frames",
      "version": "1.0.1",
      "backend": "easyspec",
      "inputs": [],
      "outputs": [
        "extras.master_dark"
      ],
      "parameters": [
        {
          "name": "dark_dir",
          "default": null,
          "required": false,
          "description": "Directory containing the dark FITS files."
        },
        {
          "name": "dark_paths",
          "default": null,
          "required": false,
          "description": "List of dark FITS file paths."
        },
        {
          "name": "method",
          "default": "median",
          "required": false,
          "description": "Stacking method: median, mean, or mode."
        },
        {
          "name": "header_hdu_entry",
          "default": 0,
          "required": false,
          "description": "HDU extension where the FITS header lives."
        }
      ],
      "references": [
        "easyspec — Lobão et al. (https://pypi.org/project/easyspec/).",
        "easyspec.cleaning.cleaning.master."
      ],
      "related": [
        "bias_combine",
        "bias_combine_easyspec",
        "dark_combine",
        "flat_combine",
        "flat_combine_easyspec"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"dark_combine_easyspec\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run dark_combine_easyspec --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "dark_combine_easyspec",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/master_creation/dark_combine_easyspec/"
    },
    {
      "id": "flat_combine",
      "name": "flat_combine",
      "summary": "Combine a stack of flat frames into a master flat (raw, not normalised).",
      "description": "Pixel-wise median (default) or σ-clipped mean. Normalisation is intentionally NOT folded in: keep flat_combine producing the raw master, then call flat_normalize on the result. Separation of concerns mirrors the bias_combine / dark_combine pattern. Caveat: flat_normalize divides the master by a single global median (not row-by-row / not along the dispersion), so the flat lamp's spectral shape stays imprinted in the normalised flat and is divided into the science frame — the catalogue has no equivalent of IRAF's `response` (fit of the lamp continuum along the dispersion) yet; for spectrophotometry derive the response with response_from_standard afterwards. v2.0.0 (method='mean' with sigma_clip only): same MAD = 0 fallback as dark_combine v2.0.0 — the σ-clip scale falls back to 1.2533 × the mean absolute deviation about the median instead of rejecting every value ≠ median ([10,10,10,11,12], σ=3: v1 → 10.0, v2 → 10.6; [10,10,10,11,1000] → 10.25, outlier still rejected). method='median' is unchanged.",
      "category": "master_creation",
      "category_title": "Master frames",
      "version": "2.0.0",
      "backend": "numpy",
      "inputs": [
        "images"
      ],
      "outputs": [
        "extras.master_flat"
      ],
      "parameters": [
        {
          "name": "method",
          "default": "median",
          "required": false,
          "description": "'median' (robust default) or 'mean' (with optional sigma_clip)."
        },
        {
          "name": "bias_key",
          "default": null,
          "required": false,
          "description": "ctx.extras key for a master bias to subtract from each frame. None = no pre-subtraction."
        },
        {
          "name": "dark_key",
          "default": null,
          "required": false,
          "description": "ctx.extras key for a master dark to subtract from each frame. None = no pre-subtraction."
        },
        {
          "name": "scale_by_exptime",
          "default": true,
          "required": false,
          "description": "Scale the master dark by EXPTIME_flat / EXPTIME_dark before subtracting. Requires both EXPTIMEs in the headers."
        },
        {
          "name": "sigma_clip",
          "default": null,
          "required": false,
          "description": "σ threshold for σ-clipped mean (method='mean' only). None or ≤0 disables clipping."
        }
      ],
      "references": [
        "Howell 2006 — Handbook of CCD Astronomy, ch. 4 (CCD reduction)."
      ],
      "related": [
        "bias_combine",
        "bias_combine_easyspec",
        "dark_combine",
        "dark_combine_easyspec",
        "flat_combine_easyspec"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"flat_combine\", ctx, params)"
        },
        "cli": {
          "available": false,
          "command": "spectro run flat_combine --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "flat_combine",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/master_creation/flat_combine/"
    },
    {
      "id": "flat_combine_easyspec",
      "name": "flat_combine_easyspec",
      "summary": "Combine flat frames into a master flat via ``easyspec.cleaning.master``.",
      "description": "",
      "category": "master_creation",
      "category_title": "Master frames",
      "version": "1.0.1",
      "backend": "easyspec",
      "inputs": [],
      "outputs": [
        "extras.master_flat"
      ],
      "parameters": [
        {
          "name": "flat_dir",
          "default": null,
          "required": false,
          "description": "Directory containing the flat FITS files."
        },
        {
          "name": "flat_paths",
          "default": null,
          "required": false,
          "description": "List of flat FITS file paths."
        },
        {
          "name": "method",
          "default": "median",
          "required": false,
          "description": "Stacking method: median, mean, or mode."
        },
        {
          "name": "header_hdu_entry",
          "default": 0,
          "required": false,
          "description": "HDU extension where the FITS header lives."
        }
      ],
      "references": [
        "easyspec — Lobão et al. (https://pypi.org/project/easyspec/).",
        "easyspec.cleaning.cleaning.master."
      ],
      "related": [
        "bias_combine",
        "bias_combine_easyspec",
        "dark_combine",
        "dark_combine_easyspec",
        "flat_combine"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"flat_combine_easyspec\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run flat_combine_easyspec --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "flat_combine_easyspec",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/master_creation/flat_combine_easyspec/"
    },
    {
      "id": "bpt_line_ratios",
      "name": "bpt_line_ratios",
      "summary": "Measure BPT line ratios + classify a galaxy (HII / Composite / Seyfert / LINER).",
      "description": "BPT classification uses the [N II]/Hα plane only (the most-cited standard). Auxiliary log ratios log_oii_hb (3727/Hβ) and log_sii_ha ((6716+6731)/Hα) are reported for downstream analysis but not used for the class label. Override the default rest wavelengths via params['lines'] (dict). v1.1.0: extras['method'] (shared with the other nebular bricks) is also written as extras['bpt_method'], and line_fluxes / rest_wavelengths / fit_details as bpt_line_fluxes / bpt_rest_wavelengths / bpt_fit_details, so a pipeline keeps unambiguous provenance.",
      "category": "nebular",
      "category_title": "Nebular diagnostics",
      "version": "1.1.0",
      "backend": "scipy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "metrics.log_nii_ha",
        "metrics.log_oiii_hb",
        "metrics.log_sii_ha",
        "metrics.log_oii_hb",
        "extras.bpt_classification",
        "extras.line_fluxes",
        "extras.rest_wavelengths",
        "extras.fit_details",
        "extras.method",
        "extras.bpt_line_fluxes",
        "extras.bpt_rest_wavelengths",
        "extras.bpt_fit_details",
        "extras.bpt_method"
      ],
      "parameters": [
        {
          "name": "window",
          "default": 8.0,
          "required": false,
          "description": "Half-width (Å) of the fit window around each line."
        },
        {
          "name": "lines",
          "default": {
            "ha": 6562.82,
            "hb": 4861.33,
            "oiii_5007": 5006.84,
            "nii_6583": 6583.45,
            "sii_6716": 6716.44,
            "sii_6731": 6730.82,
            "oii_3727": 3727.42
          },
          "required": false,
          "description": "Mapping of line keys to rest wavelengths (Å). Keys: ha, hb, oiii_5007, nii_6583, sii_6716, sii_6731, oii_3727."
        }
      ],
      "references": [
        "Baldwin, Phillips & Terlevich 1981, PASP 93, 5 — original BPT.",
        "Kewley & Dopita 2001, ApJ 556, 121 — maximum-starburst line.",
        "Kauffmann et al. 2003, MNRAS 346, 1055 — HII/AGN empirical split.",
        "Cid Fernandes et al. 2010, MNRAS 403, 1036 — Seyfert/LINER cut.",
        "Kewley et al. 2006, MNRAS 372, 961 — [S II], [O I] variants."
      ],
      "related": [
        "oiii_electron_temperature",
        "sii_electron_density"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"bpt_line_ratios\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run bpt_line_ratios --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "bpt_line_ratios",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/nebular/bpt_line_ratios/"
    },
    {
      "id": "oiii_electron_temperature",
      "name": "oiii_electron_temperature",
      "summary": "Electron temperature Te (K) from the [O III] (λ4959+λ5007)/λ4363 ratio.",
      "description": "F = |amp| · |σ| · √(2π) per line (shared Gaussian-line primitive, default window ±8.0 Å). metrics['ratio'] (= 'oiii_ratio' = 'oiii_ratio_doublet') is the doublet ratio [F(4959)+F(5007)]/F(4363); the v1 single-line ratio is kept as metrics['ratio_5007_4363']. Returns NaN te_kelvin when ratio ≤ 7.90 (log argument non-positive) or Te ∉ [3000, 30000] K. The validity regime is exposed in extras['regime'] (and the prefixed alias extras['oiii_regime']) so downstream code can treat it explicitly. v2.0.0: the 7.90 prefactor now multiplies the (4959+5007)/4363 doublet ratio as in Osterbrock & Ferland 2006 Eq. 5.4 — v1.x applied it to F(5007)/F(4363) alone, which biased Te high (R5007 = 100 : 12961 K → 11635 K ; R5007 = 50 : 17830 K → 15413 K). λ4959 is now fitted (new param lambda_4959) and falls back to F(5007)·(1+1/2.98) when unmeasurable ; the path is recorded in extras['oiii_doublet_source']. Prefixed keys oiii_* were added alongside the generic ratio/regime/notes/method keys.",
      "category": "nebular",
      "category_title": "Nebular diagnostics",
      "version": "2.0.0",
      "backend": "scipy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "metrics.te_kelvin",
        "metrics.ratio",
        "metrics.oiii_ratio",
        "metrics.oiii_ratio_doublet",
        "metrics.ratio_5007_4363",
        "metrics.ratio_5007_4959",
        "metrics.flux_5007",
        "metrics.flux_4959",
        "metrics.flux_4363",
        "metrics.oiii_doublet_from_fit",
        "extras.regime",
        "extras.notes",
        "extras.method",
        "extras.oiii_regime",
        "extras.oiii_notes",
        "extras.oiii_method",
        "extras.oiii_doublet_source"
      ],
      "parameters": [
        {
          "name": "lambda_5007",
          "default": 5006.84,
          "required": false,
          "description": "Rest wavelength (Å) of [O III] 5007 (default: NIST air)."
        },
        {
          "name": "lambda_4959",
          "default": 4958.911,
          "required": false,
          "description": "Rest wavelength (Å) of [O III] 4959 (default: NIST air). Fitted when in range; otherwise F(4959) = F(5007)/2.98 is assumed."
        },
        {
          "name": "lambda_4363",
          "default": 4363.21,
          "required": false,
          "description": "Rest wavelength (Å) of [O III] 4363 (default: NIST air)."
        },
        {
          "name": "window",
          "default": 8.0,
          "required": false,
          "description": "Half-width (Å) of the fit window around each line."
        }
      ],
      "references": [
        "Osterbrock & Ferland 2006, Astrophysics of Gaseous Nebulae and AGN, 2nd ed., University Science Books — Eq. 5.4, (4959+5007)/4363 = 7.90·exp(32900/T).",
        "Aller 1984, Physics of Thermal Gaseous Nebulae, Reidel — Eq. 5-3 (5-level atom inversion).",
        "Storey & Zeippen 2000, MNRAS 312, 813 — theoretical [O III] 5007/4959 ratio 2.98 (doublet reconstruction when 4959 is absent).",
        "Kramida et al., NIST ASD — air rest wavelengths λ5006.84, λ4958.911, λ4363.21."
      ],
      "related": [
        "bpt_line_ratios",
        "sii_electron_density"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"oiii_electron_temperature\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run oiii_electron_temperature --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "oiii_electron_temperature",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/nebular/oiii_electron_temperature/"
    },
    {
      "id": "sii_electron_density",
      "name": "sii_electron_density",
      "summary": "Electron density n_e (cm⁻³) from the [S II] λ6716/λ6731 ratio.",
      "description": "F = |amp| · |σ| · √(2π) per line (shared Gaussian-line primitive). Default window ±5.0 Å so the 14.4 Å doublet stays resolved. The polynomial domain [0.45, 1.43] is enforced by clamp; the regime label records 'valid' / 'low-density' / 'high-density' / 'invalid'. v1.1.0: the generic keys metrics['ratio'] and extras['regime'|'notes'|'method'] are shared with oiii_electron_temperature and get overwritten in a pipeline ; the prefixed aliases metrics['sii_ratio'] and extras['sii_regime'|'sii_notes'|'sii_method'] carry the same values unambiguously.",
      "category": "nebular",
      "category_title": "Nebular diagnostics",
      "version": "1.1.0",
      "backend": "scipy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "metrics.ne_cm3",
        "metrics.ratio",
        "metrics.sii_ratio",
        "metrics.log_ne",
        "metrics.flux_6716",
        "metrics.flux_6731",
        "extras.regime",
        "extras.notes",
        "extras.method",
        "extras.sii_regime",
        "extras.sii_notes",
        "extras.sii_method"
      ],
      "parameters": [
        {
          "name": "lambda_6716",
          "default": 6716.44,
          "required": false,
          "description": "Rest wavelength (Å) of [S II] 6716 (default: NIST air)."
        },
        {
          "name": "lambda_6731",
          "default": 6730.82,
          "required": false,
          "description": "Rest wavelength (Å) of [S II] 6731 (default: NIST air)."
        },
        {
          "name": "window",
          "default": 5.0,
          "required": false,
          "description": "Half-width (Å) of the fit window around each line."
        }
      ],
      "references": [
        "Proxauf, Öttl & Kimeswenger 2014, A&A 561, A10 — improved n_e from nebular line ratios (Eq. 6, Table 2).",
        "Osterbrock & Ferland 2006, Astrophysics of Gaseous Nebulae and AGN, 2nd ed., University Science Books — §5.4.",
        "Kramida et al., NIST ASD — air rest wavelengths λ6716.44, λ6730.82."
      ],
      "related": [
        "bpt_line_ratios",
        "oiii_electron_temperature"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"sii_electron_density\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run sii_electron_density --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "sii_electron_density",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/nebular/sii_electron_density/"
    },
    {
      "id": "correct_slant_affine",
      "name": "correct_slant_affine",
      "summary": "Make monochromatic lines parallel to the rows by a horizontal shear.",
      "description": "scipy.ndimage.affine_transform with shear [[1, 0], [-tan θ, 1]] and offset [0, tan θ · pivot_row], order=1 bilinear. Zero slant is a no-op.",
      "category": "preprocessing",
      "category_title": "Preprocessing (2-D image)",
      "version": "1.0.0",
      "backend": "scipy",
      "inputs": [
        "image"
      ],
      "outputs": [
        "image"
      ],
      "parameters": [
        {
          "name": "slant_deg",
          "default": 0.0,
          "required": false,
          "description": "Slant angle of monochromatic lines (degrees)."
        },
        {
          "name": "pivot_row",
          "default": 0,
          "required": false,
          "description": "Row where the shear has zero displacement (use the trace row)."
        }
      ],
      "references": [
        "Howell 2006, Handbook of CCD Astronomy 2nd ed. §5.2 (Cambridge UP).",
        "Tody 1986, Proc. SPIE 627, 733 — IRAF transform heritage."
      ],
      "related": [
        "correct_smile_polynomial",
        "correct_tilt_affine",
        "dark_subtract",
        "denoise_gaussian_2d",
        "denoise_median_2d",
        "flat_normalize",
        "flat_normalize_easyspec",
        "outlier_rejection_mad_adaptive",
        "subtract_bias_easyspec",
        "subtract_dark_easyspec",
        "subtract_sky_2d"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"correct_slant_affine\", ctx, params)"
        },
        "cli": {
          "available": false,
          "command": "spectro run correct_slant_affine --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "correct_slant_affine",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/preprocessing/correct_slant_affine/"
    },
    {
      "id": "correct_smile_polynomial",
      "name": "correct_smile_polynomial",
      "summary": "Undo smile curvature via the Schroeder even-order expansion.",
      "description": "Computes the column-displacement field from the paraxial Seidel expansion, then resamples with scipy.ndimage.map_coordinates (order=1, zero-fill outside). Reference row is the optical axis (usually the science trace) — no displacement there.",
      "category": "preprocessing",
      "category_title": "Preprocessing (2-D image)",
      "version": "1.0.0",
      "backend": "scipy",
      "inputs": [
        "image"
      ],
      "outputs": [
        "image"
      ],
      "parameters": [
        {
          "name": "reference_row",
          "default": 0,
          "required": true,
          "description": "Optical-axis row y₀ (no lateral shift there); use the trace row."
        },
        {
          "name": "smile_radius",
          "default": 0.0,
          "required": true,
          "description": "Smile radius R in pixels (instrument-specific). 0 disables."
        },
        {
          "name": "polynomial_order",
          "default": 6,
          "required": false,
          "description": "Highest even power kept: one of 2, 4, 6."
        }
      ],
      "references": [
        "Schroeder 2000, Astronomical Optics 2nd ed. ch.15 §15.3 — off-axis aberration expansion.",
        "Bottema 1980, Appl. Opt. 19, 444 — smile curvature in concave-grating spectrographs."
      ],
      "related": [
        "correct_slant_affine",
        "correct_tilt_affine",
        "dark_subtract",
        "denoise_gaussian_2d",
        "denoise_median_2d",
        "flat_normalize",
        "flat_normalize_easyspec",
        "outlier_rejection_mad_adaptive",
        "subtract_bias_easyspec",
        "subtract_dark_easyspec",
        "subtract_sky_2d"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"correct_smile_polynomial\", ctx, params)"
        },
        "cli": {
          "available": false,
          "command": "spectro run correct_smile_polynomial --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "correct_smile_polynomial",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/preprocessing/correct_smile_polynomial/"
    },
    {
      "id": "correct_tilt_affine",
      "name": "correct_tilt_affine",
      "summary": "Re-align the slit with the detector columns by a vertical shear.",
      "description": "scipy.ndimage.affine_transform with shear matrix [[1, -tan θ], [0, 1]], order=1 bilinear, zero-fill outside. The offset re-centres the shear on the image so the centre column remains in place. Pair with correct_slant_affine and correct_smile_polynomial in a long-slit preset.",
      "category": "preprocessing",
      "category_title": "Preprocessing (2-D image)",
      "version": "1.0.0",
      "backend": "scipy",
      "inputs": [
        "image"
      ],
      "outputs": [
        "image"
      ],
      "parameters": [
        {
          "name": "tilt_deg",
          "default": 0.0,
          "required": false,
          "description": "Tilt angle of the slit vs. detector columns (degrees)."
        }
      ],
      "references": [
        "Howell 2006, Handbook of CCD Astronomy 2nd ed. §5.2 (Cambridge UP).",
        "Tody 1986, Proc. SPIE 627, 733 — IRAF transform heritage."
      ],
      "related": [
        "correct_slant_affine",
        "correct_smile_polynomial",
        "dark_subtract",
        "denoise_gaussian_2d",
        "denoise_median_2d",
        "flat_normalize",
        "flat_normalize_easyspec",
        "outlier_rejection_mad_adaptive",
        "subtract_bias_easyspec",
        "subtract_dark_easyspec",
        "subtract_sky_2d"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"correct_tilt_affine\", ctx, params)"
        },
        "cli": {
          "available": false,
          "command": "spectro run correct_tilt_affine --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "correct_tilt_affine",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/preprocessing/correct_tilt_affine/"
    },
    {
      "id": "dark_subtract",
      "name": "dark_subtract",
      "summary": "Subtract a master dark from ``ctx.image``.",
      "description": "Reads the master dark from ``ctx.extras['master_dark']`` (an ImageFrame) or from ``dark_key`` if you stored it under a different key.",
      "category": "preprocessing",
      "category_title": "Preprocessing (2-D image)",
      "version": "1.0.0",
      "backend": "numpy",
      "inputs": [
        "image"
      ],
      "outputs": [
        "image"
      ],
      "parameters": [
        {
          "name": "dark_key",
          "default": "master_dark",
          "required": false,
          "description": "Key in ctx.extras where the master dark is stored."
        },
        {
          "name": "scale_by_exptime",
          "default": true,
          "required": false,
          "description": "Scale the dark by EXPTIME(science)/EXPTIME(dark) before subtracting."
        }
      ],
      "references": [
        "Howell 2006 — Handbook of CCD Astronomy, ch. 4."
      ],
      "related": [
        "correct_slant_affine",
        "correct_smile_polynomial",
        "correct_tilt_affine",
        "denoise_gaussian_2d",
        "denoise_median_2d",
        "flat_normalize",
        "flat_normalize_easyspec",
        "outlier_rejection_mad_adaptive",
        "subtract_bias_easyspec",
        "subtract_dark_easyspec",
        "subtract_sky_2d"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"dark_subtract\", ctx, params)"
        },
        "cli": {
          "available": false,
          "command": "spectro run dark_subtract --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "dark_subtract",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/preprocessing/dark_subtract/"
    },
    {
      "id": "denoise_gaussian_2d",
      "name": "denoise_gaussian_2d",
      "summary": "Separable isotropic Gaussian smoothing of ``ctx.image``.",
      "description": "scipy.ndimage.gaussian_filter(sigma), default mode 'reflect'. Isotropic — the same σ is applied to rows and columns. For anisotropic smoothing, pass a tuple via the underlying scipy API directly.",
      "category": "preprocessing",
      "category_title": "Preprocessing (2-D image)",
      "version": "1.0.0",
      "backend": "scipy",
      "inputs": [
        "image"
      ],
      "outputs": [
        "image"
      ],
      "parameters": [
        {
          "name": "sigma",
          "default": 0.0,
          "required": false,
          "description": "Gaussian σ in pixels (isotropic). 0 = no-op."
        }
      ],
      "references": [
        "Lindeberg 1994, Scale-Space Theory in Computer Vision (Kluwer).",
        "scipy.ndimage.gaussian_filter — separable Gaussian implementation."
      ],
      "related": [
        "correct_slant_affine",
        "correct_smile_polynomial",
        "correct_tilt_affine",
        "dark_subtract",
        "denoise_median_2d",
        "flat_normalize",
        "flat_normalize_easyspec",
        "outlier_rejection_mad_adaptive",
        "subtract_bias_easyspec",
        "subtract_dark_easyspec",
        "subtract_sky_2d"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"denoise_gaussian_2d\", ctx, params)"
        },
        "cli": {
          "available": false,
          "command": "spectro run denoise_gaussian_2d --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "denoise_gaussian_2d",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/preprocessing/denoise_gaussian_2d/"
    },
    {
      "id": "denoise_median_2d",
      "name": "denoise_median_2d",
      "summary": "Square median filter over ``ctx.image``, optionally banded by rows.",
      "description": "scipy.ndimage.median_filter(size=kernel_size) over rows [row_lo, row_hi). Outside the band the original pixels are kept.",
      "category": "preprocessing",
      "category_title": "Preprocessing (2-D image)",
      "version": "1.0.0",
      "backend": "scipy",
      "inputs": [
        "image"
      ],
      "outputs": [
        "image"
      ],
      "parameters": [
        {
          "name": "kernel_size",
          "default": 3,
          "required": false,
          "description": "Square kernel size, odd integer ≥ 3."
        },
        {
          "name": "row_lo",
          "default": 0,
          "required": false,
          "description": "First row (inclusive); 0 ⇒ top of image."
        },
        {
          "name": "row_hi",
          "default": 0,
          "required": false,
          "description": "Last row (exclusive); 0 ⇒ bottom of image."
        }
      ],
      "references": [
        "Tukey 1977, Exploratory Data Analysis (Addison-Wesley) — rank-order filtering for impulse noise.",
        "scipy.ndimage.median_filter — multi-dim median implementation."
      ],
      "related": [
        "correct_slant_affine",
        "correct_smile_polynomial",
        "correct_tilt_affine",
        "dark_subtract",
        "denoise_gaussian_2d",
        "flat_normalize",
        "flat_normalize_easyspec",
        "outlier_rejection_mad_adaptive",
        "subtract_bias_easyspec",
        "subtract_dark_easyspec",
        "subtract_sky_2d"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"denoise_median_2d\", ctx, params)"
        },
        "cli": {
          "available": false,
          "command": "spectro run denoise_median_2d --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "denoise_median_2d",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/preprocessing/denoise_median_2d/"
    },
    {
      "id": "flat_normalize",
      "name": "flat_normalize",
      "summary": "Divide ``ctx.image`` by a master flat normalised to unity.",
      "description": "Normalisation is by ONE global median of the master flat — not per row and not along the dispersion axis — so the flat lamp's spectral shape remains in the normalised flat and is divided into the science frame. This removes pixel-to-pixel response but also imprints the inverse lamp continuum; the catalogue has no equivalent of IRAF's `response` (dispersion-direction fit of the lamp) yet, so for spectrophotometry derive the instrumental response afterwards with response_from_standard. Low-response pixels (normalised flat < min_response, or non-finite result) are NOT masked: the original science value is substituted, i.e. those pixels stay un-flattened.",
      "category": "preprocessing",
      "category_title": "Preprocessing (2-D image)",
      "version": "1.0.1",
      "backend": "numpy",
      "inputs": [
        "image"
      ],
      "outputs": [
        "image"
      ],
      "parameters": [
        {
          "name": "flat_key",
          "default": "master_flat",
          "required": false,
          "description": "Key in ctx.extras where the master flat is stored."
        },
        {
          "name": "min_response",
          "default": 0.05,
          "required": false,
          "description": "Below this normalised flat value the science pixel is left unchanged (not divided, not masked)."
        }
      ],
      "references": [
        "Howell 2006 — Handbook of CCD Astronomy, ch. 4 (flat fielding)."
      ],
      "related": [
        "correct_slant_affine",
        "correct_smile_polynomial",
        "correct_tilt_affine",
        "dark_subtract",
        "denoise_gaussian_2d",
        "denoise_median_2d",
        "flat_normalize_easyspec",
        "outlier_rejection_mad_adaptive",
        "subtract_bias_easyspec",
        "subtract_dark_easyspec",
        "subtract_sky_2d"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"flat_normalize\", ctx, params)"
        },
        "cli": {
          "available": false,
          "command": "spectro run flat_normalize --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "flat_normalize",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/preprocessing/flat_normalize/"
    },
    {
      "id": "flat_normalize_easyspec",
      "name": "flat_normalize_easyspec",
      "summary": "Flat-field correct a science frame via ``cleaning.flatten``.",
      "description": "",
      "category": "preprocessing",
      "category_title": "Preprocessing (2-D image)",
      "version": "1.0.1",
      "backend": "easyspec",
      "inputs": [],
      "outputs": [
        "image"
      ],
      "parameters": [
        {
          "name": "target_path",
          "default": null,
          "required": false,
          "description": "Science FITS to flat-field; falls back to ctx.image."
        },
        {
          "name": "master_flat_path",
          "default": null,
          "required": false,
          "description": "Master-flat FITS; falls back to ctx.extras['master_flat']."
        },
        {
          "name": "auto_normalise",
          "default": true,
          "required": false,
          "description": "If true, divide the master flat by its median first."
        }
      ],
      "references": [
        "easyspec.cleaning.cleaning.flatten."
      ],
      "related": [
        "correct_slant_affine",
        "correct_smile_polynomial",
        "correct_tilt_affine",
        "dark_subtract",
        "denoise_gaussian_2d",
        "denoise_median_2d",
        "flat_normalize",
        "outlier_rejection_mad_adaptive",
        "subtract_bias_easyspec",
        "subtract_dark_easyspec",
        "subtract_sky_2d"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"flat_normalize_easyspec\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run flat_normalize_easyspec --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "flat_normalize_easyspec",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/preprocessing/flat_normalize_easyspec/"
    },
    {
      "id": "outlier_rejection_mad_adaptive",
      "name": "outlier_rejection_mad_adaptive",
      "summary": "Replace pixels whose deviation from a local median exceeds ``threshold·MAD``.",
      "description": "Vectorised with numpy.lib.stride_tricks.sliding_window_view, so the cost is one O(kernel · pixels) sort. Pixels within the band are replaced by the local median if their deviation exceeds threshold × MAD. The half-pixel border on each side is left untouched (window does not fit there). v1.0.1: rows are processed in chunks so the transient window copies stay around 64 MB regardless of frame size (a 4k² frame with k=5 needed ~6 GB before) — identical output.",
      "category": "preprocessing",
      "category_title": "Preprocessing (2-D image)",
      "version": "1.0.1",
      "backend": "numpy",
      "inputs": [
        "image"
      ],
      "outputs": [
        "image",
        "metrics.n_replaced"
      ],
      "parameters": [
        {
          "name": "kernel_size",
          "default": 3,
          "required": false,
          "description": "Square neighbourhood size, odd integer ≥ 3."
        },
        {
          "name": "threshold",
          "default": 3.0,
          "required": false,
          "description": "Multiplier on the local MAD above which a pixel is replaced."
        },
        {
          "name": "row_lo",
          "default": 0,
          "required": false,
          "description": "First row (inclusive) where the filter applies; 0 = top."
        },
        {
          "name": "row_hi",
          "default": 0,
          "required": false,
          "description": "Last row (exclusive); 0 ⇒ bottom of the image."
        }
      ],
      "references": [
        "Hwang & Haddad 1995, IEEE Trans. Image Processing 4(4):499 — adaptive median filter for impulsive noise.",
        "Hoaglin, Mosteller & Tukey 1983, Understanding Robust and Exploratory Data Analysis — Median Absolute Deviation properties."
      ],
      "related": [
        "correct_slant_affine",
        "correct_smile_polynomial",
        "correct_tilt_affine",
        "dark_subtract",
        "denoise_gaussian_2d",
        "denoise_median_2d",
        "flat_normalize",
        "flat_normalize_easyspec",
        "subtract_bias_easyspec",
        "subtract_dark_easyspec",
        "subtract_sky_2d"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"outlier_rejection_mad_adaptive\", ctx, params)"
        },
        "cli": {
          "available": false,
          "command": "spectro run outlier_rejection_mad_adaptive --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "outlier_rejection_mad_adaptive",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/preprocessing/outlier_rejection_mad_adaptive/"
    },
    {
      "id": "subtract_bias_easyspec",
      "name": "subtract_bias_easyspec",
      "summary": "Subtract a master bias from a single science frame via ``cleaning.debias``.",
      "description": "",
      "category": "preprocessing",
      "category_title": "Preprocessing (2-D image)",
      "version": "1.0.1",
      "backend": "easyspec",
      "inputs": [],
      "outputs": [
        "image"
      ],
      "parameters": [
        {
          "name": "target_path",
          "default": null,
          "required": false,
          "description": "Science FITS to debias; falls back to ctx.image."
        },
        {
          "name": "master_bias_path",
          "default": null,
          "required": false,
          "description": "Master-bias FITS; falls back to ctx.extras['master_bias']."
        },
        {
          "name": "pad_with_zeros",
          "default": true,
          "required": false,
          "description": "Clip negative pixels to zero after subtraction (easyspec default)."
        }
      ],
      "references": [
        "easyspec.cleaning.cleaning.debias."
      ],
      "related": [
        "correct_slant_affine",
        "correct_smile_polynomial",
        "correct_tilt_affine",
        "dark_subtract",
        "denoise_gaussian_2d",
        "denoise_median_2d",
        "flat_normalize",
        "flat_normalize_easyspec",
        "outlier_rejection_mad_adaptive",
        "subtract_dark_easyspec",
        "subtract_sky_2d"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"subtract_bias_easyspec\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run subtract_bias_easyspec --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "subtract_bias_easyspec",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/preprocessing/subtract_bias_easyspec/"
    },
    {
      "id": "subtract_dark_easyspec",
      "name": "subtract_dark_easyspec",
      "summary": "Subtract a master dark from a science frame via ``cleaning.sub_dark``.",
      "description": "",
      "category": "preprocessing",
      "category_title": "Preprocessing (2-D image)",
      "version": "1.0.1",
      "backend": "easyspec",
      "inputs": [],
      "outputs": [
        "image"
      ],
      "parameters": [
        {
          "name": "target_path",
          "default": null,
          "required": false,
          "description": "Debiased science FITS; falls back to ctx.image."
        },
        {
          "name": "master_dark_path",
          "default": null,
          "required": false,
          "description": "Master-dark FITS; falls back to ctx.extras['master_dark']."
        },
        {
          "name": "pad_with_zeros",
          "default": true,
          "required": false,
          "description": "Clip negative pixels to zero after subtraction."
        }
      ],
      "references": [
        "easyspec.cleaning.cleaning.sub_dark."
      ],
      "related": [
        "correct_slant_affine",
        "correct_smile_polynomial",
        "correct_tilt_affine",
        "dark_subtract",
        "denoise_gaussian_2d",
        "denoise_median_2d",
        "flat_normalize",
        "flat_normalize_easyspec",
        "outlier_rejection_mad_adaptive",
        "subtract_bias_easyspec",
        "subtract_sky_2d"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"subtract_dark_easyspec\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run subtract_dark_easyspec --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "subtract_dark_easyspec",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/preprocessing/subtract_dark_easyspec/"
    },
    {
      "id": "subtract_sky_2d",
      "name": "subtract_sky_2d",
      "summary": "Subtract a per-column sky model fitted from off-trace rows of ``ctx.image``.",
      "description": "Sky regions are taken on either side of the trace and far enough away to be clean of stellar flux. The fit defaults to a degree-1 polynomial (linear gradient across the slit), which handles tilted sky and twilight gradients without over-fitting. v1.0.1: all columns are fitted in one vectorised least-squares call (numpy.polyfit on the 2-D sky block) — identical numbers, ~60× faster on a 200×2048 frame.",
      "category": "preprocessing",
      "category_title": "Preprocessing (2-D image)",
      "version": "1.0.1",
      "backend": "numpy",
      "inputs": [
        "image"
      ],
      "outputs": [
        "image",
        "metrics.sky_median"
      ],
      "parameters": [
        {
          "name": "trace_row",
          "default": null,
          "required": true,
          "description": "Approximate y-pixel of the spectral trace."
        },
        {
          "name": "trace_half_width",
          "default": 8,
          "required": false,
          "description": "Half-width (rows) of the protected aperture around the trace."
        },
        {
          "name": "sky_offset",
          "default": 4,
          "required": false,
          "description": "Number of rows between trace edge and sky window start."
        },
        {
          "name": "sky_half_width",
          "default": 10,
          "required": false,
          "description": "Half-width (rows) of each sky window."
        },
        {
          "name": "poly_order",
          "default": 1,
          "required": false,
          "description": "Polynomial degree for the per-column cross-slit sky fit (0 = constant)."
        }
      ],
      "references": [
        "Horne 1986, PASP 98, 609 — §2 (standard extraction): sky estimated at each wavelength by a low-order polynomial fit across the spatial direction to the pixels outside the object aperture.",
        "Tody 1986, Proc. SPIE 627, 733 — IRAF apall background fitting (per-column polynomial in the background windows)."
      ],
      "related": [
        "correct_slant_affine",
        "correct_smile_polynomial",
        "correct_tilt_affine",
        "dark_subtract",
        "denoise_gaussian_2d",
        "denoise_median_2d",
        "flat_normalize",
        "flat_normalize_easyspec",
        "outlier_rejection_mad_adaptive",
        "subtract_bias_easyspec",
        "subtract_dark_easyspec"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"subtract_sky_2d\", ctx, params)"
        },
        "cli": {
          "available": false,
          "command": "spectro run subtract_sky_2d --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "subtract_sky_2d",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/preprocessing/subtract_sky_2d/"
    },
    {
      "id": "compare_snr_methods",
      "name": "compare_snr_methods",
      "summary": "Run every SNR estimator on ``ctx.spectrum`` and collect their numbers.",
      "description": "Each method writes to ``ctx.metrics``; this wrapper aggregates them into ``ctx.extras['snr_methods']`` so a downstream caller (or an MCP agent) can compare at a glance. The wrapper itself does not mutate ``ctx.spectrum`` ; each method runs on a slim context holding a copy of the spectrum only.",
      "category": "quality",
      "category_title": "Quality / SNR",
      "version": "1.0.1",
      "backend": "numpy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "extras.snr_methods",
        "metrics.snr_methods_spread"
      ],
      "parameters": [
        {
          "name": "methods",
          "default": [
            "snr_der",
            "snr_edge",
            "snr_linear_fit"
          ],
          "required": false,
          "description": "Names of SNR algorithms to run (defaults to the 3 native ones)."
        },
        {
          "name": "per_method_params",
          "default": {},
          "required": false,
          "description": "Optional dict {method_name: {param: value}} for overrides."
        }
      ],
      "references": [
        "Catalogue snr_* algorithms; this wrapper is composition only."
      ],
      "related": [
        "snr_der",
        "snr_edge",
        "snr_linear_fit",
        "validate_bess_header"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"compare_snr_methods\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run compare_snr_methods --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "compare_snr_methods",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/quality/compare_snr_methods/"
    },
    {
      "id": "snr_der",
      "name": "snr_der",
      "summary": "Derivative-based SNR estimator (DER_SNR, Stoehr et al. 2008).",
      "description": "SNR = median(flux) / (1.482602 / sqrt(6) * median(|2*F[i] - F[i-2] - F[i+2]|)). Works on any reasonably sampled spectrum and is insensitive to broadband features.",
      "category": "quality",
      "category_title": "Quality / SNR",
      "version": "1.0.0",
      "backend": "numpy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "metrics.snr_der"
      ],
      "parameters": [],
      "references": [
        "Stoehr et al. 2008, 'DER_SNR: A Simple & General Spectroscopic Signal-to-Noise Measurement Algorithm', ASP Conf. Ser. 394, 505"
      ],
      "related": [
        "compare_snr_methods",
        "snr_edge",
        "snr_linear_fit",
        "validate_bess_header"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"snr_der\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run snr_der --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "snr_der",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/quality/snr_der/"
    },
    {
      "id": "snr_edge",
      "name": "snr_edge",
      "summary": "Estimate SNR from the flat, line-free regions at the spectrum's edges.",
      "description": "SNR = median(flux) / std(detrended edge flux), where each edge window is detrended with a linear fit. Non-finite samples (NaN/inf) are masked before the fit and excluded from the noise estimate ; the brick fails when fewer than 3 finite samples remain in either edge window. Returns inf when the detrended edges have zero scatter.",
      "category": "quality",
      "category_title": "Quality / SNR",
      "version": "1.0.1",
      "backend": "numpy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "metrics.snr_edge"
      ],
      "parameters": [
        {
          "name": "region_fraction",
          "default": 0.1,
          "required": false,
          "description": "Fraction of the spectrum, at each end, used as a noise window."
        }
      ],
      "references": [
        "Stoehr et al. 2008, 'DER_SNR: A Simple & General Spectroscopic Signal-to-Noise Measurement Algorithm', ASP Conf. Ser. 394, 505 — the comparison baseline (snr_der) for continuum-window SNR estimators.",
        "Gray 2005, The Observation and Analysis of Stellar Photospheres, 3rd ed., Cambridge UP — ch. 12, continuum placement and noise estimation in line-free windows."
      ],
      "related": [
        "compare_snr_methods",
        "snr_der",
        "snr_linear_fit",
        "validate_bess_header"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"snr_edge\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run snr_edge --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "snr_edge",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/quality/snr_edge/"
    },
    {
      "id": "snr_linear_fit",
      "name": "snr_linear_fit",
      "summary": "Estimate SNR from the scatter around a linear fit of a continuum region.",
      "description": "SNR = median(flux) / std(flux − linear fit) over the window. Non-finite samples (NaN/inf) are masked before the fit and excluded from the statistics ; the brick fails when fewer than 3 finite samples remain. Returns inf when the residuals have zero scatter.",
      "category": "quality",
      "category_title": "Quality / SNR",
      "version": "1.0.1",
      "backend": "numpy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "metrics.snr_linear_fit"
      ],
      "parameters": [
        {
          "name": "wavelength_min",
          "default": null,
          "required": false,
          "description": "Lower bound of the continuum window (Å); null uses the start."
        },
        {
          "name": "wavelength_max",
          "default": null,
          "required": false,
          "description": "Upper bound of the continuum window (Å); null uses the end."
        }
      ],
      "references": [
        "Stoehr et al. 2008, 'DER_SNR: A Simple & General Spectroscopic Signal-to-Noise Measurement Algorithm', ASP Conf. Ser. 394, 505 — the comparison baseline (snr_der) for continuum-window SNR estimators.",
        "Gray 2005, The Observation and Analysis of Stellar Photospheres, 3rd ed., Cambridge UP — ch. 12, continuum placement and noise estimation in line-free windows."
      ],
      "related": [
        "compare_snr_methods",
        "snr_der",
        "snr_edge",
        "validate_bess_header"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"snr_linear_fit\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run snr_linear_fit --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "snr_linear_fit",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/quality/snr_linear_fit/"
    },
    {
      "id": "validate_bess_header",
      "name": "validate_bess_header",
      "summary": "Check a FITS header against the BeSS keyword contract.",
      "description": "The mirror of export_fits_bess: both bricks consume the same keyword table (algorithms.exports._bess_contract), so a file produced by the exporter always validates cleanly. Checks: NAXIS == 1 and NAXIS1 > 0; spectral WCS (CRVAL1, CDELT1, CRPIX1, CTYPE1, CUNIT1) present and non-empty; BeSS identification (BSS_INST, BSS_SITE, BSS_ESRC) and observation keywords (OBJNAME, DATE-OBS, EXPTIME, OBSERVER) present and non-empty; BSS_VHEL present (value 0 licit). strict=false reports without failing — refusing a product is application policy, not kernel policy.",
      "category": "quality",
      "category_title": "Quality / SNR",
      "version": "1.0.0",
      "backend": "numpy",
      "inputs": [],
      "outputs": [
        "metrics.bess_compliant"
      ],
      "parameters": [
        {
          "name": "strict",
          "default": false,
          "required": false,
          "description": "If true, the step fails when the header is non-conforming; if false (default), issues are reported and the step succeeds."
        },
        {
          "name": "header_key",
          "default": null,
          "required": false,
          "description": "Optional ctx.extras key holding the header dict to check; null reads ctx.spectrum.header."
        },
        {
          "name": "require_geo",
          "default": false,
          "required": false,
          "description": "Also require observing-site coordinates (BSS_LAT/GEO_LAT, BSS_LONG/GEO_LONG, BSS_ELEV/GEO_ELEV) — optional in BeSS but mandatory for a STAROS deposit."
        }
      ],
      "references": [
        "Teyssier 2015, A&A Pro-Am collaboration — BeSS/ARAS submission protocol.",
        "Buil 2012, ARAS Observation Guide — BeSS FITS header convention.",
        "FITS Standard 4.0 — Pence et al. 2010, A&A 524, A42."
      ],
      "related": [
        "compare_snr_methods",
        "snr_der",
        "snr_edge",
        "snr_linear_fit"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"validate_bess_header\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run validate_bess_header --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "validate_bess_header",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/quality/validate_bess_header/"
    },
    {
      "id": "cross_correlate_rv",
      "name": "cross_correlate_rv",
      "summary": "Measure radial velocity by cross-correlation against a template spectrum.",
      "description": "Template comes from `template_path` (FITS), `template_key` (a Spectrum1D under ctx.extras), `ctx.extras['template_spectrum']` or `ctx.spectra[0]` — first match wins. By default the brick subtracts a running-median continuum from both observed and template (continuum_subtract=True) — the Tonry-Davis CCF only behaves correctly on continuum-normalised inputs ; with raw flux the SED slope dominates the correlation and biases the RV toward zero. Disable with continuum_subtract=False when the caller has already normalised the spectrum. The RV error is the Tonry-Davis 1979 formula ; it underestimates the true error for asymmetric CCFs (multi-component blended templates) — for a physics-grounded floor see rv_precision_bouchy (Bouchy, Pepe & Queloz 2001). The default n_grid=4096 under-samples finely sampled spectra (41 km/s per sample on 4000-7000 Å) and the parabolic refinement does not fully recover the loss ; pass n_grid=None (v2.1.0) to derive the grid from the finest native Δlnλ of the two spectra (capped at 2^18). σ_v is a lower bound at coarse grids because it ignores the interpolation error. Descending wavelength axes are sorted before resampling. DEPRECATED: the ctx.spectra[0] template fallback violates the 'second spectrum in ctx.extras' convention and will be removed in a future major release — it is flagged in the message, in extras['template_source'] and via a DeprecationWarning.",
      "category": "radial_velocity",
      "category_title": "Radial velocity",
      "version": "2.1.0",
      "backend": "scipy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "metrics.radial_velocity_kms",
        "metrics.radial_velocity_error_kms",
        "metrics.peak_strength",
        "metrics.ccf_peak",
        "metrics.tonry_davis_r",
        "metrics.ccf_fwhm_kms",
        "metrics.n_grid_used",
        "metrics.velocity_step_kms",
        "extras.ccf",
        "extras.template_id",
        "extras.template_source"
      ],
      "parameters": [
        {
          "name": "template_path",
          "default": null,
          "required": false,
          "description": "Path / URL of a template FITS spectrum to load."
        },
        {
          "name": "template_key",
          "default": null,
          "required": false,
          "description": "Key in ctx.extras where a Spectrum1D template is stored."
        },
        {
          "name": "n_grid",
          "default": 4096,
          "required": false,
          "description": "Number of log-wavelength samples on which to interpolate (default 4096). null/None derives it from the finest native Δlnλ of the two spectra inside the overlap (capped at 2^18)."
        },
        {
          "name": "vmin_kms",
          "default": -800.0,
          "required": false,
          "description": "Lower bound of the velocity search range (km/s)."
        },
        {
          "name": "vmax_kms",
          "default": 800.0,
          "required": false,
          "description": "Upper bound of the velocity search range (km/s)."
        },
        {
          "name": "continuum_subtract",
          "default": true,
          "required": false,
          "description": "If True (default), subtract a running-median continuum from both observed and template before correlation — the Tonry-Davis 1979 prerequisite. Disable only when the spectra are already continuum-normalised."
        },
        {
          "name": "continuum_window",
          "default": 101,
          "required": false,
          "description": "Running-median window (samples on the log-λ grid) for the continuum estimator. Must be odd ≥ 3; the brick rounds up if even. Ignored when continuum_subtract is False."
        }
      ],
      "references": [
        "Tonry & Davis 1979, AJ 84, 1511 — fundamental CCF method for RV; r-value, σ_v = 3·w/(8·(1+r)) error formula (§III), and the requirement of continuum-normalised inputs (§II).",
        "Bouchy, Pepe & Queloz 2001, A&A 374, 733 — photon-limited RV precision (see also: rv_precision_bouchy).",
        "scipy.signal.correlate — cross-correlation engine.",
        "scipy.ndimage.median_filter — running-median continuum."
      ],
      "related": [
        "fit_keplerian_orbit",
        "measure_radial_velocity",
        "redshift_lines",
        "rv_precision_bouchy"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"cross_correlate_rv\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run cross_correlate_rv --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "cross_correlate_rv",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/radial_velocity/cross_correlate_rv/"
    },
    {
      "id": "fit_keplerian_orbit",
      "name": "fit_keplerian_orbit",
      "summary": "Fit a single-companion Keplerian RV curve to a velocity time series.",
      "description": "The eccentricity is bound to [0, 0.95]; the argument of periastron ω to [−2π, 2π] (unwrapped, so the solver can cross 0/2π freely ; reduce omega_rad mod 2π downstream). Initial guesses are derived from the data when not supplied (period from the dominant Lomb-Scargle peak if astropy is available, otherwise the user must provide ``period_initial``) ; an explicit 0.0 guess is honoured. The fit is UNWEIGHTED: ``chi_squared`` is the plain sum of squared residuals (km/s)², not a χ². When ``curve.uncertainty`` is set (finite, > 0) the additive ``reduced_chi_squared`` = Σ((v − model)/σ)² / (N − 6) is reported as a goodness-of-fit diagnostic ; the residuals are still not weighted by σ in the optimisation.",
      "category": "radial_velocity",
      "category_title": "Radial velocity",
      "version": "1.1.0",
      "backend": "scipy",
      "inputs": [],
      "outputs": [
        "extras.keplerian_orbit",
        "metrics.keplerian_period_days",
        "metrics.keplerian_semi_amplitude_kms",
        "metrics.keplerian_rms_kms",
        "metrics.keplerian_reduced_chi_squared"
      ],
      "parameters": [
        {
          "name": "light_curve_key",
          "default": "rv",
          "required": false,
          "description": "Key into ctx.light_curves for the RV time series."
        },
        {
          "name": "period_initial",
          "default": null,
          "required": false,
          "description": "Initial guess for the orbital period (days). Falls back to a Lomb-Scargle peak when null."
        },
        {
          "name": "semi_amplitude_initial",
          "default": null,
          "required": false,
          "description": "Initial guess for K (km/s). Default: half the peak-to-peak velocity range."
        },
        {
          "name": "eccentricity_initial",
          "default": 0.1,
          "required": false,
          "description": "Initial guess for e (0..0.95)."
        },
        {
          "name": "omega_initial",
          "default": 0.0,
          "required": false,
          "description": "Initial guess for ω (radians)."
        },
        {
          "name": "t_peri_initial",
          "default": null,
          "required": false,
          "description": "Initial guess for time of periastron (same unit as the time axis)."
        },
        {
          "name": "gamma_initial",
          "default": null,
          "required": false,
          "description": "Initial systemic velocity guess (km/s). Default: median of the data."
        },
        {
          "name": "max_nfev",
          "default": 5000,
          "required": false,
          "description": "Maximum function evaluations for the least-squares solver."
        }
      ],
      "references": [
        "scipy.optimize.least_squares (Trust Region Reflective).",
        "Murray & Correia 2010 — Keplerian elements / RV formalism review."
      ],
      "related": [
        "cross_correlate_rv",
        "measure_radial_velocity",
        "redshift_lines",
        "rv_precision_bouchy"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"fit_keplerian_orbit\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run fit_keplerian_orbit --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "fit_keplerian_orbit",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/radial_velocity/fit_keplerian_orbit/"
    },
    {
      "id": "measure_radial_velocity",
      "name": "measure_radial_velocity",
      "summary": "Measure a radial velocity from the Doppler shift of a single line.",
      "description": "A positive velocity means the source is receding (redshift). For an absolute velocity, apply barycentric_correction to the spectrum first. profile='voigt' is slower but the right choice when neither pure Gaussian (Doppler-dominated) nor pure Lorentzian (collisionally-dominated) fits the data.",
      "category": "radial_velocity",
      "category_title": "Radial velocity",
      "version": "1.1.0",
      "backend": "scipy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "metrics.radial_velocity_kms",
        "line_fits"
      ],
      "parameters": [
        {
          "name": "rest_wavelength_angstrom",
          "default": null,
          "required": true,
          "description": "Laboratory (rest) wavelength of the line in Å."
        },
        {
          "name": "line_label",
          "default": null,
          "required": false,
          "description": "Key of an existing fit in ctx.line_fits; null fits a new line."
        },
        {
          "name": "window_angstrom",
          "default": 20.0,
          "required": false,
          "description": "Half-width of the fit window when fitting a new line (Å)."
        },
        {
          "name": "profile",
          "default": "gaussian",
          "required": false,
          "description": "Line-profile model used when fitting a new centroid; one of ['gaussian', 'lorentzian', 'voigt']."
        }
      ],
      "references": [
        "Classical Doppler relation: v = c · (λ_obs − λ_rest) / λ_rest.",
        "Line centroid via scipy.optimize.curve_fit on the selected profile (Gaussian / Lorentzian / Voigt — see fit_gaussian_line, fit_lorentzian_line, fit_voigt_line for the standalone bricks)."
      ],
      "related": [
        "cross_correlate_rv",
        "fit_keplerian_orbit",
        "redshift_lines",
        "rv_precision_bouchy"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"measure_radial_velocity\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run measure_radial_velocity --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "measure_radial_velocity",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/radial_velocity/measure_radial_velocity/"
    },
    {
      "id": "redshift_lines",
      "name": "redshift_lines",
      "summary": "Redshift z by per-line Gaussian fits against a list of rest-frame anchors.",
      "description": "Per-line Gaussian+constant fit in a window centred on the seeded λ_obs = λ_rest · (1 + z_guess); both emission and absorption amplitudes are tried, the better residual wins. Reported z is the median over surviving anchors ; z_error is the POPULATION standard deviation (np.std, ddof=0) of the per-line z values — a spread, not the error of the median — and both are rounded to 5 decimals (contract). Refuses to commit with < 2 matched lines (no spread). Non-finite samples are dropped and a descending wavelength axis is sorted before fitting (v1.0.1).",
      "category": "radial_velocity",
      "category_title": "Radial velocity",
      "version": "1.0.1",
      "backend": "scipy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "metrics.z",
        "metrics.z_error",
        "metrics.n_lines_used",
        "extras.matched_lines",
        "extras.rows"
      ],
      "parameters": [
        {
          "name": "z_guess",
          "default": 0.0,
          "required": true,
          "description": "Seed redshift (within ±z_search_width of the true value)."
        },
        {
          "name": "z_search_width",
          "default": 0.05,
          "required": false,
          "description": "Reject |z_est − z_guess| > this (z-units). Keeps catastrophic mis-identifications out of the median."
        },
        {
          "name": "fit_window",
          "default": 15.0,
          "required": false,
          "description": "Half-window (Å) for each per-line Gaussian fit."
        },
        {
          "name": "anchor_lines",
          "default": [
            [
              "[O II] 3727",
              3727.42
            ],
            [
              "Ca II K",
              3933.66
            ],
            [
              "Ca II H",
              3968.47
            ],
            [
              "Hβ",
              4861.33
            ],
            [
              "[O III] 5007",
              5006.84
            ],
            [
              "Mg b",
              5175.3
            ],
            [
              "Na D",
              5895.92
            ],
            [
              "Hα",
              6562.82
            ],
            [
              "[N II] 6583",
              6583.45
            ]
          ],
          "required": false,
          "description": "List of (name, λ_rest_aa) pairs to fit. Default = 9 strong SDSS-spec1d anchors (override for AGN templates etc.)."
        }
      ],
      "references": [
        "Stoughton et al. 2002, AJ 123, 485 — SDSS spec1d redshift pipeline (anchor-line seed approach).",
        "Bolton et al. 2012, AJ 144, 144 — BOSS spec1d (template-based refinement on top of an anchor-line seed).",
        "Kramida et al., NIST ASD — air rest wavelengths of the anchor lines."
      ],
      "related": [
        "cross_correlate_rv",
        "fit_keplerian_orbit",
        "measure_radial_velocity",
        "rv_precision_bouchy"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"redshift_lines\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run redshift_lines --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "redshift_lines",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/radial_velocity/redshift_lines/"
    },
    {
      "id": "rv_precision_bouchy",
      "name": "rv_precision_bouchy",
      "summary": "Compute the fundamental photon-noise limit on RV precision (Bouchy 2001).",
      "description": "Uses sigma_v = c / sqrt(sum_i ( (dF/d(lambda))_i * lambda_i / sigma_F_i )^2). When the spectrum's ``uncertainty`` array is not set, sigma_F is approximated from sqrt(|F|) (photon-noise assumption) scaled by the spectrum's median SNR. For an empirical companion measurement, run ``cross_correlate_rv`` and compare its ``radial_velocity_error_kms`` (Tonry & Davis r-value error) with this photon-noise floor — they bracket the realistic uncertainty budget.",
      "category": "radial_velocity",
      "category_title": "Radial velocity",
      "version": "1.0.1",
      "backend": "numpy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "metrics.rv_precision_ms"
      ],
      "parameters": [
        {
          "name": "snr_floor",
          "default": 50.0,
          "required": false,
          "description": "Assumed SNR when no uncertainty array is set (photon-noise floor)."
        }
      ],
      "references": [
        "Bouchy, Pepe & Queloz 2001, A&A 374, 733 — fundamental noise limits on RV.",
        "Brault 1987, ARA&A 25, 575 — original derivation in the line-fitting context.",
        "See also cross_correlate_rv for the empirical CCF-based RV error (Tonry & Davis 1979)."
      ],
      "related": [
        "cross_correlate_rv",
        "fit_keplerian_orbit",
        "measure_radial_velocity",
        "redshift_lines"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"rv_precision_bouchy\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run rv_precision_bouchy --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "rv_precision_bouchy",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/radial_velocity/rv_precision_bouchy/"
    },
    {
      "id": "resample_flux_conserving",
      "name": "resample_flux_conserving",
      "summary": "Resample onto a new wavelength grid while preserving integrated flux.",
      "description": "Specify the new grid either with a fixed step (Å) or with a fixed number of points. Samples outside the input wavelength range come back as NaN. The flux_unit label is only used to build the specutils object: 'ADU' / 'adu' map to astropy's adu, 'counts' / 'count' to count, any label astropy does not recognise ('transmission', 'response', …) is treated as dimensionless, and the output keeps the input label verbatim (v1.0.1 — v1.0.0 failed for every unrecognised label, including the kernel default 'ADU').",
      "category": "resampling",
      "category_title": "Resampling",
      "version": "1.0.1",
      "backend": "specutils",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "spectrum"
      ],
      "parameters": [
        {
          "name": "wavelength_min",
          "default": null,
          "required": false,
          "description": "Lower bound of the new grid (Å); null keeps the input minimum."
        },
        {
          "name": "wavelength_max",
          "default": null,
          "required": false,
          "description": "Upper bound of the new grid (Å); null keeps the input maximum."
        },
        {
          "name": "step",
          "default": null,
          "required": false,
          "description": "Constant sampling step (Å). Mutually exclusive with n_points."
        },
        {
          "name": "n_points",
          "default": null,
          "required": false,
          "description": "Number of samples in the new grid. Mutually exclusive with step."
        }
      ],
      "references": [
        "specutils.manipulation.FluxConservingResampler",
        "Carnall 2017, arXiv:1705.05165 — SpectRes (the conservation idea)"
      ],
      "related": [
        "resample_linear"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"resample_flux_conserving\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run resample_flux_conserving --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "resample_flux_conserving",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/resampling/resample_flux_conserving/"
    },
    {
      "id": "resample_linear",
      "name": "resample_linear",
      "summary": "Resample a spectrum onto a uniform wavelength grid by linear interpolation.",
      "description": "Linear interpolation is fast and adequate when the target sampling is similar to the source. For large changes in resolution prefer a flux-conserving method.",
      "category": "resampling",
      "category_title": "Resampling",
      "version": "1.0.1",
      "backend": "numpy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "spectrum"
      ],
      "parameters": [
        {
          "name": "wavelength_min",
          "default": null,
          "required": false,
          "description": "Lower bound of the new grid (Å); null keeps the input minimum."
        },
        {
          "name": "wavelength_max",
          "default": null,
          "required": false,
          "description": "Upper bound of the new grid (Å); null keeps the input maximum."
        },
        {
          "name": "step",
          "default": null,
          "required": false,
          "description": "Constant sampling step (Å). Mutually exclusive with n_points."
        },
        {
          "name": "n_points",
          "default": null,
          "required": false,
          "description": "Number of samples in the new grid. Mutually exclusive with step."
        }
      ],
      "references": [
        "numpy.interp — piecewise-linear interpolation",
        "For large resolution changes, prefer flux-conserving resampling (specutils.manipulation.FluxConservingResampler)"
      ],
      "related": [
        "resample_flux_conserving"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"resample_linear\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run resample_linear --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "resample_linear",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/resampling/resample_linear/"
    },
    {
      "id": "compare_smoothings",
      "name": "compare_smoothings",
      "summary": "Run every smoothing kernel on ``ctx.spectrum`` and collect the results.",
      "description": "Pairwise residual RMS between any two smoothings is recorded in ``ctx.metrics`` so the caller can see how much the kernel choice actually matters on this particular spectrum.",
      "category": "smoothing",
      "category_title": "Smoothing",
      "version": "1.0.1",
      "backend": "numpy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "extras.smoothings",
        "metrics.smoothings_rms"
      ],
      "parameters": [
        {
          "name": "methods",
          "default": [
            "smooth_savgol",
            "smooth_gaussian"
          ],
          "required": false,
          "description": "Names of smoothing algorithms to run (defaults to the 2 native ones)."
        },
        {
          "name": "per_method_params",
          "default": {},
          "required": false,
          "description": "Optional dict {method_name: {param: value}} for overrides."
        }
      ],
      "references": [
        "Catalogue smooth_* algorithms; this wrapper is composition only."
      ],
      "related": [
        "smooth_gaussian",
        "smooth_savgol"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"compare_smoothings\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run compare_smoothings --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "compare_smoothings",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/smoothing/compare_smoothings/"
    },
    {
      "id": "smooth_gaussian",
      "name": "smooth_gaussian",
      "summary": "Smooth a spectrum by convolution with a Gaussian kernel.",
      "description": "Non-finite samples (NaN/inf) are bridged by linear interpolation over the finite neighbours before the convolution, so a single bad pixel no longer spreads NaN across the kernel footprint ; the original non-finite positions are restored to NaN in the output (finite inputs are processed bit-identically to v1.0.0). The uncertainty array is copied unchanged, i.e. it is an overestimate after smoothing ; propagating it is a contract change left for a future major release.",
      "category": "smoothing",
      "category_title": "Smoothing",
      "version": "1.0.1",
      "backend": "scipy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "spectrum"
      ],
      "parameters": [
        {
          "name": "sigma",
          "default": 2.0,
          "required": false,
          "description": "Standard deviation of the Gaussian kernel, in samples."
        }
      ],
      "references": [
        "scipy.ndimage.gaussian_filter1d"
      ],
      "related": [
        "compare_smoothings",
        "smooth_savgol"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"smooth_gaussian\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run smooth_gaussian --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "smooth_gaussian",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/smoothing/smooth_gaussian/"
    },
    {
      "id": "smooth_savgol",
      "name": "smooth_savgol",
      "summary": "Smooth a spectrum with a Savitzky-Golay filter.",
      "description": "Non-finite samples (NaN/inf) are bridged by linear interpolation over the finite neighbours before filtering, so a bad pixel neither raises nor smears ; the original non-finite positions are restored to NaN in the output (finite inputs are processed bit-identically to v1.0.0). The uncertainty array is copied unchanged, i.e. it is an overestimate after smoothing (the filter reduces the noise) ; propagating it is a contract change left for a future major release.",
      "category": "smoothing",
      "category_title": "Smoothing",
      "version": "1.0.1",
      "backend": "scipy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "spectrum"
      ],
      "parameters": [
        {
          "name": "window",
          "default": 11,
          "required": false,
          "description": "Sliding-window length in samples (odd; coerced up to the next odd)."
        },
        {
          "name": "polyorder",
          "default": 3,
          "required": false,
          "description": "Polynomial order fitted within each window (< window)."
        }
      ],
      "references": [
        "Savitzky & Golay 1964, Analytical Chemistry 36, 1627",
        "scipy.signal.savgol_filter"
      ],
      "related": [
        "compare_smoothings",
        "smooth_gaussian"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"smooth_savgol\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run smooth_savgol --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "smooth_savgol",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/smoothing/smooth_savgol/"
    },
    {
      "id": "merge_echelle_orders",
      "name": "merge_echelle_orders",
      "summary": "Merge every Spectrum1D in ``ctx.spectra`` into a single ``ctx.spectrum``.",
      "description": "Use after ``read_echelle_fits``. The output is a 1D spectrum with a log-uniformly-sampled wavelength axis ready for analysis algorithms (``snr_der``, ``detect_lines``, …) — most of which expect a single Spectrum1D, not a list of orders.",
      "category": "stacking",
      "category_title": "Stacking",
      "version": "1.0.1",
      "backend": "numpy",
      "inputs": [
        "spectra"
      ],
      "outputs": [
        "spectrum",
        "metrics.n_orders_merged"
      ],
      "parameters": [
        {
          "name": "n_grid_per_order",
          "default": 4000,
          "required": false,
          "description": "Number of log-λ samples to allocate per order on the merged grid. Higher = finer output but slower."
        },
        {
          "name": "weighting",
          "default": "uncertainty",
          "required": false,
          "description": "How to combine overlapping pixels: 'uncertainty' (1/σ², requires Spectrum1D.uncertainty), 'mean' (plain average), or 'first' (keep the first order in declared sequence)."
        }
      ],
      "references": [
        "Tody 1993, ASP Conf. Ser. 52, 173 — IRAF scombine (echelle package): orders interpolated to a common dispersion and combined by (weighted) average in the overlap regions.",
        "Horne 1986, PASP 98, 609 — inverse-variance (1/σ²) weighting of independent estimates of the same flux."
      ],
      "related": [
        "stack_spectra"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"merge_echelle_orders\", ctx, params)"
        },
        "cli": {
          "available": false,
          "command": "spectro run merge_echelle_orders --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "merge_echelle_orders",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/stacking/merge_echelle_orders/"
    },
    {
      "id": "stack_spectra",
      "name": "stack_spectra",
      "summary": "Combine every spectrum in ``ctx.spectra`` into one stacked spectrum.",
      "description": "Median stacking is the most robust against cosmic rays and outliers; mean maximises SNR for clean data; sum is useful for co-adding sub-exposures. Each spectrum contributes only inside its own wavelength range: samples of the reference grid outside a spectrum's coverage are NaN for that spectrum and ignored by the NaN-aware combiners (a sample covered by no spectrum at all is NaN in the output; count in metrics.n_nan_samples). v2.0.0: v1 let numpy.interp clamp to the edge value, so a spectrum was extrapolated as a constant over the whole grid — with s1 = 1.0 on 4000-5000 Å and s2 = 3.0 on 4500-5500 Å the v1 mean at 4000 Å was 2.0; it is now 1.0 (2.0 inside the overlap).",
      "category": "stacking",
      "category_title": "Stacking",
      "version": "2.0.0",
      "backend": "numpy",
      "inputs": [
        "spectra"
      ],
      "outputs": [
        "spectrum",
        "metrics.n_stacked",
        "metrics.n_nan_samples"
      ],
      "parameters": [
        {
          "name": "method",
          "default": "median",
          "required": false,
          "description": "Combination method: mean, median or sum."
        },
        {
          "name": "reference_index",
          "default": 0,
          "required": false,
          "description": "Index in ctx.spectra whose wavelength grid is the target."
        }
      ],
      "references": [
        "Tody 1993, ASP Conf. Ser. 52, 173 — IRAF scombine: spectra interpolated to a common dispersion, then summed / averaged / medianed sample by sample."
      ],
      "related": [
        "merge_echelle_orders"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"stack_spectra\", ctx, params)"
        },
        "cli": {
          "available": false,
          "command": "spectro run stack_spectra --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "stack_spectra",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/stacking/stack_spectra/"
    },
    {
      "id": "lomb_scargle",
      "name": "lomb_scargle",
      "summary": "Compute a Lomb-Scargle periodogram and report the dominant period.",
      "description": "Frequency bounds default to astropy's automatic grid. The false-alarm probability of the highest peak is reported using the 'baluev' approximation, evaluated over the same frequency bounds as the searched grid (minimum_frequency / maximum_frequency are forwarded to astropy's false_alarm_probability ; with both null the FAP is identical to v1.1.0). The 'normalization' parameter is forwarded verbatim to astropy and selects between 'standard' (default, scaled χ² of the fit), 'model' (χ² of the null model), 'log' (log-likelihood), and 'psd' (unnormalised power spectral density). Use 'psd' when stitching with classical Fourier-domain tools or comparing to noise variance ; 'standard' is the right default for peak-detection and FAP.",
      "category": "timeseries",
      "category_title": "Time series",
      "version": "1.1.1",
      "backend": "astropy",
      "inputs": [],
      "outputs": [
        "periodograms.lomb_scargle",
        "metrics.best_period",
        "metrics.best_frequency"
      ],
      "parameters": [
        {
          "name": "time",
          "default": null,
          "required": false,
          "description": "List of observation times; omit to use a light curve from the context."
        },
        {
          "name": "flux",
          "default": null,
          "required": false,
          "description": "List of flux values, paired with 'time'."
        },
        {
          "name": "uncertainty",
          "default": null,
          "required": false,
          "description": "Optional list of per-point flux uncertainties."
        },
        {
          "name": "light_curve_key",
          "default": null,
          "required": false,
          "description": "Key in ctx.light_curves to use when time/flux are omitted."
        },
        {
          "name": "minimum_frequency",
          "default": null,
          "required": false,
          "description": "Lower bound of the frequency grid (null = automatic)."
        },
        {
          "name": "maximum_frequency",
          "default": null,
          "required": false,
          "description": "Upper bound of the frequency grid (null = automatic)."
        },
        {
          "name": "samples_per_peak",
          "default": 5,
          "required": false,
          "description": "Frequency-grid oversampling factor."
        },
        {
          "name": "normalization",
          "default": "standard",
          "required": false,
          "description": "Astropy normalisation mode for the power spectrum ; one of ['standard', 'model', 'log', 'psd']. Forwarded verbatim to astropy.timeseries.LombScargle.autopower."
        }
      ],
      "references": [
        "Lomb 1976, Ap&SS 39, 447.",
        "Scargle 1982, ApJ 263, 835.",
        "VanderPlas 2018, ApJS 236, 16 — understanding the Lomb-Scargle periodogram (normalisation conventions reviewed §7.2).",
        "astropy.timeseries.LombScargle (normalization parameter)."
      ],
      "related": [
        "phase_fold"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"lomb_scargle\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run lomb_scargle --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "lomb_scargle",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/timeseries/lomb_scargle/"
    },
    {
      "id": "phase_fold",
      "name": "phase_fold",
      "summary": "Phase-fold a light curve on a known period.",
      "description": "",
      "category": "timeseries",
      "category_title": "Time series",
      "version": "1.0.1",
      "backend": "numpy",
      "inputs": [
        "light_curves"
      ],
      "outputs": [
        "light_curves.folded"
      ],
      "parameters": [
        {
          "name": "period",
          "default": null,
          "required": true,
          "description": "Folding period, in the light curve's time unit (required)."
        },
        {
          "name": "epoch",
          "default": 0.0,
          "required": false,
          "description": "Reference time mapped to phase 0."
        },
        {
          "name": "light_curve_key",
          "default": null,
          "required": false,
          "description": "Key in ctx.light_curves to fold; null uses the first one."
        }
      ],
      "references": [
        "Stellingwerf 1978, ApJ 224, 953 — phase dispersion minimization ; defines the phase-folding convention φ = ((t − t₀)/P) mod 1 used here.",
        "Lafler & Kinman 1965, ApJS 11, 216 — period search by folding the light curve on trial periods."
      ],
      "related": [
        "lomb_scargle"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"phase_fold\", ctx, params)"
        },
        "cli": {
          "available": false,
          "command": "spectro run phase_fold --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "phase_fold",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/timeseries/phase_fold/"
    },
    {
      "id": "clip_sigma",
      "name": "clip_sigma",
      "summary": "Remove outlier samples (cosmic rays, hot pixels) by iterative sigma clipping.",
      "description": "",
      "category": "transform",
      "category_title": "Transforms",
      "version": "1.0.1",
      "backend": "scipy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "spectrum",
        "metrics.n_clipped"
      ],
      "parameters": [
        {
          "name": "sigma",
          "default": 5.0,
          "required": false,
          "description": "Clipping threshold, in standard deviations of the residual."
        },
        {
          "name": "window",
          "default": 5,
          "required": false,
          "description": "Median-filter window (samples) used to estimate the local baseline."
        },
        {
          "name": "iterations",
          "default": 3,
          "required": false,
          "description": "Maximum number of clipping iterations."
        }
      ],
      "references": [
        "Bevington & Robinson 2003, Data Reduction and Error Analysis for the Physical Sciences, 3rd ed., McGraw-Hill — Chauvenet's criterion / iterative rejection of samples beyond k·σ of the fit.",
        "astropy.stats.sigma_clip — iterative σ-clipping about a robust centre (the same iterate-until-no-new-rejection scheme, applied here to the residual from a running median).",
        "scipy.ndimage.median_filter — local baseline."
      ],
      "related": [
        "combine_spectra_arithmetic",
        "extract_region",
        "mask_range"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"clip_sigma\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run clip_sigma --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "clip_sigma",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/transform/clip_sigma/"
    },
    {
      "id": "combine_spectra_arithmetic",
      "name": "combine_spectra_arithmetic",
      "summary": "Add, subtract, multiply or divide ``ctx.spectrum`` by a reference.",
      "description": "operation ∈ {add, sub, mul, div}. For 'div', samples where |reference| < min_denominator become NaN (clamp on near-zero responses). Pass-through outside the reference domain matches IRAF sarith and prevents wing zero-out.",
      "category": "transform",
      "category_title": "Transforms",
      "version": "1.0.1",
      "backend": "numpy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "spectrum"
      ],
      "parameters": [
        {
          "name": "operation",
          "default": "div",
          "required": false,
          "description": "One of 'add', 'sub', 'mul', 'div'."
        },
        {
          "name": "min_denominator",
          "default": 5e-05,
          "required": false,
          "description": "Division samples with |reference| below this become NaN. Ignored for non-div operations."
        },
        {
          "name": "reference_path",
          "default": null,
          "required": false,
          "description": "Path / URL of a FITS reference spectrum (loaded with read_fits)."
        },
        {
          "name": "reference_key",
          "default": "reference_spectrum",
          "required": false,
          "description": "ctx.extras key holding a Spectrum1D reference."
        }
      ],
      "references": [
        "Tody 1986, Proc. SPIE 627, 733 — IRAF sarith heritage.",
        "Robitaille et al. 2013, A&A 558, A33 — specutils Spectrum1D arithmetic."
      ],
      "related": [
        "clip_sigma",
        "extract_region",
        "mask_range"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"combine_spectra_arithmetic\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run combine_spectra_arithmetic --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "combine_spectra_arithmetic",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/transform/combine_spectra_arithmetic/"
    },
    {
      "id": "extract_region",
      "name": "extract_region",
      "summary": "Crop a spectrum to the wavelength window ``[wavelength_min, wavelength_max]``.",
      "description": "",
      "category": "transform",
      "category_title": "Transforms",
      "version": "1.0.0",
      "backend": "numpy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "spectrum"
      ],
      "parameters": [
        {
          "name": "wavelength_min",
          "default": null,
          "required": true,
          "description": "Lower bound of the window to keep (Å)."
        },
        {
          "name": "wavelength_max",
          "default": null,
          "required": true,
          "description": "Upper bound of the window to keep (Å)."
        }
      ],
      "references": [],
      "related": [
        "clip_sigma",
        "combine_spectra_arithmetic",
        "mask_range"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"extract_region\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run extract_region --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "extract_region",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/transform/extract_region/"
    },
    {
      "id": "mask_range",
      "name": "mask_range",
      "summary": "Flag every sample inside a wavelength window as masked.",
      "description": "",
      "category": "transform",
      "category_title": "Transforms",
      "version": "1.0.0",
      "backend": "numpy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "spectrum",
        "metrics.n_masked"
      ],
      "parameters": [
        {
          "name": "wavelength_min",
          "default": null,
          "required": true,
          "description": "Lower bound of the range to mask (Å)."
        },
        {
          "name": "wavelength_max",
          "default": null,
          "required": true,
          "description": "Upper bound of the range to mask (Å)."
        }
      ],
      "references": [],
      "related": [
        "clip_sigma",
        "combine_spectra_arithmetic",
        "extract_region"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"mask_range\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run mask_range --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "mask_range",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/transform/mask_range/"
    },
    {
      "id": "plot_3d_surface_plotly",
      "name": "plot_3d_surface_plotly",
      "summary": "Render every spectrum in ``ctx.spectra`` as one row of a 3D surface.",
      "description": "",
      "category": "visualization",
      "category_title": "Visualisation",
      "version": "1.0.0",
      "backend": "plotly",
      "inputs": [
        "spectra"
      ],
      "outputs": [
        "figures.surface"
      ],
      "parameters": [
        {
          "name": "title",
          "default": "stacked spectra — 3D surface",
          "required": false,
          "description": "Figure title."
        },
        {
          "name": "n_grid",
          "default": 512,
          "required": false,
          "description": "Number of wavelength samples on the common grid."
        },
        {
          "name": "colormap",
          "default": "Viridis",
          "required": false,
          "description": "Plotly colour scale name."
        }
      ],
      "references": [
        "Plotly — https://plotly.com/python/3d-surface-plots/"
      ],
      "related": [
        "plot_animation_plotly",
        "plot_dynamic_spectrum",
        "plot_overlay_plotly",
        "plot_spectrum_plotly"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"plot_3d_surface_plotly\", ctx, params)"
        },
        "cli": {
          "available": false,
          "command": "spectro run plot_3d_surface_plotly --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "plot_3d_surface_plotly",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/visualization/plot_3d_surface_plotly/"
    },
    {
      "id": "plot_animation_plotly",
      "name": "plot_animation_plotly",
      "summary": "Render an animated Plotly figure that plays through ``ctx.spectra``.",
      "description": "",
      "category": "visualization",
      "category_title": "Visualisation",
      "version": "1.0.0",
      "backend": "plotly",
      "inputs": [
        "spectra"
      ],
      "outputs": [
        "figures.animation"
      ],
      "parameters": [
        {
          "name": "title",
          "default": "stacked spectra — animation",
          "required": false,
          "description": "Figure title."
        },
        {
          "name": "frame_duration_ms",
          "default": 600,
          "required": false,
          "description": "Milliseconds each frame is displayed."
        }
      ],
      "references": [
        "Plotly — https://plotly.com/python/animations/"
      ],
      "related": [
        "plot_3d_surface_plotly",
        "plot_dynamic_spectrum",
        "plot_overlay_plotly",
        "plot_spectrum_plotly"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"plot_animation_plotly\", ctx, params)"
        },
        "cli": {
          "available": false,
          "command": "spectro run plot_animation_plotly --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "plot_animation_plotly",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/visualization/plot_animation_plotly/"
    },
    {
      "id": "plot_dynamic_spectrum",
      "name": "plot_dynamic_spectrum",
      "summary": "Render every spectrum in ``ctx.spectra`` as one row of a 2D heatmap.",
      "description": "All spectra are resampled to a common wavelength grid (linear, taking the intersection of their ranges) before stacking. Use this together with phase_fold or lomb_scargle for variable-star and exoplanet work.",
      "category": "visualization",
      "category_title": "Visualisation",
      "version": "1.0.0",
      "backend": "plotly",
      "inputs": [
        "spectra"
      ],
      "outputs": [
        "figures.dynamic"
      ],
      "parameters": [
        {
          "name": "title",
          "default": "dynamic spectrum",
          "required": false,
          "description": "Figure title."
        },
        {
          "name": "n_grid",
          "default": 1024,
          "required": false,
          "description": "Number of wavelength samples on the common grid."
        },
        {
          "name": "colormap",
          "default": "Viridis",
          "required": false,
          "description": "Plotly colour scale name (Viridis, Cividis, Plasma…)."
        }
      ],
      "references": [
        "Plotly — open-source graphing library, https://plotly.com/python/"
      ],
      "related": [
        "plot_3d_surface_plotly",
        "plot_animation_plotly",
        "plot_overlay_plotly",
        "plot_spectrum_plotly"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"plot_dynamic_spectrum\", ctx, params)"
        },
        "cli": {
          "available": false,
          "command": "spectro run plot_dynamic_spectrum --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "plot_dynamic_spectrum",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/visualization/plot_dynamic_spectrum/"
    },
    {
      "id": "plot_overlay_plotly",
      "name": "plot_overlay_plotly",
      "summary": "Render every spectrum in ``ctx.spectra`` overlaid on one Plotly figure.",
      "description": "",
      "category": "visualization",
      "category_title": "Visualisation",
      "version": "1.0.0",
      "backend": "plotly",
      "inputs": [
        "spectra"
      ],
      "outputs": [
        "figures.overlay"
      ],
      "parameters": [
        {
          "name": "title",
          "default": "spectra overlay",
          "required": false,
          "description": "Figure title."
        }
      ],
      "references": [
        "Plotly — open-source graphing library, https://plotly.com/python/"
      ],
      "related": [
        "plot_3d_surface_plotly",
        "plot_animation_plotly",
        "plot_dynamic_spectrum",
        "plot_spectrum_plotly"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"plot_overlay_plotly\", ctx, params)"
        },
        "cli": {
          "available": false,
          "command": "spectro run plot_overlay_plotly --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "plot_overlay_plotly",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/visualization/plot_overlay_plotly/"
    },
    {
      "id": "plot_spectrum_plotly",
      "name": "plot_spectrum_plotly",
      "summary": "Render ``ctx.spectrum`` as a Plotly line figure.",
      "description": "",
      "category": "visualization",
      "category_title": "Visualisation",
      "version": "1.0.0",
      "backend": "plotly",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "figures.spectrum"
      ],
      "parameters": [
        {
          "name": "title",
          "default": "",
          "required": false,
          "description": "Figure title; empty uses the spectrum's object name."
        },
        {
          "name": "show_uncertainty",
          "default": true,
          "required": false,
          "description": "Draw the uncertainty band when the spectrum has one."
        }
      ],
      "references": [
        "Plotly — open-source graphing library, https://plotly.com/python/"
      ],
      "related": [
        "plot_3d_surface_plotly",
        "plot_animation_plotly",
        "plot_dynamic_spectrum",
        "plot_overlay_plotly"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"plot_spectrum_plotly\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run plot_spectrum_plotly --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "plot_spectrum_plotly",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/visualization/plot_spectrum_plotly/"
    },
    {
      "id": "fit_emission_lines_gaussian",
      "name": "fit_emission_lines_gaussian",
      "summary": "Measure sub-pixel centroids of several emission lines at once.",
      "description": "Each line is fit in its own window after a cheap argmax recentring step. Fit failures degrade gracefully to the recentred argmax (warning recorded in the result message); the algorithm never raises mid-batch.",
      "category": "wavelength_calibration",
      "category_title": "Wavelength calibration",
      "version": "1.0.0",
      "backend": "astropy",
      "inputs": [],
      "outputs": [
        "extras.emission_line_centroids",
        "metrics.n_lines"
      ],
      "parameters": [
        {
          "name": "guess_positions",
          "default": [],
          "required": true,
          "description": "Approximate pixel positions of the lines to fit."
        },
        {
          "name": "search_width",
          "default": 40.0,
          "required": false,
          "description": "Full width (pixels) of the fit window around each guess (≥ 4)."
        },
        {
          "name": "initial_sigma",
          "default": 5.0,
          "required": false,
          "description": "Starting Gaussian σ (pixels)."
        },
        {
          "name": "source_key",
          "default": "sky_spectrum",
          "required": false,
          "description": "ctx.extras key of the spectrum to fit; falls back to ctx.spectrum when the key is absent."
        }
      ],
      "references": [
        "Markwardt 2009, ASP Conf. 411, 251 — Levenberg-Marquardt (MINPACK lmdif) algorithm in astronomy.",
        "Robitaille et al. 2013, A&A 558, A33 — Astropy."
      ],
      "related": [
        "match_lamp_lines",
        "measure_arc_geometry",
        "reidentify_arc_features",
        "wavelength_calibrate_easyspec",
        "wavelength_calibrate_polynomial",
        "wavelength_calibration_in_situ",
        "wavelength_calibration_solar"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"fit_emission_lines_gaussian\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run fit_emission_lines_gaussian --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "fit_emission_lines_gaussian",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/wavelength_calibration/fit_emission_lines_gaussian/"
    },
    {
      "id": "match_lamp_lines",
      "name": "match_lamp_lines",
      "summary": "Auto-identify arc-lamp lines against the bundled NIST atlas.",
      "description": "Global brute-force seed (linear λ(x) grid scored by atlas-match count) followed by σ-clipped polynomial refinement. The atlas comes from spectro_kernel.algorithms.wavelength_calibration.lamp_atlas (NIST ASD persistent lines for Ne / Ar / NeAr / ThAr). Output goes to ctx.extras[output_key] in the same dict shape wavelength_calibrate_polynomial expects: {'pixel_positions': [...], 'wavelengths_angstrom': [...]}. For night-to-night re-anchoring of an already-identified instrument set-up (IRAF reidentify), use the companion brick reidentify_arc_features — it conserves a stored empirical feature list instead of re-matching the atlas.",
      "category": "wavelength_calibration",
      "category_title": "Wavelength calibration",
      "version": "1.0.2",
      "backend": "scipy",
      "inputs": [],
      "outputs": [
        "extras.lamp_identifications",
        "extras.lamp_polynomial_coefficients",
        "metrics.rms_angstrom",
        "metrics.n_matched",
        "metrics.dispersion_a_per_px"
      ],
      "parameters": [
        {
          "name": "lamp",
          "default": "NeAr",
          "required": true,
          "description": "Arc-lamp identifier. One of ('Ar', 'Ne', 'NeAr', 'ThAr')."
        },
        {
          "name": "wave_min",
          "default": null,
          "required": false,
          "description": "Lower bound (Å) of the expected spectral domain. Strongly recommended — used to bound the linear-seed grid."
        },
        {
          "name": "wave_max",
          "default": null,
          "required": false,
          "description": "Upper bound (Å) of the expected spectral domain."
        },
        {
          "name": "dispersion_hint",
          "default": null,
          "required": false,
          "description": "Expected dispersion (Å / pixel). Optional but accelerates and constrains the linear seed."
        },
        {
          "name": "detection_sigma",
          "default": 5.0,
          "required": false,
          "description": "Peak prominence threshold expressed as a multiple of the median-absolute-deviation noise (DER_SNR style)."
        },
        {
          "name": "min_prominence",
          "default": 0.01,
          "required": false,
          "description": "Floor on the prominence as a fraction of peak amplitude (prevents detection_sigma from going to 0 on flat regions)."
        },
        {
          "name": "min_distance_px",
          "default": 5,
          "required": false,
          "description": "Minimum spacing between detected peaks (pixels)."
        },
        {
          "name": "poly_order",
          "default": 3,
          "required": false,
          "description": "Polynomial order of the fitted λ(x)."
        },
        {
          "name": "match_tol_px",
          "default": 2.0,
          "required": false,
          "description": "Maximum residual (pixels) between an observed peak and the nearest atlas line for the match to count."
        },
        {
          "name": "sigma_clip",
          "default": 3.0,
          "required": false,
          "description": "σ-clip threshold during the polyfit refinement."
        },
        {
          "name": "max_lines",
          "default": 30,
          "required": false,
          "description": "Cap on the number of detected peaks fed to matching."
        },
        {
          "name": "source_key",
          "default": "lamp_spectrum",
          "required": false,
          "description": "ctx.extras key holding the lamp Spectrum1D (pixel axis)."
        },
        {
          "name": "output_key",
          "default": "lamp_identifications",
          "required": false,
          "description": "ctx.extras key receiving the identifications dict {'pixel_positions': [...], 'wavelengths_angstrom': [...]}."
        }
      ],
      "references": [
        "Tody 1986, SPIE 627, 733 — IRAF Data Reduction and Analysis System.",
        "Tody 1993, ASP Conf. Ser. 52, 173 — IRAF identify/autoidentify.",
        "Murphy et al. 2007, MNRAS 378, 221 — robust ThAr wavelength solution.",
        "Kramida, Ralchenko, Reader & NIST ASD Team — NIST Atomic Spectra Database (Ne I / Ar I / Th-Ar persistent lines), https://physics.nist.gov/asd."
      ],
      "related": [
        "fit_emission_lines_gaussian",
        "measure_arc_geometry",
        "reidentify_arc_features",
        "wavelength_calibrate_easyspec",
        "wavelength_calibrate_polynomial",
        "wavelength_calibration_in_situ",
        "wavelength_calibration_solar"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"match_lamp_lines\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run match_lamp_lines --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "match_lamp_lines",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/wavelength_calibration/match_lamp_lines/"
    },
    {
      "id": "measure_arc_geometry",
      "name": "measure_arc_geometry",
      "summary": "Measure smile_radius + slant_deg from an arc-lamp 2-D image.",
      "description": "Sums the arc along the slit, picks bright arc lines with scipy.signal.find_peaks, then walks each line row-by-row across the slit computing a sub-pixel column centroid in a small window. Fits a Schroeder parabola dx(y) = (y - y₀)² / (2 R) on the row-averaged offsets to get R; the residual linear slope dx/dy → tilt angle. y₀ comes from ctx.extras['trace'] (set by detect_trace) when present, otherwise defaults to the image centre. v2.0.0: slant_deg is now returned with the sign correct_slant_affine expects (slant_deg = -atan(dx/dy)); feeding it unchanged to correct_slant_affine(slant_deg=…, pivot_row=reference_row) straightens the lines. v1 returned the opposite sign, so the documented chain doubled the slant (synthetic +0.05 px/row: residual +0.10 px/row in v1, 0.000 in v2).",
      "category": "wavelength_calibration",
      "category_title": "Wavelength calibration",
      "version": "2.0.0",
      "backend": "scipy",
      "inputs": [],
      "outputs": [
        "extras.geometry",
        "metrics.geometry_rms_px",
        "metrics.n_lines_used"
      ],
      "parameters": [
        {
          "name": "lamp_key",
          "default": "lamp_image",
          "required": false,
          "description": "ctx.extras key holding the arc-lamp 2-D ImageFrame."
        },
        {
          "name": "trace_key",
          "default": "trace",
          "required": false,
          "description": "ctx.extras key holding the science trace dict (from detect_trace). The trace's center_row becomes the reference row y₀ ; absent ⇒ image centre."
        },
        {
          "name": "detection_sigma",
          "default": 5.0,
          "required": false,
          "description": "Peak prominence threshold (× DER_SNR-style noise) when finding arc lines on the summed spectrum."
        },
        {
          "name": "min_prominence",
          "default": 0.05,
          "required": false,
          "description": "Lower bound on the prominence as a fraction of the peak amplitude."
        },
        {
          "name": "min_lines",
          "default": 6,
          "required": false,
          "description": "Minimum number of arc lines successfully tracked across the slit. The algorithm fails below this — too few lines makes the smile fit unreliable."
        },
        {
          "name": "max_lines",
          "default": 30,
          "required": false,
          "description": "Cap on the number of arc lines fed to the fit."
        },
        {
          "name": "search_half_width_px",
          "default": 12,
          "required": false,
          "description": "Half-window (column pixels) around each line's reference x for the per-row centroid."
        },
        {
          "name": "row_step",
          "default": 5,
          "required": false,
          "description": "Spacing (rows) between sample points along the slit."
        },
        {
          "name": "row_half_range",
          "default": 200,
          "required": false,
          "description": "Half-range (rows) above and below the reference row over which each line is tracked."
        },
        {
          "name": "smile_order",
          "default": 2,
          "required": false,
          "description": "Polynomial order for the smile fit (2 → Schroeder parabola)."
        },
        {
          "name": "fit_tilt",
          "default": true,
          "required": false,
          "description": "When True, also estimate the linear tilt slope."
        },
        {
          "name": "output_key",
          "default": "geometry",
          "required": false,
          "description": "ctx.extras key receiving the geometry dict."
        }
      ],
      "references": [
        "Tody 1986, Proc. SPIE 627, 733 — IRAF identify / reidentify / fitcoords 2-D wavelength solutions.",
        "Prochaska et al. 2020, JOSS 5, 2308 — PypeIt wavelength / tilts module.",
        "Schroeder 2000, Astronomical Optics 2nd ed. ch. 15 §15.3 — smile curvature parametrisation R."
      ],
      "related": [
        "fit_emission_lines_gaussian",
        "match_lamp_lines",
        "reidentify_arc_features",
        "wavelength_calibrate_easyspec",
        "wavelength_calibrate_polynomial",
        "wavelength_calibration_in_situ",
        "wavelength_calibration_solar"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"measure_arc_geometry\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run measure_arc_geometry --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "measure_arc_geometry",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/wavelength_calibration/measure_arc_geometry/"
    },
    {
      "id": "reidentify_arc_features",
      "name": "reidentify_arc_features",
      "summary": "Re-anchor stored (pixel, λ) arc features onto a fresh arc exposure.",
      "description": "The complement of match_lamp_lines: that brick identifies lines against the bundled NIST atlas from scratch (IRAF identify/autoidentify); this one re-anchors a previously stored, instrument-specific feature list (IRAF reidentify). The stored wavelengths are an empirical description of one instrument unit — at low resolution blends are stable barycentres, not atlas lines — and are therefore conserved, never re-matched. Peaks are detected with the same DER_SNR-scaled prominence recipe as match_lamp_lines; each reference takes the nearest peak within search_window_px, centroids are refined to sub-pixel by a Gaussian+constant fit (refine=true), and the run fails explicitly when fewer than min_fraction of the references are recovered. Output: ctx.extras[output_key] = {'pixel_positions': [...], 'wavelengths_angstrom': [...]}.",
      "category": "wavelength_calibration",
      "category_title": "Wavelength calibration",
      "version": "1.0.0",
      "backend": "scipy",
      "inputs": [],
      "outputs": [
        "extras.lamp_identifications",
        "metrics.reidentified_fraction",
        "metrics.median_shift_px"
      ],
      "parameters": [
        {
          "name": "reference_pixels",
          "default": null,
          "required": true,
          "description": "Stored feature positions (pixels) from the one-off identification of this instrument set-up (REQUIRED)."
        },
        {
          "name": "reference_wavelengths",
          "default": null,
          "required": true,
          "description": "Wavelengths (Å) paired with reference_pixels (REQUIRED). Conserved verbatim — this is an empirical feature list, not a physical atlas."
        },
        {
          "name": "search_window_px",
          "default": 8.0,
          "required": false,
          "description": "Half-width (pixels) of the search window around each reference. Bounds the drift the brick will absorb; a shift beyond it is a failure, not a guess."
        },
        {
          "name": "min_fraction",
          "default": 0.7,
          "required": false,
          "description": "Minimum fraction of references that must be recovered; below it the run fails explicitly (weak arc or abnormal shift → back to assisted identification)."
        },
        {
          "name": "refine",
          "default": true,
          "required": false,
          "description": "Refine each matched peak to sub-pixel with a Gaussian+constant centroid fit."
        },
        {
          "name": "detection_sigma",
          "default": 5.0,
          "required": false,
          "description": "Peak prominence threshold as a multiple of the DER_SNR-style noise (same recipe as match_lamp_lines)."
        },
        {
          "name": "min_prominence",
          "default": 0.01,
          "required": false,
          "description": "Floor on the prominence as a fraction of the flux amplitude."
        },
        {
          "name": "min_distance_px",
          "default": 5,
          "required": false,
          "description": "Minimum spacing between detected peaks (pixels)."
        },
        {
          "name": "source_key",
          "default": "lamp_spectrum",
          "required": false,
          "description": "ctx.extras key holding the arc Spectrum1D (pixel axis)."
        },
        {
          "name": "output_key",
          "default": "lamp_identifications",
          "required": false,
          "description": "ctx.extras key receiving the identifications dict {'pixel_positions': [...], 'wavelengths_angstrom': [...]}."
        }
      ],
      "references": [
        "Tody 1986, SPIE 627, 733 — IRAF Data Reduction and Analysis System.",
        "Tody 1993, ASP Conf. Ser. 52, 173 — IRAF identify/autoidentify."
      ],
      "related": [
        "fit_emission_lines_gaussian",
        "match_lamp_lines",
        "measure_arc_geometry",
        "wavelength_calibrate_easyspec",
        "wavelength_calibrate_polynomial",
        "wavelength_calibration_in_situ",
        "wavelength_calibration_solar"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"reidentify_arc_features\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run reidentify_arc_features --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "reidentify_arc_features",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/wavelength_calibration/reidentify_arc_features/"
    },
    {
      "id": "wavelength_calibrate_easyspec",
      "name": "wavelength_calibrate_easyspec",
      "summary": "Fit a wavelength polynomial via ``extraction.wavelength_calibration`` and apply it.",
      "description": "Lamp peaks must be pre-identified — typically you run an arc-lamp through ``extract_spectrum_easyspec`` first, pick the line peaks (e.g. with ``detect_lines``) and match them to a reference list (NIST, NeAr…). The matched ``(pixel, wavelength)`` pairs feed this algorithm.",
      "category": "wavelength_calibration",
      "category_title": "Wavelength calibration",
      "version": "1.0.1",
      "backend": "easyspec",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "spectrum",
        "extras.wavelength_polynomial_coefficients"
      ],
      "parameters": [
        {
          "name": "lamp_peak_positions",
          "default": null,
          "required": true,
          "description": "List of identified peak pixel positions in the arc-lamp spectrum."
        },
        {
          "name": "corresponding_wavelengths",
          "default": null,
          "required": true,
          "description": "Reference wavelengths (Å) for those peaks, same length."
        },
        {
          "name": "poly_order",
          "default": 2,
          "required": false,
          "description": "Polynomial order of the wavelength fit."
        },
        {
          "name": "data_type",
          "default": "target",
          "required": false,
          "description": "easyspec data_type label (target / standard_star)."
        }
      ],
      "references": [
        "easyspec.extraction.extraction.wavelength_calibration."
      ],
      "related": [
        "fit_emission_lines_gaussian",
        "match_lamp_lines",
        "measure_arc_geometry",
        "reidentify_arc_features",
        "wavelength_calibrate_polynomial",
        "wavelength_calibration_in_situ",
        "wavelength_calibration_solar"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"wavelength_calibrate_easyspec\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run wavelength_calibrate_easyspec --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "wavelength_calibrate_easyspec",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/wavelength_calibration/wavelength_calibrate_easyspec/"
    },
    {
      "id": "wavelength_calibrate_polynomial",
      "name": "wavelength_calibrate_polynomial",
      "summary": "Fit a polynomial to (pixel → wavelength) pairs and apply it to the spectrum.",
      "description": "The polynomial is fitted with numpy.polynomial.Polynomial.fit, which handles the domain mapping so high orders stay well-conditioned. pixel_positions are 0-based indices on the current ctx.spectrum.wavelength axis (see the class docstring).",
      "category": "wavelength_calibration",
      "category_title": "Wavelength calibration",
      "version": "1.0.1",
      "backend": "numpy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "spectrum",
        "metrics.calibration_rms_angstrom"
      ],
      "parameters": [
        {
          "name": "pixel_positions",
          "default": null,
          "required": true,
          "description": "List of 0-based pixel indices identified in the arc spectrum (same frame as ctx.spectrum.wavelength; first sample = 0)."
        },
        {
          "name": "wavelengths_angstrom",
          "default": null,
          "required": true,
          "description": "Rest wavelengths corresponding to those pixels (Å)."
        },
        {
          "name": "order",
          "default": 3,
          "required": false,
          "description": "Polynomial degree."
        }
      ],
      "references": [
        "Tody 1986, Proc. SPIE 627, 733 — IRAF identify: polynomial dispersion solution fitted through identified arc lines.",
        "Tody 1993, ASP Conf. Ser. 52, 173 — IRAF in the Nineties (identify / dispcor dispersion functions)."
      ],
      "related": [
        "fit_emission_lines_gaussian",
        "match_lamp_lines",
        "measure_arc_geometry",
        "reidentify_arc_features",
        "wavelength_calibrate_easyspec",
        "wavelength_calibration_in_situ",
        "wavelength_calibration_solar"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"wavelength_calibrate_polynomial\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run wavelength_calibrate_polynomial --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "wavelength_calibrate_polynomial",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/wavelength_calibration/wavelength_calibrate_polynomial/"
    },
    {
      "id": "wavelength_calibration_in_situ",
      "name": "wavelength_calibration_in_situ",
      "summary": "Refine the wavelength zero-point from simultaneously-acquired sky lines.",
      "description": "The polynomial coefficients describe the initial wavelength solution (highest order first, as accepted by numpy.poly1d). The Δλ shift is the *zero-point* refinement — slope and higher-order coefficients are kept. RMS of residuals after subtracting the mean Δλ measures how well a pure zero-point shift explains the calibration error; large RMS means the initial polynomial itself needs re-fitting (use wavelength_calibrate_polynomial).",
      "category": "wavelength_calibration",
      "category_title": "Wavelength calibration",
      "version": "1.0.0",
      "backend": "astropy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "spectrum",
        "metrics.mean_delta_angstrom",
        "metrics.rms_residual_angstrom"
      ],
      "parameters": [
        {
          "name": "polynomial_coef",
          "default": [],
          "required": true,
          "description": "Initial λ(x) coefficients, highest order first (numpy.poly1d)."
        },
        {
          "name": "reference_wavelengths",
          "default": [],
          "required": true,
          "description": "Catalogue λ (Å) of the reference lines, same length as guesses."
        },
        {
          "name": "guess_positions",
          "default": [],
          "required": true,
          "description": "Pixel positions of those lines in the sky spectrum."
        },
        {
          "name": "search_width",
          "default": 40.0,
          "required": false,
          "description": "Fit-window width (px) forwarded to the line fitter."
        },
        {
          "name": "sky_key",
          "default": "sky_spectrum",
          "required": false,
          "description": "ctx.extras key holding the sky reference Spectrum1D."
        },
        {
          "name": "oversampling",
          "default": 2.0,
          "required": false,
          "description": "Uniform-grid oversampling factor vs. the native pixel step (≥ 1)."
        }
      ],
      "references": [
        "Stoughton et al. 2002, AJ 123, 485 — SDSS in-situ wavelength refinement from night-sky lines.",
        "Hanuschik 2003, A&A 407, 1157 — UVES optical sky atlas."
      ],
      "related": [
        "fit_emission_lines_gaussian",
        "match_lamp_lines",
        "measure_arc_geometry",
        "reidentify_arc_features",
        "wavelength_calibrate_easyspec",
        "wavelength_calibrate_polynomial",
        "wavelength_calibration_solar"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"wavelength_calibration_in_situ\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run wavelength_calibration_in_situ --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "wavelength_calibration_in_situ",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/wavelength_calibration/wavelength_calibration_in_situ/"
    },
    {
      "id": "wavelength_calibration_solar",
      "name": "wavelength_calibration_solar",
      "summary": "Calibrate a solar spectrum from built-in Fraunhofer line wavelengths.",
      "description": "Built-in catalogue of 15 strong Fraunhofer lines (Ca II K/H, Hβ, Mg b triplet, Na D, Hα, O2 telluric bands). Use the solar or daylight twilight spectrum directly as input; no arc lamp needed. Set min_lines_for_fit ≥ poly_order + 1 — typically 5+. approx_wavelength_min_angstrom helps the matcher choose the right Fraunhofer line per peak. The pixel axis is 0-based. v2.0.0: when several detected peaks fall within match_tolerance_angstrom of the same catalogue line, the peak whose approximate wavelength is closest to that line is kept — v1 compared the catalogue wavelength with itself and therefore always kept the first peak encountered (a spurious dip a few Å blueward of a real line displaced the identification and the fit).",
      "category": "wavelength_calibration",
      "category_title": "Wavelength calibration",
      "version": "2.0.0",
      "backend": "scipy",
      "inputs": [
        "spectrum"
      ],
      "outputs": [
        "spectrum",
        "extras.wavelength_polynomial_coefficients",
        "metrics.n_matched_lines",
        "metrics.fit_rms_angstrom"
      ],
      "parameters": [
        {
          "name": "approx_wavelength_min_angstrom",
          "default": 3800.0,
          "required": true,
          "description": "Approximate wavelength (Å) of pixel 0 — used as the starting point of the peak-to-Fraunhofer matcher."
        },
        {
          "name": "approx_dispersion_angstrom_per_pixel",
          "default": 1.0,
          "required": true,
          "description": "Dispersion estimate (Å / pixel) for the grating + camera combination. Read it off the instrument documentation."
        },
        {
          "name": "poly_order",
          "default": 3,
          "required": false,
          "description": "Polynomial order of the fitted λ(x) (typically 2 or 3)."
        },
        {
          "name": "min_lines_for_fit",
          "default": 5,
          "required": false,
          "description": "Refuse to fit fewer than this many matched lines (must be ≥ poly_order + 1)."
        },
        {
          "name": "match_tolerance_angstrom",
          "default": 8.0,
          "required": false,
          "description": "Maximum residual (Å) between a peak's approximate wavelength and the nearest Fraunhofer line for the match to count."
        },
        {
          "name": "peak_prominence_quantile",
          "default": 0.5,
          "required": false,
          "description": "scipy.signal.find_peaks prominence threshold expressed as a quantile of the inverted-spectrum amplitude (0..1, higher = stricter)."
        },
        {
          "name": "peak_min_distance_pixels",
          "default": 5,
          "required": false,
          "description": "Minimum spacing between detected peaks (pixels)."
        }
      ],
      "references": [
        "Delbouille, Roland & Neven 1973, Atlas du spectre solaire — high-resolution Fraunhofer atlas.",
        "Kurucz 2005, Mem. Soc. Astron. It. Suppl. 8, 14 — synthetic solar atlas (BASS2000).",
        "Tody 1986, Proc. SPIE 627, 733 — IRAF identify heritage."
      ],
      "related": [
        "fit_emission_lines_gaussian",
        "match_lamp_lines",
        "measure_arc_geometry",
        "reidentify_arc_features",
        "wavelength_calibrate_easyspec",
        "wavelength_calibrate_polynomial",
        "wavelength_calibration_in_situ"
      ],
      "interfaces": {
        "python": {
          "call": "run_algorithm(\"wavelength_calibration_solar\", ctx, params)"
        },
        "cli": {
          "available": true,
          "command": "spectro run wavelength_calibration_solar --input <spectrum> --param key=value"
        },
        "mcp": {
          "tool": "wavelength_calibration_solar",
          "arguments": [
            "session_id",
            "params"
          ]
        }
      },
      "docs_url": "https://docs.spectrokernel.io/algorithms/wavelength_calibration/wavelength_calibration_solar/"
    }
  ]
}
