mabuseif commited on
Commit
9511b71
·
verified ·
1 Parent(s): ebd736c

Upload 2 files

Browse files
Files changed (2) hide show
  1. utils/ctf_calculations.py +230 -0
  2. utils/solar.py +480 -0
utils/ctf_calculations.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ CTF Calculations Module
3
+
4
+ This module contains the CTFCalculator class for calculating Conduction Transfer Function (CTF)
5
+ coefficients for HVAC load calculations using the implicit Finite Difference Method.
6
+
7
+ Developed by: Dr Majed Abuseif, Deakin University
8
+ © 2025
9
+ """
10
+
11
+ import numpy as np
12
+ import scipy.sparse as sparse
13
+ import scipy.sparse.linalg as sparse_linalg
14
+ import hashlib
15
+ import logging
16
+ import threading
17
+ from typing import List
18
+ from data.material_library import Construction
19
+ from enum import Enum
20
+ from typing import Dict, List, Optional, NamedTuple
21
+
22
+ # Configure logging
23
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
24
+ logger = logging.getLogger(__name__)
25
+
26
+ class ComponentType(Enum):
27
+ WALL = "Wall"
28
+ ROOF = "Roof"
29
+ FLOOR = "Floor"
30
+ WINDOW = "Window"
31
+ DOOR = "Door"
32
+ SKYLIGHT = "Skylight"
33
+
34
+ class CTFCoefficients(NamedTuple):
35
+ X: List[float] # Exterior temperature coefficients
36
+ Y: List[float] # Cross coefficients
37
+ Z: List[float] # Interior temperature coefficients
38
+ F: List[float] # Flux history coefficients
39
+
40
+ class CTFCalculator:
41
+ """Class to calculate and cache CTF coefficients for building components."""
42
+
43
+ # Cache for CTF coefficients based on construction properties
44
+ _ctf_cache = {}
45
+ _cache_lock = threading.Lock() # Thread-safe lock for cache access
46
+
47
+ @staticmethod
48
+ def _hash_construction(construction: Construction) -> str:
49
+ """Generate a unique hash for a construction based on its properties.
50
+
51
+ Args:
52
+ construction: Construction object containing material layers.
53
+
54
+ Returns:
55
+ str: SHA-256 hash of the construction properties.
56
+ """
57
+ hash_input = f"{construction.name}"
58
+ for layer in construction.layers:
59
+ material = layer["material"]
60
+ hash_input += f"{material.name}{material.conductivity}{material.density}{material.specific_heat}{layer['thickness']}"
61
+ return hashlib.sha256(hash_input.encode()).hexdigest()
62
+
63
+ @classmethod
64
+ def calculate_ctf_coefficients(cls, component) -> CTFCoefficients:
65
+ """Calculate CTF coefficients using implicit Finite Difference Method.
66
+
67
+ Note: Per ASHRAE, CTF calculations are skipped for WINDOW, DOOR, and SKYLIGHT components,
68
+ as they use typical material properties. CTF tables for these components will be added later.
69
+
70
+ Args:
71
+ component: Building component with construction properties.
72
+
73
+ Returns:
74
+ CTFCoefficients: Named tuple containing X, Y, Z, and F coefficients.
75
+ """
76
+ # Skip CTF for WINDOW, DOOR, SKYLIGHT as per ASHRAE; return zero coefficients
77
+ if component.component_type in [ComponentType.WINDOW, ComponentType.DOOR, ComponentType.SKYLIGHT]:
78
+ logger.info(f"Skipping CTF calculation for {component.component_type.value} component '{component.name}'. Using zero coefficients until CTF tables are implemented.")
79
+ return CTFCoefficients(X=[0.0], Y=[0.0], Z=[0.0], F=[0.0])
80
+
81
+ # Check if construction exists and has layers
82
+ construction = component.construction
83
+ if not construction or not construction.layers:
84
+ logger.warning(f"No valid construction or layers for component '{component.name}' ({component.component_type.value}). Returning zero CTFs.")
85
+ return CTFCoefficients(X=[0.0], Y=[0.0], Z=[0.0], F=[0.0])
86
+
87
+ # Check cache with thread-safe access
88
+ construction_hash = cls._hash_construction(construction)
89
+ with cls._cache_lock:
90
+ if construction_hash in cls._ctf_cache:
91
+ logger.info(f"Using cached CTF coefficients for construction {construction.name}")
92
+ return cls._ctf_cache[construction_hash]
93
+
94
+ # Discretization parameters
95
+ dt = 3600 # 1-hour time step (s)
96
+ nodes_per_layer = 3 # 2–4 nodes per layer for balance
97
+ R_out = 0.04 # Outdoor surface resistance (m²·K/W, ASHRAE)
98
+ R_in = 0.12 # Indoor surface resistance (m²·K/W, ASHRAE)
99
+
100
+ # Collect layer properties
101
+ thicknesses = [layer["thickness"] for layer in construction.layers]
102
+ materials = [layer["material"] for layer in construction.layers]
103
+ k = [m.conductivity for m in materials] # W/m·K
104
+ rho = [m.density for m in materials] # kg/m³
105
+ c = [m.specific_heat for m in materials] # J/kg·K
106
+ alpha = [k_i / (rho_i * c_i) for k_i, rho_i, c_i in zip(k, rho, c)] # Thermal diffusivity (m²/s)
107
+
108
+ # Calculate node spacing and check stability
109
+ total_nodes = sum(nodes_per_layer for _ in thicknesses)
110
+ dx = [t / nodes_per_layer for t in thicknesses] # Node spacing per layer
111
+ node_positions = []
112
+ node_idx = 0
113
+ for i, t in enumerate(thicknesses):
114
+ for j in range(nodes_per_layer):
115
+ node_positions.append((i, j, node_idx)) # (layer_idx, node_in_layer, global_node_idx)
116
+ node_idx += 1
117
+
118
+ # Stability check: Fourier number
119
+ for i, (a, d) in enumerate(zip(alpha, dx)):
120
+ Fo = a * dt / (d ** 2)
121
+ if Fo < 0.33:
122
+ logger.warning(f"Fourier number {Fo:.3f} < 0.33 for layer {i} ({materials[i].name}). Adjusting node spacing.")
123
+ dx[i] = np.sqrt(a * dt / 0.33)
124
+ nodes_per_layer = max(2, int(np.ceil(thicknesses[i] / dx[i])))
125
+ dx[i] = thicknesses[i] / nodes_per_layer
126
+ Fo = a * dt / (dx[i] ** 2)
127
+ logger.info(f"Adjusted node spacing for layer {i}: dx={dx[i]:.4f} m, Fo={Fo:.3f}")
128
+
129
+ # Build system matrices
130
+ A = sparse.lil_matrix((total_nodes, total_nodes))
131
+ b = np.zeros(total_nodes)
132
+ node_to_layer = [i for i, _, _ in node_positions]
133
+
134
+ for idx, (layer_idx, node_j, global_idx) in enumerate(node_positions):
135
+ k_i = k[layer_idx]
136
+ rho_i = rho[layer_idx]
137
+ c_i = c[layer_idx]
138
+ dx_i = dx[layer_idx]
139
+
140
+ if node_j == 0 and layer_idx == 0: # Outdoor surface node
141
+ A[idx, idx] = 1.0 + 2 * dt * k_i / (dx_i * rho_i * c_i * dx_i) + dt / (rho_i * c_i * dx_i * R_out)
142
+ A[idx, idx + 1] = -2 * dt * k_i / (dx_i * rho_i * c_i * dx_i)
143
+ b[idx] = dt / (rho_i * c_i * dx_i * R_out) # Outdoor temp contribution
144
+ elif node_j == nodes_per_layer - 1 and layer_idx == len(thicknesses) - 1: # Indoor surface node
145
+ A[idx, idx] = 1.0 + 2 * dt * k_i / (dx_i * rho_i * c_i * dx_i) + dt / (rho_i * c_i * dx_i * R_in)
146
+ A[idx, idx - 1] = -2 * dt * k_i / (dx_i * rho_i * c_i * dx_i)
147
+ b[idx] = dt / (rho_i * c_i * dx_i * R_in) # Indoor temp contribution
148
+ elif node_j == nodes_per_layer - 1 and layer_idx < len(thicknesses) - 1: # Interface between layers
149
+ k_next = k[layer_idx + 1]
150
+ dx_next = dx[layer_idx + 1]
151
+ rho_next = rho[layer_idx + 1]
152
+ c_next = c[layer_idx + 1]
153
+ A[idx, idx] = 1.0 + dt * (k_i / dx_i + k_next / dx_next) / (0.5 * (rho_i * c_i * dx_i + rho_next * c_next * dx_next))
154
+ A[idx, idx - 1] = -dt * k_i / (dx_i * 0.5 * (rho_i * c_i * dx_i + rho_next * c_next * dx_next))
155
+ A[idx, idx + 1] = -dt * k_next / (dx_next * 0.5 * (rho_i * c_i * dx_i + rho_next * c_next * dx_next))
156
+ elif node_j == 0 and layer_idx > 0: # Interface from previous layer
157
+ k_prev = k[layer_idx - 1]
158
+ dx_prev = dx[layer_idx - 1]
159
+ rho_prev = rho[layer_idx - 1]
160
+ c_prev = c[layer_idx - 1]
161
+ A[idx, idx] = 1.0 + dt * (k_prev / dx_prev + k_i / dx_i) / (0.5 * (rho_prev * c_prev * dx_prev + rho_i * c_i * dx_i))
162
+ A[idx, idx - 1] = -dt * k_prev / (dx_prev * 0.5 * (rho_prev * c_prev * dx_prev + rho_i * c_i * dx_i))
163
+ A[idx, idx + 1] = -dt * k_i / (dx_i * 0.5 * (rho_prev * c_prev * dx_prev + rho_i * c_i * dx_i))
164
+ else: # Internal node
165
+ A[idx, idx] = 1.0 + 2 * dt * k_i / (dx_i * rho_i * c_i * dx_i)
166
+ A[idx, idx - 1] = -dt * k_i / (dx_i * rho_i * c_i * dx_i)
167
+ A[idx, idx + 1] = -dt * k_i / (dx_i * rho_i * c_i * dx_i)
168
+
169
+ A = A.tocsr() # Convert to CSR for efficient solving
170
+
171
+ # Calculate CTF coefficients (X, Y, Z, F)
172
+ num_ctf = 12 # Standard number of coefficients
173
+ X = [0.0] * num_ctf # Exterior temp response
174
+ Y = [0.0] * num_ctf # Cross response
175
+ Z = [0.0] * num_ctf # Interior temp response
176
+ F = [0.0] * num_ctf # Flux history
177
+ T_prev = np.zeros(total_nodes) # Previous temperatures
178
+
179
+ # Impulse response for exterior temperature (X, Y)
180
+ for t in range(num_ctf):
181
+ b_out = b.copy()
182
+ if t == 0:
183
+ b_out[0] = dt / (rho[0] * c[0] * dx[0] * R_out) # Unit outdoor temp impulse
184
+ T = sparse_linalg.spsolve(A, b_out + T_prev)
185
+ q_in = (T[-1] - 0.0) / R_in # Indoor heat flux (W/m²)
186
+ Y[t] = q_in
187
+ q_out = (0.0 - T[0]) / R_out # Outdoor heat flux
188
+ X[t] = q_out
189
+ T_prev = T.copy()
190
+
191
+ # Reset for interior temperature (Z)
192
+ T_prev = np.zeros(total_nodes)
193
+ for t in range(num_ctf):
194
+ b_in = b.copy()
195
+ if t == 0:
196
+ b_in[-1] = dt / (rho[-1] * c[-1] * dx[-1] * R_in) # Unit indoor temp impulse
197
+ T = sparse_linalg.spsolve(A, b_in + T_prev)
198
+ q_in = (T[-1] - 0.0) / R_in
199
+ Z[t] = q_in
200
+ T_prev = T.copy()
201
+
202
+ # Flux history coefficients (F)
203
+ T_prev = np.zeros(total_nodes)
204
+ for t in range(num_ctf):
205
+ b_flux = np.zeros(total_nodes)
206
+ if t == 0:
207
+ b_flux[-1] = -1.0 / (rho[-1] * c[-1] * dx[-1]) # Unit flux impulse
208
+ T = sparse_linalg.spsolve(A, b_flux + T_prev)
209
+ q_in = (T[-1] - 0.0) / R_in
210
+ F[t] = q_in
211
+ T_prev = T.copy()
212
+
213
+ ctf = CTFCoefficients(X=X, Y=Y, Z=Z, F=F)
214
+ with cls._cache_lock:
215
+ cls._ctf_cache[construction_hash] = ctf
216
+ logger.info(f"Calculated CTF coefficients for construction {construction.name}")
217
+ return ctf
218
+
219
+ @classmethod
220
+ def calculate_ctf_tables(cls, component) -> CTFCoefficients:
221
+ """Placeholder for future implementation of CTF table lookups for windows, doors, and skylights.
222
+
223
+ Args:
224
+ component: Building component with construction properties.
225
+
226
+ Returns:
227
+ CTFCoefficients: Placeholder zero coefficients until implementation.
228
+ """
229
+ logger.info(f"CTF table calculation for {component.component_type.value} component '{component.name}' not yet implemented. Returning zero coefficients.")
230
+ return CTFCoefficients(X=[0.0], Y=[0.0], Z=[0.0], F=[0.0])
utils/solar.py ADDED
@@ -0,0 +1,480 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ from typing import List, Dict, Any, Optional, Tuple
3
+ import math
4
+ from datetime import datetime
5
+ from data.material_library import MaterialLibrary, Construction, GlazingMaterial, DoorMaterial, Material
6
+ from utils.ctf_calculations import ComponentType
7
+ import logging
8
+
9
+ # Configure logging
10
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
11
+ logger = logging.getLogger(__name__)
12
+
13
+ class SolarCalculations:
14
+ """Class for performing ASHRAE-compliant solar radiation and angle calculations.
15
+
16
+ References:
17
+ - ASHRAE Handbook—Fundamentals, Chapter 18 (Sol-air temperature)
18
+ - ASHRAE Handbook—Fundamentals, Chapter 15 (Dynamic SHGC)
19
+ """
20
+
21
+ # Updated dynamic SHGC coefficients (provided by user, May 2025)
22
+ SHGC_COEFFICIENTS = {
23
+ "Single Clear": [0.1, -0.0, 0.0, -0.0, 0.0, 0.87],
24
+ "Single Tinted": [0.12, -0.0, 0.0, -0.0, 0.8, -0.0],
25
+ "Double Clear": [0.14, -0.0, 0.0, -0.0, 0.78, -0.0],
26
+ "Double Low-E": [0.2, -0.0, 0.0, 0.7, 0.0, -0.0],
27
+ "Double Tinted": [0.15, -0.0, 0.0, -0.0, 0.65, -0.0],
28
+ "Double Low-E with Argon": [0.18, -0.0, 0.0, 0.68, 0.0, -0.0],
29
+ "Single Low-E Reflective": [0.22, -0.0, 0.0, 0.6, 0.0, -0.0],
30
+ "Double Reflective": [0.24, -0.0, 0.0, 0.58, 0.0, -0.0],
31
+ "Electrochromic": [0.25, -0.0, 0.5, -0.0, 0.0, -0.0]
32
+ }
33
+
34
+ # Mapping of GlazingMaterial names to SHGC types
35
+ GLAZING_TYPE_MAPPING = {
36
+ "Single Clear 3mm": "Single Clear",
37
+ "Single Clear 6mm": "Single Clear",
38
+ "Single Tinted 6mm": "Single Tinted",
39
+ "Double Clear 6mm/13mm Air": "Double Clear",
40
+ "Double Low-E 6mm/13mm Air": "Double Low-E",
41
+ "Double Tinted 6mm/13mm Air": "Double Tinted",
42
+ "Double Low-E 6mm/13mm Argon": "Double Low-E with Argon",
43
+ "Single Low-E Reflective 6mm": "Single Low-E Reflective",
44
+ "Double Reflective 6mm/13mm Air": "Double Reflective",
45
+ "Electrochromic 6mm/13mm Air": "Electrochromic"
46
+ }
47
+
48
+ def __init__(self, material_library: MaterialLibrary, project_materials: Optional[Dict] = None,
49
+ project_constructions: Optional[Dict] = None, project_glazing_materials: Optional[Dict] = None,
50
+ project_door_materials: Optional[Dict] = None):
51
+ """
52
+ Initialize SolarCalculations with material library and optional project-specific libraries.
53
+
54
+ Args:
55
+ material_library: MaterialLibrary instance for accessing library materials/constructions.
56
+ project_materials: Dict of project-specific Material objects.
57
+ project_constructions: Dict of project-specific Construction objects.
58
+ project_glazing_materials: Dict of project-specific GlazingMaterial objects.
59
+ project_door_materials: Dict of project-specific DoorMaterial objects.
60
+ """
61
+ self.material_library = material_library
62
+ self.project_materials = project_materials or {}
63
+ self.project_constructions = project_constructions or {}
64
+ self.project_glazing_materials = project_glazing_materials or {}
65
+ self.project_door_materials = project_door_materials or {}
66
+ logger.info("Initialized SolarCalculations with MaterialLibrary and project-specific libraries.")
67
+
68
+ @staticmethod
69
+ def day_of_year(month: int, day: int, year: int) -> int:
70
+ """Calculate day of the year (n) from month, day, and year, accounting for leap years.
71
+
72
+ Args:
73
+ month (int): Month of the year (1-12).
74
+ day (int): Day of the month (1-31).
75
+ year (int): Year.
76
+
77
+ Returns:
78
+ int: Day of the year (1-365 or 366 for leap years).
79
+
80
+ References:
81
+ ASHRAE Handbook—Fundamentals, Chapter 18.
82
+ """
83
+ days_in_month = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
84
+ if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0):
85
+ days_in_month[1] = 29
86
+ return sum(days_in_month[:month-1]) + day
87
+
88
+ @staticmethod
89
+ def equation_of_time(n: int) -> float:
90
+ """Calculate Equation of Time (EOT) in minutes using Spencer's formula.
91
+
92
+ Args:
93
+ n (int): Day of the year (1-365 or 366).
94
+
95
+ Returns:
96
+ float: Equation of Time in minutes.
97
+
98
+ References:
99
+ ASHRAE Handbook—Fundamentals, Chapter 18.
100
+ """
101
+ B = (n - 1) * 360 / 365
102
+ B_rad = math.radians(B)
103
+ EOT = 229.2 * (0.000075 + 0.001868 * math.cos(B_rad) - 0.032077 * math.sin(B_rad) -
104
+ 0.014615 * math.cos(2 * B_rad) - 0.04089 * math.sin(2 * B_rad))
105
+ return EOT
106
+
107
+ def get_surface_parameters(self, component: Any, building_info: Dict) -> Tuple[float, float, float, Optional[float], float]:
108
+ """
109
+ Determine surface parameters (tilt, azimuth, h_o, emissivity, solar_absorption) for a component.
110
+ Uses MaterialLibrary to fetch properties from first layer for walls/roofs, DoorMaterial for doors,
111
+ and GlazingMaterial for windows/skylights. Handles orientation and tilt based on component type:
112
+ - Walls, Doors, Windows: Azimuth = facade base azimuth + component.rotation; Tilt = 90°.
113
+ - Roofs, Skylights: Azimuth = component.orientation; Tilt = component.tilt (default 180°).
114
+
115
+ Args:
116
+ component: Component object with component_type, facade, rotation, orientation, tilt,
117
+ construction, glazing_material, or door_material.
118
+ building_info (Dict): Building information containing orientation_angle for facade mapping.
119
+
120
+ Returns:
121
+ Tuple[float, float, float, Optional[float], float]: Surface tilt (°), surface azimuth (°),
122
+ h_o (W/m²·K), emissivity, solar_absorption.
123
+
124
+ Raises:
125
+ ValueError: If facade is missing or invalid for walls, doors, or windows.
126
+ """
127
+ # Default parameters
128
+ if component.component_type == ComponentType.ROOF:
129
+ surface_tilt = getattr(component, 'tilt', 180.0) # Horizontal, downward if tilt absent
130
+ h_o = 23.0 # W/m²·K for roofs
131
+ elif component.component_type == ComponentType.SKYLIGHT:
132
+ surface_tilt = getattr(component, 'tilt', 180.0) # Horizontal, downward if tilt absent
133
+ h_o = 23.0 # W/m²·K for skylights
134
+ elif component.component_type == ComponentType.FLOOR:
135
+ surface_tilt = 0.0 # Horizontal, upward
136
+ h_o = 17.0 # W/m²·K
137
+ else: # WALL, DOOR, WINDOW
138
+ surface_tilt = 90.0 # Vertical
139
+ h_o = 17.0 # W/m²·K
140
+
141
+ emissivity = 0.9 # Default for opaque components
142
+ solar_absorption = 0.6 # Default
143
+ shgc = None # Only for windows/skylights
144
+
145
+ try:
146
+ # Determine surface azimuth
147
+ if component.component_type in [ComponentType.ROOF, ComponentType.SKYLIGHT]:
148
+ # Use component's orientation attribute directly, ignoring facade
149
+ surface_azimuth = getattr(component, 'orientation', 0.0)
150
+ logger.debug(f"Using component orientation for {component.id}: "
151
+ f"azimuth={surface_azimuth}, tilt={surface_tilt}")
152
+ else: # WALL, DOOR, WINDOW
153
+ # Check for facade attribute
154
+ facade = getattr(component, 'facade', None)
155
+ if not facade:
156
+ component_id = getattr(component, 'id', 'unknown_component')
157
+ raise ValueError(f"Component {component_id} is missing 'facade' field")
158
+
159
+ # Define facade azimuths based on building orientation_angle
160
+ base_azimuth = building_info.get("orientation_angle", 0.0)
161
+ facade_angles = {
162
+ "A": base_azimuth,
163
+ "B": (base_azimuth + 90.0) % 360,
164
+ "C": (base_azimuth + 180.0) % 360,
165
+ "D": (base_azimuth + 270.0) % 360
166
+ }
167
+
168
+ if facade not in facade_angles:
169
+ component_id = getattr(component, 'id', 'unknown_component')
170
+ raise ValueError(f"Invalid facade '{facade}' for component {component_id}. "
171
+ f"Expected one of {list(facade_angles.keys())}")
172
+
173
+ # Add component rotation to facade azimuth
174
+ surface_azimuth = (facade_angles[facade] + getattr(component, 'rotation', 0.0)) % 360
175
+ logger.debug(f"Component {component.id}: facade={facade}, "
176
+ f"base_azimuth={facade_angles[facade]}, rotation={getattr(component, 'rotation', 0.0)}, "
177
+ f"total_azimuth={surface_azimuth}, tilt={surface_tilt}")
178
+
179
+ # Fetch material properties
180
+ if component.component_type in [ComponentType.WALL, ComponentType.ROOF]:
181
+ construction = getattr(component, 'construction', None)
182
+ if not construction:
183
+ logger.warning(f"No construction defined for {component.id}. "
184
+ f"Using defaults: solar_absorption=0.6, emissivity=0.9.")
185
+ else:
186
+ # Get construction from library or project
187
+ construction_obj = (self.project_constructions.get(construction.name) or
188
+ self.material_library.library_constructions.get(construction.name))
189
+ if not construction_obj:
190
+ logger.error(f"Construction '{construction.name}' not found for {component.id}.")
191
+ elif not construction_obj.layers:
192
+ logger.warning(f"No layers in construction '{construction.name}' for {component.id}.")
193
+ else:
194
+ # Use first (outermost) layer's properties
195
+ first_layer = construction_obj.layers[0]
196
+ material = first_layer["material"]
197
+ solar_absorption = material.solar_absorption
198
+ emissivity = material.emissivity
199
+ logger.debug(f"Using first layer material '{material.name}' for {component.id}: "
200
+ f"solar_absorption={solar_absorption}, emissivity={emissivity}")
201
+
202
+ elif component.component_type == ComponentType.DOOR:
203
+ door_material = getattr(component, 'door_material', None)
204
+ if not door_material:
205
+ logger.warning(f"No door material defined for {component.id}. "
206
+ f"Using defaults: solar_absorption=0.6, emissivity=0.9.")
207
+ else:
208
+ # Get door material from library or project
209
+ door_material_obj = (self.project_door_materials.get(door_material.name) or
210
+ self.material_library.library_door_materials.get(door_material.name))
211
+ if not door_material_obj:
212
+ logger.error(f"Door material '{door_material.name}' not found for {component.id}.")
213
+ else:
214
+ solar_absorption = door_material_obj.solar_absorption
215
+ emissivity = door_material_obj.emissivity
216
+ logger.debug(f"Using door material '{door_material_obj.name}' for {component.id}: "
217
+ f"solar_absorption={solar_absorption}, emissivity={emissivity}")
218
+
219
+ elif component.component_type in [ComponentType.WINDOW, ComponentType.SKYLIGHT]:
220
+ glazing_material = getattr(component, 'glazing_material', None)
221
+ if not glazing_material:
222
+ logger.warning(f"No glazing material defined for {component.id}. "
223
+ f"Using default SHGC=0.7, h_o={h_o}.")
224
+ shgc = 0.7
225
+ else:
226
+ # Get glazing material from library or project
227
+ glazing_material_obj = (self.project_glazing_materials.get(glazing_material.name) or
228
+ self.material_library.library_glazing_materials.get(glazing_material.name))
229
+ if not glazing_material_obj:
230
+ logger.error(f"Glazing material '{glazing_material.name}' not found for {component.id}.")
231
+ shgc = 0.7
232
+ else:
233
+ shgc = glazing_material_obj.shgc
234
+ h_o = glazing_material_obj.h_o
235
+ logger.debug(f"Using glazing material '{glazing_material_obj.name}' for {component.id}: "
236
+ f"shgc={shgc}, h_o={h_o}")
237
+ emissivity = None # Not used for glazing
238
+
239
+ except Exception as e:
240
+ component_id = getattr(component, 'id', 'unknown_component')
241
+ logger.error(f"Error retrieving surface parameters for {component_id}: {str(e)}")
242
+ # Apply defaults
243
+ if component.component_type in [ComponentType.WALL, ComponentType.ROOF, ComponentType.DOOR]:
244
+ solar_absorption = 0.6
245
+ emissivity = 0.9
246
+ else: # WINDOW, SKYLIGHT
247
+ shgc = 0.7
248
+ # h_o retains default from component type
249
+
250
+ return surface_tilt, surface_azimuth, h_o, emissivity, solar_absorption
251
+
252
+ @staticmethod
253
+ def calculate_dynamic_shgc(glazing_type: str, cos_theta: float) -> float:
254
+ """Calculate dynamic SHGC based on incidence angle.
255
+
256
+ Args:
257
+ glazing_type (str): Type of glazing (e.g., 'Single Clear').
258
+ cos_theta (float): Cosine of the angle of incidence.
259
+
260
+ Returns:
261
+ float: Dynamic SHGC value.
262
+
263
+ References:
264
+ ASHRAE Handbook—Fundamentals, Chapter 15, Table 13.
265
+ """
266
+ if glazing_type not in SolarCalculations.SHGC_COEFFICIENTS:
267
+ logger.warning(f"Unknown glazing type '{glazing_type}'. Using default SHGC coefficients for Single Clear.")
268
+ glazing_type = "Single Clear"
269
+
270
+ c = SolarCalculations.SHGC_COEFFICIENTS[glazing_type]
271
+ # Incidence angle modifier: f(cos(θ)) = c_0 + c_1·cos(θ) + c_2·cos²(θ) + c_3·cos³(θ) + c_4·cos⁴(θ) + c_5·cos⁵(θ)
272
+ f_cos_theta = (c[0] + c[1] * cos_theta + c[2] * cos_theta**2 +
273
+ c[3] * cos_theta**3 + c[4] * cos_theta**4 + c[5] * cos_theta**5)
274
+ return f_cos_theta
275
+
276
+ def calculate_solar_parameters(
277
+ self,
278
+ hourly_data: List[Dict[str, Any]],
279
+ latitude: float,
280
+ longitude: float,
281
+ timezone: float,
282
+ ground_reflectivity: float,
283
+ components: Dict[str, List[Any]]
284
+ ) -> List[Dict[str, Any]]:
285
+ """Calculate solar angles, sol-air temperature, and solar heat gain for hourly data with GHI > 0.
286
+
287
+ Args:
288
+ hourly_data (List[Dict]): Hourly weather data containing month, day, hour, GHI, DNI, DHI, dry_bulb.
289
+ latitude (float): Latitude in degrees.
290
+ longitude (float): Longitude in degrees.
291
+ timezone (float): Timezone offset in hours.
292
+ ground_reflectivity (float): Ground reflectivity (albedo, typically 0.2).
293
+ components (Dict[str, List]): Dictionary of component lists (e.g., walls, windows) with id, area,
294
+ component_type, facade, construction, glazing_material, or door_material.
295
+
296
+ Returns:
297
+ List[Dict]: List of results for each hour with GHI > 0, containing solar angles and per-component results.
298
+
299
+ Raises:
300
+ ValueError: If required weather data or component parameters are missing or invalid.
301
+
302
+ References:
303
+ ASHRAE Handbook—Fundamentals, Chapters 18 and 15.
304
+ """
305
+ year = 2025 # Fixed year since not provided in data
306
+ results = []
307
+
308
+ # Validate input parameters
309
+ if not -90 <= latitude <= 90:
310
+ logger.warning(f"Invalid latitude {latitude}. Using default 0.0.")
311
+ latitude = 0.0
312
+ if not -180 <= longitude <= 180:
313
+ logger.warning(f"Invalid longitude {longitude}. Using default 0.0.")
314
+ longitude = 0.0
315
+ if not -12 <= timezone <= 14:
316
+ logger.warning(f"Invalid timezone {timezone}. Using default 0.0.")
317
+ timezone = 0.0
318
+ if not 0 <= ground_reflectivity <= 1:
319
+ logger.warning(f"Invalid ground_reflectivity {ground_reflectivity}. Using default 0.2.")
320
+ ground_reflectivity = 0.2
321
+
322
+ logger.info(f"Using parameters: latitude={latitude}, longitude={longitude}, timezone={timezone}, "
323
+ f"ground_reflectivity={ground_reflectivity}")
324
+
325
+ lambda_std = 15 * timezone # Standard meridian longitude (°)
326
+
327
+ # Cache facade azimuths (used only for walls, doors, windows)
328
+ building_info = components.get("_building_info", {})
329
+ facade_cache = {
330
+ "A": building_info.get("orientation_angle", 0.0),
331
+ "B": (building_info.get("orientation_angle", 0.0) + 90.0) % 360,
332
+ "C": (building_info.get("orientation_angle", 0.0) + 180.0) % 360,
333
+ "D": (building_info.get("orientation_angle", 0.0) + 270.0) % 360
334
+ }
335
+
336
+ for record in hourly_data:
337
+ # Step 1: Extract and validate data
338
+ month = record.get("month")
339
+ day = record.get("day")
340
+ hour = record.get("hour")
341
+ ghi = record.get("global_horizontal_radiation", 0)
342
+ dni = record.get("direct_normal_radiation", ghi * 0.7) # Fallback: estimate DNI
343
+ dhi = record.get("diffuse_horizontal_radiation", ghi * 0.3) # Fallback: estimate DHI
344
+ outdoor_temp = record.get("dry_bulb")
345
+
346
+ if None in [month, day, hour, outdoor_temp]:
347
+ logger.error(f"Missing required weather data for {month}/{day}/{hour}")
348
+ raise ValueError(f"Missing required weather data for {month}/{day}/{hour}")
349
+
350
+ if ghi < 0 or dni < 0 or dhi < 0:
351
+ logger.error(f"Negative radiation values for {month}/{day}/{hour}")
352
+ raise ValueError(f"Negative radiation values for {month}/{day}/{hour}")
353
+
354
+ if ghi <= 0:
355
+ logger.info(f"Skipping hour {month}/{day}/{hour} due to GHI={ghi} <= 0")
356
+ continue # Skip hours with no solar radiation
357
+
358
+ logger.info(f"Processing solar for {month}/{day}/{hour} with GHI={ghi}, DNI={dni}, DHI={dhi}, "
359
+ f"dry_bulb={outdoor_temp}")
360
+
361
+ # Step 2: Local Solar Time (LST) with Equation of Time
362
+ n = self.day_of_year(month, day, year)
363
+ EOT = self.equation_of_time(n)
364
+ standard_time = hour - 1 + 0.5 # Convert to decimal, assume mid-hour
365
+ LST = standard_time + (4 * (lambda_std - longitude) + EOT) / 60
366
+
367
+ # Step 3: Solar Declination (δ)
368
+ delta = 23.45 * math.sin(math.radians(360 / 365 * (284 + n)))
369
+
370
+ # Step 4: Hour Angle (HRA)
371
+ hra = 15 * (LST - 12)
372
+
373
+ # Step 5: Solar Altitude (α) and Azimuth (ψ)
374
+ phi = math.radians(latitude)
375
+ delta_rad = math.radians(delta)
376
+ hra_rad = math.radians(hra)
377
+
378
+ sin_alpha = math.sin(phi) * math.sin(delta_rad) + math.cos(phi) * math.cos(delta_rad) * math.cos(hra_rad)
379
+ alpha = math.degrees(math.asin(sin_alpha))
380
+
381
+ if abs(math.cos(math.radians(alpha))) < 0.01:
382
+ azimuth = 0 # North at sunrise/sunset
383
+ else:
384
+ sin_az = math.cos(delta_rad) * math.sin(hra_rad) / math.cos(math.radians(alpha))
385
+ cos_az = (sin_alpha * math.sin(phi) - math.sin(delta_rad)) / (math.cos(math.radians(alpha)) * math.cos(phi))
386
+ azimuth = math.degrees(math.atan2(sin_az, cos_az))
387
+ if hra > 0: # Afternoon
388
+ azimuth = 360 - azimuth if azimuth > 0 else -azimuth
389
+
390
+ logger.info(f"Solar angles for {month}/{day}/{hour}: declination={delta:.2f}, LST={LST:.2f}, "
391
+ f"HRA={hra:.2f}, altitude={alpha:.2f}, azimuth={azimuth:.2f}")
392
+
393
+ # Step 6: Component-specific calculations
394
+ component_results = []
395
+ for comp_type, comp_list in components.items():
396
+ if comp_type == "_building_info":
397
+ continue
398
+ for comp in comp_list:
399
+ try:
400
+ # Get surface parameters
401
+ surface_tilt, surface_azimuth, h_o, emissivity, solar_absorption = \
402
+ self.get_surface_parameters(comp, building_info)
403
+
404
+ # For windows/skylights, get SHGC from material
405
+ shgc = 0.7 # Default
406
+ if comp.component_type in [ComponentType.WINDOW, ComponentType.SKYLIGHT]:
407
+ glazing_material = getattr(comp, 'glazing_material', None)
408
+ if glazing_material:
409
+ glazing_material_obj = (self.project_glazing_materials.get(glazing_material.name) or
410
+ self.material_library.library_glazing_materials.get(glazing_material.name))
411
+ if glazing_material_obj:
412
+ shgc = glazing_material_obj.shgc
413
+ else:
414
+ logger.warning(f"Glazing material '{glazing_material.name}' not found for {comp.id}. Using default SHGC=0.7.")
415
+
416
+ # Calculate angle of incidence (θ)
417
+ cos_theta = (math.sin(math.radians(alpha)) * math.cos(math.radians(surface_tilt)) +
418
+ math.cos(math.radians(alpha)) * math.sin(math.radians(surface_tilt)) *
419
+ math.cos(math.radians(azimuth - surface_azimuth)))
420
+ cos_theta = max(min(cos_theta, 1.0), 0.0) # Clamp to [0, 1]
421
+
422
+ logger.info(f" Component {getattr(comp, 'id', 'unknown_component')} at {month}/{day}/{hour}: "
423
+ f"surface_tilt={surface_tilt:.2f}, surface_tazimuth={surface_sazimuth:.2f}, "
424
+ f"cos_theta={cos_theta:.2f}")
425
+
426
+ # Calculate total incident radiation (I_t)
427
+ view_factor = (1 - math.cos(math.radians(surface_tilt))) / 2
428
+ ground_reflected = ground_reflectivity * ghi * view_factor
429
+ I_t = dni * cos_theta + dhi + ground_reflected
430
+
431
+ # Initialize result
432
+ comp_result = {
433
+ "component_id": getattr(comp, 'id', 'unknown_component'),
434
+ "total_incident_radiation": round(I_t, 2),
435
+ "solar_absorption": round(solar_absorption, 2),
436
+ "emissivity": round(emissivity, 2) if emissivity is not None else None
437
+ }
438
+
439
+ # Calculate sol-air temperature for opaque surfaces
440
+ if comp.component_type in [ComponentType.WALL, ComponentType.ROOF, ComponentType.DOOR]:
441
+ delta_R = 63.0 if comp.component_type == ComponentType.ROOF else 0.0
442
+ sol_air_temp = outdoor_temp + (solar_absorption * I_t - (emissivity or 0.9) *
443
+ delta_R) / h_o
444
+ comp_result["sol_air_temp"] = round(sol_air_temp, 2)
445
+ logger.info(f"Sol-air temp for {comp_result['component_id']} at {month}/{day}/{hour}: {sol_air_temp:.2f}°C")
446
+
447
+ # Calculate solar heat gain for fenestration
448
+ elif comp.component_type in [ComponentType.WINDOW, ComponentType.SKYLIGHT]:
449
+ glazing_type = self.GLAZING_TYPE_MAPPING.get(comp.name, 'Single Clear')
450
+ iac = getattr(comp, 'iac', 1.0) # Default internal shading
451
+ shgc_dynamic = shgc * self.calculate_dynamic_shgc(glazing_type, cos_theta)
452
+ solar_heat_gain = comp.area * shgc_dynamic * I_t * iac / 1000 # kW
453
+ comp_result["solar_heat_gain"] = round(solar_heat_gain, 2)
454
+ comp_result["shgc_dynamic"] = round(shgc_dynamic, 2)
455
+ logger.info(f"Solar heat gain for {comp_result['component_id']} at {month}/{day}/{hour}: "
456
+ f"{solar_heat_gain:.2f} kW (area={comp.area}, shgc_dynamic={shgc_dynamic:.2f}, "
457
+ f"I_t={I_t:.2f}, iac={iac})")
458
+
459
+ component_results.append(comp_result)
460
+
461
+ except Exception as e:
462
+ component_id = getattr(comp, 'id', 'unknown_component')
463
+ logger.error(f"Error processing component {component_id} at {month}/{day}/{hour}: {str(e)}")
464
+ continue
465
+
466
+ # Store results for this hour
467
+ result = {
468
+ "month": month,
469
+ "day": day,
470
+ "hour": hour,
471
+ "declination": round(delta, 2),
472
+ "LST": round(LST, 2),
473
+ "HRA": round(hra, 2),
474
+ "altitude": round(alpha, 2),
475
+ "azimuth": round(azimuth, 2),
476
+ "component_results": component_results
477
+ }
478
+ results.append(result)
479
+
480
+ return results