Skip to content

feat: add charge_profile for SoC-dependent charge power limits#97

Draft
1va13n7in wants to merge 3 commits into
evcc-io:mainfrom
1va13n7in:feature/charge-profile
Draft

feat: add charge_profile for SoC-dependent charge power limits#97
1va13n7in wants to merge 3 commits into
evcc-io:mainfrom
1va13n7in:feature/charge-profile

Conversation

@1va13n7in

Copy link
Copy Markdown
Contributor

Add CC-CV taper modeling to the optimizer. Batteries can now specify a charge_profile (e.g. lifepo4_conservative) that limits charge power as a function of SoC, matching real-world BMS behavior.

The MILP solver uses iterative constraint tightening: solve, extract SoC trajectory, compute per-slot P_max(SoC) from the profile, add upper bound constraints, re-solve until convergence (max 5 iterations).

New API fields in BatteryConfig:

  • charge_profile: named profile (e.g. lifepo4_conservative)
  • charge_knee: SoC% where taper begins (override)
  • charge_k: exponential decay constant (override)
  • charge_c_rate_float: minimum C-rate at float (override)

New module: charging_profiles.py with profile definitions and max_charge_power() calculation.

Part of evcc-io/evcc#31963

Lars Valentin added 2 commits July 22, 2026 08:02
Add CC-CV taper modeling to the optimizer. Batteries can now specify
a charge_profile (e.g. lifepo4_conservative) that limits charge power
as a function of SoC, matching real-world BMS behavior.

The MILP solver uses iterative constraint tightening: solve, extract
SoC trajectory, compute per-slot P_max(SoC) from the profile, add
upper bound constraints, re-solve until convergence (max 5 iterations).

New API fields in BatteryConfig:
- charge_profile: named profile (e.g. lifepo4_conservative)
- charge_knee: SoC% where taper begins (override)
- charge_k: exponential decay constant (override)
- charge_c_rate_float: minimum C-rate at float (override)

New module: charging_profiles.py with profile definitions and
max_charge_power() calculation.
@andig

andig commented Jul 22, 2026

Copy link
Copy Markdown
Member

Imho we had discussed that this is not worth the cost/benefit ratio?

@1va13n7in

Copy link
Copy Markdown
Contributor Author

The cost is 4 optional API fields and ~65 lines of tightening logic. The benefit: without it, the optimizer plans charge power above 80% SoC that the BMS will reject. The schedule becomes infeasible in the last 20-30%. With charge_profile, the schedule matches what the battery actually accepts.

This is also a prerequisite for solar-first (#98) to produce realistic results.

If the concern is about parameter complexity for end users: the only required field is charge_profile (a single string, e.g. "lifepo4_conservative"). The three override fields are optional and only relevant for custom setups.

@andig

andig commented Jul 22, 2026

Copy link
Copy Markdown
Member

Some notes on the solver side of the iterative tightening.

Cost when unused is zero_has_charge_profiles() gates the whole loop, so existing requests are unaffected. Good.

Cost when used is 3–6 full MILP solves instead of 1. The loop only exits early on n_added == 0, which rarely fires: as long as any slot sits above the knee, a constraint gets added, so termination almost always comes from the trajectory comparison, which needs two consecutive solves within 50 Wh. And these are cold solves — PuLP writes a fresh LP file to the temp dir and restarts CBC each round, so it is roughly Nx the full solve time, not Nx a warm re-optimization.

Worth noting time_limit is applied per solve and defaults to None, so with a profile configured the worst-case wall clock for one request becomes 6 × unbounded. For an HTTP service that is more concerning than the extra rows.

The model grows monotonically. Constraint names carry iter{n}, so each round appends up to n_batteries × n_slots new upper bounds on c[i][t] without removing the previous ones — for a 48 h / 15 min horizon with two batteries that is ~1900 redundant rows by the last iteration. Presolve will eat most of them, but the LP write and presolve cost still scale with it.

The part I would flag beyond performance: constraints are never relaxed. A bound derived from iteration 1's SoC trajectory keeps binding even after later iterations produce a lower SoC at that slot, so the result can end up over-tightened and suboptimal with no signal that it happened. With hard p_demand / s_goal the accumulated stale bounds can also make the model infeasible; the loop then breaks on the non-Optimal status but leaves self.problem.status infeasible, and result extraction runs on that state.

Two directions that avoid the loop entirely:

  • Model the taper as a piecewise-linear curve (SOS2, or big-M linked to SoC bands) so P_max(SoC) is part of a single MILP.
  • Or, much simpler, add 3–4 static SoC-band constraints at build time — the taper is monotonic, so a coarse staircase is a valid upper bound and costs one solve.

If the iterative approach stays, rebuilding the taper_* constraints each round instead of appending would fix the stale-bound issue, and budgeting time_limit / max_taper_iterations per solve would bound the request time.

🤖 Generated with Claude Code

@andig

andig commented Jul 22, 2026

Copy link
Copy Markdown
Member

The cost is 4 optional API fields and ~65 lines of tightening logic.

I disagree. The cost is added complexity and x times solver cost.

The schedule becomes infeasible in the last 20-30%.

i disagree. The schedule becomes inaccurate, but so is the MILP solution in any case. I don't see any indication that this added complexity really pays off.

@andig
andig marked this pull request as draft July 22, 2026 06:48
Replace the iterative constraint tightening (3-6 re-solves) with
piecewise-linear upper bounds added at model build time. The CC-CV
taper curve is convex, so tangent lines at 4 breakpoints (knee to
100% SoC) provide valid linear constraints on charge power as a
function of s[i][t-1]. One solve, no stale bounds, bounded wall clock.
@1va13n7in

Copy link
Copy Markdown
Contributor Author

Valid points, all of them. The stale-bound issue and the unbounded
wall clock are real problems with the iterative approach.

Reworked to use static tangent-line constraints at build time. The
taper curve is convex, so 4 tangent lines from knee to 100% give a
valid piecewise-linear upper bound on charge power as a function of
SoC. One solve, no loop, no stale constraints, no extra binary
variables. The constraints use s[i][t-1] (SoC at slot start) so
they are linear in existing variables.

Pushed the fix to this branch.

@1va13n7in
1va13n7in marked this pull request as ready for review July 22, 2026 14:20
@andig
andig marked this pull request as draft July 23, 2026 10:18
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