Dynamic Unit Conversion

The library automatically converts temperature, flow rate, and volume values to the user’s preferred unit system (metric or imperial) based on the device’s configured temperature_type setting.

Overview

All measurements in the library are stored and transmitted by the device in metric units (Celsius, LPM, Liters). When you retrieve a DeviceStatus or DeviceFeature object, values are automatically converted to the user’s preferred unit system:

  • Celsius devices: Values remain in metric (°C, LPM, L)

  • Fahrenheit devices: Values are converted to imperial (°F, GPM, gal)

This conversion happens transparently at the model validation layer, so you always receive values in the correct unit for display.

Quick Start

Basic Usage

from nwp500 import DeviceStatus, TemperatureType

# Device configured for Celsius
status_celsius = DeviceStatus(
    temperature_type=TemperatureType.CELSIUS,
    dhw_temperature=120,  # raw: 60°C (half-degree encoded)
)

print(status_celsius.dhw_temperature)  # Output: 60.0

# Get the unit for display
unit = status_celsius.get_field_unit('dhw_temperature')
print(f"Temperature: {status_celsius.dhw_temperature}{unit}")
# Output: Temperature: 60.0 °C

# Same device, now in Fahrenheit mode
status_fahrenheit = DeviceStatus(
    temperature_type=TemperatureType.FAHRENHEIT,
    dhw_temperature=120,  # raw: 60°C, converted to 140°F
)

print(status_fahrenheit.dhw_temperature)  # Output: 140.0

unit = status_fahrenheit.get_field_unit('dhw_temperature')
print(f"Temperature: {status_fahrenheit.dhw_temperature}{unit}")
# Output: Temperature: 140.0 °F

Get Field Units

Use the get_field_unit() method to retrieve the correct unit suffix for any field:

status = device_status

# Temperature field
temp_unit = status.get_field_unit('dhw_temperature')
# Returns: " °C" or " °F"

# Flow rate field
flow_unit = status.get_field_unit('current_dhw_flow_rate')
# Returns: " LPM" or " GPM"

# Volume field
volume_unit = status.get_field_unit('cumulated_dhw_flow_rate')
# Returns: " L" or " gal"

# Static unit field (no conversion)
power_unit = status.get_field_unit('current_inst_power')
# Returns: ""  (check metadata for static unit)

How It Works

Conversion Process

  1. Raw Device Value: Device sends all measurements in metric units

  2. Model Instantiation: Pydantic validates and converts the value

  3. Wrap Validator Checks: Converter checks temperature_type field

  4. Unit Conversion: If Fahrenheit mode, applies conversion formula

  5. Stored Value: Model stores converted value in correct unit

Example Conversion Flow

# Device data from API/MQTT (always metric)
raw_data = {
    'temperature_type': 2,  # FAHRENHEIT
    'dhw_temperature': 120,  # raw: 60°C (120 / 2)
}

# Pydantic validation with WrapValidator
status = DeviceStatus.model_validate(raw_data)

# Internally:
# 1. temperature_type = TemperatureType.FAHRENHEIT
# 2. For dhw_temperature: half_celsius_to_preferred(120, info)
#    - Checks info.data['temperature_type']
#    - Is FAHRENHEIT? Yes
#    - Convert: 60°C → (60 × 9/5) + 32 = 140°F
#    - Store: 140.0

print(status.dhw_temperature)  # 140.0
unit = status.get_field_unit('dhw_temperature')  # " °F"
print(f"Temp: {status.dhw_temperature}{unit}")  # "Temp: 140.0 °F"

Field Ordering Requirements

IMPORTANT: The temperature_type field MUST be defined before all temperature-dependent fields in the model.

Pydantic’s WrapValidator accesses sibling fields through ValidationInfo.data, which only includes fields processed earlier. If temperature_type is defined after temperature fields, the converter won’t find it and will default to Fahrenheit.

This is correctly implemented in the library - do not reorder fields in DeviceStatus or DeviceFeature classes.

Supported Dynamic Fields

Temperature Fields (42 total)

DeviceStatus - Half-degree Celsius encoding (raw value ÷ 2 = °C)

  • dhw_temperature

  • dhw_temperature_setting

  • dhw_target_temperature_setting

  • freeze_protection_temperature

  • dhw_temperature2

  • hp_upper_on_temp_setting

  • hp_upper_off_temp_setting

  • hp_lower_on_temp_setting

  • hp_lower_off_temp_setting

  • he_upper_on_temp_setting

  • he_upper_off_temp_setting

  • he_lower_on_temp_setting

  • he_lower_off_temp_setting

  • heat_min_op_temperature

  • recirc_temp_setting

  • recirc_temperature

  • recirc_faucet_temperature

  • current_inlet_temperature

DeviceStatus - Deci-degree encoding (raw value ÷ 10 = °C)

  • tank_upper_temperature

  • tank_lower_temperature

  • external_temp_sensor

DeviceStatus - Raw Celsius encoding (device-specific)

  • outside_temperature

DeviceStatus - Differential temperature (raw value ÷ 10 = °C)

  • hp_upper_on_diff_temp_setting

  • hp_upper_off_diff_temp_setting

  • hp_lower_on_diff_temp_setting

  • hp_lower_off_diff_temp_setting

  • he_upper_on_diff_temp_setting

  • he_upper_off_diff_temp_setting

  • he_lower_on_diff_temp_setting

  • he_lower_off_diff_temp_setting

DeviceFeature - Temperature configuration limits (raw value ÷ 2 = °C)

  • dhw_temperature_min

  • dhw_temperature_max

  • freeze_protection_temp_min

  • freeze_protection_temp_max

  • recirc_temperature_min

  • recirc_temperature_max

Flow Rate Fields (2 total)

Converts between LPM (Liters Per Minute) and GPM (Gallons Per Minute)

  • current_dhw_flow_rate - Current DHW flow rate

  • recirc_dhw_flow_rate - Recirculation DHW flow rate

Conversion formula: 1 GPM = 3.785 LPM

Volume Fields (1 total)

Converts between Liters and Gallons

  • cumulated_dhw_flow_rate - Cumulative water usage

  • volume_code (DeviceFeature) - Tank capacity

Conversion formula: 1 gallon = 3.785 liters

Static Unit Fields (NOT Converted)

The following fields have universal units that don’t need conversion:

Time-Based

  • air_filter_alarm_period (hours)

  • air_filter_alarm_elapsed (hours)

  • vacation_day_setting (days)

  • vacation_day_elapsed (days)

  • cumulated_op_time_eva_fan (hours)

  • dr_override_status (hours)

Electrical

  • current_inst_power (Watts)

  • total_energy_capacity (Watt-hours)

  • available_energy_capacity (Watt-hours)

Mechanical/Signal

  • target_fan_rpm (RPM)

  • current_fan_rpm (RPM)

  • wifi_rssi (dBm)

Dimensionless

  • dhw_charge_per (%)

  • mixing_rate (%)

These fields return an empty string from get_field_unit(). Check the field’s json_schema_extra metadata for the static unit value if needed.

Conversion Formulas

Temperature Conversions

Celsius to Fahrenheit

fahrenheit = (celsius * 9/5) + 32

# Example: 60°C
temp_f = (60 * 9/5) + 32 = 140°F

Fahrenheit to Celsius

celsius = (fahrenheit - 32) * 5/9

# Example: 140°F
temp_c = (140 - 32) * 5/9 = 60°C

Flow Rate Conversions

LPM to GPM

gpm = lpm / 3.785

# Example: 3.785 LPM
flow_gpm = 3.785 / 3.785 = 1.0 GPM

GPM to LPM

lpm = gpm * 3.785

# Example: 1.0 GPM
flow_lpm = 1.0 * 3.785 = 3.785 LPM

Volume Conversions

Liters to Gallons

gallons = liters / 3.785

# Example: 37.85 L
vol_gal = 37.85 / 3.785 = 10.0 gal

Gallons to Liters

liters = gallons * 3.785

# Example: 10.0 gal
vol_l = 10.0 * 3.785 = 37.85 L

Temperature Formula Type (temp_formula_type)

Important: The temp_formula_type field is independent of temperature_type.

  • temperature_type: User preference (Celsius or Fahrenheit) - controls WHICH unit system is used

  • temp_formula_type: Device model configuration (ASYMMETRIC or STANDARD) - affects rounding when converting Fahrenheit

Scenario: Device with ASYMMETRIC formula in Celsius mode

feature = DeviceFeature(
    temperature_type=TemperatureType.CELSIUS,
    temp_formula_type=TemperatureFormulaType.ASYMMETRIC,
    dhw_temperature_min=81,  # 40.5°C
)

# Correct behavior:
print(feature.dhw_temperature_min)  # 40.5 (Celsius, no conversion)
print(feature.temp_formula_type)  # ASYMMETRIC (device capability)
unit = feature.get_field_unit('dhw_temperature_min')
print(f"{feature.dhw_temperature_min}{unit}")  # "40.5 °C"

# The ASYMMETRIC formula type is a device characteristic that only affects
# the Fahrenheit conversion formula if/when the user switches to Fahrenheit.
# In Celsius mode, values display in Celsius regardless of formula type.

Advanced Usage

Working with DeviceFeature

Temperature ranges in DeviceFeature also support dynamic conversion:

from nwp500 import DeviceFeature, TemperatureType

# Get device capabilities
feature = device_feature  # Retrieved from API

# Check device's temperature preference
if feature.temperature_type == TemperatureType.CELSIUS:
    print(f"DHW Range: {feature.dhw_temperature_min}°C - {feature.dhw_temperature_max}°C")
else:
    print(f"DHW Range: {feature.dhw_temperature_min}°F - {feature.dhw_temperature_max}°F")

# Or use get_field_unit for dynamic display
min_unit = feature.get_field_unit('dhw_temperature_min')
max_unit = feature.get_field_unit('dhw_temperature_max')
print(f"DHW Range: {feature.dhw_temperature_min}{min_unit} - {feature.dhw_temperature_max}{max_unit}")

Handling Unit Conversion in UI

For Web UIs (HTML/JavaScript)

from nwp500 import DeviceStatus

async def get_status_with_units(device_status: DeviceStatus):
    """Return status data with unit metadata for frontend."""
    return {
        'dhw_temperature': {
            'value': device_status.dhw_temperature,
            'unit': device_status.get_field_unit('dhw_temperature').strip(),
        },
        'current_dhw_flow_rate': {
            'value': device_status.current_dhw_flow_rate,
            'unit': device_status.get_field_unit('current_dhw_flow_rate').strip(),
        },
        'current_inst_power': {
            'value': device_status.current_inst_power,
            'unit': 'W',  # Static unit from metadata
        },
    }

For CLI Output

from nwp500 import DeviceStatus

def format_status(status: DeviceStatus) -> str:
    """Format device status for CLI display."""
    lines = []

    # Temperature with dynamic unit
    temp_unit = status.get_field_unit('dhw_temperature')
    lines.append(f"DHW Temperature: {status.dhw_temperature}{temp_unit}")

    # Flow rate with dynamic unit
    flow_unit = status.get_field_unit('current_dhw_flow_rate')
    lines.append(f"DHW Flow Rate: {status.current_dhw_flow_rate}{flow_unit}")

    # Static unit
    lines.append(f"Power: {status.current_inst_power} W")

    return '\n'.join(lines)

Checking for Unit Changes

Monitor device status updates to detect unit preference changes:

from nwp500 import DeviceStatus, TemperatureType

previous_status = None

async def handle_status_update(status: DeviceStatus):
    """Handle status updates and detect unit changes."""
    global previous_status

    if previous_status and status.temperature_type != previous_status.temperature_type:
        # Unit preference changed!
        old_unit = "Celsius" if previous_status.temperature_type == TemperatureType.CELSIUS else "Fahrenheit"
        new_unit = "Celsius" if status.temperature_type == TemperatureType.CELSIUS else "Fahrenheit"
        print(f"Unit preference changed: {old_unit}{new_unit}")

        # Trigger UI refresh to update all unit displays
        refresh_all_units()

    previous_status = status

Accessing Field Metadata

For advanced use cases, access field metadata directly:

from nwp500 import DeviceStatus

# Get field information
field_info = DeviceStatus.model_fields['dhw_temperature']
extra = field_info.json_schema_extra

print(f"Description: {field_info.description}")
print(f"Device class: {extra.get('device_class')}")
print(f"Fallback unit: {extra.get('unit_of_measurement')}")

# Output:
# Description: Current Domestic Hot Water (DHW) outlet temperature
# Device class: temperature
# Fallback unit: °F

Troubleshooting

Wrong Unit Displayed

Issue: A field shows the wrong unit (e.g., °F when device is in Celsius mode)

Solution: 1. Verify the device’s temperature_type is set correctly 2. Check that status is being updated with latest device data 3. Ensure no local caching is preventing updates 4. Call get_field_unit() to verify correct unit resolution

# Debug: Check device preference
print(f"Device mode: {status.temperature_type}")

# Debug: Query unit directly
unit = status.get_field_unit('dhw_temperature')
print(f"Resolved unit: {repr(unit)}")

Field Not Found

Issue: get_field_unit() returns empty string for a valid field

Solution: 1. Verify exact field name (case-sensitive) 2. Confirm field exists in model 3. Check if field has static unit (should return empty)

# Check if field exists
if 'dhw_temperature' in DeviceStatus.model_fields:
    print("Field exists")
else:
    print("Field not found")

# List all temperature fields
temp_fields = [
    name for name, field in DeviceStatus.model_fields.items()
    if field.json_schema_extra and
    field.json_schema_extra.get('device_class') == 'temperature'
]
print(f"Temperature fields: {temp_fields}")

Conversion Precision Issues

Issue: Converted values don’t match expected precision

Solution: This is expected due to floating-point arithmetic. Use rounding for display:

from nwp500 import DeviceStatus, TemperatureType

status = DeviceStatus(
    temperature_type=TemperatureType.FAHRENHEIT,
    dhw_temperature=120,  # 60°C → 140°F
)

# Raw value
print(f"Raw: {status.dhw_temperature}")  # 140.0

# Rounded for display
temp_rounded = round(status.dhw_temperature, 1)
unit = status.get_field_unit('dhw_temperature')
print(f"Display: {temp_rounded}{unit}")  # 140.0 °F

API Reference

DeviceStatus.get_field_unit()

def get_field_unit(self, field_name: str) -> str:
    """Get the correct unit suffix based on temperature preference.

    Resolves dynamic units for temperature, flow rate, and volume fields
    that change based on the device's temperature_type setting
    (Celsius or Fahrenheit).

    Args:
        field_name: Name of the field to get the unit for

    Returns:
        Unit string (e.g., " °C", " LPM", " L") or empty string if:
        - Field not found in model
        - Field has no dynamic unit conversion
        - Field has static unit (check metadata)

    Examples:
        >>> status = DeviceStatus(temperature_type=TemperatureType.CELSIUS, ...)
        >>> status.get_field_unit('dhw_temperature')
        ' °C'
        >>> status.get_field_unit('current_dhw_flow_rate')
        ' LPM'
        >>> status.get_field_unit('current_inst_power')
        ''
    """

DeviceFeature.get_field_unit()

Same as DeviceStatus.get_field_unit(), with support for: - Temperature range fields (dhw_temperature_min, dhw_temperature_max, etc.) - Volume fields (volume_code)

Implementation Details

Conversion Implementation

Dynamic unit conversion is implemented using Pydantic’s WrapValidator:

from pydantic import WrapValidator, ValidationInfo
from typing import Annotated

def half_celsius_to_preferred(value, handler, info: ValidationInfo):
    """Convert half-degree Celsius to preferred unit."""
    # Run normal validation first
    validated = handler(value)

    # Check device temperature preference
    temp_type = info.data.get('temperature_type')

    # If Fahrenheit mode, convert
    if temp_type == TemperatureType.FAHRENHEIT:
        # Convert: raw/2 = °C, then to °F
        celsius = validated / 2.0
        fahrenheit = (celsius * 9/5) + 32
        return fahrenheit

    # Otherwise keep in Celsius
    return validated / 2.0

# Usage in model
HalfCelsiusToPreferred = Annotated[
    float,
    WrapValidator(half_celsius_to_preferred)
]

class DeviceStatus(BaseModel):
    temperature_type: TemperatureType
    dhw_temperature: HalfCelsiusToPreferred

Field Metadata Structure

Each dynamically converted field includes metadata:

temperature_field = {
    'description': 'Current DHW temperature',
    'json_schema_extra': {
        'device_class': 'temperature',  # Home Assistant class
        'unit_of_measurement': '°F',   # Fallback/default unit
    }
}

flow_field = {
    'description': 'Current DHW flow rate',
    'json_schema_extra': {
        'device_class': 'flow_rate',
        'unit_of_measurement': 'GPM',
    }
}

Backward Compatibility

This feature represents a breaking change from previous versions where all values were hardcoded to Fahrenheit.

Old Behavior:

# All devices returned Fahrenheit values
status = DeviceStatus(...)
print(status.dhw_temperature)  # Always °F, even for Celsius devices

New Behavior:

# Values converted based on device preference
status_c = DeviceStatus(temperature_type=TemperatureType.CELSIUS, ...)
print(status_c.dhw_temperature)  # °C

status_f = DeviceStatus(temperature_type=TemperatureType.FAHRENHEIT, ...)
print(status_f.dhw_temperature)  # °F

Migration Guide:

  1. Always query temperature_type to determine unit

  2. Use get_field_unit() for display purposes

  3. Update UI/integrations to handle both unit systems

  4. Remove any hardcoded unit assumptions

See Also