diff --git a/CHANGELOG.md b/CHANGELOG.md index 28934b1028..79fec54c7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Create `CITATION.cff` [#1035](https://github.com/ie3-institute/simona/issues/1035) - Introduce ThermalDemandWrapper [#1049](https://github.com/ie3-institute/simona/issues/1049) - Added Marius Staudt to list of reviewers [#1057](https://github.com/ie3-institute/simona/issues/1057) +- Integration test for thermal grids [#878](https://github.com/ie3-institute/simona/issues/878) ### Changed - Adapted to changed data source in PSDM [#435](https://github.com/ie3-institute/simona/issues/435) @@ -155,6 +156,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Correct wrong use of term "wall clock time" [#727](https://github.com/ie3-institute/simona/issues/727) - Fixed Deployment of `simona` to `Maven Central` in new GHA Pipeline [#1029](https://github.com/ie3-institute/simona/issues/1029) - Fixed SonarQube quality gate using the right link for PRs or Branches [#1061](https://github.com/ie3-institute/simona/issues/1061) +- Refactoring of `ThermalGrid.handleInfeed` to fix thermal storage recharge correctly when empty [#930](https://github.com/ie3-institute/simona/issues/930) ## [3.0.0] - 2023-08-07 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 25d8e1d92a..17298208c6 100644 --- a/src/main/scala/edu/ie3/simona/model/participant/HpModel.scala +++ b/src/main/scala/edu/ie3/simona/model/participant/HpModel.scala @@ -112,7 +112,12 @@ final case class HpModel( tick: Long, currentState: HpState, data: HpRelevantData, - ): Power = currentState.qDot + ): Power = + // only if Hp is running qDot will be from Hp, else qDot results from other source, e.g. some storage + if (currentState.isRunning) + currentState.qDot + else + zeroKW /** Given a [[HpRelevantData]] object and the last [[HpState]], this function * calculates the heat pump's next state to get the actual active power of @@ -149,7 +154,7 @@ final case class HpModel( // Updating the HpState val updatedState = - calcState(lastHpState, relevantData, turnOn) + calcState(lastHpState, relevantData, turnOn, thermalDemandWrapper) (canOperate, canBeOutOfOperation, updatedState) } @@ -212,6 +217,8 @@ 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) * @return * next [[HpState]] */ @@ -219,17 +226,26 @@ final case class HpModel( lastState: HpState, relevantData: HpRelevantData, isRunning: Boolean, + demandWrapper: ThermalDemandWrapper, ): HpState = { val lastStateStorageQDot = lastState.thermalGridState.storageState .map(_.qDot) .getOrElse(zeroKW) - val (newActivePower, newThermalPower) = + val (newActivePower, newThermalPower) = { if (isRunning) (pRated, pThermal) else if (lastStateStorageQDot < zeroKW) - (zeroKW, lastStateStorageQDot * -1) + (zeroKW, lastStateStorageQDot * (-1)) + else if ( + lastStateStorageQDot == zeroKW && (demandWrapper.houseDemand.hasRequiredDemand || demandWrapper.heatStorageDemand.hasRequiredDemand) + ) + ( + zeroKW, + thermalGrid.storage.map(_.getChargingPower: squants.Power).get, + ) else (zeroKW, zeroKW) + } /* Push thermal energy to the thermal grid and get its updated state in return */ val (thermalGridState, maybeThreshold) = @@ -237,7 +253,9 @@ final case class HpModel( relevantData, lastState.thermalGridState, lastState.ambientTemperature.getOrElse(relevantData.ambientTemperature), + isRunning, newThermalPower, + demandWrapper, ) HpState( @@ -285,7 +303,7 @@ final case class HpModel( * operating state and give back the next tick in which something will * change. * - * @param data + * @param relevantData * Relevant data for model calculation * @param lastState * The last known model state @@ -296,17 +314,27 @@ final case class HpModel( * options will change next */ override def handleControlledPowerChange( - data: HpRelevantData, + relevantData: HpRelevantData, lastState: HpState, setPower: Power, ): (HpState, FlexChangeIndicator) = { /* If the set point value is above 50 % of the electrical power, turn on the heat pump otherwise turn it off */ val turnOn = setPower > (sRated.toActivePower(cosPhiRated) * 0.5) + val ( + thermalDemandWrapper, + _, + ) = + thermalGrid.energyDemandAndUpdatedState( + relevantData, + lastState, + ) + val updatedHpState = calcState( lastState, - data, + relevantData, turnOn, + thermalDemandWrapper, ) ( 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 e49dde2633..13145f2c7a 100644 --- a/src/main/scala/edu/ie3/simona/model/thermal/CylindricalThermalStorage.scala +++ b/src/main/scala/edu/ie3/simona/model/thermal/CylindricalThermalStorage.scala @@ -187,8 +187,10 @@ object CylindricalThermalStorage { input: CylindricalStorageInput, initialStoredEnergy: Energy = DefaultQuantities.zeroKWh, ): CylindricalThermalStorage = { - val minEnergyThreshold: Energy = - CylindricalThermalStorage.volumeToEnergy( + val minEnergyThreshold: Energy = { + // Temporary fix until changes in PSDM are released, Some minimumEnergyThreshold would lead to non-plausible behaviour + zeroKWh + /*CylindricalThermalStorage.volumeToEnergy( CubicMeters( input.getStorageVolumeLvlMin .to(Units.CUBIC_METRE) @@ -204,6 +206,8 @@ object CylindricalThermalStorage { Celsius(input.getInletTemp.to(Units.CELSIUS).getValue.doubleValue()), Celsius(input.getReturnTemp.to(Units.CELSIUS).getValue.doubleValue()), ) + */ + } val maxEnergyThreshold: Energy = CylindricalThermalStorage.volumeToEnergy( 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 4a118c3481..58950faeb4 100644 --- a/src/main/scala/edu/ie3/simona/model/thermal/ThermalGrid.scala +++ b/src/main/scala/edu/ie3/simona/model/thermal/ThermalGrid.scala @@ -13,6 +13,7 @@ import edu.ie3.datamodel.models.result.thermal.{ CylindricalStorageResult, ThermalHouseResult, } +import edu.ie3.simona.exceptions.InvalidParameterException import edu.ie3.simona.exceptions.agent.InconsistentStateException import edu.ie3.simona.model.participant.HpModel.{HpRelevantData, HpState} import edu.ie3.simona.model.thermal.ThermalGrid.{ @@ -151,8 +152,12 @@ final case class ThermalGrid( * Currently applicable state * @param lastAmbientTemperature * Ambient temperature valid up until (not including) the current tick + * @param isRunning + * determines whether the heat pump is running or not * @param qDot * Thermal energy balance + * @param thermalDemands + * holds the thermal demands of the thermal units (house, storage) * @return * The updated state of the grid */ @@ -160,9 +165,18 @@ final case class ThermalGrid( relevantData: HpRelevantData, state: ThermalGridState, lastAmbientTemperature: Temperature, + isRunning: Boolean, qDot: Power, + thermalDemands: ThermalDemandWrapper, ): (ThermalGridState, Option[ThermalThreshold]) = if (qDot > zeroKW) - handleInfeed(relevantData, lastAmbientTemperature, state, qDot) + handleInfeed( + relevantData, + lastAmbientTemperature, + state, + isRunning, + qDot, + thermalDemands, + ) else handleConsumption( relevantData, @@ -171,8 +185,8 @@ final case class ThermalGrid( qDot, ) - /** Handles the case, when a grid has infeed. First, heat up all the houses to - * their maximum temperature, then fill up the storages + /** Handles the case, when a grid has infeed. Depending which entity has some + * heat demand the house or the storage will be heated up / filled up. * * @param relevantData * data of heat pump including state of the heat pump @@ -180,8 +194,12 @@ final case class ThermalGrid( * Ambient temperature valid up until (not including) the current tick * @param state * Current state of the houses + * @param isRunning + * determines whether the heat pump is running or not * @param qDot * Infeed to the grid + * @param thermalDemands + * holds the thermal demands of the thermal units (house, storage) * @return * Updated thermal grid state */ @@ -189,40 +207,283 @@ final case class ThermalGrid( relevantData: HpRelevantData, lastAmbientTemperature: Temperature, state: ThermalGridState, + isRunning: Boolean, qDot: Power, - ): (ThermalGridState, Option[ThermalThreshold]) = - house.zip(state.houseState) match { - case Some((thermalHouse, lastHouseState)) => - /* Set thermal power exchange with storage to zero */ - // TODO: We would need to issue a storage result model here... - val updatedStorageState = storage.zip(state.storageState) match { - case Some((thermalStorage, storageState)) => - Some( - thermalStorage - .updateState( - relevantData.currentTick, - zeroKW, - storageState, - ) - ._1 - ) - case _ => state.storageState - } + thermalDemands: ThermalDemandWrapper, + ): (ThermalGridState, Option[ThermalThreshold]) = { + // TODO: We would need to issue a storage result model here... + + /* Consider the action in the last state */ + val (qDotHouseLastState, qDotStorageLastState) = state match { + case ThermalGridState(Some(houseState), Some(storageState)) => + (houseState.qDot, storageState.qDot) + case ThermalGridState(Some(houseState), None) => (houseState.qDot, zeroKW) + case ThermalGridState(None, Some(storageState)) => + (zeroKW, storageState.qDot) + case _ => + throw new InconsistentStateException( + "There should be at least a house or a storage state." + ) + } - val (updatedHouseState, maybeHouseThreshold) = - thermalHouse.determineState( - relevantData, - lastHouseState, - lastAmbientTemperature, - qDot, + if ( + (qDotHouseLastState > zeroKW && (qDotStorageLastState >= zeroKW)) | (qDotStorageLastState > zeroKW && thermalDemands.heatStorageDemand.hasAdditionalDemand) + ) { + val (updatedHouseState, thermalHouseThreshold, remainingQDotHouse) = + handleInfeedHouse( + relevantData, + lastAmbientTemperature, + state, + qDotHouseLastState, + ) + val (updatedStorageState, thermalStorageThreshold) = + if ( + qDotStorageLastState >= zeroKW && remainingQDotHouse > qDotStorageLastState + ) { + handleInfeedStorage( + relevantData.currentTick, + state, + remainingQDotHouse, + ) + } else { + handleInfeedStorage( + relevantData.currentTick, + state, + qDotStorageLastState, ) + } + val nextThreshold = determineMostRecentThreshold( + thermalHouseThreshold, + thermalStorageThreshold, + ) + ( + state.copy( + houseState = updatedHouseState, + storageState = updatedStorageState, + ), + nextThreshold, + ) + } + // Handle edge case where house was heated from storage and HP will be activated in between + else if (qDotHouseLastState > zeroKW && qDotStorageLastState < zeroKW) { + if (isRunning) { + handleCases( + relevantData, + lastAmbientTemperature, + state, + qDot, + zeroKW, + ) + } else { + + handleCases( + relevantData, + lastAmbientTemperature, + state, + qDotHouseLastState, + qDotStorageLastState, + ) + } + } + // Handle edge case where house should be heated from storage + else if (!isRunning && qDot > zeroKW) { + handleCases( + relevantData, + lastAmbientTemperature, + state, + qDot, + -qDot, + ) + } else + handleFinaleInfeedCases( + thermalDemands, + relevantData, + lastAmbientTemperature, + state, + qDot, + ) + } + + /** 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 five cases + * | No | Conditions | Result | + * |:---|:-------------------------------------------------------------------------|:----------| + * | 1 | if(house.reqD) => house | house | + * | 2 | if(!house.reqD && !storage.reqD) => storage | storage | + * | 3 | if(!house.reqD && !storage.reqD && storage.addD) => storage | storage | + * | 4 | if(!house.reqD && house.addD && !storage.reqD && !storage.addD) => house | house | + * | 5 | if(all == false) => no output | no output | + */ + private def handleFinaleInfeedCases( + thermalDemands: ThermalDemandWrapper, + relevantData: HpRelevantData, + lastAmbientTemperature: Temperature, + state: ThermalGridState, + qDot: Power, + ): (ThermalGridState, Option[ThermalThreshold]) = { + ( + thermalDemands.houseDemand.hasRequiredDemand, + thermalDemands.houseDemand.hasAdditionalDemand, + thermalDemands.heatStorageDemand.hasRequiredDemand, + thermalDemands.heatStorageDemand.hasAdditionalDemand, + ) match { + + case (true, _, _, _) => + // house first then heatStorage after heating House + handleCases( + relevantData, + lastAmbientTemperature, + state, + qDot, + zeroKW, + ) + + case (_, _, true, _) => + handleCases( + relevantData, + lastAmbientTemperature, + state, + zeroKW, + qDot, + ) + + case (false, _, false, true) => + handleCases( + relevantData, + lastAmbientTemperature, + state, + zeroKW, + qDot, + ) + + case (_, true, false, false) => + handleCases( + relevantData, + lastAmbientTemperature, + state, + qDot, + zeroKW, + ) + + case (false, false, false, false) => + handleCases( + relevantData, + lastAmbientTemperature, + state, + zeroKW, + zeroKW, + ) + case _ => + throw new InconsistentStateException( + "There should be at least a house or a storage state." + ) + } + } + + /** Handles the different cases, of thermal flows from and into the thermal + * grid. + * + * @param relevantData + * data of heat pump including state of the heat pump + * @param lastAmbientTemperature + * Ambient temperature until this tick + * @param state + * Current state of the thermal grid + * @param qDotHouse + * Infeed to the house + * @param qDotHeatStorage + * Infeed to the heat storage + * @return + * Updated thermal grid state and the next threshold if there is one + */ + private def handleCases( + relevantData: HpRelevantData, + lastAmbientTemperature: Temperature, + state: ThermalGridState, + qDotHouse: Power, + qDotHeatStorage: Power, + ): (ThermalGridState, Option[ThermalThreshold]) = { + val (updatedHouseState, thermalHouseThreshold, _) = + handleInfeedHouse( + relevantData, + lastAmbientTemperature, + state, + qDotHouse, + ) + + val (updatedStorageState, thermalStorageThreshold) = + handleInfeedStorage(relevantData.currentTick, state, qDotHeatStorage) + + val nextThreshold = determineMostRecentThreshold( + thermalHouseThreshold, + thermalStorageThreshold, + ) + + ( + state.copy( + houseState = updatedHouseState, + storageState = updatedStorageState, + ), + nextThreshold, + ) + } + + /** Handles the case, when the house has heat demand and will be heated up + * here. + * + * @param relevantData + * data of heat pump including state of the heat pump + * @param lastAmbientTemperature + * Ambient temperature until this tick + * @param state + * Current state of the houses + * @param qDot + * Infeed to the grid + * @return + * Updated thermal house state, a ThermalThreshold and the remaining qDot + */ + private def handleInfeedHouse( + relevantData: HpRelevantData, + lastAmbientTemperature: Temperature, + state: ThermalGridState, + qDot: Power, + ): (Option[ThermalHouseState], Option[ThermalThreshold], Power) = { + (house, state.houseState) match { + case (Some(thermalHouse), Some(lastHouseState)) => + val (newState, threshold) = thermalHouse.determineState( + relevantData, + lastHouseState, + lastAmbientTemperature, + qDot, + ) + /* Check if house can handle the thermal feed in */ if ( thermalHouse.isInnerTemperatureTooHigh( - updatedHouseState.innerTemperature + newState.innerTemperature ) ) { - /* The house is already heated up fully, set back the infeed and put it into storage, if available */ val (fullHouseState, maybeFullHouseThreshold) = thermalHouse.determineState( relevantData, @@ -230,63 +491,52 @@ final case class ThermalGrid( lastAmbientTemperature, zeroKW, ) - storage.zip(updatedStorageState) match { - case Some((thermalStorage, storageState)) => - val (updatedStorageState, maybeStorageThreshold) = - thermalStorage.updateState( - relevantData.currentTick, - qDot, - storageState, - ) - - /* Both house and storage are updated. Determine what reaches the next threshold */ - val nextThreshold = determineMostRecentThreshold( - maybeFullHouseThreshold, - maybeStorageThreshold, - ) - - ( - state.copy( - houseState = Some(fullHouseState), - storageState = Some(updatedStorageState), - ), - nextThreshold, - ) - case None => - /* There is no storage, house determines the next activation */ - ( - state.copy(houseState = Some(fullHouseState)), - maybeFullHouseThreshold, - ) - } + (Some(fullHouseState), maybeFullHouseThreshold, qDot) } else { - /* The house can handle the infeed */ - ( - state.copy(houseState = Some(updatedHouseState)), - maybeHouseThreshold, - ) + (Some(newState), threshold, zeroKW) } + case _ => (None, None, zeroKW) + } + } - case None => - storage.zip(state.storageState) match { - case Some((thermalStorage, storageState)) => - val (updatedStorageState, maybeStorageThreshold) = - thermalStorage.updateState( - relevantData.currentTick, - qDot, - storageState, - ) - ( - state.copy(storageState = Some(updatedStorageState)), - maybeStorageThreshold, - ) - case None => - throw new InconsistentStateException( - "A thermal grid has to contain either at least a house or a storage." - ) - } + /** Handles the cases, when the storage has heat demand and will be filled up + * here (positive qDot) or will be return its stored energy into the thermal + * grid (negative qDot). + * @param tick + * Current tick + * @param state + * Current state of the houses + * @param qDot + * Infeed to the grid + * @return + * Updated thermal grid state + */ + private def handleInfeedStorage( + tick: Long, + state: ThermalGridState, + qDot: Power, + ): (Option[ThermalStorageState], Option[ThermalThreshold]) = { + (storage, state.storageState) match { + case (Some(thermalStorage), Some(lastStorageState)) => + val (newState, threshold) = thermalStorage.updateState( + tick, + qDot, + lastStorageState, + ) + (Some(newState), threshold) + case _ => (None, None) } + } + /** Determines the most recent threshold of two given input thresholds + * + * @param maybeHouseThreshold + * Option of a possible next threshold of the thermal house + * @param maybeStorageThreshold + * Option of a possible next threshold of the thermal storage + * @return + * The next threshold + */ private def determineMostRecentThreshold( maybeHouseThreshold: Option[ThermalThreshold], maybeStorageThreshold: Option[ThermalThreshold], @@ -576,13 +826,12 @@ object ThermalGrid { def hasRequiredDemand: Boolean = required > zeroMWh - def hasAdditionalDemand: Boolean = possible > required + def hasAdditionalDemand: Boolean = possible > zeroMWh } object ThermalEnergyDemand { /** Builds a new instance of [[ThermalEnergyDemand]]. If the possible energy - * is less than the required energy, this is considered to be a bad state - * and the required energy is curtailed to the possible energy. + * is less than the required energy, this is considered to be a bad state. * @param required * The absolutely required energy to reach target state * @param possible @@ -594,8 +843,12 @@ object ThermalGrid { required: Energy, possible: Energy, ): ThermalEnergyDemand = { - if (possible < required) - new ThermalEnergyDemand(possible, possible) + if ( + math.abs(possible.toKilowattHours) < math.abs(required.toKilowattHours) + ) + throw new InvalidParameterException( + s"The possible amount of energy {$possible} is smaller than the required amount of energy {$required}. This is not supported." + ) else new ThermalEnergyDemand(required, possible) } diff --git a/src/main/scala/edu/ie3/simona/model/thermal/ThermalHouse.scala b/src/main/scala/edu/ie3/simona/model/thermal/ThermalHouse.scala index 4296efa1ae..655728c8c4 100644 --- a/src/main/scala/edu/ie3/simona/model/thermal/ThermalHouse.scala +++ b/src/main/scala/edu/ie3/simona/model/thermal/ThermalHouse.scala @@ -161,8 +161,8 @@ final case class ThermalHouse( innerTemperature: Temperature, boundaryTemperature: Temperature = upperBoundaryTemperature, ): Boolean = - innerTemperature > Kelvin( - boundaryTemperature.toKelvinScale - temperatureTolerance.toKelvinScale + innerTemperature > ( + boundaryTemperature - temperatureTolerance ) /** Check if inner temperature is lower than preferred minimum temperature @@ -174,8 +174,8 @@ final case class ThermalHouse( innerTemperature: Temperature, boundaryTemperature: Temperature = lowerBoundaryTemperature, ): Boolean = - innerTemperature < Kelvin( - boundaryTemperature.toKelvinScale + temperatureTolerance.toKelvinScale + innerTemperature < ( + boundaryTemperature + temperatureTolerance ) /** Calculate the new inner temperature of the thermal house. diff --git a/src/test/scala/edu/ie3/simona/agent/em/EmAgentIT.scala b/src/test/scala/edu/ie3/simona/agent/em/EmAgentIT.scala index 09f6acb019..37155b6cba 100644 --- a/src/test/scala/edu/ie3/simona/agent/em/EmAgentIT.scala +++ b/src/test/scala/edu/ie3/simona/agent/em/EmAgentIT.scala @@ -692,25 +692,6 @@ class EmAgentIT emResult.getQ should equalWithTolerance(0.000088285537.asMegaVar) } - scheduler.expectMessage(Completion(emAgentActivation, Some(28665))) - - /* TICK 28666 - LOAD: 0.000269 MW (unchanged) - PV: -0.000032 MW (unchanged) - Heat pump: Is turned on again and cannot be turned off - -> flex signal is no control -> 0.00485 MW - */ - - emAgentActivation ! Activation(28665) - - resultListener.expectMessageType[ParticipantResultEvent] match { - case ParticipantResultEvent(emResult: EmResult) => - emResult.getInputModel shouldBe emInput.getUuid - emResult.getTime shouldBe 28665.toDateTime - emResult.getP should equalWithTolerance(0.0050867679996.asMegaWatt) - emResult.getQ should equalWithTolerance(0.001073120040.asMegaVar) - } - scheduler.expectMessage(Completion(emAgentActivation, Some(28800))) } } diff --git a/src/test/scala/edu/ie3/simona/agent/grid/ThermalGridIT.scala b/src/test/scala/edu/ie3/simona/agent/grid/ThermalGridIT.scala new file mode 100644 index 0000000000..c4e8d736f4 --- /dev/null +++ b/src/test/scala/edu/ie3/simona/agent/grid/ThermalGridIT.scala @@ -0,0 +1,857 @@ +/* + * © 2020. TU Dortmund University, + * Institute of Energy Systems, Energy Efficiency and Energy Economics, + * Research group Distribution grid planning and operation + */ + +package edu.ie3.simona.agent.grid + +import edu.ie3.simona.agent.participant.data.secondary.SecondaryDataService.ActorWeatherService +import edu.ie3.simona.agent.participant.hp.HpAgent +import edu.ie3.simona.agent.participant.statedata.ParticipantStateData.ParticipantInitializeStateData +import edu.ie3.simona.config.SimonaConfig.HpRuntimeConfig +import edu.ie3.simona.event.ResultEvent.{ + CylindricalThermalStorageResult, + ParticipantResultEvent, + ThermalHouseResult, +} +import edu.ie3.simona.event.notifier.NotifierConfig +import edu.ie3.simona.event.{ResultEvent, RuntimeEvent} +import edu.ie3.simona.model.thermal.ThermalHouseTestData +import edu.ie3.simona.ontology.messages.SchedulerMessage.Completion +import edu.ie3.simona.ontology.messages.services.ServiceMessage +import edu.ie3.simona.ontology.messages.services.ServiceMessage.PrimaryServiceRegistrationMessage +import edu.ie3.simona.ontology.messages.services.ServiceMessage.RegistrationResponseMessage.{ + RegistrationFailedMessage, + RegistrationSuccessfulMessage, +} +import edu.ie3.simona.ontology.messages.services.WeatherMessage.{ + ProvideWeatherMessage, + RegisterForWeatherMessage, + WeatherData, +} +import edu.ie3.simona.ontology.messages.{Activation, SchedulerMessage} +import edu.ie3.simona.test.common.DefaultTestData +import edu.ie3.simona.test.common.input.EmInputTestData +import edu.ie3.simona.util.SimonaConstants.INIT_SIM_TICK +import edu.ie3.simona.util.TickUtil.TickLong +import edu.ie3.util.TimeUtil +import edu.ie3.util.quantities.QuantityMatchers.equalWithTolerance +import edu.ie3.util.quantities.QuantityUtils.RichQuantityDouble +import edu.ie3.util.scala.quantities.WattsPerSquareMeter +import org.apache.pekko.actor.ActorSystem +import org.apache.pekko.actor.testkit.typed.scaladsl.{ + ScalaTestWithActorTestKit, + TestProbe, +} +import org.apache.pekko.actor.typed.scaladsl.adapter.{TypedActorRefOps, _} +import org.apache.pekko.testkit.TestActorRef +import org.scalatest.matchers.should +import org.scalatest.wordspec.AnyWordSpecLike +import org.scalatestplus.mockito.MockitoSugar +import squants.motion.MetersPerSecond +import squants.thermal.Celsius + +import java.time.ZonedDateTime +import scala.language.postfixOps + +/** Test to ensure the functions that a thermal grid and its connected assets is + * capable. + */ +class ThermalGridIT + extends ScalaTestWithActorTestKit + with ThermalHouseTestData + with AnyWordSpecLike + with should.Matchers + with EmInputTestData + with MockitoSugar + with DefaultTestData { + private implicit val classicSystem: ActorSystem = system.toClassic + protected implicit val simulationStartDate: ZonedDateTime = + TimeUtil.withDefaults.toZonedDateTime("2020-01-01T00:00:00Z") + protected val simulationEndDate: ZonedDateTime = + TimeUtil.withDefaults.toZonedDateTime("2020-01-02T02:00:00Z") + + private val resolution = + simonaConfig.simona.powerflow.resolution.getSeconds + + private val outputConfigOn = NotifierConfig( + simulationResultInfo = true, + powerRequestReply = false, + flexResult = false, + ) + + val scheduler: TestProbe[SchedulerMessage] = TestProbe("scheduler") + val runtimeEvents: TestProbe[RuntimeEvent] = + TestProbe("runtimeEvents") + val primaryServiceProxy = + TestProbe[ServiceMessage]("PrimaryServiceProxy") + + val weatherService = TestProbe[ServiceMessage]("WeatherService") + + val resultListener: TestProbe[ResultEvent] = TestProbe("resultListener") + + "A Thermal Grid with thermal house, storage and heat pump not under the control of an energy management" should { + "be initialized correctly and run through some activations" in { + val heatPumpAgent = TestActorRef( + new HpAgent( + scheduler = scheduler.ref.toClassic, + initStateData = ParticipantInitializeStateData( + typicalHpInputModel, + typicalThermalGrid, + HpRuntimeConfig( + calculateMissingReactivePowerWithModel = true, + 1.0, + List.empty[String], + ), + primaryServiceProxy.ref.toClassic, + Iterable(ActorWeatherService(weatherService.ref.toClassic)), + simulationStartDate, + simulationEndDate, + resolution, + simonaConfig.simona.runtime.participant.requestVoltageDeviationThreshold, + outputConfigOn, + None, + ), + listener = Iterable(resultListener.ref.toClassic), + ), + "HeatPumpAgent1", + ) + + val pRunningHp = 0.0038.asMegaWatt + val qRunningHp = 0.0012489995996796802.asMegaVar + + scheduler.expectNoMessage() + + /* INIT */ + + // heat pump + heatPumpAgent ! Activation(INIT_SIM_TICK) + + primaryServiceProxy.expectMessage( + PrimaryServiceRegistrationMessage(typicalHpInputModel.getUuid) + ) + heatPumpAgent ! RegistrationFailedMessage( + primaryServiceProxy.ref.toClassic + ) + + weatherService.expectMessage( + RegisterForWeatherMessage( + typicalHpInputModel.getNode.getGeoPosition.getY, + typicalHpInputModel.getNode.getGeoPosition.getX, + ) + ) + + heatPumpAgent ! RegistrationSuccessfulMessage( + weatherService.ref.toClassic, + Some(0L), + ) + val weatherDependentAgents = Seq(heatPumpAgent) + + /* TICK 0 + Start of Simulation + House demand heating : requiredDemand = 0.0 kWh, additionalDemand ~ 15 kWh + House demand water : tba + ThermalStorage : requiredDemand = 10.44 kWh, additionalDemand = 10.44 kWh + DomesticWaterStorage : tba + Heat pump: turned on - to serve the storage demand + */ + + heatPumpAgent ! Activation(0L) + + weatherDependentAgents.foreach { + _ ! ProvideWeatherMessage( + 0, + weatherService.ref.toClassic, + WeatherData( + WattsPerSquareMeter(0d), + WattsPerSquareMeter(0d), + Celsius(-5d), + MetersPerSecond(0d), + ), + Some(7200), + ) + } + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(hpResult) => + hpResult.getInputModel shouldBe typicalHpInputModel.getUuid + hpResult.getTime shouldBe 0.toDateTime + hpResult.getP should equalWithTolerance(pRunningHp) + hpResult.getQ should equalWithTolerance( + qRunningHp + ) + } + + Range(0, 2) + .map { _ => + resultListener.expectMessageType[ResultEvent] + } + .foreach { case ResultEvent.ThermalResultEvent(thermalUnitResult) => + thermalUnitResult match { + case ThermalHouseResult( + time, + inputModel, + qDot, + indoorTemperature, + ) => + inputModel shouldBe typicalThermalHouse.getUuid + time shouldBe 0.toDateTime + qDot should equalWithTolerance(0.0.asMegaWatt) + indoorTemperature should equalWithTolerance( + 19.9999074074074.asDegreeCelsius + ) + case CylindricalThermalStorageResult( + time, + inputModel, + qDot, + energy, + ) => + inputModel shouldBe typicalThermalStorage.getUuid + time shouldBe 0.toDateTime + qDot should equalWithTolerance(0.011.asMegaWatt) + energy should equalWithTolerance(0.asMegaWattHour) + case _ => + fail( + "Expected a ThermalHouseResult and a ThermalStorageResult but got something else" + ) + } + } + + scheduler.expectMessage(Completion(heatPumpAgent, Some(0L))) + + /* TICK 3417 + Storage is fully heated up + House demand heating : requiredDemand = 0.0 kWh, additionalDemand = 17.37 kWh + House demand water : tba + ThermalStorage : requiredDemand = 0.0 kWh, additionalDemand = 0.0 kWh + DomesticWaterStorage : tba + Heat pump: stays on since it was on and the house as additional demand + */ + + heatPumpAgent ! Activation(3417) + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(hpResult) => + hpResult.getInputModel shouldBe typicalHpInputModel.getUuid + hpResult.getTime shouldBe 3417.toDateTime + hpResult.getP should equalWithTolerance(pRunningHp) + hpResult.getQ should equalWithTolerance( + qRunningHp + ) + } + + Range(0, 2) + .map { _ => + resultListener.expectMessageType[ResultEvent] + } + .foreach { case ResultEvent.ThermalResultEvent(thermalUnitResult) => + thermalUnitResult match { + case ThermalHouseResult( + time, + inputModel, + qDot, + indoorTemperature, + ) => + inputModel shouldBe typicalThermalHouse.getUuid + time shouldBe 3417.toDateTime + qDot should equalWithTolerance(0.011.asMegaWatt) + indoorTemperature should equalWithTolerance( + 19.6835196903292.asDegreeCelsius + ) + + case CylindricalThermalStorageResult( + time, + inputModel, + qDot, + energy, + ) => + inputModel shouldBe typicalThermalStorage.getUuid + time shouldBe 3417.toDateTime + qDot should equalWithTolerance(0.asMegaWatt) + energy should equalWithTolerance(0.01044.asMegaWattHour) + case _ => + fail( + "Expected a ThermalHouseResult and a ThermalStorageResult but got something else" + ) + } + } + + // FIXME? Why next tick 3417? + scheduler.expectMessage(Completion(heatPumpAgent, Some(3417))) + + /* TICK 7200 + New weather data (unchanged) incoming + House demand heating : requiredDemand = 0.0 kWh, additionalDemand = 8.41 kWh + House demand water : tba + ThermalStorage : requiredDemand = 0.0 kWh, additionalDemand = 0.0 kWh + DomesticWaterStorage : tba + Heat pump: stays on + */ + + heatPumpAgent ! Activation(7200L) + + weatherDependentAgents.foreach { + _ ! ProvideWeatherMessage( + 7200L, + weatherService.ref.toClassic, + WeatherData( + WattsPerSquareMeter(1d), + WattsPerSquareMeter(1d), + Celsius(-5d), + MetersPerSecond(0d), + ), + Some(28800L), + ) + } + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(hpResult) => + hpResult.getInputModel shouldBe typicalHpInputModel.getUuid + hpResult.getTime shouldBe 7200.toDateTime + hpResult.getP should equalWithTolerance(pRunningHp) + hpResult.getQ should equalWithTolerance( + qRunningHp + ) + } + + Range(0, 2) + .map { _ => + resultListener.expectMessageType[ResultEvent] + } + .foreach { case ResultEvent.ThermalResultEvent(thermalUnitResult) => + thermalUnitResult match { + case ThermalHouseResult( + time, + inputModel, + qDot, + indoorTemperature, + ) => + inputModel shouldBe typicalThermalHouse.getUuid + time shouldBe 7200.toDateTime + qDot should equalWithTolerance(0.011.asMegaWatt) + indoorTemperature should equalWithTolerance( + 20.8788983755569.asDegreeCelsius + ) + case CylindricalThermalStorageResult( + time, + inputModel, + qDot, + energy, + ) => + inputModel shouldBe typicalThermalStorage.getUuid + time shouldBe 7200.toDateTime + qDot should equalWithTolerance(0.asMegaWatt) + energy should equalWithTolerance(0.01044.asMegaWattHour) + case _ => + fail( + "Expected a ThermalHouseResult and a ThermalStorageResult but got something else" + ) + } + } + + // FIXME? Why next tick 7200? + scheduler.expectMessage(Completion(heatPumpAgent, Some(7200L))) + + /* TICK 10798 + House reaches upper temperature boundary + House demand heating : requiredDemand = 0.0 kWh, additionalDemand = 0.0 kWh + House demand water : tba + ThermalStorage : requiredDemand = 0.0 kWh, additionalDemand = 0.0 kWh + DomesticWaterStorage : tba + Heat pump: turned off + */ + + heatPumpAgent ! Activation(10798) + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(hpResult) => + hpResult.getInputModel shouldBe typicalHpInputModel.getUuid + hpResult.getTime shouldBe 10798.toDateTime + hpResult.getP should equalWithTolerance(0.asMegaWatt) + hpResult.getQ should equalWithTolerance(0.asMegaVar) + } + + Range(0, 2) + .map { _ => + resultListener.expectMessageType[ResultEvent] + } + .foreach { case ResultEvent.ThermalResultEvent(thermalUnitResult) => + thermalUnitResult match { + case ThermalHouseResult( + time, + inputModel, + qDot, + indoorTemperature, + ) => + inputModel shouldBe typicalThermalHouse.getUuid + time shouldBe 10798.toDateTime + qDot should equalWithTolerance(0.asMegaWatt) + indoorTemperature should equalWithTolerance( + 21.9998899446115.asDegreeCelsius + ) + case CylindricalThermalStorageResult( + time, + inputModel, + qDot, + energy, + ) => + inputModel shouldBe typicalThermalStorage.getUuid + time shouldBe 10798.toDateTime + qDot should equalWithTolerance(0.asMegaWatt) + energy should equalWithTolerance(0.01044.asMegaWattHour) + case _ => + fail( + "Expected a ThermalHouseResult and a ThermalStorageResult but got something else" + ) + } + } + + // FIXME? Why next tick 10798? + scheduler.expectMessage(Completion(heatPumpAgent, Some(10798))) + + /* TICK 28800 + House would reach lowerTempBoundary at tick 50797 + but now it's getting colder which should decrease inner temp of house faster + House demand heating : requiredDemand = 0.0 kWh, additionalDemand = 0.0 kWh + House demand water : tba + ThermalStorage : requiredDemand = 0.0 kWh, additionalDemand = 0.0 kWh + DomesticWaterStorage : tba + Heat pump: stays off + */ + + heatPumpAgent ! Activation(28800L) + + weatherDependentAgents.foreach { + _ ! ProvideWeatherMessage( + 28800L, + weatherService.ref.toClassic, + WeatherData( + WattsPerSquareMeter(2d), + WattsPerSquareMeter(2d), + Celsius(-25d), + MetersPerSecond(0d), + ), + Some(45000L), + ) + } + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(hpResult) => + hpResult.getInputModel shouldBe typicalHpInputModel.getUuid + hpResult.getTime shouldBe 28800.toDateTime + hpResult.getP should equalWithTolerance(0.0.asMegaWatt) + hpResult.getQ should equalWithTolerance(0.0.asMegaVar) + } + + Range(0, 2) + .map { _ => + resultListener.expectMessageType[ResultEvent] + } + .foreach { case ResultEvent.ThermalResultEvent(thermalUnitResult) => + thermalUnitResult match { + case ThermalHouseResult( + time, + inputModel, + qDot, + indoorTemperature, + ) => + inputModel shouldBe typicalThermalHouse.getUuid + time shouldBe 28800.toDateTime + qDot should equalWithTolerance(0.0.asMegaWatt) + indoorTemperature should equalWithTolerance( + 20.19969728245267.asDegreeCelsius + ) + + case CylindricalThermalStorageResult( + time, + inputModel, + qDot, + energy, + ) => + inputModel shouldBe typicalThermalStorage.getUuid + time shouldBe 28800.toDateTime + qDot should equalWithTolerance(0.0.asMegaWatt) + energy should equalWithTolerance(0.01044.asMegaWattHour) + case _ => + fail( + "Expected a ThermalHouseResult and a ThermalStorageResult but got something else" + ) + } + } + + scheduler.expectMessage(Completion(heatPumpAgent, Some(28800L))) + + /* TICK 41940 + House reach lowerTemperatureBoundary + House demand heating : requiredDemand = 15.0 kWh, additionalDemand = 30.00 kWh + House demand water : tba + ThermalStorage : requiredDemand = 0.0 kWh, additionalDemand = 0.0 kWh + DomesticWaterStorage : tba + Heat pump: stays off, demand should be covered by storage + */ + + heatPumpAgent ! Activation(41940) + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(hpResult) => + hpResult.getInputModel shouldBe typicalHpInputModel.getUuid + hpResult.getTime shouldBe 41940.toDateTime + hpResult.getP should equalWithTolerance(0.0.asMegaWatt) + hpResult.getQ should equalWithTolerance( + 0.0.asMegaVar + ) + } + + Range(0, 2) + .map { _ => + resultListener.expectMessageType[ResultEvent] + } + .foreach { case ResultEvent.ThermalResultEvent(thermalUnitResult) => + thermalUnitResult match { + case ThermalHouseResult( + time, + inputModel, + qDot, + indoorTemperature, + ) => + inputModel shouldBe typicalThermalHouse.getUuid + time shouldBe 41940.toDateTime + qDot should equalWithTolerance(0.01044.asMegaWatt) + indoorTemperature should equalWithTolerance( + 17.9999786813733.asDegreeCelsius + ) + case CylindricalThermalStorageResult( + time, + inputModel, + qDot, + energy, + ) => + inputModel shouldBe typicalThermalStorage.getUuid + time shouldBe 41940.toDateTime + qDot should equalWithTolerance((-0.01044).asMegaWatt) + energy should equalWithTolerance(0.01044.asMegaWattHour) + case _ => + fail( + "Expected a ThermalHouseResult and a ThermalStorageResult but got something else" + ) + } + } + + scheduler.expectMessage(Completion(heatPumpAgent, Some(41940))) + + /* TICK 45000 + Storage will be empty at tick 45540 + Additional trigger caused by (unchanged) weather data should not change this + House demand heating : requiredDemand = 9.78 kWh, additionalDemand = 24.78 kWh + House demand water : tba + ThermalStorage : requiredDemand = 0.0 kWh, additionalDemand = 8.87 kWh + DomesticWaterStorage : tba + Heat pump: stays off + */ + + heatPumpAgent ! Activation(45000) + + weatherDependentAgents.foreach { + _ ! ProvideWeatherMessage( + 45000, + weatherService.ref.toClassic, + WeatherData( + WattsPerSquareMeter(3d), + WattsPerSquareMeter(3d), + Celsius(-25d), + MetersPerSecond(0d), + ), + Some(57600), + ) + } + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(hpResult) => + hpResult.getInputModel shouldBe typicalHpInputModel.getUuid + hpResult.getTime shouldBe 45000.toDateTime + hpResult.getP should equalWithTolerance(0.0.asMegaWatt) + hpResult.getQ should equalWithTolerance(0.0.asMegaVar) + } + + Range(0, 2) + .map { _ => + resultListener.expectMessageType[ResultEvent] + } + .foreach { case ResultEvent.ThermalResultEvent(thermalUnitResult) => + thermalUnitResult match { + case ThermalHouseResult( + time, + inputModel, + qDot, + indoorTemperature, + ) => + inputModel shouldBe typicalThermalHouse.getUuid + time shouldBe 45000.toDateTime + qDot should equalWithTolerance(0.01044.asMegaWatt) + indoorTemperature should equalWithTolerance( + 18.69584558965105.asDegreeCelsius + ) + + case CylindricalThermalStorageResult( + time, + inputModel, + qDot, + energy, + ) => + inputModel shouldBe typicalThermalStorage.getUuid + time shouldBe 45000.toDateTime + qDot should equalWithTolerance((-0.01044).asMegaWatt) + energy should equalWithTolerance( + 0.00156599999999999.asMegaWattHour + ) + case _ => + fail( + "Expected a ThermalHouseResult and a ThermalStorageResult but got something else" + ) + } + } + + scheduler.expectMessage(Completion(heatPumpAgent, Some(45000))) + + /* TICK 45540 + Storage will be empty + House demand heating : requiredDemand = 8.87kWh, additionalDemand = 23.87 kWh + House demand water : tba + ThermalStorage : requiredDemand = 10.44 kWh, additionalDemand = 10.44 kWh + DomesticWaterStorage : tba + Heat pump: will be turned on - to serve the remaining heat demand of house (and refill storage later) + */ + + heatPumpAgent ! Activation(45540) + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(hpResult) => + hpResult.getInputModel shouldBe typicalHpInputModel.getUuid + hpResult.getTime shouldBe 45540.toDateTime + hpResult.getP should equalWithTolerance(pRunningHp) + hpResult.getQ should equalWithTolerance( + qRunningHp + ) + } + + Range(0, 2) + .map { _ => + resultListener.expectMessageType[ResultEvent] + } + .foreach { case ResultEvent.ThermalResultEvent(thermalUnitResult) => + thermalUnitResult match { + case ThermalHouseResult( + time, + inputModel, + qDot, + indoorTemperature, + ) => + inputModel shouldBe typicalThermalHouse.getUuid + time shouldBe 45540.toDateTime + qDot should equalWithTolerance(0.011.asMegaWatt) + indoorTemperature should equalWithTolerance( + 18.81725389847177.asDegreeCelsius + ) + + case CylindricalThermalStorageResult( + time, + inputModel, + qDot, + energy, + ) => + inputModel shouldBe typicalThermalStorage.getUuid + time shouldBe 45540.toDateTime + qDot should equalWithTolerance(0.asMegaWatt) + energy should equalWithTolerance(0.asMegaWattHour) + case _ => + fail( + "Expected a ThermalHouseResult and a ThermalStorageResult but got something else" + ) + } + } + + scheduler.expectMessage(Completion(heatPumpAgent, Some(45540))) + + /* TICK 57600 + New weather data: it's getting warmer again + House demand heating : requiredDemand = 0.00 kWh, additionalDemand = 1.70 kWh + House demand water : tba + ThermalStorage : requiredDemand = 10.44 kWh, additionalDemand = 10.44 kWh + DomesticWaterStorage : tba + Heat pump: stays on + */ + + heatPumpAgent ! Activation(57600) + + weatherDependentAgents.foreach { + _ ! ProvideWeatherMessage( + 57600, + weatherService.ref.toClassic, + WeatherData( + WattsPerSquareMeter(4d), + WattsPerSquareMeter(4d), + Celsius(5d), + MetersPerSecond(0d), + ), + Some(151200), + ) + } + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(hpResult) => + hpResult.getInputModel shouldBe typicalHpInputModel.getUuid + hpResult.getTime shouldBe 57600.toDateTime + hpResult.getP should equalWithTolerance(pRunningHp) + hpResult.getQ should equalWithTolerance(qRunningHp) + } + + Range(0, 2) + .map { _ => + resultListener.expectMessageType[ResultEvent] + } + .foreach { case ResultEvent.ThermalResultEvent(thermalUnitResult) => + thermalUnitResult match { + case ThermalHouseResult( + time, + inputModel, + qDot, + indoorTemperature, + ) => + inputModel shouldBe typicalThermalHouse.getUuid + time shouldBe 57600.toDateTime + qDot should equalWithTolerance(0.011.asMegaWatt) + indoorTemperature should equalWithTolerance( + 21.77341655767336.asDegreeCelsius + ) + + case CylindricalThermalStorageResult( + time, + inputModel, + qDot, + energy, + ) => + inputModel shouldBe typicalThermalStorage.getUuid + time shouldBe 57600.toDateTime + qDot should equalWithTolerance(0.asMegaWatt) + energy should equalWithTolerance( + 0.asMegaWattHour + ) + } + } + + scheduler.expectMessage(Completion(heatPumpAgent, Some(57600))) + + /* TICK 58256 + House will reach the upperTemperatureBoundary + House demand heating : requiredDemand = 0.00 kWh, additionalDemand = 0.00 kWh + House demand water : tba + ThermalStorage : requiredDemand = 10.44 kWh, additionalDemand = 10.44 kWh + DomesticWaterStorage : tba + Heat pump: stays on to refill the storage now + */ + + heatPumpAgent ! Activation(58256) + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(hpResult) => + hpResult.getInputModel shouldBe typicalHpInputModel.getUuid + hpResult.getTime shouldBe 58256.toDateTime + hpResult.getP should equalWithTolerance(pRunningHp) + hpResult.getQ should equalWithTolerance(qRunningHp) + } + + Range(0, 2) + .map { _ => + resultListener.expectMessageType[ResultEvent] + } + .foreach { case ResultEvent.ThermalResultEvent(thermalUnitResult) => + thermalUnitResult match { + case ThermalHouseResult( + time, + inputModel, + qDot, + indoorTemperature, + ) => + inputModel shouldBe typicalThermalHouse.getUuid + time shouldBe 58256.toDateTime + qDot should equalWithTolerance(0.asMegaWatt) + indoorTemperature should equalWithTolerance( + 21.999922627074.asDegreeCelsius + ) + + case CylindricalThermalStorageResult( + time, + inputModel, + qDot, + energy, + ) => + inputModel shouldBe typicalThermalStorage.getUuid + time shouldBe 58256.toDateTime + qDot should equalWithTolerance(0.011.asMegaWatt) + energy should equalWithTolerance( + 0.asMegaWattHour + ) + } + } + + scheduler.expectMessage(Completion(heatPumpAgent, Some(58256))) + + /* TICK 61673 + Storage will be fully charged + House demand heating : requiredDemand = ?0.00 kWh, additionalDemand = 0.00 kWh + House demand water : tba + ThermalStorage : requiredDemand = 0.0 kWh, additionalDemand = 0.0 kWh + DomesticWaterStorage : tba + Heat pump: stays on to refill the storage now + */ + + heatPumpAgent ! Activation(61673) + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(hpResult) => + hpResult.getInputModel shouldBe typicalHpInputModel.getUuid + hpResult.getTime shouldBe 61673.toDateTime + hpResult.getP should equalWithTolerance(0.asMegaWatt) + hpResult.getQ should equalWithTolerance(0.asMegaVar) + } + + Range(0, 2) + .map { _ => + resultListener.expectMessageType[ResultEvent] + } + .foreach { case ResultEvent.ThermalResultEvent(thermalUnitResult) => + thermalUnitResult match { + case ThermalHouseResult( + time, + inputModel, + qDot, + indoorTemperature, + ) => + inputModel shouldBe typicalThermalHouse.getUuid + time shouldBe 61673.toDateTime + qDot should equalWithTolerance(0.asMegaWatt) + indoorTemperature should equalWithTolerance( + 21.7847791618269.asDegreeCelsius + ) + + case CylindricalThermalStorageResult( + time, + inputModel, + qDot, + energy, + ) => + inputModel shouldBe typicalThermalStorage.getUuid + time shouldBe 61673.toDateTime + qDot should equalWithTolerance(0.asMegaWatt) + energy should equalWithTolerance( + 0.01044.asMegaWattHour + ) + } + } + + scheduler.expectMessage(Completion(heatPumpAgent, Some(61673))) + + } + } +} diff --git a/src/test/scala/edu/ie3/simona/model/participant/HpModelSpec.scala b/src/test/scala/edu/ie3/simona/model/participant/HpModelSpec.scala index 0479798610..9964f7af3f 100644 --- a/src/test/scala/edu/ie3/simona/model/participant/HpModelSpec.scala +++ b/src/test/scala/edu/ie3/simona/model/participant/HpModelSpec.scala @@ -265,7 +265,7 @@ class HpModelSpec (95.0, 95.0, 95.0), ), // 2. Same as before but heat storage is NOT empty - // should be possible to keep hp off + // should be possible to turn hp on ( ThermalGridState( Some(ThermalHouseState(0L, Celsius(15), Kilowatts(0))), diff --git a/src/test/scala/edu/ie3/simona/model/thermal/ThermalGridSpec.scala b/src/test/scala/edu/ie3/simona/model/thermal/ThermalGridSpec.scala index bb34a88682..cb09b2bb80 100644 --- a/src/test/scala/edu/ie3/simona/model/thermal/ThermalGridSpec.scala +++ b/src/test/scala/edu/ie3/simona/model/thermal/ThermalGridSpec.scala @@ -7,6 +7,7 @@ package edu.ie3.simona.model.thermal import edu.ie3.datamodel.models.input.thermal.ThermalStorageInput +import edu.ie3.simona.exceptions.InvalidParameterException import edu.ie3.simona.model.thermal.ThermalGrid.ThermalEnergyDemand import edu.ie3.simona.test.common.UnitSpec import squants.energy.{KilowattHours, MegawattHours, WattHours, Watts} @@ -26,14 +27,22 @@ class ThermalGridSpec "Testing the thermal energy demand" when { "instantiating it from given values" should { - "correct non-sensible input" in { + "throw exception for non-plausible input (positive)" in { val possible = MegawattHours(40d) val required = MegawattHours(42d) - val energyDemand = ThermalEnergyDemand(required, possible) + intercept[InvalidParameterException] { + ThermalEnergyDemand(required, possible) + }.getMessage shouldBe s"The possible amount of energy {$possible} is smaller than the required amount of energy {$required}. This is not supported." + } - energyDemand.required should approximate(possible) - energyDemand.possible should approximate(possible) + "throw exception for non-sensible input (negative)" in { + val possible = MegawattHours(-40d) + val required = MegawattHours(-42d) + + intercept[InvalidParameterException] { + ThermalEnergyDemand(required, possible) + }.getMessage shouldBe s"The possible amount of energy {$possible} is smaller than the required amount of energy {$required}. This is not supported." } "set the correct values, if they are sensible" in { @@ -57,22 +66,30 @@ class ThermalGridSpec } "checking for required and additional demand" should { - "return proper information, if no required but additional demand is apparent" in { + "return proper information, if no required and no additional demand is apparent" in { val required = MegawattHours(0d) - val possible = MegawattHours(45d) + val possible = MegawattHours(0d) val energyDemand = ThermalEnergyDemand(required, possible) energyDemand.hasRequiredDemand shouldBe false - energyDemand.hasAdditionalDemand shouldBe true + energyDemand.hasAdditionalDemand shouldBe false } - "return proper information, if required but no additional demand is apparent" in { - val required = MegawattHours(45d) + "return proper information, if no required but additional demand is apparent" in { + val required = MegawattHours(0d) val possible = MegawattHours(45d) val energyDemand = ThermalEnergyDemand(required, possible) - energyDemand.hasRequiredDemand shouldBe true - energyDemand.hasAdditionalDemand shouldBe false + energyDemand.hasRequiredDemand shouldBe false + energyDemand.hasAdditionalDemand shouldBe true + } + + "throw exception, if required demand is higher than possible demand" in { + val required = MegawattHours(1d) + val possible = MegawattHours(0d) + intercept[InvalidParameterException] { + ThermalEnergyDemand(required, possible) + }.getMessage shouldBe s"The possible amount of energy {$possible} is smaller than the required amount of energy {$required}. This is not supported." } "return proper information, if required and additional demand is apparent" in { diff --git a/src/test/scala/edu/ie3/simona/model/thermal/ThermalGridWithHouseAndStorageSpec.scala b/src/test/scala/edu/ie3/simona/model/thermal/ThermalGridWithHouseAndStorageSpec.scala index 5b57075ed6..302834f9c4 100644 --- a/src/test/scala/edu/ie3/simona/model/thermal/ThermalGridWithHouseAndStorageSpec.scala +++ b/src/test/scala/edu/ie3/simona/model/thermal/ThermalGridWithHouseAndStorageSpec.scala @@ -239,7 +239,9 @@ class ThermalGridWithHouseAndStorageSpec updatedGridState match { case ThermalGridState( - Some(ThermalHouseState(houseTick, innerTemperature, qDotHouse)), + Some( + ThermalHouseState(houseTick, innerTemperature, qDotHouse) + ), Some( ThermalStorageState(storageTick, storedEnergy, qDotStorage) ), @@ -530,12 +532,16 @@ class ThermalGridWithHouseAndStorageSpec relevantData, testGridAmbientTemperature, initialGridState, + isRunning, externalQDot, + onlyThermalDemandOfHouse, ) updatedGridState match { case ThermalGridState( - Some(ThermalHouseState(houseTick, innerTemperature, qDotHouse)), + Some( + ThermalHouseState(houseTick, innerTemperature, qDotHouse) + ), Some( ThermalStorageState(storageTick, storedEnergy, qDotStorage) ), @@ -544,7 +550,7 @@ class ThermalGridWithHouseAndStorageSpec innerTemperature should approximate(Celsius(18.9999d)) qDotHouse should approximate(externalQDot) - storageTick shouldBe -1L + storageTick shouldBe 0L storedEnergy should approximate( initialGridState.storageState .map(_.storedEnergy) @@ -578,12 +584,16 @@ class ThermalGridWithHouseAndStorageSpec relevantData, testGridAmbientTemperature, gridState, + isRunning, externalQDot, + onlyThermalDemandOfHeatStorage, ) updatedGridState match { case ThermalGridState( - Some(ThermalHouseState(houseTick, innerTemperature, qDotHouse)), + Some( + ThermalHouseState(houseTick, innerTemperature, qDotHouse) + ), Some( ThermalStorageState(storageTick, storedEnergy, qDotStorage) ), diff --git a/src/test/scala/edu/ie3/simona/model/thermal/ThermalGridWithHouseOnlySpec.scala b/src/test/scala/edu/ie3/simona/model/thermal/ThermalGridWithHouseOnlySpec.scala index 46ad85a3f0..4d1186869b 100644 --- a/src/test/scala/edu/ie3/simona/model/thermal/ThermalGridWithHouseOnlySpec.scala +++ b/src/test/scala/edu/ie3/simona/model/thermal/ThermalGridWithHouseOnlySpec.scala @@ -200,7 +200,9 @@ class ThermalGridWithHouseOnlySpec extends UnitSpec with ThermalHouseTestData { relevantData, testGridAmbientTemperature, gridState, + isNotRunning, testGridQDotInfeed, + onlyThermalDemandOfHouse, ) updatedGridState match { @@ -227,7 +229,9 @@ class ThermalGridWithHouseOnlySpec extends UnitSpec with ThermalHouseTestData { relevantData, ThermalGrid.startingState(thermalGrid), testGridAmbientTemperature, + isRunning, testGridQDotInfeed, + onlyThermalDemandOfHouse, ) match { case ( ThermalGridState( @@ -249,7 +253,9 @@ class ThermalGridWithHouseOnlySpec extends UnitSpec with ThermalHouseTestData { relevantData, ThermalGrid.startingState(thermalGrid), testGridAmbientTemperature, + isNotRunning, testGridQDotConsumption, + onlyThermalDemandOfHouse, ) match { case ( ThermalGridState( @@ -271,7 +277,9 @@ class ThermalGridWithHouseOnlySpec extends UnitSpec with ThermalHouseTestData { relevantData, ThermalGrid.startingState(thermalGrid), testGridAmbientTemperature, + isNotRunning, zeroKW, + onlyThermalDemandOfHouse, ) match { case ( ThermalGridState( diff --git a/src/test/scala/edu/ie3/simona/model/thermal/ThermalGridWithStorageOnlySpec.scala b/src/test/scala/edu/ie3/simona/model/thermal/ThermalGridWithStorageOnlySpec.scala index ddff53ff39..2c0b6b0771 100644 --- a/src/test/scala/edu/ie3/simona/model/thermal/ThermalGridWithStorageOnlySpec.scala +++ b/src/test/scala/edu/ie3/simona/model/thermal/ThermalGridWithStorageOnlySpec.scala @@ -212,7 +212,9 @@ class ThermalGridWithStorageOnlySpec relevantData, testGridAmbientTemperature, gridState, + isRunning, testGridQDotInfeed, + onlyThermalDemandOfHeatStorage, ) updatedGridState match { @@ -227,6 +229,44 @@ class ThermalGridWithStorageOnlySpec } reachedThreshold shouldBe Some(StorageFull(276000L)) } + + "properly take energy from storage" in { + val relevantData = HpRelevantData(0, testGridAmbientTemperature) + val gridState = ThermalGrid + .startingState(thermalGrid) + .copy(storageState = + Some( + ThermalStorageState( + 0L, + KilowattHours(150d), + zeroKW, + ) + ) + ) + + val (updatedGridState, reachedThreshold) = + thermalGrid invokePrivate handleInfeed( + relevantData, + testGridAmbientTemperature, + gridState, + isNotRunning, + testGridQDotInfeed, + onlyThermalDemandOfHeatStorage, + ) + + updatedGridState match { + case ThermalGridState( + None, + Some(ThermalStorageState(tick, storedEnergy, qDot)), + ) => + tick shouldBe 0L + storedEnergy should approximate(KilowattHours(150d)) + qDot should approximate(testGridQDotInfeed * (-1)) + case _ => fail("Thermal grid state has been calculated wrong.") + } + reachedThreshold shouldBe Some(StorageEmpty(36000L)) + } + } "updating the grid state dependent on the given thermal infeed" should { @@ -236,7 +276,9 @@ class ThermalGridWithStorageOnlySpec relevantData, ThermalGrid.startingState(thermalGrid), testGridAmbientTemperature, + isRunning, testGridQDotInfeed, + onlyThermalDemandOfHeatStorage, ) nextThreshold shouldBe Some(StorageFull(276000L)) @@ -268,7 +310,9 @@ class ThermalGridWithStorageOnlySpec ) ), testGridAmbientTemperature, + isRunning, testGridQDotConsumptionHigh, + onlyThermalDemandOfHouse, ) match { case ( ThermalGridState( @@ -290,7 +334,9 @@ class ThermalGridWithStorageOnlySpec relevantData, ThermalGrid.startingState(thermalGrid), testGridAmbientTemperature, + isRunning, zeroKW, + noThermalDemand, ) updatedState match { case ( diff --git a/src/test/scala/edu/ie3/simona/test/common/input/EmInputTestData.scala b/src/test/scala/edu/ie3/simona/test/common/input/EmInputTestData.scala index c5f3cf8efe..9c5abd30de 100644 --- a/src/test/scala/edu/ie3/simona/test/common/input/EmInputTestData.scala +++ b/src/test/scala/edu/ie3/simona/test/common/input/EmInputTestData.scala @@ -140,7 +140,7 @@ trait EmInputTestData UUID.fromString("91940626-bdd0-41cf-96dd-47c94c86b20e"), "thermal house", thermalBusInput, - Quantities.getQuantity(0.325, StandardUnits.THERMAL_TRANSMISSION), + Quantities.getQuantity(0.15, StandardUnits.THERMAL_TRANSMISSION), Quantities.getQuantity(75, StandardUnits.HEAT_CAPACITY), Quantities.getQuantity(20.3, StandardUnits.TEMPERATURE), Quantities.getQuantity(22.0, StandardUnits.TEMPERATURE), diff --git a/src/test/scala/edu/ie3/simona/test/common/input/HpInputTestData.scala b/src/test/scala/edu/ie3/simona/test/common/input/HpInputTestData.scala index a2ec9c70b0..1128c89fe0 100644 --- a/src/test/scala/edu/ie3/simona/test/common/input/HpInputTestData.scala +++ b/src/test/scala/edu/ie3/simona/test/common/input/HpInputTestData.scala @@ -10,6 +10,7 @@ import edu.ie3.datamodel.models.input.system.HpInput import edu.ie3.datamodel.models.input.system.`type`.HpTypeInput import edu.ie3.datamodel.models.input.system.characteristic.CosPhiFixed import edu.ie3.datamodel.models.input.thermal.{ + CylindricalStorageInput, ThermalHouseInput, ThermalStorageInput, } @@ -31,7 +32,7 @@ import tech.units.indriya.quantity.Quantities import tech.units.indriya.unit.Units import java.util.UUID -import scala.jdk.CollectionConverters.SeqHasAsJava +import scala.jdk.CollectionConverters.{SeqHasAsJava, _} trait HpInputTestData extends NodeInputTestData with ThermalGridTestData { @@ -84,6 +85,58 @@ trait HpInputTestData extends NodeInputTestData with ThermalGridTestData { Seq.empty[ThermalStorageInput].asJava, ) + protected val typicalThermalHouse = new ThermalHouseInput( + UUID.fromString("74ac67b4-4743-416a-b731-1b5fe4a0a4e7"), + "thermal house", + thermalBusInput, + Quantities.getQuantity(0.1, StandardUnits.THERMAL_TRANSMISSION), + Quantities.getQuantity(7.5, StandardUnits.HEAT_CAPACITY), + Quantities.getQuantity(20.0, StandardUnits.TEMPERATURE), + Quantities.getQuantity(22.0, StandardUnits.TEMPERATURE), + Quantities.getQuantity(18.0, StandardUnits.TEMPERATURE), + ) + + protected val typicalThermalStorage: CylindricalStorageInput = + new CylindricalStorageInput( + UUID.fromString("4b8933dc-aeb6-4573-b8aa-59d577214150"), + "thermal storage", + thermalBusInput, + Quantities.getQuantity(300.0, Units.LITRE), + Quantities.getQuantity(0.0, Units.LITRE), + Quantities.getQuantity(60.0, StandardUnits.TEMPERATURE), + Quantities.getQuantity(30.0, StandardUnits.TEMPERATURE), + Quantities.getQuantity(1.16, StandardUnits.SPECIFIC_HEAT_CAPACITY), + ) + + protected val typicalThermalGrid = new container.ThermalGrid( + thermalBusInput, + Seq(typicalThermalHouse).asJava, + Set[ThermalStorageInput](typicalThermalStorage).asJava, + // Set.empty[ThermalStorageInput].asJava, + ) + + protected val typicalHpTypeInput = new HpTypeInput( + UUID.fromString("2829d5eb-352b-40df-a07f-735b65a0a7bd"), + "TypicalHpTypeInput", + Quantities.getQuantity(7500d, PowerSystemUnits.EURO), + Quantities.getQuantity(200d, PowerSystemUnits.EURO_PER_MEGAWATTHOUR), + Quantities.getQuantity(4, PowerSystemUnits.KILOVOLTAMPERE), + 0.95, + Quantities.getQuantity(11, PowerSystemUnits.KILOWATT), + ) + + protected val typicalHpInputModel = new HpInput( + UUID.fromString("1b5e928e-65a3-444c-b7f2-6a48af092224"), + "TypicalHpInput", + OperatorInput.NO_OPERATOR_ASSIGNED, + OperationTime.notLimited(), + nodeInputNoSlackNs04KvA, + thermalBusInput, + new CosPhiFixed("cosPhiFixed:{(0.0,0.95)}"), + null, + typicalHpTypeInput, + ) + protected def thermalGrid( thermalHouse: ThermalHouse, thermalStorage: Option[ThermalStorage] = None,