feat(manual): extend manual rate override horizon to 7 days - #4930
Conversation
A manual import/export/load/soc rate override placed more than 48 hours ahead was silently discarded: manual_rates() dropped the slot, rewrote the select back to "off" and pushed that to HA, with no error anywhere. From a user's point of view the entry simply vanished after being saved. That 48 hour bound makes sense for a manual charge/export/freeze/demand slot, which Predbat can only act on if the plan reaches it. A rate override is different: it is future tariff data that only has to be REMEMBERED until the plan reaches it. The case that motivated this is an energy supplier announcing a free-electricity hour several days in advance - users are told about it, enter it straight away, and it disappears. Split the two horizons into named constants and give rate overrides 7 days, which is the ceiling the "Day HH:MM" selection format can express anyway. Manual time slots keep their 48 hours. The web rate-override endpoint's matching validation follows the same constants, so its error message can no longer drift away from what the engine actually accepts. Out-of-plan-window overrides are inert until the plan reaches them: rate_minmax() and publish_rates() are both bounded by forecast_minutes, so a distant slot cannot skew rate min/max/average or the published rates. Tests: test_manual_times T14 (a rate override 4 days out survives) and T15 (a manual demand slot the same distance out is still dropped). T14 fails against the old 48 hour value. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
The updated html_rate_override() path still needs input validation and a safer midnight origin to avoid returning 500s on bad time strings and to prevent clear-operations from misaddressing stored rates under the known midnight_utc mutation scenario.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Extends Predbat’s manual rate override retention horizon so users can enter future tariff events (e.g., free-electricity hours announced days ahead) without the override being silently dropped, while keeping manual time/action overrides bounded to the plan horizon.
Changes:
- Introduces separate horizon constants for manual time overrides (48h) vs manual rate overrides (7d) in
const.py. - Updates manual override decoding/validation to use the new constants (UI parsing in
userinterface.py, web endpoints inweb.py). - Adds regression tests covering a retained 4-day-ahead rate override and a still-dropped 4-day-ahead manual time slot.
File summaries
| File | Description |
|---|---|
| apps/predbat/web.py | Uses the new horizon constants in rate/plan override endpoints and derives the validation message from the constants. |
| apps/predbat/userinterface.py | Switches manual rate/time override horizons from hardcoded values to named constants. |
| apps/predbat/tests/test_manual_times.py | Adds tests verifying 7-day rate retention vs 48-hour time-slot dropping. |
| apps/predbat/const.py | Adds MANUAL_TIME_MAX_MINUTES (48h) and MANUAL_RATE_MAX_MINUTES (7d) constants with rationale. |
Review details
Suppressed comments (1)
apps/predbat/web.py:4647
- html_rate_override() computes minutes_from_midnight using self.midnight_utc (base.midnight_utc). manual_rates()/manual_times() deliberately avoid base.midnight_utc because calculate_yesterday() can rewrite it temporarily (#4900); using it here can make Clear Import/Export/SOC fail to find the actual stored rate and generate a non-matching clear option. Use the same origin as manual_rates by calling base.manual_time_origin() (or deriving midnight from now_utc) before calculating minutes_from_midnight.
return web.json_response({"success": False, "message": f"Override time must be within {max_hours} hours from now."}, status=400)
# Calculate minutes from midnight for looking up existing rates
minutes_from_midnight = int((override_time - self.midnight_utc).total_seconds() / 60)
- Files reviewed: 4/4 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Problem
A manual rate override (
select.predbat_manual_import_ratesand friends) placed more than 48 hours ahead is silently discarded.manual_rates()filters on(minutes - minutes_now_real) < manual_rate_maxwheremanual_rate_max = 48 * 60. A slot beyond that is dropped, the select value is rebuilt without it —offif nothing is left — and pushed back viaexpose_config(..., force=True). No warning is logged and nothing surfaces to the user, so the entry simply vanishes shortly after being saved.The case that motivated this: an energy supplier announces a free-electricity hour several days in advance. Users are told about it, enter the 0p override straight away, and it disappears. Repeatedly, because there is no feedback telling them why. It only starts working once the target slot drifts inside 48 hours, which looks like an intermittent bug rather than a horizon.
Why rate overrides are different from time overrides
The 48 hour bound is right for a manual charge/export/freeze/demand slot: Predbat can only act on a slot the plan actually reaches, so there is no point holding one further out.
A rate override is not an instruction to act — it is future tariff data that only has to be remembered until the plan reaches it. Nothing about it needs the plan to be that long.
Change
const.py:MANUAL_TIME_MAX_MINUTES(48 hours, unchanged) andMANUAL_RATE_MAX_MINUTES(7 days).Day HH:MMselection format can express anyway —get_override_time_from_string()resolves a weekday to the next occurrence within 7 days — so this is the natural limit rather than an arbitrary one.>= 48 * 60validation now reads the same constants (and the plan-override endpoint reads the time constant), so its error message can no longer drift away from what the engine accepts. The message is derived from the constant instead of being hardcoded.Safety of distant overrides
An override outside the plan window is inert until the plan reaches it:
apply_manual_rates()writes into a plain dict, so an out-of-window minute key is simply never read.rate_minmax()iteratesrange(minutes_now, forecast_minutes + minutes_now)— a distant 0p slot cannot dragrate_min/rate_averagedown.publish_rates()iterates up tominutes_now + forecast_minutes + 24 * 60, so a distant slot is not published until it comes into range.Tests
Added to
test_manual_times(inline inrun_test_manual_times, so it runs underunit_test.py, not only under pytest):manual_select()→manual_rates().T14 fails against the old 48 hour value (verified by reverting the constant), so it is not vacuous.
unit_test.py -k manual_times / manual_select / manual_api / manual_soc / manual_overridesall pass.