From 3721415cff0c4e271472176b648f01c893b6342d Mon Sep 17 00:00:00 2001 From: oleost <5670710+oleost@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:28:15 +0200 Subject: [PATCH 01/19] Back to the beta channel after 0.9.0 Co-Authored-By: Claude Opus 5.5 --- visionstate/config.yaml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/visionstate/config.yaml b/visionstate/config.yaml index 91c3af2..c6c90b8 100644 --- a/visionstate/config.yaml +++ b/visionstate/config.yaml @@ -1,5 +1,5 @@ -name: VisionState -version: "0.9.0" +name: VisionState (beta) +version: "0.9.0b5" slug: visionstate description: Teach a local AI to recognise states (open, closed, …), find objects (people, cars, pets) or read numbers in your camera images url: https://github.com/oleost/VisionState @@ -13,7 +13,7 @@ arch: ingress: true ingress_port: 8099 panel_icon: mdi:eye-check-outline -panel_title: VisionState +panel_title: VisionState β homeassistant_api: true hassio_api: true services: @@ -34,3 +34,5 @@ schema: mqtt_port: port? mqtt_username: str? mqtt_password: password? +environment: + VISIONSTATE_MEDIA: /media/visionstate_beta From b84060a56098d29c6ba6b2540903e5778d850468 Mon Sep 17 00:00:00 2001 From: oleost <5670710+oleost@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:32:12 +0200 Subject: [PATCH 02/19] Faces: show switching the face model on the sensors' cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching the face model gave no sign on the start page or the sensor's page, and the taught faces were embedded again in the middle of the first check, with no progress anywhere. Now they are embedded as soon as the new model is loaded, with a count in the face status, and the model is used only once they are done (the checks go on without faces meanwhile, as while it downloads). Object sensors that recognise faces show Downloading / Loading / Learning faces… where state sensors show Training…; the Faces card, People and Settings show the progress. Co-Authored-By: Claude Opus 5.5 --- visionstate/DOCS.md | 7 +- visionstate/backend/tests/test_faces.py | 28 ++++++++ .../backend/visionstate/engine/base.py | 1 + .../backend/visionstate/engine/faces.py | 68 +++++++++++++------ visionstate/frontend/e2e/ui.spec.ts | 22 ++++++ .../src/lib/components/FacesCard.svelte | 7 ++ visionstate/frontend/src/lib/objects.ts | 10 +++ visionstate/frontend/src/lib/types.ts | 4 +- .../frontend/src/pages/Dashboard.svelte | 6 +- visionstate/frontend/src/pages/People.svelte | 6 ++ .../frontend/src/pages/SensorPage.svelte | 6 +- .../frontend/src/pages/Settings.svelte | 1 + 12 files changed, 140 insertions(+), 26 deletions(-) diff --git a/visionstate/DOCS.md b/visionstate/DOCS.md index 1973456..1121aab 100644 --- a/visionstate/DOCS.md +++ b/visionstate/DOCS.md @@ -196,7 +196,8 @@ everyone. (**Recognise people by their face** under the objects, once Person is picked). A sheet says what is stored and which face model is downloaded (once, from Hugging Face), and nothing happens until you confirm. While it downloads the sensor keeps working without faces; the card - shows the progress, and **Try again** if it failed. Without internet, put the file the message + shows the progress, and **Try again** if it failed. The sensor's card on the start page and its + page say *Downloading faces…*, *Loading faces…* or *Learning faces…* until faces are named. Without internet, put the file the message names in the app's `models` folder by hand. - **Does my camera see faces well enough?** The **Live** tab draws a small rounded box around each face it finds in a person, and says for each person what it saw: a name, *face too small*, @@ -529,7 +530,9 @@ The models, chosen under **Settings → AI model** for all sensors of a kind (sm times as slow per face), which tells faces apart better in poor light and at an angle. YuNet was trained on a research data set and SFace's paper names research data sets too (their terms are not commercial); AuraFace's makers say its data allows commercial use. Switching - compares every taught face again with the new model; the people are kept. Only loaded while a + computes every taught face again with the new model (*Learning faces… 12/40* on the sensors' + cards, the progress on the Faces card and the People page; the sensors find people without + naming them until it is done); the people are kept. Only loaded while a sensor recognises faces. - **Reading sensors:** PP-OCRv6 tiny (PaddleOCR) — included, a few milliseconds per reading. PP-OCRv6 small is larger and reads unusual fonts more reliably (downloaded on first use, diff --git a/visionstate/backend/tests/test_faces.py b/visionstate/backend/tests/test_faces.py index 5c75c1b..17eeb41 100644 --- a/visionstate/backend/tests/test_faces.py +++ b/visionstate/backend/tests/test_faces.py @@ -4,6 +4,7 @@ 2018, Butch Wilmore, the two of them together, and a crew of nine with both of them in it. """ +import asyncio import io import json import zipfile @@ -339,6 +340,33 @@ def teach(name): assert all(len(index.ids) == 2 for index in built) with rt.db.session() as s: assert s.scalar(select(func.count()).select_from(FaceEmbedding)) == 2 + # Embedding the taught faces again (a new model) shows how far it is, and nothing when done. + with rt.db.session() as s: + s.execute(delete(FaceEmbedding)) + progress, embed = [], rt._embed_taught + rt._embed_taught = lambda *args: progress.append(rt.face_status()["embedding"]) or embed(*args) + try: + assert len(rt._build_face_index(rt.face_model, 0).ids) == 2 + finally: + del rt._embed_taught + assert progress == [{"done": 0, "total": 2}, {"done": 1, "total": 2}] + assert rt.face_status()["embedding"] is None + # A newly loaded model embeds them before it is used: the checks go on without faces meanwhile. + with rt.db.session() as s: + s.execute(delete(FaceEmbedding)) + unused = [] + rt._embed_taught = lambda *args: unused.append(rt.face_model is None) or embed(*args) + + async def reload(): + rt.face_model, rt.face_index, rt._face_task = None, None, asyncio.current_task() + await rt._load_faces() + + try: + client.portal.call(reload) + finally: + del rt._embed_taught + assert unused == [True, True] and rt.face_model is not None and rt.face_index is not None + assert len(rt.face_index.ids) == 2 # Each person's own entity says where they are seen: on this sensor now. assert people["people"][0]["seen"]["now"] == "beach" and people["people"][0]["seen"]["sensor_id"] == sid image = client.get(f"/api/v1/faces/{face_id}/image") diff --git a/visionstate/backend/visionstate/engine/base.py b/visionstate/backend/visionstate/engine/base.py index 60fc325..ed553ee 100644 --- a/visionstate/backend/visionstate/engine/base.py +++ b/visionstate/backend/visionstate/engine/base.py @@ -69,6 +69,7 @@ def __init__(self, settings: Settings, db: Database): self.face_choice = faces.DEFAULT_RECOGNISER self.face_error = "" self.face_progress: dict | None = None # {"done", "total"} bytes while it downloads + self.face_embedding: dict | None = None # {"done", "total"} taught faces while they are embedded self._face_lock = asyncio.Lock() self._face_task: asyncio.Task | None = None self.face_index: faces.FaceIndex | None = None # the people's taught faces; built on use diff --git a/visionstate/backend/visionstate/engine/faces.py b/visionstate/backend/visionstate/engine/faces.py index 42e4b7a..b00f6b8 100644 --- a/visionstate/backend/visionstate/engine/faces.py +++ b/visionstate/backend/visionstate/engine/faces.py @@ -56,13 +56,15 @@ class FacesMixin(ModelsMixin, PublishingMixin, RuntimeBase): # --- The model ------------------------------------------------------------------------------ def face_status(self) -> dict: - """The face model for the UI: chosen, loaded, downloading (bytes), or why it failed.""" + """The face model for the UI: chosen, loaded, downloading (bytes), embedding the taught faces + (count), or why it failed.""" spec = faces.recogniser_spec(self.face_choice) return { "model": spec.id, "loaded": self.face_model is not None and self.face_model.id == spec.id, "loading": self._face_task is not None and not self._face_task.done(), "progress": self.face_progress, + "embedding": self.face_embedding, "error": self.face_error, "downloaded": self.face_files_present(spec.id), } @@ -127,15 +129,26 @@ async def _load_faces(self) -> None: return finally: self.face_progress = None - replaced, self.face_model, self.face_error = self.face_model, model, "" - log.info("Face model %s loaded", spec.id) + replaced, self.face_model, self.face_error = self.face_model, None, "" if replaced is not None: await asyncio.to_thread(backbones.release_memory) sensor_ids = await asyncio.to_thread(self.face_sensor_ids) if not sensor_ids: # Turned off while it loaded (a check in flight asked for it): let it go again. - await self.faces_settled() + del model + await asyncio.to_thread(backbones.release_memory) return + # The taught faces are embedded before the model is used (the UI shows how far it is): the + # checks meanwhile go on without faces, as while it downloaded, instead of waiting for it. + try: + await self.face_index_for(model) + except Exception: # noqa: BLE001 - the first check builds it again + log.exception("Could not embed the taught faces with %s", spec.id) + self.face_model = model + log.info("Face model %s loaded", spec.id) + sensor_ids = await asyncio.to_thread(self.face_sensor_ids) # turned off meanwhile: let it go + if not sensor_ids: + await self.faces_settled() for sensor_id in sensor_ids: self.wake(sensor_id, force=True) @@ -206,24 +219,41 @@ def _build_face_index_locked(self, model: faces.FaceModel, generation: int) -> f ) } ids, labels, vectors = [], [], [] - for face_id, key, filename, face in examples: - vector = cached.get(face_id) - if vector is None: - path = self.storage.face_path(filename) - if not path.exists(): - continue - crop = imaging.load(path) - aligned = faces.align(crop, faces.face_from_dict(face), model.recogniser.spec.input_size) - vector = model.recogniser.embed([aligned])[0] - row = {"face_id": face_id, "model": cache_key, "vector": vector.tobytes()} - with self.db.session() as s: # stored once, whoever comes first - s.execute(insert(FaceEmbedding).values(**row).on_conflict_do_nothing()) - ids.append(face_id) - labels.append(key) - vectors.append(vector) + missing, done = sum(1 for e in examples if e[0] not in cached), 0 + if missing: + self.face_embedding = {"done": 0, "total": missing} # a new model: the UI shows the progress + try: + for face_id, key, filename, face in examples: + vector = cached.get(face_id) + if vector is None: + vector = self._embed_taught(model, cache_key, face_id, filename, face) + done += 1 + self.face_embedding = {"done": done, "total": missing} + if vector is None: + continue + ids.append(face_id) + labels.append(key) + vectors.append(vector) + finally: + self.face_embedding = None stacked = np.stack(vectors) if vectors else np.zeros((0, 0), dtype=np.float32) return faces.FaceIndex(model.id, ids, labels, stacked, generation) + def _embed_taught( + self, model: faces.FaceModel, cache_key: str, face_id: int, filename: str, face: dict + ) -> np.ndarray | None: + """A taught face's embedding, stored for the next index (None: its image is gone).""" + path = self.storage.face_path(filename) + if not path.exists(): + return None + crop = imaging.load(path) + aligned = faces.align(crop, faces.face_from_dict(face), model.recogniser.spec.input_size) + vector = model.recogniser.embed([aligned])[0] + row = {"face_id": face_id, "model": cache_key, "vector": vector.tobytes()} + with self.db.session() as s: # stored once, whoever comes first + s.execute(insert(FaceEmbedding).values(**row).on_conflict_do_nothing()) + return vector + def faces_changed(self) -> None: """Taught faces were added, moved or removed: every sensor that recognises faces compares with the new set from its next check on.""" diff --git a/visionstate/frontend/e2e/ui.spec.ts b/visionstate/frontend/e2e/ui.spec.ts index 0a61357..78d7f4b 100644 --- a/visionstate/frontend/e2e/ui.spec.ts +++ b/visionstate/frontend/e2e/ui.spec.ts @@ -1306,6 +1306,28 @@ test('an answer in the review queue can be changed from the list', async ({ page errors.expectNone(); }); +test('faces: a new face model learning the taught faces shows on the cards, the Faces card and People', async ({ page, request }) => { + const errors = watchErrors(page); + const faceId = await seededFaceSensorId(request); + // Switching the face model embeds every taught face again; the status says how far it is. + await page.route('**/api/v1/status', async (route) => { + const status = await (await route.fetch()).json(); + status.faces = { ...status.faces, loaded: false, loading: true, embedding: { done: 3, total: 8 } }; + await route.fulfill({ json: status }); + }); + await page.goto('#/'); + const card = page.locator('.card', { has: page.getByRole('link', { name: 'Crew', exact: true }) }); + await expect(card.getByText('Learning faces… 3/8')).toBeVisible(); + await expectNoHorizontalOverflow(page); + await page.goto(`#/sensors/${faceId}/settings`); + await expect(page.locator('h1 ~ .chip', { hasText: 'Learning faces… 3/8' })).toBeVisible(); + await expect(page.getByTestId('faces-card').getByText(/Learning the taught faces .* 3 of 8/)).toBeVisible(); + await page.goto('#/people'); + await expect(page.getByTestId('faces-embedding')).toContainText('3 of 8'); + await expectNoHorizontalOverflow(page); + errors.expectNone(); +}); + test('faces: turned on with a confirmation, a person taught from the Live tab, shown under Quality', async ({ page, request }, info) => { const errors = watchErrors(page); const faceId = await seededFaceSensorId(request); diff --git a/visionstate/frontend/src/lib/components/FacesCard.svelte b/visionstate/frontend/src/lib/components/FacesCard.svelte index 4837af9..202391b 100644 --- a/visionstate/frontend/src/lib/components/FacesCard.svelte +++ b/visionstate/frontend/src/lib/components/FacesCard.svelte @@ -125,6 +125,13 @@ + {:else if status?.embedding} +
+ + Learning the taught faces with the face model… {status.embedding.done} of {status.embedding.total} + + +
{:else if status && !status.loaded} Loading the face model… {:else if sensor.objects} diff --git a/visionstate/frontend/src/lib/objects.ts b/visionstate/frontend/src/lib/objects.ts index 9899c14..643c4ef 100644 --- a/visionstate/frontend/src/lib/objects.ts +++ b/visionstate/frontend/src/lib/objects.ts @@ -113,6 +113,16 @@ export function objectSummary(sensor: Sensor): string { return text.charAt(0).toUpperCase() + text.slice(1); } +/** What a running object sensor that recognises faces waits for, as a chip ("" when ready, or when the + * face model failed: its Faces card says why). It works without faces meanwhile. */ +export function facesBusy(sensor: Pick): string { + const f = app.status?.faces; + if (!f || f.error || !sensor.enabled || !isObjectSensor(sensor) || !sensor.objects?.faces?.enabled) return ''; + if (f.progress) return 'Downloading faces…'; + if (f.embedding) return `Learning faces… ${f.embedding.done}/${f.embedding.total}`; + return f.loaded ? '' : 'Loading faces…'; +} + /** Whether a box's label comes from its face: a person taught by face, or the unknown person. */ export const byFace = (label: string, own: OwnLabel[] = []) => label === app.config?.face_unknown_key || own.some((l) => l.key === label && l.face); diff --git a/visionstate/frontend/src/lib/types.ts b/visionstate/frontend/src/lib/types.ts index b49a406..d295908 100644 --- a/visionstate/frontend/src/lib/types.ts +++ b/visionstate/frontend/src/lib/types.ts @@ -270,12 +270,14 @@ export interface ImportResult { faces_off: boolean; } -/** The face model: chosen, loaded, downloading (bytes) or why it failed. */ +/** The face model: chosen, loaded, downloading (bytes), embedding the taught faces (count) or why it failed. */ export interface FaceStatus { model: string; loaded: boolean; loading: boolean; progress: { done: number; total: number } | null; + /** The taught faces being embedded with a newly loaded model: how many of how many. */ + embedding: { done: number; total: number } | null; error: string; downloaded: boolean; } diff --git a/visionstate/frontend/src/pages/Dashboard.svelte b/visionstate/frontend/src/pages/Dashboard.svelte index e29c2b6..5224504 100644 --- a/visionstate/frontend/src/pages/Dashboard.svelte +++ b/visionstate/frontend/src/pages/Dashboard.svelte @@ -9,7 +9,7 @@ import StatePill from '../lib/components/StatePill.svelte'; import Timeline from '../lib/components/Timeline.svelte'; import { plural, pct } from '../lib/format'; - import { isObjectSensor, objectName } from '../lib/objects'; + import { facesBusy, isObjectSensor, objectName } from '../lib/objects'; import { isReadingSensor, readingText, readingUnit } from '../lib/reading'; import { READING_MODE_INFO } from '../lib/ui'; import { go, href, paths } from '../lib/router.svelte'; @@ -118,7 +118,9 @@

{s.name}

- {#if s.training}Training…{:else}{status.label}{/if} + {#if s.training}Training…{:else if facesBusy(s)}{facesBusy(s)}{:else}{status.label}{/if}
{s.entity_id} diff --git a/visionstate/frontend/src/pages/People.svelte b/visionstate/frontend/src/pages/People.svelte index 2668a87..538aadf 100644 --- a/visionstate/frontend/src/pages/People.svelte +++ b/visionstate/frontend/src/pages/People.svelte @@ -65,6 +65,12 @@ No sensor recognises faces yet: turn it on under an object sensor's Settings → Faces (one that looks for Person).
{/if} + {#if faces?.embedding} +
+ Learning the taught faces with the face model… {faces.embedding.done} of {faces.embedding.total}. Until then the + cameras find people without naming them. +
+ {/if} {#if room && faces?.sensors}

Add a person

diff --git a/visionstate/frontend/src/pages/SensorPage.svelte b/visionstate/frontend/src/pages/SensorPage.svelte index 27861f7..77d068b 100644 --- a/visionstate/frontend/src/pages/SensorPage.svelte +++ b/visionstate/frontend/src/pages/SensorPage.svelte @@ -7,7 +7,7 @@ import HistoryView from '../lib/components/history/HistoryView.svelte'; import ObjectChips from '../lib/components/ObjectChips.svelte'; import StatePill from '../lib/components/StatePill.svelte'; - import { isObjectSensor } from '../lib/objects'; + import { facesBusy, isObjectSensor } from '../lib/objects'; import { isReadingSensor, readingText } from '../lib/reading'; import { href, paths } from '../lib/router.svelte'; import type { Sensor } from '../lib/types'; @@ -87,7 +87,9 @@ {:else} {/if} - {#if sensor.training}Training…{:else if sensor.status !== 'ok'}Training…{:else if facesBusy(sensor)}{facesBusy(sensor)}{:else if sensor.status !== 'ok'}{SENSOR_STATUS[sensor.status].label}{/if} {isObjectSensor(sensor) ? `${sensor.entity_ids.length} entities` : sensor.entity_id} diff --git a/visionstate/frontend/src/pages/Settings.svelte b/visionstate/frontend/src/pages/Settings.svelte index e6580ef..7ca8422 100644 --- a/visionstate/frontend/src/pages/Settings.svelte +++ b/visionstate/frontend/src/pages/Settings.svelte @@ -159,6 +159,7 @@ function faceStatus(f: FaceStatus): string { if (f.error) return f.error; if (f.progress) return `downloading… ${size(f.progress.done)} of ${size(f.progress.total)}`; + if (f.embedding) return `learning the taught faces… ${f.embedding.done} of ${f.embedding.total}`; if (f.loaded) return info?.face_models.find((m) => m.id === f.model)?.name ?? f.model; if (f.loading) return 'loading…'; return 'loaded when a sensor recognises faces'; From fee1319b1a6666c5d0cb06e190952facf0f1708c Mon Sep 17 00:00:00 2001 From: oleost <5670710+oleost@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:49:37 +0200 Subject: [PATCH 03/19] Counters: a change limit that grows with the time away, learned from the counter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fixed largest change between two readings was both too loose and too tight: issue #40's water meter (0.04 m³ per one-minute reading, 2.4 m³/h) rejected right readings for good after a 35-minute update. A counter's limit is now a rate times the time it was not seen (the app down, the camera away, readings unsure); time in which it was read clearly but too high does not count, so a wheel misread over and over stays rejected. The limit starts again only when a reading confirms the published value. - "Learn it" (new counters): the busiest rise over 10 minutes and per day, second busiest of the last two weeks, times two, after a week of readings; learned from accepted readings (each with the lower of it and the next one), only over stretches where the counter was read all along. - Readings over the limit that agree for 5 minutes are accepted, unless the jump is one wheel's step: those wait in Review. - Read correctly (or a typed right value) on the newest rejected reading makes it the value; it is kept over a restart (reading_watch). - Existing counters keep their limit as a rate (max_step per check interval), or none. Replayed on issue #40's data (25 days, downtime, unsure stretches, stuck wheels): right readings rejected for a too large change 30,764 -> 5, hours behind the meter 528 -> 31, no wrong value published once learned. A live run with restarts passed all steps. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 5 +- docs/SCOPE.md | 48 ++++- visionstate/DOCS.md | 28 ++- .../backend/tests/test_change_limit.py | 123 ++++++++++++ visionstate/backend/tests/test_reading.py | 118 +++++++++++- visionstate/backend/visionstate/api/common.py | 31 ++- .../backend/visionstate/api/imports.py | 4 +- visionstate/backend/visionstate/api/review.py | 38 +++- visionstate/backend/visionstate/bundle.py | 13 +- .../backend/visionstate/change_limit.py | 181 ++++++++++++++++++ visionstate/backend/visionstate/db.py | 51 ++++- .../backend/visionstate/engine/logic.py | 12 +- .../backend/visionstate/engine/reading.py | 166 +++++++++++++--- .../backend/visionstate/engine/state.py | 2 + visionstate/backend/visionstate/readers.py | 16 +- visionstate/backend/visionstate/settings.py | 35 +++- visionstate/frontend/e2e/ui.spec.ts | 29 ++- .../src/lib/components/ReadingParams.svelte | 67 ++++++- visionstate/frontend/src/lib/types.ts | 27 ++- .../src/pages/sensor/ReadingLiveTab.svelte | 3 + .../src/pages/sensor/SettingsTab.svelte | 5 +- 21 files changed, 931 insertions(+), 71 deletions(-) create mode 100644 visionstate/backend/tests/test_change_limit.py create mode 100644 visionstate/backend/visionstate/change_limit.py diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7cb8281..16c2912 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -58,6 +58,7 @@ API as `request.app.state.runtime`. | `ha_events.py` | Home Assistant WebSocket: state changes of trigger entities, entity registry | | `lights.py` | A sensor's light: switched on for checks and while someone looks, shared by both | | `backbones.py`, `detectors.py`, `readers.py` | The three AI models and their registries (`*.json`): embedding backbone (state sensors), object detector, number reader (and the wheel reader for mechanical counters, `readers.WheelReader`; trained with `tools/wheelreader`) | +| `change_limit.py` | Counters: how much the value may go up since the last accepted reading — what it learns, time not seen, readings that agree (pure logic; the engine keeps a `Watch` per sensor) | | `faces.py` | Faces in person boxes (registry `faces.json`, never bundled): finding them (YuNet), the quality gate, aligning, evening out the light (`even_light`), embedding, telling who it is | | `classifier.py` | The small per-sensor classifier ("head") on top of the backbone | | `teach.py` | Object sensors: comparing detected boxes with the boxes the user taught | @@ -107,7 +108,7 @@ sensor loop (checks._sensor_loop) woken by: its interval, a trigger ent └─ by kind: state sensor → checks._run_states backbone embedding → head → probabilities object sensor → objects._run_objects detector → taught boxes (teaching) → faces → tracks (logic) - reading sensor → reading._run_reading reader → parse → plausibility → debounce + reading sensor → reading._run_reading reader → parse → plausibility (change_limit) → debounce │ ├─ publish (publishing._send → mqtt.MqttBridge.publish) state, attributes, image └─ history: a Prediction row + frame, when the value changed or the frame goes to review @@ -120,7 +121,7 @@ error is kept in `LiveState.error` and shown in the UI. - **SQLite** (`/visionstate.db`): `sensor` (+ `state`), `sample` (+ `sample_label`, `embedding` cache), `prediction` (the history and the review queue), `reading_stat`, - `model_info` (per sensor), `setting` (global settings as JSON), `person` (+ `face`, + `reading_watch` (a counter's limit between readings), `model_info` (per sensor), `setting` (global settings as JSON), `person` (+ `face`, `face_embedding` cache: the people taught by face, shared by every sensor). - **Faces** (`/faces/`): crops of the people's taught faces — in the app's own storage, not the media folder (see `storage.Storage.face_path`). diff --git a/docs/SCOPE.md b/docs/SCOPE.md index 0ac105a..af0274e 100644 --- a/docs/SCOPE.md +++ b/docs/SCOPE.md @@ -271,14 +271,56 @@ it read 297 of 329 right, tiny 304 and small 316; it took 9 for 3 or 8 on seven- (the class indices are stored in the registry, so the dictionary file is not needed). 3. Parse: only digits count, the configured `decimals` place the decimal point; `time_left` reads `h:mm` → minutes. Reject when empty, below the threshold (default 70 %), a counter - going down, a mechanical counter read with another number of digits than it has wheels, or a - change above `max_step`; otherwise publish after `debounce` equal reads. A counter read exactly + going down, a mechanical counter read with another number of digits than it has wheels, a + counter going up faster than its limit (below), or another value changing more than + `max_step`; otherwise publish after `debounce` equal reads. A counter read exactly one step of its last digit below the value (`readers.settling`) is the last wheel turning: the value stays, but it is no rejection (no history row, no review item, counted as accepted) — seen in a user's export, where one too-high value made every right reading after it a "went down" rejection. A right value typed in the review (`readers.right_value`) is taken as written with a point or comma, and digits only are placed with the configured decimals. - The last published value is restored from the history after a restart. + The last published value is restored from the history after a restart (or from + `reading_watch`, when that is newer: a counter's, or a value confirmed in Review). + +**Counter limit** (`change_limit.py`, `settings.COUNTER_LIMIT`): how much a counter may go up +since a reading last confirmed its published value is a rate times the time it was *not seen*, +plus two steps of the last digit. (Confirmed, not merely accepted: a reading still waiting for +its debounce leaves the published value older than it, and during a long shower that is minutes.) A fixed `max_step` between two readings (what counters had until then) was both +too loose and too tight: issue #40's water meter (0.04 m³ per one-minute reading = 2.4 m³/h, four +times its busiest shower) rejected right readings after a 35-minute update. +- *Not seen* is time the app was down (a pause longer than `gap_checks` intervals), and time the + readings were unsure. Once a reading was read with confidence over the limit (`held`), normal + pauses stop counting: otherwise a wheel misread over and over would get through after a while, + as the allowed rise grows. This is the reason the limit is not simply "rate × time since the + last accepted value". +- *Learn it* (`auto`, the default for new counters): per local day `reading_stat` keeps the + largest rise over `window_s` (600 s) and the day's rise, from the accepted readings (each with + the lower of its value and the next one's, so one read too high adds nothing; published values + would put a whole shower into one step) — only over stretches where the counter was read all + the time, so a rise over a pause is never learned. The limit takes the + second-largest day of the last `learn_days`, so one misread that slipped through does not count, + times `safety`; for a long pause it is capped by that many busy days. It needs `min_days` days + (a whole week: wash day and weekend). On issue #40's data, ×2 left room above every right + reading (the largest was 1.1 × the learned rate). Replayed three times over with readings every + minute, 14 times away (35 min to two days), 15 stretches of unsure readings and six wheels read + a digit too high for 20–120 min: the fixed 0.04 per reading kept the value behind the meter for + 528 hours; learned, 32 hours (the debounce during long showers, as with a fixed rate), it + learned 0.82 m³/h for a meter that ran at 0.75 at most, and after its first week no wrong + value was published. A wheel read too high right after a long time away does get through — + nothing tells it from water used meanwhile — and then the "went down" lock below applies. +- Readings over the limit that agree (`agree_readings` in a row over `agree_s`, never lower, each + step within the limit) are accepted, and learned from: a real jump while the readings were + unsure, or a counter that goes faster than it ever did. Not when the jump is one wheel's step + (or nine of them) plus a plausible rise — what a misread wheel gives (on a digital display any + digit, 1–9 steps): those wait for an answer in Review. +- Confirmed in Review (*read correctly* for an unsure or implausible reading, or a typed right + value) on a rejected reading newer than the last accepted one: that value becomes the last one + (`engine.reading.anchor_reading`). It is also the way out for a counter stuck above its real + value, the "went down" lock — never automatic, as Home Assistant takes a counter going down + for a reset. +- The limit's state (`reading_watch`) is stored with every reading, so a restart knows how long + the app was away. Counters made before the limit (schema v14, and importing an older export: + `db.keep_change_step`) keep theirs as `fixed`: the old `max_step` per check interval, as a rate. **Wheel reader** (default for mechanical counters, `counter_reader: "wheels"`, registry `wheel_readers` in `readers.json`, model `wheels-v1`, bundled, 2.2 MB, ~2 ms for 8 wheels on one diff --git a/visionstate/DOCS.md b/visionstate/DOCS.md index 1121aab..61a32e7 100644 --- a/visionstate/DOCS.md +++ b/visionstate/DOCS.md @@ -309,8 +309,8 @@ First choose **what it looks like**: - **Mechanical counters**: the coloured wheels are usually the decimals — a water meter with five black and three red wheels has 8 digits and 3 digits after the decimal point. The last wheel is rounded to the nearest digit. A counter that reads lower than before is rejected, and - a limit on how much the value may change (Settings → Sensor output) catches a misread that is - too high. A reading exactly one step of the last digit below the value is the + a limit on how fast it may go up (see *Counter limit* below) catches a misread that is too + high. A reading exactly one step of the last digit below the value is the last wheel turning: the value stays, but it is not counted as rejected and does not go to the review queue (the Live tab says *Last wheel turning*). If the last wheel never stands still, you can also leave it out of the region and count one digit and one decimal less. @@ -319,10 +319,26 @@ First choose **what it looks like**: choose *Wheel reader* on their Settings tab (their Live tab suggests it). - **Safety net**: a reading is rejected — and the last value kept — when the reader is less sure than the minimum (default 70 %), finds no number, a counter reads lower than before, a - mechanical counter is read with the wrong number of digits, or the value changes more than the - limit you set. Every rejected reading is listed in the **History** tab with the reason and - waits in the **Review** queue. A new value is published after 2 equal readings in a row - (adjustable). + mechanical counter is read with the wrong number of digits, a counter goes up faster than its + limit, or another value changes more than the limit you set. Every rejected reading is listed + in the **History** tab with the reason and waits in the **Review** queue. A new value is + published after 2 equal readings in a row (adjustable). +- **Counter limit** (Settings → Sensor output): how fast a counter may go up. *Learn it* (new + counters) learns it from the counter's own readings: the fastest it went up over ten minutes + and in a whole day, on a busy day of the last two weeks, with room to spare. It needs a week of + readings first and sets no limit until then; the Settings tab shows what it learned. *Fixed* + takes a rise per hour you choose; *No limit* rejects only a counter that reads lower. + The longer the counter was not seen — the app restarted or was updated, the camera was away, + the readings were unsure — the more it may have gone up, so a reading after a restart is + accepted. Time in which the counter was read clearly but too high does not count: a wheel + that is read one digit too high over and over stays rejected. + A real jump while the app was watching (say the light failed during a shower) is accepted + once several readings in a row agree — the Live tab says *Jump accepted* — unless the jump is + exactly what one digit read wrong gives (0.1, 1, 10 … more): that waits in the Review queue. + Answering *Read correctly*, or *Misread* with the right value, on the newest rejected reading + makes that the counter's value, so it goes on from there. Counters set up before the limit + was learned keep the limit they had, turned into a rate (the old change per reading, per check + interval), or no limit. - **Quality** tab: the share of readings that were accepted today, in the last 7 and 30 days, why the others were rejected, and a chart per day. Below it the rejected readings with their frame: tell whether the reader read the meter right (*Read correctly*) or not (*Misread*, optionally with the value it showed — the diff --git a/visionstate/backend/tests/test_change_limit.py b/visionstate/backend/tests/test_change_limit.py new file mode 100644 index 0000000..d3b1e64 --- /dev/null +++ b/visionstate/backend/tests/test_change_limit.py @@ -0,0 +1,123 @@ +"""How much a counter may go up: the limit over time, what it learns, agreeing readings.""" + +import pytest + +from visionstate import change_limit +from visionstate.change_limit import Learned, Watch +from visionstate.settings import COUNTER_LIMIT, merge_reading + +WATER = merge_reading({"mode": "counter", "display": "counter", "digits": 7, "decimals": 3}) +FIXED = {**WATER, "change_limit": "fixed", "max_rate": 2.4} # 0.04 m³ a minute +MARGIN = COUNTER_LIMIT["margin_steps"] * 0.001 +BUSY = Learned(per_hour=0.55, per_day=0.8, days=9) + + +def test_limit_grows_with_time(): + assert change_limit.limit(FIXED, None, 60) == pytest.approx(MARGIN + 0.04) + assert change_limit.limit(FIXED, None, 35 * 60) == pytest.approx(MARGIN + 1.4) + assert change_limit.limit({**FIXED, "max_rate": 0}, None, 60) is None + assert change_limit.limit({**WATER, "change_limit": "off"}, BUSY, 60) is None + assert change_limit.limit(WATER, None, 60) is None # "auto", nothing learned yet: no limit + safety = COUNTER_LIMIT["safety"] + assert change_limit.limit(WATER, BUSY, 60) == pytest.approx(MARGIN + safety * 0.55 / 60) + # Away for three days: no more than three busy days, not three days of showering. + assert change_limit.limit(WATER, BUSY, 3 * 86_400) == pytest.approx(MARGIN + safety * 3 * 0.8) + + +def test_learn_needs_days_and_ignores_the_busiest_one(): + week = [(0.05, 0.5)] * (COUNTER_LIMIT["min_days"] - 1) + assert change_limit.learn(week) is None + learned = change_limit.learn([*week, (0.09, 0.7), (4.0, 9.0)]) # one day with a misread + assert learned is not None and learned.days == COUNTER_LIMIT["min_days"] + 1 + assert learned.per_hour == pytest.approx(0.09 * 3600 / COUNTER_LIMIT["window_s"]) + assert learned.per_day == pytest.approx(0.7) + assert change_limit.learn([(0.0, 0.0)] * 10) is None # nothing used: nothing to learn + + +def test_only_unseen_time_counts(): + watch = Watch(seen_at=0.0, accepted_at=0.0) + change_limit.advance(watch, 60, 60) + assert watch.budget_s == 60 + # Read with confidence over the limit: the next minutes do not add to the limit. + watch.held = True + for minute in range(2, 30): + change_limit.advance(watch, minute * 60, 60) + assert watch.budget_s == 60 + # The app was down for an hour: that does count (beyond a normal pause). + change_limit.advance(watch, 29 * 60 + 3600, 60) + assert watch.budget_s == pytest.approx(60 + 3600 - change_limit.gap(60)) + + +def test_learns_the_rise_over_the_window(): + watch = Watch() + assert change_limit.observe(watch, 0, 100.0, 60) is None # the first one: only the start + for minute in range(1, 21): + rise = change_limit.observe(watch, minute * 60, 100.0 + 0.01 * minute, 60) + assert rise is not None and rise[1] == pytest.approx(0.01 if minute > 1 else 0.0) + assert rise is not None and rise[0] == pytest.approx(0.01 * COUNTER_LIMIT["window_s"] / 60) + # One reading too high (a misread that got through) counts with the next one's value. + assert change_limit.observe(watch, 21 * 60, 101.2, 60) == (pytest.approx(0.1), pytest.approx(0.01)) + assert change_limit.observe(watch, 22 * 60, 100.22, 60) == (pytest.approx(0.11), pytest.approx(0.02)) + # A pause (down, unsure, rejected): the rise over it is not learned. + assert change_limit.observe(watch, 3600, 101.0, 60) is None + assert change_limit.observe(watch, 3660, 101.0, 60) == (0.0, 0.0) + # Accepted because readings over the limit agreed: it was read all along, so it counts. + assert change_limit.observe(watch, 3660 + 600, 101.5, 60, agreed=True) is not None + + +def test_accept_starts_the_limit_again(): + watch = Watch(accepted_at=0.0, budget_s=900.0, held=True, pending=[(60.0, 101.0)]) + change_limit.accept(watch, 1000) + assert (watch.accepted_at, watch.budget_s, watch.held, watch.pending) == (1000, 0.0, False, []) + + +@pytest.mark.parametrize( + ("jump", "misread"), + [ + (0.1, True), # the 0.1 wheel one step on + (0.104, True), # ... plus a little water + (1.0, True), + (9.002, True), # the 1 wheel read 9 instead of 0 + (0.27, False), # a shower while the readings were unsure + (0.62, False), + (0.5, False), + ], +) +def test_looks_misread(jump, misread): + assert change_limit.looks_misread(jump, 0.042, WATER) is misread + + +def test_digits_on_a_display_may_be_read_as_any_other(): + display = {**WATER, "display": "auto"} + assert change_limit.looks_misread(0.5, 0.042, display) + assert not change_limit.looks_misread(0.55, 0.042, display) + + +def run(watch: Watch, settings: dict, last: float, readings: list[tuple[float, float]]) -> list[bool]: + """Readings over the limit, one after another: whether each is accepted after all.""" + return [change_limit.hold(watch, at, value, "changed too much", last, settings) for at, value in readings] + + +def test_agreeing_readings_are_accepted(): + watch = Watch(seen_at=0.0, accepted_at=0.0, learned=BUSY) + # A shower while the light was broken: 0.27 more, then going on up a little. + readings = [(1800 + i * 60, 100.27 + i * 0.005) for i in range(6)] + accepted = run(watch, WATER, 100.0, readings) + assert accepted == [False] * 5 + [True] # agree_readings in a row, over agree_s + assert watch.held + + +@pytest.mark.parametrize("wheel", [0.1, 1.0]) +def test_a_misread_wheel_is_never_accepted_by_itself(wheel): + watch = Watch(seen_at=0.0, accepted_at=0.0, learned=BUSY) + readings = [(60 + i * 60, 100.0 + wheel + i * 0.001) for i in range(300)] # a wheel a step on, for hours + assert not any(run(watch, WATER, 100.0, readings)) + assert watch.pending[0] == readings[0] # measured from where the series started + + +def test_readings_that_do_not_agree_start_again(): + watch = Watch(seen_at=0.0, accepted_at=0.0, learned=BUSY) + assert not any(run(watch, WATER, 100.0, [(60, 100.3), (120, 100.31), (180, 100.2), (240, 100.21)])) + assert watch.pending == [(180, 100.2), (240, 100.21)] + assert not change_limit.hold(watch, 300, 99.0, "went down", 100.0, WATER) + assert watch.pending == [] diff --git a/visionstate/backend/tests/test_reading.py b/visionstate/backend/tests/test_reading.py index 21dd126..447a569 100644 --- a/visionstate/backend/tests/test_reading.py +++ b/visionstate/backend/tests/test_reading.py @@ -13,11 +13,11 @@ from sqlalchemy import select from visionstate import readers -from visionstate.db import Database, Prediction, Sensor, keep_text_reader +from visionstate.db import Database, Prediction, Sensor, keep_change_step, keep_text_reader from visionstate.main import create_app from visionstate.mqtt import SensorDescriptor, discovery_messages from visionstate.readers import counter_line, decode, format_value, implausible, parse, wrong_digit_count -from visionstate.settings import KIND_READING, READING, VERSION, merge_reading +from visionstate.settings import COUNTER_LIMIT, KIND_READING, READING, VERSION, merge_reading from .conftest import ASSETS, MODEL_DIR, requires_reader from .displays import counter_box, counter_positions, render, render_counter @@ -51,13 +51,15 @@ def test_parse(text, settings, expected): def test_implausible_readings(): - counter = cfg(mode="counter", max_step=10) - assert implausible(100, None, counter) is None # nothing to compare with yet - assert implausible(99, 100, counter) == "went down" - assert implausible(111, 100, counter) == "changed too much" - assert implausible(105, 100, counter) is None + counter = cfg(mode="counter", max_step=10) # a counter's limit is change_limit's, not max_step + assert implausible(100, None, counter, 10) is None # nothing to compare with yet + assert implausible(99, 100, counter, 10) == "went down" + assert implausible(111, 100, counter, 10) == "changed too much" + assert implausible(105, 100, counter, 10) is None + assert implausible(1000, 100, counter) is None # no limit value = cfg(mode="value") assert implausible(1.2, 1.9, value) is None # values may go down, no step limit set + assert implausible(1.2, 1.9, cfg(mode="value", max_step=0.5)) == "changed too much" def test_counter_digit_count(): @@ -348,6 +350,35 @@ def test_counters_made_before_the_wheel_reader_keep_the_text_reader(tmp_path): assert keep_text_reader(None) is None +def test_counters_keep_their_change_limit_as_a_rate(tmp_path): + path = tmp_path / "old.db" + Database(path).init() + con = sqlite3.connect(path) + row = ( + "INSERT INTO sensor (slug, name, kind, source_type, source, interval_s, threshold, debounce, enabled, " + "entity_prefix, publish, created_at, reading) VALUES (?, ?, 'reading', 'http', 'x', ?, 0.7, 2, 1, 0, 1, " + "'2026-10-01', ?)" + ) + con.execute(row, ("water", "Water", 60, json.dumps({"mode": "counter", "decimals": 3, "max_step": 0.04}))) + con.execute(row, ("gas", "Gas", 30, json.dumps({"mode": "counter", "max_step": 0}))) + con.execute(row, ("price", "Price", 60, json.dumps({"mode": "value", "max_step": 5}))) + con.execute("PRAGMA user_version = 13") # as a database of 0.9.0 + con.commit() + con.close() + db = Database(path) + db.init() + with db.session() as s: + kept = {r.slug: merge_reading(r.reading) for r in s.query(Sensor)} + # 0.04 m³ a minute is 2.4 m³ an hour; no limit stays no limit; values keep max_step. + assert (kept["water"]["change_limit"], kept["water"]["max_rate"]) == ("fixed", 2.4) + assert kept["gas"]["change_limit"] == "off" + assert kept["price"]["max_step"] == 5 + assert keep_change_step({"mode": "counter", "change_limit": "auto"}, 60) == { + "mode": "counter", + "change_limit": "auto", + } + + def test_darkest_turns_coloured_digits_as_dark_as_black_ones(): image = Image.new("RGB", (3, 1)) image.putpixel((0, 0), (200, 30, 35)) # red digit @@ -599,6 +630,79 @@ def test_restart_keeps_checking_against_the_last_value(settings): assert client.get(f"/api/v1/sensors/{sid}").json()["reading"]["value"] == "500" +@requires_reader +def test_counter_limit_over_time(settings, monkeypatch): + """A counter's limit: a misread that repeats stays out, time the app was down lets the value + on, readings that agree are accepted, and a value confirmed in Review is the new last value.""" + monkeypatch.setitem(COUNTER_LIMIT, "agree_s", 2.0) + camera = DisplayCamera("00500") + reading = {"mode": "counter", "change_limit": "fixed", "max_rate": 360} # 0.1 a second + body = {"name": "Meter", "kind": "reading", "source_type": "http", "source": "x", "interval_s": 1, "debounce": 1} + body["reading"] = reading + sent: dict[str, object] = {} + + async def publish(topic, payload, retain=False): + sent[topic] = payload + + with TestClient(create_app(settings)) as client: + rt = client.app.state.runtime + rt.grabber = camera + rt.mqtt.publish = publish + sid = client.post("/api/v1/sensors", json=body).json()["id"] + + def view(): + return client.get(f"/api/v1/sensors/{sid}").json()["reading"] + + def last(): + return view()["last"] or {} + + assert wait_for(lambda: view()["value"] == "500", timeout=60) + assert view()["limit"]["allowed"] is not None and view()["limit"]["needed"] == COUNTER_LIMIT["min_days"] + + # The 100 wheel read a step on, again and again: rejected, and the limit does not grow. + camera.text = "00600" + assert wait_for(lambda: last().get("value") == "600" and last().get("reason") == "changed too much", 30) + held = rt.live[sid].watch.budget_s + assert wait_for(lambda: len(rt.live[sid].watch.pending) >= 4, timeout=30) + assert view()["value"] == "500" and view()["limit"]["held"] + assert rt.live[sid].watch.budget_s == held + + # The app was down for an hour: the counter went on, and that rise is accepted. + camera.text = "00650" + assert wait_for(lambda: last().get("value") == "650", timeout=30) + rt.live[sid].watch.seen_at -= 3600 + assert wait_for(lambda: view()["value"] == "650", timeout=30) + + # A real jump while it was watching (no whole step of a digit): accepted once readings agree. + camera.text = "00800" + assert wait_for(lambda: view()["value"] == "800", timeout=30) + assert sent["visionstate/meter/state"] == "800" + + def agreed(): + items = client.get("/api/v1/history", params={"sensor": sid, "limit": 50}).json()["items"] + return [h for h in items if h["published_key"] == "800" and h["probs"].get("agreed")] + + assert wait_for(lambda: len(agreed()) == 1, timeout=10) # the row is written after publishing + + # Read lower (the 800 was wrong, say): confirmed in Review, it becomes the last value. + camera.text = "00700" + assert wait_for(lambda: last().get("reason") == "went down", timeout=30) + assert wait_for(lambda: client.get("/api/v1/review").json()["total"] >= 1, timeout=30) + newest = client.get("/api/v1/review").json()["items"][0] + assert newest["probs"]["reason"] == "went down" + assert client.post(f"/api/v1/review/{newest['id']}", json={"action": "read_ok"}).status_code == 200 + assert wait_for(lambda: view()["value"] == "700", timeout=30) + assert sent["visionstate/meter/state"] == "700" + # After a restart it goes on from the confirmed value (which is not in the history as a value). + with TestClient(create_app(settings)) as client: + client.app.state.runtime.grabber = camera + assert wait_for( + lambda: (client.get(f"/api/v1/sensors/{sid}").json()["reading"]["last"] or {}).get("reason") is None, + timeout=60, + ) + assert client.get(f"/api/v1/sensors/{sid}").json()["reading"]["value"] == "700" + + @requires_reader def test_counter_sensor_flow(settings): """A counter read as text (OCR): a wrong number of digits is rejected, the export, the preview.""" diff --git a/visionstate/backend/visionstate/api/common.py b/visionstate/backend/visionstate/api/common.py index 98efc59..5383389 100644 --- a/visionstate/backend/visionstate/api/common.py +++ b/visionstate/backend/visionstate/api/common.py @@ -11,11 +11,12 @@ from sqlalchemy import func, select from sqlalchemy.orm import Session -from .. import detectors, imaging, teach +from .. import change_limit, detectors, imaging, teach from ..db import Face, ModelInfo, Sample, SampleLabel, Sensor from ..engine import LiveState, ObjectTrack, Runtime, SensorConfig, with_people from ..mqtt import main_entities from ..settings import ( + COUNTER_LIMIT, COUNTER_READERS, FACE_DEFAULTS, FACE_LIMITS, @@ -28,6 +29,7 @@ OBJECT_DEFAULTS, OBJECT_LIMITS, OBJECT_MAX_CLASSES, + READING_CHANGE_LIMITS, READING_DEFAULTS, READING_DEVICE_CLASSES, READING_DISPLAYS, @@ -297,6 +299,8 @@ class ReadingIn(BaseModel): digits: int = Field(default=READING_DEFAULTS["digits"], ge=_dlo["digits"], le=_dhi["digits"]) counter_reader: str = READING_DEFAULTS["counter_reader"] max_step: float = Field(default=READING_DEFAULTS["max_step"], ge=_dlo["max_step"], le=_dhi["max_step"]) + change_limit: str = READING_DEFAULTS["change_limit"] + max_rate: float = Field(default=READING_DEFAULTS["max_rate"], ge=_dlo["max_rate"], le=_dhi["max_rate"]) spot_rate: float = Field(default=READING_DEFAULTS["spot_rate"], ge=_dlo["spot_rate"], le=_dhi["spot_rate"]) rate_window_min: float = Field( default=READING_DEFAULTS["rate_window_min"], ge=_dlo["rate_window_min"], le=_dhi["rate_window_min"] @@ -316,6 +320,13 @@ def _display(cls, value: str) -> str: raise ValueError(f"Unknown display {value!r}") return value + @field_validator("change_limit") + @classmethod + def _change_limit(cls, value: str) -> str: + if value not in READING_CHANGE_LIMITS: + raise ValueError(f"Unknown change limit {value!r}") + return value + @field_validator("counter_reader") @classmethod def _counter_reader(cls, value: str) -> str: @@ -468,11 +479,27 @@ def reading_view(sensor: Sensor, live: LiveState | None) -> dict | None: """A reading sensor's part of its view: its settings, the published value and the last read.""" if sensor.kind != KIND_READING: return None + settings = merge_reading(sensor.reading) return { - **merge_reading(sensor.reading), + **settings, "value": live.debouncer.published if live else None, "last": live.reading if live else None, "has_image": bool(live and live.reading_image), + "limit": limit_view(settings, live.watch) if live and settings["mode"] == "counter" else None, + } + + +def limit_view(settings: dict, watch: change_limit.Watch) -> dict: + """What a counter's limit is now: learned (per hour and day, from how many days; ``needed`` + days before it is) and how much the next reading may go up (None: no limit).""" + learned = watch.learned + return { + "days": watch.days, + "needed": COUNTER_LIMIT["min_days"], + "per_hour": learned.per_hour if learned else None, + "per_day": learned.per_day if learned else None, + "allowed": change_limit.limit(settings, learned, watch.budget_s), + "held": watch.held, } diff --git a/visionstate/backend/visionstate/api/imports.py b/visionstate/backend/visionstate/api/imports.py index b81e20c..02dcac6 100644 --- a/visionstate/backend/visionstate/api/imports.py +++ b/visionstate/backend/visionstate/api/imports.py @@ -12,7 +12,7 @@ from sqlalchemy import select from .. import bundle, detectors, faces, imaging -from ..db import Person, keep_text_reader +from ..db import Person, keep_change_step, keep_text_reader from ..engine import Runtime from ..redact import redact from ..settings import ( @@ -70,6 +70,8 @@ def _import_sync(rt: Runtime, path: Path) -> tuple[int, int, bool]: manifest = bundle.read_manifest(path) data = dict(manifest["sensor"]) data["reading"] = keep_text_reader(data.get("reading")) # exported before the wheel reader + # exported before a counter's change limit was a rate + data["reading"] = keep_change_step(data["reading"], float(data.get("interval_s") or 1.0)) # Validate exactly like a sensor created in the UI (kind, limits, source type, states, objects, triggers). try: spec = SensorIn.model_validate( diff --git a/visionstate/backend/visionstate/api/review.py b/visionstate/backend/visionstate/api/review.py index a21feb5..d18cac1 100644 --- a/visionstate/backend/visionstate/api/review.py +++ b/visionstate/backend/visionstate/api/review.py @@ -4,14 +4,16 @@ import asyncio import logging +from datetime import UTC, datetime from typing import cast from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel, Field from sqlalchemy import CursorResult, delete, func, select, update +from sqlalchemy.orm import Session from .. import imaging, readers -from ..db import Face, Prediction, Sample, SampleLabel, Sensor +from ..db import Face, Prediction, ReadingWatch, Sample, SampleLabel, Sensor from ..engine import Runtime, with_people from ..settings import FACE_UNKNOWN_KEY, KIND_OBJECTS, KIND_READING, merge_objects, merge_reading from .common import ( @@ -101,6 +103,31 @@ async def review_dismiss_all(sensor_id: int, request: Request) -> dict: return {"dismissed": dismissed} +# Rejected readings whose value, confirmed in Review, becomes the last value (see _newest_rejected). +ANCHOR_REASONS = ("unsure", "went down", "changed too much") + + +def _newest_rejected(s: Session, row: Prediction) -> bool: + """Whether a reading is a rejected one read after the last accepted value (so the value it + confirms can take that value's place: engine.reading.anchor_reading).""" + if row.published_key is not None: + return False + watch = s.get(ReadingWatch, row.sensor_id) + read_at = (row.created_at if row.created_at.tzinfo else row.created_at.replace(tzinfo=UTC)).timestamp() + if watch is not None and (watch.accepted_at or 0.0) >= read_at: + return False + newer = s.scalar( + select(func.count()) + .select_from(Prediction) + .where( + Prediction.sensor_id == row.sensor_id, + Prediction.published_key.is_not(None), + Prediction.created_at > row.created_at, + ) + ) + return not newer + + @router.post("/review/{prediction_id}") async def review_answer(prediction_id: int, body: ReviewIn, request: Request) -> dict: rt = runtime(request) @@ -112,6 +139,7 @@ async def review_answer(prediction_id: int, body: ReviewIn, request: Request) -> kind = sensor.kind if kind == KIND_OBJECTS: return await _answer_object(rt, request, prediction_id, body) + anchor: tuple[int, str, datetime] | None = None with rt.db.session() as s: row = s.get(Prediction, prediction_id) if row is None: @@ -129,10 +157,18 @@ async def review_answer(prediction_id: int, body: ReviewIn, request: Request) -> if number is None: raise HTTPException(400, f"Not a number: {body.value!r}") row.correct_value = readers.format_value(number, settings) + # A reading with the wrong number of digits is no value, even when read as it is shown. + probs = row.probs or {} + kept = probs.get("reason") in ANCHOR_REASONS + right = row.correct_value if body.action == "misread" else probs.get("value") if kept else None + if right is not None and _newest_rejected(s, row): + anchor = (row.sensor_id, right, row.created_at) row.reviewed = True elif body.action not in ("confirm", "label", "skip"): raise HTTPException(400, "Answer confirm, label or skip") if sensor.kind == KIND_READING: + if anchor is not None: + await rt.anchor_reading(*anchor) await rt.publish_review_count() return {"ok": True} with rt.db.session() as s: diff --git a/visionstate/backend/visionstate/bundle.py b/visionstate/backend/visionstate/bundle.py index bc12625..bdb7eb9 100644 --- a/visionstate/backend/visionstate/bundle.py +++ b/visionstate/backend/visionstate/bundle.py @@ -235,7 +235,18 @@ def export_readings(db: Database, storage: Storage, sensor_id: int, target: Path "meter": meter.strip()[: READING["export_meter_max_chars"]] or None, # How the sensor reads; nothing about where it is (no camera, name or region position). "settings": { - k: settings[k] for k in ("mode", "display", "digits", "counter_reader", "decimals", "unit", "max_step") + k: settings[k] + for k in ( + "mode", + "display", + "digits", + "counter_reader", + "decimals", + "unit", + "max_step", + "change_limit", + "max_rate", + ) }, "left_out": left_out, "readings": items, diff --git a/visionstate/backend/visionstate/change_limit.py b/visionstate/backend/visionstate/change_limit.py new file mode 100644 index 0000000..5b528f2 --- /dev/null +++ b/visionstate/backend/visionstate/change_limit.py @@ -0,0 +1,181 @@ +"""How much a counter may go up since its last accepted reading (settings.COUNTER_LIMIT). + +The limit grows with the time the counter was not seen: the app down, the camera away, or the +readings unsure. Time in which the counter was read with confidence but went over the limit does +not count, so a wheel misread over and over never waits its way through. Such readings are +accepted once enough of them agree — unless the jump is what one wheel or digit read one step +wrong gives; those wait for an answer in Review. + +"auto" learns the limit from the counter's own accepted readings: the largest rise over a few +minutes per day and the rise per day (db.ReadingStat ``peak`` and ``total``), only over stretches +in which it was read all the time. Pure logic, no I/O: +the engine (engine/reading.py) keeps a ``Watch`` per sensor and stores it (db.ReadingWatch). +""" + +from __future__ import annotations + +import math +from collections import deque +from dataclasses import dataclass, field + +from .settings import COUNTER_LIMIT + +# Readings that were read with confidence but do not fit the last value. +IMPLAUSIBLE = ("went down", "changed too much") + + +@dataclass +class Learned: + """What a counter does on a busy day (the second busiest of the last days).""" + + per_hour: float # the largest rise over COUNTER_LIMIT["window_s"], as a rate per hour + per_day: float + days: int # days with readings it was learned from + + +@dataclass +class Watch: + """A counter's limit between readings. The first four fields are stored (db.ReadingWatch).""" + + seen_at: float | None = None # the last reading, of any kind + accepted_at: float | None = None # the last accepted reading + budget_s: float = 0.0 # time since then that counts towards the limit + held: bool = False # read with confidence over the limit since then: its time no longer counts + pending: list[tuple[float, float]] = field(default_factory=list) # (time, value) over the limit, agreeing + # Learning: accepted readings over the last window (time, value), and the newest one, which + # counts once the next one says it was no misread (see observe). + window: deque[tuple[float, float]] = field(default_factory=deque) + sample: tuple[float, float] | None = None + learned: Learned | None = None + days: int = 0 # days with readings so far (learned from min_days on) + learned_at: float = 0.0 + learned_day: str = "" + + +def gap(interval_s: float) -> float: + """A pause between two readings longer than this is time the counter was not seen.""" + return max(COUNTER_LIMIT["gap_checks"] * interval_s, COUNTER_LIMIT["gap_min_s"]) + + +def margin(settings: dict) -> float: + return COUNTER_LIMIT["margin_steps"] * 10 ** -int(settings["decimals"]) + + +def limit(settings: dict, learned: Learned | None, seconds: float) -> float | None: + """The largest rise allowed after ``seconds`` of counting time, or None (no limit).""" + hours = max(0.0, seconds) / 3600 + mode = settings["change_limit"] + if mode == "fixed": + rate = float(settings["max_rate"]) + return margin(settings) + rate * hours if rate > 0 else None + if mode == "auto" and learned is not None: + days = max(1, math.ceil(seconds / 86_400)) + rise = min(learned.per_hour * hours, learned.per_day * days) + return margin(settings) + COUNTER_LIMIT["safety"] * rise + return None + + +def learn(days: list[tuple[float, float]]) -> Learned | None: + """The limit learned from (largest rise over the window, total rise) of each day with readings.""" + if len(days) < COUNTER_LIMIT["min_days"]: + return None + peaks = sorted(peak for peak, _ in days) + totals = sorted(total for _, total in days) + per_hour = peaks[-2] / (COUNTER_LIMIT["window_s"] / 3600) + if per_hour <= 0: + return None # nothing used yet: nothing to learn from + return Learned(per_hour=per_hour, per_day=max(totals[-2], peaks[-2]), days=len(days)) + + +def advance(watch: Watch, now: float, interval_s: float) -> None: + """Before a reading is checked: the time since the last one counts towards the limit. + + Once the counter was read with confidence over the limit, only the part of a pause longer + than a normal one counts (the app was down).""" + if watch.seen_at is not None: + passed = max(0.0, now - watch.seen_at) + watch.budget_s += max(0.0, passed - gap(interval_s)) if watch.held else passed + watch.seen_at = now + + +def accept(watch: Watch, now: float) -> None: + """A reading confirmed the published value (or made it): the limit starts again.""" + watch.accepted_at, watch.budget_s, watch.held = now, 0.0, False + watch.pending.clear() + + +def observe( + watch: Watch, now: float, value: float, interval_s: float, agreed: bool = False +) -> tuple[float, float] | None: + """An accepted reading, for learning. Returns what the one before it adds to the day — (rise + over the window up to it, rise since the one before) — or None. + + A reading counts with the lower of its value and the next one's, so one read too high adds + nothing. A pause longer than a normal one (down, unsure, rejected) starts again: a rise over + a pause says nothing about how fast the counter goes up. ``agreed``: accepted because + readings over the limit agreed — read all along, so it counts (and the limit can grow).""" + before = watch.sample + watch.sample = (now, value) + if before is None or (not agreed and now - before[0] > gap(interval_s)): + watch.window.clear() + return None + at, earlier = before[0], min(before[1], value) + if not watch.window: + watch.window.append((at, earlier)) + return 0.0, 0.0 + cut = at - COUNTER_LIMIT["window_s"] + base = watch.window[0][1] + for t, v in watch.window: + if t > cut: + break + base = v + rise = earlier - watch.window[-1][1] + watch.window.append((at, max(earlier, watch.window[-1][1]))) + while len(watch.window) >= 2 and watch.window[1][0] <= cut: + watch.window.popleft() + return max(0.0, earlier - base), max(0.0, rise) + + +def looks_misread(jump: float, tolerance: float, settings: dict) -> bool: + """Whether a rise is what one wheel (or digit) read wrong gives, plus a plausible rise. + + A wheel read one step off adds that wheel's step — or nine of them, 0 read as 9; a digit on a + display can be read as any other. Only steps above ``tolerance`` count: smaller ones pass the + limit anyway.""" + step = 10 ** -int(settings["decimals"]) + multiples = (1, 9) if settings["display"] == "counter" else range(1, 10) + wheel = step * 10 + while wheel <= jump + step: + if wheel > tolerance: + for m in multiples: + if -margin(settings) <= jump - m * wheel <= tolerance: + return True + wheel *= 10 + return False + + +def hold(watch: Watch, now: float, value: float, reason: str, last: float, settings: dict) -> bool: + """A reading read with confidence but rejected (``reason``, one of IMPLAUSIBLE). + + Returns whether it is to be accepted after all: enough readings over the limit in a row + agree with each other, and the jump does not look like a misread wheel.""" + watch.held = True + pending = watch.pending + if reason != "changed too much": + pending.clear() + return False + if pending: + at, before = pending[-1] + step = limit(settings, watch.learned, now - at) + if value < before or (step is not None and value - before > step): + pending.clear() + pending.append((now, value)) + # The series may go on long: keep its start (the jump is measured from there, or the time + # since would make room for a misread wheel) and the last readings (the next step from there). + del pending[1 : -COUNTER_LIMIT["agree_readings"]] + first_at, first = pending[0] + if len(pending) < COUNTER_LIMIT["agree_readings"] or now - first_at < COUNTER_LIMIT["agree_s"]: + return False + since = first_at - watch.accepted_at if watch.accepted_at is not None else 0.0 + tolerance = limit(settings, watch.learned, since) + return tolerance is not None and not looks_misread(first - last, tolerance, settings) diff --git a/visionstate/backend/visionstate/db.py b/visionstate/backend/visionstate/db.py index 71799d9..289a5ca 100644 --- a/visionstate/backend/visionstate/db.py +++ b/visionstate/backend/visionstate/db.py @@ -27,7 +27,7 @@ from sqlalchemy.engine import Connection from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, relationship, sessionmaker -SCHEMA_VERSION = 13 +SCHEMA_VERSION = 14 # Schema upgrades for existing databases, keyed on the version they upgrade to. # Each step is a list of (table, column, SQL type) columns to add. @@ -50,6 +50,8 @@ ], 11: [("prediction", "sample_id", "INTEGER")], # 13: the people and their faces (new tables: person, face, face_embedding). + # 14: what a counter's limit learns per day (and a new table: reading_watch). + 14: [("reading_stat", "peak", "FLOAT"), ("reading_stat", "total", "FLOAT")], } @@ -74,10 +76,38 @@ def _keep_text_readers(conn: Connection) -> None: ) +def keep_change_step(reading: dict | None, interval_s: float) -> dict | None: + """Reading settings of a counter made before change_limit, with the limit it had kept. + + Its ``max_step`` (the change allowed between two readings) becomes that much per interval, + as a rate per hour ("fixed"); a counter without one gets no limit ("off"), as before. A + counter that has ``change_limit`` and anything else are returned as they are. + """ + if not isinstance(reading, dict) or reading.get("mode", "counter") != "counter" or "change_limit" in reading: + return reading + step = float(reading.get("max_step") or 0) + if step <= 0: + return {**reading, "change_limit": "off"} + return {**reading, "change_limit": "fixed", "max_rate": round(step * 3600 / max(interval_s, 1.0), 6)} + + +def _keep_change_steps(conn: Connection) -> None: + rows = conn.execute(text("SELECT id, reading, interval_s FROM sensor WHERE reading IS NOT NULL")).all() + for sensor_id, raw, interval_s in rows: + reading = json.loads(raw) if isinstance(raw, str) else raw + kept = keep_change_step(reading, float(interval_s)) + if kept is not reading: + conn.execute( + text("UPDATE sensor SET reading = :r WHERE id = :id"), {"r": json.dumps(kept), "id": sensor_id} + ) + + # Changes to the data of existing databases, keyed on the version they upgrade to (after the columns). DATA_MIGRATIONS: dict[int, Callable[[Connection], None]] = { # Counters made before 0.7.1b3 keep the text reader; new ones get the wheel reader. 12: _keep_text_readers, + # Counters keep the change limit they had, now as a rate (see keep_change_step). + 14: _keep_change_steps, } @@ -227,6 +257,25 @@ class ReadingStat(Base): reads: Mapped[int] = mapped_column(Integer, default=0) accepted: Mapped[int] = mapped_column(Integer, default=0) rejected: Mapped[dict] = mapped_column(JSON, default=dict) # reason -> count + # Counters, for the limit they learn (change_limit.learn): the largest rise over + # COUNTER_LIMIT["window_s"] and the total rise that day, while the counter was seen. + peak: Mapped[float | None] = mapped_column(Float, nullable=True) + total: Mapped[float | None] = mapped_column(Float, nullable=True) + + +class ReadingWatch(Base): + """A counter's limit between readings (change_limit.Watch), so a restart knows how long it was + away, and the last accepted value of a reading sensor when it is newer than the history's + (a counter's, or a value confirmed in Review).""" + + __tablename__ = "reading_watch" + + sensor_id: Mapped[int] = mapped_column(ForeignKey("sensor.id", ondelete="CASCADE"), primary_key=True) + value: Mapped[str | None] = mapped_column(String(64), nullable=True) # accepted at accepted_at + seen_at: Mapped[float | None] = mapped_column(Float, nullable=True) # epoch seconds + accepted_at: Mapped[float | None] = mapped_column(Float, nullable=True) + budget_s: Mapped[float] = mapped_column(Float, default=0.0) + held: Mapped[bool] = mapped_column(Boolean, default=False) class ModelInfo(Base): diff --git a/visionstate/backend/visionstate/engine/logic.py b/visionstate/backend/visionstate/engine/logic.py index 943de7b..f276d12 100644 --- a/visionstate/backend/visionstate/engine/logic.py +++ b/visionstate/backend/visionstate/engine/logic.py @@ -215,12 +215,18 @@ def _end_face_burst(burst: FaceBurst, spent: bool) -> str | None: def reading_verdict( - text: readers.Text, value: float | None, last: float | None, threshold: float, settings: dict + text: readers.Text, + value: float | None, + last: float | None, + threshold: float, + settings: dict, + limit: float | None = None, ) -> tuple[str | None, bool]: """Whether a reading is rejected, and why (None: accepted), and whether it is "settling". Rejected: nothing read, another number of digits than the counter has wheels, too unsure, - or implausible next to the ``last`` published value (see readers.implausible). Settling: one + or implausible next to the ``last`` published value (see readers.implausible; ``limit``: how + much a counter may go up, change_limit.limit). Settling: one step below the last value, the last wheel of a counter turning; the value stays, but that is no rejection. """ @@ -230,7 +236,7 @@ def reading_verdict( return "wrong digit count", False if text.score < threshold: return "unsure", False - reason = readers.implausible(value, last, settings) + reason = readers.implausible(value, last, settings, limit) if reason == "went down" and readers.settling(value, last, settings): return None, True return reason, False diff --git a/visionstate/backend/visionstate/engine/reading.py b/visionstate/backend/visionstate/engine/reading.py index 45690c2..e96587a 100644 --- a/visionstate/backend/visionstate/engine/reading.py +++ b/visionstate/backend/visionstate/engine/reading.py @@ -6,16 +6,17 @@ import logging import random import time -from datetime import UTC, datetime +from datetime import UTC, date, datetime, timedelta from PIL import Image from sqlalchemy import select +from sqlalchemy.orm import Session -from .. import imaging, readers -from ..db import Prediction, ReadingStat +from .. import change_limit, imaging, readers +from ..db import Prediction, ReadingStat, ReadingWatch from ..mqtt import rate_unit, topics from ..redact import redact -from ..settings import READING, VERSION +from ..settings import COUNTER_LIMIT, READING, VERSION from .base import RuntimeBase from .logic import reading_verdict from .models import ModelsMixin @@ -30,18 +31,22 @@ async def _run_reading(self, cfg: SensorConfig, image: Image.Image, lit: bool = """One check of a reading sensor: read the number, check it and publish it. A value is published after ``debounce`` equal readings in a row. Unsure, empty and - implausible readings (a counter going down, a jump above ``max_step``, a mechanical - counter read with another number of digits than it has wheels) are rejected: - the last value stays and the rejected reading is kept in the history and sent to the - review queue, every one of them. Every reading is counted per day (Quality tab). + implausible readings (a counter going down or up faster than its limit, change_limit.py; + another value changing more than ``max_step``; a mechanical counter read with another + number of digits than it has wheels) are rejected: the last value stays and the rejected + reading is kept in the history and sent to the review queue, every one of them. A + counter's readings over its limit are accepted after all when enough of them agree. + Every reading is counted per day (Quality tab). ``lit``: the frame was taken with the sensor's light switched on (kept with the reading). """ live = self.live_state(cfg.id) settings = cfg.reading + counter = settings["mode"] == "counter" if not live.reading_restored: live.reading_restored = True + restored = await asyncio.to_thread(self._restore_reading, cfg.id, live.watch) if live.debouncer.published is None: - live.debouncer.published = await asyncio.to_thread(self._last_reading, cfg.id) + live.debouncer.published = restored try: text, used = await self.read_number(image, cfg.roi, settings) except Exception as err: # noqa: BLE001 - reader missing or failed to load @@ -50,7 +55,20 @@ async def _run_reading(self, cfg: SensorConfig, image: Image.Image, lit: bool = now = time.time() value = readers.parse(text.text, settings) last = float(live.debouncer.published) if live.debouncer.published is not None else None - reason, settling = reading_verdict(text, value, last, cfg.threshold, settings) + watch = live.watch + limit = None + if counter: + change_limit.advance(watch, now, cfg.interval_s) + await asyncio.to_thread(self._learn_limit, cfg.id, watch, now) + limit = change_limit.limit(settings, watch.learned, watch.budget_s) + reason, settling = reading_verdict(text, value, last, cfg.threshold, settings, limit) + agreed = False + if counter: + if reason in change_limit.IMPLAUSIBLE and value is not None and last is not None: + agreed = change_limit.hold(watch, now, value, reason, last, settings) + if agreed: + log.info("%s: %d readings in a row agree on %s: accepted", cfg.name, len(watch.pending), value) + reason = None shown = readers.format_value(value, settings) live.reading = { "text": text.text, @@ -58,18 +76,30 @@ async def _run_reading(self, cfg: SensorConfig, image: Image.Image, lit: bool = "value": shown, "reason": reason, "settling": settling, + "agreed": agreed, "at": now, } live.reading_image = reader_jpeg = await asyncio.to_thread(imaging.encode_jpeg, used, 85) live.top, live.confidence = shown, text.score changed = False - if reason is None and not settling and shown is not None: # never let a lower reading through + if agreed and shown is not None: # the agreeing readings were the debounce + changed = shown != live.debouncer.published + live.debouncer.published, live.debouncer.candidate, live.debouncer.count = shown, shown, cfg.debounce + elif reason is None and not settling and shown is not None: # never let a lower reading through changed = live.debouncer.update(shown, cfg.debounce) - if changed: - live.changes.append(now) + if changed: + live.changes.append(now) + # The limit starts again when a reading confirms the published value — not with one that + # still waits for its debounce: until then the published value is the older one. + learned = None + published = live.debouncer.published + if counter and reason is None and value is not None: + if published is not None and (changed or settling or shown == published): + change_limit.accept(watch, now) + learned = change_limit.observe(watch, now, value, cfg.interval_s, agreed) await self._publish_reading(cfg, image, reader_jpeg, text, shown, reason, now) - await self._keep_reading(cfg, image, text, shown, reason, changed, lit) + await self._keep_reading(cfg, image, text, shown, reason, changed, lit, learned) async def _publish_reading( self, @@ -114,14 +144,17 @@ async def _keep_reading( reason: str | None, changed: bool, lit: bool = False, + learned: tuple[float, float] | None = None, ) -> None: """Count the reading for the day, and keep it in the history when the value changed, it was rejected (it goes to review) or it was picked for a spot check. With it go what the reader told about it (which reader, the wheels) and the app version, - so an export says how each reading was made.""" + so an export says how each reading was made. Counters: ``learned`` is what the reading + adds to the day's limit (change_limit.observe), and their Watch is stored.""" live = self.live_state(cfg.id) - await asyncio.to_thread(self._count_reading, cfg.id, reason) + watch = live.watch if cfg.reading["mode"] == "counter" else None + await asyncio.to_thread(self._count_reading, cfg.id, reason, learned, watch, live.debouncer.published) spot = reason is None and not changed and random.random() < float(cfg.reading["spot_rate"]) if changed or reason is not None or spot: published = live.debouncer.published if reason is None else None @@ -134,6 +167,7 @@ async def _keep_reading( **(text.details or {}), "app": VERSION, "lamp": lit, + **({"agreed": True} if (live.reading or {}).get("agreed") else {}), } review = "rejected" if reason is not None else "spot_check" if spot else None await asyncio.to_thread( @@ -165,7 +199,14 @@ async def _send_reading_diagnostics( _, _, factor = rate_unit(settings) await self._send(cfg, t["rate"], f"{per_hour * factor:.4g}", retain=True) - def _count_reading(self, sensor_id: int, reason: str | None) -> None: + def _count_reading( + self, + sensor_id: int, + reason: str | None, + learned: tuple[float, float] | None = None, + watch: change_limit.Watch | None = None, + published: str | None = None, + ) -> None: day = datetime.now().date().isoformat() # local time: "today" as the user sees it with self.db.session() as s: row = s.get(ReadingStat, (sensor_id, day)) @@ -177,6 +218,75 @@ def _count_reading(self, sensor_id: int, reason: str | None) -> None: row.accepted += 1 else: row.rejected = {**row.rejected, reason: row.rejected.get(reason, 0) + 1} + if learned is not None: + row.peak = max(row.peak or 0.0, learned[0]) + row.total = (row.total or 0.0) + learned[1] + if watch is not None: + _store_watch(s, sensor_id, watch, published) + + def _learn_limit(self, sensor_id: int, watch: change_limit.Watch, now: float) -> None: + """Work out what a counter's limit learned from its days: once an hour, and on a new day.""" + today = date.today() + if now - watch.learned_at < COUNTER_LIMIT["relearn_s"] and watch.learned_day == today.isoformat(): + return + first = (today - timedelta(days=COUNTER_LIMIT["learn_days"] - 1)).isoformat() + with self.db.session() as s: + rows = s.scalars( + select(ReadingStat).where( + ReadingStat.sensor_id == sensor_id, ReadingStat.day >= first, ReadingStat.peak.is_not(None) + ) + ).all() + days = [(row.peak or 0.0, row.total or 0.0) for row in rows] + watch.learned, watch.days = change_limit.learn(days), len(days) + watch.learned_at, watch.learned_day = now, today.isoformat() + + def _restore_reading(self, sensor_id: int, watch: change_limit.Watch) -> str | None: + """After a restart: the last published value (so the checks go on from it) and a counter's + Watch as it was stored. + + A value stored with the Watch is newer than the history's when it was accepted later (the + history keeps changes only; a value confirmed in Review is only stored there). Without a + stored Watch (the first start since there is one), the time of the last published value + stands in for when the counter was last seen.""" + with self.db.session() as s: + last = s.execute( + select(Prediction.published_key, Prediction.created_at) + .where(Prediction.sensor_id == sensor_id, Prediction.published_key.is_not(None)) + .order_by(Prediction.created_at.desc()) + .limit(1) + ).first() + stored = s.get(ReadingWatch, sensor_id) + value, at = (last.published_key, _epoch(last.created_at)) if last is not None else (None, None) + if stored is not None: + watch.seen_at, watch.accepted_at = stored.seen_at, stored.accepted_at + watch.budget_s, watch.held = stored.budget_s, stored.held + if stored.value is not None and (at is None or (stored.accepted_at or 0.0) >= at): + value = stored.value + else: + watch.seen_at = watch.accepted_at = at + return value + + async def anchor_reading(self, sensor_id: int, value: str, at: datetime) -> None: + """Make ``value``, read at ``at``, the last accepted value: a rejected reading confirmed in Review. + + Later readings are checked against it (a counter stuck on a value read too high, or held + by its limit, goes on from there), and Home Assistant gets it.""" + live = self.live_state(sensor_id) + live.debouncer.published, live.debouncer.candidate, live.debouncer.count = value, None, 0 + now = time.time() + watch = live.watch + watch.accepted_at = _epoch(at) + watch.budget_s, watch.held = max(0.0, now - watch.accepted_at), False + watch.seen_at, watch.sample = now, None # nothing learned across it + watch.pending.clear() + await asyncio.to_thread(self._save_watch, sensor_id, watch, value) + cfg = self.load_sensor(sensor_id) + if cfg is not None: + await self._send(cfg, topics(cfg.slug)["state"], value, retain=True) + + def _save_watch(self, sensor_id: int, watch: change_limit.Watch, value: str) -> None: + with self.db.session() as s: + _store_watch(s, sensor_id, watch, value) def _record_reading( self, @@ -205,12 +315,16 @@ def _record_reading( ) ) - def _last_reading(self, sensor_id: int) -> str | None: - """The last published value of a reading sensor (so a restart keeps checking against it).""" - with self.db.session() as s: - return s.scalar( - select(Prediction.published_key) - .where(Prediction.sensor_id == sensor_id, Prediction.published_key.is_not(None)) - .order_by(Prediction.created_at.desc()) - .limit(1) - ) + +def _store_watch(s: Session, sensor_id: int, watch: change_limit.Watch, value: str | None) -> None: + row = s.get(ReadingWatch, sensor_id) + if row is None: + row = ReadingWatch(sensor_id=sensor_id) + s.add(row) + row.value = value + row.seen_at, row.accepted_at, row.budget_s, row.held = watch.seen_at, watch.accepted_at, watch.budget_s, watch.held + + +def _epoch(at: datetime) -> float: + """Epoch seconds of a time from the database (SQLite keeps no time zone: they are UTC).""" + return (at if at.tzinfo else at.replace(tzinfo=UTC)).timestamp() diff --git a/visionstate/backend/visionstate/engine/state.py b/visionstate/backend/visionstate/engine/state.py index 94dc0ed..1ee5684 100644 --- a/visionstate/backend/visionstate/engine/state.py +++ b/visionstate/backend/visionstate/engine/state.py @@ -12,6 +12,7 @@ from sqlalchemy.orm import Session, object_session from .. import detectors +from ..change_limit import Watch from ..db import Person, Sensor from ..mqtt import SensorDescriptor from ..settings import ( @@ -233,6 +234,7 @@ class LiveState: # and (time, value) of accepted readings of a counter for its rate (see readers.counter_rate). reads: deque = field(default_factory=deque) rate_samples: deque = field(default_factory=deque) + watch: Watch = field(default_factory=Watch) # counters: how much the value may go up next def remember(self, data: bytes) -> str: frame_id = f"{time.time_ns():x}" diff --git a/visionstate/backend/visionstate/readers.py b/visionstate/backend/visionstate/readers.py index 14df99d..a07f2e1 100644 --- a/visionstate/backend/visionstate/readers.py +++ b/visionstate/backend/visionstate/readers.py @@ -500,13 +500,19 @@ def wrong_digit_count(text: str, settings: dict) -> bool: return settings["display"] == "counter" and digit_count(text) != int(settings["digits"]) -def implausible(value: float, last: float | None, settings: dict) -> str | None: - """Why a new value can not be right compared with the last accepted one, or None.""" +def implausible(value: float, last: float | None, settings: dict, limit: float | None = None) -> str | None: + """Why a new value can not be right compared with the last accepted one, or None. + + A counter may go up by ``limit`` at most (change_limit.limit; None: no limit), any other + value change by ``max_step`` either way (0: no limit).""" if last is None: return None - if settings["mode"] == "counter" and value < last: - return "went down" - step = float(settings["max_step"]) + if settings["mode"] == "counter": + if value < last: + return "went down" + step = limit or 0.0 + else: + step = float(settings["max_step"]) if step and abs(value - last) > step: return "changed too much" return None diff --git a/visionstate/backend/visionstate/settings.py b/visionstate/backend/visionstate/settings.py index 0e28f4a..441eeda 100644 --- a/visionstate/backend/visionstate/settings.py +++ b/visionstate/backend/visionstate/settings.py @@ -84,6 +84,8 @@ COUNTER_READERS = ("wheels", "ocr") # Home Assistant device classes offered for readings ("" = none). READING_DEVICE_CLASSES = ("", "energy", "water", "gas", "volume", "monetary", "duration", "power", "temperature") +# Counters: what limits how fast the value may go up (see READING_DEFAULTS["change_limit"]). +READING_CHANGE_LIMITS = ("auto", "fixed", "off") READING_DEFAULTS = { "mode": "counter", "decimals": 0, # digits after the decimal point; the reader's own dots and commas are ignored @@ -92,7 +94,13 @@ "display": "auto", "digits": 6, # "counter" display only: wheels inside the region; other digit counts are rejected "counter_reader": "wheels", # "counter" display only: see COUNTER_READERS - "max_step": 0.0, # largest plausible change between two readings (0 = no limit) + # Values and times left: the largest plausible change between two readings (0 = no limit). + # Counters use change_limit instead (see COUNTER_LIMIT). + "max_step": 0.0, + # Counters: how fast the counter may go up. "auto" learns it from the counter's own readings, + # "fixed" takes max_rate (per hour, in the counter's unit), "off" sets no limit. + "change_limit": "auto", + "max_rate": 0.0, # Share of accepted readings that is also sent to the review queue, to find misreads that # passed every check. Rejected readings always go there. "spot_rate": 0.0, @@ -104,9 +112,34 @@ "decimals": (0, 4), "digits": (1, 12), "max_step": (0.0, 1e9), + "max_rate": (0.0, 1e9), "spot_rate": (0.0, 1.0), "rate_window_min": (1.0, 1440.0), } +# The limit on how fast a counter may go up (change_limit.py). The time since the last accepted +# reading allows more change, but only time the counter was not seen: down, or read unsure. +# Readings that were read with confidence but went over the limit hold that time still, so a +# misread that repeats can never wait its way through. +COUNTER_LIMIT = { + # "auto": the largest rise over this window per day, and the day's total rise, of the last + # learn_days days; the second largest of them (one misread that slipped through does not + # count). Learned after min_days days with readings: a whole week, wash day and weekend. + "window_s": 600, + "learn_days": 14, + "min_days": 7, + "safety": 2.0, # "auto": the learned rate and day times this + "margin_steps": 2, # always allowed: this many steps of the last digit (a wheel half way) + # A gap between two readings longer than gap_checks intervals (at least gap_min_s) is time the + # counter was not seen (the app was down, the camera away). + "gap_checks": 3, + "gap_min_s": 300.0, + # Readings over the limit are accepted when this many in a row, over at least agree_s, agree + # with each other (never lower, each step within the limit) — unless the jump looks like one + # wheel or digit read wrong (a whole step of a higher digit): those wait for an answer in Review. + "agree_readings": 3, + "agree_s": 300.0, + "relearn_s": 3600.0, # how often the learned limit is worked out again +} # Diagnostic entities of a reading sensor (off by default in Home Assistant). "problem" is "ok" or # why the last reading was rejected, as a lower-case key. READING_PROBLEMS = ("ok", "nothing_read", "unsure", "wrong_digit_count", "went_down", "changed_too_much") diff --git a/visionstate/frontend/e2e/ui.spec.ts b/visionstate/frontend/e2e/ui.spec.ts index 78d7f4b..a76de32 100644 --- a/visionstate/frontend/e2e/ui.spec.ts +++ b/visionstate/frontend/e2e/ui.spec.ts @@ -890,11 +890,38 @@ test('a counter has a rate window for its rate entity', async ({ page, request } errors.expectNone(); } finally { const sensor = await (await request.get(`api/v1/sensors/${id}`)).json(); - const { value: _v, last: _l, has_image: _h, ...reading } = sensor.reading; + const { value: _v, last: _l, has_image: _h, limit: _lim, ...reading } = sensor.reading; await request.patch(`api/v1/sensors/${id}`, { data: { reading: { ...reading, rate_window_min: 15 } } }); } }); +test('a counter learns how fast it may go up, or takes a fixed rate', async ({ page, request }, info) => { + const errors = watchErrors(page); + const id = await seededReadingSensorId(request); + await page.goto(`#/sensors/${id}/settings`); + const choice = page.getByLabel('Limit how fast the counter may go up'); + await expect(choice).toHaveValue('auto'); + await expect(page.getByText(/Learning from its readings: \d+ of \d+ days/)).toBeVisible(); + await expect(page.getByLabel('Largest rise per hour')).toHaveCount(0); + await choice.selectOption('fixed'); + await page.getByLabel('Largest rise per hour').fill('2.4'); + await press(page.getByRole('button', { name: 'Save changes' }), info); + await expect(page.getByText('Settings saved')).toBeVisible(); + try { + const sensor = await (await request.get(`api/v1/sensors/${id}`)).json(); + expect([sensor.reading.change_limit, sensor.reading.max_rate]).toEqual(['fixed', 2.4]); + expect(sensor.reading.limit.allowed).not.toBeNull(); + await expectNoHorizontalOverflow(page); + await expectNoClipping(page, '.btn, .chip, .card'); + await page.screenshot({ path: path.join('test-results', 'pages', info.project.name, 'reading-limit.png'), fullPage: true }); + errors.expectNone(); + } finally { + const sensor = await (await request.get(`api/v1/sensors/${id}`)).json(); + const { value: _v, last: _l, has_image: _h, limit: _lim, ...reading } = sensor.reading; + await request.patch(`api/v1/sensors/${id}`, { data: { reading: { ...reading, change_limit: 'auto', max_rate: 0 } } }); + } +}); + test('rejected readings: review queue and quality tab', async ({ page, request }, info) => { const errors = watchErrors(page); const garage = await seededSensorId(request); diff --git a/visionstate/frontend/src/lib/components/ReadingParams.svelte b/visionstate/frontend/src/lib/components/ReadingParams.svelte index 57984a3..456c35b 100644 --- a/visionstate/frontend/src/lib/components/ReadingParams.svelte +++ b/visionstate/frontend/src/lib/components/ReadingParams.svelte @@ -1,28 +1,45 @@ @@ -49,13 +66,44 @@ > - + {#if mode === 'counter'} + + {#if changeLimit === 'fixed'} + + {/if} + {:else} + + {/if} - {#if changeLimit === 'fixed'} + {#if changeLimit === 'auto' || changeLimit === 'fixed'} + {:else if changeLimit === 'step'} + {/if} {:else}