1use std::collections::HashMap;
76use std::fmt;
77
78pub const BASE_SYMBOLS: [&str; 7] = ["m", "kg", "s", "A", "K", "mol", "cd"];
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
85pub struct Dimension(pub [i32; 7]);
86
87impl Dimension {
88 pub const NONE: Dimension = Dimension([0; 7]);
90
91 fn base(index: usize) -> Self {
92 let mut e = [0; 7];
93 e[index] = 1;
94 Dimension(e)
95 }
96
97 fn times(self, other: Self) -> Self {
98 let mut e = self.0;
99 for (a, b) in e.iter_mut().zip(other.0) {
100 *a += b;
101 }
102 Dimension(e)
103 }
104
105 fn pow(self, n: i32) -> Self {
106 Dimension(self.0.map(|e| e * n))
107 }
108
109 pub fn is_none(&self) -> bool {
111 *self == Self::NONE
112 }
113}
114
115impl fmt::Display for Dimension {
118 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119 const ORDER: [usize; 7] = [1, 0, 2, 3, 4, 5, 6];
121 let parts: Vec<String> = ORDER
122 .iter()
123 .filter(|&&i| self.0[i] != 0)
124 .map(|&i| match self.0[i] {
125 1 => BASE_SYMBOLS[i].to_string(),
126 e => format!("{}{e}", BASE_SYMBOLS[i]),
127 })
128 .collect();
129 if parts.is_empty() {
130 f.write_str("1")
131 } else {
132 f.write_str(&parts.join("."))
133 }
134 }
135}
136
137#[derive(Debug, Clone, Copy, PartialEq)]
140pub struct Unit {
141 pub dimension: Dimension,
143 pub scale: f64,
145 pub offset: f64,
147}
148
149impl Unit {
150 const ONE: Unit = Unit {
151 dimension: Dimension::NONE,
152 scale: 1.0,
153 offset: 0.0,
154 };
155
156 pub fn convert_to(&self, value: f64, to: &Unit) -> Result<f64, UnitError> {
162 self.check(value, to)?;
163 Ok((value * self.scale + self.offset - to.offset) / to.scale)
164 }
165
166 pub fn difference_to(&self, value: f64, to: &Unit) -> Result<f64, UnitError> {
171 self.check(value, to)?;
172 Ok(value * self.scale / to.scale)
173 }
174
175 fn check(&self, value: f64, to: &Unit) -> Result<(), UnitError> {
176 if !value.is_finite() {
177 return Err(UnitError::NotFinite { parameter: "value" });
178 }
179 if self.dimension != to.dimension {
180 return Err(UnitError::DimensionMismatch {
181 from: self.dimension,
182 to: to.dimension,
183 });
184 }
185 Ok(())
186 }
187
188 fn times(self, other: Unit) -> Unit {
189 Unit {
190 dimension: self.dimension.times(other.dimension),
191 scale: self.scale * other.scale,
192 offset: 0.0,
193 }
194 }
195
196 fn pow(self, n: i32) -> Unit {
197 Unit {
198 dimension: self.dimension.pow(n),
199 scale: self.scale.powi(n),
200 offset: 0.0,
201 }
202 }
203}
204
205#[derive(Debug, Clone, PartialEq)]
207pub enum UnitError {
208 Malformed {
211 expression: String,
213 reason: String,
215 },
216 UnknownSymbol {
218 expression: String,
220 symbol: String,
222 },
223 OffsetCombined {
225 expression: String,
227 symbol: String,
229 },
230 DimensionMismatch {
232 from: Dimension,
234 to: Dimension,
236 },
237 DuplicateSymbol {
239 symbol: String,
241 },
242 InvalidSymbol {
244 symbol: String,
246 reason: String,
248 },
249 NotFinite {
251 parameter: &'static str,
253 },
254 NonPositiveFactor {
256 got: f64,
258 },
259}
260
261impl fmt::Display for UnitError {
262 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
263 match self {
264 UnitError::Malformed { expression, reason } => {
265 write!(f, "unit \"{expression}\": {reason}")
266 }
267 UnitError::UnknownSymbol { expression, symbol } => {
268 write!(f, "unit \"{expression}\": unknown unit symbol \"{symbol}\"")
269 }
270 UnitError::OffsetCombined { expression, symbol } => write!(
271 f,
272 "unit \"{expression}\": \"{symbol}\" has an offset (a scale such as Cel) and \
273 must stand alone, without a prefix, an exponent or a product"
274 ),
275 UnitError::DimensionMismatch { from, to } => {
276 write!(f, "cannot convert between dimensions {from} and {to}")
277 }
278 UnitError::DuplicateSymbol { symbol } => {
279 write!(f, "\"{symbol}\" is already a unit")
280 }
281 UnitError::InvalidSymbol { symbol, reason } => {
282 write!(f, "\"{symbol}\" cannot be a unit symbol: {reason}")
283 }
284 UnitError::NotFinite { parameter } => {
285 write!(f, "{parameter} must be a finite number")
286 }
287 UnitError::NonPositiveFactor { got } => {
288 write!(f, "factor must be > 0, got {got}")
289 }
290 }
291 }
292}
293
294impl std::error::Error for UnitError {}
295
296const PREFIXES: [(&str, i32); 25] = [
298 ("Q", 30),
299 ("R", 27),
300 ("Y", 24),
301 ("Z", 21),
302 ("E", 18),
303 ("P", 15),
304 ("T", 12),
305 ("G", 9),
306 ("M", 6),
307 ("k", 3),
308 ("h", 2),
309 ("da", 1),
310 ("d", -1),
311 ("c", -2),
312 ("m", -3),
313 ("u", -6),
314 ("n", -9),
315 ("p", -12),
316 ("f", -15),
317 ("a", -18),
318 ("z", -21),
319 ("y", -24),
320 ("r", -27),
321 ("q", -30),
322 ("μ", -6),
323];
324
325#[derive(Debug, Clone, Copy)]
327struct Atom {
328 unit: Unit,
329 prefixable: bool,
330}
331
332#[derive(Debug, Clone)]
336pub struct Units {
337 atoms: HashMap<String, Atom>,
338}
339
340fn is_operator(c: char) -> bool {
342 matches!(c, '.' | '/' | '(' | ')' | '{' | '}') || c.is_whitespace()
343}
344
345impl Units {
346 pub fn si() -> Self {
348 let mut atoms = HashMap::new();
349 let mut put = |symbol: &str, unit: Unit, prefixable: bool| {
350 atoms.insert(symbol.to_string(), Atom { unit, prefixable });
351 };
352 let d = |e: [i32; 7]| Dimension(e);
353 let coherent = |e: [i32; 7]| Unit {
354 dimension: d(e),
355 scale: 1.0,
356 offset: 0.0,
357 };
358 let scaled = |e: [i32; 7], scale: f64| Unit {
359 dimension: d(e),
360 scale,
361 offset: 0.0,
362 };
363 put(
364 "m",
365 Unit {
366 dimension: Dimension::base(0),
367 scale: 1.0,
368 offset: 0.0,
369 },
370 true,
371 );
372 put(
373 "g",
374 Unit {
375 dimension: Dimension::base(1),
376 scale: 1e-3,
377 offset: 0.0,
378 },
379 true,
380 );
381 put(
382 "s",
383 Unit {
384 dimension: Dimension::base(2),
385 scale: 1.0,
386 offset: 0.0,
387 },
388 true,
389 );
390 put(
391 "A",
392 Unit {
393 dimension: Dimension::base(3),
394 scale: 1.0,
395 offset: 0.0,
396 },
397 true,
398 );
399 put(
400 "K",
401 Unit {
402 dimension: Dimension::base(4),
403 scale: 1.0,
404 offset: 0.0,
405 },
406 true,
407 );
408 put(
409 "mol",
410 Unit {
411 dimension: Dimension::base(5),
412 scale: 1.0,
413 offset: 0.0,
414 },
415 true,
416 );
417 put(
418 "cd",
419 Unit {
420 dimension: Dimension::base(6),
421 scale: 1.0,
422 offset: 0.0,
423 },
424 true,
425 );
426 put("rad", coherent([0, 0, 0, 0, 0, 0, 0]), true);
427 put("sr", coherent([0, 0, 0, 0, 0, 0, 0]), true);
428 put("Hz", coherent([0, 0, -1, 0, 0, 0, 0]), true);
429 put("N", coherent([1, 1, -2, 0, 0, 0, 0]), true);
430 put("Pa", coherent([-1, 1, -2, 0, 0, 0, 0]), true);
431 put("J", coherent([2, 1, -2, 0, 0, 0, 0]), true);
432 put("W", coherent([2, 1, -3, 0, 0, 0, 0]), true);
433 put("C", coherent([0, 0, 1, 1, 0, 0, 0]), true);
434 put("V", coherent([2, 1, -3, -1, 0, 0, 0]), true);
435 put("F", coherent([-2, -1, 4, 2, 0, 0, 0]), true);
436 put("Ohm", coherent([2, 1, -3, -2, 0, 0, 0]), true);
437 put("S", coherent([-2, -1, 3, 2, 0, 0, 0]), true);
438 put("Wb", coherent([2, 1, -2, -1, 0, 0, 0]), true);
439 put("T", coherent([0, 1, -2, -1, 0, 0, 0]), true);
440 put("H", coherent([2, 1, -2, -2, 0, 0, 0]), true);
441 put("lm", coherent([0, 0, 0, 0, 0, 0, 1]), true);
442 put("lx", coherent([-2, 0, 0, 0, 0, 0, 1]), true);
443 put("Bq", coherent([0, 0, -1, 0, 0, 0, 0]), true);
444 put("Gy", coherent([2, 0, -2, 0, 0, 0, 0]), true);
445 put("Sv", coherent([2, 0, -2, 0, 0, 0, 0]), true);
446 put("kat", coherent([0, 0, -1, 0, 0, 1, 0]), true);
447 put(
448 "Cel",
449 Unit {
450 dimension: Dimension::base(4),
451 scale: 1.0,
452 offset: 273.15,
453 },
454 false,
455 );
456 put("min", scaled([0, 0, 1, 0, 0, 0, 0], 60.0), false);
458 put("h", scaled([0, 0, 1, 0, 0, 0, 0], 3600.0), false);
459 put("d", scaled([0, 0, 1, 0, 0, 0, 0], 86_400.0), false);
460 put("L", scaled([3, 0, 0, 0, 0, 0, 0], 1e-3), true);
461 put("l", scaled([3, 0, 0, 0, 0, 0, 0], 1e-3), true);
462 put("t", scaled([0, 1, 0, 0, 0, 0, 0], 1e3), true);
463 put("1", Unit::ONE, false);
464 Units { atoms }
465 }
466
467 pub fn define(
485 &mut self,
486 symbol: &str,
487 factor: f64,
488 expression: &str,
489 offset: f64,
490 ) -> Result<(), UnitError> {
491 let invalid = |reason: &str| UnitError::InvalidSymbol {
492 symbol: symbol.to_string(),
493 reason: reason.to_string(),
494 };
495 let first = symbol
496 .chars()
497 .next()
498 .ok_or_else(|| invalid("it is empty"))?;
499 if first.is_ascii_digit() || first == '+' || first == '-' {
500 return Err(invalid("it starts with a digit or a sign"));
501 }
502 if symbol.chars().any(is_operator) {
503 return Err(invalid(
504 "it holds an operator character (. / ( ) { } or a space)",
505 ));
506 }
507 if self.atoms.contains_key(symbol) {
510 return Err(UnitError::DuplicateSymbol {
511 symbol: symbol.to_string(),
512 });
513 }
514 if !factor.is_finite() {
515 return Err(UnitError::NotFinite {
516 parameter: "factor",
517 });
518 }
519 if factor <= 0.0 {
520 return Err(UnitError::NonPositiveFactor { got: factor });
521 }
522 if !offset.is_finite() {
523 return Err(UnitError::NotFinite {
524 parameter: "offset",
525 });
526 }
527 let base = self.parse(expression)?;
528 let unit = Unit {
529 dimension: base.dimension,
530 scale: factor * base.scale,
531 offset: base.offset + offset,
532 };
533 self.atoms.insert(
534 symbol.to_string(),
535 Atom {
536 unit,
537 prefixable: unit.offset == 0.0,
538 },
539 );
540 Ok(())
541 }
542
543 pub fn parse(&self, expression: &str) -> Result<Unit, UnitError> {
549 let mut parser = Parser {
550 units: self,
551 expression,
552 chars: expression.char_indices().peekable(),
553 };
554 let (unit, offset_symbol) = parser.term()?;
555 if let Some(&(_, c)) = parser.chars.peek() {
556 return Err(parser.malformed(format!("unexpected \"{c}\"")));
557 }
558 if let OffsetUse::Combined(symbol) = offset_symbol {
559 return Err(UnitError::OffsetCombined {
560 expression: expression.to_string(),
561 symbol,
562 });
563 }
564 Ok(unit)
565 }
566
567 pub fn convert(&self, value: f64, from: &str, to: &str) -> Result<f64, UnitError> {
575 self.parse(from)?.convert_to(value, &self.parse(to)?)
576 }
577
578 pub fn convert_difference(&self, value: f64, from: &str, to: &str) -> Result<f64, UnitError> {
584 self.parse(from)?.difference_to(value, &self.parse(to)?)
585 }
586
587 fn atom<'t>(&self, token: &'t str) -> Option<(Unit, Option<&'t str>)> {
589 if let Some(a) = self.atoms.get(token) {
590 return Some((a.unit, None));
591 }
592 PREFIXES.iter().find_map(|&(prefix, exp)| {
593 let rest = token.strip_prefix(prefix)?;
594 let a = self.atoms.get(rest)?;
595 if !a.prefixable && a.unit.offset == 0.0 {
596 return None;
597 }
598 Some((
599 Unit {
600 dimension: a.unit.dimension,
601 scale: a.unit.scale * 10f64.powi(exp),
602 offset: a.unit.offset,
603 },
604 Some(rest),
605 ))
606 })
607 }
608}
609
610#[derive(Debug, Clone, PartialEq)]
612enum OffsetUse {
613 None,
614 Alone(String),
616 Combined(String),
618}
619
620struct Parser<'a> {
621 units: &'a Units,
622 expression: &'a str,
623 chars: std::iter::Peekable<std::str::CharIndices<'a>>,
624}
625
626impl Parser<'_> {
627 fn malformed(&self, reason: String) -> UnitError {
628 UnitError::Malformed {
629 expression: self.expression.to_string(),
630 reason,
631 }
632 }
633
634 fn term(&mut self) -> Result<(Unit, OffsetUse), UnitError> {
636 let mut unit = Unit::ONE;
637 let mut parts: Vec<OffsetUse> = Vec::new();
638 let mut divide = false;
639 if let Some(&(_, '/')) = self.chars.peek() {
640 self.chars.next();
641 divide = true;
642 }
643 let mut divided = divide;
644 loop {
645 let (next, offset) = self.component()?;
646 unit = if parts.is_empty() && !divide {
647 next
648 } else {
649 unit.times(if divide { next.pow(-1) } else { next })
650 };
651 parts.push(offset);
652 match self.chars.peek() {
653 Some(&(_, '.')) => divide = false,
654 Some(&(_, '/')) => divide = true,
655 _ => break,
656 }
657 divided |= divide;
658 self.chars.next();
659 }
660 let offset = match parts.as_slice() {
662 [OffsetUse::Alone(s)] if !divided => OffsetUse::Alone(s.clone()),
663 _ => parts
664 .into_iter()
665 .find_map(|p| match p {
666 OffsetUse::Alone(s) | OffsetUse::Combined(s) => Some(OffsetUse::Combined(s)),
667 OffsetUse::None => None,
668 })
669 .unwrap_or(OffsetUse::None),
670 };
671 if !matches!(offset, OffsetUse::Alone(_)) {
672 unit.offset = 0.0;
673 }
674 Ok((unit, offset))
675 }
676
677 fn component(&mut self) -> Result<(Unit, OffsetUse), UnitError> {
679 match self.chars.peek().copied() {
680 None => Err(self.malformed("a unit is missing at the end".to_string())),
681 Some((_, '(')) => {
682 self.chars.next();
683 let (unit, offset) = self.term()?;
684 match self.chars.next() {
685 Some((_, ')')) => {}
686 _ => return Err(self.malformed("\"(\" is not closed".to_string())),
687 }
688 self.annotation()?;
689 Ok((unit, offset))
690 }
691 Some((_, '{')) => {
692 self.annotation()?;
693 Ok((Unit::ONE, OffsetUse::None))
694 }
695 Some((_, c)) if is_operator(c) => {
696 Err(self.malformed(format!("a unit is missing before \"{c}\"")))
697 }
698 Some((start, _)) => {
699 let mut end = start;
700 while let Some(&(i, c)) = self.chars.peek() {
701 if is_operator(c) {
702 break;
703 }
704 end = i + c.len_utf8();
705 self.chars.next();
706 }
707 let token = &self.expression[start..end];
708 let result = self.token(token)?;
709 self.annotation()?;
710 Ok(result)
711 }
712 }
713 }
714
715 fn annotation(&mut self) -> Result<(), UnitError> {
717 if let Some(&(_, '{')) = self.chars.peek() {
718 self.chars.next();
719 loop {
720 match self.chars.next() {
721 Some((_, '}')) => return Ok(()),
722 Some((_, '{')) => {
723 return Err(self.malformed("an annotation holds \"{\"".to_string()))
724 }
725 Some(_) => {}
726 None => return Err(self.malformed("\"{\" is not closed".to_string())),
727 }
728 }
729 }
730 Ok(())
731 }
732
733 fn token(&self, token: &str) -> Result<(Unit, OffsetUse), UnitError> {
736 if token.bytes().all(|b| b.is_ascii_digit()) {
737 let n: f64 = token
738 .parse()
739 .map_err(|_| self.malformed(format!("\"{token}\" is not a number")))?;
740 if n <= 0.0 {
741 return Err(self.malformed("a factor must be a positive integer".to_string()));
742 }
743 return Ok((
744 Unit {
745 scale: n,
746 ..Unit::ONE
747 },
748 OffsetUse::None,
749 ));
750 }
751 if let Some(found) = self.lookup(token) {
752 return Ok(found);
753 }
754 let digits = token
755 .char_indices()
756 .rev()
757 .take_while(|(_, c)| c.is_ascii_digit())
758 .last()
759 .map(|(i, _)| i);
760 let Some(digits_at) = digits else {
761 return Err(self.unknown(token));
762 };
763 let (symbol_and_sign, digits) = token.split_at(digits_at);
764 let (symbol, sign) = match symbol_and_sign.strip_suffix(['+', '-']) {
765 Some(s) => (s, &symbol_and_sign[s.len()..]),
766 None => (symbol_and_sign, ""),
767 };
768 if symbol.is_empty() {
769 return Err(self.malformed(format!("\"{token}\" has an exponent but no unit")));
770 }
771 let n: i32 = format!("{sign}{digits}")
772 .parse()
773 .ok()
774 .filter(|n: &i32| n.unsigned_abs() <= 64)
775 .ok_or_else(|| self.malformed(format!("exponent \"{sign}{digits}\" is too large")))?;
776 let (unit, offset) = self.lookup(symbol).ok_or_else(|| self.unknown(symbol))?;
777 let offset = match offset {
778 OffsetUse::Alone(s) if n != 1 => OffsetUse::Combined(s),
779 other => other,
780 };
781 Ok((unit.pow(n), offset))
782 }
783
784 fn lookup(&self, symbol: &str) -> Option<(Unit, OffsetUse)> {
785 let (unit, prefixed) = self.units.atom(symbol)?;
786 let offset = match (unit.offset != 0.0, prefixed) {
787 (false, _) => OffsetUse::None,
788 (true, None) => OffsetUse::Alone(symbol.to_string()),
789 (true, Some(atom)) => OffsetUse::Combined(atom.to_string()),
790 };
791 Some((unit, offset))
792 }
793
794 fn unknown(&self, symbol: &str) -> UnitError {
795 UnitError::UnknownSymbol {
796 expression: self.expression.to_string(),
797 symbol: symbol.to_string(),
798 }
799 }
800}
801
802#[cfg(test)]
803mod tests {
804 use super::*;
805
806 fn close(a: f64, b: f64) -> bool {
807 (a - b).abs() <= 1e-12 * b.abs().max(1.0)
808 }
809
810 fn energy() -> Units {
812 let mut u = Units::si();
813 u.define("bar", 1e5, "Pa", 0.0).unwrap();
814 u.define("cal_IT", 4.1868, "J", 0.0).unwrap();
815 u.define("cal_th", 4.184, "J", 0.0).unwrap();
816 u.define("toe", 1e7, "kcal_IT", 0.0).unwrap();
817 u.define("Nm3", 1.0, "m3", 0.0).unwrap();
818 u
819 }
820
821 #[test]
824 fn one_bar_is_one_hundred_kilopascal() {
825 assert!(close(energy().convert(1.0, "bar", "kPa").unwrap(), 100.0));
826 }
827
828 #[test]
829 fn a_celsius_reading_converts_with_the_offset_and_a_difference_without() {
830 let u = Units::si();
831 assert!(close(u.convert(20.0, "Cel", "K").unwrap(), 293.15));
832 assert!(close(u.convert_difference(20.0, "Cel", "K").unwrap(), 20.0));
833 assert!(close(u.convert(300.0, "K", "Cel").unwrap(), 26.85));
834 }
835
836 #[test]
837 fn a_kilowatt_hour_is_three_point_six_megajoule() {
838 assert!(close(Units::si().convert(1.0, "kW.h", "J").unwrap(), 3.6e6));
839 assert!(close(Units::si().convert(1.0, "kW.h", "MJ").unwrap(), 3.6));
840 }
841
842 #[test]
843 fn the_calorie_is_the_one_the_caller_defined() {
844 let u = energy();
845 assert!(close(u.convert(1.0, "kcal_IT", "J").unwrap(), 4186.8));
846 assert!(close(u.convert(1.0, "kcal_th", "J").unwrap(), 4184.0));
847 assert!(close(u.convert(1.0, "Gcal_IT", "GJ").unwrap(), 4.1868));
848 assert!(close(u.convert(1.0, "toe", "GJ").unwrap(), 41.868));
849 }
850
851 #[test]
852 fn volume_is_not_mass() {
853 assert_eq!(
854 energy().convert(1.0, "m3", "kg"),
855 Err(UnitError::DimensionMismatch {
856 from: Dimension([3, 0, 0, 0, 0, 0, 0]),
857 to: Dimension([0, 1, 0, 0, 0, 0, 0]),
858 })
859 );
860 }
861
862 #[test]
865 fn products_and_quotients_read_left_to_right() {
866 let u = Units::si();
867 assert!(close(
868 u.convert(1.0, "km/h", "m/s").unwrap(),
869 1000.0 / 3600.0
870 ));
871 assert_eq!(
873 u.parse("kg/m.s").unwrap().dimension,
874 Dimension([-1, 1, 1, 0, 0, 0, 0])
875 );
876 assert_eq!(
877 u.parse("kg/(m.s)").unwrap().dimension,
878 Dimension([-1, 1, -1, 0, 0, 0, 0])
879 );
880 assert_eq!(
881 u.parse("/s").unwrap().dimension,
882 u.parse("Hz").unwrap().dimension
883 );
884 assert!(close(u.convert(1.0, "m/s2", "N/kg").unwrap(), 1.0));
885 assert!(close(u.convert(1.0, "J/(kg.K)", "m2/(s2.K)").unwrap(), 1.0));
886 }
887
888 #[test]
889 fn exponents_are_signed_integers() {
890 let u = Units::si();
891 assert!(close(u.convert(1.0, "m3", "L").unwrap(), 1000.0));
892 assert!(close(u.convert(1.0, "cm3", "mL").unwrap(), 1.0));
893 assert!(close(u.convert(2.0, "s-1", "Hz").unwrap(), 2.0));
894 assert!(close(u.convert(1.0, "m+2", "dm2").unwrap(), 100.0));
895 }
896
897 #[test]
898 fn integers_are_factors_and_one_is_unity() {
899 let u = Units::si();
900 assert!(close(u.convert(1.0, "1000.m", "km").unwrap(), 1.0));
901 assert!(u.parse("1").unwrap().dimension.is_none());
902 assert!(close(u.convert(1.0, "rad", "1").unwrap(), 1.0));
903 }
904
905 #[test]
906 fn annotations_have_no_effect() {
907 let u = energy();
908 assert!(close(u.convert(5.0, "m3{gas}", "m3").unwrap(), 5.0));
909 assert!(close(u.convert(5.0, "{count}/min", "/h").unwrap(), 300.0));
910 }
911
912 #[test]
913 fn an_atom_wins_over_a_prefix_split_and_a_symbol_ending_in_digits_stays_whole() {
914 let u = energy();
915 assert_eq!(u.parse("cd").unwrap().dimension, Dimension::base(6)); assert!(close(u.convert(1.0, "Nm3", "m3").unwrap(), 1.0));
917 assert!(close(u.convert(1.0, "Nm3", "Nm3").unwrap(), 1.0));
918 assert!(close(u.convert(1.0, "min", "s").unwrap(), 60.0)); assert!(close(u.convert(1.0, "mmol", "mol").unwrap(), 1e-3));
920 assert!(close(u.convert(1.0, "kg", "g").unwrap(), 1e3));
921 assert!(close(u.convert(1.0, "dam", "m").unwrap(), 10.0));
922 assert!(close(u.convert(1.0, "um", "m").unwrap(), 1e-6));
923 assert!(close(u.convert(1.0, "μm", "m").unwrap(), 1e-6));
924 }
925
926 #[test]
927 fn hours_and_days_take_no_prefix() {
928 assert!(matches!(
930 Units::si().parse("kh"),
931 Err(UnitError::UnknownSymbol { .. })
932 ));
933 }
934
935 #[test]
938 fn an_offset_unit_stands_alone() {
939 let u = Units::si();
940 for e in ["Cel2", "kCel", "Cel/s", "m.Cel", "(Cel)", "/Cel"] {
941 let r = u.parse(e);
942 if e == "(Cel)" {
943 assert!(r.is_ok(), "{e}: {r:?}");
945 continue;
946 }
947 assert!(
948 matches!(r, Err(UnitError::OffsetCombined { ref symbol, .. }) if symbol == "Cel"),
949 "{e}: {r:?}"
950 );
951 }
952 }
953
954 #[test]
955 fn a_malformed_expression_is_refused() {
956 let u = Units::si();
957 for e in ["", "m.", "m//s", "(m", "m)", "m{x", ".m", "m s", "m99999"] {
958 assert!(
959 matches!(u.parse(e), Err(UnitError::Malformed { .. })),
960 "{e:?}: {:?}",
961 u.parse(e)
962 );
963 }
964 }
965
966 #[test]
967 fn an_unknown_symbol_is_named() {
968 assert_eq!(
969 Units::si().parse("kW.hr"),
970 Err(UnitError::UnknownSymbol {
971 expression: "kW.hr".into(),
972 symbol: "hr".into()
973 })
974 );
975 assert!(matches!(
977 Units::si().parse("yd2"),
978 Err(UnitError::UnknownSymbol { ref symbol, .. }) if symbol == "yd"
979 ));
980 assert!(Units::si().parse("ft").is_ok());
983 }
984
985 #[test]
986 fn a_definition_is_checked() {
987 let mut u = Units::si();
988 assert_eq!(
989 u.define("J", 1.0, "J", 0.0),
990 Err(UnitError::DuplicateSymbol { symbol: "J".into() })
991 );
992 u.define("ft", 0.3048, "m", 0.0).unwrap();
994 assert!(close(u.convert(1.0, "ft2", "m2").unwrap(), 0.3048 * 0.3048));
995 for bad in ["", "2x", "-x", "a.b", "a b", "a/b", "{a}"] {
996 assert!(
997 matches!(
998 u.define(bad, 1.0, "m", 0.0),
999 Err(UnitError::InvalidSymbol { .. })
1000 ),
1001 "{bad:?}"
1002 );
1003 }
1004 assert_eq!(
1005 u.define("x", 0.0, "m", 0.0),
1006 Err(UnitError::NonPositiveFactor { got: 0.0 })
1007 );
1008 assert_eq!(
1009 u.define("x", f64::NAN, "m", 0.0),
1010 Err(UnitError::NotFinite {
1011 parameter: "factor"
1012 })
1013 );
1014 assert!(matches!(
1015 u.define("x", 1.0, "later", 0.0),
1016 Err(UnitError::UnknownSymbol { .. })
1017 ));
1018 }
1019
1020 #[test]
1021 fn a_defined_scale_converts_readings() {
1022 let mut u = Units::si();
1023 u.define("degF", 5.0 / 9.0, "K", 459.67 * 5.0 / 9.0)
1024 .unwrap();
1025 assert!(close(u.convert(32.0, "degF", "Cel").unwrap(), 0.0));
1026 assert!(close(u.convert(212.0, "degF", "Cel").unwrap(), 100.0));
1027 assert!(close(u.convert_difference(9.0, "degF", "K").unwrap(), 5.0));
1028 assert!(matches!(
1029 u.parse("kdegF"),
1030 Err(UnitError::OffsetCombined { .. })
1031 ));
1032 }
1033
1034 #[test]
1035 fn a_value_must_be_finite() {
1036 assert_eq!(
1037 Units::si().convert(f64::INFINITY, "m", "m"),
1038 Err(UnitError::NotFinite { parameter: "value" })
1039 );
1040 }
1041
1042 #[test]
1043 fn a_dimension_is_written_in_base_units() {
1044 let u = Units::si();
1045 assert_eq!(u.parse("J").unwrap().dimension.to_string(), "kg.m2.s-2");
1046 assert_eq!(u.parse("rad").unwrap().dimension.to_string(), "1");
1047 assert_eq!(
1048 u.parse("Ohm").unwrap().dimension.to_string(),
1049 "kg.m2.s-3.A-2"
1050 );
1051 }
1052
1053 #[test]
1054 fn round_trips_are_exact_to_rounding() {
1055 let u = energy();
1056 for (a, b) in [("kW.h", "GJ"), ("Cel", "K"), ("bar", "Pa"), ("toe", "MW.h")] {
1057 let x = 123.456;
1058 let back = u.convert(u.convert(x, a, b).unwrap(), b, a).unwrap();
1059 assert!(close(back, x), "{a} ↔ {b}: {back}");
1060 }
1061 }
1062}