2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.energidataservice.internal;
15 import java.math.BigDecimal;
16 import java.math.RoundingMode;
17 import java.time.Duration;
18 import java.time.Instant;
19 import java.time.temporal.ChronoUnit;
20 import java.util.ArrayList;
21 import java.util.Collection;
22 import java.util.HashMap;
23 import java.util.Iterator;
24 import java.util.List;
27 import javax.measure.quantity.Energy;
28 import javax.measure.quantity.Power;
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.openhab.binding.energidataservice.internal.exception.MissingPriceException;
32 import org.openhab.core.library.types.QuantityType;
33 import org.openhab.core.library.unit.Units;
34 import org.slf4j.Logger;
35 import org.slf4j.LoggerFactory;
38 * Provides calculations based on price maps.
39 * This is the current stage of evolution.
40 * Ideally this binding would simply provide data in a well-defined format for
41 * openHAB core. Operations on this data could then be implemented in core.
42 * This way there would be a unified interface from rules, and the calculations
43 * could be reused between different data providers (bindings).
45 * @author Jacob Laursen - Initial contribution
48 public class PriceCalculator {
50 private final Logger logger = LoggerFactory.getLogger(PriceCalculator.class);
52 private final Map<Instant, BigDecimal> priceMap;
54 public PriceCalculator(Map<Instant, BigDecimal> priceMap) {
55 this.priceMap = priceMap;
59 * Calculate cheapest period from list of durations with specified amount of energy
62 * @param earliestStart Earliest allowed start time.
63 * @param latestEnd Latest allowed end time.
64 * @param totalDuration Total duration to fit.
65 * @param durationPhases List of {@link Duration}'s representing different phases of using power.
66 * @param energyUsedPerPhase Amount of energy used per phase.
68 * @return Map containing resulting values
70 public Map<String, Object> calculateCheapestPeriod(Instant earliestStart, Instant latestEnd, Duration totalDuration,
71 Collection<Duration> durationPhases, QuantityType<Energy> energyUsedPerPhase) throws MissingPriceException {
72 QuantityType<Energy> energyInWattHour = energyUsedPerPhase.toUnit(Units.WATT_HOUR);
73 if (energyInWattHour == null) {
74 throw new IllegalArgumentException(
75 "Invalid unit " + energyUsedPerPhase.getUnit() + ", expected energy unit");
77 // watts = (kWh × 1,000) ÷ hrs
78 int numerator = energyInWattHour.intValue() * 3600;
79 List<QuantityType<Power>> consumptionPhases = new ArrayList<>();
80 Duration remainingDuration = totalDuration;
81 for (Duration phase : durationPhases) {
82 consumptionPhases.add(QuantityType.valueOf(numerator / phase.getSeconds(), Units.WATT));
83 remainingDuration = remainingDuration.minus(phase);
85 if (remainingDuration.isNegative()) {
86 throw new IllegalArgumentException("totalDuration must be equal to or greater than sum of phases");
88 if (!remainingDuration.isZero()) {
89 List<Duration> durationsWithTermination = new ArrayList<>(durationPhases);
90 durationsWithTermination.add(remainingDuration);
91 consumptionPhases.add(QuantityType.valueOf(0, Units.WATT));
92 return calculateCheapestPeriod(earliestStart, latestEnd, durationsWithTermination, consumptionPhases);
94 return calculateCheapestPeriod(earliestStart, latestEnd, durationPhases, consumptionPhases);
98 * Calculate cheapest period from duration with linear power usage.
100 * @param earliestStart Earliest allowed start time.
101 * @param latestEnd Latest allowed end time.
102 * @param duration Duration to fit.
103 * @param power Power consumption for the duration of time.
105 * @return Map containing resulting values
107 public Map<String, Object> calculateCheapestPeriod(Instant earliestStart, Instant latestEnd, Duration duration,
108 QuantityType<Power> power) throws MissingPriceException {
109 return calculateCheapestPeriod(earliestStart, latestEnd, List.of(duration), List.of(power));
113 * Calculate cheapest period from list of durations with corresponding list of consumption
116 * @param earliestStart Earliest allowed start time.
117 * @param latestEnd Latest allowed end time.
118 * @param durationPhases List of {@link Duration}'s representing different phases of using power.
119 * @param consumptionPhases Corresponding List of power consumption for the duration of time.
121 * @return Map containing resulting values
123 public Map<String, Object> calculateCheapestPeriod(Instant earliestStart, Instant latestEnd,
124 Collection<Duration> durationPhases, Collection<QuantityType<Power>> consumptionPhases)
125 throws MissingPriceException {
126 if (durationPhases.size() != consumptionPhases.size()) {
127 throw new IllegalArgumentException("Number of phases do not match");
129 Map<String, Object> result = new HashMap<>();
130 Duration totalDuration = durationPhases.stream().reduce(Duration.ZERO, Duration::plus);
131 Instant calculationStart = earliestStart;
132 Instant calculationEnd = earliestStart.plus(totalDuration);
133 BigDecimal lowestPrice = BigDecimal.valueOf(Double.MAX_VALUE);
134 BigDecimal highestPrice = BigDecimal.ZERO;
135 Instant cheapestStart = Instant.MIN;
136 Instant mostExpensiveStart = Instant.MIN;
138 while (calculationEnd.compareTo(latestEnd) <= 0) {
139 BigDecimal currentPrice = BigDecimal.ZERO;
140 Duration minDurationUntilNextHour = Duration.ofHours(1);
141 Instant atomStart = calculationStart;
143 Iterator<Duration> durationIterator = durationPhases.iterator();
144 Iterator<QuantityType<Power>> consumptionIterator = consumptionPhases.iterator();
145 while (durationIterator.hasNext()) {
146 Duration atomDuration = durationIterator.next();
147 QuantityType<Power> atomConsumption = consumptionIterator.next();
149 Instant atomEnd = atomStart.plus(atomDuration);
150 Instant hourStart = atomStart.truncatedTo(ChronoUnit.HOURS);
151 Instant hourEnd = hourStart.plus(1, ChronoUnit.HOURS);
153 // Get next intersection with hourly rate change.
154 Duration durationUntilNextHour = Duration.between(atomStart, hourEnd);
155 if (durationUntilNextHour.compareTo(minDurationUntilNextHour) < 0) {
156 minDurationUntilNextHour = durationUntilNextHour;
159 BigDecimal atomPrice = calculatePrice(atomStart, atomEnd, atomConsumption);
160 currentPrice = currentPrice.add(atomPrice);
164 if (currentPrice.compareTo(lowestPrice) < 0) {
165 lowestPrice = currentPrice;
166 cheapestStart = calculationStart;
168 if (currentPrice.compareTo(highestPrice) > 0) {
169 highestPrice = currentPrice;
170 mostExpensiveStart = calculationStart;
173 // Now fast forward to next hourly rate intersection.
174 calculationStart = calculationStart.plus(minDurationUntilNextHour);
175 calculationEnd = calculationStart.plus(totalDuration);
178 if (!cheapestStart.equals(Instant.MIN)) {
179 result.put("CheapestStart", cheapestStart);
180 result.put("LowestPrice", lowestPrice);
181 result.put("MostExpensiveStart", mostExpensiveStart);
182 result.put("HighestPrice", highestPrice);
189 * Calculate total price from 'start' to 'end' given linear power consumption.
191 * @param start Start time
192 * @param end End time
193 * @param power The current power consumption.
195 public BigDecimal calculatePrice(Instant start, Instant end, QuantityType<Power> power)
196 throws MissingPriceException {
197 QuantityType<Power> quantityInWatt = power.toUnit(Units.WATT);
198 if (quantityInWatt == null) {
199 throw new IllegalArgumentException("Invalid unit " + power.getUnit() + ", expected power unit");
201 BigDecimal watt = new BigDecimal(quantityInWatt.intValue());
202 if (watt.equals(BigDecimal.ZERO)) {
203 return BigDecimal.ZERO;
206 Instant current = start;
207 BigDecimal result = BigDecimal.ZERO;
208 while (current.isBefore(end)) {
209 Instant hourStart = current.truncatedTo(ChronoUnit.HOURS);
210 Instant hourEnd = hourStart.plus(1, ChronoUnit.HOURS);
212 BigDecimal currentPrice = priceMap.get(hourStart);
213 if (currentPrice == null) {
214 throw new MissingPriceException("Price missing at " + hourStart.toString());
217 Instant currentStart = hourStart;
218 if (start.isAfter(hourStart)) {
219 currentStart = start;
221 Instant currentEnd = hourEnd;
222 if (end.isBefore(hourEnd)) {
226 // E(kWh) = P(W) × t(hr) / 1000
227 Duration duration = Duration.between(currentStart, currentEnd);
228 BigDecimal contribution = currentPrice.multiply(watt).multiply(
229 new BigDecimal(duration.getSeconds()).divide(new BigDecimal(3600000), 9, RoundingMode.HALF_UP));
230 result = result.add(contribution);
231 logger.trace("Period {}-{}: {} @ {}", currentStart, currentEnd, contribution, currentPrice);