Spaces:
Sleeping
Sleeping
Upload 2 files
Browse files- utils/ctf_calculations.py +230 -0
- 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
|