Spaces:
Sleeping
Sleeping
Update utils/ctf_calculations.py
Browse files- utils/ctf_calculations.py +34 -27
utils/ctf_calculations.py
CHANGED
@@ -165,14 +165,14 @@ class CTFCalculator:
|
|
165 |
@classmethod
|
166 |
def calculate_ctf_coefficients(cls, component: Dict[str, Any], hourly_data: Dict[str, Any] = None) -> CTFCoefficients:
|
167 |
"""Calculate CTF coefficients using implicit Finite Difference Method with sol-air temperature.
|
168 |
-
|
169 |
Note: Per ASHRAE, CTF calculations are skipped for WINDOW and SKYLIGHT components,
|
170 |
as they use typical material properties. CTF tables for these components will be added later.
|
171 |
-
|
172 |
Args:
|
173 |
component: Dictionary containing component properties from st.session_state.project_data["components"].
|
174 |
hourly_data: Dictionary containing hourly weather data (T_out, dew_point, wind_speed, total_sky_cover, I_t).
|
175 |
-
|
176 |
Returns:
|
177 |
CTFCoefficients: Named tuple containing X, Y, Z, and F coefficients.
|
178 |
"""
|
@@ -189,31 +189,31 @@ class CTFCalculator:
|
|
189 |
if not component_type:
|
190 |
logger.warning(f"Invalid component type '{comp_type_str}' for component '{component.get('name', 'Unknown')}'. Returning zero CTFs.")
|
191 |
return CTFCoefficients(X=[0.0], Y=[0.0], Z=[0.0], F=[0.0])
|
192 |
-
|
193 |
# Skip CTF for WINDOW, SKYLIGHT as per ASHRAE; return zero coefficients
|
194 |
if component_type in [ComponentType.WINDOW, ComponentType.SKYLIGHT]:
|
195 |
logger.info(f"Skipping CTF calculation for {component_type.value} component '{component.get('name', 'Unknown')}'. Using zero coefficients until CTF tables are implemented.")
|
196 |
return CTFCoefficients(X=[0.0], Y=[0.0], Z=[0.0], F=[0.0])
|
197 |
-
|
198 |
# Retrieve construction
|
199 |
construction_name = component.get('construction', '')
|
200 |
if not construction_name:
|
201 |
logger.warning(f"No construction specified for component '{component.get('name', 'Unknown')}' ({component_type.value}). Returning zero CTFs.")
|
202 |
return CTFCoefficients(X=[0.0], Y=[0.0], Z=[0.0], F=[0.0])
|
203 |
-
|
204 |
constructions = st.session_state.project_data.get('constructions', {})
|
205 |
construction = constructions.get('library', {}).get(construction_name, constructions.get('project', {}).get(construction_name))
|
206 |
if not construction or not construction.get('layers'):
|
207 |
logger.warning(f"No valid construction or layers found for construction '{construction_name}' in component '{component.get('name', 'Unknown')}' ({component_type.value}). Returning zero CTFs.")
|
208 |
return CTFCoefficients(X=[0.0], Y=[0.0], Z=[0.0], F=[0.0])
|
209 |
-
|
210 |
# Check cache with thread-safe access
|
211 |
construction_hash = cls._hash_construction(construction)
|
212 |
with cls._cache_lock:
|
213 |
if construction_hash in cls._ctf_cache:
|
214 |
logger.info(f"Using cached CTF coefficients for construction '{construction_name}'")
|
215 |
return cls._ctf_cache[construction_hash]
|
216 |
-
|
217 |
# Collect layer properties
|
218 |
thicknesses = []
|
219 |
material_props = []
|
@@ -229,11 +229,11 @@ class CTFCalculator:
|
|
229 |
continue
|
230 |
thicknesses.append(thickness)
|
231 |
material_props.append(material)
|
232 |
-
|
233 |
if not thicknesses or not material_props:
|
234 |
logger.warning(f"No valid layers with material properties for construction '{construction_name}' in component '{component.get('name', 'Unknown')}'. Returning zero CTFs.")
|
235 |
return CTFCoefficients(X=[0.0], Y=[0.0], Z=[0.0], F=[0.0])
|
236 |
-
|
237 |
# Extract material properties
|
238 |
k = [m['conductivity'] for m in material_props] # W/m·K
|
239 |
rho = [m['density'] for m in material_props] # kg/m³
|
@@ -241,26 +241,26 @@ class CTFCalculator:
|
|
241 |
alpha = [k_i / (rho_i * c_i) if rho_i * c_i > 0 else 0.0 for k_i, rho_i, c_i in zip(k, rho, c)] # Thermal diffusivity (m²/s)
|
242 |
absorptivity = material_props[0].get('absorptivity', 0.6) # Use first layer's absorptivity
|
243 |
emissivity = material_props[0].get('emissivity', 0.9) # Use first layer's emissivity
|
244 |
-
|
245 |
# Discretization parameters
|
246 |
dt = 3600 # 1-hour time step (s)
|
247 |
nodes_per_layer = 3 # 2–4 nodes per layer for balance
|
248 |
R_in = 0.12 # Indoor surface resistance (m²·K/W, ASHRAE)
|
249 |
-
|
250 |
# Get weather data for sol-air temperature
|
251 |
T_out = hourly_data.get('dry_bulb', 25.0) if hourly_data else 25.0
|
252 |
dew_point = hourly_data.get('dew_point', T_out - 5.0) if hourly_data else T_out - 5.0
|
253 |
wind_speed = hourly_data.get('wind_speed', 4.0) if hourly_data else 4.0
|
254 |
-
total_sky_cover = hourly_data.get('total_sky_cover', 0.5) if hourly_data else 0.
|
255 |
I_t = hourly_data.get('total_incident_radiation', 0.0) if hourly_data else 0.0
|
256 |
-
|
257 |
# Calculate dynamic h_o and sol-air temperature
|
258 |
h_o = cls.calculate_h_o(wind_speed, component_type)
|
259 |
T_sol_air = cls.calculate_sol_air_temperature(T_out, I_t, absorptivity, emissivity, h_o, dew_point, total_sky_cover)
|
260 |
R_out = 1.0 / h_o # Outdoor surface resistance based on dynamic h_o
|
261 |
-
|
262 |
logger.info(f"Calculated h_o={h_o:.2f} W/m²·K, T_sol_air={T_sol_air:.2f}°C for component '{component.get('name', 'Unknown')}'")
|
263 |
-
|
264 |
# Calculate node spacing and check stability
|
265 |
total_nodes = sum(nodes_per_layer for _ in thicknesses)
|
266 |
dx = [t / nodes_per_layer for t in thicknesses] # Node spacing per layer
|
@@ -270,7 +270,7 @@ class CTFCalculator:
|
|
270 |
for j in range(nodes_per_layer):
|
271 |
node_positions.append((i, j, node_idx)) # (layer_idx, node_in_layer, global_node_idx)
|
272 |
node_idx += 1
|
273 |
-
|
274 |
# Stability check: Fourier number
|
275 |
for i, (a, d) in enumerate(zip(alpha, dx)):
|
276 |
if a == 0 or d == 0:
|
@@ -284,22 +284,22 @@ class CTFCalculator:
|
|
284 |
dx[i] = thicknesses[i] / nodes_per_layer
|
285 |
Fo = a * dt / (dx[i] ** 2)
|
286 |
logger.info(f"Adjusted node spacing for layer {i}: dx={dx[i]:.4f} m, Fo={Fo:.3f}")
|
287 |
-
|
288 |
# Build system matrices
|
289 |
A = sparse.lil_matrix((total_nodes, total_nodes))
|
290 |
b = np.zeros(total_nodes)
|
291 |
node_to_layer = [i for i, _, _ in node_positions]
|
292 |
-
|
293 |
for idx, (layer_idx, node_j, global_idx) in enumerate(node_positions):
|
294 |
k_i = k[layer_idx]
|
295 |
rho_i = rho[layer_idx]
|
296 |
c_i = c[layer_idx]
|
297 |
dx_i = dx[layer_idx]
|
298 |
-
|
299 |
if k_i == 0 or rho_i == 0 or c_i == 0 or dx_i == 0:
|
300 |
logger.warning(f"Invalid material properties for layer {layer_idx} ({material_props[layer_idx]['name']}) in construction '{construction_name}'. Using default values.")
|
301 |
continue
|
302 |
-
|
303 |
if node_j == 0 and layer_idx == 0: # Outdoor surface node
|
304 |
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)
|
305 |
A[idx, idx + 1] = -2 * dt * k_i / (dx_i * rho_i * c_i * dx_i)
|
@@ -308,6 +308,13 @@ class CTFCalculator:
|
|
308 |
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)
|
309 |
A[idx, idx - 1] = -2 * dt * k_i / (dx_i * rho_i * c_i * dx_i)
|
310 |
b[idx] = dt / (rho_i * c_i * dx_i * R_in) # Indoor temp contribution
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
311 |
elif node_j == nodes_per_layer - 1 and layer_idx < len(thicknesses) - 1: # Interface between layers
|
312 |
k_next = k[layer_idx + 1]
|
313 |
dx_next = dx[layer_idx + 1]
|
@@ -334,9 +341,9 @@ class CTFCalculator:
|
|
334 |
A[idx, idx] = 1.0 + 2 * dt * k_i / (dx_i * rho_i * c_i * dx_i)
|
335 |
A[idx, idx - 1] = -dt * k_i / (dx_i * rho_i * c_i * dx_i)
|
336 |
A[idx, idx + 1] = -dt * k_i / (dx_i * rho_i * c_i * dx_i)
|
337 |
-
|
338 |
A = A.tocsr() # Convert to CSR for efficient solving
|
339 |
-
|
340 |
# Calculate CTF coefficients (X, Y, Z, F)
|
341 |
num_ctf = 12 # Standard number of coefficients
|
342 |
X = [0.0] * num_ctf # Exterior temp response
|
@@ -344,7 +351,7 @@ class CTFCalculator:
|
|
344 |
Z = [0.0] * num_ctf # Interior temp response
|
345 |
F = [0.0] * num_ctf # Flux history
|
346 |
T_prev = np.zeros(total_nodes) # Previous temperatures
|
347 |
-
|
348 |
# Impulse response for exterior temperature (X, Y)
|
349 |
for t in range(num_ctf):
|
350 |
b_out = b.copy()
|
@@ -356,7 +363,7 @@ class CTFCalculator:
|
|
356 |
q_out = (0.0 - T[0]) / R_out # Outdoor heat flux
|
357 |
X[t] = q_out
|
358 |
T_prev = T.copy()
|
359 |
-
|
360 |
# Reset for interior temperature (Z)
|
361 |
T_prev = np.zeros(total_nodes)
|
362 |
for t in range(num_ctf):
|
@@ -367,7 +374,7 @@ class CTFCalculator:
|
|
367 |
q_in = (T[-1] - 0.0) / R_in
|
368 |
Z[t] = q_in
|
369 |
T_prev = T.copy()
|
370 |
-
|
371 |
# Flux history coefficients (F)
|
372 |
T_prev = np.zeros(total_nodes)
|
373 |
for t in range(num_ctf):
|
@@ -378,7 +385,7 @@ class CTFCalculator:
|
|
378 |
q_in = (T[-1] - 0.0) / R_in
|
379 |
F[t] = q_in
|
380 |
T_prev = T.copy()
|
381 |
-
|
382 |
ctf = CTFCoefficients(X=X, Y=Y, Z=Z, F=F)
|
383 |
with cls._cache_lock:
|
384 |
cls._ctf_cache[construction_hash] = ctf
|
|
|
165 |
@classmethod
|
166 |
def calculate_ctf_coefficients(cls, component: Dict[str, Any], hourly_data: Dict[str, Any] = None) -> CTFCoefficients:
|
167 |
"""Calculate CTF coefficients using implicit Finite Difference Method with sol-air temperature.
|
168 |
+
|
169 |
Note: Per ASHRAE, CTF calculations are skipped for WINDOW and SKYLIGHT components,
|
170 |
as they use typical material properties. CTF tables for these components will be added later.
|
171 |
+
|
172 |
Args:
|
173 |
component: Dictionary containing component properties from st.session_state.project_data["components"].
|
174 |
hourly_data: Dictionary containing hourly weather data (T_out, dew_point, wind_speed, total_sky_cover, I_t).
|
175 |
+
|
176 |
Returns:
|
177 |
CTFCoefficients: Named tuple containing X, Y, Z, and F coefficients.
|
178 |
"""
|
|
|
189 |
if not component_type:
|
190 |
logger.warning(f"Invalid component type '{comp_type_str}' for component '{component.get('name', 'Unknown')}'. Returning zero CTFs.")
|
191 |
return CTFCoefficients(X=[0.0], Y=[0.0], Z=[0.0], F=[0.0])
|
192 |
+
|
193 |
# Skip CTF for WINDOW, SKYLIGHT as per ASHRAE; return zero coefficients
|
194 |
if component_type in [ComponentType.WINDOW, ComponentType.SKYLIGHT]:
|
195 |
logger.info(f"Skipping CTF calculation for {component_type.value} component '{component.get('name', 'Unknown')}'. Using zero coefficients until CTF tables are implemented.")
|
196 |
return CTFCoefficients(X=[0.0], Y=[0.0], Z=[0.0], F=[0.0])
|
197 |
+
|
198 |
# Retrieve construction
|
199 |
construction_name = component.get('construction', '')
|
200 |
if not construction_name:
|
201 |
logger.warning(f"No construction specified for component '{component.get('name', 'Unknown')}' ({component_type.value}). Returning zero CTFs.")
|
202 |
return CTFCoefficients(X=[0.0], Y=[0.0], Z=[0.0], F=[0.0])
|
203 |
+
|
204 |
constructions = st.session_state.project_data.get('constructions', {})
|
205 |
construction = constructions.get('library', {}).get(construction_name, constructions.get('project', {}).get(construction_name))
|
206 |
if not construction or not construction.get('layers'):
|
207 |
logger.warning(f"No valid construction or layers found for construction '{construction_name}' in component '{component.get('name', 'Unknown')}' ({component_type.value}). Returning zero CTFs.")
|
208 |
return CTFCoefficients(X=[0.0], Y=[0.0], Z=[0.0], F=[0.0])
|
209 |
+
|
210 |
# Check cache with thread-safe access
|
211 |
construction_hash = cls._hash_construction(construction)
|
212 |
with cls._cache_lock:
|
213 |
if construction_hash in cls._ctf_cache:
|
214 |
logger.info(f"Using cached CTF coefficients for construction '{construction_name}'")
|
215 |
return cls._ctf_cache[construction_hash]
|
216 |
+
|
217 |
# Collect layer properties
|
218 |
thicknesses = []
|
219 |
material_props = []
|
|
|
229 |
continue
|
230 |
thicknesses.append(thickness)
|
231 |
material_props.append(material)
|
232 |
+
|
233 |
if not thicknesses or not material_props:
|
234 |
logger.warning(f"No valid layers with material properties for construction '{construction_name}' in component '{component.get('name', 'Unknown')}'. Returning zero CTFs.")
|
235 |
return CTFCoefficients(X=[0.0], Y=[0.0], Z=[0.0], F=[0.0])
|
236 |
+
|
237 |
# Extract material properties
|
238 |
k = [m['conductivity'] for m in material_props] # W/m·K
|
239 |
rho = [m['density'] for m in material_props] # kg/m³
|
|
|
241 |
alpha = [k_i / (rho_i * c_i) if rho_i * c_i > 0 else 0.0 for k_i, rho_i, c_i in zip(k, rho, c)] # Thermal diffusivity (m²/s)
|
242 |
absorptivity = material_props[0].get('absorptivity', 0.6) # Use first layer's absorptivity
|
243 |
emissivity = material_props[0].get('emissivity', 0.9) # Use first layer's emissivity
|
244 |
+
|
245 |
# Discretization parameters
|
246 |
dt = 3600 # 1-hour time step (s)
|
247 |
nodes_per_layer = 3 # 2–4 nodes per layer for balance
|
248 |
R_in = 0.12 # Indoor surface resistance (m²·K/W, ASHRAE)
|
249 |
+
|
250 |
# Get weather data for sol-air temperature
|
251 |
T_out = hourly_data.get('dry_bulb', 25.0) if hourly_data else 25.0
|
252 |
dew_point = hourly_data.get('dew_point', T_out - 5.0) if hourly_data else T_out - 5.0
|
253 |
wind_speed = hourly_data.get('wind_speed', 4.0) if hourly_data else 4.0
|
254 |
+
total_sky_cover = hourly_data.get('total_sky_cover', 0.5) if hourly_data else 0.0
|
255 |
I_t = hourly_data.get('total_incident_radiation', 0.0) if hourly_data else 0.0
|
256 |
+
|
257 |
# Calculate dynamic h_o and sol-air temperature
|
258 |
h_o = cls.calculate_h_o(wind_speed, component_type)
|
259 |
T_sol_air = cls.calculate_sol_air_temperature(T_out, I_t, absorptivity, emissivity, h_o, dew_point, total_sky_cover)
|
260 |
R_out = 1.0 / h_o # Outdoor surface resistance based on dynamic h_o
|
261 |
+
|
262 |
logger.info(f"Calculated h_o={h_o:.2f} W/m²·K, T_sol_air={T_sol_air:.2f}°C for component '{component.get('name', 'Unknown')}'")
|
263 |
+
|
264 |
# Calculate node spacing and check stability
|
265 |
total_nodes = sum(nodes_per_layer for _ in thicknesses)
|
266 |
dx = [t / nodes_per_layer for t in thicknesses] # Node spacing per layer
|
|
|
270 |
for j in range(nodes_per_layer):
|
271 |
node_positions.append((i, j, node_idx)) # (layer_idx, node_in_layer, global_node_idx)
|
272 |
node_idx += 1
|
273 |
+
|
274 |
# Stability check: Fourier number
|
275 |
for i, (a, d) in enumerate(zip(alpha, dx)):
|
276 |
if a == 0 or d == 0:
|
|
|
284 |
dx[i] = thicknesses[i] / nodes_per_layer
|
285 |
Fo = a * dt / (dx[i] ** 2)
|
286 |
logger.info(f"Adjusted node spacing for layer {i}: dx={dx[i]:.4f} m, Fo={Fo:.3f}")
|
287 |
+
|
288 |
# Build system matrices
|
289 |
A = sparse.lil_matrix((total_nodes, total_nodes))
|
290 |
b = np.zeros(total_nodes)
|
291 |
node_to_layer = [i for i, _, _ in node_positions]
|
292 |
+
|
293 |
for idx, (layer_idx, node_j, global_idx) in enumerate(node_positions):
|
294 |
k_i = k[layer_idx]
|
295 |
rho_i = rho[layer_idx]
|
296 |
c_i = c[layer_idx]
|
297 |
dx_i = dx[layer_idx]
|
298 |
+
|
299 |
if k_i == 0 or rho_i == 0 or c_i == 0 or dx_i == 0:
|
300 |
logger.warning(f"Invalid material properties for layer {layer_idx} ({material_props[layer_idx]['name']}) in construction '{construction_name}'. Using default values.")
|
301 |
continue
|
302 |
+
|
303 |
if node_j == 0 and layer_idx == 0: # Outdoor surface node
|
304 |
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)
|
305 |
A[idx, idx + 1] = -2 * dt * k_i / (dx_i * rho_i * c_i * dx_i)
|
|
|
308 |
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)
|
309 |
A[idx, idx - 1] = -2 * dt * k_i / (dx_i * rho_i * c_i * dx_i)
|
310 |
b[idx] = dt / (rho_i * c_i * dx_i * R_in) # Indoor temp contribution
|
311 |
+
# Add radiant load to indoor surface node (convert kW to W)
|
312 |
+
radiant_load = component.get("radiant_load", 0.0) * 1000 # kW to W
|
313 |
+
if radiant_load != 0 and rho_i * c_i * dx_i != 0:
|
314 |
+
b[idx] += dt / (rho_i * c_i * dx_i) * radiant_load
|
315 |
+
logger.debug(f"Added radiant load {radiant_load:.2f} W to indoor node for component '{component.get('name', 'Unknown')}'")
|
316 |
+
elif radiant_load != 0:
|
317 |
+
logger.warning(f"Invalid material properties (rho={rho_i}, c={c_i}, dx={dx_i}) for radiant load in component '{component.get('name', 'Unknown')}'. Skipping.")
|
318 |
elif node_j == nodes_per_layer - 1 and layer_idx < len(thicknesses) - 1: # Interface between layers
|
319 |
k_next = k[layer_idx + 1]
|
320 |
dx_next = dx[layer_idx + 1]
|
|
|
341 |
A[idx, idx] = 1.0 + 2 * dt * k_i / (dx_i * rho_i * c_i * dx_i)
|
342 |
A[idx, idx - 1] = -dt * k_i / (dx_i * rho_i * c_i * dx_i)
|
343 |
A[idx, idx + 1] = -dt * k_i / (dx_i * rho_i * c_i * dx_i)
|
344 |
+
|
345 |
A = A.tocsr() # Convert to CSR for efficient solving
|
346 |
+
|
347 |
# Calculate CTF coefficients (X, Y, Z, F)
|
348 |
num_ctf = 12 # Standard number of coefficients
|
349 |
X = [0.0] * num_ctf # Exterior temp response
|
|
|
351 |
Z = [0.0] * num_ctf # Interior temp response
|
352 |
F = [0.0] * num_ctf # Flux history
|
353 |
T_prev = np.zeros(total_nodes) # Previous temperatures
|
354 |
+
|
355 |
# Impulse response for exterior temperature (X, Y)
|
356 |
for t in range(num_ctf):
|
357 |
b_out = b.copy()
|
|
|
363 |
q_out = (0.0 - T[0]) / R_out # Outdoor heat flux
|
364 |
X[t] = q_out
|
365 |
T_prev = T.copy()
|
366 |
+
|
367 |
# Reset for interior temperature (Z)
|
368 |
T_prev = np.zeros(total_nodes)
|
369 |
for t in range(num_ctf):
|
|
|
374 |
q_in = (T[-1] - 0.0) / R_in
|
375 |
Z[t] = q_in
|
376 |
T_prev = T.copy()
|
377 |
+
|
378 |
# Flux history coefficients (F)
|
379 |
T_prev = np.zeros(total_nodes)
|
380 |
for t in range(num_ctf):
|
|
|
385 |
q_in = (T[-1] - 0.0) / R_in
|
386 |
F[t] = q_in
|
387 |
T_prev = T.copy()
|
388 |
+
|
389 |
ctf = CTFCoefficients(X=X, Y=Y, Z=Z, F=F)
|
390 |
with cls._cache_lock:
|
391 |
cls._ctf_cache[construction_hash] = ctf
|