use crate::error::{PhysicsError, PhysicsResult};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum PhysicalUnit {
Meter,
Kilometer,
Centimeter,
Millimeter,
Inch,
Foot,
KiloGram,
Gram,
Milligram,
Tonne,
PoundMass,
Second,
Millisecond,
Microsecond,
Minute,
Hour,
MetersPerSecond,
KilometersPerHour,
MilesPerHour,
MetersPerSecondSquared,
StandardGravity,
Newton,
KiloNewton,
PoundForce,
Joule,
KiloJoule,
MegaJoule,
WattHour,
KiloWattHour,
ElectronVolt,
Watt,
KiloWatt,
MegaWatt,
Kelvin,
Celsius,
Fahrenheit,
Pascal,
KiloPascal,
MegaPascal,
Bar,
Atmosphere,
Ampere,
Volt,
Ohm,
Farad,
Henry,
Hertz,
KiloHertz,
MegaHertz,
Dimensionless,
Custom(String),
}
impl fmt::Display for PhysicalUnit {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.symbol())
}
}
impl PhysicalUnit {
pub fn symbol(&self) -> &str {
match self {
Self::Meter => "m",
Self::Kilometer => "km",
Self::Centimeter => "cm",
Self::Millimeter => "mm",
Self::Inch => "in",
Self::Foot => "ft",
Self::KiloGram => "kg",
Self::Gram => "g",
Self::Milligram => "mg",
Self::Tonne => "t",
Self::PoundMass => "lb",
Self::Second => "s",
Self::Millisecond => "ms",
Self::Microsecond => "μs",
Self::Minute => "min",
Self::Hour => "h",
Self::MetersPerSecond => "m/s",
Self::KilometersPerHour => "km/h",
Self::MilesPerHour => "mph",
Self::MetersPerSecondSquared => "m/s²",
Self::StandardGravity => "g",
Self::Newton => "N",
Self::KiloNewton => "kN",
Self::PoundForce => "lbf",
Self::Joule => "J",
Self::KiloJoule => "kJ",
Self::MegaJoule => "MJ",
Self::WattHour => "Wh",
Self::KiloWattHour => "kWh",
Self::ElectronVolt => "eV",
Self::Watt => "W",
Self::KiloWatt => "kW",
Self::MegaWatt => "MW",
Self::Kelvin => "K",
Self::Celsius => "°C",
Self::Fahrenheit => "°F",
Self::Pascal => "Pa",
Self::KiloPascal => "kPa",
Self::MegaPascal => "MPa",
Self::Bar => "bar",
Self::Atmosphere => "atm",
Self::Ampere => "A",
Self::Volt => "V",
Self::Ohm => "Ω",
Self::Farad => "F",
Self::Henry => "H",
Self::Hertz => "Hz",
Self::KiloHertz => "kHz",
Self::MegaHertz => "MHz",
Self::Dimensionless => "1",
Self::Custom(s) => s.as_str(),
}
}
pub fn scale_factor(&self) -> f64 {
match self {
Self::Meter => 1.0,
Self::Kilometer => 1_000.0,
Self::Centimeter => 0.01,
Self::Millimeter => 0.001,
Self::Inch => 0.0254,
Self::Foot => 0.3048,
Self::KiloGram => 1.0,
Self::Gram => 0.001,
Self::Milligram => 1e-6,
Self::Tonne => 1_000.0,
Self::PoundMass => 0.453_592_37,
Self::Second => 1.0,
Self::Millisecond => 0.001,
Self::Microsecond => 1e-6,
Self::Minute => 60.0,
Self::Hour => 3_600.0,
Self::MetersPerSecond => 1.0,
Self::KilometersPerHour => 1.0 / 3.6,
Self::MilesPerHour => 0.447_04,
Self::MetersPerSecondSquared => 1.0,
Self::StandardGravity => 9.806_65,
Self::Newton => 1.0,
Self::KiloNewton => 1_000.0,
Self::PoundForce => 4.448_222,
Self::Joule => 1.0,
Self::KiloJoule => 1_000.0,
Self::MegaJoule => 1_000_000.0,
Self::WattHour => 3_600.0,
Self::KiloWattHour => 3_600_000.0,
Self::ElectronVolt => 1.602_176_634e-19,
Self::Watt => 1.0,
Self::KiloWatt => 1_000.0,
Self::MegaWatt => 1_000_000.0,
Self::Kelvin => 1.0,
Self::Celsius => 1.0, Self::Fahrenheit => 5.0 / 9.0, Self::Pascal => 1.0,
Self::KiloPascal => 1_000.0,
Self::MegaPascal => 1_000_000.0,
Self::Bar => 100_000.0,
Self::Atmosphere => 101_325.0,
Self::Ampere => 1.0,
Self::Volt => 1.0,
Self::Ohm => 1.0,
Self::Farad => 1.0,
Self::Henry => 1.0,
Self::Hertz => 1.0,
Self::KiloHertz => 1_000.0,
Self::MegaHertz => 1_000_000.0,
Self::Dimensionless => 1.0,
Self::Custom(_) => 1.0,
}
}
pub fn to_si_value(&self, value: f64) -> f64 {
match self {
Self::Celsius => value + 273.15,
Self::Fahrenheit => (value + 459.67) * (5.0 / 9.0),
_ => value * self.scale_factor(),
}
}
pub fn from_si_value(&self, si_value: f64) -> f64 {
match self {
Self::Celsius => si_value - 273.15,
Self::Fahrenheit => si_value * (9.0 / 5.0) - 459.67,
_ => si_value / self.scale_factor(),
}
}
pub fn has_offset(&self) -> bool {
matches!(self, Self::Celsius | Self::Fahrenheit)
}
}
#[derive(Debug, Clone)]
pub struct PhysicalValue {
pub value: f64,
pub unit: PhysicalUnit,
}
impl PhysicalValue {
pub fn new(value: f64, unit: PhysicalUnit) -> Self {
Self { value, unit }
}
pub fn as_si(&self) -> f64 {
self.unit.to_si_value(self.value)
}
pub fn convert_to(&self, target: &PhysicalUnit) -> PhysicsResult<PhysicalValue> {
convert_unit(self, target)
}
}
impl fmt::Display for PhysicalValue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} {}", self.value, self.unit)
}
}
pub fn convert_unit(
value: &PhysicalValue,
target_unit: &PhysicalUnit,
) -> PhysicsResult<PhysicalValue> {
let si_value = value.unit.to_si_value(value.value);
let converted = target_unit.from_si_value(si_value);
if !converted.is_finite() {
return Err(PhysicsError::UnitConversion(format!(
"Conversion from {} to {} produced non-finite result",
value.unit, target_unit
)));
}
Ok(PhysicalValue {
value: converted,
unit: target_unit.clone(),
})
}
fn build_unit_table() -> HashMap<&'static str, PhysicalUnit> {
let mut map = HashMap::new();
map.insert("m", PhysicalUnit::Meter);
map.insert("meter", PhysicalUnit::Meter);
map.insert("meters", PhysicalUnit::Meter);
map.insert("metre", PhysicalUnit::Meter);
map.insert("metres", PhysicalUnit::Meter);
map.insert("km", PhysicalUnit::Kilometer);
map.insert("kilometer", PhysicalUnit::Kilometer);
map.insert("kilometres", PhysicalUnit::Kilometer);
map.insert("cm", PhysicalUnit::Centimeter);
map.insert("centimeter", PhysicalUnit::Centimeter);
map.insert("centimetre", PhysicalUnit::Centimeter);
map.insert("mm", PhysicalUnit::Millimeter);
map.insert("millimeter", PhysicalUnit::Millimeter);
map.insert("in", PhysicalUnit::Inch);
map.insert("inch", PhysicalUnit::Inch);
map.insert("ft", PhysicalUnit::Foot);
map.insert("foot", PhysicalUnit::Foot);
map.insert("feet", PhysicalUnit::Foot);
map.insert("kg", PhysicalUnit::KiloGram);
map.insert("kilogram", PhysicalUnit::KiloGram);
map.insert("kilograms", PhysicalUnit::KiloGram);
map.insert("g", PhysicalUnit::Gram);
map.insert("gram", PhysicalUnit::Gram);
map.insert("grams", PhysicalUnit::Gram);
map.insert("mg", PhysicalUnit::Milligram);
map.insert("milligram", PhysicalUnit::Milligram);
map.insert("t", PhysicalUnit::Tonne);
map.insert("tonne", PhysicalUnit::Tonne);
map.insert("lb", PhysicalUnit::PoundMass);
map.insert("lbs", PhysicalUnit::PoundMass);
map.insert("pound", PhysicalUnit::PoundMass);
map.insert("s", PhysicalUnit::Second);
map.insert("sec", PhysicalUnit::Second);
map.insert("second", PhysicalUnit::Second);
map.insert("seconds", PhysicalUnit::Second);
map.insert("ms", PhysicalUnit::Millisecond);
map.insert("millisecond", PhysicalUnit::Millisecond);
map.insert("us", PhysicalUnit::Microsecond);
map.insert("μs", PhysicalUnit::Microsecond);
map.insert("microsecond", PhysicalUnit::Microsecond);
map.insert("min", PhysicalUnit::Minute);
map.insert("minute", PhysicalUnit::Minute);
map.insert("minutes", PhysicalUnit::Minute);
map.insert("h", PhysicalUnit::Hour);
map.insert("hr", PhysicalUnit::Hour);
map.insert("hour", PhysicalUnit::Hour);
map.insert("hours", PhysicalUnit::Hour);
map.insert("m/s", PhysicalUnit::MetersPerSecond);
map.insert("ms^-1", PhysicalUnit::MetersPerSecond);
map.insert("m/s^1", PhysicalUnit::MetersPerSecond);
map.insert("km/h", PhysicalUnit::KilometersPerHour);
map.insert("kph", PhysicalUnit::KilometersPerHour);
map.insert("mph", PhysicalUnit::MilesPerHour);
map.insert("m/s^2", PhysicalUnit::MetersPerSecondSquared);
map.insert("m/s²", PhysicalUnit::MetersPerSecondSquared);
map.insert("ms^-2", PhysicalUnit::MetersPerSecondSquared);
map.insert("m*s^-2", PhysicalUnit::MetersPerSecondSquared);
map.insert("standard_gravity", PhysicalUnit::StandardGravity);
map.insert("gn", PhysicalUnit::StandardGravity);
map.insert("n", PhysicalUnit::Newton);
map.insert("newton", PhysicalUnit::Newton);
map.insert("newtons", PhysicalUnit::Newton);
map.insert("kn", PhysicalUnit::KiloNewton);
map.insert("kilonewton", PhysicalUnit::KiloNewton);
map.insert("lbf", PhysicalUnit::PoundForce);
map.insert("j", PhysicalUnit::Joule);
map.insert("joule", PhysicalUnit::Joule);
map.insert("joules", PhysicalUnit::Joule);
map.insert("kj", PhysicalUnit::KiloJoule);
map.insert("kilojoule", PhysicalUnit::KiloJoule);
map.insert("mj", PhysicalUnit::MegaJoule);
map.insert("megajoule", PhysicalUnit::MegaJoule);
map.insert("wh", PhysicalUnit::WattHour);
map.insert("kwh", PhysicalUnit::KiloWattHour);
map.insert("ev", PhysicalUnit::ElectronVolt);
map.insert("w", PhysicalUnit::Watt);
map.insert("watt", PhysicalUnit::Watt);
map.insert("watts", PhysicalUnit::Watt);
map.insert("kw", PhysicalUnit::KiloWatt);
map.insert("kilowatt", PhysicalUnit::KiloWatt);
map.insert("mw", PhysicalUnit::MegaWatt);
map.insert("megawatt", PhysicalUnit::MegaWatt);
map.insert("k", PhysicalUnit::Kelvin);
map.insert("kelvin", PhysicalUnit::Kelvin);
map.insert("°c", PhysicalUnit::Celsius);
map.insert("degc", PhysicalUnit::Celsius);
map.insert("celsius", PhysicalUnit::Celsius);
map.insert("c", PhysicalUnit::Celsius);
map.insert("°f", PhysicalUnit::Fahrenheit);
map.insert("degf", PhysicalUnit::Fahrenheit);
map.insert("fahrenheit", PhysicalUnit::Fahrenheit);
map.insert("f", PhysicalUnit::Fahrenheit);
map.insert("pa", PhysicalUnit::Pascal);
map.insert("pascal", PhysicalUnit::Pascal);
map.insert("kpa", PhysicalUnit::KiloPascal);
map.insert("kilopascal", PhysicalUnit::KiloPascal);
map.insert("mpa", PhysicalUnit::MegaPascal);
map.insert("megapascal", PhysicalUnit::MegaPascal);
map.insert("bar", PhysicalUnit::Bar);
map.insert("atm", PhysicalUnit::Atmosphere);
map.insert("atmosphere", PhysicalUnit::Atmosphere);
map.insert("a", PhysicalUnit::Ampere);
map.insert("ampere", PhysicalUnit::Ampere);
map.insert("v", PhysicalUnit::Volt);
map.insert("volt", PhysicalUnit::Volt);
map.insert("ohm", PhysicalUnit::Ohm);
map.insert("ω", PhysicalUnit::Ohm);
map.insert("farad", PhysicalUnit::Farad);
map.insert("henry", PhysicalUnit::Henry);
map.insert("hz", PhysicalUnit::Hertz);
map.insert("hertz", PhysicalUnit::Hertz);
map.insert("khz", PhysicalUnit::KiloHertz);
map.insert("mhz", PhysicalUnit::MegaHertz);
map.insert("1", PhysicalUnit::Dimensionless);
map.insert("dimensionless", PhysicalUnit::Dimensionless);
map.insert("ratio", PhysicalUnit::Dimensionless);
map
}
pub fn parse_unit_str(s: &str) -> PhysicalUnit {
let table = build_unit_table();
let lower = s.trim().to_lowercase();
table
.get(lower.as_str())
.cloned()
.unwrap_or_else(|| PhysicalUnit::Custom(s.trim().to_string()))
}
fn is_xsd_numeric(datatype: &str) -> bool {
matches!(
datatype,
"http://www.w3.org/2001/XMLSchema#double"
| "http://www.w3.org/2001/XMLSchema#float"
| "http://www.w3.org/2001/XMLSchema#decimal"
| "http://www.w3.org/2001/XMLSchema#integer"
| "http://www.w3.org/2001/XMLSchema#long"
| "http://www.w3.org/2001/XMLSchema#int"
| "http://www.w3.org/2001/XMLSchema#short"
| "http://www.w3.org/2001/XMLSchema#byte"
| "http://www.w3.org/2001/XMLSchema#nonNegativeInteger"
| "xsd:double"
| "xsd:float"
| "xsd:decimal"
| "xsd:integer"
| "xsd:int"
)
}
pub fn parse_rdf_literal(literal: &str, datatype: Option<&str>) -> PhysicsResult<PhysicalValue> {
let trimmed = literal.trim();
if let Some(dt) = datatype {
if is_xsd_numeric(dt) {
if let Ok(value) = f64::from_str(trimmed) {
return Ok(PhysicalValue {
value,
unit: PhysicalUnit::Dimensionless,
});
}
}
}
if let Some((num_part, unit_part)) = split_value_unit(trimmed) {
let value = parse_f64(num_part)?;
let unit = parse_unit_str(unit_part);
return Ok(PhysicalValue { value, unit });
}
if let Ok(value) = f64::from_str(trimmed) {
return Ok(PhysicalValue {
value,
unit: PhysicalUnit::Dimensionless,
});
}
Err(PhysicsError::ParameterExtraction(format!(
"Cannot parse RDF literal as physical value: '{}'",
literal
)))
}
fn split_value_unit(s: &str) -> Option<(&str, &str)> {
let mut end = 0;
let bytes = s.as_bytes();
if end < bytes.len() && (bytes[end] == b'-' || bytes[end] == b'+') {
end += 1;
}
while end < bytes.len() && (bytes[end].is_ascii_digit() || bytes[end] == b'.') {
end += 1;
}
if end < bytes.len() && (bytes[end] == b'e' || bytes[end] == b'E') {
end += 1;
if end < bytes.len() && (bytes[end] == b'-' || bytes[end] == b'+') {
end += 1;
}
while end < bytes.len() && bytes[end].is_ascii_digit() {
end += 1;
}
}
if end == 0 || end == s.len() {
return None;
}
let num_part = s[..end].trim();
let rest = s[end..].trim();
if rest.is_empty() {
return None;
}
Some((num_part, rest))
}
fn parse_f64(s: &str) -> PhysicsResult<f64> {
f64::from_str(s.trim()).map_err(|_| {
PhysicsError::ParameterExtraction(format!(
"Cannot parse '{}' as a floating-point number",
s
))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_annotated_acceleration() {
let v = parse_rdf_literal("9.81 m/s^2", None).expect("parse failed");
assert!((v.value - 9.81).abs() < 1e-10);
assert_eq!(v.unit, PhysicalUnit::MetersPerSecondSquared);
}
#[test]
fn test_parse_mass_kg() {
let v = parse_rdf_literal("75.0 kg", None).expect("parse failed");
assert!((v.value - 75.0).abs() < 1e-10);
assert_eq!(v.unit, PhysicalUnit::KiloGram);
}
#[test]
fn test_parse_temperature_kelvin() {
let v = parse_rdf_literal("300.0 K", None).expect("parse failed");
assert!((v.value - 300.0).abs() < 1e-10);
assert_eq!(v.unit, PhysicalUnit::Kelvin);
}
#[test]
fn test_parse_xsd_double_bare() {
let v = parse_rdf_literal("42.0", Some("xsd:double")).expect("parse failed");
assert!((v.value - 42.0).abs() < 1e-10);
assert_eq!(v.unit, PhysicalUnit::Dimensionless);
}
#[allow(clippy::approx_constant)]
#[test]
fn test_parse_xsd_full_iri() {
let v = parse_rdf_literal("2.71", Some("http://www.w3.org/2001/XMLSchema#double"))
.expect("parse failed");
assert!((v.value - 2.71).abs() < 1e-10);
}
#[test]
fn test_parse_bare_number_no_datatype() {
let v = parse_rdf_literal("1234.5", None).expect("parse failed");
assert!((v.value - 1234.5).abs() < 1e-10);
assert_eq!(v.unit, PhysicalUnit::Dimensionless);
}
#[test]
fn test_parse_negative_value_with_unit() {
let v = parse_rdf_literal("-273.15 °C", None).expect("parse failed");
assert!((v.value - (-273.15)).abs() < 1e-10);
assert_eq!(v.unit, PhysicalUnit::Celsius);
}
#[test]
fn test_parse_scientific_notation_with_unit() {
let v = parse_rdf_literal("6.022e23 1", None).expect("parse failed");
assert!((v.value - 6.022e23).abs() < 1e10);
assert_eq!(v.unit, PhysicalUnit::Dimensionless);
}
#[test]
fn test_parse_velocity() {
let v = parse_rdf_literal("100 km/h", None).expect("parse failed");
assert!((v.value - 100.0).abs() < 1e-10);
assert_eq!(v.unit, PhysicalUnit::KilometersPerHour);
}
#[test]
fn test_parse_pressure_pa() {
let v = parse_rdf_literal("101325 Pa", None).expect("parse failed");
assert!((v.value - 101_325.0).abs() < 1e-10);
assert_eq!(v.unit, PhysicalUnit::Pascal);
}
#[test]
fn test_parse_invalid_literal() {
let result = parse_rdf_literal("not_a_number", None);
assert!(result.is_err());
}
#[test]
fn test_convert_km_to_m() {
let pv = PhysicalValue::new(1.0, PhysicalUnit::Kilometer);
let converted = convert_unit(&pv, &PhysicalUnit::Meter).expect("conversion failed");
assert!((converted.value - 1000.0).abs() < 1e-9);
assert_eq!(converted.unit, PhysicalUnit::Meter);
}
#[test]
fn test_convert_celsius_to_kelvin() {
let pv = PhysicalValue::new(0.0, PhysicalUnit::Celsius);
let converted = convert_unit(&pv, &PhysicalUnit::Kelvin).expect("conversion failed");
assert!((converted.value - 273.15).abs() < 1e-9);
}
#[test]
fn test_convert_fahrenheit_to_celsius() {
let pv = PhysicalValue::new(32.0, PhysicalUnit::Fahrenheit);
let converted = convert_unit(&pv, &PhysicalUnit::Celsius).expect("conversion failed");
assert!((converted.value - 0.0).abs() < 1e-9);
}
#[test]
fn test_convert_g_to_ms2() {
let pv = PhysicalValue::new(1.0, PhysicalUnit::StandardGravity);
let converted =
convert_unit(&pv, &PhysicalUnit::MetersPerSecondSquared).expect("conversion failed");
assert!((converted.value - 9.806_65).abs() < 1e-9);
}
#[test]
fn test_convert_kwh_to_joule() {
let pv = PhysicalValue::new(1.0, PhysicalUnit::KiloWattHour);
let converted = convert_unit(&pv, &PhysicalUnit::Joule).expect("conversion failed");
assert!((converted.value - 3_600_000.0).abs() < 1e-3);
}
#[test]
fn test_convert_roundtrip_kelvin() {
let original = PhysicalValue::new(373.15, PhysicalUnit::Kelvin);
let in_f = convert_unit(&original, &PhysicalUnit::Fahrenheit).expect("to F failed");
let back = convert_unit(&in_f, &PhysicalUnit::Kelvin).expect("back to K failed");
assert!((back.value - 373.15).abs() < 1e-9);
}
#[test]
fn test_convert_mph_to_ms() {
let pv = PhysicalValue::new(60.0, PhysicalUnit::MilesPerHour);
let converted = convert_unit(&pv, &PhysicalUnit::MetersPerSecond).expect("failed");
assert!((converted.value - 26.8224).abs() < 1e-3);
}
#[test]
fn test_parse_unit_case_insensitive() {
assert_eq!(parse_unit_str("KG"), PhysicalUnit::KiloGram);
assert_eq!(parse_unit_str("KELVIN"), PhysicalUnit::Kelvin);
assert_eq!(parse_unit_str("MHz"), PhysicalUnit::MegaHertz);
}
#[test]
fn test_parse_unit_unknown_is_custom() {
match parse_unit_str("furlong/fortnight") {
PhysicalUnit::Custom(s) => assert_eq!(s, "furlong/fortnight"),
other => panic!("expected Custom, got {:?}", other),
}
}
#[test]
fn test_symbol_roundtrip() {
let units = [
PhysicalUnit::Meter,
PhysicalUnit::KiloGram,
PhysicalUnit::Second,
PhysicalUnit::Newton,
PhysicalUnit::Joule,
PhysicalUnit::Kelvin,
PhysicalUnit::Pascal,
];
for u in &units {
assert!(!u.symbol().is_empty(), "empty symbol for {:?}", u);
}
}
#[test]
fn test_as_si_kelvin_identity() {
let pv = PhysicalValue::new(300.0, PhysicalUnit::Kelvin);
assert!((pv.as_si() - 300.0).abs() < 1e-12);
}
#[test]
fn test_display_physical_value() {
let pv = PhysicalValue::new(9.81, PhysicalUnit::MetersPerSecondSquared);
let s = format!("{}", pv);
assert!(s.contains("9.81"));
}
}