From b3046a10b2ea28ba2ca345d4b23916c0b88c3916 Mon Sep 17 00:00:00 2001 From: Wouter Born Date: Wed, 26 Aug 2026 12:01:11 +0200 Subject: [PATCH 1/2] Enable Spotless formatting for Groovy files --- spotless.gradle | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spotless.gradle b/spotless.gradle index 8737fc1..d20c3f6 100644 --- a/spotless.gradle +++ b/spotless.gradle @@ -55,9 +55,10 @@ spotless { endWithNewline() } - // Groovy files are not yet formatted because some use '::' operations which greclipse currently cannot handle groovy { target targetWithIgnores(['**/*.groovy']) + toggleOffOn() + greclipse().configFile(rootProject.file('tools/spotless/greclipse-google.properties')) licenseHeaderFile(rootProject.file('tools/spotless/openremote-license-header.txt'), blockCommentLicenseDelimiter) trimTrailingWhitespace() endWithNewline() From 778ba88268f7995093ae595f8f9e75f16fdee95a Mon Sep 17 00:00:00 2001 From: Wouter Born Date: Wed, 26 Aug 2026 12:02:37 +0200 Subject: [PATCH 2/2] Format Groovy files --- .../main/groovy/TestSummaryListener.groovy | 66 +- .../ems/rules/EmsDemoSimulationRules.groovy | 928 +++++---- .../gopacs/GOPACSHandlerEanTest.groovy | 28 +- .../gopacs/GOPACSHandlerHttpTest.groovy | 576 +++--- .../manager/gopacs/GOPACSHandlerTest.groovy | 474 ++--- .../energy/EnergyOptimisationAssetTest.groovy | 686 +++---- .../energy/EnergyOptimisationTest.groovy | 686 ++++--- .../energy/ForecastSolarServiceTest.groovy | 372 ++-- .../energy/ForecastWindServiceTest.groovy | 1460 +++++++------- .../extension/energy/KeycloakTestSetup.groovy | 18 +- .../extension/energy/ManagerTestSetup.groovy | 180 +- .../extension/energy/TestSetupTasks.groovy | 8 +- .../agent/protocol/EntsoeProtocolTest.groovy | 1774 +++++++++-------- 13 files changed, 3723 insertions(+), 3533 deletions(-) diff --git a/buildSrc/src/main/groovy/TestSummaryListener.groovy b/buildSrc/src/main/groovy/TestSummaryListener.groovy index 656c7cc..2abc11a 100644 --- a/buildSrc/src/main/groovy/TestSummaryListener.groovy +++ b/buildSrc/src/main/groovy/TestSummaryListener.groovy @@ -22,47 +22,47 @@ import org.gradle.api.tasks.testing.TestResult class TestSummaryListener implements TestListener, Serializable { - private final List failedTests = Collections.synchronizedList(new ArrayList<>()) + private final List failedTests = Collections.synchronizedList(new ArrayList<>()) - @Override - void beforeSuite(TestDescriptor suite) {} + @Override + void beforeSuite(TestDescriptor suite) {} - @Override - void afterSuite(TestDescriptor desc, TestResult result) { - if (desc.parent != null) { - return - } + @Override + void afterSuite(TestDescriptor desc, TestResult result) { + if (desc.parent != null) { + return + } - def output = "Results: ${result.resultType} (${result.testCount} tests, ${result.successfulTestCount} passed, ${result.failedTestCount} failed, ${result.skippedTestCount} skipped)" - def startItem = '| ' - def endItem = ' |' - def repeatLength = startItem.length() + output.length() + endItem.length() + def output = "Results: ${result.resultType} (${result.testCount} tests, ${result.successfulTestCount} passed, ${result.failedTestCount} failed, ${result.skippedTestCount} skipped)" + def startItem = '| ' + def endItem = ' |' + def repeatLength = startItem.length() + output.length() + endItem.length() - println '\n' + - ('-' * repeatLength) + '\n' + - startItem + output + endItem + '\n' + - ('-' * repeatLength) + println '\n' + + ('-' * repeatLength) + '\n' + + startItem + output + endItem + '\n' + + ('-' * repeatLength) - def failures = new ArrayList<>(failedTests).sort() + def failures = new ArrayList<>(failedTests).sort() - if (!failures.empty) { - println '\nFailed tests:' - failures.each { failure -> - println " - ${failure}" - } - } + if (!failures.empty) { + println '\nFailed tests:' + failures.each { failure -> + println " - ${failure}" + } } + } - @Override - void beforeTest(TestDescriptor desc) {} - - @Override - void afterTest(TestDescriptor desc, TestResult result) { - if (result.resultType == TestResult.ResultType.FAILURE) { - def className = desc.className ?: desc.parent?.name ?: '' - failedTests.add("${className} > ${desc.name}") - } + @Override + void beforeTest(TestDescriptor desc) {} - println "${desc.className} > ${desc.name} took: ${(result.endTime - result.startTime)}ms" + @Override + void afterTest(TestDescriptor desc, TestResult result) { + if (result.resultType == TestResult.ResultType.FAILURE) { + def className = desc.className ?: desc.parent?.name ?: '' + failedTests.add("${className} > ${desc.name}") } + + println "${desc.className} > ${desc.name} took: ${(result.endTime - result.startTime)}ms" + } } diff --git a/ems/src/main/resources/ems/rules/EmsDemoSimulationRules.groovy b/ems/src/main/resources/ems/rules/EmsDemoSimulationRules.groovy index 4ef8e67..38314c8 100644 --- a/ems/src/main/resources/ems/rules/EmsDemoSimulationRules.groovy +++ b/ems/src/main/resources/ems/rules/EmsDemoSimulationRules.groovy @@ -55,12 +55,102 @@ boolean variableTariffs = false // Set tariff prices: List tariffPricesImport = [ - 95.60, 80.00, 64.71, 66.04, 83.00, 79.65, 72.93, 64.42, 72.15, 63.50, 58.80, 50.00, 51.84, 51.00, 53.51, 57.58, - 50.00, 56.52, 60.22, 70.00, 53.85, 68.01, 89.25, 115.09, 71.41, 110.00, 117.00, 127.28, 127.28, 129.01, 133.14, 133.14, - 147.20, 147.37, 137.98, 127.45, 139.49, 129.01, 123.23, 117.00, 114.81, 108.08, 100.00, 93.98, 20.00, 20.00, 11.00, 11.00, - 5.00, 0.00, 0.00, 0.00, 81.99, 81.99, 87.70, 88.50, 0.00, 0.00, 0.00, 0.00, 85.00, 85.00, 85.00, 85.00, - 89.25, 105.37, 117.00, 118.03, 114.30, 118.03, 124.67, 127.00, 121.07, 125.27, 127.28, 133.14, 132.27, 136.92, 158.07, 158.07, - 158.07, 133.16, 127.00, 124.96, 129.89, 126.20, 118.28, 115.00, 118.03, 116.65, 115.20, 109.62, 114.01, 112.65, 104.37, 102.97 + 95.60, + 80.00, + 64.71, + 66.04, + 83.00, + 79.65, + 72.93, + 64.42, + 72.15, + 63.50, + 58.80, + 50.00, + 51.84, + 51.00, + 53.51, + 57.58, + 50.00, + 56.52, + 60.22, + 70.00, + 53.85, + 68.01, + 89.25, + 115.09, + 71.41, + 110.00, + 117.00, + 127.28, + 127.28, + 129.01, + 133.14, + 133.14, + 147.20, + 147.37, + 137.98, + 127.45, + 139.49, + 129.01, + 123.23, + 117.00, + 114.81, + 108.08, + 100.00, + 93.98, + 20.00, + 20.00, + 11.00, + 11.00, + 5.00, + 0.00, + 0.00, + 0.00, + 81.99, + 81.99, + 87.70, + 88.50, + 0.00, + 0.00, + 0.00, + 0.00, + 85.00, + 85.00, + 85.00, + 85.00, + 89.25, + 105.37, + 117.00, + 118.03, + 114.30, + 118.03, + 124.67, + 127.00, + 121.07, + 125.27, + 127.28, + 133.14, + 132.27, + 136.92, + 158.07, + 158.07, + 158.07, + 133.16, + 127.00, + 124.96, + 129.89, + 126.20, + 118.28, + 115.00, + 118.03, + 116.65, + 115.20, + 109.62, + 114.01, + 112.65, + 104.37, + 102.97 ] //List tariffPricesImport = [100.0, 100.0, 100.0, 100.0, // 0 @@ -90,14 +180,20 @@ List tariffPricesImport = [ // Set the link forecast ["inputAssetId", "inputAttributeName", "outputAssetId", "outputAttributeName", "+/-"] here: def attributes = [ - ["linkInputAssetId1", "linkInputAttributeName1", "linkOutputAssetId1", "linkOutputAttributeName1", "+"] + [ + "linkInputAssetId1", + "linkInputAttributeName1", + "linkOutputAssetId1", + "linkOutputAttributeName1", + "+" + ] ] // Set the sum forecasts ["inputAssetName", "inputAttributeName" , "inputAssetId", "+/-"] here: def inputAttributes = [ - ["sumInputAssetName1", "sumInputAttributeName1", "sumInputAssetId1", "+"], - ["sumInputAssetName2", "sumInputAttributeName2", "sumInputAssetId2", "+"], - ["sumInputAssetName3", "sumInputAttributeName3", "sumInputAssetId3", "+"] + ["sumInputAssetName1", "sumInputAttributeName1", "sumInputAssetId1", "+"], + ["sumInputAssetName2", "sumInputAttributeName2", "sumInputAssetId2", "+"], + ["sumInputAssetName3", "sumInputAttributeName3", "sumInputAssetId3", "+"] ] // Set the sum forecasts ["outputAssetName", "outputAttributeName" , "outputAssetId"] here: @@ -134,524 +230,530 @@ rules.add() .priority(1) .name("Simulate power data-points rule") .when({ facts -> - long currentTimeMillis = facts.clock.currentTimeMillis + long currentTimeMillis = facts.clock.currentTimeMillis - // Trigger rule every 1 minutes - if (currentTimeMillis > previousTimeMillisRule1) { - previousTimeMillisRule1 += (1 * 60 * 1000) - return true - } + // Trigger rule every 1 minutes + if (currentTimeMillis> previousTimeMillisRule1) { + previousTimeMillisRule1 += (1 * 60 * 1000) + return true + } - return false + return false }) .then({ facts -> - // Get power set-point for each battery - def powerSetpointBatteries = facts - .matchAssetState(new AssetQuery().parents(energyOptimisationAssetId).attributeNames("powerSetpoint")) - .toList() - - String[] attributeNames = ["energyCapacity", "energyLevel", "energyLevelPercentage"] + // Get power set-point for each battery + def powerSetpointBatteries = facts + .matchAssetState(new AssetQuery().parents(energyOptimisationAssetId).attributeNames("powerSetpoint")) + .toList() - def attributeInfoList = facts - .matchAssetState(new AssetQuery().parents(energyOptimisationAssetId).attributeNames(attributeNames)) - .toList() + String[] attributeNames = ["energyCapacity", "energyLevel", "energyLevelPercentage"] - // Create a map using id as key and attribute structure as value - def assetsAttributesMap = [:].withDefault { [:].withDefault { null } } + def attributeInfoList = facts + .matchAssetState(new AssetQuery().parents(energyOptimisationAssetId).attributeNames(attributeNames)) + .toList() - attributeInfoList.each { attributeInfo -> - String assetId = attributeInfo.id - String attributeName = attributeInfo.name - def value = attributeInfo.value.orElse(null) + // Create a map using id as key and attribute structure as value + def assetsAttributesMap = [:].withDefault { [:].withDefault { null } } - assetsAttributesMap[assetId][attributeName] = value - } + attributeInfoList.each { attributeInfo -> + String assetId = attributeInfo.id + String attributeName = attributeInfo.name + def value = attributeInfo.value.orElse(null) - // Battery energy level calculation - long timestampMillisCurrent = facts.clock.currentTimeMillis + assetsAttributesMap[assetId][attributeName] = value + } - powerSetpointBatteries.forEach { - String assetId = it.id - def powerSetpoint = it.value.orElse(0.0) + // Battery energy level calculation + long timestampMillisCurrent = facts.clock.currentTimeMillis - def assetAttributesMap = assetsAttributesMap.get(assetId) - def energyCapacity = assetAttributesMap.getOrDefault("energyCapacity", null) as Double - def energyLevel = assetAttributesMap.getOrDefault("energyLevel", null) as Double - def energyLevelPercentage = assetAttributesMap.getOrDefault("energyLevelPercentage", null) as Double + powerSetpointBatteries.forEach { + String assetId = it.id + def powerSetpoint = it.value.orElse(0.0) - if (energyCapacity != null && energyLevelPercentage != null) { - double energyLevelNew + def assetAttributesMap = assetsAttributesMap.get(assetId) + def energyCapacity = assetAttributesMap.getOrDefault("energyCapacity", null) as Double + def energyLevel = assetAttributesMap.getOrDefault("energyLevel", null) as Double + def energyLevelPercentage = assetAttributesMap.getOrDefault("energyLevelPercentage", null) as Double - if (energyLevel == null) { - energyLevelNew = (Math.round(energyLevelPercentage * energyCapacity * 10)) / 1000 - } else { - def timeIntervalMillis = timestampMillisCurrent - timestampMillisPrevious - double energyChange = powerSetpoint * (timeIntervalMillis / 3600000.0) - energyLevelNew = (Math.round((energyLevel + energyChange) * 1000.0)) / 1000 - } + if (energyCapacity != null && energyLevelPercentage != null) { + double energyLevelNew - long energyLevelPercentageNew = Math.round(energyLevelNew / energyCapacity * 100) + if (energyLevel == null) { + energyLevelNew = (Math.round(energyLevelPercentage * energyCapacity * 10)) / 1000 + } else { + def timeIntervalMillis = timestampMillisCurrent - timestampMillisPrevious + double energyChange = powerSetpoint * (timeIntervalMillis / 3600000.0) + energyLevelNew = (Math.round((energyLevel + energyChange) * 1000.0)) / 1000 + } - assets.dispatch(assetId, "energyLevel", energyLevelNew) - assets.dispatch(assetId, "energyLevelPercentage", energyLevelPercentageNew) - } else if (energyCapacity != null && energyLevel != null && energyLevelPercentage == null) { - long energyLevelPercentageNew = Math.round(energyLevel / energyCapacity * 100) + long energyLevelPercentageNew = Math.round(energyLevelNew / energyCapacity * 100) - assets.dispatch(assetId, "energyLevelPercentage", energyLevelPercentageNew) - } - } + assets.dispatch(assetId, "energyLevel", energyLevelNew) + assets.dispatch(assetId, "energyLevelPercentage", energyLevelPercentageNew) + } else if (energyCapacity != null && energyLevel != null && energyLevelPercentage == null) { + long energyLevelPercentageNew = Math.round(energyLevel / energyCapacity * 100) - timestampMillisPrevious = timestampMillisCurrent - - // Power consumption profiles - def powerConsumption - - switch (powerConsumptionProfileNumber) { - case "2": - // Power Consumption profile: "Flip plus and minus" - def now = LocalTime.now() - if (now.isBefore(LocalTime.NOON)) { - powerConsumption = powerConsumptionConstant - } else { - powerConsumption = -powerConsumptionConstant - } - break - case "3": - // Power Consumption profile: "Sinus wave" - def amplitude = powerConsumptionConstant - def periodHours = 24 - def phaseShift = 0 - def verticalShift = 0 - - def currentSecondOfDay = LocalTime.now().toSecondOfDay() - def x = currentSecondOfDay / (60 * 60) // Hours - def functionSin = amplitude * sin(2 * PI / periodHours * (x + phaseShift) + verticalShift) - - powerConsumption = (functionSin * 1000).round() / 1000 - break - default: - powerConsumption = powerConsumptionConstant + assets.dispatch(assetId, "energyLevelPercentage", energyLevelPercentageNew) } - - // Add random fluctuations to power consumption - double powerNoise = 0.0 - - if (powerFluctuation > 0) { - double powerFluctuationHalf = powerFluctuation / 2 - powerNoise = Math.round(ThreadLocalRandom.current().nextDouble(-powerFluctuationHalf, powerFluctuationHalf) * 1000.0) / 1000.0 + } + + timestampMillisPrevious = timestampMillisCurrent + + // Power consumption profiles + def powerConsumption + + switch (powerConsumptionProfileNumber) { + case "2": + // Power Consumption profile: "Flip plus and minus" + def now = LocalTime.now() + if (now.isBefore(LocalTime.NOON)) { + powerConsumption = powerConsumptionConstant + } else { + powerConsumption = -powerConsumptionConstant + } + break + case "3": + // Power Consumption profile: "Sinus wave" + def amplitude = powerConsumptionConstant + def periodHours = 24 + def phaseShift = 0 + def verticalShift = 0 + + def currentSecondOfDay = LocalTime.now().toSecondOfDay() + def x = currentSecondOfDay / (60 * 60) // Hours + def functionSin = amplitude * sin(2 * PI / periodHours * (x + phaseShift) + verticalShift) + + powerConsumption = (functionSin * 1000).round() / 1000 + break + default: + powerConsumption = powerConsumptionConstant + } + + // Add random fluctuations to power consumption + double powerNoise = 0.0 + + if (powerFluctuation> 0) { + double powerFluctuationHalf = powerFluctuation / 2 + powerNoise = Math.round(ThreadLocalRandom.current().nextDouble(-powerFluctuationHalf, powerFluctuationHalf) * 1000.0) / 1000.0 + } + + powerConsumption = powerConsumption + powerNoise + + def powerFlexible = (powerSetpointBatteries.collect { + it.value.orElse(0.0) + }.sum() ?: 0.0) as double + def powerNet = powerConsumption + powerFlexible + + // Update power attributes + powerSetpointBatteries.forEach { + if (it.value.isPresent()) { + assets.dispatch(it.id, "power", it.value) } + } - powerConsumption = powerConsumption + powerNoise - - def powerFlexible = (powerSetpointBatteries.collect { it.value.orElse(0.0) }.sum() ?: 0.0) as double - def powerNet = powerConsumption + powerFlexible - - // Update power attributes - powerSetpointBatteries.forEach { - if (it.value.isPresent()) { - assets.dispatch(it.id, "power", it.value) - } - } - - assets.dispatch(energyOptimisationAssetId, "powerConsumption", Math.round((double) (powerConsumption * 1000.0)) / 1000.0) - assets.dispatch(energyOptimisationAssetId, "powerFlexible", Math.round((double) (powerFlexible * 1000.0)) / 1000.0) - assets.dispatch(energyOptimisationAssetId, "powerNet", Math.round((double) (powerNet * 1000.0)) / 1000.0) + assets.dispatch(energyOptimisationAssetId, "powerConsumption", Math.round((double) (powerConsumption * 1000.0)) / 1000.0) + assets.dispatch(energyOptimisationAssetId, "powerFlexible", Math.round((double) (powerFlexible * 1000.0)) / 1000.0) + assets.dispatch(energyOptimisationAssetId, "powerNet", Math.round((double) (powerNet * 1000.0)) / 1000.0) }) rules.add() .priority(2) .name("Simulate forecasts rule") .when({ facts -> - long currentMillis = facts.clock.currentTimeMillis - String dateCurrent = dateOnlyFormat.format(new Date(currentMillis)) + long currentMillis = facts.clock.currentTimeMillis + String dateCurrent = dateOnlyFormat.format(new Date(currentMillis)) - // Trigger rule at 0:00am - if (dateCurrent != datePreviousRule2) { - datePreviousRule2 = dateCurrent - triggerRule2 = true - } + // Trigger rule at 0:00am + if (dateCurrent != datePreviousRule2) { + datePreviousRule2 = dateCurrent + triggerRule2 = true + } - return triggerRule2 + return triggerRule2 }) .then({ facts -> - triggerRule2 = false - - long currentTimeMillis = facts.clock.currentTimeMillis - long intervalMillis = (long) (3600000 * 24 / tariffPricesImport.size()) - ZoneId zoneId = ZoneId.systemDefault() - LocalDate dateCurrent = Instant.ofEpochMilli(currentTimeMillis).atZone(zoneId).toLocalDate() - - TreeMap datePriceExportMapForecast = new TreeMap<>() - TreeMap datePriceExportMapHistoric = new TreeMap<>() - TreeMap datePriceImportMapForecast = new TreeMap<>() - TreeMap datePriceImportMapHistoric = new TreeMap<>() - - TreeMap datePowerConsumptionMapForecast = new TreeMap<>() - - // Create forecast data-points - for (int i = -1; i <= forecastPeriodDays; i++) { - for (int j = 0; j < tariffPricesImport.size(); j++) { - def startOfDay = dateCurrent.atStartOfDay(zoneId).plusDays(i) as ZonedDateTime - long timeMillis = startOfDay.toInstant().toEpochMilli() + j * intervalMillis //- 10 * intervalMillis - def key = sdf.format(new Date(timeMillis)) as String - - double factor = 1.0 - - if (variableTariffs) { - factor = 0.5 + Math.random() - } - - def valueImport = (factor * tariffPricesImport[j] / 1000).round(2) - def valueExport = -valueImport - - if (timeMillis <= currentTimeMillis) { - datePriceExportMapHistoric.put(key, valueExport) - datePriceImportMapHistoric.put(key, valueImport) - } else { - datePriceExportMapForecast.put(key, valueExport) - datePriceImportMapForecast.put(key, valueImport) - datePowerConsumptionMapForecast.put(key, powerConsumptionConstant) - } - } + triggerRule2 = false + + long currentTimeMillis = facts.clock.currentTimeMillis + long intervalMillis = (long) (3600000 * 24 / tariffPricesImport.size()) + ZoneId zoneId = ZoneId.systemDefault() + LocalDate dateCurrent = Instant.ofEpochMilli(currentTimeMillis).atZone(zoneId).toLocalDate() + + TreeMap datePriceExportMapForecast = new TreeMap<>() + TreeMap datePriceExportMapHistoric = new TreeMap<>() + TreeMap datePriceImportMapForecast = new TreeMap<>() + TreeMap datePriceImportMapHistoric = new TreeMap<>() + + TreeMap datePowerConsumptionMapForecast = new TreeMap<>() + + // Create forecast data-points + for (int i = -1; i <= forecastPeriodDays; i++) { + for (int j = 0; j - long currentTimeMillis = facts.clock.currentTimeMillis + long currentTimeMillis = facts.clock.currentTimeMillis - // Trigger rule every 1 minutes - if (currentTimeMillis > previousTimeMillisRule3) { - previousTimeMillisRule3 += (1 * 60 * 1000) - return true - } + // Trigger rule every 1 minutes + if (currentTimeMillis> previousTimeMillisRule3) { + previousTimeMillisRule3 += (1 * 60 * 1000) + return true + } - return false + return false }) .then({ facts -> - long currentTimeMillis = facts.clock.currentTimeMillis - - // Forecast period - long startTimeMillis = currentTimeMillis - currentTimeMillis % (forecastIntervalMinutes * 60 * 1000) - long endTimeMillis = startTimeMillis - startTimeMillis % (24 * 60 * 60000) + ((forecastPeriodDays + 1) * 24 * 60 * 60000) - - // Convert time in milliseconds to a string timestamp - String startDateTimeStr = sdf.format(new Date(startTimeMillis)) - String endDateTimeStr = sdf.format(new Date(endTimeMillis)) - String databaseTableName = "asset_predicted_datapoint" - - // Link forecast for each input attribute - for (attribute in attributes) { - String inputAssetId = attribute[0] - String inputAttributeName = attribute[1] - String outputAssetId = attribute[2] - String outputAttributeName = attribute[3] - String plusOrMinus = attribute[4] - - // Get data-points - TreeMap forecast = getDatabaseDatapoints(databaseTableName, inputAssetId, inputAttributeName, startDateTimeStr, endDateTimeStr) - - // Check if forecast is present - if (forecast.isEmpty()) { - continue - } - - if (plusOrMinus == "-") { - // Flip sign of forecast values - forecast = new TreeMap(forecast.collectEntries { key, value -> [key, -value] }) - } - - // Update data-points in database - upsertDatabaseDatapoints(databaseTableName, outputAssetId, outputAttributeName, forecast) + long currentTimeMillis = facts.clock.currentTimeMillis + + // Forecast period + long startTimeMillis = currentTimeMillis - currentTimeMillis % (forecastIntervalMinutes * 60 * 1000) + long endTimeMillis = startTimeMillis - startTimeMillis % (24 * 60 * 60000) + ((forecastPeriodDays + 1) * 24 * 60 * 60000) + + // Convert time in milliseconds to a string timestamp + String startDateTimeStr = sdf.format(new Date(startTimeMillis)) + String endDateTimeStr = sdf.format(new Date(endTimeMillis)) + String databaseTableName = "asset_predicted_datapoint" + + // Link forecast for each input attribute + for (attribute in attributes) { + String inputAssetId = attribute[0] + String inputAttributeName = attribute[1] + String outputAssetId = attribute[2] + String outputAttributeName = attribute[3] + String plusOrMinus = attribute[4] + + // Get data-points + TreeMap forecast = getDatabaseDatapoints(databaseTableName, inputAssetId, inputAttributeName, startDateTimeStr, endDateTimeStr) + + // Check if forecast is present + if (forecast.isEmpty()) { + continue + } + + if (plusOrMinus == "-") { + // Flip sign of forecast values + forecast = new TreeMap(forecast.collectEntries { key, value -> + [key, -value] + }) } + + // Update data-points in database + upsertDatabaseDatapoints(databaseTableName, outputAssetId, outputAttributeName, forecast) + } }) rules.add() .priority(4) .name("Sum forecasts rule") .when({ facts -> - long currentTimeMillis = facts.clock.currentTimeMillis + long currentTimeMillis = facts.clock.currentTimeMillis - // Trigger rule every 1 minutes - if (currentTimeMillis > previousTimeMillisRule4) { - previousTimeMillisRule4 += (1 * 60 * 1000) - return true - } + // Trigger rule every 1 minutes + if (currentTimeMillis> previousTimeMillisRule4) { + previousTimeMillisRule4 += (1 * 60 * 1000) + return true + } - return false + return false }) .then({ facts -> - long currentTimeMillis = facts.clock.currentTimeMillis + long currentTimeMillis = facts.clock.currentTimeMillis - // Interpolation interval in milliseconds - long intervalMillis = forecastIntervalMinutes * 60 * 1000 + // Interpolation interval in milliseconds + long intervalMillis = forecastIntervalMinutes * 60 * 1000 - // Forecast period - long startTimeMillis = currentTimeMillis - intervalMillis - long endTimeMillis = startTimeMillis - startTimeMillis % (24 * 60 * 60000) + ((forecastPeriodDays + 1) * 24 * 60 * 60000) + // Forecast period + long startTimeMillis = currentTimeMillis - intervalMillis + long endTimeMillis = startTimeMillis - startTimeMillis % (24 * 60 * 60000) + ((forecastPeriodDays + 1) * 24 * 60 * 60000) - boolean updateOnlyFutureDatapoints = false + boolean updateOnlyFutureDatapoints = false - sumForecasts(inputAttributes, outputAttribute, currentTimeMillis, intervalMillis, startTimeMillis, endTimeMillis, 3, updateOnlyFutureDatapoints) + sumForecasts(inputAttributes, outputAttribute, currentTimeMillis, intervalMillis, startTimeMillis, endTimeMillis, 3, updateOnlyFutureDatapoints) }) private void sumForecasts(List> inputAttributes, List outputAttribute, long currentTimeMillis, long intervalMillis, long startTimeMillis, long endTimeMillis, int decimals, boolean updateOnlyFutureDatapoints) { - // Map with all the input forecasts for summation - Map> interpolatedForecastsMap = new HashMap() + // Map with all the input forecasts for summation + Map> interpolatedForecastsMap = new HashMap() - // Database date-time format - SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss") + // Database date-time format + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss") - List startMillisList = new ArrayList() - List endMillisList = new ArrayList() + List startMillisList = new ArrayList() + List endMillisList = new ArrayList() - // Get the input forecasts and interpolate - for (attribute in inputAttributes) { - String attributeName = attribute[1] - String assetId = attribute[2] - String plusOrMinus = attribute[3] + // Get the input forecasts and interpolate + for (attribute in inputAttributes) { + String attributeName = attribute[1] + String assetId = attribute[2] + String plusOrMinus = attribute[3] - // Convert time in milliseconds to a string timestamp - String startDateTimeStr = sdf.format(new Date(startTimeMillis)) - String endDateTimeStr = sdf.format(new Date(endTimeMillis)) + // Convert time in milliseconds to a string timestamp + String startDateTimeStr = sdf.format(new Date(startTimeMillis)) + String endDateTimeStr = sdf.format(new Date(endTimeMillis)) - // Get forecast data-points from database - TreeMap forecast = getDatabaseDatapoints("asset_predicted_datapoint", assetId, attributeName, startDateTimeStr, endDateTimeStr) + // Get forecast data-points from database + TreeMap forecast = getDatabaseDatapoints("asset_predicted_datapoint", assetId, attributeName, startDateTimeStr, endDateTimeStr) - if (forecast.isEmpty()) { - continue - } - - if (plusOrMinus == "-") { - // Flip sign of forecast values - forecast = new TreeMap(forecast.collectEntries { key, value -> [key, -value] }) - } + if (forecast.isEmpty()) { + continue + } - // Find the forecast period - String firstTimestamp = forecast.firstEntry().key - String lastTimestamp = forecast.lastEntry().key + if (plusOrMinus == "-") { + // Flip sign of forecast values + forecast = new TreeMap(forecast.collectEntries { key, value -> + [key, -value] + }) + } - long firstTimeMillis = sdf.parse(firstTimestamp).getTime() - long lastTimeMillis = sdf.parse(lastTimestamp).getTime() + // Find the forecast period + String firstTimestamp = forecast.firstEntry().key + String lastTimestamp = forecast.lastEntry().key - long firstTimeMillisRounded = firstTimeMillis - firstTimeMillis % intervalMillis - long lastTimeMillisRounded = lastTimeMillis - lastTimeMillis % intervalMillis + intervalMillis + long firstTimeMillis = sdf.parse(firstTimestamp).getTime() + long lastTimeMillis = sdf.parse(lastTimestamp).getTime() - startMillisList.add(firstTimeMillisRounded) - endMillisList.add(lastTimeMillisRounded) + long firstTimeMillisRounded = firstTimeMillis - firstTimeMillis % intervalMillis + long lastTimeMillisRounded = lastTimeMillis - lastTimeMillis % intervalMillis + intervalMillis - // List with desired date-times for forecast interpolation - List dateTimeList = new ArrayList<>() + startMillisList.add(firstTimeMillisRounded) + endMillisList.add(lastTimeMillisRounded) - for (long i = firstTimeMillisRounded; i <= lastTimeMillisRounded; i += intervalMillis) { - String dateTimeStr = sdf.format(new Date(i)) - dateTimeList.add(dateTimeStr) - } + // List with desired date-times for forecast interpolation + List dateTimeList = new ArrayList<>() - TreeMap interpolatedForecast = interpolateForecast(dateTimeList, sdf, forecast) - - // Add interpolated forecast for each unique attributeRef - String attributeRef = assetId + attributeName - interpolatedForecastsMap.put(attributeRef, interpolatedForecast) + for (long i = firstTimeMillisRounded; i <= lastTimeMillisRounded; i += intervalMillis) { + String dateTimeStr = sdf.format(new Date(i)) + dateTimeList.add(dateTimeStr) } - // Check if interpolated forecasts are present - if (interpolatedForecastsMap.isEmpty()) { - return - } + TreeMap interpolatedForecast = interpolateForecast(dateTimeList, sdf, forecast) - // List with data-times for output forecast - List dateTimeListGeneral = new ArrayList<>() + // Add interpolated forecast for each unique attributeRef + String attributeRef = assetId + attributeName + interpolatedForecastsMap.put(attributeRef, interpolatedForecast) + } - long startMillis = startMillisList.min() as long - long endMillis = endMillisList.min() as long + // Check if interpolated forecasts are present + if (interpolatedForecastsMap.isEmpty()) { + return + } - for (long i = startMillis; i <= endMillis; i += intervalMillis) { - String dateTimeStr = sdf.format(new Date(i)) - dateTimeListGeneral.add(dateTimeStr) - } + // List with data-times for output forecast + List dateTimeListGeneral = new ArrayList<>() - // Calculate output forecast - TreeMap outputForecast = new TreeMap<>() - def factor = Math.pow(10, decimals) + long startMillis = startMillisList.min() as long + long endMillis = endMillisList.min() as long - for (String dateTime : dateTimeListGeneral) { - long timeMillis = sdf.parse(dateTime).getTime() + for (long i = startMillis; i <= endMillis; i += intervalMillis) { + String dateTimeStr = sdf.format(new Date(i)) + dateTimeListGeneral.add(dateTimeStr) + } - if (!updateOnlyFutureDatapoints || (updateOnlyFutureDatapoints && timeMillis > currentTimeMillis)) { - List forecastValueList = new ArrayList() + // Calculate output forecast + TreeMap outputForecast = new TreeMap<>() + def factor = Math.pow(10, decimals) - // Find all values per date-time - interpolatedForecastsMap.each { - def interpolatedForecast = it.value as TreeMap - def forecastValue = interpolatedForecast.get(dateTime) + for (String dateTime : dateTimeListGeneral) { + long timeMillis = sdf.parse(dateTime).getTime() - if (forecastValue != null) { - forecastValueList.add(forecastValue) - } - } + if (!updateOnlyFutureDatapoints || (updateOnlyFutureDatapoints && timeMillis> currentTimeMillis)) { + List forecastValueList = new ArrayList() - // Sum the values per date-time - if (!forecastValueList.isEmpty()) { - def forecastValueSum = forecastValueList.sum() as double - def forecastValueSumRounded = Math.round(forecastValueSum * factor) / factor - outputForecast.put(dateTime, forecastValueSumRounded) - } + // Find all values per date-time + interpolatedForecastsMap.each { + def interpolatedForecast = it.value as TreeMap + def forecastValue = interpolatedForecast.get(dateTime) + + if (forecastValue != null) { + forecastValueList.add(forecastValue) } + } + + // Sum the values per date-time + if (!forecastValueList.isEmpty()) { + def forecastValueSum = forecastValueList.sum() as double + def forecastValueSumRounded = Math.round(forecastValueSum * factor) / factor + outputForecast.put(dateTime, forecastValueSumRounded) + } } + } - // Upsert forecast data-points into database - if (!outputForecast.isEmpty()) { - upsertDatabaseDatapoints("asset_predicted_datapoint", outputAttribute[2], outputAttribute[1], outputForecast) - } + // Upsert forecast data-points into database + if (!outputForecast.isEmpty()) { + upsertDatabaseDatapoints("asset_predicted_datapoint", outputAttribute[2], outputAttribute[1], outputForecast) + } } private TreeMap interpolateForecast(List dateTimeList, SimpleDateFormat sdf, TreeMap forecast) { - Logger LOG = binding.LOG - - TreeMap interpolatedValues = new TreeMap<>() - - for (String dateTimeStr : dateTimeList) { - // Find the two closest date-times in forecast - String lowerDateTimeStr = forecast.floorKey(dateTimeStr) - String upperDateTimeStr = forecast.ceilingKey(dateTimeStr) - - if (lowerDateTimeStr != null && upperDateTimeStr != null) { - if (lowerDateTimeStr == upperDateTimeStr) { - // Exact value found in forecast - Double exactValue = forecast.get(dateTimeStr) - - interpolatedValues.put(dateTimeStr, exactValue) - } else { - try { - Date dateTime = sdf.parse(dateTimeStr) - Date lowerDateTime = sdf.parse(lowerDateTimeStr) - Date upperDateTime = sdf.parse(upperDateTimeStr) - - // Calculate interpolation factor - long diff1 = dateTime.getTime() - lowerDateTime.getTime() - long diff2 = upperDateTime.getTime() - lowerDateTime.getTime() - double factor = (double) diff1 / diff2 - - // Interpolate value - Double lowerValue = forecast.get(lowerDateTimeStr) - Double upperValue = forecast.get(upperDateTimeStr) - Double interpolatedValue = lowerValue + factor * (upperValue - lowerValue) - - interpolatedValues.put(dateTimeStr, interpolatedValue) - } catch (Exception e) { - LOG.warning("Failed interpolation; Exception: " + e) - } - } - } else { - interpolatedValues.put(dateTimeStr, null) + Logger LOG = binding.LOG + + TreeMap interpolatedValues = new TreeMap<>() + + for (String dateTimeStr : dateTimeList) { + // Find the two closest date-times in forecast + String lowerDateTimeStr = forecast.floorKey(dateTimeStr) + String upperDateTimeStr = forecast.ceilingKey(dateTimeStr) + + if (lowerDateTimeStr != null && upperDateTimeStr != null) { + if (lowerDateTimeStr == upperDateTimeStr) { + // Exact value found in forecast + Double exactValue = forecast.get(dateTimeStr) + + interpolatedValues.put(dateTimeStr, exactValue) + } else { + try { + Date dateTime = sdf.parse(dateTimeStr) + Date lowerDateTime = sdf.parse(lowerDateTimeStr) + Date upperDateTime = sdf.parse(upperDateTimeStr) + + // Calculate interpolation factor + long diff1 = dateTime.getTime() - lowerDateTime.getTime() + long diff2 = upperDateTime.getTime() - lowerDateTime.getTime() + double factor = (double) diff1 / diff2 + + // Interpolate value + Double lowerValue = forecast.get(lowerDateTimeStr) + Double upperValue = forecast.get(upperDateTimeStr) + Double interpolatedValue = lowerValue + factor * (upperValue - lowerValue) + + interpolatedValues.put(dateTimeStr, interpolatedValue) + } catch (Exception e) { + LOG.warning("Failed interpolation; Exception: " + e) } + } + } else { + interpolatedValues.put(dateTimeStr, null) } - return interpolatedValues + } + return interpolatedValues } private TreeMap getDatabaseDatapoints(String tableName, String assetId, String attributeName, String dateTimeFromStr, String dateTimeToStr) { - Logger LOG = binding.LOG + Logger LOG = binding.LOG - TreeMap datapoints = new TreeMap<>() + TreeMap datapoints = new TreeMap<>() - try (Connection connection = connectToDatabase() - Statement statement = connection.createStatement()) { + try (Connection connection = connectToDatabase() + Statement statement = connection.createStatement()) { - String query = - "SELECT timestamp, value " + - "FROM " + tableName + " " + - "WHERE entity_id = '" + assetId + "' " + - "AND attribute_name = '" + attributeName + "' " + - "AND timestamp BETWEEN '" + dateTimeFromStr + "' AND '" + dateTimeToStr + "' " + - "ORDER BY timestamp ASC;" + String query = + "SELECT timestamp, value " + + "FROM " + tableName + " " + + "WHERE entity_id = '" + assetId + "' " + + "AND attribute_name = '" + attributeName + "' " + + "AND timestamp BETWEEN '" + dateTimeFromStr + "' AND '" + dateTimeToStr + "' " + + "ORDER BY timestamp ASC;" - try (ResultSet resultSet = statement.executeQuery(query)) { - while (resultSet.next()) { - String value1 = resultSet.getString("timestamp") - Double value2 = resultSet.getDouble("value") + try (ResultSet resultSet = statement.executeQuery(query)) { + while (resultSet.next()) { + String value1 = resultSet.getString("timestamp") + Double value2 = resultSet.getDouble("value") - datapoints.put(value1, value2) - } - } - } catch (SQLException e) { - LOG.warning("Failed to obtain data-points from database; Exception: " + e) + datapoints.put(value1, value2) + } } - return datapoints + } catch (SQLException e) { + LOG.warning("Failed to obtain data-points from database; Exception: " + e) + } + return datapoints } private void upsertDatabaseDatapoints(String tableName, String assetId, String attributeName, TreeMap datapoints) { - Logger LOG = binding.LOG + Logger LOG = binding.LOG - try (Connection connection = connectToDatabase()) { - String query = - "INSERT INTO " + tableName + " (entity_id, attribute_name, value, timestamp) VALUES (?, ?, ?, ?)" + - "ON CONFLICT (entity_id, attribute_name, timestamp) DO UPDATE " + - "SET value = EXCLUDED.value" + try (Connection connection = connectToDatabase()) { + String query = + "INSERT INTO " + tableName + " (entity_id, attribute_name, value, timestamp) VALUES (?, ?, ?, ?)" + + "ON CONFLICT (entity_id, attribute_name, timestamp) DO UPDATE " + + "SET value = EXCLUDED.value" - PreparedStatement preparedStatement = connection.prepareStatement(query) + PreparedStatement preparedStatement = connection.prepareStatement(query) - for (Map.Entry entry : datapoints.entrySet()) { - String key = entry.getKey() - Double value = entry.getValue() + for (Map.Entry entry : datapoints.entrySet()) { + String key = entry.getKey() + Double value = entry.getValue() - PGobject pgJsonValue = new PGobject() - pgJsonValue.setType("jsonb") - pgJsonValue.setValue(ValueUtil.asJSON(value).orElse("null")) + PGobject pgJsonValue = new PGobject() + pgJsonValue.setType("jsonb") + pgJsonValue.setValue(ValueUtil.asJSON(value).orElse("null")) - preparedStatement.setString(1, assetId) - preparedStatement.setString(2, attributeName) - preparedStatement.setObject(3, pgJsonValue) - preparedStatement.setTimestamp(4, Timestamp.valueOf(key)) + preparedStatement.setString(1, assetId) + preparedStatement.setString(2, attributeName) + preparedStatement.setObject(3, pgJsonValue) + preparedStatement.setTimestamp(4, Timestamp.valueOf(key)) - preparedStatement.addBatch() - } + preparedStatement.addBatch() + } - int[] affectedRows = preparedStatement.executeBatch() + int[] affectedRows = preparedStatement.executeBatch() - for (int affectedRow : affectedRows) { - if (affectedRow != 1) { - LOG.warning("Failed to insert row into database") - } - } - } catch (SQLException e) { - LOG.warning("Failed to insert data-points into database; Exception: " + e) + for (int affectedRow : affectedRows) { + if (affectedRow != 1) { + LOG.warning("Failed to insert row into database") + } } + } catch (SQLException e) { + LOG.warning("Failed to insert data-points into database; Exception: " + e) + } } private static Connection connectToDatabase() throws SQLException { - String dbPort = System.getenv(PersistenceService.OR_DB_PORT) - String dbHost = System.getenv(PersistenceService.OR_DB_HOST) - String dbName = System.getenv(PersistenceService.OR_DB_NAME) - String dbUsername = System.getenv(PersistenceService.OR_DB_USER) - String dbPassword = System.getenv(PersistenceService.OR_DB_PASSWORD) - - if (dbPort == null) { - dbPort = PersistenceService.OR_DB_PORT_DEFAULT.toString() - } - if (dbHost == null) { - dbHost = PersistenceService.OR_DB_HOST_DEFAULT - } - if (dbName == null) { - dbName = PersistenceService.OR_DB_NAME_DEFAULT - } - if (dbUsername == null) { - dbUsername = PersistenceService.OR_DB_USER_DEFAULT - } - if (dbPassword == null) { - dbPassword = PersistenceService.OR_DB_PASSWORD_DEFAULT - } - - String databaseUrl = "jdbc:postgresql://" + dbHost + ":" + dbPort + "/" + dbName - - return DriverManager.getConnection(databaseUrl, dbUsername, dbPassword) + String dbPort = System.getenv(PersistenceService.OR_DB_PORT) + String dbHost = System.getenv(PersistenceService.OR_DB_HOST) + String dbName = System.getenv(PersistenceService.OR_DB_NAME) + String dbUsername = System.getenv(PersistenceService.OR_DB_USER) + String dbPassword = System.getenv(PersistenceService.OR_DB_PASSWORD) + + if (dbPort == null) { + dbPort = PersistenceService.OR_DB_PORT_DEFAULT.toString() + } + if (dbHost == null) { + dbHost = PersistenceService.OR_DB_HOST_DEFAULT + } + if (dbName == null) { + dbName = PersistenceService.OR_DB_NAME_DEFAULT + } + if (dbUsername == null) { + dbUsername = PersistenceService.OR_DB_USER_DEFAULT + } + if (dbPassword == null) { + dbPassword = PersistenceService.OR_DB_PASSWORD_DEFAULT + } + + String databaseUrl = "jdbc:postgresql://" + dbHost + ":" + dbPort + "/" + dbName + + return DriverManager.getConnection(databaseUrl, dbUsername, dbPassword) } diff --git a/ems/src/test/groovy/org/openremote/extension/ems/manager/gopacs/GOPACSHandlerEanTest.groovy b/ems/src/test/groovy/org/openremote/extension/ems/manager/gopacs/GOPACSHandlerEanTest.groovy index a21dedc..1a82c6d 100644 --- a/ems/src/test/groovy/org/openremote/extension/ems/manager/gopacs/GOPACSHandlerEanTest.groovy +++ b/ems/src/test/groovy/org/openremote/extension/ems/manager/gopacs/GOPACSHandlerEanTest.groovy @@ -27,19 +27,19 @@ import spock.lang.Specification */ class GOPACSHandlerEanTest extends Specification { - def "toCongestionPoint canonicalises an EAN to the GOPACS ean. format (#input -> #expected)"() { - expect: "the optional, case-insensitive ean. prefix is normalised to lower-case and added when missing" - GOPACSHandler.toCongestionPoint(input) == expected + def "toCongestionPoint canonicalises an EAN to the GOPACS ean. format (#input -> #expected)"() { + expect: "the optional, case-insensitive ean. prefix is normalised to lower-case and added when missing" + GOPACSHandler.toCongestionPoint(input) == expected - where: - input || expected - "ean.265987182507322951" || "ean.265987182507322951" - "265987182507322951" || "ean.265987182507322951" - "EAN.265987182507322951" || "ean.265987182507322951" - "Ean.265987182507322951" || "ean.265987182507322951" - " ean.265987182507322951 " || "ean.265987182507322951" - " 265987182507322951 " || "ean.265987182507322951" - "" || "ean." - null || null - } + where: + input || expected + "ean.265987182507322951" || "ean.265987182507322951" + "265987182507322951" || "ean.265987182507322951" + "EAN.265987182507322951" || "ean.265987182507322951" + "Ean.265987182507322951" || "ean.265987182507322951" + " ean.265987182507322951 " || "ean.265987182507322951" + " 265987182507322951 " || "ean.265987182507322951" + "" || "ean." + null || null + } } diff --git a/ems/src/test/groovy/org/openremote/extension/ems/manager/gopacs/GOPACSHandlerHttpTest.groovy b/ems/src/test/groovy/org/openremote/extension/ems/manager/gopacs/GOPACSHandlerHttpTest.groovy index 6f209fc..e24f9c8 100644 --- a/ems/src/test/groovy/org/openremote/extension/ems/manager/gopacs/GOPACSHandlerHttpTest.groovy +++ b/ems/src/test/groovy/org/openremote/extension/ems/manager/gopacs/GOPACSHandlerHttpTest.groovy @@ -77,310 +77,312 @@ import static org.openremote.model.Constants.MASTER_REALM * writes are scheduled asynchronously and are already covered precisely by {@code GOPACSHandlerTest}. This * test focuses on the HTTP contract, so a placeholder asset id is sufficient. */ -@IgnoreIf({ System.getenv("GITHUB_ACTIONS") == "true" }) +@IgnoreIf({ + System.getenv("GITHUB_ACTIONS") == "true" +}) class GOPACSHandlerHttpTest extends Specification implements ManagerContainerTrait { - static final String CONTRACTED_EAN = "ean.871234567890123456" - static final String ASSET_ID = "0abcDEFghiJKLmnoPQRstu" - static final String AGR_DOMAIN = "openremote.io" // our AGR: recipient of inbound, sender of replies - static final String DSO_DOMAIN = "nilsgrid.net" // the DSO: sender of inbound, recipient of replies - static final LocalDate PERIOD = LocalDate.of(2026, 6, 4) - static final String FLEX_REQUEST_MESSAGE_ID = "b3030b8b-2f45-43f4-8bf3-f00ef6fd74fc" - static final String CONVERSATION_ID = "5f7c9c4d-5988-4a17-a479-2f716051fd6d" - static final String ACCESS_TOKEN = "test-access-token" - - static final String OAUTH_PATH = "/oauth/token" - static final String ADDRESS_BOOK_PATH = "/uftp-participants/v3/participants/" + DSO_DOMAIN - static final String BROKER_PATH = "/shapeshifter/api/v3/message" - - @Shared - WireMockServer wireMock - @Shared - String dsoPrivateKeyB64 - @Shared - String dsoPublicKeyB64 - @Shared - File privateKeyFile - @Shared - GOPACSHandler handler - @Shared - int serverPort - - def setupSpec() { - // Two ed25519 keypairs: the DSO signs the inbound messages (its public key is served by the - // address-book stub so the handler can verify them), and the AGR signs its outbound replies. - def lazySodium = new LazySodiumJava(new SodiumJava()) - KeyPair dso = lazySodium.cryptoSignKeypair() - dsoPrivateKeyB64 = Base64.encoder.encodeToString(dso.secretKey.asBytes) - dsoPublicKeyB64 = Base64.encoder.encodeToString(dso.publicKey.asBytes) - - KeyPair agr = lazySodium.cryptoSignKeypair() - privateKeyFile = File.createTempFile("gopacs-agr-private-key", ".txt") - privateKeyFile.deleteOnExit() - privateKeyFile.text = Base64.encoder.encodeToString(agr.secretKey.asBytes) - - wireMock = new WireMockServer(0) - wireMock.start() + static final String CONTRACTED_EAN = "ean.871234567890123456" + static final String ASSET_ID = "0abcDEFghiJKLmnoPQRstu" + static final String AGR_DOMAIN = "openremote.io" // our AGR: recipient of inbound, sender of replies + static final String DSO_DOMAIN = "nilsgrid.net" // the DSO: sender of inbound, recipient of replies + static final LocalDate PERIOD = LocalDate.of(2026, 6, 4) + static final String FLEX_REQUEST_MESSAGE_ID = "b3030b8b-2f45-43f4-8bf3-f00ef6fd74fc" + static final String CONVERSATION_ID = "5f7c9c4d-5988-4a17-a479-2f716051fd6d" + static final String ACCESS_TOKEN = "test-access-token" + + static final String OAUTH_PATH = "/oauth/token" + static final String ADDRESS_BOOK_PATH = "/uftp-participants/v3/participants/" + DSO_DOMAIN + static final String BROKER_PATH = "/shapeshifter/api/v3/message" + + @Shared + WireMockServer wireMock + @Shared + String dsoPrivateKeyB64 + @Shared + String dsoPublicKeyB64 + @Shared + File privateKeyFile + @Shared + GOPACSHandler handler + @Shared + int serverPort + + def setupSpec() { + // Two ed25519 keypairs: the DSO signs the inbound messages (its public key is served by the + // address-book stub so the handler can verify them), and the AGR signs its outbound replies. + def lazySodium = new LazySodiumJava(new SodiumJava()) + KeyPair dso = lazySodium.cryptoSignKeypair() + dsoPrivateKeyB64 = Base64.encoder.encodeToString(dso.secretKey.asBytes) + dsoPublicKeyB64 = Base64.encoder.encodeToString(dso.publicKey.asBytes) + + KeyPair agr = lazySodium.cryptoSignKeypair() + privateKeyFile = File.createTempFile("gopacs-agr-private-key", ".txt") + privateKeyFile.deleteOnExit() + privateKeyFile.text = Base64.encoder.encodeToString(agr.secretKey.asBytes) + + wireMock = new WireMockServer(0) + wireMock.start() + } + + def cleanupSpec() { + handler?.undeploy() + wireMock?.stop() + } + + def setup() { + // Must be a plain String (not a Groovy GString): OpenRemote's Config.init casts every config value to String. + String base = "http://localhost:${wireMock.port()}".toString() + def config = defaultConfig() << [ + (GOPACSHandler.GOPACS_CLIENT_ID) : "test-client", + (GOPACSHandler.GOPACS_CLIENT_SECRET) : "test-secret", + (GOPACSHandler.GOPACS_PRIVATE_KEY_FILE) : privateKeyFile.absolutePath, + (GOPACSHandler.GOPACS_OAUTH2_URL) : base + OAUTH_PATH, + (GOPACSHandler.GOPACS_PARTICIPANT_URL) : base, + (GOPACSHandler.GOPACS_BROKER_URL) : base, + // Send replies immediately so assertions don't wait on the production response/offer delays. + (GOPACSHandler.GOPACS_RESPONSE_DELAY_SECONDS) : "0", + (GOPACSHandler.GOPACS_FLEX_OFFER_DELAY_SECONDS): "0" + ] + + // startContainer reuses the already-running container when config (ignoring the web-server port) + // and services match, so this is cheap after the first feature. + def runningContainer = startContainer(config, gopacsServices()) + + if (handler == null) { + serverPort = runningContainer.getConfig().get(WebService.OR_WEBSERVER_LISTEN_PORT) as int + + // Construct the production handler directly (real RESTEasy client, real send service, real + // JAX-RS deployment). EmsOptimisationService is excluded from the service list above so it + // cannot spin up a second handler on the same /gopacs deployment path. + handler = new GOPACSHandler(CONTRACTED_EAN, MASTER_REALM, ASSET_ID, runningContainer) } - def cleanupSpec() { - handler?.undeploy() - wireMock?.stop() + // Reset cross-test state: the participant cache leaks across features otherwise, and the request + // journal / stubs must start clean for the per-feature verifications. + handler.participants.clear() + wireMock.resetAll() + stubOAuthToken(ACCESS_TOKEN) + stubAddressBook(dsoPublicKeyB64) + stubBrokerAccepts() + } + + // The manager services minus the EMS optimisation services, which would otherwise deploy their own + // GOPACS handler (on the same /gopacs path) from any persisted EmsGOPACSAsset. + private Iterable gopacsServices() { + defaultServices().findAll { + !(it instanceof EmsOptimisationService) && !(it instanceof EmsOptimisationSetupService) } + } - def setup() { - // Must be a plain String (not a Groovy GString): OpenRemote's Config.init casts every config value to String. - String base = "http://localhost:${wireMock.port()}".toString() - def config = defaultConfig() << [ - (GOPACSHandler.GOPACS_CLIENT_ID) : "test-client", - (GOPACSHandler.GOPACS_CLIENT_SECRET) : "test-secret", - (GOPACSHandler.GOPACS_PRIVATE_KEY_FILE) : privateKeyFile.absolutePath, - (GOPACSHandler.GOPACS_OAUTH2_URL) : base + OAUTH_PATH, - (GOPACSHandler.GOPACS_PARTICIPANT_URL) : base, - (GOPACSHandler.GOPACS_BROKER_URL) : base, - // Send replies immediately so assertions don't wait on the production response/offer delays. - (GOPACSHandler.GOPACS_RESPONSE_DELAY_SECONDS) : "0", - (GOPACSHandler.GOPACS_FLEX_OFFER_DELAY_SECONDS): "0" - ] - - // startContainer reuses the already-running container when config (ignoring the web-server port) - // and services match, so this is cheap after the first feature. - def runningContainer = startContainer(config, gopacsServices()) - - if (handler == null) { - serverPort = runningContainer.getConfig().get(WebService.OR_WEBSERVER_LISTEN_PORT) as int - - // Construct the production handler directly (real RESTEasy client, real send service, real - // JAX-RS deployment). EmsOptimisationService is excluded from the service list above so it - // cannot spin up a second handler on the same /gopacs deployment path. - handler = new GOPACSHandler(CONTRACTED_EAN, MASTER_REALM, ASSET_ID, runningContainer) - } - - // Reset cross-test state: the participant cache leaks across features otherwise, and the request - // journal / stubs must start clean for the per-feature verifications. - handler.participants.clear() - wireMock.resetAll() - stubOAuthToken(ACCESS_TOKEN) - stubAddressBook(dsoPublicKeyB64) - stubBrokerAccepts() - } - - // The manager services minus the EMS optimisation services, which would otherwise deploy their own - // GOPACS handler (on the same /gopacs path) from any persisted EmsGOPACSAsset. - private Iterable gopacsServices() { - defaultServices().findAll { - !(it instanceof EmsOptimisationService) && !(it instanceof EmsOptimisationSetupService) - } - } - - def "a signed in-scope FlexRequest POSTed to /gopacs/message is accepted and the reply is delivered to the broker with a bearer token"() { - given: "polling for the asynchronous outbound deliveries" - def conditions = new PollingConditions(timeout: 10, delay: 0.2) - - when: "a signed FlexRequest is POSTed to the deployed endpoint" - Response response = postSignedAsDso(flexRequestXml(CONTRACTED_EAN)) - - then: "the transport call succeeds" - response.statusInfo.family == Response.Status.Family.SUCCESSFUL - response.close() - - and: "verifying the signature fetched an OAuth2 token and resolved the DSO via the address book" - conditions.eventually { - wireMock.verify(postRequestedFor(urlPathEqualTo(OAUTH_PATH)) - .withRequestBody(containing("grant_type=client_credentials"))) - wireMock.verify(getRequestedFor(urlPathEqualTo(ADDRESS_BOOK_PATH)) - .withHeader("Authorization", equalTo("Bearer " + ACCESS_TOKEN))) - } - - and: "the FlexRequestResponse and FlexOffer are actually POSTed to the broker as signed XML with a bearer token" - conditions.eventually { - wireMock.verify(postRequestedFor(urlPathEqualTo(BROKER_PATH)) - .withHeader("Authorization", equalTo("Bearer " + ACCESS_TOKEN)) - .withRequestBody(containing("SignedMessage"))) - def payloads = decodedBrokerPayloads() - assert payloads.any { it.contains("FlexRequestResponse") } - assert payloads.any { it.contains("FlexOffer") } - } - } - - def "a signed in-scope FlexOrder POSTed to /gopacs/message is accepted and a FlexOrderResponse is delivered to the broker"() { - given: - def conditions = new PollingConditions(timeout: 10, delay: 0.2) - - when: "a signed FlexOrder is POSTed to the deployed endpoint" - Response response = postSignedAsDso(flexOrderXml(CONTRACTED_EAN, [4000, 8000])) - - then: "the transport call succeeds" - response.statusInfo.family == Response.Status.Family.SUCCESSFUL - response.close() - - and: "a FlexOrderResponse is POSTed to the broker as signed XML with a bearer token" - conditions.eventually { - wireMock.verify(postRequestedFor(urlPathEqualTo(BROKER_PATH)) - .withHeader("Authorization", equalTo("Bearer " + ACCESS_TOKEN)) - .withRequestBody(containing("SignedMessage"))) - assert decodedBrokerPayloads().any { it.contains("FlexOrderResponse") } - } - } - - def "a validly-signed FlexRequest for a different congestion point is rejected via the broker without a FlexOffer"() { - given: "polling for the asynchronous outbound delivery" - def conditions = new PollingConditions(timeout: 10, delay: 0.2) - - when: "a signed out-of-scope FlexRequest (different EAN) is POSTed" - Response response = postSignedAsDso(flexRequestXml("ean.999999999999999999")) - - then: "the transport call still succeeds (the signed envelope is accepted)" - response.statusInfo.family == Response.Status.Family.SUCCESSFUL - response.close() - - and: "a rejected FlexRequestResponse with a reason is delivered to the broker as signed XML" - conditions.eventually { - wireMock.verify(postRequestedFor(urlPathEqualTo(BROKER_PATH)) - .withHeader("Authorization", equalTo("Bearer " + ACCESS_TOKEN)) - .withRequestBody(containing("SignedMessage"))) - assert decodedBrokerPayloads().any { - it.contains("FlexRequestResponse") && it.contains('Result="Rejected"') && it.contains("RejectionReason=") - } - } - - and: "no FlexOffer is ever delivered" - // Replies are scheduled with zero delay in this test; give any (erroneous) FlexOffer time to arrive. - Thread.sleep(1000) - def payloads = decodedBrokerPayloads() - payloads.size() == 1 - !payloads.any { it.contains("FlexOffer") } - } - - def "malformed transport XML is rejected with 400"() { - when: "a body that is not a SignedMessage envelope is POSTed" - Response response = postXml("") - - then: "the endpoint maps the failure to 400 Bad Request" - response.status == 400 - response.close() - - and: "nothing is delivered to the broker" - wireMock.verify(0, postRequestedFor(urlPathEqualTo(BROKER_PATH))) - } - - def "a correctly-formed message whose signature does not match the resolved public key is rejected with 400"() { - given: "the address book returns a public key that does not match the DSO signing key" - def lazySodium = new LazySodiumJava(new SodiumJava()) - def wrongPublicKeyB64 = Base64.encoder.encodeToString(lazySodium.cryptoSignKeypair().publicKey.asBytes) - stubAddressBook(wrongPublicKeyB64) + def "a signed in-scope FlexRequest POSTed to /gopacs/message is accepted and the reply is delivered to the broker with a bearer token"() { + given: "polling for the asynchronous outbound deliveries" + def conditions = new PollingConditions(timeout: 10, delay: 0.2) - when: "a validly-signed FlexRequest is POSTed" - Response response = postSignedAsDso(flexRequestXml(CONTRACTED_EAN)) + when: "a signed FlexRequest is POSTed to the deployed endpoint" + Response response = postSignedAsDso(flexRequestXml(CONTRACTED_EAN)) - then: "signature verification fails and the endpoint returns 400 (the UftpConnectorException branch)" - response.status == 400 - response.close() + then: "the transport call succeeds" + response.statusInfo.family == Response.Status.Family.SUCCESSFUL + response.close() - and: "nothing is delivered to the broker" - wireMock.verify(0, postRequestedFor(urlPathEqualTo(BROKER_PATH))) + and: "verifying the signature fetched an OAuth2 token and resolved the DSO via the address book" + conditions.eventually { + wireMock.verify(postRequestedFor(urlPathEqualTo(OAUTH_PATH)) + .withRequestBody(containing("grant_type=client_credentials"))) + wireMock.verify(getRequestedFor(urlPathEqualTo(ADDRESS_BOOK_PATH)) + .withHeader("Authorization", equalTo("Bearer " + ACCESS_TOKEN))) } - def "an OAuth2 token failure (#scenario) prevents participant resolution so the inbound message is rejected with 400"() { - given: "the token endpoint is unhealthy" - wireMock.stubFor(post(urlPathEqualTo(OAUTH_PATH)).willReturn(tokenResponse)) - - when: "a validly-signed FlexRequest is POSTed" - Response response = postSignedAsDso(flexRequestXml(CONTRACTED_EAN)) - - then: "without a bearer token the DSO cannot be resolved, verification fails and the endpoint returns 400" - response.status == 400 - response.close() - - and: "nothing is delivered to the broker" - wireMock.verify(0, postRequestedFor(urlPathEqualTo(BROKER_PATH))) - - where: - scenario | tokenResponse - "500 error" | aResponse().withStatus(500) - "invalid token JSON" | okJson("not-json") + and: "the FlexRequestResponse and FlexOffer are actually POSTed to the broker as signed XML with a bearer token" + conditions.eventually { + wireMock.verify(postRequestedFor(urlPathEqualTo(BROKER_PATH)) + .withHeader("Authorization", equalTo("Bearer " + ACCESS_TOKEN)) + .withRequestBody(containing("SignedMessage"))) + def payloads = decodedBrokerPayloads() + assert payloads.any { it.contains("FlexRequestResponse") } + assert payloads.any { it.contains("FlexOffer") } } + } - def "an address-book 404 for the sender domain is rejected with 400"() { - given: "the address book does not know the DSO" - wireMock.stubFor(get(urlPathEqualTo(ADDRESS_BOOK_PATH)).willReturn(aResponse().withStatus(404))) + def "a signed in-scope FlexOrder POSTed to /gopacs/message is accepted and a FlexOrderResponse is delivered to the broker"() { + given: + def conditions = new PollingConditions(timeout: 10, delay: 0.2) - when: "a validly-signed FlexRequest is POSTed" - Response response = postSignedAsDso(flexRequestXml(CONTRACTED_EAN)) + when: "a signed FlexOrder is POSTed to the deployed endpoint" + Response response = postSignedAsDso(flexOrderXml(CONTRACTED_EAN, [4000, 8000])) - then: "the sender cannot be resolved, verification fails and the endpoint returns 400" - response.status == 400 - response.close() + then: "the transport call succeeds" + response.statusInfo.family == Response.Status.Family.SUCCESSFUL + response.close() - and: "nothing is delivered to the broker" - wireMock.verify(0, postRequestedFor(urlPathEqualTo(BROKER_PATH))) + and: "a FlexOrderResponse is POSTed to the broker as signed XML with a bearer token" + conditions.eventually { + wireMock.verify(postRequestedFor(urlPathEqualTo(BROKER_PATH)) + .withHeader("Authorization", equalTo("Bearer " + ACCESS_TOKEN)) + .withRequestBody(containing("SignedMessage"))) + assert decodedBrokerPayloads().any { it.contains("FlexOrderResponse") } } - - def "the resolved participant is cached so a second inbound message does not trigger a second address-book lookup"() { - given: - def conditions = new PollingConditions(timeout: 10, delay: 0.2) - - when: "two signed in-scope FlexRequests are POSTed" - postSignedAsDso(flexRequestXml(CONTRACTED_EAN)).close() - conditions.eventually { - wireMock.verify(getRequestedFor(urlPathEqualTo(ADDRESS_BOOK_PATH))) - } - postSignedAsDso(flexRequestXml(CONTRACTED_EAN)).close() - // Let any (zero-delay) second lookup happen before asserting it did not. - Thread.sleep(1000) - - then: "the address book is queried exactly once -- the second message uses the cached participant" - wireMock.verify(1, getRequestedFor(urlPathEqualTo(ADDRESS_BOOK_PATH))) + } + + def "a validly-signed FlexRequest for a different congestion point is rejected via the broker without a FlexOffer"() { + given: "polling for the asynchronous outbound delivery" + def conditions = new PollingConditions(timeout: 10, delay: 0.2) + + when: "a signed out-of-scope FlexRequest (different EAN) is POSTed" + Response response = postSignedAsDso(flexRequestXml("ean.999999999999999999")) + + then: "the transport call still succeeds (the signed envelope is accepted)" + response.statusInfo.family == Response.Status.Family.SUCCESSFUL + response.close() + + and: "a rejected FlexRequestResponse with a reason is delivered to the broker as signed XML" + conditions.eventually { + wireMock.verify(postRequestedFor(urlPathEqualTo(BROKER_PATH)) + .withHeader("Authorization", equalTo("Bearer " + ACCESS_TOKEN)) + .withRequestBody(containing("SignedMessage"))) + assert decodedBrokerPayloads().any { + it.contains("FlexRequestResponse") && it.contains('Result="Rejected"') && it.contains("RejectionReason=") + } } - // ---- WireMock stubs ---- - - private void stubOAuthToken(String token) { - wireMock.stubFor(post(urlPathEqualTo(OAUTH_PATH)).willReturn( - okJson("""{"access_token":"${token}","token_type":"Bearer","expires_in":3600,"scope":"uftp"}"""))) + and: "no FlexOffer is ever delivered" + // Replies are scheduled with zero delay in this test; give any (erroneous) FlexOffer time to arrive. + Thread.sleep(1000) + def payloads = decodedBrokerPayloads() + payloads.size() == 1 + !payloads.any { it.contains("FlexOffer") } + } + + def "malformed transport XML is rejected with 400"() { + when: "a body that is not a SignedMessage envelope is POSTed" + Response response = postXml("") + + then: "the endpoint maps the failure to 400 Bad Request" + response.status == 400 + response.close() + + and: "nothing is delivered to the broker" + wireMock.verify(0, postRequestedFor(urlPathEqualTo(BROKER_PATH))) + } + + def "a correctly-formed message whose signature does not match the resolved public key is rejected with 400"() { + given: "the address book returns a public key that does not match the DSO signing key" + def lazySodium = new LazySodiumJava(new SodiumJava()) + def wrongPublicKeyB64 = Base64.encoder.encodeToString(lazySodium.cryptoSignKeypair().publicKey.asBytes) + stubAddressBook(wrongPublicKeyB64) + + when: "a validly-signed FlexRequest is POSTed" + Response response = postSignedAsDso(flexRequestXml(CONTRACTED_EAN)) + + then: "signature verification fails and the endpoint returns 400 (the UftpConnectorException branch)" + response.status == 400 + response.close() + + and: "nothing is delivered to the broker" + wireMock.verify(0, postRequestedFor(urlPathEqualTo(BROKER_PATH))) + } + + def "an OAuth2 token failure (#scenario) prevents participant resolution so the inbound message is rejected with 400"() { + given: "the token endpoint is unhealthy" + wireMock.stubFor(post(urlPathEqualTo(OAUTH_PATH)).willReturn(tokenResponse)) + + when: "a validly-signed FlexRequest is POSTed" + Response response = postSignedAsDso(flexRequestXml(CONTRACTED_EAN)) + + then: "without a bearer token the DSO cannot be resolved, verification fails and the endpoint returns 400" + response.status == 400 + response.close() + + and: "nothing is delivered to the broker" + wireMock.verify(0, postRequestedFor(urlPathEqualTo(BROKER_PATH))) + + where: + scenario | tokenResponse + "500 error" | aResponse().withStatus(500) + "invalid token JSON" | okJson("not-json") + } + + def "an address-book 404 for the sender domain is rejected with 400"() { + given: "the address book does not know the DSO" + wireMock.stubFor(get(urlPathEqualTo(ADDRESS_BOOK_PATH)).willReturn(aResponse().withStatus(404))) + + when: "a validly-signed FlexRequest is POSTed" + Response response = postSignedAsDso(flexRequestXml(CONTRACTED_EAN)) + + then: "the sender cannot be resolved, verification fails and the endpoint returns 400" + response.status == 400 + response.close() + + and: "nothing is delivered to the broker" + wireMock.verify(0, postRequestedFor(urlPathEqualTo(BROKER_PATH))) + } + + def "the resolved participant is cached so a second inbound message does not trigger a second address-book lookup"() { + given: + def conditions = new PollingConditions(timeout: 10, delay: 0.2) + + when: "two signed in-scope FlexRequests are POSTed" + postSignedAsDso(flexRequestXml(CONTRACTED_EAN)).close() + conditions.eventually { + wireMock.verify(getRequestedFor(urlPathEqualTo(ADDRESS_BOOK_PATH))) } - - private void stubAddressBook(String publicKeyB64) { - wireMock.stubFor(get(urlPathEqualTo(ADDRESS_BOOK_PATH)).willReturn( - okJson("""{"domain":"${DSO_DOMAIN}","publicKey":"${publicKeyB64}"}"""))) + postSignedAsDso(flexRequestXml(CONTRACTED_EAN)).close() + // Let any (zero-delay) second lookup happen before asserting it did not. + Thread.sleep(1000) + + then: "the address book is queried exactly once -- the second message uses the cached participant" + wireMock.verify(1, getRequestedFor(urlPathEqualTo(ADDRESS_BOOK_PATH))) + } + + // ---- WireMock stubs ---- + + private void stubOAuthToken(String token) { + wireMock.stubFor(post(urlPathEqualTo(OAUTH_PATH)).willReturn( + okJson("""{"access_token":"${token}","token_type":"Bearer","expires_in":3600,"scope":"uftp"}"""))) + } + + private void stubAddressBook(String publicKeyB64) { + wireMock.stubFor(get(urlPathEqualTo(ADDRESS_BOOK_PATH)).willReturn( + okJson("""{"domain":"${DSO_DOMAIN}","publicKey":"${publicKeyB64}"}"""))) + } + + private void stubBrokerAccepts() { + wireMock.stubFor(post(urlPathEqualTo(BROKER_PATH)).willReturn(aResponse().withStatus(200))) + } + + // ---- HTTP helpers ---- + + // Signs the payload XML as the DSO and POSTs the resulting transport XML to the deployed endpoint. + private Response postSignedAsDso(String payloadXml) { + def sender = new UftpParticipant(DSO_DOMAIN, USEFRoleType.DSO) + SignedMessage signed = handler.cryptoService.signMessage(payloadXml, sender, dsoPrivateKeyB64) + return postXml(GOPACSHandler.serializer.toXml(signed)) + } + + // The broker receives SignedMessage envelopes carrying the payload in the base64 "Body" attribute. The + // decoded bytes are the libsodium-signed payload (a 64-byte signature prefixed to the payload XML), so the + // outgoing message type is still a substring of the decoded text and can be asserted on the wire. + private List decodedBrokerPayloads() { + wireMock.findAll(postRequestedFor(urlPathEqualTo(BROKER_PATH))).collect { req -> + def matcher = (req.bodyAsString =~ /Body="([^"]+)"/) + matcher ? new String(Base64.decoder.decode(matcher[0][1] as String)) : "" } + } - private void stubBrokerAccepts() { - wireMock.stubFor(post(urlPathEqualTo(BROKER_PATH)).willReturn(aResponse().withStatus(200))) - } + private Response postXml(String transportXml) { + // The deployment registers a realm-path-extractor filter (realmIndex 0), so the realm is the first + // segment after the /gopacs context path: /gopacs/{realm}/message -> resource @Path("message"). + createClient(null) + .target(serverUri(serverPort).path("gopacs").path(MASTER_REALM).path("message")) + .request() + .post(Entity.entity(transportXml, "text/xml")) + } - // ---- HTTP helpers ---- + // ---- Embedded UFTP payload fixtures (attribute-style XML, matching the example message format) ---- - // Signs the payload XML as the DSO and POSTs the resulting transport XML to the deployed endpoint. - private Response postSignedAsDso(String payloadXml) { - def sender = new UftpParticipant(DSO_DOMAIN, USEFRoleType.DSO) - SignedMessage signed = handler.cryptoService.signMessage(payloadXml, sender, dsoPrivateKeyB64) - return postXml(GOPACSHandler.serializer.toXml(signed)) - } - - // The broker receives SignedMessage envelopes carrying the payload in the base64 "Body" attribute. The - // decoded bytes are the libsodium-signed payload (a 64-byte signature prefixed to the payload XML), so the - // outgoing message type is still a substring of the decoded text and can be asserted on the wire. - private List decodedBrokerPayloads() { - wireMock.findAll(postRequestedFor(urlPathEqualTo(BROKER_PATH))).collect { req -> - def matcher = (req.bodyAsString =~ /Body="([^"]+)"/) - matcher ? new String(Base64.decoder.decode(matcher[0][1] as String)) : "" - } - } - - private Response postXml(String transportXml) { - // The deployment registers a realm-path-extractor filter (realmIndex 0), so the realm is the first - // segment after the /gopacs context path: /gopacs/{realm}/message -> resource @Path("message"). - createClient(null) - .target(serverUri(serverPort).path("gopacs").path(MASTER_REALM).path("message")) - .request() - .post(Entity.entity(transportXml, "text/xml")) - } - - // ---- Embedded UFTP payload fixtures (attribute-style XML, matching the example message format) ---- - - private static String flexRequestXml(String congestionPoint) { - """ + private static String flexRequestXml(String congestionPoint) { + """ """ - } + } - private static String flexOrderXml(String congestionPoint, List powers) { - def isps = (0.. - """ """ - }.join("\n") - """ + private static String flexOrderXml(String congestionPoint, List powers) { + def isps = (0.. + """ """ + }.join("\n") + """ ${isps} """ - } + } } diff --git a/ems/src/test/groovy/org/openremote/extension/ems/manager/gopacs/GOPACSHandlerTest.groovy b/ems/src/test/groovy/org/openremote/extension/ems/manager/gopacs/GOPACSHandlerTest.groovy index 8755d01..6bc3936 100644 --- a/ems/src/test/groovy/org/openremote/extension/ems/manager/gopacs/GOPACSHandlerTest.groovy +++ b/ems/src/test/groovy/org/openremote/extension/ems/manager/gopacs/GOPACSHandlerTest.groovy @@ -48,215 +48,215 @@ import java.util.concurrent.TimeUnit */ class GOPACSHandlerTest extends Specification { - static final String CONTRACTED_EAN = "ean.871234567890123456" - static final String ASSET_ID = "0abcDEFghiJKLmnoPQRstu" - static final String AGR_DOMAIN = "openremote.io" // our AGR: recipient of inbound, sender of replies - static final String DSO_DOMAIN = "nilsgrid.net" // the DSO: sender of inbound, recipient of replies - static final LocalDate PERIOD = LocalDate.of(2026, 6, 4) - // IDs taken from the provided example payload.xml so that file is literally the expected FlexRequest reply. - static final String FLEX_REQUEST_MESSAGE_ID = "b3030b8b-2f45-43f4-8bf3-f00ef6fd74fc" - static final String CONVERSATION_ID = "5f7c9c4d-5988-4a17-a479-2f716051fd6d" - - AssetProcessingService assetProcessingService - AssetPredictedDatapointService assetPredictedDatapointService - TimerService timerService - ScheduledExecutorService executor - RecordingGOPACSHandler handler - String privKeyB64 - String pubB64 - - def setup() { - assetProcessingService = Mock(AssetProcessingService) - assetPredictedDatapointService = Mock(AssetPredictedDatapointService) - timerService = Stub(TimerService) { - getCurrentTimeMillis() >> PERIOD.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() - } - // Run every scheduled task inline so processRawMessage is fully synchronous. - executor = Stub(ScheduledExecutorService) { - schedule(_ as Runnable, _ as Long, _ as TimeUnit) >> { Runnable r, long d, TimeUnit u -> - r.run() - Stub(ScheduledFuture) - } - } - - // Generate an ed25519 keypair. The handler's crypto pool (LazySodiumBase64Pool) decodes both the - // signing secret key and the verifying public key as base64. - def lazySodium = new LazySodiumJava(new SodiumJava()) - KeyPair kp = lazySodium.cryptoSignKeypair() - privKeyB64 = Base64.encoder.encodeToString(kp.secretKey.asBytes) - pubB64 = Base64.encoder.encodeToString(kp.publicKey.asBytes) - - handler = newHandler(CONTRACTED_EAN) + static final String CONTRACTED_EAN = "ean.871234567890123456" + static final String ASSET_ID = "0abcDEFghiJKLmnoPQRstu" + static final String AGR_DOMAIN = "openremote.io" // our AGR: recipient of inbound, sender of replies + static final String DSO_DOMAIN = "nilsgrid.net" // the DSO: sender of inbound, recipient of replies + static final LocalDate PERIOD = LocalDate.of(2026, 6, 4) + // IDs taken from the provided example payload.xml so that file is literally the expected FlexRequest reply. + static final String FLEX_REQUEST_MESSAGE_ID = "b3030b8b-2f45-43f4-8bf3-f00ef6fd74fc" + static final String CONVERSATION_ID = "5f7c9c4d-5988-4a17-a479-2f716051fd6d" + + AssetProcessingService assetProcessingService + AssetPredictedDatapointService assetPredictedDatapointService + TimerService timerService + ScheduledExecutorService executor + RecordingGOPACSHandler handler + String privKeyB64 + String pubB64 + + def setup() { + assetProcessingService = Mock(AssetProcessingService) + assetPredictedDatapointService = Mock(AssetPredictedDatapointService) + timerService = Stub(TimerService) { + getCurrentTimeMillis() >> PERIOD.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() } - - // Builds a handler for the given contracted EAN and pre-seeds the DSO participant so inbound - // signature verification never makes an HTTP call. - private RecordingGOPACSHandler newHandler(String contractedEan) { - def h = new RecordingGOPACSHandler(contractedEan, "master", ASSET_ID, - assetProcessingService, assetPredictedDatapointService, timerService, executor, privKeyB64) - h.participants.put(DSO_DOMAIN, - new UftpParticipantInformation(DSO_DOMAIN, pubB64, "https://nilsgrid.net/shapeshifter/api/v3/message", true)) - return h - } - - def "FlexRequest updates the asset from request ISPs and replies with FlexRequestResponse then FlexOffer"() { - given: "a signed in-scope FlexRequest in the example wire format" - def xml = flexRequestXml(CONTRACTED_EAN) - - when: "it is processed" - signAndProcess(xml) - - then: "predicted datapoints are written: max=importMax, min=exportMax" - 1 * assetPredictedDatapointService.updateValues(ASSET_ID, "powerMaximumFlexRequest", { List dps -> - dps.size() == 2 && dps.collect { it.value as double } == [5.0d, 6.0d] - }) - 1 * assetPredictedDatapointService.updateValues(ASSET_ID, "powerMinimumFlexRequest", { List dps -> - dps.size() == 2 && dps.collect { it.value as double } == [3.0d, 4.0d] - }) - - and: "a FlexRequestResponse is sent, followed by a FlexOffer for the same congestion point" - handler.sent.size() == 2 - handler.sent[0] instanceof FlexRequestResponse - handler.sent[1] instanceof FlexOffer - ((FlexOffer) handler.sent[1]).congestionPoint == CONTRACTED_EAN - !((FlexOffer) handler.sent[1]).offerOptions.isEmpty() - - and: "the emitted FlexRequestResponse matches the example payload.xml shape" - def response = (FlexRequestResponse) handler.sent[0] - response.result == AcceptedRejectedType.ACCEPTED - response.flexRequestMessageID == FLEX_REQUEST_MESSAGE_ID - response.senderDomain == AGR_DOMAIN - response.recipientDomain == DSO_DOMAIN - response.version == "3.0.0" - response.conversationID == CONVERSATION_ID - } - - def "FlexOfferResponse (#result) is handled without mutating the asset or sending a reply"() { - when: "a signed FlexOfferResponse is processed" - signAndProcess(flexOfferResponseXml(result)) - - then: "no asset mutation and no outbound message" - 0 * assetPredictedDatapointService.updateValues(_, _, _) - 0 * assetProcessingService.sendAttributeEvent(_, _) - handler.sent.isEmpty() - - where: - result << [AcceptedRejectedType.ACCEPTED, AcceptedRejectedType.REJECTED] - } - - def "FlexOrder with offtake power updates currentPower and the max-profile and replies with FlexOrderResponse"() { - when: "a signed in-scope FlexOrder with positive (offtake) power is processed" - signAndProcess(flexOrderXml(CONTRACTED_EAN, [4000, 8000])) // 4.0, 8.0 kW - - then: "current power and the offtake (max) profile are written; the feed-in (min) profile is not" - 1 * assetPredictedDatapointService.updateValues(ASSET_ID, "currentPowerFlexRequest", { List dps -> - dps.size() == 2 && dps.collect { it.value as double } == [4.0d, 8.0d] - }) - 1 * assetPredictedDatapointService.updateValues(ASSET_ID, "powerLimitMaximumProfileFlexOrder", { List dps -> - dps.size() == 2 && dps.collect { it.value as double } == [4.0d, 8.0d] - }) - 0 * assetPredictedDatapointService.updateValues(ASSET_ID, "powerLimitMinimumProfileFlexOrder", _) - - and: "a FlexOrderResponse (Accepted) is sent back" - handler.sent.size() == 1 - handler.sent[0] instanceof FlexOrderResponse - ((FlexOrderResponse) handler.sent[0]).result == AcceptedRejectedType.ACCEPTED - } - - def "FlexOrder with feed-in power updates currentPower and the min-profile"() { - when: "a signed in-scope FlexOrder with negative (feed-in) power is processed" - signAndProcess(flexOrderXml(CONTRACTED_EAN, [-2000, -5000])) // -2.0, -5.0 kW - - then: "current power and the feed-in (min) profile are written; the offtake (max) profile is not" - 1 * assetPredictedDatapointService.updateValues(ASSET_ID, "currentPowerFlexRequest", { List dps -> - dps.size() == 2 && dps.collect { it.value as double } == [-2.0d, -5.0d] - }) - 1 * assetPredictedDatapointService.updateValues(ASSET_ID, "powerLimitMinimumProfileFlexOrder", { List dps -> - dps.size() == 2 && dps.collect { it.value as double } == [-2.0d, -5.0d] - }) - 0 * assetPredictedDatapointService.updateValues(ASSET_ID, "powerLimitMaximumProfileFlexOrder", _) - - and: "a FlexOrderResponse (Accepted) is sent back" - handler.sent.size() == 1 - handler.sent[0] instanceof FlexOrderResponse - ((FlexOrderResponse) handler.sent[0]).result == AcceptedRejectedType.ACCEPTED - } - - def "a validly-signed FlexRequest for a different congestion point is rejected without mutation or FlexOffer"() { - when: "a signed out-of-scope FlexRequest (different EAN) is processed" - signAndProcess(flexRequestXml("ean.999999999999999999")) - - then: "the asset is not mutated" - 0 * assetPredictedDatapointService.updateValues(_, _, _) - 0 * assetProcessingService.sendAttributeEvent(_, _) - - and: "only a rejected FlexRequestResponse is sent back, no FlexOffer" - handler.sent.size() == 1 - handler.sent[0] instanceof FlexRequestResponse - def response = (FlexRequestResponse) handler.sent[0] - response.result == AcceptedRejectedType.REJECTED - !response.rejectionReason.isBlank() - response.flexRequestMessageID == FLEX_REQUEST_MESSAGE_ID - response.conversationID == CONVERSATION_ID - response.senderDomain == AGR_DOMAIN - response.recipientDomain == DSO_DOMAIN + // Run every scheduled task inline so processRawMessage is fully synchronous. + executor = Stub(ScheduledExecutorService) { + schedule(_ as Runnable, _ as Long, _ as TimeUnit) >> { Runnable r, long d, TimeUnit u -> + r.run() + Stub(ScheduledFuture) + } } - def "a validly-signed FlexOrder for a different congestion point is rejected without mutation"() { - when: "a signed out-of-scope FlexOrder (different EAN) is processed" - signAndProcess(flexOrderXml("ean.999999999999999999", [4000, 8000])) - - then: "the asset is not mutated" - 0 * assetPredictedDatapointService.updateValues(_, _, _) - 0 * assetProcessingService.sendAttributeEvent(_, _) - - and: "only a rejected FlexOrderResponse is sent back" - handler.sent.size() == 1 - handler.sent[0] instanceof FlexOrderResponse - def response = (FlexOrderResponse) handler.sent[0] - response.result == AcceptedRejectedType.REJECTED - !response.rejectionReason.isBlank() - response.conversationID == CONVERSATION_ID - } - - def "toCongestionPoint canonicalises an EAN to the GOPACS ean. format (#input -> #expected)"() { - expect: "the optional, case-insensitive ean. prefix is normalised to lower-case and added when missing" - GOPACSHandler.toCongestionPoint(input) == expected - - where: - input || expected - "ean.265987182507322951" || "ean.265987182507322951" - "265987182507322951" || "ean.265987182507322951" - "EAN.265987182507322951" || "ean.265987182507322951" - "Ean.265987182507322951" || "ean.265987182507322951" - " ean.265987182507322951 " || "ean.265987182507322951" - " 265987182507322951 " || "ean.265987182507322951" - "" || "ean." - null || null - } - - def "FlexRequest is in scope when the contracted EAN is configured without the ean. prefix"() { - given: "a handler whose contracted EAN omits the ean. prefix, and a standard prefixed FlexRequest" - def bareEan = CONTRACTED_EAN.substring(GOPACSHandler.EAN_PREFIX.length()) - def bareHandler = newHandler(bareEan) - def sender = new UftpParticipant(DSO_DOMAIN, USEFRoleType.DSO) - def signed = bareHandler.cryptoService.signMessage(flexRequestXml(CONTRACTED_EAN), sender, bareHandler.privateKey) - - when: "the signed FlexRequest is processed" - bareHandler.processRawMessage(GOPACSHandler.serializer.toXml(signed)) - - then: "it is treated as in scope: the asset is updated and both replies are sent" - 1 * assetPredictedDatapointService.updateValues(ASSET_ID, "powerMaximumFlexRequest", _) - 1 * assetPredictedDatapointService.updateValues(ASSET_ID, "powerMinimumFlexRequest", _) - bareHandler.sent.size() == 2 - bareHandler.sent[0] instanceof FlexRequestResponse - bareHandler.sent[1] instanceof FlexOffer - } - - // ---- Embedded UFTP payload fixtures (attribute-style XML, matching the example message format) ---- - - private static String flexRequestXml(String congestionPoint) { - """ + // Generate an ed25519 keypair. The handler's crypto pool (LazySodiumBase64Pool) decodes both the + // signing secret key and the verifying public key as base64. + def lazySodium = new LazySodiumJava(new SodiumJava()) + KeyPair kp = lazySodium.cryptoSignKeypair() + privKeyB64 = Base64.encoder.encodeToString(kp.secretKey.asBytes) + pubB64 = Base64.encoder.encodeToString(kp.publicKey.asBytes) + + handler = newHandler(CONTRACTED_EAN) + } + + // Builds a handler for the given contracted EAN and pre-seeds the DSO participant so inbound + // signature verification never makes an HTTP call. + private RecordingGOPACSHandler newHandler(String contractedEan) { + def h = new RecordingGOPACSHandler(contractedEan, "master", ASSET_ID, + assetProcessingService, assetPredictedDatapointService, timerService, executor, privKeyB64) + h.participants.put(DSO_DOMAIN, + new UftpParticipantInformation(DSO_DOMAIN, pubB64, "https://nilsgrid.net/shapeshifter/api/v3/message", true)) + return h + } + + def "FlexRequest updates the asset from request ISPs and replies with FlexRequestResponse then FlexOffer"() { + given: "a signed in-scope FlexRequest in the example wire format" + def xml = flexRequestXml(CONTRACTED_EAN) + + when: "it is processed" + signAndProcess(xml) + + then: "predicted datapoints are written: max=importMax, min=exportMax" + 1 * assetPredictedDatapointService.updateValues(ASSET_ID, "powerMaximumFlexRequest", { List dps -> + dps.size() == 2 && dps.collect { it.value as double } == [5.0d, 6.0d] + }) + 1 * assetPredictedDatapointService.updateValues(ASSET_ID, "powerMinimumFlexRequest", { List dps -> + dps.size() == 2 && dps.collect { it.value as double } == [3.0d, 4.0d] + }) + + and: "a FlexRequestResponse is sent, followed by a FlexOffer for the same congestion point" + handler.sent.size() == 2 + handler.sent[0] instanceof FlexRequestResponse + handler.sent[1] instanceof FlexOffer + ((FlexOffer) handler.sent[1]).congestionPoint == CONTRACTED_EAN + !((FlexOffer) handler.sent[1]).offerOptions.isEmpty() + + and: "the emitted FlexRequestResponse matches the example payload.xml shape" + def response = (FlexRequestResponse) handler.sent[0] + response.result == AcceptedRejectedType.ACCEPTED + response.flexRequestMessageID == FLEX_REQUEST_MESSAGE_ID + response.senderDomain == AGR_DOMAIN + response.recipientDomain == DSO_DOMAIN + response.version == "3.0.0" + response.conversationID == CONVERSATION_ID + } + + def "FlexOfferResponse (#result) is handled without mutating the asset or sending a reply"() { + when: "a signed FlexOfferResponse is processed" + signAndProcess(flexOfferResponseXml(result)) + + then: "no asset mutation and no outbound message" + 0 * assetPredictedDatapointService.updateValues(_, _, _) + 0 * assetProcessingService.sendAttributeEvent(_, _) + handler.sent.isEmpty() + + where: + result << [AcceptedRejectedType.ACCEPTED, AcceptedRejectedType.REJECTED] + } + + def "FlexOrder with offtake power updates currentPower and the max-profile and replies with FlexOrderResponse"() { + when: "a signed in-scope FlexOrder with positive (offtake) power is processed" + signAndProcess(flexOrderXml(CONTRACTED_EAN, [4000, 8000])) // 4.0, 8.0 kW + + then: "current power and the offtake (max) profile are written; the feed-in (min) profile is not" + 1 * assetPredictedDatapointService.updateValues(ASSET_ID, "currentPowerFlexRequest", { List dps -> + dps.size() == 2 && dps.collect { it.value as double } == [4.0d, 8.0d] + }) + 1 * assetPredictedDatapointService.updateValues(ASSET_ID, "powerLimitMaximumProfileFlexOrder", { List dps -> + dps.size() == 2 && dps.collect { it.value as double } == [4.0d, 8.0d] + }) + 0 * assetPredictedDatapointService.updateValues(ASSET_ID, "powerLimitMinimumProfileFlexOrder", _) + + and: "a FlexOrderResponse (Accepted) is sent back" + handler.sent.size() == 1 + handler.sent[0] instanceof FlexOrderResponse + ((FlexOrderResponse) handler.sent[0]).result == AcceptedRejectedType.ACCEPTED + } + + def "FlexOrder with feed-in power updates currentPower and the min-profile"() { + when: "a signed in-scope FlexOrder with negative (feed-in) power is processed" + signAndProcess(flexOrderXml(CONTRACTED_EAN, [-2000, -5000])) // -2.0, -5.0 kW + + then: "current power and the feed-in (min) profile are written; the offtake (max) profile is not" + 1 * assetPredictedDatapointService.updateValues(ASSET_ID, "currentPowerFlexRequest", { List dps -> + dps.size() == 2 && dps.collect { it.value as double } == [-2.0d, -5.0d] + }) + 1 * assetPredictedDatapointService.updateValues(ASSET_ID, "powerLimitMinimumProfileFlexOrder", { List dps -> + dps.size() == 2 && dps.collect { it.value as double } == [-2.0d, -5.0d] + }) + 0 * assetPredictedDatapointService.updateValues(ASSET_ID, "powerLimitMaximumProfileFlexOrder", _) + + and: "a FlexOrderResponse (Accepted) is sent back" + handler.sent.size() == 1 + handler.sent[0] instanceof FlexOrderResponse + ((FlexOrderResponse) handler.sent[0]).result == AcceptedRejectedType.ACCEPTED + } + + def "a validly-signed FlexRequest for a different congestion point is rejected without mutation or FlexOffer"() { + when: "a signed out-of-scope FlexRequest (different EAN) is processed" + signAndProcess(flexRequestXml("ean.999999999999999999")) + + then: "the asset is not mutated" + 0 * assetPredictedDatapointService.updateValues(_, _, _) + 0 * assetProcessingService.sendAttributeEvent(_, _) + + and: "only a rejected FlexRequestResponse is sent back, no FlexOffer" + handler.sent.size() == 1 + handler.sent[0] instanceof FlexRequestResponse + def response = (FlexRequestResponse) handler.sent[0] + response.result == AcceptedRejectedType.REJECTED + !response.rejectionReason.isBlank() + response.flexRequestMessageID == FLEX_REQUEST_MESSAGE_ID + response.conversationID == CONVERSATION_ID + response.senderDomain == AGR_DOMAIN + response.recipientDomain == DSO_DOMAIN + } + + def "a validly-signed FlexOrder for a different congestion point is rejected without mutation"() { + when: "a signed out-of-scope FlexOrder (different EAN) is processed" + signAndProcess(flexOrderXml("ean.999999999999999999", [4000, 8000])) + + then: "the asset is not mutated" + 0 * assetPredictedDatapointService.updateValues(_, _, _) + 0 * assetProcessingService.sendAttributeEvent(_, _) + + and: "only a rejected FlexOrderResponse is sent back" + handler.sent.size() == 1 + handler.sent[0] instanceof FlexOrderResponse + def response = (FlexOrderResponse) handler.sent[0] + response.result == AcceptedRejectedType.REJECTED + !response.rejectionReason.isBlank() + response.conversationID == CONVERSATION_ID + } + + def "toCongestionPoint canonicalises an EAN to the GOPACS ean. format (#input -> #expected)"() { + expect: "the optional, case-insensitive ean. prefix is normalised to lower-case and added when missing" + GOPACSHandler.toCongestionPoint(input) == expected + + where: + input || expected + "ean.265987182507322951" || "ean.265987182507322951" + "265987182507322951" || "ean.265987182507322951" + "EAN.265987182507322951" || "ean.265987182507322951" + "Ean.265987182507322951" || "ean.265987182507322951" + " ean.265987182507322951 " || "ean.265987182507322951" + " 265987182507322951 " || "ean.265987182507322951" + "" || "ean." + null || null + } + + def "FlexRequest is in scope when the contracted EAN is configured without the ean. prefix"() { + given: "a handler whose contracted EAN omits the ean. prefix, and a standard prefixed FlexRequest" + def bareEan = CONTRACTED_EAN.substring(GOPACSHandler.EAN_PREFIX.length()) + def bareHandler = newHandler(bareEan) + def sender = new UftpParticipant(DSO_DOMAIN, USEFRoleType.DSO) + def signed = bareHandler.cryptoService.signMessage(flexRequestXml(CONTRACTED_EAN), sender, bareHandler.privateKey) + + when: "the signed FlexRequest is processed" + bareHandler.processRawMessage(GOPACSHandler.serializer.toXml(signed)) + + then: "it is treated as in scope: the asset is updated and both replies are sent" + 1 * assetPredictedDatapointService.updateValues(ASSET_ID, "powerMaximumFlexRequest", _) + 1 * assetPredictedDatapointService.updateValues(ASSET_ID, "powerMinimumFlexRequest", _) + bareHandler.sent.size() == 2 + bareHandler.sent[0] instanceof FlexRequestResponse + bareHandler.sent[1] instanceof FlexOffer + } + + // ---- Embedded UFTP payload fixtures (attribute-style XML, matching the example message format) ---- + + private static String flexRequestXml(String congestionPoint) { + """ """ - } + } - private static String flexOrderXml(String congestionPoint, List powers) { - def isps = (0.. - """ """ - }.join("\n") - """ + private static String flexOrderXml(String congestionPoint, List powers) { + def isps = (0.. + """ """ + }.join("\n") + """ ${isps} """ - } + } - private static String flexOfferResponseXml(AcceptedRejectedType result) { - def reason = result == AcceptedRejectedType.REJECTED ? ' RejectionReason="insufficient flexibility"' : '' - """ + private static String flexOfferResponseXml(AcceptedRejectedType result) { + def reason = result == AcceptedRejectedType.REJECTED ? ' RejectionReason="insufficient flexibility"' : '' + """ """ + } + + // Signs the payload XML as the DSO and feeds the transport XML through the real entry point. + private void signAndProcess(String payloadXml) { + def sender = new UftpParticipant(DSO_DOMAIN, USEFRoleType.DSO) + SignedMessage signed = handler.cryptoService.signMessage(payloadXml, sender, handler.privateKey) + handler.processRawMessage(GOPACSHandler.serializer.toXml(signed)) + } + + // Records outbound messages (library-generated responses + handler-built FlexOffer) instead of sending them. + static class RecordingGOPACSHandler extends GOPACSHandler { + final List sent = new ArrayList<>() + + RecordingGOPACSHandler(String ean, String realm, String assetId, + AssetProcessingService aps, AssetPredictedDatapointService apds, + TimerService ts, ScheduledExecutorService exec, String privateKey) { + super(ean, realm, assetId, aps, apds, ts, exec, privateKey) } - // Signs the payload XML as the DSO and feeds the transport XML through the real entry point. - private void signAndProcess(String payloadXml) { - def sender = new UftpParticipant(DSO_DOMAIN, USEFRoleType.DSO) - SignedMessage signed = handler.cryptoService.signMessage(payloadXml, sender, handler.privateKey) - handler.processRawMessage(GOPACSHandler.serializer.toXml(signed)) - } - - // Records outbound messages (library-generated responses + handler-built FlexOffer) instead of sending them. - static class RecordingGOPACSHandler extends GOPACSHandler { - final List sent = new ArrayList<>() - - RecordingGOPACSHandler(String ean, String realm, String assetId, - AssetProcessingService aps, AssetPredictedDatapointService apds, - TimerService ts, ScheduledExecutorService exec, String privateKey) { - super(ean, realm, assetId, aps, apds, ts, exec, privateKey) - } - - @Override - void notifyNewOutgoingMessage(OutgoingUftpMessage message) { - sent.add(message.payloadMessage()) - } + @Override + void notifyNewOutgoingMessage(OutgoingUftpMessage message) { + sent.add(message.payloadMessage()) } + } } diff --git a/energy/src/test/groovy/org/openremote/extension/energy/EnergyOptimisationAssetTest.groovy b/energy/src/test/groovy/org/openremote/extension/energy/EnergyOptimisationAssetTest.groovy index d7edb6d..0a24db0 100644 --- a/energy/src/test/groovy/org/openremote/extension/energy/EnergyOptimisationAssetTest.groovy +++ b/energy/src/test/groovy/org/openremote/extension/energy/EnergyOptimisationAssetTest.groovy @@ -71,349 +71,351 @@ import static spock.util.matcher.HamcrestMatchers.closeTo */ @Ignore class EnergyOptimisationAssetTest extends Specification implements ManagerContainerTrait { - def "Test storage asset with consumer and producer"() { - - given: "the container environment is started" - def conditions = new PollingConditions(timeout: 10, delay: 0.2) - def services = Lists.newArrayList(defaultServices()) - def spyOptimisationService = Spy(EnergyOptimisationService) { - scheduleOptimisation(_ as String, _ as EnergyOptimiser, _ as Duration, _ as Long) >> { - // Don't use the scheduler as we will manually trigger the optimisation for testing - optimisationAssetId, optimiser, startDuration, periodSeconds -> - return null - } - } - - services.replaceAll { it instanceof EnergyOptimisationService ? spyOptimisationService : it } - def container = startContainer(defaultConfig(), services) - def managerTestSetup = container.getService(SetupService.class).getTaskOfType(ManagerTestSetup.class) - def optimisationService = container.getService(EnergyOptimisationService.class) - def assetStorageService = container.getService(AssetStorageService.class) - def assetProcessingService = container.getService(AssetProcessingService.class) - def assetPredictedDatapointService = container.getService(AssetPredictedDatapointService.class) - def timerService = container.getService(TimerService.class) - - expect: "an optimisation instance should exist" - conditions.eventually { - assert !optimisationService.assetOptimisationInstanceMap.isEmpty() - assert optimisationService.assetOptimisationInstanceMap.get(managerTestSetup.electricityOptimisationAssetId) != null - } - - when: "the pseudo clock is stopped and the system time is set to midnight of next day" - stopPseudoClock() - def now = Instant.ofEpochMilli(timerService.getCurrentTimeMillis()) - now = now.truncatedTo(ChronoUnit.DAYS).plus(1, ChronoUnit.DAYS) - advancePseudoClock(now.toEpochMilli()-timerService.getCurrentTimeMillis(), TimeUnit.MILLISECONDS, container) - - then: "the optimisation start time should be correctly calculated" - def optimiser = optimisationService.assetOptimisationInstanceMap.get(managerTestSetup.electricityOptimisationAssetId).energyOptimiser - def optimisationTime = optimisationService.getOptimisationStartTime(now.toEpochMilli(), (long)optimiser.intervalSize * 60 * 60) - def optimisationDateTime = optimisationTime.atZone(ZoneId.systemDefault()).toLocalDateTime() - assert optimisationTime.isBefore(now) - assert optimisationTime.plus((long)optimiser.intervalSize*60, ChronoUnit.MINUTES).equals(now) - - when: "supplier tariff values are set for the next 24hrs" - def tariffExports = [-5, -2, -8, 2, 2, 5, -2, -2] - def tariffImports = [3, -5, 10, 1, 3, -5, 7, 8] - assetProcessingService.sendAttributeEvent(new AttributeEvent(managerTestSetup.electricitySupplierAssetId, ElectricityAsset.TARIFF_IMPORT.name, tariffImports.get(0))) - assetProcessingService.sendAttributeEvent(new AttributeEvent(managerTestSetup.electricitySupplierAssetId, ElectricityAsset.TARIFF_EXPORT.name, tariffExports.get(0))) - - for (int i = 1; i < tariffExports.size(); i++) { - assetPredictedDatapointService.updateValue(new AttributeRef(managerTestSetup.electricitySupplierAssetId, ElectricityAsset.TARIFF_IMPORT.name), tariffImports.get(i), optimisationDateTime.plus((long)(optimiser.intervalSize * 60)*i, ChronoUnit.MINUTES)) - assetPredictedDatapointService.updateValue(new AttributeRef(managerTestSetup.electricitySupplierAssetId, ElectricityAsset.TARIFF_EXPORT.name), tariffExports.get(i), optimisationDateTime.plus((long)(optimiser.intervalSize * 60)*i, ChronoUnit.MINUTES)) - } - - and: "consumer and producer prediction values are set for the next 24hrs" - def consumerPower = [0, 5, 20, 0, 0, 10, 5, 0] - def producerPower = [-5, -30, -5, 0, 0, -15, -10, -2] - assetProcessingService.sendAttributeEvent(new AttributeEvent(managerTestSetup.electricityConsumerAssetId, ElectricityAsset.POWER.name, consumerPower.get(0))) - assetProcessingService.sendAttributeEvent(new AttributeEvent(managerTestSetup.electricitySolarAssetId, ElectricityAsset.POWER.name, producerPower.get(0))) - - for (int i = 1; i < consumerPower.size(); i++) { - assetPredictedDatapointService.updateValue(new AttributeRef(managerTestSetup.electricityConsumerAssetId, ElectricityAsset.POWER.name), consumerPower.get(i), optimisationDateTime.plus((long)(optimiser.intervalSize * 60)*i, ChronoUnit.MINUTES)) - assetPredictedDatapointService.updateValue(new AttributeRef(managerTestSetup.electricitySolarAssetId, ElectricityAsset.POWER.name), producerPower.get(i), optimisationDateTime.plus((long)(optimiser.intervalSize * 60)*i, ChronoUnit.MINUTES)) - } - - then: "the current values of each attribute should have reached the DB" - conditions.eventually { - assert (assetStorageService.find(managerTestSetup.electricitySupplierAssetId) as ElectricitySupplierAsset).getTariffImport().orElse(0d) == tariffImports.get(0) - assert (assetStorageService.find(managerTestSetup.electricitySupplierAssetId) as ElectricitySupplierAsset).getTariffExport().orElse(0d) == tariffExports.get(0) - assert (assetStorageService.find(managerTestSetup.electricityConsumerAssetId) as ElectricityConsumerAsset).getPower().orElse(-1d) == consumerPower.get(0) - assert (assetStorageService.find(managerTestSetup.electricitySolarAssetId) as ElectricityProducerSolarAsset).getPower().orElse(-1d) == producerPower.get(0) - } - - when: "the optimisation runs" - optimisationService.runOptimisation(managerTestSetup.electricityOptimisationAssetId, optimisationTime) - - then: "the setpoints of the storage asset for the next 24hrs should be correctly optimised" - conditions.eventually { - assert ((ElectricityStorageAsset)assetStorageService.find(managerTestSetup.electricityBatteryAssetId)).getPowerSetpoint().orElse(0d) == 0d - def setpoints = assetPredictedDatapointService.queryDatapoints( - managerTestSetup.electricityBatteryAssetId, - ElectricityAsset.POWER_SETPOINT.name, - new AssetDatapointIntervalQuery( - optimisationTime.atZone(ZoneId.systemDefault()).plus((long)(optimiser.intervalSize * 60), ChronoUnit.MINUTES).toLocalDateTime(), - optimisationTime.atZone(ZoneId.systemDefault()).plus(1, ChronoUnit.DAYS).minus((long)(optimiser.intervalSize * 60), ChronoUnit.MINUTES).toLocalDateTime(), - (optimiser.intervalSize * 60) + " minutes", - AssetDatapointIntervalQuery.Formula.AVG, - true - ) - ) - - assert setpoints.size() == 7 - assert setpoints[0].value == 0d - assert setpoints[1].value == -20d - assert setpoints[2].value == 7d //that(setpoints[2].value, closeTo(2.33333, 0.0001)) - assert setpoints[3].value == 0d - assert setpoints[4].value == 7d - assert setpoints[5].value == -14d - assert setpoints[6].value == 0d - } - - when: "storage asset import and export tariffs are added to make storage un-viable and optimisation is run" - def batteryAsset = ((ElectricityStorageAsset)assetStorageService.find(managerTestSetup.electricityBatteryAssetId)) - batteryAsset.setTariffExport(10) - batteryAsset.setTariffImport(10) - batteryAsset = assetStorageService.merge(batteryAsset) - optimisationService.runOptimisation(managerTestSetup.electricityOptimisationAssetId, optimisationTime) - - then: "the battery should not be used in any interval" - conditions.eventually { - assert ((ElectricityStorageAsset)assetStorageService.find(managerTestSetup.electricityBatteryAssetId)).getPowerSetpoint().orElse(0d) == 0d - def setpoints = assetPredictedDatapointService.queryDatapoints( - managerTestSetup.electricityBatteryAssetId, - ElectricityAsset.POWER_SETPOINT.name, - new AssetDatapointIntervalQuery( - optimisationTime.atZone(ZoneId.systemDefault()).plus((long)(optimiser.intervalSize * 60), ChronoUnit.MINUTES).toLocalDateTime(), - optimisationTime.atZone(ZoneId.systemDefault()).plus(1, ChronoUnit.DAYS).minus((long)(optimiser.intervalSize * 60), ChronoUnit.MINUTES).toLocalDateTime(), - (optimiser.intervalSize * 60) + " minutes", - AssetDatapointIntervalQuery.Formula.AVG, - true - ) - ) - - assert setpoints.size() == 7 - assert setpoints[0].value == 0d - assert setpoints[1].value == 0d - assert setpoints[2].value == 0d - assert setpoints[3].value == 0d - assert setpoints[4].value == 0d - assert setpoints[5].value == 0d - assert setpoints[6].value == 0d - } - - when: "storage asset import and export tariffs are modified to make storage viable in limited intervals and optimisation is run" - batteryAsset = ((ElectricityStorageAsset)assetStorageService.find(managerTestSetup.electricityBatteryAssetId)) - batteryAsset.setTariffExport(5) - batteryAsset.setTariffImport(5) - batteryAsset = assetStorageService.merge(batteryAsset) - optimisationService.runOptimisation(managerTestSetup.electricityOptimisationAssetId, optimisationTime) - - then: "the battery should only be used in the correct intervals" - conditions.eventually { - assert ((ElectricityStorageAsset)assetStorageService.find(managerTestSetup.electricityBatteryAssetId)).getPowerSetpoint().orElse(0d) == 0d - def setpoints = assetPredictedDatapointService.queryDatapoints( - managerTestSetup.electricityBatteryAssetId, - ElectricityAsset.POWER_SETPOINT.name, - new AssetDatapointIntervalQuery( - optimisationTime.atZone(ZoneId.systemDefault()).plus((long)(optimiser.intervalSize * 60), ChronoUnit.MINUTES).toLocalDateTime(), - optimisationTime.atZone(ZoneId.systemDefault()).plus(1, ChronoUnit.DAYS).minus((long)(optimiser.intervalSize * 60), ChronoUnit.MINUTES).toLocalDateTime(), - (optimiser.intervalSize * 60) + " minutes", - AssetDatapointIntervalQuery.Formula.AVG, - true - ) - ) - - assert setpoints.size() == 7 - assert setpoints[0].value == 0d - assert setpoints[1].value == -20d - assert setpoints[2].value == 0d - assert setpoints[3].value == 0d - assert setpoints[4].value == 0d - assert setpoints[5].value == 0d - assert setpoints[6].value == 0d - } + def "Test storage asset with consumer and producer"() { + + given: "the container environment is started" + def conditions = new PollingConditions(timeout: 10, delay: 0.2) + def services = Lists.newArrayList(defaultServices()) + def spyOptimisationService = Spy(EnergyOptimisationService) { + scheduleOptimisation(_ as String, _ as EnergyOptimiser, _ as Duration, _ as Long) >> { // Don't use the scheduler as we will manually trigger the optimisation for testing + optimisationAssetId, optimiser, startDuration, periodSeconds -> + return null + } } - def "Test savings calculations"() { - - given: "the container environment is started" - def conditions = new PollingConditions(timeout: 10, delay: 0.2) - def services = Lists.newArrayList(defaultServices()) - def spyOptimisationService = Spy(EnergyOptimisationService) { - scheduleOptimisation(_ as String, _ as EnergyOptimiser, _ as Duration, _ as Long) >> { - // Don't use the scheduler as we will manually trigger the optimisation for testing - optimisationAssetId, optimiser, startDuration, periodSeconds -> - return null - } - } - - services.replaceAll { it instanceof EnergyOptimisationService ? spyOptimisationService : it } - def container = startContainer(defaultConfig(), services) - def managerTestSetup = container.getService(SetupService.class).getTaskOfType(ManagerTestSetup.class) - def optimisationService = container.getService(EnergyOptimisationService.class) - def assetStorageService = container.getService(AssetStorageService.class) - def assetProcessingService = container.getService(AssetProcessingService.class) - def assetPredictedDatapointService = container.getService(AssetPredictedDatapointService.class) - def timerService = container.getService(TimerService.class) - - expect: "an optimisation instance should exist" - conditions.eventually { - assert !optimisationService.assetOptimisationInstanceMap.isEmpty() - assert optimisationService.assetOptimisationInstanceMap.get(managerTestSetup.electricityOptimisationAssetId) != null - } - - when: "the battery energy level is set to lower limit for ease of testing" - def batteryAsset = assetStorageService.find(managerTestSetup.electricityBatteryAssetId) as ElectricityBatteryAsset - batteryAsset.setEnergyLevel(40) - batteryAsset.setPowerImportMax(10) - batteryAsset.setPowerExportMax(10) - batteryAsset = assetStorageService.merge(batteryAsset) - - and: "the pseudo clock is stopped and the system time is set to midnight of two days time" - stopPseudoClock() - def now = Instant.ofEpochMilli(timerService.getCurrentTimeMillis()) - now = now.truncatedTo(ChronoUnit.DAYS).plus(2, ChronoUnit.DAYS) - advancePseudoClock(now.toEpochMilli()-timerService.getCurrentTimeMillis(), TimeUnit.MILLISECONDS, container) - - then: "the optimisation start time should be correctly calculated" - def optimiser = optimisationService.assetOptimisationInstanceMap.get(managerTestSetup.electricityOptimisationAssetId).energyOptimiser - def optimisationTime = optimisationService.getOptimisationStartTime(now.toEpochMilli(), (long)optimiser.intervalSize * 60 * 60) - def optimisationDateTime = optimisationTime.atZone(ZoneId.systemDefault()) - assert optimisationTime.isBefore(now) - assert optimisationTime.plus((long)optimiser.intervalSize*60, ChronoUnit.MINUTES).equals(now) - - and: "consumer and producer prediction values are set for the next 24hrs" - def consumerPower = [0, 0, 0, 0, 10, 10, 10, 10] - def producerPower = [-50, -50, -50, -50, 0, 0, 0, 0] - assetProcessingService.sendAttributeEvent(new AttributeEvent(managerTestSetup.electricityConsumerAssetId, ElectricityAsset.POWER.name, consumerPower.get(0))) - assetProcessingService.sendAttributeEvent(new AttributeEvent(managerTestSetup.electricitySolarAssetId, ElectricityAsset.POWER.name, producerPower.get(0))) - - for (int i = 1; i < consumerPower.size(); i++) { - assetPredictedDatapointService.updateValue(new AttributeRef(managerTestSetup.electricityConsumerAssetId, ElectricityAsset.POWER.name), consumerPower.get(i), optimisationDateTime.plus((long)(optimiser.intervalSize * 60)*i, ChronoUnit.MINUTES).toLocalDateTime()) - assetPredictedDatapointService.updateValue(new AttributeRef(managerTestSetup.electricitySolarAssetId, ElectricityAsset.POWER.name), producerPower.get(i), optimisationDateTime.plus((long)(optimiser.intervalSize * 60)*i, ChronoUnit.MINUTES).toLocalDateTime()) - } - - and: "supplier tariff values are set for the next 24hrs" - def tariffExports = [-0.05, -0.05, -0.05, -0.05, -0.05, -0.05, -0.05, -0.05] - def tariffImports = [0.08, 0.08, 0.08, 0.08, 0.08, 0.08, 0.08, 0.08] - assetProcessingService.sendAttributeEvent(new AttributeEvent(managerTestSetup.electricitySupplierAssetId, ElectricityAsset.TARIFF_IMPORT.name, tariffImports.get(0))) - assetProcessingService.sendAttributeEvent(new AttributeEvent(managerTestSetup.electricitySupplierAssetId, ElectricityAsset.TARIFF_EXPORT.name, tariffExports.get(0))) - - for (int i = 1; i < tariffExports.size(); i++) { - assetPredictedDatapointService.updateValue(new AttributeRef(managerTestSetup.electricitySupplierAssetId, ElectricityAsset.TARIFF_IMPORT.name), tariffImports.get(i), optimisationDateTime.plus((long)(optimiser.intervalSize * 60)*i, ChronoUnit.MINUTES).toLocalDateTime()) - assetPredictedDatapointService.updateValue(new AttributeRef(managerTestSetup.electricitySupplierAssetId, ElectricityAsset.TARIFF_EXPORT.name), tariffExports.get(i), optimisationDateTime.plus((long)(optimiser.intervalSize * 60)*i, ChronoUnit.MINUTES).toLocalDateTime()) - } - - then: "the current values of each attribute should have reached the DB" - conditions.eventually { - assert (assetStorageService.find(managerTestSetup.electricityConsumerAssetId) as ElectricityConsumerAsset).getPower().orElse(-1d) == consumerPower.get(0) - assert (assetStorageService.find(managerTestSetup.electricitySolarAssetId) as ElectricityProducerSolarAsset).getPower().orElse(-1d) == producerPower.get(0) - } - - when: "the optimisation asset is reset which will cause optimisation to run" - def optimisationAsset = assetStorageService.find(managerTestSetup.electricityOptimisationAssetId) as EnergyOptimisationAsset - optimisationAsset.setOptimisationDisabled(true) - optimisationAsset.setFinancialSaving(0d) - optimisationAsset = ValueUtil.clone(assetStorageService.merge(optimisationAsset)) - optimisationAsset.setOptimisationDisabled(false) - optimisationAsset = assetStorageService.merge(optimisationAsset) - - then: "the battery setpoint should be set to max to store produced energy for future deficit" - conditions.eventually { - assert ((ElectricityStorageAsset)assetStorageService.find(managerTestSetup.electricityBatteryAssetId)).getPowerSetpoint().orElse(0d) == 10d - - def setpoints = assetPredictedDatapointService.queryDatapoints( - managerTestSetup.electricityBatteryAssetId, - ElectricityAsset.POWER_SETPOINT.name, - new AssetDatapointIntervalQuery( - optimisationTime.atZone(ZoneId.systemDefault()).plus((long)(optimiser.intervalSize * 60), ChronoUnit.MINUTES).toLocalDateTime(), - optimisationTime.atZone(ZoneId.systemDefault()).plus(1, ChronoUnit.DAYS).minus((long)(optimiser.intervalSize * 60), ChronoUnit.MINUTES).toLocalDateTime(), - (optimiser.intervalSize * 60) + " minutes", - AssetDatapointIntervalQuery.Formula.AVG, - true - ) - ) - - assert setpoints.size() == 7 - assert setpoints[0].value == 10d - assert setpoints[1].value == 10d - assert setpoints[2].value == 10d - assert setpoints[3].value == -10d - assert setpoints[4].value == -10d - assert setpoints[5].value == -10d - assert setpoints[6].value == -10d - } - - and: "the optimisation saving should be inverse of earning from lost production export -(10x3x0.05)" - conditions.eventually { - assert (assetStorageService.find(managerTestSetup.electricityOptimisationAssetId) as EnergyOptimisationAsset).getFinancialSaving().orElse(0d) == -1.5 - } - - when: "the battery energy level changes and time advances" - batteryAsset.setEnergyLevel(70d) - batteryAsset = assetStorageService.merge(batteryAsset) - advancePseudoClock(1, TimeUnit.SECONDS, container) // This prevents attribute timestamp issues - - and: "another optimisation run occurs" - optimisationService.runOptimisation(managerTestSetup.electricityOptimisationAssetId, optimisationTime.plus((long)optimiser.intervalSize*60, ChronoUnit.MINUTES)) - - then: "the optimisation saving should have decreased by the same amount (i.e. cost)" - conditions.eventually { - assert ((ElectricityStorageAsset)assetStorageService.find(managerTestSetup.electricityBatteryAssetId)).getPowerSetpoint().orElse(0d) == 10d - assert (assetStorageService.find(managerTestSetup.electricityOptimisationAssetId) as EnergyOptimisationAsset).getFinancialSaving().orElse(0d) == -3.0 - } - - when: "the battery energy level changes and time advances" - batteryAsset.setEnergyLevel(100d) - batteryAsset = assetStorageService.merge(batteryAsset) - advancePseudoClock(1, TimeUnit.SECONDS, container) // This prevents attribute timestamp issues - - and: "another optimisation run occurs" - optimisationService.runOptimisation(managerTestSetup.electricityOptimisationAssetId, optimisationTime.plus((long)optimiser.intervalSize*60*2, ChronoUnit.MINUTES)) - - then: "the optimisation saving should have decreased by the same amount (i.e. cost)" - conditions.eventually { - assert ((ElectricityStorageAsset)assetStorageService.find(managerTestSetup.electricityBatteryAssetId)).getPowerSetpoint().orElse(0d) == 10d - assert (assetStorageService.find(managerTestSetup.electricityOptimisationAssetId) as EnergyOptimisationAsset).getFinancialSaving().orElse(0d) == -4.5 - } - - when: "the battery energy level changes and time advances" - batteryAsset.setEnergyLevel(130d) - batteryAsset = assetStorageService.merge(batteryAsset) - advancePseudoClock(1, TimeUnit.SECONDS, container) // This prevents attribute timestamp issues - - and: "another optimisation run occurs" - optimisationService.runOptimisation(managerTestSetup.electricityOptimisationAssetId, optimisationTime.plus((long)optimiser.intervalSize*60*3, ChronoUnit.MINUTES)) - - then: "the optimisation saving should have decreased by the same amount (i.e. cost)" - conditions.eventually { - assert ((ElectricityStorageAsset)assetStorageService.find(managerTestSetup.electricityBatteryAssetId)).getPowerSetpoint().orElse(0d) == 10d - assert (assetStorageService.find(managerTestSetup.electricityOptimisationAssetId) as EnergyOptimisationAsset).getFinancialSaving().orElse(0d) == -6.0 - } - - when: "the consumer and producer power attributes are updated (as they have changed according to the predicted data)" - assetProcessingService.sendAttributeEvent(new AttributeEvent(managerTestSetup.electricityConsumerAssetId, ElectricityAsset.POWER.name, consumerPower.get(4))) - assetProcessingService.sendAttributeEvent(new AttributeEvent(managerTestSetup.electricitySolarAssetId, ElectricityAsset.POWER.name, producerPower.get(4))) - - then: "the values should have reached the DB" - conditions.eventually { - assert (assetStorageService.find(managerTestSetup.electricityConsumerAssetId) as ElectricityConsumerAsset).getPower().orElse(-1d) == consumerPower.get(4) - assert (assetStorageService.find(managerTestSetup.electricitySolarAssetId) as ElectricityProducerSolarAsset).getPower().orElse(-1d) == producerPower.get(4) - } - - when: "another optimisation run occurs (this should now be exporting from storage)" - batteryAsset.setEnergyLevel(160d) - batteryAsset = assetStorageService.merge(batteryAsset) - advancePseudoClock(1, TimeUnit.SECONDS, container) // This prevents attribute timestamp issues - - and: "another optimisation run occurs" - optimisationService.runOptimisation(managerTestSetup.electricityOptimisationAssetId, optimisationTime.plus((long)optimiser.intervalSize*60*4, ChronoUnit.MINUTES)) - - then: "the optimisation saving should have increased by the cost to import 30kWh (as battery will now be exporting)" - conditions.eventually { - assert ((ElectricityStorageAsset)assetStorageService.find(managerTestSetup.electricityBatteryAssetId)).getPowerSetpoint().orElse(0d) == -10d - assert (assetStorageService.find(managerTestSetup.electricityOptimisationAssetId) as EnergyOptimisationAsset).getFinancialSaving().orElse(0d), closeTo(-6.0+2.4, 0.1) - } + services.replaceAll { + it instanceof EnergyOptimisationService ? spyOptimisationService : it } + def container = startContainer(defaultConfig(), services) + def managerTestSetup = container.getService(SetupService.class).getTaskOfType(ManagerTestSetup.class) + def optimisationService = container.getService(EnergyOptimisationService.class) + def assetStorageService = container.getService(AssetStorageService.class) + def assetProcessingService = container.getService(AssetProcessingService.class) + def assetPredictedDatapointService = container.getService(AssetPredictedDatapointService.class) + def timerService = container.getService(TimerService.class) + + expect: "an optimisation instance should exist" + conditions.eventually { + assert !optimisationService.assetOptimisationInstanceMap.isEmpty() + assert optimisationService.assetOptimisationInstanceMap.get(managerTestSetup.electricityOptimisationAssetId) != null + } + + when: "the pseudo clock is stopped and the system time is set to midnight of next day" + stopPseudoClock() + def now = Instant.ofEpochMilli(timerService.getCurrentTimeMillis()) + now = now.truncatedTo(ChronoUnit.DAYS).plus(1, ChronoUnit.DAYS) + advancePseudoClock(now.toEpochMilli()-timerService.getCurrentTimeMillis(), TimeUnit.MILLISECONDS, container) + + then: "the optimisation start time should be correctly calculated" + def optimiser = optimisationService.assetOptimisationInstanceMap.get(managerTestSetup.electricityOptimisationAssetId).energyOptimiser + def optimisationTime = optimisationService.getOptimisationStartTime(now.toEpochMilli(), (long)optimiser.intervalSize * 60 * 60) + def optimisationDateTime = optimisationTime.atZone(ZoneId.systemDefault()).toLocalDateTime() + assert optimisationTime.isBefore(now) + assert optimisationTime.plus((long)optimiser.intervalSize*60, ChronoUnit.MINUTES).equals(now) + + when: "supplier tariff values are set for the next 24hrs" + def tariffExports = [-5, -2, -8, 2, 2, 5, -2, -2] + def tariffImports = [3, -5, 10, 1, 3, -5, 7, 8] + assetProcessingService.sendAttributeEvent(new AttributeEvent(managerTestSetup.electricitySupplierAssetId, ElectricityAsset.TARIFF_IMPORT.name, tariffImports.get(0))) + assetProcessingService.sendAttributeEvent(new AttributeEvent(managerTestSetup.electricitySupplierAssetId, ElectricityAsset.TARIFF_EXPORT.name, tariffExports.get(0))) + + for (int i = 1; i > { // Don't use the scheduler as we will manually trigger the optimisation for testing + optimisationAssetId, optimiser, startDuration, periodSeconds -> + return null + } + } + + services.replaceAll { + it instanceof EnergyOptimisationService ? spyOptimisationService : it + } + def container = startContainer(defaultConfig(), services) + def managerTestSetup = container.getService(SetupService.class).getTaskOfType(ManagerTestSetup.class) + def optimisationService = container.getService(EnergyOptimisationService.class) + def assetStorageService = container.getService(AssetStorageService.class) + def assetProcessingService = container.getService(AssetProcessingService.class) + def assetPredictedDatapointService = container.getService(AssetPredictedDatapointService.class) + def timerService = container.getService(TimerService.class) + + expect: "an optimisation instance should exist" + conditions.eventually { + assert !optimisationService.assetOptimisationInstanceMap.isEmpty() + assert optimisationService.assetOptimisationInstanceMap.get(managerTestSetup.electricityOptimisationAssetId) != null + } + + when: "the battery energy level is set to lower limit for ease of testing" + def batteryAsset = assetStorageService.find(managerTestSetup.electricityBatteryAssetId) as ElectricityBatteryAsset + batteryAsset.setEnergyLevel(40) + batteryAsset.setPowerImportMax(10) + batteryAsset.setPowerExportMax(10) + batteryAsset = assetStorageService.merge(batteryAsset) + + and: "the pseudo clock is stopped and the system time is set to midnight of two days time" + stopPseudoClock() + def now = Instant.ofEpochMilli(timerService.getCurrentTimeMillis()) + now = now.truncatedTo(ChronoUnit.DAYS).plus(2, ChronoUnit.DAYS) + advancePseudoClock(now.toEpochMilli()-timerService.getCurrentTimeMillis(), TimeUnit.MILLISECONDS, container) + + then: "the optimisation start time should be correctly calculated" + def optimiser = optimisationService.assetOptimisationInstanceMap.get(managerTestSetup.electricityOptimisationAssetId).energyOptimiser + def optimisationTime = optimisationService.getOptimisationStartTime(now.toEpochMilli(), (long)optimiser.intervalSize * 60 * 60) + def optimisationDateTime = optimisationTime.atZone(ZoneId.systemDefault()) + assert optimisationTime.isBefore(now) + assert optimisationTime.plus((long)optimiser.intervalSize*60, ChronoUnit.MINUTES).equals(now) + + and: "consumer and producer prediction values are set for the next 24hrs" + def consumerPower = [0, 0, 0, 0, 10, 10, 10, 10] + def producerPower = [-50, -50, -50, -50, 0, 0, 0, 0] + assetProcessingService.sendAttributeEvent(new AttributeEvent(managerTestSetup.electricityConsumerAssetId, ElectricityAsset.POWER.name, consumerPower.get(0))) + assetProcessingService.sendAttributeEvent(new AttributeEvent(managerTestSetup.electricitySolarAssetId, ElectricityAsset.POWER.name, producerPower.get(0))) + + for (int i = 1; i > optimisedPower = IntStream.range(0, intervalCount).mapToObj{new Pair<>(it, costCalculator.apply(it, [0d, powerImportMax] as double[]))}.collect() - Collections.sort(optimisedPower, Comparator.comparingDouble({Pair optimisedInterval -> optimisedInterval.value[0]})) - - then: "the optimised import values should be correct" - optimisedPower.get(0).key == 5 - optimisedPower.get(0).value[0] == -5d - optimisedPower.get(0).value[1] == 0d - optimisedPower.get(0).value[2] == 7d - optimisedPower.get(1).key == 3 - optimisedPower.get(1).value[0] == 1d - optimisedPower.get(1).value[1] == 0d - optimisedPower.get(1).value[2] == 7d - optimisedPower.get(2).key == 1 - optimisedPower.get(2).value[0] == 2d - optimisedPower.get(2).value[1] == 0d - optimisedPower.get(2).value[2] == 7d - optimisedPower.get(3).key == 6 - optimisedPower.get(3).value[0] == 2d - optimisedPower.get(3).value[1] == 0d - optimisedPower.get(3).value[2] == 5d - optimisedPower.get(4).key == 7 - optimisedPower.get(4).value[0] == 2d - optimisedPower.get(4).value[1] == 0d - optimisedPower.get(4).value[2] == 2d - optimisedPower.get(5).key == 4 - optimisedPower.get(5).value[0] == 3d - optimisedPower.get(5).value[1] == 0d - optimisedPower.get(5).value[2] == 7d - optimisedPower.get(6).key == 0 - optimisedPower.get(6).value[0] closeTo(4.428, 0.001) - optimisedPower.get(6).value[1] == 7d - optimisedPower.get(6).value[2] == 7d - optimisedPower.get(7).key == 2 - optimisedPower.get(7).value[0] == 10d - optimisedPower.get(7).value[1] == 0d - optimisedPower.get(7).value[2] == 7d - - when: "we request the optimised consumption cost values for the EV asset with a minimum power requirement of 6" - optimisedPower = IntStream.range(0, intervalCount).mapToObj{new Pair<>(it, costCalculator.apply(it, [6d, powerImportMax] as double[]))}.collect() - Collections.sort(optimisedPower, Comparator.comparingDouble({Pair optimisedInterval -> optimisedInterval.value[0]})) - - then: "the optimised import values should be correct" - optimisedPower.get(0).key == 5 - optimisedPower.get(0).value[0] == -5d - optimisedPower.get(0).value[1] == 6d - optimisedPower.get(0).value[2] == 7d - optimisedPower.get(1).key == 3 - optimisedPower.get(1).value[0] == 1d - optimisedPower.get(1).value[1] == 6d - optimisedPower.get(1).value[2] == 7d - optimisedPower.get(2).key == 1 - optimisedPower.get(2).value[0] == 2d - optimisedPower.get(2).value[1] == 6d - optimisedPower.get(2).value[2] == 7d - optimisedPower.get(3).key == 6 - optimisedPower.get(3).value[0] closeTo(2.8333, 0.001) - optimisedPower.get(3).value[1] == 6d - optimisedPower.get(3).value[2] == 6d - optimisedPower.get(4).key == 4 - optimisedPower.get(4).value[0] == 3d - optimisedPower.get(4).value[1] == 6d - optimisedPower.get(4).value[2] == 7d - optimisedPower.get(5).key == 0 - optimisedPower.get(5).value[0] closeTo(4.428, 0.001) - optimisedPower.get(5).value[1] == 7d - optimisedPower.get(5).value[2] == 7d - optimisedPower.get(6).key == 7 - optimisedPower.get(6).value[0] == 6d - optimisedPower.get(6).value[1] == 6d - optimisedPower.get(6).value[2] == 6d - optimisedPower.get(7).key == 2 - optimisedPower.get(7).value[0] == 10d - optimisedPower.get(7).value[1] == 6d - optimisedPower.get(7).value[2] == 7d + def gridId = UniqueIdentifierGenerator.generateId("grid") + def intervalCount = 8 + def intervalSize = 24 / intervalCount + def currentInterval = 0 + def tariffExports = [-5, -2, -8, 2, 2, 5, -2, -2] + def tariffImports = [3, -5, 10, 1, 3, -5, 7, 8] + def powerNets = [-5, -25, 15, 0, 0, -5, -5, -2] + + def "Check basic import optimiser"() { + + given: "an energy optimisation instance" + def optimisation = new EnergyOptimiser(intervalSize, 1d) + + and: "some import parameters" + def powerImportMax = 7d + + when: "we request the optimised import power values for the input parameters" + def powerNetLimits = new double[intervalCount] + Arrays.fill(powerNetLimits, 30d) + def costCalculator = optimisation.getImportOptimiser(powerNets as double[], powerNetLimits, tariffImports as double[], tariffExports as double[], 0d) + List> optimisedPower = IntStream.range(0, intervalCount).mapToObj{ + new Pair<>(it, costCalculator.apply(it, [0d, powerImportMax] as double[])) + }.collect() + Collections.sort(optimisedPower, Comparator.comparingDouble({Pair optimisedInterval -> + optimisedInterval.value[0] + })) + + then: "the optimised import values should be correct" + optimisedPower.get(0).key == 5 + optimisedPower.get(0).value[0] == -5d + optimisedPower.get(0).value[1] == 0d + optimisedPower.get(0).value[2] == 7d + optimisedPower.get(1).key == 3 + optimisedPower.get(1).value[0] == 1d + optimisedPower.get(1).value[1] == 0d + optimisedPower.get(1).value[2] == 7d + optimisedPower.get(2).key == 1 + optimisedPower.get(2).value[0] == 2d + optimisedPower.get(2).value[1] == 0d + optimisedPower.get(2).value[2] == 7d + optimisedPower.get(3).key == 6 + optimisedPower.get(3).value[0] == 2d + optimisedPower.get(3).value[1] == 0d + optimisedPower.get(3).value[2] == 5d + optimisedPower.get(4).key == 7 + optimisedPower.get(4).value[0] == 2d + optimisedPower.get(4).value[1] == 0d + optimisedPower.get(4).value[2] == 2d + optimisedPower.get(5).key == 4 + optimisedPower.get(5).value[0] == 3d + optimisedPower.get(5).value[1] == 0d + optimisedPower.get(5).value[2] == 7d + optimisedPower.get(6).key == 0 + optimisedPower.get(6).value[0] closeTo(4.428, 0.001) + optimisedPower.get(6).value[1] == 7d + optimisedPower.get(6).value[2] == 7d + optimisedPower.get(7).key == 2 + optimisedPower.get(7).value[0] == 10d + optimisedPower.get(7).value[1] == 0d + optimisedPower.get(7).value[2] == 7d + + when: "we request the optimised consumption cost values for the EV asset with a minimum power requirement of 6" + optimisedPower = IntStream.range(0, intervalCount).mapToObj{ + new Pair<>(it, costCalculator.apply(it, [6d, powerImportMax] as double[])) + }.collect() + Collections.sort(optimisedPower, Comparator.comparingDouble({Pair optimisedInterval -> + optimisedInterval.value[0] + })) + + then: "the optimised import values should be correct" + optimisedPower.get(0).key == 5 + optimisedPower.get(0).value[0] == -5d + optimisedPower.get(0).value[1] == 6d + optimisedPower.get(0).value[2] == 7d + optimisedPower.get(1).key == 3 + optimisedPower.get(1).value[0] == 1d + optimisedPower.get(1).value[1] == 6d + optimisedPower.get(1).value[2] == 7d + optimisedPower.get(2).key == 1 + optimisedPower.get(2).value[0] == 2d + optimisedPower.get(2).value[1] == 6d + optimisedPower.get(2).value[2] == 7d + optimisedPower.get(3).key == 6 + optimisedPower.get(3).value[0] closeTo(2.8333, 0.001) + optimisedPower.get(3).value[1] == 6d + optimisedPower.get(3).value[2] == 6d + optimisedPower.get(4).key == 4 + optimisedPower.get(4).value[0] == 3d + optimisedPower.get(4).value[1] == 6d + optimisedPower.get(4).value[2] == 7d + optimisedPower.get(5).key == 0 + optimisedPower.get(5).value[0] closeTo(4.428, 0.001) + optimisedPower.get(5).value[1] == 7d + optimisedPower.get(5).value[2] == 7d + optimisedPower.get(6).key == 7 + optimisedPower.get(6).value[0] == 6d + optimisedPower.get(6).value[1] == 6d + optimisedPower.get(6).value[2] == 6d + optimisedPower.get(7).key == 2 + optimisedPower.get(7).value[0] == 10d + optimisedPower.get(7).value[1] == 6d + optimisedPower.get(7).value[2] == 7d + } + + def "Check basic export optimiser"() { + + given: "an energy optimisation instance" + currentInterval = 0 + def optimisation = new EnergyOptimiser(intervalSize, 1d) + + and: "some storage export parameters" + def powerExportMax = -20d + + when: "we request the optimised export power values for the input parameters" + def powerNetLimits = new double[intervalCount] + Arrays.fill(powerNetLimits, -30d) + def costCalculator = optimisation.getExportOptimiser(powerNets as double[], powerNetLimits, tariffImports as double[], tariffExports as double[], 0d) + List> optimisedPower = IntStream.range(0, intervalCount).mapToObj{ + new Pair<>(it, costCalculator.apply(it, powerExportMax)) + }.collect() + Collections.sort(optimisedPower, Comparator.comparingDouble({Pair optimisedInterval -> + optimisedInterval.value[0] + })) + + then: "the optimised export values should be correct" + optimisedPower.get(0).key == 2 + optimisedPower.get(0).value[0] == -9.5d + optimisedPower.get(0).value[1] == -20d + optimisedPower.get(0).value[2] == -20d + optimisedPower.get(1).key == 0 + optimisedPower.get(1).value[0] == -5d + optimisedPower.get(1).value[1] == -20d + optimisedPower.get(1).value[2] == 0d + optimisedPower.get(2).key == 1 + optimisedPower.get(2).value[0] == -2d + optimisedPower.get(2).value[1] == -5d + optimisedPower.get(2).value[2] == 0d + optimisedPower.get(3).key == 6 + optimisedPower.get(3).value[0] == -2d + optimisedPower.get(3).value[1] == -20d + optimisedPower.get(3).value[2] == 0d + optimisedPower.get(4).key == 7 + optimisedPower.get(4).value[0] == -2d + optimisedPower.get(4).value[1] == -20d + optimisedPower.get(4).value[2] == 0d + optimisedPower.get(5).key == 3 + optimisedPower.get(5).value[0] == 2d + optimisedPower.get(5).value[1] == -20d + optimisedPower.get(5).value[2] == 0d + optimisedPower.get(6).key == 4 + optimisedPower.get(6).value[0] == 2d + optimisedPower.get(6).value[1] == -20d + optimisedPower.get(6).value[2] == 0d + optimisedPower.get(7).key == 5 + optimisedPower.get(7).value[0] == 5d + optimisedPower.get(7).value[1] == -20d + optimisedPower.get(7).value[2] == 0d + } + + def "Check energy schedule extraction"() { + + given: "an energy optimisation instance" + currentInterval = 0 + def startOfDay = LocalDateTime.now().truncatedTo(ChronoUnit.DAYS) + def currentTime = startOfDay.plus(23, ChronoUnit.HOURS) + def optimisation = new EnergyOptimiser(intervalSize, 1d) + + and: "some input parameters" + double energyCapacity = 200d + double energyLevelMin = 40d + double energyLevelMax = 160d + double[] energyMinLevels = new double[intervalCount] + double[] energyMaxLevels = new double[intervalCount] + Arrays.fill(energyMinLevels, energyLevelMin) + Arrays.fill(energyMaxLevels, energyLevelMax) + + when: "an energy schedule is defined and the energy min levels generated from this" + int[] energyScheduleDay = [ + 0, + // 00:00 + 0, + 0, + 0, + 0, + 0, + 0, + 80, + 0, + 0, + 0, + 0, + 0, + // 12:00 + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 // 23:00 + ] + int[][] energyScheduleWeek = new int[24][7] + Arrays.fill(energyScheduleWeek, energyScheduleDay) + optimisation.applyEnergySchedule(energyMinLevels, energyMaxLevels, energyCapacity, energyScheduleWeek, currentTime) + + then: "the energy min levels should be correct" + energyMinLevels == [40d, 40d, 160d, 40d, 40d, 40d, 40d, 40d] as double[] + + when: "the energy min levels are regenerated for a different time" + currentTime = currentTime.plus(7, ChronoUnit.HOURS) + Arrays.fill(energyMinLevels, energyLevelMin) + optimisation.applyEnergySchedule(energyMinLevels, energyMaxLevels, energyCapacity, energyScheduleWeek, currentTime) + + then: "the energy min levels should be correct" + energyMinLevels == [160d, 40d, 40d, 40d, 40d, 40d, 40d, 40d] as double[] + + when: "the optimisation is changed to have an interval size less than 1 hour" + energyMinLevels = new double[24*4] + energyMaxLevels = new double[24*4] + Arrays.fill(energyMinLevels, energyLevelMin) + Arrays.fill(energyMaxLevels, energyLevelMax) + optimisation = new EnergyOptimiser(0.25d, 1d) + optimisation.applyEnergySchedule(energyMinLevels, energyMaxLevels, energyCapacity, energyScheduleWeek, currentTime) + + then: "the energy min levels should be correct" + energyMinLevels[0] == 160d + IntStream.range(1, energyMinLevels.length).mapToDouble{ + energyMinLevels[it] + }.allMatch{ + it == 40d } - - def "Check basic export optimiser"() { - - given: "an energy optimisation instance" - currentInterval = 0 - def optimisation = new EnergyOptimiser(intervalSize, 1d) - - and: "some storage export parameters" - def powerExportMax = -20d - - when: "we request the optimised export power values for the input parameters" - def powerNetLimits = new double[intervalCount] - Arrays.fill(powerNetLimits, -30d) - def costCalculator = optimisation.getExportOptimiser(powerNets as double[], powerNetLimits, tariffImports as double[], tariffExports as double[], 0d) - List> optimisedPower = IntStream.range(0, intervalCount).mapToObj{new Pair<>(it, costCalculator.apply(it, powerExportMax))}.collect() - Collections.sort(optimisedPower, Comparator.comparingDouble({Pair optimisedInterval -> optimisedInterval.value[0]})) - - then: "the optimised export values should be correct" - optimisedPower.get(0).key == 2 - optimisedPower.get(0).value[0] == -9.5d - optimisedPower.get(0).value[1] == -20d - optimisedPower.get(0).value[2] == -20d - optimisedPower.get(1).key == 0 - optimisedPower.get(1).value[0] == -5d - optimisedPower.get(1).value[1] == -20d - optimisedPower.get(1).value[2] == 0d - optimisedPower.get(2).key == 1 - optimisedPower.get(2).value[0] == -2d - optimisedPower.get(2).value[1] == -5d - optimisedPower.get(2).value[2] == 0d - optimisedPower.get(3).key == 6 - optimisedPower.get(3).value[0] == -2d - optimisedPower.get(3).value[1] == -20d - optimisedPower.get(3).value[2] == 0d - optimisedPower.get(4).key == 7 - optimisedPower.get(4).value[0] == -2d - optimisedPower.get(4).value[1] == -20d - optimisedPower.get(4).value[2] == 0d - optimisedPower.get(5).key == 3 - optimisedPower.get(5).value[0] == 2d - optimisedPower.get(5).value[1] == -20d - optimisedPower.get(5).value[2] == 0d - optimisedPower.get(6).key == 4 - optimisedPower.get(6).value[0] == 2d - optimisedPower.get(6).value[1] == -20d - optimisedPower.get(6).value[2] == 0d - optimisedPower.get(7).key == 5 - optimisedPower.get(7).value[0] == 5d - optimisedPower.get(7).value[1] == -20d - optimisedPower.get(7).value[2] == 0d - } - - def "Check energy schedule extraction"() { - - given: "an energy optimisation instance" - currentInterval = 0 - def startOfDay = LocalDateTime.now().truncatedTo(ChronoUnit.DAYS) - def currentTime = startOfDay.plus(23, ChronoUnit.HOURS) - def optimisation = new EnergyOptimiser(intervalSize, 1d) - - and: "some input parameters" - double energyCapacity = 200d - double energyLevelMin = 40d - double energyLevelMax = 160d - double[] energyMinLevels = new double[intervalCount] - double[] energyMaxLevels = new double[intervalCount] - Arrays.fill(energyMinLevels, energyLevelMin) - Arrays.fill(energyMaxLevels, energyLevelMax) - - when: "an energy schedule is defined and the energy min levels generated from this" - int[] energyScheduleDay = [ - 0, // 00:00 - 0, - 0, - 0, - 0, - 0, - 0, - 80, - 0, - 0, - 0, - 0, - 0, // 12:00 - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 // 23:00 - ] - int[][] energyScheduleWeek = new int[24][7] - Arrays.fill(energyScheduleWeek, energyScheduleDay) - optimisation.applyEnergySchedule(energyMinLevels, energyMaxLevels, energyCapacity, energyScheduleWeek, currentTime) - - then: "the energy min levels should be correct" - energyMinLevels == [40d, 40d, 160d, 40d, 40d, 40d, 40d, 40d] as double[] - - when: "the energy min levels are regenerated for a different time" - currentTime = currentTime.plus(7, ChronoUnit.HOURS) - Arrays.fill(energyMinLevels, energyLevelMin) - optimisation.applyEnergySchedule(energyMinLevels, energyMaxLevels, energyCapacity, energyScheduleWeek, currentTime) - - then: "the energy min levels should be correct" - energyMinLevels == [160d, 40d, 40d, 40d, 40d, 40d, 40d, 40d] as double[] - - when: "the optimisation is changed to have an interval size less than 1 hour" - energyMinLevels = new double[24*4] - energyMaxLevels = new double[24*4] - Arrays.fill(energyMinLevels, energyLevelMin) - Arrays.fill(energyMaxLevels, energyLevelMax) - optimisation = new EnergyOptimiser(0.25d, 1d) - optimisation.applyEnergySchedule(energyMinLevels, energyMaxLevels, energyCapacity, energyScheduleWeek, currentTime) - - then: "the energy min levels should be correct" - energyMinLevels[0] == 160d - IntStream.range(1, energyMinLevels.length).mapToDouble{energyMinLevels[it]}.allMatch{it == 40d} - } - - def "Check full storage optimisation functionality"() { - - given: "an energy optimisation instance" - currentInterval = 0 - def startOfDay = LocalDateTime.now().truncatedTo(ChronoUnit.DAYS) - def currentTime = startOfDay.plus(23, ChronoUnit.HOURS) - def optimisation = new EnergyOptimiser(intervalSize, 1d) - - and: "some input parameters" - double energyCapacity = 200d - double energyLevelMin = 40d - double energyLevelMax = 160d - double currentEnergyLevel = 100d - double[] powerSetpoints = new double[intervalCount] - double[] energyMinLevels = new double[intervalCount] - double[] energyMaxLevels = new double[intervalCount] - Arrays.fill(energyMinLevels, energyLevelMin) - Arrays.fill(energyMaxLevels, energyLevelMax) - Function powerImportMaxCalculator = {interval -> 7d} - Function powerExportMaxCalculator = {interval -> -20d} - - when: "an energy schedule is defined and the energy min levels generated from this" - int[] energyScheduleDay = [ - 0, // 00:00 - 0, - 0, - 0, - 0, - 0, - 0, - 80, - 0, - 0, - 0, - 0, - 0, // 12:00 - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 // 23:00 - ] - int[][] energyScheduleWeek = new int[24][7] - Arrays.fill(energyScheduleWeek, energyScheduleDay) - optimisation.applyEnergySchedule(energyMinLevels, energyMaxLevels, energyCapacity, energyScheduleWeek, currentTime) - - then: "the energy min levels should be correct" - energyMinLevels == [40d, 40d, 160d, 40d, 40d, 40d, 40d, 40d] as double[] - - when: "the energy min levels are normalised" - optimisation.normaliseEnergyMinRequirements(energyMinLevels, powerImportMaxCalculator, powerExportMaxCalculator, currentEnergyLevel) - - then: "the energy min levels should be correctly normalised" - energyMinLevels == [118d, 139d, 160d, 100d, 40d, 40d, 40d, 40d] as double[] - - when: "the energy min levels are modified to improve code coverage in this test" - Arrays.fill(energyMaxLevels, 170d) - energyMinLevels = [133d, 150d, 166d, 130d, 130d, 100d, 10d, 100d] as double[] - - and: "the energy min levels are normalised" - optimisation.normaliseEnergyMinRequirements(energyMinLevels, powerImportMaxCalculator, powerExportMaxCalculator, currentEnergyLevel) - - then: "the energy min levels should be correctly normalised" - energyMinLevels == [121d, 142d, 163d, 130d, 130d, 100d, 79d, 100d] as double[] - - when: "the import requirements are optimised" - def powerNetLimits = new double[intervalCount] - Arrays.fill(powerNetLimits, 30d) - def importCostCalculator = optimisation.getImportOptimiser(powerNets as double[], powerNetLimits, tariffImports as double[], tariffExports as double[], 0d) - double[][] optimisedImport = IntStream.range(0, intervalCount).mapToObj{importCostCalculator.apply(it, [0d, powerImportMaxCalculator.apply(it)] as double[])}.toArray({new double[it][1]}) - - and: "the export requirements are optimised" - Arrays.fill(powerNetLimits, -30d) - def exportCostCalculator = optimisation.getExportOptimiser(powerNets as double[], powerNetLimits, tariffImports as double[], tariffExports as double[], 0d) - double[][] optimisedExport = IntStream.range(0, intervalCount).mapToObj{exportCostCalculator.apply(it, powerExportMaxCalculator.apply(it))}.toArray({new double[it][1]}) - - and: "the applyEnergyMinImports routine is run on the input parameters" - Function energyLevelCalculator = {int interval -> - currentEnergyLevel + IntStream.range(0, interval).mapToDouble({j -> powerSetpoints[j] * intervalSize}).sum() - } - optimisation.applyEnergyMinImports(optimisedImport, energyMinLevels, powerSetpoints, energyLevelCalculator, importCostCalculator, powerImportMaxCalculator) - - then: "the power setpoints should have been updated to meet energy min requirements in the most cost effective way" - powerSetpoints[0] == 7d - powerSetpoints[1] == 7d - powerSetpoints[2] == 7d - powerSetpoints[3] == 0d - powerSetpoints[4] == 0d - powerSetpoints[5] == 0d - powerSetpoints[6] == 0d - powerSetpoints[7] == 0d - - when: "the earning opportunities are applied to the power setpoints" - optimisation.applyEarningOpportunities(optimisedImport, optimisedExport, energyMinLevels, energyMaxLevels, powerSetpoints, energyLevelCalculator, powerImportMaxCalculator, powerExportMaxCalculator) - - then: "the power setpoints should have been updated to reflect utilisation of earning opportunities" - powerSetpoints[0] == 7d - powerSetpoints[1] == 7d - powerSetpoints[2] == 7d - powerSetpoints[3] == 0d - powerSetpoints[4] closeTo(-4.66666, 0.0001) - powerSetpoints[5] == 7d - powerSetpoints[6] == -20d - powerSetpoints[7] closeTo(-3.33333, 0.0001) + } + + def "Check full storage optimisation functionality"() { + + given: "an energy optimisation instance" + currentInterval = 0 + def startOfDay = LocalDateTime.now().truncatedTo(ChronoUnit.DAYS) + def currentTime = startOfDay.plus(23, ChronoUnit.HOURS) + def optimisation = new EnergyOptimiser(intervalSize, 1d) + + and: "some input parameters" + double energyCapacity = 200d + double energyLevelMin = 40d + double energyLevelMax = 160d + double currentEnergyLevel = 100d + double[] powerSetpoints = new double[intervalCount] + double[] energyMinLevels = new double[intervalCount] + double[] energyMaxLevels = new double[intervalCount] + Arrays.fill(energyMinLevels, energyLevelMin) + Arrays.fill(energyMaxLevels, energyLevelMax) + Function powerImportMaxCalculator = {interval -> 7d} + Function powerExportMaxCalculator = {interval -> -20d} + + when: "an energy schedule is defined and the energy min levels generated from this" + int[] energyScheduleDay = [ + 0, + // 00:00 + 0, + 0, + 0, + 0, + 0, + 0, + 80, + 0, + 0, + 0, + 0, + 0, + // 12:00 + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 // 23:00 + ] + int[][] energyScheduleWeek = new int[24][7] + Arrays.fill(energyScheduleWeek, energyScheduleDay) + optimisation.applyEnergySchedule(energyMinLevels, energyMaxLevels, energyCapacity, energyScheduleWeek, currentTime) + + then: "the energy min levels should be correct" + energyMinLevels == [40d, 40d, 160d, 40d, 40d, 40d, 40d, 40d] as double[] + + when: "the energy min levels are normalised" + optimisation.normaliseEnergyMinRequirements(energyMinLevels, powerImportMaxCalculator, powerExportMaxCalculator, currentEnergyLevel) + + then: "the energy min levels should be correctly normalised" + energyMinLevels == [118d, 139d, 160d, 100d, 40d, 40d, 40d, 40d] as double[] + + when: "the energy min levels are modified to improve code coverage in this test" + Arrays.fill(energyMaxLevels, 170d) + energyMinLevels = [133d, 150d, 166d, 130d, 130d, 100d, 10d, 100d] as double[] + + and: "the energy min levels are normalised" + optimisation.normaliseEnergyMinRequirements(energyMinLevels, powerImportMaxCalculator, powerExportMaxCalculator, currentEnergyLevel) + + then: "the energy min levels should be correctly normalised" + energyMinLevels == [121d, 142d, 163d, 130d, 130d, 100d, 79d, 100d] as double[] + + when: "the import requirements are optimised" + def powerNetLimits = new double[intervalCount] + Arrays.fill(powerNetLimits, 30d) + def importCostCalculator = optimisation.getImportOptimiser(powerNets as double[], powerNetLimits, tariffImports as double[], tariffExports as double[], 0d) + double[][] optimisedImport = IntStream.range(0, intervalCount).mapToObj{ + importCostCalculator.apply(it, [0d, powerImportMaxCalculator.apply(it)] as double[]) + }.toArray({ + new double[it][1] + }) + + and: "the export requirements are optimised" + Arrays.fill(powerNetLimits, -30d) + def exportCostCalculator = optimisation.getExportOptimiser(powerNets as double[], powerNetLimits, tariffImports as double[], tariffExports as double[], 0d) + double[][] optimisedExport = IntStream.range(0, intervalCount).mapToObj{ + exportCostCalculator.apply(it, powerExportMaxCalculator.apply(it)) + }.toArray({ + new double[it][1] + }) + + and: "the applyEnergyMinImports routine is run on the input parameters" + Function energyLevelCalculator = {int interval -> + currentEnergyLevel + IntStream.range(0, interval).mapToDouble({j -> + powerSetpoints[j] * intervalSize + }).sum() } + optimisation.applyEnergyMinImports(optimisedImport, energyMinLevels, powerSetpoints, energyLevelCalculator, importCostCalculator, powerImportMaxCalculator) + + then: "the power setpoints should have been updated to meet energy min requirements in the most cost effective way" + powerSetpoints[0] == 7d + powerSetpoints[1] == 7d + powerSetpoints[2] == 7d + powerSetpoints[3] == 0d + powerSetpoints[4] == 0d + powerSetpoints[5] == 0d + powerSetpoints[6] == 0d + powerSetpoints[7] == 0d + + when: "the earning opportunities are applied to the power setpoints" + optimisation.applyEarningOpportunities(optimisedImport, optimisedExport, energyMinLevels, energyMaxLevels, powerSetpoints, energyLevelCalculator, powerImportMaxCalculator, powerExportMaxCalculator) + + then: "the power setpoints should have been updated to reflect utilisation of earning opportunities" + powerSetpoints[0] == 7d + powerSetpoints[1] == 7d + powerSetpoints[2] == 7d + powerSetpoints[3] == 0d + powerSetpoints[4] closeTo(-4.66666, 0.0001) + powerSetpoints[5] == 7d + powerSetpoints[6] == -20d + powerSetpoints[7] closeTo(-3.33333, 0.0001) + } } diff --git a/energy/src/test/groovy/org/openremote/extension/energy/ForecastSolarServiceTest.groovy b/energy/src/test/groovy/org/openremote/extension/energy/ForecastSolarServiceTest.groovy index ecd17b8..67bca10 100644 --- a/energy/src/test/groovy/org/openremote/extension/energy/ForecastSolarServiceTest.groovy +++ b/energy/src/test/groovy/org/openremote/extension/energy/ForecastSolarServiceTest.groovy @@ -47,186 +47,200 @@ import static org.openremote.extension.energy.manager.ForecastSolarService.OR_FO @Ignore class ForecastSolarServiceTest extends Specification implements ManagerContainerTrait { - @Shared - def mockServer = new ClientRequestFilter() { - - boolean finished = false - - @Override - void filter(ClientRequestContext requestContext) throws IOException { - if (finished) return - - def requestUri = requestContext.uri - - switch (requestUri.host) { - case "api.forecast.solar": - def now = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS) - def content = "{\n" + - " \"result\": {\n" + - " \"watts\": {\n"+ - " \"${now.toLocalDate().toString()} ${now.toLocalTime().toString()}\": 0,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(15).toLocalTime().toString()}\": 780000,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(30).toLocalTime().toString()}\": 3904036,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(45).toLocalTime().toString()}\": 3854598,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(60).toLocalTime().toString()}\": 3746874,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(75).toLocalTime().toString()}\": 3675780,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(90).toLocalTime().toString()}\": 3553500,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(105).toLocalTime().toString()}\": 3439679,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(120).toLocalTime().toString()}\": 3278604,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(135).toLocalTime().toString()}\": 3137450,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(150).toLocalTime().toString()}\": 2954064,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(165).toLocalTime().toString()}\": 2739056,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(180).toLocalTime().toString()}\": 2493609,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(195).toLocalTime().toString()}\": 2254420,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(210).toLocalTime().toString()}\": 1949976,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(225).toLocalTime().toString()}\": 1697104,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(240).toLocalTime().toString()}\": 1452142,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(255).toLocalTime().toString()}\": 1194060,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(270).toLocalTime().toString()}\": 925642,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(285).toLocalTime().toString()}\": 761949,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(300).toLocalTime().toString()}\": 594217,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(315).toLocalTime().toString()}\": 423624,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(330).toLocalTime().toString()}\": 80000,\n"+ - " \"${now.plusDays(1).toLocalDate().toString()} ${now.toLocalTime().toString()}\": 0\n"+ - " },\n"+ - " \"watt_hours\": {\n"+ - " \"${now.toLocalDate().toString()} ${now.toLocalTime().toString()}\": 0,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(15).toLocalTime().toString()}\": 3471000,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(30).toLocalTime().toString()}\": 20778893,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(45).toLocalTime().toString()}\": 21742542,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(60).toLocalTime().toString()}\": 22679261,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(75).toLocalTime().toString()}\": 23598206,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(90).toLocalTime().toString()}\": 24486581,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(105).toLocalTime().toString()}\": 25346501,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(120).toLocalTime().toString()}\": 26166152,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(135).toLocalTime().toString()}\": 26950514,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(150).toLocalTime().toString()}\": 27689030,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(165).toLocalTime().toString()}\": 28373794,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(180).toLocalTime().toString()}\": 28997196,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(195).toLocalTime().toString()}\": 29560801,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(210).toLocalTime().toString()}\": 30048295,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(225).toLocalTime().toString()}\": 30472571,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(240).toLocalTime().toString()}\": 30835607,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(255).toLocalTime().toString()}\": 31134122,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(270).toLocalTime().toString()}\": 31365532,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(285).toLocalTime().toString()}\": 31556020,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(300).toLocalTime().toString()}\": 31704574,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(315).toLocalTime().toString()}\": 31810480,\n"+ - " \"${now.toLocalDate().toString()} ${now.plusMinutes(330).toLocalTime().toString()}\": 31817147,\n"+ - " \"${now.plusDays(1).toLocalDate().toString()} ${now.toLocalTime().toString()}\": 0\n"+ - " },\n"+ - " \"watt_hours_day\": {\n"+ - " \"${now.toLocalDate().toString()}\": 31817147\n"+ - " }\n"+ - " },\n"+ - " \"message\": {\n"+ - " \"code\": 0,\n"+ - " \"type\": \"success\",\n"+ - " \"text\": \"\",\n"+ - " \"info\": {\n"+ - " \"latitude\": 51.4969,\n"+ - " \"longitude\": -0.2773,\n"+ - " \"place\": \"W3 8BZ Turnham Green, Hounslow London Boro, England, GB\",\n"+ - " \"timezone\": \"Europe/London\"\n"+ - " },\n"+ - " \"ratelimit\": {\n"+ - " \"period\": 60,\n"+ - " \"limit\": 5,\n"+ - " \"remaining\": 4\n"+ - " }\n"+ - " }\n"+ - "}" - def responseBody = ValueUtil.JSON.readValue(content, ForecastSolarService.EstimateResponse.class) - requestContext.abortWith( - Response.ok(responseBody, MediaType.APPLICATION_JSON_TYPE).build() - ) - return - } - - requestContext.abortWith(Response.serverError().build()) - } + @Shared + def mockServer = new ClientRequestFilter() { + + boolean finished = false + + @Override + void filter(ClientRequestContext requestContext) throws IOException { + if (finished) return + + def requestUri = requestContext.uri + + switch (requestUri.host) { + case "api.forecast.solar": + def now = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS) + def content = "{\n" + + " \"result\": {\n" + + " \"watts\": {\n"+ + " \"${now.toLocalDate().toString()} ${now.toLocalTime().toString()}\": 0,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(15).toLocalTime().toString()}\": 780000,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(30).toLocalTime().toString()}\": 3904036,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(45).toLocalTime().toString()}\": 3854598,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(60).toLocalTime().toString()}\": 3746874,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(75).toLocalTime().toString()}\": 3675780,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(90).toLocalTime().toString()}\": 3553500,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(105).toLocalTime().toString()}\": 3439679,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(120).toLocalTime().toString()}\": 3278604,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(135).toLocalTime().toString()}\": 3137450,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(150).toLocalTime().toString()}\": 2954064,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(165).toLocalTime().toString()}\": 2739056,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(180).toLocalTime().toString()}\": 2493609,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(195).toLocalTime().toString()}\": 2254420,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(210).toLocalTime().toString()}\": 1949976,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(225).toLocalTime().toString()}\": 1697104,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(240).toLocalTime().toString()}\": 1452142,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(255).toLocalTime().toString()}\": 1194060,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(270).toLocalTime().toString()}\": 925642,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(285).toLocalTime().toString()}\": 761949,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(300).toLocalTime().toString()}\": 594217,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(315).toLocalTime().toString()}\": 423624,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(330).toLocalTime().toString()}\": 80000,\n"+ + " \"${now.plusDays(1).toLocalDate().toString()} ${now.toLocalTime().toString()}\": 0\n"+ + " },\n"+ + " \"watt_hours\": {\n"+ + " \"${now.toLocalDate().toString()} ${now.toLocalTime().toString()}\": 0,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(15).toLocalTime().toString()}\": 3471000,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(30).toLocalTime().toString()}\": 20778893,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(45).toLocalTime().toString()}\": 21742542,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(60).toLocalTime().toString()}\": 22679261,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(75).toLocalTime().toString()}\": 23598206,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(90).toLocalTime().toString()}\": 24486581,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(105).toLocalTime().toString()}\": 25346501,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(120).toLocalTime().toString()}\": 26166152,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(135).toLocalTime().toString()}\": 26950514,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(150).toLocalTime().toString()}\": 27689030,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(165).toLocalTime().toString()}\": 28373794,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(180).toLocalTime().toString()}\": 28997196,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(195).toLocalTime().toString()}\": 29560801,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(210).toLocalTime().toString()}\": 30048295,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(225).toLocalTime().toString()}\": 30472571,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(240).toLocalTime().toString()}\": 30835607,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(255).toLocalTime().toString()}\": 31134122,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(270).toLocalTime().toString()}\": 31365532,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(285).toLocalTime().toString()}\": 31556020,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(300).toLocalTime().toString()}\": 31704574,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(315).toLocalTime().toString()}\": 31810480,\n"+ + " \"${now.toLocalDate().toString()} ${now.plusMinutes(330).toLocalTime().toString()}\": 31817147,\n"+ + " \"${now.plusDays(1).toLocalDate().toString()} ${now.toLocalTime().toString()}\": 0\n"+ + " },\n"+ + " \"watt_hours_day\": {\n"+ + " \"${now.toLocalDate().toString()}\": 31817147\n"+ + " }\n"+ + " },\n"+ + " \"message\": {\n"+ + " \"code\": 0,\n"+ + " \"type\": \"success\",\n"+ + " \"text\": \"\",\n"+ + " \"info\": {\n"+ + " \"latitude\": 51.4969,\n"+ + " \"longitude\": -0.2773,\n"+ + " \"place\": \"W3 8BZ Turnham Green, Hounslow London Boro, England, GB\",\n"+ + " \"timezone\": \"Europe/London\"\n"+ + " },\n"+ + " \"ratelimit\": {\n"+ + " \"period\": 60,\n"+ + " \"limit\": 5,\n"+ + " \"remaining\": 4\n"+ + " }\n"+ + " }\n"+ + "}" + def responseBody = ValueUtil.JSON.readValue(content, ForecastSolarService.EstimateResponse.class) + requestContext.abortWith( + Response.ok(responseBody, MediaType.APPLICATION_JSON_TYPE).build() + ) + return + } + + requestContext.abortWith(Response.serverError().build()) + } + } + + def "Test adding and removing asset with enabled attributes"() { + given: "the container environment is started" + def conditions = new PollingConditions(timeout: 10, delay: 0.2) + def config = defaultConfig() + config << [(OR_FORECAST_SOLAR_API_KEY): "test-key"] + WebTargetBuilder.client.register(mockServer, Integer.MAX_VALUE) + + def container = startContainer(config, defaultServices()) + def managerTestSetup = container.getService(SetupService.class).getTaskOfType(ManagerTestSetup.class) + def assetStorageService = container.getService(AssetStorageService.class) + def assetPredictedDatapointService = container.getService(AssetPredictedDatapointService.class) + def assetProcessingService = container.getService(AssetProcessingService.class) + def forecastSolarService = container.getService(ForecastSolarService.class) + + expect: "the asset should have filled in value for power and powerForecast" + conditions.eventually { + def solarAsset = assetStorageService.find(managerTestSetup.electricitySolarAssetId) + assert solarAsset.getAttribute(ElectricityProducerSolarAsset.POWER).flatMap { + it.value + }.orElse(0d) != 0d + assert solarAsset.getAttribute(ElectricityProducerSolarAsset.POWER_FORECAST).flatMap { + it.value + }.orElse(0d) != 0d + assert assetPredictedDatapointService.getDatapoints(new AttributeRef(solarAsset.getId(), ElectricityProducerSolarAsset.POWER.getName())).size() > 0 + assert assetPredictedDatapointService.getDatapoints(new AttributeRef(solarAsset.getId(), ElectricityProducerSolarAsset.POWER_FORECAST.getName())).size() > 0 + } + + when: "an asset is added with includeForecastSolarService set to true" + def newSolarAsset = new ElectricityProducerSolarAsset("SolarAsset") + newSolarAsset.setParentId(managerTestSetup.electricityOptimisationAssetId) + newSolarAsset.setRealm(managerTestSetup.realmEnergyName) + newSolarAsset.setPanelAzimuth(0) + newSolarAsset.setPanelPitch(30) + newSolarAsset.setEfficiencyExport(100) + newSolarAsset.setPowerExportMax(2.5) + newSolarAsset.setLocation(new GeoJSONPoint(9.195295, 48.787418)) + newSolarAsset.setSetActualSolarValueWithForecast(true) + newSolarAsset.setIncludeForecastSolarService(true) + newSolarAsset = assetStorageService.merge(newSolarAsset) + + then: "the asset attributes and datapoints should be updated" + conditions.eventually { + newSolarAsset = assetStorageService.find(newSolarAsset.getId()) + assert newSolarAsset.getAttribute(ElectricityProducerSolarAsset.POWER).flatMap { + it.value + }.orElse(0d) != 0d + assert newSolarAsset.getAttribute(ElectricityProducerSolarAsset.POWER_FORECAST).flatMap { + it.value + }.orElse(0d) != 0d + assert assetPredictedDatapointService.getDatapoints(new AttributeRef(newSolarAsset.getId(), ElectricityProducerSolarAsset.POWER.getName())).size() > 0 + assert assetPredictedDatapointService.getDatapoints(new AttributeRef(newSolarAsset.getId(), ElectricityProducerSolarAsset.POWER_FORECAST.getName())).size() > 0 } - def "Test adding and removing asset with enabled attributes"() { - given: "the container environment is started" - def conditions = new PollingConditions(timeout: 10, delay: 0.2) - def config = defaultConfig() - config << [(OR_FORECAST_SOLAR_API_KEY): "test-key"] - WebTargetBuilder.client.register(mockServer, Integer.MAX_VALUE) - - def container = startContainer(config, defaultServices()) - def managerTestSetup = container.getService(SetupService.class).getTaskOfType(ManagerTestSetup.class) - def assetStorageService = container.getService(AssetStorageService.class) - def assetPredictedDatapointService = container.getService(AssetPredictedDatapointService.class) - def assetProcessingService = container.getService(AssetProcessingService.class) - def forecastSolarService = container.getService(ForecastSolarService.class) - - expect: "the asset should have filled in value for power and powerForecast" - conditions.eventually { - def solarAsset = assetStorageService.find(managerTestSetup.electricitySolarAssetId) - assert solarAsset.getAttribute(ElectricityProducerSolarAsset.POWER).flatMap { it.value }.orElse(0d) != 0d - assert solarAsset.getAttribute(ElectricityProducerSolarAsset.POWER_FORECAST).flatMap { it.value }.orElse(0d) != 0d - assert assetPredictedDatapointService.getDatapoints(new AttributeRef(solarAsset.getId(), ElectricityProducerSolarAsset.POWER.getName())).size() > 0 - assert assetPredictedDatapointService.getDatapoints(new AttributeRef(solarAsset.getId(), ElectricityProducerSolarAsset.POWER_FORECAST.getName())).size() > 0 - } - - when: "an asset is added with includeForecastSolarService set to true" - def newSolarAsset = new ElectricityProducerSolarAsset("SolarAsset") - newSolarAsset.setParentId(managerTestSetup.electricityOptimisationAssetId) - newSolarAsset.setRealm(managerTestSetup.realmEnergyName) - newSolarAsset.setPanelAzimuth(0) - newSolarAsset.setPanelPitch(30) - newSolarAsset.setEfficiencyExport(100) - newSolarAsset.setPowerExportMax(2.5) - newSolarAsset.setLocation(new GeoJSONPoint(9.195295, 48.787418)) - newSolarAsset.setSetActualSolarValueWithForecast(true) - newSolarAsset.setIncludeForecastSolarService(true) - newSolarAsset = assetStorageService.merge(newSolarAsset) - - then: "the asset attributes and datapoints should be updated" - conditions.eventually { - newSolarAsset = assetStorageService.find(newSolarAsset.getId()) - assert newSolarAsset.getAttribute(ElectricityProducerSolarAsset.POWER).flatMap { it.value }.orElse(0d) != 0d - assert newSolarAsset.getAttribute(ElectricityProducerSolarAsset.POWER_FORECAST).flatMap { it.value }.orElse(0d) != 0d - assert assetPredictedDatapointService.getDatapoints(new AttributeRef(newSolarAsset.getId(), ElectricityProducerSolarAsset.POWER.getName())).size() > 0 - assert assetPredictedDatapointService.getDatapoints(new AttributeRef(newSolarAsset.getId(), ElectricityProducerSolarAsset.POWER_FORECAST.getName())).size() > 0 - } - - when: "an asset is added with includeForecastSolarService set to false" - def newSolarAsset2 = new ElectricityProducerSolarAsset("SolarAsset2") - newSolarAsset2.setParentId(managerTestSetup.electricityOptimisationAssetId) - newSolarAsset2.setRealm(managerTestSetup.realmEnergyName) - newSolarAsset2.setPanelAzimuth(0); - newSolarAsset2.setPanelPitch(30); - newSolarAsset2.setEfficiencyExport(100); - newSolarAsset2.setPowerExportMax(2.5); - newSolarAsset2.setLocation(new GeoJSONPoint(9.195275, 48.787418)); - newSolarAsset2.setSetActualSolarValueWithForecast(false); - newSolarAsset2.setIncludeForecastSolarService(false); - newSolarAsset2 = assetStorageService.merge(newSolarAsset2) - - and: "an asset updated its includeForecastSolarService to true" - assetProcessingService.sendAttributeEvent(new AttributeEvent(newSolarAsset2.getId(), ElectricityProducerSolarAsset.INCLUDE_FORECAST_SOLAR_SERVICE.name, true)) - - then: "the asset attributes and datapoints should be updated" - conditions.eventually { - newSolarAsset2 = assetStorageService.find(newSolarAsset2.getId()) - assert newSolarAsset2.getAttribute(ElectricityProducerSolarAsset.POWER).flatMap { it.value }.orElse(0d) == 0d - assert newSolarAsset2.getAttribute(ElectricityProducerSolarAsset.POWER_FORECAST).flatMap { it.value }.orElse(0d) != 0d - assert assetPredictedDatapointService.getDatapoints(new AttributeRef(newSolarAsset2.getId(), ElectricityProducerSolarAsset.POWER.getName())).size() > 0 - assert assetPredictedDatapointService.getDatapoints(new AttributeRef(newSolarAsset2.getId(), ElectricityProducerSolarAsset.POWER_FORECAST.getName())).size() > 0 - } - - when: "an asset updated it's setActualValueWithForecast to true" - assetProcessingService.sendAttributeEvent(new AttributeEvent(newSolarAsset2.getId(), ElectricityProducerSolarAsset.SET_ACTUAL_SOLAR_VALUE_WITH_FORECAST.name, true)) - - then: "it power should be updated too" - conditions.eventually { - newSolarAsset2 = assetStorageService.find(newSolarAsset2.getId()) - assert newSolarAsset2.getAttribute(ElectricityProducerSolarAsset.POWER).flatMap { it.value }.orElse(0d) != 0d - } - - cleanup: "disable mock filter" - mockServer.finished = true + when: "an asset is added with includeForecastSolarService set to false" + def newSolarAsset2 = new ElectricityProducerSolarAsset("SolarAsset2") + newSolarAsset2.setParentId(managerTestSetup.electricityOptimisationAssetId) + newSolarAsset2.setRealm(managerTestSetup.realmEnergyName) + newSolarAsset2.setPanelAzimuth(0) + newSolarAsset2.setPanelPitch(30) + newSolarAsset2.setEfficiencyExport(100) + newSolarAsset2.setPowerExportMax(2.5) + newSolarAsset2.setLocation(new GeoJSONPoint(9.195275, 48.787418)) + newSolarAsset2.setSetActualSolarValueWithForecast(false) + newSolarAsset2.setIncludeForecastSolarService(false) + newSolarAsset2 = assetStorageService.merge(newSolarAsset2) + + and: "an asset updated its includeForecastSolarService to true" + assetProcessingService.sendAttributeEvent(new AttributeEvent(newSolarAsset2.getId(), ElectricityProducerSolarAsset.INCLUDE_FORECAST_SOLAR_SERVICE.name, true)) + + then: "the asset attributes and datapoints should be updated" + conditions.eventually { + newSolarAsset2 = assetStorageService.find(newSolarAsset2.getId()) + assert newSolarAsset2.getAttribute(ElectricityProducerSolarAsset.POWER).flatMap { + it.value + }.orElse(0d) == 0d + assert newSolarAsset2.getAttribute(ElectricityProducerSolarAsset.POWER_FORECAST).flatMap { + it.value + }.orElse(0d) != 0d + assert assetPredictedDatapointService.getDatapoints(new AttributeRef(newSolarAsset2.getId(), ElectricityProducerSolarAsset.POWER.getName())).size() > 0 + assert assetPredictedDatapointService.getDatapoints(new AttributeRef(newSolarAsset2.getId(), ElectricityProducerSolarAsset.POWER_FORECAST.getName())).size() > 0 } + + when: "an asset updated it's setActualValueWithForecast to true" + assetProcessingService.sendAttributeEvent(new AttributeEvent(newSolarAsset2.getId(), ElectricityProducerSolarAsset.SET_ACTUAL_SOLAR_VALUE_WITH_FORECAST.name, true)) + + then: "it power should be updated too" + conditions.eventually { + newSolarAsset2 = assetStorageService.find(newSolarAsset2.getId()) + assert newSolarAsset2.getAttribute(ElectricityProducerSolarAsset.POWER).flatMap { + it.value + }.orElse(0d) != 0d + } + + cleanup: "disable mock filter" + mockServer.finished = true + } } diff --git a/energy/src/test/groovy/org/openremote/extension/energy/ForecastWindServiceTest.groovy b/energy/src/test/groovy/org/openremote/extension/energy/ForecastWindServiceTest.groovy index 62690f6..f48e576 100644 --- a/energy/src/test/groovy/org/openremote/extension/energy/ForecastWindServiceTest.groovy +++ b/energy/src/test/groovy/org/openremote/extension/energy/ForecastWindServiceTest.groovy @@ -49,748 +49,766 @@ import static org.openremote.extension.energy.manager.ForecastWindService.OR_OPE @Ignore class ForecastWindServiceTest extends Specification implements ManagerContainerTrait { - @Shared - def mockServer = new ClientRequestFilter() { - boolean finished = false + @Shared + def mockServer = new ClientRequestFilter() { + boolean finished = false - @Override - void filter(ClientRequestContext requestContext) throws IOException { - if (finished) { - return - } - def requestUri = requestContext.uri + @Override + void filter(ClientRequestContext requestContext) throws IOException { + if (finished) { + return + } + def requestUri = requestContext.uri - switch (requestUri.path) { - case "/data/2.5/onecall": - def now = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS) - def content = "{\n" + - " \"lat\": 51.97,\n" + - " \"lon\": 5.9,\n" + - " \"timezone\": \"Europe/Amsterdam\",\n" + - " \"timezone_offset\": 3600,\n" + - " \"current\": {\n" + - " \"dt\": ${now.toEpochSecond(ZoneOffset.UTC)},\n" + - " \"sunrise\": 1637045887,\n" + - " \"sunset\": 1637077471,\n" + - " \"temp\": 5.37,\n" + - " \"feels_like\": 5.37,\n" + - " \"pressure\": 1022,\n" + - " \"humidity\": 93,\n" + - " \"dew_point\": 4.33,\n" + - " \"uvi\": 0.51,\n" + - " \"clouds\": 90,\n" + - " \"visibility\": 6000,\n" + - " \"wind_speed\": 3.45,\n" + - " \"wind_deg\": 170,\n" + - " \"wind_gust\": 0,\n" + - " \"weather\": [\n" + - " {\n" + - " \"id\": 701,\n" + - " \"main\": \"Mist\",\n" + - " \"description\": \"mist\",\n" + - " \"icon\": \"50d\"\n" + - " }\n" + - " ]\n" + - " },\n" + - " \"hourly\": [\n" + - " {\n" + - " \"dt\": ${now.toEpochSecond(ZoneOffset.UTC)},\n" + - " \"temp\": 5.37,\n" + - " \"feels_like\": 5.37,\n" + - " \"pressure\": 1022,\n" + - " \"humidity\": 93,\n" + - " \"dew_point\": 4.33,\n" + - " \"uvi\": 0.51,\n" + - " \"clouds\": 90,\n" + - " \"visibility\": 10000,\n" + - " \"wind_speed\": 3.19,\n" + - " \"wind_deg\": 230,\n" + - " \"wind_gust\": 1.4,\n" + - " \"weather\": [\n" + - " {\n" + - " \"id\": 804,\n" + - " \"main\": \"Clouds\",\n" + - " \"description\": \"overcast clouds\",\n" + - " \"icon\": \"04d\"\n" + - " }\n" + - " ],\n" + - " \"pop\": 0\n" + - " },\n" + - " {\n" + - " \"dt\": ${now.plusHours(1).toEpochSecond(ZoneOffset.UTC)},\n" + - " \"temp\": 5.63,\n" + - " \"feels_like\": 4.53,\n" + - " \"pressure\": 1022,\n" + - " \"humidity\": 89,\n" + - " \"dew_point\": 3.96,\n" + - " \"uvi\": 0.3,\n" + - " \"clouds\": 92,\n" + - " \"visibility\": 10000,\n" + - " \"wind_speed\": 2.61,\n" + - " \"wind_deg\": 232,\n" + - " \"wind_gust\": 2.04,\n" + - " \"weather\": [\n" + - " {\n" + - " \"id\": 804,\n" + - " \"main\": \"Clouds\",\n" + - " \"description\": \"overcast clouds\",\n" + - " \"icon\": \"04d\"\n" + - " }\n" + - " ],\n" + - " \"pop\": 0\n" + - " },\n" + - " {\n" + - " \"dt\": ${now.plusHours(2).toEpochSecond(ZoneOffset.UTC)},\n" + - " \"temp\": 5.79,\n" + - " \"feels_like\": 4.46,\n" + - " \"pressure\": 1022,\n" + - " \"humidity\": 86,\n" + - " \"dew_point\": 3.63,\n" + - " \"uvi\": 0.11,\n" + - " \"clouds\": 94,\n" + - " \"visibility\": 10000,\n" + - " \"wind_speed\": 1.83,\n" + - " \"wind_deg\": 239,\n" + - " \"wind_gust\": 2.56,\n" + - " \"weather\": [\n" + - " {\n" + - " \"id\": 804,\n" + - " \"main\": \"Clouds\",\n" + - " \"description\": \"overcast clouds\",\n" + - " \"icon\": \"04d\"\n" + - " }\n" + - " ],\n" + - " \"pop\": 0\n" + - " },\n" + - " {\n" + - " \"dt\": ${now.plusHours(3).toEpochSecond(ZoneOffset.UTC)},\n" + - " \"temp\": 5.83,\n" + - " \"feels_like\": 4.51,\n" + - " \"pressure\": 1021,\n" + - " \"humidity\": 83,\n" + - " \"dew_point\": 3.17,\n" + - " \"uvi\": 0,\n" + - " \"clouds\": 96,\n" + - " \"visibility\": 10000,\n" + - " \"wind_speed\": 1.83,\n" + - " \"wind_deg\": 248,\n" + - " \"wind_gust\": 2.92,\n" + - " \"weather\": [\n" + - " {\n" + - " \"id\": 804,\n" + - " \"main\": \"Clouds\",\n" + - " \"description\": \"overcast clouds\",\n" + - " \"icon\": \"04n\"\n" + - " }\n" + - " ],\n" + - " \"pop\": 0\n" + - " },\n" + - " {\n" + - " \"dt\": ${now.plusHours(4).toEpochSecond(ZoneOffset.UTC)},\n" + - " \"temp\": 5.89,\n" + - " \"feels_like\": 4.64,\n" + - " \"pressure\": 1021,\n" + - " \"humidity\": 81,\n" + - " \"dew_point\": 2.88,\n" + - " \"uvi\": 0,\n" + - " \"clouds\": 98,\n" + - " \"visibility\": 10000,\n" + - " \"wind_speed\": 1.77,\n" + - " \"wind_deg\": 254,\n" + - " \"wind_gust\": 3.22,\n" + - " \"weather\": [\n" + - " {\n" + - " \"id\": 804,\n" + - " \"main\": \"Clouds\",\n" + - " \"description\": \"overcast clouds\",\n" + - " \"icon\": \"04n\"\n" + - " }\n" + - " ],\n" + - " \"pop\": 0\n" + - " },\n" + - " {\n" + - " \"dt\": ${now.plusHours(5).toEpochSecond(ZoneOffset.UTC)},\n" + - " \"temp\": 5.99,\n" + - " \"feels_like\": 4.72,\n" + - " \"pressure\": 1021,\n" + - " \"humidity\": 78,\n" + - " \"dew_point\": 2.31,\n" + - " \"uvi\": 0,\n" + - " \"clouds\": 100,\n" + - " \"visibility\": 10000,\n" + - " \"wind_speed\": 1.81,\n" + - " \"wind_deg\": 251,\n" + - " \"wind_gust\": 3.57,\n" + - " \"weather\": [\n" + - " {\n" + - " \"id\": 804,\n" + - " \"main\": \"Clouds\",\n" + - " \"description\": \"overcast clouds\",\n" + - " \"icon\": \"04n\"\n" + - " }\n" + - " ],\n" + - " \"pop\": 0\n" + - " },\n" + - " {\n" + - " \"dt\": ${now.plusHours(6).toEpochSecond(ZoneOffset.UTC)},\n" + - " \"temp\": 6,\n" + - " \"feels_like\": 4.55,\n" + - " \"pressure\": 1021,\n" + - " \"humidity\": 77,\n" + - " \"dew_point\": 2.15,\n" + - " \"uvi\": 0,\n" + - " \"clouds\": 100,\n" + - " \"visibility\": 10000,\n" + - " \"wind_speed\": 1.98,\n" + - " \"wind_deg\": 254,\n" + - " \"wind_gust\": 3.76,\n" + - " \"weather\": [\n" + - " {\n" + - " \"id\": 804,\n" + - " \"main\": \"Clouds\",\n" + - " \"description\": \"overcast clouds\",\n" + - " \"icon\": \"04n\"\n" + - " }\n" + - " ],\n" + - " \"pop\": 0\n" + - " },\n" + - " {\n" + - " \"dt\": ${now.plusHours(7).toEpochSecond(ZoneOffset.UTC)},\n" + - " \"temp\": 5.95,\n" + - " \"feels_like\": 4.68,\n" + - " \"pressure\": 1021,\n" + - " \"humidity\": 77,\n" + - " \"dew_point\": 2.15,\n" + - " \"uvi\": 0,\n" + - " \"clouds\": 100,\n" + - " \"visibility\": 10000,\n" + - " \"wind_speed\": 1.8,\n" + - " \"wind_deg\": 242,\n" + - " \"wind_gust\": 3.37,\n" + - " \"weather\": [\n" + - " {\n" + - " \"id\": 804,\n" + - " \"main\": \"Clouds\",\n" + - " \"description\": \"overcast clouds\",\n" + - " \"icon\": \"04n\"\n" + - " }\n" + - " ],\n" + - " \"pop\": 0\n" + - " },\n" + - " {\n" + - " \"dt\": ${now.plusHours(8).toEpochSecond(ZoneOffset.UTC)},\n" + - " \"temp\": 5.93,\n" + - " \"feels_like\": 4.53,\n" + - " \"pressure\": 1021,\n" + - " \"humidity\": 77,\n" + - " \"dew_point\": 2.18,\n" + - " \"uvi\": 0,\n" + - " \"clouds\": 100,\n" + - " \"visibility\": 10000,\n" + - " \"wind_speed\": 1.92,\n" + - " \"wind_deg\": 213,\n" + - " \"wind_gust\": 3.31,\n" + - " \"weather\": [\n" + - " {\n" + - " \"id\": 804,\n" + - " \"main\": \"Clouds\",\n" + - " \"description\": \"overcast clouds\",\n" + - " \"icon\": \"04n\"\n" + - " }\n" + - " ],\n" + - " \"pop\": 0\n" + - " },\n" + - " {\n" + - " \"dt\": ${now.plusHours(9).toEpochSecond(ZoneOffset.UTC)},\n" + - " \"temp\": 5.94,\n" + - " \"feels_like\": 4.27,\n" + - " \"pressure\": 1020,\n" + - " \"humidity\": 77,\n" + - " \"dew_point\": 2.24,\n" + - " \"uvi\": 0,\n" + - " \"clouds\": 100,\n" + - " \"visibility\": 10000,\n" + - " \"wind_speed\": 2.2,\n" + - " \"wind_deg\": 195,\n" + - " \"wind_gust\": 3.58,\n" + - " \"weather\": [\n" + - " {\n" + - " \"id\": 804,\n" + - " \"main\": \"Clouds\",\n" + - " \"description\": \"overcast clouds\",\n" + - " \"icon\": \"04n\"\n" + - " }\n" + - " ],\n" + - " \"pop\": 0\n" + - " },\n" + - " {\n" + - " \"dt\": ${now.plusHours(10).toEpochSecond(ZoneOffset.UTC)},\n" + - " \"temp\": 6.05,\n" + - " \"feels_like\": 4.19,\n" + - " \"pressure\": 1019,\n" + - " \"humidity\": 77,\n" + - " \"dew_point\": 2.35,\n" + - " \"uvi\": 0,\n" + - " \"clouds\": 100,\n" + - " \"visibility\": 10000,\n" + - " \"wind_speed\": 2.45,\n" + - " \"wind_deg\": 192,\n" + - " \"wind_gust\": 4.3,\n" + - " \"weather\": [\n" + - " {\n" + - " \"id\": 804,\n" + - " \"main\": \"Clouds\",\n" + - " \"description\": \"overcast clouds\",\n" + - " \"icon\": \"04n\"\n" + - " }\n" + - " ],\n" + - " \"pop\": 0\n" + - " },\n" + - " {\n" + - " \"dt\": ${now.plusHours(11).toEpochSecond(ZoneOffset.UTC)},\n" + - " \"temp\": 6.16,\n" + - " \"feels_like\": 4.12,\n" + - " \"pressure\": 1020,\n" + - " \"humidity\": 78,\n" + - " \"dew_point\": 2.52,\n" + - " \"uvi\": 0,\n" + - " \"clouds\": 100,\n" + - " \"visibility\": 10000,\n" + - " \"wind_speed\": 2.7,\n" + - " \"wind_deg\": 228,\n" + - " \"wind_gust\": 6.04,\n" + - " \"weather\": [\n" + - " {\n" + - " \"id\": 804,\n" + - " \"main\": \"Clouds\",\n" + - " \"description\": \"overcast clouds\",\n" + - " \"icon\": \"04n\"\n" + - " }\n" + - " ],\n" + - " \"pop\": 0\n" + - " },\n" + - " {\n" + - " \"dt\": ${now.plusHours(12).toEpochSecond(ZoneOffset.UTC)},\n" + - " \"temp\": 6.24,\n" + - " \"feels_like\": 4.04,\n" + - " \"pressure\": 1019,\n" + - " \"humidity\": 78,\n" + - " \"dew_point\": 2.66,\n" + - " \"uvi\": 0,\n" + - " \"clouds\": 100,\n" + - " \"visibility\": 10000,\n" + - " \"wind_speed\": 2.93,\n" + - " \"wind_deg\": 221,\n" + - " \"wind_gust\": 7.28,\n" + - " \"weather\": [\n" + - " {\n" + - " \"id\": 804,\n" + - " \"main\": \"Clouds\",\n" + - " \"description\": \"overcast clouds\",\n" + - " \"icon\": \"04n\"\n" + - " }\n" + - " ],\n" + - " \"pop\": 0\n" + - " },\n" + - " {\n" + - " \"dt\": ${now.plusHours(13).toEpochSecond(ZoneOffset.UTC)},\n" + - " \"temp\": 6.33,\n" + - " \"feels_like\": 3.88,\n" + - " \"pressure\": 1019,\n" + - " \"humidity\": 79,\n" + - " \"dew_point\": 2.84,\n" + - " \"uvi\": 0,\n" + - " \"clouds\": 100,\n" + - " \"visibility\": 10000,\n" + - " \"wind_speed\": 3.34,\n" + - " \"wind_deg\": 208,\n" + - " \"wind_gust\": 7.93,\n" + - " \"weather\": [\n" + - " {\n" + - " \"id\": 804,\n" + - " \"main\": \"Clouds\",\n" + - " \"description\": \"overcast clouds\",\n" + - " \"icon\": \"04n\"\n" + - " }\n" + - " ],\n" + - " \"pop\": 0\n" + - " },\n" + - " {\n" + - " \"dt\": ${now.plusHours(14).toEpochSecond(ZoneOffset.UTC)},\n" + - " \"temp\": 6.49,\n" + - " \"feels_like\": 3.7,\n" + - " \"pressure\": 1019,\n" + - " \"humidity\": 79,\n" + - " \"dew_point\": 2.97,\n" + - " \"uvi\": 0,\n" + - " \"clouds\": 100,\n" + - " \"visibility\": 10000,\n" + - " \"wind_speed\": 3.98,\n" + - " \"wind_deg\": 202,\n" + - " \"wind_gust\": 8.55,\n" + - " \"weather\": [\n" + - " {\n" + - " \"id\": 804,\n" + - " \"main\": \"Clouds\",\n" + - " \"description\": \"overcast clouds\",\n" + - " \"icon\": \"04n\"\n" + - " }\n" + - " ],\n" + - " \"pop\": 0\n" + - " },\n" + - " {\n" + - " \"dt\": ${now.plusHours(15).toEpochSecond(ZoneOffset.UTC)},\n" + - " \"temp\": 6.52,\n" + - " \"feels_like\": 3.53,\n" + - " \"pressure\": 1019,\n" + - " \"humidity\": 79,\n" + - " \"dew_point\": 3.15,\n" + - " \"uvi\": 0,\n" + - " \"clouds\": 98,\n" + - " \"visibility\": 10000,\n" + - " \"wind_speed\": 4.37,\n" + - " \"wind_deg\": 214,\n" + - " \"wind_gust\": 9.76,\n" + - " \"weather\": [\n" + - " {\n" + - " \"id\": 804,\n" + - " \"main\": \"Clouds\",\n" + - " \"description\": \"overcast clouds\",\n" + - " \"icon\": \"04n\"\n" + - " }\n" + - " ],\n" + - " \"pop\": 0\n" + - " },\n" + - " {\n" + - " \"dt\": ${now.plusHours(16).toEpochSecond(ZoneOffset.UTC)},\n" + - " \"temp\": 6.69,\n" + - " \"feels_like\": 3.65,\n" + - " \"pressure\": 1018,\n" + - " \"humidity\": 81,\n" + - " \"dew_point\": 3.62,\n" + - " \"uvi\": 0,\n" + - " \"clouds\": 98,\n" + - " \"visibility\": 10000,\n" + - " \"wind_speed\": 4.57,\n" + - " \"wind_deg\": 211,\n" + - " \"wind_gust\": 9.8,\n" + - " \"weather\": [\n" + - " {\n" + - " \"id\": 804,\n" + - " \"main\": \"Clouds\",\n" + - " \"description\": \"overcast clouds\",\n" + - " \"icon\": \"04n\"\n" + - " }\n" + - " ],\n" + - " \"pop\": 0\n" + - " },\n" + - " {\n" + - " \"dt\": ${now.plusHours(17).toEpochSecond(ZoneOffset.UTC)},\n" + - " \"temp\": 6.72,\n" + - " \"feels_like\": 3.71,\n" + - " \"pressure\": 1019,\n" + - " \"humidity\": 84,\n" + - " \"dew_point\": 4.1,\n" + - " \"uvi\": 0,\n" + - " \"clouds\": 99,\n" + - " \"visibility\": 10000,\n" + - " \"wind_speed\": 4.52,\n" + - " \"wind_deg\": 218,\n" + - " \"wind_gust\": 10.41,\n" + - " \"weather\": [\n" + - " {\n" + - " \"id\": 804,\n" + - " \"main\": \"Clouds\",\n" + - " \"description\": \"overcast clouds\",\n" + - " \"icon\": \"04n\"\n" + - " }\n" + - " ],\n" + - " \"pop\": 0\n" + - " },\n" + - " {\n" + - " \"dt\": ${now.plusHours(18).toEpochSecond(ZoneOffset.UTC)},\n" + - " \"temp\": 6.85,\n" + - " \"feels_like\": 3.86,\n" + - " \"pressure\": 1019,\n" + - " \"humidity\": 86,\n" + - " \"dew_point\": 4.58,\n" + - " \"uvi\": 0,\n" + - " \"clouds\": 100,\n" + - " \"visibility\": 10000,\n" + - " \"wind_speed\": 4.54,\n" + - " \"wind_deg\": 225,\n" + - " \"wind_gust\": 10.91,\n" + - " \"weather\": [\n" + - " {\n" + - " \"id\": 804,\n" + - " \"main\": \"Clouds\",\n" + - " \"description\": \"overcast clouds\",\n" + - " \"icon\": \"04d\"\n" + - " }\n" + - " ],\n" + - " \"pop\": 0.01\n" + - " },\n" + - " {\n" + - " \"dt\": ${now.plusHours(19).toEpochSecond(ZoneOffset.UTC)},\n" + - " \"temp\": 7.15,\n" + - " \"feels_like\": 4.2,\n" + - " \"pressure\": 1019,\n" + - " \"humidity\": 89,\n" + - " \"dew_point\": 5.39,\n" + - " \"uvi\": 0,\n" + - " \"clouds\": 98,\n" + - " \"visibility\": 10000,\n" + - " \"wind_speed\": 4.6,\n" + - " \"wind_deg\": 228,\n" + - " \"wind_gust\": 10.3,\n" + - " \"weather\": [\n" + - " {\n" + - " \"id\": 804,\n" + - " \"main\": \"Clouds\",\n" + - " \"description\": \"overcast clouds\",\n" + - " \"icon\": \"04d\"\n" + - " }\n" + - " ],\n" + - " \"pop\": 0.05\n" + - " },\n" + - " {\n" + - " \"dt\": ${now.plusHours(20).toEpochSecond(ZoneOffset.UTC)},\n" + - " \"temp\": 7.85,\n" + - " \"feels_like\": 5.02,\n" + - " \"pressure\": 1019,\n" + - " \"humidity\": 91,\n" + - " \"dew_point\": 6.35,\n" + - " \"uvi\": 0.1,\n" + - " \"clouds\": 91,\n" + - " \"visibility\": 10000,\n" + - " \"wind_speed\": 4.72,\n" + - " \"wind_deg\": 229,\n" + - " \"wind_gust\": 10.31,\n" + - " \"weather\": [\n" + - " {\n" + - " \"id\": 804,\n" + - " \"main\": \"Clouds\",\n" + - " \"description\": \"overcast clouds\",\n" + - " \"icon\": \"04d\"\n" + - " }\n" + - " ],\n" + - " \"pop\": 0.05\n" + - " },\n" + - " {\n" + - " \"dt\": ${now.plusHours(21).toEpochSecond(ZoneOffset.UTC)},\n" + - " \"temp\": 8.52,\n" + - " \"feels_like\": 5.91,\n" + - " \"pressure\": 1020,\n" + - " \"humidity\": 93,\n" + - " \"dew_point\": 7.31,\n" + - " \"uvi\": 0.26,\n" + - " \"clouds\": 94,\n" + - " \"visibility\": 10000,\n" + - " \"wind_speed\": 4.6,\n" + - " \"wind_deg\": 258,\n" + - " \"wind_gust\": 9.79,\n" + - " \"weather\": [\n" + - " {\n" + - " \"id\": 804,\n" + - " \"main\": \"Clouds\",\n" + - " \"description\": \"overcast clouds\",\n" + - " \"icon\": \"04d\"\n" + - " }\n" + - " ],\n" + - " \"pop\": 0.05\n" + - " },\n" + - " {\n" + - " \"dt\": ${now.plusHours(22).toEpochSecond(ZoneOffset.UTC)},\n" + - " \"temp\": 10.23,\n" + - " \"feels_like\": 9.5,\n" + - " \"pressure\": 1020,\n" + - " \"humidity\": 84,\n" + - " \"dew_point\": 7.47,\n" + - " \"uvi\": 0.36,\n" + - " \"clouds\": 94,\n" + - " \"visibility\": 10000,\n" + - " \"wind_speed\": 3.92,\n" + - " \"wind_deg\": 287,\n" + - " \"wind_gust\": 8.04,\n" + - " \"weather\": [\n" + - " {\n" + - " \"id\": 804,\n" + - " \"main\": \"Clouds\",\n" + - " \"description\": \"overcast clouds\",\n" + - " \"icon\": \"04d\"\n" + - " }\n" + - " ],\n" + - " \"pop\": 0.05\n" + - " },\n" + - " {\n" + - " \"dt\": ${now.plusHours(23).toEpochSecond(ZoneOffset.UTC)},\n" + - " \"temp\": 10.03,\n" + - " \"feels_like\": 9.05,\n" + - " \"pressure\": 1020,\n" + - " \"humidity\": 75,\n" + - " \"dew_point\": 5.78,\n" + - " \"uvi\": 0.37,\n" + - " \"clouds\": 95,\n" + - " \"visibility\": 10000,\n" + - " \"wind_speed\": 3.91,\n" + - " \"wind_deg\": 294,\n" + - " \"wind_gust\": 8.34,\n" + - " \"weather\": [\n" + - " {\n" + - " \"id\": 804,\n" + - " \"main\": \"Clouds\",\n" + - " \"description\": \"overcast clouds\",\n" + - " \"icon\": \"04d\"\n" + - " }\n" + - " ],\n" + - " \"pop\": 0.05\n" + - " }\n" + - " ]\n" + - "}" - def responseBody = ValueUtil.JSON.readValue(content, ForecastWindService.WeatherForecastResponseModel.class) - requestContext.abortWith( - Response.ok(responseBody, MediaType.APPLICATION_JSON_TYPE).build() - ) - return - } + switch (requestUri.path) { + case "/data/2.5/onecall": + def now = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS) + def content = "{\n" + + " \"lat\": 51.97,\n" + + " \"lon\": 5.9,\n" + + " \"timezone\": \"Europe/Amsterdam\",\n" + + " \"timezone_offset\": 3600,\n" + + " \"current\": {\n" + + " \"dt\": ${now.toEpochSecond(ZoneOffset.UTC)},\n" + + " \"sunrise\": 1637045887,\n" + + " \"sunset\": 1637077471,\n" + + " \"temp\": 5.37,\n" + + " \"feels_like\": 5.37,\n" + + " \"pressure\": 1022,\n" + + " \"humidity\": 93,\n" + + " \"dew_point\": 4.33,\n" + + " \"uvi\": 0.51,\n" + + " \"clouds\": 90,\n" + + " \"visibility\": 6000,\n" + + " \"wind_speed\": 3.45,\n" + + " \"wind_deg\": 170,\n" + + " \"wind_gust\": 0,\n" + + " \"weather\": [\n" + + " {\n" + + " \"id\": 701,\n" + + " \"main\": \"Mist\",\n" + + " \"description\": \"mist\",\n" + + " \"icon\": \"50d\"\n" + + " }\n" + + " ]\n" + + " },\n" + + " \"hourly\": [\n" + + " {\n" + + " \"dt\": ${now.toEpochSecond(ZoneOffset.UTC)},\n" + + " \"temp\": 5.37,\n" + + " \"feels_like\": 5.37,\n" + + " \"pressure\": 1022,\n" + + " \"humidity\": 93,\n" + + " \"dew_point\": 4.33,\n" + + " \"uvi\": 0.51,\n" + + " \"clouds\": 90,\n" + + " \"visibility\": 10000,\n" + + " \"wind_speed\": 3.19,\n" + + " \"wind_deg\": 230,\n" + + " \"wind_gust\": 1.4,\n" + + " \"weather\": [\n" + + " {\n" + + " \"id\": 804,\n" + + " \"main\": \"Clouds\",\n" + + " \"description\": \"overcast clouds\",\n" + + " \"icon\": \"04d\"\n" + + " }\n" + + " ],\n" + + " \"pop\": 0\n" + + " },\n" + + " {\n" + + " \"dt\": ${now.plusHours(1).toEpochSecond(ZoneOffset.UTC)},\n" + + " \"temp\": 5.63,\n" + + " \"feels_like\": 4.53,\n" + + " \"pressure\": 1022,\n" + + " \"humidity\": 89,\n" + + " \"dew_point\": 3.96,\n" + + " \"uvi\": 0.3,\n" + + " \"clouds\": 92,\n" + + " \"visibility\": 10000,\n" + + " \"wind_speed\": 2.61,\n" + + " \"wind_deg\": 232,\n" + + " \"wind_gust\": 2.04,\n" + + " \"weather\": [\n" + + " {\n" + + " \"id\": 804,\n" + + " \"main\": \"Clouds\",\n" + + " \"description\": \"overcast clouds\",\n" + + " \"icon\": \"04d\"\n" + + " }\n" + + " ],\n" + + " \"pop\": 0\n" + + " },\n" + + " {\n" + + " \"dt\": ${now.plusHours(2).toEpochSecond(ZoneOffset.UTC)},\n" + + " \"temp\": 5.79,\n" + + " \"feels_like\": 4.46,\n" + + " \"pressure\": 1022,\n" + + " \"humidity\": 86,\n" + + " \"dew_point\": 3.63,\n" + + " \"uvi\": 0.11,\n" + + " \"clouds\": 94,\n" + + " \"visibility\": 10000,\n" + + " \"wind_speed\": 1.83,\n" + + " \"wind_deg\": 239,\n" + + " \"wind_gust\": 2.56,\n" + + " \"weather\": [\n" + + " {\n" + + " \"id\": 804,\n" + + " \"main\": \"Clouds\",\n" + + " \"description\": \"overcast clouds\",\n" + + " \"icon\": \"04d\"\n" + + " }\n" + + " ],\n" + + " \"pop\": 0\n" + + " },\n" + + " {\n" + + " \"dt\": ${now.plusHours(3).toEpochSecond(ZoneOffset.UTC)},\n" + + " \"temp\": 5.83,\n" + + " \"feels_like\": 4.51,\n" + + " \"pressure\": 1021,\n" + + " \"humidity\": 83,\n" + + " \"dew_point\": 3.17,\n" + + " \"uvi\": 0,\n" + + " \"clouds\": 96,\n" + + " \"visibility\": 10000,\n" + + " \"wind_speed\": 1.83,\n" + + " \"wind_deg\": 248,\n" + + " \"wind_gust\": 2.92,\n" + + " \"weather\": [\n" + + " {\n" + + " \"id\": 804,\n" + + " \"main\": \"Clouds\",\n" + + " \"description\": \"overcast clouds\",\n" + + " \"icon\": \"04n\"\n" + + " }\n" + + " ],\n" + + " \"pop\": 0\n" + + " },\n" + + " {\n" + + " \"dt\": ${now.plusHours(4).toEpochSecond(ZoneOffset.UTC)},\n" + + " \"temp\": 5.89,\n" + + " \"feels_like\": 4.64,\n" + + " \"pressure\": 1021,\n" + + " \"humidity\": 81,\n" + + " \"dew_point\": 2.88,\n" + + " \"uvi\": 0,\n" + + " \"clouds\": 98,\n" + + " \"visibility\": 10000,\n" + + " \"wind_speed\": 1.77,\n" + + " \"wind_deg\": 254,\n" + + " \"wind_gust\": 3.22,\n" + + " \"weather\": [\n" + + " {\n" + + " \"id\": 804,\n" + + " \"main\": \"Clouds\",\n" + + " \"description\": \"overcast clouds\",\n" + + " \"icon\": \"04n\"\n" + + " }\n" + + " ],\n" + + " \"pop\": 0\n" + + " },\n" + + " {\n" + + " \"dt\": ${now.plusHours(5).toEpochSecond(ZoneOffset.UTC)},\n" + + " \"temp\": 5.99,\n" + + " \"feels_like\": 4.72,\n" + + " \"pressure\": 1021,\n" + + " \"humidity\": 78,\n" + + " \"dew_point\": 2.31,\n" + + " \"uvi\": 0,\n" + + " \"clouds\": 100,\n" + + " \"visibility\": 10000,\n" + + " \"wind_speed\": 1.81,\n" + + " \"wind_deg\": 251,\n" + + " \"wind_gust\": 3.57,\n" + + " \"weather\": [\n" + + " {\n" + + " \"id\": 804,\n" + + " \"main\": \"Clouds\",\n" + + " \"description\": \"overcast clouds\",\n" + + " \"icon\": \"04n\"\n" + + " }\n" + + " ],\n" + + " \"pop\": 0\n" + + " },\n" + + " {\n" + + " \"dt\": ${now.plusHours(6).toEpochSecond(ZoneOffset.UTC)},\n" + + " \"temp\": 6,\n" + + " \"feels_like\": 4.55,\n" + + " \"pressure\": 1021,\n" + + " \"humidity\": 77,\n" + + " \"dew_point\": 2.15,\n" + + " \"uvi\": 0,\n" + + " \"clouds\": 100,\n" + + " \"visibility\": 10000,\n" + + " \"wind_speed\": 1.98,\n" + + " \"wind_deg\": 254,\n" + + " \"wind_gust\": 3.76,\n" + + " \"weather\": [\n" + + " {\n" + + " \"id\": 804,\n" + + " \"main\": \"Clouds\",\n" + + " \"description\": \"overcast clouds\",\n" + + " \"icon\": \"04n\"\n" + + " }\n" + + " ],\n" + + " \"pop\": 0\n" + + " },\n" + + " {\n" + + " \"dt\": ${now.plusHours(7).toEpochSecond(ZoneOffset.UTC)},\n" + + " \"temp\": 5.95,\n" + + " \"feels_like\": 4.68,\n" + + " \"pressure\": 1021,\n" + + " \"humidity\": 77,\n" + + " \"dew_point\": 2.15,\n" + + " \"uvi\": 0,\n" + + " \"clouds\": 100,\n" + + " \"visibility\": 10000,\n" + + " \"wind_speed\": 1.8,\n" + + " \"wind_deg\": 242,\n" + + " \"wind_gust\": 3.37,\n" + + " \"weather\": [\n" + + " {\n" + + " \"id\": 804,\n" + + " \"main\": \"Clouds\",\n" + + " \"description\": \"overcast clouds\",\n" + + " \"icon\": \"04n\"\n" + + " }\n" + + " ],\n" + + " \"pop\": 0\n" + + " },\n" + + " {\n" + + " \"dt\": ${now.plusHours(8).toEpochSecond(ZoneOffset.UTC)},\n" + + " \"temp\": 5.93,\n" + + " \"feels_like\": 4.53,\n" + + " \"pressure\": 1021,\n" + + " \"humidity\": 77,\n" + + " \"dew_point\": 2.18,\n" + + " \"uvi\": 0,\n" + + " \"clouds\": 100,\n" + + " \"visibility\": 10000,\n" + + " \"wind_speed\": 1.92,\n" + + " \"wind_deg\": 213,\n" + + " \"wind_gust\": 3.31,\n" + + " \"weather\": [\n" + + " {\n" + + " \"id\": 804,\n" + + " \"main\": \"Clouds\",\n" + + " \"description\": \"overcast clouds\",\n" + + " \"icon\": \"04n\"\n" + + " }\n" + + " ],\n" + + " \"pop\": 0\n" + + " },\n" + + " {\n" + + " \"dt\": ${now.plusHours(9).toEpochSecond(ZoneOffset.UTC)},\n" + + " \"temp\": 5.94,\n" + + " \"feels_like\": 4.27,\n" + + " \"pressure\": 1020,\n" + + " \"humidity\": 77,\n" + + " \"dew_point\": 2.24,\n" + + " \"uvi\": 0,\n" + + " \"clouds\": 100,\n" + + " \"visibility\": 10000,\n" + + " \"wind_speed\": 2.2,\n" + + " \"wind_deg\": 195,\n" + + " \"wind_gust\": 3.58,\n" + + " \"weather\": [\n" + + " {\n" + + " \"id\": 804,\n" + + " \"main\": \"Clouds\",\n" + + " \"description\": \"overcast clouds\",\n" + + " \"icon\": \"04n\"\n" + + " }\n" + + " ],\n" + + " \"pop\": 0\n" + + " },\n" + + " {\n" + + " \"dt\": ${now.plusHours(10).toEpochSecond(ZoneOffset.UTC)},\n" + + " \"temp\": 6.05,\n" + + " \"feels_like\": 4.19,\n" + + " \"pressure\": 1019,\n" + + " \"humidity\": 77,\n" + + " \"dew_point\": 2.35,\n" + + " \"uvi\": 0,\n" + + " \"clouds\": 100,\n" + + " \"visibility\": 10000,\n" + + " \"wind_speed\": 2.45,\n" + + " \"wind_deg\": 192,\n" + + " \"wind_gust\": 4.3,\n" + + " \"weather\": [\n" + + " {\n" + + " \"id\": 804,\n" + + " \"main\": \"Clouds\",\n" + + " \"description\": \"overcast clouds\",\n" + + " \"icon\": \"04n\"\n" + + " }\n" + + " ],\n" + + " \"pop\": 0\n" + + " },\n" + + " {\n" + + " \"dt\": ${now.plusHours(11).toEpochSecond(ZoneOffset.UTC)},\n" + + " \"temp\": 6.16,\n" + + " \"feels_like\": 4.12,\n" + + " \"pressure\": 1020,\n" + + " \"humidity\": 78,\n" + + " \"dew_point\": 2.52,\n" + + " \"uvi\": 0,\n" + + " \"clouds\": 100,\n" + + " \"visibility\": 10000,\n" + + " \"wind_speed\": 2.7,\n" + + " \"wind_deg\": 228,\n" + + " \"wind_gust\": 6.04,\n" + + " \"weather\": [\n" + + " {\n" + + " \"id\": 804,\n" + + " \"main\": \"Clouds\",\n" + + " \"description\": \"overcast clouds\",\n" + + " \"icon\": \"04n\"\n" + + " }\n" + + " ],\n" + + " \"pop\": 0\n" + + " },\n" + + " {\n" + + " \"dt\": ${now.plusHours(12).toEpochSecond(ZoneOffset.UTC)},\n" + + " \"temp\": 6.24,\n" + + " \"feels_like\": 4.04,\n" + + " \"pressure\": 1019,\n" + + " \"humidity\": 78,\n" + + " \"dew_point\": 2.66,\n" + + " \"uvi\": 0,\n" + + " \"clouds\": 100,\n" + + " \"visibility\": 10000,\n" + + " \"wind_speed\": 2.93,\n" + + " \"wind_deg\": 221,\n" + + " \"wind_gust\": 7.28,\n" + + " \"weather\": [\n" + + " {\n" + + " \"id\": 804,\n" + + " \"main\": \"Clouds\",\n" + + " \"description\": \"overcast clouds\",\n" + + " \"icon\": \"04n\"\n" + + " }\n" + + " ],\n" + + " \"pop\": 0\n" + + " },\n" + + " {\n" + + " \"dt\": ${now.plusHours(13).toEpochSecond(ZoneOffset.UTC)},\n" + + " \"temp\": 6.33,\n" + + " \"feels_like\": 3.88,\n" + + " \"pressure\": 1019,\n" + + " \"humidity\": 79,\n" + + " \"dew_point\": 2.84,\n" + + " \"uvi\": 0,\n" + + " \"clouds\": 100,\n" + + " \"visibility\": 10000,\n" + + " \"wind_speed\": 3.34,\n" + + " \"wind_deg\": 208,\n" + + " \"wind_gust\": 7.93,\n" + + " \"weather\": [\n" + + " {\n" + + " \"id\": 804,\n" + + " \"main\": \"Clouds\",\n" + + " \"description\": \"overcast clouds\",\n" + + " \"icon\": \"04n\"\n" + + " }\n" + + " ],\n" + + " \"pop\": 0\n" + + " },\n" + + " {\n" + + " \"dt\": ${now.plusHours(14).toEpochSecond(ZoneOffset.UTC)},\n" + + " \"temp\": 6.49,\n" + + " \"feels_like\": 3.7,\n" + + " \"pressure\": 1019,\n" + + " \"humidity\": 79,\n" + + " \"dew_point\": 2.97,\n" + + " \"uvi\": 0,\n" + + " \"clouds\": 100,\n" + + " \"visibility\": 10000,\n" + + " \"wind_speed\": 3.98,\n" + + " \"wind_deg\": 202,\n" + + " \"wind_gust\": 8.55,\n" + + " \"weather\": [\n" + + " {\n" + + " \"id\": 804,\n" + + " \"main\": \"Clouds\",\n" + + " \"description\": \"overcast clouds\",\n" + + " \"icon\": \"04n\"\n" + + " }\n" + + " ],\n" + + " \"pop\": 0\n" + + " },\n" + + " {\n" + + " \"dt\": ${now.plusHours(15).toEpochSecond(ZoneOffset.UTC)},\n" + + " \"temp\": 6.52,\n" + + " \"feels_like\": 3.53,\n" + + " \"pressure\": 1019,\n" + + " \"humidity\": 79,\n" + + " \"dew_point\": 3.15,\n" + + " \"uvi\": 0,\n" + + " \"clouds\": 98,\n" + + " \"visibility\": 10000,\n" + + " \"wind_speed\": 4.37,\n" + + " \"wind_deg\": 214,\n" + + " \"wind_gust\": 9.76,\n" + + " \"weather\": [\n" + + " {\n" + + " \"id\": 804,\n" + + " \"main\": \"Clouds\",\n" + + " \"description\": \"overcast clouds\",\n" + + " \"icon\": \"04n\"\n" + + " }\n" + + " ],\n" + + " \"pop\": 0\n" + + " },\n" + + " {\n" + + " \"dt\": ${now.plusHours(16).toEpochSecond(ZoneOffset.UTC)},\n" + + " \"temp\": 6.69,\n" + + " \"feels_like\": 3.65,\n" + + " \"pressure\": 1018,\n" + + " \"humidity\": 81,\n" + + " \"dew_point\": 3.62,\n" + + " \"uvi\": 0,\n" + + " \"clouds\": 98,\n" + + " \"visibility\": 10000,\n" + + " \"wind_speed\": 4.57,\n" + + " \"wind_deg\": 211,\n" + + " \"wind_gust\": 9.8,\n" + + " \"weather\": [\n" + + " {\n" + + " \"id\": 804,\n" + + " \"main\": \"Clouds\",\n" + + " \"description\": \"overcast clouds\",\n" + + " \"icon\": \"04n\"\n" + + " }\n" + + " ],\n" + + " \"pop\": 0\n" + + " },\n" + + " {\n" + + " \"dt\": ${now.plusHours(17).toEpochSecond(ZoneOffset.UTC)},\n" + + " \"temp\": 6.72,\n" + + " \"feels_like\": 3.71,\n" + + " \"pressure\": 1019,\n" + + " \"humidity\": 84,\n" + + " \"dew_point\": 4.1,\n" + + " \"uvi\": 0,\n" + + " \"clouds\": 99,\n" + + " \"visibility\": 10000,\n" + + " \"wind_speed\": 4.52,\n" + + " \"wind_deg\": 218,\n" + + " \"wind_gust\": 10.41,\n" + + " \"weather\": [\n" + + " {\n" + + " \"id\": 804,\n" + + " \"main\": \"Clouds\",\n" + + " \"description\": \"overcast clouds\",\n" + + " \"icon\": \"04n\"\n" + + " }\n" + + " ],\n" + + " \"pop\": 0\n" + + " },\n" + + " {\n" + + " \"dt\": ${now.plusHours(18).toEpochSecond(ZoneOffset.UTC)},\n" + + " \"temp\": 6.85,\n" + + " \"feels_like\": 3.86,\n" + + " \"pressure\": 1019,\n" + + " \"humidity\": 86,\n" + + " \"dew_point\": 4.58,\n" + + " \"uvi\": 0,\n" + + " \"clouds\": 100,\n" + + " \"visibility\": 10000,\n" + + " \"wind_speed\": 4.54,\n" + + " \"wind_deg\": 225,\n" + + " \"wind_gust\": 10.91,\n" + + " \"weather\": [\n" + + " {\n" + + " \"id\": 804,\n" + + " \"main\": \"Clouds\",\n" + + " \"description\": \"overcast clouds\",\n" + + " \"icon\": \"04d\"\n" + + " }\n" + + " ],\n" + + " \"pop\": 0.01\n" + + " },\n" + + " {\n" + + " \"dt\": ${now.plusHours(19).toEpochSecond(ZoneOffset.UTC)},\n" + + " \"temp\": 7.15,\n" + + " \"feels_like\": 4.2,\n" + + " \"pressure\": 1019,\n" + + " \"humidity\": 89,\n" + + " \"dew_point\": 5.39,\n" + + " \"uvi\": 0,\n" + + " \"clouds\": 98,\n" + + " \"visibility\": 10000,\n" + + " \"wind_speed\": 4.6,\n" + + " \"wind_deg\": 228,\n" + + " \"wind_gust\": 10.3,\n" + + " \"weather\": [\n" + + " {\n" + + " \"id\": 804,\n" + + " \"main\": \"Clouds\",\n" + + " \"description\": \"overcast clouds\",\n" + + " \"icon\": \"04d\"\n" + + " }\n" + + " ],\n" + + " \"pop\": 0.05\n" + + " },\n" + + " {\n" + + " \"dt\": ${now.plusHours(20).toEpochSecond(ZoneOffset.UTC)},\n" + + " \"temp\": 7.85,\n" + + " \"feels_like\": 5.02,\n" + + " \"pressure\": 1019,\n" + + " \"humidity\": 91,\n" + + " \"dew_point\": 6.35,\n" + + " \"uvi\": 0.1,\n" + + " \"clouds\": 91,\n" + + " \"visibility\": 10000,\n" + + " \"wind_speed\": 4.72,\n" + + " \"wind_deg\": 229,\n" + + " \"wind_gust\": 10.31,\n" + + " \"weather\": [\n" + + " {\n" + + " \"id\": 804,\n" + + " \"main\": \"Clouds\",\n" + + " \"description\": \"overcast clouds\",\n" + + " \"icon\": \"04d\"\n" + + " }\n" + + " ],\n" + + " \"pop\": 0.05\n" + + " },\n" + + " {\n" + + " \"dt\": ${now.plusHours(21).toEpochSecond(ZoneOffset.UTC)},\n" + + " \"temp\": 8.52,\n" + + " \"feels_like\": 5.91,\n" + + " \"pressure\": 1020,\n" + + " \"humidity\": 93,\n" + + " \"dew_point\": 7.31,\n" + + " \"uvi\": 0.26,\n" + + " \"clouds\": 94,\n" + + " \"visibility\": 10000,\n" + + " \"wind_speed\": 4.6,\n" + + " \"wind_deg\": 258,\n" + + " \"wind_gust\": 9.79,\n" + + " \"weather\": [\n" + + " {\n" + + " \"id\": 804,\n" + + " \"main\": \"Clouds\",\n" + + " \"description\": \"overcast clouds\",\n" + + " \"icon\": \"04d\"\n" + + " }\n" + + " ],\n" + + " \"pop\": 0.05\n" + + " },\n" + + " {\n" + + " \"dt\": ${now.plusHours(22).toEpochSecond(ZoneOffset.UTC)},\n" + + " \"temp\": 10.23,\n" + + " \"feels_like\": 9.5,\n" + + " \"pressure\": 1020,\n" + + " \"humidity\": 84,\n" + + " \"dew_point\": 7.47,\n" + + " \"uvi\": 0.36,\n" + + " \"clouds\": 94,\n" + + " \"visibility\": 10000,\n" + + " \"wind_speed\": 3.92,\n" + + " \"wind_deg\": 287,\n" + + " \"wind_gust\": 8.04,\n" + + " \"weather\": [\n" + + " {\n" + + " \"id\": 804,\n" + + " \"main\": \"Clouds\",\n" + + " \"description\": \"overcast clouds\",\n" + + " \"icon\": \"04d\"\n" + + " }\n" + + " ],\n" + + " \"pop\": 0.05\n" + + " },\n" + + " {\n" + + " \"dt\": ${now.plusHours(23).toEpochSecond(ZoneOffset.UTC)},\n" + + " \"temp\": 10.03,\n" + + " \"feels_like\": 9.05,\n" + + " \"pressure\": 1020,\n" + + " \"humidity\": 75,\n" + + " \"dew_point\": 5.78,\n" + + " \"uvi\": 0.37,\n" + + " \"clouds\": 95,\n" + + " \"visibility\": 10000,\n" + + " \"wind_speed\": 3.91,\n" + + " \"wind_deg\": 294,\n" + + " \"wind_gust\": 8.34,\n" + + " \"weather\": [\n" + + " {\n" + + " \"id\": 804,\n" + + " \"main\": \"Clouds\",\n" + + " \"description\": \"overcast clouds\",\n" + + " \"icon\": \"04d\"\n" + + " }\n" + + " ],\n" + + " \"pop\": 0.05\n" + + " }\n" + + " ]\n" + + "}" + def responseBody = ValueUtil.JSON.readValue(content, ForecastWindService.WeatherForecastResponseModel.class) + requestContext.abortWith( + Response.ok(responseBody, MediaType.APPLICATION_JSON_TYPE).build() + ) + return + } - requestContext.abortWith(Response.serverError().build()) - } + requestContext.abortWith(Response.serverError().build()) } + } - def "Test adding and removing asset with enabled attributes"() { - given: "the container environment is started" - def conditions = new PollingConditions(timeout: 10, delay: 0.2) - def config = defaultConfig() - config << [(OR_OPEN_WEATHER_API_APP_ID): "test-key"] - WebTargetBuilder.client.register(mockServer, Integer.MAX_VALUE) + def "Test adding and removing asset with enabled attributes"() { + given: "the container environment is started" + def conditions = new PollingConditions(timeout: 10, delay: 0.2) + def config = defaultConfig() + config << [(OR_OPEN_WEATHER_API_APP_ID): "test-key"] + WebTargetBuilder.client.register(mockServer, Integer.MAX_VALUE) - def container = startContainer(config, defaultServices()) - def managerTestSetup = container.getService(SetupService.class).getTaskOfType(ManagerTestSetup.class) - def assetStorageService = container.getService(AssetStorageService.class) - def assetPredictedDatapointService = container.getService(AssetPredictedDatapointService.class) - def assetProcessingService = container.getService(AssetProcessingService.class) - def forecastWindService = container.getService(ForecastWindService.class) + def container = startContainer(config, defaultServices()) + def managerTestSetup = container.getService(SetupService.class).getTaskOfType(ManagerTestSetup.class) + def assetStorageService = container.getService(AssetStorageService.class) + def assetPredictedDatapointService = container.getService(AssetPredictedDatapointService.class) + def assetProcessingService = container.getService(AssetProcessingService.class) + def forecastWindService = container.getService(ForecastWindService.class) - expect: "a Future for calculation should exist and the asset should have filled in value for power and powerForecast" - conditions.eventually { - assert !forecastWindService.calculationFutures.isEmpty() - assert forecastWindService.calculationFutures.get(managerTestSetup.electricityWindAssetId) != null - def windAsset = assetStorageService.find(managerTestSetup.electricityWindAssetId) - assert windAsset.getAttribute(ElectricityProducerWindAsset.POWER).flatMap { it.value }.orElse(0d) != 0d - assert windAsset.getAttribute(ElectricityProducerWindAsset.POWER_FORECAST).flatMap { it.value }.orElse(0d) != 0d - assert assetPredictedDatapointService.getDatapoints(new AttributeRef(windAsset.getId(), ElectricityProducerWindAsset.POWER.getName())).size() > 0 - assert assetPredictedDatapointService.getDatapoints(new AttributeRef(windAsset.getId(), ElectricityProducerWindAsset.POWER_FORECAST.getName())).size() > 0 - } - - when: "an asset is added with includeForecastWindService set to true" - def newWindAsset = new ElectricityProducerWindAsset("WindAsset") - newWindAsset.setParentId(managerTestSetup.electricityOptimisationAssetId) - newWindAsset.setRealm(managerTestSetup.realmEnergyName) - newWindAsset.setWindSpeedMax(18d); - newWindAsset.setWindSpeedMin(2d); - newWindAsset.setWindSpeedReference(12d); - newWindAsset.setEfficiencyExport(100); - newWindAsset.setPowerExportMax(2.5); - newWindAsset.setLocation(new GeoJSONPoint(9.195295, 48.787418)); - newWindAsset.setSetActualWindValueWithForecast(true); - newWindAsset.setIncludeForecastWindService(true); - newWindAsset = assetStorageService.merge(newWindAsset) + expect: "a Future for calculation should exist and the asset should have filled in value for power and powerForecast" + conditions.eventually { + assert !forecastWindService.calculationFutures.isEmpty() + assert forecastWindService.calculationFutures.get(managerTestSetup.electricityWindAssetId) != null + def windAsset = assetStorageService.find(managerTestSetup.electricityWindAssetId) + assert windAsset.getAttribute(ElectricityProducerWindAsset.POWER).flatMap { + it.value + }.orElse(0d) != 0d + assert windAsset.getAttribute(ElectricityProducerWindAsset.POWER_FORECAST).flatMap { + it.value + }.orElse(0d) != 0d + assert assetPredictedDatapointService.getDatapoints(new AttributeRef(windAsset.getId(), ElectricityProducerWindAsset.POWER.getName())).size() > 0 + assert assetPredictedDatapointService.getDatapoints(new AttributeRef(windAsset.getId(), ElectricityProducerWindAsset.POWER_FORECAST.getName())).size() > 0 + } - then: "the assetId should be present in the calculationFutures" - conditions.eventually { - assert forecastWindService.calculationFutures.get(newWindAsset.getId()) != null - newWindAsset = assetStorageService.find(newWindAsset.getId()) - assert newWindAsset.getAttribute(ElectricityProducerWindAsset.POWER).flatMap { it.value }.orElse(0d) != 0d - assert newWindAsset.getAttribute(ElectricityProducerWindAsset.POWER_FORECAST).flatMap { it.value }.orElse(0d) != 0d - assert assetPredictedDatapointService.getDatapoints(new AttributeRef(newWindAsset.getId(), ElectricityProducerWindAsset.POWER.getName())).size() > 0 - assert assetPredictedDatapointService.getDatapoints(new AttributeRef(newWindAsset.getId(), ElectricityProducerWindAsset.POWER_FORECAST.getName())).size() > 0 - } + when: "an asset is added with includeForecastWindService set to true" + def newWindAsset = new ElectricityProducerWindAsset("WindAsset") + newWindAsset.setParentId(managerTestSetup.electricityOptimisationAssetId) + newWindAsset.setRealm(managerTestSetup.realmEnergyName) + newWindAsset.setWindSpeedMax(18d) + newWindAsset.setWindSpeedMin(2d) + newWindAsset.setWindSpeedReference(12d) + newWindAsset.setEfficiencyExport(100) + newWindAsset.setPowerExportMax(2.5) + newWindAsset.setLocation(new GeoJSONPoint(9.195295, 48.787418)) + newWindAsset.setSetActualWindValueWithForecast(true) + newWindAsset.setIncludeForecastWindService(true) + newWindAsset = assetStorageService.merge(newWindAsset) - when: "an asset is added with includeForecastWindService set to false" - def newWindAsset2 = new ElectricityProducerWindAsset("WindAsset2") - newWindAsset2.setParentId(managerTestSetup.electricityOptimisationAssetId) - newWindAsset2.setRealm(managerTestSetup.realmEnergyName) - newWindAsset2.setWindSpeedMax(18d); - newWindAsset2.setWindSpeedMin(2d); - newWindAsset2.setWindSpeedReference(12d); - newWindAsset2.setEfficiencyExport(100); - newWindAsset2.setPowerExportMax(2.5); - newWindAsset2.setLocation(new GeoJSONPoint(9.195275, 48.787418)); - newWindAsset2.setSetActualWindValueWithForecast(false); - newWindAsset2.setIncludeForecastWindService(false); - newWindAsset2 = assetStorageService.merge(newWindAsset2) + then: "the assetId should be present in the calculationFutures" + conditions.eventually { + assert forecastWindService.calculationFutures.get(newWindAsset.getId()) != null + newWindAsset = assetStorageService.find(newWindAsset.getId()) + assert newWindAsset.getAttribute(ElectricityProducerWindAsset.POWER).flatMap { + it.value + }.orElse(0d) != 0d + assert newWindAsset.getAttribute(ElectricityProducerWindAsset.POWER_FORECAST).flatMap { + it.value + }.orElse(0d) != 0d + assert assetPredictedDatapointService.getDatapoints(new AttributeRef(newWindAsset.getId(), ElectricityProducerWindAsset.POWER.getName())).size() > 0 + assert assetPredictedDatapointService.getDatapoints(new AttributeRef(newWindAsset.getId(), ElectricityProducerWindAsset.POWER_FORECAST.getName())).size() > 0 + } - then: "the assetId shouldn't be present in the calculationFutures" - conditions.eventually { - assert forecastWindService.calculationFutures.get(newWindAsset2.getId()) == null - } + when: "an asset is added with includeForecastWindService set to false" + def newWindAsset2 = new ElectricityProducerWindAsset("WindAsset2") + newWindAsset2.setParentId(managerTestSetup.electricityOptimisationAssetId) + newWindAsset2.setRealm(managerTestSetup.realmEnergyName) + newWindAsset2.setWindSpeedMax(18d) + newWindAsset2.setWindSpeedMin(2d) + newWindAsset2.setWindSpeedReference(12d) + newWindAsset2.setEfficiencyExport(100) + newWindAsset2.setPowerExportMax(2.5) + newWindAsset2.setLocation(new GeoJSONPoint(9.195275, 48.787418)) + newWindAsset2.setSetActualWindValueWithForecast(false) + newWindAsset2.setIncludeForecastWindService(false) + newWindAsset2 = assetStorageService.merge(newWindAsset2) - when: "an asset updated it's includeForecastWindService to true" - assetProcessingService.sendAttributeEvent(new AttributeEvent(newWindAsset2.getId(), ElectricityProducerWindAsset.INCLUDE_FORECAST_WIND_SERVICE.name, true)) + then: "the assetId shouldn't be present in the calculationFutures" + conditions.eventually { + assert forecastWindService.calculationFutures.get(newWindAsset2.getId()) == null + } - then: "it should be present present in the calculationFutures" - conditions.eventually { - assert forecastWindService.calculationFutures.get(newWindAsset2.getId()) != null - newWindAsset2 = assetStorageService.find(newWindAsset2.getId()) - assert newWindAsset2.getAttribute(ElectricityProducerWindAsset.POWER).flatMap { it.value }.orElse(0d) == 0d - assert newWindAsset2.getAttribute(ElectricityProducerWindAsset.POWER_FORECAST).flatMap { it.value }.orElse(0d) != 0d - assert assetPredictedDatapointService.getDatapoints(new AttributeRef(newWindAsset2.getId(), ElectricityProducerWindAsset.POWER.getName())).size() > 0 - assert assetPredictedDatapointService.getDatapoints(new AttributeRef(newWindAsset2.getId(), ElectricityProducerWindAsset.POWER_FORECAST.getName())).size() > 0 - } + when: "an asset updated it's includeForecastWindService to true" + assetProcessingService.sendAttributeEvent(new AttributeEvent(newWindAsset2.getId(), ElectricityProducerWindAsset.INCLUDE_FORECAST_WIND_SERVICE.name, true)) - when: "an asset updated it's setActualValueWithForecast to true" - assetProcessingService.sendAttributeEvent(new AttributeEvent(newWindAsset2.getId(), ElectricityProducerWindAsset.SET_ACTUAL_WIND_VALUE_WITH_FORECAST.name, true)) + then: "it should be present present in the calculationFutures" + conditions.eventually { + assert forecastWindService.calculationFutures.get(newWindAsset2.getId()) != null + newWindAsset2 = assetStorageService.find(newWindAsset2.getId()) + assert newWindAsset2.getAttribute(ElectricityProducerWindAsset.POWER).flatMap { + it.value + }.orElse(0d) == 0d + assert newWindAsset2.getAttribute(ElectricityProducerWindAsset.POWER_FORECAST).flatMap { + it.value + }.orElse(0d) != 0d + assert assetPredictedDatapointService.getDatapoints(new AttributeRef(newWindAsset2.getId(), ElectricityProducerWindAsset.POWER.getName())).size() > 0 + assert assetPredictedDatapointService.getDatapoints(new AttributeRef(newWindAsset2.getId(), ElectricityProducerWindAsset.POWER_FORECAST.getName())).size() > 0 + } - then: "it power should be updated too" - conditions.eventually { - newWindAsset2 = assetStorageService.find(newWindAsset2.getId()) - assert newWindAsset2.getAttribute(ElectricityProducerWindAsset.POWER).flatMap { it.value }.orElse(0d) != 0d - } + when: "an asset updated it's setActualValueWithForecast to true" + assetProcessingService.sendAttributeEvent(new AttributeEvent(newWindAsset2.getId(), ElectricityProducerWindAsset.SET_ACTUAL_WIND_VALUE_WITH_FORECAST.name, true)) - when: "an asset updated it's includeForecastWindService to false" - assetProcessingService.sendAttributeEvent(new AttributeEvent(newWindAsset2.getId(), ElectricityProducerWindAsset.INCLUDE_FORECAST_WIND_SERVICE.name, false)) + then: "it power should be updated too" + conditions.eventually { + newWindAsset2 = assetStorageService.find(newWindAsset2.getId()) + assert newWindAsset2.getAttribute(ElectricityProducerWindAsset.POWER).flatMap { + it.value + }.orElse(0d) != 0d + } - then: "it shouldn't be present present in the calculationFutures" - conditions.eventually { - assert forecastWindService.calculationFutures.get(newWindAsset2.getId()) == null - } + when: "an asset updated it's includeForecastWindService to false" + assetProcessingService.sendAttributeEvent(new AttributeEvent(newWindAsset2.getId(), ElectricityProducerWindAsset.INCLUDE_FORECAST_WIND_SERVICE.name, false)) - when: "an asset is added with includeForecastWindService set to true but no location" - def newWindAsset3 = new ElectricityProducerWindAsset("WindAsset3") - newWindAsset3.setParentId(managerTestSetup.electricityOptimisationAssetId) - newWindAsset3.setRealm(managerTestSetup.realmEnergyName) - newWindAsset3.setWindSpeedMax(18d); - newWindAsset3.setWindSpeedMin(2d); - newWindAsset3.setWindSpeedReference(12d); - newWindAsset3.setEfficiencyExport(100); - newWindAsset3.setPowerExportMax(2.5); - newWindAsset3.setSetActualWindValueWithForecast(true); - newWindAsset3.setIncludeForecastWindService(true); - newWindAsset3 = assetStorageService.merge(newWindAsset3) + then: "it shouldn't be present present in the calculationFutures" + conditions.eventually { + assert forecastWindService.calculationFutures.get(newWindAsset2.getId()) == null + } - then: "the assetId shouldn't be present in the calculationFutures" - conditions.eventually { - assert forecastWindService.calculationFutures.get(newWindAsset3.getId()) == null - } + when: "an asset is added with includeForecastWindService set to true but no location" + def newWindAsset3 = new ElectricityProducerWindAsset("WindAsset3") + newWindAsset3.setParentId(managerTestSetup.electricityOptimisationAssetId) + newWindAsset3.setRealm(managerTestSetup.realmEnergyName) + newWindAsset3.setWindSpeedMax(18d) + newWindAsset3.setWindSpeedMin(2d) + newWindAsset3.setWindSpeedReference(12d) + newWindAsset3.setEfficiencyExport(100) + newWindAsset3.setPowerExportMax(2.5) + newWindAsset3.setSetActualWindValueWithForecast(true) + newWindAsset3.setIncludeForecastWindService(true) + newWindAsset3 = assetStorageService.merge(newWindAsset3) - when: "an asset updated it's location to have a value" - assetProcessingService.sendAttributeEvent(new AttributeEvent(newWindAsset3.getId(), Asset.LOCATION.name, new GeoJSONPoint(9.195275, 48.787418))) + then: "the assetId shouldn't be present in the calculationFutures" + conditions.eventually { + assert forecastWindService.calculationFutures.get(newWindAsset3.getId()) == null + } - and: "trigger the service by setting includeForecastWindService to true again " - assetProcessingService.sendAttributeEvent(new AttributeEvent(newWindAsset3.getId(), ElectricityProducerWindAsset.INCLUDE_FORECAST_WIND_SERVICE.name, true)) + when: "an asset updated it's location to have a value" + assetProcessingService.sendAttributeEvent(new AttributeEvent(newWindAsset3.getId(), Asset.LOCATION.name, new GeoJSONPoint(9.195275, 48.787418))) - then: "it should be present present in the calculationFutures" - conditions.eventually { - assert forecastWindService.calculationFutures.get(newWindAsset3.getId()) != null - newWindAsset3 = assetStorageService.find(newWindAsset3.getId()) - assert newWindAsset3.getAttribute(ElectricityProducerWindAsset.POWER).flatMap { it.value }.orElse(0d) != 0d - assert newWindAsset3.getAttribute(ElectricityProducerWindAsset.POWER_FORECAST).flatMap { it.value }.orElse(0d) != 0d - assert assetPredictedDatapointService.getDatapoints(new AttributeRef(newWindAsset3.getId(), ElectricityProducerWindAsset.POWER.getName())).size() > 0 - assert assetPredictedDatapointService.getDatapoints(new AttributeRef(newWindAsset3.getId(), ElectricityProducerWindAsset.POWER_FORECAST.getName())).size() > 0 - } + and: "trigger the service by setting includeForecastWindService to true again " + assetProcessingService.sendAttributeEvent(new AttributeEvent(newWindAsset3.getId(), ElectricityProducerWindAsset.INCLUDE_FORECAST_WIND_SERVICE.name, true)) - cleanup: "disable mock filter" - mockServer.finished = true + then: "it should be present present in the calculationFutures" + conditions.eventually { + assert forecastWindService.calculationFutures.get(newWindAsset3.getId()) != null + newWindAsset3 = assetStorageService.find(newWindAsset3.getId()) + assert newWindAsset3.getAttribute(ElectricityProducerWindAsset.POWER).flatMap { + it.value + }.orElse(0d) != 0d + assert newWindAsset3.getAttribute(ElectricityProducerWindAsset.POWER_FORECAST).flatMap { + it.value + }.orElse(0d) != 0d + assert assetPredictedDatapointService.getDatapoints(new AttributeRef(newWindAsset3.getId(), ElectricityProducerWindAsset.POWER.getName())).size() > 0 + assert assetPredictedDatapointService.getDatapoints(new AttributeRef(newWindAsset3.getId(), ElectricityProducerWindAsset.POWER_FORECAST.getName())).size() > 0 } + + cleanup: "disable mock filter" + mockServer.finished = true + } } diff --git a/energy/src/test/groovy/org/openremote/extension/energy/KeycloakTestSetup.groovy b/energy/src/test/groovy/org/openremote/extension/energy/KeycloakTestSetup.groovy index db360c1..768bee8 100644 --- a/energy/src/test/groovy/org/openremote/extension/energy/KeycloakTestSetup.groovy +++ b/energy/src/test/groovy/org/openremote/extension/energy/KeycloakTestSetup.groovy @@ -24,15 +24,15 @@ import org.openremote.model.security.Realm class KeycloakTestSetup extends AbstractKeycloakSetup { - Realm realmEnergy + Realm realmEnergy - KeycloakTestSetup(Container container) { - super(container) - } + KeycloakTestSetup(Container container) { + super(container) + } - @Override - void onStart() throws Exception { - super.onStart() - realmEnergy = createRealm("energy", "Energy Test", true) - } + @Override + void onStart() throws Exception { + super.onStart() + realmEnergy = createRealm("energy", "Energy Test", true) + } } diff --git a/energy/src/test/groovy/org/openremote/extension/energy/ManagerTestSetup.groovy b/energy/src/test/groovy/org/openremote/extension/energy/ManagerTestSetup.groovy index 818cb62..7fdd27c 100644 --- a/energy/src/test/groovy/org/openremote/extension/energy/ManagerTestSetup.groovy +++ b/energy/src/test/groovy/org/openremote/extension/energy/ManagerTestSetup.groovy @@ -34,103 +34,103 @@ import org.openremote.model.geo.GeoJSONPoint class ManagerTestSetup extends ManagerSetup { - String realmEnergyName - String electricityOptimisationAssetId - String electricityConsumerAssetId - String electricitySolarAssetId - String electricityWindAssetId - String electricitySupplierAssetId - String electricityBatteryAssetId + String realmEnergyName + String electricityOptimisationAssetId + String electricityConsumerAssetId + String electricitySolarAssetId + String electricityWindAssetId + String electricitySupplierAssetId + String electricityBatteryAssetId - ManagerTestSetup(Container container) { - super(container) - } + ManagerTestSetup(Container container) { + super(container) + } - @Override - void onStart() throws Exception { - super.onStart() + @Override + void onStart() throws Exception { + super.onStart() - KeycloakTestSetup keycloakTestSetup = setupService.getTaskOfType(KeycloakTestSetup.class) - realmEnergyName = keycloakTestSetup.realmEnergy.getName() + KeycloakTestSetup keycloakTestSetup = setupService.getTaskOfType(KeycloakTestSetup.class) + realmEnergyName = keycloakTestSetup.realmEnergy.getName() - EnergyOptimisationAsset electricityOptimisationAsset = new EnergyOptimisationAsset("Optimisation") - electricityOptimisationAsset.setIntervalSize(3d) - electricityOptimisationAsset.setRealm(keycloakTestSetup.realmEnergy.getName()) - electricityOptimisationAsset.setFinancialWeighting(100) - electricityOptimisationAsset = assetStorageService.merge(electricityOptimisationAsset) - electricityOptimisationAssetId = electricityOptimisationAsset.getId() + EnergyOptimisationAsset electricityOptimisationAsset = new EnergyOptimisationAsset("Optimisation") + electricityOptimisationAsset.setIntervalSize(3d) + electricityOptimisationAsset.setRealm(keycloakTestSetup.realmEnergy.getName()) + electricityOptimisationAsset.setFinancialWeighting(100) + electricityOptimisationAsset = assetStorageService.merge(electricityOptimisationAsset) + electricityOptimisationAssetId = electricityOptimisationAsset.getId() - ElectricityConsumerAsset electricityConsumerAsset = new ElectricityConsumerAsset("Consumer") - electricityConsumerAsset.setParent(electricityOptimisationAsset) - electricityConsumerAsset.getAttribute(ElectricityAsset.POWER).ifPresent(attr -> - attr.addMeta(new MetaItem<>(HAS_PREDICTED_DATA_POINTS)) - ) - electricityConsumerAsset = assetStorageService.merge(electricityConsumerAsset) - electricityConsumerAssetId = electricityConsumerAsset.getId() + ElectricityConsumerAsset electricityConsumerAsset = new ElectricityConsumerAsset("Consumer") + electricityConsumerAsset.setParent(electricityOptimisationAsset) + electricityConsumerAsset.getAttribute(ElectricityAsset.POWER).ifPresent(attr -> + attr.addMeta(new MetaItem<>(HAS_PREDICTED_DATA_POINTS)) + ) + electricityConsumerAsset = assetStorageService.merge(electricityConsumerAsset) + electricityConsumerAssetId = electricityConsumerAsset.getId() - ElectricityProducerSolarAsset electricitySolarAsset = new ElectricityProducerSolarAsset("Producer") - electricitySolarAsset.setParent(electricityOptimisationAsset) - electricitySolarAsset.getAttribute(ElectricityAsset.POWER).ifPresent(attr -> - attr.addMeta(new MetaItem<>(HAS_PREDICTED_DATA_POINTS)) - ) - electricitySolarAsset.setPanelOrientation(ElectricityProducerSolarAsset.PanelOrientation.SOUTH) - electricitySolarAsset.setPanelAzimuth(0) - electricitySolarAsset.setPanelPitch(30) - electricitySolarAsset.setEfficiencyExport(100) - electricitySolarAsset.setPowerExportMax(2.5) - electricitySolarAsset.setLocation(new GeoJSONPoint(9.195285, 48.787418)) - electricitySolarAsset.setSetActualSolarValueWithForecast(true) - electricitySolarAsset.setIncludeForecastSolarService(true) - electricitySolarAsset = assetStorageService.merge(electricitySolarAsset) - electricitySolarAssetId = electricitySolarAsset.getId() + ElectricityProducerSolarAsset electricitySolarAsset = new ElectricityProducerSolarAsset("Producer") + electricitySolarAsset.setParent(electricityOptimisationAsset) + electricitySolarAsset.getAttribute(ElectricityAsset.POWER).ifPresent(attr -> + attr.addMeta(new MetaItem<>(HAS_PREDICTED_DATA_POINTS)) + ) + electricitySolarAsset.setPanelOrientation(ElectricityProducerSolarAsset.PanelOrientation.SOUTH) + electricitySolarAsset.setPanelAzimuth(0) + electricitySolarAsset.setPanelPitch(30) + electricitySolarAsset.setEfficiencyExport(100) + electricitySolarAsset.setPowerExportMax(2.5) + electricitySolarAsset.setLocation(new GeoJSONPoint(9.195285, 48.787418)) + electricitySolarAsset.setSetActualSolarValueWithForecast(true) + electricitySolarAsset.setIncludeForecastSolarService(true) + electricitySolarAsset = assetStorageService.merge(electricitySolarAsset) + electricitySolarAssetId = electricitySolarAsset.getId() - ElectricityProducerWindAsset electricityWindAsset = new ElectricityProducerWindAsset("Wind Turbine") - electricityWindAsset.setParent(electricityOptimisationAsset) - electricityWindAsset.getAttribute(ElectricityAsset.POWER).ifPresent(attr -> - attr.addMeta(new MetaItem<>(HAS_PREDICTED_DATA_POINTS)) - ) - electricityWindAsset.setWindSpeedMax(18d) - electricityWindAsset.setWindSpeedMin(2d) - electricityWindAsset.setWindSpeedReference(12d) - electricityWindAsset.setPowerExportMax(9000d) - electricityWindAsset.setEfficiencyExport(100) - electricityWindAsset.setPowerExportMax(2.5) - electricityWindAsset.setLocation(new GeoJSONPoint(9.195285, 48.787418)) - electricityWindAsset.setSetActualWindValueWithForecast(true) - electricityWindAsset.setIncludeForecastWindService(true) - electricityWindAsset = assetStorageService.merge(electricityWindAsset) - electricityWindAssetId = electricityWindAsset.getId() + ElectricityProducerWindAsset electricityWindAsset = new ElectricityProducerWindAsset("Wind Turbine") + electricityWindAsset.setParent(electricityOptimisationAsset) + electricityWindAsset.getAttribute(ElectricityAsset.POWER).ifPresent(attr -> + attr.addMeta(new MetaItem<>(HAS_PREDICTED_DATA_POINTS)) + ) + electricityWindAsset.setWindSpeedMax(18d) + electricityWindAsset.setWindSpeedMin(2d) + electricityWindAsset.setWindSpeedReference(12d) + electricityWindAsset.setPowerExportMax(9000d) + electricityWindAsset.setEfficiencyExport(100) + electricityWindAsset.setPowerExportMax(2.5) + electricityWindAsset.setLocation(new GeoJSONPoint(9.195285, 48.787418)) + electricityWindAsset.setSetActualWindValueWithForecast(true) + electricityWindAsset.setIncludeForecastWindService(true) + electricityWindAsset = assetStorageService.merge(electricityWindAsset) + electricityWindAssetId = electricityWindAsset.getId() - ElectricityBatteryAsset electricityBatteryAsset = new ElectricityBatteryAsset("Battery") - electricityBatteryAsset.setParent(electricityOptimisationAsset) - electricityBatteryAsset.setEnergyCapacity(200d) - electricityBatteryAsset.setEnergyLevelPercentageMin(20) - electricityBatteryAsset.setEnergyLevelPercentageMax(80) - electricityBatteryAsset.setEnergyLevel(100d) - electricityBatteryAsset.setPowerImportMax(7d) - electricityBatteryAsset.setPowerExportMax(20d) - electricityBatteryAsset.setPowerSetpoint(0d) - electricityBatteryAsset.setEfficiencyImport(95) - electricityBatteryAsset.setEfficiencyExport(98) - electricityBatteryAsset.setSupportsExport(true) - electricityBatteryAsset.setSupportsImport(true) - electricityBatteryAsset.getAttribute(ElectricityAsset.POWER_SETPOINT).ifPresent(attr -> - attr.addMeta(new MetaItem<>(HAS_PREDICTED_DATA_POINTS)) - ) - electricityBatteryAsset = assetStorageService.merge(electricityBatteryAsset) - electricityBatteryAssetId = electricityBatteryAsset.getId() + ElectricityBatteryAsset electricityBatteryAsset = new ElectricityBatteryAsset("Battery") + electricityBatteryAsset.setParent(electricityOptimisationAsset) + electricityBatteryAsset.setEnergyCapacity(200d) + electricityBatteryAsset.setEnergyLevelPercentageMin(20) + electricityBatteryAsset.setEnergyLevelPercentageMax(80) + electricityBatteryAsset.setEnergyLevel(100d) + electricityBatteryAsset.setPowerImportMax(7d) + electricityBatteryAsset.setPowerExportMax(20d) + electricityBatteryAsset.setPowerSetpoint(0d) + electricityBatteryAsset.setEfficiencyImport(95) + electricityBatteryAsset.setEfficiencyExport(98) + electricityBatteryAsset.setSupportsExport(true) + electricityBatteryAsset.setSupportsImport(true) + electricityBatteryAsset.getAttribute(ElectricityAsset.POWER_SETPOINT).ifPresent(attr -> + attr.addMeta(new MetaItem<>(HAS_PREDICTED_DATA_POINTS)) + ) + electricityBatteryAsset = assetStorageService.merge(electricityBatteryAsset) + electricityBatteryAssetId = electricityBatteryAsset.getId() - ElectricitySupplierAsset electricitySupplierAsset = new ElectricitySupplierAsset("Supplier") - electricitySupplierAsset.setParent(electricityOptimisationAsset) - electricitySupplierAsset.setTariffExport(-0.05) - electricitySupplierAsset.setTariffImport(0.08) - electricitySupplierAsset.getAttribute(ElectricityAsset.TARIFF_IMPORT).ifPresent(attr -> - attr.addMeta(new MetaItem<>(HAS_PREDICTED_DATA_POINTS)) - ) - electricitySupplierAsset.getAttribute(ElectricityAsset.TARIFF_EXPORT).ifPresent(attr -> - attr.addMeta(new MetaItem<>(HAS_PREDICTED_DATA_POINTS)) - ) - electricitySupplierAsset = assetStorageService.merge(electricitySupplierAsset) - electricitySupplierAssetId = electricitySupplierAsset.getId() - } + ElectricitySupplierAsset electricitySupplierAsset = new ElectricitySupplierAsset("Supplier") + electricitySupplierAsset.setParent(electricityOptimisationAsset) + electricitySupplierAsset.setTariffExport(-0.05) + electricitySupplierAsset.setTariffImport(0.08) + electricitySupplierAsset.getAttribute(ElectricityAsset.TARIFF_IMPORT).ifPresent(attr -> + attr.addMeta(new MetaItem<>(HAS_PREDICTED_DATA_POINTS)) + ) + electricitySupplierAsset.getAttribute(ElectricityAsset.TARIFF_EXPORT).ifPresent(attr -> + attr.addMeta(new MetaItem<>(HAS_PREDICTED_DATA_POINTS)) + ) + electricitySupplierAsset = assetStorageService.merge(electricitySupplierAsset) + electricitySupplierAssetId = electricitySupplierAsset.getId() + } } diff --git a/energy/src/test/groovy/org/openremote/extension/energy/TestSetupTasks.groovy b/energy/src/test/groovy/org/openremote/extension/energy/TestSetupTasks.groovy index 29e3d99..ce0fca4 100644 --- a/energy/src/test/groovy/org/openremote/extension/energy/TestSetupTasks.groovy +++ b/energy/src/test/groovy/org/openremote/extension/energy/TestSetupTasks.groovy @@ -24,8 +24,8 @@ import org.openremote.model.setup.SetupTasks class TestSetupTasks implements SetupTasks { - @Override - List createTasks(Container container, String setupType, boolean keycloakEnabled) { - return List.of(new KeycloakTestSetup(container), new ManagerTestSetup(container)) - } + @Override + List createTasks(Container container, String setupType, boolean keycloakEnabled) { + return List.of(new KeycloakTestSetup(container), new ManagerTestSetup(container)) + } } diff --git a/entsoe/src/test/groovy/org/openremote/extension/entsoe/agent/protocol/EntsoeProtocolTest.groovy b/entsoe/src/test/groovy/org/openremote/extension/entsoe/agent/protocol/EntsoeProtocolTest.groovy index 623d463..3bc4b7b 100644 --- a/entsoe/src/test/groovy/org/openremote/extension/entsoe/agent/protocol/EntsoeProtocolTest.groovy +++ b/entsoe/src/test/groovy/org/openremote/extension/entsoe/agent/protocol/EntsoeProtocolTest.groovy @@ -51,43 +51,43 @@ import static org.openremote.model.value.ValueType.NUMBER @Issue("https://github.com/openremote/openremote/issues/2599") class EntsoeProtocolTest extends Specification implements ManagerContainerTrait { - private static final String DATASET_START = "2026-02-16T23:00:00.000Z" - private static final String BEFORE_DATASET_START = "2026-02-16T22:00:00.000Z" - - @Shared - Map requestCountByZone = [:].withDefault { 0 } - @Shared - def validator = Validation.buildDefaultValidatorFactory().validator - @Shared - List> requestLog = Collections.synchronizedList(new ArrayList<>()) - - @Shared - def mockServer = new ClientRequestFilter() { - - @Override - void filter(ClientRequestContext requestContext) throws IOException { - // We want the call to take at least 1ms or we get issues with attribute events being ignored as outdated - Thread.sleep(1) - def requestUri = requestContext.uri - - if (requestUri.host == "web-api.tp.entsoe.eu" && requestUri.path == "/api") { - def queryParams = [:] - requestUri.query?.split("&")?.each { pair -> - def parts = pair.split("=", 2) - if (parts.length == 2) { - queryParams[parts[0]] = parts[1] - } - } - def zone = queryParams["in_Domain"] - requestLog.add([ - zone : queryParams["in_Domain"], - periodStart: queryParams["periodStart"], - periodEnd : queryParams["periodEnd"] - ]) - - def content - if (zone == "10YBE----------2") { - content = ''' + private static final String DATASET_START = "2026-02-16T23:00:00.000Z" + private static final String BEFORE_DATASET_START = "2026-02-16T22:00:00.000Z" + + @Shared + Map requestCountByZone = [:].withDefault { 0 } + @Shared + def validator = Validation.buildDefaultValidatorFactory().validator + @Shared + List> requestLog = Collections.synchronizedList(new ArrayList<>()) + + @Shared + def mockServer = new ClientRequestFilter() { + + @Override + void filter(ClientRequestContext requestContext) throws IOException { + // We want the call to take at least 1ms or we get issues with attribute events being ignored as outdated + Thread.sleep(1) + def requestUri = requestContext.uri + + if (requestUri.host == "web-api.tp.entsoe.eu" && requestUri.path == "/api") { + def queryParams = [:] + requestUri.query?.split("&")?.each { pair -> + def parts = pair.split("=", 2) + if (parts.length == 2) { + queryParams[parts[0]] = parts[1] + } + } + def zone = queryParams["in_Domain"] + requestLog.add([ + zone : queryParams["in_Domain"], + periodStart: queryParams["periodStart"], + periodEnd : queryParams["periodEnd"] + ]) + + def content + if (zone == "10YBE----------2") { + content = ''' 68f443af488b4a0a9c4442bddd8c59c0 1 @@ -137,8 +137,8 @@ class EntsoeProtocolTest extends Specification implements ManagerContainerTrait ''' - } else if (zone == "10YNL----------L") { - content = ''' + } else if (zone == "10YNL----------L") { + content = ''' bbf443af488b4a0a9c4442bddd8c59c9 1 @@ -177,11 +177,11 @@ class EntsoeProtocolTest extends Specification implements ManagerContainerTrait ''' - } else if (zone == "10YER----------X") { - requestCountByZone[zone] = requestCountByZone[zone] + 1 + } else if (zone == "10YER----------X") { + requestCountByZone[zone] = requestCountByZone[zone] + 1 - if (requestCountByZone[zone] == 1) { - content = ''' + if (requestCountByZone[zone] == 1) { + content = ''' 2026-02-16T23:00Z @@ -214,12 +214,12 @@ class EntsoeProtocolTest extends Specification implements ManagerContainerTrait ''' - } else { - requestContext.abortWith(Response.status(500).build()) - return - } - } else if (zone == "10YNDATA-------A") { - content = ''' + } else { + requestContext.abortWith(Response.status(500).build()) + return + } + } else if (zone == "10YNDATA-------A") { + content = ''' 0985f391-49af-4 2026-03-11T06:29:10Z @@ -229,8 +229,8 @@ class EntsoeProtocolTest extends Specification implements ManagerContainerTrait ''' - } else if (zone == "10YXXE---------X") { - content = ''' + } else if (zone == "10YXXE---------X") { + content = ''' ]> @@ -254,8 +254,8 @@ class EntsoeProtocolTest extends Specification implements ManagerContainerTrait ''' - } else if (zone == "10YGAP---------G") { - content = ''' + } else if (zone == "10YGAP---------G") { + content = ''' 2026-02-16T23:00Z @@ -284,8 +284,8 @@ class EntsoeProtocolTest extends Specification implements ManagerContainerTrait ''' - } else if (zone == "10YMULTI-------A") { - content = ''' + } else if (zone == "10YMULTI-------A") { + content = ''' 2026-02-16T23:00Z @@ -327,941 +327,963 @@ class EntsoeProtocolTest extends Specification implements ManagerContainerTrait ''' - } else { - requestContext.abortWith(Response.serverError().build()) - return - } - - requestContext.abortWith(Response.ok(content, MediaType.APPLICATION_XML_TYPE).build()) - return - } - - requestContext.abortWith(Response.serverError().build()) - } - } - - def "ENTSO-E integration test writes predicted datapoints from publication document"() { - given: "the container environment is started" - requestCountByZone.clear() - def conditions = new PollingConditions(timeout: 10, delay: 0.2) - - EntsoeProtocol.initClient() - - if (!EntsoeProtocol.client.get().configuration.isRegistered(mockServer)) { - EntsoeProtocol.client.get().register(mockServer, Integer.MAX_VALUE) - } - - def container = startContainer(defaultConfig(), defaultServices()) - setPseudoClock(BEFORE_DATASET_START) - def assetStorageService = container.getService(AssetStorageService.class) - def assetPredictedDatapointService = container.getService(AssetPredictedDatapointService.class) - def agentService = container.getService(AgentService.class) - EntsoeAgent agent = null - ThingAsset asset = null - - when: "an ENTSO-E agent is created" - agent = new EntsoeAgent("ENTSO-E Agent") - .setRealm(MASTER_REALM) - .setSecurityToken("test-token") - agent = assetStorageService.merge(agent) - - then: "the protocol instance for the agent should be created and connected" - conditions.eventually { - assert agentService.getProtocolInstance(agent.id) != null - assert ((EntsoeProtocol) agentService.getProtocolInstance(agent.id)) != null - assert agentService.getAgent(agent.id).getAgentStatus().orElse(null) == ConnectionStatus.CONNECTED + } else { + requestContext.abortWith(Response.serverError().build()) + return } - when: "an asset attribute is linked to the ENTSO-E agent" - def entsoeLink = new EntsoeAgentLink(agent.id) - entsoeLink.setZone("10YBE----------2") + requestContext.abortWith(Response.ok(content, MediaType.APPLICATION_XML_TYPE).build()) + return + } - asset = new ThingAsset("Energy Price Asset") - .setRealm(MASTER_REALM) - .addOrReplaceAttributes( - new Attribute<>("energyPrice", NUMBER) - .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, entsoeLink)) - ) - asset = assetStorageService.merge(asset) + requestContext.abortWith(Response.serverError().build()) + } + } - def attributeRef = new AttributeRef(asset.id, "energyPrice") - def protocol = (EntsoeProtocol) agentService.getProtocolInstance(agent.id) + def "ENTSO-E integration test writes predicted datapoints from publication document"() { + given: "the container environment is started" + requestCountByZone.clear() + def conditions = new PollingConditions(timeout: 10, delay: 0.2) - and: "the attribute is linked by protocol" - conditions.eventually { - assert protocol.getLinkedAttributes().containsKey(attributeRef) - } + EntsoeProtocol.initClient() - and: "a polling update is triggered" - protocol.updateAllLinkedAttributes() + if (!EntsoeProtocol.client.get().configuration.isRegistered(mockServer)) { + EntsoeProtocol.client.get().register(mockServer, Integer.MAX_VALUE) + } - then: "predicted datapoints are written using period start and resolution" - conditions.eventually { - List datapoints = assetPredictedDatapointService.getDatapoints(attributeRef) - assert datapoints.size() == 4 + def container = startContainer(defaultConfig(), defaultServices()) + setPseudoClock(BEFORE_DATASET_START) + def assetStorageService = container.getService(AssetStorageService.class) + def assetPredictedDatapointService = container.getService(AssetPredictedDatapointService.class) + def agentService = container.getService(AgentService.class) + EntsoeAgent agent = null + ThingAsset asset = null + + when: "an ENTSO-E agent is created" + agent = new EntsoeAgent("ENTSO-E Agent") + .setRealm(MASTER_REALM) + .setSecurityToken("test-token") + agent = assetStorageService.merge(agent) + + then: "the protocol instance for the agent should be created and connected" + conditions.eventually { + assert agentService.getProtocolInstance(agent.id) != null + assert ((EntsoeProtocol) agentService.getProtocolInstance(agent.id)) != null + assert agentService.getAgent(agent.id).getAgentStatus().orElse(null) == ConnectionStatus.CONNECTED + } - def asc = datapoints.sort { it.timestamp } - def start = Instant.parse(DATASET_START).toEpochMilli() - def step = 15 * 60 * 1000L + when: "an asset attribute is linked to the ENTSO-E agent" + def entsoeLink = new EntsoeAgentLink(agent.id) + entsoeLink.setZone("10YBE----------2") - assert asc[0].timestamp == start - assert asc[1].timestamp == start + step - assert asc[2].timestamp == start + (2 * step) - assert asc[3].timestamp == start + (3 * step) + asset = new ThingAsset("Energy Price Asset") + .setRealm(MASTER_REALM) + .addOrReplaceAttributes( + new Attribute<>("energyPrice", NUMBER) + .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, entsoeLink)) + ) + asset = assetStorageService.merge(asset) - assert (asc[0].value as BigDecimal).compareTo(73.24G) == 0 - assert (asc[1].value as BigDecimal).compareTo(69.79G) == 0 - assert (asc[2].value as BigDecimal).compareTo(65.84G) == 0 - assert (asc[3].value as BigDecimal).compareTo(65.05G) == 0 - } + def attributeRef = new AttributeRef(asset.id, "energyPrice") + def protocol = (EntsoeProtocol) agentService.getProtocolInstance(agent.id) - cleanup: "remove created assets and mock client" - if (asset?.id) { - assetStorageService.delete([asset.id]) - } - if (agent?.id) { - assetStorageService.delete([agent.id]) - } - closeClient() + and: "the attribute is linked by protocol" + conditions.eventually { + assert protocol.getLinkedAttributes().containsKey(attributeRef) } - @Ignore - def "ENTSO-E integration test filters out points in the past when clock is mid-period"() { - given: "the container environment is started with clock in the middle of the dataset period" - requestCountByZone.clear() - def conditions = new PollingConditions(timeout: 10, delay: 0.2) + and: "a polling update is triggered" + protocol.updateAllLinkedAttributes() - EntsoeProtocol.initClient() + then: "predicted datapoints are written using period start and resolution" + conditions.eventually { + List datapoints = assetPredictedDatapointService.getDatapoints(attributeRef) + assert datapoints.size() == 4 - if (!EntsoeProtocol.client.get().configuration.isRegistered(mockServer)) { - EntsoeProtocol.client.get().register(mockServer, Integer.MAX_VALUE) - } + def asc = datapoints.sort { it.timestamp } + def start = Instant.parse(DATASET_START).toEpochMilli() + def step = 15 * 60 * 1000L - def container = startContainer(defaultConfig(), defaultServices()) - setPseudoClock("2026-02-16T23:20:00.000Z") - def assetStorageService = container.getService(AssetStorageService.class) - def assetPredictedDatapointService = container.getService(AssetPredictedDatapointService.class) - def agentService = container.getService(AgentService.class) - EntsoeAgent agent = null - ThingAsset asset = null - - when: "an ENTSO-E agent and linked attribute are created" - agent = new EntsoeAgent("ENTSO-E Agent") - .setRealm(MASTER_REALM) - .setSecurityToken("test-token") - agent = assetStorageService.merge(agent) - - def entsoeLink = new EntsoeAgentLink(agent.id) - entsoeLink.setZone("10YBE----------2") - - asset = new ThingAsset("Energy Price Asset") - .setRealm(MASTER_REALM) - .addOrReplaceAttributes( - new Attribute<>("energyPrice", NUMBER) - .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, entsoeLink)) - ) - asset = assetStorageService.merge(asset) - - def attributeRef = new AttributeRef(asset.id, "energyPrice") - def protocol = null - - then: "the protocol is connected and attribute linked" - conditions.eventually { - protocol = (EntsoeProtocol) agentService.getProtocolInstance(agent.id) - assert protocol != null - assert agentService.getAgent(agent.id).getAgentStatus().orElse(null) == ConnectionStatus.CONNECTED - assert protocol.getLinkedAttributes().containsKey(attributeRef) - } + assert asc[0].timestamp == start + assert asc[1].timestamp == start + step + assert asc[2].timestamp == start + (2 * step) + assert asc[3].timestamp == start + (3 * step) - when: "a polling update is triggered" - protocol.updateAllLinkedAttributes() + assert (asc[0].value as BigDecimal).compareTo(73.24G) == 0 + assert (asc[1].value as BigDecimal).compareTo(69.79G) == 0 + assert (asc[2].value as BigDecimal).compareTo(65.84G) == 0 + assert (asc[3].value as BigDecimal).compareTo(65.05G) == 0 + } - then: "only datapoints after the cutoff time are stored" - conditions.eventually { - List datapoints = assetPredictedDatapointService.getDatapoints(attributeRef).sort { it.timestamp } - assert datapoints.size() == 2 + cleanup: "remove created assets and mock client" + if (asset?.id) { + assetStorageService.delete([asset.id]) + } + if (agent?.id) { + assetStorageService.delete([agent.id]) + } + closeClient() + } - def start = Instant.parse(DATASET_START).toEpochMilli() - def step = 15 * 60 * 1000L + @Ignore + def "ENTSO-E integration test filters out points in the past when clock is mid-period"() { + given: "the container environment is started with clock in the middle of the dataset period" + requestCountByZone.clear() + def conditions = new PollingConditions(timeout: 10, delay: 0.2) - assert datapoints[0].timestamp == start + (2 * step) - assert datapoints[1].timestamp == start + (3 * step) - assert (datapoints[0].value as BigDecimal).compareTo(65.84G) == 0 - assert (datapoints[1].value as BigDecimal).compareTo(65.05G) == 0 - } + EntsoeProtocol.initClient() - cleanup: "remove created assets and mock client" - if (asset?.id) { - assetStorageService.delete([asset.id]) - } - if (agent?.id) { - assetStorageService.delete([agent.id]) - } - closeClient() + if (!EntsoeProtocol.client.get().configuration.isRegistered(mockServer)) { + EntsoeProtocol.client.get().register(mockServer, Integer.MAX_VALUE) } - def "ENTSO-E integration test writes predicted datapoints for 2 linked attributes with different zones"() { - given: "the container environment is started" - requestCountByZone.clear() - def conditions = new PollingConditions(timeout: 10, delay: 0.2) - - EntsoeProtocol.initClient() - - if (!EntsoeProtocol.client.get().configuration.isRegistered(mockServer)) { - EntsoeProtocol.client.get().register(mockServer, Integer.MAX_VALUE) - } + def container = startContainer(defaultConfig(), defaultServices()) + setPseudoClock("2026-02-16T23:20:00.000Z") + def assetStorageService = container.getService(AssetStorageService.class) + def assetPredictedDatapointService = container.getService(AssetPredictedDatapointService.class) + def agentService = container.getService(AgentService.class) + EntsoeAgent agent = null + ThingAsset asset = null + + when: "an ENTSO-E agent and linked attribute are created" + agent = new EntsoeAgent("ENTSO-E Agent") + .setRealm(MASTER_REALM) + .setSecurityToken("test-token") + agent = assetStorageService.merge(agent) + + def entsoeLink = new EntsoeAgentLink(agent.id) + entsoeLink.setZone("10YBE----------2") + + asset = new ThingAsset("Energy Price Asset") + .setRealm(MASTER_REALM) + .addOrReplaceAttributes( + new Attribute<>("energyPrice", NUMBER) + .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, entsoeLink)) + ) + asset = assetStorageService.merge(asset) + + def attributeRef = new AttributeRef(asset.id, "energyPrice") + def protocol = null + + then: "the protocol is connected and attribute linked" + conditions.eventually { + protocol = (EntsoeProtocol) agentService.getProtocolInstance(agent.id) + assert protocol != null + assert agentService.getAgent(agent.id).getAgentStatus().orElse(null) == ConnectionStatus.CONNECTED + assert protocol.getLinkedAttributes().containsKey(attributeRef) + } - def container = startContainer(defaultConfig(), defaultServices()) - setPseudoClock(BEFORE_DATASET_START) - def assetStorageService = container.getService(AssetStorageService.class) - def assetPredictedDatapointService = container.getService(AssetPredictedDatapointService.class) - def agentService = container.getService(AgentService.class) - EntsoeAgent agent = null - ThingAsset asset = null - - when: "an ENTSO-E agent is created" - agent = new EntsoeAgent("ENTSO-E Agent") - .setRealm(MASTER_REALM) - .setSecurityToken("test-token") - agent = assetStorageService.merge(agent) - - then: "the protocol instance for the agent should be created and connected" - conditions.eventually { - assert agentService.getProtocolInstance(agent.id) != null - assert ((EntsoeProtocol) agentService.getProtocolInstance(agent.id)) != null - assert agentService.getAgent(agent.id).getAgentStatus().orElse(null) == ConnectionStatus.CONNECTED - } + when: "a polling update is triggered" + protocol.updateAllLinkedAttributes() - when: "2 attributes are linked to the same agent with different zones" - def beLink = new EntsoeAgentLink(agent.id) - beLink.setZone("10YBE----------2") - def nlLink = new EntsoeAgentLink(agent.id) - nlLink.setZone("10YNL----------L") - - asset = new ThingAsset("Multi Zone Energy Price Asset") - .setRealm(MASTER_REALM) - .addOrReplaceAttributes( - new Attribute<>("energyPriceBe", NUMBER) - .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, beLink)), - new Attribute<>("energyPriceNl", NUMBER) - .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, nlLink)) - ) - asset = assetStorageService.merge(asset) - - def beRef = new AttributeRef(asset.id, "energyPriceBe") - def nlRef = new AttributeRef(asset.id, "energyPriceNl") - def protocol = (EntsoeProtocol) agentService.getProtocolInstance(agent.id) - - and: "both attributes are linked by protocol" - conditions.eventually { - assert protocol.getLinkedAttributes().containsKey(beRef) - assert protocol.getLinkedAttributes().containsKey(nlRef) - } + then: "only datapoints after the cutoff time are stored" + conditions.eventually { + List datapoints = assetPredictedDatapointService.getDatapoints(attributeRef).sort { + it.timestamp + } + assert datapoints.size() == 2 - and: "a polling update is triggered" - protocol.updateAllLinkedAttributes() + def start = Instant.parse(DATASET_START).toEpochMilli() + def step = 15 * 60 * 1000L - then: "each attribute receives predicted datapoints for its configured zone" - conditions.eventually { - List beDatapoints = assetPredictedDatapointService.getDatapoints(beRef).sort { it.timestamp } - List nlDatapoints = assetPredictedDatapointService.getDatapoints(nlRef).sort { it.timestamp } + assert datapoints[0].timestamp == start + (2 * step) + assert datapoints[1].timestamp == start + (3 * step) + assert (datapoints[0].value as BigDecimal).compareTo(65.84G) == 0 + assert (datapoints[1].value as BigDecimal).compareTo(65.05G) == 0 + } - assert beDatapoints.size() == 4 - assert nlDatapoints.size() == 4 + cleanup: "remove created assets and mock client" + if (asset?.id) { + assetStorageService.delete([asset.id]) + } + if (agent?.id) { + assetStorageService.delete([agent.id]) + } + closeClient() + } - assert (beDatapoints[0].value as BigDecimal).compareTo(73.24G) == 0 - assert (beDatapoints[1].value as BigDecimal).compareTo(69.79G) == 0 - assert (beDatapoints[2].value as BigDecimal).compareTo(65.84G) == 0 - assert (beDatapoints[3].value as BigDecimal).compareTo(65.05G) == 0 + def "ENTSO-E integration test writes predicted datapoints for 2 linked attributes with different zones"() { + given: "the container environment is started" + requestCountByZone.clear() + def conditions = new PollingConditions(timeout: 10, delay: 0.2) - assert (nlDatapoints[0].value as BigDecimal).compareTo(81.11G) == 0 - assert (nlDatapoints[1].value as BigDecimal).compareTo(82.22G) == 0 - assert (nlDatapoints[2].value as BigDecimal).compareTo(83.33G) == 0 - assert (nlDatapoints[3].value as BigDecimal).compareTo(84.44G) == 0 - } + EntsoeProtocol.initClient() - cleanup: "remove created assets and mock client" - if (asset?.id) { - assetStorageService.delete([asset.id]) - } - if (agent?.id) { - assetStorageService.delete([agent.id]) - } - closeClient() + if (!EntsoeProtocol.client.get().configuration.isRegistered(mockServer)) { + EntsoeProtocol.client.get().register(mockServer, Integer.MAX_VALUE) } - def "ENTSO-E integration test fetches a shared zone only once per polling cycle"() { - given: "the container environment is started" - requestCountByZone.clear() - requestLog.clear() - def conditions = new PollingConditions(timeout: 10, delay: 0.2) - - EntsoeProtocol.initClient() + def container = startContainer(defaultConfig(), defaultServices()) + setPseudoClock(BEFORE_DATASET_START) + def assetStorageService = container.getService(AssetStorageService.class) + def assetPredictedDatapointService = container.getService(AssetPredictedDatapointService.class) + def agentService = container.getService(AgentService.class) + EntsoeAgent agent = null + ThingAsset asset = null + + when: "an ENTSO-E agent is created" + agent = new EntsoeAgent("ENTSO-E Agent") + .setRealm(MASTER_REALM) + .setSecurityToken("test-token") + agent = assetStorageService.merge(agent) + + then: "the protocol instance for the agent should be created and connected" + conditions.eventually { + assert agentService.getProtocolInstance(agent.id) != null + assert ((EntsoeProtocol) agentService.getProtocolInstance(agent.id)) != null + assert agentService.getAgent(agent.id).getAgentStatus().orElse(null) == ConnectionStatus.CONNECTED + } - if (!EntsoeProtocol.client.get().configuration.isRegistered(mockServer)) { - EntsoeProtocol.client.get().register(mockServer, Integer.MAX_VALUE) - } + when: "2 attributes are linked to the same agent with different zones" + def beLink = new EntsoeAgentLink(agent.id) + beLink.setZone("10YBE----------2") + def nlLink = new EntsoeAgentLink(agent.id) + nlLink.setZone("10YNL----------L") + + asset = new ThingAsset("Multi Zone Energy Price Asset") + .setRealm(MASTER_REALM) + .addOrReplaceAttributes( + new Attribute<>("energyPriceBe", NUMBER) + .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, beLink)), + new Attribute<>("energyPriceNl", NUMBER) + .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, nlLink)) + ) + asset = assetStorageService.merge(asset) + + def beRef = new AttributeRef(asset.id, "energyPriceBe") + def nlRef = new AttributeRef(asset.id, "energyPriceNl") + def protocol = (EntsoeProtocol) agentService.getProtocolInstance(agent.id) + + and: "both attributes are linked by protocol" + conditions.eventually { + assert protocol.getLinkedAttributes().containsKey(beRef) + assert protocol.getLinkedAttributes().containsKey(nlRef) + } - def container = startContainer(defaultConfig(), defaultServices()) - setPseudoClock(BEFORE_DATASET_START) - def assetStorageService = container.getService(AssetStorageService.class) - def assetPredictedDatapointService = container.getService(AssetPredictedDatapointService.class) - def agentService = container.getService(AgentService.class) - EntsoeAgent agent = null - ThingAsset asset = null - - when: "an ENTSO-E agent is created" - agent = new EntsoeAgent("ENTSO-E Agent") - .setRealm(MASTER_REALM) - .setSecurityToken("test-token") - agent = assetStorageService.merge(agent) - - then: "the protocol instance for the agent should be created and connected" - conditions.eventually { - assert agentService.getProtocolInstance(agent.id) != null - assert ((EntsoeProtocol) agentService.getProtocolInstance(agent.id)) != null - assert agentService.getAgent(agent.id).getAgentStatus().orElse(null) == ConnectionStatus.CONNECTED - } + and: "a polling update is triggered" + protocol.updateAllLinkedAttributes() + + then: "each attribute receives predicted datapoints for its configured zone" + conditions.eventually { + List beDatapoints = assetPredictedDatapointService.getDatapoints(beRef).sort { + it.timestamp + } + List nlDatapoints = assetPredictedDatapointService.getDatapoints(nlRef).sort { + it.timestamp + } + + assert beDatapoints.size() == 4 + assert nlDatapoints.size() == 4 + + assert (beDatapoints[0].value as BigDecimal).compareTo(73.24G) == 0 + assert (beDatapoints[1].value as BigDecimal).compareTo(69.79G) == 0 + assert (beDatapoints[2].value as BigDecimal).compareTo(65.84G) == 0 + assert (beDatapoints[3].value as BigDecimal).compareTo(65.05G) == 0 + + assert (nlDatapoints[0].value as BigDecimal).compareTo(81.11G) == 0 + assert (nlDatapoints[1].value as BigDecimal).compareTo(82.22G) == 0 + assert (nlDatapoints[2].value as BigDecimal).compareTo(83.33G) == 0 + assert (nlDatapoints[3].value as BigDecimal).compareTo(84.44G) == 0 + } - when: "2 attributes are linked to the same agent with the same zone" - def link1 = new EntsoeAgentLink(agent.id) - link1.setZone("10YBE----------2") - def link2 = new EntsoeAgentLink(agent.id) - link2.setZone("10YBE----------2") - - asset = new ThingAsset("Shared Zone Energy Price Asset") - .setRealm(MASTER_REALM) - .addOrReplaceAttributes( - new Attribute<>("energyPriceOne", NUMBER) - .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, link1)), - new Attribute<>("energyPriceTwo", NUMBER) - .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, link2)) - ) - asset = assetStorageService.merge(asset) - - def refOne = new AttributeRef(asset.id, "energyPriceOne") - def refTwo = new AttributeRef(asset.id, "energyPriceTwo") - def protocol = (EntsoeProtocol) agentService.getProtocolInstance(agent.id) - - and: "both attributes are linked by protocol" - conditions.eventually { - assert protocol.getLinkedAttributes().containsKey(refOne) - assert protocol.getLinkedAttributes().containsKey(refTwo) - } + cleanup: "remove created assets and mock client" + if (asset?.id) { + assetStorageService.delete([asset.id]) + } + if (agent?.id) { + assetStorageService.delete([agent.id]) + } + closeClient() + } - and: "the request log is cleared before the explicit polling update" - requestLog.clear() + def "ENTSO-E integration test fetches a shared zone only once per polling cycle"() { + given: "the container environment is started" + requestCountByZone.clear() + requestLog.clear() + def conditions = new PollingConditions(timeout: 10, delay: 0.2) - and: "a polling update is triggered" - protocol.updateAllLinkedAttributes() + EntsoeProtocol.initClient() - then: "both attributes receive datapoints and the shared zone is requested only once" - conditions.eventually { - List datapointsOne = assetPredictedDatapointService.getDatapoints(refOne).sort { it.timestamp } - List datapointsTwo = assetPredictedDatapointService.getDatapoints(refTwo).sort { it.timestamp } + if (!EntsoeProtocol.client.get().configuration.isRegistered(mockServer)) { + EntsoeProtocol.client.get().register(mockServer, Integer.MAX_VALUE) + } - assert datapointsOne.size() == 4 - assert datapointsTwo.size() == 4 - assert datapointsOne.collect { [it.timestamp, it.value] } == datapointsTwo.collect { [it.timestamp, it.value] } + def container = startContainer(defaultConfig(), defaultServices()) + setPseudoClock(BEFORE_DATASET_START) + def assetStorageService = container.getService(AssetStorageService.class) + def assetPredictedDatapointService = container.getService(AssetPredictedDatapointService.class) + def agentService = container.getService(AgentService.class) + EntsoeAgent agent = null + ThingAsset asset = null + + when: "an ENTSO-E agent is created" + agent = new EntsoeAgent("ENTSO-E Agent") + .setRealm(MASTER_REALM) + .setSecurityToken("test-token") + agent = assetStorageService.merge(agent) + + then: "the protocol instance for the agent should be created and connected" + conditions.eventually { + assert agentService.getProtocolInstance(agent.id) != null + assert ((EntsoeProtocol) agentService.getProtocolInstance(agent.id)) != null + assert agentService.getAgent(agent.id).getAgentStatus().orElse(null) == ConnectionStatus.CONNECTED + } - assert requestLog.count { it.zone == "10YBE----------2" } == 1 - } + when: "2 attributes are linked to the same agent with the same zone" + def link1 = new EntsoeAgentLink(agent.id) + link1.setZone("10YBE----------2") + def link2 = new EntsoeAgentLink(agent.id) + link2.setZone("10YBE----------2") + + asset = new ThingAsset("Shared Zone Energy Price Asset") + .setRealm(MASTER_REALM) + .addOrReplaceAttributes( + new Attribute<>("energyPriceOne", NUMBER) + .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, link1)), + new Attribute<>("energyPriceTwo", NUMBER) + .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, link2)) + ) + asset = assetStorageService.merge(asset) + + def refOne = new AttributeRef(asset.id, "energyPriceOne") + def refTwo = new AttributeRef(asset.id, "energyPriceTwo") + def protocol = (EntsoeProtocol) agentService.getProtocolInstance(agent.id) + + and: "both attributes are linked by protocol" + conditions.eventually { + assert protocol.getLinkedAttributes().containsKey(refOne) + assert protocol.getLinkedAttributes().containsKey(refTwo) + } - cleanup: "remove created assets and mock client" - if (asset?.id) { - assetStorageService.delete([asset.id]) - } - if (agent?.id) { - assetStorageService.delete([agent.id]) - } - closeClient() + and: "the request log is cleared before the explicit polling update" + requestLog.clear() + + and: "a polling update is triggered" + protocol.updateAllLinkedAttributes() + + then: "both attributes receive datapoints and the shared zone is requested only once" + conditions.eventually { + List datapointsOne = assetPredictedDatapointService.getDatapoints(refOne).sort { + it.timestamp + } + List datapointsTwo = assetPredictedDatapointService.getDatapoints(refTwo).sort { + it.timestamp + } + + assert datapointsOne.size() == 4 + assert datapointsTwo.size() == 4 + assert datapointsOne.collect { + [it.timestamp, it.value] + } == datapointsTwo.collect { + [it.timestamp, it.value] + } + + assert requestLog.count { it.zone == "10YBE----------2" } == 1 } - def "ENTSO-E integration test keeps existing predicted datapoints when subsequent poll fetch fails"() { - given: "the container environment is started" - requestCountByZone.clear() - def conditions = new PollingConditions(timeout: 10, delay: 0.2) + cleanup: "remove created assets and mock client" + if (asset?.id) { + assetStorageService.delete([asset.id]) + } + if (agent?.id) { + assetStorageService.delete([agent.id]) + } + closeClient() + } - EntsoeProtocol.initClient() + def "ENTSO-E integration test keeps existing predicted datapoints when subsequent poll fetch fails"() { + given: "the container environment is started" + requestCountByZone.clear() + def conditions = new PollingConditions(timeout: 10, delay: 0.2) - if (!EntsoeProtocol.client.get().configuration.isRegistered(mockServer)) { - EntsoeProtocol.client.get().register(mockServer, Integer.MAX_VALUE) - } + EntsoeProtocol.initClient() - def container = startContainer(defaultConfig(), defaultServices()) - setPseudoClock(BEFORE_DATASET_START) - def assetStorageService = container.getService(AssetStorageService.class) - def assetPredictedDatapointService = container.getService(AssetPredictedDatapointService.class) - def agentService = container.getService(AgentService.class) - EntsoeAgent agent = null - ThingAsset asset = null - - when: "an ENTSO-E agent is created" - agent = new EntsoeAgent("ENTSO-E Agent") - .setRealm(MASTER_REALM) - .setSecurityToken("test-token") - agent = assetStorageService.merge(agent) - - then: "the protocol instance for the agent should be created and connected" - conditions.eventually { - assert agentService.getProtocolInstance(agent.id) != null - assert ((EntsoeProtocol) agentService.getProtocolInstance(agent.id)) != null - assert agentService.getAgent(agent.id).getAgentStatus().orElse(null) == ConnectionStatus.CONNECTED - } + if (!EntsoeProtocol.client.get().configuration.isRegistered(mockServer)) { + EntsoeProtocol.client.get().register(mockServer, Integer.MAX_VALUE) + } - when: "an attribute is linked to a zone that fails after first successful fetch" - def errLink = new EntsoeAgentLink(agent.id) - errLink.setZone("10YER----------X") + def container = startContainer(defaultConfig(), defaultServices()) + setPseudoClock(BEFORE_DATASET_START) + def assetStorageService = container.getService(AssetStorageService.class) + def assetPredictedDatapointService = container.getService(AssetPredictedDatapointService.class) + def agentService = container.getService(AgentService.class) + EntsoeAgent agent = null + ThingAsset asset = null + + when: "an ENTSO-E agent is created" + agent = new EntsoeAgent("ENTSO-E Agent") + .setRealm(MASTER_REALM) + .setSecurityToken("test-token") + agent = assetStorageService.merge(agent) + + then: "the protocol instance for the agent should be created and connected" + conditions.eventually { + assert agentService.getProtocolInstance(agent.id) != null + assert ((EntsoeProtocol) agentService.getProtocolInstance(agent.id)) != null + assert agentService.getAgent(agent.id).getAgentStatus().orElse(null) == ConnectionStatus.CONNECTED + } - asset = new ThingAsset("Error On Second Poll Asset") - .setRealm(MASTER_REALM) - .addOrReplaceAttributes( - new Attribute<>("energyPrice", NUMBER) - .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, errLink)) - ) - asset = assetStorageService.merge(asset) + when: "an attribute is linked to a zone that fails after first successful fetch" + def errLink = new EntsoeAgentLink(agent.id) + errLink.setZone("10YER----------X") - def attributeRef = new AttributeRef(asset.id, "energyPrice") - def protocol = (EntsoeProtocol) agentService.getProtocolInstance(agent.id) + asset = new ThingAsset("Error On Second Poll Asset") + .setRealm(MASTER_REALM) + .addOrReplaceAttributes( + new Attribute<>("energyPrice", NUMBER) + .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, errLink)) + ) + asset = assetStorageService.merge(asset) - and: "the attribute is linked by protocol" - conditions.eventually { - assert protocol.getLinkedAttributes().containsKey(attributeRef) - } + def attributeRef = new AttributeRef(asset.id, "energyPrice") + def protocol = (EntsoeProtocol) agentService.getProtocolInstance(agent.id) - and: "first poll succeeds" - protocol.updateAllLinkedAttributes() - - then: "predicted datapoints are written" - def firstSnapshot - conditions.eventually { - List datapoints = assetPredictedDatapointService.getDatapoints(attributeRef).sort { it.timestamp } - assert datapoints.size() == 4 - assert (datapoints[0].value as BigDecimal).compareTo(91.11G) == 0 - assert (datapoints[1].value as BigDecimal).compareTo(92.22G) == 0 - assert (datapoints[2].value as BigDecimal).compareTo(93.33G) == 0 - assert (datapoints[3].value as BigDecimal).compareTo(94.44G) == 0 - firstSnapshot = datapoints.collect { [it.timestamp, (it.value as BigDecimal)] } - } + and: "the attribute is linked by protocol" + conditions.eventually { + assert protocol.getLinkedAttributes().containsKey(attributeRef) + } - when: "next poll cycle fetch fails" - protocol.updateAllLinkedAttributes() + and: "first poll succeeds" + protocol.updateAllLinkedAttributes() + + then: "predicted datapoints are written" + def firstSnapshot + conditions.eventually { + List datapoints = assetPredictedDatapointService.getDatapoints(attributeRef).sort { + it.timestamp + } + assert datapoints.size() == 4 + assert (datapoints[0].value as BigDecimal).compareTo(91.11G) == 0 + assert (datapoints[1].value as BigDecimal).compareTo(92.22G) == 0 + assert (datapoints[2].value as BigDecimal).compareTo(93.33G) == 0 + assert (datapoints[3].value as BigDecimal).compareTo(94.44G) == 0 + firstSnapshot = datapoints.collect { [it.timestamp, (it.value as BigDecimal)] } + } - then: "existing predicted datapoints remain available" - conditions.eventually { - List datapoints = assetPredictedDatapointService.getDatapoints(attributeRef).sort { it.timestamp } - assert datapoints.size() == 4 - assert firstSnapshot != null - assert datapoints.collect { [it.timestamp, (it.value as BigDecimal)] } == firstSnapshot - } + when: "next poll cycle fetch fails" + protocol.updateAllLinkedAttributes() + + then: "existing predicted datapoints remain available" + conditions.eventually { + List datapoints = assetPredictedDatapointService.getDatapoints(attributeRef).sort { + it.timestamp + } + assert datapoints.size() == 4 + assert firstSnapshot != null + assert datapoints.collect { [it.timestamp, (it.value as BigDecimal)] } == firstSnapshot + } - cleanup: "remove created assets and mock client" - if (asset?.id) { - assetStorageService.delete([asset.id]) - } - if (agent?.id) { - assetStorageService.delete([agent.id]) - } - closeClient() + cleanup: "remove created assets and mock client" + if (asset?.id) { + assetStorageService.delete([asset.id]) } + if (agent?.id) { + assetStorageService.delete([agent.id]) + } + closeClient() + } - def "ENTSO-E integration test handles no-data acknowledgement response cleanly"() { - given: "the container environment is started" - requestCountByZone.clear() - def conditions = new PollingConditions(timeout: 10, delay: 0.2) + def "ENTSO-E integration test handles no-data acknowledgement response cleanly"() { + given: "the container environment is started" + requestCountByZone.clear() + def conditions = new PollingConditions(timeout: 10, delay: 0.2) - EntsoeProtocol.initClient() + EntsoeProtocol.initClient() - if (!EntsoeProtocol.client.get().configuration.isRegistered(mockServer)) { - EntsoeProtocol.client.get().register(mockServer, Integer.MAX_VALUE) - } + if (!EntsoeProtocol.client.get().configuration.isRegistered(mockServer)) { + EntsoeProtocol.client.get().register(mockServer, Integer.MAX_VALUE) + } - def container = startContainer(defaultConfig(), defaultServices()) - setPseudoClock(BEFORE_DATASET_START) - def assetStorageService = container.getService(AssetStorageService.class) - def assetPredictedDatapointService = container.getService(AssetPredictedDatapointService.class) - def agentService = container.getService(AgentService.class) - EntsoeAgent agent = null - ThingAsset asset = null - - when: "an ENTSO-E agent is created" - agent = new EntsoeAgent("ENTSO-E Agent") - .setRealm(MASTER_REALM) - .setSecurityToken("test-token") - agent = assetStorageService.merge(agent) - - then: "the protocol instance for the agent should be created and connected" - conditions.eventually { - assert agentService.getProtocolInstance(agent.id) != null - assert ((EntsoeProtocol) agentService.getProtocolInstance(agent.id)) != null - assert agentService.getAgent(agent.id).getAgentStatus().orElse(null) == ConnectionStatus.CONNECTED - } + def container = startContainer(defaultConfig(), defaultServices()) + setPseudoClock(BEFORE_DATASET_START) + def assetStorageService = container.getService(AssetStorageService.class) + def assetPredictedDatapointService = container.getService(AssetPredictedDatapointService.class) + def agentService = container.getService(AgentService.class) + EntsoeAgent agent = null + ThingAsset asset = null + + when: "an ENTSO-E agent is created" + agent = new EntsoeAgent("ENTSO-E Agent") + .setRealm(MASTER_REALM) + .setSecurityToken("test-token") + agent = assetStorageService.merge(agent) + + then: "the protocol instance for the agent should be created and connected" + conditions.eventually { + assert agentService.getProtocolInstance(agent.id) != null + assert ((EntsoeProtocol) agentService.getProtocolInstance(agent.id)) != null + assert agentService.getAgent(agent.id).getAgentStatus().orElse(null) == ConnectionStatus.CONNECTED + } - when: "an attribute is linked to a zone that returns no-data acknowledgement" - def noDataLink = new EntsoeAgentLink(agent.id) - noDataLink.setZone("10YNDATA-------A") + when: "an attribute is linked to a zone that returns no-data acknowledgement" + def noDataLink = new EntsoeAgentLink(agent.id) + noDataLink.setZone("10YNDATA-------A") - asset = new ThingAsset("No Data Zone Asset") - .setRealm(MASTER_REALM) - .addOrReplaceAttributes( - new Attribute<>("energyPrice", NUMBER) - .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, noDataLink)) - ) - asset = assetStorageService.merge(asset) + asset = new ThingAsset("No Data Zone Asset") + .setRealm(MASTER_REALM) + .addOrReplaceAttributes( + new Attribute<>("energyPrice", NUMBER) + .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, noDataLink)) + ) + asset = assetStorageService.merge(asset) - def attributeRef = new AttributeRef(asset.id, "energyPrice") - def protocol = (EntsoeProtocol) agentService.getProtocolInstance(agent.id) + def attributeRef = new AttributeRef(asset.id, "energyPrice") + def protocol = (EntsoeProtocol) agentService.getProtocolInstance(agent.id) - and: "the attribute is linked by protocol" - conditions.eventually { - assert protocol.getLinkedAttributes().containsKey(attributeRef) - } + and: "the attribute is linked by protocol" + conditions.eventually { + assert protocol.getLinkedAttributes().containsKey(attributeRef) + } - and: "a polling update is triggered" - protocol.updateAllLinkedAttributes() + and: "a polling update is triggered" + protocol.updateAllLinkedAttributes() - then: "no predicted datapoints are written and no exception escapes" - conditions.eventually { - List datapoints = assetPredictedDatapointService.getDatapoints(attributeRef) - assert datapoints.isEmpty() - } + then: "no predicted datapoints are written and no exception escapes" + conditions.eventually { + List datapoints = assetPredictedDatapointService.getDatapoints(attributeRef) + assert datapoints.isEmpty() + } - cleanup: "remove created assets and mock client" - if (asset?.id) { - assetStorageService.delete([asset.id]) - } - if (agent?.id) { - assetStorageService.delete([agent.id]) - } - closeClient() + cleanup: "remove created assets and mock client" + if (asset?.id) { + assetStorageService.delete([asset.id]) } + if (agent?.id) { + assetStorageService.delete([agent.id]) + } + closeClient() + } - def "ENTSO-E integration test rejects XML with DTD and external entity declarations"() { - given: "the container environment is started" - requestCountByZone.clear() - def conditions = new PollingConditions(timeout: 10, delay: 0.2) + def "ENTSO-E integration test rejects XML with DTD and external entity declarations"() { + given: "the container environment is started" + requestCountByZone.clear() + def conditions = new PollingConditions(timeout: 10, delay: 0.2) - EntsoeProtocol.initClient() + EntsoeProtocol.initClient() - if (!EntsoeProtocol.client.get().configuration.isRegistered(mockServer)) { - EntsoeProtocol.client.get().register(mockServer, Integer.MAX_VALUE) - } + if (!EntsoeProtocol.client.get().configuration.isRegistered(mockServer)) { + EntsoeProtocol.client.get().register(mockServer, Integer.MAX_VALUE) + } - def container = startContainer(defaultConfig(), defaultServices()) - setPseudoClock(BEFORE_DATASET_START) - def assetStorageService = container.getService(AssetStorageService.class) - def assetPredictedDatapointService = container.getService(AssetPredictedDatapointService.class) - def agentService = container.getService(AgentService.class) - EntsoeAgent agent = null - ThingAsset asset = null - - when: "an ENTSO-E agent is created" - agent = new EntsoeAgent("ENTSO-E Agent") - .setRealm(MASTER_REALM) - .setSecurityToken("test-token") - agent = assetStorageService.merge(agent) - - then: "the protocol instance for the agent should be created and connected" - conditions.eventually { - assert agentService.getProtocolInstance(agent.id) != null - assert ((EntsoeProtocol) agentService.getProtocolInstance(agent.id)) != null - assert agentService.getAgent(agent.id).getAgentStatus().orElse(null) == ConnectionStatus.CONNECTED - } + def container = startContainer(defaultConfig(), defaultServices()) + setPseudoClock(BEFORE_DATASET_START) + def assetStorageService = container.getService(AssetStorageService.class) + def assetPredictedDatapointService = container.getService(AssetPredictedDatapointService.class) + def agentService = container.getService(AgentService.class) + EntsoeAgent agent = null + ThingAsset asset = null + + when: "an ENTSO-E agent is created" + agent = new EntsoeAgent("ENTSO-E Agent") + .setRealm(MASTER_REALM) + .setSecurityToken("test-token") + agent = assetStorageService.merge(agent) + + then: "the protocol instance for the agent should be created and connected" + conditions.eventually { + assert agentService.getProtocolInstance(agent.id) != null + assert ((EntsoeProtocol) agentService.getProtocolInstance(agent.id)) != null + assert agentService.getAgent(agent.id).getAgentStatus().orElse(null) == ConnectionStatus.CONNECTED + } - when: "an attribute is linked to a zone that returns XML with a DTD and external entity" - def unsafeXmlLink = new EntsoeAgentLink(agent.id) - unsafeXmlLink.setZone("10YXXE---------X") + when: "an attribute is linked to a zone that returns XML with a DTD and external entity" + def unsafeXmlLink = new EntsoeAgentLink(agent.id) + unsafeXmlLink.setZone("10YXXE---------X") - asset = new ThingAsset("Unsafe XML Zone Asset") - .setRealm(MASTER_REALM) - .addOrReplaceAttributes( - new Attribute<>("energyPrice", NUMBER) - .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, unsafeXmlLink)) - ) - asset = assetStorageService.merge(asset) + asset = new ThingAsset("Unsafe XML Zone Asset") + .setRealm(MASTER_REALM) + .addOrReplaceAttributes( + new Attribute<>("energyPrice", NUMBER) + .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, unsafeXmlLink)) + ) + asset = assetStorageService.merge(asset) - def attributeRef = new AttributeRef(asset.id, "energyPrice") - def protocol = (EntsoeProtocol) agentService.getProtocolInstance(agent.id) + def attributeRef = new AttributeRef(asset.id, "energyPrice") + def protocol = (EntsoeProtocol) agentService.getProtocolInstance(agent.id) - and: "the attribute is linked by protocol" - conditions.eventually { - assert protocol.getLinkedAttributes().containsKey(attributeRef) - } + and: "the attribute is linked by protocol" + conditions.eventually { + assert protocol.getLinkedAttributes().containsKey(attributeRef) + } - and: "a polling update is triggered" - protocol.updateAllLinkedAttributes() + and: "a polling update is triggered" + protocol.updateAllLinkedAttributes() - then: "the unsafe XML is rejected and no predicted datapoints are written" - conditions.eventually { - List datapoints = assetPredictedDatapointService.getDatapoints(attributeRef) - assert datapoints.isEmpty() - } + then: "the unsafe XML is rejected and no predicted datapoints are written" + conditions.eventually { + List datapoints = assetPredictedDatapointService.getDatapoints(attributeRef) + assert datapoints.isEmpty() + } - cleanup: "remove created assets and mock client" - if (asset?.id) { - assetStorageService.delete([asset.id]) - } - if (agent?.id) { - assetStorageService.delete([agent.id]) - } - closeClient() + cleanup: "remove created assets and mock client" + if (asset?.id) { + assetStorageService.delete([asset.id]) } + if (agent?.id) { + assetStorageService.delete([agent.id]) + } + closeClient() + } - def "ENTSO-E integration test keeps position timing when intermediate point is missing"() { - given: "the container environment is started" - requestCountByZone.clear() - def conditions = new PollingConditions(timeout: 10, delay: 0.2) + def "ENTSO-E integration test keeps position timing when intermediate point is missing"() { + given: "the container environment is started" + requestCountByZone.clear() + def conditions = new PollingConditions(timeout: 10, delay: 0.2) - EntsoeProtocol.initClient() + EntsoeProtocol.initClient() - if (!EntsoeProtocol.client.get().configuration.isRegistered(mockServer)) { - EntsoeProtocol.client.get().register(mockServer, Integer.MAX_VALUE) - } - - def container = startContainer(defaultConfig(), defaultServices()) - setPseudoClock(BEFORE_DATASET_START) - def assetStorageService = container.getService(AssetStorageService.class) - def assetPredictedDatapointService = container.getService(AssetPredictedDatapointService.class) - def agentService = container.getService(AgentService.class) - EntsoeAgent agent = null - ThingAsset asset = null - - when: "an ENTSO-E agent and linked attribute are created for a zone with a missing point position" - agent = new EntsoeAgent("ENTSO-E Agent") - .setRealm(MASTER_REALM) - .setSecurityToken("test-token") - agent = assetStorageService.merge(agent) - - def gapLink = new EntsoeAgentLink(agent.id) - gapLink.setZone("10YGAP---------G") - - asset = new ThingAsset("Gap Position Asset") - .setRealm(MASTER_REALM) - .addOrReplaceAttributes( - new Attribute<>("energyPrice", NUMBER) - .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, gapLink)) - ) - asset = assetStorageService.merge(asset) - - def attributeRef = new AttributeRef(asset.id, "energyPrice") - def protocol = null - - then: "the protocol is connected and attribute linked" - conditions.eventually { - protocol = (EntsoeProtocol) agentService.getProtocolInstance(agent.id) - assert protocol != null - assert agentService.getAgent(agent.id).getAgentStatus().orElse(null) == ConnectionStatus.CONNECTED - assert protocol.getLinkedAttributes().containsKey(attributeRef) - } + if (!EntsoeProtocol.client.get().configuration.isRegistered(mockServer)) { + EntsoeProtocol.client.get().register(mockServer, Integer.MAX_VALUE) + } - when: "a polling update is triggered" - protocol.updateAllLinkedAttributes() + def container = startContainer(defaultConfig(), defaultServices()) + setPseudoClock(BEFORE_DATASET_START) + def assetStorageService = container.getService(AssetStorageService.class) + def assetPredictedDatapointService = container.getService(AssetPredictedDatapointService.class) + def agentService = container.getService(AgentService.class) + EntsoeAgent agent = null + ThingAsset asset = null + + when: "an ENTSO-E agent and linked attribute are created for a zone with a missing point position" + agent = new EntsoeAgent("ENTSO-E Agent") + .setRealm(MASTER_REALM) + .setSecurityToken("test-token") + agent = assetStorageService.merge(agent) + + def gapLink = new EntsoeAgentLink(agent.id) + gapLink.setZone("10YGAP---------G") + + asset = new ThingAsset("Gap Position Asset") + .setRealm(MASTER_REALM) + .addOrReplaceAttributes( + new Attribute<>("energyPrice", NUMBER) + .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, gapLink)) + ) + asset = assetStorageService.merge(asset) + + def attributeRef = new AttributeRef(asset.id, "energyPrice") + def protocol = null + + then: "the protocol is connected and attribute linked" + conditions.eventually { + protocol = (EntsoeProtocol) agentService.getProtocolInstance(agent.id) + assert protocol != null + assert agentService.getAgent(agent.id).getAgentStatus().orElse(null) == ConnectionStatus.CONNECTED + assert protocol.getLinkedAttributes().containsKey(attributeRef) + } - then: "only provided points are stored and their timestamps follow position offsets" - conditions.eventually { - List datapoints = assetPredictedDatapointService.getDatapoints(attributeRef).sort { it.timestamp } - assert datapoints.size() == 3 + when: "a polling update is triggered" + protocol.updateAllLinkedAttributes() - def start = Instant.parse(DATASET_START).toEpochMilli() - def step = 15 * 60 * 1000L + then: "only provided points are stored and their timestamps follow position offsets" + conditions.eventually { + List datapoints = assetPredictedDatapointService.getDatapoints(attributeRef).sort { + it.timestamp + } + assert datapoints.size() == 3 - assert datapoints[0].timestamp == start - assert datapoints[1].timestamp == start + step - assert datapoints[2].timestamp == start + (3 * step) - assert datapoints[2].timestamp - datapoints[1].timestamp == 2 * step + def start = Instant.parse(DATASET_START).toEpochMilli() + def step = 15 * 60 * 1000L - assert (datapoints[0].value as BigDecimal).compareTo(81.11G) == 0 - assert (datapoints[1].value as BigDecimal).compareTo(82.22G) == 0 - assert (datapoints[2].value as BigDecimal).compareTo(84.44G) == 0 - } + assert datapoints[0].timestamp == start + assert datapoints[1].timestamp == start + step + assert datapoints[2].timestamp == start + (3 * step) + assert datapoints[2].timestamp - datapoints[1].timestamp == 2 * step - cleanup: "remove created assets and mock client" - if (asset?.id) { - assetStorageService.delete([asset.id]) - } - if (agent?.id) { - assetStorageService.delete([agent.id]) - } - closeClient() + assert (datapoints[0].value as BigDecimal).compareTo(81.11G) == 0 + assert (datapoints[1].value as BigDecimal).compareTo(82.22G) == 0 + assert (datapoints[2].value as BigDecimal).compareTo(84.44G) == 0 } - @Ignore - def "ENTSO-E integration test supports multiple periods in a single timeseries"() { - given: "the container environment is started" - requestCountByZone.clear() - def conditions = new PollingConditions(timeout: 10, delay: 0.2) - - EntsoeProtocol.initClient() - - if (!EntsoeProtocol.client.get().configuration.isRegistered(mockServer)) { - EntsoeProtocol.client.get().register(mockServer, Integer.MAX_VALUE) - } - - def container = startContainer(defaultConfig(), defaultServices()) - setPseudoClock(BEFORE_DATASET_START) - def assetStorageService = container.getService(AssetStorageService.class) - def assetPredictedDatapointService = container.getService(AssetPredictedDatapointService.class) - def agentService = container.getService(AgentService.class) - EntsoeAgent agent = null - ThingAsset asset = null - - when: "an ENTSO-E agent and linked attribute are created for a zone with multiple periods in one timeseries" - agent = new EntsoeAgent("ENTSO-E Agent") - .setRealm(MASTER_REALM) - .setSecurityToken("test-token") - agent = assetStorageService.merge(agent) - - def multiPeriodLink = new EntsoeAgentLink(agent.id) - multiPeriodLink.setZone("10YMULTI-------A") - - asset = new ThingAsset("Multi Period Asset") - .setRealm(MASTER_REALM) - .addOrReplaceAttributes( - new Attribute<>("energyPrice", NUMBER) - .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, multiPeriodLink)) - ) - asset = assetStorageService.merge(asset) - - def attributeRef = new AttributeRef(asset.id, "energyPrice") - def protocol = null - - then: "the protocol is connected and attribute linked" - conditions.eventually { - protocol = (EntsoeProtocol) agentService.getProtocolInstance(agent.id) - assert protocol != null - assert agentService.getAgent(agent.id).getAgentStatus().orElse(null) == ConnectionStatus.CONNECTED - assert protocol.getLinkedAttributes().containsKey(attributeRef) - } - - when: "a polling update is triggered" - protocol.updateAllLinkedAttributes() + cleanup: "remove created assets and mock client" + if (asset?.id) { + assetStorageService.delete([asset.id]) + } + if (agent?.id) { + assetStorageService.delete([agent.id]) + } + closeClient() + } - then: "datapoints are produced from all periods in order" - conditions.eventually { - List datapoints = assetPredictedDatapointService.getDatapoints(attributeRef).sort { it.timestamp } - assert datapoints.size() == 4 + @Ignore + def "ENTSO-E integration test supports multiple periods in a single timeseries"() { + given: "the container environment is started" + requestCountByZone.clear() + def conditions = new PollingConditions(timeout: 10, delay: 0.2) - def firstPeriodStart = Instant.parse(DATASET_START).toEpochMilli() - def secondPeriodStart = Instant.parse("2026-02-17T00:00:00.000Z").toEpochMilli() - def step = 15 * 60 * 1000L + EntsoeProtocol.initClient() - assert datapoints[0].timestamp == firstPeriodStart - assert datapoints[1].timestamp == firstPeriodStart + step - assert datapoints[2].timestamp == secondPeriodStart - assert datapoints[3].timestamp == secondPeriodStart + step + if (!EntsoeProtocol.client.get().configuration.isRegistered(mockServer)) { + EntsoeProtocol.client.get().register(mockServer, Integer.MAX_VALUE) + } - assert (datapoints[0].value as BigDecimal).compareTo(51.11G) == 0 - assert (datapoints[1].value as BigDecimal).compareTo(52.22G) == 0 - assert (datapoints[2].value as BigDecimal).compareTo(53.33G) == 0 - assert (datapoints[3].value as BigDecimal).compareTo(54.44G) == 0 - } + def container = startContainer(defaultConfig(), defaultServices()) + setPseudoClock(BEFORE_DATASET_START) + def assetStorageService = container.getService(AssetStorageService.class) + def assetPredictedDatapointService = container.getService(AssetPredictedDatapointService.class) + def agentService = container.getService(AgentService.class) + EntsoeAgent agent = null + ThingAsset asset = null + + when: "an ENTSO-E agent and linked attribute are created for a zone with multiple periods in one timeseries" + agent = new EntsoeAgent("ENTSO-E Agent") + .setRealm(MASTER_REALM) + .setSecurityToken("test-token") + agent = assetStorageService.merge(agent) + + def multiPeriodLink = new EntsoeAgentLink(agent.id) + multiPeriodLink.setZone("10YMULTI-------A") + + asset = new ThingAsset("Multi Period Asset") + .setRealm(MASTER_REALM) + .addOrReplaceAttributes( + new Attribute<>("energyPrice", NUMBER) + .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, multiPeriodLink)) + ) + asset = assetStorageService.merge(asset) + + def attributeRef = new AttributeRef(asset.id, "energyPrice") + def protocol = null + + then: "the protocol is connected and attribute linked" + conditions.eventually { + protocol = (EntsoeProtocol) agentService.getProtocolInstance(agent.id) + assert protocol != null + assert agentService.getAgent(agent.id).getAgentStatus().orElse(null) == ConnectionStatus.CONNECTED + assert protocol.getLinkedAttributes().containsKey(attributeRef) + } - cleanup: "remove created assets and mock client" - if (asset?.id) { - assetStorageService.delete([asset.id]) - } - if (agent?.id) { - assetStorageService.delete([agent.id]) - } - closeClient() - } - - def "ENTSO-E agent link validates zone against EIC regex pattern"() { - given: "an ENTSO-E agent link" - def link = new EntsoeAgentLink("agent-id") - - expect: "zone validation follows the EIC regex" - link.setZone(zoneId) - validator.validate(link).isEmpty() == valid - - where: - zoneId || valid - "10YBE----------2" || true - "10YNL----------L" || true - "10YGAP---------G" || true - "1YBE----------2" || false - "10yBE----------2" || false - "10YBE----------" || false - "10YBE----------22" || false - "10YBE_____-----2" || false - "10YNDATA-------A" || true - "10YMULTI-------A" || true - "10YXXE---------X" || true - } - - def "ENTSO-E integration test updates requested period range when clock advances by one day"() { - given: "the container environment is started" - requestCountByZone.clear() - requestLog.clear() - def conditions = new PollingConditions(timeout: 10, delay: 0.2) - def periodFormatter = DateTimeFormatter.ofPattern("yyyyMMddHHmm").withZone(ZoneId.of("UTC")) - - EntsoeProtocol.initClient() - - if (!EntsoeProtocol.client.get().configuration.isRegistered(mockServer)) { - EntsoeProtocol.client.get().register(mockServer, Integer.MAX_VALUE) - } + when: "a polling update is triggered" + protocol.updateAllLinkedAttributes() + + then: "datapoints are produced from all periods in order" + conditions.eventually { + List datapoints = assetPredictedDatapointService.getDatapoints(attributeRef).sort { + it.timestamp + } + assert datapoints.size() == 4 + + def firstPeriodStart = Instant.parse(DATASET_START).toEpochMilli() + def secondPeriodStart = Instant.parse("2026-02-17T00:00:00.000Z").toEpochMilli() + def step = 15 * 60 * 1000L + + assert datapoints[0].timestamp == firstPeriodStart + assert datapoints[1].timestamp == firstPeriodStart + step + assert datapoints[2].timestamp == secondPeriodStart + assert datapoints[3].timestamp == secondPeriodStart + step + + assert (datapoints[0].value as BigDecimal).compareTo(51.11G) == 0 + assert (datapoints[1].value as BigDecimal).compareTo(52.22G) == 0 + assert (datapoints[2].value as BigDecimal).compareTo(53.33G) == 0 + assert (datapoints[3].value as BigDecimal).compareTo(54.44G) == 0 + } - def container = startContainer(defaultConfig(), defaultServices()) - def assetStorageService = container.getService(AssetStorageService.class) - def agentService = container.getService(AgentService.class) - EntsoeAgent agent = null - ThingAsset asset = null - - when: "an ENTSO-E agent is created" - agent = new EntsoeAgent("ENTSO-E Agent") - .setRealm(MASTER_REALM) - .setSecurityToken("test-token") - agent = assetStorageService.merge(agent) - - then: "the protocol instance for the agent should be created and connected" - conditions.eventually { - assert agentService.getProtocolInstance(agent.id) != null - assert ((EntsoeProtocol) agentService.getProtocolInstance(agent.id)) != null - assert agentService.getAgent(agent.id).getAgentStatus().orElse(null) == ConnectionStatus.CONNECTED - } + cleanup: "remove created assets and mock client" + if (asset?.id) { + assetStorageService.delete([asset.id]) + } + if (agent?.id) { + assetStorageService.delete([agent.id]) + } + closeClient() + } + + def "ENTSO-E agent link validates zone against EIC regex pattern"() { + given: "an ENTSO-E agent link" + def link = new EntsoeAgentLink("agent-id") + + expect: "zone validation follows the EIC regex" + link.setZone(zoneId) + validator.validate(link).isEmpty() == valid + + where: + zoneId || valid + "10YBE----------2" || true + "10YNL----------L" || true + "10YGAP---------G" || true + "1YBE----------2" || false + "10yBE----------2" || false + "10YBE----------" || false + "10YBE----------22" || false + "10YBE_____-----2" || false + "10YNDATA-------A" || true + "10YMULTI-------A" || true + "10YXXE---------X" || true + } + + def "ENTSO-E integration test updates requested period range when clock advances by one day"() { + given: "the container environment is started" + requestCountByZone.clear() + requestLog.clear() + def conditions = new PollingConditions(timeout: 10, delay: 0.2) + def periodFormatter = DateTimeFormatter.ofPattern("yyyyMMddHHmm").withZone(ZoneId.of("UTC")) + + EntsoeProtocol.initClient() + + if (!EntsoeProtocol.client.get().configuration.isRegistered(mockServer)) { + EntsoeProtocol.client.get().register(mockServer, Integer.MAX_VALUE) + } - when: "an asset attribute is linked to the ENTSO-E agent" - def link = new EntsoeAgentLink(agent.id) - link.setZone("10YBE----------2") + def container = startContainer(defaultConfig(), defaultServices()) + def assetStorageService = container.getService(AssetStorageService.class) + def agentService = container.getService(AgentService.class) + EntsoeAgent agent = null + ThingAsset asset = null + + when: "an ENTSO-E agent is created" + agent = new EntsoeAgent("ENTSO-E Agent") + .setRealm(MASTER_REALM) + .setSecurityToken("test-token") + agent = assetStorageService.merge(agent) + + then: "the protocol instance for the agent should be created and connected" + conditions.eventually { + assert agentService.getProtocolInstance(agent.id) != null + assert ((EntsoeProtocol) agentService.getProtocolInstance(agent.id)) != null + assert agentService.getAgent(agent.id).getAgentStatus().orElse(null) == ConnectionStatus.CONNECTED + } - asset = new ThingAsset("Time Range Asset") - .setRealm(MASTER_REALM) - .addOrReplaceAttributes( - new Attribute<>("energyPrice", NUMBER) - .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, link)) - ) - asset = assetStorageService.merge(asset) + when: "an asset attribute is linked to the ENTSO-E agent" + def link = new EntsoeAgentLink(agent.id) + link.setZone("10YBE----------2") - def attributeRef = new AttributeRef(asset.id, "energyPrice") - def protocol = (EntsoeProtocol) agentService.getProtocolInstance(agent.id) + asset = new ThingAsset("Time Range Asset") + .setRealm(MASTER_REALM) + .addOrReplaceAttributes( + new Attribute<>("energyPrice", NUMBER) + .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, link)) + ) + asset = assetStorageService.merge(asset) - and: "the attribute is linked by protocol" - conditions.eventually { - assert protocol.getLinkedAttributes().containsKey(attributeRef) - } + def attributeRef = new AttributeRef(asset.id, "energyPrice") + def protocol = (EntsoeProtocol) agentService.getProtocolInstance(agent.id) - and: "an update is triggered at the fixed time" - stopPseudoClock() - setPseudoClock("2026-02-10T10:15:00.000Z") - def fixedNow = Instant.parse("2026-02-10T10:15:00.000Z") - requestLog.clear() - protocol.updateAllLinkedAttributes() - - then: "request periodStart/periodEnd use the fixed clock time" - conditions.eventually { - assert requestLog.any { - it.zone == "10YBE----------2" && - it.periodStart == periodFormatter.format(fixedNow) && - it.periodEnd == periodFormatter.format(fixedNow.plus(1, java.time.temporal.ChronoUnit.DAYS)) - } - } + and: "the attribute is linked by protocol" + conditions.eventually { + assert protocol.getLinkedAttributes().containsKey(attributeRef) + } - when: "clock advances by one day and another update is triggered" - advancePseudoClock(1, TimeUnit.DAYS, container) - def nextNow = fixedNow.plus(1, java.time.temporal.ChronoUnit.DAYS) - requestLog.clear() - protocol.updateAllLinkedAttributes() - - then: "request periodStart/periodEnd are shifted by one day" - conditions.eventually { - assert requestLog.any { - it.zone == "10YBE----------2" && - it.periodStart == periodFormatter.format(nextNow) && - it.periodEnd == periodFormatter.format(nextNow.plus(1, java.time.temporal.ChronoUnit.DAYS)) - } - } + and: "an update is triggered at the fixed time" + stopPseudoClock() + setPseudoClock("2026-02-10T10:15:00.000Z") + def fixedNow = Instant.parse("2026-02-10T10:15:00.000Z") + requestLog.clear() + protocol.updateAllLinkedAttributes() + + then: "request periodStart/periodEnd use the fixed clock time" + conditions.eventually { + assert requestLog.any { + it.zone == "10YBE----------2" && + it.periodStart == periodFormatter.format(fixedNow) && + it.periodEnd == periodFormatter.format(fixedNow.plus(1, java.time.temporal.ChronoUnit.DAYS)) + } + } - cleanup: "remove created assets and mock client" - if (asset?.id) { - assetStorageService.delete([asset.id]) - } - if (agent?.id) { - assetStorageService.delete([agent.id]) - } - closeClient() - } - - def "ENTSO-E scheduled polling continues after RuntimeException in updateAllLinkedAttributes"() { - given: "the container environment is started with a pseudo clock" - requestCountByZone.clear() - def conditions = new PollingConditions(timeout: 10, delay: 0.2) - - def container = startContainer(defaultConfig(), defaultServices()) - setPseudoClock(BEFORE_DATASET_START) - def assetStorageService = container.getService(AssetStorageService.class) - EntsoeAgent agent = null - ThrowingEntsoeProtocol protocol = null - - when: "a protocol schedules polling and the scheduled task throws a RuntimeException" - agent = new EntsoeAgent("ENTSO-E Agent") - .setRealm(MASTER_REALM) - .setSecurityToken("test-token") - agent.getAttributes().getOrCreate(org.openremote.model.asset.agent.Agent.POLLING_MILLIS).setValue(1000) - agent = assetStorageService.merge(agent) - - protocol = new ThrowingEntsoeProtocol(agent) - protocol.start(container) - - and: "the clock advances beyond the initial delay and several polling intervals" - advancePseudoClock(10, TimeUnit.SECONDS, container) - - then: "the scheduled task keeps running on the defined schedule despite failures" - conditions.eventually { - assert protocol.invocationCount.get() >= 3 - assert protocol.pollingFuture != null - assert !protocol.pollingFuture.isDone() - } + when: "clock advances by one day and another update is triggered" + advancePseudoClock(1, TimeUnit.DAYS, container) + def nextNow = fixedNow.plus(1, java.time.temporal.ChronoUnit.DAYS) + requestLog.clear() + protocol.updateAllLinkedAttributes() + + then: "request periodStart/periodEnd are shifted by one day" + conditions.eventually { + assert requestLog.any { + it.zone == "10YBE----------2" && + it.periodStart == periodFormatter.format(nextNow) && + it.periodEnd == periodFormatter.format(nextNow.plus(1, java.time.temporal.ChronoUnit.DAYS)) + } + } - when: "the clock advances further" - advancePseudoClock(10, TimeUnit.SECONDS, container) + cleanup: "remove created assets and mock client" + if (asset?.id) { + assetStorageService.delete([asset.id]) + } + if (agent?.id) { + assetStorageService.delete([agent.id]) + } + closeClient() + } + + def "ENTSO-E scheduled polling continues after RuntimeException in updateAllLinkedAttributes"() { + given: "the container environment is started with a pseudo clock" + requestCountByZone.clear() + def conditions = new PollingConditions(timeout: 10, delay: 0.2) + + def container = startContainer(defaultConfig(), defaultServices()) + setPseudoClock(BEFORE_DATASET_START) + def assetStorageService = container.getService(AssetStorageService.class) + EntsoeAgent agent = null + ThrowingEntsoeProtocol protocol = null + + when: "a protocol schedules polling and the scheduled task throws a RuntimeException" + agent = new EntsoeAgent("ENTSO-E Agent") + .setRealm(MASTER_REALM) + .setSecurityToken("test-token") + agent.getAttributes().getOrCreate(org.openremote.model.asset.agent.Agent.POLLING_MILLIS).setValue(1000) + agent = assetStorageService.merge(agent) + + protocol = new ThrowingEntsoeProtocol(agent) + protocol.start(container) + + and: "the clock advances beyond the initial delay and several polling intervals" + advancePseudoClock(10, TimeUnit.SECONDS, container) + + then: "the scheduled task keeps running on the defined schedule despite failures" + conditions.eventually { + assert protocol.invocationCount.get() >= 3 + assert protocol.pollingFuture != null + assert !protocol.pollingFuture.isDone() + } - then: "more scheduled executions continue to happen" - conditions.eventually { - assert protocol.invocationCount.get() >= 6 - assert !protocol.pollingFuture.isDone() - } + when: "the clock advances further" + advancePseudoClock(10, TimeUnit.SECONDS, container) - cleanup: "cancel the scheduled task and remove created assets" - if (protocol?.pollingFuture != null) { - protocol.pollingFuture.cancel(true) - } - if (agent?.id) { - assetStorageService.delete([agent.id]) - } - closeClient() + then: "more scheduled executions continue to happen" + conditions.eventually { + assert protocol.invocationCount.get() >= 6 + assert !protocol.pollingFuture.isDone() } - static class ThrowingEntsoeProtocol extends EntsoeProtocol { - final AtomicInteger invocationCount = new AtomicInteger() + cleanup: "cancel the scheduled task and remove created assets" + if (protocol?.pollingFuture != null) { + protocol.pollingFuture.cancel(true) + } + if (agent?.id) { + assetStorageService.delete([agent.id]) + } + closeClient() + } - ThrowingEntsoeProtocol(EntsoeAgent agent) { - super(agent) - } + static class ThrowingEntsoeProtocol extends EntsoeProtocol { + final AtomicInteger invocationCount = new AtomicInteger() - @Override - protected boolean healthCheck() { - return true - } + ThrowingEntsoeProtocol(EntsoeAgent agent) { + super(agent) + } - @Override - protected void doStart(org.openremote.model.Container container) throws Exception { - restartPollingWithInitialDelay() - } + @Override + protected boolean healthCheck() { + return true + } - @Override - protected void updateAllLinkedAttributes() { - invocationCount.incrementAndGet() - throw new RuntimeException("Synthetic scheduled polling failure") - } + @Override + protected void doStart(org.openremote.model.Container container) throws Exception { + restartPollingWithInitialDelay() } - protected static void closeClient() { - synchronized (EntsoeProtocol.client) { - def existingClient = EntsoeProtocol.client.getAndSet(null) - if (existingClient != null) { - existingClient.close() - } - } + @Override + protected void updateAllLinkedAttributes() { + invocationCount.incrementAndGet() + throw new RuntimeException("Synthetic scheduled polling failure") + } + } + + protected static void closeClient() { + synchronized (EntsoeProtocol.client) { + def existingClient = EntsoeProtocol.client.getAndSet(null) + if (existingClient != null) { + existingClient.close() + } } + } }