Last active
February 19, 2025 17:42
-
-
Save markusand/05aaffd9cad47858b3a9c45e4ec5c58b to your computer and use it in GitHub Desktop.
A simple unit converter system with Measure value object
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"""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}" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Using NamedTuple becomes more verbose but also a lot more explicit