From e3cfb84976fd164b1337aa36c15924efd76b39f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 11:19:53 +0000 Subject: [PATCH 01/18] DATA_DIR: keep the state file and SQLite caches apart from the code (default: where they are now) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 1 + lib/config.py | 13 +++++++++++++ scripts/cache_status.py | 7 ++++--- scripts/check_rain.py | 5 +++-- scripts/dump_specs.py | 5 +++-- scripts/show_request.py | 5 +++-- tests/test_data_dir.py | 36 ++++++++++++++++++++++++++++++++++++ 7 files changed, 63 insertions(+), 9 deletions(-) create mode 100644 tests/test_data_dir.py diff --git a/README.md b/README.md index f7c4609..5b503cf 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Under systemd see `ecobot.service` (it loads the environment file; the clone's l | `TZ` | e.g. `Australia/Melbourne` | | `ECOWITT_API_KEY`, `ECOWITT_APP_KEY` | enables Ecowitt | | `AIRGRADIENT_API_TOKEN`, `AIRGRADIENT_LOCATION_ID` | enables AirGradient | +| `DATA_DIR` | optional: the folder that holds the bot's state (`bot_state.json`) and its SQLite caches (`ecowitt_cache.sqlite`, `airgradient_cache.sqlite`, `conditions_cache.sqlite`), kept apart from the code so a container can mount it. A relative path is taken from the working directory; it is created if missing. Default: the app's own folder, where the files have always been | | `AIRGRADIENT_DASHBOARD_URL` | optional "live chart" link under air-quality replies and alerts | | `POLLEN=on` | optional: Melbourne grass pollen and thunderstorm asthma risk (scraped from melbournepollen.com.au) in the report, a `pollen_asthma` tool and an alert when High. Off by default; `POLLEN_DISTRICT` picks the district (default `Central`, never printed) | | `PLACE` | the name shown after the Pollen & asthma and Forecast headings in the report (default `Melbourne`) | diff --git a/lib/config.py b/lib/config.py index 1422751..0ebf225 100644 --- a/lib/config.py +++ b/lib/config.py @@ -12,6 +12,13 @@ log = logging.getLogger(__name__) +def data_dir() -> Path: + """Where the state file and the SQLite caches live: DATA_DIR (a relative path is taken from the working directory), else the + app's own folder, where they have always been. Kept apart from the code so a container can mount it.""" + value = os.getenv("DATA_DIR", "").strip() + return Path(value).expanduser().resolve() if value else ROOT + + def _tz() -> tzinfo: name = os.getenv("TZ", "").lstrip(":") return ZoneInfo(name) if name else datetime.now().astimezone().tzinfo @@ -112,6 +119,8 @@ def airgradient(self) -> bool: @classmethod def from_env(cls) -> "Config": env = lambda k, default="": os.getenv(k, default).strip() + data = data_dir() + data.mkdir(parents=True, exist_ok=True) cfg = cls( telegram_token=os.environ["TELEGRAM_BOT_TOKEN"], xai_api_key=os.environ["XAI_API_KEY"], @@ -140,6 +149,10 @@ def from_env(cls) -> "Config": admin_only=_flag(env("ADMIN_ONLY"), True), admin_chat_ids=_ids(env("ADMIN_CHAT_IDS")), alert_cooldown_minutes=int(min(150, max(5, _number(env("ALERT_COOLDOWN_MINUTES")) or 30))), + state_path=data / "bot_state.json", + cache_path=data / "ecowitt_cache.sqlite", + air_cache_path=data / "airgradient_cache.sqlite", + conditions_cache_path=data / "conditions_cache.sqlite", ) if not (cfg.ecowitt or cfg.airgradient): raise SystemExit("Set ECOWITT_API_KEY + ECOWITT_APP_KEY and/or AIRGRADIENT_API_TOKEN + AIRGRADIENT_LOCATION_ID") diff --git a/scripts/cache_status.py b/scripts/cache_status.py index 0c2699a..147c461 100644 --- a/scripts/cache_status.py +++ b/scripts/cache_status.py @@ -12,7 +12,8 @@ from datetime import date, datetime, timedelta, UTC from pathlib import Path -from _common import ROOT, parser # noqa: E402 (also puts the repo on sys.path) +from _common import parser # noqa: E402 (also puts the repo on sys.path) +from lib.config import data_dir # noqa: E402 from lib.ecowitt.api import GROUPS, RETENTION # noqa: E402 from lib.ecowitt.store import horizon, subtract # noqa: E402 @@ -70,8 +71,8 @@ def airgradient(path: Path): if __name__ == "__main__": ap = parser(__doc__) - ap.add_argument("--ecowitt", type=Path, default=ROOT / "ecowitt_cache.sqlite") - ap.add_argument("--airgradient", type=Path, default=ROOT / "airgradient_cache.sqlite") + ap.add_argument("--ecowitt", type=Path, default=data_dir() / "ecowitt_cache.sqlite") + ap.add_argument("--airgradient", type=Path, default=data_dir() / "airgradient_cache.sqlite") args = ap.parse_args() for path, report in ((args.ecowitt, ecowitt), (args.airgradient, airgradient)): if path.exists(): diff --git a/scripts/check_rain.py b/scripts/check_rain.py index 4ec6562..3767f37 100644 --- a/scripts/check_rain.py +++ b/scripts/check_rain.py @@ -20,7 +20,8 @@ from pathlib import Path from zoneinfo import ZoneInfo -from _common import ROOT, parser # noqa: E402 (also puts the repo on sys.path) +from _common import parser # noqa: E402 (also puts the repo on sys.path) +from lib.config import data_dir # noqa: E402 from lib.ecowitt import days as day_tool # noqa: E402 from lib.ecowitt.api import UNITS # noqa: E402 @@ -107,7 +108,7 @@ def report(res: dict): def main(): ap = parser(__doc__) ap.add_argument("--days", type=int, default=88) - ap.add_argument("--cache", type=Path, default=ROOT / "ecowitt_cache.sqlite") + ap.add_argument("--cache", type=Path, default=data_dir() / "ecowitt_cache.sqlite") args = ap.parse_args() tz = ZoneInfo(os.getenv("TZ") or "Australia/Melbourne") macs = [m for (m,) in sqlite3.connect(f"file:{args.cache}?mode=ro", uri=True).execute("SELECT DISTINCT mac FROM coverage")] diff --git a/scripts/dump_specs.py b/scripts/dump_specs.py index ec0c1b6..4c10f32 100644 --- a/scripts/dump_specs.py +++ b/scripts/dump_specs.py @@ -18,7 +18,8 @@ from zoneinfo import ZoneInfo import httpx -from _common import ROOT, parser # noqa: E402 (also puts the repo on sys.path) +from _common import parser # noqa: E402 (also puts the repo on sys.path) +from lib.config import data_dir # noqa: E402 from lib.airgradient import AirGradient # noqa: E402 from lib.charts import render # noqa: E402 @@ -103,7 +104,7 @@ def copy_db(src: Path, dest: Path): async def main(): logging.disable(logging.WARNING) # the fetchers log every refused request ap = parser(__doc__) - ap.add_argument("--data-dir", default=str(ROOT), help="folder holding ecowitt_cache.sqlite and airgradient_cache.sqlite") + ap.add_argument("--data-dir", default=str(data_dir()), help="folder (default DATA_DIR) holding ecowitt_cache.sqlite and airgradient_cache.sqlite") ap.add_argument("--tz", default="Australia/Melbourne") ap.add_argument("--out", default="chart_dump") args = ap.parse_args() diff --git a/scripts/show_request.py b/scripts/show_request.py index deb1def..6072344 100644 --- a/scripts/show_request.py +++ b/scripts/show_request.py @@ -20,7 +20,8 @@ from types import SimpleNamespace as NS from zoneinfo import ZoneInfo -from _common import ROOT, parser # noqa: E402 (also puts the repo on sys.path) +from _common import parser # noqa: E402 (also puts the repo on sys.path) +from lib.config import data_dir # noqa: E402 from lib import intent, prompt # noqa: E402 from lib.airgradient import source as air # noqa: E402 @@ -61,7 +62,7 @@ def banner(text): async def main(): ap = parser(__doc__) ap.add_argument("question") - ap.add_argument("--cache", type=Path, default=ROOT / "ecowitt_cache.sqlite") + ap.add_argument("--cache", type=Path, default=data_dir() / "ecowitt_cache.sqlite") ap.add_argument("--call", help="JSON tool call the stand-in model makes first") args = ap.parse_args() diff --git a/tests/test_data_dir.py b/tests/test_data_dir.py new file mode 100644 index 0000000..902adcd --- /dev/null +++ b/tests/test_data_dir.py @@ -0,0 +1,36 @@ +from pathlib import Path + +from lib.config import ROOT, Config, data_dir + +NAMES = ("bot_state.json", "ecowitt_cache.sqlite", "airgradient_cache.sqlite", "conditions_cache.sqlite") + + +def configured(monkeypatch, **env): + for key, value in {"TELEGRAM_BOT_TOKEN": "t", "XAI_API_KEY": "x", "ECOWITT_API_KEY": "a", "ECOWITT_APP_KEY": "b", **env}.items(): + monkeypatch.setenv(key, value) + return Config.from_env() + + +def paths(cfg): + return (cfg.state_path, cfg.cache_path, cfg.air_cache_path, cfg.conditions_cache_path) + + +def test_without_data_dir_the_files_stay_where_they_have_always_been(monkeypatch): + monkeypatch.delenv("DATA_DIR", raising=False) + assert data_dir() == ROOT + assert paths(configured(monkeypatch)) == tuple(ROOT / n for n in NAMES) + monkeypatch.setenv("DATA_DIR", " ") + assert data_dir() == ROOT # blank is the same as unset + + +def test_data_dir_moves_the_state_and_all_three_caches_and_is_created(monkeypatch, tmp_path): + target = tmp_path / "a" / "data" + cfg = configured(monkeypatch, DATA_DIR=str(target)) + assert paths(cfg) == tuple(target.resolve() / n for n in NAMES) and target.is_dir() + + +def test_a_relative_data_dir_is_taken_from_the_working_directory(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + cfg = configured(monkeypatch, DATA_DIR="state") + assert cfg.state_path == (tmp_path / "state" / "bot_state.json").resolve() and (tmp_path / "state").is_dir() + assert data_dir() == Path(tmp_path / "state").resolve() From d3a28fd1eacc8f71c21bc53f8a74c630e464d80d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 11:25:58 +0000 Subject: [PATCH 02/18] README: DATA_DIR example for containers Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5b503cf..c118ab7 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Under systemd see `ecobot.service` (it loads the environment file; the clone's l | `TZ` | e.g. `Australia/Melbourne` | | `ECOWITT_API_KEY`, `ECOWITT_APP_KEY` | enables Ecowitt | | `AIRGRADIENT_API_TOKEN`, `AIRGRADIENT_LOCATION_ID` | enables AirGradient | -| `DATA_DIR` | optional: the folder that holds the bot's state (`bot_state.json`) and its SQLite caches (`ecowitt_cache.sqlite`, `airgradient_cache.sqlite`, `conditions_cache.sqlite`), kept apart from the code so a container can mount it. A relative path is taken from the working directory; it is created if missing. Default: the app's own folder, where the files have always been | +| `DATA_DIR` | optional: the folder that holds the bot's state (`bot_state.json`) and its SQLite caches (`ecowitt_cache.sqlite`, `airgradient_cache.sqlite`, `conditions_cache.sqlite`), kept apart from the code so a container can mount it (e.g. `/data`). A relative path is also accepted and is taken from the working directory; the folder is created if missing. Default: the app's own folder, where the files have always been | | `AIRGRADIENT_DASHBOARD_URL` | optional "live chart" link under air-quality replies and alerts | | `POLLEN=on` | optional: Melbourne grass pollen and thunderstorm asthma risk (scraped from melbournepollen.com.au) in the report, a `pollen_asthma` tool and an alert when High. Off by default; `POLLEN_DISTRICT` picks the district (default `Central`, never printed) | | `PLACE` | the name shown after the Pollen & asthma and Forecast headings in the report (default `Melbourne`) | From 0b722e20fb558fce8b116e6b3399a11190d8bce8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 05:50:54 +0000 Subject: [PATCH 03/18] Run under a systemd DynamicUser: STATE_DIRECTORY default, no symlink resolve, clear unwritable error; Matplotlib cache in the data folder; read the commit without running git Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- .gitignore | 1 + README.md | 2 +- ecobot.py | 26 ++++++++++++++--- lib/config.py | 17 ++++++++--- tests/test_data_dir.py | 64 ++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 99 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 2b46247..dccd7a8 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ mcp_ecowitt.json chart-diffs/ !tests/data/*.sqlite .claude/settings.local.json +matplotlib/ diff --git a/README.md b/README.md index c118ab7..66c1bef 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Under systemd see `ecobot.service` (it loads the environment file; the clone's l | `TZ` | e.g. `Australia/Melbourne` | | `ECOWITT_API_KEY`, `ECOWITT_APP_KEY` | enables Ecowitt | | `AIRGRADIENT_API_TOKEN`, `AIRGRADIENT_LOCATION_ID` | enables AirGradient | -| `DATA_DIR` | optional: the folder that holds the bot's state (`bot_state.json`) and its SQLite caches (`ecowitt_cache.sqlite`, `airgradient_cache.sqlite`, `conditions_cache.sqlite`), kept apart from the code so a container can mount it (e.g. `/data`). A relative path is also accepted and is taken from the working directory; the folder is created if missing. Default: the app's own folder, where the files have always been | +| `DATA_DIR` | optional: the folder that holds the bot's state (`bot_state.json`) and its SQLite caches (`ecowitt_cache.sqlite`, `airgradient_cache.sqlite`, `conditions_cache.sqlite`), kept apart from the code so a container can mount it (e.g. `/data`). A relative path is also accepted and is taken from the working directory; the folder is created if missing. Unset, a systemd service with `StateDirectory=` uses that folder (`STATE_DIRECTORY`, e.g. `/var/lib/ecobot`), else the app's own folder. Matplotlib's font cache goes in a `matplotlib` sub-folder (unless `MPLCONFIGDIR` is set). Default: the app's own folder, where the files have always been | | `AIRGRADIENT_DASHBOARD_URL` | optional "live chart" link under air-quality replies and alerts | | `POLLEN=on` | optional: Melbourne grass pollen and thunderstorm asthma risk (scraped from melbournepollen.com.au) in the report, a `pollen_asthma` tool and an alert when High. Off by default; `POLLEN_DISTRICT` picks the district (default `Central`, never printed) | | `PLACE` | the name shown after the Pollen & asthma and Forecast headings in the report (default `Melbourne`) | diff --git a/ecobot.py b/ecobot.py index 778b7b0..adef3f4 100644 --- a/ecobot.py +++ b/ecobot.py @@ -6,10 +6,16 @@ import asyncio import contextlib import logging +import os import signal -import subprocess import time +from lib.config import ROOT, Config, data_dir + +# Matplotlib keeps its font cache here: a service user without a home directory would otherwise get a throw-away /tmp one on every start. +# Set before anything imports Matplotlib; an MPLCONFIGDIR in the environment wins. +os.environ.setdefault("MPLCONFIGDIR", str(data_dir() / "matplotlib")) + from openai import AsyncOpenAI from telegram import Update from telegram.ext import Application, Defaults @@ -19,7 +25,6 @@ from lib.alerts import AIR_CHECK_SECONDS, AirMonitor, AlertState, ForecastMonitor, IrrigationMonitor, Notifier, PollenMonitor, WeatherMonitor from lib.alerts.menu import available_kinds from lib.bot import Bot, polling_error -from lib.config import ROOT, Config from lib.ecowitt import Archive, Ecowitt from lib.forecast import Forecast from lib.irrigation import Tuya @@ -60,9 +65,22 @@ async def start(source): def version() -> str: """The checked-out commit, so the log shows which code is running.""" + return commit_in(ROOT / ".git") + + +def commit_in(git_dir) -> str: + """The short hash HEAD points at, read from the repository's files rather than by running git (which refuses a folder owned by + another user, as when the service runs as its own user). "unknown" when it cannot be read.""" try: - return subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=ROOT, capture_output=True, text=True, - timeout=5, check=True).stdout.strip() or "unknown" + head = (git_dir / "HEAD").read_text().strip() + if head.startswith("ref:"): + ref = head.split(None, 1)[1] + try: + head = (git_dir / ref).read_text().strip() + except FileNotFoundError: # after `git pack-refs` or gc the branch is a line in packed-refs + head = next(line.split()[0] for line in (git_dir / "packed-refs").read_text().splitlines() + if line.endswith(f" {ref}")) + return head[:7] if len(head) >= 7 and all(c in "0123456789abcdef" for c in head.lower()) else "unknown" except Exception: return "unknown" diff --git a/lib/config.py b/lib/config.py index 0ebf225..a8784ec 100644 --- a/lib/config.py +++ b/lib/config.py @@ -14,9 +14,11 @@ def data_dir() -> Path: """Where the state file and the SQLite caches live: DATA_DIR (a relative path is taken from the working directory), else the - app's own folder, where they have always been. Kept apart from the code so a container can mount it.""" - value = os.getenv("DATA_DIR", "").strip() - return Path(value).expanduser().resolve() if value else ROOT + folder systemd gives a service that has StateDirectory= (STATE_DIRECTORY), else the app's own folder, where they have always been. + Kept apart from the code so a container can mount it. Symlinks are left as written: /var/lib/ecobot, not the closed + /var/lib/private/ecobot behind it.""" + value = os.getenv("DATA_DIR", "").strip() or os.getenv("STATE_DIRECTORY", "").split(":")[0].strip() + return Path(os.path.abspath(Path(value).expanduser())) if value else ROOT def _tz() -> tzinfo: @@ -120,7 +122,14 @@ def airgradient(self) -> bool: def from_env(cls) -> "Config": env = lambda k, default="": os.getenv(k, default).strip() data = data_dir() - data.mkdir(parents=True, exist_ok=True) + try: + data.mkdir(parents=True, exist_ok=True) + writable = os.access(data, os.W_OK | os.X_OK) + except OSError: + writable = False + if not writable: + raise SystemExit(f"{data} is not writable by this user: set DATA_DIR to a writable folder " + "(under systemd with StateDirectory=ecobot that is /var/lib/ecobot)") cfg = cls( telegram_token=os.environ["TELEGRAM_BOT_TOKEN"], xai_api_key=os.environ["XAI_API_KEY"], diff --git a/tests/test_data_dir.py b/tests/test_data_dir.py index 902adcd..a1abe68 100644 --- a/tests/test_data_dir.py +++ b/tests/test_data_dir.py @@ -1,5 +1,10 @@ +import os +import subprocess +import sys from pathlib import Path +import pytest + from lib.config import ROOT, Config, data_dir NAMES = ("bot_state.json", "ecowitt_cache.sqlite", "airgradient_cache.sqlite", "conditions_cache.sqlite") @@ -17,6 +22,7 @@ def paths(cfg): def test_without_data_dir_the_files_stay_where_they_have_always_been(monkeypatch): monkeypatch.delenv("DATA_DIR", raising=False) + monkeypatch.delenv("STATE_DIRECTORY", raising=False) assert data_dir() == ROOT assert paths(configured(monkeypatch)) == tuple(ROOT / n for n in NAMES) monkeypatch.setenv("DATA_DIR", " ") @@ -26,11 +32,65 @@ def test_without_data_dir_the_files_stay_where_they_have_always_been(monkeypatch def test_data_dir_moves_the_state_and_all_three_caches_and_is_created(monkeypatch, tmp_path): target = tmp_path / "a" / "data" cfg = configured(monkeypatch, DATA_DIR=str(target)) - assert paths(cfg) == tuple(target.resolve() / n for n in NAMES) and target.is_dir() + assert paths(cfg) == tuple(target / n for n in NAMES) and target.is_dir() def test_a_relative_data_dir_is_taken_from_the_working_directory(monkeypatch, tmp_path): monkeypatch.chdir(tmp_path) cfg = configured(monkeypatch, DATA_DIR="state") - assert cfg.state_path == (tmp_path / "state" / "bot_state.json").resolve() and (tmp_path / "state").is_dir() + assert cfg.state_path == tmp_path / "state" / "bot_state.json" and (tmp_path / "state").is_dir() assert data_dir() == Path(tmp_path / "state").resolve() + + +def test_systemd_state_directory_is_the_default_and_data_dir_wins(monkeypatch, tmp_path): + first, second = tmp_path / "one", tmp_path / "two" + monkeypatch.delenv("DATA_DIR", raising=False) + monkeypatch.setenv("STATE_DIRECTORY", f"{first}:{second}") # systemd may list several: the first is used + assert data_dir() == first and paths(configured(monkeypatch))[0] == first / "bot_state.json" and first.is_dir() + monkeypatch.setenv("DATA_DIR", str(second)) + assert data_dir() == second + + +def test_a_symlinked_data_dir_is_used_as_written(monkeypatch, tmp_path): + real = tmp_path / "private" + real.mkdir() + link = tmp_path / "ecobot" + link.symlink_to(real) + monkeypatch.setenv("DATA_DIR", str(link)) + assert data_dir() == link and configured(monkeypatch).state_path == link / "bot_state.json" + + +def test_a_folder_that_cannot_be_made_stops_the_start_with_a_plain_message(monkeypatch, tmp_path): + blocker = tmp_path / "file" + blocker.write_text("not a folder") + with pytest.raises(SystemExit) as stop: + configured(monkeypatch, DATA_DIR=str(blocker / "data")) + assert "not writable" in str(stop.value) and "DATA_DIR" in str(stop.value) + + +def test_matplotlib_keeps_its_cache_in_the_data_folder_unless_told_otherwise(tmp_path): + code = "import os, ecobot; print(os.environ['MPLCONFIGDIR'])" + env = {k: v for k, v in os.environ.items() if k != "MPLCONFIGDIR"} + out = subprocess.run([sys.executable, "-c", code], cwd=ROOT, capture_output=True, text=True, env={**env, "DATA_DIR": str(tmp_path)}) + assert out.stdout.strip() == str(tmp_path / "matplotlib"), out.stderr[-500:] + out = subprocess.run([sys.executable, "-c", code], cwd=ROOT, capture_output=True, text=True, + env={**env, "DATA_DIR": str(tmp_path), "MPLCONFIGDIR": str(tmp_path / "mine")}) + assert out.stdout.strip() == str(tmp_path / "mine") + + +def test_the_running_commit_is_read_without_running_git(tmp_path): + from ecobot import commit_in + git = lambda *a: subprocess.run(["git", *a], cwd=tmp_path, check=True, capture_output=True, text=True, + env={**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t", + "GIT_COMMITTER_EMAIL": "t@t"}).stdout.strip() + assert commit_in(tmp_path / ".git") == "unknown" # no repository + git("init", "-q", "-b", "main") + git("commit", "-q", "--allow-empty", "-m", "one") + want = git("rev-parse", "--short=7", "HEAD") + assert commit_in(tmp_path / ".git") == want # a loose branch ref + git("pack-refs", "--all", "--prune") + assert commit_in(tmp_path / ".git") == want # a packed ref + git("checkout", "-q", "--detach") + assert commit_in(tmp_path / ".git") == want # a detached HEAD + (tmp_path / ".git" / "HEAD").write_text("garbage") + assert commit_in(tmp_path / ".git") == "unknown" From db6cf8f3dad3d887b3adde4296495116b42698a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 07:03:19 +0000 Subject: [PATCH 04/18] Explain an empty history answer in the log; log the history cache at start Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/ecowitt/query.py | 14 ++++++++++++++ lib/ecowitt/station.py | 11 ++++++++++- lib/ecowitt/store.py | 10 ++++++++++ tests/test_ecowitt.py | 23 ++++++++++++++++++++++- tests/test_store.py | 10 ++++++++++ 5 files changed, 66 insertions(+), 2 deletions(-) diff --git a/lib/ecowitt/query.py b/lib/ecowitt/query.py index 0cbef41..b072459 100644 --- a/lib/ecowitt/query.py +++ b/lib/ecowitt/query.py @@ -99,6 +99,7 @@ async def run(self) -> str: self.rain_bars = await self._rain_bars() await self._summarise_direction() if not self.store and not self.direction: + await self._log_empty() return "Error: no history data returned." + ( " Details: " + "; ".join(self.f.errors[:5]) if self.f.errors else "") self.overall = {k: ex for k, s in self.store.items() if len(ex := series_extremes(s)) == 2} @@ -106,6 +107,19 @@ async def run(self) -> str: refined = await self._refine() return self._answer(refined) + async def _log_empty(self): + """Say why a query found nothing: what was asked, where it was answered from, and what the cache holds for the window.""" + f = self.f + lo, hi = f.epoch(self.start), f.epoch(self.end) + try: + held = {c: sum([await asyncio.to_thread(f.cache.count, f.mac, c, g, lo, hi) for g in f.groups]) for c in ("5min", "30min", "1day")} + except Exception as e: + held = f"unreadable ({e})" + log.warning("History query empty: %s to %s (tz %s, epoch %d-%d), groups %s, %d ranges, %d from cache, %d from memory, %d requests, " + "%d failed%s, cache %s, readings held for the window %s", f"{self.start:%Y-%m-%d %H:%M}", f"{self.end:%Y-%m-%d %H:%M}", + getattr(self.tz, "key", self.tz), lo, hi, ",".join(f.groups), f.ranges, f.from_cache, f.from_memory, f.calls, + len(f.errors), f" ({f.errors[0]})" if f.errors else "", getattr(f.cache, "path", "?"), held) + async def _fine_ranges(self): """30-minute readings with no low and high of their own get them from the cached 5-minute readings (store.slot_ranges), so their bands are real ranges. Wind keeps its own band (the speed up to the gusts).""" diff --git a/lib/ecowitt/station.py b/lib/ecowitt/station.py index e8e1809..94aa598 100644 --- a/lib/ecowitt/station.py +++ b/lib/ecowitt/station.py @@ -5,12 +5,13 @@ import asyncio import json import logging +import os import time from datetime import datetime, timedelta, UTC from ..config import Config from ..intent import NOW_GROUPS -from ..timeutil import now_local +from ..timeutil import now_local, to_local from .outlook import rain_outlook from ..tools import Tool, Turn from ..warm import FAST_REFRESH_SECONDS, Warmer @@ -139,6 +140,14 @@ async def start(self): log.warning("Public holiday questions won't work: pip install holidays (then restart)") log.info("Weather station: Ecowitt '%s' (%s)%s", self.station_name, self.mac, f", devices found: {len(devices)}" if len(devices) > 1 else "") + try: + size = os.path.getsize(self.cache.path) if os.path.exists(self.cache.path) else 0 + newest = {c: (f"{to_local(t, self.tz):%d %b %H:%M}" if (t := await asyncio.to_thread(self.cache.newest, self.mac, c)) else "none") + for c in ("5min", "30min", "4hour", "1day")} + log.info("History cache: %s (%.1f MB), newest reading %s, time zone %s", self.cache.path, size / 1e6, + " / ".join(f"{c} {v}" for c, v in newest.items()), getattr(self.tz, "key", self.tz)) + except Exception as e: # only a log line + log.warning("History cache not readable: %s", e) def describe(self) -> str: created = f", created {self.created:%Y-%m-%d %H:%M}" if self.created else "" diff --git a/lib/ecowitt/store.py b/lib/ecowitt/store.py index 38b9637..567edd0 100644 --- a/lib/ecowitt/store.py +++ b/lib/ecowitt/store.py @@ -75,6 +75,7 @@ def merge(intervals: list[Interval]) -> list[Interval]: class HistoryCache: def __init__(self, path, units: dict): self._lock = threading.Lock() + self.path = path self.db = sqlite3.connect(path, check_same_thread=False) self.db.execute("PRAGMA journal_mode=WAL") self.db.executescript(""" @@ -120,6 +121,15 @@ def _query(self, sql: str, *args) -> list: with self._lock: return self.db.execute(sql, args).fetchall() + def count(self, mac: str, cycle: str, grp: str, start: int, end: int) -> int: + """How many readings are held for this group at this resolution between two times (for diagnosing an empty answer).""" + return self._query("SELECT COUNT(*) FROM points WHERE mac=? AND cycle=? AND grp=? AND ts BETWEEN ? AND ?", + mac, cycle, grp, start, end)[0][0] + + def newest(self, mac: str, cycle: str) -> int | None: + """The time of the newest reading held at this resolution (None if none).""" + return self._query("SELECT MAX(ts) FROM points WHERE mac=? AND cycle=?", mac, cycle)[0][0] + def coverage(self, mac: str, cycle: str, grp: str) -> list[Interval]: """Every time range held for this group at this resolution (merged, oldest first).""" return merge(self._query("SELECT start, end FROM coverage WHERE mac=? AND cycle=? AND grp=?", mac, cycle, grp)) diff --git a/tests/test_ecowitt.py b/tests/test_ecowitt.py index 883bb07..e49af68 100644 --- a/tests/test_ecowitt.py +++ b/tests/test_ecowitt.py @@ -8,7 +8,7 @@ from lib.tools import Turn from lib.ecowitt import Ecowitt from lib.ecowitt import api as ecowitt_api -from tests.fakes import MAC, archived_station, config, ecowitt_transport +from tests.fakes import MAC, FakeEcowitt, archived_station, config, ecowitt_transport @pytest.fixture @@ -1213,3 +1213,24 @@ async def test_rain_older_than_the_30_minute_readings_is_drawn_from_the_daily_re q.turn = Turn(text="rain by month for 2 months") monthly = await q._rain_bars() assert monthly.per == "month" and monthly.values and round(sum(monthly.y), 1) == 120.0 and len(monthly.x) in (2, 3) + + +async def test_an_empty_history_answer_is_explained_in_the_log_and_the_start_says_which_cache_it_uses(tmp_path, caplog): + import logging + + class Silent(FakeEcowitt): + @staticmethod + def _history(p): + return {} + + transport, _ = ecowitt_transport(Silent()) + eco = Ecowitt(config(tmp_path), transport=transport) + with caplog.at_level(logging.INFO): + await eco.start() + today = datetime.now(eco.tz).date() + reply = await eco.tools[1].handler({"groups": "outdoor", "start_date": f"{today - timedelta(days=1)} 00:00:00", + "end_date": f"{today} 23:59:59"}) + assert reply.startswith("Error: no history data returned") + text = caplog.text + assert "History cache: " in text and str(eco.cache.path) in text and "newest reading 5min none" in text + assert "History query empty" in text and "readings held for the window {'5min': 0, '30min': 0, '1day': 0}" in text diff --git a/tests/test_store.py b/tests/test_store.py index 92d6045..433a9e4 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -90,3 +90,13 @@ def test_slot_ranges_come_from_the_cached_five_minute_readings(tmp_path): lows, highs = cache.slot_ranges("M", "outdoor", "dew_point", {base: 12.0, base + 1800: 18.0, base + 3600: 30.0}, base, base + 7200) assert (lows[base], highs[base]) == (10.0, 15.0) and (lows[base + 1800], highs[base + 1800]) == (16.0, 21.0) assert base + 3600 not in lows # no 5-minute readings there: no range + + +def test_the_cache_says_where_it_is_and_how_much_it_holds(tmp_path): + cache, now = make(tmp_path), int(time.time()) + old = now - 5 * 86400 + cache.store("M", "5min", ["outdoor"], data(old, old + 300, old + 600), old - 300, now) + assert cache.path == tmp_path / "c.sqlite" + assert cache.count("M", "5min", "outdoor", old, old + 300) == 2 and cache.count("M", "5min", "outdoor", old + 1000, now) == 0 + assert cache.count("M", "30min", "outdoor", 0, now) == 0 + assert cache.newest("M", "5min") == old + 600 and cache.newest("M", "30min") is None From 9cd4da1a185dda5fde484318f5b7adb93e431b8e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 22:03:59 +0000 Subject: [PATCH 05/18] Add a Refresh button to the status report Pressing it fetches the report again and redraws it in the same message. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 2 +- TELEGRAM_UX.md | 2 +- lib/bot.py | 30 ++++++++++++++++++++++----- lib/report_menu.py | 11 ++++++++++ tests/test_admin_only.py | 9 ++++---- tests/test_bot.py | 44 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 87 insertions(+), 11 deletions(-) create mode 100644 lib/report_menu.py diff --git a/README.md b/README.md index 66c1bef..9b84031 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Both are off by default. They are websites, not APIs (the pollen page is parsed) data starts. Questions only go to the APIs for data the cache is missing. ## The report -"report" (and "status", "sitrep") and "weather now" are written in code, with no model call (`lib/report.py`): the bot fetches the tools' results together and lays them out, so they are quick and always the same. Sun, UV and wind are left out when zero (night, calm). The report is: Weather station, Air quality, and Pollen & asthma and Forecast when those sources are on (pollen and forecast always come from the cache). +"report" (and "status", "sitrep") and "weather now" are written in code, with no model call (`lib/report.py`): the bot fetches the tools' results together and lays them out, so they are quick and always the same. Sun, UV and wind are left out when zero (night, calm). The report is: Weather station, Air quality, and Pollen & asthma and Forecast when those sources are on (pollen and forecast always come from the cache). The report carries a ๐Ÿ”„ Refresh button that reads everything again and redraws it in the same message (`lib/report_menu.py`). A plain chart request ("rain chart 7d", "plot temperature and humidity", "air quality 30d", the chart buttons) is also drawn and written up in code, with no model call (the chart goes out with just its title as the caption; the write-up is kept in the chat's memory, not sent) (`intent.weather_chart`, `report.chart_caption`): the period, then low and high per reading (the average first if one was asked), the peak and rating for air quality, and for a rain chart the least and most rain and whether more is expected. Anything that asks for correlation, analysis, a forecast, thinking or a description, or compares readings or names a particular date or time, still goes to the model. diff --git a/TELEGRAM_UX.md b/TELEGRAM_UX.md index 1b69217..94c23f0 100644 --- a/TELEGRAM_UX.md +++ b/TELEGRAM_UX.md @@ -33,7 +33,7 @@ A command or a button never does work of its own. It is turned into the plain se | `/reset` | as today | (unchanged) | | `/help` | the command list | (unchanged) | -Defaults: `/weather` and `/air` with no period are "now"; `/chart` with no reading is temperature; a chart with no period is 7 days. `/status` and `/report` use the sentence the bot already recognises (`intent.wants_report`), which fetches the weather and the air quality in one go and lists everything. +Defaults: `/weather` and `/air` with no period are "now"; `/chart` with no reading is temperature; a chart with no period is 7 days. `/status` and `/report` use the sentence the bot already recognises (`intent.wants_report`), which fetches the weather and the air quality in one go and lists everything. The report has a ๐Ÿ”„ Refresh button (`rp:refresh`) that fetches it again and edits the same message. **Implementation.** A pure function `command_to_text(name, args) -> str` holds the table above. One generic `CommandHandler` for the new names calls it and then `Bot.respond(update, context, text)`. In groups, Telegram sends `/weather@ecobot`; `python-telegram-bot` strips the suffix, and a command reaches a bot in a group even with privacy mode on, so no mention is needed. Replies stay silent. diff --git a/lib/bot.py b/lib/bot.py index f3ad98d..8068f09 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -20,7 +20,7 @@ from telegram.error import BadRequest, ChatMigrated, Forbidden, NetworkError, TelegramError from telegram.ext import CallbackQueryHandler, ChatMemberHandler, CommandHandler, ContextTypes, MessageHandler, filters -from . import intent, periods, prompt, report, templates +from . import intent, periods, prompt, report, report_menu, templates from .admins import Admins from .alerts import menu from .alerts import AlertState, with_footer @@ -298,7 +298,7 @@ async def _press(self, query) -> bool: def buttons(self) -> dict: """Every family of buttons the bot draws: the start of its data -> what handles it. The only way into any of them is on_button.""" return {"al:": self.on_alert_button, **({"ir:": self.on_irrigation_button} if self.irrigation else {}), - periods.PREFIX: self.on_period_button} + periods.PREFIX: self.on_period_button, report_menu.PREFIX: self.on_report_button} async def on_button(self, update: Update, context: ContextTypes.DEFAULT_TYPE): """The one place every button press comes in: believed (_press), a known family (a prefix nobody handles is refused, so a @@ -554,6 +554,24 @@ async def _edit_irrigation(query, text: str | None, markup, entities=None): if "not modified" not in str(e).lower(): raise + async def on_report_button(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """A press on Refresh under the report: read everything again and redraw the report in the same message. The chat's + conversation is not touched (this is not a new question).""" + query = update.callback_query + if query.data != report_menu.REFRESH: + await query.answer(STALE_BUTTON) + return + read = intent.read("Report", now_local(self.cfg.tz), "Ecowitt" in self.by_name, "AirGradient" in self.by_name, + "Pollen" in self.by_name, "Forecast" in self.by_name) + try: + text = await asyncio.wait_for(self._written_in_code(read, Turn(text="Report")), TURN_SECONDS) + except Exception as e: # the message stays as it was + log.warning("Report refresh failed: %s", e) + await query.answer("Couldn't refresh, please try again", show_alert=True) + return + await query.answer("Refreshed") + await self._edit_irrigation(query, text, report_menu.keyboard()) + async def on_period_button(self, update: Update, context: ContextTypes.DEFAULT_TYPE): """A press on 1D / 7D / 1M / 3M / 1Y under a chart: ask the chart's question again for that period and put the new chart in the same message.""" @@ -870,15 +888,17 @@ async def respond(self, update: Update, context: ContextTypes.DEFAULT_TYPE, text for m in working[new_from:] for tc in m.get("tool_calls") or []) air = self.by_name.get("AirGradient") markup = templates.keyboard(self.irrigation is not None) if self._keyboard_stale(msg) else None - row = periods.keyboard(intent.period_days(text, now)) if len(photos) == 1 else None # period buttons under a single chart + refresh = ok and read.report and not photos and 0 < len(reply) <= TG_LIMIT and not markup # the report, in one message, to be refreshed in place + row =periods.keyboard(intent.period_days(text, now)) if len(photos) == 1 else None # period buttons under a single chart sent_photo = [] if row and ok and not reply.strip(): # a chart and nothing else: keep it for the period buttons self.images.put(self.images.key(text, intent.period_days(text, now), now), photos[0], titles[0]) try: if redraw is not None and row and await self._redraw(redraw, reply, photos[0], titles[0], row, text): reply, photos, row = "", [], None - carried_keyboard = await deliver(msg, reply, photos, link=air.link if air and used_air else None, markup=markup, - titles=titles, period_row=row, sent_photo=sent_photo) + carried_keyboard = await deliver(msg, reply, photos, link=air.link if air and used_air else None, + markup=markup or (report_menu.keyboard() if refresh else None), + titles=titles, period_row=row, sent_photo=sent_photo) and bool(markup) # the refresh button is not the chat's keyboard if sent_photo: self.charted.remember(msg.chat_id, sent_photo[0].message_id, text) if self.state and ok and turn.forecast_shown: # it was sent: a later revision of these days is worth telling this chat diff --git a/lib/report_menu.py b/lib/report_menu.py new file mode 100644 index 0000000..48da949 --- /dev/null +++ b/lib/report_menu.py @@ -0,0 +1,11 @@ +"""The button under the full report ("status"): Refresh, which reads everything again and redraws the report in place +(Bot.on_report_button).""" + +from telegram import InlineKeyboardButton, InlineKeyboardMarkup + +PREFIX = "rp:" +REFRESH = f"{PREFIX}refresh" + + +def keyboard() -> InlineKeyboardMarkup: + return InlineKeyboardMarkup([[InlineKeyboardButton("๐Ÿ”„ Refresh", callback_data=REFRESH)]]) diff --git a/tests/test_admin_only.py b/tests/test_admin_only.py index 4a59e3e..b1783ad 100644 --- a/tests/test_admin_only.py +++ b/tests/test_admin_only.py @@ -7,7 +7,7 @@ from telegram import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup from telegram.ext import CallbackQueryHandler -from lib import periods, templates +from lib import periods, report_menu, templates from lib.admins import Admins from lib.alerts import AlertState, menu from lib.bot import MAX_CALLBACK_BYTES, NOT_ADMIN, NOT_ADMIN_TOAST, STALE_BUTTON, Bot @@ -331,6 +331,7 @@ def every_keyboard(bot): yield irrigation_menu.keyboard(battery, pause, opened, paused) for current in (None, *periods.PERIODS): yield periods.keyboard(current) + yield report_menu.keyboard() def test_every_button_the_bot_draws_is_short_plain_and_has_exactly_one_handler(tmp_path): @@ -344,7 +345,7 @@ def test_every_button_the_bot_draws_is_short_plain_and_has_exactly_one_handler(t assert 0 < len(data.encode()) <= MAX_CALLBACK_BYTES and data.isprintable(), data assert sum(data.startswith(prefix) for prefix in table) == 1, data assert bot._on_keyboard(NS(reply_markup=markup), data) - assert len(seen) > 60 and {p for p in table} == {"al:", "ir:", periods.PREFIX} + assert len(seen) > 60 and {p for p in table} == {"al:", "ir:", periods.PREFIX, report_menu.PREFIX} assert "ir:" not in make(tmp_path, irrigation=None)[0].buttons() @@ -432,13 +433,13 @@ async def test_random_data_never_gets_past_the_entry_point(tmp_path): rng = random.Random(7) spy = Spy() bot, state, tg = make(tmp_path, irrigation=spy) - boards = [alert_keyboard("sub"), irrigation_menu.keyboard(True, True), periods.keyboard(1)] + boards = [alert_keyboard("sub"), irrigation_menu.keyboard(True, True), periods.keyboard(1), report_menu.keyboard()] known = {b.callback_data for board_ in boards for row in board_.inline_keyboard for b in row} pieces = ["ir", "al", "pd", "on", "off", "water", "delay", "sw", "batt", "pause", "refresh", "open", "close", "all", "sub", "unsub", "rain", "uv", "24h", "cancel", "5", "30", "99999", "-1", "", " ", "\n", "รฉ", "๐Ÿ’ง", "*", "%s", "{}", "\\", ";", "$(x)", "None"] before = (dict(state.chats), dict(state.charts)) for _ in range(3000): - data = rng.choice(["ir", "al", "pd", ""]) + ":" + ":".join(rng.choice(pieces) for _ in range(rng.randint(0, 4))) + data = rng.choice(["ir", "al", "pd", "rp", ""]) + ":" + ":".join(rng.choice(pieces) for _ in range(rng.randint(0, 4))) if data in known: continue press = await press_through(bot, tg, data, rng.choice(boards), user=rng.choice([ADMIN, STRANGER])) diff --git a/tests/test_bot.py b/tests/test_bot.py index f62b3a4..812d5cc 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -510,3 +510,47 @@ async def send_chat_action(*a, **k): pass await Bot(NS(tz=TZ), Agent(), sources, None).respond(update, NS(bot=NS(send_chat_action=send_chat_action, id=99)), text) assert sent == ["t"], text + + +async def test_the_report_carries_a_refresh_button_that_redraws_it_in_place(): + import json + from lib import report_menu + + class Tools: + reading = "12.3 โ„ƒ" + + async def call(self, name, raw, turn=None): + return {"weather_now": json.dumps({"outdoor": {"temperature": self.reading}}), + "air_quality": json.dumps({"pm10": {"value": 3.0, "unit": "ยตg/mยณ", "rating": "๐ŸŸข good"}})}[name] + + class Agent: + tools = Tools() + + class Query: + data, edits, toast = report_menu.REFRESH, [], None + + async def answer(self, text=None, **kw): + self.toast = text + + async def edit_message_text(self, text, reply_markup=None, **kw): + self.edits.append((text, reply_markup)) + sources = [NS(name=n, wants=lambda t: True, poke=lambda: None, describe=lambda n=n: n) for n in ("Ecowitt", "AirGradient")] + sent = [] + + async def reply_text(body, **kw): + sent.append((body, kw.get("reply_markup"))) + msg = NS(chat_id=1, message_thread_id=None, is_topic_message=False, reply_text=reply_text, + chat=NS(type="private", title=None), from_user=NS(full_name="Rob"), reply_to_message=None) + update = NS(effective_message=msg, effective_chat=msg.chat, effective_user=NS(username="rob", full_name="Rob")) + + async def send_chat_action(*a, **k): + pass + context = NS(bot=NS(send_chat_action=send_chat_action, id=99)) + bot = Bot(NS(tz=TZ), Agent(), sources, None) + await bot.respond(update, context, "report") + await bot.respond(update, context, "weather now") + assert [b.callback_data for b in sent[0][1].inline_keyboard[0]] == [report_menu.REFRESH] and sent[1][1] is None + Tools.reading = "13.0 โ„ƒ" + query = Query() + await bot.on_report_button(NS(callback_query=query), context) + assert query.toast == "Refreshed" and "13.0 ยฐC" in query.edits[0][0] and query.edits[0][1].inline_keyboard[0][0].callback_data == report_menu.REFRESH From b276203c7946c746348dc017c823ff94cf87d77d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 22:22:20 +0000 Subject: [PATCH 06/18] Log a tool result in full only under 70 chars Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/tools.py b/lib/tools.py index 83f4390..7987ca7 100644 --- a/lib/tools.py +++ b/lib/tools.py @@ -69,7 +69,7 @@ async def call(self, name: str, raw_args: str, turn: Turn | None = None) -> str: if len(out) > MAX_OUTPUT_CHARS: log.warning("Tool %s output truncated: %d chars", name, len(out)) out = out[:MAX_OUTPUT_CHARS] + "\n...[truncated - request a shorter range]" - log.info("Tool result %s: %s", name, out if len(out) < 300 else f"{len(out)} chars") + log.info("Tool result %s: %s", name, out if len(out) < 70 else f"{len(out)} chars") if turn: turn.result_chars += len(out) return out From 8bc5bc51ee5dab98396bc1b49a8cbfcb9defa0ac Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 23:14:43 +0000 Subject: [PATCH 07/18] Alerts: report what started, drop rain-likely, rename kinds and monitors - Startup Alerts line is built from the alert types that actually started (same names as the buttons), wrapped at 90 chars; a warning names any that did not - Remove the rain-likely alert (the status report's outlook stays) - Rename kinds air/pollen/irrigation to particulates/pollen_and_asthma/irrigation_battery, the monitors and files to match (ParticulatesMonitor, PollenAndAsthmaMonitor, IrrigationDailyCheck) and the saved cursors; the irrigation cursor is split into irrigation_daily_check and irrigation_watchdog - History cache startup line: path and size only Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 8 +-- RECODE.md | 2 +- ecobot.py | 48 +++++++++++----- lib/alerts/__init__.py | 8 +-- lib/alerts/forecast.py | 2 + ...rrigation.py => irrigation_daily_check.py} | 10 ++-- lib/alerts/menu.py | 21 ++++--- lib/alerts/{air.py => particulates.py} | 14 +++-- .../{pollen.py => pollen_and_asthma.py} | 8 ++- lib/alerts/weather.py | 29 +--------- lib/bot.py | 8 +-- lib/ecowitt/outlook.py | 5 +- lib/ecowitt/station.py | 9 +-- lib/irrigation/watchdog.py | 2 +- lib/prompt.py | 2 +- tests/test_admin_only.py | 2 +- tests/test_alert_menu.py | 55 +++++++++++-------- tests/test_alerts.py | 31 +++-------- tests/test_ecowitt.py | 2 +- tests/test_irrigation.py | 52 +++++++++--------- tests/test_startup.py | 33 +++++++++++ 21 files changed, 192 insertions(+), 159 deletions(-) rename lib/alerts/{irrigation.py => irrigation_daily_check.py} (94%) rename lib/alerts/{air.py => particulates.py} (84%) rename lib/alerts/{pollen.py => pollen_and_asthma.py} (92%) diff --git a/README.md b/README.md index 9b84031..4d0e13b 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ A Telegram bot for a personal Ecowitt weather station and AirGradient air-quality sensor. Ask it about the weather or air quality (text or charts); it also sends silent alerts to every -chat it is in: rain starting/stopping, rain likely soon, gusts over 40 km/h, UV index of 10 or more, indoor/outdoor temperatures crossing (held back for the cooldown, then announced if it still stands), -and unhealthy air (with when it is safe again). Every alert carries Subscribe / Unsubscribe buttons that expand into the alert types (rain, rain predicted, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes): Subscribe lists the types that are off, Unsubscribe the ones that are on; `/alerts` opens the same settings (in a private chat, a โš™๏ธ Settings button there and under each alert has Enable and Disable notification sounds sub-menus that list the alert types; alerts are silent unless you do; and `/alerts on|off` still subscribes or unsubscribes every type); `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`). +chat it is in: rain starting/stopping, gusts over 40 km/h, UV index of 10 or more, indoor/outdoor temperatures crossing (held back for the cooldown, then announced if it still stands), +and unhealthy air (with when it is safe again). Every alert carries Subscribe / Unsubscribe buttons that expand into the alert types (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes): Subscribe lists the types that are off, Unsubscribe the ones that are on; `/alerts` opens the same settings (in a private chat, a โš™๏ธ Settings button there and under each alert has Enable and Disable notification sounds sub-menus that list the alert types; alerts are silent unless you do; and `/alerts on|off` still subscribes or unsubscribes every type); `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`). ## Run @@ -32,7 +32,7 @@ Under systemd see `ecobot.service` (it loads the environment file; the clone's l | `ADMIN_ONLY` | on by default: the bot answers nobody (private chats and groups alike) except the admins and creators of the groups it is in; anyone else gets "Sorry, only group admins can use this bot." when they address it (ordinary group talk is ignored). The one exception is `/alerts` in a group, which anyone may open (its buttons and `/alerts on|off` stay admin-only). `ADMIN_ONLY=off` answers everyone, as before. The admins are asked of Telegram and remembered for 10 minutes; a group counts once the bot is added to it or someone speaks there | | `ADMIN_CHAT_IDS` | optional, with `ADMIN_ONLY`: more ids allowed besides those admins (added to them, not instead of them), separated by commas or spaces. A user id (the same number as their private chat) lets someone in before the bot is in any group; a group id (negative) adds that group's admins and lets its anonymous admins (those who post "as the group") in | | `ALERT_COOLDOWN_MINUTES` | optional: the rain and temperature-crossing alerts: how long it must stay dry before "the rain has stopped" is sent, and the least time between alerts of the same kind (default 30, from 5 to 150). A change that comes inside the cooldown is held and announced when it ends, if the state still differs from the last one alerted; if it is back where it was, nothing is sent | -| `RAIN_QUIET_HOURS` | optional: local hours with no rain alerts (started, stopped, predicted), as `0-6` (the default; `22-6` wraps midnight; `off` for none). Rain that fell in them is summed up in one message once they end | +| `RAIN_QUIET_HOURS` | optional: local hours with no rain alerts (started, stopped), as `0-6` (the default; `22-6` wraps midnight; `off` for none). Rain that fell in them is summed up in one message once they end | | `CHART_ALL_FEELS_LIKE=on` | optional: "weather all week" (every reading, a panel each, sent with just its title; UV is left out as it has the same shape as solar) also draws the feels-like panel; off by default, as it nearly repeats temperature. Naming either ("plot feels like", "plot solar and uv") always works | | `TUYA_DEVICE_ID`, `TUYA_CLIENT_ID`, `TUYA_CLIENT_SECRET` | enables the irrigation controller (a Tuya smart irrigation timer, through the Tuya cloud API): an Irrigation button in private chats (it replaces Wind) with its status and buttons to water for 5 to 30 minutes or switch the valve off, pause for 24 to 72 hours (the Pause button becomes Unpause while paused), refresh, and an Alerts menu to turn the chat's irrigation battery and irrigation pause alerts on or off (or type irrigation, water, tap or sprinkler); a daily check at 18:00 that delays it 24 hours when more than 1 mm fell in the last 24 hours or Open-Meteo gives today or tomorrow a 50% or better chance of 1 mm (needs Ecowitt and `FORECAST=on` for each half); an "irrigation pause" alert when it does, and an "irrigation battery" alert below 5%. `TUYA_BASE_URL` (default `https://openapi.tuyaeu.com`, the EU data centre) and `IRRIGATION_CHECK_HOUR` (default `18`) are optional | | `CHART_ALL_VPD=on` | optional: "weather all week" also draws the vapour pressure deficit panel; off by default, as it is temperature and humidity combined (the station reports it for the outdoor sensor only). Naming it ("plot vpd") always works | @@ -59,7 +59,7 @@ lib/captions.py when a chart is drawn, and the hint that makes the model lib/ecowitt/ api, store (SQLite + memory), fetch, extremes, query (one history question), link (weather_link), days (rank/count days), outlook (raining / likely soon), station, nightly archive lib/airgradient/ metrics, store (SQLite), source -lib/alerts/ notify (chats, silent send), weather, air, pollen, irrigation (the daily check) +lib/alerts/ notify (chats, silent send), weather, particulates, pollen_and_asthma, irrigation_daily_check (battery and rain delay) lib/irrigation/ the optional Tuya irrigation controller: tuya (signed API client), menu (status text and buttons), watchdog (closes the valve after a run) lib/pollen/, forecast/ the optional website sources: pollen + thunderstorm asthma, Open-Meteo forecast (fetched only 6 am-6 pm, see warm.py SYNC_HOURS) tests/ pytest, against fake Ecowitt/AirGradient/Telegram; tests/evals holds the routing cases, tests/charts the chart reference images diff --git a/RECODE.md b/RECODE.md index 744e8e6..778df08 100644 --- a/RECODE.md +++ b/RECODE.md @@ -24,7 +24,7 @@ 88 lib/airgradient/metrics.py 83 lib/ecowitt/direction.py 71 lib/airgradient/store.py - 59 lib/alerts/air.py + 59 lib/alerts/particulates.py 58 lib/config.py 54 lib/tools.py 51 lib/warm.py diff --git a/ecobot.py b/ecobot.py index adef3f4..62fcdce 100644 --- a/ecobot.py +++ b/ecobot.py @@ -22,8 +22,9 @@ from lib import intent from lib.airgradient import AirGradient -from lib.alerts import AIR_CHECK_SECONDS, AirMonitor, AlertState, ForecastMonitor, IrrigationMonitor, Notifier, PollenMonitor, WeatherMonitor -from lib.alerts.menu import available_kinds +from lib.alerts import (PARTICULATES_CHECK_SECONDS, AlertState, ForecastMonitor, IrrigationDailyCheck, Notifier, ParticulatesMonitor, + PollenAndAsthmaMonitor, WeatherMonitor) +from lib.alerts.menu import LABELS, kinds_for from lib.bot import Bot, polling_error from lib.ecowitt import Archive, Ecowitt from lib.forecast import Forecast @@ -85,6 +86,19 @@ def commit_in(git_dir) -> str: return "unknown" +def wrapped(head: str, items: list[str], tail: str = "", width: int = 90) -> str: + """head + the items joined by ", " + tail, over several lines when it is longer than `width`: a line breaks only between items, + and the next one is indented.""" + lines, line = [], head + for i, item in enumerate(items): + piece = item + (", " if i < len(items) - 1 else tail) + if line.strip() != head.strip() and len(line + piece.rstrip()) > width: + lines.append(line.rstrip()) + line = " " + line += piece + return "\n".join([*lines, line]) + + async def main(): log.info("Starting ecobot %s", version()) cfg = Config.from_env() @@ -132,30 +146,29 @@ async def main(): try: # any startup failure below still runs the shutdown steps (and shows the real error) log.info("@%s + %s (sources: %s)", app.bot.username, cfg.xai_model, ", ".join(s.name for s in sources)) - notify = Notifier(app.bot, state, available_kinds({s.name for s in sources} | ({"Irrigation"} if irrigation else set()))) + kinds = kinds_for((s.name for s in sources), irrigation is not None) + notify = Notifier(app.bot, state, kinds) await bot.refresh_keyboards(app.bot) # chats whose buttons are out of date are told, with the new ones # Alerts - kinds = [] + running: list[str] = [] # the alert types whose monitors are hooked up (the weather ones once they have started) if eco: monitor = WeatherMonitor(eco, state, notify, cfg.alert_cooldown_minutes * 60, cfg.rain_quiet_hours) - kinds += ["rain", "rain likely", "temp. crossing"] if air: - air_monitor = AirMonitor(air, state, notify) - tasks.append(asyncio.create_task(every(AIR_CHECK_SECONDS, air_monitor.check))) - kinds.append("air") + particulates_monitor = ParticulatesMonitor(air, state, notify) + tasks.append(asyncio.create_task(every(PARTICULATES_CHECK_SECONDS, particulates_monitor.check))) + running += ParticulatesMonitor.KINDS if forecast: forecast.warmer.after.append(ForecastMonitor(forecast, state, notify).check) # after each refresh (in the day) - kinds.append("forecast changes") + running += ForecastMonitor.KINDS if pollen: - pollen.warmer.after.append(PollenMonitor(pollen, state, notify).check) # after each refresh (in the day) - kinds += ["pollen", "asthma"] + pollen.warmer.after.append(PollenAndAsthmaMonitor(pollen, state, notify).check) # after each refresh (in the day) + running += PollenAndAsthmaMonitor.KINDS if irrigation: # the daily check (rain delay, battery) and the watchdog that closes the valve after a run - tasks.append(asyncio.create_task(IrrigationMonitor(irrigation, state, notify, cfg.tz, cfg.irrigation_check_hour, - eco, forecast).run())) + tasks.append(asyncio.create_task(IrrigationDailyCheck(irrigation, state, notify, cfg.tz, cfg.irrigation_check_hour, + eco, forecast).run())) tasks.append(asyncio.create_task(every(WATCHDOG_SECONDS, bot.watchdog.check))) - kinds.append("irrigation battery") - log.info("Alerts: %s: %d chats", ", ".join(kinds) or "none", len(state.alert_chats())) + running += IrrigationDailyCheck.KINDS # Keeping warm: everything questions need, refreshed before they arrive tasks.extend(asyncio.create_task(s.warmer.run()) for s in sources) @@ -173,6 +186,7 @@ async def ecowitt_part(): try: await monitor.init() eco.warmer.after.append(monitor.check) # checked on every fresh refresh + running[:0] = WeatherMonitor.KINDS await monitor.check() except Exception: log.exception("Weather alerts couldn't start") @@ -196,6 +210,10 @@ async def air_backfill(): log.exception("Startup warm-up failed (the regular refreshes will catch up)") log.info("Startup warm-up: %s, %.1fs", ", ".join(parts) or "nothing to fetch", time.monotonic() - started) + on = [k for k in LABELS if k in running] # the line says what started, not what is configured + log.info("%s", wrapped("Alerts: ", [LABELS[k] for k in on] or ["none"], f": {len(state.alert_chats())} chats")) + if missing := [LABELS[k] for k in kinds if k not in running]: + log.warning("Alerts NOT running: %s (see the error above)", ", ".join(missing)) tasks.append(asyncio.create_task(startup_warmup())) await stop.wait() diff --git a/lib/alerts/__init__.py b/lib/alerts/__init__.py index 1f19430..0347a20 100644 --- a/lib/alerts/__init__.py +++ b/lib/alerts/__init__.py @@ -1,8 +1,8 @@ -from .air import CHECK_SECONDS as AIR_CHECK_SECONDS, AirMonitor +from .particulates import CHECK_SECONDS as PARTICULATES_CHECK_SECONDS, ParticulatesMonitor from .forecast import ForecastMonitor -from .irrigation import IrrigationMonitor +from .irrigation_daily_check import IrrigationDailyCheck from .notify import AlertState, Notifier, with_footer -from .pollen import PollenMonitor +from .pollen_and_asthma import PollenAndAsthmaMonitor from .weather import WeatherMonitor -__all__ = ["AIR_CHECK_SECONDS", "AirMonitor", "AlertState", "ForecastMonitor", "IrrigationMonitor", "Notifier", "PollenMonitor", "WeatherMonitor", "with_footer"] +__all__ = ["PARTICULATES_CHECK_SECONDS", "ParticulatesMonitor", "AlertState", "ForecastMonitor", "IrrigationDailyCheck", "Notifier", "PollenAndAsthmaMonitor", "WeatherMonitor", "with_footer"] diff --git a/lib/alerts/forecast.py b/lib/alerts/forecast.py index dd8f27b..958f6a7 100644 --- a/lib/alerts/forecast.py +++ b/lib/alerts/forecast.py @@ -33,6 +33,8 @@ def revised(old: dict, new: dict) -> bool: class ForecastMonitor: + KINDS = ("forecast",) + def __init__(self, forecast, state, notify): """notify: the Notifier (its to_chat sends to one chat).""" self.forecast, self.state, self.notify = forecast, state, notify diff --git a/lib/alerts/irrigation.py b/lib/alerts/irrigation_daily_check.py similarity index 94% rename from lib/alerts/irrigation.py rename to lib/alerts/irrigation_daily_check.py index bcddb6c..3d36ea6 100644 --- a/lib/alerts/irrigation.py +++ b/lib/alerts/irrigation_daily_check.py @@ -1,6 +1,6 @@ """The irrigation controller, checked once a day (18:00 local by default). - - a battery below 5% is told to the chats subscribed to "irrigation battery" (at most once a day, until it is charged); + - a battery below 5% is told to the chats subscribed to "irrigation battery" (kind irrigation_battery) (at most once a day, until it is charged); - if more than 1 mm of rain fell in the last 24 hours, or Open-Meteo gives today or tomorrow a 50% or better chance of at least 1 mm of rain, the controller is put on a 24 hour weather delay (it skips its own schedule) and the chats subscribed to "irrigation pause" are told why. A longer delay already set is left alone (nothing changes, so nothing is sent). @@ -25,14 +25,16 @@ MEASURED_HOURS = 24 -class IrrigationMonitor: +class IrrigationDailyCheck: + KINDS = ("irrigation_battery", "irrigation_pause") + def __init__(self, device, state, notify, tz, hour: int = 18, station=None, forecast=None): """station: the Ecowitt source (its recent() gives the measured rain); forecast: the Forecast source. Either may be None.""" self.device, self.state, self.notify, self.tz, self.hour = device, state, notify, tz, hour self.station, self.forecast = station, forecast def _memory(self) -> dict: - return self.state.monitor.setdefault("irrigation", {}) + return self.state.monitor.setdefault("irrigation_daily_check", {}) def due(self, now: datetime) -> bool: """Is today's check still to be done (it is past the check time and none is recorded for today)?""" @@ -71,7 +73,7 @@ async def check(self, now: datetime | None = None): memory["battery_warned"] = today self.state.save() await self.notify(f"๐Ÿชซ The irrigation battery is at {battery}%. Replace or recharge it soon, or watering will stop.", - kind="irrigation") + kind="irrigation_battery") reason = await self.rain_reason() if reason: current = status.get("weather_delay") diff --git a/lib/alerts/menu.py b/lib/alerts/menu.py index b5b7605..1825e14 100644 --- a/lib/alerts/menu.py +++ b/lib/alerts/menu.py @@ -17,12 +17,12 @@ from ..buttons import menu_text # kind -> the name on the button, in the order listed -LABELS = {"rain": "rain", "rain_likely": "rain predicted", "gusts": "gusts", "uv": "UV", "temps": "temperature crossing", - "air": "particulates", "pollen": "pollen & asthma", "forecast": "forecast changes", - "irrigation": "irrigation battery", "irrigation_pause": "irrigation pause"} +LABELS = {"rain": "rain", "gusts": "gusts", "uv": "UV", "temps": "temperature crossing", + "particulates": "particulates", "pollen_and_asthma": "pollen & asthma", "forecast": "forecast changes", + "irrigation_battery": "irrigation battery", "irrigation_pause": "irrigation pause"} # the emoji on each type's button: the one its own alerts start with (every type has one: a test holds the two lists together) -EMOJI = {"rain": "\U0001f327๏ธ", "rain_likely": "\U0001f326๏ธ", "gusts": "\U0001f4a8", "uv": "\U0001f9f4", "temps": "\U0001f321๏ธ", - "air": "\U0001f637", "pollen": "\U0001f33c", "forecast": "\U0001f504", "irrigation": "\U0001faab", "irrigation_pause": "โ˜”"} +EMOJI = {"rain": "\U0001f327๏ธ", "gusts": "\U0001f4a8", "uv": "\U0001f9f4", "temps": "\U0001f321๏ธ", + "particulates": "\U0001f637", "pollen_and_asthma": "\U0001f33c", "forecast": "\U0001f504", "irrigation_battery": "\U0001faab", "irrigation_pause": "โ˜”"} ALL = "all" TITLE = "๐Ÿ”” Alerts in this chat" PER_ROW = 2 @@ -31,12 +31,17 @@ def available_kinds(sources: set[str]) -> list[str]: """The alert types the bot can send, from the names of its sources.""" - wanted = (["rain", "rain_likely", "gusts", "uv", "temps"] if "Ecowitt" in sources else []) + ( - ["air"] if "AirGradient" in sources else []) + (["pollen"] if "Pollen" in sources else []) + ( - ["forecast"] if "Forecast" in sources else []) + (["irrigation", "irrigation_pause"] if "Irrigation" in sources else []) + wanted = (["rain", "gusts", "uv", "temps"] if "Ecowitt" in sources else []) + ( + ["particulates"] if "AirGradient" in sources else []) + (["pollen_and_asthma"] if "Pollen" in sources else []) + ( + ["forecast"] if "Forecast" in sources else []) + (["irrigation_battery", "irrigation_pause"] if "Irrigation" in sources else []) return wanted +def kinds_for(source_names, irrigation: bool) -> list[str]: + """The alert types the bot sends, from the names of its sources and whether an irrigation controller is configured.""" + return available_kinds(set(source_names) | ({"Irrigation"} if irrigation else set())) + + def label(kind: str) -> str: """The name of an alert type on its button: its emoji, then its name.""" return f"{EMOJI[kind]} {LABELS[kind]}" diff --git a/lib/alerts/air.py b/lib/alerts/particulates.py similarity index 84% rename from lib/alerts/air.py rename to lib/alerts/particulates.py index 5025625..db021c8 100644 --- a/lib/alerts/air.py +++ b/lib/alerts/particulates.py @@ -1,4 +1,4 @@ -"""Air-quality alerts from the AirGradient sensor, checked every 30 minutes. +"""Particulates alerts (PM2.5 and PM10) from the AirGradient sensor, checked every 30 minutes. "Wear a mask outside" when PM2.5 or PM10 reaches US AQI 151+ ("unhealthy", the official level at which masks are advised for everyone) for two checks in a row; "back to safe" when both are at @@ -18,17 +18,19 @@ STALE_SECONDS = 2 * 3600 # ignore readings older than this (sensor offline) -class AirMonitor: +class ParticulatesMonitor: + KINDS = ("particulates",) + def __init__(self, air, state, notify): self.air, self.state, self.notify = air, state, notify async def check(self, reading: dict | None = None, now: datetime | None = None): reading = reading if reading is not None else await self.air.current() now = now or datetime.now(self.air.tz) - m = self.state.monitor.setdefault("air", {"unsafe": False, "above": 0, "below": 0}) + m = self.state.monitor.setdefault("particulates", {"unsafe": False, "above": 0, "below": 0}) when = reading.get("_time_utc") if when and (now - when).total_seconds() > STALE_SECONDS: - log.info("Air quality: latest reading is stale (%s); skipping", when) + log.info("Particulates: latest reading is stale (%s); skipping", when) return pm25 = (reading.get("pm2_5") or {}).get("value") pm10 = (reading.get("pm10") or {}).get("value") @@ -41,10 +43,10 @@ async def check(self, reading: dict | None = None, now: datetime | None = None): if not m["unsafe"] and m["above"] >= CONFIRM_CHECKS: m["unsafe"] = True await self.notify("\U0001f637 Unhealthy air outside โ€” wear a P2/N95 mask today. " + self._levels(reading), - link=self.air.link, kind="air") + link=self.air.link, kind="particulates") elif m["unsafe"] and m["below"] >= CONFIRM_CHECKS: m["unsafe"] = False - await self.notify("โœ… Outdoor air is safe again. " + self._levels(reading), link=self.air.link, kind="air") + await self.notify("โœ… Outdoor air is safe again. " + self._levels(reading), link=self.air.link, kind="particulates") self.state.save() @staticmethod diff --git a/lib/alerts/pollen.py b/lib/alerts/pollen_and_asthma.py similarity index 92% rename from lib/alerts/pollen.py rename to lib/alerts/pollen_and_asthma.py index 5cef778..ab5f81a 100644 --- a/lib/alerts/pollen.py +++ b/lib/alerts/pollen_and_asthma.py @@ -20,7 +20,9 @@ LINK = ("melbournepollen.com.au", "https://www.melbournepollen.com.au/") -class PollenMonitor: +class PollenAndAsthmaMonitor: + KINDS = ("pollen_and_asthma",) + def __init__(self, pollen, state, notify): self.pollen, self.state, self.notify = pollen, state, notify @@ -30,7 +32,7 @@ async def check(self): cur = self.pollen.current() if not cur["fetched_at"] or time.time() - cur["fetched_at"] > STALE_SECONDS: return - m = self.state.monitor.setdefault("pollen", {}) + m = self.state.monitor.setdefault("pollen_and_asthma", {}) today = self.pollen.now().date() for key in ("grass", "asthma"): reading = cur[key] @@ -45,5 +47,5 @@ async def check(self): m[key] = {"rank": rank, "day": day} for_day = reading.get("date") when = f" (forecast for {for_day:%a %d %b})" if for_day and for_day != today else "" - await self.notify(f"{LEVEL_EMOJI[reading['level']]} {LABELS[key]} is {reading['level']}{when}.{ADVICE[key]}", link=LINK, kind="pollen") + await self.notify(f"{LEVEL_EMOJI[reading['level']]} {LABELS[key]} is {reading['level']}{when}.{ADVICE[key]}", link=LINK, kind="pollen_and_asthma") self.state.save() diff --git a/lib/alerts/weather.py b/lib/alerts/weather.py index f6895b6..c67bf65 100644 --- a/lib/alerts/weather.py +++ b/lib/alerts/weather.py @@ -4,8 +4,6 @@ - Rain: "started" (one tip of the gauge is enough) and "stopped" (dry for ALERT_COOLDOWN_MINUTES), with how much fell. Not during quiet hours (RAIN_QUIET_HOURS, default midnight to 6am): those are kept quiet and one summary of the overnight rain is sent after they end. - - Rain likely soon: pressure falling over 3 hours plus arriving moisture, scored, tuned for - Melbourne (see assess_rain). At most once every 6 hours. - Strong gusts: one alert when a gust goes over 40 km/h, and no more until the gusts have stayed at or under it for an hour, so a blustery afternoon is one message, not twenty. - Strong sun: one alert when the UV index reaches 10, and no more until it has stayed below 10 for an hour. @@ -19,13 +17,12 @@ from ..ecowitt.glance import UVI_ALERT from .backoff import due, remember -from ..ecowitt.outlook import PREDICT_MIN_SCORE, Rows, assess_rain, duration, rain_amount, wet_flags +from ..ecowitt.outlook import Rows, duration, rain_amount, wet_flags from ..timeutil import to_local log = logging.getLogger(__name__) ALERT_COOLDOWN_SECONDS = 30 * 60 # the default; ALERT_COOLDOWN_MINUTES sets it (the readings looked at go back 3 hours, so 150 minutes is the most) -PREDICT_EVERY_SECONDS = 6 * 3600 CROSS_MARGIN = 0.3 GUST_ALERT_KMH = 40 GUST_REARM_SECONDS = 3600 @@ -41,6 +38,8 @@ def side(r: dict) -> str | None: class WeatherMonitor: + KINDS = ("rain", "gusts", "uv", "temps") + def __init__(self, station, state, notify, cooldown_seconds: int = ALERT_COOLDOWN_SECONDS, quiet: tuple[int, int] | None = None): self.station, self.state, self.notify = station, state, notify self.cooldown = cooldown_seconds @@ -85,7 +84,6 @@ async def check(self): live = await self.station.live_rain() # newer than the 5-minute history by up to 5 minutes await self._rain(rows + [live] if live and live[0] > rows[-1][0] else rows) await self._night_summary(rows) - await self._rain_likely(rows) await self._gusts(rows) await self._uv(rows) await self._cross(rows) @@ -156,27 +154,6 @@ async def _night_summary(self, rows: Rows): still = " It's still raining." if self.state.monitor.get("rain", {}).get("alerted_state") == "raining" else "" await self.notify(f"\U0001f327๏ธ Overnight rain: {amount}, from about {clock(night['first'])} to {clock(night['last'] + 300)}.{still}", kind="rain") - async def _rain_likely(self, rows: Rows): - """Not while raining or within an hour of rain; at most every 6 hours.""" - m = self.state.monitor.setdefault("predict", {"last": 0}) - latest_ts = rows[-1][0] - if self._quiet(latest_ts): - return - if self.state.monitor.get("rain", {}).get("alerted_state") == "raining" or any( - w for ts, w, _ in wet_flags(rows) if latest_ts - ts < 3600): - return - if latest_ts - m.get("last", 0) < PREDICT_EVERY_SECONDS: - return - outlook = assess_rain(rows, self.tz, self.station.longitude) - if outlook is None: - return - log.info("Rain-likely check: pressure fall %.1f hPa (%.1f beyond the tide), %s, score %d", - outlook.raw_drop, outlook.drop, "night" if outlook.night else "day", outlook.score) - if outlook.score >= PREDICT_MIN_SCORE: - m["last"] = latest_ts - await self.notify("\U0001f326๏ธ Rain looks likely soon: " + ", ".join(outlook.reasons[:3]) + - ". (An estimate from the station's readings, not an official forecast.)", kind="rain_likely") - async def _gusts(self, rows: Rows): """Only readings since the last check count, so a restart doesn't re-announce an old gust.""" m = self.state.monitor.setdefault("gust", {"active": False, "calm_since": None}) diff --git a/lib/bot.py b/lib/bot.py index 8068f09..60999ab 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -344,7 +344,7 @@ async def on_membership(self, update: Update, context: ContextTypes.DEFAULT_TYPE self.state.remove_chat(change.chat.id, f"bot {status}") def _alert_kinds(self) -> list[str]: - return menu.available_kinds(set(self.by_name) | ({"Irrigation"} if self.irrigation else set())) + return menu.kinds_for(self.by_name, bool(self.irrigation)) async def on_alerts(self, update: Update, context: ContextTypes.DEFAULT_TYPE): """/alerts: the alert settings, as buttons (Subscribe | Unsubscribe). /alerts on and /alerts off still subscribe or @@ -443,8 +443,8 @@ async def on_alert_button(self, update: Update, context: ContextTypes.DEFAULT_TY raise # ---------- irrigation ---------- - def _irrigation_alerts(self, chat_id: int, kind: str = "irrigation") -> bool: - """Does this chat get the irrigation battery alerts (kind "irrigation") or the pause alerts ("irrigation_pause")?""" + def _irrigation_alerts(self, chat_id: int, kind: str = "irrigation_battery") -> bool: + """Does this chat get the irrigation battery alerts (kind "irrigation_battery") or the pause alerts ("irrigation_pause")?""" return bool(self.state and self.state.subscribed(chat_id, kind)) def _irrigation_keyboard(self, chat_id: int, opened: str | None = None, paused: bool = False, running: bool = False): @@ -479,7 +479,7 @@ async def _irrigation_act(self, action: str, arg: str, chat_id: int) -> str | No self.watchdog.disarm() return "Switched off" if action in ("batt", "pause") and arg in ("on", "off") and self.state and chat_id in self.state.chats: - self.state.set_kind(chat_id, "irrigation" if action == "batt" else "irrigation_pause", arg == "on") + self.state.set_kind(chat_id, "irrigation_battery" if action == "batt" else "irrigation_pause", arg == "on") return f"Irrigation {'battery' if action == 'batt' else 'pause'} alerts {arg} in this chat" return None diff --git a/lib/ecowitt/outlook.py b/lib/ecowitt/outlook.py index 304243a..93a5854 100644 --- a/lib/ecowitt/outlook.py +++ b/lib/ecowitt/outlook.py @@ -1,5 +1,4 @@ -"""Is it raining, or likely to soon? Read from the station's recent 5-minute readings, by one set of rules for the alerts -and the status report.""" +"""Is it raining, or likely to soon? Read from the station's recent 5-minute readings (for the status report and the rain alerts).""" import math from datetime import datetime, UTC @@ -113,7 +112,7 @@ def assess_rain(rows: Rows, tz, longitude: float) -> Outlook | None: def rain_outlook(rows: Rows, tz, longitude: float) -> str | None: - """"Raining now" or "rain likely soon", in words for a status report, by the same rules as the alerts; None when neither.""" + """"Raining now" or "rain likely soon", in words for a status report; None when neither.""" wet = wet_flags(rows) if any(w for _, w, _ in wet[-2:]): rate = max(r for _, w, r in wet[-2:] if w) diff --git a/lib/ecowitt/station.py b/lib/ecowitt/station.py index 94aa598..5a55ab4 100644 --- a/lib/ecowitt/station.py +++ b/lib/ecowitt/station.py @@ -11,7 +11,7 @@ from ..config import Config from ..intent import NOW_GROUPS -from ..timeutil import now_local, to_local +from ..timeutil import now_local from .outlook import rain_outlook from ..tools import Tool, Turn from ..warm import FAST_REFRESH_SECONDS, Warmer @@ -142,10 +142,7 @@ async def start(self): f", devices found: {len(devices)}" if len(devices) > 1 else "") try: size = os.path.getsize(self.cache.path) if os.path.exists(self.cache.path) else 0 - newest = {c: (f"{to_local(t, self.tz):%d %b %H:%M}" if (t := await asyncio.to_thread(self.cache.newest, self.mac, c)) else "none") - for c in ("5min", "30min", "4hour", "1day")} - log.info("History cache: %s (%.1f MB), newest reading %s, time zone %s", self.cache.path, size / 1e6, - " / ".join(f"{c} {v}" for c, v in newest.items()), getattr(self.tz, "key", self.tz)) + log.info("History cache: %s (%.1f MB)", self.cache.path, size / 1e6) except Exception as e: # only a log line log.warning("History cache not readable: %s", e) @@ -229,7 +226,7 @@ async def live_rain(self) -> tuple[int, dict] | None: return ts, row async def _rain_outlook(self) -> str | None: - """Raining now, or likely soon (the same rules as the alerts), from the last 3 hours of readings.""" + """Raining now, or likely soon, from the last 3 hours of readings.""" try: return rain_outlook(await self.recent(3), self.tz, self.longitude) except Exception as e: # the current reading is still worth sending without it diff --git a/lib/irrigation/watchdog.py b/lib/irrigation/watchdog.py index 041be3c..92ff8e0 100644 --- a/lib/irrigation/watchdog.py +++ b/lib/irrigation/watchdog.py @@ -21,7 +21,7 @@ def __init__(self, device: Tuya, state, clock=time.time): self.device, self.state, self.clock = device, state, clock def _memory(self) -> dict: - return self.state.monitor.setdefault("irrigation", {}) + return self.state.monitor.setdefault("irrigation_watchdog", {}) def arm(self, minutes: int): """A run of this many minutes has started.""" diff --git a/lib/prompt.py b/lib/prompt.py index 356fbed..1432f03 100644 --- a/lib/prompt.py +++ b/lib/prompt.py @@ -190,7 +190,7 @@ def capabilities(sources: list[str], brief: bool = False) -> str: lines.append("- Charts: any one reading, or several readings together on one time axis, one panel each (" + ", ".join(WEATHER_NAMES) + "; \"weather all week\" draws every reading); wind as average speed with gusts beside a compass rose of directions; " "air quality with ratings.") - lines.append("- Alerts, sent to chats automatically: rain starting or stopping, rain likely soon, wind gusts over 40 km/h, UV index of 10 or more, " + lines.append("- Alerts, sent to chats automatically: rain starting or stopping, wind gusts over 40 km/h, UV index of 10 or more, " "indoor/outdoor temperature crossing, air-quality mask alerts" + (", pollen or thunderstorm asthma risk reaching High" if "melbournepollen" in have else "") + ( ", a forecast it sent being revised (rain, or max temperature over 2 degrees)" if "Open-Meteo" in have else "") + ". Each alert has buttons to subscribe or unsubscribe by type, and /alerts opens the same settings. Custom alerts " "(\"tell me when winds reach 100\", another limit) can't be added: say so.") diff --git a/tests/test_admin_only.py b/tests/test_admin_only.py index b1783ad..7806e5c 100644 --- a/tests/test_admin_only.py +++ b/tests/test_admin_only.py @@ -319,7 +319,7 @@ async def test_alert_buttons_in_a_group_stay_admin_only_even_when_admin_only_is_ def every_keyboard(bot): """Every inline keyboard the bot can draw, in every state.""" kinds = menu.available_kinds({"Ecowitt", "AirGradient", "Pollen", "Forecast", "Irrigation"}) - muted_sets = [set(), {"rain"}, {"rain", "uv", "air"}, set(menu.LABELS)] + muted_sets = [set(), {"rain"}, {"rain", "uv", "particulates"}, set(menu.LABELS)] for muted in muted_sets: for opened in (None, "sub", "unsub"): for parent in (None, *kinds): diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index 3c8872a..cb81969 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -8,7 +8,7 @@ from lib.bot import Bot from tests.fakes import TZ -KINDS = ["rain", "rain_likely", "gusts", "uv", "temps", "air", "pollen", "forecast"] +KINDS = ["rain", "gusts", "uv", "temps", "particulates", "pollen_and_asthma", "forecast"] def rows(markup): @@ -20,30 +20,30 @@ def test_the_collapsed_menu_is_subscribe_and_unsubscribe(): def test_unsubscribe_opens_the_types_that_are_on_and_subscribe_the_types_that_are_off(): - got = rows(keyboard({"pollen", "forecast"}, KINDS, "unsub")) + got = rows(keyboard({"pollen_and_asthma", "forecast"}, KINDS, "unsub")) assert got[0] == [("โž• Sub โ–ธ", "al:open:sub"), ("โž– Unsub โ–พ", "al:close")] - assert got[1] == [("๐ŸŒง๏ธ rain", "al:off:rain:unsub"), ("๐ŸŒฆ๏ธ rain predicted", "al:off:rain_likely:unsub")] - assert [text for row in got[1:-1] for text, _ in row] == ["๐ŸŒง๏ธ rain", "๐ŸŒฆ๏ธ rain predicted", "๐Ÿ’จ gusts", "๐Ÿงด UV", "๐ŸŒก๏ธ temperature crossing", "๐Ÿ˜ท particulates"] + assert got[1] == [("๐ŸŒง๏ธ rain", "al:off:rain:unsub"), ("๐Ÿ’จ gusts", "al:off:gusts:unsub")] + assert [text for row in got[1:-1] for text, _ in row] == ["๐ŸŒง๏ธ rain", "๐Ÿ’จ gusts", "๐Ÿงด UV", "๐ŸŒก๏ธ temperature crossing", "๐Ÿ˜ท particulates"] assert got[-1] == [("๐Ÿ”• Unsubscribe from all", "al:off:all:unsub")] - got = rows(keyboard({"pollen", "forecast"}, KINDS, "sub")) + got = rows(keyboard({"pollen_and_asthma", "forecast"}, KINDS, "sub")) assert got[0][0] == ("โž• Sub โ–พ", "al:close") - assert got[1:] == [[("๐ŸŒผ pollen & asthma", "al:on:pollen:sub"), ("๐Ÿ”„ forecast changes", "al:on:forecast:sub")], [("๐Ÿ”” Subscribe to all", "al:on:all:sub")]] + assert got[1:] == [[("๐ŸŒผ pollen & asthma", "al:on:pollen_and_asthma:sub"), ("๐Ÿ”„ forecast changes", "al:on:forecast:sub")], [("๐Ÿ”” Subscribe to all", "al:on:all:sub")]] assert not any(data == "al:noop" for row in got for _, data in row) # no button that does nothing - assert options({"pollen"}, KINDS, "sub") == ["pollen"] and options({"pollen"}, ["rain", "pollen"], "unsub") == ["rain"] + assert options({"pollen_and_asthma"}, KINDS, "sub") == ["pollen_and_asthma"] and options({"pollen_and_asthma"}, ["rain", "pollen_and_asthma"], "unsub") == ["rain"] assert len(rows(keyboard(set(), KINDS, "sub"))) == 1 # nothing off: nothing listed def test_only_the_types_the_bot_has_are_listed_and_every_callback_is_short(): - assert available_kinds({"Ecowitt"}) == ["rain", "rain_likely", "gusts", "uv", "temps"] - assert available_kinds({"Ecowitt", "AirGradient", "Pollen", "Forecast"}) == KINDS and available_kinds({"AirGradient"}) == ["air"] + assert available_kinds({"Ecowitt"}) == ["rain", "gusts", "uv", "temps"] + assert available_kinds({"Ecowitt", "AirGradient", "Pollen", "Forecast"}) == KINDS and available_kinds({"AirGradient"}) == ["particulates"] for section in (None, "sub", "unsub"): for text, data in (b for row in rows(keyboard({"uv"}, KINDS, section)) for b in row): assert len(data.encode()) <= 64 def test_the_title_lists_what_is_on_and_off(): - assert title({"gusts", "uv"}, KINDS[:5]) == f"{TITLE}\nโœ… On: rain, rain predicted, temperature crossing\n๐Ÿ”• Off: gusts, UV" - assert title(set(), ["air"]).endswith("๐Ÿ”• Off: nothing") and title({"air"}, ["air"]).endswith("โœ… On: nothing\n๐Ÿ”• Off: particulates") + assert title({"gusts", "uv"}, KINDS[:4]) == f"{TITLE}\nโœ… On: rain, temperature crossing\n๐Ÿ”• Off: gusts, UV" + assert title(set(), ["particulates"]).endswith("๐Ÿ”• Off: nothing") and title({"particulates"}, ["particulates"]).endswith("โœ… On: nothing\n๐Ÿ”• Off: particulates") def test_state_mutes_one_type_or_all_and_old_files_load(tmp_path): @@ -204,7 +204,7 @@ async def reply_text(body, **kw): chat = NS(type="private", id=1, title=None) update = NS(effective_message=NS(reply_text=reply_text, chat_id=1), effective_chat=chat, effective_user=NS(full_name="Rob", username="rob")) await bot.on_alerts(update, NS(args=[])) - assert replies[0][0].startswith(TITLE) and "โœ… On: rain, rain predicted, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes" in replies[0][0] + assert replies[0][0].startswith(TITLE) and "โœ… On: rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes" in replies[0][0] assert rows(replies[0][1]["reply_markup"])[0] == [("โž• Sub โ–ธ", "al:open:sub"), ("โž– Unsub โ–ธ", "al:open:unsub"), ("โš™๏ธ Settings โ–ธ", "al:open:set")] await bot.on_alerts(update, NS(args=["off"])) assert not state.alerts_on(1) and "โœ… On: nothing" in replies[1][0] @@ -213,7 +213,7 @@ async def reply_text(body, **kw): async def test_every_monitors_alert_names_its_type(tmp_path): - from lib.alerts import AirMonitor, ForecastMonitor, PollenMonitor, WeatherMonitor + from lib.alerts import ParticulatesMonitor, ForecastMonitor, PollenAndAsthmaMonitor, WeatherMonitor from tests.test_alerts import FakePollen, FakeStation, air_reading, gusts, rain, uv from tests.test_forecast_alert import FakeForecast, day state = AlertState(tmp_path / "s.json") @@ -234,15 +234,15 @@ async def weather(first, then): await weather(uv(3, 5), uv(3, 5, 10)) assert kinds == ["rain", "rain", "gusts", "uv"] kinds.clear() - mask = AirMonitor(NS(tz=TZ, link=None), state, notify) + mask = ParticulatesMonitor(NS(tz=TZ, link=None), state, notify) for pm in (80, 80, 20, 20): await mask.check(air_reading(pm)) - assert kinds == ["air", "air"] + assert kinds == ["particulates", "particulates"] kinds.clear() pollen = FakePollen() pollen.grass = "High" - await PollenMonitor(pollen, state, notify).check() - assert kinds == ["pollen"] + await PollenAndAsthmaMonitor(pollen, state, notify).check() + assert kinds == ["pollen_and_asthma"] seen = [] notifier = Notifier(Sent(), state) real = notifier.to_chat @@ -257,10 +257,10 @@ async def spy(chat_id, text, link=None, kind=None): def test_under_an_alert_the_unsubscribe_list_is_its_type_then_other_which_holds_the_rest_and_all(): - got = rows(keyboard({"pollen", "forecast"}, KINDS, "unsub", parent="uv")) + got = rows(keyboard({"pollen_and_asthma", "forecast"}, KINDS, "unsub", parent="uv")) assert got == [[("โž• Sub โ–ธ", "al:open:sub:uv"), ("โž– Unsub โ–พ", "al:close:uv")], [("โ— ๐Ÿงด UV", "al:off:uv:unsub:uv")], [("Other โ–ธ", "al:open:other:uv")]] - other = rows(keyboard({"pollen", "forecast"}, KINDS, "other", parent="uv")) + other = rows(keyboard({"pollen_and_asthma", "forecast"}, KINDS, "other", parent="uv")) assert other[1:3] == [[("โ— ๐Ÿงด UV", "al:off:uv:other:uv")], [("Other โ–พ", "al:open:unsub:uv")]] listed = [d for r in other[3:-1] for _, d in r] assert listed and all(d.startswith("al:off:") and d.endswith(":other:uv") and ":uv:" not in d for d in listed) # the rest, not its own type @@ -271,13 +271,13 @@ def test_under_an_alert_the_unsubscribe_list_is_its_type_then_other_which_holds_ gone = rows(keyboard({"uv", "gusts"}, KINDS, "unsub", parent="uv")) # its type is already off: a plain list of the rest, with "all" assert "Other" not in str(gone) and gone[-1] == [("๐Ÿ”• Unsubscribe from all", "al:off:all:unsub:uv")] assert len(rows(keyboard(set(KINDS), KINDS, "unsub", parent="uv"))) == 1 # nothing is on at all: nothing listed - sub = rows(keyboard({"pollen", "forecast"}, KINDS, "sub", parent="uv")) # Subscribe is the types that are off, as ever + sub = rows(keyboard({"pollen_and_asthma", "forecast"}, KINDS, "sub", parent="uv")) # Subscribe is the types that are off, as ever assert [t for row in sub[1:-1] for t, _ in row] == ["๐ŸŒผ pollen & asthma", "๐Ÿ”„ forecast changes"] and sub[-1][0][0] == "๐Ÿ”” Subscribe to all" assert rows(keyboard(set(), KINDS, "unsub"))[1][0][0] == "๐ŸŒง๏ธ rain" # the /alerts message has no parent: every type that is on def test_the_all_button_is_only_there_when_the_list_has_more_than_one_item(): - assert not any("all" in data for row in rows(keyboard({"uv", "gusts", "rain", "rain_likely", "temps", "air", "pollen"}, KINDS, "unsub")) + assert not any("all" in data for row in rows(keyboard({"uv", "gusts", "rain", "temps", "particulates", "pollen_and_asthma"}, KINDS, "unsub")) for _, data in row) # one type on (forecast changes): no "Unsubscribe from all" assert not any("all" in data for row in rows(keyboard({"uv"}, KINDS, "sub")) for _, data in row) # one type off: no "Subscribe to all" assert rows(keyboard({"uv", "gusts"}, KINDS, "sub"))[-1][0][0] == "๐Ÿ”” Subscribe to all" @@ -320,7 +320,7 @@ def test_every_alert_type_has_its_emoji_and_every_button_in_the_lists_starts_wit from lib.alerts.menu import EMOJI, label assert set(EMOJI) == set(LABELS) # a new type cannot be added without one assert len(set(EMOJI.values())) == len(EMOJI) and all(not e.isascii() for e in EMOJI.values()) # all different, all emoji - assert label("rain") == "๐ŸŒง๏ธ rain" and label("irrigation") == "๐Ÿชซ irrigation battery" and label("irrigation_pause") == "โ˜” irrigation pause" + assert label("rain") == "๐ŸŒง๏ธ rain" and label("irrigation_battery") == "๐Ÿชซ irrigation battery" and label("irrigation_pause") == "โ˜” irrigation pause" everything = list(LABELS) for section in ("sub", "unsub"): for parent in (None, "uv"): @@ -455,3 +455,14 @@ async def test_the_sound_lists_have_an_all_button(tmp_path): assert rows(q.edits[0][1])[-1] == [("๐Ÿ”” Enable all", "al:snd:all:on:uv")] await press(bot, Query("al:snd:all:on:uv", text="โ˜€๏ธ UV 10")) assert "uv" in state.loud(1) and "rain" in state.loud(1) and "gusts" not in state.loud(1) + + +def test_every_alert_type_sent_is_registered_and_each_monitor_declares_what_it_sends(): + import re + from pathlib import Path + from lib import alerts + monitors = [alerts.WeatherMonitor, alerts.ParticulatesMonitor, alerts.ForecastMonitor, alerts.PollenAndAsthmaMonitor, alerts.IrrigationDailyCheck] + assert sorted(k for m in monitors for k in m.KINDS) == sorted(LABELS) # every type has exactly one monitor + for m in monitors: + sent = set(re.findall(r'kind="(\w+)"', Path(__import__(m.__module__, fromlist=["x"]).__file__).read_text())) + assert sent == set(m.KINDS), m.__name__ diff --git a/tests/test_alerts.py b/tests/test_alerts.py index e45e809..0eb51fc 100644 --- a/tests/test_alerts.py +++ b/tests/test_alerts.py @@ -1,7 +1,7 @@ import time from datetime import datetime, timedelta, timezone -from lib.alerts import AirMonitor, AlertState, Notifier, WeatherMonitor, with_footer +from lib.alerts import ParticulatesMonitor, AlertState, Notifier, WeatherMonitor, with_footer from lib.ecowitt import outlook from tests.fakes import TZ @@ -165,21 +165,6 @@ def test_a_real_night_front_still_counts(): assert out and out.night and out.score >= outlook.PREDICT_MIN_SCORE -async def test_rain_likely_alerts_at_most_every_6_hours(tmp_path): - m, state, sent = monitor(tmp_path, pressure_rows(3.5, 15)) - await m.check() - await m.check() - assert [s for s in sent if "likely" in s].__len__() == 1 - - -async def test_no_rain_prediction_while_raining(tmp_path): - data = pressure_rows(3.5, 15) - data[-1][1]["rainfall.rain_rate"] = 2.0 - m, state, sent = monitor(tmp_path, data) - await m.check() - assert not [s for s in sent if "likely" in s] - - def temps(outdoor, indoor, ts=T0): return [(ts, {"outdoor.temperature": outdoor, "indoor.temperature": indoor})] @@ -269,7 +254,7 @@ async def test_mask_alert_needs_two_bad_checks_and_pairs_with_all_clear(tmp_path async def notify(text, link=None, **kw): sent.append((text, link)) - mon = AirMonitor(FakeAir(), state, notify) + mon = ParticulatesMonitor(FakeAir(), state, notify) await mon.check(air_reading(80)) assert not sent # one puff of smoke isn't enough await mon.check(air_reading(20)) @@ -290,7 +275,7 @@ async def test_mask_threshold_is_official_aqi_151(tmp_path): async def notify(text, link=None, **kw): sent.append(text) - mon = AirMonitor(FakeAir(), state, notify) + mon = ParticulatesMonitor(FakeAir(), state, notify) for _ in range(3): await mon.check(air_reading(55.4)) # AQI 150: unhealthy for sensitive groups only assert not sent @@ -304,7 +289,7 @@ async def test_stale_air_readings_are_ignored(tmp_path): async def notify(text, link=None, **kw): sent.append(text) - mon = AirMonitor(FakeAir(), state, notify) + mon = ParticulatesMonitor(FakeAir(), state, notify) for _ in range(3): await mon.check(air_reading(200, age=timedelta(hours=3))) assert not sent @@ -401,13 +386,13 @@ def current(self): def pollen_monitor(tmp_path): - from lib.alerts import PollenMonitor + from lib.alerts import PollenAndAsthmaMonitor state, sent = AlertState(tmp_path / "s.json"), [] async def notify(text, link=None, **kw): sent.append(text) pollen = FakePollen() - return PollenMonitor(pollen, state, notify), pollen, sent + return PollenAndAsthmaMonitor(pollen, state, notify), pollen, sent async def test_high_pollen_and_asthma_alert_once_per_metric_and_day(tmp_path): @@ -435,12 +420,12 @@ async def test_high_pollen_and_asthma_alert_once_per_metric_and_day(tmp_path): async def test_a_restart_does_not_repeat_an_alert_and_a_missing_forecast_or_stale_page_is_ignored(tmp_path): - from lib.alerts import PollenMonitor + from lib.alerts import PollenAndAsthmaMonitor mon, pollen, sent = pollen_monitor(tmp_path) pollen.grass = "High" await mon.check() assert len(sent) == 1 - again = PollenMonitor(pollen, mon.state, mon.notify) # the state file keeps what was sent + again = PollenAndAsthmaMonitor(pollen, mon.state, mon.notify) # the state file keeps what was sent await again.check() assert len(sent) == 1 pollen.asthma = None # off-season: nothing to say diff --git a/tests/test_ecowitt.py b/tests/test_ecowitt.py index e49af68..f9d7c6b 100644 --- a/tests/test_ecowitt.py +++ b/tests/test_ecowitt.py @@ -1232,5 +1232,5 @@ def _history(p): "end_date": f"{today} 23:59:59"}) assert reply.startswith("Error: no history data returned") text = caplog.text - assert "History cache: " in text and str(eco.cache.path) in text and "newest reading 5min none" in text + assert "History cache: " in text and str(eco.cache.path) in text assert "History query empty" in text and "readings held for the window {'5min': 0, '30min': 0, '1day': 0}" in text diff --git a/tests/test_irrigation.py b/tests/test_irrigation.py index c4b94a8..3096509 100644 --- a/tests/test_irrigation.py +++ b/tests/test_irrigation.py @@ -13,8 +13,8 @@ import lib.bot as bot_module from lib import templates -from lib.alerts import AlertState, IrrigationMonitor -from lib.alerts import irrigation as monitor_module +from lib.alerts import AlertState, IrrigationDailyCheck +from lib.alerts import irrigation_daily_check as monitor_module from lib.alerts.menu import available_kinds from lib.bot import Bot from lib.config import Config @@ -200,12 +200,12 @@ def test_the_irrigation_button_takes_wind_s_place_only_when_configured_and_chang def test_the_alert_list_gets_irrigation_battery_only_when_there_is_a_controller(): - assert available_kinds({"Ecowitt", "Irrigation"})[-2:] == ["irrigation", "irrigation_pause"] - assert "irrigation" not in available_kinds({"Ecowitt"}) and "irrigation_pause" not in available_kinds({"Ecowitt"}) + assert available_kinds({"Ecowitt", "Irrigation"})[-2:] == ["irrigation_battery", "irrigation_pause"] + assert "irrigation_battery" not in available_kinds({"Ecowitt"}) and "irrigation_pause" not in available_kinds({"Ecowitt"}) from lib.alerts.menu import LABELS, keyboard - assert LABELS["irrigation"] == "irrigation battery" and LABELS["irrigation_pause"] == "irrigation pause" - unsub = rows(keyboard(set(), ["rain", "irrigation"], "unsub")) - assert ("๐Ÿชซ irrigation battery", "al:off:irrigation:unsub") in [b for row in unsub for b in row] + assert LABELS["irrigation_battery"] == "irrigation battery" and LABELS["irrigation_pause"] == "irrigation pause" + unsub = rows(keyboard(set(), ["rain", "irrigation_battery"], "unsub")) + assert ("๐Ÿชซ irrigation battery", "al:off:irrigation_battery:unsub") in [b for row in unsub for b in row] # ---------- the daily check ---------- @@ -270,7 +270,7 @@ def daily(tmp_path, device, station=None, forecast=None): async def notify(text, link=None, **kw): sent.append((text, kw)) - return IrrigationMonitor(device, state, notify, TZ, 18, station, forecast), state, sent + return IrrigationDailyCheck(device, state, notify, TZ, 18, station, forecast), state, sent @pytest.mark.parametrize("station, forecast, delayed", [ @@ -294,7 +294,7 @@ async def test_the_check_delays_a_day_when_more_than_1_mm_fell_or_is_likely(tmp_ assert device.commands == ([("delay", "24h")] if delayed else []) assert [(text.startswith("โ˜” Irrigation paused for 24 hours: ") and text.endswith("."), kw) for text, kw in sent] == ( [(True, {"kind": "irrigation_pause"})] if delayed else []) # told once when it paused, never otherwise - assert state.monitor["irrigation"]["checked"] == "2026-10-08" + assert state.monitor["irrigation_daily_check"]["checked"] == "2026-10-08" async def test_a_delay_already_set_is_never_shortened_or_repeated(tmp_path): @@ -309,7 +309,7 @@ async def test_a_battery_under_5_percent_is_told_once_a_day_to_the_irrigation_al m, state, sent = daily(tmp_path, device) await m.check(NOW) await m.check(NOW) - assert len(sent) == 1 and sent[0][1] == {"kind": "irrigation"} and "4%" in sent[0][0] and "๐Ÿชซ" in sent[0][0] + assert len(sent) == 1 and sent[0][1] == {"kind": "irrigation_battery"} and "4%" in sent[0][0] and "๐Ÿชซ" in sent[0][0] await m.check(datetime(2026, 10, 9, 18, 0)) # the next day: again, until it is charged assert len(sent) == 2 device.status_now["battery_percentage"] = 5 # 5% is not below 5% @@ -324,16 +324,16 @@ async def delay(self, duration): m, state, sent = daily(tmp_path, Failing(battery_percentage=2), None, Forecast(90, 90)) with pytest.raises(IrrigationError): await m.check(NOW) - assert len(sent) == 1 and "checked" not in state.monitor["irrigation"] # not recorded: the next try repeats the check + assert len(sent) == 1 and "checked" not in state.monitor["irrigation_daily_check"] # not recorded: the next try repeats the check with pytest.raises(IrrigationError): await m.check(NOW) - assert len(sent) == 1 and sent[0][1] == {"kind": "irrigation"} # no "paused" alert for a pause that was not made + assert len(sent) == 1 and sent[0][1] == {"kind": "irrigation_battery"} # no "paused" alert for a pause that was not made def test_a_check_is_due_once_a_day_after_the_hour_and_the_next_is_the_following_18_00(tmp_path): m, state, _ = daily(tmp_path, Device()) assert not m.due(datetime(2026, 10, 8, 17, 59)) and m.due(datetime(2026, 10, 8, 18, 0)) and m.due(datetime(2026, 10, 8, 23, 0)) - state.monitor["irrigation"] = {"checked": "2026-10-08"} + state.monitor["irrigation_daily_check"] = {"checked": "2026-10-08"} assert not m.due(datetime(2026, 10, 8, 23, 0)) and not m.due(datetime(2026, 10, 9, 3, 0)) and m.due(datetime(2026, 10, 9, 18, 0)) assert m.seconds_until_next(datetime(2026, 10, 8, 17, 0)) == 3600 and m.seconds_until_next(datetime(2026, 10, 8, 18, 0)) == 86400 assert m.seconds_until_next(datetime(2026, 10, 8, 23, 30)) == 18.5 * 3600 @@ -356,7 +356,7 @@ async def fake_sleep(seconds): with pytest.raises(asyncio.CancelledError): await m.run() assert sleeps[0] == 1800 and device.commands == [("delay", "24h")] # tried, failed, waited 30 minutes, tried, did it - assert state.monitor["irrigation"]["checked"] == "2026-10-08" + assert state.monitor["irrigation_daily_check"]["checked"] == "2026-10-08" assert sleeps[1] == 86400 # then until 18:00 tomorrow (now is exactly 18:00 here) @@ -492,19 +492,19 @@ async def test_the_alerts_menu_toggles_each_alert_type_for_this_chat_alone_and_s q = await press(bot, "ir:open:alerts") assert q.edits[0][0] == "markup" and [r[0][0] for r in rows(q.edits[0][1])[2:]] == ["๐Ÿ”• Disable battery alerts", "๐Ÿ”• Disable pause alerts"] q = await press(bot, "ir:batt:off") - assert state.muted(1) == {"irrigation"} and q.toast == "Irrigation battery alerts off in this chat" + assert state.muted(1) == {"irrigation_battery"} and q.toast == "Irrigation battery alerts off in this chat" assert rows(q.edits[0][2])[2:] == [[("๐Ÿ”” Enable battery alerts", "ir:batt:on")], [("๐Ÿ”• Disable pause alerts", "ir:pause:off")]] # still open assert q.edits[0][1].endswith("pause: not paused (alerts: on)\nbattery: 100% ๐Ÿ”‹ (alerts: off)") # the status text follows q = await press(bot, "ir:pause:off") - assert state.muted(1) == {"irrigation", "irrigation_pause"} and q.toast == "Irrigation pause alerts off in this chat" + assert state.muted(1) == {"irrigation_battery", "irrigation_pause"} and q.toast == "Irrigation pause alerts off in this chat" assert q.edits[0][1].endswith("pause: not paused (alerts: off)\nbattery: 100% ๐Ÿ”‹ (alerts: off)") and rows(q.edits[0][2])[3] == [("๐Ÿ”” Enable pause alerts", "ir:pause:on")] q = await press(bot, "ir:batt:on") assert state.muted(1) == {"irrigation_pause"} and q.toast == "Irrigation battery alerts on in this chat" # the other type is untouched q = await press(bot, "ir:pause:on") - assert state.muted(1) == set() and state.alert_chats("irrigation") == [1] == state.alert_chats("irrigation_pause") - for kind, prefix in (("irrigation", "batt"), ("irrigation_pause", "pause")): # whatever a button says, pressing it does that + assert state.muted(1) == set() and state.alert_chats("irrigation_battery") == [1] == state.alert_chats("irrigation_pause") + for kind, prefix in (("irrigation_battery", "batt"), ("irrigation_pause", "pause")): # whatever a button says, pressing it does that for _ in range(2): - alerts = rows(menu.keyboard(state.subscribed(1, "irrigation"), state.subscribed(1, "irrigation_pause"), "alerts")) + alerts = rows(menu.keyboard(state.subscribed(1, "irrigation_battery"), state.subscribed(1, "irrigation_pause"), "alerts")) text, data = next(b for row in alerts[2:] for b in row if b[1].startswith(f"ir:{prefix}:")) await press(bot, data) assert state.subscribed(1, kind) is text.startswith("๐Ÿ”” Enable"), (text, data) @@ -541,7 +541,7 @@ async def test_the_handler_is_registered_and_the_alert_list_includes_irrigation_ plain.register(NS(add_handler=lambda h, group=0: added_plain.append(h), add_error_handler=lambda h: None)) assert len(added) == len(added_plain) # one entry point for every button, with or without a controller assert "ir:" in bot.buttons() and "ir:" not in plain.buttons() and {"al:", "pd:"} <= set(plain.buttons()) - assert "irrigation" in bot._alert_kinds() and "irrigation" not in plain._alert_kinds() + assert "irrigation_battery" in bot._alert_kinds() and "irrigation_battery" not in plain._alert_kinds() assert bot.keyboard_version == templates.version(True) != plain.keyboard_version == templates.VERSION @@ -687,9 +687,9 @@ async def delay(self, duration): m, state, sent = daily(tmp_path, device, None, Forecast(90, 90)) with pytest.raises(IrrigationError): await m.check(NOW) - assert sent == [] and "checked" not in state.monitor["irrigation"] # nothing told for a pause that was not made + assert sent == [] and "checked" not in state.monitor["irrigation_daily_check"] # nothing told for a pause that was not made await m.check(NOW) - assert device.commands == [("delay", "24h")] and len(sent) == 1 and state.monitor["irrigation"]["checked"] == "2026-10-08" + assert device.commands == [("delay", "24h")] and len(sent) == 1 and state.monitor["irrigation_daily_check"]["checked"] == "2026-10-08" async def test_the_pause_alert_reaches_every_subscribed_chat_with_the_usual_menu(tmp_path): @@ -698,13 +698,13 @@ async def test_the_pause_alert_reaches_every_subscribed_chat_with_the_usual_menu for chat in (1, 2, 3, -7): state.add_chat(chat, str(chat)) state.set_kind(2, "irrigation_pause", False) # this chat turned the pause alerts off - state.set_kind(3, "irrigation", False) # ... and this one only the battery alerts: it still gets the pause + state.set_kind(3, "irrigation_battery", False) # ... and this one only the battery alerts: it still gets the pause class Telegram: async def send_message(self, chat_id, text, **kw): delivered.append((chat_id, text, kw)) notifier = Notifier(Telegram(), state, available_kinds({"Ecowitt", "Irrigation"})) - m = IrrigationMonitor(Device(), state, notifier, TZ, 18, None, Forecast(90, 90)) + m = IrrigationDailyCheck(Device(), state, notifier, TZ, 18, None, Forecast(90, 90)) await m.check(NOW) assert sorted(c for c, _, _ in delivered) == [-7, 1, 3] and all(text.startswith("โ˜” Irrigation paused") for _, text, _ in delivered) markup = delivered[0][2]["reply_markup"] @@ -713,7 +713,7 @@ async def send_message(self, chat_id, text, **kw): from lib.alerts.menu import keyboard kinds = available_kinds({"Ecowitt", "AirGradient", "Pollen", "Forecast", "Irrigation"}) for section in (None, "sub", "unsub"): # every button's data fits Telegram's 64 bytes, for the new type too - for parent in (None, "irrigation_pause", "irrigation"): + for parent in (None, "irrigation_pause", "irrigation_battery"): assert all(len(data.encode()) <= 64 for row in rows(keyboard(set(), kinds, section, parent)) for _, data in row) @@ -757,7 +757,7 @@ async def test_every_member_of_a_group_may_use_the_irrigation_buttons(tmp_path): q = await press(bot, "ir:refresh", **group) assert q.toast == "Refreshed" and q.edits q = await press(bot, "ir:batt:off", **group) - assert state.muted(-5) == {"irrigation"} and q.toast == "Irrigation battery alerts off in this chat" + assert state.muted(-5) == {"irrigation_battery"} and q.toast == "Irrigation battery alerts off in this chat" def test_the_error_footer_is_bold_and_positioned_in_utf16_units(): diff --git a/tests/test_startup.py b/tests/test_startup.py index 25d03bb..66f8e30 100644 --- a/tests/test_startup.py +++ b/tests/test_startup.py @@ -77,6 +77,39 @@ async def stop_when_archived(): # the background archives finish, then we shut final = next(line for line in log.splitlines() if "Ecowitt archive:" in line and " req, held " in line) assert "failed" not in final and "days (5min/30min/4h/1d)" in final assert len(app.handlers) == 10 # start+help, reset, keyboard, alerts, usage, the one button handler, other updates, membership, messages, errors + assert "Alerts: rain, gusts, UV, temperature crossing, particulates: 0 chats" in log and "Alerts NOT running" not in log + + +async def test_the_alerts_line_leaves_out_the_weather_alerts_when_they_could_not_start(tmp_path, monkeypatch, caplog): + caplog.set_level("INFO") + cfg = config(tmp_path) + eco_t, _ = ecowitt_transport(history_days=45) + air_t, _ = air_transport(oldest=datetime.now(timezone.utc) - timedelta(days=5)) + app = FakeApp() + monkeypatch.setattr(ecobot.Config, "from_env", classmethod(lambda cls: cfg)) + for cls, transport in ((ecobot.Ecowitt, eco_t), (ecobot.AirGradient, air_t)): + monkeypatch.setattr(cls, "__init__", lambda self, c, transport=None, orig=cls.__init__, t=transport: orig(self, c, t)) + monkeypatch.setattr(ecobot.Application, "builder", staticmethod(lambda: FakeBuilder(app))) + monkeypatch.setattr(archive, "PACE_SECONDS", 0) + monkeypatch.setattr(air_source, "BACKFILL_PACE", 0) + monkeypatch.setattr(air_source, "BACKFILL_EMPTY_STOP", 3) + + async def broken(self): + raise RuntimeError("no readings") + monkeypatch.setattr(ecobot.WeatherMonitor, "init", broken) + + async def stop_when_archived(): + for _ in range(300): + if " req, held " in caplog.text and "day(s) cached" in caplog.text: + break + await asyncio.sleep(0.1) + os.kill(os.getpid(), signal.SIGTERM) + stopper = asyncio.create_task(stop_when_archived()) + await ecobot.main() + await stopper + log = caplog.text + assert "Weather alerts couldn't start" in log and "Alerts: particulates: 0 chats" in log + assert "Alerts NOT running: rain, gusts, UV, temperature crossing (see the error above)" in log async def test_a_failed_first_refresh_does_not_stop_the_alerts_or_the_archives(tmp_path, monkeypatch, caplog): From ddcd43a511cde2f8362593c333780793046ba92d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 23:19:51 +0000 Subject: [PATCH 08/18] Name the irrigation monitor IrrigationMonitor (lib/alerts/irrigation.py); cursor irrigation_monitor; log results under 90 chars Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 2 +- ecobot.py | 6 +++--- lib/alerts/__init__.py | 4 ++-- ...rrigation_daily_check.py => irrigation.py} | 4 ++-- lib/tools.py | 2 +- tests/test_alert_menu.py | 2 +- tests/test_irrigation.py | 20 +++++++++---------- 7 files changed, 20 insertions(+), 20 deletions(-) rename lib/alerts/{irrigation_daily_check.py => irrigation.py} (98%) diff --git a/README.md b/README.md index 4d0e13b..2e20df7 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ lib/captions.py when a chart is drawn, and the hint that makes the model lib/ecowitt/ api, store (SQLite + memory), fetch, extremes, query (one history question), link (weather_link), days (rank/count days), outlook (raining / likely soon), station, nightly archive lib/airgradient/ metrics, store (SQLite), source -lib/alerts/ notify (chats, silent send), weather, particulates, pollen_and_asthma, irrigation_daily_check (battery and rain delay) +lib/alerts/ notify (chats, silent send), weather, particulates, pollen_and_asthma, irrigation (battery and rain delay) lib/irrigation/ the optional Tuya irrigation controller: tuya (signed API client), menu (status text and buttons), watchdog (closes the valve after a run) lib/pollen/, forecast/ the optional website sources: pollen + thunderstorm asthma, Open-Meteo forecast (fetched only 6 am-6 pm, see warm.py SYNC_HOURS) tests/ pytest, against fake Ecowitt/AirGradient/Telegram; tests/evals holds the routing cases, tests/charts the chart reference images diff --git a/ecobot.py b/ecobot.py index 62fcdce..54bd8f8 100644 --- a/ecobot.py +++ b/ecobot.py @@ -22,7 +22,7 @@ from lib import intent from lib.airgradient import AirGradient -from lib.alerts import (PARTICULATES_CHECK_SECONDS, AlertState, ForecastMonitor, IrrigationDailyCheck, Notifier, ParticulatesMonitor, +from lib.alerts import (PARTICULATES_CHECK_SECONDS, AlertState, ForecastMonitor, IrrigationMonitor, Notifier, ParticulatesMonitor, PollenAndAsthmaMonitor, WeatherMonitor) from lib.alerts.menu import LABELS, kinds_for from lib.bot import Bot, polling_error @@ -165,10 +165,10 @@ async def main(): pollen.warmer.after.append(PollenAndAsthmaMonitor(pollen, state, notify).check) # after each refresh (in the day) running += PollenAndAsthmaMonitor.KINDS if irrigation: # the daily check (rain delay, battery) and the watchdog that closes the valve after a run - tasks.append(asyncio.create_task(IrrigationDailyCheck(irrigation, state, notify, cfg.tz, cfg.irrigation_check_hour, + tasks.append(asyncio.create_task(IrrigationMonitor(irrigation, state, notify, cfg.tz, cfg.irrigation_check_hour, eco, forecast).run())) tasks.append(asyncio.create_task(every(WATCHDOG_SECONDS, bot.watchdog.check))) - running += IrrigationDailyCheck.KINDS + running += IrrigationMonitor.KINDS # Keeping warm: everything questions need, refreshed before they arrive tasks.extend(asyncio.create_task(s.warmer.run()) for s in sources) diff --git a/lib/alerts/__init__.py b/lib/alerts/__init__.py index 0347a20..c8405d4 100644 --- a/lib/alerts/__init__.py +++ b/lib/alerts/__init__.py @@ -1,8 +1,8 @@ from .particulates import CHECK_SECONDS as PARTICULATES_CHECK_SECONDS, ParticulatesMonitor from .forecast import ForecastMonitor -from .irrigation_daily_check import IrrigationDailyCheck +from .irrigation import IrrigationMonitor from .notify import AlertState, Notifier, with_footer from .pollen_and_asthma import PollenAndAsthmaMonitor from .weather import WeatherMonitor -__all__ = ["PARTICULATES_CHECK_SECONDS", "ParticulatesMonitor", "AlertState", "ForecastMonitor", "IrrigationDailyCheck", "Notifier", "PollenAndAsthmaMonitor", "WeatherMonitor", "with_footer"] +__all__ = ["PARTICULATES_CHECK_SECONDS", "ParticulatesMonitor", "AlertState", "ForecastMonitor", "IrrigationMonitor", "Notifier", "PollenAndAsthmaMonitor", "WeatherMonitor", "with_footer"] diff --git a/lib/alerts/irrigation_daily_check.py b/lib/alerts/irrigation.py similarity index 98% rename from lib/alerts/irrigation_daily_check.py rename to lib/alerts/irrigation.py index 3d36ea6..d12bf4a 100644 --- a/lib/alerts/irrigation_daily_check.py +++ b/lib/alerts/irrigation.py @@ -25,7 +25,7 @@ MEASURED_HOURS = 24 -class IrrigationDailyCheck: +class IrrigationMonitor: KINDS = ("irrigation_battery", "irrigation_pause") def __init__(self, device, state, notify, tz, hour: int = 18, station=None, forecast=None): @@ -34,7 +34,7 @@ def __init__(self, device, state, notify, tz, hour: int = 18, station=None, fore self.station, self.forecast = station, forecast def _memory(self) -> dict: - return self.state.monitor.setdefault("irrigation_daily_check", {}) + return self.state.monitor.setdefault("irrigation_monitor", {}) def due(self, now: datetime) -> bool: """Is today's check still to be done (it is past the check time and none is recorded for today)?""" diff --git a/lib/tools.py b/lib/tools.py index 7987ca7..123f19e 100644 --- a/lib/tools.py +++ b/lib/tools.py @@ -69,7 +69,7 @@ async def call(self, name: str, raw_args: str, turn: Turn | None = None) -> str: if len(out) > MAX_OUTPUT_CHARS: log.warning("Tool %s output truncated: %d chars", name, len(out)) out = out[:MAX_OUTPUT_CHARS] + "\n...[truncated - request a shorter range]" - log.info("Tool result %s: %s", name, out if len(out) < 70 else f"{len(out)} chars") + log.info("Tool result %s: %s", name, out if len(out) < 90 else f"{len(out)} chars") if turn: turn.result_chars += len(out) return out diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index cb81969..894c771 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -461,7 +461,7 @@ def test_every_alert_type_sent_is_registered_and_each_monitor_declares_what_it_s import re from pathlib import Path from lib import alerts - monitors = [alerts.WeatherMonitor, alerts.ParticulatesMonitor, alerts.ForecastMonitor, alerts.PollenAndAsthmaMonitor, alerts.IrrigationDailyCheck] + monitors = [alerts.WeatherMonitor, alerts.ParticulatesMonitor, alerts.ForecastMonitor, alerts.PollenAndAsthmaMonitor, alerts.IrrigationMonitor] assert sorted(k for m in monitors for k in m.KINDS) == sorted(LABELS) # every type has exactly one monitor for m in monitors: sent = set(re.findall(r'kind="(\w+)"', Path(__import__(m.__module__, fromlist=["x"]).__file__).read_text())) diff --git a/tests/test_irrigation.py b/tests/test_irrigation.py index 3096509..0acd6c7 100644 --- a/tests/test_irrigation.py +++ b/tests/test_irrigation.py @@ -13,8 +13,8 @@ import lib.bot as bot_module from lib import templates -from lib.alerts import AlertState, IrrigationDailyCheck -from lib.alerts import irrigation_daily_check as monitor_module +from lib.alerts import AlertState, IrrigationMonitor +from lib.alerts import irrigation as monitor_module from lib.alerts.menu import available_kinds from lib.bot import Bot from lib.config import Config @@ -270,7 +270,7 @@ def daily(tmp_path, device, station=None, forecast=None): async def notify(text, link=None, **kw): sent.append((text, kw)) - return IrrigationDailyCheck(device, state, notify, TZ, 18, station, forecast), state, sent + return IrrigationMonitor(device, state, notify, TZ, 18, station, forecast), state, sent @pytest.mark.parametrize("station, forecast, delayed", [ @@ -294,7 +294,7 @@ async def test_the_check_delays_a_day_when_more_than_1_mm_fell_or_is_likely(tmp_ assert device.commands == ([("delay", "24h")] if delayed else []) assert [(text.startswith("โ˜” Irrigation paused for 24 hours: ") and text.endswith("."), kw) for text, kw in sent] == ( [(True, {"kind": "irrigation_pause"})] if delayed else []) # told once when it paused, never otherwise - assert state.monitor["irrigation_daily_check"]["checked"] == "2026-10-08" + assert state.monitor["irrigation_monitor"]["checked"] == "2026-10-08" async def test_a_delay_already_set_is_never_shortened_or_repeated(tmp_path): @@ -324,7 +324,7 @@ async def delay(self, duration): m, state, sent = daily(tmp_path, Failing(battery_percentage=2), None, Forecast(90, 90)) with pytest.raises(IrrigationError): await m.check(NOW) - assert len(sent) == 1 and "checked" not in state.monitor["irrigation_daily_check"] # not recorded: the next try repeats the check + assert len(sent) == 1 and "checked" not in state.monitor["irrigation_monitor"] # not recorded: the next try repeats the check with pytest.raises(IrrigationError): await m.check(NOW) assert len(sent) == 1 and sent[0][1] == {"kind": "irrigation_battery"} # no "paused" alert for a pause that was not made @@ -333,7 +333,7 @@ async def delay(self, duration): def test_a_check_is_due_once_a_day_after_the_hour_and_the_next_is_the_following_18_00(tmp_path): m, state, _ = daily(tmp_path, Device()) assert not m.due(datetime(2026, 10, 8, 17, 59)) and m.due(datetime(2026, 10, 8, 18, 0)) and m.due(datetime(2026, 10, 8, 23, 0)) - state.monitor["irrigation_daily_check"] = {"checked": "2026-10-08"} + state.monitor["irrigation_monitor"] = {"checked": "2026-10-08"} assert not m.due(datetime(2026, 10, 8, 23, 0)) and not m.due(datetime(2026, 10, 9, 3, 0)) and m.due(datetime(2026, 10, 9, 18, 0)) assert m.seconds_until_next(datetime(2026, 10, 8, 17, 0)) == 3600 and m.seconds_until_next(datetime(2026, 10, 8, 18, 0)) == 86400 assert m.seconds_until_next(datetime(2026, 10, 8, 23, 30)) == 18.5 * 3600 @@ -356,7 +356,7 @@ async def fake_sleep(seconds): with pytest.raises(asyncio.CancelledError): await m.run() assert sleeps[0] == 1800 and device.commands == [("delay", "24h")] # tried, failed, waited 30 minutes, tried, did it - assert state.monitor["irrigation_daily_check"]["checked"] == "2026-10-08" + assert state.monitor["irrigation_monitor"]["checked"] == "2026-10-08" assert sleeps[1] == 86400 # then until 18:00 tomorrow (now is exactly 18:00 here) @@ -687,9 +687,9 @@ async def delay(self, duration): m, state, sent = daily(tmp_path, device, None, Forecast(90, 90)) with pytest.raises(IrrigationError): await m.check(NOW) - assert sent == [] and "checked" not in state.monitor["irrigation_daily_check"] # nothing told for a pause that was not made + assert sent == [] and "checked" not in state.monitor["irrigation_monitor"] # nothing told for a pause that was not made await m.check(NOW) - assert device.commands == [("delay", "24h")] and len(sent) == 1 and state.monitor["irrigation_daily_check"]["checked"] == "2026-10-08" + assert device.commands == [("delay", "24h")] and len(sent) == 1 and state.monitor["irrigation_monitor"]["checked"] == "2026-10-08" async def test_the_pause_alert_reaches_every_subscribed_chat_with_the_usual_menu(tmp_path): @@ -704,7 +704,7 @@ class Telegram: async def send_message(self, chat_id, text, **kw): delivered.append((chat_id, text, kw)) notifier = Notifier(Telegram(), state, available_kinds({"Ecowitt", "Irrigation"})) - m = IrrigationDailyCheck(Device(), state, notifier, TZ, 18, None, Forecast(90, 90)) + m = IrrigationMonitor(Device(), state, notifier, TZ, 18, None, Forecast(90, 90)) await m.check(NOW) assert sorted(c for c, _, _ in delivered) == [-7, 1, 3] and all(text.startswith("โ˜” Irrigation paused") for _, text, _ in delivered) markup = delivered[0][2]["reply_markup"] From 18c6d344e55788791f2fcde6ec7287e65b926ee8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 23:24:31 +0000 Subject: [PATCH 09/18] Remove the rain outlook (raining now / likely soon) everywhere The weather_now rain_outlook field, the status report's outlook, the rain chart caption's outlook line and the scoring in outlook.py are gone. The rain helpers the alerts use live on in lib/ecowitt/rain.py. The status report still says it is raining, from the current rain rate. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 2 +- RECODE.md | 2 +- lib/alerts/irrigation.py | 2 +- lib/alerts/weather.py | 2 +- lib/bot.py | 3 +- lib/ecowitt/outlook.py | 124 --------------------------------------- lib/ecowitt/rain.py | 38 ++++++++++++ lib/ecowitt/station.py | 15 +---- lib/intent.py | 6 +- lib/prompt.py | 5 +- lib/report.py | 22 +++---- tests/test_agent.py | 4 +- tests/test_alerts.py | 36 ------------ tests/test_contract.py | 2 +- tests/test_ecowitt.py | 21 ------- tests/test_report.py | 20 +++---- 16 files changed, 66 insertions(+), 238 deletions(-) delete mode 100644 lib/ecowitt/outlook.py create mode 100644 lib/ecowitt/rain.py diff --git a/README.md b/README.md index 2e20df7..79e4c31 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ lib/panels.py how a reading becomes a panel (title, unit, colour key, lib/charts.py the one renderer: panels on a shared time axis, rain behind the lines, one colour per reading lib/captions.py when a chart is drawn, and the hint that makes the model's reply its caption lib/ecowitt/ api, store (SQLite + memory), fetch, extremes, query (one history question), link (weather_link), - days (rank/count days), outlook (raining / likely soon), station, nightly archive + days (rank/count days), rain (rain times and amounts for the alerts), station, nightly archive lib/airgradient/ metrics, store (SQLite), source lib/alerts/ notify (chats, silent send), weather, particulates, pollen_and_asthma, irrigation (battery and rain delay) lib/irrigation/ the optional Tuya irrigation controller: tuya (signed API client), menu (status text and buttons), watchdog (closes the valve after a run) diff --git a/RECODE.md b/RECODE.md index 778df08..a9e762e 100644 --- a/RECODE.md +++ b/RECODE.md @@ -49,7 +49,7 @@ What did change: `analysis/pairs.py` and the rain helpers in `rain.py`; `correlate.py` is `analysis/scan.py`; - one weather-series table (`series.py`) feeds the stack chart, the composer and the question recogniser; - `intent.read` returns one `Reading` used by the bot, the evals and `show_request`; -- the station no longer imports the alert module (the rain outlook lives in `ecowitt/outlook.py`); +- the station no longer imports the alert module (the rain helpers live in `ecowitt/rain.py`); - the renderer draws bands and lines in one place. Left as they were, on purpose: typed chart specs (dict specs are read by many tests and the tools), the alert rules diff --git a/lib/alerts/irrigation.py b/lib/alerts/irrigation.py index d12bf4a..ac3237c 100644 --- a/lib/alerts/irrigation.py +++ b/lib/alerts/irrigation.py @@ -13,7 +13,7 @@ import logging from datetime import datetime, timedelta -from ..ecowitt.outlook import rain_amount +from ..ecowitt.rain import rain_amount from ..timeutil import now_local from .forecast import RAIN_FROM_PCT diff --git a/lib/alerts/weather.py b/lib/alerts/weather.py index c67bf65..dee3ee7 100644 --- a/lib/alerts/weather.py +++ b/lib/alerts/weather.py @@ -17,7 +17,7 @@ from ..ecowitt.glance import UVI_ALERT from .backoff import due, remember -from ..ecowitt.outlook import Rows, duration, rain_amount, wet_flags +from ..ecowitt.rain import Rows, duration, rain_amount, wet_flags from ..timeutil import to_local log = logging.getLogger(__name__) diff --git a/lib/bot.py b/lib/bot.py index 60999ab..d7c1635 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -769,8 +769,7 @@ async def _chart_in_code(self, read, turn: Turn) -> str | None: question) when there is nothing to caption.""" calls = [read.fast[:2], *read.more] results = await asyncio.gather(*(self.agent.tools.call(name, json.dumps(args), turn) for name, args in calls)) - caption = report.chart_caption(read.fast[0], read.fast[1], results[0], turn, self.cfg.tz, - results[1] if len(results) > 1 else None) + caption = report.chart_caption(read.fast[0], read.fast[1], results[0], turn, self.cfg.tz) if caption is None: turn.charts.clear() turn.forecast_shown.clear() diff --git a/lib/ecowitt/outlook.py b/lib/ecowitt/outlook.py deleted file mode 100644 index 93a5854..0000000 --- a/lib/ecowitt/outlook.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Is it raining, or likely to soon? Read from the station's recent 5-minute readings (for the status report and the rain alerts).""" - -import math -from datetime import datetime, UTC -from statistics import fmean -from typing import NamedTuple - -PREDICT_MIN_SCORE = 4 -# Air pressure has a twice-daily "tide" (highs ~10am/10pm, lows ~4am/4pm local solar time). -# Around 37 degrees south its swing is ~0.7 hPa either side; it's subtracted before judging a fall. -TIDE_AMPLITUDE_HPA = 0.7 -NIGHT_HOURS = (20, 8) # 8pm-8am: cooling alone brings the air close to its dew point - -type Rows = list[tuple[int, dict]] # [(epoch, {"group.field": value})], oldest first - - -def tide(ts: int, longitude: float) -> float: - """Expected pressure offset from the atmospheric tide at this moment (hPa).""" - utc = datetime.fromtimestamp(ts, UTC) - solar_hour = (utc.hour + utc.minute / 60 + longitude / 15) % 24 - return TIDE_AMPLITUDE_HPA * math.cos(2 * math.pi * (solar_hour - 10) / 12) - - -def wet_flags(rows: Rows) -> list[tuple[int, bool, float]]: - """[(ts, rained in this reading, rain rate)]: rate above zero, or the daily total rising.""" - out, prev_daily = [], None - for ts, r in rows: - rate = r.get("rainfall.rain_rate", 0.0) - daily = r.get("rainfall.daily") - rising = daily is not None and prev_daily is not None and daily > prev_daily # ignores the midnight reset - if daily is not None: - prev_daily = daily - out.append((ts, rate > 0 or rising, rate)) - return out - - -def rain_amount(rows: Rows, since: int, last_wet: int) -> float | None: - """Rain that fell between since and last_wet: the rise in the daily total (allowing for its - midnight reset). Ecowitt's 'event' counter isn't used, as it spans several showers.""" - total, prev = 0.0, None - for ts, r in rows: - daily = r.get("rainfall.daily") - if daily is None or ts > last_wet: - continue - if ts >= since and prev is not None: - total += daily - prev if daily >= prev else daily # a drop means the midnight reset - prev = daily - return round(total, 1) if prev is not None else None - - -def duration(seconds: float) -> str: - minutes = int(round(seconds / 60)) - if minutes < 60: - return f"{minutes} min" - hours, minutes = divmod(minutes, 60) - return f"{hours}h {minutes:02d}m" if minutes else f"{hours}h" - - -class Outlook(NamedTuple): - score: int - reasons: list[str] - raw_drop: float # hPa fallen in 3 hours - drop: float # the same with the tide's expected change removed - night: bool - - -def assess_rain(rows: Rows, tz, longitude: float) -> Outlook | None: - """Score how likely rain is soon; None when there is no real pressure fall to go on. - - Two normal night-time effects are ignored, as they aren't signs of rain: the pressure tide's - overnight dip (subtracted from the fall), and the air cooling to near its dew point (at night - only a *rising* dew point counts, i.e. moister air moving in).""" - latest_ts, latest = rows[-1] - within = lambda secs: next((r for ts, r in rows if latest_ts - ts <= secs), None) - old_ts, old = next(((ts, r) for ts, r in rows if latest_ts - ts <= 3 * 3600 - and r.get("pressure.relative") is not None), (None, None)) - p_now = latest.get("pressure.relative") - if p_now is None or old is None: - return None - raw_drop = old["pressure.relative"] - p_now - drop = raw_drop + (tide(latest_ts, longitude) - tide(old_ts, longitude)) - if drop < 1.0: # a real (beyond-tide) pressure fall is required - return None - score = 3 if drop >= 3 else 2 if drop >= 2 else 1 - reasons = [f"pressure down {raw_drop:.1f} hPa in 3 hours"] - hour = datetime.fromtimestamp(latest_ts, UTC).astimezone(tz).hour - night = hour >= NIGHT_HOURS[0] or hour < NIGHT_HOURS[1] - temp, dew = latest.get("outdoor.temperature"), latest.get("outdoor.dew_point") - if night: - dew_old = (within(2 * 3600) or {}).get("outdoor.dew_point") - if dew is not None and dew_old is not None and dew - dew_old >= 1: - score += 2 if dew - dew_old >= 2 else 1 - reasons.append(f"dew point up {dew - dew_old:.1f}ยฐC in 2 hours") - else: - hum = latest.get("outdoor.humidity") - if hum is not None: - hum_old = (within(3600) or {}).get("outdoor.humidity") - rising = hum_old is not None and hum - hum_old >= 5 - if hum >= 90 or rising: - score += (2 if hum >= 95 else 1 if hum >= 90 else 0) + (1 if rising else 0) - reasons.append(f"humidity {hum:.0f}%" + (" and rising" if rising else "")) - if temp is not None and dew is not None and temp - dew <= 2: - score += 2 if temp - dew <= 1 else 1 - reasons.append(f"only {temp - dew:.1f}ยฐC above the dew point") - gusts = [(ts, r["wind.wind_gust"]) for ts, r in rows if r.get("wind.wind_gust") is not None] - now_gusts = [g for ts, g in gusts if latest_ts - ts <= 1800] - earlier = [g for ts, g in gusts if latest_ts - ts > 1800] - if now_gusts and earlier and max(now_gusts) - fmean(earlier) >= 10: - score += 1 - reasons.append(f"gusts picking up ({max(now_gusts):.0f} km/h)") - return Outlook(score, reasons, raw_drop, drop, night) - - -def rain_outlook(rows: Rows, tz, longitude: float) -> str | None: - """"Raining now" or "rain likely soon", in words for a status report; None when neither.""" - wet = wet_flags(rows) - if any(w for _, w, _ in wet[-2:]): - rate = max(r for _, w, r in wet[-2:] if w) - return "raining now" + (f" ({rate:g} mm/h)" if rate > 0 else "") - outlook = assess_rain(rows, tz, longitude) - if outlook and outlook.score >= PREDICT_MIN_SCORE: - return ("rain looks likely soon: " + ", ".join(outlook.reasons[:3]) + - " (an estimate from the station's readings, not an official forecast)") - return None diff --git a/lib/ecowitt/rain.py b/lib/ecowitt/rain.py new file mode 100644 index 0000000..383ea71 --- /dev/null +++ b/lib/ecowitt/rain.py @@ -0,0 +1,38 @@ +"""Rain from the station's recent 5-minute readings, for the rain alerts: when it rained, how much fell, and how long it took.""" + +type Rows = list[tuple[int, dict]] # [(epoch, {"group.field": value})], oldest first + + +def wet_flags(rows: Rows) -> list[tuple[int, bool, float]]: + """[(ts, rained in this reading, rain rate)]: rate above zero, or the daily total rising.""" + out, prev_daily = [], None + for ts, r in rows: + rate = r.get("rainfall.rain_rate", 0.0) + daily = r.get("rainfall.daily") + rising = daily is not None and prev_daily is not None and daily > prev_daily # ignores the midnight reset + if daily is not None: + prev_daily = daily + out.append((ts, rate > 0 or rising, rate)) + return out + + +def rain_amount(rows: Rows, since: int, last_wet: int) -> float | None: + """Rain that fell between since and last_wet: the rise in the daily total (allowing for its + midnight reset). Ecowitt's 'event' counter isn't used, as it spans several showers.""" + total, prev = 0.0, None + for ts, r in rows: + daily = r.get("rainfall.daily") + if daily is None or ts > last_wet: + continue + if ts >= since and prev is not None: + total += daily - prev if daily >= prev else daily # a drop means the midnight reset + prev = daily + return round(total, 1) if prev is not None else None + + +def duration(seconds: float) -> str: + minutes = int(round(seconds / 60)) + if minutes < 60: + return f"{minutes} min" + hours, minutes = divmod(minutes, 60) + return f"{hours}h {minutes:02d}m" if minutes else f"{hours}h" diff --git a/lib/ecowitt/station.py b/lib/ecowitt/station.py index 5a55ab4..93c2004 100644 --- a/lib/ecowitt/station.py +++ b/lib/ecowitt/station.py @@ -12,7 +12,6 @@ from ..config import Config from ..intent import NOW_GROUPS from ..timeutil import now_local -from .outlook import rain_outlook from ..tools import Tool, Turn from ..warm import FAST_REFRESH_SECONDS, Warmer from .api import EcowittAPI, GROUPS, UNITS @@ -85,8 +84,7 @@ def readings(data: dict) -> list[tuple[int, dict]]: "with times, for any range up to 4 years. Resolution and units are handled automatically.") REALTIME_PARAMS = { "type": "object", - "properties": {"groups": {"type": "string", "description": "Comma-separated group names, e.g. 'outdoor,indoor'. " - "For rain outlooks: 'outdoor,pressure,rainfall,rainfall_piezo,wind'."}}, + "properties": {"groups": {"type": "string", "description": "Comma-separated group names, e.g. 'outdoor,indoor'."}}, } REALTIME_DESCRIPTION = "Current readings from the owner's Ecowitt weather station." @@ -190,8 +188,7 @@ async def _realtime(self, args: dict, turn: Turn | None = None) -> str: except (TypeError, ValueError): pass when = datetime.fromtimestamp(newest, UTC).astimezone(self.tz).strftime("%a %d %b %Y %H:%M") if newest else None - outlook = await self._rain_outlook() if "rainfall" in groups else None - return json.dumps({"time": when, **out, **({"rain_outlook": outlook} if outlook else {}), **({"emoji": emoji} if emoji else {})}, + return json.dumps({"time": when, **out, **({"emoji": emoji} if emoji else {})}, ensure_ascii=False, separators=(",", ":")) async def _live_data(self, groups: list[str]) -> dict: @@ -225,14 +222,6 @@ async def live_rain(self) -> tuple[int, dict] | None: self._live_ok = True return ts, row - async def _rain_outlook(self) -> str | None: - """Raining now, or likely soon, from the last 3 hours of readings.""" - try: - return rain_outlook(await self.recent(3), self.tz, self.longitude) - except Exception as e: # the current reading is still worth sending without it - log.warning("Rain outlook unavailable: %s", e) - return None - # ---------- keeping warm ---------- async def warm(self, fresh: bool = True) -> str: """Every resolution's newest readings for every group: the last 7 days at 30 minutes, yesterday and today at diff --git a/lib/intent.py b/lib/intent.py index f8e1a46..1927c90 100644 --- a/lib/intent.py +++ b/lib/intent.py @@ -393,7 +393,7 @@ def chart_field(text: str) -> str | None: return WEATHER_READINGS[found[0]].field if len(found) == 1 and found[0] != "temperature" else None -# "rain chart 7d": a rain chart whose caption is just the least and most rain and whether more is expected +# "rain chart 7d": a rain chart whose caption is just the least and most rain RAIN_CHART = re.compile(r"^\s*rain\s+(chart|graph|plot)\b[^,&+]*$", I) @@ -781,7 +781,7 @@ class Reading: average_asked: bool = False more: list[tuple[str, dict]] = field(default_factory=list) # the rest of the report's calls (fast is the first) weather_now: bool = False # "weather now": every reading the station has, in the report's layout - rain_caption: bool = False # "rain chart 7d": the caption is the least and most rain and whether rain is expected + rain_caption: bool = False # "rain chart 7d": the caption is just the least and most rain chart_in_code: bool = False # a chart asked for plainly: fetched and captioned in code, no model chart_all: bool = False # "weather all week": every reading charted, sent with no caption readings: list[str] = field(default_factory=list) # the readings the words name @@ -830,8 +830,6 @@ def read(text: str, now: datetime, ecowitt: bool = True, air: bool = True, polle and not any(p.search(text) for p in JUDGEMENT)) rain_caption = ecowitt and wants_rain_caption(text) lookup, named = plain_lookup(text, fast) if not (report or in_code) else ("", []) - if in_code and rain_caption: # the caption also says whether rain is expected - calls = [("", {}), ("weather_now", {"groups": "rainfall"})] return Reading(reasoning_effort(text), needs_data(text), about_the_bot(text), report, period_hints(text, now), fast, bool(GRAPH.search(text)), chart_field(text), chart_fields(text), bool(AVERAGE.search(text)), more=calls[1:], weather_now=ecowitt and wants_weather_now(text), rain_caption=rain_caption, chart_in_code=in_code, chart_all=in_code and wants_chart_all(text), diff --git a/lib/prompt.py b/lib/prompt.py index 1432f03..7d1cb2c 100644 --- a/lib/prompt.py +++ b/lib/prompt.py @@ -204,9 +204,8 @@ def capabilities(sources: list[str], brief: bool = False) -> str: RAIN_CAPTION_SECTION = """ -THIS IS A RAIN CHART: its caption is ONLY the least and the most rain in the period (daily totals, mm, with the dated days), then one line -saying whether rain is expected: call weather_now with groups "rainfall" and use its "rain_outlook" (raining now, or rain likely -soon); with no rain_outlook, say no rain is expected soon. Nothing else: no averages, no other readings. +THIS IS A RAIN CHART: its caption is ONLY the least and the most rain in the period (daily totals, mm, with the dated days). +Nothing else: no averages, no other readings, no outlook. """ diff --git a/lib/report.py b/lib/report.py index 5a25113..2cf7a17 100644 --- a/lib/report.py +++ b/lib/report.py @@ -9,7 +9,6 @@ from .series import WEATHER, find AIR_ROWS = (("pm2_5", "PM2.5"), ("pm10", "PM10"), ("pm1", "PM1"), ("co2", "COโ‚‚"), ("voc_index", "VOC index"), ("nox_index", "NOx index")) -OUTLOOK_NOTE = re.compile(r"\s*\(an estimate from the station's readings, not an official forecast\)", re.I) def _load(result: str | None) -> dict | None: @@ -68,13 +67,9 @@ def weather_lines(now: dict) -> list[str]: """The weather station's bullets from a weather_now result: an emoji sits right before the reading it is keyed to.""" get, tag = _getters(now) rain_rate = (now.get("rainfall") or {}).get("rain_rate", "") - outlook = OUTLOOK_NOTE.sub(" (an estimate)", now["rain_outlook"]) if now.get("rain_outlook") else None - if outlook and outlook.startswith("raining"): - outlook = tag("rainfall", "rain_rate", outlook) - elif not outlook and rain_rate.split()[:1] not in ([], ["0"], ["0.0"]): - outlook = tag("rainfall", "rain_rate", f"raining ({get('rainfall', 'rain_rate')})") + raining = tag("rainfall", "rain_rate", f"raining ({get('rainfall', 'rain_rate')})") if rain_rate.split()[:1] not in ([], ["0"], ["0.0"]) else None month = get("rainfall", "monthly") - rain = _join([_join([tag("rainfall", "daily"), f"(month total {month})" if month else None], " "), outlook], "; ") + rain = _join([_join([tag("rainfall", "daily"), f"(month total {month})" if month else None], " "), raining], "; ") degrees = (now.get("wind") or {}).get("wind_direction", "") calm = _zero(get("wind", "wind_speed")) and _zero(get("wind", "wind_gust")) # no wind: no line @@ -225,7 +220,7 @@ def _range(label: str, entry: dict, average: bool) -> str: def _rain_line(turn, tz) -> list[str]: - """Least and most rain in a period (the chart's own bars), then whether more is expected.""" + """Least and most rain in a period (the chart's own bars).""" bars = next((p.bars for c in turn.charts for p in c.panels if p.bars and p.bars.x), None) if not bars: return [] @@ -236,18 +231,15 @@ def day(i: int) -> str: return [f"โ˜” Rain{per}: least {bars.y[lo]:g} mm ({day(lo)}) ยท most {bars.y[hi]:g} mm ({day(hi)}) ยท {sum(bars.y):.1f} mm in all"] -def _weather_caption(result: dict, args: dict, turn, tz, now_result: dict | None) -> list[str]: +def _weather_caption(result: dict, args: dict, turn, tz) -> list[str]: series = result.get("series") or {} names = [n for n in dict.fromkeys(turn.chart_fields or [turn.chart_field or "temperature"]) if find(n)] lines: list[str] = [] for name in names: reading = find(name) keys = [k for k in series if k.split(".", 1)[1] == reading.field] - if reading.field == "daily": # rain: the amounts the chart draws, and what is expected + if reading.field == "daily": # rain: the amounts the chart draws lines += _rain_line(turn, tz) - if now_result is not None: - outlook = OUTLOOK_NOTE.sub(" (an estimate)", now_result.get("rain_outlook") or "") - lines.append("โ˜” " + (outlook or "No rain is expected soon.").lstrip("โ˜” ")) continue for key in keys: group = key.split(".", 1)[0] @@ -275,14 +267,14 @@ def _air_caption(result: dict, args: dict, turn) -> list[str]: return lines -def chart_caption(tool: str, args: dict, result: str | None, turn, tz, extra: str | None = None) -> str | None: +def chart_caption(tool: str, args: dict, result: str | None, turn, tz) -> str | None: """The caption for a chart, from the tool's result: the period, then a line per reading. None when there is nothing to say (the model then takes the question).""" data = _load(result) if not data or not turn.charts: return None lines = (_air_caption(data, args, turn) if tool == "air_quality" else - _weather_caption(data, args, turn, tz, _load(extra) if extra is not None else None)) + _weather_caption(data, args, turn, tz)) if not lines: return None notes = (["(Older days are still downloading, so they are left out.)"] if data.get("note_missing") else diff --git a/tests/test_agent.py b/tests/test_agent.py index 237a46f..fedbe25 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -238,12 +238,12 @@ async def test_streamed_answers_give_the_same_result_and_report_the_text_so_far( assert msgs[1]["tool_calls"][0]["function"] == {"name": "weather_now", "arguments": '{"groups": "outdoor"}'} -def test_a_rain_chart_caption_is_only_the_least_and_most_rain_and_whether_more_is_expected(): +def test_a_rain_chart_caption_is_only_the_least_and_most_rain(): from datetime import datetime from lib import prompt now = datetime(2026, 9, 29, 14, 5) text = prompt.build(now, ["Ecowitt weather station"], rain_caption=True) - assert "THIS IS A RAIN CHART" in text and "rain_outlook" in text + assert "THIS IS A RAIN CHART" in text and "rain_outlook" not in text assert "THIS IS A RAIN CHART" not in prompt.build(now, ["Ecowitt weather station"]) diff --git a/tests/test_alerts.py b/tests/test_alerts.py index 0eb51fc..1093e3e 100644 --- a/tests/test_alerts.py +++ b/tests/test_alerts.py @@ -2,7 +2,6 @@ from datetime import datetime, timedelta, timezone from lib.alerts import ParticulatesMonitor, AlertState, Notifier, WeatherMonitor, with_footer -from lib.ecowitt import outlook from tests.fakes import TZ T0 = int(datetime(2026, 9, 29, 13, 0, tzinfo=TZ).timestamp()) # 1pm Melbourne, a settled day @@ -96,14 +95,6 @@ async def test_a_live_rate_alone_starts_it_and_a_failed_live_call_is_ignored(tmp assert len(sent) == 1 and "3 mm/h" in sent[0] -def test_the_status_outlook_says_raining_now_or_likely_soon_or_nothing(): - assert outlook.rain_outlook(rain(0, 0, 1), TZ, 145.0) == "raining now (1.2 mm/h)" - assert outlook.rain_outlook(rain(0, 0, 0), TZ, 145.0) is None - likely = outlook.rain_outlook(pressure_rows(4.0, 14), TZ, 145.0) - assert likely.startswith("rain looks likely soon: pressure down") and "not an official forecast" in likely - assert outlook.rain_outlook(pressure_rows(0.2, 14), TZ, 145.0) is None - - def gusts(*values): return [(T0 + i * 300, {"wind.wind_gust": v}) for i, v in enumerate(values)] @@ -138,33 +129,6 @@ async def test_old_gusts_are_not_announced_after_a_restart(tmp_path): assert sent == [] -def pressure_rows(drop, hour_local, hum=93.0, dew_rise=0.0, temp=15.0): - """3 hours of readings ending at the given local hour, pressure falling `drop` hPa in total.""" - end = int(datetime(2026, 9, 29, hour_local, 0, tzinfo=TZ).timestamp()) - n = 37 - return rows(n, start=end - 36 * 300, **{ - "pressure.relative": lambda i: 1015 - drop * i / 36, "outdoor.humidity": hum, - "outdoor.temperature": temp, "outdoor.dew_point": lambda i: temp - 1 + dew_rise * i / 36, - "rainfall.rain_rate": 0.0, "wind.wind_gust": 10.0}) - - -def test_daytime_fall_with_humid_air_predicts_rain(): - out = outlook.assess_rain(pressure_rows(3.2, 15), TZ, 145.0) - assert out and out.score >= outlook.PREDICT_MIN_SCORE and not out.night - - -def test_the_overnight_pressure_dip_and_cooling_are_not_rain(): - # ~4am: pressure sags with the daily tide and the air sits near its dew point, as every night - for drop in (0.5, 1.2, 1.6): - out = outlook.assess_rain(pressure_rows(drop, 4), TZ, 145.0) - assert out is None or out.score < outlook.PREDICT_MIN_SCORE, (drop, out) - - -def test_a_real_night_front_still_counts(): - out = outlook.assess_rain(pressure_rows(4.5, 3, dew_rise=2.5), TZ, 145.0) - assert out and out.night and out.score >= outlook.PREDICT_MIN_SCORE - - def temps(outdoor, indoor, ts=T0): return [(ts, {"outdoor.temperature": outdoor, "indoor.temperature": indoor})] diff --git a/tests/test_contract.py b/tests/test_contract.py index b18b99b..aa21502 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -58,7 +58,7 @@ async def test_every_tool_result_keeps_its_shape(tmp_path, archived_cache): span = {"start_date": day(9), "end_date": f"{today - timedelta(days=2)} 23:59:59"} call = lambda i, args: eco.tools[i].handler(args) now = json.loads(await call(0, {"groups": "outdoor,indoor,pressure,wind,rainfall"})) - assert set(now) - {"rain_outlook"} == {"time", "outdoor", "emoji"} # the outlook line only appears when the data suggests rain + assert set(now) == {"time", "outdoor", "emoji"} history = json.loads(await call(1, {"groups": "outdoor", **span})) assert set(history) - {"chart"} == {"period", "series"} # "chart": a hint that appears when one was drawn assert set(history["series"]["outdoor.temperature"]) == { diff --git a/tests/test_ecowitt.py b/tests/test_ecowitt.py index f9d7c6b..0c149c7 100644 --- a/tests/test_ecowitt.py +++ b/tests/test_ecowitt.py @@ -568,27 +568,6 @@ async def wind_chart(days, **extra): await eco.close() -async def test_weather_now_carries_the_rain_outlook_when_it_is_raining_or_likely(tmp_path, monkeypatch): - transport, _ = ecowitt_transport() - eco = Ecowitt(config(tmp_path), transport=transport) - await eco.start() - now = int(datetime.now(eco.tz).timestamp()) - wet = [(now - 300 * i, {"rainfall.rain_rate": 1.5, "rainfall.daily": 2.0}) for i in range(2, -1, -1)] - dry = [(now - 300 * i, {"rainfall.rain_rate": 0.0, "rainfall.daily": 2.0}) for i in range(2, -1, -1)] - rows = {"value": wet} - - async def recent(hours): - return rows["value"] - monkeypatch.setattr(eco, "recent", recent) - out = json.loads(await eco.tools[0].handler({"groups": "outdoor,rainfall"})) - assert out["rain_outlook"] == "raining now (1.5 mm/h)" - rows["value"] = dry - assert "rain_outlook" not in json.loads(await eco.tools[0].handler({"groups": "outdoor,rainfall"})) - rows["value"] = wet - assert "rain_outlook" not in json.loads(await eco.tools[0].handler({"groups": "outdoor"})) # no rain group asked for - await eco.close() - - def test_the_rolling_mean_smooths_a_staircase_without_moving_timestamps_or_crossing_gaps(): from lib.timeutil import rolling_mean assert rolling_mean({0: 5.0, 300: 5.0, 600: 5.0}, 450) == {0: 5.0, 300: 5.0, 600: 5.0} diff --git a/tests/test_report.py b/tests/test_report.py index 8bbdc64..7cc9505 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -8,7 +8,7 @@ '"app_tempin":"18.7 โ„ƒ"},"solar_and_uvi":{"solar":"0.0 W/mยฒ","uvi":"0"},"rainfall":{"rain_rate":"1.8 mm/hr","daily":"6.3 mm",' '"event":"49.8 mm","1_hour":"1.3 mm","weekly":"49.8 mm","monthly":"49.8 mm","yearly":"5105.7 mm"},"wind":{"wind_speed":"0.0 km/h",' '"wind_gust":"0.0 km/h","wind_direction":"131 ยบ"},"pressure":{"relative":"1025.0 hPa","absolute":"1009.5 hPa"},' - '"rain_outlook":"raining now (4.8 mm/h)","emoji":{"outdoor.temperature":"๐Ÿงฅ","outdoor.feels_like":"๐Ÿงฅ","outdoor.app_temp":"๐Ÿงฅ",' + '"emoji":{"outdoor.temperature":"๐Ÿงฅ","outdoor.feels_like":"๐Ÿงฅ","outdoor.app_temp":"๐Ÿงฅ",' '"outdoor.humidity":"๐Ÿ’ฆ","indoor.temperature":"๐Ÿงฅ","indoor.feels_like":"๐Ÿงฅ","indoor.app_tempin":"๐Ÿงฅ","rainfall.rain_rate":"๐ŸŒง๏ธ",' '"rainfall.daily":"โ˜”","pressure.relative":"๐Ÿ—œ๏ธ"}}') AIR = ('{"sensor": "Fairleigh", "sensor_type": "outdoor", "time": "Fri 2 Oct 2026 2:22am", "pm2_5": {"value": 1.4, "unit": "ยตg/mยณ", ' @@ -24,7 +24,7 @@ โ€ข Outdoor: ๐Ÿงฅ 12.3 ยฐC, ๐Ÿ’ฆ 94 %, dew point 11.3 ยฐC, VPD 0.085 kPa โ€ข Indoor: ๐Ÿงฅ 18.3 ยฐC, 63 % โ€ข Pressure: ๐Ÿ—œ๏ธ 1025.0 hPa -โ€ข Rain today: โ˜” 6.3 mm (month total 49.8 mm); ๐ŸŒง๏ธ raining now (4.8 mm/h)""" # night and calm: no Sun or Wind line +โ€ข Rain today: โ˜” 6.3 mm (month total 49.8 mm); ๐ŸŒง๏ธ raining (1.8 mm/h)""" # night and calm: no Sun or Wind line def test_the_report_is_laid_out_in_code_with_emoji_before_the_readings_they_belong_to(): @@ -55,14 +55,11 @@ def test_weather_now_is_just_the_weather_stations_bullets(): assert report.weather_now("Error calling tool: timed out") is None and report.weather_now(None) is None -def test_a_dry_calm_day_has_no_rain_line_noise_and_a_likely_rain_outlook_is_shortened(): +def test_a_dry_calm_day_has_no_rain_line_noise(): dry = ('{"outdoor":{"temperature":"21.0 โ„ƒ","humidity":"50 %"},"rainfall":{"rain_rate":"0.0 mm/hr","daily":"0.0 mm","monthly":"12.0 mm"},' '"wind":{"wind_speed":"12.0 km/h","wind_gust":"20.0 km/h","wind_direction":"270 ยบ"},"emoji":{}}') lines = report.weather_now(dry).splitlines() assert lines == ["โ€ข Outdoor: 21.0 ยฐC, 50 %", "โ€ข Rain today: 0.0 mm (month total 12.0 mm)", "โ€ข Wind: 12.0 km/h from 270ยฐ, gust 20.0 km/h"] - likely = dry.replace('"emoji"', '"rain_outlook":"rain looks likely soon: pressure down 3.0 hPa in 3 hours (an estimate from the station\'s ' - 'readings, not an official forecast)","emoji"') - assert "โ€ข Rain today: 0.0 mm (month total 12.0 mm); rain looks likely soon: pressure down 3.0 hPa in 3 hours (an estimate)" in report.weather_now(likely) drizzle = dry.replace('"0.0 mm/hr"', '"0.6 mm/hr"').replace('"emoji":{}', '"emoji":{"rainfall.rain_rate":"๐ŸŒง๏ธ"}') assert "; ๐ŸŒง๏ธ raining (0.6 mm/h)" in report.weather_now(drizzle) @@ -150,17 +147,14 @@ def test_an_average_chart_caption_leads_with_the_average(): "Temperature: average 15.2 ยฐC (low 8.1, high 24.3)" -def test_a_rain_chart_caption_is_the_least_and_most_rain_then_whether_more_is_expected(): +def test_a_rain_chart_caption_is_the_least_and_most_rain(): from zoneinfo import ZoneInfo turn = _turn() turn.chart_fields = ["rain"] result = json.dumps({"period": "Tue 29 Sep 2026 - Tue 06 Oct 2026", "series": {"rainfall.daily": {"unit": "mm", "low": "0", "high": "7.2"}}}) - now = json.dumps({"rain_outlook": "Rain looks likely soon: pressure falling. (An estimate from the station's readings, not an official forecast)"}) - lines = report.chart_caption("weather_history", {}, result, turn, ZoneInfo("UTC"), now).splitlines() + lines = report.chart_caption("weather_history", {}, result, turn, ZoneInfo("UTC")).splitlines() assert lines[1].startswith("โ˜” Rain per day: least 0 mm (") and "most 7.2 mm (" in lines[1] and "8.6 mm in all" in lines[1] - assert lines[2] == "โ˜” Rain looks likely soon: pressure falling. (an estimate)" - dry = report.chart_caption("weather_history", {}, result, turn, ZoneInfo("UTC"), "{}") - assert dry.splitlines()[2] == "โ˜” No rain is expected soon." + assert len(lines) == 2 def test_an_air_chart_caption_gives_each_metrics_peak_rating_and_average(): @@ -188,7 +182,7 @@ def test_a_reading_lookup_prints_only_what_was_asked(): assert report.reading_lines(now, ["humidity"], ["outdoor"]) == ["โ€ข Outdoor: ๐Ÿ’ฆ 94 %"] assert report.reading_lines(now, ["wind"], []) == ["โ€ข Wind: calm"] assert report.reading_lines(now, ["pressure"], []) == ["โ€ข Pressure: ๐Ÿ—œ๏ธ 1025.0 hPa"] - assert report.reading_lines(now, ["rain"], [])[0].startswith("โ€ข Rain today: โ˜” 6.3 mm") and "raining now" in report.reading_lines(now, ["rain"], [])[0] + assert report.reading_lines(now, ["rain"], [])[0].startswith("โ€ข Rain today: โ˜” 6.3 mm") and "raining (1.8 mm/h)" in report.reading_lines(now, ["rain"], [])[0] def test_air_pollen_and_forecast_lookups_print_the_tools_own_lines(): From 09e6da188a8c78ac59d7c9db878610760fa2ec3b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 23:34:59 +0000 Subject: [PATCH 10/18] Indent the continuation of the wrapped Alerts line by 13 characters Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- ecobot.py | 5 ++++- tests/test_startup.py | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/ecobot.py b/ecobot.py index 54bd8f8..ce1fdca 100644 --- a/ecobot.py +++ b/ecobot.py @@ -86,6 +86,9 @@ def commit_in(git_dir) -> str: return "unknown" +INDENT = 13 # the continuation lines of a wrapped log line + + def wrapped(head: str, items: list[str], tail: str = "", width: int = 90) -> str: """head + the items joined by ", " + tail, over several lines when it is longer than `width`: a line breaks only between items, and the next one is indented.""" @@ -94,7 +97,7 @@ def wrapped(head: str, items: list[str], tail: str = "", width: int = 90) -> str piece = item + (", " if i < len(items) - 1 else tail) if line.strip() != head.strip() and len(line + piece.rstrip()) > width: lines.append(line.rstrip()) - line = " " + line = " " * INDENT line += piece return "\n".join([*lines, line]) diff --git a/tests/test_startup.py b/tests/test_startup.py index 66f8e30..afec284 100644 --- a/tests/test_startup.py +++ b/tests/test_startup.py @@ -247,3 +247,10 @@ async def stop_when_archived(): await stopper assert "irrigation battery" in caplog.text # in the list of alert types assert len(app.handlers) == 10 and closed == [1] # the same handlers (buttons share one entry point), and the client closed at shutdown + + +def test_a_long_alerts_line_continues_indented_at_90_characters(): + from lib.alerts.menu import LABELS + lines = ecobot.wrapped("Alerts: ", list(LABELS.values()), ": 3 chats").splitlines() + assert len(lines) > 1 and all(len(line) <= 90 for line in lines) + assert all(line.startswith(" " * 13) and not line.startswith(" " * 14) for line in lines[1:]) From 11cf5792fce246267e3a6a767f9b0ca9f1e965fa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 23:37:36 +0000 Subject: [PATCH 11/18] Admin-only startup line: point at ADMIN_CHAT_IDS Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- ecobot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ecobot.py b/ecobot.py index ce1fdca..de3ef4b 100644 --- a/ecobot.py +++ b/ecobot.py @@ -120,7 +120,7 @@ async def main(): agent = Agent(AsyncOpenAI(api_key=cfg.xai_api_key, base_url=cfg.xai_base_url), cfg.xai_model, tools) state = AlertState(cfg.state_path) if cfg.admin_only: - log.info("Admin only: the bot answers the admins of the groups it is in%s", + log.info("Admin only: the bot answers the admins of the groups it is in%s; set ADMIN_CHAT_IDS for more", f" and {len(cfg.admin_chat_ids)} listed id(s)" if cfg.admin_chat_ids else "") if not cfg.admin_chat_ids and not any(chat < 0 for chat in state.chats): log.warning("Admin only is on but no group is known yet and ADMIN_CHAT_IDS is empty, so nobody can use the bot: " From 69050d28528c10bf85fdea12cd00cbdaaa2266a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 23:47:41 +0000 Subject: [PATCH 12/18] Reword the ADMIN_ONLY startup line; no id count Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- ecobot.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ecobot.py b/ecobot.py index de3ef4b..46ee405 100644 --- a/ecobot.py +++ b/ecobot.py @@ -120,8 +120,7 @@ async def main(): agent = Agent(AsyncOpenAI(api_key=cfg.xai_api_key, base_url=cfg.xai_base_url), cfg.xai_model, tools) state = AlertState(cfg.state_path) if cfg.admin_only: - log.info("Admin only: the bot answers the admins of the groups it is in%s; set ADMIN_CHAT_IDS for more", - f" and {len(cfg.admin_chat_ids)} listed id(s)" if cfg.admin_chat_ids else "") + log.info("ADMIN_ONLY=on the bot only answers admins of known groups; set ADMIN_CHAT_IDS for more") if not cfg.admin_chat_ids and not any(chat < 0 for chat in state.chats): log.warning("Admin only is on but no group is known yet and ADMIN_CHAT_IDS is empty, so nobody can use the bot: " "add it to a group, list your user id in ADMIN_CHAT_IDS, or set ADMIN_ONLY=off") From 8e0e99e3b11ad5be05c932349f711d5d8167dab6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 00:14:45 +0000 Subject: [PATCH 13/18] Log clean-ups: no ecobot prefix, Ecowitt source is source.py, live readings summary, archive start line only for long runs Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- RECODE.md | 2 +- ecobot.py | 13 ++++++++++++- lib/ecowitt/__init__.py | 2 +- lib/ecowitt/archive.py | 5 +++-- lib/ecowitt/{station.py => source.py} | 22 +++++++++++++++++++--- scripts/show_request.py | 2 +- tests/evalkit.py | 2 +- tests/test_ecowitt.py | 13 +++++++++++-- tests/test_startup.py | 7 +++++++ 9 files changed, 56 insertions(+), 12 deletions(-) rename lib/ecowitt/{station.py => source.py} (92%) diff --git a/RECODE.md b/RECODE.md index a9e762e..e65371e 100644 --- a/RECODE.md +++ b/RECODE.md @@ -12,7 +12,7 @@ 267 lib/alerts/weather.py 258 lib/correlate.py 227 lib/ecowitt/store.py - 203 lib/ecowitt/station.py + 203 lib/ecowitt/source.py 200 lib/prompt.py 190 lib/ecowitt/days.py 160 ecobot.py diff --git a/ecobot.py b/ecobot.py index 46ee405..903eda0 100644 --- a/ecobot.py +++ b/ecobot.py @@ -36,7 +36,18 @@ from lib.tools import Tools from lib.warm import every, safely -logging.basicConfig(format="%(levelname)s %(name)s: %(message)s", level=logging.INFO) + +class LogFormat(logging.Formatter): + """"INFO message" for this file's own lines, "INFO lib.bot: message" for the rest: the module name says who spoke.""" + + def format(self, record): + record.where = "" if record.name == "ecobot" else f"{record.name}: " + return super().format(record) + + +_handler = logging.StreamHandler() +_handler.setFormatter(LogFormat("%(levelname)s %(where)s%(message)s")) +logging.basicConfig(level=logging.INFO, handlers=[_handler]) logging.getLogger("httpx").setLevel(logging.WARNING) logging.getLogger("telegram.ext.Application").setLevel(logging.WARNING) # "Application started" etc. log = logging.getLogger("ecobot") diff --git a/lib/ecowitt/__init__.py b/lib/ecowitt/__init__.py index 8c242fc..f225d3e 100644 --- a/lib/ecowitt/__init__.py +++ b/lib/ecowitt/__init__.py @@ -1,4 +1,4 @@ from .archive import Archive -from .station import Ecowitt +from .source import Ecowitt __all__ = ["Archive", "Ecowitt"] diff --git a/lib/ecowitt/archive.py b/lib/ecowitt/archive.py index 22d1aa2..8602968 100644 --- a/lib/ecowitt/archive.py +++ b/lib/ecowitt/archive.py @@ -75,8 +75,9 @@ async def run_once(self) -> tuple[int, int]: if todo: # each range takes the pause after it plus about a second for the request itself seconds = len(todo) * (max(self.pace, MIN_GAP_SECONDS) + 1) - log.info("Ecowitt archive: %d req to fetch (about %s)", len(todo), - f"{seconds:.0f} s" if seconds < 90 else f"{seconds / 60:.0f} min") + if seconds >= 60: # a short run only gets the line when it finishes + log.info("Ecowitt archive: %d req to fetch (about %s)", len(todo), + f"{seconds:.0f} s" if seconds < 90 else f"{seconds / 60:.0f} min") fetched = failed = 0 for i, (cycle, start, end) in enumerate(todo, 1): fetcher = self.station.fetcher(self.groups) diff --git a/lib/ecowitt/station.py b/lib/ecowitt/source.py similarity index 92% rename from lib/ecowitt/station.py rename to lib/ecowitt/source.py index 93c2004..6a5c47f 100644 --- a/lib/ecowitt/station.py +++ b/lib/ecowitt/source.py @@ -93,6 +93,22 @@ def readings(data: dict) -> list[tuple[int, dict]]: LIVE_FRESH_SECONDS = 90 # a question is answered from that reading while it is this young +def live_summary(data: dict) -> str: + """A live reading in a few words for the log: rain first, then outdoor temperature and humidity, wind and pressure. A reading the + response lacks is left out.""" + def get(group: str, field: str) -> str | None: + obj = (data.get(group) or {}).get(field) + if not isinstance(obj, dict) or "value" not in obj: + return None + return f"{obj['value']} {obj.get('unit', '')}".replace("โ„ƒ", "ยฐC").replace("mm/hr", "mm/h").strip() + wind = get("wind", "wind_speed") + parts = [f"rain {rate}" if (rate := get("rainfall", "rain_rate")) else None, + f"{daily} today" if (daily := get("rainfall", "daily")) else None, + get("outdoor", "temperature"), get("outdoor", "humidity"), + f"wind {wind}" if wind else None, get("pressure", "relative")] + return ", ".join(p for p in parts if p) or "no readings" + + class Ecowitt: name = "Ecowitt" @@ -212,13 +228,13 @@ async def live_rain(self) -> tuple[int, dict] | None: raise ValueError("no rain reading in the response") except Exception as e: # the alert still works from the history if self._live_ok is not False: # once per outage, not every minute - log.warning("Live rain reading failed (%s); the rain alert uses the 5-minute history", e) + log.warning("Live readings failed (%s); the rain alert uses the 5-minute history", e) self._live_ok = False return None if self._live_ok is None: - log.info("Live rain reading working (%.1f mm/h, %.1f mm today)", row.get("rainfall.rain_rate", 0), row.get("rainfall.daily", 0)) + log.info("Live readings working (%s)", live_summary(data)) elif not self._live_ok: - log.info("Live rain reading recovered") + log.info("Live readings recovered (%s)", live_summary(data)) self._live_ok = True return ts, row diff --git a/scripts/show_request.py b/scripts/show_request.py index 6072344..8ca76d7 100644 --- a/scripts/show_request.py +++ b/scripts/show_request.py @@ -25,7 +25,7 @@ from lib import intent, prompt # noqa: E402 from lib.airgradient import source as air # noqa: E402 -from lib.ecowitt import days, station # noqa: E402 +from lib.ecowitt import days, source as station # noqa: E402 from lib.ecowitt.api import UNITS # noqa: E402 from lib.ecowitt.store import HistoryCache # noqa: E402 from lib.llm import Agent # noqa: E402 diff --git a/tests/evalkit.py b/tests/evalkit.py index c2efacf..5fdb060 100644 --- a/tests/evalkit.py +++ b/tests/evalkit.py @@ -14,7 +14,7 @@ from lib import compose, intent, prompt from lib.airgradient import days as air_days, source as air -from lib.ecowitt import days, link, station +from lib.ecowitt import days, link, source as station from lib.forecast import source as forecast from lib.pollen import source as pollen from lib.llm import Agent diff --git a/tests/test_ecowitt.py b/tests/test_ecowitt.py index 0c149c7..7dad03d 100644 --- a/tests/test_ecowitt.py +++ b/tests/test_ecowitt.py @@ -973,7 +973,7 @@ async def test_weather_all_week_is_a_stack_of_every_reading_the_station_has(tmp_ def test_a_reading_named_as_a_group_becomes_its_group_and_an_unknown_name_is_dropped(): - from lib.ecowitt.station import parse_groups + from lib.ecowitt.source import parse_groups assert parse_groups("pressure,humidity") == ["pressure", "outdoor"] assert parse_groups("rain, dew_point, wind, outdoor") == ["rainfall", "outdoor", "wind"] assert parse_groups("outdoor.temperature,bogus") == ["outdoor"] and parse_groups("bogus") == ["outdoor", "indoor"] @@ -1083,7 +1083,7 @@ async def test_a_failing_live_rain_reading_warns_once_then_says_when_it_recovers async def realtime(mac, groups): return answers.pop(0) monkeypatch.setattr(station.api, "realtime", realtime) - with caplog.at_level("INFO", logger="lib.ecowitt.station"): + with caplog.at_level("INFO", logger="lib.ecowitt.source"): assert await station.live_rain() is None and await station.live_rain() is None assert await station.live_rain() == (100, {"rainfall.rain_rate": 1.5, "rainfall.daily": 2.0}) await station.live_rain() @@ -1213,3 +1213,12 @@ def _history(p): text = caplog.text assert "History cache: " in text and str(eco.cache.path) in text assert "History query empty" in text and "readings held for the window {'5min': 0, '30min': 0, '1day': 0}" in text + + +def test_the_live_summary_gives_rain_first_then_a_few_readings_and_skips_what_is_missing(): + from lib.ecowitt.source import live_summary + cell = lambda v, u: {"value": v, "unit": u} + data = {"rainfall": {"rain_rate": cell("0.0", "mm/hr"), "daily": cell("0.8", "mm")}, "outdoor": {"temperature": cell("20.3", "โ„ƒ"), "humidity": cell("62", "%")}, + "wind": {"wind_speed": cell("12", "km/h")}, "pressure": {"relative": cell("1013.2", "hPa")}} + assert live_summary(data) == "rain 0.0 mm/h, 0.8 mm today, 20.3 ยฐC, 62 %, wind 12 km/h, 1013.2 hPa" + assert live_summary({"rainfall": {"daily": cell("0.8", "mm")}}) == "0.8 mm today" and live_summary({}) == "no readings" diff --git a/tests/test_startup.py b/tests/test_startup.py index afec284..5ef1f26 100644 --- a/tests/test_startup.py +++ b/tests/test_startup.py @@ -1,5 +1,6 @@ """Runs ecobot.main() end to end against fake sources and a fake Telegram.""" +import logging import asyncio import os import signal @@ -254,3 +255,9 @@ def test_a_long_alerts_line_continues_indented_at_90_characters(): lines = ecobot.wrapped("Alerts: ", list(LABELS.values()), ": 3 chats").splitlines() assert len(lines) > 1 and all(len(line) <= 90 for line in lines) assert all(line.startswith(" " * 13) and not line.startswith(" " * 14) for line in lines[1:]) + + +def test_log_lines_name_their_module_except_the_main_script(): + fmt = ecobot.LogFormat("%(levelname)s %(where)s%(message)s") + record = lambda name: logging.LogRecord(name, logging.INFO, __file__, 1, "hello", None, None) + assert fmt.format(record("ecobot")) == "INFO hello" and fmt.format(record("lib.bot")) == "INFO lib.bot: hello" From 1f000c06b73f1d1f37d924e8656933eec3867d52 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 00:55:36 +0000 Subject: [PATCH 14/18] Report refresh without a toast; the time of each section's data after its heading Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/airgradient/source.py | 12 ++++-------- lib/bot.py | 2 +- lib/forecast/source.py | 5 +++-- lib/pollen/source.py | 4 +++- lib/report.py | 21 +++++++++++++++++---- lib/timeutil.py | 6 ++++++ tests/test_bot.py | 2 +- tests/test_forecast.py | 1 + tests/test_pollen.py | 1 + tests/test_report.py | 38 +++++++++++++++++++++++++++++--------- 10 files changed, 66 insertions(+), 26 deletions(-) diff --git a/lib/airgradient/source.py b/lib/airgradient/source.py index b90bd29..9aa1a51 100644 --- a/lib/airgradient/source.py +++ b/lib/airgradient/source.py @@ -27,7 +27,7 @@ from ..lines import Plotted, build_line from ..panels import panel_for from ..specs import Chart, Line -from ..timeutil import local_date, now_local, to_local +from ..timeutil import local_date, now_local, to_local, when from ..tools import Tool, Turn from ..warm import Warmer from .days import DESCRIPTION as DAYS_DESCRIPTION, PARAMETERS as DAYS_PARAMETERS, air_days_tool @@ -125,10 +125,6 @@ def _lock(self, key) -> asyncio.Lock: def _now(self) -> datetime: return now_local(self.tz) - def _when(self, ts: int) -> str: - dt = to_local(ts, self.tz) - return f"{dt:%a} {dt.day} {dt:%b %Y} {dt:%I:%M%p}".replace(" 0", " ").replace("AM", "am").replace("PM", "pm") - # ---------- cached data ---------- async def _current_row(self, refresh: bool = False) -> dict: async with self._lock("current"): @@ -278,7 +274,7 @@ async def current(self) -> dict: row = await self._current_row() previous = await self._previous_row(epoch(row["timestamp"])) if row.get("timestamp") else None out = {"sensor": row.get("locationName"), "sensor_type": row.get("locationType"), - "time": self._when(epoch(row["timestamp"])) if row.get("timestamp") else None} + "time": when(epoch(row["timestamp"]), self.tz) if row.get("timestamp") else None} if row.get("timestamp"): # for the air alerts' staleness check (removed before the model sees it) out["_time_utc"] = datetime.fromisoformat(row["timestamp"].replace("Z", "+00:00")) for name, (_, unit) in METRICS.items(): @@ -337,8 +333,8 @@ async def history(self, start: str | None, end: str | None, chart: bool = False, if not pts: continue lo, hi = min(pts, key=lambda p: p[1]), max(pts, key=lambda p: p[1]) - entry = {"unit": unit, "low": lo[1], "low_time": self._when(lo[0]), - "high": hi[1], "high_time": self._when(hi[0]), + entry = {"unit": unit, "low": lo[1], "low_time": when(lo[0], self.tz), + "high": hi[1], "high_time": when(hi[0], self.tz), "average": round(statistics.fmean(v for _, v in pts), 1)} entry["high_rating"], entry["average_rating"] = rating(name, hi[1]), rating(name, entry["average"]) if name == "pm2_5": diff --git a/lib/bot.py b/lib/bot.py index d7c1635..cac0cec 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -569,7 +569,7 @@ async def on_report_button(self, update: Update, context: ContextTypes.DEFAULT_T log.warning("Report refresh failed: %s", e) await query.answer("Couldn't refresh, please try again", show_alert=True) return - await query.answer("Refreshed") + await query.answer() # no toast: the times in the headings show what changed await self._edit_irrigation(query, text, report_menu.keyboard()) async def on_period_button(self, update: Update, context: ContextTypes.DEFAULT_TYPE): diff --git a/lib/forecast/source.py b/lib/forecast/source.py index 8d1a5d3..9ad1787 100644 --- a/lib/forecast/source.py +++ b/lib/forecast/source.py @@ -13,7 +13,7 @@ from .. import intent from ..config import Config from ..snapshots import Snapshots -from ..timeutil import now_local +from ..timeutil import now_local, when from ..tools import Tool, Turn from ..warm import Warmer, in_sync_hours @@ -227,4 +227,5 @@ async def handle(self, args: dict, turn: Turn | None = None) -> str: return json.dumps({"error": "No forecast is available right now."}) if turn: turn.forecast_shown.extend(self.upcoming(count)) # remembered for the chat once the answer is sent - return json.dumps({"lines": lines, "place": self.place, "source": self.source}, ensure_ascii=False) + stamped = {"time": when(int(self.fetched_at), self.tz)} if self.fetched_at else {} # when it was last fetched + return json.dumps({"lines": lines, "place": self.place, "source": self.source, **stamped}, ensure_ascii=False) diff --git a/lib/pollen/source.py b/lib/pollen/source.py index e591dee..0022131 100644 --- a/lib/pollen/source.py +++ b/lib/pollen/source.py @@ -14,7 +14,7 @@ from .. import intent from ..config import Config from ..snapshots import Snapshots -from ..timeutil import now_local +from ..timeutil import now_local, when from ..tools import Tool, Turn from ..warm import Warmer, in_sync_hours from .parse import LEVEL_EMOJI, parse_melbourne_pollen @@ -151,6 +151,8 @@ async def handle(self, args: dict, turn: Turn | None = None) -> str: cur = self.current() out = {"lines": lines, "place": self.place, "source": "melbournepollen.com.au (grass pollen from pollen-trap data; asthma risk from the " "Victorian Department of Health and BOM, 1 Oct - 31 Dec)"} + if cur["fetched_at"]: + out["time"] = when(int(cur["fetched_at"]), self.tz) # when the page was last fetched if cur["asthma"] and cur["asthma"]["updated"]: out["asthma_updated"] = cur["asthma"]["updated"] if not cur["asthma"]: diff --git a/lib/report.py b/lib/report.py index 2cf7a17..dee2e44 100644 --- a/lib/report.py +++ b/lib/report.py @@ -45,9 +45,22 @@ def _join(parts: list[str | None], sep: str = ", ") -> str: return sep.join(p for p in parts if p) +def _stamp(result: dict) -> str | None: + """The time of day of the data, "11:45pm", from the result's "time" ("Sun 11 Oct 2026 23:50" from the station, "Sun 11 Oct 2026 11:45pm" + from the rest); None when it is missing or unreadable.""" + for form in ("%a %d %b %Y %I:%M%p", "%a %d %b %Y %H:%M"): + try: + at = datetime.strptime(result.get("time") or "", form) + except ValueError: + continue + return f"{at.hour % 12 or 12}:{at:%M}{'am' if at.hour < 12 else 'pm'}" + return None + + def _heading(name: str, result: dict) -> str: - """"Forecast (Melbourne)": the place the result names, if it does.""" - return f"{name} ({result['place']})" if result.get("place") else name + """"Forecast (Melbourne, 6:00pm)": the place the result names and the time of its data, whichever it has.""" + note = ", ".join(x for x in (result.get("place"), _stamp(result)) if x) + return f"{name} ({note})" if note else name def _getters(now: dict): @@ -180,10 +193,10 @@ def report(results: dict[str, str]) -> str: sections = [] if "weather_now" in results: now = _load(results["weather_now"]) - sections.append(["Weather station", *(weather_lines(now) if now else ["โ€ข not available right now"])]) + sections.append([_heading("Weather station", now or {}), *(weather_lines(now) if now else ["โ€ข not available right now"])]) if "air_quality" in results: air = _load(results["air_quality"]) - sections.append(["Air quality", *(air_lines(air) if air else ["โ€ข not available right now"])]) + sections.append([_heading("Air quality", air or {}), *(air_lines(air) if air else ["โ€ข not available right now"])]) if pollen := _load(results.get("pollen_asthma")): sections.append([_heading("Pollen & asthma", pollen), *(f"โ€ข {line}" for line in pollen.get("lines", []))]) if forecast := _load(results.get("weather_forecast")): diff --git a/lib/timeutil.py b/lib/timeutil.py index 8dde16c..671a33f 100644 --- a/lib/timeutil.py +++ b/lib/timeutil.py @@ -17,6 +17,12 @@ def to_local(ts: int, tz: tzinfo) -> datetime: return datetime.fromtimestamp(ts, UTC).astimezone(tz) +def when(ts: int, tz: tzinfo) -> str: + """An epoch as "Sun 11 Oct 2026 11:45pm" in tz (the form the tools put in their "time" field).""" + dt = to_local(ts, tz) + return f"{dt:%a} {dt.day} {dt:%b %Y} {dt:%I:%M%p}".replace(" 0", " ").replace("AM", "am").replace("PM", "pm") + + def local_date(ts: int, tz: tzinfo) -> date: return to_local(ts, tz).date() diff --git a/tests/test_bot.py b/tests/test_bot.py index 812d5cc..a37b119 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -553,4 +553,4 @@ async def send_chat_action(*a, **k): Tools.reading = "13.0 โ„ƒ" query = Query() await bot.on_report_button(NS(callback_query=query), context) - assert query.toast == "Refreshed" and "13.0 ยฐC" in query.edits[0][0] and query.edits[0][1].inline_keyboard[0][0].callback_data == report_menu.REFRESH + assert query.toast is None and "13.0 ยฐC" in query.edits[0][0] and query.edits[0][1].inline_keyboard[0][0].callback_data == report_menu.REFRESH diff --git a/tests/test_forecast.py b/tests/test_forecast.py index 86ea375..e0f9da8 100644 --- a/tests/test_forecast.py +++ b/tests/test_forecast.py @@ -95,6 +95,7 @@ async def test_the_place_is_the_configured_name(tmp_path): forecast, _ = make(tmp_path, place="Geelong") await forecast.start() assert json.loads(await forecast.handle({}))["place"] == "Geelong" + assert json.loads(await forecast.handle({}))["time"] # when it was fetched, for the report's heading await forecast.close() diff --git a/tests/test_pollen.py b/tests/test_pollen.py index d24a54f..0201a2c 100644 --- a/tests/test_pollen.py +++ b/tests/test_pollen.py @@ -149,6 +149,7 @@ async def test_last_seasons_page_is_not_shown_and_the_report_leaves_the_block_ou pollen, _ = make_on(tmp_path, date(2026, 12, 20), html=lambda: page(grass="High", day=date(2026, 12, 20))) await pollen.start() assert pollen.lines()[0].startswith("Grass pollen: ๐Ÿ”ด High") + assert json.loads(await pollen.handle({"cached": True}))["time"] # when the page was fetched, for the report's heading await pollen.close() january, calls = make_on(tmp_path, date(2027, 1, 5)) # the saved page is still in the database await january.start() diff --git a/tests/test_report.py b/tests/test_report.py index 7cc9505..08fb92a 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -16,9 +16,9 @@ '"unit": "ยตg/mยณ", "rating": "๐ŸŸข good"}, "co2": {"value": 433.0, "unit": "ppm", "rating": "๐ŸŸข good"}, "voc_index": {"value": 88.0, ' '"unit": "relative index (100 = this sensor\'s recent average)", "rating": "๐ŸŸข good"}, "nox_index": {"value": 1.0, ' '"unit": "relative index (1 = baseline)", "rating": "๐ŸŸข good"}}') -POLLEN = '{"lines": ["Grass pollen: ๐ŸŸข Low", "Thunderstorm asthma risk: ๐ŸŸข Low"], "place": "Melbourne", "source": "melbournepollen.com.au"}' +POLLEN = '{"lines": ["Grass pollen: ๐ŸŸข Low", "Thunderstorm asthma risk: ๐ŸŸข Low"], "place": "Melbourne", "source": "melbournepollen.com.au", "time": "Fri 2 Oct 2026 6:00pm"}' FORECAST = ('{"lines": ["Today: ๐ŸŒฆ๏ธ Showers. 11โ€“16ยฐC, 97% chance of rain", "Saturday: โ›ˆ๏ธ Thunderstorm. 12โ€“20ยฐC, 95% chance of rain"], ' - '"place": "Melbourne", "source": "Open-Meteo"}') + '"place": "Melbourne", "source": "Open-Meteo", "time": "Fri 2 Oct 2026 6:05pm"}') WEATHER = """\ โ€ข Outdoor: ๐Ÿงฅ 12.3 ยฐC, ๐Ÿ’ฆ 94 %, dew point 11.3 ยฐC, VPD 0.085 kPa @@ -29,10 +29,10 @@ def test_the_report_is_laid_out_in_code_with_emoji_before_the_readings_they_belong_to(): got = report.report({"weather_now": NOW, "air_quality": AIR, "pollen_asthma": POLLEN, "weather_forecast": FORECAST}) - assert got == f"""Weather station + assert got == f"""Weather station (2:21am) {WEATHER} -Air quality +Air quality (2:22am) โ€ข PM2.5: 1.4 ยตg/mยณ ๐ŸŸข good (AQI 8) โ€ข PM10: 3.0 ยตg/mยณ ๐ŸŸข good โ€ข PM1: 1.0 ยตg/mยณ ๐ŸŸข good @@ -40,11 +40,11 @@ def test_the_report_is_laid_out_in_code_with_emoji_before_the_readings_they_belo โ€ข VOC index: 88 ๐ŸŸข good โ€ข NOx index: 1.0 ๐ŸŸข good -Pollen & asthma (Melbourne) +Pollen & asthma (Melbourne, 6:00pm) โ€ข Grass pollen: ๐ŸŸข Low โ€ข Thunderstorm asthma risk: ๐ŸŸข Low -Forecast (Melbourne) +Forecast (Melbourne, 6:05pm) โ€ข Today: ๐ŸŒฆ๏ธ Showers. 11โ€“16ยฐC, 97% chance of rain โ€ข Saturday: โ›ˆ๏ธ Thunderstorm. 12โ€“20ยฐC, 95% chance of rain""" assert not got.startswith("Current report") @@ -68,7 +68,7 @@ def test_a_failed_or_missing_source_is_left_out_or_said_so_and_the_forecast_with got = report.report({"weather_now": "Error calling tool: boom", "air_quality": AIR, "pollen_asthma": '{"error": "No pollen levels are available right now."}', "weather_forecast": '{"lines": ["Today: โ˜€๏ธ Sunny. 20โ€“25ยฐC"]}'}) - assert got.startswith("Weather station\nโ€ข not available right now\n\nAir quality\nโ€ข PM2.5:") + assert got.startswith("Weather station\nโ€ข not available right now\n\nAir quality (2:22am)\nโ€ข PM2.5:") assert "Pollen" not in got and got.endswith("Forecast\nโ€ข Today: โ˜€๏ธ Sunny. 20โ€“25ยฐC") assert report.report({}) == "No readings are available right now." @@ -188,8 +188,8 @@ def test_a_reading_lookup_prints_only_what_was_asked(): def test_air_pollen_and_forecast_lookups_print_the_tools_own_lines(): assert report.lookup("air", AIR, ["pm10"], []) == "โ€ข PM10: 3.0 ยตg/mยณ ๐ŸŸข good" assert report.lookup("air", AIR, [], []).splitlines()[0] == "โ€ข PM2.5: 1.4 ยตg/mยณ ๐ŸŸข good (AQI 8)" - assert report.lookup("pollen", POLLEN, [], []) == "Pollen & asthma (Melbourne)\nโ€ข Grass pollen: ๐ŸŸข Low\nโ€ข Thunderstorm asthma risk: ๐ŸŸข Low" - assert report.lookup("forecast", FORECAST, [], []).startswith("Forecast (Melbourne)\nโ€ข Today: ") + assert report.lookup("pollen", POLLEN, [], []) == "Pollen & asthma (Melbourne, 6:00pm)\nโ€ข Grass pollen: ๐ŸŸข Low\nโ€ข Thunderstorm asthma risk: ๐ŸŸข Low" + assert report.lookup("forecast", FORECAST, [], []).startswith("Forecast (Melbourne, 6:05pm)\nโ€ข Today: ") assert report.lookup("pollen", json.dumps({"error": "off season"}), [], []) is None @@ -226,3 +226,23 @@ def test_a_figure_per_period_lookup_gives_the_figure_over_the_whole_period(): assert report.lookup("days", result, [], []) == "Total rain: 1234.5 mm over 731 days (Fri 4 Oct 2024 โ€“ Sat 3 Oct 2026)." hot = json.dumps({"period": "a 2025 - b 2026", "days_checked": 3, "matching_days": 3, "stat": {"what": "Highest temperature", "of": "temp_max", "unit": "โ„ƒ"}, "value": 41.2}) assert report.lookup("days", hot, [], []).startswith("Highest temperature: 41.2 ยฐC over 3 days") + + +def test_a_heading_carries_the_place_and_the_time_of_day_of_its_data(): + assert report._heading("Forecast", {"place": "Melbourne", "time": "Fri 2 Oct 2026 6:05pm"}) == "Forecast (Melbourne, 6:05pm)" + assert report._heading("Weather station", {"time": "Fri 02 Oct 2026 00:07"}) == "Weather station (12:07am)" # the station's 24-hour form + assert report._heading("Weather station", {"time": "Fri 02 Oct 2026 13:30"}) == "Weather station (1:30pm)" + assert report._heading("Air quality", {"time": "Fri 2 Oct 2026 12:00pm"}) == "Air quality (12:00pm)" + assert report._heading("Forecast", {"place": "Melbourne"}) == "Forecast (Melbourne)" # no time: as before + assert report._heading("Air quality", {"time": "yesterday"}) == "Air quality" and report._heading("Air quality", {"time": None}) == "Air quality" + + +def test_when_gives_the_form_the_tools_use_in_their_time_field(): + from datetime import datetime + from lib.timeutil import when + tz = ZoneInfo("Australia/Melbourne") + assert when(int(datetime(2026, 10, 2, 18, 5, tzinfo=tz).timestamp()), tz) == "Fri 2 Oct 2026 6:05pm" + assert when(int(datetime(2026, 10, 2, 0, 7, tzinfo=tz).timestamp()), tz) == "Fri 2 Oct 2026 12:07am" + + +from zoneinfo import ZoneInfo # noqa: E402 From 338a6ab266b93af58d80e93c4aedcefeb865ff31 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 00:56:40 +0000 Subject: [PATCH 15/18] Shorter live-readings log lines Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/ecowitt/source.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/ecowitt/source.py b/lib/ecowitt/source.py index 6a5c47f..16dd52e 100644 --- a/lib/ecowitt/source.py +++ b/lib/ecowitt/source.py @@ -232,9 +232,9 @@ async def live_rain(self) -> tuple[int, dict] | None: self._live_ok = False return None if self._live_ok is None: - log.info("Live readings working (%s)", live_summary(data)) + log.info("Working (%s)", live_summary(data)) elif not self._live_ok: - log.info("Live readings recovered (%s)", live_summary(data)) + log.info("Recovered (%s)", live_summary(data)) self._live_ok = True return ts, row From 7bf8c81135fa11b063caa973cb7d329e97c0b88e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 00:58:20 +0000 Subject: [PATCH 16/18] Fix the live-readings test for the shorter wording Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- tests/test_ecowitt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_ecowitt.py b/tests/test_ecowitt.py index 7dad03d..ba1f84c 100644 --- a/tests/test_ecowitt.py +++ b/tests/test_ecowitt.py @@ -1087,7 +1087,7 @@ async def realtime(mac, groups): assert await station.live_rain() is None and await station.live_rain() is None assert await station.live_rain() == (100, {"rainfall.rain_rate": 1.5, "rainfall.daily": 2.0}) await station.live_rain() - assert [r.levelname for r in caplog.records] == ["WARNING", "INFO"] and "recovered" in caplog.records[1].message + assert [r.levelname for r in caplog.records] == ["WARNING", "INFO"] and "Recovered" in caplog.records[1].message async def test_a_waiting_person_goes_before_the_background_requests_queued_ahead_of_them(): From f7bdc0d4eda8c9b7ad233b879466913ea8774205 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 01:29:46 +0000 Subject: [PATCH 17/18] Bold the report headings (the name only, not the brackets or time) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/bot.py | 11 +++++++---- lib/report.py | 13 +++++++++++++ tests/test_bot.py | 5 ++++- tests/test_report.py | 11 +++++++++++ 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/lib/bot.py b/lib/bot.py index cac0cec..259008a 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -183,11 +183,12 @@ async def run(self, stop: asyncio.Event): async def deliver(msg: Message, text: str, photos: list[bytes], link: tuple[str, str] | None = None, markup=None, - titles: list[str] | None = None, period_row=None, sent_photo: list | None = None) -> bool: + titles: list[str] | None = None, period_row=None, sent_photo: list | None = None, bold_headings: bool = False) -> bool: """Send the answer with any charts. A chart carries only its title as its caption; any answer text is its own message, sent first. link = (label, url) adds a small italic link line at the end of the text. markup (the button keyboard) rides on the last text, or on the single photo when there is no text; returns whether it was sent (a group of photos can't carry one). - period_row (the period buttons) rides on a single photo instead, and then the keyboard waits; the photo's Message goes into sent_photo.""" + period_row (the period buttons) rides on a single photo instead, and then the keyboard waits; the photo's Message goes into sent_photo. + bold_headings: the text is the report (or a pollen / forecast lookup), whose heading names are set in bold.""" text = text.strip() if photos: text = strip_chart_talk(text) @@ -198,6 +199,7 @@ async def deliver(msg: Message, text: str, photos: list[bytes], link: tuple[str, for i, chunk in enumerate(chunks): last = i == len(chunks) - 1 body, ents = with_footer(chunk, link) if last else (chunk, []) + ents = [*(report.heading_entities(chunk) if bold_headings else []), *ents] await msg.reply_text(body, entities=ents or None, disable_web_page_preview=True, **({"reply_markup": markup} if markup and last and not photos else {})) carried = bool(markup) and not photos @@ -570,7 +572,7 @@ async def on_report_button(self, update: Update, context: ContextTypes.DEFAULT_T await query.answer("Couldn't refresh, please try again", show_alert=True) return await query.answer() # no toast: the times in the headings show what changed - await self._edit_irrigation(query, text, report_menu.keyboard()) + await self._edit_irrigation(query, text, report_menu.keyboard(), report.heading_entities(text)) async def on_period_button(self, update: Update, context: ContextTypes.DEFAULT_TYPE): """A press on 1D / 7D / 1M / 3M / 1Y under a chart: ask the chart's question again for that period and put the new chart @@ -897,7 +899,8 @@ async def respond(self, update: Update, context: ContextTypes.DEFAULT_TYPE, text reply, photos, row = "", [], None carried_keyboard = await deliver(msg, reply, photos, link=air.link if air and used_air else None, markup=markup or (report_menu.keyboard() if refresh else None), - titles=titles, period_row=row, sent_photo=sent_photo) and bool(markup) # the refresh button is not the chat's keyboard + titles=titles, period_row=row, sent_photo=sent_photo, + bold_headings=ok and bool(read.report or read.lookup in ("pollen", "forecast"))) and bool(markup) # the refresh button is not the chat's keyboard if sent_photo: self.charted.remember(msg.chat_id, sent_photo[0].message_id, text) if self.state and ok and turn.forecast_shown: # it was sent: a later revision of these days is worth telling this chat diff --git a/lib/report.py b/lib/report.py index dee2e44..9d9b45d 100644 --- a/lib/report.py +++ b/lib/report.py @@ -5,6 +5,8 @@ import re from datetime import datetime +from telegram import MessageEntity + from .ecowitt.glance import solar_band from .series import WEATHER, find @@ -45,6 +47,17 @@ def _join(parts: list[str | None], sep: str = ", ") -> str: return sep.join(p for p in parts if p) +HEADINGS = ("Weather station", "Air quality", "Pollen & asthma", "Forecast") # the names the report's sections (and the pollen and forecast lookups) start with +HEADING_LINE = re.compile(rf"^({'|'.join(map(re.escape, HEADINGS))})(?= \(|$)", re.M) + + +def heading_entities(text: str) -> list[MessageEntity]: + """Bold entities over the name of each heading line in `text` (not over its brackets). Telegram counts positions in UTF-16 code + units, where an emoji is two.""" + units = lambda s: len(s.encode("utf-16-le")) // 2 + return [MessageEntity(MessageEntity.BOLD, units(text[:m.start()]), units(m.group(1))) for m in HEADING_LINE.finditer(text)] + + def _stamp(result: dict) -> str | None: """The time of day of the data, "11:45pm", from the result's "time" ("Sun 11 Oct 2026 23:50" from the station, "Sun 11 Oct 2026 11:45pm" from the rest); None when it is missing or unreadable.""" diff --git a/tests/test_bot.py b/tests/test_bot.py index a37b119..f655542 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -534,11 +534,12 @@ async def answer(self, text=None, **kw): async def edit_message_text(self, text, reply_markup=None, **kw): self.edits.append((text, reply_markup)) + self.entities = kw.get("entities") sources = [NS(name=n, wants=lambda t: True, poke=lambda: None, describe=lambda n=n: n) for n in ("Ecowitt", "AirGradient")] sent = [] async def reply_text(body, **kw): - sent.append((body, kw.get("reply_markup"))) + sent.append((body, kw.get("reply_markup"), kw.get("entities"))) msg = NS(chat_id=1, message_thread_id=None, is_topic_message=False, reply_text=reply_text, chat=NS(type="private", title=None), from_user=NS(full_name="Rob"), reply_to_message=None) update = NS(effective_message=msg, effective_chat=msg.chat, effective_user=NS(username="rob", full_name="Rob")) @@ -550,7 +551,9 @@ async def send_chat_action(*a, **k): await bot.respond(update, context, "report") await bot.respond(update, context, "weather now") assert [b.callback_data for b in sent[0][1].inline_keyboard[0]] == [report_menu.REFRESH] and sent[1][1] is None + assert [e.type for e in sent[0][2]] == ["bold", "bold"] and sent[1][2] is None # the report's headings are bold; "weather now" has none Tools.reading = "13.0 โ„ƒ" query = Query() await bot.on_report_button(NS(callback_query=query), context) assert query.toast is None and "13.0 ยฐC" in query.edits[0][0] and query.edits[0][1].inline_keyboard[0][0].callback_data == report_menu.REFRESH + assert [e.type for e in query.entities] == ["bold", "bold"] diff --git a/tests/test_report.py b/tests/test_report.py index 08fb92a..42c553c 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -246,3 +246,14 @@ def test_when_gives_the_form_the_tools_use_in_their_time_field(): from zoneinfo import ZoneInfo # noqa: E402 + + +def test_only_the_name_of_each_heading_is_bold_not_its_brackets_or_time(): + text = report.report({"weather_now": NOW, "air_quality": AIR, "pollen_asthma": POLLEN, "weather_forecast": FORECAST}) + units = lambda s: len(s.encode("utf-16-le")) // 2 + names = [text.encode("utf-16-le")[2 * e.offset:2 * (e.offset + e.length)].decode("utf-16-le") for e in report.heading_entities(text)] + assert names == ["Weather station", "Air quality", "Pollen & asthma", "Forecast"] + assert all(e.type == "bold" for e in report.heading_entities(text)) + after_emoji = report.heading_entities("โ€ข Rain today: โ˜” 6.3 mm ๐ŸŒง๏ธ\nForecast (Melbourne, 6:05pm)\nโ€ข Today") # emoji count as two units + assert [(e.offset, e.length) for e in after_emoji] == [(units("โ€ข Rain today: โ˜” 6.3 mm ๐ŸŒง๏ธ\n"), len("Forecast"))] + assert report.heading_entities("Forecast\nโ€ข x") and report.heading_entities("โ€ข Forecast: sunny\nForecasts") == [] From 2c050e3c9cd7621992b2622063bf44b5f5337896 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 02:25:15 +0000 Subject: [PATCH 18/18] Ecowitt cache: key readings by a small series id instead of repeating mac, cycle, group and field scripts/migrate_cache.py converts an existing file in place (bot stopped); the fixture in tests/data is converted too (1.3 MB -> 0.3 MB, identical contents). Values stay text, so every output is unchanged. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 2 +- lib/ecowitt/store.py | 57 +++++++++++---------- scripts/cache_status.py | 6 +-- scripts/check_rain.py | 2 +- scripts/migrate_cache.py | 73 +++++++++++++++++++++++++++ tests/data/ecowitt_cache.sqlite | Bin 1277952 -> 344064 bytes tests/data/make_fixture.py | 3 +- tests/test_check_rain.py | 6 +-- tests/test_contract.py | 15 +++--- tests/test_migrate_cache.py | 87 ++++++++++++++++++++++++++++++++ tests/test_store.py | 4 +- 11 files changed, 212 insertions(+), 43 deletions(-) create mode 100644 scripts/migrate_cache.py create mode 100644 tests/test_migrate_cache.py diff --git a/README.md b/README.md index 79e4c31..d0a5ed2 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ lib/irrigation/ the optional Tuya irrigation controller: tuya (signed AP lib/pollen/, forecast/ the optional website sources: pollen + thunderstorm asthma, Open-Meteo forecast (fetched only 6 am-6 pm, see warm.py SYNC_HOURS) tests/ pytest, against fake Ecowitt/AirGradient/Telegram; tests/evals holds the routing cases, tests/charts the chart reference images scripts/ ecowitt_metrics.py (which metrics the station reports), cache_status.py (is everything cached?), eval_prompts.py, show_request.py (exactly what is - sent to the model for a question), check_rain.py (are rain totals trustworthy?), + sent to the model for a question), check_rain.py (are rain totals trustworthy?), migrate_cache.py (one-off: converts an Ecowitt cache from before the series layout), dump_specs.py (draw the charts from the caches, offline, to compare before/after a change) ``` diff --git a/lib/ecowitt/store.py b/lib/ecowitt/store.py index 567edd0..80262d6 100644 --- a/lib/ecowitt/store.py +++ b/lib/ecowitt/store.py @@ -1,8 +1,8 @@ """Where Ecowitt readings are kept between questions. -HistoryCache (SQLite, survives restarts) holds settled readings per -(mac, cycle, group, field, timestamp), plus which time ranges have been fetched per -(mac, cycle, group). Coverage is recorded even when Ecowitt returns nothing (e.g. before +HistoryCache (SQLite, survives restarts) holds settled readings per series and timestamp (a series is one +(mac, cycle, group, field), kept once in the `series` table with its unit and a small integer id, so a reading's key is just that id and +its time), plus which time ranges have been fetched per (mac, cycle, group). Coverage is recorded even when Ecowitt returns nothing (e.g. before the station existed), so those ranges aren't asked for again. The newest readings may still change (late uploads), so only buckets older than a per-cycle horizon are stored. @@ -80,12 +80,12 @@ def __init__(self, path, units: dict): self.db.execute("PRAGMA journal_mode=WAL") self.db.executescript(""" CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT); + CREATE TABLE IF NOT EXISTS series ( + id INTEGER PRIMARY KEY, mac TEXT, cycle TEXT, grp TEXT, field TEXT, unit TEXT, + UNIQUE (mac, cycle, grp, field)); CREATE TABLE IF NOT EXISTS points ( - mac TEXT, cycle TEXT, grp TEXT, field TEXT, ts INTEGER, value TEXT, - PRIMARY KEY (mac, cycle, grp, field, ts)) WITHOUT ROWID; - CREATE TABLE IF NOT EXISTS fields ( - mac TEXT, cycle TEXT, grp TEXT, field TEXT, unit TEXT, - PRIMARY KEY (mac, cycle, grp, field)) WITHOUT ROWID; + series INTEGER, ts INTEGER, value TEXT, + PRIMARY KEY (series, ts)) WITHOUT ROWID; CREATE TABLE IF NOT EXISTS coverage (mac TEXT, cycle TEXT, grp TEXT, start INTEGER, end INTEGER); CREATE INDEX IF NOT EXISTS coverage_key ON coverage (mac, cycle, grp); """) @@ -94,7 +94,7 @@ def __init__(self, path, units: dict): row = self.db.execute("SELECT value FROM meta WHERE key = 'signature'").fetchone() if row and row[0] != signature: log.warning("History cache settings changed - clearing %s", path) - self.db.executescript("DELETE FROM points; DELETE FROM fields; DELETE FROM coverage;") + self.db.executescript("DELETE FROM points; DELETE FROM series; DELETE FROM coverage;") self.db.execute("INSERT OR REPLACE INTO meta VALUES ('signature', ?)", (signature,)) self._convert_units() self.db.commit() @@ -106,11 +106,10 @@ def _convert_units(self): if (row := self.db.execute("SELECT value FROM meta WHERE key = 'unit_fixes'").fetchone()) and row[0] == done: return for (prefix, old), (unit, factor) in UNIT_FIXES.items(): - for key in self.db.execute("SELECT mac, cycle, grp, field FROM fields WHERE unit = ? AND (field = ? OR substr(field, 1, ?) = ?)", - (old, prefix, len(prefix) + 1, prefix + "_")).fetchall(): - self.db.execute("UPDATE points SET value = printf('%.3f', CAST(value AS REAL) * ?) " - "WHERE mac=? AND cycle=? AND grp=? AND field=?", (factor, *key)) - self.db.execute("UPDATE fields SET unit = ? WHERE mac=? AND cycle=? AND grp=? AND field=?", (unit, *key)) + for (key,) in self.db.execute("SELECT id FROM series WHERE unit = ? AND (field = ? OR substr(field, 1, ?) = ?)", + (old, prefix, len(prefix) + 1, prefix + "_")).fetchall(): + self.db.execute("UPDATE points SET value = printf('%.3f', CAST(value AS REAL) * ?) WHERE series=?", (factor, key)) + self.db.execute("UPDATE series SET unit = ? WHERE id=?", (unit, key)) self.db.execute("INSERT OR REPLACE INTO meta VALUES ('unit_fixes', ?)", (done,)) def close(self): @@ -123,12 +122,12 @@ def _query(self, sql: str, *args) -> list: def count(self, mac: str, cycle: str, grp: str, start: int, end: int) -> int: """How many readings are held for this group at this resolution between two times (for diagnosing an empty answer).""" - return self._query("SELECT COUNT(*) FROM points WHERE mac=? AND cycle=? AND grp=? AND ts BETWEEN ? AND ?", - mac, cycle, grp, start, end)[0][0] + return self._query("SELECT COUNT(*) FROM points p JOIN series s ON s.id = p.series " + "WHERE s.mac=? AND s.cycle=? AND s.grp=? AND p.ts BETWEEN ? AND ?", mac, cycle, grp, start, end)[0][0] def newest(self, mac: str, cycle: str) -> int | None: """The time of the newest reading held at this resolution (None if none).""" - return self._query("SELECT MAX(ts) FROM points WHERE mac=? AND cycle=?", mac, cycle)[0][0] + return self._query("SELECT MAX(p.ts) FROM points p JOIN series s ON s.id = p.series WHERE s.mac=? AND s.cycle=?", mac, cycle)[0][0] def coverage(self, mac: str, cycle: str, grp: str) -> list[Interval]: """Every time range held for this group at this resolution (merged, oldest first).""" @@ -139,12 +138,13 @@ def missing(self, mac: str, cycle: str, grp: str, start: int, end: int) -> list[ def load_fields(self, mac: str, cycle: str, grp: str, fields: list[str] | None, start: int, end: int) -> dict: """A group's fields (all of them if `fields` is None) in Ecowitt's response shape: {field: {unit, list}}.""" - pick = f" AND field IN ({','.join('?' * len(fields))})" if fields is not None else "" + pick = f" AND s.field IN ({','.join('?' * len(fields))})" if fields is not None else "" args = (mac, cycle, grp, *(fields or ())) - units = dict(self._query(f"SELECT field, unit FROM fields WHERE mac=? AND cycle=? AND grp=?{pick}", *args)) + units = dict(self._query(f"SELECT s.field, s.unit FROM series s WHERE s.mac=? AND s.cycle=? AND s.grp=?{pick}", *args)) out: dict = {} - for field, ts, value in self._query(f"SELECT field, ts, value FROM points WHERE mac=? AND cycle=? AND grp=?{pick} " - "AND ts BETWEEN ? AND ? ORDER BY ts", *args, start, end): + for field, ts, value in self._query("SELECT s.field, p.ts, p.value FROM series s JOIN points p ON p.series = s.id " + f"WHERE s.mac=? AND s.cycle=? AND s.grp=?{pick} AND p.ts BETWEEN ? AND ? ORDER BY p.ts", + *args, start, end): out.setdefault(field, {"unit": units.get(field, ""), "list": {}})["list"][str(ts)] = value return out @@ -157,8 +157,9 @@ def slot_ranges(self, mac: str, grp: str, field: str, values: dict[int, float], """(lows, highs) for 30-minute readings that have no range of their own (dew point, feels-like, VPD, solar, UV, pressure ...): the lowest and highest of the cached 5-minute readings inside each slot (and the slot's own value). Only what the cache holds; slots with fewer than two 5-minute readings get none.""" - rows = self._query("SELECT ts, CAST(value AS REAL) FROM points WHERE mac=? AND cycle='5min' AND grp=? AND field=? " - "AND ts BETWEEN ? AND ? ORDER BY ts", mac, grp, field, start, end) + rows = self._query("SELECT p.ts, CAST(p.value AS REAL) FROM points p JOIN series s ON s.id = p.series " + "WHERE s.mac=? AND s.cycle='5min' AND s.grp=? AND s.field=? AND p.ts BETWEEN ? AND ? ORDER BY p.ts", + mac, grp, field, start, end) times, fine = [ts for ts, _ in rows], [v for _, v in rows] lows, highs = {}, {} for t, v in values.items(): @@ -207,13 +208,17 @@ def store(self, mac: str, cycle: str, groups: list[str], data: dict, start: int, with self._lock: grown = set() # groups that now carry a reading the cache has never had (Ecowitt added a metric) for _, _, grp, field, _ in units: - known = {r[0] for r in self.db.execute("SELECT field FROM fields WHERE mac=? AND cycle=? AND grp=?", (mac, cycle, grp))} + known = {r[0] for r in self.db.execute("SELECT field FROM series WHERE mac=? AND cycle=? AND grp=?", (mac, cycle, grp))} if known and field not in known: grown.add(grp) if grown: log.info("Ecowitt added a metric to %s (%s): its history is fetched again to fill it in", ", ".join(sorted(grown)), cycle) - self.db.executemany("INSERT OR REPLACE INTO points VALUES (?,?,?,?,?,?)", rows) - self.db.executemany("INSERT OR REPLACE INTO fields VALUES (?,?,?,?,?)", units) + ids = {} + for key in units: # a series is created the first time it is seen; its unit follows what Ecowitt says now + self.db.execute("INSERT OR IGNORE INTO series (mac, cycle, grp, field, unit) VALUES (?,?,?,?,?)", key) + self.db.execute("UPDATE series SET unit=? WHERE mac=? AND cycle=? AND grp=? AND field=?", (key[4], *key[:4])) + ids[key[:4]] = self.db.execute("SELECT id FROM series WHERE mac=? AND cycle=? AND grp=? AND field=?", key[:4]).fetchone()[0] + self.db.executemany("INSERT OR REPLACE INTO points VALUES (?,?,?)", [(ids[r[:4]], r[4], r[5]) for r in rows]) for grp, cov_end in covered: existing = [] if grp in grown else self.db.execute( # the days held so far lack it: they are asked for again "SELECT start, end FROM coverage WHERE mac=? AND cycle=? AND grp=?", (mac, cycle, grp)).fetchall() diff --git a/scripts/cache_status.py b/scripts/cache_status.py index 147c461..453fd36 100644 --- a/scripts/cache_status.py +++ b/scripts/cache_status.py @@ -26,8 +26,8 @@ def day(ts: int) -> str: def ecowitt(path: Path): db = sqlite3.connect(f"file:{path}?mode=ro", uri=True) for (mac,) in db.execute("SELECT DISTINCT mac FROM coverage").fetchall(): - first = db.execute("SELECT MIN(ts) FROM points WHERE mac=?", (mac,)).fetchone()[0] - rows = db.execute("SELECT COUNT(*) FROM points WHERE mac=?", (mac,)).fetchone()[0] + first = db.execute("SELECT MIN(p.ts) FROM points p JOIN series s ON s.id = p.series WHERE s.mac=?", (mac,)).fetchone()[0] + rows = db.execute("SELECT COUNT(*) FROM points p JOIN series s ON s.id = p.series WHERE s.mac=?", (mac,)).fetchone()[0] print(f"Ecowitt {mac}: {rows:,} readings, station data from {day(first) if first else 'n/a'}") now = time.time() for cycle in ("5min", "30min", "4hour", "1day"): @@ -42,7 +42,7 @@ def ecowitt(path: Path): gaps = subtract((int(start), int(end)), cov) if gaps and gaps[-1][1] >= end - 1 and gaps[-1][0] >= end - 2 * 86400: gaps.pop() # the newest day or two: still settling, filled in by the next archive run - stored = db.execute("SELECT COUNT(*) FROM points WHERE mac=? AND cycle=? AND grp=?", + stored = db.execute("SELECT COUNT(*) FROM points p JOIN series s ON s.id = p.series WHERE s.mac=? AND s.cycle=? AND s.grp=?", (mac, cycle, group)).fetchone()[0] span = f"{day(min(c[0] for c in cov))} to {day(max(c[1] for c in cov))}" if not gaps: diff --git a/scripts/check_rain.py b/scripts/check_rain.py index 3767f37..c4e5e97 100644 --- a/scripts/check_rain.py +++ b/scripts/check_rain.py @@ -35,7 +35,7 @@ def fields_held(cache: HistoryCache, mac: str) -> dict[str, list[str]]: """Which rainfall fields are stored at each resolution.""" with cache._lock: - rows = cache.db.execute("SELECT cycle, field FROM fields WHERE mac=? AND grp='rainfall' ORDER BY cycle, field", (mac,)).fetchall() + rows = cache.db.execute("SELECT cycle, field FROM series WHERE mac=? AND grp='rainfall' ORDER BY cycle, field", (mac,)).fetchall() out: dict[str, list[str]] = {} for cycle, field in rows: out.setdefault(cycle, []).append(field) diff --git a/scripts/migrate_cache.py b/scripts/migrate_cache.py new file mode 100644 index 0000000..8c55b67 --- /dev/null +++ b/scripts/migrate_cache.py @@ -0,0 +1,73 @@ +"""Convert the Ecowitt history cache to the smaller layout, in place. Run it ONCE, with the bot stopped: + + python scripts/migrate_cache.py /var/lib/ecobot/ecowitt_cache.sqlite [--no-backup] + +The old layout repeated (mac, cycle, group, field) as text in the key of every reading; the new one keeps each series once in a `series` +table and keys a reading by that small id and its time. Every value string and unit is carried over as it is, so nothing is fetched again +(Ecowitt only keeps 90 days of 5-minute readings, so the cache cannot be rebuilt). The conversion is one transaction; the original is kept as +.old unless --no-backup, and the file is compacted at the end. A file already in the new layout is left alone. +""" + +import shutil +import sqlite3 +from pathlib import Path + +from _common import parser + + +def converted(db: sqlite3.Connection) -> bool: + return "series" in {name for (name,) in db.execute("SELECT name FROM sqlite_master WHERE type='table'")} + + +def migrate(path: Path, backup: bool = True) -> tuple[int, int, int, int]: + """(rows, series, bytes before, bytes after); rows is -1 when the file was already converted.""" + before = path.stat().st_size + db = sqlite3.connect(path, isolation_level=None) + try: + if converted(db): + return -1, 0, before, before + db.execute("PRAGMA wal_checkpoint(TRUNCATE)") + if backup: + if shutil.disk_usage(path.parent).free < 2 * before: + raise SystemExit(f"Not enough free disk for the backup and the conversion (need about {2 * before / 1e6:.0f} MB)") + shutil.copy2(path, path.with_name(path.name + ".old")) + db.execute("BEGIN") # one statement at a time: executescript would commit first + for statement in ( + "ALTER TABLE points RENAME TO points_old", + "ALTER TABLE fields RENAME TO fields_old", + """CREATE TABLE series (id INTEGER PRIMARY KEY, mac TEXT, cycle TEXT, grp TEXT, field TEXT, unit TEXT, + UNIQUE (mac, cycle, grp, field))""", + "CREATE TABLE points (series INTEGER, ts INTEGER, value TEXT, PRIMARY KEY (series, ts)) WITHOUT ROWID", + "INSERT INTO series (mac, cycle, grp, field, unit) SELECT mac, cycle, grp, field, unit FROM fields_old ORDER BY mac, cycle, grp, field", + "INSERT OR IGNORE INTO series (mac, cycle, grp, field, unit) SELECT DISTINCT mac, cycle, grp, field, '' FROM points_old", + """INSERT INTO points SELECT s.id, p.ts, p.value FROM points_old p + JOIN series s ON s.mac = p.mac AND s.cycle = p.cycle AND s.grp = p.grp AND s.field = p.field ORDER BY s.id, p.ts"""): + db.execute(statement) + rows = db.execute("SELECT COUNT(*) FROM points").fetchone()[0] + if rows != db.execute("SELECT COUNT(*) FROM points_old").fetchone()[0]: + raise SystemExit("The converted row count differs from the original; nothing was changed") + series = db.execute("SELECT COUNT(*) FROM series").fetchone()[0] + db.execute("DROP TABLE points_old") + db.execute("DROP TABLE fields_old") + db.execute("COMMIT") + db.execute("VACUUM") + return rows, series, before, path.stat().st_size + except BaseException: + if db.in_transaction: + db.execute("ROLLBACK") + raise + finally: + db.close() + + +if __name__ == "__main__": + ap = parser(__doc__) + ap.add_argument("cache", type=Path, help="the Ecowitt history cache (ecowitt_cache.sqlite)") + ap.add_argument("--no-backup", action="store_true", help="do not keep .old") + args = ap.parse_args() + rows, series, before, after = migrate(args.cache, backup=not args.no_backup) + if rows < 0: + print(f"{args.cache} is already in the new layout; nothing to do.") + else: + print(f"Converted {rows:,} readings in {series} series: {before / 1e6:.1f} MB -> {after / 1e6:.1f} MB" + + ("" if args.no_backup else f" (the original is {args.cache.name}.old)")) diff --git a/tests/data/ecowitt_cache.sqlite b/tests/data/ecowitt_cache.sqlite index 4095c317dd3426e3d6f9641c529378b288080060..5adfda6e0fc2c03609d17abec4d92cbe1c278792 100644 GIT binary patch literal 344064 zcmeFa3%Hd<+5f-yI-eIFkTY&hau9KEj$85o3JMHjnHJTvz_?X}JKcm1y4b^YIKae3ggX6AWj z=9x9?`QG=;JtrQ2^xVa>o6fyp(fnD9n?^NsX=rS0n9$VJ(9n?P-;?;){VDO6lmq_X zsQ%vBU(*dEt{Gac?rO-Er!-Wru0B)Uv(i-gUHNXX{-@pp^&Y7AK)na*Jy7p~dJoil zpxy)Z9;o-g|1W#slx(KkpgxV4&0TQL>?LPk@QK-rW}P>C#`&`^aevPpcEaR|Crxge zdhDdh)0^C!xxgeSC)~8S7 z6)m>O?oqk_r-Ej?PwlPa>ep`Ig^On`TI~8}c+>0!zN3atY0Px%+O_c_d%yX!7tb>P zCW8BzPnw39(PAE7*tPOlhYn6;x_zW;XYDi>nbBhYo8(KDGheha|29@HY~Y{zpL!3} zd!XI}^&Y7AK)na*Jy7p~dJoilpxy)Z9;o*~y$Am9^}rQ{#?;67vys5u2e_RM%)_>um*%w|&mKo-wx#yT1bEeHQc^1u@yWree z^X93P`SVS};@R^Tst*q{pIwZZW5$IGXU{%IrJO&1PXAARszAFMt8X{(PyJ852kJdg z?}2&`)O(=b1N9!L_dvY|>OD~JfqD!}t53>pEYi%ROv3 zxqEbWT`qgU1&dtXefK4Aug>ybaM5C&dE_uM*Xokdb1t}OQH?Iyxc$?^hjvo+pp2~! zxrYoPcdah5xqX+I>kj^)>jqsy?qU0oyH=Oj>#8mx@4kb`TdPZKUfm^RZW)-_ziX{7 zX*p-sB{h1a{q#@oJAfj!28k|WJA}-G`;)m=hnUR1Kg?D8eZWl#U?oH-eonbQj&M;T) z^#NB6I)ltZdy=_UXPB$1&LHcs9%QZ6878al4Dy;y{*Bdtn0c#E{YP~~<*$|RRc@?I zuM8-^Q~p7DdHLjWx6%tJ{{^LSrF`-E;=RR-iW7^K!mkQnEzB#l=HJi%IDc#Y%>0nt z-*Z38-IO~e*Dbp(dvEsq?5J!ivo-UT%=}DiCYAnK`itqa(j!vuq#j9qHg!^}Z}QK{ zjmc%nW0K{>(}_D0vl2ra-)(%n@#e;}8%H#}+py(-HkCF{%{I_K1NP`J5la75Yf`OH-#r%<9>*P$It_;0a!Sn;}vc^6zvnNdgBGW&kGGIQphH-|za4!4E+eW*g@ zZ<%cK5B^X2&%5Zt#WSLMjhyu1+G2~u)!OecTW0SMa~oV&LNnJZH9#{E* zgKYU4gU}ACPRpBzexNDZr?aQwik>rT?z~IfL;r{^(%HBQi@1kA-j?b1A6jT{L#;+1 zV6zYEB)glV7R{bFYw_Gq%#JHIrq!0}YyfvC71ynKoGsSpL*C4c$+4zHug)HKJk@o4 z&f~_|a-BWr8s*%W9Bm7B_JBJUq>^JsnKC^(t7J5FcdX;sk+xW8b?jIyu3}4zE!SBU zJCvh_Bb!Z;S~H=2i8Qld$C+?{Tc$>o(YG4 zxV8i{;l8#^jqhH^+n7-|%x14K6NcIS#N=kep|)I&nXt2RZqyC=aBcE8q>={>Hf3tf zg!X1`a+qO-LC4W|@IJP9jnVfXipNzxV2~|eV-$XHdGpW*nxeI4!k}m{6ArLNIvZE6 zu~Ct@hWqumWopcX9dAReM)$MXYs`c;dxtqK`duE=WJ`56fIF0m>(;!tEmmVDta&pt zCi|KawPr$FqT|Hm#$+E`uCwP{qnsO)y=}qH9&pEkRB}u&Q>NBTXe-%aYKrSPwx=!D zSsgnTi>uhu!*- z+Uzw(QIK8Df-QS~sCMW{aQGg!NR3Ib!%fVi+}-A`F$V^@{fy)u6y(ly512bYfP+gi58YYe>)EN-54$&{=$`{|N; z@@pyD5}gbt*U(_@8(y$QYRr8dZbFqtH5jl+~J*U5A3SdJQwPMRXMCOuQb4wFz^ z!7&M2s*?(KEEU(UxzQHuq<;Ta41t}WYS#ZxYxq_}_2<=<)dkfRB-$~P;QRHjt= zmj7J-e)+TIY3032zbidlT3R}}G`RTp;?u=Dix(6p6ibB{3g0L!E=(xwp8rk$Tlp*V zC*%j@{+L^xyC^p~*CqQ>_B+`tvL|Hs$-I;KQRcSHS(*LQ@28(g-qa4~D(2UHdiG zl=$IW>NMNkx%=rHyr=MAl9Ygk^18Qe?U$zdj$}y%oeY$;cDf<;Y z8mnkmf=|?JaRg&tf8}eeHi69E)a8HCEzU9n*<4gN+G#&eUQo%MgGL(|mnkdK(6`*l*k&WoCA9z52R9q>Ob zdysjceQJC`ZI8QK`ro+&t;?duSHDJfQ>%}d2U_b3*D?S27L#>QCk^=bubk zoejngB}{!=?f2p8?q{muoy}AoFTZbRGgX+?PavjihIKYmbymi7&(IIo z27jX=oy}DCChoi6;YZZeZ*XTb)qf~zs<}^RGt~!|9n{%O6%_L`)j-o3eQL~9HL{yp z4d`s9>X^TOXET+}`tOs7>8^gA%~Z8ZnEEtzHdECsvUg`QmCfC80x^~9+u2OjSs7EU zKBil1%~YKf>fPB)W%GBKLQK7Sbv9FVQp8lNXJ<22?J_;cUh`$sXn~FsRd*WQwbmm|zMtTi*`najG?dWWi6z*HvJnKG(X zB5TG~tv4u>x%RY_G2^P%3sfgXOr6rEKWoiOwacVTqielBnd~*^q@-y_t+%JzB}|GybcSr7q+~v82xwCR7<_YQva7OR%6=~U>Fmd|XJGA3P(oN|-(wWq*)a$92QctHgryfe(o4PHvEOlvWUh2%$G%P2h zQ~RX4rwYk`Cf`VIPd=M`EV(whDtSlphUDeRg~_v$CnXP0wkC%qdnYT&hQ!;6*Ag!z zewz4R;-SQSi4}>@CN58WJaKm7qlqID2PF1Q^hs13%BjXw*B)&f-dxx+dc+tS6jMM-RF`W)WB74(RNcg zN9S+T6+dUIZqylDbc0XctUKelcHL1^R?~^2-8Cn!Y8fMU7{9V*?1*NUHfjar>oR_y+zpYu_Z~f1PsJg6wqDj?r{SUjVHmu*&MYV7Jnw)C;`foR=ep&yu zU8>L4-?dZq=lT^p=xf*7^pl%?vKh*&FoIl#dK=9XmbSLCylJ<2wAb*F|+Zj$i z=Qp-N{towS)%2w;nr_*w%YCk0U-O#P`hK5Y1^GKHS*h#!@fEraZRSho?|$|&-S*R$ z>VEm?65VIVnwipVCY%}nXOJnPP# z1Nl45Ff*mkIo-^Z{tm~RnbOmdW~TIXn3*YkxdU7EHOH8l(%)}{nJInF!DgoP^=#^{ z+t9;&>HHmbH#4QrS!~e#k~A}=yXM+|nwip-TlD1@`E>0n zCQ1)?Gf{fDgNf3YxtWR5U+o4aN`IfLnJ9g>%V)~0vi4##PnvGxo%TdK(M@aH*lVR3 zwS00y{K@*)cgCL_AAi!me$V)mY4IoPu5BGjuTtu`_>;9CJw5*9So_IwZ5uZ>j2vb2 z920-C;q&g3qvKE3pWR@_2G=<%{-k|e!zddgRCKSY@h5A3(=dwf4|%58 zZa=ndNYyHnx&5XHsT>{h+yx-=8Q)e|wSr+uHe^j6UgS zKN->1{v|V^n94NSPxfo$LC0T%z3nH%+t$xq6@OLw+E4av<5e>Lq>sJTu(l2PIniZ$ z+W|eaZNrq+?rYG?el)~<;oV0)ZLz^^8@DsFm`C5kUU{FkP5YVGL^Ghf+fN3yZMw`% z)aEgCvxNq=tvQ7sPg7_w`@z7rHB%d+n|HMz3}{<3erNQ-p7w+OrfZ@P_OQdGU)$P` zwK8;~uf^{6v!=Fn{B)TiX706{{cP_x^LAwNnTONG)~Ih={PM~tRr^UF(;w8)RHD{&=k^y{Dwx9HB+ki-Ev4f{%E7h}YgAr2Z85eC91lTU~8Z*lH|GSq~G*o|G-B`W7 zdSP{Hb!atHd8P75WkqFv<><=LO1iwgypf#%7nF}F4=ZQT^X(L<|Ec#ty$9+&Q15|y z57c|0-UIa>sP{m<2kJfWf2jxdPUGb7xF*kSq3%7D^iCG-h7S6^1ISrp;hnjX%h~4x z@;K*pul`)8SM3_b4&@&G$kL;BmdLx?vnkC&(2hCmx;C@^W5_Tv)M%H@;Hn#~j2bqO ztTh@IWF7iJnOxHb)BJ8Vnr5#Qw`~y3?^dI2Hc!;JeL88JT?%6wH=>iq*$iRhnmeg? zkahnL%H$fiUnh;TSBe|AZzqkjd7{P*?_@w4r@Cod|4s&^Ne`Pgu#>^2a}M}`Jg#B= zIvJ4WI&r)9?qookEK#$XO#Hv8Y__jHjryNe-~Vqyr`R#waW$6N3RB-+zGY^NFV*QUPzzea2;^Xl*4)%=2ezC-o>|N7UrzW;yTJUhcOGV1&PXZtDT zf7AYd8wJnk|7_*nhU!z*FSGxDbhWYabFluW-UIa>sP{m<2kJdg?}2&`)O(=b1N9!L z_dvY|>OJuPya)E1W}@14tr6kSEeA0ctte2tv1A=*v)71Z8DuxcFLJjWYjf8yl?A!I zTazNK$Cx5RYed=8i%L4guQ63S+LY{5Bk-aw=?pLXA7yjbh=yo#yLA9A__k@wk)}Ye zPAV2J3JCpQp_1Nmc)e+Uc)k@_( zVgS5a`DNt^_6Dr3+*7%wa!qAP<^0N-L;{#lX{q$D>`_UT|61NrezE-1@}uR|<$KDv zl&>i-DW6|HvwVDcQh6Nv2l|w|l#@gS_564e0YUc6SBdVYso=wCSE(Nfgf+5v;Qv0U$fUv zv-8;9Vb2|qze9OD)u9e2X=(VKraEdTe_JP6Hg*0l05BY(#&>wo3>Y*>0C!$O`6%EZ_~P(G!rSp(w-)5 zv`gF5q>XlIdj#e7aOLcl`Q4*5ZV{uosEI9MaknsaH(S)Fb_r9v_*C2Es!4SnST$)x z`>=Jbm=p%ABa-~^Et@nGNx|kUn_^~g*(;Vz8dZydBnL&EQZ$8J1e1bJDMTsFcH@2C z-pRy(h~h-#?Y){A=24u7T%1m{DVskRrL%cIDxZzh2_<0Dvr^txFk{kP`!godwLfiA zeEZWT$+tgcuj<;Lve$I&PwFcs^%WDr6%+1?rBps;2aV~_2)LK&&q)98)mVMC`h0au zbwl-m>Ydf))hnxutLIjySC6b7i284;61ISo0e4hhto*d{s8auJl`|^SDu-3Z5+|T{ zrCLdFYT%CYugXtza=^po`^vYMuPa|xo?o6(KE8Zdc}#h5xktHJ`d8`CrT;4Zy!0a? z34F73XXy*2&y+4MwUy2&O)DK%8vB2w{~swlSh%}zbK&a3#f7-oNl<$_$=l+rVQ|>pppXGj- zTa){G?u)t4=026XAU7*_BBvRQ%MH!-%9XS4XWz{JHv5b0kF)Eu-^_j``}ypZ*^9Ds zvLDMHo1K^)ogJL*ndR({%v+gPGtXzXWHw|T$lRG(p1Cr!ICE}hdgjQ?fteARrp)e~ zi1^p^j`WM^pQay8uTI~Sz9oH4dP%x1eR}%X^n~=t^uY99>0IjF)Spt@Q$J5Vo?4%J zAaz&jrqtD`C8@U5>8WE=6H+5n15;j-e@y;5xi$HNpZsKUL2_pD zgyf{;*yNC8&txg_USenBmBe$2#}n;|`x9SI+?ZIJSd=&?aZ2LI#6gMX#DK(J)wi5! z&yHJyMo5f3Kw|6x5@QdL7<+)k*aIZS9-w0EK`O=`q+;wrD#jk9V(dXG#vU}qM2?Dn zfFKoP4^lDqAQfW|QZe=*6=M%l8XHnE_8_IPAr)f}QZe>=w&@sqkczPfsTg~Zjj@HpX7`(+}Agd(jUi6a@mY^CM(q>_u^%AY$yf0BsO4_FQl?$i~?7Ic5 z$Q~d;_5ca82dE%>kP5N~sUUlh3bF^OAbXGsvInUkdyopU2dN-?kP5N~5vfQX(>fJo zk2Doz4^lz)ASJRP6=V-mLG~aOWDin7_8=8x53)h_A~gUV*vg=E!(yEcvgd}(IvZrq zO{(i`kUcjL))8dS=`gNhgY3D%ypAAyPT!EmE2z^qkPWiu23Hhh&t205*>n1)XPXJK z=kyIkkUghwARA=Q-ES>H_ME;U?b*#<-#{kHo|^(~l)Xq@kYb|jx#4P~>_tKHNHJ0N zd^U-)M~Xz*10>2GAW`-JiLwVsls!PA>;XQ?UaBjkR5qkkHl$QGq*OMfR5qkkHl$QG zq*OMfR5qkkHl$QGq*OMfR5qkkHl$QGq*Qj#Hm$NDrLv(nH>*(?scbhYB9-k%j8WMx zTrcIU%68+xsB9iw{9_)eY&VwIpt8sLac*AR$4X9A_Lx{Zb4{zV{WVe9qoo`wdsHm7 z$cf5Eu(1F2ne8UmHAZGf@g2!# z1FFS8|7%2MlOi%35Sa~#%mzee16F3+n)Ybhbo(^_BLyut;W?evF%jy-7P4s{6Q0wT+uKCbbGDF8-3iYb^*$Bbjfulq zA~qFPySajtBojyUTza%^{3owiF5AX;m^hrpW8)jk75r|Q0uw~fmw91{y4te~5>cN_DZ>!yu&81q~-L}|#) zLm*%7h90^PHz~#yscCH5>DszvfV5p7ZzXZ_IN(C$UUbJ@+)` zIp2n|`LXtQ$QbjSYte@HjpBDd6xNwK*2MqA|G$^`|2I{?%}IbaRX|Fp`XmC==bD&08?;Gg9;h!^ zs_<0ddz=99)xxcXYYUeY&M%x%IIb|UFsd*J3qU^qkNlrFqwr_>ALiHOzn;G%|GE6q z{Dt{*^QY#I&L5H=i3Om0zLO2c6Md)<>k{gl_B=1ZvPhOc^%$bVQlSd{GOpZu4C3jDz5`RtXNW91i0FNeCC+>?M6LhmA9`;eh%M{Xw;KtmA9)|q`X~D zt-N~$${SMA?I9K29}oURHOzgDN0& zw>!iFvbx)iib!|65o2`sSU;AH?sn%dP|oUZH+qfkZuPZ}bhodI)!lBypt~8N(N8{C zvAWymRJxlqrMn>&+#XWF?I9K19#X;WA#^w09)IT|-R;|Fb+>Ojy4wZzr<@9I52@hx z5W3she;{y@!-%w!7b8z{+m-~_a5Kvs79oXBps z{DL%Ow>zf-LUucQC1ho{8x=-&yWZfE_0RBU@l#kPm6>~{AvvfFuvNR!z1fW)>3B(^;uvF!ngZ4XFn zdq7k-AgUWMs@u(fR&~2M6V=VMVpS2@jOvaoq<~S~?g2)s+dX)ry4~RmWVEW=4UtH7 z`<$q5clZK1QQh)Ij#RfVXH~Zw6h?JB7Zl}C-7;}Rs@r!cs@omDKu)W=eXXtP_I0tU z+r1n_s@wexM5^26)T+Bzpt>RD+lG{H8&bY)NcpxQtGeCYwQrj=<=ci(-A>Ozk-IFO zUgMDIj+AekoTzRmR3PQshEUy3;6PS&yS`qdeA}dnZyONbHXy!jKz!SP__hJ@Z37~@ z0TJDR5#4V3v!dHgnO1bWpK(OD6L*v`qC4uso`L9wl<0=8o8a{hWJP!MiwIfK9sOQG zh;FBMAS=3~c?v>wJG}#0(H#vK2+{5I4ut4-dIv&ui)mz&72W=th;FAJ$%*I|(?}c^ z-acH?tmyXlLv)L21kueGFzPUJ8rAJgBdBioszZuV-Oe;(Rkt&ZSk>)JBT9AmY}+WR z8xYkEi0TGJbpxWh0a4w6Qr(bJ-H=kGv};%1HeN=7@ayx(L<5*rJ*j#`b$oR=>;Jn{6P3SIUaM?l{r{%Qw<{|vH&s4U`9x(-ytwLyNtO)ndY*09bnkz<@&6LN@>R{2%f!=by=cKmTz4zWnX^>++Z7=jUhSkIx^L zACn)P?_t&do4HqWzsx<6dnC6y_to5Ox$APDWCehn0N6WM$u(r(%D$R?KD#BmA^Sk~ z&g^nl04&a)o1LCLGJ9ZlM7Alrdp4E%Yi390#mrALk7ib9?#bMexyGvhGcwaMhh@fQ zhGu$as+mOk9aaG769BJGUy?pQeMb7Y^u+Y2^q_RNbUyVDRsj4a^|RCuQ)^ORPkk}< z+0>^}7o=vTPE1WsjY|zp^-7hK@1y>IoBRdpe`E67$-9%cB(F_gnw-xHfD?lg0N+Zy zns`33C9xs#K;q8C^2C*i#ffth(^&y@BZ0FjBlxrJOu5C!Uwjt%(hLmd?Qm$=Cxwavtu_2|g zA*Hb)rLiHUu_2|gA*Hb)rLiHUu_2|gA*Hb)rLlVl8XHm?8&Vn@QW_gl8XHm?8xoBT zh{gs)V*{eG0nyliXly_lr!yht*@l#78$x3{UnHbF+mP~XL&~!amBkbZDbIHAz_Sf0&o-nq zHl#E*q%=09G&ZC(Hl)^%LrP;qN@GJxV?%2FIHWW-q%=09G&ZC(Hl#E*q%<}p8XFLe z4T#1DL}LS@u>sN8fM{$$G&UfPZ9p8`fH<}Racl$P*zOf*Y)ENrNIAA4<=BRlV;fSA zZAdw`A?4VHlw%uGj%`Rewjt%%hLmF)QjTp%Ikq9?*oKs28&ZyKNF3XMIJNm8oogu_ZY-b3u65AO7n?`(kV$iWIgp zl^KQYHlc7|6t+8$6GCB&Ju_0+zMNIq&NyZiwp(C8ITW`0U4yK`c7`RRu-&2s(yYRE z1~8+rX^*R?RoKoH7%6P$Q9xnyBIa`{h0QgU!iJPz8&ZC4NcpuP6t;NyB8BbmuKe2M zRDNwp`L*%>MSktL4e_~lj!@X*12PI*{6j`zi|;5>*siaQ!WN&CQP|>-iWIgp$@M zAFZ)=J!;liH^)y9MHj7Pj{N_mGdg6R*tG1RN24MkNyAY^54q8XZ`QO2c-z)AczEXUy z_&DDG`-@*L-dJ2(TvR-#cye({aeQ%jv2U?UFwtj3r7|XEQ}~L6?QMA@_)_m$iJBXY5vjtYS#bXlD{UuB;S@lJ%4O|0^a|D`MvVF z+`G9y=6;>qn)^ZSyScCBR^+aa)&EEF{(mGlBG)gsXD*w4H~XjT_UyCS$Fgg)tFm`w zZ^&MrU6?&9ds6oBY-@H{ws*GjKU)9)RC;szVZ8reOy7|Hbb3*Gc6xgHsPsYU{nP!@ zd!*B;zomYk`c>+w)b~=~NqseSYwFt6C8_gMXQYlxO-zl#``;~ayPvHe@ncccsa2(@x#QL#McvFOnf%+sl)|| zS&0)9lM~|-LleDZ{l8rXV823~H;LSKOUWVRwp&ULsiowQmD`b#3PNtXrQ{HD+qn`U zwUiuEOUWVRwp&ULA-CO9a>&Z1{~qZAj^DNa<}z>Fqv&-iDOkhLqlhl-`Dv-iDOkhLqlhl-`Dv-iDOk zhLqlhl-`Dv-iDOkhLqlh)KYRtEhUGn-gaX~FC`}pz3rBgLsoA`KdF$_+tJSvgx+>S z4pK|WA+?koLT|gJEhUH4QgTQwC5O~fatOWcmXbqOZ$}d~Wb}5lHW!ekh~5T7Zv&#Y0nyul(%X>I+mO=RkkZ?b(%X>I+mO=RkkZ?b z(%X>I+mO=RkkZ?b(%X>I+mO=RkkZ?b(%X>I+mO=RkkZ?b)!WfK5~P-rLsoA`HgrfW zC5Noub_37oZ70b{vwGVNX1$c0H1xKUd5~I4-Y3@E?wWcjIcaJsIfUMJYa$`5x842p zQgYJNQgTR^k^@F?M=uP3EF}kw;ErB(0a;28h~Nf9a04Q^0TJAQ2yQ?GHz0x=P=Xs$ zf*VqT8&ZNBQi2;&f*VqT8&ZNBQi2;&f*VqT8&ZNBQi2;&f*VqT8&ZNBQi8irAh;nV zxFNNa9I}GjjS9V#oHPWt^Or(aaJzA!my(la1-Bc^dMP<+2yW*uh161VNG&CY5ZumR z3L&_izZ61nyQSoi72N)QYAHEsYAHEn6t|lJ)lza&WGOjd6t|mP)lza&L~#S6xB*ez zfGBQ26gMD>8xY0aJ5bz^QrwVI+>lb-kW$=`QrwVI+>lb-kW$=`QrwVI+>lb-kW$=` zQrwVI+>lb-kW$=`QrwUzZa@?_Ac`9h#ob#gZa@?_Ac`9h#SMs48xW^9AWm&SoZ5gm zwE^YShLlqqQci6M$?c|1NIA73<nyokdoYxlH8DWYDWSQQj!}|k{eQz8}XhXEqF0g>E* z>}v*OUo#;4ngQ9@3@qA?uJ+AP`AhUG2-Uy4tN>B~9&XhSa`hM2GdMj*a~PT|P+v*WUksE35x6 zt1PI@s+?3gqB6d+U!|$CdnfV#+RCSwk1bCqk1P)??^Vu~-YxyH^y|`AqW^uj^tIB8 z()FcJmKKy|mQE;5V)y@$QqNKu@BiDy*NQI`e_H%r@uA{<#TCWR7B4R@ES^<7sd#v? zwKxp#f2G(^c&qSg;rYUr!iK^Fg*yw&3s<84&nuk9{{IQA{@?%~{hx!dvnUzVGn zn~^&{cUW#rZg8$gu9*E-_Rrb>%09>b{|(u1vHJh!>^0d-vh%VtvL|FG{~zi9TKoSe zrbnj-v--cBZb-eIdM))r>Zj=chf?>ZZc8moU7DJgIx{sbb!cjIY9Chr7n1)>zLDIX zd^Y)5a&2-|@{Z&U$;*=qlV>GQ>csp1t;EX2&8+^vBrz{BBXI)yzcsOMqHm&0^(`y< z`v!h(Nc`G>__YD?YXe4SM{kdS__YDi*?{P5Kvs?evT_`dmE(Y{90z3OIACRV^xc5e z%5g}o9EYsTb~*LR@xH;zaR{01tcH-4+0pa{S(zPKVIgF;TR9G?mE(|=*^$D4klAiU z17u~kuZvnaPMTUd4q2J)b1Iolnv&U&a%)4%tqm!+Hl*CzkaBB7R%ZM5DYrIh$ZWR& z6jE+&NV&BkWVSQ+K+3HRDYrJH+}eSU zmE#a9+pQdjP}y$fIHXpNLsn(Gr>j?vlV(-6ueDk^PMTUd4yl#nkW$%@QrVF5YD3Da z4JofSq`caY@@hlMs|_iyHl)1Tkn(Cn%B$Ts@M=TKs|_iyHl)1TkX7048>qe7q={D> z5U(~MUTr|U+JJbq0r6@BBC-Jy*?_DZ2aL#$+)ls^e&skIE5`v@ISyEn?H;^dIZm2d zISyHo?Q^P?>XqYt1Cb3WuQsH-+R$&!woBI{?bRmDitOl>3{qZgNO`p(<<*9i zR~u4ZZAf{wAw;%YISwhWHl)1TkQLdkueDd3H1TQ!;?)Mks||=(8xXHHAYN@iG&Ud_ z8!#H%O@C_TI4MSByY~RKa-0-dIo>DG*pSlLkXktoS&i*1A$sLFX=rRGry#X*98xRC zA+>TGQY*(HG`3hm^vZG4tj2cM6{E4m5~5d*lM{_CmJq#ioHR7HTR9GOmzCp?tQ-eq zg!D0^nYST>jns>-hhl$ZyEs&+h;0^OxfNKP`V`etdpdzGuFG@Bj7Oi>&&Yj>ob?K*Z(x4{f*BI%k<0?*y;Z|vHqS&Z%E&tzJq=KmlEmkwDghb z@#$gc`~q?QUQfN4dLp%fJ^pv3u1DjyrA|v7nHrxOmgeGh^2Ov6?C!rGeSbZx z``ePIvA2JGav0J53W;~|?Z23Kf|dREC+ zj$&z&j@k?uy%{iyGboSdG#tg+q&%wA-2VVqUvy_sAC#xzaV{P0X*i1ANqN*~!069_ zQJ?`E4XUqaR4DA*-=`H;_1ml2vTtv7h-KfOxhBiLK8@WiyY{f586M^P4`ph3RNNlK zsbzOP^r_`BF8$@5mfiJHsUaJGFxU6_jaG&I^~^qJ*xwJm8q^2HYS=vwqghS+U4IzW zYS`Z&-D=q159Mmu-w*9-*xwKJ8Zi1bU=(an9#64he|=P}VSj>YJ4EjFL6%`+NFy zSoQbu(=5B^hn}_U>vz}$%f9|-T2tOLsy1MBZNMm7!~XhcTf^@AV$^L=KlE)-e-y4^ ze?K&?VRt>VKiaT+K1S#2@oJQ=9^Xdm8g|z+YBylBiko!Te(P7({A7EW{@CiU{3EL@ zyXS-Ywd|e`0@$*9K4{>O?_OrvjW3jN$Tu$u`G$pI`K#wx_V>Gdre)t=BaCT}??3#{ zhJF7bj?H}O>xVox?8_sN4ZG)OB(nL{_4P$08}{wT7j4-0C*EkozC2Rdu)jYZ>1Hg4 zZoC?~Y}j8P!5lD>IbcL{P#)PFaAq4U_d_~|ylYFy$mbwG0$N|sNN9b3Bcctv{x>q( zuW_Qc}`loaWd0H;ysSRQI ziMv7`ztghspHVw3yYYnthh4O+zJUsdlnRHGryEk9Zb*5$A?4|Yl&2d~o^D8ax*_H1 zhLoopQl4%|dAcFx>4uc28&aNbNO`&;<>`jR(+!BH8xT)7AfE2N+S3h)ryCFv4u}W` zj0kt861BFR6eGf&sYIWHq=mk?FPNq^Y&#kk#PPLQd#tSz8V%4Gt*{ z4k<@B)GRU6Am!+W(BN)uIb=1sGwf+cH)+bz4Jk)=-@wrgSq<*4q8;6&DMvS?9Nmy| zbVJI~4Jk)Aq#WIlIJyCGbOYk(2E@?~h@%@22@Z$^2V`wIU?h0tDFe9;6a$@~0#M?iO zwf@aS`s;$Pe`j%facgmNadmNJaXIV!7Zzu-%YQ<#x!8opZz$|6Y%gprY)0X)#K*t1 zu&^+*Fb#d*%=-Q=g$CmLZ_jVdZ)SD>%KUO-`z_4R%ui!&e{;Sm-zDEbT)*wC?BC43 z{*}4qMD<&ko0*%In~-bHH4)RVA-j_`{adq}v#asxFV8MzMgPp~wCn`p`88#`u%3S> z(fqb%HnW?5CDHtsW)@~no$2kw@!L!!|CQ80s~MDd$O9RKEY zQ@RUY{GCMc-HZ<6` zrY7s>6O{l5DhUo$5*(-`I8aG&ppxJ~CBcD8f&-NV2Pz2;R1zGhBsf+{(OVlFtEA|y z2DU26sU|p9Nzq#%9IK@0Ee?)VQuLPAWPO36n&4O^x$;gWMQ{D2$12HPFH}jS2Pz3R zt8cwZf&-NV2Pz2;R1zGhBsfq>aG;XlKqbL}N`eEG1ji~Vdh3N_mE_wSt0dpvSS9)X zi&c_)9>#@eRFLn#SS9)Lu}X5!)2bx$M{nWWKUPV;KCw!Q-ik?&Rgy0stEA|ymUOF< zoQi>CmE`Las3g(@l>`SW2@X^e9H^uwvx=9uXt^KjctZ|U68Wu4Qu$+*g>JzIZfB#q|`TVg;a{V2tB&V7vAFCvP zzgQ*t`kIx#UNw${}@eW7r-`grB!%=_2Xr+KrO97*o0!A?z_SZu*8IJms>!F$q zJC$j4li{fTq@$b+JC$d&Q**PZVxyi6`}(7w3`hM#c@&gke|GbZK z!}7beTlUv4thVg$pIl|x^%p_0U?O?N{fn@&>{Kk;%Cf5;^2)O79~72lcYQ>bW!InR zEX)3WPtFPXv6&$ouP62K<&EZo{q<2@hW-6lO|b0RYm}Escl~Fymto%?)R$rRyo?Vt zV7#CKqrn13g&FqOLx&mm_d|&p_VvLZYS`BwHP+nBwm#S2=rP0odMGl(zWn5^mVJL5 zyajgi+1^%{8TRE-W`_OqLYo=(*F&8Jj6O5$`;(=}0i)4^{HQd|Wg? zS}kDInqgO8qt^mPu?6|jY=-^yjcS8kd#rAQUH@9|DeT4@;*I%`Rl;$9qu(t1^51N- z?D`iK2a8t*A!pg&|F)cEf4|Q+SoZh3b{8yGVMLu}-yfIku9A$rKPRk!-TTenW&yEff4@n~Ec^aFXeli75i5x; z`}*#`(6Sp3tS7eYuV=RIQr_3ctSZ)Q))i}JWpTi)EjH|~XI2*n%r;)b?*3+lalouG z4wzNOLHS>G37M6~Cf)V7S!-iYkGq5kjZ^;9&T z;lS$z2VN&Q@H)YP*9ngGR5ZTeKu^Jeo`PdNCY02@bqYaNu=<1FsVt>#6Ab2nSv#IPf~b zf!7I+^;9&!z=58E13d)?dI}Eo6ddR&IM7pYpr_!#>jVdS3J$zZaNu=M>;{D0JkHUeTg0EvfB|ZB6fCD`R2YLz)^b{QEDLB?s(eDKu>#1n|gkwDw^)DRj zDK}q6ddle&(qld4+Y{?4f4x{wMc+U2$9l?NKhRU82YLz)^b{QEDLBwmaG|u_q;+qMS7s8;6P8ofu4c`Jp~7P3J&xX9Ox-H&{J@rr{F+O!GWHF z@pFkDfdf4S2YLz)^b{QEDLBwmaIB|%{|0)h$@;xS@4&I1a{V9bDbfQy1qXTx4)hcp z=qWhRQ?T)ac|8Sh=6%ZRDLBwmaGZvBXUsLoE9Ox-H&{J@rr{F+O!GWHF13d)?dJ6tG zJ!JwV8GYsJyL!82_xslVjcu^A=TJUaWeW$ZY~f&)EgY<}g|FfJ&niDRKFy9!?avBV z*^>X`yzlx|f^e|P7Cw7fcs;wy)~zBWJziz&e!tEBPSYOWz9Z&XcJEi=DqG4Q!1tg0 z?)%?9lJ`H&!75ww2dix1V3jSrS2N|EeG3j&*}}Odo9^$|*xjk5}2c-&3ke+mx#Q*hv)f&>2)9Ox-H&{J@%ry{)o$9l?rADo_w zzAvQ5ddl@*q^F!et)JIZ(f1RM^_085(^KwxPESSOPx1$P3J&xX9Ox-H&{J^WpMnGb z6dd@c;8;)j`UL(d(gXh#9Qdc;z&`~CdI}Eo6dda*-#@XQiu4)jv7U1M@AQ=KAFHRF zJ|usvr=0!7=_%jev7Ykft)6nLq$wZkDPKO;Q@%d2p7Qkx^c3X-Jp~7P3J&~JaGhJWF>kp@=Tzj0Ja?juCDL1~Io^t);^pw9|tfyT6 zMtaJv_nnUHKSWo%(#(K)P*Xk+fucCacr`-3$=_%jdSWo%>j`fu5FQ=zm zey68g{zy-8-YuD(uBx%x(WikK3ve4wYeUZAJoKu^Jeo`M7a6ddR& zIM7pYpr_zKPrgV;8>#tBx zksjzNIM!1>f1syGkM)#mztdB0d|Ex_{Z-_T^_1%mr>Fe&V?E`|TRkQ7kJD4``8z%3 z+Z*dCU*75|r(d{#tf&0_V?E`sw`8S#e|FC&)KioX^b{QHDSy3S*COe$o^tPFPEWb< zXd>o_J|cgxYY`6g6ddR&IM!3Xy|JG1*SC7g?V==qtfzc^V?E{D8|x|GpRt~D*NgO& z+f~=k>nYcNPEWb#>GYJlp3_rqyo7p+>&1G?r^kBAmyh+7zkaNz{QYA+<=W@;l+2t{$MyG%*W+c_|$E7{NdR#bIj|&I87U8Du+~2Lgg?n@f>$AJj z({9%#>BR=izP-V&MfB)Djh=S)2Kb#FuzWw=dR*5ZZauEMUbr5Y^1*ssI9QJhKiO`t z@A|{7$MyV((bH~MB>C5x?>{F3#Lv^M$MyADWuCv=bxi(XJuZBQdH$+B!FpWMZ&(6U)b@`}SUJp1<3*Ncn}+EW7dT*5kVGrwQs~>{G71^S{~ne_ayI4b|7GFI0b8 z{a*E<>V4G})z4NhuYSCGPW6=Pk=29P1u&qxS2bVxXXVe8S1P}#Y^iMI1i-s1w^XjJ zTw0mWUVsxThgS}$46p1>48SyJ0REx;>+;XakCoSz?=OFa9RXLBKT)1b6u{%khnB~b zhm?DjE9J)0UrN6#y;ypReE|;>2k?uW0{H3DqSEZr^wLqKgG()?fu(MxLh)bh4)`tS z0RE)7srX>=p5m>=WyQ;i3yQOd1b74|0qn;ffjx?u!ru#jEc~YMY~e?R_QE%b1^9)+ zX9^b+3E&KN3LI7#TNqmCU8oik`FA)A;8*#lIScT+`L7cV;B)L3xG;Zi{?z=@L<1a| zAC&L@fu{l1IuGFI*~hc%IS=rz>`mFLvrDq)XV1(YpPiH)M|^-j>>x;TBH-^cFJ+#| z{2;R?vnunY%#E2VGK(|k5hdUl_7RNA?33w{DW%^}zm@*)^tSYq=||JwN#C2komc^% zVmHCr>66n_(+6@YK>u{tbdFO2-$?ye>bca9i5T!L_7vQlx`uNB=A~w&PT*X?*3`bK zzNy_(spQ{?8}Kqa3w}ssz;7hKocw(9%H&1KImwSDk4;WYj!q6HdO(@|1#c%_OT3Wy zY2tf{hZ6TCR&X}J<%y3c&Pkj?41t3Z&DFQcLg)LD43p8aD0P;sk;;$x>LbNb|@IFo3P(Dm9o!P1$%E% z(0#Ll-P#otRx3!Z62Nqz;GGo;-dL{ScgqxPU#j4RB?_KhsNl&t3LcxO;F0MpUXGs+ z#sLKnPEoLGf`YqS72MIR;N~F;ZfH_)b$11qcTsS0PQk(k1#@;On6*>EX*(30xLv`~ z+Z0URs^H))3dU_#(5#*h4pY@T{q^(drJv89`uUXg^U0{^!*Wx(8d_e#o9g+X;#JCT z)$>8WtCU|%Q}FZ@1wWpkU~{X2_00+%8lvEvO$zSquHY+O6x^0m@c9M>*X~lVbfSc`1lG1ZOav$ zy-dOBOBH-{iGpJnDmY?}f(bJf957wM$Y~1pouXji1OfJQV8a##4{uiRK)ZtbRx7x3m4e$>Dps}!tV zso=pC3RW#waQ89=cPv$K^AZI&EL3py90ixpRB-Wh78AtpZ-_eubEhacYl4E0wJJEN zS-~+w6dc~9;E?VLTDvG{$tf7tprHRQ1-*7E*mH-1@^%H8Z31kbRIqD{f;TrSc)eXZ zgay6|URtH#7b_J!y+Xl{mn+!3Ou_o43LaXb;F}8-+&f3XS7s`>ZMuTbPgAgLih?U9 zD7ds$!G+BV<_%FWyGg;B-4&eDMZvV3f~gG(Chk%&ey4&_I~*Y4Ea3ldtn2@}{{J88 z|AA(`w;M@-3@bn^0|T^W01(RnAeI3@ECYa81^}@P0Ad*c#4-SgWdIP%03enDKr91* zSOx&G3;<#o0K_r?h-Cl}%K#vj0YEGRfLI0qu?!4oQ`JVi^F$G60BW01(RnAeI3@ECYa81^}@P3^0y<_r(CjG60BW z01(RnAeI3@ECYa81^}@P0Ad*c#4-SgWdIP%03enDKr91*SOx&G3;<#o0K_r?h-Cl} z%K#vj0YEGRfLI0qu?zrW834pG0ElG(5X%4{mH|L41Atft0I>`JVi^F$G60BW01(Rn zAeMmvW+k&cVBGEs#4`JVi^F$G60BW z01(RnAeI3@ECYa81^}@P0Ad*c#4-SgWdIP%03enDKr91*SOx&G3;<#o0K_r?h-Cl} z%K#vj0YEGRfLI0qu?!3_N3P0a`JVi^F$G60BW01(RnAeI3@ECWCf{d~kSK#Euf0I>`VFfL`J zVi`b_zQ5J!e?Tk)fLI0qu?zrW834pG0ElG(5X%4{mH|L41Atft0I>`JVi^F$G60BW z01(RnAeI3@ECYa81^}@P0Ad*c#4-SgWdIP%03enDKr91*SOx&G3?S4j)b)Se|NlSs z{||}||B2#)0&>7EU=II@&I1ADfL*{Iuxo9a1GQxU5X%4{mH|L41Atft0I>`JVi^F$ zG60BW01(RnAeI3@ECYa81^}@P0Ad*c#4-SgWdIP%03enDKr91*SOx&G3=Gtk0YEGR zfLI0qu?zrW834pG0ElG(5X%4{mH|L41Atft0I>`JVi^F$G60BW01(RnAeI3@ECYa8 z1^}@P0Ad*c#4-SgWdIP%03enDKr91*SOx&G3;<#o0K_r?h-Cl}%K#vj0YEGRfLI0q zu?!5`JVi^F$G60BW01(RnAeI3@ECYa81^}@P0Ad*c#4-SgWdIP% z03enDKr91*SOx&G3;<#o0K_r?h-Cl}%K#vj0YEGRfLI0qu?zrW834pG0ElG(5X%4{ zmH|L41Atft0I>`JVi_2yEdzj91^}@P0Ad*c#4-SEYj!WOfLI0qu?zrW834pG0ElG( z5X%4{mH|L41Atft0I>`JVi^F$G60BW01(RnAeI3@ECYa81^}@P0Ad*c#4-SgWdIP% z03enDKr91*SOx&G3;<#o0K_r?h-Cl}%fLWw834pG0ElG(5X%4{mH|L41Atft0I>`J zVi^D)^~wei%K#vj0YEGRfLI0qu?zrW834pG0ElG(5X%4{mH|L41Atft0I>`JVi^F$ zG60BW01(RnAeI3@ECYa81^}@P0Ad*c#4-SgWdIP%03enDKr90TwPgSh%K#vj0YEGR zfLI0qu?zrW834pG0ElG(5X%4{mH|L41Atft0I>`JVi^F$G60BW01(RnAeI3@ECYa8 z1^}@P0Ad*c#4-SgWdIP%03enDKr91*SOx&G3;<#o0K_r?^yngUB2k_dh-H9mVi_2y zEdzj91^}@P0Ad*c#4-SgWdIP%03enDKr91*SOx&G3;<#o0K_r?h-Cl}%K#vj0YEGR zfLI0qu?zrW834pG0ElG(5X%4{mH|L41Atft0I>`JVi^F$G60BW01(RnAeI3@ECYa8 z1^}@P0Ad+Ht-kLq1Atft0I>`JVi^F$G60BW01(RnAeI3@ECYa81^}@P0Ad*c#4-Sg zWdIP%03enDKr91*SOx&G3;<#o0K_r?h-Cl}%K#vj0YEGRfLI2QWA-ptqyLkMZVlD9 z)eeArtG88`RWGg1tDeaz0EbpbSNExQuNGMU{|4s(JX?9JvbM6Sa!2Kc%H^B{a8~6c zR{ytFhE;l3Dx3xI7U%yxU*1yQSpIhT?(!|=Ys;6G=W`ywiRHt~2b71G_b%^NPL|$b z?f(mEhDd(#J~2ln!C#|A120QnvW_;vb4H7oRD9zxZ(RzT)l0 z>sa?czc_>Q{|+mTDGo07ES5PN;O)X|g%>#g?|X%Z3ilOO6s|9Pvao>j0Zu4PV%7hU zLeD}e{~jjPtH%_48Y;}zMK+}$o(bvT5cPs0B*{C zn{xtg%3YmXk~=?lX72dhq}(`80_;PqfMoWa?C-KKWS`7Fl6^3HclKu1`(K=$oBde! znCv0hmh6CR*K9WP_skzMFSFYJ`C4g!I62^i^rZCI^bk%4ET!J#?0{ET>Hm1DowEVIoVt zB`e9s#9tD>+K$w*K9pnoKfys=8k_&e#K&xq#@2r}rn)pX{*N9J9eY6<+y31N$Pk+KtPdF7vX1o80=(HTl$+13w9P0zfu|9wt>odqG?$-FA6F`pj0pwU8 zK#uhRK}`yZC)KnLYf{B(sBGnrrs&OIM(}RX3Tv z`|dRca){rRdKyYtmG*_*HK$Ig6J7xv|=da^5@ zn1Ze@>Rcc0pw9IfWW>1Y7qc6GjBl;ki$B^AJF^pil)t9ghtDp@_zq?lJ~3z<5z~wv zVvf|gKHN{8>jTNTK7gF-1IW2PfSl_C$hkg%oa+O~xjuj#tqaJ}x_}(53&_#BfZ1Fx z&9DdixFKpb)~iObtzI>lP4%iX*ix@LiVgMdNMi25c6xV64a8=8juLPk#a4QExD9D+ zq$kY1OJf^7L4Y0EgMHlNHCyOqSeOm;GGq|xCitx@r;gSgWMsP82p6ZJ(>1AseaNW} z_JP#FJ`e%O8ad1;^7CNFI_o8+ZmknF0@%m#VsPqRJV z9q~+E)WJSbbg+-RI~(KO5znNVFk@0Pvnf74myEOp?KH@mXMyup(;f#iSMiN=a4Hj} z&vwEJeh^&BQOgxfTBhJ2&Oh^I#x7A;+kc_D&k!a`HM@BY!-{q9n6cDt)avDckQ zSgyhBbSKV2OlF_EbQA)fPIR-H+2cOOmoq!uiFe@2vA>-&1Rb%vo!I)0*xOFzd`CnI zqj8Ri6Xs6-9%Og56I0ry5hKi<#!TAk?F>9uYqUJ%?{LpnO<&rg>6Xp99Q)YSHQB|k z?#CW>RTp-!t9lX-OtpdC>#BY1T~}>q=ep___N}WvBMO-6Pxh>HuB>Yxlj8 z?Cy2MZgox+b!7IcOAnfz>hab!dZ|w<4T=XC`kwYUs19*wmXR&j#90L{rLnvGl;inV z@ODnY9~-0$`d+SvzL)!;@1;xaHgw+%1iq>`3STwK1oV;yqw!T|n9b+XQD*Bof#>7S zFdNU=!sdu==LC&+#HMqO>~O@Ea{{V6V#7J%C>*ifoW?m~vpJ#B9kJD%K;@3uXf8jz z=zRv4&pF-9l>QFKo0-znkyA7sHbIwTbGf=ETg%n`*jTRW!nSf%Pd1gSHn62!wT}(u zs_kqiSN+0fa_KX(m0bGMY$SJw9u6}4pXcDdtY#BAArRu8Fk8skQ0>TUAooKG8vx(3 zczj_4pgj&AW1@^UT5|bEm?+&mhYdhxKx_a6n|5VZF;RNBn~BoH9ZZz|(l;|v`m5c* zMCtEyH4~-Jb~$g9z7b|$xPS0wSGZ~tXEdqivLjq|#mCG%=>~;~4ks_==4>W;VKX z(%k{0fTmq$p7d!%43}~yUbtM-L<^VunONac7ZWL5>S^MHOB+m-aA}{35iV^v5yGWk zOnh+ZGZP(L`qRV)7m;HkgUg$}i3=`o{U$27C;}T3+&^~A1boNh5rvsRBnD;z5g(Wd zWR}BBAoCw)0$xiZ{okXZ`fBz0>Xzz;>bE!n@aF0@)k{$SGpZ+4Cs$jm`&Ro_yHpdE zzf@kUY^yv`*~ISum6e++pQ(JJGN*D{<><=6oCMgvvS%g3-v8IjFO{F>EWn4#_m*!f zFGKy$E1y}O#?JrI<$cOM%B9l#rMF7|UD}5Ff3);^W2k;{C-h7jNWTz(vJ#iYFJRuSUpzAOEO^i}DL(&wd5O&^^;Bt0@cDBV3>ME$>+dNuXS)K5|y+2?;( z>IQWvGpOP!iJDs@n5|5U%!9;r0D{C}VPRr0Ci_mba9el>Y(^4jDj$@7zEp#CQ& zMZcye`sP%VsN5I^(`m=`M>=# z?qdV#aTvCv?&gg#^81r!q;+&`C{SP`jOH=GNb6{D?h{Gt=*Jl_(%NNH(wY>J)__QB zK%_Mw(i#wH4T!V`On|(2x`C9mhLp61l(dGFw1$+lhLp61l(dGFw1$+lhLp61l(dGF zw1)ma_TD~Pjw$W`KIyLRuI{ex4l)Em5G0Y8llPN|I0%9uUWOnDGDL(4f_NE%AWWDb zL%fGECPSDrBh!MatE##_``Y{3qSlb8H6&^ciCROV){v+*6svXh0Sd)xU45f~VzsWmCqSsRePM)R zwXVLvL8vu>t6c|Vxp@#)e@EFCtF>*xsP$mCXH~7;Hq_dB;EpEM&113plK`RC_EjB< z)!H7XDQ{6nLn+zd~cTuKuF4#kjTG&AT;Q zyjug_tpV@WfOl)ayEPzg4T)Pr;?|J3H6(5giCaVB){wY0ByJ6fTSMa3khnD@ZVicB zL*mwuxHTkhy<@_yA#rO++!~7Ay84q3#cpk{uBuzxYo_Yf_8K*A?XOW&hR#Y2pMqOk zsey27D>YE;*7l06y0yK!j9Xi&VNcxJN)3ct+jn&+c58dBnIhiy9iMHuwUrtuc5Bz3 zDc@atePf%EYx_VXFK%UnEk>^GbCbNd{pn_lmutYwHQ?nM@Nx}!xdyyk1E!o@?Ntdf z#pG(L28molBG-_}^^PVvE_bZTHQPk4A(3lHJ;o(;Za$&4Zz5?{h23uZVhEelRUTYlYp0Nz{@q@osgcuC3QVv0U4Wt18#_ z;xck=y=KQ)uC3QV$hGwvD3)v6o~h1ey@qY3He1bWAY<3|5lDvJdJS8QUE9kwwrl&7 z9ow~iD<#A3-!RFr3wXQc)4W{+-mU>}*MPTcK>3ighQzKRv1>@| z8WOvP#I7N+Ye?)G61#@Pt|75&NbGt?lX92GA31lW&rC0*y$Y*#ZLf-|UE3?h*fp#UEAxx*fkFjyDheB`}i|FocmJ#~71OC# zSoQzT9i0C!sN7MRUAeR}wQ^ErEY<&pSNc@CRyL_*%U_q*mft4(|5UlPe1G|l@^$$C zx#d&JN0$#M?^fwg3KBda?9G>EY6EOE;CSDqTSK|G3h@r9G+qw-f$f zjJ}UPjs70J5;(OHnTULCexS)7P zadz?2;?&|v#j(Zxio=V2sQR~YF(`ahSW|d|?EmA1ro!EYIfW|<)2R73u5e(XvCzNJ zt*~h!%zu;rINzRsh3x<0{DS-??}bJLik}T2cvVYA!n_ZH9Fgq`MeRf9noa{tu{f*9!$ku1K%XZ3UGaE7=Wmaci z$h2i1qSD`uWdG04OwJse8I#!~GcdCw{$B{bqt0J@@N)1J+5h{4JMjM*!MVYt;Fw@c zuxBth=pJklMCl*W>(cL}SEip%x2Er-%HOr=>FG1m6VivLN2Q0Qd!@HdcTA_q{=c7E zm3lU{B=ulwUh4YP4E%p$>ZsJ{)NZN%WdA#>|Hqf-PF35kK4yXECfWxuU~Iekz5qNn z)?Q}-vTgeyp;)%7_dkSe+dfDL*|vR<5VCDwYM@xQ-T%e1U47oL4cWGRZ4k0;U!tH` zwyXC8glyaXJ1Caz>irKP+xGVnie?QGJT@eGY)JChkmRu;$zwy3$A%=24M`pw z@_B5)=dl5w#|C^J8}NB-z`Hi!T^lg2U451U#Juf+wEpd+kHUaDgMbAn|Ae=pa0pG$E5oXW6`#e zH=>`UbF!t+V%k$i?)4eBHFeuiQSB7S6@JZ7kN(F2v$AYM&5X~?FwQ? zJll4(La}GNJ@ITi&Vp@tw(Y!y@NC<$3&oyouL|SYc02*w@N7E(28un~UcFV%w$U}7 zZM*u|GxltEEU{-?58Zb6KzO!)GtwkB+a!q%#hz`y>$nQK`xTWWHaq$xHsF)k zfKOrrK8X$ZBsSnR8}OP97|piNzgV;FGZW4BzvHoH+u!k6vuyyYnr#DbG}{gj;lpFi zwwFj%v)!I(wjFZ9o@lnek*k{R{x8;Sdr=t8_JJ^(?H?Rf&30cXnr-{?Xd%{Ychs?F zyJLwp+rDa6HQW9SR5jb~shYi$8QSgy4w57`BuQ-OU7y5;B#8~Z>XX>D7I|d z7h={`v(H+#P3;aWY%^A78ii@4SKTQw7edXKA*D(J7=T!ecxV%SsK)Dc4X_U-+i*&%)}$^H~2M$ogMbxU_IqVM5`jg}nc-$cg$`ut`2srjEV-hZEbL%v6T3o8777k(VRMZUi+{9|}` zctbcNJUcuoJR%$w4#oDj3?s7rpXA=ot;ju*dnorCa{ZU*&dHse8=KoVH!Qbvu1l_j z>3^DS&%Q)e|A({pWPhE#B6}{Le`I#Q>@L|}SrOzk7KQ4VhdPKT!x@&qP?EZ_?d#P7bPm|rBpSn49b?SoDWJdNMh}+kv zwoPrEN)zPTUpeRyY}VbRbO#Kg6VGC4b^^0A70K!*aKp3&el{h6A5Bc)yW#ss!CBye$k0w3z0!27!<@Se^IyuD)rZwwRotGWbUxgmkm*Cp`0H3>Yk zJ%OjLO5lVQ2|Q+50uOIX;6bek9MzP--RCE8*t`S|n47>}vlF<(%mi*dJ%O7|OJK(- z3CvGSU~0S~W}3kDV-ooBs06;>n83Fi61b{9fiHDW;Imy5_~*_ET+%Uti^2pxSeL+i zHze@RbqSoiCV|(rC-CZ33A}7Y0xw*az_Z&DczSCBPi{)!`1uJua$W)tnVZ1E5ignUzfnAHYD)zbqQ=)lfb6-1pa z%}d}Ja}zjeb^=eFnZR+=6Zq3<2|RF00{5Aiz{c?j96C0E{l_G5=TQml)|kMp8WOl^ zeF7`p6Bu?)V293%B$pHTMVP>~bqRcLLjwP{E`hJDN#Kj^30$@+flsVR;3LZt_;6bS z7qlkuw@nGWV}1f}nwP-Ya}#*g>;zsqGl3UOPvF#P2|R5|0#BNlz~jazaO~Iw9y}(2 z`;AKAo{b3{-jKjS^$G0LJ%KxQO<>o~3EaG60yhp5SgcE6dV?Zq=mdVbCV`){C-8$+ z32a}Hz}J^0@a47yKG&MSrA-NZY<>cp=OyqDa}zj!b^`C3nZR47C-8=837j=0ftOEA z;6>vTc<$H)P8pNHQ${85gvJCO-H^b;>JxZC_XO_UHG#WzPGCdF1ojIP*t0Hy+ighT zmg^GOX-xu4?Fr1TQpBedxM5iWKWj_iN398bw<&?E=O^&hc?o=BZUUc~oxrx430yoq zfe%ef;Qdn)c+bQH-abBoH;zr z0>^|2+_x@)yKhL~uyqL>uqJ`M+GDI`9@M>eO1L*98E!~2+)(V^w#&!3x6k%f-P?B0 z829!aBvtpe9Tqy3na{+%edbZ8GP5mqZ})$=x9=d)sm$ytQ<yI*Qc@#WB)spKQF(F`9HDIHkatyEvywzM&G{?{|=|F!6usFke$EzvI-^M6`&Y;;i6$c+E( zqD`Wp_+|0^;$O-5FJ{F5t;JczU$D;Kam6vk-HZK;+ZQ_(Gvxa}V7&id3XibH-))6! z3KtcA&RqY43wsm>knR6TA)Egf>-)W)e>VRpx&GUk>3?y4O8)r#A^APY^zV@0l#%`$ z!VkkY!snRh|3G+0_^WVwcm{d?Lz(42DBLmJEDUpB=RV54nOn{r{|8yqZ+7mI+?lx( zbBE>j$_-|Wf9G62`wcVvS7)EkKA!yp8UE|CmuAn(PGE%p-q|79?#%BmWWLR;&HRo0 z{+}{UnY%LAGroUn=4Y(qw@;=a(}TJFMY8)J2X6&025rF~gS%P7Zw6!gPYRA;X8+Kj zXRsxi{qNJCq~B)!z9-TTF|vP7`f}#=pPU|>-j}@o&gm}cQtF4)r>SbX1uT-23gDmsw%Rxsv@YW2&yWAs*0ehBB-hesw#r2ilC|@sHzC6DuSwtpsFIM zstBqof~tz3sv@YW2&yWAs*0ehBB-hesw#r2ilC|@sHzC6DuSwtpsFIMstBqof~tz3 zs$vf_x>zp;h8HJLRgs-l6+u-+P*o9BRRmQPK~+UiRS{HG1XUG5RYg!$5mZ$KRTV*1 zMNm}{R8<636+u-+P*o9BRRmQPK~+UiRS{HG1XUG5RYg!$5mZ$KRTV*1MNm}{R8<63 z6+u zV*)Rg7Yug3pgys6YWD=5)-{1Abxz=M9TPY0{2*#z+KiPaA11^d#_61 zjw=$l&9Ve`ZcE@stqCkNB``f-5s6ITmva;N$?ODvFf)Pe(-Zjmv;@99C4tXPOyJV- z34Cm90-MJq@DHOBIKMH0cQqvNmih$V&^>{(x+d`Q&I!DzV*<|&6F8+VffF|*@c4BJ zJZen>4{cB2=v4{aYefP_EKA^!wglFcxgE?ibPr*H}*{tu!LU=Z2=EeqvBhy42d2l+Sh&*$6n59jaA-<+RC zKfsxc02rI!KR<#w|K0MNG3NjKa9#Lr_*(cE`U3vID1cvwGnw^&dU!me{`U!og}uYB z;U;00{(!ZN1b8|3RIW95KjZ$d%gvzb-=y3zxiQT9ADrvKXn;yCmHjfiCi`ag1?v7S z%>FKW3p4+x(=TumBLYTecgyzA?vU+F<-Z>?pJm=-?*Fry$LJgQ4Wk0C%3PTFdFF)7 zPcx%3yJY%gw#{_Phsh;m;vxy>T&A--IKbB zQ3Ah6O-Y@YIy|*+YIv$|YP-}=;+}$dh)7Rk=L`?9;0s!_#TT>&d_il#7qkXexq{Y! zFK7+;g4Td9Xbt#+)_^Z)4fulAfG=nb_=47eFK7+;g4Td9Xbt#+)_^Z)4fulAfG=nb z_=47eFK7+;g4Td9Xbt#+)_^Z)4fulAfG=nb_=47eFK7+;g4Td9Xbt#+)_^Z)4fulA zJ++`U;0syHXs zt$|nP$^ZC*)@<@CB^_U(g!x1+4*J&>HXstpQ)q8t?_J0bkG> z@CB^_U(g!x1+4*J&>HXstpQ)q8rWCjgGI_0GECxwLCTh1CO)w}_Apav^%Xf`k8D+Y z1l1lvwMS6x5mb8w)gD2$M^Nn%RC@%~9znH7Q0);^dj!=ULA6Iv?GaRa1l1lvwMS6x zvB#u@J%VbFpxPs-_6Vvyf@+VT+9Rm;2&z4TYLB4WBdGQWsy%{gkD%HksP+h|J%VbF zpxPs-_6Vvyf@+VT+9Rm;2&z4TYLB4WBdGQWsy%{gkD%Hkn6yWxtLmMS7)i{|I+7UF zk;Is%jwA**Ow*5I(rN;ABr!YdNMdmH*u>7SChlKmt|qqXNMioCts${>aeV?G>Yl*+ zyCzUa67x|yk{HyH#GsBO2Cv+Z_$VDo%vK#q4C+W?P)8DjI+7UFk;LHP(mMbi)Ec*2 z)4A%(MSbe4{{J89|C{muzpPwTIioV6as)L1cCFM`wy$hj$+H5$r_2C&wY;qSC^Z1@ zD$glj$w+|Hm;-PqYXA%__bP8w-ng8h3gACWZ29**vfZjNR}7e{ACKa0jj`$r=f70`|O0EOcBR04dL zN&tT;E-C(@cz5yF#hI)GaC-50<^=3h99rDDxK*)I?7&I@|0w*Gu>p@39w^+&tbof3 zXBSQ`99h`Euxp`jVcWtcg$$zuKFq&KzrdgJf6V_T|Lgpf`SYj*a7_N7{O zpAWxfX29FwOW~ixMd9zlo5Np*zX*R$pTMEvUaSMqgSi2v+>g1>a_>?J@R{6VdIWCI z{faRHXXSpDJA&B(yX1Q3w#jXjOVc0lLH70RbJ@ojC2&{vhV13c4>%=zboKyx1NvpR z&;BHv%Y2>rC*uTO%si2Km>B{$(HC$*=CsUlnS(QXW(H+;%52U^f$xJ)gTK=g@Kn$e z{64rfxF)zbI3qYQ_-U{YV+D4mAE3f4fiKekNdGncm-M6Q2hw+@uS;K+KAT>EBh&k* zcV(Wyw&_jMnbf~hAEw^?zg+_$9(uF0F>_bu16tBiX;G;3V4c#v8 zQr{^`-6tw-J6?%=xUEpeQIifAA{#D5GTf8Ng$t1i7a|ibL?T>>Jh%{Pa3QkbLL|Y3 z$bk!y0v93!E<^%c==ZAz3f;XziR`y*Bl#^v?puh|w-A|cArjw0^lH5Y% zxP?e@3z6X#BEcGxouA-we87dwmq4|wkMO<_GHr9o=jHT zlSyiOGC6HeCZ+AkWVAh*gtn)#V+RTyJVwc6v;CeXneEBsvOSqpwkMOx_GA*-p1O8c zGHGnTZR1eMB(eQAlf!ltr?5SJxkk5{1h(I1^4FeB`r4DpUVAdhYfmP3?a8FBJv}yG z$t14*Hj}sZWYX52OxD`dEz^}u&f0G?DQizAW9`W#tUZyhwiOq5j?>jbWUGZpRtu4< z79v$GM5bDZM70okY9Z3pLS(6hde$kCqn2%?sD;Q-3zgcHva1IAZFCVRk)4)pB&UTw zYE>dNE!)UU3z3)>A}=jOT3U##v=B*YA#&0}q@;z&NDJLQUWt6PY$F{lL^fK8WV8^u zXdzP3LS&+aNJI;fhZZ6YEkqVth$OTSIcOnL&_ZOOg-Ad{wYO?|5PaVP{RaY9H40qX zAmCRu?y0L91AbLwz^`fy_*IPozp63dS2YIql}->@)tD`QRbybF=>)N-&|5mG=pm3T zepO@sr*oTZ*{D^Z&?Jza?}1(c0l%toPtzkHTl}iVY-yh^TV9_g;8!(fH@~Veuynla z_Sjf~W{D5}FI(m}3f$Eoa7(?w4c!H1brrb0v%p0i1gCQ#oh(4$FUyZHiJ&J*yf8uN|%RgD3^s&P+a^ZtKud4bQS2>4Zv z`5(WkF|c~9?Dpyy0l%s-yZKd(fwl(OZE?N8L)``L?<(L|HRc0u?W>bs~Q7W zZjjyls>Wpe_sD@<4REes=TKBMtOPp@p4o7Zp{CR^0e|K z<^UX6ZY=j__W!2ku=Gu7E#AMP^kiva>E6;@W&vDSI-Rxu4k?W+4aWMnp!)w0(YolJ zXl3*?^8oIPZi}v^0>GKkgy`^SR5UE=6>S}Lj8et*%mi3fd=}$>usE-HeQ`$doZ`gd zQN_{45ykrAcFYCH7B&<3Jv%;oNOoj)F!KVo$VO!U*Ja+xtjs)}Y0cc1xh->TW_sq# z%miizjLHnl^r9v}$4n|%AG{x|3Z4y?1P=!D$o$U;&Iu+4M^P1EL{J}W7jz1;=?&?R z$os#LZew=9J?R_MGpP$OIelz;46FVPBaf(_sfJX~RF_oQ*ZqsNzn4j1J86YPiy_fsNVFIdErvvkA<<$;v=|aChD3`Y(PBun zxK}(x+{z0iS`3L6L!!lyXfY&O42c#)qQ#JCF(g_Hi55em#gJ$*Bw7rK7DJ-NkZ3U^ zS`3L6L!!lyXfY&O42c#)qQ#JCF(g_Hi55em#gJ$*Bw7rK7DJ-NkZ3U^S`3L6L!!ly zXmPK2^tZnX%;0Y!(PH)#ErvvkA<<$;v=|aChRmq%nvy`G#gJ$*Bw7rK7DJ-NkZ3U^ zS`3L6L!!lyXfY&O42c#)qQ#JCF(g_Hi55em#gJ$*Bw7rK7DJ-NkZ3U^S`3L6L!!ly zXfY&O+^agW+r=Rs*zHNQm_0>{A<<$;v=|aChD3`Y(PBun7!oapM2jKOVo0>9~$cV?(8b>y-B0ptQ$2rCru24QyBHy-I1v z6-wJIQ|jENv{9>4p-Cw{UkF)N`f{$)C$p74n5oo0UFr2{N-s}QdTyf9((y`&mbxIRAC>_5}>8LeIhqfz` zDVOuxYlRXOon_mQHl_Mjr5;U6+s#+na-LGBxk{zkO4*r02({9NX-c0>QTk}2(!1l8 zR*zMBb&S#rqm-U$RBCHbT3oO6PC{!uPv$H}-Y#9<;hQyX3v1LeX84_ED z#FinkWk_rp5?hAEmLaiaNNgDrTZY7zA+cphY#9<;hQyX3v1LeX84_ED#FinkWk_rp z5?hAEmLaiaNNgDrTZY7zA+cphY#9<;hGf}cNNgFG6Iovwz7^ZUTbMNO?<(|zg zp(^0K-1XD|I43tTcNA;?jmXvKw##+OWwRTyA5j z9@&A}9jOc$u_nOhnSW$fWuD7Cp7|qd|J{_in)&~yXU1m^$&Abl&UB|bU=;iitYck( zmCXKc4ekqW3$6{O2WJKog2Sl~I4tNDY|Y&NRC;~-{q(Bzv*{)22h;P?*Ha;I8fya_ zn?5)_GCd^SGrbjS|D{u3rPie0NG)gPe^cu2)Eue=PD@QnjbnX)M%Mo8mfF)LmZMfbN^W;Q*sLg|rZN)NXwEofExZIjX+^ObIzr!;%6(p9sSE}f}#!E`0o zW%A!P7Olj(OtOu2nS{n_%&=(Pw%;hFJsXu+mq|W_b(w@%mr00qnS@xENr-isgjknJ zh;^BSSeMC@S^rpwb(w@dX;)%hCfUZiOhT-GEEKPQTpfSe$5=F*;=!1JpO6js3E6<3 zkPY|=*?^yr4fqM!z%6d99^fZr1Aam_;3s4Q@r3N!HHkS(%B1Z+I%ym5lePgrX&dm9wgEqB8}O610Y7OQ@RPOyKWQ8AleU3)(sq1J_D zCln7ajw%i-_9|{&>{v_{))(GqJ%DEmO9~IN65#cP8B_zDSU9RMx-g4>3gB4Q1stC3lkJ+_I2&ZX%B;z}ky%b3KvU-K z%p6t*JfC_0<1>e5_Rb7t9>6x4jWd~GL-0|sI(UIK0v-zP32tO{!1II2Q~(?k>=6tM zb__ZPh4go<640K0IlYwi0q3W0NzY1Ol%A44A$?eS?{ouw0A14MbR8=MzMp!XK7c=^ z9!mW-H8=H3W&)g%nn3lxQK@096|i-xW99Sst*WodU%R(KNNgJt+lIupA+c>pY#Z{n z4S3rIyln&CwgGS3ebu%BZ`**kZNS?$;B6c5whegO2E?`@v293f8xq@w#I_-^ZAfez z65EEvwjr@?NNgJt+lIupA+c>pY#S2WhQziZv293f8xq@w#I_-^ZAfez65EEvwjr@? zNNgJt+lIup`pY#S2WhQziZv293f8xq@w#I_-^ZAfez65EEvwjr@? zNNgJt+lIup`mUoU4zdtyF$u8_vJh)A39%NF5bGcd zu@;jMYcUDMYcbirV{F@OtM!Rs+X8;hGvA7z^W4X%w%-lY7VvYP+0D;+2K=07z|VOG z{G4aN&v^#oInT9zI%C+r@7Nf&5QZ&;VGCi{LKwCXhAo6)3t`wo7`70GErekUP1KlS z*z(;+Wa*rM^|S^=MMsZobl%^OQQxRVvL^%FYxbAFZ@u zn$l-els=lM^zL}2)nk=j9i#NZD5Yl_4b`qm3|qj@i|%9mTDJIk(QNVaqJi7pylBAB ziw69>Xu!{l2K>Bez|V^Y;(5`v@7`FokG!#JA*@;ms}{nlg|KQNtXc@G7Q(89uxcT! zS_rEa+AI;Xj)|D%6ERC^%*a7YT(D}Tk2PjkwQhU6L1|UJ(o5Zyp6#mi=gvw?Iw~y+ zl^(29x_5)po$Hk5u2H(KUFqsoN|&urx^S7&*=>eu*Q6BC?qj4{wv2ak+u7m^Xal~0 zHn9I}`5#|En=QV8HsA|r1AV8+|M&vhY>5kK*RDxSS|X20D@CJ}GL1r*w9>!omDY7v z`mn3gJDru@?5MOdRC>Nn=_!pFBdO)vW+b&zlg5mZ)Vl3%A2ZCVnq4*ipH6Mwq4Hkk zwaT)}Bb5b}J1Vm)msX}$PO6Nh_TO+;0PI@X_`jV0e@uBydC&6Ta*uMCa)ng@zbvgO zz4>qY|6$SIQA5-->JpWsy5i@>_x{V8fB&BUx8)zo-;=*FKQn)ResccU{FwY6`9b;a z`7N0P&>>tOe!!}L&xdW{!~d!OZxKehA9Cw*@8nkIo@O0@`*OEY_3zT$*^B`=nkoRh z=LWJi;1;>kzv=&1WTs^%WyWO=%rs{DXS!uJ&4j_X!6(%HTNykPJQ6%W9e^8xD}wX> zP5=Kq^kgoB+dkgoB+dkgoB+dkgokgoB+dm!}z&khKog47Z4S44UymJHIxdHFofOl@dJ2&8+8}QBzh;u{Y+>kgoB+dNA#rX~Y)|x$KtTiNKt)UZV z>i<>8TGzy`nputW|LN2wb(QxkuUD2=90jwa?f#Wlq@ipz_S7n`X0Kc{#_aawUwaa{4hVq~C+ z`ri)e&C+@50DhYKd+L?cQ>m8JeW}}1ze-(_IxF?F)Y#Pisa;w7Z`;%+l`qWL|LWlX z0p@k1U%x*6;>#1_Wnf5cDgeY!bwi&4wQc5yw11zW@r5qC_Kf~OJL)=j@$)8Ug zT>C^bgrUDXEXEjcShY{2Q^KDMT?TxjJu@TpL;CcueTLC(e>O&j`@6%!RynNNC!&-M z?!OQ|KGA-L(Xk=!TQ#!e&&H@se|K2828UJqM1-KBPrn%YoA=J4eH!9}0tnNf+G#?B zNwzUgkbcnGo+eB-g#AsJTri+BO}}CFARz)}=bS?E=rOykI<~9&mG_T-uWS&ZBh8oR zwrx9}&#YcR{moOE;~ZK$7DC05xoy+TaaO-{_9Rp`j8JHf({3YH?upjl^zZPQ_M0GB za<&8uvPW%#WdqsYbS;>W)V@xlHPjuLiPoUnb;yoH%WorEa?mDPHj@2~8S;@fLWGMb z#Gl_!!v#pVAPE;FcU8am_0--~P<&U#-yR^*f+SjyM5|x?{$g({NNy`AzO8B>$#1j^ zR=-5BAPE*E_Z1}f6%^lBwU5+T^-IJGl2}1l3C8iD^S(+K`wwB&H3CX+vV#keD_krVWW{Lt@&Hm^LJ)4T)(( zV%m_HHYBDEiD^Tf+svI6U$c-mZNQs0;7z+;O&jp04S3TAylDg8v;lA0fH!Txn>OH0 z8xYfm#IzwXZAeTT64Qpnv>`EVNK6|N(}u*fAu(-8OdAr@hQzcXF>Odp8xqro#IzwX zZAeTT64Qpnv>`EVhzE`RU4X>2Au(-8OuIf|+K`wwB&H3CX+vV#keD_krVWW{Lt@&H zH*LV1HsDPg@TLuT(+0e01KzX&Z`y!2ZNQs0;7uFwrVWT`Lt@&Hm^LJ)4T)((V%m_H zHYBDEiD^S(+K`wwB&H3CX+vV#keD_krd^*fZAh})keD_krVWW{Lt@&Hm^LJ)4T)(( zV%m_HHYBDEiD^S(+K`wwB&H3CX+vV#keD_krVV-12E1tl-n0R4+JHB0z?(MUO&jp0 z4S3TAylDg8v;lA0fS5KUrVWW{Lt@(X3Dbtev>`EVNK6|N(}u*fAu(-8OdAr@hQzcX zF>Odp8xqro#IzwXZAeTT64Qpnv>`EVNK6|N(}u*fAu(-8W;H`Hs~M{1-D__gI?kGH zyiED$vW~N6n~bxDWSlkBtxz;(eD`le@%38 zbVhVyZTx@lXq#xGD2@MrSbVeieDP1kKNjyP-c-DbHUCa49#=f1xL0vVu}5*sVyReH zSXXE-tY8IzCPx3yE=(^>DU7H7ehkNT{=XvMmT$_>%g<)q|CIcA^8Sta z`h3@XM=JlX3)>m<-xfA8?|*hUJ)FXb|1n`>SWngej$s}7{`TAoM*BB0<9~K;I%ECE z=f+U+zdqMB*O5&Bx@6aMe-;%yBeS3N~tNWdU<&R4rM794x>F((*8Rg$0wLbL$GyR`WwWS_T-J80Z zdjA)v&a8R;WESvG&RuGcx7y+G@bSCxWDuA(Pr$FpzKih_zoj}D zq4sI0q-;5EyntWIdly}A8}J!wAWkFKJ`FXguZcWcO6?wSNP*j>9my^3q9GZkApv|y z03Q-yOFp_9tYPMrOTfPpvBif3@F4+wNQRl;WB&mX61Mn|0P!zQeA>gz?}7gx2?<+# zNB|!az=vd5ZG1@W(1^|1ZC*ziqXirtRm z*V8%V*u0&Y?x))J`0c6Rz&=AQo3|U&hhv$Z8N;@{SoclWQ*FEHovEj*V}NDzOAEX0 z=9jR)Kc4uP3byT=UK+FAa_s(8X0qRh>8Yvj?~mVftQofLnH~UBYuFxNJkmry&-B=s z&$s&%+L-O}nZ5u$e$yLJ&-vN!+jO)Uw)>kN0sVeV_f0(=KW=+3^Zx^D|KH%^UHyLH zTD*tE-xnOmn@&H=alD7tzK@2g0TJIpwe6<&t)AcP>hZDN^yrw*KfApaZ^v=G2fEL% z#@oiXq53vUe5>C#9LIZz`}}IWefu`v_VeR6D>}FA@x*UF#K)dbHQxQ*=U3xxufK*# zHs00v!*RR^)Zzu#;%&b_8}I7(-B2A_M0<9-jkoZ~MG}Yxj3G9&qCRh77j*u=3}slqCXcCLfg^KCIVWSe9@ z2E}2rA%wUWZ7?9|ONR)Q&3r)8mp;e@iaVy7-GB&`4I;$-XTt$WXZoN-XF4RE>5ybT zAVOs`ACP1|237C4S}q47Sc7Vr4@i2`A<29Uii2eX32~p<%m?JV(*dGoGanGqvYC%T zCR#4@0TC@5Nk}pukk5P!)Nlb3E=a-!$z3%taaTb;^8rY-Ac+;6nvSs2~Z|z{HIO$&Cg1 z8w=};Uv3MC;CHDyoB#h)<)O-ND|0KqtXxz%qcWj#1Tz43t<+bxuWU*Wz<1?O%kPw5 zEiWrST7Iy67i$1sSw6pfTKTy0!OQ^|#2A1r%catfrO!(5mR>C_V+_E8(jBGQrAtd^ zmrgDnS=zs}YpHK(yHcl8Hrfz<6#cE%2XJpRH@Z6dMfCIN_~_7RuV_frBib@56@M&# zR(!YkYVn!k;^O_q+l#*{UQ#@(__N{>#eIvr6#Epnl~n*=EIe6QSh%+^w{Ugg7xV(0 zP&llxccG!Mb75=x0aE$(`SGo z_)hprxHMeE`2U;2nbZKB6doOp4tHe@z-{Oa=#cw7_b#LVpUO4oewUk@yDE2n?o?I) zJb?Oted!O_D3{89!OZ_xvrlJRvh%Yyv;N-&RR23Bdti1qdIYv*-hZ0B|35OXWu9U6 zzu#wW$^4Saf2U=RCF|dq>6h6qvq>fhzGUscztSVHn7V(rlJoyXaC&eYGyZoE`Ul(p zPpc;A-+hWLO5&o@S8L z&w-@6&7S6AAbavKP?EYW>`4zf?iT-uL-EtVQuW#GNa}^`X`Tl1e>@I^cyDs6$?a(# z2THPv0NbR-KO`0S_cYG~`9Gcq?z?82)Ru>&^823Vc_4f8JW!H|v8O}~l8EhTBIecu zgCt^*L=37%tc>Gswugz>fI)nr7H4%$;GrCwY~7NB^GMVZcz@!5d5R~t4om)TD)GOB zPIu->p1>m$|4a0As|lYRm5HAs0aPS{iiA+m#8A-$(VuS|ML`ork8vCYO&|qLBn1hj zBC!-S!BixgiiA^4e0LXHq?*eK1`qnl$|x8payhz^UJLNn+Pad zHK3pdba(UftpA$`C|fn4pavAwfbJgOl=9`occqO2Tg`1Lr~%!*dSm*n@trBC0cB?m zD5wG5J-$1oA#-~wYCzdp1KOAjD5wDiHK3pdv@sb_Py-5TKtT;Cr~z$E1{Bmg6x4u% z8qmgMKta7jK@BLV0R=Unjmdz5dWV7>kHC?FaNiH1V*!n|AJg&Fb#@Bpu&fM_Tr8oFEJeHoJXWyotN;5Br&_;uNO z4RCEk`H|F#Tx4FyC)A<@v?Ot4I^LHy1D z$=fpI-BpNy*4wKbTNHi1@v4KQvAQ77pCQ!VfR1X;v4TVHQ zAu*c~3A2I3Y#`B4NHi1@4IN=( z;3GBMM2er$_+be6Oc&rYU4YMY4L4!(|3j1ne5Q-te5MQVnXcg`ME-w>kbuv0v766y z4L9-eyAd7%pXp*ZpXma8rfax~jsG7)Bj7V#?B+9F!%bZLZiGd^XS&$UXSx8N=|cJA zOqUtry$KL!y3G9lOseaDum0bf@*Cyl<;TlS<-2kJE6UT#lgi`D2bLSl{mb3To0h}U zH>I_uw@NEYPnH&z?k&wNU0u4cbb4ug>5$UM(%@3}(iWvC`XTyEM*rU#T_4Se&WR>Q zM@0ujjZy!oTeN8u7QZciLiN9wi%W~m#otTyzaxtK6^G;gU5gtRgTlWG9~Ry$JYV?J ze;ECLaAD8Fpu$dt%?ri+4~zkLC%-cPbiOrzfBugAb@|KkXXj6@RsZXk-#-77e2(gW zABS&;FNJ@`{^y6chS!7_hi8N*hKGmyhP#Bl@&Ap&Aos7_N4dW-9^lXP0Q~;{V*dXZ z+4r-rXP0OH#3}&4&CbpKk{JMJWG7^gpaS5o^aO05-87ree3$t&^G@c~%(Bd*@f?8v zx(47sgSUd0m<7;6kHGE0?BKHCoZysTTyW6;<0^pvSrvejm<6z3YIv$os%xSKfSnAm z@1#XdUCD-3yOf~^*F)8T{Lll-pz042wBRJ+in}kHrL**A>`V-c{SwPo7)7rGrDO8y{oQ0<|6m=?ftqJ79nwW?r!6 zqBR1)SVD@_?snm9@>Lctm_YK`!ubtkrY)Qokv6w*F5~PSoU?FfZ3I5;oIR85pWSWh zIDxas!@C1LGayN9cbl?G;O7e%IB&O{KAnV$h0_ibnB3jYB%#qs-wB+uf;UV1KNIf} zIC+}DNu%vF4?e*EG6E;AC!uUV;DluY$KNI}eu}_xBkep9KH%8S0^`;S9Ni{x)Et2$ zCkl)mE^tIgfuFu7a9FFrp|b=IF+O8Y;oyM+V?u!gR|_1_BxpL*^ecQf_n{&81tAMPJ_UHg}> zQMH$^_<;e)5O|2!WNvl4jn`yPynngJz$o*YY@Z2$A0iFN0A|P}xZNl63Z}$cvi}g? zk_G$#*HPvr%oRz3WN;|NOS1ob^OEd8L|(*3)n3GEXU2<|e4>$0{~6{T*;70~rS^as zW!{nf$McRX`$@SE_TwGdZ!_{K#7~g_5S|1A@zA6AHrhwS1W1@5`q6DAGf2X;Pi!-` zF$npzpAY#s0TL$&oADp1aoQ)g8UK;SJ^e>k-axhY6;Q;_7Q_A+sD*(gYIQ;_7QAjwTZlAGGgM9O8HAjwTZlAD4gH?^0EmCGtY zlAD6)IBlpsULna%L3Eq?Z6>cFB)O@*Otf6a36k8@UM5jtBoTuoVj~j~gCt_mw0XQ9Rexe3i5Mgi8)zj(aPxQs5QDTx-GgknjW1QO^6Ps`v0(~SG0B1F-jHJ7vC?g zDn46WQhcyDuXufNM)92D#NtuK(ZvzP`r>wy`G0}Y{}25S&i%iGc>tG&Q^S+OvEhE< zaB}}$!;NwOuX1as{J%W+ICBB+&dtePk(-vAME3tcW&`xkb<1s<3$x$I%>R4w{;RVW zW`EAi|DR?@Wp|AF1Ih9cW2eba)kWA0a zR+)~ObnsR1A+`Ts44w=YG5h~kRsg&tm>QfM92Fc8GzJ5x{=az;rGI4p|36p*@VWHk z=|86LN#B&dI{gdk|DTvXJiTvvc)D+TyYx@eVd~q|CsY7fnR+I*ICZ~N05~GGZ)%rR z@6=>>q0Ot!PotII<~PR`uFqZXF~tx-u%>Rfu&9U zZNK@?c#W0N<|pwQ2T$NN)<~P%@EZHF-uyUTH|+vl*+_p;K)&&B$OqcKN=EDT$ zb{DwuI|<3JR|w3xMon``~F4UlVn@^Z+veySN|=~|yO6>_bgG756-P3$85 zhs`IggWRL-XG^80v3bH=$hCgrWXQF5LL=nbJH8X-+B_W#!)HRSy`PSg9$)HWN^fxUp#kL1=-^e5Yj4Z~ z$hCLSbjY32frmk^y#u;KuD$)gV-TzR2KQS5x%lmS59Hb#H4Sp@?K2v3XC#R&uFsL{ z+2-2Ya~b5?+v7Gf>%v~UyH9~!dyOL@*WPZOA=lonYa!R(@HWVu-!5|?*WR#+kZW(~ zaB`k5ehnR629)C*{2t_5AJhuD_6E*^-1j-aWKLb{{Rcv>^?o7bTJO6Wa;^7iB3tfS z?>z%@=iX~9pXC35@rTa?A9m`JhA$Kf0UZ}Lg7|0!2w{DO-GAf|*u(RL% zw#yl=?2fGKJf&@>Lhd-X9tFAM+^P%Yj*~j7LR&0_+)-~n7jj44d9u=GjgUKsO*<)3 zjg_aMJo&;7ebHA_ zl)fCPw7#>_=WFE~G_`L1_qk}@9HmbuLN-Q=J{hj`aYvBCu&y+RhP z8L#xgK*(NMi{1|*d(|y^Z?zJ2cm4Ib=$#pmy^Zc4`LSXX{~*Yp?))A$zkjy9=6AdE9x||6|G)C@ z*Z==_^8f$;^8eGR2yjStWOi`2dv=R#l=*?00Pkd0V*jm~`!cs>uB9r#nVAWh!!x5Y z!&vukYurB-tPeg2-Uyy&-M@!}dxM*q1#oe2X7ICMY_NYYBIp-%3pQirzwguQ((h9H z?=R^k=|80JPX9VRGkrn&^z`wp{kKngSh{z*YkHG(HuZICZR+jR%T)hs#r^L{U6-1X zIyW^bbxdkZYR}Z*RF71bR0aF5rRrKYMslo?wr+&v*f;mqj!2G!3X)@A@LNkrj)Mrv zu^!M`6v=5Vh~%{9ksK=$^l}MgksRwAtr;Z8L4f2qNKX++jr3|peM=85Kdu${p-tfX zIRf8J6!> zo&VliK3Xa8;e3HL7y6L2d@x4f{cb*TtmGi@?s6aPmUrd}{C%pwLR#8K3B29Kt0qf9 zc-3rKy;R`Mxn4C}-k9uFv*mS?pVm8DR+0R)(Pk|P@0~5LF7c|_vU0Xp&6Zat2)x|j zy|d+|$p6mkP{w^HwY;zp546w9mgi07RQo&8vV0t5pQ3nmo zKRz0Atv}Wka;-nQ9&)WevJ5M9?Jd3ya_zNFf!w*bjD%e4&7C3F`ogu4YwzJU$hG&- z9LTlzM>A%}wbx|E?6~&+V8-mY_8xc-a_uc>h1~hwKMQj0-8UX`?frfr8yLav3|#z5|j zZtVuS_HNz)x%TERhuj&-fK9H?8>h0(wfE~$^6t>-WbSC6lzhs=e7q(2Vmv_dNiv!5D{)<(RYyH9nkURGarbF&n&OZ!t$8uhG z$Q{eM-zlB50&+)o_C1h0GS-IiZ`Lhmjh46WmNUC5O<51Q7 zP$uc_-K*u;fsnfa#id#Tc1bCpI;hTL7XXQR>{os=5aXdHH1 zqBLT*(ykLAceS#XkkBp>&_D|J*tBm$VJXN4@=RkUP%pra;r66}k9|n+Xk2Sd@yYT)3dFN~X$8_^PXccdhRorlcjQ^)n(SJSv zf8WY3mEM(YDjQYOi?@6jvKQ&CIw`{-7w z|F=&xG}<}ZDyl>sieD7}QT%K1FU3cT4;1e#URS)Vcy{sR;*rJui@O&4GWUOzVkR;E z|2KtS7p^RvS2(qBOyQuy?u7w`9SWNj^7(J`ALrlBzm)%Teo_8+`J3~<%>N?)^ZfDo zL-Tv(hva*(`d=yhk-7ixhOdUtgp0%b!`s7Og_ne9g+B|AkkS9?+?Tlzac!NPsYR*zsasRmQv3futo@gYDqqyDjKzD@duv?0 zdmymeDuEFT1a_V7eRA>e!vuEeE->^vFPDoORtOBa$IB(vP6Y;y_Hwy+U{`_u>$!yO zQTJOWP=A{@P0FQu(_Gw#M5xVhF7Dmgo95!3NrYO{T-=jHsDmCPLTxVv^;W%hF5Zbm zsJ){W??@ulc8x6VMk3U~_O1RUYVmfn5I=jvGC&BjH}m4ICKYNQC5yK)sZe{{FW$2c#BF=6KbnWQZI7jA zAbWnT-CD?R{iY zq3(=i*@k^5wSKT(UJqK|UnZ{(t^Y8oP9M7h>qH!o4H^;PC2%C+}blL~d;=W8Yv>RNww zAmmzK8Op0)>&vUc*}qt~E?o|}Bm46_r6;FC?l_+q1-avF>jJssqztprV@n}- z)Jx_2fF5SH*fVpY=o(l6L|9G#2mA$NI2Lp8KQC ztVb$XJ-Muhy^w5|2PawGJ*)>BS@)N*?#pJ~yU4m{oOO2>tFxYUR|)IROjgG{>yA;@ z?H!Oj_uFb&?M19x(^)sqLb5&EhFLeYv2Lt}WN*BofYqAHx^4x((g@t9*S)2M-xJqV zvaZg9~%W@!jZ7)rLWGgjJK=RxX{!O~L0g`8X zk=p+nvUuOTz)Xe8qdwnEh03GGP4lGl%v7lCW9Jq_@~F?rfaGzWJqO9-JZl7!$B7_v zdr2CrHB+H-WH`f2h00?&Jq?n_Qe(cvlhIE#U*gH=h%=|-=PBk(JQ=;(e2FKcpKQLw zlkIY%nF^Kty~<35%IFA1r{nzbW-3%hKh8{r%IKA5DpbZhW)hO|j_!eEJVdM8uR&u+ zna@8N?@06cC(pLreE!LJhnvqo*}8|B&p#Ot0qyj;S!O=}WV}N&@p&uH@8EgEH zsT`8=@^k2%FScU>oe##gH|L)+UYX0K%GbY09|xtAWX49s4+7bUT^rwMEI*$bIidb7R6 ziLF^dSR`Pim`Z{SU>9wTc zq$iTPl5RE4c`-P z3tthg4=)Q>gbzduz_#J6@EYM@=%1n4&`0P2cs|q@xP_QF-9ohiS3RWQjpg6d5FekX)f4To}!T+^?%KsW#0iN)8 z`ET(z`!7HW;4$b0*wdfq-^8Ep597E0-{Cg^Z~8`j%YEI*0c`PIjBbGAePzCVeEIkV zK!z{bm*D-;yTbbpk^tl09`Bv_-T$Sy|NkW11F+Ov;N8lbiDm$==NHe6=Y7u@?*8w^ z9RO{fD?Ig{W#|Ps&{OEy)|2H~1NZ*_6Z8KcC6418fIhSWv?pGj*pPU7VrAkXiA9+G z&rV!BF`DH4^S+e$nPw)`eY3}F%}l7fBgD@zGokKI5K=^Ihy%F? z&iFDj6Y3u1;-{EzqTF3HUR_4JZ~SC46YB0<@e|EVs63V`GZQMKpI~M}W%T3COsI^0 zTnQwjS7t&o`Z4o#ctSf59mwJpW+qg|JIc(2%5y)`%!JD5N0^yV8U1iG6Ds2!W@bWV zyuX-(xIEi3a}by3cc?jt%Xo*FgSd=WY7XKu-a+OdF3;~kGZQM~9bjfcWxSF+e7v{^ zr1<`3CRE1T&&-6%c*SNWRL0xa%!JA_+Q-a<%6NO5nNS(8$b1tl&uCBcO|Xo&hxsO0 z#w+A+g5$fJZ-V6+?Pk6SmhlSAH^DOAE@mcFo)HD??6)kwQyrZi#PiKesEoIxnF*C= zw7r=LmGSb-OsI^votX)h@wPQHp)%e!<|ImZclIORTIf;_-wlpVEvOROmNtBGf zg*l0m(Kk0IQ8M0U<|InC=cdz;jJ`=9o!+6%iBA3ESrw3szELhDqi^ViWc18QNJd}Z z%!JBwU(d{h%41o#43fu^kqyaXS$mPS);OIK$J4v$blEgZ*%Ryd8YPfCveh$LtIb35 zI9DBov?g>HCz`v$gfAq2zn;>_)pxc`D&m7io39MfySif|$er{mR zm$H7!V*Rkdnj2$%-^u!}j`eLZYc>ONpZmbqbF8mMSTpUcFKZxo3!+1e^?4fWvl+;J zjRrm)Vtvxeny!M}y%i#STaw=L@xvJkF6KNFFB|+UzCiz;m;ZZ0q4+I=T%!Yre#j$MTH% z5>G~d+I)#8qoe1|9zO@-=1V*oeZYK)C!@#Amw2*W`przJyT1=CH#4E~`k^h(9_I(1 zG&7+x`V(d*R7QW?%!JB#y=Ep<#(OLWlJOo*faKZsn9n~M?_u-#C(rgF^Z6&^J!n4v zWb1aD&p#Ot&2{#3bKpMn`6uIbnVC>|e)pQ0P#NzYGZQM~-EC$<&VIfR+-YV) zWxT(&Loz}~4J6O#4l@%f|0 zyqnCI=rZ2QSiC>aKL=XPOsI@^y_pG>EqI-o36=4#H8Y{|j9Sc0sEmh3KYK|!a8(Z^ z<29S}PkDY`_KLNfX}!;p+#*9OURKfBtA;yhfprEZuw-iMbWC8m*Q5idwb*OYftpIfsd@IAbYNM~UXu<~PZ3V;wb!Hr zCpFPE>51ipsvMfpI{`Pj$^$(PH@ONbag(ciB|UH~ZgLeIgPUCCDI85VxegpfH@OZR ziJM&I$=RsW{gw@s<0e=4>U-dD+~g`aEQ3%sM>uo@2T*tO9@0)YxQ0+#NH{2saKH?q zWQeeTD`9CBVZVGrag?ynG-2;Pz{&r^lPhWlR^I=gzVi3~|3LrWV=4Ef+?>*!a$(Bp zDaWOhrR>niX%Hmaw6+R zQX`4s`S2Iv$?(hJq41;V0lYEX6h1dx9X>K#65b6>fE$L>!v4@g=NruoZd$Rt588idl z<8Sj{;ji~E^H-n=pwPdqKMVPPLF53;`aVKC;Pbvd-~GOJ-__^>I34|fhad%D2Vb^t zZC}*;FYg@s0VljKdSl4`>p%uTBYp=^?A;mdfa`fvy@{TA&lgAlcp1L}c+}JB zxzW>vZoq2Kk)9IIZk}BH{yz=B0a!@<8g~J_hGxJg61x&_No-EMAh9O#82kcY&&0gM zO%l^N`|r{ZF>QhEPF9^x#=S)E|dR;Fhqo3VGXMTfcmD5?_;F&p)Joj2N^(l|#^a)5F z%W2(^JeHaU)~ROdMIPC*EJz+%^#UZ1?Bp>>9@$A|>P2*79nPd>D^(TKdG+848FbEV zn!4>7_+aG-B#-*oc1RxeF*U5C3n6(96=|#^XCQeFM+~vbTOoNyhgY!<%V+(?T(!uv zMS>LR5OdWc&-P$*)gmf2S1qE0%vFo%KrdYvp?jRQe-CSEBWu4hR&h3KpGDT*n9Q9&(h7xS%wJlmbkw+5p8SxB}g+SExqw6V6YhU7KM zD`0Jx%GzcHB(HRCjJ0(OB=3-|Dp_0RLGsSZ2}1I&+kA?JW_NmjW;a2yzi(R3+9U^( z_x#3Y|C8swk=g&`xo>FpKhXwe|C48%srJA1&Hg9PVO_KT$)nCN`=31Ob_^ zjE=^8I(|m0_}CiB=i_cT3d!g}GxaH3B4DOIWpuxp`jpXq6_7l4ubKLk(LG*#lE|yF zWRgEu5_;$}C;o3U_31v__`l55r~Cinf0(IH_w|eaZa)9q#~J^p`TTPqXZ$zw`R6{) z_yT|a#eX%Qf9~Uq|6)G>+*c(&Z>BzFyr0a}r;PWbnfjDR_Jf)Fl=0@w)TfO1y_x!y z5x$*;+$|BG9foAQZ_KymGQ!v9TXT6vUzu;sWxN^lt+|Z%h56Rp-SY9z&9~+<-U{=r zxr~S4O8S=mQ#17`<9%YLK4rX*&D5ui_mP?UlxOsznfjFRrp(l*jQ2qfB;&oG0Lk;4 zG*h24-n(Y%Q^tG8Onu6DZ=0!4d43aS>QhF4(@cHJ=x>;*PZ@o@6O!>>H&dVT`n^^R z$>^_|sZSaG6*KiIqrYsXK4tWOn5j=0ebh{S%IGhdsZSaG#WYBs`^XII??aG#4H|zQ zv!CwOT>LrAe!3TM@nOtnlp|g!}S2f|z0>a-? z33sd@+#Vy`)bP^vYeiK)7OzUhT^}2~BnM4!NwDaA^j;ks9ad{dUO+;o^3BTV7N{@7{(&!i8z{ zCO?0M_J#T(+Dp#k{pVclKeCsflW+DjcV4$HO8em8J?7iFZC2+0t@hu`|2sdq2Hk&$ zB=4P^pS(r#ddaIKdy{^}-2aDY0Qh^-@}vioI+Ct;+W)GPj!HT(X%Ec)Z;B3pq@;xC zPth-;??uO=&qkk!-iQ9bYcT(RCQ<+ok1mbw8qJMvgcg8MWHItRuKwS~9RP#K0lYiX z7P&HVLFBYZW#rJvK9QYp{l7kX0DR$v@HgR)!f%8}!u{a~!*_(Q4PO#IGkik$FX4T| zJD~$`-Ec~HN$980=b?8){|F6+9tqt=JplWMb`5PE+90%A$QS%I_*L+O;H$xBgO3OA z4c;8Q5-otI29FLN6x<`YU2x;zn!!-u_rSM-j{|R@1>h<40Nxh3CU8;UjKFb$Lj!vU zb_{G5SSOI=|F{1K|7U0de91rHf7t&w|MmXA`p@>Chz5ZDkOa7;e|`Td=mPxN_oeSW z-z&bSeUJI>_T7XW!1H{k_>S@&;M*N-fE)Q%_XWJadB5>~h!nu*kOXj__g3#!-V42_ zp%3t2?_S>Ry_+HfFoGO_?>(P*-txTQ>GwS7xx;g<=MvAENC5l`QUG@HY=PeaqzM9}u;0(9jE29#$mKv#a4K--xM(3KxF5XBHd6hj123=u>zM9`Mz0(9jE z4MZ_S&=%$bbma#WL@`7V#SlRhLj+L_5kxUW5XBHd6hj123=u>zL=eRgK@>v-t($=p zb$Of=Lxg1%Ljo>5J40}1W^nTL@`7V#SlR$<^pu(;RT`? zB8Xy$Ac`S^D25247$S&bh#-m~f+&Uvq8K8GVu&D$A%ZA|Xz4PJA%ZA|2%;Dwh+>E! ziXnn1h6thYWDcSj zBIs*#0lMGKFUE!iXnnNPQaQg zKaL=ZA%ZA|2%;Dwh+>E!iXnpDH5Z`rsNXRcpzhJegm#lA%mt{sk8unU{>q!?0@OW* zJVh}?SoXTP0F_sTVu-MeVu&D$A%ZA|2%;Dwh+>E!iXnn1h6th}fBS$*VGGE&l;EC|1K83Tz?RMcwsZ!tr89snodIm=3}8!V z09!f(*wPummd*gSbOx}cGk`6f0c`0EU`uBJTRH>S(iy;(&H%P_2C$_wfGwQ?Z0QVO zOJ@LEIs@3!8Nim#0Jd}nu%$DAEu8^u=?q{?X8>C|1K83Tz?RMcwsZ!tr89snodIm= z3}8!V09!f(*wPummd*gSbOx}cGk`6f0c`0EP&npZ;{vvH2C$_wfGwQ?Z0QVOOJ@LE zIs@3!8Nim#0Jd}nu%$DAEu8^u=?q{?X8>C|1K83Tz?RMcwsdB{WrKgub5D7I=Yw>; zIygMV*Q?L=%0U5ZKGOuPJX}FfmqRNLWzbL#wDPbD4JJS<4|>qR1mu3xH5luLRvxaf ztiJ(Tc_4+B+k+nTl=;?e{jp+5#(OjalJR=x z=-a`;hex25hg^*JP&*`}KUf3Fc-@7NjQ2nqB;(yT1Ic(@Ly-GHvAn>{Htx9c`u~4( z{=ez}`(Nk%&%nL^e?bGlF43)`8${Ehfyh52-=YEVEzJB6;O_ss&;f9Hq&{+LWjS`+op_0kBJTG`k@TlMc!QF$~1UCw<9t;G23w(nXz}ExM1)dDt7q~TWRp7$FX@O${2M6{F zY#-P(uvQ@A|HJ>i{}caP{uliH{s;Yc_^KnmcYp1qL+u$gBaPg3H) z6MsnjEb;CCO8)=SW&IRF1W^bPL?J{Fg%Cj$LIhC=5kw(G5QPvy6hZ`12oXdfL=c4# zK@>s+Q3w%4Aw&>`5J40|1W^bPL?J{Fg%Cj$LIhC=5kw(G5QPvy6hZ`12oXdfL=c4# zK@>s+Q3w%4Aw&>`5J40|1W^bPL?J{Fg%Cj$LIhC=5maas+<$3W}+_TsIZ6{gV^g!;}WPffW z25F&ib^lx+Pt@}!I2oaW12odzVx%Jk4*Kr6Dmi<0}cM)+ve6=dG2q`LMtaTvCKBm0Jeb! zunjbTZJ+^c0}WssXaL(l1K0){z&6kTwt)t)4K#plpaEj%v+du=@1{%ON&;Yi92Cxk@fNh`wYy%Bo8)yLAKm*tY8o)Nt z0Jeb!unjbTZJ+^c0}WssXaL(l1KbzI4KB`|zkqF^0c-;eU>j%v+du=@1{%ON&;Yi9 z2Cxk@fNh`wYy%Bo8)yLAKm*tY8o)Nt0Jeb!unjbTZJ+^c0}WssXaL(l1K0){z&6kT zwt)t)4K#plpaEmBT{4xeAp-#(Cx{R1PQ9K!Xv^F;}7P$K-NqpusX~pn=Xb_kPG@sWn%j zGCDQTV9nFaRj7uaFd4AMDgEbE`_a4i5)Ift})Ib9rYVJLjXLPW+_gJ=Psk!%9#yiN|do0h08ffrW z4lwr~%Xrj4gJsk}1MO$-y^`^Y&AnGL9yQQlO=_Tlip;&oa*D8*x%XJcqXrtRxre#; zSY9z|pusX~puwrk%FNl7_y4c^-<$uh$G!jmJM;ffiBv=mitHKLK9U_-Cz2dlg8Tlz z#Bcv!MFZfI;rqk4gUS@?0;!+uiy^B z&2ayJN-z;QfHQ#)06CXqh z;I)aDB%YagLgHT%_f6a>af`%t`Tl?WNF-rp7vR!mG0GrQSGdJS8yGe{Ph_H+@h#<-!f+&Lsq6{L4GKe6` zAc81^2%-!kh%$&E${>OoC+LbaMj1p{Mj1p9We`CP=0;ripb)#j%!9fIsMz^t9@ITN zn&Ms(Wf0-7PzDinj+qB_4^T16Ai^@rAcD?p$C~b8oHK~9>ZSltBbh1`$LVM9>N5MqJqvltF}LltBbh1`$LVL=a^VL6kuR zQ3er28AK3e5J8kd1W^VNL>WX7We`D>K?G3-5kwh85M>ZS2bvpk<<+DNA}pf}B8W1G zAj%+uD1!*13?hg!h#<-!f+&Lsq6{L4GKe6`Ac81^2%-!kh%$&E${>O$g9xGwB8W1G zpdHOTsO&kEL4;+LK?G3-5kwh85M>ZSltBbh1`$LVL{N^o5m)w(Es7y|e^Le!)}#y~ zh%$&E${>O$g9xGwBFJVC0XBmOuo*;v%^(771`%L0hya^G1lSBBz-ABuHiHPT8AO21 zAOcp$UAXS0bIeI3{`2~Y8fdVZZJ+^c0}WssXaL(l1K0){z&6kTwt)t)4K#plpaEj%v+du=@1{%ON&;Yi92Cxk@fNh`wYy%Bo8)yLAKm*tY8o)Nt0H0wd zbmh&|fNh`wYy%Bo8)yLAKm*tY8o)Nt0Jeb!unjbTZJ+^c0}WssXaL(l1K0){z&6kT zwt)t)4K%>()7bgl4^eKQ!LnDIvCKV_=LQ-qqXru2WiQrrKT^4Y2FqUR!7}$?!woc8 zMh!I3i23qO9t$P;gK*Q#D@9uR=KQ+)`88y&A)Ib9b&eLJHpBiYeY@h?zi0*Y@ zf2TaN!HujV~Dkc@YC0wmAq zt_jFJ!R^1(P;gK-Z>WO?iITm=i~Ne$+sNHLo(i;F8ym8fdVL8fc&^qF7V5CpFMu z*=2oLCR>*pXt0bLXdr5!fiCglR9Hs8Xp&B$`x|;7*;g)XgygwX0}cMl`Po<|?_Fx3 z!7^%~fzIi|nzCK$>T&rX+vV&Mx|rYw8vMW7c`S1uXKZE6?8@u^wg1n!{;!T487YbE z7Rill7)guxao7LXnE8JVSN~6hyTZ4Go5L4`Yr@BbOT&AH^Dyt9j%)wL(08Hf&|9Gw zLj$26-1UEB=<-l~=v4gf|6pYQ?HJlTv~DOB^Zvi!zW=G<>%r%PPvMvUcOd=mufe+D z$-yHr^IwQN|2GM)6^sV{jow)wuV+HgH1Vu)u!!_5ap^4bcJ+ z#MS?InEju?-T!g_BmPeRP5Ax)`Tu{<0Myt2e|{S}K$$~JmvQD0G**vyqPxd%<`9-q z<`6VGk2T%>Zh$g}u#7T?Aj%wqD02v+%pr&}hak!vf+%wcqRb)aX`49&4Vj5gcMs*v zAuOZJA&4@Epnfy)>F$M`IfP}DIRsJW5JZ_n5M>TQlsN=Z<`6`gLl9*SL6kWJQRWat znL|)_7Ct51OTq!l9Ktfn9D*ow2%^j(h%$#D${d0ya|oi$A&4@EppF`Rez`}k0m>Z0 zGRho++Gprfmota3j53EH${d2)Y~~O|nL`j|4ndST1X1P?M43YnWe!0tX5!O5rVdc% z5SCHq5JZ_n5M>TQSD14z**hq62+JsQ2%^j(h%$#D${d0ya|oi$A*jJje7c`C1C%+0 zWt2GtQRWatnL`j|4ndST1X1P?M43a7%^U)3<`7^rhX9*71lY_Wz-A5sHggEDnL~ih z90F|S5MVQh0Gl}k*vuioW)1;1a|p1RLx9a30&M0GU^9mRn>hs7%pt&L4gofE2(Xz$ zfXy5NY~~PPGlu}1IRx0uA;4x10XB08u$e=E%^U)3<`7^rhX9*71lY_Wz-A5sPUg^` zSM@71hg>)T#4c><4E~QTodIm=3}8!V09!f(*wPummd*gSbOx}cGk`6f0c`0EU`uBJ zTRH>S(iy;(&H%P_2C$_wfGwQ?Z0QVOOJ@LEIs@3!8Nim#0Jd}nu%$DAEu8^u=?t)8 z3-)+9)^1PfMk$7Al>=nyZ=FPx)+K)!c(CqoArrkuTAoV`-;WBGRYI}E5@ZW_$yR81AX3svoQA+i&5ze zmVIWDmt?$8P4beAH*J!aWIQUJ!T-04?Kx%69o_d!>;rS|D5Jk;l9$|f zYi!c|@Kwfp*ZlC+eZR!sF+Y5j(cd=7OY&?df;dx_(W!I>kL-F)^rYOG(wcH8H zp5(UVE0X_r&;S4Tum9f+jf9qmxenrMUJ#DOes{8r(IQi@W~Uz_0!n1K$Te4ZMxqzrnzx=>KcOy#EEb@4phi z``;(9Qy?d>K9c`@{slAueuSC-5#0Iz5PtdJ>c7l?uKyHd{~d(<|Ly(RnEOx0z5nz0 z?f?6}SAE02Cz1Yl8yWyE@tuX)|8m^@zZ-u2pXFQA7xw<~|6%^$(q)`E1X1P?M43Yn zWe!1TQlsN>=+sq+|GKV0_9D*ow2%^j(==*eh&bdd;__wq4`N)|= zSo0g3IRsJW5JZ_n5M>TQU+_1^oH>MLlsN=Z<`6`gLl9*SL6kWJQRWatnL`j|4ndST z1X1P?M43a-yXMTxJ)*`ba|p{Qa|oi$A?Qu}>nDgZhoEtD=H-5R#VKhs7%pt&L z4gofE2(Xz$fXy5NY~~PPGlu}1IRx0uA;4x10XB08u$e=E%^U)3<`7^rhX9*71lY_W zz-A5sHggEDnL~ih90Kl{#o5Q7mv(WRIfNxPa|p1RLx9a30&M0GU^9mRn>hs7%pt&L z4gofE2(Xz$fXy5NY~~PPGlu}1IRx0uA;4x10XB08u$e=E%^U)3<`7^rhX9*71lY_W zz-A5sHggEDnL~ih90F|S5MVQh0Gl}k*vuioW)1;1a|p1RLx9a30&M0GU^9n+bMkQ= zC`W+0DCC|!$ImkJopK~Tvk!953%O?oe}#Hxpwn})rhEPxr=A%sJ9QGv+_OFInZdGC z8u@5MJu_HFJu}dWCg)ooOO^StfqNbrKf%m*%IL?N9~;O~^SBa7Mz73-+%x(3G4r@a zlr3@eC|zlC&kROC%FK7VR}OLNnZYvZnSl;B^PMu@VP?Kl#-pAYtVumH(4i) zmwINfCiTof+s|Q5dB0H443=$Y<~wD)ZEGOeg48pEHFHg3rHr?gO{~PSElpyjynZ=m zzEhqp^~_*R>Y0J4X9n7InyxhCoAlv2P@e6^&5(?qRRPJXxlt}8qf^fe{wDRzK zQO^uSJu?vX%s|vLgR490nSrQh2BMxBhY0J4X9l948HjpjAnKWcsAmSEo*9UG zW+3XBfv9H&qMjLudS)Q%nSrQh2BMxBhY0J4X9l948HjpjAnKWcsAmSEo*9UG zW+3XBfv9H&qMjLudS)Q%nSmyA>D|gbGgwAFGth+jv8(%@A9&0B*wx)T2Hq@#+&yP- zWwtD?{}X;*@_Rzck0~os-bwk-Hvs?lZvYB{TLm+Ns|LM+UvS_5`++gc{r3j$!S(+Y zf%?F*Kt;8!Eci%U@ z50Lmbh&%r8@Ll7((03~G{`SZH{#*Fg@kPA9Bkk`4-0eT;eb{@4_ZnpVor-(?_xJAN z-NL&LlKy_jo&FzqUhxci9!AdJHMr0JRL@bK{XM%NF_<^Ox)H;Er4zJiRuhjEYp zHHjA{o|`QC3|61rc&mFH37j0NyeDRF-MMK&Rt=bn>X zU4{19I2q;j&%*6@>{^_Vvd`$zKD|-+NX?bS9fWjT(5mniS~(^+9%9A zme>FIQSIY8v@2`1k1f(ZCSAK?R{N-7?IYW?kEqrzFVH?bRr@b1w98`Jhqh=RQmK7# zo_1+a`@kvf1A4VfnzZ*X*It^Vys@zi?s_fw0E9!ysi89b{f&nZ`anlt2Vg z0udC$i!IkzoIr$S%gyYkd)~_lL|FDDe+S12L|FE?`3}y#8X4?0v!Cvn?jR))VNFUP zf+&Fqq68v{5{RG&&FrUpmN7^PL|8@%L=YtqK`Mc0@Lu!VPxt!c&phHk?>gh&8T`La z^V?4uk9udYjCyAv>Yah^sG#dr?w!Fh>Yah8cLut}{Pt7Equv=Tquv?lCiB}*_xgD7 z1~dC9&xm?wu;%q<_EXNjt~0Zr?wQ#j_0C|;7TY@mU2T5*>0Uz(Qtu3wQSS^yy)zK? z&Op>V16^i&XCUgGfv9%|YP7vG5cSSL)H?%F?+kRI?VW+BcLqA&%zn!Iv);^p%KMXg zXRs#q&Op>V15xh`bXGa8A7x)T)69O#wyrg^pR%o~cLx8DdS{@T2CONMjCyCVjCyCF z>IJMRuRHb5U>Wt!Kqs2nPkB#J?+lhv?+kRDnf;VUT{%MM?t{mg*-v>#9#g|Qx)72_ zU14TFMMs+1PuaRh46({vA$eyVUd1{rpY@k0B+vHHX%_X);5kt53{+ZyHQnpuLF%2s zGU}aysCNdU-WiB`XCUgGfv9%|qTU&ZdS@W&oq?!#2BO{>hYah8cLv(ke2XB@ zk9udYjCyAv>Yah|XL$=!?+lhv?+iq}Gf-Xu{-3;J)H{P^)H?&^#;~Tm?pwD&@}AhL zlC@SAsCNd-Htgp8XalqV z$@9xBh2%LT-WiB`XCUgGfv9%|isa*aLfPZOws!{0sCNdU-WkYmduJe@ z?VW+BcLrM7J7cc@KU*@MkTRVzo-&-$o6?Ed|HhQsl**LSl){u;%>AdPBqYxzPbZHj z4`b%PGr2XnF}XIm5}kjA$+^jy$*IW+Npnfl$owBp>P_lQYDL>$ZBk`YX;NWQZc=7a zD$@SvqSMjw=rDTzI-{-8#%L{C{z{{T$obEVrbZLc@i!eAj|@k8Bb|}fNMocH3IC;$ zLfq+}iHyI5@LYI0JdXSPd(rOK8g2~NhAVNGe<8a4GQ+9i1pK~#Iy8=Ezur)1s5R7x z8U9N2`W1$9@yq^H%k)o(mF9PABtVs^g~oqm=0O@ATg_A`U2X!M&4Ob5mT!?>>R zM4w+H?&z-!l;WyB7j1s2fdv1Yf7(CpA4Zp7C+71T{k8r|e<_;$a{Zb9RDXhR&Nq!7 zzhPgmuhZA+YxLEk#jn&?=*#tG`ci!fn8Tm;j(dl_z1~i5D;oT2y_MclZ=pBWn~DCu z1kapj+B5DM_Vl8?uhrA&sr6KPN?#uL~dJ+=n5~mZ#(cIUY*qPXx*qB(GSc%@g z!o=Lf%*52hge7xJrk9MT%q1lJdD4p@mu+&l#mt238%wlr$ke`mUi-RH$2+?J|Jn}i zmRjv=inOmz*KVHGzH->{4({K-qD}kqYVD>1?aNXfyBDoUebH`=Xn+zEC1-KG`-HWK@CI^OXl3-x>qGF7VE3ZO zq{1lImTDszCKrup?nRHuhEc8=b1d%%E%j=Dd80G2=$MSk1C&nxnBPqsM zUJqo&C?hS#Shg?nVw8~>V=UVjnK8;pjWL$(dvvRIMU`XOe#nk7>x+>dV=UXVd|Dd` zGUhv0*?!27QAUc4v1~8o$S5D`)joLAv20JI$(Z%U$dfUa?T17eWn{`2%l1U7j52a% zl#wiBEZY~^GRjDoF_!I%d>LgV%qSyc##pxZo*mkI)M_JX#;h;f8(A~TyUuDC3~TSw z=2%`I)Qy<`FGk^ra(=2dN=MZ4?PD;`I^5$+UW@j2mD=0pX`_Bb{r|0}w72Tj-m=ND zygzcvwYSL8-aJ7YWh7=i`Tv`CYom}vEk`AZGD=C5QA?tXViILklPIrWr@daW_PQC` z>&$7dJ)*rgESJ{{btTHH4QZpYL@i&XN*lE$YI#ajJ84=w+NT|9 z)(%%_hjO)pUTyy*Oz&S`kG8i_+f$~Un5~_#sEkSz?SFM?|52~KSfc%VruJ|1+6$xF zzjkQ4Rs=KqU7>(&0WNgFjNW_|Z~Z;DWqKTgm_8H!qtIuv8~^T8CND8FB-jZze| z-2J>TwJ6H(jA_5!>Dc|ecnakxW_|Hn#g5(23saC{mW#*dv|k@_?0#N6^;)|&ic-w~ zcRwFYRf;moQjFc>y{Su4Mqx^>xp(2uJ+oS809I( zvb|BCqWo-yW7)o_P%+EJPkSB9_8XjZEZZyIqdm~*ShiQJOdEA7=I_h)dTLR-Z``qL zFO;g7^~I=FF)gFAy&f;oMzxArF53&`D#o%s9vRi{>2NID=iypyl&qNlFWUn(E6NYd zI+pEo|FHIbZH{GobyaKMTcCYUs$bv2%18%MMm~r#5<-mK{oQ1QD7Ut2BPT>HM^cC~vO<)vnbE#_NE?YEYJFseC?hpQ z8Mz_KNDfg(c8Kz&&DzKhQOl7aqKpg?<%=e@kt1T3%jDwHfolOQ7@s4f(hkQ)3s4DVV1kcKT|WI zd~%!iN!8jYn=tFU$754Bp^U-_<>O+G-Qzt& zafLFfE0j@Qp}c*rHVQ1%@@*%zx9QQ&ZPebnOdC}e>hGblhxeTMQ^UhOX@9lNieDbXH_B%z| zZ>MX&HLLyRu=X2m+T+#QuNP>)ma09rLi?4N_RB5W|ESa+&2ucT&r3n=7pAmFdbR)F zr2Txk_H#Mf!wK5YOgNVJ*VEnFLk-%4rP}c}l?R%%S@9ERNyIH%l zLi?^NpYRy%gjM^H4N-I}U>-3r*A@0hv) zWfTr5qjEqQr31=W1s%)lhvEUVT#V`gWt0ynH7Wv$w$RB2b|Yo8p|MrDEdd+zzMDJ@Vwp;;Tn1!_5}3zSh_pp5zgbN(goUlbT9 zqryP>s50#%v$auVVAhxY2~`HlhjnSA&Oj|kp@A|g4U`X_cP#sJ>8Lh}4b1wo{J>gm zlpCn!s5ekvI;)L}1G8L?H^pt*`&Mi3Q=pBi1NHy+TA_`)1GRjQ7VW}H?cMXVQG1~N zKZ*~Ocj?vMxk-Dca_#&a?Hv=ex1Z3?>()jQf*OC@Qf-tWsO6|bP(~qwvAlk!5`mpl zr@ck7wkby7^|R-+O*sOV%lp&RBd|BF(KZzcynLfH$Fjd{Fr%G0q`iKt_Igz??a%Ax zYiC5Y*PeDP$D_6Sw9}il*Q{_X$Ky3}wO99QuQuseKCf5p(O#udJGIQQe4eLdYbPyg zN5>t@=Xs<{J6!Kr_ODQhb}&=hKkr!HAHGp-Z-=(0*0H?aiACB=(jB|6C+Zip|23@r zM;lDvCoNWM|6ZW|&s4|m>xV)H?O$WszqB}ZUq3W6XwTxsGs?b!tFuP3y>>eilVaO}R`Xm8N|B1?P4g7#-)+Mjl6e^RGCU9A0ahGX~r zgjNUbsS)iD+O^-W(SEN`dooS?of+-7hqNbJ9n1E3t4jOLeC;=)+NSvd?>G7Xuk~ra z+N?cRp=}x<`2WA`bu6#<=%lvkfx!B*{KZCX(*?oHN3tEu`|tThZ8Sod^<_CaA(YVy zVJz==^g<}38A5q*UOPVOSoW`h4((X2Haa5A-;?#x5@GC~Uzwf=W`{Bo{ zwb2)$mOqxN{m2T(?(xX9Mws=*54UJPRH^-7o@4j;XWApo{}-b_LKzJb#DP#oZrF2EFnVc>nNno z`Xq-B(?(9cT8^Z8 z<&GL{q}8kCx2I{h&uHH|q+bZu0DvugKPJTGU3Wy;>i+_Qvk#iAlCsMz+23C7Ie6%{z8K z-<*Vd{5|o79eVi%wT|7-Unl1t>x)g&J^S2Q$L{ggNxR2#ab26U+&x}9iT7A8KC3`4 zKQq;_`}u(6d+pO>j@|QD@(}y^5N(_o{F#+i%ZY?LEAXW&7+ejRz;5G_1Q9Ady84^&4;x& zYtzoI*50(hvAn*Uq-tla(B3GfZSwoEz2*O#1b=pBp7#1dZIk2A>u2<8uhXP$()@XS zljqMiiT><0CbW_2ua>7ZXs=ePy=sVh`%{ng(~?$k!czgmuzf8|Jqc4$r;S^sK% zr2QMq`@vtMjl_So+?%GIIHQf+f3tFp=d=jA5-U-^%yHW~oTa`*jiIslY^Yt~+< z&_)x0TK^ZX_WYza`T*4WXarFHp-g)&Tl>33?Qh4mXS=k~4xs)%`T>;D5TN|!yf#__ z)bh_ew9yoxmVZ{HjkW-@+12< z{BDc(JC)jR=Q(yiA7T?hZS)A3zbDJz=+z!?a_oM7#9lAgel17))da`x=Sl3932n3r znDJyedIgN#&ll4yp!`x6u1D?fGflgIGWrFS(J)}_exAghuhV|6*s=ThVR{D4`r>Ei zw9z$SmdpNxwgF}I4H(P&0gVI718I)s^+M}_SuS2aHcRpDc6i?k`9>*X~_( z?Cw9vJJ)`+%dxxvAoX0kr^K${ox0Y|YWWK0*813E0k`AxT~Png++ReXlOnzA8)m$_2-=y{{P4 zzPwYrsZRT{V#o4&T$-WXIH!Hdi1x+pj^*{bs7AY?Q2W9(?ek}}>xZ<@YjrHI_qkQt z=j3bGMYYeG);_aOyS7=|u<=T7aXcr}D?>V8pN4Iui zgEkW0&GwS*vs;#S!GiWKW7<1+YVTC1jkI_5dhD2?z5Sea-iY>g?b_ScXe0Su{r%iD z$8vn$YDRm@A?=)2?JcSt%kg{jd~M{woAG4%rqkM+^f{K}Inv z+P)QzW&3$!+MX82vi%Y(wU^{MmhF`gR7Re>V|RNYQQon;eUK@y{d>7%cY7dLUi-HM z$L{t)w!HQ)-HzSugM4}I`BKO3_CdzH_74j%vN-?TUdWl({=U<(yS&t?w&y3U+S66qALl!k?fX$w zdum$ygFeTyz29%vMjpM{p0a#0SNmPBV|hK0N^jN|PxNTN)u{bunf4pmFwG~vzNr1$ zxb~}E+GF+Fuas!NoT)uJ?^xasFO6!y*rEMGt@cQf_TSUBpPSVl9@c)gP5YT@?WYU0 zhf=lUE3^k<+UPEKuJ_&J$8%LHuMf@j|B5A(p8w?k{U4eCw_V~UiEAZBm;8Ilk4rvZ zGHDV3SN?Olw@Q{hwS5{c`dw@{L)dnRuuUr=w~DZJK4GgUA!i!+^ZkSnZM>gsKFi?q zLG9rw$L{gN$XOB=QKE7LB{b}ZX}pGEDx#~sV|E$Y(Vt6qE0634QA_sG=VeIB;=Lm($y8%g16 zc|omX+5SiiH_OF4r)%fWI+pE^%y6^5c!xIa?W-Nj_RlNO-Y!*pn-$u*F~{eB2FLRL$t=}g zKTCVv1?`M6?R7e}*RIoEt5`cdLwk)m?bS!L)7rIHtI=MyP#dY^>iMV4XeSS8BYRw} zAFa}k{d=aFTKSJ`?cWz2 zyZeLbr#9=0f9uj-sMr3rMEjRa?fH4_pGLKR?9l$9R(r0Hqy2t@ z_T+^2yWQIFG-$tF>R8@C6It4CE@;0oraj*2*gZZuP1kh1a=NbBuV(1=Uzv049*>;H zYpgH+N4s7=TI1OLJac-lvA+1lG`)Oe#1WJQ?5z5IOWWgs+7Z1mZt2A7J!XX)<_8@FD8GV{Au#r$)m}G z$&V)Ao!pjuW%32dr=bhr(ByrRcS_DlUO#!&WM9%k(l<#TCB2a}lGLB{P}1Mf2yj`_ zxk;xaRU{phv}e-xN!dy3Bqb*;iOxsAjJ_Xz6}Z_7Hx@M5!u zkHc?gHNM1;NIXZ!K;D|!83x#2mcZ* z4(@_Zzzu?F!9d`jfp5_r@K)f(z(AlUa97~Qz~yKKJT-7k;NU<}V8_7bfpyU!;PL+UP2MZK=X-0s$9fO( z?(NM-SKxZyRlHu$ub!_xAEH^{@1EtJ2R$90>phox&hb>EG4Mdo9-chUrk=GuN$3~& zDe;TM_Y%hvpG|xs@xH`%^afs>cxGZ%;^B!)6L(F_P5J46`Cei9ISIJA< ztDA6U1EIE*a7Gs4v;{)V7~#}T!m>KTDaC~948losgcC;yRqccmY6!<;e~{;M9KJLW z9E&ea1jpb@6T#8=(nL^!FHHnT;Y$<2k@(U?P(DpKypM2LGvO~4gtA=1pP&KzOw5yD#Sg!CH1nuUZl(gB6oF)7)Oqgqw_%@aB z%?iTTF~V0ZgqcdhmwALQf`k=QgwJ{jpEeObDJM+l5I#;Id^kav>Lz^9KzP5D@Lm?- z-37usV}!Rm2@`dMw~7gGW)Q~b2(OP2UTY`3T0`T-`~ys*ccH zOt>&;Vdtqc9L*L58?Di!f9oMnryA*W96w4pu7gln zOE|WOa7;R(VwP~!FyY8H!V%Sk@&dx)sf53*Ae6-jhqe$7sU#enM<@*v4xAz!&`T(3 zBJ5vISeirFFM+V{1Yw_U!rl#pqEf6=+EZ?cDu$(9Vr21zN3rjBqRM-2J!E&fR}A?cDusv~&00NIN%L6X>tC($3v~ zeGM(Su8?qT8sVB5!qr2Bt6B-oRfH?^30Fi3P1A(S`Usac6aHF3Xv`&C;w4-(NoeRH zT-Zpspp0;SHsQQQ!nxywbGit1^@Oua2xnyyYUc@Oj1o@oAe>f9s3{_xnoc-nmQX!R zIJu2*QZ?bk0zy?P;rJDV<6?x$7Q(TWgk$muM+XTngPslDIY??{fc%G0oO4z7_uwgA>gCas^I$^z8!n(tRj5fkL)r7SR2y3Mh z)?7hYBSu)gg^*TBSS^pRYLJjRMM&u-BsUR~$_ddNLL`9@nji$b34sQJzm(w1B6t=E ziDQH%orHus3&i&m{*^&koFn``LilGp;kO#XLLuSTG{Vm_g!v)DPpyO>s|Y{j6Xv3X z@1_ag_7P^A3Exx@zRo3lIol~5T-H- z@6Qw78zoG35Z>EaA;z!W(Ub@oK{B1%%g931ce=ufzy1w-EkONf^x| zyc8t7Fhv;YCH%dK@O(Mpxg5f90^ykn!qeS^p$5WWDIuOkh%FHM#|X;M1E|Nl4U|6}u$_N^HEiFWSTkF;~gexRK@Hb*;m>^s`IW8c!w9h;?{ zJN8XIjq^3_+_A4{=Z<|zJ9q30+PPz&)6N}RK|6QsGupXhpPHTF&-)n=_ColWcJA0m zv~$Nkq@6o9MLT!w1KPP`@6pbUTo?M&@6yg4dxv)J*xT5-<>5?V=N7z)om=n*c5cBq zc5cDz<%HLA2(Km(UYQ`g+)emL17Wn3@KP4xg$2UM7~$`ogy-uB&lMAfGYHSj5uP3) z47C#mYY6c|!ay3Ke}=Gpi11V^p|6VYWIo}ED4}N2Sg!{_~_hl3AT_oHyPPn^^&{7BW zW(cPa5tg+QPN^bP=Mzqj5>A{ZRP_-~XeJzAK{zg#Q0XNcGf6nQhfvW-II4_rWHzCE zk#P7p;jk{kU+M{EC4@sW2?x&;N=FF?br24$B^*#hC`l(Qoh9ryOek(6>|0IPr+~0` zDq*h`ggs+~Jz5Bbm4w~%2)hLd1yh7wdI>u>5q2sk=EelH;WGnKHgg79mM@JkEf=SsqS z9^t1T;fE>0Trc7KCc<~+gl}^Qvk8Q+CkS746J{C+UzQTS$Rey*Abd7P__UMoNgZLj znDB81;lnw?)Cl2&cEbBLg!c*wlWBx^W(aQ&5hhv*Z&eZA%qP4NCA>aOc&(4{YBOQ1 zg78W%;bkvjbdvB=58=f|!V6`Dk!-^Ai-hOK3Bz54XX^>iln|cIBn-|I;-iFt4nnNf zBKC`UKL5G?|IhXRf8+W;o;PCON%8Gy=Z{hx#Jtr&K=*->#WJ2zsB zXti}|=Zy7<;j|V)O(o&fJi@Xdp?ZpNaxdYeCc=s3gsL3E2?>PbCJ2??gku{B$CMI|&LSMO zKsa)Ya6~7eypC{qG2yTbLfIVQ&=JBR?SzAC2&ILDgVG2G%n(Y32>Z7ZmR1q=%O?~^ z3HwYF_Ui}fIkc1iwAhd0UPuHBI?2*b3p)qX8`_+1^hV*_@f>0dktW^Fbtsu zW3d-nqNst=#~x)+oH(lY_`;{fx<0BJ$M9<_koivYW& z15yKkT}A*qw*gYB0Xr1{c1#8A5CPbJ2(VoXVB1PSQXXKNWWZL_fW!g7mQ8>y$^o0_ z01^@bo0=aJ_k6HPA0WN~uyHA1qb$IN@qoGGfH^&Y*>!-}V!*5nKujzkFbarj2SnBY z{DlBt8o=uVY%mOnXbpq(qyqe%5BMtu@aGKRk3qoi&4B3&z;C&LUy}e+Q-GiQ0Y5bY zek=n_W&?gm0DLzAnCJz3TMziA1n_kxU_1`+^z_B7zF0l;7r;LUQt8##d269KPH0$%L{ zywU*ZF9p1u1^8Dypl=-TVh`YjI>7VAfZhzibFqMDMgcwTfbJSVs1VSV1_=599m9Zi zt$_9_z|;AFr&0iIGk_-s0gpEW9;*Pf<^mo~0<4_^wDbcWZUj7326!+V(3}9ce*$n{ zFQBO&aBm6Vo=m{qae%wV0CxrfchmwJiU7B#18xfd>PG;#v;l6e2HaEts7nRh7y-C` z2vFMsxULd#Z64s7WWbtfz|{kQtC|4S<$x=509PaeswM%K^#Lwz09;ZEsLTRf91mDM z4yfn>Tv!LVpct?!15h3dIByhiZabi?25?Rx;OsQON*~~?VZe%3z?oHml6=4!DS+iO zfZ{>GY0ZFBD*(%K0YyoGrBi^D`vHZGfRoAqOR@ncCIC*D036>7IIbR$UjkU12{<+m zkT(W6ItW-)3plC>ked!TG5|Py1d!7PIIJ3QXaV4mR6uqF;GiMEfh~ZnO2EQAzyZmC z%xS=W1Au*-0Q-~!GIGMuy=p`*k4^p`@N5||_ve2)0REqJ0Q@5GZs6rWSK!gW-GLhe zmj})dEDxLzI9&Sw?H1T3uwlR(^-I)OQ6EIT7WGWj6H)g?-6GxpE{r-e>ZGWnq83KY zi`qVFlc=c3-y^?`{8)PbJs z26yi5(#Accjq1#hPxrMAi%D@Aq`9#|`?%)pQBrKXjLJ-NUz=4!iYX*To1vO&27E&E z-2WFjO!Bvqd{rcGKFO0pikJbd`>#RL-_4}IDo8WAq(75LzfX~-`$@kw3b`v<_iGvH zmu%8h0_mp-e1hwK>?KXslYS@>a=)W>-)EA(iz9tICggt8>%Iw+zOEJ8YyD@vZoG)} zRl3ju_q8trLg_AzjgY=*6PoY7_Ib6?o-Tb>KpIUIa=+VkpGFAjr}XiVjBn0vbhRJm3F(SHNEXsHygx0ZTm9aEkZ$$6O{Af6A>HbCa)flN-%b?L?HrsG((Qb+ zZ=SSdch~nugOG07>!m`vWdm74x-GB83+cAJGA^Xi`+I~m`pb1f8vS3zLK^+03>l!K z(feYBH2MppLi$NO-!7!lduxO=`g4Us8vR)_L`I`O;}g<&-NRbp&Xv(XW;E z%Hep|lnd!%nCkTrmBdtnpcfO701`{w{M6R{)hJBj^mpFTiF19r>3<@nKW zZZA206c)(wqx->pIev6^*;9@m-Nokd_(_xFM|aCThupd$~kE*bFjG~lTLz>`gY zwsOD|Ie^C#0j-mONBaPeGyv9?0$Q>F561%@90xS_03N6V++Pg1F9XmN3%F+#aCbYP zu?BEgA>ht5z#Tro?ZbfES^>9K0qXMsx1<1WngP@e0&Z*u+)x3yJ{M4%1h{qza7{m; zrV+5F3~+Te;Hm_`l@ov~dI6W$1FA{@mt_JjjRRDU0WJ;#E~*8rE&^1f3z+=h=h-k~ z?%3S-=JwAG&261~&)mAXSIjM+d-~i0>HeQNclWtTb2pmnoAc|O@i`yP8JP3zoVGdl zOYi@y=TyvDF{g0OqB&V}_MDSEXVW=>*?-KQkk0>uvtO9qKKr5B4YRMET{-*g*+sLD zot-^)O7wefd zHS5b+!?RwMp8t=}YMOQPtm;`8%qp3+WLEC117@Yo+HTe+vjQ=H#(W?1S%QlFPx>D4-RfH-o&L}Ao$Nc>cc5>+ zZwKFIzG&~C-tWAhdf$>h|LeRDd++dG=e@*xj(3@NvG)+~-rk+PTY6`E{>$^D=L^rf zo|ipco<}`*dv5ex?m6GHTzdQ;?%B_?n`ax(hI4;%v;NLQ8YJ6vubn!xOObna9-Kuw zD4vvM?iRS$l${IB-2$Zp%-sT|{du>bGn01Xcj|-Rhl^NIOgk=~gHA3+YyG-zcQpxm}r%Zs)eyLb{zv z36cxymTf&Dq+7OCuaIs_V!e=V%a$cV8hwjQA&tIyoRCJ}Y)nX_ZyFTR=$q6^W~kBQ zi-h!(*f?ECqi+-t(&%v`LK=N;n~+AIQ!S+NW)}!)yx3I93^m@Y2q9f<^pKFo3$zGn zyr@bcjTf0Gr0er13u!#>w2;R03`l0E@iu4@()C4@3%T*6la`PhujB7TA$LU`Gm}DY zypBKngxq)?e>6yD=tk)Hy;R6uQO9(akQ=Y#w|F6q@XNT6yP}S%9wCkQbDfaJ`>9yS zT~Wu68Il=lyvbN0jraYikgn*vb|H;7Q6r@BzAY5ec;A>2;<}=*eL@=Vt6|9nHQtx4 zLK^SiRYJPHv3wzo_eF}3#`|nWNaKwT3hDYjZ5Gn#pHv8G^pA5T7u4t@NkSU$!zm$M z?Faor8hyA?NTa`BCZy5d%NEk;?42*>@mpfhyhOeqSUabvZZ+E;>1n5r>UzT^g90*^qcf2$L=xYmK5Q9ucDx|zjKcGh&bWzaN3Wza?lpSHvy#pzJR|9hdnw=1Bk7Dnx1=-f6@Nzv=}bou z=}bqbq%&^H(9t33jKVreXWY!9qg~P&g{LK*QFv0)8HF}UXWZ1J$Cy z4Jm-zX7H8VItZw5#y59M1>ojfeA_oA0dAba9&$rJ;QB`FNVR3y->%CBT$_O1vStE% z?;1IN^lQ9YjvxJkuae_O_mFBiesmYRlE=^Ga{TC?SS81g?y#51@uU0orE>h}N#c@p za|o}$0VfT&cz9nlBBY1@>NX)gEmX*4v+yb7!U7>Zom`MAq^FuyGSe)48ajVSNKc99 zwFv39oNI2f>9&-an`|2W9CMRRqpzG6(i2wcfRIK%t4TFBA&s{zUP$AeGA^X?mYSa}UEj&(XG>3|h302VA88UC}YaLK-ixRY>C1UD4s@Cb!1RsSwh5hvf?CiVjT@(sdhO3bgL6eNt&O#binZ1uli{vR(B z|E6QUi5ZD`Bc?azshH-N+hS^BE|Tnjsm%L3C?-8-$C!kenCO}4@1sYf-;VB++`r}j zb<_TS^nc-h*Z;D=%m1kVZvTz`%l+s3m-|nUS%3Tack^%K-_Y;%{o?z|_kr&<-!r}^ zeD}$uzpH!~`p)#7Am=l=pYo*z7)dEW87*MM#irt^(N8lK zPmO-6sd#GiWv1e((ThyQQ{yci7Sebpw+d;zLR0b7)t+Q3o*Hk7sd#F<0`p^_@lG&5 z2D-lE&5wb`JI?$VXuN#$W1#UCn~JBd?-*0@)OdNO;;HeDHWg1@(IQjv)Obgkil@fQ zO%T!uN0^GIuIO-6@zi)ZrsAm)4l@-`UD2VY;;HctF%?gZckq~yuIQkkkj6XER6I3a zmZ^B^iWZuRr^Y+LR6I3arm1*py!}kYQ&+TawUEZ!r$9*KWtfVm#@pLeJav5wOvO{< zrJIVU#+z?0#x&lZ=3-3OH_u#*Y4knJ#h6Cl-CT@m^xaIwQ{$zYil?r2S5xuS=)0JT zr$$dP6;F-6lc{)W^c_vbQ={)tDx}eqvxMA?G`PLgY~0)~xUJM|6q2N7<6aO4x2X$X zDhIcgnvHuA9o$N4Htw}{FflfK6&~DT6tH=F_+mYnP!qn44{lZn*fcGC=^xz07fuR- z8xI3Ek|akrbVEsUbVK7L$x)aqNsfDgADk^ojzX*?Il6>dNOFQPNOFSFlH};(0+Qq? zL`st5<{Lr3BsmH`NpcjtWdKihI5i7ykPrsxTLt*L7w}g-V5S7{XC~l}IKcE6;I|;) z*IK|YMS!Vvz|R4|k0XG|w(xp7f2an0UjXiVDJ&Ntd|5O=;_Bjg_7odbnJ?!n*rTAGl1V(5I;C*+EcfbMsBN}tfxyE9lOq-*cY7SiY)2|^mZeL_g%J>4s$@t&#|(s)mrA1+;AoB83= z^*v#JxHR5l=7&q;wVEF;UEia%LK^RpA|Z{pHeEeZ(ZNr=H|DqsL|Z~))n1lZhmXLJIu{*jn^8b)aYx>g`>v1+FUs5=UF`}q|vWz7t-ig)Cg(x%L{S2 z+gX){OX1GTd_o%il3^i@UfC+7YrnWkNVnypd?DSI)hR-{Ef>y^E*KQjEnC$rq+3>A zA(@|kF6ZY8>F07@5~*xTNVoHxej(k?vm1qUJ6D#GO0$J@tItXh(ycyof>hEgq-!{% zo^*PNkgj2QCaE}1NLO^~7-?CMR8-4?>DWrrF?poCWYVH((oqAX+$PeI<)kBWNQWm1xoZzzXdY^H=Kpy; zTm1hg|F4a{G`cMM)ad-^L!&dIcZp7no+Fk2p8{in_X7QaP@pw%PoOSvMW8%zdZ0k+ z{+WT@14)660=}r{ut?c$ejD#J-_QgbMANdp{{0A z_;ioSt_Muv(>;2-?l*-`_c-sm&lEo0^F!CYrts;WNxJSag-`b^({*=)kVa@M6>`s9 zU3Zznr^dU}6h1XVgDHHv=bWzF%^hTocbmC`tnqH;9ptWha|c=D-D2(_YrLCG;Zs*s zX9}Mh??zMj)Oa_T!l%Z&-V{D{MYX2zsh{Ar!$KPGnpPo=S7QpFy1q50@Tu{xHrHMn zui9LDX}l}VwU@5%3UlqH(JwdGUK+j1TzhHs%aU+)*LBGhuKctl-iAZ-Q86xwHt-A(!-bMT`L0N3-_*)5x^O3;fwvQ)2qX2K-cmDKyhj~ zb?7=x(i%4h?OG;jjY5&6HSQ&S*C~?LC@ht<#=Y|IIvHtA*GWigx|T><CrVnQ zP#|fIF751yM_}nt{0sEx@_L;#~k}(L_yBXiyUKM}^ zx%jrzlK^{8VGo(t4@hgoj9Cd+#p2fSv2Hlc$v6SlCI9AKk@v zf`6OeE_Z#wzszr!8!z~$seZcgf`7E*cQp8W z4Sr*T)290A#tZ&ts-GI~S5y6TS0qz^gf!mIruwPzelpchcSSPzN3Ijx?@uQG;Hp7J z{|IS>@5~KvUD1TO;jQt$H8;F9-Z$okx31`GQ~lI%8qt4&Cw4^|6l^fyiQQ=`9Os-L>{ z*G=_Px8*fc{nTxF)l@%qTVAOo_2&ucmc5)TnV4?bOVdKSWqqdlsay7Kqr+HFWfl*40VY zq$_GKCOw@&dMcLrRof`(iFP4f?c+71#|lZUX{1MeLi)L^9VWH3k{+%iJ(N#+FopEM z4C($s(tXXOrV7%%xukoNNR3mZyZT9YHVWz5?!RvY7AXr<=ed4+z(zWSAx-VT55YoMF%?Ro0HX+?Vuc{W(J-NDo zbY-fLez#Xd2i_Dfil`M)g_8YeMeP}t9JOgwAo7pMiO5eP z2P0pIY>#|Ma{p^1Dba5Bu))UGJ;%oiDlniN0K^{qHHy z{wMfmdH?qQ=pFOE?|s$#jAZ{0cyE*Hf2BPAf2#L5Z;p3A@9y4hz46{i&$Qw=E}?6gF^3`D=&9X z4-J_sFLzH5y=|_%+Ig zno_4mf5ntKHF|%kkVbzwOGu;tD_+PwvO<01q!)XH+#EFYLR~lq4Lx5RzHAQlW`wV< zL(fUI#?8z^&q%dXp{G53c^>Mn317H}LWO{?wD84#DCi5P0iljzz`E9O>JVzL3MUnz zr}F_%Nm}FPprJNNYZRW4w8p*i4?T{wCiEE6noz5xHE!wBP}>5yt`c9#wRwPRlJU*0nFd@vfN%S%CO~yL_K+)c z09PbpN2;2{{&ra(;L--{mY0-b@2$)NTpW*`eDyevg^C^=B^UDexj>E|_X;btD#ILS z?m4DBmdD_Eqe6PLp4%>@M{-$>kRIjd6bk7X;A~Ux)U(4%Q}5I>$63Qd?zLZNMXQi* z%bDh$n{G>qx#y;5hcnDQH{F)y=AN5wOR>4EAcWU$#O}$g2pJ3{p8t-^h@6>q5nFn<=UcRY! zYP`j!-l_49HT6!7muKpo8t-UR@6`1zGWAZ4ca*7jYP{TZ$qM!JJkr!VHQwQ--l_3& zOubX%9cJpCx}rl(y;I{IV(Oh5FFQi6V{}Ca4GC$y15LeC<7Jt8r$$(4>Ycix1I*Qs z#>+JKyfxl_=AO5%Xx}Eu12x`0YW;IH&gG_&okB3J2m>QrrxR1cQN%&jkmL@ck1W4lc{%V z^c~yfno*%kq|vuC^-hhRWa^z7eH&Bn)U|JI>YchRTbX*NZcC!6 zcj~rmVd|Yqn-AhDSmx^B3OTe{g^+I9rny48Wt$|CHl7mF?cAteNVjvtMj_qKxH8h* zY$4t1ISE3#)v*(#S-nEK)iL#?Xyz@U0P~hml*wE4v-XdXd_j`8R!CRtDI#r9--$uPvlsDoIm$q@R;XKTeY-2S`6Ok-je{ zeV0R;NEC9{-u2BS>FYkycmwIHQX%*A?D{f`^zV4m7vniV!o$ldF@J}4v&rwO@xYuEceA$L#idUu#K)GDOk?K@RM z`YFAgFQjXKD~0stjF7JO4IY18uk(3>u7L_1#a*xE3h7qAnndcK64I@HxnD@P`d^Jg zx}7hT3F&tBWee$czL+4STlV~fkbc&^y*RsdJ!kIQ>9#y;?%QefXUu&&joxGK+iCQW zxo@Y@yUcw%jUF`j?KFC45zgLS9qB^){jCe&Jm2;7h>%8qs!d3vKUpoL@!AT6G~N@b zLK^S!2q9fGS{p^ZNh) zJpV6>JT@{ra<9mg$Sopc{eSx>rSd=I|CeO{kN6w?H~6dk=lP5M$NO{q`%2w^YkBJ5 z4wcv{>lO zK>^n+X|~KWDrlab3v+prWu7)g^VEKr%Ni}S$P}A3`YEP&s2hE0f@Ky?&^*bs4|UBY z^_F>J3C#l2LDV%*h_lS&#%Si7CZevnxYja{H3c)>&SOj;QP<21SmvS;nn#&dqOO@+ zZJ9?F&^*F)6LrnQBP{cMwL12K1xg_%0)nCl-vknTd^tG4bJ0%lXz=d0bl=Ie9MztUew$UQfAKU*#2p0~T7 zF<+ni8`9mAD&(HwySpQV+_Q0am-+hKGj(^cMaVsicXw6_xmS?g9pYW;) z&eS_~MK_vyr^dU%)H^la^`_pbE2=g1PK|f%l#s@|re8?o)tGvx##>|Rof_|IQ}5Jx zSDAXJ#=FwgJ2l=FrrxRXF0U8TcvU4r8t<}9A^kirHP=lVz0zDaY4nTDb(6-s$Xqw+ z=egR{J2iTRsdsAh3j;zLebtDNMlWv@(&*<`r@rf4bFMn&VGti*g~| z>dkY6bgL5*g>*YNo#gptlRln%;v0l?%Qh|*($9J$bKg$4WkYk{PNUD|eY@^C=DwXq zpIs-U(PN8wZk?6E^KMM6kVX%T3h5^i)h?vbBWr{-y1!6J*X~Ob(&%2F{F3OmvcWKa zS0Y;RnRdL%*BfKX*Gr)BODl z{bqju-0ckgYJUIR&m}Z9CBLv5@8^E}-iCf^6w)pGu}nzgO=b&eydM&TG{SciLhdIK zn&=hMc;A})=DMP9%zblR(bwj_xyBne_suolm*&2?`^ks?ZSI?EyfJg%T;qLFgsY&? z=jpgY3Vjw3(s-Ya2x+`e+JtmPA6E-$ypaMSjrUQi(0|%=%Ej->e7U4G{r^nAr~lt_r~lu6PXE6Ro&JAcIsN~har*z=;`IMJ)9L@W(CPoTiPQh@Tc`iu z^G^T2+nxS@E1mv-2Rr@$Hh22}{owTfd&%klcbC)u?_8(<-(gPwzpb49e?L3@|6XzW z|K02K|6Aqs|2xv@|F`WdlmGwb{Qdvy&fot(;Qal+>HfDg=4j{d|95cy{{K(s@BiO& z{{H`A`TPIc=u4dE{}(&Y|L<&`{}23^^Zft2&h!6|I?w-K?mYi*`u=4~r+@SOzcHyl+US|EIjo(&@j(dy#bdU+T@1PXFoN9i`KMjAuqV{f~OymQMd2o)+o!U+cM4 zI{lyO$(K(58J=A{TQdJ&pBQyCq=XZrj@xFKLUr6aDA|acmUYxO!@Q-!GH=eMc~cV1 z8>cMuhJKpYH^Qtfv&`$VXt{pWwkIb zEwao@(rH!(U|uw0nX66ysGny=HOvbOEYs;0q~@v!nCA~!rqeA*&2!B_k@bmtM_Ha_ zO1B_HUukBFxMt~qWlFaom@CR*o|$8rC5bdopM<%*&oZT35Z?2&QkbV^S?02Mnx~Az zT-sxqC)d#|EQWbfhGi~^rCBfv^MrQGlx{)tqI+~4R|qpd%`&B15X@tSVdk}3rgRH} zxyYQ2-B13g6w8!uK`@UPgn4+gWlFaon1|)UJT%ENrCSipgZp6~)M%N~EeK{-Hq3U9 zR&AN7=KhU-@4KeL+$F*?rCSipomyb-SZSHkEeK|EGR*C!EmOJ$!Axp`xlOrcO1B`G zTP4EWa?&!TTM*368(=1sTIOb1G&hZh89#2BG7<*wxltX=4T~*PM#8|H8w+#xsAa~s z)0|ZUGp5ioqtj>xd@v)2Ez{ph(^m!4n{Sz(6q*q;VN)i;&|LR-Gt6~=RluCdwah=0 zX#PG0bGqL$e`}=qYZ=U6vMqBef#y#WFn{c|%*lG1Ka{}yKGQP4i=+AN7|d^imMJ4) zUa^%uhK13M#8`x8-e*nn`M4pP4lw?n4_td`Dp~rkB4B6v{{9f)!n`rhGG!zTqSsEtyl%iUosls5$zM|rvnIzfoslqVUNs4`y3aB( z5=JuOD@tKro@JR;@!ZZ!$6;R5W0{q8G%qfOc~ORCu8yU7;V8@t+AVWc4bAdGnCGWi z=6ODvWy3JfX{Fh5hjYYf{%`vKzwGq?zuW2mf4Hq(Z)Bk_1)Bpc6 zr~m(PPXGU1o&NtDIQ{?Mcl!T7=Jfx+(&_(yqSOC>59$9OKg;j*|NF@4|M#TR|8I@c z|LN|KD}a-~TUj{{DY&=kNbb=l`DqUpRmN-{t)M|BcSy z|1Wp`{(nE`@BcTHzyJSLCjFV`|DSQ5|G&j~{{Kwp`TvE^^Z%PT&;NhxJpcc^^Zftq z&h!5(BTfDPV5k1Sxl{i)fB8S;JpKQOO!_nR|8t%C|6xx3e=C2S)c=3>eJT0>E507d z|L^tPEcyQ`Uy0=ZNBRzs{C`_tyyXACdB2hT|8;M#5Fp6@09f6LP+`TxV7J0<_W#8W2u|6tQ{h~+qEq_($d0rA zEZ6zMOn5JnHb2~pI;GDK%;zd#KAUTq(&q%tSmp_-G>?yfnLlKii(6i^r$-B{}Xzs>nt>i@6Lc~0v856rn$>i?_foF(=Dqvsqb_5U5_Y$o;pKWBd@_5ZhK zzbN(phiBg*_5Vv|pCk4E#j_8Q`v1#<$XZ<4e{|{!pCiVX(X5AE3c~I*A*Th`xO#eGZ>i-L3c9Q!4tmwa_{{LC@J5vAel-YlGMqeL&nbiMJ zi#|^3|NBJmD)s-l(Hltq|L?&2QvdG`JSO%3n*vu#{r`->iO$@=J*57>alkM6|JP9; zN&f#_)RR*Gzg4FGRYaW?b+Xg_Z@$$3H;amv`u}&4pGy7z#mIG1|Gz_e|5ZkwBY*#Y zY~&%4drSR)%gEVM|2JL#hn%kek2qcbtDLU?$2(pBP2a!&)zAO`?B@UL3y{*Uhr5w! z*du$+`b)atcZ1>GC^*qW>sdvh)Go+O%$ zQ!wx9x6C^mY2Hx=vmx6uZ%?3k>jcdDUdz0tp61OZFmKAV%(^(5H;lo&K4_WJut#2W z&r8=8!MrxzGNoY;%rzr0uWqwUY1jj^x&Y>tsg@}Xdtg=#!Mv=+GNoY;%uDiMRwi4f zH0*)7dH`lclVx65PV<5sn5z;kQzo?FJxCky7;@s=qQTJWA{^}t+F zXPGjg1!hSG%rjywbNMLE;&zy))mWxXXu*3fOM_YDvrL)L0`ufnn1xlAc~U;jB`Giq zW-L=CwBS9DZ-#kXg=NZw7MP2ZU>-ANnKGdT=FyEX7nND2OlX0bn*j5O3ColTEiiNH zVIEdunKGdT<{@z~4<55jna~3Bz*?ADMV2WOT3{X!fSEaBnKGdT=DyW1_bISUna~1r z?+BO+hAdMiw7{HS33Jap%ajQ%F!z{-x%+@+Iuly-Y?E3JbJrZpbSAW@nKB7;r#{Pc zCbX!zLn+MUEX&+Jp4++YILxFT%iN}p=GMh9x5}{0#8{eJjKbW!-7*tuXl_;rbJH}- z+{8z7<6)Q^wOZzeRW#%BVa`pl%-J(EV+Uc*YPQUn3YyWmFat@J897DM-w)H*XqnzJ znx1T!8zj(_`7JOz|LzT&oqyF^=1d9AKQm$e5oejxV>EvY!u++?GJh$eIh79c=YVDY zI6`x>4dxHkmic`F&F@lSPDEJdH$ybPZh<*oX_;T;(fl$Q=Ge4lelbAv^Cp;|m0RX$ z4$V&!VSYSmnInBPKWc#aVX0+)kVSJi9_D-FmicZE&7nG&?-X0++Zi<9iiP>+sAW2X zUfeT-40?e%P-vOXpcge?^}*~Pw#=7X5nWoeimCbkKhOXF^Zb9r`b+6hVoG=?3T-jN zohT%IcjQIaOlXF=S%qav-yN8nB*ENx$}*+z4$KW3VaAnNru5x`IVS;T?1W`X-yN7S z^)RDLEHl8%AL+XT(?4dJK3@KWytObrMV7e%FMmQ2fv_oUcW8F~)rRftnyH5QXMtt@ zkxKLT2$;VOS>~@TG=HgtIhALbKPS`taT@02fMx#BMDzP{nBV1C=0qaRZzf@W-DjEO z4K%+hh52QcW&WF&KV4sp!~DF*GC!;1dyW>v{4~QdrSFcsxPDKTzB@2KYPU@3y94us zLYTv8mMMLAV7@yHbEwrarSA^Rx6R3T{q47|w^A%q`tHDdV-V)+&6X*BcVNDj3-i?^ z%ap!5FkkM6`L9OHl)gJK`?6uam|&UGcL!!~FU;raEmQjLz1`DO5YurT|t<^ zTFdM#qS=uSb6vnPrSA^j^QktNPgYx|^xc8^L@Lb3BP>(;?!bJs1?D4_mbo^MW=k^6 zho&u4X06~oo10)hP;QwrYX#R{elY?(4^1?DZWFmD>QOqsO;^TryOHxyc?%vyn2>w|giuw}}u6__>u%0bJNSt~FvuYg&VYnjfh6+PQrG6l1;-!h$9D{5X;26J_`WjeD~)VyE<=Bi%H z{Licv_gr^=3C!~{E%V$suIQXGm}dtqb7d{f(ju5=rCa8T0L_vSm}j(E=IPZmmlwb+ zPPNR_B4{off?3pJnWt3JT$%^-n!t#Vw#6%z|4uY%tJ?M9?}joyT&pPE~I%-8q5QI zmbq}4<^ioR_ph?d%zT>rrNG>0#xgSoY3|(&bFT`^T#!pMJqhNXQ7}`IJC9_UGa&-zrbCt~^IGIZ_iPhi33KB-%anO7Fyp3S&KEij`eVFvmvQ|7h6j4Xxe&$3LJ*8R{giRUOLetD^ zf%)es%s<*K^Y(}gg9OS4Rw*Mj$)8ix6EE6tE}T9p?y|NrOte>4B@pXdL5n=D$< zos)vSsQb{F@Ltq?$RPKk?(Alo2Uoy6DAzI%Orp7P3g!X*mMQ&n~yBERSE!{Gue-6xDMquvT zW|`7I2j)%%Fn3I~OzEEkbNeBf+qGDx^v{8rlm~O0WXqKPIWQ9kU~buDnbJQ8=H@vt z6A~>``scvhqz`6%gJnwp9GDwr!Q3$3GNpeG%sD+UXV+P#^v{7gD+6XstYu379GFq< zFe7U$Q~Kw?^rgY{`YcoW=fI3;4V%(FhoxP4$MDiEK~aD!2G=#=5&Q+O8*>~ zzb3()nzBskp9Ax!MwmaAS*G;Qf%!uM%FQ~Kw?{I(wEHzk%S{c~WB$HDw^%rd2a z4$QGym|qlGru5H&`B?zwrz4gr{c~V`Tn%%iz%r$O4$KcDU=9yirc6zN`CcW=ck?V$ zrl!Drdm83j1C}}1MDxvZm~Z4*rZY9gJ%5H?n}qplpJh5zQ`GD)h52%pWja$+)a)CF z`C^Y{zEH>Qe7+cFZ-!+)7fbV*QJ6jLmf2lHGgJt(E6p;4KAIiFFxRzOW_uOQr}JSx zm13D~Gc=zVg!y>0WjT%1g=%IOG9n1@gE%QHfSJq!yh00@Lo;PZl&fFC}-pguWo>OR<&fFC> zSNdR{HEfyA+!Zy?tb$pRZ<#W8MP78bbNLL+;z7%lxhpVFt$?{K*D_`93e2TbFi-Bc zOqsg^^Q1DEOR_Cf=B~gzVFKpyy_P9+S77Fsz+9YZnKE|;X5JXgql1w%eCXPGj01@E~_ z2F#sfEpw+)nme|`+@Z!YW$p^zbNe)y+xaY0=B~iprZsGKAO4>sPV@ht{y)_LFwf}# zxTDhnaK`BX__oslu*K;Bc&XC?FyH9_xQpok=yU-1e{urg|J?}yrUSr~(*fXBrvpHf z(*fWDrvpH)(*a;Rrvt#W(*fWOrvpH<(*fWjrvpHq(*a;drvt!@(*fXZrvpHX(*fX8 zrvpH~(*a-?(*eMJ{@;20_~;euJ|3Fy_WTx-b~>PsDoG#ak))jt%=f2B?+uWooes>Q za?(3FBx$Dub8wRMW*A-xwlr)eUUl}L$_mHHW4$OZQlU~XoNjn{wFN~6& zZzoAR9hlD*lAcW?Njn{w-NU3%D@oevzzpV-I#Wo}P6uZDAnEC5lC;x-`D8AsEr}%U zbYMQ#Pik!>Njn{wk7Se9CXl3^4$OyoNe|YOq@51T2Qo?b$C0F+4$OOlq;=s07=^Ez-(wE-Cj+Sb~-R`O(oSwkffas%$r(Bb(JJ(rvvkbWK!)kN!sbaytavS zO*u*0>A+l*NV;m0B<*xyUfDppqLd`2j=Qn(uJcW zX{Q5oRSl`UkRit4J&JNit0Z=8758nS&&mrULVf3exGhB$=iH z^Ry|_sr@9GrUJ95jC4viNv5g5ESw;n)Ju|SDlkthAr)kjWSR=h6mnqOjCimXoPfB8%d_Az&x^mbVMpirm4U@Y>0Gd3rVJ_z|77g9h^*(X(}+Y21pB= zNHR?Y=KeXP%tVq*Q-QfpA1R}OB-2!2?v+Ja5Koe6Dlqr#AqYQQpOnhMOea+pu#Sf)%Y46~eqT%`#=03e4MwVcyninKDfU zW_>=)TT(1jrm4WJ8-#gdvt`OO6`0rO!mLfQOqr$v^O}B`HI0@j(^OzyoelG<1k03Z zDlo6;g?V|sWy&-an3rY3yfn@-Wts}ii-RyPs0<*NzGG&?y%oWKnOQtPTrm4U@y$R;>a?6xyDlkt=gt=_e zGG&?y%u^a*E-kf8nWh4>FdpWTam$oxDliM`V4hHHnKDfU=5etw7mr$|OjCh*ObyJu zLd%qCDlixMVCD{6=8>(&)coJn|NEW(fA={3|H_^Ie@8g||B{^kf4@5Y{|21?fA>56 z|0e~0;h|BJK#@ch5^`G04iWxhLW7PUTJCS4>=!DHb`$&H@kW2%eusNMY`YoPh8t8=0 zsUFhLbtKb3Cv5(hL7I#unFcyx^SgG^L=DL_&;Y%kxT=fu=!;x>EBf((?BO| zevv}@Y=&eS=!DHrn@OKkkW2%eusM=M`f!S58t8=0;YQN?WhB!;Cv3i(Kze6_WE$v% z&9~}FgC!)>KqqXz5l0#rBbf#|Ve{2m(kn$I(?BO|z8oOEG(s{Bbi(F~)ua~+NTz{K z*zAoUJv&4)4Rpe0PbH~4k7OF?gw5bIsdIp28t8=0b>*b?9Fl3E6E>fmB(?RCOaq;; z`FJVmu`H5lpc6J987HmnA(;j`Ve{c)(nA>}(?BO|J}^qUznx?n=!DItLejlyB-21A zY&H&)?rJ67Sw*@dpVW{-G6Ph??|JJWslJ(H2B?J1n{!DwC6UYkm9Tk3Kk52Lk{O^9 zHm}PjU7J8M160E1nqJb?^&~SuC2Uq_lCF#+nE@(cvnoistd?|X5$O_U{2i6d_&a2P zikn*=y`p0^GyaYWX8au&GUM;KfEj-ys83@SY{i_&ZK##@`_WRA3e}X|02P>veWYWCNiskM=FwH8 zMfoHdpaS#A8PXAhBpILrGpB-dST0EhsKCsgA|2dMI;fF!U>PYZn&TMBc$EiNV`>&QVU4CrjmAvAf*hE zc4{HX02Qo#hdfepGD!xgz}$9#l+;9$0V*)J&LM4;NRk06Ft_LxF@>b)G*ZAviX0~STS>kuk~g2^ zNg+kdfMkFQ>F;K2waiZ;$pDq`8rJ=pMEZS-Bm-1n{?CG&Vx|qK))7N60#|F|4r6lQ}19SU$(zfFy>7N60n>y0g#U$yU12Zv}w8bb%`sct*s3C1u zNZK@ww260lB9nQ%wLK~Q|Tn>p9Ay9 z5z=HEN&4r&{JwznT`EcX=fM1Ci1c*}N&4r&{3?(1Wim~53)$Z@uc_0N$>WMhU!StKL_vmb_VIKSd#S5f%!%|vPo&5 zLo)qyG$-wRjro7)tIYp9P5+#*`7-nW&VMogciQQO%|7P;GCzgyVg{&$&0gmJozF4< z?=%Bc!sau~|2w;x|96@JDq*vW`M=Ch;cI4qO4#gR{@>Zo{J-;Q=Kq~fG5_y;lKFq9 z8K4q=&nKAwcRt4aztap*37d~H|L=T+`G2Pwpb|D)0;GpVNM?Xa*leyQJy1Y0160E1 zeG#O4he&3CO4z)+lGK<-G6Ph?<{i_dh5?cppb|E3D<|EWLox$Y!sgABq?`IkW`IiA zys?yYLl(&lPzjsYjgzkJAzf2PswpO|$sm~lD&hC69wlAbPBH^j!sg|Lq^dO1Wj@j+ z!=%bqk{O^9e$R{YNvl&xW`IiAykL;Ds+nX4sD#b)b4lkVk<0*Fh?58K4q2 zOS4I5C6LSjm9SaTOFE;Tbb1MCc_yhij${U?gx_;nkW^GlG6Ph?=F;?Z(8&SPNh72s zZKM;cNd*O@6H-aXN09P|NQ+xY$5xV#$s^?@bE_9klgt2>@QQMqNJo~F%m9_Jd3Yk} zut|~`pb|C@X&_~nk`B%y9TZQ>8Yh_nD&hA$ppLYEG06;237h-HlJ*%TnE@(cbMG3` zUWFtxKqYLZ`$&5ZljgOO(yB;%s zm>V(w?}}so-z5!nV9sIw-!;3OuSo+Pn6sGwcSSS*?~(>OFr%3NcSSP)?~(>OFn#eP z5A**nX`ll$g89Gn&%tY^flk_DBx#@n^H(2fYM3MqbYT8e zMfx$HG?_yBeunhjAZenR^lb&{n_QC2PeJsrrbu7*lVpAh%&{`k7uh74p8|7qg7j%G zN#>`({J4ZPl1cg~j`YD8X*fud`6+nM_lijGrjulT3e2}hNN=@~WPS?FHw#E_q>^NQ z3e4ArNUye#WPS?F{yfsl$t0Pd0<&*`^kNg~g>usKIi%i1(zBDKXZlDz4J4VLg6N?v zQdc}l=BL2y=pn7EBgy;}m``Vro{A;O{1li^w5Nj}uOU5FNNPZS^k%um4@?nxpwPLX7O3d}njNq3ZyWPS?F+Y?B)PLO1N z3d~#TNjH~}WPS?Fx;WAeVNvj7)6-}fI%SjjHkYs)eqMtuWITlypuO zN#>{EJxj+)XZ4U|ehSPpi%BIJq%&ek%STDY?WEIcNT(K(mZgzoehQ*59VVUJN|O00 zFi*-SElDBC{1lic43ds-CdvF1nEAP+#YrTYp8_+lpLBF1X;B&JsBBVh0_lhe(&4?N zoO+VXPeJrUGf9WUkz{@f%!7iY18Ye#KLzH(bkYFP40%>P46 zrx@>K=KrBW=KrCSnE!{CF#ivo$oxNa0`vdS@y!22$JH}lKJ)+3V&?y$W10Vl@|gdJ zj%NNJTEzT6bQJUdP;NTo9T^}UK0?ZABOO*vI<$avNGd5if^^Uj>A)6JRwZd+9_fH& zQsy*izX8&|O{9IwNf|k$y%R|bCQ0dir1=e`JxfXRvPfz1q}|6!yY-M#>qxs6lXl4< z?Ho(mX_T~MJ86d+QgR__`!v#aK2p*!X`5Ek)>Wjf@=1v)q%CGhn-7u_nn{~gkT%UF zZIVRVc#5=9KWW28Qd}8nZZ>I70x5QaG^>{sQ%{O6Aq6r?QE?>y7|9nTd230YBGLxw z^TT(bWqu0C3{VNbCi7EBGu0#+paS!cRMPJeBpILr^Vb&AFO{UJJkrm}*y^qyr%96o zq#v3{-KppZ12Mta{zdUu#K)Jl4%iu86q>8%vfn=_<021&0slLjhCujP_nO(OMA zkzVd6{i~7mQW>c)oAhD=>G=s#Z!hV&deXBcq-QcoJ#nPa7^y2r3f7W3i%1>mq;&z( z(<7v(+DK1UliCVMPo$C_k07-UksfU!JyJApnL zy_2MS`bc*-kQz%#cV&_8j3+gWlWy-J-Bw4swU|_&LAoWDbkiuQuAOva4e5qL()DSi zS|91!VbV3Nq?#(yntamLDWvKd(v^dxE1F4{SCFc5NtY#&E}0@#_LDAdBwbWSTAfX* zNFZG>L0Z*IDz7J6|gr*+J6ET2g5d>8y0piU6r(gmgw5>GW#S@&ZzE zDkyaQkjYp6$^ZY!|NmS5-yLNB-`&alzq^C^fA>1(|J_eB|L=Z^`G5D5%>TRFnE!V_ z!Ti7bapwQst<3+sA7%dE{Rs2_?zPPSyIYw5cR$ShzxzSv|J}{Z|GOVx{@;B+^Z)Mq znE!V-G5_zrhxvc^-R-y=(A`)=x~q_MXBz1aAL;gC(rvAzTdPR*`J`J?NH@)p>IO+S zHj{3sAYGqJs!bwYJ4L#tpH$OGT2n^4I-7J=0_n;L(iOd=%j-#1C8WzTNtecvD#u6{ z2T2#zl2#XyD$+?821u($Naby$^Q%ec6_CzNC6z^x&K@GIY$26alFrH_tw<)7Oq0$S zAf4VsT3${n&LN$aNLn^YD(WMh(m+~TN;)}?~@I--gs?Q~)0q>v7sAssSE%5LUXA6!8? zD3^3#5^3QS>41LH{*9!}GSYt8qLtQXXlWWYSjCq{IQzmQAEB z%1N8&kP;F}n@*B8=_AEAkTxzQZIngYFrGAboHVD0G`o%zTTGgjL5hhb1x86x?WD*W zlE0ATOCx!Gqz#5i5v?E@phEgPpY&G>>CYL`AA_Xdn@Q6Zq~CH$zb28Urbs{clYVL> z{a8kt%qIPiK>BWiG|@}?Kia$hpeXBhkK-#WyZ5h!&Ctlms3@s6LtBlE42_J8bWZbtqui7X4UG&Hm5hvxjFOUi&KJg=-kvjO z&P?b0@yv7dzt>z}{@J+)uFLPe*XMf=b;3n`7f&@?spC!5H#*hirj9wOqju_uL>+FR z4wX{}^QeYY>T4Tyz(Cd4QD2r)`*Wy$4yrDi+S^X;siA6%sn4^h-AUAEk<_kMYG*a| zX(8p$pmrospM+D@&D2L#)b;}E!*t3QM}44CTN|mW3d);LZAqglV=3K8ZK|iaO!)VopCnl`G`PrY44tNPvHM4~(m)GOuG;yh|mDpg>Eyp?sG67zpIW02JlcJBVa zIbqYnCWWPijSL$aHXtk}?2@q1ux_Dkq2Goc2>mS77rHTYb?DMicj&CpDWMZXQ$z0# z9UR&(G&=O6P{s0xrPXrGvfuKlrOLA2veNRZCExO_*aG;VCB<^5Wsv21OO)k8i%I)U zYtasCd$o@>U3*tst}W8?v}d%*+WneCyImWoU86;6VOo&-i`uLnP;1m}YPq^n^{8{z zY<04lrjAe()c$I;+EbO3-<1~SkW#B`S1Od%N})1e$x)tAoXRLAQ5mSjD7_R-{!?z1 zkIHrOC$cWTBNxdFmhz-&l1myV zB};>)Sm_EWTr!#4%_qza=G|tWd4svcyu_Suo?*@~PcS>oL(O*c)#eDZV(KuRG&P#` znyO8kOr@sRO>WanQ>N)5Q;KPrDb94QDbf^bGK8E8X$sjN;t$ypQWmm2q#$H=NLI)r zA*mt5L*hgFhD3#Q4>1~lH8vX$7;B8%jOE6aMvrl>G21xVm}VSdOfdF0MjLw?rQqL# zTY?V-*9LD7t_WToTo^n*I4AgtU}x~C;Kbm8!7;(Tg0-MOgIa@*2Gs?95~K&c6I2wm zASgFzN>F;xn4qMfK|!{l-a*!|Q(f=;J0@=$ZR{WT9*NMs^^~KG8ktLtNTKeDq3-UW zhSySel~769)Sb!HuqZ0Ajk?`W4K1REWKs!<)NK*etu0i%kGjP}4RTR8$5U}uYG4yJ zK&R|(>P9Ek-%jQd6X@cy3R&jYoKDfHn#?zf<9fFlLNXsr*mUyKv#8b zZVf29YjbiySG4owmc^63yEeB5YL`Z`c1bJMt7~&>;N-=HtVLu{ z7j&5&RTdgbz#@$*1*Z`1*~0=PFcG)w+2pzX{=cqDXnXBYv81s&zh1($z7XU z11BXTYo>ZCq-%3);G{8^wcr#gD26h0Kw=LM^=Aq7M>f@wOr43Meru!J{nY6q>eo!_ zmqe;9f;!bg{p6!sJ=Bjb>SR3igO&Qei8`TE-?^z~C-tqJIxbO74b-u6>S!KyB$aBk zQHKrG!8)p;l=?b{I^dwbil)A7r}o!S`--W$Eb5CSYHuV}+e&?2P3ca}kmrs3=Ms16wwi+pKJ+-Bbs?4SI6l!w}^?nCc zQA=$sp*Cbw?o#lbbGo6tE0Zgj^rgknY4^*-`^8x~RIu@AHxve}<$?OaZ2;j*w;E7z| z@f08<2AJFdOsWN3CBUQEz$3}P#3`GM0V5iKd&+_2JmBtBV7Lvq%K+S22Mj9(?#KZW9l-6; zz>szzp$53E7#N%d+?oW$M*@Rdft#y=xI*Bj3}9daFd!Vbu^H%J1@tQbV$*>e;()#y zU~3fU>gq;zCZ=#!-_H5p>*M?{5>t3`8|Q!TR?h$4s=wxcG1H~QcJ}@M&HO(*?CG-{a>Ui${$Lra!lE;e5zC_>y?$tt4hA|?D_fse=+}W&TGU}xOq+mH9MbrK8?!l zdaBOOCYxs&S$nRY$|<9s&823f;4j$b=`qwZ9n`d1YHA6Ul}$aJOihWRGTW#p{nQgh z)Z>{{Mk4iC1U0FJa`~u7J=7yEYGOQYf@ZxtO{;iyEFp-4#jQ*-8zortT=D5;LgV z6R4r#R6;X#TNO39fVwrEijSji(WsjnskjR2rhIB(8Z{u6vKy)X^;EwyDmIt8A%*H2 zL)kj0>uRZMOQ>tIshDJ{PZV`k8x`%Rt}LRi$fPb$q@p6I-YwLnJ}S~fUE-p8#Zwns zsf(JZo;uaTO@%wD3++^QiLy3OVdYe49%V_TG#jNFD7lW3N-1*=WpYp<(Nu6d6;wlY zE2a!tkVtc({*0tLTB$SD)bEAVZy8j30(Cl^`lXp_tD=4`piZSzKgCh48g;Uf`k{hq z$)~#5^q)Hk_QQwnt~hC0$gHP%vxOQ=KH)WKw`A&NTCMt$X{>Wio^ zGpYTF)V>Jnixz6HuTy8|xlMaKK&=b-JRaC>1!|grT{^JS4SebZ{B~f61bo~8RF?xE ziG3kTk52G&#or3Jv->A>nZV3h{E)d;Mt09ND!#c9BsvA}X8P*e{rD+6B71(v1& zg)zXA4&c>Vz*7Rek_{|Q1{OsD3)_GNe&FRIz?})alnBg^0Po5guno>$Vl|yAZs3)VT$J?ol8tSoPYH}7eDT#7LQjfG!6RWB8Lh9iR>Y)Vc!Eow< zW@P+=1HC-L64p(ngW7R9g&A+U4C_gGkm3@j| z@ha<-6^cihCsF_(7kB@om0`-wicPsp>8^yx?eh2XA$gBnEpHaL|8K|z@*H`ZJV{QI zN6JIx0dkCdiTEZ!xAXV^-HbgPll4vPqV)IKMd=mnqV$dIqVx^yqV#fhQTlpzQTjS| zQTkeTQFDE(b_QTiHoQFP3l~*FeoJr(VdT@=~ceHfpwk%B`bjl~OZvsOKD1PBb;6 zotj=lWfxP=WKq+SsHu_E)2-B$YU-&%Dl>z6GJ$#`oXTjX9;>1z7f_SZDOVizs76g} zq|z&>hx4h2(x?YxDW{Q|P*2@oMy2Ia<5Q?{F;r>?HMW)-Q$qbCn@UNhMn_Tiwowj0 zHL{2rkxAW?NF_&5!&|7kd{mN$y3<7si>L0eQnxozLv?D1n@Vs}x7n$|5*6P--BL~s z%A;;hrQ&SVO$KT}9c3@2Zp@+jJE(rq)D7)a-x})rV#=09U6({%8%f2qQhlnas|%^C zGN|Z;PMw`X^((`H%bS6yD&VpLpm#cOX&ex#0lgZ5iz|SLeBh!qpl2-5!w6hh4|FdB zF31I}DL_~ZVCev~T0kuUlx#pw2Bav!)CPq30b>ymoCyRa0^K71f=GP<{`3KVcz_NU za3&u3-3qif0jG7~S2ysB6KJyoKTE(*4M1x-@M9iuG8Op22DBJ}6Lr9Mr9g8I@T~(l z9t||L1IKEBqs73HETAz7I2;KaYy}#sfv*dJ0~x?q2|#@~u)i7DR|V7+0AHj7d*gsT z8t{3eK;RZ>H>Vhp<-#eZCXLz^OYJmL{(5Rh8TCmn^>GSS9YbyJpgydnd?nNe+0?dV zYHJkbZKJmMsmdZs&!jddQkx>EiWX|4kJ{j&-g8mq@zi=NwYG^W)2Vme)H_aUjh!l$ zsMQVBs&c9%k9sSWT4|$J7^pYvsO6>98#z>wgIX3%Ep4X?YpB&F^+s&{tL|i3$$Eqik7a8(UP=5noaAi zS;brbZDRVbS9hva>N>SpU99G*(^Qu_PE8i~{;}#6Y7g<&{~6_^azv?9b|_nvwMwz_ ziZWN3u4E_=i1+^QP~w#9l-|k(icvl-pO6pAwem;uCV7o`^M9c{Tb?SrkD%Oq^P>t!- z;W+A$Mm022Usq5E@~N-VsQOsyOCzu!ZvZs1H2UHW#%uo~p7^Tbig!ozmUZW+%1DPQ5Qt z8ylz%<g&|m zDYP=n13c{lro;nJS%D{;fG2d|aW|0R1Rk>klO@2_06ba_Jdy`YOa;w$+|vvsR{?hy z0K?OPyW)T(4H(u4+))7}<^#8<0YhVfAx7Y~dSGxFaBD6Qp90(x1KivJ#MJ^fl>h^? zfdR>YJqqaG2K4g-u|>cQnLyt};Q9#Qx)$JCA8?Hah;adZ;(@EJKy(vur4C%-1}=9B z1bUe$PB9`82EDXMgrOoi#fU^0)_QS@sl1p|j7Wsx$)22IDtmB>5s5H7c_F8m%I=(E zL?R4NhH;9i4CNFf5@C2!;}lb=a*7d&Fgz)7im5bniV=x0JQ>0%rqakMrZR|Aj7Wsx z&lxzyh(s9FL?R6Jhen-gq<*iUe#@uY)2P$2)UQUWt)BY1j5?J|{ggtr#!x3as2^&n zmJ;gwZ0bZZ^<5P8Z5wsmPkmEFHDyxA5~-sRRAUQu*hd}mPzPO9Lp=4hmHMiQs@JJ6 z-PC?3wa-q~Nz~p3YEL;;n@4?~O6|5$pBboKb@-dD?<}Q0&7u4bYDYBnaXVFALw!_C zZO@`UOrm^|)V5Y?Yc*9>NO?1;EeTNNcU?cHgq`XZ{9nKS|M%Yi|9kiUe{cQ|S4~R0 zazbfPb}K$*gHoa_5pVs^5Yztz#i0yU?8?l=%*GocTI)Z}SCaqv^Eigz2EE*7TA1 z_Wv4Fk!hi6wrQ%#Wg2f9VH#qxoBEh~nJoXp-~ada;?wB8m`|fOf={EjC!a=d4?d0F za6XOR3;8s9yYp%EUJ#CF(;L=Ig;r6P0!mA#)Hq7fD5;S$S5T&WDkP0E#!|sXs#`r} zD1*d)BI?f+>W>)eOb7LQE%jRo)t*hAPNsg1qT1T1pZ(OSBI>71sx^`NF@pM`g=+Cp z-+QPNF6z5@s@X~%Z=$}@sU|md%t;-!Q%5A~a07LyoI03CHKbBs+o%Hus=ki;vXt7N zL+x`=b5DSrmFBcW4gr_e2*gag&hz(-ZU z_5$F;bifw}e4qha8-c0{z?%W+1x?c%}fDmJUpf1F|$=N~6HpDRhg-mHBHgPiL+S zBywdSkt+jd# z%Ah84Wgw9&1BqN2NaV^uV_T>(K1$@u;H1cvfkdthG|EbeTp83vt_(E7O^I9?)I_cf zBywdSkt+jX;2gC zG>}NAfkZkDB+_Xhkxm1NbQ(yc(?BAf1`_EskVvP2L^=&5(rF-(P6LT_8c3wmKq8$6 z66rLMNT-2BIt?V!X&{kK1BrARNTkz1BAo^j=`@f?r-4K|4J6WOAdyZ3iF6uBq|-nm zody!=G>}NAfkZkDB+_Xhkxm1NbQ(yc(?HXmRJNTG=`=Vg(rF-(P6LT_8c3wmKu_7I zOat`afI>h17%$>iz^OEu0$HOx;&Sr4~?Q)2T6W)IT(8bR#vYg1R@Ka->lsW2q5F zD!HDzyNnv1OWl=1CB;y~I;cBpsl*cM_H1fsGBqTMx~+{G?5A!mqT(~DTN0^35ma0Y zb(4=8=%EI7WXmxC literal 1277952 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

FOM<|RiETW6Cbh&#OH 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�KJLpt~iC6E;xmm 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 diff --git a/tests/data/make_fixture.py b/tests/data/make_fixture.py index 8c52419..df54928 100644 --- a/tests/data/make_fixture.py +++ b/tests/data/make_fixture.py @@ -1,5 +1,6 @@ """Writes the two small cache files in this folder. They were generated ONCE by the pre-recode code; the tests load them -to prove that caches written by older versions still open and read the same. Do not regenerate them from new code.""" +to prove that a recorded cache still opens and reads the same. Do not regenerate them from new code. (The Ecowitt one was later +converted, with its data untouched, to the series layout by scripts/migrate_cache.py.)""" import asyncio import sys import tempfile diff --git a/tests/test_check_rain.py b/tests/test_check_rain.py index 776f984..1f0f8d1 100644 --- a/tests/test_check_rain.py +++ b/tests/test_check_rain.py @@ -37,9 +37,9 @@ async def test_healthy_data_is_reported_as_matching(station, capsys): def average_the_thirty_minute_rain(eco, factor): """What averages look like: no daily_high field, and values a fraction of the real total.""" with eco.cache._lock: - eco.cache.db.execute("DELETE FROM points WHERE cycle='30min' AND grp='rainfall' AND field IN ('daily_high','daily_low')") - eco.cache.db.execute("UPDATE points SET value = CAST(value AS REAL) * ? WHERE cycle='30min' AND grp='rainfall' AND field='daily'", - (factor,)) + ids = "(SELECT id FROM series WHERE cycle='30min' AND grp='rainfall' AND field {})" + eco.cache.db.execute("DELETE FROM points WHERE series IN " + ids.format("IN ('daily_high','daily_low')")) + eco.cache.db.execute("UPDATE points SET value = CAST(value AS REAL) * ? WHERE series IN " + ids.format("= 'daily'"), (factor,)) eco.cache.db.commit() diff --git a/tests/test_contract.py b/tests/test_contract.py index aa21502..239cbd7 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -23,22 +23,25 @@ def tables(path) -> dict[str, list[str]]: return {n: [r[1] for r in db.execute(f"PRAGMA table_info({n})")] for n in names} -def test_the_cache_schemas_are_unchanged(): - assert tables(DATA / "ecowitt_cache.sqlite") == { +def test_the_cache_schemas_are_unchanged(tmp_path): + layout = { "coverage": ["mac", "cycle", "grp", "start", "end"], - "fields": ["mac", "cycle", "grp", "field", "unit"], "meta": ["key", "value"], - "points": ["mac", "cycle", "grp", "field", "ts", "value"]} + "points": ["series", "ts", "value"], + "series": ["id", "mac", "cycle", "grp", "field", "unit"]} + assert tables(DATA / "ecowitt_cache.sqlite") == layout + HistoryCache(tmp_path / "fresh.sqlite", api.UNITS).close() # a new file gets the same tables + assert tables(tmp_path / "fresh.sqlite") == layout assert tables(DATA / "airgradient_cache.sqlite") == { "days": ["loc", "day", "n"], "readings": ["loc", "ts", "pm2_5", "pm10", "pm1", "co2", "voc_index", "nox_index"]} -def test_a_cache_written_by_an_earlier_version_still_opens_and_reads(tmp_path): +def test_a_recorded_cache_still_opens_and_reads(tmp_path): shutil.copy(DATA / "ecowitt_cache.sqlite", tmp_path / "eco.sqlite") shutil.copy(DATA / "airgradient_cache.sqlite", tmp_path / "air.sqlite") cache = HistoryCache(tmp_path / "eco.sqlite", api.UNITS) # the same units: nothing is cleared - now = cache._query("SELECT MAX(ts) FROM points WHERE cycle = '30min'")[0][0] # the fixture's own newest reading, so the test does not age + now = cache._query("SELECT MAX(p.ts) FROM points p JOIN series s ON s.id = p.series WHERE s.cycle = '30min'")[0][0] # the fixture's own newest reading, so the test does not age for cycle, at_least in (("5min", 100), ("30min", 100), ("1day", 1)): temperature, = cache.slots(MAC, cycle, "outdoor", ["temperature"], now - 5 * 86400, now) assert len(temperature) >= at_least, cycle diff --git a/tests/test_migrate_cache.py b/tests/test_migrate_cache.py new file mode 100644 index 0000000..8196fdd --- /dev/null +++ b/tests/test_migrate_cache.py @@ -0,0 +1,87 @@ +"""scripts/migrate_cache.py: the old Ecowitt cache layout (readings keyed by mac, cycle, group, field) becomes the series layout.""" + +import sqlite3 +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) +import migrate_cache # noqa: E402 +from lib.ecowitt import api # noqa: E402 +from lib.ecowitt.store import HistoryCache # noqa: E402 + +ROWS = [("M", "5min", "outdoor", "temperature", 100 + 300 * i, f"{10 + i / 10:.1f}") for i in range(5)] + [ + ("M", "5min", "outdoor", "humidity", 100, "94"), ("M", "1day", "rainfall", "daily", 86400, "0.0"), + ("M", "5min", "wind", "gust", 100, "-")] # a field with no `fields` row, and a value that is not a number + + +def old_cache(path: Path): + db = sqlite3.connect(path) + db.executescript(""" + CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT); + CREATE TABLE points (mac TEXT, cycle TEXT, grp TEXT, field TEXT, ts INTEGER, value TEXT, + PRIMARY KEY (mac, cycle, grp, field, ts)) WITHOUT ROWID; + CREATE TABLE fields (mac TEXT, cycle TEXT, grp TEXT, field TEXT, unit TEXT, PRIMARY KEY (mac, cycle, grp, field)) WITHOUT ROWID; + CREATE TABLE coverage (mac TEXT, cycle TEXT, grp TEXT, start INTEGER, end INTEGER);""") + db.executemany("INSERT INTO points VALUES (?,?,?,?,?,?)", ROWS) + db.executemany("INSERT INTO fields VALUES (?,?,?,?,?)", [("M", "5min", "outdoor", "temperature", "โ„ƒ"), ("M", "5min", "outdoor", "humidity", "%"), + ("M", "1day", "rainfall", "daily", "mm")]) + db.executemany("INSERT INTO coverage VALUES (?,?,?,?,?)", [("M", "5min", "outdoor", 0, 1000), ("M", "1day", "rainfall", 0, 200000)]) + db.commit() + db.close() + + +def contents(path: Path): + db = sqlite3.connect(path) + points = sorted(db.execute("SELECT s.mac, s.cycle, s.grp, s.field, p.ts, p.value FROM points p JOIN series s ON s.id = p.series").fetchall()) + return points, sorted(db.execute("SELECT mac, cycle, grp, field, unit FROM series").fetchall()), sorted(db.execute("SELECT * FROM coverage").fetchall()) + + +def test_every_value_string_unit_and_coverage_row_survives_and_the_original_is_kept(tmp_path): + path = tmp_path / "eco.sqlite" + old_cache(path) + rows, series, before, after = migrate_cache.migrate(path) + assert rows == len(ROWS) and series == 4 + points, units, coverage = contents(path) + assert points == sorted(ROWS) and coverage == [("M", "1day", "rainfall", 0, 200000), ("M", "5min", "outdoor", 0, 1000)] + assert ("M", "5min", "outdoor", "temperature", "โ„ƒ") in units and ("M", "5min", "wind", "gust", "") in units # the unmatched point gets a series + assert (tmp_path / "eco.sqlite.old").exists() + assert {r[0] for r in sqlite3.connect(tmp_path / "eco.sqlite.old").execute("SELECT name FROM sqlite_master WHERE type='table'")} >= {"points", "fields"} + cache = HistoryCache(path, {}) # and the bot reads the result + assert cache.load("M", "5min", ["outdoor"], 0, 10_000)["outdoor"]["humidity"] == {"unit": "%", "list": {"100": "94"}} + cache.close() + + +def test_a_second_run_changes_nothing_and_no_backup_leaves_none(tmp_path): + path = tmp_path / "eco.sqlite" + old_cache(path) + migrate_cache.migrate(path, backup=False) + assert not (tmp_path / "eco.sqlite.old").exists() + first = contents(path) + assert migrate_cache.migrate(path)[0] == -1 and contents(path) == first and not (tmp_path / "eco.sqlite.old").exists() + + +def test_a_failure_part_way_leaves_the_original_tables(tmp_path, monkeypatch): + path = tmp_path / "eco.sqlite" + old_cache(path) + real = sqlite3.connect + + class Failing(sqlite3.Connection): + def execute(self, sql, *args): + if sql.startswith("DROP TABLE points_old"): + raise sqlite3.OperationalError("disk full") + return super().execute(sql, *args) + monkeypatch.setattr(migrate_cache.sqlite3, "connect", lambda *a, **k: real(*a, factory=Failing, **k)) + with pytest.raises(sqlite3.OperationalError): + migrate_cache.migrate(path, backup=False) + db = sqlite3.connect(path) + assert {r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type='table'")} >= {"points", "fields"} + assert db.execute("SELECT COUNT(*) FROM points").fetchone()[0] == len(ROWS) and "mac" in [r[1] for r in db.execute("PRAGMA table_info(points)")] + + +def test_the_stored_unit_matches_what_the_bot_expects(tmp_path): + path = tmp_path / "eco.sqlite" + old_cache(path) + migrate_cache.migrate(path, backup=False) + HistoryCache(path, api.UNITS).close() # the signature step runs on the converted file without error diff --git a/tests/test_store.py b/tests/test_store.py index 433a9e4..7872a5b 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -70,8 +70,8 @@ def test_vpd_reported_in_inhg_is_stored_and_read_in_kpa_and_old_rows_are_convert assert fixed["outdoor"]["temperature"]["unit"] == "C" cache, now = make(tmp_path), int(time.time()) old = now - 5 * 86400 - cache.db.execute("INSERT INTO fields VALUES ('M','30min','outdoor','vpd','inHg')") - cache.db.execute("INSERT INTO points VALUES ('M','30min','outdoor','vpd',?, '0.261')", (old,)) + cache.db.execute("INSERT INTO series (id, mac, cycle, grp, field, unit) VALUES (1,'M','30min','outdoor','vpd','inHg')") + cache.db.execute("INSERT INTO points VALUES (1, ?, '0.261')", (old,)) cache.db.execute("DELETE FROM meta WHERE key = 'unit_fixes'") # as a cache from before the fix cache.db.commit() cache.close()