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.
@@ -0,0 +1,931 @@
1
+ """
2
+ Photometric modeling routines.
3
+
4
+ This module contains functions for photometric calibration and modeling,
5
+ including catalog matching with spatial zero-point models, S/N modeling,
6
+ and detection limit estimation.
7
+ """
8
+
9
+ import warnings
10
+
11
+ import numpy as np
12
+ import statsmodels.api as sm
13
+ from scipy import linalg
14
+ from scipy.optimize import minimize_scalar, least_squares, root_scalar
15
+ from statsmodels.regression import _tools as reg_tools
16
+ from statsmodels.robust import robust_linear_model as rlm_model
17
+ from statsmodels.tools.sm_exceptions import ConvergenceWarning
18
+
19
+ from . import astrometry
20
+
21
+
22
+ def make_series(mul=1.0, x=1.0, y=1.0, order=1, sum=False, zero=True):
23
+ x = np.atleast_1d(x)
24
+ y = np.atleast_1d(y)
25
+
26
+ if zero:
27
+ res = [np.ones_like(x) * mul]
28
+ else:
29
+ res = []
30
+
31
+ for i in range(1, order + 1):
32
+ maxr = i + 1
33
+
34
+ for j in range(maxr):
35
+ res.append(mul * x ** (i - j) * y**j)
36
+ if sum:
37
+ return np.sum(res, axis=0)
38
+ else:
39
+ return res
40
+
41
+
42
+ def get_intrinsic_scatter(y, yerr, min=0, max=None):
43
+ y = np.asarray(y, dtype=float)
44
+ yerr = np.asarray(yerr, dtype=float)
45
+
46
+ good = np.isfinite(y) & np.isfinite(yerr) & (yerr >= 0)
47
+ if np.sum(good) < 2:
48
+ return float(np.maximum(min, 0.0))
49
+
50
+ y = y[good]
51
+ yerr = yerr[good]
52
+
53
+ c_min = float(np.maximum(min, 0.0))
54
+ if max is None or not np.isfinite(max):
55
+ c_max = float(np.max([np.nanstd(y), np.nanmedian(yerr), c_min + 1e-6]))
56
+ else:
57
+ c_max = float(np.maximum(max, c_min))
58
+
59
+ if c_max <= c_min + 1e-12:
60
+ return c_min
61
+
62
+ eps = np.finfo(float).eps
63
+
64
+ def nll(c):
65
+ sigma2 = np.maximum(yerr**2 + c**2, eps)
66
+ weight = 1.0 / sigma2
67
+ model = np.sum(weight * y) / np.sum(weight)
68
+ return 0.5 * np.sum((y - model) ** 2 * weight + np.log(sigma2))
69
+
70
+ result = minimize_scalar(
71
+ nll,
72
+ bounds=(c_min, c_max),
73
+ method='bounded',
74
+ )
75
+
76
+ if not result.success or not np.isfinite(result.x):
77
+ return c_min
78
+
79
+ return float(np.clip(result.x, c_min, c_max))
80
+
81
+
82
+ def _normalize_spatial_coords(x, y):
83
+ x = np.asarray(x, dtype=float)
84
+ y = np.asarray(y, dtype=float)
85
+
86
+ finite = np.isfinite(x) & np.isfinite(y)
87
+ if not np.any(finite):
88
+ return x, y, 1.0
89
+
90
+ scale = max(float(np.nanmax(np.abs(x[finite]))), float(np.nanmax(np.abs(y[finite]))), 1.0)
91
+ return x / scale, y / scale, scale
92
+
93
+
94
+ def _prepare_parameter_covariance(cov):
95
+ cov = np.asarray(cov, dtype=float)
96
+
97
+ if cov.ndim != 2 or cov.shape[0] != cov.shape[1]:
98
+ raise ValueError("Parameter covariance must be a square matrix")
99
+
100
+ if cov.size == 0:
101
+ return cov
102
+
103
+ cov = np.where(np.isfinite(cov), cov, 0.0)
104
+ cov = 0.5 * (cov + cov.T)
105
+
106
+ try:
107
+ eigvals, eigvecs = np.linalg.eigh(cov)
108
+ except np.linalg.LinAlgError:
109
+ return np.diag(np.clip(np.diag(cov), 0.0, None))
110
+
111
+ eigvals = np.clip(eigvals, 0.0, None)
112
+ return (eigvecs * eigvals) @ eigvecs.T
113
+
114
+
115
+ def _evaluate_parameter_covariance(X, cov):
116
+ X = np.asarray(X, dtype=float)
117
+
118
+ if X.ndim == 1:
119
+ X = X[None, :]
120
+
121
+ if X.shape[0] == 0:
122
+ return np.array([], dtype=float)
123
+
124
+ err2 = np.full(X.shape[0], np.nan, dtype=float)
125
+ valid = np.all(np.isfinite(X), axis=1)
126
+
127
+ if not np.any(valid):
128
+ return np.sqrt(err2)
129
+
130
+ quad = np.einsum('ij,jk,ik->i', X[valid], cov, X[valid], optimize=True)
131
+ quad = np.where(np.isfinite(quad), np.maximum(quad, 0.0), np.nan)
132
+ err2[valid] = quad
133
+
134
+ return np.sqrt(err2)
135
+
136
+
137
+ def _stable_pinv_exog(exog):
138
+ exog = np.asarray(exog, dtype=float)
139
+
140
+ if exog.ndim != 2:
141
+ raise ValueError("Design matrix must be two-dimensional")
142
+
143
+ nobs, nparams = exog.shape
144
+ if nparams == 0:
145
+ return np.empty((0, nobs), dtype=float), np.empty((0, 0), dtype=float)
146
+
147
+ gram = exog.T @ exog
148
+ rank = np.linalg.matrix_rank(exog)
149
+
150
+ if rank == nparams:
151
+ q, r = np.linalg.qr(exog, mode='reduced')
152
+ pinv = np.linalg.solve(r, q.T)
153
+ norm_cov = np.linalg.solve(gram, np.eye(nparams, dtype=float))
154
+ else:
155
+ norm_cov = linalg.pinvh(gram)
156
+ pinv = norm_cov @ exog.T
157
+
158
+ norm_cov = 0.5 * (norm_cov + norm_cov.T)
159
+ return pinv, norm_cov
160
+
161
+
162
+ class _StableRLM(sm.RLM):
163
+ def _initialize(self):
164
+ self.pinv_wexog, self.normalized_cov_params = _stable_pinv_exog(self.exog)
165
+ self.df_resid = float(self.exog.shape[0] - np.linalg.matrix_rank(self.exog))
166
+ self.df_model = float(np.linalg.matrix_rank(self.exog) - 1)
167
+ self.nobs = float(self.endog.shape[0])
168
+
169
+ def fit(
170
+ self,
171
+ maxiter=50,
172
+ tol=1e-8,
173
+ scale_est='mad',
174
+ init=None,
175
+ cov='H1',
176
+ update_scale=True,
177
+ conv='dev',
178
+ start_params=None,
179
+ ):
180
+ if cov.upper() not in ["H1", "H2", "H3"]:
181
+ raise ValueError("Covariance matrix %s not understood" % cov)
182
+ else:
183
+ self.cov = cov.upper()
184
+
185
+ conv = conv.lower()
186
+ if conv not in ["weights", "coefs", "dev", "sresid"]:
187
+ raise ValueError("Convergence argument %s not understood" % conv)
188
+
189
+ self.scale_est = scale_est
190
+
191
+ if start_params is None:
192
+ wls_results = reg_tools._MinimalWLS(
193
+ self.endog,
194
+ self.exog,
195
+ weights=np.ones_like(self.endog),
196
+ check_weights=False,
197
+ ).fit(method='qr')
198
+ else:
199
+ start_params = np.asarray(start_params, dtype=np.double).squeeze()
200
+ if start_params.shape[0] != self.exog.shape[1] or start_params.ndim != 1:
201
+ raise ValueError(
202
+ 'start_params must by a 1-d array with {} values'.format(
203
+ self.exog.shape[1]
204
+ )
205
+ )
206
+ fake_wls = reg_tools._MinimalWLS(
207
+ self.endog,
208
+ self.exog,
209
+ weights=np.ones_like(self.endog),
210
+ check_weights=False,
211
+ )
212
+ wls_results = fake_wls.results(start_params)
213
+
214
+ if not init:
215
+ self.scale = self._estimate_scale(wls_results.resid)
216
+
217
+ history = dict(params=[np.inf], scale=[])
218
+ if conv == 'coefs':
219
+ criterion = history['params']
220
+ elif conv == 'dev':
221
+ history.update(dict(deviance=[np.inf]))
222
+ criterion = history['deviance']
223
+ elif conv == 'sresid':
224
+ history.update(dict(sresid=[np.inf]))
225
+ criterion = history['sresid']
226
+ else:
227
+ history.update(dict(weights=[np.inf]))
228
+ criterion = history['weights']
229
+
230
+ history = self._update_history(wls_results, history, conv)
231
+ iteration = 1
232
+ converged = 0
233
+ while not converged:
234
+ if self.scale == 0.0:
235
+ warnings.warn(
236
+ 'Estimated scale is 0.0 indicating that the most last iteration produced '
237
+ 'a perfect fit of the weighted data.',
238
+ ConvergenceWarning,
239
+ )
240
+ break
241
+
242
+ self.weights = self.M.weights(wls_results.resid / self.scale)
243
+ wls_results = reg_tools._MinimalWLS(
244
+ self.endog,
245
+ self.exog,
246
+ weights=self.weights,
247
+ check_weights=True,
248
+ ).fit(method='qr')
249
+
250
+ if update_scale is True:
251
+ self.scale = self._estimate_scale(wls_results.resid)
252
+
253
+ history = self._update_history(wls_results, history, conv)
254
+ iteration += 1
255
+ converged = rlm_model._check_convergence(criterion, iteration, tol, maxiter)
256
+
257
+ results = rlm_model.RLMResults(
258
+ self,
259
+ wls_results.params,
260
+ self.normalized_cov_params,
261
+ self.scale,
262
+ )
263
+
264
+ history['iteration'] = iteration
265
+ results.fit_history = history
266
+ results.fit_options = dict(
267
+ cov=cov.upper(),
268
+ scale_est=scale_est,
269
+ norm=self.M.__class__.__name__,
270
+ conv=conv,
271
+ )
272
+ return rlm_model.RLMResultsWrapper(results)
273
+
274
+
275
+ def format_color_term(color_term, name=None, color_name=None, fmt='.2f'):
276
+ result = []
277
+
278
+ if color_term is None:
279
+ return format(0.0, fmt)
280
+
281
+ if name is not None:
282
+ result += [name]
283
+
284
+ if isinstance(color_term, float) or isinstance(color_term, int):
285
+ # Scalar?..
286
+ color_term = [color_term]
287
+
288
+ # Here we assume it is a list
289
+ for i, val in enumerate(color_term):
290
+ if color_name is not None:
291
+ sign = '-' if val > 0 else '+' # Reverse signs!!!
292
+ sval = format(np.abs(val), fmt)
293
+ deg = '^%d' % (i + 1) if i > 0 else ''
294
+ result += [sign + ' ' + sval + ' (' + color_name + ')' + deg]
295
+ else:
296
+ result += [format(val, fmt)]
297
+
298
+ return " ".join(result)
299
+
300
+
301
+ def match(
302
+ obj_ra,
303
+ obj_dec,
304
+ obj_mag,
305
+ obj_magerr,
306
+ obj_flags,
307
+ cat_ra,
308
+ cat_dec,
309
+ cat_mag,
310
+ cat_magerr=None,
311
+ cat_color=None,
312
+ sr=3 / 3600,
313
+ obj_x=None,
314
+ obj_y=None,
315
+ spatial_order=0,
316
+ bg_order=None,
317
+ nonlin=False,
318
+ threshold=5.0,
319
+ niter=10,
320
+ accept_flags=0,
321
+ cat_saturation=None,
322
+ max_intrinsic_rms=0,
323
+ sn=None,
324
+ verbose=False,
325
+ robust=True,
326
+ scale_noise=False,
327
+ use_color=True,
328
+ force_color_term=None,
329
+ ):
330
+ """Low-level photometric matching routine.
331
+
332
+ Builds a photometric model for objects detected on the image that includes
333
+ catalogue magnitude, positionally-dependent zero point, linear color term,
334
+ optional additive flux term, and also takes into account possible intrinsic
335
+ magnitude scatter on top of measurement errors.
336
+
337
+ Parameters
338
+ ----------
339
+ obj_ra : array-like
340
+ Right Ascension values for the objects.
341
+ obj_dec : array-like
342
+ Declination values for the objects.
343
+ obj_mag : array-like
344
+ Instrumental magnitude values for the objects.
345
+ obj_magerr : array-like
346
+ Instrumental magnitude errors for the objects.
347
+ obj_flags : array-like
348
+ Flags for the objects.
349
+ cat_ra : array-like
350
+ Catalogue Right Ascension values.
351
+ cat_dec : array-like
352
+ Catalogue Declination values.
353
+ cat_mag : array-like
354
+ Catalogue magnitudes.
355
+ cat_magerr : array-like, optional
356
+ Catalogue magnitude errors.
357
+ cat_color : array-like, optional
358
+ Catalogue color values.
359
+ sr : float
360
+ Matching radius in degrees.
361
+ obj_x : array-like, optional
362
+ X coordinates of objects on the image.
363
+ obj_y : array-like, optional
364
+ Y coordinates of objects on the image.
365
+ spatial_order : int
366
+ Order of zero point spatial polynomial (0 for constant).
367
+ bg_order : int or None
368
+ Order of additive flux term spatial polynomial. None to disable this
369
+ term in the model.
370
+ nonlin : bool
371
+ Whether to fit for simple non-linearity.
372
+ threshold : float
373
+ Rejection threshold (relative to magnitude errors) for
374
+ object-catalogue pair to be rejected from the fit.
375
+ niter : int
376
+ Number of iterations for the fitting.
377
+ accept_flags : int
378
+ Bitmask for acceptable object flags. Objects having any other bits
379
+ set will be excluded from the model.
380
+ cat_saturation : float or None
381
+ Saturation level for the catalogue. Stars brighter than this
382
+ magnitude will be excluded from the fit.
383
+ max_intrinsic_rms : float
384
+ Maximal intrinsic RMS to use during the fitting. If set to 0, no
385
+ intrinsic scatter is included in the noise model.
386
+ sn : float or None
387
+ Minimal acceptable signal to noise ratio (1/obj_magerr) for the
388
+ objects to be included in the fit.
389
+ verbose : bool or callable
390
+ Whether to show verbose messages during the run. May be either
391
+ boolean, or a ``print``-like function.
392
+ robust : bool
393
+ Whether to use robust least squares fitting instead of weighted
394
+ least squares.
395
+ scale_noise : bool
396
+ Whether to re-scale the noise model (object and catalogue magnitude
397
+ errors) to match actual scatter of the data points. Intrinsic
398
+ scatter term is not scaled this way.
399
+ use_color : bool or int
400
+ Whether to use catalogue color for deriving the color term. If
401
+ integer, it determines the color term order.
402
+ force_color_term : float or None
403
+ Do not fit for the color term, but use this fixed value instead.
404
+
405
+ Returns
406
+ -------
407
+ dict or None
408
+ Dictionary with photometric results, or None if the fit failed.
409
+ Contains the following keys:
410
+
411
+ - ``oidx``, ``cidx``, ``dist`` -- indices of positionally matched
412
+ objects and catalogue stars, and their pairwise distances in degrees.
413
+ The matching is one-to-one: if several catalogue stars fall within
414
+ the radius of one object (or vice versa), only closest pairs are
415
+ kept.
416
+ - ``omag``, ``omag_err``, ``cmag``, ``cmag_err`` -- instrumental
417
+ magnitudes of matched objects, corresponding catalogue magnitudes,
418
+ and their errors. Array lengths equal the number of positional
419
+ matches.
420
+ - ``color`` -- catalogue colors corresponding to the matches, or
421
+ zeros if no color term fitting is requested.
422
+ - ``ox``, ``oy``, ``oflags`` -- coordinates of matched objects on the
423
+ image, and their flags.
424
+ - ``zero``, ``zero_err`` -- empirical zero points (catalogue minus
425
+ instrumental magnitudes) for every matched object, and errors
426
+ derived as a hypotenuse of their corresponding errors.
427
+ - ``zero_model``, ``zero_model_err`` -- modeled "full" zero points
428
+ (including color terms) for matched objects, and their errors from
429
+ the fit.
430
+ - ``color_term`` -- fitted color term. Instrumental photometric system
431
+ is defined as ``obj_mag = cat_mag - color * color_term``.
432
+ - ``color_term_err`` -- uncertainty of the fitted color term, with the
433
+ same structure as ``color_term``. None if the color term is not
434
+ fitted (disabled or fixed through ``force_color_term``).
435
+ - ``zero_fn`` -- function to compute the zero point (without color
436
+ term) at a given position and for a given instrumental magnitude,
437
+ and optionally its error.
438
+ - ``obj_zero`` -- zero points for all input objects (not necessarily
439
+ matched to the catalogue) computed via ``zero_fn``, without color
440
+ term.
441
+ - ``params`` -- internal parameters of the fitting polynomial.
442
+ - ``intrinsic_rms``, ``error_scale`` -- fitted values of intrinsic
443
+ scatter and noise scaling.
444
+ - ``idx`` -- boolean index of matched objects/catalogue stars used in
445
+ the final fit (not rejected during iterative thresholding, and
446
+ passing initial quality cuts).
447
+ - ``idx0`` -- same as ``idx`` but with only initial quality cuts
448
+ applied.
449
+
450
+ Notes
451
+ -----
452
+ The returned zero point function ``zero_fn`` has the signature::
453
+
454
+ zero_fn(xx, yy, mag=None, get_err=False, add_intrinsic_rms=False)
455
+
456
+ where ``xx`` and ``yy`` are coordinates on the image, ``mag`` is the
457
+ object instrumental magnitude (needed to compute the additive flux term).
458
+ If ``get_err=True``, the function returns estimated zero point error
459
+ instead of zero point, and ``add_intrinsic_rms`` controls whether this
460
+ error estimation should also include the intrinsic scatter term.
461
+
462
+ The zero point returned by this function does not include the contribution
463
+ of the color term. To derive the final calibrated magnitude for an object,
464
+ manually add the color contribution::
465
+
466
+ mag_calibrated = mag_instrumental + color * color_term
467
+
468
+ where ``color`` is the true object color, and ``color_term`` is reported
469
+ in the photometric results.
470
+
471
+ """
472
+
473
+ # Simple wrapper around print for logging in verbose mode only
474
+ log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
475
+
476
+ oidx, cidx, dist = astrometry.spherical_match(obj_ra, obj_dec, cat_ra, cat_dec, sr)
477
+
478
+ log(
479
+ len(dist),
480
+ 'initial matches between',
481
+ len(obj_ra),
482
+ 'objects and',
483
+ len(cat_ra),
484
+ 'catalogue stars, sr = %.2f arcsec' % (sr * 3600),
485
+ )
486
+
487
+ if not len(dist):
488
+ log('No matches between objects and catalogue stars')
489
+ return None
490
+
491
+ # Spherical matching returns all pairs inside the radius, so one object may
492
+ # be paired with several catalogue stars and vice versa. Resolve it by
493
+ # greedily keeping closest pairs so that every object and every catalogue
494
+ # star appears at most once
495
+ if len(np.unique(oidx)) < len(oidx) or len(np.unique(cidx)) < len(cidx):
496
+ o_used = np.zeros(np.max(oidx) + 1, dtype=bool)
497
+ c_used = np.zeros(np.max(cidx) + 1, dtype=bool)
498
+ keep = np.zeros(len(dist), dtype=bool)
499
+
500
+ for i in np.argsort(dist):
501
+ if not o_used[oidx[i]] and not c_used[cidx[i]]:
502
+ keep[i] = True
503
+ o_used[oidx[i]] = True
504
+ c_used[cidx[i]] = True
505
+
506
+ oidx, cidx, dist = oidx[keep], cidx[keep], dist[keep]
507
+ log('Kept %d unique closest pairs after resolving duplicate matches' % len(dist))
508
+
509
+ log('Median separation is %.2f arcsec' % (np.median(dist) * 3600))
510
+
511
+ omag = np.asarray(np.ma.filled(obj_mag[oidx], fill_value=np.nan), dtype=float)
512
+ omag_err = np.asarray(np.ma.filled(obj_magerr[oidx], fill_value=np.nan), dtype=float)
513
+ oflags = np.asarray(obj_flags[oidx]) if obj_flags is not None else np.zeros_like(omag, dtype=bool)
514
+ cmag = np.asarray(np.ma.filled(cat_mag[cidx], fill_value=np.nan), dtype=float)
515
+ cmag_err = (
516
+ np.asarray(np.ma.filled(cat_magerr[cidx], fill_value=np.nan), dtype=float)
517
+ if cat_magerr is not None
518
+ else np.zeros_like(cmag)
519
+ )
520
+
521
+ if obj_x is not None and obj_y is not None:
522
+ ox = np.asarray(np.ma.filled(obj_x[oidx], fill_value=np.nan), dtype=float)
523
+ oy = np.asarray(np.ma.filled(obj_y[oidx], fill_value=np.nan), dtype=float)
524
+ x0 = float(np.nanmean(ox)) if np.any(np.isfinite(ox)) else 0.0
525
+ y0 = float(np.nanmean(oy)) if np.any(np.isfinite(oy)) else 0.0
526
+ x = ox - x0
527
+ y = oy - y0
528
+ else:
529
+ x0, y0 = 0, 0
530
+ ox, oy = np.zeros_like(omag), np.zeros_like(omag)
531
+ x, y = np.zeros_like(omag), np.zeros_like(omag)
532
+
533
+ x, y, xy_scale = _normalize_spatial_coords(x, y)
534
+ if spatial_order > 0 and xy_scale > 1.0:
535
+ log('Normalizing spatial coordinates by %.1f pixels' % xy_scale)
536
+
537
+ # Regressor
538
+ X = make_series(1.0, x, y, order=spatial_order)
539
+ log('Fitting the model with spatial_order =', spatial_order)
540
+
541
+ if bg_order is not None:
542
+ # Spatially varying additive flux component, linearized in magnitudes
543
+ X += make_series(-2.5 / np.log(10) / 10 ** (-0.4 * omag), x, y, order=bg_order)
544
+ log('Adjusting background level using polynomial with bg_order =', bg_order)
545
+
546
+ if nonlin:
547
+ # Non-linearity
548
+ pos_nonlin = len(X)
549
+ X += make_series(omag, x, y, order=0)
550
+ log('Fitting for simple non-linearity')
551
+
552
+ if robust:
553
+ log('Using robust fitting')
554
+ else:
555
+ log('Using weighted fitting')
556
+
557
+ if force_color_term is not None:
558
+ fit_color_term = False
559
+ elif isinstance(use_color, int) and use_color > 1:
560
+ # use_color=N sets the color term polynomial order
561
+ fit_color_term = use_color
562
+ elif use_color:
563
+ fit_color_term = 1
564
+ else:
565
+ fit_color_term = False
566
+
567
+ if cat_color is not None:
568
+ ccolor = np.asarray(np.ma.filled(cat_color[cidx], fill_value=np.nan), dtype=float)
569
+ if fit_color_term:
570
+ pos_color = len(X)
571
+ for _ in range(int(fit_color_term)):
572
+ X += make_series(ccolor ** (_ + 1), x, y, order=0)
573
+
574
+ log('Using color term of order', int(fit_color_term))
575
+ elif force_color_term is not None:
576
+ for i, val in enumerate(np.atleast_1d(force_color_term)):
577
+ cmag -= val * ccolor ** (i + 1)
578
+ log('Using fixed color term', format_color_term(force_color_term))
579
+ else:
580
+ ccolor = np.zeros_like(cmag)
581
+
582
+ Nparams = len(X) # Number of parameters to be fitted
583
+
584
+ X = np.vstack(X).T
585
+ zero = cmag - omag # We will build a model for this definition of zero point
586
+ zero_err = np.hypot(omag_err, cmag_err)
587
+ # weights = 1.0/zero_err**2
588
+
589
+ idx0 = (
590
+ np.isfinite(omag)
591
+ & np.isfinite(omag_err)
592
+ & np.isfinite(cmag)
593
+ & np.isfinite(cmag_err)
594
+ & (zero_err > 0) # Zero errors would give infinite weights in the fit
595
+ & ((oflags & ~accept_flags) == 0)
596
+ ) # initial mask
597
+ if cat_color is not None and (fit_color_term or force_color_term is not None):
598
+ idx0 &= np.isfinite(ccolor)
599
+ if cat_saturation is not None:
600
+ idx0 &= cmag >= cat_saturation
601
+ if sn is not None:
602
+ idx0 &= omag_err < 1 / sn
603
+
604
+ log('%d objects pass initial quality cuts' % np.sum(idx0))
605
+
606
+ idx = idx0.copy()
607
+
608
+ intrinsic_rms = 0
609
+ scale_err = 1
610
+ total_err = zero_err
611
+
612
+ def run_fit():
613
+ """Single robust or weighted fit with current mask and noise model."""
614
+ if robust:
615
+ # Rescale the arguments with weights
616
+ C = _StableRLM(zero[idx] / total_err[idx], (X[idx].T / total_err[idx]).T).fit()
617
+ else:
618
+ C = sm.WLS(zero[idx], X[idx], weights=1 / total_err[idx] ** 2).fit()
619
+
620
+ # Perfect fit is fatal for RLM iterations, and whenever the scale
621
+ # is used for re-scaling the noise model
622
+ if not np.isfinite(C.scale) or (C.scale == 0.0 and (robust or scale_noise)):
623
+ log('Fit failed - fit scale collapsed to zero, model is degenerate')
624
+ return None
625
+
626
+ # RLM scale is a standard deviation while WLS one is a variance
627
+ fit_scale = float(C.scale) if robust else float(np.sqrt(C.scale))
628
+
629
+ return C, fit_scale
630
+
631
+ converged = False
632
+ total_err_fit = None
633
+
634
+ for iter in range(niter):
635
+ if np.sum(idx) < Nparams + 1:
636
+ log(
637
+ "Fit failed - %d objects remaining for fitting %d parameters"
638
+ % (np.sum(idx), Nparams)
639
+ )
640
+ return None
641
+
642
+ total_err_fit = total_err # Noise model actually used in this fit
643
+
644
+ fit = run_fit()
645
+ if fit is None:
646
+ return None
647
+ C, fit_scale = fit
648
+
649
+ zero_model = np.sum(X * C.params, axis=1)
650
+
651
+ if scale_noise:
652
+ # The fit scale measures residual scatter relative to the noise
653
+ # model used in this fit, so the applied scaling is cumulative
654
+ scale_err = scale_err * fit_scale
655
+
656
+ intrinsic_rms = (
657
+ get_intrinsic_scatter((zero - zero_model)[idx], (zero_err * scale_err)[idx], max=max_intrinsic_rms)
658
+ if max_intrinsic_rms > 0
659
+ else 0
660
+ )
661
+
662
+ total_err = np.hypot(zero_err * scale_err, intrinsic_rms)
663
+
664
+ if threshold:
665
+ idx1 = np.abs((zero - zero_model)[idx] / total_err[idx]) < threshold
666
+ else:
667
+ idx1 = np.ones_like(idx[idx])
668
+
669
+ log(
670
+ 'Iteration',
671
+ iter,
672
+ ':',
673
+ np.sum(idx),
674
+ '/',
675
+ len(idx),
676
+ '- rms',
677
+ '%.2f' % np.std((zero - zero_model)[idx0]),
678
+ '%.2f' % np.std((zero - zero_model)[idx]),
679
+ '- normed',
680
+ '%.2f' % np.std((zero - zero_model)[idx] / zero_err[idx]),
681
+ '%.2f' % np.std((zero - zero_model)[idx] / total_err[idx]),
682
+ '- scale %.2f %.2f' % (fit_scale, scale_err),
683
+ '- rms',
684
+ '%.2f' % intrinsic_rms,
685
+ )
686
+
687
+ if not np.sum(~idx1): # and new_intrinsic_rms <= intrinsic_rms:
688
+ log('Fitting converged')
689
+ converged = True
690
+ break
691
+ else:
692
+ idx[idx] &= idx1
693
+
694
+ # Refit once more if the mask was updated after the last fit, or if the
695
+ # noise model changed, so that the returned model is consistent with them
696
+ if not converged or total_err_fit is None or not np.array_equal(total_err, total_err_fit):
697
+ if np.sum(idx) < Nparams + 1:
698
+ log(
699
+ "Fit failed - %d objects remaining for fitting %d parameters"
700
+ % (np.sum(idx), Nparams)
701
+ )
702
+ return None
703
+
704
+ fit = run_fit()
705
+ if fit is None:
706
+ return None
707
+ C, fit_scale = fit
708
+
709
+ zero_model = np.sum(X * C.params, axis=1)
710
+
711
+ if max_intrinsic_rms > 0:
712
+ intrinsic_rms = get_intrinsic_scatter(
713
+ (zero - zero_model)[idx], (zero_err * scale_err)[idx], max=max_intrinsic_rms
714
+ )
715
+
716
+ log(np.sum(idx), 'good matches')
717
+ if max_intrinsic_rms > 0:
718
+ log('Intrinsic scatter is %.2f' % intrinsic_rms)
719
+
720
+ if nonlin:
721
+ log('Non-linearity term is %.3f' % C.params[pos_nonlin])
722
+
723
+ cov_p = _prepare_parameter_covariance(C.cov_params())
724
+ zero_model_err = _evaluate_parameter_covariance(X, cov_p)
725
+
726
+ # Export the model
727
+ def zero_fn(xx, yy, mag=None, get_err=False, add_intrinsic_rms=False):
728
+ if mag is not None:
729
+ mag = np.atleast_1d(np.ma.filled(mag, np.nan))
730
+
731
+ if xx is not None and yy is not None:
732
+ x, y = xx - x0, yy - y0
733
+ else:
734
+ n = len(mag) if mag is not None else 1
735
+ x, y = np.zeros(n), np.zeros(n)
736
+
737
+ # Ensure we do not have MaskedColumns
738
+ x, y = [
739
+ np.asarray(np.atleast_1d(np.ma.filled(_, fill_value=np.nan)), dtype=float)
740
+ for _ in (x, y)
741
+ ]
742
+ x /= xy_scale
743
+ y /= xy_scale
744
+
745
+ X = make_series(1.0, x, y, order=spatial_order)
746
+
747
+ if bg_order is not None and mag is not None:
748
+ X += make_series(-2.5 / np.log(10) / 10 ** (-0.4 * mag), x, y, order=bg_order)
749
+
750
+ if nonlin and mag is not None:
751
+ X += make_series(np.ma.filled(mag, np.nan), x, y, order=0)
752
+
753
+ X = np.vstack(X).T
754
+
755
+ if get_err:
756
+ err = _evaluate_parameter_covariance(X, cov_p[0 : X.shape[1], 0 : X.shape[1]])
757
+ if add_intrinsic_rms:
758
+ err = np.hypot(err, intrinsic_rms)
759
+ return err
760
+ else:
761
+ result = np.sum(X * C.params[0 : X.shape[1]], axis=1)
762
+ return result
763
+
764
+ if cat_color is not None and (fit_color_term or force_color_term is not None):
765
+ if fit_color_term:
766
+ color_term = list(C.params[pos_color:][: int(fit_color_term)])
767
+ color_term_err = list(np.sqrt(np.diag(cov_p))[pos_color:][: int(fit_color_term)])
768
+ if len(color_term) == 1:
769
+ color_term = color_term[0]
770
+ color_term_err = color_term_err[0]
771
+
772
+ log('Color term is', format_color_term(color_term))
773
+ elif force_color_term is not None:
774
+ color_term = force_color_term
775
+ color_term_err = None
776
+ log('Color term (fixed) is', format_color_term(color_term))
777
+ else:
778
+ color_term = None
779
+ color_term_err = None
780
+
781
+ return {
782
+ 'oidx': oidx,
783
+ 'cidx': cidx,
784
+ 'dist': dist,
785
+ 'omag': omag,
786
+ 'omag_err': omag_err,
787
+ 'cmag': cmag,
788
+ 'cmag_err': cmag_err,
789
+ 'color': ccolor,
790
+ 'color_term': color_term,
791
+ 'color_term_err': color_term_err,
792
+ 'zero': zero,
793
+ 'zero_err': zero_err,
794
+ 'zero_model': zero_model,
795
+ 'zero_model_err': zero_model_err,
796
+ 'zero_fn': zero_fn,
797
+ 'params': C.params,
798
+ # Total measured noise mismatch: already applied scaling (1 unless
799
+ # scale_noise is set) times the residual scale of the final fit
800
+ 'error_scale': scale_err * fit_scale,
801
+ 'intrinsic_rms': intrinsic_rms,
802
+ 'obj_zero': zero_fn(obj_x, obj_y, mag=obj_mag),
803
+ 'ox': ox,
804
+ 'oy': oy,
805
+ 'oflags': oflags,
806
+ 'idx': idx,
807
+ 'idx0': idx0,
808
+ }
809
+
810
+
811
+ def make_sn_model(mag, sn):
812
+ """Build a model for signal to noise (S/N) ratio versus magnitude.
813
+
814
+ Assumes the noise comes from constant background noise plus Poissonian
815
+ noise with constant gain, plus a constant systematic floor (flat-fielding,
816
+ scintillation etc) that limits the S/N of bright stars.
817
+
818
+ Parameters
819
+ ----------
820
+ mag : array-like
821
+ Calibrated magnitudes.
822
+ sn : array-like
823
+ S/N values corresponding to the magnitudes.
824
+
825
+ Returns
826
+ -------
827
+ callable
828
+ Function that accepts an array of magnitudes and returns the S/N
829
+ model values for them.
830
+
831
+ Raises
832
+ ------
833
+ ValueError
834
+ If no finite positive S/N values are available to build the model.
835
+ """
836
+ idx = np.isfinite(mag) & np.isfinite(sn) & (sn > 0)
837
+ mag = mag[idx]
838
+ sn = sn[idx]
839
+
840
+ if mag.size == 0:
841
+ raise ValueError('No finite positive S/N values to build the model')
842
+
843
+ def sn_fn(p, mag):
844
+ # Background-limited, Poisson-limited, and constant floor terms
845
+ return 1 / np.sqrt(p[0] * 10 ** (0.8 * mag) + p[1] * 10 ** (0.4 * mag) + p[2])
846
+
847
+ def lstsq_fn(q, x, y):
848
+ # Minimize residuals in logarithms, for better stability. Params are
849
+ # also fitted in logarithms as they span many decades, and it keeps
850
+ # them positive so that the model stays finite and monotonic
851
+ return np.log10(y) - np.log10(sn_fn(10.0 ** np.asarray(q), x))
852
+
853
+ aidx = np.argsort(sn)
854
+
855
+ # Initial params from limiting cases: background-limited on average,
856
+ # Poisson-limited at the brightest point, and floor from the highest S/N
857
+ x = np.log10(
858
+ [
859
+ np.median(10 ** (-0.8 * mag) / sn**2),
860
+ 10 ** (-0.4 * mag[aidx][-1]) / sn[aidx][-1] ** 2,
861
+ 0.5 / sn[aidx][-1] ** 2,
862
+ ]
863
+ )
864
+
865
+ res = least_squares(lstsq_fn, x, args=(mag, sn), method='lm')
866
+
867
+ return lambda mag: sn_fn(10.0**res.x, mag)
868
+
869
+
870
+ def get_detection_limit_sn(mag, mag_sn, sn=5, get_model=False, verbose=True):
871
+ """Estimate the detection limit using S/N vs magnitude method.
872
+
873
+ Parameters
874
+ ----------
875
+ mag : array-like
876
+ Calibrated magnitudes.
877
+ mag_sn : array-like
878
+ S/N values corresponding to these magnitudes.
879
+ sn : float
880
+ S/N level for the detection limit.
881
+ get_model : bool
882
+ If True, also return the S/N model function.
883
+ verbose : bool or callable
884
+ Whether to show verbose messages during the run. May be either
885
+ boolean, or a ``print``-like function.
886
+
887
+ Returns
888
+ -------
889
+ float or None
890
+ Magnitude corresponding to the detection limit at the given S/N
891
+ level. None if the model cannot be built or the root is not found.
892
+ callable or None
893
+ S/N model function (only returned when ``get_model=True``). None
894
+ if the model cannot be built.
895
+ """
896
+
897
+ # Simple wrapper around print for logging in verbose mode only
898
+ log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
899
+
900
+ mag = np.asarray(mag)
901
+ mag_sn = np.asarray(mag_sn)
902
+
903
+ idx = np.isfinite(mag) & np.isfinite(mag_sn) & (mag_sn > 0)
904
+ mag = mag[idx]
905
+ mag_sn = mag_sn[idx]
906
+
907
+ if mag.size == 0:
908
+ log('No valid magnitudes/SN values for detection limit estimation')
909
+ return (None, None) if get_model else None
910
+
911
+ mag0 = None
912
+
913
+ try:
914
+ sn_model = make_sn_model(mag, mag_sn)
915
+ except ValueError:
916
+ log('Cannot build S/N model function')
917
+ return (None, None) if get_model else None
918
+ res = root_scalar(
919
+ lambda x: np.log10(sn_model(x)) - np.log10(sn),
920
+ x0=np.nanmax(mag),
921
+ x1=np.nanmax(mag) + 1,
922
+ )
923
+ if res.converged:
924
+ mag0 = res.root
925
+ else:
926
+ log('Cannot determine the root of S/N model function')
927
+
928
+ if get_model:
929
+ return mag0, sn_model
930
+ else:
931
+ return mag0