Skip to content

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

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

strategy: fill batteries early via an orthogonal battery_first option#126
andig wants to merge 4 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 and prc_p_ramp bound how high the grid profile may go and how fast it may move. They say 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 batteriescharge_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.

battery_first 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
  "battery_first": true,                          // when to fill
  "discharging_strategy": "discharge_before_import"
}

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

Precedence: attenuation outranks battery_first

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 if 'imp' in self.peak_sides else early_unprotected
charging_strategy levels feed-in peak fills (with battery_first)
none spent full rate
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

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/battery_first), 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 009023 are stale, and origin/main reproduces the same 7 deviations against them. Only 024027 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 battery_first: 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

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

reduce_grid_feedin-2 first charge half charged
battery_first: false step 17 27.5 h
battery_first: 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, 13 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 + battery_first 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 three attenuation strategies
  • both tie breaks cost neutral, via s0-insensitive economics

110 pass, 024027 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.

Side note, not fixed here

get_clean_objective_value() values the horizon as s[T-1] - s[0], but s[0] is the state after the first step, so energy charged in step 0 is dropped from the reported objective_value. Moving a charge into step 0 lowers the reported number while real money is unchanged — 6.2525 → 6.2126 on reduce_grid_feedin-2, against identical real money. The baseline should be s_initial.

🤖 Generated with Claude Code

andig and others added 2 commits July 30, 2026 12:17
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>
@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
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>
@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
…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>
@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
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.

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