Skip to content

Instantly share code, notes, and snippets.

@markusand
Last active February 19, 2025 17:42
Show Gist options
  • Save markusand/05aaffd9cad47858b3a9c45e4ec5c58b to your computer and use it in GitHub Desktop.
Save markusand/05aaffd9cad47858b3a9c45e4ec5c58b to your computer and use it in GitHub Desktop.
A simple unit converter system with Measure value object
"""Unit conversion"""
from dataclasses import dataclass
from functools import total_ordering
from enum import Enum
from typing import Callable, NamedTuple
class UnitDesc(NamedTuple):
"""Unit description"""
scale: float
offset: float
symbol: int
class Unit(Enum):
"""Base class for units"""
@property
def scale(self) -> float:
"""Returns the scale factor of the unit"""
return self.value.scale
@property
def offset(self) -> float:
"""Returns the offset of the unit"""
return self.value.offset
@property
def symbol(self) -> str:
"""Returns the symbol of the unit"""
return self.value.symbol
def to(self, unit: 'Unit') -> Callable[[float], float]:
"""Convert to another unit"""
def convert(x: float) -> float:
base = (x - self.offset) * self.scale
return base / unit.scale + unit.offset
return convert
def is_compatible(self, unit: 'Unit') -> bool:
"""Check if the unit is compatible with another unit"""
return isinstance(unit, type(self))
class WeightUnit(Unit):
"""Weight units"""
GRAM = UnitDesc(scale=1, offset=0, symbol='g')
KILOGRAM = UnitDesc(scale=1000, offset=0, symbol='kg')
MILLIGRAM = UnitDesc(scale=0.001, offset=0, symbol='mg')
POUND = UnitDesc(scale=453.592, offset=0, symbol='lb')
OUNCE = UnitDesc(scale=28.3495, offset=0, symbol='oz')
class LengthUnit(Unit):
"""Length units"""
METER = UnitDesc(scale=1, offset=0, symbol='m')
KILOMETER = UnitDesc(scale=1000, offset=0, symbol='km')
CENTIMETER = UnitDesc(scale=0.01, offset=0, symbol='cm')
MILLIMETER = UnitDesc(scale=0.001, offset=0, symbol='mm')
MILE = UnitDesc(scale=1609.34, offset=0, symbol='mi')
FOOT = UnitDesc(scale=0.3048, offset=0, symbol='ft')
INCH = UnitDesc(scale=0.0254, offset=0, symbol='in')
class VolumeUnit(Unit):
"""Volume units"""
LITER = UnitDesc(scale=1, offset=0, symbol='l')
MILLILITER = UnitDesc(scale=0.001, offset=0, symbol='ml')
CUBIC_METER = UnitDesc(scale=1000, offset=0, symbol='m^3')
GALLON = UnitDesc(scale=4.54609, offset=0, symbol='gal')
US_GALLON = UnitDesc(scale=3.785411784, offset=0, symbol='gal')
class TemperatureUnit(Unit):
"""Temperature units"""
CELSIUS = UnitDesc(scale=1, offset=0, symbol='°C')
FAHRENHEIT = UnitDesc(scale=5/9, offset=32, symbol='°F')
KELVIN = UnitDesc(scale=1, offset=273.15, symbol='°K')
@dataclass(frozen=True)
@total_ordering
class Measure:
"""A measurement with quantity and unit"""
qty: float
unit: Unit
def to(self, unit: Unit) -> 'Measure':
"""Convert to another unit"""
if not unit.is_compatible(self.unit):
raise TypeError(f"Cannot convert from {self.unit.name} to {unit.name}")
convert = self.unit.to(unit)
return Measure(qty=convert(self.qty), unit=unit)
def __add__(self, other: 'Measure') -> 'Measure':
if not other.unit.is_compatible(self.unit):
return NotImplemented
convert = other.unit.to(self.unit)
return Measure(qty=self.qty + convert(other.qty), unit=self.unit)
def __sub__(self, other: 'Measure') -> 'Measure':
if not other.unit.is_compatible(self.unit):
return NotImplemented
convert = other.unit.to(self.unit)
return Measure(qty=self.qty - convert(other.qty), unit=self.unit)
def __mul__(self, multiplier: float) -> 'Measure':
return Measure(qty=self.qty * multiplier, unit=self.unit)
def __truediv__(self, divider: float) -> 'Measure':
if divider == 0:
raise ZeroDivisionError("Cannot divide by zero.")
return self.__mul__(1 / divider)
def __eq__(self, other: 'Measure') -> bool:
if not other.unit.is_compatible(self.unit):
return NotImplemented
convert = other.unit.to(self.unit)
return self.qty == convert(other.qty)
def __lt__(self, other: 'Measure') -> bool:
if not other.unit.is_compatible(self.unit):
return NotImplemented
convert = other.unit.to(self.unit)
return self.qty < convert(other.qty)
def __str__(self):
return f"{self.qty} {self.unit.symbol}"
def __repr__(self):
return self.__str__()
def __format__(self, spec):
return f"{self.qty:{spec}} {self.unit.symbol}"
@markusand
Copy link
Author

Using NamedTuple becomes more verbose but also a lot more explicit

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment