diff --git a/CHANGELOG.md b/CHANGELOG.md index 0749102f6d..12ff60001b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `LoadProfileService` to `ParticipantRefs` in `LoadProfileService` [#1418](https://github.com/ie3-institute/simona/issues/1418) - Enhance `Developer’s Guide` with information on steps for releasing a new version [#1450](https://github.com/ie3-institute/simona/issues/1450) - Enhancing config with more default value [#1413](https://github.com/ie3-institute/simona/issues/1413) +- Added an interpolation for missing weather data [#188](https://github.com/ie3-institute/simona/issues/188) ### Changed - Upgraded `scala2` to `scala3` [#53](https://github.com/ie3-institute/simona/issues/53) 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 fadbaa4d6b..bb54ddba41 100644 --- a/src/main/scala/edu/ie3/simona/service/weather/WeatherSource.scala +++ b/src/main/scala/edu/ie3/simona/service/weather/WeatherSource.scala @@ -6,6 +6,7 @@ package edu.ie3.simona.service.weather +import com.typesafe.scalalogging.LazyLogging import edu.ie3.datamodel.exceptions.SourceException import edu.ie3.datamodel.io.connectors.SqlConnector import edu.ie3.datamodel.io.factory.timeseries.{ @@ -17,13 +18,14 @@ import edu.ie3.datamodel.io.naming.FileNamingStrategy import edu.ie3.datamodel.io.source.IdCoordinateSource import edu.ie3.datamodel.io.source.csv.{CsvDataSource, CsvIdCoordinateSource} import edu.ie3.datamodel.io.source.sql.SqlIdCoordinateSource +import edu.ie3.datamodel.models.timeseries.individual.IndividualTimeSeries import edu.ie3.datamodel.models.value.WeatherValue -import edu.ie3.simona.config.InputConfig import edu.ie3.simona.config.ConfigParams.{ BaseCsvParams, BaseSqlParams, SampleParams, } +import edu.ie3.simona.config.InputConfig import edu.ie3.simona.exceptions.ServiceException import edu.ie3.simona.service.Data.SecondaryData.WeatherData import edu.ie3.simona.service.weather.WeatherSource.{ @@ -31,13 +33,16 @@ import edu.ie3.simona.service.weather.WeatherSource.{ WeightedCoordinates, } import edu.ie3.simona.service.weather.WeatherSourceWrapper.buildPSDMSource +import edu.ie3.simona.service.weather.WeatherValueInterpolation.interpolate import edu.ie3.simona.util.ParsableEnumeration import edu.ie3.util.geo.{CoordinateDistance, GeoUtils} import edu.ie3.util.quantities.PowerSystemUnits -import edu.ie3.util.scala.quantities.WattsPerSquareMeter +import edu.ie3.util.scala.quantities.QuantityConversionUtils.* +import edu.ie3.util.scala.quantities.{Irradiance, WattsPerSquareMeter} import org.locationtech.jts.geom.{Coordinate, Point} import squants.motion.MetersPerSecond import squants.thermal.Kelvin +import squants.{Temperature, Velocity} import tech.units.indriya.ComparableQuantity import tech.units.indriya.quantity.Quantities import tech.units.indriya.unit.Units @@ -246,7 +251,7 @@ trait WeatherSource { ): Array[Long] } -object WeatherSource { +object WeatherSource extends LazyLogging { def apply( weatherDataSourceCfg: InputConfig.WeatherDatasource @@ -348,7 +353,7 @@ object WeatherSource { /** Represents an empty weather data object * * For temperature to represent an "empty" quantity, we need to explicitly - * set temperature to absolute zero, so 0°K. When temperature measures the + * set temperature to absolute zero, so 0 K. When temperature measures the * movement of atoms, absolute zero means no movement, which represents the * "empty" concept best. */ @@ -359,52 +364,66 @@ object WeatherSource { MetersPerSecond(0d), ) - def toWeatherData( - weatherValue: WeatherValue + /** Methode to get weather data from a time series. This method automatically + * interpolates missing values. + * + * @param timeSeries + * with weather values + * @param dateTime + * timestamp in question + * @return + * weather data object + */ + def getWeatherData( + timeSeries: IndividualTimeSeries[WeatherValue], + dateTime: ZonedDateTime, ): WeatherData = { + // gets a value option + val valueOption = timeSeries.getValue(dateTime).toScala + + // check which data is missing + val (diffIrr, dirIrr, temp, windVel) = getOptions(valueOption) + WeatherData( - weatherValue.getSolarIrradiance.getDiffuseIrradiance.toScala match { - case Some(irradiance) => - WattsPerSquareMeter( - irradiance - .to(PowerSystemUnits.WATT_PER_SQUAREMETRE) - .getValue - .doubleValue() - ) - case None => EMPTY_WEATHER_DATA.diffIrr - }, - weatherValue.getSolarIrradiance.getDirectIrradiance.toScala match { - case Some(irradiance) => - WattsPerSquareMeter( - irradiance - .to(PowerSystemUnits.WATT_PER_SQUAREMETRE) - .getValue - .doubleValue() - ) - case None => EMPTY_WEATHER_DATA.dirIrr - }, - weatherValue.getTemperature.getTemperature.toScala match { - case Some(temperature) => - Kelvin( - temperature - .to(Units.KELVIN) - .getValue - .doubleValue() - ) - case None => EMPTY_WEATHER_DATA.temp - }, - weatherValue.getWind.getVelocity.toScala match { - case Some(windVel) => - MetersPerSecond( - windVel - .to(Units.METRE_PER_SECOND) - .getValue - .doubleValue() - ) - case None => EMPTY_WEATHER_DATA.windVel - }, + diffIrr.getOrElse( + interpolate(timeSeries, dateTime, "diffIrr", EMPTY_WEATHER_DATA.diffIrr) + ), + dirIrr.getOrElse( + interpolate(timeSeries, dateTime, "dirIrr", EMPTY_WEATHER_DATA.dirIrr) + ), + temp.getOrElse( + interpolate(timeSeries, dateTime, "temp", EMPTY_WEATHER_DATA.temp) + ), + windVel.getOrElse( + interpolate(timeSeries, dateTime, "windVel", EMPTY_WEATHER_DATA.windVel) + ), ) + } + /** Method to get the data of a [[WeatherValue]]. + * + * @param valueOption + * value with data + * @return + * a tuple of options + */ + def getOptions(valueOption: Option[WeatherValue]): ( + Option[Irradiance], + Option[Irradiance], + Option[Temperature], + Option[Velocity], + ) = { + valueOption match { + case Some(value) => + val solar = value.getSolarIrradiance + ( + solar.getDiffuseIrradiance.toScala.map(v => v.toSquants), + solar.getDirectIrradiance.toScala.map(v => v.toSquants), + value.getTemperature.getTemperature.toScala.map(v => v.toSquants), + value.getWind.getVelocity.toScala.map(v => v.toSquants), + ) + case None => (None, None, None, None) + } } /** Weather package private case class to combine the provided agent 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 cac7bbbe52..1c481de4a8 100644 --- a/src/main/scala/edu/ie3/simona/service/weather/WeatherSourceWrapper.scala +++ b/src/main/scala/edu/ie3/simona/service/weather/WeatherSourceWrapper.scala @@ -37,13 +37,14 @@ import edu.ie3.simona.service.Data.SecondaryData.WeatherData import edu.ie3.simona.service.weather.WeatherSource.{ EMPTY_WEATHER_DATA, WeatherScheme, - toWeatherData, + getWeatherData, } import edu.ie3.simona.service.weather.WeatherSourceWrapper.WeightSum -import edu.ie3.simona.service.weather.{WeatherSource as SimonaWeatherSource} +import edu.ie3.simona.service.weather.WeatherSource as SimonaWeatherSource import edu.ie3.simona.util.TickUtil.{RichZonedDateTime, TickLong} import edu.ie3.util.DoubleUtils.!~= import edu.ie3.util.interval.ClosedInterval +import squants.thermal.Kelvin import tech.units.indriya.ComparableQuantity import java.nio.file.Paths @@ -55,7 +56,6 @@ import scala.jdk.CollectionConverters.{ IterableHasAsJava, MapHasAsScala, } -import scala.jdk.OptionConverters.RichOptional import scala.util.{Failure, Success, Try} /** This class provides an implementation of the SIMONA trait @@ -106,12 +106,10 @@ private[weather] final case class WeatherSourceWrapper private ( ) .asScala .toMap - val weatherDataMap = results.flatMap { case (point, timeSeries) => + val weatherDataMap = results.map { case (point, timeSeries) => // change temperature scale for the upcoming calculations - timeSeries - .getValue(dateTime) - .toScala - .map(weatherValue => point -> toWeatherData(weatherValue)) + + point -> getWeatherData(timeSeries, dateTime) } weatherDataMap.foldLeft((EMPTY_WEATHER_DATA, WeightSum.EMPTY_WEIGHT_SUM)) { @@ -156,7 +154,9 @@ private[weather] final case class WeatherSourceWrapper private ( logger.warn(s"Temperature not available at $point.") (averagedWeather.temp, 0d) case nonEmptyTemp => - (averagedWeather.temp + nonEmptyTemp * weight, weight) + // Important: squants temperature addition is bugged. + // Conversion to Kelvin necessary. + (averagedWeather.temp + nonEmptyTemp.in(Kelvin) * weight, weight) } val (windVelocity, windVelWeight) = currentWeather.windVel match { diff --git a/src/main/scala/edu/ie3/simona/service/weather/WeatherValueInterpolation.scala b/src/main/scala/edu/ie3/simona/service/weather/WeatherValueInterpolation.scala new file mode 100644 index 0000000000..a95c9b4b39 --- /dev/null +++ b/src/main/scala/edu/ie3/simona/service/weather/WeatherValueInterpolation.scala @@ -0,0 +1,217 @@ +/* + * © 2023. TU Dortmund University, + * Institute of Energy Systems, Energy Efficiency and Energy Economics, + * Research group Distribution grid planning and operation + */ + +package edu.ie3.simona.service.weather + +import com.typesafe.scalalogging.LazyLogging +import edu.ie3.datamodel.models.timeseries.individual.IndividualTimeSeries +import edu.ie3.datamodel.models.value.WeatherValue +import edu.ie3.util.scala.quantities.QuantityConversionUtils.* +import squants.Quantity + +import java.time.ZonedDateTime +import java.time.temporal.ChronoUnit +import scala.jdk.CollectionConverters.CollectionHasAsScala +import scala.jdk.OptionConverters.RichOptional + +object WeatherValueInterpolation extends LazyLogging { + + /** Method for interpolating weather values. + * + * @param timeSeries + * with weather data + * @param dateTime + * timestamp for which an interpolation is needed + * @param typeString + * string containing the searched value + * @param empty + * default value, if no other value was found + * @tparam V + * type of value + * @return + * a new quantity + */ + def interpolate[V <: Quantity[V]]( + timeSeries: IndividualTimeSeries[WeatherValue], + dateTime: ZonedDateTime, + typeString: String, + empty: V, + ): V = { + getValueOptions[V](timeSeries, dateTime, typeString) match { + case Some((preVal, preWeight, nextVal, nextWeight)) => + val interval: Long = preWeight + nextWeight + + val weightedQuantity1 = preVal * preWeight + val weightedQuantity2 = nextVal * nextWeight + + (weightedQuantity1 + weightedQuantity2) / interval + case None => + logger.warn( + s"Interpolating value with unit ${empty.unit} for timestamp $dateTime was not possible. The default value is used." + ) + empty + } + } + + /** Method for getting interval and values for an interpolation- + * + * @param timeSeries + * with weather data + * @param dateTime + * timestamp for which an interpolation is needed + * @param typeString + * string containing the searched value + * @tparam V + * type of value + * @return + * an option + */ + private def getValueOptions[V]( + timeSeries: IndividualTimeSeries[WeatherValue], + dateTime: ZonedDateTime, + typeString: String, + ): Option[(V, Long, V, Long)] = { + if timeSeries.getEntries.size() < 3 then { + logger.info( + s"Not enough entries in time series $timeSeries to interpolate weather data. At least three values are needed, found ${timeSeries.getEntries.size()}." + ) + None + } else { + val intervalStart: ZonedDateTime = dateTime.minusHours(2) + val intervalEnd: ZonedDateTime = dateTime.plusHours(2) + + val previous: Option[ValueWithWeight[V]] = getValue( + timeSeries, + dateTime, + intervalStart, + dateTime, + typeString, + ) + val next: Option[ValueWithWeight[V]] = + getValue(timeSeries, dateTime, dateTime, intervalEnd, typeString) + + (previous, next) match { + case (Some(previous), Some(next)) => + Some((previous.value, previous.weight, next.value, next.weight)) + case (_, _) => + logger.warn( + s"Interpolating value $typeString for timestamp $dateTime was not possible. The default value is used." + ) + None + } + } + } + + /** Method to get a weather value with its weight from an interval of a time + * series. + * + * @param timeSeries + * given time series + * @param timestamp + * given timestamp + * @param intervalStart + * start of the interval + * @param intervalEnd + * end of the interval + * @param typeString + * value that is searched + * @return + * an option of a quantity with a weight + */ + private def getValue[V]( + timeSeries: IndividualTimeSeries[WeatherValue], + timestamp: ZonedDateTime, + intervalStart: ZonedDateTime, + intervalEnd: ZonedDateTime, + typeString: String, + ): Option[ValueWithWeight[V]] = { + val values: List[ValueWithWeight[V]] = + timeSeries.getEntries.asScala.flatMap { weatherValue => + val time: ZonedDateTime = weatherValue.getTime + + // calculates the time difference to the given timestamp + val weight = if time.isBefore(timestamp) then { + ChronoUnit.SECONDS.between(time, timestamp) + } else { + ChronoUnit.SECONDS.between(timestamp, time) + } + + // check is the found timestamp is in the defined interval + if time.isAfter(intervalStart) && time.isBefore(intervalEnd) then { + getValue[V](weatherValue.getValue, typeString).map { value => + ValueWithWeight(value, weight) + } + } else { + // if timestamp is not inside is not inside the interval none is returned + None + } + }.toList + + if values.isEmpty then { + None + } else { + // sorting the list to return the value with the least time difference + val sortedSet: Set[ValueWithWeight[V]] = values.sortBy { x => + x.weight + }.toSet + + sortedSet.headOption + } + } + + /** Method to get a value from a [[WeatherValue]].. + * + * @param weatherValue + * given value + * @param typeString + * value that is searched + * @return + * an option for a quantity + */ + private def getValue[V]( + weatherValue: WeatherValue, + typeString: String, + ): Option[V] = { + typeString match { + case "diffIrr" => + weatherValue.getSolarIrradiance.getDiffuseIrradiance.toScala + .map(v => v.toSquants) + .asInstanceOf[Option[V]] + case "dirIrr" => + weatherValue.getSolarIrradiance.getDirectIrradiance.toScala + .map(v => v.toSquants) + .asInstanceOf[Option[V]] + case "temp" => + weatherValue.getTemperature.getTemperature.toScala + .map(v => v.toSquants) + .asInstanceOf[Option[V]] + case "windVel" => + weatherValue.getWind.getVelocity.toScala + .map(v => v.toSquants) + .asInstanceOf[Option[V]] + case _ => + logger.warn( + s"Getting value of type $typeString is not implemented yet." + ) + None + } + } + + /** Container class for a weather value with a weight. It is primarily used + * for interpolation. + * + * @param value + * weather value + * @param weight + * of the value + * @tparam V + * type of value + */ + final case class ValueWithWeight[V]( + value: V, + weight: Long, + ) +} diff --git a/src/main/scala/edu/ie3/util/scala/quantities/QuantityConversionUtils.scala b/src/main/scala/edu/ie3/util/scala/quantities/QuantityConversionUtils.scala index e802806c9b..b346550680 100644 --- a/src/main/scala/edu/ie3/util/scala/quantities/QuantityConversionUtils.scala +++ b/src/main/scala/edu/ie3/util/scala/quantities/QuantityConversionUtils.scala @@ -6,9 +6,11 @@ package edu.ie3.util.scala.quantities +import edu.ie3.util.quantities.PowerSystemUnits import edu.ie3.util.quantities.PowerSystemUnits.* import edu.ie3.util.quantities.interfaces.{ EnergyPrice, + Irradiance, SpecificConductance, SpecificHeatCapacity, SpecificResistance, @@ -16,10 +18,12 @@ import edu.ie3.util.quantities.interfaces.{ import edu.ie3.util.scala.quantities import squants.electro.{Kilovolts, Ohms, Siemens} import squants.energy.{KilowattHours, Kilowatts} +import squants.motion.MetersPerSecond import squants.space.{CubicMeters, SquareMeters} import squants.thermal.Celsius -import squants.{Amperes, Each, Radians} +import squants.{Amperes, Each, Radians, Velocity} import tech.units.indriya.ComparableQuantity +import tech.units.indriya.unit.Units import tech.units.indriya.unit.Units.* import javax.measure.quantity.* @@ -296,4 +300,38 @@ object QuantityConversionUtils { .doubleValue ) } + + /** Implicit class that contains a method to convert a given + * [[ComparableQuantity]] with unit [[WATT_PER_SQUAREMETRE]] into + * [[WattsPerSquareMeter]]. + * + * @param quantity + * To convert. + */ + implicit class IrradianceConversion( + quantity: ComparableQuantity[Irradiance] + ) { + def toSquants: quantities.Irradiance = WattsPerSquareMeter( + quantity + .to(PowerSystemUnits.WATT_PER_SQUAREMETRE) + .getValue + .doubleValue() + ) + } + + /** Implicit class that contains a method to convert a given + * [[ComparableQuantity]] with unit [[METRE_PER_SECOND]] into + * [[MetersPerSecond]]. + * + * @param quantity + * To convert. + */ + implicit class VelocityConversion(quantity: ComparableQuantity[Speed]) { + def toSquants: Velocity = MetersPerSecond( + quantity + .to(Units.METRE_PER_SECOND) + .getValue + .doubleValue() + ) + } } diff --git a/src/test/scala/edu/ie3/simona/service/weather/WeatherValueInterpolationSpec.scala b/src/test/scala/edu/ie3/simona/service/weather/WeatherValueInterpolationSpec.scala new file mode 100644 index 0000000000..78ff8aca64 --- /dev/null +++ b/src/test/scala/edu/ie3/simona/service/weather/WeatherValueInterpolationSpec.scala @@ -0,0 +1,332 @@ +/* + * © 2023. TU Dortmund University, + * Institute of Energy Systems, Energy Efficiency and Energy Economics, + * Research group Distribution grid planning and operation + */ + +package edu.ie3.simona.service.weather + +import edu.ie3.datamodel.models.StandardUnits +import edu.ie3.datamodel.models.timeseries.individual.{ + IndividualTimeSeries, + TimeBasedValue, +} +import edu.ie3.datamodel.models.value.{ + SolarIrradianceValue, + TemperatureValue, + WeatherValue, + WindValue, +} +import edu.ie3.simona.service.weather.WeatherValueInterpolation.ValueWithWeight +import edu.ie3.simona.service.weather.WeatherValueInterpolationSpec.* +import edu.ie3.simona.test.common.UnitSpec +import edu.ie3.util.geo.GeoUtils +import edu.ie3.util.scala.quantities.WattsPerSquareMeter +import org.scalatest.prop.TableDrivenPropertyChecks +import squants.Kelvin +import squants.motion.MetersPerSecond +import squants.thermal.{Celsius, Temperature} +import tech.units.indriya.quantity.Quantities +import tech.units.indriya.unit.Units + +import java.time.ZonedDateTime +import java.util.UUID +import scala.jdk.CollectionConverters.* + +class WeatherValueInterpolationSpec + extends UnitSpec + with TableDrivenPropertyChecks { + "The WeatherValueInterpolation" should { + val getValue = PrivateMethod[Option[ValueWithWeight[?]]](Symbol("getValue")) + + "find correct previous values" in { + val cases = Table( + ( + "timeBasedValues", + "expectedDiffIrr", + "expectedDirIrr", + "expectedTemp", + "expectedWindVel", + ), + ( + Set( + timeBasedValue0, + timeBasedValue1, + timeBasedValue2, + timeBasedValue3, + timeBasedValue4, + ), + Some(ValueWithWeight(WattsPerSquareMeter(50d), 1800)), + Some(ValueWithWeight(WattsPerSquareMeter(50d), 1800)), + Some(ValueWithWeight(Temperature(238.15d, Kelvin), 6000)), + Some(ValueWithWeight(MetersPerSecond(10d), 6000)), + ), + (Set(timeBasedValue0), None, None, None, None), + ) + + forAll(cases) { + ( + timeBasedValues, + expectedDiffIrr, + expectedDirIrr, + expectedTemp, + expectedWindVel, + ) => + val timeSeries: IndividualTimeSeries[WeatherValue] = + buildTimeSeries(timeBasedValues) + val intervalStart: ZonedDateTime = time.minusHours(2) + + WeatherValueInterpolation invokePrivate getValue( + timeSeries, + time, + intervalStart, + time, + "diffIrr", + ) shouldBe expectedDiffIrr + + WeatherValueInterpolation invokePrivate getValue( + timeSeries, + time, + intervalStart, + time, + "diffIrr", + ) shouldBe expectedDirIrr + + WeatherValueInterpolation invokePrivate getValue( + timeSeries, + time, + intervalStart, + time, + "temp", + ) shouldBe expectedTemp + + WeatherValueInterpolation invokePrivate getValue( + timeSeries, + time, + intervalStart, + time, + "windVel", + ) shouldBe expectedWindVel + } + } + + "find correct next values" in { + val cases = Table( + ( + "timeBasedValues", + "expectedDiffIrr", + "expectedDirIrr", + "expectedTemp", + "expectedWindVel", + ), + ( + Set( + timeBasedValue0, + timeBasedValue1, + timeBasedValue2, + timeBasedValue3, + timeBasedValue4, + timeBasedValue5, + ), + Some(ValueWithWeight(WattsPerSquareMeter(60d), 1800)), + Some(ValueWithWeight(WattsPerSquareMeter(60d), 1800)), + Some(ValueWithWeight(Temperature(15d, Celsius), 1800)), + Some(ValueWithWeight(MetersPerSecond(20d), 1800)), + ), + ( + Set(timeBasedValue0, timeBasedValue4, timeBasedValue5), + Some(ValueWithWeight(WattsPerSquareMeter(40d), 6000)), + Some(ValueWithWeight(WattsPerSquareMeter(40d), 6000)), + None, + None, + ), + ) + + forAll(cases) { + ( + timeBasedValues, + expectedDiffIrr, + expectedDirIrr, + expectedTemp, + expectedWindVel, + ) => + val timeSeries: IndividualTimeSeries[WeatherValue] = + buildTimeSeries(timeBasedValues) + val intervalEnd: ZonedDateTime = time.plusHours(2) + + WeatherValueInterpolation invokePrivate getValue( + timeSeries, + time, + time, + intervalEnd, + "diffIrr", + ) shouldBe expectedDiffIrr + + WeatherValueInterpolation invokePrivate getValue( + timeSeries, + time, + time, + intervalEnd, + "diffIrr", + ) shouldBe expectedDirIrr + + WeatherValueInterpolation invokePrivate getValue( + timeSeries, + time, + time, + intervalEnd, + "temp", + ) shouldBe expectedTemp + + WeatherValueInterpolation invokePrivate getValue( + timeSeries, + time, + time, + intervalEnd, + "windVel", + ) shouldBe expectedWindVel + } + } + + "get a value correctly" in { + val getValue = PrivateMethod[Option[?]](Symbol("getValue")) + + val weatherValue = new WeatherValue( + coordinate67775, + new SolarIrradianceValue( + Quantities.getQuantity(50d, StandardUnits.SOLAR_IRRADIANCE), + Quantities.getQuantity(50d, StandardUnits.SOLAR_IRRADIANCE), + ), + new TemperatureValue(Quantities.getQuantity(238.15d, Units.KELVIN)), + new WindValue( + missingValue, + Quantities.getQuantity(10d, Units.METRE_PER_SECOND), + ), + ) + + val cases = Table( + ("typeString", "expectedValue"), + ("diffIrr", Some(WattsPerSquareMeter(50d))), + ("dirIrr", Some(WattsPerSquareMeter(50d))), + ("temp", Some(Temperature(238.15d, Kelvin))), + ("windVel", Some(MetersPerSecond(10d))), + ("other", None), + ) + + forAll(cases) { (typeString, expectedValue) => + WeatherValueInterpolation invokePrivate getValue( + weatherValue, + typeString, + ) shouldBe expectedValue + } + } + } +} + +case object WeatherValueInterpolationSpec { + private val coordinate67775 = GeoUtils.buildPoint(51.5, 7.438) + private val time: ZonedDateTime = ZonedDateTime.now() + private val missingValue: Null = null + + private val timeBasedValue0: TimeBasedValue[WeatherValue] = + buildTimeBasedValue( + time.minusMinutes(130), + new WeatherValue( + coordinate67775, + new SolarIrradianceValue(missingValue, missingValue), + new TemperatureValue( + missingValue + ), + new WindValue(missingValue, missingValue), + ), + ) + + private val timeBasedValue1: TimeBasedValue[WeatherValue] = + buildTimeBasedValue( + time.minusMinutes(100), + new WeatherValue( + coordinate67775, + new SolarIrradianceValue( + missingValue, + Quantities.getQuantity(40d, StandardUnits.SOLAR_IRRADIANCE), + ), + new TemperatureValue(Quantities.getQuantity(238.15d, Units.KELVIN)), + new WindValue( + missingValue, + Quantities.getQuantity(10, Units.METRE_PER_SECOND), + ), + ), + ) + + private val timeBasedValue2: TimeBasedValue[WeatherValue] = + buildTimeBasedValue( + time.minusMinutes(30), + new WeatherValue( + coordinate67775, + new SolarIrradianceValue( + Quantities.getQuantity(50d, StandardUnits.SOLAR_IRRADIANCE), + Quantities.getQuantity(50d, StandardUnits.SOLAR_IRRADIANCE), + ), + new TemperatureValue(missingValue), + new WindValue(missingValue, missingValue), + ), + ) + + private val timeBasedValue3: TimeBasedValue[WeatherValue] = + buildTimeBasedValue( + time.plusMinutes(30), + new WeatherValue( + coordinate67775, + new SolarIrradianceValue( + missingValue, + Quantities.getQuantity(60d, StandardUnits.SOLAR_IRRADIANCE), + ), + new TemperatureValue(Quantities.getQuantity(288.15d, Units.KELVIN)), + new WindValue( + missingValue, + Quantities.getQuantity(20, Units.METRE_PER_SECOND), + ), + ), + ) + + private val timeBasedValue4: TimeBasedValue[WeatherValue] = + buildTimeBasedValue( + time.plusMinutes(100), + new WeatherValue( + coordinate67775, + new SolarIrradianceValue( + Quantities.getQuantity(40d, StandardUnits.SOLAR_IRRADIANCE), + Quantities.getQuantity(40d, StandardUnits.SOLAR_IRRADIANCE), + ), + new TemperatureValue(missingValue), + new WindValue(missingValue, missingValue), + ), + ) + + private val timeBasedValue5: TimeBasedValue[WeatherValue] = + buildTimeBasedValue( + time.plusMinutes(130), + new WeatherValue( + coordinate67775, + new SolarIrradianceValue( + missingValue, + Quantities.getQuantity(50d, StandardUnits.SOLAR_IRRADIANCE), + ), + new TemperatureValue(Quantities.getQuantity(288.15d, Units.KELVIN)), + new WindValue(missingValue, missingValue), + ), + ) + + def buildTimeBasedValue( + time: ZonedDateTime, + value: WeatherValue, + ): TimeBasedValue[WeatherValue] = { + new TimeBasedValue[WeatherValue](time, value) + } + + def buildTimeSeries( + set: Set[TimeBasedValue[WeatherValue]] + ): IndividualTimeSeries[WeatherValue] = { + new IndividualTimeSeries[WeatherValue](UUID.randomUUID(), set.asJava) + } +}