Skip to main content

braid_sim/
scenario.rs

1// Copyright (C) The Strand-Braid Authors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! The `sim.toml` scenario schema: the single source of truth for a simulated
5//! run (arena, cameras, insects, blob rendering, frame rate).
6
7use serde::{Deserialize, Serialize};
8
9/// Axis-aligned bounding box of the tracking volume, in meters.
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11pub struct Arena {
12    /// Minimum (x, y, z) corner, meters.
13    pub min: [f64; 3],
14    /// Maximum (x, y, z) corner, meters.
15    pub max: [f64; 3],
16}
17
18impl Arena {
19    /// Center of the arena, meters.
20    pub fn center(&self) -> [f64; 3] {
21        [
22            0.5 * (self.min[0] + self.max[0]),
23            0.5 * (self.min[1] + self.max[1]),
24            0.5 * (self.min[2] + self.max[2]),
25        ]
26    }
27    /// Half-extent of the arena along each axis, meters.
28    pub fn half_extent(&self) -> [f64; 3] {
29        [
30            0.5 * (self.max[0] - self.min[0]),
31            0.5 * (self.max[1] - self.min[1]),
32            0.5 * (self.max[2] - self.min[2]),
33        ]
34    }
35}
36
37/// How the synthetic cameras are arranged: an evenly-spaced horizontal ring
38/// around the arena center, all looking inward. Intrinsics are an ideal pinhole
39/// (no distortion) for the perfect-world baseline.
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41pub struct CameraRig {
42    /// Number of cameras.
43    pub count: usize,
44    /// Radius of the camera ring around the arena center, meters.
45    pub radius_m: f64,
46    /// Height (world z) of the cameras, meters.
47    pub height_m: f64,
48    /// Focal length in pixels (fx == fy).
49    pub focal_length_px: f64,
50    /// Image width in pixels.
51    pub image_width: usize,
52    /// Image height in pixels.
53    pub image_height: usize,
54}
55
56/// Parameters for rendering an insect as a Gaussian blob (used by the `ci2-sim`
57/// backend). Defaults are values the real detector reliably localizes.
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
59pub struct BlobParams {
60    /// Peak intensity added above the background (gray levels). Must clear the
61    /// detector threshold (default `diff_threshold` is 30).
62    pub peak: u8,
63    /// Gaussian standard deviation, pixels.
64    pub sigma: f64,
65    /// Flat background gray level.
66    pub background: u8,
67}
68
69impl Default for BlobParams {
70    fn default() -> Self {
71        // Peak well above the detector threshold; sigma ~1.5 localizes best.
72        BlobParams {
73            peak: 160,
74            sigma: 1.5,
75            background: 0,
76        }
77    }
78}
79
80/// A smooth, bounded, deterministic 3D motion: a per-axis sinusoid (Lissajous
81/// figure) confined to a fraction of the arena half-extent.
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83pub struct Lissajous {
84    /// Per-axis frequency, Hz.
85    pub freq_hz: [f64; 3],
86    /// Per-axis phase, radians.
87    pub phase: [f64; 3],
88    /// Fraction of the arena half-extent the motion spans (0..1).
89    pub fill: f64,
90    /// Amplitude (meters) of an additional high-frequency "maneuver" overlay
91    /// per axis. Small amplitude at high [`Self::maneuver_freq_hz`] produces
92    /// large acceleration/jerk (sharp turns) without large displacement,
93    /// modeling a maneuvering target (e.g. a flying insect). A constant-velocity
94    /// EKF cannot predict this, so it produces nonzero innovations — needed to
95    /// exercise the over-confident-gate fragmentation bug. Default 0 (smooth).
96    #[serde(default)]
97    pub maneuver_amp_m: f64,
98    /// Frequency (Hz) of the maneuver overlay. Default 0.
99    #[serde(default)]
100    pub maneuver_freq_hz: f64,
101}
102
103impl Default for Lissajous {
104    fn default() -> Self {
105        Lissajous {
106            freq_hz: [0.11, 0.13, 0.07],
107            phase: [0.0, 1.0, 2.0],
108            fill: 0.7,
109            maneuver_amp_m: 0.0,
110            maneuver_freq_hz: 0.0,
111        }
112    }
113}
114
115/// One simulated insect.
116#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
117pub struct InsectSpec {
118    /// Ground-truth identity.
119    pub id: u32,
120    /// Time (seconds) at which the insect enters; absent before this.
121    #[serde(default)]
122    pub enter_t: f64,
123    /// Time (seconds) at which the insect leaves; present forever if `None`.
124    #[serde(default)]
125    pub exit_t: Option<f64>,
126    /// Motion model.
127    #[serde(default)]
128    pub motion: Lissajous,
129}
130
131fn default_bg_warmup_frames() -> u32 {
132    // Establish the background on insect-free frames before insects enter.
133    30
134}
135
136/// Per-camera frame-arrival timing perturbation.
137///
138/// The simulated cameras deliver each rendered frame late by this much, which
139/// causes their 2D detections to reach the mainbrain after it may have advanced
140/// past that frame. Late data is then silently dropped from the *live* 3D
141/// bundling (see `braid/flydra2/src/frame_bundler.rs`) while still being saved
142/// to disk, so retracking can recover it — the mechanism behind the
143/// "live trajectories shorter than retrack" bug.
144///
145/// The default is no perturbation, so the perfect-world baseline is unchanged.
146/// The frame *content* is unaffected: only delivery is delayed, so a frame still
147/// depicts the same world time on every camera.
148#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
149pub struct TimingModel {
150    /// Indices of the cameras whose frames are delivered late. Empty = none.
151    #[serde(default)]
152    pub lagging_cameras: Vec<usize>,
153    /// Constant extra delivery latency for lagging cameras, in seconds.
154    #[serde(default)]
155    pub extra_latency_sec: f64,
156    /// Maximum additional uniform-random per-frame latency for lagging cameras,
157    /// in seconds (drawn deterministically from the scenario seed, so runs are
158    /// reproducible).
159    #[serde(default)]
160    pub jitter_sec: f64,
161}
162
163impl TimingModel {
164    /// The extra delivery delay (seconds) for camera index `cam_index` at frame
165    /// `fno`, given the scenario `seed`. Deterministic.
166    pub fn extra_delay_sec(&self, seed: u64, cam_index: usize, fno: usize) -> f64 {
167        if !self.lagging_cameras.contains(&cam_index) {
168            return 0.0;
169        }
170        let jitter = if self.jitter_sec > 0.0 {
171            self.jitter_sec * unit_hash(seed, cam_index as u64, fno as u64)
172        } else {
173            0.0
174        };
175        self.extra_latency_sec + jitter
176    }
177}
178
179/// Observation-model imperfections applied to the 2D detections. All default to
180/// zero, so the perfect-world baseline is unchanged.
181///
182/// Everything is sampled deterministically from the scenario `seed` plus the
183/// `(camera, frame, insect)` indices, so a `(config, seed)` reproduces a run
184/// exactly — the harness can print the seed on failure and replay it.
185#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
186pub struct ObservationModel {
187    /// Standard deviation (pixels) of zero-mean Gaussian jitter added to each
188    /// projected detection. Models finite localization accuracy. Default 0.
189    #[serde(default)]
190    pub pixel_noise_px: f64,
191    /// Per-(camera, insect, frame) probability in `[0, 1]` that a real detection
192    /// is missed (dropped), i.i.d. per frame. Models sporadic detector misses;
193    /// for sustained misses use [`Self::occlusion`] instead. Default 0.
194    #[serde(default)]
195    pub dropout_prob: f64,
196    /// Expected number of spurious "clutter" detections per camera per frame
197    /// (false positives, placed uniformly in the image). Stresses data
198    /// association. Modeled as a fixed count `floor(x)` plus a fractional part
199    /// included with probability `x - floor(x)`. Default 0.
200    #[serde(default)]
201    pub clutter_per_frame: f64,
202    /// Temporally-correlated occlusion: hides an insect from a camera for whole
203    /// spans of frames (default: never). See [`OcclusionModel`].
204    #[serde(default)]
205    pub occlusion: OcclusionModel,
206}
207
208impl ObservationModel {
209    /// Whether a real detection of `insect_id` on camera `cam_index` at frame
210    /// `fno` is dropped, given the scenario `seed`. Deterministic.
211    pub fn is_dropped(&self, seed: u64, cam_index: usize, fno: usize, insect_id: u32) -> bool {
212        if self.dropout_prob <= 0.0 {
213            return false;
214        }
215        let u = unit_hash(
216            seed ^ 0x44_4f_55_54, // "DOUT"
217            (cam_index as u64) << 32 | insect_id as u64,
218            fno as u64,
219        );
220        u < self.dropout_prob
221    }
222
223    /// Whether a real detection of `insect_id` on camera `cam_index` at frame
224    /// `fno` should be suppressed for *any* reason — an i.i.d. [`Self::is_dropped`]
225    /// miss or an [`OcclusionModel`] span. This is the single check both the
226    /// image backend and the in-process injector apply before emitting a point.
227    pub fn is_suppressed(&self, seed: u64, cam_index: usize, fno: usize, insect_id: u32) -> bool {
228        self.is_dropped(seed, cam_index, fno, insect_id)
229            || self.occlusion.is_occluded(seed, cam_index, fno, insect_id)
230    }
231
232    /// The projected pixel `(x, y)` with deterministic Gaussian jitter applied.
233    /// Returns the input unchanged when `pixel_noise_px == 0`.
234    pub fn jitter_pixel(
235        &self,
236        seed: u64,
237        cam_index: usize,
238        fno: usize,
239        insect_id: u32,
240        x: f64,
241        y: f64,
242    ) -> (f64, f64) {
243        if self.pixel_noise_px <= 0.0 {
244            return (x, y);
245        }
246        let key = (cam_index as u64) << 32 | insect_id as u64;
247        // Two independent uniforms -> a 2D Gaussian via Box-Muller.
248        let u1 = unit_hash(seed ^ 0x4e_4f_49_53, key, fno as u64).max(1e-12); // "NOIS"
249        let u2 = unit_hash(seed ^ 0x4a_49_54_52, key, fno as u64); // "JITR"
250        let r = self.pixel_noise_px * (-2.0 * u1.ln()).sqrt();
251        let theta = 2.0 * std::f64::consts::PI * u2;
252        (x + r * theta.cos(), y + r * theta.sin())
253    }
254
255    /// Spurious clutter detections for camera `cam_index` at frame `fno`, placed
256    /// uniformly within a `width` x `height` image. Deterministic.
257    pub fn clutter(
258        &self,
259        seed: u64,
260        cam_index: usize,
261        fno: usize,
262        width: usize,
263        height: usize,
264    ) -> Vec<(f64, f64)> {
265        if self.clutter_per_frame <= 0.0 {
266            return Vec::new();
267        }
268        let whole = self.clutter_per_frame.floor() as usize;
269        let frac = self.clutter_per_frame - whole as f64;
270        let base = seed ^ 0x43_4c_54_52; // "CLTR"
271        let mut count = whole;
272        if frac > 0.0 {
273            let u = unit_hash(base, (cam_index as u64) << 40, fno as u64);
274            if u < frac {
275                count += 1;
276            }
277        }
278        (0..count)
279            .map(|i| {
280                let kx = (cam_index as u64) << 40 | (i as u64) << 1;
281                let ky = kx | 1;
282                let ux = unit_hash(base, kx, fno as u64);
283                let uy = unit_hash(base, ky, fno as u64);
284                (ux * width as f64, uy * height as f64)
285            })
286            .collect()
287    }
288}
289
290/// Temporally-correlated occlusion: an insect is hidden from a camera for
291/// contiguous *spans* of frames — modeling it passing behind another insect or
292/// an arena feature.
293///
294/// This differs from [`ObservationModel::dropout_prob`], which drops detections
295/// i.i.d. per frame: independent single-frame misses rarely line up into a long
296/// gap, whereas occlusion suppresses a whole span at once. Those multi-frame,
297/// few-or-zero-observation stretches are what fragment *live* tracks: the live
298/// EKF kills a coasting track that retrack, seeing all data at once, bridges.
299///
300/// Time is tiled into blocks of `span_frames`; each (camera, insect, block) is
301/// independently occluded with probability `prob`. Adjacent occluded blocks
302/// merge, so spans are at least one block and occasionally longer. Default
303/// (`prob == 0` or `span_frames == 0`) is never occluded, preserving the
304/// perfect-world baseline.
305#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
306pub struct OcclusionModel {
307    /// Probability in `[0, 1]` that any given block hides the insect from a
308    /// camera. Default 0 (never occluded).
309    #[serde(default)]
310    pub prob: f64,
311    /// Block length in frames (the occlusion granularity / typical span).
312    /// Default 0 disables occlusion regardless of `prob`.
313    #[serde(default)]
314    pub span_frames: usize,
315}
316
317impl OcclusionModel {
318    /// Whether `insect_id` is occluded from camera `cam_index` at frame `fno`,
319    /// given the scenario `seed`. Deterministic and constant within a block, so
320    /// a `(config, seed)` reproduces the exact occluded spans.
321    pub fn is_occluded(&self, seed: u64, cam_index: usize, fno: usize, insect_id: u32) -> bool {
322        if self.prob <= 0.0 || self.span_frames == 0 {
323            return false;
324        }
325        let block = (fno / self.span_frames) as u64;
326        let u = unit_hash(
327            seed ^ 0x4f_43_43_4c, // "OCCL"
328            (cam_index as u64) << 32 | insect_id as u64,
329            block,
330        );
331        u < self.prob
332    }
333}
334
335/// Deterministic per-camera offsets applied to the *tracking* calibration by a
336/// [`CalibrationPerturbation`]. Each component is signed, uniform in
337/// `[-magnitude, +magnitude]`, and reproducible from `(seed, camera index)`.
338#[derive(Debug, Clone, Copy, PartialEq)]
339pub struct CameraCalibOffsets {
340    /// Offset added to the camera center, meters (per world axis).
341    pub d_position_m: [f64; 3],
342    /// Offset added to the per-camera look-at target, meters (per world axis);
343    /// rotates the camera slightly without moving it.
344    pub d_look_at_m: [f64; 3],
345    /// Offset added to the focal length, pixels.
346    pub d_focal_px: f64,
347    /// Offset added to the principal point x, pixels.
348    pub d_cx_px: f64,
349    /// Offset added to the principal point y, pixels.
350    pub d_cy_px: f64,
351}
352
353/// Calibration perturbation: the *generation* calibration (used to project
354/// ground truth into 2D — i.e. "what was imaged") stays perfect, while
355/// the *tracking* calibration (what Braid reconstructs with) is perturbed by
356/// these magnitudes. A nonzero perturbation makes triangulation slightly
357/// inconsistent with the detections, so reprojection error is realistic rather
358/// than zero — exercising robustness and reprojection-error-driven behavior.
359///
360/// All magnitudes default to zero (perfect == tracking, the clean baseline).
361/// Offsets are sampled deterministically from the scenario seed, so a
362/// `(config, seed)` reproduces the perturbed calibration exactly.
363#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
364pub struct CalibrationPerturbation {
365    /// Max per-axis camera *position* error (meters): moves each camera center.
366    #[serde(default)]
367    pub camera_position_m: f64,
368    /// Max per-axis look-at *target* error (meters): rotates each camera (a
369    /// pointing error) without moving its center.
370    #[serde(default)]
371    pub look_at_m: f64,
372    /// Max focal-length error (pixels).
373    #[serde(default)]
374    pub focal_length_px: f64,
375    /// Max per-axis principal-point error (pixels).
376    #[serde(default)]
377    pub principal_point_px: f64,
378}
379
380impl CalibrationPerturbation {
381    /// Whether this perturbation is a no-op (all magnitudes zero), so the
382    /// tracking calibration equals the perfect generation calibration.
383    pub fn is_identity(&self) -> bool {
384        self.camera_position_m == 0.0
385            && self.look_at_m == 0.0
386            && self.focal_length_px == 0.0
387            && self.principal_point_px == 0.0
388    }
389
390    /// The deterministic calibration offsets for camera `cam_index`, given the
391    /// scenario `seed`. Each component is uniform in `[-magnitude, +magnitude]`.
392    pub fn offsets(&self, seed: u64, cam_index: usize) -> CameraCalibOffsets {
393        // Signed uniform in [-mag, mag], keyed by a per-component salt + axis.
394        let signed = |salt: u64, axis: u64, mag: f64| {
395            mag * (2.0 * unit_hash(seed ^ salt, cam_index as u64, axis) - 1.0)
396        };
397        let pos = |axis| signed(0x43_50_4f_53, axis, self.camera_position_m); // "CPOS"
398        let look = |axis| signed(0x43_4c_4b_41, axis, self.look_at_m); // "CLKA"
399        CameraCalibOffsets {
400            d_position_m: [pos(0), pos(1), pos(2)],
401            d_look_at_m: [look(0), look(1), look(2)],
402            d_focal_px: signed(0x43_46_4f_43, 0, self.focal_length_px), // "CFOC"
403            d_cx_px: signed(0x43_50_50_58, 0, self.principal_point_px), // "CPPX"
404            d_cy_px: signed(0x43_50_50_58, 1, self.principal_point_px),
405        }
406    }
407}
408
409/// Deterministic pseudo-random value in `[0, 1)` from three integers
410/// (splitmix64-style mixing). Used for reproducible per-(camera, frame) jitter.
411fn unit_hash(a: u64, b: u64, c: u64) -> f64 {
412    let mut x = a
413        .wrapping_mul(0x9E37_79B9_7F4A_7C15)
414        .wrapping_add(b.wrapping_mul(0xD1B5_4A32_D192_ED03))
415        .wrapping_add(c.wrapping_mul(0xCA5A_8267_6BE1_1B27));
416    x ^= x >> 30;
417    x = x.wrapping_mul(0xBF58_476D_1CE4_E5B9);
418    x ^= x >> 27;
419    x = x.wrapping_mul(0x94D0_49BB_1331_11EB);
420    x ^= x >> 31;
421    // Top 53 bits → f64 in [0, 1).
422    (x >> 11) as f64 / (1u64 << 53) as f64
423}
424
425/// A complete simulated scenario, deserialized from `sim.toml`.
426#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
427pub struct Scenario {
428    /// RNG seed driving all deterministic stochastic behavior (detection noise,
429    /// dropout, clutter, occlusion, timing jitter, calibration perturbation).
430    #[serde(default)]
431    pub seed: u64,
432    /// Synchronized frame rate, frames per second.
433    pub fps: f64,
434    /// Tracking volume.
435    pub arena: Arena,
436    /// Camera arrangement.
437    pub cameras: CameraRig,
438    /// The insects to simulate.
439    pub insects: Vec<InsectSpec>,
440    /// Blob rendering parameters.
441    #[serde(default)]
442    pub blob: BlobParams,
443    /// Number of insect-free frames to render first so the background model
444    /// settles before insects appear.
445    #[serde(default = "default_bg_warmup_frames")]
446    pub bg_warmup_frames: u32,
447    /// Per-camera frame-arrival timing perturbation (default: none).
448    #[serde(default)]
449    pub timing: TimingModel,
450    /// Observation-model imperfections: detection noise, dropout, clutter
451    /// (default: none).
452    #[serde(default)]
453    pub observation: ObservationModel,
454    /// If set, the cameras' *host* timestamps advance at this rate (frames per
455    /// second) instead of true wall-clock time, modeling a host clock that is
456    /// **bunched** relative to the true frame cadence — as happens under load
457    /// when the camera driver delivers buffered frames in bursts.
458    ///
459    /// The sim still emits a hardware (device) timestamp at the true cadence, so
460    /// this exercises the frame-rate-estimation fix: a fps estimator that uses
461    /// the host clock is fooled (reads `reported_fps`, corrupting the tracker's
462    /// `dt = 1/fps` and fragmenting trajectories), while one that uses the
463    /// hardware timestamp is correct. `None` reports true wall-clock host
464    /// timestamps.
465    #[serde(default)]
466    pub reported_fps: Option<f64>,
467    /// Perturbation applied to the *tracking* calibration relative to the perfect
468    /// *generation* calibration (default: none — perfect == tracking). See
469    /// [`CalibrationPerturbation`].
470    #[serde(default)]
471    pub calibration_perturbation: CalibrationPerturbation,
472}
473
474impl Scenario {
475    /// Parse a scenario from a `sim.toml` string.
476    pub fn from_toml_str(s: &str) -> eyre::Result<Self> {
477        Ok(toml::from_str(s)?)
478    }
479
480    /// The camera name for camera index `k`. Used as the calibration camera
481    /// name, the Braid `[[cameras]]` name, and the `--camera-name` passed to the
482    /// simulated `strand-cam`. Kept purely alphanumeric to avoid ROS-name
483    /// encoding mismatches.
484    pub fn camera_name(k: usize) -> String {
485        format!("simcam{k}")
486    }
487
488    /// Parse the camera index `k` from a `simcam{k}` name.
489    pub fn camera_index(name: &str) -> Option<usize> {
490        name.strip_prefix("simcam").and_then(|s| s.parse().ok())
491    }
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    #[test]
499    fn camera_name_index_roundtrip() {
500        for k in 0..7 {
501            assert_eq!(Scenario::camera_index(&Scenario::camera_name(k)), Some(k));
502        }
503        assert_eq!(Scenario::camera_index("not-a-sim-cam"), None);
504    }
505
506    #[test]
507    fn imperfect_example_parses_with_occlusion() {
508        let s = Scenario::from_toml_str(include_str!("../example-sim-imperfect.toml")).unwrap();
509        // The occlusion knob is wired through deserialization and active.
510        assert!(s.observation.occlusion.prob > 0.0);
511        assert!(s.observation.occlusion.span_frames > 0);
512        // It actually occludes some (camera, frame) and `is_suppressed` reflects
513        // it, but it is mild enough that the insect is rarely hidden everywhere.
514        let occluded = (0..2000).any(|f| s.observation.is_suppressed(s.seed, 0, f, 1));
515        assert!(
516            occluded,
517            "expected the imperfect example to occlude sometimes"
518        );
519    }
520
521    #[test]
522    fn multi_example_parses_with_two_insects() {
523        let s = Scenario::from_toml_str(include_str!("../example-sim-multi.toml")).unwrap();
524        assert_eq!(s.insects.len(), 2);
525        // Distinct ids, both present from the start (None exit = forever).
526        let ids: Vec<u32> = s.insects.iter().map(|i| i.id).collect();
527        assert_eq!(ids, vec![1, 2]);
528        for ins in &s.insects {
529            assert_eq!(ins.enter_t, 0.0);
530            assert!(ins.exit_t.is_none());
531        }
532        // The world reports both insects present simultaneously.
533        let world = crate::world::World::new(s.clone());
534        assert_eq!(world.state_at(1.0).len(), 2);
535    }
536
537    #[test]
538    fn calibration_perturbation_default_is_identity() {
539        let p = CalibrationPerturbation::default();
540        assert!(p.is_identity());
541        let o = p.offsets(7, 2);
542        assert_eq!(o.d_position_m, [0.0; 3]);
543        assert_eq!(o.d_look_at_m, [0.0; 3]);
544        assert_eq!((o.d_focal_px, o.d_cx_px, o.d_cy_px), (0.0, 0.0, 0.0));
545    }
546
547    #[test]
548    fn calibration_perturbation_offsets_bounded_and_deterministic() {
549        let p = CalibrationPerturbation {
550            camera_position_m: 0.01,
551            look_at_m: 0.02,
552            focal_length_px: 5.0,
553            principal_point_px: 3.0,
554        };
555        assert!(!p.is_identity());
556        for cam in 0..8 {
557            let o = p.offsets(42, cam);
558            // Deterministic: same (seed, camera) -> identical offsets.
559            assert_eq!(o, p.offsets(42, cam));
560            for d in o.d_position_m {
561                assert!(d.abs() <= 0.01, "position offset {d} out of bound");
562            }
563            for d in o.d_look_at_m {
564                assert!(d.abs() <= 0.02, "look-at offset {d} out of bound");
565            }
566            assert!(o.d_focal_px.abs() <= 5.0);
567            assert!(o.d_cx_px.abs() <= 3.0 && o.d_cy_px.abs() <= 3.0);
568        }
569        // Different cameras get different offsets (not a constant shift).
570        assert_ne!(p.offsets(42, 0), p.offsets(42, 1));
571    }
572
573    #[test]
574    fn timing_default_is_no_delay() {
575        let t = TimingModel::default();
576        for cam in 0..5 {
577            for fno in 0..100 {
578                assert_eq!(t.extra_delay_sec(1, cam, fno), 0.0);
579            }
580        }
581    }
582
583    #[test]
584    fn timing_lags_only_selected_cameras() {
585        let t = TimingModel {
586            lagging_cameras: vec![2, 4],
587            extra_latency_sec: 0.01,
588            jitter_sec: 0.0,
589        };
590        assert_eq!(t.extra_delay_sec(1, 0, 5), 0.0);
591        assert_eq!(t.extra_delay_sec(1, 2, 5), 0.01);
592        assert_eq!(t.extra_delay_sec(1, 4, 5), 0.01);
593    }
594
595    #[test]
596    fn timing_jitter_is_bounded_and_deterministic() {
597        let t = TimingModel {
598            lagging_cameras: vec![1],
599            extra_latency_sec: 0.0,
600            jitter_sec: 0.02,
601        };
602        for fno in 0..1000 {
603            let d = t.extra_delay_sec(42, 1, fno);
604            assert!((0.0..0.02).contains(&d), "jitter {d} out of range");
605            // Deterministic: same inputs -> same output.
606            assert_eq!(d, t.extra_delay_sec(42, 1, fno));
607        }
608    }
609
610    #[test]
611    fn observation_default_is_a_no_op() {
612        let o = ObservationModel::default();
613        for fno in 0..50 {
614            assert!(!o.is_dropped(7, 0, fno, 3));
615            assert!(!o.is_suppressed(7, 0, fno, 3));
616            assert!(!o.occlusion.is_occluded(7, 0, fno, 3));
617            assert_eq!(o.jitter_pixel(7, 0, fno, 3, 100.0, 50.0), (100.0, 50.0));
618            assert!(o.clutter(7, 0, fno, 640, 480).is_empty());
619        }
620    }
621
622    #[test]
623    fn occlusion_default_and_disabled_never_occludes() {
624        // Default, prob-without-span, and span-without-prob all disable it.
625        for o in [
626            OcclusionModel::default(),
627            OcclusionModel {
628                prob: 0.5,
629                span_frames: 0,
630            },
631            OcclusionModel {
632                prob: 0.0,
633                span_frames: 30,
634            },
635        ] {
636            assert!((0..200).all(|fno| !o.is_occluded(1, 0, fno, 3)));
637        }
638    }
639
640    #[test]
641    fn occlusion_is_blockwise_constant_and_deterministic() {
642        let span = 25usize;
643        let o = OcclusionModel {
644            prob: 0.4,
645            span_frames: span,
646        };
647        // Constant within each block; deterministic across calls.
648        for block in 0..40usize {
649            let first = o.is_occluded(7, 2, block * span, 1);
650            for off in 0..span {
651                let fno = block * span + off;
652                assert_eq!(o.is_occluded(7, 2, fno, 1), first, "frame {fno} in block");
653            }
654        }
655    }
656
657    #[test]
658    fn occlusion_rate_matches_prob_and_creates_spans() {
659        let span = 20usize;
660        let o = OcclusionModel {
661            prob: 0.3,
662            span_frames: span,
663        };
664        // Long-run occluded fraction tracks `prob` (sampled per block).
665        let n = 40_000usize;
666        let occ = (0..n).filter(|&f| o.is_occluded(11, 1, f, 0)).count();
667        let frac = occ as f64 / n as f64;
668        assert!((frac - 0.3).abs() < 0.03, "occluded fraction {frac}");
669
670        // Whenever occluded, the whole enclosing block is occluded -> a span of
671        // at least `span` consecutive frames (never an isolated single frame).
672        for f in 0..2000usize {
673            if o.is_occluded(11, 1, f, 0) {
674                let block_start = (f / span) * span;
675                assert!((block_start..block_start + span).all(|g| o.is_occluded(11, 1, g, 0)));
676            }
677        }
678    }
679
680    #[test]
681    fn observation_dropout_rate_and_determinism() {
682        let o = ObservationModel {
683            dropout_prob: 0.25,
684            ..Default::default()
685        };
686        let mut dropped = 0usize;
687        let n = 20_000usize;
688        for fno in 0..n {
689            let d = o.is_dropped(99, 1, fno, 0);
690            // Deterministic: same inputs -> same output.
691            assert_eq!(d, o.is_dropped(99, 1, fno, 0));
692            if d {
693                dropped += 1;
694            }
695        }
696        let frac = dropped as f64 / n as f64;
697        assert!((frac - 0.25).abs() < 0.02, "dropout fraction {frac}");
698    }
699
700    #[test]
701    fn observation_jitter_is_bounded_centered_and_deterministic() {
702        let sigma = 2.0;
703        let o = ObservationModel {
704            pixel_noise_px: sigma,
705            ..Default::default()
706        };
707        let (mut sx, mut sy) = (0.0f64, 0.0f64);
708        let n = 20_000usize;
709        for fno in 0..n {
710            let (x, y) = o.jitter_pixel(5, 2, fno, 0, 100.0, 200.0);
711            // Deterministic.
712            assert_eq!((x, y), o.jitter_pixel(5, 2, fno, 0, 100.0, 200.0));
713            // Box-Muller radius is unbounded in theory but practically small;
714            // anything beyond ~8 sigma over 20k draws would signal a bug.
715            let r = ((x - 100.0).powi(2) + (y - 200.0).powi(2)).sqrt();
716            assert!(r < 8.0 * sigma, "jitter radius {r} too large");
717            sx += x - 100.0;
718            sy += y - 200.0;
719        }
720        // Zero-mean: sample means are near 0 (a few hundredths of a pixel).
721        assert!(
722            (sx / n as f64).abs() < 0.1,
723            "mean x offset {}",
724            sx / n as f64
725        );
726        assert!(
727            (sy / n as f64).abs() < 0.1,
728            "mean y offset {}",
729            sy / n as f64
730        );
731    }
732
733    #[test]
734    fn observation_clutter_count_position_and_determinism() {
735        // Whole part: exactly 2 clutter blobs every frame, inside the image.
736        let o = ObservationModel {
737            clutter_per_frame: 2.0,
738            ..Default::default()
739        };
740        for fno in 0..100 {
741            let c = o.clutter(3, 0, fno, 640, 480);
742            assert_eq!(c.len(), 2);
743            assert_eq!(c, o.clutter(3, 0, fno, 640, 480)); // deterministic
744            for (x, y) in c {
745                assert!((0.0..640.0).contains(&x) && (0.0..480.0).contains(&y));
746            }
747        }
748
749        // Fractional part: ~0.5 expected -> roughly half the frames have one.
750        let o = ObservationModel {
751            clutter_per_frame: 0.5,
752            ..Default::default()
753        };
754        let n = 20_000usize;
755        let with_one = (0..n)
756            .filter(|&f| !o.clutter(3, 0, f, 640, 480).is_empty())
757            .count();
758        let frac = with_one as f64 / n as f64;
759        assert!((frac - 0.5).abs() < 0.02, "clutter-present fraction {frac}");
760    }
761}