1use crate::error::ParseError;
8use std::collections::HashMap;
9
10#[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#[derive(Clone, Debug)]
67struct UnitValue {
68 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 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 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 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 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 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 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
237pub 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_lowercase(), &table)
246}
247
248fn parse_expr(s: &str, table: &HashMap<&str, UnitValue>) -> Result<(f64, Dimension), ParseError> {
249 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 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
358pub 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
370pub 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
408pub 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
423pub 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
452pub 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 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
492pub 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
528pub 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
581pub 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
601pub 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}