Unit System Design and Architecture

This document explains the design philosophy and implementation of the unit handling system in R2X Core, providing insight into how it works and why certain architectural decisions were made.

Purpose and Motivation

Power system analysis fundamentally relies on per-unit normalization to enable meaningful comparisons across equipment with vastly different ratings. A 500 MVA generator and a 10 MVA generator cannot be directly compared using absolute power values, but expressing both as fractions of their respective rated capacities provides immediate insight into their operating states. The R2X Core unit system addresses the challenge of managing these normalizations while maintaining flexibility for different analysis contexts.

Traditional power system software often hard-codes per-unit conversions or requires manual calculation by users. This approach introduces several problems. Data imported from external sources arrives in natural units that must be converted before use. Components moving between systems with different base values require recalculation. Display and reporting tools must handle multiple unit representations. The R2X Core unit system solves these challenges through automatic conversion, flexible display modes, and type-safe field annotations.

Core Design Principles

The unit system architecture follows several guiding principles that shape its implementation and usage patterns.

Separation of Storage and Display

The system maintains a strict separation between how values are stored internally and how they are displayed to users. All per-unit quantities are stored internally as floating point numbers normalized to their designated base values. This internal representation never changes regardless of how the user chooses to view the data. Display modes are transient settings that affect only the string representation of values, not their underlying storage. This separation ensures that calculations always work with consistent values while allowing flexible reporting.

Important

Internal storage is always in device-base per-unit. Display modes (device-base, natural units, system-base) affect only the string representation when printing or generating reports. Calculations always operate on the consistent internal representation.

Type Safety Through Annotations

Field annotations using Python’s typing system provide compile-time safety and runtime validation. When a field is annotated as Annotated[float, Unit("pu", base="base_power")], the type system declares both the storage type (float) and the semantic meaning (per-unit referenced to base_power). Pydantic’s validator system hooks into these annotations to automatically convert input values to the correct internal representation. Invalid inputs are caught during construction rather than producing silent errors during calculation.

Composability and Inheritance

Components can inherit from either HasUnits or HasPerUnit depending on whether they need system-base tracking. The HasUnits mixin provides unit field support without any dependency on system base values, making it suitable for components with fixed units or quantities that do not participate in system-wide normalization. The HasPerUnit class extends HasUnits to add system-base tracking through a private _system_base attribute. This hierarchical design allows components to opt into exactly the features they need.

Tip

Use HasUnits for standalone components that don’t need system integration. Use HasPerUnit when components will be added to a System and need system-base display capabilities.

Architecture Overview

The unit system consists of four main components that work together to provide seamless unit handling.

Unit Specifications (UnitSpec)

The UnitSpec class serves as metadata attached to field annotations. Each specification contains two key pieces of information: the physical unit of the quantity (such as “MVA” or “kV”) and an optional reference to a base field for per-unit calculations. When a field has no base reference, it stores absolute quantities in the specified unit. When a base reference is provided, the field stores per-unit values calculated relative to that base.

The specification integrates with Pydantic’s validation system through the __get_pydantic_core_schema__ method. This integration allows natural unit inputs in dictionary form to be automatically converted during model construction. A user can provide {"value": 150.0, "unit": "MVA"} and the validator converts this to per-unit by dividing by the appropriate base value. The validator also handles type coercion from integers to floats and validates that required base values are available.

Unit-Aware Mixins (HasUnits and HasPerUnit)

The mixin classes provide the foundation for components to declare and manage unit-aware fields. The HasUnits mixin implements the __init_subclass__ hook to enforce that any class using units must also inherit from Pydantic’s BaseModel. This ensures that the validation machinery is available. The mixin also provides the _get_unit_specs_map method which introspects a class’s field annotations to build a mapping from field names to their UnitSpec metadata.

The HasPerUnit class extends this foundation with system-base tracking. It adds a private _system_base attribute that stores the system base power when a component is added to a system. The _get_system_base method provides access to this value for conversion calculations. When display mode is set to system-base, the formatting system uses this stored value to convert device-base per-unit to system-base per-unit.

Utility Functions and Conversions

The utility module provides pure functions for unit conversion and formatting. The _convert_to_internal function takes an input value (either float or dict with value and unit), a unit specification, and base values, then returns the per-unit representation. This function handles the mathematical conversion including dimensional analysis through the Pint library when unit strings differ from base units.

The _format_for_display function performs the inverse operation. Given an internal per-unit value, it consults the current display mode and converts to the appropriate representation. In device-base mode, it returns the per-unit value unchanged. In natural units mode, it multiplies by the base value and appends the base unit string. In system-base mode, it converts from device base to system base and appends the system-base indicator. This separation of conversion logic from component code maintains clean abstractions.

Global Display State

The unit system maintains a global display mode that affects all components simultaneously. This global state is managed through the UnitSystem enumeration which defines three modes: DEVICE_BASE, NATURAL_UNITS, and SYSTEM_BASE. The set_unit_system function updates this global state, while the get_unit_system function retrieves it. A context manager unit_system allows temporary mode changes that automatically revert when the context exits.

This global state design enables consistent reporting across an entire system. When generating a report, a user can set the display mode once and all components will render in that mode. The alternative of passing display mode to every formatting call would be cumbersome and error-prone. The global state is thread-local to support concurrent use in multi-threaded applications.

Note

The unit_system context manager provides a convenient way to temporarily change display mode for a specific code block, automatically reverting to the previous mode when the context exits.

Integration with System Class

The System class plays a crucial role in the unit system by managing the relationship between components and the system base power. When the system is initialized, it stores the system base power and defines a custom Pint unit called “system_base” with that magnitude. This allows components to perform conversions using the Pint library’s dimension analysis.

When components are added to a system through add_components, the system checks if they inherit from HasPerUnit. For components that do, it sets their _system_base attribute to the system’s base power value. The system also validates that components are not added to multiple systems with different base values, raising an error if such a conflict is detected. This validation prevents inconsistent state where a component’s per-unit values would be ambiguous.

During deserialization from JSON, the system must restore this relationship. The from_json class method reconstructs the system and its components, then iterates over all HasPerUnit components to set their _system_base attributes. This ensures that system-base display mode works correctly after reloading a saved system.

Pint Integration for Unit Conversion

The R2X Core unit system leverages the Pint library for dimensional analysis and unit conversion. Pint provides a registry of known units and the mathematical relationships between them. When a user provides input in natural units that differ from the base unit (such as kW when the base is MVA), the conversion system uses Pint to perform the transformation.

The integration creates a shared UnitRegistry instance that all conversions use. The system base power is registered as a custom unit within this registry, allowing expressions like device_pu.to('system_base') to work correctly. Pint’s dimensional analysis ensures that unit mismatches are caught. Attempting to convert a power quantity to a voltage unit will raise an exception rather than silently producing incorrect results.

This integration provides robustness and extensibility. New units can be added to support different domains without modifying core conversion logic. The dimensional type system prevents common errors such as adding voltages to powers or multiplying incompatible quantities.

Performance Considerations

The unit system is designed with performance in mind for large-scale power system models. Internal storage in per-unit form means that most calculations work with simple floating point operations without unit tracking overhead. Conversion happens only at component construction and during display formatting. The validation system caches unit specifications during class definition rather than computing them for each instance.

Display formatting is lazy and occurs only when string representations are requested. Accessing field values programmatically returns raw floats without formatting overhead. This design ensures that computational loops operating on component data do not pay any unit conversion penalty. Only user-facing operations such as printing or report generation incur the formatting cost.

Hint

For performance-critical code, access component field values directly (e.g., gen.output) to get raw floats. Unit conversion overhead only occurs when converting string representations for display or logging.

Design Trade-offs and Alternatives

The global display state design represents a trade-off between convenience and thread safety. An alternative design could pass display mode explicitly to every formatting operation, which would be more functional but significantly more verbose. The current design accepts the need for thread-local state in exchange for cleaner usage patterns.

The choice to store all per-unit values in device-base internally could have been made differently. An alternative would be to convert everything to system-base at the point of system addition. This would simplify system-base display but would require recalculating values if components are moved between systems or if the system base changes. The current design maintains component autonomy at the cost of requiring both device and system base values for system-base display.

The integration with Pydantic’s validation system through __get_pydantic_core_schema__ tightly couples the unit system to Pydantic. An alternative design using custom descriptors or metaclasses could provide more independence but would lose the automatic validation, serialization, and type introspection that Pydantic provides. The trade-off favors integration for better developer experience.

Extension Points and Future Directions

The unit system architecture provides several extension points for future enhancement. Additional display modes could be added by extending the UnitSystem enumeration and implementing corresponding formatting logic. Support for complex units (such as impedance in ohms per mile) could be added through enhanced unit specifications. Per-component display mode overrides could allow mixing modes within a single report.

The system could be extended to track uncertainty or ranges alongside values, enabling interval arithmetic for sensitivity analysis. Support for symbolic units in equations could facilitate automatic unit checking in complex calculations. Integration with optimization solvers could leverage unit information to improve numerical conditioning through automatic scaling.

Conclusion

The R2X Core unit system provides a robust foundation for handling the diverse unit requirements of power system analysis. By separating internal storage from external display, leveraging type annotations for safety, and integrating carefully with both Pydantic validation and the System class lifecycle, it achieves both flexibility and reliability. The architectural decisions reflect the practical needs of power system modeling while maintaining clean abstractions that support future extension.