Unit System Usage Guide

The Metric Engine unit system provides type-safe financial calculations with explicit unit handling and conversion capabilities. This guide demonstrates how to use the unit system effectively.

Basic Unit Creation

Using Helper Functions

The easiest way to create units is using the provided helper functions:

from metricengine import MoneyUnit, Qty, Pct, FinancialValue

# Create currency units
usd = MoneyUnit("USD")
gbp = MoneyUnit("GBP")
eur = MoneyUnit("EUR")

# Create quantity units
kg = Qty("kg")
liters = Qty("L")
meters = Qty("m")

# Create percentage units
ratio = Pct()           # Default: Percent[ratio]
basis_points = Pct("bp") # Percent[bp]

Using NewUnit Directly

For custom unit categories, use NewUnit directly:

from metricengine import NewUnit

# Custom units
seats = NewUnit("Capacity", "seats")
hours = NewUnit("Time", "hours")
points = NewUnit("Score", "points")

Creating Unit-Aware FinancialValues

from metricengine import FinancialValue, MoneyUnit
from decimal import Decimal

usd = MoneyUnit("USD")
gbp = MoneyUnit("GBP")

# Create FinancialValues with units
price_usd = FinancialValue(Decimal("100.00"), unit=usd)
price_gbp = FinancialValue(Decimal("79.00"), unit=gbp)

print(price_usd)  # 100.00 (Money[USD])
print(price_gbp)  # 79.00 (Money[GBP])

Unit Safety in Arithmetic

The unit system prevents unsafe operations between incompatible units:

from metricengine import FinancialValue, MoneyUnit

usd = MoneyUnit("USD")
gbp = MoneyUnit("GBP")

price_usd = FinancialValue(100, unit=usd)
price_gbp = FinancialValue(79, unit=gbp)

# This works - same units
total_usd = price_usd + FinancialValue(50, unit=usd)
print(total_usd)  # 150.00 (Money[USD])

# This raises ValueError - incompatible units
try:
    invalid = price_usd + price_gbp  # ValueError!
except ValueError as e:
    print(f"Error: {e}")

Unit Conversions

Registering Conversions

Register conversion functions between units using the decorator:

from metricengine import (
    MoneyUnit, register_conversion, ConversionContext
)
from decimal import Decimal

usd = MoneyUnit("USD")
gbp = MoneyUnit("GBP")
eur = MoneyUnit("EUR")

@register_conversion(usd, gbp)
def usd_to_gbp(value: Decimal, ctx: ConversionContext) -> Decimal:
    """Convert USD to GBP using fixed rate for example."""
    return value * Decimal("0.79")

@register_conversion(gbp, usd)
def gbp_to_usd(value: Decimal, ctx: ConversionContext) -> Decimal:
    """Convert GBP to USD."""
    return value * Decimal("1.27")

@register_conversion(usd, eur)
def usd_to_eur(value: Decimal, ctx: ConversionContext) -> Decimal:
    """Convert USD to EUR."""
    return value * Decimal("0.85")

Using Dynamic Rates

Conversion functions can access external data through the context:

@register_conversion(usd, gbp)
def usd_to_gbp_dynamic(value: Decimal, ctx: ConversionContext) -> Decimal:
    """Convert USD to GBP using dynamic rates."""
    # Get rate from context metadata
    if "rate" in ctx.meta:
        rate = Decimal(ctx.meta["rate"])
    else:
        # Fallback to API call or default rate
        rate = get_exchange_rate("USD", "GBP", ctx.at)

    return value * rate

def get_exchange_rate(from_currency: str, to_currency: str, at: str = None) -> Decimal:
    """Fetch exchange rate from external API."""
    # Implementation would call actual exchange rate API
    return Decimal("0.79")  # Placeholder

Performing Conversions

Use the to() method to convert between units:

from metricengine import FinancialValue, MoneyUnit

usd = MoneyUnit("USD")
gbp = MoneyUnit("GBP")

price_usd = FinancialValue(100, unit=usd)

# Convert to GBP
price_gbp = price_usd.to(gbp)
print(price_gbp)  # 79.00 (Money[GBP])

# Convert with context
price_gbp_with_context = price_usd.to(
    gbp,
    at="2025-09-06T10:30:00Z",
    meta={"rate": "0.78", "source": "ECB"}
)

Multi-Hop Conversions

The system automatically finds conversion paths through intermediate units:

# With USD->GBP and USD->EUR registered, GBP->EUR works automatically
gbp_amount = FinancialValue(100, unit=gbp)
eur_amount = gbp_amount.to(eur)  # Goes GBP->USD->EUR

Conversion Policies

Control conversion behavior using policies:

from metricengine import ConversionPolicy, use_conversions

# Strict policy (default) - raises on missing conversions
strict_policy = ConversionPolicy(strict=True, allow_paths=True)

# Permissive policy - returns original value on missing conversions
permissive_policy = ConversionPolicy(strict=False, allow_paths=True)

# Direct-only policy - no multi-hop conversions
direct_only = ConversionPolicy(strict=True, allow_paths=False)

# Use policy in context
with use_conversions(permissive_policy):
    # Missing conversions return original value instead of raising
    result = some_value.to(unknown_unit)

Working with Quantities

The unit system works with any type of measurement:

from metricengine import Qty, FinancialValue, register_conversion

# Weight units
kg = Qty("kg")
lb = Qty("lb")

@register_conversion(kg, lb)
def kg_to_lb(value: Decimal, ctx: ConversionContext) -> Decimal:
    return value * Decimal("2.20462")

@register_conversion(lb, kg)
def lb_to_kg(value: Decimal, ctx: ConversionContext) -> Decimal:
    return value * Decimal("0.453592")

# Use weight units
weight_kg = FinancialValue(10, unit=kg)
weight_lb = weight_kg.to(lb)
print(weight_lb)  # 22.0462 (Quantity[lb])

Percentage Units

Handle different percentage representations:

from metricengine import Pct, FinancialValue, register_conversion

ratio = Pct("ratio")      # Percent[ratio] - 0.15 = 15%
percent = Pct("percent")  # Percent[percent] - 15 = 15%
bp = Pct("bp")           # Percent[bp] - 1500 = 15%

@register_conversion(ratio, percent)
def ratio_to_percent(value: Decimal, ctx: ConversionContext) -> Decimal:
    return value * 100

@register_conversion(ratio, bp)
def ratio_to_bp(value: Decimal, ctx: ConversionContext) -> Decimal:
    return value * 10000

# Convert between percentage representations
rate_ratio = FinancialValue(Decimal("0.15"), unit=ratio)
rate_percent = rate_ratio.to(percent)  # 15.00 (Percent[percent])
rate_bp = rate_ratio.to(bp)           # 1500.00 (Percent[bp])

Best Practices

1. Register Bidirectional Conversions

Always register conversions in both directions:

@register_conversion(usd, gbp)
def usd_to_gbp(value: Decimal, ctx: ConversionContext) -> Decimal:
    return value * Decimal("0.79")

@register_conversion(gbp, usd)
def gbp_to_usd(value: Decimal, ctx: ConversionContext) -> Decimal:
    return value / Decimal("0.79")  # Or use inverse rate

2. Use Context for Dynamic Data

Leverage the conversion context for dynamic rates and metadata:

@register_conversion(usd, gbp)
def usd_to_gbp_with_context(value: Decimal, ctx: ConversionContext) -> Decimal:
    # Use timestamp for historical rates
    if ctx.at:
        rate = get_historical_rate("USD", "GBP", ctx.at)
    else:
        rate = get_current_rate("USD", "GBP")

    # Log conversion for audit trail
    if "audit" in ctx.meta:
        log_conversion("USD", "GBP", value, rate, ctx.meta["audit"])

    return value * rate

3. Handle Conversion Errors Gracefully

Use appropriate policies for your use case:

# For user-facing calculations - use permissive mode
with use_conversions(ConversionPolicy(strict=False)):
    result = calculate_total_in_base_currency(mixed_currency_values)

# For critical financial operations - use strict mode (default)
result = critical_calculation.to(required_currency)  # Raises on missing conversion

4. Organize Units by Domain

Create domain-specific unit modules:

# currencies.py
from metricengine import MoneyUnit

USD = MoneyUnit("USD")
GBP = MoneyUnit("GBP")
EUR = MoneyUnit("EUR")
JPY = MoneyUnit("JPY")

# measurements.py
from metricengine import Qty

KG = Qty("kg")
LB = Qty("lb")
LITER = Qty("L")
GALLON = Qty("gal")

5. Test Conversion Round-Trips

Ensure conversion accuracy with round-trip tests:

def test_usd_gbp_roundtrip():
    original = FinancialValue(100, unit=USD)
    converted = original.to(GBP).to(USD)

    # Allow for small rounding differences
    assert abs(original.as_decimal() - converted.as_decimal()) < Decimal("0.01")

Integration with Existing Code

The unit system is designed for gradual adoption:

# Existing code without units continues to work
old_value = FinancialValue(100)  # No unit
new_value = FinancialValue(100, unit=usd)  # With unit

# Mixed operations work when one operand has no unit
result = old_value + new_value  # Result has USD unit

This allows you to incrementally add unit safety to your codebase without breaking existing functionality.