Source code for ecoengine.objects.components.storage.MixedStorageTank
from __future__ import annotations
from .StorageTank import StorageTank
from ecoengine.constants.constants import _RHO_CP
[docs]
class MixedStorageTank(StorageTank):
"""
Fully-mixed (uniform temperature) storage tank model.
Unlike the stratified StratifiedTank, the entire volume is assumed to be
at a single uniform temperature at all times. This matches the behaviour
of a temperature-maintenance (TM) tank in a parallel-loop system, where
the tank is small and continuously stirred by the recirc loop.
Interface contract
------------------
* ``get_temperature_at_fraction()`` returns the same value for any height.
* ``get_usable_volume_supplyT_gal()`` returns the full tank volume when
the temperature is at or above supply, otherwise 0.
* ``heat()`` raises the tank temperature proportionally to heat added.
* ``draw()`` cools the tank proportionally to cold make-up water added.
* ``add_recirc_return()`` applies a net temperature drop equal to the
recirc heat loss for the timestep.
"""
[docs]
def __init__(self, total_volume_gal: float) -> None:
"""
Parameters
----------
total_volume_gal : float
Total physical tank volume [gallons].
"""
self.total_volume_gal = total_volume_gal
self._temperature_f: float = 0.0 # set by initialize()
# ------------------------------------------------------------------
# Initialization
# ------------------------------------------------------------------
[docs]
def initialize(
self,
storage_temp_f: float,
cold_temp_f: float,
percent_useable: float,
) -> None:
"""
Set the uniform tank temperature before a simulation begins.
For a fully-mixed tank, ``percent_useable`` does not have a spatial
meaning; the tank is simply initialized at ``storage_temp_f``.
Parameters
----------
storage_temp_f : float
Initial tank temperature [°F].
cold_temp_f : float
Cold water temperature — stored for energy-balance calculations.
percent_useable : float
Ignored for the mixed model (all volume is always at one temp).
"""
self._temperature_f = storage_temp_f
self._cold_temp_f = cold_temp_f
# ------------------------------------------------------------------
# Temperature queries
# ------------------------------------------------------------------
[docs]
def get_temperature_at_fraction(self, fract: float) -> float:
"""Return the uniform tank temperature (identical at all heights)."""
return self._temperature_f
[docs]
def get_usable_volume_supplyT_gal(self, supply_temp_f: float) -> float:
"""Return full tank volume if at or above supply temperature, else 0."""
return self.total_volume_gal if self._temperature_f >= supply_temp_f else 0.0
# ------------------------------------------------------------------
# Simulation operations
# ------------------------------------------------------------------
[docs]
def draw(
self,
volume_supplyT_gal: float,
cold_temp_f: float,
supply_temp_f: float,
outlet_temp_f: float,
) -> None:
"""
Remove DHW demand from the tank and replace with cold make-up water.
Uses a simple energy-balance mix: physically removes hot water at the
current tank temperature and replaces it with cold water, cooling the
tank uniformly.
The physical volume removed is adjusted for the temperature difference
between storage and supply (the same conversion used by StratifiedTank).
Parameters
----------
volume_supplyT_gal : float
DHW demand in supply-temperature gallons [gal].
cold_temp_f : float
Incoming cold water temperature [°F].
supply_temp_f : float
System supply (delivery) temperature [°F].
outlet_temp_f : float
Current hot water delivery temperature — used for physical-volume
conversion (same formula as StratifiedTank).
"""
self._cold_temp_f = cold_temp_f
if self._temperature_f <= cold_temp_f or volume_supplyT_gal <= 0.0:
return
# Physical gallons removed from tank
physical_vol_gal = (
volume_supplyT_gal
* (supply_temp_f - cold_temp_f)
/ max(self._temperature_f - cold_temp_f, 1e-6)
)
# Energy balance: mix physical_vol of cold water into the remaining hot tank
remaining_gal = self.total_volume_gal - physical_vol_gal
if remaining_gal <= 0.0:
self._temperature_f = cold_temp_f
return
total_energy_btu = remaining_gal * _RHO_CP * self._temperature_f + physical_vol_gal * _RHO_CP * cold_temp_f
self._temperature_f = total_energy_btu / (self.total_volume_gal * _RHO_CP)
[docs]
def heat(
self,
kbtuh: float,
duration_min: float,
outlet_temp_f: float,
) -> None:
"""
Apply heat from active water heaters for one timestep.
Raises the uniform tank temperature proportionally to heat added,
capped at ``outlet_temp_f``.
Parameters
----------
kbtuh : float
Total heating rate from all active heaters [kBTU/hr].
duration_min : float
Length of the timestep [minutes].
outlet_temp_f : float
Maximum temperature the heater can deliver [°F].
"""
if kbtuh <= 0.0:
return
heat_kbtu = kbtuh * duration_min / 60.0 # kBTU
delta_t = heat_kbtu * 1000.0 / (self.total_volume_gal * _RHO_CP)
self._temperature_f = self._temperature_f + delta_t
# self._temperature_f = min(self._temperature_f + delta_t, outlet_temp_f)
[docs]
def add_recirc_return(
self,
flow_gpm: float,
return_temp_f: float,
duration_min: float,
) -> None:
"""
Apply the net temperature drop from recirc loop losses.
The recirc loop continuously draws hot water from the tank at the
current temperature and returns it cooled to ``return_temp_f``. For a
fully-mixed tank, the net effect is a uniform temperature drop computed
from the energy removed:
ΔT = flow_gpm × duration_min × (return_temp_f − tank_temp) / total_volume_gal
(ΔT is negative when return_temp_f < tank_temp, i.e., heat is lost.)
Parameters
----------
flow_gpm : float
Recirculation loop flow rate [GPM].
return_temp_f : float
Temperature of water returning from the recirc loop [°F].
duration_min : float
Length of the timestep [minutes].
"""
vol_circulated_gal = flow_gpm * duration_min
delta_t = (
vol_circulated_gal
* (return_temp_f - self._temperature_f)
/ self.total_volume_gal
)
self._temperature_f += delta_t # always negative when return < tank temp
[docs]
def apply_fixed_heat_loss_kbtuh(self, kbtuh: float, duration_min: float) -> None:
"""
Apply a fixed heat loss rate to the tank regardless of current temperature.
Used by the swing tank simulation to apply recirculation heat loss at the
same constant rate used during sizing (``RecircSystem.get_recirc_loss_kbtuh``),
so that sizing and runtime physics remain consistent.
Parameters
----------
kbtuh : float
Heat loss rate [kBTU/hr].
duration_min : float
Timestep duration [minutes].
"""
if kbtuh <= 0.0 or duration_min <= 0.0:
return
heat_kbtu = kbtuh * duration_min / 60.0
delta_t = heat_kbtu * 1000.0 / (self.total_volume_gal * _RHO_CP)
self._temperature_f -= delta_t
[docs]
def mix_primary_inflow(self, gal: float, primary_temp_f: float) -> None:
"""
Model primary hot water flowing into the swing tank while the same volume
exits to the building.
The total tank volume is unchanged; the incoming primary water at
``primary_temp_f`` displaces an equal volume of the existing tank contents,
raising or lowering the uniform temperature via an energy balance:
T_new = (gal × primary_temp + (V - gal) × T_curr) / V
Parameters
----------
gal : float
Volume of primary hot water flowing into the tank this timestep [gal].
primary_temp_f : float
Temperature of water entering from the primary storage [°F].
"""
if gal <= 0.0:
return
vol_remaining = self.total_volume_gal - gal
if vol_remaining <= 0.0:
self._temperature_f = primary_temp_f
return
total_energy = (
gal * _RHO_CP * primary_temp_f
+ vol_remaining * _RHO_CP * self._temperature_f
)
self._temperature_f = total_energy / (self.total_volume_gal * _RHO_CP)
[docs]
def get_average_draw_temp_f(self, draw_gal: float) -> float:
"""Return the uniform tank temperature (fully mixed — no stratification)."""
return self._temperature_f
[docs]
def draw_physical_gal(
self,
gal: float,
inlet_temp_f: float,
supply_temp_f: float | None = None,
) -> None:
"""
Remove ``gal`` physical gallons and replace with cold make-up water.
For a fully-mixed tank this is an energy-balance mix: the removed hot
water is replaced by ``gal`` gallons of cold water at ``inlet_temp_f``.
``supply_temp_f`` is accepted for interface compatibility but unused —
a fully-mixed tank has no thermocline to floor.
"""
if gal <= 0.0:
return
remaining_gal = self.total_volume_gal - gal
if remaining_gal <= 0.0:
self._temperature_f = inlet_temp_f
return
total_energy = (
remaining_gal * _RHO_CP * self._temperature_f
+ gal * _RHO_CP * inlet_temp_f
)
self._temperature_f = total_energy / (self.total_volume_gal * _RHO_CP)