spectro-kernel 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spectro_kernel/__init__.py +89 -0
- spectro_kernel/adapters/__init__.py +8 -0
- spectro_kernel/adapters/easyspec.py +47 -0
- spectro_kernel/algorithms/__init__.py +20 -0
- spectro_kernel/algorithms/_common.py +56 -0
- spectro_kernel/algorithms/advanced/__init__.py +1 -0
- spectro_kernel/algorithms/advanced/aperture_photometry.py +132 -0
- spectro_kernel/algorithms/advanced/disentangle_sb2.py +165 -0
- spectro_kernel/algorithms/catalogs/__init__.py +1 -0
- spectro_kernel/algorithms/catalogs/gaia.py +106 -0
- spectro_kernel/algorithms/catalogs/simbad.py +113 -0
- spectro_kernel/algorithms/catalogs/vizier.py +89 -0
- spectro_kernel/algorithms/continuum/__init__.py +1 -0
- spectro_kernel/algorithms/continuum/compare_normalisations.py +116 -0
- spectro_kernel/algorithms/continuum/normalize_edges.py +59 -0
- spectro_kernel/algorithms/continuum/normalize_max.py +42 -0
- spectro_kernel/algorithms/continuum/normalize_percentile.py +54 -0
- spectro_kernel/algorithms/continuum/normalize_polynomial.py +62 -0
- spectro_kernel/algorithms/continuum/subtract_continuum.py +56 -0
- spectro_kernel/algorithms/corrections/__init__.py +1 -0
- spectro_kernel/algorithms/corrections/air_vacuum.py +90 -0
- spectro_kernel/algorithms/corrections/barycentric.py +118 -0
- spectro_kernel/algorithms/corrections/doppler_shift.py +52 -0
- spectro_kernel/algorithms/corrections/extinction_correct_easyspec.py +140 -0
- spectro_kernel/algorithms/corrections/fit_telluric_scaling.py +150 -0
- spectro_kernel/algorithms/corrections/flux_calibrate_easyspec.py +206 -0
- spectro_kernel/algorithms/corrections/remove_telluric.py +92 -0
- spectro_kernel/algorithms/corrections/synth_telluric.py +125 -0
- spectro_kernel/algorithms/exports/__init__.py +1 -0
- spectro_kernel/algorithms/exports/export_csv.py +56 -0
- spectro_kernel/algorithms/exports/export_fits.py +56 -0
- spectro_kernel/algorithms/exports/export_hdf5.py +73 -0
- spectro_kernel/algorithms/exports/export_votable.py +54 -0
- spectro_kernel/algorithms/extraction/__init__.py +1 -0
- spectro_kernel/algorithms/extraction/easyspec_extract.py +207 -0
- spectro_kernel/algorithms/io/__init__.py +1 -0
- spectro_kernel/algorithms/io/read_ascii.py +42 -0
- spectro_kernel/algorithms/io/read_echelle.py +223 -0
- spectro_kernel/algorithms/io/read_fits.py +40 -0
- spectro_kernel/algorithms/io/read_votable.py +39 -0
- spectro_kernel/algorithms/lines/__init__.py +1 -0
- spectro_kernel/algorithms/lines/_profiles.py +187 -0
- spectro_kernel/algorithms/lines/catalogs.py +77 -0
- spectro_kernel/algorithms/lines/compare_line_fits.py +136 -0
- spectro_kernel/algorithms/lines/detect.py +142 -0
- spectro_kernel/algorithms/lines/equivalent_width.py +84 -0
- spectro_kernel/algorithms/lines/fit_gaussian.py +44 -0
- spectro_kernel/algorithms/lines/fit_lorentzian.py +44 -0
- spectro_kernel/algorithms/lines/fit_voigt.py +45 -0
- spectro_kernel/algorithms/quality/__init__.py +1 -0
- spectro_kernel/algorithms/quality/compare_snr_methods.py +109 -0
- spectro_kernel/algorithms/quality/snr_der.py +52 -0
- spectro_kernel/algorithms/quality/snr_edge.py +65 -0
- spectro_kernel/algorithms/quality/snr_linear_fit.py +55 -0
- spectro_kernel/algorithms/reduction/__init__.py +1 -0
- spectro_kernel/algorithms/reduction/_easyspec_apply.py +93 -0
- spectro_kernel/algorithms/reduction/_easyspec_helpers.py +162 -0
- spectro_kernel/algorithms/reduction/bias_combine.py +64 -0
- spectro_kernel/algorithms/reduction/clip_cosmic_rays.py +86 -0
- spectro_kernel/algorithms/reduction/dark_subtract.py +69 -0
- spectro_kernel/algorithms/reduction/easyspec_bias.py +85 -0
- spectro_kernel/algorithms/reduction/easyspec_cosmic_ray.py +90 -0
- spectro_kernel/algorithms/reduction/easyspec_dark.py +72 -0
- spectro_kernel/algorithms/reduction/easyspec_flat.py +74 -0
- spectro_kernel/algorithms/reduction/easyspec_flat_normalize.py +86 -0
- spectro_kernel/algorithms/reduction/easyspec_subtract_bias.py +81 -0
- spectro_kernel/algorithms/reduction/easyspec_subtract_dark.py +79 -0
- spectro_kernel/algorithms/reduction/extract_spectrum_sum.py +91 -0
- spectro_kernel/algorithms/reduction/flat_normalize.py +69 -0
- spectro_kernel/algorithms/reduction/subtract_sky_2d.py +130 -0
- spectro_kernel/algorithms/reduction/wavelength_calibrate.py +86 -0
- spectro_kernel/algorithms/rv/__init__.py +1 -0
- spectro_kernel/algorithms/rv/cross_correlate.py +131 -0
- spectro_kernel/algorithms/rv/fit_keplerian_orbit.py +202 -0
- spectro_kernel/algorithms/rv/measure.py +85 -0
- spectro_kernel/algorithms/rv/precision_bouchy.py +78 -0
- spectro_kernel/algorithms/smoothing/__init__.py +1 -0
- spectro_kernel/algorithms/smoothing/compare_smoothings.py +98 -0
- spectro_kernel/algorithms/smoothing/smooth_gaussian.py +45 -0
- spectro_kernel/algorithms/smoothing/smooth_savgol.py +62 -0
- spectro_kernel/algorithms/stacking/__init__.py +1 -0
- spectro_kernel/algorithms/stacking/merge_echelle_orders.py +140 -0
- spectro_kernel/algorithms/stacking/stack.py +78 -0
- spectro_kernel/algorithms/timeseries/__init__.py +1 -0
- spectro_kernel/algorithms/timeseries/lomb_scargle.py +124 -0
- spectro_kernel/algorithms/timeseries/phase_fold.py +58 -0
- spectro_kernel/algorithms/transforms/__init__.py +1 -0
- spectro_kernel/algorithms/transforms/clip_sigma.py +76 -0
- spectro_kernel/algorithms/transforms/extract_region.py +41 -0
- spectro_kernel/algorithms/transforms/mask_range.py +54 -0
- spectro_kernel/algorithms/transforms/resample.py +91 -0
- spectro_kernel/algorithms/transforms/resample_flux_conserving.py +123 -0
- spectro_kernel/algorithms/viz/__init__.py +1 -0
- spectro_kernel/algorithms/viz/plot_3d_surface.py +95 -0
- spectro_kernel/algorithms/viz/plot_animation.py +132 -0
- spectro_kernel/algorithms/viz/plot_dynamic_spectrum.py +94 -0
- spectro_kernel/algorithms/viz/plot_plotly.py +129 -0
- spectro_kernel/algorithms/wavelength_calibration/__init__.py +1 -0
- spectro_kernel/algorithms/wavelength_calibration/easyspec_wavelength.py +147 -0
- spectro_kernel/base.py +201 -0
- spectro_kernel/cli.py +339 -0
- spectro_kernel/errors.py +51 -0
- spectro_kernel/io/__init__.py +21 -0
- spectro_kernel/io/ascii.py +124 -0
- spectro_kernel/io/fits.py +225 -0
- spectro_kernel/io/votable.py +81 -0
- spectro_kernel/pipeline.py +306 -0
- spectro_kernel/presets/__init__.py +7 -0
- spectro_kernel/presets/catalog/analysis/balmer_quick.yaml +35 -0
- spectro_kernel/presets/catalog/analysis/quality_report.yaml +24 -0
- spectro_kernel/presets/catalog/analysis/rv_quick.yaml +21 -0
- spectro_kernel/presets/catalog/analysis/snr_check.yaml +21 -0
- spectro_kernel/presets/catalog/analysis/time_series_overview.yaml +27 -0
- spectro_kernel/presets/catalog/reduction/full_reduction_easyspec.yaml +68 -0
- spectro_kernel/presets/loader.py +83 -0
- spectro_kernel/py.typed +0 -0
- spectro_kernel/registry.py +281 -0
- spectro_kernel/types/__init__.py +31 -0
- spectro_kernel/types/catalog.py +54 -0
- spectro_kernel/types/context.py +124 -0
- spectro_kernel/types/enums.py +55 -0
- spectro_kernel/types/history.py +51 -0
- spectro_kernel/types/image.py +62 -0
- spectro_kernel/types/line.py +82 -0
- spectro_kernel/types/spectrum.py +175 -0
- spectro_kernel/types/timeseries.py +96 -0
- spectro_kernel/version.py +3 -0
- spectro_kernel-0.1.0.dist-info/METADATA +229 -0
- spectro_kernel-0.1.0.dist-info/RECORD +140 -0
- spectro_kernel-0.1.0.dist-info/WHEEL +4 -0
- spectro_kernel-0.1.0.dist-info/entry_points.txt +3 -0
- spectro_kernel-0.1.0.dist-info/licenses/LICENSE +21 -0
- spectro_mcp/__init__.py +22 -0
- spectro_mcp/__main__.py +80 -0
- spectro_mcp/auth.py +88 -0
- spectro_mcp/auto_tools.py +341 -0
- spectro_mcp/observability.py +133 -0
- spectro_mcp/py.typed +0 -0
- spectro_mcp/server.py +76 -0
- spectro_mcp/session.py +187 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Algorithm: resolve an object against the SIMBAD database.
|
|
2
|
+
|
|
3
|
+
Requires the optional ``catalogs`` extra (``pip install spectro-kernel[catalogs]``).
|
|
4
|
+
If astroquery is not installed the algorithm simply does not register, and the rest of
|
|
5
|
+
the catalogue loads normally.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ...base import AlgorithmOutput, BaseAlgorithm
|
|
13
|
+
from ...registry import register_algorithm
|
|
14
|
+
from ...types import AlgorithmCategory, CatalogResult, WorkContext
|
|
15
|
+
|
|
16
|
+
try: # optional dependency
|
|
17
|
+
from astroquery.simbad import Simbad as _Simbad
|
|
18
|
+
|
|
19
|
+
_HAVE_ASTROQUERY = True
|
|
20
|
+
except ImportError: # pragma: no cover - depends on the install extras
|
|
21
|
+
_HAVE_ASTROQUERY = False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _cell(row: Any, *names: str) -> Any:
|
|
25
|
+
"""Return the first present column value from *row*, matched case-insensitively."""
|
|
26
|
+
available = {str(c).lower(): c for c in row.colnames}
|
|
27
|
+
for name in names:
|
|
28
|
+
col = available.get(name.lower())
|
|
29
|
+
if col is not None:
|
|
30
|
+
value = row[col]
|
|
31
|
+
return None if value is None or str(value).strip() in ("", "--") else value
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
if _HAVE_ASTROQUERY:
|
|
36
|
+
|
|
37
|
+
@register_algorithm("simbad_query", category=AlgorithmCategory.CATALOG, version="1.0.0")
|
|
38
|
+
class SimbadQuery(BaseAlgorithm):
|
|
39
|
+
"""Resolve an object name against SIMBAD and store the record in the context.
|
|
40
|
+
|
|
41
|
+
The normalised :class:`CatalogResult` (identifier, ICRS coordinates, object
|
|
42
|
+
type) is stored in ``ctx.catalog_lookups`` keyed by the queried name.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
backend = "astroquery"
|
|
46
|
+
references = [
|
|
47
|
+
"Wenger et al. 2000, A&AS 143, 9 — the SIMBAD astronomical database",
|
|
48
|
+
"Ginsburg et al. 2019, AJ 157, 98 — astroquery",
|
|
49
|
+
]
|
|
50
|
+
long_description = (
|
|
51
|
+
"Requires network access and the optional 'catalogs' extra. Results are not "
|
|
52
|
+
"cached by the algorithm itself; wrap it in a caching layer for batch use."
|
|
53
|
+
)
|
|
54
|
+
default_params = {"object_name": None}
|
|
55
|
+
required_params = ["object_name"]
|
|
56
|
+
param_descriptions = {"object_name": "Object identifier to resolve (e.g. 'Vega')."}
|
|
57
|
+
input_requirements: list[str] = []
|
|
58
|
+
output_produces = ["catalog_lookups"]
|
|
59
|
+
|
|
60
|
+
def run(self, ctx: WorkContext, params: dict[str, Any]) -> AlgorithmOutput:
|
|
61
|
+
name = str(params["object_name"])
|
|
62
|
+
try:
|
|
63
|
+
table = _Simbad.query_object(name)
|
|
64
|
+
except Exception as exc: # noqa: BLE001 - network/parse errors
|
|
65
|
+
return AlgorithmOutput.fail(f"SIMBAD query failed: {exc}")
|
|
66
|
+
|
|
67
|
+
if table is None or len(table) == 0:
|
|
68
|
+
result = CatalogResult(source="SIMBAD", query=name)
|
|
69
|
+
ctx.catalog_lookups[name] = result
|
|
70
|
+
return AlgorithmOutput.ok(
|
|
71
|
+
artifacts={"result": result.to_dict()},
|
|
72
|
+
message=f"SIMBAD returned no match for {name!r}.",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
row = table[0]
|
|
76
|
+
ra = _cell(row, "ra", "ra_d")
|
|
77
|
+
dec = _cell(row, "dec", "dec_d")
|
|
78
|
+
result = CatalogResult(
|
|
79
|
+
source="SIMBAD",
|
|
80
|
+
query=name,
|
|
81
|
+
identifier=_str_or_none(_cell(row, "main_id")),
|
|
82
|
+
ra_deg=_coord_to_deg(ra, is_ra=True),
|
|
83
|
+
dec_deg=_coord_to_deg(dec, is_ra=False),
|
|
84
|
+
object_type=_str_or_none(_cell(row, "otype")),
|
|
85
|
+
fields={"raw": {c: str(row[c]) for c in row.colnames}},
|
|
86
|
+
)
|
|
87
|
+
ctx.catalog_lookups[name] = result
|
|
88
|
+
return AlgorithmOutput.ok(
|
|
89
|
+
artifacts={"result": result.to_dict()},
|
|
90
|
+
message=f"SIMBAD resolved {name!r} to {result.identifier}.",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _str_or_none(value: Any) -> str | None:
|
|
95
|
+
return None if value is None else str(value).strip()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _coord_to_deg(value: Any, *, is_ra: bool) -> float | None:
|
|
99
|
+
"""Convert a SIMBAD coordinate (degrees or sexagesimal) to decimal degrees."""
|
|
100
|
+
if value is None:
|
|
101
|
+
return None
|
|
102
|
+
try:
|
|
103
|
+
return float(value)
|
|
104
|
+
except (TypeError, ValueError):
|
|
105
|
+
pass
|
|
106
|
+
try: # sexagesimal string
|
|
107
|
+
import astropy.units as u
|
|
108
|
+
from astropy.coordinates import Angle
|
|
109
|
+
|
|
110
|
+
unit = u.hourangle if is_ra else u.deg
|
|
111
|
+
return float(Angle(str(value), unit=unit).to(u.deg).value)
|
|
112
|
+
except Exception: # noqa: BLE001
|
|
113
|
+
return None
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Algorithm: query VizieR for tabular catalogue data on an object."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ...base import AlgorithmOutput, BaseAlgorithm
|
|
8
|
+
from ...registry import register_algorithm
|
|
9
|
+
from ...types import AlgorithmCategory, CatalogResult, WorkContext
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
from astroquery.vizier import Vizier as _Vizier
|
|
13
|
+
|
|
14
|
+
_HAVE_ASTROQUERY = True
|
|
15
|
+
except ImportError: # pragma: no cover - depends on install extras
|
|
16
|
+
_HAVE_ASTROQUERY = False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
if _HAVE_ASTROQUERY:
|
|
20
|
+
|
|
21
|
+
@register_algorithm("vizier_query", category=AlgorithmCategory.CATALOG, version="1.0.0")
|
|
22
|
+
class VizierQuery(BaseAlgorithm):
|
|
23
|
+
"""Look up an object in VizieR — the CDS table service.
|
|
24
|
+
|
|
25
|
+
Returns the first row of the first matching catalogue as a
|
|
26
|
+
:class:`CatalogResult` stored in ``ctx.catalog_lookups``.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
backend = "astroquery"
|
|
30
|
+
references = [
|
|
31
|
+
"Ochsenbein, Bauer & Marcout 2000, A&AS 143, 23 — VizieR",
|
|
32
|
+
"Ginsburg et al. 2019, AJ 157, 98 — astroquery",
|
|
33
|
+
]
|
|
34
|
+
long_description = (
|
|
35
|
+
"Requires network access and the optional 'catalogs' extra. By default the "
|
|
36
|
+
"row count per catalogue is capped at 5; set `row_limit` to change it."
|
|
37
|
+
)
|
|
38
|
+
default_params = {
|
|
39
|
+
"object_name": None,
|
|
40
|
+
"catalog": None,
|
|
41
|
+
"row_limit": 5,
|
|
42
|
+
"radius_arcsec": 5.0,
|
|
43
|
+
}
|
|
44
|
+
required_params = ["object_name"]
|
|
45
|
+
param_descriptions = {
|
|
46
|
+
"object_name": "Object identifier (e.g. 'HD 209458').",
|
|
47
|
+
"catalog": "Optional VizieR catalogue ID to restrict to (e.g. 'I/350/gaiaedr3').",
|
|
48
|
+
"row_limit": "Maximum number of rows to fetch per catalogue.",
|
|
49
|
+
"radius_arcsec": "Cone-search radius around the resolved position.",
|
|
50
|
+
}
|
|
51
|
+
input_requirements: list[str] = []
|
|
52
|
+
output_produces = ["catalog_lookups"]
|
|
53
|
+
|
|
54
|
+
def run(self, ctx: WorkContext, params: dict[str, Any]) -> AlgorithmOutput:
|
|
55
|
+
name = str(params["object_name"])
|
|
56
|
+
try:
|
|
57
|
+
v = _Vizier(row_limit=int(params["row_limit"]))
|
|
58
|
+
tables = (
|
|
59
|
+
v.query_object(name, catalog=params["catalog"])
|
|
60
|
+
if params["catalog"]
|
|
61
|
+
else v.query_object(name)
|
|
62
|
+
)
|
|
63
|
+
except Exception as exc: # noqa: BLE001 - network/parse errors
|
|
64
|
+
return AlgorithmOutput.fail(f"VizieR query failed: {exc}")
|
|
65
|
+
|
|
66
|
+
if tables is None or len(tables) == 0:
|
|
67
|
+
result = CatalogResult(source="VizieR", query=name)
|
|
68
|
+
ctx.catalog_lookups[name] = result
|
|
69
|
+
return AlgorithmOutput.ok(
|
|
70
|
+
artifacts={"result": result.to_dict()},
|
|
71
|
+
message=f"VizieR returned no match for {name!r}.",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
first_table = tables[0]
|
|
75
|
+
row = first_table[0]
|
|
76
|
+
result = CatalogResult(
|
|
77
|
+
source=f"VizieR/{first_table.meta.get('name', '?')}",
|
|
78
|
+
query=name,
|
|
79
|
+
identifier=str(row.colnames[0]) if row.colnames else None,
|
|
80
|
+
fields={
|
|
81
|
+
"n_tables": len(tables),
|
|
82
|
+
"first_row": {c: str(row[c]) for c in row.colnames},
|
|
83
|
+
},
|
|
84
|
+
)
|
|
85
|
+
ctx.catalog_lookups[name] = result
|
|
86
|
+
return AlgorithmOutput.ok(
|
|
87
|
+
artifacts={"result": result.to_dict(), "n_tables": len(tables)},
|
|
88
|
+
message=f"VizieR returned {len(tables)} catalogue(s) for {name!r}.",
|
|
89
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Continuum algorithms — normalisation and subtraction of the spectral continuum."""
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Algorithm: run every continuum-normalisation variant side-by-side.
|
|
2
|
+
|
|
3
|
+
A *guided shelf* helper: instead of asking the user to pick between
|
|
4
|
+
``normalize_polynomial`` / ``normalize_percentile`` / ``normalize_max`` /
|
|
5
|
+
``normalize_edges`` blind, this runs all of them on the same input and stores
|
|
6
|
+
each result so the caller can compare and pick. Pair with a Plotly viz tool to
|
|
7
|
+
render the overlay.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from ...base import AlgorithmOutput, BaseAlgorithm
|
|
17
|
+
from ...errors import InvalidParameterError
|
|
18
|
+
from ...registry import register_algorithm
|
|
19
|
+
from ...types import AlgorithmCategory, WorkContext
|
|
20
|
+
|
|
21
|
+
_DEFAULT_METHODS: tuple[str, ...] = (
|
|
22
|
+
"normalize_polynomial",
|
|
23
|
+
"normalize_percentile",
|
|
24
|
+
"normalize_max",
|
|
25
|
+
"normalize_edges",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@register_algorithm(
|
|
30
|
+
"compare_normalisations",
|
|
31
|
+
category=AlgorithmCategory.CONTINUUM,
|
|
32
|
+
version="1.0.0",
|
|
33
|
+
)
|
|
34
|
+
class CompareNormalisations(BaseAlgorithm):
|
|
35
|
+
"""Run every continuum-normalisation method on ``ctx.spectrum`` and collect them.
|
|
36
|
+
|
|
37
|
+
Each method is invoked on a deep copy of the context so the original spectrum
|
|
38
|
+
stays untouched; the resulting normalised spectra are stored in
|
|
39
|
+
``ctx.extras['normalisations']`` as a dict ``{method_name: Spectrum1D}``.
|
|
40
|
+
Pairwise RMS differences between any two methods are recorded in
|
|
41
|
+
``ctx.metrics`` so the caller can quantify how close the choices are.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
backend = "numpy"
|
|
45
|
+
references = [
|
|
46
|
+
"Catalogue normalise_* algorithms; this wrapper is composition only.",
|
|
47
|
+
]
|
|
48
|
+
long_description = (
|
|
49
|
+
"The Spectrum1D ``ctx.spectrum`` you pass in is *not* modified — only "
|
|
50
|
+
"``ctx.extras['normalisations']`` is populated. To then apply one of the "
|
|
51
|
+
"results, copy it back into ``ctx.spectrum`` yourself."
|
|
52
|
+
)
|
|
53
|
+
default_params = {
|
|
54
|
+
"methods": list(_DEFAULT_METHODS),
|
|
55
|
+
"per_method_params": {},
|
|
56
|
+
}
|
|
57
|
+
param_descriptions = {
|
|
58
|
+
"methods": "Names of normalisation algorithms to run (defaults to the 4 native ones).",
|
|
59
|
+
"per_method_params": "Optional dict {method_name: {param: value}} for non-default params.",
|
|
60
|
+
}
|
|
61
|
+
input_requirements = ["spectrum"]
|
|
62
|
+
output_produces = ["extras.normalisations", "metrics.normalisations_rms"]
|
|
63
|
+
|
|
64
|
+
def run(self, ctx: WorkContext, params: dict[str, Any]) -> AlgorithmOutput:
|
|
65
|
+
from ...registry import run_algorithm # noqa: PLC0415 - avoid import cycle
|
|
66
|
+
|
|
67
|
+
methods = list(params["methods"]) if params["methods"] else list(_DEFAULT_METHODS)
|
|
68
|
+
if not methods:
|
|
69
|
+
raise InvalidParameterError("methods must contain at least one algorithm name.")
|
|
70
|
+
per_method = params.get("per_method_params") or {}
|
|
71
|
+
original_spectrum = ctx.spectrum
|
|
72
|
+
|
|
73
|
+
results = {}
|
|
74
|
+
failures = []
|
|
75
|
+
for method in methods:
|
|
76
|
+
try:
|
|
77
|
+
trial = ctx.copy()
|
|
78
|
+
method_params = dict(per_method.get(method, {}))
|
|
79
|
+
output = run_algorithm(method, trial, method_params, raise_on_error=False)
|
|
80
|
+
if output.success and trial.spectrum is not None:
|
|
81
|
+
results[method] = trial.spectrum
|
|
82
|
+
else:
|
|
83
|
+
failures.append((method, output.error or "no spectrum produced"))
|
|
84
|
+
except Exception as exc: # noqa: BLE001 - any failure is recorded
|
|
85
|
+
failures.append((method, f"{type(exc).__name__}: {exc}"))
|
|
86
|
+
|
|
87
|
+
# Restore the original spectrum on the working context.
|
|
88
|
+
ctx.spectrum = original_spectrum
|
|
89
|
+
ctx.extras["normalisations"] = results
|
|
90
|
+
|
|
91
|
+
# Pairwise RMS — only between methods sharing the input grid (true for the
|
|
92
|
+
# native normalize_* algos, which leave the wavelength axis untouched).
|
|
93
|
+
names = sorted(results)
|
|
94
|
+
pairwise_rms: dict[str, float] = {}
|
|
95
|
+
for i, a in enumerate(names):
|
|
96
|
+
for b in names[i + 1 :]:
|
|
97
|
+
fa, fb = results[a].flux, results[b].flux
|
|
98
|
+
if fa.shape == fb.shape:
|
|
99
|
+
rms = float(np.sqrt(np.nanmean((fa - fb) ** 2)))
|
|
100
|
+
pairwise_rms[f"{a}__vs__{b}"] = rms
|
|
101
|
+
for key, value in pairwise_rms.items():
|
|
102
|
+
ctx.metrics[f"normalisations_rms[{key}]"] = value
|
|
103
|
+
|
|
104
|
+
message = (
|
|
105
|
+
f"Compared {len(results)}/{len(methods)} normalisations."
|
|
106
|
+
+ ("" if not failures else f" Failed: {[f[0] for f in failures]}.")
|
|
107
|
+
)
|
|
108
|
+
return AlgorithmOutput.ok(
|
|
109
|
+
metrics={"n_methods": float(len(results))},
|
|
110
|
+
artifacts={
|
|
111
|
+
"methods_run": names,
|
|
112
|
+
"pairwise_rms": pairwise_rms,
|
|
113
|
+
"failures": failures,
|
|
114
|
+
},
|
|
115
|
+
message=message,
|
|
116
|
+
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Algorithm: normalise a spectrum using a continuum fitted on its edges only."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from ...base import AlgorithmOutput, BaseAlgorithm
|
|
10
|
+
from ...errors import InvalidParameterError
|
|
11
|
+
from ...registry import register_algorithm
|
|
12
|
+
from ...types import AlgorithmCategory, WorkContext
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@register_algorithm("normalize_edges", category=AlgorithmCategory.CONTINUUM, version="1.0.0")
|
|
16
|
+
class NormalizeEdges(BaseAlgorithm):
|
|
17
|
+
"""Normalise a spectrum using a continuum fitted only on its line-free edges.
|
|
18
|
+
|
|
19
|
+
A low-order polynomial is fitted through the outer fraction of the spectrum at each
|
|
20
|
+
end — assumed to be pure continuum — then extrapolated across the whole range and
|
|
21
|
+
divided out. The right tool for a narrow window centred on a single line, where a
|
|
22
|
+
global fit would be biased by the line itself.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
default_params = {"edge_fraction": 0.15, "order": 1}
|
|
26
|
+
param_descriptions = {
|
|
27
|
+
"edge_fraction": "Fraction of the spectrum, at each end, used to fit the continuum.",
|
|
28
|
+
"order": "Polynomial order of the edge continuum fit (1 = linear).",
|
|
29
|
+
}
|
|
30
|
+
input_requirements = ["spectrum"]
|
|
31
|
+
output_produces = ["spectrum", "metrics.continuum_median"]
|
|
32
|
+
|
|
33
|
+
def run(self, ctx: WorkContext, params: dict[str, Any]) -> AlgorithmOutput:
|
|
34
|
+
fraction = float(params["edge_fraction"])
|
|
35
|
+
order = int(params["order"])
|
|
36
|
+
if not 0.0 < fraction < 0.5:
|
|
37
|
+
raise InvalidParameterError("edge_fraction must be in the interval (0, 0.5).")
|
|
38
|
+
|
|
39
|
+
spec = ctx.spectrum
|
|
40
|
+
n_edge = max(order + 1, int(spec.npix * fraction))
|
|
41
|
+
if spec.npix < 2 * n_edge:
|
|
42
|
+
return AlgorithmOutput.fail("Spectrum too short for the requested edge windows.")
|
|
43
|
+
|
|
44
|
+
edge_idx = np.r_[0:n_edge, spec.npix - n_edge : spec.npix]
|
|
45
|
+
poly = np.polynomial.Polynomial.fit(
|
|
46
|
+
spec.wavelength[edge_idx], spec.flux[edge_idx], deg=order
|
|
47
|
+
)
|
|
48
|
+
continuum = poly(spec.wavelength)
|
|
49
|
+
safe = np.where(continuum == 0.0, np.nan, continuum)
|
|
50
|
+
|
|
51
|
+
out = spec.with_flux(spec.flux / safe, flux_unit="normalized")
|
|
52
|
+
if spec.uncertainty is not None:
|
|
53
|
+
out.uncertainty = spec.uncertainty / np.abs(safe)
|
|
54
|
+
out.meta["continuum_method"] = "edges"
|
|
55
|
+
ctx.spectrum = out
|
|
56
|
+
return AlgorithmOutput.ok(
|
|
57
|
+
metrics={"continuum_median": float(np.nanmedian(continuum))},
|
|
58
|
+
message="Continuum fitted on the edges and normalised to unity.",
|
|
59
|
+
)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Algorithm: normalise a spectrum by its maximum flux."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from ...base import AlgorithmOutput, BaseAlgorithm
|
|
10
|
+
from ...errors import InvalidParameterError
|
|
11
|
+
from ...registry import register_algorithm
|
|
12
|
+
from ...types import AlgorithmCategory, WorkContext
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@register_algorithm("normalize_max", category=AlgorithmCategory.CONTINUUM, version="1.0.0")
|
|
16
|
+
class NormalizeMax(BaseAlgorithm):
|
|
17
|
+
"""Normalise a spectrum by dividing the flux by its maximum value.
|
|
18
|
+
|
|
19
|
+
The simplest possible normalisation: the peak becomes 1.0. Appropriate for
|
|
20
|
+
emission-line spectra or for quick visual comparison; it is sensitive to outliers,
|
|
21
|
+
so smooth or clip cosmic rays first.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
default_params: dict[str, Any] = {}
|
|
25
|
+
input_requirements = ["spectrum"]
|
|
26
|
+
output_produces = ["spectrum", "metrics.max_flux"]
|
|
27
|
+
|
|
28
|
+
def run(self, ctx: WorkContext, params: dict[str, Any]) -> AlgorithmOutput:
|
|
29
|
+
spec = ctx.spectrum
|
|
30
|
+
peak = float(np.nanmax(spec.flux))
|
|
31
|
+
if peak == 0.0:
|
|
32
|
+
raise InvalidParameterError("Maximum flux is zero; cannot normalise.")
|
|
33
|
+
|
|
34
|
+
out = spec.with_flux(spec.flux / peak, flux_unit="normalized")
|
|
35
|
+
if spec.uncertainty is not None:
|
|
36
|
+
out.uncertainty = spec.uncertainty / abs(peak)
|
|
37
|
+
out.meta["continuum_method"] = "max"
|
|
38
|
+
ctx.spectrum = out
|
|
39
|
+
return AlgorithmOutput.ok(
|
|
40
|
+
metrics={"max_flux": peak},
|
|
41
|
+
message=f"Normalised by the maximum flux ({peak:.4g}).",
|
|
42
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Algorithm: normalise a spectrum by a high flux percentile."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from ...base import AlgorithmOutput, BaseAlgorithm
|
|
10
|
+
from ...errors import InvalidParameterError
|
|
11
|
+
from ...registry import register_algorithm
|
|
12
|
+
from ...types import AlgorithmCategory, WorkContext
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@register_algorithm(
|
|
16
|
+
"normalize_percentile", category=AlgorithmCategory.CONTINUUM, version="1.0.0"
|
|
17
|
+
)
|
|
18
|
+
class NormalizePercentile(BaseAlgorithm):
|
|
19
|
+
"""Normalise a spectrum by dividing the flux by a high percentile of itself.
|
|
20
|
+
|
|
21
|
+
A fast, parameter-light continuum estimate: the chosen percentile (95 by default)
|
|
22
|
+
approximates the continuum level for spectra dominated by absorption lines. Cheaper
|
|
23
|
+
and more robust than a polynomial fit, but it assumes a roughly flat continuum.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
default_params = {"percentile": 95.0}
|
|
27
|
+
param_descriptions = {
|
|
28
|
+
"percentile": "Flux percentile (0-100) taken as the continuum level."
|
|
29
|
+
}
|
|
30
|
+
input_requirements = ["spectrum"]
|
|
31
|
+
output_produces = ["spectrum", "metrics.continuum_level"]
|
|
32
|
+
|
|
33
|
+
def run(self, ctx: WorkContext, params: dict[str, Any]) -> AlgorithmOutput:
|
|
34
|
+
percentile = float(params["percentile"])
|
|
35
|
+
if not 0.0 < percentile <= 100.0:
|
|
36
|
+
raise InvalidParameterError("percentile must be in the interval (0, 100].")
|
|
37
|
+
|
|
38
|
+
spec = ctx.spectrum
|
|
39
|
+
level = float(np.nanpercentile(spec.flux, percentile))
|
|
40
|
+
if level == 0.0:
|
|
41
|
+
raise InvalidParameterError(
|
|
42
|
+
f"The {percentile}th percentile of the flux is zero; cannot normalise."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
out = spec.with_flux(spec.flux / level, flux_unit="normalized")
|
|
46
|
+
if spec.uncertainty is not None:
|
|
47
|
+
out.uncertainty = spec.uncertainty / abs(level)
|
|
48
|
+
out.meta["continuum_method"] = f"percentile_{percentile:g}"
|
|
49
|
+
ctx.spectrum = out
|
|
50
|
+
|
|
51
|
+
return AlgorithmOutput.ok(
|
|
52
|
+
metrics={"continuum_level": level},
|
|
53
|
+
message=f"Normalised by the {percentile:g}th flux percentile ({level:.4g}).",
|
|
54
|
+
)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Algorithm: normalise a spectrum by a sigma-clipped polynomial continuum."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from ...base import AlgorithmOutput, BaseAlgorithm
|
|
10
|
+
from ...registry import register_algorithm
|
|
11
|
+
from ...types import AlgorithmCategory, WorkContext
|
|
12
|
+
from .._common import fit_polynomial_continuum
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@register_algorithm(
|
|
16
|
+
"normalize_polynomial", category=AlgorithmCategory.CONTINUUM, version="1.0.0"
|
|
17
|
+
)
|
|
18
|
+
class NormalizePolynomial(BaseAlgorithm):
|
|
19
|
+
"""Normalise the continuum to unity with a sigma-clipped polynomial fit.
|
|
20
|
+
|
|
21
|
+
A polynomial of the requested order is fitted to the continuum (lines rejected by
|
|
22
|
+
iterative sigma clipping); the flux is divided by it. The result is a continuum-
|
|
23
|
+
normalised spectrum oscillating around 1.0.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
long_description = (
|
|
27
|
+
"Robust against absorption/emission lines thanks to iterative sigma clipping. "
|
|
28
|
+
"Use a low order (2-4) for a slowly varying continuum; higher orders risk "
|
|
29
|
+
"absorbing real spectral features."
|
|
30
|
+
)
|
|
31
|
+
default_params = {"order": 3, "sigma_clip": 3.0, "iterations": 3}
|
|
32
|
+
param_descriptions = {
|
|
33
|
+
"order": "Polynomial degree of the continuum fit.",
|
|
34
|
+
"sigma_clip": "Reject samples beyond this many standard deviations per iteration.",
|
|
35
|
+
"iterations": "Number of sigma-clipping iterations.",
|
|
36
|
+
}
|
|
37
|
+
input_requirements = ["spectrum"]
|
|
38
|
+
output_produces = ["spectrum", "metrics.continuum_median"]
|
|
39
|
+
|
|
40
|
+
def run(self, ctx: WorkContext, params: dict[str, Any]) -> AlgorithmOutput:
|
|
41
|
+
spec = ctx.spectrum
|
|
42
|
+
continuum = fit_polynomial_continuum(
|
|
43
|
+
spec.wavelength,
|
|
44
|
+
spec.flux,
|
|
45
|
+
order=int(params["order"]),
|
|
46
|
+
sigma_clip=float(params["sigma_clip"]),
|
|
47
|
+
iterations=int(params["iterations"]),
|
|
48
|
+
)
|
|
49
|
+
safe = np.where(continuum == 0.0, np.nan, continuum)
|
|
50
|
+
normalised = spec.flux / safe
|
|
51
|
+
|
|
52
|
+
out = spec.with_flux(normalised, flux_unit="normalized")
|
|
53
|
+
if spec.uncertainty is not None:
|
|
54
|
+
out.uncertainty = spec.uncertainty / np.abs(safe)
|
|
55
|
+
out.meta["continuum_method"] = "polynomial"
|
|
56
|
+
ctx.spectrum = out
|
|
57
|
+
|
|
58
|
+
metrics = {
|
|
59
|
+
"continuum_median": float(np.nanmedian(continuum)),
|
|
60
|
+
"normalized_rms": float(np.nanstd(normalised)),
|
|
61
|
+
}
|
|
62
|
+
return AlgorithmOutput.ok(metrics=metrics, message="Continuum normalised to unity.")
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Algorithm: subtract a fitted polynomial continuum from a spectrum."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from ...base import AlgorithmOutput, BaseAlgorithm
|
|
10
|
+
from ...registry import register_algorithm
|
|
11
|
+
from ...types import AlgorithmCategory, WorkContext
|
|
12
|
+
from .._common import fit_polynomial_continuum
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@register_algorithm(
|
|
16
|
+
"subtract_continuum", category=AlgorithmCategory.CONTINUUM, version="1.0.0"
|
|
17
|
+
)
|
|
18
|
+
class SubtractContinuum(BaseAlgorithm):
|
|
19
|
+
"""Subtract a sigma-clipped polynomial continuum, leaving the line residual.
|
|
20
|
+
|
|
21
|
+
Unlike ``normalize_polynomial`` (which divides), this subtracts the continuum so the
|
|
22
|
+
result is centred on zero — the natural input for emission-line detection and
|
|
23
|
+
equivalent-width work.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
default_params = {"order": 3, "sigma_clip": 3.0, "iterations": 3}
|
|
27
|
+
param_descriptions = {
|
|
28
|
+
"order": "Polynomial degree of the continuum fit.",
|
|
29
|
+
"sigma_clip": "Reject samples beyond this many standard deviations per iteration.",
|
|
30
|
+
"iterations": "Number of sigma-clipping iterations.",
|
|
31
|
+
}
|
|
32
|
+
input_requirements = ["spectrum"]
|
|
33
|
+
output_produces = ["spectrum", "metrics.continuum_median"]
|
|
34
|
+
|
|
35
|
+
def run(self, ctx: WorkContext, params: dict[str, Any]) -> AlgorithmOutput:
|
|
36
|
+
spec = ctx.spectrum
|
|
37
|
+
continuum = fit_polynomial_continuum(
|
|
38
|
+
spec.wavelength,
|
|
39
|
+
spec.flux,
|
|
40
|
+
order=int(params["order"]),
|
|
41
|
+
sigma_clip=float(params["sigma_clip"]),
|
|
42
|
+
iterations=int(params["iterations"]),
|
|
43
|
+
)
|
|
44
|
+
residual = spec.flux - continuum
|
|
45
|
+
|
|
46
|
+
out = spec.with_flux(residual)
|
|
47
|
+
out.meta["continuum_method"] = "polynomial_subtracted"
|
|
48
|
+
ctx.spectrum = out
|
|
49
|
+
|
|
50
|
+
return AlgorithmOutput.ok(
|
|
51
|
+
metrics={
|
|
52
|
+
"continuum_median": float(np.nanmedian(continuum)),
|
|
53
|
+
"residual_rms": float(np.nanstd(residual)),
|
|
54
|
+
},
|
|
55
|
+
message="Polynomial continuum subtracted.",
|
|
56
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Correction algorithms — move a spectrum between reference frames."""
|