mabuseif commited on
Commit
dfe2313
·
verified ·
1 Parent(s): 997653f

Update utils/ctf_calculations.py

Browse files
Files changed (1) hide show
  1. 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], nodes_per_layer: List[int]) -> str:
117
- """Generate a unique hash for a construction based on its properties and node discretization.
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 and nodes_per_layer.
125
  """
126
- hash_input = f"{construction.get('name', '')}{construction.get('adiabatic', False)}{''.join(map(str, nodes_per_layer))}"
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 with validation.
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 or properties are invalid.
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 or invalid properties.")
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 = [3] * len(thicknesses) # Initial number of 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
- # Check cache with thread-safe access
284
- construction_hash = cls._hash_construction(construction, nodes_per_layer)
285
- with cls._cache_lock:
286
- if construction_hash in cls._ctf_cache:
287
- logger.info(f"Using cached CTF coefficients for construction '{construction_name}'")
288
- return cls._ctf_cache[construction_hash]
 
 
 
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, t) in enumerate(zip(alpha, dx, thicknesses)):
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 > 0.5 or Fo < 0.33:
300
- logger.warning(f"Fourier number {Fo:.3f} out of stable range (0.33 ≤ Fo ≤ 0.5) for layer {i} ({material_props[i]['name']}). Adjusting dx.")
301
- if Fo > 1000: # Handle extreme cases (e.g., air spaces, thin metal layers)
302
- logger.warning(f"Extreme Fourier number {Fo:.3f} detected. Using minimum dx for layer {i}.")
303
- dx[i] = t / 2 # Minimum 2 nodes per layer
304
- nodes_per_layer[i] = 2
305
- else:
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) if idx + 1 < total_nodes else 0.0
347
  b[idx] = dt / (rho_i * c_i * dx_i * R_out) * T_sol_air # Use sol-air temperature
348
- elif node_j == npl_i - 1 and layer_idx == len(thicknesses) - 1: # Indoor surface node
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) if idx - 1 >= 0 else 0.0
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 == npl_i - 1 and layer_idx < len(thicknesses) - 1: # Interface between layers
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)) if idx - 1 >= 0 else 0.0
369
- 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
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)) if idx - 1 >= 0 else 0.0
380
- 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
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) if idx - 1 >= 0 else 0.0
384
- A[idx, idx + 1] = -dt * k_i / (dx_i * rho_i * c_i * dx_i) if idx + 1 < total_nodes else 0.0
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