Spaces:
Sleeping
Sleeping
File size: 39,537 Bytes
39bd750 f2f7fec 46d1490 39bd750 e48516b 46d1490 39bd750 46d1490 39bd750 f2f7fec 39bd750 f2f7fec 39bd750 46d1490 39bd750 f2f7fec 39bd750 46d1490 957a902 46d1490 957a902 46d1490 957a902 46d1490 957a902 46d1490 281f3f7 46d1490 957a902 46d1490 281f3f7 46d1490 957a902 46d1490 957a902 46d1490 957a902 46d1490 957a902 46d1490 957a902 46d1490 281f3f7 46d1490 281f3f7 46d1490 281f3f7 46d1490 281f3f7 46d1490 281f3f7 46d1490 281f3f7 46d1490 281f3f7 46d1490 281f3f7 46d1490 281f3f7 46d1490 281f3f7 46d1490 281f3f7 46d1490 39bd750 281f3f7 e48516b 957a902 e48516b 281f3f7 e48516b 39bd750 281f3f7 e48516b 281f3f7 e48516b 281f3f7 e48516b 46d1490 e48516b 281f3f7 e48516b 281f3f7 e48516b 281f3f7 46d1490 281f3f7 46d1490 281f3f7 46d1490 281f3f7 46d1490 281f3f7 46d1490 e48516b 281f3f7 e48516b 281f3f7 e48516b 281f3f7 46d1490 281f3f7 46d1490 e48516b 03fb307 281f3f7 03fb307 46d1490 281f3f7 46d1490 281f3f7 46d1490 281f3f7 46d1490 281f3f7 46d1490 281f3f7 46d1490 281f3f7 46d1490 281f3f7 46d1490 281f3f7 46d1490 281f3f7 46d1490 281f3f7 46d1490 281f3f7 46d1490 281f3f7 46d1490 281f3f7 46d1490 281f3f7 46d1490 281f3f7 273585e 281f3f7 46d1490 281f3f7 46d1490 281f3f7 46d1490 281f3f7 46d1490 281f3f7 46d1490 281f3f7 46d1490 281f3f7 e48516b 281f3f7 39bd750 cd7751f 39bd750 cd7751f 39bd750 f2f7fec cd7751f 39bd750 cd7751f 39bd750 cd7751f 39bd750 cd7751f 39bd750 cd7751f 39bd750 cd7751f 39bd750 cd7751f 39bd750 cd7751f 39bd750 cd7751f 39bd750 cd7751f 39bd750 cd7751f 39bd750 cd7751f 39bd750 cd7751f 39bd750 cd7751f |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 |
"""
HVAC Calculator Code Documentation
Developed by: Dr Majed Abuseif, Deakin University
© 2025
"""
import numpy as np
import pandas as pd
from typing import Dict, List, Optional, NamedTuple, Any, Tuple
from enum import Enum
import streamlit as st
from data.material_library import Construction, GlazingMaterial, DoorMaterial, Material, MaterialLibrary
from data.internal_loads import PEOPLE_ACTIVITY_LEVELS, DIVERSITY_FACTORS, LIGHTING_FIXTURE_TYPES, EQUIPMENT_HEAT_GAINS, VENTILATION_RATES, INFILTRATION_SETTINGS
from datetime import datetime
from collections import defaultdict
import logging
import math
from utils.ctf_calculations import CTFCalculator, ComponentType, CTFCoefficients
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
class TFMCalculations:
# Solar calculation constants (from solar.py)
SHGC_COEFFICIENTS = {
"Single Clear": [0.1, -0.0, 0.0, -0.0, 0.0, 0.87],
"Single Tinted": [0.12, -0.0, 0.0, -0.0, 0.8, -0.0],
"Double Clear": [0.14, -0.0, 0.0, -0.0, 0.78, -0.0],
"Double Low-E": [0.2, -0.0, 0.0, 0.7, 0.0, -0.0],
"Double Tinted": [0.15, -0.0, 0.0, -0.0, 0.65, -0.0],
"Double Low-E with Argon": [0.18, -0.0, 0.0, 0.68, 0.0, -0.0],
"Single Low-E Reflective": [0.22, -0.0, 0.0, 0.6, 0.0, -0.0],
"Double Reflective": [0.24, -0.0, 0.0, 0.58, 0.0, -0.0],
"Electrochromic": [0.25, -0.0, 0.5, -0.0, 0.0, -0.0]
}
GLAZING_TYPE_MAPPING = {
"Single Clear 3mm": "Single Clear",
"Single Clear 6mm": "Single Clear",
"Single Tinted 6mm": "Single Tinted",
"Double Clear 6mm/13mm Air": "Double Clear",
"Double Low-E 6mm/13mm Air": "Double Low-E",
"Double Tinted 6mm/13mm Air": "Double Tinted",
"Double Low-E 6mm/13mm Argon": "Double Low-E with Argon",
"Single Low-E Reflective 6mm": "Single Low-E Reflective",
"Double Reflective 6mm/13mm Air": "Double Reflective",
"Electrochromic 6mm/13mm Air": "Electrochromic"
}
@staticmethod
def calculate_conduction_load(component, outdoor_temp: float, indoor_temp: float, hour: int, mode: str = "none") -> tuple[float, float]:
"""Calculate conduction load for heating and cooling in kW based on mode."""
if mode == "none":
return 0, 0
delta_t = outdoor_temp - indoor_temp
if mode == "cooling" and delta_t <= 0:
return 0, 0
if mode == "heating" and delta_t >= 0:
return 0, 0
# Get CTF coefficients using CTFCalculator
ctf = CTFCalculator.calculate_ctf_coefficients(component)
# Initialize history terms (simplified: assume steady-state history for demonstration)
# In practice, maintain temperature and flux histories
load = component.u_value * component.area * delta_t
for i in range(len(ctf.Y)):
load += component.area * ctf.Y[i] * (outdoor_temp - indoor_temp) * np.exp(-i * 3600 / 3600)
load -= component.area * ctf.Z[i] * (outdoor_temp - indoor_temp) * np.exp(-i * 3600 / 3600)
# Note: F terms require flux history, omitted here for simplicity
cooling_load = load / 1000 if mode == "cooling" else 0
heating_load = -load / 1000 if mode == "heating" else 0
return cooling_load, heating_load
@staticmethod
def day_of_year(month: int, day: int, year: int) -> int:
"""Calculate day of the year (n) from month, day, and year, accounting for leap years.
Args:
month (int): Month of the year (1-12).
day (int): Day of the month (1-31).
year (int): Year.
Returns:
int: Day of the year (1-365 or 366 for leap years).
References:
ASHRAE Handbook—Fundamentals, Chapter 18.
"""
days_in_month = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0):
days_in_month[1] = 29
return sum(days_in_month[:month-1]) + day
@staticmethod
def equation_of_time(n: int) -> float:
"""Calculate Equation of Time (EOT) in minutes using Spencer's formula.
Args:
n (int): Day of the year (1-365 or 366).
Returns:
float: Equation of Time in minutes.
References:
ASHRAE Handbook—Fundamentals, Chapter 18.
"""
B = (n - 1) * 360 / 365
B_rad = math.radians(B)
EOT = 229.2 * (0.000075 + 0.001868 * math.cos(B_rad) - 0.032077 * math.sin(B_rad) -
0.014615 * math.cos(2 * B_rad) - 0.04089 * math.sin(2 * B_rad))
return EOT
@staticmethod
def calculate_dynamic_shgc(glazing_type: str, cos_theta: float) -> float:
"""Calculate dynamic SHGC based on incidence angle.
Args:
glazing_type (str): Type of glazing (e.g., 'Single Clear').
cos_theta (float): Cosine of the angle of incidence.
Returns:
float: Dynamic SHGC value.
References:
ASHRAE Handbook—Fundamentals, Chapter 15, Table 13.
"""
if glazing_type not in TFMCalculations.SHGC_COEFFICIENTS:
logger.warning(f"Unknown glazing type '{glazing_type}'. Using default SHGC coefficients for Single Clear.")
glazing_type = "Single Clear"
c = TFMCalculations.SHGC_COEFFICIENTS[glazing_type]
# Incidence angle modifier: f(cos(θ)) = c_0 + c_1·cos(θ) + c_2·cos²(θ) + c_3·cos³(θ) + c_4·cos⁴(θ) + c_5·cos⁵(θ)
f_cos_theta = (c[0] + c[1] * cos_theta + c[2] * cos_theta**2 +
c[3] * cos_theta**3 + c[4] * cos_theta**4 + c[5] * cos_theta**5)
return f_cos_theta
@staticmethod
def get_surface_parameters(component: Any, building_info: Dict, material_library: MaterialLibrary,
project_materials: Dict, project_constructions: Dict,
project_glazing_materials: Dict, project_door_materials: Dict) -> Tuple[float, float, float, Optional[float], float]:
"""
Determine surface parameters (tilt, azimuth, h_o, emissivity, solar_absorption) for a component.
Uses MaterialLibrary to fetch properties from first layer for walls/roofs, DoorMaterial for doors,
and GlazingMaterial for windows/skylights. Handles orientation and tilt based on component type:
- Walls, Doors, Windows: Azimuth = elevation base azimuth + component.rotation; Tilt = 90°.
- Roofs, Skylights: Azimuth = component.orientation; Tilt = component.tilt (default 180°).
Args:
component: Component object with component_type, elevation, rotation, orientation, tilt,
construction, glazing_material, or door_material.
building_info (Dict): Building information containing orientation_angle for elevation mapping.
material_library: MaterialLibrary instance for accessing library materials/constructions.
project_materials: Dict of project-specific Material objects.
project_constructions: Dict of project-specific Construction objects.
project_glazing_materials: Dict of project-specific GlazingMaterial objects.
project_door_materials: Dict of project-specific DoorMaterial objects.
Returns:
Tuple[float, float, float, Optional[float], float]: Surface tilt (°), surface azimuth (°),
h_o (W/m²·K), emissivity, solar_absorption.
Raises:
ValueError: If elevation is missing or invalid for walls, doors, or windows.
"""
# Default parameters
if component.component_type == ComponentType.ROOF:
surface_tilt = getattr(component, 'tilt', 180.0) # Horizontal, downward if tilt absent
h_o = 23.0 # W/m²·K for roofs
elif component.component_type == ComponentType.SKYLIGHT:
surface_tilt = getattr(component, 'tilt', 180.0) # Horizontal, downward if tilt absent
h_o = 23.0 # W/m²·K for skylights
elif component.component_type == ComponentType.FLOOR:
surface_tilt = 0.0 # Horizontal, upward
h_o = 17.0 # W/m²·K
else: # WALL, DOOR, WINDOW
surface_tilt = 90.0 # Vertical
h_o = 17.0 # W/m²·K
emissivity = 0.9 # Default for opaque components
solar_absorption = 0.6 # Default
shgc = None # Only for windows/skylights
component_name = getattr(component, 'name', 'unnamed_component')
try:
# Determine surface azimuth
if component.component_type in [ComponentType.ROOF, ComponentType.SKYLIGHT]:
# Use component's orientation attribute directly, ignoring elevation
surface_azimuth = getattr(component, 'orientation', 0.0)
logger.debug(f"Using component orientation for {component_name} ({component.component_type.value}): "
f"azimuth={surface_azimuth}, tilt={surface_tilt}")
else: # WALL, DOOR, WINDOW
# Check for elevation attribute
elevation = getattr(component, 'elevation', None)
if not elevation:
raise ValueError(f"Component {component_name} ({component.component_type.value}) is missing 'elevation' field")
# Define elevation azimuths based on building orientation_angle
base_azimuth = building_info.get("orientation_angle", 0.0)
elevation_angles = {
"A": base_azimuth,
"B": (base_azimuth + 90.0) % 360,
"C": (base_azimuth + 180.0) % 360,
"D": (base_azimuth + 270.0) % 360
}
if elevation not in elevation_angles:
raise ValueError(f"Invalid elevation '{elevation}' for component {component_name} ({component.component_type.value}). "
f"Expected one of {list(elevation_angles.keys())}")
# Add component rotation to elevation azimuth
surface_azimuth = (elevation_angles[elevation] + getattr(component, 'rotation', 0.0)) % 360
logger.debug(f"Component {component_name} ({component.component_type.value}): elevation={elevation}, "
f"base_azimuth={elevation_angles[elevation]}, rotation={getattr(component, 'rotation', 0.0)}, "
f"total_azimuth={surface_azimuth}, tilt={surface_tilt}")
# Fetch material properties
if component.component_type in [ComponentType.WALL, ComponentType.ROOF]:
construction = getattr(component, 'construction', None)
if not construction:
logger.warning(f"No construction defined for {component_name} ({component.component_type.value}). "
f"Using defaults: solar_absorption=0.6, emissivity=0.9.")
else:
# Get construction from library or project
construction_obj = (project_constructions.get(construction.name) or
material_library.library_constructions.get(construction.name))
if not construction_obj:
logger.error(f"Construction '{construction.name}' not found for {component_name} ({component.component_type.value}).")
elif not construction_obj.layers:
logger.warning(f"No layers in construction '{construction.name}' for {component_name} ({component.component_type.value}).")
else:
# Use first (outermost) layer's properties
first_layer = construction_obj.layers[0]
material = first_layer["material"]
solar_absorption = material.solar_absorption
emissivity = material.emissivity
logger.debug(f"Using first layer material '{material.name}' for {component_name} ({component.component_type.value}): "
f"solar_absorption={solar_absorption}, emissivity={emissivity}")
elif component.component_type == ComponentType.DOOR:
door_material = getattr(component, 'door_material', None)
if not door_material:
logger.warning(f"No door material defined for {component_name} ({component.component_type.value}). "
f"Using defaults: solar_absorption=0.6, emissivity=0.9.")
else:
# Get door material from library or project
door_material_obj = (project_door_materials.get(door_material.name) or
material_library.library_door_materials.get(door_material.name))
if not door_material_obj:
logger.error(f"Door material '{door_material.name}' not found for {component_name} ({component.component_type.value}).")
else:
solar_absorption = door_material_obj.solar_absorption
emissivity = door_material_obj.emissivity
logger.debug(f"Using door material '{door_material_obj.name}' for {component_name} ({component.component_type.value}): "
f"solar_absorption={solar_absorption}, emissivity={emissivity}")
elif component.component_type in [ComponentType.WINDOW, ComponentType.SKYLIGHT]:
glazing_material = getattr(component, 'glazing_material', None)
if not glazing_material:
logger.warning(f"No glazing material defined for {component_name} ({component.component_type.value}). "
f"Using default SHGC=0.7, h_o={h_o}.")
shgc = 0.7
else:
# Get glazing material from library or project
glazing_material_obj = (project_glazing_materials.get(glazing_material.name) or
material_library.library_glazing_materials.get(glazing_material.name))
if not glazing_material_obj:
logger.error(f"Glazing material '{glazing_material.name}' not found for {component_name} ({component.component_type.value}).")
shgc = 0.7
else:
shgc = glazing_material_obj.shgc
h_o = glazing_material_obj.h_o
logger.debug(f"Using glazing material '{glazing_material_obj.name}' for {component_name} ({component.component_type.value}): "
f"shgc={shgc}, h_o={h_o}")
emissivity = None # Not used for glazing
except Exception as e:
logger.error(f"Error retrieving surface parameters for {component_name} ({component.component_type.value}): {str(e)}")
# Apply defaults
if component.component_type in [ComponentType.WALL, ComponentType.ROOF, ComponentType.DOOR]:
solar_absorption = 0.6
emissivity = 0.9
else: # WINDOW, SKYLIGHT
shgc = 0.7
# h_o retains default from component type
return surface_tilt, surface_azimuth, h_o, emissivity, solar_absorption
@staticmethod
def calculate_solar_load(component, hourly_data: Dict, hour: int, building_orientation: float, mode: str = "none") -> float:
"""Calculate solar load in kW (cooling only) using ASHRAE-compliant solar calculations.
Args:
component: Component object with area, component_type, elevation, glazing_material, shgc, iac.
hourly_data (Dict): Single hour's weather data with GHI, DNI, DHI, dry_bulb, month, day, hour.
hour (int): Hour of the day (1-24).
building_orientation (float): Building orientation angle in degrees.
mode (str): Operating mode ('cooling', 'heating', 'none').
Returns:
float: Solar cooling load in kW. Returns 0 for non-cooling modes or non-fenestration components.
References:
ASHRAE Handbook—Fundamentals, Chapters 15 and 18.
"""
if mode != "cooling" or component.component_type not in [ComponentType.WINDOW, ComponentType.SKYLIGHT]:
return 0
component_name = getattr(component, 'name', 'unnamed_component')
try:
# Get MaterialLibrary and project-specific data from session state
material_library = st.session_state.get("material_library")
if not material_library:
logger.error(f"MaterialLibrary not found in session_state for {component_name} ({component.component_type.value})")
raise ValueError("MaterialLibrary not found in session_state")
project_materials = st.session_state.get("project_materials", {})
project_constructions = st.session_state.get("project_constructions", {})
project_glazing_materials = st.session_state.get("project_glazing_materials", {})
project_door_materials = st.session_state.get("project_door_materials", {})
# Get location parameters from climate_data
climate_data = st.session_state.get("climate_data", {})
latitude = climate_data.get("latitude", 0.0)
longitude = climate_data.get("longitude", 0.0)
timezone = climate_data.get("time_zone", 0.0)
# Get ground reflectivity (default 0.2)
ground_reflectivity = st.session_state.get("ground_reflectivity", 0.2)
# Validate input parameters
if not -90 <= latitude <= 90:
logger.warning(f"Invalid latitude {latitude} for {component_name} ({component.component_type.value}). Using default 0.0.")
latitude = 0.0
if not -180 <= longitude <= 180:
logger.warning(f"Invalid longitude {longitude} for {component_name} ({component.component_type.value}). Using default 0.0.")
longitude = 0.0
if not -12 <= timezone <= 14:
logger.warning(f"Invalid timezone {timezone} for {component_name} ({component.component_type.value}). Using default 0.0.")
timezone = 0.0
if not 0 <= ground_reflectivity <= 1:
logger.warning(f"Invalid ground_reflectivity {ground_reflectivity} for {component_name} ({component.component_type.value}). Using default 0.2.")
ground_reflectivity = 0.2
# Ensure hourly_data has required fields
required_fields = ["month", "day", "hour", "global_horizontal_radiation", "direct_normal_radiation",
"diffuse_horizontal_radiation", "dry_bulb"]
if not all(field in hourly_data for field in required_fields):
logger.warning(f"Missing required fields in hourly_data for hour {hour} for {component_name} ({component.component_type.value}): {hourly_data}")
return 0
# Skip if GHI <= 0
if hourly_data["global_horizontal_radiation"] <= 0:
logger.info(f"No solar load for hour {hour} due to GHI={hourly_data['global_horizontal_radiation']} for {component_name} ({component.component_type.value})")
return 0
# Extract weather data
month = hourly_data["month"]
day = hourly_data["day"]
hour = hourly_data["hour"]
ghi = hourly_data["global_horizontal_radiation"]
dni = hourly_data.get("direct_normal_radiation", ghi * 0.7) # Fallback: estimate DNI
dhi = hourly_data.get("diffuse_horizontal_radiation", ghi * 0.3) # Fallback: estimate DHI
outdoor_temp = hourly_data["dry_bulb"]
if ghi < 0 or dni < 0 or dhi < 0:
logger.error(f"Negative radiation values for {month}/{day}/{hour} for {component_name} ({component.component_type.value})")
raise ValueError(f"Negative radiation values for {month}/{day}/{hour}")
logger.info(f"Processing solar for {month}/{day}/{hour} with GHI={ghi}, DNI={dni}, DHI={dhi}, "
f"dry_bulb={outdoor_temp} for {component_name} ({component.component_type.value})")
# Step 1: Local Solar Time (LST) with Equation of Time
year = 2025 # Fixed year since not provided
n = TFMCalculations.day_of_year(month, day, year)
EOT = TFMCalculations.equation_of_time(n)
lambda_std = 15 * timezone # Standard meridian longitude (°)
standard_time = hour - 1 + 0.5 # Convert to decimal, assume mid-hour
LST = standard_time + (4 * (lambda_std - longitude) + EOT) / 60
# Step 2: Solar Declination (δ)
delta = 23.45 * math.sin(math.radians(360 / 365 * (284 + n)))
# Step 3: Hour Angle (HRA)
hra = 15 * (LST - 12)
# Step 4: Solar Altitude (α) and Azimuth (ψ)
phi = math.radians(latitude)
delta_rad = math.radians(delta)
hra_rad = math.radians(hra)
sin_alpha = math.sin(phi) * math.sin(delta_rad) + math.cos(phi) * math.cos(delta_rad) * math.cos(hra_rad)
alpha = math.degrees(math.asin(sin_alpha))
if abs(math.cos(math.radians(alpha))) < 0.01:
azimuth = 0 # North at sunrise/sunset
else:
sin_az = math.cos(delta_rad) * math.sin(hra_rad) / math.cos(math.radians(alpha))
cos_az = (sin_alpha * math.sin(phi) - math.sin(delta_rad)) / (math.cos(math.radians(alpha)) * math.cos(phi))
azimuth = math.degrees(math.atan2(sin_az, cos_az))
if hra > 0: # Afternoon
azimuth = 360 - azimuth if azimuth > 0 else -azimuth
logger.info(f"Solar angles for {month}/{day}/{hour}: declination={delta:.2f}, LST={LST:.2f}, "
f"HRA={hra:.2f}, altitude={alpha:.2f}, azimuth={azimuth:.2f} for {component_name} ({component.component_type.value})")
# Step 5: Get surface parameters
building_info = {"orientation_angle": building_orientation}
surface_tilt, surface_azimuth, h_o, emissivity, solar_absorption = \
TFMCalculations.get_surface_parameters(
component, building_info, material_library, project_materials,
project_constructions, project_glazing_materials, project_door_materials
)
# For windows/skylights, get SHGC from material
shgc = 0.7 # Default
if component.component_type in [ComponentType.WINDOW, ComponentType.SKYLIGHT]:
glazing_material = getattr(component, 'glazing_material', None)
if glazing_material:
glazing_material_obj = (project_glazing_materials.get(glazing_material.name) or
material_library.library_glazing_materials.get(glazing_material.name))
if glazing_material_obj:
shgc = glazing_material_obj.shgc
h_o = glazing_material_obj.h_o
else:
logger.warning(f"Glazing material '{glazing_material.name}' not found for {component_name} ({component.component_type.value}). Using default SHGC=0.7.")
else:
logger.warning(f"No glazing material defined for {component_name} ({component.component_type.value}). Using default SHGC=0.7.")
# Step 6: Calculate angle of incidence (θ)
cos_theta = (math.sin(math.radians(alpha)) * math.cos(math.radians(surface_tilt)) +
math.cos(math.radians(alpha)) * math.sin(math.radians(surface_tilt)) *
math.cos(math.radians(azimuth - surface_azimuth)))
cos_theta = max(min(cos_theta, 1.0), 0.0) # Clamp to [0, 1]
logger.info(f" Component {component_name} ({component.component_type.value}) at {month}/{day}/{hour}: "
f"surface_tilt={surface_tilt:.2f}, surface_azimuth={surface_azimuth:.2f}, "
f"cos_theta={cos_theta:.2f}")
# Step 7: Calculate total incident radiation (I_t)
view_factor = (1 - math.cos(math.radians(surface_tilt))) / 2
ground_reflected = ground_reflectivity * ghi * view_factor
I_t = dni * cos_theta + dhi + ground_reflected
# Step 8: Calculate solar heat gain for fenestration
glazing_type = TFMCalculations.GLAZING_TYPE_MAPPING.get(component.name, 'Single Clear')
iac = getattr(component, 'iac', 1.0) # Default internal shading
shgc_dynamic = shgc * TFMCalculations.calculate_dynamic_shgc(glazing_type, cos_theta)
solar_heat_gain = component.area * shgc_dynamic * I_t * iac / 1000 # kW
logger.info(f"Solar heat gain for {component_name} ({component.component_type.value}) at {month}/{day}/{hour}: "
f"{solar_heat_gain:.2f} kW (area={component.area}, shgc_dynamic={shgc_dynamic:.2f}, "
f"I_t={I_t:.2f}, iac={iac})")
return solar_heat_gain
except Exception as e:
logger.error(f"Error calculating solar load for {component_name} ({component.component_type.value}) at hour {hour}: {str(e)}")
return 0
@staticmethod
def calculate_internal_load(internal_loads: Dict, hour: int, operation_hours: int, area: float) -> float:
"""Calculate total internal load in kW."""
total_load = 0
for group in internal_loads.get("people", []):
activity_data = group["activity_data"]
sensible = (activity_data["sensible_min_w"] + activity_data["sensible_max_w"]) / 2
latent = (activity_data["latent_min_w"] + activity_data["latent_max_w"]) / 2
load_per_person = sensible + latent
total_load += group["num_people"] * load_per_person * group["diversity_factor"]
for light in internal_loads.get("lighting", []):
lpd = light["lpd"]
lighting_operating_hours = light["operating_hours"]
fraction = min(lighting_operating_hours, operation_hours) / operation_hours if operation_hours > 0 else 0
lighting_load = lpd * area * fraction
total_load += lighting_load
equipment = internal_loads.get("equipment")
if equipment:
total_power_density = equipment.get("total_power_density", 0)
equipment_load = total_power_density * area
total_load += equipment_load
return total_load / 1000
@staticmethod
def calculate_ventilation_load(internal_loads: Dict, outdoor_temp: float, indoor_temp: float, area: float, building_info: Dict, mode: str = "none") -> tuple[float, float]:
"""Calculate ventilation load for heating and cooling in kW based on mode."""
if mode == "none":
return 0, 0
ventilation = internal_loads.get("ventilation")
if not ventilation:
return 0, 0
space_rate = ventilation.get("space_rate", 0.3) # L/s/m²
people_rate = ventilation.get("people_rate", 2.5) # L/s/person
num_people = sum(group["num_people"] for group in internal_loads.get("people", []))
ventilation_flow = (space_rate * area + people_rate * num_people) / 1000 # m³/s
air_density = 1.2 # kg/m³
specific_heat = 1000 # J/kg·K
delta_t = outdoor_temp - indoor_temp
if mode == "cooling" and delta_t <= 0:
return 0, 0
if mode == "heating" and delta_t >= 0:
return 0, 0
load = ventilation_flow * air_density * specific_heat * delta_t / 1000 # kW
cooling_load = load if mode == "cooling" else 0
heating_load = -load if mode == "heating" else 0
return cooling_load, heating_load
@staticmethod
def calculate_infiltration_load(internal_loads: Dict, outdoor_temp: float, indoor_temp: float, area: float, building_info: Dict, mode: str = "none") -> tuple[float, float]:
"""Calculate infiltration load for heating and cooling in kW based on mode."""
if mode == "none":
return 0, 0
infiltration = internal_loads.get("infiltration")
if not infiltration:
return 0, 0
method = infiltration.get("method", "ACH")
settings = infiltration.get("settings", {})
building_height = building_info.get("building_height", 3.0)
volume = area * building_height # m³
air_density = 1.2 # kg/m³
specific_heat = 1000 # J/kg·K
delta_t = outdoor_temp - indoor_temp
if mode == "cooling" and delta_t <= 0:
return 0, 0
if mode == "heating" and delta_t >= 0:
return 0, 0
if method == "ACH":
ach = settings.get("rate", 0.5)
infiltration_flow = ach * volume / 3600 # m³/s
elif method == "Crack Flow":
ela = settings.get("ela", 0.0001) # m²/m²
wind_speed = 4.0 # m/s (assumed)
infiltration_flow = ela * area * wind_speed / 2 # m³/s
else: # Empirical Equations
c = settings.get("c", 0.1)
n = settings.get("n", 0.65)
delta_t_abs = abs(delta_t)
infiltration_flow = c * (delta_t_abs ** n) * area / 3600 # m³/s
load = infiltration_flow * air_density * specific_heat * delta_t / 1000 # kW
cooling_load = load if mode == "cooling" else 0
heating_load = -load if mode == "heating" else 0
return cooling_load, heating_load
@staticmethod
def get_adaptive_comfort_temp(outdoor_temp: float) -> float:
"""Calculate adaptive comfort temperature per ASHRAE 55."""
if 10 <= outdoor_temp <= 33.5:
return 0.31 * outdoor_temp + 17.8
return 24.0 # Default to standard setpoint if outside range
@staticmethod
def filter_hourly_data(hourly_data: List[Dict], sim_period: Dict, climate_data: Dict) -> List[Dict]:
"""Filter hourly data based on simulation period, ignoring year."""
if sim_period["type"] == "Full Year":
return hourly_data
filtered_data = []
if sim_period["type"] == "From-to":
start_month = sim_period["start_date"].month
start_day = sim_period["start_date"].day
end_month = sim_period["end_date"].month
end_day = sim_period["end_date"].day
for data in hourly_data:
month, day = data["month"], data["day"]
if (month > start_month or (month == start_month and day >= start_day)) and \
(month < end_month or (month == end_month and day <= end_day)):
filtered_data.append(data)
elif sim_period["type"] in ["HDD", "CDD"]:
base_temp = sim_period.get("base_temp", 18.3 if sim_period["type"] == "HDD" else 23.9)
for data in hourly_data:
temp = data["dry_bulb"]
if (sim_period["type"] == "HDD" and temp < base_temp) or (sim_period["type"] == "CDD" and temp > base_temp):
filtered_data.append(data)
return filtered_data
@staticmethod
def get_indoor_conditions(indoor_conditions: Dict, hour: int, outdoor_temp: float) -> Dict:
"""Determine indoor conditions based on user settings."""
if indoor_conditions["type"] == "Fixed":
mode = "none" if abs(outdoor_temp - 18) < 0.01 else "cooling" if outdoor_temp > 18 else "heating"
if mode == "cooling":
return {
"temperature": indoor_conditions.get("cooling_setpoint", {}).get("temperature", 24.0),
"rh": indoor_conditions.get("cooling_setpoint", {}).get("rh", 50.0)
}
elif mode == "heating":
return {
"temperature": indoor_conditions.get("heating_setpoint", {}).get("temperature", 22.0),
"rh": indoor_conditions.get("heating_setpoint", {}).get("rh", 50.0)
}
else:
return {"temperature": 24.0, "rh": 50.0}
elif indoor_conditions["type"] == "Time-varying":
schedule = indoor_conditions.get("schedule", [])
if schedule:
hour_idx = hour % 24
for entry in schedule:
if entry["hour"] == hour_idx:
return {"temperature": entry["temperature"], "rh": entry["rh"]}
return {"temperature": 24.0, "rh": 50.0}
else: # Adaptive
return {"temperature": TFMCalculations.get_adaptive_comfort_temp(outdoor_temp), "rh": 50.0}
@staticmethod
def calculate_tfm_loads(components: Dict, hourly_data: List[Dict], indoor_conditions: Dict, internal_loads: Dict, building_info: Dict, sim_period: Dict, hvac_settings: Dict) -> List[Dict]:
"""Calculate TFM loads for heating and cooling with user-defined filters and temperature threshold."""
filtered_data = TFMCalculations.filter_hourly_data(hourly_data, sim_period, building_info)
temp_loads = []
building_orientation = building_info.get("orientation_angle", 0.0)
operating_periods = hvac_settings.get("operating_hours", [{"start": 8, "end": 18}])
area = building_info.get("floor_area", 100.0)
# Pre-calculate CTF coefficients for all components using CTFCalculator
for comp_list in components.values():
for comp in comp_list:
comp.ctf = CTFCalculator.calculate_ctf_coefficients(comp)
for hour_data in filtered_data:
hour = hour_data["hour"]
outdoor_temp = hour_data["dry_bulb"]
indoor_cond = TFMCalculations.get_indoor_conditions(indoor_conditions, hour, outdoor_temp)
indoor_temp = indoor_cond["temperature"]
# Initialize all loads to 0
conduction_cooling = conduction_heating = solar = internal = ventilation_cooling = ventilation_heating = infiltration_cooling = infiltration_heating = 0
# Check if hour is within operating periods
is_operating = False
for period in operating_periods:
start_hour = period.get("start", 8)
end_hour = period.get("end", 18)
if start_hour <= hour % 24 <= end_hour:
is_operating = True
break
# Determine mode based on temperature threshold (18°C)
mode = "none" if abs(outdoor_temp - 18) < 0.01 else "cooling" if outdoor_temp > 18 else "heating"
if is_operating and mode == "cooling":
for comp_list in components.values():
for comp in comp_list:
cool_load, _ = TFMCalculations.calculate_conduction_load(comp, outdoor_temp, indoor_temp, hour, mode="cooling")
conduction_cooling += cool_load
# Updated call to calculate_solar_load to match current signature
solar += TFMCalculations.calculate_solar_load(comp, hour_data, hour, building_orientation, mode="cooling")
internal = TFMCalculations.calculate_internal_load(internal_loads, hour, max([p["end"] - p["start"] for p in operating_periods]), area)
ventilation_cooling, _ = TFMCalculations.calculate_ventilation_load(internal_loads, outdoor_temp, indoor_temp, area, building_info, mode="cooling")
infiltration_cooling, _ = TFMCalculations.calculate_infiltration_load(internal_loads, outdoor_temp, indoor_temp, area, building_info, mode="cooling")
elif is_operating and mode == "heating":
for comp_list in components.values():
for comp in comp_list:
_, heat_load = TFMCalculations.calculate_conduction_load(comp, outdoor_temp, indoor_temp, hour, mode="heating")
conduction_heating += heat_load
internal = TFMCalculations.calculate_internal_load(internal_loads, hour, max([p["end"] - p["start"] for p in operating_periods]), area)
_, ventilation_heating = TFMCalculations.calculate_ventilation_load(internal_loads, outdoor_temp, indoor_temp, area, building_info, mode="heating")
_, infiltration_heating = TFMCalculations.calculate_infiltration_load(internal_loads, outdoor_temp, indoor_temp, area, building_info, mode="heating")
else: # mode == "none" or not is_operating
internal = 0 # No internal loads when no heating or cooling is needed or outside operating hours
# Calculate total loads, subtracting internal load for heating
total_cooling = conduction_cooling + solar + internal + ventilation_cooling + infiltration_cooling
total_heating = max(conduction_heating + ventilation_heating + infiltration_heating - internal, 0)
# Enforce mutual exclusivity within hour
if mode == "cooling":
total_heating = 0
elif mode == "heating":
total_cooling = 0
temp_loads.append({
"hour": hour,
"month": hour_data["month"],
"day": hour_data["day"],
"conduction_cooling": conduction_cooling,
"conduction_heating": conduction_heating,
"solar": solar,
"internal": internal,
"ventilation_cooling": ventilation_cooling,
"ventilation_heating": ventilation_heating,
"infiltration_cooling": infiltration_cooling,
"infiltration_heating": infiltration_heating,
"total_cooling": total_cooling,
"total_heating": total_heating
})
# Group loads by day and apply daily control
loads_by_day = defaultdict(list)
for load in temp_loads:
day_key = (load["month"], load["day"])
loads_by_day[day_key].append(load)
final_loads = []
for day_key, day_loads in loads_by_day.items():
# Count hours with non-zero cooling and heating loads
cooling_hours = sum(1 for load in day_loads if load["total_cooling"] > 0)
heating_hours = sum(1 for load in day_loads if load["total_heating"] > 0)
# Apply daily control
for load in day_loads:
if cooling_hours > heating_hours:
load["total_heating"] = 0 # Keep cooling components, zero heating total
elif heating_hours > cooling_hours:
load["total_cooling"] = 0 # Keep heating components, zero cooling total
else: # Equal hours
load["total_cooling"] = 0
load["total_heating"] = 0 # Zero both totals, keep components
final_loads.append(load)
return final_loads |