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/sfft.py ADDED
@@ -0,0 +1,1077 @@
1
+ """
2
+ SFFT — Space-Frequency Fourier Transform image subtraction.
3
+
4
+ Implements spatially varying kernel fitting in a single global least-squares
5
+ solve, following the approach of Hu et al. (2022, ApJ, 936, 157).
6
+
7
+ The model is::
8
+
9
+ science(x,y) = Σ_α Σ_β c_{α,β} · [P_β(x,y) · R_α(x,y)] + Σ_γ d_γ · P_γ(x,y)
10
+
11
+ where R_α is the reference shifted by kernel offset α, P_β are polynomial
12
+ basis functions encoding spatial variation, and c_{α,β} / d_γ are scalar
13
+ coefficients solved for globally.
14
+
15
+ The normal equations are assembled via batched BLAS matmuls over polynomial
16
+ term pairs, avoiding materializing the full design matrix.
17
+
18
+ A soft kernel-sum constraint enforces that Σ_α a_α(x,y) = f(x,y) where f is
19
+ a low-order polynomial modelling smooth flux-scale variation across the image.
20
+ """
21
+
22
+ import numpy as np
23
+ from dataclasses import dataclass
24
+ from scipy import linalg as scipy_linalg
25
+ from typing import Tuple
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Data containers
30
+ # ---------------------------------------------------------------------------
31
+
32
+
33
+ @dataclass
34
+ class SFFTResult:
35
+ """Result of SFFT image subtraction."""
36
+
37
+ diff: np.ndarray
38
+ """Difference image (science - model)."""
39
+
40
+ model: np.ndarray
41
+ """Convolved reference + background model."""
42
+
43
+ kernel_coeffs: np.ndarray
44
+ """Kernel coefficients, shape (n_kernel, n_kpoly)."""
45
+
46
+ bg_coeffs: np.ndarray
47
+ """Background coefficients, shape (n_bgpoly,)."""
48
+
49
+ kernel_shape: Tuple[int, int]
50
+ """Kernel support size (ky, kx)."""
51
+
52
+ kernel_poly_order: int
53
+ """Polynomial order for kernel spatial variation."""
54
+
55
+ bg_poly_order: int
56
+ """Polynomial order for differential background."""
57
+
58
+ flux_poly_order: int
59
+ """Polynomial order for the kernel-sum (flux scale) constraint."""
60
+
61
+ flux_poly_coeffs: np.ndarray
62
+ """Fitted flux-scale polynomial coefficients."""
63
+
64
+ n_iter: int
65
+ """Number of solver passes performed (sigma-clipping iterations plus
66
+ the template-noise reweighting pass, if any)."""
67
+
68
+ rms: float
69
+ """Final RMS of residuals over good (unmasked, unclipped) pixels."""
70
+
71
+ n_good: int
72
+ """Number of good (unmasked, unclipped) pixels in final iteration."""
73
+
74
+ dmask: np.ndarray = None
75
+ """Boolean mask (True = unreliable) of difference pixels whose model is
76
+ built from missing template data: image edges and pixels within the
77
+ kernel footprint of template defects (NaN/Inf or ``template_mask``)."""
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # Polynomial helpers
82
+ # ---------------------------------------------------------------------------
83
+
84
+
85
+ def _poly_terms_2d(x, y, order):
86
+ """Triangular 2-D polynomial basis evaluated on coordinate arrays.
87
+
88
+ Term ordering: (0,0), (1,0), (0,1), (2,0), (1,1), (0,2), ...
89
+
90
+ Parameters
91
+ ----------
92
+ x, y : ndarray, same shape (any)
93
+ order : int >= 0
94
+
95
+ Returns
96
+ -------
97
+ terms : ndarray, shape (n_terms, *x.shape)
98
+ """
99
+ terms = []
100
+ for total in range(order + 1):
101
+ for px in range(total + 1):
102
+ py = total - px
103
+ terms.append(x**px * y**py)
104
+ return np.array(terms, dtype=np.float64)
105
+
106
+
107
+ def _n_poly(order):
108
+ """Number of terms in a 2-D triangular polynomial of given order."""
109
+ return (order + 1) * (order + 2) // 2
110
+
111
+
112
+ def _norm_coords(ny, nx):
113
+ """Pixel coordinate arrays normalized to [-1, 1]."""
114
+ yy, xx = np.indices((ny, nx), dtype=np.float64)
115
+ cx, cy = 0.5 * (nx - 1), 0.5 * (ny - 1)
116
+ sx = max(1.0, cx)
117
+ sy = max(1.0, cy)
118
+ return (xx - cx) / sx, (yy - cy) / sy
119
+
120
+
121
+ def _norm_xy(x, y, image_shape):
122
+ """Normalize pixel coordinate value(s) to [-1, 1], matching :func:`_norm_coords`."""
123
+ ny, nx = image_shape[:2]
124
+ xn = (np.asarray(x, dtype=np.float64) - 0.5 * (nx - 1)) / max(1.0, 0.5 * (nx - 1))
125
+ yn = (np.asarray(y, dtype=np.float64) - 0.5 * (ny - 1)) / max(1.0, 0.5 * (ny - 1))
126
+ return xn, yn
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # Kernel offset helpers
131
+ # ---------------------------------------------------------------------------
132
+
133
+
134
+ def _kernel_offsets(ky, kx):
135
+ """List of (dy, dx) offsets for a kernel of shape (ky, kx). Both must be odd."""
136
+ if ky % 2 == 0 or kx % 2 == 0:
137
+ raise ValueError("kernel dimensions must be odd, got (%d, %d)" % (ky, kx))
138
+ hy, hx = ky // 2, kx // 2
139
+ return [(dy, dx) for dy in range(-hy, hy + 1) for dx in range(-hx, hx + 1)]
140
+
141
+
142
+ def _shift_image(img, dy, dx):
143
+ """Shift 2-D array by integer (dy, dx), zero-filling exposed edges."""
144
+ ny, nx = img.shape
145
+ out = np.zeros_like(img)
146
+ sy0, sy1 = max(0, -dy), min(ny, ny - dy)
147
+ dy0 = sy0 + dy
148
+ sx0, sx1 = max(0, -dx), min(nx, nx - dx)
149
+ dx0 = sx0 + dx
150
+ if sy1 > sy0 and sx1 > sx0:
151
+ out[dy0 : dy0 + (sy1 - sy0), dx0 : dx0 + (sx1 - sx0)] = img[sy0:sy1, sx0:sx1]
152
+ return out
153
+
154
+
155
+ def _shift_band(img, dy, dx, r0, r1, out):
156
+ """Rows ``r0:r1`` of ``_shift_image(img, dy, dx)``, written into ``out``.
157
+
158
+ ``out`` must be a 2-D array of shape (r1 - r0, nx). Used to assemble
159
+ the normal equations in row bands without materializing full shifted
160
+ copies of the image.
161
+ """
162
+ ny, nx = img.shape
163
+ out[:] = 0.0
164
+ # Valid output rows of the full shift are [max(0, dy), min(ny, ny + dy))
165
+ y0 = max(r0, dy)
166
+ y1 = min(r1, ny + dy)
167
+ sx0, sx1 = max(0, -dx), min(nx, nx - dx)
168
+ dx0 = sx0 + dx
169
+ if y1 > y0 and sx1 > sx0:
170
+ out[y0 - r0 : y1 - r0, dx0 : dx0 + (sx1 - sx0)] = img[y0 - dy : y1 - dy, sx0:sx1]
171
+ return out
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # Core SFFT solve
176
+ # ---------------------------------------------------------------------------
177
+
178
+
179
+ # Target size (in elements) for the per-band (n_kernel, band_pix) work arrays
180
+ # used by _assemble_normal_equations: 2**25 float64 elements = 256 MB.
181
+ _ASSEMBLE_BAND_ELEMENTS = 2**25
182
+
183
+ # Downdate the accumulated normal equations by the clipped pixels'
184
+ # contributions instead of reassembling from scratch after each
185
+ # sigma-clipping iteration. Exact up to floating-point roundoff; the flag
186
+ # exists for testing and debugging.
187
+ _USE_INCREMENTAL_UPDATES = True
188
+
189
+
190
+ def _accumulate_normal_blocks(H, g, srefs, w_c, s_c, pk_c, pbg_c, n_kernel_params):
191
+ """Accumulate a pixel subset's contributions into H (upper blocks) and g.
192
+
193
+ Every term is linear in the pixel weight ``w_c``, so calling this with
194
+ negated weights exactly subtracts (downdates) previously accumulated
195
+ contributions.
196
+
197
+ Only the upper triangle of the kernel-kernel block structure (b1 <= b2
198
+ polynomial pairs) and the kernel→background cross rows are filled;
199
+ use :func:`_mirror_normal_matrix` to complete the symmetric matrix.
200
+
201
+ Parameters
202
+ ----------
203
+ H : (n_total, n_total), modified in place
204
+ g : (n_total,), modified in place
205
+ srefs : (n_kernel, n_pix) shifted reference values at the subset pixels
206
+ w_c : (n_pix,) pixel weights (may be negative for downdating)
207
+ s_c : (n_pix,) science values
208
+ pk_c : (n_kpoly, n_pix) kernel polynomial terms at the subset pixels
209
+ pbg_c : (n_bgpoly, n_pix) background polynomial terms at the subset pixels
210
+ n_kernel_params : number of kernel parameters (offset × poly terms)
211
+ """
212
+ n_kpoly = pk_c.shape[0]
213
+
214
+ with np.errstate(over='ignore', invalid='ignore', divide='ignore'):
215
+ # === Background-background block ===
216
+ w_poly_bg = pbg_c * w_c[np.newaxis, :] # (n_bgpoly, n_pix)
217
+ H[n_kernel_params:, n_kernel_params:] += w_poly_bg @ pbg_c.T
218
+ g[n_kernel_params:] += w_poly_bg @ s_c
219
+
220
+ # === Kernel RHS and kernel-background cross-block ===
221
+ # g[α*n_kpoly + β] = Σ_pix w * P_β * R_α * S
222
+ # H[α*n_kpoly+β, n_kernel_params+γ] = Σ_pix w * P_β * R_α * Q_γ
223
+ ws_c = w_c * s_c
224
+ for b in range(n_kpoly):
225
+ g[b:n_kernel_params:n_kpoly] += srefs @ (ws_c * pk_c[b])
226
+ wPb_Q = pbg_c * (w_c * pk_c[b])[np.newaxis, :] # (n_bgpoly, n_pix)
227
+ # Rows α*n_kpoly+b for all α form the strided slice below
228
+ H[b:n_kernel_params:n_kpoly, n_kernel_params:] += srefs @ wPb_Q.T
229
+
230
+ # === Kernel-kernel block (the main bottleneck) ===
231
+ # H[α1*n_kpoly+β1, α2*n_kpoly+β2] = Σ_pix w * P_β1 * R_α1 * P_β2 * R_α2
232
+ # For fixed (β1, β2):
233
+ # M[α1, α2] = (shifted_refs * (w*P_β1)) @ (shifted_refs * P_β2).T
234
+ # This is n_kpoly*(n_kpoly+1)/2 matmuls of size (n_kernel × n_pix) @ (n_pix × n_kernel);
235
+ # only the b1 <= b2 blocks are accumulated.
236
+ for b1 in range(n_kpoly):
237
+ wPb1_refs = srefs * (w_c * pk_c[b1])[np.newaxis, :] # (n_kernel, n_pix)
238
+ for b2 in range(b1, n_kpoly):
239
+ M = wPb1_refs @ (srefs * pk_c[b2][np.newaxis, :]).T
240
+ H[b1:n_kernel_params:n_kpoly, b2:n_kernel_params:n_kpoly] += M
241
+
242
+
243
+ def _mirror_normal_matrix(H, n_kernel_params, n_kpoly):
244
+ """Mirror the accumulated upper blocks of H into the lower triangle."""
245
+ for b1 in range(n_kpoly):
246
+ for b2 in range(b1 + 1, n_kpoly):
247
+ H[b2:n_kernel_params:n_kpoly, b1:n_kernel_params:n_kpoly] = H[
248
+ b1:n_kernel_params:n_kpoly, b2:n_kernel_params:n_kpoly
249
+ ].T
250
+ H[n_kernel_params:, :n_kernel_params] = H[:n_kernel_params, n_kernel_params:].T
251
+ return H
252
+
253
+
254
+ def _assemble_normal_equations(
255
+ reference, science, weight, kernel_shape, kernel_poly_order, bg_poly_order, x_norm, y_norm
256
+ ):
257
+ """Assemble normal equations H·θ = g via polynomial-pair batching.
258
+
259
+ Instead of iterating over O(n_kernel²) offset pairs with small matmuls,
260
+ iterates over O(n_kpoly²) polynomial term pairs with large BLAS matmuls.
261
+ For typical parameters (7×7 kernel, poly=2), this is ~60× fewer iterations
262
+ with much better BLAS utilization (49×49 matmuls vs 6×6).
263
+
264
+ All contributions are plain sums over pixels, so the image is processed
265
+ in bands of rows: peak memory is bounded by a few (n_kernel, band_pix)
266
+ work arrays (~256 MB each) instead of the full (n_kernel, npix) set of
267
+ shifted references, which would reach tens of GB for 4k images.
268
+
269
+ Parameters
270
+ ----------
271
+ reference : (ny, nx)
272
+ science : (ny, nx)
273
+ weight : (ny, nx)
274
+ kernel_shape, kernel_poly_order, bg_poly_order : model specification
275
+ x_norm, y_norm : (ny, nx) normalized coordinates
276
+
277
+ Returns
278
+ -------
279
+ H : (n_total, n_total) normal matrix, upper-block form — pass through
280
+ :func:`_mirror_normal_matrix` before solving
281
+ g : (n_total,) right-hand side
282
+ """
283
+ ky, kx = kernel_shape
284
+ offsets = _kernel_offsets(ky, kx)
285
+ n_kernel_pixels = ky * kx
286
+ n_kpoly = _n_poly(kernel_poly_order)
287
+ n_bgpoly = _n_poly(bg_poly_order)
288
+ n_kernel_params = n_kernel_pixels * n_kpoly
289
+ n_total = n_kernel_params + n_bgpoly
290
+
291
+ ny, nx = reference.shape
292
+ npix = ny * nx
293
+
294
+ poly_k = _poly_terms_2d(x_norm, y_norm, kernel_poly_order) # (n_kpoly, ny, nx)
295
+ poly_bg = _poly_terms_2d(x_norm, y_norm, bg_poly_order) # (n_bgpoly, ny, nx)
296
+
297
+ # Flatten everything for matrix ops
298
+ poly_k_flat = poly_k.reshape(n_kpoly, npix) # (n_kpoly, npix)
299
+ poly_bg_flat = poly_bg.reshape(n_bgpoly, npix) # (n_bgpoly, npix)
300
+ w_flat = weight.ravel() # (npix,)
301
+ s_flat = science.ravel() # (npix,)
302
+
303
+ H = np.zeros((n_total, n_total), dtype=np.float64)
304
+ g = np.zeros(n_total, dtype=np.float64)
305
+
306
+ band_rows = max(1, min(ny, _ASSEMBLE_BAND_ELEMENTS // (n_kernel_pixels * nx)))
307
+ shifted_refs = np.empty((n_kernel_pixels, band_rows * nx), dtype=np.float64)
308
+
309
+ for r0 in range(0, ny, band_rows):
310
+ r1 = min(ny, r0 + band_rows)
311
+ bpix = (r1 - r0) * nx
312
+ sl = slice(r0 * nx, r1 * nx)
313
+
314
+ # Shifted references restricted to this band: (n_kernel, bpix)
315
+ srefs = shifted_refs[:, :bpix]
316
+ for a, (dy, dx) in enumerate(offsets):
317
+ _shift_band(reference, dy, dx, r0, r1, srefs[a].reshape(r1 - r0, nx))
318
+
319
+ _accumulate_normal_blocks(
320
+ H, g, srefs, w_flat[sl], s_flat[sl],
321
+ poly_k_flat[:, sl], poly_bg_flat[:, sl], n_kernel_params,
322
+ )
323
+
324
+ return H, g
325
+
326
+
327
+ def _downdate_normal_equations(
328
+ H, g, reference, science, w_pix, ys, xs,
329
+ kernel_shape, kernel_poly_order, bg_poly_order, x_norm, y_norm,
330
+ ):
331
+ """Subtract individual pixels' contributions from accumulated normal equations.
332
+
333
+ Since every term of H and g is linear in the pixel weight, removing
334
+ pixels (sigma-clipping) can be done exactly by accumulating their
335
+ contributions with negated weights — far cheaper than reassembling
336
+ from the full image when only a small fraction of pixels is clipped.
337
+
338
+ ``H`` must be in the upper-block (pre-mirror) form produced by
339
+ :func:`_assemble_normal_equations`.
340
+
341
+ Parameters
342
+ ----------
343
+ H, g : accumulated normal equations, modified in place
344
+ reference, science : (ny, nx) images
345
+ w_pix : (n_pix,) weights the pixels contributed with
346
+ ys, xs : (n_pix,) pixel coordinates
347
+ kernel_shape, kernel_poly_order, bg_poly_order : model specification
348
+ x_norm, y_norm : (ny, nx) normalized coordinates
349
+ """
350
+ ky, kx = kernel_shape
351
+ hy, hx = ky // 2, kx // 2
352
+ offsets = _kernel_offsets(ky, kx)
353
+ n_kpoly = _n_poly(kernel_poly_order)
354
+ n_kernel_params = ky * kx * n_kpoly
355
+
356
+ # Shifted reference values R_α(p) = ref(p - offset_α) at scattered pixels,
357
+ # gathered from a zero-padded copy so out-of-image offsets read 0 exactly
358
+ # like _shift_image does
359
+ ref_pad = np.pad(reference, ((hy, hy), (hx, hx)))
360
+ dys = np.array([o[0] for o in offsets])
361
+ dxs = np.array([o[1] for o in offsets])
362
+ srefs = ref_pad[
363
+ ys[np.newaxis, :] - dys[:, np.newaxis] + hy,
364
+ xs[np.newaxis, :] - dxs[:, np.newaxis] + hx,
365
+ ] # (n_kernel, n_pix)
366
+
367
+ pk_c = _poly_terms_2d(x_norm[ys, xs], y_norm[ys, xs], kernel_poly_order)
368
+ pbg_c = _poly_terms_2d(x_norm[ys, xs], y_norm[ys, xs], bg_poly_order)
369
+
370
+ _accumulate_normal_blocks(
371
+ H, g, srefs, -w_pix, science[ys, xs], pk_c, pbg_c, n_kernel_params
372
+ )
373
+
374
+
375
+ def _build_kernel_sum_constraint(kernel_shape, kernel_poly_order, flux_poly_order):
376
+ """Build constraint matrix for kernel sum = flux polynomial.
377
+
378
+ The kernel-sum at position (x,y) is:
379
+ Σ_α a_α(x,y) = Σ_α Σ_β c_{α,β} · P_β(x,y)
380
+ = Σ_β [Σ_α c_{α,β}] · P_β(x,y)
381
+
382
+ We want this to equal a flux polynomial:
383
+ f(x,y) = Σ_γ f_γ · Q_γ(x,y)
384
+
385
+ where Q_γ are polynomial terms up to flux_poly_order.
386
+
387
+ If flux_poly_order <= kernel_poly_order, Q_γ ⊂ P_β, so the constraint is:
388
+ Σ_α c_{α,β} = f_β for β ≤ flux_poly_order terms
389
+ Σ_α c_{α,β} = 0 for higher-order β terms
390
+
391
+ This gives n_kpoly linear constraints on the kernel coefficients.
392
+
393
+ Returns
394
+ -------
395
+ C : (n_constraints, n_kernel_params) or None
396
+ Homogeneous constraint rows (C·θ_kernel = 0), or None if nothing
397
+ needs constraining.
398
+ n_flux_free : number of free flux polynomial coefficients
399
+ """
400
+ n_kernel_pixels = kernel_shape[0] * kernel_shape[1]
401
+ n_kpoly = _n_poly(kernel_poly_order)
402
+ n_flux = _n_poly(flux_poly_order)
403
+
404
+ # We constrain: for each kernel poly term β with order > flux_poly_order,
405
+ # the sum Σ_α c_{α,β} = 0.
406
+ # For terms β within flux_poly_order, we don't constrain (those define
407
+ # the free flux scale polynomial).
408
+
409
+ # Count terms with total degree > flux_poly_order
410
+ n_constrained = n_kpoly - n_flux
411
+ if n_constrained <= 0:
412
+ return None, n_flux
413
+
414
+ # Build constraint rows
415
+ # Parameter layout: [c_{0,0}, c_{0,1}, ..., c_{0,n_kpoly-1},
416
+ # c_{1,0}, ..., c_{n_kernel-1, n_kpoly-1},
417
+ # d_0, ..., d_{n_bgpoly-1}]
418
+ # Total kernel params = n_kernel_pixels * n_kpoly
419
+ # For poly term β: params at indices [α * n_kpoly + β for α in range(n_kernel_pixels)]
420
+
421
+ n_total_kernel = n_kernel_pixels * n_kpoly
422
+ C_rows = []
423
+
424
+ # Identify which poly terms have total degree > flux_poly_order
425
+ term_idx = 0
426
+ for total in range(kernel_poly_order + 1):
427
+ for px in range(total + 1):
428
+ if total > flux_poly_order:
429
+ # This term β should have Σ_α c_{α,β} = 0
430
+ row = np.zeros(n_total_kernel, dtype=np.float64)
431
+ for alpha in range(n_kernel_pixels):
432
+ row[alpha * n_kpoly + term_idx] = 1.0
433
+ C_rows.append(row)
434
+ term_idx += 1
435
+
436
+ C = np.array(C_rows, dtype=np.float64)
437
+ return C, n_flux
438
+
439
+
440
+ def _reconstruct_model(
441
+ theta, reference, kernel_shape, kernel_poly_order, bg_poly_order, x_norm, y_norm
442
+ ):
443
+ """Reconstruct the model image from solved coefficients.
444
+
445
+ model(x,y) = Σ_α [Σ_β c_{α,β} · P_β(x,y)] · R_α(x,y) + Σ_γ d_γ · Q_γ(x,y)
446
+
447
+ Parameters
448
+ ----------
449
+ theta : (n_total,) solved coefficients
450
+ reference : (ny, nx)
451
+ kernel_shape, kernel_poly_order, bg_poly_order : model specification
452
+ x_norm, y_norm : (ny, nx) normalized coordinates
453
+
454
+ Returns
455
+ -------
456
+ model : (ny, nx)
457
+ """
458
+ ky, kx = kernel_shape
459
+ offsets = _kernel_offsets(ky, kx)
460
+ n_kpoly = _n_poly(kernel_poly_order)
461
+ n_bgpoly = _n_poly(bg_poly_order)
462
+ n_kernel_pixels = ky * kx
463
+
464
+ poly_k = _poly_terms_2d(x_norm, y_norm, kernel_poly_order) # (n_kpoly, ny, nx)
465
+ poly_bg = _poly_terms_2d(x_norm, y_norm, bg_poly_order) # (n_bgpoly, ny, nx)
466
+
467
+ kernel_coeffs = theta[: n_kernel_pixels * n_kpoly].reshape(n_kernel_pixels, n_kpoly)
468
+ bg_coeffs = theta[n_kernel_pixels * n_kpoly :]
469
+
470
+ ny, nx = reference.shape
471
+ npix = ny * nx
472
+
473
+ # model = Σ_α a_α(x,y) · R_α(x,y) where a_α = Σ_β c_{α,β} · P_β
474
+ # Accumulate one offset at a time to avoid materializing the full
475
+ # (n_kernel, npix) arrays of coefficient maps and shifted references.
476
+ poly_k_flat = poly_k.reshape(n_kpoly, npix)
477
+ model_flat = np.zeros(npix, dtype=np.float64)
478
+ with np.errstate(over='ignore', invalid='ignore', divide='ignore'):
479
+ for alpha, (dy, dx) in enumerate(offsets):
480
+ a_map = kernel_coeffs[alpha] @ poly_k_flat # (npix,)
481
+ model_flat += a_map * _shift_image(reference, dy, dx).ravel()
482
+
483
+ model = model_flat.reshape(ny, nx)
484
+
485
+ # Background contribution (vectorized)
486
+ model += np.tensordot(bg_coeffs, poly_bg, axes=(0, 0))
487
+
488
+ return model
489
+
490
+
491
+ def _propagate_template_variance(kernel_coeffs, kernel_shape, kernel_poly_order, var_tmpl):
492
+ """Propagate template variance through the spatially varying kernel.
493
+
494
+ var_conv(x,y) = Σ_α a_α²(x,y) · var_tmpl(x - offset_α)
495
+
496
+ where a_α(x,y) are the kernel coefficient maps. This is the template
497
+ noise contribution to the difference image (and to proper fit weights).
498
+
499
+ Parameters
500
+ ----------
501
+ kernel_coeffs : (n_kernel, n_kpoly) solved kernel coefficients
502
+ kernel_shape : (ky, kx)
503
+ kernel_poly_order : polynomial order of kernel spatial variation
504
+ var_tmpl : (ny, nx) template variance map
505
+
506
+ Returns
507
+ -------
508
+ var_conv : (ny, nx) kernel-convolved template variance
509
+ """
510
+ ny, nx = var_tmpl.shape
511
+ npix = ny * nx
512
+ offsets = _kernel_offsets(*kernel_shape)
513
+ n_kpoly = _n_poly(kernel_poly_order)
514
+
515
+ x_norm, y_norm = _norm_coords(ny, nx)
516
+ poly_k_flat = _poly_terms_2d(x_norm, y_norm, kernel_poly_order).reshape(n_kpoly, npix)
517
+
518
+ # Accumulate one offset at a time, computing each coefficient map
519
+ # a_α(x,y) = kernel_coeffs[α] @ poly_k on the fly to avoid
520
+ # materializing the full (n_kernel, npix) array
521
+ var_conv = np.zeros(npix, dtype=np.float64)
522
+ with np.errstate(over='ignore', invalid='ignore', divide='ignore'):
523
+ for alpha, (dy, dx) in enumerate(offsets):
524
+ a_map = kernel_coeffs[alpha] @ poly_k_flat # (npix,)
525
+ shifted_var = _shift_image(var_tmpl, dy, dx).ravel()
526
+ var_conv += a_map**2 * shifted_var
527
+
528
+ return var_conv.reshape(ny, nx)
529
+
530
+
531
+ def _extract_flux_poly(theta, kernel_shape, kernel_poly_order, flux_poly_order):
532
+ """Extract the fitted flux-scale polynomial from kernel coefficients.
533
+
534
+ flux(x,y) = Σ_α a_α(x,y) = Σ_β [Σ_α c_{α,β}] · P_β(x,y)
535
+
536
+ Only terms with total degree ≤ flux_poly_order contribute (higher
537
+ terms are constrained to zero by the kernel-sum constraint).
538
+
539
+ Returns
540
+ -------
541
+ flux_coeffs : (n_flux,) polynomial coefficients for the flux scale
542
+ """
543
+ n_kernel_pixels = kernel_shape[0] * kernel_shape[1]
544
+ n_kpoly = _n_poly(kernel_poly_order)
545
+ n_flux = _n_poly(flux_poly_order)
546
+
547
+ kernel_coeffs = theta[: n_kernel_pixels * n_kpoly].reshape(n_kernel_pixels, n_kpoly)
548
+
549
+ # Sum over all kernel pixels for each poly term
550
+ sum_per_poly = kernel_coeffs.sum(axis=0) # (n_kpoly,)
551
+
552
+ # Return only the flux_poly_order terms
553
+ return sum_per_poly[:n_flux]
554
+
555
+
556
+ # ---------------------------------------------------------------------------
557
+ # Public API
558
+ # ---------------------------------------------------------------------------
559
+
560
+
561
+ def solve(
562
+ image,
563
+ template,
564
+ mask=None,
565
+ template_mask=None,
566
+ err=None,
567
+ template_err=None,
568
+ kernel_shape=(7, 7),
569
+ kernel_poly_order=2,
570
+ bg_poly_order=2,
571
+ flux_poly_order=1,
572
+ flux_penalty=1e3,
573
+ ridge=1e-6,
574
+ sigma_clip=3.0,
575
+ max_iter=5,
576
+ verbose=False,
577
+ ):
578
+ """SFFT image subtraction with spatially varying kernel.
579
+
580
+ Solves for a spatially varying convolution kernel and differential
581
+ background in a single global least-squares problem. The kernel
582
+ at each pixel is modelled as a delta-function basis with polynomial
583
+ spatial variation. A soft constraint enforces that the kernel sum
584
+ varies smoothly as a low-order polynomial (modelling flux-scale
585
+ differences between science and template).
586
+
587
+ Iterative sigma-clipping rejects outlier pixels (transients, cosmic
588
+ rays, artifacts) that would otherwise bias the kernel solution.
589
+
590
+ Since the template acts as the basis of the model, any template defect
591
+ (NaN/Inf or ``template_mask``) corrupts the model within a full kernel
592
+ footprint around it. Such pixels are excluded from the fit, and flagged
593
+ in the ``dmask`` attribute of the result — the difference image values
594
+ there (and in the kernel half-width band along image edges) are
595
+ unreliable and should not be searched for transients.
596
+
597
+ Parameters
598
+ ----------
599
+ image : numpy.ndarray
600
+ Science image as a 2-D NumPy array.
601
+ template : numpy.ndarray
602
+ Template/reference image, same shape, aligned to science.
603
+ mask : numpy.ndarray, optional
604
+ Boolean mask where True = bad pixels to exclude from the fit
605
+ (science-side defects, or regions to ignore). Unlike template
606
+ defects, these only affect their own pixel.
607
+ template_mask : numpy.ndarray, optional
608
+ Mask of template defects (True = bad). Grown by the kernel footprint,
609
+ since the model at every pixel within a kernel half-width of a
610
+ template defect depends on the missing data. Pass template-side
611
+ defects here rather than merging them into ``mask``.
612
+ err : numpy.ndarray, optional
613
+ Per-pixel error (standard deviation) map for inverse-variance
614
+ weighting. If None, uniform weights are used. A constant error map
615
+ (or True) is equivalent to uniform weighting and does not change
616
+ the solution (unless ``template_err`` is also given).
617
+ template_err : numpy.ndarray, optional
618
+ Per-pixel error map of the template. If given, one reweighting pass
619
+ is performed: after an initial solve, the template variance is
620
+ propagated through the fitted kernel and the weights are rebuilt as
621
+ ``1 / (err² + Σ_α a_α² · template_err²)``, followed by a re-solve.
622
+ This properly de-weights pixels dominated by template noise and
623
+ matters when the template is not much deeper than the science image.
624
+ Costs one extra solver pass.
625
+ kernel_shape : tuple of int, optional
626
+ (ky, kx) size of the convolution kernel, must be odd. Default (7, 7).
627
+ Larger kernels handle bigger PSF differences but are slower.
628
+ kernel_poly_order : int, optional
629
+ Polynomial order for spatial variation of each kernel coefficient.
630
+ Default 2 (quadratic). Higher orders capture more complex PSF variation
631
+ but need more pixels.
632
+ bg_poly_order : int, optional
633
+ Polynomial order for the differential background model. Default 2.
634
+ flux_poly_order : int, optional
635
+ Polynomial order for the kernel-sum constraint (flux scale variation).
636
+ Default 1 (linear gradient). Set to 0 for constant flux scale.
637
+ flux_penalty : float, optional
638
+ Dimensionless penalty weight for the kernel-sum constraint, relative
639
+ to the median diagonal of the normal matrix (so its effect does not
640
+ depend on image size or flux units). Default 1e3, which enforces the
641
+ constraint nearly strictly. Set to 0 to disable the constraint
642
+ entirely.
643
+ ridge : float, optional
644
+ Dimensionless Tikhonov regularization, relative to the median
645
+ diagonal of the normal matrix. Default 1e-6.
646
+ sigma_clip : float, optional
647
+ Sigma threshold for iterative outlier rejection. Default 3.0. Set to
648
+ None or 0 to disable clipping.
649
+ max_iter : int, optional
650
+ Maximum number of sigma-clipping iterations. Default 5. Must be at
651
+ least 1. The reweighting pass triggered by ``template_err`` is
652
+ performed in addition to these.
653
+ verbose : bool or callable, optional
654
+ If True, print progress. If callable, use as log function.
655
+
656
+ Returns
657
+ -------
658
+ SFFTResult
659
+ Result object with difference image and all fit metadata. Pixels
660
+ flagged in its ``dmask`` attribute are unreliable in the difference.
661
+ """
662
+
663
+ log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
664
+
665
+ # --- Input validation and preparation ---
666
+ sci = np.asarray(image, dtype=np.float64)
667
+ ref = np.asarray(template, dtype=np.float64)
668
+
669
+ if sci.shape != ref.shape:
670
+ raise ValueError(
671
+ "science and template must have the same shape, got %s vs %s" % (sci.shape, ref.shape)
672
+ )
673
+ if sci.ndim != 2:
674
+ raise ValueError("images must be 2-D arrays")
675
+
676
+ ny, nx = sci.shape
677
+ ky, kx = kernel_shape
678
+
679
+ if ky % 2 == 0 or kx % 2 == 0:
680
+ raise ValueError("kernel_shape must be odd, got (%d, %d)" % (ky, kx))
681
+
682
+ if flux_poly_order > kernel_poly_order:
683
+ raise ValueError(
684
+ "flux_poly_order (%d) must be <= kernel_poly_order (%d)"
685
+ % (flux_poly_order, kernel_poly_order)
686
+ )
687
+
688
+ if max_iter < 1:
689
+ raise ValueError("max_iter must be at least 1, got %d" % max_iter)
690
+
691
+ # Replace NaN/Inf with 0 to prevent propagation through matmuls.
692
+ # These pixels are masked via the weight map, so the values don't matter.
693
+ nan_sci = ~np.isfinite(sci)
694
+ nan_ref = ~np.isfinite(ref)
695
+ if np.any(nan_sci) or np.any(nan_ref):
696
+ sci = sci.copy()
697
+ ref = ref.copy()
698
+ sci[nan_sci] = 0.0
699
+ ref[nan_ref] = 0.0
700
+
701
+ n_kernel_pixels = ky * kx
702
+ n_kpoly = _n_poly(kernel_poly_order)
703
+ n_bgpoly = _n_poly(bg_poly_order)
704
+ n_kernel_params = n_kernel_pixels * n_kpoly
705
+ n_total = n_kernel_params + n_bgpoly
706
+
707
+ n_fpoly = _n_poly(flux_poly_order)
708
+ log(
709
+ "SFFT: image %dx%d, kernel %dx%d, kernel_poly=%d (%d terms), "
710
+ "bg_poly=%d (%d terms), flux_poly=%d (%d terms)"
711
+ % (
712
+ nx,
713
+ ny,
714
+ kx,
715
+ ky,
716
+ kernel_poly_order,
717
+ n_kpoly,
718
+ bg_poly_order,
719
+ n_bgpoly,
720
+ flux_poly_order,
721
+ n_fpoly,
722
+ )
723
+ )
724
+ log(
725
+ "SFFT: %d kernel + %d background = %d total parameters"
726
+ % (n_kernel_params, n_bgpoly, n_total)
727
+ )
728
+
729
+ # --- Build good-pixel mask ---
730
+ # `good` tracks which pixels participate in the fit; it only ever shrinks
731
+ # (masking, clipping). Float weights are derived from it separately so
732
+ # that reweighting cannot resurrect clipped pixels.
733
+ good = np.ones((ny, nx), dtype=bool)
734
+
735
+ if mask is not None:
736
+ good[np.asarray(mask, dtype=bool)] = False
737
+
738
+ # Science NaN/Inf pixels only corrupt their own residual
739
+ if np.any(nan_sci):
740
+ good[nan_sci] = False
741
+
742
+ # Template defects (masked or non-finite) corrupt the model everywhere
743
+ # the kernel footprint overlaps them: the shifted template values enter
744
+ # the design matrix of every pixel within a kernel half-width. Grow the
745
+ # defect mask by the kernel footprint before excluding those pixels.
746
+ bad_tmpl = nan_ref.copy()
747
+ if template_mask is not None:
748
+ bad_tmpl |= np.asarray(template_mask, dtype=bool)
749
+
750
+ dmask = np.zeros((ny, nx), dtype=bool)
751
+ if np.any(bad_tmpl):
752
+ for dy, dx in _kernel_offsets(ky, kx):
753
+ dmask |= _shift_image(bad_tmpl, dy, dx)
754
+ n_grown = int(np.sum(dmask & ~bad_tmpl))
755
+ if n_grown:
756
+ log(
757
+ "SFFT: template defects grown by kernel footprint: "
758
+ "%d -> %d pixels" % (int(np.sum(bad_tmpl)), int(np.sum(dmask)))
759
+ )
760
+ good[dmask] = False
761
+
762
+ # Edge pixels are likewise affected by zero-filled kernel shifts
763
+ hy, hx = ky // 2, kx // 2
764
+ if hy > 0:
765
+ good[:hy, :] = False
766
+ good[-hy:, :] = False
767
+ dmask[:hy, :] = True
768
+ dmask[-hy:, :] = True
769
+ if hx > 0:
770
+ good[:, :hx] = False
771
+ good[:, -hx:] = False
772
+ dmask[:, :hx] = True
773
+ dmask[:, -hx:] = True
774
+
775
+ # --- Inverse-variance base weights ---
776
+ def _make_weight(var_total):
777
+ """Weight map 1/var with invalid entries zeroed; None = uniform."""
778
+ w = np.ones((ny, nx), dtype=np.float64)
779
+ if var_total is not None:
780
+ valid = (var_total > 0) & np.isfinite(var_total)
781
+ w[valid] = 1.0 / var_total[valid]
782
+ w[~valid] = 0.0
783
+ return w
784
+
785
+ if err is not None and err is not True:
786
+ err_map = np.asarray(err, dtype=np.float64)
787
+ var_sci = err_map**2
788
+ else:
789
+ var_sci = None
790
+ if err is True:
791
+ # A constant error map rescales all weights uniformly and does
792
+ # not change the solution, so uniform weighting is used instead.
793
+ log("SFFT: err=True has no effect; pass a per-pixel error map for weighting")
794
+
795
+ if template_err is not None:
796
+ template_err = np.asarray(template_err, dtype=np.float64)
797
+ if template_err.shape != (ny, nx):
798
+ raise ValueError("template_err must have the same shape as the images")
799
+
800
+ base_weight = _make_weight(var_sci)
801
+
802
+ # --- Normalized coordinates ---
803
+ x_norm, y_norm = _norm_coords(ny, nx)
804
+
805
+ # --- Build kernel-sum constraint ---
806
+ C_kernel, _ = _build_kernel_sum_constraint(
807
+ kernel_shape, kernel_poly_order, flux_poly_order
808
+ )
809
+
810
+ if C_kernel is not None and flux_penalty > 0:
811
+ log(
812
+ "SFFT: kernel-sum constraint: %d equations, flux_poly_order=%d, "
813
+ "penalty=%.1e" % (C_kernel.shape[0], flux_poly_order, flux_penalty)
814
+ )
815
+ # The constraint only involves kernel parameters; its normal-equation
816
+ # contribution (CᵀC) is constant across iterations
817
+ with np.errstate(over='ignore', invalid='ignore', divide='ignore'):
818
+ CtC_kernel = C_kernel.T @ C_kernel # (n_kernel_params, n_kernel_params)
819
+ else:
820
+ CtC_kernel = None
821
+
822
+ # --- Iterative sigma-clipping solve ---
823
+ do_clip = sigma_clip is not None and sigma_clip > 0 and max_iter > 1
824
+ # One extra solver pass to rebuild the weights with template noise
825
+ # propagated through the fitted kernel (which needs a first-pass solve)
826
+ reweight_pending = template_err is not None
827
+ total_iters = max_iter + (1 if reweight_pending else 0)
828
+
829
+ n_iter_done = 0
830
+ final_rms = np.nan
831
+ # Accumulated data-term normal equations (upper-block form). Kept pristine
832
+ # (no penalty/ridge) so clipped pixels can be downdated incrementally;
833
+ # set to None whenever the weights change globally (reweighting).
834
+ H_data = None
835
+ g_data = None
836
+
837
+ for iteration in range(total_iters):
838
+ n_iter_done = iteration + 1
839
+ weight = np.where(good, base_weight, 0.0)
840
+ n_good = int(np.sum(weight > 0))
841
+
842
+ if n_good < n_total + 10:
843
+ raise RuntimeError("Too few good pixels (%d) for %d parameters" % (n_good, n_total))
844
+
845
+ if H_data is None:
846
+ # Assemble normal equations (processed in row bands to bound memory)
847
+ log("SFFT: assembling normal equations (%d parameters)" % n_total)
848
+ H_data, g_data = _assemble_normal_equations(
849
+ ref, sci, weight, kernel_shape, kernel_poly_order, bg_poly_order, x_norm, y_norm
850
+ )
851
+ else:
852
+ log("SFFT: reusing normal equations downdated by clipped pixels")
853
+
854
+ H = _mirror_normal_matrix(H_data.copy(), n_kernel_params, n_kpoly)
855
+ g = g_data.copy()
856
+
857
+ # Typical magnitude of the data term, used to make the penalty and
858
+ # ridge scale-free (independent of image size, flux units, weights)
859
+ h_diag = np.diagonal(H)[:n_kernel_params]
860
+ h_pos = h_diag[h_diag > 0]
861
+ h_scale = np.median(h_pos) if len(h_pos) else 1.0
862
+
863
+ # Add kernel-sum penalty to normal equations
864
+ if CtC_kernel is not None:
865
+ H[:n_kernel_params, :n_kernel_params] += (flux_penalty * h_scale) * CtC_kernel
866
+
867
+ # Solve with regularization; H is symmetric positive-definite,
868
+ # so try Cholesky first
869
+ H[np.diag_indices(n_total)] += ridge * h_scale
870
+ try:
871
+ theta = scipy_linalg.solve(H, g, assume_a='pos')
872
+ except np.linalg.LinAlgError:
873
+ log("SFFT: WARNING - singular normal matrix, increasing ridge")
874
+ H[np.diag_indices(n_total)] += 1e-3 * h_scale
875
+ theta = np.linalg.solve(H, g)
876
+
877
+ # Reconstruct model and compute residuals
878
+ model = _reconstruct_model(
879
+ theta, ref, kernel_shape, kernel_poly_order, bg_poly_order, x_norm, y_norm
880
+ )
881
+ residual = sci - model
882
+
883
+ # Compute RMS on good pixels
884
+ good_mask = weight > 0
885
+ if np.any(good_mask):
886
+ final_rms = np.sqrt(np.mean(residual[good_mask] ** 2))
887
+ else:
888
+ final_rms = np.nan
889
+
890
+ log(
891
+ "SFFT: iteration %d/%d: n_good=%d, rms=%.4f"
892
+ % (iteration + 1, total_iters, n_good, final_rms)
893
+ )
894
+
895
+ # Reweighting pass: rebuild the weights including template noise
896
+ # propagated through the first-pass kernel, then re-solve
897
+ if reweight_pending:
898
+ reweight_pending = False
899
+ kc = theta[:n_kernel_params].reshape(n_kernel_pixels, n_kpoly)
900
+ var_conv = _propagate_template_variance(
901
+ kc, kernel_shape, kernel_poly_order, template_err**2
902
+ )
903
+ if var_sci is not None:
904
+ var_total = var_sci + var_conv
905
+ else:
906
+ # Science noise unknown: estimate a constant floor from the
907
+ # residual scatter in excess of the template contribution
908
+ abs_resid = np.abs(residual)
909
+ robust_var = (np.median(abs_resid[good_mask]) * 1.4826) ** 2
910
+ var_sci_const = max(robust_var - np.median(var_conv[good_mask]), 0.0)
911
+ var_total = var_sci_const + var_conv
912
+ log(
913
+ "SFFT: science noise not given, using constant variance %.4g "
914
+ "estimated from residuals" % var_sci_const
915
+ )
916
+ base_weight = _make_weight(var_total)
917
+ # Weights changed globally — the accumulated data term is invalid
918
+ H_data = None
919
+ g_data = None
920
+ log("SFFT: reweighting with kernel-propagated template noise")
921
+ continue
922
+
923
+ # Sigma-clipping
924
+ if not do_clip or iteration == total_iters - 1:
925
+ break
926
+
927
+ if np.any(good_mask) and np.isfinite(final_rms) and final_rms > 0:
928
+ # Use MAD for robust scale estimate
929
+ abs_resid = np.abs(residual)
930
+ robust_sigma = np.median(abs_resid[good_mask]) * 1.4826
931
+ if robust_sigma > 0:
932
+ newly_clipped = (abs_resid > sigma_clip * robust_sigma) & good_mask
933
+ n_clipped = int(np.sum(newly_clipped))
934
+ if n_clipped == 0:
935
+ log("SFFT: no pixels clipped, converged")
936
+ break
937
+
938
+ good[newly_clipped] = False
939
+ if _USE_INCREMENTAL_UPDATES:
940
+ # Remove just the clipped pixels' contributions instead
941
+ # of reassembling from the full image (exact: all terms
942
+ # are linear in the pixel weight)
943
+ ys, xs = np.nonzero(newly_clipped)
944
+ _downdate_normal_equations(
945
+ H_data, g_data, ref, sci, weight[ys, xs], ys, xs,
946
+ kernel_shape, kernel_poly_order, bg_poly_order, x_norm, y_norm,
947
+ )
948
+ else:
949
+ H_data = None
950
+ g_data = None
951
+ log("SFFT: clipped %d pixels (%.3f%%)" % (n_clipped, 100.0 * n_clipped / n_good))
952
+ else:
953
+ break
954
+ else:
955
+ # No usable residual statistics — further iterations would
956
+ # re-solve the identical system
957
+ break
958
+
959
+ # --- Extract results ---
960
+ kernel_coeffs = theta[:n_kernel_params].reshape(n_kernel_pixels, n_kpoly)
961
+ bg_coeffs = theta[n_kernel_params:]
962
+ flux_coeffs = _extract_flux_poly(theta, kernel_shape, kernel_poly_order, flux_poly_order)
963
+
964
+ diff = sci - model
965
+ n_good_final = int(np.sum(np.where(good, base_weight, 0.0) > 0))
966
+
967
+ log(
968
+ "SFFT: done. Final RMS=%.4f, n_good=%d (%.1f%%)"
969
+ % (final_rms, n_good_final, 100.0 * n_good_final / (ny * nx))
970
+ )
971
+ log(
972
+ "SFFT: flux scale polynomial coeffs: %s"
973
+ % np.array2string(flux_coeffs, precision=4, separator=', ')
974
+ )
975
+
976
+ return SFFTResult(
977
+ diff=diff,
978
+ model=model,
979
+ kernel_coeffs=kernel_coeffs,
980
+ bg_coeffs=bg_coeffs,
981
+ kernel_shape=kernel_shape,
982
+ kernel_poly_order=kernel_poly_order,
983
+ bg_poly_order=bg_poly_order,
984
+ flux_poly_order=flux_poly_order,
985
+ flux_poly_coeffs=flux_coeffs,
986
+ n_iter=n_iter_done,
987
+ rms=float(final_rms),
988
+ n_good=n_good_final,
989
+ dmask=dmask,
990
+ )
991
+
992
+
993
+ # ---------------------------------------------------------------------------
994
+ # Convenience functions
995
+ # ---------------------------------------------------------------------------
996
+
997
+
998
+ def evaluate_kernel_at(result, x, y, image_shape):
999
+ """Evaluate the spatially varying kernel at a single image position.
1000
+
1001
+ The returned stamp is the convolution kernel in the usual sense:
1002
+ ``model(p) = Σ k[dy, dx] · template(p - (dy, dx))``, with the element
1003
+ ``k[hy + dy, hx + dx]`` at array index ``(hy + dy, hx + dx)`` for
1004
+ half-sizes ``hy, hx``.
1005
+
1006
+ Parameters
1007
+ ----------
1008
+ result : SFFTResult
1009
+ :class:`SFFTResult` from :func:`solve`.
1010
+ x : float
1011
+ X pixel coordinate.
1012
+ y : float
1013
+ Y pixel coordinate.
1014
+ image_shape : tuple of int
1015
+ (ny, nx) of the original image.
1016
+
1017
+ Returns
1018
+ -------
1019
+ numpy.ndarray
1020
+ 2-D array of shape ``result.kernel_shape``.
1021
+ """
1022
+ xn, yn = _norm_xy(x, y, image_shape)
1023
+
1024
+ poly_k = _poly_terms_2d(xn, yn, result.kernel_poly_order) # (n_kpoly,)
1025
+
1026
+ kernel_vals = result.kernel_coeffs @ poly_k # (n_kernel_pixels,)
1027
+ return kernel_vals.reshape(result.kernel_shape)
1028
+
1029
+
1030
+ def evaluate_flux_scale(result, x, y, image_shape):
1031
+ """Evaluate the flux-scale polynomial at image position(s).
1032
+
1033
+ Parameters
1034
+ ----------
1035
+ result : SFFTResult
1036
+ :class:`SFFTResult` from :func:`solve`.
1037
+ x : float or array-like
1038
+ X coordinate(s), scalar or array.
1039
+ y : float or array-like
1040
+ Y coordinate(s), scalar or array.
1041
+ image_shape : tuple of int
1042
+ (ny, nx) of the original image.
1043
+
1044
+ Returns
1045
+ -------
1046
+ float or numpy.ndarray
1047
+ Flux scale value(s), same shape as x/y.
1048
+ """
1049
+ xn, yn = _norm_xy(x, y, image_shape)
1050
+
1051
+ poly = _poly_terms_2d(xn, yn, result.flux_poly_order)
1052
+ return np.tensordot(result.flux_poly_coeffs, poly, axes=(0, 0))
1053
+
1054
+
1055
+ def evaluate_background(result, x, y, image_shape):
1056
+ """Evaluate the differential background model at image position(s).
1057
+
1058
+ Parameters
1059
+ ----------
1060
+ result : SFFTResult
1061
+ :class:`SFFTResult` from :func:`solve`.
1062
+ x : float or array-like
1063
+ X coordinate(s), scalar or array.
1064
+ y : float or array-like
1065
+ Y coordinate(s), scalar or array.
1066
+ image_shape : tuple of int
1067
+ (ny, nx) of the original image.
1068
+
1069
+ Returns
1070
+ -------
1071
+ float or numpy.ndarray
1072
+ Background value(s), same shape as x/y.
1073
+ """
1074
+ xn, yn = _norm_xy(x, y, image_shape)
1075
+
1076
+ poly = _poly_terms_2d(xn, yn, result.bg_poly_order)
1077
+ return np.tensordot(result.bg_coeffs, poly, axes=(0, 0))