diff --git a/CHANGELOG.md b/CHANGELOG.md index 4341dd7c92..6aa9ed1ccd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Replace `BmModel` with its new implementation [#1157](https://github.com/ie3-institute/simona/issues/1157) - Integration test for thermal grids without Em [#1145](https://github.com/ie3-institute/simona/issues/1145) - Change thermal house behaviour to heat till targetTemperature [#1176](https://github.com/ie3-institute/simona/issues/1176) +- Integration test for thermal grids with Em [#1146](https://github.com/ie3-institute/simona/issues/1146) +- Introducing warm water heating to `ThermalHouse` [#856](https://github.com/ie3-institute/simona/issues/856) ### Changed - Adapted to changed data source in PSDM [#435](https://github.com/ie3-institute/simona/issues/435) 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 4ef6f35347..fa7b0dca18 100644 --- a/docs/readthedocs/models/thermal_house_model.md +++ b/docs/readthedocs/models/thermal_house_model.md @@ -4,10 +4,10 @@ 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. @@ -21,7 +21,9 @@ As the storage is initialised as empty, the heat source will start charging the ### Behaviour under EM control -Currently, not fully supported. Will be fixed by [PR #1159](https://github.com/ie3-institute/simona/pull/1159) +When {ref}`em` is applied to the heat source of this building, the thermal behaviour should be basically the same as without EM control, so internal temperature of the house should remain between the target temperature and the lower temperature limit. + +However, for flexibility usage, the energy management system can turn on the heat source whenever the thermal house has additional demand. Same for the thermal storage. When the heat source is EM controlled, the thermal storage will only be recharged when the flexibility strategy allows. E.g. when there is surplus energy from PV plant. ## Attributes, Units and Remarks diff --git a/src/main/resources/config/config-template.conf b/src/main/resources/config/config-template.conf new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/main/scala/edu/ie3/simona/agent/participant/ParticipantAgentFundamentals.scala b/src/main/scala/edu/ie3/simona/agent/participant/ParticipantAgentFundamentals.scala index 8487b5d61e..5d7eee27ba 100644 --- a/src/main/scala/edu/ie3/simona/agent/participant/ParticipantAgentFundamentals.scala +++ b/src/main/scala/edu/ie3/simona/agent/participant/ParticipantAgentFundamentals.scala @@ -61,7 +61,6 @@ import edu.ie3.simona.event.ResultEvent import edu.ie3.simona.event.ResultEvent.{ FlexOptionsResultEvent, ParticipantResultEvent, - ThermalResultEvent, } import edu.ie3.simona.event.notifier.NotifierConfig import edu.ie3.simona.exceptions.CriticalFailureException @@ -1946,8 +1945,7 @@ protected trait ParticipantAgentFundamentals[ result: R ): Option[ResultEvent] = result match { case thermalUnitResult: ThermalUnitResult => - Some(ThermalResultEvent(thermalUnitResult)) - + Some(ResultEvent.ThermalResultEvent(thermalUnitResult)) case unsupported => log.debug( s"Results of class '${unsupported.getClass.getSimpleName}' are currently not supported." diff --git a/src/main/scala/edu/ie3/simona/agent/participant/hp/HpAgentFundamentals.scala b/src/main/scala/edu/ie3/simona/agent/participant/hp/HpAgentFundamentals.scala index d99b8f4e8e..00b5c12f27 100644 --- a/src/main/scala/edu/ie3/simona/agent/participant/hp/HpAgentFundamentals.scala +++ b/src/main/scala/edu/ie3/simona/agent/participant/hp/HpAgentFundamentals.scala @@ -430,6 +430,12 @@ trait HpAgentFundamentals HpRelevantData( tick, weatherData.temp.inKelvin, + baseStateData.startDate, + baseStateData.model.thermalGrid.house.map(_.houseInhabitants).getOrElse { + throw new InconsistentStateException( + s"The model ${baseStateData.model.thermalGrid.house} was not provided with the needed number of inhabitants." + ) + }, ) } diff --git a/src/main/scala/edu/ie3/simona/event/ResultEvent.scala b/src/main/scala/edu/ie3/simona/event/ResultEvent.scala index 6e91283b65..4e72749d21 100644 --- a/src/main/scala/edu/ie3/simona/event/ResultEvent.scala +++ b/src/main/scala/edu/ie3/simona/event/ResultEvent.scala @@ -18,9 +18,11 @@ import edu.ie3.datamodel.models.result.system.{ } import edu.ie3.datamodel.models.result.thermal.{ CylindricalStorageResult, + DomesticHotWaterStorageResult, ThermalHouseResult, ThermalUnitResult, } +import edu.ie3.datamodel.models.result.system.{EmResult, HpResult} import edu.ie3.simona.agent.grid.GridResultsSupport.PartialTransformer3wResult import edu.ie3.simona.event.listener.ResultEventListener import tech.units.indriya.ComparableQuantity @@ -45,6 +47,44 @@ object ResultEvent { systemParticipantResult: SystemParticipantResult ) extends ResultEvent + object HpResult { + def unapply(result: HpResult): Option[ + ( + ZonedDateTime, + UUID, + ComparableQuantity[Power], + ComparableQuantity[Power], + ) + ] = + Option(result).map { result => + ( + result.getTime, + result.getInputModel, + result.getP, + result.getQ, + ) + } + } + + object EmResult { + def unapply(result: EmResult): Option[ + ( + ZonedDateTime, + UUID, + ComparableQuantity[Power], + ComparableQuantity[Power], + ) + ] = + Option(result).map { result => + ( + result.getTime, + result.getInputModel, + result.getP, + result.getQ, + ) + } + } + /** Event, that is triggered every time a thermal model has a new result * @param thermalResult * Result of the thermal calculation @@ -92,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 d46fc9844d..1ea0ce5e8c 100644 --- a/src/main/scala/edu/ie3/simona/model/participant/HpModel.scala +++ b/src/main/scala/edu/ie3/simona/model/participant/HpModel.scala @@ -175,7 +175,8 @@ final case class HpModel( * ThermalEnergyDemand of the house and the thermal storage * @return * boolean defining if heat pump runs in next time step, if it can be in - * operation and can be out of operation + * operation and out of operation plus the [[ThermalEnergyDemand]] of + * house, heat storage, domestic hot water storage */ private def operatesInNextState( lastState: HpState, @@ -186,6 +187,9 @@ final case class HpModel( val demandHouse = thermalDemands.houseDemand val demandThermalStorage = thermalDemands.heatStorageDemand + val demandDomesticHotWaterStorage = + thermalDemands.domesticHotWaterStorageDemand + val noThermalStorageOrThermalStorageIsEmpty = currentThermalGridState.isThermalStorageEmpty @@ -193,13 +197,16 @@ final case class HpModel( (demandHouse.hasRequiredDemand && noThermalStorageOrThermalStorageIsEmpty) || (demandHouse.hasAdditionalDemand && lastState.isRunning) || demandThermalStorage.hasRequiredDemand || - (demandThermalStorage.hasAdditionalDemand && lastState.isRunning) + (demandThermalStorage.hasAdditionalDemand && lastState.isRunning) || + demandDomesticHotWaterStorage.hasRequiredDemand || + (demandDomesticHotWaterStorage.hasAdditionalDemand && lastState.isRunning) val canOperate = demandHouse.hasRequiredDemand || demandHouse.hasAdditionalDemand || - demandThermalStorage.hasRequiredDemand || demandThermalStorage.hasAdditionalDemand + demandThermalStorage.hasRequiredDemand || demandThermalStorage.hasAdditionalDemand || + demandDomesticHotWaterStorage.hasRequiredDemand || demandDomesticHotWaterStorage.hasAdditionalDemand val canBeOutOfOperation = - !(demandHouse.hasRequiredDemand && noThermalStorageOrThermalStorageIsEmpty) + !(demandHouse.hasRequiredDemand && noThermalStorageOrThermalStorageIsEmpty) && !demandDomesticHotWaterStorage.hasRequiredDemand ( turnHpOn, @@ -218,8 +225,9 @@ final case class HpModel( * data of heat pump including state of the heat pump * @param isRunning * determines whether the heat pump is running or not - * @param demandWrapper - * holds the thermal demands of the thermal units (house, storage) + * @param thermalDemands + * holds the thermal demands of the thermal units (house, heatStorage, + * hotDomesticWaterStorage) * @param currentThermalGridState * Current state of the thermalGrid * @return @@ -229,7 +237,7 @@ final case class HpModel( lastState: HpState, relevantData: HpRelevantData, isRunning: Boolean, - demandWrapper: ThermalDemandWrapper, + thermalDemands: ThermalDemandWrapper, currentThermalGridState: ThermalGridState, ): HpState = { val lastHouseQDot = lastState.thermalGridState.houseState @@ -245,12 +253,12 @@ final case class HpModel( if (isRunning) (pRated, pThermal, pThermal) else if ( - currentStorageEnergy > zeroKWh && demandWrapper.houseDemand.hasRequiredDemand + currentStorageEnergy > zeroKWh && thermalDemands.houseDemand.hasRequiredDemand ) { // If the house has req. demand and storage isn't empty, we can heat the house from storage. (zeroKW, zeroKW, currentStoragePThermal) } else if ( - currentStorageEnergy > zeroKWh && demandWrapper.houseDemand.hasAdditionalDemand && lastHouseQDot > zeroKW + currentStorageEnergy > zeroKWh && thermalDemands.houseDemand.hasAdditionalDemand && lastHouseQDot > zeroKW ) // Edge case when em controlled: If the house was heated last state by Hp and setPower is below turnOn condition now, // but house didn't reach target or boundary temperature yet. House can be heated from storage, if this one is not empty. @@ -266,7 +274,7 @@ final case class HpModel( lastState.ambientTemperature.getOrElse(relevantData.ambientTemperature), isRunning, qDotIntoGrid, - demandWrapper, + thermalDemands, ) HpState( @@ -333,7 +341,7 @@ final case class HpModel( val turnOn = setPower > (sRated.toActivePower(cosPhiRated) * 0.5) val ( - thermalDemandWrapper, + thermalDemands, updatedThermalGridState, ) = thermalGrid.energyDemandAndUpdatedState( @@ -345,7 +353,7 @@ final case class HpModel( lastState, relevantData, turnOn, - thermalDemandWrapper, + thermalDemands, updatedThermalGridState, ) @@ -445,10 +453,16 @@ object HpModel { * contains current time tick * @param ambientTemperature * Ambient temperature at current tick + * @param simulationStart + * simulationStartDate as ZonedDateTime + * @param houseInhabitants + * number of people living in the building */ final case class HpRelevantData( currentTick: Long, ambientTemperature: Temperature, + simulationStart: ZonedDateTime, + houseInhabitants: Double, ) extends CalcRelevantData /** Internal method to construct a new [[HpModel]] based on a provided 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 10bbbd0bd6..f99c5d1854 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,13 @@ 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.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 @@ -131,7 +128,7 @@ final case class CylindricalThermalStorage( ) } -object CylindricalThermalStorage { +object CylindricalThermalStorage extends ThermalStorageCalculations { /** Function to construct a new [[CylindricalThermalStorage]] based on a * provided [[CylindricalStorageInput]] @@ -150,21 +147,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 @@ -186,49 +181,4 @@ object CylindricalThermalStorage { ) } - /** 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..636ad1d9fc --- /dev/null +++ b/src/main/scala/edu/ie3/simona/model/thermal/DomesticHotWaterStorage.scala @@ -0,0 +1,221 @@ +/* + * © 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 minEnergyThreshold + * Minimum permissible energy stored in the storage + * @param maxEnergyThreshold + * Maximum permissible energy stored in the storage + * @param chargingPower + * Thermal power, that can be charged / discharged + */ +final case class DomesticHotWaterStorage( + uuid: UUID, + id: String, + operatorInput: OperatorInput, + operationTime: OperationTime, + bus: ThermalBusInput, + maxEnergyThreshold: Energy, + chargingPower: Power, + override protected var _storedEnergy: Energy, +) extends ThermalStorage( + uuid, + id, + operatorInput, + operationTime, + bus, + maxEnergyThreshold, + chargingPower, + ) + with MutableStorage + 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. Additionally, the tick, when the next threshold is + * reached, is calculated as well. + * + * @param tick + * Tick, where this change happens + * @param qDot + * Influx + * @param lastState + * Last known state + * @return + * The updated state as well as the tick, when a threshold is reached + */ + override def updateState( + tick: Long, + qDot: Power, + lastState: ThermalStorageState, + ): (ThermalStorageState, Option[ThermalThreshold]) = { + /* Determine new state based on time difference and given state */ + val energyBalance = lastState.qDot * Seconds(tick - lastState.tick) + val newEnergy = lastState.storedEnergy + energyBalance + val updatedEnergy = + if (isFull(newEnergy)) + maxEnergyThreshold + else if (isEmpty(newEnergy)) + zeroKWh + else + newEnergy + + /* Determine, when a threshold is reached */ + val nextThreshold = + if (qDot > zeroKW) { + val duration = (maxEnergyThreshold - updatedEnergy) / qDot + val durationInTicks = Math.round(duration.toSeconds) + if (durationInTicks <= 0L) + None + else + Some(StorageFull(tick + durationInTicks)) + } else if (qDot < zeroKW) { + val duration = updatedEnergy / qDot * (-1) + val durationInTicks = Math.round(duration.toSeconds) + if (durationInTicks <= 0L) + None + else + Some(StorageEmpty(tick + durationInTicks)) + } else { + return (ThermalStorageState(tick, updatedEnergy, qDot), None) + } + + (ThermalStorageState(tick, updatedEnergy, qDot), nextThreshold) + } + + override def startingState: ThermalStorageState = ThermalStorageState( + -1L, + maxEnergyThreshold, + zeroKW, + ) + + @deprecated("Use thermal storage state instead") + override def usableThermalEnergy: Energy = + _storedEnergy + + @deprecated("Use thermal storage state instead") + override def tryToStoreAndReturnRemainder( + addedEnergy: Energy + ): Option[Energy] = { + if (addedEnergy > zeroKWh) { + _storedEnergy = _storedEnergy + addedEnergy + if (_storedEnergy > maxEnergyThreshold) { + val surplus = _storedEnergy - maxEnergyThreshold + _storedEnergy = maxEnergyThreshold + return Option(surplus) + } + } + Option.empty + } + + @deprecated("Use thermal storage state instead") + override def tryToTakeAndReturnLack( + takenEnergy: Energy + ): Option[Energy] = { + if (takenEnergy > zeroKWh) { + _storedEnergy = _storedEnergy - takenEnergy + if (_storedEnergy < zeroKWh) { + val lack = zeroKWh - _storedEnergy + _storedEnergy = zeroKWh + return Some(lack) + } + } + None + } + +} + +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 + * @return + * a ready-to-use [[DomesticHotWaterStorageStorage]] with referenced + * electric parameters + */ + def apply( + input: DomesticHotWaterStorageInput + ): DomesticHotWaterStorage = { + val maxEnergyThreshold: Energy = + 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 chargingPower = Kilowatts( + input + .getpThermalMax() + .to(PowerSystemUnits.KILOWATT) + .getValue + .doubleValue() + ) + + new DomesticHotWaterStorage( + input.getUuid, + input.getId, + input.getOperator, + input.getOperationTime, + input.getThermalBus, + maxEnergyThreshold, + chargingPower, + maxEnergyThreshold, + ) + } +} 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 fa7c98ab97..8b4394d09c 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,14 @@ 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, + DomesticHotWaterStorageResult, ThermalHouseResult, } import edu.ie3.simona.exceptions.InvalidParameterException @@ -27,7 +31,7 @@ import edu.ie3.simona.util.TickUtil.TickLong import edu.ie3.util.quantities.QuantityUtils.RichQuantityDouble import edu.ie3.util.scala.quantities.DefaultQuantities._ import squants.energy.{KilowattHours, Kilowatts} -import squants.{Energy, Power, Temperature} +import squants.{Energy, Power, Seconds, Temperature} import java.time.ZonedDateTime import scala.jdk.CollectionConverters.SetHasAsScala @@ -40,10 +44,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 { /** Determine the energy demand of the total grid at the given instance in @@ -62,9 +69,9 @@ final case class ThermalGrid( lastHpState: HpState, ): (ThermalDemandWrapper, ThermalGridState) = { /* First get the energy demand of the houses but only if inner temperature is below target temperature */ - val (houseDemand, updatedHouseState) = + val (houseDemand, updatedHouseState, demandHotDomesticWater) = house.zip(lastHpState.thermalGridState.houseState) match { - case Some((thermalHouse, lastHouseState)) => + case Some((thermalHouse, lastHouseState)) => { val (updatedHouseState, _) = thermalHouse.updateState( relevantData, @@ -74,11 +81,11 @@ final case class ThermalGrid( ), lastHouseState.qDot, ) - if ( + val (heatDemand, newHouseState) = if ( updatedHouseState.innerTemperature < thermalHouse.targetTemperature ) { ( - thermalHouse.energyDemand( + thermalHouse.energyDemandHeating( relevantData, updatedHouseState, ), @@ -87,11 +94,20 @@ final case class ThermalGrid( } else { (ThermalEnergyDemand.noDemand, Some(updatedHouseState)) } + + val energyDemandDomesticHotWater = + thermalHouse.energyDemandDomesticHotWater( + relevantData, + Some(lastHouseState), + ) + (heatDemand, newHouseState, energyDemandDomesticHotWater) + } case None => - (ThermalEnergyDemand.noDemand, None) + (ThermalEnergyDemand.noDemand, None, ThermalEnergyDemand.noDemand) } /* Then go over the storages, see what they can provide and what they might be able to charge */ + // Heat storages first val (storageDemand, updatedStorageState) = { heatStorage @@ -106,7 +122,7 @@ final case class ThermalGrid( storage.getMaxEnergyThreshold - storedEnergy } else { - zeroMWh + zeroKWh } } @@ -121,7 +137,53 @@ final case class ThermalGrid( } .getOrElse( - ThermalEnergyDemand(zeroMWh, zeroMWh), + ThermalEnergyDemand(zeroKWh, zeroKWh), + None, + ) + } + + // Domestic hot water storages + val (domesticHotWaterStorageDemand, updatedDomesticHotWaterStorageState) = { + val domesticHotWaterDemand: ThermalEnergyDemand = house + .map( + _.energyDemandDomesticHotWater( + relevantData, + lastHpState.thermalGridState.houseState, + ) + ) + .getOrElse(ThermalEnergyDemand(zeroKWh, zeroKWh)) + val (applicableqDotDomesticStorage, _) = + identifyApplicableQDot(relevantData.currentTick, domesticHotWaterDemand) + + domesticHotWaterStorage + .zip(lastHpState.thermalGridState.domesticHotWaterStorageState) + .map { case (storage, state) => + val updatedStorageState = + storage + .updateState( + relevantData.currentTick, + state.qDot.plus(applicableqDotDomesticStorage), + state, + ) + ._1 + val storedEnergy = updatedStorageState.storedEnergy + + // Declare demand of domestic hot water storage if demand is higher than stored energy or stored energy is less than 20% of capacity + val demandOfStorage = + if ( + storedEnergy < demandHotDomesticWater.required || storedEnergy < storage.getMaxEnergyThreshold * 0.2 + ) { + + storage.getMaxEnergyThreshold + demandHotDomesticWater.required - storedEnergy + + } else zeroKWh + ( + ThermalEnergyDemand(demandOfStorage, demandOfStorage), + Some(updatedStorageState), + ) + } + .getOrElse( + ThermalEnergyDemand(zeroKWh, zeroKWh), None, ) } @@ -136,8 +198,16 @@ final case class ThermalGrid( storageDemand.required, storageDemand.possible, ), + ThermalEnergyDemand( + domesticHotWaterStorageDemand.required, + domesticHotWaterStorageDemand.possible, + ), + ), + ThermalGridState( + updatedHouseState, + updatedStorageState, + updatedDomesticHotWaterStorageState, ), - ThermalGridState(updatedHouseState, updatedStorageState), ) } @@ -217,131 +287,80 @@ final case class ThermalGrid( ): (ThermalGridState, Option[ThermalThreshold]) = { // TODO: We would need to issue a storage result model here... - /* Consider the action in the last state */ - val qDotHouseLastState = - lastThermalGridState.houseState.map(_.qDot).getOrElse(zeroKW) - val qDotStorageLastState = - lastThermalGridState.storageState.map(_.qDot).getOrElse(zeroKW) + /* Consider the action in the last state and if it's possible to continue*/ + val (_, qDotHouseLastState, houseReachedBoundary, houseLeftBoundary) = + updateStateGetLastThermalActionAndCheckIfCanContinueThermalHouse( + relevantData, + lastThermalGridState, + lastAmbientTemperature, + ) + val ( + updatedHeatStorageState, + qDotStorageLastState, + thermalStorageReachedBoundary, + thermalStorageLeftBoundary, + ) = + updateStateGetLastThermalActionAndCheckIfCanContinueThermalStorage( + relevantData.currentTick, + lastThermalGridState, + ) + val ( + _, + qDotDomesticWaterStorageLastState, + domesticHotWaterStorageReachedBoundary, + domesticHotWaterStorageLeftBoundary, + ) = + updateStateGetLastThermalActionAndCheckIfCanContinueDomesticHotWaterStorage( + relevantData.currentTick, + lastThermalGridState, + ) + val domesticHotWaterStorageDemand = + thermalDemands.domesticHotWaterStorageDemand - // We can use the qDots from lastState to keep continuity. If... if ( - // ... house was heated in lastState but not from Storage and has still some demand. Hp must still run for this. - ((qDotHouseLastState > zeroKW && (qDotStorageLastState >= zeroKW) && thermalDemands.houseDemand.hasAdditionalDemand) && isRunning || - // ... storage was filled up in the lastState and has still additional demand - // But only if the house not reached some requiredDemand. Hp must still run for this. - qDotStorageLastState > zeroKW && thermalDemands.heatStorageDemand.hasAdditionalDemand && !thermalDemands.houseDemand.hasRequiredDemand && isRunning) + !houseReachedBoundary && !houseLeftBoundary && + !thermalStorageReachedBoundary && !thermalStorageLeftBoundary && + !domesticHotWaterStorageReachedBoundary && !domesticHotWaterStorageLeftBoundary ) { - // We can continue for the house - val (updatedHouseState, thermalHouseThreshold, remainingQDotHouse) = - handleInfeedHouse( - relevantData, - lastAmbientTemperature, - lastThermalGridState, - qDotHouseLastState, - ) - - // ...and for the storage - val (updatedStorageState, thermalStorageThreshold) = { - // In case the ThermalHouse could not handle the infeed it will be used for the storage. - if (remainingQDotHouse > qDotStorageLastState) { - handleStorageCases( - relevantData.currentTick, - lastThermalGridState, - remainingQDotHouse, - ) - } else { - handleStorageCases( - relevantData.currentTick, - lastThermalGridState, - qDotStorageLastState, - ) - } - } - - val nextThreshold = determineMostRecentThreshold( - thermalHouseThreshold, - thermalStorageThreshold, - ) - ( - lastThermalGridState.copy( - houseState = updatedHouseState, - storageState = updatedStorageState, - ), - nextThreshold, - ) - } - // Handle edge case where house was heated from storage... - else if (qDotHouseLastState > zeroKW && qDotStorageLastState < zeroKW) { - // ...and HP gets activated in current tick - if (isRunning) { - handleCases( - relevantData, - lastAmbientTemperature, - lastThermalGridState, - qDot, - zeroKW, - ) - } else { - // ... or continue lastState's behaviour - handleCases( - relevantData, - lastAmbientTemperature, - lastThermalGridState, - qDotHouseLastState, - qDotStorageLastState, - ) - } - } - // Handle edge case where house should be heated from storage - else if (!isRunning && qDot > zeroKW) { handleCases( relevantData, lastAmbientTemperature, lastThermalGridState, - qDot, - -qDot, + qDotHouseLastState.getOrElse(zeroKW), + qDotStorageLastState.getOrElse(zeroKW), + if (domesticHotWaterStorageDemand.hasRequiredDemand) + qDotDomesticWaterStorageLastState.getOrElse(zeroKW) + else zeroKW, ) - } - // or finally check for all other cases. - else + } else { handleFinalInfeedCases( thermalDemands, relevantData, lastAmbientTemperature, lastThermalGridState, qDot, + updatedHeatStorageState, + qDotHouseLastState, + qDotStorageLastState, + qDotDomesticWaterStorageLastState, + domesticHotWaterStorageLeftBoundary, + isRunning, ) + } } /** Handles the last cases of [[ThermalGrid.handleInfeed]], where the thermal - * infeed 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 | + * infeed should be determined. * + * | No | Conditions | Result | + * |:---|:--------------------------------|:---------------------------------------------------------------------------------------------| + * | 1 | if WStorage.reqD AND house.reqD | if HStorage !empty: qDot to WStorage + HStorage to House else split qDot to WStorage + House | + * | 2 | if house.reqD | if HStorage !empty: HStorage to House else qDot to House | + * | 3 | if WStorage.reqD | qDot to WStorage | + * | 4 | if HStorage.reqD | qDot to HStorage | + * | 5 | else only additionalDemands | | + * | 5a | continue as before | qDot as in lastState | + * | 5b | if HStorage.addD | qDot to HStorage | + * | 5c | else | qDot to House | * * @param thermalDemands * holds the thermal demands of the thermal units (house, storage). @@ -354,6 +373,19 @@ final case class ThermalGrid( * @param qDot * Infeed to the grid from thermal generation (e.g. heat pump) or thermal * storages. + * @param updatedHeatStorageState + * Updated state of the heat storage. + * @param qDotHouseLastState + * Thermal infeed into the house at the last state. + * @param qDotStorageLastState + * Thermal infeed into the heat storage at the last state. + * @param qDotDomesticWaterStorageLastState + * Thermal infeed into the domestic hot water storage at the last state. + * @param domesticHotWaterStorageLeftBoundary + * Indicates if the domestic hot water storage has left on of its + * boundaries (if true, its neither full or empty). + * @param isRunning + * Determines whether the heat pump is running or not. * @return * Updated thermal grid state and the thermalThreshold if there is one. */ @@ -363,43 +395,418 @@ final case class ThermalGrid( lastAmbientTemperature: Temperature, gridState: ThermalGridState, qDot: Power, + updatedHeatStorageState: Option[ThermalStorageState], + qDotHouseLastState: Option[Power], + qDotStorageLastState: Option[Power], + qDotDomesticWaterStorageLastState: Option[Power], + domesticHotWaterStorageLeftBoundary: Boolean, + isRunning: Boolean, ): (ThermalGridState, Option[ThermalThreshold]) = { + ( + thermalDemands.houseDemand.hasRequiredDemand, + thermalDemands.houseDemand.hasAdditionalDemand, + thermalDemands.heatStorageDemand.hasRequiredDemand, + thermalDemands.heatStorageDemand.hasAdditionalDemand, + thermalDemands.domesticHotWaterStorageDemand.hasRequiredDemand, + thermalDemands.domesticHotWaterStorageDemand.hasAdditionalDemand, + ) match { + case (true, _, _, _, true, _) => + updatedHeatStorageState match { + // if heatStorage is not empty, house and hot water storage have reqDemand + // take qDot to recharge domesticHotWaterStorage and + // cover thermal demand of house by heatStorage + case Some(storageState) if storageState.storedEnergy > zeroKWh => + handleCases( + relevantData, + lastAmbientTemperature, + gridState, + heatStorage.map(_.getpThermalMax).getOrElse(zeroKW), + heatStorage.map(_.getpThermalMax).getOrElse(zeroKW) * (-1), + qDot, + ) + // else split qDot into house and domesticHotWaterStorage + case _ => + splitThermalHeatAndPushIntoHouseAndDomesticStorage( + relevantData, + lastAmbientTemperature, + gridState, + qDotHouseLastState, + qDotDomesticWaterStorageLastState, + qDot, + ) + } - if (thermalDemands.houseDemand.hasRequiredDemand) - handleCases( - relevantData, - lastAmbientTemperature, - gridState, - qDot, - zeroKW, - ) - else if ( - thermalDemands.heatStorageDemand.hasRequiredDemand || thermalDemands.heatStorageDemand.hasAdditionalDemand + case (true, _, _, _, false, _) => + // if there is a heatStorage that isn't empty, take energy from storage + updatedHeatStorageState match { + case Some(storageState) + if (storageState.storedEnergy > zeroKWh && !isRunning) => + handleCases( + relevantData, + lastAmbientTemperature, + gridState, + heatStorage.map(_.getpThermalMax).getOrElse(zeroKW), + heatStorage.map(_.getpThermalMax).getOrElse(zeroKW) * (-1), + zeroKW, + ) + case _ => + pushThermalHeatIntoHouseOnly( + relevantData, + lastAmbientTemperature, + gridState, + qDot, + ) + } + // Prioritize domestic hot water storage + // Same case if there is Some(heatStorageDemand) or not + case (false, _, _, _, true, _) => + pushThermalHeatIntoDomesticHotWaterStorageOnly( + relevantData, + lastAmbientTemperature, + gridState, + qDot, + ) + + // No reqDemand of house or domestic hot water storage but of thermal storage + case (false, _, true, _, false, _) => + pushThermalHeatIntoThermalStorageOnly( + relevantData, + lastAmbientTemperature, + gridState, + qDot, + ) + // all cases for required demands are handled now + // now take last action into account + case _ => + // House and domestic hot water storage can only have additional demand now + domesticHotWaterStorageLeftBoundary match { + case true => + handleCases( + relevantData, + lastAmbientTemperature, + gridState, + qDotHouseLastState.getOrElse(zeroKW), + qDotStorageLastState.getOrElse(zeroKW), + zeroKW, + ) + // if storage has additional demand charge it before heating the house + case _ => + if (thermalDemands.heatStorageDemand.hasAdditionalDemand) + handleCases( + relevantData, + lastAmbientTemperature, + gridState, + zeroKW, + qDot, + zeroKW, + ) + else { + handleCases( + relevantData, + lastAmbientTemperature, + gridState, + qDot, + zeroKW, + zeroKW, + ) + } + } + + } + } + + /** Method that updates the state of thermal house, get its last thermal + * action (qDot) and if it reached or left any boundaries this tick. + * + * @param relevantData + * data of heat pump including state of the heat pump + * @param state + * Current state of the houses + * @param lastAmbientTemperature + * Ambient temperature valid up until (not including) the current tick + * @return + * Option of the updated house state, option of the last qDot and Booleans + * if some boundaries are reached or left. + */ + + private def updateStateGetLastThermalActionAndCheckIfCanContinueThermalHouse( + relevantData: HpRelevantData, + state: ThermalGridState, + lastAmbientTemperature: Temperature, + ): (Option[ThermalHouseState], Option[Power], Boolean, Boolean) = { + state match { + case ThermalGridState( + Some(_), + _, + _, + ) => + (house, state.houseState) match { + case (Some(thermalHouse), Some(lastHouseState)) => + val (newState, _) = thermalHouse.updateState( + relevantData, + lastHouseState, + lastAmbientTemperature, + lastHouseState.qDot, + ) + val houseReachedBoundary = thermalHouse.isInnerTemperatureTooHigh( + newState.innerTemperature + ) || thermalHouse.isInnerTemperatureTooLow( + newState.innerTemperature + ) + val houseLeftBoundary = (thermalHouse.isInnerTemperatureTooHigh( + lastHouseState.innerTemperature + ) && !thermalHouse.isInnerTemperatureTooHigh( + newState.innerTemperature + )) || (thermalHouse.isInnerTemperatureTooLow( + newState.innerTemperature + ) && !thermalHouse.isInnerTemperatureTooLow( + newState.innerTemperature + )) + ( + Some(newState), + Some(lastHouseState.qDot), + houseReachedBoundary, + houseLeftBoundary, + ) + } + case ThermalGridState( + None, + _, + _, + ) => + (None, None, false, false) + } + } + + /** Method that updates the state of thermal storage, get its last thermal + * action (qDot) and if it reached or left any boundaries this tick. + * + * @param tick + * Current tick + * @param state + * Current state of the houses + * @return + * Option of the updated storage state, option of the last qDot and + * Booleans if some boundaries are reached or left. + */ + + private def updateStateGetLastThermalActionAndCheckIfCanContinueThermalStorage( + tick: Long, + state: ThermalGridState, + ): (Option[ThermalStorageState], Option[Power], Boolean, Boolean) = { + getLastThermalActionAndCheckIfCanContinueStorage( + tick, + state, + heatStorage, + state.storageState, ) - handleCases( - relevantData, - lastAmbientTemperature, - gridState, - zeroKW, - qDot, - ) - else if (thermalDemands.houseDemand.hasAdditionalDemand) - handleCases( - relevantData, - lastAmbientTemperature, - gridState, - qDot, - zeroKW, - ) - else - handleCases( - relevantData, - lastAmbientTemperature, - gridState, - zeroKW, - zeroKW, - ) + } + + /** Method that updates the state of domestic hot water storage, get its last + * thermal action (qDot) and if it reached or left any boundaries this tick. + * + * @param tick + * Current tick + * @param state + * Current state of the houses + * @return + * Option of the updated storage state, option of the last qDot and + * Booleans if some boundaries are reached or left. + */ + private def updateStateGetLastThermalActionAndCheckIfCanContinueDomesticHotWaterStorage( + tick: Long, + state: ThermalGridState, + ): (Option[ThermalStorageState], Option[Power], Boolean, Boolean) = { + getLastThermalActionAndCheckIfCanContinueStorage( + tick, + state, + domesticHotWaterStorage, + state.domesticHotWaterStorageState, + ) + } + + /** Abstract method that updates the state of thermal storage, get its last + * thermal action (qDot) and if it reached or left any boundaries this tick. + * @param tick + * Current tick + * @param state + * Current state of the houses + * @param storage + * The storage handled here + * @param lastStorageState + * Last state of the storage + * @return + * Option of the updated storage state, option of the last qDot and + * Booleans if some boundaries are reached or left. + */ + + private def getLastThermalActionAndCheckIfCanContinueStorage( + tick: Long, + state: ThermalGridState, + storage: Option[ThermalStorage], + lastStorageState: Option[ThermalStorageState], + ): (Option[ThermalStorageState], Option[Power], Boolean, Boolean) = { + (storage, lastStorageState) match { + case (Some(storage), Some(lastStorageState)) => + val (newState, _) = storage.updateState( + tick, + lastStorageState.qDot, + lastStorageState, + ) + + val storageReachedBorder = + (newState.storedEnergy == storage.getMaxEnergyThreshold) || + (newState.storedEnergy == zeroKWh) + + val storageLeftBorder = + (lastStorageState.storedEnergy == storage.getMaxEnergyThreshold && newState.storedEnergy != zeroKWh) || + (lastStorageState.storedEnergy == zeroKWh && newState.storedEnergy != zeroKWh) + ( + Some(newState), + Some(lastStorageState.qDot), + storageReachedBorder, + storageLeftBorder, + ) + case _ => + (None, None, false, false) + } + } + + /** Helper method to push energy directly into the thermal house + * + * @param relevantData + * data of heat pump including state of the heat pump + * @param lastAmbientTemperature + * Ambient temperature valid up until (not including) the current tick + * @param state + * Current state of the houses + * @param qDot + * Infeed to the grid + * @return + */ + + private def pushThermalHeatIntoHouseOnly( + relevantData: HpRelevantData, + lastAmbientTemperature: Temperature, + state: ThermalGridState, + qDot: Power, + ) = { + handleCases( + relevantData, + lastAmbientTemperature, + state, + qDot, + zeroKW, + zeroKW, + ) + } + + /** Helper method to push energy directly into the thermal heat storage + * + * @param relevantData + * data of heat pump including state of the heat pump + * @param lastAmbientTemperature + * Ambient temperature valid up until (not including) the current tick + * @param state + * Current state of the houses + * @param qDot + * Infeed to the grid + * @return + */ + + private def pushThermalHeatIntoThermalStorageOnly( + relevantData: HpRelevantData, + lastAmbientTemperature: Temperature, + state: ThermalGridState, + qDot: Power, + ) = { + handleCases( + relevantData, + lastAmbientTemperature, + state, + zeroKW, + qDot, + zeroKW, + ) + } + + /** Helper method to push energy directly into the domestic hot water storage + * + * @param relevantData + * data of heat pump including state of the heat pump + * @param lastAmbientTemperature + * Ambient temperature valid up until (not including) the current tick + * @param state + * Current state of the houses + * @param qDot + * Infeed to the grid + * @return + */ + + private def pushThermalHeatIntoDomesticHotWaterStorageOnly( + relevantData: HpRelevantData, + lastAmbientTemperature: Temperature, + state: ThermalGridState, + qDot: Power, + ) = { + handleCases( + relevantData, + lastAmbientTemperature, + state, + zeroKW, + zeroKW, + qDot, + ) + } + + /** Helper method to split qDot of the heat pump and push energy directly into + * house and the domestic hot water storage + * + * @param relevantData + * data of heat pump including state of the heat pump + * @param lastAmbientTemperature + * Ambient temperature valid up until (not including) the current tick + * @param state + * Current state of the houses + * @param qDotHouseLastState + * The infeed into the thermal house in the last state + * @param qDotDomesticHotWaterStorageLastState + * The infeed into the domestic hot water storage in the last state + * @param qDot + * Infeed to the grid + * @return + */ + + private def splitThermalHeatAndPushIntoHouseAndDomesticStorage( + relevantData: HpRelevantData, + lastAmbientTemperature: Temperature, + state: ThermalGridState, + qDotHouseLastState: Option[Power], + qDotDomesticHotWaterStorageLastState: Option[Power], + qDot: Power, + ) = { + val qDotDomesticHotWaterStorage: Power = + if ( + (qDot / 2) > domesticHotWaterStorage + .map(_.getpThermalMax) + .getOrElse(Kilowatts(0d)) + ) { + domesticHotWaterStorage + .map(_.getpThermalMax) + .getOrElse(Kilowatts(0d)) + } else { + qDot / 2 + } + + handleCases( + relevantData, + lastAmbientTemperature, + state, + qDot - qDotDomesticHotWaterStorage, + zeroKW, + qDotDomesticHotWaterStorage, + ) } /** Handles the different cases, of thermal flows from and into the thermal @@ -408,7 +815,7 @@ final case class ThermalGrid( * @param relevantData * data of heat pump including state of the heat pump. * @param lastAmbientTemperature - * Ambient temperature until this tick. + * Ambient temperature valid up until (not including) the current tick. * @param state * Current state of the thermal grid. * @param qDotHouse @@ -416,6 +823,8 @@ final case class ThermalGrid( * @param qDotHeatStorage * Infeed to the heat storage (positive: Storage is charging, negative: * Storage is discharging). + * @param qDotDomesticHotWaterStorage + * Infeed to the domestic hot water storage. * @return * Updated thermal grid state and the next threshold if there is one. */ @@ -425,8 +834,10 @@ final case class ThermalGrid( state: ThermalGridState, qDotHouse: Power, qDotHeatStorage: Power, + qDotDomesticHotWaterStorage: Power, ): (ThermalGridState, Option[ThermalThreshold]) = { - val (updatedHouseState, thermalHouseThreshold, _) = + // FIXME: Is there any case where we get back some remainingQDotHouse? + val (updatedHouseState, thermalHouseThreshold, remainingQDotHouse) = handleInfeedHouse( relevantData, lastAmbientTemperature, @@ -435,17 +846,31 @@ final case class ThermalGrid( ) val (updatedStorageState, thermalStorageThreshold) = - handleStorageCases(relevantData.currentTick, state, qDotHeatStorage) + handleStorageCases(relevantData, state, qDotHeatStorage, heatStorage) + + val ( + updatedDomesticHotWaterStorageState, + domesticHotWaterStorageThreshold, + ) = handleStorageCases( + relevantData, + state, + qDotDomesticHotWaterStorage, + domesticHotWaterStorage, + ) val nextThreshold = determineMostRecentThreshold( - thermalHouseThreshold, - thermalStorageThreshold, + Seq( + thermalHouseThreshold, + thermalStorageThreshold, + domesticHotWaterStorageThreshold, + ) ) ( state.copy( houseState = updatedHouseState, storageState = updatedStorageState, + domesticHotWaterStorageState = updatedDomesticHotWaterStorageState, ), nextThreshold, ) @@ -457,7 +882,7 @@ final case class ThermalGrid( * @param relevantData * Data of heat pump including state of the heat pump. * @param lastAmbientTemperature - * Ambient temperature until this tick. + * Ambient temperature valid up until (not including) the current tick. * @param state * Current state of the houses. * @param qDotHouse @@ -502,58 +927,158 @@ final case class ThermalGrid( /** Handles the cases, 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). - * @param tick - * Current tick. + * grid (negative qDot). Same if the storage is used for domestic hot water. + * Positive qDot will fill the storage, negative will cover the demand. + * + * @param relevantData + * data of heat pump including state of the heat pump. * @param state * Current state of the houses. * @param qDotStorage * Infeed to the storage (positive: Storage is charging, negative: Storage * is discharging). + * @param storage + * the storage that should be handled. * @return * Updated thermal grid state. */ private def handleStorageCases( - tick: Long, + relevantData: HpRelevantData, state: ThermalGridState, qDotStorage: Power, + storage: Option[ThermalStorage], ): (Option[ThermalStorageState], Option[ThermalThreshold]) = { - (heatStorage, state.storageState) match { - case (Some(thermalStorage), Some(lastStorageState)) => + // 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.storageState + case Some(_: DomesticHotWaterStorage) => + state.domesticHotWaterStorageState + case _ => None + } + + (storage, selectedState) match { + case ( + Some(domesticHotWaterStorage: DomesticHotWaterStorage), + Some(lastDomesticHotWaterStorageState), + ) => + val storedEnergy = domesticHotWaterStorage + .updateState( + relevantData.currentTick, + lastDomesticHotWaterStorageState.qDot, + lastDomesticHotWaterStorageState, + ) + ._1 + .storedEnergy + + val domesticHotWaterDemand = house + .map( + _.energyDemandDomesticHotWater( + relevantData, + state.houseState, + ) + ) + .getOrElse(ThermalEnergyDemand(zeroKWh, zeroKWh)) + + val (applicableQDotDomesticStorage, thresholdToCoverDemand) = + domesticHotWaterDemand match { + case demand + if demand.required > zeroKWh && storedEnergy == zeroKWh => + // Use qDot from Hp directly to cover hot water demand + val threshold = Some( + SimpleThermalThreshold( + (domesticHotWaterDemand.required / qDotStorage).toSeconds.toLong + ) + ) + (zeroKW, threshold) + case demand + if demand.required > zeroKWh && storedEnergy > zeroKWh => + // Use storage to cover hot water demand + identifyApplicableQDot(relevantData.currentTick, demand) + + case demand + if demand.required == zeroKWh && lastDomesticHotWaterStorageState.qDot > zeroKW && storedEnergy != domesticHotWaterStorage.maxEnergyThreshold => + // Storage got recharged in the last state + // Threshold will be calculated later + (lastDomesticHotWaterStorageState.qDot, None) + case demand if demand.required == zeroKWh & qDotStorage == zeroKW => + // Don't do anything with domestic hot water storage + (zeroKW, None) + case demand if demand.required == zeroKWh & qDotStorage > zeroKW => + // Use qDot from Hp to recharge domestic hot water storage + // Threshold will be calculated later + (qDotStorage, None) + case demand if demand.required == zeroKWh & qDotStorage < zeroKW => + // Storage was discharging but demand is now covered. Set output to zeroKw + (zeroKW, None) + + case _ => + throw new RuntimeException( + s"Unexpected case occur when try to handle infeed into domestic hot water storage ${domesticHotWaterStorage.uuid}." + ) + } + + val (updatedStorageState, updatedThreshold) = + domesticHotWaterStorage + .updateState( + relevantData.currentTick, + applicableQDotDomesticStorage, + lastDomesticHotWaterStorageState, + ) + + val nextThreshold = determineMostRecentThreshold( + Seq(updatedThreshold, thresholdToCoverDemand) + ) + ( + Some(updatedStorageState), + nextThreshold, + ) + + case ( + Some(thermalStorage: CylindricalThermalStorage), + Some(lastStorageState), + ) => val (newState, threshold) = thermalStorage.updateState( - tick, + relevantData.currentTick, qDotStorage, lastStorageState, ) (Some(newState), threshold) + case _ => (None, 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 infeed) from thermal grid. * * @param relevantData @@ -565,6 +1090,10 @@ final case class ThermalGrid( * @param qDot * Infeed to the grid from thermal generation (e.g. heat pump) or thermal * storages. + * @param simulationStartTime + * simulationStartDate as ZonedDateTime. + * @param houseInhabitants + * number of people living in the building. * @return * Updated thermal grid state. */ @@ -582,7 +1111,7 @@ final case class ThermalGrid( relevantData, houseState, lastAmbientTemperature, - zeroMW, + zeroKW, ) } @@ -604,20 +1133,100 @@ final case class ThermalGrid( qDot, ) + heatStorage.zip(lastThermalGridState.storageState).map { + case (storage, storageState) => + storage.updateState(relevantData.currentTick, qDot, storageState) + } + + val domesticHotWaterDemand = house + .map( + _.energyDemandDomesticHotWater( + relevantData, + lastThermalGridState.houseState, + ) + ) + .getOrElse(ThermalEnergyDemand(zeroKWh, zeroKWh)) + + val ( + qDotDomesticHotWaterDemand, + tickWhenStorageDemandEnds, + ) = + identifyApplicableQDot(relevantData.currentTick, domesticHotWaterDemand) + + val ( + updatedDomesticHotWaterStorageState, + domesticHotWaterStorageThreshold, + ) = handleStorageCases( + relevantData, + lastThermalGridState, + qDotDomesticHotWaterDemand, + domesticHotWaterStorage, + ) + + val nextThresholdHotWaterStorage = determineMostRecentThreshold( + Seq(tickWhenStorageDemandEnds, domesticHotWaterStorageThreshold) + ) + val nextThreshold = determineMostRecentThreshold( - revisedHouseState.flatMap(_._2), - revisedStorageState.flatMap(_._2), + Seq( + revisedHouseState.flatMap(_._2), + revisedStorageState.flatMap(_._2), + nextThresholdHotWaterStorage, + ) ) ( lastThermalGridState.copy( houseState = revisedHouseState.map(_._1), storageState = revisedStorageState.map(_._1), + domesticHotWaterStorageState = updatedDomesticHotWaterStorageState, ), nextThreshold, ) } + private def identifyApplicableQDot( + tick: Long, + domesticHotWaterDemand: ThermalEnergyDemand, + ): (Power, Option[SimpleThermalThreshold]) = { + + 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(tick + math.round(threshold.toSeconds)) + ), + ) + } else { + ( + (-1) * domesticHotWaterDemand.required / Seconds(1d), + Some(SimpleThermalThreshold(tick + 1)), + ) + } + } else { + (zeroKW, None) + } + } + /** Check, if the storage can heat the house. This is only done, if