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,89 @@
1
+ """spectro-kernel — a shared catalogue of astronomical spectroscopy algorithms.
2
+
3
+ The public surface lives here so callers can do ``from spectro_kernel import ...`` for
4
+ everything they need: the canonical types, the registry, the pipeline machinery and the
5
+ pure I/O helpers. Algorithms register themselves lazily on first discovery.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .base import AlgorithmOutput, BaseAlgorithm
11
+ from .errors import (
12
+ AlgorithmNotFoundError,
13
+ InvalidParameterError,
14
+ PipelineError,
15
+ PresetNotFoundError,
16
+ SpectroKernelError,
17
+ )
18
+ from .io import read_ascii_spectrum, read_fits, write_ascii_spectrum, write_fits
19
+ from .pipeline import Pipeline, PipelineBuilder, PipelineResult, PipelineStep
20
+ from .presets import list_presets, load_preset
21
+ from .registry import (
22
+ create_algorithm,
23
+ describe_algorithm,
24
+ get_algorithm,
25
+ has_algorithm,
26
+ list_algorithms,
27
+ list_categories,
28
+ register_algorithm,
29
+ run_algorithm,
30
+ )
31
+ from .types import (
32
+ AlgorithmCategory,
33
+ CatalogResult,
34
+ ImageFrame,
35
+ LightCurve,
36
+ LineFitResult,
37
+ Periodogram,
38
+ ProcessingStep,
39
+ SpectralLine,
40
+ Spectrum1D,
41
+ WorkContext,
42
+ )
43
+ from .version import __version__
44
+
45
+ __all__ = [
46
+ "__version__",
47
+ # types
48
+ "AlgorithmCategory",
49
+ "CatalogResult",
50
+ "ImageFrame",
51
+ "LightCurve",
52
+ "LineFitResult",
53
+ "Periodogram",
54
+ "ProcessingStep",
55
+ "SpectralLine",
56
+ "Spectrum1D",
57
+ "WorkContext",
58
+ # algorithm contract
59
+ "BaseAlgorithm",
60
+ "AlgorithmOutput",
61
+ # registry
62
+ "register_algorithm",
63
+ "list_algorithms",
64
+ "list_categories",
65
+ "get_algorithm",
66
+ "create_algorithm",
67
+ "describe_algorithm",
68
+ "has_algorithm",
69
+ "run_algorithm",
70
+ # pipeline
71
+ "Pipeline",
72
+ "PipelineBuilder",
73
+ "PipelineResult",
74
+ "PipelineStep",
75
+ # presets
76
+ "load_preset",
77
+ "list_presets",
78
+ # io
79
+ "read_fits",
80
+ "write_fits",
81
+ "read_ascii_spectrum",
82
+ "write_ascii_spectrum",
83
+ # errors
84
+ "SpectroKernelError",
85
+ "AlgorithmNotFoundError",
86
+ "InvalidParameterError",
87
+ "PipelineError",
88
+ "PresetNotFoundError",
89
+ ]
@@ -0,0 +1,8 @@
1
+ """External-library adapters.
2
+
3
+ Each module in this package wraps a domain library (astropy, specutils, astroquery,
4
+ easyspec, …) behind the same algorithm interface as the native spectro-kernel
5
+ algorithms. That is what makes the *we don't reinvent* principle visible: the same
6
+ operation can be served either by our own implementation or by the canonical pro
7
+ library, side by side in the catalogue.
8
+ """
@@ -0,0 +1,47 @@
1
+ """Adapter scaffold for `easyspec` (Lobão et al.) — the CCD-reduction shelf option.
2
+
3
+ `easyspec` (https://pypi.org/project/easyspec/) is a reduction toolkit oriented at
4
+ notebook use: it offers stateful classes whose methods take you from raw FITS frames
5
+ to a calibrated 1D spectrum, with rich plotting along the way. The native
6
+ ``spectro_kernel.algorithms.reduction`` algorithms cover the same operations as
7
+ small, stateless, pipeline-friendly building blocks.
8
+
9
+ Both have a place. This module is the bridge: as adapters land here, the catalogue
10
+ will expose *both* paths so the user picks per operation:
11
+
12
+ | Operation | Native (`reduction/`) | Easyspec (`adapters/easyspec`) |
13
+ |-----------|-----------------------|--------------------------------|
14
+ | master bias / dark / flat | `bias_combine` | `cleaning.master(kind="bias"…)` |
15
+ | flat-fielding | `flat_normalize` | `cleaning.flatten` |
16
+ | cosmic rays | `clip_cosmic_rays` (L.A.Cosmic) | `cleaning.CR_and_gain_corrections` |
17
+ | 1D extraction | `extract_spectrum_sum` | `extraction.tracing` + `extracting` |
18
+ | wavelength calibration | `wavelength_calibrate_polynomial` | `extraction.wavelength_calibration` |
19
+ | flux calibration | (planned) | `extraction.target_flux_calibration` |
20
+
21
+ Status: scaffold. The native path is complete and tested; easyspec wrappers will land
22
+ incrementally so they fit our stateless contract (no plotting during execution, no
23
+ required file paths). Install support is available now via the `reduction` extra:
24
+
25
+ pip install "spectro-kernel[reduction]"
26
+
27
+ When the wrappers are in, they will register conditionally — the catalogue degrades
28
+ gracefully if easyspec is not installed.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ try: # presence flag for downstream wrappers
34
+ import easyspec as _easyspec # noqa: F401
35
+
36
+ EASYSPEC_AVAILABLE = True
37
+ EASYSPEC_VERSION = getattr(_easyspec, "__version__", "unknown")
38
+ except ImportError: # pragma: no cover - depends on install extras
39
+ EASYSPEC_AVAILABLE = False
40
+ EASYSPEC_VERSION = None
41
+
42
+
43
+ # No algorithms registered yet — the wrappers will be added under
44
+ # spectro_kernel/algorithms/reduction/, each marked `backend = "easyspec"` and
45
+ # importing the helpers from this module.
46
+
47
+ __all__ = ["EASYSPEC_AVAILABLE", "EASYSPEC_VERSION"]
@@ -0,0 +1,20 @@
1
+ """The algorithm catalogue, with automatic discovery.
2
+
3
+ Importing this package walks every submodule so that each ``@register_algorithm``
4
+ decorator runs and the registry is fully populated. Adding an algorithm is therefore
5
+ just adding a file under ``algorithms/<category>/`` — nothing else to wire up.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ import pkgutil
12
+
13
+
14
+ def _discover() -> None:
15
+ """Import every submodule so its algorithms register themselves."""
16
+ for module_info in pkgutil.walk_packages(__path__, prefix=__name__ + "."):
17
+ importlib.import_module(module_info.name)
18
+
19
+
20
+ _discover()
@@ -0,0 +1,56 @@
1
+ """Numerical helpers shared by several algorithms.
2
+
3
+ This module registers no algorithm; it only factors out maths used in more than one
4
+ place so there is one implementation, not five.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+
11
+
12
+ def fit_polynomial_continuum(
13
+ wavelength: np.ndarray,
14
+ flux: np.ndarray,
15
+ *,
16
+ order: int = 3,
17
+ sigma_clip: float = 3.0,
18
+ iterations: int = 3,
19
+ ) -> np.ndarray:
20
+ """Fit a sigma-clipped polynomial continuum and return it sampled on *wavelength*.
21
+
22
+ The fit runs on a rescaled abscissa (``numpy.polynomial.Polynomial.fit`` handles the
23
+ domain mapping) so it stays well-conditioned even for raw Ångström values. Samples
24
+ deviating by more than *sigma_clip* standard deviations are dropped between
25
+ iterations, which keeps emission/absorption lines out of the continuum estimate.
26
+ """
27
+ x = np.asarray(wavelength, dtype=np.float64)
28
+ y = np.asarray(flux, dtype=np.float64)
29
+ mask = np.isfinite(y)
30
+ if mask.sum() <= order + 1:
31
+ return np.full_like(y, float(np.nanmedian(y)))
32
+
33
+ for _ in range(max(1, iterations)):
34
+ model_poly = np.polynomial.Polynomial.fit(x[mask], y[mask], deg=order)
35
+ residual = y - model_poly(x)
36
+ std = float(np.std(residual[mask]))
37
+ if std == 0.0:
38
+ break
39
+ new_mask = mask & (np.abs(residual) < sigma_clip * std)
40
+ if new_mask.sum() <= order + 1 or new_mask.sum() == mask.sum():
41
+ mask = new_mask if new_mask.sum() > order + 1 else mask
42
+ break
43
+ mask = new_mask
44
+
45
+ return np.polynomial.Polynomial.fit(x[mask], y[mask], deg=order)(x)
46
+
47
+
48
+ def r_squared(observed: np.ndarray, modelled: np.ndarray) -> float:
49
+ """Return the coefficient of determination of *modelled* against *observed*."""
50
+ observed = np.asarray(observed, dtype=np.float64)
51
+ modelled = np.asarray(modelled, dtype=np.float64)
52
+ ss_res = float(np.sum((observed - modelled) ** 2))
53
+ ss_tot = float(np.sum((observed - np.mean(observed)) ** 2))
54
+ if ss_tot == 0.0:
55
+ return 0.0
56
+ return 1.0 - ss_res / ss_tot
@@ -0,0 +1 @@
1
+ """Advanced spectroscopy algorithms — niche but useful when you need them."""
@@ -0,0 +1,132 @@
1
+ """Algorithm: differential aperture photometry on a 2D image (photutils)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+
10
+ from ...base import AlgorithmOutput, BaseAlgorithm
11
+ from ...errors import InvalidParameterError
12
+ from ...registry import register_algorithm
13
+ from ...types import AlgorithmCategory, WorkContext
14
+
15
+ try:
16
+ from photutils.aperture import (
17
+ ApertureStats,
18
+ CircularAnnulus,
19
+ CircularAperture,
20
+ aperture_photometry,
21
+ )
22
+
23
+ _HAVE_PHOTUTILS = True
24
+ except ImportError: # pragma: no cover - depends on install extras
25
+ _HAVE_PHOTUTILS = False
26
+
27
+
28
+ if _HAVE_PHOTUTILS:
29
+
30
+ @register_algorithm(
31
+ "aperture_photometry",
32
+ category=AlgorithmCategory.ADVANCED,
33
+ version="1.0.0",
34
+ )
35
+ class AperturePhotometry(BaseAlgorithm):
36
+ """Differential aperture photometry on ``ctx.image`` (photutils).
37
+
38
+ Measure the brightness of a target relative to one or more comparison stars
39
+ — the standard amateur/AAVSO recipe. Each aperture's background is taken from
40
+ a concentric sky annulus and subtracted before integration.
41
+ """
42
+
43
+ backend = "photutils"
44
+ references = [
45
+ "Bradley et al. — Astropy Photutils (https://photutils.readthedocs.io/).",
46
+ "AAVSO Guide to CCD Photometry.",
47
+ ]
48
+ long_description = (
49
+ "Provide the target's (x, y) pixel coordinates and one or more comparison "
50
+ "stars. The differential magnitude for each comparison is "
51
+ "-2.5 * log10(target_flux / comp_flux); positive means the target is "
52
+ "fainter than the comparison."
53
+ )
54
+ default_params = {
55
+ "target_xy": None,
56
+ "comparison_xy": None,
57
+ "aperture_radius": 5.0,
58
+ "annulus_in_radius": 8.0,
59
+ "annulus_out_radius": 12.0,
60
+ }
61
+ required_params = ["target_xy", "comparison_xy"]
62
+ param_descriptions = {
63
+ "target_xy": "Target (x, y) pixel coordinates (length-2 list).",
64
+ "comparison_xy": "List of (x, y) coordinates for comparison stars.",
65
+ "aperture_radius": "Aperture radius (pixels) for source extraction.",
66
+ "annulus_in_radius": "Inner radius of the sky annulus (pixels).",
67
+ "annulus_out_radius": "Outer radius of the sky annulus (pixels).",
68
+ }
69
+ input_requirements = ["image"]
70
+ output_produces = ["extras.photometry", "metrics.target_flux"]
71
+
72
+ def run(self, ctx: WorkContext, params: dict[str, Any]) -> AlgorithmOutput:
73
+ target = list(params["target_xy"])
74
+ comparisons = [list(c) for c in params["comparison_xy"]]
75
+ if len(target) != 2:
76
+ raise InvalidParameterError("target_xy must be a (x, y) pair.")
77
+ if not comparisons:
78
+ raise InvalidParameterError("Need at least one comparison star.")
79
+ r_ap = float(params["aperture_radius"])
80
+ r_in = float(params["annulus_in_radius"])
81
+ r_out = float(params["annulus_out_radius"])
82
+ if not (r_ap > 0 and r_in > r_ap and r_out > r_in):
83
+ raise InvalidParameterError(
84
+ "Need 0 < aperture_radius < annulus_in_radius < annulus_out_radius."
85
+ )
86
+
87
+ positions = [tuple(target)] + [tuple(c) for c in comparisons]
88
+ aperture = CircularAperture(positions, r=r_ap)
89
+ annulus = CircularAnnulus(positions, r_in=r_in, r_out=r_out)
90
+ image = ctx.image.data
91
+
92
+ # Background per-source (median of the annulus, photutils helper).
93
+ bkg_stats = ApertureStats(image, annulus)
94
+ bkg_median = np.asarray(bkg_stats.median, dtype=np.float64)
95
+
96
+ phot = aperture_photometry(image, aperture)
97
+ ap_area = aperture.area
98
+ raw = np.asarray(phot["aperture_sum"], dtype=np.float64)
99
+ net = raw - bkg_median * ap_area
100
+
101
+ target_flux = float(net[0])
102
+ comp_fluxes = net[1:].astype(float).tolist()
103
+
104
+ mags = []
105
+ for cf in comp_fluxes:
106
+ if cf > 0.0 and target_flux > 0.0:
107
+ mags.append(-2.5 * math.log10(target_flux / cf))
108
+ else:
109
+ mags.append(None)
110
+
111
+ ctx.metrics["target_flux"] = target_flux
112
+ ctx.extras["photometry"] = {
113
+ "target_xy": target,
114
+ "target_flux": target_flux,
115
+ "comparison_xy": comparisons,
116
+ "comparison_flux": comp_fluxes,
117
+ "differential_magnitudes": mags,
118
+ "aperture_radius": r_ap,
119
+ "annulus_radii": [r_in, r_out],
120
+ "background_per_source": bkg_median.tolist(),
121
+ }
122
+ return AlgorithmOutput.ok(
123
+ metrics={
124
+ "target_flux": target_flux,
125
+ "n_comparisons": float(len(comp_fluxes)),
126
+ },
127
+ artifacts={"photometry": ctx.extras["photometry"]},
128
+ message=(
129
+ f"Differential photometry: target flux = {target_flux:.3g} "
130
+ f"vs {len(comp_fluxes)} comparison(s)."
131
+ ),
132
+ )
@@ -0,0 +1,165 @@
1
+ """Algorithm: iterative wavelength-domain disentangling of an SB2 binary."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import astropy.constants as const
8
+ import astropy.units as u
9
+ import numpy as np
10
+
11
+ from ...base import AlgorithmOutput, BaseAlgorithm
12
+ from ...errors import InvalidParameterError
13
+ from ...registry import register_algorithm
14
+ from ...types import AlgorithmCategory, Spectrum1D, WorkContext
15
+
16
+ _C_KMS = const.c.to(u.km / u.s).value
17
+
18
+
19
+ @register_algorithm(
20
+ "disentangle_sb2",
21
+ category=AlgorithmCategory.ADVANCED,
22
+ version="1.0.0",
23
+ )
24
+ class DisentangleSb2(BaseAlgorithm):
25
+ """Separate the spectra of the two components of an SB2 spectroscopic binary.
26
+
27
+ Given several spectra of the same binary at orbital phases where the radial
28
+ velocities of the primary and secondary differ enough to break their degeneracy,
29
+ plus the per-spectrum velocities for both components, this algorithm reconstructs
30
+ each component's rest-frame spectrum.
31
+ """
32
+
33
+ backend = "numpy"
34
+ references = [
35
+ "Hadrava 1995, A&AS 114, 393 — Fourier-domain disentangling (original).",
36
+ "Simon & Sturm 1994, A&A 281, 286 — wavelength-domain spectral separation.",
37
+ ]
38
+ long_description = (
39
+ "Implements a simplified iterative wavelength-domain separation. All "
40
+ "observations are resampled onto a common log-wavelength grid so that a "
41
+ "Doppler shift is a constant number of samples. At each iteration the "
42
+ "current estimate of one component is removed from every observation in the "
43
+ "other component's rest frame, and the new estimate is the average of the "
44
+ "residuals. Stores ``primary_spectrum`` and ``secondary_spectrum`` in "
45
+ "``ctx.extras``."
46
+ )
47
+ default_params = {
48
+ "v1_kms": None,
49
+ "v2_kms": None,
50
+ "n_iter": 30,
51
+ "n_grid": 4096,
52
+ }
53
+ required_params = ["v1_kms", "v2_kms"]
54
+ param_descriptions = {
55
+ "v1_kms": "List of per-spectrum primary velocities (km/s, length = len(ctx.spectra)).",
56
+ "v2_kms": "List of per-spectrum secondary velocities (km/s, same length).",
57
+ "n_iter": "Number of iterations of the alternating subtraction.",
58
+ "n_grid": "Number of log-wavelength samples on the common grid.",
59
+ }
60
+ input_requirements = ["spectra"]
61
+ output_produces = ["extras.primary_spectrum", "extras.secondary_spectrum"]
62
+
63
+ def run(self, ctx: WorkContext, params: dict[str, Any]) -> AlgorithmOutput:
64
+ spectra = ctx.spectra
65
+ v1 = np.asarray(params["v1_kms"], dtype=np.float64)
66
+ v2 = np.asarray(params["v2_kms"], dtype=np.float64)
67
+ if v1.size != len(spectra) or v2.size != len(spectra):
68
+ raise InvalidParameterError(
69
+ "v1_kms and v2_kms must have the same length as ctx.spectra."
70
+ )
71
+ if len(spectra) < 2:
72
+ return AlgorithmOutput.fail(
73
+ "Need at least 2 spectra at different phases to disentangle SB2."
74
+ )
75
+
76
+ wmin = max(s.wavelength_min for s in spectra)
77
+ wmax = min(s.wavelength_max for s in spectra)
78
+ if wmin >= wmax:
79
+ return AlgorithmOutput.fail("Spectra do not overlap in wavelength.")
80
+
81
+ n = int(params["n_grid"])
82
+ log_grid = np.linspace(np.log(wmin), np.log(wmax), n)
83
+ wave_grid = np.exp(log_grid)
84
+ delta_log = (log_grid[-1] - log_grid[0]) / (n - 1)
85
+ v_per_pixel = _C_KMS * delta_log
86
+
87
+ # Resample observations to the common log-wavelength grid (in the observed frame).
88
+ obs = np.stack(
89
+ [np.interp(wave_grid, s.wavelength, s.flux) for s in spectra]
90
+ ).astype(np.float64)
91
+
92
+ n_iter = int(params["n_iter"])
93
+ return _disentangle(
94
+ ctx, obs, v1, v2, wave_grid, v_per_pixel, n_iter
95
+ )
96
+
97
+
98
+ def _shift(values: np.ndarray, shift_pixels: float) -> np.ndarray:
99
+ """Shift *values* along its index by ``shift_pixels`` (positive = redward)."""
100
+ idx = np.arange(values.size, dtype=np.float64)
101
+ return np.interp(idx - shift_pixels, idx, values, left=np.nan, right=np.nan)
102
+
103
+
104
+ def _disentangle(
105
+ ctx: WorkContext,
106
+ obs: np.ndarray,
107
+ v1: np.ndarray,
108
+ v2: np.ndarray,
109
+ wave_grid: np.ndarray,
110
+ v_per_pixel: float,
111
+ n_iter: int,
112
+ ) -> AlgorithmOutput:
113
+ """Run the alternating wavelength-domain separation."""
114
+ n_obs = obs.shape[0]
115
+ shifts1 = v1 / v_per_pixel
116
+ shifts2 = v2 / v_per_pixel
117
+
118
+ primary = np.nanmean(obs, axis=0).copy() # rough first guess
119
+ secondary = np.zeros_like(primary)
120
+
121
+ for _ in range(n_iter):
122
+ # Pull every obs back into primary rest frame, subtract the secondary's
123
+ # contribution in that same frame, average. Re-do symmetrically for the
124
+ # secondary.
125
+ prim_estimates = np.array(
126
+ [
127
+ _shift(obs[i], -shifts1[i]) - _shift(secondary, shifts2[i] - shifts1[i])
128
+ for i in range(n_obs)
129
+ ]
130
+ )
131
+ primary = np.nanmean(prim_estimates, axis=0)
132
+
133
+ sec_estimates = np.array(
134
+ [
135
+ _shift(obs[i], -shifts2[i]) - _shift(primary, shifts1[i] - shifts2[i])
136
+ for i in range(n_obs)
137
+ ]
138
+ )
139
+ secondary = np.nanmean(sec_estimates, axis=0)
140
+
141
+ # Replace any residual NaN (at frame edges where the shift extrapolates) with 0.
142
+ primary = np.nan_to_num(primary, nan=0.0)
143
+ secondary = np.nan_to_num(secondary, nan=0.0)
144
+
145
+ ref = ctx.spectra[0]
146
+ ctx.extras["primary_spectrum"] = Spectrum1D(
147
+ wavelength=wave_grid.copy(),
148
+ flux=primary,
149
+ flux_unit=ref.flux_unit,
150
+ wavelength_unit=ref.wavelength_unit,
151
+ meta={"sb2": "primary", "n_iter": int(n_iter)},
152
+ )
153
+ ctx.extras["secondary_spectrum"] = Spectrum1D(
154
+ wavelength=wave_grid.copy(),
155
+ flux=secondary,
156
+ flux_unit=ref.flux_unit,
157
+ wavelength_unit=ref.wavelength_unit,
158
+ meta={"sb2": "secondary", "n_iter": int(n_iter)},
159
+ )
160
+ return AlgorithmOutput.ok(
161
+ metrics={"n_iter": float(n_iter), "n_grid": float(wave_grid.size)},
162
+ message=(
163
+ f"Disentangled SB2 from {n_obs} epochs over {wave_grid.size} log-λ samples."
164
+ ),
165
+ )
@@ -0,0 +1 @@
1
+ """Catalogue algorithms — queries to external astronomical databases."""
@@ -0,0 +1,106 @@
1
+ """Algorithm: query the Gaia archive (TAP) for astrometry/photometry 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.gaia import Gaia as _Gaia
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("gaia_query", category=AlgorithmCategory.CATALOG, version="1.0.0")
22
+ class GaiaQuery(BaseAlgorithm):
23
+ """Cone-search the Gaia archive around ICRS coordinates.
24
+
25
+ Returns the nearest record as a :class:`CatalogResult` stored under the source
26
+ identifier in ``ctx.catalog_lookups``.
27
+ """
28
+
29
+ backend = "astroquery"
30
+ references = [
31
+ "Gaia Collaboration 2016, A&A 595, A1 — the Gaia mission",
32
+ "Salgado et al. 2017 — Gaia archive TAP+ access",
33
+ "Ginsburg et al. 2019, AJ 157, 98 — astroquery",
34
+ ]
35
+ long_description = (
36
+ "Requires network access and the optional 'catalogs' extra. By default "
37
+ "queries the latest release available via astroquery.gaia. Use `data_release` "
38
+ "to pin a specific release such as 'DR3' or 'DR2'."
39
+ )
40
+ default_params = {
41
+ "ra_deg": None,
42
+ "dec_deg": None,
43
+ "radius_arcsec": 5.0,
44
+ "data_release": None,
45
+ }
46
+ required_params = ["ra_deg", "dec_deg"]
47
+ param_descriptions = {
48
+ "ra_deg": "Right ascension (ICRS) in degrees.",
49
+ "dec_deg": "Declination (ICRS) in degrees.",
50
+ "radius_arcsec": "Cone-search radius around the position.",
51
+ "data_release": "Gaia release table to query (e.g. 'gaiadr3.gaia_source').",
52
+ }
53
+ input_requirements: list[str] = []
54
+ output_produces = ["catalog_lookups"]
55
+
56
+ def run(self, ctx: WorkContext, params: dict[str, Any]) -> AlgorithmOutput:
57
+ ra = float(params["ra_deg"])
58
+ dec = float(params["dec_deg"])
59
+ radius_deg = float(params["radius_arcsec"]) / 3600.0
60
+ table_name = params.get("data_release") or "gaiadr3.gaia_source"
61
+ adql = (
62
+ f"SELECT TOP 1 source_id, ra, dec, parallax, phot_g_mean_mag, "
63
+ f"DISTANCE(POINT('ICRS', ra, dec), POINT('ICRS', {ra}, {dec})) AS sep "
64
+ f"FROM {table_name} "
65
+ f"WHERE 1 = CONTAINS(POINT('ICRS', ra, dec), "
66
+ f"CIRCLE('ICRS', {ra}, {dec}, {radius_deg})) "
67
+ f"ORDER BY sep ASC"
68
+ )
69
+ try:
70
+ job = _Gaia.launch_job(adql)
71
+ table = job.get_results()
72
+ except Exception as exc: # noqa: BLE001 - network/parse errors
73
+ return AlgorithmOutput.fail(f"Gaia query failed: {exc}")
74
+
75
+ if table is None or len(table) == 0:
76
+ result = CatalogResult(source="Gaia", query=f"{ra},{dec}")
77
+ key = f"gaia:{ra:.5f},{dec:.5f}"
78
+ ctx.catalog_lookups[key] = result
79
+ return AlgorithmOutput.ok(
80
+ artifacts={"result": result.to_dict()},
81
+ message=f"Gaia returned no source within {params['radius_arcsec']}\".",
82
+ )
83
+
84
+ row = table[0]
85
+ source_id = str(row["source_id"])
86
+ result = CatalogResult(
87
+ source=f"Gaia ({table_name})",
88
+ query=f"{ra},{dec}",
89
+ identifier=source_id,
90
+ ra_deg=float(row["ra"]),
91
+ dec_deg=float(row["dec"]),
92
+ fields={
93
+ "parallax_mas": float(row["parallax"]) if row["parallax"] is not None else None,
94
+ "G_mag": (
95
+ float(row["phot_g_mean_mag"])
96
+ if row["phot_g_mean_mag"] is not None
97
+ else None
98
+ ),
99
+ "separation_deg": float(row["sep"]),
100
+ },
101
+ )
102
+ ctx.catalog_lookups[source_id] = result
103
+ return AlgorithmOutput.ok(
104
+ artifacts={"result": result.to_dict()},
105
+ message=f"Gaia source {source_id} at {row['sep']*3600:.2f}\".",
106
+ )