diff --git a/CHANGELOG.md b/CHANGELOG.md index aa9ff87da1..5371ca3012 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Consider scaling factor with flex options [#734](https://github.com/ie3-institute/simona/issues/734) - Implementation of Energy Management Agents [#204](https://github.com/ie3-institute/simona/issues/204) - Providing documentation for EmAgent protocols and algorithms [#774](https://github.com/ie3-institute/simona/issues/774) +- Option to flush out `CylindricalStorageResults` [#826](https://github.com/ie3-institute/simona/issues/826) +- Printing the directory of log to terminal upon simulation failure [#626](https://github.com/ie3-institute/simona/issues/626) +- Implementation of StorageAgent [#309](https://github.com/ie3-institute/simona/issues/309) ### Changed - Adapted to changed data source in PSDM [#435](https://github.com/ie3-institute/simona/issues/435) @@ -50,6 +53,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Refactoring of `GridAgent` messages [#736](https://github.com/ie3-institute/simona/issues/736) - Rewrote PVModelTest from groovy to scala [#646](https://github.com/ie3-institute/simona/issues/646) - Making configuration of `RefSystem` via config optional [#769](https://github.com/ie3-institute/simona/issues/769) +- Updated PSDM to version 5.1.0 [#835](https://github.com/ie3-institute/simona/issues/835) +- Refactor `WeatherSource` and `WeatherSourceWrapper` [#180](https://github.com/ie3-institute/simona/issues/180) ### Fixed - Removed a repeated line in the documentation of vn_simona config [#658](https://github.com/ie3-institute/simona/issues/658) @@ -65,6 +70,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Default RefSystem using the unit `Volt` for low voltage grids [#811](https://github.com/ie3-institute/simona/issues/811) - Fixed grid within GridSpec test [#806](https://github.com/ie3-institute/simona/issues/806) - Fixed log entry after power flow calculation [#814](https://github.com/ie3-institute/simona/issues/814) +- Delete "Indices and tables" on the index page [#375](https://github.com/ie3-institute/simona/issues/375) +- Fixed provision of controllingEms within buildParticipantToActorRef [#841](https://github.com/ie3-institute/simona/issues/841) +- Simulation stopping at unhandled messages in `DBFSAlgorithm` [#821](https://github.com/ie3-institute/simona/issues/821) ## [3.0.0] - 2023-08-07 diff --git a/build.gradle b/build.gradle index d67da623de..48535a090c 100644 --- a/build.gradle +++ b/build.gradle @@ -13,7 +13,7 @@ plugins { id "kr.motd.sphinx" version "2.10.1" // documentation generation id "com.github.johnrengelman.shadow" version "8.1.1" // fat jar id "org.sonarqube" version "5.0.0.4638" // sonarqube - id "org.scoverage" version "8.0.3" // scala code coverage scoverage + id "org.scoverage" version "8.1" // scala code coverage scoverage id "com.github.maiflai.scalatest" version "0.32" // run scalatest without specific spec task id 'org.hidetake.ssh' version '2.11.2' id 'net.thauvin.erik.gradle.semver' version '1.0.4' // semantic versioning @@ -31,7 +31,7 @@ ext { tscfgVersion = '1.0.0' scapegoatVersion = '2.1.6' - testContainerVersion = '0.41.3' + testContainerVersion = '0.41.4' scriptsLocation = 'gradle' + File.separator + 'scripts' + File.separator // location of script plugins } @@ -80,7 +80,7 @@ dependencies { /* Exclude our own nested dependencies */ exclude group: 'com.github.ie3-institute' } - implementation('com.github.ie3-institute:PowerSystemDataModel:5.0.1') { + implementation('com.github.ie3-institute:PowerSystemDataModel:5.1.0') { exclude group: 'org.apache.logging.log4j' exclude group: 'org.slf4j' /* Exclude our own nested dependencies */ @@ -114,7 +114,7 @@ dependencies { testImplementation 'org.spockframework:spock-core:2.3-groovy-4.0' testImplementation 'org.scalatestplus:mockito-3-4_2.13:3.2.10.0' testImplementation 'org.mockito:mockito-core:5.12.0' // mocking framework - testImplementation "org.scalatest:scalatest_${scalaVersion}:3.2.18" + testImplementation "org.scalatest:scalatest_${scalaVersion}:3.2.19" testRuntimeOnly 'com.vladsch.flexmark:flexmark-all:0.64.8' //scalatest html output testImplementation group: 'org.pegdown', name: 'pegdown', version: '1.6.0' testImplementation "org.apache.pekko:pekko-testkit_${scalaVersion}:${pekkoVersion}" // pekko testkit @@ -156,7 +156,7 @@ dependencies { implementation "com.sksamuel.avro4s:avro4s-core_${scalaVersion}:4.1.2" implementation 'org.apache.commons:commons-math3:3.6.1' // apache commons math3 - implementation 'org.apache.poi:poi-ooxml:5.2.5' // used for FilenameUtils + implementation 'org.apache.poi:poi-ooxml:5.3.0' // used for FilenameUtils implementation 'javax.measure:unit-api:2.2' implementation 'tech.units:indriya:2.2' // quantities implementation "org.typelevel:squants_${scalaVersion}:1.8.3" diff --git a/docs/readthedocs/conf.py b/docs/readthedocs/conf.py index a97c81859f..6ec36e73ef 100644 --- a/docs/readthedocs/conf.py +++ b/docs/readthedocs/conf.py @@ -37,7 +37,7 @@ html_theme = 'sphinx_rtd_theme' html_short_title = "simona" htmlhelp_basename = 'simona-doc' -html_use_index = True +html_use_index = False html_show_sourcelink = False html_static_path = ['_static'] diff --git a/docs/readthedocs/config.md b/docs/readthedocs/config.md index f964378c67..ec8244a7e9 100644 --- a/docs/readthedocs/config.md +++ b/docs/readthedocs/config.md @@ -147,6 +147,33 @@ simona.output.participant.individualConfigs = [ ] ``` +#### Output configuration of thermal elements + +To use the default configuration the default notifier has to be used. By setting "simulationResult" to true, the thermal elements is enabled to return its results. + +``` +simona.output.thermal.defaultConfig = { + notifier = "default", + simulationResult = true +} +``` + +The default configuration applies to all models except the ones with individual configurations assigned. +If individual configurations have to be performed for certain thermal elements, these must be listed with the corresponding notifier as in the following example. + +``` +simona.output.thermal.individualConfigs = [ + { + notifier = "house", + simulationResult = true + }, + { + notifier = "cylindricalstorage", + simulationResult = true + } +] +``` + Further model classes which can be used to load the outcome of a system simulation are described in [PSDM](https://powersystemdatamodel.readthedocs.io/en/latest/models/models.html#result). Data sources and data sinks are explained in the [I/O-capabilities](https://powersystemdatamodel.readthedocs.io/en/latest/io/basiciousage.html) section of the PSDM. @@ -196,6 +223,16 @@ The load reference can scale the load model behaviour to reach the given annual If an individual configuration is to be assigned, the default configuration parameters must be adjusted accordingly. Runtime configurations of other system participants are done similarly, except that model behavior and reference are not defined. +### Storage runtime configuration + +The storage model takes parameters for the initial state of charge (SOC) and the target SOC for electrical energy storages, with 0.0 <= SOC <= 1.0. +The initial SOC defaults to 0%, while the target SOC is optional. When no target SOC is set, the reference behavior (see flexibility messages) of storages is 0 kW. + + initialSoc = "0.0" + targetSoc = "1.0" + +Individual configuration can be assigned accordingly. + ## Event configuration Tba: diff --git a/docs/readthedocs/index.rst b/docs/readthedocs/index.rst index 68cc508bcb..5ad7bc0ffd 100644 --- a/docs/readthedocs/index.rst +++ b/docs/readthedocs/index.rst @@ -18,10 +18,3 @@ Institute of Energy Systems, Energy Efficiency and Energy Economics at TU Dortmu models developersguide references - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` diff --git a/input/samples/vn_simona/fullGrid/storage_input.csv b/input/samples/vn_simona/fullGrid/storage_input.csv deleted file mode 100644 index 7d68ff5120..0000000000 --- a/input/samples/vn_simona/fullGrid/storage_input.csv +++ /dev/null @@ -1,2 +0,0 @@ -uuid,id,node,operates_from,operates_until,operator,q_characteristics,type -a2a92cfd-3492-465f-9587-e789f4620af8,Speicher_1,033d0230-4aee-47cf-91f9-81f5f40e60b0,,,,"cosPhiFixed:{(0.0,0.98)}",95d4c980-d9e1-4813-9f2a-b0942488a570 diff --git a/input/samples/vn_simona/vn_simona.conf b/input/samples/vn_simona/vn_simona.conf index 401d783120..9d7b4e4c39 100644 --- a/input/samples/vn_simona/vn_simona.conf +++ b/input/samples/vn_simona/vn_simona.conf @@ -92,6 +92,10 @@ simona.output.thermal = { { notifier = "house", simulationResult = true + }, + { + notifier = "cylindricalstorage", + simulationResult = true } ] } @@ -158,6 +162,15 @@ simona.runtime.participant.hp = { individualConfigs = [] } +simona.runtime.participant.storage = { + defaultConfig = { + calculateMissingReactivePowerWithModel = false + uuids = ["default"] + scaling = 1.0 + } + individualConfigs = [] +} + # # # # # # ATTENTION: calculateMissingReactivePowerWithModel and scaling is ignored here. # # # # # diff --git a/src/main/resources/config/config-template.conf b/src/main/resources/config/config-template.conf index 07591dc431..0b636a5d50 100644 --- a/src/main/resources/config/config-template.conf +++ b/src/main/resources/config/config-template.conf @@ -54,6 +54,15 @@ EvcsRuntimeConfig { lowestEvSoc: Double | 0.2 # Defines the lowest possible state of charge (SoC) that an EV is allowed to uncharge in vehicle to grid (V2G) mode } +#@define extends BaseRuntimeConfig +StorageRuntimeConfig { + baseRuntimeConfig: BaseRuntimeConfig # this entry is ignored by the config generator, + # but cannot removed bc otherwise StorageRuntimeConfig is handled as String + initialSoc: Double | 0 # Defines initial soc of storages as a share of capacity, 0-1 + #@optional + targetSoc: Double +} + #@define extends BaseRuntimeConfig EmRuntimeConfig { # # # # # @@ -324,6 +333,10 @@ simona.runtime.participant = { defaultConfig = HpRuntimeConfig # Mandatory default config (uuids are ignored, best provide "default") individualConfigs = [HpRuntimeConfig] } + storage = { + defaultConfig = StorageRuntimeConfig # Mandatory default config (uuids are ignored, best provide "default") + individualConfigs = [StorageRuntimeConfig] + } em = { defaultConfig = EmRuntimeConfig # Mandatory default config (uuids are ignored, best provide "default") individualConfigs = [EmRuntimeConfig] diff --git a/src/main/scala/edu/ie3/simona/agent/grid/DBFSAlgorithm.scala b/src/main/scala/edu/ie3/simona/agent/grid/DBFSAlgorithm.scala index 24236f43d3..7999c226f0 100644 --- a/src/main/scala/edu/ie3/simona/agent/grid/DBFSAlgorithm.scala +++ b/src/main/scala/edu/ie3/simona/agent/grid/DBFSAlgorithm.scala @@ -490,9 +490,13 @@ trait DBFSAlgorithm extends PowerFlowSupport with GridResultsSupport { // return to Idle idle(cleanedGridAgentBaseData) - case _ => - // preventing "match may not be exhaustive" - Behaviors.unhandled + // handles power request that arrive to early + case (requestGridPower: RequestGridPower, _) => + ctx.log.debug( + s"Received the message $requestGridPower too early. Stash away!" + ) + buffer.stash(requestGridPower) + Behaviors.same } } @@ -783,7 +787,7 @@ trait DBFSAlgorithm extends PowerFlowSupport with GridResultsSupport { // happens only when we received slack data and power values before we received a request to provide grid data // (only possible when first simulation triggered and this agent is faster in this state as the request // by a superior grid arrives) - case (powerResponse: PowerResponse, _: GridAgentBaseData) => + case (powerResponse: PowerResponse, _) => ctx.log.debug( "Received Request for Grid Power too early. Stashing away" ) @@ -791,20 +795,12 @@ trait DBFSAlgorithm extends PowerFlowSupport with GridResultsSupport { buffer.stash(powerResponse) Behaviors.same - // happens only when we received slack data and power values before we received a request to provide grid - // (only possible when first simulation triggered and this agent is faster - // with its power flow calculation in this state as the request by a superior grid arrives) - case (powerResponse: PowerResponse, _: PowerFlowDoneData) => + case (requestGridPower: RequestGridPower, _) => ctx.log.debug( - "Received Request for Grid Power too early. Stashing away" + s"Received the message $requestGridPower too early. Stashing away!" ) - - buffer.stash(powerResponse) + buffer.stash(requestGridPower) Behaviors.same - - case _ => - // preventing "match may not be exhaustive" - Behaviors.unhandled } } diff --git a/src/main/scala/edu/ie3/simona/agent/grid/GridAgentController.scala b/src/main/scala/edu/ie3/simona/agent/grid/GridAgentController.scala index e32001d994..e86d914bfe 100644 --- a/src/main/scala/edu/ie3/simona/agent/grid/GridAgentController.scala +++ b/src/main/scala/edu/ie3/simona/agent/grid/GridAgentController.scala @@ -23,6 +23,7 @@ import edu.ie3.simona.agent.participant.hp.HpAgent import edu.ie3.simona.agent.participant.load.LoadAgent import edu.ie3.simona.agent.participant.pv.PvAgent import edu.ie3.simona.agent.participant.statedata.ParticipantStateData.ParticipantInitializeStateData +import edu.ie3.simona.agent.participant.storage.StorageAgent import edu.ie3.simona.agent.participant.wec.WecAgent import edu.ie3.simona.config.SimonaConfig import edu.ie3.simona.config.SimonaConfig._ @@ -127,8 +128,7 @@ class GridAgentController( curSysPart, ) => curSysPart match { - case entity @ (_: BmInput | _: ChpInput | _: EvInput | - _: StorageInput) => + case entity @ (_: BmInput | _: ChpInput | _: EvInput) => ( notProcessedElements + entity.getClass.getSimpleName, availableSystemParticipants, @@ -208,7 +208,17 @@ class GridAgentController( //log.info(s"Built Participant = $participant") val node = participant.getNode - + val controllingEm = + participant.getControllingEm.toScala + .map(_.getUuid) + .map(uuid => + allEms.getOrElse( + uuid, + throw new CriticalFailureException( + s"EM actor with UUID $uuid not found." + ), + ) + ) val actorRef = buildParticipantActor( participantsConfig.requestVoltageDeviationThreshold, @@ -217,14 +227,7 @@ class GridAgentController( participant, thermalIslandGridsByBusId, environmentRefs, - participant.getControllingEm.toScala.map(_.getUuid).map( - uuid => allEms.getOrElse( - uuid, - throw new CriticalFailureException( - s"Actor for EM $uuid not found." - ), - ), - ) + controllingEm, ) introduceAgentToEnvironment(actorRef) // return uuid to actorRef @@ -442,6 +445,20 @@ class GridAgentController( s"Unable to find thermal island grid for heat pump '${hpInput.getUuid}' with thermal bus '${hpInput.getThermalBus.getUuid}'." ) } + case input: StorageInput => + buildStorage( + input, + participantConfigUtil.getOrDefault[StorageRuntimeConfig]( + input.getUuid + ), + environmentRefs.primaryServiceProxy, + simulationStartDate, + simulationEndDate, + resolution, + requestVoltageDeviationThreshold, + outputConfigUtil.getOrDefault(NotifierIdentifier.Storage), + maybeControllingEm, + ) case input: SystemParticipantInput => throw new NotImplementedError( s"Building ${input.getClass.getSimpleName} is not implemented, yet." @@ -803,6 +820,63 @@ class GridAgentController( ) .toTyped + /** Creates a storage agent and determines the needed additional information + * for later initialization of the agent. + * + * @param storageInput + * Storage input model to derive information from + * @param modelConfiguration + * User-provided configuration for this specific storage model + * @param primaryServiceProxy + * Reference to the primary data service proxy + * @param simulationStartDate + * First wall clock time in simulation + * @param simulationEndDate + * Last wall clock time in simulation + * @param resolution + * Frequency of power flow calculations + * @param requestVoltageDeviationThreshold + * Maximum deviation in p.u. of request voltages to be considered equal + * @param outputConfig + * Configuration of the output behavior + * @param maybeControllingEm + * The parent EmAgent, if applicable + * @return + * The [[StorageAgent]] 's [[ActorRef]] + */ + private def buildStorage( + storageInput: StorageInput, + modelConfiguration: SimonaConfig.StorageRuntimeConfig, + primaryServiceProxy: ClassicRef, + simulationStartDate: ZonedDateTime, + simulationEndDate: ZonedDateTime, + resolution: Long, + requestVoltageDeviationThreshold: Double, + outputConfig: NotifierConfig, + maybeControllingEm: Option[ActorRef[FlexResponse]] = None, + ): ActorRef[ParticipantMessage] = + gridAgentContext.toClassic + .simonaActorOf( + StorageAgent.props( + environmentRefs.scheduler.toClassic, + ParticipantInitializeStateData( + storageInput, + modelConfiguration, + primaryServiceProxy, + None, + simulationStartDate, + simulationEndDate, + resolution, + requestVoltageDeviationThreshold, + outputConfig, + maybeControllingEm, + ), + listener.map(_.toClassic), + ), + storageInput.getId, + ) + .toTyped + /** Builds an [[EmAgent]] from given input * * @param emInput diff --git a/src/main/scala/edu/ie3/simona/agent/participant/storage/StorageAgent.scala b/src/main/scala/edu/ie3/simona/agent/participant/storage/StorageAgent.scala new file mode 100644 index 0000000000..f38328ea3b --- /dev/null +++ b/src/main/scala/edu/ie3/simona/agent/participant/storage/StorageAgent.scala @@ -0,0 +1,68 @@ +/* + * © 2022. 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.storage + +import edu.ie3.datamodel.models.input.system.StorageInput +import edu.ie3.simona.agent.participant.ParticipantAgent +import edu.ie3.simona.agent.participant.data.Data.PrimaryData.ApparentPower +import edu.ie3.simona.agent.participant.statedata.ParticipantStateData +import edu.ie3.simona.agent.participant.statedata.ParticipantStateData.ParticipantInitializeStateData +import edu.ie3.simona.config.SimonaConfig.StorageRuntimeConfig +import edu.ie3.simona.model.participant.StorageModel +import edu.ie3.simona.model.participant.StorageModel.{ + StorageRelevantData, + StorageState, +} +import org.apache.pekko.actor.{ActorRef, Props} + +object StorageAgent { + def props( + scheduler: ActorRef, + initStateData: ParticipantInitializeStateData[ + StorageInput, + StorageRuntimeConfig, + ApparentPower, + ], + listener: Iterable[ActorRef], + ): Props = + Props( + new StorageAgent( + scheduler, + initStateData, + listener, + ) + ) +} + +/** Creating a battery storage agent + * + * @param scheduler + * Actor reference of the scheduler + * @param listener + * List of listeners interested in results + */ +class StorageAgent( + scheduler: ActorRef, + initStateData: ParticipantInitializeStateData[ + StorageInput, + StorageRuntimeConfig, + ApparentPower, + ], + override val listener: Iterable[ActorRef], +) extends ParticipantAgent[ + ApparentPower, + StorageRelevantData, + StorageState, + ParticipantStateData[ApparentPower], + StorageInput, + StorageRuntimeConfig, + StorageModel, + ]( + scheduler, + initStateData, + ) + with StorageAgentFundamentals {} diff --git a/src/main/scala/edu/ie3/simona/agent/participant/storage/StorageAgentFundamentals.scala b/src/main/scala/edu/ie3/simona/agent/participant/storage/StorageAgentFundamentals.scala new file mode 100644 index 0000000000..c233288d67 --- /dev/null +++ b/src/main/scala/edu/ie3/simona/agent/participant/storage/StorageAgentFundamentals.scala @@ -0,0 +1,382 @@ +/* + * © 2022. 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.storage + +import edu.ie3.datamodel.models.input.system.StorageInput +import edu.ie3.datamodel.models.result.system.{ + StorageResult, + SystemParticipantResult, +} +import edu.ie3.simona.agent.ValueStore +import edu.ie3.simona.agent.participant.ParticipantAgent.getAndCheckNodalVoltage +import edu.ie3.simona.agent.participant.ParticipantAgentFundamentals +import edu.ie3.simona.agent.participant.data.Data +import edu.ie3.simona.agent.participant.data.Data.PrimaryData.{ + ApparentPower, + ZERO_POWER, +} +import edu.ie3.simona.agent.participant.data.secondary.SecondaryDataService +import edu.ie3.simona.agent.participant.statedata.BaseStateData.{ + FlexControlledData, + ParticipantModelBaseStateData, +} +import edu.ie3.simona.agent.participant.statedata.ParticipantStateData.InputModelContainer +import edu.ie3.simona.agent.participant.statedata.{ + BaseStateData, + ParticipantStateData, +} +import edu.ie3.simona.config.SimonaConfig.StorageRuntimeConfig +import edu.ie3.simona.event.ResultEvent.ParticipantResultEvent +import edu.ie3.simona.event.notifier.NotifierConfig +import edu.ie3.simona.exceptions.agent.{ + AgentInitializationException, + InvalidRequestException, +} +import edu.ie3.simona.model.participant.StorageModel.{ + StorageRelevantData, + StorageState, +} +import edu.ie3.simona.model.participant.{FlexChangeIndicator, StorageModel} +import edu.ie3.simona.ontology.messages.flex.FlexibilityMessage.{ + FlexRequest, + FlexResponse, +} +import edu.ie3.simona.util.SimonaConstants +import edu.ie3.simona.util.TickUtil.TickLong +import edu.ie3.util.quantities.PowerSystemUnits +import edu.ie3.util.quantities.QuantityUtils.RichQuantityDouble +import edu.ie3.util.scala.quantities.DefaultQuantities._ +import edu.ie3.util.scala.quantities.ReactivePower +import org.apache.pekko.actor.ActorRef +import org.apache.pekko.actor.typed.scaladsl.adapter.ClassicActorRefOps +import org.apache.pekko.actor.typed.{ActorRef => TypedActorRef} +import squants.{Dimensionless, Each, Power} + +import java.time.ZonedDateTime +import java.util.UUID +import scala.collection.SortedSet +import scala.reflect.{ClassTag, classTag} + +trait StorageAgentFundamentals + extends ParticipantAgentFundamentals[ + ApparentPower, + StorageRelevantData, + StorageState, + ParticipantStateData[ApparentPower], + StorageInput, + StorageRuntimeConfig, + StorageModel, + ] { + this: StorageAgent => + override val alternativeResult: ApparentPower = ZERO_POWER + + override protected val pdClassTag: ClassTag[ApparentPower] = + classTag[ApparentPower] + + /** Abstract definition, individual implementations found in individual agent + * fundamental classes + */ + override def determineModelBaseStateData( + inputModel: ParticipantStateData.InputModelContainer[StorageInput], + modelConfig: StorageRuntimeConfig, + services: Iterable[SecondaryDataService[_ <: Data.SecondaryData]], + simulationStartDate: ZonedDateTime, + simulationEndDate: ZonedDateTime, + resolution: Long, + requestVoltageDeviationThreshold: Double, + outputConfig: NotifierConfig, + maybeEmAgent: Option[TypedActorRef[FlexResponse]], + ): BaseStateData.ParticipantModelBaseStateData[ + ApparentPower, + StorageRelevantData, + StorageState, + StorageModel, + ] = { + if (maybeEmAgent.isEmpty) + throw new AgentInitializationException( + "StorageAgent needs to be EM-controlled." + ) + + /* Build the calculation model */ + val model = + buildModel( + inputModel, + modelConfig, + simulationStartDate, + simulationEndDate, + ) + + ParticipantModelBaseStateData[ + ApparentPower, + StorageRelevantData, + StorageState, + StorageModel, + ]( + simulationStartDate, + simulationEndDate, + model, + services, + outputConfig, + SortedSet.empty, + Map.empty, + requestVoltageDeviationThreshold, + ValueStore.forVoltage( + resolution, + Each( + inputModel.electricalInputModel.getNode + .getvTarget() + .to(PowerSystemUnits.PU) + .getValue + .doubleValue + ), + ), + ValueStore(resolution), + ValueStore(resolution), + ValueStore(resolution), + ValueStore(resolution), + maybeEmAgent.map( + FlexControlledData(_, self.toTyped[FlexRequest]) + ), + ) + } + + override def buildModel( + inputModel: InputModelContainer[StorageInput], + modelConfig: StorageRuntimeConfig, + simulationStartDate: ZonedDateTime, + simulationEndDate: ZonedDateTime, + ): StorageModel = StorageModel( + inputModel.electricalInputModel, + modelConfig.scaling, + simulationStartDate, + simulationEndDate, + modelConfig.initialSoc, + modelConfig.targetSoc, + ) + + override protected def createInitialState( + baseStateData: BaseStateData.ParticipantModelBaseStateData[ + ApparentPower, + StorageRelevantData, + StorageState, + StorageModel, + ] + ): StorageState = StorageState( + baseStateData.model.eStorage * baseStateData.model.initialSoc, + zeroKW, + SimonaConstants.INIT_SIM_TICK, + ) + + override protected def createCalcRelevantData( + baseStateData: ParticipantModelBaseStateData[ + ApparentPower, + StorageRelevantData, + StorageState, + StorageModel, + ], + tick: Long, + ): StorageRelevantData = + StorageRelevantData(tick) + + override val calculateModelPowerFunc: ( + Long, + BaseStateData.ParticipantModelBaseStateData[ + ApparentPower, + StorageRelevantData, + StorageState, + StorageModel, + ], + StorageState, + Dimensionless, + ) => ApparentPower = + (_, _, _, _) => + throw new InvalidRequestException( + "Storage model cannot be run without secondary data." + ) + + override def calculatePowerWithSecondaryDataAndGoToIdle( + baseStateData: ParticipantModelBaseStateData[ + ApparentPower, + StorageRelevantData, + StorageState, + StorageModel, + ], + modelState: StorageState, + currentTick: Long, + scheduler: ActorRef, + ): State = + throw new InvalidRequestException( + "StorageAgent cannot be used without EM control" + ) + + override def averageResults( + tickToResults: Map[Long, ApparentPower], + windowStart: Long, + windowEnd: Long, + activeToReactivePowerFuncOpt: Option[ + Power => ReactivePower + ], + ): ApparentPower = ParticipantAgentFundamentals.averageApparentPower( + tickToResults, + windowStart, + windowEnd, + activeToReactivePowerFuncOpt, + log, + ) + + override protected def buildResult( + uuid: UUID, + dateTime: ZonedDateTime, + result: ApparentPower, + ): SystemParticipantResult = new StorageResult( + dateTime, + uuid, + result.p.toMegawatts.asMegaWatt, + result.q.toMegavars.asMegaVar, + (-1d).asPercent, // dummy value + ) + + /** Additional actions on a new calculated simulation result. Overridden here + * because SOC needs to be calculated. + * + * @param baseStateData + * The base state data + * @param result + * that has been calculated for the current tick + * @param currentTick + * the current tick + * @return + * updated base state data + */ + override protected def handleCalculatedResult( + baseStateData: ParticipantModelBaseStateData[ + ApparentPower, + StorageRelevantData, + StorageState, + StorageModel, + ], + result: ApparentPower, + currentTick: Long, + ): ParticipantModelBaseStateData[ + ApparentPower, + StorageRelevantData, + StorageState, + StorageModel, + ] = { + + // announce last result to listeners + if (baseStateData.outputConfig.simulationResultInfo) { + val uuid = baseStateData.modelUuid + val dateTime = currentTick.toDateTime(baseStateData.startDate) + + val storedEnergy = baseStateData.stateDataStore + .get(currentTick) + .getOrElse( + throw new IllegalStateException( + s"State data for current tick $currentTick should be available." + ) + ) + .storedEnergy + + val soc = Each( + storedEnergy / baseStateData.model.eStorage + ).toPercent.asPercent + + val storageResult = new StorageResult( + dateTime, + uuid, + result.p.toMegawatts.asMegaWatt, + result.q.toMegavars.asMegaVar, + soc, + ) + + notifyListener(ParticipantResultEvent(storageResult)) + } + + baseStateData.copy( + resultValueStore = ValueStore.updateValueStore( + baseStateData.resultValueStore, + currentTick, + result, + ) + ) + } + + /** Handle an active power change by flex control. + * + * @param tick + * Tick, in which control is issued + * @param baseStateData + * Base state data of the agent + * @param data + * Calculation relevant data + * @param lastState + * Last known model state + * @param setPower + * Setpoint active power + * @return + * Updated model state, a result model and a [[FlexChangeIndicator]] + */ + override def handleControlledPowerChange( + tick: Long, + baseStateData: BaseStateData.ParticipantModelBaseStateData[ + ApparentPower, + StorageRelevantData, + StorageState, + StorageModel, + ], + data: StorageRelevantData, + lastState: StorageState, + setPower: Power, + ): (StorageState, ApparentPower, FlexChangeIndicator) = { + val (updatedState, flexChangeIndicator) = + baseStateData.model.handleControlledPowerChange(data, lastState, setPower) + // In edge cases, the model does not accept the given set power + // and returns an adapted value + val updatedSetPower = updatedState.chargingPower + + val voltage = getAndCheckNodalVoltage(baseStateData, tick) + val reactivePower = baseStateData.model.calculateReactivePower( + updatedSetPower, + voltage, + ) + + ( + updatedState, + ApparentPower(updatedSetPower, reactivePower), + flexChangeIndicator, + ) + } + + /** Update the last known model state with the given external, relevant data + * + * @param tick + * Tick to update state for + * @param modelState + * Last known model state + * @param calcRelevantData + * Data, relevant for calculation + * @param nodalVoltage + * Current nodal voltage of the agent + * @param model + * Model for calculation + * @return + * The updated state at given tick under consideration of calculation + * relevant data + */ + override protected def updateState( + tick: Long, + modelState: StorageState, + calcRelevantData: StorageRelevantData, + nodalVoltage: Dimensionless, + model: StorageModel, + ): StorageState = + throw new InvalidRequestException( + "StorageAgent cannot be used without EM control" + ) + +} diff --git a/src/main/scala/edu/ie3/simona/config/ConfigFailFast.scala b/src/main/scala/edu/ie3/simona/config/ConfigFailFast.scala index df199e55c5..3437944c78 100644 --- a/src/main/scala/edu/ie3/simona/config/ConfigFailFast.scala +++ b/src/main/scala/edu/ie3/simona/config/ConfigFailFast.scala @@ -8,23 +8,26 @@ package edu.ie3.simona.config import com.typesafe.config.{Config, ConfigException} import com.typesafe.scalalogging.LazyLogging -import edu.ie3.simona.config.SimonaConfig.Simona.Output.Sink.InfluxDb1x -import edu.ie3.simona.config.SimonaConfig.{ - BaseOutputConfig, - RefSystemConfig, - ResultKafkaParams, - Simona, - TransformerControlGroup, +import edu.ie3.simona.config.SimonaConfig.Simona.Input.Weather.Datasource.{ + CouchbaseParams, + InfluxDb1xParams, + SampleParams, + SqlParams, } +import edu.ie3.simona.config.SimonaConfig.Simona.Output.Sink.InfluxDb1x +import edu.ie3.simona.config.SimonaConfig._ import edu.ie3.simona.exceptions.InvalidConfigParameterException import edu.ie3.simona.io.result.ResultSinkType import edu.ie3.simona.model.participant.load.{LoadModelBehaviour, LoadReference} import edu.ie3.simona.service.primary.PrimaryServiceProxy -import edu.ie3.simona.service.weather.WeatherSource +import edu.ie3.simona.service.weather.WeatherSource.WeatherScheme import edu.ie3.simona.util.CollectionUtils +import edu.ie3.simona.util.ConfigUtil.CsvConfigUtil.checkBaseCsvParams import edu.ie3.simona.util.ConfigUtil.DatabaseConfigUtil.{ + checkCouchbaseParams, checkInfluxDb1xParams, checkKafkaParams, + checkSqlParams, } import edu.ie3.simona.util.ConfigUtil.{CsvConfigUtil, NotifierIdentifier} import edu.ie3.util.scala.ReflectionTools @@ -144,11 +147,19 @@ case object ConfigFailFast extends LazyLogging { simonaConfig.simona.output.participant ) + /* Check all output configurations for thermal models */ + checkThermalOutputConfig( + simonaConfig.simona.output.thermal + ) + /* Check power flow resolution configuration */ checkPowerFlowResolutionConfiguration(simonaConfig.simona.powerflow) /* Check control scheme definitions */ simonaConfig.simona.control.foreach(checkControlSchemes) + + /* Check correct parameterization of storages */ + checkStoragesConfig(simonaConfig.simona.runtime.participant.storage) } /** Checks for valid sink configuration @@ -429,8 +440,8 @@ case object ConfigFailFast extends LazyLogging { /** Sanity checks for a [[SimonaConfig.RefSystemConfig]] * - * @param refSystem - * the [[SimonaConfig.RefSystemConfig]] that should be checked + * @param refSystems + * a list of [[SimonaConfig.RefSystemConfig]]s that should be checked */ private def checkRefSystem(refSystems: List[RefSystemConfig]): Unit = { @@ -537,8 +548,120 @@ case object ConfigFailFast extends LazyLogging { PrimaryServiceProxy.checkConfig(primary) private def checkWeatherDataSource( - dataSourceConfig: SimonaConfig.Simona.Input.Weather.Datasource - ): Unit = WeatherSource.checkConfig(dataSourceConfig) + weatherDataSourceCfg: SimonaConfig.Simona.Input.Weather.Datasource + ): Unit = { + // check coordinate source + val definedCoordinateSource: String = checkCoordinateSource( + weatherDataSourceCfg.coordinateSource + ) + + /* Check, if the column scheme is supported */ + if (!WeatherScheme.isEligibleInput(weatherDataSourceCfg.scheme)) + throw new InvalidConfigParameterException( + s"The weather data scheme '${weatherDataSourceCfg.scheme}' is not supported. Supported schemes:\n\t${WeatherScheme.values + .mkString("\n\t")}" + ) + + // check weather source parameters + val supportedWeatherSources = + Set("influxdb1x", "csv", "sql", "couchbase", "sample") + val definedWeatherSources = Vector( + weatherDataSourceCfg.sampleParams, + weatherDataSourceCfg.csvParams, + weatherDataSourceCfg.influxDb1xParams, + weatherDataSourceCfg.couchbaseParams, + weatherDataSourceCfg.sqlParams, + ).filter(_.isDefined) + + // check that only one source is defined + if (definedWeatherSources.size > 1) + throw new InvalidConfigParameterException( + s"Multiple weather sources defined: '${definedWeatherSources.map(_.getClass.getSimpleName).mkString("\n\t")}'." + + s"Please define only one source!\nAvailable sources:\n\t${supportedWeatherSources.mkString("\n\t")}" + ) + + definedWeatherSources.headOption.flatten match { + case Some(baseCsvParams: BaseCsvParams) => + checkBaseCsvParams(baseCsvParams, "WeatherSource") + case Some(params: CouchbaseParams) => + checkCouchbaseParams(params) + case Some(InfluxDb1xParams(database, _, url)) => + checkInfluxDb1xParams("WeatherSource", url, database) + case Some(params: SqlParams) => + checkSqlParams(params) + case Some(_: SampleParams) => + // sample weather, no check required + // coordinate source must be sample coordinate source + if (weatherDataSourceCfg.coordinateSource.sampleParams.isEmpty) { + // cannot use sample weather source with other combination of weather source than sample weather source + throw new InvalidConfigParameterException( + s"Invalid coordinate source " + + s"'$definedCoordinateSource' defined for SampleWeatherSource. " + + "Please adapt the configuration to use sample coordinate source for weather data!" + ) + } + case None | Some(_) => + throw new InvalidConfigParameterException( + s"No weather source defined! This is currently not supported! Please provide the config parameters for one " + + s"of the following weather sources:\n\t${supportedWeatherSources.mkString("\n\t")}" + ) + } + } + + /** Check the provided coordinate id data source configuration to ensure its + * validity. For any invalid configuration parameters exceptions are thrown. + * + * @param coordinateSourceConfig + * the config to be checked + * @return + * the name of the defined + * [[edu.ie3.datamodel.io.source.IdCoordinateSource]] + */ + private def checkCoordinateSource( + coordinateSourceConfig: SimonaConfig.Simona.Input.Weather.Datasource.CoordinateSource + ): String = { + val supportedCoordinateSources = Set("csv", "sql", "sample") + val definedCoordSources = Vector( + coordinateSourceConfig.sampleParams, + coordinateSourceConfig.csvParams, + coordinateSourceConfig.sqlParams, + ).filter(_.isDefined) + + // check that only one source is defined + if (definedCoordSources.size > 1) + throw new InvalidConfigParameterException( + s"Multiple coordinate sources defined: '${definedCoordSources.map(_.getClass.getSimpleName).mkString("\n\t")}'." + + s"Please define only one source!\nAvailable sources:\n\t${supportedCoordinateSources.mkString("\n\t")}" + ) + + definedCoordSources.headOption.flatten match { + case Some(baseCsvParams: BaseCsvParams) => + checkBaseCsvParams(baseCsvParams, "CoordinateSource") + + // check the grid model configuration + val gridModel = coordinateSourceConfig.gridModel.toLowerCase + if (gridModel != "icon" && gridModel != "cosmo") { + throw new InvalidConfigParameterException( + s"Grid model '$gridModel' is not supported!" + ) + } + + "csv" + case Some(sqlParams: SqlParams) => + checkSqlParams(sqlParams) + "sql" + case Some( + _: SimonaConfig.Simona.Input.Weather.Datasource.CoordinateSource.SampleParams + ) => + "sample" + case None | Some(_) => + throw new InvalidConfigParameterException( + s"No coordinate source defined! This is currently not supported! Please provide the config parameters for one " + + s"of the following coordinate sources:\n\t${supportedCoordinateSources.mkString("\n\t")}" + ) + } + + } /** Check the config sub tree for output parameterization * @@ -556,11 +679,23 @@ case object ConfigFailFast extends LazyLogging { ) ) - checkDefaultBaseOutputConfig( - subConfig.defaultConfig, - defaultString = "default", - ) - checkIndividualParticipantsOutputConfigs(subConfig.individualConfigs) + implicit val elementType: String = "participant" + + checkDefaultBaseOutputConfig(subConfig.defaultConfig) + checkIndividualOutputConfigs(subConfig.individualConfigs) + } + + /** Check the config sub tree for output parameterization + * + * @param subConfig + * Output sub config tree for participants + */ + private def checkThermalOutputConfig( + subConfig: SimonaConfig.Simona.Output.Thermal + ): Unit = { + implicit val elementType: String = "thermal" + checkDefaultBaseOutputConfig(subConfig.defaultConfig) + checkIndividualOutputConfigs(subConfig.individualConfigs) } /** Checks resolution of power flow calculation @@ -648,6 +783,43 @@ case object ConfigFailFast extends LazyLogging { } } + /** Check the suitability of storage config parameters. + * + * @param StorageRuntimeConfig + * RuntimeConfig of Storages + */ + private def checkStoragesConfig( + storageConfig: SimonaConfig.Simona.Runtime.Participant.Storage + ): Unit = { + if ( + storageConfig.defaultConfig.initialSoc < 0.0 || storageConfig.defaultConfig.initialSoc > 1.0 + ) + throw new RuntimeException( + s"StorageRuntimeConfig: Default initial SOC needs to be between 0.0 and 1.0." + ) + + if ( + storageConfig.defaultConfig.targetSoc.exists( + _ < 0.0 + ) || storageConfig.defaultConfig.targetSoc.exists(_ > 1.0) + ) + throw new RuntimeException( + s"StorageRuntimeConfig: Default target SOC needs to be between 0.0 and 1.0." + ) + + storageConfig.individualConfigs.foreach { config => + if (config.initialSoc < 0.0 || config.initialSoc > 1.0) + throw new RuntimeException( + s"StorageRuntimeConfig: ${config.uuids} initial SOC needs to be between 0.0 and 1.0." + ) + + if (config.targetSoc.exists(_ < 0.0) || config.targetSoc.exists(_ > 1.0)) + throw new RuntimeException( + s"StorageRuntimeConfig: ${config.uuids} target SOC needs to be between 0.0 and 1.0." + ) + } + } + /** Check the default config * * @param config @@ -657,26 +829,26 @@ case object ConfigFailFast extends LazyLogging { */ private def checkDefaultBaseOutputConfig( config: SimonaConfig.BaseOutputConfig, - defaultString: String, - ): Unit = { + defaultString: String = "default", + )(implicit elementType: String): Unit = { if ( StringUtils .cleanString(config.notifier) .toLowerCase != StringUtils.cleanString(defaultString).toLowerCase ) logger.warn( - s"You provided '${config.notifier}' as model type for the default participant output config. This will not be considered!" + s"You provided '${config.notifier}' as model type for the default $elementType output config. This will not be considered!" ) } - /** Checks the participant output configurations on duplicates + /** Checks the given output configurations on duplicates * * @param configs * List of individual config entries */ - private def checkIndividualParticipantsOutputConfigs( + private def checkIndividualOutputConfigs( configs: List[SimonaConfig.BaseOutputConfig] - ): Unit = { + )(implicit elementType: String): Unit = { val duplicateKeys = configs .map(config => StringUtils.cleanString(config.notifier).toLowerCase()) .groupMapReduce(identity)(_ => 1)(_ + _) @@ -687,9 +859,21 @@ case object ConfigFailFast extends LazyLogging { if (duplicateKeys.nonEmpty) throw new InvalidConfigParameterException( - s"There are multiple output configurations for participant types '${duplicateKeys.mkString(",")}'." + s"There are multiple output configurations for $elementType types '${duplicateKeys.mkString(",")}'." ) + implicit val exceptedNotifiers: Set[NotifierIdentifier.Value] = + elementType match { + case "participant" => + NotifierIdentifier.getParticipantIdentifiers + case "thermal" => + NotifierIdentifier.getThermalIdentifiers + case other => + throw new InvalidConfigParameterException( + s"The output config for $other has no notifiers!" + ) + } + configs.foreach(checkBaseOutputConfig) } @@ -697,23 +881,39 @@ case object ConfigFailFast extends LazyLogging { * * @param config * to be checked + * @param exceptedNotifiers + * a set of all valid identifiers */ - private def checkBaseOutputConfig(config: BaseOutputConfig): Unit = { - checkNotifierIdentifier(config.notifier) + private def checkBaseOutputConfig( + config: BaseOutputConfig + )(implicit exceptedNotifiers: Set[NotifierIdentifier.Value]): Unit = { + checkNotifierIdentifier(config.notifier, exceptedNotifiers) } /** Check the validity of the identifier String * * @param id * identifier String to check + * @param exceptedNotifiers + * a set of all valid identifiers */ - private def checkNotifierIdentifier(id: String): Unit = { + private def checkNotifierIdentifier( + id: String, + exceptedNotifiers: Set[NotifierIdentifier.Value], + ): Unit = { try { - NotifierIdentifier(id) + val notifier = NotifierIdentifier(id) + + if (!exceptedNotifiers.contains(notifier)) { + throw new InvalidConfigParameterException( + s"The identifier '$id' you provided is not valid. Valid input: ${exceptedNotifiers.map(_.toString).mkString(",")}" + ) + } + } catch { case e: NoSuchElementException => throw new InvalidConfigParameterException( - s"The identifier '$id' you provided is not valid. Valid input: ${NotifierIdentifier.values.map(_.toString).mkString(",")}", + s"The identifier '$id' you provided is not valid. Valid input: ${exceptedNotifiers.map(_.toString).mkString(",")}", e, ) } diff --git a/src/main/scala/edu/ie3/simona/config/SimonaConfig.scala b/src/main/scala/edu/ie3/simona/config/SimonaConfig.scala index 2a046a31cf..d8ec6729ef 100644 --- a/src/main/scala/edu/ie3/simona/config/SimonaConfig.scala +++ b/src/main/scala/edu/ie3/simona/config/SimonaConfig.scala @@ -871,6 +871,73 @@ object SimonaConfig { } + final case class StorageRuntimeConfig( + override val calculateMissingReactivePowerWithModel: scala.Boolean, + override val scaling: scala.Double, + override val uuids: scala.List[java.lang.String], + initialSoc: scala.Double, + targetSoc: scala.Option[scala.Double], + ) extends BaseRuntimeConfig( + calculateMissingReactivePowerWithModel, + scaling, + uuids, + ) + object StorageRuntimeConfig { + def apply( + c: com.typesafe.config.Config, + parentPath: java.lang.String, + $tsCfgValidator: $TsCfgValidator, + ): SimonaConfig.StorageRuntimeConfig = { + SimonaConfig.StorageRuntimeConfig( + initialSoc = + if (c.hasPathOrNull("initialSoc")) c.getDouble("initialSoc") else 0, + targetSoc = + if (c.hasPathOrNull("targetSoc")) Some(c.getDouble("targetSoc")) + else None, + calculateMissingReactivePowerWithModel = $_reqBln( + parentPath, + c, + "calculateMissingReactivePowerWithModel", + $tsCfgValidator, + ), + scaling = $_reqDbl(parentPath, c, "scaling", $tsCfgValidator), + uuids = $_L$_str(c.getList("uuids"), parentPath, $tsCfgValidator), + ) + } + private def $_reqBln( + parentPath: java.lang.String, + c: com.typesafe.config.Config, + path: java.lang.String, + $tsCfgValidator: $TsCfgValidator, + ): scala.Boolean = { + if (c == null) false + else + try c.getBoolean(path) + catch { + case e: com.typesafe.config.ConfigException => + $tsCfgValidator.addBadPath(parentPath + path, e) + false + } + } + + private def $_reqDbl( + parentPath: java.lang.String, + c: com.typesafe.config.Config, + path: java.lang.String, + $tsCfgValidator: $TsCfgValidator, + ): scala.Double = { + if (c == null) 0 + else + try c.getDouble(path) + catch { + case e: com.typesafe.config.ConfigException => + $tsCfgValidator.addBadPath(parentPath + path, e) + 0 + } + } + + } + final case class TransformerControlGroup( measurements: scala.List[java.lang.String], transformers: scala.List[java.lang.String], @@ -2321,6 +2388,7 @@ object SimonaConfig { load: SimonaConfig.Simona.Runtime.Participant.Load, pv: SimonaConfig.Simona.Runtime.Participant.Pv, requestVoltageDeviationThreshold: scala.Double, + storage: SimonaConfig.Simona.Runtime.Participant.Storage, wec: SimonaConfig.Simona.Runtime.Participant.Wec, ) object Participant { @@ -2594,6 +2662,51 @@ object SimonaConfig { } } + final case class Storage( + defaultConfig: SimonaConfig.StorageRuntimeConfig, + individualConfigs: scala.List[SimonaConfig.StorageRuntimeConfig], + ) + object Storage { + def apply( + c: com.typesafe.config.Config, + parentPath: java.lang.String, + $tsCfgValidator: $TsCfgValidator, + ): SimonaConfig.Simona.Runtime.Participant.Storage = { + SimonaConfig.Simona.Runtime.Participant.Storage( + defaultConfig = SimonaConfig.StorageRuntimeConfig( + if (c.hasPathOrNull("defaultConfig")) + c.getConfig("defaultConfig") + else + com.typesafe.config.ConfigFactory + .parseString("defaultConfig{}"), + parentPath + "defaultConfig.", + $tsCfgValidator, + ), + individualConfigs = $_LSimonaConfig_StorageRuntimeConfig( + c.getList("individualConfigs"), + parentPath, + $tsCfgValidator, + ), + ) + } + private def $_LSimonaConfig_StorageRuntimeConfig( + cl: com.typesafe.config.ConfigList, + parentPath: java.lang.String, + $tsCfgValidator: $TsCfgValidator, + ): scala.List[SimonaConfig.StorageRuntimeConfig] = { + import scala.jdk.CollectionConverters._ + cl.asScala + .map(cv => + SimonaConfig.StorageRuntimeConfig( + cv.asInstanceOf[com.typesafe.config.ConfigObject].toConfig, + parentPath, + $tsCfgValidator, + ) + ) + .toList + } + } + final case class Wec( defaultConfig: SimonaConfig.WecRuntimeConfig, individualConfigs: scala.List[SimonaConfig.WecRuntimeConfig], @@ -2686,6 +2799,12 @@ object SimonaConfig { if (c.hasPathOrNull("requestVoltageDeviationThreshold")) c.getDouble("requestVoltageDeviationThreshold") else 1e-14, + storage = SimonaConfig.Simona.Runtime.Participant.Storage( + if (c.hasPathOrNull("storage")) c.getConfig("storage") + else com.typesafe.config.ConfigFactory.parseString("storage{}"), + parentPath + "storage.", + $tsCfgValidator, + ), wec = SimonaConfig.Simona.Runtime.Participant.Wec( if (c.hasPathOrNull("wec")) c.getConfig("wec") else com.typesafe.config.ConfigFactory.parseString("wec{}"), diff --git a/src/main/scala/edu/ie3/simona/main/RunSimona.scala b/src/main/scala/edu/ie3/simona/main/RunSimona.scala index 707da41338..8265ed0cdb 100644 --- a/src/main/scala/edu/ie3/simona/main/RunSimona.scala +++ b/src/main/scala/edu/ie3/simona/main/RunSimona.scala @@ -11,6 +11,7 @@ import edu.ie3.simona.sim.setup.SimonaSetup import edu.ie3.util.scala.quantities.QuantityUtil import org.apache.pekko.util.Timeout +import java.nio.file.Path import java.util.Locale import scala.concurrent.duration.FiniteDuration import scala.util.Random @@ -39,7 +40,7 @@ trait RunSimona[T <: SimonaSetup] extends LazyLogging { val successful = run(simonaSetup) - printGoodbye() + printGoodbye(successful, simonaSetup.logOutputDir) // prevents cutting of the log when having a fast simulation Thread.sleep(1000) @@ -54,7 +55,10 @@ trait RunSimona[T <: SimonaSetup] extends LazyLogging { ) } - private def printGoodbye(): Unit = { + private def printGoodbye( + successful: Boolean, + outputPath: String = "", + ): Unit = { val myWords = Array( "\"Vielleicht ist heute ein besonders guter Tag zum Sterben.\" - Worf (in Star Trek: Der erste Kontakt)", "\"Assimiliert das!\" - Worf (in Star Trek: Der erste Kontakt)", @@ -68,6 +72,17 @@ trait RunSimona[T <: SimonaSetup] extends LazyLogging { val randIdx = rand.nextInt(myWords.length) logger.info(myWords(randIdx)) logger.info("Goodbye!") + + if (!successful) { + // to ensure that the link to the log is printed last + Thread.sleep(1000) + + val path = Path.of(outputPath).resolve("simona.log").toUri + + logger.error( + s"Simulation stopped due to the occurrence of an error! The full log can be found here: $path" + ) + } } /** Method to be implemented to setup everything that is necessary for a diff --git a/src/main/scala/edu/ie3/simona/model/participant/StorageModel.scala b/src/main/scala/edu/ie3/simona/model/participant/StorageModel.scala new file mode 100644 index 0000000000..02f82d2670 --- /dev/null +++ b/src/main/scala/edu/ie3/simona/model/participant/StorageModel.scala @@ -0,0 +1,387 @@ +/* + * © 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.participant + +import edu.ie3.datamodel.models.input.system.StorageInput +import edu.ie3.simona.agent.participant.data.Data.PrimaryData.ApparentPower +import edu.ie3.simona.model.SystemComponent +import edu.ie3.simona.model.participant.StorageModel.{ + RefTargetSocParams, + StorageRelevantData, + StorageState, +} +import edu.ie3.simona.model.participant.control.QControl +import edu.ie3.simona.ontology.messages.flex.FlexibilityMessage.ProvideFlexOptions +import edu.ie3.simona.ontology.messages.flex.MinMaxFlexibilityMessage.ProvideMinMaxFlexOptions +import edu.ie3.util.quantities.PowerSystemUnits +import edu.ie3.util.scala.OperationInterval +import edu.ie3.util.scala.quantities.DefaultQuantities._ +import squants.energy.{KilowattHours, Kilowatts} +import squants.{Dimensionless, Each, Energy, Power, Seconds} + +import java.time.ZonedDateTime +import java.util.UUID + +final case class StorageModel( + uuid: UUID, + id: String, + operationInterval: OperationInterval, + qControl: QControl, + sRated: Power, + cosPhiRated: Double, + eStorage: Energy, + pMax: Power, + eta: Dimensionless, + initialSoc: Double, + targetSoc: Option[Double], +) extends SystemParticipant[StorageRelevantData, ApparentPower, StorageState]( + uuid, + id, + operationInterval, + qControl, + sRated, + cosPhiRated, + ) { + + 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 = 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 calculatePower( + tick: Long, + voltage: Dimensionless, + modelState: StorageState, + data: StorageRelevantData, + ): ApparentPower = + throw new NotImplementedError( + "Storage model cannot calculate power without flexibility control." + ) + + override protected def calculateActivePower( + modelState: StorageState, + data: StorageRelevantData, + ): Power = + throw new NotImplementedError( + "Storage model cannot calculate power without flexibility control." + ) + + override def determineFlexOptions( + data: StorageRelevantData, + lastState: StorageState, + ): ProvideFlexOptions = { + val currentStoredEnergy = + determineCurrentState(lastState, data.currentTick) + + val chargingPossible = !isFull(currentStoredEnergy) + val dischargingPossible = !isEmpty(currentStoredEnergy) + + val refPower = refTargetSoc + .map { targetParams => + if (currentStoredEnergy <= targetParams.targetWithPosMargin) { + if (currentStoredEnergy >= 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, + ) + } + + private def calcNetPower(setPower: Power): 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 + } + + override def handleControlledPowerChange( + data: StorageRelevantData, + lastState: StorageState, + setPower: Power, + ): (StorageState, FlexChangeIndicator) = { + val currentStoredEnergy = + determineCurrentState(lastState, data.currentTick) + + 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(currentStoredEnergy)) + // do not keep discharging if we're already empty (including safety margin) + || (setPower < zeroKW && isEmpty(currentStoredEnergy)) + ) + zeroKW + else + setPower + + // net power after considering efficiency + val netPower = calcNetPower(adaptedSetPower) + + val currentState = + StorageState( + currentStoredEnergy, + adaptedSetPower, + data.currentTick, + ) + + // 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(currentStoredEnergy) || isFull(currentStoredEnergy) + // if target soc is enabled, we can also be at that exact point + val isAtTarget = refTargetSoc.exists { targetParams => + currentStoredEnergy <= targetParams.targetWithPosMargin && + currentStoredEnergy >= targetParams.targetWithNegMargin + } + val isChargingOrDischarging = netPower != 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 netPower 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(currentStoredEnergy) && setPower > zeroKW) || + (isEmpty(currentStoredEnergy) && setPower < zeroKW) || + (isAtTarget && setPower != zeroKW) + + val activateAtNextTick = + ((isEmptyOrFull || isAtTarget) && isChargingOrDischarging) || hasObsoleteFlexOptions + + // calculate the time span until we're full or empty, if applicable + val maybeTimeSpan = + if (!isChargingOrDischarging) { + // we're at 0 kW, do nothing + None + } else if (netPower > zeroKW) { + // we're charging, calculate time until we're full or at target energy + + val closestEnergyTarget = refTargetSoc + .flatMap { targetParams => + Option.when( + currentStoredEnergy <= targetParams.targetWithNegMargin + )(targetParams.targetSoc) + } + .getOrElse(eStorage) + + val energyToFull = closestEnergyTarget - currentStoredEnergy + Some(energyToFull / netPower) + } else { + // we're discharging, calculate time until we're at lowest energy allowed or at target energy + + val closestEnergyTarget = refTargetSoc + .flatMap { targetParams => + Option.when( + currentStoredEnergy >= targetParams.targetWithPosMargin + )(targetParams.targetSoc) + } + .getOrElse(minEnergy) + + val energyToEmpty = currentStoredEnergy - closestEnergyTarget + Some(energyToEmpty / (netPower * (-1))) + } + + // calculate the tick from time span + val maybeNextTick = maybeTimeSpan.map { timeSpan => + val timeSpanTicks = Math.round(timeSpan.toSeconds) + data.currentTick + timeSpanTicks + } + + (currentState, FlexChangeIndicator(activateAtNextTick, maybeNextTick)) + } + + private def determineCurrentState( + lastState: StorageState, + currentTick: Long, + ): Energy = { + val timespan = Seconds(currentTick - lastState.tick) + val netPower = calcNetPower(lastState.chargingPower) + val energyChange = netPower * timespan + + val newEnergy = lastState.storedEnergy + energyChange + + // don't allow under- or overcharge e.g. due to tick rounding error + minEnergy.max(eStorage.min(newEnergy)) + } + + /** @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 { + + final case class StorageRelevantData( + currentTick: Long + ) extends CalcRelevantData + + /** @param storedEnergy + * The amount of currently stored energy + * @param chargingPower + * The power with which the storage is (dis-)charging, valid until the next + * state. Gross value that is valid outside the model, i.e. before + * considering efficiency etc. + * @param tick + * The tick at which this state is valid + */ + final case class StorageState( + storedEnergy: Energy, + chargingPower: Power, + tick: Long, + ) extends ModelState + + /** @param targetSoc + * The SOC that the StorageModel aims at, i.e. that it prefers to + * charge/discharge towards + * @param targetWithPosMargin + * The targetSoc plus a tolerance margin + * @param targetWithNegMargin + * The targetSoc minus a tolerance margin + */ + final case class RefTargetSocParams( + targetSoc: Energy, + targetWithPosMargin: Energy, + targetWithNegMargin: Energy, + ) + + def apply( + inputModel: StorageInput, + scalingFactor: Double, + simulationStartDate: ZonedDateTime, + simulationEndDate: ZonedDateTime, + initialSoc: Double, + targetSoc: Option[Double], + ): StorageModel = { + + val scaledInput = inputModel.copy().scale(scalingFactor).build() + + /* Determine the operation interval */ + val operationInterval: OperationInterval = + SystemComponent.determineOperationInterval( + simulationStartDate, + simulationEndDate, + scaledInput.getOperationTime, + ) + + // build the fixed feed in model + val model = StorageModel( + scaledInput.getUuid, + scaledInput.getId, + operationInterval, + QControl.apply(scaledInput.getqCharacteristics), + Kilowatts( + scaledInput.getType.getsRated + .to(PowerSystemUnits.KILOWATT) + .getValue + .doubleValue + ), + scaledInput.getType.getCosPhiRated, + KilowattHours( + scaledInput.getType.geteStorage + .to(PowerSystemUnits.KILOWATTHOUR) + .getValue + .doubleValue + ), + Kilowatts( + scaledInput.getType.getpMax + .to(PowerSystemUnits.KILOWATT) + .getValue + .doubleValue + ), + Each( + scaledInput.getType.getEta.to(PowerSystemUnits.PU).getValue.doubleValue + ), + initialSoc, + targetSoc, + ) + + model.enable() + model + } + +} diff --git a/src/main/scala/edu/ie3/simona/service/weather/SampleWeatherSource.scala b/src/main/scala/edu/ie3/simona/service/weather/SampleWeatherSource.scala index 5788aca327..c8045ced59 100644 --- a/src/main/scala/edu/ie3/simona/service/weather/SampleWeatherSource.scala +++ b/src/main/scala/edu/ie3/simona/service/weather/SampleWeatherSource.scala @@ -168,6 +168,19 @@ object SampleWeatherSource { else Vector.empty[CoordinateDistance].asJava } + + override def findCornerPoints( + coordinate: Point, + distance: ComparableQuantity[Length], + ): util.List[CoordinateDistance] = + findCornerPoints( + coordinate, + getClosestCoordinates(coordinate, 9, distance), + ) + + override def validate(): Unit = { + /* nothing to do here */ + } } // these lists contain the hourly weather values for each first of the month of 2011 + january of diff --git a/src/main/scala/edu/ie3/simona/service/weather/WeatherService.scala b/src/main/scala/edu/ie3/simona/service/weather/WeatherService.scala index 7543dc74c8..a75b0c38fd 100644 --- a/src/main/scala/edu/ie3/simona/service/weather/WeatherService.scala +++ b/src/main/scala/edu/ie3/simona/service/weather/WeatherService.scala @@ -123,8 +123,7 @@ final case class WeatherService( ): Try[(WeatherInitializedStateData, Option[Long])] = initServiceData match { case InitWeatherServiceStateData(sourceDefinition) => - val weatherSource = - WeatherSource(sourceDefinition, simulationStart) + val weatherSource = WeatherSource(sourceDefinition) /* What is the first tick to be triggered for? And what are further activation ticks */ val (maybeNextTick, furtherActivationTicks) = SortedDistinctSeq( diff --git a/src/main/scala/edu/ie3/simona/service/weather/WeatherSource.scala b/src/main/scala/edu/ie3/simona/service/weather/WeatherSource.scala index b996d5a475..12a26c3f6c 100644 --- a/src/main/scala/edu/ie3/simona/service/weather/WeatherSource.scala +++ b/src/main/scala/edu/ie3/simona/service/weather/WeatherSource.scala @@ -6,11 +6,11 @@ package edu.ie3.simona.service.weather +import edu.ie3.datamodel.exceptions.SourceException import edu.ie3.datamodel.io.connectors.SqlConnector import edu.ie3.datamodel.io.factory.timeseries.{ CosmoIdCoordinateFactory, IconIdCoordinateFactory, - IdCoordinateFactory, SqlIdCoordinateFactory, } import edu.ie3.datamodel.io.naming.FileNamingStrategy @@ -21,21 +21,13 @@ import edu.ie3.datamodel.models.value.WeatherValue import edu.ie3.simona.config.SimonaConfig import edu.ie3.simona.config.SimonaConfig.BaseCsvParams import edu.ie3.simona.config.SimonaConfig.Simona.Input.Weather.Datasource._ -import edu.ie3.simona.exceptions.{ - InvalidConfigParameterException, - ServiceException, -} +import edu.ie3.simona.exceptions.ServiceException import edu.ie3.simona.ontology.messages.services.WeatherMessage.WeatherData import edu.ie3.simona.service.weather.WeatherSource.{ AgentCoordinates, WeightedCoordinates, } -import edu.ie3.simona.util.ConfigUtil.CsvConfigUtil.checkBaseCsvParams -import edu.ie3.simona.util.ConfigUtil.DatabaseConfigUtil.{ - checkCouchbaseParams, - checkInfluxDb1xParams, - checkSqlParams, -} +import edu.ie3.simona.service.weather.WeatherSourceWrapper.buildPSDMSource import edu.ie3.simona.util.ParsableEnumeration import edu.ie3.util.geo.{CoordinateDistance, GeoUtils} import edu.ie3.util.quantities.PowerSystemUnits @@ -108,64 +100,26 @@ trait WeatherSource { ): Try[Iterable[CoordinateDistance]] = { val queryPoint = coordinate.toPoint - /* Go and get the nearest coordinates, that are known to the weather source */ - val nearestCoords = idCoordinateSource - .getClosestCoordinates( - queryPoint, - amountOfInterpolationCoords, - maxCoordinateDistance, - ) - .asScala + /* Go and get the corner coordinates, that are within a given distance */ + val possibleCornerPoints = idCoordinateSource.findCornerPoints( + queryPoint, + maxCoordinateDistance, + ) - nearestCoords.find(coordinateDistance => - coordinateDistance.getCoordinateB.equalsExact(queryPoint, 1e-6) - ) match { - case Some(exactHit) => - /* The queried coordinate hit one of the weather coordinates. Don't average and take it directly */ - Success(Vector(exactHit)) - case None if nearestCoords.size < amountOfInterpolationCoords => + possibleCornerPoints.size() match { + case 1 => + // found one exact match + Success(possibleCornerPoints.asScala) + case nr if nr == amountOfInterpolationCoords => + // found enough points for interpolating + Success(possibleCornerPoints.asScala) + case invalidNo => Failure( ServiceException( - s"There are not enough coordinates for averaging. Found ${nearestCoords.size} within the given distance of " + + s"There are not enough coordinates for averaging. Found $invalidNo within the given distance of " + s"$maxCoordinateDistance but need $amountOfInterpolationCoords. Please make sure that there are enough coordinates within the given distance." ) ) - case None => - /* Check if enough coordinates are within the coordinate distance limit */ - val nearestCoordsInMaxDistance = nearestCoords.filter(coordDistance => - coordDistance.getDistance - .isLessThan(maxCoordinateDistance) - ) - if (nearestCoordsInMaxDistance.size < amountOfInterpolationCoords) { - Failure( - ServiceException( - s"There are not enough coordinates within the max coordinate distance of $maxCoordinateDistance. Found ${nearestCoordsInMaxDistance.size} but need $amountOfInterpolationCoords. Please make sure that there are enough coordinates within the given distance." - ) - ) - } else { - /* Check, if the queried coordinate is surrounded at each quadrant */ - val (topLeft, topRight, bottomLeft, bottomRight) = nearestCoords - .map(_.getCoordinateB) - .foldLeft((false, false, false, false)) { - case ((tl, tr, bl, br), point) => - ( - tl || (point.getX < queryPoint.getX && point.getY > queryPoint.getY), - tr || (point.getX > queryPoint.getX && point.getY > queryPoint.getY), - bl || (point.getX < queryPoint.getX && point.getY < queryPoint.getY), - br || (point.getX > queryPoint.getX && point.getY < queryPoint.getY), - ) - } - - /* There has to be a coordinate in each quadrant */ - if (topLeft && topRight && bottomLeft && bottomRight) - Success(nearestCoords) - else - Failure( - ServiceException( - s"The queried point shall be surrounded by $amountOfInterpolationCoords weather coordinates, which are in each quadrant. This is not the case." - ) - ) - } } } @@ -293,137 +247,37 @@ trait WeatherSource { object WeatherSource { def apply( - dataSourceConfig: SimonaConfig.Simona.Input.Weather.Datasource, - simulationStart: ZonedDateTime, - ): WeatherSource = - checkConfig(dataSourceConfig)(simulationStart) - - /** Check the provided weather data source configuration to ensure its - * validity. If the configuration is valid, a function to build the - * corresponding [[WeatherSource]] instance is returned. For any invalid - * configuration parameters exceptions are thrown. - * - * @param weatherDataSourceCfg - * the config to be checked - * @return - * a function that can be used to actually build the configured weather - * data source - */ - def checkConfig( weatherDataSourceCfg: SimonaConfig.Simona.Input.Weather.Datasource - ): ZonedDateTime => WeatherSource = { + )(implicit simulationStart: ZonedDateTime): WeatherSource = { + // get coordinate source + implicit val coordinateSourceFunction: IdCoordinateSource = + buildCoordinateSource(weatherDataSourceCfg.coordinateSource) - // check and get coordinate source - val coordinateSourceFunction: () => IdCoordinateSource = - checkCoordinateSource( - weatherDataSourceCfg.coordinateSource - ) - - /* Check, if the column scheme is supported */ - if (!WeatherScheme.isEligibleInput(weatherDataSourceCfg.scheme)) - throw new InvalidConfigParameterException( - s"The weather data scheme '${weatherDataSourceCfg.scheme}' is not supported. Supported schemes:\n\t${WeatherScheme.values - .mkString("\n\t")}" - ) - - // check weather source parameters - val supportedWeatherSources = - Set("influxdb1x", "csv", "sql", "couchbase", "sample") val definedWeatherSources = Vector( weatherDataSourceCfg.sampleParams, weatherDataSourceCfg.csvParams, weatherDataSourceCfg.influxDb1xParams, weatherDataSourceCfg.couchbaseParams, weatherDataSourceCfg.sqlParams, - ).filter(_.isDefined) + ).find(_.isDefined).flatten + + if (definedWeatherSources.isEmpty) { + // should not happen, due to the config fail fast check + throw new SourceException( + s"Expected a WeatherSource, but no source where defined in $weatherDataSourceCfg." + ) + } - val timestampPattern: Option[String] = weatherDataSourceCfg.timestampPattern - val scheme: String = weatherDataSourceCfg.scheme - val resolution: Option[Long] = weatherDataSourceCfg.resolution - val distance: ComparableQuantity[Length] = + implicit val resolution: Option[Long] = weatherDataSourceCfg.resolution + implicit val distance: ComparableQuantity[Length] = Quantities.getQuantity( weatherDataSourceCfg.maxCoordinateDistance, Units.METRE, ) - // check that only one source is defined - if (definedWeatherSources.size > 1) - throw new InvalidConfigParameterException( - s"Multiple weather sources defined: '${definedWeatherSources.map(_.getClass.getSimpleName).mkString("\n\t")}'." + - s"Please define only one source!\nAvailable sources:\n\t${supportedWeatherSources.mkString("\n\t")}" - ) - definedWeatherSources.headOption match { - case Some( - Some(baseCsvParams @ BaseCsvParams(csvSep, directoryPath, _)) - ) => - checkBaseCsvParams(baseCsvParams, "WeatherSource") - (simulationStart: ZonedDateTime) => - WeatherSourceWrapper( - csvSep, - Paths.get(directoryPath), - coordinateSourceFunction, - timestampPattern, - scheme, - resolution, - distance, - )(simulationStart) - case Some(Some(params: CouchbaseParams)) => - checkCouchbaseParams(params) - (simulationStart: ZonedDateTime) => - WeatherSourceWrapper( - params, - coordinateSourceFunction, - timestampPattern, - scheme, - resolution, - distance, - )(simulationStart) - case Some(Some(params @ InfluxDb1xParams(database, _, url))) => - checkInfluxDb1xParams("WeatherSource", url, database) - (simulationStart: ZonedDateTime) => - WeatherSourceWrapper( - params, - coordinateSourceFunction, - timestampPattern, - scheme, - resolution, - distance, - )(simulationStart) - case Some(Some(params: SqlParams)) => - checkSqlParams(params) - (simulationStart: ZonedDateTime) => - WeatherSourceWrapper( - params, - coordinateSourceFunction, - timestampPattern, - scheme, - resolution, - distance, - )(simulationStart) - case Some(Some(_: SampleParams)) => - // sample weather, no check required - // coordinate source must be sample coordinate source - // calling the function here is not an issue as the sample coordinate source is already - // an object (= no overhead costs) - coordinateSourceFunction() match { - case _: SampleWeatherSource.SampleIdCoordinateSource.type => - // all fine - (simulationStart: ZonedDateTime) => - new SampleWeatherSource()(simulationStart) - case coordinateSource => - // cannot use sample weather source with other combination of weather source than sample weather source - throw new InvalidConfigParameterException( - s"Invalid coordinate source " + - s"'${coordinateSource.getClass.getSimpleName}' defined for SampleWeatherSource. " + - "Please adapt the configuration to use sample coordinate source for weather data!" - ) - } - case None | Some(_) => - throw new InvalidConfigParameterException( - s"No weather source defined! This is currently not supported! Please provide the config parameters for one " + - s"of the following weather sources:\n\t${supportedWeatherSources.mkString("\n\t")}" - ) - } + buildPSDMSource(weatherDataSourceCfg, definedWeatherSources) + .map(WeatherSourceWrapper.apply) + .getOrElse(new SampleWeatherSource()) } /** Check the provided coordinate id data source configuration to ensure its @@ -437,99 +291,57 @@ object WeatherSource { * a function that can be used to actually build the configured coordinate * id data source */ - private def checkCoordinateSource( + private def buildCoordinateSource( coordinateSourceConfig: SimonaConfig.Simona.Input.Weather.Datasource.CoordinateSource - ): () => IdCoordinateSource = { - val supportedCoordinateSources = Set("csv", "sql", "sample") + ): IdCoordinateSource = { val definedCoordSources = Vector( coordinateSourceConfig.sampleParams, coordinateSourceConfig.csvParams, coordinateSourceConfig.sqlParams, - ).filter(_.isDefined) + ).find(_.isDefined).flatten - // check that only one source is defined - if (definedCoordSources.size > 1) - throw new InvalidConfigParameterException( - s"Multiple coordinate sources defined: '${definedCoordSources.map(_.getClass.getSimpleName).mkString("\n\t")}'." + - s"Please define only one source!\nAvailable sources:\n\t${supportedCoordinateSources.mkString("\n\t")}" - ) - - // check source parameters - definedCoordSources.headOption match { + definedCoordSources match { case Some( - Some(baseCsvParams @ BaseCsvParams(csvSep, directoryPath, _)) + BaseCsvParams(csvSep, directoryPath, _) ) => - checkBaseCsvParams(baseCsvParams, "CoordinateSource") - val idCoordinateFactory = checkCoordinateFactory( - coordinateSourceConfig.gridModel + val idCoordinateFactory = + coordinateSourceConfig.gridModel.toLowerCase match { + case "icon" => new IconIdCoordinateFactory() + case "cosmo" => new CosmoIdCoordinateFactory() + } + + new CsvIdCoordinateSource( + idCoordinateFactory, + new CsvDataSource( + csvSep, + Paths.get(directoryPath), + new FileNamingStrategy(), + ), ) - () => - new CsvIdCoordinateSource( - idCoordinateFactory, - new CsvDataSource( - csvSep, - Paths.get(directoryPath), - new FileNamingStrategy(), - ), - ) case Some( - Some( - sqlParams @ SqlParams( - jdbcUrl, - userName, - password, - schemaName, - tableName, - ) + SqlParams( + jdbcUrl, + userName, + password, + schemaName, + tableName, ) ) => - checkSqlParams(sqlParams) - - () => - new SqlIdCoordinateSource( - new SqlConnector(jdbcUrl, userName, password), - schemaName, - tableName, - new SqlIdCoordinateFactory(), - ) + new SqlIdCoordinateSource( + new SqlConnector(jdbcUrl, userName, password), + schemaName, + tableName, + new SqlIdCoordinateFactory(), + ) case Some( - Some( - _: SimonaConfig.Simona.Input.Weather.Datasource.CoordinateSource.SampleParams - ) + _: SimonaConfig.Simona.Input.Weather.Datasource.CoordinateSource.SampleParams ) => // sample coordinates, no check required - () => SampleWeatherSource.SampleIdCoordinateSource - case None | Some(_) => - throw new InvalidConfigParameterException( - s"No coordinate source defined! This is currently not supported! Please provide the config parameters for one " + - s"of the following coordinate sources:\n\t${supportedCoordinateSources.mkString("\n\t")}" - ) - } - } - - /** Check the provided coordinate grid model configuration to ensure its - * validity. If the configuration is valid, the corresponding - * IdCoordinateSource is returned. For any invalid configuration parameters - * exceptions are thrown. - * - * @param gridModel - * the grid model string to be checked - * @return - * a function that can be used to actually build the id coordinate factory - * for the grid model - */ - private def checkCoordinateFactory( - gridModel: String - ): IdCoordinateFactory = { - if (gridModel.isEmpty) - throw new InvalidConfigParameterException("No grid model defined!") - gridModel.toLowerCase() match { - case "icon" => new IconIdCoordinateFactory() - case "cosmo" => new CosmoIdCoordinateFactory() - case _ => - throw new InvalidConfigParameterException( - s"Grid model '$gridModel' is not supported!" - ) + SampleWeatherSource.SampleIdCoordinateSource + case None => + throw new SourceException( + s"Expected an IdCoordinateSource, but no source where defined in $coordinateSourceConfig." + ); } } diff --git a/src/main/scala/edu/ie3/simona/service/weather/WeatherSourceWrapper.scala b/src/main/scala/edu/ie3/simona/service/weather/WeatherSourceWrapper.scala index 0fe6d10480..6ccc0942bd 100644 --- a/src/main/scala/edu/ie3/simona/service/weather/WeatherSourceWrapper.scala +++ b/src/main/scala/edu/ie3/simona/service/weather/WeatherSourceWrapper.scala @@ -25,6 +25,8 @@ import edu.ie3.datamodel.io.source.{ IdCoordinateSource, WeatherSource => PsdmWeatherSource, } +import edu.ie3.simona.config.SimonaConfig +import edu.ie3.simona.config.SimonaConfig.BaseCsvParams import edu.ie3.simona.config.SimonaConfig.Simona.Input.Weather.Datasource.{ CouchbaseParams, InfluxDb1xParams, @@ -46,7 +48,7 @@ import edu.ie3.util.DoubleUtils.ImplicitDouble import edu.ie3.util.interval.ClosedInterval import tech.units.indriya.ComparableQuantity -import java.nio.file.Path +import java.nio.file.Paths import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import javax.measure.quantity.Length @@ -199,124 +201,102 @@ private[weather] object WeatherSourceWrapper extends LazyLogging { private val DEFAULT_RESOLUTION = 3600L def apply( - csvSep: String, - directoryPath: Path, - idCoordinateSourceFunction: () => IdCoordinateSource, - timestampPattern: Option[String], - scheme: String, + source: PsdmWeatherSource + )(implicit + simulationStart: ZonedDateTime, + idCoordinateSource: IdCoordinateSource, resolution: Option[Long], - maxCoordinateDistance: ComparableQuantity[Length], - )(implicit simulationStart: ZonedDateTime): WeatherSourceWrapper = { - val idCoordinateSource = idCoordinateSourceFunction() - val source = new CsvWeatherSource( - csvSep, - directoryPath, - new FileNamingStrategy(), - idCoordinateSource, - buildFactory(scheme, timestampPattern), - ) - logger.info( - "Successfully initiated CsvWeatherSource as source for WeatherSourceWrapper." - ) + distance: ComparableQuantity[Length], + ): WeatherSourceWrapper = { WeatherSourceWrapper( source, idCoordinateSource, resolution.getOrElse(DEFAULT_RESOLUTION), - maxCoordinateDistance, + distance, ) } - def apply( - couchbaseParams: CouchbaseParams, - idCoordinateSourceFunction: () => IdCoordinateSource, - timestampPattern: Option[String], - scheme: String, - resolution: Option[Long], - maxCoordinateDistance: ComparableQuantity[Length], - )(implicit simulationStart: ZonedDateTime): WeatherSourceWrapper = { - val couchbaseConnector = new CouchbaseConnector( - couchbaseParams.url, - couchbaseParams.bucketName, - couchbaseParams.userName, - couchbaseParams.password, - ) - val idCoordinateSource = idCoordinateSourceFunction() - val source = new CouchbaseWeatherSource( - couchbaseConnector, - idCoordinateSourceFunction(), - couchbaseParams.coordinateColumnName, - couchbaseParams.keyPrefix, - buildFactory(scheme, timestampPattern), - "yyyy-MM-dd'T'HH:mm:ssxxx", - ) - logger.info( - "Successfully initiated CouchbaseWeatherSource as source for WeatherSourceWrapper." - ) - WeatherSourceWrapper( - source, - idCoordinateSource, - resolution.getOrElse(DEFAULT_RESOLUTION), - maxCoordinateDistance, - ) - } + private[weather] def buildPSDMSource( + cfgParams: SimonaConfig.Simona.Input.Weather.Datasource, + definedWeatherSources: Option[Serializable], + )(implicit + idCoordinateSource: IdCoordinateSource + ): Option[PsdmWeatherSource] = { + implicit val timestampPattern: Option[String] = + cfgParams.timestampPattern + implicit val scheme: String = cfgParams.scheme - def apply( - influxDbParams: InfluxDb1xParams, - idCoordinateSourceFunction: () => IdCoordinateSource, - timestampPattern: Option[String], - scheme: String, - resolution: Option[Long], - maxCoordinateDistance: ComparableQuantity[Length], - )(implicit simulationStart: ZonedDateTime): WeatherSourceWrapper = { - val influxDb1xConnector = - new InfluxDbConnector(influxDbParams.url, influxDbParams.database) - val idCoordinateSource = idCoordinateSourceFunction() - val source = new InfluxDbWeatherSource( - influxDb1xConnector, - idCoordinateSource, - buildFactory(scheme, timestampPattern), - ) - logger.info( - "Successfully initiated InfluxDbWeatherSource as source for WeatherSourceWrapper." - ) - WeatherSourceWrapper( - source, - idCoordinateSource, - resolution.getOrElse(DEFAULT_RESOLUTION), - maxCoordinateDistance, - ) - } + val factory = buildFactory(scheme, timestampPattern) - def apply( - sqlParams: SqlParams, - idCoordinateSourceFunction: () => IdCoordinateSource, - timestampPattern: Option[String], - scheme: String, - resolution: Option[Long], - maxCoordinateDistance: ComparableQuantity[Length], - )(implicit simulationStart: ZonedDateTime): WeatherSourceWrapper = { - val sqlConnector = new SqlConnector( - sqlParams.jdbcUrl, - sqlParams.userName, - sqlParams.password, - ) - val idCoordinateSource = idCoordinateSourceFunction() - val source = new SqlWeatherSource( - sqlConnector, - idCoordinateSource, - sqlParams.schemaName, - sqlParams.tableName, - buildFactory(scheme, timestampPattern), - ) - logger.info( - "Successfully initiated SqlWeatherSource as source for WeatherSourceWrapper." - ) - WeatherSourceWrapper( - source, - idCoordinateSource, - resolution.getOrElse(DEFAULT_RESOLUTION), - maxCoordinateDistance, - ) + val source = definedWeatherSources.flatMap { + case BaseCsvParams(csvSep, directoryPath, _) => + // initializing a csv weather source + Some( + new CsvWeatherSource( + csvSep, + Paths.get(directoryPath), + new FileNamingStrategy(), + idCoordinateSource, + factory, + ) + ) + case couchbaseParams: CouchbaseParams => + // initializing a couchbase weather source + val couchbaseConnector = new CouchbaseConnector( + couchbaseParams.url, + couchbaseParams.bucketName, + couchbaseParams.userName, + couchbaseParams.password, + ) + Some( + new CouchbaseWeatherSource( + couchbaseConnector, + idCoordinateSource, + couchbaseParams.coordinateColumnName, + couchbaseParams.keyPrefix, + factory, + "yyyy-MM-dd'T'HH:mm:ssxxx", + ) + ) + case InfluxDb1xParams(database, _, url) => + // initializing an influxDb weather source + val influxDb1xConnector = + new InfluxDbConnector(url, database) + Some( + new InfluxDbWeatherSource( + influxDb1xConnector, + idCoordinateSource, + factory, + ) + ) + case sqlParams: SqlParams => + // initializing a sql weather source + val sqlConnector = new SqlConnector( + sqlParams.jdbcUrl, + sqlParams.userName, + sqlParams.password, + ) + Some( + new SqlWeatherSource( + sqlConnector, + idCoordinateSource, + sqlParams.schemaName, + sqlParams.tableName, + factory, + ) + ) + case _ => + // no weather source is initialized + None + } + + source.foreach { source => + logger.info( + s"Successfully initialized ${source.getClass.getSimpleName} as source for WeatherSourceWrapper." + ) + } + + source } private def buildFactory(scheme: String, timestampPattern: Option[String]) = diff --git a/src/main/scala/edu/ie3/simona/sim/setup/SimonaSetup.scala b/src/main/scala/edu/ie3/simona/sim/setup/SimonaSetup.scala index 5e96a6a8fd..63db3d90bf 100644 --- a/src/main/scala/edu/ie3/simona/sim/setup/SimonaSetup.scala +++ b/src/main/scala/edu/ie3/simona/sim/setup/SimonaSetup.scala @@ -38,6 +38,10 @@ trait SimonaSetup { */ val args: Array[String] + /** Directory of the log output. + */ + def logOutputDir: String + /** Creates the runtime event listener * * @param context diff --git a/src/main/scala/edu/ie3/simona/sim/setup/SimonaStandaloneSetup.scala b/src/main/scala/edu/ie3/simona/sim/setup/SimonaStandaloneSetup.scala index 73770416a5..5f8745abf0 100644 --- a/src/main/scala/edu/ie3/simona/sim/setup/SimonaStandaloneSetup.scala +++ b/src/main/scala/edu/ie3/simona/sim/setup/SimonaStandaloneSetup.scala @@ -59,6 +59,8 @@ class SimonaStandaloneSetup( override val args: Array[String], ) extends SimonaSetup { + override def logOutputDir: String = resultFileHierarchy.logOutputDir + override def gridAgents( context: ActorContext[_], environmentRefs: EnvironmentRefs, diff --git a/src/main/scala/edu/ie3/simona/util/ConfigUtil.scala b/src/main/scala/edu/ie3/simona/util/ConfigUtil.scala index b041129c90..f32b66ed5f 100644 --- a/src/main/scala/edu/ie3/simona/util/ConfigUtil.scala +++ b/src/main/scala/edu/ie3/simona/util/ConfigUtil.scala @@ -88,6 +88,7 @@ object ConfigUtil { subConfig.pv.individualConfigs, subConfig.evcs.individualConfigs, subConfig.wec.individualConfigs, + subConfig.storage.individualConfigs, subConfig.em.individualConfigs, ).flatten ), @@ -98,6 +99,7 @@ object ConfigUtil { subConfig.evcs.defaultConfig, subConfig.wec.defaultConfig, subConfig.hp.defaultConfig, + subConfig.storage.defaultConfig, subConfig.em.defaultConfig, ).map { conf => conf.getClass -> conf }.toMap, ) @@ -148,8 +150,7 @@ object ConfigUtil { if (defaultConfig.simulationResultInfo) { val notifiers = if (thermal) NotifierIdentifier.getThermalIdentifiers - else - NotifierIdentifier.values -- NotifierIdentifier.getThermalIdentifiers + else NotifierIdentifier.getParticipantIdentifiers /* Generally inform about all simulation results, but not on those, that are explicitly marked */ notifiers -- configs.flatMap { case ( @@ -292,10 +293,16 @@ object ConfigUtil { val Wec: Value = Value("wec") val Hp: Value = Value("hp") val House: Value = Value("house") + val CylindricalStorage: Value = Value("cylindricalstorage") + + /** All participant identifiers */ + def getParticipantIdentifiers: Set[Value] = + (NotifierIdentifier.values -- getThermalIdentifiers).toSet /** All thermal identifiers */ def getThermalIdentifiers: Set[Value] = Set( - NotifierIdentifier.House + NotifierIdentifier.House, + NotifierIdentifier.CylindricalStorage, ) } diff --git a/src/main/scala/edu/ie3/simona/util/EntityMapperUtil.scala b/src/main/scala/edu/ie3/simona/util/EntityMapperUtil.scala index ed9192dcb1..3308813320 100644 --- a/src/main/scala/edu/ie3/simona/util/EntityMapperUtil.scala +++ b/src/main/scala/edu/ie3/simona/util/EntityMapperUtil.scala @@ -8,7 +8,10 @@ package edu.ie3.simona.util import edu.ie3.datamodel.models.result.ResultEntity import edu.ie3.datamodel.models.result.system._ -import edu.ie3.datamodel.models.result.thermal.ThermalHouseResult +import edu.ie3.datamodel.models.result.thermal.{ + CylindricalStorageResult, + ThermalHouseResult, +} import edu.ie3.simona.util.ConfigUtil.NotifierIdentifier import edu.ie3.simona.util.ConfigUtil.NotifierIdentifier._ @@ -27,6 +30,7 @@ object EntityMapperUtil { Em -> classOf[EmResult], Hp -> classOf[HpResult], House -> classOf[ThermalHouseResult], + CylindricalStorage -> classOf[CylindricalStorageResult], ) /** Get the classes of [[ResultEntity]], that are issued by the notifier, that diff --git a/src/test/groovy/edu/ie3/simona/model/participant/StorageModelTest.groovy b/src/test/groovy/edu/ie3/simona/model/participant/StorageModelTest.groovy new file mode 100644 index 0000000000..a28a405901 --- /dev/null +++ b/src/test/groovy/edu/ie3/simona/model/participant/StorageModelTest.groovy @@ -0,0 +1,374 @@ +/* + * © 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.participant + +import edu.ie3.datamodel.models.OperationTime +import edu.ie3.datamodel.models.input.NodeInput +import edu.ie3.datamodel.models.input.OperatorInput +import edu.ie3.datamodel.models.input.system.StorageInput +import edu.ie3.datamodel.models.input.system.characteristic.CosPhiFixed +import edu.ie3.datamodel.models.input.system.type.StorageTypeInput +import edu.ie3.datamodel.models.voltagelevels.GermanVoltageLevelUtils +import edu.ie3.simona.ontology.messages.flex.MinMaxFlexibilityMessage.ProvideMinMaxFlexOptions +import edu.ie3.util.TimeUtil +import edu.ie3.util.scala.quantities.Sq +import scala.Option +import spock.lang.Shared +import spock.lang.Specification +import squants.energy.* + +import static edu.ie3.util.quantities.PowerSystemUnits.* +import static tech.units.indriya.quantity.Quantities.getQuantity + +class StorageModelTest extends Specification { + + @Shared + StorageInput inputModel + @Shared + static final Double TOLERANCE = 1e-10 + + def setupSpec() { + def nodeInput = new NodeInput( + UUID.fromString("ad39d0b9-5ad6-4588-8d92-74c7d7de9ace"), + "NodeInput", + OperatorInput.NO_OPERATOR_ASSIGNED, + OperationTime.notLimited(), + getQuantity(1d, PU), + false, + NodeInput.DEFAULT_GEO_POSITION, + GermanVoltageLevelUtils.LV, + -1) + + def typeInput = new StorageTypeInput( + UUID.fromString("fbee4995-24dd-45e4-9c85-7d986fe99ff3"), + "Test_StorageTypeInput", + getQuantity(10000d, EURO), + getQuantity(0.05d, EURO_PER_MEGAWATTHOUR), + getQuantity(100d, KILOWATTHOUR), + getQuantity(13d, KILOVOLTAMPERE), + 0.997, + getQuantity(10d, KILOWATT), + getQuantity(0.03, PU_PER_HOUR), + getQuantity(0.9, PU), + ) + + inputModel = new StorageInput( + UUID.randomUUID(), + "Test_StorageInput", + new OperatorInput(UUID.randomUUID(), "NO_OPERATOR"), + OperationTime.notLimited(), + nodeInput, + CosPhiFixed.CONSTANT_CHARACTERISTIC, + null, + typeInput + ) + } + + def buildStorageModel(Option targetSoc = Option.empty()) { + return StorageModel.apply(inputModel, 1, + TimeUtil.withDefaults.toZonedDateTime("2020-01-01T00:00:00Z"), + TimeUtil.withDefaults.toZonedDateTime("2020-01-01T01:00:00Z"), + 0d, + targetSoc) + } + + def "Calculate flex options"() { + given: + def storageModel = buildStorageModel() + def startTick = 3600L + def data = new StorageModel.StorageRelevantData(startTick + timeDelta) + def oldState = new StorageModel.StorageState( + Sq.create(lastStored.doubleValue(), KilowattHours$.MODULE$), + Sq.create(lastPower.doubleValue(), Kilowatts$.MODULE$), + startTick + ) + + when: + def result = (ProvideMinMaxFlexOptions) storageModel.determineFlexOptions(data, oldState) + + then: + Math.abs(result.ref().toKilowatts() - pRef) < TOLERANCE + Math.abs(result.min().toKilowatts() - pMin) < TOLERANCE + Math.abs(result.max().toKilowatts() - pMax) < TOLERANCE + + where: + lastStored | lastPower | timeDelta || pRef | pMin | pMax + // UNCHANGED STATE + // completely empty + 0 | 0 | 1 || 0 | 0 | 10 + // at a tiny bit above empty + 0.011d | 0 | 1 || 0 | -10 | 10 + // at mid-level charge + 60 | 0 | 1 || 0 | -10 | 10 + // almost fully charged + 99.989d | 0 | 1 || 0 | -10 | 10 + // fully charged + 100 | 0 | 1 || 0 | -10 | 0 + // CHANGED STATE + // discharged to empty + 10 | -9 | 3600 || 0 | 0 | 10 + // almost discharged to lowest allowed charge + 10 | -9 | 3590 || 0 | -10 | 10 + // charged to mid-level charge + 41 | 10 | 3600 || 0 | -10 | 10 + // discharged to mid-level charge + 60 | -9 | 3600 || 0 | -10 | 10 + // almost fully charged + 95.5 | 4.98 | 3600 || 0 | -10 | 10 + // fully charged + 95.5 | 5 | 3600 || 0 | -10 | 0 + } + + def "Calculate flex options with target SOC"() { + given: + def storageModel = buildStorageModel(Option.apply(0.5d)) + def startTick = 3600L + def data = new StorageModel.StorageRelevantData(startTick + 1) + def oldState = new StorageModel.StorageState( + Sq.create(lastStored.doubleValue(), KilowattHours$.MODULE$), + Sq.create(0d, Kilowatts$.MODULE$), + startTick + ) + + when: + def result = (ProvideMinMaxFlexOptions) storageModel.determineFlexOptions(data, oldState) + + then: + Math.abs(result.ref().toKilowatts() - pRef) < TOLERANCE + Math.abs(result.min().toKilowatts() - pMin) < TOLERANCE + Math.abs(result.max().toKilowatts() - pMax) < TOLERANCE + + where: + lastStored || pRef | pMin | pMax + // completely empty + 0 || 10 | 0 | 10 + // below margin of ref power target + 49.9974 || 10 | -10 | 10 + // within margin below ref power target + 49.9976 || 0 | -10 | 10 + // exactly at ref power target + 50 || 0 | -10 | 10 + // within margin above ref power target + 50.0030 || 0 | -10 | 10 + // above margin of ref power target + 50.0031 || -10 | -10 | 10 + // at mid-level charge + 60 || -10 | -10 | 10 + // fully charged + 100 || -10 | -10 | 0 + } + + def "Handle controlled power change"() { + given: + def storageModel = buildStorageModel() + def startTick = 3600L + def data = new StorageModel.StorageRelevantData(startTick + 1) + def oldState = new StorageModel.StorageState( + Sq.create(lastStored.doubleValue(), KilowattHours$.MODULE$), + Sq.create(0d, Kilowatts$.MODULE$), + startTick + ) + + when: + def result = storageModel.handleControlledPowerChange( + data, + oldState, + Sq.create(setPower.doubleValue(), Kilowatts$.MODULE$) + ) + + then: + Math.abs(result._1.chargingPower().toKilowatts() - expPower.doubleValue()) < TOLERANCE + result._1.tick() == startTick + 1 + Math.abs(result._1.storedEnergy().toKilowattHours() - lastStored.doubleValue()) < TOLERANCE + def flexChangeIndication = result._2 + flexChangeIndication.changesAtTick().defined == expScheduled + flexChangeIndication.changesAtTick().map(x -> x == startTick + 1 + expDelta).getOrElse(_ -> true) + flexChangeIndication.changesAtNextActivation() == expActiveNext + + where: + lastStored | setPower || expPower | expActiveNext | expScheduled | expDelta + // no power + 0 | 0 || 0 | false | false | 0 + 50 | 0 || 0 | false | false | 0 + 100 | 0 || 0 | false | false | 0 + // charging on empty + 0 | 1 || 1 | true | true | 100 * 3600 / 0.9 + 0 | 2.5 || 2.5 | true | true | 40 * 3600 / 0.9 + 0 | 5 || 5 | true | true | 20 * 3600 / 0.9 + 0 | 10 || 10 | true | true | 10 * 3600 / 0.9 + // charging on half full + 50 | 5 || 5 | false | true | 10 * 3600 / 0.9 + 50 | 10 || 10 | false | true | 5 * 3600 / 0.9 + // discharging on half full + 50 | -4.5 || -4.5 | false | true | 10 * 3600 + 50 | -9 || -9 | false | true | 5 * 3600 + // discharging on full + 100 | -4.5 || -4.5 | true | true | 20 * 3600 + 100 | -9 || -9 | true | true | 10 * 3600 + } + + def "Handle controlled power change with ref target SOC"() { + given: + def storageModel = buildStorageModel(Option.apply(0.5d)) + def startTick = 3600L + def data = new StorageModel.StorageRelevantData(startTick + 1) + def oldState = new StorageModel.StorageState( + Sq.create(lastStored.doubleValue(), KilowattHours$.MODULE$), + Sq.create(0d, Kilowatts$.MODULE$), + startTick + ) + + when: + def result = storageModel.handleControlledPowerChange( + data, + oldState, + Sq.create(setPower.doubleValue(), Kilowatts$.MODULE$) + ) + + then: + Math.abs(result._1.chargingPower().toKilowatts() - expPower.doubleValue()) < TOLERANCE + result._1.tick() == startTick + 1 + Math.abs(result._1.storedEnergy().toKilowattHours() - lastStored.doubleValue()) < TOLERANCE + def flexChangeIndication = result._2 + flexChangeIndication.changesAtTick().defined == expScheduled + flexChangeIndication.changesAtTick().map(x -> x == startTick + 1 + expDelta).getOrElse(_ -> true) + flexChangeIndication.changesAtNextActivation() == expActiveNext + + where: + lastStored | setPower || expPower | expActiveNext | expScheduled | expDelta + // no power + 0 | 0 || 0 | false | false | 0 + 50 | 0 || 0 | false | false | 0 + 100 | 0 || 0 | false | false | 0 + // charging on empty + 0 | 1 || 1 | true | true | 50 * 3600 / 0.9 + 0 | 2.5 || 2.5 | true | true | 20 * 3600 / 0.9 + 0 | 5 || 5 | true | true | 10 * 3600 / 0.9 + 0 | 10 || 10 | true | true | 5 * 3600 / 0.9 + // charging on target ref + 50 | 5 || 5 | true | true | 10 * 3600 / 0.9 + 50 | 10 || 10 | true | true | 5 * 3600 / 0.9 + // discharging on target ref + 50 | -4.5 || -4.5 | true | true | 10 * 3600 + 50 | -9 || -9 | true | true | 5 * 3600 + // discharging on full + 100 | -4.5 || -4.5 | true | true | 10 * 3600 + 100 | -9 || -9 | true | true | 5 * 3600 + } + + def "Handle the edge case of discharging in tolerance margins"() { + given: + def storageModel = buildStorageModel() + def startTick = 1800L + def data = new StorageModel.StorageRelevantData(startTick + 1) + // margin is at ~ 0.0030864 kWh + def oldState = new StorageModel.StorageState( + Sq.create(0.002d, KilowattHours$.MODULE$), + Sq.create(0d, Kilowatts$.MODULE$), + startTick + ) + + when: + def result = storageModel.handleControlledPowerChange( + data, + oldState, + Sq.create(-5d, Kilowatts$.MODULE$) + ) + + then: + Math.abs(result._1.chargingPower().toKilowatts()) < TOLERANCE + result._1.tick() == startTick + 1 + Math.abs(result._1.storedEnergy().toKilowattHours() - oldState.storedEnergy().toKilowattHours()) < TOLERANCE + def flexChangeIndication = result._2 + !flexChangeIndication.changesAtTick().defined + flexChangeIndication.changesAtNextActivation() + } + + def "Handle the edge case of charging in tolerance margins"() { + given: + def storageModel = buildStorageModel() + def startTick = 1800L + def data = new StorageModel.StorageRelevantData(startTick + 1) + // margin is at ~ 99.9975 kWh + def oldState = new StorageModel.StorageState( + Sq.create(99.999d, KilowattHours$.MODULE$), + Sq.create(0d, Kilowatts$.MODULE$), + startTick + ) + + when: + def result = storageModel.handleControlledPowerChange( + data, + oldState, + Sq.create(9d, Kilowatts$.MODULE$) + ) + + then: + Math.abs(result._1.chargingPower().toKilowatts()) < TOLERANCE + result._1.tick() == startTick + 1 + Math.abs(result._1.storedEnergy().toKilowattHours() - oldState.storedEnergy().toKilowattHours()) < TOLERANCE + def flexChangeIndication = result._2 + !flexChangeIndication.changesAtTick().defined + flexChangeIndication.changesAtNextActivation() + } + + def "Handle the edge case of discharging in positive target margin"() { + given: + def storageModel = buildStorageModel(Option.apply(0.3d)) + def startTick = 1800L + def data = new StorageModel.StorageRelevantData(startTick + 1) + // margin is at ~ 30.0025 kWh + def oldState = new StorageModel.StorageState( + Sq.create(30.0024d, KilowattHours$.MODULE$), + Sq.create(0d, Kilowatts$.MODULE$), + startTick + ) + + when: + def result = storageModel.handleControlledPowerChange( + data, + oldState, + Sq.create(-9d, Kilowatts$.MODULE$) + ) + + then: + Math.abs(result._1.chargingPower().toKilowatts() - (-9d)) < TOLERANCE + result._1.tick() == startTick + 1 + Math.abs(result._1.storedEnergy().toKilowattHours() - oldState.storedEnergy().toKilowattHours()) < TOLERANCE + def flexChangeIndication = result._2 + flexChangeIndication.changesAtTick() == Option.apply(startTick + 1L + 10801L) + flexChangeIndication.changesAtNextActivation() + } + + def "Handle the edge case of charging in negative target margin"() { + given: + def storageModel = buildStorageModel(Option.apply(0.4d)) + def startTick = 1800L + def data = new StorageModel.StorageRelevantData(startTick + 1) + // margin is at ~ 39.9975 kWh + def oldState = new StorageModel.StorageState( + Sq.create(39.998d, KilowattHours$.MODULE$), + Sq.create(0d, Kilowatts$.MODULE$), + startTick + ) + + when: + def result = storageModel.handleControlledPowerChange( + data, + oldState, + Sq.create(5d, Kilowatts$.MODULE$) + ) + + then: + Math.abs(result._1.chargingPower().toKilowatts() - (5d)) < TOLERANCE + result._1.tick() == startTick + 1 + Math.abs(result._1.storedEnergy().toKilowattHours() - oldState.storedEnergy().toKilowattHours()) < TOLERANCE + def flexChangeIndication = result._2 + flexChangeIndication.changesAtTick() == Option.apply(startTick + 1L + 48002L) + flexChangeIndication.changesAtNextActivation() + } +} diff --git a/src/test/scala/edu/ie3/simona/agent/em/EmAgentIT.scala b/src/test/scala/edu/ie3/simona/agent/em/EmAgentIT.scala index cc0ce1f43b..09f6acb019 100644 --- a/src/test/scala/edu/ie3/simona/agent/em/EmAgentIT.scala +++ b/src/test/scala/edu/ie3/simona/agent/em/EmAgentIT.scala @@ -12,6 +12,7 @@ import edu.ie3.simona.agent.participant.hp.HpAgent import edu.ie3.simona.agent.participant.load.LoadAgent.FixedLoadAgent import edu.ie3.simona.agent.participant.pv.PvAgent import edu.ie3.simona.agent.participant.statedata.ParticipantStateData.ParticipantInitializeStateData +import edu.ie3.simona.agent.participant.storage.StorageAgent import edu.ie3.simona.config.SimonaConfig._ import edu.ie3.simona.event.ResultEvent import edu.ie3.simona.event.ResultEvent.ParticipantResultEvent @@ -96,6 +97,291 @@ class EmAgentIT private implicit val classicSystem: ActorSystem = system.toClassic "An em agent" when { + "having load, pv and storage agents connected" should { + "be initialized correctly and run through some activations" in { + val resultListener = TestProbe[ResultEvent]("ResultListener") + val primaryServiceProxy = + TestProbe[ServiceMessage]("PrimaryServiceProxy") + val weatherService = TestProbe[ServiceMessage]("WeatherService") + val scheduler = TestProbe[SchedulerMessage]("Scheduler") + + val emAgent = spawn( + EmAgent( + emInput, + modelConfig, + outputConfigOn, + "PRIORITIZED", + simulationStartDate, + parent = Left(scheduler.ref), + listener = Iterable(resultListener.ref), + ), + "EmAgent", + ) + + val loadAgent = TestActorRef( + new FixedLoadAgent( + scheduler = scheduler.ref.toClassic, + initStateData = ParticipantInitializeStateData( + loadInput, + LoadRuntimeConfig( + calculateMissingReactivePowerWithModel = true, + scaling = 1d, + modelBehaviour = "fix", + reference = "power", + uuids = List.empty, + ), + primaryServiceProxy.ref.toClassic, + None, + simulationStartDate, + simulationEndDate, + resolution, + simonaConfig.simona.runtime.participant.requestVoltageDeviationThreshold, + outputConfigOff, + Some(emAgent), + ), + listener = Iterable(resultListener.ref.toClassic), + ), + "LoadAgent", + ) + val pvAgent = TestActorRef( + new PvAgent( + scheduler = scheduler.ref.toClassic, + initStateData = ParticipantInitializeStateData( + pvInput, + PvRuntimeConfig( + calculateMissingReactivePowerWithModel = true, + scaling = 2d, + uuids = List.empty, + ), + primaryServiceProxy.ref.toClassic, + Iterable(ActorWeatherService(weatherService.ref.toClassic)), + simulationStartDate, + simulationEndDate, + resolution, + simonaConfig.simona.runtime.participant.requestVoltageDeviationThreshold, + outputConfigOff, + Some(emAgent), + ), + listener = Iterable(resultListener.ref.toClassic), + ), + "PvAgent", + ) + val storageAgent = TestActorRef( + new StorageAgent( + scheduler = scheduler.ref.toClassic, + initStateData = ParticipantInitializeStateData( + householdStorageInput, + StorageRuntimeConfig( + calculateMissingReactivePowerWithModel = true, + scaling = 1d, + uuids = List.empty, + initialSoc = 0d, + targetSoc = None, + ), + primaryServiceProxy.ref.toClassic, + None, + simulationStartDate, + simulationEndDate, + resolution, + simonaConfig.simona.runtime.participant.requestVoltageDeviationThreshold, + outputConfigOff, + Some(emAgent), + ), + listener = Iterable(resultListener.ref.toClassic), + ), + "StorageAgent", + ) + + scheduler.expectNoMessage() + + /* INIT */ + + // load + loadAgent ! Activation(INIT_SIM_TICK) + + primaryServiceProxy.expectMessage( + PrimaryServiceRegistrationMessage(loadInput.getUuid) + ) + loadAgent ! RegistrationFailedMessage(primaryServiceProxy.ref.toClassic) + + // the order of the two messages is not given + val emAgentActivation = scheduler + .receiveMessages(2) + .flatMap { + case Completion(ref, maybeNewTick) => + ref shouldBe loadAgent.toTyped + maybeNewTick shouldBe None + None + case ScheduleActivation(ref, tick, unlockKey) => + // em agent schedules itself + tick shouldBe 0 + unlockKey shouldBe None + Some(ref) + case unexpected => + fail(s"Received unexpected message $unexpected") + } + .headOption + .value + + // pv + pvAgent ! Activation(INIT_SIM_TICK) + + primaryServiceProxy.expectMessage( + PrimaryServiceRegistrationMessage(pvInput.getUuid) + ) + pvAgent ! RegistrationFailedMessage(primaryServiceProxy.ref.toClassic) + + // deal with weather service registration + weatherService.expectMessage( + RegisterForWeatherMessage( + pvInput.getNode.getGeoPosition.getY, + pvInput.getNode.getGeoPosition.getX, + ) + ) + + pvAgent ! RegistrationSuccessfulMessage( + weatherService.ref.toClassic, + Some(0L), + ) + + scheduler.expectMessage(Completion(pvAgent)) + + // storage + storageAgent ! Activation(INIT_SIM_TICK) + + primaryServiceProxy.expectMessage( + PrimaryServiceRegistrationMessage(householdStorageInput.getUuid) + ) + storageAgent ! RegistrationFailedMessage( + primaryServiceProxy.ref.toClassic + ) + + scheduler.expectMessage(Completion(storageAgent)) + + /* TICK 0 + LOAD: 0.000269 MW + PV: -0.005685 MW + STORAGE: SOC 0 % + -> charge with 5 kW + -> remaining -0.0004161 MW + */ + + emAgentActivation ! Activation(0) + + pvAgent ! ProvideWeatherMessage( + 0, + weatherService.ref.toClassic, + WeatherData( + WattsPerSquareMeter(400d), + WattsPerSquareMeter(200d), + Celsius(0d), + MetersPerSecond(0d), + ), + Some(7200), + ) + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(emResult: EmResult) => + emResult.getInputModel shouldBe emInput.getUuid + emResult.getTime shouldBe 0L.toDateTime + emResult.getP should equalWithTolerance( + (-0.000416087825).asMegaWatt + ) + emResult.getQ should equalWithTolerance(0.0000882855367.asMegaVar) + } + + scheduler.expectMessage(Completion(emAgentActivation, Some(7200))) + + /* TICK 7200 + LOAD: 0.000269 MW (unchanged) + PV: -0.003797 MW + STORAGE: SOC 63.3 % + -> charge with 3.5282 kW + -> remaining 0 MW + */ + + emAgentActivation ! Activation(7200) + + pvAgent ! ProvideWeatherMessage( + 7200, + weatherService.ref.toClassic, + WeatherData( + WattsPerSquareMeter(300d), + WattsPerSquareMeter(500d), + Celsius(0d), + MetersPerSecond(0d), + ), + Some(14400), + ) + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(emResult: EmResult) => + emResult.getInputModel shouldBe emInput.getUuid + emResult.getTime shouldBe 7200.toDateTime + emResult.getP should equalWithTolerance(0.asMegaWatt) + emResult.getQ should equalWithTolerance(0.0000882855367.asMegaVar) + } + + scheduler.expectMessage(Completion(emAgentActivation, Some(13107))) + + /* TICK 13107 + LOAD: 0.000269 MW (unchanged) + PV: -0.003797 MW (unchanged) + STORAGE: SOC 100 % + -> charge with 0 kW + -> remaining -0.003528 MW + */ + + emAgentActivation ! Activation(13107) + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(emResult: EmResult) => + emResult.getInputModel shouldBe emInput.getUuid + emResult.getTime shouldBe 13107L.toDateTime + emResult.getP should equalWithTolerance( + (-0.0035281545552).asMegaWatt + ) + emResult.getQ should equalWithTolerance(0.0000882855367.asMegaVar) + } + + scheduler.expectMessage(Completion(emAgentActivation, Some(14400))) + + /* TICK 14400 + LOAD: 0.000269 MW (unchanged) + PV: -0.000066 MW + STORAGE: SOC 100 % + -> charge with -0.202956 kW + -> remaining 0 MW + */ + + // send weather data before activation, which can happen + // it got cloudy now... + pvAgent ! ProvideWeatherMessage( + 14400, + weatherService.ref.toClassic, + WeatherData( + WattsPerSquareMeter(5d), + WattsPerSquareMeter(5d), + Celsius(0d), + MetersPerSecond(0d), + ), + Some(21600), + ) + + emAgentActivation ! Activation(14400) + + resultListener.expectMessageType[ParticipantResultEvent] match { + case ParticipantResultEvent(emResult: EmResult) => + emResult.getInputModel shouldBe emInput.getUuid + emResult.getTime shouldBe 14400L.toDateTime + emResult.getP should equalWithTolerance(0.asMegaWatt) + emResult.getQ should equalWithTolerance(0.000088285537.asMegaVar) + } + + scheduler.expectMessage(Completion(emAgentActivation, Some(21600))) + + } + } "having load, pv and heat pump agents connected" should { "be initialized correctly and run through some activations" in { diff --git a/src/test/scala/edu/ie3/simona/agent/participant/StorageAgentModelCalculationSpec.scala b/src/test/scala/edu/ie3/simona/agent/participant/StorageAgentModelCalculationSpec.scala new file mode 100644 index 0000000000..d7c8378ce5 --- /dev/null +++ b/src/test/scala/edu/ie3/simona/agent/participant/StorageAgentModelCalculationSpec.scala @@ -0,0 +1,644 @@ +/* + * © 2022. 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 + +import com.typesafe.config.ConfigFactory +import edu.ie3.datamodel.models.input.system.StorageInput +import edu.ie3.datamodel.models.input.system.characteristic.QV +import edu.ie3.datamodel.models.result.system.StorageResult +import edu.ie3.simona.agent.ValueStore +import edu.ie3.simona.agent.grid.GridAgentMessages.AssetPowerChangedMessage +import edu.ie3.simona.agent.participant.ParticipantAgent.RequestAssetPowerMessage +import edu.ie3.simona.agent.participant.data.Data.PrimaryData.ApparentPower +import edu.ie3.simona.agent.participant.statedata.BaseStateData.ParticipantModelBaseStateData +import edu.ie3.simona.agent.participant.statedata.ParticipantStateData.{ + ParticipantInitializeStateData, + ParticipantInitializingStateData, + SimpleInputContainer, +} +import edu.ie3.simona.agent.participant.storage.StorageAgent +import edu.ie3.simona.agent.state.AgentState.Idle +import edu.ie3.simona.agent.state.ParticipantAgentState.HandleInformation +import edu.ie3.simona.config.SimonaConfig +import edu.ie3.simona.config.SimonaConfig.StorageRuntimeConfig +import edu.ie3.simona.event.ResultEvent.{ + FlexOptionsResultEvent, + ParticipantResultEvent, +} +import edu.ie3.simona.event.notifier.NotifierConfig +import edu.ie3.simona.model.participant.load.{LoadModelBehaviour, LoadReference} +import edu.ie3.simona.ontology.messages.Activation +import edu.ie3.simona.ontology.messages.flex.FlexibilityMessage._ +import edu.ie3.simona.ontology.messages.flex.MinMaxFlexibilityMessage.ProvideMinMaxFlexOptions +import edu.ie3.simona.ontology.messages.SchedulerMessage.Completion +import edu.ie3.simona.ontology.messages.services.ServiceMessage.PrimaryServiceRegistrationMessage +import edu.ie3.simona.ontology.messages.services.ServiceMessage.RegistrationResponseMessage.RegistrationFailedMessage +import edu.ie3.simona.test.ParticipantAgentSpec +import edu.ie3.simona.test.common.input.StorageInputTestData +import edu.ie3.simona.util.ConfigUtil +import edu.ie3.simona.util.SimonaConstants.INIT_SIM_TICK +import edu.ie3.simona.util.TickUtil.TickLong +import edu.ie3.util.TimeUtil +import edu.ie3.util.quantities.PowerSystemUnits +import edu.ie3.util.quantities.QuantityUtils.RichQuantityDouble +import edu.ie3.util.scala.quantities.{Megavars, ReactivePower, Vars} +import org.apache.pekko.actor.typed.scaladsl.adapter.ClassicActorRefOps +import org.apache.pekko.actor.{ActorRef, ActorSystem} +import org.apache.pekko.testkit.{TestFSMRef, TestProbe} +import squants.{Each, Power} +import squants.energy.{Kilowatts, Megawatts, Watts} + +import java.time.ZonedDateTime +import scala.collection.SortedMap + +class StorageAgentModelCalculationSpec + extends ParticipantAgentSpec( + ActorSystem( + "StorageAgentModelCalculationSpec", + ConfigFactory + .parseString(""" + |akka.loggers =["akka.event.slf4j.Slf4jLogger"] + |akka.loglevel="DEBUG" + """.stripMargin), + ) + ) + with StorageInputTestData { + + protected implicit val simulationStartDate: ZonedDateTime = + TimeUtil.withDefaults.toZonedDateTime("2020-01-01T00:00:00Z") + protected val simulationEndDate: ZonedDateTime = + TimeUtil.withDefaults.toZonedDateTime("2020-01-01T01:00:00Z") + + /* Alter the input model to have a voltage sensitive reactive power calculation */ + private val storageInputQv = storageInput + .copy() + .qCharacteristics(new QV("qV:{(0.95,-0.625),(1.05,0.625)}")) + .build() + + /* Assign this test to receive the result events from agent */ + override val systemListener: Iterable[ActorRef] = Vector(self) + + private val simonaConfig: SimonaConfig = + createSimonaConfig( + LoadModelBehaviour.FIX, + LoadReference.ActivePower(Kilowatts(0d)), + ) + private val outputConfig = NotifierConfig( + simulationResultInfo = true, + powerRequestReply = false, + flexResult = true, + ) + private val configUtil = ConfigUtil.ParticipantConfigUtil( + simonaConfig.simona.runtime.participant + ) + private val modelConfig = configUtil.getOrDefault[StorageRuntimeConfig]( + storageInputQv.getUuid + ) + private val services = Iterable.empty + private val resolution = simonaConfig.simona.powerflow.resolution.getSeconds + + private implicit val powerTolerance: Power = Watts(0.1) + private implicit val reactivePowerTolerance: ReactivePower = Vars(0.1) + + "A storage agent with model calculation depending on no secondary data service" should { + val emAgent = TestProbe("EmAgent") + + val initStateData = ParticipantInitializeStateData[ + StorageInput, + StorageRuntimeConfig, + ApparentPower, + ]( + inputModel = storageInputQv, + modelConfig = modelConfig, + secondaryDataServices = services, + simulationStartDate = simulationStartDate, + simulationEndDate = simulationEndDate, + resolution = resolution, + requestVoltageDeviationThreshold = + simonaConfig.simona.runtime.participant.requestVoltageDeviationThreshold, + outputConfig = outputConfig, + primaryServiceProxy = primaryServiceProxy.ref, + maybeEmAgent = Some(emAgent.ref.toTyped), + ) + + "end in correct state with correct state data after initialisation" in { + val storageAgent = TestFSMRef( + new StorageAgent( + scheduler = scheduler.ref, + initStateData = initStateData, + listener = Iterable.empty, + ) + ) + + scheduler.send(storageAgent, Activation(INIT_SIM_TICK)) + + /* Actor should ask for registration with primary service */ + primaryServiceProxy.expectMsg( + PrimaryServiceRegistrationMessage(storageInputQv.getUuid) + ) + /* State should be information handling and having correct state data */ + storageAgent.stateName shouldBe HandleInformation + storageAgent.stateData match { + case ParticipantInitializingStateData( + inputModel, + modelConfig, + secondaryDataServices, + simulationStartDate, + simulationEndDate, + resolution, + requestVoltageDeviationThreshold, + outputConfig, + maybeEmAgent, + ) => + inputModel shouldBe SimpleInputContainer(storageInputQv) + modelConfig shouldBe modelConfig + secondaryDataServices shouldBe services + simulationStartDate shouldBe simulationStartDate + simulationEndDate shouldBe simulationEndDate + resolution shouldBe resolution + requestVoltageDeviationThreshold shouldBe simonaConfig.simona.runtime.participant.requestVoltageDeviationThreshold + outputConfig shouldBe outputConfig + maybeEmAgent shouldBe Some(emAgent.ref.toTyped) + case unsuitableStateData => + fail(s"Agent has unsuitable state data '$unsuitableStateData'.") + } + + /* Refuse registration */ + primaryServiceProxy.send( + storageAgent, + RegistrationFailedMessage(primaryServiceProxy.ref), + ) + + emAgent.expectMsg( + RegisterParticipant( + storageInputQv.getUuid, + storageAgent.toTyped, + storageInputQv, + ) + ) + emAgent.expectMsg( + ScheduleFlexRequest(storageInputQv.getUuid, 0) + ) + + scheduler.expectMsg(Completion(storageAgent.toTyped)) + + /* ... as well as corresponding state and state data */ + storageAgent.stateName shouldBe Idle + storageAgent.stateData match { + case ParticipantModelBaseStateData( + startDate, + endDate, + _, + secondaryDataServices, + outputConfig, + additionalActivationTicks, + foreseenDataTicks, + _, + voltageValueStore, + resultValueStore, + requestValueStore, + _, + _, + _, + ) => + /* Base state data */ + startDate shouldBe simulationStartDate + endDate shouldBe simulationEndDate + secondaryDataServices shouldBe services + outputConfig shouldBe outputConfig + additionalActivationTicks shouldBe empty + foreseenDataTicks shouldBe Map.empty + voltageValueStore shouldBe ValueStore( + resolution, + SortedMap(0L -> Each(1.0)), + ) + resultValueStore shouldBe ValueStore( + resolution + ) + requestValueStore shouldBe ValueStore[ApparentPower]( + resolution + ) + case unrecognized => + fail( + s"Did not find expected state data $ParticipantModelBaseStateData, but $unrecognized" + ) + } + } + + "answer with zero power, if asked directly after initialisation" in { + val storageAgent = TestFSMRef( + new StorageAgent( + scheduler = scheduler.ref, + initStateData = initStateData, + listener = Iterable.empty, + ) + ) + + scheduler.send(storageAgent, Activation(INIT_SIM_TICK)) + + /* Refuse registration with primary service */ + primaryServiceProxy.expectMsgType[PrimaryServiceRegistrationMessage] + primaryServiceProxy.send( + storageAgent, + RegistrationFailedMessage(primaryServiceProxy.ref), + ) + + emAgent.expectMsgType[RegisterParticipant] + emAgent.expectMsg(ScheduleFlexRequest(storageInputQv.getUuid, 0)) + + /* I'm not interested in the content of the Completion */ + scheduler.expectMsgType[Completion] + + storageAgent.stateName shouldBe Idle + /* State data has already been tested */ + + storageAgent ! RequestAssetPowerMessage( + 0, + Each(1d), + Each(0d), + ) + expectMsg( + AssetPowerChangedMessage( + Megawatts(0d), + Megavars(0d), + ) + ) + + inside(storageAgent.stateData) { + case modelBaseStateData: ParticipantModelBaseStateData[_, _, _, _] => + modelBaseStateData.requestValueStore shouldBe ValueStore[ + ApparentPower + ]( + resolution, + SortedMap( + 0L -> ApparentPower( + Megawatts(0d), + Megavars(0d), + ) + ), + ) + case _ => + fail( + s"Did not find expected state data $ParticipantModelBaseStateData, but ${storageAgent.stateData}" + ) + } + } + + "provide correct flex options when in Idle" in { + val resultListener = TestProbe("ResultListener") + + val storageAgent = TestFSMRef( + new StorageAgent( + scheduler = scheduler.ref, + initStateData = initStateData, + listener = Iterable(resultListener.ref), + ) + ) + + scheduler.send(storageAgent, Activation(INIT_SIM_TICK)) + + /* Refuse registration with primary service */ + primaryServiceProxy.expectMsgType[PrimaryServiceRegistrationMessage] + primaryServiceProxy.send( + storageAgent, + RegistrationFailedMessage(primaryServiceProxy.ref), + ) + + emAgent.expectMsgType[RegisterParticipant] + emAgent.expectMsg(ScheduleFlexRequest(storageInputQv.getUuid, 0)) + + /* I am not interested in the Completion */ + scheduler.expectMsgType[Completion] + awaitAssert(storageAgent.stateName shouldBe Idle) + /* State data is tested in another test */ + + val pMax = Kilowatts( + storageInputQv.getType.getpMax + .to(PowerSystemUnits.KILOWATT) + .getValue + .doubleValue + ) + + /* TICK 0 (expected activation) + - charging with pMax (12.961 kW) + - expecting changing flex options indicator (charging from empty) + */ + + emAgent.send(storageAgent, RequestFlexOptions(0)) + + emAgent.expectMsgType[ProvideFlexOptions] match { + case ProvideMinMaxFlexOptions( + modelUuid, + refPower, + minPower, + maxPower, + ) => + modelUuid shouldBe storageInputQv.getUuid + refPower should approximate(Kilowatts(0.0)) + minPower should approximate(Kilowatts(0.0)) + maxPower should approximate(pMax) + } + + resultListener.expectMsgPF() { case FlexOptionsResultEvent(flexResult) => + flexResult.getInputModel shouldBe storageInputQv.getUuid + flexResult.getTime shouldBe 0.toDateTime(simulationStartDate) + flexResult.getpRef should beEquivalentTo(0d.asKiloWatt) + flexResult.getpMin should beEquivalentTo(0d.asKiloWatt) + flexResult.getpMax should beEquivalentTo(storageInputQv.getType.getpMax) + } + + emAgent.send( + storageAgent, + IssuePowerControl( + 0, + Kilowatts(storageInputQv.getType.getpMax().getValue.doubleValue()), + ), + ) + + // next potential activation at fully charged battery: + // net power = 12.961kW * 0.92 = 11.92412kW + // time to charge fully ~= 16.7727262054h = 60382 ticks (rounded) + emAgent.expectMsgPF() { + case FlexCtrlCompletion( + modelUuid, + result, + requestAtNextActivation, + requestAtTick, + ) => + modelUuid shouldBe storageInputQv.getUuid + result.p should approximate(pMax) + result.q should approximate(Megavars(0)) + requestAtNextActivation shouldBe true + requestAtTick shouldBe Some(60382) + } + + resultListener.expectMsgPF() { + case ParticipantResultEvent(result: StorageResult) => + result.getInputModel shouldBe storageInputQv.getUuid + result.getTime shouldBe 0.toDateTime(simulationStartDate) + result.getP should beEquivalentTo(storageInputQv.getType.getpMax) + result.getQ should beEquivalentTo(0d.asMegaVar) + result.getSoc should beEquivalentTo(0d.asPercent) + } + + /* TICK 28800 (unplanned activation) + - charging with 9 kW + - expecting trigger revoke + */ + + // Re-request flex options, since we've been asked to + emAgent.send(storageAgent, RequestFlexOptions(28800)) + + emAgent.expectMsgType[ProvideFlexOptions] match { + case ProvideMinMaxFlexOptions( + modelUuid, + refPower, + minPower, + maxPower, + ) => + modelUuid shouldBe storageInputQv.getUuid + refPower should approximate(Kilowatts(0.0)) + minPower should approximate(pMax * -1) + maxPower should approximate(pMax) + } + + resultListener.expectMsgPF() { case FlexOptionsResultEvent(flexResult) => + flexResult.getInputModel shouldBe storageInputQv.getUuid + flexResult.getTime shouldBe 28800.toDateTime(simulationStartDate) + flexResult.getpRef should beEquivalentTo(0d.asKiloWatt) + flexResult.getpMin should beEquivalentTo( + storageInputQv.getType.getpMax().multiply(-1) + ) + flexResult.getpMax should beEquivalentTo(storageInputQv.getType.getpMax) + } + + emAgent.send(storageAgent, IssuePowerControl(28800, Kilowatts(9))) + + // after 8 hours, we're at about half full storage: 95.39296 kWh + // net power = 9kW * 0.92 = 8.28kW + // time to charge fully ~= 12.6337004831h = 45481 ticks (rounded) from now + // current tick is 28800, thus: 28800 + 45481 = 74281 + emAgent.expectMsgPF() { + case FlexCtrlCompletion( + modelUuid, + result, + requestAtNextActivation, + requestAtTick, + ) => + modelUuid shouldBe storageInputQv.getUuid + result.p should approximate(Kilowatts(9)) + result.q should approximate(Megavars(0)) + requestAtNextActivation shouldBe false + requestAtTick shouldBe Some(74281) + } + + resultListener.expectMsgPF() { + case ParticipantResultEvent(result: StorageResult) => + result.getInputModel shouldBe storageInputQv.getUuid + result.getTime shouldBe 28800.toDateTime(simulationStartDate) + result.getP should beEquivalentTo(9d.asKiloWatt) + result.getQ should beEquivalentTo(0d.asMegaVar) + result.getSoc should beEquivalentTo(47.69648d.asPercent) + } + + /* TICK 36000 (unplanned activation) + - discharging with pMax (-12.961 kW) + - expecting trigger revoke + */ + + emAgent.send( + storageAgent, + IssuePowerControl( + 36000, + Kilowatts( + storageInputQv.getType.getpMax().multiply(-1).getValue.doubleValue + ), + ), + ) + + // after 2 hours, we're at: 111.95296 kWh + // net power = -12.961kW / 0.92 = -14.08804348kW + // time to discharge until lowest energy (0 kWh) ~= 7.946664856h = 28608 ticks (rounded) from now + // current tick is 36000, thus: 36000 + 28608 = 64608 + emAgent.expectMsgPF() { + case FlexCtrlCompletion( + modelUuid, + result, + requestAtNextActivation, + requestAtTick, + ) => + modelUuid shouldBe storageInputQv.getUuid + result.p should approximate(pMax * -1) + result.q should approximate(Megavars(0)) + requestAtNextActivation shouldBe false + requestAtTick shouldBe Some(64608) + } + + resultListener.expectMsgPF() { + case ParticipantResultEvent(result: StorageResult) => + result.getInputModel shouldBe storageInputQv.getUuid + result.getTime shouldBe 36000.toDateTime(simulationStartDate) + result.getP should beEquivalentTo( + storageInputQv.getType.getpMax().multiply(-1) + ) + result.getQ should beEquivalentTo(0d.asMegaVar) + result.getSoc should beEquivalentTo(55.97648d.asPercent) + } + + /* TICK 43200 (unplanned activation) + - charging with 12 kW + - expecting trigger revoke + */ + + emAgent.send(storageAgent, IssuePowerControl(43200, Kilowatts(12))) + + // after 2 hours, we're at: 83.77687304 kWh + // net power = 12 * 0.92 = 11.04 kW + // time to charge until full ~= 10.52745715h = 37899 ticks (rounded) from now + // current tick is 43200, thus: 43200 + 37899 = 81099 + emAgent.expectMsgPF() { + case FlexCtrlCompletion( + modelUuid, + result, + requestAtNextActivation, + requestAtTick, + ) => + modelUuid shouldBe storageInputQv.getUuid + result.p should approximate(Kilowatts(12)) + result.q should approximate(Megavars(0)) + requestAtNextActivation shouldBe false + requestAtTick shouldBe Some(81099) + } + + resultListener.expectMsgPF() { + case ParticipantResultEvent(result: StorageResult) => + result.getInputModel shouldBe storageInputQv.getUuid + result.getTime shouldBe 43200.toDateTime(simulationStartDate) + result.getP should beEquivalentTo(12d.asKiloWatt) + result.getQ should beEquivalentTo(0d.asMegaVar) + result.getSoc should beEquivalentTo(41.88843652173913d.asPercent) + } + + /* TICK 81099 (expected activation) + - discharging with 12 kW + - expecting changing flex options indicator (discharging from full) + */ + + // Request flex options + emAgent.send(storageAgent, RequestFlexOptions(81099)) + + emAgent.expectMsgType[ProvideFlexOptions] match { + case ProvideMinMaxFlexOptions( + modelUuid, + refPower, + minPower, + maxPower, + ) => + modelUuid shouldBe storageInputQv.getUuid + refPower should approximate(Kilowatts(0.0)) + minPower should approximate(pMax * -1) + maxPower should approximate(Kilowatts(0.0)) + } + + resultListener.expectMsgPF() { case FlexOptionsResultEvent(flexResult) => + flexResult.getInputModel shouldBe storageInputQv.getUuid + flexResult.getTime shouldBe 81099.toDateTime(simulationStartDate) + flexResult.getpRef should beEquivalentTo(0d.asKiloWatt) + flexResult.getpMin should beEquivalentTo( + storageInputQv.getType.getpMax().multiply(-1) + ) + flexResult.getpMax should beEquivalentTo(0d.asKiloWatt) + } + + emAgent.send(storageAgent, IssuePowerControl(81099, Kilowatts(-12))) + + // we're full now at 200 kWh + // net power = -12 / 0.92 = -13.04347826 kW + // time to discharge until empty ~= 15.33333333h = 55200 ticks from now + // current tick is 79688, thus: 81099 + 55200 = 136299 + emAgent.expectMsgPF() { + case FlexCtrlCompletion( + modelUuid, + result, + requestAtNextActivation, + requestAtTick, + ) => + modelUuid shouldBe storageInputQv.getUuid + result.p should approximate(Kilowatts(-12)) + result.q should approximate(Megavars(0)) + requestAtNextActivation shouldBe true + requestAtTick shouldBe Some(136299) + } + + resultListener.expectMsgPF() { + case ParticipantResultEvent(result: StorageResult) => + result.getInputModel shouldBe storageInputQv.getUuid + result.getTime shouldBe 81099.toDateTime(simulationStartDate) + result.getP should beEquivalentTo((-12d).asKiloWatt) + result.getQ should beEquivalentTo(0d.asMegaVar) + result.getSoc should beEquivalentTo(100d.asPercent) + } + + /* TICK 136299 (expected activation) + - no charging + - expecting no changing flex options indicator + */ + + // Request flex options + emAgent.send(storageAgent, RequestFlexOptions(136299)) + + emAgent.expectMsgType[ProvideFlexOptions] match { + case ProvideMinMaxFlexOptions( + modelUuid, + refPower, + minPower, + maxPower, + ) => + modelUuid shouldBe storageInputQv.getUuid + refPower should approximate(Kilowatts(0.0)) + minPower should approximate(Kilowatts(0.0)) + maxPower should approximate(pMax) + } + + resultListener.expectMsgPF() { case FlexOptionsResultEvent(flexResult) => + flexResult.getInputModel shouldBe storageInputQv.getUuid + flexResult.getTime shouldBe 136299.toDateTime(simulationStartDate) + flexResult.getpRef should beEquivalentTo(0d.asKiloWatt) + flexResult.getpMin should beEquivalentTo(0d.asKiloWatt) + flexResult.getpMax should beEquivalentTo(storageInputQv.getType.getpMax) + } + + emAgent.send(storageAgent, IssuePowerControl(136299, Kilowatts(0d))) + + // we're not charging or discharging, no new expected tick + emAgent.expectMsgPF() { + case FlexCtrlCompletion( + modelUuid, + result, + requestAtNextActivation, + requestAtTick, + ) => + modelUuid shouldBe storageInputQv.getUuid + result.p should approximate(Kilowatts(0)) + result.q should approximate(Megavars(0)) + requestAtNextActivation shouldBe false + requestAtTick shouldBe None + } + + resultListener.expectMsgPF() { + case ParticipantResultEvent(result: StorageResult) => + result.getInputModel shouldBe storageInputQv.getUuid + result.getTime shouldBe 136299.toDateTime(simulationStartDate) + result.getP should beEquivalentTo(0.asKiloWatt) + result.getQ should beEquivalentTo(0d.asMegaVar) + result.getSoc should beEquivalentTo(0d.asPercent) + } + + } + + } +} diff --git a/src/test/scala/edu/ie3/simona/config/ConfigFailFastSpec.scala b/src/test/scala/edu/ie3/simona/config/ConfigFailFastSpec.scala index 227d8f05cf..c38ef668f1 100644 --- a/src/test/scala/edu/ie3/simona/config/ConfigFailFastSpec.scala +++ b/src/test/scala/edu/ie3/simona/config/ConfigFailFastSpec.scala @@ -7,7 +7,11 @@ package edu.ie3.simona.config import com.typesafe.config.ConfigFactory -import edu.ie3.simona.config.SimonaConfig.Simona.Input.Weather.Datasource.CoordinateSource +import edu.ie3.simona.config.SimonaConfig.Simona.Input.Weather.Datasource +import edu.ie3.simona.config.SimonaConfig.Simona.Input.Weather.Datasource.{ + CoordinateSource, + SampleParams, +} import edu.ie3.simona.config.SimonaConfig.Simona.Output.Sink import edu.ie3.simona.config.SimonaConfig.Simona.Output.Sink.{Csv, InfluxDb1x} import edu.ie3.simona.config.SimonaConfig.Simona.Powerflow.Newtonraphson @@ -680,23 +684,27 @@ class ConfigFailFastSpec extends UnitSpec with ConfigTestData { "identify faulty notifier identifiers" in { intercept[InvalidConfigParameterException] { - ConfigFailFast invokePrivate checkNotifierIdentifier("whatever") - }.getMessage shouldBe s"The identifier 'whatever' you provided is not valid. Valid input: ${NotifierIdentifier.values.map(_.toString).mkString(",")}" + ConfigFailFast invokePrivate checkNotifierIdentifier( + "whatever", + NotifierIdentifier.getParticipantIdentifiers, + ) + }.getMessage shouldBe s"The identifier 'whatever' you provided is not valid. Valid input: ${NotifierIdentifier.getParticipantIdentifiers.map(_.toString).mkString(",")}" } "let all valid notifier identifiers pass" in { noException shouldBe thrownBy { - NotifierIdentifier.values.map(id => + NotifierIdentifier.getParticipantIdentifiers.map(id => ConfigFailFast invokePrivate checkNotifierIdentifier( - id.toString + id.toString, + NotifierIdentifier.getParticipantIdentifiers, ) ) } } - val checkIndividualParticipantsOutputConfigs = + val checkIndividualOutputConfigs = PrivateMethod[Unit]( - Symbol("checkIndividualParticipantsOutputConfigs") + Symbol("checkIndividualOutputConfigs") ) "let distinct configs pass" in { @@ -722,8 +730,9 @@ class ConfigFailFastSpec extends UnitSpec with ConfigTestData { ) noException shouldBe thrownBy { - ConfigFailFast invokePrivate checkIndividualParticipantsOutputConfigs( - validInput + ConfigFailFast invokePrivate checkIndividualOutputConfigs( + validInput, + "participant", ) } } @@ -751,13 +760,88 @@ class ConfigFailFastSpec extends UnitSpec with ConfigTestData { ) intercept[InvalidConfigParameterException]( - ConfigFailFast invokePrivate checkIndividualParticipantsOutputConfigs( - invalidInput + ConfigFailFast invokePrivate checkIndividualOutputConfigs( + invalidInput, + "participant", ) ).getMessage shouldBe "There are multiple output configurations for participant types 'load'." } } + "Checking thermal output configs" should { + val checkNotifierIdentifier = + PrivateMethod[Unit](Symbol("checkNotifierIdentifier")) + + "identify faulty notifier identifiers" in { + intercept[InvalidConfigParameterException] { + ConfigFailFast invokePrivate checkNotifierIdentifier( + "whatever", + NotifierIdentifier.getThermalIdentifiers, + ) + }.getMessage shouldBe s"The identifier 'whatever' you provided is not valid. Valid input: ${NotifierIdentifier.getThermalIdentifiers.map(_.toString).mkString(",")}" + } + + "let all valid notifier identifiers pass" in { + noException shouldBe thrownBy { + Set("house", "cylindricalstorage").map(id => + ConfigFailFast invokePrivate checkNotifierIdentifier( + id, + NotifierIdentifier.getThermalIdentifiers, + ) + ) + } + } + + val checkIndividualOutputConfigs = + PrivateMethod[Unit]( + Symbol("checkIndividualOutputConfigs") + ) + + "let distinct configs pass" in { + val validInput = List( + SimonaConfig.SimpleOutputConfig( + notifier = "house", + simulationResult = false, + ), + SimonaConfig.SimpleOutputConfig( + notifier = "cylindricalstorage", + simulationResult = false, + ), + ) + + noException shouldBe thrownBy { + ConfigFailFast invokePrivate checkIndividualOutputConfigs( + validInput, + "thermal", + ) + } + } + + "throw an exception, when there is a duplicate entry for the same model type" in { + val invalidInput = List( + SimonaConfig.SimpleOutputConfig( + notifier = "house", + simulationResult = false, + ), + SimonaConfig.SimpleOutputConfig( + notifier = "cylindricalstorage", + simulationResult = false, + ), + SimonaConfig.SimpleOutputConfig( + notifier = "house", + simulationResult = false, + ), + ) + + intercept[InvalidConfigParameterException]( + ConfigFailFast invokePrivate checkIndividualOutputConfigs( + invalidInput, + "thermal", + ) + ).getMessage shouldBe "There are multiple output configurations for thermal types 'house'." + } + } + "Checking data sinks" should { val checkDataSink = PrivateMethod[Unit](Symbol("checkDataSink")) @@ -932,39 +1016,81 @@ class ConfigFailFastSpec extends UnitSpec with ConfigTestData { /* Checking of primary source configuration is delegated to the specific actor. Tests are placed there */ "Checking weather data sources" should { - val checkWeatherDataSource = PrivateMethod[Unit](Symbol("checkWeatherDataSource")) + val csv: BaseCsvParams = + BaseCsvParams(",", "input", isHierarchic = false) + val sample = new SampleParams(true) + + val weatherDataSource = Datasource( + CoordinateSource( + None, + "icon", + Some( + SimonaConfig.Simona.Input.Weather.Datasource.CoordinateSource + .SampleParams(true) + ), + None, + ), + None, + None, + None, + 50000d, + Some(360L), + None, + "icon", + None, + Some("yyyy-MM-dd HH:mm"), + ) + "detects invalid weather data scheme" in { - val weatherDataSource = - new SimonaConfig.Simona.Input.Weather.Datasource( - CoordinateSource( - None, - "icon", - Some( - SimonaConfig.Simona.Input.Weather.Datasource.CoordinateSource - .SampleParams(true) - ), - None, - ), - None, - None, - None, - 50000d, - Some(360L), - Some( - SimonaConfig.Simona.Input.Weather.Datasource.SampleParams(true) - ), - "this won't work", - None, - Some("yyyy-MM-dd HH:mm"), + intercept[InvalidConfigParameterException] { + ConfigFailFast invokePrivate checkWeatherDataSource( + weatherDataSource.copy(scheme = "this won't work") ) + }.getMessage shouldBe "The weather data scheme 'this won't work' is not supported. " + + "Supported schemes:\n\ticon\n\tcosmo" + } + + "detect missing source" in { intercept[InvalidConfigParameterException] { ConfigFailFast invokePrivate checkWeatherDataSource( weatherDataSource ) - }.getMessage shouldBe "The weather data scheme 'this won't work' is not supported. Supported schemes:\n\ticon\n\tcosmo" + }.getMessage should startWith( + "No weather source defined! This is currently not supported! Please provide the config parameters for " + + "one of the following weather sources:" + ) + } + + "detect too many sources" in { + val tooManySources = weatherDataSource.copy( + csvParams = Some(csv), + sampleParams = Some(sample), + ) + + intercept[InvalidConfigParameterException] { + ConfigFailFast invokePrivate checkWeatherDataSource(tooManySources) + }.getMessage should startWith("Multiple weather sources defined:") + } + + "detects sample source mismatch" in { + val csvCoordinateSource = new CoordinateSource( + csvParams = Some(csv), + gridModel = "icon", + sampleParams = None, + sqlParams = None, + ) + + val sampleMismatch = weatherDataSource.copy( + coordinateSource = csvCoordinateSource, + sampleParams = Some(sample), + ) + + intercept[InvalidConfigParameterException] { + ConfigFailFast invokePrivate checkWeatherDataSource(sampleMismatch) + }.getMessage shouldBe "Invalid coordinate source 'csv' defined for SampleWeatherSource. Please adapt the configuration to use sample coordinate source for weather data!" } } @@ -1041,6 +1167,178 @@ class ConfigFailFastSpec extends UnitSpec with ConfigTestData { } } + "checking the parameterization of storages" should { + val checkStorageConfigs = + PrivateMethod[Unit](Symbol("checkStoragesConfig")) + + "throw exception if default initial SOC is negative" in { + + val defaultConfig: SimonaConfig.StorageRuntimeConfig = + SimonaConfig.StorageRuntimeConfig( + calculateMissingReactivePowerWithModel = false, + 1.0, + List(java.util.UUID.randomUUID().toString), + -0.5, + Some(0.8), + ) + val storageConfig = SimonaConfig.Simona.Runtime.Participant + .Storage(defaultConfig, List.empty) + + intercept[RuntimeException] { + ConfigFailFast invokePrivate checkStorageConfigs(storageConfig) + }.getMessage shouldBe "StorageRuntimeConfig: Default initial SOC needs to be between 0.0 and 1.0." + } + + "throw exception if default target SOC is negative" in { + val defaultConfig: SimonaConfig.StorageRuntimeConfig = + SimonaConfig.StorageRuntimeConfig( + calculateMissingReactivePowerWithModel = false, + 1.0, + List(java.util.UUID.randomUUID().toString), + 0.5, + Some(-0.8), + ) + val storageConfig = SimonaConfig.Simona.Runtime.Participant + .Storage(defaultConfig, List.empty) + + intercept[RuntimeException] { + ConfigFailFast invokePrivate checkStorageConfigs(storageConfig) + }.getMessage shouldBe "StorageRuntimeConfig: Default target SOC needs to be between 0.0 and 1.0." + } + + "throw exception if individual initial SOC is negative" in { + val uuid = java.util.UUID.randomUUID().toString + val defaultConfig: SimonaConfig.StorageRuntimeConfig = + SimonaConfig.StorageRuntimeConfig( + calculateMissingReactivePowerWithModel = false, + 1.0, + List(java.util.UUID.randomUUID().toString), + 0.5, + Some(0.8), + ) + val individualConfig: List[SimonaConfig.StorageRuntimeConfig] = List( + SimonaConfig.StorageRuntimeConfig( + calculateMissingReactivePowerWithModel = false, + 1.0, + List(uuid), + -0.5, + Some(0.8), + ) + ) + val storageConfig = SimonaConfig.Simona.Runtime.Participant + .Storage(defaultConfig, individualConfig) + + intercept[RuntimeException] { + ConfigFailFast invokePrivate checkStorageConfigs(storageConfig) + }.getMessage shouldBe s"StorageRuntimeConfig: List($uuid) initial SOC needs to be between 0.0 and 1.0." + } + + "throw exception if individual target SOC is negative" in { + val uuid = java.util.UUID.randomUUID().toString + val defaultConfig: SimonaConfig.StorageRuntimeConfig = + SimonaConfig.StorageRuntimeConfig( + calculateMissingReactivePowerWithModel = false, + 1.0, + List(java.util.UUID.randomUUID().toString), + 0.5, + Some(0.8), + ) + val individualConfig: List[SimonaConfig.StorageRuntimeConfig] = List( + SimonaConfig.StorageRuntimeConfig( + calculateMissingReactivePowerWithModel = false, + 1.0, + List(uuid), + 0.5, + Some(-0.8), + ) + ) + val storageConfig = SimonaConfig.Simona.Runtime.Participant + .Storage(defaultConfig, individualConfig) + + intercept[RuntimeException] { + ConfigFailFast invokePrivate checkStorageConfigs(storageConfig) + }.getMessage shouldBe s"StorageRuntimeConfig: List($uuid) target SOC needs to be between 0.0 and 1.0." + } + + "not throw exception if all parameters are in parameter range" in { + val defaultConfig: SimonaConfig.StorageRuntimeConfig = + SimonaConfig.StorageRuntimeConfig( + calculateMissingReactivePowerWithModel = false, + 1.0, + List(java.util.UUID.randomUUID().toString), + 0.5, + Some(0.8), + ) + val individualConfig: List[SimonaConfig.StorageRuntimeConfig] = List( + SimonaConfig.StorageRuntimeConfig( + calculateMissingReactivePowerWithModel = false, + 1.0, + List(java.util.UUID.randomUUID().toString), + 0.5, + Some(0.8), + ) + ) + val storageConfig = SimonaConfig.Simona.Runtime.Participant + .Storage(defaultConfig, individualConfig) + + noException should be thrownBy { + ConfigFailFast invokePrivate checkStorageConfigs(storageConfig) + } + } + } + + "Checking coordinate sources" should { + val checkCoordinateSource = + PrivateMethod[Unit](Symbol("checkCoordinateSource")) + val csvParams: BaseCsvParams = BaseCsvParams( + ",", + "input", + isHierarchic = false, + ) + val sampleParams = + new SimonaConfig.Simona.Input.Weather.Datasource.CoordinateSource.SampleParams( + true + ) + + val coordinateSource = new CoordinateSource( + csvParams = None, + gridModel = "icon", + sampleParams = None, + sqlParams = None, + ) + + "detect missing source" in { + intercept[InvalidConfigParameterException] { + ConfigFailFast invokePrivate checkCoordinateSource(coordinateSource) + }.getMessage should startWith( + "No coordinate source defined! This is currently not supported! Please provide the config parameters for one of the following coordinate sources" + ) + } + + "detect too many sources" in { + val tooManySources = coordinateSource.copy( + csvParams = Some(csvParams), + sampleParams = Some(sampleParams), + ) + + intercept[InvalidConfigParameterException] { + ConfigFailFast invokePrivate checkCoordinateSource(tooManySources) + }.getMessage should startWith("Multiple coordinate sources defined:") + } + + "detect invalid grid model" in { + val invalidGridModel = coordinateSource.copy( + csvParams = Some(csvParams), + gridModel = "invalid", + ) + + intercept[InvalidConfigParameterException] { + ConfigFailFast invokePrivate checkCoordinateSource(invalidGridModel) + }.getMessage should startWith("Grid model 'invalid' is not supported!") + } + + } + "validating the typesafe config" when { "checking the availability of pekko logger parameterization" should { val checkPekkoLoggers = PrivateMethod[Unit](Symbol("checkPekkoLoggers")) diff --git a/src/test/scala/edu/ie3/simona/service/weather/WeatherSourceSpec.scala b/src/test/scala/edu/ie3/simona/service/weather/WeatherSourceSpec.scala index 2da8e6c63e..3aacd91ff0 100644 --- a/src/test/scala/edu/ie3/simona/service/weather/WeatherSourceSpec.scala +++ b/src/test/scala/edu/ie3/simona/service/weather/WeatherSourceSpec.scala @@ -6,16 +6,8 @@ package edu.ie3.simona.service.weather -import edu.ie3.datamodel.io.factory.timeseries.{ - CosmoIdCoordinateFactory, - IconIdCoordinateFactory, - IdCoordinateFactory, -} import edu.ie3.datamodel.io.source.IdCoordinateSource -import edu.ie3.simona.exceptions.{ - InvalidConfigParameterException, - ServiceException, -} +import edu.ie3.simona.exceptions.ServiceException import edu.ie3.simona.ontology.messages.services.WeatherMessage import edu.ie3.simona.service.weather.WeatherSource.{ AgentCoordinates, @@ -35,7 +27,7 @@ import java.util.Optional import javax.measure.quantity.Length import scala.jdk.CollectionConverters._ import scala.jdk.OptionConverters._ -import scala.util.{Failure, Success, Try} +import scala.util.{Failure, Success} class WeatherSourceSpec extends UnitSpec { private val coordinate0 = GeoUtils.buildPoint(51.47, 7.41) @@ -47,29 +39,17 @@ class WeatherSourceSpec extends UnitSpec { 9, ) match { case Failure(exception: ServiceException) => - exception.getMessage shouldBe "There are not enough coordinates for averaging. Found 8 within the given distance of 400000 m but need 9. Please make sure that there are enough coordinates within the given distance." + exception.getMessage shouldBe "There are not enough coordinates for averaging. Found 4 within the given distance of 400000 m but need 9. Please make sure that there are enough coordinates within the given distance." case _ => fail("You shall not pass!") } } "issue a ServiceException, if there are not enough coordinates in max distance available" in { DummyWeatherSource.getNearestCoordinatesWithDistances( AgentCoordinates(coordinate0.getY, coordinate0.getX), - 9, + 5, ) match { case Failure(exception: ServiceException) => - exception.getMessage shouldBe "There are not enough coordinates for averaging. Found 8 within the given distance of 400000 m but need 9. Please make sure that there are enough coordinates within the given distance." - case _ => fail("You shall not pass!") - } - } - - "issue a ServiceException, if the queried coordinate is not surrounded by the found weather coordinates" in { - val agentCoordinates = AgentCoordinates(51.3, 7.3) - DummyWeatherSource.getNearestCoordinatesWithDistances( - agentCoordinates, - 4, - ) match { - case Failure(exception: ServiceException) => - exception.getMessage shouldBe "The queried point shall be surrounded by 4 weather coordinates, which are in each quadrant. This is not the case." + exception.getMessage shouldBe "There are not enough coordinates for averaging. Found 4 within the given distance of 400000 m but need 5. Please make sure that there are enough coordinates within the given distance." case _ => fail("You shall not pass!") } } @@ -244,7 +224,7 @@ class WeatherSourceSpec extends UnitSpec { case Failure(exception: ServiceException) => exception.getMessage shouldBe "Determination of coordinate weights failed." exception.getCause shouldBe ServiceException( - "There are not enough coordinates for averaging. Found 8 within the given distance of 400000 m but need 9. Please make sure that there are enough coordinates within the given distance." + "There are not enough coordinates for averaging. Found 4 within the given distance of 400000 m but need 9. Please make sure that there are enough coordinates within the given distance." ) case _ => fail("You shall not pass!") } @@ -299,41 +279,6 @@ class WeatherSourceSpec extends UnitSpec { ) } } - - "return correct coordinate factory" in { - val checkCoordinateFactory = - PrivateMethod[IdCoordinateFactory](Symbol("checkCoordinateFactory")) - - val cases = Table( - ("gridModel", "expectedClass", "failureMessage"), - ( - "", - classOf[InvalidConfigParameterException], - "No grid model defined!", - ), - ("icon", classOf[IconIdCoordinateFactory], ""), - ("cosmo", classOf[CosmoIdCoordinateFactory], ""), - ( - "else", - classOf[InvalidConfigParameterException], - "Grid model 'else' is not supported!", - ), - ) - - forAll(cases) { (gridModel, expectedClass, failureMessage) => - val actual = - Try(WeatherSource invokePrivate checkCoordinateFactory(gridModel)) - - actual match { - case Success(factory) => - factory.getClass shouldBe expectedClass - - case Failure(exception) => - exception.getClass shouldBe expectedClass - exception.getMessage shouldBe failureMessage - } - } - } } } @@ -450,5 +395,18 @@ case object WeatherSourceSpec { ): util.List[CoordinateDistance] = { calculateCoordinateDistances(coordinate, n, coordinateToId.keySet.asJava) } + + override def findCornerPoints( + coordinate: Point, + distance: ComparableQuantity[Length], + ): util.List[CoordinateDistance] = + findCornerPoints( + coordinate, + getClosestCoordinates(coordinate, 9, distance), + ) + + override def validate(): Unit = { + /* nothing to do here */ + } } } diff --git a/src/test/scala/edu/ie3/simona/service/weather/WeatherSourceWrapperSpec.scala b/src/test/scala/edu/ie3/simona/service/weather/WeatherSourceWrapperSpec.scala index ab00e32bdf..3c937e0976 100644 --- a/src/test/scala/edu/ie3/simona/service/weather/WeatherSourceWrapperSpec.scala +++ b/src/test/scala/edu/ie3/simona/service/weather/WeatherSourceWrapperSpec.scala @@ -318,11 +318,9 @@ object WeatherSourceWrapperSpec { ), ) - override def getSourceFields[C <: WeatherValue]( - entityClass: Class[C] - ): Optional[util.Set[String]] = + override def getSourceFields: Optional[util.Set[String]] = // only required for validation - Optional.empty + Optional.empty() override def getWeather( timeInterval: ClosedInterval[ZonedDateTime] diff --git a/src/test/scala/edu/ie3/simona/sim/SimonaSimSpec.scala b/src/test/scala/edu/ie3/simona/sim/SimonaSimSpec.scala index ea39a5e654..704f226112 100644 --- a/src/test/scala/edu/ie3/simona/sim/SimonaSimSpec.scala +++ b/src/test/scala/edu/ie3/simona/sim/SimonaSimSpec.scala @@ -399,6 +399,8 @@ object SimonaSimSpec { override val args: Array[String] = Array.empty[String] + override def logOutputDir: String = throw new NotImplementedError() + override def runtimeEventListener( context: ActorContext[_] ): ActorRef[RuntimeEventListener.Request] = context.spawn( diff --git a/src/test/scala/edu/ie3/simona/sim/setup/SimonaSetupSpec.scala b/src/test/scala/edu/ie3/simona/sim/setup/SimonaSetupSpec.scala index 0283aa4968..2e8aef8277 100644 --- a/src/test/scala/edu/ie3/simona/sim/setup/SimonaSetupSpec.scala +++ b/src/test/scala/edu/ie3/simona/sim/setup/SimonaSetupSpec.scala @@ -32,6 +32,8 @@ class SimonaSetupSpec extends UnitSpec with SimonaSetup with SubGridGateMokka { override val args: Array[String] = Array.empty[String] + override def logOutputDir: String = throw new NotImplementedError() + override def runtimeEventListener( context: ActorContext[_] ): ActorRef[RuntimeEventListener.Request] = diff --git a/src/test/scala/edu/ie3/simona/test/common/ConfigTestData.scala b/src/test/scala/edu/ie3/simona/test/common/ConfigTestData.scala index 26cb038257..e9a1010194 100644 --- a/src/test/scala/edu/ie3/simona/test/common/ConfigTestData.scala +++ b/src/test/scala/edu/ie3/simona/test/common/ConfigTestData.scala @@ -121,6 +121,15 @@ trait ConfigTestData { | individualConfigs = [] |} | + |simona.runtime.participant.storage = { + | defaultConfig = { + | calculateMissingReactivePowerWithModel = false + | uuids = ["default"] + | scaling = 1.0 + | } + | individualConfigs = [] + |} + | |simona.runtime.participant.em = { | defaultConfig = { | calculateMissingReactivePowerWithModel = false diff --git a/src/test/scala/edu/ie3/simona/test/common/DefaultTestData.scala b/src/test/scala/edu/ie3/simona/test/common/DefaultTestData.scala index c1d93d7476..6f16001303 100644 --- a/src/test/scala/edu/ie3/simona/test/common/DefaultTestData.scala +++ b/src/test/scala/edu/ie3/simona/test/common/DefaultTestData.scala @@ -203,6 +203,21 @@ trait DefaultTestData { | ] |} | + |simona.runtime.participant.storage = { + | defaultConfig = { + | calculateMissingReactivePowerWithModel = false + | uuids = ["default"] + | scaling = 1.0 + | } + | individualConfigs = [ + | { + | calculateMissingReactivePowerWithModel = false + | uuids = ["9abe950d-362e-4ffe-b686-500f84d8f368"] + | scaling = 1.0 + | } + | ] + |} + | |simona.runtime.participant.em = { | defaultConfig = { | calculateMissingReactivePowerWithModel = false diff --git a/src/test/scala/edu/ie3/simona/test/common/input/EmInputTestData.scala b/src/test/scala/edu/ie3/simona/test/common/input/EmInputTestData.scala index 4c349c603e..28cc5fcf15 100644 --- a/src/test/scala/edu/ie3/simona/test/common/input/EmInputTestData.scala +++ b/src/test/scala/edu/ie3/simona/test/common/input/EmInputTestData.scala @@ -32,7 +32,6 @@ import edu.ie3.simona.util.ConfigUtil import edu.ie3.util.quantities.PowerSystemUnits._ import squants.energy.Kilowatts import tech.units.indriya.quantity.Quantities -import tech.units.indriya.unit.Units._ import java.util.UUID import scala.jdk.CollectionConverters.SeqHasAsJava @@ -70,17 +69,14 @@ trait EmInputTestData protected val householdStorageTypeInput = new StorageTypeInput( UUID.randomUUID(), "Dummy_Household_StorageTypeInput", - Quantities.getQuantity(100d, EURO), - Quantities.getQuantity(101d, EURO_PER_MEGAWATTHOUR), + Quantities.getQuantity(4000d, EURO), + Quantities.getQuantity(0.05d, EURO_PER_MEGAWATTHOUR), Quantities.getQuantity(15d, KILOWATTHOUR), Quantities.getQuantity(5d, KILOVOLTAMPERE), 0.997, Quantities.getQuantity(5d, KILOWATT), Quantities.getQuantity(0.03, PU_PER_HOUR), Quantities.getQuantity(0.95, PU), - Quantities.getQuantity(20d, PERCENT), - Quantities.getQuantity(50000d, HOUR), - 100000, ) protected val householdStorageInput = new StorageInput( diff --git a/src/test/scala/edu/ie3/simona/test/common/input/StorageInputTestData.scala b/src/test/scala/edu/ie3/simona/test/common/input/StorageInputTestData.scala new file mode 100644 index 0000000000..99135b24fa --- /dev/null +++ b/src/test/scala/edu/ie3/simona/test/common/input/StorageInputTestData.scala @@ -0,0 +1,46 @@ +/* + * © 2022. TU Dortmund University, + * Institute of Energy Systems, Energy Efficiency and Energy Economics, + * Research group Distribution grid planning and operation + */ + +package edu.ie3.simona.test.common.input + +import edu.ie3.datamodel.models.OperationTime +import edu.ie3.datamodel.models.input.OperatorInput +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.simona.test.common.DefaultTestData +import edu.ie3.util.quantities.PowerSystemUnits._ +import tech.units.indriya.quantity.Quantities + +import java.util.UUID + +trait StorageInputTestData extends DefaultTestData with NodeInputTestData { + + protected val storageTypeInput = new StorageTypeInput( + UUID.fromString("fbee4995-24dd-45e4-9c85-7d986fe99ff3"), + "Dummy_StorageTypeInput", + Quantities.getQuantity(15000d, EURO), + Quantities.getQuantity(0.05d, EURO_PER_MEGAWATTHOUR), + Quantities.getQuantity(200d, KILOWATTHOUR), + Quantities.getQuantity(13d, KILOVOLTAMPERE), + 0.997, + Quantities.getQuantity(12.961, KILOWATT), + Quantities.getQuantity(0.03, PU_PER_HOUR), + Quantities.getQuantity(0.92, PU), + ) + + protected val storageInput = new StorageInput( + UUID.randomUUID(), + "Dummy_StorageInput", + new OperatorInput(UUID.randomUUID(), "NO_OPERATOR"), + OperationTime.notLimited(), + nodeInputNoSlackNs04KvA, + CosPhiFixed.CONSTANT_CHARACTERISTIC, + null, + storageTypeInput, + ) + +} diff --git a/src/test/scala/edu/ie3/simona/util/ConfigUtilSpec.scala b/src/test/scala/edu/ie3/simona/util/ConfigUtilSpec.scala index 1356fef9cb..a19cca5be3 100644 --- a/src/test/scala/edu/ie3/simona/util/ConfigUtilSpec.scala +++ b/src/test/scala/edu/ie3/simona/util/ConfigUtilSpec.scala @@ -58,7 +58,7 @@ class ConfigUtilSpec inside(actual) { case ParticipantConfigUtil(configs, defaultConfigs) => configs shouldBe Map.empty[UUID, SimonaConfig.LoadRuntimeConfig] - defaultConfigs.size shouldBe 7 + defaultConfigs.size shouldBe 8 inside(defaultConfigs.get(classOf[LoadRuntimeConfig])) { case Some( @@ -118,7 +118,7 @@ class ConfigUtilSpec UUID.fromString("49f250fa-41ff-4434-a083-79c98d260a76") ) - defaultConfigs.size shouldBe 7 + defaultConfigs.size shouldBe 8 inside(defaultConfigs.get(classOf[LoadRuntimeConfig])) { case Some( LoadRuntimeConfig( @@ -331,7 +331,7 @@ class ConfigUtilSpec UUID.fromString("49f250fa-41ff-4434-a083-79c98d260a76") ) - defaultConfigs.size shouldBe 7 + defaultConfigs.size shouldBe 8 inside(defaultConfigs.get(classOf[FixedFeedInRuntimeConfig])) { case Some( FixedFeedInRuntimeConfig( @@ -721,9 +721,10 @@ class ConfigUtilSpec ), ) val configUtil = OutputConfigUtil(inputConfig) - val expectedResult: Set[Value] = NotifierIdentifier.values -- Vector( - NotifierIdentifier.PvPlant - ) -- NotifierIdentifier.getThermalIdentifiers.toVector + val expectedResult: Set[Value] = + NotifierIdentifier.getParticipantIdentifiers -- Vector( + NotifierIdentifier.PvPlant + ) configUtil.simulationResultIdentifiersToConsider( false