From 46fa039cacf467d3a41070b2c00ebe787bde0316 Mon Sep 17 00:00:00 2001 From: AboveColin Date: Wed, 23 Sep 2026 22:18:50 +0200 Subject: [PATCH 1/2] A new action finds the threshold that fits a yes/no sensor, from its recorded history --- custom_components/jev/calibrate.py | 256 ++++++++++++++++++ custom_components/jev/const.py | 1 + custom_components/jev/icons.json | 3 +- custom_components/jev/manifest.json | 2 +- custom_components/jev/services.py | 8 +- custom_components/jev/services.yaml | 25 ++ custom_components/jev/strings.json | 34 +++ custom_components/jev/translations/cs.json | 34 +++ custom_components/jev/translations/da.json | 34 +++ custom_components/jev/translations/de.json | 34 +++ custom_components/jev/translations/en.json | 34 +++ custom_components/jev/translations/es.json | 34 +++ custom_components/jev/translations/fr.json | 34 +++ custom_components/jev/translations/it.json | 34 +++ custom_components/jev/translations/nl.json | 34 +++ custom_components/jev/translations/pl.json | 34 +++ custom_components/jev/translations/pt-BR.json | 34 +++ custom_components/jev/translations/ru.json | 34 +++ custom_components/jev/translations/sv.json | 34 +++ .../jev/translations/zh-Hans.json | 34 +++ tests/test_calibrate.py | 175 ++++++++++++ tests/test_services.py | 10 + 22 files changed, 953 insertions(+), 3 deletions(-) create mode 100644 custom_components/jev/calibrate.py create mode 100644 tests/test_calibrate.py diff --git a/custom_components/jev/calibrate.py b/custom_components/jev/calibrate.py new file mode 100644 index 0000000..a6ee4f0 --- /dev/null +++ b/custom_components/jev/calibrate.py @@ -0,0 +1,256 @@ +"""Pick a threshold from what actually happened, instead of guessing one. + +A noul is a probability, and the threshold that turns it into yes or no is the +user's call. 0.5 is where a noul says "cannot tell", not where a given house's +washing machine is done. This action reads the recorder: the noul's history next to +the history of an entity that says what was really true, such as a door contact or +a smart plug's own "running" state. For every threshold it measures how much of the +time a yes was right (precision) and how much of the true time it said yes +(recall), and returns the threshold that balances the two best. + +Nothing is sent to TypeSafe, so it costs no tokens. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta +from statistics import median +from typing import Any, Final + +import voluptuous as vol +from homeassistant.components.recorder import history +from homeassistant.const import ATTR_ENTITY_ID, STATE_ON +from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, State +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.recorder import get_instance +from homeassistant.util import dt as dt_util + +from .const import DOMAIN + +CONF_TRUTH_ENTITY: Final = "truth_entity_id" +CONF_TRUTH_STATE: Final = "truth_state" +CONF_DAYS: Final = "days" + +# The recorder keeps 10 days by default (`purge_keep_days`), so a week is inside +# what a default install still has. A longer window only reads further back. +DEFAULT_DAYS: Final = 7 + +# Every hundredth. The noul sensor itself rounds to three places, but nobody sets a +# threshold that fine, and two neighbouring hundredths rarely differ in outcome. +CANDIDATES: Final = tuple(round(i / 100, 2) for i in range(1, 100)) + +# The coarse table returned next to the best threshold, so a caller can see how +# steep the trade-off is around it. +TABLE: Final = tuple(round(i / 10, 1) for i in range(1, 10)) + +CALIBRATE_SCHEMA: Final = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TRUTH_ENTITY): cv.entity_id, + vol.Optional(CONF_TRUTH_STATE, default=STATE_ON): cv.string, + vol.Optional(CONF_DAYS, default=DEFAULT_DAYS): vol.All( + vol.Coerce(int), vol.Range(min=1) + ), + } +) + + +@dataclass(frozen=True, slots=True) +class Span: + """A stretch of time where neither the probability nor the truth changed.""" + + probability: float + truth: bool + seconds: float + + +@dataclass(frozen=True, slots=True) +class Outcome: + """What one threshold would have said over the spans, in seconds.""" + + threshold: float + true_yes: float + false_yes: float + missed: float + + @property + def precision(self) -> float | None: + said_yes = self.true_yes + self.false_yes + return self.true_yes / said_yes if said_yes else None + + @property + def recall(self) -> float | None: + was_true = self.true_yes + self.missed + return self.true_yes / was_true if was_true else None + + @property + def f1(self) -> float: + precision, recall = self.precision, self.recall + if not precision or not recall: + return 0.0 + return 2 * precision * recall / (precision + recall) + + def as_dict(self) -> dict[str, Any]: + return { + "threshold": self.threshold, + "precision": _round(self.precision), + "recall": _round(self.recall), + "f1": round(self.f1, 3), + } + + +def _round(value: float | None) -> float | None: + return None if value is None else round(value, 3) + + +def outcome(spans: list[Span], threshold: float) -> Outcome: + true_yes = false_yes = missed = 0.0 + for span in spans: + said_yes = span.probability >= threshold + if said_yes and span.truth: + true_yes += span.seconds + elif said_yes: + false_yes += span.seconds + elif span.truth: + missed += span.seconds + return Outcome(threshold, true_yes, false_yes, missed) + + +def best(spans: list[Span]) -> Outcome: + """The threshold with the highest F1. + + Several neighbouring thresholds often tie, because no probability fell between + them. The middle of the tied ones is returned rather than an edge, so a noul + that lands a little off its usual values still falls on the same side. + """ + outcomes = [outcome(spans, threshold) for threshold in CANDIDATES] + top = max(o.f1 for o in outcomes) + tied = [o.threshold for o in outcomes if o.f1 == top] + return outcome(spans, round(median(tied), 2)) + + +def build_spans( + probabilities: list[State], + truths: list[State], + truth_state: str, + start: datetime, + end: datetime, +) -> list[Span]: + """Cut the window at every change of either entity. + + The state an entity already had when the window opened counts from the start of + the window. A span where the probability is not a number, such as unavailable + while the API was down, is left out rather than guessed. + """ + changes = sorted( + [(max(s.last_changed, start), "p", s.state) for s in probabilities] + + [(max(s.last_changed, start), "t", s.state) for s in truths], + key=lambda change: change[0], + ) + spans: list[Span] = [] + probability: float | None = None + truth: bool | None = None + for index, (when, kind, value) in enumerate(changes): + if kind == "p": + probability = _number(value) + else: + truth = value == truth_state + until = changes[index + 1][0] if index + 1 < len(changes) else end + seconds = (until - when).total_seconds() + if probability is not None and truth is not None and seconds > 0: + spans.append(Span(probability, truth, seconds)) + return spans + + +def _number(value: str) -> float | None: + try: + return float(value) + except ValueError: + return None + + +async def async_calibrate(hass: HomeAssistant, call: ServiceCall) -> ServiceResponse: + source = call.data[ATTR_ENTITY_ID] + truth_entity = call.data[CONF_TRUTH_ENTITY] + truth_state = call.data[CONF_TRUTH_STATE] + days = call.data[CONF_DAYS] + if "recorder" not in hass.config.components: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="calibrate_needs_recorder" + ) + end = dt_util.utcnow() + start = end - timedelta(days=days) + found = await get_instance(hass).async_add_executor_job( + _history, hass, start, end, [source, truth_entity] + ) + spans = build_spans( + found.get(source, []), found.get(truth_entity, []), truth_state, start, end + ) + if not spans: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="calibrate_no_history", + translation_placeholders={ + "entity": source, + "truth": truth_entity, + "days": str(days), + }, + ) + true_seconds = sum(span.seconds for span in spans if span.truth) + # With only one side there is nothing to separate, and every threshold scores + # the same. + if true_seconds in (0, sum(span.seconds for span in spans)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key=( + "calibrate_never_true" if true_seconds == 0 else "calibrate_always_true" + ), + translation_placeholders={ + "truth": truth_entity, + "state": truth_state, + "days": str(days), + }, + ) + return { + **best(spans).as_dict(), + "hours": round(sum(span.seconds for span in spans) / 3600, 1), + "hours_true": round(true_seconds / 3600, 1), + # F1 over two occasions is noise, whatever its value. These are the counts + # to judge it by. + "times_true": _times_true(spans), + "probability_changes": sum( + 1 for s in found.get(source, []) if _number(s.state) is not None + ), + "table": [outcome(spans, threshold).as_dict() for threshold in TABLE], + } + + +def _times_true(spans: list[Span]) -> int: + """How many separate stretches the truth was on for.""" + count = 0 + previous = False + for span in spans: + if span.truth and not previous: + count += 1 + previous = span.truth + return count + + +def _history( + hass: HomeAssistant, start: datetime, end: datetime, entity_ids: list[str] +) -> dict[str, list[State]]: + found = history.get_significant_states( + hass, + start, + end, + entity_ids, + include_start_time_state=True, + significant_changes_only=False, + no_attributes=True, + ) + return { + entity_id: [s for s in states if isinstance(s, State)] + for entity_id, states in found.items() + } diff --git a/custom_components/jev/const.py b/custom_components/jev/const.py index 126ac69..dde5175 100644 --- a/custom_components/jev/const.py +++ b/custom_components/jev/const.py @@ -75,6 +75,7 @@ SERVICE_NOUL: Final = "noul" SERVICE_CHOICE: Final = "choice" SERVICE_SCORE: Final = "score" +SERVICE_CALIBRATE: Final = "calibrate" CONF_TRUE_MEANS: Final = "true_means" CONF_FALSE_MEANS: Final = "false_means" diff --git a/custom_components/jev/icons.json b/custom_components/jev/icons.json index 02c1706..9bdb5ed 100644 --- a/custom_components/jev/icons.json +++ b/custom_components/jev/icons.json @@ -30,6 +30,7 @@ "noul": "mdi:comment-question-outline", "choice": "mdi:format-list-checks", "score": "mdi:ruler", - "ask": "mdi:comment-multiple-outline" + "ask": "mdi:comment-multiple-outline", + "calibrate": "mdi:tune-vertical" } } diff --git a/custom_components/jev/manifest.json b/custom_components/jev/manifest.json index 439ca71..1ed6a55 100644 --- a/custom_components/jev/manifest.json +++ b/custom_components/jev/manifest.json @@ -1,7 +1,7 @@ { "domain": "jev", "name": "Jev (TypeSafe)", - "after_dependencies": ["conversation"], + "after_dependencies": ["conversation", "recorder"], "codeowners": ["@abovecolin"], "config_flow": true, "dependencies": [], diff --git a/custom_components/jev/services.py b/custom_components/jev/services.py index fafb146..0c5604e 100644 --- a/custom_components/jev/services.py +++ b/custom_components/jev/services.py @@ -46,6 +46,7 @@ if TYPE_CHECKING: from . import JevConfigEntry +from .calibrate import CALIBRATE_SCHEMA, async_calibrate from .const import ( ATTR_ANSWERS, ATTR_CONFIG_ENTRY, @@ -64,6 +65,7 @@ CONF_TRUE_MEANS, DOMAIN, SERVICE_ASK, + SERVICE_CALIBRATE, SERVICE_CHOICE, SERVICE_NOUL, SERVICE_SCORE, @@ -283,7 +285,7 @@ def answer_as_dict(answer: Any) -> dict[str, Any]: def async_register_services(hass: HomeAssistant) -> None: - """Register all four actions once, the first time the component loads.""" + """Register all five actions once, the first time the component loads.""" if hass.services.has_service(DOMAIN, SERVICE_NOUL): return @@ -382,11 +384,15 @@ async def _ask_many(call: ServiceCall) -> ServiceResponse: **_envelope(response), } + async def _calibrate(call: ServiceCall) -> ServiceResponse: + return await async_calibrate(hass, call) + for name, handler, schema in ( (SERVICE_NOUL, _noul, NOUL_SCHEMA), (SERVICE_CHOICE, _choice, CHOICE_SCHEMA), (SERVICE_SCORE, _score, SCORE_SCHEMA), (SERVICE_ASK, _ask_many, ASK_SCHEMA), + (SERVICE_CALIBRATE, _calibrate, CALIBRATE_SCHEMA), ): hass.services.async_register( DOMAIN, name, handler, schema=schema, supports_response=SupportsResponse.ONLY diff --git a/custom_components/jev/services.yaml b/custom_components/jev/services.yaml index bab8b08..e35dd40 100644 --- a/custom_components/jev/services.yaml +++ b/custom_components/jev/services.yaml @@ -196,3 +196,28 @@ ask: selector: config_entry: integration: jev + +calibrate: + fields: + entity_id: + required: true + selector: + entity: + domain: sensor + truth_entity_id: + required: true + selector: + entity: {} + truth_state: + required: false + default: "on" + selector: + text: + days: + required: false + default: 7 + selector: + number: + min: 1 + mode: box + unit_of_measurement: days diff --git a/custom_components/jev/strings.json b/custom_components/jev/strings.json index 54cf0a1..68f7d28 100644 --- a/custom_components/jev/strings.json +++ b/custom_components/jev/strings.json @@ -228,6 +228,28 @@ "description": "Off by default. Sends every attribute of the picked entities, not just the value, unit, device class and area. A weather forecast or a media player artwork list runs to thousands of tokens, and you pay for them on every evaluation." } } + }, + "calibrate": { + "name": "Find the best threshold", + "description": "Compare the recorded history of a yes/no probability with an entity that shows what was really true, and get the threshold that would have been right most often. Reads only the recorder, so it costs no tokens.", + "fields": { + "entity_id": { + "name": "Probability", + "description": "A Jev yes/no sensor, or any sensor whose state is a probability from 0 to 1." + }, + "truth_entity_id": { + "name": "What was really true", + "description": "An entity whose state shows the real answer, such as a door contact or a plug that reports when a machine runs." + }, + "truth_state": { + "name": "State that means yes", + "description": "The state of that entity that means yes. on if you leave it empty." + }, + "days": { + "name": "Days of history", + "description": "How far back to read. The recorder keeps 10 days unless you changed it." + } + } } }, "entity": { @@ -341,6 +363,18 @@ }, "rate_limited": { "message": "TypeSafe rate limited the request: {reason}" + }, + "calibrate_needs_recorder": { + "message": "This action reads the recorder, and the recorder is not running." + }, + "calibrate_no_history": { + "message": "No time in the last {days} days has both a number from {entity} and a state from {truth}. Check that the recorder records both, and that {entity} reports a number." + }, + "calibrate_never_true": { + "message": "{truth} was never {state} in the last {days} days, so there is nothing to compare against. Read a longer period, or check which state means yes." + }, + "calibrate_always_true": { + "message": "{truth} was {state} for all of the last {days} days, so there is nothing to compare against. Read a longer period, or check which state means yes." } }, "common": { diff --git a/custom_components/jev/translations/cs.json b/custom_components/jev/translations/cs.json index dd03fe5..8925543 100644 --- a/custom_components/jev/translations/cs.json +++ b/custom_components/jev/translations/cs.json @@ -228,6 +228,28 @@ "description": "Ve výchozím stavu vypnuto. Posílá každý atribut vybraných entit, nejen hodnotu, jednotku, třídu zařízení a oblast. Předpověď počasí nebo seznam obalů alb v přehrávači médií vydá na tisíce tokenů a ty zaplatíte při každém vyhodnocení." } } + }, + "calibrate": { + "name": "Najít nejlepší práh", + "description": "Porovná zaznamenanou historii pravděpodobnosti ano/ne s entitou, která ukazuje, co skutečně platilo, a vrátí práh, který by nejčastěji měl pravdu. Čte jen recorder, takže nestojí žádné tokeny.", + "fields": { + "entity_id": { + "name": "Pravděpodobnost", + "description": "Senzor ano/ne od Jev, nebo jakýkoli senzor, jehož stav je pravděpodobnost od 0 do 1." + }, + "truth_entity_id": { + "name": "Co skutečně platilo", + "description": "Entita, jejíž stav ukazuje skutečnou odpověď, například kontakt dveří nebo zásuvka, která hlásí, kdy spotřebič běží." + }, + "truth_state": { + "name": "Stav znamenající ano", + "description": "Stav té entity, který znamená ano. on, pokud pole necháte prázdné." + }, + "days": { + "name": "Dny historie", + "description": "Jak daleko zpět číst. Recorder uchovává 10 dní, pokud jste to nezměnili." + } + } } }, "entity": { @@ -341,6 +363,18 @@ }, "rate_limited": { "message": "TypeSafe omezil požadavek: {reason}" + }, + "calibrate_needs_recorder": { + "message": "Tato akce čte recorder a recorder neběží." + }, + "calibrate_no_history": { + "message": "V posledních {days} dnech není žádný okamžik s číslem z {entity} i stavem z {truth}. Zkontrolujte, že recorder zaznamenává obojí a že {entity} hlásí číslo." + }, + "calibrate_never_true": { + "message": "{truth} nebyl v posledních {days} dnech nikdy {state}, takže není s čím porovnat. Čtěte delší období, nebo zkontrolujte, který stav znamená ano." + }, + "calibrate_always_true": { + "message": "{truth} byl celých posledních {days} dní {state}, takže není s čím porovnat. Čtěte delší období, nebo zkontrolujte, který stav znamená ano." } }, "common": { diff --git a/custom_components/jev/translations/da.json b/custom_components/jev/translations/da.json index bbdea05..bacdca1 100644 --- a/custom_components/jev/translations/da.json +++ b/custom_components/jev/translations/da.json @@ -228,6 +228,28 @@ "description": "Slået fra som standard. Sender alle attributter for de valgte entiteter, ikke kun værdien, måleenheden, enhedsklassen og området. En vejrudsigt eller en medieafspillers liste med albumbilleder løber op i tusindvis af tokens, og dem betaler du for ved hver evaluering." } } + }, + "calibrate": { + "name": "Find den bedste tærskel", + "description": "Sammenligner den gemte historik for en ja/nej-sandsynlighed med en enhed, der viser, hvad der faktisk var sandt, og giver den tærskel, der oftest ville have haft ret. Læser kun recorderen og koster derfor ingen tokens.", + "fields": { + "entity_id": { + "name": "Sandsynlighed", + "description": "En Jev ja/nej-sensor eller en anden sensor, hvis tilstand er en sandsynlighed fra 0 til 1." + }, + "truth_entity_id": { + "name": "Hvad der faktisk var sandt", + "description": "En enhed, hvis tilstand viser det rigtige svar, f.eks. en dørkontakt eller et stik, der melder, når et apparat kører." + }, + "truth_state": { + "name": "Tilstand der betyder ja", + "description": "Den tilstand for enheden, der betyder ja. on hvis du lader feltet være tomt." + }, + "days": { + "name": "Dages historik", + "description": "Hvor langt tilbage der læses. Recorderen gemmer 10 dage, medmindre du har ændret det." + } + } } }, "entity": { @@ -341,6 +363,18 @@ }, "rate_limited": { "message": "TypeSafe begrænsede forespørgslen: {reason}" + }, + "calibrate_needs_recorder": { + "message": "Denne handling læser recorderen, og recorderen kører ikke." + }, + "calibrate_no_history": { + "message": "Intet tidspunkt i de seneste {days} dage har både et tal fra {entity} og en tilstand fra {truth}. Kontroller, at recorderen gemmer begge, og at {entity} melder et tal." + }, + "calibrate_never_true": { + "message": "{truth} var aldrig {state} i de seneste {days} dage, så der er intet at sammenligne med. Læs en længere periode, eller kontroller, hvilken tilstand der betyder ja." + }, + "calibrate_always_true": { + "message": "{truth} var {state} i hele de seneste {days} dage, så der er intet at sammenligne med. Læs en længere periode, eller kontroller, hvilken tilstand der betyder ja." } }, "common": { diff --git a/custom_components/jev/translations/de.json b/custom_components/jev/translations/de.json index 89c7dbc..39e9f8f 100644 --- a/custom_components/jev/translations/de.json +++ b/custom_components/jev/translations/de.json @@ -228,6 +228,28 @@ "description": "Standardmäßig aus. Sendet jedes Attribut der gewählten Entitäten mit, nicht nur Wert, Einheit, Geräteklasse und Bereich. Eine Wettervorhersage oder die Coverliste eines Media Players geht in die Tausende von Tokens, und die bezahlst du bei jeder Auswertung." } } + }, + "calibrate": { + "name": "Besten Schwellenwert finden", + "description": "Vergleicht den aufgezeichneten Verlauf einer Ja/Nein-Wahrscheinlichkeit mit einer Entität, die zeigt, was wirklich zutraf, und liefert den Schwellenwert, der am häufigsten richtig gewesen wäre. Liest nur den Recorder und kostet daher keine Tokens.", + "fields": { + "entity_id": { + "name": "Wahrscheinlichkeit", + "description": "Ein Jev-Ja/Nein-Sensor oder ein anderer Sensor, dessen Zustand eine Wahrscheinlichkeit von 0 bis 1 ist." + }, + "truth_entity_id": { + "name": "Was wirklich zutraf", + "description": "Eine Entität, deren Zustand die echte Antwort zeigt, etwa ein Türkontakt oder eine Steckdose, die meldet, wann ein Gerät läuft." + }, + "truth_state": { + "name": "Zustand für Ja", + "description": "Der Zustand dieser Entität, der Ja bedeutet. on, wenn du das Feld leer lässt." + }, + "days": { + "name": "Tage Verlauf", + "description": "Wie weit zurück gelesen wird. Der Recorder behält 10 Tage, sofern du das nicht geändert hast." + } + } } }, "entity": { @@ -341,6 +363,18 @@ }, "rate_limited": { "message": "TypeSafe hat die Anfrage gedrosselt: {reason}" + }, + "calibrate_needs_recorder": { + "message": "Diese Aktion liest den Recorder, und der Recorder läuft nicht." + }, + "calibrate_no_history": { + "message": "In den letzten {days} Tagen gibt es keinen Zeitraum mit einer Zahl von {entity} und einem Zustand von {truth}. Prüfe, ob der Recorder beide aufzeichnet und ob {entity} eine Zahl meldet." + }, + "calibrate_never_true": { + "message": "{truth} war in den letzten {days} Tagen nie {state}, also gibt es nichts zum Vergleichen. Lies einen längeren Zeitraum oder prüfe, welcher Zustand Ja bedeutet." + }, + "calibrate_always_true": { + "message": "{truth} war die gesamten letzten {days} Tage {state}, also gibt es nichts zum Vergleichen. Lies einen längeren Zeitraum oder prüfe, welcher Zustand Ja bedeutet." } }, "common": { diff --git a/custom_components/jev/translations/en.json b/custom_components/jev/translations/en.json index 54cf0a1..68f7d28 100644 --- a/custom_components/jev/translations/en.json +++ b/custom_components/jev/translations/en.json @@ -228,6 +228,28 @@ "description": "Off by default. Sends every attribute of the picked entities, not just the value, unit, device class and area. A weather forecast or a media player artwork list runs to thousands of tokens, and you pay for them on every evaluation." } } + }, + "calibrate": { + "name": "Find the best threshold", + "description": "Compare the recorded history of a yes/no probability with an entity that shows what was really true, and get the threshold that would have been right most often. Reads only the recorder, so it costs no tokens.", + "fields": { + "entity_id": { + "name": "Probability", + "description": "A Jev yes/no sensor, or any sensor whose state is a probability from 0 to 1." + }, + "truth_entity_id": { + "name": "What was really true", + "description": "An entity whose state shows the real answer, such as a door contact or a plug that reports when a machine runs." + }, + "truth_state": { + "name": "State that means yes", + "description": "The state of that entity that means yes. on if you leave it empty." + }, + "days": { + "name": "Days of history", + "description": "How far back to read. The recorder keeps 10 days unless you changed it." + } + } } }, "entity": { @@ -341,6 +363,18 @@ }, "rate_limited": { "message": "TypeSafe rate limited the request: {reason}" + }, + "calibrate_needs_recorder": { + "message": "This action reads the recorder, and the recorder is not running." + }, + "calibrate_no_history": { + "message": "No time in the last {days} days has both a number from {entity} and a state from {truth}. Check that the recorder records both, and that {entity} reports a number." + }, + "calibrate_never_true": { + "message": "{truth} was never {state} in the last {days} days, so there is nothing to compare against. Read a longer period, or check which state means yes." + }, + "calibrate_always_true": { + "message": "{truth} was {state} for all of the last {days} days, so there is nothing to compare against. Read a longer period, or check which state means yes." } }, "common": { diff --git a/custom_components/jev/translations/es.json b/custom_components/jev/translations/es.json index a3e9da7..5d8edd9 100644 --- a/custom_components/jev/translations/es.json +++ b/custom_components/jev/translations/es.json @@ -228,6 +228,28 @@ "description": "Desactivado por defecto. Envía todos los atributos de las entidades elegidas, no solo el valor, la unidad, la clase de dispositivo y el área. Una previsión meteorológica o la lista de carátulas de un reproductor multimedia llega a miles de tokens, y los pagas en cada evaluación." } } + }, + "calibrate": { + "name": "Encontrar el mejor umbral", + "description": "Compara el historial grabado de una probabilidad de sí o no con una entidad que muestra lo que era cierto de verdad, y devuelve el umbral que habría acertado más veces. Solo lee el recorder, así que no cuesta tokens.", + "fields": { + "entity_id": { + "name": "Probabilidad", + "description": "Un sensor de sí o no de Jev, o cualquier sensor cuyo estado sea una probabilidad de 0 a 1." + }, + "truth_entity_id": { + "name": "Lo que era cierto", + "description": "Una entidad cuyo estado da la respuesta real, como un contacto de puerta o un enchufe que indica cuándo funciona un aparato." + }, + "truth_state": { + "name": "Estado que significa sí", + "description": "El estado de esa entidad que significa sí. on si lo dejas vacío." + }, + "days": { + "name": "Días de historial", + "description": "Hasta dónde leer hacia atrás. El recorder guarda 10 días, salvo que lo hayas cambiado." + } + } } }, "entity": { @@ -341,6 +363,18 @@ }, "rate_limited": { "message": "TypeSafe limitó la solicitud: {reason}" + }, + "calibrate_needs_recorder": { + "message": "Esta acción lee el recorder, y el recorder no está en marcha." + }, + "calibrate_no_history": { + "message": "Ningún momento de los últimos {days} días tiene a la vez un número de {entity} y un estado de {truth}. Comprueba que el recorder guarda los dos, y que {entity} da un número." + }, + "calibrate_never_true": { + "message": "{truth} nunca estuvo en {state} en los últimos {days} días, así que no hay nada con qué comparar. Lee un periodo más largo, o comprueba qué estado significa sí." + }, + "calibrate_always_true": { + "message": "{truth} estuvo en {state} durante todos los últimos {days} días, así que no hay nada con qué comparar. Lee un periodo más largo, o comprueba qué estado significa sí." } }, "common": { diff --git a/custom_components/jev/translations/fr.json b/custom_components/jev/translations/fr.json index d05d12e..338c90a 100644 --- a/custom_components/jev/translations/fr.json +++ b/custom_components/jev/translations/fr.json @@ -228,6 +228,28 @@ "description": "Désactivé par défaut. Envoie tous les attributs des entités choisies, pas seulement la valeur, l'unité, la classe d'appareil et la zone. Une prévision météo ou une liste de pochettes d'un lecteur multimédia représente des milliers de jetons, et vous les payez à chaque évaluation." } } + }, + "calibrate": { + "name": "Trouver le meilleur seuil", + "description": "Compare l'historique enregistré d'une probabilité oui/non avec une entité qui montre ce qui était vraiment vrai, et donne le seuil qui aurait eu raison le plus souvent. Lit seulement le recorder, donc ne coûte aucun token.", + "fields": { + "entity_id": { + "name": "Probabilité", + "description": "Un capteur oui/non Jev, ou tout capteur dont l'état est une probabilité de 0 à 1." + }, + "truth_entity_id": { + "name": "Ce qui était vraiment vrai", + "description": "Une entité dont l'état donne la vraie réponse, comme un contact de porte ou une prise qui signale quand un appareil tourne." + }, + "truth_state": { + "name": "État qui veut dire oui", + "description": "L'état de cette entité qui veut dire oui. on si vous laissez vide." + }, + "days": { + "name": "Jours d'historique", + "description": "Jusqu'où remonter. Le recorder garde 10 jours, sauf si vous l'avez modifié." + } + } } }, "entity": { @@ -341,6 +363,18 @@ }, "rate_limited": { "message": "TypeSafe a limité la requête : {reason}" + }, + "calibrate_needs_recorder": { + "message": "Cette action lit le recorder, et le recorder ne tourne pas." + }, + "calibrate_no_history": { + "message": "Aucun moment des {days} derniers jours n'a à la fois un nombre de {entity} et un état de {truth}. Vérifiez que le recorder enregistre les deux, et que {entity} donne un nombre." + }, + "calibrate_never_true": { + "message": "{truth} n'a jamais été {state} pendant les {days} derniers jours, il n'y a donc rien à comparer. Lisez une période plus longue, ou vérifiez quel état veut dire oui." + }, + "calibrate_always_true": { + "message": "{truth} a été {state} pendant tous les {days} derniers jours, il n'y a donc rien à comparer. Lisez une période plus longue, ou vérifiez quel état veut dire oui." } }, "common": { diff --git a/custom_components/jev/translations/it.json b/custom_components/jev/translations/it.json index 9d88f65..0abc0d6 100644 --- a/custom_components/jev/translations/it.json +++ b/custom_components/jev/translations/it.json @@ -228,6 +228,28 @@ "description": "Disattivo per impostazione predefinita. Invia ogni attributo delle entità scelte, non solo il valore, l'unità, la classe del dispositivo e l'area. Una previsione meteo o l'elenco delle copertine di un lettore multimediale arriva a migliaia di token, e li paghi a ogni valutazione." } } + }, + "calibrate": { + "name": "Trova la soglia migliore", + "description": "Confronta la cronologia registrata di una probabilità sì/no con un'entità che mostra cosa era davvero vero, e restituisce la soglia che avrebbe indovinato più spesso. Legge solo il recorder, quindi non costa token.", + "fields": { + "entity_id": { + "name": "Probabilità", + "description": "Un sensore sì/no di Jev, o qualsiasi sensore il cui stato sia una probabilità da 0 a 1." + }, + "truth_entity_id": { + "name": "Cosa era davvero vero", + "description": "Un'entità il cui stato dà la risposta reale, come un contatto porta o una presa che segnala quando un apparecchio è in funzione." + }, + "truth_state": { + "name": "Stato che significa sì", + "description": "Lo stato di quell'entità che significa sì. on se lo lasci vuoto." + }, + "days": { + "name": "Giorni di cronologia", + "description": "Quanto indietro leggere. Il recorder conserva 10 giorni, salvo modifiche." + } + } } }, "entity": { @@ -341,6 +363,18 @@ }, "rate_limited": { "message": "TypeSafe ha limitato la richiesta: {reason}" + }, + "calibrate_needs_recorder": { + "message": "Questa azione legge il recorder, e il recorder non è in esecuzione." + }, + "calibrate_no_history": { + "message": "Nessun momento degli ultimi {days} giorni ha sia un numero da {entity} sia uno stato da {truth}. Controlla che il recorder registri entrambi e che {entity} dia un numero." + }, + "calibrate_never_true": { + "message": "{truth} non è mai stato {state} negli ultimi {days} giorni, quindi non c'è nulla da confrontare. Leggi un periodo più lungo, o controlla quale stato significa sì." + }, + "calibrate_always_true": { + "message": "{truth} è stato {state} per tutti gli ultimi {days} giorni, quindi non c'è nulla da confrontare. Leggi un periodo più lungo, o controlla quale stato significa sì." } }, "common": { diff --git a/custom_components/jev/translations/nl.json b/custom_components/jev/translations/nl.json index 48220fd..d46aca9 100644 --- a/custom_components/jev/translations/nl.json +++ b/custom_components/jev/translations/nl.json @@ -228,6 +228,28 @@ "description": "Staat uit. Stuurt alle attributen van de gekozen entiteiten mee, niet alleen de waarde, eenheid, device class en ruimte. Een weersverwachting of een lijst met albumhoezen loopt in de duizenden tokens, en die betaal je bij elke evaluatie." } } + }, + "calibrate": { + "name": "Beste drempel bepalen", + "description": "Vergelijkt de opgeslagen geschiedenis van een ja/nee-kans met een entiteit die laat zien wat echt waar was, en geeft de drempel die het vaakst goed zou zijn geweest. Leest alleen de recorder en kost dus geen tokens.", + "fields": { + "entity_id": { + "name": "Kans", + "description": "Een Jev ja/nee-sensor, of een andere sensor waarvan de status een kans van 0 tot 1 is." + }, + "truth_entity_id": { + "name": "Wat echt waar was", + "description": "Een entiteit waarvan de status het echte antwoord geeft, zoals een deurcontact of een stekker die meldt wanneer een apparaat draait." + }, + "truth_state": { + "name": "Status die ja betekent", + "description": "De status van die entiteit die ja betekent. on als je dit leeg laat." + }, + "days": { + "name": "Dagen geschiedenis", + "description": "Hoe ver terug er gelezen wordt. De recorder bewaart 10 dagen, tenzij je dat hebt aangepast." + } + } } }, "entity": { @@ -341,6 +363,18 @@ }, "rate_limited": { "message": "TypeSafe heeft het verzoek begrensd: {reason}" + }, + "calibrate_needs_recorder": { + "message": "Deze actie leest de recorder, en de recorder draait niet." + }, + "calibrate_no_history": { + "message": "Er is in de laatste {days} dagen geen moment met zowel een getal van {entity} als een status van {truth}. Controleer of de recorder beide opslaat, en of {entity} een getal geeft." + }, + "calibrate_never_true": { + "message": "{truth} was in de laatste {days} dagen nooit {state}, dus er is niets om mee te vergelijken. Lees een langere periode, of controleer welke status ja betekent." + }, + "calibrate_always_true": { + "message": "{truth} was de hele laatste {days} dagen {state}, dus er is niets om mee te vergelijken. Lees een langere periode, of controleer welke status ja betekent." } }, "common": { diff --git a/custom_components/jev/translations/pl.json b/custom_components/jev/translations/pl.json index a5765eb..f68e736 100644 --- a/custom_components/jev/translations/pl.json +++ b/custom_components/jev/translations/pl.json @@ -228,6 +228,28 @@ "description": "Domyślnie wyłączone. Wysyła każdy atrybut wybranych encji, nie tylko wartość, jednostkę, klasę urządzenia i obszar. Prognoza pogody albo lista okładek odtwarzacza multimediów to tysiące tokenów, za które płacisz przy każdej ocenie." } } + }, + "calibrate": { + "name": "Znajdź najlepszy próg", + "description": "Porównuje zapisaną historię prawdopodobieństwa tak/nie z encją, która pokazuje, co było naprawdę prawdą, i zwraca próg, który najczęściej miałby rację. Czyta tylko recorder, więc nie kosztuje tokenów.", + "fields": { + "entity_id": { + "name": "Prawdopodobieństwo", + "description": "Czujnik tak/nie z Jev albo dowolny czujnik, którego stan jest prawdopodobieństwem od 0 do 1." + }, + "truth_entity_id": { + "name": "Co było prawdą", + "description": "Encja, której stan pokazuje prawdziwą odpowiedź, na przykład kontaktron drzwi albo gniazdko, które zgłasza, kiedy urządzenie pracuje." + }, + "truth_state": { + "name": "Stan oznaczający tak", + "description": "Stan tej encji, który oznacza tak. on, jeśli zostawisz pole puste." + }, + "days": { + "name": "Dni historii", + "description": "Jak daleko wstecz czytać. Recorder przechowuje 10 dni, chyba że to zmieniono." + } + } } }, "entity": { @@ -341,6 +363,18 @@ }, "rate_limited": { "message": "TypeSafe ograniczył żądanie: {reason}" + }, + "calibrate_needs_recorder": { + "message": "Ta akcja czyta recorder, a recorder nie działa." + }, + "calibrate_no_history": { + "message": "W ostatnich {days} dniach nie ma chwili, w której jest zarówno liczba z {entity}, jak i stan z {truth}. Sprawdź, czy recorder zapisuje obie encje i czy {entity} podaje liczbę." + }, + "calibrate_never_true": { + "message": "{truth} ani razu nie miał stanu {state} w ostatnich {days} dniach, więc nie ma z czym porównać. Czytaj dłuższy okres albo sprawdź, który stan oznacza tak." + }, + "calibrate_always_true": { + "message": "{truth} miał stan {state} przez całe ostatnie {days} dni, więc nie ma z czym porównać. Czytaj dłuższy okres albo sprawdź, który stan oznacza tak." } }, "common": { diff --git a/custom_components/jev/translations/pt-BR.json b/custom_components/jev/translations/pt-BR.json index 22b1c26..0f63aca 100644 --- a/custom_components/jev/translations/pt-BR.json +++ b/custom_components/jev/translations/pt-BR.json @@ -228,6 +228,28 @@ "description": "Desligado por padrão. Envia todos os atributos das entidades escolhidas, não apenas o valor, a unidade, a classe de dispositivo e a área. Uma previsão do tempo ou a lista de capas de um reprodutor de mídia chega a milhares de tokens, e você paga por eles em cada avaliação." } } + }, + "calibrate": { + "name": "Encontrar o melhor limite", + "description": "Compara o histórico gravado de uma probabilidade de sim ou não com uma entidade que mostra o que era de fato verdade, e retorna o limite que teria acertado mais vezes. Lê apenas o recorder, então não custa tokens.", + "fields": { + "entity_id": { + "name": "Probabilidade", + "description": "Um sensor de sim ou não do Jev, ou qualquer sensor cujo estado seja uma probabilidade de 0 a 1." + }, + "truth_entity_id": { + "name": "O que era verdade", + "description": "Uma entidade cujo estado mostra a resposta real, como um contato de porta ou uma tomada que informa quando um aparelho está ligado." + }, + "truth_state": { + "name": "Estado que significa sim", + "description": "O estado dessa entidade que significa sim. on se você deixar vazio." + }, + "days": { + "name": "Dias de histórico", + "description": "Até onde ler para trás. O recorder guarda 10 dias, a menos que você tenha mudado isso." + } + } } }, "entity": { @@ -341,6 +363,18 @@ }, "rate_limited": { "message": "O TypeSafe limitou a solicitação: {reason}" + }, + "calibrate_needs_recorder": { + "message": "Esta ação lê o recorder, e o recorder não está em execução." + }, + "calibrate_no_history": { + "message": "Nenhum momento dos últimos {days} dias tem ao mesmo tempo um número de {entity} e um estado de {truth}. Verifique se o recorder grava os dois e se {entity} informa um número." + }, + "calibrate_never_true": { + "message": "{truth} nunca esteve em {state} nos últimos {days} dias, então não há com o que comparar. Leia um período maior, ou verifique qual estado significa sim." + }, + "calibrate_always_true": { + "message": "{truth} esteve em {state} durante todos os últimos {days} dias, então não há com o que comparar. Leia um período maior, ou verifique qual estado significa sim." } }, "common": { diff --git a/custom_components/jev/translations/ru.json b/custom_components/jev/translations/ru.json index 1f50471..ce2a4ba 100644 --- a/custom_components/jev/translations/ru.json +++ b/custom_components/jev/translations/ru.json @@ -228,6 +228,28 @@ "description": "По умолчанию выключено. Передаёт каждый атрибут выбранных объектов, а не только значение, единицу измерения, класс устройства и помещение. Прогноз погоды или список обложек в медиаплеере занимает тысячи токенов, и вы платите за них при каждой оценке." } } + }, + "calibrate": { + "name": "Найти лучший порог", + "description": "Сравнивает записанную историю вероятности да/нет с объектом, который показывает, что было на самом деле, и возвращает порог, который чаще всего оказался бы прав. Читает только recorder, поэтому не тратит токены.", + "fields": { + "entity_id": { + "name": "Вероятность", + "description": "Датчик да/нет от Jev или любой датчик, состояние которого является вероятностью от 0 до 1." + }, + "truth_entity_id": { + "name": "Что было на самом деле", + "description": "Объект, состояние которого показывает настоящий ответ, например датчик двери или розетка, которая сообщает, когда работает прибор." + }, + "truth_state": { + "name": "Состояние, означающее да", + "description": "Состояние этого объекта, которое означает да. on, если оставить поле пустым." + }, + "days": { + "name": "Дней истории", + "description": "Насколько далеко назад читать. Recorder хранит 10 дней, если вы это не меняли." + } + } } }, "entity": { @@ -341,6 +363,18 @@ }, "rate_limited": { "message": "TypeSafe ограничил запрос: {reason}" + }, + "calibrate_needs_recorder": { + "message": "Это действие читает recorder, а recorder не запущен." + }, + "calibrate_no_history": { + "message": "За последние {days} дней нет ни одного момента, где есть и число от {entity}, и состояние от {truth}. Проверьте, что recorder записывает оба объекта и что {entity} сообщает число." + }, + "calibrate_never_true": { + "message": "{truth} ни разу не был в состоянии {state} за последние {days} дней, поэтому сравнивать не с чем. Прочитайте более длинный период или проверьте, какое состояние означает да." + }, + "calibrate_always_true": { + "message": "{truth} был в состоянии {state} все последние {days} дней, поэтому сравнивать не с чем. Прочитайте более длинный период или проверьте, какое состояние означает да." } }, "common": { diff --git a/custom_components/jev/translations/sv.json b/custom_components/jev/translations/sv.json index d49d5b2..488ca5c 100644 --- a/custom_components/jev/translations/sv.json +++ b/custom_components/jev/translations/sv.json @@ -228,6 +228,28 @@ "description": "Av som standard. Skickar alla attribut för de valda entiteterna, inte bara värde, måttenhet, enhetsklass och område. En väderprognos eller en mediaspelares lista med omslagsbilder blir tusentals token, och du betalar för dem vid varje utvärdering." } } + }, + "calibrate": { + "name": "Hitta den bästa tröskeln", + "description": "Jämför den inspelade historiken för en ja/nej-sannolikhet med en entitet som visar vad som verkligen var sant, och ger den tröskel som oftast skulle ha haft rätt. Läser bara recordern och kostar därför inga tokens.", + "fields": { + "entity_id": { + "name": "Sannolikhet", + "description": "En Jev ja/nej-sensor eller en annan sensor vars tillstånd är en sannolikhet från 0 till 1." + }, + "truth_entity_id": { + "name": "Vad som verkligen var sant", + "description": "En entitet vars tillstånd visar det riktiga svaret, till exempel en dörrkontakt eller ett uttag som rapporterar när en apparat är igång." + }, + "truth_state": { + "name": "Tillstånd som betyder ja", + "description": "Tillståndet för entiteten som betyder ja. on om du lämnar fältet tomt." + }, + "days": { + "name": "Dagar historik", + "description": "Hur långt tillbaka som läses. Recordern sparar 10 dagar om du inte har ändrat det." + } + } } }, "entity": { @@ -341,6 +363,18 @@ }, "rate_limited": { "message": "TypeSafe begränsade begäran: {reason}" + }, + "calibrate_needs_recorder": { + "message": "Den här åtgärden läser recordern, och recordern körs inte." + }, + "calibrate_no_history": { + "message": "Ingen tidpunkt de senaste {days} dagarna har både ett tal från {entity} och ett tillstånd från {truth}. Kontrollera att recordern sparar båda och att {entity} rapporterar ett tal." + }, + "calibrate_never_true": { + "message": "{truth} var aldrig {state} de senaste {days} dagarna, så det finns inget att jämföra med. Läs en längre period, eller kontrollera vilket tillstånd som betyder ja." + }, + "calibrate_always_true": { + "message": "{truth} var {state} under hela de senaste {days} dagarna, så det finns inget att jämföra med. Läs en längre period, eller kontrollera vilket tillstånd som betyder ja." } }, "common": { diff --git a/custom_components/jev/translations/zh-Hans.json b/custom_components/jev/translations/zh-Hans.json index 85f813c..0bf139d 100644 --- a/custom_components/jev/translations/zh-Hans.json +++ b/custom_components/jev/translations/zh-Hans.json @@ -228,6 +228,28 @@ "description": "默认关闭。会发送所选实体的每一个属性,而不只是值、单位、设备类别和区域。一份天气预报或一个媒体播放器的封面列表可达数千个令牌,而且每次评估都要为此付费。" } } + }, + "calibrate": { + "name": "找出最佳阈值", + "description": "将是/否概率的记录历史与显示真实情况的实体进行比较,并返回最常判断正确的阈值。只读取 recorder,因此不消耗 token。", + "fields": { + "entity_id": { + "name": "概率", + "description": "Jev 的是/否传感器,或任何状态为 0 到 1 概率的传感器。" + }, + "truth_entity_id": { + "name": "真实情况", + "description": "状态显示真实答案的实体,例如门磁或报告设备何时运行的插座。" + }, + "truth_state": { + "name": "表示是的状态", + "description": "该实体表示“是”的状态。留空时为 on。" + }, + "days": { + "name": "历史天数", + "description": "向前读取多少天。除非你修改过,recorder 保留 10 天。" + } + } } }, "entity": { @@ -341,6 +363,18 @@ }, "rate_limited": { "message": "TypeSafe 限制了该请求的速率:{reason}" + }, + "calibrate_needs_recorder": { + "message": "此操作需要读取 recorder,但 recorder 未运行。" + }, + "calibrate_no_history": { + "message": "过去 {days} 天中,没有任何时刻同时有 {entity} 的数值和 {truth} 的状态。请检查 recorder 是否记录了两者,以及 {entity} 是否报告数值。" + }, + "calibrate_never_true": { + "message": "过去 {days} 天中 {truth} 从未处于 {state},因此没有可比较的内容。请读取更长的时间段,或检查哪个状态表示“是”。" + }, + "calibrate_always_true": { + "message": "过去 {days} 天中 {truth} 一直处于 {state},因此没有可比较的内容。请读取更长的时间段,或检查哪个状态表示“是”。" } }, "common": { diff --git a/tests/test_calibrate.py b/tests/test_calibrate.py new file mode 100644 index 0000000..874e7ad --- /dev/null +++ b/tests/test_calibrate.py @@ -0,0 +1,175 @@ +"""jev.calibrate: a threshold measured against what was really true.""" + +from datetime import timedelta + +import pytest +from homeassistant.exceptions import ServiceValidationError +from homeassistant.util import dt as dt_util +from pytest_homeassistant_custom_component.components.recorder.common import ( + async_wait_recording_done, +) + +from custom_components.jev.calibrate import Span, best, build_spans, outcome +from custom_components.jev.const import DOMAIN + +PROBABILITY = "sensor.laundry_done" +TRUTH = "binary_sensor.laundry_door" + + +@pytest.fixture +def mock_recorder_before_hass(async_test_recorder): + """The recorder's database has to exist before hass starts.""" + + +@pytest.fixture(autouse=True) +def recorder(recorder_mock): + return recorder_mock + + +def at_hours(*states): + """States one hour apart, as the recorder would return them.""" + from homeassistant.core import State + + start = dt_util.parse_datetime("2026-09-22 08:00:00+00:00") + return [ + State(entity_id, value, last_changed=start + timedelta(hours=hour)) + for entity_id, value, hour in states + ], start + + +def test_every_change_of_either_entity_cuts_a_span(): + states, start = at_hours( + ("sensor.p", "0.2", 0), + ("binary_sensor.t", "off", 0), + ("sensor.p", "0.7", 1), + ("binary_sensor.t", "on", 1), + ) + probabilities = [s for s in states if s.entity_id == "sensor.p"] + truths = [s for s in states if s.entity_id == "binary_sensor.t"] + spans = build_spans(probabilities, truths, "on", start, start + timedelta(hours=3)) + assert [(s.probability, s.truth, s.seconds) for s in spans] == [ + (0.2, False, 3600.0), + (0.7, True, 7200.0), + ] + + +def test_a_state_from_before_the_window_counts_from_its_start(): + states, start = at_hours(("sensor.p", "0.9", -5), ("binary_sensor.t", "on", -2)) + spans = build_spans(states[:1], states[1:], "on", start, start + timedelta(hours=1)) + assert spans == [Span(0.9, True, 3600.0)] + + +def test_time_without_a_number_is_left_out(): + """Unavailable while the API was down is not a probability of anything.""" + states, start = at_hours( + ("sensor.p", "0.9", 0), + ("binary_sensor.t", "on", 0), + ("sensor.p", "unavailable", 1), + ("sensor.p", "0.8", 2), + ) + probabilities = [s for s in states if s.entity_id == "sensor.p"] + truths = [s for s in states if s.entity_id == "binary_sensor.t"] + spans = build_spans(probabilities, truths, "on", start, start + timedelta(hours=3)) + assert sum(s.seconds for s in spans) == 7200.0 + assert {s.probability for s in spans} == {0.9, 0.8} + + +def test_precision_and_recall_are_weighted_by_time(): + spans = [Span(0.8, True, 30), Span(0.8, False, 10), Span(0.2, True, 60)] + result = outcome(spans, 0.5) + assert result.precision == 0.75 + assert result.recall == 30 / 90 + + +def test_nothing_said_yes_has_no_precision_and_no_f1(): + result = outcome([Span(0.2, True, 60), Span(0.1, False, 60)], 0.5) + assert result.precision is None + assert result.f1 == 0.0 + + +def test_the_middle_of_the_tied_thresholds_is_returned(): + """Every threshold from 0.21 to 0.41 separates these perfectly. 0.31 is central.""" + spans = [ + Span(0.2, False, 3600), + Span(0.7, True, 3600), + Span(0.41, True, 3600), + Span(0.1, False, 3600), + ] + result = best(spans) + assert result.threshold == 0.31 + assert (result.precision, result.recall) == (1.0, 1.0) + + +async def record_a_day(hass, freezer): + """Four hours: the door opens with the noul at 0.7, and closes at 0.1.""" + start = dt_util.utcnow() - timedelta(hours=4) + for hour, probability, door in ( + (0, "0.2", "off"), + (1, "0.7", "on"), + (2, "0.41", "on"), + (3, "0.1", "off"), + ): + freezer.move_to(start + timedelta(hours=hour)) + hass.states.async_set(PROBABILITY, probability) + hass.states.async_set(TRUTH, door) + await hass.async_block_till_done() + freezer.move_to(start + timedelta(hours=4)) + await async_wait_recording_done(hass) + + +async def calibrate(hass, **data): + return await hass.services.async_call( + DOMAIN, + "calibrate", + {"entity_id": PROBABILITY, "truth_entity_id": TRUTH, **data}, + blocking=True, + return_response=True, + ) + + +async def test_calibrate_reads_the_recorder_and_returns_the_best_threshold( + hass, loaded_entry, mock_client, freezer +): + mock_client.ask.reset_mock() + await record_a_day(hass, freezer) + result = await calibrate(hass) + + assert result["threshold"] == 0.31 + assert (result["precision"], result["recall"], result["f1"]) == (1.0, 1.0, 1.0) + assert result["hours"] == 4.0 + assert result["hours_true"] == 2.0 + assert result["times_true"] == 1 + assert result["probability_changes"] == 4 + assert result["table"][0] == { + "threshold": 0.1, + "precision": 0.5, + "recall": 1.0, + "f1": 0.667, + } + # It reads history and nothing else, so it costs no tokens. + assert mock_client.ask.await_count == 0 + + +async def test_truth_that_never_happened_is_refused(hass, loaded_entry, freezer): + await record_a_day(hass, freezer) + with pytest.raises(ServiceValidationError) as err: + await calibrate(hass, truth_state="open") + assert err.value.translation_key == "calibrate_never_true" + + +async def test_truth_that_never_changed_is_refused(hass, loaded_entry, freezer): + hass.states.async_set("binary_sensor.always", "on") + await hass.async_block_till_done() + freezer.tick(timedelta(hours=4)) + await record_a_day(hass, freezer) + with pytest.raises(ServiceValidationError) as err: + await calibrate(hass, truth_entity_id="binary_sensor.always") + assert err.value.translation_key == "calibrate_always_true" + + +async def test_an_entity_with_no_history_is_refused(hass, loaded_entry): + await async_wait_recording_done(hass) + with pytest.raises(ServiceValidationError) as err: + await calibrate(hass) + assert err.value.translation_key == "calibrate_no_history" + assert err.value.translation_placeholders["entity"] == PROBABILITY diff --git a/tests/test_services.py b/tests/test_services.py index df47b4c..3ed47ba 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -437,3 +437,13 @@ async def test_an_action_with_two_entries_and_none_named_is_refused( {"state": "x", "instructions": "y", "config_entry": second.entry_id}, ) assert "noul" in named + + +async def test_without_the_recorder_it_says_so(hass, loaded_entry): + with pytest.raises(ServiceValidationError) as err: + await call( + hass, + "calibrate", + {"entity_id": "sensor.x", "truth_entity_id": "binary_sensor.y"}, + ) + assert err.value.translation_key == "calibrate_needs_recorder" From 28bdf87a39369d189f8fa38385f32c2e24e79da0 Mon Sep 17 00:00:00 2001 From: AboveColin Date: Thu, 24 Sep 2026 08:32:00 +0200 Subject: [PATCH 2/2] Calibrate reports hours to two places A test window with the door open for two minutes returned hours_true 0.0 beside times_true 1, which read as the never-true refusal failing to fire. Both hour counts now keep two places, so the same window reads 0.03. --- custom_components/jev/calibrate.py | 6 ++++-- tests/test_calibrate.py | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/custom_components/jev/calibrate.py b/custom_components/jev/calibrate.py index a6ee4f0..07d2bc4 100644 --- a/custom_components/jev/calibrate.py +++ b/custom_components/jev/calibrate.py @@ -215,8 +215,10 @@ async def async_calibrate(hass: HomeAssistant, call: ServiceCall) -> ServiceResp ) return { **best(spans).as_dict(), - "hours": round(sum(span.seconds for span in spans) / 3600, 1), - "hours_true": round(true_seconds / 3600, 1), + # Two places, so a door open for two minutes reads 0.03 and not 0.0, which + # looked like the never-true refusal had failed to fire. + "hours": round(sum(span.seconds for span in spans) / 3600, 2), + "hours_true": round(true_seconds / 3600, 2), # F1 over two occasions is noise, whatever its value. These are the counts # to judge it by. "times_true": _times_true(spans), diff --git a/tests/test_calibrate.py b/tests/test_calibrate.py index 874e7ad..07eaa3b 100644 --- a/tests/test_calibrate.py +++ b/tests/test_calibrate.py @@ -117,6 +117,22 @@ async def record_a_day(hass, freezer): await async_wait_recording_done(hass) +async def record_two_minutes_open(hass, freezer): + """An hour with the door open for two minutes of it.""" + start = dt_util.utcnow() - timedelta(hours=1) + for minute, probability, door in ( + (0, "0.2", "off"), + (30, "0.8", "on"), + (32, "0.1", "off"), + ): + freezer.move_to(start + timedelta(minutes=minute)) + hass.states.async_set(PROBABILITY, probability) + hass.states.async_set(TRUTH, door) + await hass.async_block_till_done() + freezer.move_to(start + timedelta(hours=1)) + await async_wait_recording_done(hass) + + async def calibrate(hass, **data): return await hass.services.async_call( DOMAIN, @@ -173,3 +189,13 @@ async def test_an_entity_with_no_history_is_refused(hass, loaded_entry): await calibrate(hass) assert err.value.translation_key == "calibrate_no_history" assert err.value.translation_placeholders["entity"] == PROBABILITY + + +async def test_a_short_time_true_is_not_rounded_to_nothing( + hass, loaded_entry, mock_client, freezer +): + await record_two_minutes_open(hass, freezer) + result = await calibrate(hass) + + assert result["hours_true"] == 0.03 + assert result["times_true"] == 1