Source code for ferrosoft.apps.ferrobase.services.conversion

#  Copyright (c) 2025 Ferrosoft GmbH. All rights reserved.
"""Scientific unit conversion and normalisation.

Provides direct unit-to-unit conversion via the :data:`MULTIPLIERS` lookup
table and category-based normalisation via :data:`NORMALIZED_UNITS`.  All
arithmetic is done with :class:`decimal.Decimal` and quantised to
:data:`PRECISION` to absorb precision loss introduced by Django ORM fields.
"""

import dataclasses
from decimal import Decimal

from django.utils.translation import gettext as _

from ferrosoft.apps.ferrobase.models import UnitCursor, UnitSystem, UnitCategory
from ferrosoft.apps.ferrobase.models.decimal import DecimalWithUnit


[docs] class ConversionError(Exception): """Raised when no multiplier exists for the requested source → target pair."""
[docs] class NormalizationError(ConversionError): """Raised when a value cannot be normalised (missing unit or density)."""
PRECISION = Decimal("1.0000000000000000") """Working precision (16 decimal places) used to quantise intermediate values.""" # fmt: off MULTIPLIERS: dict[tuple[UnitCursor, UnitCursor], Decimal] = { (UnitCursor("kWh"), UnitCursor("MJ")): Decimal("3.6"), (UnitCursor("kg"), UnitCursor("t")): Decimal("0.001"), (UnitCursor("kg"), UnitCursor("g")): Decimal("1000"), (UnitCursor("t"), UnitCursor("kg")): Decimal("1000"), (UnitCursor("t"), UnitCursor("g")): Decimal(1_000_000), (UnitCursor("g"), UnitCursor("kg")): Decimal("0.001"), (UnitCursor("g"), UnitCursor("t")): Decimal("0.000001"), (UnitCursor("m"), UnitCursor("km")): Decimal("0.001"), (UnitCursor("km"), UnitCursor("m")): Decimal("1000"), (UnitCursor("kgCO2e"), UnitCursor("gCO2e")): Decimal("1000"), # US distance units (UnitCursor("ft", UnitSystem.US_CUSTOMARY), UnitCursor("km")): Decimal("0.0003048"), (UnitCursor("yd", UnitSystem.US_CUSTOMARY), UnitCursor("km")): Decimal("0.0009144"), (UnitCursor("mi", UnitSystem.US_CUSTOMARY), UnitCursor("km")): Decimal("1.609344"), (UnitCursor("le", UnitSystem.US_CUSTOMARY), UnitCursor("km")): Decimal("4.828032"), (UnitCursor("ftm", UnitSystem.US_CUSTOMARY), UnitCursor("km")): Decimal("0.0018288"), (UnitCursor("cb", UnitSystem.US_CUSTOMARY), UnitCursor("km")): Decimal("0.219456"), (UnitCursor("nmi", UnitSystem.US_CUSTOMARY), UnitCursor("km")): Decimal("1.852"), # Imperial distance units (UnitCursor("ft", UnitSystem.IMPERIAL), UnitCursor("km")): Decimal("0.0003048"), (UnitCursor("yd", UnitSystem.IMPERIAL), UnitCursor("km")): Decimal("0.0009144"), (UnitCursor("ch", UnitSystem.IMPERIAL), UnitCursor("km")): Decimal("0.0201168"), (UnitCursor("fur", UnitSystem.IMPERIAL), UnitCursor("km")): Decimal("0.201168"), (UnitCursor("mile", UnitSystem.IMPERIAL), UnitCursor("km")): Decimal("1.609344"), (UnitCursor("lea", UnitSystem.IMPERIAL), UnitCursor("km")): Decimal("4.828032"), (UnitCursor("ftm", UnitSystem.IMPERIAL), UnitCursor("km")): Decimal("0.0018288"), (UnitCursor("cb", UnitSystem.IMPERIAL), UnitCursor("km")): Decimal("0.1852"), (UnitCursor("nmi", UnitSystem.IMPERIAL), UnitCursor("km")): Decimal("1.852"), # US volume units (UnitCursor("cu in", UnitSystem.US_CUSTOMARY), UnitCursor("L")): Decimal("0.016387064"), (UnitCursor("cu ft", UnitSystem.US_CUSTOMARY), UnitCursor("L")): Decimal("28.316846592"), (UnitCursor("cu yd", UnitSystem.US_CUSTOMARY), UnitCursor("L")): Decimal("764.554857984"), (UnitCursor("acre ft", UnitSystem.US_CUSTOMARY), UnitCursor("L")): Decimal("1233481.8375475"), (UnitCursor("US fl oz", UnitSystem.US_CUSTOMARY), UnitCursor("L")): Decimal("0.029573529563"), (UnitCursor("US cup", UnitSystem.US_CUSTOMARY), UnitCursor("L")): Decimal("0.2365882365"), (UnitCursor("US pt", UnitSystem.US_CUSTOMARY), UnitCursor("L")): Decimal("0.473176473"), (UnitCursor("US qt", UnitSystem.US_CUSTOMARY), UnitCursor("L")): Decimal("0.946352946"), (UnitCursor("pot", UnitSystem.US_CUSTOMARY), UnitCursor("L")): Decimal("1.892705892"), (UnitCursor("US gal", UnitSystem.US_CUSTOMARY), UnitCursor("L")): Decimal("3.785411784"), (UnitCursor("bbl", UnitSystem.US_CUSTOMARY), UnitCursor("L")): Decimal("119.240471196"), (UnitCursor("oil bbl", UnitSystem.US_CUSTOMARY), UnitCursor("L")): Decimal("158.987294928"), (UnitCursor("hhd", UnitSystem.US_CUSTOMARY), UnitCursor("L")): Decimal("238.480942392"), (UnitCursor("pt", UnitSystem.US_CUSTOMARY), UnitCursor("L")): Decimal("0.5506104713575"), (UnitCursor("qt", UnitSystem.US_CUSTOMARY), UnitCursor("L")): Decimal("1.101220942715"), (UnitCursor("gal", UnitSystem.US_CUSTOMARY), UnitCursor("L")): Decimal("4.40488377086"), (UnitCursor("pk", UnitSystem.US_CUSTOMARY), UnitCursor("L")): Decimal("8.80976754172"), (UnitCursor("bu", UnitSystem.US_CUSTOMARY), UnitCursor("L")): Decimal("35.23907016688"), (UnitCursor("dry bbl", UnitSystem.US_CUSTOMARY), UnitCursor("L")): Decimal("115.627123584"), # Imperial volume units (UnitCursor("fl oz", UnitSystem.IMPERIAL), UnitCursor("L")): Decimal("0.028413062500"), (UnitCursor("gi", UnitSystem.IMPERIAL), UnitCursor("L")): Decimal("0.1420653125"), (UnitCursor("pt", UnitSystem.IMPERIAL), UnitCursor("L")): Decimal("0.56826125"), (UnitCursor("qt", UnitSystem.IMPERIAL), UnitCursor("L")): Decimal("1.1365225"), (UnitCursor("gal", UnitSystem.IMPERIAL), UnitCursor("L")): Decimal("4.54609"), # US mass units (UnitCursor("oz", UnitSystem.US_CUSTOMARY), UnitCursor("t")): Decimal("28.349523125") * Decimal("0.000001"), (UnitCursor("lb", UnitSystem.US_CUSTOMARY), UnitCursor("t")): Decimal("453.59237") * Decimal("0.000001"), (UnitCursor("cwt", UnitSystem.US_CUSTOMARY), UnitCursor("t")): Decimal("0.045359237"), (UnitCursor("long cwt", UnitSystem.US_CUSTOMARY), UnitCursor("t")): Decimal("0.05080234544"), (UnitCursor("st", UnitSystem.US_CUSTOMARY), UnitCursor("t")): Decimal("0.90718474"), (UnitCursor("ton", UnitSystem.US_CUSTOMARY), UnitCursor("t")): Decimal("1.0160469088"), (UnitCursor("dw t", UnitSystem.US_CUSTOMARY), UnitCursor("t")): Decimal("1.55517384") * Decimal("0.000001"), (UnitCursor("oz t", UnitSystem.US_CUSTOMARY), UnitCursor("t")): Decimal("31.1034768") * Decimal("0.000001"), (UnitCursor("lb t", UnitSystem.US_CUSTOMARY), UnitCursor("t")): Decimal("373.2417216") * Decimal("0.000001"), # Imperial mass units (UnitCursor("oz", UnitSystem.IMPERIAL), UnitCursor("t")): Decimal("0.028349523125") * Decimal("0.000001"), (UnitCursor("lb", UnitSystem.IMPERIAL), UnitCursor("t")): Decimal("453.59237") * Decimal("0.000001"), (UnitCursor("st", UnitSystem.IMPERIAL), UnitCursor("t")): Decimal("0.00635029318"), (UnitCursor("qr", UnitSystem.IMPERIAL), UnitCursor("t")): Decimal("0.01270058636"), (UnitCursor("cwt", UnitSystem.IMPERIAL), UnitCursor("t")): Decimal("0.05080234544"), (UnitCursor("ton", UnitSystem.IMPERIAL), UnitCursor("t")): Decimal("1.0160469088"), } """Define multipliers for conversion from one unit to another unit.""" # fmt: on NORMALIZED_UNITS: dict[tuple[UnitCategory, UnitCursor | None], UnitCursor] = { (UnitCategory.DISTANCE, None): UnitCursor("km"), (UnitCategory.EMISSION_FACTOR, UnitCursor("gCO2e/MJ")): UnitCursor("gCO2e/MJ"), (UnitCategory.EMISSION_FACTOR, UnitCursor("kgCO2e/kg")): UnitCursor("kgCO2e/kg"), (UnitCategory.EMISSION_VALUE, None): UnitCursor("gCO2e"), (UnitCategory.MASS, None): UnitCursor("t"), (UnitCategory.NATIVE_CONSUMPTION, None): UnitCursor("kg"), (UnitCategory.NATIVE_CONSUMPTION, UnitCursor("kWh")): UnitCursor("MJ"), } """Define which unit to convert to for a given unit category. The second tuple member specifies the source unit. If it is None, the target unit serves as the default for any source unit. If it's not None, the target unit takes precedence over the default target unit. Advanced special casing is done in normalize_unit.""" NON_METRIC_VOLUME_UNITS = [ # US Customary UnitCursor("cu in", UnitSystem.US_CUSTOMARY), UnitCursor("cu ft", UnitSystem.US_CUSTOMARY), UnitCursor("cu yd", UnitSystem.US_CUSTOMARY), UnitCursor("acre ft", UnitSystem.US_CUSTOMARY), UnitCursor("US fl oz", UnitSystem.US_CUSTOMARY), UnitCursor("US cup", UnitSystem.US_CUSTOMARY), UnitCursor("US pt", UnitSystem.US_CUSTOMARY), UnitCursor("US qt", UnitSystem.US_CUSTOMARY), UnitCursor("pot", UnitSystem.US_CUSTOMARY), UnitCursor("US gal", UnitSystem.US_CUSTOMARY), UnitCursor("bbl", UnitSystem.US_CUSTOMARY), UnitCursor("oil bbl", UnitSystem.US_CUSTOMARY), UnitCursor("hhd", UnitSystem.US_CUSTOMARY), UnitCursor("pt", UnitSystem.US_CUSTOMARY), UnitCursor("qt", UnitSystem.US_CUSTOMARY), UnitCursor("gal", UnitSystem.US_CUSTOMARY), UnitCursor("pk", UnitSystem.US_CUSTOMARY), UnitCursor("bu", UnitSystem.US_CUSTOMARY), UnitCursor("dry bbl", UnitSystem.US_CUSTOMARY), # Imperial UnitCursor("fl oz", UnitSystem.IMPERIAL), UnitCursor("gi", UnitSystem.IMPERIAL), UnitCursor("pt", UnitSystem.IMPERIAL), UnitCursor("qt", UnitSystem.IMPERIAL), UnitCursor("gal", UnitSystem.IMPERIAL), ] """List of non-metric units which can be converted to liter."""
[docs] def convert_unit( value: DecimalWithUnit, target_unit: UnitCursor, ) -> DecimalWithUnit: """Convert ``value`` to ``target_unit`` using :data:`MULTIPLIERS`. Args: value: Source quantity carrying its own unit. target_unit: Desired target unit. Returns: DecimalWithUnit: ``value`` expressed in ``target_unit``. When the source and target units already match, ``value`` is returned as a shallow copy without arithmetic. Raises: ConversionError: If no direct multiplier exists for the ``(source_unit, target_unit)`` pair. """ source_unit = value.try_unit().cursor if target_unit == source_unit: # Return value without needing explicit multiplier of 1. return dataclasses.replace(value) key = (source_unit, target_unit) multiplier = MULTIPLIERS.get(key, None) if multiplier is None: raise ConversionError( _("Cannot convert from %(source_unit)s to %(target_unit)s") % {"source_unit": str(source_unit), "target_unit": str(target_unit)} ) # Quantize value to avoid precision loss with values coming from Django ORM # (NormalDecimalField defaults to 4 decimal places). return DecimalWithUnit.create( value.value.quantize(PRECISION) * multiplier, target_unit )
[docs] def normalize_unit( category: UnitCategory, value: DecimalWithUnit, *, density: Decimal | None = None, ) -> DecimalWithUnit: """Convert value to a blessed unit of the given unit category. See NORMALIZED_UNITS for blessed units. In addition, this function may handle some special casing pertaining to the unit category. Args: category: Unit category of the blessed unit to convert to. value: Value to convert. density: Density must be specified for NATIVE_CONSUMPTION category, if the source unit is a volume unit (e.g. liter or fluid ounce). """ # Early return for zero value if value == DecimalWithUnit.zero() and value.unit is None: return dataclasses.replace(value) # Quantize value to avoid precision loss with values coming from Django ORM # (NormalDecimalField defaults to 4 decimal places). value = dataclasses.replace(value, value=value.value.quantize(PRECISION)) if value.unit is None: raise NormalizationError(_("Cannot normalize value with unknown unit")) source_unit = value.unit.cursor # Let source unit have precedence over default record. target_unit = NORMALIZED_UNITS.get( (category, source_unit), NORMALIZED_UNITS.get((category, None), None) ) # Special case NATIVE_CONSUMPTION: volume unit "Liter" must be converted to # mass based on density. Also, there are many non-metric volume units, which # require an additional conversion step beforehand. if category == UnitCategory.NATIVE_CONSUMPTION: if source_unit in NON_METRIC_VOLUME_UNITS: value = convert_unit(value, UnitCursor("L")) source_unit = value.unit.cursor if source_unit == UnitCursor("L"): if density is None: raise NormalizationError( _("Density must be specified for quantity with Liter unit") ) # No need to go through convert_unit return (value * density).with_unit("kg") return convert_unit(value, target_unit)