use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum BaseUnit {
Length,
Mass,
Time,
Current,
Temperature,
Amount,
LuminousIntensity,
}
impl BaseUnit {
pub const ALL: [BaseUnit; 7] = [
BaseUnit::Length,
BaseUnit::Mass,
BaseUnit::Time,
BaseUnit::Current,
BaseUnit::Temperature,
BaseUnit::Amount,
BaseUnit::LuminousIntensity,
];
}
#[derive(Debug, Clone)]
pub struct PhysicalVar {
pub name: String,
pub dimensions: HashMap<BaseUnit, i32>,
}
impl PhysicalVar {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
dimensions: HashMap::new(),
}
}
pub fn set_dimension(&mut self, unit: BaseUnit, exponent: i32) {
if exponent == 0 {
self.dimensions.remove(&unit);
} else {
self.dimensions.insert(unit, exponent);
}
}
pub fn exponent(&self, unit: BaseUnit) -> i32 {
self.dimensions.get(&unit).copied().unwrap_or(0)
}
}
#[derive(Debug, Clone)]
pub struct PiGroup {
pub name: String,
pub exponents: Vec<(String, (i64, i64))>,
}
impl PiGroup {
pub fn exponent_f64(&self, var_name: &str) -> f64 {
self.exponents
.iter()
.find(|(n, _)| n == var_name)
.map(|(_, (num, den))| *num as f64 / *den as f64)
.unwrap_or(0.0)
}
pub fn display(&self) -> String {
let terms: Vec<String> = self
.exponents
.iter()
.filter(|(_, (num, _))| *num != 0)
.map(|(name, (num, den))| {
if *den == 1 {
format!("{name}^{num}")
} else {
format!("{name}^({num}/{den})")
}
})
.collect();
if terms.is_empty() {
format!("{} = 1 (trivially dimensionless)", self.name)
} else {
format!("{} = {}", self.name, terms.join(" · "))
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum BuckinghamPiError {
TooFewVariables,
AllDimensionless,
NoPiGroupsPossible {
n: usize,
k: usize,
},
}
impl std::fmt::Display for BuckinghamPiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BuckinghamPiError::TooFewVariables => {
write!(f, "at least 2 variables are required")
}
BuckinghamPiError::AllDimensionless => {
write!(f, "all variables are already dimensionless")
}
BuckinghamPiError::NoPiGroupsPossible { n, k } => {
write!(
f,
"no π groups possible: n={n} variables, k={k} independent dimensions (n ≤ k)"
)
}
}
}
}
impl std::error::Error for BuckinghamPiError {}
pub struct BuckinghamPi;
impl BuckinghamPi {
pub fn analyze(variables: &[PhysicalVar]) -> Result<Vec<PiGroup>, BuckinghamPiError> {
let n = variables.len();
if n < 2 {
return Err(BuckinghamPiError::TooFewVariables);
}
let active_dims: Vec<BaseUnit> = BaseUnit::ALL
.iter()
.copied()
.filter(|&dim| variables.iter().any(|v| v.exponent(dim) != 0))
.collect();
if active_dims.is_empty() {
return Err(BuckinghamPiError::AllDimensionless);
}
let m = active_dims.len();
let matrix: Vec<Vec<(i64, i64)>> = (0..m)
.map(|i| {
(0..n)
.map(|j| (variables[j].exponent(active_dims[i]) as i64, 1i64))
.collect()
})
.collect();
let mut aug: Vec<Vec<(i64, i64)>> = (0..m)
.map(|i| {
let mut row: Vec<(i64, i64)> = matrix[i].clone();
for k in 0..m {
row.push(if k == i { (1, 1) } else { (0, 1) });
}
row
})
.collect();
let mut pivot_cols: Vec<usize> = Vec::new(); let mut row = 0_usize;
for col in 0..n {
let pivot = (row..m).find(|&r| aug[r][col].0 != 0);
if let Some(p) = pivot {
aug.swap(row, p);
pivot_cols.push(col);
let pivot_val = aug[row][col];
for entry in &mut aug[row] {
*entry = rat_div(*entry, pivot_val);
}
let pivot_row = aug[row].clone();
for (r, aug_row) in aug.iter_mut().enumerate() {
if r != row && aug_row[col].0 != 0 {
let factor = aug_row[col];
for (entry, &pv) in aug_row.iter_mut().zip(pivot_row.iter()) {
let sub = rat_mul(factor, pv);
*entry = rat_sub(*entry, sub);
}
}
}
row += 1;
}
}
let rank = pivot_cols.len(); let pi_count = n - rank;
if pi_count == 0 {
return Err(BuckinghamPiError::NoPiGroupsPossible { n, k: rank });
}
let pivot_set: std::collections::HashSet<usize> = pivot_cols.iter().copied().collect();
let free_cols: Vec<usize> = (0..n).filter(|c| !pivot_set.contains(c)).collect();
let mut pi_groups: Vec<PiGroup> = Vec::with_capacity(pi_count);
for (pi_idx, &free_col) in free_cols.iter().enumerate() {
let mut exponents: Vec<(String, (i64, i64))> = Vec::with_capacity(n);
for (piv_row, &piv_col) in pivot_cols.iter().enumerate() {
let coeff = rat_neg(aug[piv_row][free_col]);
let red = rat_reduce(coeff);
exponents.push((variables[piv_col].name.clone(), red));
}
for &fc in &free_cols {
let exp = if fc == free_col { (1, 1) } else { (0, 1) };
exponents.push((variables[fc].name.clone(), exp));
}
exponents.retain(|(_, (num, _))| *num != 0);
pi_groups.push(PiGroup {
name: format!("π{}", pi_idx + 1),
exponents,
});
}
let _ = matrix; Ok(pi_groups)
}
}
fn gcd(a: i64, b: i64) -> i64 {
let (mut a, mut b) = (a.abs(), b.abs());
while b != 0 {
a %= b;
std::mem::swap(&mut a, &mut b);
}
a.max(1)
}
fn rat_reduce((num, den): (i64, i64)) -> (i64, i64) {
if num == 0 {
return (0, 1);
}
let sign = if (num < 0) ^ (den < 0) { -1 } else { 1 };
let g = gcd(num.abs(), den.abs());
(sign * num.abs() / g, den.abs() / g)
}
fn rat_neg((num, den): (i64, i64)) -> (i64, i64) {
(-num, den)
}
fn rat_mul((an, ad): (i64, i64), (bn, bd): (i64, i64)) -> (i64, i64) {
rat_reduce((an * bn, ad * bd))
}
fn rat_div((an, ad): (i64, i64), (bn, bd): (i64, i64)) -> (i64, i64) {
rat_reduce((an * bd, ad * bn))
}
fn rat_sub((an, ad): (i64, i64), (bn, bd): (i64, i64)) -> (i64, i64) {
rat_reduce((an * bd - bn * ad, ad * bd))
}
#[cfg(test)]
mod tests {
use super::*;
fn var(name: &str, dims: &[(BaseUnit, i32)]) -> PhysicalVar {
let mut v = PhysicalVar::new(name);
for &(unit, exp) in dims {
v.set_dimension(unit, exp);
}
v
}
#[test]
fn test_pendulum_one_pi_group() {
let length = var("L", &[(BaseUnit::Length, 1)]);
let gravity = var("g", &[(BaseUnit::Length, 1), (BaseUnit::Time, -2)]);
let period = var("T", &[(BaseUnit::Time, 1)]);
let groups = BuckinghamPi::analyze(&[length, gravity, period]).unwrap();
assert_eq!(groups.len(), 1, "Pendulum should produce exactly 1 π group");
}
#[test]
fn test_reynolds_number_one_pi_group() {
let rho = var("rho", &[(BaseUnit::Mass, 1), (BaseUnit::Length, -3)]);
let v = var("v", &[(BaseUnit::Length, 1), (BaseUnit::Time, -1)]);
let l = var("L", &[(BaseUnit::Length, 1)]);
let mu = var(
"mu",
&[
(BaseUnit::Mass, 1),
(BaseUnit::Length, -1),
(BaseUnit::Time, -1),
],
);
let groups = BuckinghamPi::analyze(&[rho, v, l, mu]).unwrap();
assert_eq!(groups.len(), 1, "Reynolds number: 1 π group expected");
}
#[test]
fn test_drag_two_pi_groups() {
let f = var(
"F",
&[
(BaseUnit::Mass, 1),
(BaseUnit::Length, 1),
(BaseUnit::Time, -2),
],
);
let rho = var("rho", &[(BaseUnit::Mass, 1), (BaseUnit::Length, -3)]);
let v = var("v", &[(BaseUnit::Length, 1), (BaseUnit::Time, -1)]);
let l = var("L", &[(BaseUnit::Length, 1)]);
let mu = var(
"mu",
&[
(BaseUnit::Mass, 1),
(BaseUnit::Length, -1),
(BaseUnit::Time, -1),
],
);
let groups = BuckinghamPi::analyze(&[f, rho, v, l, mu]).unwrap();
assert_eq!(groups.len(), 2, "Drag: 2 π groups expected");
}
#[test]
fn test_all_dimensionless_error() {
let a = PhysicalVar::new("a");
let b = PhysicalVar::new("b");
let result = BuckinghamPi::analyze(&[a, b]);
assert!(matches!(result, Err(BuckinghamPiError::AllDimensionless)));
}
#[test]
fn test_too_few_variables_error() {
let v = var("L", &[(BaseUnit::Length, 1)]);
let result = BuckinghamPi::analyze(&[v]);
assert!(matches!(result, Err(BuckinghamPiError::TooFewVariables)));
}
#[test]
fn test_pi_groups_are_dimensionless() {
let length = var("L", &[(BaseUnit::Length, 1)]);
let gravity = var("g", &[(BaseUnit::Length, 1), (BaseUnit::Time, -2)]);
let period = var("T", &[(BaseUnit::Time, 1)]);
let variables = [length, gravity, period];
let groups = BuckinghamPi::analyze(&variables).unwrap();
for group in &groups {
for &dim in &BaseUnit::ALL {
let dim_sum: f64 = group
.exponents
.iter()
.map(|(vname, (num, den))| {
let var = variables.iter().find(|v| v.name == *vname).unwrap();
var.exponent(dim) as f64 * (*num as f64 / *den as f64)
})
.sum();
assert!(
dim_sum.abs() < 1e-10,
"π group '{}' is not dimensionless in {dim:?}: sum = {dim_sum}",
group.name
);
}
}
}
#[test]
fn test_pi_group_display_non_empty() {
let length = var("L", &[(BaseUnit::Length, 1)]);
let gravity = var("g", &[(BaseUnit::Length, 1), (BaseUnit::Time, -2)]);
let period = var("T", &[(BaseUnit::Time, 1)]);
let groups = BuckinghamPi::analyze(&[length, gravity, period]).unwrap();
let s = groups[0].display();
assert!(s.contains("π1"), "Display should contain 'π1'");
}
#[test]
fn test_rat_reduce() {
assert_eq!(rat_reduce((6, 4)), (3, 2));
assert_eq!(rat_reduce((-6, 4)), (-3, 2));
assert_eq!(rat_reduce((0, 5)), (0, 1));
}
#[test]
fn test_rat_mul() {
assert_eq!(rat_mul((2, 3), (3, 4)), (1, 2));
}
#[test]
fn test_rat_div() {
assert_eq!(rat_div((1, 2), (1, 4)), (2, 1));
}
#[test]
fn test_rat_sub() {
assert_eq!(rat_sub((3, 4), (1, 4)), (1, 2));
}
}