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    // CON-units.16 names are case-insensitive, including `Å`.
245    parse_expr(&s.to_lowercase(), &table)
246}
247
248fn parse_expr(s: &str, table: &HashMap<&str, UnitValue>) -> Result<(f64, Dimension), ParseError> {
249    // Split on * and / with left-associative scan; handle ^N on atoms.
250    let mut factor = 1.0_f64;
251    let mut dim = Dimension::ZERO;
252    let mut i = 0;
253    let bytes = s.as_bytes();
254    let mut pending_div = false;
255    while i < bytes.len() {
256        if bytes[i] == b'*' {
257            i += 1;
258            continue;
259        }
260        if bytes[i] == b'/' {
261            pending_div = true;
262            i += 1;
263            continue;
264        }
265        let (atom_f, atom_d, consumed) = parse_atom(&s[i..], table)?;
266        i += consumed;
267        if pending_div {
268            factor /= atom_f;
269            dim = dim.div(atom_d);
270            pending_div = false;
271        } else {
272            factor *= atom_f;
273            dim = dim.mul(atom_d);
274        }
275    }
276    Ok((factor, dim))
277}
278
279fn parse_atom(
280    s: &str,
281    table: &HashMap<&str, UnitValue>,
282) -> Result<(f64, Dimension, usize), ParseError> {
283    let bytes = s.as_bytes();
284    if bytes.is_empty() {
285        return Err(ParseError::ValidationError(
286            "trailing operator in unit".into(),
287        ));
288    }
289    if bytes[0] == b'(' {
290        let mut depth = 0;
291        for (j, &b) in bytes.iter().enumerate() {
292            if b == b'(' {
293                depth += 1;
294            } else if b == b')' {
295                depth -= 1;
296                if depth == 0 {
297                    let inner = &s[1..j];
298                    let (f, d) = parse_expr(inner, table)?;
299                    let mut end = j + 1;
300                    let (f2, d2, extra) = apply_power(f, d, &s[end..])?;
301                    end += extra;
302                    return Ok((f2, d2, end));
303                }
304            }
305        }
306        return Err(ParseError::ValidationError("unbalanced '(' in unit".into()));
307    }
308    // identifier [ ^ number ]
309    let mut j = 0;
310    while j < bytes.len()
311        && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_' || bytes[j] > 127)
312    {
313        j += 1;
314    }
315    if j == 0 {
316        return Err(ParseError::ValidationError(format!(
317            "expected unit name in '{s}'"
318        )));
319    }
320    let name = &s[..j];
321    let base = table
322        .get(name)
323        .ok_or_else(|| ParseError::ValidationError(format!("unknown unit '{name}'")))?;
324    let mut end = j;
325    let (f, d, extra) = apply_power(base.si_factor, base.dim, &s[end..])?;
326    end += extra;
327    Ok((f, d, end))
328}
329
330fn apply_power(f: f64, d: Dimension, rest: &str) -> Result<(f64, Dimension, usize), ParseError> {
331    let bytes = rest.as_bytes();
332    if bytes.first() == Some(&b'^') {
333        let mut k = 1;
334        let neg = bytes.get(1) == Some(&b'-');
335        if neg {
336            k = 2;
337        }
338        let start = k;
339        while k < bytes.len() && (bytes[k].is_ascii_digit() || bytes[k] == b'.') {
340            k += 1;
341        }
342        if k == start {
343            return Err(ParseError::ValidationError(
344                "expected exponent after ^".into(),
345            ));
346        }
347        let mut p: f64 = rest[start..k]
348            .parse()
349            .map_err(|_| ParseError::ValidationError("invalid unit exponent".into()))?;
350        if neg {
351            p = -p;
352        }
353        return Ok((f.powf(p), d.pow(p), k));
354    }
355    Ok((f, d, 0))
356}
357
358/// Multiplicative factor: `value_in_to = factor * value_in_from`.
359pub fn unit_conversion_factor(from_unit: &str, to_unit: &str) -> Result<f64, ParseError> {
360    let (f_from, d_from) = parse_unit_expression(from_unit)?;
361    let (f_to, d_to) = parse_unit_expression(to_unit)?;
362    if !d_from.compatible(d_to) {
363        return Err(ParseError::ValidationError(format!(
364            "incompatible units '{from_unit}' and '{to_unit}'"
365        )));
366    }
367    Ok(f_from / f_to)
368}
369
370/// Time unit that velocities in this frame are stored in (D10).
371///
372/// Versions 1, 2, and 3.0 are the natural unit regardless of a
373/// `units.time` label (CON-units.8, CON-units.9). From minor 1,
374/// `units.time` is authoritative (CON-units.10). `override_unit`
375/// replaces that choice when it is a time expression (CON-units.15).
376pub fn velocity_file_time_unit(
377    spec_version: u32,
378    metadata: &serde_json::Map<String, serde_json::Value>,
379    override_unit: Option<&str>,
380) -> Result<String, ParseError> {
381    if let Some(unit) = override_unit {
382        validate_unit_for_quantity("time", unit)?;
383        return Ok(unit.to_string());
384    }
385    let minor = metadata
386        .get(crate::types::meta::CON_SPEC_MINOR)
387        .and_then(|v| v.as_u64())
388        .unwrap_or(0);
389    if spec_version < 3 || minor == 0 {
390        return Ok(NATURAL_TIME_UNIT.to_string());
391    }
392    let time = metadata
393        .get(crate::types::meta::UNITS)
394        .and_then(|u| u.get("time"))
395        .and_then(|t| t.as_str())
396        .filter(|s| !s.is_empty());
397    match time {
398        Some(unit) => {
399            validate_unit_for_quantity("time", unit)?;
400            Ok(unit.to_string())
401        }
402        None => Err(ParseError::InvalidUnits(
403            "v3.1 velocities require units.time".into(),
404        )),
405    }
406}
407
408/// Multiply a file velocity by this to land in [`NATURAL_TIME_UNIT`].
409///
410/// Velocity scales as 1/time, so the factor is the reciprocal of
411/// [`unit_conversion_factor`] from the file time unit to the natural unit.
412pub fn velocity_scale_to_natural(file_time: &str) -> Result<f64, ParseError> {
413    validate_unit_for_quantity("time", file_time)?;
414    let factor = unit_conversion_factor(file_time, NATURAL_TIME_UNIT)?;
415    if factor == 0.0 || !factor.is_finite() {
416        return Err(ParseError::InvalidUnits(format!(
417            "time unit '{file_time}' has a zero scale"
418        )));
419    }
420    Ok(1.0 / factor)
421}
422
423/// Validate that `unit` has dimensions appropriate for `quantity`
424/// (`length`, `energy`, `mass`, `time`, `velocity`, `force`).
425pub fn validate_unit_for_quantity(quantity: &str, unit: &str) -> Result<(), ParseError> {
426    let (_, dim) = parse_unit_expression(unit)?;
427    let expect = match quantity {
428        "length" => Dimension::LENGTH,
429        "time" => Dimension::TIME,
430        "mass" => Dimension::MASS,
431        "energy" => Dimension::MASS
432            .mul(Dimension::LENGTH.pow(2.0))
433            .div(Dimension::TIME.pow(2.0)),
434        "velocity" => Dimension::LENGTH.div(Dimension::TIME),
435        "force" => Dimension::MASS
436            .mul(Dimension::LENGTH)
437            .div(Dimension::TIME.pow(2.0)),
438        _ => {
439            return Err(ParseError::ValidationError(format!(
440                "unknown quantity '{quantity}'"
441            )));
442        }
443    };
444    if !dim.compatible(expect) {
445        return Err(ParseError::ValidationError(format!(
446            "unit '{unit}' is not valid for quantity '{quantity}'"
447        )));
448    }
449    Ok(())
450}
451
452/// CON v3 requires `units` object with non-empty `length` and `energy` strings.
453pub fn validate_v3_units_metadata(units: &serde_json::Value) -> Result<(), ParseError> {
454    let obj = units
455        .as_object()
456        .ok_or_else(|| ParseError::ValidationError("units must be a JSON object".into()))?;
457    for key in ["length", "energy"] {
458        let Some(v) = obj.get(key) else {
459            return Err(ParseError::ValidationError(format!(
460                "v3 units must include non-empty '{key}'"
461            )));
462        };
463        let Some(s) = v.as_str() else {
464            return Err(ParseError::ValidationError(format!(
465                "units.{key} must be a string"
466            )));
467        };
468        if s.trim().is_empty() {
469            return Err(ParseError::ValidationError(format!(
470                "units.{key} must be non-empty"
471            )));
472        }
473        validate_unit_for_quantity(key, s)?;
474    }
475    // Optional keys if present must be valid for their quantity
476    for (key, qty) in [
477        ("mass", "mass"),
478        ("time", "time"),
479        ("velocity", "velocity"),
480        ("force", "force"),
481    ] {
482        if let Some(v) = obj.get(key) {
483            let s = v.as_str().ok_or_else(|| {
484                ParseError::ValidationError(format!("units.{key} must be a string"))
485            })?;
486            validate_unit_for_quantity(qty, s)?;
487        }
488    }
489    Ok(())
490}
491
492/// Default LODE units object for new v3 frames.
493/// Pinned spelling of the eOn/ASE natural time unit (CON-units.5, D10).
494pub const NATURAL_TIME_UNIT: &str = "(amu*angstrom^2/eV)^0.5";
495
496pub fn default_v3_units_json() -> serde_json::Value {
497    serde_json::json!({
498        "length": "angstrom",
499        "energy": "eV",
500        "mass": "amu"
501    })
502}
503
504fn preferred_atom(alias: &str) -> Option<&'static str> {
505    let lower = alias.trim().to_lowercase();
506    Some(match lower.as_str() {
507        "a" | "å" | "angstrom" | "ångstrom" => "angstrom",
508        "nm" | "nanometer" | "nanometre" => "nm",
509        "m" | "meter" | "metre" => "m",
510        "bohr" | "a0" => "bohr",
511        "fs" | "femtosecond" | "femtoseconds" => "fs",
512        "ps" | "picosecond" | "picoseconds" => "ps",
513        "ns" | "nanosecond" | "nanoseconds" => "ns",
514        "s" | "sec" | "second" | "seconds" => "s",
515        "ev" => "eV",
516        "mev" => "meV",
517        "hartree" | "ha" => "hartree",
518        "j" | "joule" => "J",
519        "kj" => "kJ",
520        "kcal" => "kcal",
521        "amu" | "u" | "dalton" | "da" => "amu",
522        "kg" | "kilogram" => "kg",
523        "mol" => "mol",
524        _ => return None,
525    })
526}
527
528/// Rewrite a unit expression to preferred names (`A` → `angstrom`, `ev` → `eV`).
529/// The expression MUST parse (`unit_conversion_factor` identity).
530pub fn canonicalize_unit_expression(expr: &str) -> Result<String, ParseError> {
531    let trimmed = expr.trim();
532    if trimmed.is_empty() {
533        return Err(ParseError::ValidationError("empty unit".into()));
534    }
535    let _ = unit_conversion_factor(trimmed, trimmed)?;
536    let compact: String = trimmed.chars().filter(|c| !c.is_whitespace()).collect();
537    if compact == NATURAL_TIME_UNIT {
538        return Ok(NATURAL_TIME_UNIT.to_string());
539    }
540    let mut out = String::new();
541    let bytes = compact.as_bytes();
542    let mut i = 0;
543    while i < bytes.len() {
544        let b = bytes[i];
545        if matches!(b, b'*' | b'/' | b'(' | b')' | b'^') {
546            if !out.is_empty() && !out.ends_with(' ') && b != b')' && b != b'^' {
547                out.push(' ');
548            }
549            out.push(b as char);
550            if b != b'(' && b != b'^' {
551                out.push(' ');
552            }
553            i += 1;
554            continue;
555        }
556        if b == b'-' || b.is_ascii_digit() || b == b'.' {
557            out.push(b as char);
558            i += 1;
559            continue;
560        }
561        let start = i;
562        while i < bytes.len() && (bytes[i].is_ascii_alphabetic() || bytes[i] > 127) {
563            i += 1;
564        }
565        if start == i {
566            return Err(ParseError::ValidationError(format!(
567                "bad unit token in '{expr}'"
568            )));
569        }
570        let atom = &compact[start..i];
571        out.push_str(preferred_atom(atom).unwrap_or(atom));
572    }
573    let rendered = out.split_whitespace().collect::<Vec<_>>().join(" ");
574    let rendered_compact: String = rendered.chars().filter(|c| !c.is_whitespace()).collect();
575    if rendered_compact == NATURAL_TIME_UNIT {
576        return Ok(NATURAL_TIME_UNIT.to_string());
577    }
578    Ok(rendered)
579}
580
581/// Rewrite every string member. Does not require v3 `length`+`energy`.
582pub fn canonicalize_units_object(
583    units: &serde_json::Value,
584) -> Result<serde_json::Value, ParseError> {
585    let obj = units
586        .as_object()
587        .ok_or_else(|| ParseError::ValidationError("units must be a JSON object".into()))?;
588    let mut out = serde_json::Map::new();
589    for (k, v) in obj {
590        let s = v
591            .as_str()
592            .ok_or_else(|| ParseError::ValidationError(format!("units.{k} must be a string")))?;
593        out.insert(
594            k.clone(),
595            serde_json::Value::String(canonicalize_unit_expression(s)?),
596        );
597    }
598    Ok(serde_json::Value::Object(out))
599}
600
601/// v3 validate then canonicalize (preferred names on line 2).
602pub fn canonicalize_units_metadata(
603    units: &serde_json::Value,
604) -> Result<serde_json::Value, ParseError> {
605    validate_v3_units_metadata(units)?;
606    canonicalize_units_object(units)
607}
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612
613    #[test]
614    fn angstrom_to_nm() {
615        let f = unit_conversion_factor("angstrom", "nm").unwrap();
616        assert!((f - 0.1).abs() < 1e-12);
617    }
618
619    #[test]
620    fn ev_to_mev() {
621        let f = unit_conversion_factor("eV", "meV").unwrap();
622        assert!((f - 1000.0).abs() < 1e-6);
623    }
624
625    #[test]
626    fn dimensional_mismatch() {
627        assert!(unit_conversion_factor("angstrom", "eV").is_err());
628    }
629
630    #[test]
631    fn ev_to_kcal_per_mol_is_metatomic() {
632        let f = unit_conversion_factor("eV", "kcal / mol").unwrap();
633        assert!((f - 23.060_548).abs() < 1e-3, "got {f}");
634        let force = unit_conversion_factor("eV / angstrom", "kJ / mol / angstrom").unwrap();
635        assert!((force - 96.485_332).abs() < 1e-3, "got {force}");
636        let ns = unit_conversion_factor("ns", "ps").unwrap();
637        assert!((ns - 1000.0).abs() < 1e-9, "got {ns}");
638    }
639
640    #[test]
641    fn validate_length_energy() {
642        validate_unit_for_quantity("length", "nm").unwrap();
643        validate_unit_for_quantity("energy", "hartree").unwrap();
644        assert!(validate_unit_for_quantity("energy", "angstrom").is_err());
645    }
646
647    #[test]
648    fn v3_units_require_length_energy() {
649        assert!(validate_v3_units_metadata(&serde_json::json!({"length": "A"})).is_err());
650        validate_v3_units_metadata(&default_v3_units_json()).unwrap();
651    }
652
653    #[test]
654    fn default_v3_units_do_not_invent_time_fs() {
655        let units = default_v3_units_json();
656        assert!(units.get("time").is_none(), "{units}");
657        assert_eq!(units["length"], "angstrom");
658        assert_eq!(units["energy"], "eV");
659        assert_eq!(units["mass"], "amu");
660    }
661
662    #[test]
663    fn canonicalize_aliases() {
664        assert_eq!(canonicalize_unit_expression("A").unwrap(), "angstrom");
665        assert_eq!(canonicalize_unit_expression("ev").unwrap(), "eV");
666        assert_eq!(canonicalize_unit_expression("femtosecond").unwrap(), "fs");
667        assert_eq!(
668            canonicalize_unit_expression("eV/angstrom").unwrap(),
669            "eV / angstrom"
670        );
671        let u = canonicalize_units_metadata(&serde_json::json!({
672            "length": "A",
673            "energy": "ev",
674            "time": "femtosecond"
675        }))
676        .unwrap();
677        assert_eq!(u["length"], "angstrom");
678        assert_eq!(u["energy"], "eV");
679        assert_eq!(u["time"], "fs");
680    }
681
682    #[test]
683    fn natural_time_spelling_is_pinned() {
684        let spaced = "( amu * angstrom ^ 2 / eV ) ^ 0.5";
685        assert_eq!(
686            canonicalize_unit_expression(spaced).unwrap(),
687            NATURAL_TIME_UNIT
688        );
689        assert!(!NATURAL_TIME_UNIT.contains(' '));
690        let factor = unit_conversion_factor(NATURAL_TIME_UNIT, "fs").unwrap();
691        assert!((factor - 10.180_505_710_759_414).abs() < 1e-9, "{factor}");
692        let mixed = "(AMU * angstrom^2 / ev)^0.5";
693        assert_eq!(
694            canonicalize_unit_expression(mixed).unwrap(),
695            NATURAL_TIME_UNIT
696        );
697    }
698}