Skip to content

fix(mpc): surplus-only EV can take leftover PV while the home battery grid-charges - #957

Draft
frahlg wants to merge 2 commits into
masterfrom
cursor/ev-pv-beside-battery-grid-charge-8f15
Draft

fix(mpc): surplus-only EV can take leftover PV while the home battery grid-charges#957
frahlg wants to merge 2 commits into
masterfrom
cursor/ev-pv-beside-battery-grid-charge-8f15

Conversation

@frahlg

@frahlg frahlg commented Aug 18, 2026

Copy link
Copy Markdown
Member

Accepted text proposal

Issue or Discussion: field reports that battery + EV charging together is broken after #953.

Maintainer comment that accepted this scope: continue from the surplus-only / home-battery combo; the remaining hole is leftover PV into the car while the house battery buys from the grid, and the missing joined EV test harness that should have caught it. Follow-up: EV must be a first-class site-power contract, not a parallel mapper in that harness.

What changed

#953 let the home battery import at night while a surplus-only EV sat plugged and idle. Surplus-only was still implemented as “no site import while the EV is charging”. That forbids the real combo: leftover PV into the car, grid into the home battery, in the same slot.

The constraint is now leftover PV after house load, in the Go DP, ValidatePlan, and the Python optimizer:

evW <= max(0, -(loadW + pvW)) + 50

That identity lives in loadpoint (GridW, PVLeftoverAfterHouseW, HouseResidualW, SurplusOnlyExceedsHousePV, BatteryDischargeFeedsEV). Planner, live surplus, and the near-term 3Φ gate read it. Site import from a simultaneous home-battery grid-charge is not the car importing. Battery→EV is still blocked.

Plan→EMS mapping is one pair of adapters, used by main.go and the site clock:

  • control.SlotDirectiveFromMPC
  • mpc.SlotDirective.LoadpointDirective
  • mpc.PeakPlannedSurplusForEV

The joined site clock (TestEVSite*) holds an mpc.Service, publishes with InstallPlan, and reads SlotDirectiveAt — the same cache main.go reads. It does not hand-map Action onto SlotDirective. Tick order is unchanged: charger first, then battery dispatch.

SlotDirectiveAt ages GeneratedAtMs on the wall clock (MaxPlanAge). Injected noon slots still stamp GeneratedAtMs with time.Now() so a simulated clock cannot bypass the live staleness rule.

Why

Isolated tests exist and they passed while the combo stayed broken:

  • Planner tests call Optimize / ValidatePlan and never tick the charger
  • Loadpoint tests inject a Directive and never run battery dispatch
  • Control golden / forecast_scenarios call ComputeDispatch with a pre-baked EVChargingW and no loadpoint controller
  • go/test/e2e has Ferroamp / Sungrow batteries and no EV charger

A harness that reimplemented leftover/grid and plan→EMS mapping would have been a second EV stack. The site clock is a plant and a clock; the contract is shared.

#953’s tests asserted idle EV + battery import. They could not see “PV to the car, grid to Pixii” on one clock.

Boundaries and safety

  • Surplus-only still cannot import. No leftover PV → EV stays off, including a deadline with no sun.
  • Home battery still cannot feed the EV (NoBatteryToEV / surplus-only).
  • Same-tick PV-soak is still hidden from the charger (EV ticks first; claiming those watts would leak into import).
  • Bat-SoC unlock stays a this-tick runtime clamp and is not written onto the 48 h MPC spec.
  • Stale site-meter data still stops dispatch (unchanged).
  • Dispatch-side CANONICAL battery-to-EV clamp in dispatch.go is unchanged in this PR (fix(control): mitigate PV-only export over limits #888 has that file).

Out of scope: repairPoisonedBuckets overnight prior, hybrid idle deadband, PV-only export (#888).

Verification

go test ./internal/control ./internal/mpc ./internal/loadpoint -count=1
go test ./cmd/ftw -count=1 -run TestControlSlotDirectiveFromMPC
optimizer/.venv/bin/pytest -q optimizer/tests/test_model.py -k surplus_only_ev

Harness scenarios that fail on unfixed master:

  • leftover PV + battery grid-charge with opportunistic surplus (no EV plan budget)
  • the same with a planned EV budget
  • Optimize → Tick → dispatch on one clock
  • night idle surplus-only EV does not block battery import (fix(mpc): active arbitrage can grid-charge beside a surplus-only EV #953)
  • leftover below 3Φ min → EV pauses
  • scheduled (not surplus-only) EV may import on a cheap night
  • surplus-only EV does not charge from battery discharge / no PV

Open PRs on overlapping files

Checklist

  • The diff implements one accepted scope and does not add follow-on work.
  • I checked open pull requests that touch the same files.
  • Tests cover the changed behaviour and its failure path.
  • A human reviewed every changed web/UI view in a browser, or no UI changed.
  • A Changeset is included, or the change is exempt.
  • Every commit has a DCO sign-off.
Open in Web Open in Cursor 

cursoragent and others added 2 commits August 18, 2026 17:25
…arge

Surplus-only is an EV policy, not a site import ban. The car may use
PV left after house load in the same slot the home battery buys from
the grid. Live surplus and the near-term 3Φ gate follow the same
accounting.

Add a joined EV site harness (plan → charger Tick → ComputeDispatch →
site identity). Isolated planner, charger and dispatch suites never
ran on one clock, which is why the combo stayed invisible.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
Give leftover PV, house residual and grid identity a home in
loadpoint.GridW. DP, ValidatePlan, main.go and the joined site clock
all read that contract.

Plan→EMS mapping lives in SlotDirectiveFromMPC and
SlotDirective.LoadpointDirective — the same adapters main.go uses.
The site clock publishes via Service.InstallPlan and reads
SlotDirectiveAt, so charger and battery cannot be given two mappings
of one slot.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
@cursor

cursor Bot commented Aug 18, 2026

Copy link
Copy Markdown

Rätt problem och rätt riktning, men jag skulle inte släppa draften som den är. Live-surplus-formeln blandar ihop “någon importerar” med “batteriet köper från nätet”, och det är precis den läcka PR:n säger sig stänga.

Inga tidigare reviews. CI grön. Relaterat: #953 (mergerad) tog idle EV + batteriimport; den här tar leftover PV + batteriimport i samma slot. #888 lämnas ifred (dispatch.go).

Det som faktiskt är fel

SurplusAvailableForEVW använder live gridW > 100 som tecken på medveten grid-charge:

if surplusOnlyActive && batW > 0 && gridW <= GridChargeImportW {
    leftover = -gridW + evW  // göm PV-soak
}

gridW > 100 betyder bara att mätaren importerar. Det kan lika gärna vara EV + PV-soak som tillsammans gick över leftover. Då flippar funktionen till “erbjud hela hus-leftovern” och bilen får behålla (eller höja) just den setpoint som orsakade importen.

Enhets-testen kodar det:

SurplusAvailableForEVW(1500, 5000, 4140, true) == 7640

Identity: leftover = 7640 W. Batteriet tar 5000 < 7640 — det är soak, inte Pixii som köper. EV+batteri = 9140, därför importerar de 1500. Att erbjuda 7640 låter EV stanna på 4140 medan importen består.

En riktig combo är leftover 7500, batteri 10 kW, EV 4140, grid ≈ 6640. Alla TestEVSite*-combo-fall använder just den (batteri > leftover), så de träffar aldrig soak+EV-grenen. Soak-testen har evW = 0.

3Φ-grinden gör det rätt: PlannedPVSoakW läser planens GridW. Live-läsaren borde göra samma sak, eller använda en proxy som inte krossas av EV-drag:

gridW > evW + GridChargeImportW — import utöver bilen är batteriet som köper.

Med den proxyn blir (1500, 5000, 4140) → 2640 (leftover minus soak), och (6640, 10000, 4140) → 7500 (äkta combo). Det är den formeln kommentaren beskriver.

Det här är samma flap #953 varnade för, bara spegelvänd: “handa batteriets soak till EV → import → ombudgetering”.

Övrigt som bör fixas i samma draft

1. Python-testen test_surplus_only_ev_still_cannot_import hävdar den gamla regeln

if action["flex_power_w"]["surplus-car"] > 1e-5:
    assert action["grid_w"] <= 50 + 1e-5

Det är “EV på ⇒ ingen site-import”. Slot 1 i base_request har pv_w = 0, så EV blir 0 och asserten är tom. Efter den här PR:n är policyn leftover, inte grid. Byt till leftover-check, annars slåss den mot den nya constrainten så fort någon sätter PV på den slotten.

2. Ingen ValidatePlan-reject för leftover-brott

Det finns accept-combo (TestValidatePlanAllowsEVPVWithBatteryGridCharge) och DP-förbud (TestSurplusOnlyEVCannotImportEvenWithDeadline). HiGHS-planen valideras i Core — det var #953:s tredje lås. En plan med evW > leftover + 50 måste avvisas explicit.

3. Python-constrainten är bara på base_pv / base_load

Gamla regeln var per scenario (sv["import"] <= …). Nya är flex.power <= leftover(base) + 50. En low-PV robust-scenario kan då ha EV över den scenariens leftover. Antingen per-scenario leftover eller ett medvetet “base only”-beslut i PR-texten.

4. controller.go ljuger nu

Kommentaren säger fortfarande att surplus är −grid+bat+ev och invariant under batteriet, och att soak räknas som EV-tillgängligt. Båda är villkorliga efter den här PR:n.

5. loadpoint_spec.go överdriver dispatch-alignment

Texten säger att runtime-clampen i dispatch.go är samma conservation-check. Helpern är inte dragen dit — medvetet p.g.a. #888. Låt kommentaren säga det, inte att de delar implementation.

6. Optimizer måste släppas med Core

Samma läxa som #953: HiGHS-imagen måste ha den nya constrainten, annars producerar default-pathen fortfarande “bilen stilla på billig sol”, och ValidatePlan släpper igenom en combo default-solvern aldrig ger. Changeset är bara "ftw": patch. Skriv dual-release i PR-body.

Beteende att känna till, inte nödvändigtvis bug

Opportunistisk surplus (planens EV-budget = 0, TestEVSiteSurplusOnlyTakesLeftoverPVWhileBatteryGridCharges): när bilen tar leftover som inte fanns i planen går live import över PlannedGridW, och energy-pathens soft cap backar batteriet ungefär lika många watt. Combon händer fortfarande (requireCombo kräver bara EV>1 kW, bat>500, grid>100), men Pixii köper mindre än planens 10 kW. Kanske rätt (“importera inte mer än planen”), men harnessen påstår inte att batteriet håller plan-watt.

mpc importerar loadpoint för site-identitet. Ingen cykel. Lite upp-och-ner (planner beror på EV-paketet), men bättre än tvärtom.

Bat-SoC-unlock ur 48 h-specen är rätt och lite utanför “leftover-hålet”, men motiverad.

Det som är bra

Verdict

Rätt scope, rätt arkitektur, CI grön — men live-grenen är inte den identitet PR-texten säljer, och den enda enhetstest som rör “grid-charge + EV” använder tal som är soak+import. Fixa SurplusAvailableForEVW (eller PlannedGridW in i läsaren), rätta Python-asserten, lägg en ValidatePlan-reject, sedan kan draften lämna draft.

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.

2 participants