Hi everyone,
I’ve been experimenting with Victron’s Dynamic ESS (DESS), and while the predictive intelligence is impressive, I find it a bit too “optimistic.” It often gambles on its own forecast by selling battery energy early in the day, leaving me with a lower buffer than I’m comfortable with if a cloud bank rolls in or a grid failure occurs.
I believe the “Perfect Green Mode” should prioritize Storage Security over Market Speculation. I’ve developed a Node-RED logic that combines the predictive intelligence of DESS with the rock-solid security of a user-defined “Feed-In SOC” buffer.
The Philosophy: “Storage First”
The core idea is to change the priority of how DESS handles your battery:
-
Storage First (Below Buffer): If the SOC is below your safety threshold (e.g., 80%), grid feed-in is physically locked at 0W. We aren’t trading; we are securing our home.
-
Injection Only for “True Surplus”: The system only plans to sell if the forecasted solar yield is mathematically certain to exceed your battery capacity (a “planned SOC” of >100%).
-
Strategic Overflow: Once the buffer is reached, only the excess is injected during peak price windows. Your core buffer remains untouched as a hedge against forecast errors.
-
The “Accelerator & Gate” Method: Unlike standard ESS, this logic actively drives the
AcPowerSetPoint(The Accelerator) while simultaneously managing theMaxGridFeedIn(The Gate). This prevents the system from “leaking” energy when we want to hoard it.
This gives you the Predictive Intelligence of DESS (knowing when the peaks are and using forecasts) combined with the Storage Security of plain ESS. It stops the system from “gambling” with your backup.
Any toughts?
The Node-RED Logic
/**
* Logic: 9kW Limit | 35% Floor | Automated Peak Detection
*/
// --- 1. SETTINGS ---
const MAX_EXPORT_LIMIT = -9000; // 9kW total (3kW per phase)
const MIN_RESERVE_SOC = 35; // Your 48h emergency buffer
const BATT_FULL_LEVEL = 98; // Point to stop hoarding
const NOMINAL_VOLTAGE = 51.2; // 16s LFP Battery
// --- 2. INPUTS ---
const {
soc,
capacityAh,
currentLimit,
solarRemainingFC,
loadRemainingFC,
sellPrices
} = msg.payload;
const hour = new Date().getHours();
const currentPrice = (Array.isArray(sellPrices)) ? sellPrices[hour] : 0;
// --- 3. DYNAMIC PEAK FINDER ---
// Scans DESS prices to find the highest value of the day
const maxPriceToday = (Array.isArray(sellPrices)) ? Math.max(...sellPrices) : 0;
// Identify if current hour is in the top 10% of price for the day
const isPeakWindow = (currentPrice >= maxPriceToday * 0.95) && currentPrice > 0;
// --- 4. THE DECISION ENGINE ---
let targetLimit = 0;
let statusReport = "";
if (typeof soc !== 'number' || typeof capacityAh !== 'number' || !sellPrices) {
targetLimit = -1; // Fallback to hardware defaults
statusReport = "DATA ERROR: Missing DESS data/SOC.";
} else {
// A. Energetic Math
const totalBankKWh = (capacityAh * NOMINAL_VOLTAGE) / 1000;
const currentStoredKWh = (soc / 100) * totalBankKWh;
const emptySpaceKWh = Math.max(0, totalBankKWh - currentStoredKWh);
// netSolar = future generation minus future house consumption
const netSolarComingKWh = Math.max(0, (solarRemainingFC || 0) - (loadRemainingFC || 0));
const trueSurplusKWh = Math.max(0, netSolarComingKWh - emptySpaceKWh);
// B. Strategy Selection
if (soc >= BATT_FULL_LEVEL) {
// STATE: FULL - Prevent solar waste
targetLimit = -1;
statusReport = `FULL (${soc}%): Exporting solar excess. Price: ${currentPrice}€`;
}
else if (trueSurplusKWh > 0.05 && isPeakWindow && soc > MIN_RESERVE_SOC) {
// STATE: PEAK ARBITRAGE
// Tapered export: Clear surplus over 2h to avoid "cycling" stress
let dynamicWatts = (trueSurplusKWh / 2) * 1000;
targetLimit = Math.max(MAX_EXPORT_LIMIT, -Math.round(dynamicWatts));
statusReport = `PEAK SELL: ${trueSurplusKWh.toFixed(1)}kWh surplus @ ${currentPrice}€`;
}
else {
// STATE: HOARDING/SAVING
targetLimit = 0;
statusReport = (trueSurplusKWh > 0)
? `HOARDING: Waiting for Peak (${maxPriceToday}€). Surplus: ${trueSurplusKWh.toFixed(1)}kWh.`
: `STORING: Preserving buffer for 48h safety.`;
}
}
// --- 5. EXECUTION ---
// Only update the GX device if the value actually changes
if (targetLimit !== currentLimit) {
msg.payload = targetLimit;
msg.statusMessage = statusReport;
return msg;
}
return null;