Skip to content

fix(optimization): DualSungrow force_charge does not clamp per-inverter charge power limit #12

Description

@Artic0din

What

DualSungrowCoordinator.force_charge splits requested charge power across the two inverters proportionally but never clamps each share to that inverter's hardware charge-power limit. The sibling force_discharge method does clamp.

File: custom_components/power_sync/optimization/coordinator.py

force_charge (≈L4618):

async def force_charge(self, duration_minutes: int = 30, power_w: float = 0) -> bool:
    if power_w > 0:
        p1, p2 = await self._split_power(power_w / 1000, prefer_lower_soc=True)
        r1 = await self._coord1.force_charge(duration_minutes, power_w=p1 * 1000)
        r2 = await self._coord2.force_charge(duration_minutes, power_w=p2 * 1000)

force_discharge (≈L4629) — note the guard that is absent from force_charge:

if power_w > 0:
    max_split = self._max_split_kw("discharge")
    if max_split and (power_w / 1000.0) >= sum(max_split):
        p1, p2 = max_split
    else:
        p1, p2 = await self._split_power(power_w / 1000, prefer_lower_soc=False)

_split_power (≈L4447) is SOC-proportional only and applies no power cap.

Why it matters

When the optimizer requests a high charge power and the SOC split is skewed (one inverter much emptier), the proportional share for one inverter can exceed its battery_max_charge_power_w. The hardware will either reject the command or clip it silently, so the requested aggregate charge power is not actually delivered — the charge schedule under-fills versus the LP plan. The discharge path was hardened against exactly this; the charge path was missed, so the protection is asymmetric.

Fix

Mirror the discharge guard in force_charge: add a _max_split_kw("charge") helper call (or reuse _max_split_kw with direction="charge") and cap to the per-inverter limits when power_w/1000 >= sum(max_split), falling back to _split_power otherwise. Additionally, _split_power should clamp each leg to its per-inverter limit so the proportional path is also safe.

Note: this code is shared with upstream (bolagnaise) and the defect likely exists there too; fork-tracking here.


Migration source: PLA-76
Linear status at cutover: Backlog
Cut over to GitHub on 2026-07-22.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions