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
+50 -9
View File
@@ -31,6 +31,8 @@ from .dayplan import EFF, _hour_slices, _local
from .modetrade import CHARGE, GIVE, HOLD, HOUSE, REST
EPS = 1e-9
MIN_CUT = timedelta(seconds=1) # no step is cut closer to its ends
MAX_FLOOR_CUTS = 400 # a bound on the floor cuts of one curve
GAP_S = 60.0 # a longer gap between readings is not counted
MIN_COVER = 0.5 # share of the elapsed hour a reading must cover
@@ -88,10 +90,42 @@ def _spread(amount, caps, weights):
return got, max(0.0, left)
def _give_caps(roles, level, by, floor, hrs):
"""`(first, rest)`: what each battery that may give can give in a step
of `hrs` hours (its rating, or what it holds above the floor), those in a
give run first."""
caps = {n: min(by[n].rating_kw * hrs,
max(0.0, level[n] - floor) / 100.0
* by[n].capacity_kwh * EFF)
for n, r in roles.items() if r[2]}
first = {n: c for n, c in caps.items() if roles[n][0] == GIVE}
return first, {n: c for n, c in caps.items() if n not in first}
def _floor_cut(a, z, net, roles, level, by, floor, hrs):
"""Where in the step `[a, z)` the first battery that gives reaches the
floor, or None. A shortfall is spread over a step at a steady rate and
shared by what each can give, so a group that holds less than its part
of the shortfall, one of them short of its rating, is empty at
`held / part` of the step."""
left, cut = -net, None
for grp in _give_caps(roles, level, by, floor, hrs):
held = sum(grp.values())
if held > EPS and left > held + EPS and any(
c < by[n].rating_kw * hrs * (1.0 - 1e-9)
for n, c in grp.items()):
t = a + (z - a) * (held / left)
if a + MIN_CUT < t < z - MIN_CUT and (cut is None or t < cut):
cut = t
left = max(0.0, left - held)
return cut
def curve(batts, start, end, sun_kwh, use_kwh, *, floor_pct, ceiling_pct,
trust=1.0):
"""`{name: [(t, pct), ...]}` from `start` (each battery's level now) to
`end`, a point at every hour edge and every block edge. A battery's list
`end`, a point at every hour edge, every block edge and where a battery
reaches its grid level or, giving, the floor. A battery's list
stops where `sun_kwh(a, b)` is None (the sun is not known) or its
`stop_at` comes. `sun_kwh` and `use_kwh` give kWh for the span `[a, b)`.
Spare solar is `trust x max(0, sun - use)`, and what is stored loses
@@ -109,6 +143,7 @@ def curve(batts, start, end, sun_kwh, use_kwh, *, floor_pct, ceiling_pct,
level = {b.name: float(b.soc) for b in batts}
out = {b.name: [(start, level[b.name])] for b in batts}
alive = {b.name for b in batts}
floor_cuts = 0
i = 0
while i < len(ts) - 1:
a, z = ts[i], ts[i + 1]
@@ -147,6 +182,14 @@ def curve(batts, start, end, sun_kwh, use_kwh, *, floor_pct, ceiling_pct,
continue
roles = {b.name: _role(b, a, bool(want), b.name in want)
for b in batts if b.name in alive}
# A battery that gives all it holds inside the step gets a point
# where it reaches the floor, the same way.
cut = _floor_cut(a, z, net, roles, level, by, floor, hrs) \
if net < -EPS and floor_cuts < MAX_FLOOR_CUTS else None
if cut is not None:
floor_cuts += 1
ts.insert(i + 1, cut)
continue
for n, to in want.items(): # charging from the grid
b = by[n]
room = (to - level[n]) / 100.0 * b.capacity_kwh
@@ -165,12 +208,7 @@ def curve(batts, start, end, sun_kwh, use_kwh, *, floor_pct, ceiling_pct,
for n, v in got.items():
level[n] += v * EFF / by[n].capacity_kwh * 100.0
elif net < -EPS:
caps = {n: min(by[n].rating_kw * hrs,
max(0.0, level[n] - floor) / 100.0
* by[n].capacity_kwh * EFF)
for n, r in roles.items() if r[2]}
first = {n: c for n, c in caps.items() if roles[n][0] == GIVE}
rest = {n: c for n, c in caps.items() if n not in first}
first, rest = _give_caps(roles, level, by, floor, hrs)
left = -net
for grp in (first, rest):
got, left = _spread(left, grp, dict(grp))
@@ -234,6 +272,9 @@ class SolarToday:
def _hour_start(t: datetime) -> datetime:
"""The start of `t`'s local hour, in UTC. Taken back from the UTC instant,
never from the wall clock, so each of the two 02:00 hours of the autumn
change has its own (`dayplan._hour_slices()`, SAFETY-123)."""
lt = _local(t)
return lt.replace(minute=0, second=0, microsecond=0).astimezone(
timezone.utc)
return t.astimezone(timezone.utc) - timedelta(
minutes=lt.minute, seconds=lt.second, microseconds=lt.microsecond)
+1 -1
View File
@@ -456,7 +456,7 @@
"measured_from": "Measured solar is counted from {at}, when the add-on started.",
"tomorrow_total": "Solar expected tomorrow: {kwh}, we count on {count}.",
"no_blocks": "No charge or give blocks planned.",
"give_later": "The add-on plans to give power. No block shows while the battery has no energy to give.",
"give_later": "The add-on plans to give power. No block shows while a battery has no energy to give.",
"ahead": "The add-on plans about a day ahead. Blocks for later hours appear as they come near."
},
"region": {
+1 -1
View File
@@ -384,7 +384,7 @@
"measured_from": "Gemeten zon telt vanaf {at}, toen de add-on startte.",
"tomorrow_total": "Verwachte zon morgen: {kwh}, we rekenen op {count}.",
"no_blocks": "Geen laad- of leverblokken gepland.",
"give_later": "De add-on plant stroom te leveren. Er verschijnt geen blok zolang de batterij geen stroom heeft.",
"give_later": "De add-on plant stroom te leveren. Er verschijnt geen blok zolang een batterij niets kan leveren.",
"ahead": "De add-on plant ongeveer een dag vooruit. Blokken voor latere uren verschijnen als ze naderen."
},
"region": {"preset": "Heffingen en btw: {region}, cijfers van {year}.", "own": "Heffingen en btw: uw eigen bedrag."},