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,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
|
+
)
|