use std::{collections::HashMap, fmt::Display, sync::Arc};
use enum_map::EnumMap;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[cfg(feature = "ts")]
use tsify::Tsify;
use crate::convert::{ConvertError, Converter, PhysicalQuantity, Unit};
#[derive(Debug, Serialize, Deserialize, Clone)]
#[cfg_attr(feature = "ts", derive(Tsify))]
#[cfg_attr(feature = "ts", tsify(into_wasm_abi, from_wasm_abi))]
pub struct Quantity {
pub(crate) value: Value,
pub(crate) unit: Option<String>,
pub(crate) scalable: bool,
}
impl PartialEq for Quantity {
fn eq(&self, other: &Self) -> bool {
self.value == other.value && self.unit == other.unit
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "ts", derive(Tsify))]
#[serde(tag = "type", content = "value", rename_all = "camelCase")]
pub enum Value {
Number(Number),
Range { start: Number, end: Number },
Text(String),
}
impl Value {
pub fn is_text(&self) -> bool {
matches!(self, Value::Text(_))
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(tag = "type", content = "value", rename_all = "camelCase")]
pub enum Number {
Regular(f64),
Fraction {
whole: u32,
num: u32,
den: u32,
err: f64,
},
}
impl From<Number> for f64 {
fn from(n: Number) -> Self {
n.value()
}
}
impl From<f64> for Number {
fn from(value: f64) -> Self {
Self::Regular(value)
}
}
impl Number {
pub fn value(self) -> f64 {
match self {
Number::Regular(v) => v,
Number::Fraction {
whole,
num,
den,
err,
} => whole as f64 + err + num as f64 / den as f64,
}
}
}
impl PartialEq for Number {
fn eq(&self, other: &Self) -> bool {
self.value().eq(&other.value())
}
}
impl Quantity {
pub fn new(value: Value, unit: Option<String>) -> Self {
Self {
value,
unit,
scalable: false,
}
}
pub fn scalable(&self) -> bool {
self.scalable
}
pub fn unit(&self) -> Option<&str> {
self.unit.as_deref()
}
pub fn value(&self) -> &Value {
&self.value
}
pub(crate) fn value_mut(&mut self) -> &mut Value {
&mut self.value
}
pub fn unit_info(&self, converter: &Converter) -> Option<Arc<Unit>> {
self.unit().and_then(|u| converter.find_unit(u))
}
}
impl Display for Quantity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.value.fmt(f)?;
if let Some(unit) = &self.unit {
f.write_str(" ")?;
unit.fmt(f)?;
}
Ok(())
}
}
impl Display for Value {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Value::Number(n) => n.fmt(f),
Value::Range { start, end } => write!(f, "{start}-{end}"),
Value::Text(t) => t.fmt(f),
}
}
}
fn round_float(n: f64) -> f64 {
(n * 1000.0).round() / 1000.0
}
impl Display for Number {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match *self {
Number::Regular(n) => write!(f, "{}", round_float(n)),
Number::Fraction {
whole,
num,
den,
err,
} => {
if self.value() == 0.0 {
return write!(f, "{}", 0.0);
}
match (whole, num, den) {
(0, 0, _) => write!(f, "{}", 0.0),
(0, num, den) => write!(f, "{num}/{den}"),
(whole, 0, _) => write!(f, "{whole}"),
(whole, num, den) => write!(f, "{whole} {num}/{den}"),
}?;
if f.alternate() && err.abs() > 0.001 {
write!(f, " ({:+})", round_float(err))?;
}
Ok(())
}
}
}
}
impl From<f64> for Value {
fn from(value: f64) -> Self {
Self::Number(Number::Regular(value))
}
}
impl From<String> for Value {
fn from(value: String) -> Self {
Self::Text(value)
}
}
#[derive(Debug, Error)]
pub enum QuantityAddError {
#[error(transparent)]
IncompatibleUnits(#[from] IncompatibleUnits),
#[error(transparent)]
TextValue(#[from] TextValueError),
#[error(transparent)]
Convert(#[from] ConvertError),
}
#[derive(Debug, Error)]
pub enum IncompatibleUnits {
#[error("Missing unit: one unit is '{found}' but the other quantity is missing an unit")]
MissingUnit {
found: String,
lhs: bool,
},
#[error("Different physical quantity: '{a}' '{b}'")]
DifferentPhysicalQuantities {
a: PhysicalQuantity,
b: PhysicalQuantity,
},
#[error("Unknown units differ: '{a}' '{b}'")]
UnknownDifferentUnits { a: String, b: String },
}
impl Quantity {
pub fn compatible_unit(
&self,
rhs: &Self,
converter: &Converter,
) -> Result<Option<Arc<Unit>>, IncompatibleUnits> {
let base = match (&self.unit, &rhs.unit) {
(None, None) => None,
(None, Some(u)) => {
return Err(IncompatibleUnits::MissingUnit {
found: u.clone(),
lhs: false,
});
}
(Some(u), None) => {
return Err(IncompatibleUnits::MissingUnit {
found: u.clone(),
lhs: true,
});
}
(Some(a), Some(b)) => {
let a_unit = converter.find_unit(a);
let b_unit = converter.find_unit(b);
match (a_unit, b_unit) {
(Some(a_unit), Some(b_unit)) => {
if a_unit.physical_quantity != b_unit.physical_quantity {
return Err(IncompatibleUnits::DifferentPhysicalQuantities {
a: a_unit.physical_quantity,
b: b_unit.physical_quantity,
});
}
Some(a_unit)
}
_ => {
if a != b {
return Err(IncompatibleUnits::UnknownDifferentUnits {
a: a.clone(),
b: b.clone(),
});
}
None
}
}
}
};
Ok(base)
}
pub fn try_add(&self, rhs: &Self, converter: &Converter) -> Result<Self, QuantityAddError> {
let convert_to = self.compatible_unit(rhs, converter)?;
let mut rhs = rhs.clone();
if let Some(to) = convert_to {
rhs.convert(&to, converter)?;
};
let value = self.value.try_add(&rhs.value)?;
let qty = Quantity::new(value, self.unit.clone());
Ok(qty)
}
}
pub trait TryAdd: Sized {
type Err;
fn try_add(&self, rhs: &Self) -> Result<Self, Self::Err>;
}
#[derive(Debug, Error, Clone)]
#[error("Cannot operate on a text value")]
pub struct TextValueError(pub Value);
impl TryAdd for Value {
type Err = TextValueError;
fn try_add(&self, rhs: &Self) -> Result<Value, TextValueError> {
let val = match (self, rhs) {
(Value::Number(a), Value::Number(b)) => Value::Number((a.value() + b.value()).into()),
(Value::Number(n), Value::Range { start, end })
| (Value::Range { start, end }, Value::Number(n)) => Value::Range {
start: (start.value() + n.value()).into(),
end: (end.value() + n.value()).into(),
},
(Value::Range { start: s1, end: e1 }, Value::Range { start: s2, end: e2 }) => {
Value::Range {
start: (s1.value() + s2.value()).into(),
end: (e1.value() + e2.value()).into(),
}
}
(t @ Value::Text(_), _) | (_, t @ Value::Text(_)) => {
return Err(TextValueError(t.to_owned()));
}
};
Ok(val)
}
}
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(Tsify))]
#[cfg_attr(feature = "ts", tsify(into_wasm_abi, from_wasm_abi))]
pub struct GroupedQuantity {
known: EnumMap<PhysicalQuantity, Option<Quantity>>,
unknown: HashMap<String, Quantity>,
no_unit: Option<Quantity>,
other: Vec<Quantity>,
}
impl GroupedQuantity {
pub fn empty() -> Self {
Self::default()
}
pub fn add(&mut self, q: &Quantity, converter: &Converter) {
macro_rules! add {
($stored:expr, $quantity:ident, $converter:expr, $other:expr) => {
match $stored.try_add($quantity, $converter) {
Ok(q) => *$stored = q,
Err(_) => {
$other.push($quantity.clone());
return;
}
}
};
}
if q.value.is_text() {
self.other.push(q.clone());
return;
}
if q.unit.is_none() {
if let Some(stored) = &mut self.no_unit {
add!(stored, q, converter, self.other);
} else {
self.no_unit = Some(q.clone());
}
return;
}
let unit_text = q.unit().unwrap();
let info = q.unit_info(converter);
match info {
Some(unit) => {
if let Some(stored) = &mut self.known[unit.physical_quantity] {
add!(stored, q, converter, self.other);
} else {
self.known[unit.physical_quantity] = Some(q.clone());
}
}
None => {
if let Some(stored) = self.unknown.get_mut(unit_text) {
add!(stored, q, converter, self.other);
} else {
self.unknown.insert(unit_text.to_string(), q.clone());
}
}
};
}
pub fn merge(&mut self, other: &Self, converter: &Converter) {
for q in other.iter() {
self.add(q, converter)
}
}
pub fn fit(&mut self, converter: &Converter) -> Result<(), ConvertError> {
for q in self.known.values_mut().filter_map(|q| q.as_mut()) {
q.fit(converter)?;
}
Ok(())
}
pub fn is_empty(&self) -> bool {
self.iter().next().is_none()
}
pub fn iter(&self) -> impl Iterator<Item = &Quantity> {
self.known
.values()
.filter_map(|q| q.as_ref())
.chain(self.unknown.values())
.chain(self.other.iter())
.chain(self.no_unit.iter())
}
pub fn len(&self) -> usize {
self.known.values().filter(|q| q.is_some()).count()
+ self.unknown.len()
+ self.other.len()
+ (self.no_unit.is_some() as usize)
}
pub fn into_vec(self) -> Vec<Quantity> {
let len = self.len();
let mut v = Vec::with_capacity(len);
for q in self
.known
.into_values()
.flatten()
.chain(self.unknown.into_values())
.chain(self.other.into_iter())
.chain(self.no_unit.into_iter())
{
v.push(q)
}
debug_assert_eq!(len, v.len(), "misscalculated groupedquantity len");
v
}
}
impl Display for GroupedQuantity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
display_comma_separated(f, self.iter())
}
}
fn display_comma_separated<T>(
f: &mut impl std::fmt::Write,
mut iter: impl Iterator<Item = T>,
) -> std::fmt::Result
where
T: Display,
{
match iter.next() {
Some(first) => write!(f, "{first}")?,
None => return Ok(()),
}
for q in iter {
write!(f, ", {q}")?;
}
Ok(())
}
static TABLE: std::sync::LazyLock<FractionLookupTable> =
std::sync::LazyLock::new(FractionLookupTable::new);
#[derive(Debug)]
struct FractionLookupTable(Vec<(i16, (u8, u8))>);
impl FractionLookupTable {
const FIX_RATIO: f64 = 1e4;
const DENOMS: &'static [u8] = &[2, 3, 4, 8, 10, 16];
pub fn new() -> Self {
#[allow(clippy::const_is_empty)]
{
debug_assert!(!Self::DENOMS.is_empty());
}
debug_assert!(Self::DENOMS.windows(2).all(|w| w[0] < w[1]));
let mut table = Vec::new();
for &den in Self::DENOMS {
for num in 1..den {
let val = num as f64 / den as f64;
let fixed = (val * Self::FIX_RATIO) as i16;
if let Err(pos) = table.binary_search_by_key(&fixed, |&(x, _)| x) {
table.insert(pos, (fixed, (num, den)));
}
}
}
table.shrink_to_fit();
Self(table)
}
pub fn lookup(&self, val: f64, max_den: u8) -> Option<(u8, u8)> {
let fixed = (val * Self::FIX_RATIO) as i16;
let t = self.0.as_slice();
let pos = t.binary_search_by_key(&fixed, |&(x, _)| x);
let found = pos.is_ok_and(|i| {
let (x, (_, d)) = t[i];
x == fixed && d <= max_den
});
if found {
return Some(t[pos.unwrap()].1);
}
let pos = pos.unwrap_or_else(|i| i);
let high = t[pos..].iter().find(|(_, (_, d))| *d <= max_den).copied();
let low = t[..pos].iter().rfind(|(_, (_, d))| *d <= max_den).copied();
match (low, high) {
(None, Some((_, f))) | (Some((_, f)), None) => Some(f),
(Some((a_val, a)), Some((b_val, b))) => {
let a_err = (a_val - fixed).abs();
let b_err = (b_val - fixed).abs();
if a_err.cmp(&b_err).then(a.1.cmp(&b.1)).is_le() {
Some(a)
} else {
Some(b)
}
}
(None, None) => None,
}
}
}
impl Number {
pub fn new_approx(value: f64, accuracy: f32, max_den: u8, max_whole: u32) -> Option<Self> {
assert!((0.0..=1.0).contains(&accuracy));
assert!(max_den <= 64);
if value <= 0.0 || !value.is_finite() {
return None;
}
let max_err = accuracy as f64 * value;
let whole = value.trunc() as u32;
let decimal = value.fract();
if whole > max_whole || whole == u32::MAX {
return None;
}
if decimal < 1e-10 {
return Some(Self::Regular(value));
}
let rounded = value.round() as u32;
let round_err = value - value.round();
if round_err.abs() < max_err && rounded > 0 && rounded <= max_whole {
return Some(Self::Fraction {
whole: rounded,
num: 0,
den: 1,
err: round_err,
});
}
let (num, den) = TABLE.lookup(decimal, max_den)?;
let approx_value = whole as f64 + num as f64 / den as f64;
let err = value - approx_value;
if err.abs() > max_err {
return None;
}
Some(Self::Fraction {
whole,
num: num as u32,
den: den as u32,
err,
})
}
pub fn try_approx(&mut self, accuracy: f32, max_den: u8, max_whole: u32) -> bool {
match Self::new_approx(self.value(), accuracy, max_den, max_whole) {
Some(f) => {
*self = f;
true
}
None => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use test_case::test_case;
macro_rules! frac {
($whole:expr) => {
frac!($whole, 0, 1)
};
($num:expr, $den:expr) => {
frac!(0, $num, $den)
};
($whole:expr, $num:expr, $den:expr) => {
Some(Number::Fraction {
whole: $whole,
num: $num,
den: $den,
..
})
};
}
#[test_case(1.0 => matches Some(Number::Regular(v)) if v == 1.0 ; "exact")]
#[test_case(1.00000000001 => matches Some(Number::Regular(v)) if 1.0 - v < 1e-10 && v > 1.0 ; "exactish")]
#[test_case(0.01 => None ; "no approx 0")]
#[test_case(1.9999 => matches frac!(2) ; "round up")]
#[test_case(1.0001 => matches frac!(1) ; "round down")]
#[test_case(400.0001 => matches frac!(400) ; "not wrong round up")]
#[test_case(399.9999 => matches frac!(400) ; "not wrong round down")]
#[test_case(1.5 => matches frac!(1, 1, 2) ; "trivial frac")]
#[test_case(0.2501 => matches frac!(1, 4) ; "frac with err")]
fn fractions(value: f64) -> Option<Number> {
let num = Number::new_approx(value, 0.05, 4, u32::MAX);
if let Some(num) = num {
assert!((num.value() - value).abs() < 10e-9);
}
num
}
}