diff --git a/.gitignore b/.gitignore index 2b424aba..c2cd097a 100644 --- a/.gitignore +++ b/.gitignore @@ -153,4 +153,4 @@ testData/download !testData/download/targetFolderExistsAndIsFile.zip # Downloader productive folder -inputData/download \ No newline at end of file +input/download \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a7f37e5..dd6a79f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Conversion of pv plants into actual PvInputs [#253](https://github.com/ie3-institute/simBench2psdm/issues/253) + + ## [1.0.0] - 2021-08-03 ### Added - Basic functionality to convert SimBench data sets to [PowerSystemDataModel](https://github.com/ie3-institute/powersystemdatamodel) diff --git a/build.gradle b/build.gradle index d329e196..5c9f83a6 100644 --- a/build.gradle +++ b/build.gradle @@ -18,7 +18,7 @@ ext { scalaVersion = '2.13' scalaBinaryVersion = '2.13.14' - tscfgVersion = '0.9.986' + tscfgVersion = '1.0.0' slf4jVersion = '2.0.13' scriptsLocation = 'gradle' + File.separator + 'scripts' + File.separator //location of script plugins @@ -87,8 +87,8 @@ dependencies { implementation 'org.mockito:mockito-core:5.12.0' // mocking framework // config // - implementation 'com.typesafe:config:+' - implementation "com.github.carueda:tscfg_2.13:${tscfgVersion}" + implementation 'com.typesafe:config:1.4.3' + implementation "com.github.carueda:tscfg_2.13:$tscfgVersion" // cmd args parser // implementation "com.github.scopt:scopt_${scalaVersion}:+" diff --git a/gradle/scripts/tscfg.gradle b/gradle/scripts/tscfg.gradle index fc2026a6..90ac4283 100644 --- a/gradle/scripts/tscfg.gradle +++ b/gradle/scripts/tscfg.gradle @@ -5,7 +5,7 @@ task genConfigClass { doLast { def tscfgJarFile = project.file('build/tscfg-' + tscfgVersion + '.jar') if (!tscfgJarFile.exists() || !tscfgJarFile.isFile()) { - download { + download.run { src 'https://github.com/carueda/tscfg/releases/download/v' + tscfgVersion + '/tscfg-' + tscfgVersion + '.jar' dest buildDir } diff --git a/inputData/config/allNoSwitches.conf b/input/config/allNoSwitches.conf similarity index 100% rename from inputData/config/allNoSwitches.conf rename to input/config/allNoSwitches.conf diff --git a/inputData/config/multiVoltLvlNoSwitches.conf b/input/config/multiVoltLvlNoSwitches.conf similarity index 100% rename from inputData/config/multiVoltLvlNoSwitches.conf rename to input/config/multiVoltLvlNoSwitches.conf diff --git a/inputData/config/mvLvNoSwitches.conf b/input/config/mvLvNoSwitches.conf similarity index 100% rename from inputData/config/mvLvNoSwitches.conf rename to input/config/mvLvNoSwitches.conf diff --git a/inputData/config/mvWithSwitches.conf b/input/config/mvWithSwitches.conf similarity index 100% rename from inputData/config/mvWithSwitches.conf rename to input/config/mvWithSwitches.conf diff --git a/inputData/config/singleVoltLvlNoSwitches.conf b/input/config/singleVoltLvlNoSwitches.conf similarity index 100% rename from inputData/config/singleVoltLvlNoSwitches.conf rename to input/config/singleVoltLvlNoSwitches.conf diff --git a/inputData/config/smallGrid.conf b/input/config/smallGrid.conf similarity index 100% rename from inputData/config/smallGrid.conf rename to input/config/smallGrid.conf diff --git a/src/main/resources/config-template.conf b/src/main/resources/config-template.conf index 55a408d4..77e9fc4d 100644 --- a/src/main/resources/config-template.conf +++ b/src/main/resources/config-template.conf @@ -10,16 +10,17 @@ io { simbenchCodes = ["String"] input { download.baseUrl = "String" | "http://141.51.193.167/simbench/gui/usecase/download" - download.folder = "String" | "inputData/download/" + download.folder = "String" | "input/download/" download.failOnExistingFiles = "Boolean" | false csv = CsvConfig } output { csv = CsvConfig - targetFolder = "String" | "convertedData" + targetFolder = "String" | "output" compress = "Boolean" | true } } conversion { removeSwitches = "Boolean" | false + convertPv = "Boolean" | false } \ No newline at end of file diff --git a/src/main/scala/edu/ie3/simbench/config/SimbenchConfig.scala b/src/main/scala/edu/ie3/simbench/config/SimbenchConfig.scala index 8f9caba7..5f5b9474 100644 --- a/src/main/scala/edu/ie3/simbench/config/SimbenchConfig.scala +++ b/src/main/scala/edu/ie3/simbench/config/SimbenchConfig.scala @@ -1,4 +1,4 @@ -// generated by tscfg 0.9.986 on Mon Aug 09 20:12:24 CEST 2021 +// generated by tscfg 1.0.0 on Mon Jul 08 11:40:43 CEST 2024 // source: src/main/resources/config-template.conf package edu.ie3.simbench.config @@ -38,6 +38,7 @@ object SimbenchConfig { } final case class Conversion( + convertPv: scala.Boolean, removeSwitches: scala.Boolean ) object Conversion { @@ -47,9 +48,9 @@ object SimbenchConfig { $tsCfgValidator: $TsCfgValidator ): SimbenchConfig.Conversion = { SimbenchConfig.Conversion( - removeSwitches = c.hasPathOrNull("removeSwitches") && c.getBoolean( - "removeSwitches" - ) + convertPv = c.hasPathOrNull("convertPv") && c.getBoolean("convertPv"), + removeSwitches = + c.hasPathOrNull("removeSwitches") && c.getBoolean("removeSwitches") ) } } @@ -86,7 +87,7 @@ object SimbenchConfig { ), folder = if (c.hasPathOrNull("folder")) c.getString("folder") - else "inputData/download/" + else "input/download/" ) } } @@ -134,7 +135,7 @@ object SimbenchConfig { ), targetFolder = if (c.hasPathOrNull("targetFolder")) c.getString("targetFolder") - else "convertedData" + else "output" ) } } @@ -208,7 +209,7 @@ object SimbenchConfig { java.lang.String.valueOf(cv.unwrapped()) } - private final class $TsCfgValidator { + final class $TsCfgValidator { private val badPaths = scala.collection.mutable.ArrayBuffer[java.lang.String]() diff --git a/src/main/scala/edu/ie3/simbench/convert/GridConverter.scala b/src/main/scala/edu/ie3/simbench/convert/GridConverter.scala index e1c25b5c..190c3b90 100644 --- a/src/main/scala/edu/ie3/simbench/convert/GridConverter.scala +++ b/src/main/scala/edu/ie3/simbench/convert/GridConverter.scala @@ -28,11 +28,13 @@ import edu.ie3.simbench.convert.NodeConverter.AttributeOverride.{ JoinOverride, SubnetOverride } +import edu.ie3.simbench.convert.ResConverter.ConvertedRes import edu.ie3.simbench.convert.types.{ LineTypeConverter, Transformer2wTypeConverter } import edu.ie3.simbench.exception.ConversionException +import edu.ie3.simbench.io.ParticipantToInput import edu.ie3.simbench.model.datamodel._ import scala.annotation.tailrec @@ -51,6 +53,9 @@ case object GridConverter extends LazyLogging { * Total grid input model to be converted * @param removeSwitches * Whether or not to remove switches from the grid structure + * @param participantToInput + * Whether or not to convert a given type of participant into actual input + * models * @return * A converted [[JointGridContainer]], a [[Vector]] of * [[IndividualTimeSeries]] as well as a [[Vector]] of [[NodeResult]]s @@ -58,7 +63,8 @@ case object GridConverter extends LazyLogging { def convert( simbenchCode: String, gridInput: GridModel, - removeSwitches: Boolean + removeSwitches: Boolean, + participantToInput: ParticipantToInput ): ( JointGridContainer, Vector[IndividualTimeSeries[_ <: PValue]], @@ -73,7 +79,7 @@ case object GridConverter extends LazyLogging { s"Converting system participants and their time series of '${gridInput.simbenchCode}'" ) val (systemParticipants, timeSeries, timeSeriesMapping) = - convertParticipants(gridInput, nodeConversion) + convertParticipants(gridInput, nodeConversion, participantToInput) logger.debug( s"Converting power flow results of '${gridInput.simbenchCode}'" @@ -601,13 +607,17 @@ case object GridConverter extends LazyLogging { * Total grid input model to convert * @param nodeConversion * Already known conversion mapping of nodes + * @param participantToInput + * Whether or not to convert a given type of participant into actual input + * models * @return * A collection of converted system participants and their individual time * series */ def convertParticipants( gridInput: GridModel, - nodeConversion: Map[Node, NodeInput] + nodeConversion: Map[Node, NodeInput], + participantToInput: ParticipantToInput ): ( SystemParticipants, Vector[IndividualTimeSeries[_ <: PValue]], @@ -626,14 +636,14 @@ case object GridConverter extends LazyLogging { logger.debug( s"Done converting ${gridInput.powerPlants.size} power plants including time series" ) - val resToTimeSeries = convertRes(gridInput, nodeConversion) + val res = convertRes(gridInput, nodeConversion, participantToInput) logger.debug( s"Done converting ${gridInput.res.size} RES including time series" ) /* Map participant uuid onto time series */ val participantsToTimeSeries = - loadsToTimeSeries ++ powerPlantsToTimeSeries ++ resToTimeSeries + loadsToTimeSeries ++ powerPlantsToTimeSeries ++ res.fixedFeedInInput val mapping = participantsToTimeSeries.map { case (model, timeSeries) => new TimeSeriesMappingSource.MappingEntry( model.getUuid, @@ -649,10 +659,10 @@ case object GridConverter extends LazyLogging { Set.empty[ChpInput].asJava, Set.empty[EvcsInput].asJava, Set.empty[EvInput].asJava, - (powerPlantsToTimeSeries.keySet ++ resToTimeSeries.keySet).asJava, + (powerPlantsToTimeSeries.keySet ++ res.fixedFeedInInput.keySet).asJava, Set.empty[HpInput].asJava, loadsToTimeSeries.keySet.asJava, - Set.empty[PvInput].asJava, + res.pvInput.asJava, Set.empty[StorageInput].asJava, Set.empty[WecInput].asJava ), @@ -709,16 +719,25 @@ case object GridConverter extends LazyLogging { * Total grid input model to convert * @param nodeConversion * Already known conversion mapping of nodes + * @param participantToInput + * Whether or not to convert a given type of participant into actual input + * models * @return * A mapping from renewable energy source system to their assigned, * specific time series */ def convertRes( gridInput: GridModel, - nodeConversion: Map[Node, NodeInput] - ): Map[FixedFeedInInput, IndividualTimeSeries[PValue]] = { + nodeConversion: Map[Node, NodeInput], + participantToInput: ParticipantToInput + ): ConvertedRes = { val resProfiles = gridInput.resProfiles.map(profile => profile.profileType -> profile).toMap - ResConverter.convert(gridInput.res, nodeConversion, resProfiles) + ResConverter.convert( + gridInput.res, + nodeConversion, + resProfiles, + participantToInput + ) } } diff --git a/src/main/scala/edu/ie3/simbench/convert/ResConverter.scala b/src/main/scala/edu/ie3/simbench/convert/ResConverter.scala index 4b254396..55f81c78 100644 --- a/src/main/scala/edu/ie3/simbench/convert/ResConverter.scala +++ b/src/main/scala/edu/ie3/simbench/convert/ResConverter.scala @@ -1,14 +1,18 @@ package edu.ie3.simbench.convert -import java.util.{Locale, UUID} - -import edu.ie3.datamodel.models.OperationTime -import edu.ie3.datamodel.models.input.system.FixedFeedInInput +import edu.ie3.datamodel.models.{OperationTime, StandardUnits} import edu.ie3.datamodel.models.input.system.characteristic.CosPhiFixed +import edu.ie3.datamodel.models.input.system.{FixedFeedInInput, PvInput} import edu.ie3.datamodel.models.input.{NodeInput, OperatorInput} import edu.ie3.datamodel.models.timeseries.individual.IndividualTimeSeries import edu.ie3.datamodel.models.value.PValue -import edu.ie3.simbench.convert.profiles.PowerProfileConverter +import edu.ie3.simbench.convert.profiles.{ + PowerProfileConverter, + PvProfileConverter, + ResProfileConverter +} +import edu.ie3.simbench.io.ParticipantToInput +import edu.ie3.simbench.model.datamodel.enums.ResType.{PV, PvMv} import edu.ie3.simbench.model.datamodel.profiles.{ResProfile, ResProfileType} import edu.ie3.simbench.model.datamodel.{Node, RES} import edu.ie3.util.quantities.PowerSystemUnits.{ @@ -16,12 +20,20 @@ import edu.ie3.util.quantities.PowerSystemUnits.{ MEGAVOLTAMPERE, MEGAWATT } +import tech.units.indriya.ComparableQuantity import tech.units.indriya.quantity.Quantities +import java.util.{Locale, UUID} +import javax.measure.quantity.Dimensionless import scala.collection.parallel.CollectionConverters._ case object ResConverter extends ShuntConverter { + final case class ConvertedRes( + fixedFeedInInput: Map[FixedFeedInInput, IndividualTimeSeries[PValue]], + pvInput: Set[PvInput] + ) + /** Convert a full set of renewable energy source system * * @param res @@ -30,6 +42,9 @@ case object ResConverter extends ShuntConverter { * Mapping from Simbench to power system data model node * @param profiles * Collection of [[ResProfile]]s + * @param participantToInput + * Whether or not to convert a given type of participant into actual input + * models * @return * A mapping from converted renewable energy source system to equivalent * individual time series @@ -37,9 +52,30 @@ case object ResConverter extends ShuntConverter { def convert( res: Vector[RES], nodes: Map[Node, NodeInput], - profiles: Map[ResProfileType, ResProfile] - ): Map[FixedFeedInInput, IndividualTimeSeries[PValue]] = - res.par + profiles: Map[ResProfileType, ResProfile], + participantToInput: ParticipantToInput + ): ConvertedRes = { + + /* check whether to convert pv plants into PvInputs */ + val (pvs, pvInputs) = if (participantToInput.pvInput) { + val plants = + res.filter(input => input.resType == PV || input.resType == PvMv) + val inputs = plants.par + .map { plant => + val node = NodeConverter.getNode(plant.node, nodes) + val profile = + PowerProfileConverter.getProfile(plant.profile, profiles) + convertPv(plant, node, profile) + } + .seq + .toSet + + (plants, inputs) + } else (Vector.empty, Set.empty[PvInput]) + + /* convert all other plants into a FixedFeedInInputs */ + val fixedFeedIns = res.diff(pvs) + val fixedFeedInInput = fixedFeedIns.par .map { plant => val node = NodeConverter.getNode(plant.node, nodes) val profile = @@ -49,6 +85,79 @@ case object ResConverter extends ShuntConverter { .seq .toMap + /* return the converted models */ + ConvertedRes(fixedFeedInInput, pvInputs) + } + + def convertPv( + input: RES, + node: NodeInput, + profile: ResProfile, + uuid: Option[UUID] = None + ): PvInput = { + val p = Quantities.getQuantity(input.p, MEGAWATT) + val q = Quantities.getQuantity(input.q, MEGAVAR) + val cosphi = cosPhi(p.getValue.doubleValue(), q.getValue.doubleValue()) + val varCharacteristicString = + "cosPhiFixed:{(0.0,%#.2f)}".formatLocal(Locale.ENGLISH, cosphi) + val sRated = Quantities.getQuantity(input.sR, MEGAVOLTAMPERE) + + /* Flip the sign, as infeed is negative in PowerSystemDataModel */ + val timeSeries = PowerProfileConverter.convert(profile, p.multiply(-1)) + + /* build pv parameter from time series */ + // TODO: Check these values, that vn_simona defaults to + val etaConv: ComparableQuantity[Dimensionless] = Quantities.getQuantity( + 97, + StandardUnits.EFFICIENCY + ) // vn_simona uses 95%, 97% and 98% + val albedo: Double = 0.20000000298023224 // see vn_simona + val kG: Double = 0.8999999761581421 // see vn_simona + val kT: Double = 1.0 // see vn_simona + + /* calculate the power values before the converter */ + val powerBeforeConverter = + PvProfileConverter.calculatePowerBeforeConverter( + timeSeries, + etaConv, + kG, + kT + ) + + val maxFeedIn = ResProfileConverter.findMaxFeedIn(powerBeforeConverter) + + /* calculate the angles of the pv input */ + val azimuth = PvProfileConverter.getAzimuth(profile.profileType) + + /* calculate the elevation angle */ + val elevationAngle = + PvProfileConverter.calculateElevationAngle( + maxFeedIn, + sRated, + profile.profileType, + azimuth + ) + + new PvInput( + uuid.getOrElse(UUID.randomUUID()), + input.id + "_" + input.resType.toString, + OperatorInput.NO_OPERATOR_ASSIGNED, + OperationTime.notLimited(), + node, + new CosPhiFixed(varCharacteristicString), + null, + albedo, + azimuth, + etaConv, + elevationAngle, + kG, + kT, + false, + sRated, + cosphi + ) + } + /** Converts a single renewable energy source system to a fixed feed in model * due to lacking information to sophistically guess typical types of assets. * Different voltage regulation strategies are not covered, yet. diff --git a/src/main/scala/edu/ie3/simbench/convert/profiles/PvProfileConverter.scala b/src/main/scala/edu/ie3/simbench/convert/profiles/PvProfileConverter.scala new file mode 100644 index 00000000..c84c6431 --- /dev/null +++ b/src/main/scala/edu/ie3/simbench/convert/profiles/PvProfileConverter.scala @@ -0,0 +1,115 @@ +package edu.ie3.simbench.convert.profiles + +import edu.ie3.datamodel.models.StandardUnits +import edu.ie3.datamodel.models.input.system.PvInput +import edu.ie3.datamodel.models.timeseries.individual.{ + IndividualTimeSeries, + TimeBasedValue +} +import edu.ie3.datamodel.models.value.PValue +import edu.ie3.simbench.convert.profiles.ResProfileConverter.CompassDirection.{ + East, + North, + South, + West +} +import edu.ie3.simbench.model.datamodel.profiles.ResProfileType +import org.locationtech.jts.geom.Coordinate +import tech.units.indriya.ComparableQuantity +import tech.units.indriya.quantity.Quantities + +import javax.measure.quantity.{Angle, Dimensionless, Power} +import scala.jdk.CollectionConverters._ + +object PvProfileConverter { + + /** Calculates the power before the converter using the efficiency of the used + * converter and the technical influence. + * + * @param timeSeries + * of power values + * @param efficiency + * efficiency of converter (typically in %) + * @param kG + * generator correction factor merging different technical influences + * @param kT + * generator correction factor merging different technical influences + * @return + */ + def calculatePowerBeforeConverter( + timeSeries: IndividualTimeSeries[PValue], + efficiency: ComparableQuantity[Dimensionless], + kG: Double, + kT: Double + ): IndividualTimeSeries[PValue] = { + val correctionFactor = efficiency.getValue.doubleValue() / 100 * kG * kT + + new IndividualTimeSeries( + timeSeries.getEntries.asScala.map { timeBasedValue => + // get the power value + val powerOption = timeBasedValue.getValue.getP + + val pValue = if (powerOption.isPresent) { + // if the power value is present, we divide it by the efficiency of the converter + new PValue( + powerOption.get().divide(correctionFactor) + ) + } else timeBasedValue.getValue // else we just return the given value + + new TimeBasedValue(timeBasedValue.getTime, pValue) + }.asJava + ) + } + + /** Determine the azimuth (inclination in a compass direction) of the + * [[PvInput]] using the [[ResProfileType]]. + * @param profile + * of the plant + * @return + * the azimuth of the pv input + */ + def getAzimuth( + profile: ResProfileType + ): ComparableQuantity[Angle] = { + /* extracting some useful information from the given profile type */ + val orientation = ResProfileConverter.getCompassDirection(profile) + /* find azimuth using the given orientation */ + orientation match { + case North => Quantities.getQuantity(180d, StandardUnits.AZIMUTH) + case East => Quantities.getQuantity(-90d, StandardUnits.AZIMUTH) + case South => Quantities.getQuantity(0d, StandardUnits.AZIMUTH) + case West => Quantities.getQuantity(90d, StandardUnits.AZIMUTH) + } + } + + /** Calculates the elevation angle of a [[PvInput]]. + * @param maxFeedIn + * option for the maximum occurred feed in + * @param sRated + * the rated maximum power of the model + * @param profile + * the profile of the pv model that can be used to retrieve the latitude of + * location the pv model is installed + * @param azimuth + * of the [[PvInput]] + * @return + * the elevation angle in radians + */ + def calculateElevationAngle( + maxFeedIn: Option[TimeBasedValue[PValue]], + sRated: ComparableQuantity[Power], + profile: ResProfileType, + azimuth: ComparableQuantity[Angle] + ): ComparableQuantity[Angle] = { + val position: Coordinate = ResProfileConverter.getCoordinate(profile) + + // TODO: Adjust angle with power value, rated power and azimuth + // angle for horizontal irradiance + val angleInDegrees = position.getY - 15 + Quantities.getQuantity( + Math.toRadians(angleInDegrees), + StandardUnits.SOLAR_ELEVATION_ANGLE + ) + + } +} diff --git a/src/main/scala/edu/ie3/simbench/convert/profiles/ResProfileConverter.scala b/src/main/scala/edu/ie3/simbench/convert/profiles/ResProfileConverter.scala new file mode 100644 index 00000000..b3257480 --- /dev/null +++ b/src/main/scala/edu/ie3/simbench/convert/profiles/ResProfileConverter.scala @@ -0,0 +1,99 @@ +package edu.ie3.simbench.convert.profiles + +import edu.ie3.datamodel.models.timeseries.individual.{ + IndividualTimeSeries, + TimeBasedValue +} +import edu.ie3.datamodel.models.value.PValue +import edu.ie3.simbench.exception.ConversionException +import edu.ie3.simbench.model.datamodel.profiles.ResProfileType +import edu.ie3.util.geo.GeoUtils +import org.locationtech.jts.geom.Coordinate + +import edu.ie3.util.quantities.QuantityUtils._ + +import scala.jdk.CollectionConverters.SetHasAsScala +import scala.jdk.OptionConverters.RichOptional + +object ResProfileConverter { + // default coordinates for two locations + val hanoverCoordinate: Coordinate = + GeoUtils.buildCoordinate(52.366667, 9.733333) + val luebeckCoordinate: Coordinate = + GeoUtils.buildCoordinate(53.866667, 10.683333) + + /** Determines the maximum feed in of the time series. + * @param timeSeries + * given time series + * @return + * time based value with the maximum feed in + */ + def findMaxFeedIn( + timeSeries: IndividualTimeSeries[PValue] + ): Option[TimeBasedValue[PValue]] = + timeSeries.getEntries.asScala + .filter { value => + value.getValue.getP.toScala + .getOrElse(1.asKiloWatt) + .getValue + .doubleValue() < 0 + } + .minByOption { value => + value.getValue.getP.toScala + .getOrElse(0.asKiloWatt) + .getValue + .doubleValue() + } + + /** Determines the [[Coordinate]] of a + * [[edu.ie3.simbench.model.datamodel.RES]] based on its [[ResProfileType]] + * @param profileType + * that can be used to determine the position + * @return + * the coordinate + */ + def getCoordinate(profileType: ResProfileType): Coordinate = + profileType match { + case ResProfileType.PV1 => hanoverCoordinate // hanover (first place) + case ResProfileType.PV2 => luebeckCoordinate // luebeck (first place) + case ResProfileType.PV3 => hanoverCoordinate // hanover (first place) + case ResProfileType.PV4 => hanoverCoordinate // hanover (second place) + case ResProfileType.PV5 => luebeckCoordinate // luebeck (second place) + case ResProfileType.PV6 => luebeckCoordinate // luebeck (third place) + case ResProfileType.PV7 => hanoverCoordinate // hanover (second place) + case ResProfileType.PV8 => hanoverCoordinate // hanover (second place) + case other => + throw ConversionException( + s"There is no coordinate for the profile type $other." + ) + } + + /** Determines the [[CompassDirection]] of a + * [[edu.ie3.simbench.model.datamodel.RES]] based on its [[ResProfileType]] + * @param profileType + * that can be used to determine the orientation + * @return + * the orientation + */ + def getCompassDirection(profileType: ResProfileType): CompassDirection.Value = + profileType match { + case ResProfileType.PV1 | ResProfileType.PV2 => CompassDirection.East + case ResProfileType.PV3 | ResProfileType.PV4 | ResProfileType.PV5 | + ResProfileType.PV6 => + CompassDirection.South + case ResProfileType.PV7 | ResProfileType.PV8 => CompassDirection.West + case other => + throw ConversionException( + s"There is no compass direction for the profile type $other." + ) + } + + /** Orientation that can be north, east, south and west. + */ + final case object CompassDirection extends Enumeration { + val North: Value = Value("north") + val East: Value = Value("east") + val South: Value = Value("south") + val West: Value = Value("west") + } +} diff --git a/src/main/scala/edu/ie3/simbench/io/ParticipantToInput.scala b/src/main/scala/edu/ie3/simbench/io/ParticipantToInput.scala new file mode 100644 index 00000000..3988e9be --- /dev/null +++ b/src/main/scala/edu/ie3/simbench/io/ParticipantToInput.scala @@ -0,0 +1,15 @@ +package edu.ie3.simbench.io + +import edu.ie3.simbench.config.SimbenchConfig + +final case class ParticipantToInput( + pvInput: Boolean +) + +object ParticipantToInput { + + def apply(cfg: SimbenchConfig.Conversion): ParticipantToInput = + ParticipantToInput( + cfg.convertPv + ) +} diff --git a/src/main/scala/edu/ie3/simbench/main/RunSimbench.scala b/src/main/scala/edu/ie3/simbench/main/RunSimbench.scala index 6cfa4af6..d9eff689 100644 --- a/src/main/scala/edu/ie3/simbench/main/RunSimbench.scala +++ b/src/main/scala/edu/ie3/simbench/main/RunSimbench.scala @@ -10,7 +10,13 @@ import edu.ie3.datamodel.io.sink.CsvFileSink import edu.ie3.simbench.config.{ConfigValidator, SimbenchConfig} import edu.ie3.simbench.convert.GridConverter import edu.ie3.simbench.exception.CodeValidationException -import edu.ie3.simbench.io.{Downloader, IoUtils, SimbenchReader, Zipper} +import edu.ie3.simbench.io.{ + Downloader, + IoUtils, + ParticipantToInput, + SimbenchReader, + Zipper +} import edu.ie3.simbench.model.SimbenchCode import edu.ie3.util.io.FileIOUtils import org.apache.commons.io.FilenameUtils @@ -36,6 +42,8 @@ object RunSimbench extends SimbenchHelper { /* Validate the config */ ConfigValidator.checkValidity(simbenchConfig) + val participantToInput = ParticipantToInput(simbenchConfig.conversion) + simbenchConfig.io.simbenchCodes.foreach { simbenchCode => logger.info(s"$simbenchCode - Downloading data set from SimBench website") val downloader = @@ -80,7 +88,8 @@ object RunSimbench extends SimbenchHelper { GridConverter.convert( simbenchCode, simbenchModel, - simbenchConfig.conversion.removeSwitches + simbenchConfig.conversion.removeSwitches, + participantToInput ) logger.info(s"$simbenchCode - Writing converted data set to files") @@ -110,7 +119,7 @@ object RunSimbench extends SimbenchHelper { } csvSink.persistJointGrid(jointGridContainer) - timeSeries.foreach(csvSink.persistTimeSeries(_)) + // timeSeries.foreach(csvSink.persistTimeSeries(_)) csvSink.persistAllIgnoreNested(timeSeriesMapping.asJava) csvSink.persistAll(powerFlowResults.asJava) diff --git a/src/test/scala/edu/ie3/simbench/convert/GridConverterSpec.scala b/src/test/scala/edu/ie3/simbench/convert/GridConverterSpec.scala index 5bf1cc3f..5957ce2b 100644 --- a/src/test/scala/edu/ie3/simbench/convert/GridConverterSpec.scala +++ b/src/test/scala/edu/ie3/simbench/convert/GridConverterSpec.scala @@ -5,9 +5,13 @@ import java.util import edu.ie3.datamodel.models.UniqueEntity import edu.ie3.datamodel.models.input.NodeInput import edu.ie3.datamodel.models.input.connector.{LineInput, Transformer2WInput} -import edu.ie3.datamodel.models.input.system.{FixedFeedInInput, LoadInput} +import edu.ie3.datamodel.models.input.system.{ + FixedFeedInInput, + LoadInput, + PvInput +} import edu.ie3.simbench.convert.NodeConverter.AttributeOverride.JoinOverride -import edu.ie3.simbench.io.SimbenchReader +import edu.ie3.simbench.io.{ParticipantToInput, SimbenchReader} import edu.ie3.simbench.model.datamodel.{GridModel, Node, Switch} import edu.ie3.test.common.{SwitchTestingData, UnitSpec} import org.scalatest.Inside._ @@ -145,7 +149,8 @@ class GridConverterSpec extends UnitSpec with SwitchTestingData { val actual = GridConverter.convert( "1-LV-rural1--0-no_sw", input, - removeSwitches = false + removeSwitches = false, + ParticipantToInput(pvInput = false) ) inside(actual) { case ( @@ -194,6 +199,61 @@ class GridConverterSpec extends UnitSpec with SwitchTestingData { powerFlowResults.size shouldBe 15 } } + + "bring the correct amount of converted models with participant conversion enabled" in { + val actual = GridConverter.convert( + "1-LV-rural1--0-no_sw", + input, + removeSwitches = false, + ParticipantToInput(pvInput = true) + ) + inside(actual) { + case ( + gridContainer, + timeSeries, + timeSeriesMapping, + powerFlowResults + ) => + /* Evaluate the correctness of the container by counting the occurrence of models (the correct conversion is + * tested in separate unit tests */ + gridContainer.getGridName shouldBe "1-LV-rural1--0-no_sw" + countClassOccurrences( + gridContainer.getRawGrid.allEntitiesAsList() + ) shouldBe Map( + classOf[NodeInput] -> 15, + classOf[LineInput] -> 13, + classOf[Transformer2WInput] -> 1 + ) + countClassOccurrences( + gridContainer.getSystemParticipants.allEntitiesAsList() + ) shouldBe Map( + classOf[PvInput] -> 4, + classOf[LoadInput] -> 13 + ) + countClassOccurrences( + gridContainer.getGraphics.allEntitiesAsList() + ) shouldBe Map + .empty[Class[_ <: UniqueEntity], Int] + + /* Evaluate the correctness of the time series by counting the occurrence of models */ + timeSeries.size shouldBe 13 + + /* Evaluate the existence of time series mappings for all participants */ + timeSeriesMapping.size shouldBe 13 + val participantUuids = gridContainer.getSystemParticipants + .allEntitiesAsList() + .asScala + .map(_.getUuid) + .toVector + /* There is no participant uuid in mapping, that is not among participants */ + timeSeriesMapping.exists(entry => + !participantUuids.contains(entry.participant()) + ) shouldBe false + + /* Evaluate the amount of converted power flow results */ + powerFlowResults.size shouldBe 15 + } + } } } diff --git a/src/test/scala/edu/ie3/simbench/convert/ResConverterSpec.scala b/src/test/scala/edu/ie3/simbench/convert/ResConverterSpec.scala index a6b69349..97e5c5ae 100644 --- a/src/test/scala/edu/ie3/simbench/convert/ResConverterSpec.scala +++ b/src/test/scala/edu/ie3/simbench/convert/ResConverterSpec.scala @@ -1,11 +1,14 @@ package edu.ie3.simbench.convert -import edu.ie3.datamodel.models.StandardUnits +import edu.ie3.datamodel.models.{OperationTime, StandardUnits} +import edu.ie3.datamodel.models.input.OperatorInput +import edu.ie3.datamodel.models.input.system.characteristic.CosPhiFixed -import java.util.Objects +import java.util.{Locale, Objects} import edu.ie3.simbench.model.datamodel.profiles.{ResProfile, ResProfileType} import edu.ie3.test.common.{ConverterTestData, TestTimeUtils, UnitSpec} import edu.ie3.test.matchers.QuantityMatchers +import edu.ie3.util.quantities.QuantityUtils.RichQuantityDouble import tech.units.indriya.quantity.Quantities import scala.jdk.OptionConverters.RichOptional @@ -86,5 +89,62 @@ class ResConverterSpec } } } + + "convert pv plants correctly" in { + + val pvPlant = pvRes + val pvNode = getNodePair("LV4.101 Bus 19")._2 + + val pvProfile: ResProfile = ResProfile( + "PV profile", + ResProfileType.PV3, + Map( + TestTimeUtils.simbench.toZonedDateTime( + "01.01.1990 00:00" + ) -> BigDecimal( + "0.75" + ), + TestTimeUtils.simbench.toZonedDateTime( + "01.01.1990 00:15" + ) -> BigDecimal( + "0.55" + ), + TestTimeUtils.simbench.toZonedDateTime( + "01.01.1990 00:30" + ) -> BigDecimal( + "0.35" + ), + TestTimeUtils.simbench.toZonedDateTime( + "01.01.1990 00:45" + ) -> BigDecimal( + "0.15" + ) + ) + ) + + val result = ResConverter.convertPv( + pvPlant, + pvNode, + pvProfile + ) + + result.getId shouldBe "LV4.101 SGen 1_pv" + result.getOperator shouldBe OperatorInput.NO_OPERATOR_ASSIGNED + result.getOperationTime shouldBe OperationTime.notLimited() + result.getNode shouldBe pvNode + result.getqCharacteristics() shouldBe new CosPhiFixed( + "cosPhiFixed:{(0.0,1.0)}" + ) + result.getControllingEm.toScala shouldBe None + result.getAlbedo shouldBe 0.20000000298023224 + result.getAzimuth shouldBe 0.asDegreeGeom + result.getEtaConv shouldBe 97.asPercent + result.getElevationAngle shouldBe 0.652171369646312.asDegreeGeom + result.getkG() shouldBe 0.8999999761581421 + result.getkT() shouldBe 1.0 + result.isMarketReaction shouldBe false + result.getsRated() shouldBe 6.48.asKiloVoltAmpere + result.getCosPhiRated shouldBe 1.0 + } } } diff --git a/src/test/scala/edu/ie3/simbench/convert/profiles/PvProfileConverterSpec.scala b/src/test/scala/edu/ie3/simbench/convert/profiles/PvProfileConverterSpec.scala new file mode 100644 index 00000000..c49af8f0 --- /dev/null +++ b/src/test/scala/edu/ie3/simbench/convert/profiles/PvProfileConverterSpec.scala @@ -0,0 +1,83 @@ +package edu.ie3.simbench.convert.profiles + +import edu.ie3.datamodel.models.timeseries.individual.{ + IndividualTimeSeries, + TimeBasedValue +} +import edu.ie3.datamodel.models.value.PValue +import edu.ie3.simbench.model.datamodel.profiles.ResProfileType +import edu.ie3.test.common.UnitSpec +import edu.ie3.util.quantities.QuantityUtils._ +import org.scalatest.prop.TableDrivenPropertyChecks + +import java.time.ZonedDateTime +import scala.jdk.CollectionConverters._ +import scala.jdk.OptionConverters.RichOptional + +class PvProfileConverterSpec extends UnitSpec with TableDrivenPropertyChecks { + + "The pv profile converter" should { + + "calculate the power before the converter correctly" in { + val time = ZonedDateTime.now() + + val timeSeries = new IndividualTimeSeries( + Set( + new TimeBasedValue(time, new PValue((-1).asKiloWatt)), + new TimeBasedValue(time.plusHours(1), new PValue((-3).asKiloWatt)), + new TimeBasedValue(time.plusHours(2), new PValue((-2).asKiloWatt)) + ).asJava + ) + + val efficiency = 97.asPercent + val kG: Double = 0.8999999761581421 + val kT: Double = 1.0 + + val result = PvProfileConverter.calculatePowerBeforeConverter( + timeSeries, + efficiency, + kG, + kT + ) + + val entries = result.getEntries.asScala.toList.map(value => + value.getValue.getP.toScala + .getOrElse(fail("This test should not fail!")) + ) + + entries(0).getValue.doubleValue() shouldBe -1.1454754026242313 + entries(1).getValue.doubleValue() shouldBe -3.436426207872694 + entries(2).getValue.doubleValue() shouldBe -2.2909508052484626 + } + + "return the correct azimuth for a given pv profile type" in { + val cases = Table( + ("profileType", "expectedAzimuth"), + (ResProfileType.PV1, (-90).asDegreeGeom), + (ResProfileType.PV2, (-90).asDegreeGeom), + (ResProfileType.PV3, 0.asDegreeGeom), + (ResProfileType.PV4, 0.asDegreeGeom), + (ResProfileType.PV5, 0.asDegreeGeom), + (ResProfileType.PV6, 0.asDegreeGeom), + (ResProfileType.PV7, 90.asDegreeGeom), + (ResProfileType.PV8, 90.asDegreeGeom) + ) + + forAll(cases) { (profileType, expectedAzimuth) => + PvProfileConverter.getAzimuth(profileType) shouldBe expectedAzimuth + } + } + + "calculate the elevation angle correctly" in { + val result = PvProfileConverter.calculateElevationAngle( + None, + 5.asKiloVoltAmpere, + ResProfileType.PV3, + 0.asDegreeGeom + ) + + result shouldBe 0.652171369646312.asDegreeGeom + } + + } +} diff --git a/src/test/scala/edu/ie3/simbench/convert/profiles/ResProfileConverterSpec.scala b/src/test/scala/edu/ie3/simbench/convert/profiles/ResProfileConverterSpec.scala new file mode 100644 index 00000000..1658f32e --- /dev/null +++ b/src/test/scala/edu/ie3/simbench/convert/profiles/ResProfileConverterSpec.scala @@ -0,0 +1,120 @@ +package edu.ie3.simbench.convert.profiles + +import edu.ie3.datamodel.models.timeseries.individual.{ + IndividualTimeSeries, + TimeBasedValue +} +import edu.ie3.datamodel.models.value.PValue +import edu.ie3.simbench.convert.profiles.ResProfileConverter.{ + CompassDirection, + findMaxFeedIn, + hanoverCoordinate, + luebeckCoordinate +} +import edu.ie3.simbench.model.datamodel.profiles.ResProfileType +import edu.ie3.test.common.UnitSpec +import edu.ie3.util.quantities.QuantityUtils._ +import org.scalatest.prop.TableDrivenPropertyChecks + +import java.time.ZonedDateTime +import scala.jdk.CollectionConverters._ +import scala.util.{Failure, Success, Try} + +class ResProfileConverterSpec extends UnitSpec with TableDrivenPropertyChecks { + + "The res profile converter" should { + "find the maximum feed in of a time series" in { + val time = ZonedDateTime.now() + + val value0 = new TimeBasedValue(time, new PValue(1.asKiloWatt)) + val value1 = + new TimeBasedValue(time.plusHours(1), new PValue((-1).asKiloWatt)) + val value2 = new TimeBasedValue(time.plusHours(2), new PValue(null)) + val value3 = + new TimeBasedValue(time.plusHours(3), new PValue((-3).asKiloWatt)) + val value4 = + new TimeBasedValue(time.plusHours(4), new PValue((-2).asKiloWatt)) + + val cases = Table( + ("timeSeries", "expectedValue"), + (new IndividualTimeSeries(Set(value0).asJava), None), + (new IndividualTimeSeries(Set(value0, value2).asJava), None), + ( + new IndividualTimeSeries(Set(value0, value1, value2).asJava), + Some(value1) + ), + ( + new IndividualTimeSeries(Set(value1, value2, value4).asJava), + Some(value4) + ), + ( + new IndividualTimeSeries(Set(value1, value2, value3, value4).asJava), + Some(value3) + ) + ) + + forAll(cases) { (timeSeries, expectedValue) => + val maxFeedIn = findMaxFeedIn(timeSeries) + maxFeedIn shouldBe expectedValue + } + + } + + "return the correct coordinate" in { + val cases = Table( + ("profileType", "expectedCoordinate"), + (ResProfileType.PV1, hanoverCoordinate), + (ResProfileType.PV2, luebeckCoordinate), + (ResProfileType.PV3, hanoverCoordinate), + (ResProfileType.PV4, hanoverCoordinate), + (ResProfileType.PV5, luebeckCoordinate), + (ResProfileType.PV6, luebeckCoordinate), + (ResProfileType.PV7, hanoverCoordinate), + (ResProfileType.PV8, hanoverCoordinate) + ) + + forAll(cases) { (profileType, expectedCoordinate) => + ResProfileConverter.getCoordinate( + profileType + ) shouldBe expectedCoordinate + } + } + + "throw an exception if unknown profile type is given for coordinate" in { + Try(ResProfileConverter.getCoordinate(ResProfileType.WP1)) match { + case Success(_) => fail("This test should not pass!") + case Failure(exception) => + exception.getMessage shouldBe s"There is no coordinate for the profile type WP1." + } + } + + "return the correct compass direction" in { + val cases = Table( + ("profileType", "expectedDirection"), + (ResProfileType.PV1, CompassDirection.East), + (ResProfileType.PV2, CompassDirection.East), + (ResProfileType.PV3, CompassDirection.South), + (ResProfileType.PV4, CompassDirection.South), + (ResProfileType.PV5, CompassDirection.South), + (ResProfileType.PV6, CompassDirection.South), + (ResProfileType.PV7, CompassDirection.West), + (ResProfileType.PV8, CompassDirection.West) + ) + + forAll(cases) { (profileType, expectedDirection) => + ResProfileConverter.getCompassDirection( + profileType + ) shouldBe expectedDirection + } + } + + "throw an exception if unknown profile type is given for compass direction" in { + Try(ResProfileConverter.getCompassDirection(ResProfileType.WP1)) match { + case Success(_) => fail("This test should not pass!") + case Failure(exception) => + exception.getMessage shouldBe s"There is no compass direction for the profile type WP1." + } + } + + } +} diff --git a/src/test/scala/edu/ie3/test/common/ConfigTestData.scala b/src/test/scala/edu/ie3/test/common/ConfigTestData.scala index ae9f0162..56fae1e1 100644 --- a/src/test/scala/edu/ie3/test/common/ConfigTestData.scala +++ b/src/test/scala/edu/ie3/test/common/ConfigTestData.scala @@ -32,7 +32,10 @@ trait ConfigTestData { List("1-LV-urban6--0-sw", "blabla", "1-EHVHV-mixed-2-0-sw") ) - val validConversionConfig: Conversion = Conversion(removeSwitches = false) + val validConversionConfig: Conversion = Conversion( + removeSwitches = false, + convertPv = false + ) val validConfig = new SimbenchConfig(validConversionConfig, validIo) } diff --git a/src/test/scala/edu/ie3/test/common/ConverterTestData.scala b/src/test/scala/edu/ie3/test/common/ConverterTestData.scala index c425bf58..7b0989d4 100644 --- a/src/test/scala/edu/ie3/test/common/ConverterTestData.scala +++ b/src/test/scala/edu/ie3/test/common/ConverterTestData.scala @@ -113,14 +113,26 @@ trait ConverterTestData { ), geometryFactory.createPoint(new JTSCoordinate(11.4097, 53.6413)) ), - "coordinate_2" -> (Coordinate( - "coordinate_2", - BigDecimal("11.411"), - BigDecimal("53.6407"), - "LV1.101", - 7 + "coordinate_2" -> ( + Coordinate( + "coordinate_2", + BigDecimal("11.411"), + BigDecimal("53.6407"), + "LV1.101", + 7 + ), + geometryFactory.createPoint(new JTSCoordinate(11.411, 53.6407)) ), - geometryFactory.createPoint(new JTSCoordinate(11.411, 53.6407))) + "coord_32" -> ( + Coordinate( + "coord_32", + BigDecimal("11.3942"), + BigDecimal("53.6381"), + "LV4.101", + 7 + ), + geometryFactory.createPoint(new JTSCoordinate(11.3942, 53.6381)) + ) ) def getCoordinatePair(key: String): (Coordinate, Point) = @@ -340,6 +352,32 @@ trait ConverterTestData { LV, 2 ) + ), + "LV4.101 Bus 19" -> ConversionPair( + Node( + "LV4.101 Bus 19", + NodeType.BusBar, + None, + None, + BigDecimal("0.4"), + BigDecimal("0.9"), + BigDecimal("1.1"), + None, + Some(getCoordinatePair("coord_32")._1), + "LV4.101", + 7 + ), + new NodeInput( + UUID.randomUUID(), + "LV4.101 Bus 19", + OperatorInput.NO_OPERATOR_ASSIGNED, + OperationTime.notLimited(), + Quantities.getQuantity(1d, PU), + false, + getCoordinatePair("coord_32")._2, + LV, + 2 + ) ) ) @@ -911,6 +949,19 @@ trait ConverterTestData { ) .getPair + val pvRes = RES( + "LV4.101 SGen 1", + getNodePair("LV4.101 Bus 19")._1, + ResType.PV, + ResProfileType.PV5, + CalculationType.PQ, + BigDecimal("0.00648"), + BigDecimal("0"), + BigDecimal("0.00648"), + "LV4.101", + 7 + ) + val res = Map( "MV1.101 SGen 2" -> ConversionPair( RES(