Skip to content

fix(hybrid): never import from the grid for a planned PV charge - #175

Merged
bvweerd merged 1 commit into
devfrom
claude/analyze-new-issue-xxx37n
Aug 19, 2026
Merged

fix(hybrid): never import from the grid for a planned PV charge#175
bvweerd merged 1 commit into
devfrom
claude/analyze-new-issue-xxx37n

Conversation

@bvweerd

@bvweerd bvweerd commented Aug 18, 2026

Copy link
Copy Markdown
Owner

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:

  1. The charge arbitration only ran while the meter showed export (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.
  2. The surplus was read straight off the grid meter, which includes the battery's own action. Once the charge started, the meter went positive, the surplus looked gone, and the arbitration could never re-enter (it latched). 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.
  3. The real-time loop replayed the cached decision, and follow_schedule in 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

  • The arbitration runs whichever way the meter points, and decides on the DP's own yardstick: a stored kWh costs 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.
  • Every hybrid branch measures the surplus as battery − grid, which is pv − load by 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-time idle → zero-grid upgrade.
  • The charge arbitration is repeated on every real-time tick (~5–10 s) for hybrid/hybrid+ — a handful of comparisons on the cached plan, no DP re-solve — so a failed PV forecast is corrected within seconds instead of at the next run. It returns to the run's committed power, not the raw plan, so the commitment filter is not bypassed.
  • A vetoed charge is 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, 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.js and 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

  • Commit title follows Conventional Commits (feat:, fix:, chore:, etc.)
  • Tests added or updated where applicable
  • Documentation updated if needed
  • CI is green
  • PR targets the dev branch (not main, unless this is a hotfix)

Tests

Seven new tests, each verified to fail without the fix:

  • planned charge with no surplus and a low λ → idle (the issue's case)
  • planned charge with 0.9 kW of surplus against a 3 kW plan → zero_grid (charges the sun, imports nothing)
  • planned charge with a high λ → still follows the schedule at full power (arbitrage preserved)
  • hybrid+ vetoes identically to hybrid
  • surplus stays visible through the battery's own 3 kW charge
  • the veto releases an active charge commitment instead of being suppressed
  • the real-time tick stops an uneconomical import, and keeps a paying one at its committed power
  • the idle → zero-grid upgrade reads the surplus through the battery

Full suite: 1019 passed. pre-commit clean (mypy included), npx jest 89 passed.

Docs

docs/algorithm.md (shadow-price section) and docs/control-modes.md (hybrid section) describe the charge test, the surplus measure and the real-time re-check. Diagnostics gain surplus_kw and grid_charge_vetoed per run, rendered in the analyzer's run-log table and in simulate_diagnostics.py.


Generated by Claude Code

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.
@github-actions github-actions Bot added documentation Improvement or addition to documentation bug Something isn't working as expected labels Aug 18, 2026
@bvweerd
bvweerd merged commit 85648ca into dev Aug 19, 2026
10 checks passed
@bvweerd
bvweerd deleted the claude/analyze-new-issue-xxx37n branch August 19, 2026 11:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working as expected documentation Improvement or addition to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hybrid+ mode: shouldn't battery charging be limited here?

2 participants