Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[energidataservice] Add CO₂ emission channels #16330

Merged
merged 1 commit into from
Mar 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
35 changes: 24 additions & 11 deletions bundles/org.openhab.binding.energidataservice/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,22 +47,24 @@ It will not impact channels, see [Electricity Tax](#electricity-tax) for further

### Channel Group `electricity`

| Channel | Type | Description | Advanced |
|--------------------------|--------------------|--------------------------------------------------------------------------------|----------|
| spot-price | Number:EnergyPrice | Spot price in DKK or EUR per kWh | no |
| grid-tariff | Number:EnergyPrice | Grid tariff in DKK per kWh. Only available when `gridCompanyGLN` is configured | no |
| system-tariff | Number:EnergyPrice | System tariff in DKK per kWh | no |
| transmission-grid-tariff | Number:EnergyPrice | Transmission grid tariff in DKK per kWh | no |
| electricity-tax | Number:EnergyPrice | Electricity tax in DKK per kWh | no |
| reduced-electricity-tax | Number:EnergyPrice | Reduced electricity tax in DKK per kWh. For electric heating customers only | no |
| Channel | Type | Description |
|--------------------------|--------------------------|----------------------------------------------------------------------------------------|
| spot-price | Number:EnergyPrice | Spot price in DKK or EUR per kWh |
| grid-tariff | Number:EnergyPrice | Grid tariff in DKK per kWh. Only available when `gridCompanyGLN` is configured |
| system-tariff | Number:EnergyPrice | System tariff in DKK per kWh |
| transmission-grid-tariff | Number:EnergyPrice | Transmission grid tariff in DKK per kWh |
| electricity-tax | Number:EnergyPrice | Electricity tax in DKK per kWh |
| reduced-electricity-tax | Number:EnergyPrice | Reduced electricity tax in DKK per kWh. For electric heating customers only |
| co2-emission-prognosis | Number:EmissionIntensity | Estimated prognosis for CO₂ emission following the day-ahead market in g/kWh |
| co2-emission-realtime | Number:EmissionIntensity | Near up-to-date history for CO₂ emission from electricity consumed in Denmark in g/kWh |

_Please note:_ There is no channel providing the total price.
Instead, create a group item with `SUM` as aggregate function and add the individual price items as children.
This has the following advantages:

- Full customization possible: Freely choose the channels which should be included in the total.
- An additional item containing the kWh fee from your electricity supplier can be added also.
- Spot price can be configured in EUR while tariffs are in DKK.
- Full customization possible: Freely choose the channels which should be included in the total (even between different bindings).
- Spot price can be configured in EUR while tariffs are in DKK (and currency conversions are performed outside the binding).
- An additional item containing the kWh fee from your electricity supplier can be added also (and it can be dynamic).

If you want electricity tax included in your total price, please add either `electricity-tax` or `reduced-electricity-tax` to the group - depending on which one applies.
See [Electricity Tax](#electricity-tax) for further information.
Expand Down Expand Up @@ -141,6 +143,17 @@ This reduced rate is made available through channel `reduced-electricity-tax`.
The binding cannot determine or manage rate variations as they depend on metering data.
Usually `reduced-electricity-tax` is preferred when using electricity for heating.

#### CO₂ Emissions

Data for the CO₂ emission channels is published as time series with a resolution of 5 minutes.

Channel `co2-emission-realtime` provides near up-to-date historic emission and is refreshed every 5 minutes.
When the binding is started, or a new item is linked, or a linked item receives an update command, historic data for the last 24 hours is provided in addition to the current value.

Channel `co2-emission-prognosis` provides estimated prognosis for future emissions and is refreshed every 15 minutes.
Depending on the time of the day, an update of the prognosis may include estimates for more than 9 hours, but every update will have at least 9 hours into the future.
A persistence configuration is required for this channel.

## Thing Actions

Thing actions can be used to perform calculations as well as import prices directly into rules without relying on persistence.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,11 @@
import org.eclipse.jetty.http.HttpStatus;
import org.openhab.binding.energidataservice.internal.api.ChargeType;
import org.openhab.binding.energidataservice.internal.api.DatahubTariffFilter;
import org.openhab.binding.energidataservice.internal.api.Dataset;
import org.openhab.binding.energidataservice.internal.api.DateQueryParameter;
import org.openhab.binding.energidataservice.internal.api.GlobalLocationNumber;
import org.openhab.binding.energidataservice.internal.api.dto.CO2EmissionRecord;
import org.openhab.binding.energidataservice.internal.api.dto.CO2EmissionRecords;
import org.openhab.binding.energidataservice.internal.api.dto.DatahubPricelistRecord;
import org.openhab.binding.energidataservice.internal.api.dto.DatahubPricelistRecords;
import org.openhab.binding.energidataservice.internal.api.dto.ElspotpriceRecord;
Expand All @@ -66,9 +69,6 @@ public class ApiController {
private static final String ENDPOINT = "https://api.energidataservice.dk/";
private static final String DATASET_PATH = "dataset/";

private static final String DATASET_NAME_SPOT_PRICES = "Elspotprices";
private static final String DATASET_NAME_DATAHUB_PRICELIST = "DatahubPricelist";

private static final String FILTER_KEY_PRICE_AREA = "PriceArea";
private static final String FILTER_KEY_CHARGE_TYPE = "ChargeType";
private static final String FILTER_KEY_CHARGE_TYPE_CODE = "ChargeTypeCode";
Expand Down Expand Up @@ -111,31 +111,16 @@ public ElspotpriceRecord[] getSpotPrices(String priceArea, Currency currency, Da
throw new IllegalArgumentException("Invalid currency " + currency.getCurrencyCode());
}

Request request = httpClient.newRequest(ENDPOINT + DATASET_PATH + DATASET_NAME_SPOT_PRICES)
Request request = httpClient.newRequest(ENDPOINT + DATASET_PATH + Dataset.SpotPrices)
.timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS) //
.param("start", start.toString()) //
.param("filter", "{\"" + FILTER_KEY_PRICE_AREA + "\":\"" + priceArea + "\"}") //
.param("columns", "HourUTC,SpotPrice" + currency) //
.agent(userAgent) //
.method(HttpMethod.GET);

logger.trace("GET request for {}", request.getURI());

try {
ContentResponse response = request.send();

updatePropertiesFromResponse(response, properties);

int status = response.getStatus();
if (!HttpStatus.isSuccess(status)) {
throw new DataServiceException("The request failed with HTTP error " + status, status);
}
String responseContent = response.getContentAsString();
if (responseContent.isEmpty()) {
throw new DataServiceException("Empty response");
}
logger.trace("Response content: '{}'", responseContent);

String responseContent = sendRequest(request, properties);
ElspotpriceRecords records = gson.fromJson(responseContent, ElspotpriceRecords.class);
if (records == null) {
throw new DataServiceException("Error parsing response");
Expand All @@ -153,6 +138,27 @@ public ElspotpriceRecord[] getSpotPrices(String priceArea, Currency currency, Da
}
}

private String sendRequest(Request request, Map<String, String> properties)
throws TimeoutException, ExecutionException, InterruptedException, DataServiceException {
logger.trace("GET request for {}", request.getURI());

ContentResponse response = request.send();

updatePropertiesFromResponse(response, properties);

int status = response.getStatus();
if (!HttpStatus.isSuccess(status)) {
throw new DataServiceException("The request failed with HTTP error " + status, status);
}
String responseContent = response.getContentAsString();
if (responseContent.isEmpty()) {
throw new DataServiceException("Empty response");
}
logger.trace("Response content: '{}'", responseContent);

return responseContent;
}

private void updatePropertiesFromResponse(ContentResponse response, Map<String, String> properties) {
HttpFields headers = response.getHeaders();
String remainingCalls = headers.get(HEADER_REMAINING_CALLS);
Expand Down Expand Up @@ -200,7 +206,7 @@ public Collection<DatahubPricelistRecord> getDatahubPriceLists(GlobalLocationNum
filterMap.put(FILTER_KEY_NOTE, notes);
}

Request request = httpClient.newRequest(ENDPOINT + DATASET_PATH + DATASET_NAME_DATAHUB_PRICELIST)
Request request = httpClient.newRequest(ENDPOINT + DATASET_PATH + Dataset.DatahubPricelist)
.timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS) //
.param("filter", mapToFilter(filterMap)) //
.param("columns", columns) //
Expand All @@ -212,23 +218,8 @@ public Collection<DatahubPricelistRecord> getDatahubPriceLists(GlobalLocationNum
request = request.param("start", dateQueryParameter.toString());
}

logger.trace("GET request for {}", request.getURI());

try {
ContentResponse response = request.send();

updatePropertiesFromResponse(response, properties);

int status = response.getStatus();
if (!HttpStatus.isSuccess(status)) {
throw new DataServiceException("The request failed with HTTP error " + status, status);
}
String responseContent = response.getContentAsString();
if (responseContent.isEmpty()) {
throw new DataServiceException("Empty response");
}
logger.trace("Response content: '{}'", responseContent);

String responseContent = sendRequest(request, properties);
DatahubPricelistRecords records = gson.fromJson(responseContent, DatahubPricelistRecords.class);
if (records == null) {
throw new DataServiceException("Error parsing response");
Expand All @@ -255,4 +246,48 @@ private String mapToFilter(Map<String, Collection<String>> map) {
e -> "\"" + e.getKey() + "\":[\"" + e.getValue().stream().collect(Collectors.joining("\",\"")) + "\"]")
.collect(Collectors.joining(",")) + "}";
}

/**
* Retrieve CO2 emissions for requested area.
*
* @param dataset Dataset to obtain
* @param priceArea Usually DK1 or DK2
* @param start Specifies the start point of the period for the data request
* @param properties Map of properties which will be updated with metadata from headers
* @return Records with 5 minute periods and emissions in g/kWh.
* @throws InterruptedException
* @throws DataServiceException
*/
public CO2EmissionRecord[] getCo2Emissions(Dataset dataset, String priceArea, DateQueryParameter start,
Map<String, String> properties) throws InterruptedException, DataServiceException {
if (dataset != Dataset.CO2Emission && dataset != Dataset.CO2EmissionPrognosis) {
throw new IllegalArgumentException("Invalid dataset " + dataset + " for getting CO2 emissions");
}
Request request = httpClient.newRequest(ENDPOINT + DATASET_PATH + dataset)
.timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS) //
.param("start", start.toString()) //
.param("filter", "{\"" + FILTER_KEY_PRICE_AREA + "\":\"" + priceArea + "\"}") //
.param("columns", "Minutes5UTC,CO2Emission") //
.param("sort", "Minutes5UTC DESC") //
.agent(userAgent) //
.method(HttpMethod.GET);

try {
String responseContent = sendRequest(request, properties);
CO2EmissionRecords records = gson.fromJson(responseContent, CO2EmissionRecords.class);
if (records == null) {
throw new DataServiceException("Error parsing response");
}

if (records.total() == 0 || Objects.isNull(records.records()) || records.records().length == 0) {
throw new DataServiceException("No records");
}

return Arrays.stream(records.records()).filter(Objects::nonNull).toArray(CO2EmissionRecord[]::new);
} catch (JsonSyntaxException e) {
throw new DataServiceException("Error parsing response", e);
} catch (TimeoutException | ExecutionException e) {
throw new DataServiceException(e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ public class EnergiDataServiceBindingConstants {
+ ChannelUID.CHANNEL_GROUP_SEPARATOR + "reduced-electricity-tax";
public static final String CHANNEL_TRANSMISSION_GRID_TARIFF = CHANNEL_GROUP_ELECTRICITY
+ ChannelUID.CHANNEL_GROUP_SEPARATOR + "transmission-grid-tariff";
public static final String CHANNEL_CO2_EMISSION_PROGNOSIS = CHANNEL_GROUP_ELECTRICITY
+ ChannelUID.CHANNEL_GROUP_SEPARATOR + "co2-emission-prognosis";
public static final String CHANNEL_CO2_EMISSION_REALTIME = CHANNEL_GROUP_ELECTRICITY
+ ChannelUID.CHANNEL_GROUP_SEPARATOR + "co2-emission-realtime";

public static final Set<String> ELECTRICITY_CHANNELS = Set.of(CHANNEL_SPOT_PRICE, CHANNEL_GRID_TARIFF,
CHANNEL_SYSTEM_TARIFF, CHANNEL_TRANSMISSION_GRID_TARIFF, CHANNEL_ELECTRICITY_TAX,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* 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.api;

import org.eclipse.jdt.annotation.NonNullByDefault;

/**
* Energi Data Service dataset.
*
* @author Jacob Laursen - Initial contribution
*/
@NonNullByDefault
public enum Dataset {
SpotPrices("Elspotprices"),
DatahubPricelist("DatahubPricelist"),
CO2Emission("CO2Emis"),
CO2EmissionPrognosis("CO2EmisProg");

private final String name;

Dataset(String name) {
this.name = name;
}

@Override
public String toString() {
return name;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* 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.api.dto;

import java.math.BigDecimal;
import java.time.Instant;
import java.time.temporal.ChronoUnit;

import org.eclipse.jdt.annotation.NonNullByDefault;

import com.google.gson.annotations.SerializedName;

/**
* Record as part of {@link CO2EmissionRecords} from Energi Data Service.
*
* @author Jacob Laursen - Initial contribution
*/
@NonNullByDefault
public record CO2EmissionRecord(@SerializedName("Minutes5UTC") Instant start,
@SerializedName("CO2Emission") BigDecimal emission) {

public Instant end() {
return start.plus(5, ChronoUnit.MINUTES);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* 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.api.dto;

import org.eclipse.jdt.annotation.NonNullByDefault;

/**
* Received {@link CO2EmissionRecords} from Energi Data Service.
*
* @author Jacob Laursen - Initial contribution
*/
@NonNullByDefault
public record CO2EmissionRecords(int total, String filters, int limit, String dataset, CO2EmissionRecord[] records) {
}