Source code for hvacpy.equipment._heatpump

"""Air-source heat pump sizing — ASHRAE HSE 2020 Ch.49.

Handles both cooling and heating modes with COP correction curves,
heating capacity derating, and supplemental heat calculation.
"""

from __future__ import annotations

import math
from typing import TYPE_CHECKING

from hvacpy.units import Q_
from hvacpy.equipment._nominal_sizes import next_size_up
from hvacpy.equipment._airflow import airflow_from_cooling_load

if TYPE_CHECKING:
    from pint import Quantity


def _cop_correction_cooling(cop_rated: float, t_outdoor_c: float) -> float:
    """COP at actual outdoor temperature for cooling mode.

    COP_cool(T) = COP_rated * (1 - 0.013*(T - 35.0))
    Valid 20–46°C. Minimum 1.5.
    """
    factor = 1.0 - 0.013 * (t_outdoor_c - 35.0)
    return max(cop_rated * factor, 1.5)


def _cop_correction_heating(cop_rated: float, t_outdoor_c: float) -> float:
    """COP at actual outdoor temperature for heating mode.

    COP_heat(T) = COP_rated * (1 + 0.025*(T - 8.3))
    Valid -15 to 15°C. Minimum 1.0.
    """
    factor = 1.0 + 0.025 * (t_outdoor_c - 8.3)
    return max(cop_rated * factor, 1.0)


def _heating_capacity_at_temp(
    nominal_cooling_kw: float, t_outdoor_c: float,
) -> float:
    """Actual heating capacity at design outdoor temperature.

    Q_heat_rated = Q_cool_nominal * 1.15
    Q_heat_actual(T) = Q_heat_rated * (1 + 0.020*(T - 8.3))
    Clamped minimum = Q_cool_nominal * 0.50
    """
    q_heat_rated = nominal_cooling_kw * 1.15
    factor = 1.0 + 0.020 * (t_outdoor_c - 8.3)
    q_actual = q_heat_rated * factor
    return max(q_actual, nominal_cooling_kw * 0.50)


[docs] class AirSourceHeatPump: """Air-source heat pump sizing for both cooling and heating. Determines binding mode, COP corrections, heating coverage, and supplemental heat requirements. """ def __init__( self, cooling_load, heating_load, cop_rated_cooling: float = 3.5, cop_rated_heating: float = 3.8, t_outdoor_cooling: 'Quantity | None' = None, t_outdoor_heating: 'Quantity | None' = None, ) -> None: self._cooling_load = cooling_load self._heating_load = heating_load self._cop_rated_cooling = cop_rated_cooling self._cop_rated_heating = cop_rated_heating # Design temperatures self._t_cool_c = ( t_outdoor_cooling.to('degC').magnitude if t_outdoor_cooling is not None else 35.0 ) self._t_heat_c = ( t_outdoor_heating.to('degC').magnitude if t_outdoor_heating is not None else 8.3 ) # Required capacities self._req_cooling_kw = cooling_load.peak_total.to('kW').magnitude self._req_heating_kw = heating_load.total.to('kW').magnitude # Size by cooling load self._nominal_kw = next_size_up( self._req_cooling_kw, 'heat_pump_air_source' ) # Check heating capacity at design conditions self._actual_heating_kw = _heating_capacity_at_temp( self._nominal_kw, self._t_heat_c, ) # Determine binding mode if self._req_heating_kw > self._actual_heating_kw: # Heating is more demanding — but we still size by cooling # since heat pump nominal is defined by cooling self._binding_mode = 'heating' else: self._binding_mode = 'cooling' # COP at design conditions self._cop_design_cooling = _cop_correction_cooling( cop_rated_cooling, self._t_cool_c, ) self._cop_design_heating = _cop_correction_heating( cop_rated_heating, self._t_heat_c, ) # Airflow self._airflow = airflow_from_cooling_load(cooling_load) # ── Properties ────────────────────────────────────────────────── @property def binding_mode(self) -> str: """'cooling' or 'heating' — which determined nominal size.""" return self._binding_mode @property def nominal_capacity_kw(self) -> 'Quantity': """Nominal cooling capacity from heat pump table.""" return Q_(self._nominal_kw, 'kW') @property def cooling_oversizing(self) -> float: """nominal / required_cooling.""" if self._req_cooling_kw == 0: return 1.0 return self._nominal_kw / self._req_cooling_kw @property def heating_coverage(self) -> float: """actual_heating_at_design / required_heating.""" if self._req_heating_kw == 0: return 1.0 return self._actual_heating_kw / self._req_heating_kw @property def needs_supplemental_heat(self) -> bool: """True if heating_coverage < 1.0.""" return self.heating_coverage < 1.0 @property def supplemental_heat_kw(self) -> 'Quantity': """Required backup heating. 0 if not needed.""" if not self.needs_supplemental_heat: return Q_(0, 'kW') deficit = self._req_heating_kw - self._actual_heating_kw return Q_(max(deficit, 0), 'kW') @property def cop_at_design_cooling(self) -> float: """COP at t_outdoor_cooling.""" return self._cop_design_cooling @property def cop_at_design_heating(self) -> float: """COP at t_outdoor_heating.""" return self._cop_design_heating @property def supply_airflow(self) -> 'Quantity': """Supply airflow in m³/s.""" return self._airflow # ── Methods ─────────────────────────────────────────────────────
[docs] def summary(self) -> str: """Box format showing both modes, binding, coverage, supplemental.""" airflow_m3h = self._airflow.magnitude * 3600 supp = (f'{self.supplemental_heat_kw.magnitude:.2f} kW' if self.needs_supplemental_heat else 'Not required') lines = [ '+================================================+', '| HEAT PUMP SIZING SUMMARY |', '| Air-Source Heat Pump |', '+================================================+', '| COOLING MODE |', f'| Required: {self._req_cooling_kw:>6.2f} kW{" ":<20s}|', f'| Nominal: {self._nominal_kw:>6.2f} kW{" ":<20s}|', f'| Oversizing: {self.cooling_oversizing:>6.3f}{" ":<24s}|', f'| COP at design: {self._cop_design_cooling:>5.2f} ({self._t_cool_c:.0f}°C){" ":<13s}|', '+================================================+', '| HEATING MODE |', f'| Required: {self._req_heating_kw:>6.2f} kW{" ":<20s}|', f'| HP capacity: {self._actual_heating_kw:>6.2f} kW (at {self._t_heat_c:.0f}°C){" ":<6s}|', f'| Coverage: {self.heating_coverage:>6.1%}{" ":<24s}|', f'| COP at design: {self._cop_design_heating:>5.2f} ({self._t_heat_c:.0f}°C){" ":<13s}|', f'| Supplemental: {supp:<30s}|', '+================================================+', f'| BINDING MODE: {self._binding_mode.upper():<30s}|', f'| Supply airflow: {self._airflow.magnitude:.3f} m3/s ({airflow_m3h:.0f} m3/h){" ":<4s}|', '+================================================+', ] return '\n'.join(lines)