Skip to content

Thermal API

The simses.thermal module provides two environment models — a simple ambient coupling and a physics-based container with walls + HVAC + solar — wired through the ThermalComponent structural protocol. For the thermal network, protocol contract, and the HVAC strategy/hardware split, see the Thermal Models concept page.

Thermal component protocol

Structural contract that any registerable node (battery, non-battery storage, custom component) must satisfy: state.T, state.heat, thermal_capacity, thermal_resistance.

simses.thermal.protocol.ThermalComponent

Bases: Protocol

Protocol for objects that can be registered as thermal nodes.

Satisfying this protocol does not require explicit inheritance — any object with these attributes qualifies (structural subtyping).

Attributes:

Name Type Description
state ThermalComponentState

Mutable state object exposing T (temperature in °C, read/written) and heat (heat generation in W, read).

thermal_capacity float

Thermal capacity in J/K.

thermal_resistance float

Thermal resistance to the thermal environment in K/W.

Source code in src/simses/thermal/protocol.py
class ThermalComponent(Protocol):
    """Protocol for objects that can be registered as thermal nodes.

    Satisfying this protocol does not require explicit inheritance — any
    object with these attributes qualifies (structural subtyping).

    Attributes:
        state:              Mutable state object exposing ``T`` (temperature in °C,
                            read/written) and ``heat`` (heat generation in W, read).
        thermal_capacity:   Thermal capacity in J/K.
        thermal_resistance: Thermal resistance to the thermal environment in K/W.
    """

    state: ThermalComponentState
    thermal_capacity: float
    thermal_resistance: float

Ambient thermal model

Zero-dimensional environment: each registered component is an independent node coupled to a single ambient temperature. Use when the thermal environment can be treated as a uniform external temperature (bench tests, climate-controlled rooms, first-order sanity checks).

simses.thermal.ambient.AmbientThermalModel

Zero-dimensional room thermal model with constant ambient temperature.

Models heat exchange between registered components and a constant-temperature environment. Each component is an independent thermal node with its own temperature, thermal capacity, and thermal resistance.

Per-component ODE (forward Euler integration)::

dT_i / dt = Q_heat_i / C_th_i + (T_ambient - T_i) / (R_th_i * C_th_i)

Components are registered via :meth:add_component and must provide:

  • state.T -- current temperature in °C (read/written)
  • state.heat -- total heat generation in W (read)
  • thermal_capacity -- thermal capacity in J/K (read)
  • thermal_resistance -- thermal resistance in K/W (read)
Source code in src/simses/thermal/ambient.py
class AmbientThermalModel:
    """Zero-dimensional room thermal model with constant ambient temperature.

    Models heat exchange between registered components and a constant-temperature
    environment. Each component is an independent thermal node with its own
    temperature, thermal capacity, and thermal resistance.

    Per-component ODE (forward Euler integration)::

        dT_i / dt = Q_heat_i / C_th_i + (T_ambient - T_i) / (R_th_i * C_th_i)

    Components are registered via :meth:`add_component` and must provide:

    * ``state.T``             -- current temperature in °C (read/written)
    * ``state.heat``          -- total heat generation in W (read)
    * ``thermal_capacity``    -- thermal capacity in J/K (read)
    * ``thermal_resistance``  -- thermal resistance in K/W (read)
    """

    def __init__(self, T_ambient: float, components: list | None = None) -> None:
        """
        Args:
            T_ambient: Ambient temperature in °C. May be overwritten at any
                time via the ``T_ambient`` attribute to drive a
                time-varying profile.
            components: Initial list of :class:`ThermalComponent` nodes.
                ``None`` (default) starts with no components — add them
                via :meth:`add_component`.
        """
        self.state = AmbientThermalState(T_ambient=T_ambient)
        self._components: list = list(components) if components else []

    def add_component(self, component: ThermalComponent) -> None:
        """Register a component as a thermal node.

        Args:
            component: Any object satisfying the :class:`ThermalComponent` protocol.
        """
        self._components.append(component)

    def step(self, dt: float) -> None:
        """Advance every registered component's temperature by one timestep.

        Args:
            dt: Timestep in seconds.
        """
        T_amb = self.T_ambient
        for comp in self._components:
            T = comp.state.T
            C_th = comp.thermal_capacity
            R_th = comp.thermal_resistance
            Q_loss = comp.state.heat

            dT_dt = Q_loss / C_th + (T_amb - T) / (R_th * C_th)
            comp.state.T = T + dT_dt * dt

    @property
    def T_ambient(self) -> float:
        """External ambient temperature in °C (convenience accessor for ``state.T_ambient``)."""
        return self.state.T_ambient

    @T_ambient.setter
    def T_ambient(self, value: float) -> None:
        self.state.T_ambient = value

T_ambient property writable

External ambient temperature in °C (convenience accessor for state.T_ambient).

__init__(T_ambient, components=None)

Parameters:

Name Type Description Default
T_ambient float

Ambient temperature in °C. May be overwritten at any time via the T_ambient attribute to drive a time-varying profile.

required
components list | None

Initial list of :class:ThermalComponent nodes. None (default) starts with no components — add them via :meth:add_component.

None
Source code in src/simses/thermal/ambient.py
def __init__(self, T_ambient: float, components: list | None = None) -> None:
    """
    Args:
        T_ambient: Ambient temperature in °C. May be overwritten at any
            time via the ``T_ambient`` attribute to drive a
            time-varying profile.
        components: Initial list of :class:`ThermalComponent` nodes.
            ``None`` (default) starts with no components — add them
            via :meth:`add_component`.
    """
    self.state = AmbientThermalState(T_ambient=T_ambient)
    self._components: list = list(components) if components else []

add_component(component)

Register a component as a thermal node.

Parameters:

Name Type Description Default
component ThermalComponent

Any object satisfying the :class:ThermalComponent protocol.

required
Source code in src/simses/thermal/ambient.py
def add_component(self, component: ThermalComponent) -> None:
    """Register a component as a thermal node.

    Args:
        component: Any object satisfying the :class:`ThermalComponent` protocol.
    """
    self._components.append(component)

step(dt)

Advance every registered component's temperature by one timestep.

Parameters:

Name Type Description Default
dt float

Timestep in seconds.

required
Source code in src/simses/thermal/ambient.py
def step(self, dt: float) -> None:
    """Advance every registered component's temperature by one timestep.

    Args:
        dt: Timestep in seconds.
    """
    T_amb = self.T_ambient
    for comp in self._components:
        T = comp.state.T
        C_th = comp.thermal_capacity
        R_th = comp.thermal_resistance
        Q_loss = comp.state.heat

        dT_dt = Q_loss / C_th + (T_amb - T) / (R_th * C_th)
        comp.state.T = T + dT_dt * dt

simses.thermal.ambient.AmbientThermalState dataclass

Mutable state of a :class:AmbientThermalModel.

Attributes:

Name Type Description
T_ambient float

External ambient temperature in °C.

Source code in src/simses/thermal/ambient.py
@dataclass
class AmbientThermalState:
    """Mutable state of a :class:`AmbientThermalModel`.

    Attributes:
        T_ambient:     External ambient temperature in °C.
    """

    T_ambient: float

Container thermal model

Physics-based BESS container with five coupled thermal nodes (batteries, internal air, three wall layers), HVAC injection at the air node, and optional solar heat on the outer wall. Use when wall conduction, air thermal mass, HVAC sizing, or diurnal external cycles matter.

simses.thermal.container.ContainerThermalModel

Physics-based container thermal model with three-layer walls and HVAC.

Five coupled thermal nodes (forward Euler): - Battery nodes (one per registered component) - Internal air - Inner wall layer - Mid wall layer - Outer wall layer

The outer wall is coupled to the ambient temperature (updatable via :attr:T_ambient). Observable outputs are stored in :attr:state after each :meth:step.

Components are registered via :meth:add_component and must satisfy the :class:~simses.thermal.protocol.ThermalComponent protocol:

  • state.T -- current temperature in °C (read/written)
  • state.heat -- total heat generation in W (read)
  • thermal_capacity -- thermal capacity in J/K (read)
  • thermal_resistance -- thermal resistance to internal air in K/W (read)
Source code in src/simses/thermal/container.py
class ContainerThermalModel:
    """Physics-based container thermal model with three-layer walls and HVAC.

    Five coupled thermal nodes (forward Euler):
      - Battery nodes (one per registered component)
      - Internal air
      - Inner wall layer
      - Mid wall layer
      - Outer wall layer

    The outer wall is coupled to the ambient temperature (updatable via
    :attr:`T_ambient`). Observable outputs are stored in :attr:`state`
    after each :meth:`step`.

    Components are registered via :meth:`add_component` and must satisfy
    the :class:`~simses.thermal.protocol.ThermalComponent` protocol:

    * ``state.T``            -- current temperature in °C (read/written)
    * ``state.heat``         -- total heat generation in W (read)
    * ``thermal_capacity``   -- thermal capacity in J/K (read)
    * ``thermal_resistance`` -- thermal resistance to internal air in K/W (read)
    """

    _RHO_AIR: float = 1.204  # kg/m³
    _CP_AIR: float = 1006.0  # J/kgK

    def __init__(
        self,
        properties: ContainerProperties,
        T_ambient: float,
        T_initial: float,
        hvac: HvacModel,
        tms: ThermalManagementStrategy,
    ) -> None:
        """
        Args:
            properties: Container geometry and wall-layer parameters.
            T_ambient: Initial external ambient temperature in °C.
            T_initial: Initial temperature for all internal nodes in °C.
            hvac: HVAC hardware model mapping thermal demand to
                electrical consumption.
            tms: Thermal-management strategy producing the thermal-power
                demand from the reference temperature.
        """
        self._props = properties
        self.hvac = hvac
        self.tms = tms
        self._components: list = []

        self.state = ContainerThermalState(
            T_air=T_initial,
            T_in=T_initial,
            T_mid=T_initial,
            T_out=T_initial,
            T_amb=T_ambient,
        )

        # precompute thermal capacities
        A = properties.A_surface
        V = properties.V_internal
        inner = properties.inner
        mid = properties.mid
        outer = properties.outer

        self._C_air = self._RHO_AIR * V * self._CP_AIR
        self._C_in = inner.density * inner.thickness * A * inner.specific_heat
        self._C_mid = mid.density * mid.thickness * A * mid.specific_heat
        self._C_out = outer.density * outer.thickness * A * outer.specific_heat

        # precompute thermal resistances
        self._R_air_out = 1.0 / (properties.h_outer * A)
        self._R_out_mid = outer.thickness / (outer.conductivity * A) + 0.5 * mid.thickness / (mid.conductivity * A)
        self._R_mid_in = 0.5 * mid.thickness / (mid.conductivity * A) + inner.thickness / (inner.conductivity * A)
        self._R_in_air = 1.0 / (properties.h_inner * A)

    @property
    def T_ambient(self) -> float:
        """External ambient temperature in °C (convenience accessor for ``state.T_ambient``)."""
        return self.state.T_amb

    @T_ambient.setter
    def T_ambient(self, value: float) -> None:
        self.state.T_amb = value

    @property
    def Q_solar(self) -> float:
        """Solar irradiance heat load on the outer wall in W."""
        return self.state.Q_solar

    @Q_solar.setter
    def Q_solar(self, value: float) -> None:
        self.state.Q_solar = value

    def add_component(self, component: ThermalComponent) -> None:
        """Register a component as a thermal node.

        Args:
            component: Any object satisfying the :class:`ThermalComponent` protocol.
        """
        self._components.append(component)

    def step(self, dt: float) -> None:
        """Advance all thermal nodes by one timestep.

        Args:
            dt: Timestep in seconds.
        """
        T_air = self.state.T_air
        T_in = self.state.T_in
        T_mid = self.state.T_mid
        T_out = self.state.T_out
        T_amb = self.state.T_amb

        # resistances (short names for readability)
        R_air_out = self._R_air_out
        R_out_mid = self._R_out_mid
        R_mid_in = self._R_mid_in
        R_in_air = self._R_in_air

        # HVAC: thermal power injected into air and associated electrical consumption
        T_ref = max((c.state.T for c in self._components), default=T_air)
        Q_hvac = self.tms.control(T_ref, dt)
        P_el = self.hvac.electrical_consumption(Q_hvac)

        # battery nodes — compute all dT before write-back
        bat_dTs: list[float] = []
        Q_bats_to_air = 0.0
        for comp in self._components:
            T_bat = comp.state.T
            C_bat = comp.thermal_capacity
            R_bat = comp.thermal_resistance
            dT = (comp.state.heat / C_bat) - (T_bat - T_air) / (R_bat * C_bat)
            bat_dTs.append(dT)
            Q_bats_to_air += (T_bat - T_air) / R_bat

        # air node
        dT_air = (Q_bats_to_air + (T_in - T_air) / R_in_air + Q_hvac) / self._C_air

        # wall nodes
        dT_in = ((T_mid - T_in) / R_mid_in - (T_in - T_air) / R_in_air) / self._C_in
        dT_mid = ((T_out - T_mid) / R_out_mid - (T_mid - T_in) / R_mid_in) / self._C_mid
        dT_out = (self.state.Q_solar + (T_amb - T_out) / R_air_out - (T_out - T_mid) / R_out_mid) / self._C_out

        # write back — batteries first, then state, then walls
        for comp, dT in zip(self._components, bat_dTs, strict=True):
            comp.state.T += dT * dt

        self.state.T_air = T_air + dT_air * dt
        self.state.power_th = Q_hvac
        self.state.power_el = P_el

        self.state.T_in = T_in + dT_in * dt
        self.state.T_mid = T_mid + dT_mid * dt
        self.state.T_out = T_out + dT_out * dt

T_ambient property writable

External ambient temperature in °C (convenience accessor for state.T_ambient).

Q_solar property writable

Solar irradiance heat load on the outer wall in W.

__init__(properties, T_ambient, T_initial, hvac, tms)

Parameters:

Name Type Description Default
properties ContainerProperties

Container geometry and wall-layer parameters.

required
T_ambient float

Initial external ambient temperature in °C.

required
T_initial float

Initial temperature for all internal nodes in °C.

required
hvac HvacModel

HVAC hardware model mapping thermal demand to electrical consumption.

required
tms ThermalManagementStrategy

Thermal-management strategy producing the thermal-power demand from the reference temperature.

required
Source code in src/simses/thermal/container.py
def __init__(
    self,
    properties: ContainerProperties,
    T_ambient: float,
    T_initial: float,
    hvac: HvacModel,
    tms: ThermalManagementStrategy,
) -> None:
    """
    Args:
        properties: Container geometry and wall-layer parameters.
        T_ambient: Initial external ambient temperature in °C.
        T_initial: Initial temperature for all internal nodes in °C.
        hvac: HVAC hardware model mapping thermal demand to
            electrical consumption.
        tms: Thermal-management strategy producing the thermal-power
            demand from the reference temperature.
    """
    self._props = properties
    self.hvac = hvac
    self.tms = tms
    self._components: list = []

    self.state = ContainerThermalState(
        T_air=T_initial,
        T_in=T_initial,
        T_mid=T_initial,
        T_out=T_initial,
        T_amb=T_ambient,
    )

    # precompute thermal capacities
    A = properties.A_surface
    V = properties.V_internal
    inner = properties.inner
    mid = properties.mid
    outer = properties.outer

    self._C_air = self._RHO_AIR * V * self._CP_AIR
    self._C_in = inner.density * inner.thickness * A * inner.specific_heat
    self._C_mid = mid.density * mid.thickness * A * mid.specific_heat
    self._C_out = outer.density * outer.thickness * A * outer.specific_heat

    # precompute thermal resistances
    self._R_air_out = 1.0 / (properties.h_outer * A)
    self._R_out_mid = outer.thickness / (outer.conductivity * A) + 0.5 * mid.thickness / (mid.conductivity * A)
    self._R_mid_in = 0.5 * mid.thickness / (mid.conductivity * A) + inner.thickness / (inner.conductivity * A)
    self._R_in_air = 1.0 / (properties.h_inner * A)

add_component(component)

Register a component as a thermal node.

Parameters:

Name Type Description Default
component ThermalComponent

Any object satisfying the :class:ThermalComponent protocol.

required
Source code in src/simses/thermal/container.py
def add_component(self, component: ThermalComponent) -> None:
    """Register a component as a thermal node.

    Args:
        component: Any object satisfying the :class:`ThermalComponent` protocol.
    """
    self._components.append(component)

step(dt)

Advance all thermal nodes by one timestep.

Parameters:

Name Type Description Default
dt float

Timestep in seconds.

required
Source code in src/simses/thermal/container.py
def step(self, dt: float) -> None:
    """Advance all thermal nodes by one timestep.

    Args:
        dt: Timestep in seconds.
    """
    T_air = self.state.T_air
    T_in = self.state.T_in
    T_mid = self.state.T_mid
    T_out = self.state.T_out
    T_amb = self.state.T_amb

    # resistances (short names for readability)
    R_air_out = self._R_air_out
    R_out_mid = self._R_out_mid
    R_mid_in = self._R_mid_in
    R_in_air = self._R_in_air

    # HVAC: thermal power injected into air and associated electrical consumption
    T_ref = max((c.state.T for c in self._components), default=T_air)
    Q_hvac = self.tms.control(T_ref, dt)
    P_el = self.hvac.electrical_consumption(Q_hvac)

    # battery nodes — compute all dT before write-back
    bat_dTs: list[float] = []
    Q_bats_to_air = 0.0
    for comp in self._components:
        T_bat = comp.state.T
        C_bat = comp.thermal_capacity
        R_bat = comp.thermal_resistance
        dT = (comp.state.heat / C_bat) - (T_bat - T_air) / (R_bat * C_bat)
        bat_dTs.append(dT)
        Q_bats_to_air += (T_bat - T_air) / R_bat

    # air node
    dT_air = (Q_bats_to_air + (T_in - T_air) / R_in_air + Q_hvac) / self._C_air

    # wall nodes
    dT_in = ((T_mid - T_in) / R_mid_in - (T_in - T_air) / R_in_air) / self._C_in
    dT_mid = ((T_out - T_mid) / R_out_mid - (T_mid - T_in) / R_mid_in) / self._C_mid
    dT_out = (self.state.Q_solar + (T_amb - T_out) / R_air_out - (T_out - T_mid) / R_out_mid) / self._C_out

    # write back — batteries first, then state, then walls
    for comp, dT in zip(self._components, bat_dTs, strict=True):
        comp.state.T += dT * dt

    self.state.T_air = T_air + dT_air * dt
    self.state.power_th = Q_hvac
    self.state.power_el = P_el

    self.state.T_in = T_in + dT_in * dt
    self.state.T_mid = T_mid + dT_mid * dt
    self.state.T_out = T_out + dT_out * dt

simses.thermal.container.ContainerThermalState dataclass

Mutable state of a :class:ContainerThermalModel.

Attributes:

Name Type Description
T_air float

Internal air temperature in °C.

T_in float

Inner wall layer temperature in °C.

T_mid float

Middle wall layer temperature in °C.

T_out float

Outer wall layer temperature in °C.

T_ambient float

External ambient temperature in °C.

Q_solar float

Solar irradiance heat load on the outer wall in W (default 0).

power_th float

HVAC thermal power delivered to the air in W (positive = heating, negative = cooling, 0 = idle).

power_el float

HVAC electrical power consumption in W (always ≥ 0).

Source code in src/simses/thermal/container.py
@dataclass
class ContainerThermalState:
    """Mutable state of a :class:`ContainerThermalModel`.

    Attributes:
        T_air:      Internal air temperature in °C.
        T_in:       Inner wall layer temperature in °C.
        T_mid:      Middle wall layer temperature in °C.
        T_out:      Outer wall layer temperature in °C.
        T_ambient:  External ambient temperature in °C.
        Q_solar:    Solar irradiance heat load on the outer wall in W
                    (default 0).
        power_th:   HVAC thermal power delivered to the air in W
                    (positive = heating, negative = cooling, 0 = idle).
        power_el:   HVAC electrical power consumption in W (always ≥ 0).
    """

    T_air: float
    T_in: float
    T_mid: float
    T_out: float
    T_amb: float
    Q_solar: float = 0.0
    power_th: float = 0.0
    power_el: float = 0.0

simses.thermal.container.ContainerProperties dataclass

Geometry and thermal properties of a container.

Internal dimensions are used to derive surface area and internal volume. Convection coefficients apply at inner and outer surfaces.

Attributes:

Name Type Description
length float

Internal length in m.

width float

Internal width in m.

height float

Internal height in m.

h_inner float

Inner surface convection coefficient in W/m²K.

h_outer float

Outer surface convection coefficient in W/m²K.

inner ContainerLayer

Innermost wall layer (e.g. aluminium).

mid ContainerLayer

Middle wall layer (e.g. insulation).

outer ContainerLayer

Outermost wall layer (e.g. steel).

vol_air float

Factor of volume of the container occupied by air in % (default: 1.0)

A_surface float

Total surface area in m² (derived).

V_internal float

Internal volume in m³ (derived).

Source code in src/simses/thermal/container.py
@dataclass
class ContainerProperties:
    """Geometry and thermal properties of a container.

    Internal dimensions are used to derive surface area and internal volume.
    Convection coefficients apply at inner and outer surfaces.

    Attributes:
        length:     Internal length in m.
        width:      Internal width in m.
        height:     Internal height in m.
        h_inner:    Inner surface convection coefficient in W/m²K.
        h_outer:    Outer surface convection coefficient in W/m²K.
        inner:      Innermost wall layer (e.g. aluminium).
        mid:        Middle wall layer (e.g. insulation).
        outer:      Outermost wall layer (e.g. steel).
        vol_air:    Factor of volume of the container occupied by air in % (default: 1.0)
        A_surface:  Total surface area in m² (derived).
        V_internal: Internal volume in m³ (derived).
    """

    length: float
    width: float
    height: float
    h_inner: float
    h_outer: float
    inner: ContainerLayer
    mid: ContainerLayer
    outer: ContainerLayer
    vol_air: float = 1.0
    A_surface: float = field(init=False)
    V_internal: float = field(init=False)

    def __post_init__(self):
        self.A_surface = 2 * (self.length * self.width + self.length * self.height + self.width * self.height)
        self.V_internal = self.length * self.width * self.height * self.vol_air

simses.thermal.container.ContainerLayer dataclass

Physical properties of a single wall layer.

Attributes:

Name Type Description
thickness float

Layer thickness in m.

conductivity float

Thermal conductivity in W/mK.

density float

Material density in kg/m³.

specific_heat float

Specific heat capacity in J/kgK.

Source code in src/simses/thermal/container.py
@dataclass(frozen=True)
class ContainerLayer:
    """Physical properties of a single wall layer.

    Attributes:
        thickness:     Layer thickness in m.
        conductivity:  Thermal conductivity in W/mK.
        density:       Material density in kg/m³.
        specific_heat: Specific heat capacity in J/kgK.
    """

    thickness: float
    conductivity: float
    density: float
    specific_heat: float

HVAC

Hardware-side Protocol converting a thermal-power demand into the corresponding electrical draw. The shipped constant-COP implementation is a reasonable default; swap it for a detailed chiller model by implementing the same two-method interface.

simses.thermal.container.HvacModel

Bases: Protocol

Protocol for HVAC hardware models.

The sole responsibility is to convert a thermal power demand into the corresponding electrical consumption. The model is stateless — no results are stored internally; the caller (the thermal model) is responsible for recording them in its state.

Source code in src/simses/thermal/container.py
class HvacModel(Protocol):
    """Protocol for HVAC hardware models.

    The sole responsibility is to convert a thermal power demand into the
    corresponding electrical consumption.  The model is stateless — no results
    are stored internally; the caller (the thermal model) is responsible for
    recording them in its state.
    """

    def electrical_consumption(self, Q_thermal: float) -> float:
        """Return the electrical power required to deliver ``Q_thermal``.

        Args:
            Q_thermal: Thermal power in W
                (positive = heating, negative = cooling, 0 = idle).

        Returns:
            Electrical power draw in W (always ≥ 0).
        """
        ...

electrical_consumption(Q_thermal)

Return the electrical power required to deliver Q_thermal.

Parameters:

Name Type Description Default
Q_thermal float

Thermal power in W (positive = heating, negative = cooling, 0 = idle).

required

Returns:

Type Description
float

Electrical power draw in W (always ≥ 0).

Source code in src/simses/thermal/container.py
def electrical_consumption(self, Q_thermal: float) -> float:
    """Return the electrical power required to deliver ``Q_thermal``.

    Args:
        Q_thermal: Thermal power in W
            (positive = heating, negative = cooling, 0 = idle).

    Returns:
        Electrical power draw in W (always ≥ 0).
    """
    ...

simses.thermal.container.ConstantCopHvac

HVAC hardware model with fixed coefficients of performance.

Electrical consumption is proportional to the thermal demand:

heating (Q_thermal > 0): P_el = Q_thermal / cop_heating cooling (Q_thermal < 0): P_el = |Q_thermal| / cop_cooling

Parameters:

Name Type Description Default
cop_cooling float

COP for cooling mode (default 3.0).

3.0
cop_heating float

COP for heating mode (default 2.5).

2.5
Source code in src/simses/thermal/container.py
class ConstantCopHvac:
    """HVAC hardware model with fixed coefficients of performance.

    Electrical consumption is proportional to the thermal demand:

      heating (Q_thermal > 0): P_el = Q_thermal / cop_heating
      cooling (Q_thermal < 0): P_el = |Q_thermal| / cop_cooling

    Args:
        cop_cooling: COP for cooling mode (default 3.0).
        cop_heating: COP for heating mode (default 2.5).
    """

    def __init__(self, cop_cooling: float = 3.0, cop_heating: float = 2.5) -> None:
        self.cop_cooling = cop_cooling
        self.cop_heating = cop_heating

    def electrical_consumption(self, Q_thermal: float) -> float:
        """Return the electrical power required to deliver ``Q_thermal``."""
        if Q_thermal > 0:
            return Q_thermal / self.cop_heating
        if Q_thermal < 0:
            return -Q_thermal / self.cop_cooling
        return 0.0

electrical_consumption(Q_thermal)

Return the electrical power required to deliver Q_thermal.

Source code in src/simses/thermal/container.py
def electrical_consumption(self, Q_thermal: float) -> float:
    """Return the electrical power required to deliver ``Q_thermal``."""
    if Q_thermal > 0:
        return Q_thermal / self.cop_heating
    if Q_thermal < 0:
        return -Q_thermal / self.cop_cooling
    return 0.0

Thermal management strategies

The "brain" side of the HVAC: decides how much heating/cooling the container needs per step. Use ThermostatStrategy for simple hysteresis control, or ExternalThermalManagement as a pass-through when an external controller (MPC, optimisation, co-simulation) computes the demand upstream.

simses.thermal.container.ThermalManagementStrategy

Bases: Protocol

Protocol for thermostat control strategies.

Used by :class:ContainerThermalModel. control() returns the requested thermal power based on a reference temperature derived from the registered storage components.

Source code in src/simses/thermal/container.py
class ThermalManagementStrategy(Protocol):
    """Protocol for thermostat control strategies.

    Used by :class:`ContainerThermalModel`.  ``control()`` returns the
    requested thermal power based on a reference temperature derived from
    the registered storage components.
    """

    def control(self, T_ref: float, dt: float) -> float:
        """Advance the strategy and return the requested thermal power.

        Args:
            T_ref: Reference temperature in °C — the maximum temperature
                   across all registered storage components.
            dt:    Timestep in seconds.

        Returns:
            ``Q_thermal``: thermal power in W
                (positive = adds heat, negative = removes heat, 0 = idle).
        """
        ...

control(T_ref, dt)

Advance the strategy and return the requested thermal power.

Parameters:

Name Type Description Default
T_ref float

Reference temperature in °C — the maximum temperature across all registered storage components.

required
dt float

Timestep in seconds.

required

Returns:

Type Description
float

Q_thermal: thermal power in W (positive = adds heat, negative = removes heat, 0 = idle).

Source code in src/simses/thermal/container.py
def control(self, T_ref: float, dt: float) -> float:
    """Advance the strategy and return the requested thermal power.

    Args:
        T_ref: Reference temperature in °C — the maximum temperature
               across all registered storage components.
        dt:    Timestep in seconds.

    Returns:
        ``Q_thermal``: thermal power in W
            (positive = adds heat, negative = removes heat, 0 = idle).
    """
    ...

simses.thermal.container.ThermostatStrategy

Hysteresis thermostat control strategy.

Decides when to heat or cool based on setpoint and dead-band. The requested thermal power (±max_power) is passed to an :class:HvacModel to obtain the corresponding electrical consumption. No power values are stored in the strategy itself.

State machine transitions

IDLE → HEATING if T_air < T_setpoint - threshold IDLE → COOLING if T_air > T_setpoint + threshold HEATING → IDLE if T_air >= T_setpoint COOLING → IDLE if T_air <= T_setpoint

Parameters:

Name Type Description Default
T_setpoint float

Target internal air temperature in °C.

required
max_power float

Maximum thermal output requested from the HVAC unit in W.

required
threshold float

Half-width of the dead-band in K (default 5.0).

5.0
Source code in src/simses/thermal/container.py
class ThermostatStrategy:
    """Hysteresis thermostat control strategy.

    Decides when to heat or cool based on setpoint and dead-band.  The
    requested thermal power (±``max_power``) is passed to an :class:`HvacModel`
    to obtain the corresponding electrical consumption.  No power values are
    stored in the strategy itself.

    State machine transitions:
      IDLE    → HEATING  if T_air < T_setpoint - threshold
      IDLE    → COOLING  if T_air > T_setpoint + threshold
      HEATING → IDLE     if T_air >= T_setpoint
      COOLING → IDLE     if T_air <= T_setpoint

    Args:
        T_setpoint: Target internal air temperature in °C.
        max_power:  Maximum thermal output requested from the HVAC unit in W.
        threshold:  Half-width of the dead-band in K (default 5.0).
    """

    def __init__(
        self,
        T_setpoint: float,
        max_power: float,
        threshold: float = 5.0,
    ) -> None:
        self.T_setpoint = T_setpoint
        self.max_power = max_power
        self.threshold = threshold
        self._mode = ThermostatMode.IDLE

    @property
    def mode(self) -> ThermostatMode:
        """Current thermostat operating mode."""
        return self._mode

    def control(self, T_ref: float, dt: float) -> float:
        """Advance the state machine and return the requested thermal power.

        Args:
            T_ref: Reference temperature in °C (max battery temperature).
            dt:    Timestep in seconds (unused but required by protocol).

        Returns:
            ``Q_thermal``: thermal power in W (±max_power or 0.0).
        """
        T_sp = self.T_setpoint
        thresh = self.threshold

        if self._mode is ThermostatMode.IDLE:
            if T_ref < T_sp - thresh:
                self._mode = ThermostatMode.HEATING
            elif T_ref > T_sp + thresh:
                self._mode = ThermostatMode.COOLING
        elif self._mode is ThermostatMode.HEATING:
            if T_ref >= T_sp:
                self._mode = ThermostatMode.IDLE
        else:  # COOLING
            if T_ref <= T_sp:
                self._mode = ThermostatMode.IDLE

        if self._mode is ThermostatMode.HEATING:
            Q = self.max_power
        elif self._mode is ThermostatMode.COOLING:
            Q = -self.max_power
        else:
            Q = 0.0

        return Q

mode property

Current thermostat operating mode.

control(T_ref, dt)

Advance the state machine and return the requested thermal power.

Parameters:

Name Type Description Default
T_ref float

Reference temperature in °C (max battery temperature).

required
dt float

Timestep in seconds (unused but required by protocol).

required

Returns:

Type Description
float

Q_thermal: thermal power in W (±max_power or 0.0).

Source code in src/simses/thermal/container.py
def control(self, T_ref: float, dt: float) -> float:
    """Advance the state machine and return the requested thermal power.

    Args:
        T_ref: Reference temperature in °C (max battery temperature).
        dt:    Timestep in seconds (unused but required by protocol).

    Returns:
        ``Q_thermal``: thermal power in W (±max_power or 0.0).
    """
    T_sp = self.T_setpoint
    thresh = self.threshold

    if self._mode is ThermostatMode.IDLE:
        if T_ref < T_sp - thresh:
            self._mode = ThermostatMode.HEATING
        elif T_ref > T_sp + thresh:
            self._mode = ThermostatMode.COOLING
    elif self._mode is ThermostatMode.HEATING:
        if T_ref >= T_sp:
            self._mode = ThermostatMode.IDLE
    else:  # COOLING
        if T_ref <= T_sp:
            self._mode = ThermostatMode.IDLE

    if self._mode is ThermostatMode.HEATING:
        Q = self.max_power
    elif self._mode is ThermostatMode.COOLING:
        Q = -self.max_power
    else:
        Q = 0.0

    return Q

simses.thermal.container.ThermostatMode

Bases: Enum

Operating mode of a :class:ThermostatStrategy.

Source code in src/simses/thermal/container.py
class ThermostatMode(enum.Enum):
    """Operating mode of a :class:`ThermostatStrategy`."""

    IDLE = "idle"
    """Neither heating nor cooling; HVAC draws no power."""

    HEATING = "heating"
    """HVAC is adding heat to the internal air node."""

    COOLING = "cooling"
    """HVAC is removing heat from the internal air node."""

IDLE = 'idle' class-attribute instance-attribute

Neither heating nor cooling; HVAC draws no power.

HEATING = 'heating' class-attribute instance-attribute

HVAC is adding heat to the internal air node.

COOLING = 'cooling' class-attribute instance-attribute

HVAC is removing heat from the internal air node.

simses.thermal.container.ExternalThermalManagement

Pass-through thermal management strategy for external controllers.

Instead of computing HVAC power internally, this strategy returns a value set externally via the :attr:Q_hvac property. Use this when an external controller (e.g. MPC) decides the thermal power.

Example::

tms = ExternalThermalManagement()
container = ContainerThermalModel(..., tms=tms)

# In the simulation loop:
tms.Q_hvac = mpc_computed_power
container.step(dt=1.0)
Source code in src/simses/thermal/container.py
class ExternalThermalManagement:
    """Pass-through thermal management strategy for external controllers.

    Instead of computing HVAC power internally, this strategy returns a
    value set externally via the :attr:`Q_hvac` property.  Use this when
    an external controller (e.g. MPC) decides the thermal power.

    Example::

        tms = ExternalThermalManagement()
        container = ContainerThermalModel(..., tms=tms)

        # In the simulation loop:
        tms.Q_hvac = mpc_computed_power
        container.step(dt=1.0)
    """

    def __init__(self) -> None:
        self.Q_hvac: float = 0.0

    def control(self, T_ref: float, dt: float) -> float:  # noqa: ARG002
        """Return the externally-set thermal power."""
        return self.Q_hvac

control(T_ref, dt)

Return the externally-set thermal power.

Source code in src/simses/thermal/container.py
def control(self, T_ref: float, dt: float) -> float:  # noqa: ARG002
    """Return the externally-set thermal power."""
    return self.Q_hvac

Solar heat load

Vectorised pre-computation of absorbed solar power on a container's outer walls. Takes a GHI time series, solar-position + Reindl decomposition + per-face geometry, and returns a per-timestep power series to feed ContainerThermalModel.Q_solar.

simses.thermal.solar.SolarConfig dataclass

Solar and surface parameters for solar heat-load pre-computation.

Attributes:

Name Type Description
latitude float

Site latitude in degrees N (negative = Southern hemisphere).

longitude float

Site longitude in degrees E (negative = West).

azimuth float

Container orientation — compass bearing that the North face of the container points toward, in degrees clockwise from true North. 0 = North face points North (standard alignment), 90 = rotated 90° clockwise (North face now points East).

absorptivity float

Outer surface absorptivity coefficient (0–1). Typical painted steel ≈ 0.6. Default: 0.6.

albedo float

Ground reflectance (0–1). Default: 0.2.

Source code in src/simses/thermal/solar.py
@dataclass(frozen=True)
class SolarConfig:
    """Solar and surface parameters for solar heat-load pre-computation.

    Attributes:
        latitude:     Site latitude in degrees N (negative = Southern hemisphere).
        longitude:    Site longitude in degrees E (negative = West).
        azimuth:      Container orientation — compass bearing that the **North
                      face** of the container points toward, in degrees clockwise
                      from true North.  0 = North face points North (standard
                      alignment), 90 = rotated 90° clockwise (North face now
                      points East).
        absorptivity: Outer surface absorptivity coefficient (0–1).  Typical
                      painted steel ≈ 0.6.  Default: 0.6.
        albedo:       Ground reflectance (0–1).  Default: 0.2.
    """

    latitude: float
    longitude: float
    azimuth: float
    absorptivity: float = 0.6
    albedo: float = 0.2

simses.thermal.solar.solar_heat_load(ghi, container, config)

Pre-compute absorbed solar heat load on a container for a full timeseries.

The calculation is vectorised: a single call processes an entire year (or any length) of GHI data at once. The returned series can be indexed during the simulation loop and assigned to :attr:~simses.thermal.ContainerThermalModel.Q_solar.

Parameters:

Name Type Description Default
ghi Series

Global horizontal irradiance [W/m²]. Must carry a timezone-aware :class:pandas.DatetimeIndex. Negative values are clamped to zero.

required
container ContainerProperties

Container geometry — length, width, and height (internal dimensions) are used to derive face areas.

required
config SolarConfig

Site location, container orientation, and surface properties.

required

Returns:

Type Description
Series

Absorbed solar power [W] with the same index as ghi. All values are

Series

≥ 0; night-time and below-horizon rows are zero.

Raises:

Type Description
TypeError

If ghi is not a :class:pandas.Series or does not have a timezone-aware DatetimeIndex.

Example::

import pandas as pd
from simses.thermal import ContainerLayer, ContainerProperties
from simses.thermal.solar import SolarConfig, solar_heat_load

df = pd.read_csv("munich_ghi_2024.csv", index_col=0, parse_dates=True)
df.index = df.index.tz_localize("Europe/Berlin")
ghi = df["ghi_wm2"]  # select one column → pd.Series

props = ContainerProperties(
    length=6.06, width=2.44, height=2.59,
    h_inner=5.0, h_outer=15.0,
    inner=ContainerLayer(0.001, 200, 2700, 900),
    mid=ContainerLayer(0.06, 0.04, 30, 1000),
    outer=ContainerLayer(0.002, 50, 7800, 500),
)
config = SolarConfig(latitude=48.14, longitude=11.58, azimuth=0.0)
q_solar = solar_heat_load(ghi.squeeze(), props, config)
Source code in src/simses/thermal/solar.py
def solar_heat_load(
    ghi: pd.Series,
    container: ContainerProperties,
    config: SolarConfig,
) -> pd.Series:
    """Pre-compute absorbed solar heat load on a container for a full timeseries.

    The calculation is vectorised: a single call processes an entire year (or
    any length) of GHI data at once.  The returned series can be indexed during
    the simulation loop and assigned to
    :attr:`~simses.thermal.ContainerThermalModel.Q_solar`.

    Args:
        ghi:       Global horizontal irradiance [W/m²].  Must carry a
                   timezone-aware :class:`pandas.DatetimeIndex`.  Negative
                   values are clamped to zero.
        container: Container geometry — ``length``, ``width``, and ``height``
                   (internal dimensions) are used to derive face areas.
        config:    Site location, container orientation, and surface properties.

    Returns:
        Absorbed solar power [W] with the same index as *ghi*.  All values are
        ≥ 0; night-time and below-horizon rows are zero.

    Raises:
        TypeError: If *ghi* is not a :class:`pandas.Series` or does not have a
            timezone-aware DatetimeIndex.

    Example::

        import pandas as pd
        from simses.thermal import ContainerLayer, ContainerProperties
        from simses.thermal.solar import SolarConfig, solar_heat_load

        df = pd.read_csv("munich_ghi_2024.csv", index_col=0, parse_dates=True)
        df.index = df.index.tz_localize("Europe/Berlin")
        ghi = df["ghi_wm2"]  # select one column → pd.Series

        props = ContainerProperties(
            length=6.06, width=2.44, height=2.59,
            h_inner=5.0, h_outer=15.0,
            inner=ContainerLayer(0.001, 200, 2700, 900),
            mid=ContainerLayer(0.06, 0.04, 30, 1000),
            outer=ContainerLayer(0.002, 50, 7800, 500),
        )
        config = SolarConfig(latitude=48.14, longitude=11.58, azimuth=0.0)
        q_solar = solar_heat_load(ghi.squeeze(), props, config)
    """
    if not isinstance(ghi, pd.Series):
        raise TypeError(f"ghi must be a pd.Series, got {type(ghi).__name__}")
    if not hasattr(ghi.index, "tz") or ghi.index.tz is None:
        raise TypeError("ghi must have a timezone-aware DatetimeIndex")

    ghi_vals = np.maximum(0.0, ghi.to_numpy(dtype=float))
    idx = ghi.index

    lat_rad = np.deg2rad(config.latitude)
    lon = config.longitude

    # --- Orbital parameters (vectorised over the full index) ---
    doy = idx.day_of_year.to_numpy(dtype=float)
    year = idx.year.to_numpy()
    is_leap = ((year % 4 == 0) & (year % 100 != 0)) | (year % 400 == 0)
    days_in_year = np.where(is_leap, 366.0, 365.0)

    oi = 360.0 * doy / days_in_year  # orbital inclination, degrees

    # Equation of time (seconds) — Spencer (1971) Fourier approximation
    eot = 60.0 * (
        0.0066
        + 7.3525 * np.cos(np.deg2rad(oi + 85.9))
        + 9.9359 * np.cos(np.deg2rad(2.0 * oi + 108.9))
        + 0.3387 * np.cos(np.deg2rad(3.0 * oi + 105.2))
    )

    # Solar declination (degrees)
    delta_rad = np.deg2rad(
        0.3948
        - 23.2559 * np.cos(np.deg2rad(oi + 9.1))
        - 0.3915 * np.cos(np.deg2rad(2.0 * oi + 5.4))
        - 0.1764 * np.cos(np.deg2rad(3.0 * oi + 26.0))
    )

    # --- Apparent solar time → hour angle ---
    # float seconds since Unix epoch — portable across pandas datetime64 resolutions
    _epoch = pd.Timestamp("1970-01-01", tz="UTC")
    unix_s = (idx.tz_convert("UTC") - _epoch).total_seconds().to_numpy(dtype=float)
    # Apparent solar time (s): UTC + longitude offset + equation of time
    solar_sec = (unix_s + lon * 240.0 + eot) % 86400.0
    # Hour angle: positive before noon (sun east of meridian)
    h_deg = (43200.0 - solar_sec) * 15.0 / 3600.0
    h_rad = np.deg2rad(h_deg)

    # --- Sun elevation ---
    sin_alpha = np.clip(
        np.cos(h_rad) * np.cos(lat_rad) * np.cos(delta_rad) + np.sin(lat_rad) * np.sin(delta_rad),
        -1.0,
        1.0,
    )
    cos_alpha = np.sqrt(np.maximum(0.0, 1.0 - sin_alpha**2))
    above = sin_alpha > 0.0  # sun above horizon

    # --- Solar azimuth (degrees, from North clockwise) ---
    # q1 = arccos((sin_alpha * sin_lat − sin_delta) / (cos_alpha * cos_lat))
    with np.errstate(divide="ignore", invalid="ignore"):
        acos_arg = np.where(
            cos_alpha > 1e-9,
            np.clip(
                (sin_alpha * np.sin(lat_rad) - np.sin(delta_rad)) / (cos_alpha * np.cos(lat_rad)),
                -1.0,
                1.0,
            ),
            0.0,
        )
    q1 = np.rad2deg(np.arccos(acos_arg))
    # Before noon h_deg > 0: sun east of meridian → azimuth < 180°
    # After noon h_deg ≤ 0: sun west of meridian → azimuth > 180°
    azimuth_sun = np.where(h_deg > 0.0, 180.0 - q1, 180.0 + q1)

    # --- GHI decomposition (Reindl clearness-index model) ---
    etr = (1.0 + 0.03344 * np.cos(np.deg2rad(doy * 0.9856 - 2.72))) * 1367.0
    etr_h = np.where(above, etr * sin_alpha, 1.0)  # avoid /0 outside daylight

    with np.errstate(divide="ignore", invalid="ignore"):
        kt = np.where(above, ghi_vals / etr_h, 0.0)

    sin_a = np.where(above, sin_alpha, 0.0)
    diffuse = np.where(
        ~above | (kt <= 0.0),
        0.0,
        np.where(
            kt <= 0.3,
            ghi_vals * (1.020 - 0.254 * kt + 0.0123 * sin_a),
            np.where(
                kt < 0.78,
                ghi_vals * (1.400 - 1.749 * kt + 0.177 * sin_a),
                np.where(kt < 1.0, ghi_vals * (0.486 - 0.182 * sin_a), 0.0),
            ),
        ),
    )
    diffuse = np.clip(diffuse, 0.0, None)
    # When kt > 1 (GHI exceeds extraterrestrial — physically impossible, data artefact),
    # the legacy code sets direct = 0 as well (mirroring get_direct_radiation_horizontal).
    direct_h = np.where(above & (diffuse > 0.0), np.maximum(0.0, ghi_vals - diffuse), 0.0)

    # DNI = direct_horizontal / sin(elevation)
    with np.errstate(divide="ignore", invalid="ignore"):
        dni = np.where(above & (sin_alpha > 1e-4), direct_h / sin_alpha, 0.0)

    # --- Per-face irradiance ---
    # Ground-reflected component on vertical surfaces (isotropic albedo)
    reflected = np.where(above, ghi_vals * config.albedo * 0.5, 0.0)
    # Isotropic sky diffuse on vertical surfaces (half-sky view factor = 0.5)
    diffuse_vert = np.where(above, diffuse * 0.5, 0.0)

    # Face areas from container geometry
    a_ns = container.length * container.height  # North and South faces
    a_ew = container.width * container.height  # East and West faces
    a_roof = container.length * container.width

    # Outward-normal azimuths of each face (degrees from North, clockwise)
    az_n = config.azimuth
    az_s = config.azimuth + 180.0
    az_e = config.azimuth + 90.0
    az_w = config.azimuth + 270.0

    def _face_power(area: float, face_az: float) -> np.ndarray:
        """Absorbed power [W] on one vertical face."""
        # cos of angle of incidence = cos(elevation) * cos(sun_az - face_normal_az)
        cos_aoi = np.where(
            above,
            cos_alpha * np.cos(np.deg2rad(azimuth_sun - face_az)),
            0.0,
        )
        direct = np.where(cos_aoi > 0.0, dni * cos_aoi, 0.0)
        return area * (direct + diffuse_vert + reflected)

    q_faces = _face_power(a_ns, az_n) + _face_power(a_ns, az_s) + _face_power(a_ew, az_e) + _face_power(a_ew, az_w)

    # Roof: full sky diffuse + direct horizontal (no reflected — faces upward)
    q_roof = np.where(above, a_roof * (direct_h + diffuse), 0.0)

    q_solar = config.absorptivity * (q_faces + q_roof)
    return pd.Series(np.maximum(0.0, q_solar), index=idx, name="Q_solar_W")