Skip to main content

haystack_core/kinds/
units.rs

1use std::collections::HashMap;
2use std::sync::LazyLock;
3
4/// A Haystack unit definition.
5#[derive(Debug, Clone, PartialEq, Eq, Hash)]
6pub struct Unit {
7    pub name: String,
8    pub symbols: Vec<String>,
9    pub quantity: String,
10}
11
12struct UnitsRegistry {
13    by_name: HashMap<String, Unit>,
14    by_symbol: HashMap<String, Unit>,
15}
16
17static UNITS: LazyLock<UnitsRegistry> = LazyLock::new(|| {
18    let data = include_str!("../../data/units.txt");
19    parse_units(data)
20});
21
22fn parse_units(data: &str) -> UnitsRegistry {
23    let mut by_name = HashMap::new();
24    let mut by_symbol = HashMap::new();
25    let mut current_quantity = String::new();
26
27    for line in data.lines() {
28        let line = line.trim();
29        if line.is_empty() || line.starts_with("//") {
30            continue;
31        }
32        if line.starts_with("-- ") && line.ends_with(" --") {
33            current_quantity = line[3..line.len() - 3].to_string();
34            continue;
35        }
36        let parts: Vec<&str> = line.split(',').collect();
37        if parts.is_empty() {
38            continue;
39        }
40        let name = parts[0].trim().to_string();
41        let symbols: Vec<String> = parts[1..]
42            .iter()
43            .map(|s| s.trim().to_string())
44            .filter(|s| !s.is_empty())
45            .collect();
46
47        let unit = Unit {
48            name: name.clone(),
49            symbols: symbols.clone(),
50            quantity: current_quantity.clone(),
51        };
52
53        by_name.insert(name, unit.clone());
54        for sym in &symbols {
55            by_symbol.insert(sym.clone(), unit.clone());
56        }
57    }
58
59    UnitsRegistry { by_name, by_symbol }
60}
61
62/// Look up a unit by name or symbol.
63pub fn unit_for(s: &str) -> Option<&'static Unit> {
64    UNITS.by_name.get(s).or_else(|| UNITS.by_symbol.get(s))
65}
66
67/// Get all units indexed by name.
68pub fn units_by_name() -> &'static HashMap<String, Unit> {
69    &UNITS.by_name
70}
71
72/// Get all units indexed by symbol.
73pub fn units_by_symbol() -> &'static HashMap<String, Unit> {
74    &UNITS.by_symbol
75}
76
77// ---------------------------------------------------------------------------
78// Unit conversion
79// ---------------------------------------------------------------------------
80
81/// A conversion factor for a unit.
82///
83/// Formula to convert to the SI base unit for the quantity:
84/// `si_value = input * scale + offset`
85#[derive(Debug, Clone, Copy)]
86pub struct ConversionFactor {
87    /// Multiply by this to get SI base unit value.
88    pub scale: f64,
89    /// Add this after multiplying to get SI base unit value (for affine transforms like °F→°C).
90    pub offset: f64,
91}
92
93/// Error type for unit conversion failures.
94#[derive(Debug, Clone)]
95pub enum UnitError {
96    UnknownUnit(String),
97    IncompatibleUnits(String, String),
98}
99
100impl std::fmt::Display for UnitError {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        match self {
103            UnitError::UnknownUnit(u) => write!(f, "unknown unit: {u}"),
104            UnitError::IncompatibleUnits(a, b) => write!(f, "incompatible units: {a} and {b}"),
105        }
106    }
107}
108
109impl std::error::Error for UnitError {}
110
111static CONVERSION_FACTORS: LazyLock<HashMap<&'static str, ConversionFactor>> =
112    LazyLock::new(|| {
113        let entries: &[(&str, f64, f64)] = &[
114            // temperature (SI base: celsius)
115            ("fahrenheit", 5.0 / 9.0, -32.0 * 5.0 / 9.0),
116            ("celsius", 1.0, 0.0),
117            ("kelvin", 1.0, -273.15),
118            // length (SI base: meter)
119            ("meter", 1.0, 0.0),
120            ("kilometer", 1000.0, 0.0),
121            ("centimeter", 0.01, 0.0),
122            ("millimeter", 0.001, 0.0),
123            ("foot", 0.3048, 0.0),
124            ("inch", 0.0254, 0.0),
125            ("mile", 1609.344, 0.0),
126            ("yard", 0.9144, 0.0),
127            // pressure (SI base: pascal)
128            ("pascal", 1.0, 0.0),
129            ("kilopascal", 1000.0, 0.0),
130            ("bar", 100_000.0, 0.0),
131            ("millibar", 100.0, 0.0),
132            ("pounds_per_square_inch", 6894.757, 0.0),
133            ("inches_of_water", 248.84, 0.0),
134            ("inches_of_mercury", 3386.389, 0.0),
135            ("atmosphere", 101_325.0, 0.0),
136            ("hectopascal", 100.0, 0.0),
137            // energy (SI base: joule)
138            ("joule", 1.0, 0.0),
139            ("kilojoule", 1000.0, 0.0),
140            ("megajoule", 1e6, 0.0),
141            ("kilowatt_hour", 3.6e6, 0.0),
142            ("watt_hour", 3600.0, 0.0),
143            ("btu", 1055.06, 0.0),
144            ("megabtu", 1.055_06e9, 0.0),
145            ("therm", 1.055_06e8, 0.0),
146            ("tons_refrigeration_hour", 1.2661e7, 0.0),
147            ("kilobtu", 1.055_06e6, 0.0),
148            // power (SI base: watt)
149            ("watt", 1.0, 0.0),
150            ("kilowatt", 1000.0, 0.0),
151            ("megawatt", 1e6, 0.0),
152            ("horsepower", 745.7, 0.0),
153            ("btus_per_hour", 0.293_07, 0.0),
154            ("tons_refrigeration", 3516.85, 0.0),
155            ("kilobtus_per_hour", 293.07, 0.0),
156            // volume (SI base: cubic_meter)
157            ("cubic_meter", 1.0, 0.0),
158            ("liter", 0.001, 0.0),
159            ("milliliter", 1e-6, 0.0),
160            ("gallon", 0.003_785, 0.0),
161            ("quart", 0.000_946_353, 0.0),
162            ("pint", 0.000_473_176, 0.0),
163            ("fluid_ounce", 2.957e-5, 0.0),
164            ("cubic_foot", 0.028_317, 0.0),
165            ("imperial_gallon", 0.004_546, 0.0),
166            // volumetric flow (SI base: cubic_meters_per_second)
167            ("cubic_meters_per_second", 1.0, 0.0),
168            ("liters_per_second", 0.001, 0.0),
169            ("liters_per_minute", 1.667e-5, 0.0),
170            ("cubic_feet_per_minute", 0.000_472, 0.0),
171            ("gallons_per_minute", 6.309e-5, 0.0),
172            ("cubic_meters_per_hour", 2.778e-4, 0.0),
173            ("liters_per_hour", 2.778e-7, 0.0),
174            // area (SI base: square_meter)
175            ("square_meter", 1.0, 0.0),
176            ("square_foot", 0.0929, 0.0),
177            ("square_kilometer", 1e6, 0.0),
178            ("square_mile", 2.59e6, 0.0),
179            ("acre", 4046.86, 0.0),
180            ("square_centimeter", 1e-4, 0.0),
181            ("square_inch", 6.452e-4, 0.0),
182            // mass (SI base: kilogram)
183            ("kilogram", 1.0, 0.0),
184            ("gram", 0.001, 0.0),
185            ("milligram", 1e-6, 0.0),
186            ("metric_ton", 1000.0, 0.0),
187            ("pound", 0.4536, 0.0),
188            ("ounce", 0.028_35, 0.0),
189            ("short_ton", 907.185, 0.0),
190            // time (SI base: second)
191            ("second", 1.0, 0.0),
192            ("millisecond", 0.001, 0.0),
193            ("minute", 60.0, 0.0),
194            ("hour", 3600.0, 0.0),
195            ("day", 86400.0, 0.0),
196            ("week", 604_800.0, 0.0),
197            ("julian_month", 2_629_800.0, 0.0),
198            ("year", 31_557_600.0, 0.0),
199            // electric current (SI base: ampere)
200            ("ampere", 1.0, 0.0),
201            ("milliampere", 0.001, 0.0),
202            // electric potential (SI base: volt)
203            ("volt", 1.0, 0.0),
204            ("millivolt", 0.001, 0.0),
205            ("kilovolt", 1000.0, 0.0),
206            ("megavolt", 1e6, 0.0),
207            // frequency (SI base: hertz)
208            ("hertz", 1.0, 0.0),
209            ("kilohertz", 1000.0, 0.0),
210            ("megahertz", 1e6, 0.0),
211            ("per_minute", 1.0 / 60.0, 0.0),
212            ("per_hour", 1.0 / 3600.0, 0.0),
213            ("per_second", 1.0, 0.0),
214            // illuminance (SI base: lux)
215            ("lux", 1.0, 0.0),
216            ("footcandle", 10.764, 0.0),
217            ("phot", 10000.0, 0.0),
218            // luminous flux (SI base: lumen)
219            ("lumen", 1.0, 0.0),
220        ];
221        entries
222            .iter()
223            .map(|&(name, scale, offset)| (name, ConversionFactor { scale, offset }))
224            .collect()
225    });
226
227static BASE_UNITS: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(|| {
228    let entries: &[(&str, &str)] = &[
229        ("temperature", "celsius"),
230        ("length", "meter"),
231        ("pressure", "pascal"),
232        ("energy", "joule"),
233        ("power", "watt"),
234        ("volume", "cubic_meter"),
235        ("volumetric flow", "cubic_meters_per_second"),
236        ("area", "square_meter"),
237        ("mass", "kilogram"),
238        ("time", "second"),
239        ("electric current", "ampere"),
240        ("electric potential", "volt"),
241        ("frequency", "hertz"),
242        ("illuminance", "lux"),
243        ("luminous flux", "lumen"),
244    ];
245    entries.iter().copied().collect()
246});
247
248/// Resolve a unit string (name or symbol) to its registry entry and conversion factor.
249fn resolve(s: &str) -> Result<(&'static Unit, &'static ConversionFactor), UnitError> {
250    let unit = unit_for(s).ok_or_else(|| UnitError::UnknownUnit(s.to_string()))?;
251    let cf = CONVERSION_FACTORS
252        .get(unit.name.as_str())
253        .ok_or_else(|| UnitError::UnknownUnit(s.to_string()))?;
254    Ok((unit, cf))
255}
256
257/// Convert a value from one unit to another.
258///
259/// Both units must belong to the same quantity (e.g. both are lengths).
260/// Units can be specified by name (`"fahrenheit"`) or symbol (`"°F"`).
261///
262/// # Precision
263///
264/// Conversions use IEEE 754 double-precision arithmetic. Chained conversions
265/// (e.g., °F → °C → K → °F) may accumulate floating-point error on the order
266/// of ±1e-10. For exact round-trip fidelity, convert directly between source
267/// and target units rather than through intermediaries.
268pub fn convert(val: f64, from: &str, to: &str) -> Result<f64, UnitError> {
269    let (from_unit, from_cf) = resolve(from)?;
270    let (to_unit, to_cf) = resolve(to)?;
271
272    if from_unit.quantity != to_unit.quantity {
273        return Err(UnitError::IncompatibleUnits(
274            from_unit.name.clone(),
275            to_unit.name.clone(),
276        ));
277    }
278
279    Ok((val * from_cf.scale + from_cf.offset - to_cf.offset) / to_cf.scale)
280}
281
282/// Check if two units are compatible (same quantity).
283pub fn compatible(a: &str, b: &str) -> bool {
284    let (ua, ub) = match (unit_for(a), unit_for(b)) {
285        (Some(ua), Some(ub)) => (ua, ub),
286        _ => return false,
287    };
288    // Both must also have conversion factors registered
289    if !CONVERSION_FACTORS.contains_key(ua.name.as_str())
290        || !CONVERSION_FACTORS.contains_key(ub.name.as_str())
291    {
292        return false;
293    }
294    ua.quantity == ub.quantity
295}
296
297/// Get the quantity name for a unit.
298pub fn quantity(unit: &str) -> Option<&'static str> {
299    let u = unit_for(unit)?;
300    // Return a &'static str by looking up in BASE_UNITS keys
301    BASE_UNITS.keys().find(|&&q| q == u.quantity).copied()
302}
303
304/// Get the SI base unit name for a quantity.
305pub fn base_unit(qty: &str) -> Option<&'static str> {
306    BASE_UNITS.get(qty).copied()
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    #[test]
314    fn units_loaded() {
315        let by_name = units_by_name();
316        assert!(!by_name.is_empty(), "units should be loaded from units.txt");
317        // Should have hundreds of units
318        assert!(
319            by_name.len() > 100,
320            "expected 100+ units, got {}",
321            by_name.len()
322        );
323    }
324
325    #[test]
326    fn unit_lookup_by_name() {
327        let u = unit_for("fahrenheit");
328        assert!(u.is_some(), "fahrenheit should exist");
329        let u = u.unwrap();
330        assert_eq!(u.name, "fahrenheit");
331        assert!(u.symbols.contains(&"°F".to_string()));
332    }
333
334    #[test]
335    fn unit_lookup_by_symbol() {
336        let u = unit_for("°F");
337        assert!(u.is_some(), "°F should resolve");
338        assert_eq!(u.unwrap().name, "fahrenheit");
339    }
340
341    #[test]
342    fn unit_lookup_celsius() {
343        let u = unit_for("celsius");
344        assert!(u.is_some());
345        assert!(u.unwrap().symbols.contains(&"°C".to_string()));
346    }
347
348    #[test]
349    fn unit_not_found() {
350        assert!(unit_for("nonexistent_unit_xyz").is_none());
351    }
352
353    #[test]
354    fn unit_has_quantity() {
355        let u = unit_for("fahrenheit").unwrap();
356        assert!(
357            !u.quantity.is_empty(),
358            "unit should have a quantity category"
359        );
360    }
361
362    // --- conversion tests ---
363
364    #[test]
365    fn unit_convert_f_to_c() {
366        let c = convert(212.0, "fahrenheit", "celsius").unwrap();
367        assert!((c - 100.0).abs() < 0.01, "212°F = 100°C, got {c}");
368    }
369
370    #[test]
371    fn unit_convert_c_to_f() {
372        let f = convert(0.0, "celsius", "fahrenheit").unwrap();
373        assert!((f - 32.0).abs() < 0.01, "0°C = 32°F, got {f}");
374    }
375
376    #[test]
377    fn unit_convert_f_to_k() {
378        // 32°F = 0°C = 273.15 K
379        let k = convert(32.0, "fahrenheit", "kelvin").unwrap();
380        assert!((k - 273.15).abs() < 0.01, "32°F = 273.15K, got {k}");
381    }
382
383    #[test]
384    fn unit_convert_k_to_c() {
385        let c = convert(373.15, "kelvin", "celsius").unwrap();
386        assert!((c - 100.0).abs() < 0.01, "373.15K = 100°C, got {c}");
387    }
388
389    #[test]
390    fn unit_convert_c_to_k() {
391        let k = convert(100.0, "celsius", "kelvin").unwrap();
392        assert!((k - 373.15).abs() < 0.01, "100°C = 373.15K, got {k}");
393    }
394
395    #[test]
396    fn unit_convert_by_symbol() {
397        let c = convert(212.0, "°F", "°C").unwrap();
398        assert!((c - 100.0).abs() < 0.01);
399    }
400
401    #[test]
402    fn unit_convert_psi_to_kpa() {
403        // 1 psi ≈ 6.895 kPa
404        let kpa = convert(1.0, "psi", "kPa").unwrap();
405        assert!(
406            (kpa - 6.894_757).abs() < 0.01,
407            "1 psi ≈ 6.895 kPa, got {kpa}"
408        );
409    }
410
411    #[test]
412    fn unit_convert_bar_to_psi() {
413        // 1 bar ≈ 14.504 psi
414        let psi = convert(1.0, "bar", "psi").unwrap();
415        assert!((psi - 14.504).abs() < 0.1, "1 bar ≈ 14.504 psi, got {psi}");
416    }
417
418    #[test]
419    fn unit_convert_feet_to_meters() {
420        let m = convert(1.0, "foot", "meter").unwrap();
421        assert!((m - 0.3048).abs() < 0.0001);
422    }
423
424    #[test]
425    fn unit_convert_miles_to_km() {
426        let km = convert(1.0, "mile", "kilometer").unwrap();
427        assert!((km - 1.609_344).abs() < 0.001);
428    }
429
430    #[test]
431    fn unit_convert_kwh_to_btu() {
432        // 1 kWh ≈ 3412.14 BTU
433        let btu = convert(1.0, "kilowatt_hour", "btu").unwrap();
434        assert!((btu - 3412.14).abs() < 1.0, "1 kWh ≈ 3412 BTU, got {btu}");
435    }
436
437    #[test]
438    fn unit_convert_gallons_to_liters() {
439        // 1 gal ≈ 3.785 L
440        let l = convert(1.0, "gallon", "liter").unwrap();
441        assert!((l - 3.785).abs() < 0.01);
442    }
443
444    #[test]
445    fn unit_convert_hours_to_seconds() {
446        let s = convert(1.0, "hour", "second").unwrap();
447        assert!((s - 3600.0).abs() < 0.01);
448    }
449
450    #[test]
451    fn unit_convert_identity() {
452        let v = convert(42.0, "celsius", "celsius").unwrap();
453        assert!((v - 42.0).abs() < 1e-10);
454    }
455
456    #[test]
457    fn unit_convert_incompatible() {
458        let err = convert(1.0, "celsius", "meter").unwrap_err();
459        assert!(matches!(err, UnitError::IncompatibleUnits(_, _)));
460    }
461
462    #[test]
463    fn unit_convert_unknown() {
464        let err = convert(1.0, "nonexistent_xyz", "celsius").unwrap_err();
465        assert!(matches!(err, UnitError::UnknownUnit(_)));
466    }
467
468    #[test]
469    fn unit_compatible_same_quantity() {
470        assert!(compatible("fahrenheit", "celsius"));
471        assert!(compatible("°F", "°C"));
472        assert!(compatible("meter", "foot"));
473        assert!(compatible("psi", "bar"));
474    }
475
476    #[test]
477    fn unit_compatible_different_quantity() {
478        assert!(!compatible("celsius", "meter"));
479        assert!(!compatible("watt", "joule"));
480    }
481
482    #[test]
483    fn unit_compatible_unknown() {
484        assert!(!compatible("nonexistent_xyz", "celsius"));
485    }
486
487    #[test]
488    fn unit_quantity_lookup() {
489        assert_eq!(quantity("fahrenheit"), Some("temperature"));
490        assert_eq!(quantity("meter"), Some("length"));
491        assert_eq!(quantity("psi"), Some("pressure"));
492        assert_eq!(quantity("nonexistent_xyz"), None);
493    }
494
495    #[test]
496    fn unit_base_unit_lookup() {
497        assert_eq!(base_unit("temperature"), Some("celsius"));
498        assert_eq!(base_unit("length"), Some("meter"));
499        assert_eq!(base_unit("pressure"), Some("pascal"));
500        assert_eq!(base_unit("energy"), Some("joule"));
501        assert_eq!(base_unit("power"), Some("watt"));
502        assert_eq!(base_unit("volume"), Some("cubic_meter"));
503        assert_eq!(
504            base_unit("volumetric flow"),
505            Some("cubic_meters_per_second")
506        );
507        assert_eq!(base_unit("area"), Some("square_meter"));
508        assert_eq!(base_unit("mass"), Some("kilogram"));
509        assert_eq!(base_unit("time"), Some("second"));
510        assert_eq!(base_unit("electric current"), Some("ampere"));
511        assert_eq!(base_unit("electric potential"), Some("volt"));
512        assert_eq!(base_unit("frequency"), Some("hertz"));
513        assert_eq!(base_unit("illuminance"), Some("lux"));
514        assert_eq!(base_unit("luminous flux"), Some("lumen"));
515        assert_eq!(base_unit("nonexistent"), None);
516    }
517
518    #[test]
519    fn unit_error_display() {
520        let e = UnitError::UnknownUnit("bogus".into());
521        assert_eq!(e.to_string(), "unknown unit: bogus");
522        let e = UnitError::IncompatibleUnits("celsius".into(), "meter".into());
523        assert_eq!(e.to_string(), "incompatible units: celsius and meter");
524    }
525}