add currency compound units by division and multiplication
Some checks are pending
Deploy / web (push) Waiting to run
Test / test (push) Waiting to run

This commit is contained in:
Zhang, Tianrong 2026-03-03 00:14:40 -05:00
parent 7e76fc4008
commit 2cca20ae00
3 changed files with 457 additions and 1 deletions

View File

@ -279,6 +279,9 @@ fn evaluate_node(ast_node: &AstNode) -> Result<Number, String> {
mod tests { mod tests {
use crate::eval; use crate::eval;
use super::*; use super::*;
use crate::currency;
use crate::extra_units;
use crate::units::UnitType;
fn eval_default<'a>(input: &'a str) -> Number { fn eval_default<'a>(input: &'a str) -> Number {
let result = eval(input, true, false).unwrap(); let result = eval(input, true, false).unwrap();
@ -336,4 +339,57 @@ mod tests {
assert_eq!(eval_num("sin(2)"), "0.9092974268256816953960198659117448427"); assert_eq!(eval_num("sin(2)"), "0.9092974268256816953960198659117448427");
assert_eq!(eval_num("sin(-2)"), "-0.9092974268256816953960198659117448427"); assert_eq!(eval_num("sin(-2)"), "-0.9092974268256816953960198659117448427");
} }
#[test]
fn test_divide_currency_and_mass_by_volume() {
// Ensure a clean registry so the IDs are deterministic
extra_units::clear_for_tests();
// Minimal rates (USD base). 1 USD = 7.07 CNY
let json = r#"{"base":"USD","rates":{"USD":1,"CNY":7.07}}"#;
currency::register_currencies_from_exchange_rate_api_json(json).unwrap();
let x = eval_default("5.55 usd / 64 fl oz");
assert_eq!(x.unit.category(), UnitType::CurrencyPerVolume);
assert_eq!(x.unit.singular(), "USD/floz");
let y = eval_default("5.55 g / 64 fl oz");
assert_eq!(y.unit.category(), UnitType::MassPerVolume);
assert_eq!(y.unit.singular(), "g/floz");
// Conversion should work on the derived units:
let z = eval_default("5.55 usd / 64 fl oz to cny/l");
assert_eq!(z.unit.singular(), "CNY/l");
let w = eval_default("5.55 g / 64 fl oz to g/ml");
assert_eq!(w.unit.singular(), "g/ml");
}
#[test]
fn test_multiply_cancels_derived_units() {
extra_units::clear_for_tests();
let json = r#"{"base":"USD","rates":{"USD":1,"CNY":7.07}}"#;
currency::register_currencies_from_exchange_rate_api_json(json).unwrap();
// Your example: (USD/L) * floz -> USD
let out = eval_default("(1.48-1/3) usd / 1.25 l * 144 fl oz");
assert_eq!(out.unit.category(), UnitType::Currency);
assert_eq!(out.unit.singular(), "USD");
assert!(!out.value.is_nan());
let assert_close = |actual: D128, expected: D128| {
// Derived unit weights use division, so allow tiny rounding differences.
assert!((actual - expected).abs() < d!(1e-24));
};
// Cancellation sanity checks
let usd_back = eval_default("5.55 usd / 64 fl oz * 64 fl oz");
assert_eq!(usd_back.unit.singular(), "USD");
assert_close(usd_back.value, d!(5.55));
let g_back = eval_default("5.55 g / 64 fl oz * 64 fl oz");
assert_eq!(g_back.unit.singular(), "gram");
assert_close(g_back.value, d!(5.55));
}
} }

View File

@ -909,7 +909,10 @@ pub fn lex(input: &str, remove_trailing_operator: bool) -> Result<Vec<Token>, St
(Token::LexerKeyword(Revolution), Token::LexerKeyword(Per), Token::Unit(Minute)) => { (Token::LexerKeyword(Revolution), Token::LexerKeyword(Per), Token::Unit(Minute)) => {
tokens[token_index-2] = Token::Unit(RevolutionsPerMinute); tokens[token_index-2] = Token::Unit(RevolutionsPerMinute);
}, },
// currency per {mass|volume|length|area}, for example usd/lb, cny/kg, usd/l, usd/m, usd/m2 // composite units:
// - currency per {mass|volume|length|area} (usd/lb, cny/kg, usd/l, usd/m, usd/m2)
// - mass per volume (g/ml, kg/l, lb/gal)
// - volume per mass (ml/g, l/kg)
(Token::Unit(left_u), Token::LexerKeyword(Per), Token::Unit(right_u)) => { (Token::Unit(left_u), Token::LexerKeyword(Per), Token::Unit(right_u)) => {
let left_cat = left_u.category(); let left_cat = left_u.category();
let right_cat = right_u.category(); let right_cat = right_u.category();
@ -1002,6 +1005,86 @@ pub fn lex(input: &str, remove_trailing_operator: bool) -> Result<Vec<Token>, St
weight, weight,
); );
tokens[token_index-2] = Token::Unit(composite); tokens[token_index-2] = Token::Unit(composite);
} else if left_cat == UnitType::Mass && right_cat == UnitType::Volume {
let mass_abbrev: &str = match *left_u {
Milligram => "mg",
Gram => "g",
Hectogram => "hg",
Kilogram => "kg",
MetricTon => "t",
Ounce => "oz",
Pound => "lb",
Stone => "st",
ShortTon => "ston",
LongTon => "lton",
_ => left_u.singular(),
};
let vol_abbrev: &str = match *right_u {
Milliliter => "ml",
Centiliter => "cl",
Deciliter => "dl",
Liter => "l",
Teaspoon => "tsp",
Tablespoon => "tbsp",
FluidOunce => "floz",
Cup => "cup",
Pint => "pt",
Quart => "qt",
Gallon => "gal",
OilBarrel => "bbl",
_ => right_u.singular(),
};
let display = format!("{}/{}", mass_abbrev, vol_abbrev);
let key = format!("{mass_abbrev}_per_{vol_abbrev}");
let weight = left_u.weight() / right_u.weight(); // grams per cubic-mm
let composite = extra_units::upsert_unit(
&key,
&display,
&display,
UnitType::MassPerVolume,
weight,
);
tokens[token_index-2] = Token::Unit(composite);
} else if left_cat == UnitType::Volume && right_cat == UnitType::Mass {
let vol_abbrev: &str = match *left_u {
Milliliter => "ml",
Centiliter => "cl",
Deciliter => "dl",
Liter => "l",
Teaspoon => "tsp",
Tablespoon => "tbsp",
FluidOunce => "floz",
Cup => "cup",
Pint => "pt",
Quart => "qt",
Gallon => "gal",
OilBarrel => "bbl",
_ => left_u.singular(),
};
let mass_abbrev: &str = match *right_u {
Milligram => "mg",
Gram => "g",
Hectogram => "hg",
Kilogram => "kg",
MetricTon => "t",
Ounce => "oz",
Pound => "lb",
Stone => "st",
ShortTon => "ston",
LongTon => "lton",
_ => right_u.singular(),
};
let display = format!("{}/{}", vol_abbrev, mass_abbrev);
let key = format!("{vol_abbrev}_per_{mass_abbrev}");
let weight = left_u.weight() / right_u.weight(); // cubic-mm per gram
let composite = extra_units::upsert_unit(
&key,
&display,
&display,
UnitType::VolumePerMass,
weight,
);
tokens[token_index-2] = Token::Unit(composite);
} else { } else {
replaced = false; replaced = false;
} }

View File

@ -50,6 +50,10 @@ pub enum UnitType {
CurrencyPerLength, CurrencyPerLength,
/// A currency-per-area unit, for example USD/m2 or EUR/ft2 /// A currency-per-area unit, for example USD/m2 or EUR/ft2
CurrencyPerArea, CurrencyPerArea,
/// A mass-per-volume unit (density / concentration), e.g. g/ml
MassPerVolume,
/// A volume-per-mass unit (specific volume), e.g. ml/g
VolumePerMass,
} }
use UnitType::*; use UnitType::*;
@ -566,6 +570,49 @@ pub fn multiply(left: Number, right: Number) -> Result<Number, String> {
actual_multiply(left, right, false) actual_multiply(left, right, false)
} }
fn parse_currency_numerator(unit: Unit) -> Option<Unit> {
let code_lower = unit.singular().split('/').next()?.to_ascii_lowercase();
extra_units::lookup_unit(&code_lower)
}
fn parse_mass_numerator(unit: Unit) -> Option<Unit> {
let s = unit.singular().split('/').next()?.to_ascii_lowercase();
let u = match s.as_str() {
"mg" => Milligram,
"g" => Gram,
"hg" => Hectogram,
"kg" => Kilogram,
"t" => MetricTon,
"oz" => Ounce,
"lb" => Pound,
"st" => Stone,
"ston" => ShortTon,
"lton" => LongTon,
_ => return None,
};
Some(u)
}
fn parse_volume_numerator(unit: Unit) -> Option<Unit> {
let s = unit.singular().split('/').next()?.to_ascii_lowercase();
let u = match s.as_str() {
"ml" => Milliliter,
"cl" => Centiliter,
"dl" => Deciliter,
"l" => Liter,
"tsp" => Teaspoon,
"tbsp" => Tablespoon,
"floz" => FluidOunce,
"cup" => Cup,
"pt" => Pint,
"qt" => Quart,
"gal" => Gallon,
"bbl" => OilBarrel,
_ => return None,
};
Some(u)
}
fn actual_multiply(left: Number, right: Number, swapped: bool) -> Result<Number, String> { fn actual_multiply(left: Number, right: Number, swapped: bool) -> Result<Number, String> {
let lcat = left.unit.category(); let lcat = left.unit.category();
let rcat = right.unit.category(); let rcat = right.unit.category();
@ -645,6 +692,81 @@ fn actual_multiply(left: Number, right: Number, swapped: bool) -> Result<Number,
}; };
let data_storage = Number::new(result, Bit); let data_storage = Number::new(result, Bit);
Ok(convert(data_storage, final_unit)?) Ok(convert(data_storage, final_unit)?)
} else if lcat == CurrencyPerVolume && rcat == Volume {
// (currency per volume) * volume = currency
let rate_usd_per_mm3 = left.value * left.unit.weight();
let volume_mm3 = right.value * right.unit.weight();
let usd_amount = rate_usd_per_mm3 * volume_mm3;
let usd_unit = extra_units::lookup_unit("usd")
.ok_or("Currency rates not initialized (missing USD)")?;
let mut result = Number::new(usd_amount, usd_unit);
if let Some(cur_unit) = parse_currency_numerator(left.unit) {
result = convert(result, cur_unit)?;
}
Ok(result)
} else if lcat == CurrencyPerMass && rcat == Mass {
let rate_usd_per_g = left.value * left.unit.weight();
let grams = right.value * right.unit.weight();
let usd_amount = rate_usd_per_g * grams;
let usd_unit = extra_units::lookup_unit("usd")
.ok_or("Currency rates not initialized (missing USD)")?;
let mut result = Number::new(usd_amount, usd_unit);
if let Some(cur_unit) = parse_currency_numerator(left.unit) {
result = convert(result, cur_unit)?;
}
Ok(result)
} else if lcat == CurrencyPerLength && rcat == Length {
let rate_usd_per_mm = left.value * left.unit.weight();
let mm = right.value * right.unit.weight();
let usd_amount = rate_usd_per_mm * mm;
let usd_unit = extra_units::lookup_unit("usd")
.ok_or("Currency rates not initialized (missing USD)")?;
let mut result = Number::new(usd_amount, usd_unit);
if let Some(cur_unit) = parse_currency_numerator(left.unit) {
result = convert(result, cur_unit)?;
}
Ok(result)
} else if lcat == CurrencyPerArea && rcat == Area {
let rate_usd_per_mm2 = left.value * left.unit.weight();
let mm2 = right.value * right.unit.weight();
let usd_amount = rate_usd_per_mm2 * mm2;
let usd_unit = extra_units::lookup_unit("usd")
.ok_or("Currency rates not initialized (missing USD)")?;
let mut result = Number::new(usd_amount, usd_unit);
if let Some(cur_unit) = parse_currency_numerator(left.unit) {
result = convert(result, cur_unit)?;
}
Ok(result)
} else if lcat == MassPerVolume && rcat == Volume {
// (mass per volume) * volume = mass
let rate_g_per_mm3 = left.value * left.unit.weight();
let volume_mm3 = right.value * right.unit.weight();
let grams = rate_g_per_mm3 * volume_mm3;
let mut result = Number::new(grams, Gram);
if let Some(mass_unit) = parse_mass_numerator(left.unit) {
result = convert(result, mass_unit)?;
}
Ok(result)
} else if lcat == VolumePerMass && rcat == Mass {
// (volume per mass) * mass = volume
let rate_mm3_per_g = left.value * left.unit.weight();
let grams = right.value * right.unit.weight();
let mm3 = rate_mm3_per_g * grams;
let mut result = Number::new(mm3, CubicMillimeter);
if let Some(vol_unit) = parse_volume_numerator(left.unit) {
result = convert(result, vol_unit)?;
}
Ok(result)
} else if lcat == Voltage && rcat == ElectricCurrent { } else if lcat == Voltage && rcat == ElectricCurrent {
// 1 volt * 1 ampere = 1 watt // 1 volt * 1 ampere = 1 watt
let result = (left.value * left.unit.weight()) * (right.value * right.unit.weight()); let result = (left.value * left.unit.weight()) * (right.value * right.unit.weight());
@ -729,6 +851,201 @@ pub fn divide(left: Number, right: Number) -> Result<Number, String> {
let bits_per_second = convert(right, BitsPerSecond)?; let bits_per_second = convert(right, BitsPerSecond)?;
let seconds = Number::new(bits.value / bits_per_second.value, Second); let seconds = Number::new(bits.value / bits_per_second.value, Second);
Ok(to_ideal_unit(seconds)) Ok(to_ideal_unit(seconds))
} else if lcat == Currency && rcat == Volume {
// 5 usd / 64 floz => usd/floz (convertible to usd/l, cny/l, etc)
let cur_upper = left.unit.singular().to_ascii_uppercase();
let cur_lower = cur_upper.to_ascii_lowercase();
let denom_abbrev: &str = match right.unit {
Milliliter => "ml",
Centiliter => "cl",
Deciliter => "dl",
Liter => "l",
Teaspoon => "tsp",
Tablespoon => "tbsp",
FluidOunce => "floz",
Cup => "cup",
Pint => "pt",
Quart => "qt",
Gallon => "gal",
OilBarrel => "bbl",
_ => right.unit.singular(),
};
let display = format!("{}/{}", cur_upper, denom_abbrev);
let key = format!("{cur_lower}_per_{denom_abbrev}");
let composite = crate::extra_units::upsert_unit(
&key,
&display,
&display,
CurrencyPerVolume,
left.unit.weight() / right.unit.weight(),
);
Ok(Number::new(left.value / right.value, composite))
} else if lcat == Currency && rcat == Mass {
// usd/kg etc
let cur_upper = left.unit.singular().to_ascii_uppercase();
let cur_lower = cur_upper.to_ascii_lowercase();
let denom_abbrev: &str = match right.unit {
Milligram => "mg",
Gram => "g",
Hectogram => "hg",
Kilogram => "kg",
MetricTon => "t",
Ounce => "oz",
Pound => "lb",
Stone => "st",
ShortTon => "ston",
LongTon => "lton",
_ => right.unit.singular(),
};
let display = format!("{}/{}", cur_upper, denom_abbrev);
let key = format!("{cur_lower}_per_{denom_abbrev}");
let composite = crate::extra_units::upsert_unit(
&key,
&display,
&display,
CurrencyPerMass,
left.unit.weight() / right.unit.weight(),
);
Ok(Number::new(left.value / right.value, composite))
} else if lcat == Currency && rcat == Length {
// usd/m, etc
let cur_upper = left.unit.singular().to_ascii_uppercase();
let cur_lower = cur_upper.to_ascii_lowercase();
let denom_abbrev: &str = match right.unit {
Millimeter => "mm",
Centimeter => "cm",
Decimeter => "dm",
Meter => "m",
Kilometer => "km",
Inch => "in",
Foot => "ft",
Yard => "yd",
Mile => "mi",
NauticalMile => "nmi",
LightYear => "ly",
LightSecond => "lightsec",
_ => right.unit.singular(),
};
let display = format!("{}/{}", cur_upper, denom_abbrev);
let key = format!("{cur_lower}_per_{denom_abbrev}");
let composite = crate::extra_units::upsert_unit(
&key,
&display,
&display,
CurrencyPerLength,
left.unit.weight() / right.unit.weight(),
);
Ok(Number::new(left.value / right.value, composite))
} else if lcat == Currency && rcat == Area {
// usd/m2, etc
let cur_upper = left.unit.singular().to_ascii_uppercase();
let cur_lower = cur_upper.to_ascii_lowercase();
let denom_abbrev: &str = match right.unit {
SquareMillimeter => "mm2",
SquareCentimeter => "cm2",
SquareDecimeter => "dm2",
SquareMeter => "m2",
SquareKilometer => "km2",
SquareInch => "in2",
SquareFoot => "ft2",
SquareYard => "yd2",
SquareMile => "mi2",
Are => "a",
Decare => "daa",
Hectare => "ha",
Acre => "acre",
_ => right.unit.singular(),
};
let display = format!("{}/{}", cur_upper, denom_abbrev);
let key = format!("{cur_lower}_per_{denom_abbrev}");
let composite = crate::extra_units::upsert_unit(
&key,
&display,
&display,
CurrencyPerArea,
left.unit.weight() / right.unit.weight(),
);
Ok(Number::new(left.value / right.value, composite))
} else if lcat == Mass && rcat == Volume {
// 5 g / 64 floz => g/floz (convertible to g/ml, kg/l, etc)
let mass_abbrev: &str = match left.unit {
Milligram => "mg",
Gram => "g",
Hectogram => "hg",
Kilogram => "kg",
MetricTon => "t",
Ounce => "oz",
Pound => "lb",
Stone => "st",
ShortTon => "ston",
LongTon => "lton",
_ => left.unit.singular(),
};
let vol_abbrev: &str = match right.unit {
Milliliter => "ml",
Centiliter => "cl",
Deciliter => "dl",
Liter => "l",
Teaspoon => "tsp",
Tablespoon => "tbsp",
FluidOunce => "floz",
Cup => "cup",
Pint => "pt",
Quart => "qt",
Gallon => "gal",
OilBarrel => "bbl",
_ => right.unit.singular(),
};
let display = format!("{}/{}", mass_abbrev, vol_abbrev);
let key = format!("{mass_abbrev}_per_{vol_abbrev}");
let composite = crate::extra_units::upsert_unit(
&key,
&display,
&display,
MassPerVolume,
left.unit.weight() / right.unit.weight(),
);
Ok(Number::new(left.value / right.value, composite))
} else if lcat == Volume && rcat == Mass {
// ml/g etc
let vol_abbrev: &str = match left.unit {
Milliliter => "ml",
Centiliter => "cl",
Deciliter => "dl",
Liter => "l",
Teaspoon => "tsp",
Tablespoon => "tbsp",
FluidOunce => "floz",
Cup => "cup",
Pint => "pt",
Quart => "qt",
Gallon => "gal",
OilBarrel => "bbl",
_ => left.unit.singular(),
};
let mass_abbrev: &str = match right.unit {
Milligram => "mg",
Gram => "g",
Hectogram => "hg",
Kilogram => "kg",
MetricTon => "t",
Ounce => "oz",
Pound => "lb",
Stone => "st",
ShortTon => "ston",
LongTon => "lton",
_ => right.unit.singular(),
};
let display = format!("{}/{}", vol_abbrev, mass_abbrev);
let key = format!("{vol_abbrev}_per_{mass_abbrev}");
let composite = crate::extra_units::upsert_unit(
&key,
&display,
&display,
VolumePerMass,
left.unit.weight() / right.unit.weight(),
);
Ok(Number::new(left.value / right.value, composite))
} else if lcat == Power && rcat == ElectricCurrent { } else if lcat == Power && rcat == ElectricCurrent {
// 1 watt / 1 ampere = 1 volt // 1 watt / 1 ampere = 1 volt
let result = (left.value * left.unit.weight()) / (right.value * right.unit.weight()); let result = (left.value * left.unit.weight()) / (right.value * right.unit.weight());