Skip to content

RFC: Optional MILP planning extension to model DC-coupled PV and inverter clipping #3829

@thebigjc

Description

@thebigjc

Problem

Predbat's current planner is a forward simulation that enumerates charge/discharge window combinations and picks the lowest-cost one. This works well for price-arbitrage on time-of-use tariffs, but it has a structural limitation for DC-coupled hybrid inverters: it cannot model the path where excess PV flows directly into the battery over the DC bus, bypassing the AC inverter limit.

This means that on flat or near-flat tariffs (small import/export spread), Predbat sees no price signal to pre-discharge — even when doing so would free battery headroom that lets DC-coupled PV charge the battery and export at full inverter capacity simultaneously, capturing generation that would otherwise clip.

Related issues that describe this symptom from different angles: #1206, #2472, #2341.


The DC-coupling gap

A DC-coupled hybrid inverter has two PV pathways:

  • PV → AC (always available): limited by the AC inverter rating
  • PV → Battery DC (only when battery is not full and inverter is in Self Consumption or Charge mode): bypasses the AC inverter limit

Predbat's model has no e_pvb variable. It sees PV as a single AC source capped at inverter_limit. So it never reasons about the trade-off:

"Pre-discharging now (earning 15 ¢/kWh) makes room for PV-DC charging tomorrow, which avoids importing at 16 ¢/kWh — worthwhile even on a near-flat tariff."


What a proper MILP would look like

A Mixed Integer Linear Program over a 48-hour horizon, 15-min steps, with variables:

Variable Meaning
e_imp[t], e_exp[t] Grid import / export (kWh)
e_pvb[t] PV energy routed to battery via DC bus
e_pv_ac[t] PV energy routed to AC inverter
e_bdis[t] Battery discharge (kWh)
soc[t] State of charge
z_dis[t] ∈ {0,1} Mode binary: 0 = Self Consumption, 1 = Discharge/Export

Key constraints:

soc[t+1] = soc[t] + η_ch·e_pvb[t] − e_bdis[t]/η_dis
e_imp + e_bdis + e_pv_ac = load + e_exp          (AC balance)
e_bdis + e_pv_ac ≤ inv_limit                      (inverter AC cap)
e_pv_ac + η_inv·e_pvb ≤ pv·η_inv                 (PV DC split)
e_pvb ≤ bat_ch_max · (1 − z_dis)                  (DC only in SC mode)
e_exp ≤ inv_limit · z_dis                          (export only in Discharge)

Objective: minimise net grid cost over the horizon.

On a flat tariff, clipping has no direct cost in the objective, but the model correctly sees that pre-discharging now creates headroom for future PV-DC charging that avoids imports — enough to drive pre-discharge even with a 1 ¢/kWh spread.


Current workaround

We have a working standalone Predbat plugin implementing this MILP using HiGHS (highspy). It hooks on_update, solves the MILP, and calls select/select_option on a SolarEdge battery directly. It works, but it is invisible to Predbat's plan display and dashboard.


Proposed integration path

An optional replace_plan hook that Predbat calls instead of its own forward simulation when an external planner is registered. The hook would receive the same data Predbat already has (PV forecast, load forecast, SoC, rates, battery/inverter parameters) and return a list of charge/discharge windows that Predbat then executes and displays natively.

class MyMILPPlanner:
    def plan(self, forecast: PredBatForecast) -> list[Window]:
        ...

Registering it via the plugin system:

def register_hooks(self, plugin_system):
    plugin_system.register_planner(MyMILPPlanner())

This would give:

  • Full Predbat dashboard / plan card visibility for MILP-derived schedules
  • Predbat still handles all inverter service calls
  • The DC-coupling model lives in the optional plugin, not in Predbat core
  • Users on simple tariffs / AC-coupled systems are unaffected

Questions for maintainers

  1. Is a replace_plan / register_planner hook something you'd consider merging, or is the architecture too deeply coupled to the forward simulation?
  2. Is there an existing internal boundary point (a method, a data class) where an external plan could be injected without a large refactor?
  3. Would you prefer this as a contribution to Predbat core, or as a documented external plugin pattern?

Happy to share the working plugin implementation and MILP formulation in detail if useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions