Skip to main content

readcon_core/
units.rs

1//! Unit expressions and conversion (metatomic-inspired, SI dimensional analysis).
2//!
3//! Supports named base units and simple products/quotients with `*`, `/`, `^`,
4//! and parentheses (case-insensitive). `unit_conversion_factor(from, to)` returns
5//! the multiplier `x_to = factor * x_from` when dimensions match.
6
7use crate::error::ParseError;
8use std::collections::HashMap;
9
10/// Physical dimension exponents: L, T, M, Q (charge), Θ (temperature).
11#[derive(Clone, Copy, Debug, PartialEq)]
12pub struct Dimension {
13    pub exponents: [f64; 5],
14}
15
16impl Dimension {
17    pub const ZERO: Self = Self {
18        exponents: [0.0; 5],
19    };
20    pub const LENGTH: Self = Self {
21        exponents: [1.0, 0.0, 0.0, 0.0, 0.0],
22    };
23    pub const TIME: Self = Self {
24        exponents: [0.0, 1.0, 0.0, 0.0, 0.0],
25    };
26    pub const MASS: Self = Self {
27        exponents: [0.0, 0.0, 1.0, 0.0, 0.0],
28    };
29    pub const CHARGE: Self = Self {
30        exponents: [0.0, 0.0, 0.0, 1.0, 0.0],
31    };
32
33    fn mul(self, other: Self) -> Self {
34        let mut e = [0.0; 5];
35        for i in 0..5 {
36            e[i] = self.exponents[i] + other.exponents[i];
37        }
38        Self { exponents: e }
39    }
40
41    fn div(self, other: Self) -> Self {
42        let mut e = [0.0; 5];
43        for i in 0..5 {
44            e[i] = self.exponents[i] - other.exponents[i];
45        }
46        Self { exponents: e }
47    }
48
49    fn pow(self, p: f64) -> Self {
50        let mut e = [0.0; 5];
51        for i in 0..5 {
52            e[i] = self.exponents[i] * p;
53        }
54        Self { exponents: e }
55    }
56
57    fn compatible(self, other: Self) -> bool {
58        self.exponents
59            .iter()
60            .zip(other.exponents.iter())
61            .all(|(a, b)| (a - b).abs() < 1e-9)
62    }
63}
64
65/// Parsed unit: SI scale factor (value in SI = factor * value_in_unit) and dimensions.
66#[derive(Clone, Debug)]
67struct UnitValue {
68    /// Multiply quantity in this unit by `si_factor` to get SI.
69    si_factor: f64,
70    dim: Dimension,
71}
72
73fn base_table() -> HashMap<&'static str, UnitValue> {
74    let mut m = HashMap::new();
75    let ins = |m: &mut HashMap<&'static str, UnitValue>, names: &[&'static str], u: UnitValue| {
76        for n in names {
77            m.insert(*n, u.clone());
78        }
79    };
80    // Length → meters
81    ins(
82        &mut m,
83        &["m", "meter", "metre"],
84        UnitValue {
85            si_factor: 1.0,
86            dim: Dimension::LENGTH,
87        },
88    );
89    ins(
90        &mut m,
91        &["angstrom", "a", "å"],
92        UnitValue {
93            si_factor: 1e-10,
94            dim: Dimension::LENGTH,
95        },
96    );
97    ins(
98        &mut m,
99        &["nm", "nanometer"],
100        UnitValue {
101            si_factor: 1e-9,
102            dim: Dimension::LENGTH,
103        },
104    );
105    ins(
106        &mut m,
107        &["bohr", "a0"],
108        UnitValue {
109            si_factor: 5.291_772_109_03e-11,
110            dim: Dimension::LENGTH,
111        },
112    );
113    // Time → seconds
114    ins(
115        &mut m,
116        &["s", "second"],
117        UnitValue {
118            si_factor: 1.0,
119            dim: Dimension::TIME,
120        },
121    );
122    ins(
123        &mut m,
124        &["fs", "femtosecond"],
125        UnitValue {
126            si_factor: 1e-15,
127            dim: Dimension::TIME,
128        },
129    );
130    ins(
131        &mut m,
132        &["ps", "picosecond"],
133        UnitValue {
134            si_factor: 1e-12,
135            dim: Dimension::TIME,
136        },
137    );
138    ins(
139        &mut m,
140        &["ns", "nanosecond"],
141        UnitValue {
142            si_factor: 1e-9,
143            dim: Dimension::TIME,
144        },
145    );
146    // Mass → kg
147    ins(
148        &mut m,
149        &["kg", "kilogram"],
150        UnitValue {
151            si_factor: 1.0,
152            dim: Dimension::MASS,
153        },
154    );
155    ins(
156        &mut m,
157        &["u", "amu", "dalton", "da"],
158        UnitValue {
159            si_factor: 1.660_539_066_60e-27,
160            dim: Dimension::MASS,
161        },
162    );
163    // Energy → joule (M L^2 T^-2)
164    let energy_dim = Dimension::MASS
165        .mul(Dimension::LENGTH.pow(2.0))
166        .div(Dimension::TIME.pow(2.0));
167    ins(
168        &mut m,
169        &["j", "joule"],
170        UnitValue {
171            si_factor: 1.0,
172            dim: energy_dim,
173        },
174    );
175    ins(
176        &mut m,
177        &["ev"],
178        UnitValue {
179            si_factor: 1.602_176_634e-19,
180            dim: energy_dim,
181        },
182    );
183    ins(
184        &mut m,
185        &["mev"],
186        UnitValue {
187            si_factor: 1.602_176_634e-22,
188            dim: energy_dim,
189        },
190    );
191    ins(
192        &mut m,
193        &["hartree", "ha"],
194        UnitValue {
195            si_factor: 4.359_744_722_207_1e-18,
196            dim: energy_dim,
197        },
198    );
199    // Charge
200    ins(
201        &mut m,
202        &["e", "electron_charge"],
203        UnitValue {
204            si_factor: 1.602_176_634e-19,
205            dim: Dimension::CHARGE,
206        },
207    );
208    // 1 mol = N_A entities. `kcal / mol` is then 4184/N_A J per entity
209    // (metatomic: eV -> kcal/mol is ~23.06).
210    ins(
211        &mut m,
212        &["mol"],
213        UnitValue {
214            si_factor: 6.022_140_76e23,
215            dim: Dimension::ZERO,
216        },
217    );
218    ins(
219        &mut m,
220        &["kcal"],
221        UnitValue {
222            si_factor: 4184.0,
223            dim: energy_dim,
224        },
225    );
226    ins(
227        &mut m,
228        &["kj"],
229        UnitValue {
230            si_factor: 1000.0,
231            dim: energy_dim,
232        },
233    );
234    m
235}
236
237/// Parse a unit expression into SI factor and dimension.
238pub fn parse_unit_expression(expr: &str) -> Result<(f64, Dimension), ParseError> {
239    let s: String = expr.chars().filter(|c| !c.is_whitespace()).collect();
240    if s.is_empty() {
241        return Err(ParseError::ValidationError("empty unit expression".into()));
242    }
243    let table = base_table();
244    parse_expr(&s.to_ascii_lowercase(), &table)
245}
246
247fn parse_expr(s: &str, table: &HashMap<&str, UnitValue>) -> Result<(f64, Dimension), ParseError> {
248    // Split on * and / with left-associative scan; handle ^N on atoms.
249    let mut factor = 1.0_f64;
250    let mut dim = Dimension::ZERO;
251    let mut i = 0;
252    let bytes = s.as_bytes();
253    let mut pending_div = false;
254    while i < bytes.len() {
255        if bytes[i] == b'*' {
256            i += 1;
257            continue;
258        }
259        if bytes[i] == b'/' {
260            pending_div = true;
261            i += 1;
262            continue;
263        }
264        let (atom_f, atom_d, consumed) = parse_atom(&s[i..], table)?;
265        i += consumed;
266        if pending_div {
267            factor /= atom_f;
268            dim = dim.div(atom_d);
269            pending_div = false;
270        } else {
271            factor *= atom_f;
272            dim = dim.mul(atom_d);
273        }
274    }
275    Ok((factor, dim))
276}
277
278fn parse_atom(
279    s: &str,
280    table: &HashMap<&str, UnitValue>,
281) -> Result<(f64, Dimension, usize), ParseError> {
282    let bytes = s.as_bytes();
283    if bytes.is_empty() {
284        return Err(ParseError::ValidationError(
285            "trailing operator in unit".into(),
286        ));
287    }
288    if bytes[0] == b'(' {
289        let mut depth = 0;
290        for (j, &b) in bytes.iter().enumerate() {
291            if b == b'(' {
292                depth += 1;
293            } else if b == b')' {
294                depth -= 1;
295                if depth == 0 {
296                    let inner = &s[1..j];
297                    let (f, d) = parse_expr(inner, table)?;
298                    let mut end = j + 1;
299                    let (f2, d2, extra) = apply_power(f, d, &s[end..])?;
300                    end += extra;
301                    return Ok((f2, d2, end));
302                }
303            }
304        }
305        return Err(ParseError::ValidationError("unbalanced '(' in unit".into()));
306    }
307    // identifier [ ^ number ]
308    let mut j = 0;
309    while j < bytes.len()
310        && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_' || bytes[j] > 127)
311    {
312        j += 1;
313    }
314    if j == 0 {
315        return Err(ParseError::ValidationError(format!(
316            "expected unit name in '{s}'"
317        )));
318    }
319    let name = &s[..j];
320    let base = table
321        .get(name)
322        .ok_or_else(|| ParseError::ValidationError(format!("unknown unit '{name}'")))?;
323    let mut end = j;
324    let (f, d, extra) = apply_power(base.si_factor, base.dim, &s[end..])?;
325    end += extra;
326    Ok((f, d, end))
327}
328
329fn apply_power(f: f64, d: Dimension, rest: &str) -> Result<(f64, Dimension, usize), ParseError> {
330    let bytes = rest.as_bytes();
331    if bytes.first() == Some(&b'^') {
332        let mut k = 1;
333        let neg = bytes.get(1) == Some(&b'-');
334        if neg {
335            k = 2;
336        }
337        let start = k;
338        while k < bytes.len() && (bytes[k].is_ascii_digit() || bytes[k] == b'.') {
339            k += 1;
340        }
341        if k == start {
342            return Err(ParseError::ValidationError(
343                "expected exponent after ^".into(),
344            ));
345        }
346        let mut p: f64 = rest[start..k]
347            .parse()
348            .map_err(|_| ParseError::ValidationError("invalid unit exponent".into()))?;
349        if neg {
350            p = -p;
351        }
352        return Ok((f.powf(p), d.pow(p), k));
353    }
354    Ok((f, d, 0))
355}
356
357/// Multiplicative factor: `value_in_to = factor * value_in_from`.
358pub fn unit_conversion_factor(from_unit: &str, to_unit: &str) -> Result<f64, ParseError> {
359    let (f_from, d_from) = parse_unit_expression(from_unit)?;
360    let (f_to, d_to) = parse_unit_expression(to_unit)?;
361    if !d_from.compatible(d_to) {
362        return Err(ParseError::ValidationError(format!(
363            "incompatible units '{from_unit}' and '{to_unit}'"
364        )));
365    }
366    Ok(f_from / f_to)
367}
368
369/// Validate that `unit` has dimensions appropriate for `quantity`
370/// (`length`, `energy`, `mass`, `time`, `velocity`, `force`).
371pub fn validate_unit_for_quantity(quantity: &str, unit: &str) -> Result<(), ParseError> {
372    let (_, dim) = parse_unit_expression(unit)?;
373    let expect = match quantity {
374        "length" => Dimension::LENGTH,
375        "time" => Dimension::TIME,
376        "mass" => Dimension::MASS,
377        "energy" => Dimension::MASS
378            .mul(Dimension::LENGTH.pow(2.0))
379            .div(Dimension::TIME.pow(2.0)),
380        "velocity" => Dimension::LENGTH.div(Dimension::TIME),
381        "force" => Dimension::MASS
382            .mul(Dimension::LENGTH)
383            .div(Dimension::TIME.pow(2.0)),
384        _ => {
385            return Err(ParseError::ValidationError(format!(
386                "unknown quantity '{quantity}'"
387            )));
388        }
389    };
390    if !dim.compatible(expect) {
391        return Err(ParseError::ValidationError(format!(
392            "unit '{unit}' is not valid for quantity '{quantity}'"
393        )));
394    }
395    Ok(())
396}
397
398/// CON v3 requires `units` object with non-empty `length` and `energy` strings.
399pub fn validate_v3_units_metadata(units: &serde_json::Value) -> Result<(), ParseError> {
400    let obj = units
401        .as_object()
402        .ok_or_else(|| ParseError::ValidationError("units must be a JSON object".into()))?;
403    for key in ["length", "energy"] {
404        let Some(v) = obj.get(key) else {
405            return Err(ParseError::ValidationError(format!(
406                "v3 units must include non-empty '{key}'"
407            )));
408        };
409        let Some(s) = v.as_str() else {
410            return Err(ParseError::ValidationError(format!(
411                "units.{key} must be a string"
412            )));
413        };
414        if s.trim().is_empty() {
415            return Err(ParseError::ValidationError(format!(
416                "units.{key} must be non-empty"
417            )));
418        }
419        validate_unit_for_quantity(key, s)?;
420    }
421    // Optional keys if present must be valid for their quantity
422    for (key, qty) in [
423        ("mass", "mass"),
424        ("time", "time"),
425        ("velocity", "velocity"),
426        ("force", "force"),
427    ] {
428        if let Some(v) = obj.get(key) {
429            let s = v.as_str().ok_or_else(|| {
430                ParseError::ValidationError(format!("units.{key} must be a string"))
431            })?;
432            validate_unit_for_quantity(qty, s)?;
433        }
434    }
435    Ok(())
436}
437
438/// Default LODE units object for new v3 frames.
439pub fn default_v3_units_json() -> serde_json::Value {
440    serde_json::json!({
441        "length": "angstrom",
442        "energy": "eV",
443        "mass": "amu",
444        "time": "fs"
445    })
446}
447
448fn preferred_atom(alias: &str) -> Option<&'static str> {
449    Some(match alias.trim().to_ascii_lowercase().as_str() {
450        "a" | "å" | "angstrom" | "ångstrom" => "angstrom",
451        "nm" | "nanometer" | "nanometre" => "nm",
452        "m" | "meter" | "metre" => "m",
453        "bohr" | "a0" => "bohr",
454        "fs" | "femtosecond" | "femtoseconds" => "fs",
455        "ps" | "picosecond" | "picoseconds" => "ps",
456        "ns" | "nanosecond" | "nanoseconds" => "ns",
457        "s" | "sec" | "second" | "seconds" => "s",
458        "ev" => "eV",
459        "mev" => "meV",
460        "hartree" | "ha" => "hartree",
461        "j" | "joule" => "J",
462        "kj" => "kJ",
463        "kcal" => "kcal",
464        "amu" | "u" | "dalton" | "da" => "amu",
465        "kg" | "kilogram" => "kg",
466        "mol" => "mol",
467        _ => return None,
468    })
469}
470
471/// Rewrite a unit expression to preferred names (`A` → `angstrom`, `ev` → `eV`).
472/// The expression MUST parse (`unit_conversion_factor` identity).
473pub fn canonicalize_unit_expression(expr: &str) -> Result<String, ParseError> {
474    let trimmed = expr.trim();
475    if trimmed.is_empty() {
476        return Err(ParseError::ValidationError("empty unit".into()));
477    }
478    let _ = unit_conversion_factor(trimmed, trimmed)?;
479    let compact: String = trimmed.chars().filter(|c| !c.is_whitespace()).collect();
480    let mut out = String::new();
481    let bytes = compact.as_bytes();
482    let mut i = 0;
483    while i < bytes.len() {
484        let b = bytes[i];
485        if matches!(b, b'*' | b'/' | b'(' | b')' | b'^') {
486            if !out.is_empty() && !out.ends_with(' ') && b != b')' && b != b'^' {
487                out.push(' ');
488            }
489            out.push(b as char);
490            if b != b'(' && b != b'^' {
491                out.push(' ');
492            }
493            i += 1;
494            continue;
495        }
496        if b == b'-' || b.is_ascii_digit() || b == b'.' {
497            out.push(b as char);
498            i += 1;
499            continue;
500        }
501        let start = i;
502        while i < bytes.len() && (bytes[i].is_ascii_alphabetic() || bytes[i] > 127) {
503            i += 1;
504        }
505        if start == i {
506            return Err(ParseError::ValidationError(format!(
507                "bad unit token in '{expr}'"
508            )));
509        }
510        let atom = &compact[start..i];
511        out.push_str(preferred_atom(atom).unwrap_or(atom));
512    }
513    Ok(out.split_whitespace().collect::<Vec<_>>().join(" "))
514}
515
516/// Rewrite every string member. Does not require v3 `length`+`energy`.
517pub fn canonicalize_units_object(
518    units: &serde_json::Value,
519) -> Result<serde_json::Value, ParseError> {
520    let obj = units.as_object().ok_or_else(|| {
521        ParseError::ValidationError("units must be a JSON object".into())
522    })?;
523    let mut out = serde_json::Map::new();
524    for (k, v) in obj {
525        let s = v.as_str().ok_or_else(|| {
526            ParseError::ValidationError(format!("units.{k} must be a string"))
527        })?;
528        out.insert(
529            k.clone(),
530            serde_json::Value::String(canonicalize_unit_expression(s)?),
531        );
532    }
533    Ok(serde_json::Value::Object(out))
534}
535
536/// v3 validate then canonicalize (preferred names on line 2).
537pub fn canonicalize_units_metadata(
538    units: &serde_json::Value,
539) -> Result<serde_json::Value, ParseError> {
540    validate_v3_units_metadata(units)?;
541    canonicalize_units_object(units)
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547
548    #[test]
549    fn angstrom_to_nm() {
550        let f = unit_conversion_factor("angstrom", "nm").unwrap();
551        assert!((f - 0.1).abs() < 1e-12);
552    }
553
554    #[test]
555    fn ev_to_mev() {
556        let f = unit_conversion_factor("eV", "meV").unwrap();
557        assert!((f - 1000.0).abs() < 1e-6);
558    }
559
560    #[test]
561    fn dimensional_mismatch() {
562        assert!(unit_conversion_factor("angstrom", "eV").is_err());
563    }
564
565    #[test]
566    fn ev_to_kcal_per_mol_is_metatomic() {
567        let f = unit_conversion_factor("eV", "kcal / mol").unwrap();
568        assert!((f - 23.060_548).abs() < 1e-3, "got {f}");
569        let force = unit_conversion_factor("eV / angstrom", "kJ / mol / angstrom").unwrap();
570        assert!((force - 96.485_332).abs() < 1e-3, "got {force}");
571        let ns = unit_conversion_factor("ns", "ps").unwrap();
572        assert!((ns - 1000.0).abs() < 1e-9, "got {ns}");
573    }
574
575    #[test]
576    fn validate_length_energy() {
577        validate_unit_for_quantity("length", "nm").unwrap();
578        validate_unit_for_quantity("energy", "hartree").unwrap();
579        assert!(validate_unit_for_quantity("energy", "angstrom").is_err());
580    }
581
582    #[test]
583    fn v3_units_require_length_energy() {
584        assert!(validate_v3_units_metadata(&serde_json::json!({"length": "A"})).is_err());
585        validate_v3_units_metadata(&default_v3_units_json()).unwrap();
586    }
587
588    #[test]
589    fn canonicalize_aliases() {
590        assert_eq!(canonicalize_unit_expression("A").unwrap(), "angstrom");
591        assert_eq!(canonicalize_unit_expression("ev").unwrap(), "eV");
592        assert_eq!(canonicalize_unit_expression("femtosecond").unwrap(), "fs");
593        assert_eq!(
594            canonicalize_unit_expression("eV/angstrom").unwrap(),
595            "eV / angstrom"
596        );
597        let u = canonicalize_units_metadata(&serde_json::json!({
598            "length": "A",
599            "energy": "ev",
600            "time": "femtosecond"
601        }))
602        .unwrap();
603        assert_eq!(u["length"], "angstrom");
604        assert_eq!(u["energy"], "eV");
605        assert_eq!(u["time"], "fs");
606    }
607}