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.
Files changed (140) hide show
  1. spectro_kernel/__init__.py +89 -0
  2. spectro_kernel/adapters/__init__.py +8 -0
  3. spectro_kernel/adapters/easyspec.py +47 -0
  4. spectro_kernel/algorithms/__init__.py +20 -0
  5. spectro_kernel/algorithms/_common.py +56 -0
  6. spectro_kernel/algorithms/advanced/__init__.py +1 -0
  7. spectro_kernel/algorithms/advanced/aperture_photometry.py +132 -0
  8. spectro_kernel/algorithms/advanced/disentangle_sb2.py +165 -0
  9. spectro_kernel/algorithms/catalogs/__init__.py +1 -0
  10. spectro_kernel/algorithms/catalogs/gaia.py +106 -0
  11. spectro_kernel/algorithms/catalogs/simbad.py +113 -0
  12. spectro_kernel/algorithms/catalogs/vizier.py +89 -0
  13. spectro_kernel/algorithms/continuum/__init__.py +1 -0
  14. spectro_kernel/algorithms/continuum/compare_normalisations.py +116 -0
  15. spectro_kernel/algorithms/continuum/normalize_edges.py +59 -0
  16. spectro_kernel/algorithms/continuum/normalize_max.py +42 -0
  17. spectro_kernel/algorithms/continuum/normalize_percentile.py +54 -0
  18. spectro_kernel/algorithms/continuum/normalize_polynomial.py +62 -0
  19. spectro_kernel/algorithms/continuum/subtract_continuum.py +56 -0
  20. spectro_kernel/algorithms/corrections/__init__.py +1 -0
  21. spectro_kernel/algorithms/corrections/air_vacuum.py +90 -0
  22. spectro_kernel/algorithms/corrections/barycentric.py +118 -0
  23. spectro_kernel/algorithms/corrections/doppler_shift.py +52 -0
  24. spectro_kernel/algorithms/corrections/extinction_correct_easyspec.py +140 -0
  25. spectro_kernel/algorithms/corrections/fit_telluric_scaling.py +150 -0
  26. spectro_kernel/algorithms/corrections/flux_calibrate_easyspec.py +206 -0
  27. spectro_kernel/algorithms/corrections/remove_telluric.py +92 -0
  28. spectro_kernel/algorithms/corrections/synth_telluric.py +125 -0
  29. spectro_kernel/algorithms/exports/__init__.py +1 -0
  30. spectro_kernel/algorithms/exports/export_csv.py +56 -0
  31. spectro_kernel/algorithms/exports/export_fits.py +56 -0
  32. spectro_kernel/algorithms/exports/export_hdf5.py +73 -0
  33. spectro_kernel/algorithms/exports/export_votable.py +54 -0
  34. spectro_kernel/algorithms/extraction/__init__.py +1 -0
  35. spectro_kernel/algorithms/extraction/easyspec_extract.py +207 -0
  36. spectro_kernel/algorithms/io/__init__.py +1 -0
  37. spectro_kernel/algorithms/io/read_ascii.py +42 -0
  38. spectro_kernel/algorithms/io/read_echelle.py +223 -0
  39. spectro_kernel/algorithms/io/read_fits.py +40 -0
  40. spectro_kernel/algorithms/io/read_votable.py +39 -0
  41. spectro_kernel/algorithms/lines/__init__.py +1 -0
  42. spectro_kernel/algorithms/lines/_profiles.py +187 -0
  43. spectro_kernel/algorithms/lines/catalogs.py +77 -0
  44. spectro_kernel/algorithms/lines/compare_line_fits.py +136 -0
  45. spectro_kernel/algorithms/lines/detect.py +142 -0
  46. spectro_kernel/algorithms/lines/equivalent_width.py +84 -0
  47. spectro_kernel/algorithms/lines/fit_gaussian.py +44 -0
  48. spectro_kernel/algorithms/lines/fit_lorentzian.py +44 -0
  49. spectro_kernel/algorithms/lines/fit_voigt.py +45 -0
  50. spectro_kernel/algorithms/quality/__init__.py +1 -0
  51. spectro_kernel/algorithms/quality/compare_snr_methods.py +109 -0
  52. spectro_kernel/algorithms/quality/snr_der.py +52 -0
  53. spectro_kernel/algorithms/quality/snr_edge.py +65 -0
  54. spectro_kernel/algorithms/quality/snr_linear_fit.py +55 -0
  55. spectro_kernel/algorithms/reduction/__init__.py +1 -0
  56. spectro_kernel/algorithms/reduction/_easyspec_apply.py +93 -0
  57. spectro_kernel/algorithms/reduction/_easyspec_helpers.py +162 -0
  58. spectro_kernel/algorithms/reduction/bias_combine.py +64 -0
  59. spectro_kernel/algorithms/reduction/clip_cosmic_rays.py +86 -0
  60. spectro_kernel/algorithms/reduction/dark_subtract.py +69 -0
  61. spectro_kernel/algorithms/reduction/easyspec_bias.py +85 -0
  62. spectro_kernel/algorithms/reduction/easyspec_cosmic_ray.py +90 -0
  63. spectro_kernel/algorithms/reduction/easyspec_dark.py +72 -0
  64. spectro_kernel/algorithms/reduction/easyspec_flat.py +74 -0
  65. spectro_kernel/algorithms/reduction/easyspec_flat_normalize.py +86 -0
  66. spectro_kernel/algorithms/reduction/easyspec_subtract_bias.py +81 -0
  67. spectro_kernel/algorithms/reduction/easyspec_subtract_dark.py +79 -0
  68. spectro_kernel/algorithms/reduction/extract_spectrum_sum.py +91 -0
  69. spectro_kernel/algorithms/reduction/flat_normalize.py +69 -0
  70. spectro_kernel/algorithms/reduction/subtract_sky_2d.py +130 -0
  71. spectro_kernel/algorithms/reduction/wavelength_calibrate.py +86 -0
  72. spectro_kernel/algorithms/rv/__init__.py +1 -0
  73. spectro_kernel/algorithms/rv/cross_correlate.py +131 -0
  74. spectro_kernel/algorithms/rv/fit_keplerian_orbit.py +202 -0
  75. spectro_kernel/algorithms/rv/measure.py +85 -0
  76. spectro_kernel/algorithms/rv/precision_bouchy.py +78 -0
  77. spectro_kernel/algorithms/smoothing/__init__.py +1 -0
  78. spectro_kernel/algorithms/smoothing/compare_smoothings.py +98 -0
  79. spectro_kernel/algorithms/smoothing/smooth_gaussian.py +45 -0
  80. spectro_kernel/algorithms/smoothing/smooth_savgol.py +62 -0
  81. spectro_kernel/algorithms/stacking/__init__.py +1 -0
  82. spectro_kernel/algorithms/stacking/merge_echelle_orders.py +140 -0
  83. spectro_kernel/algorithms/stacking/stack.py +78 -0
  84. spectro_kernel/algorithms/timeseries/__init__.py +1 -0
  85. spectro_kernel/algorithms/timeseries/lomb_scargle.py +124 -0
  86. spectro_kernel/algorithms/timeseries/phase_fold.py +58 -0
  87. spectro_kernel/algorithms/transforms/__init__.py +1 -0
  88. spectro_kernel/algorithms/transforms/clip_sigma.py +76 -0
  89. spectro_kernel/algorithms/transforms/extract_region.py +41 -0
  90. spectro_kernel/algorithms/transforms/mask_range.py +54 -0
  91. spectro_kernel/algorithms/transforms/resample.py +91 -0
  92. spectro_kernel/algorithms/transforms/resample_flux_conserving.py +123 -0
  93. spectro_kernel/algorithms/viz/__init__.py +1 -0
  94. spectro_kernel/algorithms/viz/plot_3d_surface.py +95 -0
  95. spectro_kernel/algorithms/viz/plot_animation.py +132 -0
  96. spectro_kernel/algorithms/viz/plot_dynamic_spectrum.py +94 -0
  97. spectro_kernel/algorithms/viz/plot_plotly.py +129 -0
  98. spectro_kernel/algorithms/wavelength_calibration/__init__.py +1 -0
  99. spectro_kernel/algorithms/wavelength_calibration/easyspec_wavelength.py +147 -0
  100. spectro_kernel/base.py +201 -0
  101. spectro_kernel/cli.py +339 -0
  102. spectro_kernel/errors.py +51 -0
  103. spectro_kernel/io/__init__.py +21 -0
  104. spectro_kernel/io/ascii.py +124 -0
  105. spectro_kernel/io/fits.py +225 -0
  106. spectro_kernel/io/votable.py +81 -0
  107. spectro_kernel/pipeline.py +306 -0
  108. spectro_kernel/presets/__init__.py +7 -0
  109. spectro_kernel/presets/catalog/analysis/balmer_quick.yaml +35 -0
  110. spectro_kernel/presets/catalog/analysis/quality_report.yaml +24 -0
  111. spectro_kernel/presets/catalog/analysis/rv_quick.yaml +21 -0
  112. spectro_kernel/presets/catalog/analysis/snr_check.yaml +21 -0
  113. spectro_kernel/presets/catalog/analysis/time_series_overview.yaml +27 -0
  114. spectro_kernel/presets/catalog/reduction/full_reduction_easyspec.yaml +68 -0
  115. spectro_kernel/presets/loader.py +83 -0
  116. spectro_kernel/py.typed +0 -0
  117. spectro_kernel/registry.py +281 -0
  118. spectro_kernel/types/__init__.py +31 -0
  119. spectro_kernel/types/catalog.py +54 -0
  120. spectro_kernel/types/context.py +124 -0
  121. spectro_kernel/types/enums.py +55 -0
  122. spectro_kernel/types/history.py +51 -0
  123. spectro_kernel/types/image.py +62 -0
  124. spectro_kernel/types/line.py +82 -0
  125. spectro_kernel/types/spectrum.py +175 -0
  126. spectro_kernel/types/timeseries.py +96 -0
  127. spectro_kernel/version.py +3 -0
  128. spectro_kernel-0.1.0.dist-info/METADATA +229 -0
  129. spectro_kernel-0.1.0.dist-info/RECORD +140 -0
  130. spectro_kernel-0.1.0.dist-info/WHEEL +4 -0
  131. spectro_kernel-0.1.0.dist-info/entry_points.txt +3 -0
  132. spectro_kernel-0.1.0.dist-info/licenses/LICENSE +21 -0
  133. spectro_mcp/__init__.py +22 -0
  134. spectro_mcp/__main__.py +80 -0
  135. spectro_mcp/auth.py +88 -0
  136. spectro_mcp/auto_tools.py +341 -0
  137. spectro_mcp/observability.py +133 -0
  138. spectro_mcp/py.typed +0 -0
  139. spectro_mcp/server.py +76 -0
  140. 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."""