Skip to main content

rustpix_tpx/
lib.rs

1//! rustpix-tpx: TPX3 packet parser, hit types, and file processor.
2//!
3//! This crate provides TPX3-specific data structures and parsing logic
4//! for Timepix3 pixel detector data.
5//!
6#![warn(missing_docs)]
7//!
8//! # Key Components
9//!
10//! - [`Tpx3Packet`] - Low-level packet parser with bit field extraction
11//! - `Tpx3Processor` - Section-aware file processor
12//!
13//! # Processing Pipeline
14//!
15//! 1. **Phase 1 (Sequential)**: Discover sections, propagate TDC state
16//! 2. **Phase 2 (Parallel)**: Process sections into hits
17//!
18
19mod hit;
20pub mod ordering;
21mod packet;
22pub mod section;
23
24pub use hit::{calculate_tof, correct_timestamp_rollover};
25pub use packet::Tpx3Packet;
26
27use serde::{Deserialize, Serialize};
28use std::fs::File;
29use std::io::BufReader;
30use std::path::Path;
31
32/// Affine transformation for chip coordinate mapping.
33///
34/// Formula:
35/// `global_x` = a * `local_x` + b * `local_y` + tx
36/// `global_y` = c * `local_x` + d * `local_y` + ty
37#[derive(Clone, Debug, Serialize, Deserialize)]
38pub struct ChipTransform {
39    /// Local X coefficient for affine transform.
40    pub a: i32,
41    /// Local Y coefficient for affine transform in X output.
42    pub b: i32,
43    /// Local X coefficient for affine transform in Y output.
44    pub c: i32,
45    /// Local Y coefficient for affine transform.
46    pub d: i32,
47    /// Translation in X direction.
48    pub tx: i32,
49    /// Translation in Y direction.
50    pub ty: i32,
51}
52
53impl ChipTransform {
54    /// Create an identity transform.
55    #[must_use]
56    pub fn identity() -> Self {
57        Self {
58            a: 1,
59            b: 0,
60            c: 0,
61            d: 1,
62            tx: 0,
63            ty: 0,
64        }
65    }
66
67    /// Apply transform to local coordinates.
68    ///
69    /// # Note
70    /// This method assumes the transform has been validated via `validate_bounds()`.
71    /// Using an unvalidated transform may cause incorrect results due to integer overflow.
72    #[inline]
73    #[must_use]
74    pub fn apply(&self, x: u16, y: u16) -> (u16, u16) {
75        let x = i32::from(x);
76        let y = i32::from(y);
77
78        let gx = self.a * x + self.b * y + self.tx;
79        let gy = self.c * x + self.d * y + self.ty;
80
81        debug_assert!(
82            u16::try_from(gx).is_ok(),
83            "ChipTransform: X out of bounds: {gx}"
84        );
85        debug_assert!(
86            u16::try_from(gy).is_ok(),
87            "ChipTransform: Y out of bounds: {gy}"
88        );
89
90        // Safety: bounds validated upfront via validate_bounds()
91        (
92            u16::try_from(gx).unwrap_or(u16::MAX),
93            u16::try_from(gy).unwrap_or(u16::MAX),
94        )
95    }
96
97    /// Validate that this transform produces valid u16 coordinates
98    /// for all inputs in the range [0, `chip_size_x`) x [0, `chip_size_y`).
99    ///
100    /// This checks all 4 corners of the input space, which is sufficient
101    /// because affine transforms are linear (extremes occur at corners).
102    /// # Errors
103    /// Returns an error if the transform maps any corner outside the valid output range.
104    pub fn validate_bounds(&self, chip_size_x: u16, chip_size_y: u16) -> Result<(), String> {
105        let max_x = i32::from(chip_size_x.saturating_sub(1));
106        let max_y = i32::from(chip_size_y.saturating_sub(1));
107
108        // Check all 4 corners of the input space
109        let corners = [(0, 0), (max_x, 0), (0, max_y), (max_x, max_y)];
110
111        for (x, y) in corners {
112            let gx = self.a * x + self.b * y + self.tx;
113            let gy = self.c * x + self.d * y + self.ty;
114
115            if gx < 0 || gx > i32::from(u16::MAX) {
116                return Err(format!(
117                    "Transform produces out-of-bounds x={gx} for input ({x}, {y}). \
118                     Valid range is [0, 65535].",
119                ));
120            }
121            if gy < 0 || gy > i32::from(u16::MAX) {
122                return Err(format!(
123                    "Transform produces out-of-bounds y={gy} for input ({x}, {y}). \
124                     Valid range is [0, 65535].",
125                ));
126            }
127        }
128
129        Ok(())
130    }
131}
132
133/// Detector configuration for TPX3 processing.
134#[derive(Clone, Debug, Serialize, Deserialize)]
135pub struct DetectorConfig {
136    /// TDC frequency in Hz (default: 60.0 for SNS).
137    pub tdc_frequency_hz: f64,
138    /// Enable missing TDC correction.
139    pub enable_missing_tdc_correction: bool,
140    /// Chip size X in pixels (default: 256).
141    pub chip_size_x: u16,
142    /// Chip size Y in pixels (default: 256).
143    pub chip_size_y: u16,
144    /// Per-chip affine transforms.
145    pub chip_transforms: Vec<ChipTransform>,
146}
147
148impl Default for DetectorConfig {
149    fn default() -> Self {
150        Self::venus_defaults()
151    }
152}
153
154// Intermediate structs for C++ compatible JSON schema
155#[derive(Deserialize, Serialize)]
156struct JsonConfig {
157    detector: JsonDetector,
158}
159
160#[derive(Deserialize, Serialize, Default)]
161#[serde(default)]
162struct JsonDetector {
163    timing: JsonTiming,
164    chip_layout: JsonChipLayout,
165    chip_transformations: Option<Vec<JsonChipTransform>>,
166}
167
168#[derive(Deserialize, Serialize)]
169#[serde(default)]
170struct JsonTiming {
171    tdc_frequency_hz: f64,
172    enable_missing_tdc_correction: bool,
173}
174
175impl Default for JsonTiming {
176    fn default() -> Self {
177        Self {
178            tdc_frequency_hz: 60.0,
179            enable_missing_tdc_correction: true,
180        }
181    }
182}
183
184#[derive(Deserialize, Serialize)]
185#[serde(default)]
186struct JsonChipLayout {
187    chip_size_x: u16,
188    chip_size_y: u16,
189}
190
191impl Default for JsonChipLayout {
192    fn default() -> Self {
193        Self {
194            chip_size_x: 256,
195            chip_size_y: 256,
196        }
197    }
198}
199
200#[derive(Deserialize, Serialize)]
201struct JsonChipTransform {
202    chip_id: u8,
203    matrix: [[i32; 3]; 2],
204}
205
206impl DetectorConfig {
207    /// Create VENUS/SNS default configuration.
208    ///
209    /// Uses specific affine transforms for the 4 chips:
210    /// - Chip 0: Translation (258, 0)
211    /// - Chip 1: Rotation 180 + Translation (513, 513)
212    /// - Chip 2: Rotation 180 + Translation (255, 513)
213    /// - Chip 3: Identity (0, 0)
214    #[must_use]
215    pub fn venus_defaults() -> Self {
216        let transforms = vec![
217            // Chip 0: [[1, 0, 258], [0, 1, 0]]
218            ChipTransform {
219                a: 1,
220                b: 0,
221                c: 0,
222                d: 1,
223                tx: 258,
224                ty: 0,
225            },
226            // Chip 1: [[-1, 0, 513], [0, -1, 513]]
227            ChipTransform {
228                a: -1,
229                b: 0,
230                c: 0,
231                d: -1,
232                tx: 513,
233                ty: 513,
234            },
235            // Chip 2: [[-1, 0, 255], [0, -1, 513]]
236            ChipTransform {
237                a: -1,
238                b: 0,
239                c: 0,
240                d: -1,
241                tx: 255,
242                ty: 513,
243            },
244            // Chip 3: [[1, 0, 0], [0, 1, 0]]
245            ChipTransform {
246                a: 1,
247                b: 0,
248                c: 0,
249                d: 1,
250                tx: 0,
251                ty: 0,
252            },
253        ];
254
255        Self {
256            tdc_frequency_hz: 60.0,
257            enable_missing_tdc_correction: true,
258            chip_size_x: 256,
259            chip_size_y: 256,
260            chip_transforms: transforms,
261        }
262    }
263
264    /// Load configuration from a JSON file (C++ compatible schema).
265    ///
266    /// Validates all chip transforms to ensure they produce valid coordinates.
267    ///
268    /// # Errors
269    /// Returns an error if the file cannot be read or the JSON is invalid.
270    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, Box<dyn std::error::Error>> {
271        let file = File::open(path)?;
272        let reader = BufReader::new(file);
273        let json_config: JsonConfig = serde_json::from_reader(reader)?;
274        Self::from_json_config(json_config)
275    }
276
277    /// Load configuration from a JSON string (C++ compatible schema).
278    ///
279    /// Validates all chip transforms to ensure they produce valid coordinates.
280    ///
281    /// # Errors
282    /// Returns an error if the JSON is invalid.
283    pub fn from_json(json: &str) -> Result<Self, Box<dyn std::error::Error>> {
284        let json_config: JsonConfig = serde_json::from_str(json)?;
285        Self::from_json_config(json_config)
286    }
287
288    /// Serialize configuration to a JSON string (C++ compatible schema).
289    ///
290    /// # Errors
291    /// Returns an error if serialization fails.
292    pub fn to_json_string(&self) -> Result<String, Box<dyn std::error::Error>> {
293        let transforms = {
294            let transforms = self
295                .chip_transforms
296                .iter()
297                .enumerate()
298                .map(|(chip_id, transform)| {
299                    let chip_id = u8::try_from(chip_id).map_err(|_| {
300                        std::io::Error::new(
301                            std::io::ErrorKind::InvalidInput,
302                            format!("chip_id {chip_id} exceeds u8"),
303                        )
304                    })?;
305                    Ok(JsonChipTransform {
306                        chip_id,
307                        matrix: [
308                            [transform.a, transform.b, transform.tx],
309                            [transform.c, transform.d, transform.ty],
310                        ],
311                    })
312                })
313                .collect::<Result<Vec<_>, std::io::Error>>()?;
314            Some(transforms)
315        };
316
317        let json_config = JsonConfig {
318            detector: JsonDetector {
319                timing: JsonTiming {
320                    tdc_frequency_hz: self.tdc_frequency_hz,
321                    enable_missing_tdc_correction: self.enable_missing_tdc_correction,
322                },
323                chip_layout: JsonChipLayout {
324                    chip_size_x: self.chip_size_x,
325                    chip_size_y: self.chip_size_y,
326                },
327                chip_transformations: transforms,
328            },
329        };
330
331        Ok(serde_json::to_string_pretty(&json_config)?)
332    }
333
334    /// Save configuration to a JSON file (C++ compatible schema).
335    ///
336    /// # Errors
337    /// Returns an error if serialization or file I/O fails.
338    pub fn to_file<P: AsRef<Path>>(&self, path: P) -> Result<(), Box<dyn std::error::Error>> {
339        let json = self.to_json_string()?;
340        std::fs::write(path, json)?;
341        Ok(())
342    }
343
344    fn from_json_config(config: JsonConfig) -> Result<Self, Box<dyn std::error::Error>> {
345        let detector = config.detector;
346
347        let chip_size_x = detector.chip_layout.chip_size_x;
348        let chip_size_y = detector.chip_layout.chip_size_y;
349
350        // Use VENUS defaults if no transformations specified (like C++).
351        // An explicit empty list means "no transforms".
352        let transforms = match detector.chip_transformations {
353            Some(transforms) => {
354                if transforms.is_empty() {
355                    Vec::new()
356                } else {
357                    // Find max chip ID to size the vector
358                    let max_chip_id = transforms.iter().map(|t| t.chip_id).max().unwrap_or(0);
359
360                    let mut t_vec = vec![ChipTransform::identity(); (max_chip_id + 1) as usize];
361
362                    for t in transforms {
363                        let matrix = t.matrix;
364                        // C++ matrix: [[a, b, tx], [c, d, ty]]
365                        t_vec[t.chip_id as usize] = ChipTransform {
366                            a: matrix[0][0],
367                            b: matrix[0][1],
368                            tx: matrix[0][2],
369                            c: matrix[1][0],
370                            d: matrix[1][1],
371                            ty: matrix[1][2],
372                        };
373                    }
374                    t_vec
375                }
376            }
377            None => {
378                // Fall back to VENUS defaults (already validated)
379                Self::venus_defaults().chip_transforms
380            }
381        };
382
383        let config = Self {
384            tdc_frequency_hz: detector.timing.tdc_frequency_hz,
385            enable_missing_tdc_correction: detector.timing.enable_missing_tdc_correction,
386            chip_size_x,
387            chip_size_y,
388            chip_transforms: transforms,
389        };
390
391        // Validate transforms once at load time (not per-hit)
392        config.validate_transforms()?;
393
394        Ok(config)
395    }
396
397    /// Validate all chip transforms produce valid u16 coordinates.
398    ///
399    /// This is called automatically when loading from JSON.
400    /// For programmatically created configs, call this before processing.
401    ///
402    /// # Errors
403    /// Returns an error if any transform is invalid.
404    pub fn validate_transforms(&self) -> Result<(), Box<dyn std::error::Error>> {
405        for (i, transform) in self.chip_transforms.iter().enumerate() {
406            transform
407                .validate_bounds(self.chip_size_x, self.chip_size_y)
408                .map_err(|e| format!("Chip {i} transform invalid: {e}"))?;
409        }
410        Ok(())
411    }
412
413    /// TDC period in seconds.
414    #[must_use]
415    pub fn tdc_period_seconds(&self) -> f64 {
416        1.0 / self.tdc_frequency_hz
417    }
418
419    /// TDC correction value in 25ns units.
420    #[must_use]
421    pub fn tdc_correction_25ns(&self) -> u32 {
422        let correction = (self.tdc_period_seconds() / 25e-9).round();
423        if correction <= 0.0 {
424            return 0;
425        }
426        if correction >= f64::from(u32::MAX) {
427            return u32::MAX;
428        }
429        format!("{correction:.0}")
430            .parse::<u32>()
431            .unwrap_or(u32::MAX)
432    }
433
434    /// Map local chip coordinates to global detector coordinates.
435    ///
436    /// Uses the configured affine transform for the given chip ID.
437    /// If chip ID is out of bounds, returns local coordinates as-is (identity).
438    #[must_use]
439    pub fn map_chip_to_global(&self, chip_id: u8, x: u16, y: u16) -> (u16, u16) {
440        if let Some(transform) = self.chip_transforms.get(chip_id as usize) {
441            transform.apply(x, y)
442        } else {
443            (x, y)
444        }
445    }
446
447    /// Calculate detector dimensions from chip layout and transforms.
448    ///
449    /// Returns `(width, height)` in pixels sized to include all transformed
450    /// chip coordinates, preserving gaps/offsets introduced by transforms.
451    #[must_use]
452    pub fn detector_dimensions(&self) -> (usize, usize) {
453        if self.chip_transforms.is_empty() {
454            return (usize::from(self.chip_size_x), usize::from(self.chip_size_y));
455        }
456
457        let max_x = i32::from(self.chip_size_x.saturating_sub(1));
458        let max_y = i32::from(self.chip_size_y.saturating_sub(1));
459        let corners = [(0, 0), (max_x, 0), (0, max_y), (max_x, max_y)];
460
461        let mut max_global_x = 0i32;
462        let mut max_global_y = 0i32;
463
464        for transform in &self.chip_transforms {
465            for (x, y) in corners {
466                let gx = transform.a * x + transform.b * y + transform.tx;
467                let gy = transform.c * x + transform.d * y + transform.ty;
468                if gx > max_global_x {
469                    max_global_x = gx;
470                }
471                if gy > max_global_y {
472                    max_global_y = gy;
473                }
474            }
475        }
476
477        let max_x = usize::try_from(max_global_x.max(0)).unwrap_or(0);
478        let max_y = usize::try_from(max_global_y.max(0)).unwrap_or(0);
479        (max_x.saturating_add(1), max_y.saturating_add(1))
480    }
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486    use serde_json::Value;
487
488    fn assert_f64_eq(actual: f64, expected: f64) {
489        assert!(
490            (actual - expected).abs() <= f64::EPSILON,
491            "expected {expected}, got {actual}"
492        );
493    }
494
495    #[test]
496    fn test_venus_defaults() {
497        let config = DetectorConfig::venus_defaults();
498        assert_f64_eq(config.tdc_frequency_hz, 60.0);
499        assert!(config.enable_missing_tdc_correction);
500        assert_eq!(config.chip_transforms.len(), 4);
501    }
502
503    #[test]
504    fn test_venus_detector_dimensions() {
505        let config = DetectorConfig::venus_defaults();
506        let (width, height) = config.detector_dimensions();
507        assert_eq!((width, height), (514, 514));
508    }
509
510    #[test]
511    fn test_tdc_correction() {
512        let config = DetectorConfig::venus_defaults();
513        // 1/60 Hz = 16.67ms, in 25ns units = 666,667
514        let correction = config.tdc_correction_25ns();
515        assert!(correction > 600_000 && correction < 700_000);
516    }
517
518    #[test]
519    fn test_venus_chip_mappings() {
520        let config = DetectorConfig::venus_defaults();
521
522        // Chip 0: local (100, 100) -> global (358, 100)
523        // x = 1*100 + 0*100 + 258 = 358
524        // y = 0*100 + 1*100 + 0 = 100
525        let (gx, gy) = config.map_chip_to_global(0, 100, 100);
526        assert_eq!((gx, gy), (358, 100));
527
528        // Chip 1: local (100, 100) -> global (413, 413)
529        // x = -1*100 + 0*100 + 513 = 413
530        // y = 0*100 + -1*100 + 513 = 413
531        let (gx, gy) = config.map_chip_to_global(1, 100, 100);
532        assert_eq!((gx, gy), (413, 413));
533
534        // Chip 2: local (100, 100) -> global (155, 413)
535        // x = -1*100 + 0*100 + 255 = 155
536        // y = 0*100 + -1*100 + 513 = 413
537        let (gx, gy) = config.map_chip_to_global(2, 100, 100);
538        assert_eq!((gx, gy), (155, 413));
539
540        // Chip 3: local (100, 100) -> global (100, 100)
541        // x = 1*100 + 0*100 + 0 = 100
542        // y = 0*100 + 1*100 + 0 = 100
543        let (gx, gy) = config.map_chip_to_global(3, 100, 100);
544        assert_eq!((gx, gy), (100, 100));
545    }
546    #[test]
547    fn test_json_loading() {
548        let json = r#"{
549            "detector": {
550                "timing": {
551                    "tdc_frequency_hz": 14.0,
552                    "enable_missing_tdc_correction": false
553                },
554                "chip_layout": {
555                    "chip_size_x": 256,
556                    "chip_size_y": 256
557                },
558                "chip_transformations": [
559                    {
560                        "chip_id": 0,
561                        "matrix": [[1, 0, 100], [0, 1, 200]]
562                    },
563                    {
564                        "chip_id": 1,
565                        "matrix": [[-1, 0, 300], [0, -1, 400]]
566                    }
567                ]
568            }
569        }"#;
570
571        let config = DetectorConfig::from_json(json).expect("Failed to parse JSON");
572
573        assert_f64_eq(config.tdc_frequency_hz, 14.0);
574        assert!(!config.enable_missing_tdc_correction);
575        assert_eq!(config.chip_size_x, 256);
576        assert_eq!(config.chip_size_y, 256);
577        assert_eq!(config.chip_transforms.len(), 2);
578
579        // Check Chip 0: Identity + Translation (100, 200)
580        let (gx, gy) = config.map_chip_to_global(0, 10, 20);
581        // x = 1*10 + 0*20 + 100 = 110
582        // y = 0*10 + 1*20 + 200 = 220
583        assert_eq!((gx, gy), (110, 220));
584
585        // Check Chip 1: Rotation 180 + Translation (300, 400)
586        let (gx, gy) = config.map_chip_to_global(1, 10, 20);
587        // x = -1*10 + 0*20 + 300 = 290
588        // y = 0*10 + -1*20 + 400 = 380
589        assert_eq!((gx, gy), (290, 380));
590    }
591    #[test]
592    fn test_json_partial_config_frequency_only() {
593        // User only wants to change frequency (common ESS use case)
594        let json = r#"{
595            "detector": {
596                "timing": {
597                    "tdc_frequency_hz": 14.0
598                }
599            }
600        }"#;
601
602        let config = DetectorConfig::from_json(json).expect("Should parse partial config");
603
604        assert_f64_eq(config.tdc_frequency_hz, 14.0); // Changed
605        assert!(config.enable_missing_tdc_correction); // Default: true
606        assert_eq!(config.chip_size_x, 256); // Default
607        assert_eq!(config.chip_size_y, 256); // Default
608        assert_eq!(config.chip_transforms.len(), 4); // VENUS defaults
609    }
610
611    #[test]
612    fn test_json_empty_detector() {
613        // Minimal config - just use all defaults
614        let json = r#"{ "detector": {} }"#;
615
616        let config = DetectorConfig::from_json(json).expect("Should parse minimal config");
617
618        assert_f64_eq(config.tdc_frequency_hz, 60.0); // VENUS default
619        assert_eq!(config.chip_transforms.len(), 4); // VENUS defaults
620    }
621
622    #[test]
623    fn test_json_custom_transforms_only() {
624        // User only specifies chip transforms (detector swap)
625        let json = r#"{
626            "detector": {
627                "chip_transformations": [
628                    {"chip_id": 0, "matrix": [[1, 0, 260], [0, 1, 0]]}
629                ]
630            }
631        }"#;
632
633        let config = DetectorConfig::from_json(json).expect("Should parse");
634
635        assert_f64_eq(config.tdc_frequency_hz, 60.0); // Default
636        assert_eq!(config.chip_transforms[0].tx, 260); // Custom
637    }
638
639    fn assert_transform_eq(actual: &ChipTransform, expected: &ChipTransform) {
640        assert_eq!(actual.a, expected.a);
641        assert_eq!(actual.b, expected.b);
642        assert_eq!(actual.c, expected.c);
643        assert_eq!(actual.d, expected.d);
644        assert_eq!(actual.tx, expected.tx);
645        assert_eq!(actual.ty, expected.ty);
646    }
647
648    #[test]
649    fn test_json_roundtrip_serialization() {
650        let config = DetectorConfig {
651            tdc_frequency_hz: 14.0,
652            enable_missing_tdc_correction: false,
653            chip_size_x: 128,
654            chip_size_y: 64,
655            chip_transforms: vec![
656                ChipTransform {
657                    a: 1,
658                    b: 0,
659                    c: 0,
660                    d: 1,
661                    tx: 10,
662                    ty: 20,
663                },
664                ChipTransform {
665                    a: -1,
666                    b: 0,
667                    c: 0,
668                    d: -1,
669                    tx: 127,
670                    ty: 63,
671                },
672            ],
673        };
674
675        let json = config.to_json_string().expect("serialize config");
676        let decoded = DetectorConfig::from_json(&json).expect("roundtrip decode");
677
678        assert_f64_eq(decoded.tdc_frequency_hz, config.tdc_frequency_hz);
679        assert_eq!(
680            decoded.enable_missing_tdc_correction,
681            config.enable_missing_tdc_correction
682        );
683        assert_eq!(decoded.chip_size_x, config.chip_size_x);
684        assert_eq!(decoded.chip_size_y, config.chip_size_y);
685        assert_eq!(decoded.chip_transforms.len(), config.chip_transforms.len());
686        for (actual, expected) in decoded
687            .chip_transforms
688            .iter()
689            .zip(config.chip_transforms.iter())
690        {
691            assert_transform_eq(actual, expected);
692        }
693    }
694
695    #[test]
696    fn test_json_serialization_schema() {
697        let config = DetectorConfig::venus_defaults();
698        let json = config.to_json_string().expect("serialize config");
699        let value: Value = serde_json::from_str(&json).expect("parse json");
700
701        let detector = value
702            .get("detector")
703            .and_then(|v| v.as_object())
704            .expect("detector object");
705        let timing = detector
706            .get("timing")
707            .and_then(|v| v.as_object())
708            .expect("timing object");
709        let layout = detector
710            .get("chip_layout")
711            .and_then(|v| v.as_object())
712            .expect("chip_layout object");
713
714        assert!(timing.contains_key("tdc_frequency_hz"));
715        assert!(timing.contains_key("enable_missing_tdc_correction"));
716        assert!(layout.contains_key("chip_size_x"));
717        assert!(layout.contains_key("chip_size_y"));
718
719        let transforms = detector
720            .get("chip_transformations")
721            .and_then(|v| v.as_array())
722            .expect("chip_transformations array");
723        assert_eq!(transforms.len(), config.chip_transforms.len());
724        let first = transforms[0].as_object().expect("transform object");
725        assert!(first.contains_key("chip_id"));
726        let matrix = first
727            .get("matrix")
728            .and_then(|v| v.as_array())
729            .expect("matrix array");
730        assert_eq!(matrix.len(), 2);
731        assert_eq!(matrix[0].as_array().expect("matrix row").len(), 3);
732        assert_eq!(matrix[1].as_array().expect("matrix row").len(), 3);
733    }
734
735    #[test]
736    fn test_json_empty_transforms_serialization() {
737        let config = DetectorConfig {
738            tdc_frequency_hz: 42.0,
739            enable_missing_tdc_correction: true,
740            chip_size_x: 256,
741            chip_size_y: 256,
742            chip_transforms: Vec::new(),
743        };
744
745        let json = config.to_json_string().expect("serialize config");
746        let value: Value = serde_json::from_str(&json).expect("parse json");
747        let detector = value
748            .get("detector")
749            .and_then(|v| v.as_object())
750            .expect("detector object");
751
752        let transforms = detector
753            .get("chip_transformations")
754            .and_then(|v| v.as_array())
755            .expect("chip_transformations array");
756        assert!(transforms.is_empty());
757
758        let decoded = DetectorConfig::from_json(&json).expect("decode");
759        assert_eq!(decoded.chip_size_x, 256);
760        assert_eq!(decoded.chip_size_y, 256);
761        assert!(decoded.chip_transforms.is_empty());
762    }
763
764    #[test]
765    fn test_venus_transforms_valid() {
766        // VENUS defaults should always pass validation
767        let config = DetectorConfig::venus_defaults();
768        assert!(config.validate_transforms().is_ok());
769    }
770
771    #[test]
772    fn test_invalid_transform_negative_output() {
773        // Transform that produces negative coordinates should be rejected
774        // a=-1, tx=50 means x=100 -> gx = -100 + 50 = -50 (invalid!)
775        let json = r#"{
776            "detector": {
777                "chip_transformations": [
778                    {"chip_id": 0, "matrix": [[-1, 0, 50], [0, 1, 0]]}
779                ]
780            }
781        }"#;
782
783        let result = DetectorConfig::from_json(json);
784        assert!(result.is_err());
785        let err = result.unwrap_err().to_string();
786        assert!(
787            err.contains("out-of-bounds"),
788            "Error should mention out-of-bounds: {err}"
789        );
790    }
791
792    #[test]
793    fn test_transform_validate_bounds_directly() {
794        // Valid identity transform
795        let identity = ChipTransform::identity();
796        assert!(identity.validate_bounds(256, 256).is_ok());
797
798        // Valid VENUS chip 1 transform (180° rotation)
799        let chip1 = ChipTransform {
800            a: -1,
801            b: 0,
802            c: 0,
803            d: -1,
804            tx: 513,
805            ty: 513,
806        };
807        assert!(chip1.validate_bounds(256, 256).is_ok());
808
809        // Invalid: negative output at corner (255, 0)
810        // gx = -1*255 + 0*0 + 100 = -155
811        let invalid = ChipTransform {
812            a: -1,
813            b: 0,
814            c: 0,
815            d: 1,
816            tx: 100,
817            ty: 0,
818        };
819        assert!(invalid.validate_bounds(256, 256).is_err());
820    }
821    #[test]
822    fn test_json_accepts_non_square_chips() {
823        let json = r#"{
824            "detector": {
825                "chip_layout": {
826                    "chip_size_x": 256,
827                    "chip_size_y": 128
828                }
829            }
830        }"#;
831
832        let config = DetectorConfig::from_json(json).expect("Should accept non-square chips");
833        assert_eq!(config.chip_size_x, 256);
834        assert_eq!(config.chip_size_y, 128);
835    }
836}