Skip to content

v7.10.0: Solar clipping awareness for DC-coupled hybrid inverters#58

Open
pookey wants to merge 4 commits intojohanzander:mainfrom
pookey:feat/solar-clipping-johanzander
Open

v7.10.0: Solar clipping awareness for DC-coupled hybrid inverters#58
pookey wants to merge 4 commits intojohanzander:mainfrom
pookey:feat/solar-clipping-johanzander

Conversation

@pookey
Copy link
Copy Markdown
Contributor

@pookey pookey commented Mar 14, 2026

Summary

  • Problem: The optimizer had no awareness of inverter AC output limits. On a 6.7 kW DC panel / 5 kW AC inverter setup, the battery would fill before peak solar hours, permanently wasting free DC-excess energy that the inverter cannot convert to AC.
  • Solution: When battery.inverter_ac_capacity_kw is configured, the Solcast forecast is split into AC solar (≤ inverter limit) and DC-excess solar. The DP algorithm absorbs DC excess before its AC-side charge/discharge decision. Because DC-excess energy has zero grid cost (only cycle cost), backward induction naturally keeps battery headroom open during clipping hours — no heuristics needed.
  • Backward compatible: inverter_ac_capacity_kw = 0 (default) gives identical behaviour to v7.9.5.

Changes

  • config.yaml: new battery.inverter_ac_capacity_kw and battery.solar_panel_dc_capacity_kw options
  • settings.py: new BatterySettings fields loaded from config
  • models.py: EnergyData gains dc_excess_to_battery and solar_clipped fields
  • dp_battery_algorithm.py: split_solar_forecast(), DC-aware _calculate_reward, _run_dynamic_programming, optimize_battery_schedule, and _create_idle_schedule
  • battery_system_manager.py: wires up split_solar_forecast in _run_optimization
  • api_dataclasses.py: dcExcessToBattery and solarClipped exposed in /api/dashboard per-period response; inverterAcCapacityKw and solarPanelDcCapacityKw exposed in /api/settings/battery
  • api.py: quarterly-to-hourly aggregation sums the new clipping fields
  • 5 new behaviour-based tests (all passing)

Demonstration:

We can see headroom being left in the. battery during the peak.

image

Test plan

  • All unit tests pass (rebased cleanly on johanzander/main)
  • black and ruff clean
  • Set inverter_ac_capacity_kw: 5.0 and solar_panel_dc_capacity_kw: 6.7 in config, run optimization, verify schedule defers grid charging to after peak clipping hours
  • Verify dcExcessToBattery and solarClipped appear in /api/dashboard period data
  • Verify inverterAcCapacityKw and solarPanelDcCapacityKw appear in /api/settings/battery

🤖 Generated with Claude Code

The optimizer previously had no awareness of inverter AC output limits, so it
would grid-charge or solar-store the battery before peak solar hours, wasting
free DC-excess energy that the inverter could not convert to AC.

When `battery.inverter_ac_capacity_kw` is configured, the Solcast forecast is
split into AC solar (≤ inverter limit) and DC-excess solar (the portion that
flows directly to the battery on the DC bus). The DP algorithm receives both
and automatically absorbs DC excess before evaluating AC-side charge/discharge
decisions. Because DC-excess energy carries zero grid cost (only cycle cost),
backward induction naturally reserves battery headroom for clipping hours over
grid charging — no special heuristics needed.

New EnergyData fields `dc_excess_to_battery` and `solar_clipped` expose
captured vs lost DC excess per period for dashboard visibility. When
`inverter_ac_capacity_kw = 0` (default), behaviour is unchanged.
@pookey
Copy link
Copy Markdown
Contributor Author

pookey commented Mar 14, 2026

This is still in testing - I'll not be able to verify it's effectiveness until there's another funny day in the UK, so might be a few years ;)

@pookey
Copy link
Copy Markdown
Contributor Author

pookey commented Mar 14, 2026

Code review

Found 4 issues:

  1. config.yaml schema fields should be optionalinverter_ac_capacity_kw and solar_panel_dc_capacity_kw are defined as float (required) in the HA add-on schema, but should be float? (optional). Home Assistant validates the schema before Python runs, so existing users upgrading without these fields will fail to start the add-on. The Python-side .get() defaults in settings.py are unreachable if HA rejects the config first. Other optional fields already use float? (e.g., solar_forecast_tomorrow: str?).

bess-manager/config.yaml

Lines 135 to 138 in b09a8f2

min_action_profit_threshold: float
inverter_ac_capacity_kw: float
solar_panel_dc_capacity_kw: float
temperature_derating:

  1. split_solar_forecast docstring contradicts implementation — The docstring says inverter_ac_capacity_kw: 0 = no limit, but the implementation computes ac_limit_kwh = inverter_ac_capacity_kw * period_duration_hours, so passing 0 caps all AC solar to zero and routes everything to DC excess. The test test_split_solar_forecast_zero_inverter_limit has a docstring saying "returns all solar as AC (disabled)" but asserts the opposite (ac_solar == [0.0, 0.0, 0.0]). The CHANGELOG also claims "When inverter_ac_capacity_kw = 0 (default), behaviour is identical to previous versions" which would be false if split_solar_forecast were called with 0. The production guard (if inverter_ac_capacity_kw > 0) prevents this from being hit at runtime, but the docstring and test are misleading.

solar_production: Raw solar forecast per period (kWh).
inverter_ac_capacity_kw: Inverter AC output limit in kW. 0 = no limit.
period_duration_hours: Duration of each period in hours.
Returns:
Tuple of (ac_solar, dc_excess) lists, both same length as solar_production.
"""
ac_limit_kwh = inverter_ac_capacity_kw * period_duration_hours
ac_solar = [min(s, ac_limit_kwh) for s in solar_production]
dc_excess = [max(0.0, s - ac_limit_kwh) for s in solar_production]
return ac_solar, dc_excess

  1. validate_energy_balance does not account for dc_excess_to_battery — The balance check computes energy_in = solar_production + grid_imported + battery_discharged and energy_out = home_consumption + grid_exported + battery_charged. With DC clipping, dc_excess_to_battery charges the battery but is excluded from both battery_charged (AC-side only) and solar_production (now AC-capped). Periods with DC excess absorption will produce a false energy imbalance equal to dc_excess_to_battery.

def validate_energy_balance(self, tolerance: float = 0.2) -> tuple[bool, str]:
"""Validate energy balance - always warn and continue, never fail."""
energy_in = self.solar_production + self.grid_imported + self.battery_discharged
energy_out = self.home_consumption + self.grid_exported + self.battery_charged
balance_error = abs(energy_in - energy_out)
if balance_error <= tolerance:
return True, f"Energy balance OK: {balance_error:.3f} kWh error"
else:
logger.warning(
f"Energy balance warning: In={energy_in:.2f}, Out={energy_out:.2f}, "
f"Error={balance_error:.2f} kWh"
)
return (
True,
f"Energy balance warning: {balance_error:.2f} kWh error (continuing)",
)

  1. CYCLE COST POLICY comment now contradicts implementation — The _calculate_reward docstring states "Applied only to charging operations (not discharging)". With the PR changes, when DC excess is absorbed during discharge or idle periods, battery_wear_cost = dc_wear_cost (non-zero), so cycle cost is now applied during non-charging periods too.

CYCLE COST POLICY:
- Applied only to charging operations (not discharging)
- Applied to energy actually stored (after efficiency losses)

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

ipc-zpg and others added 3 commits March 14, 2026 21:43
1. Schema fields: inverter_ac_capacity_kw and solar_panel_dc_capacity_kw
   changed from `float` to `float?` so existing users can upgrade without
   HA rejecting their config before Python runs.

2. split_solar_forecast docstring: removed misleading "0 = no limit" since
   passing 0 would cap AC to zero. Clarified that the caller must guard
   with `if inverter_ac_capacity_kw > 0`. Replaced the zero-limit test
   with a total-preservation test.

3. validate_energy_balance: documented that DC excess bypasses the AC bus
   and is balanced by definition (dc_excess_to_battery + solar_clipped =
   total DC excess). The AC-side balance holds by construction since grid
   flows are derived from the balance equation.

4. Cycle cost policy docstring: updated to reflect that DC wear cost is
   applied regardless of AC action (not only during charging).
Add dcExcessToBattery and solarClipped FormattedValue fields to
/api/dashboard per-period response. Add inverterAcCapacityKw and
solarPanelDcCapacityKw to /api/settings/battery response. Update
bess-analyst agent with API visibility and debugging steps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The DP was treating IDLE (power=0) as a flat hold, but on the Growatt
inverter IDLE maps to load_first mode where excess solar automatically
charges the battery before exporting. This caused the optimizer to
underestimate morning SOE buildup, leaving insufficient headroom for
afternoon DC excess absorption.

Changes:
- _state_transition: when power=0, excess solar auto-charges battery up
  to max_charge_power_kw and available capacity (load_first behavior)
- _calculate_reward: detects IDLE auto-charging from SOE delta, models
  reduced grid export and applies cycle wear cost on auto-charged energy;
  blends auto-charged solar into cost basis at cycle-cost-only rate
- _run_dynamic_programming: computes solar_excess_ac_kw per period
  (capped at temperature-derated limit), passes to _state_transition;
  updates fallback IDLE block and cost basis propagation accordingly
- _create_idle_schedule: added dt parameter, now models solar
  auto-charging in fallback schedule for accurate idle cost accounting

The DP's backward induction now sees that IDLE during morning solar
fills the battery, reducing headroom for free DC excess. When keeping
headroom is more valuable, it chooses discharge/EXPORT_ARBITRAGE (grid_first)
which prevents solar auto-charging on the real inverter — no new intent
types needed.

Backward compatible: no solar or nighttime periods are unchanged.
@pookey
Copy link
Copy Markdown
Contributor Author

pookey commented Mar 18, 2026

Updated this branch with an additional fix discovered after deploying the clipping feature.

Bug: The DP was modelling power=0 (IDLE) as a flat battery hold, but on the Growatt inverter IDLE maps to load_first mode where excess solar automatically charges the battery before exporting. This caused the optimizer to underestimate morning SOE buildup, leaving insufficient headroom for afternoon DC excess absorption.

Evidence (2026-03-18): Periods 30–39 were planned as IDLE, but battery charged from 1.4 → 7.2 kWh (5.8 kWh gain). By period 40 (10:00) battery was at 76% with only 2.1 kWh headroom despite >5 kW solar forecast all afternoon.

Fix: Model IDLE auto-charging in _state_transition, _calculate_reward, _run_dynamic_programming, and _create_idle_schedule. The DP's backward induction now sees that morning IDLE fills the battery, and will schedule discharge/EXPORT_ARBITRAGE (grid_first) when preserving headroom for DC excess is more valuable. No new intent types needed — discharge during solar-excess already maps to EXPORT_ARBITRAGE.

Backward compatible: no solar / nighttime periods are unchanged.

@johanzander
Copy link
Copy Markdown
Owner

Hi @pookey , sorry for not merging this one sooner, and now I have refactored the algorithm (or fixed some issues), so I cannot safely merge the changes. Could you rebase and verify your fix and I will merge it?

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.

3 participants