1pub 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#[derive(Debug, Clone, Default)]
31pub struct PhysState {
32 pub quantities: HashMap<String, f64>,
34}
35
36impl PhysState {
37 pub fn new() -> Self {
39 Self::default()
40 }
41
42 pub fn set(&mut self, name: impl Into<String>, value: f64) {
44 self.quantities.insert(name.into(), value);
45 }
46
47 pub fn get(&self, name: &str) -> Option<f64> {
49 self.quantities.get(name).copied()
50 }
51
52 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
61pub trait ConservationLaw: Send + Sync {
67 fn name(&self) -> &str;
69
70 fn check(&self, initial: &PhysState, final_state: &PhysState) -> PhysicsResult<()>;
73}
74
75pub 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
116pub 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
161pub 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
201pub 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#[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#[derive(Debug, Clone)]
250pub struct BoundViolation {
251 pub quantity: String,
252 pub actual: f64,
253 pub bound: PhysicalBound,
254 pub violation_kind: String,
256}
257
258#[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#[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#[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#[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
392fn 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 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 matrix[row].iter_mut().for_each(|v| *v /= pivot_val);
412 for r in 0..nrows {
414 if r != row {
415 let factor = matrix[r][pivot_col];
416 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
431fn rank_of(matrix: &[Vec<f64>], ncols: usize) -> usize {
433 let mut m = matrix.to_vec();
434 row_echelon_rank(&mut m, ncols)
435}
436
437fn 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 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 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
515fn 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 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 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
578pub 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 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#[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 #[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 #[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 #[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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 #[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), ("pressure".to_string(), 2e6), ("speed".to_string(), 100.0), ]
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 assert!(
945 violations.is_empty(),
946 "missing quantities should not trigger violations"
947 );
948 }
949
950 #[test]
953 fn buckingham_pi_reynolds_number() {
954 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 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)), ];
997 let pis = BuckinghamPiAnalyzer::analyze(&quantities).expect("should succeed");
998 assert_eq!(pis.len(), 2, "5 quantities - rank 3 = 2 pi groups");
999 }
1000}