findakin 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.
Potentially problematic release.
This version of findakin might be problematic. Click here for more details.
- findakin/__init__.py +19 -0
- findakin/align.py +292 -0
- findakin/analysis/__init__.py +37 -0
- findakin/analysis/anomaly.py +397 -0
- findakin/analysis/clustering.py +631 -0
- findakin/analysis/projection.py +533 -0
- findakin/benchmark.py +90 -0
- findakin/cli.py +139 -0
- findakin/connectors/__init__.py +41 -0
- findakin/connectors/base.py +61 -0
- findakin/connectors/ssap.py +793 -0
- findakin/connectors/staros.py +88 -0
- findakin/connectors/vo_service.py +227 -0
- findakin/embedding.py +325 -0
- findakin/io/__init__.py +11 -0
- findakin/io/fits_parser.py +776 -0
- findakin/io/metadata.py +166 -0
- findakin/store.py +522 -0
- findakin/timeseries.py +42 -0
- findakin-0.1.0.dist-info/METADATA +166 -0
- findakin-0.1.0.dist-info/RECORD +24 -0
- findakin-0.1.0.dist-info/WHEEL +4 -0
- findakin-0.1.0.dist-info/entry_points.txt +2 -0
- findakin-0.1.0.dist-info/licenses/LICENSE +21 -0
findakin/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Findakin — find a kin for every spectrum.
|
|
2
|
+
|
|
3
|
+
spectro-kernel answers questions about *one* spectrum. Findakin answers questions about a
|
|
4
|
+
*population* of spectra: which look alike, what structure they form, which stand out, which
|
|
5
|
+
moved in time, and which representation (encoder) is the most physical.
|
|
6
|
+
|
|
7
|
+
Everything lives in a local LanceDB store, versioned and rebuildable from your files.
|
|
8
|
+
|
|
9
|
+
from findakin import Store
|
|
10
|
+
st = Store.open("~/findakin/store")
|
|
11
|
+
st.ingest_folder("~/spectra/alphadra", source="staros")
|
|
12
|
+
enc = st.embed(strategy="dct", dim=256)
|
|
13
|
+
st.neighbours("~/spectra/new.fits", k=10, encoder=enc)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from findakin.store import IngestReport, Store
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
__all__ = ["Store", "IngestReport", "__version__"]
|
findakin/align.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Inter-instrument wavelength (λ) alignment — the project's common-grid layer.
|
|
2
|
+
|
|
3
|
+
Why this module exists
|
|
4
|
+
----------------------
|
|
5
|
+
Findakin compares spectra by embedding them into a shared vector space. But two
|
|
6
|
+
spectrographs sample λ very differently: an Alpy600 (R~600) and a LHIRES III
|
|
7
|
+
(R~10000) produce spectra with different pixel grids, different coverage, and
|
|
8
|
+
*wildly* different resolution. Embedding each one "as is" mixes physical content
|
|
9
|
+
with instrument signature — the resulting distances are not comparable.
|
|
10
|
+
|
|
11
|
+
This module resamples every spectrum of a project onto a **single common λ grid**
|
|
12
|
+
(same sampling, same coverage, homogenised resolution) *before* the embedding is
|
|
13
|
+
computed, so that all embeddings in a project live in one comparable space, even
|
|
14
|
+
across instruments.
|
|
15
|
+
|
|
16
|
+
The pipeline (``align_to_grid``) does, in this exact order:
|
|
17
|
+
|
|
18
|
+
1. **Resolution matching** (optional, degrade-only). You can only *lower* resolution,
|
|
19
|
+
never invent it. If a target FWHM is set and the native spectrum is sharper than
|
|
20
|
+
the target, the native flux is convolved by the Gaussian kernel that brings it down
|
|
21
|
+
to the target FWHM. If the native spectrum is already coarser (or a FWHM is missing),
|
|
22
|
+
nothing is convolved.
|
|
23
|
+
2. **Resampling** onto the common grid by linear interpolation, with NaN outside the
|
|
24
|
+
native coverage.
|
|
25
|
+
3. **Coverage** = fraction of grid points that fall inside the native wavelength range.
|
|
26
|
+
|
|
27
|
+
Units
|
|
28
|
+
-----
|
|
29
|
+
Findakin's FITS parser yields wavelengths in **Angstrom** (``wavelength_angstrom``),
|
|
30
|
+
while ``CommonGrid`` is expressed in **nanometres** (the brief's convention, and the
|
|
31
|
+
natural unit for log-λ spectroscopy). ``align_to_grid`` therefore accepts a
|
|
32
|
+
``wavelength_scale_to_nm`` factor: pass ``0.1`` to convert Å→nm (1 nm = 10 Å).
|
|
33
|
+
The default is ``1.0`` (input already in nm). The caller is responsible for telling
|
|
34
|
+
``align`` what unit its wavelength array is in — see ``app.core.embedding`` for the
|
|
35
|
+
wiring (it passes ``0.1`` because findakin stores Angstrom).
|
|
36
|
+
|
|
37
|
+
Resampling choice
|
|
38
|
+
-----------------
|
|
39
|
+
We use **linear interpolation** (``np.interp``). It is robust, fast, monotonic, and
|
|
40
|
+
introduces no ringing. It is *not* strictly flux-conserving (an integrating resampler
|
|
41
|
+
would be), but for embedding/similarity — where the flux is normalised downstream
|
|
42
|
+
anyway — linear interpolation onto a fixed grid is the standard, well-behaved choice.
|
|
43
|
+
This is documented here so the tradeoff is explicit.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import hashlib
|
|
49
|
+
from typing import Literal
|
|
50
|
+
|
|
51
|
+
import numpy as np
|
|
52
|
+
from pydantic import BaseModel, Field
|
|
53
|
+
|
|
54
|
+
# FWHM = 2*sqrt(2*ln2) * sigma ≈ 2.3548 * sigma (Gaussian).
|
|
55
|
+
_FWHM_TO_SIGMA = 2.0 * np.sqrt(2.0 * np.log(2.0)) # ≈ 2.3548
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class CommonGrid(BaseModel):
|
|
59
|
+
"""A project-wide common wavelength grid (in nanometres).
|
|
60
|
+
|
|
61
|
+
Two spectra aligned to the *same* ``CommonGrid`` share the same output axis,
|
|
62
|
+
coverage window, and (when ``target_fwhm_nm`` is set) resolution ceiling — which
|
|
63
|
+
is exactly what makes their embeddings comparable across instruments.
|
|
64
|
+
|
|
65
|
+
Attributes
|
|
66
|
+
----------
|
|
67
|
+
lambda_min_nm, lambda_max_nm:
|
|
68
|
+
Grid bounds in nm.
|
|
69
|
+
n_points:
|
|
70
|
+
Number of grid samples.
|
|
71
|
+
spacing:
|
|
72
|
+
``"log"`` (geometric, recommended in spectroscopy: constant Δλ/λ ⇔ constant
|
|
73
|
+
velocity step) or ``"linear"``.
|
|
74
|
+
target_fwhm_nm:
|
|
75
|
+
Optional resolution ceiling. If set, spectra sharper than this are degraded to
|
|
76
|
+
it before resampling; spectra already coarser are left untouched.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
lambda_min_nm: float = Field(..., gt=0)
|
|
80
|
+
lambda_max_nm: float = Field(..., gt=0)
|
|
81
|
+
n_points: int = Field(..., gt=1)
|
|
82
|
+
spacing: Literal["linear", "log"] = "log"
|
|
83
|
+
target_fwhm_nm: float | None = Field(default=None, gt=0)
|
|
84
|
+
|
|
85
|
+
def hash(self) -> str:
|
|
86
|
+
"""Short stable fingerprint of the grid, for the embedding signature.
|
|
87
|
+
|
|
88
|
+
Identical grids → identical hash; any change to bounds / n_points / spacing /
|
|
89
|
+
target FWHM changes it. Used so that two projects with *different* common grids
|
|
90
|
+
get different embedding signatures and are therefore refused for comparison."""
|
|
91
|
+
payload = (
|
|
92
|
+
f"{self.lambda_min_nm:.6g}:{self.lambda_max_nm:.6g}:"
|
|
93
|
+
f"{self.n_points}:{self.spacing}:"
|
|
94
|
+
f"{'' if self.target_fwhm_nm is None else format(self.target_fwhm_nm, '.6g')}"
|
|
95
|
+
)
|
|
96
|
+
return hashlib.sha256(payload.encode()).hexdigest()[:12]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class AlignResult(BaseModel):
|
|
100
|
+
"""Outcome of aligning one spectrum onto a :class:`CommonGrid`.
|
|
101
|
+
|
|
102
|
+
Attributes
|
|
103
|
+
----------
|
|
104
|
+
flux:
|
|
105
|
+
Flux resampled onto the grid (same length as ``build_grid(grid)``). Points
|
|
106
|
+
outside the native coverage are ``NaN``.
|
|
107
|
+
coverage:
|
|
108
|
+
Fraction of grid points that fall inside the native wavelength range
|
|
109
|
+
(1.0 = the spectrum spans the whole grid).
|
|
110
|
+
resolution_matched:
|
|
111
|
+
``True`` iff a Gaussian degradation to the target FWHM was actually applied.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
115
|
+
|
|
116
|
+
flux: np.ndarray
|
|
117
|
+
coverage: float
|
|
118
|
+
resolution_matched: bool
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def build_grid(grid: CommonGrid) -> np.ndarray:
|
|
122
|
+
"""Return the common wavelength axis (nm) for ``grid``.
|
|
123
|
+
|
|
124
|
+
``linear`` → :func:`numpy.linspace`; ``log`` → :func:`numpy.geomspace`
|
|
125
|
+
(log-λ, recommended in spectroscopy)."""
|
|
126
|
+
if grid.spacing == "log":
|
|
127
|
+
return np.geomspace(grid.lambda_min_nm, grid.lambda_max_nm, grid.n_points)
|
|
128
|
+
return np.linspace(grid.lambda_min_nm, grid.lambda_max_nm, grid.n_points)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _gaussian_smooth(flux: np.ndarray, sigma_px: float) -> np.ndarray:
|
|
132
|
+
"""Convolve ``flux`` by a Gaussian of width ``sigma_px`` pixels.
|
|
133
|
+
|
|
134
|
+
Uses ``scipy.ndimage.gaussian_filter1d`` when scipy is available; otherwise falls
|
|
135
|
+
back to a normalised numpy kernel (``np.convolve`` with edge padding) so the module
|
|
136
|
+
has no hard scipy dependency."""
|
|
137
|
+
if sigma_px <= 0:
|
|
138
|
+
return flux
|
|
139
|
+
try:
|
|
140
|
+
from scipy.ndimage import gaussian_filter1d
|
|
141
|
+
|
|
142
|
+
return gaussian_filter1d(flux, sigma=sigma_px, mode="nearest")
|
|
143
|
+
except Exception: # pragma: no cover - exercised only without scipy
|
|
144
|
+
# Truncate the kernel at 4 sigma (matches scipy's default truncate=4.0).
|
|
145
|
+
radius = max(1, int(round(4.0 * sigma_px)))
|
|
146
|
+
x = np.arange(-radius, radius + 1, dtype=float)
|
|
147
|
+
kernel = np.exp(-0.5 * (x / sigma_px) ** 2)
|
|
148
|
+
kernel /= kernel.sum()
|
|
149
|
+
# Reflect-pad ("nearest"-like) to avoid edge darkening.
|
|
150
|
+
padded = np.pad(flux, radius, mode="edge")
|
|
151
|
+
return np.convolve(padded, kernel, mode="valid")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def align_to_grid(
|
|
155
|
+
wavelength: np.ndarray,
|
|
156
|
+
flux: np.ndarray,
|
|
157
|
+
grid: CommonGrid,
|
|
158
|
+
*,
|
|
159
|
+
native_fwhm_nm: float | None = None,
|
|
160
|
+
wavelength_scale_to_nm: float = 1.0,
|
|
161
|
+
) -> AlignResult:
|
|
162
|
+
"""Align one spectrum onto the project common grid.
|
|
163
|
+
|
|
164
|
+
Parameters
|
|
165
|
+
----------
|
|
166
|
+
wavelength, flux:
|
|
167
|
+
Native spectrum. ``wavelength`` may be decreasing (it is sorted internally) and
|
|
168
|
+
is interpreted in nm after multiplying by ``wavelength_scale_to_nm`` (pass
|
|
169
|
+
``0.1`` for Angstrom input).
|
|
170
|
+
grid:
|
|
171
|
+
Target :class:`CommonGrid`.
|
|
172
|
+
native_fwhm_nm:
|
|
173
|
+
Native spectral resolution as a FWHM in nm. Only used for resolution matching;
|
|
174
|
+
if ``None``, the resolution step is skipped.
|
|
175
|
+
wavelength_scale_to_nm:
|
|
176
|
+
Multiplicative factor converting the input wavelength unit to nm
|
|
177
|
+
(1.0 = already nm; 0.1 = Angstrom→nm).
|
|
178
|
+
|
|
179
|
+
Returns
|
|
180
|
+
-------
|
|
181
|
+
AlignResult
|
|
182
|
+
"""
|
|
183
|
+
wl = np.asarray(wavelength, dtype=float).ravel() * float(wavelength_scale_to_nm)
|
|
184
|
+
fl = np.asarray(flux, dtype=float).ravel()
|
|
185
|
+
|
|
186
|
+
target_axis = build_grid(grid)
|
|
187
|
+
|
|
188
|
+
# Degenerate inputs: nothing usable → all-NaN flux, zero coverage.
|
|
189
|
+
n = min(wl.size, fl.size)
|
|
190
|
+
if n == 0:
|
|
191
|
+
return AlignResult(
|
|
192
|
+
flux=np.full(grid.n_points, np.nan), coverage=0.0, resolution_matched=False
|
|
193
|
+
)
|
|
194
|
+
wl, fl = wl[:n], fl[:n]
|
|
195
|
+
|
|
196
|
+
finite = np.isfinite(wl) & np.isfinite(fl)
|
|
197
|
+
if not finite.any():
|
|
198
|
+
# All-NaN flux (or wavelength): return an all-NaN grid, no coverage.
|
|
199
|
+
return AlignResult(
|
|
200
|
+
flux=np.full(grid.n_points, np.nan), coverage=0.0, resolution_matched=False
|
|
201
|
+
)
|
|
202
|
+
wl, fl = wl[finite], fl[finite]
|
|
203
|
+
|
|
204
|
+
# Wavelengths may be decreasing (e.g. some echelle/IRAF products) — sort ascending.
|
|
205
|
+
order = np.argsort(wl)
|
|
206
|
+
wl, fl = wl[order], fl[order]
|
|
207
|
+
|
|
208
|
+
# --- Step 1: resolution matching (degrade-only) ---------------------------------
|
|
209
|
+
resolution_matched = False
|
|
210
|
+
if grid.target_fwhm_nm is not None and native_fwhm_nm is not None and wl.size >= 2:
|
|
211
|
+
# We can only DEGRADE: convolve only if the native spectrum is *sharper*
|
|
212
|
+
# (smaller FWHM) than the target. Upgrading resolution is impossible.
|
|
213
|
+
if native_fwhm_nm < grid.target_fwhm_nm:
|
|
214
|
+
# FWHM add in quadrature: the kernel that takes native→target has
|
|
215
|
+
# FWHM_kernel = sqrt(target^2 - native^2); convert to sigma in nm, then px.
|
|
216
|
+
sigma_nm = np.sqrt(grid.target_fwhm_nm**2 - native_fwhm_nm**2) / _FWHM_TO_SIGMA
|
|
217
|
+
d_lambda = float(np.median(np.diff(wl))) # native median pixel step (nm)
|
|
218
|
+
if d_lambda > 0 and sigma_nm > 0:
|
|
219
|
+
sigma_px = sigma_nm / d_lambda
|
|
220
|
+
fl = _gaussian_smooth(fl, sigma_px)
|
|
221
|
+
resolution_matched = True
|
|
222
|
+
# else: native is already coarser/equal → cannot upgrade → leave flux untouched.
|
|
223
|
+
|
|
224
|
+
# --- Step 2: resample onto the common grid (linear interp, NaN outside) ---------
|
|
225
|
+
# np.interp needs strictly increasing x; collapse exact duplicate wavelengths.
|
|
226
|
+
if wl.size >= 2:
|
|
227
|
+
uniq = np.concatenate(([True], np.diff(wl) > 0))
|
|
228
|
+
if not uniq.all():
|
|
229
|
+
wl, fl = wl[uniq], fl[uniq]
|
|
230
|
+
|
|
231
|
+
resampled = np.interp(target_axis, wl, fl, left=np.nan, right=np.nan)
|
|
232
|
+
|
|
233
|
+
# --- Step 3: coverage = fraction of grid points inside the native range ---------
|
|
234
|
+
native_lo, native_hi = float(wl.min()), float(wl.max())
|
|
235
|
+
in_cov = (target_axis >= native_lo) & (target_axis <= native_hi)
|
|
236
|
+
coverage = float(np.count_nonzero(in_cov) / target_axis.size)
|
|
237
|
+
|
|
238
|
+
return AlignResult(flux=resampled, coverage=coverage, resolution_matched=resolution_matched)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def suggest_grid(
|
|
242
|
+
spectra: list[tuple[np.ndarray, np.ndarray]],
|
|
243
|
+
*,
|
|
244
|
+
spacing: Literal["linear", "log"] = "log",
|
|
245
|
+
wavelength_scale_to_nm: float = 1.0,
|
|
246
|
+
native_fwhm_nm: list[float | None] | None = None,
|
|
247
|
+
) -> CommonGrid:
|
|
248
|
+
"""Propose a :class:`CommonGrid` from a sample of spectra.
|
|
249
|
+
|
|
250
|
+
Heuristic (per the brief):
|
|
251
|
+
- bounds = **intersection** of native coverages (so every spectrum spans the grid);
|
|
252
|
+
falls back to the union if the intersection is empty.
|
|
253
|
+
- ``n_points`` = median native point count.
|
|
254
|
+
- ``target_fwhm_nm`` = median native FWHM (if provided).
|
|
255
|
+
|
|
256
|
+
``spectra`` is a list of ``(wavelength, flux)`` arrays in the same unit as
|
|
257
|
+
``wavelength_scale_to_nm`` describes.
|
|
258
|
+
"""
|
|
259
|
+
if not spectra:
|
|
260
|
+
raise ValueError("suggest_grid needs at least one spectrum")
|
|
261
|
+
|
|
262
|
+
mins, maxs, counts = [], [], []
|
|
263
|
+
for wl, _fl in spectra:
|
|
264
|
+
w = np.asarray(wl, dtype=float).ravel() * float(wavelength_scale_to_nm)
|
|
265
|
+
w = w[np.isfinite(w)]
|
|
266
|
+
if w.size == 0:
|
|
267
|
+
continue
|
|
268
|
+
mins.append(float(w.min()))
|
|
269
|
+
maxs.append(float(w.max()))
|
|
270
|
+
counts.append(int(w.size))
|
|
271
|
+
|
|
272
|
+
if not counts:
|
|
273
|
+
raise ValueError("suggest_grid: no spectrum had a usable wavelength axis")
|
|
274
|
+
|
|
275
|
+
lo_int, hi_int = max(mins), min(maxs) # intersection
|
|
276
|
+
if hi_int <= lo_int: # no common overlap → fall back to the union
|
|
277
|
+
lo_int, hi_int = min(mins), max(maxs)
|
|
278
|
+
|
|
279
|
+
n_points = int(np.median(counts))
|
|
280
|
+
target_fwhm = None
|
|
281
|
+
if native_fwhm_nm:
|
|
282
|
+
valid = [f for f in native_fwhm_nm if f is not None and f > 0]
|
|
283
|
+
if valid:
|
|
284
|
+
target_fwhm = float(np.median(valid))
|
|
285
|
+
|
|
286
|
+
return CommonGrid(
|
|
287
|
+
lambda_min_nm=lo_int,
|
|
288
|
+
lambda_max_nm=hi_int,
|
|
289
|
+
n_points=max(2, n_points),
|
|
290
|
+
spacing=spacing,
|
|
291
|
+
target_fwhm_nm=target_fwhm,
|
|
292
|
+
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Collection-level analysis over embedding vectors.
|
|
2
|
+
|
|
3
|
+
Pure functions operating on a 2D array of embeddings ``X`` (as returned by
|
|
4
|
+
``Store.vectors``), no persistent state:
|
|
5
|
+
|
|
6
|
+
- Clustering : K-Means / HDBSCAN / GMM, with optimal-k search
|
|
7
|
+
- Anomaly detection : Isolation Forest / LOF / distance-based
|
|
8
|
+
- Projection : PCA / t-SNE / UMAP for 2D-3D visualisation
|
|
9
|
+
|
|
10
|
+
Originally ported from the ``embspec`` prototype. Plotting helpers are not part of
|
|
11
|
+
this package (phase 2 adds Plotly notebook helpers on top of these results).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .anomaly import AnomalyResult, detect_anomalies
|
|
15
|
+
from .clustering import (
|
|
16
|
+
ClusteringResult,
|
|
17
|
+
OptimalKResult,
|
|
18
|
+
cluster_embeddings,
|
|
19
|
+
find_optimal_k,
|
|
20
|
+
)
|
|
21
|
+
from .projection import (
|
|
22
|
+
ProjectionResult,
|
|
23
|
+
extract_color_values,
|
|
24
|
+
project_embeddings,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"AnomalyResult",
|
|
29
|
+
"detect_anomalies",
|
|
30
|
+
"ClusteringResult",
|
|
31
|
+
"OptimalKResult",
|
|
32
|
+
"cluster_embeddings",
|
|
33
|
+
"find_optimal_k",
|
|
34
|
+
"ProjectionResult",
|
|
35
|
+
"extract_color_values",
|
|
36
|
+
"project_embeddings",
|
|
37
|
+
]
|