diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c0bd375d7..06a13beece 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added basic external em service [#1566](https://github.com/ie3-institute/simona/issues/1566) - Implement energy limit flex options and adapt optimization [#1572](https://github.com/ie3-institute/simona/issues/1572) - Introducing `onePU` as default quantity [#1607](https://github.com/ie3-institute/simona/issues/1607) +- Introducing energy demand for warm water heating [#856](https://github.com/ie3-institute/simona/issues/856) ### Changed - Upgraded `scala2` to `scala3` [#53](https://github.com/ie3-institute/simona/issues/53) diff --git a/docs/readthedocs/models/dhws_model.md b/docs/readthedocs/models/dhws_model.md index da3fdc1bba..7c192ce0bd 100644 --- a/docs/readthedocs/models/dhws_model.md +++ b/docs/readthedocs/models/dhws_model.md @@ -3,7 +3,7 @@ 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. At initialisation the storage will be full. Domestic hot water storage will be charged when the state of charge is be below 20 % or when the required heat demand for hot tap water is higher than the stored energy in the storage. +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. At initialisation the storage will be full. Domestic hot water storage will be charged when the state of charge is be below 20 % or when the required heat demand for hot tap water is higher than the stored energy in the storage. Furthermore the domestic hot water storage is not considered in any flexibility measures of the heat source (e.g. heat pump). ## Attributes, Units and Remarks diff --git a/docs/readthedocs/models/thermal_house_model.md b/docs/readthedocs/models/thermal_house_model.md index 7f8d871d80..603f26dd09 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/model/participant/hp/HpModel.scala b/src/main/scala/edu/ie3/simona/model/participant/hp/HpModel.scala index 6b07e7307c..444251d995 100644 --- a/src/main/scala/edu/ie3/simona/model/participant/hp/HpModel.scala +++ b/src/main/scala/edu/ie3/simona/model/participant/hp/HpModel.scala @@ -24,7 +24,10 @@ import edu.ie3.simona.model.participant.control.QControl import edu.ie3.simona.model.participant.hp.HpModel.{HpOperatingPoint, HpState} import edu.ie3.simona.model.participant.{ParticipantFlexModel, ParticipantModel} import edu.ie3.simona.model.thermal.ThermalGrid -import edu.ie3.simona.model.thermal.ThermalGrid.* +import edu.ie3.simona.model.thermal.ThermalGrid.{ + ThermalDemandWrapper, + ThermalGridState, +} import edu.ie3.simona.ontology.messages.flex.FlexType import edu.ie3.simona.service.Data.PrimaryData.{ ComplexPower, @@ -78,7 +81,19 @@ class HpModel private ( operatingPoint, ) - val thermalDemands = thermalGrid.determineEnergyDemand(thermalGridState) + val hoursWaterDemandToDetermine = thermalGrid.house.flatMap( + _.checkIfNeedToDetermineDomesticHotWaterDemand( + tick, + simulationTime, + lastState, + ) + ) + + val thermalDemands = + thermalGrid.determineEnergyDemand( + thermalGridState, + hoursWaterDemandToDetermine, + ) lastState.copy( tick = tick, @@ -231,7 +246,7 @@ class HpModel private ( * @param thermalGridState * State of the thermalGrid. * @param thermalDemands - * ThermalEnergyDemand of the house and the thermal storage. + * ThermalEnergyDemand of the house and the thermal storages. * @param wasRunningLastPeriod * Indicates if the Hp was running till this tick. * @return @@ -246,19 +261,23 @@ class HpModel private ( val demandHouse = thermalDemands.houseDemand val demandHeatStorage = thermalDemands.heatStorageDemand + val demandDomesticHotWaterStorage = + thermalDemands.domesticHotWaterStorageDemand val noHeatStorageOrEmpty = thermalGridState.isHeatStorageEmpty val turnHpOn = (demandHouse.hasRequiredDemand && noHeatStorageOrEmpty) || - (demandHouse.hasPossibleDemand && wasRunningLastPeriod || - demandHeatStorage.hasRequiredDemand || - (demandHeatStorage.hasPossibleDemand && wasRunningLastPeriod)) + (demandHouse.hasPossibleDemand && wasRunningLastPeriod) || + demandHeatStorage.hasRequiredDemand || + (demandHeatStorage.hasPossibleDemand && wasRunningLastPeriod) || + demandDomesticHotWaterStorage.hasRequiredDemand val canOperate = demandHouse.hasRequiredDemand || demandHouse.hasPossibleDemand || - demandHeatStorage.hasRequiredDemand || demandHeatStorage.hasPossibleDemand + demandHeatStorage.hasRequiredDemand || demandHeatStorage.hasPossibleDemand || + demandDomesticHotWaterStorage.hasRequiredDemand val canBeOutOfOperation = - !(demandHouse.hasRequiredDemand && noHeatStorageOrEmpty) + !(demandHouse.hasRequiredDemand && noHeatStorageOrEmpty) && !demandDomesticHotWaterStorage.hasRequiredDemand ( turnHpOn, @@ -333,16 +352,21 @@ object HpModel { * [[edu.ie3.simona.model.thermal.ThermalHouse]] used for space heating. * @param qDotHeatStorage * The thermal power input of the - * [[edu.ie3.simona.model.thermal.ThermalStorage]]. + * [[edu.ie3.simona.model.thermal.ThermalStorage]] used for heat storage. + * @param qDotDomesticHotWaterStorage + * The thermal power input of the + * [[edu.ie3.simona.model.thermal.DomesticHotWaterStorage]] used for + * domestic hot water / tap water. */ final case class ThermalGridOperatingPoint( 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. @@ -381,7 +405,11 @@ 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, diff --git a/src/main/scala/edu/ie3/simona/model/thermal/ThermalDemandConditions.scala b/src/main/scala/edu/ie3/simona/model/thermal/ThermalDemandConditions.scala index 4317ed3a11..15a757253a 100644 --- a/src/main/scala/edu/ie3/simona/model/thermal/ThermalDemandConditions.scala +++ b/src/main/scala/edu/ie3/simona/model/thermal/ThermalDemandConditions.scala @@ -12,8 +12,10 @@ import edu.ie3.util.scala.quantities.DefaultQuantities.zeroKW private case class ThermalDemandConditions( shouldContinueHouseHeating: Boolean, houseDemand: Boolean, + waterStorageDemand: Boolean, heatStorageDemand: Boolean, housePossible: Boolean, + waterStoragePossible: Boolean, heatStoragePossible: Boolean, houseHeatedLastState: Boolean, ) @@ -38,14 +40,19 @@ private object ThermalDemandConditions { ThermalDemandConditions( /* 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 has no demand. */ shouldContinueHouseHeating = - lastOperatingPoint.qDotHouse > zeroKW && houseDemand.hasPossibleDemand, + lastOperatingPoint.qDotHouse > zeroKW && houseDemand.hasPossibleDemand && + !state.thermalDemands.domesticHotWaterStorageDemand.hasRequiredDemand, houseDemand = houseDemand.hasRequiredDemand, + waterStorageDemand = + state.thermalDemands.domesticHotWaterStorageDemand.hasRequiredDemand, heatStorageDemand = heatStorageDemand.hasRequiredDemand || heatStorageDemand.hasPossibleDemand, housePossible = houseDemand.hasPossibleDemand, + waterStoragePossible = + state.thermalDemands.domesticHotWaterStorageDemand.hasPossibleDemand, heatStoragePossible = heatStorageDemand.hasPossibleDemand, houseHeatedLastState = isHouseHeatedLastState, ) 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 c865580df4..5f1114f350 100644 --- a/src/main/scala/edu/ie3/simona/model/thermal/ThermalGrid.scala +++ b/src/main/scala/edu/ie3/simona/model/thermal/ThermalGrid.scala @@ -7,7 +7,10 @@ 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.datamodel.models.result.ResultEntity import edu.ie3.datamodel.models.result.thermal.{ CylindricalStorageResult, @@ -27,6 +30,7 @@ import edu.ie3.simona.model.thermal.ThermalGrid.{ } import edu.ie3.simona.model.thermal.ThermalHouse.ThermalHouseState import edu.ie3.simona.model.thermal.ThermalStorage.ThermalStorageState +import edu.ie3.simona.util.TickUtil.RichZonedDateTime import edu.ie3.util.quantities.QuantityUtils.{ asKelvin, asMegaWatt, @@ -34,8 +38,9 @@ import edu.ie3.util.quantities.QuantityUtils.{ asPu, } import edu.ie3.util.scala.quantities.DefaultQuantities.* +import edu.ie3.util.scala.quantities.QuantityUtil.* import squants.energy.KilowattHours -import squants.{Energy, Power, Temperature} +import squants.{Energy, Power, Seconds, Temperature} import java.time.ZonedDateTime import scala.jdk.CollectionConverters.SetHasAsScala @@ -48,10 +53,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[CylindricalThermalStorage], + domesticHotWaterStorage: Option[DomesticHotWaterStorage], ) extends LazyLogging { /** Determines the state of the ThermalGrid by using the HpOperatingPoint. @@ -72,6 +80,7 @@ final case class ThermalGrid( ): ThermalGridState = { val houseQDot = operatingPoint.thermalOps.qDotHouse val heatStorageQDot = operatingPoint.thermalOps.qDotHeatStorage + val waterStorageQDot = operatingPoint.thermalOps.qDotDomesticHotWaterStorage val updatedHouseState = house.zip(lastState.houseState) match { case Some((thermalHouse, houseState)) => @@ -99,65 +108,158 @@ final case class ThermalGrid( case _ => None } - ThermalGridState(updatedHouseState, updatedHeatStorageState) + val updatedDomesticHotWaterStorageState = domesticHotWaterStorage + .zip(lastState.domesticHotWaterStorageState) + .map { + case ( + storage: DomesticHotWaterStorage, + waterStorageState: ThermalStorageState, + ) => + 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 { + val (houseDemandHeating, houseDemandWater) = + calculateHouseDemand(thermalGridState, hoursWaterDemandToDetermine) + val heatStorageDemand = calculateHeatStorageDemand(thermalGridState) + val domesticHotWaterStorageDemand = calculateDomesticStorageDemand( + thermalGridState + ) + + ThermalDemandWrapper( + houseDemandHeating, + heatStorageDemand, + houseDemandWater, + domesticHotWaterStorageDemand, + ) + } + + /** Determine the energy demand for heating and the water demand of the house. + * + * @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 and water demand of the house. + */ + private def calculateHouseDemand( + thermalGridState: ThermalGridState, + hoursWaterDemandToDetermine: Option[Seq[Int]], + ): (ThermalEnergyDemand, ThermalEnergyDemand) = { + house.zip(thermalGridState.houseState) match { case Some((thermalHouse, houseState)) => - if houseState.innerTemperature < thermalHouse.targetTemperature then { - thermalHouse.energyDemandHeating(houseState) - } else { - ThermalEnergyDemand.noDemand + // Calculate domestic hot water demand + val domesticHotWaterDemand = + thermalHouse.energyDemandDomesticHotWater( + hoursWaterDemandToDetermine + ) + // Calculate heating demand of house + val heatingDemand = { + if houseState.innerTemperature < thermalHouse.targetTemperature + then { + thermalHouse.energyDemandHeating(houseState) + } else { + ThermalEnergyDemand.noDemand + } } - case None => ThermalEnergyDemand.noDemand + (heatingDemand, domesticHotWaterDemand) + + case None => + (ThermalEnergyDemand.noDemand, ThermalEnergyDemand.noDemand) } + } - val heatStorageDemand = - heatStorage.zip(thermalGridState.heatStorageState) match { - case Some((storage, storageState)) => - val storedEnergy = storageState.storedEnergy - val storageRequired = { - if storedEnergy == zeroKWh then storage.getMaxEnergyThreshold - else zeroMWh - } + /** Determine the energy demand of the HeatStorage. + * + * @param thermalGridState + * Last state of the thermal grid. + * @return + * The energy demand of the HeatStorage. + */ + private def calculateHeatStorageDemand( + thermalGridState: ThermalGridState + ): ThermalEnergyDemand = { + heatStorage.zip(thermalGridState.heatStorageState) match { + case Some((storage, storageState)) => + val storedEnergy = storageState.storedEnergy + val storageRequired = { + if storedEnergy == zeroKWh then 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 + } + } - ThermalDemandWrapper( - ThermalEnergyDemand( - houseDemand.required, - houseDemand.possible, - ), - ThermalEnergyDemand( - heatStorageDemand.required, - heatStorageDemand.possible, - ), - ) + /** Determine the energy demand of the DomesticHotWaterStorage. + * + * @param thermalGridState + * Last state of the thermal grid. + * @return + * The energy demand of the domestic hot water storage. + */ + private def calculateDomesticStorageDemand( + thermalGridState: ThermalGridState + ): ThermalEnergyDemand = { + domesticHotWaterStorage.zip( + thermalGridState.domesticHotWaterStorageState + ) match { + case Some((storage, storageState)) => + val storedEnergy = storageState.storedEnergy + val storageRequired = { + if storedEnergy == zeroKWh then storage.getMaxEnergyThreshold + else zeroMWh + } + + val storagePossible = storage.getMaxEnergyThreshold - storedEnergy + ThermalEnergyDemand( + storageRequired, + storagePossible, + ) + case None => ThermalEnergyDemand.noDemand + } } - /** Handles the case, when a grid has feed in. Depending on which entity has - * some heat demand the house or the storage will be heated up / filled up. - * First the actions from last operating point will be considered and checked - * if the behaviour should be continued. This might be the case, if we got - * activated by updated weather data. If this is not the case, all other - * cases will be handled by [[ThermalGrid.handleFinalFeedInCases]]. + /** Handles the case, when a grid has feed in. To do so, first the conditions + * of all grid elements are evaluated if there is demand for heating. Based + * on these, the distribution strategy for the heating power (qDot) is + * chosen. * * @param state * State of the heat pump. @@ -175,41 +277,24 @@ final case class ThermalGrid( // TODO: We would need to issue a storage result model here... val conditions = ThermalDemandConditions.from(state) val strategy = selectFeedInStrategy(conditions) - val (qDotHouse, qDotHeatStorage) = strategy(qDot, heatStorage) - - handleCase(state, qDotHouse, qDotHeatStorage) + val (qDotHouse, qDotHeatStorage, qDotWaterStorage) = + strategy(qDot, heatStorage, domesticHotWaterStorage) + handleCase(state, qDotHouse, qDotHeatStorage, qDotWaterStorage) } - /** 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 | + /** Selects the strategy how to distribute the thermal power (qDot) from the + * heat source to the elements within this ThermalGrid. * - * 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 | + * | 6 | else if house.posD | house | + * | 7 | else | no output | * * @param conditions * The ThermalDemandConditions, describing the current status of heat @@ -222,8 +307,14 @@ final case class ThermalGrid( ): FeedInStrategy = { if conditions.shouldContinueHouseHeating then { HouseOnlyStrategy + } else if conditions.waterStorageDemand && + (conditions.houseDemand || conditions.houseHeatedLastState) + then { + SplitHouseWaterStrategy } else if conditions.houseDemand then { HouseOnlyStrategy + } else if conditions.waterStorageDemand then { + WaterStorageFirstStrategy } else if conditions.heatStorageDemand then { HeatStorageFirstStrategy } else if conditions.housePossible then { @@ -242,6 +333,8 @@ final case class ThermalGrid( * @param qDotHeatStorage * Feed in to the heat storage (positive: Storage is charging, negative: * Storage is discharging). + * @param qDotDomesticHotWaterStorage + * In-feed to the domestic hot water storage. * @return * The operating point of the thermal grid and the next threshold if there * is one. @@ -250,25 +343,38 @@ final case class ThermalGrid( state: HpState, qDotHouse: Power, qDotHeatStorage: Power, + qDotDomesticHotWaterStorage: Power, ): (ThermalGridOperatingPoint, Option[ThermalThreshold]) = { - val (_, thermalHouseThreshold) = + val (_, thresholdThermalHouse) = handleFeedInHouse(state, qDotHouse) - val heatStorageThreshold = - handleFeedInStorages(state, qDotHeatStorage) + val thresholdHeatStorage = handleFeedInHeatStorage(state, qDotHeatStorage) + + // 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 then + handleHotWaterConsumption(state) + else { + val threshold = + handleFeedInHotWaterStorage(state, qDotDomesticHotWaterStorage) + (qDotDomesticHotWaterStorage, threshold) + } val nextThreshold = determineNextThreshold( Seq( - thermalHouseThreshold, - heatStorageThreshold, + thresholdThermalHouse, + thresholdHeatStorage, + thresholdHotWaterStorage, ) ) ( ThermalGridOperatingPoint( - qDotHouse + qDotHeatStorage, + qDotHouse + qDotHeatStorage + qDotDomesticHotWaterStorage, qDotHouse, qDotHeatStorage, + resultingQDotHotWaterStorage, ), nextThreshold, ) @@ -306,16 +412,28 @@ final case class ThermalGrid( * @param qDotHeatStorage * Feed in to the heat storage (positive: Storage is charging, negative: * Storage is discharging). + * @param qDotHotWaterStorage + * Feed in to the water storage (positive: Storage is charging, negative: + * Storage is discharging). * @return * The ThermalThreshold if there is one. */ private def handleFeedInStorages( state: HpState, qDotHeatStorage: Power, - ): Option[ThermalThreshold] = { - if qDotHeatStorage != zeroKW then - handleFeedInHeatStorage(state, qDotHeatStorage) - else None + qDotHotWaterStorage: Power, + ): (Option[ThermalThreshold], Option[ThermalThreshold]) = { + val heatStorageThreshold = + if qDotHeatStorage != zeroKW then + handleFeedInHeatStorage(state, qDotHeatStorage) + else None + + val hotWaterStorageThreshold = + if qDotHotWaterStorage != zeroKW then + handleFeedInHotWaterStorage(state, qDotHotWaterStorage) + else None + + (heatStorageThreshold, hotWaterStorageThreshold) } private def handleFeedInHeatStorage( @@ -328,6 +446,18 @@ final case class ThermalGrid( } yield storage.determineNextThreshold(storageState, qDotStorage) }.flatten + private def handleFeedInHotWaterStorage( + state: HpState, + qDotStorage: Power, + ): Option[ThermalThreshold] = { + for { + storage <- domesticHotWaterStorage.collect { + case s: DomesticHotWaterStorage => s + } + storageState <- state.thermalGridState.domesticHotWaterStorageState + } yield storage.determineNextThreshold(storageState, qDotStorage) + }.flatten + /** Determines the next threshold of a given input sequence of thresholds. * * @param thresholds @@ -360,6 +490,9 @@ 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)) => @@ -368,7 +501,106 @@ final case class ThermalGrid( } /* 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 = determineNextThreshold( + 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 then { + // 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]) = { + val minimumOperationDuration = Seconds(1) + if domesticHotWaterDemand.required > zeroKWh then { + 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 > minimumOperationDuration then { + 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 / minimumOperationDuration, + Some(SimpleThermalThreshold(state.tick + 1)), + ) + } + } else { + + val nextThreshold = calculateNextHourThreshold(state) + (zeroKW, Some(SimpleThermalThreshold(nextThreshold))) + } + } + + /** Calculates the tick value for the next full hour threshold based on the + * current simulation state. + * @param state + * State of the heat pump. + * @return + * The tick of the next full hour. + */ + private def calculateNextHourThreshold(state: HpState): Long = { + val time = state.simulationTime + val nextFullHour: ZonedDateTime = + time.plusHours(1).withMinute(0).withSecond(0).withNano(0) + val simulationStartTime = time.minusSeconds(state.tick) + nextFullHour.toTick(simulationStartTime) } /** Check, if the storage can heat the house. This is only done, if