From 4210de59934e6b3aa4704b538f0f158c00e01dfb Mon Sep 17 00:00:00 2001 From: "daniel.feismann" Date: Tue, 22 Apr 2025 19:00:16 +0200 Subject: [PATCH 01/79] Introducing energy demand for warm water heating --- docs/readthedocs/models.md | 1 + docs/readthedocs/models/cts_model.md | 2 +- docs/readthedocs/models/dhws_model.md | 56 + .../readthedocs/models/thermal_house_model.md | 3 +- .../edu/ie3/simona/event/ResultEvent.scala | 21 + .../simona/model/participant/HpModel.scala | 47 +- .../thermal/CylindricalThermalStorage.scala | 89 +- .../thermal/DomesticHotWaterStorage.scala | 203 ++ .../simona/model/thermal/ThermalGrid.scala | 620 +++-- .../simona/model/thermal/ThermalHouse.scala | 381 ++- .../thermal/ThermalStorageCalculations.scala | 61 + .../model/thermal/ThermalThreshold.scala | 1 + .../scala/quantities/ThermalConductance.scala | 14 + .../edu/ie3/simona/agent/em/EmAgentIT.scala | 123 + .../ie3/simona/agent/grid/ThermalGridIT.scala | 2074 ++++++++++++++--- .../model/participant/HpModelSpec.scala | 246 +- .../model/thermal/ThermalGridSpec.scala | 4 +- .../model/thermal/ThermalGridTestData.scala | 46 +- .../ThermalGridWithHouseAndStorageSpec.scala | 450 +++- .../ThermalGridWithHouseOnlySpec.scala | 199 +- .../ThermalGridWithStorageOnlySpec.scala | 59 +- .../model/thermal/ThermalHouseSpec.scala | 352 ++- .../thermal/ThermalStorageTestData.scala | 26 +- .../test/common/input/EmInputTestData.scala | 2 +- .../test/common/input/HpInputTestData.scala | 40 +- .../input/ThermalGridITInputTestData.scala | 53 + 26 files changed, 4316 insertions(+), 857 deletions(-) create mode 100644 docs/readthedocs/models/dhws_model.md create mode 100644 src/main/scala/edu/ie3/simona/model/thermal/DomesticHotWaterStorage.scala create mode 100644 src/main/scala/edu/ie3/simona/model/thermal/ThermalStorageCalculations.scala create mode 100644 src/test/scala/edu/ie3/simona/test/common/input/ThermalGridITInputTestData.scala diff --git a/docs/readthedocs/models.md b/docs/readthedocs/models.md index 1218e2a66c..3443db7bf7 100644 --- a/docs/readthedocs/models.md +++ b/docs/readthedocs/models.md @@ -29,6 +29,7 @@ maxdepth: 1 models/bm_model models/chp_model models/cts_model +models/dhws_model models/evcs_model models/hp_model models/load_model diff --git a/docs/readthedocs/models/cts_model.md b/docs/readthedocs/models/cts_model.md index e03fa06563..b486151596 100644 --- a/docs/readthedocs/models/cts_model.md +++ b/docs/readthedocs/models/cts_model.md @@ -3,7 +3,7 @@ This page documents the functionality of the cylindrical thermal storage available in SIMONA. ## Behaviour -This storage model operates on volumes, although the functions it provides for other models all operate with energy. Internally the storage model converts energy to volume and vice versa with formulas specified below. Furthermore, it is assumed that the storage medium is water. Also, the model holds a variable for the current storage level. +This storage model operates on volumes, although the functions it provides for other models all operate with energy. Internally the storage model converts energy to volume and vice versa with formulas specified below. Furthermore, it is assumed that the storage medium is water. Also, the model holds a variable for the current storage level. At initialisation the storage will be empty. ## Attributes, Units and Remarks diff --git a/docs/readthedocs/models/dhws_model.md b/docs/readthedocs/models/dhws_model.md new file mode 100644 index 0000000000..a7225be0b9 --- /dev/null +++ b/docs/readthedocs/models/dhws_model.md @@ -0,0 +1,56 @@ +(dhws_model)= +# Domestic Hot Water Storage +This page documents the functionality of the domestic hot water storage available in SIMONA. + +## Behaviour +This storage model operates on volumes, although the functions it provides for other models all operate with energy. Internally the storage model converts energy to volume and vice versa with formulas specified below. Furthermore it is assumed that the storage medium is water. Also the model holds a variable for the current storage level. At initialisation the storage will be full. Domestic hot water storage will be refilled when the state of charge will be below 20 % or when the required water demand can not be covered by the stored energy in the storage itself. + +## Attributes, Units and Remarks + +Please refer to {doc}`PowerSystemDataModel - CTS Model ` for Attributes and Units used in this Model. + +## Calculations +### Maximal storage capacity +As the name suggests this storage has a cylindric form, hence its maximum storage capacity $V_{st, max}$ can be given by: + +$$ +V_{st, max} = \pi \cdot r^2 \cdot h +$$ + +### Conversions of energy and volume + +In this model the change of stored energy (heat) is represented by the change of the volume of the storage medium. This relation between stored heat and volume change is given by: + +$$ +V = (\frac{W}{c \cdot (tE - tA)}) +$$ + +with +W = energy to be converted to volume +V = resulting volume +tE = return temperature +tA = inlet temperature +c = $1,15 \frac{kWh}{m^3 \cdot K}$ + +The original equation is given by: + +$$ +\Delta V_{st} = (\frac{\dot{Q_{th}} \cdot \Delta t}{c \cdot (tA - tE)}) +$$ + +Reference: + +* {cite:cts}`Quaschning.2013` + + +That is the mathematical description of loading and unloading processes concerning the buffer storage. Whenever heat is stored within the storage or removed from the storage this equation is used. This includes the case that the whole heat demand is satisfied by the storage. + +The same relationship is used to determine the quantity of heat which is stored in the storage by converting the equation to: + +$$ +W = V \cdot c \cdot (tE - tA) +$$ + +## Store/Take energy + +This calculation is performed as follows: An amount of energy is specified for storing or taking. The model then changes the storage level and returns if it exceeded the maximum (when storing energy) or the minimum (when taking energy). diff --git a/docs/readthedocs/models/thermal_house_model.md b/docs/readthedocs/models/thermal_house_model.md index a8d2ba22d7..fa7b0dca18 100644 --- a/docs/readthedocs/models/thermal_house_model.md +++ b/docs/readthedocs/models/thermal_house_model.md @@ -7,8 +7,7 @@ This page documents the functionality of the thermal house available in SIMONA. ## Behaviour -This house model represents the thermal behaviour of a building. It represents a simple shoebox with thermal capacity and transmission losses. -The house can optionally be equipped with a {ref}`cts_model` as thermal storage. Both are connected by the {ref}`thermal_grid_model`. +This house model represents the thermal behaviour of a building. It represents a simple shoebox with thermal capacity and transmission losses for its heating demand. As well the hot water demand (tap water) based on the number of inhabitants and the housing type on hourly basis is considered. The house can optionally be equipped with a {ref}`cts_model` as thermal storage. Both are connected by the {ref}`thermal_grid_model`. The thermal house provides two different energy demands. The required demand indicates that the inner temperature of the house is below the lower temperature boundary and thus, requires mandatory heating. An additional demand indicates the amount of energy necessary to reach the target temperature. Additional demand not necessarily requires to be covered but could, e.g. for flexibility purposes. diff --git a/src/main/scala/edu/ie3/simona/event/ResultEvent.scala b/src/main/scala/edu/ie3/simona/event/ResultEvent.scala index f5bfd98dd6..4e72749d21 100644 --- a/src/main/scala/edu/ie3/simona/event/ResultEvent.scala +++ b/src/main/scala/edu/ie3/simona/event/ResultEvent.scala @@ -18,6 +18,7 @@ import edu.ie3.datamodel.models.result.system.{ } import edu.ie3.datamodel.models.result.thermal.{ CylindricalStorageResult, + DomesticHotWaterStorageResult, ThermalHouseResult, ThermalUnitResult, } @@ -131,6 +132,26 @@ object ResultEvent { } } + object DomesticHotWaterStorageResult { + def unapply(result: DomesticHotWaterStorageResult): Option[ + ( + ZonedDateTime, + UUID, + ComparableQuantity[Power], + ComparableQuantity[Energy], + ) + ] = { + Option(result).map { result => + ( + result.getTime, + result.getInputModel, + result.getqDot, + result.getEnergy, + ) + } + } + } + /** Event that holds all grid calculation results of a power flow calculation. * The usage of a type is necessary here, to avoid passing in other instances * of [[edu.ie3.datamodel.models.result.ResultEntity]] except of the wanted diff --git a/src/main/scala/edu/ie3/simona/model/participant/HpModel.scala b/src/main/scala/edu/ie3/simona/model/participant/HpModel.scala index 5fe713c116..d973b10a0f 100644 --- a/src/main/scala/edu/ie3/simona/model/participant/HpModel.scala +++ b/src/main/scala/edu/ie3/simona/model/participant/HpModel.scala @@ -69,10 +69,25 @@ class HpModel private ( operatingPoint, ) - val thermalDemands = thermalGrid.determineEnergyDemand(thermalGridState) + val hoursWaterDemandToDetermine = thermalGrid.house match { + case Some(house) => + house.checkIfNeedToDetermineDomesticHotWaterDemand( + tick, + simulationTime, + lastState, + ) + case None => None + } + + val thermalDemands = + thermalGrid.determineEnergyDemand( + thermalGridState, + hoursWaterDemandToDetermine, + ) lastState.copy( tick = tick, + simulationTime = simulationTime, thermalGridState = thermalGridState, lastHpOperatingPoint = operatingPoint, thermalDemands = thermalDemands, @@ -257,19 +272,24 @@ class HpModel private ( val demandHouse = thermalDemands.houseDemand val demandThermalStorage = thermalDemands.heatStorageDemand + val demandDomesticHotWaterStorage = + thermalDemands.domesticHotWaterStorageDemand val noThermalStorageOrEmpty = thermalGridState.isThermalStorageEmpty val turnHpOn = (demandHouse.hasRequiredDemand && noThermalStorageOrEmpty) || (demandHouse.hasPossibleDemand && wasRunningLastPeriod || demandThermalStorage.hasRequiredDemand || - (demandThermalStorage.hasPossibleDemand && wasRunningLastPeriod)) + (demandThermalStorage.hasPossibleDemand && wasRunningLastPeriod)) || + demandDomesticHotWaterStorage.hasRequiredDemand // || + // (demandDomesticHotWaterStorage.hasPossibleDemand && wasRunningLastPeriod) FIXME val canOperate = demandHouse.hasRequiredDemand || demandHouse.hasPossibleDemand || - demandThermalStorage.hasRequiredDemand || demandThermalStorage.hasPossibleDemand + demandThermalStorage.hasRequiredDemand || demandThermalStorage.hasPossibleDemand || + demandDomesticHotWaterStorage.hasRequiredDemand // || demandDomesticHotWaterStorage.hasPossibleDemand FIXME val canBeOutOfOperation = - !(demandHouse.hasRequiredDemand && noThermalStorageOrEmpty) + !(demandHouse.hasRequiredDemand && noThermalStorageOrEmpty) && !demandDomesticHotWaterStorage.hasRequiredDemand ( turnHpOn, @@ -341,7 +361,11 @@ object HpModel { * The thermal power output of the heat pump. * @param qDotHouse * The thermal power input of the - * [[edu.ie3.simona.model.thermal.ThermalHouse]]. + * [[edu.ie3.simona.model.thermal.ThermalHouse]] used for space heating. + * @param qDotDomesticHotWaterStorage + * The thermal power input of the + * [[edu.ie3.simona.model.thermal.ThermalHouse]] used for domestic hot + * water / tap water. * @param qDotHeatStorage * The thermal power input of the * [[edu.ie3.simona.model.thermal.ThermalStorage]]. @@ -350,16 +374,19 @@ object HpModel { qDotHp: Power, qDotHouse: Power, qDotHeatStorage: Power, + qDotDomesticHotWaterStorage: Power, ) object ThermalGridOperatingPoint { def zero: ThermalGridOperatingPoint = - ThermalGridOperatingPoint(zeroKW, zeroKW, zeroKW) + ThermalGridOperatingPoint(zeroKW, zeroKW, zeroKW, zeroKW) } /** Holds all relevant data for a hp model calculation. * * @param tick * The current tick. + * @param simulationTime + * The current simulation time * @param thermalGridState * The applicable state of the [[ThermalGrid]]. * @param lastHpOperatingPoint @@ -370,6 +397,7 @@ object HpModel { */ final case class HpState( override val tick: Long, + simulationTime: ZonedDateTime, thermalGridState: ThermalGridState, lastHpOperatingPoint: HpOperatingPoint, thermalDemands: ThermalDemandWrapper, @@ -389,10 +417,15 @@ object HpModel { ): HpState = { val therGrid = ThermalGrid(thermalGrid) val initialState = ThermalGrid.startingState(therGrid, zeroCelsius) - val thermalDemand = therGrid.determineEnergyDemand(initialState) + val thermalDemand = + therGrid.determineEnergyDemand( + initialState, + Some(Seq(simulationTime.getHour)), + ) HpState( tick, + simulationTime, initialState, HpOperatingPoint.zero, thermalDemand, diff --git a/src/main/scala/edu/ie3/simona/model/thermal/CylindricalThermalStorage.scala b/src/main/scala/edu/ie3/simona/model/thermal/CylindricalThermalStorage.scala index 11a7a52021..6f728c609d 100644 --- a/src/main/scala/edu/ie3/simona/model/thermal/CylindricalThermalStorage.scala +++ b/src/main/scala/edu/ie3/simona/model/thermal/CylindricalThermalStorage.scala @@ -19,16 +19,12 @@ import edu.ie3.simona.model.thermal.ThermalStorage.ThermalStorageThreshold.{ } import edu.ie3.util.quantities.PowerSystemUnits import edu.ie3.util.scala.quantities.DefaultQuantities._ -import edu.ie3.util.scala.quantities.SquantsUtils.RichEnergy -import edu.ie3.util.scala.quantities.{ - KilowattHoursPerKelvinCubicMeters, - SpecificHeatCapacity, -} +import edu.ie3.util.scala.quantities.KilowattHoursPerKelvinCubicMeters import squants.energy.Kilowatts -import squants.space.{CubicMeters, Volume} +import squants.space.CubicMeters import squants.thermal.Celsius import squants.time.Seconds -import squants.{Energy, Power, Temperature} +import squants.{Energy, Power} import tech.units.indriya.unit.Units import java.util.UUID @@ -71,7 +67,8 @@ final case class CylindricalThermalStorage( bus, maxEnergyThreshold, pThermalMax, - ) { + ) + with ThermalStorageCalculations { /** Updates the given last state. Based on the then set thermal influx, the * current state is calculated. Positive values of influx are consider to @@ -147,7 +144,7 @@ final case class CylindricalThermalStorage( ) } -object CylindricalThermalStorage { +object CylindricalThermalStorage extends ThermalStorageCalculations { /** Function to construct a new [[CylindricalThermalStorage]] based on a * provided [[CylindricalStorageInput]] @@ -165,21 +162,19 @@ object CylindricalThermalStorage { input: CylindricalStorageInput, initialStoredEnergy: Energy = zeroKWh, ): CylindricalThermalStorage = { - - val maxEnergyThreshold: Energy = - CylindricalThermalStorage.volumeToEnergy( - CubicMeters( - input.getStorageVolumeLvl.to(Units.CUBIC_METRE).getValue.doubleValue - ), - KilowattHoursPerKelvinCubicMeters( - input.getC - .to(PowerSystemUnits.KILOWATTHOUR_PER_KELVIN_TIMES_CUBICMETRE) - .getValue - .doubleValue - ), - Celsius(input.getInletTemp.to(Units.CELSIUS).getValue.doubleValue()), - Celsius(input.getReturnTemp.to(Units.CELSIUS).getValue.doubleValue()), - ) + val maxEnergyThreshold = volumeToEnergy( + CubicMeters( + input.getStorageVolumeLvl.to(Units.CUBIC_METRE).getValue.doubleValue + ), + KilowattHoursPerKelvinCubicMeters( + input.getC + .to(PowerSystemUnits.KILOWATTHOUR_PER_KELVIN_TIMES_CUBICMETRE) + .getValue + .doubleValue + ), + Celsius(input.getInletTemp.to(Units.CELSIUS).getValue.doubleValue), + Celsius(input.getReturnTemp.to(Units.CELSIUS).getValue.doubleValue), + ) val pThermalMax = Kilowatts( input @@ -200,50 +195,4 @@ object CylindricalThermalStorage { initialStoredEnergy, ) } - - /** Equation from docs for the relation between needed volume and energy. - * - * @param volume - * needed/available volume - * @param c - * Specific heat capacity - * @param inletTemp - * Inlet temperature - * @param returnTemp - * Return temperature - * @return - * energy - */ - def volumeToEnergy( - volume: Volume, - c: SpecificHeatCapacity, - inletTemp: Temperature, - returnTemp: Temperature, - ): Energy = { - c.calcEnergy(returnTemp, inletTemp, volume) - } - - /** Equation from docs for the relation between stored heat and volume change. - * - * @param energy - * available energy - * @param c - * Specific heat capacity - * @param inletTemp - * Inlet temperature - * @param returnTemp - * Return temperature - * @return - * volume - */ - def energyToVolume( - energy: Energy, - c: SpecificHeatCapacity, - inletTemp: Temperature, - returnTemp: Temperature, - ): Volume = { - val energyDensity = c.calcEnergyDensity(returnTemp, inletTemp) - - energy.calcVolume(energyDensity) - } } diff --git a/src/main/scala/edu/ie3/simona/model/thermal/DomesticHotWaterStorage.scala b/src/main/scala/edu/ie3/simona/model/thermal/DomesticHotWaterStorage.scala new file mode 100644 index 0000000000..2bbece8f4e --- /dev/null +++ b/src/main/scala/edu/ie3/simona/model/thermal/DomesticHotWaterStorage.scala @@ -0,0 +1,203 @@ +/* + * © 2024. TU Dortmund University, + * Institute of Energy Systems, Energy Efficiency and Energy Economics, + * Research group Distribution grid planning and operation + */ + +package edu.ie3.simona.model.thermal + +import edu.ie3.datamodel.models.OperationTime +import edu.ie3.datamodel.models.input.OperatorInput +import edu.ie3.datamodel.models.input.thermal.{ + DomesticHotWaterStorageInput, + ThermalBusInput, +} +import edu.ie3.simona.model.thermal.ThermalStorage.ThermalStorageState +import edu.ie3.simona.model.thermal.ThermalStorage.ThermalStorageThreshold.{ + StorageEmpty, + StorageFull, +} +import edu.ie3.util.quantities.PowerSystemUnits +import edu.ie3.util.scala.quantities.DefaultQuantities._ +import edu.ie3.util.scala.quantities.{ + DefaultQuantities, + KilowattHoursPerKelvinCubicMeters, +} +import squants.energy.Kilowatts +import squants.space.CubicMeters +import squants.thermal.Celsius +import squants.time.Seconds +import squants.{Energy, Power} +import tech.units.indriya.unit.Units + +import java.util.UUID + +/** A domestic hot water storage used for implementations, which require a + * mutable storage.

Important: The field storageLvl is a + * variable. + * + * @param uuid + * the element's uuid + * @param id + * the element's human-readable id + * @param operatorInput + * Operator input + * @param operationTime + * Operation time + * @param bus + * Thermal bus input + * @param maxEnergyThreshold + * Maximum permissible energy stored in the storage + * @param pThermalMax + * Thermal power, that can be charged / discharged + * @param storedEnergy + * Energy stored in the thermal storage + */ +final case class DomesticHotWaterStorage( + uuid: UUID, + id: String, + operatorInput: OperatorInput, + operationTime: OperationTime, + bus: ThermalBusInput, + maxEnergyThreshold: Energy, + pThermalMax: Power, + storedEnergy: Energy, +) extends ThermalStorage( + uuid, + id, + operatorInput, + operationTime, + bus, + maxEnergyThreshold, + pThermalMax, + ) + with ThermalStorageCalculations { + + /** Updates the given last state. Based on the then set thermal influx, the + * current state is calculated. Positive values of influx are consider to + * flow into the storage. + * + * @param tick + * Tick, where this change happens. + * @param thermalStorageState + * Last state of the heat storage. + * @param qDotHeatStorage + * Influx of the heat storage. + * @return + * The state of the instance. + */ + override def determineState( + tick: Long, + thermalStorageState: ThermalStorageState, + qDotWaterStorage: Power, + ): ThermalStorageState = { + /* Determine new state based on time difference and given state */ + val energyBalance = + qDotWaterStorage * Seconds( + tick - thermalStorageState.tick + ) + val newEnergy = thermalStorageState.storedEnergy + energyBalance + val updatedEnergy = + if (isFull(newEnergy)) + maxEnergyThreshold + else if (isEmpty(newEnergy)) + zeroKWh + else + newEnergy + + ThermalStorageState(tick, updatedEnergy) + } + + /** Calculates the tick, when the next threshold of the instance is reached. + * + * @param domesticWaterStorageState + * State of the heat storage. + * @param qDotWaterStorage + * Operating point of the domestic hot water storage. + * @return + * The next threshold if there is one. + */ + + override def determineNextThreshold( + domesticWaterStorageState: ThermalStorageState, + qDotWaterStorage: Power, + ): Option[ThermalThreshold] = { + if (qDotWaterStorage > zeroKW) { + val duration = + (maxEnergyThreshold - domesticWaterStorageState.storedEnergy) / qDotWaterStorage + val durationInTicks = Math.floor(duration.toSeconds).toLong + if (durationInTicks <= 0L) + None + else + Some(StorageFull(domesticWaterStorageState.tick + durationInTicks)) + } else if (qDotWaterStorage < zeroKW) { + val duration = + domesticWaterStorageState.storedEnergy / qDotWaterStorage * -1 + val durationInTicks = Math.floor(duration.toSeconds).toLong + if (durationInTicks <= 0L) + None + else + Some(StorageEmpty(domesticWaterStorageState.tick + durationInTicks)) + } else + None + } + + override def startingState: ThermalStorageState = ThermalStorageState( + 0L, + maxEnergyThreshold, + ) +} + +object DomesticHotWaterStorage extends ThermalStorageCalculations { + + /** Function to construct a new [[DomesticHotWaterStorage]] based on a + * provided [[DomesticHotWaterStorageInput]] + * + * @param input + * instance of [[DomesticHotWaterStorageInput]] this storage should be + * built from + * @param initialStoredEnergy + * initial stored energy + * @return + * a ready-to-use [[DomesticHotWaterStorageStorage]] with referenced + * electric parameters + */ + def apply( + input: DomesticHotWaterStorageInput, + initialStoredEnergy: Energy = zeroKWh, + ): DomesticHotWaterStorage = { + val maxEnergyThreshold = + volumeToEnergy( + CubicMeters( + input.getStorageVolumeLvl.to(Units.CUBIC_METRE).getValue.doubleValue + ), + KilowattHoursPerKelvinCubicMeters( + input.getC + .to(PowerSystemUnits.KILOWATTHOUR_PER_KELVIN_TIMES_CUBICMETRE) + .getValue + .doubleValue + ), + Celsius(input.getInletTemp.to(Units.CELSIUS).getValue.doubleValue()), + Celsius(input.getReturnTemp.to(Units.CELSIUS).getValue.doubleValue()), + ) + + val pThermalMax = Kilowatts( + input + .getpThermalMax() + .to(PowerSystemUnits.KILOWATT) + .getValue + .doubleValue() + ) + + new DomesticHotWaterStorage( + input.getUuid, + input.getId, + input.getOperator, + input.getOperationTime, + input.getThermalBus, + maxEnergyThreshold, + pThermalMax, + initialStoredEnergy, + ) + } +} diff --git a/src/main/scala/edu/ie3/simona/model/thermal/ThermalGrid.scala b/src/main/scala/edu/ie3/simona/model/thermal/ThermalGrid.scala index e38f88ea3d..7015a24dc0 100644 --- a/src/main/scala/edu/ie3/simona/model/thermal/ThermalGrid.scala +++ b/src/main/scala/edu/ie3/simona/model/thermal/ThermalGrid.scala @@ -7,10 +7,15 @@ package edu.ie3.simona.model.thermal import com.typesafe.scalalogging.LazyLogging -import edu.ie3.datamodel.models.input.thermal.CylindricalStorageInput +import edu.ie3.datamodel.models.input.thermal.{ + CylindricalStorageInput, + DomesticHotWaterStorageInput, +} +import edu.ie3.simona.util.TickUtil.RichZonedDateTime import edu.ie3.datamodel.models.result.ResultEntity import edu.ie3.datamodel.models.result.thermal.{ CylindricalStorageResult, + DomesticHotWaterStorageResult, ThermalHouseResult, } import edu.ie3.simona.exceptions.InvalidParameterException @@ -29,7 +34,7 @@ import edu.ie3.simona.model.thermal.ThermalStorage.ThermalStorageState import edu.ie3.util.quantities.QuantityUtils.RichQuantityDouble import edu.ie3.util.scala.quantities.DefaultQuantities._ import squants.energy.KilowattHours -import squants.{Energy, Power, Temperature} +import squants.{Energy, Power, Seconds, Temperature} import java.time.ZonedDateTime import scala.jdk.CollectionConverters.SetHasAsScala @@ -42,10 +47,13 @@ import scala.language.postfixOps * Thermal houses connected to the bus. * @param heatStorage * Thermal storages connected to the bus. + * @param domesticHotWaterStorage + * Storages for domestic hot water / tap water connected to the bus. */ final case class ThermalGrid( house: Option[ThermalHouse], heatStorage: Option[ThermalStorage], + domesticHotWaterStorage: Option[ThermalStorage], ) extends LazyLogging { /** Determines the state of the ThermalGrid by using the HpOperatingPoint. @@ -65,23 +73,23 @@ final case class ThermalGrid( ): ThermalGridState = { val houseQDot = operatingPoint.thermalOps.qDotHouse val heatStorageQDot = operatingPoint.thermalOps.qDotHeatStorage + val waterStorageQDot = operatingPoint.thermalOps.qDotDomesticHotWaterStorage - val updatedHouseState: Option[ThermalHouseState] = - house.zip(lastState.houseState) match { - case Some((thermalHouse, houseState)) => - Some( - thermalHouse - .determineState( - tick, - houseState, - houseQDot, - ) - ) - case _ => None - } + val updatedHouseState = house.zip(lastState.houseState) match { + case Some((thermalHouse, houseState)) => + Some( + thermalHouse + .determineState( + tick, + houseState, + houseQDot, + ) + ) + case _ => None + } - val updatedStorageState: Option[ThermalStorageState] = { - heatStorage.zip(lastState.storageState) match { + val updatedHeatStorageState = + heatStorage.zip(lastState.heatStorageState) match { case Some((storage, heatStorageState)) => Some( storage.determineState( @@ -92,57 +100,142 @@ final case class ThermalGrid( ) case _ => None } - } - ThermalGridState(updatedHouseState, updatedStorageState) + val updatedDomesticHotWaterStorageState = + domesticHotWaterStorage + .zip(lastState.domesticHotWaterStorageState) + .map { + case (storage, waterStorageState) => + storage.determineState( + tick, + waterStorageState, + waterStorageQDot, + ) + case _ => + throw new IllegalStateException( + "Could not find state of domestic hot water storage." + ) + } + + ThermalGridState( + updatedHouseState, + updatedHeatStorageState, + updatedDomesticHotWaterStorageState, + ) } /** Determine the energy demand of the thermalGrid. * @param thermalGridState * Last state of the thermal grid. + * @param hoursWaterDemandToDetermine + * The hours of which the energy demand for domestic hot water will have to + * be determined. * @return * The energy demand of elements of thermalGrid. */ def determineEnergyDemand( - thermalGridState: ThermalGridState + thermalGridState: ThermalGridState, + hoursWaterDemandToDetermine: Option[Seq[Int]], ): ThermalDemandWrapper = { - val houseDemand = house.zip(thermalGridState.houseState) match { - case Some((thermalHouse, houseState)) => - if (houseState.innerTemperature < thermalHouse.targetTemperature) { - thermalHouse.energyDemand(houseState) - } else { - ThermalEnergyDemand.noDemand - } - case None => ThermalEnergyDemand.noDemand - } + val (houseDemandHeating, houseDemandWater) = + house.zip(thermalGridState.houseState) match { + case Some((thermalHouse, houseState)) => + // Calculate domestic hot water demand + val domesticHotWaterDemand = + thermalHouse.energyDemandDomesticHotWater( + hoursWaterDemandToDetermine, + thermalGridState.houseState, + ) + // Calculate heating demand of house + val heatingDemand = { + if (houseState.innerTemperature < thermalHouse.targetTemperature) { + thermalHouse.energyDemandHeating(houseState) + } else { + ThermalEnergyDemand.noDemand + } + } + (heatingDemand, domesticHotWaterDemand) + + case None => + (ThermalEnergyDemand.noDemand, ThermalEnergyDemand.noDemand) + } - val storageDemand = heatStorage.zip(thermalGridState.storageState) match { - case Some((storage, storageState)) => - val storedEnergy = storageState.storedEnergy - val storageRequired = { - if (storedEnergy == zeroKWh) - storage.getMaxEnergyThreshold - else - zeroMWh - } + val heatStorageDemand = + heatStorage.zip(thermalGridState.heatStorageState) match { + case Some((storage, storageState)) => + val storedEnergy = storageState.storedEnergy + val storageRequired = { + if (storedEnergy == zeroKWh) + storage.getMaxEnergyThreshold + else + zeroMWh + } + + val storagePossible = storage.getMaxEnergyThreshold - storedEnergy + ThermalEnergyDemand( + storageRequired, + storagePossible, + ) + case None => ThermalEnergyDemand.noDemand + } - val storagePossible = storage.getMaxEnergyThreshold - storedEnergy - ThermalEnergyDemand( - storageRequired, - storagePossible, - ) - case None => ThermalEnergyDemand.noDemand - } + val domesticHotWaterStorageDemand = + domesticHotWaterStorage.zip( + thermalGridState.domesticHotWaterStorageState + ) match { + case Some((storage, storageState)) => + val storedEnergy = storageState.storedEnergy + val storageRequired = { + if (storedEnergy == zeroKWh) + storage.getMaxEnergyThreshold + else + zeroMWh + } + + val storagePossible = storage.getMaxEnergyThreshold - storedEnergy + ThermalEnergyDemand( + storageRequired, + storagePossible, + ) + case None => ThermalEnergyDemand.noDemand + } + + /* val domesticHotWaterStorageDemand = + domesticHotWaterStorage.zip( + thermalGridState.domesticHotWaterStorageState ) match { + case Some((storage, storageState)) => + val storedEnergyWaterStorage = storageState.storedEnergy + val storageDemand = + if ( + houseDemandWater.required > storedEnergyWaterStorage || + storedEnergyWaterStorage - houseDemandWater.required < + 0.2 * storage.getMaxEnergyThreshold + ) + (houseDemandWater.required - storedEnergyWaterStorage) + storage.getMaxEnergyThreshold + else zeroKWh + ThermalEnergyDemand(storageDemand, storageDemand) + case None => ThermalEnergyDemand.noDemand + } + + */ ThermalDemandWrapper( ThermalEnergyDemand( - houseDemand.required, - houseDemand.possible, + houseDemandHeating.required, + houseDemandHeating.possible, ), ThermalEnergyDemand( - storageDemand.required, - storageDemand.possible, + heatStorageDemand.required, + heatStorageDemand.possible, + ), + ThermalEnergyDemand( + houseDemandWater.required, + houseDemandWater.possible, + ), + ThermalEnergyDemand( + domesticHotWaterStorageDemand.required, + domesticHotWaterStorageDemand.possible, ), ) } @@ -171,11 +264,14 @@ final case class ThermalGrid( /* Consider the action in the last state We can continue using the qDots from last operating point to keep continuity. - If the house was heated in lastState and has still some demand. */ + If the house was heated in lastState and has still some demand and the domestic + hot water storage as no demand. */ if ( - state.lastHpOperatingPoint.thermalOps.qDotHouse > zeroKW && state.thermalDemands.houseDemand.hasPossibleDemand + state.lastHpOperatingPoint.thermalOps.qDotHouse > zeroKW && + state.thermalDemands.houseDemand.hasPossibleDemand && + !state.thermalDemands.domesticHotWaterStorageDemand.hasRequiredDemand ) - handleCase(state, qDot, zeroKW) + handleCase(state, qDot, zeroKW, zeroKW) // or finally check for all other cases. else handleFinalFeedInCases(state, qDot) @@ -183,33 +279,15 @@ final case class ThermalGrid( /** Handles the last cases of [[ThermalGrid.handleFeedIn]], where the thermal * feed in should be determined. - * - * | house req. demand | house add. demand | storage req. demand | storage add. demand | qDot to house | qDot to storage | - * |:------------------|:------------------|:--------------------|:--------------------|:--------------|:----------------| - * | true | true | true | true | true | false | - * | true | true | true | false | true | false | - * | true | true | false | true | true | false | - * | true | true | false | false | true | false | - * | true | false | true | true | true | false | - * | true | false | true | false | true | false | - * | true | false | false | true | true | false | - * | true | false | false | false | true | false | - * | false | true | true | true | false | true | - * | false | true | true | false | false | true | - * | false | true | false | true | false | true | - * | false | true | false | false | true | false | - * | false | false | true | true | false | true | - * | false | false | true | false | false | true | - * | false | false | false | true | false | true | - * | false | false | false | false | false | false | - * - * This can be simplified to four cases - * | No | Conditions | Result | - * |:---|:-------------------------------------|:----------| - * | 1 | if house.reqD | house | - * | 2 | else if storage.reqD OR storage.addD | storage | - * | 3 | else if house.addD | house | - * | 4 | else | no output | + * | No | Conditions | Result | + * |:---|:-----------------------------------------------|:--------------------------------| + * | 1 | if house.reqD AND waterStorage.reqD | split to house and waterStorage | + * | 2 | else if house.reqD | house | + * | 3 | else if waterStorage.reqD | waterStorage | + * | 4 | else if heatStorage.reqD OR heatStorage.posD | heatStorage | + * | 5 | else if waterStorage.posD | waterStorage | FIXME + * | 6 | else if house.posD | house | + * | 7 | else | no output | * * @param state * State of the heat pump. @@ -225,16 +303,27 @@ final case class ThermalGrid( qDot: Power, ): (ThermalGridOperatingPoint, Option[ThermalThreshold]) = { - if (state.thermalDemands.houseDemand.hasRequiredDemand) - handleCase(state, qDot, zeroKW) + if ( + state.thermalDemands.houseDemand.hasRequiredDemand && state.thermalDemands.domesticHotWaterStorageDemand.hasRequiredDemand + ) + handleCase(state, qDot / 2, zeroKW, qDot / 2) + else if (state.thermalDemands.houseDemand.hasRequiredDemand) + handleCase(state, qDot, zeroKW, zeroKW) + else if ( + state.thermalDemands.domesticHotWaterStorageDemand.hasRequiredDemand + ) + handleCase(state, zeroKW, zeroKW, qDot) else if ( state.thermalDemands.heatStorageDemand.hasRequiredDemand || state.thermalDemands.heatStorageDemand.hasPossibleDemand ) - handleCase(state, zeroKW, qDot) + handleCase(state, zeroKW, qDot, zeroKW) + // Fixme: Optionally + // else if (state.thermalDemands.domesticHotWaterStorageDemand.hasPossibleDemand) + // handleCase(state, zeroKW, zeroKW, qDot) else if (state.thermalDemands.houseDemand.hasPossibleDemand) - handleCase(state, qDot, zeroKW) + handleCase(state, qDot, zeroKW, zeroKW) else - handleCase(state, zeroKW, zeroKW) + handleCase(state, zeroKW, zeroKW, zeroKW) } /** Handles the different thermal flows from and into the thermal grid. @@ -246,6 +335,8 @@ final case class ThermalGrid( * @param qDotHeatStorage * Feed in to the heat storage (positive: Storage is charging, negative: * Storage is discharging). + * @param qDotDomesticHotWaterStorage + * Infeed to the domestic hot water storage. * @return * The operating point of the thermal grid and the next threshold if there * is one. @@ -254,23 +345,42 @@ final case class ThermalGrid( state: HpState, qDotHouse: Power, qDotHeatStorage: Power, + qDotDomesticHotWaterStorage: Power, ): (ThermalGridOperatingPoint, Option[ThermalThreshold]) = { - val (_, thermalHouseThreshold) = + val (_, thresholdThermalHouse) = handleFeedInHouse(state, qDotHouse) - val thermalStorageThreshold = - handleFeedInStorage(state, qDotHeatStorage) + val thresholdThermalStorage = + handleFeedInStorage(state, qDotHeatStorage, heatStorage) + + // Handle domestic hot water demand + val (resultingQDotHotWaterStorage, thresholdHotWaterStorage) = + // There only can be consumption, if there isn't feed in into the storage. + if (qDotDomesticHotWaterStorage == zeroKW) + handleHotWaterConsumption(state) + else { + val threshold = handleFeedInStorage( + state, + qDotDomesticHotWaterStorage, + domesticHotWaterStorage, + ) + (qDotDomesticHotWaterStorage, threshold) + } val nextThreshold = determineMostRecentThreshold( - thermalHouseThreshold, - thermalStorageThreshold, + Seq( + thresholdThermalHouse, + thresholdThermalStorage, + thresholdHotWaterStorage, + ) ) ( ThermalGridOperatingPoint( - qDotHouse + qDotHeatStorage, + qDotHouse + qDotHeatStorage + qDotDomesticHotWaterStorage, qDotHouse, qDotHeatStorage, + resultingQDotHotWaterStorage, ), nextThreshold, ) @@ -300,12 +410,12 @@ final case class ThermalGrid( ) { val maybeFullHouseThreshold = - thermalHouse.determineNextThreshold(houseState, zeroKW) + thermalHouse.determineNextThresholdRecursive(houseState, zeroKW) (qDotHouse, maybeFullHouseThreshold) } else { - val threshold = thermalHouse.determineNextThreshold( + val threshold = thermalHouse.determineNextThresholdRecursive( houseState, qDotHouse, ) @@ -316,8 +426,8 @@ final case class ThermalGrid( } /** Handles the case, when the storage has heat demand and will be filled up - * here (positive qDot) or will return its stored energy into the thermal - * grid (negative qDot). + * here (positive qDot). It will return the next thermal threshold of the + * storage. * @param state * State of the heat pump. * @param qDotStorage @@ -326,44 +436,69 @@ final case class ThermalGrid( * @return * Updated thermal storage state and the ThermalThreshold. */ + // FIXME wird das so noch gebraucht? sonst vereinfachen... private def handleFeedInStorage( state: HpState, qDotStorage: Power, + storage: Option[ThermalStorage], ): Option[ThermalThreshold] = { - heatStorage.zip(state.thermalGridState.storageState) match { - case Some((thermalStorage, storageState)) => - thermalStorage.determineNextThreshold( - storageState, - qDotStorage, - ) + // FIXME: We should somewhere check that pThermalMax of Storage is always capable for qDot pThermalMax >= pThermal of Hp + val selectedState = storage match { + case Some(_: CylindricalThermalStorage) => + state.thermalGridState.heatStorageState + case Some(_: DomesticHotWaterStorage) => + state.thermalGridState.domesticHotWaterStorageState + case _ => None + } + + (storage, selectedState) match { + case ( + Some(domesticHotWaterStorage: DomesticHotWaterStorage), + Some(domesticHotWaterStorageState), + ) => + domesticHotWaterStorage + .determineNextThreshold(domesticHotWaterStorageState, qDotStorage) + + case ( + Some(thermalStorage: CylindricalThermalStorage), + Some(heatStorageState), + ) => + thermalStorage.determineNextThreshold(heatStorageState, qDotStorage) case _ => None } } - /** Determines the most recent threshold of two given input thresholds. + /** Returns the very next threshold or None if there isn't any. * - * @param maybeHouseThreshold - * Option of a possible next threshold of the thermal house. - * @param maybeStorageThreshold - * Option of a possible next threshold of the thermal storage. + * @param thresholds + * A sequence of thresholds. * @return - * The next threshold. + * The next [[ThermalThreshold]] or [[None]]. */ private def determineMostRecentThreshold( - maybeHouseThreshold: Option[ThermalThreshold], - maybeStorageThreshold: Option[ThermalThreshold], - ): Option[ThermalThreshold] = - (maybeHouseThreshold, maybeStorageThreshold) match { - case (Some(houseThreshold), Some(storageThreshold)) => - if (houseThreshold.tick <= storageThreshold.tick) - maybeHouseThreshold - else - maybeStorageThreshold - case (None, Some(_)) => maybeStorageThreshold - case (Some(_), None) => maybeHouseThreshold - case _ => None + thresholds: Seq[Option[ThermalThreshold]] + ): Option[ThermalThreshold] = { + + @annotation.tailrec + def findMostRecent( + remaining: Seq[ThermalThreshold], + currentMin: Option[ThermalThreshold], + ): Option[ThermalThreshold] = { + remaining match { + case Nil => currentMin + case head :: tail => + val newMin = currentMin match { + case None => Some(head) + case Some(minThreshold) => + if (head.tick < minThreshold.tick) Some(head) else currentMin + } + findMostRecent(tail, newMin) + } } + findMostRecent(thresholds.flatten, None) + } + /** Handle consumption (or no feed in) from thermal grid. * * @param state @@ -375,15 +510,145 @@ final case class ThermalGrid( def handleConsumption( state: HpState ): (ThermalGridOperatingPoint, Option[ThermalThreshold]) = { + + // handle hot water demand + val (qDotHotWaterStorage, thresholdWaterStorage) = + handleHotWaterConsumption(state) /* House will be left with no influx in all cases. Determine if and when a threshold is reached */ val houseThreshold = house.zip(state.thermalGridState.houseState) match { case Some((thermalHouse, houseState)) => - thermalHouse.determineNextThreshold(houseState, zeroKW) + thermalHouse.determineNextThresholdRecursive(houseState, zeroKW) case _ => None } /* Check if house can be heated from storage */ - reviseFeedInFromStorage(state, houseThreshold) + val (revisedOp, revisedThreshold) = + reviseFeedInFromStorage(state, houseThreshold) + + val operatingPoint = + revisedOp.copy(qDotDomesticHotWaterStorage = qDotHotWaterStorage) + val nextThreshold = determineMostRecentThreshold( + Seq(revisedThreshold, thresholdWaterStorage) + ) + + (operatingPoint, nextThreshold) + } + + private def handleHotWaterConsumption( + state: HpState + ): (Power, Option[ThermalThreshold]) = { + + val domesticHotWaterDemand = + state.thermalDemands.domesticWaterDemandOfHouse + + val (qDot, threshold) = domesticHotWaterStorage.zip( + state.thermalGridState.domesticHotWaterStorageState + ) match { + case Some((_, storageState)) => + // Check if storage can handle the demand + if (storageState.storedEnergy < domesticHotWaterDemand.required) { + // if it can't, take max qDot that empties the storage asap, return the according threshold + { + identifyApplicableQDot( + state, + ThermalEnergyDemand( + storageState.storedEnergy, + storageState.storedEnergy, + ), + ) + } + } else { + // else, choose qDot to fit demand and return the according threshold + identifyApplicableQDot(state, domesticHotWaterDemand) + } + case _ => (zeroKW, None) + } + + (qDot, threshold) + } + + private def identifyApplicableQDot( + state: HpState, + domesticHotWaterDemand: ThermalEnergyDemand, + ): (Power, Option[ThermalThreshold]) = { + if (domesticHotWaterDemand.required > zeroKWh) { + val chargingPower = domesticHotWaterStorage + .map(_.getpThermalMax) + .getOrElse( + throw new RuntimeException( + s"Trying to get the chargingPower of domesticHotWaterStorage was not possible" + ) + ) + + val approxDurationAtFullPower = + domesticHotWaterDemand.required / chargingPower + + if (approxDurationAtFullPower > Seconds(1)) { + val preciseChargingPower = + -1 * domesticHotWaterDemand.required / Seconds( + approxDurationAtFullPower.toSeconds.toLong + 1 + ) + val threshold = + -1 * domesticHotWaterDemand.required / preciseChargingPower + + ( + preciseChargingPower, + Some( + SimpleThermalThreshold(state.tick + math.round(threshold.toSeconds)) + ), + ) + } else { + ( + -1 * domesticHotWaterDemand.required / Seconds(1d), + Some(SimpleThermalThreshold(state.tick + 1)), + ) + } + } else { + + val time = state.simulationTime + val simulationStartTime = time.minusSeconds(state.tick) + val nextFullHour: ZonedDateTime = ZonedDateTime.of( + time.getYear, + time.getMonthValue, + time.getDayOfMonth, + time.getHour + 1, + 0, + 0, + 0, + time.getZone, + ) + + val nextThreshold = nextFullHour.toTick(simulationStartTime) + + (zeroKW, Some(SimpleThermalThreshold(nextThreshold))) + /* + + val maxDischargingThreshold = domesticHotWaterStorage.zip( + state.thermalGridState.domesticHotWaterStorageState + ) match { + case Some((storage, storageState)) => + val assumedWorstCaseQDot = if (storageState.storedEnergy > 0.5 * storage.getMaxEnergyThreshold) + storage.getpThermalMax * 0.3 + else storage.getpThermalMax + + Some( + SimpleThermalThreshold( + tick + Math + .floor( + (storageState.storedEnergy /assumedWorstCaseQDot).toSeconds + ) + .toLong + ) + ) + + case _ => None + + + + } + (zeroKW, maxDischargingThreshold) + }*/ + } } /** Check, if the storage can heat the house. This is only done, if