use crate::types::{Equation, Expr, ExpressionNode};
use std::collections::{BTreeMap, HashMap};
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Rational {
num: i32,
den: i32,
}
impl Rational {
#[must_use]
pub fn int(n: i32) -> Self {
Rational { num: n, den: 1 }
}
#[must_use]
pub fn new(num: i32, den: i32) -> Self {
if den == 0 {
return Rational { num: 0, den: 1 };
}
let sign = if den < 0 { -1 } else { 1 };
let (num, den) = (num * sign, den * sign);
let g = gcd(num.abs(), den);
if g == 0 {
return Rational { num: 0, den: 1 };
}
Rational {
num: num / g,
den: den / g,
}
}
#[must_use]
pub fn from_f64(x: f64) -> Option<Self> {
if !x.is_finite() {
return None;
}
if x.fract() == 0.0 && x.abs() < i32::MAX as f64 {
return Some(Rational::int(x as i32));
}
for places in 1..=6u32 {
let den = 10i32.checked_pow(places)?;
let scaled = x * f64::from(den);
if scaled.fract().abs() < 1e-9 && scaled.abs() < i32::MAX as f64 {
return Some(Rational::new(scaled.round() as i32, den));
}
}
None
}
fn add(self, other: Self) -> Self {
Rational::new(
self.num * other.den + other.num * self.den,
self.den * other.den,
)
}
fn sub(self, other: Self) -> Self {
Rational::new(
self.num * other.den - other.num * self.den,
self.den * other.den,
)
}
fn mul(self, other: Self) -> Self {
Rational::new(self.num * other.num, self.den * other.den)
}
fn is_zero(self) -> bool {
self.num == 0
}
fn checked_from_i64(num: i64, den: i64) -> Option<Self> {
if den == 0 {
return None;
}
let sign = if den < 0 { -1 } else { 1 };
let (num, den) = (num.checked_mul(sign)?, den.checked_mul(sign)?);
let g = gcd_i64(num.abs(), den).max(1);
Some(Rational {
num: i32::try_from(num / g).ok()?,
den: i32::try_from(den / g).ok()?,
})
}
fn checked_add(self, other: Self) -> Option<Self> {
let (a, b, c, d) = self.parts_with(other);
Rational::checked_from_i64(a * d + c * b, b * d)
}
fn checked_sub(self, other: Self) -> Option<Self> {
let (a, b, c, d) = self.parts_with(other);
Rational::checked_from_i64(a * d - c * b, b * d)
}
fn checked_mul(self, other: Self) -> Option<Self> {
let (a, b, c, d) = self.parts_with(other);
Rational::checked_from_i64(a * c, b * d)
}
fn checked_div(self, other: Self) -> Option<Self> {
let (a, b, c, d) = self.parts_with(other);
Rational::checked_from_i64(a * d, b * c)
}
fn parts_with(self, other: Self) -> (i64, i64, i64, i64) {
(
i64::from(self.num),
i64::from(self.den),
i64::from(other.num),
i64::from(other.den),
)
}
fn as_f64(self) -> f64 {
f64::from(self.num) / f64::from(self.den)
}
}
impl std::fmt::Display for Rational {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.den == 1 {
write!(f, "{}", self.num)
} else {
write!(f, "{}/{}", self.num, self.den)
}
}
}
fn gcd(a: i32, b: i32) -> i32 {
if b == 0 { a } else { gcd(b, a % b) }
}
fn gcd_i64(a: i64, b: i64) -> i64 {
if b == 0 { a } else { gcd_i64(b, a % b) }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ScaleFactor {
Prime(u64),
Pi,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ExactScale {
factors: BTreeMap<ScaleFactor, Rational>,
}
impl ExactScale {
#[must_use]
pub fn one() -> Self {
Self::default()
}
#[must_use]
pub fn integer(n: u64) -> Self {
assert!(n > 0, "a unit scale must be positive");
let mut out = Self::one();
let mut rest = n;
let mut p = 2u64;
while p * p <= rest {
while rest.is_multiple_of(p) {
out.bump(ScaleFactor::Prime(p), Rational::int(1));
rest /= p;
}
p += if p == 2 { 1 } else { 2 };
}
if rest > 1 {
out.bump(ScaleFactor::Prime(rest), Rational::int(1));
}
out
}
#[must_use]
pub fn ratio(num: u64, den: u64) -> Self {
Self::integer(num).divide(&Self::integer(den))
}
#[must_use]
pub fn power_of_ten(k: i32) -> Self {
let mut out = Self::one();
out.bump(ScaleFactor::Prime(2), Rational::int(k));
out.bump(ScaleFactor::Prime(5), Rational::int(k));
out
}
#[must_use]
pub fn pi() -> Self {
let mut out = Self::one();
out.bump(ScaleFactor::Pi, Rational::int(1));
out
}
#[must_use]
pub fn decimal(s: &str) -> Self {
let (mantissa, exp) = match s.split_once(['e', 'E']) {
Some((m, e)) => (m, e.parse::<i32>().expect("registry decimal exponent")),
None => (s, 0),
};
let (int_part, frac_part) = mantissa.split_once('.').unwrap_or((mantissa, ""));
let digits = format!("{int_part}{frac_part}");
let n: u64 = digits
.trim_start_matches('0')
.parse()
.expect("registry decimal mantissa");
let places = i32::try_from(frac_part.len()).expect("registry decimal places");
Self::integer(n).multiply(&Self::power_of_ten(exp - places))
}
fn bump(&mut self, factor: ScaleFactor, by: Rational) {
let e = self.factors.entry(factor).or_insert(Rational::int(0));
*e = e.add(by);
if e.is_zero() {
self.factors.remove(&factor);
}
}
#[must_use]
pub fn multiply(&self, other: &Self) -> Self {
let mut out = self.clone();
for (f, e) in &other.factors {
out.bump(*f, *e);
}
out
}
#[must_use]
pub fn divide(&self, other: &Self) -> Self {
let mut out = self.clone();
for (f, e) in &other.factors {
out.bump(*f, e.mul(Rational::int(-1)));
}
out
}
#[must_use]
pub fn power(&self, exponent: Rational) -> Self {
let mut out = Self::one();
for (f, e) in &self.factors {
out.bump(*f, e.mul(exponent));
}
out
}
#[must_use]
pub fn is_one(&self) -> bool {
self.factors.is_empty()
}
#[must_use]
pub fn to_f64(&self) -> f64 {
self.factors.iter().fold(1.0, |acc, (f, e)| {
let base = match f {
ScaleFactor::Prime(p) => *p as f64,
ScaleFactor::Pi => std::f64::consts::PI,
};
acc * base.powf(e.as_f64())
})
}
#[must_use]
pub fn ratio_string(&self) -> Option<String> {
let (mut num, mut den, mut pi) = (1u128, 1u128, 0i32);
for (f, e) in &self.factors {
if e.den != 1 {
return None;
}
match f {
ScaleFactor::Pi => pi = e.num,
ScaleFactor::Prime(p) => {
let pow = u128::from(*p).checked_pow(e.num.unsigned_abs())?;
if e.num > 0 {
num = num.checked_mul(pow)?;
} else {
den = den.checked_mul(pow)?;
}
}
}
}
let mut s = num.to_string();
if den != 1 {
s = format!("{s}/{den}");
}
Some(match pi {
0 => s,
1 => format!("{s}*pi"),
k => format!("{s}*pi^{k}"),
})
}
}
impl std::fmt::Display for ExactScale {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.ratio_string() {
Some(s) => write!(f, "{s}"),
None => write!(f, "{:e}", self.to_f64()),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Unit {
dimensions: HashMap<Dimension, Rational>,
scale: f64,
exact: ExactScale,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Dimension {
Mass, Length, Time, Current, Temperature, Amount, Luminosity, Angle, }
#[derive(Debug, Clone, PartialEq, Error)]
pub enum UnitError {
#[error("Parse error: {0}")]
ParseError(String),
#[error("Dimension mismatch: {0}")]
DimensionMismatch(String),
#[error("Unknown unit: {0}")]
UnknownUnit(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UnitSeverity {
Error,
Analysis,
}
pub const UNIT_FINDING_DIMENSIONAL_MISMATCH: &str = crate::diagnostic::codes::DIMENSIONAL_MISMATCH;
pub const UNIT_FINDING_UNPARSEABLE: &str = crate::diagnostic::codes::UNPARSEABLE_UNIT;
pub const UNIT_FINDING_ANALYSIS: &str = crate::diagnostic::codes::ANALYSIS;
#[derive(Debug, Clone, PartialEq)]
pub struct UnitFinding {
pub severity: UnitSeverity,
pub message: String,
}
impl UnitFinding {
fn error(message: impl Into<String>) -> Self {
UnitFinding {
severity: UnitSeverity::Error,
message: message.into(),
}
}
fn analysis(message: impl Into<String>) -> Self {
UnitFinding {
severity: UnitSeverity::Analysis,
message: message.into(),
}
}
pub fn is_error(&self) -> bool {
self.severity == UnitSeverity::Error
}
pub fn code(&self) -> &'static str {
match self.severity {
UnitSeverity::Error => UNIT_FINDING_DIMENSIONAL_MISMATCH,
UnitSeverity::Analysis => UNIT_FINDING_ANALYSIS,
}
}
}
#[derive(Debug, Clone, PartialEq)]
enum Dim {
Known(Unit),
Unknown,
}
impl Dim {
fn known(&self) -> Option<&Unit> {
match self {
Dim::Known(u) => Some(u),
Dim::Unknown => None,
}
}
}
impl Unit {
pub fn scale(&self) -> f64 {
self.scale
}
pub fn exact_scale(&self) -> &ExactScale {
&self.exact
}
pub fn same_unit(&self, other: &Unit) -> bool {
self.is_compatible(other) && self.exact == other.exact
}
fn with_exact(mut self, exact: ExactScale) -> Self {
self.exact = exact;
self
}
pub fn same_dimensions(&self, other: &Unit) -> bool {
self.dimensions == other.dimensions
}
pub fn dimensionless() -> Self {
Unit {
dimensions: HashMap::new(),
scale: 1.0,
exact: ExactScale::one(),
}
}
pub fn base(dimension: Dimension, power: i32, scale: f64) -> Self {
Unit::base_rational(dimension, Rational::int(power), scale)
}
pub fn base_rational(dimension: Dimension, power: Rational, scale: f64) -> Self {
let mut dimensions = HashMap::new();
if !power.is_zero() {
dimensions.insert(dimension, power);
}
Unit {
dimensions,
scale,
exact: ExactScale::one(),
}
}
pub fn is_compatible(&self, other: &Unit) -> bool {
self.dimensions == other.dimensions
}
pub fn is_dimensionless(&self) -> bool {
self.dimensions.is_empty()
}
pub fn is_pure_number(&self) -> bool {
self.dimensions.is_empty() && self.exact.is_one()
}
pub fn is_plane_angle(&self) -> bool {
self.dimensions.len() == 1
&& self
.dimensions
.get(&Dimension::Angle)
.is_some_and(|p| *p == Rational::int(1))
}
pub fn multiply(&self, other: &Unit) -> Unit {
let mut unit = self.combine(other, Rational::add, self.scale * other.scale);
unit.exact = self.exact.multiply(&other.exact);
unit
}
pub fn divide(&self, other: &Unit) -> Unit {
let mut unit = self.combine(other, Rational::sub, self.scale / other.scale);
unit.exact = self.exact.divide(&other.exact);
unit
}
fn combine(&self, other: &Unit, op: fn(Rational, Rational) -> Rational, scale: f64) -> Unit {
let mut dimensions = self.dimensions.clone();
for (dim, power) in &other.dimensions {
let entry = dimensions.entry(dim.clone()).or_insert(Rational::int(0));
*entry = op(*entry, *power);
if entry.is_zero() {
dimensions.remove(dim);
}
}
Unit {
dimensions,
scale,
exact: ExactScale::one(),
}
}
pub fn power(&self, exponent: i32) -> Unit {
self.power_rational(Rational::int(exponent))
}
pub fn power_rational(&self, exponent: Rational) -> Unit {
let dimensions = self
.dimensions
.iter()
.map(|(dim, power)| (dim.clone(), power.mul(exponent)))
.filter(|(_, power)| !power.is_zero())
.collect();
Unit {
dimensions,
scale: self.scale.powf(exponent.as_f64()),
exact: self.exact.power(exponent),
}
}
pub fn propagate(expr: &Expr, env: &HashMap<String, Unit>) -> Result<Unit, UnitError> {
Self::propagate_in(expr, env)
}
pub(crate) fn propagate_in(expr: &Expr, env: &dyn UnitEnv) -> Result<Unit, UnitError> {
let mut findings = Vec::new();
let dim = propagate_dim(expr, env, &mut findings);
if let Some(err) = findings.iter().find(|f| f.is_error()) {
return Err(UnitError::DimensionMismatch(err.message.clone()));
}
match dim {
Dim::Known(unit) => Ok(unit),
Dim::Unknown => Err(UnitError::UnknownUnit(
findings
.first()
.map(|f| f.message.clone())
.unwrap_or_else(|| "undetermined dimension".to_string()),
)),
}
}
}
fn propagate_dim(expr: &Expr, env: &dyn UnitEnv, findings: &mut Vec<UnitFinding>) -> Dim {
match expr {
Expr::Number(_) | Expr::Integer(_) => Dim::Unknown,
Expr::Variable(name) => {
if let Some(unit) = env.unit_of(name) {
Dim::Known(unit.clone())
} else if name == "t" {
Dim::Known(Unit::base(Dimension::Time, 1, 1.0))
} else {
Dim::Unknown
}
}
Expr::Operator(op) => propagate_operator_dim(op, env, findings),
}
}
fn propagate_args(
op: &ExpressionNode,
env: &dyn UnitEnv,
findings: &mut Vec<UnitFinding>,
) -> Vec<Dim> {
op.args
.iter()
.map(|a| propagate_dim(a, env, findings))
.collect()
}
fn require_arity(op: &ExpressionNode, n: usize, findings: &mut Vec<UnitFinding>) -> bool {
if op.args.len() == n {
return true;
}
findings.push(UnitFinding::analysis(format!(
"'{}' expects {} argument(s) but has {}; not dimension-checked",
op.op,
n,
op.args.len()
)));
false
}
fn require_min_arity(op: &ExpressionNode, n: usize, findings: &mut Vec<UnitFinding>) -> bool {
if op.args.len() >= n {
return true;
}
findings.push(UnitFinding::analysis(format!(
"'{}' expects at least {} argument(s) but has {}; not dimension-checked",
op.op,
n,
op.args.len()
)));
false
}
fn propagate_operator_dim(
op: &ExpressionNode,
env: &dyn UnitEnv,
findings: &mut Vec<UnitFinding>,
) -> Dim {
match op.op.as_str() {
"+" | "-" => propagate_additive_dim(op, env, findings),
"*" | "/" => propagate_multiplicative_dim(op, env, findings),
"^" | "power" | "pow" => propagate_power_dim(op, env, findings),
"D" | "ic" => propagate_calculus_dim(op, env, findings),
"exp" | "log" | "log10" | "ln" | "sin" | "cos" | "tan" | "asin" | "acos" | "atan"
| "sinh" | "cosh" | "tanh" | "asinh" | "acosh" | "atanh" => {
propagate_transcendental_dim(op, env, findings)
}
"sqrt" => propagate_sqrt_dim(op, env, findings),
"abs" | "floor" | "ceil" | "round" | "sign" | "Pre" => {
if !require_arity(op, 1, findings) {
return Dim::Unknown;
}
propagate_dim(&op.args[0], env, findings)
}
"min" | "max" => propagate_matching_dim(op, env, findings),
"atan2" => {
if !require_arity(op, 2, findings) {
return Dim::Unknown;
}
propagate_matching_dim(op, env, findings);
Dim::Known(radian())
}
"ifelse" => propagate_ifelse_dim(op, env, findings),
">" | "<" | ">=" | "<=" | "==" | "!=" => {
if !require_arity(op, 2, findings) {
return Dim::Known(Unit::dimensionless());
}
propagate_matching_dim(op, env, findings);
Dim::Known(Unit::dimensionless())
}
"and" | "or" | "not" => {
propagate_args(op, env, findings);
Dim::Known(Unit::dimensionless())
}
"faq" | "makearray" | "index" | "reshape" | "transpose" | "concat" | "broadcast" => {
propagate_array_dim(op, env, findings)
}
"const" => match op.units.as_deref().map(parse_unit) {
Some(Ok(unit)) => Dim::Known(unit),
_ => Dim::Unknown,
},
_ => Dim::Unknown,
}
}
fn propagate_matching_dim(
op: &ExpressionNode,
env: &dyn UnitEnv,
findings: &mut Vec<UnitFinding>,
) -> Dim {
let mut first: Option<Unit> = None;
for arg in &op.args {
let dim = propagate_dim(arg, env, findings);
let Some(unit) = dim.known() else {
continue;
};
match &first {
None => first = Some(unit.clone()),
Some(f) if !f.is_compatible(unit) => {
findings.push(UnitFinding::error(format!(
"Incompatible dimensions in '{}': {} vs {}",
op.op,
describe(f),
describe(unit)
)));
}
Some(f) if f.exact != unit.exact => {
findings.push(UnitFinding::error(format!(
"Incompatible scales in '{}': {} vs {}",
op.op,
describe_scaled(f),
describe_scaled(unit)
)));
}
_ => {}
}
}
match first {
Some(unit) => Dim::Known(unit),
None => Dim::Unknown,
}
}
fn propagate_additive_dim(
op: &ExpressionNode,
env: &dyn UnitEnv,
findings: &mut Vec<UnitFinding>,
) -> Dim {
propagate_matching_dim(op, env, findings)
}
fn propagate_multiplicative_dim(
op: &ExpressionNode,
env: &dyn UnitEnv,
findings: &mut Vec<UnitFinding>,
) -> Dim {
let dims = propagate_args(op, env, findings);
if op.op == "*" {
let mut result = Unit::dimensionless();
for d in &dims {
match d.known() {
Some(u) => result = result.multiply(u),
None => return Dim::Unknown,
}
}
return Dim::Known(result);
}
if !require_arity(op, 2, findings) {
return Dim::Unknown;
}
match (dims[0].known(), dims[1].known()) {
(Some(num), Some(den)) => Dim::Known(num.divide(den)),
_ => Dim::Unknown,
}
}
fn propagate_power_dim(
op: &ExpressionNode,
env: &dyn UnitEnv,
findings: &mut Vec<UnitFinding>,
) -> Dim {
if !require_arity(op, 2, findings) {
return Dim::Unknown;
}
let base = propagate_dim(&op.args[0], env, findings);
let exp = propagate_dim(&op.args[1], env, findings);
if let Some(e) = exp.known()
&& !e.is_dimensionless()
{
findings.push(UnitFinding::error(format!(
"Exponent in '{}' must be dimensionless, got {}",
op.op,
describe(e)
)));
return Dim::Unknown;
}
let Some(base_unit) = base.known() else {
return Dim::Unknown;
};
if base_unit.is_dimensionless() && base_unit.exact.is_one() {
return Dim::Known(Unit::dimensionless());
}
if let Some(r) = constant_exponent(&op.args[1]) {
return Dim::Known(base_unit.power_rational(r));
}
match &op.args[1] {
Expr::Integer(_) | Expr::Number(_) => {
let n = literal_f64(&op.args[1]).unwrap_or(f64::NAN);
findings.push(UnitFinding::analysis(format!(
"Exponent {n} is not a representable rational, so the dimension of a \
dimensional base is unknown"
)));
}
_ => {
findings.push(UnitFinding::analysis(format!(
"Exponent of '{}' is not a constant expression, so the dimension of a \
dimensional base is unknown",
op.op
)));
}
}
Dim::Unknown
}
fn literal_f64(expr: &Expr) -> Option<f64> {
match expr {
Expr::Integer(i) => Some(*i as f64),
Expr::Number(n) => Some(*n),
_ => None,
}
}
pub fn constant_exponent(expr: &Expr) -> Option<Rational> {
if let Some(n) = literal_f64(expr) {
return Rational::from_f64(n);
}
let Expr::Operator(node) = expr else {
return None;
};
if node.op == "const" {
if let Some(units) = &node.units {
let unit = parse_unit(units).ok()?;
if !unit.is_pure_number() {
return None;
}
}
return node
.value
.as_ref()
.and_then(serde_json::Value::as_f64)
.and_then(Rational::from_f64);
}
let values = node
.args
.iter()
.map(constant_exponent)
.collect::<Option<Vec<Rational>>>()?;
let (first, rest) = values.split_first()?;
match node.op.as_str() {
"+" => rest.iter().try_fold(*first, |acc, v| acc.checked_add(*v)),
"*" => rest.iter().try_fold(*first, |acc, v| acc.checked_mul(*v)),
"-" if rest.is_empty() => Rational::int(0).checked_sub(*first),
"-" => rest.iter().try_fold(*first, |acc, v| acc.checked_sub(*v)),
"/" if rest.len() == 1 => first.checked_div(rest[0]),
_ => None,
}
}
fn propagate_calculus_dim(
op: &ExpressionNode,
env: &dyn UnitEnv,
findings: &mut Vec<UnitFinding>,
) -> Dim {
let well_formed = if crate::op_registry::is_rewrite_target_derivative(op) {
require_min_arity(op, 1, findings)
} else {
require_arity(op, 1, findings)
};
if !well_formed {
return Dim::Unknown;
}
let arg = propagate_dim(&op.args[0], env, findings);
if op.op == "ic" {
return arg;
}
let wrt = crate::op_registry::derivative_wrt(op);
let (Some(arg_unit), Some(wrt_unit)) = (arg.known(), env.unit_of(wrt)) else {
return Dim::Unknown;
};
Dim::Known(arg_unit.divide(wrt_unit))
}
fn derivative_of_undeclared_time<'a>(expr: &'a Expr, env: &dyn UnitEnv) -> Option<&'a Expr> {
let Expr::Operator(op) = expr else {
return None;
};
if op.op != "D" || op.args.len() != 1 {
return None;
}
let wrt = crate::op_registry::derivative_wrt(op);
(!env.contains(wrt)).then(|| &op.args[0])
}
fn derivative_time_mismatch(state: &Unit, rhs: &Unit) -> Option<String> {
let ratio = state.divide(rhs);
let unreconcilable = ratio
.dimensions
.iter()
.any(|(d, p)| *d != Dimension::Time && !p.is_zero());
unreconcilable.then(|| {
format!(
"No time unit can reconcile d({})/dt with {} (their ratio {} is not a power of time)",
describe(state),
describe(rhs),
describe(&ratio)
)
})
}
fn propagate_transcendental_dim(
op: &ExpressionNode,
env: &dyn UnitEnv,
findings: &mut Vec<UnitFinding>,
) -> Dim {
let is_inverse_circular = matches!(op.op.as_str(), "asin" | "acos" | "atan");
let result = if is_inverse_circular {
radian()
} else {
Unit::dimensionless()
};
if !require_arity(op, 1, findings) {
return Dim::Known(result);
}
let angle_ok = matches!(op.op.as_str(), "sin" | "cos" | "tan");
let arg = propagate_dim(&op.args[0], env, findings);
if let Some(u) = arg.known() {
if angle_ok && u.is_plane_angle() {
} else if u.is_pure_number() {
} else if u.is_dimensionless() {
findings.push(UnitFinding::error(scaled_dimensionless_message(&op.op, u)));
} else {
findings.push(UnitFinding::error(format!(
"Argument to '{}' must be dimensionless{}, got {}",
op.op,
if angle_ok { " (or an angle)" } else { "" },
describe(u)
)));
}
}
Dim::Known(result)
}
#[must_use]
pub fn angle_normalization_factor(unit: &Unit) -> Option<f64> {
(unit.is_plane_angle() && !unit.exact.is_one()).then_some(unit.scale)
}
#[must_use]
pub fn normalize_angle_arguments(expr: &Expr, env: &HashMap<String, Unit>) -> Option<Expr> {
let Expr::Operator(node) = expr else {
return None;
};
let mut children: Vec<Option<Expr>> = Vec::new();
node.for_each_child(&mut |child| children.push(normalize_angle_arguments(child, env)));
let mut factor = None;
if matches!(node.op.as_str(), "sin" | "cos" | "tan") && node.args.len() == 1 {
let arg = children[0].as_ref().unwrap_or(&node.args[0]);
let mut findings = Vec::new();
factor = propagate_dim(arg, env, &mut findings)
.known()
.and_then(angle_normalization_factor);
}
if factor.is_none() && children.iter().all(Option::is_none) {
return None;
}
let mut out = (**node).clone();
let mut rewritten = children.into_iter();
out.for_each_child_mut(&mut |child| {
if let Some(Some(next)) = rewritten.next() {
*child = next;
}
});
if let Some(factor) = factor {
let scaled = ExpressionNode {
op: "*".to_string(),
args: vec![out.args[0].clone(), Expr::Number(factor)],
..Default::default()
};
out.args[0] = Expr::operator(scaled);
}
Some(Expr::operator(out))
}
fn radian() -> Unit {
Unit::base(Dimension::Angle, 1, 1.0)
}
const SCALED_DIMENSIONLESS_SPELLINGS: &[(&str, i32)] =
&[("percent", -2), ("ppm", -6), ("ppb", -9), ("ppt", -12)];
#[must_use]
pub fn scaled_dimensionless_spelling(exact: &ExactScale) -> Option<&'static str> {
SCALED_DIMENSIONLESS_SPELLINGS
.iter()
.find(|(_, k)| ExactScale::power_of_ten(*k) == *exact)
.map(|(name, _)| *name)
}
fn scaled_dimensionless_message(op: &str, unit: &Unit) -> String {
match scaled_dimensionless_spelling(&unit.exact) {
Some(name) => format!(
"Argument to '{op}' must be dimensionless at scale 1, but is \
dimensionless at scale {} ({name}); divide by 1 {name}, or by the \
scale you mean, to state which reading is intended",
unit.exact
),
None => format!(
"Argument to '{op}' must be dimensionless at scale 1, but is \
dimensionless at scale {}; divide by a quantity carrying that \
scale to state which reading is intended",
unit.exact
),
}
}
fn propagate_sqrt_dim(
op: &ExpressionNode,
env: &dyn UnitEnv,
findings: &mut Vec<UnitFinding>,
) -> Dim {
if !require_arity(op, 1, findings) {
return Dim::Unknown;
}
let arg = propagate_dim(&op.args[0], env, findings);
match arg.known() {
Some(unit) => Dim::Known(unit.power_rational(Rational::new(1, 2))),
None => Dim::Unknown,
}
}
fn propagate_ifelse_dim(
op: &ExpressionNode,
env: &dyn UnitEnv,
findings: &mut Vec<UnitFinding>,
) -> Dim {
if !require_arity(op, 3, findings) {
return Dim::Unknown;
}
propagate_dim(&op.args[0], env, findings);
let t = propagate_dim(&op.args[1], env, findings);
let f = propagate_dim(&op.args[2], env, findings);
match (t.known(), f.known()) {
(Some(a), Some(b)) if !a.is_compatible(b) => {
findings.push(UnitFinding::error(format!(
"'ifelse' branches must share dimensions: {} vs {}",
describe(a),
describe(b)
)));
Dim::Unknown
}
(Some(a), Some(b)) if a.exact != b.exact => {
findings.push(UnitFinding::error(format!(
"'ifelse' branches must share a scale: {} vs {}",
describe_scaled(a),
describe_scaled(b)
)));
Dim::Unknown
}
(Some(a), _) | (None, Some(a)) => Dim::Known(a.clone()),
(None, None) => Dim::Unknown,
}
}
fn propagate_array_dim(
op: &ExpressionNode,
env: &dyn UnitEnv,
findings: &mut Vec<UnitFinding>,
) -> Dim {
match op.op.as_str() {
"faq" => {
if let Some(filter) = &op.filter {
propagate_dim(filter, env, findings);
}
let body = op
.expr
.as_ref()
.map(|body| propagate_dim(body, env, findings));
propagate_args(op, env, findings);
match body {
Some(body) => faq_unit(op, body),
None => Dim::Unknown,
}
}
"makearray" => {
propagate_args(op, env, findings);
let mut first: Option<Unit> = None;
for value in op.values.iter().flatten() {
let dim = propagate_dim(value, env, findings);
let Some(unit) = dim.known() else {
continue;
};
match &first {
None => first = Some(unit.clone()),
Some(f) if !f.same_unit(unit) => {
findings.push(UnitFinding::error(format!(
"'makearray' regions must share units: {} vs {}",
describe_scaled(f),
describe_scaled(unit)
)));
}
_ => {}
}
}
first.map_or(Dim::Unknown, Dim::Known)
}
"concat" => propagate_matching_dim(op, env, findings),
"broadcast" => {
let Some(fn_name) = op.broadcast_fn.as_deref() else {
propagate_args(op, env, findings);
findings.push(UnitFinding::analysis(
"'broadcast' is missing its 'fn'; its dimension is unknown".to_string(),
));
return Dim::Unknown;
};
let synthetic = ExpressionNode {
op: fn_name.to_string(),
args: op.args.clone(),
..ExpressionNode::default()
};
propagate_operator_dim(&synthetic, env, findings)
}
_ => propagate_args(op, env, findings)
.into_iter()
.next()
.unwrap_or(Dim::Unknown),
}
}
fn faq_unit(op: &ExpressionNode, body: Dim) -> Dim {
if op.distinct == Some(true) || op.key.is_some() {
return Dim::Unknown;
}
match crate::faq::effective_reduce_kind(op.semiring.as_deref(), op.reduce.as_deref()) {
Ok(
crate::faq::ReduceKind::Sum | crate::faq::ReduceKind::Max | crate::faq::ReduceKind::Min,
) => body,
Ok(crate::faq::ReduceKind::Product) => {
if body.known().is_some_and(Unit::is_pure_number) {
body
} else {
Dim::Unknown
}
}
Ok(crate::faq::ReduceKind::Or | crate::faq::ReduceKind::And) => {
Dim::Known(Unit::dimensionless())
}
Err(_) => Dim::Unknown,
}
}
fn describe(unit: &Unit) -> String {
if unit.is_dimensionless() {
return "dimensionless".to_string();
}
let mut parts: Vec<String> = unit
.dimensions
.iter()
.map(|(d, p)| {
let name = match d {
Dimension::Mass => "mass",
Dimension::Length => "length",
Dimension::Time => "time",
Dimension::Current => "current",
Dimension::Temperature => "temperature",
Dimension::Amount => "amount",
Dimension::Luminosity => "luminosity",
Dimension::Angle => "angle",
};
if *p == Rational::int(1) {
name.to_string()
} else {
format!("{name}^{p}")
}
})
.collect();
parts.sort();
parts.join("*")
}
fn describe_scaled(unit: &Unit) -> String {
if unit.exact.is_one() {
describe(unit)
} else {
format!("{} (scale {})", describe(unit), unit.exact)
}
}
pub fn build_unit_env(
variables: &indexmap::IndexMap<String, crate::ModelVariable>,
) -> (HashMap<String, Unit>, Vec<UnitParseFailure>) {
let mut env = HashMap::new();
let mut failures = Vec::new();
let mut parsed: HashMap<&str, Option<Unit>> = HashMap::new();
for (name, var) in variables {
let Some(declared) = &var.units else {
continue;
};
let unit = parsed
.entry(declared.as_str())
.or_insert_with(|| parse_unit(declared).ok());
match unit {
Some(unit) => {
env.insert(name.clone(), unit.clone());
}
None => failures.push(UnitParseFailure {
name: name.clone(),
units: declared.clone(),
}),
}
}
failures.sort_by(|a, b| a.name.cmp(&b.name));
(env, failures)
}
pub(crate) trait UnitEnv {
fn unit_of(&self, name: &str) -> Option<&Unit>;
fn contains(&self, name: &str) -> bool {
self.unit_of(name).is_some()
}
}
impl<S: std::hash::BuildHasher> UnitEnv for HashMap<String, Unit, S> {
fn unit_of(&self, name: &str) -> Option<&Unit> {
self.get(name)
}
}
pub(crate) fn document_scoped_units(esm_file: &crate::EsmFile) -> HashMap<String, Unit> {
let mut parsed: HashMap<String, Option<Unit>> = HashMap::new();
crate::structural::toplevel_reference_units(esm_file)
.into_iter()
.filter_map(|(name, units)| {
parsed
.entry(units)
.or_insert_with_key(|units| parse_unit(units).ok())
.clone()
.map(|unit| (name, unit))
})
.collect()
}
pub(crate) struct ComponentUnitEnv<'d> {
own: HashMap<String, Unit>,
relative: HashMap<String, Option<Unit>>,
document: &'d HashMap<String, Unit>,
}
impl<'d> ComponentUnitEnv<'d> {
pub(crate) fn new(
own: HashMap<String, Unit>,
subsystems: Option<&indexmap::IndexMap<String, serde_json::Value>>,
document: &'d HashMap<String, Unit>,
) -> Self {
let mut parsed: HashMap<String, Option<Unit>> = HashMap::new();
let relative = subsystems
.map(crate::structural::subsystem_reference_units)
.unwrap_or_default()
.into_iter()
.filter(|(name, _)| !own.contains_key(name))
.map(|(name, units)| {
let unit = units.and_then(|units| {
parsed
.entry(units)
.or_insert_with_key(|units| parse_unit(units).ok())
.clone()
});
(name, unit)
})
.collect();
ComponentUnitEnv {
own,
relative,
document,
}
}
pub(crate) fn into_map(self) -> HashMap<String, Unit> {
let mut env = self.own;
for (name, unit) in self.document {
if !env.contains_key(name) && !self.relative.contains_key(name) {
env.insert(name.clone(), unit.clone());
}
}
for (name, unit) in self.relative {
if let Some(unit) = unit {
env.entry(name).or_insert(unit);
}
}
env
}
}
impl UnitEnv for ComponentUnitEnv<'_> {
fn unit_of(&self, name: &str) -> Option<&Unit> {
if let Some(unit) = self.own.get(name) {
return Some(unit);
}
match self.relative.get(name) {
Some(unit) => unit.as_ref(),
None => self.document.get(name),
}
}
}
pub fn model_unit_env(
esm_file: &crate::EsmFile,
model: &crate::Model,
) -> (HashMap<String, Unit>, Vec<UnitParseFailure>) {
let document = document_scoped_units(esm_file);
let (own, failures) = build_unit_env(&model.variables);
let env = ComponentUnitEnv::new(own, model.subsystems.as_ref(), &document).into_map();
(env, failures)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnitParseFailure {
pub name: String,
pub units: String,
}
pub fn validate_equation_dimensions(
eq: &Equation,
env: &HashMap<String, Unit>,
) -> Result<(), UnitError> {
if let Some(err) = check_equation_dimensions(eq, env)
.iter()
.find(|f| f.is_error())
{
return Err(UnitError::DimensionMismatch(err.message.clone()));
}
let mut sink = Vec::new();
let lhs = propagate_dim(&eq.lhs, env, &mut sink);
let rhs = propagate_dim(&eq.rhs, env, &mut sink);
if lhs.known().is_none() || rhs.known().is_none() {
return Err(UnitError::UnknownUnit(
"equation has an indeterminate side; skipped for dimensional checking".to_string(),
));
}
Ok(())
}
pub fn check_equation_dimensions(eq: &Equation, env: &HashMap<String, Unit>) -> Vec<UnitFinding> {
equation_findings(eq, env)
}
pub(crate) fn equation_findings(eq: &Equation, env: &dyn UnitEnv) -> Vec<UnitFinding> {
let mut findings = Vec::new();
let lhs = propagate_dim(&eq.lhs, env, &mut findings);
let rhs = propagate_dim(&eq.rhs, env, &mut findings);
if let Some(state) = derivative_of_undeclared_time(&eq.lhs, env) {
let state_dim = propagate_dim(state, env, &mut Vec::new());
if let (Some(s), Some(r)) = (state_dim.known(), rhs.known())
&& let Some(message) = derivative_time_mismatch(s, r)
{
findings.push(UnitFinding::error(message));
}
return findings;
}
if let (Some(l), Some(r)) = (lhs.known(), rhs.known())
&& !l.same_unit(r)
{
findings.push(UnitFinding::error(format!(
"Left-hand side has units of {} but right-hand side has units of {}",
describe_scaled(l),
describe_scaled(r)
)));
}
findings
}
pub fn check_expression_dimensions(
expr: &Expr,
declared: Option<&Unit>,
env: &HashMap<String, Unit>,
) -> Vec<UnitFinding> {
expression_findings(expr, declared, env)
}
pub(crate) fn expression_findings(
expr: &Expr,
declared: Option<&Unit>,
env: &dyn UnitEnv,
) -> Vec<UnitFinding> {
let mut findings = Vec::new();
let actual = propagate_dim(expr, env, &mut findings);
if findings.iter().any(UnitFinding::is_error) {
return findings;
}
if let (Some(declared), Some(actual)) = (declared, actual.known())
&& !declared.same_unit(actual)
{
findings.push(UnitFinding::error(format!(
"Declared units of {} do not match the expression's units of {}",
describe_scaled(declared),
describe_scaled(actual)
)));
}
findings
}
#[derive(Debug, Clone, PartialEq)]
pub enum EquationDimensionVerdict {
Consistent(String),
Mismatch(Vec<String>),
NotChecked(String),
}
pub fn equation_dimension_verdict(
eq: &Equation,
env: &HashMap<String, Unit>,
) -> EquationDimensionVerdict {
let errors: Vec<String> = check_equation_dimensions(eq, env)
.into_iter()
.filter(UnitFinding::is_error)
.map(|f| f.message)
.collect();
if !errors.is_empty() {
return EquationDimensionVerdict::Mismatch(errors);
}
let rhs = propagate_dim(&eq.rhs, env, &mut Vec::new());
let (lhs_label, lhs_expr) = match derivative_of_undeclared_time(&eq.lhs, env) {
Some(state) => ("differentiated state", state),
None => ("left-hand side", &eq.lhs),
};
let lhs = propagate_dim(lhs_expr, env, &mut Vec::new());
match (lhs.known(), rhs.known()) {
(Some(_), Some(r)) => EquationDimensionVerdict::Consistent(describe(r)),
(None, _) => EquationDimensionVerdict::NotChecked(format!(
"{lhs_label}: {}",
indeterminate_operand(lhs_expr, env)
)),
(_, None) => EquationDimensionVerdict::NotChecked(format!(
"right-hand side: {}",
indeterminate_operand(&eq.rhs, env)
)),
}
}
fn indeterminate_operand(expr: &Expr, env: &dyn UnitEnv) -> String {
match expr {
Expr::Number(n) => format!("operand `{n}` has an indeterminate dimension"),
Expr::Integer(n) => format!("operand `{n}` has an indeterminate dimension"),
Expr::Variable(name) => format!("variable `{name}` has no declared units"),
Expr::Operator(op) => {
let mut literal: Option<String> = None;
for arg in &op.args {
if propagate_dim(arg, env, &mut Vec::new()).known().is_some() {
continue;
}
if matches!(arg, Expr::Number(_) | Expr::Integer(_)) {
literal.get_or_insert_with(|| indeterminate_operand(arg, env));
continue;
}
return indeterminate_operand(arg, env);
}
literal.unwrap_or_else(|| format!("`{}` node has no determinable dimension", op.op))
}
}
}
pub fn unresolvable_const_units(expr: &Expr) -> Vec<String> {
fn walk(expr: &Expr, out: &mut Vec<String>) {
if let Expr::Operator(node) = expr {
if node.op == "const"
&& let Some(units) = &node.units
&& parse_unit(units).is_err()
{
out.push(units.clone());
}
node.for_each_child(&mut |child| walk(child, out));
}
}
let mut out = Vec::new();
walk(expr, &mut out);
out
}
pub fn reject_const_units_pre_v12(
view: &serde_json::Value,
) -> Result<(), crate::diagnostic::DiagnosticError> {
reject_const_units_pre_v12_found(view, || {
crate::json_visit::find_value_path(view, &mut |v| {
v.as_object().is_some_and(|o| {
crate::json_visit::small_get(o, "op").is_some()
&& crate::json_visit::small_get(o, "units").is_some()
})
})
})
}
pub(crate) fn reject_const_units_pre_v12_found(
view: &serde_json::Value,
offending: impl FnOnce() -> Option<String>,
) -> Result<(), crate::diagnostic::DiagnosticError> {
let Some(esm) = view.get("esm").and_then(|v| v.as_str()) else {
return Ok(());
};
let Some((major, minor, _)) = crate::diagnostic::parse_semver(esm) else {
return Ok(());
};
if (major, minor) >= (1, 2) {
return Ok(());
}
match offending() {
None => Ok(()),
Some(path) => Err(crate::diagnostic::err(
crate::diagnostic::codes::CONST_UNITS_VERSION_TOO_OLD,
format!(
"declared `units` on an expression node require esm >= 1.2.0; file declares \
{esm}. Offending path: {path}"
),
)),
}
}
pub fn parse_unit(unit_str: &str) -> Result<Unit, UnitError> {
parse_normalized(&normalize_unit_str(unit_str), unit_str)
}
fn normalize_unit_str(s: &str) -> String {
let s = s.replace("°C", "degC").replace("**", "^");
let mut out = String::with_capacity(s.len());
let mut in_superscript = false;
for c in s.chars() {
let sup = match c {
'⁰' => Some('0'),
'¹' => Some('1'),
'²' => Some('2'),
'³' => Some('3'),
'⁴' => Some('4'),
'⁵' => Some('5'),
'⁶' => Some('6'),
'⁷' => Some('7'),
'⁸' => Some('8'),
'⁹' => Some('9'),
'⁻' => Some('-'),
_ => None,
};
match sup {
Some(ascii) => {
if !in_superscript {
out.push('^');
in_superscript = true;
}
out.push(ascii);
}
None => {
in_superscript = false;
match c {
'·' | '⋅' | '×' => out.push('*'),
'µ' | 'μ' => out.push('u'),
'Ω' => out.push_str("Ohm"),
_ => out.push(c),
}
}
}
}
out
}
fn parse_normalized(s: &str, original: &str) -> Result<Unit, UnitError> {
let s = s.trim();
if s.is_empty() || s == "1" || s == "dimensionless" {
return Ok(Unit::dimensionless());
}
if s.starts_with('(') && s.ends_with(')') && parens_balance(&s[1..s.len() - 1]) {
return parse_normalized(&s[1..s.len() - 1], original);
}
let base_units = get_base_units();
if let Some(unit) = base_units.get(s) {
return Ok(unit.clone());
}
if let Some(unit) = parse_si_prefixed(s, base_units) {
return Ok(unit);
}
if let Some((idx, len, sym)) = find_last_top_level_operator(s) {
let (left, right) = (&s[..idx], &s[idx + len..]);
if left.trim().is_empty() || right.trim().is_empty() {
return Err(UnitError::ParseError(format!(
"Dangling '{sym}' in unit \"{original}\""
)));
}
let l = parse_normalized(left, original)?;
let r = parse_normalized(right, original)?;
return Ok(match sym {
'/' => l.divide(&r),
_ => l.multiply(&r),
});
}
if let Some(idx) = s.rfind('^') {
let (base_s, pow_s) = (&s[..idx], &s[idx + 1..]);
let base_unit = parse_normalized(base_s, original)?;
let power = parse_exponent(pow_s).ok_or_else(|| {
UnitError::ParseError(format!(
"Invalid exponent \"{pow_s}\" in unit \"{original}\""
))
})?;
return Ok(base_unit.power_rational(power));
}
if let Ok(v) = s.parse::<f64>() {
if v != 1.0 {
return Err(UnitError::ParseError(format!(
"unit \"{original}\": a number other than 1 is a scaling factor, not a unit (esm-spec 4.8.2); a rational exponent is spelled ^(p/q)"
)));
}
return Ok(Unit {
dimensions: HashMap::new(),
scale: 1.0,
exact: ExactScale::one(),
});
}
Err(UnitError::UnknownUnit(original.to_string()))
}
fn parse_si_prefixed(s: &str, base_units: &HashMap<String, Unit>) -> Option<Unit> {
const PREFIXES: [(&str, f64, i32); 20] = [
("da", 1e1, 1),
("Y", 1e24, 24),
("Z", 1e21, 21),
("E", 1e18, 18),
("P", 1e15, 15),
("T", 1e12, 12),
("G", 1e9, 9),
("M", 1e6, 6),
("k", 1e3, 3),
("h", 1e2, 2),
("d", 1e-1, -1),
("c", 1e-2, -2),
("m", 1e-3, -3),
("u", 1e-6, -6),
("n", 1e-9, -9),
("p", 1e-12, -12),
("f", 1e-15, -15),
("a", 1e-18, -18),
("z", 1e-21, -21),
("y", 1e-24, -24),
];
const PREFIXABLE: [&str; 13] = [
"m", "g", "s", "mol", "K", "A", "cd", "L", "N", "Pa", "J", "W", "rad",
];
for (prefix, factor, power) in PREFIXES {
let Some(base) = s.strip_prefix(prefix) else {
continue;
};
if !PREFIXABLE.contains(&base) {
continue;
}
let mut unit = base_units.get(base)?.clone();
unit.scale *= factor;
unit.exact = unit.exact.multiply(&ExactScale::power_of_ten(power));
return Some(unit);
}
None
}
fn parse_exponent(s: &str) -> Option<Rational> {
let s = s.trim();
let inner = s
.strip_prefix('(')
.and_then(|rest| rest.strip_suffix(')'))
.unwrap_or(s)
.trim();
if let Some((num, den)) = inner.split_once('/') {
return Some(Rational::new(
num.trim().parse().ok()?,
den.trim().parse().ok()?,
));
}
if let Ok(n) = inner.parse::<i32>() {
return Some(Rational::int(n));
}
Rational::from_f64(inner.parse::<f64>().ok()?)
}
fn find_last_top_level_operator(s: &str) -> Option<(usize, usize, char)> {
let bytes = s.as_bytes();
let mut depth = 0i32;
let mut last = None;
let mut i = 0usize;
while i < bytes.len() {
let c = bytes[i] as char;
match c {
'(' => depth += 1,
')' => depth -= 1,
'*' | '/' if depth == 0 => last = Some((i, 1, c)),
' ' | '\t' if depth == 0 => {
let start = i;
while i < bytes.len() && matches!(bytes[i] as char, ' ' | '\t') {
i += 1;
}
let before = s[..start].trim_end().chars().last();
let after = s[i..].chars().next();
let padding = matches!(before, Some('*' | '/' | '^') | None)
|| matches!(after, Some('*' | '/' | '^') | None);
if !padding {
last = Some((start, i - start, '*'));
}
continue;
}
_ => {}
}
i += 1;
}
last
}
fn parens_balance(s: &str) -> bool {
let mut depth = 0i32;
for c in s.chars() {
match c {
'(' => depth += 1,
')' => {
depth -= 1;
if depth < 0 {
return false;
}
}
_ => {}
}
}
depth == 0
}
fn get_base_units() -> &'static HashMap<String, Unit> {
static BASE_UNITS: std::sync::LazyLock<HashMap<String, Unit>> =
std::sync::LazyLock::new(build_base_units);
&BASE_UNITS
}
fn build_base_units() -> HashMap<String, Unit> {
let mut units = HashMap::new();
units.insert("m".to_string(), Unit::base(Dimension::Length, 1, 1.0));
units.insert(
"cm".to_string(),
Unit::base(Dimension::Length, 1, 0.01).with_exact(ExactScale::power_of_ten(-2)),
);
units.insert(
"km".to_string(),
Unit::base(Dimension::Length, 1, 1000.0).with_exact(ExactScale::power_of_ten(3)),
);
units.insert(
"mm".to_string(),
Unit::base(Dimension::Length, 1, 0.001).with_exact(ExactScale::power_of_ten(-3)),
);
units.insert("meter".to_string(), Unit::base(Dimension::Length, 1, 1.0));
units.insert("meters".to_string(), Unit::base(Dimension::Length, 1, 1.0));
units.insert(
"ft".to_string(),
Unit::base(Dimension::Length, 1, 0.3048).with_exact(ExactScale::decimal("0.3048")),
);
units.insert(
"mi".to_string(),
Unit::base(Dimension::Length, 1, 5280.0 * units["ft"].scale)
.with_exact(ExactScale::integer(5280).multiply(&units["ft"].exact)),
);
units.insert("s".to_string(), Unit::base(Dimension::Time, 1, 1.0));
units.insert(
"min".to_string(),
Unit::base(Dimension::Time, 1, 60.0).with_exact(ExactScale::integer(60)),
);
units.insert(
"h".to_string(),
Unit::base(Dimension::Time, 1, 3600.0).with_exact(ExactScale::integer(3600)),
);
units.insert(
"hr".to_string(),
Unit::base(Dimension::Time, 1, 3600.0).with_exact(ExactScale::integer(3600)),
);
units.insert(
"hour".to_string(),
Unit::base(Dimension::Time, 1, 3600.0).with_exact(ExactScale::integer(3600)),
);
units.insert(
"day".to_string(),
Unit::base(Dimension::Time, 1, 86400.0).with_exact(ExactScale::integer(86400)),
);
units.insert("kg".to_string(), Unit::base(Dimension::Mass, 1, 1.0));
units.insert(
"g".to_string(),
Unit::base(Dimension::Mass, 1, 0.001).with_exact(ExactScale::power_of_ten(-3)),
);
units.insert(
"lb".to_string(),
Unit::base(Dimension::Mass, 1, 0.45359237).with_exact(ExactScale::decimal("0.45359237")),
);
units.insert(
"short_ton".to_string(),
Unit::base(Dimension::Mass, 1, 2000.0 * units["lb"].scale)
.with_exact(ExactScale::integer(2000).multiply(&units["lb"].exact)),
);
units.insert(
"tonne".to_string(),
Unit::base(Dimension::Mass, 1, 1000.0).with_exact(ExactScale::power_of_ten(3)),
);
units.insert("mol".to_string(), Unit::base(Dimension::Amount, 1, 1.0));
units.insert("K".to_string(), Unit::base(Dimension::Temperature, 1, 1.0));
units.insert(
"degC".to_string(),
Unit::base(Dimension::Temperature, 1, 1.0),
);
units.insert(
"Celsius".to_string(),
Unit::base(Dimension::Temperature, 1, 1.0),
);
units.insert("A".to_string(), Unit::base(Dimension::Current, 1, 1.0));
units.insert("cd".to_string(), Unit::base(Dimension::Luminosity, 1, 1.0));
units.insert("rad".to_string(), Unit::base(Dimension::Angle, 1, 1.0));
let degree = Unit::base(Dimension::Angle, 1, std::f64::consts::PI / 180.0)
.with_exact(ExactScale::pi().divide(&ExactScale::integer(180)));
units.insert("degrees".to_string(), degree.clone());
units.insert("degree".to_string(), degree.clone());
units.insert("deg".to_string(), degree);
units.insert("sr".to_string(), Unit::base(Dimension::Angle, 2, 1.0));
units.insert(
"L".to_string(),
Unit::base(Dimension::Length, 3, 0.001).with_exact(ExactScale::power_of_ten(-3)),
);
units.insert(
"gal".to_string(),
Unit::base(Dimension::Length, 3, 0.003_785_411_784)
.with_exact(ExactScale::decimal("0.003785411784")),
);
units.insert("Hz".to_string(), Unit::base(Dimension::Time, -1, 1.0));
units.insert(
"degF".to_string(),
Unit::base(Dimension::Temperature, 1, 5.0 / 9.0).with_exact(ExactScale::ratio(5, 9)),
);
units.insert(
"year".to_string(),
Unit::base(Dimension::Time, 1, 31_557_600.0).with_exact(ExactScale::integer(31_557_600)),
);
units.insert(
"yr".to_string(),
Unit::base(Dimension::Time, 1, 31_557_600.0).with_exact(ExactScale::integer(31_557_600)),
);
units.insert(
"m/s".to_string(),
Unit::base(Dimension::Length, 1, 1.0).divide(&Unit::base(Dimension::Time, 1, 1.0)),
);
let liter = Unit::base(Dimension::Length, 3, 0.001).with_exact(ExactScale::power_of_ten(-3));
units.insert(
"mol/L".to_string(),
Unit::base(Dimension::Amount, 1, 1.0).divide(&liter),
);
units.insert(
"M".to_string(),
Unit::base(Dimension::Amount, 1, 1.0).divide(&liter),
);
let newton = Unit::base(Dimension::Mass, 1, 1.0)
.multiply(&Unit::base(Dimension::Length, 1, 1.0))
.divide(&Unit::base(Dimension::Time, 2, 1.0));
units.insert("N".to_string(), newton.clone());
let pascal = Unit::base(Dimension::Mass, 1, 1.0)
.divide(&Unit::base(Dimension::Length, 1, 1.0))
.divide(&Unit::base(Dimension::Time, 2, 1.0));
units.insert("Pa".to_string(), pascal);
let joule = Unit::base(Dimension::Mass, 1, 1.0)
.multiply(&Unit::base(Dimension::Length, 2, 1.0))
.divide(&Unit::base(Dimension::Time, 2, 1.0));
units.insert("J".to_string(), joule.clone());
let mut kj = joule.clone();
kj.scale *= 1000.0;
kj.exact = kj.exact.multiply(&ExactScale::power_of_ten(3));
units.insert("kJ".to_string(), kj);
let mut cal = joule.clone();
cal.scale *= 4.184;
cal.exact = cal.exact.multiply(&ExactScale::decimal("4.184"));
units.insert("cal".to_string(), cal);
let mut kcal = joule.clone();
kcal.scale *= 4184.0;
kcal.exact = kcal.exact.multiply(&ExactScale::integer(4184));
units.insert("kcal".to_string(), kcal);
let watt = Unit::base(Dimension::Mass, 1, 1.0)
.multiply(&Unit::base(Dimension::Length, 2, 1.0))
.divide(&Unit::base(Dimension::Time, 3, 1.0));
units.insert("W".to_string(), watt.clone());
let foot = units["ft"].scale;
let pound = units["lb"].scale;
let mut horsepower = watt.clone();
horsepower.scale *= 550.0 * foot * pound * 9.80665;
horsepower.exact = horsepower.exact.multiply(
&ExactScale::integer(550)
.multiply(&units["ft"].exact)
.multiply(&units["lb"].exact)
.multiply(&ExactScale::decimal("9.80665")),
);
units.insert("hp".to_string(), horsepower);
let ampere = Unit::base(Dimension::Current, 1, 1.0);
let coulomb = ampere.multiply(&Unit::base(Dimension::Time, 1, 1.0));
units.insert("C".to_string(), coulomb.clone());
let volt = watt.divide(&ere);
units.insert("V".to_string(), volt.clone());
units.insert("Ohm".to_string(), volt.divide(&ere));
units.insert("F".to_string(), coulomb.divide(&volt));
units.insert(
"T".to_string(),
Unit::base(Dimension::Mass, 1, 1.0)
.divide(&Unit::base(Dimension::Time, 2, 1.0))
.divide(&ere),
);
let joule_scaled = |scale: f64, exact: ExactScale| {
let mut u = joule.clone();
u.scale *= scale;
u.exact = u.exact.multiply(&exact);
u
};
units.insert(
"erg".to_string(),
joule_scaled(1e-7, ExactScale::power_of_ten(-7)),
);
units.insert(
"BTU".to_string(),
joule_scaled(1_055.055_852_62, ExactScale::decimal("1055.05585262")),
);
units.insert(
"Wh".to_string(),
joule_scaled(3600.0, ExactScale::integer(3600)),
);
units.insert(
"kWh".to_string(),
joule_scaled(3.6e6, ExactScale::integer(3_600_000)),
);
let dimensionless_scaled = |scale: f64, exact: ExactScale| Unit {
dimensions: HashMap::new(),
scale,
exact,
};
units.insert(
"ppm".to_string(),
dimensionless_scaled(1e-6, ExactScale::power_of_ten(-6)),
);
units.insert(
"ppmv".to_string(),
dimensionless_scaled(1e-6, ExactScale::power_of_ten(-6)),
);
units.insert(
"ppb".to_string(),
dimensionless_scaled(1e-9, ExactScale::power_of_ten(-9)),
);
units.insert(
"ppbv".to_string(),
dimensionless_scaled(1e-9, ExactScale::power_of_ten(-9)),
);
units.insert(
"ppt".to_string(),
dimensionless_scaled(1e-12, ExactScale::power_of_ten(-12)),
);
units.insert(
"pptv".to_string(),
dimensionless_scaled(1e-12, ExactScale::power_of_ten(-12)),
);
units.insert(
"%".to_string(),
dimensionless_scaled(0.01, ExactScale::power_of_ten(-2)),
);
units.insert(
"percent".to_string(),
dimensionless_scaled(0.01, ExactScale::power_of_ten(-2)),
);
for count in [
"molec",
"molecule",
"count",
"individuals",
"vehicles",
"units",
] {
units.insert(count.to_string(), Unit::dimensionless());
}
units.insert("psu".to_string(), Unit::dimensionless());
units.insert(
"atm".to_string(),
pascal_scaled(&units, 101_325.0, ExactScale::integer(101_325)),
);
units.insert(
"uatm".to_string(),
pascal_scaled(&units, 0.101_325, ExactScale::decimal("0.101325")),
);
units.insert(
"bar".to_string(),
pascal_scaled(&units, 1e5, ExactScale::power_of_ten(5)),
);
units.insert(
"Torr".to_string(),
pascal_scaled(&units, 101_325.0 / 760.0, ExactScale::ratio(101_325, 760)),
);
units.insert(
"mmHg".to_string(),
pascal_scaled(
&units,
133.322_387_415,
ExactScale::decimal("133.322387415"),
),
);
units.insert(
"inHg".to_string(),
pascal_scaled(
&units,
3_386.388_640_341,
ExactScale::decimal("3386.388640341"),
),
);
let psi_scale = units["lb"].scale * 9.80665 / (0.0254 * 0.0254);
let psi_exact = units["lb"]
.exact
.multiply(&ExactScale::decimal("9.80665"))
.divide(&ExactScale::decimal("0.0254").power(Rational::int(2)));
units.insert(
"psi".to_string(),
pascal_scaled(&units, psi_scale, psi_exact),
);
units.insert(
"Dobson".to_string(),
Unit::base(Dimension::Length, -2, 2.6867e20).with_exact(ExactScale::decimal("2.6867e20")),
);
units.insert(
"DU".to_string(),
Unit::base(Dimension::Length, -2, 2.6867e20).with_exact(ExactScale::decimal("2.6867e20")),
);
units
}
fn pascal_scaled(units: &HashMap<String, Unit>, scale: f64, exact: ExactScale) -> Unit {
let mut u = units["Pa"].clone();
u.scale *= scale;
u.exact = u.exact.multiply(&exact);
u
}
pub fn check_dimensional_consistency(lhs_unit: &Unit, rhs_unit: &Unit) -> Result<(), UnitError> {
if lhs_unit.is_compatible(rhs_unit) {
Ok(())
} else {
Err(UnitError::DimensionMismatch(
"Left and right sides have incompatible dimensions".to_string(),
))
}
}
pub fn convert_units(value: f64, from_unit: &Unit, to_unit: &Unit) -> Result<f64, UnitError> {
if !from_unit.is_compatible(to_unit) {
return Err(UnitError::DimensionMismatch(
"Units have incompatible dimensions".to_string(),
));
}
Ok(value * from_unit.scale / to_unit.scale)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ExpressionNode;
fn op(name: &str, args: Vec<Expr>) -> Expr {
Expr::operator(ExpressionNode {
op: name.to_string(),
args,
..ExpressionNode::default()
})
}
fn op_with_wrt(name: &str, args: Vec<Expr>, wrt: &str) -> Expr {
Expr::operator(ExpressionNode {
op: name.to_string(),
args,
wrt: Some(wrt.to_string()),
..ExpressionNode::default()
})
}
fn env_of(pairs: &[(&str, &str)]) -> HashMap<String, Unit> {
pairs
.iter()
.map(|(k, v)| ((*k).to_string(), parse_unit(v).unwrap()))
.collect()
}
#[test]
fn test_parse_dimensionless() {
assert!(parse_unit("").unwrap().is_dimensionless());
assert!(parse_unit("1").unwrap().is_dimensionless());
assert!(parse_unit("dimensionless").unwrap().is_dimensionless());
}
#[test]
fn test_rational_exponents() {
let half = Rational::new(-1, 2);
assert_eq!(
parse_unit("1/s^0.5")
.unwrap()
.dimensions
.get(&Dimension::Time),
Some(&half)
);
assert_eq!(
parse_unit("s^(-1/2)")
.unwrap()
.dimensions
.get(&Dimension::Time),
Some(&half)
);
assert_eq!(
parse_unit("m**3")
.unwrap()
.dimensions
.get(&Dimension::Length),
Some(&Rational::int(3))
);
assert_eq!(Rational::new(2, 4), Rational::new(1, 2));
assert_eq!(Rational::from_f64(0.5), Some(Rational::new(1, 2)));
}
#[test]
fn test_unicode_normalization() {
let u = parse_unit("W/m²").unwrap();
assert_eq!(u.dimensions.get(&Dimension::Mass), Some(&Rational::int(1)));
assert_eq!(u.dimensions.get(&Dimension::Time), Some(&Rational::int(-3)));
assert_eq!(u.dimensions.get(&Dimension::Length), None);
assert_eq!(
parse_unit("cm³")
.unwrap()
.dimensions
.get(&Dimension::Length),
Some(&Rational::int(3))
);
assert_eq!(
parse_unit("m⁻³")
.unwrap()
.dimensions
.get(&Dimension::Length),
Some(&Rational::int(-3))
);
assert_eq!(
parse_unit("J/(kg·K)").unwrap(),
parse_unit("J/(kg*K)").unwrap()
);
assert_eq!(parse_unit("kg⋅m/s").unwrap(), parse_unit("kg*m/s").unwrap());
assert_eq!(parse_unit("μg/m^3").unwrap(), parse_unit("ug/m^3").unwrap());
assert_eq!(parse_unit("µg").unwrap(), parse_unit("ug").unwrap());
assert!(
parse_unit("°C")
.unwrap()
.is_compatible(&parse_unit("K").unwrap())
);
}
#[test]
fn test_implicit_multiplication() {
assert_eq!(
parse_unit("ppb^-1 s^-1").unwrap(),
parse_unit("ppb^-1*s^-1").unwrap()
);
assert_eq!(parse_unit("m / s").unwrap(), parse_unit("m/s").unwrap());
assert_eq!(parse_unit("kg * m").unwrap(), parse_unit("kg*m").unwrap());
}
#[test]
fn test_inches_of_mercury() {
let inhg = parse_unit("inHg").unwrap();
let mmhg = parse_unit("mmHg").unwrap();
assert!(
inhg.is_compatible(&parse_unit("Pa").unwrap()),
"inHg must carry the pressure dimension"
);
assert_eq!(
inhg.scale,
mmhg.scale * 25.4,
"inHg must be EXACTLY 25.4 mmHg, not merely close to it"
);
}
#[test]
fn test_international_mile() {
let mi = parse_unit("mi").unwrap();
let ft = parse_unit("ft").unwrap();
assert!(
mi.is_compatible(&parse_unit("m").unwrap()),
"mi must carry the length dimension"
);
assert_eq!(mi.scale, 1609.344, "the international mile is 1609.344 m");
assert_eq!(
mi.scale,
ft.scale * 5280.0,
"mi must be EXACTLY 5280 ft, not merely close to it"
);
let mph = parse_unit("mi/h").unwrap();
assert!(mph.is_compatible(&parse_unit("m/s").unwrap()));
assert_eq!(mph.scale, 0.44704);
assert!(
parse_unit("mph").is_err(),
"`mph` must stay unresolvable: a fused spelling of a compound the \
grammar already builds is a second way to say one thing"
);
}
#[test]
fn test_avoirdupois_pound() {
let lb = parse_unit("lb").unwrap();
assert!(lb.is_compatible(&parse_unit("kg").unwrap()));
assert_eq!(lb.scale, 0.45359237);
assert_eq!(
parse_unit("short_ton").unwrap().scale,
lb.scale * 2000.0,
"short_ton is DEFINED as 2000 lb; the two must agree to the last bit"
);
}
#[test]
fn test_mechanical_horsepower() {
let hp = parse_unit("hp").unwrap();
assert!(hp.is_compatible(&parse_unit("W").unwrap()));
assert_eq!(hp.scale, 745.699_871_582_270_2);
assert_eq!(
hp.scale,
550.0 * parse_unit("ft").unwrap().scale * parse_unit("lb").unwrap().scale * 9.80665,
"hp must be exactly 550 ft*lbf/s in terms of the table's own ft and lb"
);
assert!(
(hp.scale - 735.498_75).abs() > 1.0,
"hp must be the MECHANICAL horsepower, not the metric one"
);
}
#[test]
fn test_us_liquid_gallon() {
let gal = parse_unit("gal").unwrap();
let l = parse_unit("L").unwrap();
assert_eq!(
gal.dimensions.get(&Dimension::Length),
Some(&Rational::int(3)),
"a gallon is a volume, i.e. [length]^3"
);
assert_eq!(gal.scale, 0.003_785_411_784);
assert!(
(gal.scale / l.scale - 3.785_411_784).abs() < 1e-12,
"the US liquid gallon is 3.785411784 L"
);
assert!(
(gal.scale - 0.004_546_09).abs() > 1e-6,
"must be the US liquid gallon, not the imperial one"
);
}
#[test]
fn test_moves_compounds_derive() {
assert!(
parse_unit("mi/h")
.unwrap()
.is_compatible(&parse_unit("m/s").unwrap())
);
assert!(
parse_unit("g/(hp*h)")
.unwrap()
.is_compatible(&parse_unit("kg/J").unwrap())
);
assert!(
parse_unit("lb/(hp*h)")
.unwrap()
.is_compatible(&parse_unit("kg/J").unwrap())
);
assert!(
parse_unit("g/gal")
.unwrap()
.is_compatible(&parse_unit("kg/m^3").unwrap())
);
assert!(
parse_unit("g/mi")
.unwrap()
.is_compatible(&parse_unit("kg/m").unwrap())
);
assert!(
parse_unit("kJ/gal")
.unwrap()
.is_compatible(&parse_unit("J/m^3").unwrap())
);
}
#[test]
fn test_si_prefixes() {
assert_eq!(
parse_unit("mg").unwrap().dimensions.get(&Dimension::Mass),
Some(&Rational::int(1))
);
assert!((parse_unit("mg").unwrap().scale - 1e-6).abs() < 1e-18);
assert!((parse_unit("um").unwrap().scale - 1e-6).abs() < 1e-18);
assert!((parse_unit("nm").unwrap().scale - 1e-9).abs() < 1e-21);
assert_eq!(parse_unit("mL").unwrap(), {
let mut l = parse_unit("L").unwrap();
l.scale *= 1e-3;
l.exact = l.exact.multiply(&ExactScale::power_of_ten(-3));
l
});
assert_eq!(
parse_unit("umol")
.unwrap()
.dimensions
.get(&Dimension::Amount),
Some(&Rational::int(1))
);
assert!((parse_unit("min").unwrap().scale - 60.0).abs() < 1e-9);
assert_eq!(
parse_unit("mol")
.unwrap()
.dimensions
.get(&Dimension::Amount),
Some(&Rational::int(1))
);
assert!(parse_unit("not_a_unit").is_err());
}
#[test]
fn test_counts_are_dimensionless() {
for c in [
"molec",
"molecule",
"count",
"individuals",
"vehicles",
"units",
] {
assert!(
parse_unit(c).unwrap().is_dimensionless(),
"{c} must be dimensionless"
);
}
assert_eq!(
parse_unit("individuals/km^2")
.unwrap()
.dimensions
.get(&Dimension::Length),
Some(&Rational::int(-2))
);
assert!(parse_unit("psu").unwrap().is_dimensionless());
assert!(parse_unit("%").unwrap().is_dimensionless());
assert!((parse_unit("%").unwrap().scale - 0.01).abs() < 1e-12);
assert!(
parse_unit("uatm")
.unwrap()
.is_compatible(&parse_unit("Pa").unwrap())
);
}
#[test]
fn test_sqrt_halves_dimensions() {
let env = env_of(&[("area", "m^2"), ("vol", "m^3")]);
let e = op("sqrt", vec![Expr::Variable("area".into())]);
let u = Unit::propagate(&e, &env).unwrap();
assert_eq!(
u.dimensions.get(&Dimension::Length),
Some(&Rational::int(1))
);
assert!(check_expression_dimensions(&e, None, &env).is_empty());
let e = op("sqrt", vec![Expr::Variable("vol".into())]);
let u = Unit::propagate(&e, &env).unwrap();
assert_eq!(
u.dimensions.get(&Dimension::Length),
Some(&Rational::new(3, 2))
);
assert!(
!check_expression_dimensions(&e, None, &env)
.iter()
.any(UnitFinding::is_error),
"sqrt of an odd power is representable, not an error"
);
}
#[test]
fn test_trig_accepts_angle_but_exp_does_not() {
let env = env_of(&[("theta", "rad"), ("m", "m")]);
let e = op("sin", vec![Expr::Variable("theta".into())]);
assert!(
!check_expression_dimensions(&e, None, &env)
.iter()
.any(UnitFinding::is_error),
"sin(theta) with theta in radians is correct"
);
let e = op("sin", vec![Expr::Variable("m".into())]);
assert!(
check_expression_dimensions(&e, None, &env)
.iter()
.any(UnitFinding::is_error)
);
let e = op("exp", vec![Expr::Variable("theta".into())]);
assert!(
check_expression_dimensions(&e, None, &env)
.iter()
.any(UnitFinding::is_error),
"exp requires strict dimensionlessness"
);
}
#[test]
fn test_transcendental_argument_must_be_at_scale_one() {
let env = env_of(&[
("c", "ppm"),
("p", "percent"),
("n", "1"),
("theta", "rad"),
("lat", "deg"),
("solid", "sr"),
]);
let errors = |e: &Expr| {
check_expression_dimensions(e, None, &env)
.iter()
.filter(|f| f.is_error())
.map(|f| f.message.clone())
.collect::<Vec<_>>()
};
for name in [
"log", "ln", "log10", "exp", "sinh", "cosh", "tanh", "asinh", "acosh", "atanh", "asin",
"acos", "atan",
] {
let found = errors(&op(name, vec![Expr::Variable("c".into())]));
assert_eq!(found.len(), 1, "{name}(c [ppm]) must be refused");
assert!(
found[0].contains("dimensionless at scale 1")
&& found[0].contains("(ppm)")
&& found[0].contains("divide by 1 ppm"),
"{name}: the diagnostic must name the repair, got {}",
found[0]
);
assert!(
errors(&op(name, vec![Expr::Variable("n".into())])).is_empty(),
"{name}(n [1]) is a pure number and stays accepted"
);
}
assert!(
errors(&op("exp", vec![Expr::Variable("p".into())]))[0].contains("divide by 1 percent"),
"the percent spelling is named too"
);
for name in ["sin", "cos", "tan"] {
assert!(
errors(&op(name, vec![Expr::Variable("lat".into())])).is_empty(),
"{name}(lat [deg]) is a conforming document"
);
assert!(
errors(&op(name, vec![Expr::Variable("theta".into())])).is_empty(),
"{name}(theta [rad]) is a conforming document"
);
assert_eq!(
errors(&op(name, vec![Expr::Variable("p".into())])).len(),
1,
"{name}(p [percent]) leaves the reading unstated"
);
assert_eq!(
errors(&op(name, vec![Expr::Variable("solid".into())])).len(),
1,
"{name}(solid [sr]) is not a plane angle"
);
}
}
#[test]
fn test_scaled_dimensionless_repair_is_accepted() {
let env = env_of(&[("c", "ppm"), ("one_ppm", "ppm")]);
let quotient = op(
"/",
vec![Expr::Variable("c".into()), Expr::Variable("one_ppm".into())],
);
assert!(
!check_expression_dimensions(&op("log", vec![quotient]), None, &env)
.iter()
.any(UnitFinding::is_error),
"log(c / one_ppm) states the reading and must be accepted"
);
}
#[test]
fn test_angle_normalization_factor() {
assert!(angle_normalization_factor(&parse_unit("rad").unwrap()).is_none());
assert!(angle_normalization_factor(&parse_unit("1").unwrap()).is_none());
assert!(angle_normalization_factor(&parse_unit("ppm").unwrap()).is_none());
assert!(angle_normalization_factor(&parse_unit("m").unwrap()).is_none());
assert!(angle_normalization_factor(&parse_unit("sr").unwrap()).is_none());
let deg = angle_normalization_factor(&parse_unit("deg").unwrap()).unwrap();
assert_eq!(deg, std::f64::consts::PI / 180.0);
assert_eq!((90.0 * deg).sin(), 1.0);
}
#[test]
fn test_normalize_angle_arguments_rewrite() {
let env = env_of(&[("lat", "deg"), ("theta", "rad")]);
let untouched = op("sin", vec![Expr::Variable("theta".into())]);
assert!(
normalize_angle_arguments(&untouched, &env).is_none(),
"a `rad` argument is at scale 1 and must not be rewritten"
);
let nested = op(
"+",
vec![
Expr::Number(1.0),
op("cos", vec![Expr::Variable("lat".into())]),
],
);
let rewritten =
normalize_angle_arguments(&nested, &env).expect("the `deg` cos is rewritten");
let Expr::Operator(sum) = &rewritten else {
panic!("expected the sum back")
};
let Expr::Operator(cos) = &sum.args[1] else {
panic!("expected the cos back")
};
let Expr::Operator(product) = &cos.args[0] else {
panic!("expected the argument to be a product")
};
assert_eq!(product.op, "*");
assert_eq!(product.args[0], Expr::Variable("lat".into()));
assert_eq!(
product.args[1],
Expr::Number(std::f64::consts::PI / 180.0),
"the factor is the declared scale, applied ONCE"
);
}
#[test]
fn test_normalize_angle_arguments_reaches_every_child() {
let env = env_of(&[("lat", "deg")]);
let sin = || op("sin", vec![Expr::Variable("lat".into())]);
let folded = |e: &Expr| {
let Expr::Operator(n) = e else { return false };
n.op == "sin"
&& matches!(&n.args[0], Expr::Operator(p) if p.op == "*"
&& p.args[1] == Expr::Number(std::f64::consts::PI / 180.0))
};
let nodes = [
ExpressionNode {
op: "makearray".into(),
values: Some(vec![sin()]),
..Default::default()
},
ExpressionNode {
op: "table_lookup".into(),
axes: Some([("x".to_string(), sin())].into_iter().collect()),
..Default::default()
},
ExpressionNode {
op: "faq".into(),
filter: Some(Box::new(sin())),
key: Some(Box::new(sin())),
..Default::default()
},
];
for node in nodes {
let op_name = node.op.clone();
let rewritten = normalize_angle_arguments(&Expr::operator(node), &env)
.unwrap_or_else(|| panic!("{op_name}: the nested `deg` sin was not reached"));
let Expr::Operator(out) = &rewritten else {
panic!("{op_name}: expected the node back")
};
let mut children = 0;
out.for_each_child(&mut |c| {
assert!(folded(c), "{op_name}: {c:?} was not folded");
children += 1;
});
assert!(children > 0, "{op_name}");
}
}
#[test]
fn test_normalize_angle_arguments_is_idempotent() {
let env = env_of(&[("lat", "deg")]);
let once = normalize_angle_arguments(&op("sin", vec![Expr::Variable("lat".into())]), &env)
.expect("the `deg` argument is folded on the first pass");
assert!(
normalize_angle_arguments(&once, &env).is_none(),
"a second pass must find nothing to rewrite, got {once:?}"
);
}
#[test]
fn test_derivative_time_exponent_is_free() {
let env = env_of(&[("x", "m"), ("accel", "m/s^2"), ("mass", "kg")]);
let eq = Equation {
comment: None,
lhs: op_with_wrt("D", vec![Expr::Variable("x".into())], "t"),
rhs: Expr::Variable("accel".into()),
};
assert!(
!check_equation_dimensions(&eq, &env)
.iter()
.any(UnitFinding::is_error),
"ratio m/(m/s^2) = s^2 is a power of time ⇒ reconcilable"
);
let eq = Equation {
comment: None,
lhs: op_with_wrt("D", vec![Expr::Variable("x".into())], "t"),
rhs: Expr::Variable("mass".into()),
};
assert!(
check_equation_dimensions(&eq, &env)
.iter()
.any(UnitFinding::is_error),
"ratio m/kg is not a power of time ⇒ provable mismatch"
);
}
#[test]
fn every_registry_scale_has_a_matching_exact_form() {
for (name, unit) in build_base_units() {
let exact = unit.exact_scale().to_f64();
assert!(
(exact - unit.scale()).abs() <= 1e-12 * unit.scale().abs(),
"{name}: exact scale {exact} disagrees with the float {}",
unit.scale()
);
}
}
#[test]
fn reconciled_registry_scales_are_exact() {
let exact = |s: &str| parse_unit(s).unwrap().exact_scale().ratio_string().unwrap();
assert_eq!(exact("Torr"), "20265/152");
assert_eq!(exact("psi"), "8896443230521/1290320000");
assert_eq!(exact("degF"), "5/9");
assert_eq!(exact("deg"), "1/180*pi");
assert_eq!(exact("mi"), "201168/125");
assert_eq!(exact("hp"), "37284993579113511/50000000000000");
assert_eq!(exact("DU"), "268670000000000000000");
assert_eq!(parse_unit("psi").unwrap().scale(), 6_894.757_293_168_361);
assert_eq!(parse_unit("Torr").unwrap().scale(), 101_325.0 / 760.0);
assert_eq!(parse_unit("degF").unwrap().scale(), 5.0 / 9.0);
}
#[test]
fn adding_metres_to_kilometres_is_a_scale_mismatch() {
let env = env_of(&[("x", "m"), ("y", "km"), ("z", "m")]);
let var = |n: &str| Expr::Variable(n.into());
let findings = check_expression_dimensions(&op("+", vec![var("x"), var("y")]), None, &env);
assert!(
findings
.iter()
.any(|f| f.is_error() && f.message.contains("scale")),
"m + km must be a scale mismatch: {findings:?}"
);
let findings = check_expression_dimensions(&op("+", vec![var("x"), var("z")]), None, &env);
assert!(!findings.iter().any(UnitFinding::is_error), "{findings:?}");
}
#[test]
fn an_equation_between_m_per_s_and_mi_per_h_is_a_scale_mismatch() {
let env = env_of(&[
("speed_ms", "m/s"),
("speed_mph", "mi/h"),
("ms_per_mph", "m*h/(mi*s)"),
]);
let var = |n: &str| Expr::Variable(n.into());
let bare = Equation {
comment: None,
lhs: var("speed_ms"),
rhs: var("speed_mph"),
};
assert!(
check_equation_dimensions(&bare, &env)
.iter()
.any(UnitFinding::is_error),
"m/s = mi/h agrees in dimension and not in scale"
);
let converted = Equation {
comment: None,
lhs: var("speed_ms"),
rhs: op("*", vec![var("speed_mph"), var("ms_per_mph")]),
};
assert!(
!check_equation_dimensions(&converted, &env)
.iter()
.any(UnitFinding::is_error),
"mi/h times m*h/(mi*s) is exactly m/s"
);
}
#[test]
fn ifelse_branches_must_agree_in_scale() {
let env = env_of(&[("flag", "1"), ("x", "m"), ("y", "km")]);
let var = |n: &str| Expr::Variable(n.into());
let e = op("ifelse", vec![var("flag"), var("x"), var("y")]);
assert!(
check_expression_dimensions(&e, None, &env)
.iter()
.any(UnitFinding::is_error)
);
}
#[test]
fn an_observed_expression_must_agree_with_its_declared_scale() {
let env = env_of(&[("y", "km")]);
let declared = parse_unit("m").unwrap();
assert!(
check_expression_dimensions(&Expr::Variable("y".into()), Some(&declared), &env)
.iter()
.any(UnitFinding::is_error)
);
}
#[test]
fn a_const_with_declared_units_has_that_unit() {
let konst = |units: Option<&str>| {
Expr::Operator(std::sync::Arc::new(ExpressionNode {
op: "const".into(),
value: Some(serde_json::json!(0.44704)),
units: units.map(str::to_string),
..ExpressionNode::default()
}))
};
let env = env_of(&[
("speed_mph", "mi/h"),
("speed_ms", "m/s"),
("speed_kg", "kg"),
]);
let rhs = |units| op("*", vec![Expr::Variable("speed_mph".into()), konst(units)]);
let eq = |lhs: &str, units| Equation {
comment: None,
lhs: Expr::Variable(lhs.into()),
rhs: rhs(units),
};
assert!(
!check_equation_dimensions(&eq("speed_ms", Some("m*h/(mi*s)")), &env)
.iter()
.any(UnitFinding::is_error),
"mi/h times a const declared m*h/(mi*s) is exactly m/s"
);
assert!(
check_equation_dimensions(&eq("speed_kg", Some("m*h/(mi*s)")), &env)
.iter()
.any(UnitFinding::is_error),
"declared kg against m/s is a provable mismatch"
);
assert!(
!check_equation_dimensions(&eq("speed_kg", None), &env)
.iter()
.any(UnitFinding::is_error),
"a const without units is undeterminable, so nothing is checked"
);
assert_eq!(
unresolvable_const_units(&rhs(Some("mph"))),
vec!["mph".to_string()]
);
assert!(unresolvable_const_units(&rhs(Some("m*h/(mi*s)"))).is_empty());
}
#[test]
fn const_units_are_gated_at_esm_1_2_0() {
let doc = |esm: &str| {
serde_json::json!({
"esm": esm,
"models": {"M": {"equations": [{"lhs": "x", "rhs":
{"op": "const", "args": [], "value": 1.0, "units": "m"}}]}}
})
};
let err = reject_const_units_pre_v12(&doc("1.1.0")).expect_err("1.1.0 must be rejected");
assert!(
err.to_string().contains("const_units_version_too_old"),
"{err}"
);
assert!(
err.to_string().contains("/models/M/equations/0/rhs"),
"{err}"
);
assert!(reject_const_units_pre_v12(&doc("1.2.0")).is_ok());
}
#[test]
fn scaled_powers_and_roots_stay_exact() {
assert_eq!(
parse_unit("km^2")
.unwrap()
.power_rational(Rational::new(1, 2))
.exact_scale(),
parse_unit("km").unwrap().exact_scale()
);
let env = env_of(&[("p", "%"), ("q", "1")]);
let eq = Equation {
comment: None,
lhs: Expr::Variable("q".into()),
rhs: op("^", vec![Expr::Variable("p".into()), Expr::Integer(2)]),
};
assert!(
check_equation_dimensions(&eq, &env)
.iter()
.any(UnitFinding::is_error),
"a percentage squared has scale 1/10000, not 1"
);
}
#[test]
fn test_parse_base_units() {
assert_eq!(
parse_unit("m").unwrap().dimensions.get(&Dimension::Length),
Some(&Rational::int(1))
);
assert_eq!(
parse_unit("s").unwrap().dimensions.get(&Dimension::Time),
Some(&Rational::int(1))
);
assert_eq!(
parse_unit("kg").unwrap().dimensions.get(&Dimension::Mass),
Some(&Rational::int(1))
);
}
#[test]
fn test_parse_compound_units() {
let u = parse_unit("m/s").unwrap();
assert_eq!(
u.dimensions.get(&Dimension::Length),
Some(&Rational::int(1))
);
assert_eq!(u.dimensions.get(&Dimension::Time), Some(&Rational::int(-1)));
let u = parse_unit("mol/L").unwrap();
assert_eq!(
u.dimensions.get(&Dimension::Amount),
Some(&Rational::int(1))
);
assert_eq!(
u.dimensions.get(&Dimension::Length),
Some(&Rational::int(-3))
);
}
#[test]
fn test_parse_parenthesised_units() {
let u = parse_unit("J/(mol*K)").unwrap();
assert_eq!(u.dimensions.get(&Dimension::Mass), Some(&Rational::int(1)));
assert_eq!(
u.dimensions.get(&Dimension::Length),
Some(&Rational::int(2))
);
assert_eq!(u.dimensions.get(&Dimension::Time), Some(&Rational::int(-2)));
assert_eq!(
u.dimensions.get(&Dimension::Amount),
Some(&Rational::int(-1))
);
assert_eq!(
u.dimensions.get(&Dimension::Temperature),
Some(&Rational::int(-1))
);
}
#[test]
fn test_parse_pascal_and_kg_per_m3() {
let pa = parse_unit("Pa").unwrap();
assert_eq!(pa.dimensions.get(&Dimension::Mass), Some(&Rational::int(1)));
assert_eq!(
pa.dimensions.get(&Dimension::Length),
Some(&Rational::int(-1))
);
assert_eq!(
pa.dimensions.get(&Dimension::Time),
Some(&Rational::int(-2))
);
let rho = parse_unit("kg/m^3").unwrap();
assert_eq!(
rho.dimensions.get(&Dimension::Mass),
Some(&Rational::int(1))
);
assert_eq!(
rho.dimensions.get(&Dimension::Length),
Some(&Rational::int(-3))
);
}
#[test]
fn test_parse_negative_power() {
let inv_s = parse_unit("s^-1").unwrap();
assert_eq!(
inv_s.dimensions.get(&Dimension::Time),
Some(&Rational::int(-1))
);
}
#[test]
fn test_division_is_left_associative() {
let u = parse_unit("W/m^2/K").unwrap();
assert_eq!(u.dimensions.get(&Dimension::Mass), Some(&Rational::int(1)));
assert_eq!(u.dimensions.get(&Dimension::Time), Some(&Rational::int(-3)));
assert_eq!(
u.dimensions.get(&Dimension::Temperature),
Some(&Rational::int(-1))
);
assert_eq!(u.dimensions.get(&Dimension::Length), None);
}
#[test]
fn test_find_last_top_level_operator() {
assert_eq!(find_last_top_level_operator("a/b/c"), Some((3, 1, '/')));
assert_eq!(find_last_top_level_operator("a*b*c"), Some((3, 1, '*')));
assert_eq!(find_last_top_level_operator("a/b*c"), Some((3, 1, '*')));
assert_eq!(find_last_top_level_operator("a/(b/c)"), Some((1, 1, '/')));
assert_eq!(find_last_top_level_operator("kg"), None);
}
#[test]
fn test_mixed_product_quotient_is_left_associative() {
let u = parse_unit("J/mol*K").unwrap();
assert_eq!(
u.dimensions.get(&Dimension::Temperature),
Some(&Rational::int(1))
);
assert_eq!(
u.dimensions.get(&Dimension::Amount),
Some(&Rational::int(-1))
);
let u = parse_unit("kg/m^3*s").unwrap();
assert_eq!(u.dimensions.get(&Dimension::Time), Some(&Rational::int(1)));
assert_eq!(
u.dimensions.get(&Dimension::Length),
Some(&Rational::int(-3))
);
let u = parse_unit("m/s/s").unwrap();
assert_eq!(u.dimensions.get(&Dimension::Time), Some(&Rational::int(-2)));
assert_eq!(
u.dimensions.get(&Dimension::Length),
Some(&Rational::int(1))
);
let u = parse_unit("J/(mol*K)").unwrap();
assert_eq!(
u.dimensions.get(&Dimension::Temperature),
Some(&Rational::int(-1))
);
assert_eq!(
u.dimensions.get(&Dimension::Amount),
Some(&Rational::int(-1))
);
}
#[test]
fn test_dangling_operator_is_rejected() {
assert!(matches!(parse_unit("m/"), Err(UnitError::ParseError(_))));
assert!(matches!(parse_unit("/s"), Err(UnitError::ParseError(_))));
assert!(matches!(parse_unit("kg*"), Err(UnitError::ParseError(_))));
}
#[test]
fn test_parse_mixed_product_quotient() {
let u = parse_unit("kg*m/s^2").unwrap();
assert_eq!(u.dimensions.get(&Dimension::Mass), Some(&Rational::int(1)));
assert_eq!(
u.dimensions.get(&Dimension::Length),
Some(&Rational::int(1))
);
assert_eq!(u.dimensions.get(&Dimension::Time), Some(&Rational::int(-2)));
}
#[test]
fn test_unit_compatibility() {
let m = parse_unit("m").unwrap();
let cm = parse_unit("cm").unwrap();
let s = parse_unit("s").unwrap();
assert!(m.is_compatible(&cm));
assert!(!m.is_compatible(&s));
}
#[test]
fn test_unit_arithmetic() {
let m = parse_unit("m").unwrap();
let s = parse_unit("s").unwrap();
let v = m.divide(&s);
assert_eq!(
v.dimensions.get(&Dimension::Length),
Some(&Rational::int(1))
);
assert_eq!(v.dimensions.get(&Dimension::Time), Some(&Rational::int(-1)));
assert_eq!(
m.power(2).dimensions.get(&Dimension::Length),
Some(&Rational::int(2))
);
}
#[test]
fn test_dimensional_consistency() {
let m = parse_unit("m").unwrap();
let cm = parse_unit("cm").unwrap();
let s = parse_unit("s").unwrap();
assert!(check_dimensional_consistency(&m, &cm).is_ok());
assert!(check_dimensional_consistency(&m, &s).is_err());
}
#[test]
fn test_unit_conversion() {
let m = parse_unit("m").unwrap();
let cm = parse_unit("cm").unwrap();
assert!((convert_units(1.0, &m, &cm).unwrap() - 100.0).abs() < 1e-10);
assert!((convert_units(100.0, &cm, &m).unwrap() - 1.0).abs() < 1e-10);
}
#[test]
fn test_unknown_unit() {
let result = parse_unit("unknown_unit");
assert!(matches!(result, Err(UnitError::UnknownUnit(_))));
}
#[test]
fn propagate_literal_is_indeterminate() {
let env = HashMap::new();
assert!(matches!(
Unit::propagate(&Expr::Number(2.5), &env),
Err(UnitError::UnknownUnit(_))
));
let env = env_of(&[("T", "K")]);
let expr = op("-", vec![Expr::Variable("T".into()), Expr::Number(273.15)]);
let u = Unit::propagate(&expr, &env).unwrap();
assert_eq!(
u.dimensions.get(&Dimension::Temperature),
Some(&Rational::int(1))
);
assert!(check_expression_dimensions(&expr, None, &env).is_empty());
let literal_pairs = [
vec![Expr::Number(1.0), Expr::Number(2.0)],
vec![Expr::Integer(1), Expr::Integer(2)],
];
for args in literal_pairs {
for name in ["+", "-", "min", "max"] {
assert!(
matches!(
Unit::propagate(&op(name, args.clone()), &HashMap::new()),
Err(UnitError::UnknownUnit(_))
),
"{name} over literals must be undeterminable"
);
}
}
for lit in [Expr::Integer(2), Expr::Number(2.5)] {
assert!(matches!(
Unit::propagate(&op("+", vec![lit]), &HashMap::new()),
Err(UnitError::UnknownUnit(_))
));
}
}
#[test]
fn propagate_sum_with_undeterminable_operand_takes_known_unit() {
let env = env_of(&[("x", "m"), ("y", "m")]);
let half_y = op("*", vec![Expr::Number(0.5), Expr::Variable("y".into())]);
for name in ["+", "-", "min", "max"] {
let expr = op(name, vec![Expr::Variable("x".into()), half_y.clone()]);
let u = Unit::propagate(&expr, &env).unwrap();
assert_eq!(
u.dimensions.get(&Dimension::Length),
Some(&Rational::int(1)),
"{name}"
);
}
let plus_x = op("+", vec![Expr::Variable("x".into())]);
let u = Unit::propagate(&plus_x, &env).unwrap();
assert_eq!(
u.dimensions.get(&Dimension::Length),
Some(&Rational::int(1))
);
}
#[test]
fn ifelse_takes_the_unit_of_its_determinable_branch() {
let env = env_of(&[("x", "m"), ("y", "m"), ("c", "1")]);
let cond = op(">", vec![Expr::Variable("c".into()), Expr::Integer(0)]);
let x = Expr::Variable("x".into());
let half_y = op("*", vec![Expr::Number(0.5), Expr::Variable("y".into())]);
let branch_pairs = [
(x.clone(), half_y.clone()),
(half_y.clone(), x.clone()),
(Expr::Integer(2), x.clone()),
];
for (then_branch, else_branch) in branch_pairs {
let expr = op("ifelse", vec![cond.clone(), then_branch, else_branch]);
let u = Unit::propagate(&expr, &env).unwrap();
assert_eq!(
u.dimensions.get(&Dimension::Length),
Some(&Rational::int(1))
);
}
let literal_branches = op("ifelse", vec![cond, Expr::Integer(1), Expr::Number(2.0)]);
assert!(matches!(
Unit::propagate(&literal_branches, &env),
Err(UnitError::UnknownUnit(_))
));
}
#[test]
fn connectives_report_a_mismatch_inside_their_operands() {
let env = env_of(&[("x", "m"), ("z", "kg"), ("c", "1")]);
let mismatch = op(
">",
vec![Expr::Variable("x".into()), Expr::Variable("z".into())],
);
let ok = op(">", vec![Expr::Variable("c".into()), Expr::Integer(0)]);
for expr in [
op("not", vec![mismatch.clone()]),
op("and", vec![mismatch.clone(), ok.clone()]),
op("or", vec![ok.clone(), mismatch.clone()]),
] {
assert!(
!check_expression_dimensions(&expr, None, &env).is_empty(),
"{expr:?}"
);
}
let dimensional_operands = op(
"and",
vec![Expr::Variable("x".into()), Expr::Variable("z".into())],
);
assert!(check_expression_dimensions(&dimensional_operands, None, &env).is_empty());
}
#[test]
fn propagate_literal_exponent_is_read_by_value() {
let env = env_of(&[("L", "m")]);
let expr = op("^", vec![Expr::Variable("L".into()), Expr::Integer(2)]);
let u = Unit::propagate(&expr, &env).unwrap();
assert_eq!(
u.dimensions.get(&Dimension::Length),
Some(&Rational::int(2))
);
}
#[test]
fn propagate_huge_integer_exponent_does_not_wrap() {
let env = env_of(&[("L", "m")]);
let exp = i64::from(i32::MAX) + 6;
let expr = op("^", vec![Expr::Variable("L".into()), Expr::Integer(exp)]);
let err = Unit::propagate(&expr, &env).unwrap_err();
match err {
UnitError::UnknownUnit(msg) => assert!(
msg.contains(&exp.to_string()),
"finding must name the untruncated exponent, got: {msg}"
),
other => panic!("expected UnknownUnit, got {other:?}"),
}
}
#[test]
fn propagate_variable_lookup() {
let env = env_of(&[("v", "m/s")]);
let u = Unit::propagate(&Expr::Variable("v".into()), &env).unwrap();
assert_eq!(
u.dimensions.get(&Dimension::Length),
Some(&Rational::int(1))
);
assert_eq!(u.dimensions.get(&Dimension::Time), Some(&Rational::int(-1)));
}
#[test]
fn propagate_time_variable_default() {
let env = HashMap::new();
let u = Unit::propagate(&Expr::Variable("t".into()), &env).unwrap();
assert_eq!(u.dimensions.get(&Dimension::Time), Some(&Rational::int(1)));
}
#[test]
fn propagate_unknown_variable_errors() {
let env = HashMap::new();
let err = Unit::propagate(&Expr::Variable("nope".into()), &env).unwrap_err();
assert!(matches!(err, UnitError::UnknownUnit(_)));
}
#[test]
fn propagate_addition_matches() {
let env = env_of(&[("h1", "m"), ("h2", "m")]);
let e = op(
"+",
vec![Expr::Variable("h1".into()), Expr::Variable("h2".into())],
);
let u = Unit::propagate(&e, &env).unwrap();
assert_eq!(
u.dimensions.get(&Dimension::Length),
Some(&Rational::int(1))
);
}
#[test]
fn propagate_addition_mismatch_errors() {
let env = env_of(&[("h", "m"), ("t", "s")]);
let e = op(
"+",
vec![Expr::Variable("h".into()), Expr::Variable("t".into())],
);
let err = Unit::propagate(&e, &env).unwrap_err();
assert!(matches!(err, UnitError::DimensionMismatch(_)));
}
#[test]
fn propagate_multiplication_combines_dims() {
let env = env_of(&[("rho", "kg/m^3"), ("v", "m/s")]);
let e = op(
"*",
vec![Expr::Variable("rho".into()), Expr::Variable("v".into())],
);
let u = Unit::propagate(&e, &env).unwrap();
assert_eq!(u.dimensions.get(&Dimension::Mass), Some(&Rational::int(1)));
assert_eq!(
u.dimensions.get(&Dimension::Length),
Some(&Rational::int(-2))
);
assert_eq!(u.dimensions.get(&Dimension::Time), Some(&Rational::int(-1)));
}
#[test]
fn propagate_division_subtracts_dims() {
let env = env_of(&[("m1", "kg"), ("V", "m^3")]);
let e = op(
"/",
vec![Expr::Variable("m1".into()), Expr::Variable("V".into())],
);
let u = Unit::propagate(&e, &env).unwrap();
assert_eq!(u.dimensions.get(&Dimension::Mass), Some(&Rational::int(1)));
assert_eq!(
u.dimensions.get(&Dimension::Length),
Some(&Rational::int(-3))
);
}
#[test]
fn propagate_power_integer() {
let env = env_of(&[("s", "m")]);
let e = op("^", vec![Expr::Variable("s".into()), Expr::Number(3.0)]);
let u = Unit::propagate(&e, &env).unwrap();
assert_eq!(
u.dimensions.get(&Dimension::Length),
Some(&Rational::int(3))
);
}
#[test]
fn propagate_power_rejects_dimensional_exponent() {
let env = env_of(&[("h", "m"), ("t", "s")]);
let e = op(
"^",
vec![Expr::Variable("h".into()), Expr::Variable("t".into())],
);
let err = Unit::propagate(&e, &env).unwrap_err();
assert!(matches!(err, UnitError::DimensionMismatch(_)));
}
#[test]
fn propagate_derivative_divides_by_wrt() {
let env = env_of(&[("h", "m"), ("v", "m/s"), ("t", "s")]);
let dh = op_with_wrt("D", vec![Expr::Variable("h".into())], "t");
let dh_dim = Unit::propagate(&dh, &env).unwrap();
let v_dim = Unit::propagate(&Expr::Variable("v".into()), &env).unwrap();
assert!(dh_dim.is_compatible(&v_dim));
}
#[test]
fn propagate_derivative_of_undeclared_time_is_unknown() {
let env = env_of(&[("h", "m")]);
let dh = op_with_wrt("D", vec![Expr::Variable("h".into())], "t");
assert!(matches!(
Unit::propagate(&dh, &env),
Err(UnitError::UnknownUnit(_))
));
}
#[test]
fn propagate_exp_requires_dimensionless_arg() {
let env = env_of(&[("h", "m")]);
let e = op("exp", vec![Expr::Variable("h".into())]);
let err = Unit::propagate(&e, &env).unwrap_err();
assert!(matches!(err, UnitError::DimensionMismatch(_)));
}
#[test]
fn propagate_sqrt_halves_even_powers() {
let env = env_of(&[("a", "m^2")]);
let e = op("sqrt", vec![Expr::Variable("a".into())]);
let u = Unit::propagate(&e, &env).unwrap();
assert_eq!(
u.dimensions.get(&Dimension::Length),
Some(&Rational::int(1))
);
}
#[test]
fn propagate_ifelse_branches_must_match() {
let env = env_of(&[("h", "m"), ("x", "s")]);
let e = op(
"ifelse",
vec![
Expr::Number(1.0),
Expr::Variable("h".into()),
Expr::Variable("x".into()),
],
);
assert!(matches!(
Unit::propagate(&e, &env).unwrap_err(),
UnitError::DimensionMismatch(_)
));
}
#[test]
fn propagate_comparison_requires_matching_dims() {
let env = env_of(&[("h", "m"), ("x", "s")]);
let e = op(
">",
vec![Expr::Variable("h".into()), Expr::Variable("x".into())],
);
assert!(matches!(
Unit::propagate(&e, &env).unwrap_err(),
UnitError::DimensionMismatch(_)
));
}
#[test]
fn propagate_spatial_sugar_ops_are_undeterminable() {
let env = env_of(&[("c", "mol/m^3")]);
for op_name in ["grad", "div", "laplacian", "curl", "∇", "integral"] {
let e = op(op_name, vec![Expr::Variable("c".into())]);
assert!(
matches!(Unit::propagate(&e, &env), Err(UnitError::UnknownUnit(_))),
"{op_name} must have an undeterminable dimension"
);
let findings = check_expression_dimensions(&e, None, &env);
assert!(
findings.is_empty(),
"{op_name} must emit no finding (report unknown + skip): {findings:?}"
);
}
}
#[test]
fn propagate_faq_body() {
let env = env_of(&[("x", "m/s"), ("scale", "1")]);
let body = op(
"*",
vec![Expr::Variable("x".into()), Expr::Variable("scale".into())],
);
let node = Expr::operator(ExpressionNode {
op: "faq".to_string(),
args: vec![],
expr: Some(Box::new(body)),
..ExpressionNode::default()
});
let u = Unit::propagate(&node, &env).unwrap();
assert_eq!(
u.dimensions.get(&Dimension::Length),
Some(&Rational::int(1))
);
assert_eq!(u.dimensions.get(&Dimension::Time), Some(&Rational::int(-1)));
}
#[test]
fn propagate_index_inherits_from_source() {
let env = env_of(&[("arr", "kg")]);
let node = op(
"index",
vec![Expr::Variable("arr".into()), Expr::Number(0.0)],
);
let u = Unit::propagate(&node, &env).unwrap();
assert_eq!(u.dimensions.get(&Dimension::Mass), Some(&Rational::int(1)));
}
#[test]
fn propagate_broadcast_dispatches_to_fn() {
let env = env_of(&[("a", "m"), ("b", "m")]);
let node = Expr::operator(ExpressionNode {
op: "broadcast".to_string(),
broadcast_fn: Some("+".to_string()),
args: vec![Expr::Variable("a".into()), Expr::Variable("b".into())],
..ExpressionNode::default()
});
let u = Unit::propagate(&node, &env).unwrap();
assert_eq!(
u.dimensions.get(&Dimension::Length),
Some(&Rational::int(1))
);
}
#[test]
fn propagate_unknown_operator_reports_unknown_and_skips() {
let env = HashMap::new();
let e = op("zorp", vec![Expr::Number(1.0)]);
let findings = check_expression_dimensions(&e, None, &env);
assert!(findings.is_empty(), "expected no finding, got {findings:?}");
assert!(matches!(
Unit::propagate(&e, &env),
Err(UnitError::UnknownUnit(_))
));
}
#[test]
fn undeterminable_operand_does_not_hide_a_real_mismatch() {
let env = env_of(&[("len", "m"), ("mass", "kg")]);
let expr = op(
"+",
vec![
op("zorp", vec![Expr::Number(1.0)]),
Expr::Variable("len".into()),
Expr::Variable("mass".into()),
],
);
let findings = check_expression_dimensions(&expr, None, &env);
assert!(
findings.iter().any(UnitFinding::is_error),
"the m-vs-kg mismatch must still be proven: {findings:?}"
);
assert!(
findings.iter().all(UnitFinding::is_error),
"the undeterminable op must not add a finding of its own: {findings:?}"
);
}
#[test]
fn validate_equation_dimensions_passes() {
let env = env_of(&[("h", "m"), ("v", "m/s"), ("t", "s")]);
let eq = Equation {
comment: None,
lhs: op_with_wrt("D", vec![Expr::Variable("h".into())], "t"),
rhs: Expr::Variable("v".into()),
};
validate_equation_dimensions(&eq, &env).unwrap();
}
#[test]
fn validate_equation_dimensions_detects_mismatch() {
let env = env_of(&[("h", "m"), ("v", "m/s"), ("t", "s")]);
let eq = Equation {
comment: None,
lhs: op_with_wrt("D", vec![Expr::Variable("h".into())], "t"),
rhs: Expr::Variable("h".into()),
};
assert!(validate_equation_dimensions(&eq, &env).is_err());
let eq = Equation {
comment: None,
lhs: op_with_wrt("D", vec![Expr::Variable("h".into())], "t"),
rhs: Expr::Variable("v".into()),
};
assert!(validate_equation_dimensions(&eq, &env).is_ok());
}
#[test]
fn derivative_time_ratio_rule() {
let env = env_of(&[("h", "m"), ("v", "m/s"), ("mass", "kg")]);
let eq = Equation {
comment: None,
lhs: op_with_wrt("D", vec![Expr::Variable("h".into())], "t"),
rhs: Expr::Variable("mass".into()),
};
let findings = check_equation_dimensions(&eq, &env);
assert!(findings.iter().any(UnitFinding::is_error), "{findings:?}");
let eq = Equation {
comment: None,
lhs: op_with_wrt("D", vec![Expr::Variable("h".into())], "t"),
rhs: Expr::Variable("v".into()),
};
assert!(
!check_equation_dimensions(&eq, &env)
.iter()
.any(UnitFinding::is_error)
);
let env = env_of(&[("x", "dimensionless")]);
let eq = Equation {
comment: None,
lhs: op_with_wrt("D", vec![Expr::Variable("x".into())], "t"),
rhs: op("-", vec![Expr::Variable("x".into())]),
};
assert!(
!check_equation_dimensions(&eq, &env)
.iter()
.any(UnitFinding::is_error)
);
}
fn state_var_with_units(units: Option<&str>) -> crate::ModelVariable {
crate::test_support::var(crate::VariableType::Unknown, units)
}
#[test]
fn build_unit_env_unparseable_unit_is_unknown_not_dimensionless() {
let mut variables = indexmap::IndexMap::new();
variables.insert("x".to_string(), state_var_with_units(Some("not_a_unit")));
variables.insert("y".to_string(), state_var_with_units(Some("m")));
let (env, failures) = build_unit_env(&variables);
assert!(
!env.contains_key("x"),
"unparseable unit must not be in env"
);
assert!(env.contains_key("y"));
assert_eq!(
failures,
vec![UnitParseFailure {
name: "x".to_string(),
units: "not_a_unit".to_string(),
}]
);
let eq = Equation {
comment: None,
lhs: Expr::Variable("y".into()),
rhs: Expr::Variable("x".into()),
};
let result = validate_equation_dimensions(&eq, &env);
assert!(
matches!(result, Err(UnitError::UnknownUnit(_))),
"expected UnknownUnit (skipped), not a false mismatch, got {result:?}"
);
}
fn konst(value: f64, units: Option<&str>) -> Expr {
Expr::operator(ExpressionNode {
op: "const".to_string(),
value: Some(serde_json::json!(value)),
units: units.map(str::to_string),
..ExpressionNode::default()
})
}
#[test]
fn constant_exponents_fold_to_exact_rationals() {
let int = Expr::Integer;
let cases = [
(op("/", vec![int(3), int(2)]), Rational::new(3, 2)),
(op("-", vec![int(1)]), Rational::int(-1)),
(op("-", vec![int(5), int(3)]), Rational::int(2)),
(
op(
"/",
vec![op("+", vec![int(1), int(2)]), op("*", vec![int(4), int(2)])],
),
Rational::new(3, 8),
),
(konst(1.5, None), Rational::new(3, 2)),
(konst(0.5, Some("1")), Rational::new(1, 2)),
(Expr::Number(0.25), Rational::new(1, 4)),
];
for (expr, expected) in cases {
assert_eq!(constant_exponent(&expr), Some(expected), "{expr:?}");
}
}
#[test]
fn symbolic_exponents_do_not_fold() {
let int = Expr::Integer;
let var = |n: &str| Expr::Variable(n.into());
for expr in [
var("n"),
op("+", vec![var("n"), int(1)]),
konst(50.0, Some("%")),
konst(2.0, Some("m")),
op("/", vec![int(1), int(0)]),
op("/", vec![int(1), int(2), int(3)]),
op("sqrt", vec![int(4)]),
op(
"*",
vec![int(i64::from(i32::MAX)), int(i64::from(i32::MAX))],
),
] {
assert_eq!(constant_exponent(&expr), None, "{expr:?}");
}
}
#[test]
fn a_folded_exponent_is_checked_and_a_symbolic_one_is_skipped() {
let env = env_of(&[("T", "K"), ("n", "1"), ("T_32", "K^(3/2)"), ("T_2", "K^2")]);
let var = |n: &str| Expr::Variable(n.into());
let three_halves = op("/", vec![Expr::Integer(3), Expr::Integer(2)]);
let pow = op("^", vec![var("T"), three_halves]);
let ok = check_expression_dimensions(&pow, env.get("T_32"), &env);
assert!(!ok.iter().any(UnitFinding::is_error), "{ok:?}");
let bad = check_expression_dimensions(&pow, env.get("T_2"), &env);
assert!(bad.iter().any(UnitFinding::is_error), "{bad:?}");
let sym = op("^", vec![var("T"), var("n")]);
let skipped = check_expression_dimensions(&sym, env.get("T_2"), &env);
assert!(!skipped.iter().any(UnitFinding::is_error), "{skipped:?}");
}
#[test]
fn an_faq_filter_is_dimension_checked() {
let env = env_of(&[("x", "m"), ("y", "m"), ("z", "kg")]);
let var = |n: &str| Expr::Variable(n.into());
let faq = |filter: Expr| {
Expr::operator(ExpressionNode {
op: "faq".to_string(),
filter: Some(Box::new(filter)),
expr: Some(Box::new(konst(1.0, Some("1")))),
..ExpressionNode::default()
})
};
let bad = check_expression_dimensions(&faq(op("<=", vec![var("x"), var("z")])), None, &env);
assert!(bad.iter().any(UnitFinding::is_error), "{bad:?}");
let ok = check_expression_dimensions(&faq(op("<=", vec![var("x"), var("y")])), None, &env);
assert!(!ok.iter().any(UnitFinding::is_error), "{ok:?}");
}
fn faq_node(body: Expr, semiring: Option<&str>, reduce: Option<&str>) -> ExpressionNode {
ExpressionNode {
op: "faq".to_string(),
expr: Some(Box::new(body)),
semiring: semiring.map(str::to_string),
reduce: reduce.map(str::to_string),
..ExpressionNode::default()
}
}
#[test]
fn array_operators_carry_their_element_unit() {
let env = env_of(&[("v", "h"), ("w", "1"), ("a", "m"), ("b", "kg")]);
let var = |n: &str| Expr::Variable(n.into());
let unit = |e: &Expr| propagate_dim(e, &env, &mut Vec::new());
let h = parse_unit("h").unwrap();
let idx = op("index", vec![var("v"), var("i")]);
assert_eq!(unit(&idx).known(), Some(&h));
assert!(unit(&op("index", vec![])).known().is_none());
for (sr, rd) in [
(Some("sum_product"), None),
(Some("max_product"), None),
(Some("min_sum"), None),
(None, Some("max")),
(None, None),
] {
let f = Expr::operator(faq_node(idx.clone(), sr, rd));
assert_eq!(unit(&f).known(), Some(&h), "{sr:?} {rd:?}");
}
let prod = Expr::operator(faq_node(idx.clone(), None, Some("*")));
assert!(unit(&prod).known().is_none());
let wprod = Expr::operator(faq_node(
op("index", vec![var("w"), var("i")]),
None,
Some("*"),
));
assert!(unit(&wprod).known().is_some_and(Unit::is_pure_number));
let any = Expr::operator(faq_node(idx.clone(), Some("bool_and_or"), None));
assert!(unit(&any).known().is_some_and(Unit::is_dimensionless));
let mut distinct = faq_node(idx.clone(), None, None);
distinct.distinct = Some(true);
assert!(unit(&Expr::operator(distinct)).known().is_none());
let mk = |values: Vec<Expr>| {
Expr::operator(ExpressionNode {
op: "makearray".to_string(),
values: Some(values),
..ExpressionNode::default()
})
};
assert_eq!(
unit(&mk(vec![var("v"), Expr::Number(0.0)])).known(),
Some(&h)
);
assert!(unit(&mk(vec![])).known().is_none());
let mut f = Vec::new();
propagate_dim(&mk(vec![var("a"), var("b")]), &env, &mut f);
assert!(f.iter().any(UnitFinding::is_error), "{f:?}");
let mut f = Vec::new();
propagate_dim(&op("concat", vec![var("a"), var("b")]), &env, &mut f);
assert!(f.iter().any(UnitFinding::is_error), "{f:?}");
}
#[test]
fn array_operators_check_every_operand() {
let env = env_of(&[("v", "h"), ("a", "m"), ("b", "kg")]);
let var = |n: &str| Expr::Variable(n.into());
let h = parse_unit("h").unwrap();
let bad = || op("+", vec![var("a"), var("b")]);
for name in ["index", "reshape", "transpose"] {
let mut f = Vec::new();
let dim = propagate_dim(&op(name, vec![var("v"), bad()]), &env, &mut f);
assert_eq!(dim.known(), Some(&h), "{name}");
assert!(f.iter().any(UnitFinding::is_error), "{name}: {f:?}");
let mut f = Vec::new();
propagate_dim(&op(name, vec![var("v"), var("a")]), &env, &mut f);
assert!(!f.iter().any(UnitFinding::is_error), "{name}: {f:?}");
}
let mut fq = faq_node(var("v"), None, None);
fq.args = vec![bad()];
let mut mk = ExpressionNode {
op: "makearray".to_string(),
values: Some(vec![var("v")]),
..ExpressionNode::default()
};
mk.args = vec![bad()];
let bc = ExpressionNode {
op: "broadcast".to_string(),
args: vec![bad()],
..ExpressionNode::default()
};
for node in [fq, mk, bc] {
let name = node.op.clone();
let mut f = Vec::new();
propagate_dim(&Expr::operator(node), &env, &mut f);
assert!(f.iter().any(UnitFinding::is_error), "{name}: {f:?}");
}
}
}