# Copyright (c) 2025 Ferrosoft GmbH. All rights reserved.
from dataclasses import dataclass
from enum import IntEnum
from typing import Tuple, Iterable
from uuid import UUID
from django.db import models
from django.db.models import QuerySet, IntegerChoices
from django.utils.translation import gettext_lazy as _
from ferrosoft.apps.ferrobase.models.base import UUIDModel
[docs]
class UndefinedUnit(Exception):
"""Raised when a unit symbol or cursor cannot be resolved to a known ``MeasurementUnit``."""
[docs]
class UnitSystem(IntegerChoices):
"""System of measurement that groups related units together."""
METRIC = 0, _("Metric")
US_CUSTOMARY = 1, _("US Customary")
IMPERIAL = 2, _("Imperial")
[docs]
@dataclass(frozen=True)
class UnitCursor:
"""Used for lookup of units in UnitRegistry"""
symbol: str
system: UnitSystem = UnitSystem.METRIC
def __str__(self):
return f"{self.symbol} ({self.system.label})"
[docs]
class UnitCategory(IntEnum):
"""Physical quantity category used to group ``UnitCollection`` entries."""
DISTANCE = 1
EMISSION_VALUE = 2
EMISSION_FACTOR = 3
ENERGY = 4
MASS = 5
NATIVE_CONSUMPTION = 6
[docs]
class UnitCollection:
"""Pre-defined sets of ``UnitCursor`` objects organised by physical quantity.
Each class attribute is a list of ``UnitCursor`` instances covering the
supported units for that quantity across all ``UnitSystem`` values.
Used to populate ``UnitChoiceField`` and to seed the ``MeasurementUnit``
table during ``ferrobase_update``.
"""
DISTANCE = [
UnitCursor("km", UnitSystem.METRIC),
UnitCursor("m", UnitSystem.METRIC),
# US units normalizing to kilometer
UnitCursor("ft", UnitSystem.US_CUSTOMARY),
UnitCursor("yd", UnitSystem.US_CUSTOMARY),
UnitCursor("mi", UnitSystem.US_CUSTOMARY),
UnitCursor("le", UnitSystem.US_CUSTOMARY),
UnitCursor("ftm", UnitSystem.US_CUSTOMARY),
UnitCursor("cb", UnitSystem.US_CUSTOMARY),
UnitCursor("nmi", UnitSystem.US_CUSTOMARY),
# Imperial units normalizing to kilometer
UnitCursor("ft", UnitSystem.IMPERIAL),
UnitCursor("yd", UnitSystem.IMPERIAL),
UnitCursor("ch", UnitSystem.IMPERIAL),
UnitCursor("fur", UnitSystem.IMPERIAL),
UnitCursor("mile", UnitSystem.IMPERIAL),
UnitCursor("lea", UnitSystem.IMPERIAL),
UnitCursor("ftm", UnitSystem.IMPERIAL),
UnitCursor("cb", UnitSystem.IMPERIAL),
UnitCursor("nmi", UnitSystem.IMPERIAL),
]
EMISSION = [
UnitCursor("gCO2e", UnitSystem.METRIC),
UnitCursor("kgCO2e", UnitSystem.METRIC),
UnitCursor("tCO2e", UnitSystem.METRIC),
]
EMISSION_FACTOR = [
UnitCursor("kgCO2e/kg"),
UnitCursor("gCO2e/MJ"),
]
ENERGY = [
UnitCursor("MJ/L", UnitSystem.METRIC),
UnitCursor("MJ/kg", UnitSystem.METRIC),
UnitCursor("MJ/kWh", UnitSystem.METRIC),
]
MASS = [
UnitCursor("kg", UnitSystem.METRIC),
UnitCursor("t", UnitSystem.METRIC),
# US units normalizing to metric ton
UnitCursor("oz", UnitSystem.US_CUSTOMARY),
UnitCursor("lb", UnitSystem.US_CUSTOMARY),
UnitCursor("cwt", UnitSystem.US_CUSTOMARY),
UnitCursor("long cwt", UnitSystem.US_CUSTOMARY),
UnitCursor("st", UnitSystem.US_CUSTOMARY),
UnitCursor("ton", UnitSystem.US_CUSTOMARY),
UnitCursor("dw t", UnitSystem.US_CUSTOMARY),
UnitCursor("oz t", UnitSystem.US_CUSTOMARY),
UnitCursor("lb t", UnitSystem.US_CUSTOMARY),
# Imperial units normalizing to metric ton
UnitCursor("oz", UnitSystem.IMPERIAL),
UnitCursor("lb", UnitSystem.IMPERIAL),
UnitCursor("st", UnitSystem.IMPERIAL),
UnitCursor("qr", UnitSystem.IMPERIAL),
UnitCursor("cwt", UnitSystem.IMPERIAL),
UnitCursor("ton", UnitSystem.IMPERIAL),
]
NATIVE_CONSUMPTION = [
UnitCursor("kg", UnitSystem.METRIC),
UnitCursor("L", UnitSystem.METRIC),
UnitCursor("kWh", UnitSystem.METRIC),
# US units normalizing to liter
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 units normalizing to liter
UnitCursor("fl oz", UnitSystem.IMPERIAL),
UnitCursor("gi", UnitSystem.IMPERIAL),
UnitCursor("pt", UnitSystem.IMPERIAL),
UnitCursor("qt", UnitSystem.IMPERIAL),
UnitCursor("gal", UnitSystem.IMPERIAL),
]
VOLUMETRIC = [
UnitCursor("L", UnitSystem.METRIC),
# US units normalizing to liter
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 units normalizing to liter
UnitCursor("fl oz", UnitSystem.IMPERIAL),
UnitCursor("gi", UnitSystem.IMPERIAL),
UnitCursor("pt", UnitSystem.IMPERIAL),
UnitCursor("qt", UnitSystem.IMPERIAL),
UnitCursor("gal", UnitSystem.IMPERIAL),
]
[docs]
class MeasurementUnitManager(models.Manager):
"""Manager for ``MeasurementUnit`` with bulk-lookup and upsert helpers."""
[docs]
def get_lookup(self, *symbols: str) -> "dict[str, MeasurementUnit]":
return {unit.symbol: unit for unit in self.filter(symbol__in=symbols)}
[docs]
def upsert(self, objects):
self.bulk_create(
objects,
update_conflicts=True,
update_fields=["name"],
unique_fields=["system", "symbol"],
)
[docs]
class MeasurementUnit(UUIDModel):
"""A physical unit of measurement identified by a symbol and a ``UnitSystem``.
The combination of ``symbol`` and ``system`` is unique so that, for
example, the imperial ``"ft"`` and the US customary ``"ft"`` are separate
entries. Equality comparison supports ``UnitCursor`` and plain symbol
strings (implicitly metric) for convenience.
"""
class Meta:
verbose_name = _("Measurement Unit")
verbose_name_plural = _("Measurement Units")
indexes = [
models.Index(fields=["name"]),
models.Index(fields=["system"]),
]
constraints = [
models.UniqueConstraint(
name="uniq_measurement_unit",
fields=["system", "symbol"],
)
]
objects = MeasurementUnitManager()
symbol = models.CharField(
verbose_name=_("Symbol"),
)
name = models.CharField(
verbose_name=_("Name"),
)
system = models.IntegerField(
choices=UnitSystem.choices,
default=UnitSystem.METRIC,
verbose_name=_("System of Units of Measurement"),
)
def __str__(self):
if self.name == "":
return self.symbol
return f"{self.symbol} - {self.name}"
def __eq__(self, other):
if isinstance(other, UnitCursor):
return self.symbol == other.symbol and self.system == other.system
elif isinstance(other, str):
return self.symbol == other and self.system == UnitSystem.METRIC.value
else:
return super().__eq__(other)
[docs]
@classmethod
def get_ordered(cls) -> QuerySet:
return cls.objects.filter(system=UnitSystem.METRIC).order_by("symbol")
@property
def cursor(self) -> UnitCursor:
return UnitCursor(self.symbol, UnitSystem(self.system))
@property
def fraction(self) -> Tuple[str, str]:
"""Get tuple of denominator and divider of the symbol, if it is a fraction.
Raise exception if it is not a fraction."""
parts = self.symbol.split("/")
if len(parts) != 2:
raise ValueError("Unit is not a fraction")
return parts[0], parts[1]
UnitRef = str | UUID | MeasurementUnit | UnitCursor
"""Measurement units can be retrieved by values of these types. A string is
treated as the unit symbol (and implicit metric unit system)."""