Skip to content

fix: enforce minimum charge power in charge-demand slots#91

Open
andig wants to merge 2 commits into
mainfrom
fix/semicontinuous-charge-demand
Open

fix: enforce minimum charge power in charge-demand slots#91
andig wants to merge 2 commits into
mainfrom
fix/semicontinuous-charge-demand

Conversation

@andig

@andig andig commented Jul 18, 2026

Copy link
Copy Markdown
Member

Problem

The MILP already models minimum charge power as a semi-continuous variable: charge energy c is either 0 or >= c_min via the binary z_c. But that constraint was only applied in time steps without a charge demand:

if bat.p_demand is not None:
    for t in self.time_steps:
        if bat.p_demand[t] > 0:
            <demand constraints only>       # <-- no min-charge bound
        elif bat.c_min > 0:
            <semi-continuous bound>          # only when demand[t] == 0
elif bat.c_min > 0:
    <semi-continuous bound>                  # only when p_demand is None

So in exactly the slots where a loadpoint is actively charging (p_demand[t] > 0), c was free to take any value in (0, c_min). That produces physically impossible schedules — e.g. ~950 W for a wallbox whose real minimum is ~1.4 kW+ (evcc-io/evcc#31894).

Fix

Apply the semi-continuous bound in every time step for c_min > 0 batteries, independent of the demand branch. Demand constraints and the min-charge bound now coexist: the solver still tries to meet p_demand, but can only do so with 0 or >= c_min.

Test

tests/test_semicontinuous.py requests a 500 Wh demand in a slot while c_min = 1400 W, with charging strictly costly (no solar, priced grid, p_a=0) so the solver is forced to the cheapest feasible power.

  • pre-fix: returns 500 W (sub-minimum) → test fails
  • post-fix: returns >= c_min or 0 → test passes

Full suite: 15 passed.

🤖 Generated with Claude Code

The semi-continuous charge constraint (c is 0 or >= c_min) was only applied
in slots without a charge demand. In demand slots (p_demand[t] > 0) only the
demand constraints ran, so the solver could return any power in (0, c_min) -
e.g. ~950 W for a charger whose real minimum is far higher (evcc-io/evcc#31894).

Apply the semi-continuous bound in every time step, independent of demand.
@andig

andig commented Jul 18, 2026

Copy link
Copy Markdown
Member Author

@ekkea could you kindly check?

andig added a commit to evcc-io/evcc that referenced this pull request Jul 18, 2026
Parallel planned sessions under load management must each be granted a whole
effectiveMinPower chunk (charger runs >= min or off). At most
floor(circuitBudget/effectiveMinPower) sessions fit a slot; the rest shift.
Same semi-continuous rule as evcc-io/optimizer#91.
@ekkea

ekkea commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Will do.

@ekkea

ekkea commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

This will work in most cases, however, there is a corner case why the lines 458 - 477 exist: If charging gets close to s_max, cmay be < c_min to reach s_max in the very last interval of charging. With your change, this cannot happen anymore and the battery will not be fully charged in some cases (I admit, the remaining delta will be very small).

The actual bugs seems to sit in line 463 (p_demand = min(bat.c_max * self.time_series.dt[t] / 3600., bat.p_demand[t])), this needs to be max(), not min().

@ekkea

ekkea commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Let me know if you want me to fix and test this, I can take some time tomorrow night.

@andig

andig commented Jul 20, 2026

Copy link
Copy Markdown
Member Author

Ran both points against the solver. Split result: the s_max corner case is real, the min()/max() swap is not.

1. s_max corner case — confirmed

Probe: s_initial=9000, s_max=10000, c_min=1400 W, p_demand=[2000, 0, 0], 3×1h slots. Headroom is 1000 Wh, below the 1400 Wh that c_min implies for the slot.

main (pre-fix):    charging_power = [512.8, 0.0, 0.0]
PR91 (post-fix):   charging_power = [  0.0, 0.0, 0.0]

So yes — the option-2 disjunction on lines 458-461 is doing exactly what you described, and this PR disables it. The remaining delta is bounded by c_min * dt, so up to 1.4 kWh for an hour slot, not always negligible.

One thing worth weighing though: a wallbox with a 1.4 kW minimum physically cannot deliver 512 W. The pre-fix schedule was one the hardware can't execute. Leaving the last sliver uncharged is arguably the honest answer rather than a regression — the question is which we'd rather return.

Also note s_max is soft (s_max_pen, optimizer.py:421), so the model could overshoot to s + c_min and pay the penalty. It chose 0 instead, meaning the current penalty weighting makes overshoot unattractive. That's a tuning knob if full-charge is the priority.

2. min()max() on line 453 — does not hold

p_demand = min(bat.c_max * dt / 3600., bat.p_demand[t]) clips the requested energy to what the charger can physically deliver in that slot; both sides are Wh. max() inverts the intent: the demand floor becomes at least the full c_max slot capacity, and the caller's actual request is discarded.

Measured on main with only that one character changed:

request: p_demand = [500, 0, 0] Wh
main:          charging_power = [ 500.0, 0.0, 0.0]
main + max():  charging_power = [5128.2, 0.0, 0.0]   <- charges 10x what was asked

pytest: 2 failed, 13 passed  (test_case5, test_case8 — objective value mismatch)

It also doesn't reach the reported bug: in evcc-io/evcc#31894 c_max is far above 950 W, so min() returns the raw demand untouched. The clipping isn't what produces sub-c_min power — the skipped semi-continuous bound is.

Suggested direction

Keep the unconditional semi-continuous bound, but restore the top-off case as a third branch rather than losing it: allow c < c_min only when c exactly fills to s_max, i.e. gate that relaxation on the option-2 binary instead of dropping the bound in every demand slot. That preserves both the physical minimum and the full-charge behaviour you're protecting.

@andig

andig commented Jul 20, 2026

Copy link
Copy Markdown
Member Author

Let me know if you want me to fix and test this, I can take some time tomorrow night.

That would be great- see Claude's comment above. I guess a test case for the corner cases would also be great.

@ekkea

ekkea commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

running out of time here. I will raise another PR as I cannot commit to this one. It will not be ready, still needing a tweak.

@ekkea

ekkea commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

see PR #95 - not ready for merging!

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.

2 participants