Spaces:
Sleeping
Sleeping
Update utils/heating_load.py
Browse files- utils/heating_load.py +289 -421
utils/heating_load.py
CHANGED
|
@@ -1,48 +1,157 @@
|
|
| 1 |
"""
|
| 2 |
Heating load calculation module for HVAC Load Calculator.
|
| 3 |
-
|
| 4 |
-
pressure-driven infiltration, and schedule-based internal gains.
|
| 5 |
Updated 2025-04-28: Added F-factor for floor losses, ground temperature validation, and negative load prevention.
|
|
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
-
from typing import Dict, List, Any, Optional
|
| 9 |
import math
|
| 10 |
import numpy as np
|
| 11 |
-
import
|
| 12 |
-
import os
|
| 13 |
-
from datetime import datetime, timedelta
|
| 14 |
from enum import Enum
|
| 15 |
|
| 16 |
-
#
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
class HeatingLoadCalculator:
|
| 25 |
-
"""Class for calculating heating loads
|
| 26 |
|
| 27 |
def __init__(self):
|
| 28 |
-
"""Initialize
|
| 29 |
self.heat_transfer = HeatTransferCalculations()
|
| 30 |
self.psychrometrics = Psychrometrics()
|
| 31 |
-
|
| 32 |
def validate_inputs(self, temp: float, rh: float, area: float, u_value: float, ground_temp: Optional[float] = None) -> None:
|
| 33 |
-
"""
|
| 34 |
-
Validate input parameters for calculations.
|
| 35 |
-
|
| 36 |
-
Args:
|
| 37 |
-
temp: Temperature in °C
|
| 38 |
-
rh: Relative humidity in %
|
| 39 |
-
area: Area in m²
|
| 40 |
-
u_value: U-value in W/(m²·K)
|
| 41 |
-
ground_temp: Ground temperature in °C (optional)
|
| 42 |
-
|
| 43 |
-
Raises:
|
| 44 |
-
ValueError: If inputs are out of acceptable ranges
|
| 45 |
-
"""
|
| 46 |
if not -50 <= temp <= 60:
|
| 47 |
raise ValueError(f"Temperature {temp}°C is outside valid range (-50 to 60°C)")
|
| 48 |
if not 0 <= rh <= 100:
|
|
@@ -53,309 +162,121 @@ class HeatingLoadCalculator:
|
|
| 53 |
raise ValueError(f"U-value {u_value} W/(m²·K) cannot be negative")
|
| 54 |
if ground_temp is not None and not -10 <= ground_temp <= 40:
|
| 55 |
raise ValueError(f"Ground temperature {ground_temp}°C is outside valid range (-10 to 40°C)")
|
| 56 |
-
|
| 57 |
def calculate_wall_heating_load(self, wall: Wall, outdoor_temp: float, indoor_temp: float) -> float:
|
| 58 |
-
"""
|
| 59 |
-
Calculate heating load through a wall with thermal mass effects.
|
| 60 |
-
|
| 61 |
-
Args:
|
| 62 |
-
wall: Wall object
|
| 63 |
-
outdoor_temp: Outdoor temperature in °C
|
| 64 |
-
indoor_temp: Indoor temperature in °C
|
| 65 |
-
|
| 66 |
-
Returns:
|
| 67 |
-
Heating load in W
|
| 68 |
-
"""
|
| 69 |
self.validate_inputs(outdoor_temp, 80.0, wall.area, wall.u_value)
|
| 70 |
-
|
| 71 |
-
u_value = wall.u_value
|
| 72 |
-
area = wall.area
|
| 73 |
delta_t = indoor_temp - outdoor_temp
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
thermal_mass = getattr(wall, "thermal_mass", 100000) # J/K, default
|
| 77 |
-
time_constant = getattr(wall, "time_constant", 2.0) # hours, default
|
| 78 |
-
lag_factor = self.heat_transfer.thermal_lag_factor(thermal_mass, time_constant, 1.0)
|
| 79 |
-
|
| 80 |
-
heating_load = u_value * area * delta_t * lag_factor
|
| 81 |
-
|
| 82 |
return max(0, heating_load)
|
| 83 |
-
|
| 84 |
def calculate_roof_heating_load(self, roof: Roof, outdoor_temp: float, indoor_temp: float) -> float:
|
| 85 |
-
"""
|
| 86 |
-
Calculate heating load through a roof with thermal mass effects.
|
| 87 |
-
|
| 88 |
-
Args:
|
| 89 |
-
roof: Roof object
|
| 90 |
-
outdoor_temp: Outdoor temperature in °C
|
| 91 |
-
indoor_temp: Indoor temperature in °C
|
| 92 |
-
|
| 93 |
-
Returns:
|
| 94 |
-
Heating load in W
|
| 95 |
-
"""
|
| 96 |
self.validate_inputs(outdoor_temp, 80.0, roof.area, roof.u_value)
|
| 97 |
-
|
| 98 |
-
u_value = roof.u_value
|
| 99 |
-
area = roof.area
|
| 100 |
delta_t = indoor_temp - outdoor_temp
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
thermal_mass = getattr(roof, "thermal_mass", 200000) # J/K, default
|
| 104 |
-
time_constant = getattr(roof, "time_constant", 3.0) # hours, default
|
| 105 |
-
lag_factor = self.heat_transfer.thermal_lag_factor(thermal_mass, time_constant, 1.0)
|
| 106 |
-
|
| 107 |
-
heating_load = u_value * area * delta_t * lag_factor
|
| 108 |
-
|
| 109 |
return max(0, heating_load)
|
| 110 |
-
|
| 111 |
def calculate_floor_heating_load(self, floor: Floor, ground_temp: float, indoor_temp: float) -> float:
|
| 112 |
-
"""
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
Args:
|
| 116 |
-
floor: Floor object
|
| 117 |
-
ground_temp: Ground or adjacent space temperature in °C
|
| 118 |
-
indoor_temp: Indoor temperature in °C
|
| 119 |
-
|
| 120 |
-
Returns:
|
| 121 |
-
Heating load in W
|
| 122 |
-
"""
|
| 123 |
-
self.validate_inputs(ground_temp, 80.0, floor.area, floor.u_value, ground_temp=ground_temp)
|
| 124 |
-
|
| 125 |
-
heating_load = 0.0
|
| 126 |
delta_t = indoor_temp - ground_temp
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
lag_factor = self.heat_transfer.thermal_lag_factor(thermal_mass, time_constant, 1.0)
|
| 134 |
-
conduction_load = u_value * area * delta_t * lag_factor
|
| 135 |
-
|
| 136 |
-
heating_load += conduction_load
|
| 137 |
-
|
| 138 |
-
# NEW: Perimeter losses (F-factor method, ASHRAE Fundamentals Chapter 18)
|
| 139 |
-
if getattr(floor, 'ground_contact', False):
|
| 140 |
-
perimeter = getattr(floor, 'perimeter', 0.0)
|
| 141 |
-
if perimeter < 0:
|
| 142 |
-
raise ValueError(f"Perimeter for floor {floor.name} cannot be negative")
|
| 143 |
-
if perimeter > 0:
|
| 144 |
-
f_factor = 0.73 # W/(m·K) for uninsulated slab-on-grade (Table 18.3)
|
| 145 |
-
perimeter_load = f_factor * perimeter * delta_t
|
| 146 |
-
heating_load += perimeter_load
|
| 147 |
-
|
| 148 |
return max(0, heating_load)
|
| 149 |
-
|
| 150 |
def calculate_window_heating_load(self, window: Window, outdoor_temp: float, indoor_temp: float) -> float:
|
| 151 |
-
"""
|
| 152 |
-
Calculate heating load through a window using steady-state conduction.
|
| 153 |
-
|
| 154 |
-
Args:
|
| 155 |
-
window: Window object
|
| 156 |
-
outdoor_temp: Outdoor temperature in °C
|
| 157 |
-
indoor_temp: Indoor temperature in °C
|
| 158 |
-
|
| 159 |
-
Returns:
|
| 160 |
-
Heating load in W
|
| 161 |
-
"""
|
| 162 |
self.validate_inputs(outdoor_temp, 80.0, window.area, window.u_value)
|
| 163 |
-
|
| 164 |
-
u_value = window.u_value
|
| 165 |
-
area = window.area
|
| 166 |
delta_t = indoor_temp - outdoor_temp
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
return max(0, heating_load)
|
| 170 |
-
|
| 171 |
def calculate_door_heating_load(self, door: Door, outdoor_temp: float, indoor_temp: float) -> float:
|
| 172 |
-
"""
|
| 173 |
-
Calculate heating load through a door using steady-state conduction.
|
| 174 |
-
|
| 175 |
-
Args:
|
| 176 |
-
door: Door object
|
| 177 |
-
outdoor_temp: Outdoor temperature in °C
|
| 178 |
-
indoor_temp: Indoor temperature in °C
|
| 179 |
-
|
| 180 |
-
Returns:
|
| 181 |
-
Heating load in W
|
| 182 |
-
"""
|
| 183 |
self.validate_inputs(outdoor_temp, 80.0, door.area, door.u_value)
|
| 184 |
-
|
| 185 |
-
u_value = door.u_value
|
| 186 |
-
area = door.area
|
| 187 |
delta_t = indoor_temp - outdoor_temp
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
return max(0, heating_load)
|
| 191 |
-
|
| 192 |
def calculate_infiltration_heating_load(self, building_volume: float, outdoor_temp: float,
|
| 193 |
indoor_temp: float, outdoor_rh: float, indoor_rh: float,
|
| 194 |
wind_speed: float = 4.0, height: float = 3.0,
|
| 195 |
-
crack_length: float =
|
| 196 |
-
"""
|
| 197 |
-
Calculate sensible and latent heating loads due to pressure-driven infiltration.
|
| 198 |
-
|
| 199 |
-
Args:
|
| 200 |
-
building_volume: Building volume in m³
|
| 201 |
-
outdoor_temp: Outdoor temperature in °C
|
| 202 |
-
indoor_temp: Indoor temperature in °C
|
| 203 |
-
outdoor_rh: Outdoor relative humidity in %
|
| 204 |
-
indoor_rh: Indoor relative humidity in %
|
| 205 |
-
wind_speed: Wind speed in m/s (default: 4.0 m/s)
|
| 206 |
-
height: Building height in m (default: 3.0 m)
|
| 207 |
-
crack_length: Total crack length in m (default: 10.0 m)
|
| 208 |
-
|
| 209 |
-
Returns:
|
| 210 |
-
Dictionary with sensible, latent, and total heating loads in W
|
| 211 |
-
"""
|
| 212 |
self.validate_inputs(outdoor_temp, outdoor_rh, building_volume, 0.0)
|
| 213 |
-
|
| 214 |
-
# Calculate infiltration flow rate
|
| 215 |
wind_pd = self.heat_transfer.wind_pressure_difference(wind_speed, wind_coefficient=0.4)
|
| 216 |
-
stack_pd = self.heat_transfer.stack_pressure_difference(
|
| 217 |
-
height=height,
|
| 218 |
-
indoor_temp=indoor_temp + 273.15,
|
| 219 |
-
outdoor_temp=outdoor_temp + 273.15
|
| 220 |
-
)
|
| 221 |
total_pd = self.heat_transfer.combined_pressure_difference(wind_pd, stack_pd)
|
| 222 |
-
flow_rate = self.heat_transfer.crack_method_infiltration(
|
| 223 |
-
|
| 224 |
-
coefficient=0.0001,
|
| 225 |
-
pressure_difference=total_pd
|
| 226 |
-
)
|
| 227 |
-
|
| 228 |
-
# Calculate sensible heating load
|
| 229 |
-
sensible_load = self.heat_transfer.infiltration_heat_transfer(
|
| 230 |
-
flow_rate=flow_rate,
|
| 231 |
-
delta_t=indoor_temp - outdoor_temp
|
| 232 |
-
)
|
| 233 |
-
|
| 234 |
-
# Calculate humidity ratios
|
| 235 |
w_outdoor = self.psychrometrics.humidity_ratio(outdoor_temp, outdoor_rh)
|
| 236 |
w_indoor = self.psychrometrics.humidity_ratio(indoor_temp, indoor_rh)
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
delta_w = w_indoor - w_outdoor
|
| 240 |
-
latent_load = self.heat_transfer.infiltration_latent_heat_transfer(
|
| 241 |
-
flow_rate=flow_rate,
|
| 242 |
-
delta_w=delta_w
|
| 243 |
-
) if delta_w > 0 else 0
|
| 244 |
-
|
| 245 |
-
total_load = sensible_load + latent_load
|
| 246 |
-
|
| 247 |
return {
|
| 248 |
"sensible": max(0, sensible_load),
|
| 249 |
"latent": max(0, latent_load),
|
| 250 |
-
"total": max(0,
|
| 251 |
}
|
| 252 |
-
|
| 253 |
def calculate_ventilation_heating_load(self, flow_rate: float, outdoor_temp: float, indoor_temp: float,
|
| 254 |
-
|
| 255 |
-
"""
|
| 256 |
-
Calculate sensible and latent heating loads due to ventilation.
|
| 257 |
-
|
| 258 |
-
Args:
|
| 259 |
-
flow_rate: Ventilation flow rate in m³/s
|
| 260 |
-
outdoor_temp: Outdoor temperature in °C
|
| 261 |
-
indoor_temp: Indoor temperature in °C
|
| 262 |
-
outdoor_rh: Outdoor relative humidity in %
|
| 263 |
-
indoor_rh: Indoor relative humidity in %
|
| 264 |
-
|
| 265 |
-
Returns:
|
| 266 |
-
Dictionary with sensible, latent, and total heating loads in W
|
| 267 |
-
"""
|
| 268 |
self.validate_inputs(outdoor_temp, outdoor_rh, 0.0, 0.0)
|
| 269 |
-
|
| 270 |
-
sensible_load = self.heat_transfer.infiltration_heat_transfer(
|
| 271 |
-
flow_rate=flow_rate,
|
| 272 |
-
delta_t=indoor_temp - outdoor_temp
|
| 273 |
-
)
|
| 274 |
w_outdoor = self.psychrometrics.humidity_ratio(outdoor_temp, outdoor_rh)
|
| 275 |
w_indoor = self.psychrometrics.humidity_ratio(indoor_temp, indoor_rh)
|
| 276 |
-
delta_w = w_indoor - w_outdoor
|
| 277 |
-
latent_load = self.heat_transfer.infiltration_latent_heat_transfer(
|
| 278 |
-
flow_rate=flow_rate,
|
| 279 |
-
delta_w=delta_w
|
| 280 |
-
) if delta_w > 0 else 0
|
| 281 |
-
total_load = sensible_load + latent_load
|
| 282 |
-
|
| 283 |
return {
|
| 284 |
"sensible": max(0, sensible_load),
|
| 285 |
"latent": max(0, latent_load),
|
| 286 |
-
"total": max(0,
|
| 287 |
}
|
| 288 |
-
|
| 289 |
def calculate_internal_gains_offset(self, people_load: float, lights_load: float,
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
"""
|
| 293 |
-
Calculate internal gains offset for heating load with schedule.
|
| 294 |
-
|
| 295 |
-
Args:
|
| 296 |
-
people_load: Heat gain from people in W
|
| 297 |
-
lights_load: Heat gain from lights in W
|
| 298 |
-
equipment_load: Heat gain from equipment in W
|
| 299 |
-
usage_factor: Usage factor for internal gains (0-1)
|
| 300 |
-
hour: Hour of the day (0-23)
|
| 301 |
-
schedule: List of 24 hourly gain factors (0-1, default: None)
|
| 302 |
-
|
| 303 |
-
Returns:
|
| 304 |
-
Internal gains offset in W
|
| 305 |
-
"""
|
| 306 |
total_gains = people_load + lights_load + equipment_load
|
| 307 |
-
schedule_factor = 1.0
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
offset = total_gains * usage_factor * schedule_factor
|
| 311 |
-
return max(0, offset)
|
| 312 |
-
|
| 313 |
def calculate_design_heating_load(self, building_components: Dict[str, List[Any]],
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
"""
|
| 320 |
-
Calculate design heating load for a building with enhanced calculations.
|
| 321 |
-
|
| 322 |
-
Args:
|
| 323 |
-
building_components: Dictionary with lists of building components
|
| 324 |
-
outdoor_conditions: Dictionary with outdoor conditions
|
| 325 |
-
indoor_conditions: Dictionary with indoor conditions
|
| 326 |
-
internal_loads: Dictionary with internal loads
|
| 327 |
-
building_volume: Building volume in m³ (default: 300 m³)
|
| 328 |
-
safety_factor: Safety factor for heating load (default: 1.15)
|
| 329 |
-
|
| 330 |
-
Returns:
|
| 331 |
-
Dictionary with design heating loads
|
| 332 |
-
"""
|
| 333 |
-
walls = building_components.get("walls", [])
|
| 334 |
-
roofs = building_components.get("roofs", [])
|
| 335 |
-
floors = building_components.get("floors", [])
|
| 336 |
-
windows = building_components.get("windows", [])
|
| 337 |
-
doors = building_components.get("doors", [])
|
| 338 |
-
|
| 339 |
outdoor_temp = outdoor_conditions.get("design_temperature", -10.0)
|
| 340 |
outdoor_rh = outdoor_conditions.get("design_relative_humidity", 80.0)
|
| 341 |
ground_temp = outdoor_conditions.get("ground_temperature", 10.0)
|
| 342 |
wind_speed = outdoor_conditions.get("wind_speed", 4.0)
|
| 343 |
-
|
| 344 |
indoor_temp = indoor_conditions.get("temperature", 21.0)
|
| 345 |
indoor_rh = indoor_conditions.get("relative_humidity", 40.0)
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
loads = {
|
| 354 |
-
"walls":
|
| 355 |
-
"roofs":
|
| 356 |
-
"floors":
|
| 357 |
-
"windows":
|
| 358 |
-
"doors":
|
| 359 |
"infiltration_sensible": 0,
|
| 360 |
"infiltration_latent": 0,
|
| 361 |
"ventilation_sensible": 0,
|
|
@@ -365,99 +286,51 @@ class HeatingLoadCalculator:
|
|
| 365 |
"safety_factor": safety_factor,
|
| 366 |
"total": 0
|
| 367 |
}
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
for wall in walls:
|
| 371 |
-
loads["walls"] += self.calculate_wall_heating_load(wall, outdoor_temp, indoor_temp)
|
| 372 |
-
for roof in roofs:
|
| 373 |
-
loads["roofs"] += self.calculate_roof_heating_load(roof, outdoor_temp, indoor_temp)
|
| 374 |
-
for floor in floors:
|
| 375 |
-
loads["floors"] += self.calculate_floor_heating_load(floor, ground_temp, indoor_temp)
|
| 376 |
-
for window in windows:
|
| 377 |
-
loads["windows"] += self.calculate_window_heating_load(window, outdoor_temp, indoor_temp)
|
| 378 |
-
for door in doors:
|
| 379 |
-
loads["doors"] += self.calculate_door_heating_load(door, outdoor_temp, indoor_temp)
|
| 380 |
-
|
| 381 |
-
# Calculate infiltration loads
|
| 382 |
-
if infiltration:
|
| 383 |
infiltration_loads = self.calculate_infiltration_heating_load(
|
| 384 |
-
building_volume
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
outdoor_rh=outdoor_rh,
|
| 388 |
-
indoor_rh=indoor_rh,
|
| 389 |
-
wind_speed=wind_speed,
|
| 390 |
-
height=infiltration.get("height", 3.0),
|
| 391 |
-
crack_length=infiltration.get("crack_length", 10.0)
|
| 392 |
)
|
| 393 |
loads["infiltration_sensible"] = infiltration_loads["sensible"]
|
| 394 |
loads["infiltration_latent"] = infiltration_loads["latent"]
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
if ventilation:
|
| 398 |
ventilation_loads = self.calculate_ventilation_heating_load(
|
| 399 |
-
|
| 400 |
-
outdoor_temp
|
| 401 |
-
indoor_temp=indoor_temp,
|
| 402 |
-
outdoor_rh=outdoor_rh,
|
| 403 |
-
indoor_rh=indoor_rh
|
| 404 |
)
|
| 405 |
loads["ventilation_sensible"] = ventilation_loads["sensible"]
|
| 406 |
loads["ventilation_latent"] = ventilation_loads["latent"]
|
| 407 |
-
|
| 408 |
-
|
|
|
|
|
|
|
| 409 |
people_load = people.get("number", 0) * people.get("sensible_gain", 70.0)
|
| 410 |
lights_load = lights.get("power", 0) * lights.get("use_factor", 0.8)
|
| 411 |
equipment_load = equipment.get("power", 0) * equipment.get("use_factor", 0.7)
|
| 412 |
-
|
| 413 |
schedule = None
|
| 414 |
operating_hours = internal_loads.get("operating_hours", "8:00-18:00")
|
| 415 |
if operating_hours:
|
| 416 |
start_hour = int(operating_hours.split(":")[0])
|
| 417 |
end_hour = int(operating_hours.split("-")[1].split(":")[0])
|
| 418 |
schedule = [1.0 if start_hour <= h % 24 < end_hour else 0.5 for h in range(24)]
|
| 419 |
-
|
| 420 |
loads["internal_gains_offset"] = self.calculate_internal_gains_offset(
|
| 421 |
-
people_load
|
| 422 |
-
|
| 423 |
-
equipment_load=equipment_load,
|
| 424 |
-
usage_factor=internal_loads.get("usage_factor", 0.7),
|
| 425 |
-
hour=12, # Assume peak load at noon
|
| 426 |
-
schedule=schedule
|
| 427 |
-
)
|
| 428 |
-
|
| 429 |
-
# Calculate subtotal
|
| 430 |
-
loads["subtotal"] = (
|
| 431 |
-
loads["walls"] +
|
| 432 |
-
loads["roofs"] +
|
| 433 |
-
loads["floors"] +
|
| 434 |
-
loads["windows"] +
|
| 435 |
-
loads["doors"] +
|
| 436 |
-
loads["infiltration_sensible"] +
|
| 437 |
-
loads["infiltration_latent"] +
|
| 438 |
-
loads["ventilation_sensible"] +
|
| 439 |
-
loads["ventilation_latent"]
|
| 440 |
)
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
loads["total"] = max(0, loads["subtotal"] - loads["internal_gains_offset"])
|
| 444 |
-
|
| 445 |
-
# Apply safety factor
|
| 446 |
-
loads["total"] *= safety_factor
|
| 447 |
-
|
| 448 |
return loads
|
| 449 |
-
|
| 450 |
def calculate_heating_load_summary(self, design_loads: Dict[str, float]) -> Dict[str, float]:
|
| 451 |
-
"""
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
Args:
|
| 455 |
-
design_loads: Dictionary with design heating loads
|
| 456 |
-
|
| 457 |
-
Returns:
|
| 458 |
-
Summary dictionary with key load components
|
| 459 |
-
"""
|
| 460 |
-
summary = {
|
| 461 |
"walls": design_loads.get("walls", 0),
|
| 462 |
"roofs": design_loads.get("roofs", 0),
|
| 463 |
"floors": design_loads.get("floors", 0),
|
|
@@ -468,108 +341,103 @@ class HeatingLoadCalculator:
|
|
| 468 |
"internal_gains_offset": design_loads.get("internal_gains_offset", 0),
|
| 469 |
"subtotal": design_loads.get("subtotal", 0),
|
| 470 |
"safety_factor": design_loads.get("safety_factor", 1.15),
|
| 471 |
-
"total": design_loads.get("total", 0)
|
| 472 |
}
|
| 473 |
-
|
| 474 |
-
|
| 475 |
def calculate_monthly_heating_loads(self, building_components: Dict[str, List[Any]],
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
"""
|
| 480 |
-
Calculate monthly heating loads using monthly average temperatures.
|
| 481 |
-
|
| 482 |
-
Args:
|
| 483 |
-
building_components: Dictionary with lists of building components
|
| 484 |
-
monthly_temps: Dictionary with monthly outdoor temperatures (°C)
|
| 485 |
-
ground_temps: Dictionary with monthly ground temperatures (°C)
|
| 486 |
-
indoor_conditions: Dictionary with indoor conditions
|
| 487 |
-
internal_loads: Dictionary with internal loads
|
| 488 |
-
building_volume: Building volume in m³ (default: 300 m³)
|
| 489 |
-
|
| 490 |
-
Returns:
|
| 491 |
-
Dictionary with monthly heating loads in W
|
| 492 |
-
"""
|
| 493 |
monthly_loads = {}
|
| 494 |
indoor_temp = indoor_conditions.get("temperature", 21.0)
|
| 495 |
indoor_rh = indoor_conditions.get("relative_humidity", 40.0)
|
| 496 |
-
|
| 497 |
for month, outdoor_temp in monthly_temps.items():
|
| 498 |
-
ground_temp = ground_temps.get(month, outdoor_temp)
|
| 499 |
outdoor_conditions = {
|
| 500 |
"design_temperature": outdoor_temp,
|
| 501 |
-
"design_relative_humidity": 80.0,
|
| 502 |
"ground_temperature": ground_temp,
|
| 503 |
"wind_speed": 4.0
|
| 504 |
}
|
| 505 |
-
|
| 506 |
-
# Skip if no heating is needed
|
| 507 |
if outdoor_temp >= indoor_temp:
|
| 508 |
monthly_loads[month] = 0.0
|
| 509 |
continue
|
| 510 |
-
|
| 511 |
loads = self.calculate_design_heating_load(
|
| 512 |
-
building_components
|
| 513 |
-
|
| 514 |
-
indoor_conditions=indoor_conditions,
|
| 515 |
-
internal_loads=internal_loads,
|
| 516 |
-
building_volume=building_volume,
|
| 517 |
-
safety_factor=1.0 # No safety factor for monthly loads
|
| 518 |
)
|
| 519 |
-
monthly_loads[month] =
|
| 520 |
-
|
| 521 |
return monthly_loads
|
| 522 |
-
|
| 523 |
def calculate_heating_degree_days(self, monthly_temps: Dict[str, float], base_temp: float = 18.3) -> float:
|
| 524 |
-
"""
|
| 525 |
-
Calculate annual heating degree days.
|
| 526 |
-
|
| 527 |
-
Args:
|
| 528 |
-
monthly_temps: Dictionary with monthly outdoor temperatures (°C)
|
| 529 |
-
base_temp: Base temperature for degree days (°C, default: 18.3°C)
|
| 530 |
-
|
| 531 |
-
Returns:
|
| 532 |
-
Total heating degree days
|
| 533 |
-
"""
|
| 534 |
-
hdd = 0.0
|
| 535 |
days_per_month = {
|
| 536 |
"Jan": 31, "Feb": 28, "Mar": 31, "Apr": 30, "May": 31, "Jun": 30,
|
| 537 |
"Jul": 31, "Aug": 31, "Sep": 30, "Oct": 31, "Nov": 30, "Dec": 31
|
| 538 |
}
|
| 539 |
-
|
| 540 |
for month, temp in monthly_temps.items():
|
| 541 |
if temp < base_temp:
|
| 542 |
hdd += (base_temp - temp) * days_per_month.get(month, 30)
|
| 543 |
-
|
| 544 |
return hdd
|
| 545 |
-
|
| 546 |
def calculate_annual_heating_energy(self, monthly_loads: Dict[str, float],
|
| 547 |
operating_hours: str = "8:00-18:00") -> float:
|
| 548 |
-
"""
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
Args:
|
| 552 |
-
monthly_loads: Dictionary with monthly heating loads in W
|
| 553 |
-
operating_hours: Operating hours (e.g., "8:00-18:00")
|
| 554 |
-
|
| 555 |
-
Returns:
|
| 556 |
-
Annual heating energy in kWh
|
| 557 |
-
"""
|
| 558 |
-
hours_per_day = 10.0 # Default 10 hours
|
| 559 |
if operating_hours:
|
| 560 |
start_hour = int(operating_hours.split(":")[0])
|
| 561 |
end_hour = int(operating_hours.split("-")[1].split(":")[0])
|
| 562 |
hours_per_day = end_hour - start_hour
|
| 563 |
-
|
| 564 |
days_per_month = {
|
| 565 |
"Jan": 31, "Feb": 28, "Mar": 31, "Apr": 30, "May": 31, "Jun": 30,
|
| 566 |
"Jul": 31, "Aug": 31, "Sep": 30, "Oct": 31, "Nov": 30, "Dec": 31
|
| 567 |
}
|
| 568 |
-
|
| 569 |
total_energy = 0.0
|
| 570 |
for month, load in monthly_loads.items():
|
| 571 |
hours = hours_per_day * days_per_month.get(month, 30)
|
| 572 |
-
energy = load * hours / 1000 #
|
| 573 |
total_energy += energy
|
| 574 |
-
|
| 575 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
Heating load calculation module for HVAC Load Calculator.
|
| 3 |
+
Implements enhanced steady-state methods with thermal mass effects, pressure-driven infiltration, and schedule-based internal gains.
|
|
|
|
| 4 |
Updated 2025-04-28: Added F-factor for floor losses, ground temperature validation, and negative load prevention.
|
| 5 |
+
Updated 2025-04-30: Added dynamic F-factor, climate skip logic, debug prints, and restored original features (summary, annual energy).
|
| 6 |
"""
|
| 7 |
|
| 8 |
+
from typing import Dict, List, Any, Optional
|
| 9 |
import math
|
| 10 |
import numpy as np
|
| 11 |
+
from datetime import datetime, time
|
|
|
|
|
|
|
| 12 |
from enum import Enum
|
| 13 |
|
| 14 |
+
# --- Enums ---
|
| 15 |
+
class Orientation(Enum):
|
| 16 |
+
"""Represents building component orientations."""
|
| 17 |
+
NORTH = "North"
|
| 18 |
+
NORTHEAST = "Northeast"
|
| 19 |
+
EAST = "East"
|
| 20 |
+
SOUTHEAST = "Southeast"
|
| 21 |
+
SOUTH = "South"
|
| 22 |
+
SOUTHWEST = "Southwest"
|
| 23 |
+
WEST = "West"
|
| 24 |
+
NORTHWEST = "Northwest"
|
| 25 |
+
HORIZONTAL = "Horizontal"
|
| 26 |
+
NOT_APPLICABLE = "N/A"
|
| 27 |
|
| 28 |
+
class ComponentType(Enum):
|
| 29 |
+
"""Represents types of building components."""
|
| 30 |
+
WALL = "Wall"
|
| 31 |
+
ROOF = "Roof"
|
| 32 |
+
FLOOR = "Floor"
|
| 33 |
+
WINDOW = "Window"
|
| 34 |
+
DOOR = "Door"
|
| 35 |
|
| 36 |
+
# --- Data Models ---
|
| 37 |
+
@dataclass
|
| 38 |
+
class BuildingComponent:
|
| 39 |
+
"""Base class for building components."""
|
| 40 |
+
id: str
|
| 41 |
+
name: str
|
| 42 |
+
component_type: ComponentType
|
| 43 |
+
u_value: float # W/(m²·K)
|
| 44 |
+
area: float # m²
|
| 45 |
+
orientation: Orientation
|
| 46 |
+
|
| 47 |
+
@dataclass
|
| 48 |
+
class Wall(BuildingComponent):
|
| 49 |
+
"""Wall component with thermal and solar properties."""
|
| 50 |
+
wall_type: str
|
| 51 |
+
wall_group: str
|
| 52 |
+
absorptivity: float
|
| 53 |
+
shading_coefficient: float
|
| 54 |
+
infiltration_rate_cfm: float
|
| 55 |
+
thermal_mass: float = 100000.0 # J/K
|
| 56 |
+
time_constant: float = 2.0 # hours
|
| 57 |
+
|
| 58 |
+
@dataclass
|
| 59 |
+
class Roof(BuildingComponent):
|
| 60 |
+
"""Roof component with ventilation properties."""
|
| 61 |
+
roof_type: str
|
| 62 |
+
roof_group: str
|
| 63 |
+
slope: str
|
| 64 |
+
absorptivity: float
|
| 65 |
+
thermal_mass: float = 200000.0 # J/K
|
| 66 |
+
time_constant: float = 3.0 # hours
|
| 67 |
+
|
| 68 |
+
@dataclass
|
| 69 |
+
class Floor(BuildingComponent):
|
| 70 |
+
"""Floor component with ground contact and insulation properties."""
|
| 71 |
+
floor_type: str
|
| 72 |
+
ground_contact: bool
|
| 73 |
+
ground_temperature_c: float
|
| 74 |
+
perimeter: float
|
| 75 |
+
insulated: bool = False
|
| 76 |
+
thermal_mass: float = 150000.0 # J/K
|
| 77 |
+
time_constant: float = 2.5 # hours
|
| 78 |
+
|
| 79 |
+
@dataclass
|
| 80 |
+
class Window(BuildingComponent):
|
| 81 |
+
"""Window component with solar and frame properties."""
|
| 82 |
+
shgc: float
|
| 83 |
+
shading_device: str
|
| 84 |
+
shading_coefficient: float
|
| 85 |
+
frame_type: str
|
| 86 |
+
frame_percentage: float
|
| 87 |
+
infiltration_rate_cfm: float
|
| 88 |
+
|
| 89 |
+
@dataclass
|
| 90 |
+
class Door(BuildingComponent):
|
| 91 |
+
"""Door component with infiltration properties."""
|
| 92 |
+
door_type: str
|
| 93 |
+
infiltration_rate_cfm: float
|
| 94 |
+
|
| 95 |
+
# --- Constants ---
|
| 96 |
+
AIR_DENSITY = 1.2 # kg/m³
|
| 97 |
+
SPECIFIC_HEAT = 1000 # J/(kg·K)
|
| 98 |
+
LATENT_HEAT_VAPORIZATION = 2260e3 # J/kg
|
| 99 |
+
STANDARD_PRESSURE = 101325 # Pa
|
| 100 |
+
GRAVITY = 9.81 # m/s²
|
| 101 |
+
|
| 102 |
+
# --- Utility Classes (Embedded to Replace External Dependencies) ---
|
| 103 |
+
class Psychrometrics:
|
| 104 |
+
"""Simplified psychrometric calculations."""
|
| 105 |
+
def humidity_ratio(self, temp_c: float, rh: float) -> float:
|
| 106 |
+
"""Calculate humidity ratio (kg/kg dry air)."""
|
| 107 |
+
p_sat = 610.78 * np.exp(17.2694 * temp_c / (temp_c + 237.3))
|
| 108 |
+
p_v = rh / 100 * p_sat
|
| 109 |
+
return 0.62198 * p_v / (STANDARD_PRESSURE - p_v)
|
| 110 |
+
|
| 111 |
+
class HeatTransferCalculations:
|
| 112 |
+
"""Simplified heat transfer calculations."""
|
| 113 |
+
def thermal_lag_factor(self, thermal_mass: float, time_constant: float, time_step: float) -> float:
|
| 114 |
+
"""Calculate thermal lag factor for transient effects."""
|
| 115 |
+
return 1.0 - np.exp(-time_step / time_constant) if time_constant > 0 else 1.0
|
| 116 |
+
|
| 117 |
+
def wind_pressure_difference(self, wind_speed: float, wind_coefficient: float = 0.4) -> float:
|
| 118 |
+
"""Calculate wind-induced pressure difference (Pa)."""
|
| 119 |
+
return 0.5 * AIR_DENSITY * wind_coefficient * wind_speed ** 2
|
| 120 |
+
|
| 121 |
+
def stack_pressure_difference(self, height: float, indoor_temp_k: float, outdoor_temp_k: float) -> float:
|
| 122 |
+
"""Calculate stack effect pressure difference (Pa)."""
|
| 123 |
+
delta_t = abs(indoor_temp_k - outdoor_temp_k)
|
| 124 |
+
return 0.034 * AIR_DENSITY * height * delta_t / min(indoor_temp_k, outdoor_temp_k)
|
| 125 |
+
|
| 126 |
+
def combined_pressure_difference(self, wind_pd: float, stack_pd: float) -> float:
|
| 127 |
+
"""Combine wind and stack pressure differences (Pa)."""
|
| 128 |
+
return np.sqrt(wind_pd ** 2 + stack_pd ** 2)
|
| 129 |
+
|
| 130 |
+
def crack_method_infiltration(self, crack_length: float, coefficient: float, pressure_difference: float) -> float:
|
| 131 |
+
"""Calculate infiltration flow rate (m³/s)."""
|
| 132 |
+
flow_rate = coefficient * crack_length * np.sqrt(pressure_difference)
|
| 133 |
+
print(f"Infiltration: Crack Length: {crack_length} m, Pressure: {pressure_difference:.2f} Pa, Flow Rate: {flow_rate:.6f} m³/s")
|
| 134 |
+
return flow_rate
|
| 135 |
+
|
| 136 |
+
def infiltration_heat_transfer(self, flow_rate: float, delta_t: float) -> float:
|
| 137 |
+
"""Calculate sensible heat transfer due to infiltration (W)."""
|
| 138 |
+
return AIR_DENSITY * SPECIFIC_HEAT * flow_rate * delta_t
|
| 139 |
+
|
| 140 |
+
def infiltration_latent_heat_transfer(self, flow_rate: float, delta_w: float) -> float:
|
| 141 |
+
"""Calculate latent heat transfer due to infiltration (W)."""
|
| 142 |
+
return AIR_DENSITY * LATENT_HEAT_VAPORIZATION * flow_rate * delta_w
|
| 143 |
+
|
| 144 |
+
# --- Heating Load Calculator ---
|
| 145 |
class HeatingLoadCalculator:
|
| 146 |
+
"""Class for calculating heating loads with enhanced steady-state methods."""
|
| 147 |
|
| 148 |
def __init__(self):
|
| 149 |
+
"""Initialize with embedded utilities."""
|
| 150 |
self.heat_transfer = HeatTransferCalculations()
|
| 151 |
self.psychrometrics = Psychrometrics()
|
| 152 |
+
|
| 153 |
def validate_inputs(self, temp: float, rh: float, area: float, u_value: float, ground_temp: Optional[float] = None) -> None:
|
| 154 |
+
"""Validate input parameters."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
if not -50 <= temp <= 60:
|
| 156 |
raise ValueError(f"Temperature {temp}°C is outside valid range (-50 to 60°C)")
|
| 157 |
if not 0 <= rh <= 100:
|
|
|
|
| 162 |
raise ValueError(f"U-value {u_value} W/(m²·K) cannot be negative")
|
| 163 |
if ground_temp is not None and not -10 <= ground_temp <= 40:
|
| 164 |
raise ValueError(f"Ground temperature {ground_temp}°C is outside valid range (-10 to 40°C)")
|
| 165 |
+
|
| 166 |
def calculate_wall_heating_load(self, wall: Wall, outdoor_temp: float, indoor_temp: float) -> float:
|
| 167 |
+
"""Calculate wall heating load with thermal mass effects."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
self.validate_inputs(outdoor_temp, 80.0, wall.area, wall.u_value)
|
|
|
|
|
|
|
|
|
|
| 169 |
delta_t = indoor_temp - outdoor_temp
|
| 170 |
+
lag_factor = self.heat_transfer.thermal_lag_factor(wall.thermal_mass, wall.time_constant, 1.0)
|
| 171 |
+
heating_load = wall.u_value * wall.area * delta_t * lag_factor
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
return max(0, heating_load)
|
| 173 |
+
|
| 174 |
def calculate_roof_heating_load(self, roof: Roof, outdoor_temp: float, indoor_temp: float) -> float:
|
| 175 |
+
"""Calculate roof heating load with thermal mass effects."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
self.validate_inputs(outdoor_temp, 80.0, roof.area, roof.u_value)
|
|
|
|
|
|
|
|
|
|
| 177 |
delta_t = indoor_temp - outdoor_temp
|
| 178 |
+
lag_factor = self.heat_transfer.thermal_lag_factor(roof.thermal_mass, roof.time_constant, 1.0)
|
| 179 |
+
heating_load = roof.u_value * roof.area * delta_t * lag_factor
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
return max(0, heating_load)
|
| 181 |
+
|
| 182 |
def calculate_floor_heating_load(self, floor: Floor, ground_temp: float, indoor_temp: float) -> float:
|
| 183 |
+
"""Calculate floor heating load with perimeter losses and thermal mass."""
|
| 184 |
+
self.validate_inputs(ground_temp, 80.0, floor.area, floor.u_value, ground_temp)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
delta_t = indoor_temp - ground_temp
|
| 186 |
+
lag_factor = self.heat_transfer.thermal_lag_factor(floor.thermal_mass, floor.time_constant, 1.0)
|
| 187 |
+
conduction_load = floor.u_value * floor.area * delta_t * lag_factor
|
| 188 |
+
f_factor = 0.3 if floor.insulated else 0.73 # Dynamic F-factor
|
| 189 |
+
perimeter_load = f_factor * floor.perimeter * delta_t if floor.ground_contact else 0
|
| 190 |
+
heating_load = conduction_load + perimeter_load
|
| 191 |
+
print(f"Floor {floor.name}: Conduction: {conduction_load:.2f} W, Perimeter: {perimeter_load:.2f} W, Total: {heating_load:.2f} W")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
return max(0, heating_load)
|
| 193 |
+
|
| 194 |
def calculate_window_heating_load(self, window: Window, outdoor_temp: float, indoor_temp: float) -> float:
|
| 195 |
+
"""Calculate window heating load."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
self.validate_inputs(outdoor_temp, 80.0, window.area, window.u_value)
|
|
|
|
|
|
|
|
|
|
| 197 |
delta_t = indoor_temp - outdoor_temp
|
| 198 |
+
return max(0, window.u_value * window.area * delta_t)
|
| 199 |
+
|
|
|
|
|
|
|
| 200 |
def calculate_door_heating_load(self, door: Door, outdoor_temp: float, indoor_temp: float) -> float:
|
| 201 |
+
"""Calculate door heating load."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
self.validate_inputs(outdoor_temp, 80.0, door.area, door.u_value)
|
|
|
|
|
|
|
|
|
|
| 203 |
delta_t = indoor_temp - outdoor_temp
|
| 204 |
+
return max(0, door.u_value * door.area * delta_t)
|
| 205 |
+
|
|
|
|
|
|
|
| 206 |
def calculate_infiltration_heating_load(self, building_volume: float, outdoor_temp: float,
|
| 207 |
indoor_temp: float, outdoor_rh: float, indoor_rh: float,
|
| 208 |
wind_speed: float = 4.0, height: float = 3.0,
|
| 209 |
+
crack_length: float = 20.0) -> Dict[str, float]:
|
| 210 |
+
"""Calculate infiltration heating loads."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
self.validate_inputs(outdoor_temp, outdoor_rh, building_volume, 0.0)
|
|
|
|
|
|
|
| 212 |
wind_pd = self.heat_transfer.wind_pressure_difference(wind_speed, wind_coefficient=0.4)
|
| 213 |
+
stack_pd = self.heat_transfer.stack_pressure_difference(height, indoor_temp + 273.15, outdoor_temp + 273.15)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
total_pd = self.heat_transfer.combined_pressure_difference(wind_pd, stack_pd)
|
| 215 |
+
flow_rate = self.heat_transfer.crack_method_infiltration(crack_length, coefficient=0.0002, total_pd) # Adjusted coefficient
|
| 216 |
+
sensible_load = self.heat_transfer.infiltration_heat_transfer(flow_rate, indoor_temp - outdoor_temp)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
w_outdoor = self.psychrometrics.humidity_ratio(outdoor_temp, outdoor_rh)
|
| 218 |
w_indoor = self.psychrometrics.humidity_ratio(indoor_temp, indoor_rh)
|
| 219 |
+
delta_w = max(0, w_indoor - w_outdoor)
|
| 220 |
+
latent_load = self.heat_transfer.infiltration_latent_heat_transfer(flow_rate, delta_w)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
return {
|
| 222 |
"sensible": max(0, sensible_load),
|
| 223 |
"latent": max(0, latent_load),
|
| 224 |
+
"total": max(0, sensible_load + latent_load)
|
| 225 |
}
|
| 226 |
+
|
| 227 |
def calculate_ventilation_heating_load(self, flow_rate: float, outdoor_temp: float, indoor_temp: float,
|
| 228 |
+
outdoor_rh: float, indoor_rh: float) -> Dict[str, float]:
|
| 229 |
+
"""Calculate ventilation heating loads."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
self.validate_inputs(outdoor_temp, outdoor_rh, 0.0, 0.0)
|
| 231 |
+
sensible_load = self.heat_transfer.infiltration_heat_transfer(flow_rate, indoor_temp - outdoor_temp)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
w_outdoor = self.psychrometrics.humidity_ratio(outdoor_temp, outdoor_rh)
|
| 233 |
w_indoor = self.psychrometrics.humidity_ratio(indoor_temp, indoor_rh)
|
| 234 |
+
delta_w = max(0, w_indoor - w_outdoor)
|
| 235 |
+
latent_load = self.heat_transfer.infiltration_latent_heat_transfer(flow_rate, delta_w)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
return {
|
| 237 |
"sensible": max(0, sensible_load),
|
| 238 |
"latent": max(0, latent_load),
|
| 239 |
+
"total": max(0, sensible_load + latent_load)
|
| 240 |
}
|
| 241 |
+
|
| 242 |
def calculate_internal_gains_offset(self, people_load: float, lights_load: float,
|
| 243 |
+
equipment_load: float, usage_factor: float = 0.7,
|
| 244 |
+
hour: int = 12, schedule: Optional[List[float]] = None) -> float:
|
| 245 |
+
"""Calculate internal gains offset with schedule."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
total_gains = people_load + lights_load + equipment_load
|
| 247 |
+
schedule_factor = schedule[hour] if schedule and len(schedule) == 24 else 1.0
|
| 248 |
+
return max(0, total_gains * usage_factor * schedule_factor)
|
| 249 |
+
|
|
|
|
|
|
|
|
|
|
| 250 |
def calculate_design_heating_load(self, building_components: Dict[str, List[Any]],
|
| 251 |
+
outdoor_conditions: Dict[str, Any],
|
| 252 |
+
indoor_conditions: Dict[str, Any],
|
| 253 |
+
internal_loads: Dict[str, Any],
|
| 254 |
+
building_volume: float = 300.0,
|
| 255 |
+
safety_factor: float = 1.15) -> Dict[str, float]:
|
| 256 |
+
"""Calculate design heating load."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
outdoor_temp = outdoor_conditions.get("design_temperature", -10.0)
|
| 258 |
outdoor_rh = outdoor_conditions.get("design_relative_humidity", 80.0)
|
| 259 |
ground_temp = outdoor_conditions.get("ground_temperature", 10.0)
|
| 260 |
wind_speed = outdoor_conditions.get("wind_speed", 4.0)
|
|
|
|
| 261 |
indoor_temp = indoor_conditions.get("temperature", 21.0)
|
| 262 |
indoor_rh = indoor_conditions.get("relative_humidity", 40.0)
|
| 263 |
+
|
| 264 |
+
# Climate skip logic
|
| 265 |
+
if outdoor_temp >= indoor_temp - 1:
|
| 266 |
+
return {
|
| 267 |
+
"walls": 0, "roofs": 0, "floors": 0, "windows": 0, "doors": 0,
|
| 268 |
+
"infiltration_sensible": 0, "infiltration_latent": 0,
|
| 269 |
+
"ventilation_sensible": 0, "ventilation_latent": 0,
|
| 270 |
+
"internal_gains_offset": 0, "subtotal": 0,
|
| 271 |
+
"safety_factor": safety_factor, "total": 0
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
loads = {
|
| 275 |
+
"walls": sum(self.calculate_wall_heating_load(wall, outdoor_temp, indoor_temp) for wall in building_components.get("walls", [])),
|
| 276 |
+
"roofs": sum(self.calculate_roof_heating_load(roof, outdoor_temp, indoor_temp) for roof in building_components.get("roofs", [])),
|
| 277 |
+
"floors": sum(self.calculate_floor_heating_load(floor, ground_temp, indoor_temp) for floor in building_components.get("floors", [])),
|
| 278 |
+
"windows": sum(self.calculate_window_heating_load(window, outdoor_temp, indoor_temp) for window in building_components.get("windows", [])),
|
| 279 |
+
"doors": sum(self.calculate_door_heating_load(door, outdoor_temp, indoor_temp) for door in building_components.get("doors", [])),
|
| 280 |
"infiltration_sensible": 0,
|
| 281 |
"infiltration_latent": 0,
|
| 282 |
"ventilation_sensible": 0,
|
|
|
|
| 286 |
"safety_factor": safety_factor,
|
| 287 |
"total": 0
|
| 288 |
}
|
| 289 |
+
|
| 290 |
+
if internal_loads.get("infiltration"):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
infiltration_loads = self.calculate_infiltration_heating_load(
|
| 292 |
+
building_volume, outdoor_temp, indoor_temp, outdoor_rh, indoor_rh,
|
| 293 |
+
wind_speed, internal_loads["infiltration"].get("height", 3.0),
|
| 294 |
+
internal_loads["infiltration"].get("crack_length", 20.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
)
|
| 296 |
loads["infiltration_sensible"] = infiltration_loads["sensible"]
|
| 297 |
loads["infiltration_latent"] = infiltration_loads["latent"]
|
| 298 |
+
|
| 299 |
+
if internal_loads.get("ventilation"):
|
|
|
|
| 300 |
ventilation_loads = self.calculate_ventilation_heating_load(
|
| 301 |
+
internal_loads["ventilation"].get("flow_rate", 0.1),
|
| 302 |
+
outdoor_temp, indoor_temp, outdoor_rh, indoor_rh
|
|
|
|
|
|
|
|
|
|
| 303 |
)
|
| 304 |
loads["ventilation_sensible"] = ventilation_loads["sensible"]
|
| 305 |
loads["ventilation_latent"] = ventilation_loads["latent"]
|
| 306 |
+
|
| 307 |
+
people = internal_loads.get("people", {})
|
| 308 |
+
lights = internal_loads.get("lights", {})
|
| 309 |
+
equipment = internal_loads.get("equipment", {})
|
| 310 |
people_load = people.get("number", 0) * people.get("sensible_gain", 70.0)
|
| 311 |
lights_load = lights.get("power", 0) * lights.get("use_factor", 0.8)
|
| 312 |
equipment_load = equipment.get("power", 0) * equipment.get("use_factor", 0.7)
|
| 313 |
+
|
| 314 |
schedule = None
|
| 315 |
operating_hours = internal_loads.get("operating_hours", "8:00-18:00")
|
| 316 |
if operating_hours:
|
| 317 |
start_hour = int(operating_hours.split(":")[0])
|
| 318 |
end_hour = int(operating_hours.split("-")[1].split(":")[0])
|
| 319 |
schedule = [1.0 if start_hour <= h % 24 < end_hour else 0.5 for h in range(24)]
|
| 320 |
+
|
| 321 |
loads["internal_gains_offset"] = self.calculate_internal_gains_offset(
|
| 322 |
+
people_load, lights_load, equipment_load,
|
| 323 |
+
internal_loads.get("usage_factor", 0.7), hour=12, schedule=schedule
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
)
|
| 325 |
+
|
| 326 |
+
loads["subtotal"] = sum(v for k, v in loads.items() if k not in ["internal_gains_offset", "subtotal", "safety_factor", "total"])
|
| 327 |
+
loads["total"] = max(0, loads["subtotal"] - loads["internal_gains_offset"]) * safety_factor / 1000 # kW
|
| 328 |
+
|
|
|
|
|
|
|
|
|
|
| 329 |
return loads
|
| 330 |
+
|
| 331 |
def calculate_heating_load_summary(self, design_loads: Dict[str, float]) -> Dict[str, float]:
|
| 332 |
+
"""Summarize heating loads for reporting."""
|
| 333 |
+
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
"walls": design_loads.get("walls", 0),
|
| 335 |
"roofs": design_loads.get("roofs", 0),
|
| 336 |
"floors": design_loads.get("floors", 0),
|
|
|
|
| 341 |
"internal_gains_offset": design_loads.get("internal_gains_offset", 0),
|
| 342 |
"subtotal": design_loads.get("subtotal", 0),
|
| 343 |
"safety_factor": design_loads.get("safety_factor", 1.15),
|
| 344 |
+
"total": design_loads.get("total", 0) * 1000 # W
|
| 345 |
}
|
| 346 |
+
|
|
|
|
| 347 |
def calculate_monthly_heating_loads(self, building_components: Dict[str, List[Any]],
|
| 348 |
+
monthly_temps: Dict[str, float], ground_temps: Dict[str, float],
|
| 349 |
+
indoor_conditions: Dict[str, Any], internal_loads: Dict[str, Any],
|
| 350 |
+
building_volume: float = 300.0) -> Dict[str, float]:
|
| 351 |
+
"""Calculate monthly heating loads."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
monthly_loads = {}
|
| 353 |
indoor_temp = indoor_conditions.get("temperature", 21.0)
|
| 354 |
indoor_rh = indoor_conditions.get("relative_humidity", 40.0)
|
| 355 |
+
|
| 356 |
for month, outdoor_temp in monthly_temps.items():
|
| 357 |
+
ground_temp = ground_temps.get(month, outdoor_temp)
|
| 358 |
outdoor_conditions = {
|
| 359 |
"design_temperature": outdoor_temp,
|
| 360 |
+
"design_relative_humidity": 80.0,
|
| 361 |
"ground_temperature": ground_temp,
|
| 362 |
"wind_speed": 4.0
|
| 363 |
}
|
|
|
|
|
|
|
| 364 |
if outdoor_temp >= indoor_temp:
|
| 365 |
monthly_loads[month] = 0.0
|
| 366 |
continue
|
|
|
|
| 367 |
loads = self.calculate_design_heating_load(
|
| 368 |
+
building_components, outdoor_conditions, indoor_conditions,
|
| 369 |
+
internal_loads, building_volume, safety_factor=1.0
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
)
|
| 371 |
+
monthly_loads[month] = loads["total"] * 1000 # W
|
|
|
|
| 372 |
return monthly_loads
|
| 373 |
+
|
| 374 |
def calculate_heating_degree_days(self, monthly_temps: Dict[str, float], base_temp: float = 18.3) -> float:
|
| 375 |
+
"""Calculate annual heating degree days."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
days_per_month = {
|
| 377 |
"Jan": 31, "Feb": 28, "Mar": 31, "Apr": 30, "May": 31, "Jun": 30,
|
| 378 |
"Jul": 31, "Aug": 31, "Sep": 30, "Oct": 31, "Nov": 30, "Dec": 31
|
| 379 |
}
|
| 380 |
+
hdd = 0.0
|
| 381 |
for month, temp in monthly_temps.items():
|
| 382 |
if temp < base_temp:
|
| 383 |
hdd += (base_temp - temp) * days_per_month.get(month, 30)
|
|
|
|
| 384 |
return hdd
|
| 385 |
+
|
| 386 |
def calculate_annual_heating_energy(self, monthly_loads: Dict[str, float],
|
| 387 |
operating_hours: str = "8:00-18:00") -> float:
|
| 388 |
+
"""Calculate annual heating energy in kWh."""
|
| 389 |
+
hours_per_day = 10.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
if operating_hours:
|
| 391 |
start_hour = int(operating_hours.split(":")[0])
|
| 392 |
end_hour = int(operating_hours.split("-")[1].split(":")[0])
|
| 393 |
hours_per_day = end_hour - start_hour
|
|
|
|
| 394 |
days_per_month = {
|
| 395 |
"Jan": 31, "Feb": 28, "Mar": 31, "Apr": 30, "May": 31, "Jun": 30,
|
| 396 |
"Jul": 31, "Aug": 31, "Sep": 30, "Oct": 31, "Nov": 30, "Dec": 31
|
| 397 |
}
|
|
|
|
| 398 |
total_energy = 0.0
|
| 399 |
for month, load in monthly_loads.items():
|
| 400 |
hours = hours_per_day * days_per_month.get(month, 30)
|
| 401 |
+
energy = load * hours / 1000 / 1000 # W to kWh
|
| 402 |
total_energy += energy
|
| 403 |
+
return total_energy
|
| 404 |
+
|
| 405 |
+
# --- Example Execution ---
|
| 406 |
+
if __name__ == "__main__":
|
| 407 |
+
"""Example with Geelong debug inputs."""
|
| 408 |
+
calculator = HeatingLoadCalculator()
|
| 409 |
+
components = {
|
| 410 |
+
"walls": [Wall(id="w1", name="Wall 1", component_type=ComponentType.WALL, u_value=0.5, area=120, orientation=Orientation.NOT_APPLICABLE, wall_type="Brick", wall_group="A", absorptivity=0.6, shading_coefficient=1.0, infiltration_rate_cfm=0)],
|
| 411 |
+
"roofs": [Roof(id="r1", name="Roof 1", component_type=ComponentType.ROOF, u_value=0.3, area=100, orientation=Orientation.HORIZONTAL, roof_type="Concrete", roof_group="A", slope="Flat", absorptivity=0.6)],
|
| 412 |
+
"floors": [Floor(id="f1", name="Main Floor", component_type=ComponentType.FLOOR, u_value=0.4, area=100, orientation=Orientation.NOT_APPLICABLE, floor_type="Concrete", ground_contact=True, ground_temperature_c=16.0, perimeter=40.0, insulated=True)],
|
| 413 |
+
"windows": [Window(id="win1", name="Window 1", component_type=ComponentType.WINDOW, u_value=2.0, area=1, orientation=Orientation.NOT_APPLICABLE, shgc=0.7, shading_device="None", shading_coefficient=1.0, frame_type="Aluminum", frame_percentage=20, infiltration_rate_cfm=0)],
|
| 414 |
+
"doors": [Door(id="d1", name="Door 1", component_type=ComponentType.DOOR, u_value=2.0, area=1, orientation=Orientation.NOT_APPLICABLE, door_type="Solid Wood", infiltration_rate_cfm=0)]
|
| 415 |
+
}
|
| 416 |
+
outdoor_conditions = {
|
| 417 |
+
"design_temperature": 17.5,
|
| 418 |
+
"design_relative_humidity": 81.3,
|
| 419 |
+
"ground_temperature": 16.0,
|
| 420 |
+
"wind_speed": 4.0
|
| 421 |
+
}
|
| 422 |
+
indoor_conditions = {
|
| 423 |
+
"temperature": 21.0,
|
| 424 |
+
"relative_humidity": 40.0
|
| 425 |
+
}
|
| 426 |
+
internal_loads = {
|
| 427 |
+
"people": {"number": 1, "sensible_gain": 70.0},
|
| 428 |
+
"lights": {"power": 200.0, "use_factor": 0.8},
|
| 429 |
+
"equipment": {"power": 100.0, "use_factor": 0.7},
|
| 430 |
+
"infiltration": {"height": 3.0, "crack_length": 20.0},
|
| 431 |
+
"ventilation": {"flow_rate": 0.115},
|
| 432 |
+
"operating_hours": "8:00-18:00",
|
| 433 |
+
"usage_factor": 0.7
|
| 434 |
+
}
|
| 435 |
+
result = calculator.calculate_design_heating_load(components, outdoor_conditions, indoor_conditions, internal_loads, building_volume=300.0)
|
| 436 |
+
print(f"Total Heating Load: {result['total']:.2f} kW")
|
| 437 |
+
print(f"Summary: {calculator.calculate_heating_load_summary(result)}")
|
| 438 |
+
monthly_temps = {"Jul": 17.5}
|
| 439 |
+
ground_temps = {"Jul": 16.0}
|
| 440 |
+
monthly_result = calculator.calculate_monthly_heating_loads(components, monthly_temps, ground_temps, indoor_conditions, internal_loads)
|
| 441 |
+
print(f"Monthly Loads: {monthly_result}")
|
| 442 |
+
annual_energy = calculator.calculate_annual_heating_energy(monthly_result)
|
| 443 |
+
print(f"Annual Energy: {annual_energy:.2f} kWh")
|