Skip to content

feat: PV curtail support (SolarEdge) + operator manual hold - #289

Merged
erikarenhill merged 16 commits into
masterfrom
feat/solaredge-pv-curtail
May 24, 2026
Merged

feat: PV curtail support (SolarEdge) + operator manual hold#289
erikarenhill merged 16 commits into
masterfrom
feat/solaredge-pv-curtail

Conversation

@erikarenhill

@erikarenhill erikarenhill commented May 23, 2026

Copy link
Copy Markdown
Collaborator

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_disable action handlers in drivers/solaredge.lua, drivers/solaredge_pv.lua, and drivers/solaredge_legacy.lua writing the SolarEdge proprietary Advanced Power Control registers (F000 enable + F001 percent 0-100).
  • Single atomic FC 0x10 multi-register write across F000+F001 — inverter never sees enable=1 paired with a stale F001 from the previous tick.
  • Release path writes BOTH F001=100 AND F000=0 — verified on hardware that disabling F000 alone leaves the cap stuck at the last F001 value on some firmware revisions.
  • All three drivers advertise the new pv-curtail capability. Operator opt-in via supports_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

  • New control.State.ManualPVHold + POST/GET/DELETE /api/pv/manual_hold mirroring the battery manual-hold UX. Body accepts limit_w or limit_pct; both site-aggregate and driver-scoped holds. limit_pct resolves against each driver's nominal_w (rated inverter output) so "100 %" means the inverter nameplate, not the current cloudy production.
  • ComputePVCurtail now treats the planner's PVLimitW as 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.
  • Manual hold bypasses live calc — operator override is unconditional.

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 as control.State.EVCurtailHeadroomW.
  • loadpoint.SurplusReserveW now reserves MinChargeW for 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-vehicle polls every 30 s while charging_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).
  • New telemetry.DriverHealth.WatchdogTimeoutOverride + Store.SetDriverWatchdogTimeout + lua binding host.set_watchdog_timeout_s(seconds). Tesla driver registers 5 min at init for headroom on legitimate BLE wake delays.
  • Stale-while-charging force-wake at 2.5 min — if last.charging_state == "Charging" and 2.5 min have passed without fresh telemetry, the next poll attaches wakeup=true to 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.
  • 30-min stability verification: stale flips 0, wake-kicks 0, wallbox-cycle 0, EV-stopped MPC replans 0 across all six 5-min windows.

UI

  • New <ftw-pv-control> modal — Watts / Percent toggle, duration chips, driver scope picker. Opens from the solar planet click in ftw-energy-flow; d.id from an expanded per-driver bubble pre-selects that driver. Reads the picker list from /api/config so only drivers with supports_pv_curtail: true appear.
  • Percent label reads "% of inverter max" — matches the backend behavior.

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.goPVManualHold, EVCurtailHeadroomW, liveCurtailLimitW.
  • go/internal/api/api_pv_manual.go (new) + routes — /api/pv/manual_hold endpoints.
  • go/internal/loadpoint/surplus_reserve.goSurplusPotentialW + headroom-aware SurplusReserveW.
  • go/internal/telemetry/store.go — per-driver watchdog override.
  • go/internal/drivers/lua.gohost.set_watchdog_timeout_s binding.
  • go/cmd/forty-two-watts/main.go — wires EVCurtailHeadroomW.
  • web/components/ftw-pv-control.js + next-app.js + index.html — modal + planet-click routing.
  • Tests: 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

# config.yaml — example wiring for the HD-Wave variant
- name: solaredge
  lua: drivers/solaredge_pv.lua
  supports_pv_curtail: true
  config:
    nominal_w: 8000          # inverter rated AC output
  capabilities:
    modbus:
      host: 192.168.1.15
      port: 502
      unit_id: 1

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 = experimental on 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.
  • F000/F001 do NOT auto-revert on SolarEdge. Cleanup paths write {0, 100} on curtail_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

@erikarenhill
erikarenhill force-pushed the feat/solaredge-pv-curtail branch 2 times, most recently from 97823fc to 9d2a826 Compare May 24, 2026 11:22
erikarenhill and others added 16 commits May 24, 2026 20:51
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
erikarenhill force-pushed the feat/solaredge-pv-curtail branch from ab44784 to 0578d4b Compare May 24, 2026 18:52
@erikarenhill
erikarenhill merged commit a361d9e into master May 24, 2026
1 check passed
@frahlg
frahlg deleted the feat/solaredge-pv-curtail branch August 7, 2026 08:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant