noobase 0.0.3

Foundational pure-function utilities for astronomy analysis
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
//! Forward PSF model: oversampled effective PSF -> predicted detector
//! stamps.
//!
//! Given an oversampled effective-PSF model `epsf` and, for each stamp in
//! a batch, a sub-pixel offset, a flux and a background, this renders the
//! predicted detector-resolution stamp by *point sampling* the model with
//! a separable bicubic Catmull-Rom kernel. The pixel response (Pi) is
//! considered already absorbed into the empirical effective PSF, so the
//! sampler is a point evaluation, not a box/area integration.
//!
//! Design notes:
//!
//! - **Model grid / oversample convention.** `epsf` is a square
//!   `(os*s, os*s)` array where `os = oversample` is forced odd and
//!   `s = stamp_size` is the (odd) detector stamp edge. `s` is *derived*
//!   from `epsf.shape()` and `os`, never passed separately, so the two
//!   can never disagree. The model is evaluated through the interpolation
//!   kernel (never read by integer index), so it carries no load-bearing
//!   parity of its own, but `os*s` is kept odd so the global center cell
//!   `K_c = (os*s - 1) / 2` lands exactly on the PSF center and the
//!   detector center `c_det = (s - 1) / 2` nests symmetrically inside it.
//!
//! - **Sampling.** The source center of stamp `n` sits at detector
//!   coordinate `c_det + delta_n`. Detector pixel `(i, j)` therefore maps
//!   to model coordinates
//!   `k_u = K_c + os * ((i - c_det) - delta_row)`,
//!   `k_v = K_c + os * ((j - c_det) - delta_col)`,
//!   and the prediction is
//!   `pred = flux * CatmullRom(epsf; k_u, k_v) + background`.
//!   The `delta` sign is fixed by this formula: it is exactly the offset
//!   reported by `build_stamp` (`centroid - round(centroid)`), so feeding
//!   a `build_stamp` delta straight back in reproduces the source at its
//!   true sub-pixel location (pinned by a round-trip test).
//!
//! - **Odd `os` is an intentional, scoped convention.** With `s` odd and
//!   `os` odd, `os*s` is odd, so a `delta = 0` source lands on integer
//!   model coordinates (`k_u = K_c + os*(i - c_det)`), the Catmull-Rom
//!   weights collapse to `[0, 1, 0, 0]`, and the render reduces to a
//!   zero-interpolation grid extraction. An even `os` would force the PSF
//!   center onto a cell boundary and interpolate even at `delta = 0`;
//!   that is rejected up front (`OversampleNotOdd`). Tools such as
//!   photutils do not impose this; the restriction is deliberate here for
//!   the exact-nesting property above.
//!
//! - **Interpolation kernel.** Separable bicubic Catmull-Rom (Keys cubic
//!   convolution with `a = -1/2`), four taps per axis, provided by the
//!   shared [`super::kernel`] module. It is a pure-Rust, psf-internal
//!   helper that never crosses the FFI boundary and is never visible to
//!   the caller. The adjoint ([`super::accumulate`]) is the *exact*
//!   transpose of this sampler because it scatters the very same per-tap
//!   weights from that shared module (pinned by the transpose-identity
//!   test).
//!
//! - **Zero padding.** Model taps that fall outside `[0, os*s)` on either
//!   axis contribute zero. A source pushed entirely off the model grid
//!   therefore renders to a flat `background` stamp.
//!
//! - **Errors are hard preconditions only.** Every [`RenderError`] is a
//!   shape/parity precondition; there is no algorithmic `Ok(None)` path
//!   (contrast `build_stamp`). The model is `f64` end to end (decision:
//!   the internal effective-PSF model is always `f64`), so `render` is
//!   not generic over the float type.

use ndarray::{Array3, ArrayView1, ArrayView2, ArrayViewMut2, Axis};
use rayon::prelude::*;
use thiserror::Error;

use super::kernel::catmull_rom_sample;

/// Errors returned by [`render`] for ill-shaped / ill-parameterized
/// inputs.
///
/// All variants are hard preconditions. Unlike `build_stamp`, `render`
/// has no algorithmic skip path: a well-formed call always yields an
/// `(N, s, s)` array.
#[derive(Debug, Error, PartialEq)]
pub enum RenderError {
    #[error("epsf must be square; got ({rows}, {cols})")]
    EpsfNotSquare { rows: usize, cols: usize },
    #[error("oversample must be odd; got {oversample}")]
    OversampleNotOdd { oversample: usize },
    #[error("epsf side ({epsf_side}) must be an integer multiple of oversample ({oversample})")]
    EpsfSizeNotMultiple { epsf_side: usize, oversample: usize },
    #[error("derived stamp_size (epsf_side / oversample) must be odd; got {stamp_size}")]
    DerivedStampSizeEven { stamp_size: usize },
    #[error(
        "batch dimensions disagree: delta shape {delta:?} must be (N, 2) with N == flux len ({flux}) == background len ({background})"
    )]
    BatchLengthMismatch {
        delta: (usize, usize),
        flux: usize,
        background: usize,
    },
}

/// Render a batch of predicted detector stamps from an oversampled
/// effective-PSF model.
///
/// See the module-level documentation for the model-grid / oversample
/// convention, the sampling formula and `delta` sign, the odd-`os`
/// rationale, and the zero-padding rule.
///
/// # Parameters
///
/// - `epsf`: oversampled effective PSF, square `(os*s, os*s)`. Always
///   `f64` (the internal model is `f64` end to end).
/// - `oversample`: `os`, forced odd.
/// - `delta`: `(N, 2)` per-stamp sub-pixel offset `(row, col)`, as
///   produced by `build_stamp` (`centroid - round(centroid)`).
/// - `flux`: `(N,)` per-stamp scale `f_i`.
/// - `background`: `(N,)` per-stamp constant offset `b_i`.
///
/// # Returns
///
/// `Ok(Array3<f64>)` of shape `(N, s, s)`, where `s = (os*s) / os` is
/// derived from `epsf.shape()` and `oversample`. Stamp `n` is
/// `flux[n] * CatmullRom(epsf; ...) + background[n]`.
///
/// # Errors
///
/// Returns a [`RenderError`] if `epsf` is not square, if `oversample` is
/// not odd, if the `epsf` side is not an integer multiple of
/// `oversample`, if the derived `stamp_size` is even, or if `delta`,
/// `flux` and `background` do not share a consistent batch length
/// (`delta` must be `(N, 2)`).
pub fn render(
    epsf: ArrayView2<f64>,
    oversample: usize,
    delta: ArrayView2<f64>,
    flux: ArrayView1<f64>,
    background: ArrayView1<f64>,
) -> Result<Array3<f64>, RenderError> {
    let epsf_rows = epsf.shape()[0];
    let epsf_cols = epsf.shape()[1];

    // --- Hard preconditions (returned as Err; no Ok(None) path). ---
    if epsf_rows != epsf_cols {
        return Err(RenderError::EpsfNotSquare {
            rows: epsf_rows,
            cols: epsf_cols,
        });
    }
    if oversample.is_multiple_of(2) {
        // Also rejects oversample == 0 (0 is even, hence not odd).
        return Err(RenderError::OversampleNotOdd { oversample });
    }
    let epsf_side = epsf_rows;
    if !epsf_side.is_multiple_of(oversample) {
        return Err(RenderError::EpsfSizeNotMultiple {
            epsf_side,
            oversample,
        });
    }
    let stamp_size = epsf_side / oversample;
    if stamp_size.is_multiple_of(2) {
        // Catches the degenerate empty model (stamp_size == 0) too.
        return Err(RenderError::DerivedStampSizeEven { stamp_size });
    }
    let batch_size = flux.len();
    if delta.shape()[1] != 2 || delta.shape()[0] != batch_size || background.len() != batch_size {
        return Err(RenderError::BatchLengthMismatch {
            delta: (delta.shape()[0], delta.shape()[1]),
            flux: batch_size,
            background: background.len(),
        });
    }

    // `epsf_side` and `stamp_size` are both odd, so these centers are
    // exact integers in f64 (no rounding): the delta = 0 source lands on
    // an integer model coordinate and the kernel collapses to a pick.
    let psf_center = (epsf_side as f64 - 1.0) / 2.0;
    let detector_center = (stamp_size as f64 - 1.0) / 2.0;
    let oversample_f = oversample as f64;

    let mut output = Array3::<f64>::zeros((batch_size, stamp_size, stamp_size));

    // Stamps are independent: each writes only its own `(s, s)` slice and
    // reads only the shared immutable model and per-stamp scalars. Drive
    // the batch in parallel via rayon (the iterative solver's hot path).
    output
        .axis_iter_mut(Axis(0))
        .into_par_iter()
        .enumerate()
        .for_each(|(stamp_index, stamp_out)| {
            render_stamp(
                stamp_out,
                epsf,
                delta[(stamp_index, 0)],
                delta[(stamp_index, 1)],
                flux[stamp_index],
                background[stamp_index],
                oversample_f,
                stamp_size,
                psf_center,
                detector_center,
            );
        });

    Ok(output)
}

/// Render one detector stamp in place. Factored out of the driver so the
/// parallel and any future sequential driver share the exact same
/// per-stamp work unit.
#[allow(clippy::too_many_arguments)]
fn render_stamp(
    mut stamp_out: ArrayViewMut2<f64>,
    epsf: ArrayView2<f64>,
    delta_row: f64,
    delta_column: f64,
    flux: f64,
    background: f64,
    oversample: f64,
    stamp_size: usize,
    psf_center: f64,
    detector_center: f64,
) {
    for i in 0..stamp_size {
        let k_u = psf_center + oversample * ((i as f64 - detector_center) - delta_row);
        for j in 0..stamp_size {
            let k_v = psf_center + oversample * ((j as f64 - detector_center) - delta_column);
            stamp_out[(i, j)] = flux * catmull_rom_sample(&epsf, k_u, k_v) + background;
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::image::psf::kernel::{catmull_rom_sample, catmull_rom_weights};
    use ndarray::{Array1, Array2, arr2};

    const TOL: f64 = 1e-9;

    /// Oversampled Gaussian effective PSF of side `epsf_side`, centered on
    /// the global center cell `K_c = (epsf_side - 1) / 2`, with standard
    /// deviation `sigma_model` in model-grid pixels and peak `amplitude`.
    fn gaussian_epsf(epsf_side: usize, sigma_model: f64, amplitude: f64) -> Array2<f64> {
        let center = (epsf_side as f64 - 1.0) / 2.0;
        let mut model = Array2::<f64>::zeros((epsf_side, epsf_side));
        for p in 0..epsf_side {
            for q in 0..epsf_side {
                let dp = p as f64 - center;
                let dq = q as f64 - center;
                model[(p, q)] =
                    amplitude * (-0.5 * (dp * dp + dq * dq) / (sigma_model * sigma_model)).exp();
            }
        }
        model
    }

    /// A deterministic, non-separable, non-symmetric model so an
    /// off-by-row/col or transposed indexing bug cannot hide.
    fn ramp_epsf(epsf_side: usize) -> Array2<f64> {
        let mut model = Array2::<f64>::zeros((epsf_side, epsf_side));
        for p in 0..epsf_side {
            for q in 0..epsf_side {
                model[(p, q)] = 1.0 + p as f64 + 3.0 * q as f64 + 0.5 * (p * q) as f64;
            }
        }
        model
    }

    #[test]
    fn catmull_rom_weights_partition_of_unity_and_integer_pick() {
        // At frac = 0 the kernel must collapse to a pure pick.
        assert_eq!(catmull_rom_weights(0.0), [0.0, 1.0, 0.0, 0.0]);
        // Weights sum to one for arbitrary fractions.
        for &frac in &[0.1, 0.25, 0.5, 0.7, 0.9999] {
            let weights = catmull_rom_weights(frac);
            let sum: f64 = weights.iter().sum();
            assert!((sum - 1.0).abs() < TOL, "sum = {sum} at frac = {frac}");
        }
    }

    #[test]
    fn delta_zero_is_exact_grid_extraction() {
        // os = 5, s = 7 -> epsf_side = 35, K_c = 17, c_det = 3.
        let oversample = 5;
        let stamp_size = 7;
        let epsf_side = oversample * stamp_size;
        let epsf = ramp_epsf(epsf_side);

        let delta = arr2(&[[0.0, 0.0]]);
        let flux = Array1::from_elem(1, 1.0);
        let background = Array1::from_elem(1, 0.0);

        let out = render(
            epsf.view(),
            oversample,
            delta.view(),
            flux.view(),
            background.view(),
        )
        .unwrap();
        assert_eq!(out.shape(), &[1, stamp_size, stamp_size]);

        let psf_center = (epsf_side - 1) / 2; // 17
        let detector_center = (stamp_size - 1) / 2; // 3
        for i in 0..stamp_size {
            for j in 0..stamp_size {
                // delta = 0 -> integer model coordinate -> exact pick.
                let p = psf_center + oversample * i - oversample * detector_center;
                let q = psf_center + oversample * j - oversample * detector_center;
                assert!(
                    (out[(0, i, j)] - epsf[(p, q)]).abs() < 1e-12,
                    "({i},{j}) rendered {} != model {}",
                    out[(0, i, j)],
                    epsf[(p, q)]
                );
            }
        }
    }

    #[test]
    fn subpixel_shift_matches_analytic_gaussian() {
        // sigma_model = os * sigma_det so the rendered stamp equals an
        // analytic Gaussian with detector-pixel sigma_det.
        let oversample = 5;
        let stamp_size = 9;
        let epsf_side = oversample * stamp_size;
        let sigma_det = 1.5;
        let amplitude = 50.0;
        let epsf = gaussian_epsf(epsf_side, oversample as f64 * sigma_det, amplitude);

        let delta_row = 0.3;
        let delta_col = -0.2;
        let flux = 3.0;
        let back = 2.0;
        let delta = arr2(&[[delta_row, delta_col]]);
        let out = render(
            epsf.view(),
            oversample,
            delta.view(),
            Array1::from_elem(1, flux).view(),
            Array1::from_elem(1, back).view(),
        )
        .unwrap();

        let detector_center = (stamp_size as f64 - 1.0) / 2.0;
        let mut max_abs_error = 0.0_f64;
        for i in 0..stamp_size {
            for j in 0..stamp_size {
                let du = (i as f64 - detector_center) - delta_row;
                let dv = (j as f64 - detector_center) - delta_col;
                let expected =
                    flux * amplitude * (-0.5 * (du * du + dv * dv) / (sigma_det * sigma_det)).exp()
                        + back;
                max_abs_error = max_abs_error.max((out[(0, i, j)] - expected).abs());
            }
        }
        // Catmull-Rom on a well-sampled (sigma_model = 7.5 grid px)
        // Gaussian is far tighter than this; the loose bound still
        // rejects any off-by-os / sign / center bug (those are gross).
        assert!(
            max_abs_error < 5e-3 * flux * amplitude,
            "max abs error = {max_abs_error}"
        );
    }

    #[test]
    fn flux_and_background_are_linear() {
        let oversample = 3;
        let stamp_size = 5;
        let epsf_side = oversample * stamp_size;
        let epsf = gaussian_epsf(epsf_side, oversample as f64 * 1.2, 10.0);
        let delta = arr2(&[[0.27, -0.13]]);

        let unit = render(
            epsf.view(),
            oversample,
            delta.view(),
            Array1::from_elem(1, 1.0).view(),
            Array1::from_elem(1, 0.0).view(),
        )
        .unwrap();
        let flux = 7.5;
        let back = -3.25;
        let scaled = render(
            epsf.view(),
            oversample,
            delta.view(),
            Array1::from_elem(1, flux).view(),
            Array1::from_elem(1, back).view(),
        )
        .unwrap();

        for i in 0..stamp_size {
            for j in 0..stamp_size {
                let expected = flux * unit[(0, i, j)] + back;
                assert!(
                    (scaled[(0, i, j)] - expected).abs() < 1e-12,
                    "({i},{j}) {} != {}",
                    scaled[(0, i, j)],
                    expected
                );
            }
        }
    }

    #[test]
    fn source_pushed_off_grid_renders_flat_background() {
        // A huge delta moves every tap outside the model -> zero-padded
        // sample -> flat `background` stamp (also exercises zero-pad).
        let oversample = 5;
        let stamp_size = 7;
        let epsf_side = oversample * stamp_size;
        let epsf = gaussian_epsf(epsf_side, oversample as f64 * 1.5, 100.0);
        let delta = arr2(&[[1000.0, -1000.0]]);
        let back = 4.0;
        let out = render(
            epsf.view(),
            oversample,
            delta.view(),
            Array1::from_elem(1, 9.0).view(),
            Array1::from_elem(1, back).view(),
        )
        .unwrap();
        for value in out.iter() {
            assert!((value - back).abs() < TOL, "value = {value}");
        }
    }

    #[test]
    fn uniform_model_interior_is_partition_of_unity() {
        // For a uniform model every fully-in-bounds 4x4 stencil sums the
        // separable weights to exactly 1, so interior pixels render to
        // the uniform value regardless of the (fractional) delta. Edge
        // pixels lose clipped taps; assert only the interior, and that
        // nothing is NaN anywhere.
        let oversample = 5;
        let stamp_size = 9;
        let epsf_side = oversample * stamp_size;
        let epsf = Array2::<f64>::from_elem((epsf_side, epsf_side), 1.0);
        let delta = arr2(&[[0.3, 0.3]]);
        let out = render(
            epsf.view(),
            oversample,
            delta.view(),
            Array1::from_elem(1, 1.0).view(),
            Array1::from_elem(1, 0.0).view(),
        )
        .unwrap();

        for value in out.iter() {
            assert!(value.is_finite(), "non-finite render value {value}");
        }
        // Interior: drop a 1-pixel rim so every contributing model tap is
        // safely in bounds for this small delta.
        for i in 1..stamp_size - 1 {
            for j in 1..stamp_size - 1 {
                assert!(
                    (out[(0, i, j)] - 1.0).abs() < 1e-12,
                    "interior ({i},{j}) = {}",
                    out[(0, i, j)]
                );
            }
        }
    }

    #[test]
    fn batch_stamps_are_independent() {
        let oversample = 5;
        let stamp_size = 7;
        let epsf_side = oversample * stamp_size;
        let epsf = gaussian_epsf(epsf_side, oversample as f64 * 1.4, 80.0);

        let delta = arr2(&[[0.10, -0.20], [-0.35, 0.05], [0.40, 0.40]]);
        let flux = Array1::from(vec![1.0, 2.5, 0.7]);
        let background = Array1::from(vec![0.0, -1.0, 3.0]);

        let batch = render(
            epsf.view(),
            oversample,
            delta.view(),
            flux.view(),
            background.view(),
        )
        .unwrap();
        assert_eq!(batch.shape(), &[3, stamp_size, stamp_size]);

        for n in 0..3 {
            let single = render(
                epsf.view(),
                oversample,
                delta.slice(ndarray::s![n..n + 1, ..]),
                flux.slice(ndarray::s![n..n + 1]),
                background.slice(ndarray::s![n..n + 1]),
            )
            .unwrap();
            for i in 0..stamp_size {
                for j in 0..stamp_size {
                    assert!(
                        (batch[(n, i, j)] - single[(0, i, j)]).abs() < 1e-12,
                        "stamp {n} ({i},{j}) differs from single render"
                    );
                }
            }
        }
    }

    #[test]
    fn error_epsf_not_square() {
        let epsf = Array2::<f64>::zeros((10, 15));
        let err = render(
            epsf.view(),
            5,
            arr2(&[[0.0, 0.0]]).view(),
            Array1::from_elem(1, 1.0).view(),
            Array1::from_elem(1, 0.0).view(),
        )
        .unwrap_err();
        assert_eq!(err, RenderError::EpsfNotSquare { rows: 10, cols: 15 });
    }

    #[test]
    fn error_oversample_not_odd() {
        let epsf = Array2::<f64>::zeros((20, 20));
        let err = render(
            epsf.view(),
            4,
            arr2(&[[0.0, 0.0]]).view(),
            Array1::from_elem(1, 1.0).view(),
            Array1::from_elem(1, 0.0).view(),
        )
        .unwrap_err();
        assert_eq!(err, RenderError::OversampleNotOdd { oversample: 4 });
    }

    #[test]
    fn error_epsf_size_not_multiple() {
        // 34 is not a multiple of 5.
        let epsf = Array2::<f64>::zeros((34, 34));
        let err = render(
            epsf.view(),
            5,
            arr2(&[[0.0, 0.0]]).view(),
            Array1::from_elem(1, 1.0).view(),
            Array1::from_elem(1, 0.0).view(),
        )
        .unwrap_err();
        assert_eq!(
            err,
            RenderError::EpsfSizeNotMultiple {
                epsf_side: 34,
                oversample: 5,
            }
        );
    }

    #[test]
    fn error_derived_stamp_size_even() {
        // os = 3, epsf_side = 18 -> stamp_size = 6 (even).
        let epsf = Array2::<f64>::zeros((18, 18));
        let err = render(
            epsf.view(),
            3,
            arr2(&[[0.0, 0.0]]).view(),
            Array1::from_elem(1, 1.0).view(),
            Array1::from_elem(1, 0.0).view(),
        )
        .unwrap_err();
        assert_eq!(err, RenderError::DerivedStampSizeEven { stamp_size: 6 });
    }

    #[test]
    fn error_batch_length_mismatch_row_count() {
        let epsf = Array2::<f64>::zeros((35, 35));
        // delta has 2 rows, flux/background have 1.
        let err = render(
            epsf.view(),
            5,
            arr2(&[[0.0, 0.0], [0.1, 0.1]]).view(),
            Array1::from_elem(1, 1.0).view(),
            Array1::from_elem(1, 0.0).view(),
        )
        .unwrap_err();
        assert_eq!(
            err,
            RenderError::BatchLengthMismatch {
                delta: (2, 2),
                flux: 1,
                background: 1,
            }
        );
    }

    #[test]
    fn error_batch_length_mismatch_delta_not_two_columns() {
        let epsf = Array2::<f64>::zeros((35, 35));
        // delta is (1, 3): not (N, 2).
        let err = render(
            epsf.view(),
            5,
            arr2(&[[0.0, 0.0, 0.0]]).view(),
            Array1::from_elem(1, 1.0).view(),
            Array1::from_elem(1, 0.0).view(),
        )
        .unwrap_err();
        assert_eq!(
            err,
            RenderError::BatchLengthMismatch {
                delta: (1, 3),
                flux: 1,
                background: 1,
            }
        );
    }

    #[test]
    fn empty_batch_yields_empty_output() {
        let epsf = Array2::<f64>::zeros((35, 35));
        let out = render(
            epsf.view(),
            5,
            Array2::<f64>::zeros((0, 2)).view(),
            Array1::<f64>::zeros(0).view(),
            Array1::<f64>::zeros(0).view(),
        )
        .unwrap();
        assert_eq!(out.shape(), &[0, 7, 7]);
    }

    #[test]
    fn build_stamp_delta_round_trip() {
        // End-to-end pin of the delta sign/center convention shared by
        // build_stamp and render. A flipped delta sign would shift the
        // rendered PSF by ~2*delta and blow this tolerance.
        use crate::image::stamp::{
            DEFAULT_CENTROID_MAX_ITER, DEFAULT_CENTROID_TOL, DEFAULT_WEIGHT_FWHM, build_stamp,
        };

        let true_row = 7.35_f64;
        let true_col = 6.80_f64;
        let amplitude = 100.0;
        let sigma_det = 1.6;
        let back = 5.0;

        // Isolated Gaussian point source in a generous rough cutout.
        let cutout_size = 15;
        let mut cutout = Array2::<f64>::from_elem((cutout_size, cutout_size), back);
        for r in 0..cutout_size {
            for c in 0..cutout_size {
                let dr = r as f64 - true_row;
                let dc = c as f64 - true_col;
                cutout[(r, c)] +=
                    amplitude * (-0.5 * (dr * dr + dc * dc) / (sigma_det * sigma_det)).exp();
            }
        }

        let stamp_size = 7;
        let stamp_result = build_stamp(
            cutout.view(),
            stamp_size,
            None,
            None,
            DEFAULT_WEIGHT_FWHM,
            DEFAULT_CENTROID_MAX_ITER,
            DEFAULT_CENTROID_TOL,
        )
        .unwrap()
        .expect("expected a stamp for an isolated source");

        // Reconstructed centroid (origin + center + delta) must recover
        // the true source location.
        let detector_center = (stamp_size as i64 - 1) / 2;
        let recon_row = (stamp_result.origin.0 + detector_center) as f64 + stamp_result.delta.0;
        let recon_col = (stamp_result.origin.1 + detector_center) as f64 + stamp_result.delta.1;
        assert!(
            (recon_row - true_row).abs() < 0.05 && (recon_col - true_col).abs() < 0.05,
            "reconstructed centroid ({recon_row}, {recon_col}) far from ({true_row}, {true_col})"
        );

        // Build the matching oversampled Gaussian model and render with
        // the delta build_stamp reported.
        let oversample = 5;
        let epsf_side = oversample * stamp_size;
        let epsf = gaussian_epsf(epsf_side, oversample as f64 * sigma_det, amplitude);
        let delta = arr2(&[[stamp_result.delta.0, stamp_result.delta.1]]);
        let rendered = render(
            epsf.view(),
            oversample,
            delta.view(),
            Array1::from_elem(1, 1.0).view(),
            Array1::from_elem(1, back).view(),
        )
        .unwrap();

        let mut max_abs_error = 0.0_f64;
        for i in 0..stamp_size {
            for j in 0..stamp_size {
                max_abs_error =
                    max_abs_error.max((rendered[(0, i, j)] - stamp_result.stamp[(i, j)]).abs());
            }
        }
        // Centroid-bias + interpolation residual stay well under 2% of
        // the peak; a sign flip would be an order of magnitude larger.
        assert!(
            max_abs_error < 0.02 * amplitude,
            "round-trip max abs error = {max_abs_error}"
        );
    }

    #[test]
    fn rejects_transposed_indexing_with_asymmetric_delta() {
        // Asymmetric model + asymmetric delta: swapping (row, col)
        // anywhere in the pipeline would change the result.
        let oversample = 5;
        let stamp_size = 5;
        let epsf_side = oversample * stamp_size;
        let epsf = ramp_epsf(epsf_side);
        let delta = arr2(&[[0.4, -0.1]]);
        let out = render(
            epsf.view(),
            oversample,
            delta.view(),
            Array1::from_elem(1, 1.0).view(),
            Array1::from_elem(1, 0.0).view(),
        )
        .unwrap();
        // Hand-compute the (0, 0) detector pixel via the documented
        // formula and the same kernel.
        let psf_center = (epsf_side as f64 - 1.0) / 2.0;
        let detector_center = (stamp_size as f64 - 1.0) / 2.0;
        let k_u = psf_center + oversample as f64 * ((0.0 - detector_center) - 0.4);
        let k_v = psf_center + oversample as f64 * ((0.0 - detector_center) - (-0.1));
        let expected = catmull_rom_sample(&epsf.view(), k_u, k_v);
        assert!(
            (out[(0, 0, 0)] - expected).abs() < 1e-12,
            "{} != {}",
            out[(0, 0, 0)],
            expected
        );
        // Sanity: the model is genuinely asymmetric so this is a real
        // constraint.
        assert!((epsf[(0, 1)] - epsf[(1, 0)]).abs() > 1.0);
    }
}