Appearance
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: strCreating 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 feetNote: 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 metersSerializing 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 feetAdvanced 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 convertedLists 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.0Unit 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
- Connect unit-aware models to endpoint exposure in Generate Endpoints
- Add user feedback rules with Validation
- Apply display settings for unit fields in UI Styling