File size: 21,945 Bytes
9511b71
 
 
 
16e2fd4
 
 
9511b71
 
 
 
 
 
 
 
 
 
 
54945c1
 
16e2fd4
9511b71
 
 
 
 
16e2fd4
 
 
 
 
 
 
9511b71
 
 
 
 
 
 
 
 
 
 
 
 
16e2fd4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9511b71
dfe2313
 
4950b18
9511b71
4950b18
 
9511b71
dfe2313
9511b71
dfe2313
54945c1
 
 
 
 
9511b71
 
 
54945c1
dfe2313
54945c1
 
 
 
 
 
dfe2313
54945c1
 
 
 
 
 
 
 
 
 
 
 
dfe2313
 
 
54945c1
 
 
 
 
 
 
 
16e2fd4
 
a186076
54945c1
9511b71
a186076
9511b71
54945c1
16e2fd4
a186076
9511b71
 
 
54945c1
 
 
 
 
 
 
 
 
 
 
 
 
4950b18
 
 
 
 
 
 
 
 
 
a186076
54945c1
 
 
9511b71
a186076
54945c1
 
 
 
 
a186076
54945c1
 
 
 
9511b71
a186076
dfe2313
 
 
 
 
 
 
54945c1
 
 
 
 
 
 
 
 
 
 
dfe2313
54945c1
 
 
a186076
54945c1
 
 
a186076
54945c1
 
 
 
 
16e2fd4
 
a186076
9511b71
 
dfe2313
9511b71
a186076
16e2fd4
 
 
 
a186076
16e2fd4
a186076
16e2fd4
 
 
 
a186076
16e2fd4
a186076
dfe2313
 
 
 
 
 
 
 
 
6a27fd5
9511b71
dfe2313
54945c1
 
 
9511b71
dfe2313
 
 
 
 
 
 
997653f
9511b71
 
 
 
a186076
9511b71
 
 
 
 
a186076
54945c1
 
 
a186076
9511b71
 
dfe2313
16e2fd4
dfe2313
9511b71
dfe2313
9511b71
a186076
 
 
 
 
 
 
dfe2313
9511b71
 
 
 
54945c1
 
 
9511b71
dfe2313
 
9511b71
 
 
 
 
54945c1
 
 
9511b71
dfe2313
 
9511b71
 
dfe2313
 
a186076
9511b71
a186076
9511b71
 
 
 
 
 
 
a186076
9511b71
 
 
 
16e2fd4
9511b71
 
 
 
 
 
a186076
9511b71
 
 
 
 
54945c1
9511b71
 
 
 
a186076
9511b71
 
 
 
 
54945c1
9511b71
 
 
 
a186076
9511b71
 
 
54945c1
9511b71
 
 
54945c1
 
9511b71
 
54945c1
9511b71
 
 
 
54945c1
16e2fd4
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
"""
CTF Calculations Module

This module contains the CTFCalculator class for calculating Conduction Transfer Function (CTF)
coefficients for HVAC load calculations using the implicit Finite Difference Method, enhanced
with sol-air temperature calculations accounting for solar radiation, longwave radiation, and
dynamic outdoor heat transfer coefficient.

Developed by: Dr Majed Abuseif, Deakin University
© 2025
"""

import numpy as np
import scipy.sparse as sparse
import scipy.sparse.linalg as sparse_linalg
import hashlib
import logging
import threading
from typing import List, Dict, Any, NamedTuple
import streamlit as st
from enum import Enum

# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

class ComponentType(Enum):
    WALL = "Wall"
    ROOF = "Roof"
    FLOOR = "Floor"
    WINDOW = "Window"
    SKYLIGHT = "Skylight"

class CTFCoefficients(NamedTuple):
    X: List[float]  # Exterior temperature coefficients
    Y: List[float]  # Cross coefficients
    Z: List[float]  # Interior temperature coefficients
    F: List[float]  # Flux history coefficients

class CTFCalculator:
    """Class to calculate and cache CTF coefficients for building components."""
    
    # Cache for CTF coefficients based on construction properties
    _ctf_cache = {}
    _cache_lock = threading.Lock()  # Thread-safe lock for cache access

    @staticmethod
    def calculate_sky_temperature(T_out: float, dew_point: float, total_sky_cover: float = 0.5) -> float:
        """Calculate sky temperature using cloud-cover-dependent model.

        Args:
            T_out (float): Outdoor dry-bulb temperature (°C).
            dew_point (float): Dew point temperature (°C).
            total_sky_cover (float): Sky cover fraction (0 to 1).

        Returns:
            float: Sky temperature (°C), bounded by dew point.

        References:
            ASHRAE Handbook—Fundamentals (2021), Chapter 26.
        """
        epsilon_sky = 0.9 + 0.04 * total_sky_cover
        T_sky = (epsilon_sky * (T_out + 273.15)**4)**0.25 - 273.15
        return T_sky if dew_point <= T_out else dew_point

    @staticmethod
    def calculate_h_o(wind_speed: float, surface_type: ComponentType) -> float:
        """Calculate dynamic outdoor heat transfer coefficient based on wind speed and surface type.

        Args:
            wind_speed (float): Wind speed (m/s).
            surface_type (ComponentType): Type of surface (WALL, ROOF, FLOOR, WINDOW, SKYLIGHT).

        Returns:
            float: Outdoor heat transfer coefficient (W/m²·K).

        References:
            ASHRAE Handbook—Fundamentals (2021), Chapter 26.
        """
        from app.m_c_data import DEFAULT_WINDOW_PROPERTIES  # Delayed import to avoid circular dependency
        wind_speed = max(min(wind_speed, 20.0), 0.0)  # Bound for stability
        if surface_type in [ComponentType.WALL, ComponentType.FLOOR]:
            h_o = 8.3 + 4.0 * (wind_speed ** 0.6)  # ASHRAE Ch. 26
        elif surface_type == ComponentType.ROOF:
            h_o = 9.1 + 2.8 * wind_speed  # ASHRAE Ch. 26
        else:  # WINDOW, SKYLIGHT
            h_o = DEFAULT_WINDOW_PROPERTIES["h_o"]
        return max(h_o, 5.0)  # Minimum for stability

    @staticmethod
    def calculate_sol_air_temperature(T_out: float, I_t: float, absorptivity: float, emissivity: float, 
                                     h_o: float, dew_point: float, total_sky_cover: float = 0.5) -> float:
        """Calculate sol-air temperature for a surface.

        Args:
            T_out (float): Outdoor dry-bulb temperature (°C).
            I_t (float): Total incident solar radiation (W/m²).
            absorptivity (float): Surface absorptivity.
            emissivity (float): Surface emissivity.
            h_o (float): Outdoor heat transfer coefficient (W/m²·K).
            dew_point (float): Dew point temperature (°C).
            total_sky_cover (float): Sky cover fraction (0 to 1).

        Returns:
            float: Sol-air temperature (°C).

        References:
            ASHRAE Handbook—Fundamentals (2021), Chapter 26.
        """
        sigma = 5.67e-8  # Stefan-Boltzmann constant (W/m²·K⁴)
        T_sky = CTFCalculator.calculate_sky_temperature(T_out, dew_point, total_sky_cover)
        T_sol_air = T_out + (absorptivity * I_t - emissivity * sigma * ((T_out + 273.15)**4 - (T_sky + 273.15)**4)) / h_o
        return T_sol_air

    @staticmethod
    def _hash_construction(construction: Dict[str, Any]) -> str:
        """Generate a unique hash for a construction based on its properties.
    
        Args:
            construction: Dictionary containing construction properties (name, layers, adiabatic).
    
        Returns:
            str: SHA-256 hash of the construction properties.
        """
        hash_input = f"{construction.get('name', '')}{construction.get('adiabatic', False)}"
        layers = construction.get('layers', [])
        for layer in layers:
            material_name = layer.get('material', '')
            thickness = layer.get('thickness', 0.0)
            hash_input += f"{material_name}{thickness}"
        return hashlib.sha256(hash_input.encode()).hexdigest()

    @classmethod
    def _get_material_properties(cls, material_name: str) -> Dict[str, float]:
        """Retrieve material properties from session state.

        Args:
            material_name: Name of the material.

        Returns:
            Dict containing conductivity, density, specific_heat, absorptivity, emissivity.
            Returns empty dict if material not found.
        """
        try:
            materials = st.session_state.project_data.get('materials', {})
            material = materials.get('library', {}).get(material_name, materials.get('project', {}).get(material_name))
            if not material:
                logger.error(f"Material '{material_name}' not found in library or project materials.")
                return {}
            
            # Extract required properties
            thermal_props = material.get('thermal_properties', {})
            return {
                'name': material_name,
                'conductivity': thermal_props.get('conductivity', 0.0),
                'density': thermal_props.get('density', 0.0),
                'specific_heat': thermal_props.get('specific_heat', 0.0),
                'absorptivity': material.get('absorptivity', 0.6),
                'emissivity': material.get('emissivity', 0.9)
            }
        except Exception as e:
            logger.error(f"Error retrieving material '{material_name}' properties: {str(e)}")
            return {}

    @classmethod
    def calculate_ctf_coefficients(cls, component: Dict[str, Any], hourly_data: Dict[str, Any] = None) -> CTFCoefficients:
        """Calculate CTF coefficients using implicit Finite Difference Method with sol-air temperature.
    
        Note: Per ASHRAE, CTF calculations are skipped for WINDOW and SKYLIGHT components,
        as they use typical material properties. CTF tables for these components will be added later.
    
        Args:
            component: Dictionary containing component properties from st.session_state.project_data["components"].
            hourly_data: Dictionary containing hourly weather data (T_out, dew_point, wind_speed, total_sky_cover, I_t).
    
        Returns:
            CTFCoefficients: Named tuple containing X, Y, Z, and F coefficients.
        """
        # Determine component type
        comp_type_str = component.get('type', '').lower()  # Expected from component dictionary key (e.g., 'walls')
        comp_type_map = {
            'walls': ComponentType.WALL,
            'roofs': ComponentType.ROOF,
            'floors': ComponentType.FLOOR,
            'windows': ComponentType.WINDOW,
            'skylights': ComponentType.SKYLIGHT
        }
        component_type = comp_type_map.get(comp_type_str, None)
        if not component_type:
            logger.warning(f"Invalid component type '{comp_type_str}' for component '{component.get('name', 'Unknown')}'. Returning zero CTFs.")
            return CTFCoefficients(X=[0.0], Y=[0.0], Z=[0.0], F=[0.0])
            
        # Validate adiabatic and ground_contact mutual exclusivity
        if component.get('adiabatic', False) and component.get('ground_contact', False):
            logger.warning(f"Component {component.get('name', 'Unknown')} has both adiabatic and ground_contact set to True. Treating as adiabatic, setting ground_contact to False.")
            component['ground_contact'] = False
    
        # Skip CTF for adiabatic components
        if component.get('adiabatic', False):
            logger.info(f"Skipping CTF calculation for adiabatic {component_type.value} component '{component.get('name', 'Unknown')}'. Returning zero coefficients.")
            return CTFCoefficients(X=[0.0], Y=[0.0], Z=[0.0], F=[0.0])
    
        # Skip CTF for WINDOW, SKYLIGHT as per ASHRAE; return zero coefficients
        if component_type in [ComponentType.WINDOW, ComponentType.SKYLIGHT]:
            logger.info(f"Skipping CTF calculation for {component_type.value} component '{component.get('name', 'Unknown')}'. Using zero coefficients until CTF tables are implemented.")
            return CTFCoefficients(X=[0.0], Y=[0.0], Z=[0.0], F=[0.0])
    
        # Retrieve construction
        construction_name = component.get('construction', '')
        if not construction_name:
            logger.warning(f"No construction specified for component '{component.get('name', 'Unknown')}' ({component_type.value}). Returning zero CTFs.")
            return CTFCoefficients(X=[0.0], Y=[0.0], Z=[0.0], F=[0.0])
    
        constructions = st.session_state.project_data.get('constructions', {})
        construction = constructions.get('library', {}).get(construction_name, constructions.get('project', {}).get(construction_name))
        if not construction or not construction.get('layers'):
            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.")
            return CTFCoefficients(X=[0.0], Y=[0.0], Z=[0.0], F=[0.0])
    
        # Check cache with thread-safe access
        construction_hash = cls._hash_construction(construction)
        with cls._cache_lock:
            if construction_hash in cls._ctf_cache:
                logger.info(f"Using cached CTF coefficients for construction '{construction_name}'")
                return cls._ctf_cache[construction_hash]
    
        # Collect layer properties
        thicknesses = []
        material_props = []
        for layer in construction.get('layers', []):
            material_name = layer.get('material', '')
            thickness = layer.get('thickness', 0.0)
            if thickness <= 0.0:
                logger.warning(f"Invalid thickness {thickness} for material '{material_name}' in construction '{construction_name}'. Skipping layer.")
                continue
            material = cls._get_material_properties(material_name)
            if not material:
                logger.warning(f"Skipping layer with material '{material_name}' in construction '{construction_name}' due to missing properties.")
                continue
            thicknesses.append(thickness)
            material_props.append(material)
    
        if not thicknesses or not material_props:
            logger.warning(f"No valid layers with material properties for construction '{construction_name}' in component '{component.get('name', 'Unknown')}'. Returning zero CTFs.")
            return CTFCoefficients(X=[0.0], Y=[0.0], Z=[0.0], F=[0.0])
    
        # Extract material properties
        k = [m['conductivity'] for m in material_props]  # W/m·K
        rho = [m['density'] for m in material_props]    # kg/m³
        c = [m['specific_heat'] for m in material_props]  # J/kg·K
        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)
        absorptivity = material_props[0].get('absorptivity', 0.6)  # Use first layer's absorptivity
        emissivity = material_props[0].get('emissivity', 0.9)      # Use first layer's emissivity
    
        # Discretization parameters
        dt = 3600  # 1-hour time step (s)
        nodes_per_layer = 3  # 2–4 nodes per layer for balance
        R_in = 0.12   # Indoor surface resistance (m²·K/W, ASHRAE)
    
        # Get weather data for sol-air temperature
        T_out = hourly_data.get('dry_bulb', 25.0) if hourly_data else 25.0
        dew_point = hourly_data.get('dew_point', T_out - 5.0) if hourly_data else T_out - 5.0
        wind_speed = hourly_data.get('wind_speed', 4.0) if hourly_data else 4.0
        total_sky_cover = hourly_data.get('total_sky_cover', 0.5) if hourly_data else 0.0
        I_t = hourly_data.get('total_incident_radiation', 0.0) if hourly_data else 0.0
    
        # Calculate dynamic h_o and sol-air temperature
        h_o = cls.calculate_h_o(wind_speed, component_type)
        T_sol_air = cls.calculate_sol_air_temperature(T_out, I_t, absorptivity, emissivity, h_o, dew_point, total_sky_cover)
        R_out = 1.0 / h_o  # Outdoor surface resistance based on dynamic h_o
    
        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')}'")
    
        # Calculate node spacing and check stability
        total_nodes = sum(nodes_per_layer for _ in thicknesses)
        dx = [t / nodes_per_layer for t in thicknesses]  # Node spacing per layer
        node_positions = []
        node_idx = 0
        for i, t in enumerate(thicknesses):
            for j in range(nodes_per_layer):
                node_positions.append((i, j, node_idx))  # (layer_idx, node_in_layer, global_node_idx)
                node_idx += 1
    
        # Stability check: Fourier number
        for i, (a, d) in enumerate(zip(alpha, dx)):
            if a == 0 or d == 0:
                logger.warning(f"Invalid thermal diffusivity or node spacing for layer {i} in construction '{construction_name}'. Skipping stability adjustment.")
                continue
            Fo = a * dt / (d ** 2)
            if Fo < 0.33:
                logger.warning(f"Fourier number {Fo:.3f} < 0.33 for layer {i} ({material_props[i]['name']}). Adjusting node spacing.")
                dx[i] = np.sqrt(a * dt / 0.33)
                nodes_per_layer = max(2, int(np.ceil(thicknesses[i] / dx[i])))
                dx[i] = thicknesses[i] / nodes_per_layer
                Fo = a * dt / (dx[i] ** 2)
                logger.info(f"Adjusted node spacing for layer {i}: dx={dx[i]:.4f} m, Fo={Fo:.3f}")
    
        # Build system matrices
        A = sparse.lil_matrix((total_nodes, total_nodes))
        b = np.zeros(total_nodes)
        node_to_layer = [i for i, _, _ in node_positions]
    
        for idx, (layer_idx, node_j, global_idx) in enumerate(node_positions):
            k_i = k[layer_idx]
            rho_i = rho[layer_idx]
            c_i = c[layer_idx]
            dx_i = dx[layer_idx]
    
            if k_i == 0 or rho_i == 0 or c_i == 0 or dx_i == 0:
                logger.warning(f"Invalid material properties for layer {layer_idx} ({material_props[layer_idx]['name']}) in construction '{construction_name}'. Using default values.")
                continue
    
            if node_j == 0 and layer_idx == 0:  # Outdoor surface node
                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)
                A[idx, idx + 1] = -2 * dt * k_i / (dx_i * rho_i * c_i * dx_i)
                b[idx] = dt / (rho_i * c_i * dx_i * R_out) * T_sol_air  # Use sol-air temperature
            elif node_j == nodes_per_layer - 1 and layer_idx == len(thicknesses) - 1:  # Indoor surface node
                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)
                A[idx, idx - 1] = -2 * dt * k_i / (dx_i * rho_i * c_i * dx_i)
                b[idx] = dt / (rho_i * c_i * dx_i * R_in)  # Indoor temp contribution
                # Add radiant load to indoor surface node (convert kW to W)
                radiant_load = component.get("radiant_load", 0.0) * 1000  # kW to W
                if radiant_load != 0 and rho_i * c_i * dx_i != 0:
                    b[idx] += dt / (rho_i * c_i * dx_i) * radiant_load
                    logger.debug(f"Added radiant load {radiant_load:.2f} W to indoor node for component '{component.get('name', 'Unknown')}'")
                elif radiant_load != 0:
                    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.")
            elif node_j == nodes_per_layer - 1 and layer_idx < len(thicknesses) - 1:  # Interface between layers
                k_next = k[layer_idx + 1]
                dx_next = dx[layer_idx + 1]
                rho_next = rho[layer_idx + 1]
                c_next = c[layer_idx + 1]
                if k_next == 0 or dx_next == 0 or rho_next == 0 or c_next == 0:
                    logger.warning(f"Invalid material properties for layer {layer_idx + 1} ({material_props[layer_idx + 1]['name']}) in construction '{construction_name}'. Skipping interface.")
                    continue
                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))
                A[idx, idx - 1] = -dt * k_i / (dx_i * 0.5 * (rho_i * c_i * dx_i + rho_next * c_next * dx_next))
                A[idx, idx + 1] = -dt * k_next / (dx_next * 0.5 * (rho_i * c_i * dx_i + rho_next * c_next * dx_next))
            elif node_j == 0 and layer_idx > 0:  # Interface from previous layer
                k_prev = k[layer_idx - 1]
                dx_prev = dx[layer_idx - 1]
                rho_prev = rho[layer_idx - 1]
                c_prev = c[layer_idx - 1]
                if k_prev == 0 or dx_prev == 0 or rho_prev == 0 or c_prev == 0:
                    logger.warning(f"Invalid material properties for layer {layer_idx - 1} ({material_props[layer_idx - 1]['name']}) in construction '{construction_name}'. Skipping interface.")
                    continue
                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))
                A[idx, idx - 1] = -dt * k_prev / (dx_prev * 0.5 * (rho_prev * c_prev * dx_prev + rho_i * c_i * dx_i))
                A[idx, idx + 1] = -dt * k_i / (dx_i * 0.5 * (rho_prev * c_prev * dx_prev + rho_i * c_i * dx_i))
            else:  # Internal node
                A[idx, idx] = 1.0 + 2 * dt * k_i / (dx_i * rho_i * c_i * dx_i)
                A[idx, idx - 1] = -dt * k_i / (dx_i * rho_i * c_i * dx_i)
                A[idx, idx + 1] = -dt * k_i / (dx_i * rho_i * c_i * dx_i)
    
        A = A.tocsr()  # Convert to CSR for efficient solving
    
        # Calculate CTF coefficients (X, Y, Z, F)
        num_ctf = 12  # Standard number of coefficients
        X = [0.0] * num_ctf  # Exterior temp response
        Y = [0.0] * num_ctf  # Cross response
        Z = [0.0] * num_ctf  # Interior temp response
        F = [0.0] * num_ctf  # Flux history
        T_prev = np.zeros(total_nodes)  # Previous temperatures
    
        # Impulse response for exterior temperature (X, Y)
        for t in range(num_ctf):
            b_out = b.copy()
            if t == 0:
                b_out[0] = dt / (rho[0] * c[0] * dx[0] * R_out) * T_sol_air if rho[0] * c[0] * dx[0] != 0 else 0.0  # Unit outdoor temp impulse with sol-air
            T = sparse_linalg.spsolve(A, b_out + T_prev)
            q_in = (T[-1] - 0.0) / R_in  # Indoor heat flux (W/m²)
            Y[t] = q_in
            q_out = (0.0 - T[0]) / R_out  # Outdoor heat flux
            X[t] = q_out
            T_prev = T.copy()
    
        # Reset for interior temperature (Z)
        T_prev = np.zeros(total_nodes)
        for t in range(num_ctf):
            b_in = b.copy()
            if t == 0:
                b_in[-1] = dt / (rho[-1] * c[-1] * dx[-1] * R_in) if rho[-1] * c[-1] * dx[-1] != 0 else 0.0  # Unit indoor temp impulse
            T = sparse_linalg.spsolve(A, b_in + T_prev)
            q_in = (T[-1] - 0.0) / R_in
            Z[t] = q_in
            T_prev = T.copy()
    
        # Flux history coefficients (F)
        T_prev = np.zeros(total_nodes)
        for t in range(num_ctf):
            b_flux = np.zeros(total_nodes)
            if t == 0:
                b_flux[-1] = -1.0 / (rho[-1] * c[-1] * dx[-1]) if rho[-1] * c[-1] * dx[-1] != 0 else 0.0  # Unit flux impulse
            T = sparse_linalg.spsolve(A, b_flux + T_prev)
            q_in = (T[-1] - 0.0) / R_in
            F[t] = q_in
            T_prev = T.copy()
    
        ctf = CTFCoefficients(X=X, Y=Y, Z=Z, F=F)
        with cls._cache_lock:
            cls._ctf_cache[construction_hash] = ctf
        logger.info(f"Calculated CTF coefficients for construction '{construction_name}' in component '{component.get('name', 'Unknown')}'")
        return ctf

    @classmethod
    def calculate_ctf_tables(cls, component: Dict[str, Any]) -> CTFCoefficients:
        """Placeholder for future implementation of CTF table lookups for windows and skylights.

        Args:
            component: Dictionary containing component properties.

        Returns:
            CTFCoefficients: Placeholder zero coefficients until implementation.
        """
        logger.info(f"CTF table calculation for {component.get('type', 'Unknown')} component '{component.get('name', 'Unknown')}' not yet implemented. Returning zero coefficients.")
        return CTFCoefficients(X=[0.0], Y=[0.0], Z=[0.0], F=[0.0])