Spaces:
Sleeping
Sleeping
Update utils/ctf_calculations.py
Browse files- utils/ctf_calculations.py +45 -161
utils/ctf_calculations.py
CHANGED
|
@@ -113,17 +113,16 @@ class CTFCalculator:
|
|
| 113 |
return T_sol_air
|
| 114 |
|
| 115 |
@staticmethod
|
| 116 |
-
def _hash_construction(construction: Dict[str, Any]
|
| 117 |
-
"""Generate a unique hash for a construction based on its properties
|
| 118 |
|
| 119 |
Args:
|
| 120 |
construction: Dictionary containing construction properties (name, layers, adiabatic).
|
| 121 |
-
nodes_per_layer: List of number of nodes per layer used in discretization.
|
| 122 |
|
| 123 |
Returns:
|
| 124 |
-
str: SHA-256 hash of the construction properties
|
| 125 |
"""
|
| 126 |
-
hash_input = f"{construction.get('name', '')}{construction.get('adiabatic', False)}
|
| 127 |
layers = construction.get('layers', [])
|
| 128 |
for layer in layers:
|
| 129 |
material_name = layer.get('material', '')
|
|
@@ -133,14 +132,14 @@ class CTFCalculator:
|
|
| 133 |
|
| 134 |
@classmethod
|
| 135 |
def _get_material_properties(cls, material_name: str) -> Dict[str, float]:
|
| 136 |
-
"""Retrieve material properties from session state
|
| 137 |
|
| 138 |
Args:
|
| 139 |
material_name: Name of the material.
|
| 140 |
|
| 141 |
Returns:
|
| 142 |
Dict containing conductivity, density, specific_heat, absorptivity, emissivity.
|
| 143 |
-
Returns empty dict if material not found
|
| 144 |
"""
|
| 145 |
try:
|
| 146 |
materials = st.session_state.project_data.get('materials', {})
|
|
@@ -151,26 +150,11 @@ class CTFCalculator:
|
|
| 151 |
|
| 152 |
# Extract required properties
|
| 153 |
thermal_props = material.get('thermal_properties', {})
|
| 154 |
-
conductivity = thermal_props.get('conductivity', 0.0)
|
| 155 |
-
density = thermal_props.get('density', 0.0)
|
| 156 |
-
specific_heat = thermal_props.get('specific_heat', 0.0)
|
| 157 |
-
|
| 158 |
-
# Validate properties
|
| 159 |
-
if conductivity <= 0 or conductivity > 1000: # Reasonable bounds for conductivity (W/m·K)
|
| 160 |
-
logger.error(f"Invalid conductivity {conductivity} for material '{material_name}'. Must be between 0 and 1000 W/m·K.")
|
| 161 |
-
return {}
|
| 162 |
-
if density <= 0 or density > 10000: # Reasonable bounds for density (kg/m³)
|
| 163 |
-
logger.error(f"Invalid density {density} for material '{material_name}'. Must be between 0 and 10000 kg/m³.")
|
| 164 |
-
return {}
|
| 165 |
-
if specific_heat <= 0 or specific_heat > 5000: # Reasonable bounds for specific heat (J/kg·K)
|
| 166 |
-
logger.error(f"Invalid specific heat {specific_heat} for material '{material_name}'. Must be between 0 and 5000 J/kg·K.")
|
| 167 |
-
return {}
|
| 168 |
-
|
| 169 |
return {
|
| 170 |
'name': material_name,
|
| 171 |
-
'conductivity': conductivity,
|
| 172 |
-
'density': density,
|
| 173 |
-
'specific_heat': specific_heat,
|
| 174 |
'absorptivity': material.get('absorptivity', 0.6),
|
| 175 |
'emissivity': material.get('emissivity', 0.9)
|
| 176 |
}
|
|
@@ -233,6 +217,13 @@ class CTFCalculator:
|
|
| 233 |
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.")
|
| 234 |
return CTFCoefficients(X=[0.0], Y=[0.0], Z=[0.0], F=[0.0])
|
| 235 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
# Collect layer properties
|
| 237 |
thicknesses = []
|
| 238 |
material_props = []
|
|
@@ -244,7 +235,7 @@ class CTFCalculator:
|
|
| 244 |
continue
|
| 245 |
material = cls._get_material_properties(material_name)
|
| 246 |
if not material:
|
| 247 |
-
logger.warning(f"Skipping layer with material '{material_name}' in construction '{construction_name}' due to missing
|
| 248 |
continue
|
| 249 |
thicknesses.append(thickness)
|
| 250 |
material_props.append(material)
|
|
@@ -263,7 +254,7 @@ class CTFCalculator:
|
|
| 263 |
|
| 264 |
# Discretization parameters
|
| 265 |
dt = 3600 # 1-hour time step (s)
|
| 266 |
-
nodes_per_layer =
|
| 267 |
R_in = 0.12 # Indoor surface resistance (m²·K/W, ASHRAE)
|
| 268 |
|
| 269 |
# Get weather data for sol-air temperature
|
|
@@ -280,52 +271,31 @@ class CTFCalculator:
|
|
| 280 |
|
| 281 |
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')}'")
|
| 282 |
|
| 283 |
-
#
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
|
|
|
|
|
|
|
|
|
| 289 |
|
| 290 |
-
# Calculate node spacing
|
| 291 |
-
dx = [t / n for t, n in zip(thicknesses, nodes_per_layer)] # Initial node spacing per layer
|
| 292 |
-
|
| 293 |
# Stability check: Fourier number
|
| 294 |
-
for i, (a, d
|
| 295 |
if a == 0 or d == 0:
|
| 296 |
logger.warning(f"Invalid thermal diffusivity or node spacing for layer {i} in construction '{construction_name}'. Skipping stability adjustment.")
|
| 297 |
continue
|
| 298 |
Fo = a * dt / (d ** 2)
|
| 299 |
-
if Fo
|
| 300 |
-
logger.warning(f"Fourier number {Fo:.3f}
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
dx[i] = np.sqrt(a * dt / 0.4) # Target Fo = 0.4
|
| 307 |
-
nodes_per_layer[i] = max(2, int(np.ceil(t / dx[i])))
|
| 308 |
-
dx[i] = t / nodes_per_layer[i]
|
| 309 |
-
Fo = a * dt / (dx[i] ** 2) if dx[i] != 0 else 0.0
|
| 310 |
-
logger.info(f"Adjusted node spacing for layer {i}: dx={dx[i]:.4f} m, Fo={Fo:.3f}, nodes={nodes_per_layer[i]}")
|
| 311 |
-
|
| 312 |
-
# Update construction hash if nodes_per_layer changed
|
| 313 |
-
construction_hash = cls._hash_construction(construction, nodes_per_layer)
|
| 314 |
-
with cls._cache_lock:
|
| 315 |
-
if construction_hash in cls._ctf_cache:
|
| 316 |
-
logger.info(f"Using cached CTF coefficients for construction '{construction_name}' after node adjustment")
|
| 317 |
-
return cls._ctf_cache[construction_hash]
|
| 318 |
-
|
| 319 |
-
# Build node positions
|
| 320 |
-
node_positions = []
|
| 321 |
-
node_idx = 0
|
| 322 |
-
for i, n in enumerate(nodes_per_layer):
|
| 323 |
-
for j in range(n):
|
| 324 |
-
node_positions.append((i, j, node_idx)) # (layer_idx, node_in_layer, global_node_idx)
|
| 325 |
-
node_idx += 1
|
| 326 |
|
| 327 |
# Build system matrices
|
| 328 |
-
total_nodes = sum(nodes_per_layer)
|
| 329 |
A = sparse.lil_matrix((total_nodes, total_nodes))
|
| 330 |
b = np.zeros(total_nodes)
|
| 331 |
node_to_layer = [i for i, _, _ in node_positions]
|
|
@@ -335,7 +305,6 @@ class CTFCalculator:
|
|
| 335 |
rho_i = rho[layer_idx]
|
| 336 |
c_i = c[layer_idx]
|
| 337 |
dx_i = dx[layer_idx]
|
| 338 |
-
npl_i = nodes_per_layer[layer_idx]
|
| 339 |
|
| 340 |
if k_i == 0 or rho_i == 0 or c_i == 0 or dx_i == 0:
|
| 341 |
logger.warning(f"Invalid material properties for layer {layer_idx} ({material_props[layer_idx]['name']}) in construction '{construction_name}'. Using default values.")
|
|
@@ -343,11 +312,11 @@ class CTFCalculator:
|
|
| 343 |
|
| 344 |
if node_j == 0 and layer_idx == 0: # Outdoor surface node
|
| 345 |
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)
|
| 346 |
-
A[idx, idx + 1] = -2 * dt * k_i / (dx_i * rho_i * c_i * dx_i)
|
| 347 |
b[idx] = dt / (rho_i * c_i * dx_i * R_out) * T_sol_air # Use sol-air temperature
|
| 348 |
-
elif node_j ==
|
| 349 |
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)
|
| 350 |
-
A[idx, idx - 1] = -2 * dt * k_i / (dx_i * rho_i * c_i * dx_i)
|
| 351 |
b[idx] = dt / (rho_i * c_i * dx_i * R_in) # Indoor temp contribution
|
| 352 |
# Add radiant load to indoor surface node (convert kW to W)
|
| 353 |
radiant_load = component.get("radiant_load", 0.0) * 1000 # kW to W
|
|
@@ -356,7 +325,7 @@ class CTFCalculator:
|
|
| 356 |
logger.debug(f"Added radiant load {radiant_load:.2f} W to indoor node for component '{component.get('name', 'Unknown')}'")
|
| 357 |
elif radiant_load != 0:
|
| 358 |
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.")
|
| 359 |
-
elif node_j ==
|
| 360 |
k_next = k[layer_idx + 1]
|
| 361 |
dx_next = dx[layer_idx + 1]
|
| 362 |
rho_next = rho[layer_idx + 1]
|
|
@@ -365,8 +334,8 @@ class CTFCalculator:
|
|
| 365 |
logger.warning(f"Invalid material properties for layer {layer_idx + 1} ({material_props[layer_idx + 1]['name']}) in construction '{construction_name}'. Skipping interface.")
|
| 366 |
continue
|
| 367 |
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))
|
| 368 |
-
A[idx, idx - 1] = -dt * k_i / (dx_i * 0.5 * (rho_i * c_i * dx_i + rho_next * c_next * dx_next))
|
| 369 |
-
A[idx, idx + 1] = -dt * k_next / (dx_next * 0.5 * (rho_i * c_i * dx_i + rho_next * c_next * dx_next))
|
| 370 |
elif node_j == 0 and layer_idx > 0: # Interface from previous layer
|
| 371 |
k_prev = k[layer_idx - 1]
|
| 372 |
dx_prev = dx[layer_idx - 1]
|
|
@@ -376,100 +345,15 @@ class CTFCalculator:
|
|
| 376 |
logger.warning(f"Invalid material properties for layer {layer_idx - 1} ({material_props[layer_idx - 1]['name']}) in construction '{construction_name}'. Skipping interface.")
|
| 377 |
continue
|
| 378 |
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))
|
| 379 |
-
A[idx, idx - 1] = -dt * k_prev / (dx_prev * 0.5 * (rho_prev * c_prev * dx_prev + rho_i * c_i * dx_i))
|
| 380 |
-
A[idx, idx + 1] = -dt * k_i / (dx_i * 0.5 * (rho_prev * c_prev * dx_prev + rho_i * c_i * dx_i))
|
| 381 |
else: # Internal node
|
| 382 |
A[idx, idx] = 1.0 + 2 * dt * k_i / (dx_i * rho_i * c_i * dx_i)
|
| 383 |
-
A[idx, idx - 1] = -dt * k_i / (dx_i * rho_i * c_i * dx_i)
|
| 384 |
-
A[idx, idx + 1] = -dt * k_i / (dx_i * rho_i * c_i * dx_i)
|
| 385 |
|
| 386 |
A = A.tocsr() # Convert to CSR for efficient solving
|
| 387 |
|
| 388 |
-
# Check matrix conditioning
|
| 389 |
-
try:
|
| 390 |
-
from scipy.sparse.linalg import norm
|
| 391 |
-
A_csc = A.tocsc()
|
| 392 |
-
cond_number = norm(A, ord=2) * norm(sparse_linalg.inv(A_csc), ord=2)
|
| 393 |
-
if cond_number > 1e6:
|
| 394 |
-
logger.warning(f"Matrix A is ill-conditioned (condition number: {cond_number:.2e}). Adjusting node spacing.")
|
| 395 |
-
nodes_per_layer = [min(n + 1, 6) for n in nodes_per_layer] # Increase nodes, cap at 6
|
| 396 |
-
dx = [t / n for t, n in zip(thicknesses, nodes_per_layer)]
|
| 397 |
-
|
| 398 |
-
# Recalculate node positions
|
| 399 |
-
node_positions = []
|
| 400 |
-
node_idx = 0
|
| 401 |
-
for i, n in enumerate(nodes_per_layer):
|
| 402 |
-
for j in range(n):
|
| 403 |
-
node_positions.append((i, j, node_idx))
|
| 404 |
-
node_idx += 1
|
| 405 |
-
|
| 406 |
-
# Rebuild system matrices
|
| 407 |
-
total_nodes = sum(nodes_per_layer)
|
| 408 |
-
A = sparse.lil_matrix((total_nodes, total_nodes))
|
| 409 |
-
b = np.zeros(total_nodes)
|
| 410 |
-
node_to_layer = [i for i, _, _ in node_positions]
|
| 411 |
-
|
| 412 |
-
for idx, (layer_idx, node_j, global_idx) in enumerate(node_positions):
|
| 413 |
-
k_i = k[layer_idx]
|
| 414 |
-
rho_i = rho[layer_idx]
|
| 415 |
-
c_i = c[layer_idx]
|
| 416 |
-
dx_i = dx[layer_idx]
|
| 417 |
-
npl_i = nodes_per_layer[layer_idx]
|
| 418 |
-
|
| 419 |
-
if k_i == 0 or rho_i == 0 or c_i == 0 or dx_i == 0:
|
| 420 |
-
logger.warning(f"Invalid material properties for layer {layer_idx} ({material_props[layer_idx]['name']}) in construction '{construction_name}'. Using default values.")
|
| 421 |
-
continue
|
| 422 |
-
|
| 423 |
-
if node_j == 0 and layer_idx == 0:
|
| 424 |
-
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)
|
| 425 |
-
A[idx, idx + 1] = -2 * dt * k_i / (dx_i * rho_i * c_i * dx_i) if idx + 1 < total_nodes else 0.0
|
| 426 |
-
b[idx] = dt / (rho_i * c_i * dx_i * R_out) * T_sol_air
|
| 427 |
-
elif node_j == npl_i - 1 and layer_idx == len(thicknesses) - 1:
|
| 428 |
-
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)
|
| 429 |
-
A[idx, idx - 1] = -2 * dt * k_i / (dx_i * rho_i * c_i * dx_i) if idx - 1 >= 0 else 0.0
|
| 430 |
-
b[idx] = dt / (rho_i * c_i * dx_i * R_in)
|
| 431 |
-
radiant_load = component.get("radiant_load", 0.0) * 1000
|
| 432 |
-
if radiant_load != 0 and rho_i * c_i * dx_i != 0:
|
| 433 |
-
b[idx] += dt / (rho_i * c_i * dx_i) * radiant_load
|
| 434 |
-
logger.debug(f"Added radiant load {radiant_load:.2f} W to indoor node for component '{component.get('name', 'Unknown')}'")
|
| 435 |
-
elif radiant_load != 0:
|
| 436 |
-
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.")
|
| 437 |
-
elif node_j == npl_i - 1 and layer_idx < len(thicknesses) - 1:
|
| 438 |
-
k_next = k[layer_idx + 1]
|
| 439 |
-
dx_next = dx[layer_idx + 1]
|
| 440 |
-
rho_next = rho[layer_idx + 1]
|
| 441 |
-
c_next = c[layer_idx + 1]
|
| 442 |
-
if k_next == 0 or dx_next == 0 or rho_next == 0 or c_next == 0:
|
| 443 |
-
logger.warning(f"Invalid material properties for layer {layer_idx + 1} ({material_props[layer_idx + 1]['name']}) in construction '{construction_name}'. Skipping interface.")
|
| 444 |
-
continue
|
| 445 |
-
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))
|
| 446 |
-
A[idx, idx - 1] = -dt * k_i / (dx_i * 0.5 * (rho_i * c_i * dx_i + rho_next * c_next * dx_next)) if idx - 1 >= 0 else 0.0
|
| 447 |
-
A[idx, idx + 1] = -dt * k_next / (dx_next * 0.5 * (rho_i * c_i * dx_i + rho_next * c_next * dx_next)) if idx + 1 < total_nodes else 0.0
|
| 448 |
-
elif node_j == 0 and layer_idx > 0:
|
| 449 |
-
k_prev = k[layer_idx - 1]
|
| 450 |
-
dx_prev = dx[layer_idx - 1]
|
| 451 |
-
rho_prev = rho[layer_idx - 1]
|
| 452 |
-
c_prev = c[layer_idx - 1]
|
| 453 |
-
if k_prev == 0 or dx_prev == 0 or rho_prev == 0 or c_prev == 0:
|
| 454 |
-
logger.warning(f"Invalid material properties for layer {layer_idx - 1} ({material_props[layer_idx - 1]['name']}) in construction '{construction_name}'. Skipping interface.")
|
| 455 |
-
continue
|
| 456 |
-
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))
|
| 457 |
-
A[idx, idx - 1] = -dt * k_prev / (dx_prev * 0.5 * (rho_prev * c_prev * dx_prev + rho_i * c_i * dx_i)) if idx - 1 >= 0 else 0.0
|
| 458 |
-
A[idx, idx + 1] = -dt * k_i / (dx_i * 0.5 * (rho_prev * c_prev * dx_prev + rho_i * c_i * dx_i)) if idx + 1 < total_nodes else 0.0
|
| 459 |
-
else:
|
| 460 |
-
A[idx, idx] = 1.0 + 2 * dt * k_i / (dx_i * rho_i * c_i * dx_i)
|
| 461 |
-
A[idx, idx - 1] = -dt * k_i / (dx_i * rho_i * c_i * dx_i) if idx - 1 >= 0 else 0.0
|
| 462 |
-
A[idx, idx + 1] = -dt * k_i / (dx_i * rho_i * c_i * dx_i) if idx + 1 < total_nodes else 0.0
|
| 463 |
-
|
| 464 |
-
A = A.tocsr()
|
| 465 |
-
construction_hash = cls._hash_construction(construction, nodes_per_layer)
|
| 466 |
-
with cls._cache_lock:
|
| 467 |
-
if construction_hash in cls._ctf_cache:
|
| 468 |
-
logger.info(f"Using cached CTF coefficients for construction '{construction_name}' after matrix conditioning adjustment")
|
| 469 |
-
return cls._ctf_cache[construction_hash]
|
| 470 |
-
except Exception as e:
|
| 471 |
-
logger.warning(f"Failed to compute matrix condition number: {str(e)}. Proceeding with current discretization.")
|
| 472 |
-
|
| 473 |
# Calculate CTF coefficients (X, Y, Z, F)
|
| 474 |
num_ctf = 12 # Standard number of coefficients
|
| 475 |
X = [0.0] * num_ctf # Exterior temp response
|
|
|
|
| 113 |
return T_sol_air
|
| 114 |
|
| 115 |
@staticmethod
|
| 116 |
+
def _hash_construction(construction: Dict[str, Any]) -> str:
|
| 117 |
+
"""Generate a unique hash for a construction based on its properties.
|
| 118 |
|
| 119 |
Args:
|
| 120 |
construction: Dictionary containing construction properties (name, layers, adiabatic).
|
|
|
|
| 121 |
|
| 122 |
Returns:
|
| 123 |
+
str: SHA-256 hash of the construction properties.
|
| 124 |
"""
|
| 125 |
+
hash_input = f"{construction.get('name', '')}{construction.get('adiabatic', False)}"
|
| 126 |
layers = construction.get('layers', [])
|
| 127 |
for layer in layers:
|
| 128 |
material_name = layer.get('material', '')
|
|
|
|
| 132 |
|
| 133 |
@classmethod
|
| 134 |
def _get_material_properties(cls, material_name: str) -> Dict[str, float]:
|
| 135 |
+
"""Retrieve material properties from session state.
|
| 136 |
|
| 137 |
Args:
|
| 138 |
material_name: Name of the material.
|
| 139 |
|
| 140 |
Returns:
|
| 141 |
Dict containing conductivity, density, specific_heat, absorptivity, emissivity.
|
| 142 |
+
Returns empty dict if material not found.
|
| 143 |
"""
|
| 144 |
try:
|
| 145 |
materials = st.session_state.project_data.get('materials', {})
|
|
|
|
| 150 |
|
| 151 |
# Extract required properties
|
| 152 |
thermal_props = material.get('thermal_properties', {})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
return {
|
| 154 |
'name': material_name,
|
| 155 |
+
'conductivity': thermal_props.get('conductivity', 0.0),
|
| 156 |
+
'density': thermal_props.get('density', 0.0),
|
| 157 |
+
'specific_heat': thermal_props.get('specific_heat', 0.0),
|
| 158 |
'absorptivity': material.get('absorptivity', 0.6),
|
| 159 |
'emissivity': material.get('emissivity', 0.9)
|
| 160 |
}
|
|
|
|
| 217 |
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.")
|
| 218 |
return CTFCoefficients(X=[0.0], Y=[0.0], Z=[0.0], F=[0.0])
|
| 219 |
|
| 220 |
+
# Check cache with thread-safe access
|
| 221 |
+
construction_hash = cls._hash_construction(construction)
|
| 222 |
+
with cls._cache_lock:
|
| 223 |
+
if construction_hash in cls._ctf_cache:
|
| 224 |
+
logger.info(f"Using cached CTF coefficients for construction '{construction_name}'")
|
| 225 |
+
return cls._ctf_cache[construction_hash]
|
| 226 |
+
|
| 227 |
# Collect layer properties
|
| 228 |
thicknesses = []
|
| 229 |
material_props = []
|
|
|
|
| 235 |
continue
|
| 236 |
material = cls._get_material_properties(material_name)
|
| 237 |
if not material:
|
| 238 |
+
logger.warning(f"Skipping layer with material '{material_name}' in construction '{construction_name}' due to missing properties.")
|
| 239 |
continue
|
| 240 |
thicknesses.append(thickness)
|
| 241 |
material_props.append(material)
|
|
|
|
| 254 |
|
| 255 |
# Discretization parameters
|
| 256 |
dt = 3600 # 1-hour time step (s)
|
| 257 |
+
nodes_per_layer = 3 # 2–4 nodes per layer for balance
|
| 258 |
R_in = 0.12 # Indoor surface resistance (m²·K/W, ASHRAE)
|
| 259 |
|
| 260 |
# Get weather data for sol-air temperature
|
|
|
|
| 271 |
|
| 272 |
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')}'")
|
| 273 |
|
| 274 |
+
# Calculate node spacing and check stability
|
| 275 |
+
total_nodes = sum(nodes_per_layer for _ in thicknesses)
|
| 276 |
+
dx = [t / nodes_per_layer for t in thicknesses] # Node spacing per layer
|
| 277 |
+
node_positions = []
|
| 278 |
+
node_idx = 0
|
| 279 |
+
for i, t in enumerate(thicknesses):
|
| 280 |
+
for j in range(nodes_per_layer):
|
| 281 |
+
node_positions.append((i, j, node_idx)) # (layer_idx, node_in_layer, global_node_idx)
|
| 282 |
+
node_idx += 1
|
| 283 |
|
|
|
|
|
|
|
|
|
|
| 284 |
# Stability check: Fourier number
|
| 285 |
+
for i, (a, d) in enumerate(zip(alpha, dx)):
|
| 286 |
if a == 0 or d == 0:
|
| 287 |
logger.warning(f"Invalid thermal diffusivity or node spacing for layer {i} in construction '{construction_name}'. Skipping stability adjustment.")
|
| 288 |
continue
|
| 289 |
Fo = a * dt / (d ** 2)
|
| 290 |
+
if Fo < 0.33:
|
| 291 |
+
logger.warning(f"Fourier number {Fo:.3f} < 0.33 for layer {i} ({material_props[i]['name']}). Adjusting node spacing.")
|
| 292 |
+
dx[i] = np.sqrt(a * dt / 0.33)
|
| 293 |
+
nodes_per_layer = max(2, int(np.ceil(thicknesses[i] / dx[i])))
|
| 294 |
+
dx[i] = thicknesses[i] / nodes_per_layer
|
| 295 |
+
Fo = a * dt / (dx[i] ** 2)
|
| 296 |
+
logger.info(f"Adjusted node spacing for layer {i}: dx={dx[i]:.4f} m, Fo={Fo:.3f}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
|
| 298 |
# Build system matrices
|
|
|
|
| 299 |
A = sparse.lil_matrix((total_nodes, total_nodes))
|
| 300 |
b = np.zeros(total_nodes)
|
| 301 |
node_to_layer = [i for i, _, _ in node_positions]
|
|
|
|
| 305 |
rho_i = rho[layer_idx]
|
| 306 |
c_i = c[layer_idx]
|
| 307 |
dx_i = dx[layer_idx]
|
|
|
|
| 308 |
|
| 309 |
if k_i == 0 or rho_i == 0 or c_i == 0 or dx_i == 0:
|
| 310 |
logger.warning(f"Invalid material properties for layer {layer_idx} ({material_props[layer_idx]['name']}) in construction '{construction_name}'. Using default values.")
|
|
|
|
| 312 |
|
| 313 |
if node_j == 0 and layer_idx == 0: # Outdoor surface node
|
| 314 |
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)
|
| 315 |
+
A[idx, idx + 1] = -2 * dt * k_i / (dx_i * rho_i * c_i * dx_i)
|
| 316 |
b[idx] = dt / (rho_i * c_i * dx_i * R_out) * T_sol_air # Use sol-air temperature
|
| 317 |
+
elif node_j == nodes_per_layer - 1 and layer_idx == len(thicknesses) - 1: # Indoor surface node
|
| 318 |
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)
|
| 319 |
+
A[idx, idx - 1] = -2 * dt * k_i / (dx_i * rho_i * c_i * dx_i)
|
| 320 |
b[idx] = dt / (rho_i * c_i * dx_i * R_in) # Indoor temp contribution
|
| 321 |
# Add radiant load to indoor surface node (convert kW to W)
|
| 322 |
radiant_load = component.get("radiant_load", 0.0) * 1000 # kW to W
|
|
|
|
| 325 |
logger.debug(f"Added radiant load {radiant_load:.2f} W to indoor node for component '{component.get('name', 'Unknown')}'")
|
| 326 |
elif radiant_load != 0:
|
| 327 |
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.")
|
| 328 |
+
elif node_j == nodes_per_layer - 1 and layer_idx < len(thicknesses) - 1: # Interface between layers
|
| 329 |
k_next = k[layer_idx + 1]
|
| 330 |
dx_next = dx[layer_idx + 1]
|
| 331 |
rho_next = rho[layer_idx + 1]
|
|
|
|
| 334 |
logger.warning(f"Invalid material properties for layer {layer_idx + 1} ({material_props[layer_idx + 1]['name']}) in construction '{construction_name}'. Skipping interface.")
|
| 335 |
continue
|
| 336 |
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))
|
| 337 |
+
A[idx, idx - 1] = -dt * k_i / (dx_i * 0.5 * (rho_i * c_i * dx_i + rho_next * c_next * dx_next))
|
| 338 |
+
A[idx, idx + 1] = -dt * k_next / (dx_next * 0.5 * (rho_i * c_i * dx_i + rho_next * c_next * dx_next))
|
| 339 |
elif node_j == 0 and layer_idx > 0: # Interface from previous layer
|
| 340 |
k_prev = k[layer_idx - 1]
|
| 341 |
dx_prev = dx[layer_idx - 1]
|
|
|
|
| 345 |
logger.warning(f"Invalid material properties for layer {layer_idx - 1} ({material_props[layer_idx - 1]['name']}) in construction '{construction_name}'. Skipping interface.")
|
| 346 |
continue
|
| 347 |
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))
|
| 348 |
+
A[idx, idx - 1] = -dt * k_prev / (dx_prev * 0.5 * (rho_prev * c_prev * dx_prev + rho_i * c_i * dx_i))
|
| 349 |
+
A[idx, idx + 1] = -dt * k_i / (dx_i * 0.5 * (rho_prev * c_prev * dx_prev + rho_i * c_i * dx_i))
|
| 350 |
else: # Internal node
|
| 351 |
A[idx, idx] = 1.0 + 2 * dt * k_i / (dx_i * rho_i * c_i * dx_i)
|
| 352 |
+
A[idx, idx - 1] = -dt * k_i / (dx_i * rho_i * c_i * dx_i)
|
| 353 |
+
A[idx, idx + 1] = -dt * k_i / (dx_i * rho_i * c_i * dx_i)
|
| 354 |
|
| 355 |
A = A.tocsr() # Convert to CSR for efficient solving
|
| 356 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
# Calculate CTF coefficients (X, Y, Z, F)
|
| 358 |
num_ctf = 12 # Standard number of coefficients
|
| 359 |
X = [0.0] * num_ctf # Exterior temp response
|