1use crate::types::{Equation, Expr, ExpressionNode};
21use std::collections::{BTreeMap, HashMap};
22use thiserror::Error;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37pub struct Rational {
38 num: i32,
39 den: i32,
40}
41
42impl Rational {
43 #[must_use]
45 pub fn int(n: i32) -> Self {
46 Rational { num: n, den: 1 }
47 }
48
49 #[must_use]
51 pub fn new(num: i32, den: i32) -> Self {
52 if den == 0 {
53 return Rational { num: 0, den: 1 };
54 }
55 let sign = if den < 0 { -1 } else { 1 };
56 let (num, den) = (num * sign, den * sign);
57 let g = gcd(num.abs(), den);
58 if g == 0 {
59 return Rational { num: 0, den: 1 };
60 }
61 Rational {
62 num: num / g,
63 den: den / g,
64 }
65 }
66
67 #[must_use]
75 pub fn from_f64(x: f64) -> Option<Self> {
76 if !x.is_finite() {
77 return None;
78 }
79 if x.fract() == 0.0 && x.abs() < i32::MAX as f64 {
80 return Some(Rational::int(x as i32));
81 }
82 for places in 1..=6u32 {
84 let den = 10i32.checked_pow(places)?;
85 let scaled = x * f64::from(den);
86 if scaled.fract().abs() < 1e-9 && scaled.abs() < i32::MAX as f64 {
87 return Some(Rational::new(scaled.round() as i32, den));
88 }
89 }
90 None
91 }
92
93 fn add(self, other: Self) -> Self {
94 Rational::new(
95 self.num * other.den + other.num * self.den,
96 self.den * other.den,
97 )
98 }
99
100 fn sub(self, other: Self) -> Self {
101 Rational::new(
102 self.num * other.den - other.num * self.den,
103 self.den * other.den,
104 )
105 }
106
107 fn mul(self, other: Self) -> Self {
108 Rational::new(self.num * other.num, self.den * other.den)
109 }
110
111 fn is_zero(self) -> bool {
112 self.num == 0
113 }
114
115 fn checked_from_i64(num: i64, den: i64) -> Option<Self> {
118 if den == 0 {
119 return None;
120 }
121 let sign = if den < 0 { -1 } else { 1 };
122 let (num, den) = (num.checked_mul(sign)?, den.checked_mul(sign)?);
123 let g = gcd_i64(num.abs(), den).max(1);
124 Some(Rational {
125 num: i32::try_from(num / g).ok()?,
126 den: i32::try_from(den / g).ok()?,
127 })
128 }
129
130 fn checked_add(self, other: Self) -> Option<Self> {
132 let (a, b, c, d) = self.parts_with(other);
133 Rational::checked_from_i64(a * d + c * b, b * d)
134 }
135
136 fn checked_sub(self, other: Self) -> Option<Self> {
138 let (a, b, c, d) = self.parts_with(other);
139 Rational::checked_from_i64(a * d - c * b, b * d)
140 }
141
142 fn checked_mul(self, other: Self) -> Option<Self> {
144 let (a, b, c, d) = self.parts_with(other);
145 Rational::checked_from_i64(a * c, b * d)
146 }
147
148 fn checked_div(self, other: Self) -> Option<Self> {
150 let (a, b, c, d) = self.parts_with(other);
151 Rational::checked_from_i64(a * d, b * c)
152 }
153
154 fn parts_with(self, other: Self) -> (i64, i64, i64, i64) {
157 (
158 i64::from(self.num),
159 i64::from(self.den),
160 i64::from(other.num),
161 i64::from(other.den),
162 )
163 }
164
165 fn as_f64(self) -> f64 {
167 f64::from(self.num) / f64::from(self.den)
168 }
169}
170
171impl std::fmt::Display for Rational {
172 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173 if self.den == 1 {
174 write!(f, "{}", self.num)
175 } else {
176 write!(f, "{}/{}", self.num, self.den)
177 }
178 }
179}
180
181fn gcd(a: i32, b: i32) -> i32 {
182 if b == 0 { a } else { gcd(b, a % b) }
183}
184
185fn gcd_i64(a: i64, b: i64) -> i64 {
186 if b == 0 { a } else { gcd_i64(b, a % b) }
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
191pub enum ScaleFactor {
192 Prime(u64),
194 Pi,
196}
197
198#[derive(Debug, Clone, PartialEq, Eq, Default)]
207pub struct ExactScale {
208 factors: BTreeMap<ScaleFactor, Rational>,
209}
210
211impl ExactScale {
212 #[must_use]
214 pub fn one() -> Self {
215 Self::default()
216 }
217
218 #[must_use]
224 pub fn integer(n: u64) -> Self {
225 assert!(n > 0, "a unit scale must be positive");
226 let mut out = Self::one();
227 let mut rest = n;
228 let mut p = 2u64;
229 while p * p <= rest {
230 while rest.is_multiple_of(p) {
231 out.bump(ScaleFactor::Prime(p), Rational::int(1));
232 rest /= p;
233 }
234 p += if p == 2 { 1 } else { 2 };
235 }
236 if rest > 1 {
237 out.bump(ScaleFactor::Prime(rest), Rational::int(1));
238 }
239 out
240 }
241
242 #[must_use]
244 pub fn ratio(num: u64, den: u64) -> Self {
245 Self::integer(num).divide(&Self::integer(den))
246 }
247
248 #[must_use]
250 pub fn power_of_ten(k: i32) -> Self {
251 let mut out = Self::one();
252 out.bump(ScaleFactor::Prime(2), Rational::int(k));
253 out.bump(ScaleFactor::Prime(5), Rational::int(k));
254 out
255 }
256
257 #[must_use]
259 pub fn pi() -> Self {
260 let mut out = Self::one();
261 out.bump(ScaleFactor::Pi, Rational::int(1));
262 out
263 }
264
265 #[must_use]
273 pub fn decimal(s: &str) -> Self {
274 let (mantissa, exp) = match s.split_once(['e', 'E']) {
275 Some((m, e)) => (m, e.parse::<i32>().expect("registry decimal exponent")),
276 None => (s, 0),
277 };
278 let (int_part, frac_part) = mantissa.split_once('.').unwrap_or((mantissa, ""));
279 let digits = format!("{int_part}{frac_part}");
280 let n: u64 = digits
281 .trim_start_matches('0')
282 .parse()
283 .expect("registry decimal mantissa");
284 let places = i32::try_from(frac_part.len()).expect("registry decimal places");
285 Self::integer(n).multiply(&Self::power_of_ten(exp - places))
286 }
287
288 fn bump(&mut self, factor: ScaleFactor, by: Rational) {
289 let e = self.factors.entry(factor).or_insert(Rational::int(0));
290 *e = e.add(by);
291 if e.is_zero() {
292 self.factors.remove(&factor);
293 }
294 }
295
296 #[must_use]
298 pub fn multiply(&self, other: &Self) -> Self {
299 let mut out = self.clone();
300 for (f, e) in &other.factors {
301 out.bump(*f, *e);
302 }
303 out
304 }
305
306 #[must_use]
308 pub fn divide(&self, other: &Self) -> Self {
309 let mut out = self.clone();
310 for (f, e) in &other.factors {
311 out.bump(*f, e.mul(Rational::int(-1)));
312 }
313 out
314 }
315
316 #[must_use]
318 pub fn power(&self, exponent: Rational) -> Self {
319 let mut out = Self::one();
320 for (f, e) in &self.factors {
321 out.bump(*f, e.mul(exponent));
322 }
323 out
324 }
325
326 #[must_use]
328 pub fn is_one(&self) -> bool {
329 self.factors.is_empty()
330 }
331
332 #[must_use]
335 pub fn to_f64(&self) -> f64 {
336 self.factors.iter().fold(1.0, |acc, (f, e)| {
337 let base = match f {
338 ScaleFactor::Prime(p) => *p as f64,
339 ScaleFactor::Pi => std::f64::consts::PI,
340 };
341 acc * base.powf(e.as_f64())
342 })
343 }
344
345 #[must_use]
349 pub fn ratio_string(&self) -> Option<String> {
350 let (mut num, mut den, mut pi) = (1u128, 1u128, 0i32);
351 for (f, e) in &self.factors {
352 if e.den != 1 {
353 return None;
354 }
355 match f {
356 ScaleFactor::Pi => pi = e.num,
357 ScaleFactor::Prime(p) => {
358 let pow = u128::from(*p).checked_pow(e.num.unsigned_abs())?;
359 if e.num > 0 {
360 num = num.checked_mul(pow)?;
361 } else {
362 den = den.checked_mul(pow)?;
363 }
364 }
365 }
366 }
367 let mut s = num.to_string();
368 if den != 1 {
369 s = format!("{s}/{den}");
370 }
371 Some(match pi {
372 0 => s,
373 1 => format!("{s}*pi"),
374 k => format!("{s}*pi^{k}"),
375 })
376 }
377}
378
379impl std::fmt::Display for ExactScale {
380 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
381 match self.ratio_string() {
382 Some(s) => write!(f, "{s}"),
383 None => write!(f, "{:e}", self.to_f64()),
384 }
385 }
386}
387
388#[derive(Debug, Clone, PartialEq)]
390pub struct Unit {
391 dimensions: HashMap<Dimension, Rational>,
393 scale: f64,
396 exact: ExactScale,
398}
399
400#[derive(Debug, Clone, PartialEq, Eq, Hash)]
408pub enum Dimension {
409 Mass, Length, Time, Current, Temperature, Amount, Luminosity, Angle, }
418
419#[derive(Debug, Clone, PartialEq, Error)]
421pub enum UnitError {
422 #[error("Parse error: {0}")]
423 ParseError(String),
424 #[error("Dimension mismatch: {0}")]
425 DimensionMismatch(String),
426 #[error("Unknown unit: {0}")]
427 UnknownUnit(String),
428}
429
430#[derive(Debug, Clone, Copy, PartialEq, Eq)]
461pub enum UnitSeverity {
462 Error,
464 Analysis,
466}
467
468pub const UNIT_FINDING_DIMENSIONAL_MISMATCH: &str = crate::diagnostic::codes::DIMENSIONAL_MISMATCH;
484pub const UNIT_FINDING_UNPARSEABLE: &str = crate::diagnostic::codes::UNPARSEABLE_UNIT;
486pub const UNIT_FINDING_ANALYSIS: &str = crate::diagnostic::codes::ANALYSIS;
488
489#[derive(Debug, Clone, PartialEq)]
491pub struct UnitFinding {
492 pub severity: UnitSeverity,
494 pub message: String,
496}
497
498impl UnitFinding {
499 fn error(message: impl Into<String>) -> Self {
500 UnitFinding {
501 severity: UnitSeverity::Error,
502 message: message.into(),
503 }
504 }
505
506 fn analysis(message: impl Into<String>) -> Self {
507 UnitFinding {
508 severity: UnitSeverity::Analysis,
509 message: message.into(),
510 }
511 }
512
513 pub fn is_error(&self) -> bool {
515 self.severity == UnitSeverity::Error
516 }
517
518 pub fn code(&self) -> &'static str {
526 match self.severity {
527 UnitSeverity::Error => UNIT_FINDING_DIMENSIONAL_MISMATCH,
528 UnitSeverity::Analysis => UNIT_FINDING_ANALYSIS,
529 }
530 }
531}
532
533#[derive(Debug, Clone, PartialEq)]
544enum Dim {
545 Known(Unit),
547 Unknown,
551}
552
553impl Dim {
554 fn known(&self) -> Option<&Unit> {
555 match self {
556 Dim::Known(u) => Some(u),
557 Dim::Unknown => None,
558 }
559 }
560}
561
562impl Unit {
563 pub fn scale(&self) -> f64 {
570 self.scale
571 }
572
573 pub fn exact_scale(&self) -> &ExactScale {
575 &self.exact
576 }
577
578 pub fn same_unit(&self, other: &Unit) -> bool {
581 self.is_compatible(other) && self.exact == other.exact
582 }
583
584 fn with_exact(mut self, exact: ExactScale) -> Self {
587 self.exact = exact;
588 self
589 }
590
591 pub fn same_dimensions(&self, other: &Unit) -> bool {
594 self.dimensions == other.dimensions
595 }
596
597 pub fn dimensionless() -> Self {
599 Unit {
600 dimensions: HashMap::new(),
601 scale: 1.0,
602 exact: ExactScale::one(),
603 }
604 }
605
606 pub fn base(dimension: Dimension, power: i32, scale: f64) -> Self {
612 Unit::base_rational(dimension, Rational::int(power), scale)
613 }
614
615 pub fn base_rational(dimension: Dimension, power: Rational, scale: f64) -> Self {
619 let mut dimensions = HashMap::new();
620 if !power.is_zero() {
621 dimensions.insert(dimension, power);
622 }
623 Unit {
624 dimensions,
625 scale,
626 exact: ExactScale::one(),
627 }
628 }
629
630 pub fn is_compatible(&self, other: &Unit) -> bool {
632 self.dimensions == other.dimensions
633 }
634
635 pub fn is_dimensionless(&self) -> bool {
637 self.dimensions.is_empty()
638 }
639
640 pub fn is_pure_number(&self) -> bool {
649 self.dimensions.is_empty() && self.exact.is_one()
650 }
651
652 pub fn is_plane_angle(&self) -> bool {
659 self.dimensions.len() == 1
660 && self
661 .dimensions
662 .get(&Dimension::Angle)
663 .is_some_and(|p| *p == Rational::int(1))
664 }
665
666 pub fn multiply(&self, other: &Unit) -> Unit {
668 let mut unit = self.combine(other, Rational::add, self.scale * other.scale);
669 unit.exact = self.exact.multiply(&other.exact);
670 unit
671 }
672
673 pub fn divide(&self, other: &Unit) -> Unit {
675 let mut unit = self.combine(other, Rational::sub, self.scale / other.scale);
676 unit.exact = self.exact.divide(&other.exact);
677 unit
678 }
679
680 fn combine(&self, other: &Unit, op: fn(Rational, Rational) -> Rational, scale: f64) -> Unit {
683 let mut dimensions = self.dimensions.clone();
684 for (dim, power) in &other.dimensions {
685 let entry = dimensions.entry(dim.clone()).or_insert(Rational::int(0));
686 *entry = op(*entry, *power);
687 if entry.is_zero() {
688 dimensions.remove(dim);
689 }
690 }
691 Unit {
692 dimensions,
693 scale,
694 exact: ExactScale::one(),
695 }
696 }
697
698 pub fn power(&self, exponent: i32) -> Unit {
700 self.power_rational(Rational::int(exponent))
701 }
702
703 pub fn power_rational(&self, exponent: Rational) -> Unit {
706 let dimensions = self
707 .dimensions
708 .iter()
709 .map(|(dim, power)| (dim.clone(), power.mul(exponent)))
710 .filter(|(_, power)| !power.is_zero())
711 .collect();
712
713 Unit {
714 dimensions,
715 scale: self.scale.powf(exponent.as_f64()),
716 exact: self.exact.power(exponent),
717 }
718 }
719
720 pub fn propagate(expr: &Expr, env: &HashMap<String, Unit>) -> Result<Unit, UnitError> {
741 Self::propagate_in(expr, env)
742 }
743
744 pub(crate) fn propagate_in(expr: &Expr, env: &dyn UnitEnv) -> Result<Unit, UnitError> {
746 let mut findings = Vec::new();
747 let dim = propagate_dim(expr, env, &mut findings);
748 if let Some(err) = findings.iter().find(|f| f.is_error()) {
750 return Err(UnitError::DimensionMismatch(err.message.clone()));
751 }
752 match dim {
753 Dim::Known(unit) => Ok(unit),
754 Dim::Unknown => Err(UnitError::UnknownUnit(
755 findings
756 .first()
757 .map(|f| f.message.clone())
758 .unwrap_or_else(|| "undetermined dimension".to_string()),
759 )),
760 }
761 }
762}
763
764fn propagate_dim(expr: &Expr, env: &dyn UnitEnv, findings: &mut Vec<UnitFinding>) -> Dim {
769 match expr {
770 Expr::Number(_) | Expr::Integer(_) => Dim::Unknown,
786 Expr::Variable(name) => {
787 if let Some(unit) = env.unit_of(name) {
788 Dim::Known(unit.clone())
789 } else if name == "t" {
790 Dim::Known(Unit::base(Dimension::Time, 1, 1.0))
792 } else {
793 Dim::Unknown
801 }
802 }
803 Expr::Operator(op) => propagate_operator_dim(op, env, findings),
804 }
805}
806
807fn propagate_args(
809 op: &ExpressionNode,
810 env: &dyn UnitEnv,
811 findings: &mut Vec<UnitFinding>,
812) -> Vec<Dim> {
813 op.args
814 .iter()
815 .map(|a| propagate_dim(a, env, findings))
816 .collect()
817}
818
819fn require_arity(op: &ExpressionNode, n: usize, findings: &mut Vec<UnitFinding>) -> bool {
823 if op.args.len() == n {
824 return true;
825 }
826 findings.push(UnitFinding::analysis(format!(
827 "'{}' expects {} argument(s) but has {}; not dimension-checked",
828 op.op,
829 n,
830 op.args.len()
831 )));
832 false
833}
834
835fn require_min_arity(op: &ExpressionNode, n: usize, findings: &mut Vec<UnitFinding>) -> bool {
840 if op.args.len() >= n {
841 return true;
842 }
843 findings.push(UnitFinding::analysis(format!(
844 "'{}' expects at least {} argument(s) but has {}; not dimension-checked",
845 op.op,
846 n,
847 op.args.len()
848 )));
849 false
850}
851
852fn propagate_operator_dim(
857 op: &ExpressionNode,
858 env: &dyn UnitEnv,
859 findings: &mut Vec<UnitFinding>,
860) -> Dim {
861 match op.op.as_str() {
862 "+" | "-" => propagate_additive_dim(op, env, findings),
863 "*" | "/" => propagate_multiplicative_dim(op, env, findings),
864 "^" | "power" | "pow" => propagate_power_dim(op, env, findings),
865 "D" | "ic" => propagate_calculus_dim(op, env, findings),
866 "exp" | "log" | "log10" | "ln" | "sin" | "cos" | "tan" | "asin" | "acos" | "atan"
867 | "sinh" | "cosh" | "tanh" | "asinh" | "acosh" | "atanh" => {
868 propagate_transcendental_dim(op, env, findings)
869 }
870 "sqrt" => propagate_sqrt_dim(op, env, findings),
871 "abs" | "floor" | "ceil" | "round" | "sign" | "Pre" => {
874 if !require_arity(op, 1, findings) {
875 return Dim::Unknown;
876 }
877 propagate_dim(&op.args[0], env, findings)
878 }
879 "min" | "max" => propagate_matching_dim(op, env, findings),
880 "atan2" => {
881 if !require_arity(op, 2, findings) {
882 return Dim::Unknown;
883 }
884 propagate_matching_dim(op, env, findings);
888 Dim::Known(radian())
889 }
890 "ifelse" => propagate_ifelse_dim(op, env, findings),
891 ">" | "<" | ">=" | "<=" | "==" | "!=" => {
892 if !require_arity(op, 2, findings) {
893 return Dim::Known(Unit::dimensionless());
894 }
895 propagate_matching_dim(op, env, findings);
897 Dim::Known(Unit::dimensionless())
898 }
899 "and" | "or" | "not" => {
900 propagate_args(op, env, findings);
905 Dim::Known(Unit::dimensionless())
906 }
907 "faq" | "makearray" | "index" | "reshape" | "transpose" | "concat" | "broadcast" => {
911 propagate_array_dim(op, env, findings)
912 }
913 "const" => match op.units.as_deref().map(parse_unit) {
918 Some(Ok(unit)) => Dim::Known(unit),
919 _ => Dim::Unknown,
920 },
921 _ => Dim::Unknown,
932 }
933}
934
935fn propagate_matching_dim(
946 op: &ExpressionNode,
947 env: &dyn UnitEnv,
948 findings: &mut Vec<UnitFinding>,
949) -> Dim {
950 let mut first: Option<Unit> = None;
951 for arg in &op.args {
952 let dim = propagate_dim(arg, env, findings);
953 let Some(unit) = dim.known() else {
954 continue;
955 };
956 match &first {
957 None => first = Some(unit.clone()),
958 Some(f) if !f.is_compatible(unit) => {
959 findings.push(UnitFinding::error(format!(
960 "Incompatible dimensions in '{}': {} vs {}",
961 op.op,
962 describe(f),
963 describe(unit)
964 )));
965 }
966 Some(f) if f.exact != unit.exact => {
967 findings.push(UnitFinding::error(format!(
968 "Incompatible scales in '{}': {} vs {}",
969 op.op,
970 describe_scaled(f),
971 describe_scaled(unit)
972 )));
973 }
974 _ => {}
975 }
976 }
977 match first {
978 Some(unit) => Dim::Known(unit),
979 None => Dim::Unknown,
980 }
981}
982
983fn propagate_additive_dim(
987 op: &ExpressionNode,
988 env: &dyn UnitEnv,
989 findings: &mut Vec<UnitFinding>,
990) -> Dim {
991 propagate_matching_dim(op, env, findings)
992}
993
994fn propagate_multiplicative_dim(
996 op: &ExpressionNode,
997 env: &dyn UnitEnv,
998 findings: &mut Vec<UnitFinding>,
999) -> Dim {
1000 let dims = propagate_args(op, env, findings);
1001 if op.op == "*" {
1002 let mut result = Unit::dimensionless();
1003 for d in &dims {
1004 match d.known() {
1005 Some(u) => result = result.multiply(u),
1006 None => return Dim::Unknown,
1007 }
1008 }
1009 return Dim::Known(result);
1010 }
1011 if !require_arity(op, 2, findings) {
1012 return Dim::Unknown;
1013 }
1014 match (dims[0].known(), dims[1].known()) {
1015 (Some(num), Some(den)) => Dim::Known(num.divide(den)),
1016 _ => Dim::Unknown,
1017 }
1018}
1019
1020fn propagate_power_dim(
1024 op: &ExpressionNode,
1025 env: &dyn UnitEnv,
1026 findings: &mut Vec<UnitFinding>,
1027) -> Dim {
1028 if !require_arity(op, 2, findings) {
1029 return Dim::Unknown;
1030 }
1031 let base = propagate_dim(&op.args[0], env, findings);
1032 let exp = propagate_dim(&op.args[1], env, findings);
1033
1034 if let Some(e) = exp.known()
1036 && !e.is_dimensionless()
1037 {
1038 findings.push(UnitFinding::error(format!(
1039 "Exponent in '{}' must be dimensionless, got {}",
1040 op.op,
1041 describe(e)
1042 )));
1043 return Dim::Unknown;
1044 }
1045
1046 let Some(base_unit) = base.known() else {
1047 return Dim::Unknown;
1048 };
1049 if base_unit.is_dimensionless() && base_unit.exact.is_one() {
1053 return Dim::Known(Unit::dimensionless());
1054 }
1055
1056 if let Some(r) = constant_exponent(&op.args[1]) {
1062 return Dim::Known(base_unit.power_rational(r));
1063 }
1064 match &op.args[1] {
1065 Expr::Integer(_) | Expr::Number(_) => {
1066 let n = literal_f64(&op.args[1]).unwrap_or(f64::NAN);
1067 findings.push(UnitFinding::analysis(format!(
1068 "Exponent {n} is not a representable rational, so the dimension of a \
1069 dimensional base is unknown"
1070 )));
1071 }
1072 _ => {
1076 findings.push(UnitFinding::analysis(format!(
1077 "Exponent of '{}' is not a constant expression, so the dimension of a \
1078 dimensional base is unknown",
1079 op.op
1080 )));
1081 }
1082 }
1083 Dim::Unknown
1084}
1085
1086fn literal_f64(expr: &Expr) -> Option<f64> {
1089 match expr {
1090 Expr::Integer(i) => Some(*i as f64),
1091 Expr::Number(n) => Some(*n),
1092 _ => None,
1093 }
1094}
1095
1096pub fn constant_exponent(expr: &Expr) -> Option<Rational> {
1109 if let Some(n) = literal_f64(expr) {
1110 return Rational::from_f64(n);
1111 }
1112 let Expr::Operator(node) = expr else {
1113 return None;
1114 };
1115 if node.op == "const" {
1116 if let Some(units) = &node.units {
1117 let unit = parse_unit(units).ok()?;
1118 if !unit.is_pure_number() {
1119 return None;
1120 }
1121 }
1122 return node
1123 .value
1124 .as_ref()
1125 .and_then(serde_json::Value::as_f64)
1126 .and_then(Rational::from_f64);
1127 }
1128 let values = node
1129 .args
1130 .iter()
1131 .map(constant_exponent)
1132 .collect::<Option<Vec<Rational>>>()?;
1133 let (first, rest) = values.split_first()?;
1134 match node.op.as_str() {
1135 "+" => rest.iter().try_fold(*first, |acc, v| acc.checked_add(*v)),
1136 "*" => rest.iter().try_fold(*first, |acc, v| acc.checked_mul(*v)),
1137 "-" if rest.is_empty() => Rational::int(0).checked_sub(*first),
1138 "-" => rest.iter().try_fold(*first, |acc, v| acc.checked_sub(*v)),
1139 "/" if rest.len() == 1 => first.checked_div(rest[0]),
1140 _ => None,
1141 }
1142}
1143
1144fn propagate_calculus_dim(
1149 op: &ExpressionNode,
1150 env: &dyn UnitEnv,
1151 findings: &mut Vec<UnitFinding>,
1152) -> Dim {
1153 let well_formed = if crate::op_registry::is_rewrite_target_derivative(op) {
1160 require_min_arity(op, 1, findings)
1161 } else {
1162 require_arity(op, 1, findings)
1163 };
1164 if !well_formed {
1165 return Dim::Unknown;
1166 }
1167 let arg = propagate_dim(&op.args[0], env, findings);
1168 if op.op == "ic" {
1169 return arg;
1170 }
1171 let wrt = crate::op_registry::derivative_wrt(op);
1172 let (Some(arg_unit), Some(wrt_unit)) = (arg.known(), env.unit_of(wrt)) else {
1181 return Dim::Unknown;
1182 };
1183 Dim::Known(arg_unit.divide(wrt_unit))
1184}
1185
1186fn derivative_of_undeclared_time<'a>(expr: &'a Expr, env: &dyn UnitEnv) -> Option<&'a Expr> {
1190 let Expr::Operator(op) = expr else {
1191 return None;
1192 };
1193 if op.op != "D" || op.args.len() != 1 {
1194 return None;
1195 }
1196 let wrt = crate::op_registry::derivative_wrt(op);
1197 (!env.contains(wrt)).then(|| &op.args[0])
1198}
1199
1200fn derivative_time_mismatch(state: &Unit, rhs: &Unit) -> Option<String> {
1207 let ratio = state.divide(rhs);
1208 let unreconcilable = ratio
1209 .dimensions
1210 .iter()
1211 .any(|(d, p)| *d != Dimension::Time && !p.is_zero());
1212 unreconcilable.then(|| {
1213 format!(
1214 "No time unit can reconcile d({})/dt with {} (their ratio {} is not a power of time)",
1215 describe(state),
1216 describe(rhs),
1217 describe(&ratio)
1218 )
1219 })
1220}
1221
1222fn propagate_transcendental_dim(
1234 op: &ExpressionNode,
1235 env: &dyn UnitEnv,
1236 findings: &mut Vec<UnitFinding>,
1237) -> Dim {
1238 let is_inverse_circular = matches!(op.op.as_str(), "asin" | "acos" | "atan");
1253 let result = if is_inverse_circular {
1254 radian()
1255 } else {
1256 Unit::dimensionless()
1257 };
1258
1259 if !require_arity(op, 1, findings) {
1260 return Dim::Known(result);
1263 }
1264
1265 let angle_ok = matches!(op.op.as_str(), "sin" | "cos" | "tan");
1267
1268 let arg = propagate_dim(&op.args[0], env, findings);
1269 if let Some(u) = arg.known() {
1270 if angle_ok && u.is_plane_angle() {
1282 } else if u.is_pure_number() {
1284 } else if u.is_dimensionless() {
1286 findings.push(UnitFinding::error(scaled_dimensionless_message(&op.op, u)));
1287 } else {
1288 findings.push(UnitFinding::error(format!(
1289 "Argument to '{}' must be dimensionless{}, got {}",
1290 op.op,
1291 if angle_ok { " (or an angle)" } else { "" },
1292 describe(u)
1293 )));
1294 }
1295 }
1296 Dim::Known(result)
1297}
1298
1299#[must_use]
1305pub fn angle_normalization_factor(unit: &Unit) -> Option<f64> {
1306 (unit.is_plane_angle() && !unit.exact.is_one()).then_some(unit.scale)
1307}
1308
1309#[must_use]
1324pub fn normalize_angle_arguments(expr: &Expr, env: &HashMap<String, Unit>) -> Option<Expr> {
1325 let Expr::Operator(node) = expr else {
1326 return None;
1327 };
1328
1329 let mut children: Vec<Option<Expr>> = Vec::new();
1336 node.for_each_child(&mut |child| children.push(normalize_angle_arguments(child, env)));
1337
1338 let mut factor = None;
1342 if matches!(node.op.as_str(), "sin" | "cos" | "tan") && node.args.len() == 1 {
1343 let arg = children[0].as_ref().unwrap_or(&node.args[0]);
1344 let mut findings = Vec::new();
1345 factor = propagate_dim(arg, env, &mut findings)
1346 .known()
1347 .and_then(angle_normalization_factor);
1348 }
1349
1350 if factor.is_none() && children.iter().all(Option::is_none) {
1351 return None;
1352 }
1353 let mut out = (**node).clone();
1354 let mut rewritten = children.into_iter();
1355 out.for_each_child_mut(&mut |child| {
1356 if let Some(Some(next)) = rewritten.next() {
1357 *child = next;
1358 }
1359 });
1360 if let Some(factor) = factor {
1361 let scaled = ExpressionNode {
1362 op: "*".to_string(),
1363 args: vec![out.args[0].clone(), Expr::Number(factor)],
1364 ..Default::default()
1365 };
1366 out.args[0] = Expr::operator(scaled);
1367 }
1368 Some(Expr::operator(out))
1369}
1370
1371fn radian() -> Unit {
1374 Unit::base(Dimension::Angle, 1, 1.0)
1375}
1376
1377const SCALED_DIMENSIONLESS_SPELLINGS: &[(&str, i32)] =
1385 &[("percent", -2), ("ppm", -6), ("ppb", -9), ("ppt", -12)];
1386
1387#[must_use]
1390pub fn scaled_dimensionless_spelling(exact: &ExactScale) -> Option<&'static str> {
1391 SCALED_DIMENSIONLESS_SPELLINGS
1392 .iter()
1393 .find(|(_, k)| ExactScale::power_of_ten(*k) == *exact)
1394 .map(|(name, _)| *name)
1395}
1396
1397fn scaled_dimensionless_message(op: &str, unit: &Unit) -> String {
1406 match scaled_dimensionless_spelling(&unit.exact) {
1407 Some(name) => format!(
1408 "Argument to '{op}' must be dimensionless at scale 1, but is \
1409 dimensionless at scale {} ({name}); divide by 1 {name}, or by the \
1410 scale you mean, to state which reading is intended",
1411 unit.exact
1412 ),
1413 None => format!(
1414 "Argument to '{op}' must be dimensionless at scale 1, but is \
1415 dimensionless at scale {}; divide by a quantity carrying that \
1416 scale to state which reading is intended",
1417 unit.exact
1418 ),
1419 }
1420}
1421
1422fn propagate_sqrt_dim(
1426 op: &ExpressionNode,
1427 env: &dyn UnitEnv,
1428 findings: &mut Vec<UnitFinding>,
1429) -> Dim {
1430 if !require_arity(op, 1, findings) {
1431 return Dim::Unknown;
1432 }
1433 let arg = propagate_dim(&op.args[0], env, findings);
1434 match arg.known() {
1435 Some(unit) => Dim::Known(unit.power_rational(Rational::new(1, 2))),
1436 None => Dim::Unknown,
1437 }
1438}
1439
1440fn propagate_ifelse_dim(
1445 op: &ExpressionNode,
1446 env: &dyn UnitEnv,
1447 findings: &mut Vec<UnitFinding>,
1448) -> Dim {
1449 if !require_arity(op, 3, findings) {
1450 return Dim::Unknown;
1451 }
1452 propagate_dim(&op.args[0], env, findings);
1457 let t = propagate_dim(&op.args[1], env, findings);
1458 let f = propagate_dim(&op.args[2], env, findings);
1459 match (t.known(), f.known()) {
1460 (Some(a), Some(b)) if !a.is_compatible(b) => {
1461 findings.push(UnitFinding::error(format!(
1462 "'ifelse' branches must share dimensions: {} vs {}",
1463 describe(a),
1464 describe(b)
1465 )));
1466 Dim::Unknown
1467 }
1468 (Some(a), Some(b)) if a.exact != b.exact => {
1469 findings.push(UnitFinding::error(format!(
1470 "'ifelse' branches must share a scale: {} vs {}",
1471 describe_scaled(a),
1472 describe_scaled(b)
1473 )));
1474 Dim::Unknown
1475 }
1476 (Some(a), _) | (None, Some(a)) => Dim::Known(a.clone()),
1477 (None, None) => Dim::Unknown,
1478 }
1479}
1480
1481fn propagate_array_dim(
1503 op: &ExpressionNode,
1504 env: &dyn UnitEnv,
1505 findings: &mut Vec<UnitFinding>,
1506) -> Dim {
1507 match op.op.as_str() {
1508 "faq" => {
1509 if let Some(filter) = &op.filter {
1514 propagate_dim(filter, env, findings);
1515 }
1516 let body = op
1517 .expr
1518 .as_ref()
1519 .map(|body| propagate_dim(body, env, findings));
1520 propagate_args(op, env, findings);
1521 match body {
1522 Some(body) => faq_unit(op, body),
1523 None => Dim::Unknown,
1524 }
1525 }
1526 "makearray" => {
1527 propagate_args(op, env, findings);
1528 let mut first: Option<Unit> = None;
1529 for value in op.values.iter().flatten() {
1530 let dim = propagate_dim(value, env, findings);
1531 let Some(unit) = dim.known() else {
1532 continue;
1533 };
1534 match &first {
1535 None => first = Some(unit.clone()),
1536 Some(f) if !f.same_unit(unit) => {
1537 findings.push(UnitFinding::error(format!(
1538 "'makearray' regions must share units: {} vs {}",
1539 describe_scaled(f),
1540 describe_scaled(unit)
1541 )));
1542 }
1543 _ => {}
1544 }
1545 }
1546 first.map_or(Dim::Unknown, Dim::Known)
1547 }
1548 "concat" => propagate_matching_dim(op, env, findings),
1549 "broadcast" => {
1550 let Some(fn_name) = op.broadcast_fn.as_deref() else {
1562 propagate_args(op, env, findings);
1563 findings.push(UnitFinding::analysis(
1564 "'broadcast' is missing its 'fn'; its dimension is unknown".to_string(),
1565 ));
1566 return Dim::Unknown;
1567 };
1568 let synthetic = ExpressionNode {
1569 op: fn_name.to_string(),
1570 args: op.args.clone(),
1571 ..ExpressionNode::default()
1572 };
1573 propagate_operator_dim(&synthetic, env, findings)
1574 }
1575 _ => propagate_args(op, env, findings)
1577 .into_iter()
1578 .next()
1579 .unwrap_or(Dim::Unknown),
1580 }
1581}
1582
1583fn faq_unit(op: &ExpressionNode, body: Dim) -> Dim {
1598 if op.distinct == Some(true) || op.key.is_some() {
1599 return Dim::Unknown;
1600 }
1601 match crate::faq::effective_reduce_kind(op.semiring.as_deref(), op.reduce.as_deref()) {
1602 Ok(
1603 crate::faq::ReduceKind::Sum | crate::faq::ReduceKind::Max | crate::faq::ReduceKind::Min,
1604 ) => body,
1605 Ok(crate::faq::ReduceKind::Product) => {
1606 if body.known().is_some_and(Unit::is_pure_number) {
1607 body
1608 } else {
1609 Dim::Unknown
1610 }
1611 }
1612 Ok(crate::faq::ReduceKind::Or | crate::faq::ReduceKind::And) => {
1613 Dim::Known(Unit::dimensionless())
1614 }
1615 Err(_) => Dim::Unknown,
1616 }
1617}
1618
1619fn describe(unit: &Unit) -> String {
1621 if unit.is_dimensionless() {
1622 return "dimensionless".to_string();
1623 }
1624 let mut parts: Vec<String> = unit
1625 .dimensions
1626 .iter()
1627 .map(|(d, p)| {
1628 let name = match d {
1629 Dimension::Mass => "mass",
1630 Dimension::Length => "length",
1631 Dimension::Time => "time",
1632 Dimension::Current => "current",
1633 Dimension::Temperature => "temperature",
1634 Dimension::Amount => "amount",
1635 Dimension::Luminosity => "luminosity",
1636 Dimension::Angle => "angle",
1637 };
1638 if *p == Rational::int(1) {
1639 name.to_string()
1640 } else {
1641 format!("{name}^{p}")
1642 }
1643 })
1644 .collect();
1645 parts.sort();
1646 parts.join("*")
1647}
1648
1649fn describe_scaled(unit: &Unit) -> String {
1652 if unit.exact.is_one() {
1653 describe(unit)
1654 } else {
1655 format!("{} (scale {})", describe(unit), unit.exact)
1656 }
1657}
1658
1659pub fn build_unit_env(
1693 variables: &indexmap::IndexMap<String, crate::ModelVariable>,
1694) -> (HashMap<String, Unit>, Vec<UnitParseFailure>) {
1695 let mut env = HashMap::new();
1696 let mut failures = Vec::new();
1697 let mut parsed: HashMap<&str, Option<Unit>> = HashMap::new();
1699 for (name, var) in variables {
1700 let Some(declared) = &var.units else {
1701 continue;
1703 };
1704 let unit = parsed
1705 .entry(declared.as_str())
1706 .or_insert_with(|| parse_unit(declared).ok());
1707 match unit {
1708 Some(unit) => {
1709 env.insert(name.clone(), unit.clone());
1710 }
1711 None => failures.push(UnitParseFailure {
1712 name: name.clone(),
1713 units: declared.clone(),
1714 }),
1715 }
1716 }
1717 failures.sort_by(|a, b| a.name.cmp(&b.name));
1720 (env, failures)
1721}
1722
1723pub(crate) trait UnitEnv {
1726 fn unit_of(&self, name: &str) -> Option<&Unit>;
1728
1729 fn contains(&self, name: &str) -> bool {
1731 self.unit_of(name).is_some()
1732 }
1733}
1734
1735impl<S: std::hash::BuildHasher> UnitEnv for HashMap<String, Unit, S> {
1736 fn unit_of(&self, name: &str) -> Option<&Unit> {
1737 self.get(name)
1738 }
1739}
1740
1741pub(crate) fn document_scoped_units(esm_file: &crate::EsmFile) -> HashMap<String, Unit> {
1749 let mut parsed: HashMap<String, Option<Unit>> = HashMap::new();
1750 crate::structural::toplevel_reference_units(esm_file)
1751 .into_iter()
1752 .filter_map(|(name, units)| {
1753 parsed
1754 .entry(units)
1755 .or_insert_with_key(|units| parse_unit(units).ok())
1756 .clone()
1757 .map(|unit| (name, unit))
1758 })
1759 .collect()
1760}
1761
1762pub(crate) struct ComponentUnitEnv<'d> {
1775 own: HashMap<String, Unit>,
1776 relative: HashMap<String, Option<Unit>>,
1777 document: &'d HashMap<String, Unit>,
1778}
1779
1780impl<'d> ComponentUnitEnv<'d> {
1781 pub(crate) fn new(
1784 own: HashMap<String, Unit>,
1785 subsystems: Option<&indexmap::IndexMap<String, serde_json::Value>>,
1786 document: &'d HashMap<String, Unit>,
1787 ) -> Self {
1788 let mut parsed: HashMap<String, Option<Unit>> = HashMap::new();
1789 let relative = subsystems
1790 .map(crate::structural::subsystem_reference_units)
1791 .unwrap_or_default()
1792 .into_iter()
1793 .filter(|(name, _)| !own.contains_key(name))
1794 .map(|(name, units)| {
1795 let unit = units.and_then(|units| {
1796 parsed
1797 .entry(units)
1798 .or_insert_with_key(|units| parse_unit(units).ok())
1799 .clone()
1800 });
1801 (name, unit)
1802 })
1803 .collect();
1804 ComponentUnitEnv {
1805 own,
1806 relative,
1807 document,
1808 }
1809 }
1810
1811 pub(crate) fn into_map(self) -> HashMap<String, Unit> {
1813 let mut env = self.own;
1814 for (name, unit) in self.document {
1815 if !env.contains_key(name) && !self.relative.contains_key(name) {
1816 env.insert(name.clone(), unit.clone());
1817 }
1818 }
1819 for (name, unit) in self.relative {
1820 if let Some(unit) = unit {
1821 env.entry(name).or_insert(unit);
1822 }
1823 }
1824 env
1825 }
1826}
1827
1828impl UnitEnv for ComponentUnitEnv<'_> {
1829 fn unit_of(&self, name: &str) -> Option<&Unit> {
1830 if let Some(unit) = self.own.get(name) {
1831 return Some(unit);
1832 }
1833 match self.relative.get(name) {
1834 Some(unit) => unit.as_ref(),
1835 None => self.document.get(name),
1836 }
1837 }
1838}
1839
1840pub fn model_unit_env(
1845 esm_file: &crate::EsmFile,
1846 model: &crate::Model,
1847) -> (HashMap<String, Unit>, Vec<UnitParseFailure>) {
1848 let document = document_scoped_units(esm_file);
1849 let (own, failures) = build_unit_env(&model.variables);
1850 let env = ComponentUnitEnv::new(own, model.subsystems.as_ref(), &document).into_map();
1851 (env, failures)
1852}
1853
1854#[derive(Debug, Clone, PartialEq, Eq)]
1860pub struct UnitParseFailure {
1861 pub name: String,
1863 pub units: String,
1865}
1866
1867pub fn validate_equation_dimensions(
1883 eq: &Equation,
1884 env: &HashMap<String, Unit>,
1885) -> Result<(), UnitError> {
1886 if let Some(err) = check_equation_dimensions(eq, env)
1887 .iter()
1888 .find(|f| f.is_error())
1889 {
1890 return Err(UnitError::DimensionMismatch(err.message.clone()));
1891 }
1892 let mut sink = Vec::new();
1896 let lhs = propagate_dim(&eq.lhs, env, &mut sink);
1897 let rhs = propagate_dim(&eq.rhs, env, &mut sink);
1898 if lhs.known().is_none() || rhs.known().is_none() {
1899 return Err(UnitError::UnknownUnit(
1900 "equation has an indeterminate side; skipped for dimensional checking".to_string(),
1901 ));
1902 }
1903 Ok(())
1904}
1905
1906pub fn check_equation_dimensions(eq: &Equation, env: &HashMap<String, Unit>) -> Vec<UnitFinding> {
1914 equation_findings(eq, env)
1915}
1916
1917pub(crate) fn equation_findings(eq: &Equation, env: &dyn UnitEnv) -> Vec<UnitFinding> {
1919 let mut findings = Vec::new();
1920 let lhs = propagate_dim(&eq.lhs, env, &mut findings);
1921 let rhs = propagate_dim(&eq.rhs, env, &mut findings);
1922
1923 if let Some(state) = derivative_of_undeclared_time(&eq.lhs, env) {
1926 let state_dim = propagate_dim(state, env, &mut Vec::new());
1927 if let (Some(s), Some(r)) = (state_dim.known(), rhs.known())
1928 && let Some(message) = derivative_time_mismatch(s, r)
1929 {
1930 findings.push(UnitFinding::error(message));
1931 }
1932 return findings;
1933 }
1934
1935 if let (Some(l), Some(r)) = (lhs.known(), rhs.known())
1936 && !l.same_unit(r)
1937 {
1938 findings.push(UnitFinding::error(format!(
1939 "Left-hand side has units of {} but right-hand side has units of {}",
1940 describe_scaled(l),
1941 describe_scaled(r)
1942 )));
1943 }
1944 findings
1945}
1946
1947pub fn check_expression_dimensions(
1954 expr: &Expr,
1955 declared: Option<&Unit>,
1956 env: &HashMap<String, Unit>,
1957) -> Vec<UnitFinding> {
1958 expression_findings(expr, declared, env)
1959}
1960
1961pub(crate) fn expression_findings(
1963 expr: &Expr,
1964 declared: Option<&Unit>,
1965 env: &dyn UnitEnv,
1966) -> Vec<UnitFinding> {
1967 let mut findings = Vec::new();
1968 let actual = propagate_dim(expr, env, &mut findings);
1969
1970 if findings.iter().any(UnitFinding::is_error) {
1975 return findings;
1976 }
1977
1978 if let (Some(declared), Some(actual)) = (declared, actual.known())
1979 && !declared.same_unit(actual)
1980 {
1981 findings.push(UnitFinding::error(format!(
1982 "Declared units of {} do not match the expression's units of {}",
1983 describe_scaled(declared),
1984 describe_scaled(actual)
1985 )));
1986 }
1987 findings
1988}
1989
1990#[derive(Debug, Clone, PartialEq)]
1993pub enum EquationDimensionVerdict {
1994 Consistent(String),
1997 Mismatch(Vec<String>),
1999 NotChecked(String),
2002}
2003
2004pub fn equation_dimension_verdict(
2012 eq: &Equation,
2013 env: &HashMap<String, Unit>,
2014) -> EquationDimensionVerdict {
2015 let errors: Vec<String> = check_equation_dimensions(eq, env)
2016 .into_iter()
2017 .filter(UnitFinding::is_error)
2018 .map(|f| f.message)
2019 .collect();
2020 if !errors.is_empty() {
2021 return EquationDimensionVerdict::Mismatch(errors);
2022 }
2023 let rhs = propagate_dim(&eq.rhs, env, &mut Vec::new());
2024 let (lhs_label, lhs_expr) = match derivative_of_undeclared_time(&eq.lhs, env) {
2027 Some(state) => ("differentiated state", state),
2028 None => ("left-hand side", &eq.lhs),
2029 };
2030 let lhs = propagate_dim(lhs_expr, env, &mut Vec::new());
2031 match (lhs.known(), rhs.known()) {
2032 (Some(_), Some(r)) => EquationDimensionVerdict::Consistent(describe(r)),
2033 (None, _) => EquationDimensionVerdict::NotChecked(format!(
2034 "{lhs_label}: {}",
2035 indeterminate_operand(lhs_expr, env)
2036 )),
2037 (_, None) => EquationDimensionVerdict::NotChecked(format!(
2038 "right-hand side: {}",
2039 indeterminate_operand(&eq.rhs, env)
2040 )),
2041 }
2042}
2043
2044fn indeterminate_operand(expr: &Expr, env: &dyn UnitEnv) -> String {
2047 match expr {
2048 Expr::Number(n) => format!("operand `{n}` has an indeterminate dimension"),
2049 Expr::Integer(n) => format!("operand `{n}` has an indeterminate dimension"),
2050 Expr::Variable(name) => format!("variable `{name}` has no declared units"),
2051 Expr::Operator(op) => {
2052 let mut literal: Option<String> = None;
2056 for arg in &op.args {
2057 if propagate_dim(arg, env, &mut Vec::new()).known().is_some() {
2058 continue;
2059 }
2060 if matches!(arg, Expr::Number(_) | Expr::Integer(_)) {
2061 literal.get_or_insert_with(|| indeterminate_operand(arg, env));
2062 continue;
2063 }
2064 return indeterminate_operand(arg, env);
2065 }
2066 literal.unwrap_or_else(|| format!("`{}` node has no determinable dimension", op.op))
2067 }
2068 }
2069}
2070
2071pub fn unresolvable_const_units(expr: &Expr) -> Vec<String> {
2074 fn walk(expr: &Expr, out: &mut Vec<String>) {
2075 if let Expr::Operator(node) = expr {
2076 if node.op == "const"
2077 && let Some(units) = &node.units
2078 && parse_unit(units).is_err()
2079 {
2080 out.push(units.clone());
2081 }
2082 node.for_each_child(&mut |child| walk(child, out));
2083 }
2084 }
2085 let mut out = Vec::new();
2086 walk(expr, &mut out);
2087 out
2088}
2089
2090pub fn reject_const_units_pre_v12(
2094 view: &serde_json::Value,
2095) -> Result<(), crate::diagnostic::DiagnosticError> {
2096 reject_const_units_pre_v12_found(view, || {
2097 crate::json_visit::find_value_path(view, &mut |v| {
2098 v.as_object().is_some_and(|o| {
2099 crate::json_visit::small_get(o, "op").is_some()
2100 && crate::json_visit::small_get(o, "units").is_some()
2101 })
2102 })
2103 })
2104}
2105
2106pub(crate) fn reject_const_units_pre_v12_found(
2110 view: &serde_json::Value,
2111 offending: impl FnOnce() -> Option<String>,
2112) -> Result<(), crate::diagnostic::DiagnosticError> {
2113 let Some(esm) = view.get("esm").and_then(|v| v.as_str()) else {
2114 return Ok(());
2115 };
2116 let Some((major, minor, _)) = crate::diagnostic::parse_semver(esm) else {
2117 return Ok(());
2118 };
2119 if (major, minor) >= (1, 2) {
2120 return Ok(());
2121 }
2122 match offending() {
2123 None => Ok(()),
2124 Some(path) => Err(crate::diagnostic::err(
2125 crate::diagnostic::codes::CONST_UNITS_VERSION_TOO_OLD,
2126 format!(
2127 "declared `units` on an expression node require esm >= 1.2.0; file declares \
2128 {esm}. Offending path: {path}"
2129 ),
2130 )),
2131 }
2132}
2133
2134pub fn parse_unit(unit_str: &str) -> Result<Unit, UnitError> {
2144 parse_normalized(&normalize_unit_str(unit_str), unit_str)
2145}
2146
2147fn normalize_unit_str(s: &str) -> String {
2160 let s = s.replace("°C", "degC").replace("**", "^");
2162
2163 let mut out = String::with_capacity(s.len());
2164 let mut in_superscript = false;
2165 for c in s.chars() {
2166 let sup = match c {
2167 '⁰' => Some('0'),
2168 '¹' => Some('1'),
2169 '²' => Some('2'),
2170 '³' => Some('3'),
2171 '⁴' => Some('4'),
2172 '⁵' => Some('5'),
2173 '⁶' => Some('6'),
2174 '⁷' => Some('7'),
2175 '⁸' => Some('8'),
2176 '⁹' => Some('9'),
2177 '⁻' => Some('-'),
2178 _ => None,
2179 };
2180 match sup {
2181 Some(ascii) => {
2183 if !in_superscript {
2184 out.push('^');
2185 in_superscript = true;
2186 }
2187 out.push(ascii);
2188 }
2189 None => {
2190 in_superscript = false;
2191 match c {
2192 '·' | '⋅' | '×' => out.push('*'),
2193 'µ' | 'μ' => out.push('u'),
2194 'Ω' => out.push_str("Ohm"),
2195 _ => out.push(c),
2196 }
2197 }
2198 }
2199 }
2200 out
2201}
2202
2203fn parse_normalized(s: &str, original: &str) -> Result<Unit, UnitError> {
2206 let s = s.trim();
2207 if s.is_empty() || s == "1" || s == "dimensionless" {
2208 return Ok(Unit::dimensionless());
2209 }
2210
2211 if s.starts_with('(') && s.ends_with(')') && parens_balance(&s[1..s.len() - 1]) {
2213 return parse_normalized(&s[1..s.len() - 1], original);
2214 }
2215
2216 let base_units = get_base_units();
2217 if let Some(unit) = base_units.get(s) {
2218 return Ok(unit.clone());
2219 }
2220 if let Some(unit) = parse_si_prefixed(s, base_units) {
2225 return Ok(unit);
2226 }
2227
2228 if let Some((idx, len, sym)) = find_last_top_level_operator(s) {
2240 let (left, right) = (&s[..idx], &s[idx + len..]);
2241 if left.trim().is_empty() || right.trim().is_empty() {
2242 return Err(UnitError::ParseError(format!(
2243 "Dangling '{sym}' in unit \"{original}\""
2244 )));
2245 }
2246 let l = parse_normalized(left, original)?;
2247 let r = parse_normalized(right, original)?;
2248 return Ok(match sym {
2249 '/' => l.divide(&r),
2250 _ => l.multiply(&r),
2251 });
2252 }
2253
2254 if let Some(idx) = s.rfind('^') {
2257 let (base_s, pow_s) = (&s[..idx], &s[idx + 1..]);
2258 let base_unit = parse_normalized(base_s, original)?;
2259 let power = parse_exponent(pow_s).ok_or_else(|| {
2260 UnitError::ParseError(format!(
2261 "Invalid exponent \"{pow_s}\" in unit \"{original}\""
2262 ))
2263 })?;
2264 return Ok(base_unit.power_rational(power));
2265 }
2266
2267 if let Ok(v) = s.parse::<f64>() {
2279 if v != 1.0 {
2280 return Err(UnitError::ParseError(format!(
2281 "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)"
2282 )));
2283 }
2284 return Ok(Unit {
2285 dimensions: HashMap::new(),
2286 scale: 1.0,
2287 exact: ExactScale::one(),
2288 });
2289 }
2290
2291 Err(UnitError::UnknownUnit(original.to_string()))
2292}
2293
2294fn parse_si_prefixed(s: &str, base_units: &HashMap<String, Unit>) -> Option<Unit> {
2302 const PREFIXES: [(&str, f64, i32); 20] = [
2304 ("da", 1e1, 1),
2305 ("Y", 1e24, 24),
2306 ("Z", 1e21, 21),
2307 ("E", 1e18, 18),
2308 ("P", 1e15, 15),
2309 ("T", 1e12, 12),
2310 ("G", 1e9, 9),
2311 ("M", 1e6, 6),
2312 ("k", 1e3, 3),
2313 ("h", 1e2, 2),
2314 ("d", 1e-1, -1),
2315 ("c", 1e-2, -2),
2316 ("m", 1e-3, -3),
2317 ("u", 1e-6, -6),
2318 ("n", 1e-9, -9),
2319 ("p", 1e-12, -12),
2320 ("f", 1e-15, -15),
2321 ("a", 1e-18, -18),
2322 ("z", 1e-21, -21),
2323 ("y", 1e-24, -24),
2324 ];
2325 const PREFIXABLE: [&str; 13] = [
2327 "m", "g", "s", "mol", "K", "A", "cd", "L", "N", "Pa", "J", "W", "rad",
2328 ];
2329
2330 for (prefix, factor, power) in PREFIXES {
2331 let Some(base) = s.strip_prefix(prefix) else {
2332 continue;
2333 };
2334 if !PREFIXABLE.contains(&base) {
2335 continue;
2336 }
2337 let mut unit = base_units.get(base)?.clone();
2338 unit.scale *= factor;
2339 unit.exact = unit.exact.multiply(&ExactScale::power_of_ten(power));
2340 return Some(unit);
2341 }
2342 None
2343}
2344
2345fn parse_exponent(s: &str) -> Option<Rational> {
2348 let s = s.trim();
2349 let inner = s
2350 .strip_prefix('(')
2351 .and_then(|rest| rest.strip_suffix(')'))
2352 .unwrap_or(s)
2353 .trim();
2354 if let Some((num, den)) = inner.split_once('/') {
2355 return Some(Rational::new(
2356 num.trim().parse().ok()?,
2357 den.trim().parse().ok()?,
2358 ));
2359 }
2360 if let Ok(n) = inner.parse::<i32>() {
2361 return Some(Rational::int(n));
2362 }
2363 Rational::from_f64(inner.parse::<f64>().ok()?)
2364}
2365
2366fn find_last_top_level_operator(s: &str) -> Option<(usize, usize, char)> {
2379 let bytes = s.as_bytes();
2380 let mut depth = 0i32;
2381 let mut last = None;
2382 let mut i = 0usize;
2383 while i < bytes.len() {
2384 let c = bytes[i] as char;
2385 match c {
2386 '(' => depth += 1,
2387 ')' => depth -= 1,
2388 '*' | '/' if depth == 0 => last = Some((i, 1, c)),
2389 ' ' | '\t' if depth == 0 => {
2390 let start = i;
2395 while i < bytes.len() && matches!(bytes[i] as char, ' ' | '\t') {
2396 i += 1;
2397 }
2398 let before = s[..start].trim_end().chars().last();
2399 let after = s[i..].chars().next();
2400 let padding = matches!(before, Some('*' | '/' | '^') | None)
2401 || matches!(after, Some('*' | '/' | '^') | None);
2402 if !padding {
2403 last = Some((start, i - start, '*'));
2404 }
2405 continue;
2406 }
2407 _ => {}
2408 }
2409 i += 1;
2410 }
2411 last
2412}
2413
2414fn parens_balance(s: &str) -> bool {
2415 let mut depth = 0i32;
2416 for c in s.chars() {
2417 match c {
2418 '(' => depth += 1,
2419 ')' => {
2420 depth -= 1;
2421 if depth < 0 {
2422 return false;
2423 }
2424 }
2425 _ => {}
2426 }
2427 }
2428 depth == 0
2429}
2430
2431fn get_base_units() -> &'static HashMap<String, Unit> {
2437 static BASE_UNITS: std::sync::LazyLock<HashMap<String, Unit>> =
2438 std::sync::LazyLock::new(build_base_units);
2439 &BASE_UNITS
2440}
2441
2442fn build_base_units() -> HashMap<String, Unit> {
2443 let mut units = HashMap::new();
2444
2445 units.insert("m".to_string(), Unit::base(Dimension::Length, 1, 1.0));
2447 units.insert(
2448 "cm".to_string(),
2449 Unit::base(Dimension::Length, 1, 0.01).with_exact(ExactScale::power_of_ten(-2)),
2450 );
2451 units.insert(
2452 "km".to_string(),
2453 Unit::base(Dimension::Length, 1, 1000.0).with_exact(ExactScale::power_of_ten(3)),
2454 );
2455 units.insert(
2456 "mm".to_string(),
2457 Unit::base(Dimension::Length, 1, 0.001).with_exact(ExactScale::power_of_ten(-3)),
2458 );
2459 units.insert("meter".to_string(), Unit::base(Dimension::Length, 1, 1.0));
2460 units.insert("meters".to_string(), Unit::base(Dimension::Length, 1, 1.0));
2461 units.insert(
2467 "ft".to_string(),
2468 Unit::base(Dimension::Length, 1, 0.3048).with_exact(ExactScale::decimal("0.3048")),
2469 );
2470 units.insert(
2488 "mi".to_string(),
2489 Unit::base(Dimension::Length, 1, 5280.0 * units["ft"].scale)
2490 .with_exact(ExactScale::integer(5280).multiply(&units["ft"].exact)),
2491 );
2492
2493 units.insert("s".to_string(), Unit::base(Dimension::Time, 1, 1.0));
2495 units.insert(
2496 "min".to_string(),
2497 Unit::base(Dimension::Time, 1, 60.0).with_exact(ExactScale::integer(60)),
2498 );
2499 units.insert(
2500 "h".to_string(),
2501 Unit::base(Dimension::Time, 1, 3600.0).with_exact(ExactScale::integer(3600)),
2502 );
2503 units.insert(
2504 "hr".to_string(),
2505 Unit::base(Dimension::Time, 1, 3600.0).with_exact(ExactScale::integer(3600)),
2506 );
2507 units.insert(
2508 "hour".to_string(),
2509 Unit::base(Dimension::Time, 1, 3600.0).with_exact(ExactScale::integer(3600)),
2510 );
2511 units.insert(
2515 "day".to_string(),
2516 Unit::base(Dimension::Time, 1, 86400.0).with_exact(ExactScale::integer(86400)),
2517 );
2518
2519 units.insert("kg".to_string(), Unit::base(Dimension::Mass, 1, 1.0));
2521 units.insert(
2522 "g".to_string(),
2523 Unit::base(Dimension::Mass, 1, 0.001).with_exact(ExactScale::power_of_ten(-3)),
2524 );
2525 units.insert(
2532 "lb".to_string(),
2533 Unit::base(Dimension::Mass, 1, 0.45359237).with_exact(ExactScale::decimal("0.45359237")),
2534 );
2535 units.insert(
2545 "short_ton".to_string(),
2546 Unit::base(Dimension::Mass, 1, 2000.0 * units["lb"].scale)
2547 .with_exact(ExactScale::integer(2000).multiply(&units["lb"].exact)),
2548 );
2549 units.insert(
2550 "tonne".to_string(),
2551 Unit::base(Dimension::Mass, 1, 1000.0).with_exact(ExactScale::power_of_ten(3)),
2552 );
2553
2554 units.insert("mol".to_string(), Unit::base(Dimension::Amount, 1, 1.0));
2556
2557 units.insert("K".to_string(), Unit::base(Dimension::Temperature, 1, 1.0));
2562 units.insert(
2563 "degC".to_string(),
2564 Unit::base(Dimension::Temperature, 1, 1.0),
2565 );
2566 units.insert(
2567 "Celsius".to_string(),
2568 Unit::base(Dimension::Temperature, 1, 1.0),
2569 );
2570
2571 units.insert("A".to_string(), Unit::base(Dimension::Current, 1, 1.0));
2573 units.insert("cd".to_string(), Unit::base(Dimension::Luminosity, 1, 1.0));
2574
2575 units.insert("rad".to_string(), Unit::base(Dimension::Angle, 1, 1.0));
2578 let degree = Unit::base(Dimension::Angle, 1, std::f64::consts::PI / 180.0)
2582 .with_exact(ExactScale::pi().divide(&ExactScale::integer(180)));
2583 units.insert("degrees".to_string(), degree.clone());
2584 units.insert("degree".to_string(), degree.clone());
2585 units.insert("deg".to_string(), degree);
2586
2587 units.insert("sr".to_string(), Unit::base(Dimension::Angle, 2, 1.0));
2593
2594 units.insert(
2596 "L".to_string(),
2597 Unit::base(Dimension::Length, 3, 0.001).with_exact(ExactScale::power_of_ten(-3)),
2598 );
2599 units.insert(
2608 "gal".to_string(),
2609 Unit::base(Dimension::Length, 3, 0.003_785_411_784)
2610 .with_exact(ExactScale::decimal("0.003785411784")),
2611 );
2612
2613 units.insert("Hz".to_string(), Unit::base(Dimension::Time, -1, 1.0));
2615
2616 units.insert(
2620 "degF".to_string(),
2621 Unit::base(Dimension::Temperature, 1, 5.0 / 9.0).with_exact(ExactScale::ratio(5, 9)),
2622 );
2623
2624 units.insert(
2632 "year".to_string(),
2633 Unit::base(Dimension::Time, 1, 31_557_600.0).with_exact(ExactScale::integer(31_557_600)),
2634 );
2635 units.insert(
2636 "yr".to_string(),
2637 Unit::base(Dimension::Time, 1, 31_557_600.0).with_exact(ExactScale::integer(31_557_600)),
2638 );
2639
2640 units.insert(
2643 "m/s".to_string(),
2644 Unit::base(Dimension::Length, 1, 1.0).divide(&Unit::base(Dimension::Time, 1, 1.0)),
2645 );
2646
2647 let liter = Unit::base(Dimension::Length, 3, 0.001).with_exact(ExactScale::power_of_ten(-3));
2649 units.insert(
2650 "mol/L".to_string(),
2651 Unit::base(Dimension::Amount, 1, 1.0).divide(&liter),
2652 );
2653 units.insert(
2654 "M".to_string(),
2655 Unit::base(Dimension::Amount, 1, 1.0).divide(&liter),
2656 );
2657
2658 let newton = Unit::base(Dimension::Mass, 1, 1.0)
2660 .multiply(&Unit::base(Dimension::Length, 1, 1.0))
2661 .divide(&Unit::base(Dimension::Time, 2, 1.0));
2662 units.insert("N".to_string(), newton.clone());
2663
2664 let pascal = Unit::base(Dimension::Mass, 1, 1.0)
2666 .divide(&Unit::base(Dimension::Length, 1, 1.0))
2667 .divide(&Unit::base(Dimension::Time, 2, 1.0));
2668 units.insert("Pa".to_string(), pascal);
2669
2670 let joule = Unit::base(Dimension::Mass, 1, 1.0)
2672 .multiply(&Unit::base(Dimension::Length, 2, 1.0))
2673 .divide(&Unit::base(Dimension::Time, 2, 1.0));
2674 units.insert("J".to_string(), joule.clone());
2675 let mut kj = joule.clone();
2676 kj.scale *= 1000.0;
2677 kj.exact = kj.exact.multiply(&ExactScale::power_of_ten(3));
2678 units.insert("kJ".to_string(), kj);
2679 let mut cal = joule.clone();
2680 cal.scale *= 4.184;
2681 cal.exact = cal.exact.multiply(&ExactScale::decimal("4.184"));
2682 units.insert("cal".to_string(), cal);
2683 let mut kcal = joule.clone();
2684 kcal.scale *= 4184.0;
2685 kcal.exact = kcal.exact.multiply(&ExactScale::integer(4184));
2686 units.insert("kcal".to_string(), kcal);
2687
2688 let watt = Unit::base(Dimension::Mass, 1, 1.0)
2690 .multiply(&Unit::base(Dimension::Length, 2, 1.0))
2691 .divide(&Unit::base(Dimension::Time, 3, 1.0));
2692 units.insert("W".to_string(), watt.clone());
2693
2694 let foot = units["ft"].scale;
2706 let pound = units["lb"].scale;
2707 let mut horsepower = watt.clone();
2708 horsepower.scale *= 550.0 * foot * pound * 9.80665;
2709 horsepower.exact = horsepower.exact.multiply(
2710 &ExactScale::integer(550)
2711 .multiply(&units["ft"].exact)
2712 .multiply(&units["lb"].exact)
2713 .multiply(&ExactScale::decimal("9.80665")),
2714 );
2715 units.insert("hp".to_string(), horsepower);
2716
2717 let ampere = Unit::base(Dimension::Current, 1, 1.0);
2724 let coulomb = ampere.multiply(&Unit::base(Dimension::Time, 1, 1.0));
2725 units.insert("C".to_string(), coulomb.clone());
2726
2727 let volt = watt.divide(&ere);
2729 units.insert("V".to_string(), volt.clone());
2730
2731 units.insert("Ohm".to_string(), volt.divide(&ere));
2733
2734 units.insert("F".to_string(), coulomb.divide(&volt));
2736
2737 units.insert(
2740 "T".to_string(),
2741 Unit::base(Dimension::Mass, 1, 1.0)
2742 .divide(&Unit::base(Dimension::Time, 2, 1.0))
2743 .divide(&ere),
2744 );
2745
2746 let joule_scaled = |scale: f64, exact: ExactScale| {
2748 let mut u = joule.clone();
2749 u.scale *= scale;
2750 u.exact = u.exact.multiply(&exact);
2751 u
2752 };
2753 units.insert(
2754 "erg".to_string(),
2755 joule_scaled(1e-7, ExactScale::power_of_ten(-7)),
2756 );
2757 units.insert(
2758 "BTU".to_string(),
2759 joule_scaled(1_055.055_852_62, ExactScale::decimal("1055.05585262")),
2760 );
2761 units.insert(
2767 "Wh".to_string(),
2768 joule_scaled(3600.0, ExactScale::integer(3600)),
2769 );
2770 units.insert(
2771 "kWh".to_string(),
2772 joule_scaled(3.6e6, ExactScale::integer(3_600_000)),
2773 );
2774
2775 let dimensionless_scaled = |scale: f64, exact: ExactScale| Unit {
2780 dimensions: HashMap::new(),
2781 scale,
2782 exact,
2783 };
2784 units.insert(
2785 "ppm".to_string(),
2786 dimensionless_scaled(1e-6, ExactScale::power_of_ten(-6)),
2787 );
2788 units.insert(
2789 "ppmv".to_string(),
2790 dimensionless_scaled(1e-6, ExactScale::power_of_ten(-6)),
2791 );
2792 units.insert(
2793 "ppb".to_string(),
2794 dimensionless_scaled(1e-9, ExactScale::power_of_ten(-9)),
2795 );
2796 units.insert(
2797 "ppbv".to_string(),
2798 dimensionless_scaled(1e-9, ExactScale::power_of_ten(-9)),
2799 );
2800 units.insert(
2801 "ppt".to_string(),
2802 dimensionless_scaled(1e-12, ExactScale::power_of_ten(-12)),
2803 );
2804 units.insert(
2805 "pptv".to_string(),
2806 dimensionless_scaled(1e-12, ExactScale::power_of_ten(-12)),
2807 );
2808 units.insert(
2810 "%".to_string(),
2811 dimensionless_scaled(0.01, ExactScale::power_of_ten(-2)),
2812 );
2813 units.insert(
2814 "percent".to_string(),
2815 dimensionless_scaled(0.01, ExactScale::power_of_ten(-2)),
2816 );
2817
2818 for count in [
2823 "molec",
2824 "molecule",
2825 "count",
2826 "individuals",
2827 "vehicles",
2828 "units",
2829 ] {
2830 units.insert(count.to_string(), Unit::dimensionless());
2831 }
2832
2833 units.insert("psu".to_string(), Unit::dimensionless());
2835
2836 units.insert(
2838 "atm".to_string(),
2839 pascal_scaled(&units, 101_325.0, ExactScale::integer(101_325)),
2840 );
2841 units.insert(
2842 "uatm".to_string(),
2843 pascal_scaled(&units, 0.101_325, ExactScale::decimal("0.101325")),
2844 );
2845 units.insert(
2847 "bar".to_string(),
2848 pascal_scaled(&units, 1e5, ExactScale::power_of_ten(5)),
2849 );
2850 units.insert(
2853 "Torr".to_string(),
2854 pascal_scaled(&units, 101_325.0 / 760.0, ExactScale::ratio(101_325, 760)),
2855 );
2856 units.insert(
2857 "mmHg".to_string(),
2858 pascal_scaled(
2859 &units,
2860 133.322_387_415,
2861 ExactScale::decimal("133.322387415"),
2862 ),
2863 );
2864 units.insert(
2869 "inHg".to_string(),
2870 pascal_scaled(
2871 &units,
2872 3_386.388_640_341,
2873 ExactScale::decimal("3386.388640341"),
2874 ),
2875 );
2876 let psi_scale = units["lb"].scale * 9.80665 / (0.0254 * 0.0254);
2879 let psi_exact = units["lb"]
2880 .exact
2881 .multiply(&ExactScale::decimal("9.80665"))
2882 .divide(&ExactScale::decimal("0.0254").power(Rational::int(2)));
2883 units.insert(
2884 "psi".to_string(),
2885 pascal_scaled(&units, psi_scale, psi_exact),
2886 );
2887
2888 units.insert(
2892 "Dobson".to_string(),
2893 Unit::base(Dimension::Length, -2, 2.6867e20).with_exact(ExactScale::decimal("2.6867e20")),
2894 );
2895 units.insert(
2896 "DU".to_string(),
2897 Unit::base(Dimension::Length, -2, 2.6867e20).with_exact(ExactScale::decimal("2.6867e20")),
2898 );
2899
2900 units
2901}
2902
2903fn pascal_scaled(units: &HashMap<String, Unit>, scale: f64, exact: ExactScale) -> Unit {
2905 let mut u = units["Pa"].clone();
2906 u.scale *= scale;
2907 u.exact = u.exact.multiply(&exact);
2908 u
2909}
2910
2911pub fn check_dimensional_consistency(lhs_unit: &Unit, rhs_unit: &Unit) -> Result<(), UnitError> {
2922 if lhs_unit.is_compatible(rhs_unit) {
2923 Ok(())
2924 } else {
2925 Err(UnitError::DimensionMismatch(
2926 "Left and right sides have incompatible dimensions".to_string(),
2927 ))
2928 }
2929}
2930
2931pub fn convert_units(value: f64, from_unit: &Unit, to_unit: &Unit) -> Result<f64, UnitError> {
2943 if !from_unit.is_compatible(to_unit) {
2944 return Err(UnitError::DimensionMismatch(
2945 "Units have incompatible dimensions".to_string(),
2946 ));
2947 }
2948
2949 Ok(value * from_unit.scale / to_unit.scale)
2950}
2951
2952#[cfg(test)]
2953mod tests {
2954 use super::*;
2955 use crate::types::ExpressionNode;
2956
2957 fn op(name: &str, args: Vec<Expr>) -> Expr {
2958 Expr::operator(ExpressionNode {
2959 op: name.to_string(),
2960 args,
2961 ..ExpressionNode::default()
2962 })
2963 }
2964
2965 fn op_with_wrt(name: &str, args: Vec<Expr>, wrt: &str) -> Expr {
2966 Expr::operator(ExpressionNode {
2967 op: name.to_string(),
2968 args,
2969 wrt: Some(wrt.to_string()),
2970 ..ExpressionNode::default()
2971 })
2972 }
2973
2974 fn env_of(pairs: &[(&str, &str)]) -> HashMap<String, Unit> {
2975 pairs
2976 .iter()
2977 .map(|(k, v)| ((*k).to_string(), parse_unit(v).unwrap()))
2978 .collect()
2979 }
2980
2981 #[test]
2982 fn test_parse_dimensionless() {
2983 assert!(parse_unit("").unwrap().is_dimensionless());
2984 assert!(parse_unit("1").unwrap().is_dimensionless());
2985 assert!(parse_unit("dimensionless").unwrap().is_dimensionless());
2986 }
2987
2988 #[test]
2992 fn test_rational_exponents() {
2993 let half = Rational::new(-1, 2);
2994 assert_eq!(
2995 parse_unit("1/s^0.5")
2996 .unwrap()
2997 .dimensions
2998 .get(&Dimension::Time),
2999 Some(&half)
3000 );
3001 assert_eq!(
3003 parse_unit("s^(-1/2)")
3004 .unwrap()
3005 .dimensions
3006 .get(&Dimension::Time),
3007 Some(&half)
3008 );
3009 assert_eq!(
3011 parse_unit("m**3")
3012 .unwrap()
3013 .dimensions
3014 .get(&Dimension::Length),
3015 Some(&Rational::int(3))
3016 );
3017 assert_eq!(Rational::new(2, 4), Rational::new(1, 2));
3019 assert_eq!(Rational::from_f64(0.5), Some(Rational::new(1, 2)));
3020 }
3021
3022 #[test]
3025 fn test_unicode_normalization() {
3026 let u = parse_unit("W/m²").unwrap();
3028 assert_eq!(u.dimensions.get(&Dimension::Mass), Some(&Rational::int(1)));
3029 assert_eq!(u.dimensions.get(&Dimension::Time), Some(&Rational::int(-3)));
3030 assert_eq!(u.dimensions.get(&Dimension::Length), None);
3032
3033 assert_eq!(
3034 parse_unit("cm³")
3035 .unwrap()
3036 .dimensions
3037 .get(&Dimension::Length),
3038 Some(&Rational::int(3))
3039 );
3040 assert_eq!(
3042 parse_unit("m⁻³")
3043 .unwrap()
3044 .dimensions
3045 .get(&Dimension::Length),
3046 Some(&Rational::int(-3))
3047 );
3048 assert_eq!(
3050 parse_unit("J/(kg·K)").unwrap(),
3051 parse_unit("J/(kg*K)").unwrap()
3052 );
3053 assert_eq!(parse_unit("kg⋅m/s").unwrap(), parse_unit("kg*m/s").unwrap());
3054 assert_eq!(parse_unit("μg/m^3").unwrap(), parse_unit("ug/m^3").unwrap());
3056 assert_eq!(parse_unit("µg").unwrap(), parse_unit("ug").unwrap());
3057 assert!(
3060 parse_unit("°C")
3061 .unwrap()
3062 .is_compatible(&parse_unit("K").unwrap())
3063 );
3064 }
3065
3066 #[test]
3068 fn test_implicit_multiplication() {
3069 assert_eq!(
3070 parse_unit("ppb^-1 s^-1").unwrap(),
3071 parse_unit("ppb^-1*s^-1").unwrap()
3072 );
3073 assert_eq!(parse_unit("m / s").unwrap(), parse_unit("m/s").unwrap());
3076 assert_eq!(parse_unit("kg * m").unwrap(), parse_unit("kg*m").unwrap());
3077 }
3078
3079 #[test]
3086 fn test_inches_of_mercury() {
3087 let inhg = parse_unit("inHg").unwrap();
3088 let mmhg = parse_unit("mmHg").unwrap();
3089 assert!(
3090 inhg.is_compatible(&parse_unit("Pa").unwrap()),
3091 "inHg must carry the pressure dimension"
3092 );
3093 assert_eq!(
3094 inhg.scale,
3095 mmhg.scale * 25.4,
3096 "inHg must be EXACTLY 25.4 mmHg, not merely close to it"
3097 );
3098 }
3099
3100 #[test]
3108 fn test_international_mile() {
3109 let mi = parse_unit("mi").unwrap();
3110 let ft = parse_unit("ft").unwrap();
3111 assert!(
3112 mi.is_compatible(&parse_unit("m").unwrap()),
3113 "mi must carry the length dimension"
3114 );
3115 assert_eq!(mi.scale, 1609.344, "the international mile is 1609.344 m");
3116 assert_eq!(
3117 mi.scale,
3118 ft.scale * 5280.0,
3119 "mi must be EXACTLY 5280 ft, not merely close to it"
3120 );
3121 let mph = parse_unit("mi/h").unwrap();
3124 assert!(mph.is_compatible(&parse_unit("m/s").unwrap()));
3125 assert_eq!(mph.scale, 0.44704);
3127 assert!(
3128 parse_unit("mph").is_err(),
3129 "`mph` must stay unresolvable: a fused spelling of a compound the \
3130 grammar already builds is a second way to say one thing"
3131 );
3132 }
3133
3134 #[test]
3139 fn test_avoirdupois_pound() {
3140 let lb = parse_unit("lb").unwrap();
3141 assert!(lb.is_compatible(&parse_unit("kg").unwrap()));
3142 assert_eq!(lb.scale, 0.45359237);
3143 assert_eq!(
3144 parse_unit("short_ton").unwrap().scale,
3145 lb.scale * 2000.0,
3146 "short_ton is DEFINED as 2000 lb; the two must agree to the last bit"
3147 );
3148 }
3149
3150 #[test]
3156 fn test_mechanical_horsepower() {
3157 let hp = parse_unit("hp").unwrap();
3158 assert!(hp.is_compatible(&parse_unit("W").unwrap()));
3159 assert_eq!(hp.scale, 745.699_871_582_270_2);
3160 assert_eq!(
3161 hp.scale,
3162 550.0 * parse_unit("ft").unwrap().scale * parse_unit("lb").unwrap().scale * 9.80665,
3163 "hp must be exactly 550 ft*lbf/s in terms of the table's own ft and lb"
3164 );
3165 assert!(
3166 (hp.scale - 735.498_75).abs() > 1.0,
3167 "hp must be the MECHANICAL horsepower, not the metric one"
3168 );
3169 }
3170
3171 #[test]
3175 fn test_us_liquid_gallon() {
3176 let gal = parse_unit("gal").unwrap();
3177 let l = parse_unit("L").unwrap();
3178 assert_eq!(
3179 gal.dimensions.get(&Dimension::Length),
3180 Some(&Rational::int(3)),
3181 "a gallon is a volume, i.e. [length]^3"
3182 );
3183 assert_eq!(gal.scale, 0.003_785_411_784);
3184 assert!(
3185 (gal.scale / l.scale - 3.785_411_784).abs() < 1e-12,
3186 "the US liquid gallon is 3.785411784 L"
3187 );
3188 assert!(
3189 (gal.scale - 0.004_546_09).abs() > 1e-6,
3190 "must be the US liquid gallon, not the imperial one"
3191 );
3192 }
3193
3194 #[test]
3201 fn test_moves_compounds_derive() {
3202 assert!(
3203 parse_unit("mi/h")
3204 .unwrap()
3205 .is_compatible(&parse_unit("m/s").unwrap())
3206 );
3207 assert!(
3208 parse_unit("g/(hp*h)")
3209 .unwrap()
3210 .is_compatible(&parse_unit("kg/J").unwrap())
3211 );
3212 assert!(
3213 parse_unit("lb/(hp*h)")
3214 .unwrap()
3215 .is_compatible(&parse_unit("kg/J").unwrap())
3216 );
3217 assert!(
3218 parse_unit("g/gal")
3219 .unwrap()
3220 .is_compatible(&parse_unit("kg/m^3").unwrap())
3221 );
3222 assert!(
3223 parse_unit("g/mi")
3224 .unwrap()
3225 .is_compatible(&parse_unit("kg/m").unwrap())
3226 );
3227 assert!(
3228 parse_unit("kJ/gal")
3229 .unwrap()
3230 .is_compatible(&parse_unit("J/m^3").unwrap())
3231 );
3232 }
3233
3234 #[test]
3238 fn test_si_prefixes() {
3239 assert_eq!(
3240 parse_unit("mg").unwrap().dimensions.get(&Dimension::Mass),
3241 Some(&Rational::int(1))
3242 );
3243 assert!((parse_unit("mg").unwrap().scale - 1e-6).abs() < 1e-18);
3244 assert!((parse_unit("um").unwrap().scale - 1e-6).abs() < 1e-18);
3245 assert!((parse_unit("nm").unwrap().scale - 1e-9).abs() < 1e-21);
3246 assert_eq!(parse_unit("mL").unwrap(), {
3247 let mut l = parse_unit("L").unwrap();
3248 l.scale *= 1e-3;
3249 l.exact = l.exact.multiply(&ExactScale::power_of_ten(-3));
3250 l
3251 });
3252 assert_eq!(
3253 parse_unit("umol")
3254 .unwrap()
3255 .dimensions
3256 .get(&Dimension::Amount),
3257 Some(&Rational::int(1))
3258 );
3259 assert!((parse_unit("min").unwrap().scale - 60.0).abs() < 1e-9);
3261 assert_eq!(
3262 parse_unit("mol")
3263 .unwrap()
3264 .dimensions
3265 .get(&Dimension::Amount),
3266 Some(&Rational::int(1))
3267 );
3268 assert!(parse_unit("not_a_unit").is_err());
3270 }
3271
3272 #[test]
3275 fn test_counts_are_dimensionless() {
3276 for c in [
3277 "molec",
3278 "molecule",
3279 "count",
3280 "individuals",
3281 "vehicles",
3282 "units",
3283 ] {
3284 assert!(
3285 parse_unit(c).unwrap().is_dimensionless(),
3286 "{c} must be dimensionless"
3287 );
3288 }
3289 assert_eq!(
3291 parse_unit("individuals/km^2")
3292 .unwrap()
3293 .dimensions
3294 .get(&Dimension::Length),
3295 Some(&Rational::int(-2))
3296 );
3297 assert!(parse_unit("psu").unwrap().is_dimensionless());
3298 assert!(parse_unit("%").unwrap().is_dimensionless());
3299 assert!((parse_unit("%").unwrap().scale - 0.01).abs() < 1e-12);
3300 assert!(
3302 parse_unit("uatm")
3303 .unwrap()
3304 .is_compatible(&parse_unit("Pa").unwrap())
3305 );
3306 }
3307
3308 #[test]
3312 fn test_sqrt_halves_dimensions() {
3313 let env = env_of(&[("area", "m^2"), ("vol", "m^3")]);
3314
3315 let e = op("sqrt", vec![Expr::Variable("area".into())]);
3316 let u = Unit::propagate(&e, &env).unwrap();
3317 assert_eq!(
3318 u.dimensions.get(&Dimension::Length),
3319 Some(&Rational::int(1))
3320 );
3321 assert!(check_expression_dimensions(&e, None, &env).is_empty());
3322
3323 let e = op("sqrt", vec![Expr::Variable("vol".into())]);
3324 let u = Unit::propagate(&e, &env).unwrap();
3325 assert_eq!(
3326 u.dimensions.get(&Dimension::Length),
3327 Some(&Rational::new(3, 2))
3328 );
3329 assert!(
3330 !check_expression_dimensions(&e, None, &env)
3331 .iter()
3332 .any(UnitFinding::is_error),
3333 "sqrt of an odd power is representable, not an error"
3334 );
3335 }
3336
3337 #[test]
3340 fn test_trig_accepts_angle_but_exp_does_not() {
3341 let env = env_of(&[("theta", "rad"), ("m", "m")]);
3342
3343 let e = op("sin", vec![Expr::Variable("theta".into())]);
3344 assert!(
3345 !check_expression_dimensions(&e, None, &env)
3346 .iter()
3347 .any(UnitFinding::is_error),
3348 "sin(theta) with theta in radians is correct"
3349 );
3350
3351 let e = op("sin", vec![Expr::Variable("m".into())]);
3353 assert!(
3354 check_expression_dimensions(&e, None, &env)
3355 .iter()
3356 .any(UnitFinding::is_error)
3357 );
3358
3359 let e = op("exp", vec![Expr::Variable("theta".into())]);
3361 assert!(
3362 check_expression_dimensions(&e, None, &env)
3363 .iter()
3364 .any(UnitFinding::is_error),
3365 "exp requires strict dimensionlessness"
3366 );
3367 }
3368
3369 #[test]
3378 fn test_transcendental_argument_must_be_at_scale_one() {
3379 let env = env_of(&[
3380 ("c", "ppm"),
3381 ("p", "percent"),
3382 ("n", "1"),
3383 ("theta", "rad"),
3384 ("lat", "deg"),
3385 ("solid", "sr"),
3386 ]);
3387 let errors = |e: &Expr| {
3388 check_expression_dimensions(e, None, &env)
3389 .iter()
3390 .filter(|f| f.is_error())
3391 .map(|f| f.message.clone())
3392 .collect::<Vec<_>>()
3393 };
3394
3395 for name in [
3398 "log", "ln", "log10", "exp", "sinh", "cosh", "tanh", "asinh", "acosh", "atanh", "asin",
3399 "acos", "atan",
3400 ] {
3401 let found = errors(&op(name, vec![Expr::Variable("c".into())]));
3402 assert_eq!(found.len(), 1, "{name}(c [ppm]) must be refused");
3403 assert!(
3404 found[0].contains("dimensionless at scale 1")
3405 && found[0].contains("(ppm)")
3406 && found[0].contains("divide by 1 ppm"),
3407 "{name}: the diagnostic must name the repair, got {}",
3408 found[0]
3409 );
3410 assert!(
3411 errors(&op(name, vec![Expr::Variable("n".into())])).is_empty(),
3412 "{name}(n [1]) is a pure number and stays accepted"
3413 );
3414 }
3415 assert!(
3416 errors(&op("exp", vec![Expr::Variable("p".into())]))[0].contains("divide by 1 percent"),
3417 "the percent spelling is named too"
3418 );
3419
3420 for name in ["sin", "cos", "tan"] {
3423 assert!(
3424 errors(&op(name, vec![Expr::Variable("lat".into())])).is_empty(),
3425 "{name}(lat [deg]) is a conforming document"
3426 );
3427 assert!(
3428 errors(&op(name, vec![Expr::Variable("theta".into())])).is_empty(),
3429 "{name}(theta [rad]) is a conforming document"
3430 );
3431 assert_eq!(
3432 errors(&op(name, vec![Expr::Variable("p".into())])).len(),
3433 1,
3434 "{name}(p [percent]) leaves the reading unstated"
3435 );
3436 assert_eq!(
3439 errors(&op(name, vec![Expr::Variable("solid".into())])).len(),
3440 1,
3441 "{name}(solid [sr]) is not a plane angle"
3442 );
3443 }
3444 }
3445
3446 #[test]
3449 fn test_scaled_dimensionless_repair_is_accepted() {
3450 let env = env_of(&[("c", "ppm"), ("one_ppm", "ppm")]);
3451 let quotient = op(
3452 "/",
3453 vec![Expr::Variable("c".into()), Expr::Variable("one_ppm".into())],
3454 );
3455 assert!(
3456 !check_expression_dimensions(&op("log", vec![quotient]), None, &env)
3457 .iter()
3458 .any(UnitFinding::is_error),
3459 "log(c / one_ppm) states the reading and must be accepted"
3460 );
3461 }
3462
3463 #[test]
3466 fn test_angle_normalization_factor() {
3467 assert!(angle_normalization_factor(&parse_unit("rad").unwrap()).is_none());
3468 assert!(angle_normalization_factor(&parse_unit("1").unwrap()).is_none());
3469 assert!(angle_normalization_factor(&parse_unit("ppm").unwrap()).is_none());
3470 assert!(angle_normalization_factor(&parse_unit("m").unwrap()).is_none());
3471 assert!(angle_normalization_factor(&parse_unit("sr").unwrap()).is_none());
3472 let deg = angle_normalization_factor(&parse_unit("deg").unwrap()).unwrap();
3473 assert_eq!(deg, std::f64::consts::PI / 180.0);
3474 assert_eq!((90.0 * deg).sin(), 1.0);
3476 }
3477
3478 #[test]
3481 fn test_normalize_angle_arguments_rewrite() {
3482 let env = env_of(&[("lat", "deg"), ("theta", "rad")]);
3483 let untouched = op("sin", vec![Expr::Variable("theta".into())]);
3484 assert!(
3485 normalize_angle_arguments(&untouched, &env).is_none(),
3486 "a `rad` argument is at scale 1 and must not be rewritten"
3487 );
3488
3489 let nested = op(
3490 "+",
3491 vec![
3492 Expr::Number(1.0),
3493 op("cos", vec![Expr::Variable("lat".into())]),
3494 ],
3495 );
3496 let rewritten =
3497 normalize_angle_arguments(&nested, &env).expect("the `deg` cos is rewritten");
3498 let Expr::Operator(sum) = &rewritten else {
3499 panic!("expected the sum back")
3500 };
3501 let Expr::Operator(cos) = &sum.args[1] else {
3502 panic!("expected the cos back")
3503 };
3504 let Expr::Operator(product) = &cos.args[0] else {
3505 panic!("expected the argument to be a product")
3506 };
3507 assert_eq!(product.op, "*");
3508 assert_eq!(product.args[0], Expr::Variable("lat".into()));
3509 assert_eq!(
3510 product.args[1],
3511 Expr::Number(std::f64::consts::PI / 180.0),
3512 "the factor is the declared scale, applied ONCE"
3513 );
3514 }
3515
3516 #[test]
3520 fn test_normalize_angle_arguments_reaches_every_child() {
3521 let env = env_of(&[("lat", "deg")]);
3522 let sin = || op("sin", vec![Expr::Variable("lat".into())]);
3523 let folded = |e: &Expr| {
3524 let Expr::Operator(n) = e else { return false };
3525 n.op == "sin"
3526 && matches!(&n.args[0], Expr::Operator(p) if p.op == "*"
3527 && p.args[1] == Expr::Number(std::f64::consts::PI / 180.0))
3528 };
3529 let nodes = [
3530 ExpressionNode {
3531 op: "makearray".into(),
3532 values: Some(vec![sin()]),
3533 ..Default::default()
3534 },
3535 ExpressionNode {
3536 op: "table_lookup".into(),
3537 axes: Some([("x".to_string(), sin())].into_iter().collect()),
3538 ..Default::default()
3539 },
3540 ExpressionNode {
3541 op: "faq".into(),
3542 filter: Some(Box::new(sin())),
3543 key: Some(Box::new(sin())),
3544 ..Default::default()
3545 },
3546 ];
3547 for node in nodes {
3548 let op_name = node.op.clone();
3549 let rewritten = normalize_angle_arguments(&Expr::operator(node), &env)
3550 .unwrap_or_else(|| panic!("{op_name}: the nested `deg` sin was not reached"));
3551 let Expr::Operator(out) = &rewritten else {
3552 panic!("{op_name}: expected the node back")
3553 };
3554 let mut children = 0;
3555 out.for_each_child(&mut |c| {
3556 assert!(folded(c), "{op_name}: {c:?} was not folded");
3557 children += 1;
3558 });
3559 assert!(children > 0, "{op_name}");
3560 }
3561 }
3562
3563 #[test]
3571 fn test_normalize_angle_arguments_is_idempotent() {
3572 let env = env_of(&[("lat", "deg")]);
3573 let once = normalize_angle_arguments(&op("sin", vec![Expr::Variable("lat".into())]), &env)
3574 .expect("the `deg` argument is folded on the first pass");
3575 assert!(
3576 normalize_angle_arguments(&once, &env).is_none(),
3577 "a second pass must find nothing to rewrite, got {once:?}"
3578 );
3579 }
3580
3581 #[test]
3586 fn test_derivative_time_exponent_is_free() {
3587 let env = env_of(&[("x", "m"), ("accel", "m/s^2"), ("mass", "kg")]);
3588
3589 let eq = Equation {
3590 comment: None,
3591 lhs: op_with_wrt("D", vec![Expr::Variable("x".into())], "t"),
3592 rhs: Expr::Variable("accel".into()),
3593 };
3594 assert!(
3595 !check_equation_dimensions(&eq, &env)
3596 .iter()
3597 .any(UnitFinding::is_error),
3598 "ratio m/(m/s^2) = s^2 is a power of time ⇒ reconcilable"
3599 );
3600
3601 let eq = Equation {
3602 comment: None,
3603 lhs: op_with_wrt("D", vec![Expr::Variable("x".into())], "t"),
3604 rhs: Expr::Variable("mass".into()),
3605 };
3606 assert!(
3607 check_equation_dimensions(&eq, &env)
3608 .iter()
3609 .any(UnitFinding::is_error),
3610 "ratio m/kg is not a power of time ⇒ provable mismatch"
3611 );
3612 }
3613
3614 #[test]
3618 fn every_registry_scale_has_a_matching_exact_form() {
3619 for (name, unit) in build_base_units() {
3620 let exact = unit.exact_scale().to_f64();
3621 assert!(
3622 (exact - unit.scale()).abs() <= 1e-12 * unit.scale().abs(),
3623 "{name}: exact scale {exact} disagrees with the float {}",
3624 unit.scale()
3625 );
3626 }
3627 }
3628
3629 #[test]
3632 fn reconciled_registry_scales_are_exact() {
3633 let exact = |s: &str| parse_unit(s).unwrap().exact_scale().ratio_string().unwrap();
3634 assert_eq!(exact("Torr"), "20265/152");
3635 assert_eq!(exact("psi"), "8896443230521/1290320000");
3636 assert_eq!(exact("degF"), "5/9");
3637 assert_eq!(exact("deg"), "1/180*pi");
3638 assert_eq!(exact("mi"), "201168/125");
3639 assert_eq!(exact("hp"), "37284993579113511/50000000000000");
3640 assert_eq!(exact("DU"), "268670000000000000000");
3641 assert_eq!(parse_unit("psi").unwrap().scale(), 6_894.757_293_168_361);
3642 assert_eq!(parse_unit("Torr").unwrap().scale(), 101_325.0 / 760.0);
3643 assert_eq!(parse_unit("degF").unwrap().scale(), 5.0 / 9.0);
3644 }
3645
3646 #[test]
3647 fn adding_metres_to_kilometres_is_a_scale_mismatch() {
3648 let env = env_of(&[("x", "m"), ("y", "km"), ("z", "m")]);
3649 let var = |n: &str| Expr::Variable(n.into());
3650 let findings = check_expression_dimensions(&op("+", vec![var("x"), var("y")]), None, &env);
3651 assert!(
3652 findings
3653 .iter()
3654 .any(|f| f.is_error() && f.message.contains("scale")),
3655 "m + km must be a scale mismatch: {findings:?}"
3656 );
3657 let findings = check_expression_dimensions(&op("+", vec![var("x"), var("z")]), None, &env);
3658 assert!(!findings.iter().any(UnitFinding::is_error), "{findings:?}");
3659 }
3660
3661 #[test]
3662 fn an_equation_between_m_per_s_and_mi_per_h_is_a_scale_mismatch() {
3663 let env = env_of(&[
3664 ("speed_ms", "m/s"),
3665 ("speed_mph", "mi/h"),
3666 ("ms_per_mph", "m*h/(mi*s)"),
3667 ]);
3668 let var = |n: &str| Expr::Variable(n.into());
3669 let bare = Equation {
3670 comment: None,
3671 lhs: var("speed_ms"),
3672 rhs: var("speed_mph"),
3673 };
3674 assert!(
3675 check_equation_dimensions(&bare, &env)
3676 .iter()
3677 .any(UnitFinding::is_error),
3678 "m/s = mi/h agrees in dimension and not in scale"
3679 );
3680 let converted = Equation {
3681 comment: None,
3682 lhs: var("speed_ms"),
3683 rhs: op("*", vec![var("speed_mph"), var("ms_per_mph")]),
3684 };
3685 assert!(
3686 !check_equation_dimensions(&converted, &env)
3687 .iter()
3688 .any(UnitFinding::is_error),
3689 "mi/h times m*h/(mi*s) is exactly m/s"
3690 );
3691 }
3692
3693 #[test]
3694 fn ifelse_branches_must_agree_in_scale() {
3695 let env = env_of(&[("flag", "1"), ("x", "m"), ("y", "km")]);
3696 let var = |n: &str| Expr::Variable(n.into());
3697 let e = op("ifelse", vec![var("flag"), var("x"), var("y")]);
3698 assert!(
3699 check_expression_dimensions(&e, None, &env)
3700 .iter()
3701 .any(UnitFinding::is_error)
3702 );
3703 }
3704
3705 #[test]
3706 fn an_observed_expression_must_agree_with_its_declared_scale() {
3707 let env = env_of(&[("y", "km")]);
3708 let declared = parse_unit("m").unwrap();
3709 assert!(
3710 check_expression_dimensions(&Expr::Variable("y".into()), Some(&declared), &env)
3711 .iter()
3712 .any(UnitFinding::is_error)
3713 );
3714 }
3715
3716 #[test]
3720 fn a_const_with_declared_units_has_that_unit() {
3721 let konst = |units: Option<&str>| {
3722 Expr::Operator(std::sync::Arc::new(ExpressionNode {
3723 op: "const".into(),
3724 value: Some(serde_json::json!(0.44704)),
3725 units: units.map(str::to_string),
3726 ..ExpressionNode::default()
3727 }))
3728 };
3729 let env = env_of(&[
3730 ("speed_mph", "mi/h"),
3731 ("speed_ms", "m/s"),
3732 ("speed_kg", "kg"),
3733 ]);
3734 let rhs = |units| op("*", vec![Expr::Variable("speed_mph".into()), konst(units)]);
3735 let eq = |lhs: &str, units| Equation {
3736 comment: None,
3737 lhs: Expr::Variable(lhs.into()),
3738 rhs: rhs(units),
3739 };
3740 assert!(
3741 !check_equation_dimensions(&eq("speed_ms", Some("m*h/(mi*s)")), &env)
3742 .iter()
3743 .any(UnitFinding::is_error),
3744 "mi/h times a const declared m*h/(mi*s) is exactly m/s"
3745 );
3746 assert!(
3747 check_equation_dimensions(&eq("speed_kg", Some("m*h/(mi*s)")), &env)
3748 .iter()
3749 .any(UnitFinding::is_error),
3750 "declared kg against m/s is a provable mismatch"
3751 );
3752 assert!(
3753 !check_equation_dimensions(&eq("speed_kg", None), &env)
3754 .iter()
3755 .any(UnitFinding::is_error),
3756 "a const without units is undeterminable, so nothing is checked"
3757 );
3758 assert_eq!(
3759 unresolvable_const_units(&rhs(Some("mph"))),
3760 vec!["mph".to_string()]
3761 );
3762 assert!(unresolvable_const_units(&rhs(Some("m*h/(mi*s)"))).is_empty());
3763 }
3764
3765 #[test]
3766 fn const_units_are_gated_at_esm_1_2_0() {
3767 let doc = |esm: &str| {
3768 serde_json::json!({
3769 "esm": esm,
3770 "models": {"M": {"equations": [{"lhs": "x", "rhs":
3771 {"op": "const", "args": [], "value": 1.0, "units": "m"}}]}}
3772 })
3773 };
3774 let err = reject_const_units_pre_v12(&doc("1.1.0")).expect_err("1.1.0 must be rejected");
3775 assert!(
3776 err.to_string().contains("const_units_version_too_old"),
3777 "{err}"
3778 );
3779 assert!(
3780 err.to_string().contains("/models/M/equations/0/rhs"),
3781 "{err}"
3782 );
3783 assert!(reject_const_units_pre_v12(&doc("1.2.0")).is_ok());
3784 }
3785
3786 #[test]
3787 fn scaled_powers_and_roots_stay_exact() {
3788 assert_eq!(
3789 parse_unit("km^2")
3790 .unwrap()
3791 .power_rational(Rational::new(1, 2))
3792 .exact_scale(),
3793 parse_unit("km").unwrap().exact_scale()
3794 );
3795 let env = env_of(&[("p", "%"), ("q", "1")]);
3796 let eq = Equation {
3797 comment: None,
3798 lhs: Expr::Variable("q".into()),
3799 rhs: op("^", vec![Expr::Variable("p".into()), Expr::Integer(2)]),
3800 };
3801 assert!(
3802 check_equation_dimensions(&eq, &env)
3803 .iter()
3804 .any(UnitFinding::is_error),
3805 "a percentage squared has scale 1/10000, not 1"
3806 );
3807 }
3808
3809 #[test]
3810 fn test_parse_base_units() {
3811 assert_eq!(
3812 parse_unit("m").unwrap().dimensions.get(&Dimension::Length),
3813 Some(&Rational::int(1))
3814 );
3815 assert_eq!(
3816 parse_unit("s").unwrap().dimensions.get(&Dimension::Time),
3817 Some(&Rational::int(1))
3818 );
3819 assert_eq!(
3820 parse_unit("kg").unwrap().dimensions.get(&Dimension::Mass),
3821 Some(&Rational::int(1))
3822 );
3823 }
3824
3825 #[test]
3826 fn test_parse_compound_units() {
3827 let u = parse_unit("m/s").unwrap();
3828 assert_eq!(
3829 u.dimensions.get(&Dimension::Length),
3830 Some(&Rational::int(1))
3831 );
3832 assert_eq!(u.dimensions.get(&Dimension::Time), Some(&Rational::int(-1)));
3833
3834 let u = parse_unit("mol/L").unwrap();
3835 assert_eq!(
3836 u.dimensions.get(&Dimension::Amount),
3837 Some(&Rational::int(1))
3838 );
3839 assert_eq!(
3840 u.dimensions.get(&Dimension::Length),
3841 Some(&Rational::int(-3))
3842 );
3843 }
3844
3845 #[test]
3846 fn test_parse_parenthesised_units() {
3847 let u = parse_unit("J/(mol*K)").unwrap();
3849 assert_eq!(u.dimensions.get(&Dimension::Mass), Some(&Rational::int(1)));
3850 assert_eq!(
3851 u.dimensions.get(&Dimension::Length),
3852 Some(&Rational::int(2))
3853 );
3854 assert_eq!(u.dimensions.get(&Dimension::Time), Some(&Rational::int(-2)));
3855 assert_eq!(
3856 u.dimensions.get(&Dimension::Amount),
3857 Some(&Rational::int(-1))
3858 );
3859 assert_eq!(
3860 u.dimensions.get(&Dimension::Temperature),
3861 Some(&Rational::int(-1))
3862 );
3863 }
3864
3865 #[test]
3866 fn test_parse_pascal_and_kg_per_m3() {
3867 let pa = parse_unit("Pa").unwrap();
3868 assert_eq!(pa.dimensions.get(&Dimension::Mass), Some(&Rational::int(1)));
3869 assert_eq!(
3870 pa.dimensions.get(&Dimension::Length),
3871 Some(&Rational::int(-1))
3872 );
3873 assert_eq!(
3874 pa.dimensions.get(&Dimension::Time),
3875 Some(&Rational::int(-2))
3876 );
3877
3878 let rho = parse_unit("kg/m^3").unwrap();
3879 assert_eq!(
3880 rho.dimensions.get(&Dimension::Mass),
3881 Some(&Rational::int(1))
3882 );
3883 assert_eq!(
3884 rho.dimensions.get(&Dimension::Length),
3885 Some(&Rational::int(-3))
3886 );
3887 }
3888
3889 #[test]
3890 fn test_parse_negative_power() {
3891 let inv_s = parse_unit("s^-1").unwrap();
3892 assert_eq!(
3893 inv_s.dimensions.get(&Dimension::Time),
3894 Some(&Rational::int(-1))
3895 );
3896 }
3897
3898 #[test]
3899 fn test_division_is_left_associative() {
3900 let u = parse_unit("W/m^2/K").unwrap();
3905 assert_eq!(u.dimensions.get(&Dimension::Mass), Some(&Rational::int(1)));
3906 assert_eq!(u.dimensions.get(&Dimension::Time), Some(&Rational::int(-3)));
3907 assert_eq!(
3908 u.dimensions.get(&Dimension::Temperature),
3909 Some(&Rational::int(-1))
3910 );
3911 assert_eq!(u.dimensions.get(&Dimension::Length), None);
3913 }
3914
3915 #[test]
3916 fn test_find_last_top_level_operator() {
3917 assert_eq!(find_last_top_level_operator("a/b/c"), Some((3, 1, '/')));
3920 assert_eq!(find_last_top_level_operator("a*b*c"), Some((3, 1, '*')));
3921 assert_eq!(find_last_top_level_operator("a/b*c"), Some((3, 1, '*')));
3924 assert_eq!(find_last_top_level_operator("a/(b/c)"), Some((1, 1, '/')));
3926 assert_eq!(find_last_top_level_operator("kg"), None);
3927 }
3928
3929 #[test]
3934 fn test_mixed_product_quotient_is_left_associative() {
3935 let u = parse_unit("J/mol*K").unwrap();
3936 assert_eq!(
3938 u.dimensions.get(&Dimension::Temperature),
3939 Some(&Rational::int(1))
3940 );
3941 assert_eq!(
3942 u.dimensions.get(&Dimension::Amount),
3943 Some(&Rational::int(-1))
3944 );
3945
3946 let u = parse_unit("kg/m^3*s").unwrap();
3947 assert_eq!(u.dimensions.get(&Dimension::Time), Some(&Rational::int(1)));
3948 assert_eq!(
3949 u.dimensions.get(&Dimension::Length),
3950 Some(&Rational::int(-3))
3951 );
3952
3953 let u = parse_unit("m/s/s").unwrap();
3955 assert_eq!(u.dimensions.get(&Dimension::Time), Some(&Rational::int(-2)));
3956 assert_eq!(
3957 u.dimensions.get(&Dimension::Length),
3958 Some(&Rational::int(1))
3959 );
3960
3961 let u = parse_unit("J/(mol*K)").unwrap();
3963 assert_eq!(
3964 u.dimensions.get(&Dimension::Temperature),
3965 Some(&Rational::int(-1))
3966 );
3967 assert_eq!(
3968 u.dimensions.get(&Dimension::Amount),
3969 Some(&Rational::int(-1))
3970 );
3971 }
3972
3973 #[test]
3976 fn test_dangling_operator_is_rejected() {
3977 assert!(matches!(parse_unit("m/"), Err(UnitError::ParseError(_))));
3978 assert!(matches!(parse_unit("/s"), Err(UnitError::ParseError(_))));
3979 assert!(matches!(parse_unit("kg*"), Err(UnitError::ParseError(_))));
3980 }
3981
3982 #[test]
3983 fn test_parse_mixed_product_quotient() {
3984 let u = parse_unit("kg*m/s^2").unwrap();
3986 assert_eq!(u.dimensions.get(&Dimension::Mass), Some(&Rational::int(1)));
3987 assert_eq!(
3988 u.dimensions.get(&Dimension::Length),
3989 Some(&Rational::int(1))
3990 );
3991 assert_eq!(u.dimensions.get(&Dimension::Time), Some(&Rational::int(-2)));
3992 }
3993
3994 #[test]
3995 fn test_unit_compatibility() {
3996 let m = parse_unit("m").unwrap();
3997 let cm = parse_unit("cm").unwrap();
3998 let s = parse_unit("s").unwrap();
3999 assert!(m.is_compatible(&cm));
4000 assert!(!m.is_compatible(&s));
4001 }
4002
4003 #[test]
4004 fn test_unit_arithmetic() {
4005 let m = parse_unit("m").unwrap();
4006 let s = parse_unit("s").unwrap();
4007 let v = m.divide(&s);
4008 assert_eq!(
4009 v.dimensions.get(&Dimension::Length),
4010 Some(&Rational::int(1))
4011 );
4012 assert_eq!(v.dimensions.get(&Dimension::Time), Some(&Rational::int(-1)));
4013 assert_eq!(
4014 m.power(2).dimensions.get(&Dimension::Length),
4015 Some(&Rational::int(2))
4016 );
4017 }
4018
4019 #[test]
4020 fn test_dimensional_consistency() {
4021 let m = parse_unit("m").unwrap();
4022 let cm = parse_unit("cm").unwrap();
4023 let s = parse_unit("s").unwrap();
4024 assert!(check_dimensional_consistency(&m, &cm).is_ok());
4025 assert!(check_dimensional_consistency(&m, &s).is_err());
4026 }
4027
4028 #[test]
4029 fn test_unit_conversion() {
4030 let m = parse_unit("m").unwrap();
4031 let cm = parse_unit("cm").unwrap();
4032 assert!((convert_units(1.0, &m, &cm).unwrap() - 100.0).abs() < 1e-10);
4033 assert!((convert_units(100.0, &cm, &m).unwrap() - 1.0).abs() < 1e-10);
4034 }
4035
4036 #[test]
4037 fn test_unknown_unit() {
4038 let result = parse_unit("unknown_unit");
4039 assert!(matches!(result, Err(UnitError::UnknownUnit(_))));
4040 }
4041
4042 #[test]
4045 fn propagate_literal_is_indeterminate() {
4046 let env = HashMap::new();
4051 assert!(matches!(
4052 Unit::propagate(&Expr::Number(2.5), &env),
4053 Err(UnitError::UnknownUnit(_))
4054 ));
4055
4056 let env = env_of(&[("T", "K")]);
4059 let expr = op("-", vec![Expr::Variable("T".into()), Expr::Number(273.15)]);
4060 let u = Unit::propagate(&expr, &env).unwrap();
4061 assert_eq!(
4062 u.dimensions.get(&Dimension::Temperature),
4063 Some(&Rational::int(1))
4064 );
4065 assert!(check_expression_dimensions(&expr, None, &env).is_empty());
4066
4067 let literal_pairs = [
4071 vec![Expr::Number(1.0), Expr::Number(2.0)],
4072 vec![Expr::Integer(1), Expr::Integer(2)],
4073 ];
4074 for args in literal_pairs {
4075 for name in ["+", "-", "min", "max"] {
4076 assert!(
4077 matches!(
4078 Unit::propagate(&op(name, args.clone()), &HashMap::new()),
4079 Err(UnitError::UnknownUnit(_))
4080 ),
4081 "{name} over literals must be undeterminable"
4082 );
4083 }
4084 }
4085 for lit in [Expr::Integer(2), Expr::Number(2.5)] {
4086 assert!(matches!(
4087 Unit::propagate(&op("+", vec![lit]), &HashMap::new()),
4088 Err(UnitError::UnknownUnit(_))
4089 ));
4090 }
4091 }
4092
4093 #[test]
4097 fn propagate_sum_with_undeterminable_operand_takes_known_unit() {
4098 let env = env_of(&[("x", "m"), ("y", "m")]);
4099 let half_y = op("*", vec![Expr::Number(0.5), Expr::Variable("y".into())]);
4100 for name in ["+", "-", "min", "max"] {
4101 let expr = op(name, vec![Expr::Variable("x".into()), half_y.clone()]);
4102 let u = Unit::propagate(&expr, &env).unwrap();
4103 assert_eq!(
4104 u.dimensions.get(&Dimension::Length),
4105 Some(&Rational::int(1)),
4106 "{name}"
4107 );
4108 }
4109 let plus_x = op("+", vec![Expr::Variable("x".into())]);
4110 let u = Unit::propagate(&plus_x, &env).unwrap();
4111 assert_eq!(
4112 u.dimensions.get(&Dimension::Length),
4113 Some(&Rational::int(1))
4114 );
4115 }
4116
4117 #[test]
4121 fn ifelse_takes_the_unit_of_its_determinable_branch() {
4122 let env = env_of(&[("x", "m"), ("y", "m"), ("c", "1")]);
4123 let cond = op(">", vec![Expr::Variable("c".into()), Expr::Integer(0)]);
4124 let x = Expr::Variable("x".into());
4125 let half_y = op("*", vec![Expr::Number(0.5), Expr::Variable("y".into())]);
4126 let branch_pairs = [
4127 (x.clone(), half_y.clone()),
4128 (half_y.clone(), x.clone()),
4129 (Expr::Integer(2), x.clone()),
4130 ];
4131 for (then_branch, else_branch) in branch_pairs {
4132 let expr = op("ifelse", vec![cond.clone(), then_branch, else_branch]);
4133 let u = Unit::propagate(&expr, &env).unwrap();
4134 assert_eq!(
4135 u.dimensions.get(&Dimension::Length),
4136 Some(&Rational::int(1))
4137 );
4138 }
4139 let literal_branches = op("ifelse", vec![cond, Expr::Integer(1), Expr::Number(2.0)]);
4140 assert!(matches!(
4141 Unit::propagate(&literal_branches, &env),
4142 Err(UnitError::UnknownUnit(_))
4143 ));
4144 }
4145
4146 #[test]
4149 fn connectives_report_a_mismatch_inside_their_operands() {
4150 let env = env_of(&[("x", "m"), ("z", "kg"), ("c", "1")]);
4151 let mismatch = op(
4152 ">",
4153 vec![Expr::Variable("x".into()), Expr::Variable("z".into())],
4154 );
4155 let ok = op(">", vec![Expr::Variable("c".into()), Expr::Integer(0)]);
4156 for expr in [
4157 op("not", vec![mismatch.clone()]),
4158 op("and", vec![mismatch.clone(), ok.clone()]),
4159 op("or", vec![ok.clone(), mismatch.clone()]),
4160 ] {
4161 assert!(
4162 !check_expression_dimensions(&expr, None, &env).is_empty(),
4163 "{expr:?}"
4164 );
4165 }
4166 let dimensional_operands = op(
4167 "and",
4168 vec![Expr::Variable("x".into()), Expr::Variable("z".into())],
4169 );
4170 assert!(check_expression_dimensions(&dimensional_operands, None, &env).is_empty());
4171 }
4172
4173 #[test]
4176 fn propagate_literal_exponent_is_read_by_value() {
4177 let env = env_of(&[("L", "m")]);
4178 let expr = op("^", vec![Expr::Variable("L".into()), Expr::Integer(2)]);
4179 let u = Unit::propagate(&expr, &env).unwrap();
4180 assert_eq!(
4181 u.dimensions.get(&Dimension::Length),
4182 Some(&Rational::int(2))
4183 );
4184 }
4185
4186 #[test]
4191 fn propagate_huge_integer_exponent_does_not_wrap() {
4192 let env = env_of(&[("L", "m")]);
4193 let exp = i64::from(i32::MAX) + 6;
4194 let expr = op("^", vec![Expr::Variable("L".into()), Expr::Integer(exp)]);
4195 let err = Unit::propagate(&expr, &env).unwrap_err();
4196 match err {
4197 UnitError::UnknownUnit(msg) => assert!(
4198 msg.contains(&exp.to_string()),
4199 "finding must name the untruncated exponent, got: {msg}"
4200 ),
4201 other => panic!("expected UnknownUnit, got {other:?}"),
4202 }
4203 }
4204
4205 #[test]
4206 fn propagate_variable_lookup() {
4207 let env = env_of(&[("v", "m/s")]);
4208 let u = Unit::propagate(&Expr::Variable("v".into()), &env).unwrap();
4209 assert_eq!(
4210 u.dimensions.get(&Dimension::Length),
4211 Some(&Rational::int(1))
4212 );
4213 assert_eq!(u.dimensions.get(&Dimension::Time), Some(&Rational::int(-1)));
4214 }
4215
4216 #[test]
4217 fn propagate_time_variable_default() {
4218 let env = HashMap::new();
4219 let u = Unit::propagate(&Expr::Variable("t".into()), &env).unwrap();
4220 assert_eq!(u.dimensions.get(&Dimension::Time), Some(&Rational::int(1)));
4221 }
4222
4223 #[test]
4224 fn propagate_unknown_variable_errors() {
4225 let env = HashMap::new();
4226 let err = Unit::propagate(&Expr::Variable("nope".into()), &env).unwrap_err();
4227 assert!(matches!(err, UnitError::UnknownUnit(_)));
4228 }
4229
4230 #[test]
4231 fn propagate_addition_matches() {
4232 let env = env_of(&[("h1", "m"), ("h2", "m")]);
4235 let e = op(
4236 "+",
4237 vec![Expr::Variable("h1".into()), Expr::Variable("h2".into())],
4238 );
4239 let u = Unit::propagate(&e, &env).unwrap();
4240 assert_eq!(
4241 u.dimensions.get(&Dimension::Length),
4242 Some(&Rational::int(1))
4243 );
4244 }
4245
4246 #[test]
4247 fn propagate_addition_mismatch_errors() {
4248 let env = env_of(&[("h", "m"), ("t", "s")]);
4249 let e = op(
4250 "+",
4251 vec![Expr::Variable("h".into()), Expr::Variable("t".into())],
4252 );
4253 let err = Unit::propagate(&e, &env).unwrap_err();
4254 assert!(matches!(err, UnitError::DimensionMismatch(_)));
4255 }
4256
4257 #[test]
4258 fn propagate_multiplication_combines_dims() {
4259 let env = env_of(&[("rho", "kg/m^3"), ("v", "m/s")]);
4260 let e = op(
4261 "*",
4262 vec![Expr::Variable("rho".into()), Expr::Variable("v".into())],
4263 );
4264 let u = Unit::propagate(&e, &env).unwrap();
4265 assert_eq!(u.dimensions.get(&Dimension::Mass), Some(&Rational::int(1)));
4266 assert_eq!(
4267 u.dimensions.get(&Dimension::Length),
4268 Some(&Rational::int(-2))
4269 );
4270 assert_eq!(u.dimensions.get(&Dimension::Time), Some(&Rational::int(-1)));
4271 }
4272
4273 #[test]
4274 fn propagate_division_subtracts_dims() {
4275 let env = env_of(&[("m1", "kg"), ("V", "m^3")]);
4276 let e = op(
4277 "/",
4278 vec![Expr::Variable("m1".into()), Expr::Variable("V".into())],
4279 );
4280 let u = Unit::propagate(&e, &env).unwrap();
4281 assert_eq!(u.dimensions.get(&Dimension::Mass), Some(&Rational::int(1)));
4282 assert_eq!(
4283 u.dimensions.get(&Dimension::Length),
4284 Some(&Rational::int(-3))
4285 );
4286 }
4287
4288 #[test]
4289 fn propagate_power_integer() {
4290 let env = env_of(&[("s", "m")]);
4291 let e = op("^", vec![Expr::Variable("s".into()), Expr::Number(3.0)]);
4292 let u = Unit::propagate(&e, &env).unwrap();
4293 assert_eq!(
4294 u.dimensions.get(&Dimension::Length),
4295 Some(&Rational::int(3))
4296 );
4297 }
4298
4299 #[test]
4300 fn propagate_power_rejects_dimensional_exponent() {
4301 let env = env_of(&[("h", "m"), ("t", "s")]);
4303 let e = op(
4304 "^",
4305 vec![Expr::Variable("h".into()), Expr::Variable("t".into())],
4306 );
4307 let err = Unit::propagate(&e, &env).unwrap_err();
4308 assert!(matches!(err, UnitError::DimensionMismatch(_)));
4309 }
4310
4311 #[test]
4312 fn propagate_derivative_divides_by_wrt() {
4313 let env = env_of(&[("h", "m"), ("v", "m/s"), ("t", "s")]);
4318 let dh = op_with_wrt("D", vec![Expr::Variable("h".into())], "t");
4319 let dh_dim = Unit::propagate(&dh, &env).unwrap();
4320 let v_dim = Unit::propagate(&Expr::Variable("v".into()), &env).unwrap();
4321 assert!(dh_dim.is_compatible(&v_dim));
4322 }
4323
4324 #[test]
4327 fn propagate_derivative_of_undeclared_time_is_unknown() {
4328 let env = env_of(&[("h", "m")]);
4329 let dh = op_with_wrt("D", vec![Expr::Variable("h".into())], "t");
4330 assert!(matches!(
4331 Unit::propagate(&dh, &env),
4332 Err(UnitError::UnknownUnit(_))
4333 ));
4334 }
4335
4336 #[test]
4337 fn propagate_exp_requires_dimensionless_arg() {
4338 let env = env_of(&[("h", "m")]);
4339 let e = op("exp", vec![Expr::Variable("h".into())]);
4340 let err = Unit::propagate(&e, &env).unwrap_err();
4341 assert!(matches!(err, UnitError::DimensionMismatch(_)));
4342 }
4343
4344 #[test]
4345 fn propagate_sqrt_halves_even_powers() {
4346 let env = env_of(&[("a", "m^2")]);
4347 let e = op("sqrt", vec![Expr::Variable("a".into())]);
4348 let u = Unit::propagate(&e, &env).unwrap();
4349 assert_eq!(
4350 u.dimensions.get(&Dimension::Length),
4351 Some(&Rational::int(1))
4352 );
4353 }
4354
4355 #[test]
4356 fn propagate_ifelse_branches_must_match() {
4357 let env = env_of(&[("h", "m"), ("x", "s")]);
4358 let e = op(
4359 "ifelse",
4360 vec![
4361 Expr::Number(1.0),
4362 Expr::Variable("h".into()),
4363 Expr::Variable("x".into()),
4364 ],
4365 );
4366 assert!(matches!(
4367 Unit::propagate(&e, &env).unwrap_err(),
4368 UnitError::DimensionMismatch(_)
4369 ));
4370 }
4371
4372 #[test]
4373 fn propagate_comparison_requires_matching_dims() {
4374 let env = env_of(&[("h", "m"), ("x", "s")]);
4375 let e = op(
4376 ">",
4377 vec![Expr::Variable("h".into()), Expr::Variable("x".into())],
4378 );
4379 assert!(matches!(
4380 Unit::propagate(&e, &env).unwrap_err(),
4381 UnitError::DimensionMismatch(_)
4382 ));
4383 }
4384
4385 #[test]
4395 fn propagate_spatial_sugar_ops_are_undeterminable() {
4396 let env = env_of(&[("c", "mol/m^3")]);
4397 for op_name in ["grad", "div", "laplacian", "curl", "∇", "integral"] {
4398 let e = op(op_name, vec![Expr::Variable("c".into())]);
4399 assert!(
4402 matches!(Unit::propagate(&e, &env), Err(UnitError::UnknownUnit(_))),
4403 "{op_name} must have an undeterminable dimension"
4404 );
4405 let findings = check_expression_dimensions(&e, None, &env);
4409 assert!(
4410 findings.is_empty(),
4411 "{op_name} must emit no finding (report unknown + skip): {findings:?}"
4412 );
4413 }
4414 }
4415
4416 #[test]
4417 fn propagate_faq_body() {
4418 let env = env_of(&[("x", "m/s"), ("scale", "1")]);
4423 let body = op(
4424 "*",
4425 vec![Expr::Variable("x".into()), Expr::Variable("scale".into())],
4426 );
4427 let node = Expr::operator(ExpressionNode {
4428 op: "faq".to_string(),
4429 args: vec![],
4430 expr: Some(Box::new(body)),
4431 ..ExpressionNode::default()
4432 });
4433 let u = Unit::propagate(&node, &env).unwrap();
4434 assert_eq!(
4435 u.dimensions.get(&Dimension::Length),
4436 Some(&Rational::int(1))
4437 );
4438 assert_eq!(u.dimensions.get(&Dimension::Time), Some(&Rational::int(-1)));
4439 }
4440
4441 #[test]
4442 fn propagate_index_inherits_from_source() {
4443 let env = env_of(&[("arr", "kg")]);
4444 let node = op(
4445 "index",
4446 vec![Expr::Variable("arr".into()), Expr::Number(0.0)],
4447 );
4448 let u = Unit::propagate(&node, &env).unwrap();
4449 assert_eq!(u.dimensions.get(&Dimension::Mass), Some(&Rational::int(1)));
4450 }
4451
4452 #[test]
4453 fn propagate_broadcast_dispatches_to_fn() {
4454 let env = env_of(&[("a", "m"), ("b", "m")]);
4456 let node = Expr::operator(ExpressionNode {
4457 op: "broadcast".to_string(),
4458 broadcast_fn: Some("+".to_string()),
4459 args: vec![Expr::Variable("a".into()), Expr::Variable("b".into())],
4460 ..ExpressionNode::default()
4461 });
4462 let u = Unit::propagate(&node, &env).unwrap();
4463 assert_eq!(
4464 u.dimensions.get(&Dimension::Length),
4465 Some(&Rational::int(1))
4466 );
4467 }
4468
4469 #[test]
4470 fn propagate_unknown_operator_reports_unknown_and_skips() {
4471 let env = HashMap::new();
4477 let e = op("zorp", vec![Expr::Number(1.0)]);
4478 let findings = check_expression_dimensions(&e, None, &env);
4479 assert!(findings.is_empty(), "expected no finding, got {findings:?}");
4480 assert!(matches!(
4481 Unit::propagate(&e, &env),
4482 Err(UnitError::UnknownUnit(_))
4483 ));
4484 }
4485
4486 #[test]
4490 fn undeterminable_operand_does_not_hide_a_real_mismatch() {
4491 let env = env_of(&[("len", "m"), ("mass", "kg")]);
4492 let expr = op(
4496 "+",
4497 vec![
4498 op("zorp", vec![Expr::Number(1.0)]),
4499 Expr::Variable("len".into()),
4500 Expr::Variable("mass".into()),
4501 ],
4502 );
4503 let findings = check_expression_dimensions(&expr, None, &env);
4504 assert!(
4505 findings.iter().any(UnitFinding::is_error),
4506 "the m-vs-kg mismatch must still be proven: {findings:?}"
4507 );
4508 assert!(
4510 findings.iter().all(UnitFinding::is_error),
4511 "the undeterminable op must not add a finding of its own: {findings:?}"
4512 );
4513 }
4514
4515 #[test]
4516 fn validate_equation_dimensions_passes() {
4517 let env = env_of(&[("h", "m"), ("v", "m/s"), ("t", "s")]);
4519 let eq = Equation {
4520 comment: None,
4521 lhs: op_with_wrt("D", vec![Expr::Variable("h".into())], "t"),
4522 rhs: Expr::Variable("v".into()),
4523 };
4524 validate_equation_dimensions(&eq, &env).unwrap();
4525 }
4526
4527 #[test]
4528 fn validate_equation_dimensions_detects_mismatch() {
4529 let env = env_of(&[("h", "m"), ("v", "m/s"), ("t", "s")]);
4532 let eq = Equation {
4533 comment: None,
4534 lhs: op_with_wrt("D", vec![Expr::Variable("h".into())], "t"),
4535 rhs: Expr::Variable("h".into()),
4536 };
4537 assert!(validate_equation_dimensions(&eq, &env).is_err());
4538
4539 let eq = Equation {
4541 comment: None,
4542 lhs: op_with_wrt("D", vec![Expr::Variable("h".into())], "t"),
4543 rhs: Expr::Variable("v".into()),
4544 };
4545 assert!(validate_equation_dimensions(&eq, &env).is_ok());
4546 }
4547
4548 #[test]
4552 fn derivative_time_ratio_rule() {
4553 let env = env_of(&[("h", "m"), ("v", "m/s"), ("mass", "kg")]);
4554
4555 let eq = Equation {
4557 comment: None,
4558 lhs: op_with_wrt("D", vec![Expr::Variable("h".into())], "t"),
4559 rhs: Expr::Variable("mass".into()),
4560 };
4561 let findings = check_equation_dimensions(&eq, &env);
4562 assert!(findings.iter().any(UnitFinding::is_error), "{findings:?}");
4563
4564 let eq = Equation {
4566 comment: None,
4567 lhs: op_with_wrt("D", vec![Expr::Variable("h".into())], "t"),
4568 rhs: Expr::Variable("v".into()),
4569 };
4570 assert!(
4571 !check_equation_dimensions(&eq, &env)
4572 .iter()
4573 .any(UnitFinding::is_error)
4574 );
4575
4576 let env = env_of(&[("x", "dimensionless")]);
4579 let eq = Equation {
4580 comment: None,
4581 lhs: op_with_wrt("D", vec![Expr::Variable("x".into())], "t"),
4582 rhs: op("-", vec![Expr::Variable("x".into())]),
4583 };
4584 assert!(
4585 !check_equation_dimensions(&eq, &env)
4586 .iter()
4587 .any(UnitFinding::is_error)
4588 );
4589 }
4590
4591 fn state_var_with_units(units: Option<&str>) -> crate::ModelVariable {
4594 crate::test_support::var(crate::VariableType::Unknown, units)
4595 }
4596
4597 #[test]
4598 fn build_unit_env_unparseable_unit_is_unknown_not_dimensionless() {
4599 let mut variables = indexmap::IndexMap::new();
4600 variables.insert("x".to_string(), state_var_with_units(Some("not_a_unit")));
4602 variables.insert("y".to_string(), state_var_with_units(Some("m")));
4603
4604 let (env, failures) = build_unit_env(&variables);
4605
4606 assert!(
4609 !env.contains_key("x"),
4610 "unparseable unit must not be in env"
4611 );
4612 assert!(env.contains_key("y"));
4613
4614 assert_eq!(
4618 failures,
4619 vec![UnitParseFailure {
4620 name: "x".to_string(),
4621 units: "not_a_unit".to_string(),
4622 }]
4623 );
4624
4625 let eq = Equation {
4630 comment: None,
4631 lhs: Expr::Variable("y".into()),
4632 rhs: Expr::Variable("x".into()),
4633 };
4634 let result = validate_equation_dimensions(&eq, &env);
4635 assert!(
4636 matches!(result, Err(UnitError::UnknownUnit(_))),
4637 "expected UnknownUnit (skipped), not a false mismatch, got {result:?}"
4638 );
4639 }
4640
4641 fn konst(value: f64, units: Option<&str>) -> Expr {
4642 Expr::operator(ExpressionNode {
4643 op: "const".to_string(),
4644 value: Some(serde_json::json!(value)),
4645 units: units.map(str::to_string),
4646 ..ExpressionNode::default()
4647 })
4648 }
4649
4650 #[test]
4651 fn constant_exponents_fold_to_exact_rationals() {
4652 let int = Expr::Integer;
4653 let cases = [
4654 (op("/", vec![int(3), int(2)]), Rational::new(3, 2)),
4655 (op("-", vec![int(1)]), Rational::int(-1)),
4656 (op("-", vec![int(5), int(3)]), Rational::int(2)),
4657 (
4658 op(
4659 "/",
4660 vec![op("+", vec![int(1), int(2)]), op("*", vec![int(4), int(2)])],
4661 ),
4662 Rational::new(3, 8),
4663 ),
4664 (konst(1.5, None), Rational::new(3, 2)),
4665 (konst(0.5, Some("1")), Rational::new(1, 2)),
4666 (Expr::Number(0.25), Rational::new(1, 4)),
4667 ];
4668 for (expr, expected) in cases {
4669 assert_eq!(constant_exponent(&expr), Some(expected), "{expr:?}");
4670 }
4671 }
4672
4673 #[test]
4674 fn symbolic_exponents_do_not_fold() {
4675 let int = Expr::Integer;
4676 let var = |n: &str| Expr::Variable(n.into());
4677 for expr in [
4678 var("n"),
4679 op("+", vec![var("n"), int(1)]),
4680 konst(50.0, Some("%")),
4681 konst(2.0, Some("m")),
4682 op("/", vec![int(1), int(0)]),
4683 op("/", vec![int(1), int(2), int(3)]),
4684 op("sqrt", vec![int(4)]),
4685 op(
4686 "*",
4687 vec![int(i64::from(i32::MAX)), int(i64::from(i32::MAX))],
4688 ),
4689 ] {
4690 assert_eq!(constant_exponent(&expr), None, "{expr:?}");
4691 }
4692 }
4693
4694 #[test]
4695 fn a_folded_exponent_is_checked_and_a_symbolic_one_is_skipped() {
4696 let env = env_of(&[("T", "K"), ("n", "1"), ("T_32", "K^(3/2)"), ("T_2", "K^2")]);
4697 let var = |n: &str| Expr::Variable(n.into());
4698 let three_halves = op("/", vec![Expr::Integer(3), Expr::Integer(2)]);
4699 let pow = op("^", vec![var("T"), three_halves]);
4700 let ok = check_expression_dimensions(&pow, env.get("T_32"), &env);
4701 assert!(!ok.iter().any(UnitFinding::is_error), "{ok:?}");
4702 let bad = check_expression_dimensions(&pow, env.get("T_2"), &env);
4703 assert!(bad.iter().any(UnitFinding::is_error), "{bad:?}");
4704 let sym = op("^", vec![var("T"), var("n")]);
4705 let skipped = check_expression_dimensions(&sym, env.get("T_2"), &env);
4706 assert!(!skipped.iter().any(UnitFinding::is_error), "{skipped:?}");
4707 }
4708
4709 #[test]
4710 fn an_faq_filter_is_dimension_checked() {
4711 let env = env_of(&[("x", "m"), ("y", "m"), ("z", "kg")]);
4712 let var = |n: &str| Expr::Variable(n.into());
4713 let faq = |filter: Expr| {
4714 Expr::operator(ExpressionNode {
4715 op: "faq".to_string(),
4716 filter: Some(Box::new(filter)),
4717 expr: Some(Box::new(konst(1.0, Some("1")))),
4718 ..ExpressionNode::default()
4719 })
4720 };
4721 let bad = check_expression_dimensions(&faq(op("<=", vec![var("x"), var("z")])), None, &env);
4722 assert!(bad.iter().any(UnitFinding::is_error), "{bad:?}");
4723 let ok = check_expression_dimensions(&faq(op("<=", vec![var("x"), var("y")])), None, &env);
4724 assert!(!ok.iter().any(UnitFinding::is_error), "{ok:?}");
4725 }
4726
4727 fn faq_node(body: Expr, semiring: Option<&str>, reduce: Option<&str>) -> ExpressionNode {
4728 ExpressionNode {
4729 op: "faq".to_string(),
4730 expr: Some(Box::new(body)),
4731 semiring: semiring.map(str::to_string),
4732 reduce: reduce.map(str::to_string),
4733 ..ExpressionNode::default()
4734 }
4735 }
4736
4737 #[test]
4738 fn array_operators_carry_their_element_unit() {
4739 let env = env_of(&[("v", "h"), ("w", "1"), ("a", "m"), ("b", "kg")]);
4740 let var = |n: &str| Expr::Variable(n.into());
4741 let unit = |e: &Expr| propagate_dim(e, &env, &mut Vec::new());
4742 let h = parse_unit("h").unwrap();
4743 let idx = op("index", vec![var("v"), var("i")]);
4744 assert_eq!(unit(&idx).known(), Some(&h));
4745 assert!(unit(&op("index", vec![])).known().is_none());
4746 for (sr, rd) in [
4748 (Some("sum_product"), None),
4749 (Some("max_product"), None),
4750 (Some("min_sum"), None),
4751 (None, Some("max")),
4752 (None, None),
4753 ] {
4754 let f = Expr::operator(faq_node(idx.clone(), sr, rd));
4755 assert_eq!(unit(&f).known(), Some(&h), "{sr:?} {rd:?}");
4756 }
4757 let prod = Expr::operator(faq_node(idx.clone(), None, Some("*")));
4758 assert!(unit(&prod).known().is_none());
4759 let wprod = Expr::operator(faq_node(
4760 op("index", vec![var("w"), var("i")]),
4761 None,
4762 Some("*"),
4763 ));
4764 assert!(unit(&wprod).known().is_some_and(Unit::is_pure_number));
4765 let any = Expr::operator(faq_node(idx.clone(), Some("bool_and_or"), None));
4766 assert!(unit(&any).known().is_some_and(Unit::is_dimensionless));
4767 let mut distinct = faq_node(idx.clone(), None, None);
4768 distinct.distinct = Some(true);
4769 assert!(unit(&Expr::operator(distinct)).known().is_none());
4770 let mk = |values: Vec<Expr>| {
4772 Expr::operator(ExpressionNode {
4773 op: "makearray".to_string(),
4774 values: Some(values),
4775 ..ExpressionNode::default()
4776 })
4777 };
4778 assert_eq!(
4779 unit(&mk(vec![var("v"), Expr::Number(0.0)])).known(),
4780 Some(&h)
4781 );
4782 assert!(unit(&mk(vec![])).known().is_none());
4783 let mut f = Vec::new();
4784 propagate_dim(&mk(vec![var("a"), var("b")]), &env, &mut f);
4785 assert!(f.iter().any(UnitFinding::is_error), "{f:?}");
4786 let mut f = Vec::new();
4788 propagate_dim(&op("concat", vec![var("a"), var("b")]), &env, &mut f);
4789 assert!(f.iter().any(UnitFinding::is_error), "{f:?}");
4790 }
4791
4792 #[test]
4793 fn array_operators_check_every_operand() {
4794 let env = env_of(&[("v", "h"), ("a", "m"), ("b", "kg")]);
4795 let var = |n: &str| Expr::Variable(n.into());
4796 let h = parse_unit("h").unwrap();
4797 let bad = || op("+", vec![var("a"), var("b")]);
4798 for name in ["index", "reshape", "transpose"] {
4801 let mut f = Vec::new();
4802 let dim = propagate_dim(&op(name, vec![var("v"), bad()]), &env, &mut f);
4803 assert_eq!(dim.known(), Some(&h), "{name}");
4804 assert!(f.iter().any(UnitFinding::is_error), "{name}: {f:?}");
4805 let mut f = Vec::new();
4807 propagate_dim(&op(name, vec![var("v"), var("a")]), &env, &mut f);
4808 assert!(!f.iter().any(UnitFinding::is_error), "{name}: {f:?}");
4809 }
4810 let mut fq = faq_node(var("v"), None, None);
4812 fq.args = vec![bad()];
4813 let mut mk = ExpressionNode {
4814 op: "makearray".to_string(),
4815 values: Some(vec![var("v")]),
4816 ..ExpressionNode::default()
4817 };
4818 mk.args = vec![bad()];
4819 let bc = ExpressionNode {
4820 op: "broadcast".to_string(),
4821 args: vec![bad()],
4822 ..ExpressionNode::default()
4823 };
4824 for node in [fq, mk, bc] {
4825 let name = node.op.clone();
4826 let mut f = Vec::new();
4827 propagate_dim(&Expr::operator(node), &env, &mut f);
4828 assert!(f.iter().any(UnitFinding::is_error), "{name}: {f:?}");
4829 }
4830 }
4831}