All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.openremote.manager.energy.EnergyOptimiser Maven / Gradle / Ivy

/*
 * Copyright 2021, OpenRemote Inc.
 *
 * See the CONTRIBUTORS.txt file in the distribution for a
 * full listing of individual contributors.
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program. If not, see .
 */
package org.openremote.manager.energy;

import org.openremote.model.util.Pair;

import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.temporal.ChronoField;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.logging.Level;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

import static org.openremote.manager.energy.EnergyOptimisationService.LOG;

public class EnergyOptimiser {

    protected double intervalSize;
    protected double financialWeighting;

    /**
     * 24 divided by intervalSize must be a whole number
     */
    public EnergyOptimiser(double intervalSize, double financialWeighting) throws IllegalArgumentException {
        if ((24d / intervalSize) != (int) (24d / intervalSize)) {
            throw new IllegalArgumentException("24 divided by intervalSizeHours must be whole number");
        }
        this.intervalSize = intervalSize;
        this.financialWeighting = Math.max(0, Math.min(1d, financialWeighting));
    }

    public double getIntervalSize() {
        return intervalSize;
    }

    public double getFinancialWeighting() {
        return financialWeighting;
    }

    public int get24HourIntervalCount() {
        return (int) (24d / intervalSize);
    }

//    /**
//     * Function to calculate the power requirements for the given storage asset at each interval in the window
//     * based on the provided power demand requirements and potential cost saving at each interval so for each interval
//     * a an array of [powerDemand, gridCost] is required. For a given interval a positive value means importing
//     * power and negative means exporting power:
//     * 
    // *
  • powerDemand = The net demand of suppliers - consumers - higher priority supplier capabilities
  • // *
  • gridCost = Grid cost / kWh (positive means expense, negative means income)
  • // *
// */ // // TODO: powerImportMax should be a function of energy level // public Function getStoragePowerCalculator(ElectricityStorageAsset storageAsset) { // // return (powerExportsAndSavings) -> { // // double energyCapacity = storageAsset.getEnergyCapacity().orElse(0d); // double storedEnergy = storageAsset.getEnergyLevel().orElse(0d); // double tariffExport = storageAsset.getTariffExport().orElse(0d); // double tariffImport = storageAsset.getTariffExport().orElse(0d); // double carbonExport = storageAsset.getCarbonExport().orElse(0d); // double carbonImport = storageAsset.getCarbonImport().orElse(0d); // double costExport = financialWeighting * tariffExport + (1d-financialWeighting) * carbonExport; // double costImport = financialWeighting * tariffImport + (1d-financialWeighting) * carbonImport; // double powerEfficiencyImport = storageAsset.getEfficiencyImport().orElse(100); // double powerEfficiencyExport = storageAsset.getEfficiencyExport().orElse(100); // double powerImportMax = storageAsset.getPowerImportMax().orElse(Double.MAX_VALUE); // double powerExportMax = storageAsset.getPowerImportMax().orElse(Double.MAX_VALUE); // int energyLevelMax = storageAsset.getEnergyLevelPercentageMax().orElse(100); // // // double[] powerImport = new double[intervalCount]; // double[] powerExport = new double[intervalCount]; // double[] energyLevel = new double[intervalCount]; // Arrays.fill(energyLevel, storedEnergy); // //// // Calculate total energy requirement //// double totalEnergyRequired = Arrays.stream(powerExportsAndSavings) //// .map(interval -> interval[2] * intervalSize) //// .reduce(0d, Double::sum); // //// // If we have enough energy stored then just consume that (no need to import more) //// if (totalEnergyRequired < storedEnergy) { //// Arrays.fill(powerImport, 0d); //// return powerImport; //// } // // /* Look for storage import income opportunities (i.e. when grid pays us to import) */ // // // Order intervals based on potential income [gridCost+storageImportCost] < 0 (smallest first) // Integer[] indexesSortedIncome = IntStream.range(0, powerExportsAndSavings.length).boxed().toArray(Integer[]::new); // Arrays.sort( // indexesSortedIncome, // Comparator.comparingDouble((i) -> powerExportsAndSavings[i][1] + costExport) // ); // // for (int i=0; i= 0) { // // No income opportunity // break; // } // // // TODO: Ensure we don't exceed power demand limits // // Find an earlier interval // } // // // // Order intervals based on potential cost saving [gridCost-storageExportCost] (largest first) // Integer[] indexesSortedSaving = IntStream.range(0, powerExportsAndSavings.length).boxed().toArray(Integer[]::new); // Arrays.sort( // indexesSortedSaving, // Comparator.comparingDouble((i) -> powerExportsAndSavings[i][1] - costExport).reversed() // ); // // // // Find an interval earlier than each ordered export interval that costs less than the potential saving // Arrays.stream(indexesSortedSaving).forEach(powerExportIndex -> { // double[] powerExportDemand = powerExportsAndSavings[powerExportIndex]; // double powerDemand = powerExportDemand[0]; // double saving = powerExportDemand[1] - costExport; // // // Order grid costs up to this interval (smallest first) // Integer[] indexesSortedGridCost = IntStream.range(0, powerExportIndex).boxed().toArray(Integer[]::new); // Arrays.sort( // indexesSortedGridCost, // Comparator.comparingDouble((i) -> powerExportsAndSavings[i][1]) // ); // // // Go through intervals allocating required power demand until interval reaches powerImportMax or storage is full // for (int i=0; i 0 && i powerDemand) { // // This interval can fulfill entire demand // used += powerDemand; // powerDemand = 0; // } else { // // This interval can only partially fulfill the demand // powerDemand -= (powerImportMax - used); // used = powerImportMax; // } // } // powerImport[i] = used; // i++; // } // }); // }; // } /** * Will take the supplied 24x7 energy schedule percentages and energy level min/max values and apply them to the * supplied energyLevelMins also adjusting for any intervalSize difference. The energy schedule should be in UTC * time. */ public void applyEnergySchedule(double[] energyLevelMins, double[] energyLevelMaxs, double energyCapacity, int[][] energyLevelSchedule, LocalDateTime currentTime) { if (energyLevelSchedule == null) { return; } // Extract the schedule for the next 24 hour period starting at current hour plus 1 (need to attain energy level by the time the hour starts) OffsetDateTime date = currentTime.plus(1, ChronoUnit.HOURS).atOffset(ZoneOffset.UTC); int dayIndex = date.getDayOfWeek().getValue(); int hourIndex = date.get(ChronoField.HOUR_OF_DAY); int i = 0; double[] schedule = new double[24]; while (i < 24) { // Convert from % to absolute value schedule[i] = energyCapacity * energyLevelSchedule[dayIndex][hourIndex] * 0.01; hourIndex++; if (hourIndex > 23) { hourIndex = 0; dayIndex = (dayIndex + 1) % 7; } i++; } // Convert schedule intervals to match optimisation intervals - need to look at schedule for if (intervalSize <= 1d) { int hourIntervals = (int) (1d / intervalSize); for (i = 0; i < schedule.length; i++) { // Put energy level schedule value into first interval for the hour energyLevelMins[(hourIntervals * i)] = Math.min(energyLevelMaxs[hourIntervals * i], Math.max(energyLevelMins[hourIntervals * i], schedule[i])); } } else { int takeSize = (int) intervalSize; int hourIntervals = (int) (24d / intervalSize); for (i = 0; i < hourIntervals; i++) { // Take largest energy level for the intervals energyLevelMins[i] = Math.min(energyLevelMaxs[i], Math.max(energyLevelMins[i], java.util.Arrays.stream(schedule, (i * takeSize), (i * takeSize) + takeSize).max().orElse(0))); } } } /** * Adjusts the supplied energyLevelMin values to match the physical characteristics (i.e. the charge and discharge * rates). */ public void normaliseEnergyMinRequirements(double[] energyLevelMins, Function powerImportMaxCalculator, Function powerExportMaxCalculator, double energyLevel) { int intervalCount = get24HourIntervalCount(); Function previousEnergyLevelCalculator = i -> (i == 0 ? energyLevel : energyLevelMins[i - 1]); // Adjust energy min requirements to match physical characteristics (charge/discharge rate) IntStream.range(0, intervalCount).forEach(i -> { double energyDelta = energyLevelMins[i] - previousEnergyLevelCalculator.apply(i); if (energyDelta > 0) { // May need to increase earlier min values until there is no energy deficit with previous interval // If we reach interval 0 and there is still a deficit then need to reduce this energy level for (int j = i; j >= 0; j--) { double previousMin = energyLevelMins[j] - (powerImportMaxCalculator.apply(j) * intervalSize); double previous = previousEnergyLevelCalculator.apply(j); if (previous < previousMin) { if (j == 0) { // Can't attain so shift all min values down double shift = previous - previousMin; for (int k = 0; k <= i; k++) { energyLevelMins[k] += shift; } } else { // Increase the previous min value energyLevelMins[j - 1] = previousMin; } } else { // Already at or above min requirement break; } } } else if (energyDelta < 0) { // May need to spread discharge over this and later intervals for (int j = i; j < intervalCount; j++) { double min = previousEnergyLevelCalculator.apply(j) + (powerExportMaxCalculator.apply(j) * intervalSize); if (min > energyLevelMins[j]) { energyLevelMins[j] = min; } else { // Already at or above min requirement break; } } } }); } /** * Will update the powerSetpoints in order to achieve the energyLevelMin values supplied. */ public void applyEnergyMinImports(double[][] importCostAndPower, double[] energyLevelMins, double[] powerSetpoints, Function energyLevelCalculator, BiFunction importOptimiser, Function powerImportMaxCalculator) { // Ensure min energy levels are attained by the end of the interval as these have priority AtomicInteger fromInterval = new AtomicInteger(0); IntStream.range(0, get24HourIntervalCount()).forEach(i -> { double intervalEnergyLevel = energyLevelCalculator.apply(i); double energyDeficit = energyLevelMins[i] - intervalEnergyLevel; if (energyDeficit > 0) { double energyAttainable = powerImportMaxCalculator.apply(i) * intervalSize; energyAttainable = Math.min(energyDeficit, energyAttainable); powerSetpoints[i] = energyAttainable / intervalSize; energyDeficit -= energyAttainable; if (energyDeficit > 0) { retrospectiveEnergyAllocator(importCostAndPower, energyLevelMins, powerSetpoints, importOptimiser, powerImportMaxCalculator, energyDeficit, fromInterval.getAndSet(i), i); } } }); } /** * Creates earlier imports between fromInterval (inclusive) and toInterval (exclusive) in order to meet min energy * level requirement at the specified interval based on the provided energy level at the start of fromInterval. */ public void retrospectiveEnergyAllocator(double[][] importCostAndPower, double[] energyLevelMins, double[] powerSetpoints, BiFunction importOptimiser, Function powerImportMaxCalculator, double energyLevel, int fromInterval, int toInterval) { double energyDeficit = energyLevelMins[toInterval] - energyLevel; if (energyDeficit <= 0) { return; } // Do import until energy deficit reaches 0 or there are no more intervals boolean canMeetDeficit = IntStream.range(fromInterval, toInterval).mapToDouble(i -> Math.min(powerImportMaxCalculator.apply(i), importCostAndPower[i][2]) ).sum() >= energyDeficit; boolean morePowerAvailable = !canMeetDeficit && IntStream.range(fromInterval, toInterval).mapToObj(i -> importCostAndPower[i][2] < powerImportMaxCalculator.apply(i)).anyMatch(b -> b); if (!canMeetDeficit && morePowerAvailable) { // Need to push imports beyond optimum to fulfill energy deficit IntStream.range(fromInterval, toInterval).forEach(i -> { double powerImportMax = powerImportMaxCalculator.apply(i); if (importCostAndPower[i][2] < powerImportMax) { importCostAndPower[i] = importOptimiser.apply(i, new double[]{0d, powerImportMax}); } }); } // Sort import intervals by cost (lowest to highest) List> sortedImportCostAndPower = IntStream.range(fromInterval, toInterval) .mapToObj(i -> new Pair<>(i, importCostAndPower[i])).sorted( Comparator.comparingDouble(pair -> pair.value[0]) ).collect(Collectors.toList()); int i = 0; while (energyDeficit > 0 && i < sortedImportCostAndPower.size()) { double importPower = Math.min(powerImportMaxCalculator.apply(i), importCostAndPower[i][2]); double requiredPower = energyDeficit / intervalSize; // If we earn by importing then take the maximum power importPower = importCostAndPower[i][0] < 0 ? importPower : Math.min(importPower, requiredPower); powerSetpoints[i] = importPower; energyDeficit -= importPower; i++; } } /** * Will find the best earning opportunity for each interval (import or export) and will then try to apply them in * chronological order (reallocating earlier import/exports if it cost beneficial). The powerSetpoints will be * updated as a result. */ public void applyEarningOpportunities(double[][] importCostAndPower, double[][] exportCostAndPower, double[] energyLevelMins, double[] energyLevelMaxs, double[] powerSetpoints, Function energyLevelCalculator, Function powerImportMaxCalculator, Function powerExportMaxCalculator) { LOG.finest("Applying earning opportunities"); // Look for import and export earning opportunities double[][] primary = importCostAndPower != null ? importCostAndPower : exportCostAndPower; // Never null double[][] secondary = importCostAndPower != null ? exportCostAndPower : null; // Could be null List> earningOpportunities = IntStream.range(0, primary.length).mapToObj(i -> { if (secondary == null) { return new Pair<>(i, primary[i]); } // Return whichever has the lowest cost if (primary[i][0] < secondary[i][0]) { return new Pair<>(i, primary[i]); } return new Pair<>(i, secondary[i]); }) .filter(intervalCostAndPowerBand -> intervalCostAndPowerBand.value[0] < 0) .sorted(Comparator.comparingDouble(optimisedInterval -> optimisedInterval.value[0])) .collect(Collectors.toList()); if (earningOpportunities.isEmpty()) { LOG.finest("No earning opportunities found"); } if (LOG.isLoggable(Level.FINEST)) { earningOpportunities.forEach(op -> LOG.finest("Earning opportunity: interval=" + op.key + ", cost=" + op.value[0] + ", powerMin=" + op.value[1] + ", powerMax=" + op.value[2])); } // Go through each earning opportunity and determine if it can be utilised without breaching the energy min // levels for (Pair earningOpportunity : earningOpportunities) { int interval = earningOpportunity.key; double[] costAndPower = earningOpportunity.value; assert importCostAndPower != null; assert exportCostAndPower != null; if (isImportOpportunity(costAndPower, powerSetpoints[interval], interval, powerImportMaxCalculator)) { // import opportunity and interval still available to import power applyImportOpportunity(importCostAndPower, exportCostAndPower, energyLevelMins, energyLevelMaxs, powerSetpoints, energyLevelCalculator, powerImportMaxCalculator, powerExportMaxCalculator, interval); } else if (isExportOpportunity(costAndPower, powerSetpoints[interval], interval, powerExportMaxCalculator)) { // export opportunity and interval still available to export power applyExportOpportunity(importCostAndPower, exportCostAndPower, energyLevelMins, energyLevelMaxs, powerSetpoints, energyLevelCalculator, powerImportMaxCalculator, powerExportMaxCalculator, interval); } } } protected boolean isImportOpportunity(double[] costAndPower, double powerSetpoint, int interval, Function powerImportMaxCalculator) { return costAndPower[2] > 0 && powerSetpoint >= 0 && powerSetpoint < Math.min(powerImportMaxCalculator.apply(interval), costAndPower[2]); } protected boolean isExportOpportunity(double[] costAndPower, double powerSetpoint, int interval, Function powerExportMaxCalculator) { return costAndPower[1] < 0 && powerSetpoint <= 0 && powerSetpoint > Math.max(powerExportMaxCalculator.apply(interval), costAndPower[1]); } /** * Tries to apply the maximum import power as defined in the importCostAndPower at the specified interval taking * into consideration the maximum power and energy levels; if there is insufficient power or energy capacity at the * interval then an earlier cost effective export opportunity will be attempted to offset the requirement. The * powerSetpoints will be updated as a result. */ public void applyImportOpportunity(double[][] importCostAndPower, double[][] exportCostAndPower, double[] energyLevelMins, double[] energyLevelMaxs, double[] powerSetpoints, Function energyLevelCalculator, Function powerImportMaxCalculator, Function powerExportMaxCalculator, int interval) { LOG.finest("Applying import earning opportunity: interval=" + interval); double[] costAndPower = importCostAndPower[interval]; double impPowerMin = costAndPower[1]; double impPowerMax = Math.min(powerImportMaxCalculator.apply(interval), costAndPower[2]); double powerCapacity = impPowerMax - powerSetpoints[interval]; if (impPowerMin > powerCapacity) { LOG.finest("Can't attain min power level to make use of this opportunity"); return; } double energySpace = energyLevelMaxs[interval] - energyLevelCalculator.apply(interval); double energySpaceMax = powerCapacity * intervalSize; double energySpaceMin = impPowerMin * intervalSize; List> pastIntervalPowerDeltas = new ArrayList<>(); int k = interval; while (k < powerSetpoints.length && energySpace > 0 && energySpace >= energySpaceMin) { double futureEnergySpace = energyLevelMaxs[k] - energyLevelCalculator.apply(k); energySpace = Math.min(energySpace, futureEnergySpace); k++; } if (energySpace < energySpaceMax && exportCostAndPower != null) { // Can't maximise on opportunity without exporting earlier on so can this be done // in a cost effective way LOG.finest("Looking for earlier export opportunities to maximise on this import opportunity: space=" + energySpace + ", max=" + energySpaceMax); int i = interval - 1; List> pastOpportunities = new ArrayList<>(); while (i >= 0) { if (costAndPower[0] + exportCostAndPower[i][0] < 0 && powerSetpoints[i] <= 0) { // We can afford to export earlier and still earn from this import pastOpportunities.add(new Pair<>(i, exportCostAndPower[i][0])); } i--; } pastOpportunities.sort(Comparator.comparingDouble(op -> op.value)); int j = 0; if (pastOpportunities.isEmpty()) { LOG.finest("No earlier export opportunities identified"); } while (energySpace < energySpaceMax && j < pastOpportunities.size()) { // Energy level at this interval must be above energy min to consider exporting Pair opportunity = pastOpportunities.get(j); int pastInterval = opportunity.key; // Power capacity must be within the optimum power band double[] pastCostAndPower = exportCostAndPower[pastInterval]; double expPowerMax = Math.max(powerExportMaxCalculator.apply(pastInterval), pastCostAndPower[1]); double expPowerCapacity = expPowerMax - powerSetpoints[pastInterval]; if (expPowerCapacity >= 0 || expPowerCapacity > pastCostAndPower[2]) { LOG.finest("Power capacity is outside optimum power band so cannot use this opportunity"); j++; continue; } double energySurplusMin = pastCostAndPower[2] * intervalSize; double energySurplus = energyLevelMins[pastInterval] - energyLevelCalculator.apply(pastInterval); energySurplus = Math.max(energySurplus, energySpace - energySpaceMax); // We have spare energy capacity and power check if we don't violate energy min for any future exports k = pastInterval; while (k < powerSetpoints.length && energySurplus < 0 && energySurplus <= energySurplusMin) { double futureEnergySurplus = energyLevelCalculator.apply(k) - energyLevelMins[k]; energySurplus = Math.max(energySurplus, -futureEnergySurplus); if (energySurplus <= 0) { LOG.finest("Earlier export opportunity would violate future energy min level: interval=" + j + ", futureInterval=" + k); } k++; } expPowerCapacity = Math.max(expPowerCapacity, energySurplus / intervalSize); if (expPowerCapacity < 0 && expPowerCapacity < pastCostAndPower[2]) { // We can export in the optimum range energySpace += (-1d * expPowerCapacity * intervalSize); pastIntervalPowerDeltas.add(new Pair<>(pastInterval, expPowerCapacity)); LOG.finest("Earlier export opportunity identified: interval=" + pastInterval + ", power=" + expPowerCapacity); } j++; } } // Do original import if there is enough energy space if (energySpace > 0 && energySpace >= energySpaceMin) { // Adjust past interval set points as required pastIntervalPowerDeltas.forEach(intervalAndDelta -> powerSetpoints[intervalAndDelta.key] += intervalAndDelta.value); energySpaceMax = Math.min(energySpaceMax, energySpace); powerCapacity = Math.min(impPowerMax - powerSetpoints[interval], (energySpaceMax / intervalSize)); powerSetpoints[interval] = powerSetpoints[interval] + powerCapacity; LOG.finest("Applied import earning opportunity: set point=" + powerSetpoints[interval] + " (delta: " + powerCapacity + ")"); } } /** * Tries to apply the maximum export power as defined in the exportCostAndPower at the specified interval taking * into consideration the maximum power and energy levels; if there is insufficient power or energy capacity at the * interval then an earlier cost effective import opportunity will be attempted to offset the requirement. The * powerSetpoints will be updated as a result. */ public void applyExportOpportunity(double[][] importCostAndPower, double[][] exportCostAndPower, double[] energyLevelMins, double[] energyLevelMaxs, double[] powerSetpoints, Function energyLevelCalculator, Function powerImportMaxCalculator, Function powerExportMaxCalculator, int interval) { LOG.finest("Applying export earning opportunity: interval=" + interval); double[] costAndPower = exportCostAndPower[interval]; double expPowerMin = costAndPower[2]; double expPowerMax = Math.max(powerExportMaxCalculator.apply(interval), costAndPower[1]); double powerCapacity = expPowerMax - powerSetpoints[interval]; if (expPowerMin < powerCapacity) { LOG.finest("Can't attain min power level to make use of this opportunity"); return; } double energySurplus = energyLevelCalculator.apply(interval) - energyLevelMins[interval]; double energySurplusMin = -1d * expPowerMin * intervalSize; double energySurplusMax = -1d * powerCapacity * intervalSize; List> pastAndFutureIntervalPowerDeltas = new ArrayList<>(); int k = interval; while (k < powerSetpoints.length && energySurplus > 0 && energySurplus >= energySurplusMin) { double futureEnergySurplus = energyLevelCalculator.apply(k) - energyLevelMins[k]; // The following is an attempt to make use of an earning opportunity that would violate future energy limits // by allocating extra imports between 'now' and 'then' - this needs more work // if (futureEnergySurplus < energySurplusMin || futureEnergySurplus <= 0) { // // Try and allocate an import between this future point and the earning opportunity to prevent this deficit // int l = k - 1; // while (futureEnergySurplus < energySurplusMax && l > interval) { // int finalL = l; // if (powerSetpoints[l] >= 0 && pastAndFutureIntervalPowerDeltas.stream().noneMatch(delta -> delta.key.equals(finalL))) { // // double surplusDeficit = energySurplusMax - futureEnergySurplus; // double intermediateEnergyCapacity = energyLevelMax - energyLevelCalculator.apply(l); // double intermediatePowerCapacity = powerImportMaxCalculator.apply(l) - powerSetpoints[l]; // intermediatePowerCapacity = Math.min(intermediatePowerCapacity, surplusDeficit / intervalSize); // intermediatePowerCapacity = Math.min(intermediatePowerCapacity, intermediateEnergyCapacity / intervalSize); // // if (intermediatePowerCapacity > 0 && powerSetpoints[l] + intermediatePowerCapacity > importCostAndPower[l][1] && costAndPower[0] + importCostAndPower[l][0] < 0) { // // There is capacity and it is cost effective to use it // futureEnergySurplus = intermediatePowerCapacity * intervalSize; // pastAndFutureIntervalPowerDeltas.add(new Pair<>(l, intermediatePowerCapacity)); // } // } // l--; // } // } energySurplus = Math.min(energySurplus, futureEnergySurplus); k++; } if (energySurplus < energySurplusMax && importCostAndPower != null) { // Can't maximise on opportunity without importing earlier on so can this be done // in a cost effective way LOG.finest("Looking for earlier import opportunities to maximise on this export opportunity: surplus=" + energySurplus + ", max=" + energySurplusMax); int i = interval - 1; List> pastOpportunities = new ArrayList<>(); while (i >= 0) { if (costAndPower[0] + importCostAndPower[i][0] < 0 && powerSetpoints[i] >= 0) { // We can afford to import and still earn using original export pastOpportunities.add(new Pair<>(i, importCostAndPower[i][0])); } i--; } pastOpportunities.sort(Comparator.comparingDouble(op -> op.value)); int j = 0; if (pastOpportunities.isEmpty()) { LOG.finest("No earlier import opportunities identified"); } while (energySurplus < energySurplusMax && j < pastOpportunities.size()) { Pair opportunity = pastOpportunities.get(j); int pastInterval = opportunity.key; // Power capacity must be within the optimum power band double[] pastCostAndPower = importCostAndPower[pastInterval]; double impPowerMax = Math.min(powerImportMaxCalculator.apply(interval), pastCostAndPower[2]); double impPowerCapacity = impPowerMax - powerSetpoints[pastInterval]; if (impPowerCapacity <= 0 || impPowerCapacity < pastCostAndPower[1]) { LOG.finest("Power capacity is outside optimum power band so cannot use this opportunity"); j++; continue; } double energySpaceMin = pastCostAndPower[1] * intervalSize; double energySpace = energyLevelMaxs[interval] - energyLevelCalculator.apply(pastInterval); energySpace = Math.max(energySpace, energySpace - energySurplusMax); // We have spare energy capacity and power check if we don't violate energy max for any future imports k = pastInterval; while (k < powerSetpoints.length && energySpace > 0 && energySpace >= energySpaceMin) { double futureEnergySpace = energyLevelMaxs[k] - energyLevelCalculator.apply(k); energySpace = Math.min(energySpace, futureEnergySpace); if (energySpace <= 0) { LOG.finest("Earlier import opportunity would violate future energy max level: interval=" + j + ", futureInterval=" + k); } k++; } impPowerCapacity = Math.min(impPowerCapacity, energySpace / intervalSize); if (impPowerCapacity > 0 && impPowerCapacity > pastCostAndPower[1]) { // We can import in the optimum range energySurplus += (impPowerCapacity * intervalSize); pastAndFutureIntervalPowerDeltas.add(new Pair<>(pastInterval, impPowerCapacity)); LOG.finest("Earlier import opportunity identified: interval=" + pastInterval + ", power=" + impPowerCapacity); } j++; } } // Do original export if there is any energy surplus if (energySurplus > 0 && energySurplus >= energySurplusMin) { // Adjust past interval set points as required pastAndFutureIntervalPowerDeltas.forEach(intervalAndDelta -> powerSetpoints[intervalAndDelta.key] += intervalAndDelta.value); energySurplusMax = Math.min(energySurplusMax, energySurplus); powerCapacity = Math.max(expPowerMax - powerSetpoints[interval], -1d * (energySurplusMax / intervalSize)); powerSetpoints[interval] = powerSetpoints[interval] + powerCapacity; LOG.finest("Applied export earning opportunity: interval=" + interval + ", set point=" + powerSetpoints[interval] + " (delta: " + powerCapacity + ")"); } } /** * Returns a function that can be used to calculate any export saving (per kWh) and power band needed to achieve it * based on requested interval index and power export max value (negative as this is for export). This is used to * determine whether there are export opportunities for earning/saving rather than using the grid. */ public BiFunction getExportOptimiser(double[] powerNets, double[] powerNetLimits, double[] tariffImports, double[] tariffExports, double assetExportCost) { // Power max should be negative as this is export return (interval, powerMax) -> { double powerNet = powerNets[interval]; double powerNetLimit = powerNetLimits[interval]; double tariffImport = tariffImports[interval]; double tariffExport = tariffExports[interval]; powerMax = Math.max(powerMax, powerNetLimit - powerNet); if (powerMax >= 0) { // No capacity to export return new double[]{Double.MAX_VALUE, 0d, 0d}; } if (powerNet <= 0) { // Already net exporting so tariff will not change if we export more return new double[]{tariffExport + assetExportCost, powerMax, 0d}; } if (powerNet + powerMax > 0d) { // Can't make tariff flip (we're reducing import hence the -1d) return new double[]{(-1d * tariffImport) + assetExportCost, powerMax, 0d}; } // We can flip tariffs if we export enough power double powerStart = 0d; double powerEnd = 0d - powerNet; // Inflection point where switch to export instead of import // If import was paying then reducing import is a loss in earnings double cost = powerEnd * (tariffImport - assetExportCost); // Is it beneficial to include remaining (-ve export power) if (tariffExport + assetExportCost < 0d || tariffExport <= (-1d * tariffImport)) { if (Math.abs((-1d * tariffImport) - tariffExport) > Double.MIN_VALUE) { // We need to be at power max to achieve the optimum cost powerStart = powerMax; } cost += -1d * (powerMax - powerEnd) * (tariffExport + assetExportCost); powerEnd = powerMax; } // Normalise the cost cost = cost / (-1d * powerEnd); return new double[]{cost, powerEnd, powerStart}; }; } /** * Returns a function that can be used to calculate the optimum cost (per kWh) and power band needed to achieve it * based on requested interval index, power min and power max values. The returned power band will satisfy the * requested power min value but this could mean that cost is not optimum for that interval if a lower power could * be used. If possible a 0 power min should be tried first for all applicable intervals and if more energy is * required then another pass can be made with a high enough min power to allow desired energy levels to be reached. * This is used to determine the best times and power values for importing energy to meet the requirements. */ public BiFunction getImportOptimiser(double[] powerNets, double[] powerNetLimits, double[] tariffImports, double[] tariffExports, double assetImportCost) { return (interval, powerRequiredMinMax) -> { double powerNet = powerNets[interval]; double powerNetLimit = powerNetLimits[interval]; double tariffImport = tariffImports[interval]; double tariffExport = tariffExports[interval]; double powerMin = powerRequiredMinMax[0]; double powerMax = Math.min(powerRequiredMinMax[1], powerNetLimit - powerNet); if (powerMax <= 0d) { // No capacity to import return new double[]{Double.MAX_VALUE, 0d, 0d}; } if (powerNet >= 0d) { // Already net importing so tariff will not change if we import more return new double[]{tariffImport + assetImportCost, powerMin, powerMax}; } if (powerNet + powerMax < 0d) { // Can't make tariff flip (we're reducing import hence the -1d) return new double[]{(-1d * tariffExport) + assetImportCost, powerMin, powerMax}; } // We can flip tariffs if we take enough power double powerStart = powerMin; double powerEnd = 0d - powerNet; // Inflection point where switch to import instead of export // If export was paying then reducing export is a loss in earnings i.e. a cost and vice versa hence the -1d double cost = -1d * powerEnd * (tariffExport - assetImportCost); if (powerMin > powerEnd) { // We have to flip to meet power req cost += (powerMin - powerEnd) * (tariffImport + assetImportCost); powerEnd = powerMin; } if (powerEnd < powerMax) { // Is it beneficial to include remaining (+ve import power) if (tariffImport + assetImportCost < 0d || tariffImport <= (-1d * tariffExport)) { if (Math.abs((-1d * tariffExport) - tariffImport) > Double.MIN_VALUE) { // We need to be at power max to achieve the optimum cost powerStart = powerMax; } cost += (powerMax - powerEnd) * (tariffImport + assetImportCost); powerEnd = powerMax; } } // Normalise the cost cost = cost / powerEnd; return new double[]{cost, powerStart, powerEnd}; }; } }




© 2015 - 2025 Weber Informatics LLC | Privacy Policy