stdpipe 0.4.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.
stdpipe/lcs.py ADDED
@@ -0,0 +1,440 @@
1
+ import sys
2
+ import numpy as np
3
+ from astropy.stats import mad_std
4
+ from astropy.time import Time
5
+
6
+ from scipy.spatial import cKDTree
7
+
8
+ from . import astrometry
9
+
10
+
11
+ # Fast-conversion registry for types that cannot be stored directly as
12
+ # ndarray chunks (e.g. astropy.time.Time).
13
+ #
14
+ # Each handler is a callable ``handler(val) -> (storage_values, finalizer)``:
15
+ # * ``storage_values`` is an ndarray (or scalar) of plain numbers
16
+ # (typically float64) that is stored in place of the original value.
17
+ # * ``finalizer`` is called once during column consolidation as
18
+ # ``finalizer(ndarray) -> typed_object`` to rebuild the original
19
+ # container from the accumulated storage values.
20
+ #
21
+ # To add support for another such type, append a (predicate, handler)
22
+ # tuple to ``_FAST_TYPE_HANDLERS``.
23
+
24
+
25
+ def _time_handler(val):
26
+ """Round-trip Time through MJD floats (~150 ms for 3.5 M rows)."""
27
+ scale, fmt = val.scale, val.format
28
+
29
+ def finalize(arr):
30
+ # Rows padded with NaN (missing values) become masked Time entries,
31
+ # as Time rejects non-finite plain doubles
32
+ if np.any(np.isnan(arr)):
33
+ arr = np.ma.masked_invalid(arr)
34
+ out = Time(arr, format='mjd', scale=scale)
35
+ if fmt != 'mjd':
36
+ out.format = fmt
37
+ return out
38
+
39
+ return val.mjd, finalize
40
+
41
+
42
+ _FAST_TYPE_HANDLERS = [
43
+ (lambda v: isinstance(v, Time), _time_handler),
44
+ ]
45
+
46
+
47
+ def _fast_storage_handler(val):
48
+ for predicate, handler in _FAST_TYPE_HANDLERS:
49
+ if predicate(val):
50
+ return handler
51
+ return None
52
+
53
+
54
+ def _vector_length(val):
55
+ """Length of a vector-like value, or None for scalars, strings and None."""
56
+ if val is None or isinstance(val, str):
57
+ return None
58
+ try:
59
+ return len(val)
60
+ except TypeError:
61
+ # Objects like scalar Time define __len__ but raise on scalars
62
+ return None
63
+
64
+
65
+ class _PadChunk:
66
+ """
67
+ Placeholder for rows without a value for a given key.
68
+
69
+ The actual fill value and dtype are decided lazily during column
70
+ consolidation, once the dtype of the real chunks is known: NaN for
71
+ floating-point columns (including fast-converted types like Time),
72
+ None (object dtype) otherwise.
73
+ """
74
+
75
+ __slots__ = ('n',)
76
+
77
+ def __init__(self, n):
78
+ self.n = n
79
+
80
+ def __len__(self):
81
+ return self.n
82
+
83
+
84
+ class LCs:
85
+ """
86
+ Container for light-curve data vectors with spatial clustering utilities.
87
+
88
+ Stores user-provided per-detection vectors (e.g., ra/dec/flux/time) and
89
+ groups detections into spatial clusters using a KDTree radius search.
90
+ Clustering returns per-cluster centroids and member indices in `self.lcs`.
91
+
92
+ Notes
93
+ -----
94
+ - `add()` broadcasts scalars to the length of the vector inputs, which
95
+ must all share the same length. Keys omitted from a call, and rows
96
+ preceding the first appearance of a new key, are padded so that all
97
+ stored vectors stay aligned: with NaN for floating-point columns
98
+ (missing Time entries become masked), with None (object dtype)
99
+ otherwise.
100
+ - Data vectors are stored as per-key lists of ndarray chunks and
101
+ consolidated lazily into single arrays on first attribute access.
102
+ - `cluster()` refines centroids and can call an `analyze(self, ids)` callback
103
+ per cluster.
104
+ - Coordinate jitter is applied when building the KDTree to avoid degeneracy
105
+ from repeated positions.
106
+ - Clustering results are stored in `self.lcs` with keys:
107
+ - `x`, `y`, `z`: centroid unit-vector coordinates.
108
+ - `ra`, `dec`: centroid sky coordinates in degrees.
109
+ - `N`: number of points per cluster.
110
+ - `ids`: list of index arrays for member points in the container.
111
+ - `kd`: KDTree built from centroid vectors for fast queries.
112
+ """
113
+
114
+ def __init__(self):
115
+ # Storage for user-supplied data vectors: key -> list of ndarray chunks
116
+ self._params = {}
117
+ # Cache of consolidated (concatenated and finalized) columns
118
+ self._columns = {}
119
+ # Per-key finalizer callables for keys whose values were stored via
120
+ # the fast-conversion path (see ``_FAST_TYPE_HANDLERS``). Applied
121
+ # during consolidation to restore the original type.
122
+ self._finalizers = {}
123
+ # Total number of stored rows
124
+ self._length = 0
125
+ # Data version, and the (version, col_ra, col_dec) the KDTree was built from
126
+ self._version = 0
127
+ self._kd_key = None
128
+
129
+ self.lcs = None
130
+ self.kd = None
131
+
132
+ def __getattr__(self, name):
133
+ # Allows direct access to stored data vectors. Underscored names never
134
+ # resolve here, so the `self._params` access below cannot recurse when
135
+ # the instance dict is not yet populated (e.g. during unpickling).
136
+ if name.startswith('_'):
137
+ raise AttributeError(name)
138
+
139
+ if name in self._params:
140
+ return self._get_column(name)
141
+
142
+ raise AttributeError(
143
+ "'%s' object has no attribute '%s'" % (self.__class__.__name__, name)
144
+ )
145
+
146
+ def __dir__(self):
147
+ # For auto-completion of stored data vectors names
148
+ return (
149
+ list(self.__dict__.keys())
150
+ + list(self.__class__.__dict__.keys())
151
+ + list(self._params.keys())
152
+ )
153
+
154
+ def _materialize_pads(self, name, chunks):
155
+ """Convert _PadChunk placeholders to arrays of NaN or None."""
156
+ real = [_ for _ in chunks if not isinstance(_, _PadChunk)]
157
+ dtype = np.result_type(*real) if real else None
158
+
159
+ if (dtype is not None and dtype.kind in 'fc') or (
160
+ dtype is None and name in self._finalizers
161
+ ):
162
+ # Floating-point columns (including fast-converted types whose
163
+ # storage is numeric) can hold NaN without dtype degradation
164
+ fill = np.nan
165
+ fill_dtype = dtype if dtype is not None else np.float64
166
+ else:
167
+ fill = None
168
+ fill_dtype = object
169
+
170
+ return [
171
+ np.full(len(_), fill, dtype=fill_dtype) if isinstance(_, _PadChunk) else _
172
+ for _ in chunks
173
+ ]
174
+
175
+ def _get_column(self, name):
176
+ """Return the consolidated (concatenated and finalized) vector for a key."""
177
+ if name not in self._columns:
178
+ chunks = self._params[name]
179
+
180
+ if any(isinstance(_, _PadChunk) for _ in chunks):
181
+ chunks = self._materialize_pads(name, chunks)
182
+ self._params[name] = chunks
183
+
184
+ if not chunks:
185
+ arr = np.array([])
186
+ elif len(chunks) == 1:
187
+ arr = np.asanyarray(chunks[0])
188
+ else:
189
+ arr = np.concatenate(chunks)
190
+ # Keep the single consolidated chunk so repeated access is cheap
191
+ self._params[name] = [arr]
192
+
193
+ finalizer = self._finalizers.get(name)
194
+ if finalizer is not None:
195
+ arr = finalizer(arr)
196
+
197
+ self._columns[name] = arr
198
+
199
+ return self._columns[name]
200
+
201
+ def add(self, **kwargs):
202
+ """
203
+ Add per-detection vectors to the container.
204
+
205
+ Each keyword defines a stored vector. Scalars are broadcast to the
206
+ length of the vector inputs, which must all share the same length
207
+ (ValueError is raised otherwise). This method may be called repeatedly
208
+ to append new chunks of measurements (e.g., per-image batches) to the
209
+ existing vectors. Previously stored keys omitted from a call, as well
210
+ as rows preceding the first appearance of a new key, are padded so
211
+ that all stored vectors stay aligned - with NaN for floating-point
212
+ columns (missing Time entries become masked), with None otherwise.
213
+
214
+ Examples
215
+ --------
216
+ >>> lcs = LCs()
217
+ >>> lcs.add(ra=[1, 2], dec=[3, 4], flux=10.0)
218
+ """
219
+
220
+ # All vector inputs must agree on the chunk length
221
+ vec_lengths = {}
222
+ for key, val in kwargs.items():
223
+ n = _vector_length(val)
224
+ if n is not None:
225
+ vec_lengths[key] = n
226
+
227
+ if len(set(vec_lengths.values())) > 1:
228
+ raise ValueError(
229
+ 'Mismatched vector lengths in add(): '
230
+ + ', '.join('%s has %d' % _ for _ in vec_lengths.items())
231
+ )
232
+
233
+ length = next(iter(vec_lengths.values()), 0)
234
+
235
+ for key in set(kwargs) | set(self._params):
236
+ if key not in self._params:
237
+ # New key: backfill rows stored before it first appeared
238
+ self._params[key] = (
239
+ [_PadChunk(self._length)] if self._length else []
240
+ )
241
+
242
+ val = kwargs.get(key)
243
+
244
+ # Types like astropy.time.Time cannot be stored as plain ndarray
245
+ # chunks; convert them to numeric storage values and remember the
246
+ # finalizer that restores the original type during consolidation
247
+ handler = _fast_storage_handler(val)
248
+ if handler is not None:
249
+ val, finalizer = handler(val)
250
+ self._finalizers.setdefault(key, finalizer)
251
+
252
+ if _vector_length(val) is not None:
253
+ chunk = np.asanyarray(val)
254
+ elif val is not None:
255
+ # Broadcast scalar to the common vector length
256
+ chunk = np.full(length, val)
257
+ else:
258
+ # Key omitted from this call, or explicit None
259
+ chunk = _PadChunk(length)
260
+
261
+ if len(chunk):
262
+ self._params[key].append(chunk)
263
+
264
+ self._length += length
265
+ # Invalidate consolidated columns and KDTree built from previous data
266
+ self._columns = {}
267
+ self._version += 1
268
+
269
+ def cluster(
270
+ self,
271
+ sr=1 / 3600,
272
+ min_length=None,
273
+ col_ra='ra',
274
+ col_dec='dec',
275
+ verbose=True,
276
+ analyze=None,
277
+ N=1000,
278
+ max_refine_iter=1,
279
+ rng=0,
280
+ ):
281
+ """
282
+ Spatially cluster the data vectors using ra/dec values stored in `col_ra` and `col_dec`.
283
+
284
+ Parameters
285
+ ----------
286
+ sr : float, optional
287
+ Clustering radius in degrees.
288
+ min_length : int or None, optional
289
+ Minimum number of points required to keep a cluster.
290
+ col_ra : str, optional
291
+ Name of the RA column in stored vectors.
292
+ col_dec : str, optional
293
+ Name of the Dec column in stored vectors.
294
+ verbose : bool or callable, optional
295
+ Logging control, can be a print-like function.
296
+ analyze : callable or None, optional
297
+ Optional callback `analyze(self, ids)` called per accepted cluster.
298
+ Any returned mapping entries are appended into `self.lcs` under their
299
+ respective keys (one entry per cluster). The callback may also
300
+ return None to skip reporting for a given cluster.
301
+ N : int, optional
302
+ Progress update interval in points.
303
+ max_refine_iter : int, optional
304
+ Maximum number of centroid refinement iterations (default 1).
305
+ rng : int, numpy.random.Generator, or None, optional
306
+ Seed or generator for the coordinate jitter used to break KDTree
307
+ degeneracies from repeated positions. The default fixed seed makes
308
+ clustering deterministic; pass None for non-deterministic jitter.
309
+
310
+ Notes
311
+ -----
312
+ Clustering is greedy in storage order: every not-yet-masked point seeds
313
+ a radius search, and all points within the refined cluster radius are
314
+ excluded from seeding afterwards. Such points may still be claimed as
315
+ members of later clusters, so nearby clusters can share points. For
316
+ point distributions wider than `sr`, the exact set of clusters may
317
+ depend on the ordering of the stored points.
318
+ """
319
+
320
+ log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
321
+
322
+ if min_length is None:
323
+ min_length = 0
324
+
325
+ # Clustering radius as 3-D chord length corresponding to the angular radius
326
+ sr0 = 2 * np.sin(np.deg2rad(sr) / 2)
327
+
328
+ for col in (col_ra, col_dec):
329
+ if col not in self._params:
330
+ raise KeyError("Column '%s' is not stored in the container" % col)
331
+
332
+ kd_key = (self._version, col_ra, col_dec)
333
+ if self._kd_key != kd_key:
334
+ log('Building positional KDTree')
335
+
336
+ self._xarr, self._yarr, self._zarr = astrometry.radectoxyz(
337
+ self._get_column(col_ra), self._get_column(col_dec)
338
+ )
339
+ # Add some additional jitter to coordinates, or KDTree may hang on repeating positions
340
+ gen = np.random.default_rng(rng)
341
+ self._xarr = gen.normal(self._xarr, 0.01 / 206265)
342
+ self._yarr = gen.normal(self._yarr, 0.01 / 206265)
343
+ self._zarr = gen.normal(self._zarr, 0.01 / 206265)
344
+ self.kd = cKDTree(np.array([self._xarr, self._yarr, self._zarr]).T)
345
+ self._kd_key = kd_key
346
+
347
+ def refine_pos(x, y, z):
348
+ """Returns mean position for a list of individual positions"""
349
+ x1, y1, z1 = [np.mean(_) for _ in [x, y, z]]
350
+
351
+ # Normalize back to unit sphere
352
+ r = np.sqrt(x1 * x1 + y1 * y1 + z1 * z1)
353
+ x1, y1, z1 = [_ / r for _ in [x1, y1, z1]]
354
+
355
+ return x1, y1, z1
356
+
357
+ xarr = self._xarr
358
+ yarr = self._yarr
359
+ zarr = self._zarr
360
+ kd = self.kd
361
+
362
+ vmask = np.zeros(len(xarr), bool)
363
+
364
+ self.lcs = {'x': [], 'y': [], 'z': [], 'N': [], 'ids': []}
365
+ lcs_x = self.lcs['x']
366
+ lcs_y = self.lcs['y']
367
+ lcs_z = self.lcs['z']
368
+ lcs_n = self.lcs['N']
369
+ lcs_ids = self.lcs['ids']
370
+
371
+ log(
372
+ 'Starting spatial clustering of %d points with %.1f arcsec radius'
373
+ % (len(vmask), sr * 3600)
374
+ )
375
+
376
+ def process_seed(i):
377
+ # Select points around seed position
378
+ ids = kd.query_ball_point([xarr[i], yarr[i], zarr[i]], sr0)
379
+
380
+ if len(ids) < min_length:
381
+ vmask[ids] = True
382
+ return
383
+
384
+ x1, y1, z1 = refine_pos(xarr[ids], yarr[ids], zarr[ids])
385
+ ids = kd.query_ball_point([x1, y1, z1], sr0)
386
+
387
+ for _ in range(max_refine_iter - 1):
388
+ x2, y2, z2 = refine_pos(xarr[ids], yarr[ids], zarr[ids])
389
+ ids2 = kd.query_ball_point([x2, y2, z2], sr0)
390
+ if set(ids2) == set(ids):
391
+ x1, y1, z1 = x2, y2, z2
392
+ break
393
+ ids = ids2
394
+ x1, y1, z1 = x2, y2, z2
395
+
396
+ vmask[ids] = True # Mask all points around mean position
397
+
398
+ if len(ids) >= min_length:
399
+ # Actual processing of points
400
+ lcs_x.append(x1)
401
+ lcs_y.append(y1)
402
+ lcs_z.append(z1)
403
+ lcs_n.append(len(ids))
404
+ lcs_ids.append(ids)
405
+
406
+ if analyze is not None and callable(analyze):
407
+ ares = analyze(self, ids)
408
+
409
+ if ares:
410
+ for _, __ in ares.items():
411
+ if _ not in self.lcs:
412
+ self.lcs[_] = []
413
+ self.lcs[_].append(__)
414
+
415
+ for i in range(len(vmask)):
416
+ if not vmask[i]:
417
+ process_seed(i)
418
+
419
+ if i % N == 0:
420
+ if verbose is True:
421
+ # Interactive in-place progress for plain print mode
422
+ sys.stdout.write("\r %d points - %d lcs" % (i, len(self.lcs['x'])))
423
+ sys.stdout.flush()
424
+ elif callable(verbose):
425
+ log("%d points - %d lcs" % (i, len(self.lcs['x'])))
426
+
427
+ if verbose is True:
428
+ sys.stdout.write("\n")
429
+ sys.stdout.flush()
430
+
431
+ for _ in self.lcs.keys():
432
+ if isinstance(self.lcs[_], list) and _ not in ['ids']:
433
+ self.lcs[_] = np.array(self.lcs[_])
434
+
435
+ self.lcs['ra'], self.lcs['dec'] = astrometry.xyztoradec(
436
+ [self.lcs['x'], self.lcs['y'], self.lcs['z']]
437
+ )
438
+ self.lcs['kd'] = cKDTree(np.array([self.lcs['x'], self.lcs['y'], self.lcs['z']]).T)
439
+
440
+ log('%d spatial clusters isolated' % len(self.lcs['ra']))