mabuseif commited on
Commit
83e8a76
·
verified ·
1 Parent(s): 362a13d

Upload hvac_loads.py

Browse files
Files changed (1) hide show
  1. app/hvac_loads.py +803 -912
app/hvac_loads.py CHANGED
@@ -1,947 +1,838 @@
1
  """
2
- BuildSustain - HVAC Loads Module
3
-
4
- This module performs the core cooling and heating load calculations based on the
5
- ASHRAE methodology. It integrates data from climate, components, and internal loads
6
- modules to determine the building's thermal loads.
7
 
8
  Developed by: Dr Majed Abuseif, Deakin University
9
  © 2025
10
  """
11
 
12
- import streamlit as st
13
- import pandas as pd
14
  import numpy as np
15
- import json
16
- import logging
17
- import uuid
18
- import plotly.graph_objects as go
19
- import plotly.express as px
20
- from typing import Dict, List, Any, Optional, Tuple, Union
21
  from datetime import datetime
22
- import math
23
  from collections import defaultdict
 
 
 
24
 
25
  # Configure logging
26
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
27
  logger = logging.getLogger(__name__)
28
 
29
- # Constants
30
- STEFAN_BOLTZMANN = 5.67e-8 # W/m²·K⁴
31
- ABSORPTIVITY_DEFAULT = 0.7 # Default surface absorptivity
32
- EMISSIVITY_DEFAULT = 0.9 # Default surface emissivity
33
- SKY_TEMP_FACTOR = 0.0552 # Factor for sky temperature calculation
34
- AIR_DENSITY = 1.2 # kg/m³
35
- AIR_SPECIFIC_HEAT = 1005 # J/kg·K
36
- LATENT_HEAT_VAPORIZATION = 2260000 # J/kg
37
-
38
- def display_hvac_loads_page():
39
- """
40
- Display the HVAC loads calculation page.
41
- This is the main function called by main.py when the HVAC Loads page is selected.
42
- """
43
- st.title("HVAC Load Calculations")
44
-
45
- # Display help information in an expandable section
46
- with st.expander("Help & Information"):
47
- display_hvac_loads_help()
48
-
49
- # Check if necessary data is available
50
- if not check_prerequisites():
51
- st.warning("Please complete all previous steps (Building Info, Climate, Materials, Construction, Components, Internal Loads) before calculating HVAC loads.")
52
- return
53
-
54
- # --- User Input for Simulation Parameters --- #
55
- st.subheader("Simulation Parameters")
56
-
57
- # Simulation Period
58
- with st.expander("Simulation Period"):
59
- sim_period_type = st.selectbox(
60
- "Simulation Period Type",
61
- ["Full Year", "From-to", "HDD", "CDD"],
62
- key="sim_period_type"
63
- )
64
- sim_period = {"type": sim_period_type}
65
-
66
- if sim_period_type == "From-to":
67
- col1, col2 = st.columns(2)
68
- with col1:
69
- start_date = st.date_input("Start Date", value=datetime(2025, 1, 1))
70
- with col2:
71
- end_date = st.date_input("End Date", value=datetime(2025, 12, 31))
72
- sim_period["start_date"] = start_date
73
- sim_period["end_date"] = end_date
74
- elif sim_period_type in ["HDD", "CDD"]:
75
- base_temp = st.number_input(
76
- f"Base Temperature (°C) for {sim_period_type}",
77
- value=18.3 if sim_period_type == "HDD" else 23.9,
78
- min_value=0.0,
79
- max_value=40.0,
80
- step=0.1
81
- )
82
- sim_period["base_temp"] = base_temp
83
-
84
- # Indoor Conditions
85
- with st.expander("Indoor Conditions"):
86
- indoor_cond_type = st.selectbox(
87
- "Indoor Conditions Type",
88
- ["Fixed", "Time-varying", "Adaptive"],
89
- key="indoor_conditions_type"
90
- )
91
- indoor_conditions = {"type": indoor_cond_type}
92
-
93
- if indoor_cond_type == "Fixed":
94
- col1, col2 = st.columns(2)
95
- with col1:
96
- cooling_temp = st.number_input(
97
- "Cooling Setpoint Temperature (°C)",
98
- value=24.0,
99
- min_value=15.0,
100
- max_value=30.0,
101
- step=0.5
102
- )
103
- cooling_rh = st.number_input(
104
- "Cooling Setpoint Relative Humidity (%)",
105
- value=50.0,
106
- min_value=30.0,
107
- max_value=70.0,
108
- step=5.0
109
- )
110
- with col2:
111
- heating_temp = st.number_input(
112
- "Heating Setpoint Temperature (°C)",
113
- value=22.0,
114
- min_value=15.0,
115
- max_value=30.0,
116
- step=0.5
117
- )
118
- heating_rh = st.number_input(
119
- "Heating Setpoint Relative Humidity (%)",
120
- value=50.0,
121
- min_value=30.0,
122
- max_value=70.0,
123
- step=5.0
124
- )
125
- indoor_conditions["cooling_setpoint"] = {"temperature": cooling_temp, "rh": cooling_rh}
126
- indoor_conditions["heating_setpoint"] = {"temperature": heating_temp, "rh": heating_rh}
127
- elif indoor_cond_type == "Time-varying":
128
- st.write("Define Hourly Schedule")
129
- schedule = []
130
- for hour in range(24):
131
- with st.container():
132
- col1, col2, col3 = st.columns([1, 2, 2])
133
- with col1:
134
- st.write(f"Hour {hour}:00")
135
- with col2:
136
- temp = st.number_input(
137
- "Temperature (°C)",
138
- value=24.0,
139
- min_value=15.0,
140
- max_value=30.0,
141
- step=0.5,
142
- key=f"schedule_temp_{hour}"
143
- )
144
- with col3:
145
- rh = st.number_input(
146
- "Relative Humidity (%)",
147
- value=50.0,
148
- min_value=30.0,
149
- max_value=70.0,
150
- step=5.0,
151
- key=f"schedule_rh_{hour}"
152
- )
153
- schedule.append({"hour": hour, "temperature": temp, "rh": rh})
154
- indoor_conditions["schedule"] = schedule
155
-
156
- # Operating Hours
157
- with st.expander("HVAC Operating Hours"):
158
- num_periods = st.number_input(
159
- "Number of Operating Periods",
160
- value=1,
161
- min_value=1,
162
- max_value=5,
163
- step=1
164
- )
165
- operating_periods = []
166
- for i in range(int(num_periods)):
167
- with st.container():
168
- st.write(f"Operating Period {i+1}")
169
- col1, col2 = st.columns(2)
170
- with col1:
171
- start_hour = st.number_input(
172
- "Start Hour",
173
- value=8,
174
- min_value=0,
175
- max_value=23,
176
- step=1,
177
- key=f"start_hour_{i}"
178
- )
179
- with col2:
180
- end_hour = st.number_input(
181
- "End Hour",
182
- value=18,
183
- min_value=0,
184
- max_value=23,
185
- step=1,
186
- key=f"end_hour_{i}"
187
- )
188
- operating_periods.append({"start": start_hour, "end": end_hour})
189
- hvac_settings = {"operating_hours": operating_periods}
190
-
191
- # Calculation trigger
192
- if st.button("Calculate HVAC Loads", key="calculate_hvac_loads"):
193
- try:
194
- results = calculate_loads(
195
- sim_period=sim_period,
196
- indoor_conditions=indoor_conditions,
197
- hvac_settings=hvac_settings
198
- )
199
- st.session_state.project_data["hvac_loads"] = results
200
- st.success("HVAC loads calculated successfully.")
201
- logger.info("HVAC loads calculated.")
202
- except Exception as e:
203
- st.error(f"Error calculating HVAC loads: {e}")
204
- logger.error(f"Error calculating HVAC loads: {e}", exc_info=True)
205
- st.session_state.project_data["hvac_loads"] = None
206
-
207
- # Display results if available
208
- if "hvac_loads" in st.session_state.project_data and st.session_state.project_data["hvac_loads"]:
209
- display_load_results(st.session_state.project_data["hvac_loads"])
210
- else:
211
- st.info("Click the button above to calculate HVAC loads.")
212
-
213
- # Navigation buttons
214
- col1, col2 = st.columns(2)
215
-
216
- with col1:
217
- if st.button("Back to Internal Loads", key="back_to_internal_loads"):
218
- st.session_state.current_page = "Internal Loads"
219
- st.rerun()
220
-
221
- with col2:
222
- if st.button("Continue to Building Energy", key="continue_to_building_energy"):
223
- st.session_state.current_page = "Building Energy"
224
- st.rerun()
225
-
226
- def check_prerequisites() -> bool:
227
- """Check if all prerequisite data is available in session state."""
228
- required_keys = [
229
- "building_info",
230
- "climate_data",
231
- "materials",
232
- "constructions",
233
- "components",
234
- "internal_loads"
235
- ]
236
-
237
- for key in required_keys:
238
- if key not in st.session_state.project_data or not st.session_state.project_data[key]:
239
- logger.warning(f"Prerequisite check failed: Missing data for '{key}'.")
240
- return False
241
-
242
- # Specific checks within components
243
- components = st.session_state.project_data["components"]
244
- if not components.get("walls") and not components.get("roofs") and not components.get("floors"):
245
- logger.warning("Prerequisite check failed: No opaque components defined.")
246
- return False
247
-
248
- # Specific checks within climate data
249
- climate = st.session_state.project_data["climate_data"]
250
- if not climate.get("hourly_data") or not climate.get("design_conditions"):
251
- logger.warning("Prerequisite check failed: Climate data not processed.")
252
- return False
253
-
254
- return True
255
-
256
- def filter_hourly_data(hourly_data: List[Dict], sim_period: Dict, climate_data: Dict) -> List[Dict]:
257
- """Filter hourly data based on simulation period, ignoring year."""
258
- if sim_period["type"] == "Full Year":
259
- return hourly_data
260
- filtered_data = []
261
- if sim_period["type"] == "From-to":
262
- start_month = sim_period["start_date"].month
263
- start_day = sim_period["start_date"].day
264
- end_month = sim_period["end_date"].month
265
- end_day = sim_period["end_date"].day
266
- for data in hourly_data:
267
- month, day = data["month"], data["day"]
268
- if (month > start_month or (month == start_month and day >= start_day)) and \
269
- (month < end_month or (month == end_month and day <= end_day)):
270
- filtered_data.append(data)
271
- elif sim_period["type"] in ["HDD", "CDD"]:
272
- base_temp = sim_period.get("base_temp", 18.3 if sim_period["type"] == "HDD" else 23.9)
273
- for data in hourly_data:
274
- temp = data["dry_bulb"]
275
- if (sim_period["type"] == "HDD" and temp < base_temp) or (sim_period["type"] == "CDD" and temp > base_temp):
276
- filtered_data.append(data)
277
- return filtered_data
278
-
279
- def get_indoor_conditions(indoor_conditions: Dict, hour: int, outdoor_temp: float) -> Dict:
280
- """Determine indoor conditions based on user settings."""
281
- if indoor_conditions["type"] == "Fixed":
282
- mode = "none" if abs(outdoor_temp - 18) < 0.01 else "cooling" if outdoor_temp > 18 else "heating"
283
- if mode == "cooling":
284
- return {
285
- "temperature": indoor_conditions.get("cooling_setpoint", {}).get("temperature", 24.0),
286
- "rh": indoor_conditions.get("cooling_setpoint", {}).get("rh", 50.0)
287
- }
288
- elif mode == "heating":
289
- return {
290
- "temperature": indoor_conditions.get("heating_setpoint", {}).get("temperature", 22.0),
291
- "rh": indoor_conditions.get("heating_setpoint", {}).get("rh", 50.0)
292
- }
293
- else:
294
- return {"temperature": 24.0, "rh": 50.0}
295
- elif indoor_conditions["type"] == "Time-varying":
296
- schedule = indoor_conditions.get("schedule", [])
297
- if schedule:
298
- hour_idx = hour % 24
299
- for entry in schedule:
300
- if entry["hour"] == hour_idx:
301
- return {"temperature": entry["temperature"], "rh": entry["rh"]}
302
- return {"temperature": 24.0, "rh": 50.0}
303
- else: # Adaptive
304
- if 10 <= outdoor_temp <= 33.5:
305
- return {"temperature": 0.31 * outdoor_temp + 17.8, "rh": 50.0}
306
- return {"temperature": 24.0, "rh": 50.0}
307
-
308
- def calculate_loads(sim_period: Dict, indoor_conditions: Dict, hvac_settings: Dict) -> Dict[str, Any]:
309
- """
310
- Perform the main HVAC load calculations with user-defined filters and temperature threshold.
311
-
312
- Args:
313
- sim_period: Simulation period settings.
314
- indoor_conditions: Indoor conditions settings.
315
- hvac_settings: HVAC operating hours settings.
316
-
317
- Returns:
318
- Dictionary containing calculation results.
319
- """
320
- logger.info("Starting HVAC load calculations...")
321
-
322
- # Get data from session state
323
- building_info = st.session_state.project_data["building_info"]
324
- climate_data = st.session_state.project_data["climate_data"]
325
- materials = get_available_materials()
326
- constructions = get_available_constructions()
327
- fenestrations = get_available_fenestrations()
328
- components = st.session_state.project_data["components"]
329
- internal_loads = st.session_state.project_data["internal_loads"]
330
-
331
- # Get design conditions
332
- design_conditions = climate_data["design_conditions"]
333
- hourly_data_list = climate_data["hourly_data"]
334
-
335
- # Filter hourly data based on simulation period
336
- filtered_hourly_data = filter_hourly_data(hourly_data_list, sim_period, climate_data)
337
- hourly_data_df = pd.DataFrame(filtered_hourly_data)
338
- if hourly_data_df.empty:
339
- logger.warning("No hourly data available after filtering.")
340
- return {}
341
-
342
- # --- Solar Calculations --- #
343
- logger.info("Calculating solar geometry...")
344
- latitude = climate_data["location"]["latitude"]
345
- longitude = climate_data["location"]["longitude"]
346
- timezone = climate_data["location"]["timezone"]
347
- building_orientation_angle = building_info["orientation_angle"]
348
-
349
- # Calculate solar angles for filtered hours
350
- solar_angles = calculate_solar_angles(latitude, longitude, timezone, hourly_data_df.index)
351
- hourly_data_df = pd.concat([hourly_data_df, solar_angles], axis=1)
352
-
353
- # Initialize load arrays
354
- num_hours = len(filtered_hourly_data)
355
- cooling_loads = {
356
- "opaque_conduction": np.zeros(num_hours),
357
- "fenestration_conduction": np.zeros(num_hours),
358
- "fenestration_solar": np.zeros(num_hours),
359
- "infiltration": np.zeros(num_hours),
360
- "ventilation": np.zeros(num_hours),
361
- "internal_sensible": np.zeros(num_hours),
362
- "internal_latent": np.zeros(num_hours)
363
  }
364
- heating_loads = {
365
- "opaque_conduction": np.zeros(num_hours),
366
- "fenestration_conduction": np.zeros(num_hours),
367
- "infiltration": np.zeros(num_hours),
368
- "ventilation": np.zeros(num_hours)
 
 
 
 
 
 
 
369
  }
370
-
371
- # --- Load Calculations --- #
372
- operating_periods = hvac_settings.get("operating_hours", [{"start": 8, "end": 18}])
373
- area = building_info["floor_area"]
374
-
375
- for idx, hour_data in enumerate(filtered_hourly_data):
376
- hour = hour_data["hour"]
377
- outdoor_temp = hour_data["dry_bulb"]
378
- indoor_cond = get_indoor_conditions(indoor_conditions, hour, outdoor_temp)
379
- indoor_temp = indoor_cond["temperature"]
 
 
 
 
380
 
381
- # Check if hour is within operating periods
382
- is_operating = False
383
- for period in operating_periods:
384
- start_hour = period.get("start", 8)
385
- end_hour = period.get("end", 18)
386
- if start_hour <= hour % 24 <= end_hour:
387
- is_operating = True
388
- break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
 
390
- # Determine mode based on temperature threshold (18°C)
391
- mode = "none" if abs(outdoor_temp - 18) < 0.01 else "cooling" if outdoor_temp > 18 else "heating"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
392
 
393
- if not is_operating:
394
- continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
395
 
396
- # 1. Opaque Surfaces (Walls, Roofs, Floors)
397
- for comp_type in ["walls", "roofs", "floors"]:
398
- for comp in components.get(comp_type, []):
399
- logger.debug(f"Calculating loads for {comp_type}: {comp['name']}")
400
- construction = constructions[comp['construction']]
401
- u_value = construction['u_value']
402
- area = comp['area']
403
- tilt = comp['tilt']
404
- orientation = comp['orientation']
405
-
406
- # Calculate surface azimuth
407
- surface_azimuth = calculate_surface_azimuth(orientation, building_orientation_angle)
408
-
409
- # Calculate sol-air temperature
410
- sol_air_temp = calculate_sol_air_temperature(
411
- pd.Series([outdoor_temp], index=[idx]),
412
- pd.Series([hour_data.get("direct_normal_radiation", 0)], index=[idx]),
413
- pd.Series([hour_data.get("diffuse_horizontal_radiation", 0)], index=[idx]),
414
- pd.Series([hourly_data_df.loc[idx, "solar_altitude"]], index=[idx]),
415
- pd.Series([hourly_data_df.loc[idx, "solar_azimuth"]], index=[idx]),
416
- tilt,
417
- surface_azimuth,
418
- absorptivity=ABSORPTIVITY_DEFAULT,
419
- emissivity=EMISSIVITY_DEFAULT,
420
- h_o=20.0
421
- )[0]
422
-
423
- # Calculate conduction heat gain/loss
424
- if mode == "cooling":
425
- delta_t = sol_air_temp - indoor_temp
426
- if delta_t > 0:
427
- cooling_loads["opaque_conduction"][idx] += u_value * area * delta_t
428
- elif mode == "heating":
429
- delta_t = indoor_temp - outdoor_temp
430
- if delta_t > 0:
431
- heating_loads["opaque_conduction"][idx] += u_value * area * delta_t
432
 
433
- # 2. Fenestration (Windows, Doors, Skylights)
434
- for comp_type in ["windows", "doors", "skylights"]:
435
- for comp in components.get(comp_type, []):
436
- logger.debug(f"Calculating loads for {comp_type}: {comp['name']}")
437
- fenestration = fenestrations[comp['fenestration']]
438
- u_value = fenestration['u_value']
439
- shgc = fenestration['shgc']
440
- area = comp['area']
441
- tilt = comp['tilt']
442
- orientation = comp['orientation']
443
 
444
- # Calculate surface azimuth
445
- surface_azimuth = calculate_surface_azimuth(orientation, building_orientation_angle)
 
 
 
 
446
 
447
- # Calculate incident solar radiation
448
- incident_solar = calculate_incident_solar(
449
- pd.Series([hour_data.get("direct_normal_radiation", 0)], index=[idx]),
450
- pd.Series([hour_data.get("diffuse_horizontal_radiation", 0)], index=[idx]),
451
- pd.Series([hourly_data_df.loc[idx, "solar_altitude"]], index=[idx]),
452
- pd.Series([hourly_data_df.loc[idx, "solar_azimuth"]], index=[idx]),
453
- tilt,
454
- surface_azimuth
455
- )[0]
456
 
457
- # Calculate conduction heat gain/loss
458
- if mode == "cooling":
459
- delta_t = outdoor_temp - indoor_temp
460
- if delta_t > 0:
461
- cooling_loads["fenestration_conduction"][idx] += u_value * area * delta_t
462
- elif mode == "heating":
463
- delta_t = indoor_temp - outdoor_temp
464
- if delta_t > 0:
465
- heating_loads["fenestration_conduction"][idx] += u_value * area * delta_t
466
 
467
- # Calculate solar heat gain
468
- if mode == "cooling":
469
- solar_gain = shgc * area * incident_solar
470
- cooling_loads["fenestration_solar"][idx] += solar_gain
471
-
472
- # 3. Infiltration
473
- if mode in ["cooling", "heating"]:
474
- logger.info("Calculating infiltration loads...")
475
- volume = building_info["floor_area"] * building_info["building_height"]
476
- ach = 0.5
477
- infiltration_rate_m3s = volume * ach / 3600.0
478
-
479
- delta_t = outdoor_temp - indoor_temp if mode == "cooling" else indoor_temp - outdoor_temp
480
- if delta_t > 0:
481
- infiltration_sensible = AIR_DENSITY * AIR_SPECIFIC_HEAT * infiltration_rate_m3s * delta_t
482
- if mode == "cooling":
483
- cooling_loads["infiltration"][idx] += infiltration_sensible
484
  else:
485
- heating_loads["infiltration"][idx] += infiltration_sensible
486
-
487
- # 4. Ventilation
488
- if mode in ["cooling", "heating"]:
489
- logger.info("Calculating ventilation loads...")
490
- ventilation_rate_lps = building_info["ventilation_rate"]
491
- ventilation_rate_m3s = ventilation_rate_lps * building_info["floor_area"] / 1000.0
492
-
493
- delta_t = outdoor_temp - indoor_temp if mode == "cooling" else indoor_temp - outdoor_temp
494
- if delta_t > 0:
495
- ventilation_sensible = AIR_DENSITY * AIR_SPECIFIC_HEAT * ventilation_rate_m3s * delta_t
496
- if mode == "cooling":
497
- cooling_loads["ventilation"][idx] += ventilation_sensible
 
 
 
 
 
 
 
 
 
 
 
 
 
498
  else:
499
- heating_loads["ventilation"][idx] += ventilation_sensible
500
-
501
- # 5. Internal Loads
502
- if mode == "cooling":
503
- logger.info("Calculating internal loads...")
504
- internal_sensible, internal_latent = calculate_hourly_internal_loads(internal_loads)
505
- cooling_loads["internal_sensible"][idx] = internal_sensible[idx % 8760]
506
- cooling_loads["internal_latent"][idx] = internal_latent[idx % 8760]
507
-
508
- # --- Total Loads --- #
509
- logger.info("Summing up loads...")
510
- total_cooling_sensible = (
511
- cooling_loads["opaque_conduction"] +
512
- cooling_loads["fenestration_conduction"] +
513
- cooling_loads["fenestration_solar"] +
514
- cooling_loads["infiltration"] +
515
- cooling_loads["ventilation"] +
516
- cooling_loads["internal_sensible"]
517
- )
518
- total_cooling_latent = cooling_loads["internal_latent"]
519
- total_cooling = total_cooling_sensible + total_cooling_latent
520
-
521
- total_heating_loss = (
522
- heating_loads["opaque_conduction"] +
523
- heating_loads["fenestration_conduction"] +
524
- heating_loads["infiltration"] +
525
- heating_loads["ventilation"]
526
- )
527
- total_heating_needed = np.maximum(0, total_heating_loss - cooling_loads["internal_sensible"])
528
-
529
- # --- Daily Control --- #
530
- loads_by_day = defaultdict(list)
531
- for idx, hour_data in enumerate(filtered_hourly_data):
532
- day_key = (hour_data["month"], hour_data["day"])
533
- loads_by_day[day_key].append({
534
- "hour": hour_data["hour"],
535
- "month": hour_data["month"],
536
- "day": hour_data["day"],
537
- "total_cooling": total_cooling[idx],
538
- "total_heating": total_heating_needed[idx],
539
- "cooling_components": {k: v[idx] for k, v in cooling_loads.items()},
540
- "heating_components": {k: v[idx] for k, v in heating_loads.items()}
541
- })
542
-
543
- final_loads = []
544
- for day_key, day_loads in loads_by_day.items():
545
- cooling_hours = sum(1 for load in day_loads if load["total_cooling"] > 0)
546
- heating_hours = sum(1 for load in day_loads if load["total_heating"] > 0)
547
- for load in day_loads:
548
- if cooling_hours > heating_hours:
549
- load["total_heating"] = 0
550
- elif heating_hours > cooling_hours:
551
- load["total_cooling"] = 0
552
- else:
553
- load["total_cooling"] = 0
554
- load["total_heating"] = 0
555
- final_loads.append(load)
556
-
557
- # --- Peak Loads --- #
558
- logger.info("Calculating peak loads...")
559
- hourly_cooling_sensible = np.array([load["cooling_components"]["opaque_conduction"] +
560
- load["cooling_components"]["fenestration_conduction"] +
561
- load["cooling_components"]["fenestration_solar"] +
562
- load["cooling_components"]["infiltration"] +
563
- load["cooling_components"]["ventilation"] +
564
- load["cooling_components"]["internal_sensible"]
565
- for load in final_loads])
566
- hourly_cooling_latent = np.array([load["cooling_components"]["internal_latent"] for load in final_loads])
567
- hourly_cooling_total = hourly_cooling_sensible + hourly_cooling_latent
568
- hourly_heating = np.array([load["total_heating"] for load in final_loads])
569
-
570
- peak_cooling_sensible = np.max(hourly_cooling_sensible) if len(hourly_cooling_sensible) > 0 else 0
571
- peak_cooling_latent = np.max(hourly_cooling_latent) if len(hourly_cooling_latent) > 0 else 0
572
- peak_cooling_total = np.max(hourly_cooling_total) if len(hourly_cooling_total) > 0 else 0
573
- peak_heating = np.max(hourly_heating) if len(hourly_heating) > 0 else 0
574
-
575
- peak_cooling_sensible_hour = np.argmax(hourly_cooling_sensible) if len(hourly_cooling_sensible) > 0 else 0
576
- peak_cooling_latent_hour = np.argmax(hourly_cooling_latent) if len(hourly_cooling_latent) > 0 else 0
577
- peak_cooling_total_hour = np.argmax(hourly_cooling_total) if len(hourly_cooling_total) > 0 else 0
578
- peak_heating_hour = np.argmax(hourly_heating) if len(hourly_heating) > 0 else 0
579
-
580
- results = {
581
- "hourly_cooling_sensible": hourly_cooling_sensible.tolist(),
582
- "hourly_cooling_latent": hourly_cooling_latent.tolist(),
583
- "hourly_cooling_total": hourly_cooling_total.tolist(),
584
- "hourly_heating": hourly_heating.tolist(),
585
- "cooling_load_components": {k: [load["cooling_components"][k] for load in final_loads] for k in cooling_loads},
586
- "heating_load_components": {k: [load["heating_components"][k] for load in final_loads] for k in heating_loads},
587
- "peak_cooling_sensible": peak_cooling_sensible,
588
- "peak_cooling_latent": peak_cooling_latent,
589
- "peak_cooling_total": peak_cooling_total,
590
- "peak_heating": peak_heating,
591
- "peak_cooling_sensible_hour": int(peak_cooling_sensible_hour),
592
- "peak_cooling_latent_hour": int(peak_cooling_latent_hour),
593
- "peak_cooling_total_hour": int(peak_cooling_total_hour),
594
- "peak_heating_hour": int(peak_heating_hour),
595
- "filtered_hours": [{"month": load["month"], "day": load["day"], "hour": load["hour"]} for load in final_loads]
596
- }
597
-
598
- logger.info("HVAC load calculations completed.")
599
- return results
600
-
601
- def calculate_solar_angles(latitude: float, longitude: float, timezone: float, index: pd.Index) -> pd.DataFrame:
602
- """
603
- Calculate solar altitude and azimuth angles for given location and time.
604
- Uses formulas from Duffie & Beckman, Solar Engineering of Thermal Processes.
605
-
606
- Args:
607
- latitude: Latitude in degrees
608
- longitude: Longitude in degrees (East positive)
609
- timezone: Timezone offset from UTC in hours
610
- index: Pandas Index for the hours
611
-
612
- Returns:
613
- DataFrame with solar altitude and azimuth angles in degrees.
614
- """
615
- # Convert angles to radians
616
- lat_rad = np.radians(latitude)
617
-
618
- # Day of year
619
- day_of_year = pd.Series(index).apply(lambda x: pd.Timestamp(x).dayofyear)
620
-
621
- # Equation of time (simplified)
622
- b = 2 * np.pi * (day_of_year - 81) / 364
623
- eot = 9.87 * np.sin(2 * b) - 7.53 * np.cos(b) - 1.5 * np.sin(b) # minutes
624
-
625
- # Local Standard Time Meridian (LSTM)
626
- lstm = 15 * timezone
627
-
628
- # Time Correction Factor (TC)
629
- tc = 4 * (longitude - lstm) + eot # minutes
630
-
631
- # Local Solar Time (LST)
632
- local_time_hour = pd.Series(index).apply(lambda x: pd.Timestamp(x).hour + pd.Timestamp(x).minute / 60.0)
633
- lst = local_time_hour + tc / 60.0
634
-
635
- # Hour Angle (HRA)
636
- hra = 15 * (lst - 12) # degrees
637
- hra_rad = np.radians(hra)
638
-
639
- # Declination Angle
640
- declination_rad = np.radians(23.45 * np.sin(2 * np.pi * (284 + day_of_year) / 365))
641
-
642
- # Solar Altitude (alpha)
643
- sin_alpha = np.sin(lat_rad) * np.sin(declination_rad) + \
644
- np.cos(lat_rad) * np.cos(declination_rad) * np.cos(hra_rad)
645
- solar_altitude_rad = np.arcsin(np.clip(sin_alpha, -1, 1))
646
- solar_altitude_deg = np.degrees(solar_altitude_rad)
647
-
648
- # Solar Azimuth (gamma_s) - measured from South, positive West
649
- cos_gamma_s = (np.sin(solar_altitude_rad) * np.sin(lat_rad) - np.sin(declination_rad)) / \
650
- (np.cos(solar_altitude_rad) * np.cos(lat_rad))
651
- solar_azimuth_rad = np.arccos(np.clip(cos_gamma_s, -1, 1))
652
- solar_azimuth_deg = np.degrees(solar_azimuth_rad)
653
- # Adjust azimuth based on hour angle
654
- solar_azimuth_deg = np.where(hra > 0, solar_azimuth_deg, 360 - solar_azimuth_deg)
655
- # Convert to azimuth from North, positive East
656
- solar_azimuth_deg = (solar_azimuth_deg + 180) % 360
657
-
658
- return pd.DataFrame({
659
- "solar_altitude": solar_altitude_deg,
660
- "solar_azimuth": solar_azimuth_deg
661
- }, index=index)
662
-
663
- def calculate_surface_azimuth(orientation: str, building_orientation_angle: float) -> float:
664
- """
665
- Calculate the actual surface azimuth based on orientation label and building rotation.
666
- Azimuth: 0=N, 90=E, 180=S, 270=W
667
-
668
- Args:
669
- orientation: Orientation label (e.g., "A (North)", "B (South)")
670
- building_orientation_angle: Building rotation angle from North (degrees, positive East)
671
-
672
- Returns:
673
- Surface azimuth in degrees.
674
- """
675
- base_azimuth = {
676
- "A (North)": 0.0,
677
- "B (South)": 180.0,
678
- "C (East)": 90.0,
679
- "D (West)": 270.0,
680
- "Horizontal": 0.0
681
- }.get(orientation, 0.0)
682
-
683
- # Adjust for building rotation
684
- surface_azimuth = (base_azimuth + building_orientation_angle) % 360
685
- return surface_azimuth
686
-
687
- def calculate_incident_solar(dnr: pd.Series, dhr: pd.Series, solar_altitude: pd.Series, solar_azimuth: pd.Series, tilt: float, surface_azimuth: float) -> pd.Series:
688
- """
689
- Calculate total incident solar radiation on a tilted surface.
690
-
691
- Args:
692
- dnr: Direct Normal Radiation (W/m²)
693
- dhr: Diffuse Horizontal Radiation (W/m²)
694
- solar_altitude: Solar altitude angle (degrees)
695
- solar_azimuth: Solar azimuth angle (degrees, 0=N, positive E)
696
- tilt: Surface tilt angle from horizontal (degrees)
697
- surface_azimuth: Surface azimuth angle (degrees, 0=N, positive E)
698
-
699
- Returns:
700
- Total incident solar radiation on the surface (W/m²).
701
- """
702
- # Convert angles to radians
703
- alt_rad = np.radians(solar_altitude)
704
- az_rad = np.radians(solar_azimuth)
705
- tilt_rad = np.radians(tilt)
706
- surf_az_rad = np.radians(surface_azimuth)
707
-
708
- # Angle of Incidence (theta)
709
- cos_theta = np.cos(alt_rad) * np.sin(tilt_rad) * np.cos(az_rad - surf_az_rad) + \
710
- np.sin(alt_rad) * np.cos(tilt_rad)
711
- cos_theta = np.maximum(0, cos_theta)
712
-
713
- # Direct radiation component
714
- direct_tilted = dnr * cos_theta
715
-
716
- # Diffuse radiation component (simplified isotropic sky model)
717
- sky_diffuse_tilted = dhr * (1 + np.cos(tilt_rad)) / 2
718
-
719
- # Ground reflected component
720
- albedo = 0.2
721
- ground_reflected_tilted = (dnr * np.sin(alt_rad) + dhr) * albedo * (1 - np.cos(tilt_rad)) / 2
722
-
723
- # Total incident solar radiation
724
- total_incident = direct_tilted + sky_diffuse_tilted + ground_reflected_tilted
725
- return total_incident
726
-
727
- def calculate_sol_air_temperature(t_oa: pd.Series, dnr: pd.Series, dhr: pd.Series, solar_altitude: pd.Series, solar_azimuth: pd.Series, tilt: float, surface_azimuth: float, absorptivity: float, emissivity: float, h_o: float) -> pd.Series:
728
- """
729
- Calculate Sol-Air Temperature.
730
-
731
- Args:
732
- t_oa: Outdoor air temperature (°C)
733
- dnr, dhr, solar_altitude, solar_azimuth: Solar data
734
- tilt, surface_azimuth: Surface orientation
735
- absorptivity: Surface solar absorptivity
736
- emissivity: Surface thermal emissivity
737
- h_o: Outside surface heat transfer coefficient (W/m²·K)
738
-
739
- Returns:
740
- Sol-air temperature (°C).
741
- """
742
- # Calculate incident solar radiation
743
- i_total = calculate_incident_solar(dnr, dhr, solar_altitude, solar_azimuth, tilt, surface_azimuth)
744
-
745
- # Calculate sky temperature
746
- t_sky = t_oa * (SKY_TEMP_FACTOR * t_oa**0.25)**0.25
747
-
748
- # Longwave radiation exchange term
749
- delta_r = STEFAN_BOLTZMANN * emissivity * ((t_oa + 273.15)**4 - (t_sky + 273.15)**4) / h_o
750
-
751
- # Sol-air temperature
752
- t_sol_air = t_oa + (absorptivity * i_total / h_o) - delta_r
753
- return t_sol_air
754
-
755
- def calculate_hourly_internal_loads(internal_loads_data: Dict[str, List[Dict[str, Any]]]) -> Tuple[np.ndarray, np.ndarray]:
756
- """
757
- Calculate total hourly sensible and latent internal loads.
758
-
759
- Args:
760
- internal_loads_data: Dictionary containing lists of loads for each type.
761
 
762
- Returns:
763
- Tuple of (hourly_sensible_loads, hourly_latent_loads) as numpy arrays.
764
- """
765
- hourly_sensible = np.zeros(8760)
766
- hourly_latent = np.zeros(8760)
767
-
768
- # Process each load type
769
- for load_type, loads in internal_loads_data.items():
770
- for load in loads:
771
- total_load = load["total"]
772
- schedule_type = load["schedule_type"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
773
 
774
- # Get schedule multipliers
775
- schedule_multipliers = np.zeros(8760)
776
- if schedule_type == "Custom" and "custom_schedule" in load:
777
- weekday_schedule = load["custom_schedule"]["Weekday"]
778
- weekend_schedule = load["custom_schedule"]["Weekend"]
779
- elif schedule_type in DEFAULT_SCHEDULES:
780
- weekday_schedule = DEFAULT_SCHEDULES[schedule_type]["Weekday"]
781
- weekend_schedule = DEFAULT_SCHEDULES[schedule_type]["Weekend"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
782
  else:
783
- weekday_schedule = [1.0] * 24
784
- weekend_schedule = [1.0] * 24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
785
 
786
- for hour in range(8760):
787
- day_of_week = (hour // 24) % 7
788
- hour_of_day = hour % 24
789
- if day_of_week < 5:
790
- schedule_multipliers[hour] = weekday_schedule[hour_of_day]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
791
  else:
792
- schedule_multipliers[hour] = weekend_schedule[hour_of_day]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
793
 
794
- if load_type == "occupancy":
795
- sensible_fraction = load["sensible_fraction"]
796
- latent_fraction = load["latent_fraction"]
797
- hourly_sensible += total_load * sensible_fraction * schedule_multipliers
798
- hourly_latent += total_load * latent_fraction * schedule_multipliers
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
799
  else:
800
- hourly_sensible += total_load * schedule_multipliers
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
801
 
802
- return hourly_sensible, hourly_latent
803
-
804
- def display_load_results(results: Dict[str, Any]):
805
- """
806
- Display the calculated HVAC load results.
807
-
808
- Args:
809
- results: Dictionary containing calculation results.
810
- """
811
- st.subheader("Peak Load Summary")
812
-
813
- col1, col2, col3 = st.columns(3)
814
- with col1:
815
- st.metric("Peak Cooling (Total)", f"{results['peak_cooling_total'] / 1000:.1f} kW", help=f"Occurred at hour {results['peak_cooling_total_hour']}")
816
- st.metric("Peak Cooling (Sensible)", f"{results['peak_cooling_sensible'] / 1000:.1f} kW", help=f"Occurred at hour {results['peak_cooling_sensible_hour']}")
817
- with col2:
818
- st.metric("Peak Heating", f"{results['peak_heating'] / 1000:.1f} kW", help=f"Occurred at hour {results['peak_heating_hour']}")
819
- st.metric("Peak Cooling (Latent)", f"{results['peak_cooling_latent'] / 1000:.1f} kW", help=f"Occurred at hour {results['peak_cooling_latent_hour']}")
820
-
821
- st.subheader("Hourly Load Profiles")
822
-
823
- # Create DataFrame for plotting
824
- hourly_df = pd.DataFrame({
825
- "Hour": range(len(results["hourly_cooling_total"])),
826
- "Cooling (Sensible)": results["hourly_cooling_sensible"],
827
- "Cooling (Latent)": results["hourly_cooling_latent"],
828
- "Cooling (Total)": results["hourly_cooling_total"],
829
- "Heating": results["hourly_heating"]
830
- })
831
-
832
- # Plot cooling loads
833
- fig_cooling = go.Figure()
834
- fig_cooling.add_trace(go.Scatter(x=hourly_df["Hour"], y=hourly_df["Cooling (Sensible)"], name="Sensible Cooling", stackgroup='one'))
835
- fig_cooling.add_trace(go.Scatter(x=hourly_df["Hour"], y=hourly_df["Cooling (Latent)"], name="Latent Cooling", stackgroup='one'))
836
- fig_cooling.update_layout(title="Hourly Cooling Load Profile", xaxis_title="Hour Index", yaxis_title="Load (W)", height=400)
837
- st.plotly_chart(fig_cooling, use_container_width=True)
838
-
839
- # Plot heating loads
840
- fig_heating = go.Figure()
841
- fig_heating.add_trace(go.Scatter(x=hourly_df["Hour"], y=hourly_df["Heating"], name="Heating Load", line=dict(color='red')))
842
- fig_heating.update_layout(title="Hourly Heating Load Profile", xaxis_title="Hour Index", yaxis_title="Load (W)", height=400)
843
- st.plotly_chart(fig_heating, use_container_width=True)
844
-
845
- st.subheader("Load Components at Peak Cooling Hour")
846
- peak_hour = results["peak_cooling_total_hour"]
847
-
848
- cooling_components = results["cooling_load_components"]
849
- peak_cooling_data = {
850
- "Component": list(cooling_components.keys()),
851
- "Load (W)": [cooling_components[k][peak_hour] for k in cooling_components]
852
- }
853
- peak_cooling_df = pd.DataFrame(peak_cooling_data)
854
- peak_cooling_df = peak_cooling_df[peak_cooling_df["Load (W)"] > 0]
855
-
856
- fig_peak_cooling = px.pie(
857
- peak_cooling_df,
858
- values="Load (W)",
859
- names="Component",
860
- title=f"Cooling Load Breakdown at Peak Hour ({peak_hour})"
861
- )
862
- st.plotly_chart(fig_peak_cooling, use_container_width=True)
863
-
864
- st.subheader("Load Components at Peak Heating Hour")
865
- peak_hour_heating = results["peak_heating_hour"]
866
-
867
- heating_components = results["heating_load_components"]
868
- internal_gains_at_peak = results["cooling_load_components"]["internal_sensible"][peak_hour_heating]
869
-
870
- peak_heating_data = {
871
- "Component": list(heating_components.keys()) + ["Internal Gains Offset"],
872
- "Load (W)": [heating_components[k][peak_hour_heating] for k in heating_components] + [-internal_gains_at_peak]
873
- }
874
- peak_heating_df = pd.DataFrame(peak_heating_data)
875
- peak_heating_df = peak_heating_df[peak_heating_df["Load (W)"] != 0]
876
-
877
- fig_peak_heating = px.pie(
878
- peak_heating_df,
879
- values="Load (W)",
880
- names="Component",
881
- title=f"Heating Load Breakdown at Peak Hour ({peak_hour_heating})"
882
- )
883
- st.plotly_chart(fig_peak_heating, use_container_width=True)
884
-
885
- def get_available_materials() -> Dict[str, Any]:
886
- mats = {}
887
- if "materials" in st.session_state.project_data:
888
- mats.update(st.session_state.project_data["materials"].get("library", {}))
889
- mats.update(st.session_state.project_data["materials"].get("project", {}))
890
- return mats
891
-
892
- def get_available_constructions() -> Dict[str, Any]:
893
- consts = {}
894
- if "constructions" in st.session_state.project_data:
895
- consts.update(st.session_state.project_data["constructions"].get("library", {}))
896
- consts.update(st.session_state.project_data["constructions"].get("project", {}))
897
- return consts
898
-
899
- def get_available_fenestrations() -> Dict[str, Any]:
900
- fens = {}
901
- if "fenestrations" in st.session_state.project_data:
902
- fens.update(st.session_state.project_data["fenestrations"].get("library", {}))
903
- fens.update(st.session_state.project_data["fenestrations"].get("project", {}))
904
- return fens
905
-
906
- def display_hvac_loads_help():
907
- """
908
- Display help information for the HVAC loads page.
909
- """
910
- st.markdown("""
911
- ### HVAC Loads Help
912
-
913
- This section calculates the building's heating and cooling loads based on the information provided in the previous steps.
914
-
915
- **Calculation Process:**
916
-
917
- 1. **Heat Transfer**: Calculates heat gains and losses through the building envelope (walls, roofs, floors, windows, doors, skylights) considering conduction and solar radiation.
918
- 2. **Infiltration & Ventilation**: Calculates loads due to air exchange with the outside.
919
- 3. **Internal Loads**: Incorporates heat gains from occupants, lighting, and equipment.
920
- 4. **Summation**: Combines all heat gains and losses to determine the net hourly cooling and heating loads.
921
-
922
- **Simulation Parameters:**
923
-
924
- * **Simulation Period**: Choose 'Full Year', a specific date range ('From-to'), or degree-day methods ('HDD' or 'CDD').
925
- * **Indoor Conditions**: Set fixed setpoints, time-varying schedules, or adaptive comfort temperatures.
926
- * **Operating Hours**: Define when the HVAC system operates.
927
-
928
- **Results:**
929
-
930
- * **Peak Loads**: Shows the maximum calculated cooling and heating loads.
931
- * **Hourly Profiles**: Displays graphs of loads for the selected period.
932
- * **Load Components**: Shows the breakdown of loads at peak hours.
933
-
934
- **Workflow:**
935
-
936
- 1. Complete all previous sections.
937
- 2. Configure simulation parameters.
938
- 3. Click "Calculate HVAC Loads".
939
- 4. Review results.
940
- 5. Proceed to Building Energy section.
941
-
942
- **Important:**
943
-
944
- * Calculations follow ASHRAE methodology.
945
- * Input data quality affects accuracy.
946
- * Review results carefully.
947
- """)
 
1
  """
2
+ HVAC Calculator Code Documentation
 
 
 
 
3
 
4
  Developed by: Dr Majed Abuseif, Deakin University
5
  © 2025
6
  """
7
 
 
 
8
  import numpy as np
9
+ import pandas as pd
10
+ from typing import Dict, List, Optional, NamedTuple, Any, Tuple
11
+ from enum import Enum
12
+ import streamlit as st
13
+ from data.material_library import Construction, GlazingMaterial, DoorMaterial, Material, MaterialLibrary
14
+ from data.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
18
+ import math
19
+ from utils.ctf_calculations import CTFCalculator, ComponentType, CTFCoefficients
20
 
21
  # Configure logging
22
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
23
  logger = logging.getLogger(__name__)
24
 
25
+ class TFMCalculations:
26
+ # Solar calculation constants (from solar.py)
27
+ SHGC_COEFFICIENTS = {
28
+ "Single Clear": [0.1, -0.0, 0.0, -0.0, 0.0, 0.87],
29
+ "Single Tinted": [0.12, -0.0, 0.0, -0.0, 0.8, -0.0],
30
+ "Double Clear": [0.14, -0.0, 0.0, -0.0, 0.78, -0.0],
31
+ "Double Low-E": [0.2, -0.0, 0.0, 0.7, 0.0, -0.0],
32
+ "Double Tinted": [0.15, -0.0, 0.0, -0.0, 0.65, -0.0],
33
+ "Double Low-E with Argon": [0.18, -0.0, 0.0, 0.68, 0.0, -0.0],
34
+ "Single Low-E Reflective": [0.22, -0.0, 0.0, 0.6, 0.0, -0.0],
35
+ "Double Reflective": [0.24, -0.0, 0.0, 0.58, 0.0, -0.0],
36
+ "Electrochromic": [0.25, -0.0, 0.5, -0.0, 0.0, -0.0]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  }
38
+
39
+ GLAZING_TYPE_MAPPING = {
40
+ "Single Clear 3mm": "Single Clear",
41
+ "Single Clear 6mm": "Single Clear",
42
+ "Single Tinted 6mm": "Single Tinted",
43
+ "Double Clear 6mm/13mm Air": "Double Clear",
44
+ "Double Low-E 6mm/13mm Air": "Double Low-E",
45
+ "Double Tinted 6mm/13mm Air": "Double Tinted",
46
+ "Double Low-E 6mm/13mm Argon": "Double Low-E with Argon",
47
+ "Single Low-E Reflective 6mm": "Single Low-E Reflective",
48
+ "Double Reflective 6mm/13mm Air": "Double Reflective",
49
+ "Electrochromic 6mm/13mm Air": "Electrochromic"
50
  }
51
+
52
+ @staticmethod
53
+ def calculate_conduction_load(component, outdoor_temp: float, indoor_temp: float, hour: int, mode: str = "none") -> tuple[float, float]:
54
+ """Calculate conduction load for heating and cooling in kW based on mode."""
55
+ if mode == "none":
56
+ return 0, 0
57
+ delta_t = outdoor_temp - indoor_temp
58
+ if mode == "cooling" and delta_t <= 0:
59
+ return 0, 0
60
+ if mode == "heating" and delta_t >= 0:
61
+ return 0, 0
62
+
63
+ # Get CTF coefficients using CTFCalculator
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
95
+ return sum(days_in_month[:month-1]) + day
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) -
113
+ 0.014615 * math.cos(2 * B_rad) - 0.04089 * math.sin(2 * B_rad))
114
+ return EOT
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
139
+
140
+ @staticmethod
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, project_door_materials: Dict) -> Tuple[float, float, float, Optional[float], float]:
144
+ """
145
+ Determine surface parameters (tilt, azimuth, h_o, emissivity, solar_absorption) for a component.
146
+ Uses MaterialLibrary to fetch properties from first layer for walls/roofs, DoorMaterial for doors,
147
+ and GlazingMaterial for windows/skylights. Handles orientation and tilt based on component type:
148
+ - Walls, Doors, Windows: Azimuth = elevation base azimuth + component.rotation; Tilt = 90°.
149
+ - Roofs, Skylights: Azimuth = component.orientation; Tilt = component.tilt (default 180°).
150
 
151
+ Args:
152
+ component: Component object with component_type, elevation, rotation, orientation, tilt,
153
+ construction, glazing_material, or door_material.
154
+ building_info (Dict): Building information containing orientation_angle for elevation mapping.
155
+ material_library: MaterialLibrary instance for accessing library materials/constructions.
156
+ project_materials: Dict of project-specific Material objects.
157
+ project_constructions: Dict of project-specific Construction objects.
158
+ project_glazing_materials: Dict of project-specific GlazingMaterial objects.
159
+ project_door_materials: Dict of project-specific DoorMaterial objects.
160
+
161
+ Returns:
162
+ Tuple[float, float, float, Optional[float], float]: Surface tilt (°), surface azimuth (°),
163
+ h_o (W/m²·K), emissivity, solar_absorption.
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, doors
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
+ solar_absorption = 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
+ # For roofs, use orientation directly
181
+ surface_azimuth = getattr(component, 'orientation', 0.0)
182
+ logger.debug(f"Roof component {component_name}: using orientation={surface_azimuth}, tilt={surface_tilt}")
 
 
183
 
184
+ elif component.component_type == ComponentType.SKYLIGHT:
185
+ surface_tilt = getattr(component, 'tilt', 0.0) # Horizontal, upward if tilt absent
186
+ h_o = 23.0 # W/m²·K for skylights
187
+ # For skylights, use orientation directly, not elevation
188
+ surface_azimuth = getattr(component, 'orientation', 0.0)
189
+ logger.debug(f"Skylight component {component_name}: using orientation={surface_azimuth}, tilt={surface_tilt}")
190
 
191
+ elif component.component_type == ComponentType.FLOOR:
192
+ surface_tilt = 180.0 # Horizontal, downward
193
+ h_o = 17.0 # W/m²·K
194
+ surface_azimuth = 0.0 # Default azimuth for floors
195
+ logger.debug(f"Floor component {component_name}: using default azimuth={surface_azimuth}, tilt={surface_tilt}")
 
 
 
 
196
 
197
+ else: # WALL, DOOR, WINDOW
198
+ surface_tilt = 90.0 # Vertical
199
+ h_o = 17.0 # W/m²·K
 
 
 
 
 
 
200
 
201
+ # Check for elevation attribute
202
+ elevation = getattr(component, 'elevation', None)
203
+ if not elevation:
204
+ logger.warning(f"Component {component_name} ({component.component_type.value}) is missing 'elevation' field. Using default azimuth=0.")
205
+ surface_azimuth = 0.0 # Default to north-facing if elevation is missing
 
 
 
 
 
 
 
 
 
 
 
 
206
  else:
207
+ # Define elevation azimuths based on building orientation_angle
208
+ base_azimuth = building_info.get("orientation_angle", 0.0)
209
+ elevation_angles = {
210
+ "A": base_azimuth,
211
+ "B": (base_azimuth + 90.0) % 360,
212
+ "C": (base_azimuth + 180.0) % 360,
213
+ "D": (base_azimuth + 270.0) % 360
214
+ }
215
+
216
+ if elevation not in elevation_angles:
217
+ logger.warning(f"Invalid elevation '{elevation}' for component {component_name} ({component.component_type.value}). "
218
+ f"Expected one of {list(elevation_angles.keys())}. Using default azimuth=0.")
219
+ surface_azimuth = 0.0 # Default to north-facing if elevation is invalid
220
+ else:
221
+ # Add component rotation to elevation azimuth
222
+ surface_azimuth = (elevation_angles[elevation] + getattr(component, 'rotation', 0.0)) % 360
223
+ logger.debug(f"Component {component_name} ({component.component_type.value}): elevation={elevation}, "
224
+ f"base_azimuth={elevation_angles[elevation]}, rotation={getattr(component, 'rotation', 0.0)}, "
225
+ f"total_azimuth={surface_azimuth}, tilt={surface_tilt}")
226
+
227
+ # Fetch material properties
228
+ if component.component_type in [ComponentType.WALL, ComponentType.ROOF]:
229
+ construction = getattr(component, 'construction', None)
230
+ if not construction:
231
+ logger.warning(f"No construction defined for {component_name} ({component.component_type.value}). "
232
+ f"Using defaults: solar_absorption=0.6, emissivity=0.9.")
233
  else:
234
+ # Get construction from library or project
235
+ construction_obj = None
236
+ if hasattr(construction, 'name'):
237
+ construction_obj = (project_constructions.get(construction.name) or
238
+ material_library.library_constructions.get(construction.name))
239
+
240
+ if not construction_obj:
241
+ logger.warning(f"Construction not found for {component_name} ({component.component_type.value}). "
242
+ f"Using defaults: solar_absorption=0.6, emissivity=0.9.")
243
+ elif not construction_obj.layers:
244
+ logger.warning(f"No layers in construction for {component_name} ({component.component_type.value}). "
245
+ f"Using defaults: solar_absorption=0.6, emissivity=0.9.")
246
+ else:
247
+ # Use first (outermost) layer's properties
248
+ first_layer = construction_obj.layers[0]
249
+ material = first_layer.get("material")
250
+ if material:
251
+ solar_absorption = getattr(material, 'solar_absorption', 0.6)
252
+ emissivity = getattr(material, 'emissivity', 0.9)
253
+ logger.debug(f"Using first layer material for {component_name} ({component.component_type.value}): "
254
+ f"solar_absorption={solar_absorption}, emissivity={emissivity}")
255
+
256
+ elif component.component_type == ComponentType.DOOR:
257
+ door_material = getattr(component, 'door_material', None)
258
+ if not door_material:
259
+ logger.warning(f"No door material defined for {component_name} ({component.component_type.value}). "
260
+ f"Using defaults: solar_absorption=0.6, emissivity=0.9.")
261
+ else:
262
+ # Get door material from library or project
263
+ door_material_obj = None
264
+ if hasattr(door_material, 'name'):
265
+ door_material_obj = (project_door_materials.get(door_material.name) or
266
+ material_library.library_door_materials.get(door_material.name))
267
+
268
+ if not door_material_obj:
269
+ logger.warning(f"Door material not found for {component_name} ({component.component_type.value}). "
270
+ f"Using defaults: solar_absorption=0.6, emissivity=0.9.")
271
+ else:
272
+ solar_absorption = getattr(door_material_obj, 'solar_absorption', 0.6)
273
+ emissivity = getattr(door_material_obj, 'emissivity', 0.9)
274
+ logger.debug(f"Using door material for {component_name} ({component.component_type.value}): "
275
+ f"solar_absorption={solar_absorption}, emissivity={emissivity}")
276
+
277
+ elif component.component_type in [ComponentType.WINDOW, ComponentType.SKYLIGHT]:
278
+ glazing_material = getattr(component, 'glazing_material', None)
279
+ if not glazing_material:
280
+ logger.warning(f"No glazing material defined for {component_name} ({component.component_type.value}). "
281
+ f"Using default SHGC=0.7, h_o={h_o}.")
282
+ shgc = 0.7
283
+ else:
284
+ # Get glazing material from library or project
285
+ glazing_material_obj = None
286
+ if hasattr(glazing_material, 'name'):
287
+ glazing_material_obj = (project_glazing_materials.get(glazing_material.name) or
288
+ material_library.library_glazing_materials.get(glazing_material.name))
289
+
290
+ if not glazing_material_obj:
291
+ logger.warning(f"Glazing material not found for {component_name} ({component.component_type.value}). "
292
+ f"Using default SHGC=0.7, h_o={h_o}.")
293
+ shgc = 0.7
294
+ else:
295
+ shgc = getattr(glazing_material_obj, 'shgc', 0.7)
296
+ h_o = getattr(glazing_material_obj, 'h_o', h_o)
297
+ logger.debug(f"Using glazing material for {component_name} ({component.component_type.value}): "
298
+ f"shgc={shgc}, h_o={h_o}")
299
+ emissivity = None # Not used for glazing
300
+
301
+ except Exception as e:
302
+ logger.error(f"Error retrieving surface parameters for {component_name} ({component.component_type.value}): {str(e)}")
303
+ # Apply defaults based on component type
304
+ if component.component_type == ComponentType.ROOF:
305
+ surface_tilt = 0.0 # Horizontal, upward
306
+ h_o = 23.0 # W/m²·K for roofs
307
+ surface_azimuth = 0.0 # Default north
308
+ elif component.component_type == ComponentType.SKYLIGHT:
309
+ surface_tilt = 0.0 # Horizontal, upward
310
+ h_o = 23.0 # W/m²·K for skylights
311
+ surface_azimuth = 0.0 # Default north
312
+ elif component.component_type == ComponentType.FLOOR:
313
+ surface_tilt = 180.0 # Horizontal, downward
314
+ h_o = 17.0 # W/m²·K
315
+ surface_azimuth = 0.0 # Default north
316
+ else: # WALL, DOOR, WINDOW
317
+ surface_tilt = 90.0 # Vertical
318
+ h_o = 17.0 # W/m²·K
319
+ surface_azimuth = 0.0 # Default north
320
+
321
+ # Apply material defaults
322
+ if component.component_type in [ComponentType.WALL, ComponentType.ROOF, ComponentType.DOOR]:
323
+ solar_absorption = 0.6
324
+ emissivity = 0.9
325
+ else: # WINDOW, SKYLIGHT
326
+ shgc = 0.7
327
+ emissivity = None
328
+
329
+ # Debug output for all components
330
+ logger.info(f"Final surface parameters for {component_name} ({component.component_type.value}): "
331
+ f"tilt={surface_tilt:.1f}, azimuth={surface_azimuth:.1f}, h_o={h_o:.1f}")
332
+
333
+ return surface_tilt, surface_azimuth, h_o, emissivity, solar_absorption
334
+
335
+ @staticmethod
336
+ def calculate_solar_load(component, hourly_data: Dict, hour: int, building_orientation: float, mode: str = "none") -> float:
337
+ """Calculate solar load in kW (cooling only) using ASHRAE-compliant solar calculations.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
338
 
339
+ Args:
340
+ component: Component object with area, component_type, elevation, glazing_material, shgc, iac.
341
+ hourly_data (Dict): Hourly weather data including solar radiation.
342
+ hour (int): Current hour.
343
+ building_orientation (float): Building orientation angle in degrees.
344
+ mode (str): Operating mode ('cooling', 'heating', 'none').
345
+
346
+ Returns:
347
+ float: Solar cooling load in kW. Returns 0 for non-cooling modes or non-fenestration components.
348
+
349
+ References:
350
+ ASHRAE Handbook—Fundamentals, Chapters 15 and 18.
351
+ """
352
+ # Only calculate solar loads in cooling mode
353
+ if mode != "cooling":
354
+ return 0
355
+
356
+ # Skip floors for solar calculation
357
+ if component.component_type == ComponentType.FLOOR:
358
+ return 0
359
+
360
+ component_name = getattr(component, 'name', 'unnamed_component')
361
+
362
+ try:
363
+ # Ensure MaterialLibrary is properly initialized and accessible
364
+ material_library = st.session_state.get("material_library")
365
+ if not material_library:
366
+ logger.error(f"MaterialLibrary not found in session_state for {component_name} ({component.component_type.value})")
367
+ # Instead of raising an error, initialize a new MaterialLibrary
368
+ from data.material_library import MaterialLibrary
369
+ material_library = MaterialLibrary()
370
+ st.session_state.material_library = material_library
371
+ logger.info(f"Created new MaterialLibrary for {component_name} ({component.component_type.value})")
372
 
373
+ project_materials = st.session_state.get("project_materials", {})
374
+ project_constructions = st.session_state.get("project_constructions", {})
375
+ project_glazing_materials = st.session_state.get("project_glazing_materials", {})
376
+ project_door_materials = st.session_state.get("project_door_materials", {})
377
+
378
+ # Get location parameters from climate_data
379
+ climate_data = st.session_state.get("climate_data", {})
380
+ latitude = climate_data.get("latitude", 0.0)
381
+ longitude = climate_data.get("longitude", 0.0)
382
+ timezone = climate_data.get("time_zone", 0.0)
383
+
384
+ # Get ground reflectivity (default 0.2)
385
+ ground_reflectivity = st.session_state.get("ground_reflectivity", 0.2)
386
+
387
+ # Validate input parameters
388
+ if not -90 <= latitude <= 90:
389
+ logger.warning(f"Invalid latitude {latitude} for {component_name} ({component.component_type.value}). Using default 0.0.")
390
+ latitude = 0.0
391
+ if not -180 <= longitude <= 180:
392
+ logger.warning(f"Invalid longitude {longitude} for {component_name} ({component.component_type.value}). Using default 0.0.")
393
+ longitude = 0.0
394
+ if not -12 <= timezone <= 14:
395
+ logger.warning(f"Invalid timezone {timezone} for {component_name} ({component.component_type.value}). Using default 0.0.")
396
+ timezone = 0.0
397
+ if not 0 <= ground_reflectivity <= 1:
398
+ logger.warning(f"Invalid ground_reflectivity {ground_reflectivity} for {component_name} ({component.component_type.value}). Using default 0.2.")
399
+ ground_reflectivity = 0.2
400
+
401
+ # Ensure hourly_data has required fields
402
+ required_fields = ["month", "day", "hour", "global_horizontal_radiation", "direct_normal_radiation",
403
+ "diffuse_horizontal_radiation", "dry_bulb"]
404
+ if not all(field in hourly_data for field in required_fields):
405
+ logger.warning(f"Missing required fields in hourly_data for hour {hour} for {component_name} ({component.component_type.value}): {hourly_data}")
406
+ return 0
407
+
408
+ # Skip if GHI <= 0
409
+ if hourly_data["global_horizontal_radiation"] <= 0:
410
+ logger.info(f"No solar load for hour {hour} due to GHI={hourly_data['global_horizontal_radiation']} for {component_name} ({component.component_type.value})")
411
+ return 0
412
+
413
+ # Extract weather data
414
+ month = hourly_data["month"]
415
+ day = hourly_data["day"]
416
+ hour = hourly_data["hour"]
417
+ ghi = hourly_data["global_horizontal_radiation"]
418
+ dni = hourly_data.get("direct_normal_radiation", ghi * 0.7) # Fallback: estimate DNI
419
+ dhi = hourly_data.get("diffuse_horizontal_radiation", ghi * 0.3) # Fallback: estimate DHI
420
+ outdoor_temp = hourly_data["dry_bulb"]
421
+
422
+ if ghi < 0 or dni < 0 or dhi < 0:
423
+ logger.error(f"Negative radiation values for {month}/{day}/{hour} for {component_name} ({component.component_type.value})")
424
+ raise ValueError(f"Negative radiation values for {month}/{day}/{hour}")
425
+
426
+ # Add detailed logging for solar calculation
427
+ logger.info(f"Processing solar for {month}/{day}/{hour} with GHI={ghi}, DNI={dni}, DHI={dhi}, "
428
+ f"dry_bulb={outdoor_temp} for {component_name} ({component.component_type.value})")
429
+
430
+ # Step 1: Local Solar Time (LST) with Equation of Time
431
+ year = 2025 # Fixed year since not provided
432
+ n = TFMCalculations.day_of_year(month, day, year)
433
+ EOT = TFMCalculations.equation_of_time(n)
434
+ lambda_std = 15 * timezone # Standard meridian longitude (°)
435
+ standard_time = hour - 1 + 0.5 # Convert to decimal, assume mid-hour
436
+ LST = standard_time + (4 * (lambda_std - longitude) + EOT) / 60
437
+
438
+ # Step 2: Solar Declination (δ)
439
+ delta = 23.45 * math.sin(math.radians(360 / 365 * (284 + n)))
440
+
441
+ # Step 3: Hour Angle (HRA)
442
+ hra = 15 * (LST - 12)
443
+
444
+ # Step 4: Solar Altitude (α) and Azimuth (ψ)
445
+ phi = math.radians(latitude)
446
+ delta_rad = math.radians(delta)
447
+ hra_rad = math.radians(hra)
448
+
449
+ sin_alpha = math.sin(phi) * math.sin(delta_rad) + math.cos(phi) * math.cos(delta_rad) * math.cos(hra_rad)
450
+ alpha = math.degrees(math.asin(sin_alpha))
451
+
452
+ if abs(math.cos(math.radians(alpha))) < 0.01:
453
+ azimuth = 0 # North at sunrise/sunset
454
  else:
455
+ sin_az = math.cos(delta_rad) * math.sin(hra_rad) / math.cos(math.radians(alpha))
456
+ cos_az = (sin_alpha * math.sin(phi) - math.sin(delta_rad)) / (math.cos(math.radians(alpha)) * math.cos(phi))
457
+ azimuth = math.degrees(math.atan2(sin_az, cos_az))
458
+ if hra > 0: # Afternoon
459
+ azimuth = 360 - azimuth if azimuth > 0 else -azimuth
460
+
461
+ logger.info(f"Solar angles for {month}/{day}/{hour}: declination={delta:.2f}, LST={LST:.2f}, "
462
+ f"HRA={hra:.2f}, altitude={alpha:.2f}, azimuth={azimuth:.2f} for {component_name} ({component.component_type.value})")
463
+
464
+ # Step 5: Get surface parameters with robust error handling
465
+ building_info = {"orientation_angle": building_orientation}
466
+ try:
467
+ surface_tilt, surface_azimuth, h_o, emissivity, solar_absorption = \
468
+ TFMCalculations.get_surface_parameters(
469
+ component, building_info, material_library, project_materials,
470
+ project_constructions, project_glazing_materials, project_door_materials
471
+ )
472
+ except Exception as e:
473
+ logger.error(f"Error getting surface parameters for {component_name}: {str(e)}. Using defaults.")
474
+ # Apply defaults based on component type
475
+ if component.component_type == ComponentType.ROOF:
476
+ surface_tilt = 0.0 # Horizontal, upward
477
+ surface_azimuth = 0.0 # Default north
478
+ elif component.component_type == ComponentType.SKYLIGHT:
479
+ surface_tilt = 0.0 # Horizontal, upward
480
+ surface_azimuth = 0.0 # Default north
481
+ elif component.component_type == ComponentType.FLOOR:
482
+ surface_tilt = 180.0 # Horizontal, downward
483
+ surface_azimuth = 0.0 # Default north
484
+ else: # WALL, DOOR, WINDOW
485
+ surface_tilt = 90.0 # Vertical
486
+ surface_azimuth = 0.0 # Default north
487
 
488
+ # Apply material defaults
489
+ if component.component_type in [ComponentType.WALL, ComponentType.ROOF, ComponentType.DOOR]:
490
+ solar_absorption = 0.6
491
+ h_o = 17.0 if component.component_type == ComponentType.WALL else 23.0
492
+ else: # WINDOW, SKYLIGHT
493
+ solar_absorption = 0.0 # Not used for glazing
494
+ h_o = 17.0 if component.component_type == ComponentType.WINDOW else 23.0
495
+
496
+ # Step 6: Calculate angle of incidence (θ)
497
+ # Convert angles to radians for calculation
498
+ alpha_rad = math.radians(alpha)
499
+ surface_tilt_rad = math.radians(surface_tilt)
500
+ azimuth_rad = math.radians(azimuth)
501
+ surface_azimuth_rad = math.radians(surface_azimuth)
502
+
503
+ # Calculate cos(θ) using the solar position and surface orientation
504
+ cos_theta = (math.sin(alpha_rad) * math.cos(surface_tilt_rad) +
505
+ math.cos(alpha_rad) * math.sin(surface_tilt_rad) *
506
+ math.cos(azimuth_rad - surface_azimuth_rad))
507
+
508
+ # Clamp to [0, 1] to avoid numerical issues
509
+ cos_theta = max(min(cos_theta, 1.0), 0.0)
510
+
511
+ # Log the calculated values
512
+ logger.info(f" Component {component_name} ({component.component_type.value}) at {month}/{day}/{hour}: "
513
+ f"surface_tilt={surface_tilt:.2f}, surface_azimuth={surface_azimuth:.2f}, "
514
+ f"cos_theta={cos_theta:.4f}")
515
+
516
+ # Step 7: Calculate total incident radiation (I_t)
517
+ # Calculate view factor for ground-reflected radiation
518
+ view_factor = (1 - math.cos(surface_tilt_rad)) / 2
519
+
520
+ # Calculate ground-reflected radiation
521
+ ground_reflected = ground_reflectivity * ghi * view_factor
522
+
523
+ # Calculate total incident radiation
524
+ if cos_theta > 0: # Surface receives direct beam radiation
525
+ I_t = dni * cos_theta + dhi + ground_reflected
526
+ else: # Surface in shade, only diffuse and reflected
527
+ I_t = dhi + ground_reflected
528
+
529
+ # Step 8: Calculate solar heat gain based on component type
530
+ solar_heat_gain = 0.0
531
+
532
+ if component.component_type in [ComponentType.WINDOW, ComponentType.SKYLIGHT]:
533
+ # For windows/skylights, get SHGC from material
534
+ shgc = 0.7 # Default
535
+ glazing_material = getattr(component, 'glazing_material', None)
536
+ if glazing_material:
537
+ glazing_material_obj = None
538
+ if hasattr(glazing_material, 'name'):
539
+ glazing_material_obj = (project_glazing_materials.get(glazing_material.name) or
540
+ material_library.library_glazing_materials.get(glazing_material.name))
541
+
542
+ if glazing_material_obj:
543
+ shgc = getattr(glazing_material_obj, 'shgc', 0.7)
544
+ h_o = getattr(glazing_material_obj, 'h_o', h_o)
545
+ else:
546
+ logger.warning(f"Glazing material not found for {component_name} ({component.component_type.value}). Using default SHGC=0.7.")
547
  else:
548
+ logger.warning(f"No glazing material defined for {component_name} ({component.component_type.value}). Using default SHGC=0.7.")
549
+
550
+ # Get glazing type for dynamic SHGC calculation
551
+ glazing_type = "Single Clear" # Default
552
+ if hasattr(component, 'name') and component.name in TFMCalculations.GLAZING_TYPE_MAPPING:
553
+ glazing_type = TFMCalculations.GLAZING_TYPE_MAPPING[component.name]
554
+
555
+ # Get internal shading coefficient
556
+ iac = getattr(component, 'iac', 1.0) # Default internal shading
557
+
558
+ # Calculate dynamic SHGC based on incidence angle
559
+ shgc_dynamic = shgc * TFMCalculations.calculate_dynamic_shgc(glazing_type, cos_theta)
560
+
561
+ # Calculate solar heat gain for fenestration
562
+ solar_heat_gain = component.area * shgc_dynamic * I_t * iac / 1000 # kW
563
+
564
+ logger.info(f"Fenestration solar heat gain for {component_name} ({component.component_type.value}) at {month}/{day}/{hour}: "
565
+ f"{solar_heat_gain:.4f} kW (area={component.area}, shgc_dynamic={shgc_dynamic:.4f}, "
566
+ f"I_t={I_t:.2f}, iac={iac})")
567
+
568
+ elif component.component_type in [ComponentType.WALL, ComponentType.ROOF, ComponentType.DOOR]:
569
+ # For opaque surfaces, use solar absorptivity and surface resistance
570
+ surface_resistance = 1/h_o # m²·K/W
571
+
572
+ # Calculate absorbed solar radiation
573
+ solar_heat_gain = component.area * solar_absorption * I_t * surface_resistance / 1000 # kW
574
+
575
+ logger.info(f"Opaque surface solar heat gain for {component_name} ({component.component_type.value}) at {month}/{day}/{hour}: "
576
+ f"{solar_heat_gain:.4f} kW (area={component.area}, solar_absorption={solar_absorption:.2f}, "
577
+ f"I_t={I_t:.2f}, surface_resistance={surface_resistance:.4f})")
578
 
579
+ return solar_heat_gain
580
+
581
+ except Exception as e:
582
+ logger.error(f"Error calculating solar load for {component_name} ({component.component_type.value}) at hour {hour}: {str(e)}")
583
+ return 0
584
+
585
+ @staticmethod
586
+ def calculate_internal_load(internal_loads: Dict, hour: int, operation_hours: int, area: float) -> float:
587
+ """Calculate total internal load in kW."""
588
+ total_load = 0
589
+ for group in internal_loads.get("people", []):
590
+ activity_data = group["activity_data"]
591
+ sensible = (activity_data["sensible_min_w"] + activity_data["sensible_max_w"]) / 2
592
+ latent = (activity_data["latent_min_w"] + activity_data["latent_max_w"]) / 2
593
+ load_per_person = sensible + latent
594
+ total_load += group["num_people"] * load_per_person * group["diversity_factor"]
595
+ for light in internal_loads.get("lighting", []):
596
+ lpd = light["lpd"]
597
+ lighting_operating_hours = light["operating_hours"]
598
+ fraction = min(lighting_operating_hours, operation_hours) / operation_hours if operation_hours > 0 else 0
599
+ lighting_load = lpd * area * fraction
600
+ total_load += lighting_load
601
+ equipment = internal_loads.get("equipment")
602
+ if equipment:
603
+ total_power_density = equipment.get("total_power_density", 0)
604
+ equipment_load = total_power_density * area
605
+ total_load += equipment_load
606
+ return total_load / 1000
607
+
608
+ @staticmethod
609
+ def calculate_ventilation_load(internal_loads: Dict, outdoor_temp: float, indoor_temp: float, area: float, building_info: Dict, mode: str = "none") -> tuple[float, float]:
610
+ """Calculate ventilation load for heating and cooling in kW based on mode."""
611
+ if mode == "none":
612
+ return 0, 0
613
+ ventilation = internal_loads.get("ventilation")
614
+ if not ventilation:
615
+ return 0, 0
616
+ space_rate = ventilation.get("space_rate", 0.3) # L/s/m²
617
+ people_rate = ventilation.get("people_rate", 2.5) # L/s/person
618
+ num_people = sum(group["num_people"] for group in internal_loads.get("people", []))
619
+ ventilation_flow = (space_rate * area + people_rate * num_people) / 1000 # m³/s
620
+ air_density = 1.2 # kg/m³
621
+ specific_heat = 1000 # J/kg·K
622
+ delta_t = outdoor_temp - indoor_temp
623
+ if mode == "cooling" and delta_t <= 0:
624
+ return 0, 0
625
+ if mode == "heating" and delta_t >= 0:
626
+ return 0, 0
627
+ load = ventilation_flow * air_density * specific_heat * delta_t / 1000 # kW
628
+ cooling_load = load if mode == "cooling" else 0
629
+ heating_load = -load if mode == "heating" else 0
630
+ return cooling_load, heating_load
631
+
632
+ @staticmethod
633
+ def calculate_infiltration_load(internal_loads: Dict, outdoor_temp: float, indoor_temp: float, area: float, building_info: Dict, mode: str = "none") -> tuple[float, float]:
634
+ """Calculate infiltration load for heating and cooling in kW based on mode."""
635
+ if mode == "none":
636
+ return 0, 0
637
+ infiltration = internal_loads.get("infiltration")
638
+ if not infiltration:
639
+ return 0, 0
640
+ method = infiltration.get("method", "ACH")
641
+ settings = infiltration.get("settings", {})
642
+ building_height = building_info.get("building_height", 3.0)
643
+ volume = area * building_height # m³
644
+ air_density = 1.2 # kg/m³
645
+ specific_heat = 1000 # J/kg·K
646
+ delta_t = outdoor_temp - indoor_temp
647
+ if mode == "cooling" and delta_t <= 0:
648
+ return 0, 0
649
+ if mode == "heating" and delta_t >= 0:
650
+ return 0, 0
651
+ if method == "ACH":
652
+ ach = settings.get("rate", 0.5)
653
+ infiltration_flow = ach * volume / 3600 # m³/s
654
+ elif method == "Crack Flow":
655
+ ela = settings.get("ela", 0.0001) # m²/m²
656
+ wind_speed = 4.0 # m/s (assumed)
657
+ infiltration_flow = ela * area * wind_speed / 2 # m³/s
658
+ else: # Empirical Equations
659
+ c = settings.get("c", 0.1)
660
+ n = settings.get("n", 0.65)
661
+ delta_t_abs = abs(delta_t)
662
+ infiltration_flow = c * (delta_t_abs ** n) * area / 3600 # m³/s
663
+ load = infiltration_flow * air_density * specific_heat * delta_t / 1000 # kW
664
+ cooling_load = load if mode == "cooling" else 0
665
+ heating_load = -load if mode == "heating" else 0
666
+ return cooling_load, heating_load
667
+
668
+ @staticmethod
669
+ def get_adaptive_comfort_temp(outdoor_temp: float) -> float:
670
+ """Calculate adaptive comfort temperature per ASHRAE 55."""
671
+ if 10 <= outdoor_temp <= 33.5:
672
+ return 0.31 * outdoor_temp + 17.8
673
+ return 24.0 # Default to standard setpoint if outside range
674
+
675
+ @staticmethod
676
+ def filter_hourly_data(hourly_data: List[Dict], sim_period: Dict, climate_data: Dict) -> List[Dict]:
677
+ """Filter hourly data based on simulation period, ignoring year."""
678
+ if sim_period["type"] == "Full Year":
679
+ return hourly_data
680
+ filtered_data = []
681
+ if sim_period["type"] == "From-to":
682
+ start_month = sim_period["start_date"].month
683
+ start_day = sim_period["start_date"].day
684
+ end_month = sim_period["end_date"].month
685
+ end_day = sim_period["end_date"].day
686
+ for data in hourly_data:
687
+ month, day = data["month"], data["day"]
688
+ if (month > start_month or (month == start_month and day >= start_day)) and \
689
+ (month < end_month or (month == end_month and day <= end_day)):
690
+ filtered_data.append(data)
691
+ elif sim_period["type"] in ["HDD", "CDD"]:
692
+ base_temp = sim_period.get("base_temp", 18.3 if sim_period["type"] == "HDD" else 23.9)
693
+ for data in hourly_data:
694
+ temp = data["dry_bulb"]
695
+ if (sim_period["type"] == "HDD" and temp < base_temp) or (sim_period["type"] == "CDD" and temp > base_temp):
696
+ filtered_data.append(data)
697
+ return filtered_data
698
+
699
+ @staticmethod
700
+ def get_indoor_conditions(indoor_conditions: Dict, hour: int, outdoor_temp: float) -> Dict:
701
+ """Determine indoor conditions based on user settings."""
702
+ if indoor_conditions["type"] == "Fixed":
703
+ mode = "none" if abs(outdoor_temp - 18) < 0.01 else "cooling" if outdoor_temp > 18 else "heating"
704
+ if mode == "cooling":
705
+ return {
706
+ "temperature": indoor_conditions.get("cooling_setpoint", {}).get("temperature", 24.0),
707
+ "rh": indoor_conditions.get("cooling_setpoint", {}).get("rh", 50.0)
708
+ }
709
+ elif mode == "heating":
710
+ return {
711
+ "temperature": indoor_conditions.get("heating_setpoint", {}).get("temperature", 22.0),
712
+ "rh": indoor_conditions.get("heating_setpoint", {}).get("rh", 50.0)
713
+ }
714
  else:
715
+ return {"temperature": 24.0, "rh": 50.0}
716
+ elif indoor_conditions["type"] == "Time-varying":
717
+ schedule = indoor_conditions.get("schedule", [])
718
+ if schedule:
719
+ hour_idx = hour % 24
720
+ for entry in schedule:
721
+ if entry["hour"] == hour_idx:
722
+ return {"temperature": entry["temperature"], "rh": entry["rh"]}
723
+ return {"temperature": 24.0, "rh": 50.0}
724
+ else: # Adaptive
725
+ return {"temperature": TFMCalculations.get_adaptive_comfort_temp(outdoor_temp), "rh": 50.0}
726
+
727
+ @staticmethod
728
+ 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]:
729
+ """Calculate TFM loads for heating and cooling with user-defined filters and temperature threshold."""
730
+ filtered_data = TFMCalculations.filter_hourly_data(hourly_data, sim_period, building_info)
731
+ temp_loads = []
732
+ building_orientation = building_info.get("orientation_angle", 0.0)
733
+ operating_periods = hvac_settings.get("operating_hours", [{"start": 8, "end": 18}])
734
+ area = building_info.get("floor_area", 100.0)
735
+
736
+ # Ensure MaterialLibrary is properly initialized
737
+ if "material_library" not in st.session_state:
738
+ from data.material_library import MaterialLibrary
739
+ st.session_state.material_library = MaterialLibrary()
740
+ logger.info("Initialized MaterialLibrary in session_state for solar calculations")
741
+
742
+ # Pre-calculate CTF coefficients for all components using CTFCalculator
743
+ for comp_list in components.values():
744
+ for comp in comp_list:
745
+ comp.ctf = CTFCalculator.calculate_ctf_coefficients(comp)
746
+
747
+ for hour_data in filtered_data:
748
+ hour = hour_data["hour"]
749
+ outdoor_temp = hour_data["dry_bulb"]
750
+ indoor_cond = TFMCalculations.get_indoor_conditions(indoor_conditions, hour, outdoor_temp)
751
+ indoor_temp = indoor_cond["temperature"]
752
+ # Initialize all loads to 0
753
+ conduction_cooling = conduction_heating = solar = internal = ventilation_cooling = ventilation_heating = infiltration_cooling = infiltration_heating = 0
754
+ # Check if hour is within operating periods
755
+ is_operating = False
756
+ for period in operating_periods:
757
+ start_hour = period.get("start", 8)
758
+ end_hour = period.get("end", 18)
759
+ if start_hour <= hour % 24 <= end_hour:
760
+ is_operating = True
761
+ break
762
+ # Determine mode based on temperature threshold (18°C)
763
+ mode = "none" if abs(outdoor_temp - 18) < 0.01 else "cooling" if outdoor_temp > 18 else "heating"
764
+ if is_operating and mode == "cooling":
765
+ # Calculate solar load for each component and accumulate
766
+ for comp_list in components.values():
767
+ for comp in comp_list:
768
+ cool_load, _ = TFMCalculations.calculate_conduction_load(comp, outdoor_temp, indoor_temp, hour, mode="cooling")
769
+ conduction_cooling += cool_load
770
+
771
+ # Calculate solar load for each component and accumulate
772
+ component_solar_load = TFMCalculations.calculate_solar_load(comp, hour_data, hour, building_orientation, mode="cooling")
773
+ solar += component_solar_load
774
+
775
+ # Add detailed logging for solar load accumulation
776
+ logger.info(f"Component {comp.name} ({comp.component_type.value}) solar load: {component_solar_load:.3f} kW, accumulated solar: {solar:.3f} kW")
777
 
778
+ internal = TFMCalculations.calculate_internal_load(internal_loads, hour, max([p["end"] - p["start"] for p in operating_periods]), area)
779
+ ventilation_cooling, _ = TFMCalculations.calculate_ventilation_load(internal_loads, outdoor_temp, indoor_temp, area, building_info, mode="cooling")
780
+ infiltration_cooling, _ = TFMCalculations.calculate_infiltration_load(internal_loads, outdoor_temp, indoor_temp, area, building_info, mode="cooling")
781
+ elif is_operating and mode == "heating":
782
+ for comp_list in components.values():
783
+ for comp in comp_list:
784
+ _, heat_load = TFMCalculations.calculate_conduction_load(comp, outdoor_temp, indoor_temp, hour, mode="heating")
785
+ conduction_heating += heat_load
786
+ internal = TFMCalculations.calculate_internal_load(internal_loads, hour, max([p["end"] - p["start"] for p in operating_periods]), area)
787
+ _, ventilation_heating = TFMCalculations.calculate_ventilation_load(internal_loads, outdoor_temp, indoor_temp, area, building_info, mode="heating")
788
+ _, infiltration_heating = TFMCalculations.calculate_infiltration_load(internal_loads, outdoor_temp, indoor_temp, area, building_info, mode="heating")
789
+ else: # mode == "none" or not is_operating
790
+ internal = 0 # No internal loads when no heating or cooling is needed or outside operating hours
791
+
792
+ # Add detailed logging for total loads
793
+ logger.info(f"Hour {hour} total loads - conduction: {conduction_cooling:.3f} kW, solar: {solar:.3f} kW, internal: {internal:.3f} kW")
794
+
795
+ # Calculate total loads, subtracting internal load for heating
796
+ total_cooling = conduction_cooling + solar + internal + ventilation_cooling + infiltration_cooling
797
+ total_heating = max(conduction_heating + ventilation_heating + infiltration_heating - internal, 0)
798
+ # Enforce mutual exclusivity within hour
799
+ if mode == "cooling":
800
+ total_heating = 0
801
+ elif mode == "heating":
802
+ total_cooling = 0
803
+ temp_loads.append({
804
+ "hour": hour,
805
+ "month": hour_data["month"],
806
+ "day": hour_data["day"],
807
+ "conduction_cooling": conduction_cooling,
808
+ "conduction_heating": conduction_heating,
809
+ "solar": solar,
810
+ "internal": internal,
811
+ "ventilation_cooling": ventilation_cooling,
812
+ "ventilation_heating": ventilation_heating,
813
+ "infiltration_cooling": infiltration_cooling,
814
+ "infiltration_heating": infiltration_heating,
815
+ "total_cooling": total_cooling,
816
+ "total_heating": total_heating
817
+ })
818
+ # Group loads by day and apply daily control
819
+ loads_by_day = defaultdict(list)
820
+ for load in temp_loads:
821
+ day_key = (load["month"], load["day"])
822
+ loads_by_day[day_key].append(load)
823
+ final_loads = []
824
+ for day_key, day_loads in loads_by_day.items():
825
+ # Count hours with non-zero cooling and heating loads
826
+ cooling_hours = sum(1 for load in day_loads if load["total_cooling"] > 0)
827
+ heating_hours = sum(1 for load in day_loads if load["total_heating"] > 0)
828
+ # Apply daily control
829
+ for load in day_loads:
830
+ if cooling_hours > heating_hours:
831
+ load["total_heating"] = 0 # Keep cooling components, zero heating total
832
+ elif heating_hours > cooling_hours:
833
+ load["total_cooling"] = 0 # Keep heating components, zero cooling total
834
+ else: # Equal hours
835
+ load["total_cooling"] = 0
836
+ load["total_heating"] = 0 # Zero both totals, keep components
837
+ final_loads.append(load)
838
+ return final_loads