Skip to content

battery c min is ignored if charge demand p demand is greater than zero#95

Open
ekkea wants to merge 9 commits into
evcc-io:mainfrom
ekkea:19-battery-c_min-is-ignored-if-charge-demand-p_demand-is-greater-than-zero
Open

battery c min is ignored if charge demand p demand is greater than zero#95
ekkea wants to merge 9 commits into
evcc-io:mainfrom
ekkea:19-battery-c_min-is-ignored-if-charge-demand-p_demand-is-greater-than-zero

Conversation

@ekkea

@ekkea ekkea commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Alternative fix to PR #91

NOT ready for merging!

@andig

andig commented Jul 21, 2026

Copy link
Copy Markdown
Member

I checked this branch out and ran it against #91. Summary up front: the optimizer change does not enforce c_min — it only nudges a soft target — and the new line has a copy/paste bug that silently disables the existing c_max clip. The testbench.py change is a genuine unit fix and worth keeping regardless.

1. The min() result is discarded

# clip requested charging power to max charging power if needed
p_demand = min(bat.c_max * self.time_series.dt[t] / 3600., bat.p_demand[t])
# clip requested charging power to min charging power where needed
p_demand = max(bat.c_min * self.time_series.dt[t] / 3600., bat.p_demand[t])

The second line reads bat.p_demand[t] again instead of the just-computed p_demand, so the c_max clip from the previous line is thrown away. Whenever p_demand[t] > c_max * dt/3600, the constraint now targets an energy the battery physically cannot reach in that slot, so p_demand_pen is forced positive and the objective carries a permanent penalty offset it did not carry before. The observable schedule often survives (because c is separately bounded by c_max), which is exactly what makes this the kind of bug that slips through CI.

If the approach is kept at all, it needs to be a nested clip:

p_demand = max(bat.c_min * self.time_series.dt[t] / 3600.,
               min(bat.c_max * self.time_series.dt[t] / 3600., bat.p_demand[t]))

2. Raising the target does not enforce the minimum

The constraint this touches is soft:

self.problem += (self.variables['c'][i][t] + self.variables['p_demand_pen'][i][t]
                 + self.M * self.variables['z_p_demand'][i][t] >= p_demand)

p_demand_pen absorbs any shortfall, so c remains free to take any value in (0, c_min) — raising the right-hand side to c_min only makes that choice more expensive, never infeasible. As soon as reaching c_min is impossible or merely uneconomical, the solver returns a sub-minimum power again.

Measured on this branch, c_min = 1400 W, 3 × 1 h slots, no solar, grid at 0.30 €/kWh, p_a = 0, compared against #91:

scenario this PR #91
headroom 800 Wh (s_max=800), demand 500 Wh 410 W 0 W
headroom 1000 Wh, demand 1400 Wh 513 W 0 W
charge_from_grid: false, 400 Wh solar, demand 500 Wh 400 W 0 W
demand 500 Wh (ample headroom) 1400 W 1400 W
demand 500 Wh in all three slots 1400/1400/1400 W 1400/1400/1400 W

The last two rows are the cases this PR does fix; the first three are the ones it does not. #91 returns 0 there because the semi-continuous bound (c == 0 or c >= c_min, via the existing z_c binary) is a hard constraint, so "cannot reach the minimum" correctly resolves to "do not charge" rather than "charge something impossible". Solar-limited charging is not an exotic case — it is the everyday PV-surplus scenario, and it is where a wallbox that cannot run below ~1.4 kW matters most.

3. The new test case bakes the remaining defect into the expected output

test_cases/023-c_min-limit-kept-with-p_demand-set.json has c_min = 1380, and expected_response.batteries[0].charging_power contains at indices 18 and 19 (both dt = 3600, both with p_demand = 4000):

1019.4 Wh  ->  1019 W
 536.5 Wh  ->   537 W

Both are below c_min. As a snapshot comparison this locks the bug in as the expected result — a future correct fix would have to change this file to land. (Index 0 is fine: dt = 31 s, so 31.7 Wh is 3680 W.) If the case is kept, it should assert the invariant (c == 0 or c >= c_min * dt/3600 for every slot) rather than pin exact values.

4. testbench.py is correct and unrelated

ts_Wh_to_kW = 3.6 / dt followed by multiply converts Wh-per-slot to kW properly; the old divide(x, dt) produced Wh/s and was plotted under axes already labelled [kW]. Purely a diagram fix, no effect on any assertion (ts_grid_dev is a ratio, so the scaling cancels). Worth splitting into its own PR so it can merge immediately.

5. On CI being green

The suite passing is not evidence here — nothing in it asserted c_min in demand slots before #91 added tests/test_semicontinuous.py. I dropped that test file onto this branch as well and it passes, because it only covers the ample-headroom case (row 4 above). The three failing scenarios above need explicit coverage either way.

Recommendation

Take #91 for the optimizer change and cherry-pick the testbench.py unit fix out of this branch. If you prefer the target-clipping formulation for other reasons, it still needs the hard semi-continuous bound underneath it — the two are complementary, not alternatives: the bound guarantees feasible power levels, the clip keeps the soft target reachable so the penalty term stays meaningful.

@ekkea

ekkea commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

@andig, sorry this one is not ready, I'm aware it still has a flaw. Sorry for creating confusion ... I'm still working on it.

@ekkea

ekkea commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

I have a solution but this is changing results in other test cases ... I want to understand why they changed before I commit it.

@andig

andig commented Jul 21, 2026

Copy link
Copy Markdown
Member

Sorry, didn't want to pressure you. Thought Claude might help with its comments.

@andig andig added the bug Something isn't working label Jul 21, 2026
@ekkea

ekkea commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Fixing this bug reveals another problem: If a charge demand that cannot be fulfilled 100% is set for a battery that can discharge, the optimizer will start playing around with charging and discharging the battery to minimize the penalty for not reaching the charge demand target.

Question: is it a requirement to set charge demands on batteries that can discharge (maybe not home batteries but bidirectionally chargeable vehicles)?

@andig

andig commented Jul 21, 2026

Copy link
Copy Markdown
Member

At least not at the moment- we don't have bidi support and home batteries don't have charging targets.

Eventually I would expect that a charging demand is fulfilled even if it requires battery discharging if no other source of energy is available. Does that make sense?

@ekkea

ekkea commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

OK, I think I figured it out, also for the case that batteries can be discharged. The trick was to deacivate the penalty for not meeting the charge demand once the battery is full.

@andig, Plesse check.

@ekkea

ekkea commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Eventually I would expect that a charging demand is fulfilled even if it requires battery discharging if no other source of energy is available. Does that make sense?

Yes, absolutely. I think that requirement is met but I shall create a test case to make sure.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants