Source code for hvacpy.loads._cooling

"""Cooling load calculations using the ASHRAE CLTD/CLF method.

Implements ASHRAE 1997 HOF Chapter 28 simplified cooling load method.
This is the industry-standard method for preliminary and design-stage
cooling load calculations in commercial buildings.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

from hvacpy.units import Q_
from hvacpy.exceptions import HvacpyError
from hvacpy.loads._components import WallComponent, WindowComponent, InternalGain, Orientation
from hvacpy.loads._cltd_tables import (
    get_cltd, get_clf_solar, get_i_max, get_design_conditions,
)
from hvacpy.loads._room import Room, Zone
from hvacpy.loads._internal import (
    calculate_people_gain, calculate_lighting_gain, calculate_equipment_gain,
)
from hvacpy.loads._infiltration import (
    calculate_infiltration_mass_flow,
    calculate_infiltration_sensible,
    calculate_infiltration_latent,
)

if TYPE_CHECKING:
    from pint import Quantity


# ── Design conditions reference — ASHRAE 1997 HOF Ch.28 ────────────
T_OUTDOOR_DESIGN_C   = 35.0    # °C — standard CLTD table base outdoor temp
T_INDOOR_DESIGN_C    = 24.0    # °C — standard CLTD table base indoor temp
DELTA_T_BASE         = 11.0    # K  — T_OUTDOOR_DESIGN - T_INDOOR_DESIGN

# Solar constants
SC_CLEAR_GLASS       = 1.0     # shading coefficient for clear single glass (reference)

# Internal gain factors — ASHRAE 1997 Table 3
PEOPLE_SENSIBLE_W = {
    'seated_quiet':      60,
    'office_work':       65,
    'standing_light':    70,
    'walking':           75,
    'light_bench_work':  80,
    'retail_banking':    75,
    'restaurant':        70,
    'dancing':          140,
    'heavy_work':       185,
}
PEOPLE_LATENT_W = {
    'seated_quiet':      45,
    'office_work':       55,
    'standing_light':    55,
    'walking':           55,
    'light_bench_work':  80,
    'retail_banking':    55,
    'restaurant':        80,
    'dancing':          175,
    'heavy_work':       250,
}


[docs] class CoolingLoad: """Calculates peak cooling load for a room or zone using CLTD/CLF method. Args: space: Room or Zone to analyse. city: City name string for design conditions lookup. OR provide t_outdoor_db and t_outdoor_wb directly. t_outdoor_db: Outdoor design dry bulb temp as Quantity. Overrides city. t_outdoor_wb: Outdoor design wet bulb temp as Quantity. Overrides city. design_hour: Hour (1–24) at which to calculate load. Default: None (calculates all 24 hours and returns peak). diurnal_range: Daily temp swing in K. Default Q_(10, 'delta_degC'). """ def __init__( self, space: Room | Zone, city: str | None = None, *, t_outdoor_db: 'Quantity | None' = None, t_outdoor_wb: 'Quantity | None' = None, design_hour: int | None = None, diurnal_range: 'Quantity | None' = None, ) -> None: self._space = space self._design_hour = design_hour # Resolve design conditions if t_outdoor_db is not None: self._t_outdoor_db = t_outdoor_db.to('degC').magnitude self._t_outdoor_wb = ( t_outdoor_wb.to('degC').magnitude if t_outdoor_wb is not None else self._t_outdoor_db - 5.0 # rough default ) elif city is not None: dc = get_design_conditions(city) self._t_outdoor_db = dc['t_outdoor_db'] self._t_outdoor_wb = dc['t_outdoor_wb'] else: raise HvacpyError( "Must provide either 'city' or 't_outdoor_db' for cooling load." ) # Diurnal range if diurnal_range is not None: self._diurnal_range = diurnal_range.to('delta_degC').magnitude else: self._diurnal_range = 10.0 # default # Get rooms list if isinstance(space, Zone): self._rooms = space.rooms else: self._rooms = [space] # Calculate all 24 hours self._hourly: dict[int, dict] = {} for hour in range(1, 25): self._hourly[hour] = self._calc_hour(hour) # Find peak hour if design_hour is not None: self._peak_hour = design_hour else: self._peak_hour = max( range(1, 25), key=lambda h: self._hourly[h]['total'] ) def _calc_hour(self, hour: int) -> dict: """Calculate all load components at a given hour.""" wall_cond = 0.0 window_cond = 0.0 solar = 0.0 inf_sensible = 0.0 inf_latent = 0.0 people_sens = 0.0 people_lat = 0.0 lighting = 0.0 equip_sens = 0.0 equip_lat = 0.0 t_outdoor_db = self._t_outdoor_db diurnal = self._diurnal_range for room in self._rooms: t_indoor = room.t_indoor.to('degC').magnitude # Wall conduction — CLTD method (ASHRAE 1997 HOF Eq. 28-1) for wall in room.walls: u_val = wall.assembly.u_value.magnitude # W/(m²K) area = wall.area.to('m**2').magnitude orient = wall.orientation.value # Look up CLTD if wall.is_roof: cltd_table = get_cltd(hour, orient, 'G') else: cltd_table = get_cltd(hour, orient, wall.wall_group) # CLTD correction — ASHRAE 1997 HOF # CLTD_corrected = CLTD_table + (25.5 - T_indoor) # + (T_outdoor_mean - 29.4) # T_outdoor_mean = T_outdoor_db - 0.5 * diurnal_range t_outdoor_mean = t_outdoor_db - 0.5 * diurnal cltd_corrected = ( cltd_table + (25.5 - t_indoor) + (t_outdoor_mean - 29.4) ) # Q_wall = U * A * CLTD_corrected # CLTD_corrected can be negative — physically valid q = u_val * area * cltd_corrected wall_cond += q # Window conduction — instantaneous ΔT (no CLTD) for win in room.windows: u_win = win.u_factor.to('W/(m**2*K)').magnitude area_win = win.area.to('m**2').magnitude q_win_cond = u_win * area_win * (t_outdoor_db - t_indoor) window_cond += q_win_cond # Solar heat gain — ASHRAE 1997 HOF Eq. 28-3 orient = win.orientation.value a_glazed = area_win * (1.0 - win.frame_fraction) shgc = win.shgc sc_ratio = shgc / 0.87 # normalise to clear single glass clf = get_clf_solar(hour, orient, win.has_interior_shading) i_max = get_i_max(orient) # Q_solar = A_glazed * SC_ratio * CLF * I_max # Note: we use SC_ratio (= SHGC/0.87) not SHGC directly. # The I_max values already incorporate the solar geometry. # This is a simplification for 32°N July — will be replaced # with full solar geometry in v0.5. q_solar = a_glazed * sc_ratio * clf * i_max solar += q_solar # Infiltration volume = room.volume_m3 m_dot = calculate_infiltration_mass_flow( room.ach_infiltration, volume ) inf_sensible += calculate_infiltration_sensible( m_dot, t_outdoor_db, t_indoor ) inf_latent += calculate_infiltration_latent(m_dot) # Internal gains floor_area_m2 = room.floor_area_m2 for gain in room.internal_gains: if gain.gain_type == 'people': s, l = calculate_people_gain( gain.count, gain.activity, gain.diversity, gain.clf ) people_sens += s people_lat += l elif gain.gain_type == 'lighting': q_light = calculate_lighting_gain( gain.total_watts, gain.watts_per_m2, floor_area_m2, gain.diversity, gain.clf ) lighting += q_light elif gain.gain_type == 'equipment': s, l = calculate_equipment_gain( gain.total_watts, gain.watts_per_m2, floor_area_m2, gain.diversity, gain.clf ) equip_sens += s equip_lat += l sensible = ( wall_cond + window_cond + solar + inf_sensible + people_sens + lighting + equip_sens ) latent = inf_latent + people_lat + equip_lat total = sensible + latent return { 'wall_conduction': wall_cond, 'window_conduction': window_cond, 'solar_gain': solar, 'infiltration_sensible': inf_sensible, 'infiltration_latent': inf_latent, 'people_sensible': people_sens, 'people_latent': people_lat, 'lighting_gain': lighting, 'equipment_sensible': equip_sens, 'equipment_latent': equip_lat, 'sensible': sensible, 'latent': latent, 'total': total, } # ── Properties — all return Quantities in Watts ───────────────── @property def peak_total(self) -> 'Quantity': """Sum of all sensible + latent at the peak hour.""" return Q_(self._hourly[self._peak_hour]['total'], 'W') @property def peak_sensible(self) -> 'Quantity': """Sensible component at peak hour.""" return Q_(self._hourly[self._peak_hour]['sensible'], 'W') @property def peak_latent(self) -> 'Quantity': """Latent component at peak hour = infiltration_latent + people_latent + equipment_latent.""" return Q_(self._hourly[self._peak_hour]['latent'], 'W') @property def peak_hour(self) -> int: """Hour (1–24) at which peak_total occurs.""" return self._peak_hour @property def wall_conduction(self) -> 'Quantity': """Total wall+roof conduction at peak hour.""" return Q_(self._hourly[self._peak_hour]['wall_conduction'], 'W') @property def window_conduction(self) -> 'Quantity': """Total window conduction at peak hour.""" return Q_(self._hourly[self._peak_hour]['window_conduction'], 'W') @property def solar_gain(self) -> 'Quantity': """Total solar heat gain at peak hour.""" return Q_(self._hourly[self._peak_hour]['solar_gain'], 'W') @property def infiltration_sensible(self) -> 'Quantity': """Infiltration sensible at peak hour.""" return Q_(self._hourly[self._peak_hour]['infiltration_sensible'], 'W') @property def infiltration_latent(self) -> 'Quantity': """Infiltration latent at peak hour.""" return Q_(self._hourly[self._peak_hour]['infiltration_latent'], 'W') @property def people_sensible(self) -> 'Quantity': """People sensible at peak hour.""" return Q_(self._hourly[self._peak_hour]['people_sensible'], 'W') @property def people_latent(self) -> 'Quantity': """People latent at peak hour.""" return Q_(self._hourly[self._peak_hour]['people_latent'], 'W') @property def lighting_gain(self) -> 'Quantity': """Lighting gain (all sensible) at peak hour.""" return Q_(self._hourly[self._peak_hour]['lighting_gain'], 'W') @property def equipment_sensible(self) -> 'Quantity': """Equipment sensible at peak hour.""" return Q_(self._hourly[self._peak_hour]['equipment_sensible'], 'W') @property def equipment_latent(self) -> 'Quantity': """Equipment latent at peak hour.""" return Q_(self._hourly[self._peak_hour]['equipment_latent'], 'W') @property def sensible_heat_ratio(self) -> float: """SHR = peak_sensible / peak_total.""" total = self._hourly[self._peak_hour]['total'] if total == 0: return 1.0 return self._hourly[self._peak_hour]['sensible'] / total # ── Methods ─────────────────────────────────────────────────────
[docs] def hourly_profile(self) -> dict[int, float]: """Returns dict of {hour: total_load_W} for all 24 hours. Useful for understanding load shape over the day. """ return {h: data['total'] for h, data in self._hourly.items()}
[docs] def breakdown(self) -> str: """Formatted table of all components, their W value, and % of peak_total. Same style as Assembly.breakdown(). """ data = self._hourly[self._peak_hour] total = data['total'] def pct(val: float) -> str: if total == 0: return ' 0.0%' return f'{val / total * 100:5.1f}%' lines: list[str] = [] lines.append(f'Cooling Load Breakdown — Peak Hour {self._peak_hour}:00') lines.append('━' * 55) components = [ ('Wall conduction', data['wall_conduction']), ('Window conduction', data['window_conduction']), ('Solar gain', data['solar_gain']), ('Infiltration (sensible)', data['infiltration_sensible']), ('Infiltration (latent)', data['infiltration_latent']), ('People (sensible)', data['people_sensible']), ('People (latent)', data['people_latent']), ('Lighting', data['lighting_gain']), ('Equipment (sensible)', data['equipment_sensible']), ('Equipment (latent)', data['equipment_latent']), ] for label, val in components: lines.append(f' {label:<26s} {val:>8.1f} W {pct(val)}') lines.append('━' * 55) lines.append(f' {"TOTAL SENSIBLE":<26s} {data["sensible"]:>8.1f} W {pct(data["sensible"])}') lines.append(f' {"TOTAL LATENT":<26s} {data["latent"]:>8.1f} W {pct(data["latent"])}') lines.append(f' {"PEAK TOTAL":<26s} {total:>8.1f} W 100.0%') lines.append(f' SHR = {self.sensible_heat_ratio:.3f}') return '\n'.join(lines)
[docs] def to_dict(self) -> dict: """All properties as plain floats in SI. Includes 'peak_hour', all component loads in W, 'shr'. """ data = self._hourly[self._peak_hour] return { 'peak_hour': self._peak_hour, 'peak_total_W': data['total'], 'peak_sensible_W': data['sensible'], 'peak_latent_W': data['latent'], 'wall_conduction_W': data['wall_conduction'], 'window_conduction_W': data['window_conduction'], 'solar_gain_W': data['solar_gain'], 'infiltration_sensible_W': data['infiltration_sensible'], 'infiltration_latent_W': data['infiltration_latent'], 'people_sensible_W': data['people_sensible'], 'people_latent_W': data['people_latent'], 'lighting_gain_W': data['lighting_gain'], 'equipment_sensible_W': data['equipment_sensible'], 'equipment_latent_W': data['equipment_latent'], 'shr': self.sensible_heat_ratio, }