strategy: fill batteries early via an orthogonal battery_first option - #126
Open
andig wants to merge 4 commits into
Open
strategy: fill batteries early via an orthogonal battery_first option#126andig wants to merge 4 commits into
andig wants to merge 4 commits into
Conversation
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>
blampe
reviewed
Jul 30, 2026
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
|
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 — thenoneanswer.Reintroduces #125, which fixed the export side but was merged into
perf/two-stage-solverather thanmain, 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_peakandprc_p_rampbound 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_strategywas answering two questions at onceattenuate_*valuescharge_before_exportSharing 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_firstis now its own boolean, orthogonal tocharging_strategy, andcharge_before_exportis removed — it was exactly no profile shaping plus that option, so it is spelled that way:charge_before_exportis gone from the enum; senders migrate tocharging_strategy: "none"+battery_first: true. The 14 stored cases that used it are converted, andclient.gen.gois regenerated.Precedence: attenuation outranks
battery_firstFilling 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:
charging_strategybattery_first)noneattenuate_demand_peaksattenuate_feedin_peaksattenuate_grid_peaksVerification
The refactor is provably exact. With the import-side term disabled, all 19 stored cases reproduce
origin/main'scharge_before_exportschedules to 0.00 W. Getting there required preserving two artifacts of the old term, both now documented at the weight:(T - t)coefficient, un-normalisedfor i, bat in enumerate(self.batteries)but never referencediorbat, 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_exportvs 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
009–023are stale, andorigin/mainreproduces the same 7 deviations against them. Only024–027arestrict; for the rest just status andobjective_valueare contractual, andobjective_valuestrips preference terms, so it cannot see a tie-break shift. Comparisons here are head-to-head againstmain, 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_strategybattery_first: falsetruenoneattenuate_demand_peaksattenuate_feedin_peaksattenuate_grid_peaks(
reduce_grid_feedin-2, 192 steps;020at 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_firstdefaults to false, so a request must now ask for the early charge:reduce_grid_feedin-2battery_first: falsebattery_first: trueThis means the reported symptom is only fixed once evcc sends the flag. Defaulting to
truewould fix it for everyone but also switch it on forcharging_strategy: none, which does not fill early today. It is one constant either way — say which you want.Separately,
attenuate_feedin_peaksdeliberately 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_firstfills in the first two steps and exports the rest — whatcharge_before_exportmeantattenuate_feedin_peaksstill flattens export to half the surplus; levelling keeps the last wordattenuate_feedin_peaksfills immediately (fails withprc_n_earlyzeroed, verified)attenuate_demand_peakskeeps its flat import profilecharging_strategyvalues110 pass,
024–027strict goldens unchanged,ruff checkclean,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 ass[T-1] - s[0], buts[0]is the state after the first step, so energy charged in step 0 is dropped from the reportedobjective_value. Moving a charge into step 0 lowers the reported number while real money is unchanged — 6.2525 → 6.2126 onreduce_grid_feedin-2, against identical real money. The baseline should bes_initial.🤖 Generated with Claude Code