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