diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 8434f1e..3a8225c 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -1609,8 +1609,10 @@ class Controller: def weather_held(self, now) -> bool: """TARIFF-24: no forecast now, but a run planned with one is under way: - it keeps its gap to its end. The wider gap applies from the next - run. (DECISIONS/MAIN-orchestration-archive.md)""" + it keeps its gap to its end (OPT-34: a night charge that keeps its + level too); the wider gap applies from the next run. Pure; Home asks + the same (`homeview.trade_verdict()`). + (DECISIONS/MAIN-orchestration-archive.md)""" return self.solar_fc is None and ( (self._wx_free_to is not None and now < self._wx_free_to) or (self._charge_latch is not None @@ -1618,8 +1620,9 @@ class Controller: def _price_runs(self, now, packs, floor_pct, ceiling_pct): """The kept run plan, rebuilt at most once per price slot (15 min) or - when the prices or a battery's choice change; a run under way is - kept to its end (`priceruns.keep_started()`). + when the prices or a battery's choice change; a run under way is kept + to its end (`priceruns.keep_started()`). None with no fresh firm + prices: then no battery holds or trades. (DECISIONS/MAIN-orchestration-archive.md)""" plan = self.fresh_tariff_plan(now) if plan is None or not plan.has_prices: @@ -1672,9 +1675,9 @@ class Controller: required, ref, done_c, e): """OPT-11 P4: the day plan (`dayplan.plan()`) on the runs just made, its levels held by the hysteresis (`dayplan.hold()`), then the runs - made again with each battery's shortfall as the charge, so the cheap - run is only as long as the shortfall needs. - (DECISIONS/MAIN-orchestration-archive.md)""" + made again with each battery's shortfall as the charge. With no plan + (no or stale forecast, no house profile yet) the levels are dropped: P3 + option A. (DECISIONS/MAIN-orchestration-archive.md)""" dp = dayplan.plan( self.solar_fc, None if self.house_profile is None @@ -1710,8 +1713,13 @@ class Controller: return self._plan_targets def _latch_charge(self, now, runs, targets, packs=None) -> None: - """OPT-34, once per cycle after `modetrade.decide()`. - (DECISIONS/MAIN-orchestration-archive.md)""" + """OPT-34, once per cycle after `modetrade.decide()`: a started night + grid charge keeps its planned level when the forecast goes mid-charge + (`plan_targets()` gives the latched levels, `weather_held()` the narrow + price gap). The latch lasts through every cheap run plus one price slot + (`CHARGE_LATCH_GRACE_S`), is dropped when `now` passes its end or the + battery leaves its mode, and a charge that never started has none. + DECISIONS/OPT-34-the-night-charge-keeps-its-level.md""" old = lt = self._charge_latch tick = self.mode_tick run = None if runs is None else runs.cheap @@ -1752,7 +1760,10 @@ class Controller: "born": lt[2].isoformat()}) def _mode_roles(self, now, maint_results): - """OPT-11 P3: each battery's role and range this tick. + """OPT-11 P3: each battery's role and range this tick. Grid charging + and selling need a fresh plan that `TariffPlan.arbitrage()` allows with + the MEASURED peak guard (None refuses: nothing measures the quarter), + exactly the gate `arbitrage.decide()` has. (DECISIONS/MAIN-orchestration-archive.md)""" floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o) packs = self._mode_packs(maint_results) @@ -1823,6 +1834,8 @@ class Controller: draining=any(r.phase == DRAIN for r in maint_results or ())) self._latch_charge(now, runs, targets, packs) # A charge the guard or the headroom stopped stays off this period. + # OPT-15: only a MEASURED stop latches; the first unmeasured headroom + # in a quarter stops one tick, a second latches. # (DECISIONS/MAIN-orchestration-archive.md) if was and self.mode_tick.action != modetrade.CHARGE: why = self.mode_tick.why @@ -1851,15 +1864,19 @@ class Controller: def _mode_trade(self, claims, now) -> bool: """OPT-11 P3: file the site trade the roles ask for, sized inside the - modes and the hard limits (the loop bound) and, for a charge, the - billed quarter's headroom (SAFETY-45, aim cap - margin, SAFETY-79). + modes and the hard limits and, for a charge, the billed quarter's + headroom (SAFETY-45, aim cap - margin, SAFETY-79), so no battery that + its mode or a refusal holds is asked for watts it may not give. (DECISIONS/MAIN-orchestration-archive.md)""" tick = self.mode_tick if tick is None: return False if tick.action == modetrade.CHARGE: # OPT-20: the ask carries PROBE_W for exactly the probes whose pin - # is filed this tick. (DECISIONS/MAIN-orchestration-archive.md) + # is filed this tick (`decide()` ran before the pin, so they can + # differ by one cycle; left alone the allocator moves the pin's + # watts to another battery). + # (DECISIONS/MAIN-orchestration-archive.md) pins = _ctl.PROBE_W * sum( 1 for c in claims if c.source == "refused" and c.kind == "set" and c.value < 0) @@ -1912,7 +1929,10 @@ class Controller: def _reseed_run_edge(self, claims, now) -> None: """OPT-11 P3: a mode trade just ended, so the loop takes over from a - command no loop chose. (DECISIONS/MAIN-orchestration-archive.md)""" + command no loop chose: it starts at the house's own point E, not at the + trade's watts, and OPT-15 HOLDS it at E (`_edge_grid()`) until the + meter shows the step or `EDGE_HOLD_S` passes. + DECISIONS/OPT-11-battery-modes.md""" self._edge = None batt = self.site_batt_w if batt is None or self.grid is None: @@ -1929,8 +1949,9 @@ class Controller: def _edge_grid(self, end, now): """OPT-15: the grid reading the loop acts on this tick. While a run - edge is held, the target itself (no error, the command stays at E). - (DECISIONS/MAIN-orchestration-archive.md)""" + edge is held, the target itself (no error, the command stays at E); the + hold ends when the meter reaches the target, on `end`, or after + `EDGE_HOLD_S`. (DECISIONS/MAIN-orchestration-archive.md)""" e = self._edge if e is None: return self.grid @@ -1951,7 +1972,8 @@ class Controller: def _charge_track(self, claims, maint_results) -> None: """SAFETY-93, N>=2: a charge ends when its battery leaves CHARGE or can no longer be placed (stale link, refusal). Kept until the loop runs. - (DECISIONS/MAIN-orchestration-archive.md)""" + SAFETY-95: a drain likewise; its refusal (SAFETY-73) is read from the + battery (+1 = will not give). (DECISIONS/MAIN-orchestration-archive.md)""" if len(self.inverters) < 2: return live = set(self.live_units()) @@ -1970,8 +1992,9 @@ class Controller: def _reseed_charge_end(self, maint_results) -> None: """SAFETY-93, N>=2: when a battery's maintenance charge ends, the loop starts from what the others stood at plus that battery's new command - (its HOLD pin, or its last discharge if it is free; a charge stops). - (DECISIONS/MAIN-orchestration-archive.md)""" + (its HOLD pin, or its last discharge if it is free; a charge stops); + else the charge's watts land on the others (B 0 -> -1600 W in one + cycle). DECISIONS/SAFETY-93-charge-end.md""" ended, self._charge_ended = self._charge_ended, frozenset() if len(self.inverters) < 2 or not ended: return