/**
* Copyright (c) 2026 Florian Hotze
*
* 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
*/
const { time, log, Quantity } = require('openhab');
const constants = require('./constants');
const logger = log(`${constants.LOGGER_PREFIX}.ems`);
const dtFormatter = time.DateTimeFormatter.ISO_TIME;
/**
* Energy Management System (EMS) namespace.
*
* This namespace provides functions for energy management.
* @namespace ems
*/
/**
* Calculates the minimum charging power limit (in kW) needed to ensure the battery reaches target SoC exactly by the target time.
*
* @memberOf ems
* @param {number} currentSoC Current State of Charge in percentage (0 - 100)
* @param {items.PersistedItem[]} surplusForecast Array of forecasted surplus power (Solar - Consumption) in kW
* @param {number} targetSoC Target State of Charge in percentage (0 - 100)
* @param {time.ZonedDateTime} targetTime Target time to reach the target SoC
* @param {number} [batteryCapacityKWh=10.2] Total capacity of the battery in kWh
* @param {boolean} [includeConsumption=false] If true, negative surplus (house consumption) reduces the available energy for charging.
* @returns {Quantity|null} The calculated charging power limit
*/
function calculateOptimalChargeLimit (
currentSoC,
surplusForecast,
targetSoC = 100,
targetTime,
batteryCapacityKWh = 10.2,
includeConsumption = false
) {
// Calculate the energy needed to reach target SoC
const energyNeededKWh = batteryCapacityKWh * (1 - currentSoC / targetSoC);
logger.debug(`calculateOptimalChargeLimit: need ${energyNeededKWh} kWh to reach ${targetSoC}% SoC until ${dtFormatter.format(targetTime)}`);
if (energyNeededKWh <= 0) {
logger.info('calculateOptimalChargeLimit: energy needed is <= 0 kWh');
return null;
}
// Define the time window
const now = time.toInstant();
const targetInstant = targetTime.toInstant();
if (now.isAfter(targetInstant)) {
logger.warn(`calculateOptimalChargeLimit: target time ${targetTime} is in the past`);
return null;
}
// Automatically determine the resolution (in hours) from the forecast data
let intervalHours = null;
if (surplusForecast.length >= 2) {
const timeDiffMs = surplusForecast[1].instant.toEpochMilli() - surplusForecast[0].instant.toEpochMilli();
intervalHours = timeDiffMs / (1000 * 60 * 60); // Convert milliseconds to hours
}
if (intervalHours === null) {
logger.warn('calculateOptimalChargeLimit: no forecast data available');
return null;
}
logger.debug(`calculateOptimalChargeLimit: surplus forecast resolution = ${intervalHours}`);
// Filter the forecast for the relevant window and extract the numeric power
const availablePowerKW = surplusForecast
.filter(item => item.instant.isAfter(now) && item.instant.isBefore(targetInstant))
.map(item => {
let power;
if (item.quantityState) {
// Automatically handles W to kW conversion if needed
power = item.quantityState.toUnit('kW').float;
} else if (item.numericState !== null) {
// Fallback for Number items without dimensions
power = item.numericState;
}
if (!includeConsumption) {
// Ignore battery drain caused by consumption
return Math.max(0, power);
}
// Include consumption from the battery caused by missing surplus power
return power;
});
if (availablePowerKW.length === 0) {
logger.warn(`calculateOptimalChargeLimit: no surplus forecast available for timeframe ${dtFormatter.format(now)} - ${dtFormatter.format(targetInstant)}`);
return null;
}
// Check if a full charge is even possible without any limits
const maxPossibleEnergyKWh = availablePowerKW.reduce(
(sum, power) => sum + power * intervalHours,
0
);
if (maxPossibleEnergyKWh <= energyNeededKWh) {
logger.info(`calculateOptimalChargeLimit: max possible energy ${maxPossibleEnergyKWh} kWh <= needed ${energyNeededKWh} kWh`);
return null;
}
// Binary Search to find the exact P_limit
let low = 0;
let high = Math.max(...availablePowerKW);
const precisionEpsilon = 0.01; // 10 Watt precision
while (high - low > precisionEpsilon) {
const mid = (low + high) / 2; // Test limit
// Calculate how much energy we would store with this tested limit
const energyWithLimit = availablePowerKW.reduce((sum, available) => {
return sum + Math.min(available, mid) * intervalHours;
}, 0);
if (energyWithLimit >= energyNeededKWh) {
// We reached the target. See if we can do it with an even lower limit.
high = mid;
} else {
// We fell short. We need a higher charging power limit.
low = mid;
}
}
// Return the upper bound to ensure we strictly meet or barely exceed the required energy
return Quantity(Number(high.toFixed(2)) + ' kW');
}
module.exports = {
calculateOptimalChargeLimit
};