Skip to content

GRID_CHARGING throttled to ~1% hardware charge rate — DP tie-break reports wrong action magnitude #203

Description

@johanzander

Summary

On 2026-06-30, actual solar underperformed forecast and the battery should have grid-charged heavily during the cheap midday/early-afternoon window (09:45–14:45, prices ~0.90–1.24 SEK/kWh) to be full for evening home support (evening prices spiked to 3.5–5.9 SEK). Instead, SOE was flat at exactly 9.0 kWh (30% of 30 kWh capacity) for the entire 12:00–18:30 window, despite the optimizer's Intent reading GRID_CHARGING almost continuously through that period.

This is not the known 500 Server Error control-issue (those are on set_discharging_power_rate, dense overnight, and only 7 sparse/self-healing occurrences in the 12:00–18:30 window — not correlated with the plateau). It's a separate, real bug: grid charging is being silently throttled to ~1% hardware charge rate for the entire duration of every GRID_CHARGING period, introduced by PR #191.

Root cause

inverter_controller.py:221-227 (added in a7a366d / PR #191, "action-derived charge rate for GRID_CHARGING periods", merged 2026-06-27, 3 days before this incident) computes the hardware charge-rate percentage from the DP's planned action size:

if intent == "GRID_CHARGING" and battery_action_kw > 0.01:
    charge_rate = min(100, max(0, round(battery_action_kw / self.max_charge_power_kw * 100)))

But dp_battery_algorithm.py's charging physics are binary — any charging power above POWER_TOLERANCE_KW produces an identical result (rate_throughput = max_charge_power_kw * dt, dp_battery_algorithm.py:213, independent of the tested power magnitude). In _run_dynamic_programming (dp_battery_algorithm.py:686-772), power_levels is iterated in ascending order and the tie-break is if value > best_value (strict >, line 765). Since every positive power level ties, the smallest one always "wins": +0.2 kW → 0.05 kWh/period. That meaningless tie-break artifact is then fed straight into the charge-rate formula above, producing round(0.2/15*100) ≈ 1%.

Confirmed empirically (same battery/solar/home state, varying only the tested power):

power=  0.2 kW  next_soe=12.6375  reward=-6.55500
power=  1.0 kW  next_soe=12.6375  reward=-6.55500
power=  5.0 kW  next_soe=12.6375  reward=-6.55500
power= 15.0 kW  next_soe=12.6375  reward=-6.55500

Evidence from the debug log

Every single GRID_CHARGING apply-event in the 24h bundle reports the same tied value, e.g.:

2026-06-30 10:45:23 | INFO | battery_system_manager:2380 - Period 43 (10:45): Intent=GRID_CHARGING, Action=0.05 kWh (0.20 kW), DischargeRate=0%

And the actual hardware register write confirms the throttle happened, in lockstep with intent transitions, all day:

10:45:00  Updating target charging power from 100.0% to 1.0%   ← GRID_CHARGING period starts
11:00:00  Updating target charging power from 1.0% to 100.0%   ← intent leaves GRID_CHARGING
12:00:00  Updating target charging power from 1.0% to 100.0%
12:15:00  Updating target charging power from 100.0% to 1.0%   ← GRID_CHARGING again
13:30:00  Updating target charging power from 1.0% to 100.0%
14:00:00  Updating target charging power from 100.0% to 1.0%

(Non-GRID_CHARGING periods are correctly left at 100%, which is why solar-only SOLAR_STORAGE charging worked fine 00:00–12:00 and SOE only stalled once solar itself weakened in the afternoon.)

Full debug export (settings, prices, historical sensor data, period decisions, system logs) for 2026-06-30: https://gist.github.com/johanzander/3310437737189f053661dd02ea3d61b3

Verdict

  • Not the known 500 control-execution bug (uncorrelated timing, different register).
  • Not a deliberate economic decision to stop charging — the DP's own internal reward/SOE-trajectory computation, at the moment it picks STORE, already assumes a near-max-rate charge. Only the reported action magnitude used downstream is wrong.
  • Not a gap in solar-deficit-compensation logic — the re-optimizer correctly reacts to live SOC/price every 15 minutes and correctly decides to charge heavily during the cheap window. The bug is purely in translating that decision's magnitude to the hardware register two layers downstream. No objective-function change is needed.

Suggested fix (scoped)

  1. In dp_battery_algorithm.py, when the STORE branch wins, report the actual achieved throughput (grid_to_battery / solar_to_battery, or next_soe - soe) as the policy action, instead of the arbitrary tied power loop variable that happens to win the strict-> comparison.
  2. Alternative/complementary: since all positive power levels in the STORE branch are provably physically identical, stop enumerating ~75 redundant positive levels — test one canonical "charge" action and use the real achieved rate directly as best_action.
  3. Add a regression test asserting that for a GRID_CHARGING decision, the reported action (kWh/charge_rate) is consistent with the actual SOE delta it produces. PR feat: action-derived charge rate for GRID_CHARGING periods #191's own tests only checked the charge_rate formula, not that its input (battery_action_kw) was itself meaningful — this bug would have been caught immediately.
  4. Worth a quick check whether the same tied 0.05 kWh value leaks into user-facing "planned action" displays (frontend schedule table added right after PR feat: action-derived charge rate for GRID_CHARGING periods #191, commit 6af892e) — if so the UI may currently show a misleading 0.05 kWh for every grid-charging period even though the schedule's actual SOE/SOC trajectory is computed correctly.

Impact

Any day where the optimizer plans GRID_CHARGING periods, actual grid charging is throttled to ~1% hardware rate — the battery essentially never grid-charges at meaningful power, even though the plan/pricing logic behind the decision is correct. This has been live since PR #191 merged (2026-06-27).

Metadata

Metadata

Assignees

No one assigned

    Labels

    analyzedRoot-cause diagnosis posted, awaiting @claude-bot fixbot-analyzedTriage bot has processed this issuebugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions