Source code for twin4build.systems.space_heater.space_heater_torch_system

# Standard library imports
import datetime
from typing import Dict, List, Optional

# Third party imports
import torch
import torch.nn as nn
from scipy.optimize import fsolve

# Local application imports
import twin4build.utils.types as tps
from twin4build import core
from twin4build.systems.utils.discrete_statespace_system import DiscreteStatespaceSystem
from twin4build.translator.translator import (
    Exact,
    MultiPath,
    Node,
    Optional_,
    SignaturePattern,
    SinglePath,
)
from twin4build.utils.constants import Constants


[docs] def get_signature_pattern(): """ Get the signature pattern of the FMU component. Returns: SignaturePattern: The signature pattern for the building space 0 adjacent boundary outdoor FMU system. """ node2 = Node(cls=core.namespace.S4BLDG.BuildingSpace) node3 = Node(cls=core.namespace.S4BLDG.Valve) # supply valve node4 = Node(cls=core.namespace.S4BLDG.SpaceHeater) sp = SignaturePattern( semantic_model_=core.ontologies, id="space_heater_signature_pattern", ) sp.add_triple( Exact( subject=node3, object=node2, predicate=core.namespace.S4BLDG.isContainedIn ) ) sp.add_triple( Exact( subject=node4, object=node2, predicate=core.namespace.S4BLDG.isContainedIn ) ) sp.add_triple( Exact(subject=node3, object=node4, predicate=core.namespace.FSO.suppliesFluidTo) ) sp.add_input("waterFlowRate", node3) sp.add_input("indoorTemperature", node2, "indoorTemperature") sp.add_modeled_node(node4) return sp
[docs] class SpaceHeaterTorchSystem(core.System, nn.Module): r""" Space Heater (Radiator) Model with Finite Element Discretization. This model represents a radiator that transfers heat from hot water to a room using multiple finite elements and bilinear state-space dynamics. The radiator is discretized to capture temperature distribution along its length with flow-dependent heat transfer characteristics. Args: Q_flow_nominal_sh: Nominal heat output [W] T_a_nominal_sh: Nominal supply water temperature [°C] T_b_nominal_sh: Nominal return water temperature [°C] TAir_nominal_sh: Nominal room air temperature [°C] thermalMassHeatCapacity: Total thermal mass heat capacity [J/K] nelements: Number of finite elements Mathematical Formulation: ========================= **Continuous-Time Differential Equations:** The model uses a state-space representation with n finite elements. For each element i, the temperature dynamics are described by: .. math:: C_1 \frac{dT_1}{dt} = \dot{m} \cdot c_p \cdot (T_{sup} - T_1) - \frac{UA}{n} \cdot (T_1 - T_z) \quad \forall i = 1 .. math:: C_i \frac{dT_i}{dt} = \dot{m} \cdot c_p \cdot (T_{i-1} - T_i) - \frac{UA}{n} \cdot (T_i - T_z) \quad \forall i \in {2..n} where: - :math:`C_i` is the thermal capacitance of element i [J/K] - :math:`T_i` is the temperature of element i [°C] - :math:`\dot{m}` is the water mass flow rate [kg/s] - :math:`c_p` is the specific heat capacity of water [J/(kg·K)] - :math:`UA` is the overall heat transfer coefficient [W/K] - :math:`n` is the number of elements - :math:`T_z` is the zone (room) temperature [°C] - :math:`T_{sup}` is the supply water temperature [°C] **Heat Output Calculation:** The total heat output is calculated as: .. math:: Q = \frac{UA}{n} \sum_{i=1}^n (T_i - T_z) **State-Space Representation:** The system is implemented using the DiscreteStatespaceSystem with matrices: *State vector:* :math:`\mathbf{x} = \begin{bmatrix}T_1 \\ T_2 \\ \vdots \\ T_n\end{bmatrix}` *Input vector:* :math:`\mathbf{u} = \begin{bmatrix}T_{sup} \\ \dot{m} \\ T_z\end{bmatrix}` *Base System Matrices:* For n finite elements: .. math:: \mathbf{A} = \begin{bmatrix} -\frac{UA/n}{C_1} & 0 & 0 & \cdots & 0 \\ 0 & -\frac{UA/n}{C_2} & 0 & \cdots & 0 \\ 0 & 0 & -\frac{UA/n}{C_3} & \cdots & 0 \\ \vdots & \vdots & \vdots & \ddots & \vdots \\ 0 & 0 & 0 & \cdots & -\frac{UA/n}{C_n} \end{bmatrix} .. math:: \mathbf{B} = \begin{bmatrix} 0 & 0 & \frac{UA/n}{C_1} \\ 0 & 0 & \frac{UA/n}{C_2} \\ \vdots & \vdots & \vdots \\ 0 & 0 & \frac{UA/n}{C_n} \end{bmatrix} .. math:: \mathbf{C} = \begin{bmatrix} 0 & 0 & \cdots & 0 & 1 \end{bmatrix} .. math:: \mathbf{D} = \begin{bmatrix} 0 & 0 & 0 \end{bmatrix} **Bilinear Coupling Matrices:** *State-Input Coupling (E matrices):* .. math:: \mathbf{E} \in \mathbb{R}^{3 \times n \times n} = \begin{bmatrix} \mathbf{0}_{n \times n} & \text{(supply temperature)} \\ \begin{bmatrix} -\frac{c_p}{C_1} & 0 & 0 & \cdots & 0 & 0 \\ \frac{c_p}{C_1} & -\frac{c_p}{C_2} & 0 & \cdots & 0 & 0 \\ 0 & \frac{c_p}{C_2} & -\frac{c_p}{C_3} & \cdots & 0 & 0 \\ \vdots & \vdots & \vdots & \ddots & \vdots & \vdots \\ 0 & 0 & 0 & \cdots & \frac{c_p}{C_{n-1}} & -\frac{c_p}{C_n} \end{bmatrix} & \text{(water flow rate)} \\ \mathbf{0}_{n \times n} & \text{(zone temperature)} \end{bmatrix} *Input-Input Coupling (F matrices):* .. math:: \mathbf{F} \in \mathbb{R}^{3 \times n \times 3} = \begin{bmatrix} \begin{bmatrix} 0 & \frac{c_p}{C_1} & 0 \\ 0 & 0 & 0 \\ \vdots & \vdots & \vdots \\ 0 & 0 & 0 \end{bmatrix} & \text{(supply temperature)} \\ \mathbf{0}_{n \times 3} & \text{(water flow rate)} \\ \mathbf{0}_{n \times 3} & \text{(zone temperature)} \end{bmatrix} *Bilinear Effects* The bilinear terms handle specific flow-dependent heat transfer effects: - :math:`\mathbf{E}[1,i,i] \cdot u_1 \cdot x_i = -\frac{c_p}{C_i} \dot{m} T_i`: Water flow removing heat from element i - :math:`\mathbf{E}[1,i,i-1] \cdot u_1 \cdot x_{i-1} = \frac{c_p}{C_i} \dot{m} T_{i-1}`: Water flow bringing heat from previous element - :math:`\mathbf{F}[0,0,1] \cdot u_0 \cdot u_1 = \frac{c_p}{C_1} T_{sup} \dot{m}`: Supply temperature bringing heat to first element **Model Initialization:** The model is initialized by solving numerically for UA in steady-state nominal conditions: .. math:: 0 = \mathbf{A}_{U\!A} \mathbf{x} + \mathbf{B}_{U\!A} \mathbf{u} where: - :math:`\mathbf{A}_{U\!A}` is the A matrix given :math:`U\!A` - :math:`\mathbf{B}_{U\!A}` is the B matrix given :math:`U\!A` - :math:`\mathbf{x}` is the state vector - :math:`\mathbf{u}` is the input vector Note: the gradients of this computation is not tracked, meaning that that parameters Q_flow_nominal_sh, T_a_nominal_sh, T_b_nominal_sh, TAir_nominal_sh cannot be calibrated. Physical Interpretation: ====================== **Finite Element Discretization:** - Each element represents a section of the radiator with its own thermal mass - States capture temperature distribution along radiator length - Flow-dependent heat transfer between elements modeled with bilinear terms **Flow-Dependent Effects:** - Water flow brings heat at supply temperature to first element (F matrix coupling) - Water flow transfers heat between consecutive elements (E matrix coupling) - These effects are critical for accurate radiator performance modeling Computational Features: ====================== - **Automatic Differentiation:** PyTorch tensors enable gradient computation - **Adaptive Discretization:** Matrices updated when flow conditions change significantly - **Parameter Estimation:** UA coefficient and thermal mass available for calibration Examples -------- Basic space heater model: >>> import twin4build as tb >>> >>> # Create single-element radiator >>> heater = tb.SpaceHeaterTorchSystem( ... Q_flow_nominal_sh=2000, # 2 kW nominal output ... T_a_nominal_sh=70, # 70°C supply temperature ... T_b_nominal_sh=50, # 50°C return temperature ... TAir_nominal_sh=20, # 20°C room temperature ... thermalMassHeatCapacity=50000, # 50 kJ/K thermal mass ... nelements=1, ... id="radiator_single" ... ) Multi-element radiator for detailed modeling: >>> # Create multi-element radiator for better temperature distribution >>> heater = tb.SpaceHeaterTorchSystem( ... Q_flow_nominal_sh=3500, # 3.5 kW nominal output ... T_a_nominal_sh=75, # Higher supply temperature ... T_b_nominal_sh=55, # Higher return temperature ... TAir_nominal_sh=22, # Slightly warmer room ... thermalMassHeatCapacity=80000, # Higher thermal mass ... nelements=5, # 5 elements for detail ... id="radiator_multi_element" ... ) Notes ----- Model Characteristics: - The radiator is discretized into multiple elements for accurate temperature distribution modeling - Each element has its own thermal mass and heat transfer characteristics - The UA value is calculated numerically to match nominal conditions - The model accounts for both convective and radiative heat transfer Implementation Details: - The model uses a state-space representation for efficient computation - All calculations are performed using PyTorch tensors for gradient tracking - The UA value is optimized using numerical methods during initialization - The model supports both steady-state and dynamic simulations """ sp = [get_signature_pattern()] def __init__( self, Q_flow_nominal_sh: float = 1000, T_a_nominal_sh: float = 60, T_b_nominal_sh: float = 45, TAir_nominal_sh: float = 21, thermalMassHeatCapacity: float = 500000, nelements: int = 3, **kwargs, ): super().__init__(**kwargs) nn.Module.__init__(self) self.Q_flow_nominal_sh = Q_flow_nominal_sh self.T_a_nominal_sh = T_a_nominal_sh self.T_b_nominal_sh = T_b_nominal_sh self.TAir_nominal_sh = TAir_nominal_sh self.nelements = nelements self.UA = tps.Parameter( torch.tensor(10.0, dtype=torch.float64), requires_grad=False ) # Placeholder, will be set in initialize self.thermalMassHeatCapacity = tps.Parameter( torch.tensor(thermalMassHeatCapacity, dtype=torch.float64), requires_grad=False, ) # Define inputs and outputs as private variables self._input = { "supplyWaterTemperature": tps.Scalar(), "waterFlowRate": tps.Scalar(), "indoorTemperature": tps.Scalar(), } self._output = { # "outletWaterTemperature": tps.Vector(tensor=torch.ones(nelements)*21, size=nelements), "outletWaterTemperature": tps.Scalar(21), "Power": tps.Scalar(0), } self.parameter = { "Q_flow_nominal_sh": {}, "T_a_nominal_sh": {}, "T_b_nominal_sh": {}, "TAir_nominal_sh": {}, "thermalMassHeatCapacity": {}, "UA": {}, } self._config = {"parameters": list(self.parameter.keys())} self.INITIALIZED = False @property def config(self) -> Dict[str, List[str]]: """Get the configuration parameters. Returns: Dict[str, List[str]]: Dictionary containing configuration parameter names. """ return self._config @property def input(self) -> dict: """ Get the input ports of the space heater system. Returns: dict: Dictionary containing input ports: - "supplyWaterTemperature": Supply water temperature [°C] - "waterFlowRate": Water flow rate [kg/s] - "indoorTemperature": Indoor air temperature [°C] """ return self._input @property def output(self) -> dict: """ Get the output ports of the space heater system. Returns: dict: Dictionary containing output ports: - "outletWaterTemperature": Outlet water temperature [°C] - "Power": Heating power [W] """ return self._output
[docs] def initialize( self, start_time: datetime.datetime, end_time: datetime.datetime, step_size: int, simulator: core.Simulator, ) -> None: """Initialize the space heater system for simulation. This method performs the following initialization steps: 1. Numerically solves for the UA value that matches the nominal heat output 2. Initializes input/output data structures 3. Creates or reinitializes the state-space model Args: start_time (datetime.datetime): Start time of the simulation period. end_time (datetime.datetime): End time of the simulation period. step_size (int): Time step size in seconds. simulator (core.Simulator): Simulation model object. """ # Initialize I/O for input in self.input.values(): input.initialize( start_time=start_time, end_time=end_time, step_size=step_size, simulator=simulator, ) for output in self.output.values(): output.initialize( start_time=start_time, end_time=end_time, step_size=step_size, simulator=simulator, ) if not self.INITIALIZED: # Numerically solve for UA using fsolve so that steady-state output matches Q_flow_nominal_sh UA0 = float( self.Q_flow_nominal_sh / (self.T_b_nominal_sh - self.TAir_nominal_sh) ) root = fsolve(self._ua_residual, UA0, full_output=True) UA_val = root[0][0] self.UA.data = torch.tensor(UA_val, dtype=torch.float64) # First initialization self._create_state_space_model() self.ss_model.initialize(start_time, end_time, step_size, simulator) self.INITIALIZED = True else: # Re-initialize the state space model self._create_state_space_model() self.ss_model.initialize(start_time, end_time, step_size, simulator)
def _ua_residual(self, UA_candidate): """Calculate the residual for UA optimization. This method is used by fsolve to find the UA value that matches the nominal heat output. It calculates the steady-state temperatures and heat output for a given UA value and returns the difference from the nominal heat output. Args: UA_candidate (float): Candidate UA value to evaluate. Returns: float: Difference between calculated and nominal heat output. """ n = self.nelements C_elem = float(self.thermalMassHeatCapacity.get().item()) / n UA_elem = float(UA_candidate) / n m_dot = float( self.Q_flow_nominal_sh / ( Constants.specificHeatCapacity["water"] * (self.T_a_nominal_sh - self.T_b_nominal_sh) ) ) c_p = float(Constants.specificHeatCapacity["water"]) # Build A, B A = torch.zeros((n, n), dtype=torch.float64) B = torch.zeros((n, 3), dtype=torch.float64) for i in range(n): A[i, i] = -(m_dot * c_p + UA_elem) / C_elem if i > 0: A[i, i - 1] = (m_dot * c_p) / C_elem B[0, 0] = (m_dot * c_p) / C_elem for i in range(n): B[i, 2] = UA_elem / C_elem u = torch.tensor( [self.T_a_nominal_sh, m_dot, self.TAir_nominal_sh], dtype=torch.float64 ) try: x_ss = -torch.linalg.solve( A, B @ u ) # Find states in steady-state given nominal conditions and UA guess except Exception: return 1e6 Power = UA_elem * torch.sum( x_ss - self.TAir_nominal_sh ) # Calculate power given states and UA guess return Power - self.Q_flow_nominal_sh def _create_state_space_model(self): """Create the state-space model for the space heater. This method creates a discrete state-space model representing the thermal dynamics of the space heater. The model includes: - State matrix A for thermal dynamics - Input matrix B for external inputs - Output matrix C for temperature output - Feedthrough matrix D - State-input coupling matrix E for flow effects - Input-input coupling matrix F for supply temperature effects """ n = self.nelements n_inputs = 3 # [supplyWaterTemperature, waterFlowRate, indoorTemperature] C_elem = self.thermalMassHeatCapacity.get() / n UA_elem = self.UA.get() / n c_p = Constants.specificHeatCapacity["water"] # LTI part: Only UA/C on diagonal A = torch.zeros((n, n), dtype=torch.float64) for i in range(n): A[i, i] = -UA_elem / C_elem # B matrix: Only UA/C for indoor temperature input B = torch.zeros((n, n_inputs), dtype=torch.float64) for i in range(n): B[i, 2] = UA_elem / C_elem # State-input coupling (E): waterFlowRate E = torch.zeros((n_inputs, n, n), dtype=torch.float64) for i in range(n): E[1, i, i] = -c_p / C_elem if i > 0: E[1, i, i - 1] = c_p / C_elem # Input-input coupling (F): T_supply * m_dot for first state F = torch.zeros((n_inputs, n, n_inputs), dtype=torch.float64) F[0, 0, 1] = c_p / C_elem # Only first state, T_supply * m_dot C = torch.zeros((1, n), dtype=torch.float64) C[0, n - 1] = 1.0 D = torch.zeros((1, n_inputs), dtype=torch.float64) x0 = torch.zeros(n, dtype=torch.float64) x0[:] = self.output["outletWaterTemperature"].get() self.ss_model = DiscreteStatespaceSystem( A=A, B=B, C=C, D=D, x0=x0, state_names=[f"T_{i+1}" for i in range(n)], E=E, # State-input coupling F=F, # Input-input coupling add_noise=False, id=f"ss_model_{self.id}", )
[docs] def do_step( self, secondTime=None, dateTime=None, step_size=None, stepIndex: Optional[int] = None, ): """Perform one simulation step. This method advances the state-space model by one time step and calculates the outlet water temperature and heat output. The method: 1. Collects current input values 2. Updates the state-space model 3. Calculates the heat output based on element temperatures 4. Updates output values Args: secondTime (float, optional): Current simulation time in seconds. dateTime (datetime, optional): Current simulation date and time. step_size (float, optional): Time step size in seconds. stepIndex (int, optional): Current simulation step index. """ u = torch.stack( [ self.input["supplyWaterTemperature"].get(), self.input["waterFlowRate"].get(), self.input["indoorTemperature"].get(), ] ).squeeze() self.ss_model.input["u"].set(u, stepIndex) self.ss_model.do_step(secondTime, dateTime, step_size, stepIndex) y = self.ss_model.output["y"].get() outletWaterTemperature = y[0] UA_elem = self.UA.get() / self.nelements temps = self.ss_model.get_state() # print("----") # print("temps: ", temps) # print("u[2]: ", u[2]) Power = UA_elem * torch.sum(temps - u[2]) # print("Power: ", Power) self.output["outletWaterTemperature"].set(outletWaterTemperature, stepIndex) self.output["Power"].set(Power, stepIndex)