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 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
+ ]