From e4b9dee699653f5e4fcb7677fbdf761b0a345547 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Mon, 2 Nov 2026 10:15:20 +0100 Subject: [PATCH] OPT-36 round 3: B's charge counts the cheap time left; OPT-34 nights on their own shapes The run is sized at the batteries' ratings, not the peak limit, so a run too short to fill a Cheap hours battery is planned again later in the night. Counting that run alone made B's give grow through the night and A's level fall after A had bought (round 2: 55 -> 69 -> 41 -> 37 %, A to 56 %). dayplan._cheap_give_kwh() now also counts the later firm slots before the expensive hours that a run would still be worth charging in (price + the spread control asks at most the give run's dearest slot); the Controller passes the slots and the spread. test_opt34: back to the original house shapes, fixture and expected values. B is Off in the nights that test A's level (a Cheap hours B now covers these hours); B's checks run in new `_b` nights; the 03:00 check runs in a night from midnight; tail starts A at 60 %; late_fresh reads the kept 6.0. test_opt36: slot cases, a whole night on one figure (level never moves), F-1 with B counted (raised level reached and held). test_ux54: the run end is read from the plan and asserted (02:30); the F1/F2 sweep uses a peak limit that does not bind and reads a level shown as the target as at it. Co-Authored-By: Claude --- ...-the-night-charge-covers-the-dear-hours.md | 2 +- goodwe_controller/DOCS.md | 2 +- goodwe_controller/app/dayplan.py | 52 ++++--- goodwe_controller/app/main.py | 4 +- goodwe_controller/test_opt34.py | 82 ++++++----- goodwe_controller/test_opt36.py | 130 ++++++++++++++++-- goodwe_controller/test_ux54.py | 34 ++++- 7 files changed, 232 insertions(+), 74 deletions(-) diff --git a/goodwe_controller/DECISIONS/OPT-23-28-the-night-charge-covers-the-dear-hours.md b/goodwe_controller/DECISIONS/OPT-23-28-the-night-charge-covers-the-dear-hours.md index 0d85aaa..99a0e77 100644 --- a/goodwe_controller/DECISIONS/OPT-23-28-the-night-charge-covers-the-dear-hours.md +++ b/goodwe_controller/DECISIONS/OPT-23-28-the-night-charge-covers-the-dear-hours.md @@ -8,5 +8,5 @@ - **House use.** An hour with sun is read at no less than the night base, as the sun sum reads it (the profile is net of the sun). An hour without is read as measured. An hour with no forecast counts no sun. - **Several batteries.** One total, shared by capacity with the ceiling passed on (`dayplan._spread()`). With the same floor and ceiling for all, they reach the ceiling together, so a shortfall that one battery cannot hold fills both to the same level, not one first. More than all hold: all go to the ceiling, `Plan.uncovered_kwh` is the rest, carried by another battery (Cheap hours, Solar first) or the grid, as the screen says. - **Cheap hours** still fills up; **Solar first** still never charges from the grid. The mode help says so. -- **OPT-36 (0.20.1):** what Cheap hours batteries will give in the expensive hours is taken off the shortfall before Leftover solar shares it (`dayplan._cheap_give_kwh()`: its level when they start, the ceiling or what its cheap run reaches under the peak aim, minus the floor, x capacity x EFF). Owner, 2026-11-02: Solar first batteries count too (`_solar_first_give_kwh()`): their level now less the house's night use until the hours start, shared by the energy held, then the same sum; a battery the night drains to the floor counts 0. Live, 2026-11-02: A at 60 % went to 95 % while B at 94 % covered the hours. Test: `test_opt36.py`. +- **OPT-36 (0.20.1):** what Cheap hours batteries will give in the expensive hours is taken off the shortfall before Leftover solar shares it (`dayplan._cheap_give_kwh()`: its level when they start, the ceiling or what it charges under the peak aim in the cheap run and the later slots still worth charging in, minus the floor, x capacity x EFF; the run alone, sized at the ratings, let A's level fall after A had bought). Owner, 2026-11-02: Solar first batteries count too (`_solar_first_give_kwh()`): their level now less the house's night use until the hours start, shared by the energy held, then the same sum; a battery the night drains to the floor counts 0. Live, 2026-11-02: A at 60 % went to 95 % while B at 94 % covered the hours. Test: `test_opt36.py`. - **Not done:** the run length (OPT-25). diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 0b7c438..42f9762 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -549,7 +549,7 @@ With battery modes on, these choices replace the *Charging and selling on price* - The hourly shape is kept only if every sensor has one. Otherwise the total is spread over the day as for Forecast.Solar. - Before each cheap run the add-on asks: will the sun fill the batteries before the expensive hours? It counts on `solar_trust_pct` of the forecast (70 %), less what the house uses. Solar first batteries get that sun first. - *Leftover solar* then charges from the grid only the shortfall: for each expensive hour, what the house uses less the sun counted for that hour (never below 0), added up over all the expensive hours, early morning and evening. With a day and night tariff that is 07:00 to 22:00. Not up to the top level. -- All *Leftover solar* batteries hold that shortfall together, shared by their size. A battery that reaches the top level passes the rest to the others. If all of them together cannot hold it, they all fill to the top level. *Cheap hours only* and *Solar first* batteries give first: what they will hold when the expensive hours start (a *Cheap hours only* battery full, or as far as its cheap run reaches under the peak limit; a *Solar first* battery after the night's use) is taken off the shortfall. In the expensive hours the rest comes from the grid. The Batteries screen says so. The sun before the expensive hours still fills first, and is taken off what is bought. +- All *Leftover solar* batteries hold that shortfall together, shared by their size. A battery that reaches the top level passes the rest to the others. If all of them together cannot hold it, they all fill to the top level. *Cheap hours only* and *Solar first* batteries give first: what they will hold when the expensive hours start (a *Cheap hours only* battery full, or as far as it can charge under the peak limit in the cheap hours left; a *Solar first* battery after the night's use) is taken off the shortfall. In the expensive hours the rest comes from the grid. The Batteries screen says so. The sun before the expensive hours still fills first, and is taken off what is bought. - A forecast that gives only one figure for the day (Forecast.Solar) is spread over the hours from now, so the hours add up to that figure. - *Cheap hours only* ignores the forecast and fills to the top level. - *Solar first* never charges from the grid. diff --git a/goodwe_controller/app/dayplan.py b/goodwe_controller/app/dayplan.py index fa27d77..53fe3af 100644 --- a/goodwe_controller/app/dayplan.py +++ b/goodwe_controller/app/dayplan.py @@ -231,14 +231,21 @@ def _room(p, to_pct): def _cheap_give_kwh(runs, now, house, packs, *, floor_pct, ceiling_pct, - aim_w): + aim_w, until=None, slots=None, spread=None): """OPT-36: what the Cheap hours batteries in `packs` give in the expensive hours, in kWh on the house's side: (the level at the start of those hours - the floor) x capacity x `EFF`. - That level is the one its own cheap run reaches: the ceiling, or less when - the run cannot get there. From now to the run's end (or the whole run - ahead of it) the batteries together charge at their ratings, held to what + That level is the one its cheap charging reaches: the ceiling, or less + when the time left cannot get there. That time is the cheap run from now + (or the whole run ahead of it) and, given the firm price `slots`, each + later slot before the expensive hours (`until`, a give run) still worth + charging in: its price plus the spread control asks (`spread`) at most + the give run's dearest slot, `priceruns.plan()`'s test. The run is sized + at the batteries' ratings (`priceruns.need_hours()`), not the peak limit, + so a run too short to fill them is planned again in that time; counting + the run alone let A's level fall in the night after A had bought. In + that time the batteries together charge at their ratings, held to what the peak aim leaves above the house's use that hour (`aim_w`, None: no limit), shared by rating. No cheap run worth charging in: the level now. A battery that refuses a direction counts 0.""" @@ -249,16 +256,26 @@ def _cheap_give_kwh(runs, now, house, packs, *, floor_pct, ceiling_pct, gain = {p.name: 0.0 for p in ps} rate = {p.name: abs(float(p.rating_w)) for p in ps} total = sum(rate.values()) + run = runs.cheap + spans = [(max(now, run.start), run.end)] + top = max((eur for s, _e, eur in slots or () + if until is not None and until.start <= s < until.end), + default=None) + if top is not None and spread is not None: + spans += [(max(s, now, run.end), min(e, until.start)) + for s, e, eur in slots + if eur + float(spread) <= top + 1e-9 + and e > run.end and s < until.start] if runs.charge_useful() and total > 0.0: - for s, e, h in _hour_slices(max(now, runs.cheap.start), - runs.cheap.end): - w = total - if aim_w is not None: - w = min(w, max(0.0, float(aim_w) - - max(house_w(h), base_w, 0.0))) - hrs = (e - s).total_seconds() / 3600.0 - for p in ps: - gain[p.name] += w * rate[p.name] / total * hrs / 1000.0 + for t0, t1 in spans: + for s, e, h in _hour_slices(t0, t1): + w = total + if aim_w is not None: + w = min(w, max(0.0, float(aim_w) + - max(house_w(h), base_w, 0.0))) + hrs = (e - s).total_seconds() / 3600.0 + for p in ps: + gain[p.name] += w * rate[p.name] / total * hrs / 1000.0 out = 0.0 for p in ps: cap = float(p.capacity_kwh) @@ -289,11 +306,13 @@ def _solar_first_give_kwh(house, now, start, packs, *, floor_pct): def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct, - ceiling_pct, aim_w=None): + ceiling_pct, aim_w=None, slots=None, spread=None): """The day plan, or None when there is none to make: no forecast, no house profile yet, no price plan, or no expensive run after the cheap run. `house` is `HouseProfile.estimator()`'s answer. `packs` are - `modetrade.Pack`s.""" + `modetrade.Pack`s. `aim_w`, `slots` (`priceruns.firm_slots()`) and + `spread` (the price gap control asks, EUR/kWh) size a Cheap hours + battery's charge (`_cheap_give_kwh()`).""" if forecast is None or house is None or runs is None or not runs.on \ or runs.cheap is None: return None @@ -346,7 +365,8 @@ def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct, # Leftover solar holds the rest. A Leftover solar battery's own stored energy counts through its # level: it buys from where it is to the level it needs. cheap = _cheap_give_kwh(runs, now, house, usable, floor_pct=floor_pct, - ceiling_pct=ceiling_pct, aim_w=aim_w) \ + ceiling_pct=ceiling_pct, aim_w=aim_w, + until=until, slots=slots, spread=spread) \ if price else 0.0 cheap = min(cheap, short) sfirst = _solar_first_give_kwh(house, now, until.start, usable, diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 6060099..da551c9 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -2375,7 +2375,9 @@ class Controller: else self.house_profile.estimator(), priceruns.keep_started(self.mode_runs, runs, now), now, packs, trust_pct=self.solar_trust_pct, floor_pct=floor_pct, - ceiling_pct=ceiling_pct, aim_w=self.peak_aim_w()) + ceiling_pct=ceiling_pct, aim_w=self.peak_aim_w(), + slots=priceruns.firm_slots(plan.read.points, now), + spread=required) self.day_plan = dp if dp is None: self._plan_targets = None diff --git a/goodwe_controller/test_opt34.py b/goodwe_controller/test_opt34.py index a81544e..bf83d36 100644 --- a/goodwe_controller/test_opt34.py +++ b/goodwe_controller/test_opt34.py @@ -26,7 +26,9 @@ DECISIONS note and the dev report. solar + B Cheap hours, both 10 % at the start of the night, the night rate 22:00-07:00, tomorrow 6 kWh, a 2.5 kW peak limit so the charge takes hours), sensors silent from 17:05, whole nights at 5 s per cycle (run in parallel - processes, about 2.5 minutes each): + processes, about 2.5 minutes each). OPT-36: B is Off in the nights that + test A's level (a Cheap hours B now counts before A buys and would cover + these hours); the `_b` nights keep it on for B's checks: a. silent from 17:05: both batteries end the night where a fresh figure ends them (the base left A at 57 % against 87 %), and the screens agree; b. restarts at 01:00 and 04:00 change nothing; @@ -267,8 +269,11 @@ PROFILE_LATE = ('{"periods": [' ' "to": "22:00", "eur_per_kwh": 0.34}]}') CHOICE = {"a": (True, "leftover_solar", False), "b": (True, "cheap_hours", False)} +# OPT-36: a Cheap hours B now gives first, and with these house shapes it would +# cover the dear hours alone, so A would not charge. The nights that test A's +# level have B Off (`b_off`, the default; the house is as written); `_b` +# nights keep B on Cheap hours for B's own checks. LAST = at(3, 17, 5) -MIDNIGHT = at(4, 0, 0) EVENING = [at(3, 16, 0) + timedelta(minutes=30 * i) for i in range(12)] \ + [at(3, 21, 55)] @@ -291,12 +296,7 @@ def sensors(*, silent=True, fresh_from=None, fresh=(1.0, 5.0), when = now if fresh_until is None else min(now, fresh_until) return st(fresh[0] if ent == T_ENT else fresh[1], when) when = min(now, LAST) if silent else now - # OPT-36: a fresh sensor reads the day it is on. After midnight "today" - # is the 4th (6.0 kWh, the figure the 17:05 reading called tomorrow's), - # not the evening's 0.4 kWh left of the 3rd; with that, fresh and - # kept are the same figure all night (before, the plans only agreed - # because A was capped at the ceiling in both). - return st(0.4 if ent == T_ENT and now < MIDNIGHT else 6.0, when) + return st(0.4 if ent == T_ENT else 6.0, when) return f @@ -305,12 +305,12 @@ def run_night(spec): hours, silent, fresh_from, fresh, restarts, soc, load_until_s, marks.""" start = spec["start"] soc0 = spec.get("soc", {"a": 10.0, "b": 10.0}) - # OPT-36: Cheap hours B now covers part of the dear hours, so the house the - # plan expects is 1.6 x larger than it was written for (700 W -> 1.1 kW): - # A still has a shortfall to buy and the night charge is still long. - house_w = 1.6 * spec.get("house_w", 700.0) + house_w = spec.get("house_w", 700.0) store = rp._Store() - for u, (use, mode, sell) in CHOICE.items(): + choice = dict(CHOICE) + if spec.get("b_off", True): + choice["b"] = (False, "cheap_hours", False) + for u, (use, mode, sell) in choice.items(): for k, val in (("pack_use", use), ("pack_mode", mode), ("pack_sell", sell)): store.data[unit_key(f"inverter-{u}", k)] = val @@ -428,15 +428,24 @@ SCEN = { marks=[at(4, 4, 30)]), "silent": dict(start=NIGHT, hours=9, marks=[at(4, 1, 0), at(4, 4, 30), at(4, 6, 30)]), + # OPT-36: B on Cheap hours, for B's checks + "fresh_b": dict(start=NIGHT, hours=9, silent=False, b_off=False), + "silent_b": dict(start=NIGHT, hours=9, b_off=False), + # OPT-36: alone, A is full by 02:00 from 22:00; from midnight its charge + # runs through 03:00 + "midnight": dict(start=at(4, 0, 0), hours=7), "restart": dict(start=NIGHT, hours=9, restarts=[at(4, 1, 0), at(4, 4, 0)], marks=[at(4, 1, 30), at(4, 4, 30)]), # c. the charge cannot start before 03:00 (the price dips at 04:00) "late": dict(start=at(4, 2, 30), hours=4.5, late=True, house_w=500.0, soc={"a": 40.0, "b": 70.0}, marks=[at(4, 3, 30)]), + # OPT-36: the fresh sensors read the kept figure's 6.0 kWh for the 4th. + # Alone, A reaches its level, so the runs must plan on the same sun (with + # B charging too, A reached neither level by 07:00 and they matched). "late_fresh": dict(start=at(4, 2, 30), hours=4.5, late=True, house_w=500.0, soc={"a": 40.0, "b": 70.0}, - silent=False), + fresh_from=at(4, 2, 30), fresh=(6.0, 6.0)), # d. a fresh, darker figure at 02:00 during the charge (a lighter house # use, so the level has room to rise) "dark": dict(start=at(4, 1, 30), hours=2.0, soc={"a": 45.0, "b": 45.0}, @@ -455,8 +464,10 @@ SCEN = { house_w=500.0, fresh_from=at(4, 2, 40), fresh=(30.0, 30.0), marks=[at(4, 2, 44)]), # d. the last hour, after the kept figure ended at 06:00 + # (OPT-36: A at 60 %, not 70 %: alone it charges twice as fast and from + # 70 % is full at 06:38, before the 06:40 mark) "tail": dict(start=at(4, 5, 40), hours=80 / 60.0, - soc={"a": 70.0, "b": 70.0}, restarts=[at(4, 6, 30)], + soc={"a": 60.0, "b": 70.0}, restarts=[at(4, 6, 30)], marks=[at(4, 5, 50), at(4, 6, 10), at(4, 6, 40)]), # a fresh, brighter figure at 03:10 lowers A's level below its SoC (A # stops, B goes on), then the sensors go quiet: the forecast ends at 06:10 @@ -470,12 +481,12 @@ SCEN = { latch={"until": at(3, 7, 15).isoformat(), "born": at(2, 23, 0).isoformat(), "levels": {A: 80.0, B: 80.0}}, - marks=[at(3, 22, 20)]), + marks=[at(3, 22, 20)], b_off=False), "stale_live": dict(start=at(3, 22, 0), hours=0.5, gone=True, latch={"until": at(4, 7, 15).isoformat(), "born": at(3, 22, 0).isoformat(), "levels": {A: 80.0, B: 80.0}}, - marks=[at(3, 22, 20)]), + marks=[at(3, 22, 20)], b_off=False), # the same, B already full and a 1.5 kW limit: the run is sized at the # battery's 2.5 kW rating and ends at 06:33, before A is there, so the # next slice is planned after 06:00 and is only A's @@ -515,20 +526,20 @@ for k in R: fa, si, rr = R["fresh"], R["silent"], R["restart"] # a. silent from 17:05 -# OPT-36: A's level is 95 % to ~05:00, then 78.4 % when B's cheap run is longer -# (B's give 5.9 -> 7.1 kWh); A, already at 81.5 %, stops there. -check("a fresh figure all night: A ends at 81.5 % (the plan's 78.4 % came " - "after it had passed), B full", - abs(fa["soc"][0] - 81.5) < 0.5 and fa["soc"][1] > 94.5) +fb_, sb_ = R["fresh_b"], R["silent_b"] +check("a fresh figure all night: A ends at its planned level (87 %), B " + "(Cheap hours, on in the `_b` night) full", + abs(fa["soc"][0] - 86.9) < 0.5 and fb_["soc"][1] > 94.5) check("sensors silent from 17:05: A ends where the fresh run ends it " f"({si['soc'][0]:.1f} against {fa['soc'][0]:.1f} %)", abs(si["soc"][0] - fa["soc"][0]) < 0.3) check("... and so does B (Cheap hours; the wide no-forecast gap does not " - f"stop its slices): {si['soc'][1]:.1f} against {fa['soc'][1]:.1f} %", - abs(si["soc"][1] - fa["soc"][1]) < 0.3) + f"stop its slices): {sb_['soc'][1]:.1f} against {fb_['soc'][1]:.1f} %", + abs(sb_["soc"][1] - fb_["soc"][1]) < 0.3) +mn = R["midnight"] check("A charges through 03:00 without a break (no step at 03:00)", - si["soc_at"]["03:30"][0] > si["soc_at"]["03:00"][0] + 4.0 - and si["soc_at"]["03:00"][0] > si["soc_at"]["02:55"][0] + 0.3) + mn["soc_at"]["03:30"][0] > mn["soc_at"]["03:00"][0] + 4.0 + and mn["soc_at"]["03:00"][0] > mn["soc_at"]["02:55"][0] + 0.3) m1, m4 = si["marks"]["01:00"], si["marks"]["04:30"] check("01:00 and 04:30: the kept figure is in use, with a day plan, and the " "line says so (EN)", @@ -538,7 +549,7 @@ check("01:00 and 04:30: the kept figure is in use, with a day plan, and the " and m4["target_a"] is not None and near(m4["target_a"], fa["marks"]["04:30"]["target_a"], 1e-9)) check("... and the level is the fresh run's (the kept figure counts as a " - "fresh one)", near(m4["target_a"], 95.0, 0.2)) + "fresh one)", near(m4["target_a"], 86.87, 0.2)) m6 = si["marks"]["06:30"] check("06:30: the kept figure has ended (the bell started) and the screen " "does not say 'kept' (the charge was done by then in this shape)", @@ -613,12 +624,12 @@ check("a fresh, brighter figure lowers A's level below its SoC during the " and lo["soc_at"]["03:30"][0] < lo["soc_at"]["03:20"][0] + 0.5) check("the sensors go quiet, the forecast ends (06:30): the latch holds the " "LOWERED level, so A does not charge back up to the old one " - f"({lo['soc_at']['03:30'][0]:.1f} -> {lo['soc_at']['06:55'][0]:.1f} %), " - "while B goes on", + f"({lo['soc_at']['03:30'][0]:.1f} -> {lo['soc_at']['06:55'][0]:.1f} %)" + " (OPT-36: B is Off here; B's slices with no forecast are the `_b` " + "night's check above)", l2["fc_none"] and l2["target_a"] is not None and l2["target_a"] < l2["soc_a"] and l2["p_a"] > -200.0 - and lo["soc_at"]["06:55"][0] < lo["soc_at"]["03:30"][0] + 1.0 - and lo["soc"][1] > lo["soc_at"]["03:30"][1] + 15.0) + and lo["soc_at"]["06:55"][0] < lo["soc_at"]["03:30"][0] + 1.0) st_, sl_ = R["stale"], R["stale_live"] check("F1: a latch from the night before (ended 07:15) loaded at a restart " "inside a cheap run, with no forecast: nothing grid-charges " @@ -661,19 +672,14 @@ md = fd["marks"] print(f" f1_dark: A level 01:55 {md['01:55']['target_a']} -> 02:15 " f"{md['02:15']['target_a']}; A {fd['soc_at']['06:55'][0]:.1f} % " "at 06:55") -# OPT-36: the level (82.3 % at 02:15) falls to 64.2 % by 04:00 as B's cheap run -# is made longer (B's expected give grows); A stops at its SoC of that moment -# (72.8 %) and the latch holds 64.2 % after the figure ends. check("F-1 darker fresh figure at 02:00: the level rises, is held after " - "05:00 with no forecast (never 'read 17:05'), and A charges while it is " - "above its level", + "05:00 with no forecast (never 'read 17:05'), and A charges to it", md["02:15"]["target_a"] is not None and md["01:55"]["target_a"] is not None and md["02:15"]["target_a"] > md["01:55"]["target_a"] + 3.0 and all("17:05" not in md[h]["line"] for h in md if h >= "02:15") and all(md[h]["fc_none"] for h in md if "05:15" <= h < "07:00") - and near(md["05:15"]["target_a"], 64.24, 0.2) - and near(fd["soc_at"]["06:55"][0], 72.8, 0.5)) + and near(fd["soc_at"]["06:55"][0], md["02:15"]["target_a"], 1.5)) tb = R["tail_bfull"] check("B full: the run ends at 06:33 before A is at its level, so the next " diff --git a/goodwe_controller/test_opt36.py b/goodwe_controller/test_opt36.py index c12a460..4001e60 100644 --- a/goodwe_controller/test_opt36.py +++ b/goodwe_controller/test_opt36.py @@ -11,15 +11,19 @@ hold" out; this adds it. 1. The live figures through the real Controller: A buys 0, nothing is "uncovered", and Home says the same in EN and NL. 2. `dayplan.plan()` alone: B low with a short cheap run under the peak limit, - A buys only what B cannot reach; the numbers written out. + A buys only what B cannot reach; the numbers written out. Round 3: B's + charge counts the cheap slots left before the expensive hours, not only + the run (sized at the ratings), so the level does not fall in the night. 3. A Cheap hours battery that is off, refusing, in maintenance, not live or - without capacity counts 0; Solar first is not counted; N=1 and Leftover - solar only are unchanged. + without capacity counts 0; Solar first counts after the night's use + (owner, 2026-11-02); N=1 and Leftover solar only are unchanged. 4. The screens: the give line reads the level after the planned charge, `plan_cover()` is the plan's own figure, the Why line says who covers. 5. The real Controller through a whole night (parallel processes, 5 s per - cycle): A's commands follow the new level, and the OPT-34 latch still - holds a started run (a lowered level stays lowered). + cycle): A's commands follow the new level, which holds all night while + the figure does; OPT-34 F-1 with B counted (a raised level is reached and + held); the OPT-34 latch still holds a started run (a lowered level stays + lowered). Mutations (MUTATION.md, purged copies): listed in the pull request. """ @@ -345,6 +349,66 @@ give_two = sum((expect_b_level(30.0, CAP_B, g) - FLOOR) / 100.0 * CAP_B * EFF check("two Cheap hours batteries share the peak room by rating", abs(p_two.cheap_kwh - min(give_two, p_two.short_kwh)) < 0.01) +# Round 3: the run is sized at the ratings, not the peak limit, so a run too +# short to fill B is planned again later in the night. B's charge counts the +# firm price slots after the run, up to the expensive hours, that a run would +# still be worth charging in: price + the spread control asks (`spread`, 0.04 +# here) at most the give run's dearest slot (0.2801). Without them A's level +# fell through the night after A had bought. +RUN1 = priceruns.RunPlan( + priceruns.Run(loc(30, 4), loc(30, 5), 0.2333), RUNS.give, None, + RUNS.level_eur_kwh, None, True) + + +def quarters(t0, t1, eur): + out, t = [], t0 + while t < t1: + out.append((t, t + timedelta(minutes=15), eur)) + t += timedelta(minutes=15) + return out + + +NIGHT_Q = quarters(loc(30, 4), loc(30, 7), 0.2333) +DAY_Q = quarters(loc(30, 7), loc(30, 22), 0.2801) +LATE_Q = quarters(loc(30, 22), loc(31, 7), 0.2333) +PK2 = [pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A), + pk(B, packmode.CHEAP_HOURS, 30.0, CAP_B)] + + +def mk_slots(slots, runs=RUN1, spread=0.04): + return dayplan.plan(fc_of(6.0, T), profile_of(SHAPE, T).estimator(), + runs, T, PK2, trust_pct=70, floor_pct=FLOOR, + ceiling_pct=CEIL, aim_w=3450.0, slots=slots, + spread=spread) + + +p_run1 = mk_slots(None) +p_q = mk_slots(NIGHT_Q + DAY_Q + LATE_Q) +GIVE_1H = (expect_b_level(30.0, CAP_B, 2.85) - FLOOR) / 100.0 * CAP_B * EFF +print(f" a 1 h run (04:00-05:00): B gives {p_run1.cheap_kwh:.3f} without " + f"the slots, {p_q.cheap_kwh:.3f} with the night's slots to 07:00") +check("a 1 h run with no slots given: B counts what 1 h reaches (as before)", + abs(p_run1.cheap_kwh - GIVE_1H) < 0.005) +check("...and with the slots but no spread given, the same", + abs(mk_slots(NIGHT_Q + DAY_Q, spread=None).cheap_kwh - GIVE_1H) + < 0.005) +check("the same run with the night's quarters (0.2333) to 07:00: B " + "counts what 04:00-07:00 reach, as the 3 h run does (and A buys the " + "same); the day and the next night are not counted", + abs(p_q.cheap_kwh - GIVE_B) < 0.005 + and abs(p_q.targets[A] - p_low.targets[A]) < 0.01) +p_dear = mk_slots(quarters(loc(30, 5), loc(30, 7), 0.2500) + DAY_Q) +check("slots after the run not worth charging in (0.25 + 0.04 over the " + "dearest 0.2801) are not counted: B counts the 1 h run only", + abs(p_dear.cheap_kwh - GIVE_1H) < 0.005) +p_part = mk_slots(quarters(loc(30, 5), loc(30, 6), 0.2400) + + quarters(loc(30, 6), loc(30, 7), 0.2500) + DAY_Q) +GIVE_2H = (expect_b_level(30.0, CAP_B, 2.85 + 2.85) - FLOOR) / 100.0 \ + * CAP_B * EFF +check("...and only those are: 05:00-06:00 at 0.24 (dearer than the run, " + "still worth it) adds one hour (04:00-06:00 reach)", + abs(p_part.cheap_kwh - GIVE_2H) < 0.005) + # ============================================================================ # print("3. who counts, and who does not") # ============================================================================ # @@ -654,6 +718,7 @@ def run_night(spec): NIGHT = at(3, 22, 0) +HOURLY = [NIGHT + timedelta(minutes=30 * i) for i in range(18)] SCEN = { # The live night: A 60 % Leftover solar, B 94 % Cheap hours, a fresh # figure all night. A must not charge from the grid. @@ -663,7 +728,21 @@ SCEN = { # leaves B 1.65 kW in a 3 h run (the run is sized to the batteries' # ratings, not the limit), so B counts for part and A buys the rest. "mid": dict(start=NIGHT, hours=9, soc={"a": 25.0, "b": 25.0}, - house_w=800.0, marks=[at(3, 22, 30), at(4, 5, 0)]), + house_w=800.0, marks=HOURLY), + # Round 3: the same with one figure for the 4th all night (today 5.0 + # after midnight = tomorrow 5.0 before): A's level must not move at all. + # On round 2's code it went 55 -> 69 -> 41 -> 37 % and A bought to 56 %. + "mid_flat": dict(start=NIGHT, hours=9, soc={"a": 25.0, "b": 25.0}, + house_w=800.0, fresh=(5.0, 5.0), marks=HOURLY), + # Round 3, OPT-34 F-1 with B on Cheap hours and counted: a darker fresh + # figure at 02:00, then silent (no forecast from 05:00). The level rises, + # A charges to it, and the latch holds it after 05:00. + "f1_dark_b": dict(start=at(4, 0, 30), hours=6.5, cap=3500, + soc={"a": 50.0, "b": 50.0}, house_w=900.0, + fresh=(5.0, 5.0), change=at(4, 2, 0), + after=(1.0, 1.0), silent_from=at(4, 2, 0), + marks=[at(4, 1, 55)] + [at(4, 2, 15) + timedelta( + minutes=15 * i) for i in range(19)]), # OPT-34 with the new level: a fresh, brighter figure at 03:10 lowers A's # level below its SoC (A stops), then the sensors stay silent: the # forecast ends and the latch holds the LOWERED level to 07:00. @@ -701,11 +780,42 @@ check("mid night: the plan counts B's give and A's level is a partial one " "(not the ceiling, as 0.20.0 gave: 95 %)", m1["target_a"] is not None and 25.0 < m1["target_a"] < CEIL - 15.0 and m1["cheap"] > 3.0) -check("mid night: A's commands follow the levels: it stops charging at " - "its level and ends well under the ceiling (0.20.0: 95 %), and not " - "under the level the plan ends the night with", - md["soc"][0] < 85.0 and md["soc"][0] >= m2["target_a"] - 1.0) +check("mid night: B's counted give is the same at every mark (B reaches the " + "ceiling in the night left), and A's level moves only at midnight, " + "when the 4th's figure changes from tomorrow's 5.0 to today's 1.0", + len({mk["cheap"] for mk in md["marks"].values()}) == 1 + and len({mk["target_a"] for h, mk in md["marks"].items() + if h >= "12"}) == 1 + and len({mk["target_a"] for h, mk in md["marks"].items() + if h < "12"}) == 1) +check("mid night: A's commands follow the levels: it charges to its level " + "and stops there, well under the ceiling (0.20.0: 95 %)", + md["soc"][0] < 85.0 and abs(md["soc"][0] - m2["target_a"]) < 0.5) check("mid night: B fills as before (to the ceiling)", md["soc"][1] > 94.0) +mf = R["mid_flat"] +lv_f = {mk["target_a"] for mk in mf["marks"].values()} +print(f" mid_flat: A level(s) {sorted(lv_f)}; A ends {mf['soc'][0]:.2f} %") +check("one figure all night: A's level never moves (round 2: 55 -> 69 -> " + "41 -> 37 %), A charges to it and stops (round 2: 56 %)", + len(lv_f) == 1 and None not in lv_f + and abs(mf["soc"][0] - next(iter(lv_f))) < 0.5) +fd = R["f1_dark_b"] +dk = fd["marks"] +held = [dk[h]["target_a"] for h in dk if "05:15" <= h < "07:00"] +print(f" f1_dark_b: A level 01:55 {dk['01:55']['target_a']} -> 02:15 " + f"{dk['02:15']['target_a']}, held {sorted(set(held))}; A " + f"{fd['soc_at']['06:55'][0]:.1f} % at 06:55, B {fd['soc'][1]:.1f} %") +check("F-1 with B counted: a darker fresh figure at 02:00 raises A's level, " + "the level does not move after that (B's give is the same), it is held " + "with no forecast from 05:15, and A charges to it", + dk["01:55"]["target_a"] is not None + and dk["02:15"]["target_a"] > dk["01:55"]["target_a"] + 3.0 + and dk["01:55"]["cheap"] > 3.0 + and all(dk[h]["target_a"] == dk["02:15"]["target_a"] for h in dk + if h >= "02:15") + and all(dk[h]["fc_none"] for h in dk if "05:15" <= h < "07:00") + and abs(fd["soc_at"]["06:55"][0] - dk["02:15"]["target_a"]) < 1.5 + and fd["soc"][1] > 94.0) lw = R["lowered"] l0, l1, l2 = (lw["marks"][k] for k in ("03:05", "03:20", "06:30")) diff --git a/goodwe_controller/test_ux54.py b/goodwe_controller/test_ux54.py index 8838b4b..e2f3ac9 100644 --- a/goodwe_controller/test_ux54.py +++ b/goodwe_controller/test_ux54.py @@ -225,7 +225,12 @@ check("Leftover solar and Cheap hours: a give block exactly on the give run " # OPT-36: a larger evening (4.8 kW) so that B (Cheap hours) does not cover the # dear hours alone and A still buys from the grid; A starts at 30 %. c_n = S.make("dynamic", at(0, 18), soc=(30.0, 40.0), evening=4800.0) -END_S, END_H = "03:00", 3.0 # the run's end, recomputed for this house +# The run's end, read from the plan; for this house it is 02:30, as in 0.20.0. +END = c_n.mode_runs.cheap.end +END_H = (END - DAY0).total_seconds() / 3600.0 +END_S = S.brussels(END).strftime("%H:%M") +check(f"the cheap run is 01:00 to {END_S} (02:30 for this house)", + c_n.mode_runs.cheap.start == at(1, 0, 0) and END == at(2, 30, 0)) nb = tab(c_n, at(0, 18))["days"][0]["batteries"] check("in the night before the cheap run both charge from the grid in it " f"(01:00 to {END_S}), then give at 17:00", @@ -249,11 +254,12 @@ def at_h(b, h): return dict((round(x, 3), v) for x, v in b["levels"]).get(h, float("nan")) -check("the curve charges in a grid block: A (planned level 73 %) and B " - "(Cheap hours, to the end of its run) are well above where they " +LVL_A = c_n.plan_targets()[c_n.inverters[0].name] +check(f"the curve charges in a grid block: A (planned level {LVL_A:.0f} %) " + "and B (Cheap hours, to the end of its run) are well above where they " "were at 01:00, and stay put after the run", all(at_h(b, END_H) - at_h(b, 1.0) > 8.0 - and abs(at_h(b, 4.0) - at_h(b, END_H)) < 1e-6 + and abs(at_h(b, END_H + 0.5) - at_h(b, END_H)) < 1e-6 for b in nb) and nb[0]["levels"][0][1] == 30.0) # Modes off: the verdict's legs. c_f = S.make("off", at(10, 7)) @@ -828,14 +834,16 @@ def may_give(inv): def sweep(name, now0, soc, *, guard=None, until=48.0, sun=None, - evening=None): + evening=None, peak_w=None): """Walk the real control cycle (`_mode_roles()`) every 15 minutes from `now0` to the end of tomorrow, each battery at the level the tab's curve has at that time, and return the tab and the role seen per battery and quarter. A GIVE role on a pack that control's discharge envelope closes is seen as "closed": the pack is not asked to give. The tab is built - once, at `now0`.""" + once, at `now0`. `peak_w`: the site's peak limit (OPT-36, below).""" c = S.make(name, now0, soc=soc, evening=evening) + if peak_w is not None: + c.o = dict(c.o, peak_cap_w=peak_w) cut_out_at_floor(c) c.tariff_plan = S.quarters(now0, night_evening, days=3) c.tariff_at = now0 @@ -856,6 +864,13 @@ def sweep(name, now0, soc, *, guard=None, until=48.0, sun=None, b = day["batteries"][i] inv.soc = level_at(b, h if h < 24 else h - 24) if b["levels"] \ else inv.soc + # OPT-36: the tab shows a level to 0.1 % (`dayview.LEVEL_DP`); a + # battery whose curve shows its planned level (50.0 for 50.01) is + # at it, or control charges it for the last 0.01 %. + tg = (c.plan_targets() or {}).get(inv.name) + if tg is not None and inv.soc is not None and round( + inv.soc, dayview.LEVEL_DP) == round(tg, dayview.LEVEL_DP): + inv.soc = max(inv.soc, tg) c.tariff_at = t tick = c._mode_roles(t, None) for inv in c.inverters: @@ -894,7 +909,12 @@ def tomorrow_match(out, seen, d0, role, kind): t22 = at(22, 0, 5) -c1, o1, s1, d01 = sweep("dynamic", t22, (20.0, 20.0), evening=4800.0) +# OPT-36: the plan counts B's charge under the peak limit, the tab's curve +# charges at the batteries' ratings. Walked along the curve, B runs ahead of +# the plan and A's level falls behind the tab's block. A limit that does not +# bind (20 kW) makes the curve the charge control plans. +c1, o1, s1, d01 = sweep("dynamic", t22, (20.0, 20.0), evening=4800.0, + peak_w=20000.0) tm1 = o1["days"][1]["batteries"] print(" 22:00, both batteries at 20 %: tomorrow", [[(r["kind"], r["from"], r["to"]) for r in b["blocks"]] for b in tm1]) check("F1, modes on: at 22:00 with both batteries at 20 % tomorrow's give "