# Standard library imports
import datetime
from typing import Any, Dict, List, Optional
# Third party imports
import numpy as np
import torch
import torch.nn as nn
# Local application imports
import twin4build.core as core
import twin4build.utils.types as tps
from twin4build.systems.utils.discrete_statespace_system import DiscreteStatespaceSystem
from twin4build.utils.constants import Constants
[docs]
class BuildingSpaceMassTorchSystem(core.System, nn.Module):
r"""
Building Space CO2 Concentration Model using Mass Balance Dynamics.
This model represents the CO2 concentration dynamics in a building space considering
supply and exhaust air flows, occupant CO2 generation, infiltration, and outdoor
CO2 concentration using bilinear state-space dynamics.
Args:
V: Volume of the space [m³]
G_occ: CO2 generation rate per occupant [ppm·kg/s]
m_inf: Infiltration rate [kg/s]
Mathematical Formulation:
=========================
**Continuous-Time Differential Equation:**
The CO2 concentration dynamics are governed by the mass balance equation:
.. math::
V\frac{dC}{dt} = \dot{m}_{sup}C_{out} - \dot{m}_{exh}C + \dot{m}_{inf}(C_{out} - C) + G_{occ} N_{occ}
where:
- :math:`V`: Volume of the space [m³]
- :math:`C`: Indoor CO2 concentration [ppm] (state variable)
- :math:`\dot{m}_{sup}`: Supply air flow rate [kg/s] (input)
- :math:`\dot{m}_{exh}`: Exhaust air flow rate [kg/s] (input)
- :math:`\dot{m}_{inf}`: Infiltration rate [kg/s] (parameter)
- :math:`C_{out}`: Outdoor CO2 concentration [ppm] (input)
- :math:`G_{occ}`: CO2 generation rate per occupant [ppm·kg/s] (parameter)
- :math:`N_{occ}`: Number of occupants (input)
Note: Supply air CO2 concentration is assumed equal to outdoor CO2 concentration.
**State-Space Representation:**
The system is implemented using the DiscreteStatespaceSystem with matrices:
*State vector:* :math:`\mathbf{x} = \begin{bmatrix}C\end{bmatrix}`
*Input vector:* :math:`\mathbf{u} = \begin{bmatrix}\dot{m}_{sup} \\ \dot{m}_{exh} \\ C_{out} \\ N_{occ}\end{bmatrix}`
*Base System Matrices:*
.. math::
\mathbf{A} = \begin{bmatrix} -\frac{\dot{m}_{inf}}{V} \end{bmatrix}
\mathbf{B} = \begin{bmatrix} 0 & 0 & \frac{\dot{m}_{inf}}{V} & \frac{G_{occ}}{V} \end{bmatrix}
\mathbf{C} = \begin{bmatrix} 1 \end{bmatrix}
\mathbf{D} = \begin{bmatrix} 0 & 0 & 0 & 0 \end{bmatrix}
**Bilinear Coupling Matrices:**
*State-Input Coupling (E matrices):*
.. math::
\mathbf{E} \in \mathbb{R}^{4 \times 1 \times 1} = \begin{bmatrix}
\begin{bmatrix} 0 \end{bmatrix} & \text{(supply flow)} \\
\begin{bmatrix} -\frac{1}{V} \end{bmatrix} & \text{(exhaust flow)} \\
\begin{bmatrix} 0 \end{bmatrix} & \text{(outdoor CO2)} \\
\begin{bmatrix} 0 \end{bmatrix} & \text{(occupants)}
\end{bmatrix}
*Input-Input Coupling (F matrices):*
.. math::
\mathbf{F} \in \mathbb{R}^{4 \times 1 \times 4} = \begin{bmatrix}
\begin{bmatrix} 0 & 0 & \frac{1}{V} & 0 \end{bmatrix} & \text{(supply flow)} \\
\begin{bmatrix} 0 & 0 & 0 & 0 \end{bmatrix} & \text{(exhaust flow)} \\
\begin{bmatrix} 0 & 0 & 0 & 0 \end{bmatrix} & \text{(outdoor CO2)} \\
\begin{bmatrix} 0 & 0 & 0 & 0 \end{bmatrix} & \text{(occupants)}
\end{bmatrix}
*Bilinear Effects*
The bilinear terms handle specific flow-dependent mass transfer effects:
- :math:`\mathbf{E}[1,0,0] \cdot u_1 \cdot x_0 = -\frac{1}{V} \dot{m}_{exh} C`: Exhaust flow removing CO2
- :math:`\mathbf{F}[0,0,2] \cdot u_0 \cdot u_2 = \frac{1}{V} \dot{m}_{sup} C_{out}`: Supply flow bringing outdoor air
Physical Interpretation:
======================
**Mass Balance System:**
- Single state represents indoor CO2 concentration
- Inputs represent ventilation flows, outdoor conditions, and occupancy
- Bilinear terms model flow-dependent mass transfer accurately
**Flow-Dependent Effects:**
- Supply air flow brings outdoor CO2 at outdoor concentration (F matrix coupling)
- Exhaust air flow removes CO2 at indoor concentration (E matrix coupling)
Computational Features:
======================
- **Automatic Differentiation:** PyTorch tensors enable gradient computation
- **Adaptive Discretization:** Matrices updated when flows change significantly
- **Parameter Estimation:** All mass balance parameters available for calibration
Examples
--------
Basic CO2 model:
>>> import twin4build as tb
>>>
>>> # Create CO2 model with default parameters
>>> co2_model = tb.BuildingSpaceMassTorchSystem(
... V=150, # Room volume [m³]
... G_occ=6e-6, # Higher CO2 generation per person
... m_inf=0.002, # Higher infiltration rate
... id="zone_1_co2"
... )
Large space CO2 model:
>>> # Model for large space with higher occupancy
>>> co2_model = tb.BuildingSpaceMassTorchSystem(
... V=500, # Large space volume
... G_occ=4e-6, # Lower per-person generation
... m_inf=0.005, # Higher infiltration for large space
... id="large_space_co2"
... )
"""
def __init__(
self, V: float = 100, G_occ: float = 5e-6, m_inf: float = 0.001, **kwargs
):
super().__init__(**kwargs)
nn.Module.__init__(self)
# Store parameters as tps.Parameters
self.V = tps.Parameter(
torch.tensor(V, dtype=torch.float64), requires_grad=False
)
self.G_occ = tps.Parameter(
torch.tensor(G_occ, dtype=torch.float64), requires_grad=False
)
self.m_inf = tps.Parameter(
torch.tensor(m_inf, dtype=torch.float64), requires_grad=False
)
# Define inputs and outputs
self.input = {
"supplyAirFlowRate": tps.Scalar(), # Supply air flow rate [kg/s]
"exhaustAirFlowRate": tps.Scalar(), # Exhaust air flow rate [kg/s]
"outdoorCO2": tps.Scalar(), # Supply air CO2 concentration [ppm]
"numberOfPeople": tps.Scalar(), # Number of occupants
}
# Define outputs
self.output = {
"indoorCO2": tps.Scalar(400), # Indoor CO2 concentration [ppm]
}
# Define parameters for calibration
self.parameter = {
"V": {"lb": 10.0, "ub": 1000.0},
"G_occ": {"lb": 0.000001, "ub": 0.00001},
"m_inf": {"lb": 0.0001, "ub": 0.01},
}
self._config = {"parameters": list(self.parameter.keys())}
self.INITIALIZED = False
[docs]
def initialize(
self,
start_time: datetime.datetime,
end_time: datetime.datetime,
step_size: int,
simulator: core.Simulator,
) -> None:
"""Initialize the mass balance model by setting up the state-space representation."""
# 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:
# 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
self._create_state_space_model() # We need to re-create the model because the parameters have changed to create a new computation graph
self.ss_model.initialize(start_time, end_time, step_size, simulator)
def _create_state_space_model(self):
"""Create the state space model matrices using PyTorch tensors."""
# Single state for CO2 concentration
n_states = 1
n_inputs = len(self.input)
# Initialize A and B matrices with zeros
A = torch.zeros((n_states, n_states), dtype=torch.float64)
B = torch.zeros((n_states, n_inputs), dtype=torch.float64)
# State matrix A: -sum of all flow rates / volume
A[0, 0] = -(
self.m_inf.get() / self.V.get()
) # Base coefficient from infiltration
# Input matrix B coefficients
# Supply air flow rate * supply air CO2
B[0, 0] = 1 / self.V.get() # supplyAirFlowRate coefficient
B[0, 2] = 1 / self.V.get() # outdoorCO2 coefficient
# Exhaust air flow rate
B[0, 1] = -1 / self.V.get() # exhaustAirFlowRate coefficient
# Outdoor CO2
B[0, 2] = self.m_inf.get() / self.V.get() # outdoorCO2 coefficient
# Number of people
B[0, 3] = self.G_occ.get() / self.V.get() # numberOfPeople coefficient
# Output matrix C - Identity matrix for direct observation
C = torch.eye(n_states, dtype=torch.float64)
# Feedthrough matrix D (no direct feedthrough)
D = torch.zeros((n_states, n_inputs), dtype=torch.float64)
# Initial state
x0 = torch.tensor([self.output["indoorCO2"].get()], dtype=torch.float64)
# E matrix for input-state coupling: shape (n_inputs, n_states, n_states)
E = torch.zeros((n_inputs, n_states, n_states), dtype=torch.float64)
# -m_ex*C (input 1, state 0)
E[1, 0, 0] = -1 / self.V.get() # exhaustAirFlowRate * C
# F matrix for input-input coupling: shape (n_inputs, n_states, n_inputs)
F = torch.zeros((n_inputs, n_states, n_inputs), dtype=torch.float64)
# m_sup*C_sup (inputs 0 and 2)
F[0, 0, 2] = 1 / self.V.get() # supplyAirFlowRate * supplyAirCO2
self.ss_model = DiscreteStatespaceSystem(
A=A,
B=B,
C=C,
D=D,
x0=x0,
state_names=None,
add_noise=False,
id=f"ss_mass_model_{self.id}",
E=E,
F=F,
)
@property
def config(self):
"""Get the system configuration."""
return self._config
[docs]
def do_step(
self,
secondTime: Optional[float] = None,
dateTime: Optional[datetime.datetime] = None,
step_size: Optional[float] = None,
stepIndex: Optional[int] = None,
) -> None:
"""Execute a single simulation step using the state-space model."""
# Build input vector u
u = torch.stack(
[
self.input["supplyAirFlowRate"].get(),
self.input["exhaustAirFlowRate"].get(),
self.input["outdoorCO2"].get(),
self.input["numberOfPeople"].get(),
]
).squeeze()
self.ss_model.input["u"].set(u, stepIndex)
self.ss_model.do_step(secondTime, dateTime, step_size, stepIndex=stepIndex)
y = self.ss_model.output["y"].get()
self.output["indoorCO2"].set(y[0], stepIndex)