UX-54 round 5: give blocks end at the floor point, the tab on the clock-change days
checks / unit (pull_request) Failing after 50m11s
infra-pin / fresh (pull_request) Successful in 1s

daycurve.curve() puts a point where a giving battery reaches the floor
inside a step (_floor_cut(), as it already does for a grid charge reaching
its level), so _stops() ends a give block in the quarter control stops. A
walk where control moves the packs with its own model (not the curve's
levels) matches the tab quarter by quarter in 11 cases; 25/45 % with the
peak guard: control 17:00-20:15, block 17:00-20:06.

SolarToday keys an hour by its start taken back from the UTC instant, so
the two 02:00 hours of the autumn change stay apart. test_ux54_dst.py builds
the tab (forecast_payload()) at every quarter of 27-29 March and
30 October-1 November 2027, modes on and off, in the no-tzdata zone and,
where the host has tzdata, ZoneInfo, every call under a time limit raised
as a BaseException that forecast_payload() cannot swallow.

give_later: "No block shows while a battery has no energy to give." /
"Er verschijnt geen blok zolang een batterij niets kan leveren."
DECISIONS: what the walks prove, the modes-off charge drawn on a sunny day,
the clock-change days.

No decision, command, timing or option key changes.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
This commit is contained in:
glenn schrooyen
2026-10-27 15:54:33 +01:00
co-authored by Claude
parent 00c8ae354c
commit 8dbf831854
6 changed files with 536 additions and 24 deletions
@@ -31,22 +31,28 @@ battery's expected level with its blocks. It follows the add-on, as Home does
block where the emptiest battery is at the floor by that count, and control
does not give there. A charge is read from the floor: a full battery covers
the house before the cheap hour, and control charges it back then. Walked
every 15 minutes on a cloudy day with the house (`test_ux54.py` part 7,
cases A-C), control charges and gives in exactly the quarters the tab has a
block.
every 15 minutes on a cloudy day, the packs moved by control and a 400 W
house (`test_ux54.py` part 7, cases A-D, one price quarter per leg),
control charges and gives in exactly the quarters the tab has a block.
- **A block lasts as long as control acts.** A cheap run is sized for all the
batteries charging together, so a slower one is still below
`modetrade.charge_to()` when it ends, and control plans the next cheap slots
(`Controller._price_runs()`). `dayview._levels()` asks the same functions,
`modetrade.energy()` and `priceruns.plan()`, with the packs at the curve's
level, and draws the grid block to where control stops. Each battery's block
then ends where the curve has it at its own level (the curve puts a point
there), and a give block where it reaches the floor (a step end, within the
0.1 % the tab shows). Control, walked every 15 minutes through tomorrow
(`test_ux54.py` part 7), charges and gives in exactly the quarters the tab
has a block. A GIVE role counts only where control's own discharge envelope
(`packshare.envelope()`) is open; the walk puts each pack's cut-out at the
floor.
then ends where the curve has it at its own level, and a give block where
it reaches the floor: the curve puts a point at both (`daycurve.curve()`
cuts the step there), and the block ends at the first point where the level
the tab shows (0.1 %) is there. What is proven: control walked every 15
minutes through tomorrow with its own model of the packs, not the curve's
levels (`_mode_roles()` each quarter; the house from the profile, the sun
at 100 % trust, a GIVE role counted only where control's own discharge
envelope `packshare.envelope()` is open, the cut-out at the floor), charges
and gives in exactly the quarters the tab has a block in 11 cases
(`test_ux54.py` part 7, `own_walk()`), e.g. 25/45 % with the peak guard:
control gives 17:00-20:15, the block is 17:00-20:06. With a cut-out under
the floor, or a house or sun that differs from the forecast and profile,
control differs (Limits).
- **The sun** is what `solarfc` reads. An hourly split (Solcast
`detailedForecast`, Open-Meteo `wh_period`) is used as given. A daily total
(Forecast.Solar, and any other state-only sensor, the owner's three "today
@@ -89,7 +95,7 @@ is `SolarToday.add()` after the solar read, in a `try`, in memory.
at its level (`charge_to()`) and giving at the floor.
- Today, a give run in the plan on a battery that is empty now has no block on
Home (no energy to give). The tab equals Home, and says so: "The add-on plans
to give power. No block shows while the battery has no energy to give." The
to give power. No block shows while a battery has no energy to give." The
curve gives in that run if the sun fills the battery first.
- With no forecast for tomorrow the curve stops at the first hour of the sun,
`solarfc.BELL_FROM_H` (06:00): `Forecast.between()` has no answer past it. The
@@ -103,11 +109,22 @@ is `SolarToday.add()` after the solar read, in a `try`, in memory.
hour, net of the solar the house used.
- Modes off: the count knows no sun and no house. A battery at the floor that
the sun fills before a give gets no block until then (the tab says "The
add-on plans to give power. No block shows while the battery has no energy
to give."), and a small charge may be used by the house before the give.
add-on plans to give power. No block shows while a battery has no energy to
give."), and a small charge may be used by the house before the give. The
other way round, a charge is drawn even when the sun fills the batteries
before it: at the ceiling control then takes none (a cheap quarter at 13:00
on a sunny day: the tab draws the charge, control charges nothing at 95 %
or 100 %).
A leg is the verdict's as it stands now, one price period, as on Home; with
several equal prices in a row control takes the next one as each passes.
- Measured solar starts when the add-on starts; the tab says from when.
- Clock changes: the tab walks local hours with `dayplan._hour_slices()`,
which steps in UTC (SAFETY-123), and keys measured solar by the start of
the local hour taken back from the UTC instant, so the spring day has 23
hours, the autumn day 25 (two of them 02:00), and a request always returns
(`test_ux54_dst.py`, every quarter of both change days, with and without
tzdata). The solar bell is spread over hours from local midnight, so on
those two days it is an hour off the clock (SAFETY-123, not changed here).
## Found, not changed