Skip to content

Units System

The haskoning_business_logic_utils package extends Pydantic to support automatic unit conversions. This allows you to define units at the field level, with seamless conversion between different unit systems (e.g., metric vs imperial).

How It Works

Model attributes are always stored in the base unit. When data is deserialized (incoming), values are automatically converted from the specified unit system to the base unit. When data is serialized (outgoing), values are converted from the base unit to the target unit system.

Defining Unit Systems

First, define the unit systems available in your application:

python
from haskoning_bussines_logic_utils.units import UnitDefinition

class MyUnitDefinition(UnitDefinition):
    metric: str
    imperial: str

Creating Models with Units

Inherit from BaseModelWithUnits and define units using json_schema_extra:

python
from pydantic import Field
from haskoning_bussines_logic_utils.units import BaseModelWithUnits, UnitDefinition

class MyUnitDefinition(UnitDefinition):
    metric: str
    imperial: str

class MyModel(BaseModelWithUnits):
    length: float = Field(
        ..., 
        json_schema_extra=dict(
            unit=MyUnitDefinition(base='meter', metric='millimeter', imperial='feet')
        )
    )

Deserializing Data (Incoming)

When data comes in with a specific unit system, values are automatically converted to the base unit:

python
incoming_data = dict(
    unit_system='metric',
    length=4000  # millimeters
)

model = MyModel(**incoming_data)
model.length == 4.0  # Converted to meters (base unit)

Serializing Data (Outgoing)

Set the unit system before serializing to convert values to the target unit:

python
model = MyModel(length=4.0)  # 4 meters
model.unit_system = 'imperial'

model_dict = model.model_dump()
model_dict['length'] == 13.1233596  # Converted to feet

Note: Setting the unit system doesn't affect the model's attribute values. model.length will still be 4.0 (in base units).

Nested Models

Unit system propagation works automatically through nested models. You only need to set the unit system at the top level.

Basic Nested Models

python
from pydantic import Field
from haskoning_bussines_logic_utils.units import BaseModelWithUnits, UnitDefinition

class MyUnitDefinition(UnitDefinition):
    metric: str
    imperial: str

# Define unit definition for reuse
ud = MyUnitDefinition(base='meter', metric='millimeter', imperial='feet')

class NestedModel(BaseModelWithUnits):
    height: float = Field(..., json_schema_extra=dict(unit=ud))

class MyModel(BaseModelWithUnits):
    width: float = Field(..., json_schema_extra=dict(unit=ud))
    nested_model: NestedModel

# Incoming data
incoming_nested_data = dict(
    unit_system='metric',
    width=4000,  # millimeters
    nested_model=dict(
        height=6000  # millimeters
    )
)

model = MyModel(**incoming_nested_data)
model.nested_model.height == 6.0  # Converted to meters

Serializing Nested Models

Unit system propagates through nested models when serializing:

python
model.unit_system = 'imperial'
model_dict = model.model_dump()

model_dict['nested_model']['height'] == 19.6850394  # Converted to feet

Advanced Nested Models

Unit system propagation works through regular Pydantic BaseModel classes (not just BaseModelWithUnits). This allows flexible model hierarchies where unit conversion is needed only on specific nested models.

Deep Nesting Example

python
from pydantic import BaseModel, Field
from haskoning_bussines_logic_utils.units import BaseModelWithUnits, UnitDefinition

class DeepNestedModel(BaseModelWithUnits):
    depth: float = Field(..., json_schema_extra=dict(unit=ud))

class IntermediateModel(BaseModel):  # Regular BaseModel
    deep_nested: DeepNestedModel

class MyModel(BaseModelWithUnits):
    width: float = Field(..., json_schema_extra=dict(unit=ud))
    intermediate: IntermediateModel

incoming_data = dict(
    unit_system='metric',
    width=5000,
    intermediate=dict(
        deep_nested=dict(
            depth=3000
        )
    )
)

model = MyModel(**incoming_data)
model.intermediate.deep_nested.depth == 3.0  # Correctly converted

Lists of Nested Models

Unit system propagation also works through lists:

python
from typing import List
from pydantic import Field
from haskoning_bussines_logic_utils.units import BaseModelWithUnits

class MyModel(BaseModelWithUnits):
    width: float = Field(..., json_schema_extra=dict(unit=ud))
    nested_items: List[NestedModel]

incoming_data = dict(
    unit_system='metric',
    width=5000,
    nested_items=[
        dict(height=1000),
        dict(height=2000)
    ]
)

model = MyModel(**incoming_data)
# All items have unit system propagated
model.nested_items[0].height == 1.0
model.nested_items[1].height == 2.0

Unit Conversion Factors

The UnitDefinition object includes a factors property that provides multiplication factors for all defined units. This is useful for frontend implementations.

JavaScript Example:

javascript
function convertUnit(
    value: number,
    fromUnit: "base" | "metric" | "imperial",
    toUnit: "base" | "metric" | "imperial"
): number {
    if (!schema.value.unit || !schema.value.unit.factors) {
        return value;
    }
    const fromFactor = schema.value.unit.factors[fromUnit];
    const toFactor = schema.value.unit.factors[toUnit];
    return (value / fromFactor) * toFactor;
}

The multiplication is performed based on the base unit value.

Unit Formatting

The UnitDefinition object includes a .formatted property with different formatting options:

  • pretty_print — Unicode formatted (e.g., meter²)
  • latex — LaTeX format (e.g., \mathrm{meter}^{2})
  • html — HTML format (e.g., meter<sup>2</sup>)

Example Format Dictionary

For a unit definition with metric and imperial areas:

yaml
{
  "base": {
    "pretty_print": "meter²",
    "latex": "\\mathrm{meter}^{2}",
    "html": "meter<sup>2</sup>",
  },
  "metric": {
    "pretty_print": "centimeter²",
    "latex": "\\mathrm{centimeter}^{2}",
    "html": "centimeter<sup>2</sup>",
  },
  "imperial": {
    "pretty_print": "foot²",
    "latex": "\\mathrm{foot}^{2}",
    "html": "foot<sup>2</sup>",
  },
}

Table Integration with Units

You can integrate TableSchema with BaseModelWithUnits for automatic unit conversion in tables:

python
from typing import Annotated, List
from pydantic import Field, ConfigDict
from haskoning_bussines_logic_utils.units import BaseModelWithUnits, UnitDefinition
from haskoning_bussines_logic_utils.validation_context.field_path_mixin import FieldPathMixin
from haskoning_equation.style.tables.main import TableHeader, TableSchema

# Unit definitions
class EPCUnitDefinition(UnitDefinition):
    metric: str
    imperial: str

LENGTH_STANDARD = EPCUnitDefinition(base="meter", metric="meter", imperial="foot")

# Output models
ANNOTATED_FIELD = Annotated[float, Field(json_schema_extra={"unit": LENGTH_STANDARD})]

class OutputBaseModel(FieldPathMixin, BaseModelWithUnits):
    model_config = ConfigDict(validate_assignment=True, validate_default=True)

class Results(OutputBaseModel):
    min: ANNOTATED_FIELD
    avg: ANNOTATED_FIELD
    max: ANNOTATED_FIELD

class OutputSchema(OutputBaseModel):
    result_as_table: TableSchema[Results]

# Business logic
def returns_a_table() -> TableSchema[Results]:
    headers: List[TableHeader] = [
        TableHeader(title="Min"),
        TableHeader(title="Avg"),
        TableHeader(title="Max"),
    ]
    items: List[Results] = []

    for i in range(5):
        result = Results(min=i * 1.0, avg=i * 2.0, max=i * 3.0)
        items.append(result)

    return TableSchema[Results](headers=headers, items=items)

# API endpoint
def calculate_design() -> OutputSchema:
    output_schema = OutputSchema.model_construct()
    output_schema.result_as_table = returns_a_table()
    return output_schema

# Usage
if __name__ == "__main__":
    output = calculate_design()

    # Metric results
    output.unit_system = "metric"
    print(output.model_dump())

    # Imperial results
    output.unit_system = "imperial"
    print(output.model_dump())

Best Practices

  • Always define the base unit clearly
  • Use consistent unit definitions across your application
  • Set unit system only at the top level of your data structures
  • Remember that model attributes are always in base units
  • Use the factors property for frontend unit conversions

Next Steps