add currency compound units by division and multiplication
This commit is contained in:
parent
7e76fc4008
commit
2cca20ae00
@ -279,6 +279,9 @@ fn evaluate_node(ast_node: &AstNode) -> Result<Number, String> {
|
||||
mod tests {
|
||||
use crate::eval;
|
||||
use super::*;
|
||||
use crate::currency;
|
||||
use crate::extra_units;
|
||||
use crate::units::UnitType;
|
||||
|
||||
fn eval_default<'a>(input: &'a str) -> Number {
|
||||
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");
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
|
||||
85
src/lexer.rs
85
src/lexer.rs
@ -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)) => {
|
||||
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)) => {
|
||||
let left_cat = left_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,
|
||||
);
|
||||
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 {
|
||||
replaced = false;
|
||||
}
|
||||
|
||||
317
src/units.rs
317
src/units.rs
@ -50,6 +50,10 @@ pub enum UnitType {
|
||||
CurrencyPerLength,
|
||||
/// A currency-per-area unit, for example USD/m2 or EUR/ft2
|
||||
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::*;
|
||||
|
||||
@ -566,6 +570,49 @@ pub fn multiply(left: Number, right: Number) -> Result<Number, String> {
|
||||
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> {
|
||||
let lcat = left.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);
|
||||
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 {
|
||||
// 1 volt * 1 ampere = 1 watt
|
||||
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 seconds = Number::new(bits.value / bits_per_second.value, Second);
|
||||
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 {
|
||||
// 1 watt / 1 ampere = 1 volt
|
||||
let result = (left.value * left.unit.weight()) / (right.value * right.unit.weight());
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user