feat: PV curtail support (SolarEdge) + operator manual hold - #289
Merged
Conversation
erikarenhill
force-pushed
the
feat/solaredge-pv-curtail
branch
2 times, most recently
from
May 24, 2026 11:22
97823fc to
9d2a826
Compare
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Operator-installed PV curtail cap that overrides whatever the planner slot directive says about PVLimitW. Driver="" runs through the same proportional allocation as the planner path; Driver=<name> caps one driver and leaves the rest uncapped. Backbone of the dashboard's solar bubble manual hold (next commits) — primarily for verifying that a fresh driver curtail implementation actually reaches the inverter. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
POST/GET/DELETE mirroring /api/battery/manual_hold. Accepts limit_w or limit_pct (mutually exclusive); pct is resolved to W against live |PV| on the scoped driver or summed across SupportsPVCurtail drivers. Validates driver scope against the catalog and rejects holds against drivers that don't advertise pv-curtail. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements curtail / curtail_disable actions on solaredge.lua and solaredge_pv.lua via the proprietary "Advanced Power Control" registers (0xF000 enable + 0xF001 percent 0-100). Converts power_w to a percent of the operator-supplied nominal_w. Capability "pv-curtail" advertised in the catalog so the UI can scope manual holds to these drivers. SetApp setting "Limit Control Mode = Export Control / Production" must be enabled on the inverter for writes to take effect. F000/F001 don't auto-revert on SolarEdge, so cleanup paths write F000=0 on curtail_disable, deinit, and driver_cleanup; experimental status until hardware verification. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ftw-pv-control mirrors ftw-battery-control: percent / watts toggle, duration chips, driver scope picker populated from the catalog (only drivers advertising pv-curtail show up). Clicking the solar bubble in ftw-energy-flow opens the modal; clicking an expanded per-driver bubble scopes the hold to that driver. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The catalog endpoint returns {entries: [...]} not {drivers: [...]},
so the previous lookup fell through to [] and the modal showed the
"no PV drivers" empty state even when a driver was opted in. Switch
the source of truth to /api/config — only drivers with
supports_pv_curtail: true now appear in the picker, matching what
the backend will actually accept.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ComputePVCurtail now treats the planner's PVLimitW as a gating
decision only ("curtail this slot: yes/no") and recomputes the cap
live from telemetry:
live_cap = live_load + battery_absorption_headroom + EV_PV_reserve
- live_load = grid - pv - battery (site sign). A heater turning
on mid-slot lifts the cap so PV serves the new load rather than
being throttled while the home imports.
- Battery headroom = sum of MaxChargeW for online batteries with
SoC below 99 %. PV stays uncapped while the dispatch loop can
still absorb the surplus.
- EVSurplusOnlyReserveW (already populated by loadpoints) is added
so PV charging on a connected EV is preserved over curtailment.
When the live cap exceeds total live PV the dispatch skips curtail
entirely, releasing any previously-curtailed driver through the
existing diff path. Manual holds bypass the live calc and apply
verbatim — operator override stays unconditional.
Falls back to the planner's static PVLimitW when there's no fresh
site-meter reading (no live state → defer to plan).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Some HD-Wave / StorEdge firmwares treat F001 as the authoritative limit independent of the F000 enable bit, so disabling F000 alone left the inverter capped at whatever pct was last written. Verified on hardware: a stuck 3 % cap persisted past curtail_disable until the next time we wrote F001=100 explicitly. Release now writes both. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Operator hitting "100 %" expected the inverter's rated 8 kW, not 100 % of whatever PV happened to be producing (256 W during a cloud gust → cap stuck at 256 W). Now resolves pct against the driver's configured nominal_w; falls back to live |PV| only when nominal_w isn't set. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
loadpoint.SurplusReserveW deliberately returns 0 for plugged-but- not-drawing EVs so the home battery can claim PV the EV is refusing. That rule is correct for the dispatch split but wrong for curtail: a stopped EV with SoC headroom would resume drawing if PV were allowed to grow past its min charge. Cutting PV strands both PV generation AND EV charging. Introduces loadpoint.SurplusPotentialW — a parallel reserve sized for the curtail decision (every surplus_only + plugged_in LP whose vehicle still has SoC headroom contributes its MaxChargeW). main.go writes it into State.EVCurtailHeadroomW each tick, and liveCurtailLimitW prefers it over EVSurplusOnlyReserveW. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single FC 0x10 (Write Multiple Holding Registers) transaction across F000 (61440) and F001 (61441) instead of two FC 0x06 single writes. The inverter never sees a half-applied state — no risk of enable=1 briefly paired with the previous tick's old limit value, nor of the release leaving F001 stale if the F000 write fails. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Default 60 s poll cadence + HTTP RTT lands every poll just past the 60 s site watchdog → driver flapped between online and stale every cycle, which fed the loadpoint controller a stream of false "EV stopped" signals and triggered wallbox-cycle pause/resume + repeated wake-kicks (~28/30 min in observed steady state). Tightens to 30 s ONLY when last.charging_state == "Charging" so a parked / disconnected car still polls at the conservative 60 s rate. The local BLE proxy has no rate-limit concerns at this cadence. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds DriverHealth.WatchdogTimeoutOverride + Store.SetDriverWatchdogTimeout
so drivers with intrinsically slow cadences can opt out of the 60 s
site-wide watchdog without loosening it for the whole site. Exposed
to lua as host.set_watchdog_timeout_s(seconds).
Tesla driver now:
- Registers a 5 min watchdog tolerance at init (no more spurious
stale flips between Charging telemetry that lands at 60-65 s).
- Force-wakes at 2.5 min of no fresh telemetry IFF the last-known
charging_state was "Charging", catching the "proxy cached one
reading then car went BLE-silent" failure mode. Gated to Charging
so a parked car doesn't get its 12 V drained by speculative wakes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SurplusReserveW previously returned 0 for any EV with CurrentPowerW
< 50 W, so a plugged-but-stopped vehicle that was just waiting for
the wallbox to ramp surplus to its min charge let the home battery
snatch every watt of PV. Auto-wake then offered current with nothing
left to claim, the vehicle stayed Stopped, the cycle repeated, and
PV that should have charged the EV went to the battery instead.
Reserve MinChargeW when SoC is KNOWN and below the charge limit
(both vehicle_soc_pct and vehicle_charge_limit_pct > 0). Stays at 0
when:
- SoC is unknown (no vehicle driver / driver offline) — preserves
the strict pre-existing "don't hold back battery for an unknown
EV" rule.
- SoC >= limit (Tesla taper-to-stop at 59/60) — no point holding
headroom for a vehicle that won't accept more.
Verified live: Pixii dispatch dropped from 5 kW charge to <500 W
within seconds, EV ramped 4 W → 1121 W toward its min step.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The backend has been resolving limit_pct against each driver's configured nominal_w (rated AC output) since the nominal_w fix, but the label and header comment in ftw-pv-control still said "% of live PV" — leftover from the original implementation. Operators were seeing "100 %" produce a cap matching current cloudy production instead of the inverter nameplate. Label + doc updated to match. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the HD-Wave / StorEdge curtail path into solaredge_legacy.lua: atomic FC 0x10 write across F000 (Advanced Power Control enable) + F001 (Active Power Limit percent) — the same proprietary registers SolarEdge documents for K-series (SE7K..SE25K display inverters) in the Power Reduction Application Note. Reads on K-series still use FC 0x03 (legacy firmware doesn't mirror under FC 0x04) but writes always use the holding-register FCs so no new probe is needed. Driver advertises pv-curtail; operators wire up the loadpoint by setting supports_pv_curtail + nominal_w in YAML exactly like the HD-Wave driver. Failsafe + SetApp preconditions unchanged from solaredge.lua. Stays verification_status=experimental until tested on real K-series hardware. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
erikarenhill
force-pushed
the
feat/solaredge-pv-curtail
branch
from
May 24, 2026 18:52
ab44784 to
0578d4b
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
PR started as "add curtail support to SolarEdge inverters when prices are negative" and grew into a broader PV-curtail + EV-priority cleanup once real hardware exposed adjacent issues. Verified end-to-end on live hardware: 25/50/75 % manual hold sweep on an 8 kW HD-Wave inverter, EV / battery surplus split before/after the priority fix, 30-min stability monitor after the Tesla cadence change.
PV curtail — SolarEdge (HD-Wave / StorEdge / K-series)
curtail/curtail_disableaction handlers indrivers/solaredge.lua,drivers/solaredge_pv.lua, anddrivers/solaredge_legacy.luawriting the SolarEdge proprietary Advanced Power Control registers (F000 enable + F001 percent 0-100).F001=100ANDF000=0— verified on hardware that disabling F000 alone leaves the cap stuck at the last F001 value on some firmware revisions.pv-curtailcapability. Operator opt-in viasupports_pv_curtail: true+config.nominal_w: <rated W>per driver. SetApp setting "Limit Control Mode = Export Control / Production" required on the inverter for writes to take effect.PV curtail — control surface
control.State.ManualPVHold+POST/GET/DELETE /api/pv/manual_holdmirroring the battery manual-hold UX. Body acceptslimit_worlimit_pct; both site-aggregate and driver-scoped holds.limit_pctresolves against each driver'snominal_w(rated inverter output) so "100 %" means the inverter nameplate, not the current cloudy production.ComputePVCurtailnow treats the planner'sPVLimitWas the gating decision and recomputes the actual cap live from telemetry every tick:live_cap = live_load + battery_absorption_headroom + EV_PV_reserve. Self-consumption preserved when load rises mid-slot; absorption sinks (battery SoC headroom < 99 %, EV in surplus_only mode) lift the cap so PV stays uncapped while there's somewhere to put the energy.EV + battery priority
loadpoint.SurplusPotentialW— parallel reserve for the curtail decision (counts plugged-but-stopped EVs with SoC headroom, contributes MaxChargeW). The dispatch reserve still excludes them. Surfaced ascontrol.State.EVCurtailHeadroomW.loadpoint.SurplusReserveWnow reservesMinChargeWfor plugged + surplus_only EVs whose SoC is known and below the charge limit. Stays at 0 when SoC is unknown (preserves the strict pre-existing rule — a missing vehicle driver can't permanently block the home battery) or when the vehicle is at its limit (no point holding back for a Tesla taper-to-stop at 59/60). Verified live: Pixii dropped from 5+ kW to <500 W within seconds, EV ramped from 4 W to 1840 W (8 A 1ϕ min step).EV stability — Tesla driver
tesla-vehiclepolls every 30 s whilecharging_state == "Charging", 60 s otherwise. The default 60 s cadence + HTTP RTT landed every poll just past the 60 s site watchdog → driver flapped offline/online every cycle, which cascaded into wake-kick + wallbox-cycle spam (28 wake-kicks per 30 min observed before).telemetry.DriverHealth.WatchdogTimeoutOverride+Store.SetDriverWatchdogTimeout+ lua bindinghost.set_watchdog_timeout_s(seconds). Tesla driver registers 5 min at init for headroom on legitimate BLE wake delays.last.charging_state == "Charging"and 2.5 min have passed without fresh telemetry, the next poll attacheswakeup=trueto recover from "proxy cached one Charging reading then car went BLE-silent." Gated to Charging so a parked car doesn't get its 12 V drained by speculative wakes.UI
<ftw-pv-control>modal — Watts / Percent toggle, duration chips, driver scope picker. Opens from the solar planet click inftw-energy-flow;d.idfrom an expanded per-driver bubble pre-selects that driver. Reads the picker list from/api/configso only drivers withsupports_pv_curtail: trueappear.Files changed
drivers/solaredge.lua,drivers/solaredge_pv.lua,drivers/solaredge_legacy.lua— curtail action handlers + atomic FC 0x10 writes.drivers/tesla_vehicle.lua— 30 s Charging cadence + 5 min watchdog + 2.5 min force-wake.go/internal/control/dispatch.go—PVManualHold,EVCurtailHeadroomW,liveCurtailLimitW.go/internal/api/api_pv_manual.go(new) + routes —/api/pv/manual_holdendpoints.go/internal/loadpoint/surplus_reserve.go—SurplusPotentialW+ headroom-awareSurplusReserveW.go/internal/telemetry/store.go— per-driver watchdog override.go/internal/drivers/lua.go—host.set_watchdog_timeout_sbinding.go/cmd/forty-two-watts/main.go— wiresEVCurtailHeadroomW.web/components/ftw-pv-control.js+next-app.js+index.html— modal + planet-click routing.control/pv_curtail_test.go,api/api_pv_manual_test.go,drivers/solaredge_curtail_test.go,loadpoint/surplus_reserve_test.go,telemetry/telemetry_test.go.Design doc:
docs/superpowers/specs/2026-05-23-solaredge-curtail-and-pv-manual-hold-design.md.Operator setup
Plus enable "Limit Control Mode = Export Control / Production" in the SolarEdge SetApp (or MySolarEdge), otherwise the F000/F001 writes are accepted at the Modbus layer but ignored by the inverter.
Deployment note
The production Pi (192.168.1.139) is currently in dev-binary mode for verification. Switch back to the official image after merge:
mv docker-compose.override.yml docker-compose.override.yml.bak-... && docker compose pull && docker compose up -d.Caveats
verification_status = experimentalon all three SolarEdge drivers. HD-Wave 8 kW verified live in this branch; K-series and StorEdge curtail paths are documented to work but await live-hardware verification.{0, 100}oncurtail_disable/deinit/driver_cleanup. A daemon crash mid-curtail will leave the cap applied until SetApp manually releases it (acceptable — operators always have SetApp).🤖 Generated with Claude Code