Skip to content

Commit

Permalink
Introduce console command for history persistence
Browse files Browse the repository at this point in the history
Signed-off-by: Jacob Laursen <[email protected]>
  • Loading branch information
jlaur committed Apr 17, 2024
1 parent 31c3356 commit 9de34f3
Show file tree
Hide file tree
Showing 9 changed files with 373 additions and 68 deletions.
11 changes: 11 additions & 0 deletions bundles/org.openhab.binding.energidataservice/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,17 @@ The recommended persistence strategy is `forecast`, as it ensures a clean histor
Prices from the past 24 hours and all forthcoming prices will be stored.
Any changes that impact published prices (e.g. selecting or deselecting VAT Profile) will result in the replacement of persisted prices within this period.

##### Filling Gaps

During extended service interruptions, data unavailability, or openHAB downtime, historic prices may be absent from persistence.
A console command is provided to address this issue: `energidataservice update [SpotPrice|GridTariff|SystemTariff|TransmissionGridTariff|ElectricityTax|ReducedElectricitytax] <StartDate> [<EndDate>]`.

Example:

```shell
energidataservice update spotprice 2024-04-12 2024-04-14
```

#### Grid Tariff

Discounts are automatically taken into account for channel `grid-tariff` so that it represents the actual price.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ public ApiController(HttpClient httpClient, TimeZoneProvider timeZoneProvider) {
* @throws DataServiceException
*/
public ElspotpriceRecord[] getSpotPrices(String priceArea, Currency currency, DateQueryParameter start,
Map<String, String> properties) throws InterruptedException, DataServiceException {
DateQueryParameter end, Map<String, String> properties) throws InterruptedException, DataServiceException {
if (!SUPPORTED_CURRENCIES.contains(currency)) {
throw new IllegalArgumentException("Invalid currency " + currency.getCurrencyCode());
}
Expand All @@ -119,6 +119,10 @@ public ElspotpriceRecord[] getSpotPrices(String priceArea, Currency currency, Da
.agent(userAgent) //
.method(HttpMethod.GET);

if (!end.isEmpty()) {
request = request.param("end", end.toString());
}

try {
String responseContent = sendRequest(request, properties);
ElspotpriceRecords records = gson.fromJson(responseContent, ElspotpriceRecords.class);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
@NonNullByDefault
public class EnergiDataServiceBindingConstants {

private static final String BINDING_ID = "energidataservice";
public static final String BINDING_ID = "energidataservice";

// List of all Thing Type UIDs
public static final ThingTypeUID THING_TYPE_SERVICE = new ThingTypeUID(BINDING_ID, "service");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.energidataservice.internal;

import java.util.Arrays;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;

/**
* {@link PriceComponent} represents the different components making up the total electricity price.
*
* @author Jacob Laursen - Initial contribution
*/
@NonNullByDefault
public enum PriceComponent {
SPOT_PRICE("SpotPrice", null),
GRID_TARIFF("GridTariff", DatahubTariff.GRID_TARIFF),
SYSTEM_TARIFF("SystemTariff", DatahubTariff.SYSTEM_TARIFF),
TRANSMISSION_GRID_TARIFF("TransmissionGridTariff", DatahubTariff.TRANSMISSION_GRID_TARIFF),
ELECTRICITY_TAX("ElectricityTax", DatahubTariff.ELECTRICITY_TAX),
REDUCED_ELECTRICITY_TAX("ReducedElectricityTax", DatahubTariff.REDUCED_ELECTRICITY_TAX);

private static final Map<String, PriceComponent> NAME_MAP = Stream.of(values())
.collect(Collectors.toMap(PriceComponent::toLowerCaseString, Function.identity()));

private String name;
private @Nullable DatahubTariff datahubTariff;

private PriceComponent(String name, @Nullable DatahubTariff datahubTariff) {
this.name = name;
this.datahubTariff = datahubTariff;
}

@Override
public String toString() {
return name;
}

private String toLowerCaseString() {
return name.toLowerCase();
}

public static PriceComponent fromString(final String name) {
PriceComponent myEnum = NAME_MAP.get(name.toLowerCase());
if (null == myEnum) {
throw new IllegalArgumentException(String.format("'%s' has no corresponding value. Accepted values: %s",
name, Arrays.asList(values())));
}
return myEnum;
}

public @Nullable DatahubTariff getDatahubTariff() {
return datahubTariff;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,19 @@ public PriceListParser(Clock clock) {
}

public Map<Instant, BigDecimal> toHourly(Collection<DatahubPricelistRecord> records) {
Instant firstHourStart = Instant.now(clock).minus(CacheManager.NUMBER_OF_HISTORIC_HOURS, ChronoUnit.HOURS)
.truncatedTo(ChronoUnit.HOURS);
Instant lastHourStart = Instant.now(clock).truncatedTo(ChronoUnit.HOURS).plus(2, ChronoUnit.DAYS)
.truncatedTo(ChronoUnit.DAYS);

return toHourly(records, firstHourStart, lastHourStart);
}

public Map<Instant, BigDecimal> toHourly(Collection<DatahubPricelistRecord> records, Instant firstHourStart,
Instant lastHourStart) {
Map<Instant, BigDecimal> totalMap = new ConcurrentHashMap<>(CacheManager.TARIFF_MAX_CACHE_SIZE);
records.stream().map(record -> record.chargeTypeCode()).distinct().forEach(chargeTypeCode -> {
Map<Instant, BigDecimal> currentMap = toHourly(records, chargeTypeCode);
Map<Instant, BigDecimal> currentMap = toHourly(records, chargeTypeCode, firstHourStart, lastHourStart);
for (Entry<Instant, BigDecimal> current : currentMap.entrySet()) {
BigDecimal total = totalMap.get(current.getKey());
if (total == null) {
Expand All @@ -62,14 +72,10 @@ public Map<Instant, BigDecimal> toHourly(Collection<DatahubPricelistRecord> reco
return totalMap;
}

public Map<Instant, BigDecimal> toHourly(Collection<DatahubPricelistRecord> records, String chargeTypeCode) {
private Map<Instant, BigDecimal> toHourly(Collection<DatahubPricelistRecord> records, String chargeTypeCode,
Instant firstHourStart, Instant lastHourStart) {
Map<Instant, BigDecimal> tariffMap = new ConcurrentHashMap<>(CacheManager.TARIFF_MAX_CACHE_SIZE);

Instant firstHourStart = Instant.now(clock).minus(CacheManager.NUMBER_OF_HISTORIC_HOURS, ChronoUnit.HOURS)
.truncatedTo(ChronoUnit.HOURS);
Instant lastHourStart = Instant.now(clock).truncatedTo(ChronoUnit.HOURS).plus(2, ChronoUnit.DAYS)
.truncatedTo(ChronoUnit.DAYS);

LocalDateTime previousValidFrom = LocalDateTime.MAX;
LocalDateTime previousValidTo = LocalDateTime.MIN;
Map<LocalTime, BigDecimal> tariffs = Map.of();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,7 @@
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import javax.measure.quantity.Energy;
import javax.measure.quantity.Power;
Expand All @@ -35,6 +33,7 @@
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.energidataservice.internal.DatahubTariff;
import org.openhab.binding.energidataservice.internal.PriceCalculator;
import org.openhab.binding.energidataservice.internal.PriceComponent;
import org.openhab.binding.energidataservice.internal.exception.MissingPriceException;
import org.openhab.binding.energidataservice.internal.handler.EnergiDataServiceHandler;
import org.openhab.core.automation.annotation.ActionInput;
Expand Down Expand Up @@ -64,44 +63,6 @@ public class EnergiDataServiceActions implements ThingActions {

private @Nullable EnergiDataServiceHandler handler;

private enum PriceComponent {
SPOT_PRICE("spotprice", null),
GRID_TARIFF("gridtariff", DatahubTariff.GRID_TARIFF),
SYSTEM_TARIFF("systemtariff", DatahubTariff.SYSTEM_TARIFF),
TRANSMISSION_GRID_TARIFF("transmissiongridtariff", DatahubTariff.TRANSMISSION_GRID_TARIFF),
ELECTRICITY_TAX("electricitytax", DatahubTariff.ELECTRICITY_TAX),
REDUCED_ELECTRICITY_TAX("reducedelectricitytax", DatahubTariff.REDUCED_ELECTRICITY_TAX);

private static final Map<String, PriceComponent> NAME_MAP = Stream.of(values())
.collect(Collectors.toMap(PriceComponent::toString, Function.identity()));

private String name;
private @Nullable DatahubTariff datahubTariff;

private PriceComponent(String name, @Nullable DatahubTariff datahubTariff) {
this.name = name;
this.datahubTariff = datahubTariff;
}

@Override
public String toString() {
return name;
}

public static PriceComponent fromString(final String name) {
PriceComponent myEnum = NAME_MAP.get(name.toLowerCase());
if (null == myEnum) {
throw new IllegalArgumentException(String.format("'%s' has no corresponding value. Accepted values: %s",
name, Arrays.asList(values())));
}
return myEnum;
}

public @Nullable DatahubTariff getDatahubTariff() {
return datahubTariff;
}
}

@RuleAction(label = "@text/action.get-prices.label", description = "@text/action.get-prices.description")
public @ActionOutput(name = "prices", type = "java.util.Map<java.time.Instant, java.math.BigDecimal>") Map<Instant, BigDecimal> getPrices() {
EnergiDataServiceHandler handler = this.handler;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.energidataservice.internal.console;

import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.energidataservice.internal.DatahubTariff;
import org.openhab.binding.energidataservice.internal.EnergiDataServiceBindingConstants;
import org.openhab.binding.energidataservice.internal.PriceComponent;
import org.openhab.binding.energidataservice.internal.exception.DataServiceException;
import org.openhab.binding.energidataservice.internal.handler.EnergiDataServiceHandler;
import org.openhab.core.io.console.Console;
import org.openhab.core.io.console.ConsoleCommandCompleter;
import org.openhab.core.io.console.StringsCompleter;
import org.openhab.core.io.console.extensions.AbstractConsoleCommandExtension;
import org.openhab.core.io.console.extensions.ConsoleCommandExtension;
import org.openhab.core.thing.ThingRegistry;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;

/**
* The {@link EnergiDataServiceCommandExtension} is responsible for handling console commands.
*
* @author Jacob Laursen - Initial contribution
*/
@NonNullByDefault
@Component(service = ConsoleCommandExtension.class)
public class EnergiDataServiceCommandExtension extends AbstractConsoleCommandExtension {

private static final String SUBCMD_UPDATE = "update";

private static final StringsCompleter SUBCMD_COMPLETER = new StringsCompleter(List.of(SUBCMD_UPDATE), false);

private final ThingRegistry thingRegistry;

private class EnergiDataServiceConsoleCommandCompleter implements ConsoleCommandCompleter {
@Override
public boolean complete(String[] args, int cursorArgumentIndex, int cursorPosition, List<String> candidates) {
if (cursorArgumentIndex <= 0) {
return SUBCMD_COMPLETER.complete(args, cursorArgumentIndex, cursorPosition, candidates);
} else if (cursorArgumentIndex == 1) {
return new StringsCompleter(Stream.of(PriceComponent.values()).map(PriceComponent::toString).toList(),
false).complete(args, cursorArgumentIndex, cursorPosition, candidates);
}
return false;
}
}

@Activate
public EnergiDataServiceCommandExtension(final @Reference ThingRegistry thingRegistry) {
super(EnergiDataServiceBindingConstants.BINDING_ID, "Interact with the Energi Data Service binding.");
this.thingRegistry = thingRegistry;
}

@Override
public void execute(String[] args, Console console) {
if (args.length < 1) {
printUsage(console);
return;
}

switch (args[0].toLowerCase()) {
case SUBCMD_UPDATE -> update(args, console);
default -> printUsage(console);
}
}

private void update(String[] args, Console console) {
ParsedUpdateParameters updateParameters;
try {
updateParameters = new ParsedUpdateParameters(args);
} catch (IllegalArgumentException e) {
String message = e.getMessage();
if (message != null) {
console.println(message);
}
printUsage(console);
return;
}

try {
for (EnergiDataServiceHandler handler : thingRegistry.getAll().stream().map(thing -> thing.getHandler())
.filter(EnergiDataServiceHandler.class::isInstance).map(EnergiDataServiceHandler.class::cast)
.toList()) {
Instant measureStart = Instant.now();
int items = switch (updateParameters.priceComponent) {
case SPOT_PRICE ->
handler.updateSpotPriceTimeSeries(updateParameters.startDate, updateParameters.endDate);
default -> {
DatahubTariff datahubTariff = updateParameters.priceComponent.getDatahubTariff();
yield datahubTariff == null ? 0
: handler.updateTariffTimeSeries(datahubTariff, updateParameters.startDate,
updateParameters.endDate);
}
};
Instant measureEnd = Instant.now();
console.println(items + " prices updated as time series in "
+ Duration.between(measureStart, measureEnd).toMillis() + " milliseconds.");
}
} catch (InterruptedException e) {
console.println("Interrupted.");
} catch (DataServiceException e) {
console.println("Failed to fetch prices: " + e.getMessage());
}
}

private class ParsedUpdateParameters {
PriceComponent priceComponent;
LocalDate startDate;
LocalDate endDate;

private int ARGUMENT_POSITION_PRICE_COMPONENT = 1;
private int ARGUMENT_POSITION_START_DATE = 2;
private int ARGUMENT_POSITION_END_DATE = 3;

ParsedUpdateParameters(String[] args) {
if (args.length < 3 || args.length > 4) {
throw new IllegalArgumentException("Incorrect number of parameters");
}

priceComponent = PriceComponent.fromString(args[ARGUMENT_POSITION_PRICE_COMPONENT].toLowerCase());

try {
startDate = LocalDate.parse(args[ARGUMENT_POSITION_START_DATE]);
} catch (DateTimeParseException e) {
throw new IllegalArgumentException("Invalid start date: " + e.getMessage(), e);
}

try {
endDate = args.length == 3 ? startDate : LocalDate.parse(args[ARGUMENT_POSITION_END_DATE]);
} catch (DateTimeParseException e) {
throw new IllegalArgumentException("Invalid end date: " + e.getMessage(), e);
}

if (endDate.isBefore(startDate)) {
throw new IllegalArgumentException("End date must be equal to or higher than start date");
}

if (endDate.isAfter(LocalDate.now())) {
throw new IllegalArgumentException("Future end date is not allowed");
}
}
}

@Override
public List<String> getUsages() {
return Arrays.asList(buildCommandUsage(SUBCMD_UPDATE + " ["
+ String.join("|", Stream.of(PriceComponent.values()).map(PriceComponent::toString).toList())
+ "] <StartDate> [<EndDate>]", "Update time series in requested period"));
}

@Override
public @Nullable ConsoleCommandCompleter getCompleter() {
return new EnergiDataServiceConsoleCommandCompleter();
}
}
Loading

0 comments on commit 9de34f3

Please sign in to comment.