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_ascii_lowercase(), &table)
245}
246
247fn parse_expr(s: &str, table: &HashMap<&str, UnitValue>) -> Result<(f64, Dimension), ParseError> {
248 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 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
357pub 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
369pub 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
398pub 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 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
438pub 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
471pub 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
516pub 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
536pub 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}