Jakarta, Indonesia
Posts

37% of My Grid Activations Were Unnecessary — Here's How I Proved It with Data

My off-grid solar system has a grid MCB (circuit breaker) as a backup. When the battery gets low, a Node-RED flow fires a webhook to turn on the grid. When it recovers, the grid turns off. Simple. Too simple. I started noticing grid activations that lasted 10–20 minutes — the MCB would flip on, then flip off shortly after. One morning, the grid turned on at 07:40, and by 08:00 it was already off. The battery was at 30% SoC with plenty of capacity. The culprit? A water heater switching on, creating a momentary voltage sag that crossed the threshold. This wasn't a one-off. I suspected the system was crying wolf — turning on the grid for transient load spikes that resolved on their own. But I didn't want to guess. I wanted data. The setup: a Deye SUN-8K-SG01LP1-EU (8kW hybrid inverter) with a LiFePO4 battery bank (48V nominal), monitored via Modbus by a Node-RED instance on a Mini PC. Battery voltage, SoC, and inverter load power flow into InfluxDB every second. Grid switching is handled by an eWeLink 63A MCB controlled via eWeLink API webhooks. The existing control flow was straightforward — two independent hysteresis bands:
Before vs After: Simple Threshold vs Three-Tier Engine
Voltage ≤ 47.0V  →  Grid ON
Voltage ≥ 48.2V  →  Grid OFF
SoC ≤ 5%         →  Grid ON
SoC ≥ 10%        →  Grid OFF
No load awareness. No compound conditions. No sustained-condition timers. Just raw thresholds triggering instant action. Before writing a single line of new logic, I queried InfluxDB for 30 days of historical data. Three datasets: inverter load power (invrt_total_pwr), battery voltage, and grid relay state transitions.
Percentile
Power (W)
What's Happening
P10416Baseline — lights, standby, fridge
P25698Light usage
P50 (Median)889Typical household
P751,358Active usage — cooking, some AC
P901,917Heavy usage — heaters & multiple AC
P952,373Peak usage
P993,478Extreme peak
MAX7,722Surge — all appliances simultaneously
The P75 value of 1,358W became important — readings above this indicate genuinely high multi-appliance usage, not a transient blip.
30-Day Grid Event Analysis
I analyzed all gridrelay_state transitions at 10-minute resolution:
Duration
Count
% of Events
Assessment
≤ 20 min737%❌ Unnecessary — transient load
60–120 min526%⚠️ Borderline
120–300 min421%✅ Justified
> 300 min (5h+)316%✅ Clearly needed
37% of grid activations — 7 out of 19 — lasted ≤ 20 minutes. These were exactly the events I suspected: transient load spikes (water heater, induction cooker startup) sagging the voltage below 47.0V, triggering the grid, then the spike resolves and the grid turns off shortly after. One day in particular stood out — Apr 6 had two back-to-back 10-minute events: grid ON at 04:50, OFF at 05:00, ON again at 05:20, OFF at 05:30. Classic oscillation. The most recent event was a textbook false trigger:
Time (WIB)
Voltage
Load
SoC
Grid
07:0048.07V~600W30.4%OFF
07:2547.67V2,009W30.2%OFF
07:3047.60V → 46.97V spike2,041W30.0%OFF
07:4047.49V1,849W29.7%ON (triggered!)
08:0048.06V1,855W30.1%OFF (recovered)
Load jumped from 600W to 2,000W at 07:25 corresponding to the water heater plus induction stoves kicking on for morning use. Voltage sagged to 46.97V momentarily. The grid turned on at 07:40 and was already unnecessary by 08:00. With 30% SoC (which maps to ~51% actual usable capacity on my system), the battery had hours of runway at that load. The grid activation was pure waste. Armed with concrete data, I designed a replacement for the simple threshold logic — a three-tier decision engine where each tier adds intelligence:
Three-Tier Hysteresis Architecture
Javascript
// IMMEDIATE, unconditional — protects against deep discharge
if (voltage <= 45.5 || soc <= 3) {
    flow.set("grid_state", "on");
    msg.gridcmd = 1;
    msg.tier = 1;
    msg.gridsrc = "CRITICAL";
    return msg;
}
No conditions, no delays. Historical data showed voltage never dropped below 46.97V in 30 days, and the inverter shuts down around 45.0V. Setting critical at 45.5V gives a 0.5V safety margin above the danger zone. This tier should fire very rarely — it's the safety net. This is where the data-driven logic lives. Instead of "voltage low → grid on", it requires a compound condition sustained for 90 seconds:
Javascript
// Three conditions must ALL be true:
const loadCondition = avgLoad > 1400;  // Above P75 — genuinely high
const socCondition  = soc < 30;        // Battery actually depleted
const sustained     = (now - sagStart) >= 90000; // Not a transient spike

if (voltage <= 47.0 && loadCondition && socCondition && sustained) {
    // NOW activate the grid
}
The key insight: voltage alone is misleading. A 47.0V reading during a 2,000W water heater spike at 30% SoC is very different from a 47.0V reading during sustained 1,500W load at 15% SoC. The first is transient; the second is a real problem. Why these specific values?
  • 1,400W power threshold: Just above P75 (1,358W). Anything above this means multiple appliances are running — not a brief spike.
  • 30% SoC gate: My battery's usable capacity is 70% (displayed = raw − 30%). So displayed 30% = ~51% actual. At this level, the battery handles 2kW loads for hours. Below 30% = genuine concern.
  • 90-second sustained timer: Most transient spikes (water heater on, kettle boils) resolve within 30–60 seconds. 90 seconds filters these out while still being responsive to real emergencies.
Equally important — don't release the grid prematurely. The old logic turned off the grid the instant voltage hit 48.2V. The new logic requires three conditions held for 180 seconds:
Javascript
// All three must hold for 3 continuous minutes:
const voltOk = voltage >= 48.2;     // Battery recovered
const loadOk = avgLoad < 1200;      // Load has settled
const socOk  = soc >= 30;           // SoC is comfortable

if (voltOk && loadOk && socOk && heldFor180s) {
    flow.set("grid_state", "off");
}
The 200W gap between power thresholds (1,400W on vs 1,200W off) creates its own hysteresis band, preventing oscillation. All load comparisons use a 15-minute rolling average — not instantaneous readings. This is implemented as a 900-element circular buffer (at 1 reading/second):
Javascript
const WINDOW = 900;
let buffer = flow.get("load_buffer") || [];
let bufIdx = flow.get("load_buf_idx") || 0;

if (buffer.length < WINDOW) {
    buffer.push(load);
} else {
    buffer[bufIdx % WINDOW] = load;
}
bufIdx++;

const avg = buffer.reduce((a, b) => a + b, 0) / buffer.length;
flow.set("avg_load_15m", avg);
Why not use Node-RED's built-in Smooth node? It works by message count, not time. If Modbus readings arrive irregularly (e.g., after a brief disconnect), the Smooth node gives a skewed average. The manual buffer is predictable and inspectable via flow context for debugging. After deploying and restarting Node-RED, the grid was physically ON but the flow's grid_state context was initialized to "off". The hysteresis filter nodes only fire on threshold crossings — if the voltage is already past a threshold at startup, no event fires. The system sat there with the grid ON, not knowing it should evaluate release conditions. Fix: Added a Sync Grid State node that reads the actual relay state from Modbus on every reading. If the physical relay is ON but grid_state is "off", it syncs them. Combined with a Periodic Release Check that evaluates release conditions every second while the relay is ON, bypassing the hysteresis filter entirely. The SoC-based path (SoC ≤ 5%) still used the old direct-action pattern — it turned on the grid without setting grid_state, so the new release logic never knew the grid was on. And SoC ≥ 10% turned off the grid immediately with no holdoff timer. Fix: SoC Critical now sets grid_state, adds a SoC=0 reset guard (to ignore spurious resets), and includes tier metadata. SoC Recovery no longer directly turns off the grid — it defers to the Periodic Release Check with the full 180-second holdoff. I replayed the 30-day event history against the new logic:
Event
Duration
SoC
Est. Avg Load
New Logic
Apr 12 (46.97V spike)20 min30%~2000W✅ Prevented — SoC ≥ 30%
Apr 6 #1 (oscillation)10 min~35%~1200W✅ Prevented — SoC ≥ 30%
Apr 6 #2 (oscillation)10 min~35%~1200W✅ Prevented — SoC ≥ 30%
Apr 1 (short trigger)20 min~12%~900W✅ Prevented — Load < 1400W
Mar 27 (short trigger)20 min~15%~800W✅ Prevented — Load < 1400W
Apr 8 (genuine, 8.2h)490 min~2%~1800W✅ Correctly activates
5–6 out of 7 unnecessary events would have been prevented, while all legitimate long-duration events still trigger correctly. The Apr 8 event (8.2 hours, SoC at 2%, 1,800W sustained load) passes all three compound conditions easily.
Metric
Before
After
Decision LogicSingle voltage thresholdThree-tier compound engine
Inputs ConsideredVoltage onlyVoltage + Load (15m avg) + SoC + Time
Expected Monthly Activations~19≤ 10 (justified only)
False Triggers (≤20 min)7 (37%)~0
Oscillation EventsObserved (Apr 6)Prevented by holdoff timers
Safety FloorNone below 47.0VTier 1 at 45.5V (unconditional)
Startup HandlingUnsafe (stale context)Relay sync + periodic check
1. Always interrogate your data before tuning thresholds. I could have guessed at better threshold values. Instead, I queried 30 days of InfluxDB data and found that 37% of events were waste. The data told me exactly what to filter: transient spikes above P75 load at healthy SoC levels. No guesswork needed. 2. Voltage sags are misleading without load context. A 47.0V reading during a 2kW water heater spike at 30% SoC looks identical to a 47.0V reading during genuine battery depletion — unless you also look at load and SoC. Single-variable control loops will always have blind spots. 3. Sustained timers are the cheapest filter. The 90-second activation timer and 180-second release holdoff are trivial to implement (just Date.now() comparisons) but eliminate entire categories of false triggers. Most transient events self-resolve in under 60 seconds. 4. Physical state sync is non-negotiable for stateful automation. Node-RED flow context resets on restart. If your automation maintains state (grid_state = "on"/"off"), you must sync it with the physical world on startup. Otherwise you get ghost states — the code thinks the grid is off, but it's actually on, and no release logic ever fires. 5. Hysteresis needs hysteresis. The power thresholds (1,400W on / 1,200W off) create their own hysteresis band on top of the voltage hysteresis. Without this, you get oscillation at the boundary — load hovering around the threshold causes rapid on/off cycling. Every threshold pair should have a dead band.
This is Part 3 of my home IoT series. Part 1: From Polling to Push: Real-Time Tuya Monitoring. Part 2: DHCP Lease Table Discovery. All optimizations are part of my ongoing Solar Home System project.
On this page