From e1e678f6c0f4aa389a58c0b65ecfb5e04257ab97 Mon Sep 17 00:00:00 2001 From: "m0@nas" Date: Mon, 21 Sep 2026 08:56:51 +0200 Subject: [PATCH] Haltezeit: erst ausloesen, wenn die Bedingung dabei bleibt Bisher loeste eine Automatik in dem Moment aus, in dem die Bedingung wahr wurde. Fuer Dauerzustaende taugt das nicht: "der Wasserzaehler laeuft" ist jedes Haendewaschen, gemeint war "laeuft seit einer halben Stunde ohne Pause". hold_secs schiebt die Flanke nach hinten, statt sie zu verbrauchen - cond_met bleibt bis dahin 0, und alles danach (Tagessperre, Sperrzeit, Protokoll) bleibt unberuehrt. Ein Aussetzer setzt die Zeit zurueck, das Zugehen des Zeitfensters ebenfalls. Gezaehlt wird im Speicher (erfuellt_seit) wie war_aktiv: nach einem Neustart weiss niemand, ob die Bedingung zwischendurch anlag. hold_secs = 0 ist die Vorgabe und heisst: wie bisher. Co-Authored-By: Claude Opus 5 --- autoActions/autoaction_runner.py | 67 ++++++++++++++++++++++++++++++-- autoActions/haltezeit.sql | 34 ++++++++++++++++ 2 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 autoActions/haltezeit.sql diff --git a/autoActions/autoaction_runner.py b/autoActions/autoaction_runner.py index 1d441c8..b718027 100644 --- a/autoActions/autoaction_runner.py +++ b/autoActions/autoaction_runner.py @@ -35,6 +35,16 @@ Nur die steigende Flanke loest aus: `automations.cond_met` haelt fest, ob die Bedingung beim letzten Durchlauf schon erfuellt war. Ohne das wuerde "Temperatur ueber 22 Grad" bei jedem Takt erneut feuern. +Die Haltezeit (`automations.hold_secs`) schiebt diese Flanke nach hinten: Erst +wenn die Bedingung so viele Sekunden am Stueck erfuellt war, gilt sie als +erfuellt. Gedacht fuer Dauerzustaende, die sich nicht in einem einzelnen +Messwert zeigen - "der Wasserzaehler laeuft" ist jedes Haendewaschen, "laeuft +seit einer halben Stunde ohne Pause" ist ein offener Hahn. Gezaehlt wird hier +im Speicher (`erfuellt_seit`) und nicht in der Datenbank: es ist ein +Laufzustand wie `war_aktiv`, und ein Neustart soll ihn bewusst verwerfen - +nach einem Neustart weiss niemand, ob die Bedingung in der Zwischenzeit +durchgehend anlag. + Zeit-Ausloeser gibt es in drei Formen: "um 16:30" gilt ab dieser Minute noch catchup_minutes lang, "ab 16:30" von da an bis Mitternacht, "vor 16:30" bis dahin. Ausgeloest wird in allen drei Faellen nur einmal, eben wegen der @@ -750,6 +760,7 @@ class Runner: self.geschrieben_um = {} # state_id -> wann das war self.war_aktiv = {} # automation_id -> war im Zeitfenster self.lief_im_fenster = {} # automation_id -> hat im Fenster ausgeloest + self.erfuellt_seit = {} # automation_id -> seit wann die Bedingung anliegt self._sonne = (None, "00:00", "00:00") # (datum, aufgang, untergang) self._kalender = {} # datum -> {"feiertag": .., "ferien": ..} self._letzte_saeuberung = None @@ -1311,7 +1322,12 @@ class Runner: self.lief_im_fenster[automatik["id"]] = False erfuellt = gruppen_erfuellt(automatik, self.regelwerk, self.werte, jetzt, fenster) - if erfuellt and not automatik["cond_met"]: + # Mit Haltezeit zaehlt nicht, ob die Bedingung erfuellt ist, + # sondern ob sie es lange genug am Stueck ist. Weil das Ergebnis + # an dieselbe Stelle tritt, bleibt alles danach - Flanke, + # Tagessperre, Sperrzeit, Protokoll - unveraendert. + reif = self.haltezeit_reif(automatik, erfuellt, jetzt) + if reif and not automatik["cond_met"]: if automatik.get("once_per_day") and self.lief_heute(automatik, jetzt): logger.debug("%s: lief heute schon, bis Mitternacht gesperrt", automatik["name"]) @@ -1320,7 +1336,7 @@ class Runner: automatik["name"]) else: self.ausloesen(automatik, "fired") - self.flanke_merken(automatik, erfuellt) + self.flanke_merken(automatik, reif) else: # Das Fenster ist gerade zugegangen. Wer "auf jeden Fall" # angehakt hat, bekommt jetzt seinen Lauf - aber nur, wenn in @@ -1333,12 +1349,57 @@ class Runner: automatik["name"]) self.ausloesen(automatik, "forced") # Ausserhalb des Fensters die Flanke zuruecksetzen, sonst - # koennte sie im naechsten Fenster nicht mehr steigen. + # koennte sie im naechsten Fenster nicht mehr steigen. Eine + # angefangene Haltezeit verfaellt mit: sie soll innerhalb des + # Fensters voll gelaufen sein, nicht ueber dessen Rand hinweg. self.flanke_merken(automatik, False) + self.erfuellt_seit.pop(automatik["id"], None) self.lief_im_fenster[automatik["id"]] = False self.war_aktiv[automatik["id"]] = aktiv + def haltezeit_reif(self, automatik, erfuellt, jetzt): + """ + Liegt die Bedingung lange genug am Stueck an? + + Ohne Haltezeit (hold_secs = 0, die Vorgabe) ist die Antwort schlicht + die Bedingung selbst - dann verhaelt sich alles wie vorher. + + Sonst wird der Zeitpunkt gemerkt, an dem die Bedingung wahr wurde, und + erst nach Ablauf der Haltezeit "ja" gemeldet. Faellt sie zwischendurch + auch nur einen Takt aus, wird der Zeitpunkt verworfen und faengt beim + naechsten Mal von vorn an; genau das ist mit "ununterbrochen" gemeint. + + Wichtig ist, dass cond_met bis dahin auf 0 bleibt - die Flanke wird + also nicht verbraucht, sondern aufgeschoben. Deshalb wird sie hier + nicht selbst gesetzt, sondern das Ergebnis nach oben gereicht. + + Zwei Dinge, die man wissen sollte: + + * Ein punktgenauer Zeit-Ausloeser ("um 16:30") ist nur das + Nachholfenster lang wahr. Eine Haltezeit darueber hinaus wuerde nie + reif - was kein Fehler, aber auch keine sinnvolle Kombination ist. + * Eine Bedingung gilt so lange weiter, wie ihr letzter Messwert gilt. + Bei einem Zaehler, der alle fuenf Minuten meldet, ist die Haltezeit + also auf fuenf Minuten genau - sinnvolle Stufen beginnen deutlich + darueber. + """ + halten = int(automatik.get("hold_secs") or 0) + if not halten: + return erfuellt + if not erfuellt: + if self.erfuellt_seit.pop(automatik["id"], None) is not None: + logger.debug("%s: Haltezeit abgebrochen, faengt von vorn an", + automatik["name"]) + return False + seit = self.erfuellt_seit.setdefault(automatik["id"], jetzt) + offen = halten - (jetzt - seit).total_seconds() + if offen > 0: + logger.debug("%s: Bedingung erfuellt, noch %d s Haltezeit", + automatik["name"], offen) + return False + return True + @staticmethod def gesperrt(automatik, jetzt): """ diff --git a/autoActions/haltezeit.sql b/autoActions/haltezeit.sql new file mode 100644 index 0000000..dcf03c9 --- /dev/null +++ b/autoActions/haltezeit.sql @@ -0,0 +1,34 @@ +-- =========================================================================== +-- Haltezeit: die Bedingung muss eine Weile durchgehend erfuellt sein +-- +-- Einspielen: +-- mysql -h 127.0.0.1 -P 3310 -u homeMesh -p homeMesh < haltezeit.sql +-- +-- Danach den Runner neu starten (startSolarServer.sh) - Daten laedt er +-- selbst nach, neuen Code nicht. +-- +-- --------------------------------------------------------------------------- +-- Wozu +-- +-- Bisher loeste eine Automatik in dem Moment aus, in dem die Bedingung wahr +-- wurde. Fuer "Temperatur ueber 22 Grad" ist das richtig. Fuer "der +-- Wasserzaehler laeuft und hoert nicht auf" ist es unbrauchbar: Jeder +-- Spuelgang macht die Bedingung wahr, und gemeint war "seit einer halben +-- Stunde ohne Pause". +-- +-- Die Sperrzeit (lockout_secs) taugt dafuer nicht - sie begrenzt, wie oft +-- ausgeloest wird, nicht wie lange etwas anliegen muss. Und die Verkettung +-- ueber eine zweite Automatik traefe es nur ungefaehr. +-- +-- hold_secs = 0 ist die Vorgabe und heisst: sofort, also genau wie bisher. +-- Jede vorhandene Automatik behaelt damit ihr Verhalten. +-- +-- Gezaehlt wird im Runner, nicht in der Datenbank: Er weiss ohnehin bei +-- jedem Takt, ob die Bedingung gerade erfuellt ist. Faellt sie zwischendurch +-- aus, faengt die Zeit von vorn an - genau das ist "ohne Pause". +-- --------------------------------------------------------------------------- + +ALTER TABLE `automations` + ADD COLUMN IF NOT EXISTS `hold_secs` int(11) NOT NULL DEFAULT 0 + COMMENT 'Haltezeit: so lange muss die Bedingung ununterbrochen erfuellt sein, bevor ausgeloest wird. 0 = sofort' + AFTER `lockout_secs`;