That sounds like a nice problem to have, and it is. But it is still a problem.
Here at Spartina Landing in Midway, Georgia, I have three EG4 6000XP inverters running in parallel for roughly 18 kW of inverter capacity, 99.2 kWh of battery storage, and 40 solar panels totaling about 16.16 kW DC. There are 14 battery modules in the bank: three EG4 WallMount 280Ah batteries and eleven Eco-Worthy 48V 100Ah batteries. Then there is Augustus, our Tesla Model Y Long Range with a 75 kWh battery, connected through a Tesla Wall Connector.
It is a lot of capacity, but it is not infinite. The grid connection is import-only, so I cannot sell excess solar back to the utility. If the home batteries are full, the Tesla does not need power, and the house load is low, the inverters curtail the panels. Perfectly good Georgia sunshine gets left on the roof.
Meanwhile, our time-of-use rates range from very cheap to “please do not run the clothes dryer right now.” Super off-peak power is $0.06 per kWh from 10 PM to 6 AM. Peak power is $0.24 per kWh from 4 PM to 8 PM. The rest of the day is $0.11 per kWh.
| Time | Rate | What I want to do |
|---|---|---|
| 10 PM–6 AM | $0.06/kWh | Buy power if tomorrow looks weak or the Tesla needs it |
| 6 AM–4 PM and 8 PM–10 PM | $0.11/kWh | Prefer solar and avoid unnecessary imports |
| 4 PM–8 PM | $0.24/kWh | Protect the home battery so it can carry the house |
I ended up building two separate decision systems. One looks ahead and decides how much to grid-charge the home battery overnight. The other looks at conditions every five minutes and decides how many amps Augustus may have. They solve different versions of the same question: who gets the next electron?
System One: The Nightly Home-Battery Charge Engine
The first script is charge_engine.py. It runs at 9:30 PM Eastern on my spartina-charge LXC container. Its job is to decide what should happen when the $0.06 super off-peak window opens at 10 PM.
The obvious strategy would be to fill the batteries every night because grid power is cheap. That is safe, but it is not necessarily smart. If I start the morning with a nearly full 99.2 kWh bank and the sun comes out, there is nowhere for all that solar energy to go. The battery reaches full charge early, the inverters curtail PV production, and I have effectively purchased energy that displaced free energy.
The reverse is also true. If I leave lots of room for solar and thunderstorms roll in, I may enter the expensive 4 PM–8 PM window with a depleted battery. This is the central optimization:
More grid charging overnight
= more reserve
= less room for tomorrow's solar
= more possible curtailment
Less grid charging overnight
= more solar headroom
= more free energy harvested
= more risk if the forecast is wrong
The engine tries to find the useful middle instead of choosing either extreme.
Voltage Is the Source of Truth
My battery bank contains equipment from more than one manufacturer. The state-of-charge values reported by the different battery management systems do not always agree, and the EG4 readings tend to run high. A clean-looking percentage is not necessarily an accurate percentage.
The 6000XP inverters are configured in lead-acid mode, which means their charging behavior is governed by voltage thresholds and time windows rather than battery state-of-charge. That made the design decision fairly easy: the inverter-read pack voltage is the source of truth for the nightly engine.
| Pack voltage | Meaning |
|---|---|
| 51.5V | Emergency floor |
| 52.3V | Charge-start threshold; below this, the bank needs help |
| 53.6V | Normal stop target, roughly 90% |
| 56.8V | Daily maximum |
| 57.6V | Monthly balance charge |
| 58.4V | Storm-preparation maximum and charge cutoff |
The engine also does not select one fixed mode and leave it there all night. It sub-segments the 10 PM–6 AM window. It can begin in Grid Charge, where the grid carries the house and charges the battery, then switch to Grid First when the target voltage is reached. Grid First carries the house without continuing to stuff energy into an already-satisfied battery.
That detail matters. Otherwise, “charge tonight” is a blunt eight-hour instruction. The useful instruction is “charge until the target is reached, then stop draining the battery while we wait for daylight.”
Tomorrow’s Weather Changes Tonight’s Target
The engine uses the pessimistic estimate10 forecast from Solcast. In plain English, that is the conservative side of the solar-production range. If the forecast says tomorrow should be strong even on the pessimistic estimate, I am comfortable leaving more room in the batteries.
if storm_approaching:
target = 58.4V
elif first_clear_night_after_month_start:
target = 57.6V
elif forecast_kwh > 30:
charge = "minimal; let PV work"
elif forecast_kwh > 20:
charge = "less; preserve solar headroom"
elif forecast_kwh < 15:
target = 53.6V
else:
charge = "moderate"
A low forecast below 15 kWh tells the engine to charge the bank to the normal 53.6V target overnight. A forecast above 20 kWh reduces the amount of grid charging. Above 30 kWh, the goal is minimal charging and maximum room for PV. Storm preparation overrides normal economics and permits charging to 58.4V. Once a month, on the first clear night after the first of the month, the engine targets 57.6V so the battery BMS units get the high-voltage time they need for cell balancing.
The Forecast That Could Not See a Thunderstorm
This part took a real failure to get right. My first forecast source was Forecast.Solar. On a day with thunderstorms, it predicted 75.5 kWh of production. Actual production was about 15 kWh. That was not a small miss. That was a “maybe the Cardinals can still win this one in the ninth” level of optimism.
The problem was not bad arithmetic in my script. Forecast.Solar is essentially a clear-sky astronomical model. It knows where the sun should be, but it does not account for the cloud cover sitting between the sun and my panels.
Solcast uses satellite-derived cloud-opacity data. For that same stormy day, its pessimistic estimate10 value was 31.5 kWh. That was still above the roughly 15 kWh we actually harvested, but it was far more useful than 75.5 kWh and, importantly, it moved the decision in the right direction.
I prefer a conservative forecast here because the costs are asymmetric. Leaving a little extra energy in the battery may cause some curtailment. Leaving far too little can expose the house to $0.24 peak power. Accuracy beats a fancy dashboard, and conservative accuracy beats false precision.
System Two: The Tesla PV-Aware Charge Governor
The nightly engine works on tomorrow’s plan. The second script, tesla_pv_governor.py, handles what is happening right now.
It runs every five minutes from cron on my Hermes LXC container and sends commands to Augustus through a Tesla Fleet API proxy on a VPS. It considers PV production, house load, home-battery state-of-charge, and the projected state of that battery at 10 PM. Its output is simple: stop charging, or allow 24, 36, or 48 amps.
The rules are evaluated in order, and the first match wins:
| Priority | Condition | Tesla command |
|---|---|---|
| 1 | Home battery below 30% | STOP immediately |
| 2 | 10 PM–6 AM | 36A on cheap grid power |
| 3 | Battery above 80% and PV surplus above 2,000W | 48A |
| 4 | Battery above 80% and any PV surplus | 36A |
| 5 | Battery above 50% and any PV surplus | 24A |
| 6 | No PV surplus, but projected battery at 10 PM is above 60% | 24A |
| 7 | No surplus and projected battery at 10 PM is 60% or lower | STOP |
The idea is to be aggressive when energy would otherwise be curtailed, gentle when the battery still needs to charge, and protective when the house may need that stored energy during the evening peak. The below-30% emergency stop comes first because no amount of PV optimism should outrank protecting the home battery.
There is also a hard capacity check:
max_tesla_amps = (18000 - house_load_w) / 240
That keeps the combined load within the three inverters’ roughly 18 kW capacity. The governor throttles ordinary amp changes to once every 10 minutes so it does not constantly fiddle with the charging rate. Start and stop commands are not throttled; protection should not wait for a timer.
Projecting the Battery to 10 PM
A snapshot is not enough. At 2 PM, the house battery may look healthy while steadily discharging. The governor therefore projects where the battery will be at 10 PM using the actual battery discharge-power sensor. It performs the calculation in two passes: first it evaluates current conditions, then it recalculates with the Tesla load at the candidate amperage.
That second pass answers the question I actually care about: “If I let Augustus charge at 24 amps, where will the house battery end up?”
During development, two measurement assumptions made that projection badly wrong.
Bug One: The CTs Saw Only Half the Tesla
The EG4 current transformers measure house load on a 120/240V split-phase system. The Tesla Wall Connector is a 240V load, but the CT arrangement captures only one leg—about 50% of the Tesla’s actual power.
I initially treated the reported load as though it contained the whole Tesla load. Depending on where I corrected for the car, that produced double-counting errors in projected battery drain. The fix was to make the physical reality explicit:
TESLA_CT_FACTOR = 0.5
This is a good reminder that sensors do not measure “the house” in some abstract sense. They measure current at a particular wire in a particular electrical layout. Before blaming an algorithm, trace what the sensor can physically see.
Bug Two: The Tesla Was Charging Forever—On Paper
The early projection assumed the Tesla would keep drawing its candidate power all the way until 10 PM. Suppose Augustus is at 70% with an 80% charge limit. That is only about 10% of a 75 kWh pack, or 7.5 kWh. At 36 amps, it needs roughly 1.3 hours, not six hours or more.
By pretending the car would charge for the entire projection window, the governor overstated the energy demand by about five times. It would stop charging even when the battery had plenty of room to support the car.
The corrected projection caps Tesla drain at the energy actually needed to reach the configured charge limit:
energy_needed_kwh =
battery_capacity_kwh
* (charge_limit_percent - current_percent) / 100
tesla_runtime_hours =
min(hours_until_10pm,
energy_needed_kwh / candidate_charge_power_kw)
Once the car reaches 80%, its imaginary appetite no longer consumes the rest of the afternoon in my spreadsheet—or in the script.
Bug Three: The Five-Minute Charging Yo-Yo
The most visible bug was oscillation. The governor would stop Tesla charging to protect the home battery. House load would immediately fall, making PV surplus positive. Five minutes later, the governor would see that surplus and restart the Tesla. The load would jump, surplus would go negative, and the next run would stop it again.
STOP Tesla → house load drops → PV surplus appears
↑ ↓
negative surplus ← load rises ← START Tesla
It was logically consistent and operationally ridiculous.
The fix has three layers. First, a projected-SoC guard can override a “charge” decision if including the Tesla load would put the projected 10 PM state-of-charge at 60% or below. Second, restart uses hysteresis: after a stop, projected state-of-charge must exceed 68%, not merely cross back above 60%. Third, a 30-minute cooldown prevents any restart after a stop regardless of momentary conditions.
There is one important exception. From 10 PM to 6 AM, the projection horizon rolls forward to tomorrow’s 10 PM, creating a roughly 24-hour estimate that is not meaningful for this purpose. The nighttime rule deliberately bypasses the projected-SoC guard and allows 36A charging on cheap grid power.
Hysteresis is not glamorous, but neither is a thermostat that turns the furnace on and off every 30 seconds. Systems need breathing room around a boundary.
Two Time Scales, One Goal
These scripts work together because they operate on different time scales.
At 9:30 PM, the nightly engine asks, “Given pack voltage, tomorrow’s pessimistic solar forecast, and any storm or balancing requirement, how much cheap grid energy should I put into the home battery?” Every five minutes, the Tesla governor asks, “Given what the panels, house, battery, and car are doing now, how many amps can Augustus safely use?”
The first system creates solar headroom. The second tries to direct surplus solar into the car without sacrificing the home battery’s ability to carry the house through the $0.24 evening peak.
Neither algorithm is especially exotic. Most of the value comes from getting the inputs right, ordering the safety rules correctly, and admitting when a model has made a dumb assumption. I have now had a forecast that could not see clouds, CT readings that could see only half a car, a projection that thought the car would charge forever, and a governor that changed its mind every five minutes. Retired IT life is very relaxing.
At least Rudy does not care. As long as his dinner arrives on schedule, he considers the energy-management system fully operational. I apply a similar standard to cast iron: enough heat, enough time, and no need for a machine-learning model.
What Comes Next
The nightly charge engine is currently running in shadow mode with dry_run=true. It makes decisions and records what it would have done, but it does not yet change the inverter settings. That is intentional. The safest way to deploy automation around nearly 100 kWh of batteries is to let it show its work across good days, cloudy days, storms, and oddball sensor readings before handing it the keys.
The next step is to compare those shadow decisions with actual battery voltage, solar production, curtailment, and grid imports, then move the engine into live operation once the results are boring in the best possible way.
Solcast integration is central to that move. I want to keep evaluating the pessimistic estimate10 against actual production and tune the forecast bands if the local data justifies it. Longer term, the engine can learn from recent forecast error, expected house consumption, seasonal production, and the Tesla’s planned energy need. It may also be useful to recognize unusual loads—air conditioning, cooking, or shop equipment—rather than treating every day as average.
The governor will get the same treatment: more logging, clearer explanations for every override, and continued validation of the CT correction and charge-limit math. I would also like its projection to account more explicitly for the evening load curve instead of extending the present drain rate in a straight line.
The goal is not to build the cleverest charging system. It is to buy six-cent electricity when it makes sense, avoid 24-cent electricity when possible, harvest more of the sunshine already hitting the roof, and still have enough reserve when a Georgia storm decides my forecast needs another lesson in humility.
If the scripts can do that reliably—and without waking Augustus up every five minutes—I will call it a win.