diff --git a/docs/uml/main/participants/ParticipantStates.puml b/docs/uml/main/participants/ParticipantStates.puml new file mode 100644 index 0000000000..bd83f9fba6 --- /dev/null +++ b/docs/uml/main/participants/ParticipantStates.puml @@ -0,0 +1,35 @@ +@startuml +'https://plantuml.com/state-diagram + +' clock "Cock 0" as C0 with period 50 +skinparam componentStyle rectangle + +package "t0" { + component "State (t0)" as s0 + component "OperatingPoint (t0 - ?)" as op0 + component "OperationRelevantData (t0)" as or0 + +} + +package t1 { + component "State (t1)" as s1 +} + +package t2 { + component "State (t2)" as s2 +} + + +s0 -> op0 +or0 -> op0 + +s0 -> s1 +op0 -> s1 + +s1 -> s2 + +' Layouting: +s0 -[hidden]-> or0 +'States --> OperatingPoints + +@enduml \ No newline at end of file diff --git a/src/main/scala/edu/ie3/simona/agent/participant/ParticipantAgent.scala b/src/main/scala/edu/ie3/simona/agent/participant/ParticipantAgent.scala index 19eff90211..33089d72a8 100644 --- a/src/main/scala/edu/ie3/simona/agent/participant/ParticipantAgent.scala +++ b/src/main/scala/edu/ie3/simona/agent/participant/ParticipantAgent.scala @@ -14,7 +14,7 @@ import edu.ie3.simona.agent.participant.ParticipantAgent.{ getAndCheckNodalVoltage, } import edu.ie3.simona.agent.participant.data.Data -import edu.ie3.simona.agent.participant.data.Data.PrimaryData.PrimaryDataWithApparentPower +import edu.ie3.simona.agent.participant.data.Data.PrimaryData.PrimaryDataWithComplexPower import edu.ie3.simona.agent.participant.data.Data.{PrimaryData, SecondaryData} import edu.ie3.simona.agent.participant.data.secondary.SecondaryDataService import edu.ie3.simona.agent.participant.statedata.BaseStateData.{ @@ -89,7 +89,7 @@ import scala.reflect.ClassTag * @since 2019-07-04 */ abstract class ParticipantAgent[ - PD <: PrimaryDataWithApparentPower[PD], + PD <: PrimaryDataWithComplexPower[PD], CD <: CalcRelevantData, MS <: ModelState, D <: ParticipantStateData[PD], 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 a505696943..92f9166a89 100644 --- a/src/main/scala/edu/ie3/simona/agent/participant/ParticipantAgentFundamentals.scala +++ b/src/main/scala/edu/ie3/simona/agent/participant/ParticipantAgentFundamentals.scala @@ -26,7 +26,7 @@ import edu.ie3.simona.agent.participant.data.Data.PrimaryData.{ ComplexPower, ComplexPowerAndHeat, EnrichableData, - PrimaryDataWithApparentPower, + PrimaryDataWithComplexPower, } import edu.ie3.simona.agent.participant.data.Data.{PrimaryData, SecondaryData} import edu.ie3.simona.agent.participant.data.secondary.SecondaryDataService @@ -103,7 +103,7 @@ import scala.util.{Failure, Success, Try} /** Useful functions to use in [[ParticipantAgent]] s */ protected trait ParticipantAgentFundamentals[ - PD <: PrimaryDataWithApparentPower[PD], + PD <: PrimaryDataWithComplexPower[PD], CD <: CalcRelevantData, MS <: ModelState, D <: ParticipantStateData[PD], diff --git a/src/main/scala/edu/ie3/simona/agent/participant/ServiceRegistration.scala b/src/main/scala/edu/ie3/simona/agent/participant/ServiceRegistration.scala index 5308b4e768..b07a96484e 100644 --- a/src/main/scala/edu/ie3/simona/agent/participant/ServiceRegistration.scala +++ b/src/main/scala/edu/ie3/simona/agent/participant/ServiceRegistration.scala @@ -8,7 +8,7 @@ package edu.ie3.simona.agent.participant import org.apache.pekko.actor.ActorRef import edu.ie3.datamodel.models.input.system.{EvcsInput, SystemParticipantInput} -import edu.ie3.simona.agent.participant.data.Data.PrimaryData.PrimaryDataWithApparentPower +import edu.ie3.simona.agent.participant.data.Data.PrimaryData.PrimaryDataWithComplexPower import edu.ie3.simona.agent.participant.data.Data.SecondaryData import edu.ie3.simona.agent.participant.data.secondary.SecondaryDataService import edu.ie3.simona.agent.participant.data.secondary.SecondaryDataService.{ @@ -28,7 +28,7 @@ import edu.ie3.simona.ontology.messages.services.EvMessage.RegisterForEvDataMess import edu.ie3.simona.ontology.messages.services.WeatherMessage.RegisterForWeatherMessage trait ServiceRegistration[ - PD <: PrimaryDataWithApparentPower[PD], + PD <: PrimaryDataWithComplexPower[PD], CD <: CalcRelevantData, MS <: ModelState, D <: ParticipantStateData[PD], diff --git a/src/main/scala/edu/ie3/simona/agent/participant/data/Data.scala b/src/main/scala/edu/ie3/simona/agent/participant/data/Data.scala index 7da7b04c5c..ee0d792e8f 100644 --- a/src/main/scala/edu/ie3/simona/agent/participant/data/Data.scala +++ b/src/main/scala/edu/ie3/simona/agent/participant/data/Data.scala @@ -15,7 +15,6 @@ import edu.ie3.util.scala.quantities.{Kilovars, ReactivePower} import squants.energy.{Kilowatts, Power} import tech.units.indriya.ComparableQuantity -import java.time.ZonedDateTime import scala.jdk.OptionConverters.RichOptional import scala.util.{Failure, Success, Try} @@ -39,16 +38,22 @@ object Data { def toComplexPower: ComplexPower } + sealed trait PrimaryDataMeta[T <: PrimaryData] { + def zero: T + + def scale(data: T, factor: Double): T + } + object PrimaryData { - sealed trait EnrichableData[E <: PrimaryDataWithApparentPower[E]] { + sealed trait EnrichableData[E <: PrimaryDataWithComplexPower[E]] { def add(q: ReactivePower): E } - /** Denoting all primary data, that carry apparent power + /** Denoting all primary data, that carry complex power */ - sealed trait PrimaryDataWithApparentPower[ - +T <: PrimaryDataWithApparentPower[T] + sealed trait PrimaryDataWithComplexPower[ + +T <: PrimaryDataWithComplexPower[T] ] extends PrimaryData { val q: ReactivePower @@ -81,6 +86,13 @@ object Data { ComplexPower(p, q) } + object ActivePowerMeta extends PrimaryDataMeta[ActivePower] { + override def zero: ActivePower = ActivePower(zeroKW) + + override def scale(data: ActivePower, factor: Double): ActivePower = + ActivePower(data.p * factor) + } + /** Active and Reactive power as participant simulation result * * @param p @@ -91,13 +103,20 @@ object Data { final case class ComplexPower( override val p: Power, override val q: ReactivePower, - ) extends PrimaryDataWithApparentPower[ComplexPower] { + ) extends PrimaryDataWithComplexPower[ComplexPower] { override def toComplexPower: ComplexPower = this override def withReactivePower(q: ReactivePower): ComplexPower = copy(q = q) } + object ComplexPowerMeta extends PrimaryDataMeta[ComplexPower] { + override def zero: ComplexPower = ComplexPower(zeroKW, zeroKVAr) + + override def scale(data: ComplexPower, factor: Double): ComplexPower = + ComplexPower(data.p * factor, data.q * factor) + } + /** Active power and heat demand as participant simulation result * * @param p @@ -121,6 +140,16 @@ object Data { ComplexPowerAndHeat(p, q, qDot) } + object ActivePowerAndHeatMeta extends PrimaryDataMeta[ActivePowerAndHeat] { + override def zero: ActivePowerAndHeat = ActivePowerAndHeat(zeroKW, zeroKW) + + override def scale( + data: ActivePowerAndHeat, + factor: Double, + ): ActivePowerAndHeat = + ActivePowerAndHeat(data.p * factor, data.qDot * factor) + } + /** Apparent power and heat demand as participant simulation result * * @param p @@ -134,7 +163,7 @@ object Data { override val p: Power, override val q: ReactivePower, override val qDot: Power, - ) extends PrimaryDataWithApparentPower[ComplexPowerAndHeat] + ) extends PrimaryDataWithComplexPower[ComplexPowerAndHeat] with Heat { override def toComplexPower: ComplexPower = ComplexPower(p, q) @@ -143,6 +172,22 @@ object Data { copy(q = q) } + object ComplexPowerAndHeatMeta + extends PrimaryDataMeta[ComplexPowerAndHeat] { + override def zero: ComplexPowerAndHeat = + ComplexPowerAndHeat(zeroKW, zeroKVAr, zeroKW) + + override def scale( + data: ComplexPowerAndHeat, + factor: Double, + ): ComplexPowerAndHeat = + ComplexPowerAndHeat( + data.p * factor, + data.q * factor, + data.qDot * factor, + ) + } + implicit class RichValue(private val value: Value) { def toPrimaryData: Try[PrimaryData] = value match { @@ -241,7 +286,6 @@ object Data { */ trait SecondaryData extends Data object SecondaryData { - final case class DateTime(dateTime: ZonedDateTime) extends SecondaryData final case class WholesalePrice(price: ComparableQuantity[EnergyPrice]) extends SecondaryData } diff --git a/src/main/scala/edu/ie3/simona/agent/participant/evcs/EvcsAgent.scala b/src/main/scala/edu/ie3/simona/agent/participant/evcs/EvcsAgent.scala index 5b37ae70e3..069906d402 100644 --- a/src/main/scala/edu/ie3/simona/agent/participant/evcs/EvcsAgent.scala +++ b/src/main/scala/edu/ie3/simona/agent/participant/evcs/EvcsAgent.scala @@ -85,7 +85,7 @@ class EvcsAgent( when(Idle) { case Event( - EvFreeLotsRequest(tick), + EvFreeLotsRequest(tick, _), modelBaseStateData: ParticipantModelBaseStateData[ ComplexPower, EvcsRelevantData, @@ -97,7 +97,7 @@ class EvcsAgent( stay() case Event( - DepartingEvsRequest(tick, departingEvs), + DepartingEvsRequest(tick, departingEvs, _), modelBaseStateData: ParticipantModelBaseStateData[ ComplexPower, EvcsRelevantData, @@ -115,7 +115,7 @@ class EvcsAgent( // in case the activation has arrived first case Event( - EvFreeLotsRequest(tick), + EvFreeLotsRequest(tick, _), stateData: DataCollectionStateData[ComplexPower], ) => stateData.baseStateData match { @@ -134,7 +134,7 @@ class EvcsAgent( } case Event( - DepartingEvsRequest(tick, departingEvs), + DepartingEvsRequest(tick, departingEvs, _), stateData: DataCollectionStateData[ComplexPower], ) => stateData.baseStateData match { diff --git a/src/main/scala/edu/ie3/simona/agent/participant/statedata/BaseStateData.scala b/src/main/scala/edu/ie3/simona/agent/participant/statedata/BaseStateData.scala index 04650f4763..05b3916c35 100644 --- a/src/main/scala/edu/ie3/simona/agent/participant/statedata/BaseStateData.scala +++ b/src/main/scala/edu/ie3/simona/agent/participant/statedata/BaseStateData.scala @@ -7,7 +7,7 @@ package edu.ie3.simona.agent.participant.statedata import edu.ie3.simona.agent.ValueStore -import edu.ie3.simona.agent.participant.data.Data.PrimaryData.PrimaryDataWithApparentPower +import edu.ie3.simona.agent.participant.data.Data.PrimaryData.PrimaryDataWithComplexPower import edu.ie3.simona.agent.participant.data.Data.SecondaryData import edu.ie3.simona.agent.participant.data.secondary.SecondaryDataService import edu.ie3.simona.event.notifier.NotifierConfig @@ -33,10 +33,10 @@ import scala.collection.SortedSet * agents * * @tparam PD - * Type of [[PrimaryDataWithApparentPower]], that the represented Participant + * Type of [[PrimaryDataWithComplexPower]], that the represented Participant * produces */ -trait BaseStateData[+PD <: PrimaryDataWithApparentPower[PD]] +trait BaseStateData[+PD <: PrimaryDataWithComplexPower[PD]] extends ParticipantStateData[PD] { /** The date, that fits the tick 0 @@ -90,7 +90,7 @@ object BaseStateData { /** The agent is supposed to carry out model calculations * * @tparam PD - * Type of [[PrimaryDataWithApparentPower]], that the represented + * Type of [[PrimaryDataWithComplexPower]], that the represented * Participant produces * @tparam CD * Type of [[CalcRelevantData]], that is required by the included model @@ -98,7 +98,7 @@ object BaseStateData { * Restricting the model to a certain class */ trait ModelBaseStateData[ - +PD <: PrimaryDataWithApparentPower[PD], + +PD <: PrimaryDataWithComplexPower[PD], CD <: CalcRelevantData, MS <: ModelState, +M <: SystemParticipant[_ <: CalcRelevantData, PD, MS], @@ -156,7 +156,7 @@ object BaseStateData { _ <: CalcRelevantData, P, _, - ], +P <: PrimaryDataWithApparentPower[P]]( + ], +P <: PrimaryDataWithComplexPower[P]]( model: M, override val startDate: ZonedDateTime, override val endDate: ZonedDateTime, @@ -207,7 +207,7 @@ object BaseStateData { * Type of model, the base state data is attached to */ final case class ParticipantModelBaseStateData[ - +PD <: PrimaryDataWithApparentPower[PD], + +PD <: PrimaryDataWithComplexPower[PD], CD <: CalcRelevantData, MS <: ModelState, M <: SystemParticipant[_ <: CalcRelevantData, PD, MS], @@ -272,7 +272,7 @@ object BaseStateData { * @return * A copy of the base data with updated value stores */ - def updateBaseStateData[PD <: PrimaryDataWithApparentPower[PD]]( + def updateBaseStateData[PD <: PrimaryDataWithComplexPower[PD]]( baseStateData: BaseStateData[PD], updatedResultValueStore: ValueStore[PD], updatedRequestValueStore: ValueStore[PD], diff --git a/src/main/scala/edu/ie3/simona/agent/participant/statedata/DataCollectionStateData.scala b/src/main/scala/edu/ie3/simona/agent/participant/statedata/DataCollectionStateData.scala index 1ce8cf9352..fafb1e5775 100644 --- a/src/main/scala/edu/ie3/simona/agent/participant/statedata/DataCollectionStateData.scala +++ b/src/main/scala/edu/ie3/simona/agent/participant/statedata/DataCollectionStateData.scala @@ -8,7 +8,7 @@ package edu.ie3.simona.agent.participant.statedata import org.apache.pekko.actor.ActorRef import edu.ie3.simona.agent.participant.data.Data -import edu.ie3.simona.agent.participant.data.Data.PrimaryData.PrimaryDataWithApparentPower +import edu.ie3.simona.agent.participant.data.Data.PrimaryData.PrimaryDataWithComplexPower import scala.reflect.{ClassTag, classTag} @@ -24,10 +24,10 @@ import scala.reflect.{ClassTag, classTag} * True, if an [[edu.ie3.simona.ontology.messages.Activation]] has already * arrived * @tparam PD - * Type of the [[PrimaryDataWithApparentPower]], that the model will produce + * Type of the [[PrimaryDataWithComplexPower]], that the model will produce * or receive as primary data */ -final case class DataCollectionStateData[+PD <: PrimaryDataWithApparentPower[ +final case class DataCollectionStateData[+PD <: PrimaryDataWithComplexPower[ PD ]]( baseStateData: BaseStateData[PD], diff --git a/src/main/scala/edu/ie3/simona/agent/participant/statedata/ParticipantStateData.scala b/src/main/scala/edu/ie3/simona/agent/participant/statedata/ParticipantStateData.scala index 786caf63b0..dab7ff586b 100644 --- a/src/main/scala/edu/ie3/simona/agent/participant/statedata/ParticipantStateData.scala +++ b/src/main/scala/edu/ie3/simona/agent/participant/statedata/ParticipantStateData.scala @@ -8,7 +8,7 @@ package edu.ie3.simona.agent.participant.statedata import edu.ie3.datamodel.models.input.container.ThermalGrid import edu.ie3.datamodel.models.input.system.SystemParticipantInput -import edu.ie3.simona.agent.participant.data.Data.PrimaryData.PrimaryDataWithApparentPower +import edu.ie3.simona.agent.participant.data.Data.PrimaryData.PrimaryDataWithComplexPower import edu.ie3.simona.agent.participant.data.Data.{PrimaryData, SecondaryData} import edu.ie3.simona.agent.participant.data.secondary.SecondaryDataService import edu.ie3.simona.config.SimonaConfig @@ -261,11 +261,11 @@ object ParticipantStateData { * Mapping from service provider to foreseen next tick, it will send new * data * @tparam PD - * Type of [[PrimaryDataWithApparentPower]], that is covered by given + * Type of [[PrimaryDataWithComplexPower]], that is covered by given * [[BaseStateData]] */ final case class CollectRegistrationConfirmMessages[ - +PD <: PrimaryDataWithApparentPower[PD] + +PD <: PrimaryDataWithComplexPower[PD] ]( baseStateData: BaseStateData[PD], pendingResponses: Iterable[ClassicActorRef], diff --git a/src/main/scala/edu/ie3/simona/agent/participant2/ParticipantAgent.scala b/src/main/scala/edu/ie3/simona/agent/participant2/ParticipantAgent.scala new file mode 100644 index 0000000000..d76a0745e6 --- /dev/null +++ b/src/main/scala/edu/ie3/simona/agent/participant2/ParticipantAgent.scala @@ -0,0 +1,450 @@ +/* + * © 2024. TU Dortmund University, + * Institute of Energy Systems, Energy Efficiency and Energy Economics, + * Research group Distribution grid planning and operation + */ + +package edu.ie3.simona.agent.participant2 + +import breeze.numerics.{pow, sqrt} +import edu.ie3.simona.agent.grid.GridAgentMessages.{ + AssetPowerChangedMessage, + AssetPowerUnchangedMessage, +} +import edu.ie3.simona.agent.participant.data.Data +import edu.ie3.simona.agent.participant.data.Data.{PrimaryData, PrimaryDataMeta} +import edu.ie3.simona.event.ResultEvent +import edu.ie3.simona.event.ResultEvent.ParticipantResultEvent +import edu.ie3.simona.exceptions.CriticalFailureException +import edu.ie3.simona.model.participant2.ParticipantModelShell +import edu.ie3.simona.ontology.messages.SchedulerMessage.Completion +import edu.ie3.simona.ontology.messages.flex.FlexibilityMessage._ +import edu.ie3.simona.ontology.messages.{Activation, SchedulerMessage} +import edu.ie3.util.scala.Scope +import org.apache.pekko.actor.typed.scaladsl.Behaviors +import org.apache.pekko.actor.typed.{ActorRef, Behavior} +import org.apache.pekko.actor.{ActorRef => ClassicRef} +import squants.{Dimensionless, Each} + +import scala.reflect.ClassTag + +object ParticipantAgent { + + sealed trait Request + + /** Extended by all requests that activate an [[ParticipantAgent]], i.e. + * activations, flex requests and control messages + */ + private[participant2] sealed trait ActivationRequest extends Request { + val tick: Long + } + + /** Wrapper for an [[Activation]] for usage by an adapter. Activations can + * only be received if this agent is not EM-controlled. + * + * @param tick + * The tick to activate + */ + private[participant2] final case class ParticipantActivation( + override val tick: Long + ) extends ActivationRequest + + /** Wrapper for [[FlexRequest]] messages for usage by an adapter (if this + * [[ParticipantAgent]] is EM-controlled itself) + * + * @param msg + * The wrapped flex request + */ + private[participant2] final case class Flex(msg: FlexRequest) + extends ActivationRequest { + override val tick: Long = msg.tick + } + + sealed trait RegistrationResponseMessage extends Request { + val serviceRef: ClassicRef + } + + /** Message, that is used to confirm a successful registration + */ + final case class RegistrationSuccessfulMessage( + override val serviceRef: ClassicRef, + firstDataTick: Long, + ) extends RegistrationResponseMessage + + /** Message, that is used to confirm a successful registration with primary + * service + */ + final case class PrimaryRegistrationSuccessfulMessage[ + P <: PrimaryData: ClassTag + ]( + override val serviceRef: ClassicRef, + firstDataTick: Long, + primaryDataMeta: PrimaryDataMeta[P], + ) extends RegistrationResponseMessage + + /** Message, that is used to announce a failed registration + */ + final case class RegistrationFailedMessage( + override val serviceRef: ClassicRef + ) extends RegistrationResponseMessage + + /** @param tick + * @param serviceRef + * @param data + * @param nextDataTick + * Next tick at which data could arrive. If None, no data is expected for + * the rest of the simulation + * + * @tparam D + * + * TODO this should suffice as secondary data provision message + */ + final case class ProvideData[D <: Data]( + tick: Long, + serviceRef: ClassicRef, + data: D, + nextDataTick: Option[Long], + ) extends Request + + /** Request the power values for the requested tick from an AssetAgent and + * provide the latest nodal voltage + * + * @param currentTick + * The tick that power values are requested for + * @param eInPu + * Real part of the complex, dimensionless nodal voltage + * @param fInPu + * Imaginary part of the complex, dimensionless nodal voltage + */ + final case class RequestAssetPowerMessage( + currentTick: Long, + eInPu: Dimensionless, + fInPu: Dimensionless, + ) extends Request + + /** @param currentTick + * @param nextRequestTick + */ + final case class FinishParticipantSimulation( + currentTick: Long, + nextRequestTick: Long, + ) extends Request + + /** The existence of this data object indicates that the corresponding agent + * is not EM-controlled, but activated by a + * [[edu.ie3.simona.scheduler.Scheduler]] + * + * @param scheduler + * The scheduler that is activating this agent + * @param activationAdapter + * The activation adapter handling [[Activation]] messages + */ + final case class SchedulerData( + scheduler: ActorRef[SchedulerMessage], + activationAdapter: ActorRef[Activation], + ) + + /** The existence of this data object indicates that the corresponding agent + * is EM-controlled (by [[emAgent]]). + * + * @param emAgent + * The parent EmAgent that is controlling this agent. + * @param flexAdapter + * The flex adapter handling [[FlexRequest]] messages + * @param lastFlexOptions + * Last flex options that have been calculated for this agent. + */ + final case class FlexControlledData( + emAgent: ActorRef[FlexResponse], + flexAdapter: ActorRef[FlexRequest], + lastFlexOptions: Option[ProvideFlexOptions] = None, + ) + + /** A request to the participant agent that is not covered by the standard + * ways of interacting with the agent + */ + trait ParticipantRequest extends Request { + + /** The tick for which the request is valid, which is the current tick + */ + val tick: Long + } + + def apply( + modelShell: ParticipantModelShell[_, _], + inputHandler: ParticipantInputHandler, + gridAdapter: ParticipantGridAdapter, + resultListener: Iterable[ActorRef[ResultEvent]], + parentData: Either[SchedulerData, FlexControlledData], + ): Behavior[Request] = + Behaviors.receivePartial { + case (ctx, request: ParticipantRequest) => + val updatedShell = modelShell + .updateModelInput( + inputHandler.getData, + gridAdapter.nodalVoltage, + request.tick, + ) + .handleRequest(ctx, request) + + ParticipantAgent( + updatedShell, + inputHandler, + gridAdapter, + resultListener, + parentData, + ) + + case (_, activation: ActivationRequest) => + val coreWithActivation = inputHandler.handleActivation(activation) + + val (updatedShell, updatedInputHandler, updatedGridAdapter) = + maybeCalculate( + modelShell, + coreWithActivation, + gridAdapter, + resultListener, + parentData, + ) + + ParticipantAgent( + updatedShell, + updatedInputHandler, + updatedGridAdapter, + resultListener, + parentData, + ) + + case (_, msg: ProvideData[Data]) => + val inputHandlerWithData = inputHandler.handleDataProvision(msg) + + val (updatedShell, updatedInputHandler, updatedGridAdapter) = + maybeCalculate( + modelShell, + inputHandlerWithData, + gridAdapter, + resultListener, + parentData, + ) + + ParticipantAgent( + updatedShell, + updatedInputHandler, + updatedGridAdapter, + resultListener, + parentData, + ) + + case (ctx, RequestAssetPowerMessage(currentTick, eInPu, fInPu)) => + // we do not have to wait for the resulting power of the current tick, + // since the current power is irrelevant for the average power up until now + + val activeToReactivePowerFunc = modelShell.activeToReactivePowerFunc + + val nodalVoltage = Each( + sqrt( + pow(eInPu.toEach, 2) + + pow(fInPu.toEach, 2) + ) + ) + + val updatedGridAdapter = gridAdapter + .handlePowerRequest( + nodalVoltage, + currentTick, + Some(activeToReactivePowerFunc), + ctx.log, + ) + + val result = updatedGridAdapter.avgPowerResult.getOrElse( + throw new CriticalFailureException( + "Power result has not been calculated" + ) + ) + gridAdapter.gridAgent ! + (if (result.newResult) { + AssetPowerChangedMessage( + result.avgPower.p, + result.avgPower.q, + ) + } else { + AssetPowerUnchangedMessage( + result.avgPower.p, + result.avgPower.q, + ) + }) + + ParticipantAgent( + modelShell, + inputHandler, + updatedGridAdapter, + resultListener, + parentData, + ) + + case (_, FinishParticipantSimulation(_, nextRequestTick)) => + val gridAdapterFinished = + gridAdapter.updateNextRequestTick(nextRequestTick) + + // Possibly start simulation if we've been activated + val (updatedShell, updatedInputHandler, updatedGridAdapter) = + maybeCalculate( + modelShell, + inputHandler, + gridAdapterFinished, + resultListener, + parentData, + ) + + ParticipantAgent( + updatedShell, + updatedInputHandler, + updatedGridAdapter, + resultListener, + parentData, + ) + } + + private def maybeCalculate( + modelShell: ParticipantModelShell[_, _], + inputHandler: ParticipantInputHandler, + gridAdapter: ParticipantGridAdapter, + listener: Iterable[ActorRef[ResultEvent]], + parentData: Either[SchedulerData, FlexControlledData], + ): ( + ParticipantModelShell[_, _], + ParticipantInputHandler, + ParticipantGridAdapter, + ) = { + if (isReadyForCalculation(inputHandler, gridAdapter)) { + + val activation = inputHandler.activation.getOrElse( + throw new CriticalFailureException( + "Activation should be present when data collection is complete" + ) + ) + + val (updatedShell, updatedGridAdapter) = Scope(modelShell) + .map( + _.updateModelInput( + inputHandler.getData, + gridAdapter.nodalVoltage, + activation.tick, + ) + ) + .map { shell => + activation match { + case ParticipantActivation(tick) => + val shellWithOP = shell.updateOperatingPoint(tick) + + val results = + shellWithOP.determineResults(tick, gridAdapter.nodalVoltage) + + results.modelResults.foreach { res => + listener.foreach(_ ! ParticipantResultEvent(res)) + } + + val gridAdapterWithResult = + gridAdapter.storePowerValue(results.totalPower, tick) + + val changeIndicator = shellWithOP.getChangeIndicator( + tick, + inputHandler.getNextActivationTick, + ) + + parentData.fold( + schedulerData => + schedulerData.scheduler ! Completion( + schedulerData.activationAdapter, + changeIndicator.changesAtTick, + ), + _ => + throw new CriticalFailureException( + "Received activation while controlled by EM" + ), + ) + (shellWithOP, gridAdapterWithResult) + + case Flex(FlexActivation(tick)) => + val modelWithFlex = shell.updateFlexOptions(tick) + + parentData.fold( + _ => + throw new CriticalFailureException( + "Received flex activation while not controlled by EM" + ), + _.emAgent ! modelWithFlex.flexOptions, + ) + + (modelWithFlex, gridAdapter) + + case Flex(flexControl: IssueFlexControl) => + val shellWithOP = shell.updateOperatingPoint(flexControl) + + val results = + shellWithOP.determineResults( + flexControl.tick, + gridAdapter.nodalVoltage, + ) + + results.modelResults.foreach { res => + listener.foreach(_ ! ParticipantResultEvent(res)) + } + + val gridAdapterWithResult = + gridAdapter.storePowerValue( + results.totalPower, + flexControl.tick, + ) + + val changeIndicator = shellWithOP.getChangeIndicator( + flexControl.tick, + inputHandler.getNextActivationTick, + ) + + parentData.fold( + _ => + throw new CriticalFailureException( + "Received issue flex control while not controlled by EM" + ), + _.emAgent ! FlexCompletion( + shellWithOP.uuid, + changeIndicator.changesAtNextActivation, + changeIndicator.changesAtTick, + ), + ) + + (shellWithOP, gridAdapterWithResult) + } + } + .get + + (updatedShell, inputHandler.completeActivity(), updatedGridAdapter) + } else + (modelShell, inputHandler, gridAdapter) + } + + /** Checks if all requirements for calculation have been met. These are: + * - agent is activated (activation has been received and not completed + * yet) + * - all required data has been received + * - the grid adapter is not waiting for power requests + * + * @param inputHandler + * The participant input handler + * @param gridAdapter + * The participant grid adapter + * @return + * Whether power can be calculated or not. + */ + private def isReadyForCalculation( + inputHandler: ParticipantInputHandler, + gridAdapter: ParticipantGridAdapter, + ): Boolean = { + inputHandler.isComplete && + inputHandler.activation.exists(activation => + !gridAdapter.isPowerRequestAwaited(activation.tick) + ) + } + +} diff --git a/src/main/scala/edu/ie3/simona/agent/participant2/ParticipantAgentInit.scala b/src/main/scala/edu/ie3/simona/agent/participant2/ParticipantAgentInit.scala new file mode 100644 index 0000000000..f6144e3dc0 --- /dev/null +++ b/src/main/scala/edu/ie3/simona/agent/participant2/ParticipantAgentInit.scala @@ -0,0 +1,297 @@ +/* + * © 2024. TU Dortmund University, + * Institute of Energy Systems, Energy Efficiency and Energy Economics, + * Research group Distribution grid planning and operation + */ + +package edu.ie3.simona.agent.participant2 + +import edu.ie3.datamodel.models.input.system.SystemParticipantInput +import edu.ie3.simona.agent.grid.GridAgent +import edu.ie3.simona.agent.participant2.ParticipantAgent._ +import edu.ie3.simona.config.SimonaConfig.BaseRuntimeConfig +import edu.ie3.simona.event.ResultEvent +import edu.ie3.simona.exceptions.CriticalFailureException +import edu.ie3.simona.model.participant2.ParticipantModelShell +import edu.ie3.simona.ontology.messages.SchedulerMessage.{ + Completion, + ScheduleActivation, +} +import edu.ie3.simona.ontology.messages.flex.FlexibilityMessage._ +import edu.ie3.simona.ontology.messages.services.ServiceMessage.PrimaryServiceRegistrationMessage +import edu.ie3.simona.ontology.messages.{Activation, SchedulerMessage} +import edu.ie3.simona.service.ServiceType +import edu.ie3.simona.util.SimonaConstants.INIT_SIM_TICK +import org.apache.pekko.actor.typed.scaladsl.Behaviors +import org.apache.pekko.actor.typed.{ActorRef, Behavior} +import org.apache.pekko.actor.{ActorRef => ClassicRef} + +import java.time.ZonedDateTime + +object ParticipantAgentInit { + + // todo also register with GridAgent, + // wait for reply and then create + // GridAdapter + + /** Container class, that gather together reference to relevant entities, that + * represent the environment in the simulation + * + * @param gridAgent + * Reference to the grid agent + * @param primaryServiceProxy + * Reference to the primary service proxy + * @param services + * References to services by service type + * @param resultListener + * Reference to the result listeners + */ + final case class ParticipantRefs( + gridAgent: ActorRef[GridAgent.Request], + primaryServiceProxy: ClassicRef, + services: Map[ServiceType, ClassicRef], + resultListener: Iterable[ActorRef[ResultEvent]], + ) + + def apply( + participantInput: SystemParticipantInput, + config: BaseRuntimeConfig, + participantRefs: ParticipantRefs, + expectedPowerRequestTick: Long, + simulationStartDate: ZonedDateTime, + simulationEndDate: ZonedDateTime, + parent: Either[ActorRef[SchedulerMessage], ActorRef[FlexResponse]], + ): Behavior[Request] = Behaviors.setup { ctx => + val parentData = parent + .map { em => + val flexAdapter = ctx.messageAdapter[FlexRequest](Flex) + + em ! RegisterParticipant( + participantInput.getUuid, + flexAdapter, + participantInput, + ) + + em ! ScheduleFlexRequest( + participantInput.getUuid, + INIT_SIM_TICK, + ) + + FlexControlledData(em, flexAdapter) + } + .left + .map { scheduler => + { + val activationAdapter = ctx.messageAdapter[Activation] { msg => + ParticipantActivation(msg.tick) + } + + scheduler ! ScheduleActivation( + activationAdapter, + INIT_SIM_TICK, + ) + + SchedulerData(scheduler, activationAdapter) + } + } + + uninitialized( + participantInput, + config, + participantRefs, + expectedPowerRequestTick, + simulationStartDate, + simulationEndDate, + parentData, + ) + } + + private def uninitialized( + participantInput: SystemParticipantInput, + config: BaseRuntimeConfig, + participantRefs: ParticipantRefs, + expectedPowerRequestTick: Long, + simulationStartDate: ZonedDateTime, + simulationEndDate: ZonedDateTime, + parentData: Either[SchedulerData, FlexControlledData], + ): Behavior[Request] = Behaviors.receiveMessagePartial { + + case activation: ActivationRequest if activation.tick == INIT_SIM_TICK => + participantRefs.primaryServiceProxy ! PrimaryServiceRegistrationMessage( + participantInput.getUuid + ) + + waitingForPrimaryProxy( + participantInput, + config, + participantRefs, + expectedPowerRequestTick, + simulationStartDate, + simulationEndDate, + parentData, + ) + + } + + private def waitingForPrimaryProxy( + participantInput: SystemParticipantInput, + config: BaseRuntimeConfig, + participantRefs: ParticipantRefs, + expectedPowerRequestTick: Long, + simulationStartDate: ZonedDateTime, + simulationEndDate: ZonedDateTime, + parentData: Either[SchedulerData, FlexControlledData], + ): Behavior[Request] = Behaviors.receivePartial { + + case ( + _, + PrimaryRegistrationSuccessfulMessage( + serviceRef, + firstDataTick, + primaryDataMeta, + ), + ) => + val expectedFirstData = Map(serviceRef -> firstDataTick) + + completeInitialization( + ParticipantModelShell.createForPrimaryData( + participantInput, + config, + primaryDataMeta, + simulationStartDate, + simulationEndDate, + ), + expectedFirstData, + participantRefs, + expectedPowerRequestTick, + parentData, + firstDataTick, + ) + + case (_, RegistrationFailedMessage(_)) => + val modelShell = ParticipantModelShell.createForModel( + participantInput, + config, + simulationStartDate, + simulationEndDate, + ) + + val requiredServiceTypes = modelShell.requiredServices.toSet + + if (requiredServiceTypes.isEmpty) { + // Models that do not use secondary data always start at tick 0 + val firstTick = 0L + + completeInitialization( + modelShell, + Map.empty, + participantRefs, + expectedPowerRequestTick, + parentData, + firstTick, + ) + } else { + val requiredServices = requiredServiceTypes.map(serviceType => + participantRefs.services.getOrElse( + serviceType, + throw new CriticalFailureException( + s"Service of type $serviceType is not available." + ), + ) + ) + + waitingForServices( + modelShell, + participantRefs, + expectedPowerRequestTick, + requiredServices, + parentData = parentData, + ) + } + } + + private def waitingForServices( + modelShell: ParticipantModelShell[_, _], + participantRefs: ParticipantRefs, + expectedPowerRequestTick: Long, + expectedRegistrations: Set[ClassicRef], + expectedFirstData: Map[ClassicRef, Long] = Map.empty, + parentData: Either[SchedulerData, FlexControlledData], + ): Behavior[Request] = + Behaviors.receivePartial { + case (_, RegistrationSuccessfulMessage(serviceRef, nextDataTick)) => + if (!expectedRegistrations.contains(serviceRef)) + throw new CriticalFailureException( + s"Registration response from $serviceRef was not expected!" + ) + + val newExpectedRegistrations = expectedRegistrations.excl(serviceRef) + val newExpectedFirstData = + expectedFirstData.updated(serviceRef, nextDataTick) + + if (newExpectedRegistrations.isEmpty) { + val firstTick = expectedFirstData + .map { case (_, nextTick) => + nextTick + } + .minOption + .getOrElse( + throw new CriticalFailureException("No expected data registered.") + ) + + completeInitialization( + modelShell, + newExpectedFirstData, + participantRefs, + expectedPowerRequestTick, + parentData, + firstTick, + ) + } else + waitingForServices( + modelShell, + participantRefs, + expectedPowerRequestTick, + newExpectedRegistrations, + newExpectedFirstData, + parentData, + ) + } + + /** Completes initialization activation and creates actual + * [[ParticipantAgent]] + */ + private def completeInitialization( + modelShell: ParticipantModelShell[_, _], + expectedData: Map[ClassicRef, Long], + participantRefs: ParticipantRefs, + expectedPowerRequestTick: Long, + parentData: Either[SchedulerData, FlexControlledData], + firstTick: Long, + ): Behavior[Request] = { + + parentData.fold( + schedulerData => + schedulerData.scheduler ! Completion( + schedulerData.activationAdapter, + Some(firstTick), + ), + _.emAgent ! FlexCompletion( + modelShell.uuid, + requestAtNextActivation = false, + Some(firstTick), + ), + ) + + ParticipantAgent( + modelShell, + ParticipantInputHandler(expectedData), + ParticipantGridAdapter( + participantRefs.gridAgent, + expectedPowerRequestTick, + ), + participantRefs.resultListener, + parentData, + ) + } +} diff --git a/src/main/scala/edu/ie3/simona/agent/participant2/ParticipantGridAdapter.scala b/src/main/scala/edu/ie3/simona/agent/participant2/ParticipantGridAdapter.scala new file mode 100644 index 0000000000..6dd5e50f9d --- /dev/null +++ b/src/main/scala/edu/ie3/simona/agent/participant2/ParticipantGridAdapter.scala @@ -0,0 +1,231 @@ +/* + * © 2024. TU Dortmund University, + * Institute of Energy Systems, Energy Efficiency and Energy Economics, + * Research group Distribution grid planning and operation + */ + +package edu.ie3.simona.agent.participant2 + +import edu.ie3.simona.agent.grid.GridAgent +import edu.ie3.simona.agent.participant.data.Data.PrimaryData.ComplexPower +import edu.ie3.simona.agent.participant2.ParticipantGridAdapter._ +import edu.ie3.simona.exceptions.CriticalFailureException +import edu.ie3.util.scala.quantities.DefaultQuantities.{zeroMVAr, zeroMW} +import edu.ie3.util.scala.quantities.{Megavars, QuantityUtil, ReactivePower} +import org.apache.pekko.actor.typed.ActorRef +import org.slf4j.Logger +import squants.energy.Megawatts +import squants.{Dimensionless, Each, Energy, Power} + +import scala.collection.immutable.SortedMap +import scala.util.{Failure, Success} + +/** Provides (average) power values to grid agent + * + * @param gridAgent + * The ActorRef for the [[GridAgent]] + * @param expectedRequestTick + * Tick at which next power request is expected + * @param nodalVoltage + * The currently valid nodal voltage in p.u. + * @param tickToPower + * power values + * @param avgPowerResult + * The calculated average power for the current request window + */ +final case class ParticipantGridAdapter( + gridAgent: ActorRef[GridAgent.Request], + nodalVoltage: Dimensionless, + expectedRequestTick: Long, + tickToPower: SortedMap[Long, ComplexPower], + avgPowerResult: Option[AvgPowerResult], +) { + + /** Whether a power request is expected and has not yet arrived, thus is + * awaited, for the given tick. + * + * @param currentTick + * The current tick + * @return + * Whether a power request is awaited for the given tick + */ + def isPowerRequestAwaited(currentTick: Long): Boolean = { + expectedRequestTick == currentTick + } + + def storePowerValue( + power: ComplexPower, + tick: Long, + ): ParticipantGridAdapter = + copy(tickToPower = tickToPower.updated(tick, power)) + + def handlePowerRequest( + newVoltage: Dimensionless, + currentTick: Long, + activeToReactivePowerFuncOpt: Option[ + Dimensionless => Power => ReactivePower + ], + log: Logger, + ): ParticipantGridAdapter = { + if (currentTick != expectedRequestTick) + throw new CriticalFailureException( + s"Power request expected for $expectedRequestTick, but not for current tick $currentTick" + ) + + implicit val voltageTolerance: Dimensionless = Each( + 1e-3 + ) // todo requestVoltageDeviationThreshold + + val result = (avgPowerResult match { + case Some(cache @ AvgPowerResult(windowStart, windowEnd, voltage, _, _)) + if windowEnd == currentTick => + // Results have been calculated for the same tick... + if (voltage =~ newVoltage) { + // ... and same voltage, return cached result + Left(cache) + } else { + // ... and different voltage, results have to be re-calculated with same params + Right(windowStart, windowEnd) + } + case Some(AvgPowerResult(_, windowEnd, _, _, _)) => + // Results have been calculated for a former tick, take former windowEnd as the new windowStart + Right(windowEnd, currentTick) + case None => + // No results have been calculated whatsoever, calculate from simulation start (0) + Right(0L, currentTick) + }).fold( + cachedResult => cachedResult.copy(newResult = false), + { case (windowStart: Long, windowEnd: Long) => + val avgPower = averageApparentPower( + tickToPower, + windowStart, + windowEnd, + activeToReactivePowerFuncOpt.map(_.apply(newVoltage)), + log, + ) + AvgPowerResult( + windowStart, + windowEnd, + newVoltage, + avgPower, + newResult = true, + ) + }, + ) + + val reducedMap = reduceTickToPowerMap(tickToPower, result.windowStart) + + copy( + nodalVoltage = newVoltage, + tickToPower = reducedMap, + avgPowerResult = Some(result), + ) + } + + def updateNextRequestTick(nextRequestTick: Long): ParticipantGridAdapter = + copy(expectedRequestTick = nextRequestTick) + +} + +object ParticipantGridAdapter { + + final case class AvgPowerResult( + windowStart: Long, + windowEnd: Long, + voltage: Dimensionless, + avgPower: ComplexPower, + newResult: Boolean, + ) + + def apply( + gridAgentRef: ActorRef[GridAgent.Request], + expectedRequestTick: Long, + ): ParticipantGridAdapter = + new ParticipantGridAdapter( + gridAgent = gridAgentRef, + nodalVoltage = Each(1d), + expectedRequestTick = expectedRequestTick, + tickToPower = SortedMap.empty, + avgPowerResult = None, + ) + + private def reduceTickToPowerMap( + tickToPower: SortedMap[Long, ComplexPower], + windowStart: Long, + ): SortedMap[Long, ComplexPower] = { + // keep the last entry at or before windowStart + val lastTickBeforeWindowStart = + tickToPower.rangeUntil(windowStart + 1).lastOption + + // remove all entries before or at windowStart + val reducedMap = tickToPower.rangeFrom(windowStart + 1) + + // combine both + lastTickBeforeWindowStart.map(reducedMap + _).getOrElse(reducedMap) + } + + /** Determine the average apparent power within the given tick window + * + * @param tickToPower + * Mapping from data tick to actual data + * @param windowStart + * First, included tick of the time window + * @param windowEnd + * Last, included tick of the time window + * @param activeToReactivePowerFuncOpt + * An Option on a function, that transfers the active into reactive power + * @return + * The averaged apparent power + */ + private def averageApparentPower( + tickToPower: Map[Long, ComplexPower], + windowStart: Long, + windowEnd: Long, + activeToReactivePowerFuncOpt: Option[ + Power => ReactivePower + ] = None, + log: Logger, + ): ComplexPower = { + val p = QuantityUtil.average[Power, Energy]( + tickToPower.map { case (tick, pd) => + tick -> pd.p + }, + windowStart, + windowEnd, + ) match { + case Success(pSuccess) => + pSuccess + case Failure(exception) => + log.warn( + "Unable to determine average active power. Apply 0 instead.", + exception, + ) + zeroMW + } + + val q = QuantityUtil.average[Power, Energy]( + tickToPower.map { case (tick, pd) => + activeToReactivePowerFuncOpt match { + case Some(qFunc) => + // NOTE: The type conversion to Megawatts is done to satisfy the methods type constraints + // and is undone after unpacking the results + tick -> Megawatts(qFunc(pd.p).toMegavars) + case None => tick -> Megawatts(pd.q.toMegavars) + } + }, + windowStart, + windowEnd, + ) match { + case Success(pSuccess) => + Megavars(pSuccess.toMegawatts) + case Failure(exception) => + log.warn( + "Unable to determine average reactive power. Apply 0 instead.", + exception, + ) + zeroMVAr + } + + ComplexPower(p, q) + } +} diff --git a/src/main/scala/edu/ie3/simona/agent/participant2/ParticipantInputHandler.scala b/src/main/scala/edu/ie3/simona/agent/participant2/ParticipantInputHandler.scala new file mode 100644 index 0000000000..8052d3e290 --- /dev/null +++ b/src/main/scala/edu/ie3/simona/agent/participant2/ParticipantInputHandler.scala @@ -0,0 +1,74 @@ +/* + * © 2024. TU Dortmund University, + * Institute of Energy Systems, Energy Efficiency and Energy Economics, + * Research group Distribution grid planning and operation + */ + +package edu.ie3.simona.agent.participant2 + +import org.apache.pekko.actor.{ActorRef => ClassicRef} +import edu.ie3.simona.agent.participant.data.Data +import edu.ie3.simona.agent.participant2.ParticipantAgent.{ + ActivationRequest, + ProvideData, +} + +final case class ParticipantInputHandler( + expectedData: Map[ClassicRef, Long], + receivedData: Map[ClassicRef, Option[_ <: Data]], + activation: Option[ActivationRequest], +) { + + // holds active tick and received data, + // knows what data is expected and can thus decide whether everything is complete + + // holds results as well? or no? + + def handleActivation( + activation: ActivationRequest + ): ParticipantInputHandler = { + copy(activation = Some(activation)) + } + + def completeActivity(): ParticipantInputHandler = { + copy(activation = None) + } + + def handleDataProvision( + msg: ProvideData[_ <: Data] + ): ParticipantInputHandler = { + val updatedReceivedData = receivedData + (msg.serviceRef -> Some(msg.data)) + val updatedExpectedData = msg.nextDataTick + .map { nextTick => + expectedData + (msg.serviceRef -> nextTick) + } + .getOrElse { + expectedData - msg.serviceRef + } + + copy(expectedData = updatedExpectedData, receivedData = updatedReceivedData) + } + + def isComplete: Boolean = activation.exists { activationMsg => + expectedData.forall { case (_, nextTick) => + nextTick > activationMsg.tick + } + } + + def getNextActivationTick: Option[Long] = + expectedData.values.minOption + + def getData: Seq[Data] = + receivedData.values.flatten.toSeq + +} + +object ParticipantInputHandler { + + def apply(expectedData: Map[ClassicRef, Long]): ParticipantInputHandler = + new ParticipantInputHandler( + expectedData = expectedData, + receivedData = Map.empty, + activation = None, + ) +} diff --git a/src/main/scala/edu/ie3/simona/api/ExtSimAdapter.scala b/src/main/scala/edu/ie3/simona/api/ExtSimAdapter.scala index b71adf4a30..0093442a83 100644 --- a/src/main/scala/edu/ie3/simona/api/ExtSimAdapter.scala +++ b/src/main/scala/edu/ie3/simona/api/ExtSimAdapter.scala @@ -22,7 +22,7 @@ import edu.ie3.simona.ontology.messages.SchedulerMessage.{ Completion, ScheduleActivation, } -import edu.ie3.simona.ontology.messages.services.ServiceMessage.RegistrationResponseMessage.ScheduleServiceActivation +import edu.ie3.simona.ontology.messages.services.ServiceMessage.ScheduleServiceActivation import edu.ie3.simona.ontology.messages.Activation import edu.ie3.simona.scheduler.ScheduleLock import edu.ie3.simona.scheduler.ScheduleLock.ScheduleKey diff --git a/src/main/scala/edu/ie3/simona/io/result/AccompaniedSimulationResult.scala b/src/main/scala/edu/ie3/simona/io/result/AccompaniedSimulationResult.scala index a73a854de4..92c8b491ce 100644 --- a/src/main/scala/edu/ie3/simona/io/result/AccompaniedSimulationResult.scala +++ b/src/main/scala/edu/ie3/simona/io/result/AccompaniedSimulationResult.scala @@ -7,16 +7,17 @@ package edu.ie3.simona.io.result import edu.ie3.datamodel.models.result.ResultEntity -import edu.ie3.simona.agent.participant.data.Data.PrimaryData.PrimaryDataWithApparentPower +import edu.ie3.simona.agent.participant.data.Data.PrimaryData.PrimaryDataWithComplexPower /** A class to offer means to transport accompanying results alongside of - * [[PrimaryDataWithApparentPower]], e.g. heat results obtained during a + * [[PrimaryDataWithComplexPower]], e.g. heat results obtained during a * simulation + * * @param primaryData * The original primary data of the electrical asset * @tparam PD * Type of primary data, that is carried */ -final case class AccompaniedSimulationResult[PD <: PrimaryDataWithApparentPower[ +final case class AccompaniedSimulationResult[PD <: PrimaryDataWithComplexPower[ PD ]](primaryData: PD, accompanyingResults: Seq[ResultEntity] = Seq.empty) diff --git a/src/main/scala/edu/ie3/simona/model/participant/SystemParticipant.scala b/src/main/scala/edu/ie3/simona/model/participant/SystemParticipant.scala index 1aa4377f8e..7fee381e11 100644 --- a/src/main/scala/edu/ie3/simona/model/participant/SystemParticipant.scala +++ b/src/main/scala/edu/ie3/simona/model/participant/SystemParticipant.scala @@ -8,7 +8,7 @@ package edu.ie3.simona.model.participant import edu.ie3.simona.agent.participant.data.Data.PrimaryData.{ ComplexPower, - PrimaryDataWithApparentPower, + PrimaryDataWithComplexPower, } import edu.ie3.simona.model.SystemComponent import edu.ie3.simona.model.participant.control.QControl @@ -44,7 +44,7 @@ import java.util.UUID */ abstract class SystemParticipant[ CD <: CalcRelevantData, - +PD <: PrimaryDataWithApparentPower[PD], + +PD <: PrimaryDataWithComplexPower[PD], MS <: ModelState, ]( uuid: UUID, diff --git a/src/main/scala/edu/ie3/simona/model/participant/load/profile/LoadProfileStore.scala b/src/main/scala/edu/ie3/simona/model/participant/load/profile/LoadProfileStore.scala index 209d9349c8..31e997b215 100644 --- a/src/main/scala/edu/ie3/simona/model/participant/load/profile/LoadProfileStore.scala +++ b/src/main/scala/edu/ie3/simona/model/participant/load/profile/LoadProfileStore.scala @@ -18,8 +18,7 @@ import edu.ie3.simona.model.participant.load.profile.LoadProfileStore.{ } import edu.ie3.simona.model.participant.load.{DayType, profile} import org.apache.commons.csv.CSVFormat -import squants.Power -import squants.energy.{KilowattHours, Watts} +import squants.energy.{Energy, KilowattHours, Power, Watts} import java.io.{InputStreamReader, Reader} import java.time.{Duration, ZonedDateTime} @@ -58,7 +57,7 @@ class LoadProfileStore private (val reader: Reader) { def entry( time: ZonedDateTime, loadProfile: StandardLoadProfile, - ): squants.Power = { + ): Power = { val key = LoadProfileKey(loadProfile, time) profileMap.get(key) match { case Some(typeDayValues) => @@ -112,7 +111,7 @@ object LoadProfileStore extends LazyLogging { /** Default standard load profile energy scaling */ - val defaultLoadProfileEnergyScaling: squants.Energy = KilowattHours(1000d) + val profileReferenceEnergy: Energy = KilowattHours(1000d) /** Default entry point to get the default implementation with the provided * default standard load profiles @@ -200,35 +199,31 @@ object LoadProfileStore extends LazyLogging { val knownLoadProfiles: Set[StandardLoadProfile] = profileMap.keySet.map(key => key.standardLoadProfile) - knownLoadProfiles - .flatMap(loadProfile => { - (loadProfile match { - case BdewStandardLoadProfile.H0 => - // max load for h0 is expected to be exclusively found in winter, - // thus we only search there. - DayType.values - .map(dayType => { - val key = - profile.LoadProfileKey(loadProfile, Season.winter, dayType) - // maximum dynamization factor is on day 366 (leap year) or day 365 (regular year). - // The difference between day 365 and day 366 is negligible, thus pick 366 - profileMap - .get(key) - .map(typeDay => dynamization(typeDay.getMaxValue, 366)) - .getOrElse(0d) - }) - .maxOption - case _ => - (for (season <- Season.values; dayType <- DayType.values) yield { - val key = profile.LoadProfileKey(loadProfile, season, dayType) - profileMap.get(key) match { - case Some(value) => Option(value.getMaxValue) - case None => None - } - }).flatten.maxOption - }).map(maxConsumption => loadProfile -> maxConsumption) - }) - .toMap + knownLoadProfiles.flatMap { loadProfile => + val dyn = loadProfile match { + case BdewStandardLoadProfile.H0 => + // Pick the maximum dynamization factor per season, including leap years and non-leap years + (season: Season.Value, value: Double) => { + val day = season match { + case Season.winter => 366 // leap year + case Season.transition => 80 // non-leap year + case Season.summer => 135 // non-leap year + } + dynamization(value, day) + } + + case _ => + (_: Season.Value, value: Double) => value + } + + (for (season <- Season.values; dayType <- DayType.values) yield { + val key = + profile.LoadProfileKey(loadProfile, season, dayType) + profileMap + .get(key) + .map(typeDay => dyn(season, typeDay.getMaxValue)) + }).flatten.maxOption.map(maxConsumption => loadProfile -> maxConsumption) + }.toMap } /** @return diff --git a/src/main/scala/edu/ie3/simona/model/participant/load/profile/ProfileLoadModel.scala b/src/main/scala/edu/ie3/simona/model/participant/load/profile/ProfileLoadModel.scala index e30390bb74..c0ecbd6baf 100644 --- a/src/main/scala/edu/ie3/simona/model/participant/load/profile/ProfileLoadModel.scala +++ b/src/main/scala/edu/ie3/simona/model/participant/load/profile/ProfileLoadModel.scala @@ -67,7 +67,7 @@ final case class ProfileLoadModel( private lazy val energyReferenceScalingFactor = reference match { case EnergyConsumption(energyConsumption) => - energyConsumption / LoadProfileStore.defaultLoadProfileEnergyScaling + energyConsumption / LoadProfileStore.profileReferenceEnergy case _ => throw new IllegalArgumentException( s"Applying energy reference scaling factor for reference mode '$reference' is not supported!" @@ -129,7 +129,7 @@ object ProfileLoadModel { scaledInput, energyConsumption, loadProfileMax, - LoadProfileStore.defaultLoadProfileEnergyScaling, + LoadProfileStore.profileReferenceEnergy, ) } diff --git a/src/main/scala/edu/ie3/simona/model/participant2/ChargingHelper.scala b/src/main/scala/edu/ie3/simona/model/participant2/ChargingHelper.scala new file mode 100644 index 0000000000..d13da64a46 --- /dev/null +++ b/src/main/scala/edu/ie3/simona/model/participant2/ChargingHelper.scala @@ -0,0 +1,77 @@ +/* + * © 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.participant2 + +import edu.ie3.util.scala.quantities.DefaultQuantities.{zeroKW, zeroKWh} +import squants.{Dimensionless, Each, Energy, Power, Seconds} + +object ChargingHelper { + + def calcEnergy( + storedEnergy: Energy, + power: Power, + startTick: Long, + endTick: Long, + maxEnergy: Energy, + minEnergy: Energy = zeroKWh, + eta: Dimensionless = Each(1), + ): Energy = { + val timespan = Seconds(endTick - startTick) + val energyChange = calcNetPower(power, eta) * timespan + + // don't allow under- or overcharge e.g. due to tick rounding error + minEnergy.max(maxEnergy.min(storedEnergy + energyChange)) + } + + def calcNextEventTick( + storedEnergy: Energy, + power: Power, + currentTick: Long, + chargingEnergyTarget: () => Energy, + dischargingEnergyTarget: () => Energy, + eta: Dimensionless = Each(1), + )(implicit tolerance: Power): Option[Long] = { + val netPower = calcNetPower(power, eta) + + val maybeTimeSpan = + if (netPower ~= zeroKW) { + // we're at 0 kW, do nothing + None + } else if (netPower > zeroKW) { + val energyToFull = chargingEnergyTarget() - storedEnergy + Some(energyToFull / netPower) + } else { + val energyToEmpty = storedEnergy - dischargingEnergyTarget() + Some(energyToEmpty / (netPower * -1)) + } + + // calculate the tick from time span + maybeTimeSpan.map { timeSpan => + val timeSpanTicks = Math.round(timeSpan.toSeconds) + currentTick + timeSpanTicks + } + } + + /** Calculate net power (after considering efficiency eta). + * + * @param setPower + * The gross power + * @param eta + * The efficiency + * @return + * The net power + */ + private def calcNetPower(setPower: Power, eta: Dimensionless): Power = + if (setPower > zeroKW) { + // multiply eta if we're charging + setPower * eta.toEach + } else { + // divide by eta if we're discharging + // (draining the battery more than we get as output) + setPower / eta.toEach + } +} diff --git a/src/main/scala/edu/ie3/simona/model/participant2/FixedFeedInModel.scala b/src/main/scala/edu/ie3/simona/model/participant2/FixedFeedInModel.scala new file mode 100644 index 0000000000..40410db6c5 --- /dev/null +++ b/src/main/scala/edu/ie3/simona/model/participant2/FixedFeedInModel.scala @@ -0,0 +1,104 @@ +/* + * © 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.participant2 + +import edu.ie3.datamodel.models.input.system.FixedFeedInInput +import edu.ie3.datamodel.models.result.system.{ + FixedFeedInResult, + SystemParticipantResult, +} +import edu.ie3.simona.agent.participant.data.Data.PrimaryData.{ + ComplexPower, + PrimaryDataWithComplexPower, +} +import edu.ie3.simona.model.participant.control.QControl +import edu.ie3.simona.model.participant2.ParticipantFlexibility.ParticipantSimpleFlexibility +import edu.ie3.simona.model.participant2.ParticipantModel.{ + ActivePowerOperatingPoint, + FixedState, + ParticipantFixedState, +} +import edu.ie3.simona.service.ServiceType +import edu.ie3.util.quantities.PowerSystemUnits +import edu.ie3.util.quantities.QuantityUtils.RichQuantityDouble +import edu.ie3.util.scala.quantities.{ApparentPower, Kilovoltamperes} + +import java.time.ZonedDateTime +import java.util.UUID + +class FixedFeedInModel( + override val uuid: UUID, + override val sRated: ApparentPower, + override val cosPhiRated: Double, + override val qControl: QControl, +) extends ParticipantModel[ + ActivePowerOperatingPoint, + FixedState, + ] + with ParticipantFixedState[ActivePowerOperatingPoint] + with ParticipantSimpleFlexibility[FixedState] { + + override def determineOperatingPoint( + state: ParticipantModel.FixedState + ): (ActivePowerOperatingPoint, Option[Long]) = { + val power = pRated * -1 + + (ActivePowerOperatingPoint(power), None) + } + + override def zeroPowerOperatingPoint: ActivePowerOperatingPoint = + ActivePowerOperatingPoint.zero + + override def createResults( + state: ParticipantModel.FixedState, + lastOperatingPoint: Option[ActivePowerOperatingPoint], + currentOperatingPoint: ActivePowerOperatingPoint, + complexPower: ComplexPower, + dateTime: ZonedDateTime, + ): Iterable[SystemParticipantResult] = + Iterable( + new FixedFeedInResult( + dateTime, + uuid, + complexPower.p.toMegawatts.asMegaWatt, + complexPower.q.toMegavars.asMegaVar, + ) + ) + + override def createPrimaryDataResult( + data: PrimaryDataWithComplexPower[_], + dateTime: ZonedDateTime, + ): SystemParticipantResult = + new FixedFeedInResult( + dateTime, + uuid, + data.p.toMegawatts.asMegaWatt, + data.q.toMegavars.asMegaVar, + ) + + override def getRequiredSecondaryServices: Iterable[ServiceType] = + Iterable.empty + +} + +object FixedFeedInModel { + def apply( + inputModel: FixedFeedInInput + ): FixedFeedInModel = { + new FixedFeedInModel( + inputModel.getUuid, + Kilovoltamperes( + inputModel.getsRated + .to(PowerSystemUnits.KILOVOLTAMPERE) + .getValue + .doubleValue + ), + inputModel.getCosPhiRated, + QControl.apply(inputModel.getqCharacteristics), + ) + } +} diff --git a/src/main/scala/edu/ie3/simona/model/participant2/ParticipantFlexibility.scala b/src/main/scala/edu/ie3/simona/model/participant2/ParticipantFlexibility.scala new file mode 100644 index 0000000000..46afedacc2 --- /dev/null +++ b/src/main/scala/edu/ie3/simona/model/participant2/ParticipantFlexibility.scala @@ -0,0 +1,62 @@ +/* + * © 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.participant2 + +import edu.ie3.simona.model.participant2.ParticipantModel.{ + ActivePowerOperatingPoint, + OperationChangeIndicator, + ModelState, + OperatingPoint, +} +import edu.ie3.simona.ontology.messages.flex.FlexibilityMessage.ProvideFlexOptions +import edu.ie3.simona.ontology.messages.flex.MinMaxFlexibilityMessage.ProvideMinMaxFlexOptions +import edu.ie3.util.scala.quantities.DefaultQuantities +import squants.energy.Power + +trait ParticipantFlexibility[ + OP <: OperatingPoint, + S <: ModelState, +] { + + this: ParticipantModel[OP, S] => + + def calcFlexOptions(state: S): ProvideFlexOptions + + def handlePowerControl( + state: S, + flexOptions: ProvideFlexOptions, // TODO is this needed? + setPower: Power, + ): (OP, OperationChangeIndicator) + +} + +object ParticipantFlexibility { + + trait ParticipantSimpleFlexibility[ + S <: ModelState + ] extends ParticipantFlexibility[ActivePowerOperatingPoint, S] { + this: ParticipantModel[ActivePowerOperatingPoint, S] => + + override def calcFlexOptions( + state: S + ): ProvideFlexOptions = { + val (operatingPoint, _) = determineOperatingPoint(state) + val power = operatingPoint.activePower + + ProvideMinMaxFlexOptions(uuid, power, power, DefaultQuantities.zeroKW) + } + + override def handlePowerControl( + state: S, + flexOptions: ProvideFlexOptions, + setPower: Power, + ): (ActivePowerOperatingPoint, OperationChangeIndicator) = { + (ActivePowerOperatingPoint(setPower), OperationChangeIndicator()) + } + } + +} diff --git a/src/main/scala/edu/ie3/simona/model/participant2/ParticipantModel.scala b/src/main/scala/edu/ie3/simona/model/participant2/ParticipantModel.scala new file mode 100644 index 0000000000..71f8ce03fe --- /dev/null +++ b/src/main/scala/edu/ie3/simona/model/participant2/ParticipantModel.scala @@ -0,0 +1,288 @@ +/* + * © 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.participant2 + +import edu.ie3.datamodel.models.result.system.SystemParticipantResult +import edu.ie3.simona.agent.participant.data.Data.PrimaryData.{ + ComplexPower, + PrimaryDataWithComplexPower, +} +import edu.ie3.simona.agent.participant.data.Data +import edu.ie3.simona.model.participant2.ParticipantModel.{ + ModelInput, + ModelState, + OperatingPoint, +} +import edu.ie3.simona.agent.participant2.ParticipantAgent +import edu.ie3.simona.agent.participant2.ParticipantAgent.ParticipantRequest +import edu.ie3.simona.model.participant.control.QControl +import edu.ie3.simona.service.ServiceType +import edu.ie3.util.scala.quantities.DefaultQuantities.zeroKW +import edu.ie3.util.scala.quantities.{ApparentPower, ReactivePower} +import org.apache.pekko.actor.typed.scaladsl.ActorContext +import squants.Dimensionless +import squants.energy.Power + +import java.time.ZonedDateTime +import java.util.UUID + +abstract class ParticipantModel[ + OP <: OperatingPoint, + S <: ModelState, +] extends ParticipantFlexibility[OP, S] { + + val uuid: UUID + val sRated: ApparentPower + val cosPhiRated: Double + val qControl: QControl + + protected val pRated: Power = sRated.toActivePower(cosPhiRated) + + val initialState: ModelInput => S + + /** Determines the current state given the last state and the operating point + * that has been valid from the last state up until now. + * + * @param lastState + * the last state + * @param operatingPoint + * the operating point valid from the simulation time of the last state up + * until now + * @param input + * the model input data for the current tick + * @return + * the current state + */ + def determineState( + lastState: S, + operatingPoint: OP, + input: ModelInput, + ): S + + /** Get a partial function, that transfers the current active into reactive + * power based on the participants properties and the given nodal voltage + * + * @return + * A [[PartialFunction]] from [[Power]] and voltage ([[Dimensionless]]) to + * [[ReactivePower]] + */ + def activeToReactivePowerFunc: Dimensionless => Power => ReactivePower = + nodalVoltage => + qControl.activeToReactivePowerFunc( + sRated, + cosPhiRated, + nodalVoltage, + ) + + /** Given the current state, this method determines the operating point that + * is currently valid until the next operating point is determined. Also, + * optionally returns a tick at which the state will change unless the + * operating point changes due to external influences beforehand. + * + * This method should be able to handle calls at arbitrary points in + * simulation time (i.e. ticks), which are situated after the tick of the + * last state. + * + * This method is only called if the participant is '''not''' em-controlled. + * If the participant '''is''' em-controlled, + * [[ParticipantFlexibility.handlePowerControl()]] determines the operating + * point instead. + * + * @param state + * the current state + * @return + * the operating point and optionally a next activation tick + */ + def determineOperatingPoint(state: S): (OP, Option[Long]) + + /** Operating point used when model is out of operation, thus + * producing/consuming no power. + * + * @return + * an operating point representing zero power + */ + def zeroPowerOperatingPoint: OP + + /** @param state + * the current state + * @param lastOperatingPoint + * the last operating point before the current one, i.e. the one valid up + * until the last state, if applicable + * @param currentOperatingPoint + * the operating point valid from the simulation time of the last state up + * until now + * @param complexPower + * the total complex power derived from the current operating point + * @param dateTime + * the current simulation date and time + * @return + */ + def createResults( + state: S, + lastOperatingPoint: Option[OP], + currentOperatingPoint: OP, + complexPower: ComplexPower, + dateTime: ZonedDateTime, + ): Iterable[SystemParticipantResult] + + def createPrimaryDataResult( + data: PrimaryDataWithComplexPower[_], + dateTime: ZonedDateTime, + ): SystemParticipantResult + + /** Handling requests that are not part of the standard participant protocol + * + * @param state + * The current state + * @param ctx + * The actor context that can be used to send replies + * @param msg + * The received request + * @return + * An updated state, or the same state provided as parameter + */ + def handleRequest( + state: S, + ctx: ActorContext[ParticipantAgent.Request], + msg: ParticipantRequest, + ): S = + throw new NotImplementedError(s"Method not implemented by $getClass") + + /** @return + * All secondary services required by the model + */ + def getRequiredSecondaryServices: Iterable[ServiceType] + +} + +object ParticipantModel { + + /** Holds all potentially relevant input data for model calculation. + * + * @param receivedData + * The received primary or secondary data + * @param nodalVoltage + * The voltage at the node that we're connected to + * @param currentTick + * The current tick + * @param currentSimulationTime + * The current simulation time (matches the tick) + */ + final case class ModelInput( + receivedData: Seq[Data], + nodalVoltage: Dimensionless, + currentTick: Long, + currentSimulationTime: ZonedDateTime, + ) + + trait OperatingPoint { + + val activePower: Power + + /** Reactive power can be overridden by the model itself. If this is None, + * the active-to-reactive-power function is used. + */ + val reactivePower: Option[ReactivePower] + } + + final case class ActivePowerOperatingPoint(override val activePower: Power) + extends OperatingPoint { + override val reactivePower: Option[ReactivePower] = None + } + + object ActivePowerOperatingPoint { + def zero: ActivePowerOperatingPoint = ActivePowerOperatingPoint(zeroKW) + } + + trait ModelState { + val tick: Long + } + + final case class FixedState(override val tick: Long) extends ModelState + + trait ParticipantFixedState[ + OP <: OperatingPoint + ] { + this: ParticipantModel[OP, FixedState] => + + override val initialState: ModelInput => FixedState = + input => FixedState(input.currentTick) + + override def determineState( + lastState: FixedState, + operatingPoint: OP, + input: ModelInput, + ): FixedState = FixedState(input.currentTick) + + } + + /** State that just holds the current datetime and tick + * @param tick + * The current tick + * @param dateTime + * The current datetime, corresponding to the current tick + */ + final case class DateTimeState(tick: Long, dateTime: ZonedDateTime) + extends ModelState + + trait ParticipantDateTimeState[ + OP <: OperatingPoint + ] { + this: ParticipantModel[OP, DateTimeState] => + + override val initialState: ModelInput => DateTimeState = input => + DateTimeState(input.currentTick, input.currentSimulationTime) + + override def determineState( + lastState: DateTimeState, + operatingPoint: OP, + input: ModelInput, + ): DateTimeState = + DateTimeState(input.currentTick, input.currentSimulationTime) + + } + + /** Indicates when either flex options (when em-controlled) or the operating + * point are going to change (when not em-controlled). + * + * A change of flex options or operating point might occur due to various + * reasons, including expected data arrival, internal expected model changes + * and operating interval limits. + * + * @param changesAtNextActivation + * Indicates whether flex options change at the very next tick that EM is + * activated, due to e.g. storage limits being reached. Not applicable for + * not-em-controlled models. + * @param changesAtTick + * The next tick at which a change of flex options or the operating point + * is expected. + */ + final case class OperationChangeIndicator( + changesAtNextActivation: Boolean = false, + changesAtTick: Option[Long] = None, + ) { + + /** Combines two [[OperationChangeIndicator]]s by aggregating + * changesAtNextActivation via OR function and picking the earlier (or any) + * of both changesAtTick values. + * + * @param otherIndicator + * The other [[OperationChangeIndicator]] to combine with this one + * @return + * An aggregated [[OperationChangeIndicator]] + */ + def |( + otherIndicator: OperationChangeIndicator + ): OperationChangeIndicator = { + OperationChangeIndicator( + changesAtNextActivation || otherIndicator.changesAtNextActivation, + Seq(changesAtTick, otherIndicator.changesAtTick).flatten.minOption, + ) + } + } + +} diff --git a/src/main/scala/edu/ie3/simona/model/participant2/ParticipantModelInit.scala b/src/main/scala/edu/ie3/simona/model/participant2/ParticipantModelInit.scala new file mode 100644 index 0000000000..a83d7a1e49 --- /dev/null +++ b/src/main/scala/edu/ie3/simona/model/participant2/ParticipantModelInit.scala @@ -0,0 +1,114 @@ +/* + * © 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.participant2 + +import edu.ie3.datamodel.models.input.system.SystemParticipantInput.SystemParticipantInputCopyBuilder +import edu.ie3.datamodel.models.input.system._ +import edu.ie3.datamodel.models.result.system.SystemParticipantResult +import edu.ie3.simona.agent.participant.data.Data.{PrimaryData, PrimaryDataMeta} +import edu.ie3.simona.config.SimonaConfig.{ + BaseRuntimeConfig, + EvcsRuntimeConfig, + LoadRuntimeConfig, + StorageRuntimeConfig, +} +import edu.ie3.simona.exceptions.CriticalFailureException +import edu.ie3.simona.model.participant2.ParticipantModel.{ + ModelState, + OperatingPoint, +} +import edu.ie3.simona.model.participant2.PrimaryDataParticipantModel.PrimaryResultFunc +import edu.ie3.simona.model.participant2.evcs.EvcsModel +import edu.ie3.simona.model.participant2.load.LoadModel + +import java.time.ZonedDateTime +import scala.reflect.ClassTag + +object ParticipantModelInit { + + def createModel( + participantInput: SystemParticipantInput, + modelConfig: BaseRuntimeConfig, + ): ParticipantModel[ + _ <: OperatingPoint, + _ <: ModelState, + ] = { + + val scaledParticipantInput = + (participantInput.copy().scale(modelConfig.scaling) match { + // matching needed because Scala has trouble recognizing the Java type parameter + case copyBuilder: SystemParticipantInputCopyBuilder[_] => copyBuilder + }).build() + + (scaledParticipantInput, modelConfig) match { + case (input: FixedFeedInInput, _) => + FixedFeedInModel(input) + case (input: LoadInput, config: LoadRuntimeConfig) => + LoadModel(input, config) + case (input: PvInput, _) => + PvModel(input) + case (input: WecInput, _) => + WecModel(input) + case (input: StorageInput, config: StorageRuntimeConfig) => + StorageModel(input, config) + case (input: EvcsInput, config: EvcsRuntimeConfig) => + EvcsModel(input, config) + case (input, config) => + throw new CriticalFailureException( + s"Handling the input model ${input.getClass.getSimpleName} or " + + "the combination of the input model with model config " + + s"${config.getClass.getSimpleName} is not implemented." + ) + } + } + + def createPrimaryModel[P <: PrimaryData: ClassTag]( + participantInput: SystemParticipantInput, + modelConfig: BaseRuntimeConfig, + primaryDataMeta: PrimaryDataMeta[P], + ): ParticipantModel[ + _ <: OperatingPoint, + _ <: ModelState, + ] = { + // Create a fitting physical model to extract parameters from + val physicalModel = createModel( + participantInput, + modelConfig, + ) + + createPrimaryModel( + physicalModel, + primaryDataMeta, + ) + } + + def createPrimaryModel[P <: PrimaryData: ClassTag]( + physicalModel: ParticipantModel[_, _], + primaryDataMeta: PrimaryDataMeta[P], + ): ParticipantModel[ + _ <: OperatingPoint, + _ <: ModelState, + ] = { + val primaryResultFunc = new PrimaryResultFunc { + override def createResult( + data: PrimaryData.PrimaryDataWithComplexPower[_], + dateTime: ZonedDateTime, + ): SystemParticipantResult = + physicalModel.createPrimaryDataResult(data, dateTime) + } + + new PrimaryDataParticipantModel( + physicalModel.uuid, + physicalModel.sRated, + physicalModel.cosPhiRated, + physicalModel.qControl, + primaryResultFunc, + primaryDataMeta, + ) + } + +} diff --git a/src/main/scala/edu/ie3/simona/model/participant2/ParticipantModelShell.scala b/src/main/scala/edu/ie3/simona/model/participant2/ParticipantModelShell.scala new file mode 100644 index 0000000000..e4e576dd0f --- /dev/null +++ b/src/main/scala/edu/ie3/simona/model/participant2/ParticipantModelShell.scala @@ -0,0 +1,398 @@ +/* + * © 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.participant2 + +import edu.ie3.datamodel.models.input.system.SystemParticipantInput +import edu.ie3.datamodel.models.result.system.SystemParticipantResult +import edu.ie3.simona.agent.participant.data.Data +import edu.ie3.simona.agent.participant.data.Data.PrimaryData.ComplexPower +import edu.ie3.simona.agent.participant.data.Data.{PrimaryData, PrimaryDataMeta} +import edu.ie3.simona.agent.participant2.ParticipantAgent +import edu.ie3.simona.agent.participant2.ParticipantAgent.ParticipantRequest +import edu.ie3.simona.config.SimonaConfig.BaseRuntimeConfig +import edu.ie3.simona.exceptions.CriticalFailureException +import edu.ie3.simona.model.SystemComponent +import edu.ie3.simona.model.em.EmTools +import edu.ie3.simona.model.participant2.ParticipantModel.{ + ModelInput, + ModelState, + OperatingPoint, + OperationChangeIndicator, +} +import edu.ie3.simona.model.participant2.ParticipantModelShell.ResultsContainer +import edu.ie3.simona.ontology.messages.flex.FlexibilityMessage.{ + IssueFlexControl, + ProvideFlexOptions, +} +import edu.ie3.simona.ontology.messages.flex.MinMaxFlexibilityMessage.ProvideMinMaxFlexOptions +import edu.ie3.simona.service.ServiceType +import edu.ie3.simona.util.TickUtil.TickLong +import edu.ie3.util.scala.OperationInterval +import edu.ie3.util.scala.quantities.DefaultQuantities._ +import edu.ie3.util.scala.quantities.ReactivePower +import org.apache.pekko.actor.typed.scaladsl.ActorContext +import squants.Dimensionless +import squants.energy.Power + +import java.time.ZonedDateTime +import java.util.UUID +import scala.reflect.ClassTag + +/** Takes care of: + * - holding id information + * - storing: + * - states (only current needed) + * - operating points (only current needed) + * - operation relevant data (only current needed) + * - flex options? (only current needed) + */ +final case class ParticipantModelShell[ + OP <: OperatingPoint, + S <: ModelState, +]( + private val model: ParticipantModel[OP, S] + with ParticipantFlexibility[OP, S], + private val operationInterval: OperationInterval, + private val simulationStartDate: ZonedDateTime, + private val _state: Option[S] = None, + private val _modelInput: Option[ModelInput] = None, + private val _flexOptions: Option[ProvideFlexOptions] = None, + private val _lastOperatingPoint: Option[OP] = None, + private val _operatingPoint: Option[OP] = None, + private val _modelChange: OperationChangeIndicator = + OperationChangeIndicator(), +) { + + /** Returns the model UUID. + * + * @return + * the UUID of the model + */ + def uuid: UUID = model.uuid + + /** Returns the types of required secondary services for the model to + * function. + * + * @return + * the types of secondary services required + */ + def requiredServices: Iterable[ServiceType] = + model.getRequiredSecondaryServices + + /** Returns the current relevant data, if present, or throws a + * [[CriticalFailureException]]. Only call this if you are certain the + * operation relevant data has been set. + * + * @return + * the operation relevant data + */ + private def getModelInput: ModelInput = + _modelInput.getOrElse( + throw new CriticalFailureException("No relevant data available!") + ) + + /** Returns the current operating point, if present, or throws a + * [[CriticalFailureException]]. Only call this if you are certain the + * operating point has been set. + * + * @return + * the operating point + */ + private def operatingPoint: OP = { + _operatingPoint + .getOrElse( + throw new CriticalFailureException("No operating point available!") + ) + } + + /** Returns the current flex options, if present, or throws a + * [[CriticalFailureException]]. Only call this if you are certain the flex + * options have been set. + * + * @return + * the flex options + */ + def flexOptions: ProvideFlexOptions = + _flexOptions.getOrElse( + throw new CriticalFailureException( + "Flex options have not been calculated!" + ) + ) + + def updateModelInput( + receivedData: Seq[Data], + nodalVoltage: Dimensionless, + tick: Long, + ): ParticipantModelShell[OP, S] = { + val currentSimulationTime = tick.toDateTime(simulationStartDate) + + copy(_modelInput = + Some( + ModelInput( + receivedData, + nodalVoltage, + tick, + currentSimulationTime, + ) + ) + ) + } + + /** Update operating point when the model is '''not''' em-controlled. + * + * @param currentTick + * @return + */ + def updateOperatingPoint( + currentTick: Long + ): ParticipantModelShell[OP, S] = { + val currentState = determineCurrentState(currentTick) + + def modelOperatingPoint(): (OP, OperationChangeIndicator) = { + val (modelOp, modelNextTick) = + model.determineOperatingPoint(currentState) + val modelIndicator = + OperationChangeIndicator(changesAtTick = modelNextTick) + (modelOp, modelIndicator) + } + + val (newOperatingPoint, newChangeIndicator) = + determineOperatingPoint(modelOperatingPoint, currentTick) + + copy( + _state = Some(currentState), + _lastOperatingPoint = _operatingPoint, + _operatingPoint = Some(newOperatingPoint), + _modelChange = newChangeIndicator, + ) + } + + def activeToReactivePowerFunc: Dimensionless => Power => ReactivePower = + model.activeToReactivePowerFunc + + def determineResults( + currentTick: Long, + nodalVoltage: Dimensionless, + ): ResultsContainer = { + val activePower = operatingPoint.activePower + val reactivePower = operatingPoint.reactivePower.getOrElse( + activeToReactivePowerFunc(nodalVoltage)(activePower) + ) + val complexPower = ComplexPower(activePower, reactivePower) + + val participantResults = model.createResults( + determineCurrentState(currentTick), + _lastOperatingPoint, + operatingPoint, + complexPower, + currentTick.toDateTime(simulationStartDate), + ) + + ResultsContainer( + complexPower, + participantResults, + ) + } + + def updateFlexOptions(currentTick: Long): ParticipantModelShell[OP, S] = { + val currentState = determineCurrentState(currentTick) + + val flexOptions = + if (operationInterval.includes(currentTick)) { + model.calcFlexOptions(currentState) + } else { + // Out of operation, there's no way to operate besides 0 kW + ProvideMinMaxFlexOptions.noFlexOption(model.uuid, zeroKW) + } + + copy(_state = Some(currentState), _flexOptions = Some(flexOptions)) + } + + /** Update operating point on receiving [[IssueFlexControl]], i.e. when the + * model is em-controlled. + * + * @param flexControl + * @return + */ + def updateOperatingPoint( + flexControl: IssueFlexControl + ): ParticipantModelShell[OP, S] = { + val currentState = determineCurrentState(flexControl.tick) + + val currentTick = flexControl.tick + + def modelOperatingPoint(): (OP, OperationChangeIndicator) = { + val fo = _flexOptions.getOrElse( + throw new CriticalFailureException("No flex options available!") + ) + + val setPointActivePower = EmTools.determineFlexPower( + fo, + flexControl, + ) + + model.handlePowerControl( + currentState, + fo, + setPointActivePower, + ) + } + + val (newOperatingPoint, newChangeIndicator) = + determineOperatingPoint(modelOperatingPoint, currentTick) + + copy( + _state = Some(currentState), + _lastOperatingPoint = _operatingPoint, + _operatingPoint = Some(newOperatingPoint), + _modelChange = newChangeIndicator, + ) + } + + private def determineOperatingPoint( + modelOperatingPoint: () => (OP, OperationChangeIndicator), + currentTick: Long, + ): (OP, OperationChangeIndicator) = { + if (operationInterval.includes(currentTick)) { + modelOperatingPoint() + } else { + // Current tick is outside of operation interval. + // Set operating point to "zero" + (model.zeroPowerOperatingPoint, OperationChangeIndicator()) + } + } + + /** Determines and returns the next activation tick considering the operating + * interval and given next data tick. + */ + def getChangeIndicator( + currentTick: Long, + nextDataTick: Option[Long], + ): OperationChangeIndicator = { + if (operationInterval.includes(currentTick)) { + // The next activation tick should be the earliest of + // the next tick request by the model, the next data tick and + // the end of the operation interval + val adaptedNextTick = + Seq( + _modelChange.changesAtTick, + nextDataTick, + Option(operationInterval.end), + ).flatten.minOption + + _modelChange.copy(changesAtTick = adaptedNextTick) + } else { + // If the model is not active, all activation ticks are ignored besides + // potentially the operation start + val nextTick = Option.when(operationInterval.start > currentTick)( + operationInterval.start + ) + + OperationChangeIndicator(changesAtTick = nextTick) + } + } + + def handleRequest( + ctx: ActorContext[ParticipantAgent.Request], + request: ParticipantRequest, + ): ParticipantModelShell[OP, S] = { + val currentState = determineCurrentState(request.tick) + val updatedState = model.handleRequest(currentState, ctx, request) + + copy(_state = Some(updatedState)) + } + + private def determineCurrentState(currentTick: Long): S = { + // new state is only calculated if there's an old state and an operating point + val state = _state + .zip(_operatingPoint) + .flatMap { case (st, op) => + Option.when(st.tick < currentTick) { + model.determineState(st, op, getModelInput) + } + } + .getOrElse(model.initialState(getModelInput)) + + if (state.tick != currentTick) + throw new CriticalFailureException( + s"New state $state is not set to current tick $currentTick" + ) + + state + } + +} + +object ParticipantModelShell { + + final case class ResultsContainer( + totalPower: ComplexPower, + modelResults: Iterable[SystemParticipantResult], + ) + + def createForPrimaryData[P <: PrimaryData: ClassTag]( + participantInput: SystemParticipantInput, + config: BaseRuntimeConfig, + primaryDataMeta: PrimaryDataMeta[P], + simulationStartDate: ZonedDateTime, + simulationEndDate: ZonedDateTime, + ): ParticipantModelShell[_, _] = { + val model = ParticipantModelInit.createPrimaryModel( + participantInput, + config, + primaryDataMeta, + ) + createShell( + model, + participantInput, + simulationEndDate, + simulationStartDate, + ) + } + + def createForModel( + participantInput: SystemParticipantInput, + config: BaseRuntimeConfig, + simulationStartDate: ZonedDateTime, + simulationEndDate: ZonedDateTime, + ): ParticipantModelShell[_, _] = { + val model = ParticipantModelInit.createModel( + participantInput, + config, + ) + createShell( + model, + participantInput, + simulationEndDate, + simulationStartDate, + ) + } + + private def createShell[ + OP <: OperatingPoint, + S <: ModelState, + ]( + model: ParticipantModel[OP, S], + participantInput: SystemParticipantInput, + simulationEndDate: ZonedDateTime, + simulationStartDate: ZonedDateTime, + ): ParticipantModelShell[OP, S] = { + + val operationInterval: OperationInterval = + SystemComponent.determineOperationInterval( + simulationStartDate, + simulationEndDate, + participantInput.getOperationTime, + ) + + new ParticipantModelShell( + model = model, + operationInterval = operationInterval, + simulationStartDate = simulationStartDate, + ) + } +} diff --git a/src/main/scala/edu/ie3/simona/model/participant2/PrimaryDataParticipantModel.scala b/src/main/scala/edu/ie3/simona/model/participant2/PrimaryDataParticipantModel.scala new file mode 100644 index 0000000000..89ca018726 --- /dev/null +++ b/src/main/scala/edu/ie3/simona/model/participant2/PrimaryDataParticipantModel.scala @@ -0,0 +1,186 @@ +/* + * © 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.participant2 + +import edu.ie3.datamodel.models.result.system.SystemParticipantResult +import edu.ie3.simona.agent.participant.data.Data +import edu.ie3.simona.agent.participant.data.Data.{PrimaryData, PrimaryDataMeta} +import edu.ie3.simona.agent.participant.data.Data.PrimaryData.{ + ComplexPower, + EnrichableData, + PrimaryDataWithComplexPower, +} +import edu.ie3.simona.exceptions.CriticalFailureException +import edu.ie3.simona.model.participant.control.QControl +import edu.ie3.simona.model.participant2.ParticipantModel.{ + ModelInput, + ModelState, + OperatingPoint, + OperationChangeIndicator, +} +import edu.ie3.simona.model.participant2.PrimaryDataParticipantModel._ +import edu.ie3.simona.ontology.messages.flex.FlexibilityMessage +import edu.ie3.simona.ontology.messages.flex.MinMaxFlexibilityMessage.ProvideMinMaxFlexOptions +import edu.ie3.simona.service.ServiceType +import edu.ie3.util.scala.quantities.{ApparentPower, ReactivePower} +import squants.Power + +import java.time.ZonedDateTime +import java.util.UUID +import scala.reflect.ClassTag + +/** Just "replaying" primary data + */ +final case class PrimaryDataParticipantModel[P <: PrimaryData: ClassTag]( + override val uuid: UUID, + override val sRated: ApparentPower, + override val cosPhiRated: Double, + override val qControl: QControl, + primaryDataResultFunc: PrimaryResultFunc, + primaryDataMeta: PrimaryDataMeta[P], +) extends ParticipantModel[ + PrimaryOperatingPoint[P], + PrimaryDataState[P], + ] { + + override val initialState: ModelInput => PrimaryDataState[P] = { input => + val primaryData = getPrimaryData(input.receivedData) + PrimaryDataState( + primaryData, + input.currentTick, + ) + } + + override def determineState( + lastState: PrimaryDataState[P], + operatingPoint: PrimaryOperatingPoint[P], + input: ParticipantModel.ModelInput, + ): PrimaryDataState[P] = initialState(input) + + private def getPrimaryData(receivedData: Seq[Data]): P = { + receivedData + .collectFirst { case data: P => + data + } + .getOrElse { + throw new CriticalFailureException( + "Expected primary data of type " + + s"${implicitly[ClassTag[P]].runtimeClass.getSimpleName}, " + + s"got $receivedData" + ) + } + } + + override def determineOperatingPoint( + state: PrimaryDataState[P] + ): (PrimaryOperatingPoint[P], Option[Long]) = + (PrimaryOperatingPoint(state.data), None) + + override def zeroPowerOperatingPoint: PrimaryOperatingPoint[P] = + PrimaryOperatingPoint(primaryDataMeta.zero) + + override def createResults( + state: PrimaryDataState[P], + lastOperatingPoint: Option[PrimaryOperatingPoint[P]], + currentOperatingPoint: PrimaryOperatingPoint[P], + complexPower: ComplexPower, + dateTime: ZonedDateTime, + ): Iterable[SystemParticipantResult] = { + val primaryDataWithApparentPower = currentOperatingPoint.data match { + case primaryDataWithApparentPower: PrimaryDataWithComplexPower[_] => + primaryDataWithApparentPower + case enrichableData: EnrichableData[_] => + enrichableData.add(complexPower.q) + } + Iterable( + primaryDataResultFunc.createResult(primaryDataWithApparentPower, dateTime) + ) + } + + override def createPrimaryDataResult( + data: PrimaryDataWithComplexPower[_], + dateTime: ZonedDateTime, + ): SystemParticipantResult = throw new CriticalFailureException( + "Method not implemented by this model." + ) + + override def getRequiredSecondaryServices: Iterable[ServiceType] = { + // only secondary services should be specified here + Iterable.empty + } + + override def calcFlexOptions( + state: PrimaryDataState[P] + ): FlexibilityMessage.ProvideFlexOptions = { + val (operatingPoint, _) = determineOperatingPoint(state) + + ProvideMinMaxFlexOptions.noFlexOption(uuid, operatingPoint.activePower) + } + + override def handlePowerControl( + state: PrimaryDataState[P], + flexOptions: FlexibilityMessage.ProvideFlexOptions, + setPower: Power, + ): (PrimaryOperatingPoint[P], OperationChangeIndicator) = { + val factor = state.data.p / setPower + val scaledData: P = primaryDataMeta.scale(state.data, factor) + + (PrimaryOperatingPoint(scaledData), OperationChangeIndicator()) + } + +} + +object PrimaryDataParticipantModel { + + final case class PrimaryDataState[+P <: PrimaryData]( + data: P, + override val tick: Long, + ) extends ModelState + + trait PrimaryOperatingPoint[+P <: PrimaryData] extends OperatingPoint { + val data: P + + override val activePower: Power = data.p + } + + private object PrimaryOperatingPoint { + def apply[P <: PrimaryData: ClassTag]( + data: P + ): PrimaryOperatingPoint[P] = + data match { + case apparentPowerData: P with PrimaryDataWithComplexPower[_] => + PrimaryApparentPowerOperatingPoint(apparentPowerData) + case other: P with EnrichableData[_] => + PrimaryActivePowerOperatingPoint(other) + } + } + + private final case class PrimaryApparentPowerOperatingPoint[ + P <: PrimaryDataWithComplexPower[_] + ](override val data: P) + extends PrimaryOperatingPoint[P] { + override val reactivePower: Option[ReactivePower] = Some(data.q) + } + + private final case class PrimaryActivePowerOperatingPoint[ + PE <: PrimaryData with EnrichableData[_]: ClassTag + ]( + override val data: PE + ) extends PrimaryOperatingPoint[PE] { + override val reactivePower: Option[ReactivePower] = None + } + + /** Function needs to be packaged to be store it in a val + */ + trait PrimaryResultFunc { + def createResult( + data: PrimaryDataWithComplexPower[_], + dateTime: ZonedDateTime, + ): SystemParticipantResult + } + +} diff --git a/src/main/scala/edu/ie3/simona/model/participant2/PvModel.scala b/src/main/scala/edu/ie3/simona/model/participant2/PvModel.scala new file mode 100644 index 0000000000..8ef553ddfc --- /dev/null +++ b/src/main/scala/edu/ie3/simona/model/participant2/PvModel.scala @@ -0,0 +1,846 @@ +/* + * © 2020. TU Dortmund University, + * Institute of Energy Systems, Energy Efficiency and Energy Economics, + * Research group Distribution grid planning and operation + */ + +package edu.ie3.simona.model.participant2 + +import com.typesafe.scalalogging.LazyLogging +import edu.ie3.datamodel.models.input.system.PvInput +import edu.ie3.datamodel.models.result.system.{ + PvResult, + SystemParticipantResult, +} +import edu.ie3.simona.agent.participant.data.Data +import edu.ie3.simona.agent.participant.data.Data.PrimaryData.{ + ComplexPower, + PrimaryDataWithComplexPower, +} +import edu.ie3.simona.exceptions.CriticalFailureException +import edu.ie3.simona.model.participant.control.QControl +import edu.ie3.simona.model.participant2.ParticipantFlexibility.ParticipantSimpleFlexibility +import edu.ie3.simona.model.participant2.ParticipantModel.{ + ActivePowerOperatingPoint, + ModelInput, + ModelState, +} +import edu.ie3.simona.model.participant2.PvModel.PvState +import edu.ie3.simona.ontology.messages.services.WeatherMessage.WeatherData +import edu.ie3.simona.service.ServiceType +import edu.ie3.util.quantities.PowerSystemUnits +import edu.ie3.util.quantities.QuantityUtils.RichQuantityDouble +import edu.ie3.util.scala.quantities._ +import squants._ +import squants.space.{Degrees, SquareMeters} +import squants.time.Minutes +import tech.units.indriya.unit.Units._ + +import java.time.ZonedDateTime +import java.util.UUID +import java.util.stream.IntStream +import scala.math._ + +class PvModel private ( + override val uuid: UUID, + override val sRated: ApparentPower, + override val cosPhiRated: Double, + override val qControl: QControl, + private val lat: Angle, + private val lon: Angle, + private val albedo: Double, + private val etaConv: Dimensionless, + private val alphaE: Angle, + private val gammaE: Angle, + private val moduleSurface: Area = SquareMeters(1d), +) extends ParticipantModel[ + ActivePowerOperatingPoint, + PvState, + ] + with ParticipantSimpleFlexibility[PvState] + with LazyLogging { + + /** Override sMax as the power output of a pv unit could become easily up to + * 10% higher than the sRated value found in the technical sheets + */ + val sMax: ApparentPower = sRated * 1.1 + + /** Permissible maximum active power feed in (therefore negative) */ + protected val pMax: Power = sMax.toActivePower(cosPhiRated) * -1 + + /** Reference yield at standard testing conditions (STC) */ + private val yieldSTC = WattsPerSquareMeter(1000d) + + private val activationThreshold = pMax * 0.001 * -1 + + override val initialState: ModelInput => PvState = { input => + val weatherData = getWeatherData(input.receivedData) + PvState( + input.currentTick, + input.currentSimulationTime, + ???, + weatherData.diffIrr, + weatherData.dirIrr, + ) + } + + override def determineState( + lastState: PvState, + operatingPoint: ActivePowerOperatingPoint, + input: ModelInput, + ): PvState = initialState(input) + + private def getWeatherData(receivedData: Seq[Data]): WeatherData = { + receivedData + .collectFirst { case weatherData: WeatherData => + weatherData + } + .getOrElse { + throw new CriticalFailureException( + s"Expected WeatherData, got $receivedData" + ) + } + } + + /** Calculate the active power behaviour of the model + * + * @param data + * Further needed, secondary data + * @return + * Active power + */ + override def determineOperatingPoint( + state: PvState + ): (ActivePowerOperatingPoint, Option[Long]) = { + // === Weather Base Data === // + /* The pv model calculates the power in-feed based on the solar irradiance that is received over a specific + * time frame (which actually is the solar irradiation). Hence, a multiplication with the time frame within + * this irradiance is received is required. */ + val duration: Time = Seconds(state.weatherDataFrameLength) + + // eBeamH and eDifH needs to be extract to their double values in some places + // hence a conversion to watt-hour per square meter is required, to avoid + // invalid double value extraction! + val eBeamH = + state.dirIrradiance * duration + val eDifH = + state.diffIrradiance * duration + + // === Beam Radiation Parameters === // + val angleJ = calcAngleJ(state.dateTime) + val delta = calcSunDeclinationDelta(angleJ) + + val omega = calcHourAngleOmega(state.dateTime, angleJ, lon) + + val omegaSS = calcSunsetAngleOmegaSS(lat, delta) + val omegaSR = calcSunriseAngleOmegaSR(omegaSS) + + val alphaS = calcSolarAltitudeAngleAlphaS(omega, delta, lat) + val thetaG = + calcAngleOfIncidenceThetaG(delta, lat, gammaE, alphaE, omega) + + val omegas = calculateBeamOmegas(thetaG, omega, omegaSS, omegaSR) + + // === Beam Radiation ===// + val eBeamS = calcBeamRadiationOnSlopedSurface( + eBeamH, + omegas, + delta, + lat, + gammaE, + alphaE, + ) + + // === Diffuse Radiation Parameters ===// + val thetaZ = calcZenithAngleThetaZ(alphaS) + val airMass = calcAirMass(thetaZ) + val extraterrestrialRadiationI0 = calcExtraterrestrialRadiationI0(angleJ) + + // === Diffuse Radiation ===// + val eDifS = calcDiffuseRadiationOnSlopedSurfacePerez( + eDifH, + eBeamH, + airMass, + extraterrestrialRadiationI0, + thetaZ, + thetaG, + gammaE, + ) + + // === Reflected Radiation ===// + val eRefS = + calcReflectedRadiationOnSlopedSurface(eBeamH, eDifH, gammaE, albedo) + + // === Total Radiation ===// + val eTotal = eDifS + eBeamS + eRefS + + val irraditionSTC = yieldSTC * duration + val power = calcOutput( + eTotal, + state.dateTime, + irraditionSTC, + ) + + (ActivePowerOperatingPoint(power), None) + } + + override def zeroPowerOperatingPoint: ActivePowerOperatingPoint = + ActivePowerOperatingPoint.zero + + /** Calculates the position of the earth in relation to the sun (day angle) + * for the provided time + * + * @param time + * the time + * @return + * day angle J + */ + def calcAngleJ(time: ZonedDateTime): Angle = { + val day = time.getDayOfYear // day of the year + val j = 2d * Math.PI * ((day - 1d) / 365) + Radians(j) + } + + /** Calculates the declination angle delta of the sun at solar noon (i.e., + * when the sun is on the local meridian) with respect to the plane of the + * equator. Formula taken from Spencer, J.W. "Fourier series representation + * of the position of the sun". Appl. Opt. 1971, 10, 2569–2571 + * + * @param angleJ + * day angle J + * @return + * declination angle + */ + def calcSunDeclinationDelta( + angleJ: Angle + ): Angle = { + val jInRad = angleJ.toRadians + Radians( + 0.006918 - + 0.399912 * cos(jInRad) + + 0.070257 * sin(jInRad) - + 0.006758 * cos(2d * jInRad) + + 0.000907 * sin(2d * jInRad) - + 0.002697 * cos(3d * jInRad) + + 0.00148 * sin(3d * jInRad) + ) + } + + /** Calculates the hour angle omega which represents the angular displacement + * of the sun east or west of the local meridian due to rotation of the earth + * on its axis at 15◦ per hour; morning negative, afternoon positive. + * + * @param time + * the requested time (which is transformed to solar time) + * @param angleJ + * day angle J + * @param longitude + * longitude of the position + * @return + * hour angle omega + */ + def calcHourAngleOmega( + time: ZonedDateTime, + angleJ: Angle, + longitude: Angle, + ): Angle = { + val jInRad = angleJ.toRadians + val lambda = longitude.toDegrees + val et = Minutes( + 0.0066 + 7.3525 * cos(jInRad + 1.4992378274631293) + 9.9359 * cos( + 2d * jInRad + 1.9006635554218247 + ) + 0.3387 * cos(3d * jInRad + 1.8360863730980346) + ) + + val lmt = Minutes(time.getHour * 60d + time.getMinute - 4d * (15d - lambda)) + val st = lmt + et + + Radians((st.toHours - 12).toRadians * 15d) + } + + /** Calculates the sunset hour angle omegaSS which represents the omega value + * when the sun sets. The sunrise hour angle omegaSR is the negative of + * omegaSS. + * + * @param latitude + * latitude of the position + * @param delta + * sun declination angle + * @return + * sunset angle omegaSS + */ + def calcSunsetAngleOmegaSS( + latitude: Angle, + delta: Angle, + ): Angle = { + val latInRad = latitude.toRadians + val deltaInRad = delta.toRadians + + Radians(acos(-tan(latInRad) * tan(deltaInRad))) + } + + /** Calculates the sunrise hour angle omegaSR given omegaSS. + */ + private val calcSunriseAngleOmegaSR = + (omegaSS: Angle) => omegaSS * -1 + + /** Calculates the solar altitude angle alphaS which represents the angle + * between the horizontal and the line to the sun, that is, the complement of + * the zenith angle. + * + * @param omega + * hour angle + * @param delta + * sun declination angle + * @param latitude + * latitude of the position + * @return + * solar altitude angle alphaS + */ + def calcSolarAltitudeAngleAlphaS( + omega: Angle, + delta: Angle, + latitude: Angle, + ): Angle = { + val latInRad = latitude.toRadians + val deltaInRad = delta.toRadians + val omegaInRad = omega.toRadians + val sinAlphaS = + min( + max( + cos(omegaInRad) * cos(latInRad) * cos(deltaInRad) + + sin(latInRad) * sin(deltaInRad), + -1, + ), + 1, + ) + Radians(asin(sinAlphaS)) + } + + /** Calculates the zenith angle thetaG which represents the angle between the + * vertical and the line to the sun, that is, the angle of incidence of beam + * radiation on a horizontal surface. + * + * @param alphaS + * sun altitude angle + * @return + * the zenith angle + */ + def calcZenithAngleThetaZ( + alphaS: Angle + ): Angle = { + val alphaSInRad = alphaS.toRadians + + // the zenith angle is defined as 90° - gammaS in Radian + Radians(Pi / 2 - abs(alphaSInRad)) + } + + /** Calculates the ratio of the mass of atmosphere through which beam + * radiation passes to the mass it would pass through if the sun were at the + * zenith (i.e., directly overhead). + * + * @param thetaZ + * zenith angle + * @return + * air mass + */ + def calcAirMass(thetaZ: Angle): Double = { + val thetaZInRad = thetaZ.toRadians + + // radius of the earth in kilometers + val re = 6371d + // approx. effective height of the atmosphere + val yAtm = 9d + + // Ratio re / yAtm between the earth radius and the atmosphere height + val airMassRatio = re / yAtm + sqrt( + pow(airMassRatio * cos(thetaZInRad), 2d) + 2d * airMassRatio + 1d + ) - airMassRatio * cos(thetaZInRad) + } + + /** Calculates the extraterrestrial radiation, that is, the radiation that + * would be received in the absence of the atmosphere. + * + * @param angleJ + * day angle J + * @return + * extraterrestrial radiation I0 + */ + def calcExtraterrestrialRadiationI0( + angleJ: Angle + ): Irradiation = { + val jInRad = angleJ.toRadians + + // eccentricity correction factor + val e0 = 1.000110 + + 0.034221 * cos(jInRad) + + 0.001280 * sin(jInRad) + + 0.000719 * cos(2d * jInRad) + + 0.000077 * sin(2d * jInRad) + + // solar constant in W/m2 + val Gsc = WattHoursPerSquareMeter(1367) // solar constant + Gsc * e0 + } + + /** Calculates the angle of incidence thetaG of beam radiation on a surface + * + * @param delta + * sun declination angle + * @param latitude + * latitude of the position + * @param gammaE + * slope angle (the angle between the plane of the surface in question and + * the horizontal) + * @param alphaE + * surface azimuth angle (the deviation of the projection on a horizontal + * plane of the normal to the surface from the local meridian, with zero + * due south, east negative, and west positive) + * @param omega + * hour angle + * @return + * angle of incidence thetaG + */ + def calcAngleOfIncidenceThetaG( + delta: Angle, + latitude: Angle, + gammaE: Angle, + alphaE: Angle, + omega: Angle, + ): Angle = { + val deltaInRad = delta.toRadians + val omegaInRad = omega.toRadians + val gammaInRad = gammaE.toRadians + val alphaEInRad = alphaE.toRadians + val latInRad = latitude.toRadians + + Radians( + acos( + sin(deltaInRad) * sin(latInRad) * cos(gammaInRad) - + sin(deltaInRad) * cos(latInRad) * sin(gammaInRad) * cos(alphaEInRad) + + cos(deltaInRad) * cos(latInRad) * cos(gammaInRad) * cos(omegaInRad) + + cos(deltaInRad) * sin(latInRad) * sin(gammaInRad) * + cos(alphaEInRad) * cos(omegaInRad) + + cos(deltaInRad) * sin(gammaInRad) * sin(alphaEInRad) * sin(omegaInRad) + ) + ) + } + + /** Calculates omega1 and omega2, which are parameters for + * calcBeamRadiationOnSlopedSurface + * + * @param thetaG + * angle of incidence + * @param omega + * hour angle + * @param omegaSS + * sunset angle + * @param omegaSR + * sunrise angle + * @return + * omega1 and omega encapsulated in an Option, if applicable. None + * otherwise + */ + def calculateBeamOmegas( + thetaG: Angle, + omega: Angle, + omegaSS: Angle, + omegaSR: Angle, + ): Option[(Angle, Angle)] = { + val thetaGInRad = thetaG.toRadians + val omegaSSInRad = omegaSS.toRadians + val omegaSRInRad = omegaSR.toRadians + + val omegaOneHour = toRadians(15d) + val omegaHalfHour = omegaOneHour / 2d + + val omega1InRad = omega.toRadians // requested hour + val omega2InRad = omega1InRad + omegaOneHour // requested hour plus 1 hour + + // (thetaG < 90°): sun is visible + // (thetaG > 90°), otherwise: sun is behind the surface -> no direct radiation + if ( + thetaGInRad < toRadians(90) + // omega1 and omega2: sun has risen and has not set yet + && omega2InRad > omegaSRInRad + omegaHalfHour + && omega1InRad < omegaSSInRad - omegaHalfHour + ) { + + val (finalOmega1, finalOmega2) = + if (omega1InRad < omegaSRInRad) { + // requested time earlier than sunrise + (omegaSRInRad, omegaSRInRad + omegaOneHour) + } else if (omega2InRad > omegaSSInRad) { + // sunset earlier than requested time + (omegaSSInRad - omegaOneHour, omegaSSInRad) + } else { + (omega1InRad, omega2InRad) + } + + Some(Radians(finalOmega1), Radians(finalOmega2)) + } else + None + } + + /** Calculates the beam radiation on a sloped surface + * + * @param eBeamH + * beam radiation on a horizontal surface + * @param omegas + * omega1 and omega2 + * @param delta + * sun declination angle + * @param latitude + * latitude of the position + * @param gammaE + * slope angle (the angle between the plane of the surface in question and + * the horizontal) + * @param alphaE + * surface azimuth angle (the deviation of the projection on a horizontal + * plane of the normal to the surface from the local meridian, with zero + * due south, east negative, and west positive) + * @return + * the beam radiation on the sloped surface + */ + def calcBeamRadiationOnSlopedSurface( + eBeamH: Irradiation, + omegas: Option[(Angle, Angle)], + delta: Angle, + latitude: Angle, + gammaE: Angle, + alphaE: Angle, + ): Irradiation = { + + omegas match { + case Some((omega1, omega2)) => + val deltaInRad = delta.toRadians + val gammaEInRad = gammaE.toRadians + val alphaEInRad = alphaE.toRadians + val latInRad = latitude.toRadians + + val omega1InRad = omega1.toRadians + val omega2InRad = omega2.toRadians + + val a = ((sin(deltaInRad) * sin(latInRad) * cos(gammaEInRad) + - sin(deltaInRad) * cos(latInRad) * sin(gammaEInRad) * cos( + alphaEInRad + )) + * (omega2InRad - omega1InRad) + + (cos(deltaInRad) * cos(latInRad) * cos(gammaEInRad) + + cos(deltaInRad) * sin(latInRad) * sin(gammaEInRad) * cos( + alphaEInRad + )) + * (sin(omega2InRad) - sin(omega1InRad)) + - (cos(deltaInRad) * sin(gammaEInRad) * sin(alphaEInRad)) + * (cos(omega2InRad) - cos(omega1InRad))) + + val b = ((cos(latInRad) * cos(deltaInRad)) * (sin(omega2InRad) - sin( + omega1InRad + )) + + (sin(latInRad) * sin(deltaInRad)) * (omega2InRad - omega1InRad)) + + // in rare cases (close to sunrise) r can become negative (although very small) + val r = max(a / b, 0d) + eBeamH * r + case None => WattHoursPerSquareMeter(0d) + } + } + + /** Calculates the diffuse radiation on a sloped surface based on the model of + * Perez et al. + * + *
Formula taken from Perez, R., P. Ineichen, R. Seals, J. Michalsky, and + * R. Stewart, "Modeling Daylight Availability and Irradiance Components from + * Direct and Global Irradiance". Solar Energy, 44, 271 (1990). + * + * @param eDifH + * diffuse radiation on a horizontal surface + * @param eBeamH + * beam radiation on a horizontal surface + * @param airMass + * the air mass + * @param extraterrestrialRadiationI0 + * extraterrestrial radiation + * @param thetaZ + * zenith angle + * @param thetaG + * angle of incidence + * @param gammaE + * slope angle (the angle between the plane of the surface in question and + * the horizontal) + * @return + * the diffuse radiation on the sloped surface + */ + def calcDiffuseRadiationOnSlopedSurfacePerez( + eDifH: Irradiation, + eBeamH: Irradiation, + airMass: Double, + extraterrestrialRadiationI0: Irradiation, + thetaZ: Angle, + thetaG: Angle, + gammaE: Angle, + ): Irradiation = { + val thetaZInRad = thetaZ.toRadians + val thetaGInRad = thetaG.toRadians + val gammaEInRad = gammaE.toRadians + + // == brightness index beta ==// + val beta = eDifH * airMass / extraterrestrialRadiationI0 + + // == cloud index epsilon ==// + // if we have no clouds, the epsilon bin is 8, as epsilon bin for an epsilon in [6.2, inf.[ = 8 + var x = 8 + + if (eDifH.value.doubleValue > 0) { + // if we have diffuse radiation on horizontal surface we have to check if we have another epsilon due to clouds get the epsilon + var epsilon = ((eDifH + eBeamH) / eDifH + + (5.535d * 1.0e-6) * pow( + thetaZInRad, + 3, + )) / (1d + (5.535d * 1.0e-6) * pow( + thetaZInRad, + 3, + )) + + // get the corresponding bin if epsilon is smaller than 6.2 + if (epsilon < 6.2) { // define the bins based on Perez + val discreteSkyClearnessCategories = Array( + Array(1, 1.065), + Array(1.065, 1.230), + Array(1.230, 1.500), + Array(1.500, 1.950), + Array(1.950, 2.800), + Array(2.800, 4.500), + Array(4.500, 6.200), + ) + // adapt the epsilon as we have no bin < 1 + epsilon = max(1, epsilon) + + // get the corresponding bin + val finalEpsilon = epsilon + + x = IntStream + .range(0, discreteSkyClearnessCategories.length) + .filter((i: Int) => + (finalEpsilon - discreteSkyClearnessCategories(i)( + 0 + ) >= 0) && (finalEpsilon - discreteSkyClearnessCategories( + i + )(1) < 0) + ) + .findFirst + .getAsInt + 1 + } + } + + // calculate the f_ij components based on the epsilon bin + val f11 = -0.0161 * pow(x, 3) + 0.1840 * pow(x, 2) - 0.3806 * x + 0.2324 + val f12 = 0.0134 * pow(x, 4) - 0.1938 * pow(x, 3) + 0.8410 * pow( + x, + 2, + ) - 1.4018 * x + 1.3579 + val f13 = 0.0032 * pow(x, 3) - 0.0280 * pow(x, 2) - 0.0056 * x - 0.0385 + val f21 = -0.0048 * pow(x, 3) + 0.0536 * pow(x, 2) - 0.1049 * x + 0.0034 + val f22 = 0.0012 * pow(x, 3) - 0.0067 * pow(x, 2) + 0.0091 * x - 0.0269 + val f23 = 0.0052 * pow(x, 3) - 0.0971 * pow(x, 2) + 0.2856 * x - 0.1389 + + // calculate circuumsolar brightness coefficient f1 and horizon brightness coefficient f2 + val f1 = max(0, f11 + f12 * beta + f13 * thetaZInRad) + val f2 = f21 + f22 * beta + f23 * thetaZInRad + val aPerez = max(0, cos(thetaGInRad)) + val bPerez = max(cos(1.4835298641951802), cos(thetaZInRad)) + + // finally calculate the diffuse radiation on an inclined surface + eDifH * ( + ((1 + cos( + gammaEInRad + )) / 2) * (1 - f1) + (f1 * (aPerez / bPerez)) + (f2 * sin( + gammaEInRad + )) + ) + } + + /** Calculates the reflected radiation on a sloped surface + * + * @param eBeamH + * beam radiation on a horizontal surface + * @param eDifH + * diffuse radiation on a horizontal surface + * @param gammaE + * slope angle (the angle between the plane of the surface in question and + * the horizontal) + * @param albedo + * albedo / "composite" ground reflection + * @return + * the reflected radiation on the sloped surface eRefS + */ + def calcReflectedRadiationOnSlopedSurface( + eBeamH: Irradiation, + eDifH: Irradiation, + gammaE: Angle, + albedo: Double, + ): Irradiation = { + val gammaEInRad = gammaE.toRadians + (eBeamH + eDifH) * (albedo * 0.5 * (1 - cos(gammaEInRad))) + } + + private def generatorCorrectionFactor( + time: ZonedDateTime, + gammaE: Angle, + ): Double = { + val gammaEValInDeg = gammaE.toDegrees + + val genCorr = new Array[Array[Double]](4) + genCorr(0) = Array(0.69, 0.73, 0.81, 0.83, 0.84, 0.84, 0.9, 0.84, 0.84, + 0.82, 0.75, 0.66) // 30° + genCorr(1) = Array(0.8, 0.83, 0.84, 0.85, 0.86, 0.86, 0.86, 0.86, 0.86, + 0.84, 0.82, 0.77) // 45° + genCorr(2) = Array(0.84, 0.85, 0.86, 0.86, 0.85, 0.85, 0.85, 0.85, 0.86, + 0.86, 0.85, 0.84) // 60° + genCorr(3) = Array(0.86, 0.86, 0.85, 0.84, 0.82, 0.81, 0.81, 0.82, 0.84, + 0.85, 0.86, 0.86) // 90° + + val genCorrKey: Int = gammaEValInDeg match { + case gamma if gamma < 38 => 0 + case gamma if gamma < 53 => 1 + case gamma if gamma < 75 => 2 + case _ => 3 + } + + genCorr(genCorrKey)(time.getMonth.getValue - 1) + } + + private def temperatureCorrectionFactor(time: ZonedDateTime): Double = { + val tempCorr = + Array(1.06, 1.04, 1.01, 0.98, 0.94, 0.93, 0.94, 0.93, 0.96, 1, 1.04, 1.06) + + tempCorr(time.getMonth.getValue - 1) + } + + private def calcOutput( + eTotalInWhPerSM: Irradiation, + time: ZonedDateTime, + irradiationSTC: Irradiation, + ): Power = { + val genCorr = generatorCorrectionFactor(time, gammaE) + val tempCorr = temperatureCorrectionFactor(time) + /* The actual yield of this sum of available panels. As the solar irradiance summed up over the total panel surface + * area. The yield also takes care of generator and temperature correction factors as well as the converter's + * efficiency */ + val actYield = + eTotalInWhPerSM * moduleSurface.toSquareMeters * etaConv.toEach * (genCorr * tempCorr) + + /* Calculate the foreseen active power output without boundary condition adaptions */ + val proposal = pRated * -1 * ( + actYield / irradiationSTC + ) + + /* Do sanity check, if the proposed feed in is above the estimated maximum to be apparent active power of the plant */ + if (proposal < pMax) + logger.warn( + "The fed in active power is higher than the estimated maximum active power of this plant ({} < {}). " + + "Did you provide wrong weather input data?", + proposal, + pMax, + ) + + /* If the output is marginally small, suppress the output, as we are likely to be in night and then only produce incorrect output */ + if (proposal.compareTo(activationThreshold) > 0) + DefaultQuantities.zeroMW + else proposal + } + + override def createResults( + state: PvState, + lastOperatingPoint: Option[ActivePowerOperatingPoint], + currentOperatingPoint: ActivePowerOperatingPoint, + complexPower: ComplexPower, + dateTime: ZonedDateTime, + ): Iterable[SystemParticipantResult] = + Iterable( + new PvResult( + dateTime, + uuid, + complexPower.p.toMegawatts.asMegaWatt, + complexPower.q.toMegavars.asMegaVar, + ) + ) + + override def createPrimaryDataResult( + data: PrimaryDataWithComplexPower[_], + dateTime: ZonedDateTime, + ): SystemParticipantResult = + new PvResult( + dateTime, + uuid, + data.p.toMegawatts.asMegaWatt, + data.q.toMegavars.asMegaVar, + ) + + override def getRequiredSecondaryServices: Iterable[ServiceType] = + Iterable(ServiceType.WeatherService) + +} + +object PvModel { + + /** Holds all relevant data for a pv model calculation + * + * @param tick + * The current tick + * @param dateTime + * date and time of the ending of time frame to calculate + * @param weatherDataFrameLength + * the duration in ticks (= seconds) the provided irradiance is received by + * the pv panel + * @param diffIrradiance + * diffuse solar irradiance + * @param dirIrradiance + * direct solar irradiance + */ + final case class PvState( + override val tick: Long, + dateTime: ZonedDateTime, + weatherDataFrameLength: Long, + diffIrradiance: Irradiance, + dirIrradiance: Irradiance, + ) extends ModelState + + def apply( + inputModel: PvInput + ): PvModel = + new PvModel( + inputModel.getUuid, + Kilovoltamperes( + inputModel.getsRated + .to(PowerSystemUnits.KILOVOLTAMPERE) + .getValue + .doubleValue + ), + inputModel.getCosPhiRated, + QControl(inputModel.getqCharacteristics), + Degrees(inputModel.getNode.getGeoPosition.getY), + Degrees(inputModel.getNode.getGeoPosition.getX), + inputModel.getAlbedo, + Each( + inputModel.getEtaConv + .to(PowerSystemUnits.PU) + .getValue + .doubleValue + ), + Radians( + inputModel.getAzimuth + .to(RADIAN) + .getValue + .doubleValue + ), + Radians( + inputModel.getElevationAngle + .to(RADIAN) + .getValue + .doubleValue + ), + ) + +} diff --git a/src/main/scala/edu/ie3/simona/model/participant2/StorageModel.scala b/src/main/scala/edu/ie3/simona/model/participant2/StorageModel.scala new file mode 100644 index 0000000000..adeb7a9428 --- /dev/null +++ b/src/main/scala/edu/ie3/simona/model/participant2/StorageModel.scala @@ -0,0 +1,352 @@ +/* + * © 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.participant2 + +import edu.ie3.datamodel.models.input.system.StorageInput +import edu.ie3.datamodel.models.result.system.{ + StorageResult, + SystemParticipantResult, +} +import edu.ie3.simona.agent.participant.data.Data.PrimaryData.{ + ComplexPower, + PrimaryDataWithComplexPower, +} +import edu.ie3.simona.config.SimonaConfig.StorageRuntimeConfig +import edu.ie3.simona.exceptions.CriticalFailureException +import edu.ie3.simona.model.participant.StorageModel.RefTargetSocParams +import edu.ie3.simona.model.participant.control.QControl +import edu.ie3.simona.model.participant2.ParticipantModel.{ + ActivePowerOperatingPoint, + ModelInput, + ModelState, +} +import edu.ie3.simona.model.participant2.StorageModel.StorageState +import edu.ie3.simona.ontology.messages.flex.FlexibilityMessage +import edu.ie3.simona.ontology.messages.flex.MinMaxFlexibilityMessage.ProvideMinMaxFlexOptions +import edu.ie3.simona.service.ServiceType +import edu.ie3.util.quantities.PowerSystemUnits +import edu.ie3.util.quantities.QuantityUtils.RichQuantityDouble +import edu.ie3.util.scala.quantities.{ApparentPower, Kilovoltamperes} +import edu.ie3.util.scala.quantities.DefaultQuantities.{zeroKW, zeroKWh} +import squants.energy.{KilowattHours, Kilowatts} +import squants.{Dimensionless, Each, Energy, Power, Seconds} + +import java.time.ZonedDateTime +import java.util.UUID + +class StorageModel private ( + override val uuid: UUID, + override val sRated: ApparentPower, + override val cosPhiRated: Double, + override val qControl: QControl, + override val initialState: ModelInput => StorageState, + eStorage: Energy, + pMax: Power, + eta: Dimensionless, + targetSoc: Option[Double], +) extends ParticipantModel[ + ActivePowerOperatingPoint, + StorageState, + ] { + + private val minEnergy = zeroKWh + + /** Tolerance for power comparisons. With very small (dis-)charging powers, + * problems can occur when calculating the future tick at which storage is + * full or empty. For sufficiently large time frames, the maximum Long value + * ([[Long.MaxValue]]) can be exceeded, thus the Long value overflows and we + * get undefined behavior. + * + * Thus, small (dis-)charging powers compared to storage capacity have to be + * set to zero. The given tolerance value below amounts to 1 W for 1 GWh + * storage capacity and is sufficient in preventing Long overflows. + */ + private implicit val powerTolerance: Power = eStorage / Seconds(1) / 3.6e12 + + /** In order to avoid faulty behavior of storages, we want to avoid offering + * charging/discharging when storage is very close to full, to empty or to a + * target. + * + * In particular, we want to avoid offering the option to (dis-)charge if + * that operation could last less than our smallest possible time step, which + * is one second. Thus, we establish a safety margin of the energy + * (dis-)charged with maximum power in one second. + */ + private val toleranceMargin: Energy = pMax * Seconds(1d) + + /** Minimal allowed energy with tolerance margin added + */ + private val minEnergyWithMargin: Energy = + minEnergy + (toleranceMargin / eta.toEach) + + /** Maximum allowed energy with tolerance margin added + */ + private val maxEnergyWithMargin: Energy = + eStorage - (toleranceMargin * eta.toEach) + + private val refTargetSoc: Option[RefTargetSocParams] = targetSoc.map { + target => + val targetEnergy = eStorage * target + + val targetWithPosMargin = + targetEnergy + (toleranceMargin / eta.toEach) + + val targetWithNegMargin = + targetEnergy - (toleranceMargin * eta.toEach) + + RefTargetSocParams( + targetEnergy, + targetWithPosMargin, + targetWithNegMargin, + ) + } + + override def determineState( + lastState: StorageState, + operatingPoint: ActivePowerOperatingPoint, + input: ModelInput, + ): StorageState = { + val currentEnergy = ChargingHelper.calcEnergy( + lastState.storedEnergy, + operatingPoint.activePower, + lastState.tick, + input.currentTick, + eStorage, + minEnergy, + eta, + ) + + StorageState(currentEnergy, input.currentTick) + } + + override def determineOperatingPoint( + state: StorageState + ): (ActivePowerOperatingPoint, Option[Long]) = + throw new CriticalFailureException( + "Storage model cannot calculate operation point without flexibility control." + ) + + override def zeroPowerOperatingPoint: ActivePowerOperatingPoint = + ActivePowerOperatingPoint.zero + + override def createResults( + state: StorageState, + lastOperatingPoint: Option[ActivePowerOperatingPoint], + currentOperatingPoint: ActivePowerOperatingPoint, + complexPower: ComplexPower, + dateTime: ZonedDateTime, + ): Iterable[SystemParticipantResult] = + Iterable( + new StorageResult( + dateTime, + uuid, + complexPower.p.toMegawatts.asMegaWatt, + complexPower.q.toMegavars.asMegaVar, + (state.storedEnergy / eStorage).asPu, + ) + ) + + override def createPrimaryDataResult( + data: PrimaryDataWithComplexPower[_], + dateTime: ZonedDateTime, + ): SystemParticipantResult = + new StorageResult( + dateTime, + uuid, + data.p.toMegawatts.asMegaWatt, + data.q.toMegavars.asMegaVar, + -1.asPu, // FIXME currently not supported + ) + + override def getRequiredSecondaryServices: Iterable[ServiceType] = + Iterable.empty + + override def calcFlexOptions( + state: StorageState + ): FlexibilityMessage.ProvideFlexOptions = { + + val chargingPossible = !isFull(state.storedEnergy) + val dischargingPossible = !isEmpty(state.storedEnergy) + + val refPower = refTargetSoc + .map { targetParams => + if (state.storedEnergy <= targetParams.targetWithPosMargin) { + if (state.storedEnergy >= targetParams.targetWithNegMargin) { + // is within target +/- margin, no charging needed + zeroKW + } else { + // below target - margin, charge up to target + pMax + } + } else { + // above target + margin, discharge to target + pMax * -1d + } + } + .getOrElse { + // no target set + zeroKW + } + + ProvideMinMaxFlexOptions( + uuid, + refPower, + if (dischargingPossible) pMax * -1 else zeroKW, + if (chargingPossible) pMax else zeroKW, + ) + } + + override def handlePowerControl( + state: StorageState, + flexOptions: FlexibilityMessage.ProvideFlexOptions, + setPower: Power, + ): (ActivePowerOperatingPoint, ParticipantModel.OperationChangeIndicator) = { + val adaptedSetPower = + if ( + // if power is close to zero, set it to zero + (setPower ~= zeroKW) + // do not keep charging if we're already full (including safety margin) + || (setPower > zeroKW && isFull(state.storedEnergy)) + // do not keep discharging if we're already empty (including safety margin) + || (setPower < zeroKW && isEmpty(state.storedEnergy)) + ) + zeroKW + else + setPower + + // if the storage is at minimum or maximum charged energy AND we are charging + // or discharging, flex options will be different at the next activation + val isEmptyOrFull = + isEmpty(state.storedEnergy) || isFull(state.storedEnergy) + // if target soc is enabled, we can also be at that exact point + val isAtTarget = refTargetSoc.exists { targetParams => + state.storedEnergy <= targetParams.targetWithPosMargin && + state.storedEnergy >= targetParams.targetWithNegMargin + } + val isChargingOrDischarging = adaptedSetPower != zeroKW + // if we've been triggered just before we hit the minimum or maximum energy, + // and we're still discharging or charging respectively (happens in edge cases), + // we already set the power to zero (see above) and also want to refresh flex options + // at the next activation. + // Similarly, if the ref target margin area is hit before hitting target SOC, we want + // to refresh flex options. + val hasObsoleteFlexOptions = + (isFull(state.storedEnergy) && setPower > zeroKW) || + (isEmpty(state.storedEnergy) && setPower < zeroKW) || + (isAtTarget && setPower != zeroKW) + + val activateAtNextTick = + ((isEmptyOrFull || isAtTarget) && isChargingOrDischarging) || hasObsoleteFlexOptions + + // when charging, calculate time until we're full or at target energy + val chargingEnergyTarget = () => + refTargetSoc + .filter(_.targetWithNegMargin >= state.storedEnergy) + .map(_.targetSoc) + .getOrElse(eStorage) + + // when discharging, calculate time until we're at lowest energy allowed or at target energy + val dischargingEnergyTarget = () => + refTargetSoc + .filter(_.targetWithPosMargin <= state.storedEnergy) + .map(_.targetSoc) + .getOrElse(minEnergy) + + // calculate the tick from time span + val maybeNextTick = ChargingHelper.calcNextEventTick( + state.storedEnergy, + adaptedSetPower, + state.tick, + chargingEnergyTarget, + dischargingEnergyTarget, + eta, + ) + + ( + ActivePowerOperatingPoint(adaptedSetPower), + ParticipantModel.OperationChangeIndicator( + activateAtNextTick, + maybeNextTick, + ), + ) + } + + /** @param storedEnergy + * the stored energy amount to check + * @return + * whether the given stored energy is greater than the maximum charged + * energy allowed (minus a tolerance margin) + */ + private def isFull(storedEnergy: Energy): Boolean = + storedEnergy >= maxEnergyWithMargin + + /** @param storedEnergy + * the stored energy amount to check + * @return + * whether the given stored energy is less than the minimal charged energy + * allowed (plus a tolerance margin) + */ + private def isEmpty(storedEnergy: Energy): Boolean = + storedEnergy <= minEnergyWithMargin + +} + +object StorageModel { + + /** @param storedEnergy + * The amount of currently stored energy + * @param tick + * The tick at which this state is valid + */ + final case class StorageState( + storedEnergy: Energy, + tick: Long, + ) extends ModelState + + def apply( + inputModel: StorageInput, + config: StorageRuntimeConfig, + ): StorageModel = { + val eStorage = KilowattHours( + inputModel.getType.geteStorage + .to(PowerSystemUnits.KILOWATTHOUR) + .getValue + .doubleValue + ) + def getInitialState(eStorage: Energy, config: StorageRuntimeConfig)( + input: ModelInput + ): StorageState = { + val initialStorage = eStorage * config.initialSoc + StorageState(storedEnergy = initialStorage, input.currentTick) + } + + new StorageModel( + inputModel.getUuid, + Kilovoltamperes( + inputModel.getType.getsRated + .to(PowerSystemUnits.KILOVOLTAMPERE) + .getValue + .doubleValue + ), + inputModel.getType.getCosPhiRated, + QControl.apply(inputModel.getqCharacteristics), + getInitialState(eStorage, config), + eStorage, + Kilowatts( + inputModel.getType.getpMax + .to(PowerSystemUnits.KILOWATT) + .getValue + .doubleValue + ), + Each( + inputModel.getType.getEta.to(PowerSystemUnits.PU).getValue.doubleValue + ), + config.targetSoc, + ) + } + +} diff --git a/src/main/scala/edu/ie3/simona/model/participant2/WecModel.scala b/src/main/scala/edu/ie3/simona/model/participant2/WecModel.scala new file mode 100644 index 0000000000..2a879e2b3a --- /dev/null +++ b/src/main/scala/edu/ie3/simona/model/participant2/WecModel.scala @@ -0,0 +1,287 @@ +/* + * © 2020. TU Dortmund University, + * Institute of Energy Systems, Energy Efficiency and Energy Economics, + * Research group Distribution grid planning and operation + */ + +package edu.ie3.simona.model.participant2 + +import com.typesafe.scalalogging.LazyLogging +import edu.ie3.datamodel.models.input.system.WecInput +import edu.ie3.datamodel.models.input.system.characteristic.WecCharacteristicInput +import edu.ie3.datamodel.models.result.system.{ + SystemParticipantResult, + WecResult, +} +import edu.ie3.simona.agent.participant.data.Data +import edu.ie3.simona.agent.participant.data.Data.PrimaryData.{ + ComplexPower, + PrimaryDataWithComplexPower, +} +import edu.ie3.simona.exceptions.CriticalFailureException +import edu.ie3.simona.model.participant.control.QControl +import edu.ie3.simona.model.participant2.ParticipantFlexibility.ParticipantSimpleFlexibility +import edu.ie3.simona.model.participant2.ParticipantModel.{ + ActivePowerOperatingPoint, + ModelInput, + ModelState, +} +import edu.ie3.simona.model.participant2.WecModel.{ + WecCharacteristic, + WecState, + molarMassAir, + universalGasConstantR, +} +import edu.ie3.simona.model.system.Characteristic +import edu.ie3.simona.model.system.Characteristic.XYPair +import edu.ie3.simona.ontology.messages.services.WeatherMessage.WeatherData +import edu.ie3.simona.service.ServiceType +import edu.ie3.util.quantities.PowerSystemUnits.{KILOVOLTAMPERE, PU} +import edu.ie3.util.quantities.QuantityUtils.RichQuantityDouble +import edu.ie3.util.scala.Scope +import edu.ie3.util.scala.quantities.{ApparentPower, Kilovoltamperes} +import squants._ +import squants.energy.Watts +import squants.mass.{Kilograms, KilogramsPerCubicMeter} +import squants.motion.{MetersPerSecond, Pressure} +import squants.space.SquareMeters +import squants.thermal.JoulesPerKelvin +import tech.units.indriya.unit.Units._ + +import java.time.ZonedDateTime +import java.util.UUID +import scala.collection.SortedSet + +class WecModel private ( + override val uuid: UUID, + override val sRated: ApparentPower, + override val cosPhiRated: Double, + override val qControl: QControl, + private val rotorArea: Area, + private val betzCurve: WecCharacteristic, +) extends ParticipantModel[ + ActivePowerOperatingPoint, + WecState, + ] + with ParticipantSimpleFlexibility[WecState] + with LazyLogging { + + override val initialState: ModelInput => WecState = { input => + val weatherData = getWeatherData(input.receivedData) + WecState( + input.currentTick, + weatherData.windVel, + weatherData.temp, + None, + ) + } + + override def determineState( + lastState: WecState, + operatingPoint: ActivePowerOperatingPoint, + input: ModelInput, + ): WecState = initialState(input) + + private def getWeatherData(receivedData: Seq[Data]): WeatherData = { + receivedData + .collectFirst { case weatherData: WeatherData => + weatherData + } + .getOrElse { + throw new CriticalFailureException( + s"Expected WeatherData, got $receivedData" + ) + } + } + + override def determineOperatingPoint( + state: WecState + ): (ActivePowerOperatingPoint, Option[Long]) = { + val betzCoefficient = determineBetzCoefficient(state.windVelocity) + + /** air density in kg/m³ + */ + val airDensity = + calculateAirDensity( + state.temperature, + state.airPressure, + ).toKilogramsPerCubicMeter + + val v = state.windVelocity.toMetersPerSecond + + /** cubed velocity in m³/s³ + */ + val cubedVelocity = v * v * v + + val activePower = Scope( + // Combined, we get (kg * m²)/s³, which is Watts + Watts( + cubedVelocity * 0.5 * betzCoefficient.toEach * airDensity * rotorArea.toSquareMeters + ) + ).map { power => + if (power > pRated) { + logger.warn( + "The fed in active power is higher than the estimated maximum active power of this plant ({} > {}). " + + "Did you provide wrong weather input data?", + power, + pRated, + ) + pRated + } else + power + }.map(_ * -1) + .get + + (ActivePowerOperatingPoint(activePower), None) + } + + /** The coefficient is dependent on the wind velocity v. Therefore use v to + * determine the betz coefficient cₚ. + * + * @param windVelocity + * current wind velocity + * @return + * betz coefficient cₚ + */ + def determineBetzCoefficient( + windVelocity: Velocity + ): Dimensionless = { + betzCurve.interpolateXy(windVelocity) match { + case (_, cp) => cp + } + } + + /** Calculate the correct air density, dependent on the current temperature + * and air pressure. + * + * If no air pressure is given, the default density 1.2041 is returned (air + * density for 20 degrees Celsius at sea level) + * + * @param temperature + * current temperature + * @param airPressure + * current air pressure + * @return + */ + def calculateAirDensity( + temperature: Temperature, + airPressure: Option[Pressure], + ): Density = { + airPressure match { + case None => + KilogramsPerCubicMeter(1.2041d) + case Some(pressure) => + // kg * mol^-1 * J * m^-3 * J^-1 * K * mol * K^-1 + // = kg * m^-3 + KilogramsPerCubicMeter( + molarMassAir.toKilograms * pressure.toPascals / (universalGasConstantR.toJoulesPerKelvin * temperature.toKelvinScale) + ) + } + } + + override def zeroPowerOperatingPoint: ActivePowerOperatingPoint = + ActivePowerOperatingPoint.zero + + override def createResults( + state: WecState, + lastOperatingPoint: Option[ActivePowerOperatingPoint], + currentOperatingPoint: ActivePowerOperatingPoint, + complexPower: ComplexPower, + dateTime: ZonedDateTime, + ): Iterable[SystemParticipantResult] = + Iterable( + new WecResult( + dateTime, + uuid, + complexPower.p.toMegawatts.asMegaWatt, + complexPower.q.toMegavars.asMegaVar, + ) + ) + + override def createPrimaryDataResult( + data: PrimaryDataWithComplexPower[_], + dateTime: ZonedDateTime, + ): SystemParticipantResult = + new WecResult( + dateTime, + uuid, + data.p.toMegawatts.asMegaWatt, + data.q.toMegavars.asMegaVar, + ) + + override def getRequiredSecondaryServices: Iterable[ServiceType] = + Iterable(ServiceType.WeatherService) + +} + +object WecModel { + + /** Universal gas constant + */ + private val universalGasConstantR = JoulesPerKelvin(8.31446261815324d) + + /** Molar mass of air, actually in kg/mol + */ + private val molarMassAir = Kilograms(0.0289647d) + + /** Holds all relevant data for wec model calculation + * + * @param windVelocity + * current wind velocity + * @param temperature + * current temperature + * @param airPressure + * current air pressure + */ + final case class WecState( + override val tick: Long, + windVelocity: Velocity, + temperature: Temperature, + airPressure: Option[Pressure], + ) extends ModelState + + /** This class is initialized with a [[WecCharacteristicInput]], which + * contains the needed betz curve. + */ + final case class WecCharacteristic( + override val xyCoordinates: SortedSet[ + XYPair[Velocity, Dimensionless] + ] + ) extends Characteristic[Velocity, Dimensionless] + + object WecCharacteristic { + import scala.jdk.CollectionConverters._ + + /** Transform the inputs points from [[java.util.SortedSet]] to + * [[scala.collection.SortedSet]], which is fed into [[WecCharacteristic]]. + */ + def apply(input: WecCharacteristicInput): WecCharacteristic = + new WecCharacteristic( + collection.immutable + .SortedSet[XYPair[Velocity, Dimensionless]]() ++ + input.getPoints.asScala.map(p => + XYPair[Velocity, Dimensionless]( + MetersPerSecond(p.getX.to(METRE_PER_SECOND).getValue.doubleValue), + Each(p.getY.to(PU).getValue.doubleValue), + ) + ) + ) + } + + def apply( + inputModel: WecInput + ): WecModel = + new WecModel( + inputModel.getUuid, + Kilovoltamperes( + inputModel.getType.getsRated.to(KILOVOLTAMPERE).getValue.doubleValue + ), + inputModel.getType.getCosPhiRated, + QControl(inputModel.getqCharacteristics), + SquareMeters( + inputModel.getType.getRotorArea.to(SQUARE_METRE).getValue.doubleValue + ), + WecCharacteristic(inputModel.getType.getCpCharacteristic), + ) + +} diff --git a/src/main/scala/edu/ie3/simona/model/participant2/evcs/ConstantPowerCharging.scala b/src/main/scala/edu/ie3/simona/model/participant2/evcs/ConstantPowerCharging.scala new file mode 100644 index 0000000000..c89880d0a7 --- /dev/null +++ b/src/main/scala/edu/ie3/simona/model/participant2/evcs/ConstantPowerCharging.scala @@ -0,0 +1,40 @@ +/* + * © 2021. TU Dortmund University, + * Institute of Energy Systems, Energy Efficiency and Energy Economics, + * Research group Distribution grid planning and operation + */ + +package edu.ie3.simona.model.participant2.evcs + +import edu.ie3.simona.model.participant.evcs.EvModelWrapper +import squants.{Power, Seconds} + +import java.util.UUID + +/** Determine scheduling for charging the EVs currently parked at the charging + * station by charging with constant power from current time until departure. + * If less than the maximum power is required to reach 100% SoC, the power is + * reduced accordingly. + */ +object ConstantPowerCharging extends EvcsChargingStrategy { + + override def determineChargingPowers( + evs: Iterable[EvModelWrapper], + currentTick: Long, + chargingProps: EvcsChargingProperties, + ): Map[UUID, Power] = evs + .filter(ev => ev.storedEnergy < ev.eStorage) + .map { ev => + val maxChargingPower = chargingProps.getMaxAvailableChargingPower(ev) + val remainingParkingTime = Seconds(ev.departureTick - currentTick) + + val requiredEnergyUntilFull = ev.eStorage - ev.storedEnergy + + val chargingPower = + maxChargingPower.min(requiredEnergyUntilFull / remainingParkingTime) + + ev.uuid -> chargingPower + } + .toMap + +} diff --git a/src/main/scala/edu/ie3/simona/model/participant2/evcs/EvcsChargingProperties.scala b/src/main/scala/edu/ie3/simona/model/participant2/evcs/EvcsChargingProperties.scala new file mode 100644 index 0000000000..b79965c541 --- /dev/null +++ b/src/main/scala/edu/ie3/simona/model/participant2/evcs/EvcsChargingProperties.scala @@ -0,0 +1,43 @@ +/* + * © 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.participant2.evcs + +import edu.ie3.datamodel.models.ElectricCurrentType +import edu.ie3.simona.model.participant.evcs.EvModelWrapper +import squants.Power + +trait EvcsChargingProperties { + + /** Charging station rated power + */ + protected val pRated: Power + + val currentType: ElectricCurrentType + + val lowestEvSoc: Double + + /** Returns the maximum available charging power for an EV, which depends on + * ev and charging station limits for AC and DC current + * + * @param ev + * ev for which the max charging power should be returned + * @return + * maximum charging power for the EV at this charging station + */ + def getMaxAvailableChargingPower( + ev: EvModelWrapper + ): Power = { + val evPower = currentType match { + case ElectricCurrentType.AC => + ev.pRatedAc + case ElectricCurrentType.DC => + ev.pRatedDc + } + /* Limit the charging power to the minimum of ev's and evcs' permissible power */ + evPower.min(pRated) + } +} diff --git a/src/main/scala/edu/ie3/simona/model/participant2/evcs/EvcsChargingStrategy.scala b/src/main/scala/edu/ie3/simona/model/participant2/evcs/EvcsChargingStrategy.scala new file mode 100644 index 0000000000..12edcefd5b --- /dev/null +++ b/src/main/scala/edu/ie3/simona/model/participant2/evcs/EvcsChargingStrategy.scala @@ -0,0 +1,49 @@ +/* + * © 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.participant2.evcs + +import edu.ie3.simona.exceptions.CriticalFailureException +import edu.ie3.simona.model.participant.evcs.EvModelWrapper +import squants.Power + +import java.util.UUID + +trait EvcsChargingStrategy { + + /** Determine scheduling for charging the EVs currently parked at the charging + * station until their departure. + * + * @param evs + * currently parked evs at the charging station + * @param currentTick + * current tick + * @param chargingProps + * interface that provides information on charging station + * @return + * scheduling for charging the EVs + */ + def determineChargingPowers( + evs: Iterable[EvModelWrapper], + currentTick: Long, + chargingProps: EvcsChargingProperties, + ): Map[UUID, Power] + +} + +object EvcsChargingStrategy { + + def apply(token: String): EvcsChargingStrategy = + "[-_]".r.replaceAllIn(token.trim.toLowerCase, "") match { + case "maxpower" => MaximumPowerCharging + case "constantpower" => ConstantPowerCharging + case unknown => + throw new CriticalFailureException( + s"The token '$unknown' cannot be parsed to charging strategy." + ) + } + +} diff --git a/src/main/scala/edu/ie3/simona/model/participant2/evcs/EvcsModel.scala b/src/main/scala/edu/ie3/simona/model/participant2/evcs/EvcsModel.scala new file mode 100644 index 0000000000..cf3546d8ba --- /dev/null +++ b/src/main/scala/edu/ie3/simona/model/participant2/evcs/EvcsModel.scala @@ -0,0 +1,611 @@ +/* + * © 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.participant2.evcs + +import edu.ie3.datamodel.models.ElectricCurrentType +import edu.ie3.datamodel.models.input.system.EvcsInput +import edu.ie3.datamodel.models.result.system.{ + EvResult, + EvcsResult, + SystemParticipantResult, +} +import edu.ie3.simona.agent.participant.data.Data +import edu.ie3.simona.agent.participant.data.Data.PrimaryData +import edu.ie3.simona.agent.participant.data.Data.PrimaryData.ComplexPower +import edu.ie3.simona.agent.participant2.ParticipantAgent +import edu.ie3.simona.agent.participant2.ParticipantAgent.ParticipantRequest +import edu.ie3.simona.config.SimonaConfig.EvcsRuntimeConfig +import edu.ie3.simona.model.participant.control.QControl +import edu.ie3.simona.model.participant.evcs.EvModelWrapper +import edu.ie3.simona.model.participant2.ParticipantModel.{ + ModelInput, + ModelState, + OperatingPoint, + OperationChangeIndicator, +} +import edu.ie3.simona.model.participant2.evcs.EvcsModel.{ + EvcsOperatingPoint, + EvcsState, +} +import edu.ie3.simona.model.participant2.{ChargingHelper, ParticipantModel} +import edu.ie3.simona.ontology.messages.flex.FlexibilityMessage +import edu.ie3.simona.ontology.messages.flex.MinMaxFlexibilityMessage.ProvideMinMaxFlexOptions +import edu.ie3.simona.ontology.messages.services.EvMessage._ +import edu.ie3.simona.service.ServiceType +import edu.ie3.util.quantities.PowerSystemUnits.KILOVOLTAMPERE +import edu.ie3.util.quantities.QuantityUtils.RichQuantityDouble +import edu.ie3.util.scala.quantities.DefaultQuantities._ +import edu.ie3.util.scala.quantities.{ + ApparentPower, + Kilovoltamperes, + ReactivePower, +} +import org.apache.pekko.actor.typed.scaladsl.ActorContext +import squants.energy.{Kilowatts, Watts} +import squants.time.Seconds +import squants.{Energy, Power} +import tech.units.indriya.unit.Units.PERCENT + +import java.time.ZonedDateTime +import java.util.UUID + +class EvcsModel private ( + override val uuid: UUID, + override val sRated: ApparentPower, + override val cosPhiRated: Double, + override val qControl: QControl, + strategy: EvcsChargingStrategy, + override val currentType: ElectricCurrentType, + override val lowestEvSoc: Double, + chargingPoints: Int, + vehicle2grid: Boolean, +) extends ParticipantModel[ + EvcsOperatingPoint, + EvcsState, + ] + with EvcsChargingProperties { + + override val initialState: ModelInput => EvcsState = { input => + EvcsState(getArrivals(input.receivedData), input.currentTick) + } + + override def determineState( + lastState: EvcsState, + operatingPoint: EvcsOperatingPoint, + input: ModelInput, + ): EvcsState = { + + val updatedEvs = lastState.evs.map { ev => + operatingPoint.evOperatingPoints + .get(ev.uuid) + .map { chargingPower => + val currentEnergy = ChargingHelper.calcEnergy( + ev.storedEnergy, + chargingPower, + lastState.tick, + input.currentTick, + ev.eStorage, + ) + + ev.copy(storedEnergy = currentEnergy) + } + .getOrElse(ev) + } + + val arrivals = getArrivals(input.receivedData) + + EvcsState(updatedEvs ++ arrivals, input.currentTick) + } + + private def getArrivals(receivedData: Seq[Data]): Seq[EvModelWrapper] = { + receivedData + .collectFirst { case evData: ArrivingEvs => + evData.arrivals + } + .getOrElse(Seq.empty) + } + + override def determineOperatingPoint( + state: EvcsState + ): (EvcsOperatingPoint, Option[Long]) = { + val chargingPowers = + strategy.determineChargingPowers(state.evs, state.tick, this) + + val nextEvent = state.evs + .flatMap { ev => + chargingPowers.get(ev.uuid).map((ev, _)) + } + .flatMap { case (ev, power) => + determineNextEvent( + ev, + power, + state.tick, + ) + } + .minOption + + (EvcsOperatingPoint(chargingPowers), nextEvent) + } + + override def zeroPowerOperatingPoint: EvcsOperatingPoint = + EvcsOperatingPoint.zero + + override def createResults( + state: EvcsState, + lastOperatingPoint: Option[EvcsOperatingPoint], + currentOperatingPoint: EvcsOperatingPoint, + complexPower: ComplexPower, + dateTime: ZonedDateTime, + ): Iterable[SystemParticipantResult] = { + val evResults = state.evs.flatMap { ev => + val lastOp = lastOperatingPoint.flatMap(_.evOperatingPoints.get(ev.uuid)) + val currentOp = currentOperatingPoint.evOperatingPoints.get(ev.uuid) + + val currentPower = currentOp.getOrElse(zeroKW) + + val resultPower = + // only take results that are different from last time + if (!lastOp.contains(currentPower)) + Some(currentPower) + // create 0 kW results for EVs that are not charging anymore + else if (lastOp.isDefined && currentOp.isEmpty) + Some(zeroKW) + else + None + + resultPower.map { activePower => + // assume that reactive power is proportional to active power + val reactivePower = complexPower.q * (activePower / complexPower.p) + + val soc = (ev.storedEnergy / ev.eStorage).asPu + .to(PERCENT) + + new EvResult( + dateTime, + ev.uuid, + activePower.toMegawatts.asMegaWatt, + reactivePower.toMegavars.asMegaVar, + soc, + ) + } + } + + val powerDifferent = lastOperatingPoint.forall( + _.activePower != complexPower.p + ) + + val evcsResult = + if (powerDifferent) + Iterable( + new EvcsResult( + dateTime, + uuid, + complexPower.p.toMegawatts.asMegaWatt, + complexPower.q.toMegavars.asMegaVar, + ) + ) + else + Iterable.empty + + evResults ++ evcsResult + } + + override def createPrimaryDataResult( + data: PrimaryData.PrimaryDataWithComplexPower[_], + dateTime: ZonedDateTime, + ): SystemParticipantResult = + new EvcsResult( + dateTime, + uuid, + data.p.toMegawatts.asMegaWatt, + data.q.toMegavars.asMegaVar, + ) + + override def getRequiredSecondaryServices: Iterable[ServiceType] = + Iterable( + ServiceType.EvMovementService + ) + + override def calcFlexOptions( + state: EvcsState + ): FlexibilityMessage.ProvideFlexOptions = { + + val preferredPowers = + strategy.determineChargingPowers(state.evs, state.tick, this) + + val (maxCharging, preferredPower, forcedCharging, maxDischarging) = + state.evs.foldLeft( + (zeroKW, zeroKW, zeroKW, zeroKW) + ) { + case ( + (chargingSum, preferredSum, forcedSum, dischargingSum), + ev, + ) => + val maxPower = getMaxAvailableChargingPower(ev) + + val preferredPower = preferredPowers.get(ev.uuid) + + val maxCharging = + if (!isFull(ev)) + maxPower + else + zeroKW + + val forced = + if (isEmpty(ev) && !isInLowerMargin(ev)) + preferredPower.getOrElse(maxPower) + else + zeroKW + + val maxDischarging = + if (!isEmpty(ev) && vehicle2grid) + maxPower * -1 + else + zeroKW + + ( + chargingSum + maxCharging, + preferredSum + preferredPower.getOrElse(zeroKW), + forcedSum + forced, + dischargingSum + maxDischarging, + ) + } + + // if we need to charge at least one EV, we cannot discharge any other + val (adaptedMaxDischarging, adaptedPreferred) = + if (forcedCharging > zeroKW) + (forcedCharging, preferredPower.max(forcedCharging)) + else + (maxDischarging, preferredPower) + + ProvideMinMaxFlexOptions( + uuid, + adaptedPreferred, + adaptedMaxDischarging, + maxCharging, + ) + } + + override def handlePowerControl( + state: EvcsState, + flexOptions: FlexibilityMessage.ProvideFlexOptions, + setPower: Power, + ): (EvcsOperatingPoint, OperationChangeIndicator) = { + if (setPower == zeroKW) + return ( + EvcsOperatingPoint(Map.empty), + OperationChangeIndicator(), + ) + + // applicable evs can be charged/discharged, other evs cannot + val applicableEvs = state.evs.filter { ev => + if (setPower > zeroKW) + !isFull(ev) + else + !isEmpty(ev) + } + + val (forcedChargingEvs, regularChargingEvs) = + if (setPower > zeroKW) + // lower margin is excluded since charging is not required here anymore + applicableEvs.partition { ev => + isEmpty(ev) && !isInLowerMargin(ev) + } + else + (Seq.empty, applicableEvs) + + val (forcedSchedules, remainingPower) = + distributeChargingPower(state.tick, forcedChargingEvs, setPower) + + val (regularSchedules, _) = + distributeChargingPower(state.tick, regularChargingEvs, remainingPower) + + val combinedSchedules = forcedSchedules ++ regularSchedules + + val allSchedules = combinedSchedules.map { case (ev, (power, _)) => + ev -> power + }.toMap + + val aggregatedChangeIndicator = combinedSchedules + .map { case (_, (_, indicator)) => indicator } + .foldLeft(OperationChangeIndicator()) { + case (aggregateIndicator, otherIndicator) => + aggregateIndicator | otherIndicator + } + + ( + EvcsOperatingPoint(allSchedules), + aggregatedChangeIndicator, + ) + } + + /** Distributes some set power value across given EVs, taking into + * consideration the maximum charging power of EVs and the charging station + * + * @param currentTick + * The current tick + * @param evs + * The collection of EVs to assign charging power to + * @param setPower + * The remaining power to assign to given EVs + * @return + * A set of EV model and possibly charging schedule and activation + * indicators, as well as the remaining power that could not be assigned to + * given EVs + */ + private def distributeChargingPower( + currentTick: Long, + evs: Seq[EvModelWrapper], + setPower: Power, + ): ( + Seq[(UUID, (Power, OperationChangeIndicator))], + Power, + ) = { + + if (evs.isEmpty) return (Seq.empty, setPower) + + if (setPower.~=(zeroKW)(Kilowatts(1e-6))) { + // No power left. Rest is not charging + return (Seq.empty, zeroKW) + } + + val proposedPower = setPower.divide(evs.size) + + val (exceedingPowerEvs, fittingPowerEvs) = evs.partition { ev => + if (setPower > zeroKW) + proposedPower > getMaxAvailableChargingPower(ev) + else + proposedPower < (getMaxAvailableChargingPower(ev) * -1) + } + + if (exceedingPowerEvs.isEmpty) { + // end of recursion, rest of charging power fits to all + + val results = fittingPowerEvs.map { ev => + val endTick = determineNextEvent(ev, proposedPower, currentTick) + .map(math.min(_, ev.departureTick)) + .getOrElse(ev.departureTick) + + ( + ev.uuid, + ( + proposedPower, + OperationChangeIndicator( + changesAtNextActivation = + isFull(ev) || isEmpty(ev) || isInLowerMargin(ev), + changesAtTick = Some(endTick), + ), + ), + ) + } + + (results, zeroKW) + } else { + // not all evs can be charged with proposed power + + // charge all exceeded evs with their respective maximum power + val maxCharged = exceedingPowerEvs.map { ev => + val maxPower = getMaxAvailableChargingPower(ev) + val power = + if (setPower > zeroKW) + maxPower + else + maxPower * -1 + + val endTick = determineNextEvent(ev, power, currentTick) + .map(math.min(_, ev.departureTick)) + .getOrElse(ev.departureTick) + + (ev, power, endTick) + } + + val maxChargedResults = maxCharged.map { case (ev, power, endTick) => + ( + ev.uuid, + ( + power, + OperationChangeIndicator( + changesAtNextActivation = + isFull(ev) || isEmpty(ev) || isInLowerMargin(ev), + changesAtTick = Some(endTick), + ), + ), + ) + } + + // sum up allocated power + val chargingPowerSum = maxCharged.foldLeft(zeroKW) { + case (powerSum, (_, chargingPower, _)) => + powerSum + chargingPower + } + + val remainingAfterAllocation = setPower - chargingPowerSum + + // go into the next recursion step with the remaining power + val (nextIterationResults, remainingAfterRecursion) = + distributeChargingPower( + currentTick, + fittingPowerEvs, + remainingAfterAllocation, + ) + + val combinedResults = maxChargedResults ++ nextIterationResults + + (combinedResults, remainingAfterRecursion) + } + } + + /** Calculates the tick at which the target energy (e.g. full on charging or + * empty on discharging) is reached. + * + * @param ev + * The EV to charge/discharge + * @param power + * The charging/discharging power + * @param currentTick + * The current simulation tick + * @return + * The tick wat which the target is reached + */ + private def determineNextEvent( + ev: EvModelWrapper, + power: Power, + currentTick: Long, + ): Option[Long] = { + // TODO adapt like in StorageModel: dependent tolerance + implicit val tolerance: Power = Watts(1e-3) + + val chargingEnergyTarget = () => + if (isEmpty(ev) && !isInLowerMargin(ev)) + ev.eStorage * lowestEvSoc + else + ev.eStorage + + val dischargingEnergyTarget = () => ev.eStorage * lowestEvSoc + + ChargingHelper.calcNextEventTick( + ev.storedEnergy, + power, + currentTick, + chargingEnergyTarget, + dischargingEnergyTarget, + ) + } + + /** Handling requests that are not part of the standard participant protocol + * + * @param state + * The current state + * @param ctx + * The actor context that can be used to send replies + * @param msg + * The received request + * @return + * An updated state, or the same state provided as parameter + */ + override def handleRequest( + state: EvcsState, + ctx: ActorContext[ParticipantAgent.Request], + msg: ParticipantRequest, + ): EvcsState = msg match { + case freeLotsRequest: EvFreeLotsRequest => + val stayingEvsCount = + state.evs.count(_.departureTick > freeLotsRequest.tick) + + freeLotsRequest.replyTo ! FreeLotsResponse( + uuid, + chargingPoints - stayingEvsCount, + ) + + state + + case departingEvsRequest: DepartingEvsRequest => + // create a set for faster containment checking + val requestedEvs = departingEvsRequest.departingEvs.toSet + + val (departingEvs, stayingEvs) = state.evs.partition { ev => + requestedEvs.contains(ev.uuid) + } + + if (departingEvs.size != requestedEvs.size) { + requestedEvs.foreach { requestedUuid => + if (!departingEvs.exists(_.uuid == requestedUuid)) + ctx.log.warn( + s"EV $requestedUuid should depart from this station (according to external simulation), but has not been parked here." + ) + } + } + + departingEvsRequest.replyTo ! DepartingEvsResponse(uuid, departingEvs) + + state.copy(evs = stayingEvs) + + } + + /* HELPER METHODS */ + + /** @param ev + * the ev whose stored energy is to be checked + * @return + * whether the given ev's stored energy is greater than the maximum charged + * energy allowed (minus a tolerance margin) + */ + private def isFull(ev: EvModelWrapper): Boolean = + ev.storedEnergy >= (ev.eStorage - calcToleranceMargin(ev)) + + /** @param ev + * the ev whose stored energy is to be checked + * @return + * whether the given ev's stored energy is less than the minimal charged + * energy allowed (plus a tolerance margin) + */ + private def isEmpty(ev: EvModelWrapper): Boolean = + ev.storedEnergy <= ( + ev.eStorage * lowestEvSoc + calcToleranceMargin(ev) + ) + + /** @param ev + * the ev whose stored energy is to be checked + * @return + * whether the given ev's stored energy is within +- tolerance of the + * minimal charged energy allowed + */ + private def isInLowerMargin(ev: EvModelWrapper): Boolean = { + val toleranceMargin = calcToleranceMargin(ev) + val lowestSoc = ev.eStorage * lowestEvSoc + + ev.storedEnergy <= ( + lowestSoc + toleranceMargin + ) && ev.storedEnergy >= ( + lowestSoc - toleranceMargin + ) + } + + private def calcToleranceMargin(ev: EvModelWrapper): Energy = + getMaxAvailableChargingPower(ev) * Seconds(1) + +} + +object EvcsModel { + + final case class EvcsOperatingPoint(evOperatingPoints: Map[UUID, Power]) + extends OperatingPoint { + + override val activePower: Power = + evOperatingPoints.values.reduceOption(_ + _).getOrElse(zeroKW) + + override val reactivePower: Option[ReactivePower] = None + } + + object EvcsOperatingPoint { + def zero: EvcsOperatingPoint = EvcsOperatingPoint(Map.empty) + } + + final case class EvcsState( + evs: Seq[EvModelWrapper], + override val tick: Long, + ) extends ModelState + + def apply( + inputModel: EvcsInput, + modelConfig: EvcsRuntimeConfig, + ): EvcsModel = + new EvcsModel( + inputModel.getUuid, + Kilovoltamperes( + inputModel.getType.getsRated.to(KILOVOLTAMPERE).getValue.doubleValue + ), + inputModel.getCosPhiRated, + QControl(inputModel.getqCharacteristics), + EvcsChargingStrategy(modelConfig.chargingStrategy), + inputModel.getType.getElectricCurrentType, + modelConfig.lowestEvSoc, + inputModel.getChargingPoints, + inputModel.getV2gSupport, + ) + +} diff --git a/src/main/scala/edu/ie3/simona/model/participant2/evcs/MaximumPowerCharging.scala b/src/main/scala/edu/ie3/simona/model/participant2/evcs/MaximumPowerCharging.scala new file mode 100644 index 0000000000..941617bda2 --- /dev/null +++ b/src/main/scala/edu/ie3/simona/model/participant2/evcs/MaximumPowerCharging.scala @@ -0,0 +1,31 @@ +/* + * © 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.participant2.evcs + +import edu.ie3.simona.model.participant.evcs.EvModelWrapper +import squants.Power + +import java.util.UUID + +/** Determine scheduling for charging the EVs currently parked at the charging + * station by charging with maximum power from current time until it reaches + * either 100% SoC or its departure time. + */ +object MaximumPowerCharging extends EvcsChargingStrategy { + + def determineChargingPowers( + evs: Iterable[EvModelWrapper], + currentTick: Long, + chargingProps: EvcsChargingProperties, + ): Map[UUID, Power] = evs + .filter(ev => ev.storedEnergy < ev.eStorage) + .map { ev => + ev.uuid -> chargingProps.getMaxAvailableChargingPower(ev) + } + .toMap + +} diff --git a/src/main/scala/edu/ie3/simona/model/participant2/load/FixedLoadModel.scala b/src/main/scala/edu/ie3/simona/model/participant2/load/FixedLoadModel.scala new file mode 100644 index 0000000000..cdcb7d3109 --- /dev/null +++ b/src/main/scala/edu/ie3/simona/model/participant2/load/FixedLoadModel.scala @@ -0,0 +1,72 @@ +/* + * © 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.participant2.load + +import edu.ie3.datamodel.models.input.system.LoadInput +import edu.ie3.simona.config.SimonaConfig.LoadRuntimeConfig +import edu.ie3.simona.model.participant.control.QControl +import edu.ie3.simona.model.participant.load.LoadReference +import edu.ie3.simona.model.participant.load.LoadReference.{ + ActivePower, + EnergyConsumption, +} +import edu.ie3.simona.model.participant2.ParticipantModel.{ + ActivePowerOperatingPoint, + FixedState, + ParticipantFixedState, +} +import edu.ie3.util.quantities.PowerSystemUnits +import edu.ie3.util.scala.quantities.{ApparentPower, Kilovoltamperes} +import squants.time.Days +import squants.Power + +import java.util.UUID + +class FixedLoadModel( + override val uuid: UUID, + override val sRated: ApparentPower, + override val cosPhiRated: Double, + override val qControl: QControl, + private val activePower: Power, +) extends LoadModel[FixedState] + with ParticipantFixedState[ActivePowerOperatingPoint] { + + override def determineOperatingPoint( + state: FixedState + ): (ActivePowerOperatingPoint, Option[Long]) = + (ActivePowerOperatingPoint(activePower), None) + +} + +object FixedLoadModel { + def apply( + inputModel: LoadInput, + config: LoadRuntimeConfig, + ): FixedLoadModel = { + val reference = LoadReference(inputModel, config) + + val activePower: Power = reference match { + case ActivePower(power) => power + case EnergyConsumption(energyConsumption) => + val duration = Days(365d) + energyConsumption / duration + } + + new FixedLoadModel( + inputModel.getUuid, + Kilovoltamperes( + inputModel.getsRated + .to(PowerSystemUnits.KILOVOLTAMPERE) + .getValue + .doubleValue + ), + inputModel.getCosPhiRated, + QControl.apply(inputModel.getqCharacteristics), + activePower, + ) + } +} diff --git a/src/main/scala/edu/ie3/simona/model/participant2/load/LoadModel.scala b/src/main/scala/edu/ie3/simona/model/participant2/load/LoadModel.scala new file mode 100644 index 0000000000..f2d22386a1 --- /dev/null +++ b/src/main/scala/edu/ie3/simona/model/participant2/load/LoadModel.scala @@ -0,0 +1,144 @@ +/* + * © 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.participant2.load + +import edu.ie3.datamodel.models.input.system.LoadInput +import edu.ie3.datamodel.models.result.system.{ + LoadResult, + SystemParticipantResult, +} +import edu.ie3.simona.agent.participant.data.Data.PrimaryData.{ + ComplexPower, + PrimaryDataWithComplexPower, +} +import edu.ie3.simona.config.SimonaConfig.LoadRuntimeConfig +import edu.ie3.simona.model.participant.load.LoadModelBehaviour +import edu.ie3.simona.model.participant2.ParticipantFlexibility.ParticipantSimpleFlexibility +import edu.ie3.simona.model.participant2.ParticipantModel +import edu.ie3.simona.model.participant2.ParticipantModel.{ + ActivePowerOperatingPoint, + ModelState, +} +import edu.ie3.simona.service.ServiceType +import edu.ie3.util.quantities.PowerSystemUnits.{KILOVOLTAMPERE, KILOWATTHOUR} +import edu.ie3.util.quantities.QuantityUtils.RichQuantityDouble +import edu.ie3.util.scala.quantities.{ApparentPower, Kilovoltamperes} +import squants.energy.KilowattHours +import squants.{Energy, Power} + +import java.time.ZonedDateTime + +abstract class LoadModel[S <: ModelState] + extends ParticipantModel[ + ActivePowerOperatingPoint, + S, + ] + with ParticipantSimpleFlexibility[S] { + + override def zeroPowerOperatingPoint: ActivePowerOperatingPoint = + ActivePowerOperatingPoint.zero + + override def createResults( + state: S, + lastOperatingPoint: Option[ActivePowerOperatingPoint], + currentOperatingPoint: ActivePowerOperatingPoint, + complexPower: ComplexPower, + dateTime: ZonedDateTime, + ): Iterable[SystemParticipantResult] = + Iterable( + new LoadResult( + dateTime, + uuid, + complexPower.p.toMegawatts.asMegaWatt, + complexPower.q.toMegavars.asMegaVar, + ) + ) + + override def createPrimaryDataResult( + data: PrimaryDataWithComplexPower[_], + dateTime: ZonedDateTime, + ): SystemParticipantResult = + new LoadResult( + dateTime, + uuid, + data.p.toMegawatts.asMegaWatt, + data.q.toMegavars.asMegaVar, + ) + + override def getRequiredSecondaryServices: Iterable[ServiceType] = + Iterable.empty + +} + +object LoadModel { + + /** Calculates the scaling factor and scaled rated apparent power according to + * the reference type + * + * @param referenceType + * The type of reference according to which scaling is calculated + * @param input + * The [[LoadInput]] of the model + * @param maxPower + * The maximum power consumption possible for the model + * @param referenceEnergy + * The (annual) reference energy relevant to the load model + * @return + * the reference scaling factor used for calculation of specific power + * consumption values and the scaled rated apparent power + */ + def scaleToReference( + referenceType: LoadReferenceType, + input: LoadInput, + maxPower: Power, + referenceEnergy: Energy, + ): (Double, ApparentPower) = { + val sRated = Kilovoltamperes( + input.getsRated + .to(KILOVOLTAMPERE) + .getValue + .doubleValue + ) + val eConsAnnual = KilowattHours( + input.geteConsAnnual().to(KILOWATTHOUR).getValue.doubleValue + ) + + val referenceScalingFactor = referenceType match { + case LoadReferenceType.ActivePower => + val pRated = sRated.toActivePower(input.getCosPhiRated) + pRated / maxPower + case LoadReferenceType.EnergyConsumption => + eConsAnnual / referenceEnergy + } + + val scaledSRated = referenceType match { + case LoadReferenceType.ActivePower => + sRated + case LoadReferenceType.EnergyConsumption => + val maxApparentPower = Kilovoltamperes( + maxPower.toKilowatts / input.getCosPhiRated + ) + maxApparentPower * referenceScalingFactor + } + + (referenceScalingFactor, scaledSRated) + } + + def apply( + input: LoadInput, + config: LoadRuntimeConfig, + ): LoadModel[_ <: ModelState] = { + LoadModelBehaviour(config.modelBehaviour) match { + case LoadModelBehaviour.FIX => + FixedLoadModel(input, config) + case LoadModelBehaviour.PROFILE => + ProfileLoadModel(input, config) + case LoadModelBehaviour.RANDOM => + RandomLoadModel(input, config) + } + } +} diff --git a/src/main/scala/edu/ie3/simona/model/participant2/load/LoadReferenceType.scala b/src/main/scala/edu/ie3/simona/model/participant2/load/LoadReferenceType.scala new file mode 100644 index 0000000000..f1917160aa --- /dev/null +++ b/src/main/scala/edu/ie3/simona/model/participant2/load/LoadReferenceType.scala @@ -0,0 +1,49 @@ +/* + * © 2020. TU Dortmund University, + * Institute of Energy Systems, Energy Efficiency and Energy Economics, + * Research group Distribution grid planning and operation + */ + +package edu.ie3.simona.model.participant2.load + +import edu.ie3.simona.config.SimonaConfig +import edu.ie3.util.StringUtils + +/** Denoting difference referencing scenarios for scaling load model output + */ +sealed trait LoadReferenceType + +object LoadReferenceType { + + /** Scale the load model behaviour so that the rated power of the load model + * serves as the maximum power consumption + */ + case object ActivePower extends LoadReferenceType + + /** Scale the load model behaviour so that the aggregate annual energy + * consumption corresponds to the energy set by the model input + */ + case object EnergyConsumption extends LoadReferenceType + + /** Build a reference type, that denotes to which type of reference a load + * model behaviour might be scaled. + * + * @param modelConfig + * Configuration of model behaviour + * @return + * A [[LoadReferenceType]] for use in [[LoadModel]] + */ + def apply( + modelConfig: SimonaConfig.LoadRuntimeConfig + ): LoadReferenceType = + StringUtils.cleanString(modelConfig.reference).toLowerCase match { + case "power" => + LoadReferenceType.ActivePower + case "energy" => + LoadReferenceType.EnergyConsumption + case unsupported => + throw new IllegalArgumentException( + s"Unsupported load reference type '$unsupported'." + ) + } +} diff --git a/src/main/scala/edu/ie3/simona/model/participant2/load/ProfileLoadModel.scala b/src/main/scala/edu/ie3/simona/model/participant2/load/ProfileLoadModel.scala new file mode 100644 index 0000000000..f4f310405f --- /dev/null +++ b/src/main/scala/edu/ie3/simona/model/participant2/load/ProfileLoadModel.scala @@ -0,0 +1,95 @@ +/* + * © 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.participant2.load + +import edu.ie3.datamodel.models.input.system.LoadInput +import edu.ie3.datamodel.models.profile.StandardLoadProfile +import edu.ie3.simona.config.SimonaConfig.LoadRuntimeConfig +import edu.ie3.simona.exceptions.CriticalFailureException +import edu.ie3.simona.model.participant.control.QControl +import edu.ie3.simona.model.participant.load.profile.LoadProfileStore +import edu.ie3.simona.model.participant2.ParticipantModel +import edu.ie3.simona.model.participant2.ParticipantModel.{ + ActivePowerOperatingPoint, + DateTimeState, + ParticipantDateTimeState, +} +import edu.ie3.simona.util.TickUtil +import edu.ie3.util.scala.quantities.ApparentPower + +import java.util.UUID + +class ProfileLoadModel( + override val uuid: UUID, + override val sRated: ApparentPower, + override val cosPhiRated: Double, + override val qControl: QControl, + private val loadProfileStore: LoadProfileStore, + private val loadProfile: StandardLoadProfile, + val referenceScalingFactor: Double, +) extends LoadModel[DateTimeState] + with ParticipantDateTimeState[ActivePowerOperatingPoint] { + + override def determineOperatingPoint( + state: DateTimeState + ): (ParticipantModel.ActivePowerOperatingPoint, Option[Long]) = { + val resolution = LoadProfileStore.resolution.getSeconds + + val (modelTick, modelDateTime) = TickUtil.roundToResolution( + state.tick, + state.dateTime, + resolution.toInt, + ) + + val averagePower = loadProfileStore.entry(modelDateTime, loadProfile) + val nextTick = modelTick + resolution + + ( + ActivePowerOperatingPoint(averagePower * referenceScalingFactor), + Some(nextTick), + ) + } + +} + +object ProfileLoadModel { + + def apply(input: LoadInput, config: LoadRuntimeConfig): ProfileLoadModel = { + + val loadProfileStore: LoadProfileStore = LoadProfileStore() + + val loadProfile = input.getLoadProfile match { + case slp: StandardLoadProfile => + slp + case other => + throw new CriticalFailureException( + s"Expected a standard load profile type, got ${other.getClass}" + ) + } + + val referenceType = LoadReferenceType(config) + + val (referenceScalingFactor, scaledSRated) = + LoadModel.scaleToReference( + referenceType, + input, + loadProfileStore.maxPower(loadProfile), + LoadProfileStore.profileReferenceEnergy, + ) + + new ProfileLoadModel( + input.getUuid, + scaledSRated, + input.getCosPhiRated, + QControl.apply(input.getqCharacteristics()), + loadProfileStore, + loadProfile, + referenceScalingFactor, + ) + } + +} diff --git a/src/main/scala/edu/ie3/simona/model/participant2/load/RandomLoadModel.scala b/src/main/scala/edu/ie3/simona/model/participant2/load/RandomLoadModel.scala new file mode 100644 index 0000000000..bdd3d0a37d --- /dev/null +++ b/src/main/scala/edu/ie3/simona/model/participant2/load/RandomLoadModel.scala @@ -0,0 +1,158 @@ +/* + * © 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.participant2.load + +import de.lmu.ifi.dbs.elki.math.statistics.distribution.GeneralizedExtremeValueDistribution +import de.lmu.ifi.dbs.elki.utilities.random.RandomFactory +import edu.ie3.datamodel.models.input.system.LoadInput +import edu.ie3.simona.config.SimonaConfig.LoadRuntimeConfig +import edu.ie3.simona.model.participant.control.QControl +import edu.ie3.simona.model.participant.load.DayType +import edu.ie3.simona.model.participant.load.random.RandomLoadParamStore +import edu.ie3.simona.model.participant2.ParticipantModel +import edu.ie3.simona.model.participant2.ParticipantModel.{ + ActivePowerOperatingPoint, + DateTimeState, + ParticipantDateTimeState, +} +import edu.ie3.simona.util.TickUtil +import edu.ie3.util.TimeUtil +import edu.ie3.util.scala.quantities.ApparentPower +import squants.Power +import squants.energy.{KilowattHours, Kilowatts, Watts} + +import java.time.ZonedDateTime +import java.util.UUID +import scala.collection.mutable +import scala.util.Random + +class RandomLoadModel( + override val uuid: UUID, + override val sRated: ApparentPower, + override val cosPhiRated: Double, + override val qControl: QControl, + val referenceScalingFactor: Double, +) extends LoadModel[DateTimeState] + with ParticipantDateTimeState[ActivePowerOperatingPoint] { + + private val randomLoadParamStore = RandomLoadParamStore() + + private type GevKey = (DayType.Value, Int) + private val gevStorage = + mutable.Map.empty[GevKey, GeneralizedExtremeValueDistribution] + + override def determineOperatingPoint( + state: DateTimeState + ): (ParticipantModel.ActivePowerOperatingPoint, Option[Long]) = { + val resolution = RandomLoadParamStore.resolution.getSeconds + + val (modelTick, modelDateTime) = TickUtil.roundToResolution( + state.tick, + state.dateTime, + resolution.toInt, + ) + + val gev = getGevDistribution(modelDateTime) + + /* Get a next random power (in kW) */ + val randomPower = gev.nextRandom() + if (randomPower < 0) + determineOperatingPoint(state) + else { + val nextTick = modelTick + resolution + ( + ActivePowerOperatingPoint( + Kilowatts(randomPower) * referenceScalingFactor + ), + Some(nextTick), + ) + } + } + + /** Get the needed generalized extreme value distribution from the store or + * instantiate a new one and put it to the store. + * + * @param dateTime + * Questioned date time + * @return + * The needed generalized extreme value distribution + */ + private def getGevDistribution( + dateTime: ZonedDateTime + ): GeneralizedExtremeValueDistribution = { + /* Determine identifying key for a distinct generalized extreme value distribution and look it up. If it is not + * available, yet, instantiate one. */ + val key: GevKey = ( + DayType(dateTime.getDayOfWeek), + TimeUtil.withDefaults.getQuarterHourOfDay(dateTime), + ) + gevStorage.get(key) match { + case Some(foundIt) => foundIt + case None => + /* Instantiate new gev distribution, put it to storage and return it */ + val randomFactory = RandomFactory.get(Random.nextLong()) + val gevParameters = randomLoadParamStore.parameters(dateTime) + val newGev = new GeneralizedExtremeValueDistribution( + gevParameters.my, + gevParameters.sigma, + gevParameters.k, + randomFactory, + ) + gevStorage += (key -> newGev) + newGev + } + } + +} + +object RandomLoadModel { + + /** The annual energy consumption that the random profile is scaled to. + * + * It is said in 'Kays - Agent-based simulation environment for improving the + * planning of distribution grids', that the Generalized Extreme Value + * distribution's parameters are sampled from input data, that is normalized + * to 1,000 kWh annual energy consumption. However, due to inaccuracies in + * random data reproduction, the sampled values will lead to an average + * annual energy consumption of approx. this value. It has been found by + * 1,000 evaluations of the year 2019. + */ + private val profileReferenceEnergy = KilowattHours(716.5416966513656) + + /** This is the 95 % quantile resulting from 10,000 evaluations of the year + * 2019. It is only needed, when the load is meant to be scaled to rated + * active power. + */ + private val maxPower: Power = Watts(159d) + + def apply(input: LoadInput, config: LoadRuntimeConfig): RandomLoadModel = { + + val referenceType = LoadReferenceType(config) + + val (referenceScalingFactor, scaledSRated) = + LoadModel.scaleToReference( + referenceType, + input, + maxPower, + profileReferenceEnergy, + ) + + /** Safety factor to address potential higher sRated values when using + * unrestricted probability functions + */ + val safetyFactor = 1.1 + + new RandomLoadModel( + input.getUuid, + scaledSRated * safetyFactor, + input.getCosPhiRated, + QControl.apply(input.getqCharacteristics()), + referenceScalingFactor, + ) + } + +} diff --git a/src/main/scala/edu/ie3/simona/ontology/messages/services/EvMessage.scala b/src/main/scala/edu/ie3/simona/ontology/messages/services/EvMessage.scala index 1e8ae341a9..fe832cfe07 100644 --- a/src/main/scala/edu/ie3/simona/ontology/messages/services/EvMessage.scala +++ b/src/main/scala/edu/ie3/simona/ontology/messages/services/EvMessage.scala @@ -7,6 +7,7 @@ package edu.ie3.simona.ontology.messages.services import edu.ie3.simona.agent.participant.data.Data.SecondaryData +import edu.ie3.simona.agent.participant2.ParticipantAgent.ParticipantRequest import edu.ie3.simona.model.participant.evcs.EvModelWrapper import edu.ie3.simona.ontology.messages.services.ServiceMessage.{ ProvisionMessage, @@ -55,8 +56,11 @@ object EvMessage { * * @param tick * The latest tick that the data is requested for + * @param replyTo + * The actor to receive the response */ - final case class EvFreeLotsRequest(tick: Long) + final case class EvFreeLotsRequest(override val tick: Long, replyTo: ActorRef) + extends ParticipantRequest /** Requests EV models of departing EVs with given UUIDs * @@ -64,8 +68,14 @@ object EvMessage { * The latest tick that the data is requested for * @param departingEvs * The UUIDs of EVs that are requested + * @param replyTo + * The actor to receive the response */ - final case class DepartingEvsRequest(tick: Long, departingEvs: Seq[UUID]) + final case class DepartingEvsRequest( + override val tick: Long, + departingEvs: Seq[UUID], + replyTo: ActorRef, + ) extends ParticipantRequest /** Holds arrivals for one charging station * diff --git a/src/main/scala/edu/ie3/simona/ontology/messages/services/ServiceMessage.scala b/src/main/scala/edu/ie3/simona/ontology/messages/services/ServiceMessage.scala index d7444454fd..26d338ad61 100644 --- a/src/main/scala/edu/ie3/simona/ontology/messages/services/ServiceMessage.scala +++ b/src/main/scala/edu/ie3/simona/ontology/messages/services/ServiceMessage.scala @@ -60,12 +60,13 @@ object ServiceMessage { override val serviceRef: ActorRef ) extends RegistrationResponseMessage - final case class ScheduleServiceActivation( - tick: Long, - unlockKey: ScheduleKey, - ) } + final case class ScheduleServiceActivation( + tick: Long, + unlockKey: ScheduleKey, + ) + /** Actual provision of data * * @tparam D diff --git a/src/main/scala/edu/ie3/simona/service/ServiceType.scala b/src/main/scala/edu/ie3/simona/service/ServiceType.scala new file mode 100644 index 0000000000..a1796cea89 --- /dev/null +++ b/src/main/scala/edu/ie3/simona/service/ServiceType.scala @@ -0,0 +1,18 @@ +/* + * © 2024. TU Dortmund University, + * Institute of Energy Systems, Energy Efficiency and Energy Economics, + * Research group Distribution grid planning and operation + */ + +package edu.ie3.simona.service + +sealed trait ServiceType + +object ServiceType { + + case object WeatherService extends ServiceType + + case object PriceService extends ServiceType + + case object EvMovementService extends ServiceType +} diff --git a/src/main/scala/edu/ie3/simona/service/SimonaService.scala b/src/main/scala/edu/ie3/simona/service/SimonaService.scala index 310c7f0b32..3b8099f5d1 100644 --- a/src/main/scala/edu/ie3/simona/service/SimonaService.scala +++ b/src/main/scala/edu/ie3/simona/service/SimonaService.scala @@ -6,16 +6,16 @@ package edu.ie3.simona.service -import org.apache.pekko.actor.typed.scaladsl.adapter.ClassicActorRefOps -import org.apache.pekko.actor.{Actor, ActorContext, ActorRef, Stash} import edu.ie3.simona.logging.SimonaActorLogging import edu.ie3.simona.ontology.messages.Activation import edu.ie3.simona.ontology.messages.SchedulerMessage.{ Completion, ScheduleActivation, } -import edu.ie3.simona.ontology.messages.services.ServiceMessage.RegistrationResponseMessage.ScheduleServiceActivation -import edu.ie3.simona.ontology.messages.services.ServiceMessage.ServiceRegistrationMessage +import edu.ie3.simona.ontology.messages.services.ServiceMessage.{ + ScheduleServiceActivation, + ServiceRegistrationMessage, +} import edu.ie3.simona.scheduler.ScheduleLock.ScheduleKey import edu.ie3.simona.service.ServiceStateData.{ InitializeServiceStateData, @@ -23,6 +23,8 @@ import edu.ie3.simona.service.ServiceStateData.{ } import edu.ie3.simona.service.SimonaService.Create import edu.ie3.simona.util.SimonaConstants.INIT_SIM_TICK +import org.apache.pekko.actor.typed.scaladsl.adapter.ClassicActorRefOps +import org.apache.pekko.actor.{Actor, ActorContext, ActorRef, Stash} import scala.util.{Failure, Success, Try} diff --git a/src/main/scala/edu/ie3/simona/service/ev/ExtEvDataService.scala b/src/main/scala/edu/ie3/simona/service/ev/ExtEvDataService.scala index 7f27121552..951132a8e8 100644 --- a/src/main/scala/edu/ie3/simona/service/ev/ExtEvDataService.scala +++ b/src/main/scala/edu/ie3/simona/service/ev/ExtEvDataService.scala @@ -229,7 +229,7 @@ class ExtEvDataService(override val scheduler: ActorRef) serviceStateData: ExtEvStateData ): (ExtEvStateData, Option[Long]) = { serviceStateData.uuidToActorRef.foreach { case (_, evcsActor) => - evcsActor ! EvFreeLotsRequest(tick) + evcsActor ! EvFreeLotsRequest(tick, context.self) } val freeLots = @@ -261,7 +261,7 @@ class ExtEvDataService(override val scheduler: ActorRef) requestedDepartingEvs.flatMap { case (evcs, departingEvs) => serviceStateData.uuidToActorRef.get(evcs) match { case Some(evcsActor) => - evcsActor ! DepartingEvsRequest(tick, departingEvs) + evcsActor ! DepartingEvsRequest(tick, departingEvs, context.self) Some(evcs) diff --git a/src/main/scala/edu/ie3/simona/util/TickUtil.scala b/src/main/scala/edu/ie3/simona/util/TickUtil.scala index 34f16d8e6e..b15d2e4c35 100644 --- a/src/main/scala/edu/ie3/simona/util/TickUtil.scala +++ b/src/main/scala/edu/ie3/simona/util/TickUtil.scala @@ -75,4 +75,36 @@ object TickUtil { (firstFullHourTick to lastAvailableTick by resolution.intValue).toArray } + /** Rounds given tick and datetime with regard to their (implicit) minutes and + * seconds. + * + * @param tick + * The given tick to round + * @param dateTime + * The given date and time to round + * @param resolution + * Resolution in seconds. Should divide 3600 without remainder, i.e. + * {{{3600 % resolution == 0}}} + */ + def roundToResolution( + tick: Long, + dateTime: ZonedDateTime, + resolution: Int, + ): (Long, ZonedDateTime) = { + + val givenHourSeconds = dateTime.getMinute * 60 + dateTime.getSecond + + val adaptedHourSeconds = givenHourSeconds / resolution * resolution + val adaptedMinute = adaptedHourSeconds / 60 + val adaptedSecond = adaptedHourSeconds % 60 + + val adaptedDateTime = + dateTime.withMinute(adaptedMinute).withSecond(adaptedSecond).withNano(0) + + val tickDifference = givenHourSeconds - adaptedHourSeconds + + val adaptedTick = tick - tickDifference + + (adaptedTick, adaptedDateTime) + } } diff --git a/src/main/scala/edu/ie3/util/scala/OperationInterval.scala b/src/main/scala/edu/ie3/util/scala/OperationInterval.scala index 880f441460..fe80870ac0 100644 --- a/src/main/scala/edu/ie3/util/scala/OperationInterval.scala +++ b/src/main/scala/edu/ie3/util/scala/OperationInterval.scala @@ -6,34 +6,15 @@ package edu.ie3.util.scala -import edu.ie3.util.interval.ClosedInterval +import edu.ie3.util.interval.RightOpenInterval /** Wrapper class for an operation interval, as the superclass - * [[ClosedInterval]] only accepts [[java.lang.Long]] as type parameter + * [[RightOpenInterval]] only accepts [[java.lang.Long]] as type parameter * * @param start * Start of operation period (included) * @param end * End of operation period (included) */ -final case class OperationInterval(start: java.lang.Long, end: java.lang.Long) - extends ClosedInterval[java.lang.Long](start, end) { - - /** Get the first tick, in which the operation starts - * - * @return - * Tick, in which operation starts - */ - def getStart: Long = getLower - - /** Get the last tick, in which the operation end - * - * @return - * Tick, in which operation end - */ - def getEnd: Long = getUpper -} - -object OperationInterval { - def apply(start: Long, end: Long) = new OperationInterval(start, end) -} +final case class OperationInterval(start: Long, end: Long) + extends RightOpenInterval[java.lang.Long](start, end) diff --git a/src/test/scala/edu/ie3/simona/agent/participant/EvcsAgentModelCalculationSpec.scala b/src/test/scala/edu/ie3/simona/agent/participant/EvcsAgentModelCalculationSpec.scala index 5af145f60a..4c1321a1cb 100644 --- a/src/test/scala/edu/ie3/simona/agent/participant/EvcsAgentModelCalculationSpec.scala +++ b/src/test/scala/edu/ie3/simona/agent/participant/EvcsAgentModelCalculationSpec.scala @@ -756,7 +756,7 @@ class EvcsAgentModelCalculationSpec /* Send out public evcs request */ evService.send( evcsAgent, - EvFreeLotsRequest(0L), + EvFreeLotsRequest(0L, evService.ref), ) evService.expectMsg( @@ -788,7 +788,7 @@ class EvcsAgentModelCalculationSpec /* Ask for public evcs lot count again with a later tick */ evService.send( evcsAgent, - EvFreeLotsRequest(3600), + EvFreeLotsRequest(3600, evService.ref), ) // this time, only one is still free @@ -933,7 +933,7 @@ class EvcsAgentModelCalculationSpec // departures first evService.send( evcsAgent, - DepartingEvsRequest(3600, Seq(evA.getUuid)), + DepartingEvsRequest(3600, Seq(evA.getUuid), evService.ref), ) evService.expectMsgType[DepartingEvsResponse] match { case DepartingEvsResponse(evcs, evModels) => @@ -968,7 +968,7 @@ class EvcsAgentModelCalculationSpec // departures first evService.send( evcsAgent, - DepartingEvsRequest(7200, Seq(evB.getUuid)), + DepartingEvsRequest(7200, Seq(evB.getUuid), evService.ref), ) evService.expectMsgType[DepartingEvsResponse] match { case DepartingEvsResponse(evcs, evModels) => @@ -1410,7 +1410,7 @@ class EvcsAgentModelCalculationSpec // departure first evService.send( evcsAgent, - DepartingEvsRequest(4500, Seq(ev900.uuid)), + DepartingEvsRequest(4500, Seq(ev900.uuid), evService.ref), ) evService.expectMsgPF() { case DepartingEvsResponse(uuid, evs) => @@ -1907,7 +1907,7 @@ class EvcsAgentModelCalculationSpec // departure first evService.send( evcsAgent, - DepartingEvsRequest(36000, Seq(ev900.uuid)), + DepartingEvsRequest(36000, Seq(ev900.uuid), evService.ref), ) evService.expectMsgPF() { case DepartingEvsResponse(uuid, evs) => @@ -2228,7 +2228,7 @@ class EvcsAgentModelCalculationSpec // TICK 3600: ev900 leaves evService.send( evcsAgent, - DepartingEvsRequest(3600, Seq(ev900.uuid)), + DepartingEvsRequest(3600, Seq(ev900.uuid), evService.ref), ) evService.expectMsgType[DepartingEvsResponse] match { @@ -2282,7 +2282,7 @@ class EvcsAgentModelCalculationSpec evService.send( evcsAgent, - DepartingEvsRequest(4500, Seq(ev1800.uuid)), + DepartingEvsRequest(4500, Seq(ev1800.uuid), evService.ref), ) evService.expectMsgType[DepartingEvsResponse] match { @@ -2331,7 +2331,7 @@ class EvcsAgentModelCalculationSpec evService.send( evcsAgent, - DepartingEvsRequest(5400, Seq(ev2700.uuid)), + DepartingEvsRequest(5400, Seq(ev2700.uuid), evService.ref), ) evService.expectMsgType[DepartingEvsResponse] match { diff --git a/src/test/scala/edu/ie3/simona/agent/participant/data/DataSpec.scala b/src/test/scala/edu/ie3/simona/agent/participant/data/DataSpec.scala new file mode 100644 index 0000000000..5ca7e50fda --- /dev/null +++ b/src/test/scala/edu/ie3/simona/agent/participant/data/DataSpec.scala @@ -0,0 +1,53 @@ +/* + * © 2024. TU Dortmund University, + * Institute of Energy Systems, Energy Efficiency and Energy Economics, + * Research group Distribution grid planning and operation + */ + +package edu.ie3.simona.agent.participant.data + +import edu.ie3.simona.agent.participant.data.Data.PrimaryData._ +import edu.ie3.simona.test.common.UnitSpec +import edu.ie3.util.scala.quantities.DefaultQuantities._ +import edu.ie3.util.scala.quantities.Kilovars +import squants.energy.Kilowatts + +class DataSpec extends UnitSpec { + + "Meta functions for active power should work as expected" in { + ActivePowerMeta.zero shouldBe ActivePower(zeroKW) + + ActivePowerMeta.scale(ActivePower(Kilowatts(5)), 2.5) shouldBe ActivePower( + Kilowatts(12.5) + ) + } + + "Meta functions for complex power should work as expected" in { + ComplexPowerMeta.zero shouldBe ComplexPower(zeroKW, zeroKVAr) + + ComplexPowerMeta.scale( + ComplexPower(Kilowatts(5), Kilovars(2)), + 1.5, + ) shouldBe ComplexPower(Kilowatts(7.5), Kilovars(3)) + } + + "Meta functions for active power and heat should work as expected" in { + ActivePowerAndHeatMeta.zero shouldBe ActivePowerAndHeat(zeroKW, zeroKW) + + ActivePowerAndHeatMeta.scale( + ActivePowerAndHeat(Kilowatts(5), Kilowatts(2)), + 2, + ) shouldBe ActivePowerAndHeat(Kilowatts(10), Kilowatts(4)) + } + + "Meta functions for complex power and heat should work as expected" in { + ComplexPowerAndHeatMeta.zero shouldBe + ComplexPowerAndHeat(zeroKW, zeroKVAr, zeroKW) + + ComplexPowerAndHeatMeta.scale( + ComplexPowerAndHeat(Kilowatts(5), Kilovars(1), Kilowatts(2)), + 3, + ) shouldBe ComplexPowerAndHeat(Kilowatts(15), Kilovars(3), Kilowatts(6)) + } + +} diff --git a/src/test/scala/edu/ie3/simona/agent/participant2/MockParticipantModel.scala b/src/test/scala/edu/ie3/simona/agent/participant2/MockParticipantModel.scala new file mode 100644 index 0000000000..8d0cf37240 --- /dev/null +++ b/src/test/scala/edu/ie3/simona/agent/participant2/MockParticipantModel.scala @@ -0,0 +1,176 @@ +/* + * © 2024. TU Dortmund University, + * Institute of Energy Systems, Energy Efficiency and Energy Economics, + * Research group Distribution grid planning and operation + */ + +package edu.ie3.simona.agent.participant2 + +import edu.ie3.datamodel.models.result.system.SystemParticipantResult +import edu.ie3.simona.agent.participant.data.Data.{PrimaryData, SecondaryData} +import edu.ie3.simona.agent.participant2.MockParticipantModel._ +import edu.ie3.simona.agent.participant2.ParticipantAgent.ParticipantRequest +import edu.ie3.simona.model.participant.control.QControl +import edu.ie3.simona.model.participant.control.QControl.CosPhiFixed +import edu.ie3.simona.model.participant2.ParticipantModel +import edu.ie3.simona.model.participant2.ParticipantModel._ +import edu.ie3.simona.ontology.messages.flex.FlexibilityMessage +import edu.ie3.simona.ontology.messages.flex.MinMaxFlexibilityMessage.ProvideMinMaxFlexOptions +import edu.ie3.simona.service.ServiceType +import edu.ie3.util.quantities.QuantityUtils.RichQuantityDouble +import edu.ie3.util.scala.quantities.DefaultQuantities._ +import edu.ie3.util.scala.quantities.{ApparentPower, Kilovoltamperes} +import org.apache.pekko.actor.typed.ActorRef +import org.apache.pekko.actor.typed.scaladsl.ActorContext +import squants.energy.{Kilowatts, Power} +import tech.units.indriya.ComparableQuantity + +import java.time.ZonedDateTime +import java.util.UUID +import javax.measure.quantity.{Power => QuantPower} + +class MockParticipantModel( + override val uuid: UUID = UUID.fromString("0-0-0-0-1"), + override val sRated: ApparentPower = Kilovoltamperes(10), + override val cosPhiRated: Double = 0.9, + override val qControl: QControl = CosPhiFixed(0.9), + mockActivationTicks: Map[Long, Long] = Map.empty, + mockChangeAtNext: Set[Long] = Set.empty, +) extends ParticipantModel[ + ActivePowerOperatingPoint, + MockState, + ] { + + override val initialState: ModelInput => MockState = { input => + val maybeAdditionalPower = input.receivedData.collectFirst { + case data: MockSecondaryData => + data.additionalP + } + + MockState( + maybeAdditionalPower, + input.currentTick, + ) + } + + override def determineState( + lastState: MockState, + operatingPoint: ActivePowerOperatingPoint, + input: ModelInput, + ): MockState = initialState(input) + + override def determineOperatingPoint( + state: MockState + ): (ActivePowerOperatingPoint, Option[Long]) = { + ( + ActivePowerOperatingPoint( + Kilowatts(6) + state.additionalP.getOrElse(zeroKW) + ), + mockActivationTicks.get(state.tick), + ) + } + + override def zeroPowerOperatingPoint: ActivePowerOperatingPoint = + ActivePowerOperatingPoint.zero + + override def createResults( + state: MockState, + lastOperatingPoint: Option[ActivePowerOperatingPoint], + currentOperatingPoint: ActivePowerOperatingPoint, + complexPower: PrimaryData.ComplexPower, + dateTime: ZonedDateTime, + ): Iterable[SystemParticipantResult] = + Iterable( + MockResult( + dateTime, + uuid, + complexPower.p.toMegawatts.asMegaWatt, + complexPower.q.toMegavars.asMegaVar, + ) + ) + + override def createPrimaryDataResult( + data: PrimaryData.PrimaryDataWithComplexPower[_], + dateTime: ZonedDateTime, + ): SystemParticipantResult = { + MockResult( + dateTime, + uuid, + data.p.toMegawatts.asMegaWatt, + data.q.toMegavars.asMegaVar, + ) + } + + override def getRequiredSecondaryServices: Iterable[ServiceType] = + throw new NotImplementedError() // Not tested + + override def calcFlexOptions( + state: MockState + ): FlexibilityMessage.ProvideFlexOptions = { + val additionalP = state.additionalP.getOrElse(zeroKW) + ProvideMinMaxFlexOptions( + uuid, + Kilowatts(1) + additionalP, + Kilowatts(-1) + additionalP, + Kilowatts(3) + additionalP, + ) + } + + override def handlePowerControl( + state: MockState, + flexOptions: FlexibilityMessage.ProvideFlexOptions, + setPower: Power, + ): (ActivePowerOperatingPoint, OperationChangeIndicator) = + ( + ActivePowerOperatingPoint(setPower), + OperationChangeIndicator( + changesAtNextActivation = mockChangeAtNext.contains(state.tick), + changesAtTick = mockActivationTicks.get(state.tick), + ), + ) + + override def handleRequest( + state: MockState, + ctx: ActorContext[ParticipantAgent.Request], + msg: ParticipantRequest, + ): MockState = { + msg match { + case MockRequestMessage(_, replyTo) => + replyTo ! MockResponseMessage + } + + state + } + +} + +object MockParticipantModel { + + /** Simple [[ModelState]] to test its usage in operation point calculations + * + * @param additionalP + * Power value that is added to the power or flex options power for testing + * purposes + */ + final case class MockState( + additionalP: Option[Power], + override val tick: Long, + ) extends ModelState + + final case class MockResult( + time: ZonedDateTime, + inputModel: UUID, + p: ComparableQuantity[QuantPower], + q: ComparableQuantity[QuantPower], + ) extends SystemParticipantResult(time, inputModel, p, q) + + final case class MockRequestMessage( + override val tick: Long, + replyTo: ActorRef[MockResponseMessage.type], + ) extends ParticipantRequest + + case object MockResponseMessage + + final case class MockSecondaryData(additionalP: Power) extends SecondaryData + +} diff --git a/src/test/scala/edu/ie3/simona/agent/participant2/ParticipantAgentMockFactory.scala b/src/test/scala/edu/ie3/simona/agent/participant2/ParticipantAgentMockFactory.scala new file mode 100644 index 0000000000..3f56753796 --- /dev/null +++ b/src/test/scala/edu/ie3/simona/agent/participant2/ParticipantAgentMockFactory.scala @@ -0,0 +1,63 @@ +/* + * © 2024. TU Dortmund University, + * Institute of Energy Systems, Energy Efficiency and Energy Economics, + * Research group Distribution grid planning and operation + */ + +package edu.ie3.simona.agent.participant2 + +import edu.ie3.simona.agent.participant2.ParticipantAgent.{ + Flex, + FlexControlledData, + ParticipantActivation, + Request, + SchedulerData, +} +import edu.ie3.simona.event.ResultEvent +import edu.ie3.simona.model.participant2.ParticipantModelShell +import edu.ie3.simona.ontology.messages.{Activation, SchedulerMessage} +import edu.ie3.simona.ontology.messages.flex.FlexibilityMessage.{ + FlexRequest, + FlexResponse, +} +import org.apache.pekko.actor.typed.{ActorRef, Behavior} +import org.apache.pekko.actor.typed.scaladsl.Behaviors + +object ParticipantAgentMockFactory { + + /** Needed because activation adapter needs to be created and communicated + */ + def create( + modelShell: ParticipantModelShell[_, _], + inputHandler: ParticipantInputHandler, + gridAdapter: ParticipantGridAdapter, + resultListener: Iterable[ActorRef[ResultEvent]], + parent: Either[ + (ActorRef[SchedulerMessage], ActorRef[ActorRef[Activation]]), + (ActorRef[FlexResponse], ActorRef[ActorRef[FlexRequest]]), + ], + ): Behavior[Request] = Behaviors.setup { ctx => + val parentData = parent + .map { case (em, adapterReply) => + val flexAdapter = ctx.messageAdapter[FlexRequest](Flex) + adapterReply ! flexAdapter + FlexControlledData(em, flexAdapter) + } + .left + .map { case (scheduler, adapterReply) => + val activationAdapter = ctx.messageAdapter[Activation] { msg => + ParticipantActivation(msg.tick) + } + adapterReply ! activationAdapter + SchedulerData(scheduler, activationAdapter) + } + + ParticipantAgent( + modelShell, + inputHandler, + gridAdapter, + resultListener, + parentData, + ) + } +} diff --git a/src/test/scala/edu/ie3/simona/agent/participant2/ParticipantAgentSpec.scala b/src/test/scala/edu/ie3/simona/agent/participant2/ParticipantAgentSpec.scala new file mode 100644 index 0000000000..62a2124f6b --- /dev/null +++ b/src/test/scala/edu/ie3/simona/agent/participant2/ParticipantAgentSpec.scala @@ -0,0 +1,1631 @@ +/* + * © 2024. TU Dortmund University, + * Institute of Energy Systems, Energy Efficiency and Energy Economics, + * Research group Distribution grid planning and operation + */ + +package edu.ie3.simona.agent.participant2 + +import edu.ie3.simona.agent.grid.GridAgent +import edu.ie3.simona.agent.grid.GridAgentMessages.{ + AssetPowerChangedMessage, + AssetPowerUnchangedMessage, +} +import edu.ie3.simona.agent.participant.data.Data.PrimaryData.{ + ActivePower, + ActivePowerMeta, +} +import edu.ie3.simona.agent.participant2.MockParticipantModel.{ + MockRequestMessage, + MockResponseMessage, + MockResult, + MockSecondaryData, +} +import edu.ie3.simona.agent.participant2.ParticipantAgent.{ + FinishParticipantSimulation, + ProvideData, + RequestAssetPowerMessage, +} +import edu.ie3.simona.event.ResultEvent +import edu.ie3.simona.event.ResultEvent.ParticipantResultEvent +import edu.ie3.simona.model.participant2.{ + ParticipantModelInit, + ParticipantModelShell, +} +import edu.ie3.simona.ontology.messages.SchedulerMessage.Completion +import edu.ie3.simona.ontology.messages.flex.FlexibilityMessage._ +import edu.ie3.simona.ontology.messages.flex.MinMaxFlexibilityMessage.ProvideMinMaxFlexOptions +import edu.ie3.simona.ontology.messages.{Activation, SchedulerMessage} +import edu.ie3.simona.test.common.UnitSpec +import edu.ie3.simona.util.TickUtil.TickLong +import edu.ie3.util.TimeUtil +import edu.ie3.util.quantities.QuantityUtils.RichQuantityDouble +import edu.ie3.util.scala.OperationInterval +import edu.ie3.util.scala.quantities.{Kilovars, ReactivePower} +import org.apache.pekko.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit +import org.apache.pekko.actor.typed.ActorRef +import org.apache.pekko.actor.typed.scaladsl.adapter._ +import squants.energy.Kilowatts +import squants.{Each, Power} + +import java.time.ZonedDateTime + +/** Test for [[ParticipantAgent]] and [[ParticipantModelShell]] using a mock + * participant [[MockParticipantModel]] + */ +class ParticipantAgentSpec extends ScalaTestWithActorTestKit with UnitSpec { + + private implicit val simulationStartDate: ZonedDateTime = + TimeUtil.withDefaults.toZonedDateTime("2020-01-01T00:00:00Z") + + private implicit val activePowerTolerance: Power = Kilowatts(1e-10) + private implicit val reactivePowerTolerance: ReactivePower = Kilovars(1e-10) + + "A ParticipantAgent that is not controlled by EM" when { + + "not depending on external services" should { + + "calculate operating point and results correctly with no additional ticks" in { + + val scheduler = createTestProbe[SchedulerMessage]() + val gridAgent = createTestProbe[GridAgent.Request]() + val resultListener = createTestProbe[ResultEvent]() + val responseReceiver = createTestProbe[MockResponseMessage.type]() + + // receiving the activation adapter + val receiveAdapter = createTestProbe[ActorRef[Activation]]() + + // no additional activation ticks + val model = new MockParticipantModel() + val operationInterval = OperationInterval(8 * 3600, 20 * 3600) + + val participantAgent = spawn( + ParticipantAgentMockFactory.create( + ParticipantModelShell( + model, + operationInterval, + simulationStartDate, + ), + ParticipantInputHandler( + Map.empty + ), + ParticipantGridAdapter( + gridAgent.ref, + 12 * 3600, + ), + Iterable(resultListener.ref), + Left(scheduler.ref, receiveAdapter.ref), + ) + ) + val activationRef = + receiveAdapter.expectMessageType[ActorRef[Activation]] + + // TICK 0: Outside of operation interval + + participantAgent ! MockRequestMessage(0, responseReceiver.ref) + responseReceiver.expectMessage(MockResponseMessage) + + activationRef ! Activation(0) + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(result: MockResult) => + result.getInputModel shouldBe model.uuid + result.getTime shouldBe simulationStartDate + result.getP should equalWithTolerance(0.0.asMegaWatt) + result.getQ should equalWithTolerance(0.0.asMegaVar) + } + + scheduler.expectMessage( + Completion(activationRef, Some(operationInterval.start)) + ) + + // TICK 8 * 3600: Start of operation interval + + participantAgent ! MockRequestMessage(0, responseReceiver.ref) + responseReceiver.expectMessage(MockResponseMessage) + + activationRef ! Activation(operationInterval.start) + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(result: MockResult) => + result.getInputModel shouldBe model.uuid + result.getTime shouldBe operationInterval.start.toDateTime + result.getP should equalWithTolerance(0.006.asMegaWatt) + result.getQ should equalWithTolerance(0.00290593262.asMegaVar) + } + + scheduler.expectMessage( + Completion(activationRef, Some(operationInterval.end)) + ) + + // TICK 12 * 3600: GridAgent requests power + + participantAgent ! MockRequestMessage(0, responseReceiver.ref) + responseReceiver.expectMessage(MockResponseMessage) + + // first request + participantAgent ! RequestAssetPowerMessage(12 * 3600, Each(1), Each(0)) + + // 8 hours of 0 kW, 4 hours of 6 kW + gridAgent.expectMessageType[AssetPowerChangedMessage] match { + case AssetPowerChangedMessage(p, q) => + p should approximate(Kilowatts(2)) + q should approximate(Kilovars(0.968644209676)) + } + + // second request with same voltage + participantAgent ! RequestAssetPowerMessage(12 * 3600, Each(1), Each(0)) + + gridAgent.expectMessageType[AssetPowerUnchangedMessage] match { + case AssetPowerUnchangedMessage(p, q) => + p should approximate(Kilowatts(2)) + q should approximate(Kilovars(0.968644209676)) + } + + // third request with different voltage + participantAgent ! RequestAssetPowerMessage( + 12 * 3600, + Each(0.98), + Each(0), + ) + + gridAgent.expectMessageType[AssetPowerChangedMessage] match { + case AssetPowerChangedMessage(p, q) => + p should approximate(Kilowatts(2)) + // not voltage dependent + q should approximate(Kilovars(0.968644209676)) + } + + participantAgent ! FinishParticipantSimulation(12 * 3600, 24 * 3600) + + // TICK 20 * 3600: Outside of operation interval (last tick) + + participantAgent ! MockRequestMessage(0, responseReceiver.ref) + responseReceiver.expectMessage(MockResponseMessage) + + activationRef ! Activation(operationInterval.end) + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(result: MockResult) => + result.getInputModel shouldBe model.uuid + result.getTime shouldBe operationInterval.end.toDateTime + result.getP should equalWithTolerance(0.0.asMegaWatt) + result.getQ should equalWithTolerance(0.0.asMegaVar) + } + + scheduler.expectMessage(Completion(activationRef)) + + // TICK 24 * 3600: GridAgent requests power + + participantAgent ! MockRequestMessage(0, responseReceiver.ref) + responseReceiver.expectMessage(MockResponseMessage) + + participantAgent ! RequestAssetPowerMessage(24 * 3600, Each(1), Each(0)) + + gridAgent.expectMessageType[AssetPowerChangedMessage] match { + case AssetPowerChangedMessage(p, q) => + p should approximate(Kilowatts(4)) + q should approximate(Kilovars(1.93728841935)) + } + + participantAgent ! FinishParticipantSimulation(24 * 3600, 36 * 3600) + + } + + "calculate operating point and results correctly with additional ticks" in { + + val scheduler = createTestProbe[SchedulerMessage]() + val gridAgent = createTestProbe[GridAgent.Request]() + val resultListener = createTestProbe[ResultEvent]() + + // receiving the activation adapter + val receiveAdapter = createTestProbe[ActorRef[Activation]]() + + // with additional activation ticks + val model = new MockParticipantModel(mockActivationTicks = + Map( + 0 * 3600L -> 4 * 3600L, // still before operation, is ignored + 8 * 3600L -> 12 * 3600L, // middle of operation + 12 * 3600L -> 22 * 3600L, // after operation, is ignored + ) + ) + val operationInterval = OperationInterval(8 * 3600, 20 * 3600) + + val participantAgent = spawn( + ParticipantAgentMockFactory.create( + ParticipantModelShell( + model, + operationInterval, + simulationStartDate, + ), + ParticipantInputHandler( + Map.empty + ), + ParticipantGridAdapter( + gridAgent.ref, + 12 * 3600, + ), + Iterable(resultListener.ref), + Left(scheduler.ref, receiveAdapter.ref), + ) + ) + val activationRef = + receiveAdapter.expectMessageType[ActorRef[Activation]] + + // TICK 0: Outside of operation interval + + activationRef ! Activation(0) + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(result: MockResult) => + result.getInputModel shouldBe model.uuid + result.getTime shouldBe simulationStartDate + result.getP should equalWithTolerance(0.0.asMegaWatt) + result.getQ should equalWithTolerance(0.0.asMegaVar) + } + + scheduler.expectMessage( + Completion(activationRef, Some(operationInterval.start)) + ) + + // TICK 8 * 3600: Start of operation interval + + activationRef ! Activation(operationInterval.start) + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(result: MockResult) => + result.getInputModel shouldBe model.uuid + result.getTime shouldBe operationInterval.start.toDateTime + result.getP should equalWithTolerance(0.006.asMegaWatt) + result.getQ should equalWithTolerance(0.00290593262.asMegaVar) + } + + scheduler.expectMessage( + Completion(activationRef, Some(12 * 3600)) + ) + + // TICK 12 * 3600: Inside of operation interval and GridAgent requests power + + activationRef ! Activation(12 * 3600) + + participantAgent ! RequestAssetPowerMessage(12 * 3600, Each(1), Each(0)) + + // 8 hours of 0 kW, 4 hours of 6 kW + gridAgent.expectMessageType[AssetPowerChangedMessage] match { + case AssetPowerChangedMessage(p, q) => + p should approximate(Kilowatts(2)) + q should approximate(Kilovars(0.968644209676)) + } + + resultListener.expectNoMessage() + scheduler.expectNoMessage() + + participantAgent ! FinishParticipantSimulation(12 * 3600, 24 * 3600) + + // calculation should start now + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(result: MockResult) => + result.getInputModel shouldBe model.uuid + result.getTime shouldBe (12 * 3600).toDateTime + result.getP should equalWithTolerance(0.006.asMegaWatt) + result.getQ should equalWithTolerance(0.00290593262.asMegaVar) + } + + scheduler.expectMessage( + Completion(activationRef, Some(operationInterval.end)) + ) + + // TICK 20 * 3600: Outside of operation interval (last tick) + + activationRef ! Activation(operationInterval.end) + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(result: MockResult) => + result.getInputModel shouldBe model.uuid + result.getTime shouldBe operationInterval.end.toDateTime + result.getP should equalWithTolerance(0.0.asMegaWatt) + result.getQ should equalWithTolerance(0.0.asMegaVar) + } + + scheduler.expectMessage(Completion(activationRef)) + + // TICK 24 * 3600: GridAgent requests power + + participantAgent ! RequestAssetPowerMessage(24 * 3600, Each(1), Each(0)) + + gridAgent.expectMessageType[AssetPowerChangedMessage] match { + case AssetPowerChangedMessage(p, q) => + p should approximate(Kilowatts(4)) + q should approximate(Kilovars(1.93728841935)) + } + + participantAgent ! FinishParticipantSimulation(24 * 3600, 36 * 3600) + + } + + } + + "depending on secondary data" should { + + "calculate operating point and results correctly with additional ticks" in { + + val scheduler = createTestProbe[SchedulerMessage]() + val gridAgent = createTestProbe[GridAgent.Request]() + val resultListener = createTestProbe[ResultEvent]() + val service = createTestProbe() + + // receiving the activation adapter + val receiveAdapter = createTestProbe[ActorRef[Activation]]() + + // with additional activation ticks + val model = new MockParticipantModel(mockActivationTicks = + Map( + 0 * 3600L -> 4 * 3600L, // still before operation, is ignored + 8 * 3600L -> 12 * 3600L, // middle of operation + 12 * 3600L -> 22 * 3600L, // after operation, is ignored + ) + ) + val operationInterval = OperationInterval(8 * 3600, 20 * 3600) + + val participantAgent = spawn( + ParticipantAgentMockFactory.create( + ParticipantModelShell( + model, + operationInterval, + simulationStartDate, + ), + ParticipantInputHandler( + Map(service.ref.toClassic -> 0) + ), + ParticipantGridAdapter( + gridAgent.ref, + 12 * 3600, + ), + Iterable(resultListener.ref), + Left(scheduler.ref, receiveAdapter.ref), + ) + ) + val activationRef = + receiveAdapter.expectMessageType[ActorRef[Activation]] + + // TICK 0: Outside of operation interval + + activationRef ! Activation(0) + + // nothing should happen, still waiting for secondary data... + resultListener.expectNoMessage() + scheduler.expectNoMessage() + + participantAgent ! ProvideData( + 0, + service.ref.toClassic, + MockSecondaryData(Kilowatts(1)), + Some(6 * 3600), + ) + + // outside of operation interval, 0 MW + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(result: MockResult) => + result.getInputModel shouldBe model.uuid + result.getTime shouldBe simulationStartDate + result.getP should equalWithTolerance(0.0.asMegaWatt) + result.getQ should equalWithTolerance(0.0.asMegaVar) + } + + // next model tick and next data tick are ignored, + // because we are outside of operation interval + scheduler.expectMessage( + Completion(activationRef, Some(operationInterval.start)) + ) + + // TICK 6 * 3600: Outside of operation interval, only data expected, no activation + + participantAgent ! ProvideData( + 6 * 3600, + service.ref.toClassic, + MockSecondaryData(Kilowatts(3)), + Some(12 * 3600), + ) + + resultListener.expectNoMessage() + scheduler.expectNoMessage() + + // TICK 8 * 3600: Start of operation interval + + activationRef ! Activation(operationInterval.start) + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(result: MockResult) => + result.getInputModel shouldBe model.uuid + result.getTime shouldBe operationInterval.start.toDateTime + result.getP should equalWithTolerance(0.009.asMegaWatt) + result.getQ should equalWithTolerance(0.00435889893.asMegaVar) + } + + // next model tick and next data tick are both hour 12 + scheduler.expectMessage( + Completion(activationRef, Some(12 * 3600)) + ) + + // TICK 12 * 3600: Inside of operation interval, GridAgent requests power + + activationRef ! Activation(12 * 3600) + + participantAgent ! RequestAssetPowerMessage(12 * 3600, Each(1), Each(0)) + + // 8 hours of 0 kW, 4 hours of 6+3=9 kW + gridAgent.expectMessageType[AssetPowerChangedMessage] match { + case AssetPowerChangedMessage(p, q) => + p should approximate(Kilowatts(3)) + q should approximate(Kilovars(1.452966314514)) + } + + participantAgent ! FinishParticipantSimulation(12 * 3600, 24 * 3600) + + // nothing should happen, still waiting for secondary data... + resultListener.expectNoMessage() + scheduler.expectNoMessage() + + participantAgent ! ProvideData( + 12 * 3600, + service.ref.toClassic, + MockSecondaryData(Kilowatts(6)), + Some(18 * 3600), + ) + + // calculation should start now + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(result: MockResult) => + result.getInputModel shouldBe model.uuid + result.getTime shouldBe (12 * 3600).toDateTime + result.getP should equalWithTolerance(0.012.asMegaWatt) + result.getQ should equalWithTolerance(0.005811865258.asMegaVar) + } + + // new data is expected at 18 hours + scheduler.expectMessage( + Completion(activationRef, Some(18 * 3600)) + ) + + // TICK 18 * 3600: Inside of operation interval because of expected secondary data + + activationRef ! Activation(18 * 3600) + + // nothing should happen, still waiting for secondary data... + resultListener.expectNoMessage() + scheduler.expectNoMessage() + + participantAgent ! ProvideData( + 18 * 3600, + service.ref.toClassic, + MockSecondaryData(Kilowatts(9)), + Some(24 * 3600), + ) + + // calculation should start now + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(result: MockResult) => + result.getInputModel shouldBe model.uuid + result.getTime shouldBe (18 * 3600).toDateTime + result.getP should equalWithTolerance(0.015.asMegaWatt) + result.getQ should equalWithTolerance(0.00726483157257.asMegaVar) + } + + scheduler.expectMessage( + Completion(activationRef, Some(operationInterval.end)) + ) + + // TICK 20 * 3600: Outside of operation interval (last tick) + + activationRef ! Activation(operationInterval.end) + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(result: MockResult) => + result.getInputModel shouldBe model.uuid + result.getTime shouldBe operationInterval.end.toDateTime + result.getP should equalWithTolerance(0.0.asMegaWatt) + result.getQ should equalWithTolerance(0.0.asMegaVar) + } + + // Since we left the operation interval, there are no more ticks to activate + scheduler.expectMessage(Completion(activationRef)) + + // TICK 24 * 3600: GridAgent requests power + + participantAgent ! RequestAssetPowerMessage(24 * 3600, Each(1), Each(0)) + + // 6 hours of 6+6=12 kW, 2 hours of 6+9=15 kW, 4 hours of 0 kW + gridAgent.expectMessageType[AssetPowerChangedMessage] match { + case AssetPowerChangedMessage(p, q) => + p should approximate(Kilowatts(8.5)) + q should approximate(Kilovars(4.116737891123)) + } + + participantAgent ! FinishParticipantSimulation(24 * 3600, 36 * 3600) + + resultListener.expectNoMessage() + scheduler.expectNoMessage() + + } + + } + + "depending on primary data" should { + + "calculate operating point and results correctly" in { + + val scheduler = createTestProbe[SchedulerMessage]() + val gridAgent = createTestProbe[GridAgent.Request]() + val resultListener = createTestProbe[ResultEvent]() + val service = createTestProbe() + + // receiving the activation adapter + val receiveAdapter = createTestProbe[ActorRef[Activation]]() + + // no additional activation ticks + val physicalModel = new MockParticipantModel() + + val model = ParticipantModelInit.createPrimaryModel( + physicalModel, + ActivePowerMeta, + ) + val operationInterval = OperationInterval(8 * 3600, 20 * 3600) + + val participantAgent = spawn( + ParticipantAgentMockFactory.create( + ParticipantModelShell( + model, + operationInterval, + simulationStartDate, + ), + ParticipantInputHandler( + Map(service.ref.toClassic -> 0) + ), + ParticipantGridAdapter( + gridAgent.ref, + 12 * 3600, + ), + Iterable(resultListener.ref), + Left(scheduler.ref, receiveAdapter.ref), + ) + ) + val activationRef = + receiveAdapter.expectMessageType[ActorRef[Activation]] + + // TICK 0: Outside of operation interval + + activationRef ! Activation(0) + + // nothing should happen, still waiting for primary data... + resultListener.expectNoMessage() + scheduler.expectNoMessage() + + participantAgent ! ProvideData( + 0, + service.ref.toClassic, + ActivePower(Kilowatts(1)), + Some(6 * 3600), + ) + + // outside of operation interval, 0 MW + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(result: MockResult) => + result.getInputModel shouldBe model.uuid + result.getTime shouldBe simulationStartDate + result.getP should equalWithTolerance(0.0.asMegaWatt) + result.getQ should equalWithTolerance(0.0.asMegaVar) + } + + // next model tick and next data tick are ignored, + // because we are outside of operation interval + scheduler.expectMessage( + Completion(activationRef, Some(operationInterval.start)) + ) + + // TICK 6 * 3600: Outside of operation interval, only data expected, no activation + + participantAgent ! ProvideData( + 6 * 3600, + service.ref.toClassic, + ActivePower(Kilowatts(3)), + Some(12 * 3600), + ) + + resultListener.expectNoMessage() + scheduler.expectNoMessage() + + // TICK 8 * 3600: Start of operation interval + + activationRef ! Activation(operationInterval.start) + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(result: MockResult) => + result.getInputModel shouldBe model.uuid + result.getTime shouldBe operationInterval.start.toDateTime + result.getP should equalWithTolerance(0.003.asMegaWatt) + result.getQ should equalWithTolerance(0.00145296631.asMegaVar) + } + + // next data tick is hour 12 + scheduler.expectMessage( + Completion(activationRef, Some(12 * 3600)) + ) + + // TICK 12 * 3600: Inside of operation interval, GridAgent requests power + + activationRef ! Activation(12 * 3600) + + participantAgent ! RequestAssetPowerMessage(12 * 3600, Each(1), Each(0)) + + // 8 hours of 0 kW, 4 hours of 3 kW + gridAgent.expectMessageType[AssetPowerChangedMessage] match { + case AssetPowerChangedMessage(p, q) => + p should approximate(Kilowatts(1)) + q should approximate(Kilovars(0.48432210484)) + } + + participantAgent ! FinishParticipantSimulation(12 * 3600, 24 * 3600) + + // nothing should happen, still waiting for primary data... + resultListener.expectNoMessage() + scheduler.expectNoMessage() + + participantAgent ! ProvideData( + 12 * 3600, + service.ref.toClassic, + ActivePower(Kilowatts(6)), + Some(18 * 3600), + ) + + // calculation should start now + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(result: MockResult) => + result.getInputModel shouldBe model.uuid + result.getTime shouldBe (12 * 3600).toDateTime + result.getP should equalWithTolerance(0.006.asMegaWatt) + result.getQ should equalWithTolerance(0.00290593263.asMegaVar) + } + + // new data is expected at 18 hours + scheduler.expectMessage( + Completion(activationRef, Some(18 * 3600)) + ) + + // TICK 18 * 3600: Inside of operation interval because of expected primary data + + activationRef ! Activation(18 * 3600) + + // nothing should happen, still waiting for primary data... + resultListener.expectNoMessage() + scheduler.expectNoMessage() + + participantAgent ! ProvideData( + 18 * 3600, + service.ref.toClassic, + ActivePower(Kilowatts(3)), + Some(24 * 3600), + ) + + // calculation should start now + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(result: MockResult) => + result.getInputModel shouldBe model.uuid + result.getTime shouldBe (18 * 3600).toDateTime + result.getP should equalWithTolerance(0.003.asMegaWatt) + result.getQ should equalWithTolerance(0.00145296631.asMegaVar) + } + + scheduler.expectMessage( + Completion(activationRef, Some(operationInterval.end)) + ) + + // TICK 20 * 3600: Outside of operation interval (last tick) + + activationRef ! Activation(operationInterval.end) + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(result: MockResult) => + result.getInputModel shouldBe model.uuid + result.getTime shouldBe operationInterval.end.toDateTime + result.getP should equalWithTolerance(0.0.asMegaWatt) + result.getQ should equalWithTolerance(0.0.asMegaVar) + } + + // Since we left the operation interval, there are no more ticks to activate + scheduler.expectMessage(Completion(activationRef)) + + // TICK 24 * 3600: GridAgent requests power + + participantAgent ! RequestAssetPowerMessage(24 * 3600, Each(1), Each(0)) + + // 6 hours of 6 kW, 2 hours of 3 kW, 4 hours of 0 kW + gridAgent.expectMessageType[AssetPowerChangedMessage] match { + case AssetPowerChangedMessage(p, q) => + p should approximate(Kilowatts(3.5)) + q should approximate(Kilovars(1.695127366932)) + } + + participantAgent ! FinishParticipantSimulation(24 * 3600, 36 * 3600) + + resultListener.expectNoMessage() + scheduler.expectNoMessage() + + } + + } + + } + + "A ParticipantAgent that is controlled by EM" when { + + "not depending on external services" should { + + "calculate operating point and results correctly with no additional ticks" in { + + val em = createTestProbe[FlexResponse]() + val gridAgent = createTestProbe[GridAgent.Request]() + val resultListener = createTestProbe[ResultEvent]() + + // receiving the activation adapter + val receiveAdapter = createTestProbe[ActorRef[FlexRequest]]() + + // no additional activation ticks + val model = new MockParticipantModel() + val operationInterval = OperationInterval(8 * 3600, 20 * 3600) + + val participantAgent = spawn( + ParticipantAgentMockFactory.create( + ParticipantModelShell( + model, + operationInterval, + simulationStartDate, + ), + ParticipantInputHandler( + Map.empty + ), + ParticipantGridAdapter( + gridAgent.ref, + 12 * 3600, + ), + Iterable(resultListener.ref), + Right(em.ref, receiveAdapter.ref), + ) + ) + val flexRef = receiveAdapter.expectMessageType[ActorRef[FlexRequest]] + + // TICK 0: Outside of operation interval + + flexRef ! FlexActivation(0) + + em.expectMessageType[ProvideMinMaxFlexOptions] match { + case ProvideMinMaxFlexOptions(modelUuid, ref, min, max) => + modelUuid shouldBe model.uuid + ref should approximate(Kilowatts(0)) + min should approximate(Kilowatts(0)) + max should approximate(Kilowatts(0)) + } + + flexRef ! IssueNoControl(0) + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(result: MockResult) => + result.getInputModel shouldBe model.uuid + result.getTime shouldBe simulationStartDate + result.getP should equalWithTolerance(0.0.asMegaWatt) + result.getQ should equalWithTolerance(0.0.asMegaVar) + } + + em.expectMessage( + FlexCompletion( + model.uuid, + requestAtTick = Some(operationInterval.start), + ) + ) + + // TICK 8 * 3600: Start of operation interval + + flexRef ! FlexActivation(operationInterval.start) + + em.expectMessageType[ProvideMinMaxFlexOptions] match { + case ProvideMinMaxFlexOptions(modelUuid, ref, min, max) => + modelUuid shouldBe model.uuid + ref should approximate(Kilowatts(1)) + min should approximate(Kilowatts(-1)) + max should approximate(Kilowatts(3)) + } + + flexRef ! IssuePowerControl(operationInterval.start, Kilowatts(3)) + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(result: MockResult) => + result.getInputModel shouldBe model.uuid + result.getTime shouldBe operationInterval.start.toDateTime + result.getP should equalWithTolerance(0.003.asMegaWatt) + result.getQ should equalWithTolerance(0.00145296631.asMegaVar) + } + + em.expectMessage( + FlexCompletion( + model.uuid, + requestAtTick = Some(operationInterval.end), + ) + ) + + // TICK 12 * 3600: GridAgent requests power + + participantAgent ! RequestAssetPowerMessage(12 * 3600, Each(1), Each(0)) + + // 8 hours of 0 kW, 4 hours of 3 kW + gridAgent.expectMessageType[AssetPowerChangedMessage] match { + case AssetPowerChangedMessage(p, q) => + p should approximate(Kilowatts(1)) + q should approximate(Kilovars(0.48432210483)) + } + + participantAgent ! FinishParticipantSimulation(12 * 3600, 24 * 3600) + + // TICK 20 * 3600: Outside of operation interval (last tick) + + flexRef ! FlexActivation(operationInterval.end) + + em.expectMessageType[ProvideMinMaxFlexOptions] match { + case ProvideMinMaxFlexOptions(modelUuid, ref, min, max) => + modelUuid shouldBe model.uuid + ref should approximate(Kilowatts(0)) + min should approximate(Kilowatts(0)) + max should approximate(Kilowatts(0)) + } + + flexRef ! IssueNoControl(operationInterval.end) + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(result: MockResult) => + result.getInputModel shouldBe model.uuid + result.getTime shouldBe operationInterval.end.toDateTime + result.getP should equalWithTolerance(0.0.asMegaWatt) + result.getQ should equalWithTolerance(0.0.asMegaVar) + } + + em.expectMessage(FlexCompletion(model.uuid)) + + // TICK 24 * 3600: GridAgent requests power + + participantAgent ! RequestAssetPowerMessage(24 * 3600, Each(1), Each(0)) + + // 8 hours of 3 kW, 4 hours of 0 kW + gridAgent.expectMessageType[AssetPowerChangedMessage] match { + case AssetPowerChangedMessage(p, q) => + p should approximate(Kilowatts(2)) + q should approximate(Kilovars(0.96864420966)) + } + + participantAgent ! FinishParticipantSimulation(24 * 3600, 36 * 3600) + + } + + "calculate operating point and results correctly with additional ticks" in { + + val em = createTestProbe[FlexResponse]() + val gridAgent = createTestProbe[GridAgent.Request]() + val resultListener = createTestProbe[ResultEvent]() + + // receiving the activation adapter + val receiveAdapter = createTestProbe[ActorRef[FlexRequest]]() + + // with additional activation ticks + val model = new MockParticipantModel( + mockActivationTicks = Map( + 0 * 3600L -> 4 * 3600L, // out of operation, is ignored + 8 * 3600L -> 12 * 3600L, // in operation + 12 * 3600L -> 22 * 3600L, // out of operation, is ignored + ), + mockChangeAtNext = Set( + 0, // out of operation, is ignored + 12 * 3600, // in operation + 20 * 3600, // out of operation, is ignored + ), + ) + val operationInterval = OperationInterval(8 * 3600, 20 * 3600) + + val participantAgent = spawn( + ParticipantAgentMockFactory.create( + ParticipantModelShell( + model, + operationInterval, + simulationStartDate, + ), + ParticipantInputHandler( + Map.empty + ), + ParticipantGridAdapter( + gridAgent.ref, + 12 * 3600, + ), + Iterable(resultListener.ref), + Right(em.ref, receiveAdapter.ref), + ) + ) + val flexRef = receiveAdapter.expectMessageType[ActorRef[FlexRequest]] + + // TICK 0: Outside of operation interval + + flexRef ! FlexActivation(0) + + em.expectMessageType[ProvideMinMaxFlexOptions] match { + case ProvideMinMaxFlexOptions(modelUuid, ref, min, max) => + modelUuid shouldBe model.uuid + ref should approximate(Kilowatts(0)) + min should approximate(Kilowatts(0)) + max should approximate(Kilowatts(0)) + } + + flexRef ! IssueNoControl(0) + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(result: MockResult) => + result.getInputModel shouldBe model.uuid + result.getTime shouldBe simulationStartDate + result.getP should equalWithTolerance(0.0.asMegaWatt) + result.getQ should equalWithTolerance(0.0.asMegaVar) + } + + // next model tick is ignored because we are outside of operation interval + em.expectMessage( + FlexCompletion( + model.uuid, + requestAtTick = Some(operationInterval.start), + ) + ) + + // TICK 8 * 3600: Start of operation interval + + flexRef ! FlexActivation(operationInterval.start) + + em.expectMessageType[ProvideMinMaxFlexOptions] match { + case ProvideMinMaxFlexOptions(modelUuid, ref, min, max) => + modelUuid shouldBe model.uuid + ref should approximate(Kilowatts(1)) + min should approximate(Kilowatts(-1)) + max should approximate(Kilowatts(3)) + } + + flexRef ! IssuePowerControl(operationInterval.start, Kilowatts(3)) + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(result: MockResult) => + result.getInputModel shouldBe model.uuid + result.getTime shouldBe operationInterval.start.toDateTime + result.getP should equalWithTolerance(0.003.asMegaWatt) + result.getQ should equalWithTolerance(0.00145296631.asMegaVar) + } + + em.expectMessage( + FlexCompletion( + model.uuid, + requestAtTick = Some(12 * 3600), + ) + ) + + // TICK 12 * 3600: Inside of operation interval and GridAgent requests power + + flexRef ! FlexActivation(12 * 3600) + + participantAgent ! RequestAssetPowerMessage(12 * 3600, Each(1), Each(0)) + + gridAgent.expectMessageType[AssetPowerChangedMessage] match { + case AssetPowerChangedMessage(p, q) => + p should approximate(Kilowatts(1)) + q should approximate(Kilovars(0.48432210483)) + } + + resultListener.expectNoMessage() + em.expectNoMessage() + + participantAgent ! FinishParticipantSimulation(12 * 3600, 24 * 3600) + + // calculation should start now + em.expectMessageType[ProvideMinMaxFlexOptions] match { + case ProvideMinMaxFlexOptions(modelUuid, ref, min, max) => + modelUuid shouldBe model.uuid + ref should approximate(Kilowatts(1)) + min should approximate(Kilowatts(-1)) + max should approximate(Kilowatts(3)) + } + + flexRef ! IssueNoControl(12 * 3600) + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(result: MockResult) => + result.getInputModel shouldBe model.uuid + result.getTime shouldBe (12 * 3600).toDateTime + result.getP should equalWithTolerance(0.001.asMegaWatt) + result.getQ should equalWithTolerance(0.0004843221.asMegaVar) + } + + em.expectMessage( + FlexCompletion( + model.uuid, + requestAtNextActivation = true, + requestAtTick = Some(operationInterval.end), + ) + ) + + // TICK 20 * 3600: Outside of operation interval (last tick) + + flexRef ! FlexActivation(operationInterval.end) + + em.expectMessageType[ProvideMinMaxFlexOptions] match { + case ProvideMinMaxFlexOptions(modelUuid, ref, min, max) => + modelUuid shouldBe model.uuid + ref should approximate(Kilowatts(0)) + min should approximate(Kilowatts(0)) + max should approximate(Kilowatts(0)) + } + + flexRef ! IssueNoControl(operationInterval.end) + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(result: MockResult) => + result.getInputModel shouldBe model.uuid + result.getTime shouldBe operationInterval.end.toDateTime + result.getP should equalWithTolerance(0.0.asMegaWatt) + result.getQ should equalWithTolerance(0.0.asMegaVar) + } + + em.expectMessage(FlexCompletion(model.uuid)) + + // TICK 24 * 3600: GridAgent requests power + + participantAgent ! RequestAssetPowerMessage(24 * 3600, Each(1), Each(0)) + + // 8 hours of 1 kW, 4 hours of 0 kW + gridAgent.expectMessageType[AssetPowerChangedMessage] match { + case AssetPowerChangedMessage(p, q) => + p should approximate(Kilowatts(0.6666666667)) + q should approximate(Kilovars(0.32288140322)) + } + + participantAgent ! FinishParticipantSimulation(24 * 3600, 36 * 3600) + + } + + } + + "depending on secondary data" should { + + "calculate operating point and results correctly with additional ticks" in { + + val em = createTestProbe[FlexResponse]() + val gridAgent = createTestProbe[GridAgent.Request]() + val resultListener = createTestProbe[ResultEvent]() + val service = createTestProbe() + + // receiving the activation adapter + val receiveAdapter = createTestProbe[ActorRef[FlexRequest]]() + + // with additional activation ticks + val model = new MockParticipantModel( + mockActivationTicks = Map( + 0 * 3600L -> 4 * 3600L, // out of operation, is ignored + 8 * 3600L -> 12 * 3600L, // in operation + 12 * 3600L -> 22 * 3600L, // out of operation, is ignored + ), + mockChangeAtNext = Set( + 0, // out of operation, is ignored + 18 * 3600, // in operation + 20 * 3600, // out of operation, is ignored + ), + ) + val operationInterval = OperationInterval(8 * 3600, 20 * 3600) + + val participantAgent = spawn( + ParticipantAgentMockFactory.create( + ParticipantModelShell( + model, + operationInterval, + simulationStartDate, + ), + ParticipantInputHandler( + Map(service.ref.toClassic -> 0) + ), + ParticipantGridAdapter( + gridAgent.ref, + 12 * 3600, + ), + Iterable(resultListener.ref), + Right(em.ref, receiveAdapter.ref), + ) + ) + val flexRef = receiveAdapter.expectMessageType[ActorRef[FlexRequest]] + + // TICK 0: Outside of operation interval + + flexRef ! FlexActivation(0) + + // nothing should happen, still waiting for secondary data... + resultListener.expectNoMessage() + em.expectNoMessage() + + participantAgent ! ProvideData( + 0, + service.ref.toClassic, + MockSecondaryData(Kilowatts(1)), + Some(6 * 3600), + ) + + em.expectMessageType[ProvideMinMaxFlexOptions] match { + case ProvideMinMaxFlexOptions(modelUuid, ref, min, max) => + modelUuid shouldBe model.uuid + ref should approximate(Kilowatts(0)) + min should approximate(Kilowatts(0)) + max should approximate(Kilowatts(0)) + } + + flexRef ! IssueNoControl(0) + + // outside of operation interval, 0 MW + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(result: MockResult) => + result.getInputModel shouldBe model.uuid + result.getTime shouldBe simulationStartDate + result.getP should equalWithTolerance(0.0.asMegaWatt) + result.getQ should equalWithTolerance(0.0.asMegaVar) + } + + // next model tick and next data tick are ignored, + // because we are outside of operation interval + em.expectMessage( + FlexCompletion( + model.uuid, + requestAtTick = Some(operationInterval.start), + ) + ) + + // TICK 6 * 3600: Outside of operation interval, only data expected, no activation + + participantAgent ! ProvideData( + 6 * 3600, + service.ref.toClassic, + MockSecondaryData(Kilowatts(1)), + Some(12 * 3600), + ) + + resultListener.expectNoMessage() + em.expectNoMessage() + + // TICK 8 * 3600: Start of operation interval + + flexRef ! FlexActivation(operationInterval.start) + + em.expectMessageType[ProvideMinMaxFlexOptions] match { + case ProvideMinMaxFlexOptions(modelUuid, ref, min, max) => + modelUuid shouldBe model.uuid + ref should approximate(Kilowatts(2)) + min should approximate(Kilowatts(0)) + max should approximate(Kilowatts(4)) + } + + flexRef ! IssuePowerControl(operationInterval.start, Kilowatts(3)) + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(result: MockResult) => + result.getInputModel shouldBe model.uuid + result.getTime shouldBe operationInterval.start.toDateTime + result.getP should equalWithTolerance(0.003.asMegaWatt) + result.getQ should equalWithTolerance(0.00145296631.asMegaVar) + } + + // next model tick and next data tick are both hour 12 + em.expectMessage( + FlexCompletion( + model.uuid, + requestAtTick = Some(12 * 3600), + ) + ) + + // TICK 12 * 3600: Inside of operation interval, GridAgent requests power + + flexRef ! FlexActivation(12 * 3600) + + participantAgent ! RequestAssetPowerMessage(12 * 3600, Each(1), Each(0)) + + // 8 hours of 0 kW, 4 hours of 3 kW + gridAgent.expectMessageType[AssetPowerChangedMessage] match { + case AssetPowerChangedMessage(p, q) => + p should approximate(Kilowatts(1)) + q should approximate(Kilovars(0.48432210483)) + } + + participantAgent ! FinishParticipantSimulation(12 * 3600, 24 * 3600) + + // nothing should happen, still waiting for secondary data... + resultListener.expectNoMessage() + em.expectNoMessage() + + participantAgent ! ProvideData( + 12 * 3600, + service.ref.toClassic, + MockSecondaryData(Kilowatts(2)), + Some(18 * 3600), + ) + + // calculation should start now + em.expectMessageType[ProvideMinMaxFlexOptions] match { + case ProvideMinMaxFlexOptions(modelUuid, ref, min, max) => + modelUuid shouldBe model.uuid + ref should approximate(Kilowatts(3)) + min should approximate(Kilowatts(1)) + max should approximate(Kilowatts(5)) + } + + flexRef ! IssueNoControl(12 * 3600) + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(result: MockResult) => + result.getInputModel shouldBe model.uuid + result.getTime shouldBe (12 * 3600).toDateTime + result.getP should equalWithTolerance(0.003.asMegaWatt) + result.getQ should equalWithTolerance(0.00145296631.asMegaVar) + } + + em.expectMessage( + FlexCompletion( + model.uuid, + requestAtTick = Some(18 * 3600), + ) + ) + + // TICK 18 * 3600: Inside of operation interval because of expected secondary data + + flexRef ! FlexActivation(18 * 3600) + + // nothing should happen, still waiting for secondary data... + resultListener.expectNoMessage() + em.expectNoMessage() + + participantAgent ! ProvideData( + 18 * 3600, + service.ref.toClassic, + MockSecondaryData(Kilowatts(5)), + Some(24 * 3600), + ) + + // calculation should start now + em.expectMessageType[ProvideMinMaxFlexOptions] match { + case ProvideMinMaxFlexOptions(modelUuid, ref, min, max) => + modelUuid shouldBe model.uuid + ref should approximate(Kilowatts(6)) + min should approximate(Kilowatts(4)) + max should approximate(Kilowatts(8)) + } + + flexRef ! IssueNoControl(18 * 3600) + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(result: MockResult) => + result.getInputModel shouldBe model.uuid + result.getTime shouldBe (18 * 3600).toDateTime + result.getP should equalWithTolerance(0.006.asMegaWatt) + result.getQ should equalWithTolerance(0.002905932629.asMegaVar) + } + + em.expectMessage( + FlexCompletion( + model.uuid, + requestAtNextActivation = true, + requestAtTick = Some(operationInterval.end), + ) + ) + + // TICK 20 * 3600: Outside of operation interval (last tick) + + flexRef ! FlexActivation(operationInterval.end) + + em.expectMessageType[ProvideMinMaxFlexOptions] match { + case ProvideMinMaxFlexOptions(modelUuid, ref, min, max) => + modelUuid shouldBe model.uuid + ref should approximate(Kilowatts(0)) + min should approximate(Kilowatts(0)) + max should approximate(Kilowatts(0)) + } + + flexRef ! IssueNoControl(operationInterval.end) + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(result: MockResult) => + result.getInputModel shouldBe model.uuid + result.getTime shouldBe operationInterval.end.toDateTime + result.getP should equalWithTolerance(0.0.asMegaWatt) + result.getQ should equalWithTolerance(0.0.asMegaVar) + } + + // Since we left the operation interval, there are no more ticks to activate + em.expectMessage(FlexCompletion(model.uuid)) + + // TICK 24 * 3600: GridAgent requests power + + participantAgent ! RequestAssetPowerMessage(24 * 3600, Each(1), Each(0)) + + // 6 hours of 3 kW, 2 hours of 6 kW, 4 hours of 0 kW + gridAgent.expectMessageType[AssetPowerChangedMessage] match { + case AssetPowerChangedMessage(p, q) => + p should approximate(Kilowatts(2.5)) + q should approximate(Kilovars(1.210805262)) + } + + participantAgent ! FinishParticipantSimulation(24 * 3600, 36 * 3600) + + resultListener.expectNoMessage() + em.expectNoMessage() + + } + + } + + "depending on primary data" should { + + "calculate operating point and results correctly" in { + + val em = createTestProbe[FlexResponse]() + val gridAgent = createTestProbe[GridAgent.Request]() + val resultListener = createTestProbe[ResultEvent]() + val service = createTestProbe() + + // receiving the activation adapter + val receiveAdapter = createTestProbe[ActorRef[FlexRequest]]() + + // no additional activation ticks + val physicalModel = new MockParticipantModel() + + val model = ParticipantModelInit.createPrimaryModel( + physicalModel, + ActivePowerMeta, + ) + val operationInterval = OperationInterval(8 * 3600, 20 * 3600) + + val participantAgent = spawn( + ParticipantAgentMockFactory.create( + ParticipantModelShell( + model, + operationInterval, + simulationStartDate, + ), + ParticipantInputHandler( + Map(service.ref.toClassic -> 0) + ), + ParticipantGridAdapter( + gridAgent.ref, + 12 * 3600, + ), + Iterable(resultListener.ref), + Right(em.ref, receiveAdapter.ref), + ) + ) + val flexRef = receiveAdapter.expectMessageType[ActorRef[FlexRequest]] + + // TICK 0: Outside of operation interval + + flexRef ! FlexActivation(0) + + // nothing should happen, still waiting for primary data... + resultListener.expectNoMessage() + em.expectNoMessage() + + participantAgent ! ProvideData( + 0, + service.ref.toClassic, + ActivePower(Kilowatts(1)), + Some(6 * 3600), + ) + + em.expectMessageType[ProvideMinMaxFlexOptions] match { + case ProvideMinMaxFlexOptions(modelUuid, ref, min, max) => + modelUuid shouldBe model.uuid + ref should approximate(Kilowatts(0)) + min should approximate(Kilowatts(0)) + max should approximate(Kilowatts(0)) + } + + flexRef ! IssueNoControl(0) + + // outside of operation interval, 0 MW + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(result: MockResult) => + result.getInputModel shouldBe model.uuid + result.getTime shouldBe simulationStartDate + result.getP should equalWithTolerance(0.0.asMegaWatt) + result.getQ should equalWithTolerance(0.0.asMegaVar) + } + + // next model tick and next data tick are ignored, + // because we are outside of operation interval + em.expectMessage( + FlexCompletion( + model.uuid, + requestAtTick = Some(operationInterval.start), + ) + ) + + // TICK 6 * 3600: Outside of operation interval, only data expected, no activation + + participantAgent ! ProvideData( + 6 * 3600, + service.ref.toClassic, + ActivePower(Kilowatts(3)), + Some(12 * 3600), + ) + + resultListener.expectNoMessage() + em.expectNoMessage() + + // TICK 8 * 3600: Start of operation interval + + flexRef ! FlexActivation(operationInterval.start) + + em.expectMessageType[ProvideMinMaxFlexOptions] match { + case ProvideMinMaxFlexOptions(modelUuid, ref, min, max) => + modelUuid shouldBe model.uuid + ref should approximate(Kilowatts(3)) + min should approximate(Kilowatts(3)) + max should approximate(Kilowatts(3)) + } + + flexRef ! IssuePowerControl(operationInterval.start, Kilowatts(3)) + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(result: MockResult) => + result.getInputModel shouldBe model.uuid + result.getTime shouldBe operationInterval.start.toDateTime + result.getP should equalWithTolerance(0.003.asMegaWatt) + result.getQ should equalWithTolerance(0.00145296631.asMegaVar) + } + + // next data tick is hour 12 + em.expectMessage( + FlexCompletion( + model.uuid, + requestAtTick = Some(12 * 3600), + ) + ) + + // TICK 12 * 3600: Inside of operation interval, GridAgent requests power + + flexRef ! FlexActivation(12 * 3600) + + participantAgent ! RequestAssetPowerMessage(12 * 3600, Each(1), Each(0)) + + // 8 hours of 0 kW, 4 hours of 3 kW + gridAgent.expectMessageType[AssetPowerChangedMessage] match { + case AssetPowerChangedMessage(p, q) => + p should approximate(Kilowatts(1)) + q should approximate(Kilovars(0.48432210483)) + } + + participantAgent ! FinishParticipantSimulation(12 * 3600, 24 * 3600) + + // nothing should happen, still waiting for primary data... + resultListener.expectNoMessage() + em.expectNoMessage() + + participantAgent ! ProvideData( + 12 * 3600, + service.ref.toClassic, + ActivePower(Kilowatts(6)), + Some(18 * 3600), + ) + + // calculation should start now + em.expectMessageType[ProvideMinMaxFlexOptions] match { + case ProvideMinMaxFlexOptions(modelUuid, ref, min, max) => + modelUuid shouldBe model.uuid + ref should approximate(Kilowatts(6)) + min should approximate(Kilowatts(6)) + max should approximate(Kilowatts(6)) + } + + flexRef ! IssueNoControl(12 * 3600) + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(result: MockResult) => + result.getInputModel shouldBe model.uuid + result.getTime shouldBe (12 * 3600).toDateTime + result.getP should equalWithTolerance(0.006.asMegaWatt) + result.getQ should equalWithTolerance(0.00290593263.asMegaVar) + } + + em.expectMessage( + FlexCompletion( + model.uuid, + requestAtTick = Some(18 * 3600), + ) + ) + + // TICK 18 * 3600: Inside of operation interval because of expected primary data + + flexRef ! FlexActivation(18 * 3600) + + // nothing should happen, still waiting for primary data... + resultListener.expectNoMessage() + em.expectNoMessage() + + participantAgent ! ProvideData( + 18 * 3600, + service.ref.toClassic, + ActivePower(Kilowatts(3)), + Some(24 * 3600), + ) + + // calculation should start now + em.expectMessageType[ProvideMinMaxFlexOptions] match { + case ProvideMinMaxFlexOptions(modelUuid, ref, min, max) => + modelUuid shouldBe model.uuid + ref should approximate(Kilowatts(3)) + min should approximate(Kilowatts(3)) + max should approximate(Kilowatts(3)) + } + + flexRef ! IssueNoControl(18 * 3600) + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(result: MockResult) => + result.getInputModel shouldBe model.uuid + result.getTime shouldBe (18 * 3600).toDateTime + result.getP should equalWithTolerance(0.003.asMegaWatt) + result.getQ should equalWithTolerance(0.00145296631.asMegaVar) + } + + em.expectMessage( + FlexCompletion( + model.uuid, + requestAtTick = Some(operationInterval.end), + ) + ) + + // TICK 20 * 3600: Outside of operation interval (last tick) + + flexRef ! FlexActivation(operationInterval.end) + + em.expectMessageType[ProvideMinMaxFlexOptions] match { + case ProvideMinMaxFlexOptions(modelUuid, ref, min, max) => + modelUuid shouldBe model.uuid + ref should approximate(Kilowatts(0)) + min should approximate(Kilowatts(0)) + max should approximate(Kilowatts(0)) + } + + flexRef ! IssueNoControl(operationInterval.end) + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(result: MockResult) => + result.getInputModel shouldBe model.uuid + result.getTime shouldBe operationInterval.end.toDateTime + result.getP should equalWithTolerance(0.0.asMegaWatt) + result.getQ should equalWithTolerance(0.0.asMegaVar) + } + + // Since we left the operation interval, there are no more ticks to activate + em.expectMessage(FlexCompletion(model.uuid)) + + // TICK 24 * 3600: GridAgent requests power + + participantAgent ! RequestAssetPowerMessage(24 * 3600, Each(1), Each(0)) + + // 6 hours of 6 kW, 2 hours of 3 kW, 4 hours of 0 kW + gridAgent.expectMessageType[AssetPowerChangedMessage] match { + case AssetPowerChangedMessage(p, q) => + p should approximate(Kilowatts(3.5)) + q should approximate(Kilovars(1.695127366932)) + } + + participantAgent ! FinishParticipantSimulation(24 * 3600, 36 * 3600) + + resultListener.expectNoMessage() + em.expectNoMessage() + + } + + } + + } + +} diff --git a/src/test/scala/edu/ie3/simona/api/ExtSimAdapterSpec.scala b/src/test/scala/edu/ie3/simona/api/ExtSimAdapterSpec.scala index 0a4dab2d51..b1d5d34028 100644 --- a/src/test/scala/edu/ie3/simona/api/ExtSimAdapterSpec.scala +++ b/src/test/scala/edu/ie3/simona/api/ExtSimAdapterSpec.scala @@ -21,7 +21,7 @@ import edu.ie3.simona.ontology.messages.SchedulerMessage.{ Completion, ScheduleActivation, } -import edu.ie3.simona.ontology.messages.services.ServiceMessage.RegistrationResponseMessage.ScheduleServiceActivation +import edu.ie3.simona.ontology.messages.services.ServiceMessage.ScheduleServiceActivation import edu.ie3.simona.scheduler.ScheduleLock.ScheduleKey import edu.ie3.simona.test.common.{TestKitWithShutdown, TestSpawnerClassic} import edu.ie3.simona.util.SimonaConstants.INIT_SIM_TICK diff --git a/src/test/scala/edu/ie3/simona/model/participant2/ChargingHelperSpec.scala b/src/test/scala/edu/ie3/simona/model/participant2/ChargingHelperSpec.scala new file mode 100644 index 0000000000..d48a8399ed --- /dev/null +++ b/src/test/scala/edu/ie3/simona/model/participant2/ChargingHelperSpec.scala @@ -0,0 +1,72 @@ +/* + * © 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.participant2 + +import edu.ie3.simona.test.common.UnitSpec +import squants.energy.{Energy, KilowattHours, Kilowatts} + +class ChargingHelperSpec extends UnitSpec { + + private implicit val energyTolerance: Energy = KilowattHours(1e-10) + + "A ChargingHelper" should { + + "calculate charged energy correctly" in { + + val cases = Table( + ( + "storedEnergy", + "lastStateTick", + "currentTick", + "power", + "expectedStored", + ), + // empty battery + (0.0, 0L, 3600L, 5.0, 5.0), + (0.0, 0L, 3600L, 2.5, 2.5), + (0.0, 0L, 1800L, 5.0, 2.5), + (0.0, 900L, 2700L, 5.0, 2.5), + (0.0, 0L, 3600L, -5.0, 0.0), + // half full battery + (5.0, 0L, 3600L, 5.0, 10.0), + (5.0, 0L, 3600L, 2.5, 7.5), + (5.0, 0L, 1800L, 5.0, 7.5), + (5.0, 900L, 2700L, 5.0, 7.5), + (5.0, 0L, 3600L, -5.0, 0.0), + (5.0, 0L, 3600L, -2.5, 2.5), + (5.0, 0L, 1800L, -5.0, 2.5), + (5.0, 900L, 2700L, -5.0, 2.5), + // full battery + (10.0, 0L, 3600L, -5.0, 5.0), + (10.0, 0L, 3600L, -2.5, 7.5), + (10.0, 0L, 1800L, -5.0, 7.5), + (10.0, 900L, 2700L, -5.0, 7.5), + (10.0, 0L, 3600L, 5.0, 10.0), + ) + + forAll(cases) { + ( + storedEnergy, + startTick, + endTick, + power, + expectedEnergy, + ) => + val newEnergy = ChargingHelper.calcEnergy( + KilowattHours(storedEnergy), + Kilowatts(power), + startTick, + endTick, + KilowattHours(10.0), + ) + + newEnergy should approximate(KilowattHours(expectedEnergy)) + } + + } + } +} diff --git a/src/test/scala/edu/ie3/simona/model/participant2/FixedFeedInModelSpec.scala b/src/test/scala/edu/ie3/simona/model/participant2/FixedFeedInModelSpec.scala new file mode 100644 index 0000000000..a6813a2656 --- /dev/null +++ b/src/test/scala/edu/ie3/simona/model/participant2/FixedFeedInModelSpec.scala @@ -0,0 +1,56 @@ +/* + * © 2020. TU Dortmund University, + * Institute of Energy Systems, Energy Efficiency and Energy Economics, + * Research group Distribution grid planning and operation + */ + +package edu.ie3.simona.model.participant2 + +import edu.ie3.simona.model.participant.control.QControl +import edu.ie3.simona.model.participant2.ParticipantModel.FixedState +import edu.ie3.simona.test.common.UnitSpec +import edu.ie3.simona.test.common.input.FixedFeedInputTestData +import edu.ie3.util.quantities.PowerSystemUnits +import edu.ie3.util.quantities.PowerSystemUnits.MEGAVOLTAMPERE +import edu.ie3.util.scala.quantities.{Kilovoltamperes, Megavoltamperes} + +class FixedFeedInModelSpec extends UnitSpec with FixedFeedInputTestData { + + "The fixed feed in model" should { + + "build a correct FixedFeedModel from valid input" in { + + val model = FixedFeedInModel(fixedFeedInput) + + model.uuid shouldBe fixedFeedInput.getUuid + model.sRated shouldBe Megavoltamperes( + fixedFeedInput.getsRated().to(MEGAVOLTAMPERE).getValue.doubleValue + ) + model.cosPhiRated shouldBe fixedFeedInput.getCosPhiRated + model.qControl shouldBe QControl(fixedFeedInput.getqCharacteristics) + + } + + "return approximately correct power calculations" in { + + val model = FixedFeedInModel(fixedFeedInput) + + val expectedPower = Kilovoltamperes( + fixedFeedInput + .getsRated() + .to(PowerSystemUnits.KILOWATT) + .getValue + .doubleValue * -1 + ).toActivePower(fixedFeedInput.getCosPhiRated) + + val (operatingPoint, nextTick) = model.determineOperatingPoint( + FixedState(0) + ) + operatingPoint.activePower shouldBe expectedPower + nextTick shouldBe None + + } + + } + +} diff --git a/src/test/scala/edu/ie3/simona/model/participant2/PvModelSpec.scala b/src/test/scala/edu/ie3/simona/model/participant2/PvModelSpec.scala new file mode 100644 index 0000000000..b2175e852e --- /dev/null +++ b/src/test/scala/edu/ie3/simona/model/participant2/PvModelSpec.scala @@ -0,0 +1,798 @@ +/* + * © 2020. TU Dortmund University, + * Institute of Energy Systems, Energy Efficiency and Energy Economics, + * Research group Distribution grid planning and operation + */ + +package edu.ie3.simona.model.participant2 + +import edu.ie3.datamodel.models.OperationTime +import edu.ie3.datamodel.models.input.system.PvInput +import edu.ie3.datamodel.models.input.system.characteristic.CosPhiFixed +import edu.ie3.datamodel.models.input.{NodeInput, OperatorInput} +import edu.ie3.datamodel.models.voltagelevels.GermanVoltageLevelUtils +import edu.ie3.simona.test.common.{DefaultTestData, UnitSpec} +import edu.ie3.util.quantities.PowerSystemUnits._ +import edu.ie3.util.scala.quantities.{ + ApparentPower, + Irradiation, + Kilovoltamperes, + WattHoursPerSquareMeter, +} +import org.locationtech.jts.geom.{Coordinate, GeometryFactory, Point} +import org.scalatest.GivenWhenThen +import squants.space.{Angle, Degrees, Radians} +import tech.units.indriya.quantity.Quantities.getQuantity +import tech.units.indriya.unit.Units._ + +import java.time.ZonedDateTime +import java.util.UUID +import scala.math.toRadians + +/** Test class that tries to cover all special cases of the current + * implementation of the PvModel + * + * Some of these test cases are taken from the examples of Duffie, J. A., & + * Beckman, W. A. (2013). Solar engineering of thermal processes (4th ed.). + * Hoboken, N.J.: John Wiley & Sons. + * + * The page examples can be found using the page number provided in each test. + * Results may differ slightly from the book as we sometimes use different + * formulas. Furthermore, sometimes the example might be slightly adapted to + * fit our needs. + */ +class PvModelSpec extends UnitSpec with GivenWhenThen with DefaultTestData { + + // build the NodeInputModel (which defines the location of the pv input model) + // the NodeInputModel needs a GeoReference for the Pv to work + val geometryFactory = new GeometryFactory() + val p: Point = geometryFactory.createPoint(new Coordinate(13.2491, 53.457909)) + val nodeInput = new NodeInput( + UUID.fromString("85f8b517-8a2d-4c20-86c6-3ff3c5823e6d"), + "NodeInputModel for PvModel Test", + OperatorInput.NO_OPERATOR_ASSIGNED, + OperationTime.notLimited(), + getQuantity(1, PU), + false, + p, + GermanVoltageLevelUtils.MV_20KV, + 11, + ) + + // build the PvInputModel + val pvInput = new PvInput( + UUID.fromString("adb4eb23-1dd6-4406-a5e7-02e1e4c9dead"), + "Pv Model Test", + OperatorInput.NO_OPERATOR_ASSIGNED, + OperationTime.notLimited, + nodeInput, + new CosPhiFixed("cosPhiFixed:{(0.0,0.9)}"), + null, + 0.20000000298023224, + getQuantity(-8.926613807678223, DEGREE_GEOM), + getQuantity(97, PERCENT), + getQuantity(41.01871871948242, DEGREE_GEOM), + 0.8999999761581421, + 1, + false, + getQuantity(10, KILOVOLTAMPERE), + 0.8999999761581421, + ) + + // build the PvModel + val pvModel: PvModel = PvModel( + pvInput + ) + + private implicit val angleTolerance: Angle = Radians(1e-10) + private implicit val irradiationTolerance: Irradiation = + WattHoursPerSquareMeter(1e-10) + private implicit val apparentPowerTolerance: ApparentPower = Kilovoltamperes( + 1e-10 + ) + + "A PV Model" should { + "have sMax set to be 10% higher than its sRated" in { + When("sMax is calculated") + val actualSMax = pvModel.sMax + val expectedSMax = pvModel.sRated * 1.1 + + Then("result should match the test data") + actualSMax should approximate(expectedSMax) + } + + "calculate the day angle J correctly" in { + val testCases = Table( + ("time", "jSol"), + ("2019-01-05T05:15:00+01:00", 0.06885682528415985d), + ( + "2016-10-31T12:15:00+01:00", + 5.23311872159614873d, + ), // leap year => day = 305 + ( + "2017-10-31T12:15:00+01:00", + 5.21590451527510877d, + ), // regular year => day = 304 + ( + "2011-06-21T13:00:00+02:00", + 2.9436292808978335d, + ), // regular year => day = 172 + ( + "2011-04-05T16:00:00+02:00", + 1.6181353941777565d, + ), // regular year => day = 95 + ( + "2011-09-21T00:00:00+02:00", + 4.5273362624335105d, + ), // regular year => day = 264 + ( + "2011-03-21T00:00:00+01:00", + 1.359922299362157d, + ), // regular year => day = 80 + ) + + forAll(testCases) { (time, jSol) => + When("the day angle is calculated") + val jCalc = pvModel.calcAngleJ(ZonedDateTime.parse(time)) + + Then("result should match the test data") + jCalc should approximate(Radians(jSol)) + } + } + + "calculate the declination angle delta correctly" in { + val testCases = Table( + ("j", "deltaSol"), + (0d, -0.402449d), // Jan 1st + (2.943629280897834d, 0.40931542032971796d), // Jun 21 (maximum: 23.45°) + (6.024972212363987d, -0.4069636112528855d), // Dec 21 (minimum: -23.45°) + (4.52733626243351d, 0.01790836361732633d), // Sep 21 (0°) + (1.359922299362157d, -0.0011505915019577827d), // Mar 21 (0°) + ) + + forAll(testCases) { (j, deltaSol) => + When("the declination angle is calculated") + val deltaCalc = pvModel.calcSunDeclinationDelta(Radians(j)) + + Then("result should match the test data") + deltaCalc should approximate(Radians(deltaSol)) + } + } + + "calculate the hour angle omega correctly" in { + val testCases = Table( + ("time", "j", "longitude", "omegaSol"), + ( + "2019-01-01T05:00:00+01:00", + 0d, + 0.16d, + -1.9465030168609223d, + ), // long: ~9.17°E + ( + "2019-01-01T10:05:00+01:00", + 0d, + 0.16d, + -0.6156894622152458d, + ), // different time: 10:05 + ( + "2019-01-01T12:00:00+01:00", + 0d, + 0.16d, + -0.11390730226687622d, + ), // 12:00 + ( + "2019-01-01T14:00:00+01:00", + 0d, + 0.16d, + 0.40969147333142264d, + ), // 14:00 + ( + "2019-01-01T17:30:00+01:00", + 0d, + 0.16d, + 1.3259893306284447d, + ), // 17:30 + ( + "2019-03-21T05:00:00+01:00", + 1.359922299362157d, + 0.16d, + -1.9677750757840207d, + ), // different j (different date) + ( + "2019-01-01T05:00:00+01:00", + 0d, + 0.175d, + -1.9315030168609224d, + ), // different long, ~10°E + ( + "2011-06-21T11:00:00+02:00", + 2.9436292808978d, + 0.2337d, + -0.2960273936975511d, + ), // long of Berlin (13.39E) + ( + "2011-06-21T12:00:00+02:00", + 2.9436292808978d, + 0.2337d, + -0.034228005898401644d, + ), // long of Berlin (13.39E) + ( + "2011-06-21T13:00:00+02:00", + 2.9436292808978d, + 0.2337d, + 0.2275713819007478d, + ), // long of Berlin (13.39E) + ( + "2011-06-21T14:00:00+02:00", + 2.9436292808978d, + 0.2337d, + 0.4893707696998972d, + ), // long of Berlin (13.39E) + ( + "2011-06-21T15:00:00+02:00", + 2.9436292808978d, + 0.2337d, + 0.7511701574990467d, + ), // long of Berlin (13.39E) + ( + "2011-04-05T16:00:00+02:00", + 1.6181353941777565d, + 0.2337d, + 1.0062695999127786d, + ), // long of Berlin (13.39E) + ( + "2011-06-21T12:00:00+02:00", + 2.9436292808978d, + 0.5449d, + 0.2769719941015987d, + ), // long of Cairo (31.22E) + ) + + forAll(testCases) { (time, j, longitude, omegaSol) => + When("the hour angle is calculated") + val omegaCalc = pvModel.calcHourAngleOmega( + ZonedDateTime.parse(time), + Radians(j), + Radians(longitude), + ) + + Then("result should match the test data") + omegaCalc should approximate(Radians(omegaSol)) + } + } + + "calculate the sunset angle omegaSS correctly" in { + val testCases = Table( + ("latitude", "delta", "omegaSSSol"), + (0.9d, -0.402449d, 1.0045975406286176d), // lat: ~51.57°N + (0.935d, -0.402449d, 0.956011693657339d), // different lat: ~53.57°N + (0.9d, 0.017908d, 1.5933675693198284d), // different delta + ( + 0.157952297d, + 0.384670567d, + 1.635323424114512d, + ), // Example 2.2 Goswami Principles of Solar Engineering + ) + + forAll(testCases) { (latitude, delta, omegaSSSol) => + When("the sunset angle is calculated") + val omegaSSCalc = + pvModel.calcSunsetAngleOmegaSS(Radians(latitude), Radians(delta)) + + Then("result should match the test data") + omegaSSCalc should approximate(Radians(omegaSSSol)) + } + } + + "calculate the solar altitude angle alphaS correctly" in { + val testCases = Table( + ("omega", "delta", "latitude", "alphaSSol"), + ( + 1.946503016860923d, + -0.402449d, + 0.9d, + -0.5429594681352444d, + ), // Jan 1st, lat: ~51.57°N + ( + 1.967775075784021d, + -0.001150591501958d, + 0.9d, + -0.24363984335678648d, + ), // March 21st + ( + 1.946503016860923d, + -0.402449d, + 0.935d, + -0.5417322854819461d, + ), // Jan 1st, lat: ~53.57°N + ( + 1.256637061d, + -0.402449d, + 0.698d, + -0.033897520990303694d, + ), // omega: 82°, Jan 1st, lat: ~39.99°N + ( + 0.409691473331422d, + -0.402449d, + 0.9d, + 0.21956610107293822d, + ), // omega: 14:00, Jan 1st + ( + -0.85019406d, + -0.00720875d, + 0.9128072d, + 0.40911138927659646d, + ), // omega: -48.71° = 09:00, March 21st, lat: Berlin + ( + 0.22425484d, + 0.40899596d, + 0.9128072d, + 1.0386092658376944d, + ), // omega: +12.84° = 14:00 MESZ = 13:00 MEZ, June 21st, lat: Berlin + ( + -0.81703281d, + -0.00720875d, + 0.54628806d, + 0.619982384489836d, + ), // omega: -36.9809° = 09:00, March 21st, lat: Cairo + ( + -0.00438329d, + 0.40899596d, + 0.54628806d, + 1.4334492081530734d, + ), // omega: -0.25° = 12:00, June 21st, lat: Cairo + ( + 0.0126074d, + -0.40842934d, + 0.54628806d, + 0.6160025701438165d, + ), // omega: +0.7223° = 12:00, Dez 21st, lat: Cairo + ( + -0.78639785d, + 0.1549651d, + 0.54628806d, + 0.7430566034615067d, + ), // omega: -45.05° = 09:00, Sep 1st, lat: Cairo + ( + 1.04619786d, + 0.1549651d, + 0.54628806d, + 0.5270965151470974d, + ), // omega: +59.943° = 16:00, Sep 1st, lat: Cairo + ( + 0d, + -0.305432619d, + 0.518013722d, + 0.7473499857948969d, + ), // omega: 0 = Solar Noon, Feb 01st, lat/lon: Gainsville (29.68 N, 82.27 W) + ( + -1.374970385d, + 0.380755678d, + 0.157952297d, + 0.2391202791125743d, + ), // omega: -78.78° = 7:00 a.m., June 01st, lat/lon: Tocumen Panama (9.05 N, 79.37 W) + ( + 0d, + -0.268780705d, + -0.616101226d, + 1.2234758057948967d, + ), // omega: 0° = Solar noon., Nov 01st, lat/lon: Canberra Australia (35.3 S, 149.1 E) + ( + toRadians(-37.5d), + toRadians(-14d), + toRadians(43d), + toRadians(23.4529893659531784299686037109330117049955654837550d), + ), // '2011-02-13T09:30:00' from Duffie + ( + toRadians(97.5d), + toRadians(23.1d), + toRadians(43d), + toRadians(10.356151317506402829742934977890382350725031728508d), + ), // '2011-07-01T06:30:00' from Duffie + // Reference: Quaschning, Regenerative Energiesysteme figure 2.15 and figure 2.16 // gammaS@Quaschning = alphaS@SIMONA ! + ( + toRadians(-47.15114406), + toRadians(23.4337425d), + toRadians(52.3d), + toRadians(44.12595614280154d), + ), // Berlin (13.2E 52.3N) '2011-06-21T09:00:00' MEZ + ( + toRadians(-32.15114394d), + toRadians(23.4337425d), + toRadians(52.3d), + toRadians(52.15790489243239d), + ), // Berlin (13.2E 52.3N) '2011-06-21T10:00:00' MEZ + ( + toRadians(-17.15114381d), + toRadians(23.4337425d), + toRadians(52.3d), + toRadians(58.29851278388936d), + ), // Berlin (13.2E 52.3N) '2011-06-21T11:00:00' MEZ + ( + toRadians(-2.151143686d), + toRadians(23.4337425d), + toRadians(52.3d), + toRadians(61.086849596117524d), + ), // Berlin (13.2E 52.3N) '2011-06-21T12:00:00' MEZ + ( + toRadians(12.84885587d), + toRadians(23.4337425d), + toRadians(52.3d), + toRadians(59.50792770681503d), + ), // Berlin (13.2E 52.3N) '2011-06-21T13:00:00' MEZ + ( + toRadians(27.84885599d), + toRadians(23.4337425d), + toRadians(52.3d), + toRadians(54.170777340509574d), + ), // Berlin (13.2E 52.3N) '2011-06-21T14:00:00' MEZ + ( + toRadians(58.28178946d), + toRadians(7.79402247d), + toRadians(52.3d), + toRadians(25.203526133755485d), + ), // Berlin (13.2E 52.3N) '2011-09-04T16:00:00' MEZ + ( + toRadians(0.948855924d), + toRadians(23.4337425d), + toRadians(30.1d), + toRadians(83.28023248078853d), + ), // Cairo (31.3E 30.1N) '2011-06-21T12:00:00' MEZ+1h + ) + + forAll(testCases) { (omega, delta, latitude, alphaSSol) => + When("the solar altitude angle is calculated") + val alphaSCalc = pvModel.calcSolarAltitudeAngleAlphaS( + Radians(omega), + Radians(delta), + Radians(latitude), + ) + + Then("result should match the test data") + alphaSCalc should approximate(Radians(alphaSSol)) + } + } + + "calculate the zenith angle thetaZ correctly" in { + val testCases = Table( + ("alphaS", "thetaZSol"), + (0d, 1.5707963267948966d), // 0° + (0.785398163397448d, 0.7853981633974486d), // 45° + (1.570796326794897d, -4.440892098500626e-16d), // 90° + ) + + forAll(testCases) { (alphaS, thetaZSol) => + When("the zenith angle is calculated") + val thetaZCalc = pvModel.calcZenithAngleThetaZ(Radians(alphaS)) + + Then("result should match the test data") + thetaZCalc should approximate(Radians(thetaZSol)) + } + } + + "calculate the air mass correctly" in { + val testCases = Table( + ("thetaZ", "airMassSol"), + (0d, 1d), // 0° + (0.785398163397448d, 1.41321748045965d), // 45° + (1.570796326794897d, 37.640108631323025d), // 90° + ) + + forAll(testCases) { (thetaZ, airMassSol) => + When("the air mass is calculated") + val airMassCalc = pvModel.calcAirMass(Radians(thetaZ)) + + Then("result should match the test data") + airMassCalc shouldEqual airMassSol +- 1e-10 // the "approximate" function does not work for doubles, therefore the "shouldEqual" function is used + } + } + + "calculate the extraterrestrial radiation I0 correctly" in { + val testCases = Table( + ("j", "I0Sol"), + (0d, 1414.91335d), // Jan 1st + (2.943629280897834d, 1322.494291080537598d), // Jun 21st + (4.52733626243351d, 1355.944773587800003d), // Sep 21st + ) + + forAll(testCases) { (j, I0Sol) => + When("the extraterrestrial radiation is calculated") + val I0Calc = pvModel.calcExtraterrestrialRadiationI0(Radians(j)) + + Then("result should match the test data") + I0Calc should approximate(WattHoursPerSquareMeter(I0Sol)) + } + } + + "calculate the angle of incidence thetaG correctly" in { + val testCases = Table( + ( + "latitudeDeg", + "deltaDeg", + "omegaDeg", + "gammaEDeg", + "alphaEDeg", + "thetaGOut", + ), + (43d, -14d, -22.5d, 45d, 15d, + 35.176193345578606393727080835951995075234213360724d), // Duffie + ( + 51.516667d, + +18.4557514d, + -15.00225713d, + 30d, + +0d, + 14.420271449960715d, + ), // Iqbal + ( + 51.516667d, + +18.4557514d, + -15.00225713d, + 90d, + +0d, + 58.65287310017624d, + ), // Iqbal + ( + 35.0d, + +23.2320597d, + +30.00053311d, + 45d, + 10d, + 39.62841449023577d, + ), // Kalogirou - Solar Energy Engineering Example 2.7 ISBN 978-0-12-374501-9; DOI https://doi.org/10.1016/B978-0-12-374501-9.X0001-5 + ( + 35.0d, + +23.2320597d, + +30.00053311d, + 45d, + 90d, + 18.946300807438607d, + ), // Kalogirou - Solar Energy Engineering Example 2.7 changed to 90° panel azimuth to WEST + ( + 35.0d, + +23.2320597d, + +74.648850625d, + 45d, + 90d, + 21.95480347380729d, + ), // Kalogirou - Solar Energy Engineering Example 2.7 90° panel azimuth to WEST at 17:00 + ( + 35.0d, + +23.2320597d, + +74.648850625d, + 45d, + -90d, + 109.00780288303966d, + ), // Kalogirou - Solar Energy Engineering Example 2.7 90° panel azimuth to EAST at 17:00 + ( + 27.96d, + -17.51d, + -11.1d, + 30d, + +10d, + 22.384603601536398d, + ), // Goswami Principles of Solar Engineering Example 2.7a + ( + -35.3d, + -17.51d, + -4.2d, + 30d, + +170d, + 14.882390116876563d, + ), // Goswami Principles of Solar Engineering Example 2.7b + (40d, -11.6d, 82.5d, 60d, 0d, 79.11011928744357d), + (40d, -11.6d, -82.5d, 60d, 0d, + 79.11011928744357d), // inverse hour angle + (40d, -11.6d, 78.0d, 60d, 0d, 74.92072065185143d), + (40d, -11.6d, -78.0d, 60d, 0d, + 74.92072065185143d), // inverse hour angle + (45d, -7.15d, -82.5d, 0d, 0d, + 89.79565474295107d), // Duffie Solar Engineering of Thermal Processes example 2.14.1 + + ) + + /** Calculate the angle of incidence of beam radiation on a surface + * located at a latitude at a certain hour angle (solar time) on a given + * declination (date) if the surface is tilted by a certain slope from + * the horizontal and pointed to a certain panel azimuth west of south. + */ + + forAll(testCases) { + (latitudeDeg, deltaDeg, omegaDeg, gammaEDeg, alphaEDeg, thetaGOut) => + Given("using the input data") + + When("the angle of incidence is calculated") + val thetaG = pvModel.calcAngleOfIncidenceThetaG( + Degrees(deltaDeg), + Degrees(latitudeDeg), + Degrees(gammaEDeg), + Degrees(alphaEDeg), + Degrees(omegaDeg), + ) + + Then("result should match the test data") + thetaG should approximate(Degrees(thetaGOut)) + } + } + + "deliver correct angles of incidence at different latitudes and slope angles" in { + val testCases = Table( + ( + "latitudeDeg", + "deltaDeg", + "omegaDeg", + "gammaEDeg", + "alphaEDeg", + "thetaGOut", + ), + (45d, -7.15d, -82.5d, 60d, 0d, 80.94904834048776d), // thetaG + (15d, -7.15, -82.5d, 30d, 0d, + 80.94904834048776d), // same test but 30° South with 30° less sloped surface + (0d, -7.15, -82.5d, 15d, 0d, + 80.94904834048776d), // same test but 15° South with 15° less sloped surface + (-15d, -7.15, -82.5d, 0d, 0d, + 80.94904834048776d), // same test but 15° South with 15° less sloped surface + (-30d, -7.15, -82.5d, 15d, 180d, + 80.94904834048776d), // same test but 15° South with 15° more sloped surface (Surface is now facing north, since it is in the southern hemisphere, therefore the surface azimuth is 180°) + (52.3d, 23.4337425, 2.15114395d, 0d, 0d, + 28.91315041538251d), // Berlin 21.06. 12:00 => thetaG = 90 - alphaS + (70.3d, 23.4337425, 2.15114395d, 18d, 0d, + 28.91315041538251d), // same test but 18° North with 18° sloped surface + + ) + + /** Iqbal Figure 1.6.2 - the angle of incidence of a surface sloped by + * angle beta (gammaE) at latitude phi should be same as the angle of + * incidence of an "unsloped" (horizontal) surface (where the angle of + * incidence is equal to the zenith angle of the sun) positioned at + * latitude phi - beta. Note that this is only true if the surface is + * facing directly north or south. + */ + forAll(testCases) { + (latitudeDeg, deltaDeg, omegaDeg, gammaEDeg, alphaEDeg, thetaGOut) => + Given("using pre-calculated parameters") + + When("the angle of incidence is calculated") + val thetaG = pvModel.calcAngleOfIncidenceThetaG( + Degrees(deltaDeg), + Degrees(latitudeDeg), + Degrees(gammaEDeg), + Degrees(alphaEDeg), + Degrees(omegaDeg), + ) + + Then("the result should match the pre-calculated data") + thetaG should approximate(Degrees(thetaGOut)) + } + } + + "calculate the estimate beam radiation eBeamS correctly" in { + val testCases = Table( + ( + "latitudeDeg", + "gammaEDeg", + "alphaEDeg", + "deltaDeg", + "omegaDeg", + "thetaGDeg", + "eBeamSSol", + ), + (40d, 0d, 0d, -11.6d, -37.5d, 37.0d, + 67.777778d), // flat surface => eBeamS = eBeamH + (40d, 60d, 0d, -11.6d, -37.5d, 37.0d, + 112.84217113154841369d), // 2011-02-20T09:00:00 + (40d, 60d, 0d, -11.6d, -78.0d, 75.0d, 210.97937494450755d), // sunrise + (40d, 60d, 0d, -11.6d, 62.0d, 76.0d, 199.16566536224116d), // sunset + (40d, 60d, 0d, -11.6d, 69.0d, 89.9d, + 245.77637766673405d), // sunset, cut off + (40d, 60d, 0d, -11.6d, 75.0d, 89.9d, 0d), // no sun + (40d, 60d, -90.0d, -11.6d, 60.0d, 91.0d, 0d), // no direct beam + ) + + /** For a given hour angle, the estimate beam radiation on a sloped + * surface is calculated for the next 60 minutes. Reference p.95 + * https://www.sku.ac.ir/Datafiles/BookLibrary/45/John%20A.%20Duffie,%20William%20A.%20Beckman(auth.)-Solar%20Engineering%20of%20Thermal%20Processes,%20Fourth%20Edition%20(2013).pdf + */ + forAll(testCases) { + ( + latitudeDeg, + gammaEDeg, + alphaEDeg, + deltaDeg, + omegaDeg, + thetaGDeg, + eBeamSSol, + ) => + Given("using the input data") + // Beam Radiation on a horizontal surface + val eBeamH = + 67.777778d // 1 MJ/m^2 = 277,778 Wh/m^2 -> 0.244 MJ/m^2 = 67.777778 Wh/m^2 + val omegaSS = pvModel.calcSunsetAngleOmegaSS( + Degrees(latitudeDeg), + Degrees(deltaDeg), + ) // Sunset angle + val omegaSR = -omegaSS // Sunrise angle + val omegas = pvModel.calculateBeamOmegas( + Degrees(thetaGDeg), + Degrees(omegaDeg), + omegaSS, + omegaSR, + ) // omega1 and omega2 + + When("the beam radiation is calculated") + val eBeamSCalc = pvModel.calcBeamRadiationOnSlopedSurface( + WattHoursPerSquareMeter(eBeamH), + omegas, + Degrees(deltaDeg), + Degrees(latitudeDeg), + Degrees(gammaEDeg), + Degrees(alphaEDeg), + ) + + Then("result should match the test data") + eBeamSCalc should approximate(WattHoursPerSquareMeter(eBeamSSol)) + } + } + + "calculate the estimate diffuse radiation eDifS correctly" in { + val testCases = Table( + ("thetaGDeg", "thetaZDeg", "gammaEDeg", "airMass", "I0", "eDifSSol"), + (37.0d, 62.2d, 60d, 2.13873080095658d, 1399.0077631849722d, + 216.46615469650982d), + ) + + forAll(testCases) { + (thetaGDeg, thetaZDeg, gammaEDeg, airMass, I0, eDifSSol) => + // Reference p.95 + // https://www.sku.ac.ir/Datafiles/BookLibrary/45/John%20A.%20Duffie,%20William%20A.%20Beckman(auth.)-Solar%20Engineering%20of%20Thermal%20Processes,%20Fourth%20Edition%20(2013).pdf + Given("using the input data") + // Beam Radiation on horizontal surface + val eBeamH = + 67.777778d // 1 MJ/m^2 = 277,778 Wh/m^2 -> 0.244 MJ/m^2 = 67.777778 Wh/m^2 + // Diffuse Radiation on a horizontal surface + val eDifH = 213.61111d // 0.769 MJ/m^2 = 213,61111 Wh/m^2 + + When("the diffuse radiation is calculated") + val eDifSCalc = pvModel.calcDiffuseRadiationOnSlopedSurfacePerez( + WattHoursPerSquareMeter(eDifH), + WattHoursPerSquareMeter(eBeamH), + airMass, + WattHoursPerSquareMeter(I0), + Degrees(thetaZDeg), + Degrees(thetaGDeg), + Degrees(gammaEDeg), + ) + + Then("result should match the test data") + eDifSCalc should approximate(WattHoursPerSquareMeter(eDifSSol)) + } + } + + "calculate the ground reflection eRefS" in { + val testCases = Table( + ("gammaEDeg", "albedo", "eRefSSol"), + (60d, 0.60d, 42.20833319999999155833336d), // '2011-02-20T09:00:00' + ) + + forAll(testCases) { (gammaEDeg, albedo, eRefSSol) => + Given("using the input data") + // Beam Radiation on horizontal surface + val eBeamH = + 67.777778d // 1 MJ/m^2 = 277,778 Wh/m^2 -> 0.244 MJ/m^2 = 67.777778 Wh/m^2 + // Diffuse Radiation on a horizontal surface + val eDifH = 213.61111d // 0.769 MJ/m^2 = 213,61111 Wh/m^2 + + When("the ground reflection is calculated") + val eRefSCalc = pvModel.calcReflectedRadiationOnSlopedSurface( + WattHoursPerSquareMeter(eBeamH), + WattHoursPerSquareMeter(eDifH), + Degrees(gammaEDeg), + albedo, + ) + + Then("result should match the test data") + eRefSCalc should approximate(WattHoursPerSquareMeter(eRefSSol)) + } + } + } +} diff --git a/src/test/scala/edu/ie3/simona/model/participant2/StorageModelSpec.scala b/src/test/scala/edu/ie3/simona/model/participant2/StorageModelSpec.scala new file mode 100644 index 0000000000..7fb6b9df39 --- /dev/null +++ b/src/test/scala/edu/ie3/simona/model/participant2/StorageModelSpec.scala @@ -0,0 +1,515 @@ +/* + * © 2022. TU Dortmund University, + * Institute of Energy Systems, Energy Efficiency and Energy Economics, + * Research group Distribution grid planning and operation + */ + +package edu.ie3.simona.model.participant2 + +import edu.ie3.datamodel.models.OperationTime +import edu.ie3.datamodel.models.input.system.StorageInput +import edu.ie3.datamodel.models.input.system.`type`.StorageTypeInput +import edu.ie3.datamodel.models.input.system.characteristic.CosPhiFixed +import edu.ie3.datamodel.models.input.{NodeInput, OperatorInput} +import edu.ie3.datamodel.models.voltagelevels.GermanVoltageLevelUtils +import edu.ie3.simona.config.SimonaConfig.StorageRuntimeConfig +import edu.ie3.simona.model.participant2.ParticipantModel.{ + ActivePowerOperatingPoint, + ModelInput, +} +import edu.ie3.simona.ontology.messages.flex.MinMaxFlexibilityMessage.ProvideMinMaxFlexOptions +import edu.ie3.simona.test.common.UnitSpec +import edu.ie3.util.TimeUtil +import edu.ie3.util.quantities.PowerSystemUnits +import edu.ie3.util.quantities.PowerSystemUnits._ +import edu.ie3.util.scala.quantities.DefaultQuantities.zeroKW +import org.scalatest.matchers.should.Matchers +import squants.energy.{KilowattHours, Kilowatts} +import squants.{Each, Energy, Power} +import tech.units.indriya.quantity.Quantities +import tech.units.indriya.quantity.Quantities.getQuantity + +import java.time.ZonedDateTime +import java.util.UUID + +class StorageModelSpec extends UnitSpec with Matchers { + + final val inputModel: StorageInput = createStorageInput() + implicit val powerTolerance: Power = Kilowatts(1e-10) + implicit val energyTolerance: Energy = KilowattHours(1e-10) + + private val dateTime: ZonedDateTime = + TimeUtil.withDefaults.toZonedDateTime("2020-01-02T03:04:05Z") + + def createStorageInput(): StorageInput = { + val nodeInput = new NodeInput( + UUID.fromString("ad39d0b9-5ad6-4588-8d92-74c7d7de9ace"), + "NodeInput", + OperatorInput.NO_OPERATOR_ASSIGNED, + OperationTime.notLimited(), + Quantities.getQuantity(1, PowerSystemUnits.PU), + false, + NodeInput.DEFAULT_GEO_POSITION, + GermanVoltageLevelUtils.LV, + -1, + ) + + val typeInput = new StorageTypeInput( + UUID.fromString("fbee4995-24dd-45e4-9c85-7d986fe99ff3"), + "Test_StorageTypeInput", + Quantities.getQuantity(10000d, EURO), + getQuantity(0.05d, EURO_PER_MEGAWATTHOUR), + Quantities.getQuantity(100d, KILOWATTHOUR), + getQuantity(13d, KILOVOLTAMPERE), + 0.997, + getQuantity(10d, KILOWATT), + getQuantity(0.03, PU_PER_HOUR), + getQuantity(0.9, PU), + ) + + new StorageInput( + UUID.randomUUID(), + "Test_StorageInput", + new OperatorInput(UUID.randomUUID(), "NO_OPERATOR"), + OperationTime.notLimited(), + nodeInput, + CosPhiFixed.CONSTANT_CHARACTERISTIC, + null, + typeInput, + ) + } + + def buildStorageModel( + targetSoc: Option[Double] = Option.empty + ): StorageModel = { + val runtimeConfig = new StorageRuntimeConfig( + calculateMissingReactivePowerWithModel = false, + scaling = 1d, + uuids = List.empty, // not used + initialSoc = 0d, // not used + targetSoc = targetSoc, + ) + + StorageModel.apply( + inputModel, + runtimeConfig, + ) + } + + "StorageModel" should { + + "Determine the current state" in { + val storageModel = buildStorageModel() + + val lastTick = 3600L + + val testCases = Table( + ("lastEnergy", "power", "duration", "expEnergy"), + /* empty storage */ + // zero power + (0.0, 0.0, 3600, 0.0), + // zero duration + (0.0, 5.0, 0, 0.0), + // charging a tiny bit + (0.0, 1.0, 1, 0.00025), + // charging until half + (0.0, 10.0, 20000, 50.0), + // charging until almost full + (0.0, 10.0, 39999, 99.9975), + // charging until full + (0.0, 10.0, 40000, 100.0), + // overcharging a tiny bit + (0.0, 10.0, 40001, 100.0), + // discharging + (0.0, -10.0, 3600, 0.0), + /* half full storage */ + // zero power + (50.0, 0.0, 3600, 50.0), + // zero duration + (50.0, 5.0, 0, 50.0), + // charging a tiny bit + (50.0, 1.0, 1, 50.00025), + // charging until almost full + (50.0, 10.0, 19999, 99.9975), + // charging until full + (50.0, 10.0, 20000, 100.0), + // overcharging a tiny bit + (50.0, 10.0, 20001, 100.0), + // discharging a tiny bit + (50.0, -0.81, 1, 49.99975), + // discharging until almost empty + (50.0, -8.1, 19999, 0.0025), + // discharging until empty + (50.0, -8.1, 20000, 0.0), + // undercharging a tiny bit + (50.0, -8.1, 20001, 0.0), + /* full storage */ + // zero power + (100.0, 0.0, 3600, 100.0), + // zero duration + (100.0, -5.0, 0, 100.0), + // discharging a tiny bit + (100.0, -0.81, 1, 99.99975), + // discharging until half + (100.0, -8.1, 20000, 50.0), + // discharging until almost empty + (100.0, -8.1, 39999, 0.0025), + // discharging until empty + (100.0, -8.1, 40000, 0.0), + // undercharging a tiny bit + (100.0, -8.1, 40001, 0.0), + // charging + (100.0, 10.0, 3600, 100.0), + ) + + forAll(testCases) { + (lastEnergy: Double, power: Double, duration: Int, expEnergy: Double) => + val lastState = StorageModel.StorageState( + KilowattHours(lastEnergy), + lastTick, + ) + + val operatingPoint = + ActivePowerOperatingPoint(Kilowatts(power)) + + val currentTick = lastTick + duration + + val input = ModelInput( + Seq.empty, + Each(1), + currentTick, + dateTime, + ) + + val newState = storageModel.determineState( + lastState, + operatingPoint, + input, + ) + + newState.tick shouldBe currentTick + newState.storedEnergy should approximate(KilowattHours(expEnergy)) + } + } + + "Calculate flex options" in { + val storageModel = buildStorageModel() + val tick = 3600L + + val testCases = Table( + ("storedEnergy", "pRef", "pMin", "pMax"), + // completely empty + (0.0, 0.0, 0.0, 10.0), + // at a tiny bit above empty + (0.011, 0.0, -10.0, 10.0), + // at mid-level charge + (60.0, 0.0, -10.0, 10.0), + // almost fully charged + (99.989, 0.0, -10.0, 10.0), + // fully charged + (100.0, 0.0, -10.0, 0.0), + ) + + forAll(testCases) { + (storedEnergy: Double, pRef: Double, pMin: Double, pMax: Double) => + val state = StorageModel.StorageState( + KilowattHours(storedEnergy), + tick, + ) + + storageModel.calcFlexOptions(state) match { + case result: ProvideMinMaxFlexOptions => + result.ref should approximate(Kilowatts(pRef)) + result.min should approximate(Kilowatts(pMin)) + result.max should approximate(Kilowatts(pMax)) + case _ => + fail("Expected result of type ProvideMinMaxFlexOptions") + } + } + } + + "Calculate flex options with target SOC" in { + val storageModel = buildStorageModel(Some(0.5d)) + val tick = 3600L + + val testCases = Table( + ("storedEnergy", "pRef", "pMin", "pMax"), + // completely empty + (0.0, 10.0, 0.0, 10.0), + // below margin of ref power target + (49.9974, 10.0, -10.0, 10.0), + // within margin below ref power target + (49.9976, 0.0, -10.0, 10.0), + // exactly at ref power target + (50.0, 0.0, -10.0, 10.0), + // within margin above ref power target + (50.0030, 0.0, -10.0, 10.0), + // above margin of ref power target + (50.0031, -10.0, -10.0, 10.0), + // at mid-level charge + (60.0, -10.0, -10.0, 10.0), + // fully charged + (100.0, -10.0, -10.0, 0.0), + ) + + forAll(testCases) { + (storedEnergy: Double, pRef: Double, pMin: Double, pMax: Double) => + val state = StorageModel.StorageState( + KilowattHours(storedEnergy), + tick, + ) + + storageModel.calcFlexOptions(state) match { + case result: ProvideMinMaxFlexOptions => + result.ref should approximate(Kilowatts(pRef)) + result.min should approximate(Kilowatts(pMin)) + result.max should approximate(Kilowatts(pMax)) + case _ => + fail("Expected result of type ProvideMinMaxFlexOptions") + } + } + } + + "Handle controlled power change" in { + val storageModel = buildStorageModel() + val tick = 3600L + // not used in calculation + val flexOptions = + ProvideMinMaxFlexOptions.noFlexOption(inputModel.getUuid, zeroKW) + + val testCases = Table( + ( + "storedEnergy", + "setPower", + "expPower", + "expActiveNext", + "expScheduled", + "expDelta", + ), + // no power + (0.0, 0.0, 0.0, false, false, 0.0), + (50.0, 0.0, 0.0, false, false, 0.0), + (100.0, 0.0, 0.0, false, false, 0.0), + // charging on empty + (0.0, 1.0, 1.0, true, true, 100 * 3600 / 0.9), + (0.0, 2.5, 2.5, true, true, 40 * 3600 / 0.9), + (0.0, 5.0, 5.0, true, true, 20 * 3600 / 0.9), + (0.0, 10.0, 10.0, true, true, 10 * 3600 / 0.9), + // charging on half full + (50.0, 5.0, 5.0, false, true, 10 * 3600 / 0.9), + (50.0, 10.0, 10.0, false, true, 5 * 3600 / 0.9), + // discharging on half full + (50.0, -4.5, -4.5, false, true, 10 * 3600.0), + (50.0, -9.0, -9.0, false, true, 5 * 3600.0), + // discharging on full + (100.0, -4.5, -4.5, true, true, 20 * 3600.0), + (100.0, -9.0, -9.0, true, true, 10 * 3600.0), + ) + + forAll(testCases) { + ( + storedEnergy: Double, + setPower: Double, + expPower: Double, + expActiveNext: Boolean, + expScheduled: Boolean, + expDelta: Double, + ) => + val state = StorageModel.StorageState( + KilowattHours(storedEnergy), + tick, + ) + + val (operatingPoint, changeIndicator) = + storageModel.handlePowerControl( + state, + flexOptions, + Kilowatts(setPower), + ) + + operatingPoint.activePower should approximate(Kilowatts(expPower)) + + changeIndicator.changesAtTick.isDefined shouldBe expScheduled + changeIndicator.changesAtTick.forall( + _ == (tick + expDelta) + ) shouldBe true + changeIndicator.changesAtNextActivation shouldBe expActiveNext + } + } + + "Handle controlled power change with ref target SOC" in { + val storageModel = buildStorageModel(Some(0.5d)) + val tick = 3600L + // not used in calculation + val flexOptions = + ProvideMinMaxFlexOptions.noFlexOption(inputModel.getUuid, zeroKW) + + val testCases = Table( + ( + "storedEnergy", + "setPower", + "expPower", + "expActiveNext", + "expScheduled", + "expDelta", + ), + // no power + (0.0, 0.0, 0.0, false, false, 0.0), + (50.0, 0.0, 0.0, false, false, 0.0), + (100.0, 0.0, 0.0, false, false, 0.0), + // charging on empty + (0.0, 1.0, 1.0, true, true, 50 * 3600 / 0.9), + (0.0, 2.5, 2.5, true, true, 20 * 3600 / 0.9), + (0.0, 5.0, 5.0, true, true, 10 * 3600 / 0.9), + (0.0, 10.0, 10.0, true, true, 5 * 3600 / 0.9), + // charging on target ref + (50.0, 5.0, 5.0, true, true, 10 * 3600 / 0.9), + (50.0, 10.0, 10.0, true, true, 5 * 3600 / 0.9), + // discharging on target ref + (50.0, -4.5, -4.5, true, true, 10 * 3600.0), + (50.0, -9.0, -9.0, true, true, 5 * 3600.0), + // discharging on full + (100.0, -4.5, -4.5, true, true, 10 * 3600.0), + (100.0, -9.0, -9.0, true, true, 5 * 3600.0), + ) + + forAll(testCases) { + ( + storedEnergy: Double, + setPower: Double, + expPower: Double, + expActiveNext: Boolean, + expScheduled: Boolean, + expDelta: Double, + ) => + val state = StorageModel.StorageState( + KilowattHours(storedEnergy), + tick, + ) + + val (operatingPoint, changeIndicator) = + storageModel.handlePowerControl( + state, + flexOptions, + Kilowatts(setPower), + ) + + operatingPoint.activePower should approximate(Kilowatts(expPower)) + + changeIndicator.changesAtTick.isDefined shouldBe expScheduled + changeIndicator.changesAtTick.forall( + _ == (tick + expDelta) + ) shouldBe true + changeIndicator.changesAtNextActivation shouldBe expActiveNext + } + } + + "Handle the edge case of discharging in tolerance margins" in { + val storageModel = buildStorageModel() + val tick = 1800L + // not used in calculation + val flexOptions = + ProvideMinMaxFlexOptions.noFlexOption(inputModel.getUuid, zeroKW) + + // margin is at ~ 0.0030864 kWh + val state = StorageModel.StorageState( + KilowattHours(0.002d), + tick, + ) + + val (operatingPoint, changeIndicator) = + storageModel.handlePowerControl( + state, + flexOptions, + Kilowatts(-5d), + ) + + operatingPoint.activePower should approximate(zeroKW) + + changeIndicator.changesAtTick.isDefined shouldBe false + changeIndicator.changesAtNextActivation shouldBe true + } + + "Handle the edge case of charging in tolerance margins" in { + val storageModel = buildStorageModel() + val tick = 1800L + // not used in calculation + val flexOptions = + ProvideMinMaxFlexOptions.noFlexOption(inputModel.getUuid, zeroKW) + + // margin is at ~ 99.9975 kWh + val state = StorageModel.StorageState( + KilowattHours(99.999d), + tick, + ) + + val (operatingPoint, changeIndicator) = + storageModel.handlePowerControl( + state, + flexOptions, + Kilowatts(9d), + ) + + operatingPoint.activePower should approximate(zeroKW) + + changeIndicator.changesAtTick.isDefined shouldBe false + changeIndicator.changesAtNextActivation shouldBe true + } + + "Handle the edge case of discharging in positive target margin" in { + val storageModel = buildStorageModel(Some(0.3d)) + val tick = 1800L + // not used in calculation + val flexOptions = + ProvideMinMaxFlexOptions.noFlexOption(inputModel.getUuid, zeroKW) + + // margin is at ~ 30.0025 kWh + val state = StorageModel.StorageState( + KilowattHours(30.0024d), + tick, + ) + + val (operatingPoint, changeIndicator) = + storageModel.handlePowerControl( + state, + flexOptions, + Kilowatts(-9d), + ) + + operatingPoint.activePower should approximate(Kilowatts(-9d)) + + changeIndicator.changesAtTick should be( + Some(tick + 10801L) + ) + changeIndicator.changesAtNextActivation shouldBe true + } + + "Handle the edge case of charging in negative target margin" in { + val storageModel = buildStorageModel(Some(0.4d)) + val tick = 1800L + // not used in calculation + val flexOptions = + ProvideMinMaxFlexOptions.noFlexOption(inputModel.getUuid, zeroKW) + + // margin is at ~ 39.9975 kWh + val state = StorageModel.StorageState( + KilowattHours(39.998d), + tick, + ) + + val (operatingPoint, changeIndicator) = + storageModel.handlePowerControl( + state, + flexOptions, + Kilowatts(5d), + ) + + operatingPoint.activePower should approximate(Kilowatts(5d)) + + changeIndicator.changesAtTick should be( + Some(tick + 48002L) + ) + changeIndicator.changesAtNextActivation shouldBe true + } + } +} diff --git a/src/test/scala/edu/ie3/simona/model/participant2/WecModelSpec.scala b/src/test/scala/edu/ie3/simona/model/participant2/WecModelSpec.scala new file mode 100644 index 0000000000..42fa3bb4ac --- /dev/null +++ b/src/test/scala/edu/ie3/simona/model/participant2/WecModelSpec.scala @@ -0,0 +1,196 @@ +/* + * © 2020. TU Dortmund University, + * Institute of Energy Systems, Energy Efficiency and Energy Economics, + * Research group Distribution grid planning and operation + */ + +package edu.ie3.simona.model.participant2 + +import edu.ie3.datamodel.models.OperationTime +import edu.ie3.datamodel.models.input.system.WecInput +import edu.ie3.datamodel.models.input.system.`type`.WecTypeInput +import edu.ie3.datamodel.models.input.system.characteristic.{ + ReactivePowerCharacteristic, + WecCharacteristicInput, +} +import edu.ie3.datamodel.models.input.{NodeInput, OperatorInput} +import edu.ie3.datamodel.models.voltagelevels.GermanVoltageLevelUtils +import edu.ie3.simona.model.participant2.WecModel.WecState +import edu.ie3.simona.test.common.{DefaultTestData, UnitSpec} +import edu.ie3.util.quantities.PowerSystemUnits +import squants.Each +import squants.energy.Watts +import squants.motion.{MetersPerSecond, Pascals} +import squants.thermal.Celsius +import tech.units.indriya.quantity.Quantities +import tech.units.indriya.unit.Units.{METRE, PERCENT, SQUARE_METRE} + +import java.util.UUID + +class WecModelSpec extends UnitSpec with DefaultTestData { + + val nodeInput = new NodeInput( + UUID.fromString("ad39d0b9-5ad6-4588-8d92-74c7d7de9ace"), + "NodeInput", + OperatorInput.NO_OPERATOR_ASSIGNED, + OperationTime.notLimited(), + Quantities.getQuantity(1, PowerSystemUnits.PU), + false, + NodeInput.DEFAULT_GEO_POSITION, + GermanVoltageLevelUtils.LV, + -1, + ) + + val typeInput = new WecTypeInput( + UUID.randomUUID(), + "WecTypeInput", + Quantities.getQuantity(1000, PowerSystemUnits.EURO), + Quantities.getQuantity(1000, PowerSystemUnits.EURO_PER_MEGAWATTHOUR), + Quantities.getQuantity(1200.0, PowerSystemUnits.KILOVOLTAMPERE), + 0.95, + new WecCharacteristicInput( + "cP:{(0.0,0.0), (1.0,0.0), (2.0,0.115933516), (3.0,0.286255595), (4.0,0.39610618), " + + "(5.0,0.430345211), (6.0,0.45944023), (7.0,0.479507331), (8.0,0.492113623), " + + "(9.0,0.500417188), (10.0,0.488466547), (11.0,0.420415054), (12.0,0.354241299), " + + "(13.0,0.288470591), (14.0,0.230965702), (15.0,0.18778367), (16.0,0.154728976), " + + "(17.0,0.128998552), (18.0,0.108671106), (19.0,0.09239975), (20.0,0.079221236), " + + "(21.0,0.068434282), (22.0,0.059520087), (23.0,0.052089249), (24.0,0.045845623), " + + "(25.0,0.040561273), (26.0,0.036058824), (27.0,0.032198846), (28.0,0.016618264), " + + "(29.0,0.010330976), (30.0,0.006091519), (31.0,0.003331177), (32.0,0.001641637), " + + "(33.0,0.000705423), (34.0,0.000196644), (35.0,0.0), (36.0,0.0), (37.0,0.0), (38.0,0.0), " + + "(39.0,0.0), (40.0,0.0), (41.0,0.0), (42.0,0.0), (43.0,0.0), (44.0,0.0), (45.0,0.0), " + + "(46.0,0.0), (47.0,0.0), (48.0,0.0), (49.0,0.0), (50.0,0.0)}" + ), + Quantities.getQuantity(15, PERCENT), + Quantities.getQuantity(5281.0, SQUARE_METRE), + Quantities.getQuantity(20, METRE), + ) + + val inputModel = new WecInput( + UUID.randomUUID(), + "WecTypeInput", + nodeInput, + ReactivePowerCharacteristic.parse("cosPhiFixed:{(0.00,0.95)}"), + null, + typeInput, + false, + ) + + "WecModel" should { + + "check build method of companion object" in { + val wecModel = WecModel.apply(inputModel) + wecModel.uuid shouldBe inputModel.getUuid + wecModel.cosPhiRated shouldBe typeInput.getCosPhiRated + wecModel.sRated.toVoltamperes shouldBe (typeInput.getsRated.toSystemUnit.getValue + .doubleValue() +- 1e-5) + } + + "determine Betz coefficient correctly" in { + val wecModel = WecModel.apply(inputModel) + + val testCases = Table( + ("velocity", "expectedBetzResult"), + (2.0, 0.115933516), + (2.5, 0.2010945555), + (18.0, 0.108671106), + (27.0, 0.032198846), + (34.0, 0.000196644), + (40.0, 0.0), + ) + + forAll(testCases) { case (velocity: Double, expectedBetzResult: Double) => + val windVel = MetersPerSecond(velocity) + val betzFactor = wecModel.determineBetzCoefficient(windVel) + + betzFactor shouldEqual Each(expectedBetzResult) + } + } + + "calculate active power output depending on velocity" in { + val wecModel = WecModel.apply(inputModel) + val testCases = Table( + ("velocity", "expectedPower"), + (1.0, 0.0), + (2.0, -2948.8095851378266), + (3.0, -24573.41320418286), + (7.0, -522922.2325710509), + (9.0, -1140000.0), + (13.0, -1140000.0), + (15.0, -1140000.0), + (19.0, -1140000.0), + (23.0, -1140000.0), + (27.0, -1140000.0), + (34.0, -24573.39638823692), + (40.0, 0.0), + ) + + forAll(testCases) { (velocity: Double, expectedPower: Double) => + val state = WecState( + 0L, + MetersPerSecond(velocity), + Celsius(20), + Some(Pascals(101325d)), + ) + val (operatingPoint, nextTick) = + wecModel.determineOperatingPoint(state) + + operatingPoint.activePower shouldBe Watts(expectedPower) + nextTick shouldBe None + } + } + + "calculate air density correctly" in { + val wecModel = WecModel.apply(inputModel) + val testCases = Seq( + (-15.0, 100129.44, 1.3512151548083537), + (-5.0, 99535.96, 1.2931147269065832), + (0.0, 99535.96, 1.2694443127219486), + (5.0, 100129.44, 1.25405785444464), + (20.0, 100129.44, 1.1898897909390296), + (25.0, 100427.25, 1.1734149214121568), + (37.0, 100427.25, 1.1280143763309192), + // test cases where no air pressure is given + (0.0, -1.0, 1.2041), + (5.0, -1.0, 1.2041), + (40.0, -1.0, 1.2041), + ) + + testCases.foreach { case (temperature, pressure, densityResult) => + val temperatureV = Celsius(temperature) + val pressureV = + if (pressure > 0) Some(Pascals(pressure)) else Option.empty + + val airDensity = wecModel + .calculateAirDensity(temperatureV, pressureV) + .toKilogramsPerCubicMeter + + airDensity should be(densityResult) + } + } + + "calculate active power output depending on temperature" in { + val wecModel = WecModel.apply(inputModel) + val testCases = Table( + ("temperature", "expectedPower"), + (35.0, -23377.23862017266), + (20.0, -24573.41320418286), + (-25.0, -29029.60338829823), + ) + + forAll(testCases) { (temperature: Double, expectedPower: Double) => + val state = WecState( + 0L, + MetersPerSecond(3.0), + Celsius(temperature), + Some(Pascals(101325d)), + ) + val (operatingPoint, nextTick) = + wecModel.determineOperatingPoint(state) + + operatingPoint.activePower shouldBe Watts(expectedPower) + nextTick shouldBe None + } + } + } +} diff --git a/src/test/scala/edu/ie3/simona/model/participant2/evcs/ConstantPowerChargingSpec.scala b/src/test/scala/edu/ie3/simona/model/participant2/evcs/ConstantPowerChargingSpec.scala new file mode 100644 index 0000000000..ddb19ecb1b --- /dev/null +++ b/src/test/scala/edu/ie3/simona/model/participant2/evcs/ConstantPowerChargingSpec.scala @@ -0,0 +1,146 @@ +/* + * © 2022. TU Dortmund University, + * Institute of Energy Systems, Energy Efficiency and Energy Economics, + * Research group Distribution grid planning and operation + */ + +package edu.ie3.simona.model.participant2.evcs + +import edu.ie3.simona.model.participant.evcs.EvModelWrapper +import edu.ie3.simona.test.common.UnitSpec +import edu.ie3.simona.test.common.model.MockEvModel +import edu.ie3.util.quantities.QuantityUtils.RichQuantityDouble +import org.scalatest.prop.TableDrivenPropertyChecks +import squants.energy.Kilowatts + +import java.util.UUID + +class ConstantPowerChargingSpec + extends UnitSpec + with TableDrivenPropertyChecks { + + "Calculating constant power charging schedules" should { + + "not charge evs if they are fully charged" in { + val ev = EvModelWrapper( + new MockEvModel( + UUID.randomUUID(), + "Test EV", + 5.0.asKiloWatt, + 10.0.asKiloWatt, + 20.0.asKiloWattHour, + 20.0.asKiloWattHour, + 3600L, + ) + ) + + val actualSchedule = ConstantPowerCharging.determineChargingPowers( + Seq(ev), + 1800L, + MockEvcsChargingProperties, + ) + + actualSchedule shouldBe Map.empty + } + + "work correctly with one ev" in { + val offset = 1800L + + val cases = Table( + ("stayingTicks", "storedEnergy", "expectedPower"), + // empty battery + (3600L, 0.0, 5.0), // more than max power, limited + (7200L, 0.0, 5.0), // exactly max power + (14400L, 0.0, 2.5), // less than max power + (360000L, 0.0, 0.1), // long stay: 100 hours + // half full battery + (1800L, 5.0, 5.0), // more than max power, limited + (3600L, 5.0, 5.0), // exactly max power + (7200L, 5.0, 2.5), // less than max power + (180000L, 5.0, 0.1), // long stay: 100 hours + ) + + forAll(cases) { (stayingTicks, storedEnergy, expectedPower) => + val ev = EvModelWrapper( + new MockEvModel( + UUID.randomUUID(), + "Test EV", + 5.0.asKiloWatt, // using AC charging here + 10.0.asKiloWatt, + 10.0.asKiloWattHour, + storedEnergy.asKiloWattHour, + offset + stayingTicks, + ) + ) + + val chargingMap = ConstantPowerCharging.determineChargingPowers( + Seq(ev), + offset, + MockEvcsChargingProperties, + ) + + chargingMap shouldBe Map( + ev.uuid -> Kilowatts(expectedPower) + ) + } + + } + + "work correctly with two evs" in { + val offset = 3600L + + val cases = Table( + ("stayingTicks", "storedEnergy", "expectedPower"), + // empty battery + (3600L, 0.0, 5.0), // more than max power, limited + (7200L, 0.0, 5.0), // exactly max power + (14400L, 0.0, 2.5), // less than max power + (360000L, 0.0, 0.1), // long stay: 100 hours + // half full battery + (1800L, 5.0, 5.0), // more than max power, limited + (3600L, 5.0, 5.0), // exactly max power + (7200L, 5.0, 2.5), // less than max power + (180000L, 5.0, 0.1), // long stay: 100 hours + ) + + forAll(cases) { (stayingTicks, storedEnergy, expectedPower) => + val givenEv = EvModelWrapper( + new MockEvModel( + UUID.randomUUID(), + "First EV", + 5.0.asKiloWatt, // using AC charging here + 10.0.asKiloWatt, + 10.0.asKiloWattHour, + 5.0.asKiloWattHour, + offset + 3600L, + ) + ) + + val ev = EvModelWrapper( + new MockEvModel( + UUID.randomUUID(), + "Test EV", + 5.0.asKiloWatt, // using AC charging here + 10.0.asKiloWatt, + 10.0.asKiloWattHour, + storedEnergy.asKiloWattHour, + offset + stayingTicks, + ) + ) + + val chargingMap = ConstantPowerCharging.determineChargingPowers( + Seq(givenEv, ev), + offset, + MockEvcsChargingProperties, + ) + + chargingMap shouldBe Map( + givenEv.uuid -> Kilowatts(5.0), + ev.uuid -> Kilowatts(expectedPower), + ) + } + + } + } + +} diff --git a/src/test/scala/edu/ie3/simona/model/participant2/evcs/EvcsModelSpec.scala b/src/test/scala/edu/ie3/simona/model/participant2/evcs/EvcsModelSpec.scala new file mode 100644 index 0000000000..2d0b99b90f --- /dev/null +++ b/src/test/scala/edu/ie3/simona/model/participant2/evcs/EvcsModelSpec.scala @@ -0,0 +1,875 @@ +/* + * © 2022. TU Dortmund University, + * Institute of Energy Systems, Energy Efficiency and Energy Economics, + * Research group Distribution grid planning and operation + */ + +package edu.ie3.simona.model.participant2.evcs + +import edu.ie3.datamodel.models.result.system.{EvResult, EvcsResult} +import edu.ie3.simona.agent.participant.data.Data.PrimaryData.ComplexPower +import edu.ie3.simona.agent.participant2.ParticipantAgent +import edu.ie3.simona.agent.participant2.ParticipantAgent.ParticipantRequest +import edu.ie3.simona.config.SimonaConfig.EvcsRuntimeConfig +import edu.ie3.simona.model.participant.evcs.EvModelWrapper +import edu.ie3.simona.model.participant2.ParticipantModel.{ + ModelInput, + OperationChangeIndicator, +} +import edu.ie3.simona.model.participant2.evcs.EvcsModel.{ + EvcsOperatingPoint, + EvcsState, +} +import edu.ie3.simona.ontology.messages.flex.MinMaxFlexibilityMessage.ProvideMinMaxFlexOptions +import edu.ie3.simona.ontology.messages.services.EvMessage.{ + DepartingEvsRequest, + DepartingEvsResponse, + EvFreeLotsRequest, + EvResponseMessage, + FreeLotsResponse, +} +import edu.ie3.simona.test.common.UnitSpec +import edu.ie3.simona.test.common.input.EvcsInputTestData +import edu.ie3.simona.test.common.model.MockEvModel +import edu.ie3.simona.test.helper.TableDrivenHelper +import edu.ie3.util.TimeUtil +import edu.ie3.util.quantities.QuantityUtils.RichQuantityDouble +import edu.ie3.util.scala.quantities.DefaultQuantities.zeroKW +import edu.ie3.util.scala.quantities.Kilovars +import org.apache.pekko.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit +import org.apache.pekko.actor.typed.Behavior +import org.apache.pekko.actor.typed.scaladsl.Behaviors +import org.apache.pekko.actor.typed.scaladsl.adapter.TypedActorRefOps +import squants.Each +import squants.energy.{KilowattHours, Kilowatts} + +import java.time.ZonedDateTime +import java.util.UUID + +class EvcsModelSpec + extends ScalaTestWithActorTestKit + with UnitSpec + with TableDrivenHelper + with EvcsInputTestData { + + private implicit val energyTolerance: squants.Energy = KilowattHours(1e-10) + private implicit val powerTolerance: squants.Power = Kilowatts(1e-10) + + private val dateTime: ZonedDateTime = + TimeUtil.withDefaults.toZonedDateTime("2020-01-02T03:04:05Z") + + private def createModel( + chargingStrategy: String, + vehicle2Grid: Boolean = true, + ): EvcsModel = + EvcsModel( + evcsInputModel.copy().v2gSupport(vehicle2Grid).build(), + EvcsRuntimeConfig( + calculateMissingReactivePowerWithModel = false, + scaling = 1.0, + uuids = List.empty, + chargingStrategy = chargingStrategy, + lowestEvSoc = 0.2, + ), + ) + + "An EVCS model" should { + + "calculate new schedules correctly" when { + + "configured with max power charging" in { + val evcsModel = createModel("maxpower") + + val evModel = EvModelWrapper( + new MockEvModel( + UUID.randomUUID(), + "Mock EV", + 10.0.asKiloWatt, // AC is relevant, + 20.0.asKiloWatt, // DC is not + 20.0.asKiloWattHour, + 5.0.asKiloWattHour, + 10800L, + ) + ) + + val (operatingPoint, nextEvent) = evcsModel.determineOperatingPoint( + EvcsState( + Seq(evModel), + 3600L, + ) + ) + + operatingPoint.evOperatingPoints shouldBe Map( + evModel.uuid -> + // ending early at 9000 because of max power charging + Kilowatts(10.0) + ) + + nextEvent shouldBe Some(9000L) + } + + "configured with constant power charging" in { + val evcsModel = createModel("constantpower") + + val evModel = EvModelWrapper( + new MockEvModel( + UUID.randomUUID(), + "Mock EV", + 10.0.asKiloWatt, // AC is relevant, + 20.0.asKiloWatt, // DC is not + 20.0.asKiloWattHour, + 15.0.asKiloWattHour, + 10800L, + ) + ) + + val (operatingPoint, nextEvent) = evcsModel.determineOperatingPoint( + EvcsState( + Seq(evModel), + 3600L, + ) + ) + + operatingPoint.evOperatingPoints shouldBe Map( + evModel.uuid -> + // using 2.5 kW with constant power charging + Kilowatts(2.5) + ) + nextEvent shouldBe Some(10800L) + } + } + + "determining current state correctly" when { + + "being provided with a ChargingSchedule consisting of one entry" in { + val evcsModel = createModel("constantpower") + + val cases = Table( + ( + "storedEnergy", + "lastStateTick", + "currentTick", + "power", + "expectedStored", + ), + // empty battery + (0.0, 900L, 2700L, 5.0, 2.5), + (0.0, 0L, 3600L, -5.0, 0.0), + // half full battery + (5.0, 0L, 3600L, 5.0, 10.0), + (5.0, 900L, 2700L, 5.0, 7.5), + (5.0, 0L, 3600L, -5.0, 0.0), + (5.0, 900L, 2700L, -5.0, 2.5), + // full battery + (10.0, 900L, 2700L, -5.0, 7.5), + (10.0, 0L, 3600L, 5.0, 10.0), + ) + + forAll(cases) { + ( + storedEnergy, + lastStateTick, + currentTick, + power, + expectedStored, + ) => + val ev = EvModelWrapper( + new MockEvModel( + UUID.randomUUID(), + "TestEv1", + 5.0.asKiloWatt, // using AC charging here + 10.0.asKiloWatt, + 10.0.asKiloWattHour, + storedEnergy.asKiloWattHour, + 7200L, // is ignored here + ) + ) + + val state = EvcsState( + Seq(ev), + lastStateTick, + ) + + val operatingPoint = EvcsOperatingPoint( + Map(ev.uuid -> Kilowatts(power)) + ) + + val input = ModelInput( + Seq.empty, // todo test arrivals + Each(1), + currentTick, + dateTime, + ) + + val newState = evcsModel.determineState( + state, + operatingPoint, + input, + ) + + newState.evs should have size 1 + newState.tick shouldBe currentTick + + val actualEv = newState.evs.headOption.getOrElse( + fail("No charging schedule provided.") + ) + + actualEv.uuid shouldBe ev.uuid + actualEv.id shouldBe ev.id + actualEv.pRatedAc shouldBe ev.pRatedAc + actualEv.pRatedDc shouldBe ev.pRatedDc + actualEv.eStorage shouldBe ev.eStorage + actualEv.storedEnergy should approximate( + KilowattHours(expectedStored) + ) + actualEv.departureTick shouldBe ev.departureTick + + } + + } + } + + "calculate results correctly" when { + val evcsModel = createModel("constantpower") + + val ev1 = EvModelWrapper( + new MockEvModel( + UUID.randomUUID(), + "TestEv1", + 5.0.asKiloWatt, + 10.0.asKiloWatt, + 10.0.asKiloWattHour, + 5.0.asKiloWattHour, + 18000L, + ) + ) + + val ev2 = EvModelWrapper( + new MockEvModel( + UUID.randomUUID(), + "TestEv2", + 5.0.asKiloWatt, + 10.0.asKiloWatt, + 10.0.asKiloWattHour, + 7.5.asKiloWattHour, + 18000L, + ) + ) + + "two EVs are parked and charging without last operating point" in { + + val currentOperatingPoint = EvcsOperatingPoint( + Map(ev1.uuid -> Kilowatts(3.0), ev2.uuid -> Kilowatts(2.0)) + ) + + val state = EvcsState( + Seq(ev1, ev2), + 10800L, + ) + + val results = evcsModel.createResults( + state, + None, + currentOperatingPoint, + ComplexPower(Kilowatts(5), Kilovars(0.5)), + dateTime, + ) + + results should have size 3 + + results + .find(_.getInputModel == ev1.uuid) + .getOrElse(fail(s"No results for EV ${ev1.uuid}.")) match { + case evResult: EvResult => + evResult.getTime shouldBe dateTime + evResult.getP should beEquivalentTo(3.0.asKiloWatt) + evResult.getQ should beEquivalentTo(0.3.asKiloVar) + evResult.getSoc should beEquivalentTo(50.0.asPercent) + case unexpected => + fail(s"Unexpected result $unexpected was found.") + } + + results + .find(_.getInputModel == ev2.uuid) + .getOrElse(fail(s"No results for EV ${ev2.uuid}.")) match { + case evResult: EvResult => + evResult.getTime shouldBe dateTime + evResult.getP should beEquivalentTo(2.0.asKiloWatt) + evResult.getQ should beEquivalentTo(0.2.asKiloVar) + evResult.getSoc should beEquivalentTo(75.0.asPercent) + case unexpected => + fail(s"Unexpected result $unexpected was found.") + } + + results + .find(_.getInputModel == evcsModel.uuid) + .getOrElse(fail(s"No results for EVCS.")) match { + case evcsResult: EvcsResult => + evcsResult.getTime shouldBe dateTime + evcsResult.getP should beEquivalentTo(5.0.asKiloWatt) + evcsResult.getQ should beEquivalentTo(0.5.asKiloVar) + case unexpected => + fail(s"Unexpected result $unexpected was found.") + } + + } + + "two EVs are parked and charging with given last operating point" in { + + val lastOperatingPoint = EvcsOperatingPoint( + Map(ev1.uuid -> Kilowatts(3.0), ev2.uuid -> Kilowatts(2.0)) + ) + + val state = EvcsState( + Seq(ev1, ev2), + 10800L, + ) + + val cases = Table( + ("ev1P", "ev2P", "ev1Res", "ev2Res", "evcsRes"), + (4.0, 3.0, true, true, true), + (4.0, 1.0, true, true, false), + (3.0, 1.0, false, true, true), + (3.0, 2.0, false, false, false), + ) + + forAll(cases) { (ev1P, ev2P, ev1Res, ev2Res, evcsRes) => + val evcsP = ev1P + ev2P + val evcsQ = evcsP / 10 + + val currentOperatingPoint = EvcsOperatingPoint( + Map(ev1.uuid -> Kilowatts(ev1P), ev2.uuid -> Kilowatts(ev2P)) + ) + + val results = evcsModel.createResults( + state, + Some(lastOperatingPoint), + currentOperatingPoint, + ComplexPower(Kilowatts(evcsP), Kilovars(evcsQ)), + dateTime, + ) + + val expectedResults = Iterable(ev1Res, ev2Res, evcsRes).map { + if (_) 1 else 0 + }.sum + + results should have size expectedResults + + val actualEv1Result = results.find(_.getInputModel == ev1.uuid) + actualEv1Result.isDefined shouldBe ev1Res + actualEv1Result.foreach { + case evResult: EvResult => + evResult.getTime shouldBe dateTime + evResult.getP should beEquivalentTo(ev1P.asKiloWatt) + evResult.getQ should beEquivalentTo((ev1P / 10).asKiloVar) + evResult.getSoc should beEquivalentTo(50.0.asPercent) + case unexpected => + fail(s"Unexpected result $unexpected was found.") + } + + val actualEv2Result = results.find(_.getInputModel == ev2.uuid) + actualEv2Result.isDefined shouldBe ev2Res + actualEv2Result.foreach { + case evResult: EvResult => + evResult.getTime shouldBe dateTime + evResult.getP should beEquivalentTo(ev2P.asKiloWatt) + evResult.getQ should beEquivalentTo((ev2P / 10).asKiloVar) + evResult.getSoc should beEquivalentTo(75.0.asPercent) + case unexpected => + fail(s"Unexpected result $unexpected was found.") + } + + val actualEvcsResult = results.find(_.getInputModel == evcsModel.uuid) + actualEvcsResult.isDefined shouldBe evcsRes + actualEvcsResult.foreach { + case evcsResult: EvcsResult => + evcsResult.getTime shouldBe dateTime + evcsResult.getP should beEquivalentTo(evcsP.asKiloWatt) + evcsResult.getQ should beEquivalentTo(evcsQ.asKiloVar) + case unexpected => + fail(s"Unexpected result $unexpected was found.") + } + } + } + + } + + "calculate flex options correctly" when { + + "charging with constant power and allowing v2g" in { + val evcsModel = createModel("constantpower") + + val currentTick = 7200L + + val cases = Table( + ( + "stored1", + "stored2", + "expectedPRef", + "expectedPMin", + "expectedPMax", + ), + + /* 1: empty */ + // 2: empty + (0.0, 0.0, 15.0, 15.0, 15.0), + // 2: at lower margin + (0.0, 3.0, 15.0, 10.0, 15.0), + // 2: mid-way full, forced charging + (0.0, 7.5, 13.75, 10.0, 15.0), + // 2: almost full, forced charging + (0.0, 12.5, 11.25, 10.0, 15.0), + // 2: full, forced charging + (0.0, 15.0, 10.0, 10.0, 10.0), + + /* 1: at lower margin (set to 2 kWh) */ + // 2: empty + (2.0, 0.0, 13.0, 5.0, 15.0), + // 2: at lower margin + (2.0, 3.0, 13.0, 0.0, 15.0), + // 2: mid-way full (set to 7.5 kWh) + (2.0, 7.5, 11.75, -5.0, 15.0), + // 2: almost full + (2.0, 12.5, 9.25, -5.0, 15.0), + // 2: full + (2.0, 15.0, 8.0, -5.0, 10.0), + + /* 1: mid-way full (set to 5 kWh) */ + // 2: empty, forced charging + (5.0, 0.0, 10.0, 5.0, 15.0), + // 2: mid-way full (set to 7.5 kWh) + (5.0, 7.5, 8.75, -15.0, 15.0), + // 2: almost full + (5.0, 12.5, 6.25, -15.0, 15.0), + // 2: full + (5.0, 15.0, 5.0, -15.0, 10.0), + + /* 1: full (set to 10 kWh) */ + // 2: empty, forced charging + (10.0, 0.0, 5.0, 5.0, 5.0), + // 2: mid-way full + (10.0, 7.5, 3.75, -15.0, 5.0), + // 2: almost full + (10.0, 12.5, 1.25, -15.0, 5.0), + // 2: full + (10.0, 15.0, 0.0, -15.0, 0.0), + ) + + forAll(cases) { + ( + stored1, + stored2, + expectedPRef, + expectedPMin, + expectedPMax, + ) => + // stays one more hour + val ev1 = EvModelWrapper( + new MockEvModel( + UUID.randomUUID(), + "Mock EV 1", + 10.0.asKiloWatt, // AC is relevant, + 20.0.asKiloWatt, // DC is not + 10.0.asKiloWattHour, + stored1.asKiloWattHour, + 10800L, + ) + ) + + // stays two more hours + val ev2 = EvModelWrapper( + new MockEvModel( + UUID.randomUUID(), + "Mock EV 2", + 5.0.asKiloWatt, // AC is relevant, + 10.0.asKiloWatt, // DC is not + 15.0.asKiloWattHour, + stored2.asKiloWattHour, + 14400L, + ) + ) + + evcsModel.calcFlexOptions( + EvcsState( + Seq(ev1, ev2), + currentTick, + ) + ) match { + case ProvideMinMaxFlexOptions( + modelUuid, + refPower, + minPower, + maxPower, + ) => + modelUuid shouldBe evcsModel.uuid + refPower should approximate(Kilowatts(expectedPRef)) + minPower should approximate(Kilowatts(expectedPMin)) + maxPower should approximate(Kilowatts(expectedPMax)) + } + } + + } + + "charging with maximum power and allowing v2g" in { + val evcsModel = createModel("maxpower") + + val currentTick = 7200L + + val cases = Table( + ( + "stored1", + "stored2", + "expectedPRef", + "expectedPMin", + "expectedPMax", + ), + + /* 1: empty */ + // 2: empty + (0.0, 0.0, 15.0, 15.0, 15.0), + // 2: at lower margin + (0.0, 3.0, 15.0, 10.0, 15.0), + // 2: mid-way full, forced charging + (0.0, 7.5, 15.0, 10.0, 15.0), + // 2: almost full, forced charging + (0.0, 12.5, 15.0, 10.0, 15.0), + // 2: full + (0.0, 15.0, 10.0, 10.0, 10.0), + + /* 1: at lower margin (set to 2 kWh) */ + // 2: empty + (2.0, 0.0, 15.0, 5.0, 15.0), + // 2: at lower margin + (2.0, 3.0, 15.0, 0.0, 15.0), + // 2: mid-way full + (2.0, 7.5, 15.0, -5.0, 15.0), + // 2: almost full + (2.0, 12.5, 15.0, -5.0, 15.0), + // 2: full + (2.0, 15.0, 10.0, -5.0, 10.0), + + /* 1: mid-way full (set to 5 kWh) */ + // 2: empty, forced charging + (5.0, 0.0, 15.0, 5.0, 15.0), + // 2: mid-way full + (5.0, 7.5, 15.0, -15.0, 15.0), + // 2: almost full + (5.0, 12.5, 15.0, -15.0, 15.0), + // 2: full + (5.0, 15.0, 10.0, -15.0, 10.0), + + /* 1: full (set to 10 kWh) */ + // 2: empty, forced charging + (10.0, 0.0, 5.0, 5.0, 5.0), + // 2: mid-way full + (10.0, 7.5, 5.0, -15.0, 5.0), + // 2: almost full + (10.0, 12.5, 5.0, -15.0, 5.0), + // 2: full + (10.0, 15.0, 0.0, -15.0, 0.0), + ) + + forAll(cases) { + ( + stored1, + stored2, + expectedPRef, + expectedPMin, + expectedPMax, + ) => + val ev1 = EvModelWrapper( + new MockEvModel( + UUID.randomUUID(), + "Mock EV 1", + 10.0.asKiloWatt, // AC is relevant, + 20.0.asKiloWatt, // DC is not + 10.0.asKiloWattHour, + stored1.asKiloWattHour, + 10800L, + ) + ) + + val ev2 = EvModelWrapper( + new MockEvModel( + UUID.randomUUID(), + "Mock EV 2", + 5.0.asKiloWatt, // AC is relevant, + 10.0.asKiloWatt, // DC is not + 15.0.asKiloWattHour, + stored2.asKiloWattHour, + 10800L, + ) + ) + + evcsModel.calcFlexOptions( + EvcsState( + Seq(ev1, ev2), + currentTick, + ) + ) match { + case ProvideMinMaxFlexOptions( + modelUuid, + refPower, + minPower, + maxPower, + ) => + modelUuid shouldBe evcsModel.uuid + refPower should approximate(Kilowatts(expectedPRef)) + minPower should approximate(Kilowatts(expectedPMin)) + maxPower should approximate(Kilowatts(expectedPMax)) + } + } + + } + + "disallowing v2g" in { + val evcsModel = createModel("constantpower", vehicle2Grid = false) + + val currentTick = 7200L + + val ev1 = EvModelWrapper( + new MockEvModel( + UUID.randomUUID(), + "Mock EV 1", + 10.0.asKiloWatt, // AC is relevant, + 20.0.asKiloWatt, // DC is not + 10.0.asKiloWattHour, + 5.0.asKiloWattHour, + 10800L, + ) + ) + + evcsModel.calcFlexOptions( + EvcsState( + Seq(ev1), + currentTick, + ) + ) match { + case ProvideMinMaxFlexOptions( + modelUuid, + refPower, + minPower, + maxPower, + ) => + modelUuid shouldBe evcsModel.uuid + refPower should approximate(Kilowatts(5.0)) // one hour left + minPower should approximate(Kilowatts(0d)) // no v2g allowed! + maxPower should approximate(ev1.pRatedAc) + } + + } + + } + + "handle power control correctly" when { + val evcsModel = createModel("constantpower") + + "dealing with two evs" in { + val currentTick = 3600L + + // not used + val mockFlexOptions = + ProvideMinMaxFlexOptions.noFlexOption(evcsModel.uuid, zeroKW) + + val cases = Table( + ( + "stored1", + "stored2", + "setPower", + "expPower1", + "expPower2", + "expNextActivation", + "expNextTick", + ), + + /* setPower is 0 kWh */ + (0.0, 0.0, 0.0, N, N, false, N), + (10.0, 5.0, 0.0, N, N, false, N), + (5.0, 15.0, 0.0, N, N, false, N), + (10.0, 15.0, 0.0, N, N, false, N), + + /* setPower is positive (charging) */ + (0.0, 0.0, 4.0, S(2.0), S(2.0), true, S(7200L)), + (5.0, 0.0, 4.0, N, S(4.0), true, S(6300L)), + (0.0, 7.5, 4.0, S(4.0), N, true, S(5400L)), + (9.0, 0.0, 4.0, N, S(4.0), true, S(6300L)), + (5.0, 14.0, 4.0, S(2.0), S(2.0), false, S(5400L)), + (9.0, 14.0, 4.0, S(2.0), S(2.0), false, S(5400L)), + (10.0, 14.0, 4.0, N, S(4.0), false, S(4500L)), + (6.0, 15.0, 4.0, S(4.0), N, false, S(7200L)), + + /* setPower is set to > (ev2 * 2) (charging) */ + (0.0, 0.0, 13.0, S(8.0), S(5.0), true, S(4500L)), + (7.0, 0.0, 11.0, S(6.0), S(5.0), true, S(5400L)), + (0.0, 5.0, 15.0, S(10.0), S(5.0), true, S(4320L)), + (0.0, 12.5, 15.0, S(10.0), S(5.0), true, S(4320L)), + (0.0, 0.0, 15.0, S(10.0), S(5.0), true, S(4320L)), + (5.0, 7.5, 15.0, S(10.0), S(5.0), false, S(5400L)), + + /* setPower is negative (discharging) */ + (10.0, 15.0, -4.0, S(-2.0), S(-2.0), true, S(7200L)), + (5.0, 15.0, -4.0, S(-2.0), S(-2.0), true, S(7200L)), + (10.0, 7.5, -4.0, S(-2.0), S(-2.0), true, S(7200L)), + (3.0, 15.0, -4.0, S(-2.0), S(-2.0), true, S(5400L)), + (5.0, 4.0, -4.0, S(-2.0), S(-2.0), false, S(5400L)), + (3.0, 4.0, -4.0, S(-2.0), S(-2.0), false, S(5400L)), + (0.0, 4.0, -4.0, N, S(-4.0), false, S(4500L)), + (6.0, 0.0, -4.0, S(-4.0), N, false, S(7200L)), + + /* setPower is set to > (ev2 * 2) (discharging) */ + (10.0, 15.0, -13.0, S(-8.0), S(-5.0), true, S(7200L)), + (5.0, 15.0, -11.0, S(-6.0), S(-5.0), true, S(5400L)), + (10.0, 8.0, -15.0, S(-10.0), S(-5.0), true, S(6480L)), + (10.0, 5.5, -15.0, S(-10.0), S(-5.0), true, S(5400L)), + (10.0, 15.0, -15.0, S(-10.0), S(-5.0), true, S(6480L)), + (7.0, 10.5, -15.0, S(-10.0), S(-5.0), false, S(5400L)), + ) + + forAll(cases) { + ( + stored1: Double, + stored2: Double, + setPower: Double, + expPower1: Option[Double], + expPower2: Option[Double], + expNextActivation: Boolean, + expNextTick: Option[Long], + ) => + val ev1 = EvModelWrapper( + new MockEvModel( + UUID.randomUUID(), + "Mock EV 1", + 10.0.asKiloWatt, // AC is relevant, + 20.0.asKiloWatt, // DC is not + 10.0.asKiloWattHour, + stored1.asKiloWattHour, + 7200L, + ) + ) + + val ev2 = EvModelWrapper( + new MockEvModel( + UUID.randomUUID(), + "Mock EV 2", + 5.0.asKiloWatt, // AC is relevant, + 10.0.asKiloWatt, // DC is not + 15.0.asKiloWattHour, + stored2.asKiloWattHour, + 10800L, + ) + ) + + evcsModel.handlePowerControl( + EvcsState( + Seq(ev1, ev2), + currentTick, + ), + mockFlexOptions, + Kilowatts(setPower), + ) match { + case ( + EvcsOperatingPoint(evOperatingPoints), + OperationChangeIndicator( + actualNextActivation, + actualNextTick, + ), + ) => + evOperatingPoints + .get(ev1.uuid) + .map(_.toKilowatts) shouldBe expPower1 + evOperatingPoints + .get(ev2.uuid) + .map(_.toKilowatts) shouldBe expPower2 + + actualNextActivation shouldBe expNextActivation + actualNextTick shouldBe expNextTick + } + } + + } + + } + + "reply to requests" when { + val evcsModel = createModel("constantpower") + + val evModel = EvModelWrapper( + new MockEvModel( + UUID.randomUUID(), + "Mock EV", + 10.0.asKiloWatt, // AC is relevant, + 20.0.asKiloWatt, // DC is not + 20.0.asKiloWattHour, + 5.0.asKiloWattHour, + 10800L, + ) + ) + + def testAgent( + model: EvcsModel, + state: EvcsState, + ): Behavior[ParticipantAgent.Request] = Behaviors.receivePartial { + case (ctx, request: ParticipantRequest) => + val newState = model.handleRequest( + state, + ctx, + request, + ) + + testAgent(model, newState) + } + + "no EVs are parked" in { + val service = createTestProbe[EvResponseMessage]() + val currentTick = 0L + + val startingState = EvcsState(Seq.empty, currentTick) + val agent = spawn(testAgent(evcsModel, startingState)) + + agent ! EvFreeLotsRequest(currentTick, service.ref.toClassic) + service.expectMessage(FreeLotsResponse(evcsModel.uuid, 2)) + } + + "one EV is parked, departing later" in { + val service = createTestProbe[EvResponseMessage]() + val currentTick = 0L + + val startingState = EvcsState(Seq(evModel), currentTick) + val agent = spawn(testAgent(evcsModel, startingState)) + + agent ! EvFreeLotsRequest(currentTick, service.ref.toClassic) + service.expectMessage(FreeLotsResponse(evcsModel.uuid, 1)) + + // ev is supposed to be departing later, but we collect it here for testing purposes + agent ! DepartingEvsRequest( + currentTick, + Seq(evModel.uuid), + service.ref.toClassic, + ) + service.expectMessage( + DepartingEvsResponse(evcsModel.uuid, Seq(evModel)) + ) + + agent ! EvFreeLotsRequest(currentTick, service.ref.toClassic) + // now, ev should be gone + service.expectMessage(FreeLotsResponse(evcsModel.uuid, 2)) + } + + "one EV is parked, departing now" in { + val service = createTestProbe[EvResponseMessage]() + // ev is supposed to be departing at this tick + val currentTick = 10800L + + val startingState = EvcsState(Seq(evModel), currentTick) + val agent = spawn(testAgent(evcsModel, startingState)) + + agent ! EvFreeLotsRequest(currentTick, service.ref.toClassic) + // ev should not count, since it is departing now + service.expectMessage(FreeLotsResponse(evcsModel.uuid, 2)) + } + + } + + } + +} diff --git a/src/test/scala/edu/ie3/simona/model/participant2/evcs/MaximumPowerChargingSpec.scala b/src/test/scala/edu/ie3/simona/model/participant2/evcs/MaximumPowerChargingSpec.scala new file mode 100644 index 0000000000..85876fafac --- /dev/null +++ b/src/test/scala/edu/ie3/simona/model/participant2/evcs/MaximumPowerChargingSpec.scala @@ -0,0 +1,139 @@ +/* + * © 2022. TU Dortmund University, + * Institute of Energy Systems, Energy Efficiency and Energy Economics, + * Research group Distribution grid planning and operation + */ + +package edu.ie3.simona.model.participant2.evcs + +import edu.ie3.simona.model.participant.evcs.EvModelWrapper +import edu.ie3.simona.test.common.UnitSpec +import edu.ie3.simona.test.common.model.MockEvModel +import edu.ie3.util.quantities.QuantityUtils.RichQuantityDouble +import org.scalatest.prop.TableDrivenPropertyChecks +import squants.energy.Kilowatts + +import java.util.UUID + +class MaximumPowerChargingSpec extends UnitSpec with TableDrivenPropertyChecks { + + "Calculating maximum power charging schedules" should { + + "not charge evs if they are fully charged" in { + val ev = EvModelWrapper( + new MockEvModel( + UUID.randomUUID(), + "Test EV", + 5.0.asKiloWatt, + 10.0.asKiloWatt, + 20.0.asKiloWattHour, + 20.0.asKiloWattHour, + 3600, + ) + ) + + val actualSchedule = MaximumPowerCharging.determineChargingPowers( + Seq(ev), + 1800L, + MockEvcsChargingProperties, + ) + + actualSchedule shouldBe Map.empty + } + + "work correctly with one ev" in { + val offset = 1800L + + val cases = Table( + ("stayingTicks", "storedEnergy"), + // empty battery + (3600L, 0.0), // stay shorter than full + (7200L, 0.0), // exactly full + (14400L, 0.0), // full before end of stay + // half full battery + (1800L, 5.0), // stay shorter than full + (3600L, 5.0), // exactly full + (14400L, 5.0), // full before end of stay + ) + + forAll(cases) { (stayingTicks, storedEnergy) => + val ev = EvModelWrapper( + new MockEvModel( + UUID.randomUUID(), + "Test EV", + 5.0.asKiloWatt, // using AC charging here + 10.0.asKiloWatt, + 10.0.asKiloWattHour, + storedEnergy.asKiloWattHour, + offset + stayingTicks, + ) + ) + + val chargingMap = MaximumPowerCharging.determineChargingPowers( + Seq(ev), + offset, + MockEvcsChargingProperties, + ) + + chargingMap shouldBe Map( + ev.uuid -> ev.pRatedAc + ) + } + + } + + "work correctly with two evs" in { + val offset = 3600L + + val cases = Table( + ("stayingTicks", "storedEnergy"), + // empty battery + (3600L, 0.0), // stay shorter than full + (7200L, 0.0), // exactly full + (14400L, 0.0), // full before end of stay + // half full battery + (1800L, 5.0), // stay shorter than full + (3600L, 5.0), // exactly full + (14400L, 5.0), // full before end of stay + ) + + forAll(cases) { (stayingTicks, storedEnergy) => + val givenEv = EvModelWrapper( + new MockEvModel( + UUID.randomUUID(), + "First EV", + 5.0.asKiloWatt, // using AC charging here + 10.0.asKiloWatt, + 10.0.asKiloWattHour, + 5.0.asKiloWattHour, + offset + 3600L, + ) + ) + + val ev = EvModelWrapper( + new MockEvModel( + UUID.randomUUID(), + "Test EV", + 5.0.asKiloWatt, // using AC charging here + 10.0.asKiloWatt, + 10.0.asKiloWattHour, + storedEnergy.asKiloWattHour, + offset + stayingTicks, + ) + ) + + val chargingMap = MaximumPowerCharging.determineChargingPowers( + Seq(givenEv, ev), + offset, + MockEvcsChargingProperties, + ) + + chargingMap shouldBe Map( + givenEv.uuid -> Kilowatts(5.0), + ev.uuid -> Kilowatts(5.0), + ) + } + + } + } +} diff --git a/src/test/scala/edu/ie3/simona/model/participant2/evcs/MockEvcsChargingProperties.scala b/src/test/scala/edu/ie3/simona/model/participant2/evcs/MockEvcsChargingProperties.scala new file mode 100644 index 0000000000..1d393e6c15 --- /dev/null +++ b/src/test/scala/edu/ie3/simona/model/participant2/evcs/MockEvcsChargingProperties.scala @@ -0,0 +1,18 @@ +/* + * © 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.participant2.evcs +import edu.ie3.datamodel.models.ElectricCurrentType +import squants.Power +import squants.energy.Kilowatts + +object MockEvcsChargingProperties extends EvcsChargingProperties { + + override protected val pRated: Power = Kilowatts(43) + override val currentType: ElectricCurrentType = ElectricCurrentType.AC + override val lowestEvSoc: Double = 0.2 + +} diff --git a/src/test/scala/edu/ie3/simona/model/participant2/load/FixedLoadModelSpec.scala b/src/test/scala/edu/ie3/simona/model/participant2/load/FixedLoadModelSpec.scala new file mode 100644 index 0000000000..14de207aee --- /dev/null +++ b/src/test/scala/edu/ie3/simona/model/participant2/load/FixedLoadModelSpec.scala @@ -0,0 +1,58 @@ +/* + * © 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.participant2.load + +import edu.ie3.simona.config.SimonaConfig.LoadRuntimeConfig +import edu.ie3.simona.model.participant2.ParticipantModel.{FixedState} +import edu.ie3.simona.test.common.input.LoadInputTestData +import edu.ie3.simona.test.common.UnitSpec +import squants.Power +import squants.energy.Watts + +class FixedLoadModelSpec extends UnitSpec with LoadInputTestData { + + private implicit val tolerance: Power = Watts(1e-2) + + "A fixed load model" should { + + "return the desired power in 1,000 calculations" in { + + val cases = Table( + ("reference", "expectedPower"), + ("power", Watts(268.6)), + ("energy", Watts(342.47)), + ) + + forAll(cases) { (reference, expectedPower) => + val config = LoadRuntimeConfig( + calculateMissingReactivePowerWithModel = false, + scaling = 1.0, + uuids = List.empty, + modelBehaviour = "fixed", + reference = reference, + ) + + val model = FixedLoadModel( + loadInput, + config, + ) + + (0 until 1000).foreach { tick => + val (operatingPoint, nextTick) = model.determineOperatingPoint( + FixedState(tick) + ) + + operatingPoint.activePower should approximate(expectedPower) + operatingPoint.reactivePower shouldBe None + nextTick shouldBe None + } + + } + } + + } +} diff --git a/src/test/scala/edu/ie3/simona/model/participant2/load/LoadModelTestHelper.scala b/src/test/scala/edu/ie3/simona/model/participant2/load/LoadModelTestHelper.scala new file mode 100644 index 0000000000..d0da823c14 --- /dev/null +++ b/src/test/scala/edu/ie3/simona/model/participant2/load/LoadModelTestHelper.scala @@ -0,0 +1,74 @@ +/* + * © 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.participant2.load + +import edu.ie3.simona.model.participant2.ParticipantModel.{ + ActivePowerOperatingPoint, + DateTimeState, +} +import squants.{Dimensionless, Each, Energy, Power, Quantity} +import squants.energy.KilowattHours +import squants.time.Minutes + +import java.time.ZonedDateTime +import java.time.temporal.ChronoUnit + +trait LoadModelTestHelper { + + protected def calculateEnergyDiffForYear( + model: LoadModel[DateTimeState], + simulationStartDate: ZonedDateTime, + expectedEnergy: Energy, + ): Dimensionless = { + val duration = Minutes(15d) + + val avgEnergy = calculatePowerForYear( + model, + simulationStartDate, + ).foldLeft(KilowattHours(0)) { case (energySum, power) => + energySum + (power * duration) + } + + getRelativeDifference( + avgEnergy, + expectedEnergy, + ) + } + + protected def calculatePowerForYear( + model: LoadModel[DateTimeState], + simulationStartDate: ZonedDateTime, + ): Iterable[Power] = { + val quarterHoursInYear = 365L * 96L + + (0L until quarterHoursInYear) + .map { quarterHour => + val tick = quarterHour * 15 * 60 + val state = DateTimeState( + tick, + simulationStartDate.plus(quarterHour * 15, ChronoUnit.MINUTES), + ) + + model + .determineOperatingPoint(state) match { + case (ActivePowerOperatingPoint(p), _) => + p + } + } + } + + protected def getRelativeDifference[Q <: Quantity[Q]]( + actualResult: Q, + expectedResult: Q, + ): Dimensionless = + Each((expectedResult - actualResult).abs / expectedResult) + + protected def get95Quantile[V](sortedArray: Array[V]): V = sortedArray( + (sortedArray.length * 0.95).toInt + ) + +} diff --git a/src/test/scala/edu/ie3/simona/model/participant2/load/ProfileLoadModelSpec.scala b/src/test/scala/edu/ie3/simona/model/participant2/load/ProfileLoadModelSpec.scala new file mode 100644 index 0000000000..faa41a67a9 --- /dev/null +++ b/src/test/scala/edu/ie3/simona/model/participant2/load/ProfileLoadModelSpec.scala @@ -0,0 +1,217 @@ +/* + * © 2021. TU Dortmund University, + * Institute of Energy Systems, Energy Efficiency and Energy Economics, + * Research group Distribution grid planning and operation + */ + +package edu.ie3.simona.model.participant2.load + +import edu.ie3.datamodel.models.OperationTime +import edu.ie3.datamodel.models.input.system.LoadInput +import edu.ie3.datamodel.models.input.system.characteristic.CosPhiFixed +import edu.ie3.datamodel.models.input.{NodeInput, OperatorInput} +import edu.ie3.datamodel.models.profile.BdewStandardLoadProfile._ +import edu.ie3.datamodel.models.voltagelevels.GermanVoltageLevelUtils +import edu.ie3.simona.config.SimonaConfig.LoadRuntimeConfig +import edu.ie3.simona.test.common.UnitSpec +import edu.ie3.simona.test.matchers.DoubleMatchers +import edu.ie3.util.TimeUtil +import edu.ie3.util.quantities.PowerSystemUnits +import edu.ie3.util.scala.quantities.{ + ApparentPower, + Kilovoltamperes, + Voltamperes, +} +import squants.Percent +import squants.energy.{KilowattHours, Power, Watts} +import tech.units.indriya.quantity.Quantities + +import java.util.UUID + +class ProfileLoadModelSpec + extends UnitSpec + with DoubleMatchers + with LoadModelTestHelper { + + private implicit val powerTolerance: ApparentPower = Voltamperes(1e-2) + private implicit val doubleTolerance: Double = 1e-6 + + private val simulationStartDate = + TimeUtil.withDefaults.toZonedDateTime("2022-01-01T00:00:00Z") + + "A profile load model" should { + + val loadInput = new LoadInput( + UUID.fromString("4eeaf76a-ec17-4fc3-872d-34b7d6004b03"), + "testLoad", + OperatorInput.NO_OPERATOR_ASSIGNED, + OperationTime.notLimited(), + new NodeInput( + UUID.fromString("e5c1cde5-c161-4a4f-997f-fcf31fecbf57"), + "TestNodeInputModel", + OperatorInput.NO_OPERATOR_ASSIGNED, + OperationTime.notLimited(), + Quantities.getQuantity(1d, PowerSystemUnits.PU), + false, + NodeInput.DEFAULT_GEO_POSITION, + GermanVoltageLevelUtils.LV, + -1, + ), + new CosPhiFixed("cosPhiFixed:{(0.0,0.95)}"), + null, + H0, + false, + Quantities.getQuantity(3000, PowerSystemUnits.KILOWATTHOUR), + Quantities.getQuantity(282.74, PowerSystemUnits.VOLTAMPERE), + 0.95, + ) + + "be instantiated correctly with power reference" in { + + forAll( + Table( + ("profile", "sRated", "expectedScalingFactor"), + (H0, 282.736842, 1.0), + (H0, 1000.0, 3.536858), + (L0, 253.052632, 1.0), + (L0, 1000.0, 3.951747), + (G0, 253.052632, 1.0), + (G0, 1000.0, 3.951747), + ) + ) { (profile, sRated, expectedScalingFactor) => + val config = LoadRuntimeConfig( + calculateMissingReactivePowerWithModel = false, + scaling = 1.0, + uuids = List.empty, + modelBehaviour = "profile", + reference = "power", + ) + val model = ProfileLoadModel( + loadInput + .copy() + .loadprofile(profile) + .sRated(Quantities.getQuantity(sRated, PowerSystemUnits.VOLTAMPERE)) + .build(), + config, + ) + + model.referenceScalingFactor should approximate(expectedScalingFactor) + + } + + } + + "be instantiated correctly with energy reference" in { + + forAll( + Table( + ("profile", "eConsAnnual", "expectedScalingFactor", "expectedSRated"), + (H0, 1000.0, 1.0, 282.74), + (H0, 3000.0, 3.0, 848.22), + (L0, 1000.0, 1.0, 253.053), + (L0, 3000.0, 3.0, 759.158), + (G0, 1000.0, 1.0, 253.053), + (G0, 3000.0, 3.0, 759.158), + ) + ) { (profile, eConsAnnual, expectedScalingFactor, expectedSRated) => + val config = LoadRuntimeConfig( + calculateMissingReactivePowerWithModel = false, + scaling = 1.0, + uuids = List.empty, + modelBehaviour = "profile", + reference = "energy", + ) + val model = ProfileLoadModel( + loadInput + .copy() + .loadprofile(profile) + .eConsAnnual( + Quantities.getQuantity(eConsAnnual, PowerSystemUnits.KILOWATTHOUR) + ) + .build(), + config, + ) + + model.referenceScalingFactor should approximate(expectedScalingFactor) + model.sRated should approximate(Voltamperes(expectedSRated)) + + } + + } + + "reach the targeted annual energy consumption in a simulated year" in { + forAll( + Table("profile", H0, L0, G0) + ) { profile => + val input = loadInput.copy().loadprofile(profile).build() + + val config = LoadRuntimeConfig( + calculateMissingReactivePowerWithModel = false, + scaling = 1.0, + uuids = List.empty, + modelBehaviour = "profile", + reference = "energy", + ) + + val targetEnergyConsumption = KilowattHours( + loadInput + .geteConsAnnual() + .to(PowerSystemUnits.KILOWATTHOUR) + .getValue + .doubleValue + ) + + val model = ProfileLoadModel(input, config) + + /* Test against a permissible deviation of 2 %. As per official documentation of the bdew load profiles + * [https://www.bdew.de/media/documents/2000131_Anwendung-repraesentativen_Lastprofile-Step-by-step.pdf], 1.5 % + * are officially permissible. But, as we currently do not take (bank) holidays into account, we cannot reach + * this accuracy. */ + + calculateEnergyDiffForYear( + model, + simulationStartDate, + targetEnergyConsumption, + ) should be < Percent(2) + } + } + + "approximately reach the maximum power in a simulated year" in { + forAll( + Table("profile", H0, L0, G0) + ) { profile => + val input = loadInput.copy().loadprofile(profile).build() + + val config = LoadRuntimeConfig( + calculateMissingReactivePowerWithModel = false, + scaling = 1.0, + uuids = List.empty, + modelBehaviour = "profile", + reference = "power", + ) + + val model = ProfileLoadModel(input, config) + + val targetMaximumPower = Kilovoltamperes( + input + .getsRated() + .to(PowerSystemUnits.KILOVOLTAMPERE) + .getValue + .doubleValue + ).toActivePower(input.getCosPhiRated) + + val maximumPower = calculatePowerForYear( + model, + simulationStartDate, + ).maxOption.value + + // the maximum value depends on the year of the simulation, + // since the maximum value for h0 will be reached on Saturdays in the winter + // and since the dynamization function reaches its maximum on day 366 (leap year) + implicit val tolerance: Power = Watts(1) + maximumPower should approximate(targetMaximumPower) + } + } + + } +} diff --git a/src/test/scala/edu/ie3/simona/model/participant2/load/RandomLoadModelSpec.scala b/src/test/scala/edu/ie3/simona/model/participant2/load/RandomLoadModelSpec.scala new file mode 100644 index 0000000000..b52227152d --- /dev/null +++ b/src/test/scala/edu/ie3/simona/model/participant2/load/RandomLoadModelSpec.scala @@ -0,0 +1,235 @@ +/* + * © 2021. TU Dortmund University, + * Institute of Energy Systems, Energy Efficiency and Energy Economics, + * Research group Distribution grid planning and operation + */ + +package edu.ie3.simona.model.participant2.load + +import de.lmu.ifi.dbs.elki.math.statistics.distribution.GeneralizedExtremeValueDistribution +import edu.ie3.datamodel.models.OperationTime +import edu.ie3.datamodel.models.input.system.LoadInput +import edu.ie3.datamodel.models.input.system.characteristic.CosPhiFixed +import edu.ie3.datamodel.models.input.{NodeInput, OperatorInput} +import edu.ie3.datamodel.models.profile.BdewStandardLoadProfile +import edu.ie3.datamodel.models.voltagelevels.GermanVoltageLevelUtils +import edu.ie3.simona.config.SimonaConfig.LoadRuntimeConfig +import edu.ie3.simona.model.participant.load.random.RandomLoadParameters +import edu.ie3.simona.test.common.UnitSpec +import edu.ie3.util.TimeUtil +import edu.ie3.util.quantities.PowerSystemUnits +import edu.ie3.util.scala.quantities.{ + ApparentPower, + Kilovoltamperes, + Voltamperes, +} +import squants.Percent +import squants.energy.KilowattHours +import tech.units.indriya.quantity.Quantities + +import java.util.UUID + +class RandomLoadModelSpec extends UnitSpec with LoadModelTestHelper { + + implicit val powerTolerance: ApparentPower = Voltamperes(1e-2) + private implicit val doubleTolerance: Double = 1e-6 + + private val simulationStartDate = + TimeUtil.withDefaults.toZonedDateTime("2019-01-01T00:00:00Z") + + "A random load model" should { + + val loadInput = new LoadInput( + UUID.fromString("4eeaf76a-ec17-4fc3-872d-34b7d6004b03"), + "testLoad", + OperatorInput.NO_OPERATOR_ASSIGNED, + OperationTime.notLimited(), + new NodeInput( + UUID.fromString("e5c1cde5-c161-4a4f-997f-fcf31fecbf57"), + "TestNodeInputModel", + OperatorInput.NO_OPERATOR_ASSIGNED, + OperationTime.notLimited(), + Quantities.getQuantity(1d, PowerSystemUnits.PU), + false, + NodeInput.DEFAULT_GEO_POSITION, + GermanVoltageLevelUtils.LV, + -1, + ), + new CosPhiFixed("cosPhiFixed:{(0.0,0.95)}"), + null, + BdewStandardLoadProfile.H0, + false, + Quantities.getQuantity(3000d, PowerSystemUnits.KILOWATTHOUR), + Quantities.getQuantity(282.74d, PowerSystemUnits.VOLTAMPERE), + 0.95, + ) + + "be instantiated correctly with power reference" in { + + forAll( + Table( + ("sRated", "expectedScalingFactor"), + (167.368421, 1.0), + (1000.0, 5.9748428), + ) + ) { (sRated, expectedScalingFactor) => + val config = LoadRuntimeConfig( + calculateMissingReactivePowerWithModel = false, + scaling = 1.0, + uuids = List.empty, + modelBehaviour = "random", + reference = "power", + ) + val model = RandomLoadModel( + loadInput + .copy() + .sRated(Quantities.getQuantity(sRated, PowerSystemUnits.VOLTAMPERE)) + .build(), + config, + ) + + model.referenceScalingFactor should approximate(expectedScalingFactor) + + } + + } + + "be instantiated correctly with energy reference" in { + + forAll( + Table( + ("eConsAnnual", "expectedScalingFactor", "expectedSRated"), + (1000.0, 1.3955921, 256.936), + (2000.0, 2.7911842, 513.8717), + (3000.0, 4.1867763, 770.808), + ) + ) { (eConsAnnual, expectedScalingFactor, expectedSRated) => + val config = LoadRuntimeConfig( + calculateMissingReactivePowerWithModel = false, + scaling = 1.0, + uuids = List.empty, + modelBehaviour = "random", + reference = "energy", + ) + val model = RandomLoadModel( + loadInput + .copy() + .eConsAnnual( + Quantities.getQuantity(eConsAnnual, PowerSystemUnits.KILOWATTHOUR) + ) + .build(), + config, + ) + + model.referenceScalingFactor should approximate(expectedScalingFactor) + model.sRated should approximate(Voltamperes(expectedSRated)) + + } + + } + + "deliver the correct distribution on request" in { + val model = RandomLoadModel( + loadInput, + LoadRuntimeConfig( + calculateMissingReactivePowerWithModel = false, + scaling = 1.0, + uuids = List.empty, + modelBehaviour = "random", + reference = "energy", + ), + ) + + /* Working day, 61st quarter-hour */ + val queryDate = + TimeUtil.withDefaults.toZonedDateTime("2019-07-19T15:21:00Z") + val expectedParams = new RandomLoadParameters( + 0.405802458524704, + 0.0671483352780342, + 0.0417016632854939, + ) + + /* First query leeds to generation of distribution */ + val getGevDistribution = + PrivateMethod[GeneralizedExtremeValueDistribution]( + Symbol("getGevDistribution") + ) + + def firstHit = model invokePrivate getGevDistribution(queryDate) + + firstHit.getK shouldBe expectedParams.k + firstHit.getMu shouldBe expectedParams.my + firstHit.getSigma shouldBe expectedParams.sigma + + /* Second query is only look up in storage */ + def secondHit = model invokePrivate getGevDistribution(queryDate) + + secondHit shouldBe firstHit + } + + "reach the targeted annual energy consumption in a simulated year" in { + val config = LoadRuntimeConfig( + calculateMissingReactivePowerWithModel = false, + scaling = 1.0, + uuids = List.empty, + modelBehaviour = "random", + reference = "energy", + ) + + val model = RandomLoadModel( + loadInput, + config, + ) + + val targetEnergyConsumption = KilowattHours( + loadInput + .geteConsAnnual() + .to(PowerSystemUnits.KILOWATTHOUR) + .getValue + .doubleValue + ) + + calculateEnergyDiffForYear( + model, + simulationStartDate, + targetEnergyConsumption, + ) should be < Percent(1d) + } + + "approximately reach the maximum power in a simulated year" in { + val config = LoadRuntimeConfig( + calculateMissingReactivePowerWithModel = false, + scaling = 1.0, + uuids = List.empty, + modelBehaviour = "random", + reference = "power", + ) + + val model = RandomLoadModel( + loadInput, + config, + ) + + val targetMaximumPower = Kilovoltamperes( + loadInput + .getsRated() + .to(PowerSystemUnits.KILOVOLTAMPERE) + .getValue + .doubleValue + ).toActivePower(loadInput.getCosPhiRated) + + val powers = calculatePowerForYear( + model, + simulationStartDate, + ).toIndexedSeq.sorted.toArray + + val quantile95 = get95Quantile(powers) + + getRelativeDifference( + quantile95, + targetMaximumPower, + ) should be < Percent(2d) + } + + } +} diff --git a/src/test/scala/edu/ie3/simona/service/ev/ExtEvDataServiceSpec.scala b/src/test/scala/edu/ie3/simona/service/ev/ExtEvDataServiceSpec.scala index a1846a39a1..871f69fbfb 100644 --- a/src/test/scala/edu/ie3/simona/service/ev/ExtEvDataServiceSpec.scala +++ b/src/test/scala/edu/ie3/simona/service/ev/ExtEvDataServiceSpec.scala @@ -260,11 +260,11 @@ class ExtEvDataServiceSpec scheduler.send(evService, Activation(tick)) evcs1.expectMsg( - EvFreeLotsRequest(tick) + EvFreeLotsRequest(tick, evService.ref) ) evcs2.expectMsg( - EvFreeLotsRequest(tick) + EvFreeLotsRequest(tick, evService.ref) ) scheduler.expectMsg(Completion(evService.toTyped)) @@ -487,10 +487,10 @@ class ExtEvDataServiceSpec scheduler.send(evService, Activation(tick)) evcs1.expectMsg( - DepartingEvsRequest(tick, scala.collection.immutable.Seq(evA.getUuid)) + DepartingEvsRequest(tick, Seq(evA.getUuid), evService.ref) ) evcs2.expectMsg( - DepartingEvsRequest(tick, scala.collection.immutable.Seq(evB.getUuid)) + DepartingEvsRequest(tick, Seq(evB.getUuid), evService.ref) ) scheduler.expectMsg(Completion(evService.toTyped)) diff --git a/src/test/scala/edu/ie3/simona/test/common/UnitSpec.scala b/src/test/scala/edu/ie3/simona/test/common/UnitSpec.scala index 5cbf725051..b9ad362047 100644 --- a/src/test/scala/edu/ie3/simona/test/common/UnitSpec.scala +++ b/src/test/scala/edu/ie3/simona/test/common/UnitSpec.scala @@ -7,7 +7,11 @@ package edu.ie3.simona.test.common import com.typesafe.scalalogging.LazyLogging -import edu.ie3.simona.test.matchers.{QuantityMatchers, SquantsMatchers} +import edu.ie3.simona.test.matchers.{ + DoubleMatchers, + QuantityMatchers, + SquantsMatchers, +} import edu.ie3.util.scala.quantities.{QuantityUtil => PSQuantityUtil} import org.apache.pekko.actor.testkit.typed.scaladsl.LogCapturing import org.scalatest._ @@ -29,6 +33,7 @@ trait UnitSpec extends should.Matchers with QuantityMatchers with SquantsMatchers + with DoubleMatchers with AnyWordSpecLike with LogCapturing with OptionValues diff --git a/src/test/scala/edu/ie3/simona/test/matchers/DoubleMatchers.scala b/src/test/scala/edu/ie3/simona/test/matchers/DoubleMatchers.scala new file mode 100644 index 0000000000..fe9b7a2b16 --- /dev/null +++ b/src/test/scala/edu/ie3/simona/test/matchers/DoubleMatchers.scala @@ -0,0 +1,28 @@ +/* + * © 2024. TU Dortmund University, + * Institute of Energy Systems, Energy Efficiency and Energy Economics, + * Research group Distribution grid planning and operation + */ + +package edu.ie3.simona.test.matchers + +import org.scalactic.TolerantNumerics +import org.scalatest.matchers.{MatchResult, Matcher} + +trait DoubleMatchers extends TolerantNumerics { + + class DoubleMatcher(right: Double, implicit val tolerance: Double) + extends Matcher[Double] { + private val equality = tolerantDoubleEquality(tolerance) + + override def apply(left: Double): MatchResult = MatchResult( + equality.areEqual(left, right), + s"The values $left and $right differ more than $tolerance in value", + s"The values $left and $right differ less than $tolerance in value", + ) + } + + def approximate(right: Double)(implicit tolerance: Double) = + new DoubleMatcher(right, tolerance) + +} diff --git a/src/test/scala/edu/ie3/simona/util/TickUtilSpec.scala b/src/test/scala/edu/ie3/simona/util/TickUtilSpec.scala new file mode 100644 index 0000000000..7b26ee47be --- /dev/null +++ b/src/test/scala/edu/ie3/simona/util/TickUtilSpec.scala @@ -0,0 +1,51 @@ +/* + * © 2024. TU Dortmund University, + * Institute of Energy Systems, Energy Efficiency and Energy Economics, + * Research group Distribution grid planning and operation + */ + +package edu.ie3.simona.util + +import edu.ie3.simona.test.common.UnitSpec +import edu.ie3.util.TimeUtil + +class TickUtilSpec extends UnitSpec { + "TimeUtil" should { + "round ticks and date/times correctly" in { + val cases = Table( + ("tick", "dateTime", "resolution", "expectedTick", "expectedDateTime"), + // base scenario + (0, "2024-01-02T00:00:00Z", 900, 0, "2024-01-02T00:00:00Z"), + // unusual tick + (1, "2024-01-02T00:00:00Z", 900, 1, "2024-01-02T00:00:00Z"), + // uneven second + (3600, "2024-01-02T00:00:10Z", 900, 3590, "2024-01-02T00:00:00Z"), + // uneven minute + (3600, "2024-01-02T00:07:00Z", 900, 3180, "2024-01-02T00:00:00Z"), + // uneven minute and second + (3600, "2024-01-02T00:07:07Z", 900, 3173, "2024-01-02T00:00:00Z"), + // second-sized resolution, base scenario + (3600, "2024-01-02T00:00:00Z", 15, 3600, "2024-01-02T00:00:00Z"), + // second-sized resolution, uneven second + (3603, "2024-01-02T00:00:18Z", 15, 3600, "2024-01-02T00:00:15Z"), + // second-sized resolution, uneven minute + (3600, "2024-01-02T00:05:00Z", 15, 3600, "2024-01-02T00:05:00Z"), + // second-sized resolution, uneven minute and second + (3607, "2024-01-02T00:05:07Z", 15, 3600, "2024-01-02T00:05:00Z"), + ) + + forAll(cases) { + case (tick, dateTime, resolution, expectedTick, expectedDateTime) => + val (actualTick, actualDateTime) = TickUtil.roundToResolution( + tick, + TimeUtil.withDefaults.toZonedDateTime(dateTime), + resolution, + ) + + actualTick should equal(expectedTick) + TimeUtil.withDefaults.toString(actualDateTime) should + equal(expectedDateTime) + } + } + } +}