Skip to content

strategy: fill batteries early via an orthogonal charge_early option - #126

Open
andig wants to merge 8 commits into
mainfrom
fix/level-strategies-charge-early-main
Open

andig wants to merge 8 commits into
mainfrom
fix/level-strategies-charge-early-main

Conversation

@andig

@andig andig commented Jul 30, 2026 •

Copy link
Copy Markdown
Member

A levelled day used to sit on an empty battery. On reduce_grid_feedin-2.json (192 steps, attenuate_demand_peaks, flat 32.5 ct) the battery held its initial charge for 27 hours and first charged at step 17 — the none answer.

Reintroduces #125, which fixed the export side but was merged into perf/two-stage-solve rather than main, so it never applied here. This PR ports it, adds the import side, and then separates the two questions that were tangled in one enum.

Why it happened

prc_p_peak bounds how high the grid profile may go. It says nothing about when the batteries fill, and on a flat tariff no price decides it either, so every schedule inside that envelope scores the same and the solver returns whichever it reaches first.

charging_strategy was answering two questions at once

  • what to shape about the grid profile — the three attenuate_* values
  • when to fill the batteries — charge_before_export

Sharing one enum meant the two could never be asked together: 5 of 8 combinations were reachable, and the peak strategies had to smuggle earliness in implicitly.

charge_early is now its own boolean, orthogonal to charging_strategy, and charge_before_export is removed — it was exactly no profile shaping plus that option, so it is spelled that way:

"strategy": {
  "charging_strategy": "attenuate_feedin_peaks",  // what to shape
  "charge_early": true,                          // when to fill
  "discharging_strategy": "discharge_before_import"
}

⚠️ Breaking. charge_before_export is gone from the enum; senders migrate to charging_strategy: "none" + charge_early: true. The 14 stored cases that used it are converted, and client.gen.go is regenerated.

Precedence: attenuation outranks charge_early

Filling sooner is not free on either side — a battery full by the first hour has no room left for the midday solar peak, and one filled at full power draws a taller import peak than one trickled. So where the two disagree, the profile the request asked to level is the one that survives:

early_unprotected = self.min_import_price * 2e-5 * self.T * len(self.batteries)
early_protected = self.penalty_base * 1e-7
self.prc_e_early = early_protected if 'exp' in self.peak_sides else early_unprotected
self.prc_n_early = early_protected

Import earliness is always a pure tie break. Since main solves cost and preference jointly in the probe, the unprotected import weight bought earliness with real money: case 009 lost 0.37 ct. Pulling import forward buys a different price, unlike deferring export.

charging_strategy levels feed-in peak fills (with charge_early)
none — spent full rate from surplus
attenuate_demand_peaks import spent full rate
attenuate_feedin_peaks feed-in protected at levelling's rate
attenuate_grid_peaks both protected at levelling's rate

Rebase onto main (2026-10-23)

  • Earliness terms moved into the separate preference objective; the ramp term no longer exists on main, so the protected weight is now four orders below prc_p_peak.
  • explain.py labels the option, test_objective_scaling.py runs it instead of the removed enum value.
  • Option renamed from battery_first to charge_early: in evcc "battery first" means home battery before vehicle, and the name did not say first against what.
  • 298 tests pass; 020-weird-charging-at-night fails its objective_value on this Mac on clean main too.

The measurements below are from before the rebase.

Verification

The refactor is provably exact. With the import-side term disabled, all 19 stored cases reproduce origin/main's charge_before_export schedules to 0.00 W. Getting there required preserving two artifacts of the old term, both now documented at the weight:

  • its (T - t) coefficient, un-normalised
  • its accidental multiplication by the battery count — the term sat inside for i, bat in enumerate(self.batteries) but never referenced i or bat, so a two-battery request weighted it twice. Preserved to keep this a pure refactor; it is a latent bug worth removing on its own, which will move those schedules.

Head to head (origin/main + charge_before_export vs this branch + none/charge_early), 6 of 19 cases move by up to 689 W — entirely from the import-side earliness added earlier in this series — with real money identical to 8 decimals.

Note on method: the stored responses for cases 009–023 are stale, and origin/main reproduces the same 7 deviations against them. Only 024–027 are strict; for the rest just status and objective_value are contractual, and objective_value strips preference terms, so it cannot see a tie-break shift. Comparisons here are head-to-head against main, not against the stored files.

Solve time does not regress — the risk was that combining earliness with levelling, previously unreachable, would compound the known tie-break cost:

charging_strategy charge_early: false true
none 0.25 s 0.18 s
attenuate_demand_peaks 0.23 s 0.21 s
attenuate_feedin_peaks 2.52 s 2.92 s
attenuate_grid_peaks 4.06 s 1.93 s

(reduce_grid_feedin-2, 192 steps; 020 at 154 steps is faster with the flag in all four rows.) The earliness gradient gives CBC something to prune instead of a flat plateau.

The default, and what it costs

charge_early defaults to false, so a request must now ask for the early charge:

reduce_grid_feedin-2 first charge half charged
charge_early: false step 17 27.5 h
charge_early: true step 0 1.5 h

This means the reported symptom is only fixed once evcc sends the flag. Defaulting to true would fix it for everyone but also switch it on for charging_strategy: none, which does not fill early today. It is one constant either way — say which you want.

Separately, attenuate_feedin_peaks deliberately does not fill fast even with the flag: it keeps a 2936 W feed-in peak and reaches half charge at 3.1 h. Buying 1.1 h instead costs 22% of the peak the strategy exists to protect (3597 W), money unchanged. That is the precedence working as asked.

Tests

tests/test_early_charge.py, 17 tests — four steps of surplus for the export side, eight grid-only steps for the import side, eight mixed steps where the sides pull against each other, flat prices throughout.

  • none + charge_early fills in the first two steps and exports the rest — what charge_before_export meant
  • attenuate_feedin_peaks still flattens export to half the surplus; levelling keeps the last word
  • grid-only attenuate_feedin_peaks fills immediately (fails with prc_n_early zeroed, verified)
  • grid-only attenuate_demand_peaks keeps its flat import profile
  • mixed: solar absorbed at once, the grid energy following it stays flat
  • without the option, a peak strategy sits on the empty battery again — guards against it being implied
  • the option combines with all four charging_strategy values
  • the weight rule itself, parametrized over all charging strategies
  • both tie breaks cost neutral, via s0-insensitive economics

110 pass, 024–027 strict goldens unchanged, ruff check clean, go build ./client/... ./cmd/... clean.

Companion change: #125 reverted on perf/two-stage-solve (bff2e84) so the fix is not carried twice once that branch merges.

🤖 Generated with Claude Code

@andig andig changed the title fix: let the peak strategies charge before they export fix: let the peak strategies charge early, on both grid sides Jul 30, 2026
@andig andig changed the title fix: let the peak strategies charge early, on both grid sides fix: let the peak strategies fill their batteries early Jul 30, 2026
@andig andig changed the title fix: let the peak strategies fill their batteries early refactor!: fill batteries early via an orthogonal battery_first option Jul 30, 2026
@andig andig changed the title refactor!: fill batteries early via an orthogonal battery_first option strategy: fill batteries early via an orthogonal battery_first option Jul 30, 2026
Comment thread src/optimizer/optimizer.py Resolved
iseeberg79 added a commit to iseeberg79/optimizer that referenced this pull request Jul 31, 2026
…obal flag

Adds a per-battery BatteryConfig.battery_first bool: when set, a small
tie-break term rewards charging this battery as early as possible,
weighted well below any real economic term (price arbitrage,
prc_dpl_soc_high/low), so it only resolves cases the strategy/economics
leave genuinely tied - e.g. a battery idling mid-band (20-80% SOC) for
no real reason, deferring a full charge to a later day.

Built independently of evcc-io#126 rather than cherry-picked:
that PR makes battery_first a global charging_strategy-level flag and
removes charge_before_export from the enum (a breaking change evcc
doesn't need), and its per-battery-vs-global default question is still
unresolved upstream. Scoping it per-BatteryConfig from the start sidesteps
both: evcc can default it true for home batteries and leave it unset for
EV/loadpoint entries, without touching charging_strategy at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXmAz8YRFAKgg8ZjA2daCh
iseeberg79 added a commit to iseeberg79/evcc that referenced this pull request Jul 31, 2026
Sets BatteryConfig.BatteryFirst = true for home battery requests
(batteryRequest), deliberately deviating from the optimizer's own
default of false. Not set for EV/loadpoint requests (loadpointRequest)
since those may unplug before a deferred full charge completes, unlike
a home battery that's always available.

See ../optimizer's battery_first tie-break (independent of
evcc-io/optimizer#126, which ties the same idea to a breaking
charging_strategy change we don't need).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXmAz8YRFAKgg8ZjA2daCh
@iseeberg79

iseeberg79 commented Jul 31, 2026 •

Copy link
Copy Markdown

Like that approach. It's a missed detail. On same costs it's preferred to have the energy stored.

Imho it pairs perfectly with #62, not only as alternative. The battery operates between min/max SoC if cost neutral, buffering unplanned household consumption.

andig and others added 6 commits October 23, 2026 17:40
The peak weights say how high the grid profile may go, not when the
batteries fill. Everything below the cap and inside the ramp is rated
equally, so on a flat tariff, where no price decides it either, the
schedule that comes back is as arbitrary as with no strategy at all.

So the peak strategies defer export as well, the tie break
charge_before_export already makes, at prc_e_early = penalty_base * 1e-7.
That is two orders below the ramp weight, so it can only pick between
schedules the leveling rates equal.

Ported from #125 (merged into perf/two-stage-solve instead of main) onto
main's single-objective structure; test_early_charge.py's cost-neutrality
check uses s0-insensitive economics from the result instead of the
two-stage branch's cost_objective, which main does not have.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
prc_e_early moves a schedule by making early export expensive, which only
reaches a battery that has surplus to hold back. A battery charging purely
from the grid sees e[t] = 0 whatever the timing, so nothing decided when it
filled: with attenuate_feedin_peaks, which levels the feed-in side only and
leaves the import profile entirely open, the charge landed in scattered late
steps with the battery empty in between ([0, 0, 0, 2000, 0, 0, 0, 2000] over
eight steps).

prc_n_early mirrors the tie break onto import at the same weight, penalizing
import that lands late. Measured on the captured cases, real money is
unchanged to six decimals on all four, and the stored 024-027 attenuate cases
are untouched.

Note what this does not do: on the side a strategy actually levels it stays
inert, because a flat profile already is the lowest peak - attenuate_demand_peaks
spreads its import over the whole horizon rather than taking it early, and the
tie break is correctly too weak to buy earliness with peak. Where an explicit
discharging strategy pushes import the other way, discharge_before_import
outweighs this by ~50x and keeps precedence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two earliness tie breaks sat two orders below the ramp weight on both
sides, so filling the battery early won only where nothing competed. That is
the right precedence on a side the strategy levels, and the wrong one
everywhere else: attenuate_demand_peaks levels the import side only, and it was
letting a feed-in peak it was never asked to shave hold its battery back.

The weight is now picked per side from whether that side is being levelled.
Where it is, the tie break stays below prc_p_ramp and levelling keeps the last
word. Where it is not, earliness goes two orders above and takes the peak
outright.

Measured on the captured cases, real money is unchanged on all four and the
strict 024-027 goldens are untouched, because the sides that changed weight had
no peak variables to compete with. What the rule buys is that the outcome no
longer depends on nothing else bidding: with early solar plus grid charging, the
surplus is absorbed the moment it arrives while the import that follows it stays
flat, and that holds at either weight only because the rule now says which.

attenuate_feedin_peaks deliberately keeps its 2936 W feed-in peak on
reduce_grid_feedin and fills at 3.1 h rather than 1.1 h. Buying that earliness
costs 22 percent of the peak the strategy exists to protect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rst option

charging_strategy conflated two independent questions: what to shape about the
grid profile, and when to fill the batteries. charge_before_export answered only
the second, the three attenuate values only the first, and because they shared
one enum the two could never be asked together - so 5 of 8 combinations were
reachable and the peak strategies had to smuggle earliness in implicitly.

battery_first is now its own boolean, so it combines with any charging_strategy,
and charge_before_export is removed: it was exactly "no profile shaping plus
that option" and is spelled that way now. The 14 stored cases using it are
converted.

Precedence is unchanged and now explicit: attenuation outranks battery_first.
A side an attenuation strategy levels keeps its peak and the earliness weight
stays below prc_p_ramp; a side nothing levels has no peak worth protecting, so
earliness takes it outright.

Verified a pure refactor on the export side: with the import term disabled, all
19 stored cases reproduce origin/main's charge_before_export schedules to 0.00 W.
That required keeping two artifacts of the old term, the (T - t) coefficient and
its accidental multiplication by the battery count, both documented at the
weight. Head to head, 6 cases move by up to 689 W, entirely from the import side
earliness added earlier in this series, with real money identical to 8 decimals.

Solve time does not regress: measured over every strategy times the flag on two
requests of 192 and 154 steps, battery_first=true is mostly faster
(attenuate_grid_peaks 4.06 s to 1.93 s), worst case plus 16 percent. The
earliness gradient gives CBC something to prune instead of a flat plateau.

The option defaults to false, so a request must now ask for the early charge:
on reduce_grid_feedin-2 that is step 17 and 27.5 h without it, against step 0
and 1.5 h with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
On current main the joint probe optimizes cost and preference in one solve,
so the unprotected import weight bought earliness with real money: case 009
lost 0.37 ct. Import earliness now always uses the protected weight; the
export side keeps the charge_before_export coefficient.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@andig
andig force-pushed the fix/level-strategies-charge-early-main branch from 3284c01 to 33404b6 Compare October 23, 2026 16:05
battery_first collides with evcc's home battery before vehicle priority and
does not say first against what.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@andig andig changed the title strategy: fill batteries early via an orthogonal battery_first option strategy: fill batteries early via an orthogonal charge_early option Oct 23, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

3 participants