mabuseif commited on
Commit
5dca2c9
·
verified ·
1 Parent(s): da87c21

Update app/hvac_loads.py

Browse files
Files changed (1) hide show
  1. app/hvac_loads.py +212 -192
app/hvac_loads.py CHANGED
@@ -11,7 +11,7 @@ from typing import Dict, List, Optional, NamedTuple, Any, Tuple
11
  from enum import Enum
12
  import streamlit as st
13
  from app.materials_library import GlazingMaterial, Material, MaterialLibrary
14
- from app.internal_loads import PEOPLE_ACTIVITY_LEVELS, DIVERSITY_FACTORS, LIGHTING_FIXTURE_TYPES, EQUIPMENT_HEAT_GAINS, VENTILATION_RATES, INFILTRATION_SETTINGS
15
  from datetime import datetime
16
  from collections import defaultdict
17
  import logging
@@ -64,31 +64,17 @@ class TFMCalculations:
64
  ctf = CTFCalculator.calculate_ctf_coefficients(component)
65
 
66
  # Initialize history terms (simplified: assume steady-state history for demonstration)
67
- # In practice, maintain temperature and flux histories
68
  load = component.u_value * component.area * delta_t
69
  for i in range(len(ctf.Y)):
70
  load += component.area * ctf.Y[i] * (outdoor_temp - indoor_temp) * np.exp(-i * 3600 / 3600)
71
  load -= component.area * ctf.Z[i] * (outdoor_temp - indoor_temp) * np.exp(-i * 3600 / 3600)
72
- # Note: F terms require flux history, omitted here for simplicity
73
  cooling_load = load / 1000 if mode == "cooling" else 0
74
  heating_load = -load / 1000 if mode == "heating" else 0
75
  return cooling_load, heating_load
76
 
77
  @staticmethod
78
  def day_of_year(month: int, day: int, year: int) -> int:
79
- """Calculate day of the year (n) from month, day, and year, accounting for leap years.
80
-
81
- Args:
82
- month (int): Month of the year (1-12).
83
- day (int): Day of the month (1-31).
84
- year (int): Year.
85
-
86
- Returns:
87
- int: Day of the year (1-365 or 366 for leap years).
88
-
89
- References:
90
- ASHRAE Handbook—Fundamentals, Chapter 18.
91
- """
92
  days_in_month = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
93
  if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0):
94
  days_in_month[1] = 29
@@ -96,17 +82,7 @@ class TFMCalculations:
96
 
97
  @staticmethod
98
  def equation_of_time(n: int) -> float:
99
- """Calculate Equation of Time (EOT) in minutes using Spencer's formula.
100
-
101
- Args:
102
- n (int): Day of the year (1-365 or 366).
103
-
104
- Returns:
105
- float: Equation of Time in minutes.
106
-
107
- References:
108
- ASHRAE Handbook—Fundamentals, Chapter 18.
109
- """
110
  B = (n - 1) * 360 / 365
111
  B_rad = math.radians(B)
112
  EOT = 229.2 * (0.000075 + 0.001868 * math.cos(B_rad) - 0.032077 * math.sin(B_rad) -
@@ -115,24 +91,12 @@ class TFMCalculations:
115
 
116
  @staticmethod
117
  def calculate_dynamic_shgc(glazing_type: str, cos_theta: float) -> float:
118
- """Calculate dynamic SHGC based on incidence angle.
119
-
120
- Args:
121
- glazing_type (str): Type of glazing (e.g., 'Single Clear').
122
- cos_theta (float): Cosine of the angle of incidence.
123
-
124
- Returns:
125
- float: Dynamic SHGC value.
126
-
127
- References:
128
- ASHRAE Handbook—Fundamentals, Chapter 15, Table 13.
129
- """
130
  if glazing_type not in TFMCalculations.SHGC_COEFFICIENTS:
131
  logger.warning(f"Unknown glazing type '{glazing_type}'. Using default SHGC coefficients for Single Clear.")
132
  glazing_type = "Single Clear"
133
 
134
  c = TFMCalculations.SHGC_COEFFICIENTS[glazing_type]
135
- # Incidence angle modifier: f(cos(θ)) = c_0 + c_1·cos(θ) + c_2·cos²(θ) + c_3·cos³(θ) + c_4·cos⁴(θ) + c_5·cos⁵(θ)
136
  f_cos_theta = (c[0] + c[1] * cos_theta + c[2] * cos_theta**2 +
137
  c[3] * cos_theta**3 + c[4] * cos_theta**4 + c[5] * cos_theta**5)
138
  return f_cos_theta
@@ -141,60 +105,37 @@ class TFMCalculations:
141
  def get_surface_parameters(component: Any, building_info: Dict, material_library: MaterialLibrary,
142
  project_materials: Dict, project_constructions: Dict,
143
  project_glazing_materials: Dict) -> Tuple[float, float, float, Optional[float], float]:
144
- """
145
- Determine surface parameters (tilt, azimuth, h_o, emissivity, absorptivity) for a component.
146
- Uses MaterialLibrary to fetch properties from first layer for walls/roofs, and GlazingMaterial for windows/skylights.
147
- Handles orientation and tilt based on component type:
148
- - Walls, Windows: Azimuth = elevation base azimuth + component.rotation; Tilt = 90°.
149
- - Roofs, Skylights: Azimuth = component.orientation; Tilt = component.tilt (default 0°).
150
- - Floors: Tilt = 180°; Azimuth = 0°.
151
-
152
- Args:
153
- component: Component object with component_type, elevation, rotation, orientation, tilt,
154
- construction, or glazing_material.
155
- building_info (Dict): Building information containing orientation_angle for elevation mapping.
156
- material_library: MaterialLibrary instance for accessing library materials/constructions.
157
- project_materials: Dict of project-specific Material objects.
158
- project_constructions: Dict of project-specific Construction objects.
159
- project_glazing_materials: Dict of project-specific GlazingMaterial objects.
160
-
161
- Returns:
162
- Tuple[float, float, float, Optional[float], float]: Surface tilt (°), surface azimuth (°),
163
- h_o (W/m²·K), emissivity, absorptivity.
164
- """
165
- # Default parameters
166
  component_name = getattr(component, 'name', 'unnamed_component')
167
 
168
- # Initialize default values
169
- surface_tilt = 90.0 # Default vertical for walls, windows
170
- surface_azimuth = 0.0 # Default north-facing
171
- h_o = 17.0 # Default exterior convection coefficient
172
- emissivity = 0.9 # Default for opaque components
173
- absorptivity = 0.6 # Default
174
 
175
  try:
176
- # Set component-specific defaults based on type
177
  if component.component_type == ComponentType.ROOF:
178
- surface_tilt = getattr(component, 'tilt', 0.0) # Horizontal, upward if tilt absent
179
- h_o = 23.0 # W/m²·K for roofs
180
  surface_azimuth = getattr(component, 'orientation', 0.0)
181
  logger.debug(f"Roof component {component_name}: using orientation={surface_azimuth}, tilt={surface_tilt}")
182
 
183
  elif component.component_type == ComponentType.SKYLIGHT:
184
- surface_tilt = getattr(component, 'tilt', 0.0) # Horizontal, upward if tilt absent
185
- h_o = 23.0 # W/m²·K for skylights
186
  surface_azimuth = getattr(component, 'orientation', 0.0)
187
  logger.debug(f"Skylight component {component_name}: using orientation={surface_azimuth}, tilt={surface_tilt}")
188
 
189
  elif component.component_type == ComponentType.FLOOR:
190
- surface_tilt = 180.0 # Horizontal, downward
191
- h_o = 17.0 # W/m²·K
192
- surface_azimuth = 0.0 # Default azimuth for floors
193
  logger.debug(f"Floor component {component_name}: using default azimuth={surface_azimuth}, tilt={surface_tilt}")
194
 
195
  else: # WALL, WINDOW
196
- surface_tilt = 90.0 # Vertical
197
- h_o = 17.0 # W/m²·K
198
  elevation = getattr(component, 'elevation', None)
199
  if not elevation:
200
  logger.warning(f"Component {component_name} ({component.component_type.value}) is missing 'elevation' field. Using default azimuth=0.")
@@ -207,23 +148,17 @@ class TFMCalculations:
207
  "C": (base_azimuth + 180.0) % 360,
208
  "D": (base_azimuth + 270.0) % 360
209
  }
210
-
211
  if elevation not in elevation_angles:
212
- logger.warning(f"Invalid elevation '{elevation}' for component {component_name} ({component.component_type.value}). "
213
- f"Expected one of {list(elevation_angles.keys())}. Using default azimuth=0.")
214
  surface_azimuth = 0.0
215
  else:
216
  surface_azimuth = (elevation_angles[elevation] + getattr(component, 'rotation', 0.0)) % 360
217
- logger.debug(f"Component {component_name} ({component.component_type.value}): elevation={elevation}, "
218
- f"base_azimuth={elevation_angles[elevation]}, rotation={getattr(component, 'rotation', 0.0)}, "
219
- f"total_azimuth={surface_azimuth}, tilt={surface_tilt}")
220
 
221
- # Fetch material properties
222
  if component.component_type in [ComponentType.WALL, ComponentType.ROOF]:
223
  construction = getattr(component, 'construction', None)
224
  if not construction:
225
- logger.warning(f"No construction defined for {component_name} ({component.component_type.value}). "
226
- f"Using defaults: absorptivity=0.6, emissivity=0.9.")
227
  else:
228
  construction_obj = None
229
  if hasattr(construction, 'name'):
@@ -231,25 +166,21 @@ class TFMCalculations:
231
  material_library.library_constructions.get(construction.name))
232
 
233
  if not construction_obj:
234
- logger.warning(f"Construction not found for {component_name} ({component.component_type.value}). "
235
- f"Using defaults: absorptivity=0.6, emissivity=0.9.")
236
  elif not construction_obj.layers:
237
- logger.warning(f"No layers in construction for {component_name} ({component.component_type.value}). "
238
- f"Using defaults: absorptivity=0.6, emissivity=0.9.")
239
  else:
240
  first_layer = construction_obj.layers[0]
241
  material = first_layer.get("material")
242
  if material:
243
  absorptivity = getattr(material, 'absorptivity', 0.6)
244
  emissivity = getattr(material, 'emissivity', 0.9)
245
- logger.debug(f"Using first layer material for {component_name} ({component.component_type.value}): "
246
- f"absorptivity={absorptivity}, emissivity={emissivity}")
247
 
248
  elif component.component_type in [ComponentType.WINDOW, ComponentType.SKYLIGHT]:
249
  glazing_material = getattr(component, 'glazing_material', None)
250
  if not glazing_material:
251
- logger.warning(f"No glazing material defined for {component_name} ({component.component_type.value}). "
252
- f"Using default SHGC=0.7, h_o={h_o}.")
253
  shgc = 0.7
254
  else:
255
  glazing_material_obj = None
@@ -258,18 +189,16 @@ class TFMCalculations:
258
  material_library.library_glazing_materials.get(glazing_material.name))
259
 
260
  if not glazing_material_obj:
261
- logger.warning(f"Glazing material not found for {component_name} ({component.component_type.value}). "
262
- f"Using default SHGC=0.7, h_o={h_o}.")
263
  shgc = 0.7
264
  else:
265
  shgc = getattr(glazing_material_obj, 'shgc', 0.7)
266
  h_o = getattr(glazing_material_obj, 'h_o', h_o)
267
- logger.debug(f"Using glazing material for {component_name} ({component.component_type.value}): "
268
- f"shgc={shgc}, h_o={h_o}")
269
  emissivity = None
270
 
271
  except Exception as e:
272
- logger.error(f"Error retrieving surface parameters for {component_name} ({component.component_type.value}): {str(e)}")
273
  if component.component_type == ComponentType.ROOF:
274
  surface_tilt = 0.0
275
  h_o = 23.0
@@ -294,27 +223,12 @@ class TFMCalculations:
294
  shgc = 0.7
295
  emissivity = None
296
 
297
- logger.info(f"Final surface parameters for {component_name} ({component.component_type.value}): "
298
- f"tilt={surface_tilt:.1f}, azimuth={surface_azimuth:.1f}, h_o={h_o:.1f}")
299
  return surface_tilt, surface_azimuth, h_o, emissivity, absorptivity
300
 
301
  @staticmethod
302
  def calculate_solar_load(component, hourly_data: Dict, hour: int, building_orientation: float, mode: str = "none") -> float:
303
- """Calculate solar load in kW (cooling only) using ASHRAE-compliant solar calculations.
304
-
305
- Args:
306
- component: Component object with area, component_type, elevation, glazing_material, shgc, iac.
307
- hourly_data (Dict): Hourly weather data including solar radiation.
308
- hour (int): Current hour.
309
- building_orientation (float): Building orientation angle in degrees.
310
- mode (str): Operating mode ('cooling', 'heating', 'none').
311
-
312
- Returns:
313
- float: Solar cooling load in kW. Returns 0 for non-cooling modes or non-fenestration components.
314
-
315
- References:
316
- ASHRAE Handbook—Fundamentals, Chapters 15 and 18.
317
- """
318
  if mode != "cooling":
319
  return 0
320
 
@@ -329,8 +243,8 @@ class TFMCalculations:
329
  from app.material_library import MaterialLibrary
330
  material_library = MaterialLibrary()
331
  st.session_state.material_library = material_library
332
- logger.info(f"Created new MaterialLibrary for {component_name} ({component.component_type.value})")
333
-
334
  project_materials = st.session_state.get("project_data", {}).get("materials", {}).get("project", {})
335
  project_constructions = st.session_state.get("project_data", {}).get("constructions", {}).get("project", {})
336
  project_glazing_materials = st.session_state.get("project_data", {}).get("fenestrations", {}).get("project", {})
@@ -338,45 +252,45 @@ class TFMCalculations:
338
  latitude = climate_data.get("latitude", 0.0)
339
  longitude = climate_data.get("longitude", 0.0)
340
  timezone = climate_data.get("time_zone", 0.0)
341
- ground_reflectivity = st.session_state.get("project_data", {}).get("climate_data", {}).get("ground_reflectivity", 0.2)
342
 
343
  if not -90 <= latitude <= 90:
344
- logger.warning(f"Invalid latitude {latitude} for {component_name} ({component.component_type.value}). Using default 0.0.")
345
  latitude = 0.0
346
  if not -180 <= longitude <= 180:
347
- logger.warning(f"Invalid longitude {longitude} for {component_name} ({component.component_type.value}). Using default 0.0.")
348
  longitude = 0.0
349
  if not -12 <= timezone <= 14:
350
- logger.warning(f"Invalid timezone {timezone} for {component_name} ({component.component_type.value}). Using default 0.0.")
351
  timezone = 0.0
352
  if not 0 <= ground_reflectivity <= 1:
353
- logger.warning(f"Invalid ground_reflectivity {ground_reflectivity} for {component_name} ({component.component_type.value}). Using default 0.2.")
354
  ground_reflectivity = 0.2
355
 
356
  required_fields = ["month", "day", "hour", "global_horizontal_radiation", "direct_normal_radiation",
357
  "diffuse_horizontal_radiation", "dry_bulb"]
358
  if not all(field in hourly_data for field in required_fields):
359
- logger.warning(f"Missing required fields in hourly_data for hour {hour} for {component_name} ({component.component_type.value}): {hourly_data}")
360
  return 0
361
 
362
  if hourly_data["global_horizontal_radiation"] <= 0:
363
- logger.info(f"No solar load for hour {hour} due to GHI={hourly_data['global_horizontal_radiation']} for {component_name} ({component.component_type.value})")
364
  return 0
365
 
366
  month = hourly_data["month"]
367
  day = hourly_data["day"]
368
  hour = hourly_data["hour"]
369
  ghi = hourly_data["global_horizontal_radiation"]
370
- dni = hourly_data.get("direct_normal_radiation", ghi * 0.7)
371
  dhi = hourly_data.get("diffuse_horizontal_radiation", ghi * 0.3)
372
  outdoor_temp = hourly_data["dry_bulb"]
373
 
374
  if ghi < 0 or dni < 0 or dhi < 0:
375
- logger.error(f"Negative radiation values for {month}/{day}/{hour} for {component_name} ({component.component_type.value})")
376
  raise ValueError(f"Negative radiation values for {month}/{day}/{hour}")
377
 
378
  logger.info(f"Processing solar for {month}/{day}/{hour} with GHI={ghi}, DNI={dni}, DHI={dhi}, "
379
- f"dry_bulb={outdoor_temp} for {component_name} ({component.component_type.value})")
380
 
381
  year = 2025
382
  n = TFMCalculations.day_of_year(month, day, year)
@@ -404,7 +318,7 @@ class TFMCalculations:
404
  azimuth = 360 - azimuth if azimuth > 0 else -azimuth
405
 
406
  logger.info(f"Solar angles for {month}/{day}/{hour}: declination={delta:.2f}, LST={LST:.2f}, "
407
- f"HRA={hra:.2f}, altitude={alpha:.2f}, azimuth={azimuth:.2f} for {component_name} ({component.component_type.value})")
408
 
409
  building_info = {"orientation_angle": building_orientation}
410
  try:
@@ -446,7 +360,7 @@ class TFMCalculations:
446
 
447
  cos_theta = max(min(cos_theta, 1.0), 0.0)
448
 
449
- logger.info(f" Component {component_name} ({component.component_type.value}) at {month}/{day}/{hour}: "
450
  f"surface_tilt={surface_tilt:.2f}, surface_azimuth={surface_azimuth:.2f}, "
451
  f"cos_theta={cos_theta:.4f}")
452
 
@@ -473,7 +387,7 @@ class TFMCalculations:
473
  shgc = getattr(glazing_material_obj, 'shgc', 0.7)
474
  h_o = getattr(glazing_material_obj, 'h_o', h_o)
475
  else:
476
- logger.warning(f"Glazing material not found for {component_name} ({component.component_type.value}). Using default SHGC=0.7.")
477
 
478
  glazing_type = "Single Clear"
479
  if hasattr(component, 'name') and component.name in TFMCalculations.GLAZING_TYPE_MAPPING:
@@ -485,7 +399,7 @@ class TFMCalculations:
485
 
486
  solar_heat_gain = component.area * shgc_dynamic * I_t * iac / 1000
487
 
488
- logger.info(f"Fenestration solar heat gain for {component_name} ({component.component_type.value}) at {month}/{day}/{hour}: "
489
  f"{solar_heat_gain:.4f} kW (area={component.area}, shgc_dynamic={shgc_dynamic:.4f}, "
490
  f"I_t={I_t:.2f}, iac={iac})")
491
 
@@ -494,97 +408,203 @@ class TFMCalculations:
494
 
495
  solar_heat_gain = component.area * absorptivity * I_t * surface_resistance / 1000
496
 
497
- logger.info(f"Opaque surface solar heat gain for {component_name} ({component.component_type.value}) at {month}/{day}/{hour}: "
498
  f"{solar_heat_gain:.4f} kW (area={component.area}, absorptivity={absorptivity:.2f}, "
499
  f"I_t={I_t:.2f}, surface_resistance={surface_resistance:.4f})")
500
 
501
  return solar_heat_gain
502
 
503
  except Exception as e:
504
- logger.error(f"Error calculating solar load for {component_name} ({component.component_type.value}) at hour {hour}: {str(e)}")
505
  return 0
506
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
507
  @staticmethod
508
  def calculate_internal_load(internal_loads: Dict, hour: int, operation_hours: int, area: float) -> float:
509
- """Calculate total internal load in kW."""
510
- total_load = 0
511
- for group in internal_loads.get("people", []):
512
- activity_data = group["activity_data"]
513
- sensible = (activity_data["sensible_min_w"] + activity_data["sensible_max_w"]) / 2
514
- latent = (activity_data["latent_min_w"] + activity_data["latent_max_w"]) / 2
515
- load_per_person = sensible + latent
516
- total_load += group["num_people"] * load_per_person * group["diversity_factor"]
517
- for light in internal_loads.get("lighting", []):
518
- lpd = light["lpd"]
519
- lighting_operating_hours = light["operating_hours"]
520
- fraction = min(lighting_operating_hours, operation_hours) / operation_hours if operation_hours > 0 else 0
521
- lighting_load = lpd * area * fraction
522
- total_load += lighting_load
523
- equipment = internal_loads.get("equipment", {})
524
- total_power_density = equipment.get("total_power_density", 0)
525
- equipment_load = total_power_density * area
526
- total_load += equipment_load
527
- return total_load / 1000
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
528
 
529
  @staticmethod
530
  def calculate_ventilation_load(internal_loads: Dict, outdoor_temp: float, indoor_temp: float, area: float, building_info: Dict, mode: str = "none") -> tuple[float, float]:
531
  """Calculate ventilation load for heating and cooling in kW based on mode."""
532
  if mode == "none":
533
  return 0, 0
534
- ventilation = internal_loads.get("ventilation", {})
535
- if not ventilation:
536
- return 0, 0
537
- space_rate = ventilation.get("space_rate", 0.3)
538
- people_rate = ventilation.get("people_rate", 2.5)
539
- num_people = sum(group["num_people"] for group in internal_loads.get("people", []))
540
- ventilation_flow = (space_rate * area + people_rate * num_people) / 1000
541
- air_density = 1.2
542
- specific_heat = 1000
543
  delta_t = outdoor_temp - indoor_temp
544
  if mode == "cooling" and delta_t <= 0:
545
  return 0, 0
546
  if mode == "heating" and delta_t >= 0:
547
  return 0, 0
548
- load = ventilation_flow * air_density * specific_heat * delta_t / 1000
549
- cooling_load = load if mode == "cooling" else 0
550
- heating_load = -load if mode == "heating" else 0
551
- return cooling_load, heating_load
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
552
 
553
  @staticmethod
554
  def calculate_infiltration_load(internal_loads: Dict, outdoor_temp: float, indoor_temp: float, area: float, building_info: Dict, mode: str = "none") -> tuple[float, float]:
555
  """Calculate infiltration load for heating and cooling in kW based on mode."""
556
  if mode == "none":
557
  return 0, 0
558
- infiltration = internal_loads.get("infiltration", {})
559
- if not infiltration:
560
- return 0, 0
561
- method = infiltration.get("method", "ACH")
562
- settings = infiltration.get("settings", {})
563
- building_height = building_info.get("building_height", 3.0)
564
- volume = area * building_height
565
- air_density = 1.2
566
- specific_heat = 1000
567
  delta_t = outdoor_temp - indoor_temp
568
  if mode == "cooling" and delta_t <= 0:
569
  return 0, 0
570
  if mode == "heating" and delta_t >= 0:
571
  return 0, 0
572
- if method == "ACH":
573
- ach = settings.get("rate", 0.5)
574
- infiltration_flow = ach * volume / 3600
575
- elif method == "Crack Flow":
576
- ela = settings.get("ela", 0.0001)
577
- wind_speed = 4.0
578
- infiltration_flow = ela * area * wind_speed / 2
579
- else: # Empirical Equations
580
- c = settings.get("c", 0.1)
581
- n = settings.get("n", 0.65)
582
- delta_t_abs = abs(delta_t)
583
- infiltration_flow = c * (delta_t_abs ** n) * area / 3600
584
- load = infiltration_flow * air_density * specific_heat * delta_t / 1000
585
- cooling_load = load if mode == "cooling" else 0
586
- heating_load = -load if mode == "heating" else 0
587
- return cooling_load, heating_load
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
588
 
589
  @staticmethod
590
  def get_adaptive_comfort_temp(outdoor_temp: float) -> float:
 
11
  from enum import Enum
12
  import streamlit as st
13
  from app.materials_library import GlazingMaterial, Material, MaterialLibrary
14
+ from app.internal_loads import PEOPLE_ACTIVITY_LEVELS, LIGHTING_FIXTURE_TYPES, DEFAULT_BUILDING_INTERNALS
15
  from datetime import datetime
16
  from collections import defaultdict
17
  import logging
 
64
  ctf = CTFCalculator.calculate_ctf_coefficients(component)
65
 
66
  # Initialize history terms (simplified: assume steady-state history for demonstration)
 
67
  load = component.u_value * component.area * delta_t
68
  for i in range(len(ctf.Y)):
69
  load += component.area * ctf.Y[i] * (outdoor_temp - indoor_temp) * np.exp(-i * 3600 / 3600)
70
  load -= component.area * ctf.Z[i] * (outdoor_temp - indoor_temp) * np.exp(-i * 3600 / 3600)
 
71
  cooling_load = load / 1000 if mode == "cooling" else 0
72
  heating_load = -load / 1000 if mode == "heating" else 0
73
  return cooling_load, heating_load
74
 
75
  @staticmethod
76
  def day_of_year(month: int, day: int, year: int) -> int:
77
+ """Calculate day of the year (n) from month, day, and year, accounting for leap years."""
 
 
 
 
 
 
 
 
 
 
 
 
78
  days_in_month = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
79
  if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0):
80
  days_in_month[1] = 29
 
82
 
83
  @staticmethod
84
  def equation_of_time(n: int) -> float:
85
+ """Calculate Equation of Time (EOT) in minutes using Spencer's formula."""
 
 
 
 
 
 
 
 
 
 
86
  B = (n - 1) * 360 / 365
87
  B_rad = math.radians(B)
88
  EOT = 229.2 * (0.000075 + 0.001868 * math.cos(B_rad) - 0.032077 * math.sin(B_rad) -
 
91
 
92
  @staticmethod
93
  def calculate_dynamic_shgc(glazing_type: str, cos_theta: float) -> float:
94
+ """Calculate dynamic SHGC based on incidence angle."""
 
 
 
 
 
 
 
 
 
 
 
95
  if glazing_type not in TFMCalculations.SHGC_COEFFICIENTS:
96
  logger.warning(f"Unknown glazing type '{glazing_type}'. Using default SHGC coefficients for Single Clear.")
97
  glazing_type = "Single Clear"
98
 
99
  c = TFMCalculations.SHGC_COEFFICIENTS[glazing_type]
 
100
  f_cos_theta = (c[0] + c[1] * cos_theta + c[2] * cos_theta**2 +
101
  c[3] * cos_theta**3 + c[4] * cos_theta**4 + c[5] * cos_theta**5)
102
  return f_cos_theta
 
105
  def get_surface_parameters(component: Any, building_info: Dict, material_library: MaterialLibrary,
106
  project_materials: Dict, project_constructions: Dict,
107
  project_glazing_materials: Dict) -> Tuple[float, float, float, Optional[float], float]:
108
+ """Determine surface parameters (tilt, azimuth, h_o, emissivity, absorptivity) for a component."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  component_name = getattr(component, 'name', 'unnamed_component')
110
 
111
+ surface_tilt = 90.0
112
+ surface_azimuth = 0.0
113
+ h_o = 17.0
114
+ emissivity = 0.9
115
+ absorptivity = 0.6
 
116
 
117
  try:
 
118
  if component.component_type == ComponentType.ROOF:
119
+ surface_tilt = getattr(component, 'tilt', 0.0)
120
+ h_o = 23.0
121
  surface_azimuth = getattr(component, 'orientation', 0.0)
122
  logger.debug(f"Roof component {component_name}: using orientation={surface_azimuth}, tilt={surface_tilt}")
123
 
124
  elif component.component_type == ComponentType.SKYLIGHT:
125
+ surface_tilt = getattr(component, 'tilt', 0.0)
126
+ h_o = 23.0
127
  surface_azimuth = getattr(component, 'orientation', 0.0)
128
  logger.debug(f"Skylight component {component_name}: using orientation={surface_azimuth}, tilt={surface_tilt}")
129
 
130
  elif component.component_type == ComponentType.FLOOR:
131
+ surface_tilt = 180.0
132
+ h_o = 17.0
133
+ surface_azimuth = 0.0
134
  logger.debug(f"Floor component {component_name}: using default azimuth={surface_azimuth}, tilt={surface_tilt}")
135
 
136
  else: # WALL, WINDOW
137
+ surface_tilt = 90.0
138
+ h_o = 17.0
139
  elevation = getattr(component, 'elevation', None)
140
  if not elevation:
141
  logger.warning(f"Component {component_name} ({component.component_type.value}) is missing 'elevation' field. Using default azimuth=0.")
 
148
  "C": (base_azimuth + 180.0) % 360,
149
  "D": (base_azimuth + 270.0) % 360
150
  }
 
151
  if elevation not in elevation_angles:
152
+ logger.warning(f"Invalid elevation '{elevation}' for component {component_name}. Using default azimuth=0.")
 
153
  surface_azimuth = 0.0
154
  else:
155
  surface_azimuth = (elevation_angles[elevation] + getattr(component, 'rotation', 0.0)) % 360
156
+ logger.debug(f"Component {component_name}: elevation={elevation}, base_azimuth={elevation_angles[elevation]}, rotation={getattr(component, 'rotation', 0.0)}, total_azimuth={surface_azimuth}")
 
 
157
 
 
158
  if component.component_type in [ComponentType.WALL, ComponentType.ROOF]:
159
  construction = getattr(component, 'construction', None)
160
  if not construction:
161
+ logger.warning(f"No construction defined for {component_name}. Using defaults: absorptivity=0.6, emissivity=0.9.")
 
162
  else:
163
  construction_obj = None
164
  if hasattr(construction, 'name'):
 
166
  material_library.library_constructions.get(construction.name))
167
 
168
  if not construction_obj:
169
+ logger.warning(f"Construction not found for {component_name}. Using defaults: absorptivity=0.6, emissivity=0.9.")
 
170
  elif not construction_obj.layers:
171
+ logger.warning(f"No layers in construction for {component_name}. Using defaults: absorptivity=0.6, emissivity=0.9.")
 
172
  else:
173
  first_layer = construction_obj.layers[0]
174
  material = first_layer.get("material")
175
  if material:
176
  absorptivity = getattr(material, 'absorptivity', 0.6)
177
  emissivity = getattr(material, 'emissivity', 0.9)
178
+ logger.debug(f"Using first layer material for {component_name}: absorptivity={absorptivity}, emissivity={emissivity}")
 
179
 
180
  elif component.component_type in [ComponentType.WINDOW, ComponentType.SKYLIGHT]:
181
  glazing_material = getattr(component, 'glazing_material', None)
182
  if not glazing_material:
183
+ logger.warning(f"No glazing material defined for {component_name}. Using default SHGC=0.7, h_o={h_o}.")
 
184
  shgc = 0.7
185
  else:
186
  glazing_material_obj = None
 
189
  material_library.library_glazing_materials.get(glazing_material.name))
190
 
191
  if not glazing_material_obj:
192
+ logger.warning(f"Glazing material not found for {component_name}. Using default SHGC=0.7, h_o={h_o}.")
 
193
  shgc = 0.7
194
  else:
195
  shgc = getattr(glazing_material_obj, 'shgc', 0.7)
196
  h_o = getattr(glazing_material_obj, 'h_o', h_o)
197
+ logger.debug(f"Using glazing material for {component_name}: shgc={shgc}, h_o={h_o}")
 
198
  emissivity = None
199
 
200
  except Exception as e:
201
+ logger.error(f"Error retrieving surface parameters for {component_name}: {str(e)}")
202
  if component.component_type == ComponentType.ROOF:
203
  surface_tilt = 0.0
204
  h_o = 23.0
 
223
  shgc = 0.7
224
  emissivity = None
225
 
226
+ logger.info(f"Final surface parameters for {component_name}: tilt={surface_tilt:.1f}, azimuth={surface_azimuth:.1f}, h_o={h_o:.1f}")
 
227
  return surface_tilt, surface_azimuth, h_o, emissivity, absorptivity
228
 
229
  @staticmethod
230
  def calculate_solar_load(component, hourly_data: Dict, hour: int, building_orientation: float, mode: str = "none") -> float:
231
+ """Calculate solar load in kW (cooling only) using ASHRAE-compliant solar calculations."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  if mode != "cooling":
233
  return 0
234
 
 
243
  from app.material_library import MaterialLibrary
244
  material_library = MaterialLibrary()
245
  st.session_state.material_library = material_library
246
+ logger.info(f"Created new MaterialLibrary for {component_name}")
247
+
248
  project_materials = st.session_state.get("project_data", {}).get("materials", {}).get("project", {})
249
  project_constructions = st.session_state.get("project_data", {}).get("constructions", {}).get("project", {})
250
  project_glazing_materials = st.session_state.get("project_data", {}).get("fenestrations", {}).get("project", {})
 
252
  latitude = climate_data.get("latitude", 0.0)
253
  longitude = climate_data.get("longitude", 0.0)
254
  timezone = climate_data.get("time_zone", 0.0)
255
+ ground_reflectivity = climate_data.get("ground_reflectivity", 0.2)
256
 
257
  if not -90 <= latitude <= 90:
258
+ logger.warning(f"Invalid latitude {latitude} for {component_name}. Using default 0.0.")
259
  latitude = 0.0
260
  if not -180 <= longitude <= 180:
261
+ logger.warning(f"Invalid longitude {longitude} for {component_name}. Using default 0.0.")
262
  longitude = 0.0
263
  if not -12 <= timezone <= 14:
264
+ logger.warning(f"Invalid timezone {timezone} for {component_name}. Using default 0.0.")
265
  timezone = 0.0
266
  if not 0 <= ground_reflectivity <= 1:
267
+ logger.warning(f"Invalid ground_reflectivity {ground_reflectivity} for {component_name}. Using default 0.2.")
268
  ground_reflectivity = 0.2
269
 
270
  required_fields = ["month", "day", "hour", "global_horizontal_radiation", "direct_normal_radiation",
271
  "diffuse_horizontal_radiation", "dry_bulb"]
272
  if not all(field in hourly_data for field in required_fields):
273
+ logger.warning(f"Missing required fields in hourly_data for hour {hour} for {component_name}: {hourly_data}")
274
  return 0
275
 
276
  if hourly_data["global_horizontal_radiation"] <= 0:
277
+ logger.info(f"No solar load for hour {hour} due to GHI={hourly_data['global_horizontal_radiation']} for {component_name}")
278
  return 0
279
 
280
  month = hourly_data["month"]
281
  day = hourly_data["day"]
282
  hour = hourly_data["hour"]
283
  ghi = hourly_data["global_horizontal_radiation"]
284
+ dni = hourly_data.get("direct_normal_radiation", echi * 0.7)
285
  dhi = hourly_data.get("diffuse_horizontal_radiation", ghi * 0.3)
286
  outdoor_temp = hourly_data["dry_bulb"]
287
 
288
  if ghi < 0 or dni < 0 or dhi < 0:
289
+ logger.error(f"Negative radiation values for {month}/{day}/{hour} for {component_name}")
290
  raise ValueError(f"Negative radiation values for {month}/{day}/{hour}")
291
 
292
  logger.info(f"Processing solar for {month}/{day}/{hour} with GHI={ghi}, DNI={dni}, DHI={dhi}, "
293
+ f"dry_bulb={outdoor_temp} for {component_name}")
294
 
295
  year = 2025
296
  n = TFMCalculations.day_of_year(month, day, year)
 
318
  azimuth = 360 - azimuth if azimuth > 0 else -azimuth
319
 
320
  logger.info(f"Solar angles for {month}/{day}/{hour}: declination={delta:.2f}, LST={LST:.2f}, "
321
+ f"HRA={hra:.2f}, altitude={alpha:.2f}, azimuth={azimuth:.2f} for {component_name}")
322
 
323
  building_info = {"orientation_angle": building_orientation}
324
  try:
 
360
 
361
  cos_theta = max(min(cos_theta, 1.0), 0.0)
362
 
363
+ logger.info(f" Component {component_name} at {month}/{day}/{hour}: "
364
  f"surface_tilt={surface_tilt:.2f}, surface_azimuth={surface_azimuth:.2f}, "
365
  f"cos_theta={cos_theta:.4f}")
366
 
 
387
  shgc = getattr(glazing_material_obj, 'shgc', 0.7)
388
  h_o = getattr(glazing_material_obj, 'h_o', h_o)
389
  else:
390
+ logger.warning(f"Glazing material not found for {component_name}. Using default SHGC=0.7.")
391
 
392
  glazing_type = "Single Clear"
393
  if hasattr(component, 'name') and component.name in TFMCalculations.GLAZING_TYPE_MAPPING:
 
399
 
400
  solar_heat_gain = component.area * shgc_dynamic * I_t * iac / 1000
401
 
402
+ logger.info(f"Fenestration solar heat gain for {component_name} at {month}/{day}/{hour}: "
403
  f"{solar_heat_gain:.4f} kW (area={component.area}, shgc_dynamic={shgc_dynamic:.4f}, "
404
  f"I_t={I_t:.2f}, iac={iac})")
405
 
 
408
 
409
  solar_heat_gain = component.area * absorptivity * I_t * surface_resistance / 1000
410
 
411
+ logger.info(f"Opaque surface solar heat gain for {component_name} at {month}/{day}/{hour}: "
412
  f"{solar_heat_gain:.4f} kW (area={component.area}, absorptivity={absorptivity:.2f}, "
413
  f"I_t={I_t:.2f}, surface_resistance={surface_resistance:.4f})")
414
 
415
  return solar_heat_gain
416
 
417
  except Exception as e:
418
+ logger.error(f"Error calculating solar load for {component_name} at hour {hour}: {str(e)}")
419
  return 0
420
 
421
+ @staticmethod
422
+ def get_schedule_fraction(schedule_name: str, hour: int, is_weekend: bool) -> float:
423
+ """Get the schedule fraction for the given hour and day type."""
424
+ schedules = st.session_state.project_data["internal_loads"].get("schedules", {})
425
+ schedule = schedules.get(schedule_name, {})
426
+ if not schedule:
427
+ logger.warning(f"Schedule '{schedule_name}' not found. Using fraction=1.0.")
428
+ return 1.0
429
+ values = schedule.get("weekend" if is_weekend else "weekday", [1.0] * 24)
430
+ hour_idx = hour % 24
431
+ if 0 <= hour_idx < len(values):
432
+ fraction = values[hour_idx]
433
+ logger.debug(f"Schedule '{schedule_name}' at hour {hour_idx} ({'weekend' if is_weekend else 'weekday'}): fraction={fraction:.2f}")
434
+ return fraction
435
+ logger.warning(f"Invalid hour index {hour_idx} for schedule '{schedule_name}'. Using fraction=1.0.")
436
+ return 1.0
437
+
438
  @staticmethod
439
  def calculate_internal_load(internal_loads: Dict, hour: int, operation_hours: int, area: float) -> float:
440
+ """Calculate total internal load in kW, incorporating schedules."""
441
+ total_load = 0.0
442
+ is_weekend = False # Simplified; in practice, determine from date
443
+ try:
444
+ # People loads
445
+ for group in internal_loads.get("people", []):
446
+ schedule_name = group.get("schedule", "Continuous")
447
+ fraction = TFMCalculations.get_schedule_fraction(schedule_name, hour, is_weekend)
448
+ sensible = group.get("total_sensible_heat", 0.0)
449
+ latent = group.get("total_latent_heat", 0.0)
450
+ group_load = (sensible + latent) * fraction / 1000 # Convert W to kW
451
+ total_load += group_load
452
+ logger.debug(f"People group '{group.get('name', 'unknown')}': sensible={sensible:.2f} W, latent={latent:.2f} W, fraction={fraction:.2f}, load={group_load:.3f} kW")
453
+
454
+ # Lighting loads
455
+ for light in internal_loads.get("lighting", []):
456
+ schedule_name = light.get("schedule", "Continuous")
457
+ fraction = TFMCalculations.get_schedule_fraction(schedule_name, hour, is_weekend)
458
+ total_power = light.get("total_power", 0.0)
459
+ lighting_load = total_power * fraction / 1000 # Convert W to kW
460
+ total_load += lighting_load
461
+ logger.debug(f"Lighting system '{light.get('name', 'unknown')}': total_power={total_power:.2f} W, fraction={fraction:.2f}, load={lighting_load:.3f} kW")
462
+
463
+ # Equipment loads
464
+ for equip in internal_loads.get("equipment", []):
465
+ schedule_name = equip.get("schedule", "Continuous")
466
+ fraction = TFMCalculations.get_schedule_fraction(schedule_name, hour, is_weekend)
467
+ sensible = equip.get("total_sensible_power", 0.0)
468
+ latent = equip.get("total_latent_power", 0.0)
469
+ equip_load = (sensible + latent) * fraction / 1000 # Convert W to kW
470
+ total_load += equip_load
471
+ logger.debug(f"Equipment '{equip.get('name', 'unknown')}': sensible={sensible:.2f} W, latent={latent:.2f} W, fraction={fraction:.2f}, load={equip_load:.3f} kW")
472
+
473
+ logger.info(f"Total internal load for hour {hour}: {total_load:.3f} kW")
474
+ return total_load
475
+
476
+ except Exception as e:
477
+ logger.error(f"Error calculating internal load for hour {hour}: {str(e)}")
478
+ return 0.0
479
 
480
  @staticmethod
481
  def calculate_ventilation_load(internal_loads: Dict, outdoor_temp: float, indoor_temp: float, area: float, building_info: Dict, mode: str = "none") -> tuple[float, float]:
482
  """Calculate ventilation load for heating and cooling in kW based on mode."""
483
  if mode == "none":
484
  return 0, 0
 
 
 
 
 
 
 
 
 
485
  delta_t = outdoor_temp - indoor_temp
486
  if mode == "cooling" and delta_t <= 0:
487
  return 0, 0
488
  if mode == "heating" and delta_t >= 0:
489
  return 0, 0
490
+
491
+ total_cooling_load = 0.0
492
+ total_heating_load = 0.0
493
+ air_density = 1.2 # kg/m³
494
+ specific_heat = 1000 # J/kg·K
495
+ is_weekend = False # Simplified; determine from date in practice
496
+
497
+ try:
498
+ for system in internal_loads.get("ventilation", []):
499
+ schedule_name = system.get("schedule", "Continuous")
500
+ fraction = TFMCalculations.get_schedule_fraction(schedule_name, hour, is_weekend)
501
+ system_type = system.get("system_type", "AirChanges/Hour")
502
+ system_area = system.get("area", area)
503
+ ventilation_flow = 0.0
504
+
505
+ if system_type == "AirChanges/Hour":
506
+ design_flow_rate = system.get("design_flow_rate", 1.0) # L/s·m²
507
+ ventilation_flow = design_flow_rate * system_area / 1000 # Convert L/s to m³/s
508
+ if system.get("ventilation_type", "Natural") == "Mechanical":
509
+ fan_power = (system.get("fan_pressure_rise", 200.0) * ventilation_flow) / system.get("fan_efficiency", 0.7) / 1000 # kW
510
+ total_cooling_load += fan_power * fraction
511
+ logger.debug(f"Ventilation '{system.get('name', 'unknown')}': fan_power={fan_power:.3f} kW, fraction={fraction:.2f}")
512
+
513
+ elif system_type == "Wind and Stack Open Area":
514
+ opening_effectiveness = system.get("opening_effectiveness", 50.0) / 100
515
+ # Assume simplified flow based on area and effectiveness
516
+ ventilation_flow = 0.001 * system_area * opening_effectiveness # m³/s (placeholder)
517
+
518
+ elif system_type in ["Balanced Flow", "Heat Recovery"]:
519
+ design_flow_rate = system.get("design_flow_rate", 1.0) # L/s·m²
520
+ ventilation_flow = design_flow_rate * system_area / 1000 # Convert L/s to m³/s
521
+ fan_power = (system.get("fan_pressure_rise", 200.0) * ventilation_flow) / system.get("fan_efficiency", 0.7) / 1000 # kW
522
+ total_cooling_load += fan_power * fraction
523
+ if system_type == "Heat Recovery":
524
+ sensible_eff = system.get("sensible_effectiveness", 0.5)
525
+ delta_t = delta_t * (1 - sensible_eff)
526
+ logger.debug(f"Heat Recovery '{system.get('name', 'unknown')}': sensible_eff={sensible_eff:.2f}, adjusted_delta_t={delta_t:.2f}")
527
+
528
+ load = ventilation_flow * air_density * specific_heat * delta_t * fraction / 1000 # kW
529
+ cooling_load = load if mode == "cooling" else 0
530
+ heating_load = -load if mode == "heating" else 0
531
+ total_cooling_load += cooling_load
532
+ total_heating_load += heating_load
533
+ logger.debug(f"Ventilation '{system.get('name', 'unknown')}': flow={ventilation_flow:.4f} m³/s, fraction={fraction:.2f}, cooling={cooling_load:.3f} kW, heating={heating_load:.3f} kW")
534
+
535
+ logger.info(f"Total ventilation load for hour {hour}: cooling={total_cooling_load:.3f} kW, heating={total_heating_load:.3f} kW")
536
+ return total_cooling_load, total_heating_load
537
+
538
+ except Exception as e:
539
+ logger.error(f"Error calculating ventilation load for hour {hour}: {str(e)}")
540
+ return 0, 0
541
 
542
  @staticmethod
543
  def calculate_infiltration_load(internal_loads: Dict, outdoor_temp: float, indoor_temp: float, area: float, building_info: Dict, mode: str = "none") -> tuple[float, float]:
544
  """Calculate infiltration load for heating and cooling in kW based on mode."""
545
  if mode == "none":
546
  return 0, 0
 
 
 
 
 
 
 
 
 
547
  delta_t = outdoor_temp - indoor_temp
548
  if mode == "cooling" and delta_t <= 0:
549
  return 0, 0
550
  if mode == "heating" and delta_t >= 0:
551
  return 0, 0
552
+
553
+ total_cooling_load = 0.0
554
+ total_heating_load = 0.0
555
+ air_density = 1.2 # kg/m³
556
+ specific_heat = 1000 # J/kg·K
557
+ building_height = building_info.get("building_height", 3.0)
558
+ volume = area * building_height
559
+ is_weekend = False # Simplified; determine from date in practice
560
+
561
+ try:
562
+ for system in internal_loads.get("infiltration", []):
563
+ schedule_name = system.get("schedule", "Continuous")
564
+ fraction = TFMCalculations.get_schedule_fraction(schedule_name, hour, is_weekend)
565
+ system_type = system.get("system_type", "AirChanges/Hour")
566
+ system_area = system.get("area", area)
567
+ infiltration_flow = 0.0
568
+
569
+ if system_type == "AirChanges/Hour":
570
+ ach = system.get("design_flow_rate", 0.3)
571
+ infiltration_flow = ach * system_area * building_height / 3600 # m³/s
572
+
573
+ elif system_type == "Effective Leakage Area":
574
+ ela = system.get("effective_air_leakage_area", 100.0) / 10000 # Convert cm² to m²
575
+ stack_coeff = system.get("stack_coefficient", 0.0001)
576
+ wind_coeff = system.get("wind_coefficient", 0.0001)
577
+ delta_t_abs = abs(delta_t)
578
+ wind_speed = 4.0 # m/s, assumed
579
+ Q_stack = stack_coeff * ela * (delta_t_abs ** 0.5)
580
+ Q_wind = wind_coeff * ela * (wind_speed ** 2)
581
+ infiltration_flow = (Q_stack ** 2 + Q_wind ** 2) ** 0.5 # m³/s
582
+
583
+ elif system_type == "Flow Coefficient":
584
+ c = system.get("flow_coefficient", 0.0001) # m³/s·Paⁿ
585
+ n = system.get("pressure_exponent", 0.6)
586
+ stack_coeff = system.get("stack_coefficient", 0.0001)
587
+ wind_coeff = system.get("wind_coefficient", 0.0001)
588
+ delta_t_abs = abs(delta_t)
589
+ wind_speed = 4.0 # m/s, assumed
590
+ delta_p_stack = stack_coeff * delta_t_abs
591
+ delta_p_wind = wind_coeff * (wind_speed ** 2)
592
+ delta_p = (delta_p_stack ** 2 + delta_p_wind ** 2) ** 0.5
593
+ infiltration_flow = c * (delta_p ** n) * system_area
594
+
595
+ load = infiltration_flow * air_density * specific_heat * delta_t * fraction / 1000 # kW
596
+ cooling_load = load if mode == "cooling" else 0
597
+ heating_load = -load if mode == "heating" else 0
598
+ total_cooling_load += cooling_load
599
+ total_heating_load += heating_load
600
+ logger.debug(f"Infiltration '{system.get('name', 'unknown')}': flow={infiltration_flow:.4f} m³/s, fraction={fraction:.2f}, cooling={cooling_load:.3f} kW, heating={heating_load:.3f} kW")
601
+
602
+ logger.info(f"Total infiltration load for hour {hour}: cooling={total_cooling_load:.3f} kW, heating={total_heating_load:.3f} kW")
603
+ return total_cooling_load, total_heating_load
604
+
605
+ except Exception as e:
606
+ logger.error(f"Error calculating infiltration load for hour {hour}: {str(e)}")
607
+ return 0, 0
608
 
609
  @staticmethod
610
  def get_adaptive_comfort_temp(outdoor_temp: float) -> float: