use std::{collections::HashMap, ops::RangeInclusive, sync::Arc};
use enum_map::EnumMap;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{
quantity::{Number, Quantity, Value},
Recipe,
};
pub use builder::{ConverterBuilder, ConverterBuilderError};
pub use units_file::UnitsFile;
mod builder;
pub mod units_file;
#[derive(Debug, Clone)]
pub struct Converter {
all_units: Vec<Arc<Unit>>,
unit_index: UnitIndex,
quantity_index: UnitQuantityIndex,
best: EnumMap<PhysicalQuantity, BestConversionsStore>,
fractions: Fractions,
default_system: System,
}
impl Converter {
pub fn builder() -> ConverterBuilder {
ConverterBuilder::new()
}
pub fn empty() -> Self {
Self {
all_units: Default::default(),
unit_index: Default::default(),
quantity_index: Default::default(),
best: Default::default(),
default_system: Default::default(),
fractions: Default::default(),
}
}
#[cfg(feature = "bundled_units")]
pub fn bundled() -> Self {
ConverterBuilder::new()
.with_units_file(UnitsFile::bundled())
.unwrap()
.finish()
.unwrap()
}
pub fn default_system(&self) -> System {
self.default_system
}
pub fn unit_count(&self) -> usize {
self.all_units.len()
}
pub fn all_units(&self) -> impl Iterator<Item = &Unit> {
self.all_units.iter().map(|u| u.as_ref())
}
pub fn is_best_unit(&self, unit: &Unit) -> bool {
let unit_id = self
.unit_index
.get_unit_id(unit.symbol())
.expect("unit not found");
let Some(system) = unit.system else {
return false;
};
let conversions = self.best[unit.physical_quantity].conversions(system);
conversions.0.iter().any(|&(_, id)| id == unit_id)
}
pub fn best_units(&self, quantity: PhysicalQuantity, system: Option<System>) -> Vec<Arc<Unit>> {
match &self.best[quantity] {
BestConversionsStore::Unified(u) => u.all_units(self).cloned().collect(),
BestConversionsStore::BySystem { metric, imperial } => match system {
Some(System::Metric) => metric.all_units(self).cloned().collect(),
Some(System::Imperial) => imperial.all_units(self).cloned().collect(),
None => metric
.all_units(self)
.chain(imperial.all_units(self))
.cloned()
.collect(),
},
}
}
pub fn find_unit(&self, unit: &str) -> Option<Arc<Unit>> {
let uid = self.unit_index.get_unit_id(unit).ok()?;
Some(self.all_units[uid].clone())
}
#[tracing::instrument(level = "trace", skip_all, fields(unit = %unit), ret)]
pub(crate) fn fractions_config(&self, unit: &Unit) -> FractionsConfig {
let unit_id = self
.unit_index
.get_unit_id(unit.symbol())
.expect("unit not found");
self.fractions
.config(unit.system, unit.physical_quantity, unit_id)
}
pub(crate) fn should_fit_fraction(&self, unit: &Unit) -> bool {
self.fractions_config(unit).enabled
}
}
#[cfg(not(feature = "bundled_units"))]
impl Default for Converter {
fn default() -> Self {
Self::empty()
}
}
#[cfg(feature = "bundled_units")]
impl Default for Converter {
fn default() -> Self {
Self::bundled()
}
}
impl PartialEq for Converter {
fn eq(&self, other: &Self) -> bool {
self.all_units == other.all_units
&& self.unit_index == other.unit_index
&& self.quantity_index == other.quantity_index
&& self.best == other.best
&& self.default_system == other.default_system
}
}
#[derive(Debug, Clone, Default)]
struct Fractions {
all: Option<FractionsConfig>,
metric: Option<FractionsConfig>,
imperial: Option<FractionsConfig>,
quantity: HashMap<PhysicalQuantity, FractionsConfig>,
unit: HashMap<usize, FractionsConfig>,
}
impl Fractions {
fn config(
&self,
system: Option<System>,
quantity: PhysicalQuantity,
unit_id: usize,
) -> FractionsConfig {
self.unit
.get(&unit_id)
.or_else(|| self.quantity.get(&quantity))
.or_else(|| {
system.and_then(|s| match s {
System::Metric => self.metric.as_ref(),
System::Imperial => self.imperial.as_ref(),
})
})
.or(self.all.as_ref())
.copied()
.unwrap_or_default()
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct FractionsConfig {
pub enabled: bool,
pub accuracy: f32,
pub max_denominator: u8,
pub max_whole: u32,
}
impl Default for FractionsConfig {
fn default() -> Self {
Self {
enabled: false,
accuracy: 0.05,
max_denominator: 4,
max_whole: u32::MAX,
}
}
}
#[derive(Debug, Default, Clone, PartialEq)]
pub(crate) struct UnitIndex(HashMap<Arc<str>, usize>);
impl UnitIndex {
fn get_unit_id(&self, key: &str) -> Result<usize, UnknownUnit> {
self.0
.get(key)
.copied()
.ok_or_else(|| UnknownUnit(key.to_string()))
}
}
pub(crate) type UnitQuantityIndex = EnumMap<PhysicalQuantity, Vec<usize>>;
#[derive(Debug, Clone, Serialize)]
pub struct Unit {
pub names: Vec<Arc<str>>,
pub symbols: Vec<Arc<str>>,
pub aliases: Vec<Arc<str>>,
pub ratio: f64,
pub difference: f64,
pub physical_quantity: PhysicalQuantity,
pub system: Option<System>,
}
impl Unit {
fn all_keys(&self) -> impl Iterator<Item = &Arc<str>> {
self.names.iter().chain(&self.symbols).chain(&self.aliases)
}
pub fn symbol(&self) -> &str {
self.symbols
.first()
.or_else(|| self.names.first())
.or_else(|| self.aliases.first())
.expect("symbol, name or alias in unit")
}
}
impl PartialEq for Unit {
fn eq(&self, other: &Self) -> bool {
self.names == other.names
&& self.symbols == other.symbols
&& self.aliases == other.aliases
&& self.ratio == other.ratio
&& self.difference == other.difference
&& self.physical_quantity == other.physical_quantity
&& self.system == other.system
}
}
impl std::fmt::Display for Unit {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if f.alternate() && !self.names.is_empty() {
write!(f, "{}", self.names[0])
} else {
write!(f, "{}", self.symbol())
}
}
}
#[derive(Debug, Clone, PartialEq)]
enum BestConversionsStore {
Unified(BestConversions),
BySystem {
metric: BestConversions,
imperial: BestConversions,
},
}
impl BestConversionsStore {
pub(crate) fn conversions(&self, system: System) -> &BestConversions {
match self {
BestConversionsStore::Unified(u) => u,
BestConversionsStore::BySystem { metric, imperial } => match system {
System::Metric => metric,
System::Imperial => imperial,
},
}
}
}
impl Default for BestConversionsStore {
fn default() -> Self {
Self::Unified(Default::default())
}
}
#[derive(Debug, Clone, Default, PartialEq)]
struct BestConversions(Vec<(f64, usize)>);
impl BestConversions {
fn base(&self) -> Option<usize> {
self.0.first().map(|c| c.1)
}
fn best_unit(
&self,
converter: &Converter,
value: &ConvertValue,
unit: &Unit,
) -> Option<Arc<Unit>> {
let value = match value {
ConvertValue::Number(n) => n.abs(),
ConvertValue::Range(r) => r.start().abs(),
};
let base_unit_id = self.base()?;
let base_unit = &converter.all_units[base_unit_id];
let norm = converter.convert_f64(value, unit, base_unit);
let best_id = self
.0
.iter()
.rev()
.find(|(th, _)| norm >= (th - 0.001))
.or_else(|| self.0.first())
.map(|&(_, id)| id)?;
Some(Arc::clone(&converter.all_units[best_id]))
}
fn all_units<'c>(&'c self, converter: &'c Converter) -> impl Iterator<Item = &'c Arc<Unit>> {
self.0.iter().map(|(_, uid)| &converter.all_units[*uid])
}
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Deserialize,
Serialize,
PartialOrd,
Ord,
Hash,
strum::Display,
strum::EnumString,
enum_map::Enum,
)]
#[serde(rename_all = "camelCase")]
#[strum(serialize_all = "camelCase")]
pub enum PhysicalQuantity {
Volume,
Mass,
Length,
Temperature,
Time,
}
impl Recipe {
pub fn convert(&mut self, to: System, converter: &Converter) -> Vec<ConvertError> {
let mut errors = Vec::new();
let to = ConvertTo::from(to);
let mut conv = |q: &mut Quantity| {
if let Err(e) = q.convert(to, converter) {
errors.push(e)
}
};
for igr in &mut self.ingredients {
if let Some(q) = &mut igr.quantity {
conv(q);
}
}
for timer in &mut self.timers {
if let Some(q) = &mut timer.quantity {
conv(q);
}
}
for q in &mut self.inline_quantities {
conv(q);
}
errors
}
}
impl Quantity {
pub fn convert<'a>(
&mut self,
to: impl Into<ConvertTo<'a>>,
converter: &Converter,
) -> Result<(), ConvertError> {
self.convert_impl(to.into(), converter)
}
#[tracing::instrument(level = "trace", name = "convert", skip_all)]
fn convert_impl(&mut self, to: ConvertTo, converter: &Converter) -> Result<(), ConvertError> {
if self.unit().is_none() {
return Err(ConvertError::NoUnit(self.clone()));
}
let unit_info = self.unit_info(converter);
let original_system;
let unit = match unit_info {
Some(ref u) => {
original_system = u.system;
ConvertUnit::Unit(u)
}
None => {
return Err(ConvertError::UnknownUnit(UnknownUnit(
self.unit().unwrap().to_string(),
)))
}
};
let value = ConvertValue::try_from(self.value())?;
let (new_value, new_unit) = converter.convert(value, unit, to)?;
*self = Quantity::new(new_value.into(), Some(new_unit.symbol().to_string()));
match to {
ConvertTo::Unit(_) => {
self.try_fraction(converter);
}
ConvertTo::Best(target_system) => {
self.fit_fraction(&new_unit, Some(target_system), converter)?;
}
ConvertTo::SameSystem => {
self.fit_fraction(&new_unit, original_system, converter)?;
}
}
Ok(())
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn fit(&mut self, converter: &Converter) -> Result<(), ConvertError> {
let Some(unit) = self.unit_info(converter) else {
return Ok(());
};
if converter.should_fit_fraction(&unit)
&& self.fit_fraction(&unit, unit.system, converter)?
{
return Ok(());
}
self.convert(ConvertTo::SameSystem, converter)?;
Ok(())
}
fn fit_fraction(
&mut self,
unit: &Arc<Unit>,
target_system: Option<System>,
converter: &Converter,
) -> Result<bool, ConvertError> {
let approx = |val: f64, cfg: FractionsConfig| {
Number::new_approx(val, cfg.accuracy, cfg.max_denominator, cfg.max_whole)
};
let Some(system) = target_system else {
return Ok(self.try_fraction(converter)); };
let value = match self.value() {
Value::Number(n) => n.value(),
Value::Range { start, .. } => start.value(),
Value::Text(ref t) => return Err(ConvertError::TextValue(t.clone())),
};
let possible_conversions = converter.best[unit.physical_quantity]
.conversions(system)
.0
.iter()
.filter_map(|&(_, new_unit_id)| {
let new_unit = &converter.all_units[new_unit_id];
let cfg = converter.fractions.config(
new_unit.system,
new_unit.physical_quantity,
new_unit_id,
);
if !cfg.enabled {
return None;
}
let new_value = converter.convert_f64(value, unit, new_unit);
let new_value = approx(new_value, cfg)?;
Some((new_value, new_unit))
});
let selected = possible_conversions.min_by(|(a, _), (b, _)| {
let key = |v| match v {
Number::Fraction {
den, err, whole, ..
} => (den, whole as f64, err.abs()),
Number::Regular(whole) => (1, whole, 0.0),
};
let a = key(*a);
let b = key(*b);
a.partial_cmp(&b).unwrap_or(std::cmp::Ordering::Less)
});
let Some((new_value, new_unit)) = selected else {
return Ok(false);
};
let new_value = match self.value() {
Value::Number(_) => Value::Number(new_value),
Value::Range { end, .. } => {
let end = converter.convert_f64(end.value(), unit, new_unit);
let end_frac = approx(end, converter.fractions_config(new_unit))
.unwrap_or(Number::Regular(end));
Value::Range {
start: new_value,
end: end_frac,
}
}
Value::Text(_) => unreachable!(),
};
*self = Quantity::new(new_value, Some(new_unit.symbol().to_string()));
Ok(true)
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn try_fraction(&mut self, converter: &Converter) -> bool {
let Some(unit) = self.unit_info(converter) else {
return false;
};
let cfg = converter.fractions_config(&unit);
if !cfg.enabled {
return false;
}
match self.value_mut() {
Value::Number(n) => n.try_approx(cfg.accuracy, cfg.max_denominator, cfg.max_whole),
Value::Range { start, end } => {
start.try_approx(cfg.accuracy, cfg.max_denominator, cfg.max_whole)
|| end.try_approx(cfg.accuracy, cfg.max_denominator, cfg.max_whole)
}
Value::Text(_) => false,
}
}
}
impl Converter {
pub fn convert(
&self,
value: ConvertValue,
unit: ConvertUnit,
to: ConvertTo,
) -> Result<(ConvertValue, Arc<Unit>), ConvertError> {
let unit = self.get_unit(&unit)?;
let (value, unit) = match to {
ConvertTo::Unit(target_unit) => {
let to = self.get_unit(&target_unit)?;
let val = self.convert_to_unit(value, unit, to.as_ref())?;
(val, Arc::clone(to))
}
ConvertTo::Best(system) => self.convert_to_best(value, unit, system)?,
ConvertTo::SameSystem => {
self.convert_to_best(value, unit, unit.system.unwrap_or(self.default_system))?
}
};
Ok((value, unit))
}
fn convert_to_unit(
&self,
value: ConvertValue,
unit: &Unit,
target_unit: &Unit,
) -> Result<ConvertValue, ConvertError> {
if unit.physical_quantity != target_unit.physical_quantity {
return Err(ConvertError::MixedQuantities {
from: unit.physical_quantity,
to: target_unit.physical_quantity,
});
}
Ok(self.convert_value(value, unit, target_unit))
}
fn convert_to_best(
&self,
value: ConvertValue,
unit: &Unit,
system: System,
) -> Result<(ConvertValue, Arc<Unit>), ConvertError> {
let conversions = self.best[unit.physical_quantity].conversions(system);
let best_unit = conversions.best_unit(self, &value, unit).ok_or({
ConvertError::BestUnitNotFound {
physical_quantity: unit.physical_quantity,
system: unit.system,
}
})?;
let converted = self.convert_value(value, unit, best_unit.as_ref());
Ok((converted, best_unit))
}
fn convert_value(&self, value: ConvertValue, from: &Unit, to: &Unit) -> ConvertValue {
match value {
ConvertValue::Number(n) => ConvertValue::Number(self.convert_f64(n, from, to)),
ConvertValue::Range(r) => {
let s = self.convert_f64(*r.start(), from, to);
let e = self.convert_f64(*r.end(), from, to);
ConvertValue::Range(s..=e)
}
}
}
fn convert_f64(&self, value: f64, from: &Unit, to: &Unit) -> f64 {
if std::ptr::eq(from, to) {
return value;
}
convert_f64(value, from, to)
}
pub(crate) fn get_unit<'a>(
&'a self,
unit: &'a ConvertUnit,
) -> Result<&'a Arc<Unit>, UnknownUnit> {
let unit = match unit {
ConvertUnit::Unit(u) => u,
ConvertUnit::Key(key) => {
let id = self.unit_index.get_unit_id(key)?;
&self.all_units[id]
}
};
Ok(unit)
}
}
pub(crate) fn convert_f64(value: f64, from: &Unit, to: &Unit) -> f64 {
assert_eq!(from.physical_quantity, to.physical_quantity);
let norm = (value + from.difference) * from.ratio;
(norm / to.ratio) - to.difference
}
#[derive(Debug, Error)]
#[error("Unknown unit: '{0}'")]
pub struct UnknownUnit(pub String);
#[derive(PartialEq, Clone, Debug)]
pub enum ConvertValue {
Number(f64),
Range(RangeInclusive<f64>),
}
#[derive(Debug, Clone, Copy)]
pub enum ConvertUnit<'a> {
Unit(&'a Arc<Unit>),
Key(&'a str),
}
#[derive(Debug, Clone, Copy)]
pub enum ConvertTo<'a> {
SameSystem,
Best(System),
Unit(ConvertUnit<'a>),
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Deserialize,
Serialize,
Default,
PartialOrd,
Ord,
strum::Display,
strum::EnumString,
enum_map::Enum,
)]
#[serde(rename_all = "camelCase")]
#[strum(serialize_all = "camelCase")]
pub enum System {
#[default]
Metric,
Imperial,
}
impl<'a> From<&'a str> for ConvertUnit<'a> {
fn from(value: &'a str) -> Self {
Self::Key(value)
}
}
impl<'a> From<&'a Arc<Unit>> for ConvertUnit<'a> {
fn from(value: &'a Arc<Unit>) -> Self {
Self::Unit(value)
}
}
impl<'a> From<&'a str> for ConvertTo<'a> {
fn from(value: &'a str) -> Self {
Self::Unit(ConvertUnit::Key(value))
}
}
impl From<System> for ConvertTo<'_> {
fn from(value: System) -> Self {
Self::Best(value)
}
}
impl<'a> From<&'a Arc<Unit>> for ConvertTo<'a> {
fn from(value: &'a Arc<Unit>) -> Self {
Self::Unit(value.into())
}
}
impl From<ConvertValue> for Value {
fn from(value: ConvertValue) -> Self {
match value {
ConvertValue::Number(n) => Self::Number(n.into()),
ConvertValue::Range(r) => Self::Range {
start: (*r.start()).into(),
end: (*r.end()).into(),
},
}
}
}
impl TryFrom<&Value> for ConvertValue {
type Error = ConvertError;
fn try_from(value: &Value) -> Result<Self, Self::Error> {
let value = match value {
Value::Number(n) => ConvertValue::Number(n.value()),
Value::Range { start, end } => ConvertValue::Range(start.value()..=end.value()),
Value::Text(t) => return Err(ConvertError::TextValue(t.clone())),
};
Ok(value)
}
}
impl From<f64> for ConvertValue {
fn from(value: f64) -> Self {
Self::Number(value)
}
}
impl From<RangeInclusive<f64>> for ConvertValue {
fn from(value: RangeInclusive<f64>) -> Self {
Self::Range(value)
}
}
impl PartialOrd<Self> for ConvertValue {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
fn extract(v: &ConvertValue) -> f64 {
match v {
ConvertValue::Number(n) => *n,
ConvertValue::Range(r) => *r.start(),
}
}
let this = extract(self);
let other = extract(other);
this.partial_cmp(&other)
}
}
#[derive(Debug, Error)]
pub enum ConvertError {
#[error("Tried to convert a value with no unit")]
NoUnit(Quantity),
#[error("Tried to convert a text value: {0}")]
TextValue(String),
#[error("Mixed physical quantities: {from} {to}")]
MixedQuantities {
from: PhysicalQuantity,
to: PhysicalQuantity,
},
#[error("Could not find best unit for a {physical_quantity} unit. System: {system:?}")]
BestUnitNotFound {
physical_quantity: PhysicalQuantity,
system: Option<System>,
},
#[error(transparent)]
UnknownUnit(#[from] UnknownUnit),
}