1pub 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#[derive(Debug, Clone, Default)]
25pub struct PhysState {
26 pub quantities: HashMap<String, f64>,
28}
29
30impl PhysState {
31 pub fn new() -> Self {
33 Self::default()
34 }
35
36 pub fn set(&mut self, name: impl Into<String>, value: f64) {
38 self.quantities.insert(name.into(), value);
39 }
40
41 pub fn get(&self, name: &str) -> Option<f64> {
43 self.quantities.get(name).copied()
44 }
45
46 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
55pub trait ConservationLaw: Send + Sync {
61 fn name(&self) -> &str;
63
64 fn check(&self, initial: &PhysState, final_state: &PhysState) -> PhysicsResult<()>;
67}
68
69pub 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
110pub 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
155pub 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
195pub 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#[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#[derive(Debug, Clone)]
244pub struct BoundViolation {
245 pub quantity: String,
246 pub actual: f64,
247 pub bound: PhysicalBound,
248 pub violation_kind: String,
250}
251
252#[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#[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#[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#[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
386fn 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 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 matrix[row].iter_mut().for_each(|v| *v /= pivot_val);
406 for r in 0..nrows {
408 if r != row {
409 let factor = matrix[r][pivot_col];
410 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
425fn rank_of(matrix: &[Vec<f64>], ncols: usize) -> usize {
427 let mut m = matrix.to_vec();
428 row_echelon_rank(&mut m, ncols)
429}
430
431fn 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 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 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
509fn 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 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 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
572pub 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 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#[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 #[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 #[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 #[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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 #[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), ("pressure".to_string(), 2e6), ("speed".to_string(), 100.0), ]
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 assert!(
939 violations.is_empty(),
940 "missing quantities should not trigger violations"
941 );
942 }
943
944 #[test]
947 fn buckingham_pi_reynolds_number() {
948 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 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)), ];
991 let pis = BuckinghamPiAnalyzer::analyze(&quantities).expect("should succeed");
992 assert_eq!(pis.len(), 2, "5 quantities - rank 3 = 2 pi groups");
993 }
994}