Skip to main content

oxirs_physics/conservation/
mod.rs

1//! Conservation Laws, Physical Bounds, and Buckingham Pi Dimensional Analysis
2//!
3//! Provides:
4//! - [`ConservationLaw`] trait + standard implementations
5//!   (energy, momentum, mass, entropy).
6//! - [`PhysicalBoundsValidator`] for range checks.
7//! - [`BuckinghamPiAnalyzer`] implementing the Buckingham π theorem.
8
9pub mod checkers;
10
11pub use checkers::{
12    ConservationReport, ConservationSuite, ConservationViolationDetail, EnergyConservationChecker,
13    MassConservationChecker, MomentumConservationChecker, ViolationSeverity,
14};
15
16use crate::error::{PhysicsError, PhysicsResult};
17use std::collections::HashMap;
18
19// ─────────────────────────────────────────────────────────────────────────────
20// PhysState – thin wrapper around quantity map
21// ─────────────────────────────────────────────────────────────────────────────
22
23/// A snapshot of physical quantities at a single instant.
24#[derive(Debug, Clone, Default)]
25pub struct PhysState {
26    /// Quantity name → value (SI).
27    pub quantities: HashMap<String, f64>,
28}
29
30impl PhysState {
31    /// Create an empty state.
32    pub fn new() -> Self {
33        Self::default()
34    }
35
36    /// Insert / update a quantity.
37    pub fn set(&mut self, name: impl Into<String>, value: f64) {
38        self.quantities.insert(name.into(), value);
39    }
40
41    /// Get a quantity, returning `None` if absent.
42    pub fn get(&self, name: &str) -> Option<f64> {
43        self.quantities.get(name).copied()
44    }
45
46    /// Get a quantity or return an error.
47    pub fn require(&self, name: &str) -> PhysicsResult<f64> {
48        self.quantities
49            .get(name)
50            .copied()
51            .ok_or_else(|| PhysicsError::ConstraintViolation(format!("missing quantity: {name}")))
52    }
53}
54
55// ─────────────────────────────────────────────────────────────────────────────
56// ConservationLaw trait
57// ─────────────────────────────────────────────────────────────────────────────
58
59/// A conservation law that can be checked against a pair of states.
60pub trait ConservationLaw: Send + Sync {
61    /// Name of the law (for reporting).
62    fn name(&self) -> &str;
63
64    /// Check the law given the state *before* (`initial`) and *after* (`final`)
65    /// a process.
66    fn check(&self, initial: &PhysState, final_state: &PhysState) -> PhysicsResult<()>;
67}
68
69// ─────────────────────────────────────────────────────────────────────────────
70// Energy Conservation
71// ─────────────────────────────────────────────────────────────────────────────
72
73/// Checks that total mechanical energy is conserved within `tolerance`.
74pub struct EnergyConservation {
75    pub tolerance: f64,
76}
77
78impl EnergyConservation {
79    pub fn new(tolerance: f64) -> Self {
80        Self { tolerance }
81    }
82
83    fn total_energy(state: &PhysState) -> f64 {
84        if let Some(e) = state.get("total_energy") {
85            return e;
86        }
87        state.get("kinetic_energy").unwrap_or(0.0) + state.get("potential_energy").unwrap_or(0.0)
88    }
89}
90
91impl ConservationLaw for EnergyConservation {
92    fn name(&self) -> &str {
93        "Energy Conservation"
94    }
95
96    fn check(&self, initial: &PhysState, final_state: &PhysState) -> PhysicsResult<()> {
97        let e_i = Self::total_energy(initial);
98        let e_f = Self::total_energy(final_state);
99        if (e_f - e_i).abs() > self.tolerance {
100            return Err(PhysicsError::ConservationViolation {
101                law: self.name().to_string(),
102                expected: e_i,
103                actual: e_f,
104            });
105        }
106        Ok(())
107    }
108}
109
110// ─────────────────────────────────────────────────────────────────────────────
111// Momentum Conservation
112// ─────────────────────────────────────────────────────────────────────────────
113
114/// Checks conservation of linear and angular momentum.
115pub struct MomentumConservation {
116    pub tolerance: f64,
117}
118
119impl MomentumConservation {
120    pub fn new(tolerance: f64) -> Self {
121        Self { tolerance }
122    }
123}
124
125impl ConservationLaw for MomentumConservation {
126    fn name(&self) -> &str {
127        "Momentum Conservation"
128    }
129
130    fn check(&self, initial: &PhysState, final_state: &PhysState) -> PhysicsResult<()> {
131        for component in &["momentum_x", "momentum_y", "momentum_z"] {
132            let p_i = initial.get(component).unwrap_or(0.0);
133            let p_f = final_state.get(component).unwrap_or(0.0);
134            if (p_f - p_i).abs() > self.tolerance {
135                return Err(PhysicsError::ConservationViolation {
136                    law: format!("{} ({})", self.name(), component),
137                    expected: p_i,
138                    actual: p_f,
139                });
140            }
141        }
142        let l_i = initial.get("angular_momentum").unwrap_or(0.0);
143        let l_f = final_state.get("angular_momentum").unwrap_or(0.0);
144        if (l_f - l_i).abs() > self.tolerance {
145            return Err(PhysicsError::ConservationViolation {
146                law: format!("{} (angular)", self.name()),
147                expected: l_i,
148                actual: l_f,
149            });
150        }
151        Ok(())
152    }
153}
154
155// ─────────────────────────────────────────────────────────────────────────────
156// Mass Conservation
157// ─────────────────────────────────────────────────────────────────────────────
158
159/// Checks that total mass does not change.
160pub struct MassConservation {
161    pub tolerance: f64,
162}
163
164impl MassConservation {
165    pub fn new(tolerance: f64) -> Self {
166        Self { tolerance }
167    }
168}
169
170impl ConservationLaw for MassConservation {
171    fn name(&self) -> &str {
172        "Mass Conservation"
173    }
174
175    fn check(&self, initial: &PhysState, final_state: &PhysState) -> PhysicsResult<()> {
176        let m_i = initial
177            .get("total_mass")
178            .or_else(|| initial.get("mass"))
179            .unwrap_or(0.0);
180        let m_f = final_state
181            .get("total_mass")
182            .or_else(|| final_state.get("mass"))
183            .unwrap_or(0.0);
184        if (m_f - m_i).abs() > self.tolerance {
185            return Err(PhysicsError::ConservationViolation {
186                law: self.name().to_string(),
187                expected: m_i,
188                actual: m_f,
189            });
190        }
191        Ok(())
192    }
193}
194
195// ─────────────────────────────────────────────────────────────────────────────
196// Entropy Law (2nd Law of Thermodynamics)
197// ─────────────────────────────────────────────────────────────────────────────
198
199/// Checks that entropy is non-decreasing: ΔS ≥ −`tolerance`.
200pub struct EntropyLaw {
201    pub tolerance: f64,
202}
203
204impl EntropyLaw {
205    pub fn new(tolerance: f64) -> Self {
206        Self { tolerance }
207    }
208}
209
210impl ConservationLaw for EntropyLaw {
211    fn name(&self) -> &str {
212        "Second Law of Thermodynamics (Entropy Non-Decrease)"
213    }
214
215    fn check(&self, initial: &PhysState, final_state: &PhysState) -> PhysicsResult<()> {
216        let s_i = initial.get("entropy").unwrap_or(0.0);
217        let s_f = final_state.get("entropy").unwrap_or(0.0);
218        if s_f - s_i < -self.tolerance {
219            return Err(PhysicsError::ConservationViolation {
220                law: self.name().to_string(),
221                expected: s_i,
222                actual: s_f,
223            });
224        }
225        Ok(())
226    }
227}
228
229// ─────────────────────────────────────────────────────────────────────────────
230// PhysicalBoundsValidator
231// ─────────────────────────────────────────────────────────────────────────────
232
233/// Describes a physical bound on a named quantity.
234#[derive(Debug, Clone)]
235pub struct PhysicalBound {
236    pub quantity: String,
237    pub min: f64,
238    pub max: f64,
239    pub description: String,
240}
241
242/// A bound violation.
243#[derive(Debug, Clone)]
244pub struct BoundViolation {
245    pub quantity: String,
246    pub actual: f64,
247    pub bound: PhysicalBound,
248    /// "below_minimum" | "above_maximum"
249    pub violation_kind: String,
250}
251
252/// Validates a set of physical quantities against registered bounds.
253#[derive(Debug, Default)]
254pub struct PhysicalBoundsValidator {
255    bounds: Vec<PhysicalBound>,
256}
257
258impl PhysicalBoundsValidator {
259    pub fn new() -> Self {
260        Self::default()
261    }
262
263    pub fn add_bound(
264        &mut self,
265        quantity: impl Into<String>,
266        min: f64,
267        max: f64,
268        description: impl Into<String>,
269    ) {
270        self.bounds.push(PhysicalBound {
271            quantity: quantity.into(),
272            min,
273            max,
274            description: description.into(),
275        });
276    }
277
278    pub fn validate(&self, state: &HashMap<String, f64>) -> PhysicsResult<Vec<BoundViolation>> {
279        let violations = self
280            .bounds
281            .iter()
282            .filter_map(|bound| {
283                state.get(&bound.quantity).and_then(|&actual| {
284                    if actual < bound.min {
285                        Some(BoundViolation {
286                            quantity: bound.quantity.clone(),
287                            actual,
288                            bound: bound.clone(),
289                            violation_kind: "below_minimum".to_string(),
290                        })
291                    } else if actual > bound.max {
292                        Some(BoundViolation {
293                            quantity: bound.quantity.clone(),
294                            actual,
295                            bound: bound.clone(),
296                            violation_kind: "above_maximum".to_string(),
297                        })
298                    } else {
299                        None
300                    }
301                })
302            })
303            .collect();
304        Ok(violations)
305    }
306}
307
308// ─────────────────────────────────────────────────────────────────────────────
309// Buckingham Pi Theorem
310// ─────────────────────────────────────────────────────────────────────────────
311
312/// SI base dimensions (exponents).  Order: [M, L, T, I, Θ, N, J].
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314pub struct Dimensions {
315    pub mass: i8,
316    pub length: i8,
317    pub time: i8,
318    pub current: i8,
319    pub temperature: i8,
320    pub amount: i8,
321    pub luminosity: i8,
322}
323
324impl Dimensions {
325    pub const DIMENSIONLESS: Self = Self {
326        mass: 0,
327        length: 0,
328        time: 0,
329        current: 0,
330        temperature: 0,
331        amount: 0,
332        luminosity: 0,
333    };
334
335    fn as_array(self) -> [i8; 7] {
336        [
337            self.mass,
338            self.length,
339            self.time,
340            self.current,
341            self.temperature,
342            self.amount,
343            self.luminosity,
344        ]
345    }
346
347    fn is_zero(self) -> bool {
348        self.as_array().iter().all(|&x| x == 0)
349    }
350}
351
352/// A physical quantity used as input to the Buckingham π analyzer.
353#[derive(Debug, Clone)]
354pub struct PhysicalQuantity {
355    pub name: String,
356    pub value: f64,
357    pub unit: String,
358    pub dimensions: Dimensions,
359}
360
361impl PhysicalQuantity {
362    pub fn new(
363        name: impl Into<String>,
364        value: f64,
365        unit: impl Into<String>,
366        dimensions: Dimensions,
367    ) -> Self {
368        Self {
369            name: name.into(),
370            value,
371            unit: unit.into(),
372            dimensions,
373        }
374    }
375}
376
377/// A dimensionless π group produced by the Buckingham π theorem.
378#[derive(Debug, Clone)]
379pub struct DimensionlessPi {
380    pub label: String,
381    pub quantity_indices: Vec<usize>,
382    pub exponents: Vec<f64>,
383    pub value: f64,
384}
385
386// ─────────────────────────────────────────────────────────────────────────────
387// Linear algebra helpers (clippy-clean, no raw index loops where avoidable)
388// ─────────────────────────────────────────────────────────────────────────────
389
390/// Row-reduce `matrix` (DIM rows × ncols columns) in-place and return the rank.
391fn row_echelon_rank(matrix: &mut [Vec<f64>], ncols: usize) -> usize {
392    let nrows = matrix.len();
393    let mut rank = 0_usize;
394    let mut pivot_col = 0_usize;
395    let mut row = 0_usize;
396
397    while row < nrows && pivot_col < ncols {
398        // Find pivot in this column at or below `row`.
399        let pivot_row = (row..nrows).find(|&r| matrix[r][pivot_col].abs() > 1e-12);
400        if let Some(pr) = pivot_row {
401            matrix.swap(row, pr);
402            rank += 1;
403            let pivot_val = matrix[row][pivot_col];
404            // Normalise pivot row.
405            matrix[row].iter_mut().for_each(|v| *v /= pivot_val);
406            // Eliminate column entries in all other rows.
407            for r in 0..nrows {
408                if r != row {
409                    let factor = matrix[r][pivot_col];
410                    // borrow issue: collect target row first then update other row
411                    let pivot_row_copy = matrix[row].clone();
412                    matrix[r]
413                        .iter_mut()
414                        .zip(pivot_row_copy.iter())
415                        .for_each(|(cell, &p)| *cell -= factor * p);
416                }
417            }
418            row += 1;
419        }
420        pivot_col += 1;
421    }
422    rank
423}
424
425/// Return the matrix rank.
426fn rank_of(matrix: &[Vec<f64>], ncols: usize) -> usize {
427    let mut m = matrix.to_vec();
428    row_echelon_rank(&mut m, ncols)
429}
430
431/// Return the pivot column indices after row reduction.
432fn find_pivot_columns(matrix: &[Vec<f64>], ncols: usize, rank: usize) -> Vec<usize> {
433    let nrows = matrix.len();
434    let mut m = matrix.to_vec();
435    let mut pivots = Vec::new();
436    let mut pivot_col = 0_usize;
437    let mut row = 0_usize;
438
439    while row < nrows && pivot_col < ncols && pivots.len() < rank {
440        let pivot_row = (row..nrows).find(|&r| m[r][pivot_col].abs() > 1e-12);
441        if let Some(pr) = pivot_row {
442            m.swap(row, pr);
443            pivots.push(pivot_col);
444            let pv = m[row][pivot_col];
445            m[row].iter_mut().for_each(|v| *v /= pv);
446            for r in 0..nrows {
447                if r != row {
448                    let f = m[r][pivot_col];
449                    let pivot_copy = m[row].clone();
450                    m[r].iter_mut()
451                        .zip(pivot_copy.iter())
452                        .for_each(|(cell, &p)| *cell -= f * p);
453                }
454            }
455            row += 1;
456        }
457        pivot_col += 1;
458    }
459    pivots
460}
461
462fn build_pi_group(
463    quantities: &[PhysicalQuantity],
464    pivot_cols: &[usize],
465    rem_idx: usize,
466    dim_matrix: &[Vec<f64>],
467    group_num: usize,
468) -> DimensionlessPi {
469    const DIM: usize = 7;
470    let r = pivot_cols.len();
471
472    // Build A (DIM × r) and b (DIM).
473    let a: Vec<Vec<f64>> = (0..DIM)
474        .map(|d| pivot_cols.iter().map(|&pc| dim_matrix[d][pc]).collect())
475        .collect();
476    let b: Vec<f64> = (0..DIM).map(|d| -dim_matrix[d][rem_idx]).collect();
477
478    // Normal equations: (A^T A) x = A^T b.
479    let mut ata: Vec<Vec<f64>> = vec![vec![0.0; r]; r];
480    let mut atb: Vec<f64> = vec![0.0; r];
481    for i in 0..r {
482        for j in 0..r {
483            ata[i][j] = (0..DIM).map(|d| a[d][i] * a[d][j]).sum();
484        }
485        atb[i] = (0..DIM).map(|d| a[d][i] * b[d]).sum();
486    }
487
488    let x = solve_linear_system(&ata, &atb);
489
490    let mut indices = pivot_cols.to_vec();
491    indices.push(rem_idx);
492    let mut exponents = x;
493    exponents.push(1.0);
494
495    let value = indices
496        .iter()
497        .zip(exponents.iter())
498        .map(|(&idx, &exp)| quantities[idx].value.powf(exp))
499        .product::<f64>();
500
501    DimensionlessPi {
502        label: format!("π{group_num}"),
503        quantity_indices: indices,
504        exponents,
505        value,
506    }
507}
508
509/// Gaussian elimination with partial pivoting.
510fn solve_linear_system(a: &[Vec<f64>], b: &[f64]) -> Vec<f64> {
511    let n = b.len();
512    if n == 0 {
513        return Vec::new();
514    }
515
516    let mut m: Vec<Vec<f64>> = a.to_vec();
517    let mut rhs: Vec<f64> = b.to_vec();
518
519    for col in 0..n {
520        // Partial pivot.
521        if let Some(max_row) = (col..n).max_by(|&i, &j| {
522            m[i][col]
523                .abs()
524                .partial_cmp(&m[j][col].abs())
525                .unwrap_or(std::cmp::Ordering::Equal)
526        }) {
527            m.swap(col, max_row);
528            rhs.swap(col, max_row);
529        }
530        let pv = m[col][col];
531        if pv.abs() < 1e-14 {
532            continue;
533        }
534        m[col].iter_mut().for_each(|v| *v /= pv);
535        rhs[col] /= pv;
536
537        for row in (col + 1)..n {
538            let f = m[row][col];
539            let pivot_copy = m[col].clone();
540            m[row]
541                .iter_mut()
542                .zip(pivot_copy.iter())
543                .for_each(|(cell, &p)| *cell -= f * p);
544            rhs[row] -= f * rhs[col];
545        }
546    }
547
548    // Back substitution.
549    let mut x = vec![0.0_f64; n];
550    for i in (0..n).rev() {
551        x[i] = rhs[i] - ((i + 1)..n).map(|j| m[i][j] * x[j]).sum::<f64>();
552    }
553    x
554}
555
556fn is_dimensionless(quantities: &[PhysicalQuantity], pi: &DimensionlessPi) -> bool {
557    let mut total = Dimensions::DIMENSIONLESS;
558    for (&idx, &exp) in pi.quantity_indices.iter().zip(pi.exponents.iter()) {
559        let d = quantities[idx].dimensions;
560        let rounded = exp.round() as i8;
561        total.mass += d.mass * rounded;
562        total.length += d.length * rounded;
563        total.time += d.time * rounded;
564        total.current += d.current * rounded;
565        total.temperature += d.temperature * rounded;
566        total.amount += d.amount * rounded;
567        total.luminosity += d.luminosity * rounded;
568    }
569    total.is_zero()
570}
571
572/// Analyzes a set of physical quantities and returns dimensionless π groups
573/// using the Buckingham π theorem.
574pub struct BuckinghamPiAnalyzer;
575
576impl BuckinghamPiAnalyzer {
577    pub fn analyze(quantities: &[PhysicalQuantity]) -> PhysicsResult<Vec<DimensionlessPi>> {
578        if quantities.is_empty() {
579            return Err(PhysicsError::ConstraintViolation(
580                "no quantities provided for Buckingham Pi analysis".to_string(),
581            ));
582        }
583
584        let n = quantities.len();
585        const DIM: usize = 7;
586
587        // Build dimensional matrix: DIM rows × n columns.
588        let dim_matrix: Vec<Vec<f64>> = (0..DIM)
589            .map(|i| {
590                quantities
591                    .iter()
592                    .map(|q| q.dimensions.as_array()[i] as f64)
593                    .collect()
594            })
595            .collect();
596
597        let rank = rank_of(&dim_matrix, n);
598        let num_pi = n.saturating_sub(rank);
599
600        if num_pi == 0 {
601            return Ok(Vec::new());
602        }
603
604        let pivot_cols = find_pivot_columns(&dim_matrix, n, rank);
605        let remaining: Vec<usize> = (0..n).filter(|c| !pivot_cols.contains(c)).collect();
606
607        let pis: Vec<DimensionlessPi> = remaining
608            .iter()
609            .enumerate()
610            .map(|(k, &rem_idx)| {
611                build_pi_group(quantities, &pivot_cols, rem_idx, &dim_matrix, k + 1)
612            })
613            .collect();
614
615        for pi in &pis {
616            if !is_dimensionless(quantities, pi) {
617                return Err(PhysicsError::ConstraintViolation(
618                    "Buckingham Pi group is not dimensionless (numerical error)".to_string(),
619                ));
620            }
621        }
622
623        Ok(pis)
624    }
625}
626
627// ─────────────────────────────────────────────────────────────────────────────
628// Tests
629// ─────────────────────────────────────────────────────────────────────────────
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634
635    fn state_with(pairs: &[(&str, f64)]) -> PhysState {
636        let mut s = PhysState::new();
637        for &(k, v) in pairs {
638            s.set(k, v);
639        }
640        s
641    }
642
643    // ── Energy Conservation ───────────────────────────────────────────────────
644
645    #[test]
646    fn energy_conservation_ok() {
647        let law = EnergyConservation::new(1e-6);
648        let s0 = state_with(&[("kinetic_energy", 100.0), ("potential_energy", 50.0)]);
649        let s1 = state_with(&[("kinetic_energy", 140.0), ("potential_energy", 10.0)]);
650        assert!(law.check(&s0, &s1).is_ok());
651    }
652
653    #[test]
654    fn energy_conservation_violated() {
655        let law = EnergyConservation::new(1.0);
656        let s0 = state_with(&[("total_energy", 100.0)]);
657        let s1 = state_with(&[("total_energy", 200.0)]);
658        assert!(law.check(&s0, &s1).is_err());
659    }
660
661    // ── Momentum Conservation ─────────────────────────────────────────────────
662
663    #[test]
664    fn momentum_conservation_ok() {
665        let law = MomentumConservation::new(1e-6);
666        let s0 = state_with(&[
667            ("momentum_x", 10.0),
668            ("momentum_y", 5.0),
669            ("momentum_z", 0.0),
670        ]);
671        let s1 = s0.clone();
672        assert!(law.check(&s0, &s1).is_ok());
673    }
674
675    #[test]
676    fn momentum_conservation_violated() {
677        let law = MomentumConservation::new(0.01);
678        let s0 = state_with(&[("momentum_x", 10.0)]);
679        let s1 = state_with(&[("momentum_x", 15.0)]);
680        assert!(law.check(&s0, &s1).is_err());
681    }
682
683    // ── Mass Conservation ─────────────────────────────────────────────────────
684
685    #[test]
686    fn mass_conservation_ok() {
687        let law = MassConservation::new(1e-9);
688        let s0 = state_with(&[("total_mass", 1.0)]);
689        let s1 = state_with(&[("total_mass", 1.0 + 1e-12)]);
690        assert!(law.check(&s0, &s1).is_ok());
691    }
692
693    #[test]
694    fn mass_conservation_violated() {
695        let law = MassConservation::new(1e-6);
696        let s0 = state_with(&[("mass", 2.0)]);
697        let s1 = state_with(&[("mass", 3.0)]);
698        assert!(law.check(&s0, &s1).is_err());
699    }
700
701    // ── Entropy Law ───────────────────────────────────────────────────────────
702
703    #[test]
704    fn entropy_non_decreasing_ok() {
705        let law = EntropyLaw::new(1e-9);
706        let s0 = state_with(&[("entropy", 100.0)]);
707        let s1 = state_with(&[("entropy", 105.0)]);
708        assert!(law.check(&s0, &s1).is_ok());
709    }
710
711    #[test]
712    fn entropy_decrease_violates() {
713        let law = EntropyLaw::new(1e-9);
714        let s0 = state_with(&[("entropy", 100.0)]);
715        let s1 = state_with(&[("entropy", 95.0)]);
716        assert!(law.check(&s0, &s1).is_err());
717    }
718
719    // ── PhysicalBoundsValidator ───────────────────────────────────────────────
720
721    #[test]
722    fn bounds_no_violation() {
723        let mut v = PhysicalBoundsValidator::new();
724        v.add_bound("temperature", 0.0, 1000.0, "Temperature in K");
725        v.add_bound("pressure", 0.0, 1e8, "Pressure in Pa");
726
727        let state: HashMap<String, f64> = [
728            ("temperature".to_string(), 300.0),
729            ("pressure".to_string(), 1e5),
730        ]
731        .into_iter()
732        .collect();
733
734        let violations = v.validate(&state).expect("should succeed");
735        assert!(violations.is_empty());
736    }
737
738    #[test]
739    fn bounds_below_minimum() {
740        let mut v = PhysicalBoundsValidator::new();
741        v.add_bound("temperature", 200.0, 1000.0, "Temperature must be >= 200 K");
742
743        let state: HashMap<String, f64> = [("temperature".to_string(), 50.0)].into_iter().collect();
744
745        let violations = v.validate(&state).expect("should succeed");
746        assert_eq!(violations.len(), 1);
747        assert_eq!(violations[0].violation_kind, "below_minimum");
748    }
749
750    #[test]
751    fn bounds_above_maximum() {
752        let mut v = PhysicalBoundsValidator::new();
753        v.add_bound("speed", 0.0, 300.0, "Speed <= 300 m/s");
754
755        let state: HashMap<String, f64> = [("speed".to_string(), 400.0)].into_iter().collect();
756
757        let violations = v.validate(&state).expect("should succeed");
758        assert_eq!(violations.len(), 1);
759        assert_eq!(violations[0].violation_kind, "above_maximum");
760    }
761
762    // ── Buckingham Pi ─────────────────────────────────────────────────────────
763
764    fn dim(mass: i8, length: i8, time: i8) -> Dimensions {
765        Dimensions {
766            mass,
767            length,
768            time,
769            current: 0,
770            temperature: 0,
771            amount: 0,
772            luminosity: 0,
773        }
774    }
775
776    #[test]
777    fn buckingham_pi_pendulum() {
778        // n=3, rank=2 → 1 π group.
779        let quantities = vec![
780            PhysicalQuantity::new("T_period", 2.0, "s", dim(0, 0, 1)),
781            PhysicalQuantity::new("L_length", 1.0, "m", dim(0, 1, 0)),
782            PhysicalQuantity::new("g_accel", 9.81, "m/s^2", dim(0, 1, -2)),
783        ];
784
785        let pis = BuckinghamPiAnalyzer::analyze(&quantities).expect("should succeed");
786        assert_eq!(pis.len(), 1, "expected 1 dimensionless group for pendulum");
787    }
788
789    #[test]
790    fn buckingham_pi_empty_input_is_error() {
791        let result = BuckinghamPiAnalyzer::analyze(&[]);
792        assert!(result.is_err());
793    }
794
795    // ── Additional PhysState tests ────────────────────────────────────────────
796
797    #[test]
798    fn test_physstate_set_and_get() {
799        let mut s = PhysState::new();
800        s.set("velocity_x", 3.0);
801        s.set("velocity_y", 4.0);
802        assert_eq!(s.get("velocity_x"), Some(3.0));
803        assert_eq!(s.get("velocity_y"), Some(4.0));
804        assert_eq!(s.get("velocity_z"), None);
805    }
806
807    #[test]
808    fn test_physstate_require_missing_is_error() {
809        let s = PhysState::new();
810        let result = s.require("mass");
811        assert!(result.is_err(), "require on missing key should error");
812    }
813
814    #[test]
815    fn test_physstate_require_present_ok() {
816        let mut s = PhysState::new();
817        s.set("pressure", 101325.0);
818        let v = s.require("pressure").expect("should succeed");
819        assert!((v - 101325.0).abs() < 1e-6);
820    }
821
822    #[test]
823    fn test_physstate_overwrite() {
824        let mut s = PhysState::new();
825        s.set("temp", 300.0);
826        s.set("temp", 350.0);
827        assert_eq!(s.get("temp"), Some(350.0));
828    }
829
830    // ── EnergyConservation (trait impl) tests ─────────────────────────────────
831
832    #[test]
833    fn energy_law_pass_total_energy() {
834        let law = EnergyConservation::new(1.0);
835        let s0 = state_with(&[("total_energy", 500.0)]);
836        let s1 = state_with(&[("total_energy", 500.5)]);
837        assert!(law.check(&s0, &s1).is_ok());
838    }
839
840    #[test]
841    fn energy_law_fail_large_change() {
842        let law = EnergyConservation::new(0.1);
843        let s0 = state_with(&[("total_energy", 1000.0)]);
844        let s1 = state_with(&[("total_energy", 2000.0)]);
845        assert!(law.check(&s0, &s1).is_err());
846    }
847
848    #[test]
849    fn energy_law_name_correct() {
850        let law = EnergyConservation::new(1.0);
851        assert_eq!(law.name(), "Energy Conservation");
852    }
853
854    // ── MomentumConservation (trait impl) tests ───────────────────────────────
855
856    #[test]
857    fn momentum_law_pass() {
858        let law = MomentumConservation::new(0.01);
859        let s0 = state_with(&[
860            ("momentum_x", 5.0),
861            ("momentum_y", 0.0),
862            ("momentum_z", 0.0),
863        ]);
864        let s1 = state_with(&[
865            ("momentum_x", 5.0),
866            ("momentum_y", 0.0),
867            ("momentum_z", 0.0),
868        ]);
869        assert!(law.check(&s0, &s1).is_ok());
870    }
871
872    #[test]
873    fn momentum_law_fail() {
874        let law = MomentumConservation::new(0.001);
875        let s0 = state_with(&[("momentum_x", 10.0)]);
876        let s1 = state_with(&[("momentum_x", 15.0)]);
877        assert!(law.check(&s0, &s1).is_err());
878    }
879
880    #[test]
881    fn momentum_law_name_correct() {
882        let law = MomentumConservation::new(0.1);
883        assert_eq!(law.name(), "Momentum Conservation");
884    }
885
886    // ── MassConservation (trait impl) tests ──────────────────────────────────
887
888    #[test]
889    fn mass_law_pass() {
890        let law = MassConservation::new(1e-6);
891        let s0 = state_with(&[("mass", 5.0)]);
892        let s1 = state_with(&[("mass", 5.0 + 1e-8)]);
893        assert!(law.check(&s0, &s1).is_ok());
894    }
895
896    #[test]
897    fn mass_law_fail() {
898        let law = MassConservation::new(1e-6);
899        let s0 = state_with(&[("mass", 1.0)]);
900        let s1 = state_with(&[("mass", 3.0)]);
901        assert!(law.check(&s0, &s1).is_err());
902    }
903
904    #[test]
905    fn mass_law_name_correct() {
906        let law = MassConservation::new(1e-6);
907        assert_eq!(law.name(), "Mass Conservation");
908    }
909
910    // ── PhysicalBoundsValidator tests ─────────────────────────────────────────
911
912    #[test]
913    fn bounds_multiple_violations() {
914        let mut v = PhysicalBoundsValidator::new();
915        v.add_bound("temperature", 200.0, 1000.0, "K");
916        v.add_bound("pressure", 0.0, 1e6, "Pa");
917        v.add_bound("speed", 0.0, 300.0, "m/s");
918
919        let state: HashMap<String, f64> = [
920            ("temperature".to_string(), 50.0), // below min
921            ("pressure".to_string(), 2e6),     // above max
922            ("speed".to_string(), 100.0),      // ok
923        ]
924        .into_iter()
925        .collect();
926
927        let violations = v.validate(&state).expect("should succeed");
928        assert_eq!(violations.len(), 2, "expected 2 violations");
929    }
930
931    #[test]
932    fn bounds_empty_state_no_violations() {
933        let mut v = PhysicalBoundsValidator::new();
934        v.add_bound("temperature", 0.0, 1000.0, "K");
935        let state: HashMap<String, f64> = HashMap::new();
936        let violations = v.validate(&state).expect("should succeed");
937        // Missing quantities are not violations (no value present)
938        assert!(
939            violations.is_empty(),
940            "missing quantities should not trigger violations"
941        );
942    }
943
944    // ── Buckingham Pi tests ───────────────────────────────────────────────────
945
946    #[test]
947    fn buckingham_pi_reynolds_number() {
948        // Re = ρ*v*L/μ — 4 quantities, rank = 3 → 1 pi group
949        fn dim(mass: i8, length: i8, time: i8) -> Dimensions {
950            Dimensions {
951                mass,
952                length,
953                time,
954                current: 0,
955                temperature: 0,
956                amount: 0,
957                luminosity: 0,
958            }
959        }
960        let quantities = vec![
961            PhysicalQuantity::new("rho", 1000.0, "kg/m3", dim(1, -3, 0)),
962            PhysicalQuantity::new("v", 1.0, "m/s", dim(0, 1, -1)),
963            PhysicalQuantity::new("L", 0.1, "m", dim(0, 1, 0)),
964            PhysicalQuantity::new("mu", 1e-3, "Pa-s", dim(1, -1, -1)),
965        ];
966        let pis = BuckinghamPiAnalyzer::analyze(&quantities).expect("should succeed");
967        assert_eq!(pis.len(), 1, "Reynolds number → 1 pi group");
968    }
969
970    #[test]
971    fn buckingham_pi_two_groups() {
972        // 5 quantities, rank = 3 → 2 pi groups
973        fn dim(mass: i8, length: i8, time: i8) -> Dimensions {
974            Dimensions {
975                mass,
976                length,
977                time,
978                current: 0,
979                temperature: 0,
980                amount: 0,
981                luminosity: 0,
982            }
983        }
984        let quantities = vec![
985            PhysicalQuantity::new("rho", 1000.0, "kg/m3", dim(1, -3, 0)),
986            PhysicalQuantity::new("v", 1.0, "m/s", dim(0, 1, -1)),
987            PhysicalQuantity::new("L", 0.1, "m", dim(0, 1, 0)),
988            PhysicalQuantity::new("mu", 1e-3, "Pa-s", dim(1, -1, -1)),
989            PhysicalQuantity::new("dp", 100.0, "Pa", dim(1, -1, -2)), // pressure gradient
990        ];
991        let pis = BuckinghamPiAnalyzer::analyze(&quantities).expect("should succeed");
992        assert_eq!(pis.len(), 2, "5 quantities - rank 3 = 2 pi groups");
993    }
994}