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(
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 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 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 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
228pub 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 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 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
346pub 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
358pub 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
387pub 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 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
427pub 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}