Skip to content

feat(dynamic-load): cancel a car's slots and IOG cheap rate while it is not charging, with an option to distrust Intelligent slots until seen charging - #5229

Merged
springfall2008 merged 7 commits into
mainfrom
feat/dynamic-load-car-not-charging
Sep 26, 2026
Merged

springfall2008 merged 7 commits into
mainfrom
feat/dynamic-load-car-not-charging

Conversation

@springfall2008

@springfall2008 springfall2008 commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

An alternative to #5110, and now also covers its main case (not trusting future dispatches) through an opt-in switch.

Problem

Octopus Intelligent dispatch slots are treated as cheap for the house battery whether or not the car actually charges in them. If the car doesn't draw, Octopus may bill that import at the full rate (#5065), and Octopus can also withdraw a slot before it happens.

#5110 tackles this by distrusting future dispatches through the rates only. That leaves car_charging_slots untouched, so "Hold for car" still holds the battery during a slot the plan no longer trusts. At its strictest setting, with car_charging_from_battery on, the battery drains into the car during a genuine dispatch.

Dynamic load already cancelled a car's future slots when the load showed the car wasn't charging. But it ran after the rates were built, so the dispatch kept its cheap rate.

1. Dynamic load: cancel slots, and their cheap rate, while the car isn't charging

This part is gated by the existing switch.predbat_metric_dynamic_load_adjust. It only applies to slots built by Octopus Intelligent charging. Predbat-led charging is never cancelled: there, Predbat itself decides when the car charges, and its car_charging_slot sensor drives the charger, so cancelling a slot would stop the very charge the check is waiting to see.

The car check moves into fetch_sensor_data(): after the Octopus slots are built, before the rates. It decides every car in one call. While an Intelligent car is inside one of its slots and not charging:

  • Car slots: every slot ending after now is set to 0 kWh. This releases "Hold for car" and removes the predicted car load.
  • Predbat's own discount: rate_add_io_slots() skips that car's dispatch minutes from now onwards.
  • Feed discount: a discount the rate feed already delivered (io_adjusted) is set back to rate_max_base before replication, rounded out to whole 30-minute rate periods. Three kinds of minute are left alone:
    • elapsed minutes, so today's cost stays true;
    • the fixed 23:30-05:30 window, which is cheap by tariff, not by dispatch;
    • minutes where another car that is still trusted has a dispatch.

Evidence that the car isn't charging:

Source Used when Must hold for
car_charging_now it's a real entity. A static literal, an entity HA doesn't have, and unknown/unavailable are all no evidence either way 2 minutes
Low load there's no sensor and the car is inside the CT clamp (car_energy_reported_load) 10 minutes
— otherwise no detection

Reset: the decision is worked out again every cycle. It ends as soon as the car leaves its slots or starts charging again.

Poll: update_time_loop (every 15 seconds) asks for a replan as soon as the state is due to change, instead of waiting up to 5 minutes for the next cycle. It makes the same decision as the check, on the same floored, clock-skewed minute, so the two can't disagree at a slot boundary.

dynamic_load() keeps the high-load baseline unchanged. The load classification is now a shared helper.

2. switch.predbat_octopus_intelligent_trust_slots (expert mode, default On)

  • On: everything in section 1. A slot is trusted until the car is seen not charging in it.
  • Off: reversed. A car's Octopus Intelligent slots start untrusted: 0 kWh, and no cheap daytime rate from now onwards.
    • As soon as the car is seen charging inside a slot, that slot and every later one are trusted straight away.
    • They're cancelled again if the car stops charging for the usual wait, or when the slot ends. So each separate dispatch has to be confirmed.
    • The overnight rate stays cheap, as it's part of the tariff.
    • Off works whether or not dynamic load is on. It never affects slots Predbat planned itself.
    • It only acts on the car plan that Octopus Intelligent charging builds. With octopus_intelligent_charging Off, the dispatch rates are used as before and a warning is logged.
    • If a car has no sensor and is outside the CT clamp, nothing can ever confirm it charging. Predbat logs a warning whenever the set of such cars changes.

Both directions come from one function, dynamic_load_car_target():

Where now is What the car shows Result
Outside any slot anything the default: trusted if On, untrusted if Off
Inside a slot charging trusted, immediately
Inside a slot not charging, for the wait cancelled
Inside a slot no evidence unchanged

3. Cancelled slots stay visible, with a "?"

A cancelled slot keeps the kWh it had in a new kwh_cancelled field on the slot itself. That means it travels with the existing deep copies in compare.py and the yesterday plan, and disappears when the slots are rebuilt each cycle.

  • Published car plan: a cancelled slot has 0 kWh and 0 cost, plus its kwh_cancelled, in the planned attribute of binary_sensor.predbat_car_charging_slot.
  • HTML plan: the car column shows 3.5? on pale yellow. If another car is charging in the same step, it shows both, e.g. 2.0 +3.5?.
  • JSON: a new car_charging_cancelled field, which the web page shows the same way.

The planned car_charging value and the totals are unchanged.

Behaviour change for existing dynamic load users

  • The load-based cancellation now needs 10 minutes of low load. Before, it was two consecutive checks. It still needs the car inside the CT clamp, as before.
  • For Intelligent slots it also removes the dispatch's cheap rate, not just the car load.
  • A car with a real car_charging_now sensor is judged on that sensor, not on the load.
  • Slots Predbat plans itself (Predbat-led charging) are no longer cancelled on low load. The old dynamic_load() zeroed them too.

Review round 1

  • Fixed:
    • a missing car_charging_now entity read as "not charging";
    • the feed-discount strip used exact dispatch boundaries instead of whole rate periods;
    • compare.py runs applied a cancellation that was out of date;
    • a missing bounds check.
  • Discussed in the threads: rate_max_base as the restored price; the CT clamp condition; the poll's replan cost.

Review round 2

  • Fixed:
    • the poll now reads a sensor entity cached once per cycle, so a single sensor shared by two cars no longer logs a set-up warning every 15 seconds;
    • a cancelled car's "?" now shows alongside another car's live charging;
    • the trust switch's precondition (Octopus Intelligent charging On) is now documented, with a warning;
    • the dead load_last_car_slot state has been removed.
  • Predbat-led charging: an earlier version of this PR also cancelled Predbat-planned slots, in a second check that ran before publish_car_plan(). That could switch off the slot sensor driving the charger, so the charge the check was waiting for never started. Cancelling is now limited to Octopus Intelligent slots.
  • Checked: the poll across midnight. It reads the previous cycle's slots and its midnight_utc together, so they stay on the same axis. Test 18b pins this.

Review round 3

  • Fixed:
    • in compare.py runs, the rates now follow the same decision as the slots (dynamic_load_car_effective);
    • no load history no longer counts as low load;
    • an unknown reading restarts the grace timer;
    • a cancelled slot's cost is zeroed, and kwh_cancelled is published in the car plan's planned attribute;
    • the "never trusted" warning is limited to Octopus Intelligent cars;
    • the feed strip logs only when what it strips changes.
  • Discussed in the threads: deriving the night window from the tariff; rate-limiting poll-triggered replans.

Tests

test_dynamic_load_car_not_charging has 41 scenarios. They cover:

  • grace timing for each evidence source, and which source takes priority
  • unknown readings, missing entities and static literals
  • the CT clamp condition and the midnight load reset
  • both reset paths (leaving the slot, charging again)
  • compare.py's save=False runs
  • Predbat-planned cars never being cancelled, and a cancellation being cleared when a car's slots disappear
  • rate withholding
  • feed-discount stripping: past minutes, 30-minute rounding, another car's dispatch, and a dispatch straddling 23:30
  • the poll, including the slot-boundary case, across midnight, and with no per-poll warnings
  • the trust switch: its default, confirmation, re-cancel, the slot ending, own slots untouched, no evidence, the poll, and the warning
  • kwh_cancelled

test_plan_json_rate_adjust checks the 3.0? HTML cell, the JSON field, the unchanged totals, and the two-car 2.0 +3.0? cell.

To check the tests themselves, deliberately broken versions of the logic were confirmed to make the suite fail (over 30 mutations across the rounds). test_dynamic_load_car_slot_cancellation is updated, because dynamic_load() no longer cancels slots itself.

./run_all --quick: all passed. pre-commit: clean.

🤖 Generated with Claude Code

…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 #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>
@chalfontchubby

Copy link
Copy Markdown
Collaborator

This isn't strictly an alternative to 5110 in that it does not close the hole of planning to use slots that go away. I do want to land that aspect of future trust.
At a first glance this does have merit once you are in a slot, but it only closes one of the concerns we have - perhaps the one you indicate is a lower risk based on reconciling bills.

@chalfontchubby

Copy link
Copy Markdown
Collaborator

This does seem a better solution for the subset of the problem it addresses but it doesn't close the issue, and won't shut me up :-)

@springfall2008 springfall2008 added the BOT_REVIEW Trigger an autotriage label Sep 25, 2026
@springfall2008

springfall2008 commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner Author

@chalfontchubby I guess an add on would be not to trust future IOG slots, so don't mark them as low rate on the plan until the car is seen charging, this is just flipping the default of this PR?

@springfall2008

Copy link
Copy Markdown
Owner Author

Automated review failed to complete for this PR - see the triage bot's logs for details. The run exited cleanly but posted nothing, so the review step itself did not complete. Not retrying automatically; remove BOT_FAILED and re-add BOT_REVIEW to try again.

@springfall2008 springfall2008 added BOT_FAILED An automated triage/PR attempt failed; see the comment for details and removed BOT_REVIEW Trigger an autotriage labels Sep 25, 2026
Comment thread apps/predbat/plan.py Outdated
Comment thread apps/predbat/octopus.py
Comment thread apps/predbat/octopus.py
Comment thread apps/predbat/plan.py Outdated
Comment thread apps/predbat/plan.py
Comment thread apps/predbat/plan.py Outdated
Comment thread apps/predbat/predbat.py
CI and others added 3 commits September 25, 2026 18:36
- A car_charging_now entity HA does not have (deleted, renamed, integration
  reloading) read as the default "no", i.e. a confirmed stop, and cancelled the
  car's slots and cheap rate after 2 minutes. It is now read with no default, so
  it is no evidence, as an "unavailable" reading already was.
- The feed-discount strip covered a dispatch's raw boundaries, while the feed marks
  whole 30 minute rate periods and rate_add_io_slots() rounds out to them, so the
  edges of a dispatch off the half hour kept their discount. It now rounds out to
  whole periods, for the trusted car's shield too.
- A compare.py run (save=False) applied the last live cancellation even if the car
  had since started charging or left its slot. It now re-checks both, without
  advancing the saved state.
- The slot-cancellation loop indexed car_charging_slots without the bounds check
  every other access has.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ff-peak rate inside the window

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…r slots with a "?"

switch.predbat_octopus_intelligent_trust_slots (expert mode, default On) chooses
whether an Octopus Intelligent slot is trusted before the car has been seen
charging in it.

- On is the existing behaviour: trusted until the car is seen not charging in one.
- Off reverses it. An Intelligent car's slots start untrusted: 0 kWh, and no cheap
  daytime rate from now on. They are trusted as soon as the car is seen charging
  inside a slot, until it stops for the grace period or the slot ends. Every
  separate dispatch has to be confirmed again. Off works whether or not dynamic
  load is on, and never touches slots Predbat planned itself. The overnight rate
  is left alone, as before.

Both directions now come from one function, dynamic_load_car_target(), which
returns the state a car should be in:

- outside a slot: the default for the switch
- inside a slot and charging: trusted
- inside a slot, not charging for the grace period: cancelled
- otherwise: unchanged

The check and the 15 second poll both use it, so the poll fires only when the
replan would actually change something. That covers "the car just started
charging" under Off. A car with trust Off and nothing that can confirm it
charging logs a warning when that set changes.

A cancelled slot keeps the kWh it had in a new kwh_cancelled field on the slot
itself. It travels with the existing deep copies, and disappears when the slots
are rebuilt. The plan shows it in the car column as "3.5?" on pale yellow: in the
HTML table, and as car_charging_cancelled in the JSON for the web page. Planned
kWh and totals are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@springfall2008 springfall2008 changed the title feat(dynamic-load): cancel a car's slots and IOG cheap rate while it is not charging feat(dynamic-load): cancel a car's slots and IOG cheap rate while it is not charging, with an option to distrust Intelligent slots until seen charging Sep 25, 2026
@springfall2008 springfall2008 added BOT_REVIEW Trigger an autotriage and removed BOT_FAILED An automated triage/PR attempt failed; see the comment for details labels Sep 25, 2026
Comment thread apps/predbat/plan.py
Comment thread apps/predbat/plan.py Outdated
Comment thread apps/predbat/plan.py Outdated
Comment thread apps/predbat/plan.py
Comment thread apps/predbat/output.py Outdated
@springfall2008 springfall2008 removed the BOT_REVIEW Trigger an autotriage label Sep 25, 2026
WhatIsThisAllAboutSilly pushed a commit to WhatIsThisAllAboutSilly/batpred that referenced this pull request Sep 25, 2026
PR springfall2008#5229's /code-review found seven issues and posted none of them. Its
POST was endpoint-first, which the scoped gh api allowlist covers, but
the body went in as a double-quoted -f argument full of backticks. The
shell reads those as command substitution, so the permission check saw
extra commands no rule allows and dontAsk denied the whole call. Nearly
every review comment quotes code, so this blocks almost every review.

GH_API_ENDPOINT_FIRST_PROMPT now tells the agent to write each body to
the scratch directory and pass it with -F body=@<file>, and its worked
example does the same. No allowlist change: both flows that append the
prompt (review and cleanup) already hold Write and the scratch Edit scope.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- The 15 second poll read car_charging_now through get_arg(index=car_n). For a
  second car sharing a single (non-list) sensor, that logs a set-up warning every
  time. Which cars have a real sensor entity is now worked out once per cycle
  (dynamic_load_car_refresh_sensors(), from fetch_config_options()), and the
  cached entity is read directly.
- A car cancelled in the same step as another car's live charging lost its "?":
  the cancelled kWh was only looked up when no car was charging. It is now always
  computed, and shown as "2.0 +3.0?" in the HTML table, the JSON and the web page.
- octopus_intelligent_trust_slots Off only acts on the car plan that Octopus
  Intelligent charging builds. With octopus_intelligent_charging Off, the dispatch
  rates are still used, so the docs now say so and a warning is logged.
- load_last_car_slot and the slot walk that set it are removed. They have been
  dead since the car cancellation moved out of dynamic_load().

Test 18b pins the poll across midnight: it reads the previous cycle's slots and
its midnight_utc together, so a new-day slot lines up with the poll's minute.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…at-led ones

Predbat-led charging is a different mechanism: Predbat itself decides when the
car charges, and its car_charging_slot sensor drives the charger. A second check
here, for cars Predbat plans itself, ran before publish_car_plan(), so a
cancelled slot switched that sensor off. The charger automation then stopped the
charge the check was waiting to see, and every later slot in the run stayed
cancelled.

Detection now applies only to slots load_octopus_slots() built ("octopus": True).
The second check is gone, and a single call before the rates decides every car,
including one whose slots have gone this cycle, so a stale cancellation cannot
outlive them.

This changes behaviour from main: the old dynamic_load() also zeroed
Predbat-planned slots on low load. It no longer does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@springfall2008 springfall2008 added the BOT_REVIEW Trigger an autotriage label Sep 26, 2026
Comment thread apps/predbat/plan.py
Comment thread apps/predbat/plan.py
Comment thread apps/predbat/octopus.py
Comment thread apps/predbat/plan.py
Comment thread apps/predbat/plan.py
Comment thread apps/predbat/plan.py
Comment thread apps/predbat/octopus.py
Comment thread apps/predbat/predbat.py
@springfall2008 springfall2008 removed the BOT_REVIEW Trigger an autotriage label Sep 26, 2026
- compare.py runs (save=False): the rates read the saved cancellation while the
  slots followed a decision made fresh in that run. So a car that had started or
  stopped charging since the last live cycle was priced one way and planned the
  other. The check now records this run's decision in dynamic_load_car_effective,
  and rate_add_io_slots() and the feed strip read that instead.
- An install with no load history (load_forecast only, where load_last_period is
  hard-coded to 0) counted as low load, so with car_energy_reported_load on every
  dispatch was cancelled 10 minutes in. No load data is now no evidence.
- An unknown/unavailable reading left the grace timer running, so off ->
  unavailable -> off cancelled without a fresh grace. No evidence now restarts it.
- A cancelled slot kept its cost. The cost is now zeroed, and publish_car_plan()
  includes kwh_cancelled in the planned attribute, so the entity attributes say
  why kWh is 0.
- The "will never be trusted" warning now lists only cars with an
  octopus_intelligent_slot of their own, not every car without a sensor.
- The feed strip re-strips the same minutes every cycle. It now logs only when
  the number stripped changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@springfall2008
springfall2008 merged commit e96ecd9 into main Sep 26, 2026
2 checks passed
@springfall2008
springfall2008 deleted the feat/dynamic-load-car-not-charging branch September 26, 2026 08:57
chalfontchubby added a commit that referenced this pull request Sep 26, 2026
…, test isolation

- car_charging_hold_active() reads the car slots, limit and SoC of the model the
  row was drawn from: the given prediction, else what the live prediction gets -
  car_charging_slots_model() and car_charging_limit_model. So a car reporting
  car_charging_now outside its plan shows the hold execute_plan() applies since
  #5245. The docstring and output.py comment no longer claim prediction.py and
  execute.py share one car-full gate (#4967 uncaps it).
- Yesterday's actual-history table shows measured SoC, so its car icon now
  follows the recorded "Hold for car" status (publish_html_plan's new
  car_hold_minutes), not the model. Both rows and split segments go through
  one plan_row_holding_for_car().
- A split row held for the car before an export uses a new
  hold_for_car_before_export reason ("Until HH:MM, ...") and the JS joins it
  to the export half with "Then", like the demand split.
- Tests: the car hold tests save/restore car state and set SoC/limit
  explicitly; new cases for a car slot starting with the export, from-battery
  on in a split, a #5229 cancelled (kwh 0) slot, set_charge_window off,
  car_charging_now with no slot, the prediction's own limit, recorded hold
  minutes, and the Yesterday minutes-ago mapping; Test 12 checks the JS
  "Then" branch; the _render mirror uses >= 2 like the JS.
- Docs: the plan card describes the car icon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dragon2611 pushed a commit to dragon2611/batpred that referenced this pull request Sep 26, 2026
…s only well inside a dispatch

Checking Octopus Intelligent slots against whether the car is charging (from
springfall2008#5229) was gated on metric_dynamic_load_adjust, which defaults Off, and
predbat_config.json persists every setting, so almost no install had it on.
A live test on a Wallbox found nothing was ever cancelled for exactly that
reason.

- New switch.predbat_octopus_intelligent_dynamic (expert mode, default On) gates
  it instead. It still only runs for an Intelligent car with evidence: a real
  car_charging_now entity, or the house load with the car inside the CT clamp.
  So existing installs with a sensor get it without changing a setting.
  metric_dynamic_load_adjust is now only the load estimate. The trust switch's
  Off mode needs the new switch, and warns when it is Off.

Octopus schedules dispatches to the minute (the live one was 15:55-16:01:22, two
entries), so the check now judges them that way:

- "In a slot" uses the exact clock (now_utc_real plus skew), not minutes_now
  floored to 5 minutes, in the check and the poll alike. A dispatch ending at
  12:37 is over at 12:37, not 12:40, and the car stopping because it ended is
  no longer read as "not charging".
- Back-to-back slots are one dispatch, so a long dispatch split into half hours
  has no start band at each join.
- A sensor's "not charging" only counts from 3 minutes into the dispatch, while
  the car and charger wake up (earliest cancel start + 5). An in-progress
  dispatch's start can be advanced to now each cycle, so the earliest start seen
  is kept, keyed on the dispatch's end.
- A load reading is a 5 minute average and only counts when that whole window
  lies inside the dispatch. The grace is one more window on the 5 minute grid,
  so two low windows in a row cancel: start + 10 on the grid.
- "Charging" from the sensor still counts anywhere in the dispatch. There is no
  band at the end.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
gcoan pushed a commit to gcoan/batpred that referenced this pull request Sep 28, 2026
… are fetched (springfall2008#5286)

fetch_octopus_rates() replaced self.io_adjusted on every call, so the export
and gas fetches that follow the import fetch reset it to {} - every
BottlecapDave IOG user with metric_octopus_export or metric_octopus_gas set
lost all is_intelligent_adjusted markers. Regression from springfall2008#2826, which
dropped the old "only when adjust_key is set" guard.

Among other things this left dynamic_load_car_strip_feed_rates() with
nothing to strip, so a car cancelled by springfall2008#5229 still left its dispatch priced
cheap and the house battery was planned into it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
springfall2008 pushed a commit that referenced this pull request Oct 5, 2026
* feat(octopus): don't trust Intelligent slots the car won't need (#4482)

With octopus_intelligent_consider_full on, load_octopus_slots() already ends
each car's slots where it reaches its limit, so the car is modelled not to
draw in the rest - but every dispatch minute still kept its cheap rate, and
the house battery planned around slots Octopus only bills cheap when the car
charges in them.

octopus_surplus_minutes() works out the future dispatch minutes no car still
needs (rounded out to whole half hours, shared across cars, outside the fixed
23:30-05:30 window), and both rate paths now withhold them the way they
already do for a cancelled car: rate_add_io_slots() doesn't stamp them, and
dynamic_load_car_strip_feed_rates() removes a discount the rate feed already
delivered. Elapsed minutes keep their rate so today's cost stays true.
Behaviour is unchanged with consider_full off.

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

* fix(octopus): a cancelled car's slots still count as needed by the car-need gate

dynamic_load_car_check() cancels a car by zeroing its future slots and keeping
the kWh in kwh_cancelled. The car-need gate read that zero as "the car won't
need it", so with octopus_intelligent_consider_full on it stripped the cheap
rate from the rest of the half hour #5229 deliberately keeps for a car seen
charging in it. Cancellation decides a cancelled car's rate itself; the gate
now only answers whether a slot is beyond the car's need.

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

* fix(octopus): keep the current half hour out of the car-need gate

The surplus started at minutes_now, which usually falls mid-period, so the rest
of the current half hour lost its low rate once the car was full - even when
the car charged earlier in it, which makes Octopus bill the whole period cheap.
Surplus now starts at the next half-hour boundary. Clamping back to the start
of the current period instead would have marked elapsed minutes as surplus too,
and rate_add_io_slots() applies the skip to past minutes, misreporting today's
cost.

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

* fix(octopus): decide the car-need gate per car before sharing (#5403 review)

Copilot's review of the gate found four ways it could misprice a dispatch:

- The surplus started at the next half-hour boundary computed as a ceiling,
  which is the current boundary when the floored clock sits on one (a fetch at
  14:02 reads 14:00), so the current half hour could still lose its rate. It now
  starts at the strictly next boundary.
- A cancelled car's kwh_cancelled counted as needed for the whole slot, so it
  kept a full car's surplus minutes cheap though neither car would charge then.
  A car's need now counts only before its own cheap rate ends
  (dynamic_load_car_strip_from()), which still keeps the half hour GH#5316
  keeps for a car seen charging in it.
- A car not modelled from its Octopus slots was simply skipped, so a full car's
  overlapping surplus stripped its discount too. Its dispatches are now kept,
  up to its own strip_from.
- An untrimmed dispatch in progress (octopus_intelligent_slot isn't trimmed to
  now, unlike the Octopus API component's) is capped from its original start,
  so the capped slot can end before the car finishes. The running dispatch is
  now left alone; dynamic load cancels it in real time once the car stops.

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

* fix(octopus): judge a running dispatch from now, and replan when the surplus changes (#5403 review)

Trefor's review of #5403:

1. A dispatch in progress was exempt from the gate for its whole length, so
   one long dispatch kept all its surplus cheap from the moment it started,
   while the same charge chunked into half-hour dispatches didn't. With
   consider_full on, load_octopus_slots() now caps a running dispatch's need
   from its remaining span and energy (as trim_started_dispatch() already does
   for the Octopus API component, but octopus_intelligent_slot doesn't), after
   the slot-cap chunking, which still counts it from its real start. The
   exemption is gone.

2. A change in the surplus didn't force a replan - the rates can move without
   the slots moving, as the car's SoC changes how many half hours it needs.
   fetch_sensor_data() now replans when it changes, guarded by save like
   dynamic_load_car_check(). Only the part of the previous surplus still ahead
   is compared, so time passing alone isn't a change.

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

---------

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.

2 participants