Skip to content

feat(octopus): gate IOG dispatch-slot trust on a real car_charging_now sensor (#4482, #4516) - #5110

Closed
chalfontchubby wants to merge 9 commits into
mainfrom
fix/iog-dispatch-slot-trust-v2
Closed

chalfontchubby wants to merge 9 commits into
mainfrom
fix/iog-dispatch-slot-trust-v2

Conversation

@chalfontchubby

@chalfontchubby chalfontchubby commented Sep 15, 2026 •

Copy link
Copy Markdown
Collaborator

(Written by Claude, working with @chalfontchubby.)

Supersedes #4885, which was closed on two objections. Both are addressed here — the first with measurements rather than argument.

This is a defensive change. It trades away some upside to remove a downside: distrusting a slot that would have materialised costs a cheap window you could have used, but trusting one that doesn't costs real money — either an irreversible decision (an early force-export) made around a window that vanishes, or an import billed at full peak. Anyone who prefers the upside stays on the default, which trusts everything exactly as today.

The objections, answered

"It takes 5-10 minutes based on a sensor to figure out if the car is really charging."

Measured on a live system (Sigenergy + Hypervolt + VW ID.3), from the Predbat log:

Stage Measured
car_charging_now changes → Predbat writes to the inverter 17-40s
Register write → inverter physically stops discharging 36-65s
Total, from a locally-reported charging state under 2 minutes

The 5-minute figure assumes the scheduled run_time_loop cycle. It doesn't apply: a watched entity sets update_pending, and update_time_loop polls that every 15 seconds and replans out of cycle. I had this wrong myself until I measured it.

A trigger routed via the Octopus app (a boost) adds ~1:35-1:56 of cloud round-trip, but that's upstream of Predbat either way.

"It might not detect it, or falsely detect say a kettle as the car."

Agreed — so started now requires a real car_charging_now sensor for that car: a charger/car-reported signal, never inferred from a load signature, so there's no kettle ambiguity. Without one it falls back to none, decided per car, with a startup warning.

What it does

Two independent gates a future out-of-window slot must both clear. They answer different questions and can be used together or separately:

  • switch.predbat_octopus_intelligent_limit_future_slots (default Off) — does the car still need this slot? Requires octopus_intelligent_consider_full; warns at startup if enabled without it.

  • select.predbat_trust_future_dynamic_iog_slots (default planned) — will Octopus honour it?

    • planned — trust every dynamic slot. Today's behaviour.
    • started — trust a slot once car_charging_now corroborates it (a "trusted streak" - see Notes for review below). Falls back to none for a car with no such sensor.
    • none — trust nothing outside the fixed window.

    There's deliberately no separate completed level waiting on Octopus's own metered record alone: that record only appears once a slot has already ended (and can lag by up to an hour), so it could never arrive in time to influence a planning decision about the slot it confirms. started still accepts it as one form of evidence where available - it's just not a selectable level on its own.

The fixed 23:30-05:30 window is never gated — it's cheap by tariff structure, not by dispatch.

Both defaults preserve current behaviour. A fresh install or upgrade sees no plan change until someone opts in, per your condition last round.

On Predbat.com the gateway auto-wires car_charging_now from the charger's own OCPP session state, so started works there too. Gateway users with gateway_evc_control don't get that sensor (it's omitted to avoid a feedback loop) and fall back to none for that car — but if Predbat is driving the charger then Octopus isn't dispatching it, so there are no dynamic slots to gate anyway.

Related issues

Several open issues describe this same exposure from different angles. Flagging them together since some may be answerable by this, and others are explicitly out of scope:

On #4482

limit_future_slots addresses #4482's failure mode — the house battery no longer defers charging into slots outside what the car still needs — but it is not the implementation that issue asks for. It reuses the car_charging_slots cap rather than computing remaining EV energy from car_charging_soc/battery_size/limit directly, and so depends on consider_full, which #4482 explicitly notes isn't sufficient on its own. So this mitigates it rather than closing it, and I've left the issue open for the reporter to judge.

The structure is a single predicate per slot, so the stronger version builds on this rather than replacing it — #4482's own SoC calculation would swap out how the expected-slot set is derived. Time-bounded trust ("trust a slot starting within the next hour or so") would be another clause on the same decision. Out of scope here.

Notes for review

  • car_charging_now confirms a whole 30-minute settlement slot as a "trusted streak", not a single reading. get_arg() re-samples live state on each replan rather than consuming the edge that woke it, so a short pulse can be missed entirely rather than merely seen late. A streak rolls forward until an explicit "not charging" reading, which also tolerates a charger toggling at a settlement boundary — observed live as a readiness sensor flipping Charging→Ready→Charging within two minutes, negotiation noise rather than a real stop. A start guard stops a reading in a slot's closing minutes confirming the next slot; a 4-slot cap bounds how far a streak rolls without fresh evidence, so a missed negative edge degrades to untrusted rather than silently behaving as planned. Both constants are judgement calls, not measured — happy to tune.
  • Rejecting a slot actively restores rate_max_base rather than just declining to discount it, because on a real Intelligent tariff fetch_octopus_rates() can deliver the discounted rate straight from the feed. exclude_dynamic_io_slots() handles that feed-side case, consuming the same trust decisions so the two can't disagree. Both touch only future minutes — an elapsed minute records what the tariff actually charged, and rewriting it would inflate today_cost() without changing a plan that never looks behind minutes_now.
  • feat(octopus): log a diagnostic IOG dispatch timeline (#4516 Stage 1) #4948 has since landed the dispatch-timeline diagnostic fix(octopus): don't rely on unconfirmed or unneeded IOG dispatch slots (#4482, #4516) #4885 also carried, so only the trust logic is new.

Closes #4516. Addresses #4482.

Tests: 26 new/reworked cases across test_rate_add_io_slots.py, test_fetch_config_options.py and a new test_car_charging_now_confirmed_slots.py. Full quick suite and pre-commit green.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Two critical and three moderate findings remain unresolved.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds opt-in safeguards for trusting future Octopus Intelligent dispatch slots, including charging-sensor confirmation and configurable need/trust gates.

Changes:

  • Adds configurable dynamic-slot trust and EV-need gates.
  • Tracks per-car car_charging_now confirmation streaks.
  • Updates rate handling, configuration, documentation, runtime state, and tests.
File summaries
File Reviewed changes and findings
docs/car-charging.md Documents the new controls. Nit (3 votes): clarify that none keeps completed dispatches at the normal rate.
apps/predbat/unit_test.py Registers the new test suite. No final comments.
apps/predbat/tests/test_rate_add_io_slots.py Adds rate-gating tests. Nit (1 vote): add coverage for the feed-side exclusion path.
apps/predbat/tests/test_fetch_config_options.py Adds configuration tests. No final comments.
apps/predbat/tests/test_car_charging_now_confirmed_slots.py Tests charging confirmation streaks. Moderate (1 vote): isolate or fully restore shared fixture state.
apps/predbat/predbat.py Initializes new runtime state. No final comments.
apps/predbat/octopus.py Implements trust-gated rate handling. Critical (1 vote): untrusted overlaps can block later trusted slots. Moderate (1 vote): cap-rejected slots can lose valid feed discounts. Nit (1 vote): feed-side exclusion lacks regression coverage.
apps/predbat/fetch.py Adds sensor detection and configuration handling. Critical (1 vote): confirmation changes may not update the change signature or force replanning. Moderate (1 vote): zero-car configurations can record a spurious error.
apps/predbat/const.py Defines confirmation timing constants. No final comments.
apps/predbat/config.py Adds expert configuration options. No final comments.
Review details

Suppressed comments (5)

apps/predbat/fetch.py:2539

  • The raw-config check treats any scalar value as a configured sensor, including the valid fixed sensor_type: string|boolean values such as car_charging_now: "yes". With started, that constant produces trusted streaks without any charger/car sensor and can make provisional slots cheap; require a non-empty entity-id string (and the same check for list entries) before returning True.
        configured = self.args.get("car_charging_now", None)
        if configured is None:
            return False
        if not isinstance(configured, list):
            # A scalar (un-indexed) value applies to every car, not just car 0: resolve_arg() only
            # indexes when the configured value is itself a list (userinterface.py - "isinstance(
            # value, list) and index is not None"), so a single sensor resolves identically for
            # every car_n. Returning car_n == 0 here would wrongly degrade car 1+ to "completed" on
            # a single-sensor multi-car install whose sensor does in fact cover them.
            return True
        return car_n < len(configured) and configured[car_n] not in (None, "")

apps/predbat/fetch.py:3262

  • num_cars explicitly supports zero (config.py:2684), but this loop forces a fictitious car 0 and records had_errors=True whenever a car-less install selects started. Since rate_add_io_slots() never processes any car when num_cars == 0, this creates a spurious error on an otherwise valid configuration; iterate only over range(self.num_cars).
            for car_n in range(max(self.num_cars, 1)):
                if not self.has_car_charging_now_sensor(car_n):

apps/predbat/octopus.py:3574

  • This strips the feed-side discount from a future slot rejected only because octopus_slot_max was already reached. rate_add_io_slots() intentionally does not add cap-rejected minutes to trusted_dynamic_minutes when needed and trusted are both true (lines 3476-3517), so this loop cannot distinguish that case from an untrusted/withdrawn slot and restores rate_max_base anyway. That contradicts the documented cap-only exemption and makes the stricter trust modes overrule a genuine dispatch solely because Predbat's local budget was full; preserve cap-only minutes separately (or otherwise carry that reason through before stripping feed adjustments).
        for minute in list(self.io_adjusted.keys()):
            if minute < self.minutes_now:
                continue
            if self.io_adjusted[minute] and not self.minute_in_iog_fixed_window(minute) and minute not in self.trusted_dynamic_minutes:
                rates[minute] = self.rate_max_base

apps/predbat/octopus.py:3542

  • The new feed-side exclusion path is not exercised by the added tests: test_rate_add_io_slots.py calls rate_add_io_slots() directly and manually sets io_adjusted, but no test calls exclude_dynamic_io_slots() or the full fetch pipeline. This leaves the safety-critical case where a future io_adjusted minute is restored and its marker cleared, while past, fixed-window, and trusted minutes remain untouched, unverified. Add a focused regression test.
    def exclude_dynamic_io_slots(self, rates):

apps/predbat/tests/test_car_charging_now_confirmed_slots.py:317

  • This test restores only a few fields, but each get_car_charging_planned() call overwrites the shared fixture's car_charging_planned, plan arrays, battery size/limit/rate, charging slots, exclusivity, and related state. The final call rebuilds those values from old_args rather than restoring values set by the preceding test; unit_test.py reuses one PredBat instance for the registry, so later tests can inherit this mutation. Snapshot and restore all mutated fields or use an isolated fixture.
    my_predbat.get_car_charging_planned()
  • Files reviewed: 10/10 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread apps/predbat/fetch.py
Comment thread apps/predbat/octopus.py
Comment thread docs/car-charging.md Outdated
@springfall2008

Copy link
Copy Markdown
Owner

completed — require Octopus's own metered record.

Not sure this makes sense as completed is strictly after the slot finishes?

@chalfontchubby
chalfontchubby force-pushed the fix/iog-dispatch-slot-trust-v2 branch 2 times, most recently from 7d94d81 to 5f8748b Compare September 16, 2026 08:04
@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

(Written by Claude, working with @chalfontchubby.)

You're right, and it's worse than a wording problem - completed couldn't do anything useful. The record only appears after the slot has ended, planning never looks behind minutes_now, so the trust arrived too late to affect any decision about the slot it confirmed. #4917 measured that record lagging a slot's end by 0-60 minutes as well, since Octopus batch-refreshes it rather than updating live. For planning purposes it was none with a misleading name.

Dropped it. The ladder is now:

  • none - trust no daytime dispatch
  • started - trust a slot car_charging_now corroborates, or one Octopus has already recorded as completed
  • planned - trust everything (default, unchanged)

started still accepts the completed record where it exists - it's strictly stronger evidence than a live sensor reading, and costs nothing to honour on an elapsed slot. It just isn't something worth waiting for, which is what the old level did.

One consequence worth flagging: started without a car_charging_now sensor now falls back to none rather than completed. That's a bigger behaviour change for that case than before, but it's the honest one - falling back to a level that never confirms anything in time would have been the same outcome dressed up as something milder. The startup warning says none now.

Docs updated, including a note on why there's no completed-only level so this doesn't get proposed again. Tests 31/32/35/36/40 reworked onto started; full quick suite and pre-commit green.

@chalfontchubby
chalfontchubby force-pushed the fix/iog-dispatch-slot-trust-v2 branch from 5f8748b to 361cbfe Compare September 16, 2026 08:10
@Speshman

Speshman commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

I found a concrete order-dependent failure, although the original 30-minute planned -> add_now example does not reproduce as stated.

At the default 30-minute interval, car_charging_now_confirmed_slots makes the earlier planned entry trusted for the same block, so it applies the low rate before add_now is reached. Completed records are also appended before planned records, so the reverse _confirmed=False -> True order is not currently emitted by fetch_sensor_data_cars().

Partial overlap does expose the problem with the supported plan_interval_minutes: 15 configuration:

  • now: 14:00, confirmed block {840}
  • planned slot first: 14:15-14:30
  • add_now slot second: 14:00-14:30
  • low/max rates: 4p/10p

The planned slot claims minutes 855-869 in saved_slots, but because its range contains no minute % 30 == 0, it never adds block 840 to slots_added_set. The later add_now slot prices 840-854 at 4p, then skips 855-869 because they are already saved. Observed output is therefore 4p for 14:00-14:15 and 10p for 14:15-14:30; reversing the input order gives 4p for the full block.

A direct unconfirmed-then-confirmed duplicate also reproduces the original gate-ordering issue, though current fetch ordering prevents that input.

So the exact rationale should be narrowed, but saved_slots still appears to represent visited minutes rather than successfully accepted/applied minutes. A regression covering the 15-minute partial-overlap case would demonstrate the fix.


Analysis and reproduction performed with OpenCode using OpenAI GPT-5.6 Sol.

@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

Can you get your AI to explain a bit more gently please - It could be written a bit more human friendly if I'm trying to consider it myself.

@Speshman

Copy link
Copy Markdown
Contributor

@chalfontchubby Sorry, the previous explanation was much too dense. Here is the plain-English version.

I could not make this go wrong with the normal 30-minute setting. I only reproduced it when plan_interval_minutes is set to the supported 15-minute value, so this is an edge case and may be an older issue rather than something introduced by this PR.

The example is:

  1. Predbat sees a short 14:15-14:30 slot first. It marks those 15 minutes as already dealt with.
  2. However, the code only applies the slot rate on a 00 or 30-minute boundary. This short slot contains neither boundary, so it does not actually make those minutes cheap.
  3. Predbat then sees the sensor-confirmed 14:00-14:30 slot. It correctly makes 14:00-14:15 cheap, but skips 14:15-14:30 because those minutes were marked as already dealt with by the first slot.

The final result is therefore:

  • 14:00-14:15: cheap, which is correct
  • 14:15-14:30: peak rate, which is incorrect because the whole half-hour was confirmed

If the same two slots are processed in the opposite order, the full half-hour is cheap. That is what shows the result depends on ordering.

The likely fix is simply to mark minutes as dealt with only after a slot has actually been accepted and applied, or to allow a later accepted slot to replace an earlier skipped one. A small regression test using the times above should demonstrate whether the fix works.

I hope that is clearer. I can also provide the test setup as code if that would be useful.


Analysis and reproduction performed with OpenCode using OpenAI GPT-5.6 Sol.

@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

Fix pushed - not able to mark the review as resolved because it wasn't raised as such.

Can I ask we focus on trying to get something merged and then iterate to fix problems in new tickets rather than have this one descend into another round of bots raising corner cases - we've lost 3 PR already that got mired down.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved moderate correctness and test-isolation findings remain, along with a documented configuration mismatch.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (3)

apps/predbat/fetch.py:2698

  • When a fresh positive reading arrives after the previous streak anchor has exceeded the rollover cap, the code first re-anchors car_charging_now_streak_last_read to the current slot, but this branch still uses the stale streak_anchor and immediately clears that newly created anchor. The current block is confirmed, but subsequent blocks can never roll over from it until another fresh read; preserve the fresh anchor and only clear the streak when the cap is exceeded without a guard-passing positive reading.
                else:
                    # Cap exceeded with no fresh evidence - lapse the streak, but leave slots already
                    # confirmed while it was valid alone.
                    self.car_charging_now_streak_last_read[car_n] = None

apps/predbat/fetch.py:1255

  • This reconciliation path is the only code that removes a feed-side io_adjusted discount when the trust gate rejects a dynamic slot, but the added rate tests call rate_add_io_slots() directly and never exercise exclude_dynamic_io_slots(). Add coverage for a future io_adjusted minute with none/started trust (including preservation of fixed-window and trusted minutes); otherwise the real-tariff path can regress while all current tests remain green.
            # #4516: undo any dynamic (out-of-window) IOG dispatch discount that arrived via the
            # rate feed itself (self.io_adjusted) rather than rate_add_io_slots()'s own overlay -
            # see exclude_dynamic_io_slots()'s docstring for why both sources need handling.
            import_rates = self.exclude_dynamic_io_slots(import_rates)

apps/predbat/fetch.py:2680

  • The new streak logic treats every non-matching car_charging_now value as False, so a transient HA unknown/unavailable state clears the anchor here. get_car_charging_now_tristate() already distinguishes those states from an explicit negative; preserving the streak for None is needed to match the documented “explicit not charging” termination and avoid losing confirmation after a sensor/restart glitch.
            elif not charging_now:
                # Clearing the anchor too skips the backfill, so nothing is confirmed on the strength
                # of a streak this read just ended.
                streak_anchor = None
                self.car_charging_now_streak_last_read[car_n] = None
  • Files reviewed: 11/11 changed files
  • Comments generated: 3
  • Review effort level: Lite

Comment thread apps/predbat/fetch.py
Comment thread apps/predbat/tests/test_car_charging_now_confirmed_slots.py Outdated
Comment thread apps/predbat/config.py
chalfontchubby added a commit that referenced this pull request Sep 17, 2026
…, drop dead test save/restore

car_charging_now_confirmed_slots/streak_last_read store minutes-since-
midnight_utc, but midnight_utc is recomputed fresh from real time every
cycle. A slot confirmed yesterday (e.g. 840 for 14:00) survived the 24h
prune unchanged - the prune bound is itself derived from today's minutes_now
- and became indistinguishable from today's slot 840, letting "started"
trust a future slot on a sensor reading from the day before (Copilot review
on PR #5110). get_car_charging_planned() now tracks the midnight_utc both
structures are keyed against and rebases every stored slot number by the
day's elapsed minutes whenever it changes.

Also drops test_car_charging_now_confirmed_slots.py's old save/restore-state
block and its trailing get_car_charging_planned() cleanup call (also flagged
by Copilot): both were protecting a since-removed shared PredBat fixture
that PR #5102 replaced with a fresh instance per test, so there is nothing
left for a later test to inherit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
chalfontchubby added a commit that referenced this pull request Sep 18, 2026
… stale-anchor re-lapse

Three findings from Copilot's 2026-09-17 18:28 review on #5110, none
addressed by the prior midnight-rollover push (ba63fd9):

- get_car_charging_planned() treated any non-matching car_charging_now
  value as a confirmed stop, including a transient HA "unknown"/
  "unavailable" reading (e.g. around a restart). That could drop an
  active streak's confirmation even though the car may have kept
  charging throughout. Now uses the existing get_car_charging_now_tristate()
  and only ends the streak on a real negative (tristate False); an
  unknown reading falls through untouched, same as a guard-failing True.

- When a fresh confirming read re-anchored the streak in the same cycle
  the *previous* anchor had already exceeded the rollover cap, the
  cap-exceeded branch still ran against that now-superseded anchor and
  immediately cleared the one this cycle just set - discarding the
  fresh confirmation it was supposed to record. The lapse branch now
  only fires when this cycle's own reading didn't already re-anchor.

- Added exclude_dynamic_io_slots() test coverage (fetch.py:1255 finding):
  it was never called by any test, only rate_add_io_slots() directly,
  leaving the feed-side io_adjusted stripping path - the one that
  matters most on a real Intelligent tariff - unverified.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

(Posted by Rik; working with Claude.)

Following up on Copilot's 2026-09-17 18:28 review — its 3 findings there are now all fixed in 707962d, on top of the midnight-rollover fix already pushed in ba63fd9:

fetch.py:2680 - an "unknown"/"unavailable" sensor reading was ending an active streak. get_car_charging_now_tristate() already existed to distinguish "confirmed not charging" from "no evidence either way," but the streak logic wasn't using it - any non-matching value, including a transient HA unknown around a restart, was treated the same as a genuine stop. Now only a real tristate False ends the streak; None falls through untouched, so a brief sensor hiccup no longer drops confirmation for a car that may have kept charging the whole time.

fetch.py:2698 - a fresh confirming read could be wiped in the same cycle it arrived. When the previous cycle's anchor had already gone past the rollover cap, and this cycle's own reading re-anchored the streak, the cap-exceeded branch was still comparing against that stale old anchor and immediately clearing the anchor this cycle just set - silently discarding the fresh confirmation. The lapse branch now only fires when this cycle's own reading didn't already re-anchor.

fetch.py:1255 - exclude_dynamic_io_slots() had no test coverage. It's the function that strips a feed-side io_adjusted discount on a real Intelligent tariff - the path that matters most, since it's live discounted data straight from Octopus rather than anything rate_add_io_slots() itself added. Added direct tests for the strip, preserve, and fixed-window-exempt cases.

All three are covered by new regression tests (test_car_charging_now_confirmed_slots.py Test 7b/12, test_rate_add_io_slots.py Tests 42-44), confirmed to fail against the unpatched code and pass with the fix. ./run_all --quick and ./run_pre_commit both green.

@Speshman

Copy link
Copy Markdown
Contributor

As the reporter of #4482, the safety direction in this PR matches the live behaviour I captured. Future out-of-window slots with no confirmed charging can disappear and have their source rates revert, so avoiding reliance on them until the Zappi reports real charging is a useful mitigation.

My original issue proposed using the real EV SoC directly, but I would support merging this narrower protection first and iterating separately. It preserves the fixed 23:30-05:30 window and offers a safer opt-in policy without changing existing defaults.

I have no request to broaden this PR further. It appears ready for an update onto current main and maintainer review.


Prepared with assistance from GPT-5.6 Sol.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Unresolved moderate findings remain in DST handling, sensor validation, overlapping-slot handling, and shared test-state isolation.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 2 Medium severity

Open (2)
Resolved since last review (3)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Require actual HA entity references for charging sensors

apps/​predbat/​fetch.py:2593

has_car_charging_now_sensor() treats every scalar value (and any non-empty list entry) as a real sensor. However, car_charging_now is allowed to be a static off value in apps/predbat/config/apps.yaml:357-361 and the schema permits boolean/string states (apps/predbat/config.py:2687). With car_charging_now: off, started will not warn or fall back to none, and can still trust _confirmed completed records, contrary to the documented real-sensor requirement. Restrict this check to actual HA entity references for both scalar and list forms.

Comment thread apps/predbat/fetch.py Outdated
Comment thread apps/predbat/tests/test_car_charging_now_confirmed_slots.py Outdated
chalfontchubby and others added 4 commits September 23, 2026 09:23
…w sensor (#4482, #4516)

Octopus Intelligent daytime dispatch slots are provisional: Octopus can move or withdraw
one before it happens, and allocates them from its own assumption of what the car needs,
since it cannot see the car's real SoC. Predbat treats every granted slot as guaranteed
cheap for the house battery, so it can make an irreversible decision - an early force
export, or deferring a charge - in anticipation of a window that then disappears, or
import during a slot the car never uses, which Octopus bills at the full peak rate.

Two independent gates, which a future out-of-window slot must both clear:

- switch.predbat_octopus_intelligent_limit_future_slots (default Off) - does the car's
  own remaining SoC requirement still want this slot? Requires
  octopus_intelligent_consider_full, and warns at startup if enabled without it.
- select.predbat_trust_future_dynamic_iog_slots (default "planned") - will Octopus
  honour it? "none" trusts no dynamic slot; "completed" requires Octopus's own metered
  record; "started" also accepts a slot corroborated by car_charging_now; "planned" is
  the long-standing trust-everything behaviour. The fixed 23:30-05:30 window is never
  gated - it is cheap by tariff structure, not by dispatch.

Both defaults preserve today's behaviour, so an upgrade changes no plan until a user
opts in.

This reworks the design of the closed PR #4885 onto current main. That PR was rejected
on the grounds that distrusting a slot costs a house-battery discharge into the car
while waiting on a sensor, and that "it takes 5-10 minutes based on a sensor to figure
out if the car is really charging". Measured on a live system since: a watched
car_charging_now change reaches a register write in 17-40s (update_time_loop polls every
15s, so it does not wait for the 5-minute scheduled cycle), and the inverter acts on it
36-65s later - so under two minutes end to end from a locally reported charging state,
not 5-10. The second objection was false positives: inferring "the car is charging" from
a load signature could take a kettle for the car. Hence the sensor gate here - "started"
requires a real charger/car-reported car_charging_now for that car, and degrades to
"completed" per car without one, rather than guessing. #4948 has since landed the
dispatch-timeline diagnostic that PR also carried, so only the trust logic is new.

Two details worth knowing:

car_charging_now confirms a 30-minute settlement slot as a "trusted streak" rather than
per reading. get_arg() re-samples live state on each replan instead of consuming the edge
that woke it, so a charging pulse shorter than the wakeup-to-read latency can be missed
entirely rather than merely seen late. A guard-passing positive read therefore starts a
streak that rolls forward through following slots until an explicit False reading, which
also tolerates a charger toggling at a settlement boundary - observed live as a readiness
sensor flipping Charging->Ready->Charging within two minutes, negotiation noise rather
than a real stop. The guard applies only to streak starts, so a reading in a slot's last
few minutes cannot confirm the slot after it; a cap bounds how far a streak rolls without
fresh evidence, so a missed negative edge degrades to untrusted rather than silently
behaving as "planned". Both constants are judgement calls, not measured.

Rejecting a slot actively restores rate_max_base rather than merely declining to discount
it, because on a real Intelligent tariff fetch_octopus_rates() can deliver the discounted
rate directly from the feed. exclude_dynamic_io_slots() does the same for a feed-side
discount with no matching dispatch entry, consuming the trust decisions rate_add_io_slots()
just made so the two cannot disagree. Both touch only minutes from now onwards: an elapsed
minute records what the tariff actually charged, and rewriting it would inflate
today_cost() without changing a plan that never looks behind minutes_now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…undary

rate_add_io_slots() only ran its needed/trusted/octopus_slot_max admission
check on minutes where minute % 30 == 0. At the default 30-minute plan
interval every slot's own range always includes that boundary minute, so
this always worked. At plan_interval_minutes < 30 a slot can round to a
sub-range that skips it entirely (e.g. 14:15-14:30 never contains :00 or
:30), leaving that slot's own admission never decided - its rate then
depended on whichever other slot happened to cover the boundary minute for
the same slot_start, rather than resolving its own gates.

Track decided slot-starts separately from added ones and fire the decision
on the first minute visited for a block, independent of alignment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…, drop dead test save/restore

car_charging_now_confirmed_slots/streak_last_read store minutes-since-
midnight_utc, but midnight_utc is recomputed fresh from real time every
cycle. A slot confirmed yesterday (e.g. 840 for 14:00) survived the 24h
prune unchanged - the prune bound is itself derived from today's minutes_now
- and became indistinguishable from today's slot 840, letting "started"
trust a future slot on a sensor reading from the day before (Copilot review
on PR #5110). get_car_charging_planned() now tracks the midnight_utc both
structures are keyed against and rebases every stored slot number by the
day's elapsed minutes whenever it changes.

Also drops test_car_charging_now_confirmed_slots.py's old save/restore-state
block and its trailing get_car_charging_planned() cleanup call (also flagged
by Copilot): both were protecting a since-removed shared PredBat fixture
that PR #5102 replaced with a fresh instance per test, so there is nothing
left for a later test to inherit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… stale-anchor re-lapse

Three findings from Copilot's 2026-09-17 18:28 review on #5110, none
addressed by the prior midnight-rollover push (ba63fd9):

- get_car_charging_planned() treated any non-matching car_charging_now
  value as a confirmed stop, including a transient HA "unknown"/
  "unavailable" reading (e.g. around a restart). That could drop an
  active streak's confirmation even though the car may have kept
  charging throughout. Now uses the existing get_car_charging_now_tristate()
  and only ends the streak on a real negative (tristate False); an
  unknown reading falls through untouched, same as a guard-failing True.

- When a fresh confirming read re-anchored the streak in the same cycle
  the *previous* anchor had already exceeded the rollover cap, the
  cap-exceeded branch still ran against that now-superseded anchor and
  immediately cleared the one this cycle just set - discarding the
  fresh confirmation it was supposed to record. The lapse branch now
  only fires when this cycle's own reading didn't already re-anchor.

- Added exclude_dynamic_io_slots() test coverage (fetch.py:1255 finding):
  it was never called by any test, only rate_add_io_slots() directly,
  leaving the feed-side io_adjusted stripping path - the one that
  matters most on a real Intelligent tariff - unverified.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
chalfontchubby and others added 2 commits September 23, 2026 09:23
… started gate

Latest Copilot review on #5110:

- get_car_charging_planned()'s midnight rebase subtracted two
  midnight_utc values (aware local wall-clock time) as elapsed
  seconds, not calendar days. Across a spring DST transition
  consecutive local midnights are 23h apart, not 24h, which
  shifted every stored confirmed-slot number by 30 minutes and
  could let a stale slot from the day before pass as confirmed
  today. Rebase by (date() - date()).days * 1440 instead.

- has_car_charging_now_sensor() treated any scalar/list value as a
  real sensor, including car_charging_now's documented static
  literal form (e.g. "off", which apps.yaml ships as a commented
  example). A literal can never report a real charging edge, so it
  must not satisfy the "started" trust gate. Now uses utils.is_entity_id().

- Both test_car_charging_now_confirmed_slots* tests run against the
  single PredBat instance unit_test.py shares across the whole
  suite (not a fresh fixture per test, despite a stale comment
  claiming otherwise) and left num_cars/midnight_utc/minutes_now/
  args["car_charging_now"]/streak state mutated on exit. Wrapped
  both in save/restore.

Added regression tests for the DST rebase (a confirmation crossing
the UK's 2026 spring-forward) and the static-literal sensor gate,
each verified to fail against the prior code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…cepted rate

Full-diff review of #5110 (independent of the Copilot review comments
already addressed): rate_add_io_slots() runs once per car into a single
shared rates dict (fetch.py's per-car loop). On main this was purely
additive - a rejected/capped slot never wrote to rates, only affected a
log line - so call order across cars was harmless. This PR added a
restore-to-rate_max_base path for a rejected slot, but with no guard
against undoing a minute an earlier car's own call already accepted.
Two IOG-enabled cars with overlapping dispatch windows could have a
later car's rejection silently wipe an earlier car's genuine discount.

Gate the restore on minute not in self.trusted_dynamic_minutes - the
existing accumulator that already tracks "accepted this cycle" across
the whole per-car loop, so it's exactly the signal needed here.

Added Test 45, verified to fail without the guard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Unresolved slot-overlap and rate-handling issues can produce incorrect planning and tariff decisions.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 High severity

Open (1)
Resolved since last review (2)

Comment thread apps/predbat/octopus.py
The trust gating added by this PR decided a 30-minute block from whichever
dispatch entry the Octopus feed happened to list first, and whichever car
fetch.py happened to loop over first. Both are arbitrary, and both could price
a genuine dispatch at the day rate. Found by review on #5110 and by successive
cold reviews of the fix itself.

Entry order. Several entries can cover one block - Octopus lists a planned
dispatch and, later, a completed record for it - and `_confirmed` is the only
per-ENTRY input to `trusted`, since car_charging_now_confirmed_slot_starts is
keyed on slot_start and therefore shared. slots_decided made the first entry
visited decide the block permanently, and saved_slots then skipped the later
entry's minutes before it could be consulted. So an unconfirmed planned entry
rejected a block its own completed record proved had happened. Confirmation is
now resolved across all entries before any is applied.

Car order. rate_add_io_slots() runs once per car into one shared rates dict,
and its reject path rewrites the rate AND drops the io_adjusted marker. The
guard against undoing another car's dispatch read trusted_dynamic_minutes,
which that same loop builds as it goes - so a rejecting car running first saw
it empty, popped the marker, and nothing anywhere restores one. The rate was
later restored but the marker was not, dropping the block out of plan.py's IOG
skew and prediction.py's PV10 hedge. resolve_protected_dispatch_minutes() now
resolves the shield across every car before the loop begins.

One decision, one implementation. car_trusted_dispatch_blocks() is the single
per-car answer to "which blocks does this car have a genuine dispatch for",
used by both the shield and the loop's own gates. An earlier version of the
shield re-derived a looser variant alongside the loop - it dropped the `needed`
gate at "planned" and skipped the per-car degrade to "none" for a car with no
real car_charging_now sensor - shielding blocks the loop itself rejects, which
would have kept a rescindable slot cheap. That is the #4516 failure this PR
exists to prevent.

assumed_price reads rate_import_no_io, the snapshot fetch takes before the
per-car loop, rather than the working rates dict this function mutates. On main
nothing ever wrote rate_max_base into rates so the read-back was safe; this
PR's restore path made it reachable, letting one car's rejection be read back
by the next car as though it were the tariff price.

trusted_dynamic_minutes records the whole block when `needed and trusted` held,
regardless of the octopus_slot_max cap. The cap budgets what Predbat PLANS -
it is documented per-car while the rates dict is install-wide - not what it
believes the price to be. Gating the record on the cap meant
exclude_dynamic_io_slots() stripped the very minutes the #4483 cap-only
exemption had just protected, on any install whose feed delivers the discount.
Test 39 asserted that bookkeeping directly and is rewritten to assert the
user-visible outcome, with io_adjusted markers it previously lacked.

Also corrects an end_minutes clamp that used forecast_minutes where its three
siblings use forecast_minutes + minutes_now. forecast_minutes is a duration,
so the clamp truncated roughly minutes_now of the horizon; pre-existing, but
this PR added the consumer that makes it strip a real dispatch's discount.

Tests 45-51 cover entry order, cap accounting under duplication, the tariff
read-back, car-order equivalence including the io_adjusted marker, shield
breadth in both directions, and the reject path against a discount
distinguishable from rate_max_base - most existing tests run on a baseline
equal to rate_max_base, so the restore was invisible to them. Each was
confirmed to fail against the behaviour it pins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

(Written by Claude, posted by @chalfontchubby.)

Pushed f8c71d8c, addressing the high-severity finding and four more bugs found while fixing it.

On the reported finding. It is real and now fixed, but the mechanism in the review comment is not quite the one that bites. car_charging_now_confirmed_slot_starts is keyed on slot_start, so it is shared by every entry covering a block - the live car_charging_now entry and a planned entry therefore reach the same verdict and cannot disagree. The only per-entry input to trusted is _confirmed, so the reachable case is an unconfirmed planned entry visited before a completed dispatch record for the same block. Same bug, narrower trigger. Test 46 reproduces it.

Four further bugs, each found by a cold review pass and each with a test confirmed to fail without its fix:

  1. Car order decided the outcome. rate_add_io_slots() runs once per car into one shared rates dict and its reject path drops the io_adjusted marker as well as rewriting the rate. The guard against undoing another car's dispatch read trusted_dynamic_minutes, which that same loop builds as it goes - so a rejecting car running first saw it empty, popped the marker, and nothing anywhere restores one. The rate was restored by the accepting car; the marker was not, dropping the block out of plan.py's IOG skew and prediction.py's PV10 hedge. Test 49 runs both orders and compares.

  2. A rejection was read back as the tariff price. assumed_price read the working rates dict, which this function mutates. Safe on main (nothing ever wrote rate_max_base into rates), but this PR's restore path made it reachable: car 0's rejection became car 1's "tariff price". Now reads rate_import_no_io, the snapshot taken before the per-car loop. Test 48.

  3. The cap stripped the tariff's own discount. trusted_dynamic_minutes was only recorded when the octopus_slot_max cap granted the discount, so exclude_dynamic_io_slots() stripped the very minutes the #4483 cap-only exemption had just protected. The cap budgets what Predbat plans - it is documented per-car while the rates dict is install-wide - not what it believes the price to be. Test 39 previously asserted that bookkeeping directly with no io_adjusted markers at all, so it passed under either behaviour; rewritten to assert the user-visible outcome.

  4. An end_minutes clamp used forecast_minutes where its three siblings use forecast_minutes + minutes_now. forecast_minutes is a duration, so it truncated roughly minutes_now of the horizon and a dispatch landing in that tail lost its discount. Pre-existing on main, but this PR added the consumer that makes it bite.

Structural change. Three of these came from the same root: two places computing one decision and drifting apart. car_trusted_dispatch_blocks() is now the single per-car answer to "which blocks does this car have a genuine dispatch for", used by both the cross-car shield and the loop's own gates. An intermediate version of the shield re-derived a looser variant - dropping the needed gate at "planned", and skipping the per-car degrade to "none" for a car with no real car_charging_now sensor - which shielded blocks the loop itself rejects and would have kept a rescindable slot cheap. That is the #4516 failure this PR exists to prevent, so Test 50 pins shield breadth in both directions.

Process note, since it is relevant to how much weight to put on this. Four of the five bugs above were introduced or missed by me, including two inside the fix for the bug you reported. I stopped self-reviewing and ran independent cold reviews instead, iterating until one came back with "no correctness bug introduced by this branch's own work". That last pass also brute-forced the two block derivations against each other across plan_interval_minutes ∈ {1,5,10,15,20,30,60} and found zero divergence. I would still rather you or Copilot looked than take that as settled.

Out of scope, flagged not fixed: assumed_price is keyed on the entry's start_minutes rather than the current minute, so with octopus_slot_low_rate: false and two overlapping entries at different tariff prices the result depends on list order. Pre-existing on main and unrelated to trust gating - happy to raise it separately.

Full quick suite and pre-commit green. run_shuffle --hunt --quick clean so far for order dependence.

@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

(Written by Claude, posted by @chalfontchubby.)

Correction to my previous comment. I justified the trusted_dynamic_minutes change partly with "the cap is documented per-car while the rates dict is install-wide". That premise is wrong, and I should not have leaned on it.

octopus_slot_max defaults from has_six_hour_cap(), which matches on the tariff code - a property of the account, not of a vehicle. The "per-car" line in docs/car-charging.md was documenting current behaviour rather than intent, and that behaviour turns out to be a latent bug: the cap is derived per account but enforced per car, because slots_per_day is a local in rate_add_io_slots() and that function runs once per car. Raised separately as #5215, with a docs correction; not touched in this PR.

The conclusion in f8c71d8c still holds, for the reason that was actually load-bearing. The cap must not strip a discount the tariff feed itself delivered, because io_adjusted records what Octopus will bill - a fact, not a prediction. That is true whether the allowance is per-account or per-car: Predbat's own budget for planning cheap charging says nothing about what the meter is charged. The per-car detail was supporting colour in my write-up, not the argument.

Flagging it because the commit message carries the same wrong premise, and I would rather correct it here than leave it to be read as established.

The note said the cap "applies per-car, so with two cars on IOG each car is
subject to its own slot-count cap", presenting current behaviour as intent.

It is not. get_octopus_slot_max() derives the cap from has_six_hour_cap(),
which matches on the tariff code - a property of the account, not a vehicle.
Enforcement is per car only because slots_per_day is a local in
rate_add_io_slots(), which fetch.py calls once per car, so the counter resets
each time. Two IOG cars on IOG-SMB therefore get 12 slots each against an
account allowance of 12.

Raised as #5215; the fix tightens behaviour for multi-car installs so it needs
a maintainer decision rather than being folded into an unrelated PR. Until
then the note should describe the behaviour and say it is a bug, rather than
document it as designed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Two moderate issues remain in fetch.py and octopus.py and should be addressed before approval.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 Medium severity

Open (1)
Resolved since last review (1)

Comment thread apps/predbat/fetch.py Outdated
The "started needs a real car_charging_now sensor" startup warning iterated
range(max(self.num_cars, 1)), so an install with num_cars 0 - explicitly
allowed, "zero": True in the schema - was told car 0 was missing a sensor and
given had_errors=True. A red status on an install with nothing wrong.

The block is gated only on trust_future_dynamic_iog_slots being "started",
never on car count, so it is reachable: the select is expert-mode but not
car-gated.

The max(..., 1) idiom was copied from the wrong context. Its two other uses in
this file size per-car LISTS (car_charging_rate, car_charging_manual_soc)
where a spare slot avoids an IndexError on a later unconditional indexed read -
fetch.py:390 reads car_charging_rate[0] gated on car_charging_hold rather than
num_cars. Sizing a list and iterating to emit user-facing warnings are not the
same problem.

Only the warning is affected. The actual per-car degrade to "none" lives in
rate_add_io_slots(), which calls has_car_charging_now_sensor() itself each
cycle, so the trust gate is unchanged.

Test 21 covers it, confirmed to fail against the old loop on both assertions.

Reported by Copilot review on #5110.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

(Written by Claude, posted by @chalfontchubby.)

Fixed in c18f8e8a. The finding is correct.

The warning loop used range(max(self.num_cars, 1)), so an install with num_cars: 0 - explicitly allowed ("zero": True in the schema) - was warned that car 0 lacked a car_charging_now sensor and given had_errors=True. The block is gated only on trust_future_dynamic_iog_slots == "started", never on car count, so it is reachable.

Root cause is that the max(..., 1) idiom was copied from the wrong context. Its two other uses in fetch.py size per-car lists (car_charging_rate, car_charging_manual_soc), where a spare slot avoids an IndexError on a later unconditional indexed read - fetch.py:390 reads car_charging_rate[0] gated on car_charging_hold rather than on num_cars. Sizing a list and iterating to emit user-facing warnings are not the same problem, and I conflated them.

Only the warning is affected. The actual per-car degrade to "none" lives in rate_add_io_slots(), which calls has_car_charging_now_sensor() itself every cycle, so the trust gate is unchanged.

Test 21 covers it and was confirmed to fail against the old loop on both assertions. A cold review pass also checked the other IOG/trust call sites for the same assumption at num_cars == 0 (resolve_protected_dispatch_minutes, car_trusted_dispatch_blocks, get_car_charging_planned, add_now_to_octopus_slot, load_octopus_slots) and found them all either bounded by range(self.num_cars) or guarded - no sibling instances to fix.

@springfall2008

Copy link
Copy Markdown
Owner

I've opened #5229 as an alternative approach. Review welcome, @chalfontchubby.

It comes from reviewing this PR. Here, trust is gated only through the rates. car_charging_slots is left as it is, so "Hold for car" still holds the battery during a slot the plan no longer trusts. At none with car_charging_from_battery on, the planner sees the car's load priced at peak and discharges the house battery into the car during a genuine dispatch.

#5229 works the other way round. Dispatches are trusted by default, and the evidence that counts is the car not charging. It builds on the existing dynamic load feature, with no new settings:

  • When it fires: while a car is inside one of its slots and car_charging_now reports it isn't charging (for 2 minutes), its future slots are cancelled. That releases the hold and the predicted car load. The dispatch's cheap rate is withheld too: Predbat's own discount, and any discount the rate feed already delivered.
    • Without a sensor, low load stands in, if the car is inside the CT clamp (10 minutes).
  • Recovery: it clears as soon as the car starts charging again or the slot ends. A 15-second poll replans straight away instead of waiting for the 5-minute cycle.

Your latency measurements were what made a 2-minute sensor wait credible, so thank you for those.

What #5229 doesn't cover: it only reacts once a slot has started. A dispatch withdrawn before it starts, after the plan has already acted on it (e.g. an early force-export), isn't prevented there. limit_future_slots also answers a different question (does the car still need the slot), so it could stand on its own.

@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

(Written by Claude, working with @chalfontchubby.)

Closing in favour of #5229, which now covers this.

switch.predbat_octopus_intelligent_trust_slots Off does what trust_future_dynamic_iog_slots was for: an Intelligent car's slots start untrusted — 0 kWh and no cheap daytime rate — and are trusted only once the car is seen charging inside a slot, with each dispatch confirmed separately. It also works without dynamic load enabled, and it fixes something this PR got wrong: #5110 distrusted the rates but left car_charging_slots alone, so "Hold for car" still held the battery through a slot the plan no longer trusted.

One concern I'd like to carry over to #5229 rather than lose here. Under the switch, a car inside the CT clamp can be confirmed as charging by load signature alone. The asymmetry matters: a false "charging" reading now trusts a phantom slot, which is the failure mode the switch exists to prevent — the opposite direction from the original kettle objection, and a worse one. Worth considering whether confirmation should require a real car_charging_now entity even where the load test is available. I'll raise it on #5229.

Leaving #4516 open — #5229 addresses it but that's @springfall2008's call, not mine. #4482 is untouched by either PR.

CossieRob pushed a commit to CossieRob/batpred that referenced this pull request Sep 26, 2026
…is not charging

Dynamic load already cancelled a car's future slots when the load showed it was not
charging, but it ran after the rates were built, so an Octopus Intelligent dispatch
kept its cheap rate: the house battery was still planned around a slot Octopus may
bill at the full rate if the car never draws.

The car check now runs inside fetch_sensor_data(), after the car slots are built and
before the rates. While a car is inside one of its slots and not charging:

- every slot ending after now is set to 0 kWh, releasing "Hold for car" and the
  predicted car load
- rate_add_io_slots() skips the car's dispatch minutes from now on
- a discount the rate feed delivered for them (io_adjusted) is set back to
  rate_max_base before replication, except in the fixed 23:30-05:30 window and where
  another car still being trusted has a dispatch

Evidence is car_charging_now when it is a real entity (2 minutes), otherwise the
low-load test when the car is inside the CT clamp (10 minutes), otherwise nothing.
The cancellation is re-derived each cycle and ends as soon as the car leaves its
slots or starts charging again. Cars Predbat plans itself get their slots after the
rates, so a second call decides those; they have no dispatch discount to withhold.

A poll in the 15 second loop asks for a replan once a cancel or resume is due,
rather than waiting up to 5 minutes for the next cycle. It judges "in a slot" on the
same floored, skewed minute as the check, so the two cannot disagree and replan
every 15 seconds at a slot boundary.

dynamic_load() keeps the high-load baseline; the load classification is shared.

An alternative to springfall2008#5110, which gates trust in future dispatches only through the
rates and so leaves "Hold for car" firing for a slot it no longer trusts.

Co-Authored-By: Claude Opus 5.5 (1M context) <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.

IOG future planned dispatches treated as cheap home rate before EV charging earns the slot

4 participants