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}