diff --git a/CHANGELOG.md b/CHANGELOG.md index fd90684..25dcc3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,14 @@ # Changelog ## Next -- Add units of force +- Add units of force, like `newton` +- Add units of angle, like `revolution`. `degree` still defaults to temperature, but it works in angle contexts, like `degree to revolution` - Parse `kN` as `kilonewton` instead of `knot` if the N is uppercase - Add REPL to the `cpc` CLI - Add support for numbers with thousands separators `_`, ` ` and ` ` (thin space) - Format numbers with thousands separator - Fix units incorrectly being displayed with a negative exponent, like `1 meter / second^-2` +- Fix `rpm` support - Fix parsing of `inch of mercury` ## 4.2.0 - 2026 Jul 1 diff --git a/README.md b/README.md index d0182b9..805b852 100644 --- a/README.md +++ b/README.md @@ -178,9 +178,7 @@ match string { - Timezones - Binary/octal/decimal/hexadecimal/base32/base64 - Color codes - - Force - Roman numerals - - Angles ### Releasing a new version diff --git a/src/evaluator.rs b/src/evaluator.rs index 89ee33a..e00995f 100644 --- a/src/evaluator.rs +++ b/src/evaluator.rs @@ -488,6 +488,11 @@ mod tests { eval_test("4.4482216152605 newtons to lbf", "1 pound-force"); eval_test("1 lbf to newtons", "4.4482216152605 newtons"); results_eq("1 bar to Pa", "100000 Pa"); + results_eq("1 r per min", "1 revolution per minute"); + eval_test( + "60 revolutions per minute to revolutions per second", + "≈ 1 revolution / second", + ); results_eq("1 kHz to Hz", "1000 Hz"); results_eq("1 in of mercury", "1 inch of mercury"); results_eq("1 \" of mercury", "1 inch of mercury"); diff --git a/src/lexer.rs b/src/lexer.rs index b29118b..9f341f0 100644 --- a/src/lexer.rs +++ b/src/lexer.rs @@ -679,6 +679,14 @@ fn lex_word(word: &str, lexer: &mut Lexer) -> Result<(), String> { "psi" => Token::unit(PoundsPerSquareInch), "torr" | "torrs" => Token::unit(Torr), + "arcmin" | "arcminute" | "arcminutes" => Token::unit(Arcminute), + "arcsec" | "arcsecond" | "arcseconds" => Token::unit(Arcsecond), + "grad" | "gon" | "gradians" | "gons" => Token::unit(Gradian), + "rad" | "radian" | "radians" => Token::unit(Radian), + "mrad" | "milliradian" | "milliradians" => Token::unit(Milliradian), + "turn" | "turns" => Token::unit(Turn), + "r" | "rev" | "revolution" | "revolutions" => Token::unit(Revolution), + "hz" | "hertz" => Token::unit(Hertz), "khz" | "kilohertz" => Token::unit(Kilohertz), "mhz" | "megahertz" => Token::unit(Megahertz), @@ -686,7 +694,6 @@ fn lex_word(word: &str, lexer: &mut Lexer) -> Result<(), String> { "thz" | "terahertz" => Token::unit(Terahertz), "phz" | "petahertz" => Token::unit(Petahertz), "rpm" => Token::unit(RevolutionsPerMinute), - "r" | "rev" | "revolution" | "revolutions" => Token::LexerKeyword(Revolution), "kph" | "kmh" => Token::unit(KilometersPerHour), "mps" => Token::unit(MetersPerSecond), @@ -701,11 +708,16 @@ fn lex_word(word: &str, lexer: &mut Lexer) -> Result<(), String> { Token::unit(Fahrenheit) } other => { - let token = match get_region().as_str() { - "BS" | "BZ" | "KY" | "PR" | "PW" | "US" => Fahrenheit, - _ => Celsius, + let candidates = match get_region().as_str() { + "BS" | "BZ" | "KY" | "PR" | "PW" | "US" => &[Fahrenheit, Degree], + _ => &[Celsius, Degree], }; - lexer.tokens.push(Token::unit(token)); + let unit = Ambiguity(Ambiguity { + string: "degree", + candidates, + fallback: &candidates[0], + }); + lexer.tokens.push(Token::unit(unit)); lex_word_if_non_empty(other, lexer)?; return Ok(()); } @@ -2824,7 +2836,7 @@ mod tests { "1150 revolutions per minute", vec![ numtok!(1150), - Token::LexerKeyword(Revolution), + Token::unit(Revolution), Token::TextOperator(Per), Token::unit(Minute), ], @@ -2835,7 +2847,7 @@ mod tests { "1 revolution per min", vec![ numtok!(1), - Token::LexerKeyword(Revolution), + Token::unit(Revolution), Token::TextOperator(Per), Token::unit(Minute), ], @@ -2846,7 +2858,7 @@ mod tests { "4 revolution / mins", vec![ numtok!(4), - Token::LexerKeyword(Revolution), + Token::unit(Revolution), Token::Operator(Divide), Token::unit(Minute), ], @@ -2857,7 +2869,7 @@ mod tests { "1250 r / min", vec![ numtok!(1250), - Token::LexerKeyword(Revolution), + Token::unit(Revolution), Token::Operator(Divide), Token::unit(Minute), ], @@ -2868,7 +2880,7 @@ mod tests { "1300 rev / min", vec![ numtok!(1300), - Token::LexerKeyword(Revolution), + Token::unit(Revolution), Token::Operator(Divide), Token::unit(Minute), ], @@ -2879,7 +2891,7 @@ mod tests { "1350 rev / minute", vec![ numtok!(1350), - Token::LexerKeyword(Revolution), + Token::unit(Revolution), Token::Operator(Divide), Token::unit(Minute), ], @@ -2890,7 +2902,7 @@ mod tests { "1250 r per min", vec![ numtok!(1250), - Token::LexerKeyword(Revolution), + Token::unit(Revolution), Token::TextOperator(Per), Token::unit(Minute), ], @@ -2901,7 +2913,7 @@ mod tests { "1300 rev per min", vec![ numtok!(1300), - Token::LexerKeyword(Revolution), + Token::unit(Revolution), Token::TextOperator(Per), Token::unit(Minute), ], @@ -2912,7 +2924,7 @@ mod tests { "1350 rev per minute", vec![ numtok!(1350), - Token::LexerKeyword(Revolution), + Token::unit(Revolution), Token::TextOperator(Per), Token::unit(Minute), ], diff --git a/src/lib.rs b/src/lib.rs index 216c155..58c8fd7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,7 +22,7 @@ //! } //! ``` -use crate::units::{Unit, UnitType, primitive_unit, sort_units}; +use crate::units::{Unit, UnitType, primitive_unit, primitive_unit_strict, sort_units}; use fastnum::{D128, dec128 as d}; use serde::{Deserialize, Serialize}; use std::{ @@ -88,6 +88,9 @@ impl Number { pub fn primitive_unit(&self) -> Vec<(Unit, isize)> { primitive_unit(&self.unit) } + pub fn primitive_strict(&self) -> Vec<(Unit, isize)> { + primitive_unit_strict(&self.unit) + } pub fn contains_category(&self, category: UnitType) -> bool { self.unit.iter().any(|(u, _)| u.category() == category) } @@ -303,7 +306,6 @@ pub enum FunctionIdentifier { pub enum LexerKeyword { PercentChar, In, - Revolution, } #[derive(Clone, PartialEq)] diff --git a/src/units.rs b/src/units.rs index 63efd46..8c0be97 100644 --- a/src/units.rs +++ b/src/units.rs @@ -40,6 +40,8 @@ pub enum UnitType { Force, /// A unit of force, for example [`Hertz`] Pressure, + /// A unit of angle, for example [`Degree`] + Angle, /// A unit of frequency, for example [`Hertz`] Frequency, /// A unit of x, for example [`KilometersPerHour`] @@ -68,6 +70,8 @@ impl UnitType { Resistance => vec![(Meter, 2), (Kilogram, 1), (Second, -3), (Ampere, -2)], Voltage => vec![(Meter, 2), (Kilogram, 1), (Second, -3), (Ampere, -1)], Force => vec![(Meter, 1), (Kilogram, 1), (Second, -2)], + // In SI, angles are dimensionless. Wolfram Alpha does the same. + Angle => vec![], Pressure => vec![(Kilogram, 1), (Second, -2), (Meter, -1)], Frequency => vec![(Second, -1)], Speed => vec![(Meter, 1), (Second, -1)], @@ -83,6 +87,13 @@ impl UnitType { } units } + fn primitive_strict(&self) -> Vec<(Unit, isize)> { + let units = match self { + Angle => vec![(Revolution, 1)], + _ => self.primitive(), + }; + units + } } use UnitType::*; @@ -120,6 +131,24 @@ pub fn primitive_unit(unit: &[(Unit, isize)]) -> Vec<(Unit, isize)> { primitives } +/// Primitive unit, but that keeps dimnsionless unit categories separate. +/// So angles and unitless numbers are not considered equal. +pub fn primitive_unit_strict(unit: &[(Unit, isize)]) -> Vec<(Unit, isize)> { + let mut primitives: Vec<(Unit, isize)> = Vec::new(); + for (unit, exponent) in unit { + for (primitive, primitive_exponent) in unit.category().primitive_strict() { + let existing = primitives.iter_mut().find(|(u, _)| u == &primitive); + match existing { + Some(existing) => existing.1 += primitive_exponent * exponent, + None => primitives.push((primitive, primitive_exponent * exponent)), + } + } + } + primitives.retain(|(_, exponent)| exponent != &0); + sort_units(&mut primitives); + primitives +} + fn reduce_unit(number: Number) -> Number { let mut new_unit: Vec<(Unit, isize)> = Vec::new(); for (unit, exponent) in &number.unit { @@ -145,7 +174,6 @@ fn reduce_unit(number: Number) -> Number { macro_rules! create_units { ( $( $variant:ident : $properties:expr ),*, ) => { #[derive(Clone, Copy, PartialEq, Debug, Eq, PartialOrd, Ord, Hash)] - /// A Unit enum. Note that it can also be [`NoUnit`]. pub enum Unit { Ambiguity(Ambiguity), $($variant),* @@ -426,13 +454,23 @@ create_units!( PoundsPerSquareInch: (Pressure, inexact!(8896443230521/1290320000), "pound per square inch", "pounds per square inch"), Torr: (Pressure, inexact!(4053000 / 30400), "torr", "torr"), + // Gets the inexact flag because of the division + Radian: (Angle, 1 / D128::TAU, "radian", "radians"), + Degree: (Angle, inexact!(1/360), "degree", "degrees"), + Arcminute: (Angle, inexact!(1/21600), "arcminute", "arcminutes"), + Arcsecond: (Angle, inexact!(1/1296000), "arcsecond", "arcseconds"), + Gradian: (Angle, d!(1) / d!(400), "gradian", "gradians"), + Milliradian: (Angle, 1 / (d!(1000) * D128::TAU), "milliradian", "milliradians"), + Turn: (Angle, d!(1), "turn", "turns"), + Revolution: (Angle, d!(1), "revolution", "revolutions"), + + RevolutionsPerMinute: (Frequency, inexact!(1/60), "revolution per minute", "revolutions per minute"), Hertz: (Frequency, d!(1), "hertz", "hertz"), Kilohertz: (Frequency, d!(1000), "kilohertz", "kilohertz"), Megahertz: (Frequency, d!(1000000), "megahertz", "megahertz"), Gigahertz: (Frequency, d!(1000000000), "gigahertz", "gigahertz"), Terahertz: (Frequency, d!(1000000000000), "terahertz", "terahertz"), Petahertz: (Frequency, d!(1000000000000000), "petahertz", "petahertz"), - RevolutionsPerMinute: (Frequency, d!(60), "revolution per minute", "revolutions per minute"), KilometersPerHour: (Speed, inexact!(1 / 3.6), "kilometer per hour", "kilometers per hour"), MetersPerSecond: (Speed, d!(1), "meter per second", "meters per second"), @@ -717,10 +755,11 @@ pub fn convert(number: Number, to_unit: Vec<(Unit, isize)>) -> Result) -> Result Result<(Number, Number), String> { assert!(left.primitive_unit() == right.primitive_unit()); if combined_weight(&left.unit) == combined_weight(&right.unit) { - Ok((left, right)) + let right_converted = convert(right, left.unit.clone())?; + Ok((left, right_converted)) } else if combined_weight(&left.unit) > combined_weight(&right.unit) { let left_converted = convert(left, right.unit.clone())?; Ok((left_converted, right)) @@ -743,7 +783,7 @@ pub fn convert_to_lowest(left: Number, right: Number) -> Result<(Number, Number) pub fn add(left: Number, right: Number) -> Result { if left.unit == right.unit { Ok(Number::with_unit(left.value + right.value, left.unit)) - } else if left.primitive_unit() == right.primitive_unit() + } else if left.primitive_strict() == right.primitive_strict() && !left.contains_category(Temperature) { let (left, right) = convert_to_lowest(left, right)?; @@ -757,7 +797,7 @@ pub fn add(left: Number, right: Number) -> Result { pub fn subtract(left: Number, right: Number) -> Result { if left.unit == right.unit { Ok(Number::with_unit(left.value - right.value, left.unit)) - } else if left.primitive_unit() == right.primitive_unit() + } else if left.primitive_strict() == right.primitive_strict() && !left.contains_category(Temperature) { let (left, right) = convert_to_lowest(left, right)?; @@ -1018,7 +1058,7 @@ pub fn divide_any(left: Number, right: Number) -> Result { pub fn modulo(left: Number, right: Number) -> Result { if left.contains_category(Temperature) || right.contains_category(Temperature) { Err(format!("Cannot modulo {} by {}", left, right)) - } else if left.primitive_unit() == right.primitive_unit() { + } else if left.primitive_strict() == right.primitive_strict() { // 5 km % 3 m let (left, right) = convert_to_lowest(left, right)?; Ok(Number::with_unit(left.value % right.value, left.unit)) @@ -1437,12 +1477,12 @@ mod tests { 1.0 ); + assert_float_eq!(convert_test(60.0, RevolutionsPerMinute, Hertz), 1.0); assert_float_eq!(convert_test(1000.0, Hertz, Kilohertz), 1.0); assert_float_eq!(convert_test(1000.0, Kilohertz, Megahertz), 1.0); assert_float_eq!(convert_test(1000.0, Megahertz, Gigahertz), 1.0); assert_float_eq!(convert_test(1000.0, Gigahertz, Terahertz), 1.0); assert_float_eq!(convert_test(1000.0, Terahertz, Petahertz), 1.0); - assert_float_eq!(convert_test(60.0, Hertz, RevolutionsPerMinute), 1.0); // assert_float_eq!(convert_test(3.6, KilometersPerHour, MetersPerSecond), 1.0); assert_float_eq!(convert_test(0.3048, MetersPerSecond, FeetPerSecond), 1.0);