fix(hybrid): never import from the grid for a planned PV charge - #175
Merged
Conversation
The DP plans a charge against forecast PV. When that PV does not arrive, hybrid followed the plan verbatim and bought the difference from the grid until the end of the price period (issue #174). Three things kept it there: - The charge arbitration only ran while the meter showed export, so the one case that needs it — the charge that is importing — fell through to "follow the schedule" with no economic test at all. - The surplus was read straight off the grid meter, which includes the battery's own action. Once the charge started the meter went positive, so the surplus looked gone and the arbitration could never re-enter. A zero-grid capture holding the meter at ~0 W had the mirror problem: it read as surplus long after the sun stopped delivering one. - The real-time loop replayed the cached decision, so nothing could correct it before the next optimizer run. The arbitration now runs whichever way the meter points and decides on the DP's own yardstick: a stored kWh costs price / charge_eff plus degradation, and buying it is justified only while the shadow price covers that (±5% hysteresis, mirroring the discharge and surplus-capture tests). Planned grid arbitrage keeps its full power; a charge planned on PV that failed to show up charges on whatever surplus there is, or holds at idle when there is none. Every hybrid branch now measures the surplus as battery - grid, which is pv - load by construction and so invariant to the setpoint, and the charge arbitration is repeated on every real-time tick (comparisons on the cached plan, no DP re-solve). A vetoed charge is also exempt from the commitment filter's idle suppression, which would otherwise hold the import for the rest of the price period. Applies to hybrid and hybrid+ alike: only the idle-branch surplus gate is hybrid+ specific, the charge branch is shared. Diagnostics gain surplus_kw and grid_charge_vetoed per run, shown in the analyzer and simulate_diagnostics run-log tables.
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.
Description
Fixes #174.
The DP plans a charge against forecast PV. When that PV does not arrive, hybrid followed the plan verbatim and bought the difference from the grid until the end of the price period — the reporter saw a 1200 W planned charge importing 70–560 W with no sun to cover it.
Three separate things kept it there:
elif result.optimal_mode == ACTION_CHARGING and current_grid < 0). The one case that needs it — the charge that is importing — fell through to "follow the schedule" with no economic test at all.follow_schedulein the controller ignores the meter entirely, so nothing could correct it before the next optimizer run (period boundary or mid-period — matching the 13:00 / 13:07 / 13:15 timeline in the report).What changed
price / charge_eff + degradation, and buying it is justified only while the shadow price λ covers that (±5 % hysteresis, mirroring the existing discharge and surplus-capture tests). This separates the two reasons the DP plans a charge — planned grid arbitrage in a cheap hour keeps its full power, while a charge planned on PV that failed to show up charges on whatever surplus there is (zero-grid), or holds at idle when there is none. Never grid import.battery − grid, which ispv − loadby construction and therefore invariant to the setpoint. The hysteresis bands now only have to damp sensor noise instead of the controller's own feedback. Same measure for the real-timeidle→ zero-grid upgrade.Applies to hybrid and hybrid+ alike: only the idle-branch surplus gate is hybrid+ specific; the charge branch is shared, so plain hybrid had the same bug.
Note the reporter's literal suggestion (switch to zero-grid whenever import exceeds the deadband) is deliberately not what was implemented: it would also kill intentional grid charging in cheap or negative-price hours. The λ test is the version that keeps that.
The DP engine itself is unchanged, so
analyzer.jsand the simulator's DP stay in sync as-is.Type of change
fix:Bug fix (patch version bump)feat:New feature (minor version bump)feat!:/BREAKING CHANGE:Breaking change (major version bump)chore:/docs:/ci:Maintenance or documentation (no version bump)Checklist
feat:,fix:,chore:, etc.)devbranch (notmain, unless this is a hotfix)Tests
Seven new tests, each verified to fail without the fix:
idle(the issue's case)zero_grid(charges the sun, imports nothing)idle→ zero-grid upgrade reads the surplus through the batteryFull suite: 1019 passed.
pre-commitclean (mypy included),npx jest89 passed.Docs
docs/algorithm.md(shadow-price section) anddocs/control-modes.md(hybrid section) describe the charge test, the surplus measure and the real-time re-check. Diagnostics gainsurplus_kwandgrid_charge_vetoedper run, rendered in the analyzer's run-log table and insimulate_diagnostics.py.Generated by Claude Code