mabuseif commited on
Commit
ebd736c
·
verified ·
1 Parent(s): 181b06f

Update app/hvac_loads.py

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