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    // Mass → kg
139    ins(
140        &mut m,
141        &["kg", "kilogram"],
142        UnitValue {
143            si_factor: 1.0,
144            dim: Dimension::MASS,
145        },
146    );
147    ins(
148        &mut m,
149        &["u", "amu", "dalton", "da"],
150        UnitValue {
151            si_factor: 1.660_539_066_60e-27,
152            dim: Dimension::MASS,
153        },
154    );
155    // Energy → joule (M L^2 T^-2)
156    let energy_dim = Dimension::MASS
157        .mul(Dimension::LENGTH.pow(2.0))
158        .div(Dimension::TIME.pow(2.0));
159    ins(
160        &mut m,
161        &["j", "joule"],
162        UnitValue {
163            si_factor: 1.0,
164            dim: energy_dim,
165        },
166    );
167    ins(
168        &mut m,
169        &["ev"],
170        UnitValue {
171            si_factor: 1.602_176_634e-19,
172            dim: energy_dim,
173        },
174    );
175    ins(
176        &mut m,
177        &["mev"],
178        UnitValue {
179            si_factor: 1.602_176_634e-22,
180            dim: energy_dim,
181        },
182    );
183    ins(
184        &mut m,
185        &["hartree", "ha"],
186        UnitValue {
187            si_factor: 4.359_744_722_207_1e-18,
188            dim: energy_dim,
189        },
190    );
191    // Charge
192    ins(
193        &mut m,
194        &["e", "electron_charge"],
195        UnitValue {
196            si_factor: 1.602_176_634e-19,
197            dim: Dimension::CHARGE,
198        },
199    );
200    // mol (dimensionless for our 5-vector — treat as ZERO so kcal/mol works as energy/mol scale)
201    ins(
202        &mut m,
203        &["mol"],
204        UnitValue {
205            si_factor: 1.0 / 6.022_140_76e23,
206            dim: Dimension::ZERO,
207        },
208    );
209    ins(
210        &mut m,
211        &["kcal"],
212        UnitValue {
213            si_factor: 4184.0,
214            dim: energy_dim,
215        },
216    );
217    ins(
218        &mut m,
219        &["kj"],
220        UnitValue {
221            si_factor: 1000.0,
222            dim: energy_dim,
223        },
224    );
225    m
226}
227
228/// Parse a unit expression into SI factor and dimension.
229pub fn parse_unit_expression(expr: &str) -> Result<(f64, Dimension), ParseError> {
230    let s: String = expr.chars().filter(|c| !c.is_whitespace()).collect();
231    if s.is_empty() {
232        return Err(ParseError::ValidationError("empty unit expression".into()));
233    }
234    let table = base_table();
235    parse_expr(&s.to_ascii_lowercase(), &table)
236}
237
238fn parse_expr(
239    s: &str,
240    table: &HashMap<&str, UnitValue>,
241) -> Result<(f64, Dimension), ParseError> {
242    // Split on * and / with left-associative scan; handle ^N on atoms.
243    let mut factor = 1.0_f64;
244    let mut dim = Dimension::ZERO;
245    let mut i = 0;
246    let bytes = s.as_bytes();
247    let mut pending_div = false;
248    while i < bytes.len() {
249        if bytes[i] == b'*' {
250            i += 1;
251            continue;
252        }
253        if bytes[i] == b'/' {
254            pending_div = true;
255            i += 1;
256            continue;
257        }
258        let (atom_f, atom_d, consumed) = parse_atom(&s[i..], table)?;
259        i += consumed;
260        if pending_div {
261            factor /= atom_f;
262            dim = dim.div(atom_d);
263            pending_div = false;
264        } else {
265            factor *= atom_f;
266            dim = dim.mul(atom_d);
267        }
268    }
269    Ok((factor, dim))
270}
271
272fn parse_atom(
273    s: &str,
274    table: &HashMap<&str, UnitValue>,
275) -> Result<(f64, Dimension, usize), ParseError> {
276    let bytes = s.as_bytes();
277    if bytes.is_empty() {
278        return Err(ParseError::ValidationError("trailing operator in unit".into()));
279    }
280    if bytes[0] == b'(' {
281        let mut depth = 0;
282        for (j, &b) in bytes.iter().enumerate() {
283            if b == b'(' {
284                depth += 1;
285            } else if b == b')' {
286                depth -= 1;
287                if depth == 0 {
288                    let inner = &s[1..j];
289                    let (f, d) = parse_expr(inner, table)?;
290                    let mut end = j + 1;
291                    let (f2, d2, extra) = apply_power(f, d, &s[end..])?;
292                    end += extra;
293                    return Ok((f2, d2, end));
294                }
295            }
296        }
297        return Err(ParseError::ValidationError("unbalanced '(' in unit".into()));
298    }
299    // identifier [ ^ number ]
300    let mut j = 0;
301    while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_' || bytes[j] > 127)
302    {
303        j += 1;
304    }
305    if j == 0 {
306        return Err(ParseError::ValidationError(format!(
307            "expected unit name in '{s}'"
308        )));
309    }
310    let name = &s[..j];
311    let base = table.get(name).ok_or_else(|| {
312        ParseError::ValidationError(format!("unknown unit '{name}'"))
313    })?;
314    let mut end = j;
315    let (f, d, extra) = apply_power(base.si_factor, base.dim, &s[end..])?;
316    end += extra;
317    Ok((f, d, end))
318}
319
320fn apply_power(f: f64, d: Dimension, rest: &str) -> Result<(f64, Dimension, usize), ParseError> {
321    let bytes = rest.as_bytes();
322    if bytes.first() == Some(&b'^') {
323        let mut k = 1;
324        let neg = bytes.get(1) == Some(&b'-');
325        if neg {
326            k = 2;
327        }
328        let start = k;
329        while k < bytes.len() && (bytes[k].is_ascii_digit() || bytes[k] == b'.') {
330            k += 1;
331        }
332        if k == start {
333            return Err(ParseError::ValidationError("expected exponent after ^".into()));
334        }
335        let mut p: f64 = rest[start..k].parse().map_err(|_| {
336            ParseError::ValidationError("invalid unit exponent".into())
337        })?;
338        if neg {
339            p = -p;
340        }
341        return Ok((f.powf(p), d.pow(p), k));
342    }
343    Ok((f, d, 0))
344}
345
346/// Multiplicative factor: `value_in_to = factor * value_in_from`.
347pub fn unit_conversion_factor(from_unit: &str, to_unit: &str) -> Result<f64, ParseError> {
348    let (f_from, d_from) = parse_unit_expression(from_unit)?;
349    let (f_to, d_to) = parse_unit_expression(to_unit)?;
350    if !d_from.compatible(d_to) {
351        return Err(ParseError::ValidationError(format!(
352            "incompatible units '{from_unit}' and '{to_unit}'"
353        )));
354    }
355    Ok(f_from / f_to)
356}
357
358/// Validate that `unit` has dimensions appropriate for `quantity`
359/// (`length`, `energy`, `mass`, `time`, `velocity`, `force`).
360pub fn validate_unit_for_quantity(quantity: &str, unit: &str) -> Result<(), ParseError> {
361    let (_, dim) = parse_unit_expression(unit)?;
362    let expect = match quantity {
363        "length" => Dimension::LENGTH,
364        "time" => Dimension::TIME,
365        "mass" => Dimension::MASS,
366        "energy" => Dimension::MASS
367            .mul(Dimension::LENGTH.pow(2.0))
368            .div(Dimension::TIME.pow(2.0)),
369        "velocity" => Dimension::LENGTH.div(Dimension::TIME),
370        "force" => Dimension::MASS
371            .mul(Dimension::LENGTH)
372            .div(Dimension::TIME.pow(2.0)),
373        _ => {
374            return Err(ParseError::ValidationError(format!(
375                "unknown quantity '{quantity}'"
376            )));
377        }
378    };
379    if !dim.compatible(expect) {
380        return Err(ParseError::ValidationError(format!(
381            "unit '{unit}' is not valid for quantity '{quantity}'"
382        )));
383    }
384    Ok(())
385}
386
387/// CON v3 requires `units` object with non-empty `length` and `energy` strings.
388pub fn validate_v3_units_metadata(units: &serde_json::Value) -> Result<(), ParseError> {
389    let obj = units.as_object().ok_or_else(|| {
390        ParseError::ValidationError("units must be a JSON object".into())
391    })?;
392    for key in ["length", "energy"] {
393        let Some(v) = obj.get(key) else {
394            return Err(ParseError::ValidationError(format!(
395                "v3 units must include non-empty '{key}'"
396            )));
397        };
398        let Some(s) = v.as_str() else {
399            return Err(ParseError::ValidationError(format!(
400                "units.{key} must be a string"
401            )));
402        };
403        if s.trim().is_empty() {
404            return Err(ParseError::ValidationError(format!(
405                "units.{key} must be non-empty"
406            )));
407        }
408        validate_unit_for_quantity(key, s)?;
409    }
410    // Optional keys if present must be valid for their quantity
411    for (key, qty) in [
412        ("mass", "mass"),
413        ("time", "time"),
414        ("velocity", "velocity"),
415        ("force", "force"),
416    ] {
417        if let Some(v) = obj.get(key) {
418            let s = v.as_str().ok_or_else(|| {
419                ParseError::ValidationError(format!("units.{key} must be a string"))
420            })?;
421            validate_unit_for_quantity(qty, s)?;
422        }
423    }
424    Ok(())
425}
426
427/// Default LODE units object for new v3 frames.
428pub fn default_v3_units_json() -> serde_json::Value {
429    serde_json::json!({
430        "length": "angstrom",
431        "energy": "eV",
432        "mass": "amu",
433        "time": "fs"
434    })
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440
441    #[test]
442    fn angstrom_to_nm() {
443        let f = unit_conversion_factor("angstrom", "nm").unwrap();
444        assert!((f - 0.1).abs() < 1e-12);
445    }
446
447    #[test]
448    fn ev_to_mev() {
449        let f = unit_conversion_factor("eV", "meV").unwrap();
450        assert!((f - 1000.0).abs() < 1e-6);
451    }
452
453    #[test]
454    fn dimensional_mismatch() {
455        assert!(unit_conversion_factor("angstrom", "eV").is_err());
456    }
457
458    #[test]
459    fn validate_length_energy() {
460        validate_unit_for_quantity("length", "nm").unwrap();
461        validate_unit_for_quantity("energy", "hartree").unwrap();
462        assert!(validate_unit_for_quantity("energy", "angstrom").is_err());
463    }
464
465    #[test]
466    fn v3_units_require_length_energy() {
467        assert!(validate_v3_units_metadata(&serde_json::json!({"length": "A"})).is_err());
468        validate_v3_units_metadata(&default_v3_units_json()).unwrap();
469    }
470}