import math
from typing import List, Tuple, Dict, Optional, Callable
"""
Measurement and unit scaling framework.
This module provides a small, self-contained framework to represent quantities,
units, and measured values with smart, human-friendly formatting. It is used
throughout the project (notably in KPI templates) to present values using the
most appropriate unit and number of digits without writing formatting logic in
multiple places.
Core concepts:
- **Quantity**: A domain of measurement (e.g., Length, Time, Data). A quantity owns
a chain of related units and knows how they convert.
- **Unit**: A single unit with a symbol (e.g., m, s, B). Units are linked from
smaller to larger and vice versa so values can scale up or down.
- **BaseMeasurement**: A formatting preference object: it stores the base unit for a
value and how it should be displayed (min/max significant digits and decimal
places, optional smallest/largest unit clamps).
- **Measurement**: A concrete value bound to a BaseMeasurement. It can be scaled to
a better unit and formatted with the desired precision.
Why this exists:
Displaying 15320.0 seconds as "4.26 h" or 12_345_678 B as "11.77 MiB" should be
trivial for users of the framework, and consistent across the app. This module
centralizes unit chains, conversions, and pretty-printing so KPIs and UI code
just construct a Measurement and call `pretty()`.
Quick start:
1. Pick a quantity and base unit using the global `QUANTITIES` registry.
2. Create a `BaseMeasurement` describing how you want values formatted.
3. Create a `Measurement(base_measurement, value)` and call `pretty()`.
Example: Length
>>> from algomancy_utils.unit import QUANTITIES, BaseMeasurement, Measurement
>>> length = QUANTITIES["length"]
>>> length_m = BaseMeasurement(length["m"], min_digits=1, max_digits=3, decimals=2)
>>> for val in [0.000005, 0.025, 2.5, 250, 25_000, 2_500_000]:
... m = Measurement(length_m, val)
... print(m.pretty())
5.00 μm
25.12 mm
2.50 m
250.00 m
25.00 km
2.50 Mm
Example: Time with tighter bounds
>>> time = QUANTITIES["time"]
>>> # Clamp scaling between seconds and hours, show 1 decimal
>>> prefs = BaseMeasurement(time["s"], min_digits=1, max_digits=2, decimals=1,
... smallest_unit="s", largest_unit="h")
>>> for val in [0.5, 45, 3_665, 86_400]:
... print(Measurement(prefs, val).pretty())
0.5 s
45.0 s
1.0 h
24.0 h
Example: Money
>>> money = QUANTITIES["money"]
>>> usd = BaseMeasurement(money["€"], min_digits=0, max_digits=3, decimals=2)
>>> print(Measurement(usd, 1_234_567).pretty())
€1.23M
Formatting Preferences:
- **min_digits** / **max_digits** bound the number of significant digits before a unit
change is attempted. This keeps values compact while avoiding "0.00" noise.
- **decimals** controls the decimal places in the final formatted string.
- **smallest_unit** / **largest_unit** can be set to restrict automatic scaling range.
See the runnable examples at the end of the file (the `__main__` section) for
more sample outputs across quantities like mass, time, money, and data.
"""
# ============================================================================
# Scaling unit framework
# ============================================================================
[docs]
class Unit:
"""
A single unit in a quantity (e.g., m, s, B).
A `Unit` knows its printable `symbol` and can be linked to
an adjacent smaller or larger unit with known conversion factors. These
links are used by `Measurement` to automatically scale values up or down to
keep within the configured digit bounds.
Args:
symbol: The printable symbol for the unit.
Notes:
- Links are set when units are added to a `Quantity` via
`Quantity.add_unit(...)`. You generally don't set them manually.
- `conversion_factor_to_larger` represents how many of the current unit make
one of the larger unit. The reverse factor is stored on the larger unit to
point back to the smaller.
"""
def __init__(self, symbol: str):
self.symbol: str = symbol
self.smaller_unit: "Unit | None" = None
self.conversion_factor_to_smaller: float | None = None
self.larger_unit: "Unit | None" = None
self.conversion_factor_to_larger: float | None = None
def __str__(self):
"""
Returns a human-readable description for debugging.
"""
return f"Unit: {self.symbol}"
def _set_smaller_unit(self, smaller_unit: "Unit", conversion_factor: float):
"""
Sets the link to a smaller unit.
Args:
smaller_unit: The unit that is smaller than this one.
conversion_factor: How many of the smaller unit make one of this unit.
"""
self.smaller_unit = smaller_unit
self.conversion_factor_to_smaller = conversion_factor
[docs]
def set_larger_unit(self, larger_unit: "Unit", conversion_factor: float):
"""
Sets the link to a larger unit and automatically sets the reverse link.
Args:
larger_unit: The unit that is larger than this one.
conversion_factor: How many of this unit make one of the larger unit.
"""
self.larger_unit = larger_unit
self.conversion_factor_to_larger = conversion_factor
larger_unit._set_smaller_unit(self, 1 / conversion_factor)
[docs]
class Quantity:
"""
A domain of measurement that owns and links units.
A `Quantity` groups a set of related `Unit`s (e.g., meters, kilometers for
length; seconds, hours for time) and stores their conversion relationships in
increasing order. It provides convenient access by unit name via
`quantity["m"]` and is responsible for wiring the chain of smaller/larger
units so values can scale automatically.
Args:
name: The name of the quantity.
standard_unit: The standard (base) unit for this quantity.
Notes:
- Units are sorted by their factor relative to the standard/base unit you
provide when adding them.
- When you add a new unit, bidirectional links between adjacent units are
re-built automatically.
"""
def __init__(self, name: str, standard_unit: Unit):
self.name: str = name
self.standard_unit: Unit = standard_unit
self.sorted_units: List[Tuple[Unit, float]] = [(standard_unit, 1)]
self.associated_units: Dict[str, Unit] = {standard_unit.symbol: standard_unit}
def __getitem__(self, key) -> Unit | None:
"""
Retrieves a unit by its symbol.
Args:
key: The symbol of the unit to retrieve.
Returns:
The `Unit` object if found.
Raises:
KeyError: If the unit symbol is not found in this quantity.
"""
try:
return self.associated_units[key]
except KeyError as ke:
raise KeyError(
f"Unit '{key}' not found in quantity '{self.name}'\n"
f" Available units are: {', '.join(self.associated_units.keys())}"
) from ke
[docs]
def add_unit(self, base_unit: Unit, factor_to_base: float):
"""
Adds a new unit to the quantity.
Args:
base_unit: The `Unit` object to add.
factor_to_base: The conversion factor from this unit to the standard unit.
Raises:
ValueError: If a unit with the same symbol already exists.
"""
if base_unit.symbol in self.associated_units:
raise ValueError(
f"Unit '{base_unit.symbol}' already exists in quantity '{self.name}'"
)
self.associated_units[base_unit.symbol] = base_unit
self.sorted_units.append((base_unit, factor_to_base))
self._sort_associated_units()
self._relink_units()
def _sort_associated_units(self):
"""
Sorts the associated units by their factor to base.
"""
self.sorted_units.sort(key=lambda x: x[1])
def _relink_units(self):
"""
Re-establishes the links between all units in the sorted chain.
"""
self._sort_associated_units()
for i, (unit, factor) in enumerate(self.sorted_units):
if i == len(self.sorted_units) - 1:
break
next_unit, next_factor = self.sorted_units[i + 1]
unit.set_larger_unit(next_unit, factor / next_factor)
[docs]
class BaseMeasurement:
"""
Display and scaling preferences for measurements.
A `BaseMeasurement` binds a base `Unit` with formatting and scaling rules
that `Measurement` instances will follow when pretty-printing values.
Args:
base_unit: The unit in which input values are expressed (e.g., seconds,
meters, bytes). Auto-scaling starts from this unit.
min_digits: Minimum number of significant digits to keep before trying to
scale down to a smaller unit. Helps avoid values like "0.00".
Defaults to 0.
max_digits: Maximum number of significant digits to allow before trying to
scale up to a larger unit. Keeps values compact (e.g., 2500 m -> 2.5 km).
Defaults to 3.
decimals: Number of decimal places to show in the final formatted string.
Defaults to 2.
smallest_unit: Optional symbol of the smallest allowed unit for scaling.
largest_unit: Optional symbol of the largest allowed unit for scaling.
formatter: Optional custom function to format the `Measurement`.
use_scaling: Whether to enable automatic unit scaling. Defaults to True.
Notes:
- This class contains no value; it is reused across many `Measurement`
instances that share the same formatting rules.
- If `smallest_unit`/`largest_unit` are provided, they must match unit symbols
registered in the corresponding `Quantity`.
"""
def __init__(
self,
base_unit: Unit,
min_digits: int = 0,
max_digits: int = 3,
decimals: int = 2,
smallest_unit: str | None = None,
largest_unit: str | None = None,
formatter: Optional[Callable[[Measurement], str]] = None,
use_scaling: bool = True,
):
self.unit: Unit = base_unit
self.min_digits: int = min_digits
self.max_digits: int = max_digits
self.decimals: int = decimals
self.smallest_unit: str | None = smallest_unit
self.largest_unit: str | None = largest_unit
self._formatter: Optional[Callable[[Measurement], str]] = formatter
self.use_scaling: bool = use_scaling
def __str__(self):
"""
Returns a summary string of the formatting preferences.
"""
return (
f"{self.unit.symbol} | {self.min_digits} to {self.max_digits} digits |"
f" {self.decimals} decimals"
)
@property
def default_formatter(self) -> Callable[[Measurement], str]:
"""
Returns the default formatting function.
"""
def default_format(measurement: Measurement) -> str:
return str(measurement)
return default_format
@property
def formatter(self) -> Callable[[Measurement], str]:
"""
Returns the active formatter (either custom or default).
"""
return self._formatter or self.default_formatter
[docs]
class Measurement:
"""
A value bound to display rules with auto-scaling and pretty output.
`Measurement` holds a numeric `value` together with a `BaseMeasurement`
(formatting/scaling preferences) and the current `Unit`.
Args:
base_measurement: The `BaseMeasurement` with rules for formatting/scaling.
value: The numeric value, expressed in `base_measurement.unit` unless
scaled. Defaults to `INITIAL_VALUE`.
max_decimals: Maximum number of decimal places to show. Defaults to `MAX_DECIMALS`.
Example:
>>> length = QUANTITIES["length"]
>>> prefs = BaseMeasurement(length["m"], min_digits=1, max_digits=3, decimals=2)
>>> m = Measurement(prefs, 2500)
>>> print(m.pretty())
2.50 km
"""
INITIAL_VALUE = -9999999999
MAX_DECIMALS = 10
def __init__(
self,
base_measurement: BaseMeasurement,
value: float = INITIAL_VALUE,
max_decimals=MAX_DECIMALS,
) -> None:
self.base_measurement: BaseMeasurement = base_measurement
self.value: float = value
self.unit: Unit = base_measurement.unit
self.max_decimals: int = max_decimals
def __str__(self):
"""
Returns a string representation of the measurement: "value symbol".
"""
return f"{self._format_value()} {self.unit.symbol}"
[docs]
def get_display_measurement(self) -> "Measurement":
"""
Returns a scaled measurement for display purposes if scaling is enabled.
"""
if self.base_measurement.use_scaling:
return self.scale()
else:
return self
[docs]
def pretty(self) -> str:
"""
Returns a human-friendly string representation using the configured formatter.
"""
return self.base_measurement.formatter(self.get_display_measurement())
def _format_value(self) -> str:
"""
Formats the numeric value according to the specified decimal places.
"""
return f"{self.value:.{self.base_measurement.decimals}f}"
[docs]
def scale(self) -> "Measurement":
"""
Scales the measurement to fit within the desired digit range.
Returns:
A new scaled `Measurement` instance.
"""
# If no value was set, do nothing
if self.value == Measurement.INITIAL_VALUE:
return self
# Handle edge cases of zero, nan, and inf
if self.value == 0:
return Measurement(self.base_measurement, 0)
if not math.isfinite(self.value):
return self
# determine the number of digits (ignoring sign and decimal)
n_digits = self._get_digits()
# if the number of digits is within the range, return formatted measurement
if (
self.base_measurement.min_digits
<= n_digits
<= self.base_measurement.max_digits
):
formatted_value = float(self._format_value())
return Measurement(self.base_measurement, formatted_value)
# Too many digits - scale up to larger unit
elif n_digits > self.base_measurement.max_digits:
return self._scale_up()
# Too few digits - scale down to smaller unit
else:
return self._scale_down()
def _get_digits(self, value=None, progress=1) -> int:
"""
Determine the number of digits (ignoring sign and decimal) of the value.
Examples:
>>> # value = 1000 => n_digits = 4
>>> # value = 0.001 => n_digits = -2
Args:
value: Optional value to check digits for.
progress: Internal recursion progress.
Returns:
Integer value of the number of digits.
"""
if value is None:
value = abs(self.value)
if 1 <= value < 10:
return progress
elif 10 <= value:
return self._get_digits(value / 10, progress + 1)
elif value < 1:
return self._get_digits(value * 10, progress - 1)
else:
raise ValueError("Invalid value")
[docs]
def scale_to_unit(self, other_unit: Unit) -> "Measurement":
"""
Scale this measurement to match a target unit.
Args:
other_unit: The unit to scale to.
Returns:
A new `Measurement` instance in the target unit.
Raises:
ValueError: If the units are not in the same quantity system.
"""
# If already the same unit, just return a copy with formatted value
if self.unit.symbol == other_unit.symbol:
formatted_value = float(self._format_value())
return Measurement(
BaseMeasurement(
self.unit,
min_digits=self.base_measurement.min_digits,
max_digits=self.base_measurement.max_digits,
decimals=self.base_measurement.decimals,
smallest_unit=self.base_measurement.smallest_unit,
largest_unit=self.base_measurement.largest_unit,
),
formatted_value,
)
# Find conversion path from self to other's unit
conversion_factor = self._find_conversion_factor(other_unit)
if conversion_factor is None:
raise ValueError(
f"Cannot convert from {self.unit.symbol} to {other_unit.symbol}: "
f"units are not in the same quantity system"
)
# Convert value to target unit
new_value = self.value * conversion_factor
# Create new measurement with other's unit
new_base_measurement = BaseMeasurement(
base_unit=other_unit,
min_digits=self.base_measurement.min_digits,
max_digits=self.base_measurement.max_digits,
decimals=self.base_measurement.decimals,
smallest_unit=self.base_measurement.smallest_unit,
largest_unit=self.base_measurement.largest_unit,
)
return Measurement(new_base_measurement, new_value)
def _find_conversion_factor(self, target_unit: Unit) -> float | None:
"""
Finds the conversion factor from this unit to the target unit using BFS.
Args:
target_unit: The unit to find the conversion factor for.
Returns:
The conversion factor if found, otherwise `None`.
"""
# BFS to find path from self.unit to target_unit
visited = set()
queue = [(self.unit, 1.0)] # (unit, cumulative_factor)
while queue:
current_unit, current_factor = queue.pop(0)
if current_unit.symbol in visited:
continue
visited.add(current_unit.symbol)
# Found the target
if current_unit.symbol == target_unit.symbol:
return current_factor
# Explore larger unit
if current_unit.larger_unit is not None:
new_factor = current_factor * current_unit.conversion_factor_to_larger
queue.append((current_unit.larger_unit, new_factor))
# Explore smaller unit
if current_unit.smaller_unit is not None:
new_factor = current_factor * current_unit.conversion_factor_to_smaller
queue.append((current_unit.smaller_unit, new_factor))
return None
def _scale_up(self) -> "Measurement":
"""
Attempts to scale the measurement to a larger unit.
Returns:
A new `Measurement` instance, possibly with a larger unit.
"""
# Check if we can scale up
if self.unit.larger_unit is None:
# Already at largest unit, return as is
formatted_value = float(self._format_value())
return Measurement(self.base_measurement, formatted_value)
# Check if largest_unit constraint prevents scaling
if (
self.base_measurement.largest_unit is not None
and self.unit.symbol == self.base_measurement.largest_unit
):
formatted_value = float(self._format_value())
return Measurement(self.base_measurement, formatted_value)
# Scale to larger unit
new_value = self.value * self.unit.conversion_factor_to_larger
new_base_measurement = BaseMeasurement(
base_unit=self.unit.larger_unit,
min_digits=self.base_measurement.min_digits,
max_digits=self.base_measurement.max_digits,
decimals=self.base_measurement.decimals,
smallest_unit=self.base_measurement.smallest_unit,
largest_unit=self.base_measurement.largest_unit,
)
new_measurement = Measurement(new_base_measurement, new_value)
# Recursively scale if still too many digits
return new_measurement.scale()
def _scale_down(self) -> "Measurement":
"""
Attempts to scale the measurement to a smaller unit.
Returns:
A new `Measurement` instance, possibly with a smaller unit.
"""
# Check if we can scale down
if self.unit.smaller_unit is None:
# Already at smallest unit, return as is
formatted_value = float(self._format_value())
# When formatted value equal to 0.0, add extra decimal. Stop if 9 decimals and still 0.0
while (formatted_value == 0.0) and (
self.base_measurement.decimals < self.max_decimals
):
self.base_measurement.decimals = self.base_measurement.decimals + 1
formatted_value = float(self._format_value())
return Measurement(self.base_measurement, formatted_value)
# Check if smallest_unit constraint prevents scaling
if (
self.base_measurement.smallest_unit is not None
and self.unit.symbol == self.base_measurement.smallest_unit
):
formatted_value = float(self._format_value())
return Measurement(self.base_measurement, formatted_value)
# Scale to smaller unit
new_value = self.value * self.unit.conversion_factor_to_smaller
new_base_measurement = BaseMeasurement(
base_unit=self.unit.smaller_unit,
min_digits=self.base_measurement.min_digits,
max_digits=self.base_measurement.max_digits,
decimals=self.base_measurement.decimals,
smallest_unit=self.base_measurement.smallest_unit,
largest_unit=self.base_measurement.largest_unit,
)
new_measurement = Measurement(new_base_measurement, new_value)
# Recursively scale if still too few digits
return new_measurement.scale()
# ============================================================================
# Pre-defined Quantities with Extensive Unit Options
# ============================================================================
def create_length_quantity() -> Quantity:
length = Quantity("Length", Unit("m"))
# Smaller units
length.add_unit(Unit("mm"), 0.001)
length.add_unit(Unit("cm"), 0.01)
length.add_unit(Unit("dm"), 0.1)
# Larger units
length.add_unit(Unit("km"), 1_000)
# Micro and nano
length.add_unit(Unit("μm"), 0.000_001)
length.add_unit(Unit("nm"), 0.000_000_001)
# Mega
length.add_unit(Unit("Mm"), 1_000_000)
return length
def create_mass_quantity() -> Quantity:
mass = Quantity("Mass", Unit("g"))
# Smaller units
mass.add_unit(Unit("mg"), 0.001)
mass.add_unit(Unit("μg"), 0.000_001)
# Larger units
mass.add_unit(Unit("kg"), 1_000)
mass.add_unit(Unit("t"), 1_000_000) # metric ton
mass.add_unit(Unit("kt"), 1_000_000_000) # kiloton
mass.add_unit(Unit("Mt"), 1_000_000_000_000) # megaton
return mass
def create_time_quantity() -> Quantity:
time = Quantity("Time", Unit("s"))
# Smaller units
time.add_unit(Unit("ms"), 0.001)
time.add_unit(Unit("μs"), 0.000_001)
time.add_unit(Unit("ns"), 0.000_000_001)
# Larger units
time.add_unit(Unit("min"), 60)
time.add_unit(Unit("h"), 3_600)
time.add_unit(Unit("d"), 86_400)
time.add_unit(Unit("wk"), 604_800)
time.add_unit(Unit("yr"), 31_536_000)
return time
def create_area_quantity() -> Quantity:
area = Quantity("Area", Unit("m²"))
# Smaller units
area.add_unit(Unit("mm²"), 0.000_001)
area.add_unit(Unit("cm²"), 0.0001)
area.add_unit(Unit("dm²"), 0.01)
# Larger units
area.add_unit(Unit("km²"), 1_000_000)
area.add_unit(Unit("ha"), 10_000) # hectare
return area
def create_volume_quantity() -> Quantity:
volume = Quantity("Volume", Unit("L"))
# Smaller units
volume.add_unit(Unit("mL"), 0.001)
volume.add_unit(Unit("cL"), 0.01)
volume.add_unit(Unit("dL"), 0.1)
# Larger units
volume.add_unit(Unit("m³"), 1_000)
volume.add_unit(Unit("kL"), 1_000)
# Very small
volume.add_unit(Unit("μL"), 0.000_001)
return volume
def create_speed_quantity() -> Quantity:
speed = Quantity("Speed", Unit("m/s"))
speed.add_unit(Unit("km/h"), 0.277778)
speed.add_unit(Unit("cm/s"), 0.01)
speed.add_unit(Unit("mm/s"), 0.001)
return speed
def create_temperature_quantity() -> Quantity:
temp = Quantity("Temperature", Unit("°C"))
# Note: These are NOT convertible via simple multiplication
# This is a simplified example - real temperature conversion needs offset
temp.add_unit(Unit("K"), 1) # Kelvin (simplified)
return temp
def create_energy_quantity() -> Quantity:
energy = Quantity("Energy", Unit("J"))
# Smaller units
energy.add_unit(Unit("mJ"), 0.001)
energy.add_unit(Unit("μJ"), 0.000_001)
# Larger units
energy.add_unit(Unit("kJ"), 1_000)
energy.add_unit(Unit("MJ"), 1_000_000)
energy.add_unit(Unit("GJ"), 1_000_000_000)
energy.add_unit(Unit("kWh"), 3_600_000)
energy.add_unit(Unit("MWh"), 3_600_000_000)
return energy
def create_power_quantity() -> Quantity:
power = Quantity("Power", Unit("W"))
# Smaller units
power.add_unit(Unit("mW"), 0.001)
power.add_unit(Unit("μW"), 0.000_001)
# Larger units
power.add_unit(Unit("kW"), 1_000)
power.add_unit(Unit("MW"), 1_000_000)
power.add_unit(Unit("GW"), 1_000_000_000)
return power
def create_pressure_quantity() -> Quantity:
pressure = Quantity("Pressure", Unit("Pa"))
# Smaller/Larger units
pressure.add_unit(Unit("kPa"), 1_000)
pressure.add_unit(Unit("MPa"), 1_000_000)
pressure.add_unit(Unit("bar"), 100_000)
pressure.add_unit(Unit("mbar"), 100)
return pressure
def create_frequency_quantity() -> Quantity:
frequency = Quantity("Frequency", Unit("Hz"))
frequency.add_unit(Unit("kHz"), 1_000)
frequency.add_unit(Unit("MHz"), 1_000_000)
frequency.add_unit(Unit("GHz"), 1_000_000_000)
frequency.add_unit(Unit("mHz"), 0.001)
return frequency
def create_data_quantity() -> Quantity:
data = Quantity("Data", Unit("B"))
# Binary prefixes (IEC standard)
data.add_unit(Unit("KiB"), 1_024)
data.add_unit(Unit("MiB"), 1_048_576)
data.add_unit(Unit("GiB"), 1_073_741_824)
data.add_unit(Unit("TiB"), 1_099_511_627_776)
data.add_unit(Unit("PiB"), 1_125_899_906_842_624)
return data
def create_data_decimal_quantity() -> Quantity:
data = Quantity("Data (Decimal)", Unit("B"))
# Decimal prefixes (SI standard)
data.add_unit(Unit("KB"), 1_000)
data.add_unit(Unit("MB"), 1_000_000)
data.add_unit(Unit("GB"), 1_000_000_000)
data.add_unit(Unit("TB"), 1_000_000_000_000)
data.add_unit(Unit("PB"), 1_000_000_000_000_000)
return data
def create_currency_quantity(symbol: str, name: str) -> Quantity:
currency = Quantity(name, Unit(symbol))
currency.add_unit(Unit(f"k{symbol}"), 1_000)
currency.add_unit(Unit(f"M{symbol}"), 1_000_000)
currency.add_unit(Unit(f"B{symbol}"), 1_000_000_000)
return currency
def create_money_quantity() -> Quantity:
money = create_currency_quantity("€", "Euro")
return money
def create_percentage_quantity() -> Quantity:
percentage = Quantity("Percentage", Unit("%"))
percentage.add_unit(Unit("‰"), 0.1) # per mille
percentage.add_unit(Unit("bp"), 0.01) # basis points
return percentage
def create_count_quantity() -> Quantity:
count = Quantity("Count", Unit(""))
count.add_unit(Unit("k"), 1_000)
count.add_unit(Unit("M"), 1_000_000)
count.add_unit(Unit("B"), 1_000_000_000)
return count
def create_electric_current_quantity() -> Quantity:
current = Quantity("Electric Current", Unit("A"))
current.add_unit(Unit("mA"), 0.001)
current.add_unit(Unit("μA"), 0.000_001)
current.add_unit(Unit("kA"), 1_000)
return current
def create_voltage_quantity() -> Quantity:
voltage = Quantity("Voltage", Unit("V"))
voltage.add_unit(Unit("mV"), 0.001)
voltage.add_unit(Unit("μV"), 0.000_001)
voltage.add_unit(Unit("kV"), 1_000)
voltage.add_unit(Unit("MV"), 1_000_000)
return voltage
def create_resistance_quantity() -> Quantity:
resistance = Quantity("Resistance", Unit("Ω"))
resistance.add_unit(Unit("mΩ"), 0.001)
resistance.add_unit(Unit("kΩ"), 1_000)
resistance.add_unit(Unit("MΩ"), 1_000_000)
return resistance
def create_default_quantity() -> Quantity:
default = Quantity("Default", Unit("unit"))
# default.add_unit(BaseUnit("default", ""), 1)
return default
# ============================================================================
# Standard Quantities Registry
# ============================================================================
[docs]
class QuantityRegistry:
"""
Registry for commonly used quantities.
Provides convenient access to pre-defined `Quantity` objects and a simple
way to register custom ones. Use the global instance `QUANTITIES` to look up
quantities by name, e.g., `QUANTITIES["length"]`.
Built-in quantities (keys):
- length, mass, time, area, volume, speed, temperature, energy, power,
pressure, frequency, data (binary multiples), data_decimal (decimal SI),
money, percentage, count, current, voltage, resistance, default
Customization:
- Create a `Quantity`, add units with `add_unit(...)`, then register it via
`register(name, quantity)`. Afterwards it can be retrieved with the same
index syntax: `QUANTITIES[name]`.
"""
def __init__(self):
self._quantities: Dict[str, Quantity] = {}
self._initialize_standard_quantities()
def _initialize_standard_quantities(self):
"""
Initializes all standard quantities.
"""
self._quantities["length"] = create_length_quantity()
self._quantities["mass"] = create_mass_quantity()
self._quantities["time"] = create_time_quantity()
self._quantities["area"] = create_area_quantity()
self._quantities["volume"] = create_volume_quantity()
self._quantities["speed"] = create_speed_quantity()
self._quantities["temperature"] = create_temperature_quantity()
self._quantities["energy"] = create_energy_quantity()
self._quantities["power"] = create_power_quantity()
self._quantities["pressure"] = create_pressure_quantity()
self._quantities["frequency"] = create_frequency_quantity()
self._quantities["data"] = create_data_quantity()
self._quantities["data_decimal"] = create_data_decimal_quantity()
self._quantities["money"] = create_money_quantity()
self._quantities["percentage"] = create_percentage_quantity()
self._quantities["count"] = create_count_quantity()
self._quantities["current"] = create_electric_current_quantity()
self._quantities["voltage"] = create_voltage_quantity()
self._quantities["resistance"] = create_resistance_quantity()
self._quantities["default"] = create_default_quantity()
[docs]
def get(self, name: str) -> Quantity | None:
"""
Retrieves a quantity by name.
Args:
name: The name of the quantity to retrieve.
Returns:
The `Quantity` object if found, otherwise `None`.
"""
return self._quantities.get(name)
[docs]
def register(self, name: str, quantity: Quantity):
"""
Registers a custom quantity.
Args:
name: The name to register the quantity under.
quantity: The `Quantity` object to register.
Raises:
ValueError: If a quantity with the same name is already registered.
"""
if name in self._quantities:
raise ValueError(f"Quantity '{name}' already registered")
self._quantities[name] = quantity
[docs]
def list_quantities(self) -> List[str]:
"""
Lists all registered quantity names.
Returns:
A list of strings containing all registered quantity names.
"""
return list(self._quantities.keys())
def __getitem__(self, name: str) -> Quantity:
"""
Retrieves a quantity by name using the `[]` operator.
Args:
name: The name of the quantity to retrieve.
Returns:
The `Quantity` object.
Raises:
KeyError: If the quantity name is not found in the registry.
"""
quantity = self.get(name)
if quantity is None:
raise KeyError(
f"Quantity '{name}' not found in registry.\n "
f" Available quantities: {self.list_quantities()}"
)
return quantity
# Global registry instance
QUANTITIES = QuantityRegistry()
[docs]
def example_usage():
"""Example usage of the Measurement classes"""
print("=== Available Quantities ===")
for qty_name in QUANTITIES.list_quantities():
print(f" - {qty_name}")
print("\n=== Length Examples ===")
length = QUANTITIES["length"]
length_m = BaseMeasurement(length["m"], min_digits=1, max_digits=3, decimals=2)
examples = [
0.000_000_000_01,
0.000_000_000_000_1,
0.000_000_000_000_000_000_1,
0.000_000_000_000_000_000_000_000_000_1,
0.025123,
2.5,
250,
25_000,
2_500_000,
]
for val in examples:
m = Measurement(length_m, val)
print(f"{val:>15} m -> {m.pretty()}")
print("\n=== Mass Examples ===")
mass = QUANTITIES["mass"]
mass_g = BaseMeasurement(mass["g"], min_digits=1, max_digits=3, decimals=2)
for val in [0.5, 50, 5_000, 500_000, 50_000_000]:
m = Measurement(mass_g, val)
print(f"{val:>15} g -> {m.pretty()}")
print("\n=== Mass Examples, no scaling ===")
mass = QUANTITIES["mass"]
mass_g_nsc = BaseMeasurement(
mass["g"], min_digits=1, max_digits=3, decimals=2, use_scaling=False
)
for val in [0.5, 50, 5_000, 500_000, 50_000_000]:
m = Measurement(mass_g_nsc, val)
print(f"{val:>15} g -> {m.pretty()}")
print("\n=== Time Examples ===")
time = QUANTITIES["time"]
time_s = BaseMeasurement(time["s"], min_digits=1, max_digits=3, decimals=1)
for val in [0.000_001, 0.5, 45, 3_665, 86_400, 31_536_000]:
m = Measurement(time_s, val)
print(f"{val:>15} s -> {m.pretty()}")
print("\n=== Money Examples ===")
money = QUANTITIES["money"]
money_usd = BaseMeasurement(money["€"], min_digits=0, max_digits=3, decimals=2)
for val in [0.50, 50, 1_234_567, 5_000_000_000, 1_500_000_000_000]:
m = Measurement(money_usd, val)
print(f"€{val:>18,.2f} -> {m.pretty()}")
print("\n=== Custom Currency Example (EUR) ===")
eur = create_currency_quantity("€", "Euro")
eur_base = BaseMeasurement(eur["€"], min_digits=1, max_digits=3, decimals=2)
for val in [50, 5_000, 500_000, 50_000_000]:
m = Measurement(eur_base, val)
print(f"€{val:>15,.2f} -> {m.pretty()}")
def format_euro(meas: Measurement) -> str:
value = meas.value
unit = meas.unit.symbol
return f"{unit} {value:,.2f}"
print("\n=== Custom Currency Example (EUR), own formatting, no scaling ===")
eur = create_currency_quantity("€", "Euro")
eur_base = BaseMeasurement(
eur["€"],
min_digits=1,
max_digits=3,
decimals=2,
formatter=format_euro,
use_scaling=False,
)
for val in [50, 5_000, 500_000, 50_000_000]:
m = Measurement(eur_base, val)
print(f"€{val:>15,.2f} -> {m.pretty()}")
print("\n=== Data Storage Examples (Binary) ===")
data = QUANTITIES["data"]
data_b = BaseMeasurement(data["B"], min_digits=1, max_digits=3, decimals=2)
for val in [512, 5_120, 5_242_880, 5_368_709_120]:
m = Measurement(data_b, val)
print(f"{val:>15} B -> {m.pretty()}")
print("\n=== Power Examples ===")
power = QUANTITIES["power"]
power_w = BaseMeasurement(power["W"], min_digits=1, max_digits=3, decimals=2)
for val in [0.005, 5, 5_000, 5_000_000, 5_000_000_000]:
m = Measurement(power_w, val)
print(f"{val:>15} W -> {m.pretty()}")
print("\n=== Energy Examples ===")
energy = QUANTITIES["energy"]
energy_j = BaseMeasurement(energy["J"], min_digits=1, max_digits=3, decimals=2)
for val in [0.5, 500, 500_000, 3_600_000, 3_600_000_000]:
m = Measurement(energy_j, val)
print(f"{val:>15} J -> {m.pretty()}")
print("\n=== Scale to Same Unit Examples ===")
# Example 1: Different lengths in different units
length = QUANTITIES["length"]
m1 = Measurement(BaseMeasurement(length["mm"], decimals=2), 1_500_000)
m2 = Measurement(BaseMeasurement(length["km"], decimals=2), 2.5)
print(f"Measurement 1: {m1}")
print(f"Measurement 2: {m2}")
print(f"M1 scaled to M2's unit: {m1.scale_to_unit(m2.unit)}")
print(f"M2 scaled to M1's unit: {m2.scale_to_unit(m1.unit)}")
# Example 2: Money at different scales
print()
money = QUANTITIES["money"]
revenue = Measurement(BaseMeasurement(money["€"], decimals=2), 1_234_567)
budget = Measurement(BaseMeasurement(money["M€"], decimals=2), 5.5)
print(f"Revenue: {revenue}")
print(f"Budget: {budget}")
print(f"Revenue in M€: {revenue.scale_to_unit(budget.unit)}")
print(f"Budget in €: {budget.scale_to_unit(revenue.unit)}")
# Example 3: Compare auto-scaled measurements
print()
time = QUANTITIES["time"]
time_s = BaseMeasurement(time["s"], min_digits=1, max_digits=2, decimals=1)
t1 = Measurement(time_s, 45)
t2 = Measurement(time_s, 7_200)
t1_scaled = t1.scale()
t2_scaled = t2.scale()
print(f"Time 1 (auto-scaled): {t1_scaled}")
print(f"Time 2 (auto-scaled): {t2_scaled}")
print(f"Time 1 matched to Time 2's unit: {t1.scale_to_unit(t2_scaled.unit)}")
print(f"Time 2 matched to Time 1's unit: {t2.scale_to_unit(t1_scaled.unit)}")
if __name__ == "__main__":
example_usage()