diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 97985f1..3eda31b 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -91,7 +91,7 @@ from .price import Absence, Uncertainty, brussels, price_option, provider_config from .price import required_spread as price_required_spread from .price import uncertainty_of as price_uncertainty_of from .price_history import PriceHistory -from .static_tariff import resolve_tariff, static_status +from .static_tariff import resolve_tariff, static_gated, static_status from .tariff import PeakStore, PeakTracker, next_quarter from .store import migrate_maint_keys, migrate_unit_keys from .validate import (FIRMWARE_INGEST_VETO_S, ConfigRefused, refuse, @@ -1188,6 +1188,7 @@ class Controller: self.grid = None self.peak_fc = None self.cheap = False + self._price_gated = False # MAINT-13: is a price gate set up # TARIFF-03. One line of prose for the status page saying WHICH tariff # window we are in and why it is or is not cheap - so "Cheap window: # no" is an evidenced answer rather than a bare no. @@ -3312,6 +3313,7 @@ class Controller: now_p = await self.hass.number(now_e) avg_p = await self.hass.number(avg_e) self.cheap = (now_p is not None and avg_p is not None and now_p <= avg_p) + self._price_gated = True self.tariff_words = "Prices from Home Assistant entities" else: # TARIFF-03. §9.2 makes a static day/night tariff a first-class @@ -3337,6 +3339,7 @@ class Controller: # one and the loser is silent. self.cheap, self.tariff_words = static_status( o, datetime.now(timezone.utc)) + self._price_gated = static_gated(o, datetime.now(timezone.utc)) # ⚠️ TARIFF-11. THE PROVIDER PLAN REACHES THE *Tariff* ROW HERE, AND # THIS IS THE ONLY PLACE THE TWO SOURCES MEET. Everything above is the @@ -3883,10 +3886,7 @@ class Controller: really taking charge (sun outside cheap hours). Closed only when a gate exists, it is shut, and the pack is not charging. """ - o = self.o - gated = bool((o.get("price_now_entity") and o.get("price_avg_entity")) - or o.get("static_profile")) - if not gated or self.cheap: + if not self._price_gated or self.cheap: return True batt = inv.batt return batt is not None and float(batt) <= -MAINT_CHARGING_W diff --git a/goodwe_controller/app/static_tariff.py b/goodwe_controller/app/static_tariff.py index fc6659b..aa838c2 100644 --- a/goodwe_controller/app/static_tariff.py +++ b/goodwe_controller/app/static_tariff.py @@ -1232,6 +1232,16 @@ def static_plan(opts: dict, since: datetime, until: datetime, *, profile, "fallback" if provisional else "static") +def static_gated(opts: dict, now: datetime) -> bool: + """MAINT-13: is a fixed tariff set up, so that a cheap window can hold a + charge back? False for no profile or one that cannot be used.""" + try: + return bool(static_plan(opts, now, now + timedelta(days=MEAN_WINDOW_DAYS), + now=now).has_prices) + except ProfileInvalid: + return False + + def static_status(opts: dict, now: datetime): """(cheap, words) for the status page and for main.py's cheap-window flag. diff --git a/goodwe_controller/test_maint13.py b/goodwe_controller/test_maint13.py index 7cad14d..505c13a 100644 --- a/goodwe_controller/test_maint13.py +++ b/goodwe_controller/test_maint13.py @@ -454,7 +454,12 @@ m = put_in_charge(c, 0, 40, CHARGE_TIMEOUT_MIN + 1) one_cycle(c) check("...and a pack past its charging time there fails at once (not 7 days)", m.phase == IDLE and len(notes(h)) == 1) +def read(c): + asyncio.run(c.read_inputs()) + + c, h = controller(OPTS1, False) +read(c) c.inverters[0].batt = -800.0 check("price gate closed but the pack is taking 800 W (sun): gate open", c._charge_gate_open(c.inverters[0]) is True) @@ -464,11 +469,20 @@ check("...closed and about idle: gate closed", c.inverters[0].batt = None check("...no battery reading: gate closed", c._charge_gate_open(c.inverters[0]) is False) c, h = controller(OPTS1, True) -c.cheap = True +read(c) check("price gate open: gate open", c._charge_gate_open(c.inverters[0]) is True) -c, h = controller(dict(NOTARIFF, static_profile="x"), False) -check("a fixed tariff set up counts as a gate (closed outside its cheap hours)", - c._charge_gate_open(c.inverters[0]) is False) +PROFILE = json.dumps({"periods": [ + {"name": "day", "from": "07:00", "to": "22:00", "days": "all", + "eur_per_kwh": 0.29}, + {"name": "night", "from": "22:00", "to": "07:00", "days": "all", + "eur_per_kwh": 0.19}]}) +c, h = controller(dict(NOTARIFF, price_provider="off", static_profile=PROFILE), + False) +read(c) +check("a fixed tariff set up counts as a gate: outside its cheap hours a pack " + "that is not charging is closed, inside them it is open", + c._price_gated is True + and c._charge_gate_open(c.inverters[0]) is c.cheap) # the save timer is reset on a phase change mm = in_charge()