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

Update utils/ctf_calculations.py

Browse files
Files changed (1) hide show
  1. utils/ctf_calculations.py +74 -50
utils/ctf_calculations.py CHANGED
@@ -113,17 +113,17 @@ class CTFCalculator:
113
  return T_sol_air
114
 
115
  @staticmethod
116
- def _hash_construction(construction: Dict[str, Any], nodes_per_layer: 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: 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)}{nodes_per_layer}"
127
  layers = construction.get('layers', [])
128
  for layer in layers:
129
  material_name = layer.get('material', '')
@@ -133,14 +133,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,11 +151,26 @@ class CTFCalculator:
151
 
152
  # Extract required properties
153
  thermal_props = material.get('thermal_properties', {})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  return {
155
  'name': material_name,
156
- 'conductivity': thermal_props.get('conductivity', 0.0),
157
- 'density': thermal_props.get('density', 0.0),
158
- 'specific_heat': thermal_props.get('specific_heat', 0.0),
159
  'absorptivity': material.get('absorptivity', 0.6),
160
  'emissivity': material.get('emissivity', 0.9)
161
  }
@@ -229,7 +244,7 @@ class CTFCalculator:
229
  continue
230
  material = cls._get_material_properties(material_name)
231
  if not material:
232
- logger.warning(f"Skipping layer with material '{material_name}' in construction '{construction_name}' due to missing properties.")
233
  continue
234
  thicknesses.append(thickness)
235
  material_props.append(material)
@@ -248,7 +263,7 @@ class CTFCalculator:
248
 
249
  # Discretization parameters
250
  dt = 3600 # 1-hour time step (s)
251
- nodes_per_layer = 3 # Initial number of nodes per layer
252
  R_in = 0.12 # Indoor surface resistance (m²·K/W, ASHRAE)
253
 
254
  # Get weather data for sol-air temperature
@@ -273,27 +288,26 @@ class CTFCalculator:
273
  return cls._ctf_cache[construction_hash]
274
 
275
  # Calculate node spacing
276
- dx = [t / nodes_per_layer for t in thicknesses] # Initial 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.5 or Fo < 0.33:
291
  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.")
292
- dx[i] = np.sqrt(a * dt / 0.4) # Target Fo = 0.4
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
  # Update construction hash if nodes_per_layer changed
299
  construction_hash = cls._hash_construction(construction, nodes_per_layer)
@@ -302,8 +316,16 @@ class CTFCalculator:
302
  logger.info(f"Using cached CTF coefficients for construction '{construction_name}' after node adjustment")
303
  return cls._ctf_cache[construction_hash]
304
 
 
 
 
 
 
 
 
 
305
  # Build system matrices
306
- total_nodes = sum(nodes_per_layer for _ in thicknesses)
307
  A = sparse.lil_matrix((total_nodes, total_nodes))
308
  b = np.zeros(total_nodes)
309
  node_to_layer = [i for i, _, _ in node_positions]
@@ -313,6 +335,7 @@ class CTFCalculator:
313
  rho_i = rho[layer_idx]
314
  c_i = c[layer_idx]
315
  dx_i = dx[layer_idx]
 
316
 
317
  if k_i == 0 or rho_i == 0 or c_i == 0 or dx_i == 0:
318
  logger.warning(f"Invalid material properties for layer {layer_idx} ({material_props[layer_idx]['name']}) in construction '{construction_name}'. Using default values.")
@@ -320,11 +343,11 @@ class CTFCalculator:
320
 
321
  if node_j == 0 and layer_idx == 0: # Outdoor surface node
322
  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)
323
- A[idx, idx + 1] = -2 * dt * k_i / (dx_i * rho_i * c_i * dx_i)
324
  b[idx] = dt / (rho_i * c_i * dx_i * R_out) * T_sol_air # Use sol-air temperature
325
- elif node_j == nodes_per_layer - 1 and layer_idx == len(thicknesses) - 1: # Indoor surface node
326
  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)
327
- A[idx, idx - 1] = -2 * dt * k_i / (dx_i * rho_i * c_i * dx_i)
328
  b[idx] = dt / (rho_i * c_i * dx_i * R_in) # Indoor temp contribution
329
  # Add radiant load to indoor surface node (convert kW to W)
330
  radiant_load = component.get("radiant_load", 0.0) * 1000 # kW to W
@@ -333,7 +356,7 @@ class CTFCalculator:
333
  logger.debug(f"Added radiant load {radiant_load:.2f} W to indoor node for component '{component.get('name', 'Unknown')}'")
334
  elif radiant_load != 0:
335
  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.")
336
- elif node_j == nodes_per_layer - 1 and layer_idx < len(thicknesses) - 1: # Interface between layers
337
  k_next = k[layer_idx + 1]
338
  dx_next = dx[layer_idx + 1]
339
  rho_next = rho[layer_idx + 1]
@@ -342,8 +365,8 @@ class CTFCalculator:
342
  logger.warning(f"Invalid material properties for layer {layer_idx + 1} ({material_props[layer_idx + 1]['name']}) in construction '{construction_name}'. Skipping interface.")
343
  continue
344
  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))
345
- A[idx, idx - 1] = -dt * k_i / (dx_i * 0.5 * (rho_i * c_i * dx_i + rho_next * c_next * dx_next))
346
- A[idx, idx + 1] = -dt * k_next / (dx_next * 0.5 * (rho_i * c_i * dx_i + rho_next * c_next * dx_next))
347
  elif node_j == 0 and layer_idx > 0: # Interface from previous layer
348
  k_prev = k[layer_idx - 1]
349
  dx_prev = dx[layer_idx - 1]
@@ -353,12 +376,12 @@ class CTFCalculator:
353
  logger.warning(f"Invalid material properties for layer {layer_idx - 1} ({material_props[layer_idx - 1]['name']}) in construction '{construction_name}'. Skipping interface.")
354
  continue
355
  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))
356
- A[idx, idx - 1] = -dt * k_prev / (dx_prev * 0.5 * (rho_prev * c_prev * dx_prev + rho_i * c_i * dx_i))
357
- A[idx, idx + 1] = -dt * k_i / (dx_i * 0.5 * (rho_prev * c_prev * dx_prev + rho_i * c_i * dx_i))
358
  else: # Internal node
359
  A[idx, idx] = 1.0 + 2 * dt * k_i / (dx_i * rho_i * c_i * dx_i)
360
- A[idx, idx - 1] = -dt * k_i / (dx_i * rho_i * c_i * dx_i)
361
- A[idx, idx + 1] = -dt * k_i / (dx_i * rho_i * c_i * dx_i)
362
 
363
  A = A.tocsr() # Convert to CSR for efficient solving
364
 
@@ -369,19 +392,19 @@ class CTFCalculator:
369
  cond_number = norm(A, ord=2) * norm(sparse_linalg.inv(A_csc), ord=2)
370
  if cond_number > 1e6:
371
  logger.warning(f"Matrix A is ill-conditioned (condition number: {cond_number:.2e}). Adjusting node spacing.")
372
- nodes_per_layer = min(nodes_per_layer + 1, 6)
373
- dx = [t / nodes_per_layer for t in thicknesses]
374
 
375
  # Recalculate node positions
376
  node_positions = []
377
  node_idx = 0
378
- for i, t in enumerate(thicknesses):
379
- for j in range(nodes_per_layer):
380
  node_positions.append((i, j, node_idx))
381
  node_idx += 1
382
 
383
  # Rebuild system matrices
384
- total_nodes = sum(nodes_per_layer for _ in thicknesses)
385
  A = sparse.lil_matrix((total_nodes, total_nodes))
386
  b = np.zeros(total_nodes)
387
  node_to_layer = [i for i, _, _ in node_positions]
@@ -391,6 +414,7 @@ class CTFCalculator:
391
  rho_i = rho[layer_idx]
392
  c_i = c[layer_idx]
393
  dx_i = dx[layer_idx]
 
394
 
395
  if k_i == 0 or rho_i == 0 or c_i == 0 or dx_i == 0:
396
  logger.warning(f"Invalid material properties for layer {layer_idx} ({material_props[layer_idx]['name']}) in construction '{construction_name}'. Using default values.")
@@ -398,11 +422,11 @@ class CTFCalculator:
398
 
399
  if node_j == 0 and layer_idx == 0:
400
  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)
401
- A[idx, idx + 1] = -2 * dt * k_i / (dx_i * rho_i * c_i * dx_i)
402
  b[idx] = dt / (rho_i * c_i * dx_i * R_out) * T_sol_air
403
- elif node_j == nodes_per_layer - 1 and layer_idx == len(thicknesses) - 1:
404
  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)
405
- A[idx, idx - 1] = -2 * dt * k_i / (dx_i * rho_i * c_i * dx_i)
406
  b[idx] = dt / (rho_i * c_i * dx_i * R_in)
407
  radiant_load = component.get("radiant_load", 0.0) * 1000
408
  if radiant_load != 0 and rho_i * c_i * dx_i != 0:
@@ -410,7 +434,7 @@ class CTFCalculator:
410
  logger.debug(f"Added radiant load {radiant_load:.2f} W to indoor node for component '{component.get('name', 'Unknown')}'")
411
  elif radiant_load != 0:
412
  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.")
413
- elif node_j == nodes_per_layer - 1 and layer_idx < len(thicknesses) - 1:
414
  k_next = k[layer_idx + 1]
415
  dx_next = dx[layer_idx + 1]
416
  rho_next = rho[layer_idx + 1]
@@ -419,8 +443,8 @@ class CTFCalculator:
419
  logger.warning(f"Invalid material properties for layer {layer_idx + 1} ({material_props[layer_idx + 1]['name']}) in construction '{construction_name}'. Skipping interface.")
420
  continue
421
  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))
422
- A[idx, idx - 1] = -dt * k_i / (dx_i * 0.5 * (rho_i * c_i * dx_i + rho_next * c_next * dx_next))
423
- A[idx, idx + 1] = -dt * k_next / (dx_next * 0.5 * (rho_i * c_i * dx_i + rho_next * c_next * dx_next))
424
  elif node_j == 0 and layer_idx > 0:
425
  k_prev = k[layer_idx - 1]
426
  dx_prev = dx[layer_idx - 1]
@@ -430,12 +454,12 @@ class CTFCalculator:
430
  logger.warning(f"Invalid material properties for layer {layer_idx - 1} ({material_props[layer_idx - 1]['name']}) in construction '{construction_name}'. Skipping interface.")
431
  continue
432
  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))
433
- A[idx, idx - 1] = -dt * k_prev / (dx_prev * 0.5 * (rho_prev * c_prev * dx_prev + rho_i * c_i * dx_i))
434
- A[idx, idx + 1] = -dt * k_i / (dx_i * 0.5 * (rho_prev * c_prev * dx_prev + rho_i * c_i * dx_i))
435
  else:
436
  A[idx, idx] = 1.0 + 2 * dt * k_i / (dx_i * rho_i * c_i * dx_i)
437
- A[idx, idx - 1] = -dt * k_i / (dx_i * rho_i * c_i * dx_i)
438
- A[idx, idx + 1] = -dt * k_i / (dx_i * rho_i * c_i * dx_i)
439
 
440
  A = A.tocsr()
441
  construction_hash = cls._hash_construction(construction, nodes_per_layer)
 
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
 
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
 
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
  }
 
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
 
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
 
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)
 
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
  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
 
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
  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
  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
  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
 
 
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]
 
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.")
 
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:
 
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]
 
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]
 
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)