From 6ad20cd5d6c9de1f6f80fab96142f277c3d085e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 13:43:08 +0000 Subject: [PATCH 01/65] Simplify the button handlers: one message edit, one irrigation view, less dead code - Bot._edit (was _edit_irrigation) puts text and buttons into a pressed message for the alert, irrigation and report buttons alike; the alert handler no longer has its own copy. - Bot._irrigation_view gives a status's message and buttons together, replacing four repeated text-plus-keyboard calls; _irrigation_alerts returns both subscriptions at once. - The alert handler tells an alert from the /alerts message by the button's data (a bell carries its type) instead of by the message text. A bell of an alert type the bot no longer sends is out of date. - available_kinds is a table and takes the irrigation flag (kinds_for is gone). - Dropped: reading irrigation status messages from before the 8 Oct rewording ("pause time:"), the Refresh button's check for data it can't receive, and templates.VERSION / LABELS (only tests used them). Every keyboard in the 407-state matrix is unchanged. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- ecobot.py | 4 +-- lib/alerts/menu.py | 15 ++++------ lib/bot.py | 62 ++++++++++++++++++++-------------------- lib/irrigation/menu.py | 9 +++--- lib/templates.py | 4 --- tests/test_alert_menu.py | 35 ++++++++++++----------- tests/test_irrigation.py | 6 ++-- tests/test_templates.py | 16 +++++------ 8 files changed, 73 insertions(+), 78 deletions(-) diff --git a/ecobot.py b/ecobot.py index d4344d5..36fca36 100644 --- a/ecobot.py +++ b/ecobot.py @@ -25,7 +25,7 @@ from lib.airgradient import metrics as air_metrics from lib.alerts import (PARTICULATES_CHECK_SECONDS, AlertState, ForecastMonitor, IrrigationMonitor, Notifier, ParticulatesMonitor, PollenAndAsthmaMonitor, WeatherMonitor) -from lib.alerts.menu import LABELS, kinds_for +from lib.alerts.menu import LABELS, available_kinds from lib.bot import PREDRAW_SECONDS, Bot, polling_error from lib.ecowitt import Archive, Ecowitt from lib.forecast import Forecast @@ -143,7 +143,7 @@ async def main(): try: # any startup failure below still runs the shutdown steps (and shows the real error) log.info("@%s + %s", app.bot.username, cfg.xai_model) log.info("Sources: %s", ", ".join(s.name for s in sources)) - kinds = kinds_for((s.name for s in sources), irrigation is not None) + kinds = available_kinds((s.name for s in sources), irrigation is not None) notify = Notifier(app.bot, state) await bot.refresh_keyboards(app.bot) # chats whose buttons are out of date are told, with the new ones diff --git a/lib/alerts/menu.py b/lib/alerts/menu.py index 5614a1c..16840cb 100644 --- a/lib/alerts/menu.py +++ b/lib/alerts/menu.py @@ -29,17 +29,14 @@ MENUS = {ALERTS: "๐Ÿ”” Alerts", SOUND: "๐Ÿ”Š Sound"} # the /alerts buttons -def available_kinds(sources: set[str]) -> list[str]: - """The alert types the bot can send, from the names of its sources.""" - 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 +KINDS_OF = {"Ecowitt": ["rain", "gusts", "uv", "temps"], "AirGradient": ["particulates"], "Pollen": ["pollen_and_asthma"], + "Forecast": ["forecast"], "Irrigation": ["irrigation_battery", "irrigation_pause"]} # source -> the alert types it sends -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 available_kinds(sources, irrigation: bool = False) -> list[str]: + """The alert types the bot sends: those of its sources (by name), and the irrigation ones with a controller configured.""" + names = {*sources, *(["Irrigation"] if irrigation else [])} + return [kind for source, kinds in KINDS_OF.items() if source in names for kind in kinds] @dataclass(frozen=True) diff --git a/lib/bot.py b/lib/bot.py index 583c27f..22d2db9 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -369,10 +369,10 @@ 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.kinds_for(self.by_name, bool(self.irrigation)) + return menu.available_kinds(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 + """/alerts: the alert settings, as buttons (๐Ÿ”” Alerts and ๐Ÿ”Š Sound). /alerts on and /alerts off still subscribe or unsubscribe every type.""" msg, chat = update.effective_message, update.effective_chat in_group = chat.type != ChatType.PRIVATE @@ -425,7 +425,10 @@ async def on_alert_button(self, update: Update, context: ContextTypes.DEFAULT_TY await query.answer("Only group admins can change alerts") return kinds = self._alert_kinds() - parent = press.parent if press.parent in kinds else None + parent = press.parent # set: the buttons under an alert of that type; None: the /alerts message + if parent and parent not in kinds: # an alert of a type the bot no longer sends + await query.answer(STALE_BUTTON) + return private = chat.type == ChatType.PRIVATE toast, opened = None, press.section if press.action != "close" else None if press.action == "sound" and private and press.kind in kinds and chat.id in self.state.chats: @@ -439,27 +442,24 @@ async def on_alert_button(self, update: Update, context: ContextTypes.DEFAULT_TY return muted = self.state.muted(chat.id) loud = self.state.loud(chat.id) if private else None - markup = menu.alert_keyboard(parent, muted, loud, opened) if parent else menu.keyboard(muted, kinds, opened, loud) await query.answer(toast) - try: - if (message.text or "").startswith(menu.TITLE): # the /alerts message: keep its on/off summary current - await query.edit_message_text(menu.title(muted, kinds), reply_markup=markup) - else: # an alert: only its buttons change - await query.edit_message_reply_markup(reply_markup=markup) - except BadRequest as e: - if "not modified" not in str(e).lower(): - raise + if parent: # an alert: only its buttons change + await self._edit(query, None, menu.alert_keyboard(parent, muted, loud, opened)) + else: # the /alerts message: its on/off summary is kept current too + await self._edit(query, menu.title(muted, kinds), menu.keyboard(muted, kinds, opened, loud)) # ---------- irrigation ---------- - 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_alerts(self, chat_id: int) -> tuple[bool, bool]: + """Does this chat get the irrigation battery alerts, and the pause alerts?""" + return tuple(bool(self.state and self.state.subscribed(chat_id, kind)) for kind in ("irrigation_battery", "irrigation_pause")) def _irrigation_keyboard(self, chat_id: int, opened: str | None = None, paused: bool = False, running: bool = False): - return irrigation_menu.keyboard(self._irrigation_alerts(chat_id), self._irrigation_alerts(chat_id, "irrigation_pause"), opened, paused, running) + return irrigation_menu.keyboard(*self._irrigation_alerts(chat_id), opened, paused, running) - def _irrigation_text(self, status: dict, chat_id: int) -> str: - return irrigation_menu.status_text(status, self._irrigation_alerts(chat_id), self._irrigation_alerts(chat_id, "irrigation_pause")) + def _irrigation_view(self, status: dict, chat_id: int, opened: str | None = None): + """(the status message, its buttons) for the controller's `status`.""" + return (irrigation_menu.status_text(status, *self._irrigation_alerts(chat_id)), + self._irrigation_keyboard(chat_id, opened, irrigation_menu.is_paused(status), bool(status.get("switch")))) async def send_irrigation(self, msg: Message): """The Irrigation button: the controller's status, with its buttons.""" @@ -469,7 +469,8 @@ async def send_irrigation(self, msg: Message): log.warning("Irrigation status failed: %s", e) await msg.reply_text("Sorry, I couldn't reach the irrigation controller.") return - await msg.reply_text(self._irrigation_text(status, msg.chat_id), reply_markup=self._irrigation_keyboard(msg.chat_id, None, irrigation_menu.is_paused(status), bool(status.get("switch")))) + text, markup = self._irrigation_view(status, msg.chat_id) + await msg.reply_text(text, reply_markup=markup) async def _irrigation_act(self, action: str, arg: str, chat_id: int) -> str | None: """Do what a button asks. Returns the toast to show, or None for a button that means nothing. Raises IrrigationError.""" @@ -504,8 +505,8 @@ async def on_irrigation_button(self, update: Update, context: ContextTypes.DEFAU opened = arg if action == "open" and arg in irrigation_menu.SECTIONS else None await query.answer() # the message says it: no need to ask the controller to open a list - await self._edit_irrigation(query, None, self._irrigation_keyboard(message.chat_id, opened, irrigation_menu.paused_in(message.text), - irrigation_menu.running_in(message.text))) + await self._edit(query, None, self._irrigation_keyboard(message.chat_id, opened, irrigation_menu.paused_in(message.text), + irrigation_menu.running_in(message.text))) return if action == "refresh": # read the controller again and redraw: nothing is sent to it, so no wait try: @@ -515,8 +516,7 @@ async def on_irrigation_button(self, update: Update, context: ContextTypes.DEFAU await query.answer("Couldn't reach the irrigation controller", show_alert=True) return await query.answer("Refreshed") - await self._edit_irrigation(query, self._irrigation_text(status, message.chat_id), - self._irrigation_keyboard(message.chat_id, None, irrigation_menu.is_paused(status), bool(status.get("switch")))) + await self._edit(query, *self._irrigation_view(status, message.chat_id)) return if action == "water" and arg.isdigit() and int(arg) in irrigation_menu.WATER_MINUTES: # not while the timer is paused try: @@ -528,8 +528,9 @@ async def on_irrigation_button(self, update: Update, context: ContextTypes.DEFAU if irrigation_menu.is_paused(status): log.info("Irrigation: water %s refused, paused for %s (chat %s)", arg, status["weather_delay"], message.chat_id) await query.answer(irrigation_menu.PAUSED_ERROR) - text, entities = irrigation_menu.with_error(self._irrigation_text(status, message.chat_id)) - await self._edit_irrigation(query, text, self._irrigation_keyboard(message.chat_id, None, True, bool(status.get("switch"))), entities) # nothing was sent: no wait + text, markup = self._irrigation_view(status, message.chat_id) + text, entities = irrigation_menu.with_error(text) + await self._edit(query, text, markup, entities) # nothing was sent: no wait return try: toast = await self._irrigation_act(action, arg, message.chat_id) @@ -549,10 +550,12 @@ async def on_irrigation_button(self, update: Update, context: ContextTypes.DEFAU log.warning("Irrigation status after %s %s failed: %s", action, arg, e) # the toast said what was done; the message stays as it was return stay = "alerts" if action in ("batt", "pause") else None # the Alerts menu stays open after a toggle, to show the new label - await self._edit_irrigation(query, self._irrigation_text(status, message.chat_id), self._irrigation_keyboard(message.chat_id, stay, irrigation_menu.is_paused(status), bool(status.get("switch")))) + await self._edit(query, *self._irrigation_view(status, message.chat_id, stay)) @staticmethod - async def _edit_irrigation(query, text: str | None, markup, entities=None): + async def _edit(query, text: str | None, markup, entities=None): + """Put new text (None: the text stays) and buttons into the message a button was pressed under. Telegram refusing an edit + that changes nothing is fine.""" try: if text is None: await query.edit_message_reply_markup(reply_markup=markup) @@ -566,9 +569,6 @@ async def on_report_button(self, update: Update, context: ContextTypes.DEFAULT_T """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: @@ -578,7 +578,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(), report.heading_entities(text)) + await self._edit(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 diff --git a/lib/irrigation/menu.py b/lib/irrigation/menu.py index ede55c4..01e6221 100644 --- a/lib/irrigation/menu.py +++ b/lib/irrigation/menu.py @@ -39,11 +39,10 @@ def is_paused(status: dict) -> bool: def paused_in(text: str | None) -> bool: - """Does a status message (as status_text wrote it) say the timer is paused? For redrawing only the buttons, without asking the controller. - Messages sent before the line was reworded ("pause time: inactive") still read right.""" - line = next((l for l in (text or "").splitlines() if l.startswith(("pause:", "pause time:"))), "") - value = line.partition(":")[2].strip() - return bool(value) and not value.startswith(("not paused", "inactive")) + """Does a status message (as status_text wrote it) say the timer is paused? For redrawing only the buttons, without asking the controller.""" + line = next((l for l in (text or "").splitlines() if l.startswith("pause:")), "") + value = line.removeprefix("pause:").strip() + return bool(value) and not value.startswith("not paused") def running_in(text: str | None) -> bool: diff --git a/lib/templates.py b/lib/templates.py index 784f7a7..2259359 100644 --- a/lib/templates.py +++ b/lib/templates.py @@ -15,7 +15,6 @@ [("\U0001f3ed Air Quality", "Air quality all metrics 1d"), ("\U0001f52c Particulates", "Particulates chart 1d"), ("\U0001f4a8 Wind", "Wind chart 1d")]] SENTENCES = dict(button for row in ROWS for button in row) -LABELS = set(SENTENCES) # Telegram keeps the keyboard in each app until a message brings a new one; this changes whenever the buttons do, so a chat # whose stored value differs gets the current keyboard with its next reply. HIDDEN is what a chat that hid the buttons stores. HIDDEN = "hidden" @@ -30,9 +29,6 @@ def version(irrigation: bool = False) -> str: return hashlib.md5("|".join(label for row in rows(irrigation) for label, _ in row).encode()).hexdigest()[:8] -VERSION = version() - - def keyboard(irrigation: bool = False) -> ReplyKeyboardMarkup: return ReplyKeyboardMarkup([[label for label, _ in row] for row in rows(irrigation)], resize_keyboard=True, is_persistent=True, input_field_placeholder="Ask about the weather or airโ€ฆ") diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index 90ffe6e..6596732 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -142,6 +142,8 @@ async def edit_message_reply_markup(self, reply_markup=None): self.edits.append(("markup", reply_markup)) async def edit_message_text(self, text, reply_markup=None): + if self.fail: + raise self.fail self.edits.append(("text", text, reply_markup)) @@ -186,7 +188,7 @@ async def test_the_alerts_message_keeps_its_summary_current_but_an_alert_keeps_i q = Query("al:off:uv:alerts", text=f"{TITLE}\nโœ… On: ...") await press(bot, q) assert q.edits[0][0] == "text" and "๐Ÿ”• Off: UV" in q.edits[0][1] - q = Query("al:off:gusts:alerts", text="๐Ÿ’จ Strong gusts: 55 km/h") + q = Query("al:off:gusts:bell:gusts", text="๐Ÿ’จ Strong gusts: 55 km/h") await press(bot, q) assert q.edits[0][0] == "markup" @@ -299,28 +301,29 @@ async def test_the_bell_under_an_alert_toggles_the_alert_and_its_sound_and_stays bot, state = make_bot(tmp_path) q = Query("al:open:bell:uv", text="โ˜€๏ธ UV 10") await press(bot, q) - assert rows(q.edits[0][1]) == [[("๐Ÿ”” โ–พ", "al:close:uv")], [("Alert", "al:off:uv:bell:uv")], [(strike("Sound"), "al:snd:uv:on:uv")]] + assert rows(q.edits[0][-1]) == [[("๐Ÿ”” โ–พ", "al:close:uv")], [("Alert", "al:off:uv:bell:uv")], [(strike("Sound"), "al:snd:uv:on:uv")]] q = Query("al:snd:uv:on:uv", text="โ˜€๏ธ UV 10") await press(bot, q) - assert state.loud(1) == {"uv"} and q.toast == "UV alerts now make a sound" and rows(q.edits[0][1])[2] == [("Sound", "al:snd:uv:off:uv")] + assert state.loud(1) == {"uv"} and q.toast == "UV alerts now make a sound" and rows(q.edits[0][-1])[2] == [("Sound", "al:snd:uv:off:uv")] q = Query("al:off:uv:bell:uv", text="โ˜€๏ธ UV 10") # alert off: the sound toggle goes, the struck alert stays to turn it back on await press(bot, q) assert "uv" in state.muted(1) and q.toast == "UV alerts off in this chat" - assert rows(q.edits[0][1]) == [[("๐Ÿ”” โ–พ", "al:close:uv")], [(strike("Alert"), "al:on:uv:bell:uv")]] + assert rows(q.edits[0][-1]) == [[("๐Ÿ”” โ–พ", "al:close:uv")], [(strike("Alert"), "al:on:uv:bell:uv")]] q = Query("al:on:uv:bell:uv", text="โ˜€๏ธ UV 10") await press(bot, q) - assert "uv" not in state.muted(1) and rows(q.edits[0][1])[2] == [("Sound", "al:snd:uv:off:uv")] # the sound setting was kept + assert "uv" not in state.muted(1) and rows(q.edits[0][-1])[2] == [("Sound", "al:snd:uv:off:uv")] # the sound setting was kept q = Query("al:close:uv", text="โ˜€๏ธ UV 10") await press(bot, q) - assert rows(q.edits[0][1]) == [[("๐Ÿ”” โ–ธ", "al:open:bell:uv")]] + assert rows(q.edits[0][-1]) == [[("๐Ÿ”” โ–ธ", "al:open:bell:uv")]] -async def test_an_unknown_parent_in_a_button_is_ignored(tmp_path): - bot, state = make_bot(tmp_path) - q = Query("al:open:alerts:bogus", text="โ˜€๏ธ UV 10") - await press(bot, q) - drawn = rows(q.edits[0][1]) - assert drawn[1][0] == ("rain", "al:off:rain:alerts") # the /alerts menu, not carried on +async def test_the_bell_of_an_alert_type_the_bot_no_longer_sends_is_out_of_date(tmp_path): + from lib.bot import STALE_BUTTON + bot, state = make_bot(tmp_path, sources=("Ecowitt",)) # no pollen source any more + for data in ("al:open:bell:pollen_and_asthma", "al:off:pollen_and_asthma:bell:pollen_and_asthma"): + q = Query(data, text="๐ŸŒผ Grass pollen is High") + await press(bot, q) + assert q.toast == STALE_BUTTON and q.edits == [] and state.muted(1) == set(), data def test_the_title_stays_plain_words(): @@ -332,13 +335,13 @@ async def test_the_sound_menu_toggles_every_type_in_private_chats_only_and_back_ state.set_kind(1, "gusts", False) q = Query("al:open:sound") await press(bot, q) - drawn = rows(q.edits[0][1]) + drawn = rows(q.edits[0][-1]) assert drawn[0] == [("๐Ÿ”Š Sound โ–พ", "al:close")] and drawn[1] == [(strike("rain"), "al:snd:rain:on"), (strike("UV"), "al:snd:uv:on")] assert "gusts" not in str(drawn) # an alert that is off has no sound to set assert "Alerts" not in str(drawn) # the Alerts menu is hidden while Sound is open q = Query("al:snd:rain:on") await press(bot, q) - assert state.loud(1) == {"rain"} and q.toast == "Rain alerts now make a sound" and rows(q.edits[0][1])[1][0] == ("rain", "al:snd:rain:off") + assert state.loud(1) == {"rain"} and q.toast == "Rain alerts now make a sound" and rows(q.edits[0][-1])[1][0] == ("rain", "al:snd:rain:off") await press(bot, Query("al:snd:uv:on")) assert state.loud(1) == {"rain", "uv"} q = Query("al:snd:rain:off") @@ -349,14 +352,14 @@ async def test_the_sound_menu_toggles_every_type_in_private_chats_only_and_back_ assert state.loud(1) == {"uv"} q = Query("al:close") await press(bot, q) - assert rows(q.edits[0][1]) == [[("๐Ÿ”” Alerts โ–ธ", "al:open:alerts"), ("๐Ÿ”Š Sound โ–ธ", "al:open:sound")]] + assert rows(q.edits[0][-1]) == [[("๐Ÿ”” Alerts โ–ธ", "al:open:alerts"), ("๐Ÿ”Š Sound โ–ธ", "al:open:sound")]] q = Query("al:snd:rain:on", chat_type="supergroup", chat_id=-5) # groups have no sound settings state.add_chat(-5, "G") await press(bot, q) assert state.loud(-5) == set() and q.edits == [] q = Query("al:open:sound", chat_type="supergroup", chat_id=-5) await press(bot, q) - assert rows(q.edits[0][1]) == [[("๐Ÿ”” Alerts โ–ธ", "al:open:alerts")]] + assert rows(q.edits[0][-1]) == [[("๐Ÿ”” Alerts โ–ธ", "al:open:alerts")]] async def test_an_alert_has_a_sound_only_in_a_chat_that_turned_it_on_and_groups_get_no_settings_button(tmp_path): diff --git a/tests/test_irrigation.py b/tests/test_irrigation.py index fed4ba2..fdd25bb 100644 --- a/tests/test_irrigation.py +++ b/tests/test_irrigation.py @@ -196,7 +196,7 @@ def test_the_irrigation_button_takes_wind_s_place_only_when_configured_and_chang assert [b.text for row in plain for b in row][-1] == "\U0001f4a8 Wind" and templates.IRRIGATION not in [b.text for row in plain for b in row] labels = [b.text for row in with_irrigation for b in row] assert labels[-1] == templates.IRRIGATION and "\U0001f4a8 Wind" not in labels and len(labels) == 9 and with_irrigation[:2] == plain[:2] - assert templates.version() == templates.VERSION != templates.version(True) + assert templates.version() != templates.version(True) assert templates.sentence(templates.IRRIGATION) is None # not a question for the model @@ -543,7 +543,7 @@ async def test_the_handler_is_registered_and_the_alert_list_includes_irrigation_ 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_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 + assert bot.keyboard_version == templates.version(True) != plain.keyboard_version == templates.version() @pytest.mark.parametrize("text, yes", [ @@ -810,7 +810,7 @@ def test_a_status_message_says_whether_it_is_paused(): assert menu.paused_in(menu.status_text({"weather_delay": "72h"}, True, True)) is True assert menu.paused_in(menu.status_text({"weather_delay": "cancel"}, True, True)) is False assert menu.paused_in(menu.status_text({}, True, True)) is False and menu.paused_in("") is False and menu.paused_in(None) is False - assert menu.paused_in("mode: idle\npause time: 24h (alerts: on)") is True and menu.paused_in("pause time: inactive (alerts: on)") is False # an older message + assert menu.paused_in("mode: idle\npause: 24h (alerts: on)") is True async def test_while_paused_the_button_is_unpause_and_one_press_ends_the_pause(tmp_path): diff --git a/tests/test_templates.py b/tests/test_templates.py index fcc276e..adee013 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -13,7 +13,7 @@ def test_a_button_label_is_its_sentence_without_the_emoji_and_typed_text_is_left assert templates.sentence("\U0001f4c8 Temperature") == "Temperature chart 1d" assert templates.sentence(templates.CAPABILITIES) == "What can you do?" assert templates.sentence("report") is None and templates.sentence("Report") is None - assert len(templates.LABELS) == 9 + assert len(templates.SENTENCES) == 9 def test_every_button_asks_something_the_bot_understands(): @@ -37,7 +37,7 @@ def read(label): def test_the_keyboard_is_persistent_and_has_every_button(): kb = templates.keyboard() assert kb.is_persistent and kb.resize_keyboard - assert {b.text for row in kb.keyboard for b in row} == templates.LABELS + assert {b.text for row in kb.keyboard for b in row} == set(templates.SENTENCES) assert [len(row) for row in kb.keyboard] == [3, 3, 3] @@ -138,7 +138,7 @@ async def test_a_chat_with_an_old_or_no_keyboard_gets_the_current_one_with_its_n assert bot._keyboard_stale(msg) # never had it state.set_keyboard(1, "old1234") assert bot._keyboard_stale(msg) # the buttons changed since - state.set_keyboard(1, templates.VERSION) + state.set_keyboard(1, templates.version()) assert not bot._keyboard_stale(msg) # up to date: nothing is added state.set_keyboard(1, templates.HIDDEN) assert not bot._keyboard_stale(msg) # they hid it: leave it hidden @@ -156,11 +156,11 @@ async def test_start_and_keyboard_commands_record_which_keyboard_the_chat_has(tm assert state.keyboard(1) == templates.HIDDEN update, _ = message("/keyboard") await bot.on_keyboard(update, NS(args=[])) - assert state.keyboard(1) == templates.VERSION + assert state.keyboard(1) == templates.version() state.set_keyboard(1, "old1234") update, _ = message("/start") await bot.on_start(update, NS(args=[])) - assert state.keyboard(1) == templates.VERSION + assert state.keyboard(1) == templates.version() async def test_a_reply_in_a_private_chat_carries_the_new_keyboard_once(tmp_path): @@ -181,7 +181,7 @@ async def ask(): return sent first, second = await ask(), await ask() assert first[0][1].get("reply_markup") is not None and second[0][1].get("reply_markup") is None - assert state.keyboard(1) == templates.VERSION + assert state.keyboard(1) == templates.version() async def test_at_startup_private_chats_with_old_buttons_are_told_once_and_others_left_alone(tmp_path): @@ -191,7 +191,7 @@ async def test_at_startup_private_chats_with_old_buttons_are_told_once_and_other for chat_id in (10, 11, 12, 13, -20): # -20 is a group state.add_chat(chat_id, str(chat_id)) state.set_keyboard(10, "old1234") - state.set_keyboard(11, templates.VERSION) + state.set_keyboard(11, templates.version()) state.set_keyboard(12, templates.HIDDEN) # 13 and the group have no record sent = [] @@ -203,7 +203,7 @@ async def send_message(self, chat_id, text, **kw): bot = Bot(NS(tz=TZ), None, [], state) assert await bot.refresh_keyboards(TG()) == 1 assert sent == [(10, "Buttons updated.", True)] - assert state.keyboard(10) == templates.VERSION and 13 not in state.chats and state.keyboard(-20) is None + assert state.keyboard(10) == templates.version() and 13 not in state.chats and state.keyboard(-20) is None assert await bot.refresh_keyboards(TG()) == 0 # nothing more on the next start assert await Bot(NS(tz=TZ), None, [], None).refresh_keyboards(TG()) == 0 From cef9c49d04406944edfd4bbfdca638df31a01515 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 15:23:55 +0000 Subject: [PATCH 02/65] Ecowitt's API keys no longer reach the model or the log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a gateway error page the API called raise_for_status(), whose message quotes the request's URL, and Ecowitt takes both keys in the URL. That text became the weather tools' "Details:" and "missing" (shown to the model, which is told to say what is missing) and three warnings in the log. The error now says "HTTP 502", and any URL an error quotes has its application_key and api_key replaced by "โ€ฆ". lib: +4 โˆ’2 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/ecowitt/api.py | 6 ++++-- tests/test_ecowitt.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/lib/ecowitt/api.py b/lib/ecowitt/api.py index 8c9e6aa..4c44675 100644 --- a/lib/ecowitt/api.py +++ b/lib/ecowitt/api.py @@ -14,6 +14,7 @@ import asyncio import logging +import re from collections import deque from datetime import datetime, timedelta @@ -39,6 +40,7 @@ MIN_GAP_SECONDS = 1.0 # Ecowitt rejects requests that come too fast ("Operation too frequent") RETRY_ON = ("busy", "too frequent") FMT = "%Y-%m-%d %H:%M:%S" +KEYS_IN_URL = re.compile(r"\b(application_key|api_key)=[^&\s'\"]*") # the keys go in the query string # Fields Ecowitt reports in a unit we do not use: (field prefix, its unit) -> (the unit kept, the factor). The vapour pressure @@ -147,8 +149,7 @@ async def _get(self, path: str, urgent: bool = False, **params) -> dict | list: res = await self.client.get(f"{BASE}/{path}", params={**self.keys, **params}) body = _body(res) if body is None: # no Ecowitt answer: the network, or a gateway error page - res.raise_for_status() - raise ValueError("unreadable response") + raise ValueError(f"HTTP {res.status_code}" if not res.is_success else "unreadable response") if str(body.get("code")) == "0" and res.is_success: return body.get("data") or {} msg = str(body.get("msg") or f"HTTP {res.status_code}") @@ -156,6 +157,7 @@ async def _get(self, path: str, urgent: bool = False, **params) -> dict | list: passing = res.status_code in (408, 429) or res.status_code >= 500 or any(k in msg.lower() for k in RETRY_ON) except (httpx.HTTPError, ValueError) as e: msg, passing = str(e) or type(e).__name__, True + msg = KEYS_IN_URL.sub(r"\1=โ€ฆ", msg) # an error can quote the URL; its text is shown to the model and logged if any(k in msg.lower() for k in RETRY_ON) and attempt < BUSY_RETRIES: wait = 3 * 2 ** attempt log.info("Ecowitt says %r (%s), retrying in %.1fs", msg, path, wait) diff --git a/tests/test_ecowitt.py b/tests/test_ecowitt.py index 2ce42a0..0786c4e 100644 --- a/tests/test_ecowitt.py +++ b/tests/test_ecowitt.py @@ -456,6 +456,37 @@ async def test_http_errors_are_refused_only_when_ecowitt_answers_with_a_reason(t aio.sleep = orig +async def test_the_api_keys_never_reach_an_error_message(): + """Ecowitt takes its keys in the URL: a gateway page or a network error must not carry them into the error, which the + model is shown and the log keeps.""" + import httpx + + def gateway(request): + return httpx.Response(502, text="Bad gateway") + + def unreachable(request): + raise httpx.ConnectError(f"can't reach {request.url}", request=request) + for handler, said in ((gateway, "HTTP 502"), (unreachable, "can't reach")): + api = ecowitt_api.EcowittAPI("SECRET-API", "SECRET-APP", httpx.MockTransport(handler)) + with pytest.raises(ecowitt_api.EcowittError) as err: + await api.devices() + await api.close() + assert said in str(err.value) and "SECRET" not in str(err.value), str(err.value) + + +async def test_a_failed_history_request_tells_the_model_and_the_log_no_key(tmp_path, caplog): + import httpx + fake = FakeEcowitt() + transport = httpx.MockTransport(lambda r: httpx.Response(502, text="Bad gateway") + if r.url.path.endswith("/history") else fake(r)) + eco = Ecowitt(config(tmp_path, ecowitt_api_key="SECRET-API", ecowitt_app_key="SECRET-APP"), transport=transport) + await eco.start() + day = datetime.now(eco.tz).date() - timedelta(days=3) + out = await eco.tools[1].handler({"groups": "outdoor", "start_date": f"{day} 00:00:00", "end_date": f"{day} 23:59:59"}, Turn()) + await eco.close() + assert "HTTP 502" in out and "SECRET" not in out and "SECRET" not in caplog.text + + # ---------- wind direction is circular ---------- def test_direction_summary_crosses_north_without_a_wide_swing(): from lib.ecowitt.direction import point, summarise, vector_mean From 0ebb91041534f4cfa4657521affde96ca2096e6a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 15:26:50 +0000 Subject: [PATCH 03/65] A typo in an on/off setting keeps its default; a blank setting is its default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADMIN_ONLY read anything but on/1/true/yes as off, so ADMIN_ONLY=ture opened the bot to everyone. Every on/off setting (ADMIN_ONLY, POLLEN, FORECAST, CHART_ALL_*) now takes on/yes/true/1 or off/no/false/0, and anything else keeps the default with a warning that names the setting. A setting that was set but blank (XAI_MODEL= in the environment file) came through as "" instead of its default; it now means the default. TELEGRAM_BOT_TOKEN and XAI_API_KEY, when unset or blank, stop the bot with a message naming them instead of a KeyError. The README says how on/off settings are read. lib: +29 โˆ’15 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 2 ++ lib/config.py | 44 ++++++++++++++++++++++++++-------------- tests/test_admin_only.py | 4 +++- tests/test_startup.py | 18 ++++++++++++++++ 4 files changed, 52 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 38bfe81..e1449f8 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,8 @@ The state and caches go in `/data` (its `DATA_DIR`), here a named volume. A host | `LOG_WIDTH` | optional: the most characters of a log line, cut with an ellipsis (โ€ฆ); `0` cuts none. Default 71. Every log line obeys it, whatever its level (a warning is printed as `WARN`); a request, chat message, reply or alert is also put on one line | | `LOG_LEVEL` | optional: `DEBUG`, `INFO` (default), `WARNING` or `ERROR`. `DEBUG` adds the bot's own debug lines (the chart timings, charts it couldn't draw ahead); the libraries stay at their usual level. | +An on/off setting takes `on`, `yes`, `true` or `1`, and `off`, `no`, `false` or `0`; any other value keeps its default (and the log says so), so a typo can't switch `ADMIN_ONLY` off. A setting left blank is its default. + ## Layout ``` diff --git a/lib/config.py b/lib/config.py index d048e5c..2923eac 100644 --- a/lib/config.py +++ b/lib/config.py @@ -26,8 +26,26 @@ def _tz() -> tzinfo: return ZoneInfo(name) if name else datetime.now().astimezone().tzinfo -def _on(value: str) -> bool: - return value.lower() in ("on", "1", "true", "yes") +ON, OFF = ("on", "yes", "true", "1"), ("off", "no", "false", "0") + + +def _flag(value: str, default: bool, name: str) -> bool: + """An on/off setting: on, yes, true or 1; off, no, false or 0; unset or empty is the default. Any other value keeps the + default too, with a warning, so a typo can't switch a safeguard such as ADMIN_ONLY off.""" + if value.lower() in ON: + return True + if value.lower() in OFF: + return False + if value: + log.warning("%s: %r is not on or off; keeping the default (%s)", name, value, "on" if default else "off") + return default + + +def _required(name: str) -> str: + """A setting the bot can't run without; it stops with a message naming it when it is unset or blank.""" + if value := os.getenv(name, "").strip(): + return value + raise SystemExit(f"{name} is not set: the bot can't start without it (see the README)") def _hours(value: str, default: tuple[int, int] | None) -> tuple[int, int] | None: @@ -47,11 +65,6 @@ def _hour(value: str, default: int) -> int: return int(number) if number is not None and 0 <= number <= 23 else default -def _flag(value: str, default: bool) -> bool: - """A setting that is on or off, with a default when it is unset or empty.""" - return _on(value) if value else default - - def _ids(value: str) -> frozenset[int]: """"123, -1001 456" -> {123, -1001, 456}. An entry that is not a whole number is ignored (and logged): it must not stop the bot.""" found, bad = set(), [] @@ -146,7 +159,8 @@ def airgradient(self) -> bool: @classmethod def from_env(cls) -> "Config": - env = lambda k, default="": os.getenv(k, default).strip() + env = lambda k, default="": os.getenv(k, "").strip() or default # set but blank is the default too + flag = lambda k, default=False: _flag(env(k), default, k) data = data_dir() try: data.mkdir(parents=True, exist_ok=True) @@ -157,8 +171,8 @@ def from_env(cls) -> "Config": 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"], + telegram_token=_required("TELEGRAM_BOT_TOKEN"), + xai_api_key=_required("XAI_API_KEY"), xai_base_url=env("XAI_BASE_URL", "https://api.x.ai/v1"), xai_model=env("XAI_MODEL", "grok-4.3"), tz=_tz(), @@ -168,20 +182,20 @@ def from_env(cls) -> "Config": airgradient_location=env("AIRGRADIENT_LOCATION_ID"), airgradient_dashboard=env("AIRGRADIENT_DASHBOARD_URL"), place=env("PLACE", "Melbourne"), - pollen=_on(env("POLLEN")), + pollen=flag("POLLEN"), pollen_district=env("POLLEN_DISTRICT", "Central"), - forecast=_on(env("FORECAST")), + forecast=flag("FORECAST"), forecast_lat=_number(env("FORECAST_LAT")), forecast_lon=_number(env("FORECAST_LON")), rain_quiet_hours=_hours(env("RAIN_QUIET_HOURS"), (0, 6)), - chart_all_feels_like=_on(env("CHART_ALL_FEELS_LIKE")), - chart_all_vpd=_on(env("CHART_ALL_VPD")), + chart_all_feels_like=flag("CHART_ALL_FEELS_LIKE"), + chart_all_vpd=flag("CHART_ALL_VPD"), tuya_device_id=env("TUYA_DEVICE_ID"), tuya_client_id=env("TUYA_CLIENT_ID"), tuya_client_secret=env("TUYA_CLIENT_SECRET"), tuya_base_url=env("TUYA_BASE_URL", "https://openapi.tuyaeu.com").rstrip("/"), irrigation_check_hour=_hour(env("IRRIGATION_CHECK_HOUR"), 18), - admin_only=_flag(env("ADMIN_ONLY"), True), + admin_only=flag("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))), log_width=_whole(env("LOG_WIDTH"), 71), diff --git a/tests/test_admin_only.py b/tests/test_admin_only.py index fa87839..6a70497 100644 --- a/tests/test_admin_only.py +++ b/tests/test_admin_only.py @@ -107,9 +107,11 @@ def test_admin_only_is_on_unless_switched_off_and_the_listed_ids_are_read_safely monkeypatch.setenv(k, v) monkeypatch.delenv("ADMIN_ONLY", raising=False) assert Config.from_env().admin_only is True and Config.from_env().admin_chat_ids == frozenset() - for value, on in (("", True), ("on", True), ("ON", True), ("1", True), ("off", False), ("Off", False), ("0", False), ("no", False), ("false", False)): + for value, on in (("", True), ("on", True), ("ON", True), ("1", True), ("yes", True), ("off", False), ("Off", False), ("0", False), + ("no", False), ("false", False), ("ture", True), ("of", True)): # a typo keeps the default: never open by mistake monkeypatch.setenv("ADMIN_ONLY", value) assert Config.from_env().admin_only is on, value + assert "ADMIN_ONLY: 'ture' is not on or off; keeping the default (on)" in caplog.text assert _ids("1, -1001 22,,x, 3.5 -7") == frozenset({1, -1001, 22, -7}) and _ids("") == frozenset() assert "ADMIN_CHAT_IDS: ignoring 'x', '3.5'" in caplog.text monkeypatch.setenv("ADMIN_CHAT_IDS", "5 -9") diff --git a/tests/test_startup.py b/tests/test_startup.py index b66ad67..de8bde7 100644 --- a/tests/test_startup.py +++ b/tests/test_startup.py @@ -6,6 +6,8 @@ import signal from types import SimpleNamespace as NS +import pytest + import ecobot from lib.ecowitt import archive from datetime import datetime, timedelta, timezone @@ -187,6 +189,22 @@ def test_the_alert_cooldown_is_configurable_with_a_default_and_limits(monkeypatc +def test_a_blank_setting_means_its_default_and_a_missing_key_says_which(monkeypatch, caplog): + from lib.config import Config + for k, v in (("TELEGRAM_BOT_TOKEN", " t "), ("XAI_API_KEY", "k"), ("ECOWITT_API_KEY", "a"), ("ECOWITT_APP_KEY", "b")): + monkeypatch.setenv(k, v) + for k in ("XAI_MODEL", "XAI_BASE_URL", "PLACE", "POLLEN_DISTRICT", "TUYA_BASE_URL"): + monkeypatch.setenv(k, " ") + monkeypatch.setenv("POLLEN", "enabled") + cfg = Config.from_env() + assert (cfg.telegram_token, cfg.xai_model, cfg.xai_base_url, cfg.place, cfg.pollen_district, cfg.tuya_base_url) == ( + "t", "grok-4.3", "https://api.x.ai/v1", "Melbourne", "Central", "https://openapi.tuyaeu.com") + assert cfg.pollen is False and "POLLEN: 'enabled' is not on or off; keeping the default (off)" in caplog.text + monkeypatch.setenv("XAI_API_KEY", " ") + with pytest.raises(SystemExit, match="XAI_API_KEY is not set"): + Config.from_env() + + def test_the_rain_quiet_hours_are_configurable(): from lib.config import _hours assert _hours("", (0, 6)) == (0, 6) and _hours("22-6", (0, 6)) == (22, 6) and _hours("off", (0, 6)) is None From 15e5808685708fa24824d484bf41e1a91597607b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 15:31:14 +0000 Subject: [PATCH 04/65] The time zone is the weather station's own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With TZ unset, the zone was datetime.now().astimezone().tzinfo, a fixed offset: an hour out after a daylight-saving change until the bot restarted, and Open-Meteo was sent an abbreviation rather than a zone name. Ecowitt's device list names the station's zone (date_zone_id, "Australia/Melbourne"), which Ecowitt.start() now takes; the station starts first, and the air sensor, pollen, forecast, the bot and the alerts are built in its zone. A TZ that disagrees with the station is logged and the station's zone is used. Without a station (or with a zone Python doesn't know): TZ, else the zone /etc/localtime links to, and only then a fixed offset, with a warning to set TZ. The scripts use the same rule (local_tz) instead of their own copies; two of them assumed Melbourne when TZ was unset and now use the system's zone. The README's TZ row says when it is needed. The weather station's start-up line now names its zone. lib and ecobot.py: +35 โˆ’13 lines; scripts โˆ’6. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 2 +- ecobot.py | 28 +++++++++++++++++++-------- lib/config.py | 19 +++++++++++++++---- lib/ecowitt/source.py | 8 +++++++- scripts/check_rain.py | 6 ++---- scripts/conditions.py | 4 ++-- scripts/ecowitt_metrics.py | 4 ++-- scripts/report_inputs.py | 2 +- scripts/show_request.py | 6 ++---- tests/fakes.py | 7 +++++-- tests/test_ecowitt.py | 14 ++++++++++++++ tests/test_startup.py | 39 ++++++++++++++++++++++++++++++++++++-- 12 files changed, 108 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index e1449f8..a0baac2 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ The state and caches go in `/data` (its `DATA_DIR`), here a named volume. A host |---|---| | `TELEGRAM_BOT_TOKEN`, `XAI_API_KEY` | required | | `XAI_MODEL` (`grok-4.3`), `XAI_BASE_URL` (`https://api.x.ai/v1`) | model | -| `TZ` | e.g. `Australia/Melbourne` | +| `TZ` | only without an Ecowitt station: the station names its own time zone, which is used (a `TZ` that disagrees is logged). Without either, the system's zone (what `/etc/localtime` links to); 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. 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 | diff --git a/ecobot.py b/ecobot.py index 36fca36..9c68b35 100644 --- a/ecobot.py +++ b/ecobot.py @@ -5,6 +5,7 @@ import asyncio import contextlib +import dataclasses import logging import os import signal @@ -51,9 +52,11 @@ log = logging.getLogger("ecobot") -async def start_sources(cfg: Config) -> list: - """Every configured data source that starts: the weather station and the air sensor, then the optional website sources - (pollen, forecast), which are off unless switched on. The forecast uses the station's location unless one is configured.""" +async def start_sources(cfg: Config) -> tuple[list, Config]: + """Every configured data source that starts, and the settings in the time zone they run in. The weather station starts + first: it names its own time zone, which wins over TZ, so the air sensor and the optional website sources (pollen, + forecast, off unless switched on), and everything built after, use the station's. The forecast uses the station's + location unless one is configured.""" sources = [] async def start(source): @@ -63,14 +66,23 @@ async def start(source): except Exception: log.exception("%s couldn't start - continuing without it", source.name) await source.close() - for source in ([Ecowitt(cfg)] if cfg.ecowitt else []) + ([AirGradient(cfg)] if cfg.airgradient else []): - await start(source) + if cfg.ecowitt: + await start(Ecowitt(cfg)) + eco = sources[0] if sources else None + if eco and str(eco.tz) != str(cfg.tz): + if os.getenv("TZ"): + log.warning("TZ=%s is not the weather station's time zone; using the station's, %s", os.getenv("TZ"), eco.tz) + cfg = dataclasses.replace(cfg, tz=eco.tz) + if not getattr(cfg.tz, "key", None): + log.warning("The time zone has no name, only an offset (%s), so daylight saving will put the times out: set TZ, " + "e.g. TZ=Australia/Melbourne", cfg.tz) + if cfg.airgradient: + await start(AirGradient(cfg)) if cfg.pollen: await start(Pollen(cfg)) if cfg.forecast: - eco = next((s for s in sources if isinstance(s, Ecowitt)), None) await start(Forecast(cfg, (eco.latitude, eco.longitude) if eco and eco.latitude is not None else None)) - return sources + return sources, cfg def version() -> str: @@ -102,7 +114,7 @@ async def main(): air_metrics.REQUIRE_CONSECUTIVE = cfg.antispike_require_consecutive intent.FEELS_LIKE_IN_ALL = cfg.chart_all_feels_like intent.VPD_IN_ALL = cfg.chart_all_vpd - sources = await start_sources(cfg) + sources, cfg = await start_sources(cfg) # in the weather station's time zone from here on if not sources: raise SystemExit("No data source is working - nothing to talk about") eco = next((s for s in sources if isinstance(s, Ecowitt)), None) diff --git a/lib/config.py b/lib/config.py index 2923eac..87f2745 100644 --- a/lib/config.py +++ b/lib/config.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from datetime import datetime, tzinfo from pathlib import Path -from zoneinfo import ZoneInfo +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError ROOT = Path(__file__).resolve().parent.parent log = logging.getLogger(__name__) @@ -21,9 +21,20 @@ def data_dir() -> Path: return Path(os.path.abspath(Path(value).expanduser())) if value else ROOT -def _tz() -> tzinfo: +def local_tz() -> tzinfo: + """The time zone when no weather station names its own (Ecowitt does, and it wins): TZ, else the system's, the zone + /etc/localtime links to. Only a zone that has no name is a fixed offset, which daylight saving would put out.""" name = os.getenv("TZ", "").lstrip(":") - return ZoneInfo(name) if name else datetime.now().astimezone().tzinfo + if name: + try: + return ZoneInfo(name) + except (ValueError, ZoneInfoNotFoundError): + log.warning("TZ: %r is not a time zone name; using the system's", name) + linked = os.path.realpath("/etc/localtime") + try: + return ZoneInfo(linked.split("/zoneinfo/", 1)[1]) + except (IndexError, ValueError, ZoneInfoNotFoundError): + return datetime.now().astimezone().tzinfo ON, OFF = ("on", "yes", "true", "1"), ("off", "no", "false", "0") @@ -175,7 +186,7 @@ def from_env(cls) -> "Config": xai_api_key=_required("XAI_API_KEY"), xai_base_url=env("XAI_BASE_URL", "https://api.x.ai/v1"), xai_model=env("XAI_MODEL", "grok-4.3"), - tz=_tz(), + tz=local_tz(), ecowitt_api_key=env("ECOWITT_API_KEY"), ecowitt_app_key=env("ECOWITT_APP_KEY"), airgradient_token=env("AIRGRADIENT_API_TOKEN"), diff --git a/lib/ecowitt/source.py b/lib/ecowitt/source.py index ff4c0e6..81a47df 100644 --- a/lib/ecowitt/source.py +++ b/lib/ecowitt/source.py @@ -8,6 +8,7 @@ import os import time from datetime import datetime, timedelta, UTC +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from ..config import Config from ..intent import NOW_GROUPS @@ -139,6 +140,11 @@ async def start(self): device = devices[0] self.mac = str(device["mac"]).upper() self.station_name = device.get("name") or "station" + if zone := device.get("date_zone_id"): # the station's own time zone ("Australia/Melbourne"): its days are local to it + try: + self.tz = ZoneInfo(zone) + except (ValueError, ZoneInfoNotFoundError): + log.warning("Ecowitt names a time zone Python doesn't know, %r; using %s", zone, self.tz) try: self.created = datetime.fromtimestamp(int(device["createtime"]), UTC).astimezone(self.tz) except (KeyError, TypeError, ValueError): @@ -153,7 +159,7 @@ async def start(self): pass if (problem := PublicHolidays(self.tz).problem()) and "package" in problem: 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, + log.info("Weather station: Ecowitt '%s' (%s), %s%s", self.station_name, self.mac, self.tz, 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 diff --git a/scripts/check_rain.py b/scripts/check_rain.py index c4e5e97..6ea44ed 100644 --- a/scripts/check_rain.py +++ b/scripts/check_rain.py @@ -13,15 +13,13 @@ so days just over the 1 mm line can be missed. """ -import os import sqlite3 import sys from datetime import datetime, timedelta from pathlib import Path -from zoneinfo import ZoneInfo from _common import parser # noqa: E402 (also puts the repo on sys.path) -from lib.config import data_dir # noqa: E402 +from lib.config import data_dir, local_tz # noqa: E402 from lib.ecowitt import days as day_tool # noqa: E402 from lib.ecowitt.api import UNITS # noqa: E402 @@ -110,7 +108,7 @@ def main(): ap.add_argument("--days", type=int, default=88) ap.add_argument("--cache", type=Path, default=data_dir() / "ecowitt_cache.sqlite") args = ap.parse_args() - tz = ZoneInfo(os.getenv("TZ") or "Australia/Melbourne") + tz = local_tz() macs = [m for (m,) in sqlite3.connect(f"file:{args.cache}?mode=ro", uri=True).execute("SELECT DISTINCT mac FROM coverage")] if not macs: sys.exit(f"{args.cache} has no cached history") diff --git a/scripts/conditions.py b/scripts/conditions.py index 3b42df9..731e289 100644 --- a/scripts/conditions.py +++ b/scripts/conditions.py @@ -16,7 +16,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from lib.config import _tz # noqa: E402 +from lib.config import local_tz # noqa: E402 from lib.forecast import Forecast # noqa: E402 from lib.pollen import Pollen # noqa: E402 @@ -28,7 +28,7 @@ async def main(): ap.add_argument("--district", default="Central") ap.add_argument("--raw", action="store_true") args = ap.parse_args() - cfg = SimpleNamespace(tz=_tz(), pollen_district=args.district, forecast_lat=args.lat, forecast_lon=args.lon, + cfg = SimpleNamespace(tz=local_tz(), pollen_district=args.district, forecast_lat=args.lat, forecast_lon=args.lon, place=os.getenv("PLACE", "Melbourne"), conditions_cache_path=os.path.join(tempfile.mkdtemp(), "conditions.sqlite")) # a scratch cache: the bot's is left alone pollen = Pollen(cfg) diff --git a/scripts/ecowitt_metrics.py b/scripts/ecowitt_metrics.py index a26c5fd..6926102 100644 --- a/scripts/ecowitt_metrics.py +++ b/scripts/ecowitt_metrics.py @@ -19,7 +19,7 @@ from _common import parser # noqa: E402 (also puts the repo on sys.path) -from lib.config import _tz # noqa: E402 +from lib.config import local_tz # noqa: E402 from lib.ecowitt.api import GROUPS, EcowittAPI, EcowittError # noqa: E402 # Data groups the API's documentation (real_time and history "call_back") lists. Channels are numbered. @@ -50,7 +50,7 @@ async def probe(api: EcowittAPI, mac: str, group: str, history: bool) -> tuple[s return "no data", [], [] fields: list[str] = [] if history: - now = datetime.now(_tz()) + now = datetime.now(local_tz()) try: hist = await api.history(mac, "30min", (now - timedelta(hours=6)).replace(tzinfo=None), now.replace(tzinfo=None), group) fields = sorted(name for name, _, _ in leaves(hist.get(group) or {})) diff --git a/scripts/report_inputs.py b/scripts/report_inputs.py index 1dcd6fb..95a0b5a 100644 --- a/scripts/report_inputs.py +++ b/scripts/report_inputs.py @@ -23,7 +23,7 @@ async def main(): parser(__doc__).parse_args() cfg = Config.from_env() - sources = await start_sources(cfg) + sources, cfg = await start_sources(cfg) try: tools = Tools([t for s in sources for t in s.tools]) names = {s.name for s in sources} diff --git a/scripts/show_request.py b/scripts/show_request.py index 8ca76d7..b5072d7 100644 --- a/scripts/show_request.py +++ b/scripts/show_request.py @@ -12,16 +12,14 @@ import asyncio import json -import os import sqlite3 import sys from datetime import datetime, timedelta from pathlib import Path from types import SimpleNamespace as NS -from zoneinfo import ZoneInfo from _common import parser # noqa: E402 (also puts the repo on sys.path) -from lib.config import data_dir # noqa: E402 +from lib.config import data_dir, local_tz # noqa: E402 from lib import intent, prompt # noqa: E402 from lib.airgradient import source as air # noqa: E402 @@ -66,7 +64,7 @@ async def main(): ap.add_argument("--call", help="JSON tool call the stand-in model makes first") args = ap.parse_args() - tz = ZoneInfo(os.getenv("TZ") or "Australia/Melbourne") + tz = local_tz() now = datetime.now(tz) macs = [m for (m,) in sqlite3.connect(f"file:{args.cache}?mode=ro", uri=True).execute("SELECT DISTINCT mac FROM coverage")] if not macs: diff --git a/tests/fakes.py b/tests/fakes.py index 7e88afa..f21761d 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -76,10 +76,12 @@ def uv_index(ts: int) -> float: class FakeEcowitt: """Handles /device/list, /device/real_time and /device/history like api.ecowitt.net.""" - def __init__(self, busy_first: bool = False, history_days: int = HISTORY_DAYS): + def __init__(self, busy_first: bool = False, history_days: int = HISTORY_DAYS, zone: str | None = "Australia/Melbourne"): + """zone: the time zone the device list names (date_zone_id), None for none.""" self.calls: list[dict] = [] self.busy = busy_first self.history_days = history_days + self.zone = zone def __call__(self, request: httpx.Request) -> httpx.Response: p = dict(request.url.params) @@ -90,7 +92,8 @@ def __call__(self, request: httpx.Request) -> httpx.Response: path = request.url.path.rsplit("/", 1)[-1] if path == "list": return self._ok({"list": [{"mac": MAC.lower(), "name": "Fairleigh", "longitude": 145.0, "latitude": -37.8, - "createtime": int((datetime.now(timezone.utc) - timedelta(days=self.history_days)).timestamp())}]}) + "createtime": int((datetime.now(timezone.utc) - timedelta(days=self.history_days)).timestamp()), + **({"date_zone_id": self.zone} if self.zone else {})}]}) if path == "real_time": now = int(datetime.now(timezone.utc).timestamp()) return self._ok({"outdoor": {"temperature": {"time": str(now), "unit": "โ„ƒ", "value": "12.3"}}}) diff --git a/tests/test_ecowitt.py b/tests/test_ecowitt.py index 0786c4e..b805180 100644 --- a/tests/test_ecowitt.py +++ b/tests/test_ecowitt.py @@ -26,6 +26,20 @@ async def test_finds_the_station(station): assert "Fairleigh" in eco.describe() and "created" in eco.describe() +@pytest.mark.parametrize("zone, used", [("Australia/Perth", "Australia/Perth"), (None, "Australia/Melbourne"), + ("Mars/Olympus", "Australia/Melbourne")]) +async def test_the_station_names_its_own_time_zone(tmp_path, caplog, zone, used): + """The device list's date_zone_id is the station's zone, whatever the configured one (Melbourne here); one Python doesn't + know, or none, leaves the configured zone.""" + caplog.set_level("INFO") + transport, _ = ecowitt_transport(FakeEcowitt(zone=zone)) + eco = Ecowitt(config(tmp_path), transport=transport) + await eco.start() + await eco.close() + assert str(eco.tz) == used and str(eco.created.tzinfo) == used and f"(AA:BB:CC:DD:EE:FF), {used}" in caplog.text + assert ("Ecowitt names a time zone Python doesn't know, 'Mars/Olympus'" in caplog.text) == (zone == "Mars/Olympus") + + @pytest.mark.parametrize("msg", ["System is busy.", "Operation too frequent"]) async def test_busy_and_too_frequent_are_retried(monkeypatch, msg): import httpx diff --git a/tests/test_startup.py b/tests/test_startup.py index de8bde7..fea80c1 100644 --- a/tests/test_startup.py +++ b/tests/test_startup.py @@ -164,17 +164,52 @@ async def test_pollen_and_forecast_start_only_when_switched_on_and_the_forecast_ monkeypatch.setattr(ecobot.Pollen, "__init__", lambda self, c: pollen_init(self, c, pollen_transport(page, calls))) monkeypatch.setattr(ecobot.Forecast, "__init__", lambda self, c, place=None: forecast_init(self, c, place, forecast_transport(calls))) monkeypatch.setattr(ecobot.Pollen, "now", lambda self: datetime(2026, 11, 10, 12, 0)) # in the pollen season, whatever day the test runs - off = await ecobot.start_sources(config(tmp_path, airgradient_token="", airgradient_location="")) + off, _ = await ecobot.start_sources(config(tmp_path, airgradient_token="", airgradient_location="")) assert [s.name for s in off] == ["Ecowitt"] for s in off: await s.close() - on = await ecobot.start_sources(config(tmp_path, airgradient_token="", airgradient_location="", pollen=True, forecast=True)) + on, _ = await ecobot.start_sources(config(tmp_path, airgradient_token="", airgradient_location="", pollen=True, forecast=True)) assert [s.name for s in on] == ["Ecowitt", "Pollen", "Forecast"] assert on[2].location == (-37.8, 145.0) and on[2].lines() and on[1].lines() for s in on: await s.close() +async def test_the_other_sources_run_in_the_weather_station_s_time_zone(tmp_path, monkeypatch, caplog): + from zoneinfo import ZoneInfo + eco_t, _ = ecowitt_transport(history_days=45) + air_t, _ = air_transport(oldest=datetime.now(timezone.utc) - timedelta(days=5)) + 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.setenv("TZ", "UTC") + sources, cfg = await ecobot.start_sources(config(tmp_path, tz=ZoneInfo("UTC"))) # TZ says UTC; the station is in Melbourne + for s in sources: + await s.close() + assert [s.name for s in sources] == ["Ecowitt", "AirGradient"] and str(cfg.tz) == "Australia/Melbourne" + assert all(str(s.tz) == "Australia/Melbourne" for s in sources) + assert "TZ=UTC is not the weather station's time zone; using the station's, Australia/Melbourne" in caplog.text + assert "has no name" not in caplog.text + sources, cfg = await ecobot.start_sources(config(tmp_path, ecowitt_api_key="", tz=timezone(timedelta(hours=10)))) + for s in sources: + await s.close() + assert [s.name for s in sources] == ["AirGradient"] and cfg.tz == timezone(timedelta(hours=10)) # no station: the configured zone + assert "The time zone has no name, only an offset (UTC+10:00)" in caplog.text + + +def test_the_time_zone_without_a_station_is_tz_else_the_one_etc_localtime_links_to(monkeypatch, caplog): + from lib import config as config_module + from lib.config import local_tz + monkeypatch.setenv("TZ", "Australia/Perth") + assert str(local_tz()) == "Australia/Perth" + monkeypatch.setattr(config_module.os.path, "realpath", lambda p: "/usr/share/zoneinfo/Australia/Melbourne") + monkeypatch.setenv("TZ", "Mars/Olympus") + assert str(local_tz()) == "Australia/Melbourne" and "TZ: 'Mars/Olympus' is not a time zone name" in caplog.text + monkeypatch.delenv("TZ") + assert str(local_tz()) == "Australia/Melbourne" + monkeypatch.setattr(config_module.os.path, "realpath", lambda p: "/etc/localtime") # a copied file, not a link: no name + assert not hasattr(local_tz(), "key") + + def test_the_alert_cooldown_is_configurable_with_a_default_and_limits(monkeypatch): from lib.config import Config monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "t") From 7de032649fa8cf169e86d9328f920910516e6f72 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 15:36:39 +0000 Subject: [PATCH 05/65] Questions as phones type them, and words that only look like others MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A phone's curly apostrophe ("whatโ€™s the weather", the iPhone default) missed every answer written in code: the patterns only know the plain one. intent.read now reads โ€™ and โ€˜ as '. - "all" or "every" anywhere made an air question about every air reading: "pm2.5 chart all time" and "pm2.5 every day this week" charted all six, and "count days over PM2.5 of 90 per year all time" went to the model. "All time", and "every" or "each" before a day, week, month, year or hour, are about the period now; "aq all week" and "air quality all metrics" still mean every reading. - "coโ‚‚ chart 7d" drew a weather chart: two of the four air-word patterns knew only "co2" (and pm1 was missing from the "needs data" words). The four now share one list, built from AIR_METRICS. - "indoor dew point" and "indoor feels like" were charted and answered from the outdoor sensor. They come from the indoor one when it is named; unnamed, they stay outdoor as before. INDOOR_READINGS moves to series.py, which report.py reads too. A dump of intent.read over 2,681 questions (the evals, the intent tests' phrases and variants of them), for three source combinations: 276 of 8,043 decisions differ, every one from these four fixes. lib: +31 โˆ’21 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/intent.py | 46 ++++++++++++++++++++++++++------------------ lib/report.py | 7 ++++--- lib/series.py | 1 + tests/test_intent.py | 28 +++++++++++++++++++++++++++ tests/test_report.py | 2 ++ 5 files changed, 62 insertions(+), 22 deletions(-) diff --git a/lib/intent.py b/lib/intent.py index 1927c90..5bd8560 100644 --- a/lib/intent.py +++ b/lib/intent.py @@ -13,7 +13,7 @@ from dataclasses import dataclass, field from datetime import date, datetime, timedelta -from .series import PRESSURE_WORDS, SPECIFIC, TEMP_WORDS, WEATHER as WEATHER_READINGS +from .series import INDOOR_READINGS, PRESSURE_WORDS, SPECIFIC, TEMP_WORDS, WEATHER as WEATHER_READINGS log = logging.getLogger(__name__) @@ -39,6 +39,11 @@ def lower_effort(effort: str) -> str | None: # The two devices by name: "ecowitt" is the weather, "ag" / "airgradient" / "air gradient" the air quality _ECOWITT = r"ecowitt" _AG = r"air ?gradient|ag" +AIR_METRICS = [ # word -> metric; checked in order, so PM10 wins over PM1 + (r"pm ?2\.?5|pm25", "pm2_5"), (r"pm ?10", "pm10"), (r"pm ?1(?![0-9])", "pm1"), (r"co2|coโ‚‚", "co2"), + (r"voc\w*", "voc_index"), (r"nox", "nox_index"), +] +_AIR_METRIC = "|".join(words for words, _ in AIR_METRICS) # the words that name an air reading, for every pattern below _OTHER_THAN_AIR = rf"temp\w*|rain\w*|wind\w*|humid\w*|hot|cold|warm|pressure|weather|{_ECOWITT}" # a question that is not (only) about the air # Messages about the weather or air must fetch fresh data; anything else (thanks, chat) needn't @@ -48,7 +53,7 @@ def lower_effort(effort: str) -> str | None: rf"{_WIND}|humid\w*|dew|{_PRESSURE}|forecast\w*|umbrella|" r"highs?|lows?|max\w*|min\w*|records?|extremes?|average|chart\w*|graph\w*|plot\w*|trend\w*|" r"indoors?|outdoors?|inside|outside|conditions|feels?|today|tonight|tomorrow|yesterday|" - r"week|month|year|air|aqi|pm ?2\.?5|pm ?10|co2|voc\w*|nox|smok\w*|pollut\w*|stuffy)\b|ยฐ|" + rf"week|month|year|air|aqi|{_AIR_METRIC}|smok\w*|pollut\w*|stuffy)\b|ยฐ|" r"\b\d{1,2}(st|nd|rd|th)?\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\w*|" r"\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\w*\s+\d{1,2}\b|\b\d{1,2}/\d{1,2}\b", I) @@ -311,8 +316,8 @@ def period_hints(text: str, now: datetime) -> list[str]: r"extremes?|temperatures?|temps?)\b", I) GRAPH = re.compile(r"\b(graph\w*|chart\w*|plot\w*|trend\w*|visuali[sz]\w*)\b", I) # Anything that needs other data, a judgement, or a comparison goes the normal way -_NOT_SIMPLE = (r"rain\w*|pressure|humid\w*|uv|solar|lightning|pm ?2\.?5|pm2|pm ?10|pm ?1|" - rf"air|air quality|aqi?|co2|coโ‚‚|voc\w*|nox|smok\w*|pollut\w*|{_AG}|" +_NOT_SIMPLE = (rf"rain\w*|pressure|humid\w*|uv|solar|lightning|{_AIR_METRIC}|pm2|" + rf"air|air quality|aqi?|smok\w*|pollut\w*|{_AG}|" r"compare\w*|vs|versus|than|average|mean|median|why|how many|days (above|below|over|under)|" r"feels?|dew|forecast\w*|will|going to|tomorrow|tonight|later|now|current\w*|right now") NOT_SIMPLE = re.compile(rf"\b(wind\w*|gusts?|{_NOT_SIMPLE})\b", I) @@ -347,7 +352,8 @@ def weather_period(text: str, now: datetime) -> tuple[str, datetime, datetime] | AVERAGE = re.compile(r"\b(averages?|avg|mean)\b", I) -ALL = re.compile(r"\b(all|every\w*|each)\b", I) +# "all metrics", "everything", "aq all week" (every reading, a week), but not the period "all time" nor "every day" or "each month" +ALL = re.compile(r"\b(all(?![\s-]+time\b)|everything|(?:every|each)(?!\s+(?:days?|nights?|weeks?|months?|years?|hours?|times?)\b))\b", I) PER_DAY = re.compile(r"\b(each|every|per|by)\s+day|daily|day[- ]by[- ]day|which days|list|breakdown|one by one", I) @@ -362,7 +368,6 @@ def _named(text: str) -> dict[str, int]: FEELS_LIKE_IN_ALL = False # CHART_ALL_FEELS_LIKE=on puts the feels-like panel in "weather all week" (set from the config at startup) VPD_IN_ALL = False # CHART_ALL_VPD=on puts the vapour pressure deficit panel in "weather all week" (it is temperature and humidity combined) ALL_ORDER = ("temperature", "humidity", "solar", "pressure", "rain", "dew_point", "feels_like", "vpd", "uv", "wind") # the panels of "weather all week", top to bottom -INDOOR_READINGS = ("temperature", "humidity", "dew_point", "feels_like") # what the indoor sensor measures LEFT_OUT_OF_ALL = ("uv",) # "weather all week" leaves these out: the UV index has the same shape as solar radiation @@ -410,6 +415,15 @@ def _sides(text: str) -> list[str]: return ["indoor"] if indoor and not outdoor else ["outdoor"] if outdoor and not indoor else ["outdoor", "indoor"] +def _groups(text: str, names: list[str]) -> list[str]: + """The Ecowitt groups for these readings. Temperature and humidity come from both sensors unless one side is named; the + rest of what the indoor sensor measures (dew point, feels like) from the indoor one only when it is named.""" + groups = list(dict.fromkeys(WEATHER_READINGS[n].group for n in names)) + if "outdoor" in groups and ({"temperature", "humidity"} & set(names) or INDOOR.search(text) and set(INDOOR_READINGS) & set(names)): + groups = [g for g in groups if g != "outdoor"] + _sides(text) + return list(dict.fromkeys(groups)) + + def bare_chart_ask(text: str, now: datetime) -> bool: """Nothing but readings (or "weather") and a period: "humidity 7d", "weather 30d". Anything with a time range is a chart.""" if not _has_days(text, 2, now) or not (_named(text) or WEATHER_WORD.search(text)): @@ -432,10 +446,7 @@ def weather_chart(text: str, now: datetime) -> tuple[str, datetime, datetime, li spans = [default_span(now)] # a chart with no period is the last day if len(spans) != 1: return None - groups = list(dict.fromkeys(WEATHER_READINGS[f].group for f in fields)) - if "outdoor" in groups and {"temperature", "humidity"} & set(fields): # these two have an indoor sensor as well - groups = [g for g in groups if g != "outdoor"] + _sides(text) - return (*spans[0], list(dict.fromkeys(groups))) + return (*spans[0], _groups(text, fields)) AVERAGE_READINGS = ("temperature", "humidity", "pressure", "wind", "dew_point", "feels_like", "vpd", "solar", "uv") # a rain total has no average @@ -453,10 +464,7 @@ def weather_average(text: str, now: datetime) -> tuple[str, datetime, datetime, spans = spans_in(text, now) if len(spans) != 1: return None - groups = list(dict.fromkeys(WEATHER_READINGS[n].group for n in names)) - if "outdoor" in groups and {"temperature", "humidity"} & set(names): - groups = [g for g in groups if g != "outdoor"] + _sides(text) - return (*spans[0], list(dict.fromkeys(groups))) + return (*spans[0], _groups(text, names)) def weather_groups(text: str) -> str: @@ -470,7 +478,7 @@ def weather_groups(text: str) -> str: # ---------- air-quality fast path ---------- # "how's the air?", "what's the AQI", "report my airgradient aq". History, comparisons and mixed # weather questions go the normal way. -AIR = re.compile(rf"\b(air|aqi|aq|pm ?2\.?5|pm ?10|pm ?1|co2|voc\w*|nox|smok\w*|pollut\w*|particulates?|particles?|{_AG})\b", I) +AIR = re.compile(rf"\b(air|aqi|aq|{_AIR_METRIC}|smok\w*|pollut\w*|particulates?|particles?|{_AG})\b", I) AIR_QUALITY = re.compile(r"\bair quality\b", I) AIR_NOT_NOW = re.compile( r"\b(yesterday|overnight|last|past|week|month|year|since|earlier|this morning|was|were|been|trend\w*|" @@ -478,10 +486,6 @@ def weather_groups(text: str) -> str: r"average|highest|lowest|peak|max\w*|min\w*|record\w*|" rf"{_OTHER_THAN_AIR})\b", I) AIR_CHART_NOT = re.compile(rf"\b(compare\w*|vs|versus|than|why|forecast\w*|tomorrow|will|{_OTHER_THAN_AIR})\b", I) -AIR_METRICS = [ # word -> metric; checked in order, so PM10 wins over PM1 - (r"pm ?2\.?5|pm25", "pm2_5"), (r"pm ?10", "pm10"), (r"pm ?1(?![0-9])", "pm1"), (r"co2|coโ‚‚", "co2"), - (r"voc\w*", "voc_index"), (r"nox", "nox_index"), -] def mentions_air(text: str) -> bool: @@ -816,9 +820,13 @@ def plain_lookup(text: str, fast: tuple | None) -> tuple[str, list[str]]: return "", [] +APOSTROPHES = str.maketrans("โ€™โ€˜", "''") # a phone's typographic apostrophes ("whatโ€™s"), read as the plain one every pattern uses + + def read(text: str, now: datetime, ecowitt: bool = True, air: bool = True, pollen: bool = False, forecast: bool = False) -> Reading: """The decisions made in code for this message. A shortcut that fails is dropped: the model handles the question.""" + text = text.translate(APOSTROPHES) report = wants_report(text) calls = report_calls(ecowitt, air, pollen, forecast) if report else [] try: diff --git a/lib/report.py b/lib/report.py index 9d9b45d..580c58b 100644 --- a/lib/report.py +++ b/lib/report.py @@ -8,7 +8,7 @@ from telegram import MessageEntity from .ecowitt.glance import solar_band -from .series import WEATHER, find +from .series import INDOOR_READINGS, WEATHER, find AIR_ROWS = (("pm2_5", "PM2.5"), ("pm10", "PM10"), ("pm1", "PM1"), ("co2", "COโ‚‚"), ("voc_index", "VOC index"), ("nox_index", "NOx index")) @@ -151,9 +151,10 @@ def reading_lines(now: dict, names: list[str], sides: list[str]) -> list[str]: lines += [line for line in weather_lines(now) if line.startswith("โ€ข Rain today")] or [ f"โ€ข Rain today: {get('rainfall', 'daily') or 'none'}"] else: - text = tag(reading.group, reading.field) + group = sides[0] if name in INDOOR_READINGS and len(sides) == 1 else reading.group # "indoor dew point": that sensor's + text = tag(group, reading.field) if text: - lines.append(f"โ€ข {reading.label}: {text}") + lines.append(f"โ€ข {reading.label}{' (indoor)' if group == 'indoor' else ''}: {text}") return [line for line in lines if line] diff --git a/lib/series.py b/lib/series.py index cf2b90a..d347159 100644 --- a/lib/series.py +++ b/lib/series.py @@ -34,6 +34,7 @@ class Reading(NamedTuple): "uv": Reading("solar_and_uvi", "uvi", "UV index", "", r"uvi?|ultraviolet"), } SPECIFIC = ("dew_point", "feels_like", "vpd") # kinds of temperature and humidity, named on their own: they outrank "temperature" in a question +INDOOR_READINGS = ("temperature", "humidity", "dew_point", "feels_like") # what the indoor sensor measures as well def find_name(name: str) -> str | None: diff --git a/tests/test_intent.py b/tests/test_intent.py index 884f992..31350cd 100644 --- a/tests/test_intent.py +++ b/tests/test_intent.py @@ -529,3 +529,31 @@ def test_a_named_reading_or_an_air_chart_is_not_every_reading_and_indoors_has_on for ask in ("temperature chart 90d", "rain chart 7d", "chart air 90d", "plot pm2.5 90d"): assert not intent.wants_chart_all(ask) and intent.chart_fields(ask) == [] assert intent.chart_fields("chart all 90d indoors") == ["temperature", "humidity", "dew_point"] + + +# ---------- what a phone types, and words that only look like other things ---------- +def test_a_phone_s_curly_apostrophe_reads_as_the_plain_one(): + for plain in ("what's the weather", "what's the temperature", "show what you've got", "what's the forecast"): + curly = plain.replace("'", "โ€™") + assert intent.read(curly, NOW, pollen=True, forecast=True).fast == intent.read(plain, NOW, pollen=True, forecast=True).fast, curly + assert intent.read(curly, NOW, pollen=True, forecast=True).fast is not None, curly + + +def test_all_time_and_every_day_are_periods_not_every_air_reading(): + for text in ("pm2.5 chart all time", "pm2.5 every day this week", "highest pm2.5 of all-time"): + assert intent.air_named(text) == ["pm2_5"], text + for text in ("aq all week", "air quality all metrics 1d", "chart everything in the air 7d"): # every reading still means every one + assert intent.air_named(text) == ["pm2_5", "pm10", "pm1", "co2", "voc_index", "nox_index"], text + assert call("count days over PM2.5 of 90 per year all time")[0] == "air_days" + + +def test_co2_with_a_subscript_two_is_the_air_reading(): + assert call("coโ‚‚ chart 7d")[1]["metrics"] == ["co2"] and call("chart COโ‚‚ 1d")[0] == "air_quality" + assert intent.needs_data("coโ‚‚") and intent.needs_data("pm1") and intent.mentions_air("coโ‚‚") + + +def test_indoor_dew_point_and_feels_like_come_from_the_indoor_sensor(): + for text in ("indoor dew point chart", "indoor feels like chart 7d", "indoor dew point 90d"): + assert call(text)[1]["groups"] == "indoor", text + assert call("dew point chart")[1]["groups"] == "outdoor" # unasked, they stay the outdoor sensor's + assert call("indoor and outdoor dew point chart 7d")[1]["groups"] == "outdoor,indoor" diff --git a/tests/test_report.py b/tests/test_report.py index 42c553c..5d05728 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -182,6 +182,8 @@ 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, ["dew_point"], ["indoor"]) == ["โ€ข Dew point (indoor): 11.1 ยฐC"] # "indoor dew point" + assert report.reading_lines(now, ["feels_like"], ["outdoor", "indoor"])[0].startswith("โ€ข Feels like: ") # unasked: outdoor 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] From 7865509a9d6a752a6e32d241ba8d7f24f5e36c1c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 15:39:18 +0000 Subject: [PATCH 06/65] A temperature low just above zero keeps its label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every line panel's axis was floored at 0, so a temperature range from 0.4ยฐ up had its bottom clamped there (cutting its shaded range) and the low's pill was then dropped as "on the floor", as for 0 mm of rain or a calm hour. The floor is now only for readings that can't go below zero; a ยฐC panel's axis goes as low as its data needs. Three reference charts change, as they should: quarter gains its 0.4ยฐ low and its range is no longer cut at 0; in temperature and all_week the 4.8ยฐ low's pill sits below its point like any other low, now that the axis reaches a little below zero. lib: +1 โˆ’1 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/charts.py | 2 +- tests/charts/all_week.png | Bin 36580 -> 36361 bytes tests/charts/quarter.png | Bin 11930 -> 11325 bytes tests/charts/temperature.png | Bin 13899 -> 13683 bytes tests/test_chart_labels.py | 9 +++++++++ 5 files changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/charts.py b/lib/charts.py index 934faa0..6342e47 100644 --- a/lib/charts.py +++ b/lib/charts.py @@ -525,7 +525,7 @@ def _draw_panel(ax, p: Panel, tz: tzinfo, first: int, x0: float, x1: float, look pilled = bool(marked) and p.peaks == "pills" _pad_limits(ax, *_extent(lines, [float(r[1]) for s in lines for r in s.records.values()] if pilled else []), top=look.pad if pilled else 0.18 if marked else 0.12, bottom=look.pad if pilled else 0.12, - floor=0) + floor=None if _deg(p.unit) == "ยฐ" else 0) # a temperature can go below zero; nothing else drawn as a line can ybottom, ytop = ax.get_ylim() drawn = _draw_lines(ax, lines, tz, look, first, p.reading) if p.zones: # a rated reading: its good / poor / very poor zones behind the line (and over any rain behind it, as always) diff --git a/tests/charts/all_week.png b/tests/charts/all_week.png index 396a970cc08437700a0e715bd1fc0b5e7520089a..e87293d9bed864ae347c6c088fb4720143753326 100644 GIT binary patch literal 36361 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 literal 36580 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 VkfE~we>^ZCE2St|C2s8he*jvcL~H;6 literal 11930 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 HB=~;;&d~Co diff --git a/tests/charts/temperature.png b/tests/charts/temperature.png index 09b6189594afa555ce2d7435042af36490660957..81c917952bbc22bf2d7978198c6dd201a2890ba6 100644 GIT binary patch literal 13683 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

F6Btfnf+N-4$89~edbnjU4bhmKOY^N;$Y$F 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 DK+7st literal 13899 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 Df_JM_ diff --git a/tests/test_chart_labels.py b/tests/test_chart_labels.py index 7af43f9..410ca5e 100644 --- a/tests/test_chart_labels.py +++ b/tests/test_chart_labels.py @@ -109,3 +109,12 @@ def test_two_lows_that_end_together_at_the_right_edge_get_pills_on_opposite_side Line("Other", xs, floor, reading="humidity")], reading="humidity")]) sides = dict(annotations_of(chart)) assert {sides["45.3 %"], sides["48.0 %"]} == {"top", "bottom"}, sides # never the same side + + +def test_a_temperature_low_just_above_zero_is_labelled(): + """Only a reading that can't go below zero (rain, wind, sun, air) has its axis floored at 0 and its low on the floor left + unlabelled; a cold night of 0.4ยฐ is a record worth its label.""" + ys = [12.0, 8.0, 0.4, 6.0, 15.0, 20.0, 18.0] + chart = Chart("T", "s", [Panel("Temperature", "ยฐC", [one_line("temperature", ys).panels[0].lines[0]], reading="temperature")]) + assert "0.4ยฐ" in labels_of(chart) + assert "0" not in labels_of(one_line("wind", [3.0, 0.0, 5.0, 9.0])) # a calm low on the floor still says nothing From ba98be50476d616cf028b64448a962ea8ff9141b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 15:41:51 +0000 Subject: [PATCH 07/65] No promised chart that can't be drawn; bars below zero drawn as bars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A figure per month or year where no day matched ("highest temperature per month on days over 40ยฐ") gave every bucket None; the days tool still built a bar chart of none of them and told the model one was attached, and drawing it then failed ("zero-size array"). Now there is a chart only when some bucket has a figure, and a Panel refuses empty bars or shares when it is built, as specs.py says it does. - A bar below zero (a coldest night by month) was drawn all in the bright cap colour, from 0 down to its value. Its cap now sits at its far end, as above zero; bars above zero are drawn exactly as before. lib: +3 โˆ’3 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/charts.py | 4 ++-- lib/daytable.py | 2 +- lib/specs.py | 2 +- tests/test_days.py | 12 ++++++++++++ tests/test_specs.py | 16 ++++++++++++++++ 5 files changed, 32 insertions(+), 4 deletions(-) diff --git a/lib/charts.py b/lib/charts.py index 6342e47..956cb11 100644 --- a/lib/charts.py +++ b/lib/charts.py @@ -240,8 +240,8 @@ def _draw_bars(ax, bx, ys, width: float, axis_top: float, zorder: int, alpha: fl brighter cap, so each reads as a little column. `kw` goes to both (for rain behind a line, the transform).""" ys = np.asarray(ys, dtype=float) _columns(ax, bx, ys, width, facecolors=_mix(RAIN, 1 - alpha), zorder=zorder, **kw) - cap = axis_top * 0.014 - _columns(ax, bx, np.minimum(cap, ys), width, np.maximum(ys - cap, 0), facecolors=RAIN, alpha=0.95, zorder=zorder, **kw) + cap = np.sign(ys) * np.minimum(axis_top * 0.014, np.abs(ys)) # at the bar's far end, below zero as above it + _columns(ax, bx, cap, width, ys - cap, facecolors=RAIN, alpha=0.95, zorder=zorder, **kw) def _bars_behind(ax, bars, tz: tzinfo) -> float: diff --git a/lib/daytable.py b/lib/daytable.py index 55f833d..092bae1 100644 --- a/lib/daytable.py +++ b/lib/daytable.py @@ -184,7 +184,7 @@ def analyse(values: dict[str, dict[date, float]], shown: list[str], units: dict[ buckets[key(d)] += 1 if by and buckets: out[f"by_{by}"] = buckets - if turn is not None: # a count or figure per month or year is a bar chart, not a list + if turn is not None and any(v is not None for v in buckets.values()): # a bar chart, not a list (unless no bucket has a figure) on = f" on days with {conditions_text(where, units)}" if where and stat != "count" else "" if stat == "count": title, name, unit = (f"Days with {conditions_text(where, units)}" if where else "Days checked"), "Days", "days" diff --git a/lib/specs.py b/lib/specs.py index 5a729ba..dcb630b 100644 --- a/lib/specs.py +++ b/lib/specs.py @@ -86,7 +86,7 @@ class Panel: peaks: str = "pills" # how peaks are labelled: "pills" on the lines, "aside" in empty space, "none" def __post_init__(self): - if not (self.lines or self.bars or self.shares): + if not (self.lines or self.bars and self.bars.x or self.shares and self.shares.x): raise ValueError(f"panel {self.label!r}: nothing to draw") if self.shares and (self.lines or self.bars): raise ValueError(f"panel {self.label!r}: shares draw alone") diff --git a/tests/test_days.py b/tests/test_days.py index b51f40d..bf859ac 100644 --- a/tests/test_days.py +++ b/tests/test_days.py @@ -365,3 +365,15 @@ def test_the_field_list_is_in_the_tool_schema_once_and_a_wrong_sort_or_of_field_ from lib.daytable import validate units = {n: f[3] for n, f in days.FIELDS.items()} assert "sort_by must be one of" in validate({"sort_by": "nonsense"}, units) and "needs `of`" in validate({"stat": "sum", "of": "nonsense"}, units) + + +async def test_a_figure_per_month_with_no_matching_day_promises_no_chart(station): + """Highest temperature per month on days over 99ยฐ: every month is None, so there is no bar to draw and the model must not be + told a chart is attached.""" + from lib.tools import Turn + eco, _ = station + first, last = dates(eco, 70, 1) + turn = Turn() + out = json.loads(await eco.tools[2].handler(dict(start_date=str(first), end_date=str(last), count_only=True, group_by="month", + stat="max", of="temp_max", where=[{"field": "temp_max", "op": ">", "value": 99}]), turn)) + assert out["by_month"] and all(v is None for v in out["by_month"].values()) and "chart" not in out and turn.charts == [] diff --git a/tests/test_specs.py b/tests/test_specs.py index 7f3a4b9..35ee7d9 100644 --- a/tests/test_specs.py +++ b/tests/test_specs.py @@ -223,3 +223,19 @@ def test_a_single_panel_charts_caption_is_one_line_and_a_stacks_is_two(): assert rain_behind.caption(today) == "Humidity, Rain, since Mon 28 Sep 2026" # rain behind a line is still one panel stacked = stack([Panel("Humidity", "%", [line("H")]), Panel("Pressure", "hPa", [line("P")])], date(2026, 9, 28), today) assert stacked.caption(today) == "Humidity, Pressure\nMon 28 Sep โ€“ Sun 4 Oct 2026" # several panels keep two lines + + +def test_a_panel_of_bars_with_no_bar_is_refused_when_built(): + import pytest + from lib.specs import Bars, Panel + with pytest.raises(ValueError, match="nothing to draw"): + Panel("Rain", "mm", bars=Bars("Rain", "mm", [], [], 86400)) + + +def test_a_bar_below_zero_has_its_bright_end_at_the_bottom(monkeypatch): + from lib import charts + drawn = [] + monkeypatch.setattr(charts, "_columns", lambda ax, x, heights, width, bottom=0.0, **style: drawn.append((list(heights), bottom))) + charts._draw_bars(None, [0.0, 1.0], [-3.0, 2.0], 0.85, 10.0, 3) + heights, bottom = drawn[1] # the caps (the bodies are drawn first) + assert heights == [-0.14, 0.14] and [round(b, 2) for b in bottom] == [-2.86, 1.86] # each cap ends where its bar does From 262342ef96e6f8e5b82a317a500e02bfd6c04a1d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 15:46:02 +0000 Subject: [PATCH 08/65] Rain and rating bars keep to local days across daylight saving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bars were stepped by a fixed number of seconds from midnight of the first day. After a daylight-saving change (5 Apr, 4 Oct in Melbourne) every daily bar started at 23:00 or 01:00, 1 Mar to 30 Apr gave 62 bars for 61 days, and rain just after midnight was credited, and captioned, to the day before; 6-hourly bars moved to 1am, 7am... The bars now start at the same local clock times every day (midnight, 6am, noon, 6pm), found with a bisect, and the air rating bars (compose.rating_shares) use the same layout. A day of 23 or 25 hours has one bar the shorter or longer. Checked against the old bucketing on 5,000 random periods: identical in all 3,861 that don't cross a change; the rest differ only around it. lib: +26 โˆ’20 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/compose.py | 8 ++++---- lib/rain.py | 38 ++++++++++++++++++++++---------------- tests/test_rain.py | 19 +++++++++++++++++++ 3 files changed, 45 insertions(+), 20 deletions(-) diff --git a/lib/compose.py b/lib/compose.py index ca8040e..9b74f16 100644 --- a/lib/compose.py +++ b/lib/compose.py @@ -19,7 +19,7 @@ from .series import WEATHER, derives_range from .panels import panel_for from .specs import Panel, Shares, stack -from .rain import bar_layout, rain_bars, rain_slots +from .rain import bar_layout, bar_of, rain_bars, rain_slots from .timeutil import SLOT, day_bounds, now_local, parse_period from .tools import Tool, Turn @@ -97,11 +97,11 @@ def _error(text: str) -> str: def rating_shares(values: dict[int, float], limits: tuple[float, float], tz: tzinfo, first: date, last: date, label: str) -> Shares: """Per bar (see bar_layout): the percentage of its 30-minute readings that were good, poor and very poor.""" - origin, width, per = bar_layout(tz, first, last) + starts, width, per = bar_layout(tz, first, last) counts: dict[int, list[int]] = {} for t, v in values.items(): - k = origin + (t - origin) // width * width - counts.setdefault(k, [0, 0, 0])[zone(v, limits)] += 1 + if (k := bar_of(starts, t)) is not None: + counts.setdefault(k, [0, 0, 0])[zone(v, limits)] += 1 xs = sorted(counts) total = {k: sum(counts[k]) for k in xs} good, poor, very_poor = ([round(100 * counts[k][i] / total[k], 1) for k in xs] for i in range(3)) diff --git a/lib/rain.py b/lib/rain.py index b4c28cf..027ddc5 100644 --- a/lib/rain.py +++ b/lib/rain.py @@ -1,10 +1,11 @@ """Rain from the station's running daily total, at 30-minute slots: how much fell in each, which slots make up a spell, and the bars a chart draws. Shared by the analyses and the charts.""" -from datetime import date, timedelta, tzinfo +from bisect import bisect_right +from datetime import date, datetime, time, timedelta, tzinfo from .specs import Bars -from .timeutil import SLOT, day_bounds, to_local +from .timeutil import SLOT, day_bounds, local_epoch, to_local SPELL_GAP = 6 # slots: 3 dry hours end a rain spell SPELL_MM = 1.0 # a spell needs this much rain to count as an event @@ -38,12 +39,20 @@ def rain_spells(rain: dict[int, float], wet: list[int]) -> list[list[int]]: return [s for s in spells if sum(rain[t] for t in s) >= SPELL_MM] -def bar_layout(tz: tzinfo, first: date, last: date) -> tuple[int, int, str]: - """(origin epoch, seconds per bar, what a bar covers) for a bar chart of this period: hourly up to 4 days, 6-hourly - up to a month, daily beyond. Bars start at local midnight of the first day.""" +def bar_layout(tz: tzinfo, first: date, last: date) -> tuple[list[int], int, str]: + """(each bar's start, seconds per bar, what a bar covers) for a bar chart of this period: hourly up to 4 days, 6-hourly up + to a month, daily beyond. The bars start at the same local clock times every day (midnight, 6am ...) from midnight of the + first day, so a daylight-saving change doesn't move them (a day of 23 or 25 hours has one bar the shorter or longer).""" width = next(w for limit, w in CHART_BARS if (last - first).days + 1 <= limit) - return (day_bounds(first, tz)[0], width, - {3600: "hour", 6 * 3600: "6 hours", 86400: "day"}[width]) + starts = {local_epoch(datetime.combine(first + timedelta(days=n), time(hour)), tz) + for n in range((last - first).days + 1) for hour in range(0, 24, width // 3600)} + return sorted(starts), width, {3600: "hour", 6 * 3600: "6 hours", 86400: "day"}[width] + + +def bar_of(starts: list[int], t: int) -> int | None: + """The start of the bar that holds epoch `t` (None before the first).""" + i = bisect_right(starts, t) - 1 + return starts[i] if i >= 0 else None def rain_bars(rain: dict[int, float], tz: tzinfo, first: date, last: date, until: int | None = None, by: str | None = None, @@ -53,17 +62,14 @@ def rain_bars(rain: dict[int, float], tz: tzinfo, first: date, last: date, until own start) at the bar that holds it: a rolling last 24 hours has no bars left of its data.""" if by in ("month", "year"): # a figure per month or year was asked for: one bar for each, whatever the length return _calendar_bars(rain, tz, first, last, by) - origin, width, per = bar_layout(tz, first, last) + starts, width, per = bar_layout(tz, first, last) end = min(day_bounds(last, tz)[1], until) if until else day_bounds(last, tz)[1] - begin = origin + (since - origin) // width * width if since is not None and since > origin else origin - bars: dict[int, float] = dict.fromkeys(range(begin, end, width), 0.0) + begin = bar_of(starts, since) if since is not None and since > starts[0] else starts[0] + bars: dict[int, float] = dict.fromkeys((s for s in starts if begin <= s < end), 0.0) for t, mm in rain.items(): - if mm > 0: - k = origin + (t - origin) // width * width - if k >= begin: # rain before the period's start is not part of it - bars[k] = bars.get(k, 0.0) + mm - xs = sorted(bars) - return Bars("Rain", "mm", xs, [round(bars[k], 2) for k in xs], width, per) + if mm > 0 and begin <= t < end: # rain before the period's start is not part of it + bars[bar_of(starts, t)] += mm + return Bars("Rain", "mm", list(bars), [round(v, 2) for v in bars.values()], width, per) def _calendar_bars(rain: dict[int, float], tz: tzinfo, first: date, last: date, by: str) -> Bars: diff --git a/tests/test_rain.py b/tests/test_rain.py index eaf80b9..4da5535 100644 --- a/tests/test_rain.py +++ b/tests/test_rain.py @@ -63,3 +63,22 @@ def test_a_stacked_chart_over_a_rolling_day_with_no_rain_has_no_axis_left_of_its bars = rain_bars({}, tz, date(2026, 10, 7), date(2026, 10, 8), until=until, since=since) panel = Panel("Humidity", "%", [Line("Outdoor", xs, [60.0 + (i % 5) for i in range(len(xs))], reading="humidity")], bars=bars, reading="humidity") assert min(panel.xs) == bars.x[0] and since - min(panel.xs) < 3600 # at most the hour the data starts in + + +def test_rain_bars_stay_on_local_days_and_hours_across_a_daylight_saving_change(): + """5 Apr 2026 is 25 hours long in Melbourne and 4 Oct 23: the bars keep starting at midnight (and 6am, noon, 6pm), so rain just + after midnight is the new day's, not the day before's.""" + from datetime import date, datetime + from zoneinfo import ZoneInfo + from lib.compose import rating_shares + from lib.rain import rain_bars + from lib.timeutil import day_bounds + tz = ZoneInfo("Australia/Melbourne") + after_midnight = day_bounds(date(2026, 4, 10), tz)[0] + 1800 + days = rain_bars({after_midnight: 3.0}, tz, date(2026, 3, 1), date(2026, 4, 30)) + assert len(days.x) == 61 and all(datetime.fromtimestamp(x, tz).hour == 0 for x in days.x) + assert datetime.fromtimestamp(days.x[days.y.index(3.0)], tz).date() == date(2026, 4, 10) + hours = rain_bars({}, tz, date(2026, 10, 1), date(2026, 10, 10)) + assert {datetime.fromtimestamp(x, tz).hour for x in hours.x} == {0, 6, 12, 18} and len(hours.x) == 40 + shares = rating_shares({after_midnight: 5.0}, (12.0, 35.0), tz, date(2026, 3, 1), date(2026, 4, 30), "PM2.5 rating") + assert datetime.fromtimestamp(shares.x[0], tz) == datetime(2026, 4, 10, tzinfo=tz) From 9bd0e25108b7d734936d8e9b2edaf6cbd2e0debe Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 15:50:19 +0000 Subject: [PATCH 09/65] Charts are drawn in real time, with their ticks on local hours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit matplotlib was handed naive local times, so the time axis was wall-clock time: on the night the clocks go back (5 Apr) 2am to 3am was drawn twice and a line through it ran back on itself, and the pill code (np.interp) assumed the opposite; on the night they go forward an hour of nothing was drawn. The axis is now real time (epoch seconds over 86,400, matplotlib's own date numbers, with no datetime per point), and the zone is given only to what reads it: the tick locators and the tick labels, which stay on local hours, days and months. The drawing functions no longer take a time zone they don't use. Charts of a period with no clock change are unchanged: the week samples moved to 14-20 Sep render byte for byte the same (one pixel of rounding in the air chart). Six reference charts change because their week, or quarter, crosses 4 Oct, the night an hour is skipped: the 23-hour Sunday is now an hour narrower. A test checks the 25-hour 5 Apr. lib: +32 โˆ’35 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/charts.py | 67 ++++++++++++++++----------------- tests/charts/air.png | Bin 24390 -> 24960 bytes tests/charts/all_week.png | Bin 36361 -> 36087 bytes tests/charts/humidity_rain.png | Bin 15774 -> 16253 bytes tests/charts/quarter.png | Bin 11325 -> 11414 bytes tests/charts/temperature.png | Bin 13683 -> 13687 bytes tests/charts/wind.png | Bin 22333 -> 22454 bytes tests/test_chart_labels.py | 22 +++++++++++ 8 files changed, 54 insertions(+), 35 deletions(-) diff --git a/lib/charts.py b/lib/charts.py index 956cb11..f51a550 100644 --- a/lib/charts.py +++ b/lib/charts.py @@ -15,7 +15,7 @@ import threading import time from dataclasses import dataclass -from datetime import tzinfo +from datetime import UTC, datetime, tzinfo from statistics import fmean import matplotlib @@ -34,7 +34,6 @@ from PIL import Image # noqa: E402 from .specs import Chart, Compass, Line, Panel # noqa: E402 -from .timeutil import to_local # noqa: E402 # Palette. Slate neutrals for the furniture; one hue per reading (weather and air quality, kept clear of the green/yellow/red # rating zones); the same reading indoors is its complementary hue (opposite on the colour wheel, as a painter pairs them). @@ -160,15 +159,13 @@ def _dot(ax, x: float, y: float, colour: str, size: float = 12, edge: float = 0. markeredgewidth=edge, zorder=z)) -def _to_dt(tz: tzinfo): - """A function turning an epoch into a naive local datetime, for matplotlib's date axis.""" - return lambda t: to_local(t, tz).replace(tzinfo=None) +EPOCH_NUM = float(mdates.date2num(datetime(1970, 1, 1, tzinfo=UTC))) # matplotlib's date number of epoch 0 -def _nums(tz: tzinfo, epochs) -> np.ndarray: - """Epochs as matplotlib date numbers (local time).""" - to_dt = _to_dt(tz) - return mdates.date2num([to_dt(t) for t in epochs]) +def _nums(epochs) -> np.ndarray: + """Epochs as matplotlib date numbers: real time, so a day of 23 or 25 hours (daylight saving) is drawn that long. The ticks + and their labels are local (_time_axis).""" + return np.fromiter(epochs, dtype=float) / 86400 + EPOCH_NUM def _end_of(bx, width: float, default: float) -> float: @@ -208,14 +205,14 @@ def _extent(lines: list[Line], extra: list[float] = ()) -> tuple[float, float]: return (min([min(s.low or s.y) for s in lines] + list(extra)), max([max(s.high or s.y) for s in lines] + list(extra))) -def _draw_lines(ax, lines: list[Line], tz: tzinfo, look: Look, first: int = 0, reading: str = "") -> list[tuple]: +def _draw_lines(ax, lines: list[Line], look: Look, first: int = 0, reading: str = "") -> list[tuple]: """Each line's low-to-high range behind it, then the line (crisp, no glow or fill) and a dot on its end: [(colour, xs, ys)]. `first` is the colour index of the first line (the fallback colours cycle across a stack's panels).""" drawn = [] dense = max(len(s.x) for s in lines) > 200 for i, s in enumerate(lines, first): colour = _colour(s, i, reading) - xs, ys = _nums(tz, s.x), np.asarray(s.y, dtype=float) + xs, ys = _nums(s.x), np.asarray(s.y, dtype=float) if s.low: ax.fill_between(xs, s.low, s.high, color=colour, alpha=0.2, linewidth=0, zorder=3) w = 1.3 if len(lines) > 2 or s.low else 1.5 if dense else 2.2 # thinner where there are many lines, a band or many points @@ -244,11 +241,11 @@ def _draw_bars(ax, bx, ys, width: float, axis_top: float, zorder: int, alpha: fl _columns(ax, bx, cap, width, ys - cap, facecolors=RAIN, alpha=0.95, zorder=zorder, **kw) -def _bars_behind(ax, bars, tz: tzinfo) -> float: +def _bars_behind(ax, bars) -> float: """Bars (rain) behind the panel's lines and over its grid, on a scale of their own read on the right: the tallest reaches BARS_SHARE of the panel's height. They are drawn in the panel itself (x on its time axis, y as a share of its height), and the scale's two labels sit where a right-hand axis would put them. Returns where the last bar ends (matplotlib date number).""" - bx = _nums(tz, bars.x) + bx = _nums(bars.x) top = max([*bars.y, 1.0]) _draw_bars(ax, bx, np.asarray(bars.y, dtype=float) * (BARS_SHARE / top), bars.width / 86400 * 0.85, 1.0, 1, transform=ax.get_xaxis_transform()) @@ -286,32 +283,32 @@ def _headline(fig, title: str, subtitle: str, height: float, unit: str = ""): fig.text(AX_RECT[0], 1 - 0.52 / height, subtitle, fontsize=8.5, color=MUTED, va="center") -def _time_axis(ax, span_days: float, bars=None, tz: tzinfo | None = None): +def _time_axis(ax, span_days: float, tz: tzinfo, bars=None): """Tick positions and labels for a date axis, chosen by the length of the period. Bars of whole months or years (a count per - month or year) are labelled under the middle of each bar instead.""" - if bars and tz and bars.per in ("year", "month") and bars.x and (bars.per == "year" or len(bars.x) <= 14): - centres = _nums(tz, bars.x) + bars.width / 86400 * 0.85 / 2 + month or year) are labelled under the middle of each bar instead. The ticks fall on local hours, days and months.""" + if bars and bars.per in ("year", "month") and bars.x and (bars.per == "year" or len(bars.x) <= 14): + centres = _nums(bars.x) + bars.width / 86400 * 0.85 / 2 fmt = "%Y" if bars.per == "year" else "%b\n'%y" ax.xaxis.set_major_locator(FixedLocator(list(centres))) - ax.xaxis.set_major_formatter(FuncFormatter(lambda v, _: mdates.num2date(v).strftime(fmt))) + ax.xaxis.set_major_formatter(FuncFormatter(lambda v, _: mdates.num2date(v, tz).strftime(fmt))) return if span_days <= 1.1: - ax.xaxis.set_major_locator(mdates.HourLocator(byhour=range(0, 24, 3))) + ax.xaxis.set_major_locator(mdates.HourLocator(byhour=range(0, 24, 3), tz=tz)) fmt = "%-I%p" elif span_days <= 4: - ax.xaxis.set_major_locator(mdates.HourLocator(byhour=[0, 12])) + ax.xaxis.set_major_locator(mdates.HourLocator(byhour=[0, 12], tz=tz)) fmt = "%a %-I%p" elif span_days <= 45: - ax.xaxis.set_major_locator(mdates.DayLocator(interval=max(1, math.ceil(span_days / 8)))) + ax.xaxis.set_major_locator(mdates.DayLocator(interval=max(1, math.ceil(span_days / 8)), tz=tz)) fmt = "%a\n%-d %b" elif span_days <= 400: - ax.xaxis.set_major_locator(mdates.MonthLocator()) + ax.xaxis.set_major_locator(mdates.MonthLocator(tz=tz)) fmt = "%b\n'%y" else: - ax.xaxis.set_major_locator(mdates.MonthLocator(bymonth=[1, 4, 7, 10])) + ax.xaxis.set_major_locator(mdates.MonthLocator(bymonth=[1, 4, 7, 10], tz=tz)) fmt = "%b\n'%y" ax.xaxis.set_major_formatter(FuncFormatter( - lambda v, _: mdates.num2date(v).strftime(fmt).replace("AM", "am").replace("PM", "pm"))) + lambda v, _: mdates.num2date(v, tz).strftime(fmt).replace("AM", "am").replace("PM", "pm"))) def _legend_dots(ax_or_fig, colours: list[str], labels: list[str], **kw): @@ -319,7 +316,7 @@ def _legend_dots(ax_or_fig, colours: list[str], labels: list[str], **kw): ax_or_fig.legend(handles, labels, frameon=False, labelcolor=TEXT, handletextpad=0.2, columnspacing=1.1, **kw) -def _pills(ax, lines: list[Line], drawn: list[tuple], tz: tzinfo, x0: float, x1: float, look: Look, deg: str = ""): +def _pills(ax, lines: list[Line], drawn: list[tuple], x0: float, x1: float, look: Look, deg: str = ""): """A dot on the highest and the lowest point of each line with a pill of its value above or below it. The point is the top (bottom) of the line's band when it has one, else its true record at its actual time (a dotted stem joins it to a line that does not reach it). A low on the floor says nothing and is not labelled; pills that land close @@ -332,7 +329,7 @@ def _pills(ax, lines: list[Line], drawn: list[tuple], tz: tzinfo, x0: float, x1: if want in s.records and s.low is not None: # a banded line: the label sits on the top (bottom) of its band rx, ry = _extreme(s, want, xs) elif want in s.records: # the true record, at its actual time (may sit off an averaged line) - rx, ry = float(_nums(tz, [s.records[want][0]])[0]), float(s.records[want][1]) + rx, ry = float(_nums([s.records[want][0]])[0]), float(s.records[want][1]) rx = min(max(rx, x0), x1) else: # only some records given (wind: the strongest gust, no lowest): no label for the other continue @@ -493,12 +490,12 @@ def _end_labels(ax, drawn: list[tuple], show_floor: bool = False): bbox={"boxstyle": "round,pad=0.15", "fc": "none", "ec": "none"}) -def _draw_panel(ax, p: Panel, tz: tzinfo, first: int, x0: float, x1: float, look: Look): +def _draw_panel(ax, p: Panel, first: int, x0: float, x1: float, look: Look): """One panel: lines (with bands and zones, peaks labelled, rain behind them), rain on its own, or shares. Returns the number of colours used, where the drawing ends on the x axis and the legend's entries (colour, name).""" if p.shares: s = p.shares # the share of each bar's time in good / poor / very poor: traffic-light bars stacked to 100% - bx = _nums(tz, s.x) + bx = _nums(s.x) base = np.zeros(len(bx)) for column, colour in zip((s.good, s.poor, s.very_poor), ZONE_COLOURS): _columns(ax, bx, column, s.width / 86400 * 0.85, base, facecolors=colour, zorder=3) @@ -510,7 +507,7 @@ def _draw_panel(ax, p: Panel, tz: tzinfo, first: int, x0: float, x1: float, look return 0, _end_of(bx, s.width, x1), [] if not p.lines: # rain on its own b = p.bars - bx = _nums(tz, b.x) + bx = _nums(b.x) top = max([*b.y, 1.0]) * 1.15 bottom = min([*b.y, 0.0]) * 1.3 # a figure can be below zero (a coldest night) _draw_bars(ax, bx, b.y, b.width / 86400 * 0.85, top, 3, alpha=0.75) @@ -527,16 +524,16 @@ def _draw_panel(ax, p: Panel, tz: tzinfo, first: int, x0: float, x1: float, look top=look.pad if pilled else 0.18 if marked else 0.12, bottom=look.pad if pilled else 0.12, floor=None if _deg(p.unit) == "ยฐ" else 0) # a temperature can go below zero; nothing else drawn as a line can ybottom, ytop = ax.get_ylim() - drawn = _draw_lines(ax, lines, tz, look, first, p.reading) + drawn = _draw_lines(ax, lines, look, first, p.reading) if p.zones: # a rated reading: its good / poor / very poor zones behind the line (and over any rain behind it, as always) _shade_zones(ax, p.zones, ybottom, ytop, 0.07, 1.5 if p.bars else 0) ax.set_ylim(ybottom, ytop) if marked and p.peaks == "aside": _mark_highs(ax, [lines[i] for i in marked], [drawn[i] for i in marked], x0, x1, look) elif marked: - _pills(ax, [lines[i] for i in marked], [drawn[i] for i in marked], tz, x0, x1, look, _deg(p.unit)) + _pills(ax, [lines[i] for i in marked], [drawn[i] for i in marked], x0, x1, look, _deg(p.unit)) if p.bars: - x1 = max(x1, _bars_behind(ax, p.bars, tz)) + x1 = max(x1, _bars_behind(ax, p.bars)) if len(lines) > 1: _end_labels(ax, drawn) elif p.reading in END_LABELLED and drawn[0][2][-1] != 0: # a latest value of zero (night, calm) has nothing to say @@ -559,7 +556,7 @@ def _render(chart: Chart, tz: tzinfo, times: dict | None = None) -> bytes: body = height - HEAD_IN - FOOT_IN gap = 0.27 if n > 2 else 0.22 each = (body - gap * (n - 1)) / n - xs = _nums(tz, {t for p in panels for t in p.xs}) # each time once: only the range is wanted + xs = _nums({t for p in panels for t in p.xs}) # each time once: only the range is wanted x0, x1 = float(xs.min()), float(xs.max()) axes, used = [], 0 for i, p in enumerate(panels): @@ -571,7 +568,7 @@ def _render(chart: Chart, tz: tzinfo, times: dict | None = None) -> bytes: ax = fig.add_axes(rect, facecolor="none" if look.card else BG, sharex=axes[0] if axes else None) axes.append(ax) _style_axis(ax) - count, x1, entries = _draw_panel(ax, p, tz, used, x0, x1, look) + count, x1, entries = _draw_panel(ax, p, used, x0, x1, look) used += count if len(entries) >= look.key_from: # the colour key: beside the headline for a big chart, above the panel in a stack key = dict(loc="center right", bbox_to_anchor=(AX_RECT[0] + AX_RECT[2], 1 - 0.27 / height)) if look.in_headline else \ @@ -590,7 +587,7 @@ def _render(chart: Chart, tz: tzinfo, times: dict | None = None) -> bytes: ax.xaxis.set_visible(False) # the time is read off the bottom panel (these ticks have no length and no grid) margin = (x1 - x0) * 0.015 # room for the end dots axes[0].set_xlim(x0 - margin, x1 + margin) - _time_axis(axes[-1], x1 - x0, panels[-1].bars if not panels[-1].lines else None, tz) + _time_axis(axes[-1], x1 - x0, tz, panels[-1].bars if not panels[-1].lines else None) if chart.compass: # beside the wind line pos = axes[0].get_position() axes[0].set_position([pos.x0, pos.y0, 0.57, pos.height]) diff --git a/tests/charts/air.png b/tests/charts/air.png index a164156836851315154c98d340f713b6a51138ba..3c1b5448de9006032514e18cc456771501b0f2a2 100644 GIT binary patch literal 24960 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 XsiAMMA=MBw00000NkvXXu0mjfo^G() literal 24390 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 diff --git a/tests/charts/all_week.png b/tests/charts/all_week.png index e87293d9bed864ae347c6c088fb4720143753326..57712f96ee2689bc43da922626659e82aa471f93 100644 GIT binary patch literal 36087 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 V>dMT2W&8jD002ovPDHLkV1fdlnWX># literal 36361 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 diff --git a/tests/charts/humidity_rain.png b/tests/charts/humidity_rain.png index 3795a04afc36454adfd5ac091a1ca5032b2a6025..2b444f242a8b0828334b4934f2fdb11f00b23854 100644 GIT binary patch literal 16253 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 Y6MH_#Itq2($I}B91x@*KIjfNW0n8s3X#fBK literal 15774 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 diff --git a/tests/charts/quarter.png b/tests/charts/quarter.png index 08c3a891483b6edc92c0c76e9b1c5521fd863d68..721d9e8f329b51de0b5962cc63c9ce1e7997bc20 100644 GIT binary patch literal 11414 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 VkfE~we>^ZCE2St|C2s8he*jvcL~H;6 diff --git a/tests/charts/temperature.png b/tests/charts/temperature.png index 81c917952bbc22bf2d7978198c6dd201a2890ba6..5cb8ff260caeb7b96c9563e76ba7f150d8bb845c 100644 GIT binary patch literal 13687 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

F6Btfnf+N-4$89~edbnjU4bhmKOY^N;$Y$F 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 DK+7st diff --git a/tests/charts/wind.png b/tests/charts/wind.png index f4c1e64a93655f8abcab1200df5190ddaf690bd2..2d0fe819574f9b8ba4ea3f2e306872854bad453b 100644 GIT binary patch literal 22454 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 AeEc*U(c5N6n8)W@4Gu! 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 Y9|~%5wa~(#tpET307*qoM6N<$f&m8BT>t<8 diff --git a/tests/test_chart_labels.py b/tests/test_chart_labels.py index 410ca5e..f1abda5 100644 --- a/tests/test_chart_labels.py +++ b/tests/test_chart_labels.py @@ -118,3 +118,25 @@ def test_a_temperature_low_just_above_zero_is_labelled(): chart = Chart("T", "s", [Panel("Temperature", "ยฐC", [one_line("temperature", ys).panels[0].lines[0]], reading="temperature")]) assert "0.4ยฐ" in labels_of(chart) assert "0" not in labels_of(one_line("wind", [3.0, 0.0, 5.0, 9.0])) # a calm low on the floor still says nothing + + +def test_a_day_of_25_hours_is_drawn_25_hours_long_with_its_ticks_on_local_hours(): + """5 Apr 2026 in Melbourne: 2am to 3am happens twice. The axis is real time, so a line through it never runs backwards (on + local clock times it drew that hour twice), and the hour ticks still fall on local 0, 3, 6 ... o'clock.""" + from datetime import datetime + from zoneinfo import ZoneInfo + import matplotlib.dates as mdates + import matplotlib.pyplot as plt + import numpy as np + tz = ZoneInfo("Australia/Melbourne") + start = int(datetime(2026, 4, 5, tzinfo=tz).timestamp()) + xs = charts._nums(range(start, start + 25 * 3600, 1800)) + assert np.all(np.diff(xs) > 0) and round((xs[-1] - xs[0]) * 24, 3) == 24.5 + fig, ax = plt.subplots() + try: + ax.set_xlim(xs[0], xs[-1]) + charts._time_axis(ax, xs[-1] - xs[0], tz) + ticks = [mdates.num2date(t, tz) for t in ax.xaxis.get_major_locator()()] + finally: + plt.close(fig) + assert ticks and {t.hour for t in ticks} <= {0, 3, 6, 9, 12, 15, 18, 21} and ticks[0].day == 5 From 36762d9e043d06b81e2ab4e8d91131d6251243be Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 15:54:14 +0000 Subject: [PATCH 10/65] Rain charts include the newest rain; a past week's wind keeps its last day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rain bars and rain_total_mm came only from the disk cache, which holds 30-minute readings only up to the settle horizon, about 2.5 hours ago. Whatever fell since (in the query's own readings, from memory or Ecowitt) was left out: at 1:30am after 5 mm, the Rain button showed none. The bars now take the query's own 5- and 30-minute readings from the end of the last cached slot on (not inside it, where a reading below the slot's total would look like the midnight reset and count the day twice), or only them when nothing is cached. - Wind direction over a past period treated its last day like today: asked Ecowitt for 5-minute readings it no longer keeps (over 90 days) and dropped that day's 30-minute ones, so a week 200 days back was summarised from six days. Only today, or a yesterday still settling, is read that way now. The wind rose's own failed requests are reported in "missing" with the rest. lib: +9 โˆ’4 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/ecowitt/query.py | 15 ++++++---- tests/test_ecowitt.py | 64 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/lib/ecowitt/query.py b/lib/ecowitt/query.py index 16cd1d4..fc586cf 100644 --- a/lib/ecowitt/query.py +++ b/lib/ecowitt/query.py @@ -20,7 +20,7 @@ from ..lines import build_line from ..daytable import grouping_in from ..rain import rain_bars, rain_slots -from ..timeutil import day_bounds, daily_summary, local_date, now_local +from ..timeutil import SLOT, day_bounds, daily_summary, local_date, now_local from ..series import WEATHER, derives_range, field_of, find, find_name from ..panels import panel_for from ..specs import Bars, Chart, Compass, Line, Panel, period_text, stack @@ -210,7 +210,8 @@ async def _summarise_direction(self): if a < b: bounds[day] = a, b held = await wind.covered_each("5min", [(wind.epoch(a), wind.epoch(b)) for a, b in bounds.values()]) - fine_days = {day for day, whole in zip(bounds, held) if whole or day == self.end.date()} # today's comes from memory or one request + fine_days = {day for day, whole in zip(bounds, held) # today's (or a settling yesterday's) comes from memory or one request + if whole or day == self.end.date() and (self.now.date() - day).days <= 1} fine: dict = {} for run in consecutive(sorted(fine_days)): # a run of days is one read collect(fine, await wind.get("5min", bounds[run[0]][0], bounds[run[-1]][1]), "5min") @@ -328,8 +329,8 @@ def _answer(self, refined: int) -> str: "have no dates, and a low early on the 1st may be counted in the previous month.") if wants_chart(self.args, self.turn, self.start, self.end): self._add_charts(out, {k: v for k, v in series_out.items() if k in self.store}) - if f.errors: - out["missing"] = f.errors[:10] + if errors := f.errors + (self.wind.errors if self.wind else []): + out["missing"] = errors[:10] out["warning"] = "Some data could not be fetched; the answer may be incomplete. Say so." return json.dumps(out, ensure_ascii=False, separators=(",", ":")) @@ -498,11 +499,15 @@ def _series_entry(self, k: str, label: str | None = None) -> tuple[Line, str] | return line.spec(label or group.replace("_", " ").capitalize(), indoor=group == "indoor"), line.name async def _rain_bars(self) -> Bars: - """Rain for the chart, summed into bars. From the cached 30-minute readings; days older than those (about a year) come from + """Rain for the chart, summed into bars. From the cached 30-minute readings and, after the last of them, the query's own + (the newest hours are not in the cache yet: they are still settling); days older than those (about a year) come from the daily records, the day's total, which are approximate (10am to 10am). A figure per month or year asked for in the person's words (`by`) gets one bar for each, whatever the length of the period.""" lo, hi = self.f.epoch(self.start), self.f.epoch(self.end) (daily,) = await self.f.read(self.f.cache.slots, self.f.mac, "30min", "rainfall", ["daily"], lo, hi) + after = max(daily) + SLOT if daily else lo # the cache stops at the settle horizon: what is newer, this query read itself + daily.update({t: r["value"][0] for t, r in self.store.get("rainfall.daily", {}).get("pts", {}).items() + if t >= after and r["cycle"] in ("5min", "30min") and "value" in r}) rain = rain_slots(daily) held = {local_date(t, self.tz) for t in rain} (old,) = await self.f.read(self.f.cache.slots, self.f.mac, "1day", "rainfall", ["daily"], lo - 86400, hi + 86400) diff --git a/tests/test_ecowitt.py b/tests/test_ecowitt.py index b805180..8326470 100644 --- a/tests/test_ecowitt.py +++ b/tests/test_ecowitt.py @@ -1327,7 +1327,7 @@ async def test_rain_older_than_the_30_minute_readings_is_drawn_from_the_daily_re q.f, q.tz, q.turn = f, TZ, Turn() q.start, q.end = datetime.combine(days[0], datetime.min.time()), datetime.combine(days[-1], datetime.max.time()) q.span = q.end - q.start - q.rain_from_daily = False + q.rain_from_daily, q.store = False, {} daily = await q._rain_bars() assert q.rain_from_daily and len(daily.x) >= 59 and round(sum(daily.y), 1) == 120.0 and daily.per == "day" q.turn = Turn(text="rain by month for 2 months") @@ -1383,3 +1383,65 @@ async def lines(text): assert sorted(await lines("Temperature chart 1d")) == ["Indoor", "Outdoor"] # these two tell the lines apart, so they keep their names assert await lines("Wind chart 1d") == ["Wind"] await eco.close() + + +async def test_the_newest_rain_is_in_the_bars_though_the_cache_has_not_got_it_yet(tmp_path): + """The cache holds 30-minute readings only up to the settle horizon (now less 2.5 hours); the newer ones are the query's own + (from memory or Ecowitt) and their rain must be in the bars and the total, not left out.""" + from lib.ecowitt.fetch import Fetcher + from lib.ecowitt.query import HistoryQuery + from lib.ecowitt.store import HistoryCache + from tests.fakes import TZ + cache = HistoryCache(tmp_path / "c.sqlite", {}) + day = datetime.now(TZ).date() - timedelta(days=3) + t0 = int(datetime.combine(day, time()).replace(tzinfo=TZ).timestamp()) + cache.store("M", "30min", ["rainfall"], {"rainfall": {"daily": {"unit": "mm", "list": {str(t0 + i * 1800): "0.0" for i in range(4)}}}}, + t0, t0 + 4 * 1800 - 1) # midnight to 2am, dry + f = Fetcher(None, cache, None, "M", [], TZ) + q = HistoryQuery.__new__(HistoryQuery) + q.f, q.tz, q.turn, q.rain_from_daily = f, TZ, Turn(), False + q.start, q.end = datetime.combine(day, time()), datetime.combine(day, time(3)) + q.span = q.end - q.start + q.store = {"rainfall.daily": {"unit": "mm", "pts": {t0 + 7200 + i * 300: {"cycle": "5min", "value": (0.5 * i, f"{0.5 * i}")} + for i in range(11)}}} # 2am to 2:50am, 5 mm + bars = await q._rain_bars() + assert round(sum(bars.y), 1) == 5.0 and bars.y[2] == 5.0 # all of it in the 2am bar + + +async def test_wind_direction_over_a_past_week_keeps_its_last_day(tmp_path, archived_cache): + """A week 200 days back is cached at 30 minutes only, and Ecowitt keeps 5-minute readings for 90 days: the week's last day comes + from the 30-minute readings like the others, not asked for at 5 minutes (as today's is) and dropped.""" + import shutil + import httpx + from tests.fakes import TZ + + class Retaining(FakeEcowitt): # as Ecowitt: no 5-minute readings older than 90 days + def _history(self, p): + start = datetime.strptime(p["start_date"], "%Y-%m-%d %H:%M:%S").replace(tzinfo=TZ) + return {} if p["cycle_type"] == "5min" and (datetime.now(TZ) - start).days > 89 else FakeEcowitt._history(p) + cfg = config(tmp_path) + shutil.copy(archived_cache, cfg.cache_path) + eco = Ecowitt(cfg, transport=httpx.MockTransport(Retaining())) + await eco.start() + last = datetime.now(eco.tz).date() - timedelta(days=200) + out = json.loads(await eco.tools[1].handler({"groups": "wind", "start_date": f"{last - timedelta(days=6)} 00:00:00", + "end_date": f"{last} 23:59:59"})) + await eco.close() + assert len(out["series"]["wind.wind_direction"]["daily"]) == 7 + + +async def test_the_wind_rose_s_failed_requests_are_reported_with_the_rest(tmp_path, archived_cache, monkeypatch): + from types import SimpleNamespace + from lib.ecowitt import query + eco, _ = await archived_station(tmp_path, archived_cache) + counted = query.HistoryQuery._summarise_direction + + async def with_a_failure(self): + await counted(self) + self.wind = SimpleNamespace(errors=["5min 16 Oct 2026 - 16 Oct 2026: HTTP 502"], seconds=0.0) + monkeypatch.setattr(query.HistoryQuery, "_summarise_direction", with_a_failure) + today = datetime.now(eco.tz).date() + out = json.loads(await eco.tools[1].handler({"groups": "wind", "start_date": f"{today - timedelta(days=3)} 00:00:00", + "end_date": f"{today - timedelta(days=2)} 23:59:59"})) + await eco.close() + assert "HTTP 502" in " ".join(out.get("missing", [])) and "warning" in out From 179494b12ba75206b9b609c1d2ee03f9ee1f79d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 16:00:41 +0000 Subject: [PATCH 11/65] Ecowitt retries server errors without blocking questions; outages cost the archive less MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A 429 or 5xx counted as passing but was never asked again (only "busy" and "too frequent" were), while the request kept the turn through the whole back-off (up to 21 s), so a question's request waited behind a background one's retries. Now one rule decides: busy, rate limited or a server error is asked again (3, 6, 12 s apart); a refusal, or no answer at all, is not. The turn is given up while waiting. - During an Ecowitt outage the archive asked each failed range again group by group (8 requests for one), though only a refusal says anything about a group, and went on through every range. It now probes the groups only after a refusal, and three failures in a row end the run: the rest wait for the next. - Every fetched range replaced the one in-memory copy per cycle and group, so while the archive worked back in time the copy of the last hours, which questions are answered from, was pushed out and asked of Ecowitt again. A fresh copy that reaches later is now kept. lib: +42 โˆ’27 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/ecowitt/api.py | 48 ++++++++++++++++--------------- lib/ecowitt/archive.py | 16 +++++++---- lib/ecowitt/store.py | 5 ++++ tests/test_ecowitt.py | 64 ++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 104 insertions(+), 29 deletions(-) diff --git a/lib/ecowitt/api.py b/lib/ecowitt/api.py index 4c44675..202c1e5 100644 --- a/lib/ecowitt/api.py +++ b/lib/ecowitt/api.py @@ -135,37 +135,41 @@ async def close(self): await self.client.aclose() async def _get(self, path: str, urgent: bool = False, **params) -> dict | list: - """The response's "data". Retries when Ecowitt says it is busy or we were too quick; - raises EcowittError. urgent: someone is waiting for it, so it goes before queued background requests.""" + """The response's "data"; raises EcowittError. Asked again, up to BUSY_RETRIES times and waiting longer each time, when + Ecowitt is busy, rate limited or erring (429, 5xx), not when it refuses the request or there is no answer at all. + urgent: someone is waiting for it, so it goes before queued background requests (and the turn is free while a request + waits to be asked again).""" loop = asyncio.get_running_loop() - await self._turn.acquire(urgent) - try: - for attempt in range(BUSY_RETRIES + 1): + for attempt in range(BUSY_RETRIES + 1): + await self._turn.acquire(urgent) + try: if (pause := self._last + MIN_GAP_SECONDS - loop.time()) > 0: await asyncio.sleep(pause) self._last = loop.time() self.requests += 1 try: res = await self.client.get(f"{BASE}/{path}", params={**self.keys, **params}) + except httpx.HTTPError as e: # no answer: the network, a timeout + msg, passing, again = str(e) or type(e).__name__, True, False + else: body = _body(res) - if body is None: # no Ecowitt answer: the network, or a gateway error page - raise ValueError(f"HTTP {res.status_code}" if not res.is_success else "unreadable response") - if str(body.get("code")) == "0" and res.is_success: + if body is not None and str(body.get("code")) == "0" and res.is_success: return body.get("data") or {} - msg = str(body.get("msg") or f"HTTP {res.status_code}") - # Ecowitt answered and said no (any HTTP status): passing only if busy or rate limited - passing = res.status_code in (408, 429) or res.status_code >= 500 or any(k in msg.lower() for k in RETRY_ON) - except (httpx.HTTPError, ValueError) as e: - msg, passing = str(e) or type(e).__name__, True - msg = KEYS_IN_URL.sub(r"\1=โ€ฆ", msg) # an error can quote the URL; its text is shown to the model and logged - if any(k in msg.lower() for k in RETRY_ON) and attempt < BUSY_RETRIES: - wait = 3 * 2 ** attempt - log.info("Ecowitt says %r (%s), retrying in %.1fs", msg, path, wait) - await asyncio.sleep(wait) - continue - raise EcowittError(msg, transient=passing) - finally: - self._turn.release() + if body is None: # a gateway's error page, or something unreadable + msg = f"HTTP {res.status_code}" if not res.is_success else "unreadable response" + else: # Ecowitt said no: passing only if it is busy, rate limiting or failing itself + msg = str(body.get("msg") or f"HTTP {res.status_code}") + again = res.status_code == 429 or res.status_code >= 500 or any(k in msg.lower() for k in RETRY_ON) + passing = again or body is None or res.status_code == 408 + finally: + self._turn.release() + msg = KEYS_IN_URL.sub(r"\1=โ€ฆ", msg) # an error can quote the URL; its text is shown to the model and logged + if again and attempt < BUSY_RETRIES: + wait = 3 * 2 ** attempt + log.info("Ecowitt says %r (%s), retrying in %.1fs", msg, path, wait) + await asyncio.sleep(wait) + continue + raise EcowittError(msg, transient=passing) async def devices(self) -> list[dict]: data = await self._get("list", limit=50) diff --git a/lib/ecowitt/archive.py b/lib/ecowitt/archive.py index 88fc674..2d7dde8 100644 --- a/lib/ecowitt/archive.py +++ b/lib/ecowitt/archive.py @@ -22,6 +22,7 @@ RUN_AT = time(1, 30) PACE_SECONDS = 2.0 # gap between requests, so questions aren't starved COARSE_CYCLES = ("1day", "4hour", "30min") # 5-minute data goes day by day, first +OUTAGE_STOP = 3 # requests failing in a row (no answer, or a busy Ecowitt) that end a run: the rest wait for the next class Archive: @@ -78,21 +79,26 @@ async def run_once(self) -> tuple[int, int]: if seconds >= 60: # a short run only gets the line when it finishes log.info("Ecowitt: %d req to fetch (about %s)", len(todo), f"{seconds:.0f} s" if seconds < 90 else f"{seconds / 60:.0f} min") - fetched = failed = 0 + fetched = failed = in_a_row = 0 for i, (cycle, start, end) in enumerate(todo, 1): fetcher = self.station.fetcher(self.groups) await fetcher.get(cycle, start, end, load=False) # only to cache it - if fetcher.errors and not await self._drop_unsupported_groups(cycle, start, end): - failed += 1 + if not fetcher.errors or fetcher.rejected and await self._drop_unsupported_groups(cycle, start, end): + fetched, in_a_row = fetched + 1, 0 else: - fetched += 1 + failed += 1 + in_a_row = 0 if fetcher.rejected else in_a_row + 1 # a refusal is about this range; a timeout, about Ecowitt + if in_a_row == OUTAGE_STOP: + log.warning("Ecowitt: %d requests in a row failed (%s); the other %d wait for the next run", in_a_row, + fetcher.errors[-1], len(todo) - i) + break if i % 20 == 0 and i < len(todo): log.info("Ecowitt: %d of %d req done", i, len(todo)) await asyncio.sleep(self.pace) return fetched, failed async def _drop_unsupported_groups(self, cycle: str, start: datetime, end: datetime) -> bool: - """After a failed request, try each group alone. Groups that fail on their own are dropped + """After a request Ecowitt refused, try each group alone. Groups that fail on their own are dropped (the station probably doesn't have them). True if the range was then cached for the rest.""" if len(self.groups) < 2: return False diff --git a/lib/ecowitt/store.py b/lib/ecowitt/store.py index 27dc46d..32364f5 100644 --- a/lib/ecowitt/store.py +++ b/lib/ecowitt/store.py @@ -265,5 +265,10 @@ def get(self, mac: str, cycle: str, group: str, start: int, end: int) -> dict | return entry["fields"] if end <= entry["end"] or reaches_present else None def put(self, mac: str, cycle: str, group: str, start: int, end: int, fields: dict): + """Keep a response, unless a fresh one that reaches later is held: an old range (the archive's) is cached on disk, and must + not push out the copy of the last hours that questions are answered from.""" + held = self._entries.get((mac, cycle, group)) + if held and time.time() - held["fetched_at"] <= HOT_TTL_SECONDS and held["end"] > end: + return self._entries[(mac, cycle, group)] = {"start": start, "end": end, "fields": fields, "fetched_at": time.time()} diff --git a/tests/test_ecowitt.py b/tests/test_ecowitt.py index 8326470..8db60de 100644 --- a/tests/test_ecowitt.py +++ b/tests/test_ecowitt.py @@ -470,10 +470,12 @@ async def test_http_errors_are_refused_only_when_ecowitt_answers_with_a_reason(t aio.sleep = orig -async def test_the_api_keys_never_reach_an_error_message(): +async def test_the_api_keys_never_reach_an_error_message(monkeypatch): """Ecowitt takes its keys in the URL: a gateway page or a network error must not carry them into the error, which the model is shown and the log keeps.""" import httpx + real_sleep = asyncio.sleep + monkeypatch.setattr(ecowitt_api.asyncio, "sleep", lambda s: real_sleep(0)) # a 502 is asked again: without the waits def gateway(request): return httpx.Response(502, text="Bad gateway") @@ -488,8 +490,10 @@ def unreachable(request): assert said in str(err.value) and "SECRET" not in str(err.value), str(err.value) -async def test_a_failed_history_request_tells_the_model_and_the_log_no_key(tmp_path, caplog): +async def test_a_failed_history_request_tells_the_model_and_the_log_no_key(tmp_path, caplog, monkeypatch): import httpx + real_sleep = asyncio.sleep + monkeypatch.setattr(ecowitt_api.asyncio, "sleep", lambda s: real_sleep(0)) fake = FakeEcowitt() transport = httpx.MockTransport(lambda r: httpx.Response(502, text="Bad gateway") if r.url.path.endswith("/history") else fake(r)) @@ -501,6 +505,62 @@ async def test_a_failed_history_request_tells_the_model_and_the_log_no_key(tmp_p assert "HTTP 502" in out and "SECRET" not in out and "SECRET" not in caplog.text +async def test_a_server_error_is_asked_again_and_the_turn_is_free_while_it_waits(monkeypatch): + """A 503 is asked again after a pause, like "System is busy."; meanwhile the turn is free, so another request (a question's) + goes ahead instead of waiting behind the pause.""" + import httpx + order, first = [], {"list": True} + + def handler(request): + path = request.url.path.rsplit("/", 1)[-1] + order.append(path) + if path == "list" and first.pop("list", False): + return httpx.Response(503, text="Service unavailable") + return httpx.Response(200, json={"code": 0, "data": {"list": [{"mac": "x"}]} if path == "list" else {}}) + real_sleep = asyncio.sleep + monkeypatch.setattr(ecowitt_api.asyncio, "sleep", lambda s: real_sleep(0.05 if s >= 3 else 0)) + api = ecowitt_api.EcowittAPI("a", "b", httpx.MockTransport(handler)) + + async def later(): + await real_sleep(0.01) # once the first request has had its 503 + return await api.realtime("x", "outdoor", urgent=True) + devices, _ = await asyncio.gather(api.devices(), later()) + await api.close() + assert len(devices) == 1 and order == ["list", "real_time", "list"] + + +async def test_during_an_outage_the_archive_asks_once_a_range_and_stops_after_three(tmp_path, monkeypatch): + """A timeout says nothing about a group: no range is asked again group by group (8 requests where there was one), and three + failures in a row end the run; the rest wait for the next one.""" + import httpx + from lib.ecowitt import Archive, archive as archive_mod + monkeypatch.setattr(archive_mod, "PACE_SECONDS", 0) + monkeypatch.setattr(ecowitt_api, "MIN_GAP_SECONDS", 0) + real, asked = ecowitt_transport()[1], [] + + def handler(request): + if request.url.path.endswith("/history"): + asked.append(request.url.params["call_back"]) + raise httpx.ConnectTimeout("timed out") + return real(request) + eco = Ecowitt(config(tmp_path), transport=httpx.MockTransport(handler)) + await eco.start() + fetched, failed = await Archive(eco).run_once() + await eco.close() + assert (fetched, failed) == (0, 3) and len(asked) == 3 and all("," in groups for groups in asked) + + +def test_an_old_range_fetched_does_not_push_out_the_fresh_copy_of_the_last_hours(): + import time as _time + from lib.ecowitt.store import HotStore + hot, now = HotStore(), int(_time.time()) + hot.put("M", "5min", "outdoor", now - 3 * 3600, now, {"temperature": "recent"}) + hot.put("M", "5min", "outdoor", now - 90 * 86400, now - 89 * 86400, {"temperature": "old"}) # the archive, going back + assert hot.get("M", "5min", "outdoor", now - 3600, now) == {"temperature": "recent"} + hot.put("M", "5min", "outdoor", now - 3600, now + 1, {"temperature": "newer"}) # a refresh replaces it + assert hot.get("M", "5min", "outdoor", now - 1800, now) == {"temperature": "newer"} + + # ---------- wind direction is circular ---------- def test_direction_summary_crosses_north_without_a_wide_swing(): from lib.ecowitt.direction import point, summarise, vector_mean From b8a6a70bfeb1d857401fae02356a3c874a25e140 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 16:08:29 +0000 Subject: [PATCH 12/65] Waits, windows and dates in real time across daylight saving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subtracting two times of one zone gives their clock difference, an hour out the night the clocks change: - The nightly archive run woke an hour early or late, and twice on 5 Apr; the daily irrigation check woke at 7pm on 4 Oct. One timeutil.seconds_until(hour, minute, tz) counts real seconds for both; the archive's run time is measured with time.monotonic. - The alert checks' "last 3 hours" were 2 or 4 real hours that night: recent() now goes back in real hours (naive_local keeps `fold`, so the hour that happens twice converts back exactly). Dates the model sends with an offset ("2026-10-01T00:00:00Z") lost it: Ecowitt history read UTC midnight as local midnight, and AirGradient failed with "couldn't read the air-quality sensor (can't compare offset-naive and offset-aware datetimes)". timeutil.parse_local moves them into the local zone for both; a date AirGradient can't read is now "bad date", not a sensor fault. The notes on Ecowitt's daily records said "10am to 10am"; in daylight saving they run 11am to 11am. ecowitt.api.daily_run works it out from the zone for the period ("10am to 10am (11am to 11am in daylight saving)" when it spans both). lib: +66 โˆ’33 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/airgradient/source.py | 9 +++++--- lib/alerts/irrigation.py | 9 +++----- lib/ecowitt/api.py | 11 ++++++++- lib/ecowitt/archive.py | 15 +++++------- lib/ecowitt/days.py | 3 ++- lib/ecowitt/fetch.py | 4 ++-- lib/ecowitt/query.py | 20 ++++++++-------- lib/ecowitt/source.py | 5 ++-- lib/timeutil.py | 23 +++++++++++++++++++ tests/test_airgradient.py | 11 +++++++++ tests/test_irrigation.py | 1 + tests/test_timeutil.py | 48 +++++++++++++++++++++++++++++++++++++++ 12 files changed, 126 insertions(+), 33 deletions(-) create mode 100644 tests/test_timeutil.py diff --git a/lib/airgradient/source.py b/lib/airgradient/source.py index 294cbbc..3b61ecb 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, when +from ..timeutil import local_date, now_local, parse_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 @@ -335,8 +335,11 @@ async def history(self, start: str | None, end: str | None, chart: bool = False, metrics: list[str] | None = None, turn: Turn | None = None) -> dict: turn = turn or Turn() now = self._now() - parse = lambda s, d: datetime.fromisoformat(str(s).replace("T", " ")) if s else d - t0, t1 = parse(start, now - timedelta(days=1)), min(parse(end, now), now) + try: # a date the model sends, with or without an offset; a bad one is its mistake, not the sensor's + t0 = parse_local(start, self.tz) if start else now - timedelta(days=1) + t1 = min(parse_local(end, self.tz), now) if end else now + except ValueError as e: + return {"error": f"bad date ({e}); use 'YYYY-MM-DD HH:MM:SS'"} trimmed = t0 < t1 - timedelta(days=MAX_DAYS) t0 = max(t0, t1 - timedelta(days=MAX_DAYS)) if t0 >= t1: diff --git a/lib/alerts/irrigation.py b/lib/alerts/irrigation.py index ac3237c..6006749 100644 --- a/lib/alerts/irrigation.py +++ b/lib/alerts/irrigation.py @@ -11,10 +11,10 @@ import asyncio import logging -from datetime import datetime, timedelta +from datetime import datetime from ..ecowitt.rain import rain_amount -from ..timeutil import now_local +from ..timeutil import now_local, seconds_until from .forecast import RAIN_FROM_PCT log = logging.getLogger(__name__) @@ -41,10 +41,7 @@ def due(self, now: datetime) -> bool: return now.hour >= self.hour and self._memory().get("checked") != now.date().isoformat() def seconds_until_next(self, now: datetime) -> float: - target = now.replace(hour=self.hour, minute=0, second=0, microsecond=0) - if target <= now: - target += timedelta(days=1) - return max(1.0, (target - now).total_seconds()) + return max(1.0, seconds_until(self.hour, 0, self.tz, now)) async def run(self): """Forever: check when due, else sleep until the next check time.""" diff --git a/lib/ecowitt/api.py b/lib/ecowitt/api.py index 202c1e5..1ee6e5b 100644 --- a/lib/ecowitt/api.py +++ b/lib/ecowitt/api.py @@ -16,7 +16,7 @@ import logging import re from collections import deque -from datetime import datetime, timedelta +from datetime import UTC, date, datetime, time, timedelta, tzinfo import httpx @@ -48,6 +48,15 @@ UNIT_FIXES = {("vpd", "inHg"): ("kPa", 3.38639)} +def daily_run(tz: tzinfo, first: date, last: date) -> str: + """When Ecowitt's daily records start and end, local time ("10am to 10am"): they are UTC days, so in Melbourne they run from + 10am, or from 11am in daylight saving; a period that has both says both.""" + days = [first + timedelta(days=n) for n in range(0, (last - first).days + 1, 30)] + [last] + hours = sorted({datetime.combine(d, time(), UTC).astimezone(tz).hour for d in days}) + run = [f"{h % 12 or 12}{'am' if h < 12 else 'pm'} to {h % 12 or 12}{'am' if h < 12 else 'pm'}" for h in hours] + return run[0] if len(run) == 1 else f"{run[0]} ({run[1]} in daylight saving)" + + def fix_units(data) -> dict: """The response with any field in UNIT_FIXES converted (readings and unit), in place.""" for fields in data.values() if isinstance(data, dict) else (): diff --git a/lib/ecowitt/archive.py b/lib/ecowitt/archive.py index 2d7dde8..3c2a67c 100644 --- a/lib/ecowitt/archive.py +++ b/lib/ecowitt/archive.py @@ -11,8 +11,9 @@ import logging from collections.abc import Iterator from datetime import datetime, time, timedelta +from time import monotonic -from ..timeutil import now_local +from ..timeutil import naive_local, now_local, seconds_until from .api import MIN_GAP_SECONDS, RETENTION from .fetch import spans from .store import BUCKET_SECONDS, horizon, subtract @@ -53,7 +54,7 @@ def settled(cycle: str) -> datetime: would leave a few seconds uncovered, and refetched, every time).""" size = BUCKET_SECONDS[cycle] ts = horizon(cycle) - size - return datetime.fromtimestamp(ts - ts % size - 1, tz).replace(tzinfo=None) + return naive_local(ts - ts % size - 1, tz) for n in range((today - first_day("5min")).days, 0, -1): # finished days only day = today - timedelta(days=n) @@ -123,20 +124,16 @@ def held(self) -> str: return f"{days} days (5m/30m/4h/1d)" def _seconds_until_next_run(self) -> float: - now = datetime.now(self.station.tz) - nxt = now.replace(hour=RUN_AT.hour, minute=RUN_AT.minute, second=0, microsecond=0) - if nxt <= now: - nxt += timedelta(days=1) - return (nxt - now).total_seconds() + return seconds_until(RUN_AT.hour, RUN_AT.minute, self.station.tz) async def loop(self): """Backfill now, then again every night at RUN_AT, until cancelled.""" while True: try: - started = datetime.now() + started = monotonic() fetched, failed = await self.run_once() log.info("Ecowitt: %d req, %sheld %s, %.0fs", fetched, f"{failed} failed, " if failed else "", - self.held(), (datetime.now() - started).total_seconds()) + self.held(), monotonic() - started) except asyncio.CancelledError: raise except Exception: diff --git a/lib/ecowitt/days.py b/lib/ecowitt/days.py index 5ae6b0e..ba7179a 100644 --- a/lib/ecowitt/days.py +++ b/lib/ecowitt/days.py @@ -19,6 +19,7 @@ from ..daytable import analyse, grouped_as_asked, parameters, validate from ..timeutil import day_bounds, local_date, now_local +from .api import daily_run from .calendar import PublicHolidays from .extremes import collect from .store import HistoryCache, subtract @@ -179,7 +180,7 @@ def find_days(cache: HistoryCache, mac: str, tz: tzinfo, args: dict, now: dateti "are not in \"days\". Mention the first as a short footnote.") if any(source.get(d) == "daily" for d in checked): out["exact_from"] = label(exact_days[0]) if exact_days else None - out["note_daily"] = ("Days marked \"daily\" come from daily readings that run 10am to 10am, so their rain and dates " + out["note_daily"] = (f"Days marked \"daily\" come from daily readings that run {daily_run(tz, first, last)}, so their rain and dates " "are approximate (a morning shower can be counted on the day before). Say so briefly if any " "listed day is marked daily, or if the period reaches back before exact_from.") if AVERAGED & set(shown) and any(source.get(d) != "5min" for d in checked): diff --git a/lib/ecowitt/fetch.py b/lib/ecowitt/fetch.py index 648035c..ae0772a 100644 --- a/lib/ecowitt/fetch.py +++ b/lib/ecowitt/fetch.py @@ -7,7 +7,7 @@ import time from datetime import datetime, timedelta, tzinfo, UTC -from ..timeutil import local_epoch, to_local +from ..timeutil import local_epoch, naive_local from .api import EcowittError, MAX_SPAN, RETENTION from .store import HistoryCache, HotStore, merge as merge_intervals, subtract @@ -73,7 +73,7 @@ def epoch(self, local: datetime) -> int: return local_epoch(local, self.tz) def local(self, epoch: int) -> datetime: - return to_local(epoch, self.tz).replace(tzinfo=None) + return naive_local(epoch, self.tz) @timed async def get(self, cycle: str, start: datetime, end: datetime, refresh: bool = False, load: bool = True) -> dict: diff --git a/lib/ecowitt/query.py b/lib/ecowitt/query.py index fc586cf..f16ac7e 100644 --- a/lib/ecowitt/query.py +++ b/lib/ecowitt/query.py @@ -20,11 +20,11 @@ from ..lines import build_line from ..daytable import grouping_in from ..rain import rain_bars, rain_slots -from ..timeutil import SLOT, day_bounds, daily_summary, local_date, now_local +from ..timeutil import SLOT, day_bounds, daily_summary, local_date, now_local, parse_local from ..series import WEATHER, derives_range, field_of, find, find_name from ..panels import panel_for from ..specs import Bars, Chart, Compass, Line, Panel, period_text, stack -from .api import CYCLE_SECONDS, RETENTION +from .api import CYCLE_SECONDS, RETENTION, daily_run from .direction import SPEED_STEPS, rose as direction_rose, summarise as summarise_direction from .extremes import Ext, better, collect, daily_readings, describe_time, fold, high_of, low_of, series_extremes from .fetch import Fetcher, spans @@ -99,12 +99,12 @@ async def run(self) -> str: async def _run(self) -> str: today = self.now.date() try: - start = datetime.fromisoformat(str(self.args.get("start_date") or today).replace("T", " ")) - end = datetime.fromisoformat(str(self.args.get("end_date") or self.now).replace("T", " ")) + start = parse_local(self.args.get("start_date") or today, self.tz) + end = parse_local(self.args.get("end_date") or self.now, self.tz) except ValueError as e: return f"Error: bad date ({e}). Use 'YYYY-MM-DD HH:MM:SS'." - self.start = max(start.replace(tzinfo=None), datetime.combine(today - timedelta(days=RETENTION["1day"] - 2), time())) - self.end = min(end.replace(tzinfo=None), self.now) + self.start = max(start, datetime.combine(today - timedelta(days=RETENTION["1day"] - 2), time())) + self.end = min(end, self.now) if self.start >= self.end: return "Error: start_date must be before end_date and within the last 4 years." self.span = self.end - self.start @@ -325,8 +325,9 @@ def _answer(self, refined: int) -> str: f", {len(f.errors)} failed" if f.errors else "", refined) out = {"period": f"{self.start:%a %d %b %Y} - {self.end:%a %d %b %Y}", "series": shown if shown else series_out} if self.monthly and not self.detailed: - out["monthly_note"] = ("Monthly figures for long periods come from daily data that runs 10am-10am, so they " - "have no dates, and a low early on the 1st may be counted in the previous month.") + out["monthly_note"] = (f"Monthly figures for long periods come from daily data that runs " + f"{daily_run(self.tz, self.start.date(), self.end.date())}, so they have no dates, and a low " + "early on the 1st may be counted in the previous month.") if wants_chart(self.args, self.turn, self.start, self.end): self._add_charts(out, {k: v for k, v in series_out.items() if k in self.store}) if errors := f.errors + (self.wind.errors if self.wind else []): @@ -437,7 +438,8 @@ def _chart_spec(self, series_out: dict, field: str | None = None) -> Chart | Non keys = [k for k in series_out if k.endswith("." + field)] if keys[0] == "rainfall.daily" and self.rain_bars.x: # the day's counter is a running total: draw what fell, as columns return Chart("Rain", f"{period_text(self.start.date(), self.end.date())} ยท rain per {self.rain_bars.per}" - + (" ยท older days from daily records (10am to 10am)" if self.rain_from_daily else ""), + + (f" ยท older days from daily records ({daily_run(self.tz, self.start.date(), self.end.date())})" + if self.rain_from_daily else ""), [panel_for("rain", bars=self.rain_bars)]) field = keys[0].split(".", 1)[-1] unit = series_out[keys[0]]["unit"].replace("ยบ", "ยฐ") diff --git a/lib/ecowitt/source.py b/lib/ecowitt/source.py index 81a47df..018dc1e 100644 --- a/lib/ecowitt/source.py +++ b/lib/ecowitt/source.py @@ -12,7 +12,7 @@ from ..config import Config from ..intent import NOW_GROUPS -from ..timeutil import now_local +from ..timeutil import local_epoch, naive_local, now_local from ..tools import Tool, Turn from ..warm import FAST_REFRESH_SECONDS, Warmer from .api import EcowittAPI, GROUPS, UNITS @@ -276,8 +276,9 @@ async def readings(self, cycle: str, start: datetime, end: datetime, return readings(await self.fetcher(groups or self.groups).get(cycle, start, end)) async def recent(self, hours: float) -> list[tuple[int, dict]]: + """The last `hours` of 5-minute readings: real hours, so the night the clocks change is no longer or shorter.""" now = self.now() - return await self.readings("5min", now - timedelta(hours=hours), now) + return await self.readings("5min", naive_local(local_epoch(now, self.tz) - hours * 3600, self.tz), now) async def close(self): await self.api.close() diff --git a/lib/timeutil.py b/lib/timeutil.py index 671a33f..86d07a2 100644 --- a/lib/timeutil.py +++ b/lib/timeutil.py @@ -27,6 +27,29 @@ def local_date(ts: int, tz: tzinfo) -> date: return to_local(ts, tz).date() +def naive_local(ts: float, tz: tzinfo) -> datetime: + """An epoch as a naive local datetime. It keeps `fold`, so local_epoch gives the epoch back exactly, even in the hour that + happens twice when the clocks go back.""" + return to_local(ts, tz).replace(tzinfo=None) + + +def parse_local(text, tz: tzinfo) -> datetime: + """A date or time as sent ("2026-10-01", "2026-10-01 14:00", "2026-10-01T03:00:00Z") as a naive local time: one with an offset + is moved into tz first. ValueError for anything else.""" + dt = datetime.fromisoformat(str(text)) + return dt.astimezone(tz).replace(tzinfo=None) if dt.tzinfo else dt + + +def seconds_until(hour: int, minute: int, tz: tzinfo, now: datetime | None = None) -> float: + """Real seconds from `now` (naive local; default the present) to the next `hour`:`minute` local time. Two clock times of one zone + are an hour more or less apart than they look across a daylight-saving change; this counts the real seconds.""" + now = now or now_local(tz) + target = now.replace(hour=hour, minute=minute, second=0, microsecond=0) + if target <= now: + target += timedelta(days=1) + return target.replace(tzinfo=tz).timestamp() - now.replace(tzinfo=tz).timestamp() + + def local_epoch(local: datetime, tz: tzinfo) -> int: """A naive local datetime as an epoch.""" return int(local.replace(tzinfo=tz).timestamp()) diff --git a/tests/test_airgradient.py b/tests/test_airgradient.py index a9caccb..21d7a5f 100644 --- a/tests/test_airgradient.py +++ b/tests/test_airgradient.py @@ -426,3 +426,14 @@ def test_the_antispike_setting_is_a_number_of_readings_default_2_and_off_is_1(mo for value, want in (("3", 3), ("1", 1), ("0", 1), ("off", 1), ("junk", 2), ("", 2)): monkeypatch.setenv("ANTISPIKE_REQUIRE_CONSECUTIVE", value) assert Config.from_env().antispike_require_consecutive == want, value + + +async def test_a_date_with_an_offset_is_read_and_a_bad_one_is_not_called_a_sensor_fault(tmp_path): + """The model sometimes sends "โ€ฆZ": that is a time, not a failure to read the sensor. A date that can't be read says so.""" + from datetime import timezone as tz_ + air, _ = await make(tmp_path) + start = (datetime.now(TZ) - timedelta(days=2)).astimezone(tz_.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + out = json.loads(await air.handle({"start_date": start})) + bad = json.loads(await air.handle({"start_date": "yesterday morning"})) + await air.close() + assert "error" not in out and out["readings"] > 0 and bad["error"].startswith("bad date") diff --git a/tests/test_irrigation.py b/tests/test_irrigation.py index fdd25bb..e8a048a 100644 --- a/tests/test_irrigation.py +++ b/tests/test_irrigation.py @@ -338,6 +338,7 @@ def test_a_check_is_due_once_a_day_after_the_hour_and_the_next_is_the_following_ 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 + assert m.seconds_until_next(datetime(2026, 10, 3, 18, 0)) == 23 * 3600 # the clocks go forward that night: 23 real hours, not 24 async def test_run_checks_when_due_retries_after_a_failure_and_then_waits_for_tomorrow(tmp_path, monkeypatch): diff --git a/tests/test_timeutil.py b/tests/test_timeutil.py new file mode 100644 index 0000000..b3118b0 --- /dev/null +++ b/tests/test_timeutil.py @@ -0,0 +1,48 @@ +"""Local time across daylight saving: Melbourne skips 2am to 3am on 4 Oct 2026 and has 2am to 3am twice on 5 Apr 2026.""" + +from datetime import date, datetime +from zoneinfo import ZoneInfo + +from lib.timeutil import local_epoch, naive_local, parse_local, seconds_until + +TZ = ZoneInfo("Australia/Melbourne") + + +def test_seconds_until_a_clock_time_are_real_seconds_across_a_change(): + assert seconds_until(18, 0, TZ, datetime(2026, 10, 3, 18, 0)) == 23 * 3600 # the day the clocks go forward + assert seconds_until(1, 30, TZ, datetime(2026, 10, 4, 1, 40)) == (22 * 60 + 50) * 60 # the archive's nightly run + assert seconds_until(18, 0, TZ, datetime(2026, 4, 4, 18, 0)) == 25 * 3600 # the day they go back + assert seconds_until(18, 0, TZ, datetime(2026, 9, 8, 17, 0)) == 3600 # an ordinary day + + +def test_a_naive_local_time_keeps_which_of_the_twice_happening_hours_it_is(): + second = int(datetime(2026, 4, 5, 2, 30, fold=1, tzinfo=TZ).timestamp()) + assert local_epoch(naive_local(second, TZ), TZ) == second + + +def test_a_date_with_an_offset_is_read_as_the_local_time_it_is(): + assert parse_local("2026-10-01T00:00:00Z", TZ) == datetime(2026, 10, 1, 10, 0) # UTC midnight: 10am in Melbourne + assert parse_local("2026-12-01T00:00:00+00:00", TZ) == datetime(2026, 12, 1, 11, 0) # 11am in daylight saving + assert parse_local("2026-10-01 14:30:00", TZ) == datetime(2026, 10, 1, 14, 30) and parse_local(date(2026, 10, 1), TZ) == datetime(2026, 10, 1) + + +def test_ecowitts_daily_records_say_when_they_run_in_local_time(): + from lib.ecowitt.api import daily_run + assert daily_run(TZ, date(2026, 6, 1), date(2026, 8, 31)) == "10am to 10am" + assert daily_run(TZ, date(2026, 12, 1), date(2027, 2, 28)) == "11am to 11am" + assert daily_run(TZ, date(2026, 8, 1), date(2026, 11, 30)) == "10am to 10am (11am to 11am in daylight saving)" + + +async def test_the_last_hours_are_real_hours_the_night_the_clocks_go_forward(tmp_path): + from lib.ecowitt import Ecowitt + from tests.fakes import config + eco = Ecowitt(config(tmp_path)) + asked = [] + + async def readings(cycle, start, end, groups=None): + asked.append((start, end)) + return [] + eco.readings, eco.now = readings, (lambda: datetime(2026, 10, 4, 4, 0)) + await eco.recent(3) + await eco.close() + assert asked == [(datetime(2026, 10, 4, 0, 0), datetime(2026, 10, 4, 4, 0))] # three real hours: 2am to 3am never happened From bb94a9cb823260f1cf6d388ffd888745e86c29be Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 16:11:36 +0000 Subject: [PATCH 13/65] A failed cache write is rolled back; day figures read each field once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HistoryCache.store committed by hand at the end, so an error part-way (after the readings went in, before the coverage) left the transaction open, and the next save committed the half-written response. It is one transaction now (with self._lock, self.db:): an error rolls it back. - The days tool loaded the same rows once per reading that uses them (temp_max, temp_min and temp_avg each read outdoor temperature) and held every field's readings at once, as dicts of tuples, before taking each day's figure: about 100 MB for a year of 5-minute data. day_figures (it replaces load_cached and per_day) reads each field once and folds it into its days before the next. On the archived test station it gives the same figures as before for every reading and cycle. lib: +29 โˆ’36 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/ecowitt/days.py | 62 +++++++++++++++++++------------------------ lib/ecowitt/store.py | 3 +-- scripts/check_rain.py | 3 +-- tests/test_store.py | 15 +++++++++++ 4 files changed, 45 insertions(+), 38 deletions(-) diff --git a/lib/ecowitt/days.py b/lib/ecowitt/days.py index ba7179a..9745939 100644 --- a/lib/ecowitt/days.py +++ b/lib/ecowitt/days.py @@ -21,7 +21,6 @@ from ..timeutil import day_bounds, local_date, now_local from .api import daily_run from .calendar import PublicHolidays -from .extremes import collect from .store import HistoryCache, subtract log = logging.getLogger(__name__) @@ -65,34 +64,29 @@ "days and the top ones. For a record's value and the time it happened (hottest, coldest, fastest gust), use weather_history instead. For ONE known day, give start_date = end_date = that day and no conditions: it returns that day's figures. Can be limited to local public holidays or weekends (only). Works from cached history, so any period up to the whole record is fast.") -def load_cached(cache: HistoryCache, mac: str, cycle: str, names: list[str], start: int, end: int) -> dict: - """The cached readings of the wanted fields, folded by collect() into {"group.field": {"pts": ...}}.""" - data: dict = {} +def day_figures(cache: HistoryCache, mac: str, cycle: str, names: list[str], days: set[date], tz: tzinfo, start: int, + end: int) -> dict[str, dict[date, float]]: + """{reading: {day: its figure}} for each of `days`, from the cached readings between start and end (epochs): the day's highest + high, lowest low or mean. Each field is read once, however many readings use it (temp_max, temp_min and temp_avg are one), + and folded into days as it is read, one at a time, so a long period of 5-minute readings is not held whole.""" + readers: dict[tuple[str, str], list[str]] = {} for name in names: - group, field, _, _ = FIELDS[name] - found = cache.load_fields(mac, cycle, group, [field, f"{field}_high", f"{field}_low"], start, end) - data.setdefault(group, {}).update(found) - store: dict = {} - collect(store, data, cycle) - return store - - -def per_day(store: dict, name: str, days: set[date], tz: tzinfo) -> dict[date, float]: - """One extreme per day (the highest high, or the lowest low) of the readings that fall in `days`.""" - group, field, kind, _ = FIELDS[name] - out: dict[date, float] = {} - if kind == "mean": # the day's mean of its readings - sums: dict[date, list[float]] = {} - for ts, rec in store.get(f"{group}.{field}", {}).get("pts", {}).items(): - if (day := local_date(ts, tz)) in days and rec.get("value") is not None: - sums.setdefault(day, []).append(rec["value"][0]) - return {d: fmean(v) for d, v in sums.items()} - for ts, rec in store.get(f"{group}.{field}", {}).get("pts", {}).items(): - day = local_date(ts, tz) - found = rec.get(kind) or rec.get("value") - if day in days and found is not None: - if day not in out or (found[0] > out[day] if kind == "high" else found[0] < out[day]): - out[day] = found[0] + readers.setdefault(FIELDS[name][:2], []).append(name) + out: dict[str, dict[date, float]] = {} + for (group, field), wanted in readers.items(): + value, high, low = cache.slots(mac, cycle, group, [field, f"{field}_high", f"{field}_low"], start, end) + for name in wanted: + kind = FIELDS[name][2] + points = value if kind == "mean" else {**value, **(high if kind == "high" else low)} # a slot's own high or low, if it has one + figures: dict[date, list[float]] = {} + for ts, v in points.items(): + if (day := local_date(ts, tz)) in days: + got = figures.setdefault(day, []) + if kind == "mean" or not got: + got.append(v) + elif v > got[0] if kind == "high" else v < got[0]: + got[0] = v + out[name] = {d: fmean(v) if kind == "mean" else v[0] for d, v in figures.items()} return out @@ -136,13 +130,13 @@ def find_days(cache: HistoryCache, mac: str, tz: tzinfo, args: dict, now: dateti for cycle in SUB_DAILY: mine = {d for d, c in source.items() if c == cycle} if mine: - store = load_cached(cache, mac, cycle, shown, day_bounds(min(mine), tz, True)[0], day_bounds(max(mine), tz, True)[1]) - for n in shown: - values[n].update(per_day(store, n, mine, tz)) + for n, figures in day_figures(cache, mac, cycle, shown, mine, tz, day_bounds(min(mine), tz, True)[0], + day_bounds(max(mine), tz, True)[1]).items(): + values[n].update(figures) if rest: # daily buckets: labelled with the local date their 10am start falls on - store = load_cached(cache, mac, "1day", shown, day_bounds(min(rest), tz, True)[0] - 86400, day_bounds(max(rest), tz, True)[1] + 86400) - for n in shown: - values[n].update(per_day(store, n, rest, tz)) + for n, figures in day_figures(cache, mac, "1day", shown, rest, tz, day_bounds(min(rest), tz, True)[0] - 86400, + day_bounds(max(rest), tz, True)[1] + 86400).items(): + values[n].update(figures) for d in rest: if any(d in values[n] for n in shown): source[d] = "daily" diff --git a/lib/ecowitt/store.py b/lib/ecowitt/store.py index 32364f5..3b5e6be 100644 --- a/lib/ecowitt/store.py +++ b/lib/ecowitt/store.py @@ -213,7 +213,7 @@ def store(self, mac: str, cycle: str, groups: list[str], data: dict, start: int, covered.append((grp, limit)) elif reached is not None: covered.append((grp, min(limit, reached + BUCKET_SECONDS[cycle] - 1))) - with self._lock: + with self._lock, self.db: # one transaction: an error part-way rolls it back (no half-written response is kept) 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 series WHERE mac=? AND cycle=? AND grp=?", (mac, cycle, grp))} @@ -233,7 +233,6 @@ def store(self, mac: str, cycle: str, groups: list[str], data: dict, start: int, self.db.execute("DELETE FROM coverage WHERE mac=? AND cycle=? AND grp=?", (mac, cycle, grp)) self.db.executemany("INSERT INTO coverage VALUES (?,?,?,?,?)", [(mac, cycle, grp, s, e) for s, e in merge(existing + [(start, cov_end)])]) - self.db.commit() def load(self, mac: str, cycle: str, groups: list[str], start: int, end: int) -> dict: """Cached readings in Ecowitt's response shape: {grp: {field: {unit, list}}}.""" diff --git a/scripts/check_rain.py b/scripts/check_rain.py index 6ea44ed..3975674 100644 --- a/scripts/check_rain.py +++ b/scripts/check_rain.py @@ -47,8 +47,7 @@ def totals(cache: HistoryCache, mac: str, tz, cycle: str, days: list) -> dict: lo, hi = day_bounds(min(days), tz, True)[0], day_bounds(max(days), tz, True)[1] if cycle == "1day": lo, hi = lo - 86400, hi + 86400 - store = day_tool.load_cached(cache, mac, cycle, ["rain"], lo, hi) - return day_tool.per_day(store, "rain", set(days), tz) + return day_tool.day_figures(cache, mac, cycle, ["rain"], set(days), tz, lo, hi)["rain"] def compare_rain(cache: HistoryCache, mac: str, tz, today, days_back: int = 88) -> dict: diff --git a/tests/test_store.py b/tests/test_store.py index ce5053f..05442f9 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -112,3 +112,18 @@ def test_the_cache_says_where_it_is_and_how_much_it_holds(tmp_path): 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 + + +def test_a_write_that_fails_part_way_leaves_nothing_behind(tmp_path, monkeypatch): + """A response is saved in one transaction: if the coverage update fails after the readings went in, they are rolled back, + not committed half-written by the next save.""" + import pytest + cache = make(tmp_path) + old = int(time.time()) - 10 * 86400 + real_merge = store.merge + monkeypatch.setattr(store, "merge", lambda spans: (_ for _ in ()).throw(RuntimeError("disk trouble"))) + with pytest.raises(RuntimeError): + cache.store("M", "5min", ["outdoor"], data(old, old + 300), old, old + 300) + monkeypatch.setattr(store, "merge", real_merge) + cache.store("M", "5min", ["outdoor"], data(old + 3600), old + 3600, old + 3600) # a later save commits only its own + assert cache.count("M", "5min", "outdoor", old, old + 300) == 0 and cache.count("M", "5min", "outdoor", old + 3600, old + 3600) == 1 From 2f545510813da880fe6b0d2b82f8709f655181d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 16:16:15 +0000 Subject: [PATCH 14/65] A forecast revision no longer silences later ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit record_forecast dropped every baseline older than the first day it was given, taking that as today. The forecast monitor passes only the days that changed, so when Monday's forecast was revised, Saturday's and Sunday's baselines went with it and a later flip on Sunday was never told. An answer about tomorrow alone did the same to today's. The caller now says what today is, and only days before it are dropped. lib: +6 โˆ’7 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/alerts/forecast.py | 2 +- lib/alerts/notify.py | 9 ++++----- lib/bot.py | 2 +- tests/test_alert_menu.py | 4 ++-- tests/test_forecast_alert.py | 33 +++++++++++++++++++++++++++------ 5 files changed, 35 insertions(+), 15 deletions(-) diff --git a/lib/alerts/forecast.py b/lib/alerts/forecast.py index 958f6a7..e4df865 100644 --- a/lib/alerts/forecast.py +++ b/lib/alerts/forecast.py @@ -54,4 +54,4 @@ async def check(self): continue lines = [f"โ€ข {day_label(day, today)}: was {describe_day(old)}\n now {describe_day(new)}" for day, old, new in changes] if await self.notify.to_chat(chat_id, "\U0001f504 The forecast has changed since I sent it:\n" + "\n".join(lines), kind="forecast"): - self.state.record_forecast(chat_id, [new for _, _, new in changes]) + self.state.record_forecast(chat_id, [new for _, _, new in changes], today) diff --git a/lib/alerts/notify.py b/lib/alerts/notify.py index 712339b..4f8b188 100644 --- a/lib/alerts/notify.py +++ b/lib/alerts/notify.py @@ -136,14 +136,13 @@ def set_keyboard(self, chat_id: int, version: str): self.chats[chat_id]["keyboard"] = version self.save() - def record_forecast(self, chat_id: int, days: list[dict]): - """Remember the forecast days this chat was just sent (a newer send replaces the baseline for the same day); days - already past are dropped. days is not empty.""" + def record_forecast(self, chat_id: int, days: list[dict], today: date): + """Remember the forecast days this chat was just sent (a newer send replaces the baseline for the same day, the other + days keep theirs); days before today are dropped.""" sent = self.monitor.setdefault("forecast_sent", {}).setdefault(str(chat_id), {}) for d in days: sent[d["date"].isoformat()] = {**d, "date": d["date"].isoformat()} - first = min(d["date"] for d in days).isoformat() # what was sent starts today: older days are past - for day in [k for k in sent if k < first]: + for day in [k for k in sent if k < today.isoformat()]: del sent[day] self.save() diff --git a/lib/bot.py b/lib/bot.py index 22d2db9..73c4403 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -1037,7 +1037,7 @@ async def _respond(self, update: Update, context: ContextTypes.DEFAULT_TYPE, tex if kept and read.fast and read.chart_in_code: # a chart with period buttons, made in code: its other periods are uploaded ahead self.preload_soon(context.bot, text) if self.state and ok and turn.forecast_shown: # it was sent: a later revision of these days is worth telling this chat - self.state.record_forecast(msg.chat_id, turn.forecast_shown) + self.state.record_forecast(msg.chat_id, turn.forecast_shown, now_local(self.cfg.tz).date()) if carried_keyboard: log.info("Buttons: sent keyboard %s to chat %s (it had %s)", self.keyboard_version, msg.chat_id, self.state.keyboard(msg.chat_id)) self.state.set_keyboard(msg.chat_id, self.keyboard_version) diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index 6596732..e689d97 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -243,7 +243,7 @@ async def reply_text(body, **kw): async def test_every_monitors_alert_names_its_type(tmp_path): from lib.alerts import ParticulatesMonitor, ForecastMonitor, PollenAndAsthmaMonitor, WeatherMonitor from tests.test_alerts import FakePollen, FakeStation, T0, air_reading, gusts, rain, uv - from tests.test_forecast_alert import FakeForecast, day + from tests.test_forecast_alert import TODAY, FakeForecast, day state = AlertState(tmp_path / "s.json") state.add_chat(1, "chat") kinds = [] @@ -279,7 +279,7 @@ async def spy(chat_id, text, link=None, kind=None): seen.append(kind) return await real(chat_id, text, link, kind) notifier.to_chat = spy - state.record_forecast(1, [day(0, pct=90)]) + state.record_forecast(1, [day(0, pct=90)], TODAY) await ForecastMonitor(FakeForecast([day(0, pct=5)]), state, notifier).check() assert seen == ["forecast"] diff --git a/tests/test_forecast_alert.py b/tests/test_forecast_alert.py index f768fd4..cbc5c7a 100644 --- a/tests/test_forecast_alert.py +++ b/tests/test_forecast_alert.py @@ -52,7 +52,7 @@ def test_substantial_means_rain_flips_or_a_max_temperature_over_2_degrees_differ async def test_a_day_sent_as_rainy_that_turns_dry_is_told_once_to_that_chat(tmp_path): mon, forecast, state, bot = setup(tmp_path, [day(0, pct=95, summary="Thunderstorm."), day(1)]) - state.record_forecast(1, [day(0, pct=95, summary="Thunderstorm."), day(1)]) + state.record_forecast(1, [day(0, pct=95, summary="Thunderstorm."), day(1)], TODAY) await mon.check() assert bot.sent == [] # unchanged forecast.days = [day(0, pct=10, summary="Sunny."), day(1)] @@ -66,7 +66,7 @@ async def test_a_day_sent_as_rainy_that_turns_dry_is_told_once_to_that_chat(tmp_ async def test_dry_to_rain_and_max_temperature_changes_alert_and_a_further_change_alerts_again(tmp_path): mon, forecast, state, bot = setup(tmp_path, [day(0), day(1, max_c=20.0)]) - state.record_forecast(1, [day(0), day(1, max_c=20.0)]) + state.record_forecast(1, [day(0), day(1, max_c=20.0)], TODAY) forecast.days = [day(0, pct=80, summary="Showers."), day(1, max_c=22.1)] await mon.check() assert len(bot.sent) == 1 # two changed days: one message @@ -78,24 +78,45 @@ async def test_dry_to_rain_and_max_temperature_changes_alert_and_a_further_chang async def test_small_changes_past_days_muted_chats_and_chats_never_sent_a_forecast_are_left_alone(tmp_path): mon, forecast, state, bot = setup(tmp_path, [day(0), day(1)]) - state.record_forecast(1, [day(0), day(1)]) + state.record_forecast(1, [day(0), day(1)], TODAY) forecast.days = [day(0, max_c=21.9, pct=40), day(1, max_c=18.5)] await mon.check() assert bot.sent == [] # inside the thresholds - state.record_forecast(1, [day(-1, pct=90), day(0), day(1)]) + state.record_forecast(1, [day(-1, pct=90), day(0), day(1)], TODAY) forecast.days = [day(0, pct=70), day(1)] # yesterday is not in the forecast any more; today flips await mon.check() assert len(bot.sent) == 1 and "Yesterday" not in bot.sent[0][1] and bot.sent[0][1].count("โ€ข ") == 1 - state.record_forecast(2, [day(0, pct=90)]) + state.record_forecast(2, [day(0, pct=90)], TODAY) state.set_alerts(2, "chat 2", False) forecast.days = [day(0, pct=5), day(1)] await mon.check() assert [chat for chat, _ in bot.sent] == [1, 1] and state.forecast_sent(2)[TODAY]["rain_chance_pct"] == 90 # muted: not told, not reset +async def test_a_revised_day_leaves_the_other_days_sent_with_it_to_be_told(tmp_path): + week = [day(i) for i in range(7)] + mon, forecast, state, bot = setup(tmp_path, week) + state.record_forecast(1, week, TODAY) + forecast.days = [*week[:2], day(2, pct=80), *week[3:]] # Monday turns rainy + await mon.check() + forecast.days = [week[0], day(1, pct=80), day(2, pct=80), *week[3:]] # then Sunday does + await mon.check() + assert len(bot.sent) == 2 and "โ€ข Monday: was" in bot.sent[0][1] and "โ€ข Sunday: was" in bot.sent[1][1] + assert sorted(state.forecast_sent(1)) == [d["date"] for d in week] + + +async def test_an_answer_about_later_days_leaves_today_to_be_told(tmp_path): + mon, forecast, state, bot = setup(tmp_path, [day(0, pct=90), day(1)]) + state.record_forecast(1, [day(0, pct=90), day(1)], TODAY) # the morning report + state.record_forecast(1, [day(1)], TODAY) # "what about tomorrow?" + forecast.days = [day(0, pct=5), day(1)] + await mon.check() + assert len(bot.sent) == 1 and "โ€ข Today: was" in bot.sent[0][1] + + async def test_what_was_sent_survives_a_restart_and_is_not_repeated(tmp_path): mon, forecast, state, bot = setup(tmp_path, [day(0, pct=90)]) - state.record_forecast(1, [day(0, pct=90)]) + state.record_forecast(1, [day(0, pct=90)], TODAY) again = AlertState(tmp_path / "s.json") assert again.forecast_sent(1)[TODAY]["rain_chance_pct"] == 90 forecast.days = [day(0, pct=5)] From 388c4d6fbbfbc93428223e08e5d043067df4fe09 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 16:24:33 +0000 Subject: [PATCH 15/65] Rain totals cover the whole spell, and quiet hours across midnight keep the evening's rain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "The rain has stopped" summed only the 3 hours of readings the check looks at: five hours of rain at 0.2 mm a reading read "6.0 mm fell over 5h" instead of 12.0. A spell that began earlier is now read back to its start, and to the reading before it, which its first tip is measured against. - Quiet hours that run across midnight (RAIN_QUIET_HOURS=22-6) lost the evening's rain: the night note started again at midnight, so the summary began at the first rain after it and used the day's running total. The note now belongs to the quiet spell (when it began) and lives until the summary after it takes it, and the amount comes from the readings, read back to the night's first rain. A note left from a night the bot missed gives way to the next night's. The default 0-6 reads the same as before. - The test station returns only the hours asked for, as the real one does. lib: +30 โˆ’20 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/alerts/weather.py | 50 ++++++++++++++++++++++++++----------------- tests/test_alerts.py | 49 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 74 insertions(+), 25 deletions(-) diff --git a/lib/alerts/weather.py b/lib/alerts/weather.py index 64fa084..4562e9e 100644 --- a/lib/alerts/weather.py +++ b/lib/alerts/weather.py @@ -1,9 +1,9 @@ """Weather alerts from the Ecowitt station, checked after every keep-warm refresh (from the same 5-minute readings; the rain check also reads the gauge's live value, one small request). - - 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: "started" (one tip of the gauge is enough) and "stopped" (dry for ALERT_COOLDOWN_MINUTES), with how much fell over + the whole spell. Not during quiet hours (RAIN_QUIET_HOURS, default midnight to 6am, and they may run across midnight): + those are kept quiet and one summary of the rain that fell in them is sent after they end. - 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 is 10 or more in two readings in a row (confirm.py), and no more until it has stayed @@ -15,13 +15,13 @@ """ import logging -from datetime import timedelta +from datetime import datetime, time, timedelta from ..ecowitt.glance import UVI_ALERT from . import confirm, pictures from .backoff import due, remember from ..ecowitt.rain import Rows, duration, rain_amount, wet_flags -from ..timeutil import to_local +from ..timeutil import local_epoch, to_local log = logging.getLogger(__name__) @@ -55,6 +55,12 @@ def _quiet(self, ts: int) -> bool: hour = to_local(ts, self.tz).hour return start <= hour < end if start < end else hour >= start or hour < end + def _quiet_began(self, ts: int) -> int: + """When the quiet hours that `ts` falls in began (epoch): the evening before, for hours that run across midnight.""" + local = to_local(ts, self.tz) + day = local.date() - timedelta(days=1 if local.hour < self.quiet[0] else 0) + return local_epoch(datetime.combine(day, time(self.quiet[0])), self.tz) + @property def tz(self): return self.station.tz @@ -101,7 +107,7 @@ async def _rain(self, rows: Rows): latest_ts = wet[-1][0] quiet = self._quiet(latest_ts) if quiet: - self._note_night(rows, wet) + self._note_night(wet) wet_times = [ts for ts, w, _ in wet if w] last_wet = wet_times[-1] if wet_times else None raining = last_wet is not None and latest_ts - last_wet < self.cooldown # dry means no rain for the whole cooldown @@ -121,27 +127,23 @@ async def _rain(self, rows: Rows): log.info("Alerts: rain stopped in quiet hours; the morning summary covers it") else: since = m.get("since") or wet_times[0] - fell = rain_amount(rows, since, last_wet) + fell = await self._fell(rows, since, last_wet) amount = f"{fell:.1f} mm fell" if fell else "Only a trace fell" took = duration(last_wet + 300 - since) await self.notify(f"\U0001f324๏ธ The rain has stopped. {amount} over {took}.", kind="rain") m["since"] = None remember(m, state, latest_ts, alerted=not quiet) - def _note_night(self, rows: Rows, wet: list): - """In quiet hours: when it first and last rained since midnight, and the day's total so far (the gauge's running total).""" - latest = rows[-1][0] - today = to_local(latest, self.tz).date() + def _note_night(self, wet: list): + """In quiet hours: when it first and last rained since they began. The note lives until the summary after them takes it.""" + began = self._quiet_began(wet[-1][0]) night = self.state.monitor.get("night") - if not night or night["date"] != today.isoformat(): - night = self.state.monitor["night"] = {"date": today.isoformat(), "first": None, "last": None, "mm": 0.0} + if not night or night.get("began") != began: # a new night (a note left from an earlier one is replaced) + night = self.state.monitor["night"] = {"began": began, "first": None, "last": None} for ts, w, _ in wet: - if w and to_local(ts, self.tz).date() == today and self._quiet(ts): + if w and ts >= began: night["first"] = night["first"] or ts night["last"] = ts - daily = rows[-1][1].get("rainfall.daily") - if daily is not None: - night["mm"] = max(night["mm"], daily) async def _night_summary(self, rows: Rows): """After quiet hours end: one message about the rain that fell in them (only if any did).""" @@ -150,10 +152,11 @@ async def _night_summary(self, rows: Rows): if not night or self._quiet(latest): return self.state.monitor.pop("night") - if night["date"] != to_local(latest, self.tz).date().isoformat() or not night["first"]: - return # an old note (the bot was down) or a dry night + if not night.get("first") or latest - night.get("began", 0) >= 86400: + return # a dry night, or an old note (the bot was down) clock = lambda ts: f"{to_local(ts, self.tz):%-I:%M%p}".replace("AM", "am").replace("PM", "pm") - amount = f"{night['mm']:.1f} mm" if night["mm"] >= 0.1 else "a trace" + fell = await self._fell(rows, night["first"], night["last"]) + amount = f"{fell:.1f} mm" if fell else "a trace" 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") @@ -227,6 +230,13 @@ async def _cross(self, rows: Rows): f"({o:.1f}ยฐC vs {i:.1f}ยฐC). It had been {'cooler' if current == 'warmer' else 'warmer'} for {was}.", kind="temps", **({"photo": photo} if photo else {})) remember(c, current, ts) + async def _fell(self, rows: Rows, since: int, until: int) -> float | None: + """The rain that fell from `since` to `until`. A spell that began before `rows` (the last 3 hours) is read back to its start: + its first tip shows only against the reading before it.""" + if since <= rows[0][0]: + rows = await self.station.recent((rows[-1][0] - since) / 3600 + 0.25) + return rain_amount(rows, since, until) + async def _picture(self, text: str) -> bytes | None: """The chart a typed `text` ("Wind chart 1d") would give, for an alert; None when it cannot be made.""" return await pictures.chart(self.station, text, self.tz) diff --git a/tests/test_alerts.py b/tests/test_alerts.py index ee058fd..e8a3201 100644 --- a/tests/test_alerts.py +++ b/tests/test_alerts.py @@ -1,6 +1,8 @@ import time from datetime import datetime, timedelta, timezone +import pytest + from lib.alerts import ParticulatesMonitor, AlertState, Notifier, WeatherMonitor, with_footer from tests.fakes import TZ @@ -28,7 +30,8 @@ async def live_rain(self): return self.live async def recent(self, hours): - return self.data + """The readings of the last `hours` (up to the newest one, which stands for now).""" + return [(ts, r) for ts, r in self.data if ts >= self.data[-1][0] - hours * 3600] if self.data else [] def now(self): return datetime.now(TZ).replace(tzinfo=None) @@ -63,6 +66,17 @@ async def test_rain_starts_once_then_stops_after_30_dry_minutes(tmp_path): assert len(sent) == 2 and "stopped" in sent[1] and "mm fell" in sent[1] +@pytest.mark.parametrize("wet, told", [(60, "12.0 mm fell over 5h"), + (31, "6.2 mm fell over 2h 35m")]) # its first tip is the oldest of the 3 hours looked at +async def test_the_rain_stopped_alert_counts_a_spell_longer_than_the_readings_it_looks_at(tmp_path, wet, told): + m, state, sent = monitor(tmp_path, None) + spell = rain(0, 0, *[1] * wet, *[0] * 6) # 0.2 mm a wet reading, then 30 dry minutes + for n in (3, len(spell)): + m.station.data = spell[:n] + await m.check() + assert len(sent) == 2 and sent[1] == f"\U0001f324๏ธ The rain has stopped. {told}." + + async def test_a_dry_gap_shorter_than_the_stop_time_does_not_flap(tmp_path): m, state, sent = monitor(tmp_path, None) for data in (rain(1, 1), rain(1, 1, 0, 0), rain(1, 1, 0, 0, 0), rain(1, 1, 0, 0, 0, 1), rain(1, 1, 0, 0, 0, 1, 0)): @@ -467,12 +481,12 @@ async def test_no_rain_alerts_in_quiet_hours_and_one_summary_after_them(tmp_path m, state, sent = monitor(tmp_path, None) m.quiet = (0, 6) one_am = int(datetime(2026, 9, 30, 1, 0, tzinfo=TZ).timestamp()) - for data in (night_rain(one_am, 0, 0, 0), night_rain(one_am, 0, 0, 1), night_rain(one_am, 0, 0, 1, 1, 1), - night_rain(one_am, 0, 0, 1, 1, 1, *[0] * 13)): # it starts, rains, stops after 60 dry minutes - m.station.data = data + night = night_rain(one_am, 0, 0, 1, 1, 1, *[0] * 57) # 1am to 6:05am + for n in (3, 4, 6, 18, 60): # it starts, rains, stops after 60 dry minutes; 5:55 + m.station.data = night[:n] await m.check() assert sent == [] # all in quiet hours - m.station.data = night_rain(int(datetime(2026, 9, 30, 5, 55, tzinfo=TZ).timestamp()), 0, 0, 0) # 5:55 to 6:05 + m.station.data = night # 6:05 await m.check() assert len(sent) == 1 and sent[0].startswith("\U0001f327๏ธ Overnight rain: 1.2 mm, from about 1:10am to 1:25am.") await m.check() @@ -482,6 +496,31 @@ async def test_no_rain_alerts_in_quiet_hours_and_one_summary_after_them(tmp_path assert len(sent) == 2 and "started raining" in sent[1] # by day, rain alerts are back +async def test_quiet_hours_across_midnight_keep_the_evening_rain_for_the_summary(tmp_path): + m, state, sent = monitor(tmp_path, None) + m.quiet = (22, 6) + evening = night_rain(int(datetime(2026, 9, 29, 22, 0, tzinfo=TZ).timestamp()), *[0] * 12, *[1] * 6, *[0] * 6) # 11pm, 2.4 mm + morning = night_rain(int(datetime(2026, 9, 30, 0, 0, tzinfo=TZ).timestamp()), *[0] * 12, *[1] * 3, *[0] * 59) # the gauge + night = evening + morning # reset at midnight; 1am, 1.2 mm; dry to 6:05am + for n in range(1, len(night) + 1): # a check every 5 minutes + m.station.data = night[:n] + await m.check() + assert sent == ["\U0001f327๏ธ Overnight rain: 3.6 mm, from about 11:00pm to 1:15am."] + + +async def test_a_note_left_from_a_night_the_bot_missed_gives_way_to_the_next_nights(tmp_path): + m, state, sent = monitor(tmp_path, None) + m.quiet = (22, 6) + m.station.data = night_rain(int(datetime(2026, 9, 29, 23, 0, tzinfo=TZ).timestamp()), 0, 1) + await m.check() # rain noted at 11:05pm, then the bot is down until the next night + night = night_rain(int(datetime(2026, 9, 30, 22, 30, tzinfo=TZ).timestamp()), 0, 1, *[0] * 89) # to 6am + m.station.data = night[:2] + await m.check() + m.station.data = night + await m.check() + assert sent == ["\U0001f327๏ธ Overnight rain: 0.4 mm, from about 10:35pm to 10:40pm."] + + async def test_a_dry_night_has_no_summary_and_quiet_hours_can_be_off(tmp_path): m, state, sent = monitor(tmp_path, None) m.quiet = (0, 6) From a6e8e8ca9225f9fddff23e4be1ee7600c6fefd11 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 16:29:58 +0000 Subject: [PATCH 16/65] A damaged state file is kept aside; one chat's failed alert no longer stops the rest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A bot_state.json that couldn't be read (cut short by a crash, say) was logged and then overwritten by the next save, losing every chat and setting. It is now moved aside as bot_state.json.damaged-, so it can be recovered by hand. A save is on disk (fsync) before it replaces the old file, so a crash leaves one or the other whole, not an empty file. - An error while forgetting a chat or following a migrated group (saving the state can fail, a full disk) escaped the send loop, so the chats after it got nothing; ForecastMonitor's loop over chats had the same problem through to_chat. Whatever fails for one chat is now logged (with its traceback when it isn't Telegram's), and the others still get the alert. - Telegram's flood limit and a dropped connection simply cost that chat its alert. Each now gets one more try: after the wait Telegram asks for (up to a minute; a longer one isn't waited out), or 3 seconds after a dropped connection. A timeout isn't retried, as the message may have arrived. lib: +55 โˆ’24 lines (the plan guessed +11: the docstrings and the second try layer cost more). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/alerts/notify.py | 79 ++++++++++++++++++++++++++++++-------------- tests/test_alerts.py | 71 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 24 deletions(-) diff --git a/lib/alerts/notify.py b/lib/alerts/notify.py index 4f8b188..ce16507 100644 --- a/lib/alerts/notify.py +++ b/lib/alerts/notify.py @@ -6,34 +6,52 @@ settings buttons and, when there is one, a small italic footer with a link label. """ +import asyncio import json import logging import os import time -from datetime import date +from datetime import date, timedelta from telegram import MessageEntity -from telegram.error import BadRequest, ChatMigrated, Forbidden, TelegramError +from telegram.error import BadRequest, ChatMigrated, Forbidden, NetworkError, RetryAfter, TelegramError, TimedOut from ..logs import QUOTES from .menu import ALL, LABELS, alert_keyboard log = logging.getLogger(__name__) +RETRY_SECONDS = 3 # before sending an alert again after a dropped connection +FLOOD_WAIT_MAX = 60 # Telegram's flood limit asks to wait: a longer wait is not waited out (that chat misses the alert) + + +def _load(path) -> dict: + """The saved state, {} when there is none. A file that can't be read is moved aside, as the next save would replace it, + so its chats and settings can still be recovered by hand.""" + try: + with open(path) as f: + return json.load(f) + except FileNotFoundError: + return {} + except (OSError, ValueError) as e: + aside = f"{path}.damaged-{time.strftime('%Y%m%d-%H%M%S')}" + os.replace(path, aside) + log.error("Couldn't read %s (%s): moved it to %s and starting with no known chats", path, e, aside) + return {} + + +def _flood_wait(e: RetryAfter) -> float: + """The seconds Telegram asks to wait (python-telegram-bot gives a number, or a timedelta once PTB_TIMEDELTA is set).""" + wait = e.retry_after + return wait.total_seconds() if isinstance(wait, timedelta) else wait + class AlertState: """Known chats (with alerts on/off) and monitor state, saved to a JSON file.""" def __init__(self, path): self.path = path - data: dict = {} - try: - with open(path) as f: - data = json.load(f) - except FileNotFoundError: - pass - except Exception: - log.exception("Couldn't read %s - starting with no known chats", path) + data = _load(path) self.chats: dict[int, dict] = {int(k): v for k, v in data.get("chats", {}).items()} for entry in self.chats.values(): # the old on/off flag, and a chat with every type muted, are "unsubscribed from all" if entry.pop("alerts", True) is False or set(entry.get("muted", [])) >= set(LABELS): @@ -43,9 +61,12 @@ def __init__(self, path): self.charts: dict[str, str] = data.get("charts", {}) # the question behind each chart sent, for its period buttons def save(self): + """Written in full to a new file that then replaces the old one, on disk first, so a crash leaves one or the other whole.""" tmp = f"{self.path}.tmp" with open(tmp, "w") as f: json.dump({"chats": {str(k): v for k, v in self.chats.items()}, "monitor": self.monitor, "charts": self.charts}, f, indent=1) + f.flush() + os.fsync(f.fileno()) os.replace(tmp, self.path) def add_chat(self, chat_id: int, title: str): @@ -231,20 +252,30 @@ async def _post(self, chat_id: int, text: str, entities, kind: str | None, photo log.warning("Alert picture to %s failed (%s); sending the text alone", chat_id, e) return await self.bot.send_message(chat_id, text, entities=entities, disable_web_page_preview=True, **common) - async def _send(self, chat_id: int, text: str, entities, kind: str | None = None, photo: bytes | str | None = None) -> tuple[bool, object]: - """Send an alert (see _post); forget a chat the bot can no longer post to. (whether it was sent, the message sent)""" + async def _send(self, chat_id: int, text: str, entities, kind: str | None = None, photo: bytes | str | None = None, + again: bool = True) -> tuple[bool, object]: + """Send an alert (see _post) to one chat; forget a chat the bot can no longer post to. Telegram's flood limit (when it asks + for a short wait) and a dropped connection get one more try; a timeout doesn't, as the message may have arrived. Whatever + fails for one chat is logged, so the others still get the alert. (whether it was sent, the message sent)""" try: - return True, await self._post(chat_id, text, entities, kind, photo) - except ChatMigrated as e: # the group became a supergroup: follow it, and send again there - self.state.migrate_chat(chat_id, e.new_chat_id) - return await self._send(e.new_chat_id, text, entities, kind, photo) if e.new_chat_id != chat_id else (False, None) - except Forbidden as e: # kicked from the group, or blocked in a private chat - self.state.remove_chat(chat_id, f"can't post: {e}") - except BadRequest as e: - if "not found" in str(e).lower(): + try: + return True, await self._post(chat_id, text, entities, kind, photo) + except ChatMigrated as e: # the group became a supergroup: follow it, and send again there + self.state.migrate_chat(chat_id, e.new_chat_id) + return await self._send(e.new_chat_id, text, entities, kind, photo) if e.new_chat_id != chat_id else (False, None) + except Forbidden as e: # kicked from the group, or blocked in a private chat + self.state.remove_chat(chat_id, f"can't post: {e}") + except BadRequest as e: + if "not found" not in str(e).lower(): + raise self.state.remove_chat(chat_id, f"chat gone: {e}") - else: - log.warning("Alert to %s failed: %s", chat_id, e) - except TelegramError as e: - log.warning("Alert to %s failed: %s", chat_id, e) + except (RetryAfter, NetworkError) as e: # BadRequest, a NetworkError too, is caught above + wait = _flood_wait(e) if isinstance(e, RetryAfter) else RETRY_SECONDS + if not again or isinstance(e, TimedOut) or wait > FLOOD_WAIT_MAX: + raise + log.info("Alert to %s: %s; trying again in %gs", chat_id, e, wait) + await asyncio.sleep(wait) + return await self._send(chat_id, text, entities, kind, photo, again=False) + except Exception as e: # Telegram refused it, or the state file couldn't be saved + log.warning("Alert to %s failed: %s", chat_id, e, exc_info=not isinstance(e, TelegramError)) return False, None diff --git a/tests/test_alerts.py b/tests/test_alerts.py index e8a3201..b557b9c 100644 --- a/tests/test_alerts.py +++ b/tests/test_alerts.py @@ -310,6 +310,77 @@ async def send_message(self, chat_id, text, **kw): assert all(kw["disable_notification"] for _, kw in calls) and 2 not in state.chats and 1 in state.chats +def test_a_damaged_state_file_is_kept_aside_not_overwritten(tmp_path, caplog): + path = tmp_path / "s.json" + path.write_text('{"chats": {"1": {"title": "Home"}}, "monitor": {"rain": {"alerted_st') # cut short + state = AlertState(path) + assert state.chats == {} + state.add_chat(2, "Work") # the next save + aside = list(tmp_path.glob("s.json.damaged-*")) + assert len(aside) == 1 and aside[0].read_text().startswith('{"chats": {"1"') and "moved it to" in caplog.text + assert AlertState(path).chats == {2: {"title": "Work"}} + + +def test_a_save_is_on_disk_before_it_replaces_the_old_file(tmp_path, monkeypatch): + from lib.alerts import notify + calls, replace = [], notify.os.replace + monkeypatch.setattr(notify.os, "fsync", lambda fd: calls.append("fsync")) + monkeypatch.setattr(notify.os, "replace", lambda a, b: (calls.append("replace"), replace(a, b))) + AlertState(tmp_path / "s.json").add_chat(1, "Home") + assert calls == ["fsync", "replace"] + + +async def test_one_chat_failing_never_stops_the_alert_reaching_the_others(tmp_path, monkeypatch): + from telegram.error import Forbidden + state = AlertState(tmp_path / "s.json") + for chat in (1, 2, 3): + state.add_chat(chat, f"chat {chat}") + got = [] + + class Bot: + async def send_message(self, chat_id, text, **kw): + if chat_id == 1: + raise Forbidden("bot was blocked") + got.append(chat_id) + + def full(): + raise OSError(28, "No space left on device") + monkeypatch.setattr(state, "save", full) # forgetting chat 1 can't be saved + await Notifier(Bot(), state)("hi") + assert got == [2, 3] + + +@pytest.mark.filterwarnings("ignore:Deprecated since version v22.2") # python-telegram-bot 22: retry_after will be a timedelta +async def test_a_short_flood_wait_or_a_dropped_connection_is_tried_once_more_and_a_timeout_is_not(tmp_path, monkeypatch): + from telegram.error import NetworkError, RetryAfter, TimedOut + from lib.alerts import notify + waits = [] + + async def sleep(seconds): + waits.append(seconds) + monkeypatch.setattr(notify.asyncio, "sleep", sleep) + flood, long_flood = RetryAfter(5), RetryAfter(300) + state = AlertState(tmp_path / "s.json") + state.add_chat(1, "chat") + + class Bot: + def __init__(self, *errors): + self.errors, self.tries = list(errors), 0 + + async def send_message(self, chat_id, text, **kw): + self.tries += 1 + if self.errors: + raise self.errors.pop(0) + cases = [((flood,), True, 2, [5]), ((NetworkError("Connection reset by peer"),), True, 2, [3]), + ((NetworkError("down"), NetworkError("down")), False, 2, [3]), # once more, not twice + ((TimedOut(),), False, 1, []), # it may have arrived + ((long_flood,), False, 1, [])] # not waited out + for errors, sent, tries, waited in cases: + waits.clear() + bot = Bot(*errors) + assert (await Notifier(bot, state).to_chat(1, "hi"), bot.tries, waits) == (sent, tries, waited), errors + + def uv(*values, start=T0): return [(start + i * 300, {"solar_and_uvi.uvi": v}) for i, v in enumerate(values)] From afe2ac97098dc9db4e88d8db8c908b091982893d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 16:32:51 +0000 Subject: [PATCH 17/65] An odd reply from the irrigation controller is an error the buttons report; a run is watched from the start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The bot's irrigation buttons catch IrrigationError, but a Tuya reply of an unexpected shape raised AttributeError, KeyError or TypeError instead (a reply that isn't an object, a token reply with no token, a status that isn't a list, a status point with no value), so the press was never answered and the message never redrawn. Each is now an IrrigationError, which gets the usual "Couldn't reach the irrigation controller" toast; a status point with no value reads as unknown. - The valve watchdog was armed only after water() returned, so a command whose reply was lost (a timeout after the valve opened) left the run unwatched. It is armed first; on a run that never started it just finds the valve closed. lib: +15 โˆ’7 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/bot.py | 4 ++-- lib/irrigation/tuya.py | 18 ++++++++++++----- tests/test_irrigation.py | 42 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 7 deletions(-) diff --git a/lib/bot.py b/lib/bot.py index 73c4403..65535b5 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -475,9 +475,9 @@ async def send_irrigation(self, msg: Message): async def _irrigation_act(self, action: str, arg: str, chat_id: int) -> str | None: """Do what a button asks. Returns the toast to show, or None for a button that means nothing. Raises IrrigationError.""" if action == "water" and arg.isdigit() and int(arg) in irrigation_menu.WATER_MINUTES: - await self.irrigation.water(int(arg)) - if self.watchdog: + if self.watchdog: # first: a command whose reply is lost may still have opened the valve self.watchdog.arm(int(arg)) + await self.irrigation.water(int(arg)) return f"Watering for {arg} minutes" if action == "delay" and arg in irrigation_menu.DELAYS: await self.irrigation.delay(arg) diff --git a/lib/irrigation/tuya.py b/lib/irrigation/tuya.py index 37f9fc9..dcd82f6 100644 --- a/lib/irrigation/tuya.py +++ b/lib/irrigation/tuya.py @@ -46,7 +46,7 @@ def __init__(self, cfg: Config, transport=None): async def close(self): await self.http.aclose() - async def _send(self, method: str, path: str, payload: dict | None, token: str): + async def _send(self, method: str, path: str, payload: dict | None, token: str) -> dict: body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode() if payload is not None else b"" timestamp, nonce = str(int(time.time() * 1000)), uuid.uuid4().hex headers = {"client_id": self.client_id, "sign_method": "HMAC-SHA256", "t": timestamp, "nonce": nonce, @@ -58,17 +58,23 @@ async def _send(self, method: str, path: str, payload: dict | None, token: str): try: response = await self.http.request(method, path, content=body if payload is not None else None, headers=headers) response.raise_for_status() - return response.json() + data = response.json() except (httpx.HTTPError, ValueError) as e: # the URL and body stay out of the message raise IrrigationError(f"Tuya request failed ({type(e).__name__})") from e + if not isinstance(data, dict): + raise IrrigationError(f"Tuya sent a {type(data).__name__}, not a reply") + return data async def _authenticate(self): data = await self._send("GET", TOKEN_PATH, None, "") if not data.get("success"): raise IrrigationError(f"Tuya refused the credentials (code {data.get('code')}: {data.get('msg')})") - result = data["result"] + result = data.get("result") + if not isinstance(result, dict) or not isinstance(result.get("access_token"), str): + raise IrrigationError("Tuya's token reply has no access token") + lasts = result.get("expire_time") self.token = result["access_token"] - self.token_expires = time.time() + max(60, int(result.get("expire_time", 7200)) - 60) + self.token_expires = time.time() + max(60, (lasts if isinstance(lasts, int) else 7200) - 60) async def request(self, method: str, path: str, payload: dict | None = None, retry: bool = True): """The `result` of a signed request. An expired token is replaced and the request sent once more.""" @@ -85,7 +91,9 @@ async def request(self, method: str, path: str, payload: dict | None = None, ret async def status(self) -> dict: """{code: value} of the device's status points (switch, battery_percentage, weather_delay, countdown, work_state, ...).""" result = await self.request("GET", f"/v1.0/devices/{self.device}/status") - return {item["code"]: item["value"] for item in result or [] if "code" in item} + if not isinstance(result, list | None): + raise IrrigationError(f"Tuya's status is a {type(result).__name__}, not a list") + return {item["code"]: item.get("value") for item in result or [] if isinstance(item, dict) and "code" in item} async def command(self, *commands: tuple[str, object]): """Send one or more (code, value) commands together.""" diff --git a/tests/test_irrigation.py b/tests/test_irrigation.py index e8a048a..5191108 100644 --- a/tests/test_irrigation.py +++ b/tests/test_irrigation.py @@ -124,6 +124,29 @@ def down(request): assert SECRET not in str(e.value) and "ConnectError" in str(e.value) +TOKEN = {"success": True, "result": {"access_token": "tok", "expire_time": 7200}} + + +@pytest.mark.parametrize("token, status", [({"success": True}, None), # a token reply with no token + ({"success": True, "result": ["tok"]}, None), + ([TOKEN], None), # a reply that isn't an object + (TOKEN, {"success": True, "result": {"switch": True}}), # a status that isn't a list + (TOKEN, "OK")]) +async def test_a_reply_of_an_unexpected_shape_is_an_irrigation_error(tmp_path, token, status): + def serve(request): + return httpx.Response(200, json=token if request.url.path.startswith("/v1.0/token") else status) + device = Tuya(cfg(tmp_path), transport=httpx.MockTransport(serve)) + with pytest.raises(IrrigationError): + await device.status() + await device.close() + + +async def test_status_points_with_no_value_or_no_code_are_read_safely(tmp_path): + device, fake = tuya(tmp_path, status=[{"code": "switch"}, "battery", {"value": 3}, {"code": "countdown", "value": 60}]) + assert await device.status() == {"switch": None, "countdown": 60} + await device.close() + + def test_the_controller_is_on_only_with_all_three_settings_and_the_hour_is_read_safely(monkeypatch, tmp_path): assert cfg(tmp_path).irrigation and not config(tmp_path).irrigation and not config(tmp_path, tuya_device_id="d", tuya_client_id="c").irrigation env = {"TELEGRAM_BOT_TOKEN": "t", "XAI_API_KEY": "k", "ECOWITT_API_KEY": "a", "ECOWITT_APP_KEY": "b"} @@ -459,6 +482,25 @@ async def reply_text(body, **kw): assert sent[1][0] == "Sorry, I couldn't reach the irrigation controller." and len(sent) == 2 +async def test_an_odd_reply_from_the_controller_is_a_toast_not_a_press_left_hanging(tmp_path): + def odd(request): + return httpx.Response(200, json=["not", "a", "reply"]) + bot, state, device = irrigation_bot(tmp_path, Tuya(cfg(tmp_path), transport=httpx.MockTransport(odd))) + q = await press(bot, "ir:refresh") + assert q.toast == "Couldn't reach the irrigation controller" and q.edits == [] + await device.close() + + +async def test_a_run_whose_reply_is_lost_is_still_watched(tmp_path): + class Lost(Remote): + async def water(self, minutes): + await super().water(minutes) # the valve opened + raise IrrigationError("Tuya request failed (ReadTimeout)") # but the reply never came + bot, state, device = irrigation_bot(tmp_path, Lost()) + q = await press(bot, "ir:water:10") + assert q.toast == "Couldn't reach the irrigation controller" and bot.watchdog.off_at is not None + + async def test_opening_and_closing_a_section_only_changes_the_buttons(tmp_path): bot, _, device = irrigation_bot(tmp_path) q = await press(bot, "ir:open:water") From fd6df15c3afe2fa129fbf1783c33f6f659f4af5a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 16:35:57 +0000 Subject: [PATCH 18/65] A second rainy day keeps irrigation paused; a stale forecast no longer decides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The daily check runs at 18:00:00, and yesterday's 24-hour pause was set a few seconds after 18:00 (once the station and the forecast had been read), so at the check it was still running: "the delay is already 24h", nothing was set, and seconds later it lapsed, leaving a second rainy day unpaused. The bot now remembers the day it set its pause. Its own 24h from yesterday is ended and set again ("Irrigation paused for another 24 hours"); a delay someone set (48h, 72h, or a 24h the bot didn't set yesterday) is left alone as before. The cancel comes first because it isn't known whether the controller restarts a running 24h when told 24h again. - The pause decision used the forecast however old it was, so after days without a refresh, stale days decided. Only a forecast fetched within the last day counts now; otherwise the measured rain alone decides, and the log says so. lib: +21 โˆ’11 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 2 +- lib/alerts/irrigation.py | 32 +++++++++++++++++++++----------- tests/test_irrigation.py | 27 ++++++++++++++++++++++++++- 3 files changed, 48 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index a0baac2..9cb8dbf 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ The state and caches go in `/data` (its `DATA_DIR`), here a named volume. A host | `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), 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 | +| `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; a forecast more than a day old doesn't count); on a second rainy day the bot's own delay is started again, and a delay you set is left alone; 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 | | `TELEGRAM_PRELOAD_CHAT` | optional: a chat id (negative for a group or channel) the bot silently uploads charts to ahead of need. With it set, each chart sent with period buttons has its other periods drawn and uploaded there in the background, each on its own the moment it is drawn, starting as soon as the chart is sent and whatever else is being answered, so pressing a period button edits the message with Telegram's copy and uploads nothing. A chart uploaded this way is used for 30 minutes, then drawn afresh. The bot must be able to post in that chat; the uploaded messages are left there. If Telegram refuses an upload (the bot can't post there) none is tried again until a restart; a flood limit or a timeout only skips that photo. Unset: off | | `ANTISPIKE_REQUIRE_CONSECUTIVE` | optional: how many readings in a row a rise of the particles (PM1, PM2.5, PM10) needs to count as the air rather than a glitch of the sensor. Default `2`: a single-reading spike (at least 2.5 times, and 5 ยตg/mยณ above, the readings either side) is left out of PM charts and of every air figure (highs, lows, averages, days over a limit), and the unhealthy-air alert counts a check only when the sensor's last 2 readings are all over the mask level. `3` also drops two-reading spikes, and so on; `1` or `off` turns the rule off. The live "air now" reading and the "safe again" check are unchanged | diff --git a/lib/alerts/irrigation.py b/lib/alerts/irrigation.py index 6006749..e183496 100644 --- a/lib/alerts/irrigation.py +++ b/lib/alerts/irrigation.py @@ -2,8 +2,9 @@ - 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). + 1 mm of rain (in a forecast fetched within the last day: an older one doesn't count), 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 delay someone set is left alone + (nothing changes, so nothing is sent); the bot's own from yesterday, seconds from lapsing at the check, is started again. The bot has no scheduler, so `run` sleeps until the next check time. If the controller can't be reached it tries again every 30 minutes until it works or the day ends. The date of the last completed check is saved, so a restart neither repeats a day nor skips one. @@ -11,10 +12,10 @@ import asyncio import logging -from datetime import datetime +from datetime import datetime, timedelta from ..ecowitt.rain import rain_amount -from ..timeutil import now_local, seconds_until +from ..timeutil import local_epoch, now_local, seconds_until, when from .forecast import RAIN_FROM_PCT log = logging.getLogger(__name__) @@ -23,6 +24,7 @@ MEASURED_MM = 1.0 # more than this in the last 24 hours counts as rain RETRY_SECONDS = 30 * 60 MEASURED_HOURS = 24 +FORECAST_TRUSTED_SECONDS = 24 * 3600 # an older forecast (Open-Meteo unreachable for a day) doesn't decide class IrrigationMonitor: @@ -71,13 +73,17 @@ async def check(self, now: datetime | None = None): self.state.save() await self.notify(f"๐Ÿชซ The irrigation battery is at {battery}%. Replace or recharge it soon, or watering will stop.", kind="irrigation_battery") - reason = await self.rain_reason() + reason = await self.rain_reason(now) if reason: current = status.get("weather_delay") - if current in (None, "cancel"): + own = current == "24h" and memory.get("paused") == (now.date() - timedelta(days=1)).isoformat() + if current in (None, "cancel") or own: + if own: # the bot's pause from yesterday: ended first, so the new one runs a full day + await self.device.delay("cancel") await self.device.delay("24h") - log.info("Irrigation: delayed 24 hours (%s)", reason) - await self.notify(f"โ˜” Irrigation paused for 24 hours: {reason}.", kind="irrigation_pause") + memory["paused"] = today + log.info("Irrigation: delayed 24 hours%s (%s)", " more" if own else "", reason) + await self.notify(f"โ˜” Irrigation paused for {'another ' if own else ''}24 hours: {reason}.", kind="irrigation_pause") else: log.info("Irrigation: %s, but the delay is already %s", reason, current) else: @@ -85,8 +91,8 @@ async def check(self, now: datetime | None = None): memory["checked"] = today self.state.save() - async def rain_reason(self) -> str | None: - """Why the irrigation should be delayed (what rained or will), or None.""" + async def rain_reason(self, now: datetime) -> str | None: + """Why the irrigation should be delayed (what rained or will), or None. `now` is local.""" if self.station is not None: try: rows = await self.station.recent(MEASURED_HOURS) @@ -96,7 +102,11 @@ async def rain_reason(self) -> str | None: fell = None if fell is not None and fell > MEASURED_MM: return f"{fell:g} mm of rain in the last 24 hours" - if self.forecast is not None: + if self.forecast is not None and self.forecast.days: + if local_epoch(now, self.tz) - self.forecast.fetched_at > FORECAST_TRUSTED_SECONDS: + log.info("Irrigation: the forecast is from %s, over a day old: only the measured rain decides", + when(int(self.forecast.fetched_at), self.tz)) + return None today = self.forecast.now().date() for day in self.forecast.upcoming(2): chance = day.get("rain_1mm_pct") diff --git a/tests/test_irrigation.py b/tests/test_irrigation.py index 5191108..37b5f27 100644 --- a/tests/test_irrigation.py +++ b/tests/test_irrigation.py @@ -4,7 +4,7 @@ import hashlib import hmac import json -from datetime import datetime +from datetime import datetime, timedelta from types import SimpleNamespace as NS import httpx @@ -278,6 +278,7 @@ class Forecast: def __init__(self, *chances): self.days = [{"date": datetime(2026, 10, 8 + i).date(), "rain_1mm_pct": c} for i, c in enumerate(chances)] + [ {"date": datetime(2026, 10, 10).date(), "rain_1mm_pct": 90}] # the day after tomorrow never counts + self.fetched_at = datetime(2026, 10, 8, 17, 45, tzinfo=TZ).timestamp() def now(self): return datetime(2026, 10, 8, 18, 0) @@ -328,6 +329,30 @@ async def test_a_delay_already_set_is_never_shortened_or_repeated(tmp_path): assert device.commands == [] and sent == [] # nothing changed: nothing to tell +async def test_the_bots_own_pause_from_yesterday_is_started_again_and_one_someone_set_is_not(tmp_path): + device = Device() + m, state, sent = daily(tmp_path, device, Station(0, 3)) + await m.check(NOW) # paused at 6pm + await m.check(NOW + timedelta(days=1)) # 6pm the next day: still on, for seconds + assert device.commands == [("delay", "24h"), ("delay", "cancel"), ("delay", "24h")] + assert sent[1][0] == "โ˜” Irrigation paused for another 24 hours: 3 mm of rain in the last 24 hours." + m.station = Station(0, 0) + await m.check(NOW + timedelta(days=2)) # dry: it lapses + m.station = Station(0, 3) + await m.check(NOW + timedelta(days=4)) # a 24h someone set (the bot's is days old) + assert len(device.commands) == 3 and len(sent) == 2 + + +async def test_a_forecast_more_than_a_day_old_does_not_decide(tmp_path, caplog): + stale = Forecast(90, 90) + stale.fetched_at -= 25 * 3600 + device = Device() + m, state, sent = daily(tmp_path, device, Station(0, 0), stale) + with caplog.at_level("INFO"): + await m.check(NOW) + assert device.commands == [] and sent == [] and "over a day old: only the measured rain decides" in caplog.text + + async def test_a_battery_under_5_percent_is_told_once_a_day_to_the_irrigation_alert_type(tmp_path): device = Device(battery_percentage=4) m, state, sent = daily(tmp_path, device) From 730dcf7f27525d9dbc6df964c25f27ebb113a54c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 16:41:31 +0000 Subject: [PATCH 19/65] A reworded pollen page keeps its asthma risk; last season's page is never shown; a heading dates stale data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The thunderstorm asthma levels were read only under a line exactly "Thunderstorm Asthma Forecast", so a heading with words after it ("... for Tuesday 10 November") or in another case dropped a High risk without a word, and the tool told the model "No thunderstorm asthma forecast now (it runs 1 Oct - 31 Dec)". The headings are now found by how their line starts, whatever its case. In season, a page with no level for the district is logged ("has its layout changed?"), and the tool says the page had none, not that there is no forecast. - POLLEN_DISTRICT is matched whatever its case ("north east"), and one the site doesn't have is warned about at start, with the list (a typo gave no level, silently). The README lists the districts. - Pollen.current() checked only the month, so a restart on 1 October at 3 am served last December's saved "High" as today's, alerts included, until the site was read at 6 am. A page is now shown only if it is this season's: its grass forecast's date says (else when it was fetched). Last season's page doesn't stop a fetch at night either. - Report headings gave the time of day alone, so a station silent since Friday read "Weather station (2:21am)". A heading now gives the day when the data isn't today's: "Weather station (Fri 2 Oct 2:21am)". report() and lookup() take today for it. lib: +64 โˆ’39 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 2 +- lib/bot.py | 4 +-- lib/pollen/parse.py | 21 ++++++++++------ lib/pollen/source.py | 43 +++++++++++++++++++++++--------- lib/report.py | 35 +++++++++++++------------- tests/test_pollen.py | 42 +++++++++++++++++++++++++++++--- tests/test_report.py | 58 ++++++++++++++++++++++++++------------------ 7 files changed, 137 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index 9cb8dbf..260d0f8 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ The state and caches go in `/data` (its `DATA_DIR`), here a named volume. A host | `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. 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) | +| `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; the others are East Gippsland, Mallee, North Central, North East, Northern Country, South West, West and South Gippsland and Wimmera, in any case; one the site doesn't have is warned about at start) | | `PLACE` | the name shown after the Pollen & asthma and Forecast headings in the report (default `Melbourne`) | | `FORECAST=on` | optional: the Open-Meteo daily forecast in the report and a `weather_forecast` tool. Off by default; `FORECAST_LAT` / `FORECAST_LON` set the location, else the weather station's own is used | | `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 | diff --git a/lib/bot.py b/lib/bot.py index 65535b5..f33b99e 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -766,7 +766,7 @@ async def _written_in_code(self, read, turn: Turn) -> str: results = await asyncio.gather(*(self.agent.tools.call(name, json.dumps(args), turn) for name, args in calls)) by_tool = {name: result for (name, _), result in zip(calls, results)} if read.report: - return report.report(by_tool) + return report.report(by_tool, now_local(self.cfg.tz).date()) return report.weather_now(by_tool["weather_now"]) or "The weather station isn't answering right now." async def _warn(self, msg: Message, tokens: int): @@ -795,7 +795,7 @@ async def _lookup_in_code(self, read, turn: Turn) -> str | None: "Pollen" in self.by_name, "Forecast" in self.by_name) name, args = read.fast[:2] result = await self.agent.tools.call(name, json.dumps(args), turn) - return report.lookup(read.lookup, result, read.lookup_arg, read.sides) + return report.lookup(read.lookup, result, read.lookup_arg, read.sides, now_local(self.cfg.tz).date()) async def _ask_model(self, working: list[dict], system: str, read, turn: Turn, draft, on_heavy=None, conv_id=None) -> str: """The model's answer. If it has not answered in TURN_SECONDS, ask again once with one reasoning step less (medium > low > diff --git a/lib/pollen/parse.py b/lib/pollen/parse.py index dd28e76..d1bd82b 100644 --- a/lib/pollen/parse.py +++ b/lib/pollen/parse.py @@ -11,6 +11,7 @@ LEVEL_EMOJI = {"Low": "๐ŸŸข", "Moderate": "๐ŸŸ ", "High": "๐Ÿ”ด"} DISTRICTS = {"Central", "East Gippsland", "Mallee", "North Central", "North East", "Northern Country", "South West", "West and South Gippsland", "Wimmera"} +GRASS_HEADING, ASTHMA_HEADING = "Victorian District Grass Pollen Forecast", "Thunderstorm Asthma Forecast" class _TextLines(HTMLParser): @@ -34,15 +35,19 @@ def handle_data(self, data): self.lines.append(text) +def _find(lines: list[str], heading: str) -> int | None: + """The first line that starts with `heading`, whatever its case (the site adds words after a heading at times).""" + return next((i for i, line in enumerate(lines) if line.lower().startswith(heading.lower())), None) + + def _section(lines: list[str], heading: str, stops: list[str]) -> list[str]: - """Lines after the first line equal to `heading`, up to a line starting with a stop.""" - try: - i = lines.index(heading) - except ValueError: + """Lines after the heading's, up to a line starting with a stop.""" + i = _find(lines, heading) + if i is None: return [] out = [] for line in lines[i + 1:]: - if any(line.startswith(s) for s in stops): + if any(line.lower().startswith(s.lower()) for s in stops): break out.append(line) return out @@ -79,9 +84,9 @@ def parse_melbourne_pollen(html: str) -> dict: except ValueError: pass - grass = _section(lines, "Victorian District Grass Pollen Forecast", ["Thunderstorm Asthma Forecast"]) - asthma = _section(lines, "Thunderstorm Asthma Forecast", ["Note:", "Recent News"]) - after = lines[lines.index("Thunderstorm Asthma Forecast"):] if "Thunderstorm Asthma Forecast" in lines else [] + grass = _section(lines, GRASS_HEADING, [ASTHMA_HEADING]) + asthma = _section(lines, ASTHMA_HEADING, ["Note:", "Recent News"]) + after = lines[i:] if (i := _find(lines, ASTHMA_HEADING)) is not None else [] return { "melbourne_grass": level, "melbourne_date": date, diff --git a/lib/pollen/source.py b/lib/pollen/source.py index 6337c0a..e7ebce9 100644 --- a/lib/pollen/source.py +++ b/lib/pollen/source.py @@ -14,10 +14,10 @@ from .. import intent from ..config import Config from ..snapshots import Snapshots -from ..timeutil import now_local, when +from ..timeutil import local_date, now_local, when from ..tools import Tool, Turn from ..warm import Warmer, in_sync_hours -from .parse import LEVEL_EMOJI, parse_melbourne_pollen +from .parse import DISTRICTS, LEVEL_EMOJI, parse_melbourne_pollen log = logging.getLogger(__name__) @@ -33,11 +33,20 @@ PARAMETERS = {"type": "object", "properties": {}} +def _district(name: str) -> str: + """The site's name for this district, whatever its case. One the site doesn't have is warned about: no level can be found for it.""" + known = {d.lower(): d for d in DISTRICTS} + if name.lower() not in known: + log.warning("POLLEN_DISTRICT %r is not one of the site's districts (%s): no district level will be found", name, + ", ".join(sorted(DISTRICTS))) + return known.get(name.lower(), name) + + class Pollen: name = "Pollen" def __init__(self, cfg: Config, transport=None): - self.tz, self.district, self.place = cfg.tz, cfg.pollen_district, cfg.place + self.tz, self.district, self.place = cfg.tz, _district(cfg.pollen_district), cfg.place self.client = httpx.AsyncClient(timeout=15, headers=HEADERS, follow_redirects=True, transport=transport) self.data: dict | None = None # the parsed page self.fetched_at = 0.0 # when (epoch) @@ -93,13 +102,22 @@ def now(self) -> datetime: def in_season(self) -> bool: return self.now().month in SEASON_MONTHS + @property + def page(self) -> dict | None: + """The parsed page, if it is this season's: its grass forecast's date says (else when it was fetched). Last season's page, + kept over the summer or still on the site on 1 October, is never shown.""" + if self.data is None or not self.in_season(): + return None + day = self.data.get("melbourne_date") or local_date(int(self.fetched_at), self.tz) + return self.data if day >= date(self.now().year, SEASON_MONTHS[0], 1) else None + async def warm(self, fresh: bool = True) -> str: - """Fetch the page when it is due: never out of season, never outside the sync hours (unless nothing is cached), and not - more often than the poll interval.""" + """Fetch the page when it is due: never out of season, never outside the sync hours (unless this season's page isn't + cached), and not more often than the poll interval.""" if not self.in_season(): return "Pollen out of season" - age = time.time() - self.fetched_at - if self.data is not None and (not in_sync_hours(self.now()) or age < POLLEN_REFRESH_SECONDS - 5 and not fresh): + young = self.data is not None and time.time() - self.fetched_at < POLLEN_REFRESH_SECONDS - 5 and not fresh + if young or self.page is not None and not in_sync_hours(self.now()): return "Pollen cached" self.requests += 1 res = await self.client.get(URL, headers=self._validators) @@ -114,13 +132,14 @@ async def warm(self, fresh: bool = True) -> str: self._save() if not self.data["melbourne_grass"] and not self.data["district_grass"]: log.warning("Pollen: no grass pollen level found on the page (has its layout changed?)") + if self.district not in self.data["thunderstorm_asthma"]: + log.warning("Pollen: no thunderstorm asthma level for %s on the page (has its layout changed?)", self.district) return "Pollen 1 req" # ---------- reading ---------- def current(self) -> dict: """{"grass": {level, emoji, date} | None, "asthma": {level, emoji, updated} | None, "fetched_at": epoch}""" - d = self.data if self.in_season() else {} # last season's page is never shown - d = d or {} + d = self.page or {} grass = d.get("melbourne_grass") or (d.get("district_grass") or {}).get(self.district) asthma = (d.get("thunderstorm_asthma") or {}).get(self.district) return {"grass": {"level": grass, "emoji": LEVEL_EMOJI[grass], "date": d.get("melbourne_date")} if grass else None, @@ -150,7 +169,7 @@ def lines(self) -> list[str]: async def handle(self, args: dict, turn: Turn | None = None) -> str: if not self.in_season(): return json.dumps({"error": OFF_SEASON}) - if self.data is None and not args.get("cached"): # the report ("cached") never fetches + if self.page is None and not args.get("cached"): # the report ("cached") never fetches try: await self.warm(True) except Exception as e: @@ -165,6 +184,6 @@ async def handle(self, args: dict, turn: Turn | None = None) -> str: 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"]: - out["asthma_note"] = "No thunderstorm asthma forecast now (it runs 1 Oct - 31 Dec)." + if not cur["asthma"]: # in season the page always has one: it may have changed + out["asthma_note"] = "The page had no thunderstorm asthma level for this district when it was last read." return json.dumps(out, ensure_ascii=False) diff --git a/lib/report.py b/lib/report.py index 580c58b..b2ca0d1 100644 --- a/lib/report.py +++ b/lib/report.py @@ -3,7 +3,7 @@ import json import re -from datetime import datetime +from datetime import date, datetime from telegram import MessageEntity @@ -58,21 +58,22 @@ def heading_entities(text: str) -> list[MessageEntity]: 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.""" +def _stamp(result: dict, today: date) -> str | None: + """The time of the data, "11:45pm", with its day when that isn't today ("Sat 10 Oct 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'}" + clock = f"{at.hour % 12 or 12}:{at:%M}{'am' if at.hour < 12 else 'pm'}" + return clock if at.date() == today else f"{at:%a} {at.day} {at:%b} {clock}" return None -def _heading(name: str, result: dict) -> str: +def _heading(name: str, result: dict, today: date) -> str: """"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) + note = ", ".join(x for x in (result.get("place"), _stamp(result, today)) if x) return f"{name} ({note})" if note else name @@ -158,9 +159,9 @@ def reading_lines(now: dict, names: list[str], sides: list[str]) -> list[str]: return [line for line in lines if line] -def lookup(kind: str, result: str | None, names: list[str], sides: list[str]) -> str | None: - """A plain lookup written from its one tool result: "reading", "air", "pollen" or "forecast". None when the result can't be - used (the model then takes the question).""" +def lookup(kind: str, result: str | None, names: list[str], sides: list[str], today: date) -> str | None: + """A plain lookup written from its one tool result: "reading", "air", "pollen" or "forecast", on `today` (local). None when the + result can't be used (the model then takes the question).""" data = _load(result) if not data: return None @@ -190,7 +191,7 @@ def lookup(kind: str, result: str | None, names: list[str], sides: list[str]) -> return "\n".join([_period(data), *lines]) if lines else None elif kind in ("pollen", "forecast"): lines = [f"โ€ข {line}" for line in data.get("lines", [])] - return "\n".join([_heading("Pollen & asthma" if kind == "pollen" else "Forecast", data), *lines]) if lines else None + return "\n".join([_heading("Pollen & asthma" if kind == "pollen" else "Forecast", data, today), *lines]) if lines else None else: return None return "\n".join(lines) or None @@ -202,19 +203,19 @@ def weather_now(result: str | None) -> str | None: return "\n".join(weather_lines(now)) if now else None -def report(results: dict[str, str]) -> str: - """The full report from the tool results by tool name (weather_now, air_quality, pollen_asthma, weather_forecast).""" +def report(results: dict[str, str], today: date) -> str: + """The full report from the tool results by tool name (weather_now, air_quality, pollen_asthma, weather_forecast), on `today` (local).""" sections = [] if "weather_now" in results: now = _load(results["weather_now"]) - sections.append([_heading("Weather station", now or {}), *(weather_lines(now) if now else ["โ€ข not available right now"])]) + sections.append([_heading("Weather station", now or {}, today), *(weather_lines(now) if now else ["โ€ข not available right now"])]) if "air_quality" in results: air = _load(results["air_quality"]) - sections.append([_heading("Air quality", air or {}), *(air_lines(air) if air else ["โ€ข not available right now"])]) + sections.append([_heading("Air quality", air or {}, today), *(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", []))]) + sections.append([_heading("Pollen & asthma", pollen, today), *(f"โ€ข {line}" for line in pollen.get("lines", []))]) if forecast := _load(results.get("weather_forecast")): - sections.append([_heading("Forecast", forecast), *(f"โ€ข {line}" for line in forecast.get("lines", []))]) + sections.append([_heading("Forecast", forecast, today), *(f"โ€ข {line}" for line in forecast.get("lines", []))]) return "\n\n".join("\n".join(section) for section in sections) or "No readings are available right now." diff --git a/tests/test_pollen.py b/tests/test_pollen.py index 0201a2c..c921a58 100644 --- a/tests/test_pollen.py +++ b/tests/test_pollen.py @@ -61,11 +61,31 @@ async def test_the_lines_have_a_level_emoji_and_never_name_the_district(tmp_path await pollen.close() -async def test_a_forecast_for_another_day_and_a_missing_asthma_forecast_are_said_so(tmp_path): +async def test_a_forecast_for_another_day_and_a_missing_asthma_forecast_are_said_so(tmp_path, caplog): pollen, _ = make(tmp_path, lambda: page(grass="Moderate", day=SEASON_DAY + timedelta(days=1), season=False)) - await pollen.start() + with caplog.at_level("WARNING"): + await pollen.start() assert pollen.lines() == ["Grass pollen: ๐ŸŸ  Moderate (forecast for Wed 11 Nov)"] - assert "asthma_note" in json.loads(await pollen.handle({})) + assert json.loads(await pollen.handle({}))["asthma_note"] == "The page had no thunderstorm asthma level for this district when it was last read." + assert "no thunderstorm asthma level for Central on the page" in caplog.text # in season the page should have one + await pollen.close() + + +def test_a_heading_with_words_after_it_or_in_another_case_is_still_found(): + reworded = page(grass="Low", asthma="High").replace("

Thunderstorm Asthma Forecast

", + "

Thunderstorm asthma forecast for Tuesday 10 November

") + got = parse_melbourne_pollen(reworded) + assert got["thunderstorm_asthma"] == {"Central": "High", "Wimmera": "High"} and got["asthma_updated"] == "1 Oct 2026 4:00pm" + assert got["district_grass"] == {"Central": "Low", "Mallee": "Moderate"} # the grass list still ends at it + + +async def test_the_district_is_the_sites_whatever_its_case_and_one_it_lacks_is_warned_about(tmp_path, caplog): + pollen, _ = make(tmp_path, pollen_district="north east") + assert pollen.district == "North East" + await pollen.close() + with caplog.at_level("WARNING"): + pollen, _ = make(tmp_path, pollen_district="Centrl") + assert pollen.district == "Centrl" and "POLLEN_DISTRICT 'Centrl' is not one of the site's districts" in caplog.text await pollen.close() @@ -156,10 +176,24 @@ async def test_last_seasons_page_is_not_shown_and_the_report_leaves_the_block_ou assert january.data is not None and january.lines() == [] and january.current()["grass"] is None and calls == [] result = await january.handle({"cached": True}) assert "error" in json.loads(result) - assert "Pollen" not in report.report({"pollen_asthma": result}) # no block in the report + assert "Pollen" not in report.report({"pollen_asthma": result}, date(2027, 1, 5)) # no block in the report await january.close() +async def test_last_seasons_page_is_never_shown_on_the_first_of_october_and_is_fetched_again_even_at_night(tmp_path): + december = lambda: page(grass="High", asthma="High", day=date(2025, 12, 31)) + pollen, _ = make_on(tmp_path, date(2025, 12, 31), html=december) + await pollen.start() + await pollen.close() + october, calls = make_on(tmp_path, date(2026, 10, 1), hour=3, html=december) # a restart at 3 am, the saved page December's + assert october.data is not None and october.current()["grass"] is None and october.current()["asthma"] is None + await october.warm(True) # outside the sync hours: fetched all the same + assert len(calls) == 1 and october.current()["grass"] is None # the site still has December's page (not modified) + october.data["melbourne_date"], october.fetched_at = None, datetime(2025, 12, 31, 12, tzinfo=TZ).timestamp() + assert october.page is None # with no date on the page, when it was fetched says + await october.close() + + async def test_the_first_warm_on_the_first_of_october_fetches_without_a_restart(tmp_path): clock = {"day": date(2026, 9, 30)} pollen, calls = make(tmp_path) diff --git a/tests/test_report.py b/tests/test_report.py index 5d05728..ca5c13b 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -1,4 +1,5 @@ import json +from datetime import date from lib import report @@ -20,6 +21,8 @@ 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", "time": "Fri 2 Oct 2026 6:05pm"}') +TODAY = date(2026, 10, 2) # the day the results above are from + WEATHER = """\ โ€ข Outdoor: ๐Ÿงฅ 12.3 ยฐC, ๐Ÿ’ฆ 94 %, dew point 11.3 ยฐC, VPD 0.085 kPa โ€ข Indoor: ๐Ÿงฅ 18.3 ยฐC, 63 % @@ -28,7 +31,7 @@ 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}) + got = report.report({"weather_now": NOW, "air_quality": AIR, "pollen_asthma": POLLEN, "weather_forecast": FORECAST}, TODAY) assert got == f"""Weather station (2:21am) {WEATHER} @@ -67,10 +70,10 @@ def test_a_dry_calm_day_has_no_rain_line_noise(): def test_a_failed_or_missing_source_is_left_out_or_said_so_and_the_forecast_without_a_tag_has_a_plain_heading(): 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"]}'}) + "weather_forecast": '{"lines": ["Today: โ˜€๏ธ Sunny. 20โ€“25ยฐC"]}'}, TODAY) 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." + assert report.report({}, TODAY) == "No readings are available right now." def test_sun_uv_and_wind_are_left_out_when_zero_and_shown_when_not(): @@ -188,11 +191,11 @@ 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, 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 + assert report.lookup("air", AIR, ["pm10"], [], TODAY) == "โ€ข PM10: 3.0 ยตg/mยณ ๐ŸŸข good" + assert report.lookup("air", AIR, [], [], TODAY).splitlines()[0] == "โ€ข PM2.5: 1.4 ยตg/mยณ ๐ŸŸข good (AQI 8)" + assert report.lookup("pollen", POLLEN, [], [], TODAY) == "Pollen & asthma (Melbourne, 6:00pm)\nโ€ข Grass pollen: ๐ŸŸข Low\nโ€ข Thunderstorm asthma risk: ๐ŸŸข Low" + assert report.lookup("forecast", FORECAST, [], [], TODAY).startswith("Forecast (Melbourne, 6:05pm)\nโ€ข Today: ") + assert report.lookup("pollen", json.dumps({"error": "off season"}), [], [], TODAY) is None def test_a_short_period_highs_and_lows_lookup_gives_each_sides_extremes_with_their_times(): @@ -200,7 +203,7 @@ def test_a_short_period_highs_and_lows_lookup_gives_each_sides_extremes_with_the "outdoor.temperature": {"unit": "โ„ƒ", "low": "9.0", "low_when": "at 5am", "low_date": "Fri 2 Oct 2026", "high": "16.2", "high_when": "at 2pm", "high_date": "Fri 2 Oct 2026"}, "outdoor.humidity": {"unit": "%", "low": "40", "high": "95"}}}) - assert report.lookup("extremes", result, [], []) == ("Fri 02 Oct โ€“ Fri 02 Oct 2026\n" + assert report.lookup("extremes", result, [], [], TODAY) == ("Fri 02 Oct โ€“ Fri 02 Oct 2026\n" "โ€ข Temperature: low 9.0 ยฐC, Fri 2 Oct at 5am ยท high 16.2 ยฐC, Fri 2 Oct at 2pm") @@ -209,34 +212,41 @@ def test_an_average_or_highs_lookup_covers_the_readings_named(): "outdoor.temperature": {"unit": "โ„ƒ", "low": "9.0", "high": "16.2", "average": "12.4"}, "outdoor.humidity": {"unit": "%", "low": "40", "high": "95", "low_when": "at 3pm", "low_date": "Thu 1 Oct 2026", "high_when": "at 5am", "high_date": "Thu 1 Oct 2026"}, "wind.wind_speed": {"unit": "km/h", "low": "0", "high": "14"}, "wind.wind_gust": {"unit": "km/h", "low": "0", "high": "31", "high_when": "at 2pm", "high_date": "Thu 1 Oct 2026"}}}) - assert report.lookup("extremes", result, ["temperature"], []).splitlines()[1] == "โ€ข Temperature: average 12.4 ยฐC (low 9.0, high 16.2)" - assert report.lookup("extremes", result, ["humidity"], []).splitlines()[1] == "โ€ข Humidity: low 40 %, Thu 1 Oct at 3pm ยท high 95 %, Thu 1 Oct at 5am" - assert [line.split(":")[0] for line in report.lookup("extremes", result, ["wind"], []).splitlines()[1:]] == ["โ€ข Wind", "โ€ข Wind gust"] + assert report.lookup("extremes", result, ["temperature"], [], TODAY).splitlines()[1] == "โ€ข Temperature: average 12.4 ยฐC (low 9.0, high 16.2)" + assert report.lookup("extremes", result, ["humidity"], [], TODAY).splitlines()[1] == "โ€ข Humidity: low 40 %, Thu 1 Oct at 3pm ยท high 95 %, Thu 1 Oct at 5am" + assert [line.split(":")[0] for line in report.lookup("extremes", result, ["wind"], [], TODAY).splitlines()[1:]] == ["โ€ข Wind", "โ€ข Wind gust"] def test_a_days_count_lookup_gives_the_count_the_condition_and_the_period(): result = json.dumps({"period": "Wed 5 Oct 2022 - Sat 3 Oct 2026", "days_checked": 1454, "matching_days": 137, "condition": "UV index โ‰ฅ 10"}) - assert report.lookup("days", result, [], []) == "137 of 1,454 days with UV index โ‰ฅ 10 (Wed 5 Oct 2022 โ€“ Sat 3 Oct 2026)." + assert report.lookup("days", result, [], [], TODAY) == "137 of 1,454 days with UV index โ‰ฅ 10 (Wed 5 Oct 2022 โ€“ Sat 3 Oct 2026)." averaged = json.dumps({"period": "a - b", "days_checked": 10, "matching_days": 2, "note_averaged": "x"}) - assert "may fall slightly short" in report.lookup("days", averaged, [], []) - assert report.lookup("days", json.dumps({"error": "bad"}), [], []) is None + assert "may fall slightly short" in report.lookup("days", averaged, [], [], TODAY) + assert report.lookup("days", json.dumps({"error": "bad"}), [], [], TODAY) is None def test_a_figure_per_period_lookup_gives_the_figure_over_the_whole_period(): result = json.dumps({"period": "Fri 4 Oct 2024 - Sat 3 Oct 2026", "days_checked": 731, "matching_days": 731, "stat": {"what": "Total rain", "of": "rain", "unit": "mm"}, "value": 1234.5, "by_month": {"2024-10": 0.0}}) - assert report.lookup("days", result, [], []) == "Total rain: 1234.5 mm over 731 days (Fri 4 Oct 2024 โ€“ Sat 3 Oct 2026)." + assert report.lookup("days", result, [], [], TODAY) == "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") + assert report.lookup("days", hot, [], [], TODAY).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" + assert report._heading("Forecast", {"place": "Melbourne", "time": "Fri 2 Oct 2026 6:05pm"}, TODAY) == "Forecast (Melbourne, 6:05pm)" + assert report._heading("Weather station", {"time": "Fri 02 Oct 2026 00:07"}, TODAY) == "Weather station (12:07am)" # the station's 24-hour form + assert report._heading("Weather station", {"time": "Fri 02 Oct 2026 13:30"}, TODAY) == "Weather station (1:30pm)" + assert report._heading("Air quality", {"time": "Fri 2 Oct 2026 12:00pm"}, TODAY) == "Air quality (12:00pm)" + assert report._heading("Forecast", {"place": "Melbourne"}, TODAY) == "Forecast (Melbourne)" # no time: as before + assert report._heading("Air quality", {"time": "yesterday"}, TODAY) == "Air quality" and report._heading("Air quality", {"time": None}, TODAY) == "Air quality" + + +def test_a_heading_gives_the_day_of_data_that_is_not_todays(): + stale = report.report({"weather_now": NOW, "pollen_asthma": POLLEN}, date(2026, 10, 4)) # the station has been silent since Friday + assert stale.startswith("Weather station (Fri 2 Oct 2:21am)\n") and "\nPollen & asthma (Melbourne, Fri 2 Oct 6:00pm)\n" in stale + assert report.lookup("forecast", FORECAST, [], [], date(2026, 10, 3)).startswith("Forecast (Melbourne, Fri 2 Oct 6:05pm)\n") + assert [e.length for e in report.heading_entities(stale)] == [len("Weather station"), len("Pollen & asthma")] # still bold def test_when_gives_the_form_the_tools_use_in_their_time_field(): @@ -251,7 +261,7 @@ def test_when_gives_the_form_the_tools_use_in_their_time_field(): 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}) + text = report.report({"weather_now": NOW, "air_quality": AIR, "pollen_asthma": POLLEN, "weather_forecast": FORECAST}, TODAY) 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"] From c1d09ef5ccc64ce0ea6b89aad5a5b99fee1fa247 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 16:48:08 +0000 Subject: [PATCH 20/65] AirGradient: an odd reply is an error, not empty days; the reading stands without the history; spikes go once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A reply that wasn't readings was stored as days without any, and those days were never asked for again: a 200 {"message": "Too many requests"}, any 404 (a wrong location too), a reply that isn't a list. Now only a 404 that says "No data" is an empty period (AirGradient's docs); anything else of the wrong shape raises, so nothing is stored and the days are fetched next time. - The current reading needs the readings before it only to rate them two at a time, but a failing history endpoint failed it outright: the air tool, the report's air section and the particulates alert all broke while the current endpoint worked. When today's readings can't be read, the latest is rated alone, and the log says so. - latest() gives the reading beside when it was taken and what it has been sustained at (the alert's two extras), so nothing is added to the reading and popped again before the model sees it; current() is its reading. The two reads of today's readings are one. The tool's JSON is unchanged (compared byte for byte). - The spike filter doesn't give the same answer twice (10, 10, 100, 30, 10, 10: one pass keeps 30, a second drops it), and a chart ran it twice while its figures ran it once, so they disagreed; compose's air charts didn't run it at all. It now runs once, in rows(), for every caller. lib: +59 โˆ’54 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/airgradient/__init__.py | 4 +- lib/airgradient/source.py | 101 +++++++++++++++++++----------------- lib/alerts/particulates.py | 8 +-- tests/test_airgradient.py | 77 +++++++++++++++++++++------ tests/test_alerts.py | 12 ++--- 5 files changed, 124 insertions(+), 78 deletions(-) diff --git a/lib/airgradient/__init__.py b/lib/airgradient/__init__.py index 0961ac5..4c07dd6 100644 --- a/lib/airgradient/__init__.py +++ b/lib/airgradient/__init__.py @@ -1,4 +1,4 @@ from .metrics import pm25_aqi, rating -from .source import AirGradient +from .source import AirGradient, Latest -__all__ = ["AirGradient", "pm25_aqi", "rating"] +__all__ = ["AirGradient", "Latest", "pm25_aqi", "rating"] diff --git a/lib/airgradient/source.py b/lib/airgradient/source.py index 3b61ecb..1dd40d7 100644 --- a/lib/airgradient/source.py +++ b/lib/airgradient/source.py @@ -18,6 +18,7 @@ import time from datetime import date, datetime, timedelta, UTC from itertools import pairwise +from typing import NamedTuple import httpx @@ -32,7 +33,7 @@ from ..warm import Warmer from .days import DESCRIPTION as DAYS_DESCRIPTION, PARAMETERS as DAYS_PARAMETERS, air_days_tool from . import metrics -from .metrics import AIR_PANELS, ALL_METRICS, MARK_LOW, LABELS, METRICS, epoch, normalise, pm25_aqi, rating, value_of, without_spikes +from .metrics import AIR_PANELS, ALL_METRICS, MARK_LOW, LABELS, METRICS, normalise, pm25_aqi, rating, value_of, without_spikes from .store import AirStore log = logging.getLogger(__name__) @@ -70,6 +71,13 @@ "happened. chart=true sends a graph.") +class Latest(NamedTuple): + """The latest reading as the air_quality tool gives it, and what the particulates alert also needs.""" + reading: dict # the sensor, the time, and each metric's value, unit and rating + at: datetime | None # when it was taken (UTC) + sustained: dict[str, float] # each metric's lowest over the last REQUIRE_CONSECUTIVE readings: what the air has been that long + + class AirGradient: """AirGradient client with its data kept warm and cached: - the current reading and today's history are refreshed every few minutes (warm); @@ -111,14 +119,21 @@ async def close(self): await self.client.aclose() self.store.close() - async def _get(self, kind: str, **params): + async def _get(self, kind: str, **params) -> dict | list: + """The reply: an object for "current", a list of readings for "past". A period with no readings is an empty list (AirGradient + says so with a 404); a reply of any other shape raises, so it is never stored as days without readings.""" self.requests += 1 res = await self.client.get(API.format(loc=self.loc, kind=kind), params={**params, "token": self.token}) - if res.status_code == 404 and kind == "past": # "No data available" for the period + if res.status_code == 404 and kind == "past" and "no data" in res.text.lower(): # "No data available" for the period return [] if res.status_code != 200: raise RuntimeError(f"AirGradient {kind} failed: HTTP {res.status_code}") - return res.json() + data = res.json() + if kind == "past" and isinstance(data, dict) and isinstance(data.get("data"), list): + data = data["data"] + if not isinstance(data, list if kind == "past" else dict): + raise RuntimeError(f"AirGradient {kind} sent something unexpected: {res.text[:100]}") + return data def _lock(self, key) -> asyncio.Lock: return self._locks.setdefault(key, asyncio.Lock()) @@ -147,8 +162,7 @@ def _utc(self, local: datetime) -> str: async def _past(self, start: datetime, end: datetime) -> list[dict]: """Readings between two local times, normalised and oldest first.""" - data = await self._get("past", **{"from": self._utc(start), "to": self._utc(end)}) - raw = data if isinstance(data, list) else data.get("data", []) if isinstance(data, dict) else [] + raw = await self._get("past", **{"from": self._utc(start), "to": self._utc(end)}) return sorted((normalise(r) for r in raw if isinstance(r, dict) and r.get("timestamp")), key=lambda r: r["ts"]) async def _fetch_days(self, days: list[date]): @@ -252,10 +266,7 @@ async def handle(self, args: dict, turn: Turn | None = None) -> str: return json.dumps(await self.history(args.get("start_date"), args.get("end_date"), chart=chart, metrics=args.get("metrics"), turn=turn), ensure_ascii=False) - reading = await self.current() - reading.pop("_time_utc", None) - reading.pop("_sustained", None) - return json.dumps(reading, ensure_ascii=False) + return json.dumps(await self.current(), ensure_ascii=False) except Exception as e: log.warning("AirGradient request failed: %s", e) return f"Error: couldn't read the air-quality sensor ({e})" @@ -264,12 +275,6 @@ async def _days(self, args: dict, turn: Turn | None = None) -> str: """The air_days tool: count, rank and total days from the stored readings.""" return await air_days_tool(self.store, self.tz, args, turn) - async def _previous_row(self, ts: int) -> dict | None: - """The newest of today's cached readings older than `ts` and within MAX_GAP_SECONDS of it, else None.""" - today = to_local(ts, self.tz).date() - rows = [r for r in await self._day_rows(today) if r["ts"] < ts] - return rows[-1] if rows and ts - rows[-1]["ts"] <= MAX_GAP_SECONDS else None - async def _previous_rows(self, ts: int, count: int) -> list[dict]: """Up to `count` of today's cached readings just before `ts`, each within MAX_GAP_SECONDS of the next, oldest first.""" today = to_local(ts, self.tz).date() @@ -281,42 +286,44 @@ async def _previous_rows(self, ts: int, count: int) -> list[dict]: after = r["ts"] return got - async def current(self) -> dict: + async def latest(self) -> Latest: """The latest reading. A rating (and PM2.5's AQI band) is the level the last two readings both reached, so a passing - spike is not called poor until the next reading agrees; the value shown is always the latest one.""" + spike is not called poor until the next reading agrees; the value shown is always the latest one. The readings before it + come from today's history: when that can't be read, the latest is rated alone.""" 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": 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")) + at = datetime.fromisoformat(row["timestamp"].replace("Z", "+00:00")) if row.get("timestamp") else None + need = metrics.REQUIRE_CONSECUTIVE - 1 # the readings before the latest that `sustained` takes in + try: + earlier = await self._previous_rows(int(at.timestamp()), max(need, 1)) if at else [] + except Exception as e: # only the history failing: the latest reading still stands + log.warning("AirGradient: today's readings unavailable (%s); rating the latest alone", e) + earlier = [] + previous, held = (earlier[-1] if earlier else {}), (earlier[-need:] if need else []) + reading = {"sensor": row.get("locationName"), "sensor_type": row.get("locationType"), + "time": when(int(at.timestamp()), self.tz) if at else None} + sustained = {} for name, (_, unit) in METRICS.items(): v = value_of(row, name) - if v is not None: - before = previous.get(name) if previous else None # today's rows are already normalised - rated = v if before is None else min(v, before) # thresholds only rise: the lower value's zone is the one both reached - out[name] = {"value": v, "unit": unit, "rating": rating(name, rated)} - if out[name]["rating"] != rating(name, v): - out[name]["rating_note"] = "rated on the last two readings: the previous one was lower" - out[name]["_rated"] = rated - if "pm2_5" in out: - aqi, band = pm25_aqi(out["pm2_5"]["_rated"]) - out["pm2_5"].update(aqi_us=aqi, band=band) - sustained = {} # each metric's lowest value over the last REQUIRE_CONSECUTIVE readings: what the air has been for that long - if row.get("timestamp"): - earlier = await self._previous_rows(epoch(row["timestamp"]), max(metrics.REQUIRE_CONSECUTIVE - 1, 0)) - for name, entry in out.items(): - if isinstance(entry, dict) and "value" in entry: - sustained[name] = min([entry["value"], *(r[name] for r in earlier if name in r)]) - for entry in out.values(): - if isinstance(entry, dict): - entry.pop("_rated", None) - out["_sustained"] = sustained # for the alerts (removed before the model sees it) - return out + if v is None: + continue + rated = min(v, previous.get(name, v)) # thresholds only rise: the lower value's zone is the one both reached + reading[name] = {"value": v, "unit": unit, "rating": rating(name, rated)} + if reading[name]["rating"] != rating(name, v): + reading[name]["rating_note"] = "rated on the last two readings: the previous one was lower" + if name == "pm2_5": + aqi, band = pm25_aqi(rated) + reading[name].update(aqi_us=aqi, band=band) + sustained[name] = min([v, *(r[name] for r in held if name in r)]) + return Latest(reading, at, sustained) + + async def current(self) -> dict: + """The latest reading as the air_quality tool gives it (see latest).""" + return (await self.latest()).reading async def rows(self, t0: datetime, t1: datetime) -> tuple[list[dict], int, list[date]]: """(readings in [t0, t1] (naive local times), days that only exist as hourly averages, older days still waiting for - the backfill and left out). Finished days not yet stored are fetched first, up to MAX_INLINE_DAYS.""" + the backfill and left out). Finished days not yet stored are fetched first, up to MAX_INLINE_DAYS. A single-reading spike + of the particles is a glitch of the sensor: it is taken out here, once, so no figure or chart has it.""" days = [t0.date() + timedelta(days=k) for k in range((t1.date() - t0.date()).days + 1)] held = await self._held(days) missing = [d for d in days if self._final(d) and held[d] is None] @@ -329,7 +336,7 @@ async def rows(self, t0: datetime, t1: datetime) -> tuple[list[dict], int, list[ counts = await self._held([d for d in days if d not in skipped]) hourly = sum(1 for c in counts.values() if c and c <= HOURLY_MAX_READINGS) lo_ts, hi_ts = t0.replace(tzinfo=self.tz).timestamp(), t1.replace(tzinfo=self.tz).timestamp() - return [r for r in rows if lo_ts <= r["ts"] <= hi_ts], hourly, skipped + return [r for r in without_spikes(rows) if lo_ts <= r["ts"] <= hi_ts], hourly, skipped async def history(self, start: str | None, end: str | None, chart: bool = False, metrics: list[str] | None = None, turn: Turn | None = None) -> dict: @@ -348,7 +355,6 @@ async def history(self, start: str | None, end: str | None, chart: bool = False, before, started = self.requests, time.perf_counter() rows, hourly, skipped = await self.rows(t0, t1) turn.times["data"] = turn.times.get("data", 0.0) + time.perf_counter() - started # for the reply's timing line - rows = without_spikes(rows) # a single-reading spike of the particles is a glitch of the sensor: in no figure or chart log.log(logging.DEBUG if turn.ahead else logging.INFO, "AirGradient %s to %s: %d readings, %d req", f"{t0:%Y-%m-%d}", f"{t1:%m-%d %H:%M}", len(rows), self.requests - before) out = {"sensor_type": "outdoor", "period": f"{t0:%a} {t0.day} {t0:%b %Y} - {t1:%a} {t1.day} {t1:%b %Y}", @@ -401,7 +407,6 @@ def _line(self, name: str, rows: list[dict]) -> tuple[Line, Plotted] | None: def _chart(self, names: list[str], rows: list[dict], period: str) -> Chart | None: """These metrics as one chart. One metric is drawn large, with its rating zones. Several go into panels on a shared time axis, grouped by AIR_PANELS (the particles together, the rest each alone).""" - rows = without_spikes(rows) # (a no-op for the rows history() gives it; a caller with its own rows gets the same drawing) drawn = {n: got for n in names if (got := self._line(n, rows))} if not drawn: return None diff --git a/lib/alerts/particulates.py b/lib/alerts/particulates.py index e75f7f7..c63256f 100644 --- a/lib/alerts/particulates.py +++ b/lib/alerts/particulates.py @@ -26,11 +26,11 @@ class ParticulatesMonitor: 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() + async def check(self, latest=None, now: datetime | None = None): + """latest: the sensor's latest reading (AirGradient.latest(): the reading, when it was taken, what it has been sustained at).""" + reading, when, held = latest if latest is not None else await self.air.latest() now = now or datetime.now(self.air.tz) 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("Particulates: latest reading is stale (%s); skipping", when) return @@ -40,7 +40,7 @@ async def check(self, reading: dict | None = None, now: datetime | None = None): return if when is not None and not confirm.new_reading(m, when.timestamp()): # the sensor has said nothing new since the last check return - held = reading.get("_sustained") or {} # the lowest of the last readings (ANTISPIKE_REQUIRE_CONSECUTIVE): a spike alone can't make the air unhealthy + # held: the lowest of the last readings (ANTISPIKE_REQUIRE_CONSECUTIVE), so a spike alone can't make the air unhealthy high25, high10 = held.get("pm2_5", pm25), held.get("pm10", pm10) bad = (high25 is not None and high25 >= MASK_PM25) or (high10 is not None and high10 >= MASK_PM10) good = (pm25 is None or pm25 <= SAFE_PM25) and (pm10 is None or pm10 <= SAFE_PM10) diff --git a/tests/test_airgradient.py b/tests/test_airgradient.py index 21d7a5f..b4e1d44 100644 --- a/tests/test_airgradient.py +++ b/tests/test_airgradient.py @@ -1,7 +1,12 @@ import json from datetime import datetime, timedelta +import httpx +import pytest + from lib.airgradient import AirGradient, pm25_aqi, rating +from lib.airgradient.metrics import METRICS +from lib.timeutil import local_date from lib.tools import Turn from tests.fakes import TZ, air_transport, config @@ -22,21 +27,21 @@ def test_aqi_and_ratings_follow_the_us_scale(): async def test_current_reading_with_ratings(tmp_path): air, _ = await make(tmp_path) - async def no_previous(ts): - return None # the fake history's PM2.5 depends on the hour, so the previous reading would too - air._previous_row = no_previous + async def no_previous(ts, count): + return [] # the fake history's PM2.5 depends on the hour, so the previous reading would too + air._previous_rows = no_previous reading = json.loads(await air.handle({})) assert reading["pm2_5"]["value"] == 12.0 and reading["pm2_5"]["band"] == "moderate" - assert "_time_utc" not in reading and reading["pm2_5"]["rating"].endswith("poor") + assert set(reading) == {"sensor", "sensor_type", "time", *METRICS} and reading["pm2_5"]["rating"].endswith("poor") await air.close() async def test_a_rating_is_the_level_the_last_two_readings_both_reached(tmp_path): air, _ = await make(tmp_path) for before, band, noted in ((5.0, "good", True), (20.0, "moderate", False)): - async def previous(ts, before=before): - return {"pm2_5": before} - air._previous_row = previous + async def previous(ts, count, before=before): + return [{"pm2_5": before}] + air._previous_rows = previous reading = json.loads(await air.handle({})) assert reading["pm2_5"]["value"] == 12.0 and reading["pm2_5"]["band"] == band assert ("rating_note" in reading["pm2_5"]) is noted @@ -375,14 +380,14 @@ async def test_a_single_reading_spike_is_in_none_of_the_figures_either(tmp_path) from lib.timeutil import local_date from tests.fakes import TZ air, _ = await make(tmp_path) - start = air._now().replace(hour=0, minute=0, second=0) - timedelta(hours=6) - t0 = int(start.replace(tzinfo=TZ).timestamp()) + start = air._now().replace(microsecond=0) - timedelta(hours=1) + t0 = int(start.replace(tzinfo=TZ).timestamp()) + 600 levels = [(1, 2, 3), (1, 2, 4), (15.3, 14.2, 28.7), (3, 8, 10), (2, 4, 5), (2, 3, 4)] rows = [{"ts": t0 + 300 * i, "pm1": a, "pm2_5": b, "pm10": c} for i, (a, b, c) in enumerate(levels)] - async def held(t0_, t1_): - return rows, 0, [] - air.rows = held + async def day_rows(day, refresh=False): + return [r for r in rows if local_date(r["ts"], TZ) == day] + air._day_rows = day_rows out = await air.history(start.strftime("%Y-%m-%d %H:%M:%S"), None) assert out["pm10"]["high"] == 10 and out["pm2_5"]["high"] == 8 and out["pm1"]["high"] == 3 # not 28.7, 14.2 and 15.3 assert out["pm10"]["average"] == round((3 + 4 + 10 + 5 + 4) / 5, 1) # and not in the mean @@ -408,12 +413,50 @@ def test_the_readings_a_rise_needs_are_set_by_the_antispike_setting(): metrics.REQUIRE_CONSECUTIVE = 2 -async def test_current_gives_the_lowest_of_the_last_readings_for_the_alerts_and_not_to_the_model(tmp_path): - from lib.airgradient import metrics +async def test_the_latest_reading_comes_with_the_lowest_of_the_last_readings_for_the_alerts(tmp_path): air, _ = await make(tmp_path) - out = await air.current() - assert set(out["_sustained"]) >= {"pm2_5"} and out["_sustained"]["pm2_5"] <= out["pm2_5"]["value"] - assert "_sustained" not in json.loads(await air.handle({}, None)) + reading, at, sustained = await air.latest() + assert set(sustained) >= {"pm2_5"} and sustained["pm2_5"] <= reading["pm2_5"]["value"] and at.tzinfo is not None + assert json.loads(await air.handle({}, None)) == json.loads(json.dumps(reading)) # the tool gives the reading alone + await air.close() + + +async def test_the_reading_is_rated_alone_when_only_the_history_fails(tmp_path, caplog): + air, _ = await make(tmp_path) + + async def down(day, refresh=False): + raise RuntimeError("AirGradient past failed: HTTP 500") + air._day_rows = down + reading = json.loads(await air.handle({})) + assert reading["pm2_5"]["value"] == 12.0 and "rating_note" not in reading["pm2_5"] and "rating the latest alone" in caplog.text + assert (await air.latest()).sustained["pm2_5"] == 12.0 + await air.close() + + +@pytest.mark.parametrize("status, body", [(200, {"message": "Too many requests"}), (404, {"message": "Location not found"}), + (200, "OK")]) +async def test_a_reply_that_is_not_readings_is_an_error_and_never_stored_as_a_day_without_any(tmp_path, status, body): + air = AirGradient(config(tmp_path), transport=httpx.MockTransport(lambda request: httpx.Response(status, json=body))) + day = datetime.now(TZ).date() - timedelta(days=3) + with pytest.raises(RuntimeError): + await air._fetch_days([day]) + assert air.store.counts([day])[day] is None # not stored: asked again next time + await air.close() + + +async def test_a_chart_and_its_figures_take_out_the_same_spikes(tmp_path): + air, _ = await make(tmp_path) + now = datetime.now(TZ).replace(tzinfo=None, microsecond=0) + first = int((now - timedelta(minutes=40)).replace(tzinfo=TZ).timestamp()) + spiky = [{"ts": first + i * 300, "pm2_5": v} for i, v in enumerate((10.0, 10.0, 100.0, 30.0, 10.0, 10.0))] + + async def day_rows(day, refresh=False): + return [r for r in spiky if local_date(r["ts"], TZ) == day] + air._day_rows = day_rows + turn = Turn() + out = await air.history(f"{now - timedelta(hours=1):%Y-%m-%d %H:%M:%S}", f"{now:%Y-%m-%d %H:%M:%S}", chart=True, turn=turn) + line = turn.charts[0].panels[0].lines[0] + assert out["pm2_5"]["high"] == line.records["high"][1] == 30.0 and 100.0 not in line.y # one pass: the spike, not what was next to it await air.close() diff --git a/tests/test_alerts.py b/tests/test_alerts.py index b557b9c..7b07b05 100644 --- a/tests/test_alerts.py +++ b/tests/test_alerts.py @@ -3,6 +3,7 @@ import pytest +from lib.airgradient import Latest from lib.alerts import ParticulatesMonitor, AlertState, Notifier, WeatherMonitor, with_footer from tests.fakes import TZ @@ -219,8 +220,8 @@ async def test_small_temperature_differences_are_noise(tmp_path): # ---------- air ---------- def air_reading(pm25, pm10=10.0, age=timedelta(minutes=5)): - return {"pm2_5": {"value": pm25, "aqi_us": 160}, "pm10": {"value": pm10}, - "_time_utc": datetime.now(timezone.utc) - age} + """What air.latest() gives: the reading, when it was taken, and (none here: the reading's own) the lowest of the last readings.""" + return Latest({"pm2_5": {"value": pm25, "aqi_us": 160}, "pm10": {"value": pm10}}, datetime.now(timezone.utc) - age, {}) class FakeAir: @@ -874,11 +875,8 @@ async def notify(text, link=None, **kw): mon = ParticulatesMonitor(FakeAir(), state, notify) stamp = datetime.now(timezone.utc) - def reading(minutes, latest, before): - r = air_reading(latest) - r["_time_utc"] = stamp + timedelta(minutes=minutes) - r["_sustained"] = {"pm2_5": min(latest, before)} # what air.current() gives: the lowest of the last readings - return r + def reading(minutes, latest, before): # the lowest of the last readings, as air.latest() gives it + return Latest(air_reading(latest).reading, stamp + timedelta(minutes=minutes), {"pm2_5": min(latest, before)}) await mon.check(reading(0, 90, 5)) # a spike: the reading before it was clean await mon.check(reading(30, 90, 5)) # and again at the next check, still alone assert not sent and state.monitor["particulates"]["above"] == 0 From f6255e3b7d68208b7ef3ce8ecb979181233c56f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 16:57:28 +0000 Subject: [PATCH 21/65] The air scan fits in memory and keeps to the clock; the rain analyses' edge cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - air_scan held every weather series' 300 shuffled copies at once: 1.3 GB at the peak for 400 days (measured), and nothing capped the period. Each weather series' shuffles are now made, used against every air series and dropped before the next; the ranks are single precision; series with no readings (a metric the sensor doesn't have) are left out; and a period is cut to its latest 400 days, with a note. The same 400 days peak at 77 MB, in under a third of the time. On test worlds of 60 to 400 days, all 72 results are the same as before to six decimals: no verdict changes, identical p-values (the random draws keep their order). - The scan's days were 48 slots of real time from the first midnight, so after a daylight-saving change every day started at 1 am (October) or 11 pm (April), shifting the time-of-day averages and the daily means by an hour. Slots are now the local clock's, 48 a day (the skipped hour empty, the repeated one filled twice). scan() takes the zone for it. - With no weather readings to compare, the scan said "Nothing stands out - no reading went with air quality more than chance would". It now says there was nothing to compare ("Not enough data to say"). - analyse_air raised TypeError when the dry average was zero (the ratio was formatted anyway), and an event that changed nothing (zero before and after) counted as both cleaner and worse ("-4 about the same"). The ratio is left out when there is none; an unchanged event is neither. - analyse's rain shares were of all the slots' rain, its time shares only of the slots with a change before them, so the shares didn't add up and "x its fair share" was off whenever a slot had no reading 3 hours earlier. Both are now of the same slots. lib: +58 โˆ’35 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/analysis/pairs.py | 12 +++++--- lib/analysis/scan.py | 72 ++++++++++++++++++++++++++----------------- lib/compose.py | 9 ++++-- tests/test_compose.py | 7 +++++ tests/test_ecowitt.py | 11 +++++++ tests/test_scan.py | 39 +++++++++++++++++++---- 6 files changed, 109 insertions(+), 41 deletions(-) diff --git a/lib/analysis/pairs.py b/lib/analysis/pairs.py index dc25c28..4bdea98 100644 --- a/lib/analysis/pairs.py +++ b/lib/analysis/pairs.py @@ -94,11 +94,13 @@ def analyse(driver: dict[int, float], rain: dict[int, float], threshold: float, c = change_before(driver, t) if c is not None: groups["falling" if c <= -threshold else "rising" if c >= threshold else "steady"].append(t) + counted = [t for ts in groups.values() for t in ts] # the slots with a change before them: the shares are of these + counted_mm = sum(rain[t] for t in counted) table = {} for name, ts in groups.items(): mm = sum(rain[t] for t in ts) - time_share = len(ts) / max(sum(map(len, groups.values())), 1) - rain_share = mm / total if total else 0.0 + time_share = len(ts) / max(len(counted), 1) + rain_share = mm / counted_mm if counted_mm else 0.0 table[name] = {"slots": len(ts), "share_of_time": f"{100 * time_share:.0f}%", "rain_mm": round(mm, 1), "share_of_rain": f"{100 * rain_share:.0f}%", "rain_vs_fair_share": f"{rain_share / time_share:.1f}x" if time_share else None, @@ -162,8 +164,8 @@ def analyse_air(values: dict[int, float], rain: dict[int, float], limits: tuple[ if len(before) >= MIN_WASH_SLOTS and len(after) >= MIN_WASH_SLOTS: events.append({"start": s[0], "mm": round(sum(rain[t] for t in s), 1), "before": float(np.mean(before)), "after": float(np.mean(after))}) - cleaned = sum(1 for e in events if e["after"] <= CLEANED * e["before"]) - worse = sum(1 for e in events if e["after"] * CLEANED >= e["before"]) + cleaned = sum(1 for e in events if e["after"] < e["before"] and e["after"] <= CLEANED * e["before"]) # unchanged (as zero to + worse = sum(1 for e in events if e["after"] > e["before"] and e["after"] * CLEANED >= e["before"]) # zero) is neither ratio = wet_level / dry_level if wet_level is not None and dry_level else None days: dict = {} for t in slots: @@ -181,7 +183,7 @@ def analyse_air(values: dict[int, float], rain: dict[int, float], limits: tuple[ findings = [] if wet_level is not None and dry_level is not None: findings.append(f"Average {label} {wet_level:.1f} in wet half-hours ({word(wet_level)}) against {dry_level:.1f} when dry " - f"({word(dry_level)}): {ratio:.2f}x") + f"({word(dry_level)})" + (f": {ratio:.2f}x" if ratio is not None else "")) if events: findings.append(f"Over {len(events)} rain events (1 mm or more) with readings around them: {cleaned} left the air cleaner " f"in the 6 hours after than the 6 before, {worse} worse and {len(events) - cleaned - worse} about the same") diff --git a/lib/analysis/scan.py b/lib/analysis/scan.py index b65123b..11a6658 100644 --- a/lib/analysis/scan.py +++ b/lib/analysis/scan.py @@ -9,10 +9,11 @@ skewed, and a rank correlation is not thrown by them.""" import warnings +from datetime import tzinfo import numpy as np -from ..timeutil import MIN_DAY_SLOTS, SLOT +from ..timeutil import MIN_DAY_SLOTS, SLOT, naive_local PER_DAY = 48 LAGS = range(13) # the weather leads the air by 0 to 6 hours @@ -61,13 +62,22 @@ def benjamini_hochberg(pvalues: list[float], q: float = FDR) -> list[bool]: return keep -def to_grid(series: dict[int, float], origin: int, days: int) -> np.ndarray: - """A series as an array of 30-minute slots from `origin`, NaN where there is no reading.""" +def clock_slots(origin: int, days: int, tz: tzinfo) -> np.ndarray: + """Each 30-minute slot from `origin` (a midnight in tz) as a slot of the local clock, 48 a day: across a daylight-saving change + a time of day keeps its place (the skipped hour's slots stay empty, the repeated hour's are filled twice).""" + start = naive_local(origin, tz) + return np.array([int((naive_local(origin + k * SLOT, tz) - start).total_seconds()) // SLOT for k in range(days * PER_DAY + 2)]) + + +def to_grid(series: dict[int, float], origin: int, days: int, clock: np.ndarray) -> np.ndarray: + """A series as an array of the local clock's 30-minute slots from `origin` (clock: clock_slots), NaN where there is no reading.""" out = np.full(days * PER_DAY, np.nan) - for t, v in series.items(): - k = (t - origin) // SLOT - if 0 <= k < len(out): - out[k] = v + times = (np.fromiter(series, np.int64, len(series)) - origin) // SLOT + values = np.fromiter(series.values(), float, len(series)) + inside = (times >= 0) & (times < len(clock)) + slots = clock[times[inside]] + kept = slots < len(out) + out[slots[kept]] = values[inside][kept] return out @@ -86,8 +96,9 @@ def centred_ranks(x: np.ndarray) -> tuple[np.ndarray, np.ndarray]: def _ranked(grid: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - """(anomaly ranks centred, mask, their squares): what a series contributes to every pair it is in.""" - x, m = centred_ranks(anomalies(grid)) + """(anomaly ranks centred, mask, their squares): what a series contributes to every pair it is in. Single precision: + the correlations are judged to two decimals, and the shuffles take half the memory.""" + x, m = (a.astype(np.float32) for a in centred_ranks(anomalies(grid))) return x, m, x * x @@ -123,6 +134,14 @@ def within_day(weather: tuple, air: tuple, shuffled: tuple) -> tuple[float, int, return float(observed[best]), best, (1 + int((strongest >= abs(observed[best])).sum())) / (len(strongest) + 1) +def _within_day_tests(weather: np.ndarray, air: dict[str, tuple], permutations: np.ndarray) -> dict[str, tuple | None]: + """within_day of one weather series against every air series (`air`: their _ranked results). The weather's days shuffled + are the bulk of a scan's memory (100 MB and more for a year), so they are made for one weather series at a time.""" + ranked = _ranked(weather) + xs, ms = _shuffled(ranked[0], permutations), _shuffled(ranked[1], permutations) + return {name: within_day(ranked, a, (xs, xs * xs, ms)) for name, a in air.items()} + + def daily_means(grid: np.ndarray) -> np.ndarray: """One mean per day (NaN for a day with under 12 hours of readings).""" counts = (~np.isnan(grid)).reshape(-1, PER_DAY).sum(axis=1) @@ -177,42 +196,39 @@ def spread(secs: np.ndarray) -> float: def scan(air: dict[str, dict[int, float]], weather: dict[str, dict[int, float]], direction: dict[int, float] | None, - speed: dict[int, float] | None, origin: int, days: int, seed: int = 1, shuffles: int = PERMUTATIONS) -> dict: + speed: dict[int, float] | None, origin: int, days: int, tz: tzinfo, seed: int = 1, shuffles: int = PERMUTATIONS) -> dict: """The whole scan. `air` and `weather` map a name to {epoch: value} at 30-minute slots; `origin` is the epoch of the - first slot and `days` the number of days. Returns {"tests": [...], "directions": [...], "note"?: ...} where each test - is {air, weather, r, lag_hours, r_daily, days, p, survives}.""" - grids = {k: to_grid(v, origin, days) for k, v in air.items()} - wgrids = {k: to_grid(v, origin, days) for k, v in weather.items()} + first slot (a midnight in `tz`) and `days` the number of days. Returns {"tests": [...], "directions": [...], "note"?: ...} + where each test is {air, weather, r, lag_hours, r_daily, days, p, survives}.""" + clock = clock_slots(origin, days, tz) # series with no readings (a metric the sensor doesn't have) are left out + grids = {k: to_grid(v, origin, days, clock) for k, v in air.items() if v} + wgrids = {k: to_grid(v, origin, days, clock) for k, v in weather.items() if v} overlap = max((int(((~np.isnan(g)).reshape(-1, PER_DAY).sum(axis=1) >= MIN_DAY_SLOTS).sum()) for g in grids.values()), default=0) if overlap < MIN_DAYS: return {"tests": [], "directions": [], "note": f"Only {overlap} days have enough air-quality readings; at least {MIN_DAYS} are needed."} rng = np.random.default_rng(seed) permutations = np.array([rng.permutation(days) for _ in range(shuffles)]) - # what each series contributes to every pair, worked out once - air_ranked = {k: _ranked(g) for k, g in grids.items()} - air_daily = {k: daily_means(g) for k, g in grids.items()} - weather_prepared = {} - for k, g in wgrids.items(): - ranked = _ranked(g) - xs, ms = _shuffled(ranked[0], permutations), _shuffled(ranked[1], permutations) - weather_prepared[k] = (ranked, (xs, xs * xs, ms), daily_means(g)) + air_ranked = {k: _ranked(g) for k, g in grids.items()} # what each air series contributes to every pair, worked out once + inside = {w: _within_day_tests(g, air_ranked, permutations) for w, g in wgrids.items()} + air_daily, weather_daily = ({k: daily_means(g) for k, g in gs.items()} for gs in (grids, wgrids)) tests = [] for a_name in grids: - for w_name, (ranked, shuffled, w_daily) in weather_prepared.items(): - inside = within_day(ranked, air_ranked[a_name], shuffled) - r_daily, n_days, p_daily = day_to_day(w_daily, air_daily[a_name], rng, DAILY_PERMUTATIONS * shuffles // PERMUTATIONS) - if inside is None and p_daily is None: + for w_name in wgrids: + r_daily, n_days, p_daily = day_to_day(weather_daily[w_name], air_daily[a_name], rng, DAILY_PERMUTATIONS * shuffles // PERMUTATIONS) + if inside[w_name][a_name] is None and p_daily is None: continue - r, lag, p = inside or (None, 0, 1.0) + r, lag, p = inside[w_name][a_name] or (None, 0, 1.0) best_p = min(p, p_daily if p_daily is not None else 1.0) * 2 # two views of one pair: allow for both tests.append({"air": a_name, "weather": w_name, "r": r, "lag_hours": lag * SLOT / 3600, "r_daily": r_daily, "days": n_days, "p": min(1.0, best_p)}) directions = [] if direction and speed: - d, s = to_grid(direction, origin, days), to_grid(speed, origin, days) + d, s = to_grid(direction, origin, days, clock), to_grid(speed, origin, days, clock) for a_name, a in grids.items(): if (found := direction_effect(a, d, s, permutations)) is not None: directions.append({"air": a_name, **found}) + if not tests and not directions: # "nothing stands out" would claim a comparison that wasn't made + return {"tests": [], "directions": [], "note": "No weather readings overlap the air-quality readings enough to compare."} keep = benjamini_hochberg([t["p"] for t in tests] + [d["p"] for d in directions]) for t, k in zip([*tests, *directions], keep): t["survives"] = k diff --git a/lib/compose.py b/lib/compose.py index 9b74f16..465debe 100644 --- a/lib/compose.py +++ b/lib/compose.py @@ -8,7 +8,7 @@ import asyncio import json import logging -from datetime import date, datetime, time, tzinfo +from datetime import date, datetime, time, timedelta, tzinfo from statistics import fmean from .airgradient.metrics import ALL_METRICS, CHART_UNITS, LABELS, RATINGS, ZONES, zone @@ -53,6 +53,7 @@ SCAN_DAYS = 90 +SCAN_MAX_DAYS = 400 # a longer scan is cut to its latest days: the shuffles grow with the period SCAN_AIR = ("pm2_5", "pm10", "co2", "voc_index", "nox_index") # What the scan compares the air with: name -> (group, field, label, unit) SCAN_WEATHER = {**{n: (WEATHER[n].group, WEATHER[n].field, WEATHER[n].label.lower(), WEATHER[n].unit) @@ -220,7 +221,11 @@ async def air_scan(self, args: dict, turn: Turn | None = None) -> str: if isinstance(period := self.period(args, SCAN_DAYS), str): return _error(period) first, last = period + trimmed = (last - first).days >= SCAN_MAX_DAYS + first = max(first, last - timedelta(days=SCAN_MAX_DAYS - 1)) air, notes = await self.air_series(list(SCAN_AIR) if metric == "all" else [metric], first, last) + if trimmed: + notes["period"] = f"Only the last {SCAN_MAX_DAYS} days can be scanned per question; this covers {first} to {last}" weather = {} for name, (group, field, _, _) in SCAN_WEATHER.items(): if values := self.weather(group, field, first, last): @@ -236,7 +241,7 @@ async def air_scan(self, args: dict, turn: Turn | None = None) -> str: weather_names = {**{k: (v[2], v[3]) for k, v in SCAN_WEATHER.items()}, "pressure_change": ("3-hour pressure change", "hPa"), "rain": ("rain", "mm")} origin, days = day_bounds(first, self.tz)[0], (last - first).days + 1 - result = await asyncio.to_thread(scan.scan, {m: v[0] for m, v in air.items()}, weather, direction, speed, origin, days) + result = await asyncio.to_thread(scan.scan, {m: v[0] for m, v in air.items()}, weather, direction, speed, origin, days, self.tz) summary = scan.summarise(result, air_names, weather_names) strongest = summary.pop("strongest") out = {"period": f"{first} to {last}", "resolution": "30-minute slots; each pair compared within the day and day to day", diff --git a/tests/test_compose.py b/tests/test_compose.py index 812a875..da81f23 100644 --- a/tests/test_compose.py +++ b/tests/test_compose.py @@ -124,6 +124,13 @@ def test_air_that_clears_after_every_rain_is_a_yes_and_air_that_ignores_rain_is_ assert analyse_air(air, rain, limits, TZ, "PM2.5")["verdict"].startswith("Yes, the other way") +def test_air_that_reads_zero_when_dry_and_events_that_change_nothing_are_counted_as_such(): + air, rain = synthetic(60, lambda washed: 0.0) # a clean sensor: zero before and after every rain + out = analyse_air(air, rain, (9.0, 55.4), TZ, "PM2.5") + assert out["events"]["cleaner_after"] == out["events"]["worse_after"] == 0 and out["events"]["count"] >= 15 + assert out["findings"][0].endswith("when dry (good)") and f"{out['events']['count']} about the same" in out["findings"][1] + + def test_too_few_rain_events_or_no_overlap_says_so(): air, rain = synthetic(7, lambda washed: 10.0 if washed else 30.0) # two events in a week assert analyse_air(air, rain, (9.0, 55.4), TZ, "PM2.5")["verdict"].startswith("Not enough") diff --git a/tests/test_ecowitt.py b/tests/test_ecowitt.py index 8db60de..db3ccfc 100644 --- a/tests/test_ecowitt.py +++ b/tests/test_ecowitt.py @@ -962,6 +962,17 @@ def test_rain_that_comes_with_a_pressure_fall_is_found_and_rain_that_does_not_is assert out["correlation_change_vs_rain_next_3h"] > fell_correlation and analyse({}, {}, 1.0, TZ) == {} +def test_rain_and_time_shares_are_of_the_same_slots(): + from lib.analysis.pairs import analyse + from tests.fakes import TZ + base = 1_790_000_000 // 1800 * 1800 + rain = {base + k * 1800: 1.0 for k in range(40)} # rain in every slot, half of them with no reading 3 h before + driver = {t: 0.0 for t in list(rain)[:6]} | {t: 5.0 for t in list(rain)[20:]} + table = analyse(driver, rain, 1.0, TZ)["by_change_before"] + assert sum(int(g["share_of_rain"].rstrip("%")) for g in table.values()) == 100 + assert all(g["rain_vs_fair_share"] == "1.0x" for g in table.values() if g["slots"]) # it rained in every slot: fair shares + + async def test_weather_link_reads_the_cache_only_and_charts_rain_under_the_reading(tmp_path, archived_cache): from lib.charts import render eco, fake = await archived_station(tmp_path, archived_cache) diff --git a/tests/test_scan.py b/tests/test_scan.py index 3eeee34..e5fd833 100644 --- a/tests/test_scan.py +++ b/tests/test_scan.py @@ -1,9 +1,12 @@ import json import time +from datetime import UTC, date, datetime, time as dtime, timedelta +from zoneinfo import ZoneInfo import numpy as np from lib.analysis import scan as correlate +from lib.timeutil import day_bounds from lib.tools import Turn from tests.test_compose import composer @@ -33,7 +36,7 @@ def test_a_planted_relationship_is_found_with_its_direction_and_nothing_else(): rng, n, _, cycle, wind = world() air = {"pm2_5": series(20 - 0.5 * wind + 3 * cycle + rng.normal(0, 2, n)), "co2": series(450 + 30 * cycle + rng.normal(0, 10, n))} weather = {"wind": series(wind), "temperature": series(15 + 8 * cycle + rng.normal(0, 1, n)), "noise": series(rng.normal(0, 1, n))} - result = correlate.scan(air, weather, None, None, ORIGIN, DAYS) + result = correlate.scan(air, weather, None, None, ORIGIN, DAYS, UTC) kept = [(t["air"], t["weather"]) for t in result["tests"] if t["survives"]] assert kept == [("pm2_5", "wind")] # the shared daily cycle and the noise are not links found = next(t for t in result["tests"] if t["weather"] == "wind" and t["air"] == "pm2_5") @@ -47,7 +50,7 @@ def test_pure_noise_finds_nothing_even_across_many_pairs(): rng, n, _, _, _ = world(1) air = {f"a{i}": series(rng.normal(0, 1, n)) for i in range(3)} weather = {f"w{i}": series(rng.normal(0, 1, n)) for i in range(6)} - result = correlate.scan(air, weather, None, None, ORIGIN, DAYS, shuffles=100) + result = correlate.scan(air, weather, None, None, ORIGIN, DAYS, UTC, shuffles=100) assert not any(t["survives"] for t in result["tests"]) and len(result["tests"]) == 18 out = correlate.summarise(result, *names(air, weather)) assert out["verdict"].startswith("Nothing stands out") and out["findings"][0].startswith("Closest, probably chance") @@ -57,7 +60,7 @@ def test_a_lagged_relationship_is_found_at_its_lag(): rng, n, _, cycle, wind = world(2) lagged = np.roll(wind, 6) # the air answers the wind three hours later air = {"pm2_5": series(20 - 0.5 * lagged + rng.normal(0, 1, n))} - result = correlate.scan(air, {"wind": series(wind)}, None, None, ORIGIN, DAYS) + result = correlate.scan(air, {"wind": series(wind)}, None, None, ORIGIN, DAYS, UTC) test = result["tests"][0] assert test["survives"] and test["lag_hours"] == 3.0 and test["r"] < -0.3 @@ -67,7 +70,7 @@ def test_the_air_by_wind_direction_finds_the_sector_it_is_worst_from(): days = np.arange(n) // 48 direction = np.where(days % 3 == 0, 45.0, np.where(days % 3 == 1, 225.0, 135.0)) # north-east, south-west or south-east days air = {"pm2_5": series(10 + 8 * (direction == 45.0) + rng.normal(0, 1, n))} - result = correlate.scan(air, {"wind": series(wind)}, series(direction), series(np.full(n, 20.0)), ORIGIN, DAYS) + result = correlate.scan(air, {"wind": series(wind)}, series(direction), series(np.full(n, 20.0)), ORIGIN, DAYS, UTC) d = result["directions"][0] assert d["survives"] and d["highest"] == "NE" and d["lowest"] in ("SW", "SE") and d["highest_by"] > 3 out = correlate.summarise(result, *names(air, {"wind": 1})) @@ -77,7 +80,7 @@ def test_the_air_by_wind_direction_finds_the_sector_it_is_worst_from(): def test_too_few_days_says_so(): rng, n, _, _, wind = world(4) short = {t: v for t, v in series(rng.normal(0, 1, n)).items() if t < ORIGIN + 10 * 86400} - result = correlate.scan({"pm2_5": short}, {"wind": series(wind)}, None, None, ORIGIN, DAYS) + result = correlate.scan({"pm2_5": short}, {"wind": series(wind)}, None, None, ORIGIN, DAYS, UTC) assert result["tests"] == [] and "at least 21" in result["note"] assert correlate.summarise(result, {"pm2_5": ("PM2.5", "")}, {})["verdict"] == "Not enough data to say" @@ -94,7 +97,7 @@ def test_a_full_size_scan_of_every_pair_completes(): air = {m: series(rng.normal(0, 1, n) + cycle) for m in ("pm2_5", "pm10", "co2", "voc_index", "nox_index")} weather = {w: series(rng.normal(0, 1, n) + cycle) for w in ("temperature", "humidity", "dew_point", "pressure", "wind_speed", "wind_gust", "pressure_change", "rain")} started = time.time() - result = correlate.scan(air, weather, series(rng.uniform(0, 360, n)), series(np.full(n, 20.0)), ORIGIN, DAYS, shuffles=60) + result = correlate.scan(air, weather, series(rng.uniform(0, 360, n)), series(np.full(n, 20.0)), ORIGIN, DAYS, UTC, shuffles=60) assert len(result["tests"]) == 40 and len(result["directions"]) == 5 and time.time() - started < 30 @@ -107,3 +110,27 @@ async def test_air_scan_answers_with_a_verdict_and_can_chart_the_strongest_pair( assert all(len(s.panels) == 2 or s.panels[0].bars for s in specs) # two readings (rain behind the other): only when a relationship stood out assert "error" in json.loads(await comp.air_scan({"metric": "pm1"})) await eco.close() + + +def test_a_time_of_day_keeps_its_slot_across_a_daylight_saving_change(): + tz = ZoneInfo("Australia/Melbourne") + for first in (date(2026, 4, 3), date(2026, 10, 2)): # 5 April gains an hour, 4 October loses one + origin = day_bounds(first, tz)[0] + nine = {int(datetime.combine(first + timedelta(days=d), dtime(9), tz).timestamp()): 1.0 for d in range(4)} + grid = correlate.to_grid(nine, origin, 4, correlate.clock_slots(origin, 4, tz)) + assert list(np.flatnonzero(~np.isnan(grid))) == [18 + 48 * d for d in range(4)] # 9 am every day, not 8 or 10 + + +def test_no_weather_readings_say_there_was_nothing_to_compare_not_that_nothing_stands_out(): + rng, n, _, _, _ = world(6) + result = correlate.scan({"pm2_5": series(rng.normal(0, 1, n))}, {}, None, None, ORIGIN, DAYS, UTC) + assert result["tests"] == [] and "No weather readings" in result["note"] + assert correlate.summarise(result, {"pm2_5": ("PM2.5", "")}, {})["verdict"] == "Not enough data to say" + + +async def test_a_scan_of_years_covers_its_latest_400_days_and_says_so(tmp_path, archived_cache): + comp, eco = await composer(tmp_path, archived_cache) + out = json.loads(await comp.air_scan({"start_date": "2020-01-01"})) + first, last = (date.fromisoformat(d) for d in out["period"].split(" to ")) + assert (last - first).days == 399 and "Only the last 400 days" in out["notes"]["period"] + await eco.close() From 8bfe86c176a0eff5b56b7883a24b185128d24789 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 16:59:31 +0000 Subject: [PATCH 22/65] Tracebacks are logged whole; LOG_WIDTH cuts only the message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The formatter cut every line of an entry to LOG_WIDTH (71 by default), the traceback's included, so a frame's path or the exception's own message was lost behind an ellipsis exactly when it was needed. The message's lines are still cut; a traceback or stack after it now follows whole. lib: +13 โˆ’8 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/logs.py | 24 +++++++++++++++--------- tests/test_startup.py | 15 +++++++++++++++ 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/lib/logs.py b/lib/logs.py index 3144521..30f2d99 100644 --- a/lib/logs.py +++ b/lib/logs.py @@ -1,6 +1,6 @@ -"""How the bot's log lines look. Every line of an INFO or DEBUG entry is cut to the log width (LOG_WIDTH), an ellipsis (โ€ฆ) marking the -cut, whatever its level. A line that quotes a request or a chat message (the quote last on the line) also has the quote -put on one line first.""" +"""How the bot's log lines look. Every line of a message is cut to the log width (LOG_WIDTH), an ellipsis (โ€ฆ) marking the cut, +whatever its level; a traceback after it is logged whole. A line that quotes a request or a chat message (the quote last on the +line) also has the quote put on one line first.""" import logging @@ -13,7 +13,8 @@ def fit(text: str, room: int) -> str: class LogFormat(logging.Formatter): - """"INFO message": the level (WARNING is shown as WARN) and the message, with no module name. Cuts each line to the width.""" + """"INFO message": the level (WARNING is shown as WARN) and the message, with no module name. Cuts each line of the message to + the width; a traceback (or stack) follows whole.""" def __init__(self, fmt: str, width: int = 0): """width: the most characters of a line of the log; 0 cuts none.""" @@ -26,12 +27,17 @@ def format(self, record): if isinstance(text, str): # the HTTP client's own lines: "POST https://... "HTTP/1.1 200 OK"", not "HTTP Request: POST ..." record.msg = text.removeprefix("HTTP Request: ") try: - line = super().format(record) + record.message = record.getMessage() + if self.usesTime(): + record.asctime = self.formatTime(record, self.datefmt) + line = self.formatMessage(record) finally: record.levelname, record.msg = shown, text quote = str(record.args[-1]) if getattr(record, "quotes", False) and isinstance(record.args, tuple) and record.args else None - if quote and line.endswith(quote): # (not when a traceback follows it) + if quote and line.endswith(quote): line = line[:len(line) - len(quote)] + " ".join(quote.split()) # the quote on one line - if not self.width: - return line - return "\n".join(fit(part, self.width) for part in line.split("\n")) + if self.width: + line = "\n".join(fit(part, self.width) for part in line.split("\n")) + if record.exc_info and not record.exc_text: + record.exc_text = self.formatException(record.exc_info) + return "\n".join(filter(None, [line, record.exc_text, record.stack_info and self.formatStack(record.stack_info)])) diff --git a/tests/test_startup.py b/tests/test_startup.py index fea80c1..4fa5076 100644 --- a/tests/test_startup.py +++ b/tests/test_startup.py @@ -334,6 +334,21 @@ def line(msg, *args, quotes=True): assert line(timings, quotes=False) == "INFO " + timings +def test_a_traceback_is_logged_whole_after_its_cut_message(): + import sys + from lib.logs import LogFormat + fmt = LogFormat("%(levelname)s %(message)s", width=40) + try: + {}["a key whose name is far longer than the forty characters of the log width"] + except KeyError: + record = logging.LogRecord("lib.bot", logging.ERROR, __file__, 1, "Failed to deliver reply to chat 12345678 in the group", None, + sys.exc_info()) + lines = fmt.format(record).split("\n") + assert lines[0] == "ERROR Failed to deliver reply to chat 1โ€ฆ" and lines[1] == "Traceback (most recent call last):" + assert lines[-1] == "KeyError: 'a key whose name is far longer than the forty characters of the log width'" + assert any(len(line) > 40 for line in lines[2:-1]) # the frames too, whole + + def test_the_log_width_is_71_unless_set_and_0_cuts_none(monkeypatch): from lib.config import Config for k, v in (("TELEGRAM_BOT_TOKEN", "t"), ("XAI_API_KEY", "k"), ("ECOWITT_API_KEY", "a"), ("ECOWITT_APP_KEY", "b")): From 382a891116f414200a4d763de3343535fc637849 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 17:02:37 +0000 Subject: [PATCH 23/65] Turning off every alert type one by one is unsubscribing from all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A chat that turned every type off in the menu, one at a time, stayed in the normal mode until the bot next started, when the state file's loader converted it, so a type added later came on for it after all. Now the moment the last type the bot sends goes off, the chat is unsubscribed from all, as with /alerts off: a type added later stays off until the chat turns it on. "Every type" is every type this bot offers (the bot passes them), so a bot without irrigation doesn't wait for the irrigation types. The conversion at load goes, and with it the old on/off flag's (your decision to drop the old-state migrations: any state file this version has saved is converted already). The README says so. lib: +12 โˆ’11 lines. (The last commit's message said lib +13 โˆ’8; it was +15 โˆ’9.) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 2 +- lib/alerts/notify.py | 19 ++++++++++--------- lib/bot.py | 4 ++-- tests/test_alert_menu.py | 14 ++++++++++---- tests/test_alerts.py | 28 ++++++++++------------------ 5 files changed, 33 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 260d0f8..644c5dd 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ 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, gusts over 40 km/h (with the last day's wind chart), UV index of 10 or more in two readings in a row (with the last day's UV chart), indoor/outdoor temperatures crossing by more than 1ยฐC (held back for the cooldown, then announced if it still stands, with the last day's temperature chart), -and unhealthy air (two readings in a row; with when it is safe again; both with the last day's particulates chart). Every alert carries one ๐Ÿ”” button that opens up to two toggles for that alert type: Alert (on or off in this chat) and, while that is on and in a private chat, Sound (whether it makes a notification sound; alerts are silent unless you turn it on). Every toggle in the bot is a green button while on and a plain one with its name struck through while off. `/alerts` has two buttons, ๐Ÿ”” Alerts and (in a private chat) ๐Ÿ”Š Sound; each opens a list of alert types (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes; Sound lists only those that are on) as toggles, green while on and with the name struck through while off, while one is open its own button stays (press it to close the list) and the other is hidden until you do; `/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`). +and unhealthy air (two readings in a row; with when it is safe again; both with the last day's particulates chart). Every alert carries one ๐Ÿ”” button that opens up to two toggles for that alert type: Alert (on or off in this chat) and, while that is on and in a private chat, Sound (whether it makes a notification sound; alerts are silent unless you turn it on). Every toggle in the bot is a green button while on and a plain one with its name struck through while off. `/alerts` has two buttons, ๐Ÿ”” Alerts and (in a private chat) ๐Ÿ”Š Sound; each opens a list of alert types (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes; Sound lists only those that are on) as toggles, green while on and with the name struck through while off, while one is open its own button stays (press it to close the list) and the other is hidden until you do; `/alerts on|off` still subscribes or unsubscribes every type, and turning off every type one by one is the same as `/alerts off` (a type added later stays off too). `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`). ## Run diff --git a/lib/alerts/notify.py b/lib/alerts/notify.py index ce16507..8561793 100644 --- a/lib/alerts/notify.py +++ b/lib/alerts/notify.py @@ -11,6 +11,7 @@ import logging import os import time +from collections.abc import Collection from datetime import date, timedelta from telegram import MessageEntity @@ -53,10 +54,6 @@ def __init__(self, path): self.path = path data = _load(path) self.chats: dict[int, dict] = {int(k): v for k, v in data.get("chats", {}).items()} - for entry in self.chats.values(): # the old on/off flag, and a chat with every type muted, are "unsubscribed from all" - if entry.pop("alerts", True) is False or set(entry.get("muted", [])) >= set(LABELS): - entry.pop("muted", None) - entry.update(optin=True, on=[]) self.monitor: dict = data.get("monitor", {}) self.charts: dict[str, str] = data.get("charts", {}) # the question behind each chart sent, for its period buttons @@ -105,9 +102,10 @@ def muted(self, chat_id: int) -> set[str]: return set(LABELS) - set(entry.get("on", [])) return set(entry.get("muted", [])) - def set_kind(self, chat_id: int, kind: str, on: bool): + def set_kind(self, chat_id: int, kind: str, on: bool, offered: Collection[str] = LABELS): """Subscribe (on) or unsubscribe one alert type, or all of them (kind = "all"). Unsubscribing from all also keeps - future types off for the chat; subscribing to all turns them on again.""" + future types off for the chat, and so does unsubscribing one by one from every type `offered` (the types the bot + sends); subscribing to all turns them on again.""" if chat_id not in self.chats: return entry = self.chats[chat_id] @@ -126,10 +124,13 @@ def set_kind(self, chat_id: int, kind: str, on: bool): return muted = self.muted(chat_id) muted = muted - kinds if on else muted | kinds - if muted: - self.chats[chat_id]["muted"] = [k for k in LABELS if k in muted] + if not on and muted >= set(offered): # the last type muted: the same as unsubscribing from all + entry.pop("muted", None) + entry.update(optin=True, on=[]) + elif muted: + entry["muted"] = [k for k in LABELS if k in muted] else: - self.chats[chat_id].pop("muted", None) + entry.pop("muted", None) self.save() def loud(self, chat_id: int) -> set[str]: diff --git a/lib/bot.py b/lib/bot.py index f33b99e..df49ea5 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -435,7 +435,7 @@ async def on_alert_button(self, update: Update, context: ContextTypes.DEFAULT_TY self.state.set_sound(chat.id, press.kind, press.on) toast = f"{self._alerts_named(press.kind)} {'now make' if press.on else 'no longer make'} a sound" elif press.action == "alert" and press.kind in kinds and chat.id in self.state.chats: - self.state.set_kind(chat.id, press.kind, press.on) + self.state.set_kind(chat.id, press.kind, press.on, kinds) toast = f"{self._alerts_named(press.kind)} {'on' if press.on else 'off'} in this chat" elif press.action not in ("open", "close"): await query.answer() @@ -488,7 +488,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_battery" if action == "batt" else "irrigation_pause", arg == "on") + self.state.set_kind(chat_id, "irrigation_battery" if action == "batt" else "irrigation_pause", arg == "on", self._alert_kinds()) return f"Irrigation {'battery' if action == 'batt' else 'pause'} alerts {arg} in this chat" return None diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index e689d97..be04c73 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -82,7 +82,7 @@ def test_the_title_lists_what_is_on_and_off(): 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): +def test_state_mutes_one_type_or_all(tmp_path): state = AlertState(tmp_path / "s.json") state.add_chat(1, "a") state.add_chat(2, "b") @@ -95,9 +95,6 @@ def test_state_mutes_one_type_or_all_and_old_files_load(tmp_path): assert state.muted(1) == set() and "muted" not in state.chats[1] state.set_kind(2, "gusts", False) assert AlertState(tmp_path / "s.json").muted(2) == {"gusts"} # saved - (tmp_path / "old.json").write_text('{"chats": {"7": {"title": "x", "alerts": false}, "8": {"title": "y", "alerts": true}}, "monitor": {}}') - old = AlertState(tmp_path / "old.json") - assert old.muted(7) == set(LABELS) and old.muted(8) == set() and old.alert_chats() == [8] state.set_alerts(1, "a", False) # the /alerts off shortcut assert not state.alerts_on(1) @@ -183,6 +180,15 @@ async def test_the_alerts_menu_toggles_a_type_in_place_and_back_returns_to_the_t assert rows(q.edits[0][2]) == [[("๐Ÿ”” Alerts โ–ธ", "al:open:alerts"), ("๐Ÿ”Š Sound โ–ธ", "al:open:sound")]] +async def test_turning_off_every_type_the_bot_sends_unsubscribes_the_chat_from_all(tmp_path, monkeypatch): + from lib.alerts import menu + bot, state = make_bot(tmp_path, sources=("Ecowitt",)) + for kind in ("rain", "gusts", "uv", "temps"): # the weather station's types, one by one + await press(bot, Query(f"al:off:{kind}:alerts")) + monkeypatch.setitem(menu.LABELS, "newthing", "new thing") + assert state.chats[1].get("optin") and not state.subscribed(1, "newthing") and not state.subscribed(1, "particulates") + + async def test_the_alerts_message_keeps_its_summary_current_but_an_alert_keeps_its_text(tmp_path): bot, state = make_bot(tmp_path) q = Query("al:off:uv:alerts", text=f"{TITLE}\nโœ… On: ...") diff --git a/tests/test_alerts.py b/tests/test_alerts.py index 7b07b05..361f1d4 100644 --- a/tests/test_alerts.py +++ b/tests/test_alerts.py @@ -623,25 +623,17 @@ def test_unsubscribing_from_all_keeps_future_alert_types_off_until_subscribed(tm assert not state.subscribed(1) # nothing on at all state.set_kind(1, "all", True) # subscribe to all: new types come on again assert state.subscribed(1, "newer") and state.subscribed(1, "rain") - for kind in list(menu.LABELS): # turning every type off one by one is not "unsubscribe from all" - state.set_kind(3, kind, False) + offered = ["rain", "gusts", "uv", "temps"] # a bot with the weather station alone + for kind in offered[:-1]: + state.set_kind(3, kind, False, offered) monkeypatch.setitem(menu.LABELS, "newest", "newest") - assert state.subscribed(3, "newest") - - -def test_chats_that_already_unsubscribed_from_all_load_as_opt_in(tmp_path, monkeypatch): - import json - from lib.alerts import menu, notify - path = tmp_path / "s.json" - path.write_text(json.dumps({"chats": {"1": {"title": "a", "muted": list(menu.LABELS)}, "2": {"title": "b", "alerts": False}, - "3": {"title": "c", "muted": ["rain"]}}})) - state = notify.AlertState(path) - monkeypatch.setitem(menu.LABELS, "newthing", "new thing") - assert not state.subscribed(1, "newthing") and not state.subscribed(2, "newthing") and state.subscribed(3, "newthing") - state.set_kind(1, "uv", True) - state.save() - again = notify.AlertState(path) # and it survives a restart - assert again.subscribed(1, "uv") and not again.subscribed(1, "rain") and not again.subscribed(1, "newthing") + assert state.subscribed(3, "newest") and state.subscribed(3, "temps") # some still on: the normal mode + state.set_kind(3, "temps", False, offered) # every type the bot sends, off one by one: "unsubscribe from all" + monkeypatch.setitem(menu.LABELS, "newer still", "newer still") + assert not state.subscribed(3) and not state.subscribed(3, "newer still") + state.set_kind(3, "rain", True, offered) + again = notify.AlertState(state.path) # and it survives a restart + assert again.subscribed(3, "rain") and not again.subscribed(3, "uv") and not again.subscribed(3, "newer still") def asking(mon, photo=b"png"): From 4085db041a52f116b0fb17cbb2e3766ca6a5a2f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 17:08:06 +0000 Subject: [PATCH 24/65] A chat that blocks or removes the bot is marked gone and keeps its settings; a migrated group keeps its forecast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A chat the bot couldn't post to was deleted from the state file with its alert choices, so a chat that unblocked the bot, or a group that added it back, started again from the defaults. It is now marked gone: no alert or keyboard update is sent to it, and when it next writes (or the bot is added back) it is active again with its settings. The admins of a gone group no longer count under ADMIN_ONLY, as when it was deleted (the cached admin list would otherwise have kept them in). - Any BadRequest saying "not found" counted as the chat being gone ("Message thread not found" too, for a chat that is still there). Only "Chat not found" does now; anything else is a failed alert. - A group that became a supergroup moved its settings to the new id but left the forecast it had been sent under the old one, so a later revision of those days wasn't told (M4). It moves too. lib: +33 โˆ’18 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 2 +- lib/admins.py | 4 ++-- lib/alerts/notify.py | 41 ++++++++++++++++++++++++------------ lib/bot.py | 6 +++--- tests/test_admin_only.py | 11 ++++++++++ tests/test_alert_menu.py | 4 ++-- tests/test_alerts.py | 37 ++++++++++++++++++++++++++++++-- tests/test_forecast_alert.py | 2 +- tests/test_templates.py | 8 ++++--- 9 files changed, 88 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 644c5dd..4aeaedc 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ 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, gusts over 40 km/h (with the last day's wind chart), UV index of 10 or more in two readings in a row (with the last day's UV chart), indoor/outdoor temperatures crossing by more than 1ยฐC (held back for the cooldown, then announced if it still stands, with the last day's temperature chart), -and unhealthy air (two readings in a row; with when it is safe again; both with the last day's particulates chart). Every alert carries one ๐Ÿ”” button that opens up to two toggles for that alert type: Alert (on or off in this chat) and, while that is on and in a private chat, Sound (whether it makes a notification sound; alerts are silent unless you turn it on). Every toggle in the bot is a green button while on and a plain one with its name struck through while off. `/alerts` has two buttons, ๐Ÿ”” Alerts and (in a private chat) ๐Ÿ”Š Sound; each opens a list of alert types (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes; Sound lists only those that are on) as toggles, green while on and with the name struck through while off, while one is open its own button stays (press it to close the list) and the other is hidden until you do; `/alerts on|off` still subscribes or unsubscribes every type, and turning off every type one by one is the same as `/alerts off` (a type added later stays off too). `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`). +and unhealthy air (two readings in a row; with when it is safe again; both with the last day's particulates chart). Every alert carries one ๐Ÿ”” button that opens up to two toggles for that alert type: Alert (on or off in this chat) and, while that is on and in a private chat, Sound (whether it makes a notification sound; alerts are silent unless you turn it on). Every toggle in the bot is a green button while on and a plain one with its name struck through while off. `/alerts` has two buttons, ๐Ÿ”” Alerts and (in a private chat) ๐Ÿ”Š Sound; each opens a list of alert types (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes; Sound lists only those that are on) as toggles, green while on and with the name struck through while off, while one is open its own button stays (press it to close the list) and the other is hidden until you do; `/alerts on|off` still subscribes or unsubscribes every type, and turning off every type one by one is the same as `/alerts off` (a type added later stays off too). A chat that blocks or removes the bot gets no more alerts but keeps its settings, which apply again when it next writes (or the bot is added back). `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`). ## Run diff --git a/lib/admins.py b/lib/admins.py index c4f0da6..5fece2f 100644 --- a/lib/admins.py +++ b/lib/admins.py @@ -23,8 +23,8 @@ def __init__(self, state, extra_ids=(), ttl: float = TTL_SECONDS, clock=time.mon self._failing: set[int] = set() def groups(self) -> list[int]: - """The groups whose admins count: the ones the bot knows, and the ones ADMIN_CHAT_IDS names.""" - known = {c for c in (self.state.chats if self.state else {}) if c < 0} + """The groups whose admins count: the ones the bot is in (not one that removed it), and the ones ADMIN_CHAT_IDS names.""" + known = {c for c in (self.state.present() if self.state else []) if c < 0} return sorted(known | {i for i in self.extra if i < 0}) async def _admins_of(self, bot, chat_id: int) -> frozenset[int]: diff --git a/lib/alerts/notify.py b/lib/alerts/notify.py index 8561793..9c1e9f4 100644 --- a/lib/alerts/notify.py +++ b/lib/alerts/notify.py @@ -1,7 +1,8 @@ """Who gets alerts, and sending them. Alerts go to every chat the bot knows about (Telegram can't list a bot's chats, so they are -remembered as messages arrive) unless it unsubscribed (per alert type, with the buttons under each alert or /alerts). Chats and +remembered as messages arrive) unless it unsubscribed (per alert type, with the buttons under each alert or /alerts). A chat that +blocked or removed the bot is marked gone: it gets nothing, and has its settings back when it next writes. Chats and monitor state live in a small JSON file, so restarts neither forget chats nor repeat alerts. Alerts are sent silently, with the settings buttons and, when there is one, a small italic footer with a link label. """ @@ -67,30 +68,44 @@ def save(self): os.replace(tmp, self.path) def add_chat(self, chat_id: int, title: str): + """A message from this chat (or the bot added to it): known from now on, and back if it was gone.""" entry = self.chats.get(chat_id) if entry is None: self.chats[chat_id] = {"title": title} log.info("Alerts: now sending to %s (id %s)", title, chat_id) self.save() - elif entry.get("title") != title: + elif entry.get("title") != title or entry.get("gone"): + if entry.pop("gone", None): + log.info("Alerts: sending to %s (id %s) again", title, chat_id) entry["title"] = title self.save() - def remove_chat(self, chat_id: int, reason: str): - if self.chats.pop(chat_id, None) is not None: + def mark_gone(self, chat_id: int, reason: str): + """The bot can't post to this chat any more (it was blocked or removed): nothing is sent to it, and its settings are kept + for when it writes again.""" + entry = self.chats.get(chat_id) + if entry is not None and not entry.get("gone"): + entry["gone"] = True log.info("Alerts: no longer sending to chat %s (%s)", chat_id, reason) self.save() + def present(self) -> list[int]: + """The chats the bot is in: known, and not gone.""" + return [c for c, entry in self.chats.items() if not entry.get("gone")] + def set_alerts(self, chat_id: int, title: str, on: bool): """/alerts on and /alerts off: every type.""" self.chats.setdefault(chat_id, {"title": title}) self.set_kind(chat_id, ALL, on) def migrate_chat(self, old: int, new: int): - """A group became a supergroup: its settings move to the new chat id.""" + """A group became a supergroup: its settings, and the forecast it was sent, move to the new chat id.""" entry = self.chats.pop(old, None) if entry is not None: self.chats.setdefault(new, entry) + sent = self.monitor.get("forecast_sent", {}) + if str(old) in sent: + sent.setdefault(str(new), sent.pop(str(old))) log.info("Alerts: chat %s became %s", old, new) self.save() @@ -178,7 +193,7 @@ def forecast_chats(self) -> list[int]: def subscribed(self, chat_id: int, kind: str | None = None) -> bool: """Does this chat get this alert type (or, with no kind, any at all)?""" - if chat_id not in self.chats: + if chat_id not in self.chats or self.chats[chat_id].get("gone"): return False muted = self.muted(chat_id) return kind not in muted if kind else bool(set(LABELS) - muted) @@ -217,8 +232,8 @@ def __init__(self, bot, state: AlertState): self.bot, self.state = bot, state async def __call__(self, text: str, link: tuple[str, str] | None = None, kind: str | None = None, photo: bytes | str | None = None): - """Send an alert (silently) to every chat subscribed to its type; forget chats the bot can no - longer post to. link = (label, url) adds a clickable label to the footer. Each carries the settings buttons. + """Send an alert (silently) to every chat subscribed to its type; chats the bot can no longer post to are marked + gone. link = (label, url) adds a clickable label to the footer. Each carries the settings buttons. photo: a picture the text is the caption of (the text alone if Telegram refuses the picture): a PNG, or the file id of one Telegram holds. It is uploaded once: the chats after the first are sent Telegram's copy.""" text, entities = with_footer(text, link) @@ -248,14 +263,14 @@ async def _post(self, chat_id: int, text: str, entities, kind: str | None, photo try: return await self.bot.send_photo(chat_id, photo, caption=text, caption_entities=entities, **common) except BadRequest as e: - if "not found" in str(e).lower(): + if "chat not found" in str(e).lower(): raise log.warning("Alert picture to %s failed (%s); sending the text alone", chat_id, e) return await self.bot.send_message(chat_id, text, entities=entities, disable_web_page_preview=True, **common) async def _send(self, chat_id: int, text: str, entities, kind: str | None = None, photo: bytes | str | None = None, again: bool = True) -> tuple[bool, object]: - """Send an alert (see _post) to one chat; forget a chat the bot can no longer post to. Telegram's flood limit (when it asks + """Send an alert (see _post) to one chat; a chat the bot can no longer post to is marked gone. Telegram's flood limit (when it asks for a short wait) and a dropped connection get one more try; a timeout doesn't, as the message may have arrived. Whatever fails for one chat is logged, so the others still get the alert. (whether it was sent, the message sent)""" try: @@ -265,11 +280,11 @@ async def _send(self, chat_id: int, text: str, entities, kind: str | None = None self.state.migrate_chat(chat_id, e.new_chat_id) return await self._send(e.new_chat_id, text, entities, kind, photo) if e.new_chat_id != chat_id else (False, None) except Forbidden as e: # kicked from the group, or blocked in a private chat - self.state.remove_chat(chat_id, f"can't post: {e}") + self.state.mark_gone(chat_id, f"can't post: {e}") except BadRequest as e: - if "not found" not in str(e).lower(): + if "chat not found" not in str(e).lower(): # not "message thread not found", say: that chat is still there raise - self.state.remove_chat(chat_id, f"chat gone: {e}") + self.state.mark_gone(chat_id, f"chat gone: {e}") except (RetryAfter, NetworkError) as e: # BadRequest, a NetworkError too, is caught above wait = _flood_wait(e) if isinstance(e, RetryAfter) else RETRY_SECONDS if not again or isinstance(e, TimedOut) or wait > FLOOD_WAIT_MAX: diff --git a/lib/bot.py b/lib/bot.py index df49ea5..cdeaed0 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -366,7 +366,7 @@ async def on_membership(self, update: Update, context: ContextTypes.DEFAULT_TYPE if status in ("member", "administrator"): self.remember_chat(update) elif status in ("left", "kicked"): - self.state.remove_chat(change.chat.id, f"bot {status}") + self.state.mark_gone(change.chat.id, f"bot {status}") def _alert_kinds(self) -> list[str]: return menu.available_kinds(self.by_name, bool(self.irrigation)) @@ -662,13 +662,13 @@ async def refresh_keyboards(self, tg_bot) -> int: if not self.state: return 0 sent = 0 - for chat_id in [c for c in self.state.chats if c > 0 and self.state.keyboard(c) not in (self.keyboard_version, templates.HIDDEN)]: + for chat_id in [c for c in self.state.present() if c > 0 and self.state.keyboard(c) not in (self.keyboard_version, templates.HIDDEN)]: try: await tg_bot.send_message(chat_id, "Buttons updated.", reply_markup=templates.keyboard(self.irrigation is not None), disable_notification=True) self.state.set_keyboard(chat_id, self.keyboard_version) sent += 1 except Forbidden as e: # blocked the bot - self.state.remove_chat(chat_id, f"can't post: {e}") + self.state.mark_gone(chat_id, f"can't post: {e}") except TelegramError as e: log.warning("Buttons update to %s failed: %s", chat_id, e) if sent: diff --git a/tests/test_admin_only.py b/tests/test_admin_only.py index 6a70497..17035a5 100644 --- a/tests/test_admin_only.py +++ b/tests/test_admin_only.py @@ -157,6 +157,17 @@ async def test_a_failed_lookup_keeps_the_old_list_and_is_retried_soon_without_ra assert not await fresh.allowed(tg, [ADMIN]) # nothing known: nobody +async def test_the_admins_of_a_group_that_removed_the_bot_no_longer_count(tmp_path): + state, tg = AlertState(tmp_path / "s.json"), TG({GROUP: [ADMIN]}) + state.add_chat(GROUP, "Home") + admins = Admins(state) + assert await admins.allowed(tg, [ADMIN]) # its list is kept for ten minutes now + state.mark_gone(GROUP, "bot kicked") + assert admins.groups() == [] and not await admins.allowed(tg, [ADMIN]) # gone: its admins don't count + state.add_chat(GROUP, "Home") # the bot is added back + assert await admins.allowed(tg, [ADMIN]) + + async def test_listed_ids_are_added_to_the_known_admins_never_replacing_them(tmp_path): state, tg = AlertState(tmp_path / "s.json"), TG({GROUP: [ADMIN], OTHER_GROUP: [3]}) admins = Admins(state, extra_ids=[STRANGER, OTHER_GROUP]) diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index be04c73..2b338c4 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -454,7 +454,7 @@ async def send_photo(self, chat_id, photo, **kw): assert [(chat, photo) for chat, photo, _ in bot.photos] == [(1, b"png"), (2, "large"), (3, "large")] -async def test_a_picture_telegram_refuses_falls_back_to_the_text_but_a_missing_chat_is_still_forgotten(tmp_path): +async def test_a_picture_telegram_refuses_falls_back_to_the_text_but_a_missing_chat_is_still_marked_gone(tmp_path): from telegram.error import BadRequest state, bot = AlertState(tmp_path / "s.json"), SentWithPhotos(refuse=BadRequest("Wrong file identifier")) state.add_chat(1, "one") @@ -462,4 +462,4 @@ async def test_a_picture_telegram_refuses_falls_back_to_the_text_but_a_missing_c assert bot.photos == [] and [(c, t) for c, t, _ in bot.sent] == [(1, "It's warmer outside")] gone = SentWithPhotos(refuse=BadRequest("Chat not found")) await Notifier(gone, state)("again", kind="temps", photo=b"png") - assert gone.sent == [] and 1 not in state.chats + assert gone.sent == [] and state.chats[1]["gone"] and state.alert_chats() == [] diff --git a/tests/test_alerts.py b/tests/test_alerts.py index 361f1d4..87b7437 100644 --- a/tests/test_alerts.py +++ b/tests/test_alerts.py @@ -295,7 +295,7 @@ def test_footer_marks_up_link_and_mute_hint(): assert link.length == len("live chart") and link.url == "https://x" -async def test_alerts_are_silent_and_dead_chats_are_forgotten(tmp_path): +async def test_alerts_are_silent_and_a_chat_that_blocked_the_bot_is_marked_gone(tmp_path): from telegram.error import Forbidden state = AlertState(tmp_path / "s.json") state.add_chat(1, "ok") @@ -308,7 +308,40 @@ async def send_message(self, chat_id, text, **kw): if chat_id == 2: raise Forbidden("bot was blocked") await Notifier(Bot(), state)("hi") - assert all(kw["disable_notification"] for _, kw in calls) and 2 not in state.chats and 1 in state.chats + assert all(kw["disable_notification"] for _, kw in calls) and state.chats[2]["gone"] and state.alert_chats() == [1] + + +async def test_a_gone_chat_keeps_its_settings_gets_nothing_and_is_back_when_it_writes(tmp_path): + from telegram.error import BadRequest, Forbidden + state = AlertState(tmp_path / "s.json") + for chat in (1, 2, 3): + state.add_chat(chat, str(chat)) + state.set_kind(2, "rain", False) + calls, refusals = [], {2: Forbidden("bot was blocked by the user"), 3: BadRequest("Message thread not found")} + + class Bot: + async def send_message(self, chat_id, text, **kw): + calls.append(chat_id) + if chat_id in refusals: + raise refusals[chat_id] + await Notifier(Bot(), state)("hi", kind="uv") + assert state.chats[2]["gone"] and state.muted(2) == {"rain"} and state.present() == [1, 3] + assert not state.chats[3].get("gone") # only "chat not found" means gone: the chat is still there + calls.clear() + await Notifier(Bot(), state)("again", kind="uv") + assert calls == [1, 3] # nothing is sent to a gone chat + state.add_chat(2, "2") # it writes again + assert state.subscribed(2, "uv") and not state.subscribed(2, "rain") and AlertState(state.path).present() == [1, 2, 3] + + +def test_a_migrated_groups_forecast_moves_with_it(tmp_path): + from datetime import date + state = AlertState(tmp_path / "s.json") + state.add_chat(-5, "group") + today = date(2026, 10, 3) + state.record_forecast(-5, [{"date": today, "max_c": 20.0, "rain_chance_pct": 90}], today) + state.migrate_chat(-5, -1005) + assert state.forecast_sent(-1005)[today]["rain_chance_pct"] == 90 and state.forecast_sent(-5) == {} and state.forecast_chats() == [-1005] def test_a_damaged_state_file_is_kept_aside_not_overwritten(tmp_path, caplog): diff --git a/tests/test_forecast_alert.py b/tests/test_forecast_alert.py index cbc5c7a..b630038 100644 --- a/tests/test_forecast_alert.py +++ b/tests/test_forecast_alert.py @@ -190,7 +190,7 @@ async def send_message(self, chat_id, text, **kw): sent.append((chat_id, text)) notifier = Notifier(Bot(), state) assert await notifier.to_chat(1, "hello") is True and [c for c, _ in sent] == [1] and sent[0][1] == "hello" - assert await notifier.to_chat(2, "hello") is False and 2 not in state.chats + assert await notifier.to_chat(2, "hello") is False and state.chats[2]["gone"] def test_a_revision_compares_the_one_millimetre_chance_when_both_days_have_it_and_the_older_figure_otherwise(): diff --git a/tests/test_templates.py b/tests/test_templates.py index adee013..693a821 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -193,18 +193,20 @@ async def test_at_startup_private_chats_with_old_buttons_are_told_once_and_other state.set_keyboard(10, "old1234") state.set_keyboard(11, templates.version()) state.set_keyboard(12, templates.HIDDEN) # 13 and the group have no record - sent = [] + sent, tried = [], [] class TG: async def send_message(self, chat_id, text, **kw): + tried.append(chat_id) if chat_id == 13: raise Forbidden("bot was blocked by the user") sent.append((chat_id, text, kw["reply_markup"].is_persistent)) bot = Bot(NS(tz=TZ), None, [], state) assert await bot.refresh_keyboards(TG()) == 1 assert sent == [(10, "Buttons updated.", True)] - assert state.keyboard(10) == templates.version() and 13 not in state.chats and state.keyboard(-20) is None - assert await bot.refresh_keyboards(TG()) == 0 # nothing more on the next start + assert state.keyboard(10) == templates.version() and state.chats[13]["gone"] and state.keyboard(-20) is None + tried.clear() + assert await bot.refresh_keyboards(TG()) == 0 and tried == [] # nothing more on the next start: 13 blocked the bot assert await Bot(NS(tz=TZ), None, [], None).refresh_keyboards(TG()) == 0 From a5d1e55d4e4d6e3834d60613779ec42cda4a4404 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 17:09:29 +0000 Subject: [PATCH 25/65] Drop the weather alerts' conversions of state saved by earlier versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Your decision: the rain alert's old "raining" flag, the UV alert's old "checked" mark and the temperature crossing's old "side" and "since" were converted at every check, for state files no version since has written (each check saves the converted form). They go, with the test that kept the crossing's alive. A UV state of the old shape now just takes its first look afresh, as a new install does. lib: +1 โˆ’5 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/alerts/weather.py | 6 +----- tests/test_alerts.py | 7 ------- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/lib/alerts/weather.py b/lib/alerts/weather.py index 4562e9e..e51219d 100644 --- a/lib/alerts/weather.py +++ b/lib/alerts/weather.py @@ -100,8 +100,6 @@ async def check(self): async def _rain(self, rows: Rows): m = self.state.monitor.setdefault("rain", {}) - if "raining" in m: # saved by an earlier version - m["alerted_state"] = "raining" if m.pop("raining") else "dry" m.setdefault("alerted_state", "dry") wet = wet_flags(rows) latest_ts = wet[-1][0] @@ -184,7 +182,7 @@ async def _uv(self, rows: Rows): one counted are looked at, so a restart doesn't re-announce an old spell.""" m = self.state.monitor.setdefault("uv", {"active": False, "calm_since": None}) latest_ts = rows[-1][0] - m.setdefault("stamp", m.pop("checked", latest_ts)) # the first look only sets the mark + m.setdefault("stamp", latest_ts) # the first look only sets the mark high = False for ts, r in rows: if not confirm.new_reading(m, ts): @@ -218,8 +216,6 @@ async def _cross(self, rows: Rows): if c is None: self.state.monitor["cross"] = {"alerted_state": current, "alerted_time": ts} return - if "side" in c: # saved by an earlier version - c["alerted_state"], c["alerted_time"] = c.pop("side"), c.pop("since") if due(c, current, ts, self.cooldown): o, i = r["outdoor.temperature"], r["indoor.temperature"] held = ts - c["alerted_time"] # how long the side we last told about has stood diff --git a/tests/test_alerts.py b/tests/test_alerts.py index 87b7437..841da7a 100644 --- a/tests/test_alerts.py +++ b/tests/test_alerts.py @@ -205,13 +205,6 @@ async def test_a_crossing_soon_after_the_state_was_learned_waits_out_the_cooldow assert len(sent) == 1 and "cooler for 30 min" in sent[0] -async def test_crossing_state_saved_by_an_earlier_version_still_works(tmp_path): - m, state, sent = monitor(tmp_path, temps(25, 20, T0 + 2 * 3600)) - state.monitor["cross"] = {"side": "cooler", "since": T0} - await m.check() - assert len(sent) == 1 and "cooler for 2h" in sent[0] and state.monitor["cross"]["alerted_state"] == "warmer" - - async def test_small_temperature_differences_are_noise(tmp_path): m, state, sent = monitor(tmp_path, temps(20.1, 20.0)) await m.check() From 45c3db427b306ee172ecb313b51287efdffde005 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 17:13:08 +0000 Subject: [PATCH 26/65] Bot tests are given a real AlertState, as the running bot is 20 tests built the Bot with state=None, a case the running bot never has (ecobot always passes the state file's AlertState), so the bot's state guards were what they tested. A new alert_state fixture gives each a real one in the test's own folder; the helpers that build a Bot take it first. One test now sets its chat's keyboard as current: with a real state a private chat that never had the buttons gets them with its reply, as in the running bot. The two tests in test_templates.py that check the None case itself go with the guards, next. lib: no change (tests only). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- tests/conftest.py | 7 +++ tests/test_agent.py | 4 +- tests/test_bot.py | 130 +++++++++++++++++++++--------------------- tests/test_periods.py | 74 ++++++++++++------------ 4 files changed, 112 insertions(+), 103 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 2a8b1b1..a190e36 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,13 @@ import pytest +@pytest.fixture +def alert_state(tmp_path): + """The bot's chats and settings (an AlertState) in a file of the test's own: what a Bot is given, as in the running bot.""" + from lib.alerts import AlertState + return AlertState(tmp_path / "state.json") + + @pytest.fixture(scope="session") def archived_cache(tmp_path_factory): """A cache file holding the fake station's whole archive, built once. Tests copy it (archived_station).""" diff --git a/tests/test_agent.py b/tests/test_agent.py index fedbe25..6030d96 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -102,13 +102,13 @@ def test_charts_render_for_both_datasets(): assert charts.render(spec, ZoneInfo("Australia/Melbourne"))[:8] == b"\x89PNG\r\n\x1a\n" -async def test_reset_forgets_only_that_chat(): +async def test_reset_forgets_only_that_chat(alert_state): from lib.bot import Bot sent = [] async def reply_text(text, **kw): sent.append(text) - bot = Bot(None, None, [], None) + bot = Bot(None, None, [], alert_state) bot.chats[(1, None)].history.append({"role": "user", "content": "hi"}) bot.chats[(2, None)].history.append({"role": "user", "content": "other chat"}) bot.chats[(1, 7)].history.append({"role": "user", "content": "topic 7"}) diff --git a/tests/test_bot.py b/tests/test_bot.py index 0ae83a5..22bddbc 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -21,7 +21,7 @@ async def run(self, messages, system, effort, first_call=None, require_tool=True return "It is 12 degrees." -async def ask(agent, text="how hot is it"): +async def ask(alert_state, agent, text="how hot is it"): replies = [] async def reply_text(body, **kw): @@ -33,29 +33,29 @@ async def send_chat_action(*a, **k): 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")) context = NS(bot=NS(send_chat_action=send_chat_action, id=99)) - await Bot(NS(tz=TZ), agent, [], None).respond(update, context, text) + await Bot(NS(tz=TZ), agent, [], alert_state).respond(update, context, text) return replies -async def test_error_details_stay_in_the_log_not_the_chat(): - replies = await ask(RaisingAgent()) +async def test_error_details_stay_in_the_log_not_the_chat(alert_state): + replies = await ask(alert_state, RaisingAgent()) assert len(replies) == 1 and "went wrong" in replies[0] and "try again" in replies[0].lower() assert "SECRET" not in replies[0] and "Bearer" not in replies[0] and "acme" not in replies[0] assert "RuntimeError" in replies[0] # just the kind of problem -async def test_a_failing_shortcut_falls_back_to_the_model(monkeypatch): +async def test_a_failing_shortcut_falls_back_to_the_model(alert_state, monkeypatch): def boom(*a, **k): raise OverflowError("date value out of range") monkeypatch.setattr(intent, "fast_call", boom) agent = AnsweringAgent() - assert await ask(agent, "weather last 99999 years") == ["It is 12 degrees."] + assert await ask(alert_state, agent, "weather last 99999 years") == ["It is 12 degrees."] assert agent.first_call is None -def test_a_reply_carries_the_message_it_replies_to_in_private_chats_too(): +def test_a_reply_carries_the_message_it_replies_to_in_private_chats_too(alert_state): from types import SimpleNamespace as NS - bot = Bot(NS(tz=TZ), None, [], None) + bot = Bot(NS(tz=TZ), None, [], alert_state) mine = NS(text="Absolute pressure\nโ€ข Low: 992.9 hPa at 2:20pm on Sat 5 Sep", caption=None, from_user=NS(id=99, full_name="Bot")) theirs = NS(text="lovely day", caption=None, from_user=NS(id=7, full_name="Ann")) private = NS(chat=NS(type="private"), from_user=NS(full_name="Rob"), reply_to_message=mine) @@ -67,7 +67,7 @@ def test_a_reply_carries_the_message_it_replies_to_in_private_chats_too(): assert bot._content(NS(**{**group.__dict__, "reply_to_message": None}), "hi", 99) == "Rob: hi" -async def test_a_hung_question_is_given_up_on_so_the_next_one_in_the_chat_is_answered(monkeypatch, caplog): +async def test_a_hung_question_is_given_up_on_so_the_next_one_in_the_chat_is_answered(alert_state, monkeypatch, caplog): import asyncio from lib import bot @@ -78,7 +78,7 @@ async def run(self, *a, **k): monkeypatch.setattr(bot, "TURN_SECONDS", 0.3) monkeypatch.setattr(bot, "WATCHDOG_SECONDS", 0.1) with caplog.at_level("WARNING"): - replies = await ask(Hanging()) + replies = await ask(alert_state, Hanging()) assert len(replies) == 1 and "took too long" in replies[0] assert "Still working" in caplog.text and "Gave up" in caplog.text @@ -117,7 +117,7 @@ async def run(self, messages, system, effort, first_call=None, require_tool=True return "It is 12 degrees." -async def ask_private(agent, bot, chat_type="private"): +async def ask_private(alert_state, agent, bot, chat_type="private"): replies = [] async def reply_text(body, **kw): @@ -125,21 +125,21 @@ async def reply_text(body, **kw): msg = NS(chat_id=1, message_thread_id=None, is_topic_message=False, reply_text=reply_text, chat=NS(type=chat_type, 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")) - await Bot(NS(tz=TZ), agent, [], None).respond(update, NS(bot=bot), "how hot is it") + await Bot(NS(tz=TZ), agent, [], alert_state).respond(update, NS(bot=bot), "how hot is it") return replies -async def test_a_private_chat_shows_a_thinking_draft_that_streams_the_answer_then_sends_it(monkeypatch): +async def test_a_private_chat_shows_a_thinking_draft_that_streams_the_answer_then_sends_it(alert_state, monkeypatch): monkeypatch.setattr("lib.bot.DRAFT_MIN_GAP", 0.01) bot = DraftBot() - replies = await ask_private(StreamingAgent(), bot) + replies = await ask_private(alert_state, StreamingAgent(), bot) assert replies == ["It is 12 degrees."] assert bot.drafts[0][1] is None # empty: "Thinking..." assert [t for _, t in bot.drafts][-1] == "It is 12 degrees." and len({d for d, _ in bot.drafts}) == 1 assert bot.typing >= 1 # "typing..." runs alongside the draft -async def test_an_idle_draft_is_refreshed_before_telegram_drops_it(monkeypatch): +async def test_an_idle_draft_is_refreshed_before_telegram_drops_it(alert_state, monkeypatch): monkeypatch.setattr("lib.bot.DRAFT_REFRESH_SECONDS", 0.02) class Slow: @@ -147,27 +147,27 @@ async def run(self, *a, on_text=None, **k): await asyncio.sleep(0.15) return "ok" bot = DraftBot() - await ask_private(Slow(), bot) + await ask_private(alert_state, Slow(), bot) assert len(bot.drafts) >= 4 and all(t is None for _, t in bot.drafts) -async def test_a_refused_draft_falls_back_to_typing_and_the_reply_still_arrives(): +async def test_a_refused_draft_falls_back_to_typing_and_the_reply_still_arrives(alert_state): bot = DraftBot(fail=True) - assert await ask_private(StreamingAgent(), bot) == ["It is 12 degrees."] + assert await ask_private(alert_state, StreamingAgent(), bot) == ["It is 12 degrees."] assert bot.drafts == [] and bot.typing >= 1 -async def test_groups_never_get_drafts(): +async def test_groups_never_get_drafts(alert_state): bot = DraftBot() - assert await ask_private(AnsweringAgent(), bot, chat_type="supergroup") == ["It is 12 degrees."] + assert await ask_private(alert_state, AnsweringAgent(), bot, chat_type="supergroup") == ["It is 12 degrees."] assert bot.drafts == [] -async def test_a_generation_stopped_update_is_logged(caplog): +async def test_a_generation_stopped_update_is_logged(alert_state, caplog): update = NS(effective_message=NS(api_kwargs={"message_generation_stopped": {"draft_id": 1}}, chat_id=1), effective_chat=NS(type="private", id=1, title=None), effective_user=NS(username="rob", full_name="Rob")) with caplog.at_level("INFO", logger="lib.bot"): - await Bot(NS(tz=TZ), None, [], None).on_other(update, None) + await Bot(NS(tz=TZ), None, [], alert_state).on_other(update, None) assert "generation stopped" in caplog.text and "message_generation_stopped" in caplog.text @@ -188,7 +188,7 @@ async def reply_text(body, **kw): assert sent == [("text", "Fri was the hottest day."), ("photo", "Temperature")] -async def test_a_report_is_written_in_code_from_the_fast_path_calls_and_the_model_is_not_asked(): +async def test_a_report_is_written_in_code_from_the_fast_path_calls_and_the_model_is_not_asked(alert_state): import json class Tools: @@ -216,7 +216,7 @@ async def reply_text(body, **kw): async def send_chat_action(*a, **k): pass - bot = Bot(NS(tz=TZ), agent, sources, None) + bot = Bot(NS(tz=TZ), agent, sources, alert_state) await bot.respond(update, NS(bot=NS(send_chat_action=send_chat_action, id=99)), "report") await bot.respond(update, NS(bot=NS(send_chat_action=send_chat_action, id=99)), "weather now") assert agent.tools.called == ["weather_now", "air_quality", "weather_now"] @@ -226,7 +226,7 @@ async def send_chat_action(*a, **k): assert history[-1] == {"role": "assistant", "content": replies[1]} and history[-4]["content"] == "report" -async def test_a_question_that_times_out_is_asked_again_one_reasoning_step_lower(monkeypatch): +async def test_a_question_that_times_out_is_asked_again_one_reasoning_step_lower(alert_state, monkeypatch): from lib import bot as botmod monkeypatch.setattr(botmod, "TURN_SECONDS", 0.05) monkeypatch.setattr(botmod, "RETRY_SECONDS", 0.5) @@ -239,7 +239,7 @@ async def run(self, messages, system, effort, first_call=None, require_tool=True if effort == "medium": await asyncio.sleep(10) return f"answered at {effort}" - bot = Bot(NS(tz=TZ), SlowThenQuick(), [], None) + bot = Bot(NS(tz=TZ), SlowThenQuick(), [], alert_state) working = [{"role": "user", "content": "q"}] read = intent.read("what do you think about the weather today?", datetime(2026, 10, 2, 12, 0)) assert read.effort == "medium" @@ -247,7 +247,7 @@ async def run(self, messages, system, effort, first_call=None, require_tool=True assert efforts == ["medium", "low"] and working == [{"role": "user", "content": "q"}, {"role": "assistant", "content": "(unfinished)"}] -async def test_a_question_with_no_lower_step_or_a_second_timeout_gives_up(monkeypatch): +async def test_a_question_with_no_lower_step_or_a_second_timeout_gives_up(alert_state, monkeypatch): import pytest from lib import bot as botmod monkeypatch.setattr(botmod, "TURN_SECONDS", 0.05) @@ -258,7 +258,7 @@ class Hangs: async def run(self, messages, system, effort, **k): efforts.append(effort) await asyncio.sleep(10) - bot = Bot(NS(tz=TZ), Hangs(), [], None) + bot = Bot(NS(tz=TZ), Hangs(), [], alert_state) for text, expected in (("hello there", ["none"]), ("what do you think about the weather today?", ["medium", "low"])): efforts.clear() with pytest.raises(TimeoutError): @@ -266,7 +266,7 @@ async def run(self, messages, system, effort, **k): assert efforts == expected -async def test_a_plain_chart_request_is_drawn_and_captioned_in_code_and_the_model_is_not_asked(monkeypatch): +async def test_a_plain_chart_request_is_drawn_and_captioned_in_code_and_the_model_is_not_asked(alert_state, monkeypatch): import json from lib import bot as botmod from lib.specs import Chart, Line, Panel @@ -298,13 +298,13 @@ async def reply_photo(photo, caption=None, **kw): 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)), "Humidity chart 7d") + await Bot(NS(tz=TZ), Agent(), sources, alert_state).respond(update, NS(bot=NS(send_chat_action=send_chat_action, id=99)), "Humidity chart 7d") name, args = Agent.tools.calls[0] assert name == "weather_history" and args["chart"] and args["groups"] == "outdoor,indoor" assert sent == ["Humidity"] # the chart's title is its whole caption -async def test_a_chart_made_in_code_starts_no_refresh_and_other_questions_do(monkeypatch): +async def test_a_chart_made_in_code_starts_no_refresh_and_other_questions_do(alert_state, monkeypatch): """The chart's own reads go first in line; a refresh started beside them would only queue requests ahead of the next ones.""" import json from lib import bot as botmod @@ -333,14 +333,14 @@ async def reply(*a, **k): async def send_chat_action(*a, **k): pass - bot, context = Bot(NS(tz=TZ), Agent(), sources, None), NS(bot=NS(send_chat_action=send_chat_action, id=99)) + bot, context = Bot(NS(tz=TZ), Agent(), sources, alert_state), NS(bot=NS(send_chat_action=send_chat_action, id=99)) await bot.respond(update, context, "Temperature chart 7d") assert pokes == [] await bot.respond(update, context, "what do you think about the weather today?") assert pokes == [1] -async def test_a_chart_reply_logs_how_long_each_step_took(tmp_path, monkeypatch, caplog): +async def test_a_chart_reply_logs_how_long_each_step_took(alert_state, tmp_path, monkeypatch, caplog): """One line per chart reply: reading the data, the calculation, drawing, the PNG (and its size) and sending it.""" import re from lib.ecowitt import Ecowitt, api @@ -368,7 +368,7 @@ async def reply_photo(photo, caption=None, **kw): async def send_chat_action(*a, **k): pass with caplog.at_level("DEBUG"): - await Bot(NS(tz=TZ), Agent(), sources, None).respond(update, NS(bot=NS(send_chat_action=send_chat_action, id=99)), + await Bot(NS(tz=TZ), Agent(), sources, alert_state).respond(update, NS(bot=NS(send_chat_action=send_chat_action, id=99)), "Temperature chart 1d") await eco.close() lines = [r.getMessage() for r in caplog.records if r.getMessage().startswith("Chart ms:")] @@ -377,7 +377,7 @@ async def send_chat_action(*a, **k): assert re.fullmatch(rf"Chart ms: data \d+, calc \d+, draw \d+, PNG \d+ \({kb} KB\), send \d+", lines[0]) -async def _ask_with(tool_result, text, monkeypatch=None): +async def _ask_with(alert_state, tool_result, text, monkeypatch=None): """Ask `text` of a bot whose one tool returns `tool_result`; returns (replies, whether the model was asked).""" asked = [] @@ -402,29 +402,29 @@ async def reply_text(body, **kw): 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) + await Bot(NS(tz=TZ), Agent(), sources, alert_state).respond(update, NS(bot=NS(send_chat_action=send_chat_action, id=99)), text) return replies, bool(asked) -async def test_a_plain_reading_question_is_answered_in_code_without_the_model(): +async def test_a_plain_reading_question_is_answered_in_code_without_the_model(alert_state): import json result = json.dumps({"outdoor": {"temperature": "12.3 โ„ƒ", "feels_like": "11.0 โ„ƒ"}, "indoor": {"temperature": "18.3 โ„ƒ"}}) - replies, asked = await _ask_with(result, "how hot is it") + replies, asked = await _ask_with(alert_state, result, "how hot is it") assert not asked and replies == ["โ€ข Outdoor: 12.3 ยฐC, feels like 11.0 ยฐC\nโ€ข Indoor: 18.3 ยฐC"] -async def test_a_lookup_that_cannot_be_written_falls_back_to_the_model(): +async def test_a_lookup_that_cannot_be_written_falls_back_to_the_model(alert_state): import json - replies, asked = await _ask_with(json.dumps({"error": "station not answering"}), "how hot is it") + replies, asked = await _ask_with(alert_state, json.dumps({"error": "station not answering"}), "how hot is it") assert asked and replies == ["from the model"] -async def test_about_the_bot_is_answered_in_code_without_the_model(): - replies, asked = await _ask_with("{}", "what can you do") +async def test_about_the_bot_is_answered_in_code_without_the_model(alert_state): + replies, asked = await _ask_with(alert_state, "{}", "what can you do") assert not asked and replies[0].startswith("โ€ข Temperature, humidity") -async def test_typing_starts_before_the_model_is_asked_and_runs_alongside_the_draft(): +async def test_typing_starts_before_the_model_is_asked_and_runs_alongside_the_draft(alert_state): seen = {} class Agent: @@ -433,11 +433,11 @@ async def run(self, *a, **k): await asyncio.sleep(0.05) return "ok" bot = DraftBot() - assert await ask_private(Agent(), bot) == ["ok"] + assert await ask_private(alert_state, Agent(), bot) == ["ok"] assert seen["typing_when_asked"] >= 1 and bot.drafts -async def test_the_forecast_is_fetched_with_the_first_step_of_a_rain_ahead_question(): +async def test_the_forecast_is_fetched_with_the_first_step_of_a_rain_ahead_question(alert_state): got = [] class Agent: @@ -455,11 +455,11 @@ async def reply_text(body, **kw): 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)), "will it rain?") + await Bot(NS(tz=TZ), Agent(), sources, alert_state).respond(update, NS(bot=NS(send_chat_action=send_chat_action, id=99)), "will it rain?") assert [name for name, _ in got[0]] == ["weather_now", "weather_history", "weather_forecast"] and replies == ["Possibly."] -async def _message_aged(seconds): +async def _message_aged(alert_state, seconds): from datetime import datetime, timedelta, timezone answered = [] @@ -478,22 +478,22 @@ async def send_chat_action(*a, **k): date=datetime.now(timezone.utc) - timedelta(seconds=seconds), chat=NS(type="private", title=None, id=1), 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")) - bot = Bot(NS(tz=TZ), Agent(), [], None) + bot = Bot(NS(tz=TZ), Agent(), [], alert_state) await bot.on_message(update, NS(bot=NS(send_chat_action=send_chat_action, id=99, username="b"))) return replies -async def test_a_message_sent_while_the_bot_was_starting_is_answered(caplog): - assert await _message_aged(120) == ["ok"] +async def test_a_message_sent_while_the_bot_was_starting_is_answered(alert_state, caplog): + assert await _message_aged(alert_state, 120) == ["ok"] -async def test_a_message_that_waited_hours_is_ignored_and_logged(caplog): +async def test_a_message_that_waited_hours_is_ignored_and_logged(alert_state, caplog): with caplog.at_level("INFO", logger="lib.bot"): - assert await _message_aged(2 * 3600) == [] + assert await _message_aged(alert_state, 2 * 3600) == [] assert "Ignored a message 120 minutes old" in caplog.text -async def test_a_heavy_question_gets_a_heads_up_in_the_chat_before_the_answer_and_the_answer_still_comes(): +async def test_a_heavy_question_gets_a_heads_up_in_the_chat_before_the_answer_and_the_answer_still_comes(alert_state): class Agent: async def run(self, messages, system, effort, on_heavy=None, **k): await on_heavy(24000) @@ -509,26 +509,26 @@ async def reply_text(body, **kw): 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)), "is it too cold to run") + await Bot(NS(tz=TZ), Agent(), sources, alert_state).respond(update, NS(bot=NS(send_chat_action=send_chat_action, id=99)), "is it too cold to run") assert replies == ["\u26a0\ufe0f That's pulling in about 24k tokens of data, so it will take a little longer. Working on it...", "The answer."] -async def test_a_days_over_a_limit_question_is_counted_in_code_with_no_model(): +async def test_a_days_over_a_limit_question_is_counted_in_code_with_no_model(alert_state): import json result = json.dumps({"period": "Wed 5 Oct 2022 - Sat 3 Oct 2026", "days_checked": 1454, "matching_days": 137, "condition": "UV index โ‰ฅ 10"}) - replies, asked = await _ask_with(result, "days over UVI 10 by year") + replies, asked = await _ask_with(alert_state, result, "days over UVI 10 by year") assert not asked and replies == ["137 of 1,454 days with UV index โ‰ฅ 10 (Wed 5 Oct 2022 โ€“ Sat 3 Oct 2026)."] -async def test_rain_by_month_is_worked_out_in_code_with_no_model(): +async def test_rain_by_month_is_worked_out_in_code_with_no_model(alert_state): import json result = json.dumps({"period": "Fri 4 Oct 2024 - Sat 3 Oct 2026", "days_checked": 731, "matching_days": 731, "stat": {"what": "Total rain", "of": "rain", "unit": "mm"}, "value": 1234.5}) - replies, asked = await _ask_with(result, "rain by month for 2 years") + replies, asked = await _ask_with(alert_state, result, "rain by month for 2 years") assert not asked and replies == ["Total rain: 1234.5 mm over 731 days (Fri 4 Oct 2024 โ€“ Sat 3 Oct 2026)."] -async def test_every_model_call_sends_the_full_tool_list_not_a_per_question_one(): +async def test_every_model_call_sends_the_full_tool_list_not_a_per_question_one(alert_state): seen = [] class Agent: @@ -547,11 +547,11 @@ async def reply_text(body, **kw): 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) + await Bot(NS(tz=TZ), Agent(), sources, alert_state).respond(update, NS(bot=NS(send_chat_action=send_chat_action, id=99)), text) assert seen == ["all", "all"] -async def test_weather_all_week_and_a_plain_chart_are_sent_with_just_their_title(monkeypatch): +async def test_weather_all_week_and_a_plain_chart_are_sent_with_just_their_title(alert_state, monkeypatch): import json from lib import bot as botmod from lib.specs import Chart, Line, Panel @@ -581,11 +581,11 @@ async def reply_photo(photo, caption=None, **kw): 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) + await Bot(NS(tz=TZ), Agent(), sources, alert_state).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(): +async def test_the_report_carries_a_refresh_button_that_redraws_it_in_place(alert_state): import json from lib import report_menu @@ -620,7 +620,9 @@ async def reply_text(body, **kw): 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) + bot = Bot(NS(tz=TZ), Agent(), sources, alert_state) + alert_state.add_chat(1, "Rob") + alert_state.set_keyboard(1, bot.keyboard_version) # its buttons are current, so a reply carries only its own 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 diff --git a/tests/test_periods.py b/tests/test_periods.py index 3b19450..d9f4156 100644 --- a/tests/test_periods.py +++ b/tests/test_periods.py @@ -57,7 +57,7 @@ async def call(self, name, raw, turn=None): return json.dumps({"period": "Fri 25 Sep 2026 - Thu 01 Oct 2026", "series": {"outdoor.temperature": {"unit": "โ„ƒ", "low": "1", "high": "2"}}}) -def make_bot(monkeypatch): +def make_bot(alert_state, monkeypatch): class Agent: tools = Tools() @@ -65,7 +65,7 @@ async def run(self, *a, **k): raise AssertionError("no model for a plain chart") monkeypatch.setattr(botmod, "render_chart", lambda spec, tz, times=None: b"png") sources = [NS(name="Ecowitt", wants=lambda t: True, poke=lambda: None, describe=lambda: "Ecowitt")] - return Bot(NS(tz=TZ), Agent(), sources, None) + return Bot(NS(tz=TZ), Agent(), sources, alert_state) def chat_message(sent, edits=None): @@ -86,8 +86,8 @@ async def send_chat_action(*a, **k): CONTEXT = NS(bot=NS(send_chat_action=send_chat_action, id=99)) -async def test_a_chart_is_sent_with_the_other_periods_under_it_and_its_question_is_remembered(monkeypatch): - bot, sent = make_bot(monkeypatch), [] +async def test_a_chart_is_sent_with_the_other_periods_under_it_and_its_question_is_remembered(alert_state, monkeypatch): + bot, sent = make_bot(alert_state, monkeypatch), [] msg = chat_message(sent) update = NS(effective_message=msg, effective_chat=msg.chat, effective_user=NS(username="rob", full_name="Rob")) await bot.respond(update, CONTEXT, "Temperature chart 30d") @@ -96,8 +96,8 @@ async def test_a_chart_is_sent_with_the_other_periods_under_it_and_its_question_ assert bot.charted[(1, 101)] == "Temperature chart 30d" -async def test_pressing_a_period_redraws_the_chart_in_place_with_that_period_left_out(monkeypatch): - bot, edits = make_bot(monkeypatch), [] +async def test_pressing_a_period_redraws_the_chart_in_place_with_that_period_left_out(alert_state, monkeypatch): + bot, edits = make_bot(alert_state, monkeypatch), [] msg = chat_message([], edits) bot.charted.remember(1, 50, "Temperature chart 30d") answers = [] @@ -114,8 +114,8 @@ async def answer(text=None): assert bot.charted[(1, 50)] == "Temperature chart 90d" -async def test_a_button_on_a_chart_from_before_a_restart_says_so(monkeypatch): - bot = make_bot(monkeypatch) +async def test_a_button_on_a_chart_from_before_a_restart_says_so(alert_state, monkeypatch): + bot = make_bot(alert_state, monkeypatch) answers = [] async def answer(text=None): @@ -152,8 +152,8 @@ def test_a_chart_drawn_lately_comes_back_from_the_cache_until_it_is_too_old_or_p assert len(cache.items) == periods.ImageCache.CHART_IMAGES and "q0" not in cache.items -async def test_toggling_back_to_a_period_just_drawn_uses_the_cached_image_and_asks_nothing_again(monkeypatch): - bot, sent, edits = make_bot(monkeypatch), [], [] +async def test_toggling_back_to_a_period_just_drawn_uses_the_cached_image_and_asks_nothing_again(alert_state, monkeypatch): + bot, sent, edits = make_bot(alert_state, monkeypatch), [], [] msg = chat_message(sent, edits) update = NS(effective_message=msg, effective_chat=msg.chat, effective_user=NS(username="rob", full_name="Rob")) await bot.respond(update, CONTEXT, "Temperature chart 7d") @@ -169,8 +169,8 @@ async def answer(text=None): assert [e[0] for e in edits] == ["Temperature"] * 4 # the repeated 30d press edited nothing and bot.charted[(1, 101)] == "Temperature chart 7d" -async def test_a_chart_once_sent_goes_again_as_telegrams_copy_not_a_new_upload(monkeypatch): - bot, uploads, edits = make_bot(monkeypatch), [], [] +async def test_a_chart_once_sent_goes_again_as_telegrams_copy_not_a_new_upload(alert_state, monkeypatch): + bot, uploads, edits = make_bot(alert_state, monkeypatch), [], [] async def reply_photo(photo, caption=None, **kw): uploads.append(photo) @@ -192,8 +192,8 @@ async def answer(text=None): assert uploads == [b"png"] and edits == ["upload", "7d", "edit 1"] # 30d drawn and uploaded once; then both by Telegram's copy -async def test_redrawing_a_chart_from_a_button_shows_no_typing_or_thinking(monkeypatch): - bot = make_bot(monkeypatch) +async def test_redrawing_a_chart_from_a_button_shows_no_typing_or_thinking(alert_state, monkeypatch): + bot = make_bot(alert_state, monkeypatch) actions = [] async def chat_action(*a, **k): @@ -221,8 +221,8 @@ async def reply_photo(photo, caption=None, **kw): return NS(effective_message=msg, effective_chat=msg.chat, effective_user=NS(username="rob", full_name="Rob")) -async def test_the_button_charts_are_drawn_ahead_and_a_press_is_sent_one_at_once(monkeypatch, caplog): - bot, sent = make_bot(monkeypatch), [] +async def test_the_button_charts_are_drawn_ahead_and_a_press_is_sent_one_at_once(alert_state, monkeypatch, caplog): + bot, sent = make_bot(alert_state, monkeypatch), [] with caplog.at_level("INFO"): await bot.predraw() calls = len(bot.agent.tools.calls) @@ -242,9 +242,9 @@ async def test_the_button_charts_are_drawn_ahead_and_a_press_is_sent_one_at_once assert not any("drawn ahead" in r.getMessage() for r in caplog.records) # only the first round is logged -async def test_a_chart_drawn_ahead_is_drawn_again_once_it_is_stale_and_never_answers_other_words(monkeypatch): +async def test_a_chart_drawn_ahead_is_drawn_again_once_it_is_stale_and_never_answers_other_words(alert_state, monkeypatch): clock = [0.0] - bot, sent = make_bot(monkeypatch), [] + bot, sent = make_bot(alert_state, monkeypatch), [] bot.images = periods.ImageCache(lambda: clock[0]) await bot.predraw() calls = len(bot.agent.tools.calls) @@ -257,9 +257,9 @@ async def test_a_chart_drawn_ahead_is_drawn_again_once_it_is_stale_and_never_ans assert len(bot.agent.tools.calls) == calls + 1 -async def test_charts_are_not_drawn_ahead_while_a_question_is_being_answered(monkeypatch): +async def test_charts_are_not_drawn_ahead_while_a_question_is_being_answered(alert_state, monkeypatch): import asyncio - bot = make_bot(monkeypatch) + bot = make_bot(alert_state, monkeypatch) bot.answering = 1 bot.idle.clear() drawing = asyncio.create_task(bot.predraw()) @@ -274,11 +274,11 @@ async def test_charts_are_not_drawn_ahead_while_a_question_is_being_answered(mon -def preloading_bot(monkeypatch, refuse=None, clock=None): +def preloading_bot(alert_state, monkeypatch, refuse=None, clock=None): """A bot with TELEGRAM_PRELOAD_CHAT set, and the (chat, photo, silent) of every upload to it. The periods are uploaded side by side, in no fixed order, so each period's picture is its question's words and its file id "file " and those words; context.refused.error is what the uploads raise (none: they go through).""" - bot, uploads, refused = make_bot(monkeypatch), [], NS(error=refuse) + bot, uploads, refused = make_bot(alert_state, monkeypatch), [], NS(error=refuse) bot.cfg = NS(tz=TZ, telegram_preload_chat=-1001) if clock: bot.images = periods.ImageCache(lambda: clock[0]) @@ -307,8 +307,8 @@ async def settle(bot): await asyncio.gather(*bot.background) -async def test_the_other_periods_of_a_chart_are_uploaded_ahead_and_a_press_edits_by_file_id(monkeypatch): - bot, uploads, context = preloading_bot(monkeypatch) +async def test_the_other_periods_of_a_chart_are_uploaded_ahead_and_a_press_edits_by_file_id(alert_state, monkeypatch): + bot, uploads, context = preloading_bot(alert_state, monkeypatch) sent, edits = [], [] async def edit_media(media, reply_markup=None): @@ -329,15 +329,15 @@ async def answer(text=None): assert len(bot.agent.tools.calls) == calls and edits[0] == "file Temperature chart 90d" # Telegram's copy of the 3M, no new question or upload -async def test_nothing_is_uploaded_ahead_without_the_setting_and_a_refused_upload_stops_it_with_one_warning(monkeypatch, caplog): - bot, uploads, context = preloading_bot(monkeypatch) +async def test_nothing_is_uploaded_ahead_without_the_setting_and_a_refused_upload_stops_it_with_one_warning(alert_state, monkeypatch, caplog): + bot, uploads, context = preloading_bot(alert_state, monkeypatch) bot.cfg = NS(tz=TZ) update = pressed(bot, []) await bot.respond(update, context, "Temperature chart 7d") await settle(bot) assert uploads == [] and not bot.background from telegram.error import BadRequest - bot, uploads, context = preloading_bot(monkeypatch, refuse=BadRequest("Chat not found")) + bot, uploads, context = preloading_bot(alert_state, monkeypatch, refuse=BadRequest("Chat not found")) with caplog.at_level("WARNING"): await bot.respond(pressed(bot, []), context, "Temperature chart 7d") await settle(bot) @@ -346,13 +346,13 @@ async def test_nothing_is_uploaded_ahead_without_the_setting_and_a_refused_uploa assert [r for r in caplog.records if "TELEGRAM_PRELOAD_CHAT" in r.getMessage()].__len__() == 1 and uploads == [] and bot.preload_off -async def test_a_flood_limit_or_a_timeout_skips_those_uploads_and_the_next_chart_uploads_again(monkeypatch, caplog): +async def test_a_flood_limit_or_a_timeout_skips_those_uploads_and_the_next_chart_uploads_again(alert_state, monkeypatch, caplog): import warnings from telegram.error import RetryAfter, TimedOut with warnings.catch_warnings(): warnings.simplefilter("ignore") # python-telegram-bot 22 warns whenever it builds a RetryAfter flood = RetryAfter(30) - bot, uploads, context = preloading_bot(monkeypatch) + bot, uploads, context = preloading_bot(alert_state, monkeypatch) for error, asked in ((flood, "Temperature chart 7d"), (TimedOut(), "Temperature chart 30d")): context.refused.error = error with caplog.at_level("WARNING"): @@ -366,8 +366,8 @@ async def test_a_flood_limit_or_a_timeout_skips_those_uploads_and_the_next_chart assert sorted(uploads) == uploads_of(1, 365) # the 7D and the 1M were sent as the questions' own charts -async def test_a_period_that_cannot_be_drawn_is_left_out_and_the_others_are_uploaded(monkeypatch): - bot, uploads, context = preloading_bot(monkeypatch) +async def test_a_period_that_cannot_be_drawn_is_left_out_and_the_others_are_uploaded(alert_state, monkeypatch): + bot, uploads, context = preloading_bot(alert_state, monkeypatch) draw = bot._draw_ahead async def draw_ahead(text, now): @@ -381,9 +381,9 @@ async def draw_ahead(text, now): assert bot.images.has_copy(bot.images.key("Temperature chart 30d", 30, NOW)) and not bot.images.has_copy(bot.images.key("Temperature chart 90d", 90, NOW)) -async def test_charts_are_uploaded_ahead_at_once_even_while_a_question_is_being_answered(monkeypatch): +async def test_charts_are_uploaded_ahead_at_once_even_while_a_question_is_being_answered(alert_state, monkeypatch): import asyncio - bot, uploads, context = preloading_bot(monkeypatch) + bot, uploads, context = preloading_bot(alert_state, monkeypatch) bot.answering = 1 bot.idle.clear() bot.preload_soon(context.bot, "Temperature chart 7d") @@ -391,9 +391,9 @@ async def test_charts_are_uploaded_ahead_at_once_even_while_a_question_is_being_ assert sorted(uploads) == uploads_of(1, 30, 90, 365) -async def test_each_period_is_uploaded_as_soon_as_it_is_drawn_not_when_the_slowest_is(monkeypatch): +async def test_each_period_is_uploaded_as_soon_as_it_is_drawn_not_when_the_slowest_is(alert_state, monkeypatch): import asyncio - bot, uploads, context = preloading_bot(monkeypatch) + bot, uploads, context = preloading_bot(alert_state, monkeypatch) slow, three, draw, send = asyncio.Event(), asyncio.Event(), bot._draw_ahead, context.bot.send_photo async def draw_ahead(text, now): @@ -415,9 +415,9 @@ async def send_photo(chat, photo, **kw): assert sorted(uploads) == uploads_of(1, 30, 90, 365) -async def test_a_chart_uploaded_ahead_is_used_for_30_minutes_and_then_drawn_afresh(monkeypatch): +async def test_a_chart_uploaded_ahead_is_used_for_30_minutes_and_then_drawn_afresh(alert_state, monkeypatch): clock = [0.0] - bot, uploads, context = preloading_bot(monkeypatch, clock=clock) + bot, uploads, context = preloading_bot(alert_state, monkeypatch, clock=clock) await bot.respond(pressed(bot, []), context, "Temperature chart 7d") await settle(bot) key = bot.images.key("Temperature chart 30d", 30, NOW) From 0a24b1b42750928d093a32093dba6f3a761e6cdf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 17:18:30 +0000 Subject: [PATCH 27/65] The Bot always has its state: the None guards go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ecobot always gives the Bot the state file's AlertState, so the thirteen "if self.state" checks in bot.py (and the one in admins.py) guarded a case only tests made, along with a reply ("Alerts aren't available: no sensor is connected.") no chat could get. They go, and the state is a required AlertState. The two checks that also ask whether the chat is known stay. The tests of the None case itself, and test_templates.py's last Bot without a state, go or get the fixture's. lib: +16 โˆ’24 lines. (The last commit's message said 20 tests had a Bot with no state: it was 19 constructions, used by 45 tests.) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/admins.py | 2 +- lib/bot.py | 38 +++++++++++++++----------------------- tests/test_templates.py | 14 ++++++-------- 3 files changed, 22 insertions(+), 32 deletions(-) diff --git a/lib/admins.py b/lib/admins.py index 5fece2f..ab7b0f3 100644 --- a/lib/admins.py +++ b/lib/admins.py @@ -24,7 +24,7 @@ def __init__(self, state, extra_ids=(), ttl: float = TTL_SECONDS, clock=time.mon def groups(self) -> list[int]: """The groups whose admins count: the ones the bot is in (not one that removed it), and the ones ADMIN_CHAT_IDS names.""" - known = {c for c in (self.state.present() if self.state else []) if c < 0} + known = {c for c in self.state.present() if c < 0} return sorted(known | {i for i in self.extra if i < 0}) async def _admins_of(self, bot, chat_id: int) -> frozenset[int]: diff --git a/lib/bot.py b/lib/bot.py index cdeaed0..9256ce6 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -236,10 +236,11 @@ async def deliver(msg: Message, text: str, photos: list[bytes], link: tuple[str, class Bot: - def __init__(self, cfg: Config, agent: Agent, sources: list, state: AlertState | None, irrigation=None): - """irrigation: the Tuya client of the irrigation controller, when one is configured (its button replaces Wind).""" + def __init__(self, cfg: Config, agent: Agent, sources: list, state: AlertState, irrigation=None): + """state: the known chats and their settings. irrigation: the Tuya client of the irrigation controller, when one is + configured (its button replaces Wind).""" self.cfg, self.agent, self.sources, self.state, self.irrigation = cfg, agent, sources, state, irrigation - self.watchdog = Watchdog(irrigation, state) if irrigation and state else None # closes the valve after a run, however it ends + self.watchdog = Watchdog(irrigation, state) if irrigation else None # closes the valve after a run, however it ends self.keyboard_version = templates.version(irrigation is not None) self.admin_only = bool(getattr(cfg, "admin_only", False)) # ADMIN_ONLY: only the admins of the groups the bot is in may use it self.admins = Admins(state, getattr(cfg, "admin_chat_ids", ())) @@ -341,8 +342,7 @@ async def on_button(self, update: Update, context: ContextTypes.DEFAULT_TYPE): try: await handler(update, context) except ChatMigrated as e: # a press on an alert from before its group became a supergroup (the chat has a new id) - if self.state: - self.state.migrate_chat(query.message.chat.id, e.new_chat_id) + self.state.migrate_chat(query.message.chat.id, e.new_chat_id) with contextlib.suppress(TelegramError): await query.answer("This group was upgraded to a supergroup: please use the buttons on new alerts") @@ -354,13 +354,13 @@ def _title(update: Update) -> str: def remember_chat(self, update: Update): """Record the chat so alerts can be sent to it (Telegram can't list a bot's chats).""" - if self.state and update.effective_chat: + if update.effective_chat: self.state.add_chat(update.effective_chat.id, self._title(update)) async def on_membership(self, update: Update, context: ContextTypes.DEFAULT_TYPE): """The bot was added to or removed from a chat.""" change = update.my_chat_member - if not self.state or not change: + if not change: return status = change.new_chat_member.status if status in ("member", "administrator"): @@ -378,9 +378,6 @@ async def on_alerts(self, update: Update, context: ContextTypes.DEFAULT_TYPE): in_group = chat.type != ChatType.PRIVATE if not in_group and not await self._allowed(update, context): # anyone in a group may look; a private chat is gated like the rest return - if not self.state: - await msg.reply_text("Alerts aren't available: no sensor is connected.") - return arg = context.args[0].lower() if context.args else "" self.remember_chat(update) if arg in ("on", "off"): @@ -416,7 +413,7 @@ async def on_alert_button(self, update: Update, context: ContextTypes.DEFAULT_TY its sound on or off. In a group only admins may change them.""" query = update.callback_query press = menu.parse(query.data) - if press is None or not self.state: + if press is None: await query.answer() return message = query.message @@ -451,7 +448,7 @@ async def on_alert_button(self, update: Update, context: ContextTypes.DEFAULT_TY # ---------- irrigation ---------- def _irrigation_alerts(self, chat_id: int) -> tuple[bool, bool]: """Does this chat get the irrigation battery alerts, and the pause alerts?""" - return tuple(bool(self.state and self.state.subscribed(chat_id, kind)) for kind in ("irrigation_battery", "irrigation_pause")) + return tuple(self.state.subscribed(chat_id, kind) for kind in ("irrigation_battery", "irrigation_pause")) def _irrigation_keyboard(self, chat_id: int, opened: str | None = None, paused: bool = False, running: bool = False): return irrigation_menu.keyboard(*self._irrigation_alerts(chat_id), opened, paused, running) @@ -487,7 +484,7 @@ async def _irrigation_act(self, action: str, arg: str, chat_id: int) -> str | No if self.watchdog: 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: + if action in ("batt", "pause") and arg in ("on", "off") and chat_id in self.state.chats: self.state.set_kind(chat_id, "irrigation_battery" if action == "batt" else "irrigation_pause", arg == "on", self._alert_kinds()) return f"Irrigation {'battery' if action == 'batt' else 'pause'} alerts {arg} in this chat" return None @@ -653,14 +650,12 @@ async def on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE): await update.effective_message.reply_text( HELP.format(user=update.effective_user.id, chat=update.effective_chat.id), reply_markup=templates.keyboard(self.irrigation is not None) if private else None) - if private and self.state: + if private: self.state.set_keyboard(update.effective_chat.id, self.keyboard_version) async def refresh_keyboards(self, tg_bot) -> int: """At startup: tell each private chat whose buttons are out of date that they changed, with the new keyboard (silently). A chat that hid them is left alone. Returns how many were sent.""" - if not self.state: - return 0 sent = 0 for chat_id in [c for c in self.state.present() if c > 0 and self.state.keyboard(c) not in (self.keyboard_version, templates.HIDDEN)]: try: @@ -678,8 +673,7 @@ async def refresh_keyboards(self, tg_bot) -> int: def _keyboard_stale(self, msg: Message) -> bool: """Does this private chat need the current buttons (it has never had them, or they have changed since)? Not when it hid them.""" - return bool(self.state and msg.chat.type == ChatType.PRIVATE - and self.state.keyboard(msg.chat_id) not in (self.keyboard_version, templates.HIDDEN)) + return msg.chat.type == ChatType.PRIVATE and self.state.keyboard(msg.chat_id) not in (self.keyboard_version, templates.HIDDEN) async def on_keyboard(self, update: Update, context: ContextTypes.DEFAULT_TYPE): """/keyboard shows the buttons again, /keyboard off hides them (private chats only).""" @@ -690,12 +684,10 @@ async def on_keyboard(self, update: Update, context: ContextTypes.DEFAULT_TYPE): await msg.reply_text("The buttons are only in private chats.") elif context.args and context.args[0].lower() == "off": await msg.reply_text("Buttons hidden. /keyboard shows them again.", reply_markup=ReplyKeyboardRemove()) - if self.state: - self.state.set_keyboard(msg.chat_id, templates.HIDDEN) + self.state.set_keyboard(msg.chat_id, templates.HIDDEN) else: await msg.reply_text("Here are the buttons.", reply_markup=templates.keyboard(self.irrigation is not None)) - if self.state: - self.state.set_keyboard(msg.chat_id, self.keyboard_version) + self.state.set_keyboard(msg.chat_id, self.keyboard_version) async def on_error(self, update: object, context: ContextTypes.DEFAULT_TYPE): """Errors raised inside handlers: network blips get one line, anything else a traceback.""" @@ -1036,7 +1028,7 @@ async def _respond(self, update: Update, context: ContextTypes.DEFAULT_TYPE, tex self.images.sent(kept, sent_photo[0]) if kept and read.fast and read.chart_in_code: # a chart with period buttons, made in code: its other periods are uploaded ahead self.preload_soon(context.bot, text) - if self.state and ok and turn.forecast_shown: # it was sent: a later revision of these days is worth telling this chat + if ok and turn.forecast_shown: # it was sent: a later revision of these days is worth telling this chat self.state.record_forecast(msg.chat_id, turn.forecast_shown, now_local(self.cfg.tz).date()) if carried_keyboard: log.info("Buttons: sent keyboard %s to chat %s (it had %s)", self.keyboard_version, msg.chat_id, self.state.keyboard(msg.chat_id)) diff --git a/tests/test_templates.py b/tests/test_templates.py index 693a821..b4cc3e0 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -42,8 +42,8 @@ def test_the_keyboard_is_persistent_and_has_every_button(): class Recorder(Bot): - def __init__(self): - super().__init__(NS(tz=TZ), None, [], None) + def __init__(self, state): + super().__init__(NS(tz=TZ), None, [], state) self.asked, self.alerts = [], 0 async def respond(self, update, context, text): @@ -63,15 +63,15 @@ async def reply_text(body, **kw): return NS(effective_message=msg, effective_chat=msg.chat, effective_user=NS(id=7, full_name="Rob", username="rob")), sent -async def test_tapping_a_button_asks_its_sentence_and_capabilities_and_alerts_prints_both(): - bot = Recorder() +async def test_tapping_a_button_asks_its_sentence_and_capabilities_and_alerts_prints_both(alert_state): + bot = Recorder(alert_state) for text in ("\U0001f4cb Status", "weather now", templates.CAPABILITIES): await bot.on_message(message(text)[0], NS(bot=NS(username="b", id=99))) assert bot.asked == ["Report", "weather now"] and bot.alerts == 1 # capabilities are not sent to the model -async def test_start_carries_the_keyboard_in_private_chats_only_and_keyboard_off_removes_it(): - bot = Recorder() +async def test_start_carries_the_keyboard_in_private_chats_only_and_keyboard_off_removes_it(alert_state): + bot = Recorder(alert_state) update, sent = message("/start") await bot.on_start(update, NS(args=[])) assert sent[0][1]["reply_markup"].is_persistent @@ -143,7 +143,6 @@ async def test_a_chat_with_an_old_or_no_keyboard_gets_the_current_one_with_its_n state.set_keyboard(1, templates.HIDDEN) assert not bot._keyboard_stale(msg) # they hid it: leave it hidden assert not bot._keyboard_stale(Replies("supergroup")) - assert not Bot(NS(tz=TZ), None, [], None)._keyboard_stale(msg) async def test_start_and_keyboard_commands_record_which_keyboard_the_chat_has(tmp_path): @@ -207,7 +206,6 @@ async def send_message(self, chat_id, text, **kw): assert state.keyboard(10) == templates.version() and state.chats[13]["gone"] and state.keyboard(-20) is None tried.clear() assert await bot.refresh_keyboards(TG()) == 0 and tried == [] # nothing more on the next start: 13 blocked the bot - assert await Bot(NS(tz=TZ), None, [], None).refresh_keyboards(TG()) == 0 def test_the_capabilities_list_is_the_users_bullets(): From 712dc2ddff409493e793432a4202743108042d1f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 17:23:34 +0000 Subject: [PATCH 28/65] Each setting is declared once, on its Config field; LOG_LEVEL joins them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A setting was written three times: its field and default on Config, its variable, default and reader again in from_env, and its paths' defaults a second time. Each field now names its variable, its default and its reader, and from_env reads them all in one pass; the four file paths have no defaults of their own any more (from_env puts them in the data folder). Every reader takes the text, the default and the variable's name. Compared with the old module over 79 environments (blank, valid and broken values for every setting), only the cooldown below differs. - ALERT_COOLDOWN_MINUTES=0 was read as unset (30 minutes) while 1 to 4 meant 5; 0 and anything below 5 now mean 5. - LOG_LEVEL was read at import, outside Config, and a typo was ignored without a word (M3). It is a setting now, applied when the bot starts, and a value it doesn't know keeps INFO with a warning. The README says so. lib: +91 โˆ’83 lines (+8; the plan guessed โˆ’15: the readers for the cooldown, LOG_LEVEL, the coordinates and the URL are new, and each field line now carries its variable). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 2 +- ecobot.py | 6 +- lib/config.py | 174 ++++++++++++++++++++------------------- scripts/dump_specs.py | 3 +- tests/test_admin_only.py | 3 +- tests/test_startup.py | 21 ++++- 6 files changed, 115 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index 4aeaedc..e41d7aa 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ The state and caches go in `/data` (its `DATA_DIR`), here a named volume. A host | `TELEGRAM_PRELOAD_CHAT` | optional: a chat id (negative for a group or channel) the bot silently uploads charts to ahead of need. With it set, each chart sent with period buttons has its other periods drawn and uploaded there in the background, each on its own the moment it is drawn, starting as soon as the chart is sent and whatever else is being answered, so pressing a period button edits the message with Telegram's copy and uploads nothing. A chart uploaded this way is used for 30 minutes, then drawn afresh. The bot must be able to post in that chat; the uploaded messages are left there. If Telegram refuses an upload (the bot can't post there) none is tried again until a restart; a flood limit or a timeout only skips that photo. Unset: off | | `ANTISPIKE_REQUIRE_CONSECUTIVE` | optional: how many readings in a row a rise of the particles (PM1, PM2.5, PM10) needs to count as the air rather than a glitch of the sensor. Default `2`: a single-reading spike (at least 2.5 times, and 5 ยตg/mยณ above, the readings either side) is left out of PM charts and of every air figure (highs, lows, averages, days over a limit), and the unhealthy-air alert counts a check only when the sensor's last 2 readings are all over the mask level. `3` also drops two-reading spikes, and so on; `1` or `off` turns the rule off. The live "air now" reading and the "safe again" check are unchanged | | `LOG_WIDTH` | optional: the most characters of a log line, cut with an ellipsis (โ€ฆ); `0` cuts none. Default 71. Every log line obeys it, whatever its level (a warning is printed as `WARN`); a request, chat message, reply or alert is also put on one line | -| `LOG_LEVEL` | optional: `DEBUG`, `INFO` (default), `WARNING` or `ERROR`. `DEBUG` adds the bot's own debug lines (the chart timings, charts it couldn't draw ahead); the libraries stay at their usual level. | +| `LOG_LEVEL` | optional: `DEBUG`, `INFO` (default), `WARNING` or `ERROR` (any other value keeps `INFO`, with a warning). `DEBUG` adds the bot's own debug lines (the chart timings, charts it couldn't draw ahead); the libraries stay at their usual level. | An on/off setting takes `on`, `yes`, `true` or `1`, and `off`, `no`, `false` or `0`; any other value keeps its default (and the log says so), so a typo can't switch `ADMIN_ONLY` off. A setting left blank is its default. diff --git a/ecobot.py b/ecobot.py index 9c68b35..6699e6f 100644 --- a/ecobot.py +++ b/ecobot.py @@ -45,10 +45,6 @@ logging.basicConfig(level=logging.INFO, handlers=[_handler]) logging.getLogger("httpx").setLevel(logging.WARNING) logging.getLogger("telegram.ext.Application").setLevel(logging.WARNING) # "Application started" etc. -_level = os.environ.get("LOG_LEVEL", "INFO").strip().upper() # LOG_LEVEL: DEBUG shows the bot's own debug lines (chart timings...), not the libraries' -if _level in ("DEBUG", "WARNING", "ERROR"): - for _name in ("ecobot", "lib"): - logging.getLogger(_name).setLevel(_level) log = logging.getLogger("ecobot") @@ -111,6 +107,8 @@ async def main(): log.info("Starting ecobot %s", version()) cfg = Config.from_env() _handler.formatter.width = cfg.log_width # every log line is cut to it from here on + for name in ("ecobot", "lib"): # the bot's own lines: the libraries keep theirs + logging.getLogger(name).setLevel(cfg.log_level) air_metrics.REQUIRE_CONSECUTIVE = cfg.antispike_require_consecutive intent.FEELS_LIKE_IN_ALL = cfg.chart_all_feels_like intent.VPD_IN_ALL = cfg.chart_all_vpd diff --git a/lib/config.py b/lib/config.py index 87f2745..d341df6 100644 --- a/lib/config.py +++ b/lib/config.py @@ -1,9 +1,10 @@ -"""Settings, all from environment variables (systemd loads them from the unit's EnvironmentFile).""" +"""Settings, all from environment variables (systemd loads them from the unit's EnvironmentFile). Each is declared once, as +a field of Config: the variable it is read from, its default, and how its text is read.""" import logging import os import re -from dataclasses import dataclass +from dataclasses import dataclass, field, fields from datetime import datetime, tzinfo from pathlib import Path from zoneinfo import ZoneInfo, ZoneInfoNotFoundError @@ -40,6 +41,7 @@ def local_tz() -> tzinfo: ON, OFF = ("on", "yes", "true", "1"), ("off", "no", "false", "0") +# Each reader takes a setting's text (stripped; empty when unset or blank), its default and the variable's name. def _flag(value: str, default: bool, name: str) -> bool: """An on/off setting: on, yes, true or 1; off, no, false or 0; unset or empty is the default. Any other value keeps the default too, with a warning, so a typo can't switch a safeguard such as ADMIN_ONLY off.""" @@ -52,14 +54,18 @@ def _flag(value: str, default: bool, name: str) -> bool: return default -def _required(name: str) -> str: +def _required(value: str, default: str, name: str) -> str: """A setting the bot can't run without; it stops with a message naming it when it is unset or blank.""" - if value := os.getenv(name, "").strip(): + if value: return value raise SystemExit(f"{name} is not set: the bot can't start without it (see the README)") -def _hours(value: str, default: tuple[int, int] | None) -> tuple[int, int] | None: +def _url(value: str, default: str, name: str) -> str: + return (value or default).rstrip("/") + + +def _hours(value: str, default: tuple[int, int] | None, name: str) -> tuple[int, int] | None: """"0-6" -> (0, 6); "off" or "none" -> None; anything unreadable -> the default.""" if value.lower() in ("off", "none", "no", "false"): return None @@ -70,13 +76,13 @@ def _hours(value: str, default: tuple[int, int] | None) -> tuple[int, int] | Non return (start, end) if 0 <= start <= 23 and 0 <= end <= 24 and start != end else default -def _hour(value: str, default: int) -> int: +def _hour(value: str, default: int, name: str) -> int: """"18" -> 18; anything unreadable or outside 0 to 23 -> the default.""" number = _number(value) return int(number) if number is not None and 0 <= number <= 23 else default -def _ids(value: str) -> frozenset[int]: +def _ids(value: str, default: frozenset[int], name: str) -> frozenset[int]: """"123, -1001 456" -> {123, -1001, 456}. An entry that is not a whole number is ignored (and logged): it must not stop the bot.""" found, bad = set(), [] for part in filter(None, re.split(r"[,\s]+", value)): @@ -85,23 +91,31 @@ def _ids(value: str) -> frozenset[int]: except ValueError: bad.append(part) if bad: - log.warning("ADMIN_CHAT_IDS: ignoring %s (not whole numbers)", ", ".join(map(repr, bad))) - return frozenset(found) + log.warning("%s: ignoring %s (not whole numbers)", name, ", ".join(map(repr, bad))) + return frozenset(found) or default -def _whole(value: str, default: int) -> int: +def _whole(value: str, default: int, name: str) -> int: """"100" -> 100, "0" -> 0; anything but a whole number of 0 or more -> the default.""" return int(value) if value.isdecimal() else default -def _readings_in_a_row(value: str, default: int) -> int: +def _between(low: int, high: int): + """A reader of a whole number kept from `low` to `high` ("0" is `low`, like any number under it); unreadable -> the default.""" + def read(value: str, default: int, name: str) -> int: + number = _number(value) + return default if number is None else int(min(high, max(low, number))) + return read + + +def _readings_in_a_row(value: str, default: int, name: str) -> int: """"3" -> 3; "off", "0" or "1" -> 1 (no rule); anything unreadable -> the default.""" if value.lower() in ("off", "no", "false", "none"): return 1 return max(1, int(value)) if value.isdecimal() else default -def _chat_id(value: str, name: str) -> int | None: +def _chat_id(value: str, default: int | None, name: str) -> int | None: """A Telegram chat id (negative for a group or channel); None, with a warning, when it is not a whole number.""" if not value: return None @@ -119,42 +133,69 @@ def _number(value: str) -> float | None: return None +def _decimal(value: str, default: float | None, name: str) -> float | None: + return _number(value) if value else default + + +LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR") + + +def _level(value: str, default: str, name: str) -> str: + """One of LEVELS, whatever its case; another value keeps the default, with a warning.""" + if value.upper() in LEVELS: + return value.upper() + if value: + log.warning("%s: %r is not %s or %s; keeping %s", name, value, ", ".join(LEVELS[:-1]), LEVELS[-1], default) + return default + + +def _setting(name: str, default=None, read=None): + """A Config field read from the environment variable `name` by `read` (a reader above; none: the text, blank being the default).""" + return field(default=default, metadata={"env": name, "read": read}) + + +def _from_env(f) -> object: + text = os.getenv(f.metadata["env"], "").strip() + return f.metadata["read"](text, f.default, f.metadata["env"]) if f.metadata["read"] else text or f.default + + @dataclass(frozen=True) class Config: - telegram_token: str - xai_api_key: str - xai_base_url: str - xai_model: str tz: tzinfo - ecowitt_api_key: str - ecowitt_app_key: str - airgradient_token: str - airgradient_location: str - airgradient_dashboard: str - state_path: Path = ROOT / "bot_state.json" - cache_path: Path = ROOT / "ecowitt_cache.sqlite" - air_cache_path: Path = ROOT / "airgradient_cache.sqlite" - conditions_cache_path: Path = ROOT / "conditions_cache.sqlite" # the last pollen page and forecast, kept across restarts - place: str = "Melbourne" # PLACE: named after the Pollen & asthma and Forecast headings in the report - pollen: bool = False # POLLEN=on: Melbourne grass pollen and thunderstorm asthma risk (a scraped website) - pollen_district: str = "Central" # the Victorian forecast district whose thunderstorm asthma risk is used - forecast: bool = False # FORECAST=on: the daily forecast from Open-Meteo - forecast_lat: float | None = None # FORECAST_LAT / FORECAST_LON; the weather station's own location when unset - forecast_lon: float | None = None - rain_quiet_hours: tuple[int, int] | None = (0, 6) # RAIN_QUIET_HOURS="0-6" (local hours; "off" for none): no rain alerts, a summary after - chart_all_feels_like: bool = False # CHART_ALL_FEELS_LIKE=on: "weather all week" also draws the feels-like panel (off: it nearly repeats temperature) - chart_all_vpd: bool = False # CHART_ALL_VPD=on: "weather all week" also draws the vapour pressure deficit panel (off: it is temperature and humidity combined) - tuya_device_id: str = "" # TUYA_DEVICE_ID, TUYA_CLIENT_ID, TUYA_CLIENT_SECRET: all three switch the irrigation controller on - tuya_client_id: str = "" - tuya_client_secret: str = "" - tuya_base_url: str = "https://openapi.tuyaeu.com" # TUYA_BASE_URL: the data centre's address (EU by default) - irrigation_check_hour: int = 18 # IRRIGATION_CHECK_HOUR: the local hour of the daily check (0 to 23) - admin_only: bool = True # ADMIN_ONLY (on by default): the bot answers nobody but the admins of the groups it is in - admin_chat_ids: frozenset[int] = frozenset() # ADMIN_CHAT_IDS: more ids allowed besides those admins (a user id, or a group id for its anonymous admins) - alert_cooldown_minutes: int = 30 # ALERT_COOLDOWN_MINUTES: the rain and temperature-crossing alerts: dry this long before "the rain has stopped", and this long between alerts (5 to 150) - telegram_preload_chat: int | None = None # TELEGRAM_PRELOAD_CHAT: a chat the other periods of each chart are silently uploaded to ahead of the press (none: off) - antispike_require_consecutive: int = 2 # ANTISPIKE_REQUIRE_CONSECUTIVE: readings in a row a rise of the particles needs to be the air, not a glitch of the sensor (1: off) - log_width: int = 71 # LOG_WIDTH: the most characters of a long log line (a request, a chat message), cut with an ellipsis (โ€ฆ); 0 cuts none + state_path: Path + cache_path: Path + air_cache_path: Path + conditions_cache_path: Path # the last pollen page and forecast, kept across restarts + telegram_token: str = _setting("TELEGRAM_BOT_TOKEN", "", _required) + xai_api_key: str = _setting("XAI_API_KEY", "", _required) + xai_base_url: str = _setting("XAI_BASE_URL", "https://api.x.ai/v1") + xai_model: str = _setting("XAI_MODEL", "grok-4.3") + ecowitt_api_key: str = _setting("ECOWITT_API_KEY", "") + ecowitt_app_key: str = _setting("ECOWITT_APP_KEY", "") + airgradient_token: str = _setting("AIRGRADIENT_API_TOKEN", "") + airgradient_location: str = _setting("AIRGRADIENT_LOCATION_ID", "") + airgradient_dashboard: str = _setting("AIRGRADIENT_DASHBOARD_URL", "") + place: str = _setting("PLACE", "Melbourne") # named after the Pollen & asthma and Forecast headings in the report + pollen: bool = _setting("POLLEN", False, _flag) # Melbourne grass pollen and thunderstorm asthma risk (a scraped website) + pollen_district: str = _setting("POLLEN_DISTRICT", "Central") # the Victorian forecast district whose thunderstorm asthma risk is used + forecast: bool = _setting("FORECAST", False, _flag) # the daily forecast from Open-Meteo + forecast_lat: float | None = _setting("FORECAST_LAT", None, _decimal) # the weather station's own location when unset + forecast_lon: float | None = _setting("FORECAST_LON", None, _decimal) + rain_quiet_hours: tuple[int, int] | None = _setting("RAIN_QUIET_HOURS", (0, 6), _hours) # "0-6", local hours ("off" for none): no rain alerts, a summary after + chart_all_feels_like: bool = _setting("CHART_ALL_FEELS_LIKE", False, _flag) # "weather all week" also draws the feels-like panel (off: it nearly repeats temperature) + chart_all_vpd: bool = _setting("CHART_ALL_VPD", False, _flag) # "weather all week" also draws the vapour pressure deficit panel (off: it is temperature and humidity combined) + tuya_device_id: str = _setting("TUYA_DEVICE_ID", "") # all three switch the irrigation controller on + tuya_client_id: str = _setting("TUYA_CLIENT_ID", "") + tuya_client_secret: str = _setting("TUYA_CLIENT_SECRET", "") + tuya_base_url: str = _setting("TUYA_BASE_URL", "https://openapi.tuyaeu.com", _url) # the data centre's address (EU by default) + irrigation_check_hour: int = _setting("IRRIGATION_CHECK_HOUR", 18, _hour) # the local hour of the daily check (0 to 23) + admin_only: bool = _setting("ADMIN_ONLY", True, _flag) # the bot answers nobody but the admins of the groups it is in + admin_chat_ids: frozenset[int] = _setting("ADMIN_CHAT_IDS", frozenset(), _ids) # more ids allowed besides those admins (a user id, or a group id for its anonymous admins) + alert_cooldown_minutes: int = _setting("ALERT_COOLDOWN_MINUTES", 30, _between(5, 150)) # the rain and temperature-crossing alerts: dry this long before "the rain has stopped", and this long between alerts + telegram_preload_chat: int | None = _setting("TELEGRAM_PRELOAD_CHAT", None, _chat_id) # a chat the other periods of each chart are silently uploaded to ahead of the press (none: off) + antispike_require_consecutive: int = _setting("ANTISPIKE_REQUIRE_CONSECUTIVE", 2, _readings_in_a_row) # readings in a row a rise of the particles needs to be the air, not a glitch of the sensor (1: off) + log_width: int = _setting("LOG_WIDTH", 71, _whole) # the most characters of a long log line (a request, a chat message), cut with an ellipsis (โ€ฆ); 0 cuts none + log_level: str = _setting("LOG_LEVEL", "INFO", _level) # DEBUG adds the bot's own debug lines; the libraries keep theirs @property def ecowitt(self) -> bool: @@ -170,8 +211,8 @@ def airgradient(self) -> bool: @classmethod def from_env(cls) -> "Config": - env = lambda k, default="": os.getenv(k, "").strip() or default # set but blank is the default too - flag = lambda k, default=False: _flag(env(k), default, k) + """Every setting from its variable, the files in the data folder. Stops, saying why, when one the bot can't run without is + missing or the folder can't be written.""" data = data_dir() try: data.mkdir(parents=True, exist_ok=True) @@ -181,42 +222,9 @@ def from_env(cls) -> "Config": 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=_required("TELEGRAM_BOT_TOKEN"), - xai_api_key=_required("XAI_API_KEY"), - xai_base_url=env("XAI_BASE_URL", "https://api.x.ai/v1"), - xai_model=env("XAI_MODEL", "grok-4.3"), - tz=local_tz(), - ecowitt_api_key=env("ECOWITT_API_KEY"), - ecowitt_app_key=env("ECOWITT_APP_KEY"), - airgradient_token=env("AIRGRADIENT_API_TOKEN"), - airgradient_location=env("AIRGRADIENT_LOCATION_ID"), - airgradient_dashboard=env("AIRGRADIENT_DASHBOARD_URL"), - place=env("PLACE", "Melbourne"), - pollen=flag("POLLEN"), - pollen_district=env("POLLEN_DISTRICT", "Central"), - forecast=flag("FORECAST"), - forecast_lat=_number(env("FORECAST_LAT")), - forecast_lon=_number(env("FORECAST_LON")), - rain_quiet_hours=_hours(env("RAIN_QUIET_HOURS"), (0, 6)), - chart_all_feels_like=flag("CHART_ALL_FEELS_LIKE"), - chart_all_vpd=flag("CHART_ALL_VPD"), - tuya_device_id=env("TUYA_DEVICE_ID"), - tuya_client_id=env("TUYA_CLIENT_ID"), - tuya_client_secret=env("TUYA_CLIENT_SECRET"), - tuya_base_url=env("TUYA_BASE_URL", "https://openapi.tuyaeu.com").rstrip("/"), - irrigation_check_hour=_hour(env("IRRIGATION_CHECK_HOUR"), 18), - admin_only=flag("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))), - log_width=_whole(env("LOG_WIDTH"), 71), - antispike_require_consecutive=_readings_in_a_row(env("ANTISPIKE_REQUIRE_CONSECUTIVE"), 2), - telegram_preload_chat=_chat_id(env("TELEGRAM_PRELOAD_CHAT"), "TELEGRAM_PRELOAD_CHAT"), - 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", - ) + cfg = cls(tz=local_tz(), 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", + **{f.name: _from_env(f) for f in fields(cls) if "env" in f.metadata}) if not (cfg.ecowitt or cfg.airgradient): raise SystemExit("Set ECOWITT_API_KEY + ECOWITT_APP_KEY and/or AIRGRADIENT_API_TOKEN + AIRGRADIENT_LOCATION_ID") return cfg diff --git a/scripts/dump_specs.py b/scripts/dump_specs.py index 4c10f32..76e9ab9 100644 --- a/scripts/dump_specs.py +++ b/scripts/dump_specs.py @@ -125,7 +125,8 @@ async def main(): db.close() cfg = Config(telegram_token="", xai_api_key="", xai_base_url="", xai_model="", tz=tz, ecowitt_api_key="offline", ecowitt_app_key="offline", airgradient_token="offline", airgradient_location=str(loc or "offline"), - airgradient_dashboard="", state_path=Path(tmp) / "state.json", cache_path=eco_path, air_cache_path=air_path) + airgradient_dashboard="", state_path=Path(tmp) / "state.json", cache_path=eco_path, air_cache_path=air_path, + conditions_cache_path=Path(tmp) / "conditions.sqlite") eco = air = None if have_eco: eco = Ecowitt(cfg, transport=Offline()) diff --git a/tests/test_admin_only.py b/tests/test_admin_only.py index 17035a5..2da4811 100644 --- a/tests/test_admin_only.py +++ b/tests/test_admin_only.py @@ -112,7 +112,8 @@ def test_admin_only_is_on_unless_switched_off_and_the_listed_ids_are_read_safely monkeypatch.setenv("ADMIN_ONLY", value) assert Config.from_env().admin_only is on, value assert "ADMIN_ONLY: 'ture' is not on or off; keeping the default (on)" in caplog.text - assert _ids("1, -1001 22,,x, 3.5 -7") == frozenset({1, -1001, 22, -7}) and _ids("") == frozenset() + ids = lambda text: _ids(text, frozenset(), "ADMIN_CHAT_IDS") + assert ids("1, -1001 22,,x, 3.5 -7") == frozenset({1, -1001, 22, -7}) and ids("") == frozenset() assert "ADMIN_CHAT_IDS: ignoring 'x', '3.5'" in caplog.text monkeypatch.setenv("ADMIN_CHAT_IDS", "5 -9") assert Config.from_env().admin_chat_ids == frozenset({5, -9}) diff --git a/tests/test_startup.py b/tests/test_startup.py index 4fa5076..b1dd195 100644 --- a/tests/test_startup.py +++ b/tests/test_startup.py @@ -218,9 +218,21 @@ def test_the_alert_cooldown_is_configurable_with_a_default_and_limits(monkeypatc monkeypatch.setenv("ECOWITT_APP_KEY", "b") monkeypatch.delenv("ALERT_COOLDOWN_MINUTES", raising=False) assert Config.from_env().alert_cooldown_minutes == 30 - for value, want in (("45", 45), ("1", 5), ("999", 150), ("junk", 30)): + for value, want in (("45", 45), ("1", 5), ("0", 5), ("-3", 5), ("999", 150), ("junk", 30)): # 0 is under 5 like 1 is monkeypatch.setenv("ALERT_COOLDOWN_MINUTES", value) - assert Config.from_env().alert_cooldown_minutes == want + assert Config.from_env().alert_cooldown_minutes == want, value + + +def test_the_log_level_is_a_setting_and_a_typo_keeps_info_with_a_warning(monkeypatch, caplog): + from lib.config import Config + for k, v in (("TELEGRAM_BOT_TOKEN", "t"), ("XAI_API_KEY", "k"), ("ECOWITT_API_KEY", "a"), ("ECOWITT_APP_KEY", "b")): + monkeypatch.setenv(k, v) + monkeypatch.delenv("LOG_LEVEL", raising=False) + assert Config.from_env().log_level == "INFO" + for value, want in (("debug", "DEBUG"), ("WARNING", "WARNING"), ("verbose", "INFO")): + monkeypatch.setenv("LOG_LEVEL", value) + assert Config.from_env().log_level == want, value + assert "LOG_LEVEL: 'verbose' is not DEBUG, INFO, WARNING or ERROR; keeping INFO" in caplog.text @@ -242,8 +254,9 @@ def test_a_blank_setting_means_its_default_and_a_missing_key_says_which(monkeypa def test_the_rain_quiet_hours_are_configurable(): from lib.config import _hours - assert _hours("", (0, 6)) == (0, 6) and _hours("22-6", (0, 6)) == (22, 6) and _hours("off", (0, 6)) is None - assert _hours("junk", (0, 6)) == (0, 6) and _hours("6-6", (0, 6)) == (0, 6) and _hours("0-25", (0, 6)) == (0, 6) + hours = lambda text: _hours(text, (0, 6), "RAIN_QUIET_HOURS") + assert hours("") == (0, 6) and hours("22-6") == (22, 6) and hours("off") is None + assert hours("junk") == (0, 6) and hours("6-6") == (0, 6) and hours("0-25") == (0, 6) def test_feels_like_in_chart_all_is_off_by_default_and_a_config_option_turns_it_on(monkeypatch): From c0cd545017330ed09882514e67e4313faecf0105 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 17:34:13 +0000 Subject: [PATCH 29/65] Readings and air metrics are declared once, in series MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The air metrics' labels, chart units, words and rating limits were written in metrics.py, report.py, daytable.py and intent.py (its AIR_METRICS). They are one table, series.AIR, and the others derive from it. panels.py reads it there instead of from the airgradient package, which ends a circular import: "import lib.panels" on its own failed until now (the bot worked only because airgradient was imported first). A test imports it alone. - "Has a low worth telling" was written twice with opposite polarity (specs.NO_LOW listing the readings without one, metrics.MARK_LOW the metrics with one). It is a flag on each series reading and metric. The days tool's own list (it includes rain_rate, which isn't a series reading) goes with R12, which builds those fields from series. - intent's two ladders from a reading to its weather_days field (one for counting days, one for a figure per month or year) are one map, with each reading's default stat. Over 8,916 decisions (2,972 questions in 3 source combinations) one answer changes, a fix: "lowest UV by month" (or "min uv each year") asked for the highest daily UV; it now asks for the lowest daily high. A test pins it. - A test rates every metric at its own limits (a mutation of PM10's went unnoticed before). lib: +75 โˆ’69 lines (+6; the plan guessed โˆ’12: the field map's notes and the air table's rating notes moved rather than went). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/airgradient/metrics.py | 28 ++++++------------- lib/daytable.py | 4 +-- lib/intent.py | 56 ++++++++++++++++++-------------------- lib/panels.py | 7 ++--- lib/report.py | 7 ++--- lib/series.py | 38 +++++++++++++++++++++----- lib/specs.py | 4 +-- tests/test_airgradient.py | 7 +++++ tests/test_intent.py | 17 ++++++++++++ 9 files changed, 99 insertions(+), 69 deletions(-) diff --git a/lib/airgradient/metrics.py b/lib/airgradient/metrics.py index ac082ce..65fc3b9 100644 --- a/lib/airgradient/metrics.py +++ b/lib/airgradient/metrics.py @@ -2,6 +2,8 @@ from datetime import datetime +from ..series import AIR + # name -> (fields to try, in order: corrected first), unit METRICS = { "pm2_5": (("pm02_corrected", "pm02"), "ยตg/mยณ"), @@ -12,26 +14,12 @@ "nox_index": (("noxIndex", "nox_index"), "relative index (1 = baseline)"), } -# Traffic-light ratings: value <= first -> good, <= second -> poor, above -> very poor. -# Particles follow the US AQI (very poor = AQI 151+, the same level as the mask alerts); -# PM1 has no standard, so it uses PM2.5's; CO2, VOC and NOx follow AirGradient's colour scales. -RATINGS = { - "pm2_5": (9.0, 55.4), - "pm10": (54.0, 254.0), - "pm1": (9.0, 55.4), - "co2": (799.0, 1499.0), - "voc_index": (150.0, 250.0), - "nox_index": (20.0, 150.0), -} - -LABELS = {"pm2_5": "PM2.5", "pm10": "PM10", "pm1": "PM1", "co2": "COโ‚‚", - "voc_index": "VOC index", "nox_index": "NOx index"} -CHART_UNITS = {"pm2_5": "ยตg/mยณ", "pm10": "ยตg/mยณ", "pm1": "ยตg/mยณ", "co2": "ppm", - "voc_index": "", "nox_index": ""} -ALL_METRICS = list(LABELS) -# Only these have a lowest reading worth marking on a chart: CO2 never falls to zero (it has an outdoor floor near 400 ppm), -# the others are often 0 or at their baseline, so their lowest says nothing. -MARK_LOW = ("co2",) +# Traffic-light ratings (series.AIR): value <= first -> good, <= second -> poor, above -> very poor. +RATINGS = {name: m.limits for name, m in AIR.items()} +LABELS = {name: m.label for name, m in AIR.items()} +CHART_UNITS = {name: m.unit for name, m in AIR.items()} +ALL_METRICS = list(AIR) +MARK_LOW = tuple(name for name, m in AIR.items() if m.low) # the metrics whose lowest is marked on a chart # Metrics charted together, one panel each on a shared time axis, top to bottom: the particles on one axis, then CO2, VOC and # NOx each on its own (their scales differ too much to share an axis honestly). A panel with none of the requested metrics # is left out. diff --git a/lib/daytable.py b/lib/daytable.py index 092bae1..4e20b27 100644 --- a/lib/daytable.py +++ b/lib/daytable.py @@ -14,6 +14,7 @@ from statistics import fmean from .captions import COUNT_CHART_HINT +from .series import AIR from .specs import Bars, Chart, Panel from .timeutil import day_bounds @@ -25,8 +26,7 @@ MAX_LIMIT = 20 PRETTY = {"temp": "temperature", "uv": "UV index", "dew_point": "dew point", "feels_like": "feels-like", "vpd": "vapour pressure deficit", "wind_gust": "wind gust", "wind_speed": "wind speed", "rain_rate": "rain rate", "indoor_temp": "indoor temperature", - "indoor_humidity": "indoor humidity", "solar": "solar radiation", "pm2_5": "PM2.5", "pm10": "PM10", "pm1": "PM1", - "co2": "COโ‚‚", "voc_index": "VOC index", "nox_index": "NOx index"} + "indoor_humidity": "indoor humidity", "solar": "solar radiation", **{name: m.label for name, m in AIR.items()}} SUFFIXES = ("_max", "_min", "_avg") diff --git a/lib/intent.py b/lib/intent.py index 5bd8560..166bae7 100644 --- a/lib/intent.py +++ b/lib/intent.py @@ -13,7 +13,7 @@ from dataclasses import dataclass, field from datetime import date, datetime, timedelta -from .series import INDOOR_READINGS, PRESSURE_WORDS, SPECIFIC, TEMP_WORDS, WEATHER as WEATHER_READINGS +from .series import AIR as AIR_TABLE, INDOOR_READINGS, PRESSURE_WORDS, SPECIFIC, TEMP_WORDS, WEATHER as WEATHER_READINGS log = logging.getLogger(__name__) @@ -39,10 +39,7 @@ def lower_effort(effort: str) -> str | None: # The two devices by name: "ecowitt" is the weather, "ag" / "airgradient" / "air gradient" the air quality _ECOWITT = r"ecowitt" _AG = r"air ?gradient|ag" -AIR_METRICS = [ # word -> metric; checked in order, so PM10 wins over PM1 - (r"pm ?2\.?5|pm25", "pm2_5"), (r"pm ?10", "pm10"), (r"pm ?1(?![0-9])", "pm1"), (r"co2|coโ‚‚", "co2"), - (r"voc\w*", "voc_index"), (r"nox", "nox_index"), -] +AIR_METRICS = [(m.words, name) for name, m in AIR_TABLE.items()] # words -> metric; checked in order, so PM10 wins over PM1 _AIR_METRIC = "|".join(words for words, _ in AIR_METRICS) # the words that name an air reading, for every pattern below _OTHER_THAN_AIR = rf"temp\w*|rain\w*|wind\w*|humid\w*|hot|cold|warm|pressure|weather|{_ECOWITT}" # a question that is not (only) about the air @@ -562,8 +559,21 @@ def reading_now(text: str) -> list[str] | None: AT_LEAST = re.compile(r"\b(?:at least|reach\w*|hit|hits)\b", I) ABOVE = re.compile(r"\b(?:over|above|more than|greater than|higher than|exceed\w*)\b", I) BELOW = re.compile(r"\b(?:under|below|less than|lower than|fewer than|at most)\b", I) -DAY_COUNT = {"temperature": "temp", "humidity": "humidity", "pressure": "pressure", "dew_point": "dew_point", "feels_like": "feels_like", - "vpd": "vpd", "solar": "solar", "uv": "uv"} # reading -> the stem of its daily field (temp_max, uv_max ...) +def _figures(name: str, stem: str) -> tuple[dict[str, str], str]: + """A reading's daily fields by stat (temp_max, temp_min, temp_avg); none for its lowest when it has no low worth telling.""" + return {"max": f"{stem}_max", **({"min": f"{stem}_min"} if WEATHER_READINGS[name].low else {}), "avg": f"{stem}_avg"}, "avg" + + +# A reading's weather_days field for each stat a question asks of it ("max" its highest, "min" its lowest, "avg" its average, +# "sum" its total), and the stat when it names none. Without a "min" it has no lowest to ask for. +DAILY = { + **{name: _figures(name, stem) for name, stem in (("temperature", "temp"), ("humidity", "humidity"), ("pressure", "pressure"), + ("dew_point", "dew_point"), ("feels_like", "feels_like"), ("vpd", "vpd"), + ("solar", "solar"))}, + "uv": ({"max": "uv_max", "min": "uv_max", "avg": "uv_max"}, "max"), # a UV index is its day's high, whatever is asked of it + "wind": ({"max": "wind_gust", "avg": "wind_speed_avg"}, "avg"), # its highest is a gust + "rain": ({"max": "rain", "min": "rain", "sum": "rain"}, "sum"), # a day's rain is its total +} HOLIDAYISH = re.compile(r"\b(holidays?|weekends?|saturdays?|sundays?)\b", I) @@ -593,15 +603,10 @@ def count_days_call(text: str, now: datetime, ecowitt: bool, air: bool) -> tuple if not ecowitt or len(names) != 1: return None name = names[0] - if name == "rain": - field = "rain" - elif name == "wind": - field = "wind_gust" - elif name in DAY_COUNT: - field = f"{DAY_COUNT[name]}_{'min' if below and name not in ('uv', 'solar') else 'max'}" - else: + if name not in DAILY: return None - tool = "weather_days" + fields = DAILY[name][0] + field, tool = fields.get("min", fields["max"]) if below else fields["max"], "weather_days" # under a limit: its lowest, if it has one if name == "uv" and op == ">": # a UV index is quoted in whole numbers: "over 10" means the days that read 10 op = ">=" spans = spans_in(rest, now) @@ -648,22 +653,13 @@ def period_figures_call(text: str, now: datetime, ecowitt: bool, air: bool) -> t if not ecowitt or len(names) != 1: return None name, tool = names[0], "weather_days" - if name == "rain": - stat, field = ("max", "rain") if high else ("min", "rain") if low else ("sum", "rain") - elif name == "uv": - stat, field = ("max", "uv_max") - elif name == "wind": - if low: - return None - stat, field = ("max", "wind_gust") if high else ("avg", "wind_speed_avg") - elif name in DAY_COUNT: - stem = DAY_COUNT[name] - stat = "max" if high else "min" if low else "avg" - field = f"{stem}_{'max' if high else 'min' if low else 'avg'}" - if stem in ("solar",) and low: - return None - else: + if name not in DAILY: + return None + fields, plain = DAILY[name] + stat = "max" if high else "min" if low else plain + if stat not in fields: # a lowest of a reading that has none (wind, solar) return None + field = fields[stat] spans = spans_in(rest, now) years = set(re.findall(r"\b((?:19|20)\d\d)\b", rest)) if len(spans) + len(years) > 1: diff --git a/lib/panels.py b/lib/panels.py index 3ab5566..2db5bd4 100644 --- a/lib/panels.py +++ b/lib/panels.py @@ -2,8 +2,7 @@ air-quality reading on its own, its good / poor / very poor zones. Every chart producer (a history answer, plot_chart, the weather links, the air-quality charts) builds its panels here, so a reading looks the same whichever path drew it.""" -from .airgradient.metrics import CHART_UNITS, LABELS, RATINGS -from .series import WEATHER +from .series import AIR, WEATHER from .specs import Bars, Line, Panel @@ -16,5 +15,5 @@ def panel_for(name: str | list[str], lines: list[Line] | None = None, bars: Bars return Panel(reading.label, reading.unit, lines, bars=bars, reading=name) names = [name] if isinstance(name, str) else name one = len(names) == 1 - return Panel(", ".join(LABELS[n] for n in names), CHART_UNITS[names[0]], lines, bars=bars, reading=names[0] if one else "", - zones=tuple(RATINGS[names[0]]) if one and len(lines) == 1 else None, peaks="pills" if one else "aside") + return Panel(", ".join(AIR[n].label for n in names), AIR[names[0]].unit, lines, bars=bars, reading=names[0] if one else "", + zones=AIR[names[0]].limits if one and len(lines) == 1 else None, peaks="pills" if one else "aside") diff --git a/lib/report.py b/lib/report.py index b2ca0d1..5f1bed1 100644 --- a/lib/report.py +++ b/lib/report.py @@ -8,9 +8,8 @@ from telegram import MessageEntity from .ecowitt.glance import solar_band -from .series import INDOOR_READINGS, WEATHER, find +from .series import AIR, INDOOR_READINGS, WEATHER, find -AIR_ROWS = (("pm2_5", "PM2.5"), ("pm10", "PM10"), ("pm1", "PM1"), ("co2", "COโ‚‚"), ("voc_index", "VOC index"), ("nox_index", "NOx index")) def _load(result: str | None) -> dict | None: @@ -118,7 +117,7 @@ def weather_lines(now: dict) -> list[str]: def air_lines(air: dict, only: list[str] | None = None) -> list[str]: out = [] - for key, label in AIR_ROWS: + for key, label in ((name, m.label) for name, m in AIR.items()): entry = air.get(key) if only is None or key in only else None if not isinstance(entry, dict) or entry.get("value") is None: continue @@ -284,7 +283,7 @@ def _weather_caption(result: dict, args: dict, turn, tz) -> list[str]: def _air_caption(result: dict, args: dict, turn) -> list[str]: lines = [] - for key, label in AIR_ROWS: + for key, label in ((name, m.label) for name, m in AIR.items()): entry = result.get(key) if key not in (args.get("metrics") or ["pm2_5"]) or not isinstance(entry, dict): continue diff --git a/lib/series.py b/lib/series.py index d347159..8bba04d 100644 --- a/lib/series.py +++ b/lib/series.py @@ -1,6 +1,6 @@ -"""The weather readings a chart can plot, in one place: what each is called, where it lives in Ecowitt's data and the -words that name it. Adding a reading here makes it plottable, chartable side by side with others, and recognised in -a question.""" +"""The weather readings a chart can plot and the air-quality metrics, in one place: what each is called, where a reading lives +in Ecowitt's data, the words that name it, and whether its lowest is worth telling. Adding a reading here makes it plottable, +chartable side by side with others, and recognised in a question.""" from typing import NamedTuple @@ -15,6 +15,7 @@ class Reading(NamedTuple): unit: str words: str # a pattern for the words that name it in a question band_field: str | None = None # a field its line is shaded up to (wind: the gusts); it keeps that band at every width + low: bool = True # its lowest is worth telling: not when it is nothing every night, calm or dry spell # A rain chart puts the rain behind the first of these readings that is on it (rain tracks humidity and pressure more than @@ -25,18 +26,41 @@ class Reading(NamedTuple): "temperature": Reading("outdoor", "temperature", "Temperature", "ยฐC", TEMP_WORDS), "humidity": Reading("outdoor", "humidity", "Humidity", "%", r"humid\w*"), "pressure": Reading("pressure", "relative", "Pressure", "hPa", PRESSURE_WORDS), - "wind": Reading("wind", "wind_speed", "Wind", "km/h", r"gusts?|wind\w*", "wind_gust"), - "rain": Reading("rainfall", "daily", "Rain", "mm", r"rain\w*|precip\w*"), + "wind": Reading("wind", "wind_speed", "Wind", "km/h", r"gusts?|wind\w*", "wind_gust", low=False), + "rain": Reading("rainfall", "daily", "Rain", "mm", r"rain\w*|precip\w*", low=False), "dew_point": Reading("outdoor", "dew_point", "Dew point", "ยฐC", r"dew\w*"), "feels_like": Reading("outdoor", "feels_like", "Feels like", "ยฐC", r"feel\w*|apparent"), "vpd": Reading("outdoor", "vpd", "Vapour pressure deficit", "kPa", r"vpd|vapou?r pressure deficit"), - "solar": Reading("solar_and_uvi", "solar", "Solar radiation", "W/mยฒ", r"solar\w*|radiation|sunshine|sun(?!\s+\d)"), - "uv": Reading("solar_and_uvi", "uvi", "UV index", "", r"uvi?|ultraviolet"), + "solar": Reading("solar_and_uvi", "solar", "Solar radiation", "W/mยฒ", r"solar\w*|radiation|sunshine|sun(?!\s+\d)", low=False), + "uv": Reading("solar_and_uvi", "uvi", "UV index", "", r"uvi?|ultraviolet", low=False), } SPECIFIC = ("dew_point", "feels_like", "vpd") # kinds of temperature and humidity, named on their own: they outrank "temperature" in a question INDOOR_READINGS = ("temperature", "humidity", "dew_point", "feels_like") # what the indoor sensor measures as well + + +class Metric(NamedTuple): + label: str + unit: str # on a chart's axis ("" for an index) + words: str # a pattern for the words that name it in a question + limits: tuple[float, float] # good up to the first, poor up to the second, very poor above + low: bool = False # its lowest is worth telling: the others are often 0 or at their baseline + + +# The air sensor's metrics, in the order a question's words are tried (PM10 before PM1). The ratings: the particles follow the +# US AQI (very poor = AQI 151+, the level of the mask alerts), PM1 has no standard so it uses PM2.5's, and CO2, VOC and NOx +# follow AirGradient's colour scales. +AIR = { + "pm2_5": Metric("PM2.5", "ยตg/mยณ", r"pm ?2\.?5|pm25", (9.0, 55.4)), + "pm10": Metric("PM10", "ยตg/mยณ", r"pm ?10", (54.0, 254.0)), + "pm1": Metric("PM1", "ยตg/mยณ", r"pm ?1(?![0-9])", (9.0, 55.4)), + "co2": Metric("COโ‚‚", "ppm", r"co2|coโ‚‚", (799.0, 1499.0), low=True), # an outdoor floor near 400 ppm: its lowest says something + "voc_index": Metric("VOC index", "", r"voc\w*", (150.0, 250.0)), + "nox_index": Metric("NOx index", "", r"nox", (20.0, 150.0)), +} + + def find_name(name: str) -> str | None: """The name of a reading given its name ("uv", "pressure"), the field it plots ("uvi", "relative") or the field it is shaded up to ("wind_gust"); None if none of those.""" diff --git a/lib/specs.py b/lib/specs.py index dcb630b..58c31a3 100644 --- a/lib/specs.py +++ b/lib/specs.py @@ -11,10 +11,10 @@ from dataclasses import dataclass, field from datetime import date -from .series import RAIN_WITH +from .series import RAIN_WITH, WEATHER -NO_LOW = ("solar", "uv", "wind", "rain") # readings whose low is always nothing (night, calm, dry): only the high is tagged +NO_LOW = tuple(name for name, r in WEATHER.items() if not r.low) # readings whose low is nothing every night or calm hour: only the high is tagged @dataclass diff --git a/tests/test_airgradient.py b/tests/test_airgradient.py index b4e1d44..0de61e8 100644 --- a/tests/test_airgradient.py +++ b/tests/test_airgradient.py @@ -18,6 +18,13 @@ async def make(tmp_path): return air, calls +def test_every_metric_is_rated_on_its_own_scale(): + for name, good, poor in (("pm2_5", 9.0, 55.4), ("pm10", 54.0, 254.0), ("pm1", 9.0, 55.4), ("co2", 799.0, 1499.0), + ("voc_index", 150.0, 250.0), ("nox_index", 20.0, 150.0)): + zones = [rating(name, v).split(" ", 1)[1] for v in (good, good + 0.1, poor, poor + 0.1)] # after the emoji + assert zones == ["good", "poor", "poor", "very poor"], name + + def test_aqi_and_ratings_follow_the_us_scale(): assert pm25_aqi(9.0) == (50, "good") assert pm25_aqi(55.5)[0] == 151 and pm25_aqi(55.5)[1] == "unhealthy" diff --git a/tests/test_intent.py b/tests/test_intent.py index 31350cd..98fecf9 100644 --- a/tests/test_intent.py +++ b/tests/test_intent.py @@ -557,3 +557,20 @@ def test_indoor_dew_point_and_feels_like_come_from_the_indoor_sensor(): assert call(text)[1]["groups"] == "indoor", text assert call("dew point chart")[1]["groups"] == "outdoor" # unasked, they stay the outdoor sensor's assert call("indoor and outdoor dew point chart 7d")[1]["groups"] == "outdoor,indoor" + + +def test_the_lowest_uv_per_month_is_the_lowest_daily_high_not_the_highest(): + tool, args, _ = intent.period_figures_call("lowest uv by month", NOW, True, False) + assert (tool, args["stat"], args["of"], args["group_by"]) == ("weather_days", "min", "uv_max", "month") + assert intent.period_figures_call("uv by month", NOW, True, False)[1]["stat"] == "max" # a UV index is its day's high + assert intent.period_figures_call("lowest wind by month", NOW, True, False) is None # wind has no lowest to ask for + + +def test_the_air_table_is_the_one_in_series_and_panels_imports_on_its_own(): + import subprocess + import sys + from lib.airgradient.metrics import LABELS, MARK_LOW, RATINGS + from lib.series import AIR + assert list(LABELS) == list(AIR) == [m for _, m in intent.AIR_METRICS] and MARK_LOW == ("co2",) and RATINGS["pm2_5"] == (9.0, 55.4) + run = subprocess.run([sys.executable, "-c", "import lib.panels"], capture_output=True, text=True) + assert run.returncode == 0, run.stderr # no circular import through airgradient From f20f7bfe4e6cf7681e471c335871df591c3fcf78 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 17:40:45 +0000 Subject: [PATCH 30/65] Days and times of day are written one way; a chart's caption takes its dates from the chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - timeutil.day_text ("Sat 3 Oct 2026", or "Sat 3 Oct") and clock_text ("3:45pm"; "10am" for one on the hour where wanted) replace about twenty hand-written forms across the alerts, the report, the pollen lines, the analyses, the days tools, the extremes, the prompt and when() (with its .replace(" 0", " ")). - What you see: a day is never zero-padded any more. The weather history's period and its daily keys, the wind's daily directions, the pollen "(forecast for ...)" notes and alert, and the prompt's date ranges read "Sat 3 Oct", not "Sat 03 Oct". The station's "time" and the extremes' raw "_time" the model reads are in the 12-hour form every other tool uses ("Fri 2 Oct 2026 2:21am", not "Fri 02 Oct 2026 02:21"), so the report reads one form. The evals pass; no chart image changes. - A chart's caption re-parsed the date range out of its subtitle, looking for " โ€“ ". The air charts write theirs with " - ", so a week of air readings up to now was captioned as if it were one day ("PM2.5, Fri 16 Oct 2026 - Mon 19 Oct 2026" instead of "PM2.5, since Fri 16 Oct 2026"). A Chart now carries its first and last day, every producer gives them, and caption() is a method of the class (it was attached afterwards). lib: +94 โˆ’95 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/airgradient/source.py | 16 ++++++------ lib/alerts/pollen_and_asthma.py | 3 ++- lib/alerts/weather.py | 12 ++++----- lib/analysis/pairs.py | 4 +-- lib/daytable.py | 13 +++++----- lib/ecowitt/days.py | 4 +-- lib/ecowitt/direction.py | 4 +-- lib/ecowitt/extremes.py | 21 ++++++---------- lib/ecowitt/link.py | 4 +-- lib/ecowitt/query.py | 16 ++++++------ lib/ecowitt/source.py | 5 ++-- lib/pollen/source.py | 6 ++--- lib/prompt.py | 3 ++- lib/report.py | 21 ++++++++-------- lib/specs.py | 44 ++++++++++++++------------------- lib/timeutil.py | 13 +++++++++- tests/test_airgradient.py | 10 ++++++++ tests/test_ecowitt.py | 2 ++ tests/test_report.py | 8 +++--- tests/test_specs.py | 14 +++++------ tests/test_timeutil.py | 8 ++++++ 21 files changed, 125 insertions(+), 106 deletions(-) diff --git a/lib/airgradient/source.py b/lib/airgradient/source.py index 1dd40d7..f82dda5 100644 --- a/lib/airgradient/source.py +++ b/lib/airgradient/source.py @@ -28,7 +28,7 @@ from ..lines import Plotted, build_line from ..panels import panel_for from ..specs import Chart, Line -from ..timeutil import local_date, now_local, parse_local, to_local, when +from ..timeutil import day_text, local_date, now_local, parse_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 @@ -357,7 +357,7 @@ async def history(self, start: str | None, end: str | None, chart: bool = False, turn.times["data"] = turn.times.get("data", 0.0) + time.perf_counter() - started # for the reply's timing line log.log(logging.DEBUG if turn.ahead else logging.INFO, "AirGradient %s to %s: %d readings, %d req", f"{t0:%Y-%m-%d}", f"{t1:%m-%d %H:%M}", len(rows), self.requests - before) - out = {"sensor_type": "outdoor", "period": f"{t0:%a} {t0.day} {t0:%b %Y} - {t1:%a} {t1.day} {t1:%b %Y}", + out = {"sensor_type": "outdoor", "period": f"{day_text(t0)} - {day_text(t1)}", "readings": len(rows)} for name, (_, unit) in METRICS.items(): pts = [(r["ts"], r[name]) for r in rows if name in r] @@ -384,7 +384,8 @@ async def history(self, start: str | None, end: str | None, chart: bool = False, if not rows: out["note"] = "No readings for this period." if chart and rows: - chart_spec = self._chart([m for m in ALL_METRICS if m in (metrics or ["pm2_5"])] or ["pm2_5"], rows, out["period"]) + chart_spec = self._chart([m for m in ALL_METRICS if m in (metrics or ["pm2_5"])] or ["pm2_5"], rows, out["period"], + (t0.date(), t1.date())) if chart_spec: turn.charts.append(chart_spec) out["chart"] = CHART_HINT @@ -404,9 +405,10 @@ def _line(self, name: str, rows: list[dict]) -> tuple[Line, Plotted] | None: lo, hi = min(pts, key=lambda p: p[1]), max(pts, key=lambda p: p[1]) return plotted.spec(LABELS[name], {"high": hi, **({"low": lo} if name in MARK_LOW else {})}, name), plotted - def _chart(self, names: list[str], rows: list[dict], period: str) -> Chart | None: + def _chart(self, names: list[str], rows: list[dict], period: str, days: tuple[date, date] | None = None) -> Chart | None: """These metrics as one chart. One metric is drawn large, with its rating zones. Several go into panels on a - shared time axis, grouped by AIR_PANELS (the particles together, the rest each alone).""" + shared time axis, grouped by AIR_PANELS (the particles together, the rest each alone). `period` is the subtitle's + text for it, `days` its first and last day (for the caption).""" drawn = {n: got for n in names if (got := self._line(n, rows))} if not drawn: return None @@ -414,11 +416,11 @@ def _chart(self, names: list[str], rows: list[dict], period: str) -> Chart | Non (name, (line, plotted)), = drawn.items() subtitle = (f"{period} ยท {plotted.name}" + (", range shaded" if plotted.low else "") + " ยท records marked" if not plotted.raw else f"{period} ยท AirGradient readings") - return Chart(LABELS[name], subtitle, [panel_for(name, [line])]) + return Chart(LABELS[name], subtitle, [panel_for(name, [line])], period=days) panels = [] for group in AIR_PANELS: members = [m for m in group if m in drawn] if members: panels.append(panel_for(members, [drawn[m][0] for m in members])) return Chart("Air quality", f"{period} ยท AirGradient readings" - + (" ยท range shaded" if any(plotted.low for _, plotted in drawn.values()) else ""), panels) + + (" ยท range shaded" if any(plotted.low for _, plotted in drawn.values()) else ""), panels, period=days) diff --git a/lib/alerts/pollen_and_asthma.py b/lib/alerts/pollen_and_asthma.py index ab5f81a..b71bcdc 100644 --- a/lib/alerts/pollen_and_asthma.py +++ b/lib/alerts/pollen_and_asthma.py @@ -10,6 +10,7 @@ import time from ..pollen.parse import LEVEL_EMOJI, LEVELS +from ..timeutil import day_text log = logging.getLogger(__name__) @@ -46,6 +47,6 @@ async def check(self): if last is None or last["rank"] < rank or last["day"] != day: 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 "" + when = f" (forecast for {day_text(for_day, year=False)})" 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_and_asthma") self.state.save() diff --git a/lib/alerts/weather.py b/lib/alerts/weather.py index e51219d..cef88f5 100644 --- a/lib/alerts/weather.py +++ b/lib/alerts/weather.py @@ -21,7 +21,7 @@ from . import confirm, pictures from .backoff import due, remember from ..ecowitt.rain import Rows, duration, rain_amount, wet_flags -from ..timeutil import local_epoch, to_local +from ..timeutil import clock_text, local_epoch, to_local log = logging.getLogger(__name__) @@ -152,7 +152,7 @@ async def _night_summary(self, rows: Rows): self.state.monitor.pop("night") if not night.get("first") or latest - night.get("began", 0) >= 86400: return # a dry night, or an old note (the bot was down) - clock = lambda ts: f"{to_local(ts, self.tz):%-I:%M%p}".replace("AM", "am").replace("PM", "pm") + clock = lambda ts: clock_text(to_local(ts, self.tz)) fell = await self._fell(rows, night["first"], night["last"]) amount = f"{fell:.1f} mm" if fell else "a trace" still = " It's still raining." if self.state.monitor.get("rain", {}).get("alerted_state") == "raining" else "" @@ -170,8 +170,8 @@ async def _gusts(self, rows: Rows): m["active"] = True gust, ts = max(over) photo = await self._picture("Wind chart 1d") - await self.notify(f"\U0001f4a8 Strong gusts: {gust:.0f} km/h at {to_local(ts, self.tz):%-I:%M%p}".replace("AM", "am").replace("PM", "pm") - + f" (alerts above {GUST_ALERT_KMH} km/h).", kind="gusts", **({"photo": photo} if photo else {})) + await self.notify(f"\U0001f4a8 Strong gusts: {gust:.0f} km/h at {clock_text(to_local(ts, self.tz))}" + f" (alerts above {GUST_ALERT_KMH} km/h).", kind="gusts", **({"photo": photo} if photo else {})) elif m["active"]: m["calm_since"] = m["calm_since"] or latest_ts if latest_ts - m["calm_since"] >= GUST_REARM_SECONDS: @@ -203,8 +203,8 @@ async def _uv(self, rows: Rows): m["active"] = True uv, ts = m["peak"] photo = await self._picture("UV chart 1d") - await self.notify(f"\U0001f9f4 UV index {uv:g} at {to_local(ts, self.tz):%-I:%M%p}".replace("AM", "am").replace("PM", "pm") - + f": very high. Sunscreen, a hat and shade (alerts at {UVI_ALERT} and above).", kind="uv", + await self.notify(f"\U0001f9f4 UV index {uv:g} at {clock_text(to_local(ts, self.tz))}" + f": very high. Sunscreen, a hat and shade (alerts at {UVI_ALERT} and above).", kind="uv", **({"photo": photo} if photo else {})) async def _cross(self, rows: Rows): diff --git a/lib/analysis/pairs.py b/lib/analysis/pairs.py index 4bdea98..df59e70 100644 --- a/lib/analysis/pairs.py +++ b/lib/analysis/pairs.py @@ -12,7 +12,7 @@ from ..airgradient.metrics import ZONES, zone from ..rain import SPELL_MM, rain_spells -from ..timeutil import MIN_DAY_SLOTS, SLOT, local_date, to_local +from ..timeutil import clock_text, day_text, local_date, MIN_DAY_SLOTS, SLOT, to_local from .scan import rank LOOKBACK = 6 # slots: the change is measured over the previous 3 hours @@ -119,7 +119,7 @@ def analyse(driver: dict[int, float], rain: dict[int, float], threshold: float, "lowest": min(driver[t] for t in s if t in driver)} for s in rain_spells(rain, wet)] fell = sum(1 for e in events if e["change"] is not None and e["change"] <= -threshold) rose = sum(1 for e in events if e["change"] is not None and e["change"] >= threshold) - clock = lambda ts: (lambda dt: f"{dt:%a} {dt.day} {dt:%b} {dt.strftime('%-I:%M%p').lower()}")(to_local(ts, tz)) + clock = lambda ts: f"{day_text(to_local(ts, tz), year=False)} {clock_text(to_local(ts, tz))}" summary, call = _summary(table, level, events, threshold, unit, clock) top = sorted(events, key=lambda e: -e["mm"])[:5] return {"slots": len(slots), "rain_mm": round(total, 1), "wet_slots": len(wet), "verdict": call, "findings": summary, "by_change_before": table, diff --git a/lib/daytable.py b/lib/daytable.py index 4e20b27..a963df4 100644 --- a/lib/daytable.py +++ b/lib/daytable.py @@ -16,7 +16,7 @@ from .captions import COUNT_CHART_HINT from .series import AIR from .specs import Bars, Chart, Panel -from .timeutil import day_bounds +from .timeutil import day_bounds, day_text log = logging.getLogger(__name__) @@ -129,13 +129,14 @@ def aggregate(stat: str, numbers: list[float]) -> float | None: return round({"max": max, "min": min, "sum": sum, "avg": fmean}[stat](numbers), 1) -def bars_chart(figures: dict[str, float | None], by: str, title: str, subtitle: str, label: str, unit: str, tz: tzinfo) -> Chart: - """One bar per month or year (those with no figure left out), the number above each.""" +def bars_chart(figures: dict[str, float | None], by: str, title: str, subtitle: str, label: str, unit: str, tz: tzinfo, + period: tuple[date, date]) -> Chart: + """One bar per month or year (those with no figure left out), the number above each; `period` is the first and last day counted.""" shown = {k: v for k, v in figures.items() if v is not None} starts = [datetime.strptime(k, "%Y-%m" if by == "month" else "%Y").date() for k in shown] bars = Bars(label, unit, [day_bounds(d, tz)[0] for d in starts], [float(v) for v in shown.values()], 28 * 86400 if by == "month" else 365 * 86400, by, values=True) - return Chart(title, subtitle, [Panel(label, "" if unit == "days" else unit, bars=bars)]) + return Chart(title, subtitle, [Panel(label, "" if unit == "days" else unit, bars=bars)], period=period) @dataclass @@ -156,7 +157,7 @@ def analyse(values: dict[str, dict[date, float]], shown: list[str], units: dict[ where = args.get("where") or [] sort_by = args.get("sort_by") or (where[0]["field"] if where else shown[0]) limit = max(1, min(int(args.get("limit") or 5), MAX_LIMIT)) - label = lambda d: f"{d:%a} {d.day} {d:%b %Y}" + label = day_text holds = lambda d, conds: all(d in values[c["field"]] and OPS[c["op"]](values[c["field"]][d], c["value"]) for c in conds) rank = lambda ds: sorted((d for d in ds if d in values[sort_by]), key=lambda d: values[sort_by][d], reverse=args.get("order", "desc") != "asc") @@ -194,7 +195,7 @@ def analyse(values: dict[str, dict[date, float]], shown: list[str], units: dict[ detail = f"{len(matches):,} days counted" detail += f" ยท {scope}" if scope else "" turn.charts.append(bars_chart(buckets, by, title, f"{label(first)} โ€“ {label(last)} ยท {detail} ยท per {by}", - name, unit, tz)) + name, unit, tz, (first, last))) out["chart"] = COUNT_CHART_HINT rows = [] if args.get("count_only") or stat != "count" and by else [row(d) for d in ranked[:limit]] if not args.get("count_only") and not (stat != "count" and by): diff --git a/lib/ecowitt/days.py b/lib/ecowitt/days.py index 9745939..6830ae9 100644 --- a/lib/ecowitt/days.py +++ b/lib/ecowitt/days.py @@ -18,7 +18,7 @@ from statistics import fmean from ..daytable import analyse, grouped_as_asked, parameters, validate -from ..timeutil import day_bounds, local_date, now_local +from ..timeutil import day_bounds, day_text, local_date, now_local from .api import daily_run from .calendar import PublicHolidays from .store import HistoryCache, subtract @@ -143,7 +143,7 @@ def find_days(cache: HistoryCache, mac: str, tz: tzinfo, args: dict, now: dateti checked = [d for d in days if kind(d) and any(d in values[n] for n in shown)] units = {n: FIELDS[n][3] for n in shown} - label = lambda d: f"{d:%a} {d.day} {d:%b %Y}" + label = day_text got = analyse(values, shown, units, args, checked, first, last, tz, turn, lambda d: {**({"holiday": calendar.name(d)} if only == "public_holiday" else {}), "source": "daily" if source.get(d) == "daily" else "exact"}, diff --git a/lib/ecowitt/direction.py b/lib/ecowitt/direction.py index bffdd3c..30140fb 100644 --- a/lib/ecowitt/direction.py +++ b/lib/ecowitt/direction.py @@ -7,7 +7,7 @@ from datetime import tzinfo from statistics import fmean -from ..timeutil import local_date +from ..timeutil import day_text, local_date POINTS = ("N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW") TOP = 3 # compass points listed, most common first @@ -63,7 +63,7 @@ def summarise(readings: list[tuple[int, float, bool]], tz: tzinfo, by_day: bool, days: dict = {} for ts, d, _ in readings: days.setdefault(local_date(ts, tz), []).append(d) - out["daily"] = {day.strftime("%a %d %b"): f"{Counter(point(d) for d in ds).most_common(1)[0][0]} " + out["daily"] = {day_text(day, year=False): f"{Counter(point(d) for d in ds).most_common(1)[0][0]} " f"({_share(Counter(point(d) for d in ds).most_common(1)[0][1], len(ds))})" for day, ds in sorted(days.items())} return out diff --git a/lib/ecowitt/extremes.py b/lib/ecowitt/extremes.py index 9ac6e21..2b4836f 100644 --- a/lib/ecowitt/extremes.py +++ b/lib/ecowitt/extremes.py @@ -3,7 +3,7 @@ from datetime import date, datetime, tzinfo, UTC from typing import NamedTuple -from ..timeutil import local_date +from ..timeutil import clock_text, day_text, local_date from .api import CYCLE_SECONDS from .fetch import series_in @@ -74,14 +74,7 @@ def daily_readings(pts: dict, before: date | None = None, tz: tzinfo | None = No if r["cycle"] != "1day" and "value" in r and (before is None or local_date(t, tz) < before)] -def _clock(dt: datetime) -> str: - """12-hour clock, e.g. '7:05am', '3:30pm', '10am'.""" - text = dt.strftime("%I:%M%p").lstrip("0").lower() - return text.replace(":00", "") if dt.minute == 0 else text - - -def _day(dt: datetime) -> str: - return f"{dt:%a} {dt.day} {dt:%b %Y}" +_clock = lambda dt: clock_text(dt, whole_hours=True) # "7:05am", "3:30pm", "10am" def describe_time(ts: int, cycle: str, tz: tzinfo) -> tuple[str, str, str]: @@ -90,12 +83,12 @@ def describe_time(ts: int, cycle: str, tz: tzinfo) -> tuple[str, str, str]: the window it happened in, so the model can copy it as is. The date is separate (so an emoji can go before it) and empty when the window spans two days, since then the wording has both.""" start = datetime.fromtimestamp(ts, UTC).astimezone(tz) - raw = start.strftime("%a %d %b %Y %H:%M") + raw = f"{day_text(start)} {clock_text(start)}" if cycle == "5min": - return raw, f"at {_clock(start)}", _day(start) + return raw, f"at {_clock(start)}", day_text(start) if cycle == "30min": - return raw, f"around {_clock(start)}", _day(start) + return raw, f"around {_clock(start)}", day_text(start) end = datetime.fromtimestamp(ts + CYCLE_SECONDS[cycle], UTC).astimezone(tz) if start.date() == end.date(): - return raw, f"sometime between {_clock(start)} and {_clock(end)}", _day(start) - return raw, f"sometime between {_clock(start)} {_day(start)} and {_clock(end)} {_day(end)}", "" + return raw, f"sometime between {_clock(start)} and {_clock(end)}", day_text(start) + return raw, f"sometime between {_clock(start)} {day_text(start)} and {_clock(end)} {day_text(end)}", "" diff --git a/lib/ecowitt/link.py b/lib/ecowitt/link.py index 4dcad96..f0b1a5b 100644 --- a/lib/ecowitt/link.py +++ b/lib/ecowitt/link.py @@ -19,7 +19,7 @@ from ..panels import panel_for from ..series import WEATHER from ..specs import Chart, Line, stack -from ..timeutil import day_bounds, local_date, now_local, parse_period +from ..timeutil import day_bounds, day_text, local_date, now_local, parse_period from .store import HistoryCache log = logging.getLogger(__name__) @@ -78,7 +78,7 @@ def link(cache: HistoryCache, mac: str, tz: tzinfo, args: dict, now: datetime) - driver, lows, highs = cache.slots(mac, "30min", group, [field, field + "_low", field + "_high"], lo, hi) (daily,) = cache.slots(mac, "30min", "rainfall", ["daily"], lo, hi) rain = rain_slots(daily) - out = {"period": f"{first:%a} {first.day} {first:%b %Y} - {last:%a} {last.day} {last:%b %Y}", "driver": f"{name} ({unit})", + out = {"period": f"{day_text(first)} - {day_text(last)}", "driver": f"{name} ({unit})", "resolution": "30-minute readings (not averaged to days)", "how_to_read": ("by_change_before groups each 30-minute slot by how the reading had changed over the previous 3 " f"hours: falling = down {threshold:g} {unit} or more, rising = up {threshold:g} or more. share_of_rain " diff --git a/lib/ecowitt/query.py b/lib/ecowitt/query.py index f16ac7e..2b8c39f 100644 --- a/lib/ecowitt/query.py +++ b/lib/ecowitt/query.py @@ -20,7 +20,7 @@ from ..lines import build_line from ..daytable import grouping_in from ..rain import rain_bars, rain_slots -from ..timeutil import SLOT, day_bounds, daily_summary, local_date, now_local, parse_local +from ..timeutil import daily_summary, day_bounds, day_text, local_date, now_local, parse_local, SLOT from ..series import WEATHER, derives_range, field_of, find, find_name from ..panels import panel_for from ..specs import Bars, Chart, Compass, Line, Panel, period_text, stack @@ -323,7 +323,7 @@ def _answer(self, refined: int) -> str: log.log(logging.DEBUG if self.turn.ahead else logging.INFO, "%s..%s: %d reads (%d cache, %d mem), %d req%s, %d refined", f"{self.start:%m-%d}", f"{self.end:%m-%d}", f.ranges, f.from_cache, f.from_memory, f.calls, f", {len(f.errors)} failed" if f.errors else "", refined) - out = {"period": f"{self.start:%a %d %b %Y} - {self.end:%a %d %b %Y}", "series": shown if shown else series_out} + out = {"period": f"{day_text(self.start)} - {day_text(self.end)}", "series": shown if shown else series_out} if self.monthly and not self.detailed: out["monthly_note"] = (f"Monthly figures for long periods come from daily data that runs " f"{daily_run(self.tz, self.start.date(), self.end.date())}, so they have no dates, and a low " @@ -358,9 +358,9 @@ def _result(self, key: str, ext: dict) -> dict: if rec["cycle"] != "1day": # sub-daily points give correct local days fold(days.setdefault(local_date(ts, tz), {}), ts, rec) keep = None if len(days) <= DAILY_ALL_DAYS or self.turn.per_day else { # a long period: the days of its records, not all of them - local_date(ext[w].ts, tz).strftime("%a %d %b") for w in ("low", "high")} - entry["daily"] = {d.strftime("%a %d %b"): {w: e.raw for w, e in days[d].items()} for d in sorted(days) - if keep is None or d.strftime("%a %d %b") in keep} + day_text(local_date(ext[w].ts, tz), year=False) for w in ("low", "high")} + entry["daily"] = {day_text(d, year=False): {w: e.raw for w, e in days[d].items()} for d in sorted(days) + if keep is None or day_text(d, year=False) in keep} else: entry["monthly"] = {} for month, d in self.monthly.get(key, {}).items(): @@ -413,7 +413,7 @@ def _add_averages(self, entry: dict, means: dict): entry["average"] = fmt(fmean(means.values())) for d, v in means.items(): table = entry.get("daily") if self.span <= timedelta(days=31) else entry.get("monthly") - label = d.strftime("%a %d %b") if self.span <= timedelta(days=31) else d.strftime("%b %Y") + label = day_text(d, year=False) if self.span <= timedelta(days=31) else d.strftime("%b %Y") if table is not None and label in table: table[label].setdefault("_sum", []).append(v) for table in (entry.get("daily"), entry.get("monthly")): @@ -440,7 +440,7 @@ def _chart_spec(self, series_out: dict, field: str | None = None) -> Chart | Non return Chart("Rain", f"{period_text(self.start.date(), self.end.date())} ยท rain per {self.rain_bars.per}" + (f" ยท older days from daily records ({daily_run(self.tz, self.start.date(), self.end.date())})" if self.rain_from_daily else ""), - [panel_for("rain", bars=self.rain_bars)]) + [panel_for("rain", bars=self.rain_bars)], period=(self.start.date(), self.end.date())) field = keys[0].split(".", 1)[-1] unit = series_out[keys[0]]["unit"].replace("ยบ", "ยฐ") lines, resolution, ranged = [], None, False @@ -467,7 +467,7 @@ def _chart_spec(self, series_out: dict, field: str | None = None) -> Chart | Non + (", shaded up to the gusts" if banded and ranged else ", range shaded" if ranged else "") + (" ยท records marked" if any(x.records for x in lines) and not banded else "")) panel = panel_for(name, lines) if (name := find_name(field)) else Panel(title, unit, lines) - return Chart(panel.label, subtitle, [panel]) + return Chart(panel.label, subtitle, [panel], period=(self.start.date(), self.end.date())) def _band_key(self, k: str) -> str | None: """The series this one is shaded up to, if it is a reading with one (wind: the gusts) and that series was fetched.""" diff --git a/lib/ecowitt/source.py b/lib/ecowitt/source.py index 018dc1e..59066a8 100644 --- a/lib/ecowitt/source.py +++ b/lib/ecowitt/source.py @@ -12,7 +12,7 @@ from ..config import Config from ..intent import NOW_GROUPS -from ..timeutil import local_epoch, naive_local, now_local +from ..timeutil import local_epoch, naive_local, now_local, when from ..tools import Tool, Turn from ..warm import FAST_REFRESH_SECONDS, Warmer from .api import EcowittAPI, GROUPS, UNITS @@ -211,8 +211,7 @@ async def _realtime(self, args: dict, turn: Turn | None = None) -> str: newest = max(newest, int(obj.get("time") or 0)) except (TypeError, ValueError): pass - when = datetime.fromtimestamp(newest, UTC).astimezone(self.tz).strftime("%a %d %b %Y %H:%M") if newest else None - return json.dumps({"time": when, **out, **({"emoji": emoji} if emoji else {})}, + return json.dumps({"time": when(newest, self.tz) if newest else None, **out, **({"emoji": emoji} if emoji else {})}, ensure_ascii=False, separators=(",", ":")) async def _live_data(self, groups: list[str]) -> dict: diff --git a/lib/pollen/source.py b/lib/pollen/source.py index e7ebce9..70922b1 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 local_date, now_local, when +from ..timeutil import day_text, local_date, now_local, when from ..tools import Tool, Turn from ..warm import Warmer, in_sync_hours from .parse import DISTRICTS, LEVEL_EMOJI, parse_melbourne_pollen @@ -150,7 +150,7 @@ def summary(self) -> str: """The levels in a few words for the log: "grass ๐Ÿ”ด High (Sat 17 Oct), asthma ๐ŸŸข Low".""" cur, parts = self.current(), [] if g := cur["grass"]: - when = f" ({g['date']:%a %d %b})" if g["date"] and g["date"] != self.now().date() else "" + when = f" ({day_text(g['date'], year=False)})" if g["date"] and g["date"] != self.now().date() else "" parts.append(f"grass {g['emoji']} {g['level']}{when}") if a := cur["asthma"]: parts.append(f"asthma {a['emoji']} {a['level']}") @@ -160,7 +160,7 @@ def lines(self) -> list[str]: """The ready-made report lines; the asthma line is left out when there is no risk forecast (outside the season).""" cur, out = self.current(), [] if g := cur["grass"]: - when = f" (forecast for {g['date']:%a %d %b})" if g["date"] and g["date"] != self.now().date() else "" + when = f" (forecast for {day_text(g['date'], year=False)})" if g["date"] and g["date"] != self.now().date() else "" out.append(f"Grass pollen: {g['emoji']} {g['level']}{when}") if a := cur["asthma"]: out.append(f"Thunderstorm asthma risk: {a['emoji']} {a['level']}") diff --git a/lib/prompt.py b/lib/prompt.py index 7d1cb2c..a81883a 100644 --- a/lib/prompt.py +++ b/lib/prompt.py @@ -6,6 +6,7 @@ from .airgradient.metrics import ALL_METRICS, CHART_UNITS, LABELS from .intent import period_ranges from .series import WEATHER +from .timeutil import day_text WEATHER_NAMES = list(WEATHER) @@ -145,7 +146,7 @@ def date_ranges(now: datetime) -> str: """Pre-computed date ranges so the model never does calendar maths (same definitions as the fast path).""" - fmt = lambda d: d.strftime("%a %d %b %Y") + fmt = day_text periods = period_ranges(now.date()) lines = [f" Today: {fmt(now)} (00:00 to now)"] for name, label in DATE_LABELS: diff --git a/lib/report.py b/lib/report.py index 5f1bed1..09fa5c1 100644 --- a/lib/report.py +++ b/lib/report.py @@ -9,6 +9,7 @@ from .ecowitt.glance import solar_band from .series import AIR, INDOOR_READINGS, WEATHER, find +from .timeutil import clock_text, day_text, to_local @@ -58,16 +59,13 @@ def heading_entities(text: str) -> list[MessageEntity]: def _stamp(result: dict, today: date) -> str | None: - """The time of the data, "11:45pm", with its day when that isn't today ("Sat 10 Oct 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 - clock = f"{at.hour % 12 or 12}:{at:%M}{'am' if at.hour < 12 else 'pm'}" - return clock if at.date() == today else f"{at:%a} {at.day} {at:%b} {clock}" - return None + """The time of the data, "11:45pm", with its day when that isn't today ("Sat 10 Oct 11:45pm"), from the result's "time" (as + timeutil.when writes it, "Sun 11 Oct 2026 11:45pm"); None when it is missing or unreadable.""" + try: + at = datetime.strptime(result.get("time") or "", "%a %d %b %Y %I:%M%p") + except ValueError: + return None + return clock_text(at) if at.date() == today else f"{day_text(at, year=False)} {clock_text(at)}" def _heading(name: str, result: dict, today: date) -> str: @@ -252,7 +250,8 @@ def _rain_line(turn, tz) -> list[str]: if not bars: return [] def day(i: int) -> str: - return datetime.fromtimestamp(bars.x[i], tz).strftime("%a %-d %b" + (" %-I%p" if bars.width < 86400 else "")).replace("AM", "am").replace("PM", "pm") + start = to_local(bars.x[i], tz) + return day_text(start, year=False) + (f" {clock_text(start, whole_hours=True)}" if bars.width < 86400 else "") lo, hi = min(range(len(bars.y)), key=bars.y.__getitem__), max(range(len(bars.y)), key=bars.y.__getitem__) per = f" per {bars.per}" if bars.per else "" 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"] diff --git a/lib/specs.py b/lib/specs.py index 58c31a3..67f1486 100644 --- a/lib/specs.py +++ b/lib/specs.py @@ -12,6 +12,7 @@ from datetime import date from .series import RAIN_WITH, WEATHER +from .timeutil import day_text NO_LOW = tuple(name for name, r in WEATHER.items() if not r.low) # readings whose low is nothing every night or calm hour: only the high is tagged @@ -117,6 +118,7 @@ class Chart: subtitle: str panels: list[Panel] compass: Compass | None = None + period: tuple[date, date] | None = None # the first and last day it covers, for its caption def __post_init__(self): if not self.panels: @@ -129,37 +131,26 @@ def __post_init__(self): if panel.reading in NO_LOW: line.records.pop("low", None) - -def _caption(self, today: date) -> str: - """The chart's caption. One panel: one line, "Temperature, since Tue 7 Jul 2026" (a period that runs up to today), "Temperature, - Wed 24 โ€“ Tue 30 Sep 2026" otherwise. Several panels: the title, then the time range on a line of its own.""" - period = self.subtitle.split(" ยท ")[0].strip() # always starts with the time range - if not period: - return self.title - if len(self.panels) != 1: - return f"{self.title}\n{period}" - day = lambda d: f"{d:%a} {d.day} {d:%b %Y}" - left, _, right = period.partition(" โ€“ ") - if not right: # one day - return f"{self.title}, {'today' if period == day(today) else period}" - if right != day(today): - return f"{self.title}, {period}" - first, last = left.split(), right.split() # "Wed 24" / "Thu 27 Aug" take their month and year from the end of the range - return f"{self.title}, since {' '.join([*first, *last[len(first):]])}" - - -Chart.caption = _caption + def caption(self, today: date) -> str: + """The chart's caption. One panel: one line, "Temperature, since Tue 7 Jul 2026" (a period that runs up to today), + "Temperature, Wed 24 โ€“ Tue 30 Sep 2026" otherwise. Several panels: the title, then the period on a line of its own.""" + if not self.period: + return self.title + first, last = self.period + if len(self.panels) != 1: + return f"{self.title}\n{period_text(first, last)}" + if first == last: + return f"{self.title}, {'today' if first == today else day_text(first)}" + return f"{self.title}, since {day_text(first)}" if last == today else f"{self.title}, {period_text(first, last)}" def period_text(a: date, b: date) -> str: """'Tue 30 Sep 2026', 'Wed 24 โ€“ Tue 30 Sep 2026', 'Thu 27 Aug โ€“ Tue 30 Sep 2026'; the year goes on both dates when it differs.""" if a == b: - return f"{a:%a} {a.day} {a:%b %Y}" + return day_text(a) if (a.year, a.month) == (b.year, b.month): - return f"{a:%a} {a.day} โ€“ {b:%a} {b.day} {b:%b %Y}" - if a.year == b.year: - return f"{a:%a} {a.day} {a:%b} โ€“ {b:%a} {b.day} {b:%b %Y}" - return f"{a:%a} {a.day} {a:%b %Y} โ€“ {b:%a} {b.day} {b:%b %Y}" + return f"{a:%a} {a.day} โ€“ {day_text(b)}" + return f"{day_text(a, year=a.year != b.year)} โ€“ {day_text(b)}" def rain_behind(panels: list[Panel]) -> list[Panel]: @@ -181,4 +172,5 @@ def stack(panels: list[Panel], first: date, last: date) -> Chart: per = next((f"{'rain' if b is p.bars else 'rating'} per {b.per}" for p in panels for b in (p.bars, p.shares) if b and b.per), "") shaded = any(s.low is not None for p in panels for s in p.lines) return Chart(", ".join(p.label for p in panels), - " ยท ".join(filter(None, [period_text(first, last), per, "range shaded" if shaded else ""])), rain_behind(panels)) + " ยท ".join(filter(None, [period_text(first, last), per, "range shaded" if shaded else ""])), rain_behind(panels), + period=(first, last)) diff --git a/lib/timeutil.py b/lib/timeutil.py index 86d07a2..5b2be98 100644 --- a/lib/timeutil.py +++ b/lib/timeutil.py @@ -17,10 +17,21 @@ def to_local(ts: int, tz: tzinfo) -> datetime: return datetime.fromtimestamp(ts, UTC).astimezone(tz) +def day_text(day: date | datetime, year: bool = True) -> str: + """"Sat 3 Oct 2026", or "Sat 3 Oct" without the year: how the bot writes a day, everywhere.""" + return f"{day:%a} {day.day} {day:%b}" + (f" {day:%Y}" if year else "") + + +def clock_text(t: datetime, whole_hours: bool = False) -> str: + """"3:45pm", "12:05am": how the bot writes a time of day; whole_hours writes one on the hour as "10am".""" + hour, half = t.hour % 12 or 12, "am" if t.hour < 12 else "pm" + return f"{hour}{half}" if whole_hours and not t.minute else f"{hour}:{t:%M}{half}" + + 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") + return f"{day_text(dt)} {clock_text(dt)}" def local_date(ts: int, tz: tzinfo) -> date: diff --git a/tests/test_airgradient.py b/tests/test_airgradient.py index 0de61e8..75ae557 100644 --- a/tests/test_airgradient.py +++ b/tests/test_airgradient.py @@ -487,3 +487,13 @@ async def test_a_date_with_an_offset_is_read_and_a_bad_one_is_not_called_a_senso bad = json.loads(await air.handle({"start_date": "yesterday morning"})) await air.close() assert "error" not in out and out["readings"] > 0 and bad["error"].startswith("bad date") + + +async def test_an_air_chart_up_to_now_is_captioned_since_its_first_day_as_a_weather_chart_is(tmp_path): + air, _ = await make(tmp_path) + now = air._now().replace(microsecond=0) + first = (now - timedelta(days=3)).date() + turn = Turn() + await air.history(f"{now - timedelta(days=3):%Y-%m-%d %H:%M:%S}", None, chart=True, turn=turn) + assert turn.charts[0].caption(now.date()) == f"PM2.5, since {first:%a} {first.day} {first:%b %Y}" # not "... - ..." as one day + await air.close() diff --git a/tests/test_ecowitt.py b/tests/test_ecowitt.py index db3ccfc..ceb3248 100644 --- a/tests/test_ecowitt.py +++ b/tests/test_ecowitt.py @@ -98,6 +98,8 @@ async def test_history_gives_extremes_with_times_and_days(station): s = result["series"]["outdoor.temperature"] assert set(s) >= {"low", "high", "low_when", "high_when", "low_date", "high_date", "daily"} assert len(s["daily"]) == 3 + assert list(s["daily"]) == [f"{d:%a} {d.day} {d:%b}" for d in (start, start + timedelta(days=1), end)] # "Sat 3 Oct", never "Sat 03 Oct" + assert result["period"] == f"{start:%a} {start.day} {start:%b %Y} - {end:%a} {end.day} {end:%b %Y}" assert float(s["high"]) > float(s["low"]) assert s["high_when"].startswith(("at ", "around ")) # refined to a 5-minute or 30-minute reading diff --git a/tests/test_report.py b/tests/test_report.py index ca5c13b..ad88e2e 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -3,8 +3,8 @@ from lib import report -# What `python scripts/report_inputs.py` printed on the real station (rain, night) -NOW = ('{"time":"Fri 02 Oct 2026 02:21","outdoor":{"temperature":"12.3 โ„ƒ","feels_like":"12.3 โ„ƒ","app_temp":"12.7 โ„ƒ","dew_point":"11.3 โ„ƒ",' +# What `python scripts/report_inputs.py` printed on the real station (rain, night), its time as the station now writes it +NOW = ('{"time":"Fri 2 Oct 2026 2:21am","outdoor":{"temperature":"12.3 โ„ƒ","feels_like":"12.3 โ„ƒ","app_temp":"12.7 โ„ƒ","dew_point":"11.3 โ„ƒ",' '"vpd":"0.085 kPa","humidity":"94 %"},"indoor":{"temperature":"18.3 โ„ƒ","humidity":"63 %","dew_point":"11.1 โ„ƒ","feels_like":"18.3 โ„ƒ",' '"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",' @@ -235,8 +235,8 @@ def test_a_figure_per_period_lookup_gives_the_figure_over_the_whole_period(): 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"}, TODAY) == "Forecast (Melbourne, 6:05pm)" - assert report._heading("Weather station", {"time": "Fri 02 Oct 2026 00:07"}, TODAY) == "Weather station (12:07am)" # the station's 24-hour form - assert report._heading("Weather station", {"time": "Fri 02 Oct 2026 13:30"}, TODAY) == "Weather station (1:30pm)" + assert report._heading("Weather station", {"time": "Fri 2 Oct 2026 12:07am"}, TODAY) == "Weather station (12:07am)" + assert report._heading("Weather station", {"time": "Fri 2 Oct 2026 1:30pm"}, TODAY) == "Weather station (1:30pm)" assert report._heading("Air quality", {"time": "Fri 2 Oct 2026 12:00pm"}, TODAY) == "Air quality (12:00pm)" assert report._heading("Forecast", {"place": "Melbourne"}, TODAY) == "Forecast (Melbourne)" # no time: as before assert report._heading("Air quality", {"time": "yesterday"}, TODAY) == "Air quality" and report._heading("Air quality", {"time": None}, TODAY) == "Air quality" diff --git a/tests/test_specs.py b/tests/test_specs.py index 35ee7d9..40822a2 100644 --- a/tests/test_specs.py +++ b/tests/test_specs.py @@ -212,13 +212,13 @@ def test_a_long_list_of_readings_in_a_stack_headline_is_cut_to_fit_the_chart(): def test_a_single_panel_charts_caption_is_one_line_and_a_stacks_is_two(): today = date(2026, 10, 4) - one = lambda sub: Chart("Temperature", sub, [Panel("Temperature", "ยฐC", [line("T")])]) - assert one("Tue 7 Jul โ€“ Sun 4 Oct 2026 ยท daily averages").caption(today) == "Temperature, since Tue 7 Jul 2026" - assert one("Wed 28 Sep โ€“ Sun 4 Oct 2026").caption(today) == "Temperature, since Wed 28 Sep 2026" - assert one("Tue 29 Sep 2025 โ€“ Sun 4 Oct 2026").caption(today) == "Temperature, since Tue 29 Sep 2025" - assert one("Wed 24 โ€“ Tue 30 Sep 2026 ยท 30-minute readings").caption(today) == "Temperature, Wed 24 โ€“ Tue 30 Sep 2026" # not up to today - assert one("Sun 4 Oct 2026").caption(today) == "Temperature, today" and one("Tue 29 Sep 2026").caption(today) == "Temperature, Tue 29 Sep 2026" - assert one("").caption(today) == "Temperature" + one = lambda *period: Chart("Temperature", "whatever the subtitle says", [Panel("Temperature", "ยฐC", [line("T")])], period=period or None) + assert one(date(2026, 7, 7), today).caption(today) == "Temperature, since Tue 7 Jul 2026" + assert one(date(2026, 9, 28), today).caption(today) == "Temperature, since Mon 28 Sep 2026" + assert one(date(2025, 9, 29), today).caption(today) == "Temperature, since Mon 29 Sep 2025" + assert one(date(2026, 9, 24), date(2026, 9, 30)).caption(today) == "Temperature, Thu 24 โ€“ Wed 30 Sep 2026" # not up to today + assert one(today, today).caption(today) == "Temperature, today" and one(date(2026, 9, 29), date(2026, 9, 29)).caption(today) == "Temperature, Tue 29 Sep 2026" + assert one().caption(today) == "Temperature" # no period: the title rain_behind = stack([Panel("Humidity", "%", [line("H")]), Panel("Rain", "mm", bars=bars())], date(2026, 9, 28), today) assert rain_behind.caption(today) == "Humidity, Rain, since Mon 28 Sep 2026" # rain behind a line is still one panel stacked = stack([Panel("Humidity", "%", [line("H")]), Panel("Pressure", "hPa", [line("P")])], date(2026, 9, 28), today) diff --git a/tests/test_timeutil.py b/tests/test_timeutil.py index b3118b0..4ab0347 100644 --- a/tests/test_timeutil.py +++ b/tests/test_timeutil.py @@ -46,3 +46,11 @@ async def readings(cycle, start, end, groups=None): await eco.recent(3) await eco.close() assert asked == [(datetime(2026, 10, 4, 0, 0), datetime(2026, 10, 4, 4, 0))] # three real hours: 2am to 3am never happened + + +def test_a_day_and_a_time_of_day_are_written_one_way(): + from datetime import date, datetime + from lib.timeutil import clock_text, day_text + assert day_text(date(2026, 10, 3)) == "Sat 3 Oct 2026" and day_text(datetime(2026, 10, 3, 9), year=False) == "Sat 3 Oct" # never "03" + assert [clock_text(datetime(2026, 10, 3, h, m)) for h, m in ((0, 5), (9, 0), (12, 30), (23, 45))] == ["12:05am", "9:00am", "12:30pm", "11:45pm"] + assert clock_text(datetime(2026, 10, 3, 10), whole_hours=True) == "10am" and clock_text(datetime(2026, 10, 3, 10, 5), whole_hours=True) == "10:05am" From 9bd9d0ad83f0514eaa5168ed07edde086f4651b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 17:51:17 +0000 Subject: [PATCH 31/65] Ecowitt's resolutions are one table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Each resolution's facts (the time a reading covers, the most one request may ask for, the days Ecowitt keeps it, how long until its readings are final) were spread over api.py, store.py's identical second copy of the seconds, and the settle times. They are one CYCLES table in api.py, of Cycle rows. - The retention edge was written six times (the query's start, its 30-minute floor and 5-minute check, the fetcher's finer cycles, the archive, the cache status script), as "age < kept - 1" in some places and "today - (kept - 2)" in others; these are the same edge. kept_from(cycle, today) gives it once. The fetcher's check now counts local days, as the others do, instead of UTC days elapsed (at most a day stricter, on the safe side, at the 88-day edge). The "within the last 4 years" message comes from the table too. - FAST_CYCLES (source) and SUB_DAILY (days) were the same pair, ("5min", "30min"): FINE_CYCLES, from the table. The archive's COARSE_CYCLES derives from it. - The chart dump (scripts/dump_specs.py: 48 files, every chart's spec, picture and refused-request count, on the fake station's archive) is identical before and after. A test pins each row's consequences (each of three mutations of the table had gone unnoticed). lib: +52 โˆ’41 lines (+11; the plan guessed โˆ’8: the table's documentation and kept_from are new lines, the six edges shrank by little). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/ecowitt/api.py | 26 ++++++++++++++++++++++---- lib/ecowitt/archive.py | 10 +++++----- lib/ecowitt/days.py | 7 +++---- lib/ecowitt/extremes.py | 4 ++-- lib/ecowitt/fetch.py | 12 ++++++------ lib/ecowitt/query.py | 20 +++++++++----------- lib/ecowitt/source.py | 5 ++--- lib/ecowitt/store.py | 9 +++------ scripts/cache_status.py | 6 +++--- tests/test_store.py | 10 ++++++++++ 10 files changed, 65 insertions(+), 44 deletions(-) diff --git a/lib/ecowitt/api.py b/lib/ecowitt/api.py index 1ee6e5b..753378c 100644 --- a/lib/ecowitt/api.py +++ b/lib/ecowitt/api.py @@ -17,16 +17,34 @@ import re from collections import deque from datetime import UTC, date, datetime, time, timedelta, tzinfo +from typing import NamedTuple import httpx log = logging.getLogger(__name__) BASE = "https://api.ecowitt.net/api/v3/device" -RETENTION = {"5min": 90, "30min": 365, "4hour": 730, "1day": 1460} # days kept -MAX_SPAN = {"5min": timedelta(days=1), "30min": timedelta(days=7), - "4hour": timedelta(days=28), "1day": timedelta(days=365)} -CYCLE_SECONDS = {"5min": 300, "30min": 1800, "4hour": 14400, "1day": 86400} + + +class Cycle(NamedTuple): + """One of Ecowitt's resolutions.""" + seconds: int # the time one reading covers + span: timedelta # the most one request may ask for + kept: int # days Ecowitt keeps its readings + settle: int # seconds after a reading's time is over before its value is final + + +CYCLES = {"5min": Cycle(300, timedelta(days=1), 90, 3600), "30min": Cycle(1800, timedelta(days=7), 365, 7200), + "4hour": Cycle(14400, timedelta(days=28), 730, 8 * 3600), "1day": Cycle(86400, timedelta(days=365), 1460, 86400)} +# Readings every half hour or finer: they fit a local day exactly (Ecowitt's 4-hour and daily ones are cut on UTC hours), +# and they change at every refresh. +FINE_CYCLES = tuple(name for name, c in CYCLES.items() if c.seconds <= 1800) + + +def kept_from(cycle: str, today: date) -> date: + """The first day whose readings at this cycle Ecowitt is sure still to have: two days clear of its retention edge, so a + request can't race the deletion.""" + return today - timedelta(days=CYCLES[cycle].kept - 2) # What the bot keeps cached and warm (the extras feed the rain alerts and predictions). One list, so the # warm-up and the archive fetch the same thing and can reuse each other's data. GROUPS = ["outdoor", "indoor", "pressure", "wind", "rainfall", "rainfall_piezo", "solar_and_uvi"] diff --git a/lib/ecowitt/archive.py b/lib/ecowitt/archive.py index 3c2a67c..b2eabe9 100644 --- a/lib/ecowitt/archive.py +++ b/lib/ecowitt/archive.py @@ -14,15 +14,15 @@ from time import monotonic from ..timeutil import naive_local, now_local, seconds_until -from .api import MIN_GAP_SECONDS, RETENTION +from .api import CYCLES, MIN_GAP_SECONDS, kept_from from .fetch import spans -from .store import BUCKET_SECONDS, horizon, subtract +from .store import horizon, subtract log = logging.getLogger(__name__) RUN_AT = time(1, 30) PACE_SECONDS = 2.0 # gap between requests, so questions aren't starved -COARSE_CYCLES = ("1day", "4hour", "30min") # 5-minute data goes day by day, first +COARSE_CYCLES = tuple(c for c in reversed(CYCLES) if c != "5min") # coarsest first; 5-minute data goes day by day, before them OUTAGE_STOP = 3 # requests failing in a row (no answer, or a busy Ecowitt) that end a run: the rest wait for the next @@ -45,14 +45,14 @@ def _work(self) -> Iterator[tuple[str, datetime, datetime]]: earliest = (created.date() - timedelta(days=1)) if created else None def first_day(cycle: str): - oldest = today - timedelta(days=RETENTION[cycle] - 2) # stay clear of the retention edge + oldest = kept_from(cycle, today) return max(oldest, earliest) if earliest else oldest def settled(cycle: str) -> datetime: """The end of the last whole bucket that is final at this resolution. Whole buckets only, so the edge holds still between runs instead of creeping forward with the clock (which would leave a few seconds uncovered, and refetched, every time).""" - size = BUCKET_SECONDS[cycle] + size = CYCLES[cycle].seconds ts = horizon(cycle) - size return naive_local(ts - ts % size - 1, tz) diff --git a/lib/ecowitt/days.py b/lib/ecowitt/days.py index 6830ae9..14a4d84 100644 --- a/lib/ecowitt/days.py +++ b/lib/ecowitt/days.py @@ -19,7 +19,7 @@ from ..daytable import analyse, grouped_as_asked, parameters, validate from ..timeutil import day_bounds, day_text, local_date, now_local -from .api import daily_run +from .api import FINE_CYCLES, daily_run from .calendar import PublicHolidays from .store import HistoryCache, subtract @@ -45,7 +45,6 @@ FIELDS["temp_avg"] = ("outdoor", "temperature", "mean", "ยฐC") AVERAGED = {n for n in FIELDS if n.endswith(("_max", "_min")) and n not in ("temp_max", "temp_min")} # no range of their own at 30 minutes or daily ALWAYS_SHOWN = ("temp_max", "temp_min", "rain") -SUB_DAILY = ("5min", "30min") PARAMETERS = parameters(list(FIELDS), "rain >= 1 and temp_max > 30", { "only": {"type": "string", "enum": ["public_holiday", "weekend"], @@ -118,7 +117,7 @@ def find_days(cache: HistoryCache, mac: str, tz: tzinfo, args: dict, now: dateti # which cycle each day is answered from: exact sub-daily data where every needed group has it days = [first + timedelta(days=k) for k in range((last - first).days + 1)] source: dict[date, str] = {} - for cycle in SUB_DAILY: + for cycle in FINE_CYCLES: cover = {g: cache.coverage(mac, cycle, g) for g in groups} for d in days: lo, hi = day_bounds(d, tz, True) @@ -127,7 +126,7 @@ def find_days(cache: HistoryCache, mac: str, tz: tzinfo, args: dict, now: dateti rest = {d for d in days if d not in source} values: dict[str, dict[date, float]] = {n: {} for n in shown} - for cycle in SUB_DAILY: + for cycle in FINE_CYCLES: mine = {d for d, c in source.items() if c == cycle} if mine: for n, figures in day_figures(cache, mac, cycle, shown, mine, tz, day_bounds(min(mine), tz, True)[0], diff --git a/lib/ecowitt/extremes.py b/lib/ecowitt/extremes.py index 2b4836f..e69618e 100644 --- a/lib/ecowitt/extremes.py +++ b/lib/ecowitt/extremes.py @@ -4,7 +4,7 @@ from typing import NamedTuple from ..timeutil import clock_text, day_text, local_date -from .api import CYCLE_SECONDS +from .api import CYCLES from .fetch import series_in @@ -88,7 +88,7 @@ def describe_time(ts: int, cycle: str, tz: tzinfo) -> tuple[str, str, str]: return raw, f"at {_clock(start)}", day_text(start) if cycle == "30min": return raw, f"around {_clock(start)}", day_text(start) - end = datetime.fromtimestamp(ts + CYCLE_SECONDS[cycle], UTC).astimezone(tz) + end = datetime.fromtimestamp(ts + CYCLES[cycle].seconds, UTC).astimezone(tz) if start.date() == end.date(): return raw, f"sometime between {_clock(start)} and {_clock(end)}", day_text(start) return raw, f"sometime between {_clock(start)} {day_text(start)} and {_clock(end)} {day_text(end)}", "" diff --git a/lib/ecowitt/fetch.py b/lib/ecowitt/fetch.py index ae0772a..4310e20 100644 --- a/lib/ecowitt/fetch.py +++ b/lib/ecowitt/fetch.py @@ -5,10 +5,10 @@ import functools import logging import time -from datetime import datetime, timedelta, tzinfo, UTC +from datetime import date, datetime, timedelta, tzinfo from ..timeutil import local_epoch, naive_local -from .api import EcowittError, MAX_SPAN, RETENTION +from .api import CYCLES, EcowittError, kept_from from .store import HistoryCache, HotStore, merge as merge_intervals, subtract log = logging.getLogger(__name__) @@ -26,7 +26,7 @@ def series_in(data: dict): def spans(cycle: str, t: datetime, until: datetime): """(start, end) pieces of [t, until) that each fit Ecowitt's per-request limit for this cycle.""" while t < until: - e = min(t + MAX_SPAN[cycle] - timedelta(seconds=1), until) + e = min(t + CYCLES[cycle].span - timedelta(seconds=1), until) yield t, e t = e + timedelta(seconds=1) @@ -156,13 +156,13 @@ async def covered_each(self, cycle: str, windows: list[tuple[int, int]]) -> list """covered() for each of these windows, from one read of the coverage.""" return [bool(self.groups) and not any(missing.values()) for missing in await self._missing(cycle, windows)] - async def finer_cycle(self, ts: int, current: str, window_end: int, now_utc: datetime) -> str | None: + async def finer_cycle(self, ts: int, current: str, window_end: int, today: date) -> str | None: """Finest cycle finer than `current` available for this window: still kept by Ecowitt, or already in the cache (e.g. archived 5-minute data). None if neither.""" - age = (now_utc - datetime.fromtimestamp(ts, UTC)).days + day = self.local(ts).date() for cycle in ("5min", "30min", "4hour"): if cycle == current: return None - if age < RETENTION[cycle] - 1 or await self.covered(cycle, ts, window_end): + if day >= kept_from(cycle, today) or await self.covered(cycle, ts, window_end): return cycle return None diff --git a/lib/ecowitt/query.py b/lib/ecowitt/query.py index 2b8c39f..961e422 100644 --- a/lib/ecowitt/query.py +++ b/lib/ecowitt/query.py @@ -13,7 +13,7 @@ import asyncio import json import logging -from datetime import date, datetime, time, timedelta, UTC +from datetime import date, datetime, time, timedelta from statistics import fmean from ..captions import AVERAGE_CHART_HINT, CHART_HINT, STACK_CHART_HINT, DIRECTION_CHART_HINT, wants_chart @@ -24,7 +24,7 @@ from ..series import WEATHER, derives_range, field_of, find, find_name from ..panels import panel_for from ..specs import Bars, Chart, Compass, Line, Panel, period_text, stack -from .api import CYCLE_SECONDS, RETENTION, daily_run +from .api import CYCLES, daily_run, kept_from from .direction import SPEED_STEPS, rose as direction_rose, summarise as summarise_direction from .extremes import Ext, better, collect, daily_readings, describe_time, fold, high_of, low_of, series_extremes from .fetch import Fetcher, spans @@ -77,7 +77,6 @@ class HistoryQuery: def __init__(self, fetcher: Fetcher, args: dict, turn): self.f, self.tz, self.args, self.turn = fetcher, fetcher.tz, args, turn self.now = now_local(self.tz) - self.now_utc = datetime.now(UTC) self.store: dict = {} # "group.field" -> {"unit", "pts"}, filled by _fetch_period self.start = self.end = self.span = None # the period, set by run() self.held = False # the whole period is in the cache at 30 minutes, and fits the row budget @@ -103,10 +102,10 @@ async def _run(self) -> str: end = parse_local(self.args.get("end_date") or self.now, self.tz) except ValueError as e: return f"Error: bad date ({e}). Use 'YYYY-MM-DD HH:MM:SS'." - self.start = max(start, datetime.combine(today - timedelta(days=RETENTION["1day"] - 2), time())) + self.start = max(start, datetime.combine(kept_from("1day", today), time())) self.end = min(end, self.now) if self.start >= self.end: - return "Error: start_date must be before end_date and within the last 4 years." + return f"Error: start_date must be before end_date and within the last {CYCLES['1day'].kept // 365} years." self.span = self.end - self.start self.held = await self._held_locally() self.detailed = self.held or self.span <= timedelta(days=DETAILED_DAYS) @@ -170,10 +169,9 @@ async def _chunks(self, cycle: str, t: datetime, until: datetime): async def _fetch_period(self): """Coarse pass: the best data for the whole period at the fewest requests.""" f, start, end, today = self.f, self.start, self.end, self.now.date() - age_days = (today - start.date()).days - thirty_floor = datetime.combine(today - timedelta(days=RETENTION["30min"] - 2), time()) + thirty_floor = datetime.combine(kept_from("30min", today), time()) recent = datetime.combine(today - timedelta(days=6), time()) - if self.span <= timedelta(days=1) and (age_days < RETENTION["5min"] - 1 or await f.covered( + if self.span <= timedelta(days=1) and (start.date() >= kept_from("5min", today) or await f.covered( "5min", f.epoch(start), f.epoch(end))): collect(self.store, await f.get("5min", start, end), "5min") elif self.span <= timedelta(days=FINE_DAYS) and end > thirty_floor: @@ -272,9 +270,9 @@ async def _refine(self) -> int: used = {"all": 0, "month": 0} finer_of: dict = {} # slots on the same bucket share an answer; a new pass looks again (data was fetched) for sid, ext in slots.items(): - w_end = ext.ts + CYCLE_SECONDS[ext.cycle] - 1 + w_end = ext.ts + CYCLES[ext.cycle].seconds - 1 if (ext.ts, ext.cycle) not in finer_of: - finer_of[ext.ts, ext.cycle] = await f.finer_cycle(ext.ts, ext.cycle, w_end, self.now_utc) + finer_of[ext.ts, ext.cycle] = await f.finer_cycle(ext.ts, ext.cycle, w_end, self.now.date()) finer = finer_of[ext.ts, ext.cycle] if not finer: continue @@ -487,7 +485,7 @@ def _series_readings(self, k: str) -> list: low, high = (r["low"][0] if "low" in r else None), (r["high"][0] if "high" in r else None) if band: low, high = low_of(r), max(high_of(gust[t]) if t in gust and "value" in gust[t] else high_of(r), v) - out.append((t, v, low, high, CYCLE_SECONDS[r["cycle"]])) + out.append((t, v, low, high, CYCLES[r["cycle"]].seconds)) return out def _series_entry(self, k: str, label: str | None = None) -> tuple[Line, str] | None: diff --git a/lib/ecowitt/source.py b/lib/ecowitt/source.py index 59066a8..bd83e77 100644 --- a/lib/ecowitt/source.py +++ b/lib/ecowitt/source.py @@ -15,7 +15,7 @@ from ..timeutil import local_epoch, naive_local, now_local, when from ..tools import Tool, Turn from ..warm import FAST_REFRESH_SECONDS, Warmer -from .api import EcowittAPI, GROUPS, UNITS +from .api import FINE_CYCLES, EcowittAPI, GROUPS, UNITS from .calendar import PublicHolidays from .link import DESCRIPTION as LINK_DESCRIPTION, PARAMETERS as LINK_PARAMETERS, link_tool from .days import DESCRIPTION as DAYS_DESCRIPTION, PARAMETERS as DAYS_PARAMETERS, days_tool @@ -28,7 +28,6 @@ log = logging.getLogger(__name__) DEFAULT_GROUPS = "outdoor,indoor" -FAST_CYCLES = ("5min", "30min") # kept warm by refetching every cycle; the 4-hour and daily tails only when their memory copy is stale def parse_groups(value, default: str = DEFAULT_GROUPS) -> list[str]: @@ -258,7 +257,7 @@ async def warm(self, fresh: bool = True) -> str: async def one(cycle: str): for start, end in spans(cycle, windows[cycle], now): # each piece fits Ecowitt's per-request limit - await fetchers[cycle].get(cycle, start, end, refresh=fresh and cycle in FAST_CYCLES, load=False) + await fetchers[cycle].get(cycle, start, end, refresh=fresh and cycle in FINE_CYCLES, load=False) await asyncio.gather(*(one(cycle) for cycle in windows)) return f"Ecowitt {sum(f.calls for f in fetchers.values())} req" diff --git a/lib/ecowitt/store.py b/lib/ecowitt/store.py index 3b5e6be..8eb2e1e 100644 --- a/lib/ecowitt/store.py +++ b/lib/ecowitt/store.py @@ -19,14 +19,11 @@ from bisect import bisect_left from ..timeutil import SLOT -from .api import UNIT_FIXES +from .api import CYCLES, UNIT_FIXES log = logging.getLogger(__name__) SCHEMA_VERSION = "1" -BUCKET_SECONDS = {"5min": 300, "30min": 1800, "4hour": 14400, "1day": 86400} -# How long after a bucket ends before its data is treated as final -SETTLE_SECONDS = {"5min": 3600, "30min": 7200, "4hour": 8 * 3600, "1day": 86400} # Missing readings older than this are accepted as genuinely missing (station offline, # before it existed). Newer gaps may just be Ecowitt running late, so they're re-asked. GAP_FINAL_SECONDS = 2 * 86400 @@ -40,7 +37,7 @@ def horizon(cycle: str, now: float | None = None) -> int: """Latest bucket start whose data is final.""" now = time.time() if now is None else now - return int(now - SETTLE_SECONDS[cycle] - BUCKET_SECONDS[cycle]) + return int(now - CYCLES[cycle].settle - CYCLES[cycle].seconds) def subtract(span: Interval, covered: list[Interval]) -> list[Interval]: @@ -212,7 +209,7 @@ def store(self, mac: str, cycle: str, groups: list[str], data: dict, start: int, if old_enough: covered.append((grp, limit)) elif reached is not None: - covered.append((grp, min(limit, reached + BUCKET_SECONDS[cycle] - 1))) + covered.append((grp, min(limit, reached + CYCLES[cycle].seconds - 1))) with self._lock, self.db: # one transaction: an error part-way rolls it back (no half-written response is kept) grown = set() # groups that now carry a reading the cache has never had (Ecowitt added a metric) for _, _, grp, field, _ in units: diff --git a/scripts/cache_status.py b/scripts/cache_status.py index 453fd36..9f51d30 100644 --- a/scripts/cache_status.py +++ b/scripts/cache_status.py @@ -15,7 +15,7 @@ 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.api import CYCLES, GROUPS, kept_from # noqa: E402 from lib.ecowitt.store import horizon, subtract # noqa: E402 @@ -30,8 +30,8 @@ def ecowitt(path: Path): 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"): - start = max(now - (RETENTION[cycle] - 2) * 86400, (first or now) - 86400) + for cycle in CYCLES: + start = max(datetime.combine(kept_from(cycle, date.today()), datetime.min.time()).timestamp(), (first or now) - 86400) end = horizon(cycle) for group in GROUPS: cov = db.execute("SELECT start, end FROM coverage WHERE mac=? AND cycle=? AND grp=?", diff --git a/tests/test_store.py b/tests/test_store.py index 05442f9..773b7bf 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -127,3 +127,13 @@ def test_a_write_that_fails_part_way_leaves_nothing_behind(tmp_path, monkeypatch monkeypatch.setattr(store, "merge", real_merge) cache.store("M", "5min", ["outdoor"], data(old + 3600), old + 3600, old + 3600) # a later save commits only its own assert cache.count("M", "5min", "outdoor", old, old + 300) == 0 and cache.count("M", "5min", "outdoor", old + 3600, old + 3600) == 1 + + +def test_each_ecowitt_resolution_is_kept_asked_for_and_settled_as_its_row_says(): + from datetime import date, timedelta + from lib.ecowitt.api import CYCLES, kept_from + today, now = date(2026, 10, 3), 1_790_000_000 + # Ecowitt keeps 5-minute readings 90 days, 30-minute 365, 4-hour 730, daily 1460; the bot asks two days clear of each edge + assert [(today - kept_from(c, today)).days for c in CYCLES] == [88, 363, 728, 1458] + assert [now - store.horizon(c, now) for c in CYCLES] == [300 + 3600, 1800 + 7200, 14400 + 8 * 3600, 86400 + 86400] # final after + assert [c.span for c in CYCLES.values()] == [timedelta(days=d) for d in (1, 7, 28, 365)] # per request From 668fe9a78f9637d6be3996e8cbe0e0c3d4aea337 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 17:56:22 +0000 Subject: [PATCH 32/65] The Ecowitt fetcher splits its own requests; one read tells what the cache lacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A range longer than one Ecowitt request allows had to be cut up by every caller (the query's _chunks, used four times, its refinement of each extreme, the keep-warm refresh, a coverage check). The fetcher now cuts each uncached gap to the request limit itself, so a caller asks for the range it wants. Cutting the gaps rather than the range asked for never sends more requests: a gap is cut only where it must be. - HistoryCache.uncovered(mac, groups, windows) answers, for any number of (resolution, start, end) windows and groups, what isn't held, from one read of the coverage. It replaces coverage_of in the fetcher, the archive's 28 queries (one per resolution and group) and the days tool's read per group. - Checked on the fake station: Ecowitt request counts are identical at every stage (start, two warm-ups, six history questions from 1 to 400 days, two archive runs, three questions after them), and so is the chart dump (48 files). lib: +47 โˆ’48 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/ecowitt/archive.py | 12 +++++------- lib/ecowitt/days.py | 12 +++++------- lib/ecowitt/fetch.py | 26 ++++++++++++++------------ lib/ecowitt/query.py | 18 ++++++++---------- lib/ecowitt/source.py | 8 +++----- lib/ecowitt/store.py | 19 ++++++++++++------- tests/test_ecowitt.py | 4 ++-- tests/test_store.py | 12 +++++++----- 8 files changed, 56 insertions(+), 55 deletions(-) diff --git a/lib/ecowitt/archive.py b/lib/ecowitt/archive.py index b2eabe9..4b0a83b 100644 --- a/lib/ecowitt/archive.py +++ b/lib/ecowitt/archive.py @@ -16,7 +16,7 @@ from ..timeutil import naive_local, now_local, seconds_until from .api import CYCLES, MIN_GAP_SECONDS, kept_from from .fetch import spans -from .store import horizon, subtract +from .store import horizon log = logging.getLogger(__name__) @@ -68,12 +68,10 @@ def settled(cycle: str) -> datetime: async def run_once(self) -> tuple[int, int]: """Cache whatever is missing. Returns (ranges fetched, ranges failed). Each range is saved as soon as it arrives, so an interrupted run just carries on from there next time.""" - # What is cached, read once (a query per resolution and group) rather than once per range - cover = await asyncio.to_thread(lambda: {(c, g): self.station.cache.coverage(self.station.mac, c, g) - for c in ("5min", *COARSE_CYCLES) for g in self.groups}) - epoch = self.station.fetcher(self.groups).epoch - todo = [(cycle, start, end) for cycle, start, end in self._work() - if any(subtract((epoch(start), epoch(end)), cover[cycle, g]) for g in self.groups)] + work, epoch = list(self._work()), self.station.fetcher(self.groups).epoch + missing = await asyncio.to_thread(self.station.cache.uncovered, self.station.mac, self.groups, # one read of what is held + [(cycle, epoch(start), epoch(end)) for cycle, start, end in work]) + todo = [w for w, gaps in zip(work, missing) if any(gaps.values())] 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) diff --git a/lib/ecowitt/days.py b/lib/ecowitt/days.py index 14a4d84..da06eee 100644 --- a/lib/ecowitt/days.py +++ b/lib/ecowitt/days.py @@ -21,7 +21,7 @@ from ..timeutil import day_bounds, day_text, local_date, now_local from .api import FINE_CYCLES, daily_run from .calendar import PublicHolidays -from .store import HistoryCache, subtract +from .store import HistoryCache log = logging.getLogger(__name__) @@ -116,13 +116,11 @@ def find_days(cache: HistoryCache, mac: str, tz: tzinfo, args: dict, now: dateti # which cycle each day is answered from: exact sub-daily data where every needed group has it days = [first + timedelta(days=k) for k in range((last - first).days + 1)] + windows = [(cycle, d) for cycle in FINE_CYCLES for d in days] source: dict[date, str] = {} - for cycle in FINE_CYCLES: - cover = {g: cache.coverage(mac, cycle, g) for g in groups} - for d in days: - lo, hi = day_bounds(d, tz, True) - if d not in source and all(not subtract((lo, hi), cover[g]) for g in groups): - source[d] = cycle + for (cycle, d), gaps in zip(windows, cache.uncovered(mac, groups, [(cycle, *day_bounds(d, tz, True)) for cycle, d in windows])): + if d not in source and not any(gaps.values()): + source[d] = cycle rest = {d for d in days if d not in source} values: dict[str, dict[date, float]] = {n: {} for n in shown} diff --git a/lib/ecowitt/fetch.py b/lib/ecowitt/fetch.py index 4310e20..ce8080c 100644 --- a/lib/ecowitt/fetch.py +++ b/lib/ecowitt/fetch.py @@ -9,7 +9,7 @@ from ..timeutil import local_epoch, naive_local from .api import CYCLES, EcowittError, kept_from -from .store import HistoryCache, HotStore, merge as merge_intervals, subtract +from .store import HistoryCache, HotStore, merge as merge_intervals log = logging.getLogger(__name__) @@ -77,7 +77,8 @@ def local(self, epoch: int) -> datetime: @timed async def get(self, cycle: str, start: datetime, end: datetime, refresh: bool = False, load: bool = True) -> dict: - """Readings for [start, end] (local time). refresh=True ignores the in-memory copy (used by + """Readings for [start, end] (local time), however long: what Ecowitt is asked for is split to its per-request limit. + refresh=True ignores the in-memory copy (used by the keep-warm refresh). load=False only makes sure the range is cached and in memory, and skips building the result nobody will read (the refresh and the archive). When the cache and memory hold it all (the usual case) it is read at once, even while a refresh is fetching; otherwise @@ -106,14 +107,16 @@ async def _read(self, cycle: str, s: int, e: int, load: bool, fetch: bool, refre continue if not fetch: return None - data = await self._fetch(cycle, self.local(gap_start), self.local(gap_end), need) - if data is None: - continue # failed: not recorded as fetched, so it's retried next time - await asyncio.to_thread(self.cache.store, self.mac, cycle, need, data, gap_start, gap_end) - for g in need: - self.hot.put(self.mac, cycle, g, gap_start, gap_end, data.get(g) or {}) - if load: - merge_data(fresh, data) + for a, b in spans(cycle, self.local(gap_start), self.local(gap_end)): # one request each, within Ecowitt's limit + data = await self._fetch(cycle, a, b, need) + if data is None: + continue # failed: not recorded as fetched, so it's retried next time + piece = (self.epoch(a), self.epoch(b)) + await asyncio.to_thread(self.cache.store, self.mac, cycle, need, data, *piece) + for g in need: + self.hot.put(self.mac, cycle, g, *piece, data.get(g) or {}) + if load: + merge_data(fresh, data) self.from_cache += not gaps self.from_memory += from_memory if not load: @@ -144,8 +147,7 @@ async def read(self, fn, *args): async def _missing(self, cycle: str, windows: list[tuple[int, int]]) -> list[dict[str, list[tuple[int, int]]]]: """For each window (epoch seconds), the parts of it the cache lacks, by group: one read of the coverage in one thread hop (the hop costs more than the query, and far more while a chart is being drawn).""" - held = await asyncio.to_thread(self.cache.coverage_of, self.mac, cycle, self.groups) if self.groups else {} - return [{g: subtract(w, held[g]) for g in self.groups} for w in windows] + return await asyncio.to_thread(self.cache.uncovered, self.mac, self.groups, [(cycle, s, e) for s, e in windows]) async def covered(self, cycle: str, start: int, end: int) -> bool: """True if the cache holds every group for [start, end] (epoch seconds).""" diff --git a/lib/ecowitt/query.py b/lib/ecowitt/query.py index 961e422..060102f 100644 --- a/lib/ecowitt/query.py +++ b/lib/ecowitt/query.py @@ -160,11 +160,10 @@ async def _held_locally(self) -> bool: f = self.f if (self.span.days + 1) * 48 * len(f.groups) * FIELDS_PER_GROUP > MAX_ROWS: return False - return all(await f.covered_each("30min", [(f.epoch(t), f.epoch(e)) for t, e in spans("30min", self.start, self.end - timedelta(days=1))])) + return await f.covered("30min", f.epoch(self.start), f.epoch(self.end - timedelta(days=1))) - async def _chunks(self, cycle: str, t: datetime, until: datetime): - for a, b in spans(cycle, t, until): - collect(self.store, await self.f.get(cycle, a, b), cycle) + async def _read(self, cycle: str, t: datetime, until: datetime): + collect(self.store, await self.f.get(cycle, t, until), cycle) async def _fetch_period(self): """Coarse pass: the best data for the whole period at the fewest requests.""" @@ -181,14 +180,14 @@ async def _fetch_period(self): five = self.span <= timedelta(days=INTRADAY_DAYS) and await f.covered("5min", f.epoch(a), f.epoch(b)) collect(self.store, await f.get("5min" if five else "30min", a, b), "5min" if five else "30min") if start < thirty_floor: - await self._chunks("1day", start, min(end, thirty_floor - timedelta(seconds=1))) + await self._read("1day", start, min(end, thirty_floor - timedelta(seconds=1))) elif self.held: - await self._chunks("30min", start, end) # held in the cache: exact local days, true lows and highs, no requests + await self._read("30min", start, end) # held in the cache: exact local days, true lows and highs, no requests else: # Longer: Ecowitt's daily records (each has its own low and high, and it is a request or two, cached), # plus the newest days at 30 minutes so the line reaches today. Exact times come from refining the extremes. - await self._chunks("30min", max(start, recent), end) - await self._chunks("1day", start, min(end, recent - timedelta(seconds=1))) + await self._read("30min", max(start, recent), end) + await self._read("1day", start, min(end, recent - timedelta(seconds=1))) async def _summarise_direction(self): """Wind direction leaves the low/high pipeline: it is counted by compass point instead. Recent @@ -288,8 +287,7 @@ async def _refine(self) -> int: refined += len(windows) for (cycle, w_start, w_end), targets in windows.items(): fine: dict = {} - for a, b in spans(cycle, f.local(w_start), min(f.local(w_end), self.now)): # a UTC day is 25 local hours across a DST change - collect(fine, await f.get(cycle, a, b), cycle) + collect(fine, await f.get(cycle, f.local(w_start), min(f.local(w_end), self.now)), cycle) for sid in targets: key, want = sid[1], sid[-1] found = series_extremes(fine[key], w_start, w_end).get(want) if key in fine else None diff --git a/lib/ecowitt/source.py b/lib/ecowitt/source.py index bd83e77..3d03408 100644 --- a/lib/ecowitt/source.py +++ b/lib/ecowitt/source.py @@ -20,7 +20,7 @@ from .link import DESCRIPTION as LINK_DESCRIPTION, PARAMETERS as LINK_PARAMETERS, link_tool from .days import DESCRIPTION as DAYS_DESCRIPTION, PARAMETERS as DAYS_PARAMETERS, days_tool from .glance import glance -from .fetch import Fetcher, spans +from .fetch import Fetcher from ..series import WEATHER, find from .query import HistoryQuery, stack_names from .store import HistoryCache, HotStore @@ -255,10 +255,8 @@ async def warm(self, fresh: bool = True) -> str: "4hour": today - timedelta(days=3), "1day": today - timedelta(days=3)} fetchers = {cycle: self.fetcher(self.groups) for cycle in windows} - async def one(cycle: str): - for start, end in spans(cycle, windows[cycle], now): # each piece fits Ecowitt's per-request limit - await fetchers[cycle].get(cycle, start, end, refresh=fresh and cycle in FINE_CYCLES, load=False) - await asyncio.gather(*(one(cycle) for cycle in windows)) + await asyncio.gather(*(fetchers[cycle].get(cycle, start, now, refresh=fresh and cycle in FINE_CYCLES, load=False) + for cycle, start in windows.items())) return f"Ecowitt {sum(f.calls for f in fetchers.values())} req" def wants(self, text: str) -> bool: diff --git a/lib/ecowitt/store.py b/lib/ecowitt/store.py index 8eb2e1e..242bf77 100644 --- a/lib/ecowitt/store.py +++ b/lib/ecowitt/store.py @@ -130,13 +130,18 @@ 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)) - def coverage_of(self, mac: str, cycle: str, groups: list[str]) -> dict[str, list[Interval]]: - """coverage() of each of these groups, in one query.""" - held: dict[str, list[Interval]] = {g: [] for g in groups} - for grp, start, end in self._query(f"SELECT grp, start, end FROM coverage WHERE mac=? AND cycle=? AND grp IN ({','.join('?' * len(held))})", - mac, cycle, *held): - held[grp].append((start, end)) - return {g: merge(spans) for g, spans in held.items()} + def uncovered(self, mac: str, groups: list[str], windows: list[tuple[str, int, int]]) -> list[dict[str, list[Interval]]]: + """For each (cycle, start, end) window, the parts of it each group's readings are not held for: one read of the coverage, + however many windows, resolutions and groups.""" + cycles = sorted({cycle for cycle, _, _ in windows}) + held: dict[tuple[str, str], list[Interval]] = {(c, g): [] for c in cycles for g in groups} + if held: + for cycle, grp, start, end in self._query( + f"SELECT cycle, grp, start, end FROM coverage WHERE mac=? AND cycle IN ({','.join('?' * len(cycles))}) " + f"AND grp IN ({','.join('?' * len(groups))})", mac, *cycles, *groups): + held[cycle, grp].append((start, end)) + held = {key: merge(spans) for key, spans in held.items()} + return [{g: subtract((start, end), held[cycle, g]) for g in groups} for cycle, start, end in windows] def missing(self, mac: str, cycle: str, grp: str, start: int, end: int) -> list[Interval]: return subtract((start, end), self.coverage(mac, cycle, grp)) diff --git a/tests/test_ecowitt.py b/tests/test_ecowitt.py index ceb3248..767256d 100644 --- a/tests/test_ecowitt.py +++ b/tests/test_ecowitt.py @@ -793,12 +793,12 @@ async def chart(): async def test_a_long_period_is_read_from_the_cache_at_30_minutes_when_it_is_held_and_fits_the_row_budget(tmp_path, archived_cache, monkeypatch): from lib.ecowitt import query as history seen = [] - real = history.HistoryQuery._chunks + real = history.HistoryQuery._read async def spy(self, cycle, t, until): seen.append(cycle) await real(self, cycle, t, until) - monkeypatch.setattr(history.HistoryQuery, "_chunks", spy) + monkeypatch.setattr(history.HistoryQuery, "_read", spy) eco, fake = await archived_station(tmp_path, archived_cache) today = datetime.now(eco.tz).date() args = {"groups": "outdoor", "start_date": f"{today - timedelta(days=200)} 00:00:00", "end_date": f"{today - timedelta(days=1)} 00:00:00"} diff --git a/tests/test_store.py b/tests/test_store.py index 773b7bf..bfc2aca 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -27,16 +27,18 @@ def test_only_settled_readings_are_kept(tmp_path): assert cache.missing("M", "5min", "outdoor", fresh, now) # the unsettled tail is still to fetch -def test_the_coverage_of_several_groups_is_each_groups_own_read_at_once(tmp_path): +def test_what_is_not_held_is_read_for_many_windows_resolutions_and_groups_at_once(tmp_path): cache, now = make(tmp_path), int(time.time()) old = now - 9 * 86400 both = {"outdoor": data(old)["outdoor"], "wind": {"wind_speed": {"unit": "km/h", "list": {str(old): "3"}}}} cache.store("M", "30min", ["outdoor", "wind"], both, old, old + 86400) cache.store("M", "30min", ["outdoor"], data(old + 3 * 86400), old + 3 * 86400, old + 4 * 86400) - cache.store("M", "5min", ["wind"], both, old, old + 3600) # another cycle: not counted - held = cache.coverage_of("M", "30min", ["outdoor", "wind", "rainfall"]) - assert held == {g: cache.coverage("M", "30min", g) for g in ("outdoor", "wind", "rainfall")} - assert len(held["outdoor"]) == 2 and len(held["wind"]) == 1 and held["rainfall"] == [] + cache.store("M", "5min", ["wind"], both, old, old + 3600) + groups, windows = ["outdoor", "wind", "rainfall"], [("30min", old, old + 4 * 86400), ("5min", old, old + 3600)] + thirty, five = cache.uncovered("M", groups, windows) + assert thirty == {g: cache.missing("M", "30min", g, old, old + 4 * 86400) for g in groups} # as each group's own read + assert thirty["outdoor"] == [(old + 86401, old + 3 * 86400 - 1)] and thirty["rainfall"] == [(old, old + 4 * 86400)] + assert five["wind"] == [] and five["outdoor"] == [(old, old + 3600)] # each window its own resolution def test_empty_old_ranges_are_remembered(tmp_path): From b66c6c27fff3e6932d498e0f40138f5b04a33ade Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 18:14:40 +0000 Subject: [PATCH 33/65] Ecowitt's day fields, link drivers and live groups from series MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - days.FIELDS: each reading's group, field and unit, and whether a day's lowest is worth offering, now come from series.WEATHER (its `low` flag) instead of a second list of the same readings with its own no-low list. The rain rate and the indoor sensor's two readings are written out, as they aren't series readings. The table is the same, key for key and in order, so the tool's enum is unchanged. - The days tool's description told the model wind_speed has a _min. It has none: wind's low is 0 every calm hour, as series says. The description now lists wind_speed with the _max-only readings. A new test checks that the description names every field and which ones have a lowest. - link.DRIVERS keeps only each driver's threshold; its group, field and unit are the series reading's. A test pins each driver's series and threshold as the tool states them to the model. - series.READING_GROUPS (the groups the readings are in, plus the indoor sensor's) gives the groups for "weather now" and the live reading: the same six, in the same order. ecowitt/source.py no longer imports intent to get them. - query.DERIVED stays as written. Deriving it from series.SPECIFIC would tie "worked out from other readings" to "outranks temperature in a question", which only happen to be the same three readings today. Breaking a row of each new table now fails a test (before, four of five such breaks passed). The chart specs are identical (dump_specs, 48 files). lib: +21 โˆ’20 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/ecowitt/days.py | 23 +++++++++++++---------- lib/ecowitt/link.py | 8 +++----- lib/ecowitt/source.py | 5 ++--- lib/intent.py | 4 ++-- lib/series.py | 1 + tests/test_days.py | 10 ++++++++++ tests/test_ecowitt.py | 11 ++++++++--- 7 files changed, 39 insertions(+), 23 deletions(-) diff --git a/lib/ecowitt/days.py b/lib/ecowitt/days.py index da06eee..fa378c0 100644 --- a/lib/ecowitt/days.py +++ b/lib/ecowitt/days.py @@ -18,6 +18,7 @@ from statistics import fmean from ..daytable import analyse, grouped_as_asked, parameters, validate +from ..series import WEATHER from ..timeutil import day_bounds, day_text, local_date, now_local from .api import FINE_CYCLES, daily_run from .calendar import PublicHolidays @@ -32,15 +33,17 @@ "rain": ("rainfall", "daily", "high", "mm"), # the day's total: the rain counter's highest reading "wind_gust": ("wind", "wind_gust", "high", "km/h"), } -# every other reading the station has, a day's highest (_max) and lowest (_min): a day's peak UV, its driest air, its lowest pressure ... -for _name, _group, _field, _unit in ( - ("humidity", "outdoor", "humidity", "%"), ("pressure", "pressure", "relative", "hPa"), ("wind_speed", "wind", "wind_speed", "km/h"), - ("dew_point", "outdoor", "dew_point", "ยฐC"), ("feels_like", "outdoor", "feels_like", "ยฐC"), ("vpd", "outdoor", "vpd", "kPa"), - ("solar", "solar_and_uvi", "solar", "W/mยฒ"), ("uv", "solar_and_uvi", "uvi", ""), ("rain_rate", "rainfall", "rain_rate", "mm/h"), - ("indoor_temp", "indoor", "temperature", "ยฐC"), ("indoor_humidity", "indoor", "humidity", "%")): +# Every other reading (series), then three that aren't series readings: a day's highest (_max), its mean (_avg: "average humidity +# per month") and, where its low says something (not 0 every night or calm hour), its lowest (_min): a day's peak UV, its driest +# air, its lowest pressure ... +_OTHERS = [("wind_speed" if name == "wind" else name, r.group, r.field, r.unit, r.low) + for name, r in WEATHER.items() if name not in ("temperature", "rain")] +_OTHERS += [("rain_rate", "rainfall", "rain_rate", "mm/h", False), + ("indoor_temp", "indoor", "temperature", "ยฐC", True), ("indoor_humidity", "indoor", "humidity", "%", True)] +for _name, _group, _field, _unit, _low in _OTHERS: FIELDS[f"{_name}_max"] = (_group, _field, "high", _unit) - FIELDS[f"{_name}_avg"] = (_group, _field, "mean", _unit) # a day's mean: "average humidity per month" - if _name not in ("solar", "uv", "rain_rate", "wind_speed"): # these have no interesting low: it is 0 every night or calm hour + FIELDS[f"{_name}_avg"] = (_group, _field, "mean", _unit) + if _low: FIELDS[f"{_name}_min"] = (_group, _field, "low", _unit) FIELDS["temp_avg"] = ("outdoor", "temperature", "mean", "ยฐC") AVERAGED = {n for n in FIELDS if n.endswith(("_max", "_min")) and n not in ("temp_max", "temp_min")} # no range of their own at 30 minutes or daily @@ -55,8 +58,8 @@ "compare readings on the same day or count days (\"the hottest day it also rained\", \"how many days over " "35ยฐC\", \"how many days was UV 9 or more\", \"the wettest day\", \"the windiest cold day\"). Fields: temp_max / temp_min (outdoor ยฐC), " "rain (mm total for the day; a rainy day is 1 mm or more, above 0 is only a trace), wind_gust (km/h, highest), and a day's " - "highest (_max) or lowest (_min) of every other reading: humidity, pressure, wind_speed, dew_point, feels_like, vpd, " - "indoor_temp, indoor_humidity (_max and _min); solar, uv, rain_rate (_max only), e.g. uv_max >= 9; and _avg, a day's mean (temp_avg, " + "highest (_max) or lowest (_min) of every other reading: humidity, pressure, dew_point, feels_like, vpd, " + "indoor_temp, indoor_humidity (_max and _min); wind_speed, solar, uv, rain_rate (_max only), e.g. uv_max >= 9; and _avg, a day's mean (temp_avg, " "humidity_avg, pressure_avg ...). For \"how many\" use count_only; group_by month or year breaks it down and draws a bar chart. " "For a total, average, highest or lowest per month or year use stat and of (\"rain per month\": stat sum, of rain; \"hottest day each " "year\": stat max, of temp_max; \"average humidity per month\": stat avg, of humidity_avg). Returns the total of matching " diff --git a/lib/ecowitt/link.py b/lib/ecowitt/link.py index f0b1a5b..5238e5f 100644 --- a/lib/ecowitt/link.py +++ b/lib/ecowitt/link.py @@ -25,10 +25,7 @@ log = logging.getLogger(__name__) DEFAULT_DAYS = 90 -# driver -> (group, field, unit, the change over 3 hours that counts as falling or rising) -DRIVERS = {"pressure": ("pressure", "relative", "hPa", 1.0), - "humidity": ("outdoor", "humidity", "%", 5.0), - "wind": ("wind", "wind_speed", "km/h", 5.0)} +DRIVERS = {"pressure": 1.0, "humidity": 5.0, "wind": 5.0} # a reading (series) -> the change over 3 hours that counts as falling or rising PARAMETERS = { "type": "object", @@ -73,7 +70,8 @@ def link(cache: HistoryCache, mac: str, tz: tzinfo, args: dict, now: datetime) - name = args.get("driver") or "pressure" if name not in DRIVERS: return {"error": f"driver must be one of {', '.join(DRIVERS)}"}, None, None, None - group, field, unit, threshold = DRIVERS[name] + reading, threshold = WEATHER[name], DRIVERS[name] + group, field, unit = reading.group, reading.field, reading.unit lo, hi = day_bounds(first, tz)[0], day_bounds(last, tz, last_second=True)[1] driver, lows, highs = cache.slots(mac, "30min", group, [field, field + "_low", field + "_high"], lo, hi) (daily,) = cache.slots(mac, "30min", "rainfall", ["daily"], lo, hi) diff --git a/lib/ecowitt/source.py b/lib/ecowitt/source.py index 3d03408..075cc11 100644 --- a/lib/ecowitt/source.py +++ b/lib/ecowitt/source.py @@ -11,7 +11,6 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from ..config import Config -from ..intent import NOW_GROUPS from ..timeutil import local_epoch, naive_local, now_local, when from ..tools import Tool, Turn from ..warm import FAST_REFRESH_SECONDS, Warmer @@ -21,7 +20,7 @@ from .days import DESCRIPTION as DAYS_DESCRIPTION, PARAMETERS as DAYS_PARAMETERS, days_tool from .glance import glance from .fetch import Fetcher -from ..series import WEATHER, find +from ..series import READING_GROUPS, WEATHER, find from .query import HistoryQuery, stack_names from .store import HistoryCache, HotStore @@ -89,7 +88,7 @@ def readings(data: dict) -> list[tuple[int, dict]]: REALTIME_DESCRIPTION = "Current readings from the owner's Ecowitt weather station." -LIVE_GROUPS = NOW_GROUPS.split(",") # what the once-a-minute live reading asks for: everything "weather now" and the report show +LIVE_GROUPS = list(READING_GROUPS) # what the once-a-minute live reading asks for: everything "weather now" and the report show LIVE_FRESH_SECONDS = 90 # a question is answered from that reading while it is this young diff --git a/lib/intent.py b/lib/intent.py index 166bae7..b35fb87 100644 --- a/lib/intent.py +++ b/lib/intent.py @@ -13,7 +13,7 @@ from dataclasses import dataclass, field from datetime import date, datetime, timedelta -from .series import AIR as AIR_TABLE, INDOOR_READINGS, PRESSURE_WORDS, SPECIFIC, TEMP_WORDS, WEATHER as WEATHER_READINGS +from .series import AIR as AIR_TABLE, INDOOR_READINGS, PRESSURE_WORDS, READING_GROUPS, SPECIFIC, TEMP_WORDS, WEATHER as WEATHER_READINGS log = logging.getLogger(__name__) @@ -135,7 +135,7 @@ def pollen_ask(text: str) -> bool: # "weather", "weather now", "current weather", "ecowitt": every reading the station has right now (the weather half of the report) -NOW_GROUPS = "outdoor,indoor,pressure,wind,rainfall,solar_and_uvi" +NOW_GROUPS = ",".join(READING_GROUPS) WEATHER_NOW = re.compile(_only(rf"(weather|{_ECOWITT}|conditions)(\s+(now|right now|currently|at the moment))?|(current|latest)\s+(weather|conditions)", r"((give me|show me|show|get|what's|whats)\s+)?(the\s+)?"), I) diff --git a/lib/series.py b/lib/series.py index 8bba04d..5f24274 100644 --- a/lib/series.py +++ b/lib/series.py @@ -36,6 +36,7 @@ class Reading(NamedTuple): } SPECIFIC = ("dew_point", "feels_like", "vpd") # kinds of temperature and humidity, named on their own: they outrank "temperature" in a question INDOOR_READINGS = ("temperature", "humidity", "dew_point", "feels_like") # what the indoor sensor measures as well +READING_GROUPS = tuple(dict.fromkeys(["outdoor", "indoor", *(r.group for r in WEATHER.values())])) # the groups they are in, the indoor sensor's too diff --git a/tests/test_days.py b/tests/test_days.py index bf859ac..22dc83b 100644 --- a/tests/test_days.py +++ b/tests/test_days.py @@ -367,6 +367,16 @@ def test_the_field_list_is_in_the_tool_schema_once_and_a_wrong_sort_or_of_field_ assert "sort_by must be one of" in validate({"sort_by": "nonsense"}, units) and "needs `of`" in validate({"stat": "sum", "of": "nonsense"}, units) +def test_the_description_names_every_field_and_which_have_a_lowest(): + """The model learns the fields from the description. A reading whose lowest says nothing (0 every night or calm hour) has no _min.""" + import re + from lib.ecowitt import days + both, max_only = (re.search(rf"(\w+(?:, \w+)*) \(_max {kind}\)", days.DESCRIPTION)[1].split(", ") for kind in ("and _min", "only")) + assert all(f"{n}_max" in days.FIELDS and f"{n}_min" in days.FIELDS for n in both) + assert all(f"{n}_max" in days.FIELDS and f"{n}_min" not in days.FIELDS for n in max_only) + assert {n.removesuffix("_max").removesuffix("_min").removesuffix("_avg") for n in days.FIELDS} == {"temp", "rain", "wind_gust", *both, *max_only} + + async def test_a_figure_per_month_with_no_matching_day_promises_no_chart(station): """Highest temperature per month on days over 99ยฐ: every month is None, so there is no bar to draw and the model must not be told a chart is attached.""" diff --git a/tests/test_ecowitt.py b/tests/test_ecowitt.py index 767256d..bb3b9bd 100644 --- a/tests/test_ecowitt.py +++ b/tests/test_ecowitt.py @@ -1064,13 +1064,18 @@ def test_the_pair_chart_renders_for_a_day_a_month_and_a_year(): assert spec.panels[0].bars.width in (3600, 6 * 3600, 86400) and render(spec, TZ)[:4] == b"\x89PNG" -async def test_weather_link_says_which_series_is_missing(tmp_path): +@pytest.mark.parametrize("driver, series, falling", [(None, "pressure (pressure.relative)", "down 1 hPa"), + ("humidity", "humidity (outdoor.humidity)", "down 5 %"), + ("wind", "wind (wind.wind_speed)", "down 5 km/h")]) +async def test_weather_link_says_which_series_is_missing(tmp_path, driver, series, falling): transport, fake = ecowitt_transport() eco = Ecowitt(config(tmp_path), transport=transport) # nothing cached at all await eco.start() today = datetime.now(eco.tz).date() - out = json.loads(await eco.tools[3].handler({"start_date": str(today - timedelta(days=9)), "end_date": str(today - timedelta(days=2))})) - assert "pressure (pressure.relative) or rainfall (rainfall.daily)" in out["note"] and "chart" not in out + out = json.loads(await eco.tools[3].handler({"start_date": str(today - timedelta(days=9)), "end_date": str(today - timedelta(days=2)), + **({"driver": driver} if driver else {})})) + assert f"{series} or rainfall (rainfall.daily)" in out["note"] and "chart" not in out + assert f"falling = {falling} or more" in out["how_to_read"] # the change that counts, in the reading's unit await eco.close() From 041b616098d06ea38a464e76bf62539a1f9f8702 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 18:19:34 +0000 Subject: [PATCH 34/65] One JSON form for tool results; readings() walks the data with series_in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tools.json_result is how a tool's result is written for the model: compact JSON, with ยฐ, ยฒ and emoji written as they are. Eight results were already written that way, each spelling out json.dumps(..., ensure_ascii=False, separators=(",", ":")). The air reading, the forecast, pollen and the compose tools' errors had spaces after "," and ":". All sixteen now use json_result: the content the model reads is the same, and those results are a few characters shorter. - ecowitt.source.readings() walked Ecowitt's data object with its own copy of fetch.series_in's loop; it now uses series_in. - glance stays an if-chain, though the plan had it as a table. Two of its ten rules (humidity at or below 30, rain above 0) aren't "from this value up" steps, so a table would need code beside it for them and would be no clearer. lib: +38 โˆ’43 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/airgradient/days.py | 4 ++-- lib/airgradient/source.py | 10 ++++------ lib/compose.py | 11 +++++------ lib/ecowitt/days.py | 4 ++-- lib/ecowitt/link.py | 4 ++-- lib/ecowitt/query.py | 4 ++-- lib/ecowitt/source.py | 24 +++++++++--------------- lib/forecast/source.py | 7 +++---- lib/pollen/source.py | 8 ++++---- lib/tools.py | 5 +++++ 10 files changed, 38 insertions(+), 43 deletions(-) diff --git a/lib/airgradient/days.py b/lib/airgradient/days.py index 9776b4e..d977f03 100644 --- a/lib/airgradient/days.py +++ b/lib/airgradient/days.py @@ -3,13 +3,13 @@ days particles passed 25 ยตg/mยณ. The counting, ranking, grouping and bar charts are shared with weather_days (lib/daytable.py).""" import asyncio -import json import logging from datetime import date, datetime, timedelta, tzinfo from statistics import fmean from ..daytable import analyse, grouped_as_asked, parameters, validate from ..timeutil import day_bounds, local_date, now_local +from ..tools import json_result from .metrics import ALL_METRICS, CHART_UNITS, without_spikes log = logging.getLogger(__name__) @@ -80,4 +80,4 @@ def find_air_days(store, tz: tzinfo, args: dict, now: datetime, turn=None) -> di async def air_days_tool(store, tz: tzinfo, args: dict, turn=None) -> str: result = await asyncio.to_thread(find_air_days, store, tz, grouped_as_asked(args, turn), now_local(tz), turn) - return json.dumps(result, ensure_ascii=False, separators=(",", ":")) + return json_result(result) diff --git a/lib/airgradient/source.py b/lib/airgradient/source.py index f82dda5..83a3d49 100644 --- a/lib/airgradient/source.py +++ b/lib/airgradient/source.py @@ -13,7 +13,6 @@ import asyncio import statistics -import json import logging import time from datetime import date, datetime, timedelta, UTC @@ -29,7 +28,7 @@ from ..panels import panel_for from ..specs import Chart, Line from ..timeutil import day_text, local_date, now_local, parse_local, to_local, when -from ..tools import Tool, Turn +from ..tools import Tool, Turn, json_result from ..warm import Warmer from .days import DESCRIPTION as DAYS_DESCRIPTION, PARAMETERS as DAYS_PARAMETERS, air_days_tool from . import metrics @@ -263,10 +262,9 @@ async def handle(self, args: dict, turn: Turn | None = None) -> str: try: chart = wants_chart(args, turn) if args.get("start_date") or args.get("end_date") or chart: - return json.dumps(await self.history(args.get("start_date"), args.get("end_date"), - chart=chart, metrics=args.get("metrics"), turn=turn), - ensure_ascii=False) - return json.dumps(await self.current(), ensure_ascii=False) + return json_result(await self.history(args.get("start_date"), args.get("end_date"), + chart=chart, metrics=args.get("metrics"), turn=turn)) + return json_result(await self.current()) except Exception as e: log.warning("AirGradient request failed: %s", e) return f"Error: couldn't read the air-quality sensor ({e})" diff --git a/lib/compose.py b/lib/compose.py index 465debe..78b7b12 100644 --- a/lib/compose.py +++ b/lib/compose.py @@ -6,7 +6,6 @@ used for the weather station (nothing is fetched); AirGradient fills any missing days first, as its own tool does.""" import asyncio -import json import logging from datetime import date, datetime, time, timedelta, tzinfo from statistics import fmean @@ -21,7 +20,7 @@ from .specs import Panel, Shares, stack from .rain import bar_layout, bar_of, rain_bars, rain_slots from .timeutil import SLOT, day_bounds, now_local, parse_period -from .tools import Tool, Turn +from .tools import Tool, Turn, json_result log = logging.getLogger(__name__) @@ -93,7 +92,7 @@ def _error(text: str) -> str: - return json.dumps({"error": text}, ensure_ascii=False) + return json_result({"error": text}) def rating_shares(values: dict[int, float], limits: tuple[float, float], tz: tzinfo, first: date, last: date, label: str) -> Shares: @@ -181,7 +180,7 @@ async def plot_chart(self, args: dict, turn: Turn | None = None) -> str: summary.append(facts) out = {"period": f"{first} to {last}", "panels": summary, **({"notes": notes} if notes else {})} self._add_chart(out, built, first, last, turn) - return json.dumps(out, ensure_ascii=False, separators=(",", ":")) + return json_result(out) def _add_chart(self, out: dict, panels: list[Panel], first: date, last: date, turn: Turn): """Send the panels as one chart (rain behind the first line) and tell the model what its reply is for.""" @@ -211,7 +210,7 @@ async def air_link(self, args: dict, turn: Turn | None = None) -> str: bars = rain_bars(rain, self.tz, first, last) if result and air_panel and bars.x and wants_chart(args, turn, datetime.combine(first, time()), datetime.combine(last, time())): self._add_chart(out, [air_panel, panel_for("rain", bars=bars)], first, last, turn) - return json.dumps(out, ensure_ascii=False, separators=(",", ":")) + return json_result(out) async def air_scan(self, args: dict, turn: Turn | None = None) -> str: turn = turn or Turn() @@ -254,7 +253,7 @@ async def air_scan(self, args: dict, turn: Turn | None = None) -> str: panels = [(await self._panel(n, "bars" if n == "rain" else "line", first, last, {}, data=air.get(n)))[0] for n in drawn] if all(panels): self._add_chart(out, panels, first, last, turn) - return json.dumps(out, ensure_ascii=False, separators=(",", ":")) + return json_result(out) async def _panel(self, name: str, style: str, first: date, last: date, notes: dict, data: tuple[dict, dict, dict] | None = None) -> tuple[Panel | None, dict]: diff --git a/lib/ecowitt/days.py b/lib/ecowitt/days.py index fa378c0..a9bcbc1 100644 --- a/lib/ecowitt/days.py +++ b/lib/ecowitt/days.py @@ -10,7 +10,6 @@ """ import asyncio -import json import logging import operator import re @@ -20,6 +19,7 @@ from ..daytable import analyse, grouped_as_asked, parameters, validate from ..series import WEATHER from ..timeutil import day_bounds, day_text, local_date, now_local +from ..tools import json_result from .api import FINE_CYCLES, daily_run from .calendar import PublicHolidays from .store import HistoryCache @@ -199,4 +199,4 @@ async def days_tool(cache: HistoryCache, mac: str, tz: tzinfo, args: dict, turn= args = grouped_as_asked(args, turn) now = now_local(tz) result = await asyncio.to_thread(find_days, cache, mac, tz, args, now, turn) - return json.dumps(result, ensure_ascii=False, separators=(",", ":")) + return json_result(result) diff --git a/lib/ecowitt/link.py b/lib/ecowitt/link.py index 5238e5f..b9a7c95 100644 --- a/lib/ecowitt/link.py +++ b/lib/ecowitt/link.py @@ -8,7 +8,6 @@ before and the rain after; and the biggest rain spells with the change that preceded each.""" import asyncio -import json import logging from datetime import date, datetime, time, tzinfo @@ -20,6 +19,7 @@ from ..series import WEATHER from ..specs import Chart, Line, stack from ..timeutil import day_bounds, day_text, local_date, now_local, parse_period +from ..tools import json_result from .store import HistoryCache log = logging.getLogger(__name__) @@ -103,4 +103,4 @@ async def link_tool(cache: HistoryCache, mac: str, tz: tzinfo, args: dict, turn) if spec and wants_chart(args, turn, datetime.combine(first, time()), datetime.combine(last, time())): turn.charts.append(spec) out["chart"] = LINK_CHART_HINT - return json.dumps(out, ensure_ascii=False, separators=(",", ":")) + return json_result(out) diff --git a/lib/ecowitt/query.py b/lib/ecowitt/query.py index 060102f..aa3c5dd 100644 --- a/lib/ecowitt/query.py +++ b/lib/ecowitt/query.py @@ -11,7 +11,6 @@ """ import asyncio -import json import logging from datetime import date, datetime, time, timedelta from statistics import fmean @@ -21,6 +20,7 @@ from ..daytable import grouping_in from ..rain import rain_bars, rain_slots from ..timeutil import daily_summary, day_bounds, day_text, local_date, now_local, parse_local, SLOT +from ..tools import json_result from ..series import WEATHER, derives_range, field_of, find, find_name from ..panels import panel_for from ..specs import Bars, Chart, Compass, Line, Panel, period_text, stack @@ -329,7 +329,7 @@ def _answer(self, refined: int) -> str: if errors := f.errors + (self.wind.errors if self.wind else []): out["missing"] = errors[:10] out["warning"] = "Some data could not be fetched; the answer may be incomplete. Say so." - return json.dumps(out, ensure_ascii=False, separators=(",", ":")) + return json_result(out) def _in_focus(self, key: str) -> bool: """Is this series wanted by the question? Outdoor and indoor come with both temperature and humidity: when the words name diff --git a/lib/ecowitt/source.py b/lib/ecowitt/source.py index 075cc11..f9b05ed 100644 --- a/lib/ecowitt/source.py +++ b/lib/ecowitt/source.py @@ -3,7 +3,6 @@ readings ready, readings() feeds the alert monitors.""" import asyncio -import json import logging import os import time @@ -12,14 +11,14 @@ from ..config import Config from ..timeutil import local_epoch, naive_local, now_local, when -from ..tools import Tool, Turn +from ..tools import Tool, Turn, json_result from ..warm import FAST_REFRESH_SECONDS, Warmer from .api import FINE_CYCLES, EcowittAPI, GROUPS, UNITS from .calendar import PublicHolidays from .link import DESCRIPTION as LINK_DESCRIPTION, PARAMETERS as LINK_PARAMETERS, link_tool from .days import DESCRIPTION as DAYS_DESCRIPTION, PARAMETERS as DAYS_PARAMETERS, days_tool from .glance import glance -from .fetch import Fetcher +from .fetch import Fetcher, series_in from ..series import READING_GROUPS, WEATHER, find from .query import HistoryQuery, stack_names from .store import HistoryCache, HotStore @@ -42,16 +41,12 @@ def parse_groups(value, default: str = DEFAULT_GROUPS) -> list[str]: def readings(data: dict) -> list[tuple[int, dict]]: """Ecowitt 'data' -> [(epoch, {"group.field": value})] sorted by time.""" rows: dict[int, dict] = {} - for grp, fields in data.items(): - if not isinstance(fields, dict): - continue - for field, obj in fields.items(): - if isinstance(obj, dict) and isinstance(obj.get("list"), dict): - for ts, v in obj["list"].items(): - try: - rows.setdefault(int(ts), {})[f"{grp}.{field}"] = float(v) - except (TypeError, ValueError): - pass + for group, field, obj in series_in(data): + for ts, v in obj["list"].items(): + try: + rows.setdefault(int(ts), {})[f"{group}.{field}"] = float(v) + except (TypeError, ValueError): + pass return sorted(rows.items()) @@ -209,8 +204,7 @@ async def _realtime(self, args: dict, turn: Turn | None = None) -> str: newest = max(newest, int(obj.get("time") or 0)) except (TypeError, ValueError): pass - return json.dumps({"time": when(newest, self.tz) if newest else None, **out, **({"emoji": emoji} if emoji else {})}, - ensure_ascii=False, separators=(",", ":")) + return json_result({"time": when(newest, self.tz) if newest else None, **out, **({"emoji": emoji} if emoji else {})}) async def _live_data(self, groups: list[str]) -> dict: """The real_time data for these groups: the keep-warm's reading when it is under a minute and a half old and asked for them, diff --git a/lib/forecast/source.py b/lib/forecast/source.py index 9ad1787..ebfac62 100644 --- a/lib/forecast/source.py +++ b/lib/forecast/source.py @@ -1,7 +1,6 @@ """The forecast source: the daily forecast from Open-Meteo (open-meteo.com), kept warm and cached; fetched only from 6 am to 6 pm local time (lib/warm.py SYNC_HOURS), every 15 minutes, and once at the start (or the first question) when nothing is cached.""" -import json import logging import math import re @@ -14,7 +13,7 @@ from ..config import Config from ..snapshots import Snapshots from ..timeutil import now_local, when -from ..tools import Tool, Turn +from ..tools import Tool, Turn, json_result from ..warm import Warmer, in_sync_hours log = logging.getLogger(__name__) @@ -224,8 +223,8 @@ async def handle(self, args: dict, turn: Turn | None = None) -> str: count = 7 lines = self.lines(count) if not lines: - return json.dumps({"error": "No forecast is available right now."}) + return json_result({"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 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) + return json_result({"lines": lines, "place": self.place, "source": self.source, **stamped}) diff --git a/lib/pollen/source.py b/lib/pollen/source.py index 70922b1..6716f20 100644 --- a/lib/pollen/source.py +++ b/lib/pollen/source.py @@ -15,7 +15,7 @@ from ..config import Config from ..snapshots import Snapshots from ..timeutil import day_text, local_date, now_local, when -from ..tools import Tool, Turn +from ..tools import Tool, Turn, json_result from ..warm import Warmer, in_sync_hours from .parse import DISTRICTS, LEVEL_EMOJI, parse_melbourne_pollen @@ -168,7 +168,7 @@ def lines(self) -> list[str]: async def handle(self, args: dict, turn: Turn | None = None) -> str: if not self.in_season(): - return json.dumps({"error": OFF_SEASON}) + return json_result({"error": OFF_SEASON}) if self.page is None and not args.get("cached"): # the report ("cached") never fetches try: await self.warm(True) @@ -176,7 +176,7 @@ async def handle(self, args: dict, turn: Turn | None = None) -> str: log.warning("Pollen not readable: %s", e) lines = self.lines() if not lines: - return json.dumps({"error": "No pollen levels are available right now."}) + return json_result({"error": "No pollen levels are available right now."}) 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)"} @@ -186,4 +186,4 @@ async def handle(self, args: dict, turn: Turn | None = None) -> str: out["asthma_updated"] = cur["asthma"]["updated"] if not cur["asthma"]: # in season the page always has one: it may have changed out["asthma_note"] = "The page had no thunderstorm asthma level for this district when it was last read." - return json.dumps(out, ensure_ascii=False) + return json_result(out) diff --git a/lib/tools.py b/lib/tools.py index b69f719..17c6799 100644 --- a/lib/tools.py +++ b/lib/tools.py @@ -14,6 +14,11 @@ TOOL_TIMEOUT = 90 +def json_result(out) -> str: + """A tool's result as the model is sent it: compact JSON, with ยฐ, ยฒ and emoji written as they are, not escaped.""" + return json.dumps(out, ensure_ascii=False, separators=(",", ":")) + + @dataclass class Turn: """What one question carries between the bot and the tools it calls (nothing is shared between questions).""" From 666fce581b70b94349b3831d8b41c01d42749ee8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 18:25:37 +0000 Subject: [PATCH 35/65] Link charts shade a reading as plot_chart does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit weather_link took its reading's band from Ecowitt's 30-minute lows and highs alone. plot_chart (compose.weather_band) fell back on the cached 5-minute readings where Ecowitt gives no range, and shaded wind up to its gusts. ecowitt.link.reading_slots is now the one reader both use: Ecowitt's own lows and highs, else the range of the 5-minute readings in each slot; and for wind, a band that reaches the gusts. What you will see: - A link chart of wind is shaded, and its high labelled, up to the gusts, like every other wind chart. In the spec dump (400 days of the fake station) the labelled high goes from 25.0 to 48.0 km/h. This is the only spec that changes. - A link chart of pressure longer than 31 days gets the band plot_chart draws from the 5-minute readings when Ecowitt has no 30-minute range of its own (store.slot_ranges says pressure is one such reading). The fake data has ranges, so the dump's pressure and humidity charts don't change. - The link analysis (its figures and verdict) reads only the values and is unchanged. No golden image changes. plot_chart no longer reads a band for the rain it draws as bars. Tests pin the reader's three cases and the wind link chart's high; reverting link to its own reader, or dropping either fallback, fails them. lib: +28 โˆ’23 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/compose.py | 30 ++++++++++-------------------- lib/ecowitt/link.py | 21 ++++++++++++++++++--- tests/test_ecowitt.py | 25 +++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 23 deletions(-) diff --git a/lib/compose.py b/lib/compose.py index 78b7b12..9ae1fdd 100644 --- a/lib/compose.py +++ b/lib/compose.py @@ -14,8 +14,8 @@ from .analysis import scan from .analysis.pairs import analyse_air from .captions import COMPOSED_CHART_HINT, wants_chart -from .ecowitt.link import driver_series -from .series import WEATHER, derives_range +from .ecowitt.link import driver_series, reading_slots +from .series import WEATHER from .panels import panel_for from .specs import Panel, Shares, stack from .rain import bar_layout, bar_of, rain_bars, rain_slots @@ -121,18 +121,12 @@ def period(self, args: dict, default_days: int = DEFAULT_DAYS) -> tuple[date, da """(first, last) day, or an error text. The last day is at most yesterday: today is still settling.""" return parse_period(args, now_local(self.tz).date(), default_days) + def _span(self, first: date, last: date) -> tuple[int, int]: + return day_bounds(first, self.tz)[0], day_bounds(last, self.tz, last_second=True)[1] + def weather(self, group: str, field: str, first: date, last: date) -> dict[int, float]: """One reading per 30-minute slot from the weather station's cache.""" - return self.weather_band(group, field, first, last, bands=False)[0] - - def weather_band(self, group: str, field: str, first: date, last: date, bands: bool = True) -> tuple[dict, dict, dict]: - """(values, lows, highs) per 30-minute slot; lows and highs are Ecowitt's own where it gives them, else the cached 5-minute readings'.""" - lo, hi = day_bounds(first, self.tz)[0], day_bounds(last, self.tz, last_second=True)[1] - fields = [field, field + "_low", field + "_high"] if bands else [field] - values, lows, highs = [*self.eco.cache.slots(self.eco.mac, "30min", group, fields, lo, hi), {}, {}][:3] - if bands and derives_range(field) and not (lows or highs): # no range of its own: from the cached 5-minute readings - lows, highs = self.eco.cache.slot_ranges(self.eco.mac, group, field, values, lo, hi) - return values, lows, highs + return self.eco.cache.slots(self.eco.mac, "30min", group, [field], *self._span(first, last))[0] async def air_series(self, metrics: list[str], first: date, last: date) -> tuple[dict[str, tuple[dict, dict, dict]], dict]: """({metric: (values, lows, highs)}, notes): the sensor's readings averaged into 30-minute slots, with each slot's @@ -261,17 +255,13 @@ async def _panel(self, name: str, style: str, first: date, last: date, notes: di already loaded (values, lows, highs).""" if name in WEATHER: reading = WEATHER[name] - group, field, label, unit = reading.group, reading.field, reading.label, reading.unit - values, lows, highs = self.weather_band(group, field, first, last) if name == "rain": - bars = rain_bars(rain_slots(values), self.tz, first, last) + bars = rain_bars(rain_slots(self.weather(reading.group, reading.field, first, last)), self.tz, first, last) return (panel_for("rain", bars=bars) if bars.x else None, {"series": name, "total_mm": round(sum(bars.y), 1), "wet_bars": len(bars.y)}) - if reading.band_field: # a mean shaded up to another field (wind: the gusts) - gust, _, gust_high = self.weather_band(group, reading.band_field, first, last) - highs = {t: max(gust.get(t, 0.0), gust_high.get(t, 0.0)) for t in {*gust, *gust_high}} - line = driver_series(values, self.tz, first, last, label, lows, highs, banded_short=bool(reading.band_field)) - facts = {"series": name, **self._stats(values, unit)} + values, lows, highs = reading_slots(self.eco.cache, self.eco.mac, name, *self._span(first, last)) + line = driver_series(values, self.tz, first, last, reading.label, lows, highs, banded_short=bool(reading.band_field)) + facts = {"series": name, **self._stats(values, reading.unit)} return (panel_for(name, [line]) if line else None), facts if data is None: *data, more = await self.air_slots(name, first, last) diff --git a/lib/ecowitt/link.py b/lib/ecowitt/link.py index b9a7c95..f7ad04c 100644 --- a/lib/ecowitt/link.py +++ b/lib/ecowitt/link.py @@ -45,10 +45,25 @@ "whole record; never averaged to days.") +def reading_slots(cache: HistoryCache, mac: str, name: str, start: int, end: int) -> tuple[dict, dict, dict]: + """(values, lows, highs) per 30-minute slot of a reading (series) from the cache, as every chart of it shades them: the + band is Ecowitt's own lows and highs where it gives them, else the cached 5-minute readings' (store.slot_ranges). A + reading shaded up to another field (wind) has its band reach that field's highest (the gusts).""" + reading = WEATHER[name] + group, field = reading.group, reading.field + values, lows, highs = cache.slots(mac, "30min", group, [field, field + "_low", field + "_high"], start, end) + if reading.band_field: + gust, gust_high = cache.slots(mac, "30min", group, [reading.band_field, reading.band_field + "_high"], start, end) + highs = {t: max(gust.get(t, 0.0), gust_high.get(t, 0.0)) for t in {*gust, *gust_high}} + elif not (lows or highs): + lows, highs = cache.slot_ranges(mac, group, field, values, start, end) + return values, lows, highs + + def driver_series(driver: dict[int, float], tz: tzinfo, first: date, last: date, label: str, lows: dict[int, float] | None = None, highs: dict[int, float] | None = None, banded_short: bool = True) -> Line | None: - """The reading as a line (see lines.build_line); a day's mean has its range shaded, from Ecowitt's own 30-minute - lows and highs where the cache holds them.""" + """The reading as a line (see lines.build_line); a day's mean has its range shaded, from the slots' lows and highs + (reading_slots) where there are any.""" line = build_line(slot_readings(driver, lows, highs), tz, ((last - first).days + 1) * 86400) return line.unbanded((last - first).days + 1, keep=banded_short).spec(label) if line else None @@ -73,7 +88,7 @@ def link(cache: HistoryCache, mac: str, tz: tzinfo, args: dict, now: datetime) - reading, threshold = WEATHER[name], DRIVERS[name] group, field, unit = reading.group, reading.field, reading.unit lo, hi = day_bounds(first, tz)[0], day_bounds(last, tz, last_second=True)[1] - driver, lows, highs = cache.slots(mac, "30min", group, [field, field + "_low", field + "_high"], lo, hi) + driver, lows, highs = reading_slots(cache, mac, name, lo, hi) (daily,) = cache.slots(mac, "30min", "rainfall", ["daily"], lo, hi) rain = rain_slots(daily) out = {"period": f"{day_text(first)} - {day_text(last)}", "driver": f"{name} ({unit})", diff --git a/tests/test_ecowitt.py b/tests/test_ecowitt.py index bb3b9bd..b5a96b9 100644 --- a/tests/test_ecowitt.py +++ b/tests/test_ecowitt.py @@ -988,9 +988,34 @@ async def test_weather_link_reads_the_cache_only_and_charts_rain_under_the_readi assert len(specs) == 1 and specs[0].title == "Pressure, Rain" and specs[0].panels[0].bars.width == 6 * 3600 assert render(specs[0], eco.tz)[:4] == b"\x89PNG" assert "error" in json.loads(await eco.tools[3].handler({"driver": "nonsense"})) + wind = Turn() # shaded as plot_chart shades it: up to the gusts (48 km/h at most) + await eco.tools[3].handler({"driver": "wind", "start_date": f"{today - timedelta(days=20)}", "end_date": f"{today - timedelta(days=2)}"}, wind) + line = wind.charts[0].panels[0].lines[0] + assert line.records["high"][1] == max(line.high) == 48.0 await eco.close() +def test_a_reading_is_shaded_alike_on_every_chart_ecowitts_own_range_else_the_5_minute_readings(tmp_path): + """reading_slots, which plot_chart and weather_link both draw from: Ecowitt's own 30-minute lows and highs where it gives them, + else the range of the cached 5-minute readings in each slot; wind's band reaches its gusts.""" + from lib.ecowitt.link import reading_slots + from lib.ecowitt.store import HistoryCache + cache = HistoryCache(tmp_path / "c.sqlite", {"u": "C"}) + t = int(datetime.now().timestamp() - 10 * 86400) // 1800 * 1800 + + def put(cycle, group, **fields): + data = {group: {f: {"unit": "", "list": {str(ts): str(v) for ts, v in points.items()}} for f, points in fields.items()}} + cache.store("M", cycle, [group], data, t, t + 1799) + put("30min", "pressure", relative={t: 1015.0}) + put("5min", "pressure", relative={t + 300 * i: 1014.0 + i for i in range(6)}) + assert reading_slots(cache, "M", "pressure", t, t + 1799) == ({t: 1015.0}, {t: 1014.0}, {t: 1019.0}) + put("30min", "outdoor", humidity={t: 60.0}, humidity_low={t: 52.0}, humidity_high={t: 71.0}) + put("5min", "outdoor", humidity={t + 300 * i: 40.0 + i for i in range(6)}) + assert reading_slots(cache, "M", "humidity", t, t + 1799) == ({t: 60.0}, {t: 52.0}, {t: 71.0}) + put("30min", "wind", wind_speed={t: 8.0}, wind_speed_low={t: 2.0}, wind_speed_high={t: 12.0}, wind_gust={t: 20.0}, wind_gust_high={t: 31.0}) + assert reading_slots(cache, "M", "wind", t, t + 1799) == ({t: 8.0}, {t: 2.0}, {t: 31.0}) + + def test_the_pair_chart_band_is_ecowitts_own_lows_and_highs_where_the_cache_has_them(): from lib.ecowitt.link import driver_series from tests.fakes import TZ From c521b4150d64247237648a278dd6d9be96b10c85 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 18:35:52 +0000 Subject: [PATCH 36/65] HistoryQuery: chart_field read once; refine targets, by-day breakdown and row labels named MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - chart_field (the one reading a chart is asked for) was read three ways. The source lowercased it to add the reading's group; the answer passed it raw to field_of to decide which derived readings to send; the chart builder also turned spaces into underscores. So "Dew Point" from the model left the dew point out of the result and drew no chart, and "wind gust" didn't fetch the wind group, so it drew no chart either. query.chart_field now reads it once for all three: "Dew Point" charts the dew point, and "wind gust" charts the wind (its speed shaded up to the gusts). A test covers both spellings. - _refine's targets were tuples read by position (sid[0], sid[1], sid[-1], sid[3]). They are now a NamedTuple, Target(scope, key, want, month). - The six `span <= 31 days` checks are one property, by_day: up to FINE_DAYS the answer is broken down by day, else by month. The row keys the breakdown's tables share ("Sat 3 Oct", "Oct 2026") were written in three places that had to match; they are now one method, _label. The year-long test now also checks each month's row key, and that each month's refined low and high stay apart (before, swapping them or changing the key's form passed). The chart specs are identical (dump_specs). lib: +60 โˆ’39 lines. The growth is the four named pieces and their docstrings. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/ecowitt/query.py | 94 ++++++++++++++++++++++++++----------------- lib/ecowitt/source.py | 5 +-- tests/test_ecowitt.py | 13 +++--- 3 files changed, 68 insertions(+), 44 deletions(-) diff --git a/lib/ecowitt/query.py b/lib/ecowitt/query.py index aa3c5dd..7cf210e 100644 --- a/lib/ecowitt/query.py +++ b/lib/ecowitt/query.py @@ -14,6 +14,7 @@ import logging from datetime import date, datetime, time, timedelta from statistics import fmean +from typing import NamedTuple from ..captions import AVERAGE_CHART_HINT, CHART_HINT, STACK_CHART_HINT, DIRECTION_CHART_HINT, wants_chart from ..lines import build_line @@ -32,7 +33,7 @@ log = logging.getLogger(__name__) INTRADAY_DAYS = 8 # up to this many days: 5-minute readings where archived -FINE_DAYS = 31 # up to this many: 30-minute readings; longer periods use daily records +FINE_DAYS = 31 # up to this many: 30-minute readings and a breakdown by day; longer periods use daily records, by month DAILY_ALL_DAYS = 7 # longer periods send every day's figures only when day by day was asked for DIRECTION_DAYS = 365 # how far back wind direction is counted (from cached 5-minute readings) MAX_REFINE_WINDOWS = 4 # overall records @@ -62,6 +63,13 @@ def consecutive(days: list[date]) -> list[list[date]]: return runs +def chart_field(args: dict, turn) -> str | None: + """The one reading a chart is asked for (the person's words, else the model's chart_field) as the field it plots, however it + is written ("UV" and "uvi" -> "uvi", "wind gust" -> "wind_speed": wind is its speed shaded up to the gusts); None if none.""" + named = str(turn.chart_field or args.get("chart_field") or "").strip().lower().replace(" ", "_") + return field_of(named) if named else None + + def stack_names(args: dict, turn) -> list[str]: """The readings to put side by side (from the person's words, else the model's chart_fields), two or more.""" raw = turn.chart_fields or args.get("chart_fields") or [] @@ -69,6 +77,14 @@ def stack_names(args: dict, turn) -> list[str]: return names if len(names) >= 2 else [] +class Target(NamedTuple): + """An extreme that _refine looks for at a finer time: the period's record ("all") or a month's.""" + scope: str # "all" or "month" + key: str # "group.field" + want: str # "low" or "high" + month: str = "" # a month's record: its row in the breakdown + + # ---------- one history question ---------- class HistoryQuery: """Answers one question: fetch, find the extremes, refine them, and build the compact @@ -89,6 +105,11 @@ def __init__(self, fetcher: Fetcher, args: dict, turn): self.compass: Compass | None = None # the wind rose (counts per compass point by speed), drawn beside the wind chart self.wind: Fetcher | None = None # the wind rose's own (5-minute readings), made when wind direction is counted + @property + def by_day(self) -> bool: + """The answer breaks the period down by day (up to FINE_DAYS), else by month.""" + return self.span <= timedelta(days=FINE_DAYS) + async def run(self) -> str: try: return await self._run() @@ -120,7 +141,7 @@ async def _run(self) -> str: 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} - self.monthly = self._monthly_extremes() if self.span > timedelta(days=31) else {} + self.monthly = {} if self.by_day else self._monthly_extremes() refined = await self._refine() return self._answer(refined) @@ -223,7 +244,7 @@ async def _summarise_direction(self): counted = [(t, d, exact) for t, (d, exact) in sorted(readings.items()) if first <= f.local(t)] calm = [x for x in counted if speeds.get(x[0], 1) <= 0] counted = [x for x in counted if speeds.get(x[0], 1) > 0] - result = summarise_direction(counted, self.tz, self.span <= timedelta(days=31), len(calm)) + result = summarise_direction(counted, self.tz, self.by_day, len(calm)) if not result: continue self.compass = Compass(direction_rose([(t, d, x, speeds.get(t)) for t, d, x in counted]), @@ -241,7 +262,7 @@ def _monthly_extremes(self) -> dict: for ts, rec in sorted(series["pts"].items()): if until is None or ts >= until: day = local_date(ts, self.tz) - month, until = day.strftime("%b %Y"), day_bounds(day, self.tz)[1] + month, until = self._label(day), day_bounds(day, self.tz)[1] fold(months.setdefault(month, {}), ts, rec) out[key] = months return out @@ -252,23 +273,23 @@ async def _refine(self) -> int: for periods of up to a few months, each month's temperature records. A second pass narrows a 30-minute slot to archived 5-minute data. Returns the number of windows fetched.""" f = self.f - slots: dict = {} # ("all", key, want) or ("month", key, month, want) -> Ext + slots: dict[Target, Ext] = {} for key in sorted(self.overall, key=lambda k: "temperature" not in k): for want in ("low", "high"): - slots[("all", key, want)] = self.overall[key][want] + slots[Target("all", key, want)] = self.overall[key][want] if self.monthly and self.detailed: for key, months in self.monthly.items(): if key.endswith(".temperature"): for month, d in reversed(list(months.items())): # newest first: where 5-minute data is for want, ext in d.items(): - slots[("month", key, month, want)] = ext + slots[Target("month", key, want, month)] = ext caps = {"all": MAX_REFINE_WINDOWS, "month": MAX_MONTH_REFINE_WINDOWS} refined = 0 for _ in range(2): - windows: dict = {} # (cycle, start_ts, end_ts) -> [slot id] + windows: dict = {} # (cycle, start_ts, end_ts) -> [Target] used = {"all": 0, "month": 0} finer_of: dict = {} # slots on the same bucket share an answer; a new pass looks again (data was fetched) - for sid, ext in slots.items(): + for target, ext in slots.items(): w_end = ext.ts + CYCLES[ext.cycle].seconds - 1 if (ext.ts, ext.cycle) not in finer_of: finer_of[ext.ts, ext.cycle] = await f.finer_cycle(ext.ts, ext.cycle, w_end, self.now.date()) @@ -277,38 +298,36 @@ async def _refine(self) -> int: continue w = (finer, ext.ts, w_end) if w not in windows: - if used[sid[0]] >= caps[sid[0]]: + if used[target.scope] >= caps[target.scope]: continue - used[sid[0]] += 1 + used[target.scope] += 1 windows[w] = [] - windows[w].append(sid) + windows[w].append(target) if not windows: break refined += len(windows) for (cycle, w_start, w_end), targets in windows.items(): fine: dict = {} collect(fine, await f.get(cycle, f.local(w_start), min(f.local(w_end), self.now)), cycle) - for sid in targets: - key, want = sid[1], sid[-1] - found = series_extremes(fine[key], w_start, w_end).get(want) if key in fine else None + for target in targets: + found = series_extremes(fine[target.key], w_start, w_end).get(target.want) if target.key in fine else None if not found: continue - cur = slots[sid] - keep = cur if better(want, cur.value, found.value) else found # the more extreme value... - slots[sid] = Ext(keep.value, keep.raw, found.ts, found.cycle, keep.exact) # ...at the finer time - for sid, ext in slots.items(): - if sid[0] == "all": - self.overall[sid[1]][sid[2]] = ext + cur = slots[target] + keep = cur if better(target.want, cur.value, found.value) else found # the more extreme value... + slots[target] = Ext(keep.value, keep.raw, found.ts, found.cycle, keep.exact) # ...at the finer time + for target, ext in slots.items(): + if target.scope == "all": + self.overall[target.key][target.want] = ext else: - self.monthly[sid[1]][sid[2]][sid[3]] = ext + self.monthly[target.key][target.month][target.want] = ext return refined def _answer(self, refined: int) -> str: f = self.f wanted = self.args.get("include_derived") or [] wanted = {wanted} if isinstance(wanted, str) else set(wanted) - asked = [self.turn.chart_field or self.args.get("chart_field"), *(WEATHER[n].field for n in stack_names(self.args, self.turn))] - asked = [a and field_of(a) for a in asked] + asked = [chart_field(self.args, self.turn), *(WEATHER[n].field for n in stack_names(self.args, self.turn))] wanted |= {f for f in asked if f in DERIVED} # charting one of them brings it into the result if "app_temp" in wanted: wanted.add("app_tempin") # indoor's name for apparent temperature @@ -348,15 +367,15 @@ def _result(self, key: str, ext: dict) -> dict: entry[f"{want}_time"], entry[f"{want}_when"], entry[f"{want}_date"] = describe_time(e.ts, e.cycle, tz) if not e.exact: entry[f"{want}_note"] = "from averaged data; the real value may be more extreme" - if self.span <= timedelta(days=31): + if self.by_day: days: dict = {} for ts, rec in self.store[key]["pts"].items(): if rec["cycle"] != "1day": # sub-daily points give correct local days fold(days.setdefault(local_date(ts, tz), {}), ts, rec) keep = None if len(days) <= DAILY_ALL_DAYS or self.turn.per_day else { # a long period: the days of its records, not all of them - day_text(local_date(ext[w].ts, tz), year=False) for w in ("low", "high")} - entry["daily"] = {day_text(d, year=False): {w: e.raw for w, e in days[d].items()} for d in sorted(days) - if keep is None or day_text(d, year=False) in keep} + self._label(local_date(ext[w].ts, tz)) for w in ("low", "high")} + entry["daily"] = {self._label(d): {w: e.raw for w, e in days[d].items()} for d in sorted(days) + if keep is None or self._label(d) in keep} else: entry["monthly"] = {} for month, d in self.monthly.get(key, {}).items(): @@ -395,6 +414,11 @@ def _add_charts(self, out: dict, plottable: dict): out["chart"] = (CHART_HINT + " For wind direction, give the most common direction, not a high and low." if spec and wind is not spec else DIRECTION_CHART_HINT) + def _label(self, day: date) -> str: + """The row a day belongs to in the breakdown, in the one form every table of it uses: the day ("Sat 3 Oct"), or its month + ("Oct 2026") when the period is broken down by month.""" + return day_text(day, year=False) if self.by_day else f"{day:%b %Y}" + def _daily_means(self, pts: dict) -> dict: """{local date: mean of that day's readings}: from 5- or 30-minute readings where held, else the daily bucket.""" means = {local_date(ts, self.tz): rec["value"][0] for ts, rec in pts.items() if rec.get("cycle") == "1day" and "value" in rec} @@ -402,15 +426,14 @@ def _daily_means(self, pts: dict) -> dict: return means def _add_averages(self, entry: dict, means: dict): - """The mean over the period, and per day (up to 31 days) or per month: the answer to "what was the average".""" + """The mean over the period, and per row of the breakdown (each day, or each month): the answer to "what was the average".""" if not means: return fmt = lambda v: f"{v:.1f}" entry["average"] = fmt(fmean(means.values())) + table = entry.get("daily" if self.by_day else "monthly") for d, v in means.items(): - table = entry.get("daily") if self.span <= timedelta(days=31) else entry.get("monthly") - label = day_text(d, year=False) if self.span <= timedelta(days=31) else d.strftime("%b %Y") - if table is not None and label in table: + if table is not None and (label := self._label(d)) in table: table[label].setdefault("_sum", []).append(v) for table in (entry.get("daily"), entry.get("monthly")): for row in (table or {}).values(): @@ -422,9 +445,8 @@ def _chart_spec(self, series_out: dict, field: str | None = None) -> Chart | Non """Line chart: one line per group for the field asked about (`field`, else chart_field; temperature by default, else the first field), at the finest resolution fetched for the whole period (5- or 30-minute readings, or daily averages for long periods), plus the true record high and low with their times.""" - asked = field or self.turn.chart_field or self.args.get("chart_field") - wanted = str(asked or "temperature").strip().lower().replace(" ", "_") - wanted = field_of(wanted) # a reading's name ("uv", "pressure") or its field ("uvi") + asked = field_of(field) if field else chart_field(self.args, self.turn) + wanted = asked or "temperature" keys = [k for k in series_out if k.endswith("." + wanted)] if not keys and (wanted in DERIVED or asked and find_name(wanted)): # asked for on its own and not there: no chart, rather than a temperature one return None @@ -514,7 +536,7 @@ async def _rain_bars(self) -> Bars: if day not in held and self.start.date() <= day <= self.end.date(): rain[day_bounds(day, self.tz)[0] + 12 * 3600] = mm self.rain_from_daily = True - by = grouping_in(self.turn.text) if self.span > timedelta(days=31) else None + by = None if self.by_day else grouping_in(self.turn.text) return rain_bars(rain, self.tz, self.start.date(), self.end.date(), until=hi, by=by, since=lo) def _stack_spec(self, names: list[str]) -> Chart | None: diff --git a/lib/ecowitt/source.py b/lib/ecowitt/source.py index f9b05ed..db1aae1 100644 --- a/lib/ecowitt/source.py +++ b/lib/ecowitt/source.py @@ -20,7 +20,7 @@ from .glance import glance from .fetch import Fetcher, series_in from ..series import READING_GROUPS, WEATHER, find -from .query import HistoryQuery, stack_names +from .query import HistoryQuery, chart_field, stack_names from .store import HistoryCache, HotStore log = logging.getLogger(__name__) @@ -176,8 +176,7 @@ async def _history(self, args: dict, turn: Turn | None = None) -> str: turn = turn or Turn() groups = parse_groups(args.get("groups")) groups += [g for n in stack_names(args, turn) if (g := WEATHER[n].group) not in groups] # what "plot temperature and rain" needs - named = str(turn.chart_field or args.get("chart_field") or "").strip().lower() # ... and what a chart of one reading needs - if (r := find(named)) and r.group not in groups: + if (r := find(chart_field(args, turn) or "")) and r.group not in groups: # ... and what a chart of one reading needs groups.append(r.group) return await HistoryQuery(self.fetcher(groups, urgent=not turn.ahead), args, turn).run() diff --git a/tests/test_ecowitt.py b/tests/test_ecowitt.py index b5a96b9..44ff54c 100644 --- a/tests/test_ecowitt.py +++ b/tests/test_ecowitt.py @@ -299,6 +299,8 @@ def monthly(out): spec = turn.charts[0] after = monthly(out) assert len(after) >= 6 and all({"low_when", "low_date", "high_when", "high_date"} <= set(m) for m in after.values()) + assert all(float(m["low"]) < float(m["high"]) for m in after.values()) # each month's records refined in place + assert all(f"{datetime.strptime(month, '%b %Y'):%b %Y}" == month for month in after) # a row per month: "Oct 2026" assert "monthly_note" not in json.loads(out) and fake.calls == [] assert "daily averages, range shaded" in spec.subtitle # 200 days: a point a day from the cached 30-minute data line = spec.panels[0].lines[0] @@ -1271,16 +1273,17 @@ async def test_thirty_minute_readings_without_a_range_get_one_from_the_cached_fi cache.close() -async def test_a_chart_field_may_be_a_readings_name_or_its_field(tmp_path): +async def test_a_chart_field_may_be_a_readings_name_or_its_field_however_it_is_written(tmp_path): transport, _ = ecowitt_transport() eco = Ecowitt(config(tmp_path), transport=transport) await eco.start() today = datetime.now(eco.tz).date() - for said in ("pressure", "relative", "dew_point"): - turn = Turn() - await eco.tools[1].handler({"groups": "outdoor,pressure", "chart": True, "chart_field": said, + for said, title in (("pressure", "Pressure"), ("relative", "Pressure"), ("dew_point", "Dew point"), ("Dew Point", "Dew point"), + ("wind gust", "Wind")): + turn = Turn() # the reading's group is added for it + await eco.tools[1].handler({"groups": "outdoor", "chart": True, "chart_field": said, "start_date": f"{today - timedelta(days=9)} 00:00:00", "end_date": f"{today - timedelta(days=1)} 23:59:59"}, turn) - assert turn.charts and turn.charts[0].title == ("Dew point" if said == "dew_point" else "Pressure"), said + assert turn.charts and turn.charts[0].title == title, said await eco.close() From 69b1a1f4fe2cb85a41b46c849aca534b6e7faafb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 18:40:32 +0000 Subject: [PATCH 37/65] The four data sources share a Source base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/sources.py names what the bot and the startup rely on in a data source; until now this was only implied. A source has a name, a time zone, its tools and a Warmer, plus start(), describe(), wants(text) and warm(fresh), which each source writes for itself. These are abstract, so a source lacking one fails as soon as it is made. The base also holds the three methods each source had its own copy of: now() (four copies; AirGradient's was called _now), poke() (four) and close() (three; the weather station still closes its own API client and history cache). ecobot.py and bot.py type their source lists with it. Not done: the plan's WebsiteSource base for the pollen page and the forecast. They share the client's construction and the start-up fetch's try/except, about eight lines. Their caching rules differ (the pollen season and conditional requests, the forecast's freshness), so a base class would hold little. Also removed: the imports this left unused, and two the forecast already didn't use (timedelta, intent). lib: +69 โˆ’54 lines. The new module is 49 of them, mostly the interface's docstrings. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- ecobot.py | 7 +++--- lib/airgradient/source.py | 27 ++++++++-------------- lib/bot.py | 3 ++- lib/ecowitt/source.py | 11 +++------ lib/forecast/source.py | 18 ++++----------- lib/pollen/source.py | 17 ++++---------- lib/sources.py | 47 +++++++++++++++++++++++++++++++++++++++ tests/test_airgradient.py | 4 ++-- 8 files changed, 75 insertions(+), 59 deletions(-) create mode 100644 lib/sources.py diff --git a/ecobot.py b/ecobot.py index 6699e6f..3251027 100644 --- a/ecobot.py +++ b/ecobot.py @@ -36,6 +36,7 @@ from lib.logs import LogFormat from lib.pollen import Pollen from lib.compose import Composer +from lib.sources import Source from lib.tools import Tools from lib.warm import every, safely @@ -48,14 +49,14 @@ log = logging.getLogger("ecobot") -async def start_sources(cfg: Config) -> tuple[list, Config]: +async def start_sources(cfg: Config) -> tuple[list[Source], Config]: """Every configured data source that starts, and the settings in the time zone they run in. The weather station starts first: it names its own time zone, which wins over TZ, so the air sensor and the optional website sources (pollen, forecast, off unless switched on), and everything built after, use the station's. The forecast uses the station's location unless one is configured.""" - sources = [] + sources: list[Source] = [] - async def start(source): + async def start(source: Source): try: await source.start() sources.append(source) diff --git a/lib/airgradient/source.py b/lib/airgradient/source.py index 83a3d49..f19e927 100644 --- a/lib/airgradient/source.py +++ b/lib/airgradient/source.py @@ -24,10 +24,11 @@ from .. import intent from ..captions import CHART_HINT, wants_chart from ..config import Config +from ..sources import Source from ..lines import Plotted, build_line from ..panels import panel_for from ..specs import Chart, Line -from ..timeutil import day_text, local_date, now_local, parse_local, to_local, when +from ..timeutil import day_text, local_date, parse_local, to_local, when from ..tools import Tool, Turn, json_result from ..warm import Warmer from .days import DESCRIPTION as DAYS_DESCRIPTION, PARAMETERS as DAYS_PARAMETERS, air_days_tool @@ -77,7 +78,7 @@ class Latest(NamedTuple): sustained: dict[str, float] # each metric's lowest over the last REQUIRE_CONSECUTIVE readings: what the air has been that long -class AirGradient: +class AirGradient(Source): """AirGradient client with its data kept warm and cached: - the current reading and today's history are refreshed every few minutes (warm); - finished days are fetched once and stored on disk, so they never need fetching again.""" @@ -111,13 +112,6 @@ def wants(self, text: str) -> bool: """Should a question start refreshing this source? Only air-quality questions.""" return intent.mentions_air(text) - def poke(self): - self.warmer.poke() - - async def close(self): - await self.client.aclose() - self.store.close() - async def _get(self, kind: str, **params) -> dict | list: """The reply: an object for "current", a list of readings for "past". A period with no readings is an empty list (AirGradient says so with a 404); a reply of any other shape raises, so it is never stored as days without readings.""" @@ -137,9 +131,6 @@ async def _get(self, kind: str, **params) -> dict | list: def _lock(self, key) -> asyncio.Lock: return self._locks.setdefault(key, asyncio.Lock()) - def _now(self) -> datetime: - return now_local(self.tz) - # ---------- cached data ---------- async def _current_row(self, refresh: bool = False) -> dict: async with self._lock("current"): @@ -149,7 +140,7 @@ async def _current_row(self, refresh: bool = False) -> dict: def _final(self, day: date) -> bool: """Over for more than an hour, so its data won't change.""" - return self._now() > datetime.combine(day, datetime.min.time()) + timedelta(days=1, hours=1) + return self.now() > datetime.combine(day, datetime.min.time()) + timedelta(days=1, hours=1) async def _held(self, days: list[date]) -> dict[date, int | None]: """Readings stored per finished day, in one query (None: not fetched yet, or not finished).""" @@ -174,7 +165,7 @@ async def _fetch_days(self, days: list[date]): start = datetime.combine(window[0], datetime.min.time()) end = datetime.combine(window[-1], datetime.min.time()) + timedelta(days=1) by_day: dict = {d: [] for d in window} - for r in await self._past(start, min(end, self._now())): + for r in await self._past(start, min(end, self.now())): by_day.get(local_date(r["ts"], self.tz), []).append(r) for d, day_rows in by_day.items(): await asyncio.to_thread(self.store.save_day, d, day_rows) @@ -194,13 +185,13 @@ async def _day_rows(self, day: date, refresh: bool = False) -> list[dict]: if cached and not refresh and time.time() - cached[1] <= FRESH_SECONDS: return cached[0] start = datetime.combine(day, datetime.min.time()) - rows = await self._past(start, min(start + timedelta(days=1), self._now())) + rows = await self._past(start, min(start + timedelta(days=1), self.now())) self._recent[day] = (rows, time.time()) return rows async def warm(self, fresh: bool = True) -> str: """Current reading, today, and the last week of finished days.""" - before, today = self.requests, self._now().date() + before, today = self.requests, self.now().date() await self._current_row(fresh) await self._day_rows(today, fresh) week = [today - timedelta(days=n) for n in range(1, WARM_DAYS + 1)] @@ -219,7 +210,7 @@ async def backfill(self, pace: float | None = None, empty_stop: int | None = Non pace = BACKFILL_PACE if pace is None else pace empty_stop = BACKFILL_EMPTY_STOP if empty_stop is None else empty_stop empty_before_data = BACKFILL_EMPTY_BEFORE_DATA if empty_before_data is None else empty_before_data - today = self._now().date() + today = self.now().date() fetched = failed = failed_run = empty_run = total = 0 seen_data = False n = 1 @@ -339,7 +330,7 @@ async def rows(self, t0: datetime, t1: datetime) -> tuple[list[dict], int, list[ async def history(self, start: str | None, end: str | None, chart: bool = False, metrics: list[str] | None = None, turn: Turn | None = None) -> dict: turn = turn or Turn() - now = self._now() + now = self.now() try: # a date the model sends, with or without an offset; a bad one is its mistake, not the sensor's t0 = parse_local(start, self.tz) if start else now - timedelta(days=1) t1 = min(parse_local(end, self.tz), now) if end else now diff --git a/lib/bot.py b/lib/bot.py index 9256ce6..66600bd 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -29,6 +29,7 @@ from .charts import render as render_chart from .tools import Turn from .config import Config +from .sources import Source from .timeutil import now_local from .llm import Agent, shorten_old, strip_tool_turns, trim_history from .logs import QUOTES @@ -236,7 +237,7 @@ async def deliver(msg: Message, text: str, photos: list[bytes], link: tuple[str, class Bot: - def __init__(self, cfg: Config, agent: Agent, sources: list, state: AlertState, irrigation=None): + def __init__(self, cfg: Config, agent: Agent, sources: list[Source], state: AlertState, irrigation=None): """state: the known chats and their settings. irrigation: the Tuya client of the irrigation controller, when one is configured (its button replaces Wind).""" self.cfg, self.agent, self.sources, self.state, self.irrigation = cfg, agent, sources, state, irrigation diff --git a/lib/ecowitt/source.py b/lib/ecowitt/source.py index db1aae1..604990d 100644 --- a/lib/ecowitt/source.py +++ b/lib/ecowitt/source.py @@ -10,7 +10,8 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from ..config import Config -from ..timeutil import local_epoch, naive_local, now_local, when +from ..sources import Source +from ..timeutil import local_epoch, naive_local, when from ..tools import Tool, Turn, json_result from ..warm import FAST_REFRESH_SECONDS, Warmer from .api import FINE_CYCLES, EcowittAPI, GROUPS, UNITS @@ -104,7 +105,7 @@ def get(group: str, field: str) -> str | None: return ", ".join(p for p in parts if p) or "no readings" -class Ecowitt: +class Ecowitt(Source): name = "Ecowitt" def __init__(self, cfg: Config, transport=None): @@ -168,9 +169,6 @@ def fetcher(self, groups: list[str], urgent: bool = False) -> Fetcher: """urgent: for a question (its requests go before the background ones).""" return Fetcher(self.api, self.cache, self.hot, self.mac, groups, self.tz, urgent) - def now(self) -> datetime: - return now_local(self.tz) - # ---------- tools ---------- async def _history(self, args: dict, turn: Turn | None = None) -> str: turn = turn or Turn() @@ -255,9 +253,6 @@ def wants(self, text: str) -> bool: """Should a question start refreshing this source? Weather data is used by nearly all.""" return True - def poke(self): - self.warmer.poke() - async def readings(self, cycle: str, start: datetime, end: datetime, groups: list[str] | None = None) -> list[tuple[int, dict]]: """Readings for the alert monitors, straight from the warm data.""" diff --git a/lib/forecast/source.py b/lib/forecast/source.py index ebfac62..afe57e4 100644 --- a/lib/forecast/source.py +++ b/lib/forecast/source.py @@ -5,14 +5,14 @@ import math import re import time -from datetime import date, datetime, timedelta +from datetime import date import httpx -from .. import intent from ..config import Config from ..snapshots import Snapshots -from ..timeutil import now_local, when +from ..sources import Source +from ..timeutil import when from ..tools import Tool, Turn, json_result from ..warm import Warmer, in_sync_hours @@ -83,7 +83,7 @@ def day_label(day: date, today: date) -> str: return "Today" if day == today else f"{day:%A}" -class Forecast: +class Forecast(Source): name = "Forecast" def __init__(self, cfg: Config, location: tuple[float, float] | None = None, transport=None): @@ -118,13 +118,6 @@ def wants(self, text: str) -> bool: """Only forecast questions start a refresh; the report always uses what is cached.""" return bool(re.search(r"\bforecast\w*|\btomorrow\b|\bthis week\b", text, re.I)) - def poke(self): - self.warmer.poke() - - async def close(self): - await self.client.aclose() - self.store.close() - # ---------- kept on disk ---------- def _load(self): """Start from the last forecast fetched (before the restart), so a night-time start needs no fetch.""" @@ -138,9 +131,6 @@ def _save(self): "days": [{**d, "date": d["date"].isoformat()} for d in self.days]}, now=self.fetched_at) - def now(self) -> datetime: - return now_local(self.tz) - # ---------- fetching ---------- async def _json(self, url: str, **params) -> dict: self.requests += 1 diff --git a/lib/pollen/source.py b/lib/pollen/source.py index 6716f20..9117630 100644 --- a/lib/pollen/source.py +++ b/lib/pollen/source.py @@ -7,14 +7,15 @@ import json import logging import time -from datetime import date, datetime +from datetime import date import httpx from .. import intent from ..config import Config from ..snapshots import Snapshots -from ..timeutil import day_text, local_date, now_local, when +from ..sources import Source +from ..timeutil import day_text, local_date, when from ..tools import Tool, Turn, json_result from ..warm import Warmer, in_sync_hours from .parse import DISTRICTS, LEVEL_EMOJI, parse_melbourne_pollen @@ -42,7 +43,7 @@ def _district(name: str) -> str: return known.get(name.lower(), name) -class Pollen: +class Pollen(Source): name = "Pollen" def __init__(self, cfg: Config, transport=None): @@ -74,13 +75,6 @@ def wants(self, text: str) -> bool: """Only pollen questions start a refresh; the report always uses what is cached.""" return intent.mentions_pollen(text) - def poke(self): - self.warmer.poke() - - async def close(self): - await self.client.aclose() - self.store.close() - # ---------- kept on disk ---------- def _load(self): """Start from the last page seen (before the restart), so a night-time start needs no fetch.""" @@ -96,9 +90,6 @@ def _save(self): self.store.put("pollen_validators", json.dumps(self._validators)) # ---------- fetching ---------- - def now(self) -> datetime: - return now_local(self.tz) - def in_season(self) -> bool: return self.now().month in SEASON_MONTHS diff --git a/lib/sources.py b/lib/sources.py new file mode 100644 index 0000000..3c37226 --- /dev/null +++ b/lib/sources.py @@ -0,0 +1,47 @@ +"""What a data source is to the bot: a name, its tools, a local clock and a Warmer that keeps it fresh; started once, poked +when a question wants it, closed at the end. The bot (bot.py) and the startup (ecobot.py) use nothing else of the four sources: +the weather station (lib/ecowitt), the air sensor (lib/airgradient), the pollen page (lib/pollen) and the forecast (lib/forecast).""" + +from abc import ABC, abstractmethod +from datetime import datetime, tzinfo + +from .timeutil import now_local +from .tools import Tool +from .warm import Warmer + + +class Source(ABC): + """Each source sets `name`, `tz`, `tools` and `warmer` (a Warmer of its own `warm`). close() closes its `client` and its + `store` on disk; the weather station, which has an API client and a history cache instead, closes its own.""" + name: str + tz: tzinfo + tools: list[Tool] + warmer: Warmer + + @abstractmethod + async def start(self): + """Get ready: find the device, read the first page. A failure that leaves the source of no use raises (the bot then runs + without it); one that only means "not yet" is logged, and the next refresh tries again.""" + + @abstractmethod + def describe(self) -> str: + """The source in a line for the prompt, with the words people use for it.""" + + @abstractmethod + def wants(self, text: str) -> bool: + """Should a question start refreshing this source?""" + + @abstractmethod + async def warm(self, fresh: bool = True) -> str: + """Fetch what questions usually need, if it is due (`fresh`: a timed refresh, not a question's); says what it did.""" + + def now(self) -> datetime: + return now_local(self.tz) + + def poke(self): + """A question that wants this source arrived: refresh it in the background (Warmer.poke).""" + self.warmer.poke() + + async def close(self): + await self.client.aclose() + self.store.close() diff --git a/tests/test_airgradient.py b/tests/test_airgradient.py index 75ae557..4e42bf7 100644 --- a/tests/test_airgradient.py +++ b/tests/test_airgradient.py @@ -387,7 +387,7 @@ async def test_a_single_reading_spike_is_in_none_of_the_figures_either(tmp_path) from lib.timeutil import local_date from tests.fakes import TZ air, _ = await make(tmp_path) - start = air._now().replace(microsecond=0) - timedelta(hours=1) + start = air.now().replace(microsecond=0) - timedelta(hours=1) t0 = int(start.replace(tzinfo=TZ).timestamp()) + 600 levels = [(1, 2, 3), (1, 2, 4), (15.3, 14.2, 28.7), (3, 8, 10), (2, 4, 5), (2, 3, 4)] rows = [{"ts": t0 + 300 * i, "pm1": a, "pm2_5": b, "pm10": c} for i, (a, b, c) in enumerate(levels)] @@ -491,7 +491,7 @@ async def test_a_date_with_an_offset_is_read_and_a_bad_one_is_not_called_a_senso async def test_an_air_chart_up_to_now_is_captioned_since_its_first_day_as_a_weather_chart_is(tmp_path): air, _ = await make(tmp_path) - now = air._now().replace(microsecond=0) + now = air.now().replace(microsecond=0) first = (now - timedelta(days=3)).date() turn = Turn() await air.history(f"{now - timedelta(days=3):%Y-%m-%d %H:%M:%S}", None, chart=True, turn=turn) From e16822759690d16242225b1820ed62914b0ae765 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 18:45:19 +0000 Subject: [PATCH 38/65] AirGradient's day bookkeeping: one unstored-days helper, counted fetches, one load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "The finished days not stored yet" was worked out three times (warm, backfill, rows); _unstored(held) says it once, and _day_rows uses it too instead of its own day_count query. - _fetch_days returns how many readings it stored for each day (save_day now says so), so the backfill no longer reads the counts back, and rows() no longer asks for them a second time. - rows() reads its finished days in one query instead of one per day; today and a day that has just ended still come from _day_rows. - The request windows come from day_bounds, as epochs (_past takes epochs and never asks past now), instead of local times built by hand and converted back. - AirStore.day_count goes (counts() covers it); its one test use reads counts(). Behaviour is unchanged. Over four sensor histories (plain, young, hourly-then-5-minute, an outage) the same requests are made, with the same windows, and give the same readings, counts and backfill results. The chart specs are identical (dump_specs). lib: +34 โˆ’41 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/airgradient/source.py | 63 +++++++++++++++++++-------------------- lib/airgradient/store.py | 12 +++----- tests/test_airgradient.py | 2 +- 3 files changed, 35 insertions(+), 42 deletions(-) diff --git a/lib/airgradient/source.py b/lib/airgradient/source.py index f19e927..8f1b5ce 100644 --- a/lib/airgradient/source.py +++ b/lib/airgradient/source.py @@ -28,7 +28,7 @@ from ..lines import Plotted, build_line from ..panels import panel_for from ..specs import Chart, Line -from ..timeutil import day_text, local_date, parse_local, to_local, when +from ..timeutil import day_bounds, day_text, local_date, local_epoch, parse_local, to_local, when from ..tools import Tool, Turn, json_result from ..warm import Warmer from .days import DESCRIPTION as DAYS_DESCRIPTION, PARAMETERS as DAYS_PARAMETERS, air_days_tool @@ -147,29 +147,30 @@ async def _held(self, days: list[date]) -> dict[date, int | None]: stored = await asyncio.to_thread(self.store.counts, days) return {d: stored[d] if self._final(d) else None for d in days} - def _utc(self, local: datetime) -> str: - return local.replace(tzinfo=self.tz).astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + def _unstored(self, held: dict[date, int | None]) -> list[date]: + """The finished days in `held` (see _held) that aren't stored yet.""" + return [d for d, n in held.items() if n is None and self._final(d)] - async def _past(self, start: datetime, end: datetime) -> list[dict]: - """Readings between two local times, normalised and oldest first.""" - raw = await self._get("past", **{"from": self._utc(start), "to": self._utc(end)}) + async def _past(self, start: int, end: int) -> list[dict]: + """Readings between two epochs (never past now), normalised and oldest first.""" + utc = lambda ts: datetime.fromtimestamp(ts, UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + raw = await self._get("past", **{"from": utc(start), "to": utc(min(end, time.time()))}) return sorted((normalise(r) for r in raw if isinstance(r, dict) and r.get("timestamp")), key=lambda r: r["ts"]) - async def _fetch_days(self, days: list[date]): - """Fetch finished days and store them, in as few requests as the API allows (it takes up to - 10 days at a time; we use 9).""" - days = sorted(days) + async def _fetch_days(self, days: list[date]) -> dict[date, int]: + """Fetch finished days and store them, in as few requests as the API allows (it takes up to 10 days at a time; we use 9). + Returns how many readings each day has stored.""" + days, stored = sorted(days), {} while days: window = [d for d in days if (d - days[0]).days < MAX_REQUEST_DAYS] days = days[len(window):] - start = datetime.combine(window[0], datetime.min.time()) - end = datetime.combine(window[-1], datetime.min.time()) + timedelta(days=1) by_day: dict = {d: [] for d in window} - for r in await self._past(start, min(end, self.now())): + for r in await self._past(day_bounds(window[0], self.tz)[0], day_bounds(window[-1], self.tz)[1]): by_day.get(local_date(r["ts"], self.tz), []).append(r) for d, day_rows in by_day.items(): - await asyncio.to_thread(self.store.save_day, d, day_rows) + stored[d] = await asyncio.to_thread(self.store.save_day, d, day_rows) self._recent.pop(d, None) + return stored async def _day_rows(self, day: date, refresh: bool = False) -> list[dict]: """All of one local day's readings, normalised. Finished days come from the disk cache (and @@ -178,14 +179,13 @@ async def _day_rows(self, day: date, refresh: bool = False) -> list[dict]: final = self._final(day) async with self._lock(day): if final: - if await asyncio.to_thread(self.store.day_count, day) is None: + if self._unstored(await self._held([day])): await self._fetch_days([day]) return await asyncio.to_thread(self.store.load_day, day) cached = self._recent.get(day) if cached and not refresh and time.time() - cached[1] <= FRESH_SECONDS: return cached[0] - start = datetime.combine(day, datetime.min.time()) - rows = await self._past(start, min(start + timedelta(days=1), self.now())) + rows = await self._past(*day_bounds(day, self.tz)) self._recent[day] = (rows, time.time()) return rows @@ -195,8 +195,7 @@ async def warm(self, fresh: bool = True) -> str: await self._current_row(fresh) await self._day_rows(today, fresh) week = [today - timedelta(days=n) for n in range(1, WARM_DAYS + 1)] - held = await self._held(week) - await self._fetch_days([d for d in week if self._final(d) and held[d] is None]) # one request for all of them + await self._fetch_days(self._unstored(await self._held(week))) # one request for all of them for day in week: if not self._final(day): # a day that just ended is still refetched for a while; finished ones are stored await self._day_rows(day) @@ -218,12 +217,10 @@ async def backfill(self, pace: float | None = None, empty_stop: int | None = Non days = [today - timedelta(days=k) for k in range(n, min(n + MAX_REQUEST_DAYS, max_days + 1))] n += MAX_REQUEST_DAYS counts = await self._held(days) - missing = [d for d in days if self._final(d) and counts[d] is None] - if missing: + if missing := self._unstored(counts): try: - await self._fetch_days(missing) + counts.update(await self._fetch_days(missing)) fetched, failed_run = fetched + len(missing), 0 - counts.update(await asyncio.to_thread(self.store.counts, missing)) if fetched // 30 != (fetched - len(missing)) // 30: log.info("AirGradient archive: %d day(s) fetched so far, now at %s", fetched, days[-1]) except Exception as e: @@ -315,17 +312,17 @@ async def rows(self, t0: datetime, t1: datetime) -> tuple[list[dict], int, list[ of the particles is a glitch of the sensor: it is taken out here, once, so no figure or chart has it.""" days = [t0.date() + timedelta(days=k) for k in range((t1.date() - t0.date()).days + 1)] held = await self._held(days) - missing = [d for d in days if self._final(d) and held[d] is None] + missing = self._unstored(held) skipped = missing[:-MAX_INLINE_DAYS] if len(missing) > MAX_INLINE_DAYS else [] # the oldest wait for the backfill - await self._fetch_days([d for d in missing if d not in skipped]) - rows = [] - for d in days: - if d not in skipped: # finished days are all stored by now; today and a just-ended day come via _day_rows - rows += await (asyncio.to_thread(self.store.load_day, d) if self._final(d) else self._day_rows(d)) - counts = await self._held([d for d in days if d not in skipped]) - hourly = sum(1 for c in counts.values() if c and c <= HOURLY_MAX_READINGS) - lo_ts, hi_ts = t0.replace(tzinfo=self.tz).timestamp(), t1.replace(tzinfo=self.tz).timestamp() - return [r for r in without_spikes(rows) if lo_ts <= r["ts"] <= hi_ts], hourly, skipped + held.update(await self._fetch_days([d for d in missing if d not in skipped])) + finished = [d for d in days if self._final(d)] # all stored by now but the skipped: read in one query + rows = (await asyncio.to_thread(self.store.load, day_bounds(finished[0], self.tz)[0], day_bounds(finished[-1], self.tz)[1]) + if finished else []) + for d in days[len(finished):]: # today, and a day that has just ended: fetched, not stored + rows += await self._day_rows(d) + hourly = sum(1 for n in held.values() if n and n <= HOURLY_MAX_READINGS) + lo, hi = local_epoch(t0, self.tz), local_epoch(t1, self.tz) + return [r for r in without_spikes(rows) if lo <= r["ts"] <= hi], hourly, skipped async def history(self, start: str | None, end: str | None, chart: bool = False, metrics: list[str] | None = None, turn: Turn | None = None) -> dict: diff --git a/lib/airgradient/store.py b/lib/airgradient/store.py index 0e3d8bd..554f080 100644 --- a/lib/airgradient/store.py +++ b/lib/airgradient/store.py @@ -33,14 +33,8 @@ def close(self): with self._lock: self.db.close() - def day_count(self, day: date) -> int | None: - """Readings stored for this finished day, or None if it hasn't been fetched.""" - with self._lock: - row = self.db.execute("SELECT n FROM days WHERE loc=? AND day=?", (self.loc, day.isoformat())).fetchone() - return row[0] if row else None - def counts(self, days: list[date]) -> dict[date, int | None]: - """day_count for many days in one query.""" + """Readings stored for each of these finished days, in one query: None for a day that hasn't been fetched.""" if not days: return {} with self._lock: @@ -48,7 +42,8 @@ def counts(self, days: list[date]) -> dict[date, int | None]: (self.loc, min(days).isoformat(), max(days).isoformat())).fetchall()) return {d: found.get(d.isoformat()) for d in days} - def save_day(self, day: date, rows: list[dict]): + def save_day(self, day: date, rows: list[dict]) -> int: + """Store one day's readings, in place of any it had; returns how many it now holds.""" lo, hi = day_bounds(day, self.tz) rows = [r for r in rows if lo <= r["ts"] < hi] with self._lock: @@ -58,6 +53,7 @@ def save_day(self, day: date, rows: list[dict]): [(self.loc, r["ts"], *(r.get(c) for c in COLUMNS)) for r in rows]) self.db.execute("INSERT OR REPLACE INTO days VALUES (?, ?, ?)", (self.loc, day.isoformat(), len(rows))) self.db.commit() + return len(rows) def load(self, start_ts: int, end_ts: int) -> list[dict]: """Readings with start_ts <= ts < end_ts, oldest first.""" diff --git a/tests/test_airgradient.py b/tests/test_airgradient.py index 4e42bf7..31bd355 100644 --- a/tests/test_airgradient.py +++ b/tests/test_airgradient.py @@ -177,7 +177,7 @@ async def test_bucketed_air_lines_carry_a_band_and_the_peak_label_sits_on_its_to def await_days(air, days_ago): """Readings stored for the local day this many days ago (0 if none or not fetched).""" day = datetime.now(TZ).date() - timedelta(days=days_ago) - return air.store.day_count(day) or 0 + return air.store.counts([day])[day] or 0 # ---------- what the API's docs promise ---------- From d20b6b88d09b748e4892ce7ee33fafa8ee9b8c57 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 18:52:00 +0000 Subject: [PATCH 39/65] Sources by name, not four booleans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These functions now take the names of the sources the bot has ("Ecowitt", "AirGradient", "Pollen", "Forecast"): intent.read, fast_call, report_calls, model_prefetch and the two day helpers, templates.capabilities_text, and prompt.capabilities. Bot.by_name is such a collection, so bot.py's five calls that spelled out `"Ecowitt" in self.by_name, "AirGradient" in self.by_name, ...` now pass it as it is. read() defaults to the two devices, as the booleans did. - prompt.build takes {name: description}. The prompt still lists each source's describe() line, but what the bot can and can't do now follows the source names, not words that happen to be in those lines ("melbournepollen", "Open-Meteo"). - templates.capabilities_text reads one table (MEASURES) of what each source measures, and report_calls one table of each source's call. - ecobot.py still finds each source by its class: it calls each one's own methods. Behaviour is unchanged. Over the question corpus and all 16 combinations of sources, every decision intent.read makes (47,664), the Capabilities text, and the system prompt (4 topic sets each) are identical before and after. About 45 test calls and the two scripts that passed the booleans now pass names; tests share EVERY_SOURCE (tests/fakes.py). lib: +60 โˆ’60 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/alerts/pictures.py | 2 +- lib/bot.py | 17 ++++-------- lib/intent.py | 49 +++++++++++++++++---------------- lib/prompt.py | 31 +++++++++++---------- lib/templates.py | 21 +++++++------- scripts/report_inputs.py | 2 +- scripts/show_request.py | 2 +- tests/evalkit.py | 11 ++++---- tests/fakes.py | 1 + tests/test_admin_only.py | 2 +- tests/test_agent.py | 44 ++++++++++++++---------------- tests/test_alerts.py | 2 +- tests/test_ecowitt.py | 2 +- tests/test_intent.py | 59 ++++++++++++++++++++-------------------- tests/test_pollen.py | 6 ++-- tests/test_snapshots.py | 2 +- tests/test_templates.py | 10 +++---- 17 files changed, 131 insertions(+), 132 deletions(-) diff --git a/lib/alerts/pictures.py b/lib/alerts/pictures.py index c745467..219b499 100644 --- a/lib/alerts/pictures.py +++ b/lib/alerts/pictures.py @@ -16,7 +16,7 @@ async def chart(source, text: str, tz) -> bytes | None: """The PNG for the request `text`, made from `source`'s tool the way the bot would answer it; None when it cannot be made (the alert then goes out as text). `source` is the station or the air sensor: the tool is found in its `tools` by the name the request resolves to.""" try: - read = intent.read(text, now_local(tz), True, True) + read = intent.read(text, now_local(tz)) tool, args = read.fast[0], read.fast[1] turn = Turn(chart_asked=read.chart_asked, chart_field=read.chart_field, chart_fields=read.chart_fields, text=text) handler = next(t for t in source.tools if t.name == tool).handler diff --git a/lib/bot.py b/lib/bot.py index 66600bd..c6d9408 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -393,8 +393,7 @@ async def on_alerts(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def _help_and_alerts(self, update: Update, context: ContextTypes.DEFAULT_TYPE): """What the bot can do, then the alert settings: the Help & Alerts button and /usage.""" - await update.effective_message.reply_text(templates.capabilities_text("Ecowitt" in self.by_name, "AirGradient" in self.by_name, - "Pollen" in self.by_name, "Forecast" in self.by_name)) + await update.effective_message.reply_text(templates.capabilities_text(self.by_name)) await self.on_alerts(update, context) async def on_usage(self, update: Update, context: ContextTypes.DEFAULT_TYPE): @@ -567,8 +566,7 @@ async def on_report_button(self, update: Update, context: ContextTypes.DEFAULT_T """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 - 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) + read = intent.read("Report", now_local(self.cfg.tz), 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 @@ -784,8 +782,7 @@ async def _lookup_in_code(self, read, turn: Turn) -> str | None: """A plain lookup (a reading, the air, the pollen, the forecast, what the bot can do) answered from its one tool result; None (the model then takes the question) when it can't be.""" if read.lookup == "about": - return templates.capabilities_text("Ecowitt" in self.by_name, "AirGradient" in self.by_name, - "Pollen" in self.by_name, "Forecast" in self.by_name) + return templates.capabilities_text(self.by_name) name, args = read.fast[:2] result = await self.agent.tools.call(name, json.dumps(args), turn) return report.lookup(read.lookup, result, read.lookup_arg, read.sides, now_local(self.cfg.tz).date()) @@ -812,8 +809,7 @@ async def _ask_model(self, working: list[dict], system: str, read, turn: Turn, d async def _draw_ahead(self, text: str, now) -> tuple[str, bytes, str, str] | None: """(image key, PNG, title, write-up) of the chart `text` asks for when it is one made in code with its own words as its key; None otherwise. Raises when it can't be drawn. Nobody is waiting: its requests are not urgent and its logs are quiet.""" - read = intent.read(text, now, "Ecowitt" in self.by_name, "AirGradient" in self.by_name, "Pollen" in self.by_name, - "Forecast" in self.by_name) + read = intent.read(text, now, self.by_name) key = self.images.canonical(text, now) if not (read.fast and read.chart_in_code and key): # not a chart made in code ("Report"), or a source that isn't there return None @@ -925,8 +921,7 @@ async def _respond(self, update: Update, context: ContextTypes.DEFAULT_TYPE, tex keep_typing(context.bot, msg.chat_id, self._thread(msg), stop_typing)) await asyncio.sleep(0) now = now_local(self.cfg.tz) - read = intent.read(text, now, "Ecowitt" in self.by_name, "AirGradient" in self.by_name, "Pollen" in self.by_name, - "Forecast" in self.by_name) + read = intent.read(text, now, self.by_name) log.info("%s %s%s", describe_source(update), f"(reasoning: {read.effort}) " if read.effort != intent.EFFORT_DEFAULT else "", text, extra=QUOTES) for source in self.sources: # fetch recent readings while the model thinks @@ -970,7 +965,7 @@ async def _respond(self, update: Update, context: ContextTypes.DEFAULT_TYPE, tex else: before = [str(m["content"]) for m in chat.history if m["role"] == "user"][-2:] found = intent.topics(str(working[new_from - 1]["content"]), before) - system = prompt.build(datetime.now(self.cfg.tz), [s.describe() for s in self.sources], read.hints, + system = prompt.build(datetime.now(self.cfg.tz), {s.name: s.describe() for s in self.sources}, read.hints, read.about_the_bot, read.rain_caption, found) reply = await self._ask_model(working, system, read, turn, draft, lambda tokens: self._warn(msg, tokens), f"ecobot-{msg.chat_id}") diff --git a/lib/intent.py b/lib/intent.py index b35fb87..3df2aaa 100644 --- a/lib/intent.py +++ b/lib/intent.py @@ -10,6 +10,7 @@ import logging import re +from collections.abc import Collection from dataclasses import dataclass, field from datetime import date, datetime, timedelta @@ -107,11 +108,11 @@ def wants_report(text: str) -> bool: return bool(REPORT.search(text)) and not TIME_WORDS.search(text) -def report_calls(ecowitt: bool, air: bool, pollen: bool = False, forecast: bool = False) -> list[tuple[str, dict]]: - """What the full report needs, fetched together before the model sees the question.""" - return ([("weather_now", {"groups": NOW_GROUPS})] if ecowitt else []) + ([("air_quality", {})] if air else []) + ( - [("pollen_asthma", {"cached": True})] if pollen else []) + ( - [("weather_forecast", {"days": 3, "cached": True})] if forecast else []) +def report_calls(sources: Collection[str]) -> list[tuple[str, dict]]: + """What the full report needs from these sources (by name), fetched together before the model sees the question.""" + calls = {"Ecowitt": ("weather_now", {"groups": NOW_GROUPS}), "AirGradient": ("air_quality", {}), + "Pollen": ("pollen_asthma", {"cached": True}), "Forecast": ("weather_forecast", {"days": 3, "cached": True})} + return [call for name, call in calls.items() if name in sources] # Pollen and thunderstorm asthma (the Pollen source) @@ -577,7 +578,7 @@ def _figures(name: str, stem: str) -> tuple[dict[str, str], str]: HOLIDAYISH = re.compile(r"\b(holidays?|weekends?|saturdays?|sundays?)\b", I) -def count_days_call(text: str, now: datetime, ecowitt: bool, air: bool) -> tuple[str, dict, str] | None: +def count_days_call(text: str, now: datetime, sources: Collection[str]) -> tuple[str, dict, str] | None: """(tool, arguments, label) when the text asks how many days one reading was over or under one limit (optionally per year or month), else None and the model decides.""" if (not re.search(r"\bdays?\b|\btimes\b", text, I) or any(p.search(text) for p in (FORECAST, LINK, THINK, DESCRIBE, HOLIDAYISH)) @@ -595,12 +596,12 @@ def count_days_call(text: str, now: datetime, ecowitt: bool, air: bool) -> tuple return None if mentions_air(text): metrics = air_named(text) - if not air or len(metrics) != 1 or ALL.search(text): + if "AirGradient" not in sources or len(metrics) != 1 or ALL.search(text): return None tool, field = "air_days", f"{metrics[0]}_{'min' if below else 'max'}" else: names = list(_named(text)) - if not ecowitt or len(names) != 1: + if "Ecowitt" not in sources or len(names) != 1: return None name = names[0] if name not in DAILY: @@ -631,7 +632,7 @@ def count_days_call(text: str, now: datetime, ecowitt: bool, air: bool) -> tuple LOWER = re.compile(r"\b(coldest|lowest|min\w*|driest|coolest|calmest|smallest)\b", I) -def period_figures_call(text: str, now: datetime, ecowitt: bool, air: bool) -> tuple[str, dict, str] | None: +def period_figures_call(text: str, now: datetime, sources: Collection[str]) -> tuple[str, dict, str] | None: """(tool, arguments, label) when the text asks for one reading's figure by or per month or year, else None (the model decides).""" group = GROUPING.search(text) if (not group or any(p.search(text) for p in (FORECAST, LINK, THINK, DESCRIBE, HOLIDAYISH, ABOVE, BELOW, OR_MORE, OR_LESS, AT_LEAST)) @@ -645,12 +646,12 @@ def period_figures_call(text: str, now: datetime, ecowitt: bool, air: bool) -> t return None if mentions_air(text): metrics = air_named(text) - if not air or len(metrics) != 1 or ALL.search(text): + if "AirGradient" not in sources or len(metrics) != 1 or ALL.search(text): return None tool, stat, field = "air_days", ("max" if high else "min" if low else "avg"), f"{metrics[0]}_{'max' if high else 'min' if low else 'avg'}" else: names = list(_named(text)) - if not ecowitt or len(names) != 1: + if "Ecowitt" not in sources or len(names) != 1: return None name, tool = names[0], "weather_days" if name not in DAILY: @@ -673,11 +674,11 @@ def period_figures_call(text: str, now: datetime, ecowitt: bool, air: bool) -> t "start_date": start.strftime("%Y-%m-%d"), "end_date": end.strftime("%Y-%m-%d")}, f"figures by {by}, {name_}" -def fast_call(text: str, now: datetime, ecowitt: bool, air: bool, pollen: bool = False, - forecast: bool = False) -> tuple[str, dict, str] | None: - """(tool name, arguments, what it is) for a question the bot can fetch for without the model.""" - if (counted := count_days_call(text, now, ecowitt, air)) or (counted := period_figures_call(text, now, ecowitt, air)): +def fast_call(text: str, now: datetime, sources: Collection[str]) -> tuple[str, dict, str] | None: + """(tool name, arguments, what it is) for a question the bot can fetch for without the model, from these sources (by name).""" + if (counted := count_days_call(text, now, sources)) or (counted := period_figures_call(text, now, sources)): return counted + ecowitt, air, pollen, forecast = ("Ecowitt" in sources, "AirGradient" in sources, "Pollen" in sources, "Forecast" in sources) if SPECIFIC_MOMENT.search(text) or COUNTING.search(text): # "high on 5 Jan this year", "at 3pm today": a whole period would be the wrong data return None if air and (period := air_period(text, now)): @@ -746,16 +747,17 @@ def topics(text: str, before: list[str] = ()) -> set[str]: WEEK_AHEAD = re.compile(r"\b(week|days|weekend|next \w+day)\b", I) -def model_prefetch(text: str, now: datetime, ecowitt: bool, forecast: bool) -> list[tuple[str, dict]]: +def model_prefetch(text: str, now: datetime, sources: Collection[str]) -> list[tuple[str, dict]]: """What the model would fetch first for these questions, fetched here instead (so it answers in one call, not two): for rain ahead, the current reading, the last 3 hours and the forecast (cached); for a described today or yesterday, that day's readings.""" calls: list[tuple[str, dict]] = [] + ecowitt = "Ecowitt" in sources if RAIN_AHEAD.search(text): if ecowitt: groups = "outdoor,pressure,rainfall,rainfall_piezo,wind" calls += [("weather_now", {"groups": groups}), ("weather_history", {"groups": groups, "start_date": (now - timedelta(hours=3)).strftime(FMT), "end_date": now.strftime(FMT)})] - if forecast: + if "Forecast" in sources: calls.append(("weather_forecast", {"days": 7 if WEEK_AHEAD.search(text) else 3, "cached": True})) elif ecowitt and DESCRIBE.search(text) and not SPECIFIC_MOMENT.search(text): spans = spans_in(text, now) @@ -819,14 +821,15 @@ def plain_lookup(text: str, fast: tuple | None) -> tuple[str, list[str]]: APOSTROPHES = str.maketrans("โ€™โ€˜", "''") # a phone's typographic apostrophes ("whatโ€™s"), read as the plain one every pattern uses -def read(text: str, now: datetime, ecowitt: bool = True, air: bool = True, pollen: bool = False, - forecast: bool = False) -> Reading: - """The decisions made in code for this message. A shortcut that fails is dropped: the model handles the question.""" +def read(text: str, now: datetime, sources: Collection[str] = ("Ecowitt", "AirGradient")) -> Reading: + """The decisions made in code for this message, given the sources the bot has (by name: "Ecowitt", "AirGradient", "Pollen", + "Forecast"). A shortcut that fails is dropped: the model handles the question.""" text = text.translate(APOSTROPHES) + ecowitt = "Ecowitt" in sources report = wants_report(text) - calls = report_calls(ecowitt, air, pollen, forecast) if report else [] + calls = report_calls(sources) if report else [] try: - fast = (*calls[0], "report") if calls else fast_call(text, now, ecowitt, air, pollen, forecast) + fast = (*calls[0], "report") if calls else fast_call(text, now, sources) except Exception: log.exception("Fast path failed; using the normal path") fast = None @@ -837,4 +840,4 @@ def read(text: str, now: datetime, ecowitt: bool = True, air: bool = True, polle 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), - lookup=lookup, lookup_arg=named, sides=_sides(text), extra=model_prefetch(text, now, ecowitt, forecast) if not fast else [], readings=list(_named(text)), per_day=bool(PER_DAY.search(text))) + lookup=lookup, lookup_arg=named, sides=_sides(text), extra=model_prefetch(text, now, sources) if not fast else [], readings=list(_named(text)), per_day=bool(PER_DAY.search(text))) diff --git a/lib/prompt.py b/lib/prompt.py index a81883a..3ed7b8a 100644 --- a/lib/prompt.py +++ b/lib/prompt.py @@ -1,6 +1,7 @@ """The system prompt. Everything the model would otherwise have to work out (dates, devices, units, wording of times) is decided in code and handed over ready-made.""" +from collections.abc import Collection from datetime import datetime from .airgradient.metrics import ALL_METRICS, CHART_UNITS, LABELS @@ -155,14 +156,13 @@ def date_ranges(now: datetime) -> str: return "\n".join(lines) -def capabilities(sources: list[str], brief: bool = False) -> str: - """What the bot can and can't do, for questions about the bot itself ("what metrics do you have?") and so the model - knows what is possible before it says something can't be done. Built from the code's own lists.""" - have = " ".join(sources) +def capabilities(have: Collection[str], brief: bool = False) -> str: + """What the bot can and can't do with the sources it has (by name), for questions about the bot itself ("what metrics do you + have?") and so the model knows what is possible before it says something can't be done. Built from the code's own lists.""" if brief: # most questions: only the limits, so it never promises what it can't do missing = ["lightning", "soil or extra sensor channels", "indoor air quality", "other stations or places"] - missing += (["pollen and thunderstorm asthma"] if "melbournepollen" not in have else []) + ( - ["forecasts (only a short read of the pressure trend)"] if "Open-Meteo" not in have else []) + missing += (["pollen and thunderstorm asthma"] if "Pollen" not in have else []) + ( + ["forecasts (only a short read of the pressure trend)"] if "Forecast" not in have else []) return ("\nLIMITS: not available: " + ", ".join(missing) + ". Custom alerts (\"tell me when winds reach 100\") can't be added: say so. " "A chart series or style the tools don't offer can't be drawn: say so.\n") lines = ["\nWHAT THIS BOT CAN AND CAN'T DO (answer questions about the bot from this, with no tool call)"] @@ -183,22 +183,22 @@ def capabilities(sources: list[str], brief: bool = False) -> str: "cleaner air, and air_scan scans every air metric against every weather reading for what goes with what. " "Use them for \"plot X against Y\", \"does rain affect air quality\" and \"is there a correlation between air " "quality and other metrics\"; a series or style not listed can't be plotted: say so.") - if "melbournepollen" in have: + if "Pollen" in have: lines.append("- Pollen: Melbourne's grass pollen level and the thunderstorm asthma risk (Low, Moderate, High), tool pollen_asthma. " "Both forecasts only run from October to December: outside those months the tool says so.") - if "Open-Meteo" in have: + if "Forecast" in have: lines.append("- Forecast: today and the days ahead from Open-Meteo (summary, temperatures, chance of at least 1 mm of rain), tool weather_forecast.") 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, 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 " + "indoor/outdoor temperature crossing, air-quality mask alerts" + (", pollen or thunderstorm asthma risk reaching High" if "Pollen" in have else "") + ( + ", a forecast it sent being revised (rain, or max temperature over 2 degrees)" if "Forecast" 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.") missing = ["lightning", "soil or extra sensor channels", "indoor air quality", "other stations or places"] - if "melbournepollen" not in have: + if "Pollen" not in have: missing.append("pollen and thunderstorm asthma") - if "Open-Meteo" not in have: + if "Forecast" not in have: missing.append("forecasts (only a short read of the pressure trend)") lines.append("- Not available: " + ", ".join(missing) + ".") return "\n".join(lines) + "\n" @@ -210,12 +210,13 @@ def capabilities(sources: list[str], brief: bool = False) -> str: """ -def build(now: datetime, sources: list[str], hints: list[str] = (), about_bot: bool = False, rain_caption: bool = False, +def build(now: datetime, sources: dict[str, str], hints: list[str] = (), about_bot: bool = False, rain_caption: bool = False, topics: set[str] | None = None) -> str: - """The system prompt. `topics` (see intent.topics) picks the guidance the question needs; None puts all of it in.""" + """The system prompt for the bot's sources ({name: its line for the prompt, Source.describe()}). `topics` (see intent.topics) + picks the guidance the question needs; None puts all of it in.""" # Most stable first: the model provider caches a prompt's start, so what changes (the topics, the date, then the time) goes last everything = topics is None - text = PROMPT.format(sources="\n".join(f"- {s}" for s in sources) or "(none)", + text = PROMPT.format(sources="\n".join(f"- {s}" for s in sources.values()) or "(none)", capabilities=capabilities(sources, brief=not (everything or about_bot or "bot" in topics))) text += "".join(f"\n{block}" for name, block in BLOCKS.items() if everything or name in topics) text += f"\nRIGHT NOW (exact ranges, then the time)\n{date_ranges(now)}\nIt is now {now.strftime('%A %d %B %Y, %H:%M %Z')}.\n" diff --git a/lib/templates.py b/lib/templates.py index 2259359..51cbee1 100644 --- a/lib/templates.py +++ b/lib/templates.py @@ -2,6 +2,7 @@ message, so the answer comes through the usual path; the label stands for a plain sentence (its words without the emoji).""" import hashlib +from collections.abc import Collection from telegram import ReplyKeyboardMarkup @@ -39,16 +40,16 @@ def sentence(text: str) -> str | None: return SENTENCES.get(text.strip()) -WEATHER_METRICS = ["Temperature, humidity (indoor, outdoor)", "Dew point, vapour pressure deficit, pressure, wind speed", - "Rain, solar radiation, UV index"] -AIR_METRICS = ["PM1, PM2.5, PM10, COโ‚‚, VOC, NOx"] -POLLEN = ["Pollen and thunderstorm asthma risk (October to December)"] -FORECAST = ["Forecast"] -HISTORY = ["History charts"] +MEASURES = { # a source (by name) -> what it measures, as the Capabilities button lists it + "Ecowitt": ["Temperature, humidity (indoor, outdoor)", "Dew point, vapour pressure deficit, pressure, wind speed", + "Rain, solar radiation, UV index"], + "AirGradient": ["PM1, PM2.5, PM10, COโ‚‚, VOC, NOx"], + "Pollen": ["Pollen and thunderstorm asthma risk (October to December)"], + "Forecast": ["Forecast"], +} -def capabilities_text(weather: bool = True, air: bool = True, pollen: bool = False, forecast: bool = False) -> str: - """What the bot measures, as a short bulleted list (no dates, no detail): the answer to the Capabilities button.""" - lines = (WEATHER_METRICS if weather else []) + (AIR_METRICS if air else []) + (POLLEN if pollen else []) + ( - FORECAST if forecast else []) + HISTORY +def capabilities_text(sources: Collection[str] = ("Ecowitt", "AirGradient")) -> str: + """What the bot measures with these sources, as a short bulleted list (no dates, no detail): the answer to the Capabilities button.""" + lines = [line for name, measured in MEASURES.items() if name in sources for line in measured] + ["History charts"] return "\n".join(f"\u2022 {line}" for line in lines) diff --git a/scripts/report_inputs.py b/scripts/report_inputs.py index 95a0b5a..d0a03a4 100644 --- a/scripts/report_inputs.py +++ b/scripts/report_inputs.py @@ -27,7 +27,7 @@ async def main(): try: tools = Tools([t for s in sources for t in s.tools]) names = {s.name for s in sources} - for name, args in intent.report_calls("Ecowitt" in names, "AirGradient" in names, "Pollen" in names, "Forecast" in names): + for name, args in intent.report_calls(names): print(f"=== {name} {json.dumps(args)}") print(await tools.call(name, json.dumps(args))) print() diff --git a/scripts/show_request.py b/scripts/show_request.py index b5072d7..5205dda 100644 --- a/scripts/show_request.py +++ b/scripts/show_request.py @@ -83,7 +83,7 @@ async def unavailable(a): Tool("air_quality", air.DESCRIPTION, air.PARAMETERS, unavailable)]) text = args.question r = intent.read(text, now.replace(tzinfo=None)) - system = prompt.build(now, ["Ecowitt weather station", "AirGradient outdoor air-quality sensor"], r.hints) + system = prompt.build(now, {"Ecowitt": "Ecowitt weather station", "AirGradient": "AirGradient outdoor air-quality sensor"}, r.hints) effort, fast = r.effort, r.fast call = json.loads(args.call) if args.call else default_call(now.date()) diff --git a/tests/evalkit.py b/tests/evalkit.py index 5fdb060..aec3b56 100644 --- a/tests/evalkit.py +++ b/tests/evalkit.py @@ -17,6 +17,7 @@ from lib.ecowitt import days, link, source as station from lib.forecast import source as forecast from lib.pollen import source as pollen +from tests.fakes import EVERY_SOURCE from lib.llm import Agent from lib.tools import Tool, Tools @@ -135,7 +136,7 @@ def deterministic(case: Case) -> list[str]: """Failures of the decisions made in code (no model): reasoning effort, fast path, hints, chart choices.""" e, fails = case.expect, [] text = case.ask - r = intent.read(text, NOW, pollen=True, forecast=True) + r = intent.read(text, NOW, EVERY_SOURCE) if "effort" in e and r.effort != e["effort"]: fails.append(f"effort {r.effort}, expected {e['effort']}") if "fast" in e: @@ -167,10 +168,10 @@ async def run_live(case: Case, client, model: str, effort: str | None = None) -> """Ask the real model; returns the tool calls it made and its final reply.""" calls: list[tuple[str, dict]] = [] text = case.ask - r = intent.read(text, NOW, pollen=True, forecast=True) - system = prompt.build(NOW, ["Ecowitt weather station", "AirGradient outdoor air-quality sensor", - "Melbourne pollen forecast and thunderstorm asthma risk (melbournepollen.com.au)", - "Weather forecast for the owner's location (Open-Meteo)"], + r = intent.read(text, NOW, EVERY_SOURCE) + system = prompt.build(NOW, {"Ecowitt": "Ecowitt weather station", "AirGradient": "AirGradient outdoor air-quality sensor", + "Pollen": "Melbourne pollen forecast and thunderstorm asthma risk (melbournepollen.com.au)", + "Forecast": "Weather forecast for the owner's location (Open-Meteo)"}, r.hints, r.about_the_bot, r.rain_caption, found := intent.topics(content(case), [m["content"] for m in case.history if m["role"] == "user"][-2:])) fast = r.fast messages = [*case.history, {"role": "user", "content": content(case)}] diff --git a/tests/fakes.py b/tests/fakes.py index f21761d..15053c3 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -11,6 +11,7 @@ TZ = ZoneInfo("Australia/Melbourne") MAC = "AA:BB:CC:DD:EE:FF" +EVERY_SOURCE = ("Ecowitt", "AirGradient", "Pollen", "Forecast") # every source, by name HISTORY_DAYS = 548 # the fake station is 18 months old: enough for every test, and far quicker to archive than years STEP = {"5min": 300, "30min": 1800, "4hour": 14400, "1day": 86400} diff --git a/tests/test_admin_only.py b/tests/test_admin_only.py index 2da4811..7f331e3 100644 --- a/tests/test_admin_only.py +++ b/tests/test_admin_only.py @@ -289,7 +289,7 @@ async def test_usage_is_the_help_and_alerts_button(tmp_path, kind): shown = lambda sent: [(b, [[(x.text, x.callback_data) for x in row] for row in kw["reply_markup"].inline_keyboard] if kw.get("reply_markup") else None) for b, kw in sent] assert len(from_command) == 2 and shown(from_command) == shown(from_button) - assert from_command[0][0] == templates.capabilities_text(True, False, False, False) + assert from_command[0][0] == templates.capabilities_text(["Ecowitt"]) update, refused = message("/usage", user=STRANGER, kind=kind) await bot.on_usage(update, ctx(tg)) assert [b for b, _ in refused] == [NOT_ADMIN] # gated like the other commands diff --git a/tests/test_agent.py b/tests/test_agent.py index 6030d96..198fba4 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -128,7 +128,7 @@ def test_the_prompt_defines_a_rainy_day_and_how_to_phrase_the_count(): from datetime import datetime from lib import prompt from lib.ecowitt import days - text = prompt.build(datetime(2026, 9, 29, 14, 5), ["Ecowitt"]) + text = prompt.build(datetime(2026, 9, 29, 14, 5), {"Ecowitt": "Ecowitt"}) assert "rain >= 1" in text and "any rain" in text and "It rained on 507 of 1,454 days" in text assert "1 mm or more" in days.DESCRIPTION @@ -136,7 +136,7 @@ def test_the_prompt_defines_a_rainy_day_and_how_to_phrase_the_count(): def test_the_prompt_asks_for_a_footnote_about_hotter_trace_days(): from datetime import datetime from lib import prompt - text = prompt.build(datetime(2026, 9, 29, 14, 5), ["Ecowitt"]) + text = prompt.build(datetime(2026, 9, 29, 14, 5), {"Ecowitt": "Ecowitt"}) assert "trace_rain_days" in text and "footnote" in text @@ -149,7 +149,7 @@ def test_the_day_tool_schema_agrees_with_the_prompt_about_a_rainy_day(): def test_the_day_tool_rules_are_short_separate_bullets(): from datetime import datetime from lib import prompt - text = prompt.build(datetime(2026, 9, 29, 14, 5), ["Ecowitt"]) + text = prompt.build(datetime(2026, 9, 29, 14, 5), {"Ecowitt": "Ecowitt"}) block = text[text.index("- For questions that rank, compare or count DAYS"):text.index("- To ask whether rain comes WITH")] bullets = [line for line in block.splitlines() if line.startswith(" - ")] assert len(bullets) == 9 and all(len(b) < 300 for b in bullets) @@ -161,21 +161,21 @@ def test_the_system_prompt_carries_the_period_hints_only_when_there_are_some(): from datetime import datetime from lib import intent, prompt now = datetime(2026, 9, 29, 14, 5) - with_hint = prompt.build(now, ["Ecowitt"], intent.period_hints("average temp 3m", now)) + with_hint = prompt.build(now, {"Ecowitt": "Ecowitt"}, intent.period_hints("average temp 3m", now)) assert "THE PERSON'S WORDS NAME THESE PERIODS" in with_hint and '"3m" = last 3 months: 2026-07-01' in with_hint - assert "THE PERSON'S WORDS NAME" not in prompt.build(now, ["Ecowitt"], intent.period_hints("hello", now)) + assert "THE PERSON'S WORDS NAME" not in prompt.build(now, {"Ecowitt": "Ecowitt"}, intent.period_hints("hello", now)) def test_the_prompt_says_what_the_bot_can_and_cannot_do_for_the_sources_it_has(): from datetime import datetime from lib import prompt now = datetime(2026, 9, 29, 14, 5) - both = prompt.build(now, ["Ecowitt weather station", "AirGradient outdoor sensor"]) + both = prompt.build(now, {"Ecowitt": "Ecowitt weather station", "AirGradient": "AirGradient outdoor sensor"}) assert "WHAT THIS BOT CAN AND CAN'T DO" in both and "PM2.5 (ยตg/mยณ)" in both and "weather_link" in both assert "Custom alerts" in both and "solar radiation and UV index" in both and "Not available: lightning" in both and "correct the call once" in both - weather_only = prompt.build(now, ["Ecowitt weather station"]) + weather_only = prompt.build(now, {"Ecowitt": "Ecowitt weather station"}) assert "Weather station:" in weather_only and "Air quality (outdoor AirGradient)" not in weather_only - assert "Weather station:" not in prompt.build(now, ["AirGradient outdoor sensor"]) + assert "Weather station:" not in prompt.build(now, {"AirGradient": "AirGradient outdoor sensor"}) async def test_a_question_about_the_bot_itself_gets_no_tools_and_the_hint(): @@ -187,13 +187,13 @@ async def test_a_question_about_the_bot_itself_gets_no_tools_and_the_hint(): require_tool=False, no_tools=True) assert reply.startswith("I track") and client.requests[0]["tool_choice"] == "none" and seen == [] now = datetime(2026, 9, 29, 14, 5) - assert "ABOUT THE BOT ITSELF" in prompt.build(now, ["Ecowitt"], [], True) and "ABOUT THE BOT ITSELF" not in prompt.build(now, ["Ecowitt"]) + assert "ABOUT THE BOT ITSELF" in prompt.build(now, {"Ecowitt": "Ecowitt"}, [], True) and "ABOUT THE BOT ITSELF" not in prompt.build(now, {"Ecowitt": "Ecowitt"}) def test_the_prompt_never_shows_a_temperature_emoji_to_copy_and_says_no_entry_means_no_emoji(): from datetime import datetime from lib import prompt - text = prompt.build(datetime(2026, 9, 29, 14, 5), ["Ecowitt weather station"]) + text = prompt.build(datetime(2026, 9, 29, 14, 5), {"Ecowitt": "Ecowitt weather station"}) assert '"outdoor.temperature": "' not in text and "a reading with no entry gets NO emoji" in text @@ -242,27 +242,23 @@ 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) + text = prompt.build(now, {"Ecowitt": "Ecowitt weather station"}, rain_caption=True) 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"]) + assert "THIS IS A RAIN CHART" not in prompt.build(now, {"Ecowitt": "Ecowitt weather station"}) def test_questions_about_the_bot_are_answered_as_simple_bullet_points(): from datetime import datetime from lib import prompt - text = prompt.build(datetime(2026, 9, 29, 14, 5), ["Ecowitt weather station"], about_bot=True) + text = prompt.build(datetime(2026, 9, 29, 14, 5), {"Ecowitt": "Ecowitt weather station"}, about_bot=True) assert "simple bullet points" in text and "no sub-bullets" in text and "7 at most" in text and "No dates" in text -POLLEN_SOURCE = "Melbourne pollen forecast and thunderstorm asthma risk (melbournepollen.com.au)" -FORECAST_SOURCE = "Weather forecast for the owner's location (Open-Meteo)" - - def test_the_capabilities_mention_the_new_sources_only_when_they_are_on(): from lib import prompt - base = prompt.capabilities(["Ecowitt weather station"]) + base = prompt.capabilities(["Ecowitt"]) assert "pollen and thunderstorm asthma" in base.split("Not available:")[1] and "forecasts (only a short read" in base - both = prompt.capabilities(["Ecowitt weather station", POLLEN_SOURCE, FORECAST_SOURCE]) + both = prompt.capabilities(["Ecowitt", "Pollen", "Forecast"]) assert "tool pollen_asthma" in both and "tool weather_forecast" in both and "thunderstorm asthma risk reaching High" in both assert "pollen" not in both.split("Not available:")[1] and "forecasts" not in both.split("Not available:")[1] @@ -288,19 +284,19 @@ async def handler(args, turn=None): def test_the_capabilities_mention_forecast_revision_alerts_only_with_the_forecast_source(): from lib import prompt - assert "revised" not in prompt.capabilities(["Ecowitt weather station"]) - assert "a forecast it sent being revised" in prompt.capabilities(["Ecowitt weather station", FORECAST_SOURCE]) + assert "revised" not in prompt.capabilities(["Ecowitt"]) + assert "a forecast it sent being revised" in prompt.capabilities(["Ecowitt", "Forecast"]) def test_the_capabilities_say_pollen_only_runs_october_to_december(): from lib import prompt - assert "only run from October to December" in prompt.capabilities(["Ecowitt weather station", POLLEN_SOURCE]) + assert "only run from October to December" in prompt.capabilities(["Ecowitt", "Pollen"]) def test_the_prompt_leaves_the_heads_up_to_the_bot_and_points_at_the_count_tool(): from datetime import datetime from lib import prompt - text = prompt.build(datetime(2026, 9, 29, 14, 5), ["Ecowitt weather station"]) + text = prompt.build(datetime(2026, 9, 29, 14, 5), {"Ecowitt": "Ecowitt weather station"}) assert "the bot warns the chat by itself" in text and "\u26a0" not in text and "decline" not in text.lower() assert "uv_max >= 9" in text and "count_only" in text @@ -334,7 +330,7 @@ async def tiny(args, turn=None): def test_the_prompt_says_to_use_the_whole_record_without_a_period_and_holidays_only_when_asked(): from datetime import datetime from lib import prompt - text = prompt.build(datetime(2026, 9, 29, 14, 5), ["Ecowitt weather station"]) + text = prompt.build(datetime(2026, 9, 29, 14, 5), {"Ecowitt": "Ecowitt weather station"}) assert "use the whole record" in text and "only when the person's words say holidays or weekends" in text diff --git a/tests/test_alerts.py b/tests/test_alerts.py index 841da7a..e369a06 100644 --- a/tests/test_alerts.py +++ b/tests/test_alerts.py @@ -778,7 +778,7 @@ async def test_the_particulates_chart_draws_pm1_pm25_and_pm10_together(tmp_path) transport, _ = air_transport(oldest=datetime.now(timezone.utc) - timedelta(days=5)) air = AirGradient(config(tmp_path), transport=transport) await air.start() - read = intent.read("Particulates chart 1d", now_local(TZ), True, True) + read = intent.read("Particulates chart 1d", now_local(TZ)) turn = Turn(chart_asked=read.chart_asked, text="Particulates chart 1d") await air.handle(read.fast[1], turn) (panel,) = turn.charts[0].panels diff --git a/tests/test_ecowitt.py b/tests/test_ecowitt.py index 44ff54c..e28db56 100644 --- a/tests/test_ecowitt.py +++ b/tests/test_ecowitt.py @@ -1479,7 +1479,7 @@ async def test_a_single_reading_chart_is_labelled_with_the_reading_not_its_group eco, _ = await archived_station(tmp_path, archived_cache) async def lines(text): - read = intent.read(text, now_local(eco.tz), True, True) + read = intent.read(text, now_local(eco.tz)) turn = Turn(chart_asked=read.chart_asked, chart_field=read.chart_field, chart_fields=read.chart_fields, text=text) await eco.tools[1].handler(read.fast[1], turn) (panel,) = turn.charts[0].panels diff --git a/tests/test_intent.py b/tests/test_intent.py index 98fecf9..2b39cf1 100644 --- a/tests/test_intent.py +++ b/tests/test_intent.py @@ -1,12 +1,13 @@ from datetime import datetime, timedelta from lib import intent +from tests.fakes import EVERY_SOURCE NOW = datetime(2026, 9, 29, 14, 5) # a Tuesday -def call(text, ecowitt=True, air=True): - return intent.fast_call(text, NOW, ecowitt, air) +def call(text, sources=("Ecowitt", "AirGradient")): + return intent.fast_call(text, NOW, sources) def test_only_looking_ahead_and_descriptions_reason_a_little(): @@ -66,8 +67,8 @@ def test_air_now_and_air_chart(): def test_sources_are_optional(): - assert call("how's the air?", air=False) is None - assert call("this week's highs", ecowitt=False) is None + assert call("how's the air?", ["Ecowitt"]) is None + assert call("this week's highs", ["AirGradient"]) is None def test_weather_plus_a_period_is_a_summary_request(): @@ -89,7 +90,7 @@ def test_weather_now_fetches_every_reading_the_station_has_and_asks_for_the_repo assert (name, label) == ("weather_now", "weather now") and args == {"groups": intent.NOW_GROUPS}, text assert intent.read(text, NOW).weather_now assert not intent.read("weather last 7 days", NOW).weather_now and not intent.read("report", NOW).weather_now - assert not intent.read("weather now", NOW, ecowitt=False).weather_now + assert not intent.read("weather now", NOW, ["AirGradient"]).weather_now def test_24_hour_weather_charts_over_a_rolling_window(): @@ -345,14 +346,14 @@ def test_ecowitt_means_the_weather_and_ag_or_airgradient_means_the_air_quality() def test_the_report_fetches_everything_in_code_before_the_model_sees_it(): - r = intent.read("report", NOW, True, True, True, True) + r = intent.read("report", NOW, EVERY_SOURCE) assert r.report and r.fast == ("weather_now", {"groups": intent.NOW_GROUPS}, "report") assert r.more == [("air_quality", {}), ("pollen_asthma", {"cached": True}), ("weather_forecast", {"days": 3, "cached": True})] plain = intent.read("sitrep", NOW) assert [plain.fast[0], *(t for t, _ in plain.more)] == ["weather_now", "air_quality"] - only_air = intent.read("status", NOW, ecowitt=False) + only_air = intent.read("status", NOW, ["AirGradient"]) assert only_air.fast[0] == "air_quality" and only_air.more == [] - assert intent.read("report", NOW, ecowitt=False, air=False).fast is None + assert intent.read("report", NOW, []).fast is None assert not intent.read("report for last week", NOW).report @@ -398,7 +399,7 @@ def test_now_alone_is_not_a_weather_question_but_naming_a_reading_with_it_is(): ("temperature today", "extremes", ["temperature"]), ("is it hotter than yesterday", "", []), ("why is it so humid", "", []), ]) def test_plain_lookups_are_answered_in_code_and_everything_else_is_left_to_the_model(text, kind, named): - read = intent.read(text, datetime(2026, 10, 2, 12), True, True, True, True) + read = intent.read(text, datetime(2026, 10, 2, 12), EVERY_SOURCE) assert (read.lookup, read.lookup_arg) == (kind, named) @@ -426,38 +427,38 @@ def test_topics_pick_the_guidance_and_tools_a_question_needs(text, before, has, ("will it rain this week", 7), ("rain later?", 3)]) def test_a_question_about_rain_ahead_has_the_reading_the_last_3_hours_and_the_forecast_fetched_for_the_model(text, days): now = datetime(2026, 10, 3, 12) - read = intent.read(text, now, True, True, False, True) + read = intent.read(text, now, ["Ecowitt", "AirGradient", "Forecast"]) groups = "outdoor,pressure,rainfall,rainfall_piezo,wind" assert read.fast is None and read.extra == [ ("weather_now", {"groups": groups}), ("weather_history", {"groups": groups, "start_date": "2026-10-03 09:00:00", "end_date": "2026-10-03 12:00:00"}), ("weather_forecast", {"days": days, "cached": True})] - no_forecast = intent.read(text, now, True, True, False, False).extra + no_forecast = intent.read(text, now).extra assert [c[0] for c in no_forecast] == ["weather_now", "weather_history"] # forecast off: just the station - assert intent.read(text, now, False, True, False, True).extra == [("weather_forecast", {"days": days, "cached": True})] # no station + assert intent.read(text, now, ["AirGradient", "Forecast"]).extra == [("weather_forecast", {"days": days, "cached": True})] # no station @_pytest.mark.parametrize("text, name, day", [("what was yesterday like?", "yesterday", "2026-10-02"), ("describe today", "today", "2026-10-03")]) def test_describing_today_or_yesterday_has_that_days_readings_fetched_for_the_model(text, name, day): - read = intent.read(text, datetime(2026, 10, 3, 12), True, True, False, False) + read = intent.read(text, datetime(2026, 10, 3, 12)) (tool, args), = read.extra assert tool == "weather_history" and args["groups"] == "outdoor,indoor,rainfall,wind" and args["start_date"].startswith(day) @_pytest.mark.parametrize("text", ["what was it like on Mon 10 Aug", "what was it like at 3pm today", "how hot was yesterday", "describe last week"]) def test_a_named_date_a_moment_a_longer_period_or_a_plain_question_has_nothing_fetched_ahead(text): - assert intent.read(text, datetime(2026, 10, 3, 12), True, True, False, True).extra == [] + assert intent.read(text, datetime(2026, 10, 3, 12), ["Ecowitt", "AirGradient", "Forecast"]).extra == [] @_pytest.mark.parametrize("text", ["how much rain fell today", "how much rain this week", "did it rain yesterday", "forecast", "rain chart 7d"]) def test_questions_about_rain_so_far_do_not_fetch_the_forecast(text): - assert intent.read(text, datetime(2026, 10, 3, 12), True, True, False, True).extra == [] + assert intent.read(text, datetime(2026, 10, 3, 12), ["Ecowitt", "AirGradient", "Forecast"]).extra == [] @_pytest.mark.parametrize("text", ["how many times did it rain this year", "worst air quality day per month", "days over 35 and also rained", "days over UVI 10 on weekends"]) def test_counting_and_totalling_questions_are_never_turned_into_a_plain_chart(text): - read = intent.read(text, datetime(2026, 10, 3, 12), True, True, True, True) + read = intent.read(text, datetime(2026, 10, 3, 12), EVERY_SOURCE) assert read.fast is None and not read.chart_in_code and read.lookup == "" @@ -473,7 +474,7 @@ def test_counting_and_totalling_questions_are_never_turned_into_a_plain_chart(te ("how many days was PM2.5 over 25 in 2024", "air_days", "pm2_5_max", ">", 25, None), ]) def test_one_reading_against_one_limit_is_counted_in_code(text, tool, field, op, value, group): - read = intent.read(text, datetime(2026, 10, 3, 12), True, True, False, False) + read = intent.read(text, datetime(2026, 10, 3, 12)) name, args, _ = read.fast assert read.lookup == "days" and name == tool and args["count_only"] is True and args.get("group_by") == group assert args["where"] == [{"field": field, "op": op, "value": float(value)}] @@ -481,11 +482,11 @@ def test_one_reading_against_one_limit_is_counted_in_code(text, tool, field, op, def test_the_period_is_the_whole_record_unless_one_is_named(): now = datetime(2026, 10, 3, 12) - whole = intent.read("days over UVI 10 by year", now, True, True).fast[1] + whole = intent.read("days over UVI 10 by year", now).fast[1] assert whole["start_date"] == "2022-10-05" and whole["end_date"] == "2026-10-03" - named = intent.read("days over UVI 10 by month this year", now, True, True).fast[1] + named = intent.read("days over UVI 10 by month this year", now).fast[1] assert named["start_date"] == "2026-01-01" and named["group_by"] == "month" - year = intent.read("how many days was PM2.5 over 25 in 2024", now, True, True).fast[1] + year = intent.read("how many days was PM2.5 over 25 in 2024", now).fast[1] assert year["start_date"] == "2024-01-01" and year["end_date"] == "2024-12-31" @@ -501,7 +502,7 @@ def test_the_period_is_the_whole_record_unless_one_is_named(): ("average co2 per year", "air_days", "avg", "co2_avg", "year"), ]) def test_one_reading_by_month_or_year_is_a_figure_per_period_worked_out_in_code(text, tool, stat, of, group): - read = intent.read(text, datetime(2026, 10, 3, 12), True, True, False, False) + read = intent.read(text, datetime(2026, 10, 3, 12)) name, args, _ = read.fast assert read.lookup == "days" and (name, args["stat"], args["of"], args["group_by"]) == (tool, stat, of, group) and args["count_only"] is True @@ -509,14 +510,14 @@ def test_one_reading_by_month_or_year_is_a_figure_per_period_worked_out_in_code( @_pytest.mark.parametrize("text", ["pm2.5 and co2 by month", "rain by month on weekends", "air quality by month", "will it rain by month", "days over 30 degrees and rain per month", "rain days per month"]) def test_anything_beyond_one_reading_by_month_or_year_is_left_to_the_model(text): - read = intent.read(text, datetime(2026, 10, 3, 12), True, True, False, False) + read = intent.read(text, datetime(2026, 10, 3, 12)) assert read.lookup == "" and (read.fast is None or read.fast[0] not in ("weather_days", "air_days")) def test_weather_all_week_is_flagged_as_a_chart_with_no_caption_and_a_named_chart_is_not(): now = datetime(2026, 10, 3, 12) - assert intent.read("weather all week", now, True, True).chart_all and intent.read("ecowitt all week", now, True, True).chart_all - assert not intent.read("plot temperature and rain", now, True, True).chart_all and not intent.read("temperature chart 7d", now, True, True).chart_all + assert intent.read("weather all week", now).chart_all and intent.read("ecowitt all week", now).chart_all + assert not intent.read("plot temperature and rain", now).chart_all and not intent.read("temperature chart 7d", now).chart_all @_pytest.mark.parametrize("ask", ["weather chart 90d", "chart all 90d", "chart 90d", "chart"]) @@ -535,8 +536,8 @@ def test_a_named_reading_or_an_air_chart_is_not_every_reading_and_indoors_has_on def test_a_phone_s_curly_apostrophe_reads_as_the_plain_one(): for plain in ("what's the weather", "what's the temperature", "show what you've got", "what's the forecast"): curly = plain.replace("'", "โ€™") - assert intent.read(curly, NOW, pollen=True, forecast=True).fast == intent.read(plain, NOW, pollen=True, forecast=True).fast, curly - assert intent.read(curly, NOW, pollen=True, forecast=True).fast is not None, curly + assert intent.read(curly, NOW, EVERY_SOURCE).fast == intent.read(plain, NOW, EVERY_SOURCE).fast, curly + assert intent.read(curly, NOW, EVERY_SOURCE).fast is not None, curly def test_all_time_and_every_day_are_periods_not_every_air_reading(): @@ -560,10 +561,10 @@ def test_indoor_dew_point_and_feels_like_come_from_the_indoor_sensor(): def test_the_lowest_uv_per_month_is_the_lowest_daily_high_not_the_highest(): - tool, args, _ = intent.period_figures_call("lowest uv by month", NOW, True, False) + tool, args, _ = intent.period_figures_call("lowest uv by month", NOW, ["Ecowitt"]) assert (tool, args["stat"], args["of"], args["group_by"]) == ("weather_days", "min", "uv_max", "month") - assert intent.period_figures_call("uv by month", NOW, True, False)[1]["stat"] == "max" # a UV index is its day's high - assert intent.period_figures_call("lowest wind by month", NOW, True, False) is None # wind has no lowest to ask for + assert intent.period_figures_call("uv by month", NOW, ["Ecowitt"])[1]["stat"] == "max" # a UV index is its day's high + assert intent.period_figures_call("lowest wind by month", NOW, ["Ecowitt"]) is None # wind has no lowest to ask for def test_the_air_table_is_the_one_in_series_and_panels_imports_on_its_own(): diff --git a/tests/test_pollen.py b/tests/test_pollen.py index c921a58..2a919a8 100644 --- a/tests/test_pollen.py +++ b/tests/test_pollen.py @@ -133,10 +133,10 @@ def test_pollen_words_need_data_and_a_bare_pollen_question_is_fetched_without_th from lib import intent now = datetime(2026, 10, 1, 12, 0) for text in ("pollen", "Hay fever", "thunderstorm asthma risk"): - assert intent.read(text, now, True, True, True).fast[0] == "pollen_asthma" - assert intent.read("pollen", now, True, True, False).fast is None + assert intent.read(text, now, ["Ecowitt", "AirGradient", "Pollen"]).fast[0] == "pollen_asthma" + assert intent.read("pollen", now).fast is None for text in ("is the pollen bad today?", "any asthma risk"): - assert intent.read(text, now, True, True, True).needs_data + assert intent.read(text, now, ["Ecowitt", "AirGradient", "Pollen"]).needs_data def make_on(tmp_path, day, hour=12, html=None): diff --git a/tests/test_snapshots.py b/tests/test_snapshots.py index d9f7c3b..101f76a 100644 --- a/tests/test_snapshots.py +++ b/tests/test_snapshots.py @@ -103,7 +103,7 @@ async def test_the_report_always_uses_the_cache_for_pollen_and_forecast(tmp_path pollen = Pollen(config(tmp_path, pollen=True), pollen_transport(lambda: page(), calls)) forecast = Forecast(config(tmp_path, forecast=True, forecast_lat=-37.8, forecast_lon=144.9), None, forecast_transport(calls)) assert not pollen.wants("report") and not forecast.wants("sitrep") and pollen.wants("pollen today") and forecast.wants("forecast") - for tool, args in intent.report_calls(False, False, True, True): + for tool, args in intent.report_calls(["Pollen", "Forecast"]): out = json.loads(await {"pollen_asthma": pollen.handle, "weather_forecast": forecast.handle}[tool](args)) assert "error" in out # nothing cached yet: the report goes without it, no request made assert calls == [] diff --git a/tests/test_templates.py b/tests/test_templates.py index b4cc3e0..fff2fe3 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -3,7 +3,7 @@ from lib import intent, templates from lib.bot import Bot -from tests.fakes import TZ +from tests.fakes import EVERY_SOURCE, TZ NOW = datetime(2026, 10, 1, 12, 0, tzinfo=TZ) @@ -18,7 +18,7 @@ def test_a_button_label_is_its_sentence_without_the_emoji_and_typed_text_is_left def test_every_button_asks_something_the_bot_understands(): def read(label): - return intent.read(templates.sentence(label), NOW, True, True) + return intent.read(templates.sentence(label), NOW) assert read("\U0001f4cb Status").report weather = read("\U0001f326๏ธ Weather") assert weather.chart_all and weather.chart_in_code and weather.chart_fields[0] == "temperature" and weather.chart_fields[-1] == "wind" @@ -212,8 +212,8 @@ def test_the_capabilities_list_is_the_users_bullets(): assert templates.capabilities_text() == "\n".join([ "\u2022 Temperature, humidity (indoor, outdoor)", "\u2022 Dew point, vapour pressure deficit, pressure, wind speed", "\u2022 Rain, solar radiation, UV index", "\u2022 PM1, PM2.5, PM10, COโ‚‚, VOC, NOx", "\u2022 History charts"]) - assert len(templates.capabilities_text(air=False).splitlines()) == 4 and len(templates.capabilities_text(weather=False).splitlines()) == 2 - full = templates.capabilities_text(pollen=True, forecast=True).splitlines() + assert len(templates.capabilities_text(["Ecowitt"]).splitlines()) == 4 and len(templates.capabilities_text(["AirGradient"]).splitlines()) == 2 + full = templates.capabilities_text(EVERY_SOURCE).splitlines() assert len(full) == 7 and full[4:] == ["\u2022 Pollen and thunderstorm asthma risk (October to December)", "\u2022 Forecast", "\u2022 History charts"] @@ -223,5 +223,5 @@ def test_the_capabilities_list_is_the_users_bullets(): def test_the_capabilities_list_adds_pollen_and_forecast_only_when_they_are_on(): plain = templates.capabilities_text() assert "Pollen" not in plain and "Forecast" not in plain - full = templates.capabilities_text(pollen=True, forecast=True).splitlines() + full = templates.capabilities_text(EVERY_SOURCE).splitlines() assert full[-3:] == ["โ€ข Pollen and thunderstorm asthma risk (October to December)", "โ€ข Forecast", "โ€ข History charts"] From c6345d25cda1d37a3f6af7194ea758dd709e996c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 18:59:51 +0000 Subject: [PATCH 40/65] Tool calls made in one place; a question's Turn built in one place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Agent.run made the fast path's calls and the model's calls with two copies of one block: time them, append each result, check for a heavy question. Both now go through run_calls, and _tool_call is the form a call takes in the assistant message. - A repeated call was recognised only when its arguments were the same text, so {"groups": "outdoor", "chart": true} and {"chart": true, "groups": "outdoor"} were both made. The repeat cache now compares the arguments as parsed JSON (_same): the second gets the first's result with the "you already made this exact call" note. A new test covers it. - Tools.call_all makes the bot's own calls together; bot.py's two gathers use it. The tests' stand-ins for Tools get it from StandInTools (tests/fakes.py). - Turn.of(read, text) is the Turn for a question as intent.read read it. It replaces two field-by-field copies in bot.py and one in alerts/pictures.py; the pictures copy left out average_asked, readings and per_day. For the alert charts' requests those are False, the reading's name, and False. readings only picks what the model is shown, so every alert chart and its tool result is the same either way: a check drew each with both Turns. lib: +49 โˆ’30 lines: the shared helpers with their docstrings, and the repeat fix. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/alerts/pictures.py | 2 +- lib/bot.py | 10 +++----- lib/llm.py | 57 +++++++++++++++++++++++++----------------- lib/tools.py | 10 ++++++++ tests/fakes.py | 6 +++++ tests/test_agent.py | 5 ++++ tests/test_bot.py | 14 +++++------ tests/test_periods.py | 4 +-- 8 files changed, 69 insertions(+), 39 deletions(-) diff --git a/lib/alerts/pictures.py b/lib/alerts/pictures.py index 219b499..b24db9d 100644 --- a/lib/alerts/pictures.py +++ b/lib/alerts/pictures.py @@ -18,7 +18,7 @@ async def chart(source, text: str, tz) -> bytes | None: try: read = intent.read(text, now_local(tz)) tool, args = read.fast[0], read.fast[1] - turn = Turn(chart_asked=read.chart_asked, chart_field=read.chart_field, chart_fields=read.chart_fields, text=text) + turn = Turn.of(read, text) handler = next(t for t in source.tools if t.name == tool).handler await handler(args, turn) return await asyncio.to_thread(render, turn.charts[0], tz) diff --git a/lib/bot.py b/lib/bot.py index c6d9408..7196bb6 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -754,7 +754,7 @@ def _content(self, msg: Message, text: str, bot_id: int) -> str: async def _written_in_code(self, read, turn: Turn) -> str: """The report ("weather now") from the fast path's tool calls, made together; lib/report.py lays it out.""" calls = [read.fast[:2], *read.more] - results = await asyncio.gather(*(self.agent.tools.call(name, json.dumps(args), turn) for name, args in calls)) + results = await self.agent.tools.call_all(calls, turn) by_tool = {name: result for (name, _), result in zip(calls, results)} if read.report: return report.report(by_tool, now_local(self.cfg.tz).date()) @@ -771,7 +771,7 @@ async def _chart_in_code(self, read, turn: Turn) -> str | None: """A chart asked for plainly: the tool draws it and the caption is written here. None (the model then takes the 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)) + results = await self.agent.tools.call_all(calls, turn) caption = report.chart_caption(read.fast[0], read.fast[1], results[0], turn, self.cfg.tz) if caption is None: turn.charts.clear() @@ -813,8 +813,7 @@ async def _draw_ahead(self, text: str, now) -> tuple[str, bytes, str, str] | Non key = self.images.canonical(text, now) if not (read.fast and read.chart_in_code and key): # not a chart made in code ("Report"), or a source that isn't there return None - turn = Turn(chart_asked=read.chart_asked, chart_field=read.chart_field, chart_fields=read.chart_fields, - average_asked=read.average_asked, readings=read.readings, per_day=read.per_day, text=text, ahead=True) + turn = Turn.of(read, text, ahead=True) caption = await asyncio.wait_for(self._chart_in_code(read, turn), TURN_SECONDS) if caption is None or len(turn.charts) != 1: raise ValueError("no chart came of it") @@ -942,8 +941,7 @@ async def _respond(self, update: Update, context: ContextTypes.DEFAULT_TYPE, tex new_from = len(working) ok, photos, titles, silent = True, [], [], False made = None # how long the tool took when the chart is made in code, for the timing line - turn = Turn(chart_asked=read.chart_asked, chart_field=read.chart_field, chart_fields=read.chart_fields, - average_asked=read.average_asked, readings=read.readings, per_day=read.per_day, text=text) + turn = Turn.of(read, text) ready = self.images.ready(text, now) if read.fast and read.chart_in_code and redraw is None else None try: reply = None diff --git a/lib/llm.py b/lib/llm.py index 754560b..8efc35e 100644 --- a/lib/llm.py +++ b/lib/llm.py @@ -82,12 +82,13 @@ async def run(self, messages: list[dict], system_prompt: str, effort: str, cache: dict = {} # identical tool calls within one question are only made once async def call(name: str, args: str) -> str: - if (name, args) not in cache: - cache[(name, args)] = asyncio.ensure_future(self.tools.call(name, args, turn)) - return await cache[(name, args)] + key = (name, _same(args)) + if key not in cache: + cache[key] = asyncio.ensure_future(self.tools.call(name, args, turn)) + return await cache[key] log.info("Tool call %s served from cache (repeat)", name) return ("[You already made this exact call - same result as before. Don't repeat it; " - "change the request or answer now.]\n" + await cache[(name, args)]) + "change the request or answer now.]\n" + await cache[key]) schemas = self.tools.schemas_for(tool_names) # only the tools the question can use: each definition is sent on every call warned = False @@ -102,18 +103,23 @@ async def heavy_check(): await on_heavy(tokens) llm_time = tool_time = 0.0 + + async def run_calls(calls: list[dict]): + """The calls ({"id", "name", "arguments"}), made together; their results join the messages.""" + nonlocal tool_time + t0 = time.monotonic() + results = await asyncio.gather(*(call(c["name"], c["arguments"]) for c in calls)) + tool_time += time.monotonic() - t0 + messages.extend({"role": "tool", "tool_call_id": c["id"], "content": r} for c, r in zip(calls, results)) + await heavy_check() + first_step = 0 try: if first_call: - fast = [(n, json.dumps(a)) for n, a in (first_call if isinstance(first_call, list) else [first_call])] - messages.append({"role": "assistant", "content": "", "tool_calls": [ - {"id": f"fast_{i}", "type": "function", "function": {"name": n, "arguments": a}} - for i, (n, a) in enumerate(fast, 1)]}) - t0 = time.monotonic() - results = await asyncio.gather(*(call(n, a) for n, a in fast)) - tool_time += time.monotonic() - t0 - messages.extend({"role": "tool", "tool_call_id": f"fast_{i}", "content": r} for i, r in enumerate(results, 1)) - await heavy_check() + fast = [{"id": f"fast_{i}", "name": n, "arguments": json.dumps(a)} + for i, (n, a) in enumerate(first_call if isinstance(first_call, list) else [first_call], 1)] + messages.append({"role": "assistant", "content": "", "tool_calls": [_tool_call(c) for c in fast]}) + await run_calls(fast) first_step = 1 # data is in; the model just answers (and may still call tools) for step in range(first_step, MAX_STEPS + 1): final = step == MAX_STEPS # out of steps: force an answer from what we have @@ -134,23 +140,28 @@ async def heavy_check(): llm_time += secs entry = {"role": "assistant", "content": content} if tool_calls and not final: - entry["tool_calls"] = [{"id": tc["id"], "type": "function", - "function": {"name": tc["name"], "arguments": tc["arguments"]}} - for tc in tool_calls] + entry["tool_calls"] = [_tool_call(tc) for tc in tool_calls] messages.append(entry) if not tool_calls or final: return content.strip() or "Sorry, I couldn't get an answer together - try a narrower question." - - t0 = time.monotonic() - results = await asyncio.gather(*(call(tc["name"], tc["arguments"]) for tc in tool_calls)) - tool_time += time.monotonic() - t0 - for tc, result in zip(tool_calls, results): - messages.append({"role": "tool", "tool_call_id": tc["id"], "content": result}) - await heavy_check() + await run_calls(tool_calls) finally: log.info("Timing: LLM %.1fs, tools %.1fs", llm_time, tool_time) +def _tool_call(c: dict) -> dict: + """A call ({"id", "name", "arguments"}) as an assistant message carries it.""" + return {"id": c["id"], "type": "function", "function": {"name": c["name"], "arguments": c["arguments"]}} + + +def _same(args: str) -> str: + """A call's arguments in one form whatever their order and spacing, so a repeat is known; text that isn't JSON as it is.""" + try: + return json.dumps(json.loads(args), sort_keys=True) + except ValueError: + return args + + def strip_tool_turns(messages: list[dict]) -> list[dict]: """Keep only the questions and final answers, dropping tool calls and raw tool results, so later questions can't reuse old data (and requests stay small).""" diff --git a/lib/tools.py b/lib/tools.py index 17c6799..0dc7dfe 100644 --- a/lib/tools.py +++ b/lib/tools.py @@ -35,6 +35,12 @@ class Turn: times: dict = field(default_factory=dict) # seconds spent by step ("data", "draw", "png"), for the reply's timing line in the log ahead: bool = False # a chart drawn ahead, nobody waiting: its requests wait their turn and its routine log lines are quiet + @classmethod + def of(cls, read, text: str, **more) -> "Turn": + """The Turn for a question as intent.read read it (`read`): what its words ask of a chart and of the figures.""" + return cls(chart_asked=read.chart_asked, chart_field=read.chart_field, chart_fields=read.chart_fields, + average_asked=read.average_asked, readings=read.readings, per_day=read.per_day, text=text, **more) + @dataclass class Tool: @@ -83,3 +89,7 @@ async def call(self, name: str, raw_args: str, turn: Turn | None = None) -> str: if turn: turn.result_chars += len(out) return out + + async def call_all(self, calls: list[tuple[str, dict]], turn: Turn | None = None) -> list[str]: + """The bot's own calls (name, arguments), made together; their results in the same order.""" + return await asyncio.gather(*(self.call(name, json.dumps(args), turn) for name, args in calls)) diff --git a/tests/fakes.py b/tests/fakes.py index 15053c3..9df3d4a 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -8,6 +8,7 @@ import httpx from lib.config import Config +from lib.tools import Tools TZ = ZoneInfo("Australia/Melbourne") MAC = "AA:BB:CC:DD:EE:FF" @@ -16,6 +17,11 @@ STEP = {"5min": 300, "30min": 1800, "4hour": 14400, "1day": 86400} +class StandInTools: + """A test's stand-in for lib.tools.Tools: it answers call(name, raw, turn) itself, and call_all is the real one over it.""" + call_all = Tools.call_all + + def config(tmp_path, **over) -> Config: base = dict(telegram_token="1:x", xai_api_key="k", xai_base_url="http://x", xai_model="m", tz=TZ, ecowitt_api_key="a", ecowitt_app_key="b", airgradient_token="t", airgradient_location="42", diff --git a/tests/test_agent.py b/tests/test_agent.py index 198fba4..5eed8a7 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -63,6 +63,11 @@ async def test_chat_needs_no_tools_and_identical_calls_are_made_once(): client = FakeLLM([[("weather_now", {}), ("weather_now", {})], "done"]) await llm.Agent(client, "m", t).run([{"role": "user", "content": "x"}], "s", "none") assert len(seen) == 1 + t, seen = tools() # the same arguments in another order are the same call + client = FakeLLM([[("weather_now", {"groups": "outdoor", "chart": True}), ("weather_now", {"chart": True, "groups": "outdoor"})], "done"]) + msgs = [{"role": "user", "content": "x"}] + await llm.Agent(client, "m", t).run(msgs, "s", "none") + assert len(seen) == 1 and "You already made this exact call" in msgs[-2]["content"] def test_history_keeps_questions_and_answers_only(): diff --git a/tests/test_bot.py b/tests/test_bot.py index 22bddbc..ba743b4 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -7,7 +7,7 @@ from lib import intent from lib.bot import Bot from lib.tools import Turn -from tests.fakes import TZ +from tests.fakes import TZ, StandInTools class RaisingAgent: @@ -191,7 +191,7 @@ async def reply_text(body, **kw): async def test_a_report_is_written_in_code_from_the_fast_path_calls_and_the_model_is_not_asked(alert_state): import json - class Tools: + class Tools(StandInTools): def __init__(self): self.called = [] @@ -271,7 +271,7 @@ async def test_a_plain_chart_request_is_drawn_and_captioned_in_code_and_the_mode from lib import bot as botmod from lib.specs import Chart, Line, Panel - class Tools: + class Tools(StandInTools): calls = [] async def call(self, name, raw, turn=None): @@ -311,7 +311,7 @@ async def test_a_chart_made_in_code_starts_no_refresh_and_other_questions_do(ale from lib.specs import Chart, Line, Panel pokes = [] - class Tools: + class Tools(StandInTools): async def call(self, name, raw, turn=None): turn.charts.append(Chart("t", "", [Panel("Temperature", "ยฐC", [Line("Outdoor", [1, 2], [1.0, 2.0])])])) return json.dumps({"period": "Fri 25 Sep 2026 - Thu 01 Oct 2026", "series": { @@ -381,7 +381,7 @@ async def _ask_with(alert_state, tool_result, text, monkeypatch=None): """Ask `text` of a bot whose one tool returns `tool_result`; returns (replies, whether the model was asked).""" asked = [] - class Tools: + class Tools(StandInTools): async def call(self, name, raw, turn=None): return tool_result @@ -556,7 +556,7 @@ async def test_weather_all_week_and_a_plain_chart_are_sent_with_just_their_title from lib import bot as botmod from lib.specs import Chart, Line, Panel - class Tools: + class Tools(StandInTools): async def call(self, name, raw, turn=None): turn.charts.append(Chart("t", "", [Panel("Temperature", "ยฐC", [Line("Outdoor", [1, 2], [1.0, 2.0])])])) return json.dumps({"period": "Fri 25 Sep 2026 - Thu 01 Oct 2026", "series": { @@ -589,7 +589,7 @@ async def test_the_report_carries_a_refresh_button_that_redraws_it_in_place(aler import json from lib import report_menu - class Tools: + class Tools(StandInTools): reading = "12.3 โ„ƒ" async def call(self, name, raw, turn=None): diff --git a/tests/test_periods.py b/tests/test_periods.py index d9f4156..2ea63a3 100644 --- a/tests/test_periods.py +++ b/tests/test_periods.py @@ -6,7 +6,7 @@ from lib import intent, periods from lib.bot import Bot from lib.specs import Chart, Line, Panel -from tests.fakes import TZ +from tests.fakes import TZ, StandInTools NOW = datetime(2026, 10, 2, 12, 0) @@ -47,7 +47,7 @@ def test_the_questions_behind_charts_survive_a_restart(tmp_path): assert again.charted.get(1, 101) == "Temperature chart 7d" -class Tools: +class Tools(StandInTools): def __init__(self): self.calls = [] From 90f1ab2ab47437a2afdc2cd24604e351cfd306f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 19:04:34 +0000 Subject: [PATCH 41/65] Telegram entities in one module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/entities.py holds a message's formatting as plain text plus entities: - utf16_len, the length Telegram places entities by. There were three copies, in the alerts' notifier, the irrigation menu and the report. - footer(), a line under the text, in italic or bold, optionally linked. It now builds both footers: the alerts' link line (with_link, which the bot's replies use too) and the irrigation menu's bold error. bot.deliver no longer imports from the alerts package for its link line. with_footer's `extra` parameter, which only a test used, goes. Behaviour is unchanged: over texts with emoji and trailing space, with and without a link, the old and new builders give the same text and entities (42 of 42 cases). The test of the link footer now also checks its type, length, and the text without a link; it caught a slip while writing this. lib: +27 โˆ’35 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/alerts/__init__.py | 4 ++-- lib/alerts/notify.py | 27 +++------------------------ lib/bot.py | 5 +++-- lib/entities.py | 21 +++++++++++++++++++++ lib/irrigation/menu.py | 8 +++----- lib/report.py | 7 +++---- tests/test_alerts.py | 13 +++++++------ 7 files changed, 42 insertions(+), 43 deletions(-) create mode 100644 lib/entities.py diff --git a/lib/alerts/__init__.py b/lib/alerts/__init__.py index c8405d4..02dc94a 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 import IrrigationMonitor -from .notify import AlertState, Notifier, with_footer +from .notify import AlertState, Notifier from .pollen_and_asthma import PollenAndAsthmaMonitor from .weather import WeatherMonitor -__all__ = ["PARTICULATES_CHECK_SECONDS", "ParticulatesMonitor", "AlertState", "ForecastMonitor", "IrrigationMonitor", "Notifier", "PollenAndAsthmaMonitor", "WeatherMonitor", "with_footer"] +__all__ = ["PARTICULATES_CHECK_SECONDS", "ParticulatesMonitor", "AlertState", "ForecastMonitor", "IrrigationMonitor", "Notifier", "PollenAndAsthmaMonitor", "WeatherMonitor"] diff --git a/lib/alerts/notify.py b/lib/alerts/notify.py index 9c1e9f4..9dc4b60 100644 --- a/lib/alerts/notify.py +++ b/lib/alerts/notify.py @@ -15,9 +15,9 @@ from collections.abc import Collection from datetime import date, timedelta -from telegram import MessageEntity from telegram.error import BadRequest, ChatMigrated, Forbidden, NetworkError, RetryAfter, TelegramError, TimedOut +from ..entities import with_link from ..logs import QUOTES from .menu import ALL, LABELS, alert_keyboard @@ -206,27 +206,6 @@ def alert_chats(self, kind: str | None = None) -> list[int]: return [c for c in self.chats if self.subscribed(c, kind)] -def _utf16_len(text: str) -> int: - """Telegram measures entity positions in UTF-16 code units (emoji count as 2).""" - return len(text.encode("utf-16-le")) // 2 - - -def with_footer(text: str, link: tuple[str, str] | None = None, extra: str | None = None) -> tuple[str, list]: - """Add a small italic footer line directly under the text: the link's label (clickable), - then any extra text, joined by " ยท ". Plain text stays plain (no HTML to escape); - only the footer is formatted. link = (label, url).""" - text = text.rstrip() - parts = ([link[0]] if link else []) + ([extra] if extra else []) - if not parts: - return text, [] - start = _utf16_len(text) + 1 - footer = " ยท ".join(parts) - entities = [MessageEntity(MessageEntity.ITALIC, start, _utf16_len(footer))] - if link: - entities.append(MessageEntity(MessageEntity.TEXT_LINK, start, _utf16_len(link[0]), url=link[1])) - return f"{text}\n{footer}", entities - - class Notifier: def __init__(self, bot, state: AlertState): self.bot, self.state = bot, state @@ -236,7 +215,7 @@ async def __call__(self, text: str, link: tuple[str, str] | None = None, kind: s gone. link = (label, url) adds a clickable label to the footer. Each carries the settings buttons. photo: a picture the text is the caption of (the text alone if Telegram refuses the picture): a PNG, or the file id of one Telegram holds. It is uploaded once: the chats after the first are sent Telegram's copy.""" - text, entities = with_footer(text, link) + text, entities = with_link(text, link) started, sent = time.monotonic(), 0 for chat_id in self.state.alert_chats(kind): ok, message = await self._send(chat_id, text, entities, kind, photo) @@ -250,7 +229,7 @@ async def to_chat(self, chat_id: int, text: str, link: tuple[str, str] | None = True if it was sent.""" if not self.state.subscribed(chat_id, kind): return False - text, entities = with_footer(text, link) + text, entities = with_link(text, link) sent, _ = await self._send(chat_id, text, entities, kind) log.info("Alert %s chat %s: %s", "sent to" if sent else "NOT sent to", chat_id, text, extra=QUOTES) return sent diff --git a/lib/bot.py b/lib/bot.py index 7196bb6..584a36e 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -23,12 +23,13 @@ from . import intent, periods, prompt, report, report_menu, templates from .admins import Admins from .alerts import menu -from .alerts import AlertState, with_footer +from .alerts import AlertState from .irrigation import IrrigationError, Watchdog from .irrigation import menu as irrigation_menu from .charts import render as render_chart from .tools import Turn from .config import Config +from .entities import with_link from .sources import Source from .timeutil import now_local from .llm import Agent, shorten_old, strip_tool_turns, trim_history @@ -214,7 +215,7 @@ async def deliver(msg: Message, text: str, photos: list[bytes], link: tuple[str, chunks = split_message(text) for i, chunk in enumerate(chunks): last = i == len(chunks) - 1 - body, ents = with_footer(chunk, link) if last else (chunk, []) + body, ents = with_link(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 {})) diff --git a/lib/entities.py b/lib/entities.py new file mode 100644 index 0000000..99b7f7e --- /dev/null +++ b/lib/entities.py @@ -0,0 +1,21 @@ +"""A Telegram message's formatting as plain text plus entities (bold, italic, a link) rather than markup, so nothing in the text +needs escaping. Telegram places an entity in UTF-16 code units, where an emoji is two.""" + +from telegram import MessageEntity + + +def utf16_len(text: str) -> int: + """The length of `text` as Telegram counts it.""" + return len(text.encode("utf-16-le")) // 2 + + +def footer(text: str, line: str, style: str = MessageEntity.ITALIC, url: str | None = None) -> tuple[str, list[MessageEntity]]: + """`text` with `line` under it, set in `style` (italic: a small footer; bold: an error) and linked to `url` if there is one.""" + start, length = utf16_len(text) + 1, utf16_len(line) + links = [MessageEntity(MessageEntity.TEXT_LINK, start, length, url=url)] if url else [] + return f"{text}\n{line}", [MessageEntity(style, start, length), *links] + + +def with_link(text: str, link: tuple[str, str] | None) -> tuple[str, list[MessageEntity]]: + """`text`, trimmed, with the link (label, url) under it as a small italic footer, if there is one.""" + return footer(text.rstrip(), link[0], url=link[1]) if link else (text.rstrip(), []) diff --git a/lib/irrigation/menu.py b/lib/irrigation/menu.py index 01e6221..d4389a9 100644 --- a/lib/irrigation/menu.py +++ b/lib/irrigation/menu.py @@ -17,6 +17,7 @@ from telegram import InlineKeyboardMarkup, MessageEntity from ..buttons import button, opener, toggle_button +from ..entities import footer TITLE = "๐ŸŒฑ Irrigation" WATER_MINUTES = (5, 10, 20, 30) @@ -51,11 +52,8 @@ def running_in(text: str | None) -> bool: def with_error(text: str, error: str = PAUSED_ERROR) -> tuple[str, list[MessageEntity]]: - """The status with an error under it as a bold footer. Plain text plus entities (no markup to escape); Telegram counts - positions in UTF-16 code units, where an emoji is two.""" - units = lambda s: len(s.encode("utf-16-le")) // 2 - head = f"{text}\n\n" - return head + error, [MessageEntity(MessageEntity.BOLD, units(head), units(error))] + """The status with an error under it, after a blank line, in bold.""" + return footer(f"{text}\n", error, MessageEntity.BOLD) def status_text(status: dict, alerts: bool | None = None, pause_alerts: bool | None = None) -> str: diff --git a/lib/report.py b/lib/report.py index 09fa5c1..2fc357c 100644 --- a/lib/report.py +++ b/lib/report.py @@ -8,6 +8,7 @@ from telegram import MessageEntity from .ecowitt.glance import solar_band +from .entities import utf16_len from .series import AIR, INDOOR_READINGS, WEATHER, find from .timeutil import clock_text, day_text, to_local @@ -52,10 +53,8 @@ def _join(parts: list[str | None], sep: str = ", ") -> str: 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)] + """Bold entities over the name of each heading line in `text` (not over its brackets).""" + return [MessageEntity(MessageEntity.BOLD, utf16_len(text[:m.start()]), utf16_len(m.group(1))) for m in HEADING_LINE.finditer(text)] def _stamp(result: dict, today: date) -> str | None: diff --git a/tests/test_alerts.py b/tests/test_alerts.py index e369a06..60f86d7 100644 --- a/tests/test_alerts.py +++ b/tests/test_alerts.py @@ -4,7 +4,8 @@ import pytest from lib.airgradient import Latest -from lib.alerts import ParticulatesMonitor, AlertState, Notifier, WeatherMonitor, with_footer +from lib.alerts import ParticulatesMonitor, AlertState, Notifier, WeatherMonitor +from lib.entities import with_link from tests.fakes import TZ T0 = int(datetime(2026, 9, 29, 13, 0, tzinfo=TZ).timestamp()) # 1pm Melbourne, a settled day @@ -280,12 +281,12 @@ def test_alert_state_survives_restarts_and_opt_out(tmp_path): assert again.alert_chats() == [1] and again.monitor["rain"]["alerted_state"] == "raining" and not again.alerts_on(2) -def test_footer_marks_up_link_and_mute_hint(): - text, entities = with_footer("\U0001f327๏ธ Rain", ("live chart", "https://x"), "/alerts off to mute") - assert text.endswith("live chart ยท /alerts off to mute") +def test_the_footer_is_the_links_label_in_italic_placed_in_utf16_units(): + text, entities = with_link("\U0001f327๏ธ Rain \n", ("live chart", "https://x")) + assert text == "\U0001f327๏ธ Rain\nlive chart" and with_link("Rain \n", None) == ("Rain", []) italic, link = entities - assert italic.offset == link.offset == len("\U0001f327๏ธ Rain".encode("utf-16-le")) // 2 + 1 - assert link.length == len("live chart") and link.url == "https://x" + assert italic.type == "italic" and italic.offset == link.offset == len("\U0001f327๏ธ Rain".encode("utf-16-le")) // 2 + 1 + assert italic.length == link.length == len("live chart") and link.url == "https://x" async def test_alerts_are_silent_and_a_chat_that_blocked_the_bot_is_marked_gone(tmp_path): From a98f83d5da95560caa8047032c6f809f03a34508 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 19:08:18 +0000 Subject: [PATCH 42/65] Bot._respond in three parts: the question, its answer, sending it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _respond was about 120 lines that decided the answer and delivered it, with a dozen locals passed between the two halves. It now: - reads the question, holds the chat's lock and draws the charts (52 lines); - asks _answer for the answer: a chart drawn moments ago, a chart made in code, a lookup, the report written in code, else the model (28 lines); - hands it to _send, which delivers it and keeps what follows: the chart for its period buttons and the next to ask, the question behind a sent chart, the forecast days shown, the keyboard version. An Answer dataclass carries what passes between them. deliver() returns Delivered(carried, photo) instead of filling an out-list with the sent photo. `read.fast and read.chart_in_code` (five times) is `read.chart_in_code`, which already implies a fast call. Behaviour is unchanged: the same order of steps and the same log lines. The suite and the evals pass; the three tests that read deliver()'s result read its fields. lib: +94 โˆ’69 lines: the Answer and Delivered types, and the two new methods' signatures and docstrings. Correction: 90f1ab2's message says "lib: +27 โˆ’35"; the measured figure is +35 โˆ’37. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/bot.py | 163 +++++++++++++++++++++++----------------- tests/test_periods.py | 5 +- tests/test_templates.py | 10 +-- 3 files changed, 101 insertions(+), 77 deletions(-) diff --git a/lib/bot.py b/lib/bot.py index 584a36e..1828e3e 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -14,6 +14,7 @@ from collections import defaultdict from dataclasses import dataclass, field from datetime import datetime, UTC +from typing import NamedTuple from telegram import InputMediaPhoto, Message, ReplyKeyboardRemove, Update from telegram.constants import ChatAction, ChatType @@ -62,6 +63,18 @@ class ChatState: lock: asyncio.Lock = field(default_factory=asyncio.Lock) +@dataclass +class Answer: + """What a question is answered with, worked out before it is sent.""" + reply: str | None = None # its text; None until something gives one + photos: list = field(default_factory=list) + titles: list[str] = field(default_factory=list) + silent: bool = False # a chart made in code: it goes with just its title, its text kept for the history + ready: periods.Drawn | None = None # a chart drawn moments ago, sent as it is + made: float | None = None # seconds the tool took to make the chart in code, for the timing line + ok: bool = True # False: the reply says something went wrong + + async def watchdog(label: str, seconds: float): """Log every task's stack if the question is still running after `seconds`: a hang says where it waits.""" await asyncio.sleep(seconds) @@ -199,13 +212,18 @@ async def run(self, stop: asyncio.Event): await asyncio.wait_for(stop.wait(), DRAFT_MIN_GAP) +class Delivered(NamedTuple): + carried: bool # the keyboard (markup) went with the reply + photo: Message | None = None # the single chart, as sent + + 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, bold_headings: bool = False) -> bool: + titles: list[str] | None = None, period_row=None, bold_headings: bool = False) -> Delivered: """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. - bold_headings: the text is the report (or a pollen / forecast lookup), whose heading names are set in bold.""" + the last text, or on the single photo when there is no text (a group of photos can't carry one). period_row (the period + buttons) rides on a single photo instead, and then the keyboard waits. 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) @@ -226,15 +244,13 @@ async def deliver(msg: Message, text: str, photos: list[bytes], link: tuple[str, if len(photos) == 1: buttons = period_row or markup sent = await msg.reply_photo(photos[0], caption=caption(0), **({"reply_markup": buttons} if buttons else {})) - if sent_photo is not None and sent is not None: - sent_photo.append(sent) - return bool(markup) and not period_row + return Delivered(bool(markup) and not period_row, sent) await msg.reply_media_group([InputMediaPhoto(p, caption=caption(i)) for i, p in enumerate(photos)]) except TelegramError: log.exception("Couldn't send the chart") if not text: await msg.reply_text("Sorry, I couldn't send that chart. Please try again in a moment.") - return carried + return Delivered(carried) class Bot: @@ -812,7 +828,7 @@ async def _draw_ahead(self, text: str, now) -> tuple[str, bytes, str, str] | Non None otherwise. Raises when it can't be drawn. Nobody is waiting: its requests are not urgent and its logs are quiet.""" read = intent.read(text, now, self.by_name) key = self.images.canonical(text, now) - if not (read.fast and read.chart_in_code and key): # not a chart made in code ("Report"), or a source that isn't there + if not (read.chart_in_code and key): # not a chart made in code ("Report"), or a source that isn't there return None turn = Turn.of(read, text, ahead=True) caption = await asyncio.wait_for(self._chart_in_code(read, turn), TURN_SECONDS) @@ -912,6 +928,7 @@ async def respond(self, update: Update, context: ContextTypes.DEFAULT_TYPE, text self.idle.set() async def _respond(self, update: Update, context: ContextTypes.DEFAULT_TYPE, text: str, redraw: Message | None = None): + """Answer `text` (redraw: a chart's message, when a period button asks for that chart again in place).""" msg = update.effective_message text = text.strip() if not text: @@ -925,105 +942,113 @@ async def _respond(self, update: Update, context: ContextTypes.DEFAULT_TYPE, tex log.info("%s %s%s", describe_source(update), f"(reasoning: {read.effort}) " if read.effort != intent.EFFORT_DEFAULT else "", text, extra=QUOTES) for source in self.sources: # fetch recent readings while the model thinks - if source.wants(text) and not (read.fast and read.chart_in_code): # a chart made in code reads just what it needs, first in line + if source.wants(text) and not read.chart_in_code: # a chart made in code reads just what it needs, first in line source.poke() if read.fast: log.info("Fast path: %s", read.fast[2]) chat = self.chats[self._key(msg)] - thread_id = self._thread(msg) started = time.monotonic() async with chat.lock: - draft = (Draft(context.bot, msg.chat_id, thread_id) + draft = (Draft(context.bot, msg.chat_id, self._thread(msg)) if redraw is None and msg.chat.type == ChatType.PRIVATE and hasattr(context.bot, "send_message_draft") else None) drafting = asyncio.create_task(draft.run(stop_typing)) if draft else None # a private chat also gets the "Thinking..." draft stuck = asyncio.create_task(watchdog(_short(text, 40), WATCHDOG_SECONDS)) working = [*chat.history, {"role": "user", "content": self._content(msg, text, context.bot.id)}] new_from = len(working) - ok, photos, titles, silent = True, [], [], False - made = None # how long the tool took when the chart is made in code, for the timing line turn = Turn.of(read, text) - ready = self.images.ready(text, now) if read.fast and read.chart_in_code and redraw is None else None try: - reply = None - if ready: # drawn moments ago (ahead, or for someone's same question): sent as it is, its text kept for the history - reply, photos, titles, silent = ready.caption, [ready.photo], [ready.title], True - elif read.fast and read.chart_in_code: # drawn by the tool; the chart goes out with just its title (the text is kept for the history) - began = time.monotonic() - reply = await asyncio.wait_for(self._chart_in_code(read, turn), TURN_SECONDS) - silent = reply is not None - made = time.monotonic() - began if silent else None - elif read.lookup: - reply = await asyncio.wait_for(self._lookup_in_code(read, turn), TURN_SECONDS) - if reply is None: - turn.forecast_shown.clear() - elif read.fast and (read.report or read.weather_now): # written in code from the tools' results, no model - reply = await asyncio.wait_for(self._written_in_code(read, turn), TURN_SECONDS) - if reply is not None: - working.append({"role": "assistant", "content": reply}) - else: - before = [str(m["content"]) for m in chat.history if m["role"] == "user"][-2:] - found = intent.topics(str(working[new_from - 1]["content"]), before) - system = prompt.build(datetime.now(self.cfg.tz), {s.name: s.describe() for s in self.sources}, read.hints, - read.about_the_bot, read.rain_caption, found) - reply = await self._ask_model(working, system, read, turn, draft, lambda tokens: self._warn(msg, tokens), - f"ecobot-{msg.chat_id}") + answer = await self._answer(msg, read, turn, working, draft, now, redraw) chat.history = shorten_old(trim_history(strip_tool_turns(working))) for spec in turn.charts[:MAX_CHARTS]: # drawn while "typing..." is still showing try: - photos.append(await asyncio.to_thread(render_chart, spec, self.cfg.tz, turn.times)) - titles.append(spec.caption(now.date())) + answer.photos.append(await asyncio.to_thread(render_chart, spec, self.cfg.tz, turn.times)) + answer.titles.append(spec.caption(now.date())) except Exception: log.exception("Chart failed; sending the answer without it") except TimeoutError: log.error("Gave up after %ds on: %s", TURN_SECONDS, text, extra=QUOTES) - ok, reply = False, "Sorry, that took too long. Please try again in a moment." + answer = Answer("Sorry, that took too long. Please try again in a moment.", ok=False) except Exception as e: log.exception("Agent error") # The details (which can include provider error bodies) go to the log, not the chat - ok, reply = False, f"Sorry, something went wrong on my side ({type(e).__name__}). Please try again in a moment." + answer = Answer(f"Sorry, something went wrong on my side ({type(e).__name__}). Please try again in a moment.", ok=False) finally: stuck.cancel() stop_typing.set() await asyncio.gather(*[t for t in (typing, drafting) if t]) # wait for any in-flight "typing" or draft so none is sent after the reply - - caption = reply if silent else None # a chart made in code: its text, kept with the chart for the next to ask the same - if silent and photos: + await self._send(msg, context.bot, read, turn, answer, working[new_from:], now, redraw, started) + + async def _answer(self, msg: Message, read, turn: Turn, working: list[dict], draft, now: datetime, redraw: Message | None) -> Answer: + """The answer, from the first way that gives one: a chart drawn moments ago, a chart made in code, a lookup, the report + written in code, else the model. `working` is the conversation, the question last; the answer joins it.""" + if read.chart_in_code and redraw is None and (ready := self.images.ready(turn.text, now)): + answer = Answer(ready.caption, [ready.photo], [ready.title], silent=True, ready=ready) # drawn ahead, or for someone's same question + elif read.chart_in_code: # drawn by the tool; the chart goes out with just its title (the text is kept for the history) + began = time.monotonic() + reply = await asyncio.wait_for(self._chart_in_code(read, turn), TURN_SECONDS) + answer = Answer(reply, silent=reply is not None, made=time.monotonic() - began if reply is not None else None) + elif read.lookup: + answer = Answer(await asyncio.wait_for(self._lookup_in_code(read, turn), TURN_SECONDS)) + if answer.reply is None: + turn.forecast_shown.clear() + elif read.fast and (read.report or read.weather_now): # written in code from the tools' results, no model + answer = Answer(await asyncio.wait_for(self._written_in_code(read, turn), TURN_SECONDS)) + else: + answer = Answer() + if answer.reply is not None: + working.append({"role": "assistant", "content": answer.reply}) + return answer + before = [str(m["content"]) for m in working[:-1] if m["role"] == "user"][-2:] + found = intent.topics(str(working[-1]["content"]), before) + system = prompt.build(datetime.now(self.cfg.tz), {s.name: s.describe() for s in self.sources}, read.hints, + read.about_the_bot, read.rain_caption, found) + answer.reply = await self._ask_model(working, system, read, turn, draft, lambda tokens: self._warn(msg, tokens), + f"ecobot-{msg.chat_id}") + return answer + + async def _send(self, msg: Message, bot, read, turn: Turn, answer: Answer, added: list[dict], now: datetime, + redraw: Message | None, started: float): + """Send the answer, a single chart with the period buttons under it (in place of `redraw` when a button asked for it), the + keyboard if the chat's is out of date; then keep what follows from it: the chart for its period buttons and the next to + ask, the question behind it, the forecast days shown. `added`: the messages the answer added to the conversation.""" + text, reply, photos, titles = turn.text, answer.reply, answer.photos, answer.titles + caption = reply if answer.silent else None # a chart made in code: its text, kept with the chart for the next to ask the same + if answer.silent and photos: reply = "" - if ok and not reply.strip() and not photos: # a chart with no text that could not be drawn + if answer.ok and not reply.strip() and not photos: # a chart with no text that could not be drawn reply = "Sorry, I couldn't draw that chart. Please try again in a moment." - used = [m for m in working[new_from:] if m["role"] == "tool"] - log.info("%s %s in %.1fs, tools %d, charts %d, %d chars: %s", "Replied" if ok else "Error reply", msg.chat_id, + used = [m for m in added if m["role"] == "tool"] + log.info("%s %s in %.1fs, tools %d, charts %d, %d chars: %s", "Replied" if answer.ok else "Error reply", msg.chat_id, time.monotonic() - started, len(used), len(photos), len(reply), reply, extra=QUOTES) - used_air = any(tc["function"]["name"] == "air_quality" - for m in working[new_from:] for tc in m.get("tool_calls") or []) + used_air = any(tc["function"]["name"] == "air_quality" for m in added 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 - 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, drawn = [], list(photos) - kept = self.images.key(text, intent.period_days(text, now), now) if row and ok and not reply.strip() else None - if kept and not ready: # a chart and nothing else: keep it for the period buttons (and, its words being its key, for the next to ask them) + refresh = answer.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 + drawn = list(photos) + kept = self.images.key(text, intent.period_days(text, now), now) if row and answer.ok and not reply.strip() else None + if kept and not answer.ready: # a chart and nothing else: keep it for the period buttons (and, its words being its key, for the next to ask them) self.images.put(kept, photos[0], titles[0], caption if kept == self.images.canonical(text, now) else None) try: began = time.monotonic() if redraw is not None and row and await self._redraw(redraw, reply, photos[0], titles[0], row, text, kept): 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, - 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 ready: - log.debug("Chart ms: drawn %.0fs ago, send %.0f%s", self.images.age(ready), (time.monotonic() - began) * 1000, - " (Telegram's copy)" if isinstance(drawn[0], str) else "") + sent = 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, + bold_headings=answer.ok and bool(read.report or read.lookup in ("pollen", "forecast"))) + carried_keyboard = sent.carried and bool(markup) # the refresh button is not the chat's keyboard + if answer.ready: + log.debug("Chart ms: drawn %.0fs ago, send %.0f%s", self.images.age(answer.ready), (time.monotonic() - began) * 1000, + " (Telegram's copy)" if isinstance(drawn[0], str) else "") elif drawn: - log.debug("Chart ms: %s", chart_times(turn.times, made, drawn, time.monotonic() - began)) - if sent_photo: - self.charted.remember(msg.chat_id, sent_photo[0].message_id, text) - self.images.sent(kept, sent_photo[0]) - if kept and read.fast and read.chart_in_code: # a chart with period buttons, made in code: its other periods are uploaded ahead - self.preload_soon(context.bot, text) - if ok and turn.forecast_shown: # it was sent: a later revision of these days is worth telling this chat + log.debug("Chart ms: %s", chart_times(turn.times, answer.made, drawn, time.monotonic() - began)) + if sent.photo: + self.charted.remember(msg.chat_id, sent.photo.message_id, text) + self.images.sent(kept, sent.photo) + if kept and read.chart_in_code: # a chart with period buttons, made in code: its other periods are uploaded ahead + self.preload_soon(bot, text) + if answer.ok and turn.forecast_shown: # it was sent: a later revision of these days is worth telling this chat self.state.record_forecast(msg.chat_id, turn.forecast_shown, now_local(self.cfg.tz).date()) if carried_keyboard: log.info("Buttons: sent keyboard %s to chat %s (it had %s)", self.keyboard_version, msg.chat_id, self.state.keyboard(msg.chat_id)) diff --git a/tests/test_periods.py b/tests/test_periods.py index 2ea63a3..92c13b5 100644 --- a/tests/test_periods.py +++ b/tests/test_periods.py @@ -133,9 +133,8 @@ class Replies: async def reply_photo(self, photo, **kw): sent.append(kw["reply_markup"]) return NS(message_id=7) - shown = [] - carried = await botmod.deliver(Replies(), "", [b"p"], markup=keyboard(), titles=["T"], period_row=periods.keyboard(), sent_photo=shown) - assert carried is False and labels(sent[0])[0][0][0] == "1D" and shown[0].message_id == 7 + got = await botmod.deliver(Replies(), "", [b"p"], markup=keyboard(), titles=["T"], period_row=periods.keyboard()) + assert got.carried is False and labels(sent[0])[0][0][0] == "1D" and got.photo.message_id == 7 def test_a_chart_drawn_lately_comes_back_from_the_cache_until_it_is_too_old_or_pushed_out(): diff --git a/tests/test_templates.py b/tests/test_templates.py index fff2fe3..a97727d 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -116,17 +116,17 @@ async def test_deliver_puts_the_keyboard_on_the_last_text_or_the_single_photo(tm from lib.bot import deliver markup = templates.keyboard() msg = Replies() - assert await deliver(msg, "hello", [], markup=markup) is True and msg.sent[-1][1]["reply_markup"] is markup + assert (await deliver(msg, "hello", [], markup=markup)).carried is True and msg.sent[-1][1]["reply_markup"] is markup msg = Replies() - assert await deliver(msg, "", [b"p"], markup=markup, titles=["T"]) is True and msg.sent == [("photo", msg.sent[0][1])] + assert (await deliver(msg, "", [b"p"], markup=markup, titles=["T"])).carried is True and msg.sent == [("photo", msg.sent[0][1])] assert msg.sent[0][1]["reply_markup"] is markup and msg.sent[0][1]["caption"] == "T" msg = Replies() - assert await deliver(msg, "answer", [b"p"], markup=markup, titles=["T"]) is True # text first, the photo carries it + assert (await deliver(msg, "answer", [b"p"], markup=markup, titles=["T"])).carried is True # text first, the photo carries it assert [k for k, _ in msg.sent] == ["text", "photo"] and msg.sent[1][1]["reply_markup"] is markup and "reply_markup" not in msg.sent[0][1] msg = Replies() - assert await deliver(msg, "", [b"p", b"q"], markup=markup, titles=["T", "U"]) is False # a group of photos can't carry it + assert (await deliver(msg, "", [b"p", b"q"], markup=markup, titles=["T", "U"])).carried is False # a group of photos can't carry it msg = Replies() - assert await deliver(msg, "hello", []) is False and "reply_markup" not in msg.sent[0][1] + assert (await deliver(msg, "hello", [])).carried is False and "reply_markup" not in msg.sent[0][1] async def test_a_chat_with_an_old_or_no_keyboard_gets_the_current_one_with_its_next_reply_once(tmp_path): From f6d9ba91fcadec19da44652e3999007495ff6d5e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 19:12:01 +0000 Subject: [PATCH 43/65] Background loops: one refresh at a time, tracebacks for bugs, the archive on every() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A Warmer runs one refresh at a time (M5). A timed refresh used to run alongside a refresh a question had started (poke), fetching the same data twice. Now it waits for the question's to finish, and a question arriving during any refresh starts none of its own. - safely() logs a failure that points at the code (TypeError, KeyError and the like) with its traceback; an outage or a refusal stays one line. Before, every background failure was one line. - every() takes a delay function as well as a number of seconds. The archive's nightly loop is now every() over run_once. A failed run is logged by safely() ("Archive.run_once failed: ..."), not as "Ecowitt archive run failed" with ERROR and a traceback. run_once logs its own summary line, so a run started any other way logs it too. - The three `except CancelledError: raise` blocks in front of `except Exception` go: CancelledError isn't an Exception, so they never ran. The irrigation monitor keeps its own loop, since its retry after a failed check doesn't fit every(). Tests (tests/test_warm.py, all three failing before): a timed refresh waits for a question's and refreshes never overlap; a bug's traceback is logged and an outage's isn't; every() asks its delay function after each run. lib: +22 โˆ’24 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/alerts/irrigation.py | 2 -- lib/ecowitt/archive.py | 17 ++++-------- lib/warm.py | 27 ++++++++++++------- tests/test_warm.py | 58 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 24 deletions(-) create mode 100644 tests/test_warm.py diff --git a/lib/alerts/irrigation.py b/lib/alerts/irrigation.py index e183496..3e7ddb3 100644 --- a/lib/alerts/irrigation.py +++ b/lib/alerts/irrigation.py @@ -52,8 +52,6 @@ async def run(self): if self.due(now): try: await self.check(now) - except asyncio.CancelledError: - raise except Exception as e: log.warning("Irrigation check failed (%s: %s); trying again in %d minutes", type(e).__name__, e, RETRY_SECONDS // 60) await asyncio.sleep(min(RETRY_SECONDS, self.seconds_until_next(now))) diff --git a/lib/ecowitt/archive.py b/lib/ecowitt/archive.py index 4b0a83b..a5e076e 100644 --- a/lib/ecowitt/archive.py +++ b/lib/ecowitt/archive.py @@ -14,6 +14,7 @@ from time import monotonic from ..timeutil import naive_local, now_local, seconds_until +from ..warm import every from .api import CYCLES, MIN_GAP_SECONDS, kept_from from .fetch import spans from .store import horizon @@ -68,6 +69,7 @@ def settled(cycle: str) -> datetime: async def run_once(self) -> tuple[int, int]: """Cache whatever is missing. Returns (ranges fetched, ranges failed). Each range is saved as soon as it arrives, so an interrupted run just carries on from there next time.""" + started = monotonic() work, epoch = list(self._work()), self.station.fetcher(self.groups).epoch missing = await asyncio.to_thread(self.station.cache.uncovered, self.station.mac, self.groups, # one read of what is held [(cycle, epoch(start), epoch(end)) for cycle, start, end in work]) @@ -94,6 +96,7 @@ async def run_once(self) -> tuple[int, int]: if i % 20 == 0 and i < len(todo): log.info("Ecowitt: %d of %d req done", i, len(todo)) await asyncio.sleep(self.pace) + log.info("Ecowitt: %d req, %sheld %s, %.0fs", fetched, f"{failed} failed, " if failed else "", self.held(), monotonic() - started) return fetched, failed async def _drop_unsupported_groups(self, cycle: str, start: datetime, end: datetime) -> bool: @@ -125,15 +128,5 @@ def _seconds_until_next_run(self) -> float: return seconds_until(RUN_AT.hour, RUN_AT.minute, self.station.tz) async def loop(self): - """Backfill now, then again every night at RUN_AT, until cancelled.""" - while True: - try: - started = monotonic() - fetched, failed = await self.run_once() - log.info("Ecowitt: %d req, %sheld %s, %.0fs", fetched, f"{failed} failed, " if failed else "", - self.held(), monotonic() - started) - except asyncio.CancelledError: - raise - except Exception: - log.exception("Ecowitt archive run failed") - await asyncio.sleep(self._seconds_until_next_run()) + """Backfill now, then again every night at RUN_AT, until cancelled. A run that fails is logged; the next night's tries again.""" + await every(self._seconds_until_next_run, self.run_once) diff --git a/lib/warm.py b/lib/warm.py index 808831d..11d77a4 100644 --- a/lib/warm.py +++ b/lib/warm.py @@ -23,21 +23,23 @@ def in_sync_hours(now: datetime) -> bool: return SYNC_HOURS[0] <= now.hour < SYNC_HOURS[1] +# Errors that point at the code, logged with their traceback; any other failure (an outage, a refusal) is one line +BUGS = (AssertionError, AttributeError, IndexError, KeyError, NameError, TypeError, ValueError, ZeroDivisionError) + + async def safely(fn: Callable[..., Awaitable], *args): """Await fn(*args); log a failure instead of raising (background work must not die).""" try: return await fn(*args) - except asyncio.CancelledError: - raise except Exception as e: - log.warning("%s failed: %s: %s", getattr(fn, "__qualname__", fn), type(e).__name__, e) + log.warning("%s failed: %s: %s", getattr(fn, "__qualname__", fn), type(e).__name__, e, exc_info=isinstance(e, BUGS)) -async def every(seconds: float, fn: Callable[[], Awaitable]): - """Call fn now, then every `seconds`, until cancelled.""" +async def every(delay: float | Callable[[], float], fn: Callable[[], Awaitable]): + """Call fn now, then again after `delay` seconds (or as many as delay() says each time), until cancelled.""" while True: await safely(fn) - await asyncio.sleep(seconds) + await asyncio.sleep(delay() if callable(delay) else delay) class Warmer: @@ -45,16 +47,21 @@ def __init__(self, refresh: Callable[[bool], Awaitable], interval: float = REFRE self.refresh, self.interval = refresh, interval self.after: list[Callable[[], Awaitable]] = [] # run after each timed refresh (alert checks) self._task: asyncio.Task | None = None + self._lock = asyncio.Lock() # one refresh at a time: a timed one waits for a question's + + async def _refresh(self, fresh: bool): + async with self._lock: + await safely(self.refresh, fresh) def poke(self): - """A question arrived: refresh in the background unless one is already running.""" - if self._task is None or self._task.done(): - self._task = asyncio.create_task(safely(self.refresh, False)) + """A question arrived: refresh in the background, unless a refresh (a question's or a timed one) is running.""" + if not self._lock.locked() and (self._task is None or self._task.done()): + self._task = asyncio.create_task(self._refresh(False)) async def run(self): """Refresh every `interval` seconds until cancelled (startup already fetched once).""" while True: await asyncio.sleep(self.interval) - await safely(self.refresh, True) + await self._refresh(True) for check in self.after: await safely(check) diff --git a/tests/test_warm.py b/tests/test_warm.py new file mode 100644 index 0000000..c7641f8 --- /dev/null +++ b/tests/test_warm.py @@ -0,0 +1,58 @@ +import asyncio +import logging + +from lib.warm import Warmer, every, safely + + +async def test_a_warmer_runs_one_refresh_at_a_time(): + """A timed refresh waits for a question's to finish, and a question that arrives during a refresh starts none of its own.""" + release, started, seen = asyncio.Event(), asyncio.Event(), [] + + async def refresh(fresh): + seen.append(("start", fresh)) + started.set() + await release.wait() + seen.append(("end", fresh)) + warmer = Warmer(refresh, interval=0) + warmer.poke() + await started.wait() # a question's refresh is running + timer = asyncio.create_task(warmer.run()) # and the timed one falls due at once + warmer.poke() # another question meanwhile + for _ in range(5): + await asyncio.sleep(0) + assert seen == [("start", False)] + release.set() + for _ in range(10): + await asyncio.sleep(0) + timer.cancel() + assert seen[:3] == [("start", False), ("end", False), ("start", True)] + assert all(a[0] != b[0] for a, b in zip(seen, seen[1:])) # every refresh ends before the next starts + + +async def test_a_background_failure_has_its_traceback_logged_only_when_it_points_at_code(caplog): + async def broken(): + raise TypeError("bad") + + async def down(): + raise ConnectionError("no route to host") + caplog.set_level(logging.WARNING) + assert await safely(broken) is None and await safely(down) is None # neither escapes + bug, outage = [r for r in caplog.records if r.name == "lib.warm"] + assert bug.exc_info and "TypeError: bad" in bug.getMessage() + assert not outage.exc_info and "ConnectionError: no route to host" in outage.getMessage() + + +async def test_every_waits_as_long_as_its_delay_says_each_time(): + runs, asked = [], [] + + async def work(): + runs.append(1) + + def delay(): + asked.append(len(runs)) + return 0 + task = asyncio.create_task(every(delay, work)) + for _ in range(6): + await asyncio.sleep(0) + task.cancel() + assert asked[:3] == [1, 2, 3] # asked again after each run From e4bf554aa258964dabe16d0de6d72155c322d0b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 19:19:44 +0000 Subject: [PATCH 44/65] Alert repetition: chart drawn only for a chat that gets it, to_chat folded in, one re-arm rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The Notifier takes `chart`, a function that draws the alert's picture. It is called only when some chat gets the alert, and once however many do. Before, the gust, UV, temperature-crossing and particulates monitors drew their chart (an Ecowitt or AirGradient read and a render) before sending, even when no chat had that alert type on. The five `**({"photo": photo} if photo else {})` go with it. - `chats=` replaces to_chat, which repeated __call__ for one chat. The forecast revision sends with chats=[chat] and gets back how many it was sent to, as every alert now does. Its log line is the usual "Alert sent to N chat(s)". - confirm.rearm() is the re-arm rule the gust and UV alerts each spelled out: once fired, an alert can fire again after its condition has stopped holding for a while, and the calm starts over if it holds again. Tests: the chart is drawn once for two chats and not at all when the one chat asked has that type off; rearm waits the whole calm and starts over on a new hit (breaking that restart passed every test before). The monitors' tests read the picture from the chart function. lib: +39 โˆ’45 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/alerts/confirm.py | 15 ++++++++- lib/alerts/forecast.py | 4 +-- lib/alerts/notify.py | 27 +++++++---------- lib/alerts/particulates.py | 6 ++-- lib/alerts/weather.py | 32 ++++++------------- tests/test_alert_menu.py | 27 +++++++++-------- tests/test_alerts.py | 59 ++++++++++++++++++++++++++++++------ tests/test_forecast_alert.py | 6 ++-- 8 files changed, 106 insertions(+), 70 deletions(-) diff --git a/lib/alerts/confirm.py b/lib/alerts/confirm.py index 664e860..336df57 100644 --- a/lib/alerts/confirm.py +++ b/lib/alerts/confirm.py @@ -3,7 +3,9 @@ counted (the same or an older time) is ignored; a reading that does not meet the condition ends the run; a reading with no value is skipped (it neither extends nor ends the run). The run lives in the monitor's saved dict, so it carries across checks and restarts. -(Rain and the temperature crossing use the back-off rule in backoff.py instead: a change that must stand for a cooldown.)""" +(Rain and the temperature crossing use the back-off rule in backoff.py instead: a change that must stand for a cooldown.) + +Once an alert (gusts, UV) has fired it waits to re-arm: rearm() lets it fire again after its condition has stopped holding for a while.""" CONFIRM_READINGS = 2 @@ -21,3 +23,14 @@ def run(saved: dict, key: str, hit: bool | None) -> int: if hit is not None: saved[key] = saved.get(key, 0) + 1 if hit else 0 return saved.get(key, 0) + + +def rearm(saved: dict, hit: bool, now: float, after: float): + """The alert in `saved` (saved["active"]: it has fired) can fire again once its condition has not held (`hit`) for `after` + seconds; while it holds, the calm hasn't begun.""" + if hit: + saved["calm_since"] = None + elif saved["active"]: + saved["calm_since"] = saved["calm_since"] or now + if now - saved["calm_since"] >= after: + saved.update(active=False, calm_since=None) diff --git a/lib/alerts/forecast.py b/lib/alerts/forecast.py index e4df865..7086ebe 100644 --- a/lib/alerts/forecast.py +++ b/lib/alerts/forecast.py @@ -36,7 +36,7 @@ class ForecastMonitor: KINDS = ("forecast",) def __init__(self, forecast, state, notify): - """notify: the Notifier (its to_chat sends to one chat).""" + """notify: the Notifier (chats=[chat] sends to that one).""" self.forecast, self.state, self.notify = forecast, state, notify async def check(self): @@ -53,5 +53,5 @@ async def check(self): if not changes: continue lines = [f"โ€ข {day_label(day, today)}: was {describe_day(old)}\n now {describe_day(new)}" for day, old, new in changes] - if await self.notify.to_chat(chat_id, "\U0001f504 The forecast has changed since I sent it:\n" + "\n".join(lines), kind="forecast"): + if await self.notify("\U0001f504 The forecast has changed since I sent it:\n" + "\n".join(lines), kind="forecast", chats=[chat_id]): self.state.record_forecast(chat_id, [new for _, _, new in changes], today) diff --git a/lib/alerts/notify.py b/lib/alerts/notify.py index 9dc4b60..dcb06eb 100644 --- a/lib/alerts/notify.py +++ b/lib/alerts/notify.py @@ -12,7 +12,7 @@ import logging import os import time -from collections.abc import Collection +from collections.abc import Awaitable, Callable, Collection from datetime import date, timedelta from telegram.error import BadRequest, ChatMigrated, Forbidden, NetworkError, RetryAfter, TelegramError, TimedOut @@ -210,28 +210,23 @@ class Notifier: def __init__(self, bot, state: AlertState): self.bot, self.state = bot, state - async def __call__(self, text: str, link: tuple[str, str] | None = None, kind: str | None = None, photo: bytes | str | None = None): - """Send an alert (silently) to every chat subscribed to its type; chats the bot can no longer post to are marked - gone. link = (label, url) adds a clickable label to the footer. Each carries the settings buttons. - photo: a picture the text is the caption of (the text alone if Telegram refuses the picture): a PNG, or the file id of one - Telegram holds. It is uploaded once: the chats after the first are sent Telegram's copy.""" + async def __call__(self, text: str, link: tuple[str, str] | None = None, kind: str | None = None, + chart: Callable[[], Awaitable[bytes | None]] | None = None, chats: list[int] | None = None) -> int: + """Send an alert (silently) to the chats subscribed to its type: of `chats` (say, the one sent something that has since + changed), else of every chat. Chats the bot can no longer post to are marked gone. Returns how many it was sent to. + link = (label, url) adds a clickable label to the footer; each carries the settings buttons. chart draws the alert's + picture (None: the text alone), and is called only when some chat gets the alert. The text is the picture's caption + (the text alone if Telegram refuses it); it is uploaded once and the chats after the first are sent Telegram's copy.""" + targets = self.state.alert_chats(kind) if chats is None else [c for c in chats if self.state.subscribed(c, kind)] text, entities = with_link(text, link) + photo = await chart() if chart and targets else None started, sent = time.monotonic(), 0 - for chat_id in self.state.alert_chats(kind): + for chat_id in targets: ok, message = await self._send(chat_id, text, entities, kind, photo) sent += ok if photo and (sizes := getattr(message, "photo", None)): photo = sizes[-1].file_id # the largest size: the picture as sent log.info("Alert sent to %d chat(s) in %.1fs: %s", sent, time.monotonic() - started, text, extra=QUOTES) - - async def to_chat(self, chat_id: int, text: str, link: tuple[str, str] | None = None, kind: str | None = None) -> bool: - """An alert for one chat (the one that was sent something that has since changed), if it is subscribed to the type. - True if it was sent.""" - if not self.state.subscribed(chat_id, kind): - return False - text, entities = with_link(text, link) - sent, _ = await self._send(chat_id, text, entities, kind) - log.info("Alert %s chat %s: %s", "sent to" if sent else "NOT sent to", chat_id, text, extra=QUOTES) return sent async def _post(self, chat_id: int, text: str, entities, kind: str | None, photo: bytes | str | None): diff --git a/lib/alerts/particulates.py b/lib/alerts/particulates.py index c63256f..1146ed6 100644 --- a/lib/alerts/particulates.py +++ b/lib/alerts/particulates.py @@ -48,14 +48,12 @@ async def check(self, latest=None, now: datetime | None = None): confirm.run(m, "below", good) if not m["unsafe"] and m["above"] >= confirm.CONFIRM_READINGS: m["unsafe"] = True - photo = await self._picture() await self.notify("\U0001f637 Unhealthy air outside โ€” wear a P2/N95 mask today. " + self._levels(reading), - link=self.air.link, kind="particulates", **({"photo": photo} if photo else {})) + link=self.air.link, kind="particulates", chart=self._picture) elif m["unsafe"] and m["below"] >= confirm.CONFIRM_READINGS: m["unsafe"] = False - photo = await self._picture() await self.notify("โœ… Outdoor air is safe again. " + self._levels(reading), link=self.air.link, kind="particulates", - **({"photo": photo} if photo else {})) + chart=self._picture) self.state.save() async def _picture(self) -> bytes | None: diff --git a/lib/alerts/weather.py b/lib/alerts/weather.py index cef88f5..d6eaab0 100644 --- a/lib/alerts/weather.py +++ b/lib/alerts/weather.py @@ -164,18 +164,12 @@ async def _gusts(self, rows: Rows): latest_ts = rows[-1][0] seen, m["checked"] = m.get("checked", latest_ts), latest_ts over = [(g, ts) for ts, r in rows if ts > seen and (g := r.get("wind.wind_gust")) is not None and g > GUST_ALERT_KMH] - if over: - m["calm_since"] = None - if not m["active"]: - m["active"] = True - gust, ts = max(over) - photo = await self._picture("Wind chart 1d") - await self.notify(f"\U0001f4a8 Strong gusts: {gust:.0f} km/h at {clock_text(to_local(ts, self.tz))}" - f" (alerts above {GUST_ALERT_KMH} km/h).", kind="gusts", **({"photo": photo} if photo else {})) - elif m["active"]: - m["calm_since"] = m["calm_since"] or latest_ts - if latest_ts - m["calm_since"] >= GUST_REARM_SECONDS: - m.update(active=False, calm_since=None) + confirm.rearm(m, bool(over), latest_ts, GUST_REARM_SECONDS) + if over and not m["active"]: + m["active"] = True + gust, ts = max(over) + await self.notify(f"\U0001f4a8 Strong gusts: {gust:.0f} km/h at {clock_text(to_local(ts, self.tz))}" + f" (alerts above {GUST_ALERT_KMH} km/h).", kind="gusts", chart=lambda: self._picture("Wind chart 1d")) async def _uv(self, rows: Rows): """One alert per spell at or above UVI_ALERT, once it holds for two readings in a row (confirm.py); only readings newer than the last @@ -193,19 +187,13 @@ async def _uv(self, rows: Rows): if hit and (streak == 1 or u > m["peak"][0]): # the run's highest reading is the one told m["peak"] = [u, ts] high = high or bool(hit) - if high: - m["calm_since"] = None - elif m["active"]: - m["calm_since"] = m["calm_since"] or latest_ts - if latest_ts - m["calm_since"] >= UV_REARM_SECONDS: - m.update(active=False, calm_since=None) + confirm.rearm(m, high, latest_ts, UV_REARM_SECONDS) if m.get("streak", 0) >= confirm.CONFIRM_READINGS and not m["active"]: m["active"] = True uv, ts = m["peak"] - photo = await self._picture("UV chart 1d") await self.notify(f"\U0001f9f4 UV index {uv:g} at {clock_text(to_local(ts, self.tz))}" f": very high. Sunscreen, a hat and shade (alerts at {UVI_ALERT} and above).", kind="uv", - **({"photo": photo} if photo else {})) + chart=lambda: self._picture("UV chart 1d")) async def _cross(self, rows: Rows): ts, r = rows[-1] @@ -221,9 +209,9 @@ async def _cross(self, rows: Rows): held = ts - c["alerted_time"] # how long the side we last told about has stood was = f"{int(held // 86400)} days" if held >= 2 * 86400 else duration(held) icon = "\U0001f321๏ธ" if current == "warmer" else "\u2744\ufe0f" # a thermometer for warmer, a snowflake for cooler - photo = await self._picture("Temperature chart 1d") await self.notify(f"{icon} It's now {abs(o - i):.1f}ยฐC {current} outside than inside " - f"({o:.1f}ยฐC vs {i:.1f}ยฐC). It had been {'cooler' if current == 'warmer' else 'warmer'} for {was}.", kind="temps", **({"photo": photo} if photo else {})) + f"({o:.1f}ยฐC vs {i:.1f}ยฐC). It had been {'cooler' if current == 'warmer' else 'warmer'} for {was}.", kind="temps", + chart=lambda: self._picture("Temperature chart 1d")) remember(c, current, ts) async def _fell(self, rows: Rows, since: int, until: int) -> float | None: diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index 2b338c4..40016f5 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -120,7 +120,12 @@ async def test_an_alert_goes_to_the_chats_subscribed_to_its_type_and_carries_the markup = bot.sent[0][2]["reply_markup"] assert rows(markup) == [[("๐Ÿ”” โ–ธ", "al:open:bell:rain")]] # the alert's own type rides along assert bot.sent[0][1] == "It's raining" # no footer: the buttons say how to change it - assert await notifier.to_chat(2, "x", kind="rain") is False and await notifier.to_chat(2, "x", kind="uv") is True + assert await notifier("x", kind="rain", chats=[2]) == 0 and await notifier("x", kind="uv", chats=[2]) == 1 + + +async def png(): + """An alert's chart, drawn.""" + return b"png" class Query: @@ -254,7 +259,7 @@ async def test_every_monitors_alert_names_its_type(tmp_path): state.add_chat(1, "chat") kinds = [] - async def notify(text, link=None, kind=None): + async def notify(text, link=None, kind=None, **more): kinds.append(kind) async def weather(first, then): @@ -279,14 +284,12 @@ async def weather(first, then): assert kinds == ["pollen_and_asthma"] seen = [] notifier = Notifier(Sent(), state) - real = notifier.to_chat - async def spy(chat_id, text, link=None, kind=None): - seen.append(kind) - return await real(chat_id, text, link, kind) - notifier.to_chat = spy + async def spy(text, **kw): + seen.append(kw["kind"]) + return await notifier(text, **kw) state.record_forecast(1, [day(0, pct=90)], TODAY) - await ForecastMonitor(FakeForecast([day(0, pct=5)]), state, notifier).check() + await ForecastMonitor(FakeForecast([day(0, pct=5)]), state, spy).check() assert seen == ["forecast"] @@ -435,7 +438,7 @@ async def send_photo(self, chat_id, photo, **kw): async def test_an_alert_with_a_picture_is_the_picture_with_the_text_as_its_caption_and_the_buttons(tmp_path): state, bot = AlertState(tmp_path / "s.json"), SentWithPhotos() state.add_chat(1, "one") - await Notifier(bot, state)("It's warmer outside", kind="temps", photo=b"png") + await Notifier(bot, state)("It's warmer outside", kind="temps", chart=png) assert bot.sent == [] and len(bot.photos) == 1 chat, photo, kw = bot.photos[0] assert (chat, photo, kw["caption"]) == (1, b"png", "It's warmer outside") and kw["disable_notification"] is True @@ -450,7 +453,7 @@ async def send_photo(self, chat_id, photo, **kw): state, bot = AlertState(tmp_path / "s.json"), Keeps() for chat in (1, 2, 3): state.add_chat(chat, str(chat)) - await Notifier(bot, state)("It's warmer outside", kind="temps", photo=b"png") + await Notifier(bot, state)("It's warmer outside", kind="temps", chart=png) assert [(chat, photo) for chat, photo, _ in bot.photos] == [(1, b"png"), (2, "large"), (3, "large")] @@ -458,8 +461,8 @@ async def test_a_picture_telegram_refuses_falls_back_to_the_text_but_a_missing_c from telegram.error import BadRequest state, bot = AlertState(tmp_path / "s.json"), SentWithPhotos(refuse=BadRequest("Wrong file identifier")) state.add_chat(1, "one") - await Notifier(bot, state)("It's warmer outside", kind="temps", photo=b"png") + await Notifier(bot, state)("It's warmer outside", kind="temps", chart=png) assert bot.photos == [] and [(c, t) for c, t, _ in bot.sent] == [(1, "It's warmer outside")] gone = SentWithPhotos(refuse=BadRequest("Chat not found")) - await Notifier(gone, state)("again", kind="temps", photo=b"png") + await Notifier(gone, state)("again", kind="temps", chart=png) assert gone.sent == [] and state.chats[1]["gone"] and state.alert_chats() == [] diff --git a/tests/test_alerts.py b/tests/test_alerts.py index 60f86d7..029d5d8 100644 --- a/tests/test_alerts.py +++ b/tests/test_alerts.py @@ -305,6 +305,26 @@ async def send_message(self, chat_id, text, **kw): assert all(kw["disable_notification"] for _, kw in calls) and state.chats[2]["gone"] and state.alert_chats() == [1] +async def test_an_alerts_chart_is_drawn_once_and_only_when_some_chat_gets_the_alert(tmp_path): + state = AlertState(tmp_path / "s.json") + for chat in (1, 2): + state.add_chat(chat, str(chat)) + state.set_kind(2, "uv", False) + drawn, sent = [], [] + + async def chart(): + drawn.append(1) + return b"png" + + class Bot: + async def send_photo(self, chat_id, photo, **kw): + sent.append((chat_id, photo)) + notifier = Notifier(Bot(), state) + assert await notifier("Gusts", kind="gusts", chart=chart) == 2 and drawn == [1] and [p for _, p in sent] == [b"png"] * 2 + assert await notifier("UV", kind="uv", chart=chart, chats=[2]) == 0 and drawn == [1] # chat 2 has UV alerts off: not drawn + assert await notifier("UV", kind="uv", chart=chart, chats=[1, 2]) == 1 and drawn == [1, 1] and sent[-1] == (1, b"png") + + async def test_a_gone_chat_keeps_its_settings_gets_nothing_and_is_back_when_it_writes(tmp_path): from telegram.error import BadRequest, Forbidden state = AlertState(tmp_path / "s.json") @@ -406,7 +426,7 @@ async def send_message(self, chat_id, text, **kw): for errors, sent, tries, waited in cases: waits.clear() bot = Bot(*errors) - assert (await Notifier(bot, state).to_chat(1, "hi"), bot.tries, waits) == (sent, tries, waited), errors + assert (bool(await Notifier(bot, state)("hi", chats=[1])), bot.tries, waits) == (sent, tries, waited), errors def uv(*values, start=T0): @@ -663,6 +683,11 @@ def test_unsubscribing_from_all_keeps_future_alert_types_off_until_subscribed(tm assert again.subscribed(3, "rain") and not again.subscribed(3, "uv") and not again.subscribed(3, "newer still") +async def picture_of(kw): + """The picture an alert sent with these arguments would carry: what its chart draws (None: the text alone).""" + return await kw["chart"]() if kw.get("chart") else None + + def asking(mon, photo=b"png"): """Replace a monitor's chart maker with one that records what was asked and answers `photo`; returns the list of requests.""" asked = [] @@ -680,7 +705,7 @@ async def test_the_crossing_alert_carries_the_temperature_chart_when_there_is_on async def notify(text, link=None, **kw): sent.append(text) - photos.append(kw.get("photo")) + photos.append(await picture_of(kw)) m.notify = notify asked = asking(m) await m.check() # learns: outdoor cooler @@ -699,7 +724,7 @@ async def test_the_gusts_alert_carries_the_wind_chart(tmp_path): seen = [] async def notify(text, link=None, **kw): - seen.append((text, kw.get("photo"))) + seen.append((text, await picture_of(kw))) m.notify = notify asked = asking(m) await m.check() # the first look sets the mark @@ -711,20 +736,20 @@ async def notify(text, link=None, **kw): plain = [] async def notify2(text, link=None, **kw): - plain.append(kw) + plain.append((kw["kind"], await picture_of(kw))) m2.notify = notify2 asking(m2, photo=None) await m2.check() m2.station.data = [(T0, {"wind.wind_gust": 10}), (T0 + 300, {"wind.wind_gust": 12}), (T0 + 600, {"wind.wind_gust": 70})] await m2.check() - assert plain == [{"kind": "gusts"}] # no chart: text only + assert plain == [("gusts", None)] # no chart: text only async def test_both_particulates_alerts_carry_the_particulates_chart(tmp_path): state, seen = AlertState(tmp_path / "s.json"), [] async def notify(text, link=None, **kw): - seen.append((text, kw.get("photo"))) + seen.append((text, await picture_of(kw))) mon = ParticulatesMonitor(FakeAir(), state, notify) asked = asking(mon) for pm in (90, 90, 30, 30): @@ -748,7 +773,7 @@ async def notify(text, link=None, **kw): for data in (rain(0, 0, 0), rain(0, 0, 1)): m.station.data = data await m.check() - assert seen and all("photo" not in kw for kw in seen) + assert seen and all("chart" not in kw for kw in seen) async def test_the_alert_charts_are_the_typed_one_day_charts_drawn_from_each_sources_history(tmp_path, archived_cache): @@ -793,7 +818,7 @@ async def test_the_uv_alert_carries_the_uv_chart_once_per_spell(tmp_path): seen = [] async def notify(text, link=None, **kw): - seen.append((text, kw.get("photo"))) + seen.append((text, await picture_of(kw))) m.notify = notify asked = asking(m) await m.check() # the first look sets the mark @@ -808,13 +833,27 @@ async def notify(text, link=None, **kw): got = [] async def notify2(text, link=None, **kw): - got.append(kw) + got.append((kw["kind"], await picture_of(kw))) plain.notify = notify2 asking(plain, photo=None) await plain.check() plain.station.data = uv(3, 5, 11, 12) await plain.check() - assert got == [{"kind": "uv"}] # no chart: text only + assert got == [("uv", None)] # no chart: text only + + +def test_an_alert_rearms_only_once_its_condition_has_stopped_for_the_whole_wait(): + from lib.alerts import confirm + m = {"active": True, "calm_since": None} # it has fired + for now in (T0, T0 + 60): + confirm.rearm(m, False, now, 100) + assert m["active"] and m["calm_since"] == T0 + confirm.rearm(m, True, T0 + 90, 100) # the condition again: the calm starts over + for now in (T0 + 120, T0 + 200): + confirm.rearm(m, False, now, 100) + assert m["active"] # only 80 s of calm since T0 + 120 + confirm.rearm(m, False, T0 + 220, 100) + assert not m["active"] and m["calm_since"] is None # it can fire again def test_the_confirmation_rule_counts_distinct_readings_in_a_row(): diff --git a/tests/test_forecast_alert.py b/tests/test_forecast_alert.py index b630038..dfb0ff5 100644 --- a/tests/test_forecast_alert.py +++ b/tests/test_forecast_alert.py @@ -176,7 +176,7 @@ async def send_chat_action(*a, **k): await forecast.close() -async def test_to_chat_goes_to_one_chat_and_forgets_a_blocked_one(tmp_path): +async def test_an_alert_for_one_chat_goes_to_it_alone_and_a_blocked_one_is_marked_gone(tmp_path): from telegram.error import Forbidden state = AlertState(tmp_path / "s.json") state.add_chat(1, "a") @@ -189,8 +189,8 @@ async def send_message(self, chat_id, text, **kw): raise Forbidden("bot was blocked by the user") sent.append((chat_id, text)) notifier = Notifier(Bot(), state) - assert await notifier.to_chat(1, "hello") is True and [c for c, _ in sent] == [1] and sent[0][1] == "hello" - assert await notifier.to_chat(2, "hello") is False and state.chats[2]["gone"] + assert await notifier("hello", chats=[1]) == 1 and [c for c, _ in sent] == [1] and sent[0][1] == "hello" + assert await notifier("hello", chats=[2]) == 0 and state.chats[2]["gone"] def test_a_revision_compares_the_one_millimetre_chance_when_both_days_have_it_and_the_older_figure_otherwise(): From abed1e1c7f01f52dfc2ab8b2536f2e015bba39ca Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 19:22:37 +0000 Subject: [PATCH 45/65] Alert types declared once; the irrigation controller's delays and pause test in one place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - alerts/menu.TYPES lists each alert type with the name on its button and the source that sends it. LABELS, the types each source sends (available_kinds) and each monitor's KINDS (kinds_of) derive from it. Before, a new type meant editing three tables: LABELS, KINDS_OF and the monitor's KINDS. The derived values are the old ones, in the same order. - DELAYS (written twice, in tuya.py and the irrigation menu) and is_paused now live in tuya.py. "Paused" was tested three ways; the status text and the irrigation monitor now use is_paused too. - irrigation/menu.ALERTS maps each Alerts toggle (batt, pause) to its alert type and name, for the menu's buttons and bot.py's handling of them; bot.py spelled the pairs out in three places. Breaking a row of either table fails a test. lib: +38 โˆ’27 lines (kinds_of and the five monitors' imports of it). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/alerts/forecast.py | 3 ++- lib/alerts/irrigation.py | 6 ++++-- lib/alerts/menu.py | 17 ++++++++++------- lib/alerts/particulates.py | 3 ++- lib/alerts/pollen_and_asthma.py | 3 ++- lib/alerts/weather.py | 3 ++- lib/bot.py | 11 ++++++----- lib/irrigation/menu.py | 14 +++++--------- lib/irrigation/tuya.py | 5 +++++ 9 files changed, 38 insertions(+), 27 deletions(-) diff --git a/lib/alerts/forecast.py b/lib/alerts/forecast.py index 7086ebe..8228780 100644 --- a/lib/alerts/forecast.py +++ b/lib/alerts/forecast.py @@ -13,6 +13,7 @@ import logging from ..forecast.source import day_label, describe_day +from .menu import kinds_of log = logging.getLogger(__name__) @@ -33,7 +34,7 @@ def revised(old: dict, new: dict) -> bool: class ForecastMonitor: - KINDS = ("forecast",) + KINDS = kinds_of("Forecast") def __init__(self, forecast, state, notify): """notify: the Notifier (chats=[chat] sends to that one).""" diff --git a/lib/alerts/irrigation.py b/lib/alerts/irrigation.py index 3e7ddb3..cf59dbf 100644 --- a/lib/alerts/irrigation.py +++ b/lib/alerts/irrigation.py @@ -15,8 +15,10 @@ from datetime import datetime, timedelta from ..ecowitt.rain import rain_amount +from ..irrigation.tuya import is_paused from ..timeutil import local_epoch, now_local, seconds_until, when from .forecast import RAIN_FROM_PCT +from .menu import kinds_of log = logging.getLogger(__name__) @@ -28,7 +30,7 @@ class IrrigationMonitor: - KINDS = ("irrigation_battery", "irrigation_pause") + KINDS = kinds_of("Irrigation") 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.""" @@ -75,7 +77,7 @@ async def check(self, now: datetime | None = None): if reason: current = status.get("weather_delay") own = current == "24h" and memory.get("paused") == (now.date() - timedelta(days=1)).isoformat() - if current in (None, "cancel") or own: + if not is_paused(status) or own: if own: # the bot's pause from yesterday: ended first, so the new one runs a full day await self.device.delay("cancel") await self.device.delay("24h") diff --git a/lib/alerts/menu.py b/lib/alerts/menu.py index 16840cb..50be8a7 100644 --- a/lib/alerts/menu.py +++ b/lib/alerts/menu.py @@ -18,10 +18,12 @@ from ..buttons import opener, toggle_button -# kind -> the name on the button, in the order listed -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"} +# kind -> (the name on its button, the source that sends it: a source's name, or Irrigation for the controller), in the order listed +TYPES = {"rain": ("rain", "Ecowitt"), "gusts": ("gusts", "Ecowitt"), "uv": ("UV", "Ecowitt"), "temps": ("temperature crossing", "Ecowitt"), + "particulates": ("particulates", "AirGradient"), "pollen_and_asthma": ("pollen & asthma", "Pollen"), + "forecast": ("forecast changes", "Forecast"), + "irrigation_battery": ("irrigation battery", "Irrigation"), "irrigation_pause": ("irrigation pause", "Irrigation")} +LABELS = {kind: label for kind, (label, _) in TYPES.items()} ALL = "all" TITLE = "๐Ÿ”” Alerts in this chat" PER_ROW = 2 @@ -29,14 +31,15 @@ MENUS = {ALERTS: "๐Ÿ”” Alerts", SOUND: "๐Ÿ”Š Sound"} # the /alerts buttons -KINDS_OF = {"Ecowitt": ["rain", "gusts", "uv", "temps"], "AirGradient": ["particulates"], "Pollen": ["pollen_and_asthma"], - "Forecast": ["forecast"], "Irrigation": ["irrigation_battery", "irrigation_pause"]} # source -> the alert types it sends +def kinds_of(source: str) -> tuple[str, ...]: + """The alert types a source (by name) sends: a monitor's KINDS.""" + return tuple(kind for kind, (_, sender) in TYPES.items() if sender == source) def available_kinds(sources, irrigation: bool = False) -> list[str]: """The alert types the bot sends: those of its sources (by name), and the irrigation ones with a controller configured.""" names = {*sources, *(["Irrigation"] if irrigation else [])} - return [kind for source, kinds in KINDS_OF.items() if source in names for kind in kinds] + return [kind for kind, (_, source) in TYPES.items() if source in names] @dataclass(frozen=True) diff --git a/lib/alerts/particulates.py b/lib/alerts/particulates.py index 1146ed6..67c704d 100644 --- a/lib/alerts/particulates.py +++ b/lib/alerts/particulates.py @@ -11,6 +11,7 @@ from datetime import datetime from . import confirm, pictures +from .menu import kinds_of log = logging.getLogger(__name__) @@ -21,7 +22,7 @@ class ParticulatesMonitor: - KINDS = ("particulates",) + KINDS = kinds_of("AirGradient") def __init__(self, air, state, notify): self.air, self.state, self.notify = air, state, notify diff --git a/lib/alerts/pollen_and_asthma.py b/lib/alerts/pollen_and_asthma.py index b71bcdc..196f044 100644 --- a/lib/alerts/pollen_and_asthma.py +++ b/lib/alerts/pollen_and_asthma.py @@ -11,6 +11,7 @@ from ..pollen.parse import LEVEL_EMOJI, LEVELS from ..timeutil import day_text +from .menu import kinds_of log = logging.getLogger(__name__) @@ -22,7 +23,7 @@ class PollenAndAsthmaMonitor: - KINDS = ("pollen_and_asthma",) + KINDS = kinds_of("Pollen") def __init__(self, pollen, state, notify): self.pollen, self.state, self.notify = pollen, state, notify diff --git a/lib/alerts/weather.py b/lib/alerts/weather.py index d6eaab0..8b61c67 100644 --- a/lib/alerts/weather.py +++ b/lib/alerts/weather.py @@ -19,6 +19,7 @@ from ..ecowitt.glance import UVI_ALERT from . import confirm, pictures +from .menu import kinds_of from .backoff import due, remember from ..ecowitt.rain import Rows, duration, rain_amount, wet_flags from ..timeutil import clock_text, local_epoch, to_local @@ -41,7 +42,7 @@ def side(r: dict) -> str | None: class WeatherMonitor: - KINDS = ("rain", "gusts", "uv", "temps") + KINDS = kinds_of("Ecowitt") 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 diff --git a/lib/bot.py b/lib/bot.py index 1828e3e..a7a8d2d 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -465,7 +465,7 @@ async def on_alert_button(self, update: Update, context: ContextTypes.DEFAULT_TY # ---------- irrigation ---------- def _irrigation_alerts(self, chat_id: int) -> tuple[bool, bool]: """Does this chat get the irrigation battery alerts, and the pause alerts?""" - return tuple(self.state.subscribed(chat_id, kind) for kind in ("irrigation_battery", "irrigation_pause")) + return tuple(self.state.subscribed(chat_id, kind) for kind, _ in irrigation_menu.ALERTS.values()) def _irrigation_keyboard(self, chat_id: int, opened: str | None = None, paused: bool = False, running: bool = False): return irrigation_menu.keyboard(*self._irrigation_alerts(chat_id), opened, paused, running) @@ -501,9 +501,10 @@ async def _irrigation_act(self, action: str, arg: str, chat_id: int) -> str | No if self.watchdog: self.watchdog.disarm() return "Switched off" - if action in ("batt", "pause") and arg in ("on", "off") and chat_id in self.state.chats: - self.state.set_kind(chat_id, "irrigation_battery" if action == "batt" else "irrigation_pause", arg == "on", self._alert_kinds()) - return f"Irrigation {'battery' if action == 'batt' else 'pause'} alerts {arg} in this chat" + if action in irrigation_menu.ALERTS and arg in ("on", "off") and chat_id in self.state.chats: + kind, name = irrigation_menu.ALERTS[action] + self.state.set_kind(chat_id, kind, arg == "on", self._alert_kinds()) + return f"Irrigation {name} alerts {arg} in this chat" return None async def on_irrigation_button(self, update: Update, context: ContextTypes.DEFAULT_TYPE): @@ -563,7 +564,7 @@ async def on_irrigation_button(self, update: Update, context: ContextTypes.DEFAU except IrrigationError as e: log.warning("Irrigation status after %s %s failed: %s", action, arg, e) # the toast said what was done; the message stays as it was return - stay = "alerts" if action in ("batt", "pause") else None # the Alerts menu stays open after a toggle, to show the new label + stay = "alerts" if action in irrigation_menu.ALERTS else None # the Alerts menu stays open after a toggle, to show the new label await self._edit(query, *self._irrigation_view(status, message.chat_id, stay)) @staticmethod diff --git a/lib/irrigation/menu.py b/lib/irrigation/menu.py index d4389a9..03a018c 100644 --- a/lib/irrigation/menu.py +++ b/lib/irrigation/menu.py @@ -18,11 +18,12 @@ from ..buttons import button, opener, toggle_button from ..entities import footer +from .tuya import DELAYS, is_paused TITLE = "๐ŸŒฑ Irrigation" WATER_MINUTES = (5, 10, 20, 30) -DELAYS = ("24h", "48h", "72h", "cancel") SECTIONS = ("water", "delay", "alerts") +ALERTS = {"batt": ("irrigation_battery", "battery"), "pause": ("irrigation_pause", "pause")} # an Alerts toggle's word -> (its alert type, its name) LOW_BATTERY = 10 # below this the battery shows as ๐Ÿชซ PAUSED_ERROR = "error: can not turn on water while paused!" KEYWORDS = re.compile(r"\b(irrigation|irrigate|sprinklers?|taps?|water(?:ing)?)\b", re.IGNORECASE) @@ -34,11 +35,6 @@ def asked(text: str) -> bool: return 0 < len(text.split()) <= MAX_WORDS and bool(KEYWORDS.search(text)) -def is_paused(status: dict) -> bool: - """Is the timer paused (the controller has a weather delay other than "cancel")?""" - return status.get("weather_delay") not in (None, "cancel") - - def paused_in(text: str | None) -> bool: """Does a status message (as status_text wrote it) say the timer is paused? For redrawing only the buttons, without asking the controller.""" line = next((l for l in (text or "").splitlines() if l.startswith("pause:")), "") @@ -76,7 +72,7 @@ def status_text(status: dict, alerts: bool | None = None, pause_alerts: bool | N return "\n".join([TITLE, f"mode: {status.get('work_state') or 'unknown'}", f"state: {'on โœ…' if on else 'off โŒ'}{left}", - f"pause: {'not paused' if delay in (None, 'cancel') else delay}{bracket(pause_alerts)}", + f"pause: {delay if is_paused(status) else 'not paused'}{bracket(pause_alerts)}", f"battery: {level}{bracket(alerts)}"]) @@ -95,6 +91,6 @@ def keyboard(battery: bool, pause: bool, open: str | None = None, paused: bool = elif open == "delay" and not paused: rows.append([button(d, f"ir:delay:{d}") for d in DELAYS if d != "cancel"]) elif open == "alerts": - for on, kind, name in ((battery, "batt", "battery"), (pause, "pause", "pause")): - rows.append([toggle_button(f"{name} alerts", on, lambda turn_on, kind=kind: f"ir:{kind}:{'on' if turn_on else 'off'}")]) + for on, (word, (_, name)) in zip((battery, pause), ALERTS.items()): + rows.append([toggle_button(f"{name} alerts", on, lambda turn_on, word=word: f"ir:{word}:{'on' if turn_on else 'off'}")]) return InlineKeyboardMarkup(rows) diff --git a/lib/irrigation/tuya.py b/lib/irrigation/tuya.py index dcd82f6..92aae2c 100644 --- a/lib/irrigation/tuya.py +++ b/lib/irrigation/tuya.py @@ -25,6 +25,11 @@ MAX_MINUTES = 60 # the longest run asked for from a button +def is_paused(status: dict) -> bool: + """Is the timer paused: a weather delay other than "cancel" (which is none)?""" + return status.get("weather_delay") not in (None, "cancel") + + class IrrigationError(Exception): """The controller could not be read or told something. The message is safe to log (no secrets, no raw response).""" From 09849af9411e085007cffc1877963134e19083e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 19:29:17 +0000 Subject: [PATCH 46/65] ecobot.main made safe: whatever started is closed, however the bot stops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main is now one AsyncExitStack. Each part registers its own close the moment it starts: the sources, the model's client, the irrigation controller, the bot, Telegram (its connection, the application, the polling), the background tasks and the Warmers. On the way out they are closed in reverse order, whatever stopped the bot. This fixes the start-up and shutdown failure paths (O3): - A source whose constructor raised stopped the bot; now it is left out like one whose start fails (" couldn't start - continuing without it"). - A failure after the sources started but before the old try (building the tools, the bot, the Telegram app, or starting polling) left them open. Now they are closed. - The OpenAI client was never closed; now it is. - One close that raised, or a task that had already died, skipped the rest of the shutdown. Each close now goes through safely() (logged, then the next), and the tasks are cancelled together. - A refresh a question had started (Warmer.poke) and the bot's background uploads were never cancelled. Warmer.stop() and Bot.close() now cancel them. - FORECAST_LAT alone was paired with the station's longitude, a place nobody configured. Now the configured location is used only when both halves are set; otherwise the station's is used, with a warning. Also: - The alerts' set-up and the start-up warm-up moved out of main into start_alerts() and warm_up(); they find the sources by name. - Tasks, a small registry, logs a background task that stops with an error when it stops, not at shutdown. - The valve check's interval is imported as VALVE_CHECK_SECONDS, no longer under bot.py's name for its stuck-question watchdog (M8). Every log line the start-up tests read is unchanged. Five new tests fail before this: a source that can't be made; a failure part-way that closes everything (the last opened first) despite a close that fails, and stops with its own error; Warmer.stop; Bot.close; the forecast's half-configured location. A real start with both sources blocked (403) logs each, keeps AirGradient, and stops at Telegram with that error and no warnings. lib: +16 โˆ’3, ecobot.py: +152 โˆ’131 lines (the two functions taken out of main, with docstrings, and Tasks). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- ecobot.py | 283 ++++++++++++++++++++++------------------- lib/bot.py | 6 + lib/forecast/source.py | 7 +- lib/warm.py | 6 + tests/test_forecast.py | 5 +- tests/test_periods.py | 9 ++ tests/test_startup.py | 48 +++++++ tests/test_warm.py | 10 ++ 8 files changed, 239 insertions(+), 135 deletions(-) diff --git a/ecobot.py b/ecobot.py index 3251027..084b19a 100644 --- a/ecobot.py +++ b/ecobot.py @@ -10,6 +10,7 @@ import os import signal import time +from collections.abc import Coroutine from lib.config import ROOT, Config, data_dir @@ -31,7 +32,7 @@ from lib.ecowitt import Archive, Ecowitt from lib.forecast import Forecast from lib.irrigation import Tuya -from lib.irrigation.watchdog import CHECK_SECONDS as WATCHDOG_SECONDS +from lib.irrigation.watchdog import CHECK_SECONDS as VALVE_CHECK_SECONDS from lib.llm import Agent from lib.logs import LogFormat from lib.pollen import Pollen @@ -56,15 +57,19 @@ async def start_sources(cfg: Config) -> tuple[list[Source], Config]: location unless one is configured.""" sources: list[Source] = [] - async def start(source: Source): + async def start(kind: type[Source], *args): + """A source made and started; one that fails either way is logged and left out.""" + source = None try: + source = kind(*args) await source.start() sources.append(source) except Exception: - log.exception("%s couldn't start - continuing without it", source.name) - await source.close() + log.exception("%s couldn't start - continuing without it", kind.name) + if source is not None: + await safely(source.close) if cfg.ecowitt: - await start(Ecowitt(cfg)) + await start(Ecowitt, cfg) eco = sources[0] if sources else None if eco and str(eco.tz) != str(cfg.tz): if os.getenv("TZ"): @@ -74,14 +79,101 @@ async def start(source: Source): log.warning("The time zone has no name, only an offset (%s), so daylight saving will put the times out: set TZ, " "e.g. TZ=Australia/Melbourne", cfg.tz) if cfg.airgradient: - await start(AirGradient(cfg)) + await start(AirGradient, cfg) if cfg.pollen: - await start(Pollen(cfg)) + await start(Pollen, cfg) if cfg.forecast: - await start(Forecast(cfg, (eco.latitude, eco.longitude) if eco and eco.latitude is not None else None)) + await start(Forecast, cfg, (eco.latitude, eco.longitude) if eco and eco.latitude is not None else None) return sources, cfg +class Tasks: + """The tasks the bot runs in the background: one that stops with an error is logged as it does, and close() cancels them.""" + + def __init__(self): + self.running: set[asyncio.Task] = set() + + def start(self, work: Coroutine) -> asyncio.Task: + task = asyncio.create_task(work, name=getattr(work, "__qualname__", None)) + self.running.add(task) + task.add_done_callback(self._ended) + return task + + def _ended(self, task: asyncio.Task): + self.running.discard(task) + if not task.cancelled() and (error := task.exception()): + log.error("%s stopped: %s: %s", task.get_name(), type(error).__name__, error, exc_info=error) + + async def close(self): + for task in self.running: + task.cancel() + await asyncio.gather(*self.running, return_exceptions=True) + + +def start_alerts(cfg: Config, bot: Bot, notify: Notifier, tasks: Tasks) -> tuple[list[str], WeatherMonitor | None]: + """Hook up the alert monitor of every source that started and of the irrigation controller. The weather alerts start in + warm_up, once the station has been read. Returns the alert types now running, and the weather monitor.""" + eco, air, pollen, forecast = (bot.by_name.get(name) for name in ("Ecowitt", "AirGradient", "Pollen", "Forecast")) + running: list[str] = [] + weather = WeatherMonitor(eco, bot.state, notify, cfg.alert_cooldown_minutes * 60, cfg.rain_quiet_hours) if eco else None + if air: + tasks.start(every(PARTICULATES_CHECK_SECONDS, ParticulatesMonitor(air, bot.state, notify).check)) + running += ParticulatesMonitor.KINDS + if forecast: + forecast.warmer.after.append(ForecastMonitor(forecast, bot.state, notify).check) # after each refresh (in the day) + running += ForecastMonitor.KINDS + if pollen: + pollen.warmer.after.append(PollenAndAsthmaMonitor(pollen, bot.state, notify).check) # after each refresh (in the day) + running += PollenAndAsthmaMonitor.KINDS + if bot.irrigation: # the daily check (rain delay, battery) and the watchdog that closes the valve after a run + tasks.start(IrrigationMonitor(bot.irrigation, bot.state, notify, cfg.tz, cfg.irrigation_check_hour, eco, forecast).run()) + tasks.start(every(VALVE_CHECK_SECONDS, bot.watchdog.check)) + running += IrrigationMonitor.KINDS + return running, weather + + +async def warm_up(bot: Bot, weather: WeatherMonitor | None, running: list[str], tasks: Tasks): + """Fetch everything once, together, and log one summary line; then start what waited for it: the weather alerts, the + archives (copying each source's history into its cache) and the button charts drawn ahead.""" + eco, air = bot.by_name.get("Ecowitt"), bot.by_name.get("AirGradient") + started, parts = time.monotonic(), [] + + async def ecowitt_part(): + # One request at a time (Ecowitt rate-limits): warm first, so the alerts' own look at the last 3 hours is answered + # from what was just fetched. A failed first refresh must not stop the alerts or the archive: safely() logs it + parts.append(await safely(eco.warm, True) or "Ecowitt failed") + try: + await weather.init() + eco.warmer.after.append(weather.check) # checked on every fresh refresh + running[:0] = WeatherMonitor.KINDS + await weather.check() + except Exception: + log.exception("Weather alerts couldn't start") + # The full history is copied into the cache in the background (a few minutes the first time), so questions rarely + # need Ecowitt inline + tasks.start(Archive(eco).loop()) + + async def air_part(): + parts.append(await safely(air.warm, True) or "AirGradient failed") + tasks.start(safely(air_backfill)) # even if the sensor was unreachable just now + + async def air_backfill(): + started = time.monotonic() + fetched, failed, total = await air.backfill() + log.info("AirGradient archive: %d days cached, %d failed, %d held, %.0fs", fetched, failed, total, time.monotonic() - started) + + try: + await asyncio.gather(*([ecowitt_part()] if eco else []), *([air_part()] if air else [])) + except Exception: + 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) + tasks.start(every(PREDRAW_SECONDS, bot.predraw)) # the button charts, ready before they are pressed + on = [k for k in LABELS if k in running] # the line says what started, not what is configured + log.info("Alerts: %s: %d chats", ", ".join(LABELS[k] for k in on) or "none", len(bot.state.alert_chats())) + if missing := [LABELS[k] for k in available_kinds(bot.by_name, bot.irrigation is not None) if k not in running]: + log.warning("Alerts NOT running: %s (see the error above)", ", ".join(missing)) + + def version() -> str: """The checked-out commit, so the log shows which code is running.""" return commit_in(ROOT / ".git") @@ -113,131 +205,60 @@ async def main(): air_metrics.REQUIRE_CONSECUTIVE = cfg.antispike_require_consecutive intent.FEELS_LIKE_IN_ALL = cfg.chart_all_feels_like intent.VPD_IN_ALL = cfg.chart_all_vpd - sources, cfg = await start_sources(cfg) # in the weather station's time zone from here on - if not sources: - raise SystemExit("No data source is working - nothing to talk about") - eco = next((s for s in sources if isinstance(s, Ecowitt)), None) - air = next((s for s in sources if isinstance(s, AirGradient)), None) - pollen = next((s for s in sources if isinstance(s, Pollen)), None) - forecast = next((s for s in sources if isinstance(s, Forecast)), None) - - composer = Composer(eco, air) if eco and air else None # charts and comparisons across the two sources - tools = Tools([t for s in sources for t in s.tools] + (composer.tools if composer else [])) - 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=on: only admins are answered (see: ADMIN_CHAT_IDS)") - 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") - irrigation = Tuya(cfg) if cfg.irrigation else None - bot = Bot(cfg, agent, sources, state, irrigation) - - app = (Application.builder().token(cfg.telegram_token).concurrent_updates(True) - .defaults(Defaults(disable_notification=True)) # silent messages - .connect_timeout(10).read_timeout(15).write_timeout(15) # default 5s is tight - .build()) - bot.register(app) - - stop = asyncio.Event() - loop = asyncio.get_running_loop() - for sig in (signal.SIGINT, signal.SIGTERM): - loop.add_signal_handler(sig, stop.set) - - tasks: list[asyncio.Task] = [] - archive = Archive(eco) if eco else None - try: - async with app: # one task runs everything, so connections open and close cleanly - await app.start() - await app.updater.start_polling(allowed_updates=Update.ALL_TYPES, drop_pending_updates=False, - error_callback=polling_error) # messages sent while starting are answered (lib.bot.PENDING_MAX_SECONDS) - try: # any startup failure below still runs the shutdown steps (and shows the real error) - log.info("@%s + %s", app.bot.username, cfg.xai_model) - log.info("Sources: %s", ", ".join(s.name for s in sources)) - kinds = available_kinds((s.name for s in sources), irrigation is not None) - notify = Notifier(app.bot, state) - await bot.refresh_keyboards(app.bot) # chats whose buttons are out of date are told, with the new ones - - # Alerts - 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) - if 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) - running += ForecastMonitor.KINDS - if pollen: - 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(every(WATCHDOG_SECONDS, bot.watchdog.check))) - running += IrrigationMonitor.KINDS - - # Keeping warm: everything questions need, refreshed before they arrive - tasks.extend(asyncio.create_task(s.warmer.run()) for s in sources) - log.info("Keeping warm: %s", ", ".join(f"{s.name} {s.warmer.interval:.0f}s" for s in sources)) - - async def startup_warmup(): - """Fetch everything once, together, and log one summary line.""" - started, parts = time.monotonic(), [] - - async def ecowitt_part(): - # One request at a time (Ecowitt rate-limits): warm first, so the alerts' - # own look at the last 3 hours is answered from what was just fetched - # A failed first refresh must not stop the alerts or the archive: safely() logs it - parts.append(await safely(eco.warm, True) or "Ecowitt failed") - 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") - # The full history is copied into the cache in the background (a few minutes - # the first time), so questions rarely need Ecowitt inline - tasks.append(asyncio.create_task(archive.loop())) - - async def air_part(): - parts.append(await safely(air.warm, True) or "AirGradient failed") - tasks.append(asyncio.create_task(safely(air_backfill))) # even if the sensor was unreachable just now - - async def air_backfill(): - started = time.monotonic() - fetched, failed, total = await air.backfill() - log.info("AirGradient archive: %d days cached, %d failed, %d held, %.0fs", - fetched, failed, total, time.monotonic() - started) - - try: - await asyncio.gather(*([ecowitt_part()] if eco else []), *([air_part()] if air else [])) - except Exception: - 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) - tasks.append(asyncio.create_task(every(PREDRAW_SECONDS, bot.predraw))) # the button charts, ready before they are pressed - on = [k for k in LABELS if k in running] # the line says what started, not what is configured - log.info("Alerts: %s: %d chats", ", ".join(LABELS[k] for k in on) or "none", len(state.alert_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() - finally: - for task in tasks: - task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task - await app.updater.stop() - await app.stop() - finally: + # Whatever starts is closed on the way out, the last started first, however the bot stops (a failure part-way included) + async with contextlib.AsyncExitStack() as stack: + sources, cfg = await start_sources(cfg) # in the weather station's time zone from here on for source in sources: - await source.close() + stack.push_async_callback(safely, source.close) + if not sources: + raise SystemExit("No data source is working - nothing to talk about") + by_name = {s.name: s for s in sources} + eco, air = by_name.get("Ecowitt"), by_name.get("AirGradient") + composer = Composer(eco, air) if eco and air else None # charts and comparisons across the two sources + tools = Tools([t for s in sources for t in s.tools] + (composer.tools if composer else [])) + client = AsyncOpenAI(api_key=cfg.xai_api_key, base_url=cfg.xai_base_url) + stack.push_async_callback(safely, client.close) + state = AlertState(cfg.state_path) + if cfg.admin_only: + log.info("ADMIN_ONLY=on: only admins are answered (see: ADMIN_CHAT_IDS)") + 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") + irrigation = Tuya(cfg) if cfg.irrigation else None if irrigation: - await irrigation.close() + stack.push_async_callback(safely, irrigation.close) + bot = Bot(cfg, Agent(client, cfg.xai_model, tools), sources, state, irrigation) + stack.push_async_callback(bot.close) + + app = (Application.builder().token(cfg.telegram_token).concurrent_updates(True) + .defaults(Defaults(disable_notification=True)) # silent messages + .connect_timeout(10).read_timeout(15).write_timeout(15) # default 5s is tight + .build()) + bot.register(app) + stop = asyncio.Event() + for sig in (signal.SIGINT, signal.SIGTERM): + asyncio.get_running_loop().add_signal_handler(sig, stop.set) + await stack.enter_async_context(app) # one task runs everything, so connections open and close cleanly + await app.start() + stack.push_async_callback(app.stop) + await app.updater.start_polling(allowed_updates=Update.ALL_TYPES, drop_pending_updates=False, + error_callback=polling_error) # messages sent while starting are answered (lib.bot.PENDING_MAX_SECONDS) + stack.push_async_callback(app.updater.stop) + tasks = Tasks() + stack.push_async_callback(tasks.close) + for source in sources: + stack.push_async_callback(source.warmer.stop) + + log.info("@%s + %s", app.bot.username, cfg.xai_model) + log.info("Sources: %s", ", ".join(s.name for s in sources)) + notify = Notifier(app.bot, state) + await bot.refresh_keyboards(app.bot) # chats whose buttons are out of date are told, with the new ones + running, weather = start_alerts(cfg, bot, notify, tasks) + for source in sources: # keeping warm: everything questions need, refreshed before they arrive + tasks.start(source.warmer.run()) + log.info("Keeping warm: %s", ", ".join(f"{s.name} {s.warmer.interval:.0f}s" for s in sources)) + tasks.start(warm_up(bot, weather, running, tasks)) + await stop.wait() if __name__ == "__main__": diff --git a/lib/bot.py b/lib/bot.py index a7a8d2d..bc6acb2 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -275,6 +275,12 @@ def __init__(self, cfg: Config, agent: Agent, sources: list[Source], state: Aler self.background: set[asyncio.Task] = set() # tasks running on their own, kept so they aren't dropped self.charted = periods.Charted(state.charts if state else None, state.save if state else None) # the question behind each chart sent (kept across restarts) + async def close(self): + """Cancel the tasks still running on their own (the uploads of a chart's other periods).""" + for task in self.background: + task.cancel() + await asyncio.gather(*self.background, return_exceptions=True) + def register(self, app): app.add_handler(CommandHandler(["start", "help"], self.on_start)) app.add_handler(CommandHandler("reset", self.on_reset)) diff --git a/lib/forecast/source.py b/lib/forecast/source.py index afe57e4..30455c5 100644 --- a/lib/forecast/source.py +++ b/lib/forecast/source.py @@ -89,9 +89,10 @@ class Forecast(Source): def __init__(self, cfg: Config, location: tuple[float, float] | None = None, transport=None): """location: (lat, lon), used when the config has none (the weather station's own).""" self.tz, self.place = cfg.tz, cfg.place - lat = cfg.forecast_lat if cfg.forecast_lat is not None else (location[0] if location else None) - lon = cfg.forecast_lon if cfg.forecast_lon is not None else (location[1] if location else None) - self.location = (lat, lon) if lat is not None and lon is not None else None + configured = (cfg.forecast_lat, cfg.forecast_lon) + if configured != (None, None) and None in configured: + log.warning("FORECAST_LAT and FORECAST_LON go together: with only one set, the weather station's location is used") + self.location = configured if None not in configured else location if location and None not in location else None self.client = httpx.AsyncClient(timeout=15, headers=HEADERS, follow_redirects=True, transport=transport) self.days: list[dict] | None = None self.source = "" diff --git a/lib/warm.py b/lib/warm.py index 11d77a4..48014c0 100644 --- a/lib/warm.py +++ b/lib/warm.py @@ -53,6 +53,12 @@ async def _refresh(self, fresh: bool): async with self._lock: await safely(self.refresh, fresh) + async def stop(self): + """Cancel a refresh a question started, if one is still running (the timed ones end with their task).""" + if self._task is not None: + self._task.cancel() + await asyncio.gather(self._task, return_exceptions=True) + def poke(self): """A question arrived: refresh in the background, unless a refresh (a question's or a timed one) is running.""" if not self._lock.locked() and (self._task is None or self._task.done()): diff --git a/tests/test_forecast.py b/tests/test_forecast.py index e0f9da8..1871e35 100644 --- a/tests/test_forecast.py +++ b/tests/test_forecast.py @@ -115,9 +115,12 @@ async def test_the_forecast_is_fetched_only_in_the_day_and_not_again_while_young await forecast.close() -async def test_the_station_location_is_used_when_none_is_configured_and_without_one_the_source_does_not_start(tmp_path): +async def test_the_station_location_is_used_when_none_is_configured_and_without_one_the_source_does_not_start(tmp_path, caplog): cfg = config(tmp_path, forecast=True) assert Forecast(cfg, (-37.8, 144.9)).location == (-37.8, 144.9) + half = config(tmp_path, forecast=True, forecast_lat=-33.9) # one half alone is not a place: not paired with the station's + assert Forecast(half, (-37.8, 144.9)).location == (-37.8, 144.9) and "FORECAST_LAT and FORECAST_LON go together" in caplog.text + assert Forecast(config(tmp_path, forecast=True, forecast_lat=-33.9, forecast_lon=151.2), (-37.8, 144.9)).location == (-33.9, 151.2) with pytest.raises(RuntimeError, match="FORECAST_LAT"): await Forecast(cfg).start() diff --git a/tests/test_periods.py b/tests/test_periods.py index 92c13b5..f863f2e 100644 --- a/tests/test_periods.py +++ b/tests/test_periods.py @@ -68,6 +68,15 @@ async def run(self, *a, **k): return Bot(NS(tz=TZ), Agent(), sources, alert_state) +async def test_closing_the_bot_cancels_its_uploads_still_running(alert_state, monkeypatch): + import asyncio + bot = make_bot(alert_state, monkeypatch) + upload = asyncio.create_task(asyncio.Event().wait()) + bot.background.add(upload) + await bot.close() + assert upload.cancelled() + + def chat_message(sent, edits=None): async def reply_photo(photo, caption=None, **kw): sent.append((caption, kw.get("reply_markup"))) diff --git a/tests/test_startup.py b/tests/test_startup.py index b1dd195..6e72f39 100644 --- a/tests/test_startup.py +++ b/tests/test_startup.py @@ -154,6 +154,54 @@ async def stop_when_archived(): assert "Startup warm-up failed" not in log # not lost to the catch-all +async def test_a_source_that_cannot_even_be_made_is_left_out_and_the_others_start(tmp_path, monkeypatch, caplog): + eco_t, _ = ecowitt_transport(history_days=45) + monkeypatch.setattr(ecobot.Ecowitt, "__init__", lambda self, c, orig=ecobot.Ecowitt.__init__: orig(self, c, eco_t)) + + def broken(self, cfg, transport=None): + raise OSError("the cache folder is read-only") + monkeypatch.setattr(ecobot.AirGradient, "__init__", broken) + sources, _ = await ecobot.start_sources(config(tmp_path)) + for s in sources: + await s.close() + assert [s.name for s in sources] == ["Ecowitt"] and "AirGradient couldn't start - continuing without it" in caplog.text + + +async def test_a_failure_part_way_through_starting_closes_everything_already_open(tmp_path, monkeypatch, caplog): + """Telegram fails once the sources, the model's client and the connection are up: each is closed, the last opened first, + and a close that fails doesn't stop the rest. The bot stops with the error that stopped it.""" + cfg = config(tmp_path) + eco_t, _ = ecowitt_transport(history_days=45) + air_t, _ = air_transport(oldest=datetime.now(timezone.utc) - timedelta(days=5)) + app, closed = FakeApp(), [] + app.stop = lambda: closed.append("telegram") or app._noop() + 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))) + + async def stuck_disk(self): + closed.append("Ecowitt") + raise OSError("disk I/O error") + monkeypatch.setattr(ecobot.Ecowitt, "close", stuck_disk) + monkeypatch.setattr(ecobot.AirGradient, "close", lambda self, orig=ecobot.AirGradient.close: closed.append("AirGradient") or orig(self)) + + class Client: + def __init__(self, **settings): + pass + + async def close(self): + closed.append("model") + monkeypatch.setattr(ecobot, "AsyncOpenAI", Client) + + async def down(self, bot): + raise RuntimeError("Telegram is down") + monkeypatch.setattr(ecobot.Bot, "refresh_keyboards", down) + with pytest.raises(RuntimeError, match="Telegram is down"): + await ecobot.main() + assert closed == ["telegram", "model", "AirGradient", "Ecowitt"] and "disk I/O error" in caplog.text + + async def test_pollen_and_forecast_start_only_when_switched_on_and_the_forecast_uses_the_stations_location(tmp_path, monkeypatch): from tests.test_forecast import transport as forecast_transport from tests.test_pollen import page, transport as pollen_transport diff --git a/tests/test_warm.py b/tests/test_warm.py index c7641f8..d641121 100644 --- a/tests/test_warm.py +++ b/tests/test_warm.py @@ -29,6 +29,16 @@ async def refresh(fresh): assert all(a[0] != b[0] for a, b in zip(seen, seen[1:])) # every refresh ends before the next starts +async def test_stopping_a_warmer_cancels_the_refresh_a_question_started(): + async def refresh(fresh): + await asyncio.Event().wait() # never ends by itself + warmer = Warmer(refresh) + warmer.poke() + await asyncio.sleep(0) + await warmer.stop() + assert warmer._task.cancelled() + + async def test_a_background_failure_has_its_traceback_logged_only_when_it_points_at_code(caplog): async def broken(): raise TypeError("bad") From 57a1b53f863f4ad5e02899538ab5b1c05da72b2f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 19:41:44 +0000 Subject: [PATCH 47/65] Charts drawn without pyplot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A chart is a matplotlib Figure on its own Agg canvas (_build), not a pyplot figure. Nothing joins pyplot's global registry, so nothing has to be closed, on a failure or otherwise; the try/except and the two plt.close calls go. _png now takes the drawn pixels rather than the figure. The output is the same bytes: the 7 sample charts and the 16 dump_specs charts are byte-identical. The speed-up the plan expected isn't there, so not all of the plan was kept: - One chart takes the same time: 157 ms before, 158 ms after. - Writing the PNG outside the lock made 14 charts drawn at once slower (2.78 s against 2.34 s). With Python's one interpreter thread, the encoding only competes with the next chart's drawing. The whole render stays under the lock. - np.unique's return_inverse, meant to replace the 16 MB lookup table, takes about 50 ms a chart where the table takes 7 ms, and uses more memory at its peak (35 against 23 MB). The table stays. A test built its axes through `matplotlib.pyplot` after a bare `import matplotlib`; it worked only because lib.charts imported pyplot. It now builds a Figure itself. The unused Polygon import goes. lib: +89 โˆ’93 lines (most of it the body of _build moving out of the try). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/charts.py | 182 ++++++++++++++++++++++---------------------- tests/test_specs.py | 4 +- 2 files changed, 91 insertions(+), 95 deletions(-) diff --git a/lib/charts.py b/lib/charts.py index f51a550..62789a3 100644 --- a/lib/charts.py +++ b/lib/charts.py @@ -19,21 +19,20 @@ from statistics import fmean import matplotlib - -matplotlib.use("Agg") # no display on the server -import matplotlib.dates as mdates # noqa: E402 -import matplotlib.pyplot as plt # noqa: E402 -import numpy as np # noqa: E402 -from matplotlib import font_manager # noqa: E402 -from matplotlib.collections import PatchCollection, PolyCollection # noqa: E402 -from matplotlib.colors import to_rgb # noqa: E402 -from matplotlib.lines import Line2D # noqa: E402 -from matplotlib.patches import Polygon, Rectangle, Wedge # noqa: E402 -from matplotlib.ticker import FixedLocator, FuncFormatter, MaxNLocator # noqa: E402 -from matplotlib.transforms import ScaledTranslation # noqa: E402 -from PIL import Image # noqa: E402 - -from .specs import Chart, Compass, Line, Panel # noqa: E402 +import matplotlib.dates as mdates +import numpy as np +from matplotlib import font_manager +from matplotlib.backends.backend_agg import FigureCanvasAgg +from matplotlib.collections import PatchCollection, PolyCollection +from matplotlib.colors import to_rgb +from matplotlib.figure import Figure +from matplotlib.lines import Line2D +from matplotlib.patches import Rectangle, Wedge +from matplotlib.ticker import FixedLocator, FuncFormatter, MaxNLocator +from matplotlib.transforms import ScaledTranslation +from PIL import Image + +from .specs import Chart, Compass, Line, Panel # Palette. Slate neutrals for the furniture; one hue per reading (weather and air quality, kept clear of the green/yellow/red # rating zones); the same reading indoors is its complementary hue (opposite on the colour wheel, as a painter pairs them). @@ -112,7 +111,7 @@ def _setup_fonts() -> str: installed = {f.name for f in font_manager.fontManager.ttflist} family = next((n for n in ("Inter", "Roboto", "Helvetica Neue", "Arial", "Liberation Sans") if n in installed), "DejaVu Sans") - plt.rcParams.update({"font.family": family, "axes.unicode_minus": False}) + matplotlib.rcParams.update({"font.family": family, "axes.unicode_minus": False}) return family @@ -542,60 +541,56 @@ def _draw_panel(ax, p: Panel, first: int, x0: float, x1: float, look: Look): return len(lines), x1, entries -def _render(chart: Chart, tz: tzinfo, times: dict | None = None) -> bytes: +def _build(chart: Chart, tz: tzinfo) -> Figure: """The panels top to bottom on one time axis, so the rain (or another reading) lines up with what the others were doing. A chart of one panel of lines is the same thing drawn BIG (and with its wind rose beside it, when it has one).""" - started = time.perf_counter() panels, n = chart.panels, len(chart.panels) look = _look_for(chart) height = H_IN if n <= 2 else H_IN + PANEL_IN * (n - 2) - fig = plt.figure(figsize=(W_IN, height), dpi=DPI, facecolor=BG) - try: - _headline(fig, panels[0].label if look.in_headline else chart.title, chart.subtitle, height, panels[0].unit if look.in_headline else "") - width = _axes_width(chart) - body = height - HEAD_IN - FOOT_IN - gap = 0.27 if n > 2 else 0.22 - each = (body - gap * (n - 1)) / n - xs = _nums({t for p in panels for t in p.xs}) # each time once: only the range is wanted - x0, x1 = float(xs.min()), float(xs.max()) - axes, used = [], 0 - for i, p in enumerate(panels): - bottom = (FOOT_IN + (n - 1 - i) * (each + gap)) / height - rect = [AX_RECT[0], bottom, width, each / height] - if look.card: # the faint tint behind the panel: a rectangle on the figure (an axes of its own costs far more) - fig.add_artist(Rectangle(rect[:2], rect[2], rect[3], transform=fig.transFigure, facecolor=CARD, edgecolor="none", - linewidth=0, zorder=-1)) - ax = fig.add_axes(rect, facecolor="none" if look.card else BG, sharex=axes[0] if axes else None) - axes.append(ax) - _style_axis(ax) - count, x1, entries = _draw_panel(ax, p, used, x0, x1, look) - used += count - if len(entries) >= look.key_from: # the colour key: beside the headline for a big chart, above the panel in a stack - key = dict(loc="center right", bbox_to_anchor=(AX_RECT[0] + AX_RECT[2], 1 - 0.27 / height)) if look.in_headline else \ - dict(loc="lower right", bbox_to_anchor=(1.0, 1.0), borderaxespad=0.1) - _legend_dots(fig if look.in_headline else ax, [c for c, _ in entries], [label for _, label in entries], ncol=len(entries), - fontsize=look.legend_font, **key) - deg = _deg(p.unit) - tick_unit = deg if deg == "ยฐ" else "" - ax.yaxis.set_major_locator(MaxNLocator(nbins=3 if n > 2 else look.nbins, steps=[1, 2, 2.5, 5, 10], - integer=bool(p.bars and not p.lines and p.bars.unit == "days"))) - ax.yaxis.set_major_formatter(FuncFormatter(lambda v, _, u=tick_unit: f"{v:g}{u}")) - if not look.in_headline: - title = f"{p.label} ({p.unit})" if p.unit and not tick_unit else p.label - ax.set_title(title, loc="left", fontsize=8.5, fontweight=TITLE_WEIGHT, color=TEXT, pad=4, y=1.0) # y: placed, not searched for - if i < n - 1: - ax.xaxis.set_visible(False) # the time is read off the bottom panel (these ticks have no length and no grid) - margin = (x1 - x0) * 0.015 # room for the end dots - axes[0].set_xlim(x0 - margin, x1 + margin) - _time_axis(axes[-1], x1 - x0, tz, panels[-1].bars if not panels[-1].lines else None) - if chart.compass: # beside the wind line - pos = axes[0].get_position() - axes[0].set_position([pos.x0, pos.y0, 0.57, pos.height]) - _render_rose(fig, chart.compass) - except Exception: - plt.close(fig) - raise - return _png(fig, times, started) + fig = Figure(figsize=(W_IN, height), dpi=DPI, facecolor=BG) + FigureCanvasAgg(fig) # drawn on its own canvas: no pyplot, whose figures would need closing + _headline(fig, panels[0].label if look.in_headline else chart.title, chart.subtitle, height, panels[0].unit if look.in_headline else "") + width = _axes_width(chart) + body = height - HEAD_IN - FOOT_IN + gap = 0.27 if n > 2 else 0.22 + each = (body - gap * (n - 1)) / n + xs = _nums({t for p in panels for t in p.xs}) # each time once: only the range is wanted + x0, x1 = float(xs.min()), float(xs.max()) + axes, used = [], 0 + for i, p in enumerate(panels): + bottom = (FOOT_IN + (n - 1 - i) * (each + gap)) / height + rect = [AX_RECT[0], bottom, width, each / height] + if look.card: # the faint tint behind the panel: a rectangle on the figure (an axes of its own costs far more) + fig.add_artist(Rectangle(rect[:2], rect[2], rect[3], transform=fig.transFigure, facecolor=CARD, edgecolor="none", + linewidth=0, zorder=-1)) + ax = fig.add_axes(rect, facecolor="none" if look.card else BG, sharex=axes[0] if axes else None) + axes.append(ax) + _style_axis(ax) + count, x1, entries = _draw_panel(ax, p, used, x0, x1, look) + used += count + if len(entries) >= look.key_from: # the colour key: beside the headline for a big chart, above the panel in a stack + key = dict(loc="center right", bbox_to_anchor=(AX_RECT[0] + AX_RECT[2], 1 - 0.27 / height)) if look.in_headline else \ + dict(loc="lower right", bbox_to_anchor=(1.0, 1.0), borderaxespad=0.1) + _legend_dots(fig if look.in_headline else ax, [c for c, _ in entries], [label for _, label in entries], ncol=len(entries), + fontsize=look.legend_font, **key) + deg = _deg(p.unit) + tick_unit = deg if deg == "ยฐ" else "" + ax.yaxis.set_major_locator(MaxNLocator(nbins=3 if n > 2 else look.nbins, steps=[1, 2, 2.5, 5, 10], + integer=bool(p.bars and not p.lines and p.bars.unit == "days"))) + ax.yaxis.set_major_formatter(FuncFormatter(lambda v, _, u=tick_unit: f"{v:g}{u}")) + if not look.in_headline: + title = f"{p.label} ({p.unit})" if p.unit and not tick_unit else p.label + ax.set_title(title, loc="left", fontsize=8.5, fontweight=TITLE_WEIGHT, color=TEXT, pad=4, y=1.0) # y: placed, not searched for + if i < n - 1: + ax.xaxis.set_visible(False) # the time is read off the bottom panel (these ticks have no length and no grid) + margin = (x1 - x0) * 0.015 # room for the end dots + axes[0].set_xlim(x0 - margin, x1 + margin) + _time_axis(axes[-1], x1 - x0, tz, panels[-1].bars if not panels[-1].lines else None) + if chart.compass: # beside the wind line + pos = axes[0].get_position() + axes[0].set_position([pos.x0, pos.y0, 0.57, pos.height]) + _render_rose(fig, chart.compass) + return fig def _palette(rgb: np.ndarray, counts: np.ndarray, size: int = 256) -> tuple[list[int], np.ndarray]: @@ -629,37 +624,38 @@ def distance_to(k: int) -> np.ndarray: # every colour's squared distance to co return taken, nearest -def _png(fig, times: dict | None = None, started: float = 0.0) -> bytes: - """The figure as a PNG of at most 256 colours (see _palette): well under half the size of a full-colour one, and quicker to - write. A chart has a few thousand colours, nearly all of them the in-between shades on the edges of lines and letters. - times: the seconds since `started` (the clock when the figure was begun) are added to its "draw", and the PNG's to its "png".""" - try: - fig.canvas.draw() - drawn = time.perf_counter() - width, height = fig.canvas.get_width_height(physical=True) - pixels = np.frombuffer(fig.canvas.buffer_rgba(), "> 8 & 255, colours >> 16], axis=1) - taken, nearest = _palette(rgb, counts) - lookup = np.empty(1 << 24, np.uint8) # each colour's place in the palette - lookup[colours] = nearest - image = Image.frombuffer("P", (width, height), lookup[pixels], "raw", "P", 0, 1) - image.putpalette(rgb[taken].astype(np.uint8).tobytes()) - buf = io.BytesIO() - image.save(buf, format="png", dpi=(DPI, DPI)) - if times is not None: - times["draw"] = times.get("draw", 0.0) + drawn - started - times["png"] = times.get("png", 0.0) + time.perf_counter() - drawn - return buf.getvalue() - finally: - plt.close(fig) - - -_DRAWING = threading.Lock() # pyplot keeps global state (current figure, rcParams): one chart is drawn at a time +def _png(pixels: np.ndarray, width: int, height: int) -> bytes: + """The picture (each pixel as one number: red + greenยท256 + blueยท65536) as a PNG of at most 256 colours (see _palette): well + under half the size of a full-colour one, and quicker to write. A chart has a few thousand colours, nearly all of them the + in-between shades on the edges of lines and letters.""" + colours, counts = np.unique(pixels, return_counts=True) + rgb = np.stack([colours & 255, colours >> 8 & 255, colours >> 16], axis=1) + taken, nearest = _palette(rgb, counts) + lookup = np.empty(1 << 24, np.uint8) # each colour's place in the palette: quicker than np.unique's return_inverse (a sort of every pixel) + lookup[colours] = nearest + image = Image.frombuffer("P", (width, height), lookup[pixels], "raw", "P", 0, 1) + image.putpalette(rgb[taken].astype(np.uint8).tobytes()) + buf = io.BytesIO() + image.save(buf, format="png", dpi=(DPI, DPI)) + return buf.getvalue() + + +# matplotlib's fonts and text layout are shared between threads: one chart at a time. Its PNG is written under the lock too: with +# Python's one-thread-at-a-time interpreter, writing it beside the next chart's drawing only slows both. +_DRAWING = threading.Lock() def render(chart: Chart, tz: tzinfo, times: dict | None = None) -> bytes: """PNG bytes for one chart (1280x720; a stack of more than two panels is taller). Safe to call from several threads. times (a Turn's): the seconds spent drawing the figure and writing the PNG are added to its "draw" and "png".""" + started = time.perf_counter() with _DRAWING: - return _render(chart, tz, times) + fig = _build(chart, tz) + fig.canvas.draw() + drawn = time.perf_counter() + width, height = fig.canvas.get_width_height(physical=True) + png = _png(np.frombuffer(fig.canvas.buffer_rgba(), " Date: Sun, 18 Oct 2026 19:47:59 +0000 Subject: [PATCH 48/65] charts.py: the panel kinds drawn apart, one pill and one legend, names for the numbers that must agree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _draw_panel picks one of three: _draw_shares (traffic-light shares), _draw_bars_alone (rain alone, or a count or figure per month or year) and _draw_readings (lines). - _pill() is the value pill all three pill annotations were writing out, and _legend() the colour key, the shares key and the rose's speed key (three spellings of one unframed row of markers). - BAR_FILL and _bar_width() replace `width / 86400 * 0.85` (five copies). TITLE_IN and SUBTITLE_IN place the headline, and the colour key beside a big chart's title, which must line up with it. ROSE_RECT and BESIDE_ROSE_WIDTH are the wind rose and the line's plot beside it, which must not overlap. - BIG and SMALL are written by keyword (16 bare numbers before). Panel.bars_only and Chart.single name the two shape tests charts.py and specs.py spelled out. - M6: _mark_highs, which labels the peaks beside the lines of a panel of particles, drew its dots and pills at the small chart's size even on a big chart. It now uses the chart's look: the particulates chart on its own (the Particulates button, the particulates alert) gets the dots and pills every other big chart has. A stack's panels are unchanged, since the small look's sizes are the ones it used. - A test fails if a weather reading or air metric has no colour. Every sample chart and dump_specs chart is byte-identical: none is a particulates chart on its own, which M6 changes by design (looked at, before and after). lib: +98 โˆ’59 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/charts.py | 143 ++++++++++++++++++++++--------------- lib/specs.py | 14 +++- tests/test_chart_labels.py | 7 ++ 3 files changed, 105 insertions(+), 59 deletions(-) diff --git a/lib/charts.py b/lib/charts.py index 62789a3..86ea0dd 100644 --- a/lib/charts.py +++ b/lib/charts.py @@ -62,10 +62,19 @@ AX_RECT = [0.075, 0.13, 0.905, 0.64] # left, bottom, width, height (figure fraction) of a single chart PANEL_IN = 1.1 # each panel past two adds this much height (inches) HEAD_IN, FOOT_IN = 0.9, 0.47 # room above and below the panels (inches) +TITLE_IN, SUBTITLE_IN = 0.27, 0.52 # the headline's lines, centred this far below the top (the colour key beside a big chart's title too) +ROSE_RECT = [0.685, 0.12, 0.27, 0.62] # the wind rose beside a wind chart (figure fraction); the line's plot ends before it, at: +BESIDE_ROSE_WIDTH = 0.57 +BAR_FILL = 0.85 # a bar fills this share of its slot; the rest is the gap to the next LEADER_CLEARANCE = 0.05 # peak labels sit at least this share of the panel's width away from the peaks (shorter = closer) BARS_SHARE = 0.95 # rain behind a line is drawn over the panel's full height (the tallest bar nearly reaches the top), under the line +def _bar_width(seconds: float) -> float: + """A bar's drawn width on the date axis, for bars `seconds` apart.""" + return seconds / 86400 * BAR_FILL + + @dataclass(frozen=True) class Look: """How large the parts of a panel of lines are drawn. A chart of one panel is drawn BIG; the panels of a stack SMALL.""" @@ -87,13 +96,15 @@ class Look: in_headline: bool # the panel's name and unit are the figure's headline, and its colour key sits beside it -BIG = Look(30, 1.5, 8.5, 18, 1.2, 9, (0.35, 0.8), 6, 17, 0.26, 10, 1, True, 5, False, True) -SMALL = Look(20, 1.2, 7.5, 12, 0.8, 5, (0.25, 0.6), 4, 14, 0.3, 8.5, 2, False, 4, True, False) +BIG = Look(end_dot=30, end_rim=1.5, pill_font=8.5, pill_dot=18, pill_rim=1.2, pill_lift=9, pill_box=(0.35, 0.8), pill_z=6, pitch=17, + pad=0.26, legend_font=10, key_from=1, units=True, nbins=5, card=False, in_headline=True) +SMALL = Look(end_dot=20, end_rim=1.2, pill_font=7.5, pill_dot=12, pill_rim=0.8, pill_lift=5, pill_box=(0.25, 0.6), pill_z=4, pitch=14, + pad=0.3, legend_font=8.5, key_from=2, units=False, nbins=4, card=True, in_headline=False) def _look_for(chart: Chart) -> Look: """A chart of one panel of lines is drawn BIG; anything else (a stack, rain alone) SMALL.""" - return BIG if len(chart.panels) == 1 and chart.panels[0].lines else SMALL + return BIG if chart.single else SMALL def _setup_fonts() -> str: @@ -151,6 +162,12 @@ def _box(colour: str, pad: float = 0.25, rounding: float = 0.6) -> dict: return {"boxstyle": f"round,pad={pad},rounding_size={rounding}", "fc": colour, "ec": "none"} +def _pill(ax, text: str, at: tuple[float, float], colour: str, look: "Look", **place): + """A value on a pill of its line's colour, in the chart's look; `place` puts it (xytext, textcoords, ha, va, a leader ...).""" + ax.annotate(text, at, fontsize=look.pill_font, fontweight="bold", color=_ink_on(colour), bbox=_box(colour, *look.pill_box), + zorder=look.pill_z + 1, **place) + + def _dot(ax, x: float, y: float, colour: str, size: float = 12, edge: float = 0.8, z: int = 4): """A small dot with a white rim, marking a point on a line (`size` is its area in ptยฒ, as scatter takes it). It is a one-point line's marker: drawn just as a one-point scatter is, at a fraction of the cost.""" @@ -246,7 +263,7 @@ def _bars_behind(ax, bars) -> float: the scale's two labels sit where a right-hand axis would put them. Returns where the last bar ends (matplotlib date number).""" bx = _nums(bars.x) top = max([*bars.y, 1.0]) - _draw_bars(ax, bx, np.asarray(bars.y, dtype=float) * (BARS_SHARE / top), bars.width / 86400 * 0.85, 1.0, 1, + _draw_bars(ax, bx, np.asarray(bars.y, dtype=float) * (BARS_SHARE / top), _bar_width(bars.width), 1.0, 1, transform=ax.get_xaxis_transform()) ax.add_line(Line2D([0, 1], [BARS_SHARE, BARS_SHARE], transform=ax.transAxes, color=RAIN, linewidth=0.7, linestyle=(0, (1, 2)), alpha=0.8, zorder=1)) # the top of the rain scale, on its own @@ -268,7 +285,7 @@ def _axes_width(chart: Chart) -> float: def _headline(fig, title: str, subtitle: str, height: float, unit: str = ""): """Title and subtitle at the top left; a unit that is not a degree goes in the title (ticks stay plain numbers).""" title = f"{title} ({unit})" if unit and _deg(unit) != "ยฐ" else title - head = fig.text(AX_RECT[0], 1 - 0.27 / height, title, fontsize=13, fontweight=TITLE_WEIGHT, color=TEXT, va="center") + head = fig.text(AX_RECT[0], 1 - TITLE_IN / height, title, fontsize=13, fontweight=TITLE_WEIGHT, color=TEXT, va="center") room = AX_RECT[2] * fig.get_figwidth() * fig.dpi # the width of the plots below renderer = fig.canvas.get_renderer() names = title.split(", ") @@ -279,14 +296,14 @@ def _headline(fig, title: str, subtitle: str, height: float, unit: str = ""): while head.get_window_extent(renderer).width > room and kept > 2: # a long list of readings: the first few "and N more" kept -= 1 head.set_text(", ".join(names[:kept]) + f" and {len(names) - kept} more") - fig.text(AX_RECT[0], 1 - 0.52 / height, subtitle, fontsize=8.5, color=MUTED, va="center") + fig.text(AX_RECT[0], 1 - SUBTITLE_IN / height, subtitle, fontsize=8.5, color=MUTED, va="center") def _time_axis(ax, span_days: float, tz: tzinfo, bars=None): """Tick positions and labels for a date axis, chosen by the length of the period. Bars of whole months or years (a count per month or year) are labelled under the middle of each bar instead. The ticks fall on local hours, days and months.""" if bars and bars.per in ("year", "month") and bars.x and (bars.per == "year" or len(bars.x) <= 14): - centres = _nums(bars.x) + bars.width / 86400 * 0.85 / 2 + centres = _nums(bars.x) + _bar_width(bars.width) / 2 fmt = "%Y" if bars.per == "year" else "%b\n'%y" ax.xaxis.set_major_locator(FixedLocator(list(centres))) ax.xaxis.set_major_formatter(FuncFormatter(lambda v, _: mdates.num2date(v, tz).strftime(fmt))) @@ -310,9 +327,12 @@ def _time_axis(ax, span_days: float, tz: tzinfo, bars=None): lambda v, _: mdates.num2date(v, tz).strftime(fmt).replace("AM", "am").replace("PM", "pm"))) -def _legend_dots(ax_or_fig, colours: list[str], labels: list[str], **kw): - handles = [Line2D([], [], marker="o", linestyle="", markersize=6, color=c) for c in colours] - ax_or_fig.legend(handles, labels, frameon=False, labelcolor=TEXT, handletextpad=0.2, columnspacing=1.1, **kw) +def _legend(where, colours: list[str], labels: list[str], marker: str = "o", size: float = 6, ink: str = TEXT, spacing: float = 1.1, + **kw): + """A row of coloured markers (dots; squares for shares and the rose's speeds), each with its name, unframed: `where` is the + axes or the figure, `kw` where it goes and its font.""" + handles = [Line2D([], [], marker=marker, linestyle="", markersize=size, color=c) for c in colours] + where.legend(handles, labels, frameon=False, labelcolor=ink, handletextpad=0.2, columnspacing=spacing, **kw) def _pills(ax, lines: list[Line], drawn: list[tuple], x0: float, x1: float, look: Look, deg: str = ""): @@ -354,9 +374,8 @@ def _pills(ax, lines: list[Line], drawn: list[tuple], x0: float, x1: float, look else: left[5], right[5] = "right", "left" for rx, ry, text, colour, above, ha in pills: - ax.annotate(text, (rx, ry), xytext=(0, look.pill_lift if above else -look.pill_lift), textcoords="offset points", ha=ha, - va="bottom" if above else "top", fontsize=look.pill_font, fontweight="bold", color=_ink_on(colour), - bbox=_box(colour, *look.pill_box), zorder=look.pill_z + 1) + _pill(ax, text, (rx, ry), colour, look, xytext=(0, look.pill_lift if above else -look.pill_lift), textcoords="offset points", + ha=ha, va="bottom" if above else "top") def _rose_radius(outer: np.ndarray) -> float: @@ -375,7 +394,7 @@ def _render_rose(fig, compass: Compass): way, stacked by wind speed. It sits beside the wind speed line. The wedges are one collection in the polar axes' own units (the centre at 0.5, 0.5 and the edge at radius 0.5): 48 polar bars cost several times as much to make and draw.""" counts = np.asarray(compass.rose, dtype=float) # 16 compass points x 3 speed steps - ax = fig.add_axes([0.685, 0.12, 0.27, 0.62], projection="polar") + ax = fig.add_axes(ROSE_RECT, projection="polar") outer = np.cumsum(counts / max(counts.sum(), 1) * 100, axis=1) # where each step ends, out from the centre radius = _rose_radius(outer) ax.set_ylim(0, radius) # the grid's rings follow it @@ -395,9 +414,8 @@ def _render_rose(fig, compass: Compass): ax.set_facecolor(BG) if compass.speeds: a, b = compass.speed_steps - fig.legend([Line2D([], [], marker="s", linestyle="", markersize=6, color=c) for c in WIND_STEPS], - [f"under {a}", f"{a}โ€“{b}", f"{b}+ km/h"], loc="lower right", bbox_to_anchor=(0.985, 0.0), ncol=3, - frameon=False, fontsize=8.5, labelcolor=MUTED, handletextpad=0.2, columnspacing=0.9) + _legend(fig, WIND_STEPS, [f"under {a}", f"{a}โ€“{b}", f"{b}+ km/h"], marker="s", ink=MUTED, spacing=0.9, loc="lower right", + bbox_to_anchor=(0.985, 0.0), ncol=3, fontsize=8.5) # ---------- several panels on one time axis ---------- @@ -423,7 +441,7 @@ def _mark_highs(ax, lines: list[Line], drawn: list[tuple], x0: float, x1: float, mx, my = _extreme(line, "high", xs) peaks.append((mx, my, colour)) peaks.sort(key=lambda p: -p[1]) # the highest peak gets the top pill - size, pitch = look.pill_font, look.pitch # the pills' font and row spacing, in points + pitch = look.pitch # the pills' row spacing, in points groups = [] # peaks at about the same time share a column, stacked for peak in peaks: # (highest first) near = next((g for g in groups if abs(g[0][0] - peak[0]) < 0.05 * (x1 - x0)), None) @@ -450,19 +468,17 @@ def _mark_highs(ax, lines: list[Line], drawn: list[tuple], x0: float, x1: float, if len(columns) == len(groups): for g, cx in zip(groups, columns): for i, (mx, my, colour) in enumerate(g): - _dot(ax, mx, my, colour) - ax.annotate(f"{round(my, 1):g}", (mx, my), xytext=(cx, y_hi - (10 + pitch * i) * per_pt), textcoords="data", ha="center", - va="center", fontsize=size, fontweight="bold", color=_ink_on(colour), zorder=5, arrowprops=arrow(colour), - bbox=_box(colour)) + _dot(ax, mx, my, colour, look.pill_dot, look.pill_rim, look.pill_z) + _pill(ax, f"{round(my, 1):g}", (mx, my), colour, look, xytext=(cx, y_hi - (10 + pitch * i) * per_pt), textcoords="data", + ha="center", va="center", arrowprops=arrow(colour)) return placed = [] # no empty column: the right margin, at the peaks' heights for mx, my, colour in peaks: ly = min(my, placed[-1] - pitch * per_pt) if placed else my placed.append(ly) - _dot(ax, mx, my, colour) - ax.annotate(f"{round(my, 1):g}", (mx, my), xytext=(1.03, ly), textcoords=("axes fraction", "data"), ha="left", va="center", - fontsize=size, fontweight="bold", color=_ink_on(colour), zorder=5, annotation_clip=False, arrowprops=arrow(colour), - bbox=_box(colour)) + _dot(ax, mx, my, colour, look.pill_dot, look.pill_rim, look.pill_z) + _pill(ax, f"{round(my, 1):g}", (mx, my), colour, look, xytext=(1.03, ly), textcoords=("axes fraction", "data"), ha="left", + va="center", annotation_clip=False, arrowprops=arrow(colour)) def _figure(value: float) -> str: @@ -490,32 +506,45 @@ def _end_labels(ax, drawn: list[tuple], show_floor: bool = False): def _draw_panel(ax, p: Panel, first: int, x0: float, x1: float, look: Look): - """One panel: lines (with bands and zones, peaks labelled, rain behind them), rain on its own, or shares. Returns the - number of colours used, where the drawing ends on the x axis and the legend's entries (colour, name).""" + """One panel: shares, bars on their own (rain, counts), or lines (with bands and zones, peaks labelled, rain behind them). + Returns the number of colours used, where the drawing ends on the x axis and the legend's entries (colour, name).""" if p.shares: - s = p.shares # the share of each bar's time in good / poor / very poor: traffic-light bars stacked to 100% - bx = _nums(s.x) - base = np.zeros(len(bx)) - for column, colour in zip((s.good, s.poor, s.very_poor), ZONE_COLOURS): - _columns(ax, bx, column, s.width / 86400 * 0.85, base, facecolors=colour, zorder=3) - base += np.asarray(column, dtype=float) - ax.set_ylim(0, 100) - ax.legend([Line2D([], [], marker="s", linestyle="", markersize=5, color=c) for c in ZONE_COLOURS], - ["good", "poor", "very poor"], loc="lower right", bbox_to_anchor=(1.0, 1.0), frameon=False, fontsize=8.5, - labelcolor=TEXT, ncol=3, handletextpad=0.2, columnspacing=0.9, borderaxespad=0.1) - return 0, _end_of(bx, s.width, x1), [] - if not p.lines: # rain on its own - b = p.bars - bx = _nums(b.x) - top = max([*b.y, 1.0]) * 1.15 - bottom = min([*b.y, 0.0]) * 1.3 # a figure can be below zero (a coldest night) - _draw_bars(ax, bx, b.y, b.width / 86400 * 0.85, top, 3, alpha=0.75) - if b.values: # a few bars of counts or figures: the number at the end of each - for x, y in zip(bx, b.y): - ax.text(x + b.width / 86400 * 0.85 / 2, y + (top - bottom) * (0.015 if y >= 0 else -0.015), f"{y:g}", ha="center", - va="bottom" if y >= 0 else "top", fontsize=8, color=TEXT, zorder=5) - ax.set_ylim(bottom, top) - return 0, _end_of(bx, b.width, x1), [] + return _draw_shares(ax, p.shares, x1) + if p.bars_only: + return _draw_bars_alone(ax, p.bars, x1) + return _draw_readings(ax, p, first, x0, x1, look) + + +def _draw_shares(ax, s, x1: float): + """The share of each bar's time in good / poor / very poor: traffic-light bars stacked to 100%.""" + bx = _nums(s.x) + base = np.zeros(len(bx)) + for column, colour in zip((s.good, s.poor, s.very_poor), ZONE_COLOURS): + _columns(ax, bx, column, _bar_width(s.width), base, facecolors=colour, zorder=3) + base += np.asarray(column, dtype=float) + ax.set_ylim(0, 100) + _legend(ax, ZONE_COLOURS, ["good", "poor", "very poor"], marker="s", size=5, spacing=0.9, loc="lower right", + bbox_to_anchor=(1.0, 1.0), fontsize=8.5, ncol=3, borderaxespad=0.1) + return 0, _end_of(bx, s.width, x1), [] + + +def _draw_bars_alone(ax, b, x1: float): + """Rain on its own, or a count or figure per month or year (a few bars of them get their number on the end).""" + bx = _nums(b.x) + top = max([*b.y, 1.0]) * 1.15 + bottom = min([*b.y, 0.0]) * 1.3 # a figure can be below zero (a coldest night) + _draw_bars(ax, bx, b.y, _bar_width(b.width), top, 3, alpha=0.75) + if b.values: + for x, y in zip(bx, b.y): + ax.text(x + _bar_width(b.width) / 2, y + (top - bottom) * (0.015 if y >= 0 else -0.015), f"{y:g}", ha="center", + va="bottom" if y >= 0 else "top", fontsize=8, color=TEXT, zorder=5) + ax.set_ylim(bottom, top) + return 0, _end_of(bx, b.width, x1), [] + + +def _draw_readings(ax, p: Panel, first: int, x0: float, x1: float, look: Look): + """The panel's lines, each with its band, the rating zones of a rated reading behind it, the peaks labelled (as pills on the + lines, or aside), rain behind them on a scale of its own, and the latest values beside them.""" lines = p.lines marked = [i for i, s in enumerate(lines) if s.records and p.peaks != "none"] pilled = bool(marked) and p.peaks == "pills" @@ -569,14 +598,14 @@ def _build(chart: Chart, tz: tzinfo) -> Figure: count, x1, entries = _draw_panel(ax, p, used, x0, x1, look) used += count if len(entries) >= look.key_from: # the colour key: beside the headline for a big chart, above the panel in a stack - key = dict(loc="center right", bbox_to_anchor=(AX_RECT[0] + AX_RECT[2], 1 - 0.27 / height)) if look.in_headline else \ + key = dict(loc="center right", bbox_to_anchor=(AX_RECT[0] + AX_RECT[2], 1 - TITLE_IN / height)) if look.in_headline else \ dict(loc="lower right", bbox_to_anchor=(1.0, 1.0), borderaxespad=0.1) - _legend_dots(fig if look.in_headline else ax, [c for c, _ in entries], [label for _, label in entries], ncol=len(entries), - fontsize=look.legend_font, **key) + _legend(fig if look.in_headline else ax, [c for c, _ in entries], [label for _, label in entries], ncol=len(entries), + fontsize=look.legend_font, **key) deg = _deg(p.unit) tick_unit = deg if deg == "ยฐ" else "" ax.yaxis.set_major_locator(MaxNLocator(nbins=3 if n > 2 else look.nbins, steps=[1, 2, 2.5, 5, 10], - integer=bool(p.bars and not p.lines and p.bars.unit == "days"))) + integer=p.bars_only and p.bars.unit == "days")) ax.yaxis.set_major_formatter(FuncFormatter(lambda v, _, u=tick_unit: f"{v:g}{u}")) if not look.in_headline: title = f"{p.label} ({p.unit})" if p.unit and not tick_unit else p.label @@ -585,10 +614,10 @@ def _build(chart: Chart, tz: tzinfo) -> Figure: ax.xaxis.set_visible(False) # the time is read off the bottom panel (these ticks have no length and no grid) margin = (x1 - x0) * 0.015 # room for the end dots axes[0].set_xlim(x0 - margin, x1 + margin) - _time_axis(axes[-1], x1 - x0, tz, panels[-1].bars if not panels[-1].lines else None) + _time_axis(axes[-1], x1 - x0, tz, panels[-1].bars if panels[-1].bars_only else None) if chart.compass: # beside the wind line pos = axes[0].get_position() - axes[0].set_position([pos.x0, pos.y0, 0.57, pos.height]) + axes[0].set_position([pos.x0, pos.y0, BESIDE_ROSE_WIDTH, pos.height]) _render_rose(fig, chart.compass) return fig diff --git a/lib/specs.py b/lib/specs.py index 67f1486..fe162b2 100644 --- a/lib/specs.py +++ b/lib/specs.py @@ -94,6 +94,11 @@ def __post_init__(self): if self.peaks not in ("pills", "aside", "none"): raise ValueError(f"panel {self.label!r}: peaks are 'pills', 'aside' or 'none'") + @property + def bars_only(self) -> bool: + """Bars and no lines: rain on its own, or a count or figure per month or year.""" + return bool(self.bars) and not self.lines + @property def xs(self) -> list[int]: """Every x the panel draws, bars to the end of the last one.""" @@ -120,10 +125,15 @@ class Chart: compass: Compass | None = None period: tuple[date, date] | None = None # the first and last day it covers, for its caption + @property + def single(self) -> bool: + """One panel of lines: drawn big, with the room for a compass beside it.""" + return len(self.panels) == 1 and bool(self.panels[0].lines) + def __post_init__(self): if not self.panels: raise ValueError("a chart needs a panel") - if self.compass and not (len(self.panels) == 1 and self.panels[0].lines): + if self.compass and not self.single: raise ValueError("a compass goes beside a single line panel") for panel in self.panels: # every line is labelled with its high and low, so each carries its records for line in panel.lines: @@ -159,7 +169,7 @@ def rain_behind(panels: list[Panel]) -> list[Panel]: stays a panel of its own.""" lines = [p for p in panels if p.lines and not p.bars] line_panel = next((p for name in RAIN_WITH for p in lines if p.reading == name), lines[0] if lines else None) - bars_panel = next((p for p in panels if p.bars and not p.lines), None) + bars_panel = next((p for p in panels if p.bars_only), None) if not (line_panel and bars_panel): return panels line_panel.bars = bars_panel.bars diff --git a/tests/test_chart_labels.py b/tests/test_chart_labels.py index f1abda5..3260169 100644 --- a/tests/test_chart_labels.py +++ b/tests/test_chart_labels.py @@ -140,3 +140,10 @@ def test_a_day_of_25_hours_is_drawn_25_hours_long_with_its_ticks_on_local_hours( finally: plt.close(fig) assert ticks and {t.hour for t in ticks} <= {0, 3, 6, 9, 12, 15, 18, 21} and ticks[0].day == 5 + + +def test_every_weather_reading_and_air_metric_has_a_colour_of_its_own(): + from lib import charts + from lib.series import AIR, WEATHER + lines = [name for name in WEATHER if name != "rain"] # rain is drawn as bars, in its own blue + assert not [name for name in (*lines, *AIR) if name not in charts.READING_COLOURS] From 920a0e7230b2667419aa739435fad42d8a1f1e95 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 19:52:10 +0000 Subject: [PATCH 49/65] intent.py: Reading is Intent; repeated tests and parsing written once; every reading's words need data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - intent.Reading, one of three classes called Reading, is now Intent, and read() builds it by keyword (the first ten of its 21 fields were positional). - The pieces written several times are now named: _judgement() for "wants the model's thinking" (7 copies); _ASK for the "show me the" lead-in (4); _only_words() for "nothing but filler and the words asked about" (3). _limits(), _whole_period() and _by() are the number, period and month-or-year parsing count_days_call and period_figures_call each had a copy of. - GROUPING is imported from daytable, which had the same pattern. "The last 24 hours" is default_span() everywhere; it was built three ways. EFFORT_FORECAST, the effort for links and "think", is EFFORT_THINK. - needs_data (must the model fetch before answering?) knew some readings' words but not vpd, uvi, ultraviolet, radiation, precipitation or "dewpoint", so "vpd now" was not made to fetch. It now takes every reading's words from series, except temperature's and feels-like's, which keep their narrower asking words ("cool, thanks" and "feeling better" stay chat). A test covers both. Every decision intent.read makes, over the question corpus and all 16 combinations of sources (48,096), is the same except that one: needs_data for the 21 questions that name those words. lib: +74 โˆ’52 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/intent.py | 126 +++++++++++++++++++++++++------------------ tests/test_intent.py | 7 +++ 2 files changed, 81 insertions(+), 52 deletions(-) diff --git a/lib/intent.py b/lib/intent.py index 3df2aaa..a296f9e 100644 --- a/lib/intent.py +++ b/lib/intent.py @@ -14,6 +14,7 @@ from dataclasses import dataclass, field from datetime import date, datetime, timedelta +from .daytable import GROUPING from .series import AIR as AIR_TABLE, INDOOR_READINGS, PRESSURE_WORDS, READING_GROUPS, SPECIFIC, TEMP_WORDS, WEATHER as WEATHER_READINGS log = logging.getLogger(__name__) @@ -21,7 +22,7 @@ I = re.IGNORECASE # ---------- reasoning and fetching ---------- -EFFORT_DEFAULT, EFFORT_DESCRIBE, EFFORT_FORECAST = "none", "low", "medium" +EFFORT_DEFAULT, EFFORT_DESCRIBE, EFFORT_THINK = "none", "low", "medium" EFFORT_STEPS = ("none", "low", "medium", "high") @@ -43,10 +44,13 @@ def lower_effort(effort: str) -> str | None: AIR_METRICS = [(m.words, name) for name, m in AIR_TABLE.items()] # words -> metric; checked in order, so PM10 wins over PM1 _AIR_METRIC = "|".join(words for words, _ in AIR_METRICS) # the words that name an air reading, for every pattern below _OTHER_THAN_AIR = rf"temp\w*|rain\w*|wind\w*|humid\w*|hot|cold|warm|pressure|weather|{_ECOWITT}" # a question that is not (only) about the air +# The words of every reading but temperature and feels-like, which name their reading more broadly than a question asks for it +# ("cool, thanks", "feeling better"): those two ask with _TEMP_ASK and "feels" instead +_READING_ASK = "|".join(r.words for name, r in WEATHER_READINGS.items() if name not in ("temperature", "feels_like")) # Messages about the weather or air must fetch fresh data; anything else (thanks, chat) needn't WEATHER = re.compile( - rf"\b(weather|{_ECOWITT}|{_AG}|{_TEMP_ASK}|frost\w*|pollen|hay ?fever|asthma|" + rf"\b(weather|{_ECOWITT}|{_AG}|{_TEMP_ASK}|{_READING_ASK}|frost\w*|pollen|hay ?fever|asthma|" rf"rain\w*|showers?|drizzle|storms?|thunder\w*|hail|snow|fog\w*|cloud\w*|sun\w*|uv|solar|" rf"{_WIND}|humid\w*|dew|{_PRESSURE}|forecast\w*|umbrella|" r"highs?|lows?|max\w*|min\w*|records?|extremes?|average|chart\w*|graph\w*|plot\w*|trend\w*|" @@ -84,7 +88,7 @@ def lower_effort(effort: str) -> str | None: # Asking the model to think: "think about it", "try to work out why", "reason it through", "predict", "estimate", "grind", "whirl" THINK = re.compile(r"\b(think\w*|try|trying|reason\w*|predict\w*|estimat\w*|grind\w*|whirl\w*)\b", I) -EFFORT_RULES = ((EFFORT_FORECAST, (LINK, THINK)), (EFFORT_DESCRIBE, (FORECAST, ANALYSIS, DESCRIBE))) +EFFORT_RULES = ((EFFORT_THINK, (LINK, THINK)), (EFFORT_DESCRIBE, (FORECAST, ANALYSIS, DESCRIBE))) def reasoning_effort(text: str) -> str: @@ -99,9 +103,12 @@ def _only(words: str, lead: str = "") -> str: return rf"^\s*(please\s+)?{lead}({words})(\s+please)?\s*[?.!]*\s*$" +_ASK = r"((give me|show me|show|get|what's|whats)\s+)?(the\s+)?" # the usual lead-in: "show me the ...", "what's the ..." + + # "status", "report": the whole current picture from both devices (a period word or a subject makes it something else) REPORT = re.compile(_only(r"status|report|sitrep|overview|dashboard|summary|everything|what you'?ve got", - r"((give me|show me|show|get|what's|whats)\s+)?(the\s+)?(a\s+)?(full\s+|current\s+|complete\s+)?"), I) + _ASK + r"(a\s+)?(full\s+|current\s+|complete\s+)?"), I) def wants_report(text: str) -> bool: @@ -118,7 +125,7 @@ def report_calls(sources: Collection[str]) -> list[tuple[str, dict]]: # Pollen and thunderstorm asthma (the Pollen source) POLLEN_WORDS = re.compile(r"\b(pollen|hay ?fever|thunderstorm asthma|asthma)\b", I) POLLEN_NOW = re.compile(_only(r"pollen( count| level| forecast| today| now)?|hay ?fever|(thunderstorm )?asthma( risk)?", - r"((give me|show me|show|get|what's|whats)\s+)?(the\s+)?"), I) + _ASK), I) def mentions_pollen(text: str) -> bool: @@ -131,14 +138,18 @@ def mentions_pollen(text: str) -> bool: def pollen_ask(text: str) -> bool: """Is this only a plain question about the pollen or thunderstorm asthma ("is the pollen bad today", "how's the hay fever")?""" - return bool(mentions_pollen(text)) and all( - word in POLLEN_FILLER or word in FILLER for word in re.findall(r"[\w'โ€™.]+", text.lower())) + return mentions_pollen(text) and _only_words(text, POLLEN_FILLER.__contains__) + + +def _only_words(text: str, fits) -> bool: + """Is every word of the text filler (FILLER: what, is, the, now ...) or one `fits` (a word -> bool) takes?""" + return all(word in FILLER or fits(word) for word in re.findall(r"[\w'โ€™.]+", text.lower())) # "weather", "weather now", "current weather", "ecowitt": every reading the station has right now (the weather half of the report) NOW_GROUPS = ",".join(READING_GROUPS) WEATHER_NOW = re.compile(_only(rf"(weather|{_ECOWITT}|conditions)(\s+(now|right now|currently|at the moment))?|(current|latest)\s+(weather|conditions)", - r"((give me|show me|show|get|what's|whats)\s+)?(the\s+)?"), I) + _ASK), I) def wants_weather_now(text: str) -> bool: @@ -223,7 +234,7 @@ def period_ranges(today: date) -> dict[str, tuple[date, date]]: def span(name: str, now: datetime) -> tuple[datetime, datetime]: """Start and end of a named period. The rolling "last 24 hours" ends now; others cover whole days.""" if name == "last 24 hours": - return now - timedelta(hours=24), now + return default_span(now)[1:] first, last = period_ranges(now.date())[name] return datetime.combine(first, datetime.min.time()), datetime.combine(last, datetime.max.time()).replace(microsecond=0) @@ -386,7 +397,7 @@ def wants_chart_all(text: str) -> bool: if named < 2 and ALL.search(text) and WEATHER_WORD.search(text): return True asked = GRAPH.search(text) or WEATHER_WORD.search(text) and _has_days(text, 2) # "weather 30d" is a chart too - return named == 0 and bool(asked) and not mentions_air(text) and not any(p.search(text) for p in JUDGEMENT) + return named == 0 and bool(asked) and not mentions_air(text) and not _judgement(text) def chart_field(text: str) -> str | None: @@ -408,6 +419,10 @@ def wants_rain_caption(text: str) -> bool: JUDGEMENT = (FORECAST, LINK, THINK, ANALYSIS, DESCRIBE) # questions that want the model's thinking, not just a chart +def _judgement(text: str) -> bool: + return any(p.search(text) for p in JUDGEMENT) + + def _sides(text: str) -> list[str]: indoor, outdoor = bool(INDOOR.search(text)), bool(OUTDOOR.search(text)) return ["indoor"] if indoor and not outdoor else ["outdoor"] if outdoor and not indoor else ["outdoor", "indoor"] @@ -430,14 +445,14 @@ def bare_chart_ask(text: str, now: datetime) -> bool: for said, _ in _periods(text, now).values(): rest = re.sub(rf"\b{re.escape(said)}\b", " ", rest, flags=I) patterns = [re.compile(rf"({r.words}|weather|{_ECOWITT}|conditions|indoors?|outdoors?)", I) for r in WEATHER_READINGS.values()] - return all(word in FILLER or any(p.fullmatch(word) for p in patterns) for word in re.findall(r"[\w'โ€™.]+", rest.lower())) + return _only_words(rest, lambda word: any(p.fullmatch(word) for p in patterns)) def weather_chart(text: str, now: datetime) -> tuple[str, datetime, datetime, list[str]] | None: """(period name, start, end, Ecowitt groups) for a plain chart of named readings ("rain chart 7d", "plot temperature and humidity"), else None. A comparison, a forecast or a question that wants thinking is the model's.""" if (not (GRAPH.search(text) or bare_chart_ask(text, now)) or mentions_air(text) or _NOT_CHART.search(text) - or any(p.search(text) for p in JUDGEMENT) or not (fields := chart_fields(text) or list(_named(text)))): + or _judgement(text) or not (fields := chart_fields(text) or list(_named(text)))): return None spans = spans_in(text, now) if not spans and not TIME_WORDS.search(text): @@ -456,7 +471,7 @@ def weather_chart(text: str, now: datetime) -> tuple[str, datetime, datetime, li def weather_average(text: str, now: datetime) -> tuple[str, datetime, datetime, list[str]] | None: """(period name, start, end, Ecowitt groups) for a plain "average temp last week" or "highest humidity yesterday" (the chart-less cousin of weather_chart).""" - if (not (AVERAGE.search(text) or HILO.search(text)) or mentions_air(text) or _NOT_CHART.search(text) or any(p.search(text) for p in JUDGEMENT) + if (not (AVERAGE.search(text) or HILO.search(text)) or mentions_air(text) or _NOT_CHART.search(text) or _judgement(text) or not (names := list(_named(text))) or not set(names) <= set(AVERAGE_READINGS)): return None spans = spans_in(text, now) @@ -498,7 +513,7 @@ def air_period(text: str, now: datetime) -> tuple[str, datetime, datetime] | Non return None spans = spans_in(text, now) if not spans and GRAPH.search(text) and not TIME_WORDS.search(text): - spans = [("last 24 hours", *numbered_span("24", "h", now)[1:])] + spans = [default_span(now)] return spans[0] if len(spans) == 1 else None @@ -531,19 +546,16 @@ def air_metrics(text: str) -> list[str]: "now currently current right at moment outside outdoors outdoor indoors indoor inside out there any we point".split()) FORECAST_PLAIN = re.compile(_only(r"(weather\s+)?forecast(\s+(for\s+)?(the\s+)?(this\s+|next\s+)?(week|7 days|seven days|coming days))?|" r"(the\s+)?(7|seven)[- ]day (weather )?forecast|(the\s+)?week ahead", - r"((give me|show me|show|get|what's|whats)\s+)?(the\s+)?"), I) + _ASK), I) def reading_now(text: str) -> list[str] | None: """The readings named when the text is only a plain question about them right now ("how hot is it", "is it raining", "uv"), else None.""" if (GRAPH.search(text) or TIME_WORDS.search(text) or SPECIFIC_MOMENT.search(text) or mentions_air(text) - or any(p.search(text) for p in JUDGEMENT) or not (names := list(_named(text))) or len(names) > 2): + or _judgement(text) or not (names := list(_named(text))) or len(names) > 2): return None patterns = [re.compile(rf"({WEATHER_READINGS[n].words}|indoors?|outdoors?)", I) for n in names] - for word in re.findall(r"[\w'โ€™.]+", text.lower()): - if word not in FILLER and not any(p.fullmatch(word) for p in patterns): - return None - return names + return names if _only_words(text, lambda word: any(p.fullmatch(word) for p in patterns)) else None # Counting, ranking or totalling over periods ("count days over PM2.5 of 90 per year", "how many times", "rain per month"): the model @@ -554,7 +566,6 @@ def reading_now(text: str) -> list[str] | None: # "days over UVI 10 by year", "count days over PM2.5 of 90 per year", "how many days was UV 9 or more": one reading against one limit, # counted in code with weather_days or air_days (the model is not asked, so the answer is always the same bar chart or count) -GROUPING = re.compile(r"\b(?:per|by|each|every)\s+(year|month)\b|\b(yearly|annual\w*)\b|\b(monthly)\b", I) OR_MORE = re.compile(r"\bor (?:more|higher|above|greater|over)\b", I) OR_LESS = re.compile(r"\bor (?:less|lower|below|under)\b", I) AT_LEAST = re.compile(r"\b(?:at least|reach\w*|hit|hits)\b", I) @@ -578,6 +589,28 @@ def _figures(name: str, stem: str) -> tuple[dict[str, str], str]: HOLIDAYISH = re.compile(r"\b(holidays?|weekends?|saturdays?|sundays?)\b", I) +def _limits(rest: str) -> list[str]: + """The numbers in the text that aren't a year or part of a period: "UV over 9 for 3 months in 2024" -> ["9"].""" + return re.findall(r"(? tuple[str, datetime, datetime] | None: + """(name, start, end) of what a count or a figure per period covers: the period named, a calendar year ("in 2024": up to now, + if it is this one), else everything on record; None when the text names more than one.""" + spans, years = spans_in(rest, now), set(re.findall(r"\b((?:19|20)\d\d)\b", rest)) + if len(spans) + len(years) > 1: + return None + if years: + year = int(years.pop()) + return str(year), datetime(year, 1, 1), min(datetime(year, 12, 31, 23, 59, 59), now) + return spans[0] if spans else ("on record", *span("on record", now)) + + +def _by(group: re.Match) -> str: + """"month" or "year", as GROUPING found it.""" + return "month" if (group.group(1) or "").lower() == "month" or group.group(3) else "year" + + def count_days_call(text: str, now: datetime, sources: Collection[str]) -> tuple[str, dict, str] | None: """(tool, arguments, label) when the text asks how many days one reading was over or under one limit (optionally per year or month), else None and the model decides.""" @@ -590,9 +623,7 @@ def count_days_call(text: str, now: datetime, sources: Collection[str]) -> tuple op = ">=" if OR_MORE.search(text) or AT_LEAST.search(text) else "<=" if OR_LESS.search(text) else "<" if below else ">" group = GROUPING.search(text) rest = GROUPING.sub(" ", text) - plain = NUMBERED_PERIOD.sub(" ", re.sub(r"\b(?:19|20)\d\d\b", " ", rest)) - numbers = re.findall(r"(? tuple field, tool = fields.get("min", fields["max"]) if below else fields["max"], "weather_days" # under a limit: its lowest, if it has one if name == "uv" and op == ">": # a UV index is quoted in whole numbers: "over 10" means the days that read 10 op = ">=" - spans = spans_in(rest, now) - years = set(re.findall(r"\b((?:19|20)\d\d)\b", rest)) - if len(spans) + len(years) > 1: + if not (period := _whole_period(rest, now)): return None - if years: # "in 2024": that calendar year (so far, if it is this one) - year = int(years.pop()) - spans = [(str(year), datetime(year, 1, 1), min(datetime(year, 12, 31, 23, 59, 59), now))] - period = spans[0] if spans else ("on record", *span("on record", now)) - _, start, end = period + name_, start, end = period args = {"where": [{"field": field, "op": op, "value": float(numbers[0])}], "count_only": True, "start_date": start.strftime("%Y-%m-%d"), "end_date": end.strftime("%Y-%m-%d")} if group: - args["group_by"] = "month" if (group.group(1) or "").lower() == "month" or group.group(3) else "year" - return tool, args, f"days count, {period[0]}" + args["group_by"] = _by(group) + return tool, args, f"days count, {name_}" # "rain by month for 2 years", "average temperature per year", "highest PM2.5 by month": one reading, a figure for each month or year, worked @@ -639,7 +664,7 @@ def period_figures_call(text: str, now: datetime, sources: Collection[str]) -> t or re.search(r"\b(and|also|both|but|days|times)\b", text, I)): return None rest = GROUPING.sub(" ", text) - if re.findall(r"(? t if stat not in fields: # a lowest of a reading that has none (wind, solar) return None field = fields[stat] - spans = spans_in(rest, now) - years = set(re.findall(r"\b((?:19|20)\d\d)\b", rest)) - if len(spans) + len(years) > 1: + if not (period := _whole_period(rest, now)): return None - if years: - year = int(years.pop()) - spans = [(str(year), datetime(year, 1, 1), min(datetime(year, 12, 31, 23, 59, 59), now))] - name_, start, end = spans[0] if spans else ("on record", *span("on record", now)) - by = "month" if (group.group(1) or "").lower() == "month" or group.group(3) else "year" + name_, start, end = period + by = _by(group) return tool, {"stat": stat, "of": field, "count_only": True, "group_by": by, "start_date": start.strftime("%Y-%m-%d"), "end_date": end.strftime("%Y-%m-%d")}, f"figures by {by}, {name_}" @@ -769,7 +789,7 @@ def model_prefetch(text: str, now: datetime, sources: Collection[str]) -> list[t @dataclass -class Reading: +class Intent: """Everything decided about one message before the model sees it.""" effort: str = EFFORT_DEFAULT needs_data: bool = False # the model must call a tool first @@ -807,9 +827,9 @@ def plain_lookup(text: str, fast: tuple | None) -> tuple[str, list[str]]: return "days", [] if tool == "weather_forecast": return "forecast", [] - if any(p.search(text) for p in JUDGEMENT): + if _judgement(text): return "", [] - if tool == "weather_history" and not args.get("chart") and not any(p.search(text) for p in JUDGEMENT): + if tool == "weather_history" and not args.get("chart") and not _judgement(text): return "extremes", [n for n in _named(text)] if tool == "weather_now" and (names := reading_now(text)): return "reading", names @@ -821,7 +841,7 @@ def plain_lookup(text: str, fast: tuple | None) -> tuple[str, list[str]]: APOSTROPHES = str.maketrans("โ€™โ€˜", "''") # a phone's typographic apostrophes ("whatโ€™s"), read as the plain one every pattern uses -def read(text: str, now: datetime, sources: Collection[str] = ("Ecowitt", "AirGradient")) -> Reading: +def read(text: str, now: datetime, sources: Collection[str] = ("Ecowitt", "AirGradient")) -> Intent: """The decisions made in code for this message, given the sources the bot has (by name: "Ecowitt", "AirGradient", "Pollen", "Forecast"). A shortcut that fails is dropped: the model handles the question.""" text = text.translate(APOSTROPHES) @@ -834,10 +854,12 @@ def read(text: str, now: datetime, sources: Collection[str] = ("Ecowitt", "AirGr log.exception("Fast path failed; using the normal path") fast = None in_code = bool(fast and fast[0] in ("weather_history", "air_quality") and fast[1].get("chart") - and not any(p.search(text) for p in JUDGEMENT)) + and not _judgement(text)) rain_caption = ecowitt and wants_rain_caption(text) lookup, named = plain_lookup(text, fast) if not (report or in_code) else ("", []) - 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), - lookup=lookup, lookup_arg=named, sides=_sides(text), extra=model_prefetch(text, now, sources) if not fast else [], readings=list(_named(text)), per_day=bool(PER_DAY.search(text))) + return Intent(effort=reasoning_effort(text), needs_data=needs_data(text), about_the_bot=about_the_bot(text), report=report, + hints=period_hints(text, now), fast=fast, chart_asked=bool(GRAPH.search(text)), chart_field=chart_field(text), + chart_fields=chart_fields(text), average_asked=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), readings=list(_named(text)), per_day=bool(PER_DAY.search(text)), + extra=model_prefetch(text, now, sources) if not fast else [], lookup=lookup, lookup_arg=named, sides=_sides(text)) diff --git a/tests/test_intent.py b/tests/test_intent.py index 2b39cf1..08722b8 100644 --- a/tests/test_intent.py +++ b/tests/test_intent.py @@ -32,6 +32,13 @@ def test_chat_needs_no_data(): assert intent.needs_data("how hot is it") +def test_every_reading_named_needs_its_data_but_chat_words_like_cool_or_feeling_do_not(): + for asked in ("vpd now", "what's the uvi", "dewpoint 90d", "solar radiation today", "ultraviolet", "precipitation this week"): + assert intent.needs_data(asked), asked + for chat in ("cool, thanks", "feeling better now", "that's apparent"): + assert not intent.needs_data(chat), chat + + def test_highs_and_lows_go_fast(): name, args, _ = call("what was this week's high and low?") assert name == "weather_history" From a63e91f2ed39c45787336f90f5b72d384bdcc682 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 20:03:04 +0000 Subject: [PATCH 50/65] report.py: rain and wind written once, the report's sections as a table, each lookup kind its own function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "Weather now" and a reading lookup ("is it windy", "how much rain today") each wrote out the wind text, and the rain lookup took its line by filtering weather_lines' bullets. _wind() and _rain() now serve both. The rain rate was tested for zero by its own rule ("0" and "0.0" only), so "0.00 mm/hr" read "raining (0.00 mm/h)"; it now uses the rule every other reading uses. A test pins both. - report() runs over SECTIONS (the tool, its heading, its bullets, and whether a failed result still shows as "not available"); the bold headings and the pollen and forecast lookups take their names from it. - lookup() is a table of the kinds it answers. The days count and the highs and lows were inline branches, some returning and some falling through; they are _days() and _extremes() now. - The fix for Ecowitt's unit spellings (โ„ƒ, ยบ, mm/hr) is ecowitt.api.unit_text(), used by the report and by Ecowitt's live summary for the log, which had its own copy. - The Intent field that names the lookup kinds now lists all of them. A before/after dump of 6,991 outputs (weather_now and the reading lookups over about 4,000 variants of the station's reading, the report in 256 combinations of results, every lookup kind, the captions, the bold headings) is the same except for the "0.00 mm/hr" case (240 lines). lib: +125 โˆ’98 lines (the named pieces carry their docstrings). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/ecowitt/api.py | 8 ++ lib/ecowitt/source.py | 4 +- lib/intent.py | 2 +- lib/report.py | 209 +++++++++++++++++++++++------------------- tests/test_report.py | 4 + 5 files changed, 129 insertions(+), 98 deletions(-) diff --git a/lib/ecowitt/api.py b/lib/ecowitt/api.py index 753378c..56cab17 100644 --- a/lib/ecowitt/api.py +++ b/lib/ecowitt/api.py @@ -45,6 +45,8 @@ def kept_from(cycle: str, today: date) -> date: """The first day whose readings at this cycle Ecowitt is sure still to have: two days clear of its retention edge, so a request can't race the deletion.""" return today - timedelta(days=CYCLES[cycle].kept - 2) + + # What the bot keeps cached and warm (the extras feed the rain alerts and predictions). One list, so the # warm-up and the archive fetch the same thing and can reuse each other's data. GROUPS = ["outdoor", "indoor", "pressure", "wind", "rainfall", "rainfall_piezo", "solar_and_uvi"] @@ -54,6 +56,12 @@ def kept_from(cycle: str, today: date) -> date: UNITS = {"temp_unitid": "C", "pressure_unitid": "hPa", "wind_speed_unitid": "kmh", "rainfall_unitid": "mm"} UNIT_IDS = {"temp_unitid": 1, "pressure_unitid": 3, "wind_speed_unitid": 7, "rainfall_unitid": 12} + +def unit_text(text: str | None) -> str | None: + """A reading or a unit as Ecowitt writes it, written the bot's way: "12.3 โ„ƒ" -> "12.3 ยฐC", "131 ยบ" -> "131ยฐ", "1.8 mm/hr" -> + "1.8 mm/h". None for nothing.""" + return text.replace("โ„ƒ", "ยฐC").replace(" ยบ", "ยฐ").replace("ยบ", "ยฐ").replace("mm/hr", "mm/h").strip() if text else None + BUSY_RETRIES = 3 MIN_GAP_SECONDS = 1.0 # Ecowitt rejects requests that come too fast ("Operation too frequent") RETRY_ON = ("busy", "too frequent") diff --git a/lib/ecowitt/source.py b/lib/ecowitt/source.py index 604990d..a2a024b 100644 --- a/lib/ecowitt/source.py +++ b/lib/ecowitt/source.py @@ -14,7 +14,7 @@ from ..timeutil import local_epoch, naive_local, when from ..tools import Tool, Turn, json_result from ..warm import FAST_REFRESH_SECONDS, Warmer -from .api import FINE_CYCLES, EcowittAPI, GROUPS, UNITS +from .api import FINE_CYCLES, EcowittAPI, GROUPS, UNITS, unit_text from .calendar import PublicHolidays from .link import DESCRIPTION as LINK_DESCRIPTION, PARAMETERS as LINK_PARAMETERS, link_tool from .days import DESCRIPTION as DAYS_DESCRIPTION, PARAMETERS as DAYS_PARAMETERS, days_tool @@ -95,7 +95,7 @@ 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 - unit = obj.get("unit", "").replace("โ„ƒ", "ยฐC").replace("mm/hr", "mm/h") + unit = unit_text(obj.get("unit")) or "" return f"{obj['value']}{'' if unit in ('ยฐC', '%') else ' '}{unit}".strip() wind = get("wind", "wind_speed") parts = [f"rain {rate}" if (rate := get("rainfall", "rain_rate")) else None, diff --git a/lib/intent.py b/lib/intent.py index a296f9e..127423c 100644 --- a/lib/intent.py +++ b/lib/intent.py @@ -809,7 +809,7 @@ class Intent: readings: list[str] = field(default_factory=list) # the readings the words name per_day: bool = False # figures day by day were asked for extra: list[tuple[str, dict]] = field(default_factory=list) # fetched with the model's first step (the forecast, for "will it rain?") - lookup: str = "" # "reading", "air", "pollen", "forecast" or "about": answered in code, no model + lookup: str = "" # "reading", "air", "days", "extremes", "pollen", "forecast" or "about": answered in code, no model lookup_arg: list[str] = field(default_factory=list) # the readings named ("reading", "air"; none: all) sides: list[str] = field(default_factory=list) # "indoor", "outdoor" or both, for a reading diff --git a/lib/report.py b/lib/report.py index 2fc357c..502fe9f 100644 --- a/lib/report.py +++ b/lib/report.py @@ -7,13 +7,13 @@ from telegram import MessageEntity +from .ecowitt.api import unit_text from .ecowitt.glance import solar_band from .entities import utf16_len from .series import AIR, INDOOR_READINGS, WEATHER, find from .timeutil import clock_text, day_text, to_local - def _load(result: str | None) -> dict | None: """The tool's JSON, or None when it is missing, an error message or says it has nothing.""" try: @@ -23,11 +23,6 @@ def _load(result: str | None) -> dict | None: return data if isinstance(data, dict) and "error" not in data else None -def _units(text: str | None) -> str | None: - """"12.3 โ„ƒ" -> "12.3 ยฐC", "1.8 mm/hr" -> "1.8 mm/h".""" - return text.replace("โ„ƒ", "ยฐC").replace(" ยบ", "ยฐ").replace("ยบ", "ยฐ").replace("mm/hr", "mm/h").strip() if text else None - - def _zero(text: str | None) -> bool: """Is this reading missing or zero ("0.0 W/mยฒ", "0")?""" try: @@ -48,15 +43,6 @@ 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).""" - return [MessageEntity(MessageEntity.BOLD, utf16_len(text[:m.start()]), utf16_len(m.group(1))) for m in HEADING_LINE.finditer(text)] - - def _stamp(result: dict, today: date) -> str | None: """The time of the data, "11:45pm", with its day when that isn't today ("Sat 10 Oct 11:45pm"), from the result's "time" (as timeutil.when writes it, "Sun 11 Oct 2026 11:45pm"); None when it is missing or unreadable.""" @@ -78,7 +64,7 @@ def _getters(now: dict): emoji = now.get("emoji") or {} def get(group: str, field: str) -> str | None: - return _units((now.get(group) or {}).get(field)) + return unit_text((now.get(group) or {}).get(field)) def tag(group: str, field: str, text: str | None = None) -> str | None: text = text or get(group, field) @@ -86,18 +72,26 @@ def tag(group: str, field: str, text: str | None = None) -> str | None: return get, tag +def _rain(now: dict) -> str: + """Today's rain, the month's and, while it rains, the rate: "โ˜” 6.3 mm (month total 49.8 mm); ๐ŸŒง๏ธ raining (1.8 mm/h)".""" + get, tag = _getters(now) + rate, month = get("rainfall", "rain_rate"), get("rainfall", "monthly") + return _join([_join([tag("rainfall", "daily"), f"(month total {month})" if month else None], " "), + None if _zero(rate) else tag("rainfall", "rain_rate", f"raining ({rate})")], "; ") + + +def _wind(now: dict) -> str | None: + """The wind, where it comes from and its gusts: "14.0 km/h from 131ยฐ, gust 31.0 km/h"; None when it is calm.""" + get, tag = _getters(now) + if _zero(get("wind", "wind_speed")) and _zero(get("wind", "wind_gust")): + return None + direction, gust = get("wind", "wind_direction"), tag("wind", "wind_gust") + return _join([_join([tag("wind", "wind_speed"), f"from {direction}" if direction else None], " "), f"gust {gust}" if gust else None]) + + 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", "") - 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], " "), 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 - wind = None if calm else _join([_join([tag("wind", "wind_speed"), f"from {_units(degrees)}" if degrees else None], " "), - f"gust {tag('wind', 'wind_gust')}" if get("wind", "wind_gust") else None]) solar, uvi = get("solar_and_uvi", "solar"), get("solar_and_uvi", "uvi") sun = _join([None if _zero(solar) else tag("solar_and_uvi", "solar", f"solar radiation {solar}{_band(solar)}"), # night: no sun line None if _zero(uvi) else tag("solar_and_uvi", "uvi", f"UV index {uvi}")]) @@ -106,9 +100,9 @@ def weather_lines(now: dict) -> list[str]: tag("outdoor", "vpd", f"VPD {get('outdoor', 'vpd')}" if get("outdoor", "vpd") else None)])), ("Indoor", _join([tag("indoor", "temperature"), tag("indoor", "humidity")])), ("Pressure", tag("pressure", "relative")), - ("Rain today", rain), + ("Rain today", _rain(now)), ("Sun", sun), - ("Wind", wind)] + ("Wind", _wind(now))] # calm: no line return [f"โ€ข {name}: {text}" for name, text in bullets if text] @@ -132,65 +126,50 @@ def reading_lines(now: dict, names: list[str], sides: list[str]) -> list[str]: lines = [] for name in names: reading = WEATHER[name] - if reading.group in ("outdoor", "indoor") and name in ("temperature", "humidity"): + if name in ("temperature", "humidity"): # a line for each side asked about; the temperature with what it feels like for side in sides: - text = _join([tag(side, name), f"feels like {get(side, 'feels_like')}" if name == "temperature" and get(side, "feels_like") else None], ", ") - lines.append(f"โ€ข {side.capitalize()}: {text}" if text else None) + feels = get(side, "feels_like") if name == "temperature" else None + if text := _join([tag(side, reading.field), f"feels like {feels}" if feels else None]): + lines.append(f"โ€ข {side.capitalize()}: {text}") elif name == "wind": - wind = (now.get("wind") or {}) - if _zero(get("wind", "wind_speed")) and _zero(get("wind", "wind_gust")): - lines.append("โ€ข Wind: calm") - else: - degrees = wind.get("wind_direction") - lines.append("โ€ข Wind: " + _join([_join([tag("wind", "wind_speed"), f"from {_units(degrees)}" if degrees else None], " "), - f"gust {tag('wind', 'wind_gust')}" if get("wind", "wind_gust") else None])) + lines.append(f"โ€ข Wind: {_wind(now) or 'calm'}") elif name == "rain": - lines += [line for line in weather_lines(now) if line.startswith("โ€ข Rain today")] or [ - f"โ€ข Rain today: {get('rainfall', 'daily') or 'none'}"] + lines.append(f"โ€ข Rain today: {_rain(now) or 'none'}") else: group = sides[0] if name in INDOOR_READINGS and len(sides) == 1 else reading.group # "indoor dew point": that sensor's - text = tag(group, reading.field) - if text: + if text := tag(group, reading.field): lines.append(f"โ€ข {reading.label}{' (indoor)' if group == 'indoor' else ''}: {text}") - return [line for line in lines if line] + return lines -def lookup(kind: str, result: str | None, names: list[str], sides: list[str], today: date) -> str | None: - """A plain lookup written from its one tool result: "reading", "air", "pollen" or "forecast", on `today` (local). None when the - result can't be used (the model then takes the question).""" - data = _load(result) - if not data: - return None - if kind == "reading": - lines = reading_lines(data, names, sides) - elif kind == "air": - lines = air_lines(data, [k for k in names] or None) - elif kind == "days": # "137 of 1,454 days with UV index \u2265 10 (period)": the chart (if any) carries the breakdown - if "matching_days" not in data: - return None - if data.get("stat") and data.get("value") is not None: # a total, average, highest or lowest over the period - unit = _units(data["stat"].get("unit")) or "" - return f"{data['stat']['what']}: {data['value']:g} {unit}".rstrip() + f" over {data['days_checked']:,} days ({_period(data)})." - what = f" with {data['condition']}" if data.get("condition") else "" - note = " Older days use averages, so a count near the limit may fall slightly short." if data.get("note_averaged") else "" - return f"{data['matching_days']:,} of {data['days_checked']:,} days{what} ({_period(data)}).{note}" - elif kind == "extremes": # the highs and lows (or averages) of a short period, each with its day and time - series = data.get("series") or {} - wanted = [f for n in names if find(n) for f in (find(n).field, find(n).band_field) if f] or ["temperature"] # wind: speed and gust - keys = [k for k in series if k.split(".", 1)[1] in wanted and "low" in series[k]] - lines = [] - for k in keys: - group, field = k.split(".", 1) - label = ("Wind gust" if field == "wind_gust" else find(field).label if find(field) else field.replace("_", " ").capitalize()) + ( - f" ({group})" if sum(x.split(".", 1)[1] == field for x in keys) > 1 else "") - lines.append(f"โ€ข {_range(label, series[k], 'average' in series[k])}") - return "\n".join([_period(data), *lines]) if lines else None - elif kind in ("pollen", "forecast"): - lines = [f"โ€ข {line}" for line in data.get("lines", [])] - return "\n".join([_heading("Pollen & asthma" if kind == "pollen" else "Forecast", data, today), *lines]) if lines else None - else: - return None - return "\n".join(lines) or None +def _own_lines(result: dict) -> list[str]: + """The bullets of a result that brings its own lines (pollen and asthma, the forecast).""" + return [f"โ€ข {line}" for line in result.get("lines", [])] + + +# The report's sections in order: the tool, the section's heading, its bullets from the tool's result, and whether it is still +# shown when that result failed ("โ€ข not available right now") rather than left out +SECTIONS = (("weather_now", "Weather station", weather_lines, True), + ("air_quality", "Air quality", air_lines, True), + ("pollen_asthma", "Pollen & asthma", _own_lines, False), + ("weather_forecast", "Forecast", _own_lines, False)) +HEADINGS = {tool: heading for tool, heading, _, _ in SECTIONS} # the pollen and forecast lookups start with theirs too +HEADING_LINE = re.compile(rf"^({'|'.join(map(re.escape, HEADINGS.values()))})(?= \(|$)", re.M) + + +def heading_entities(text: str) -> list[MessageEntity]: + """Bold entities over the name of each heading line in `text` (not over its brackets).""" + return [MessageEntity(MessageEntity.BOLD, utf16_len(text[:m.start()]), utf16_len(m.group(1))) for m in HEADING_LINE.finditer(text)] + + +def report(results: dict[str, str], today: date) -> str: + """The full report from the tool results by tool name (weather_now, air_quality, pollen_asthma, weather_forecast), on `today` (local).""" + sections = [] + for tool, heading, bullets, always in SECTIONS: + data = _load(results.get(tool)) + if data or (always and tool in results): + sections.append("\n".join([_heading(heading, data or {}, today), *(bullets(data) if data else ["โ€ข not available right now"])])) + return "\n\n".join(sections) or "No readings are available right now." def weather_now(result: str | None) -> str | None: @@ -199,20 +178,60 @@ def weather_now(result: str | None) -> str | None: return "\n".join(weather_lines(now)) if now else None -def report(results: dict[str, str], today: date) -> str: - """The full report from the tool results by tool name (weather_now, air_quality, pollen_asthma, weather_forecast), on `today` (local).""" - sections = [] - if "weather_now" in results: - now = _load(results["weather_now"]) - sections.append([_heading("Weather station", now or {}, today), *(weather_lines(now) if now else ["โ€ข not available right now"])]) - if "air_quality" in results: - air = _load(results["air_quality"]) - sections.append([_heading("Air quality", air or {}, today), *(air_lines(air) if air else ["โ€ข not available right now"])]) - if pollen := _load(results.get("pollen_asthma")): - sections.append([_heading("Pollen & asthma", pollen, today), *(f"โ€ข {line}" for line in pollen.get("lines", []))]) - if forecast := _load(results.get("weather_forecast")): - sections.append([_heading("Forecast", forecast, today), *(f"โ€ข {line}" for line in forecast.get("lines", []))]) - return "\n\n".join("\n".join(section) for section in sections) or "No readings are available right now." +# ---------- plain lookups ---------- +def _alone(tool: str, result: dict, today: date) -> str | None: + """A pollen or forecast result on its own: its section of the report, or None when it has no lines.""" + lines = _own_lines(result) + return "\n".join([_heading(HEADINGS[tool], result, today), *lines]) if lines else None + + +def _days(data: dict) -> str | None: + """"137 of 1,454 days with UV index โ‰ฅ 10 (period)", or a total, average, highest or lowest over the period: the chart (if any) + carries the breakdown.""" + if "matching_days" not in data: + return None + if data.get("stat") and data.get("value") is not None: + unit = unit_text(data["stat"].get("unit")) or "" + return f"{data['stat']['what']}: {data['value']:g} {unit}".rstrip() + f" over {data['days_checked']:,} days ({_period(data)})." + what = f" with {data['condition']}" if data.get("condition") else "" + note = " Older days use averages, so a count near the limit may fall slightly short." if data.get("note_averaged") else "" + return f"{data['matching_days']:,} of {data['days_checked']:,} days{what} ({_period(data)}).{note}" + + +def _series_label(field: str) -> str: + """A series' name: its reading's label ("Temperature"), the gusts their own ("Wind gust").""" + if field == "wind_gust": + return "Wind gust" + return reading.label if (reading := find(field)) else field.replace("_", " ").capitalize() + + +def _extremes(data: dict, names: list[str]) -> str | None: + """The period, then the low and high (or the average) of each reading named, each with its day and time: wind, its speed and + its gusts; nothing named, the temperature. A reading on both sides (indoor, outdoor) has a line for each.""" + series = data.get("series") or {} + wanted = [f for r in filter(None, map(find, names)) for f in (r.field, r.band_field) if f] or ["temperature"] + keys = [k for k in series if k.split(".", 1)[1] in wanted and "low" in series[k]] + fields = [k.split(".", 1)[1] for k in keys] + lines = [] + for key in keys: + group, field = key.split(".", 1) + label = _series_label(field) + (f" ({group})" if fields.count(field) > 1 else "") + lines.append(f"โ€ข {_range(label, series[key], 'average' in series[key])}") + return "\n".join([_period(data), *lines]) if lines else None + + +def lookup(kind: str, result: str | None, names: list[str], sides: list[str], today: date) -> str | None: + """A plain lookup written from its one tool result: `kind` is "reading", "air", "days", "extremes", "pollen" or "forecast", + `names` the readings asked about and `sides` where (indoor, outdoor), on `today` (local). None when the result can't be used + (the model then takes the question).""" + data = _load(result) + answers = {"reading": lambda: "\n".join(reading_lines(data, names, sides)), + "air": lambda: "\n".join(air_lines(data, names or None)), + "days": lambda: _days(data), + "extremes": lambda: _extremes(data, names), + "pollen": lambda: _alone("pollen_asthma", data, today), + "forecast": lambda: _alone("weather_forecast", data, today)} + return (answers[kind]() or None) if data and kind in answers else None # ---------- chart captions ---------- @@ -233,11 +252,11 @@ def _period(data: dict) -> str: def _at(entry: dict, which: str) -> str: """"high 24.3 ยฐC, Tue 29 Sep around 3pm" for one record of a series.""" when = _year(f"{entry.get(f'{which}_date', '')} {entry.get(f'{which}_when', '')}".strip()) - return f"{entry[which]} {_units(entry.get('unit')) or ''}".strip() + (f", {when}" if when else "") + return f"{entry[which]} {unit_text(entry.get('unit')) or ''}".strip() + (f", {when}" if when else "") def _range(label: str, entry: dict, average: bool) -> str: - unit = _units(entry.get("unit")) or "" + unit = unit_text(entry.get("unit")) or "" if average and entry.get("average"): return f"{label}: average {entry['average']} {unit} (low {entry['low']}, high {entry['high']})".replace(" )", ")") return f"{label}: low {_at(entry, 'low')} ยท high {_at(entry, 'high')}" @@ -285,7 +304,7 @@ def _air_caption(result: dict, args: dict, turn) -> list[str]: entry = result.get(key) if key not in (args.get("metrics") or ["pm2_5"]) or not isinstance(entry, dict): continue - unit = _units(entry.get("unit")) or "" + unit = unit_text(entry.get("unit")) or "" rating = entry.get("high_rating") lines.append(f"โ€ข {label}: peak {entry['high']} {unit} ({_year(entry.get('high_time'))})".replace(" )", ")") + (f" ยท {rating}" if rating else "") + f" ยท average {entry['average']}") diff --git a/tests/test_report.py b/tests/test_report.py index ad88e2e..84dbfdd 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -65,6 +65,7 @@ def test_a_dry_calm_day_has_no_rain_line_noise(): 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"] 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) + assert "raining" not in report.weather_now(dry.replace('"0.0 mm/hr"', '"0.00 mm/hr"')) # however the zero is written def test_a_failed_or_missing_source_is_left_out_or_said_so_and_the_forecast_without_a_tag_has_a_plain_heading(): @@ -188,6 +189,9 @@ def test_a_reading_lookup_prints_only_what_was_asked(): assert report.reading_lines(now, ["dew_point"], ["indoor"]) == ["โ€ข Dew point (indoor): 11.1 ยฐC"] # "indoor dew point" assert report.reading_lines(now, ["feels_like"], ["outdoor", "indoor"])[0].startswith("โ€ข Feels like: ") # unasked: outdoor 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] + windy = json.loads(NOW.replace('"wind_speed":"0.0 km/h"', '"wind_speed":"14.0 km/h"')) + assert [line for line in report.weather_lines(windy) if line.startswith(("โ€ข Wind", "โ€ข Rain"))] == \ + report.reading_lines(windy, ["rain", "wind"], []) # the same words as the report's def test_air_pollen_and_forecast_lookups_print_the_tools_own_lines(): From e47a6bf783062b1ebd3f5b84c63799e1151b3777 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 20:11:01 +0000 Subject: [PATCH 51/65] compose.py: the date parameters written once; a chart's air panels from one load, its notes returned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The start_date and end_date that parse_period reads were spelled out in four tool schemas (plot_chart, air_link, air_scan and weather_link); they are timeutil.period_parameters() now. weather_link's start date said "Default: the last 90 days"; it now reads like the other three: "Default: 90 days before the end". - _panel loaded the sensor's readings itself, once per air panel, and added that load's notes (days not cached yet, days only kept hourly) to a dict it was handed. plot_chart now checks every panel first, then loads the air once for all its air panels (air_series) and keeps the notes that load returns. _panel no longer loads anything, so it is a plain method; air_slots, which air_link was its only other user of, goes. One difference: a request whose later panel is invalid after a panel with no readings now gets the invalid-panel error first. The old composer (HEAD) and the new one, run in one process over 480 plot_chart, air_link and air_scan calls (good and bad panels, periods and metrics, a sensor with and without notes), give the same results and chart specs; so do seven pairs forced through the scan's chart path. compose's three schemas are unchanged byte for byte. A test: two air panels and rain make one load, a weather chart none (the old code made two). lib: +30 โˆ’32 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/compose.py | 51 +++++++++++++++++++------------------------ lib/ecowitt/link.py | 5 ++--- lib/timeutil.py | 6 +++++ tests/test_compose.py | 15 +++++++++++++ 4 files changed, 45 insertions(+), 32 deletions(-) diff --git a/lib/compose.py b/lib/compose.py index 9ae1fdd..1186140 100644 --- a/lib/compose.py +++ b/lib/compose.py @@ -19,7 +19,7 @@ from .panels import panel_for from .specs import Panel, Shares, stack from .rain import bar_layout, bar_of, rain_bars, rain_slots -from .timeutil import SLOT, day_bounds, now_local, parse_period +from .timeutil import SLOT, day_bounds, now_local, parse_period, period_parameters from .tools import Tool, Turn, json_result log = logging.getLogger(__name__) @@ -44,8 +44,7 @@ "items": {"type": "object", "properties": {"series": {"type": "string", "enum": SERIES}, "style": {"type": "string", "enum": list(STYLES)}}, "required": ["series"]}}, - "start_date": {"type": "string", "description": "First day, 'YYYY-MM-DD'. Default: 30 days before the end."}, - "end_date": {"type": "string", "description": "Last day, 'YYYY-MM-DD' (up to yesterday). Default: yesterday."}, + **period_parameters(DEFAULT_DAYS), }, "required": ["panels"], } @@ -68,8 +67,7 @@ "type": "object", "properties": { "metric": {"type": "string", "enum": ["all", *SCAN_AIR], "description": "One air reading, or all (default)."}, - "start_date": {"type": "string", "description": "First day, 'YYYY-MM-DD'. Default: 90 days before the end."}, - "end_date": {"type": "string", "description": "Last day, 'YYYY-MM-DD' (up to yesterday). Default: yesterday."}, + **period_parameters(SCAN_DAYS), "chart": {"type": "boolean", "description": "Set true for a chart of the strongest relationship found."}, }, } @@ -84,8 +82,7 @@ "type": "object", "properties": { "metric": {"type": "string", "enum": list(LINK_METRICS), "description": "The air reading. Default pm2_5."}, - "start_date": {"type": "string", "description": "First day, 'YYYY-MM-DD'. Default: 60 days before the end."}, - "end_date": {"type": "string", "description": "Last day, 'YYYY-MM-DD' (up to yesterday). Default: yesterday."}, + **period_parameters(LINK_DAYS), "chart": {"type": "boolean", "description": "Set true for a chart: the reading (with its rating zones) over rain bars."}, }, } @@ -130,7 +127,9 @@ def weather(self, group: str, field: str, first: date, last: date) -> dict[int, async def air_series(self, metrics: list[str], first: date, last: date) -> tuple[dict[str, tuple[dict, dict, dict]], dict]: """({metric: (values, lows, highs)}, notes): the sensor's readings averaged into 30-minute slots, with each slot's - range, from one load of the period.""" + range, from one load of the period (none when no metric is asked for).""" + if not metrics: + return {}, {} rows, hourly, skipped = await self.air.rows(datetime.combine(first, time()), datetime.combine(last, time(23, 59, 59))) out = {} for metric in metrics: @@ -147,11 +146,6 @@ async def air_series(self, metrics: list[str], first: date, last: date) -> tuple notes["resolution"] = f"{hourly} day(s) only exist as hourly averages" return out, notes - async def air_slots(self, metric: str, first: date, last: date) -> tuple[dict, dict, dict, dict]: - """(values, lows, highs, notes) for one air metric; see air_series.""" - series, notes = await self.air_series([metric], first, last) - return (*series[metric], notes) - async def plot_chart(self, args: dict, turn: Turn | None = None) -> str: turn = turn or Turn() panels = args.get("panels") @@ -160,14 +154,16 @@ async def plot_chart(self, args: dict, turn: Turn | None = None) -> str: if isinstance(period := self.period(args), str): return _error(period) first, last = period - built, summary, notes = [], [], {} - for p in panels: - name, style = p.get("series"), p.get("style") or ("bars" if p.get("series") == "rain" else "line") + asked = [(p.get("series"), p.get("style") or ("bars" if p.get("series") == "rain" else "line")) for p in panels] + for name, style in asked: if name not in SERIES or style not in STYLES: return _error(f"series must be one of {', '.join(SERIES)} and style one of {', '.join(STYLES)}") if (style == "bars") != (name == "rain") or (style == "rating" and name not in ALL_METRICS): return _error("'bars' is for rain only, and 'rating' for the air-quality series only; use 'line' otherwise") - panel, facts = await self._panel(name, style, first, last, notes) + air, notes = await self.air_series([name for name, _ in asked if name in ALL_METRICS], first, last) + built, summary = [], [] + for name, style in asked: + panel, facts = self._panel(name, style, first, last, air.get(name)) if panel is None: return _error(f"no cached readings of {name} for {first} to {last}") built.append(panel) @@ -190,10 +186,10 @@ async def air_link(self, args: dict, turn: Turn | None = None) -> str: if isinstance(period := self.period(args, LINK_DAYS), str): return _error(period) first, last = period - values, lows, highs, notes = await self.air_slots(metric, first, last) - air_panel, _ = await self._panel(metric, "line", first, last, {}, data=(values, lows, highs)) + series, notes = await self.air_series([metric], first, last) + air_panel, _ = self._panel(metric, "line", first, last, series[metric]) rain = rain_slots(self.weather("rainfall", "daily", first, last)) - result = analyse_air(values, rain, RATINGS[metric], self.tz, LABELS[metric]) + result = analyse_air(series[metric][0], rain, RATINGS[metric], self.tz, LABELS[metric]) out = {"period": f"{first} to {last}", "metric": f"{LABELS[metric]} ({CHART_UNITS[metric]})", "resolution": "30-minute slots (air readings averaged, rain from the weather station's cache)", "how_to_read": ("Open with `verdict`, then the evidence in `findings`. Rain and clean air share weather (wind, fronts), " @@ -244,15 +240,15 @@ async def air_scan(self, args: dict, turn: Turn | None = None) -> str: if strongest and wants_chart(args, turn, datetime.combine(first, time()), datetime.combine(last, time())): drawn = [n for n in (strongest["air"], {"wind_speed": "wind"}.get(strongest["weather"], strongest["weather"])) if n in SERIES] if len(drawn) == 2: - panels = [(await self._panel(n, "bars" if n == "rain" else "line", first, last, {}, data=air.get(n)))[0] for n in drawn] + panels = [self._panel(n, "bars" if n == "rain" else "line", first, last, air.get(n))[0] for n in drawn] if all(panels): self._add_chart(out, panels, first, last, turn) return json_result(out) - async def _panel(self, name: str, style: str, first: date, last: date, notes: dict, - data: tuple[dict, dict, dict] | None = None) -> tuple[Panel | None, dict]: - """(the panel, its figures for the caption); the panel is None when there is nothing to draw. `data` is an air series - already loaded (values, lows, highs).""" + def _panel(self, name: str, style: str, first: date, last: date, + air: tuple[dict, dict, dict] | None = None) -> tuple[Panel | None, dict]: + """(the panel, its figures for the caption); the panel is None when there is nothing to draw. A weather reading comes from + the station's cache, an air metric's readings in `air` (values, lows, highs: see air_series).""" if name in WEATHER: reading = WEATHER[name] if name == "rain": @@ -263,10 +259,7 @@ async def _panel(self, name: str, style: str, first: date, last: date, notes: di line = driver_series(values, self.tz, first, last, reading.label, lows, highs, banded_short=bool(reading.band_field)) facts = {"series": name, **self._stats(values, reading.unit)} return (panel_for(name, [line]) if line else None), facts - if data is None: - *data, more = await self.air_slots(name, first, last) - notes.update(more) - values, lows, highs = data + values, lows, highs = air or ({}, {}, {}) if len(values) < 2: return None, {} if style == "rating": diff --git a/lib/ecowitt/link.py b/lib/ecowitt/link.py index f7ad04c..c9d0b40 100644 --- a/lib/ecowitt/link.py +++ b/lib/ecowitt/link.py @@ -18,7 +18,7 @@ from ..panels import panel_for from ..series import WEATHER from ..specs import Chart, Line, stack -from ..timeutil import day_bounds, day_text, local_date, now_local, parse_period +from ..timeutil import day_bounds, day_text, local_date, now_local, parse_period, period_parameters from ..tools import json_result from .store import HistoryCache @@ -30,8 +30,7 @@ PARAMETERS = { "type": "object", "properties": { - "start_date": {"type": "string", "description": f"First day, 'YYYY-MM-DD'. Default: the last {DEFAULT_DAYS} days."}, - "end_date": {"type": "string", "description": "Last day, 'YYYY-MM-DD' (up to yesterday). Default: yesterday."}, + **period_parameters(DEFAULT_DAYS), "driver": {"type": "string", "enum": list(DRIVERS), "description": "The reading to relate to rain. Default pressure (relative)."}, "chart": {"type": "boolean", "description": "Set true for a chart: the reading as a line, rain as bars behind it."}, diff --git a/lib/timeutil.py b/lib/timeutil.py index 5b2be98..3cfd437 100644 --- a/lib/timeutil.py +++ b/lib/timeutil.py @@ -96,6 +96,12 @@ def parse_period(args: dict, today: date, default_days: int) -> tuple[date, date return (first, last) if first <= last else "start_date must be before end_date (today isn't final, so the latest day is yesterday)" +def period_parameters(default_days: int) -> dict: + """The start_date and end_date in a tool's parameters, for a period parse_period reads.""" + return {"start_date": {"type": "string", "description": f"First day, 'YYYY-MM-DD'. Default: {default_days} days before the end."}, + "end_date": {"type": "string", "description": "Last day, 'YYYY-MM-DD' (up to yesterday). Default: yesterday."}} + + POINT_BUDGET = 500 # about as many points as a chart can show legibly WARN_POINTS = 2 * POINT_BUDGET # a question that sends the model more than twice a 500-point query's data gets a heads-up TOKENS_PER_POINT = 10 # a dated low/high row is about 10 tokens as the model reads it diff --git a/tests/test_compose.py b/tests/test_compose.py index da81f23..e32008d 100644 --- a/tests/test_compose.py +++ b/tests/test_compose.py @@ -56,6 +56,21 @@ async def test_a_reading_with_its_zones_over_rain_is_one_stacked_chart(tmp_path, await eco.close() +async def test_the_air_panels_share_one_load_and_a_weather_chart_asks_the_sensor_nothing(tmp_path, archived_cache): + comp, eco = await composer(tmp_path, archived_cache) + loads, rows = [], comp.air.rows + + async def counted(t0, t1): + loads.append((t0, t1)) + return await rows(t0, t1) + comp.air.rows = counted + out, _ = await plot(comp, panels=[{"series": "pm2_5"}, {"series": "pm10", "style": "rating"}, {"series": "rain"}]) + assert "chart" in out and len(loads) == 1 + out, _ = await plot(comp, panels=[{"series": "temperature"}]) + assert "chart" in out and len(loads) == 1 + await eco.close() + + async def test_the_traffic_light_rating_is_the_share_of_each_days_readings(tmp_path, archived_cache): comp, eco = await composer(tmp_path, archived_cache) out, specs = await plot(comp, panels=[{"series": "pm2_5", "style": "rating"}, {"series": "rain", "style": "bars"}]) From 039487784223df62d6c9b9dc6d40cbaafd736a07 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 20:21:30 +0000 Subject: [PATCH 52/65] daytable.py: a days question checked and filled in once for both tools; one builder for a bar per month or year MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - weather_days and air_days each parsed the period, checked the arguments, filled in sort_by and worked out the fields a listed day shows, line for line the same but for their defaults. daytable.prepare() does it once and returns an Asked (the period, the arguments, the fields shown with their units), which analyse() takes in place of seven of its eleven parameters. Each tool names its ALWAYS_SHOWN fields; days rank by the first when nothing else is named. - "Does the answer list the days?" (not for counts only, nor for a figure per month or year) was written in analyse() and again in weather_days' trace-rain footnote; it is lists_days() now. - specs.calendar_bars() is the bar per month or year that daytable's charts and rain's month and year bars each built, with the 28- and 365-day widths in it alone. - An `of` that names no field, with a plain count, made both tools raise KeyError (the model got "Error calling tool"). validate() now answers "of must be one of ...". The new test fails on the old code. - Analysis.rows, never read, goes. Mutating the new code found two rules with no test: with nothing named, days rank hottest first; and a figure per month or year lists no days, with or without count_only. Both have a test now. Over 2,118 calls (1,400 weather_days and 700 air_days with sampled arguments, the month and year rain bars, and bars_chart), the results and charts are the same before and after. lib: +109 โˆ’82 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/airgradient/days.py | 30 +++++-------- lib/daytable.py | 96 ++++++++++++++++++++++++++++++----------- lib/ecowitt/days.py | 35 ++++++--------- lib/rain.py | 19 +++----- lib/specs.py | 11 ++++- tests/test_days.py | 20 ++++++++- 6 files changed, 128 insertions(+), 83 deletions(-) diff --git a/lib/airgradient/days.py b/lib/airgradient/days.py index d977f03..58131e5 100644 --- a/lib/airgradient/days.py +++ b/lib/airgradient/days.py @@ -4,10 +4,10 @@ import asyncio import logging -from datetime import date, datetime, timedelta, tzinfo +from datetime import date, datetime, tzinfo from statistics import fmean -from ..daytable import analyse, grouped_as_asked, parameters, validate +from ..daytable import analyse, grouped_as_asked, parameters, prepare from ..timeutil import day_bounds, local_date, now_local from ..tools import json_result from .metrics import ALL_METRICS, CHART_UNITS, without_spikes @@ -17,6 +17,7 @@ HOURLY_MAX_READINGS = 30 # a day with this few readings holds hourly averages (24), not 5-minute ones (288) SUFFIX_KIND = {"max": max, "min": min, "avg": fmean} FIELDS = {f"{m}_{kind}": CHART_UNITS[m] for m in ALL_METRICS for kind in SUFFIX_KIND} +ALWAYS_SHOWN = ("pm2_5_max",) # every listed day shows it; days rank by it when nothing else is named PARAMETERS = parameters(list(FIELDS), "pm2_5_max > 25 and co2_max > 800") DESCRIPTION = ("Find, rank or count DAYS by the air-quality sensor's readings, from the stored history (any period, fast): for " @@ -44,26 +45,15 @@ def daily_values(rows: list[dict], shown: list[str], tz: tzinfo) -> dict[str, di def find_air_days(store, tz: tzinfo, args: dict, now: datetime, turn=None) -> dict: - today = now.date() - try: - first = date.fromisoformat(str(args["start_date"])[:10]) - last = min(date.fromisoformat(str(args["end_date"])[:10]), today - timedelta(days=1)) - except (KeyError, ValueError) as e: - return {"error": f"bad date ({e}); use 'YYYY-MM-DD'"} - if first > last: - return {"error": "start_date must be before end_date (today isn't final, so the latest day is yesterday)"} - if problem := validate(args, FIELDS): - return {"error": problem} - where = args.get("where") or [] - sort_by = args.get("sort_by") or (where[0]["field"] if where else args.get("of") or "pm2_5_max") - args = {**args, "sort_by": sort_by} - shown = list(dict.fromkeys(["pm2_5_max", sort_by, *(c["field"] for c in where), *([args["of"]] if args.get("of") else [])])) - days = [first + timedelta(days=k) for k in range((last - first).days + 1)] - rows = store.load(day_bounds(first, tz)[0], day_bounds(last, tz)[1]) + asked = prepare(args, FIELDS, ALWAYS_SHOWN, now.date()) + if isinstance(asked, str): + return {"error": asked} + days, shown = asked.days, asked.shown + rows = store.load(day_bounds(asked.first, tz)[0], day_bounds(asked.last, tz)[1]) counts = store.counts(days) values = daily_values(rows, shown, tz) checked = [d for d in days if any(d in values[n] for n in shown)] - got = analyse(values, shown, {n: FIELDS[n] for n in shown}, args, checked, first, last, tz, turn) + got = analyse(values, asked, checked, tz, turn) out = got.out hourly = [d for d in checked if (counts.get(d) or 0) <= HOURLY_MAX_READINGS] if hourly and any(n.endswith(("_max", "_min")) for n in shown): @@ -74,7 +64,7 @@ def find_air_days(store, tz: tzinfo, args: dict, now: datetime, turn=None) -> di out["note_missing"] = f"{missing} day(s) in this period have no stored readings (not downloaded yet, or the sensor was off) and are not counted." if not checked: out["note"] = "No stored readings for this period yet (the bot may still be downloading history)." - log.info("Air days %s to %s: %d checked, %d match", first, last, len(checked), len(got.matches)) + log.info("Air days %s to %s: %d checked, %d match", asked.first, asked.last, len(checked), len(got.matches)) return out diff --git a/lib/daytable.py b/lib/daytable.py index a963df4..88c5457 100644 --- a/lib/daytable.py +++ b/lib/daytable.py @@ -8,15 +8,15 @@ import logging import operator import re -from dataclasses import dataclass, field +from dataclasses import dataclass from collections.abc import Callable -from datetime import date, datetime, tzinfo +from datetime import date, datetime, timedelta, tzinfo from statistics import fmean from .captions import COUNT_CHART_HINT from .series import AIR -from .specs import Bars, Chart, Panel -from .timeutil import day_bounds, day_text +from .specs import Chart, Panel, calendar_bars +from .timeutil import day_text log = logging.getLogger(__name__) @@ -96,11 +96,63 @@ def validate(args: dict, fields: dict[str, str]) -> str | None: return f"stat must be one of {', '.join(STATS)}" if args.get("stat") not in (None, "count") and args.get("of") not in fields: return f"stat {args['stat']} needs `of`: one of {', '.join(fields)}" + if args.get("of") and args["of"] not in fields: + return f"of must be one of {', '.join(fields)}" if args.get("group_by") not in (None, "month", "year"): return "group_by must be month or year" return None +@dataclass(frozen=True) +class Asked: + """A days question, checked: the period (up to yesterday), the arguments with sort_by filled in, and the fields a listed + day shows, each with its unit.""" + first: date + last: date + args: dict + units: dict[str, str] + + @property + def days(self) -> list[date]: + return [self.first + timedelta(days=k) for k in range((self.last - self.first).days + 1)] + + @property + def shown(self) -> list[str]: + return list(self.units) + + @property + def where(self) -> list[dict]: + return self.args.get("where") or [] + + @property + def sort_by(self) -> str: + return self.args["sort_by"] + + +def prepare(args: dict, fields: dict[str, str], shown: tuple[str, ...], today: date) -> Asked | str: + """The question checked (an error text when it can't be used) and filled in. `fields` maps each field the tool offers to + its unit; `shown` are the ones every listed day shows, before those the arguments name. Days are ranked by sort_by, else + the first condition's field, else `of`, else the first of `shown`.""" + try: + first = date.fromisoformat(str(args["start_date"])[:10]) + last = min(date.fromisoformat(str(args["end_date"])[:10]), today - timedelta(days=1)) + except (KeyError, ValueError) as e: + return f"bad date ({e}); use 'YYYY-MM-DD'" + if first > last: + return "start_date must be before end_date (today isn't final, so the latest day is yesterday)" + if problem := validate(args, fields): + return problem + where = args.get("where") or [] + sort_by = args.get("sort_by") or (where[0]["field"] if where else args.get("of") or shown[0]) + named = dict.fromkeys([*shown, sort_by, *(c["field"] for c in where), *([args["of"]] if args.get("of") else [])]) + return Asked(first, last, {**args, "sort_by": sort_by}, {n: fields[n] for n in named}) + + +def lists_days(args: dict) -> bool: + """Does the answer list the days? Not when only the counts are asked for, nor a figure per month or year.""" + return not (args.get("count_only") or (args.get("stat") not in (None, "count") and args.get("group_by"))) + + def conditions_text(where: list[dict], units: dict[str, str]) -> str: """"UV index โ‰ฅ 10", "rain โ‰ฅ 1 mm and lowest humidity < 40 %": the conditions as a chart's title says them.""" parts = [] @@ -132,11 +184,9 @@ def aggregate(stat: str, numbers: list[float]) -> float | None: def bars_chart(figures: dict[str, float | None], by: str, title: str, subtitle: str, label: str, unit: str, tz: tzinfo, period: tuple[date, date]) -> Chart: """One bar per month or year (those with no figure left out), the number above each; `period` is the first and last day counted.""" - shown = {k: v for k, v in figures.items() if v is not None} - starts = [datetime.strptime(k, "%Y-%m" if by == "month" else "%Y").date() for k in shown] - bars = Bars(label, unit, [day_bounds(d, tz)[0] for d in starts], [float(v) for v in shown.values()], - 28 * 86400 if by == "month" else 365 * 86400, by, values=True) - return Chart(title, subtitle, [Panel(label, "" if unit == "days" else unit, bars=bars)], period=period) + shown = {datetime.strptime(k, "%Y-%m" if by == "month" else "%Y").date(): v for k, v in figures.items() if v is not None} + return Chart(title, subtitle, [Panel(label, "" if unit == "days" else unit, bars=calendar_bars(label, unit, shown, by, tz))], + period=period) @dataclass @@ -147,26 +197,23 @@ class Analysis: rank: Callable holds: Callable row: Callable - rows: list = field(default_factory=list) -def analyse(values: dict[str, dict[date, float]], shown: list[str], units: dict[str, str], args: dict, checked: list[date], - first: date, last: date, tz: tzinfo, turn=None, row_extra: Callable[[date], dict] | None = None, - scope: str = "") -> Analysis: - """Find, rank, count and group the checked days. `values` has {day: figure} for every field in `shown`.""" - where = args.get("where") or [] - sort_by = args.get("sort_by") or (where[0]["field"] if where else shown[0]) +def analyse(values: dict[str, dict[date, float]], asked: Asked, checked: list[date], tz: tzinfo, turn=None, + row_extra: Callable[[date], dict] | None = None, scope: str = "") -> Analysis: + """Find, rank, count and group the checked days. `values` has {day: figure} for every field the question shows.""" + args, where, shown, units, sort_by = asked.args, asked.where, asked.shown, asked.units, asked.sort_by + first, last = asked.first, asked.last limit = max(1, min(int(args.get("limit") or 5), MAX_LIMIT)) - label = day_text holds = lambda d, conds: all(d in values[c["field"]] and OPS[c["op"]](values[c["field"]][d], c["value"]) for c in conds) rank = lambda ds: sorted((d for d in ds if d in values[sort_by]), key=lambda d: values[sort_by][d], reverse=args.get("order", "desc") != "asc") - row = lambda d: {"date": label(d), **(row_extra(d) if row_extra else {}), + row = lambda d: {"date": day_text(d), **(row_extra(d) if row_extra else {}), **{n: round(values[n][d], 1) for n in shown if d in values[n]}} matches = [d for d in checked if holds(d, where)] ranked = rank(matches) - out = {"period": f"{label(first)} - {label(last)}", "days_checked": len(checked), "matching_days": len(matches), - "units": {n: units[n] for n in shown}} + out = {"period": f"{day_text(first)} - {day_text(last)}", "days_checked": len(checked), "matching_days": len(matches), + "units": dict(units)} if where: out["condition"] = conditions_text(where, units) stat, of, by = args.get("stat") or "count", args.get("of"), args.get("group_by") @@ -194,10 +241,9 @@ def analyse(values: dict[str, dict[date, float]], shown: list[str], units: dict[ title, name, unit = phrase(stat, of) + on, noun(of).capitalize(), units[of] detail = f"{len(matches):,} days counted" detail += f" ยท {scope}" if scope else "" - turn.charts.append(bars_chart(buckets, by, title, f"{label(first)} โ€“ {label(last)} ยท {detail} ยท per {by}", + turn.charts.append(bars_chart(buckets, by, title, f"{day_text(first)} โ€“ {day_text(last)} ยท {detail} ยท per {by}", name, unit, tz, (first, last))) out["chart"] = COUNT_CHART_HINT - rows = [] if args.get("count_only") or stat != "count" and by else [row(d) for d in ranked[:limit]] - if not args.get("count_only") and not (stat != "count" and by): - out["days"] = rows - return Analysis(out, matches, ranked, rank, holds, row, rows) + if lists_days(args): + out["days"] = [row(d) for d in ranked[:limit]] + return Analysis(out, matches, ranked, rank, holds, row) diff --git a/lib/ecowitt/days.py b/lib/ecowitt/days.py index a9bcbc1..19e02dc 100644 --- a/lib/ecowitt/days.py +++ b/lib/ecowitt/days.py @@ -13,10 +13,10 @@ import logging import operator import re -from datetime import date, datetime, timedelta, tzinfo +from datetime import date, datetime, tzinfo from statistics import fmean -from ..daytable import analyse, grouped_as_asked, parameters, validate +from ..daytable import analyse, grouped_as_asked, lists_days, parameters, prepare from ..series import WEATHER from ..timeutil import day_bounds, day_text, local_date, now_local from ..tools import json_result @@ -47,7 +47,8 @@ FIELDS[f"{_name}_min"] = (_group, _field, "low", _unit) FIELDS["temp_avg"] = ("outdoor", "temperature", "mean", "ยฐC") AVERAGED = {n for n in FIELDS if n.endswith(("_max", "_min")) and n not in ("temp_max", "temp_min")} # no range of their own at 30 minutes or daily -ALWAYS_SHOWN = ("temp_max", "temp_min", "rain") +FIELD_UNITS = {n: f[3] for n, f in FIELDS.items()} +ALWAYS_SHOWN = ("temp_max", "temp_min", "rain") # every listed day shows them; days rank by the first when nothing else is named PARAMETERS = parameters(list(FIELDS), "rain >= 1 and temp_max > 30", { "only": {"type": "string", "enum": ["public_holiday", "weekend"], @@ -93,19 +94,9 @@ def day_figures(cache: HistoryCache, mac: str, cycle: str, names: list[str], day def find_days(cache: HistoryCache, mac: str, tz: tzinfo, args: dict, now: datetime, turn=None) -> dict: - today = now.date() - try: - first = date.fromisoformat(str(args["start_date"])[:10]) - last = min(date.fromisoformat(str(args["end_date"])[:10]), today - timedelta(days=1)) - except (KeyError, ValueError) as e: - return {"error": f"bad date ({e}); use 'YYYY-MM-DD'"} - if first > last: - return {"error": "start_date must be before end_date (today isn't final, so the latest day is yesterday)"} - where = args.get("where") or [] - if problem := validate(args, {n: f[3] for n, f in FIELDS.items()}): - return {"error": problem} - sort_by = args.get("sort_by") or (where[0]["field"] if where else args.get("of") or "temp_max") - args = {**args, "sort_by": sort_by} + asked = prepare(args, FIELD_UNITS, ALWAYS_SHOWN, now.date()) + if isinstance(asked, str): + return {"error": asked} only = args.get("only") if only not in (None, "public_holiday", "weekend"): return {"error": "only must be public_holiday or weekend"} @@ -114,11 +105,11 @@ def find_days(cache: HistoryCache, mac: str, tz: tzinfo, args: dict, now: dateti return {"error": calendar.problem()} kind = {None: lambda d: True, "weekend": lambda d: d.weekday() >= 5, "public_holiday": lambda d: calendar.name(d) is not None}[only] - shown = list(dict.fromkeys([*ALWAYS_SHOWN, sort_by, *(c["field"] for c in where), *([args["of"]] if args.get("of") else [])])) + first, last, shown, where, sort_by = asked.first, asked.last, asked.shown, asked.where, asked.sort_by groups = sorted({FIELDS[n][0] for n in shown}) # which cycle each day is answered from: exact sub-daily data where every needed group has it - days = [first + timedelta(days=k) for k in range((last - first).days + 1)] + days = asked.days windows = [(cycle, d) for cycle in FINE_CYCLES for d in days] source: dict[date, str] = {} for (cycle, d), gaps in zip(windows, cache.uncovered(mac, groups, [(cycle, *day_bounds(d, tz, True)) for cycle, d in windows])): @@ -142,9 +133,7 @@ def find_days(cache: HistoryCache, mac: str, tz: tzinfo, args: dict, now: dateti source[d] = "daily" checked = [d for d in days if kind(d) and any(d in values[n] for n in shown)] - units = {n: FIELDS[n][3] for n in shown} - label = day_text - got = analyse(values, shown, units, args, checked, first, last, tz, turn, + got = analyse(values, asked, checked, tz, turn, lambda d: {**({"holiday": calendar.name(d)} if only == "public_holiday" else {}), "source": "daily" if source.get(d) == "daily" else "exact"}, {"public_holiday": "public holidays only", "weekend": "weekends only"}.get(only, "")) @@ -166,14 +155,14 @@ def find_days(cache: HistoryCache, mac: str, tz: tzinfo, args: dict, now: dateti break exact_days = sorted(d for d in checked if source.get(d) != "daily") - if args.get("count_only") or (args.get("stat") not in (None, "count") and args.get("group_by")): + if not lists_days(args): trace = [] # just the figures if trace: out["trace_rain_days"] = [row(d) for d in trace] out["note_trace"] = (f"These days rank higher but had less than {rain_cond['value']:g} mm of rain (a trace), so they " "are not in \"days\". Mention the first as a short footnote.") if any(source.get(d) == "daily" for d in checked): - out["exact_from"] = label(exact_days[0]) if exact_days else None + out["exact_from"] = day_text(exact_days[0]) if exact_days else None out["note_daily"] = (f"Days marked \"daily\" come from daily readings that run {daily_run(tz, first, last)}, so their rain and dates " "are approximate (a morning shower can be counted on the day before). Say so briefly if any " "listed day is marked daily, or if the period reaches back before exact_from.") diff --git a/lib/rain.py b/lib/rain.py index 027ddc5..de14418 100644 --- a/lib/rain.py +++ b/lib/rain.py @@ -4,7 +4,7 @@ from bisect import bisect_right from datetime import date, datetime, time, timedelta, tzinfo -from .specs import Bars +from .specs import Bars, calendar_bars from .timeutil import SLOT, day_bounds, local_epoch, to_local SPELL_GAP = 6 # slots: 3 dry hours end a rain spell @@ -73,16 +73,11 @@ def rain_bars(rain: dict[int, float], tz: tzinfo, first: date, last: date, until def _calendar_bars(rain: dict[int, float], tz: tzinfo, first: date, last: date, by: str) -> Bars: - """Rain summed per calendar month or year over the period (each with its figure above the bar).""" - key = (lambda d: (d.year, d.month)) if by == "month" else (lambda d: (d.year,)) - totals: dict[tuple, float] = {} - day = first - while day <= last: # every month or year of the period gets a bar, a dry one as 0 - totals.setdefault(key(day), 0.0) - day += timedelta(days=1) + """Rain summed per calendar month or year over the period, each with its total above its bar (every month or year of the + period has one, a dry one 0).""" + start = (lambda d: d.replace(day=1)) if by == "month" else (lambda d: d.replace(month=1, day=1)) + totals = dict.fromkeys((start(first + timedelta(days=n)) for n in range((last - first).days + 1)), 0.0) for t, mm in rain.items(): if mm > 0 and first <= (d := to_local(t, tz).date()) <= last: - totals[key(d)] += mm - starts = [date(k[0], k[1] if by == "month" else 1, 1) for k in totals] - return Bars("Rain", "mm", [day_bounds(d, tz)[0] for d in starts], [round(v, 1) for v in totals.values()], - 28 * 86400 if by == "month" else 365 * 86400, by, values=True) + totals[start(d)] += mm + return calendar_bars("Rain", "mm", {d: round(v, 1) for d, v in totals.items()}, by, tz) diff --git a/lib/specs.py b/lib/specs.py index fe162b2..13a9d43 100644 --- a/lib/specs.py +++ b/lib/specs.py @@ -9,10 +9,10 @@ built, not when it is drawn.""" from dataclasses import dataclass, field -from datetime import date +from datetime import date, tzinfo from .series import RAIN_WITH, WEATHER -from .timeutil import day_text +from .timeutil import day_bounds, day_text NO_LOW = tuple(name for name, r in WEATHER.items() if not r.low) # readings whose low is nothing every night or calm hour: only the high is tagged @@ -60,6 +60,13 @@ def __post_init__(self): raise ValueError(f"bars {self.label!r}: x and y differ in length") +def calendar_bars(label: str, unit: str, figures: dict[date, float], by: str, tz: tzinfo) -> Bars: + """A bar per calendar month or year (`by`) from {its first day: figure}, the figure written above it. A bar is as wide as + the shortest month or year.""" + return Bars(label, unit, [day_bounds(d, tz)[0] for d in figures], [float(v) for v in figures.values()], + 28 * 86400 if by == "month" else 365 * 86400, by, values=True) + + @dataclass class Shares: label: str diff --git a/tests/test_days.py b/tests/test_days.py index 22dc83b..062fd95 100644 --- a/tests/test_days.py +++ b/tests/test_days.py @@ -229,6 +229,16 @@ async def test_one_known_day_can_be_looked_up_with_no_conditions(station): assert row["rain"] == truth(day)[1] and row["temp_max"] == round(truth(day)[0], 1) +async def test_with_nothing_named_days_rank_hottest_first_and_a_figure_per_month_lists_no_days(station): + eco, _ = station + first, last = dates(eco, 30, 1) + days = [first + timedelta(days=k) for k in range((last - first).days + 1)] + out = await ask(eco, start_date=str(first), end_date=str(last), limit=3) + assert [r["date"] for r in out["days"]] == [label(d) for d in sorted(days, key=lambda d: -truth(d)[0])[:3]] + monthly = await ask(eco, start_date=str(first), end_date=str(last), stat="sum", of="rain", group_by="month") + assert monthly["by_month"] and "days" not in monthly # the figures alone, count_only or not + + async def test_days_can_be_counted_by_any_reading_not_only_temperature_rain_and_gusts(station): eco, fake = station first, last = dates(eco, 10, 1) @@ -363,8 +373,16 @@ def test_the_field_list_is_in_the_tool_schema_once_and_a_wrong_sort_or_of_field_ props = days.PARAMETERS["properties"] assert "enum" in props["where"]["items"]["properties"]["field"] and "enum" not in props["sort_by"] and "enum" not in props["of"] from lib.daytable import validate - units = {n: f[3] for n, f in days.FIELDS.items()} + units = days.FIELD_UNITS assert "sort_by must be one of" in validate({"sort_by": "nonsense"}, units) and "needs `of`" in validate({"stat": "sum", "of": "nonsense"}, units) + assert "of must be one of" in validate({"stat": "count", "of": "nonsense"}, units) # a count needs no `of`, but a wrong one is caught + + +async def test_a_wrong_of_with_a_plain_count_is_an_error_not_a_crash(station): + eco, _ = station + first, last = dates(eco, 10, 1) + out = await ask(eco, start_date=str(first), end_date=str(last), of="nonsense") + assert "of must be one of" in out["error"] def test_the_description_names_every_field_and_which_have_a_lowest(): From fb63edfe459b4810e4db2274d952e5302ee17181 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 20:24:54 +0000 Subject: [PATCH 53/65] lines.py: a reading is a Sample with named fields; unbanded() returns a new line; a slot's low is clamped too (M7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A reading was a bare 5-tuple read by position (r[1] the value, r[2] its low, r[4] the seconds it covers), and its type was called Reading, the name of series.Reading too. It is the NamedTuple Sample (t, value, low, high, seconds), and _own_range() is its bounds() method. build_line still takes plain tuples in that order; the Ecowitt query and the AirGradient source build Samples. - Plotted.unbanded() took the shaded range off the line it was called on and returned that same line; the Ecowitt query called it for the change alone. It now returns a new line and leaves the old one as it is. - M7: slot_readings took a slot's high up to its value when it fell short, but left a low above the value, so a band could sit wholly above its line. The low is now taken down to the value. The 7 sample charts and 16 dump_specs charts are byte-identical (their lows never exceed their values). The old and new build_line give the same line over 3,000 random sets of readings (every resolution, mixed, with and without ranges, smoothed, by day, across 5 April); slot_readings differs only where a low was above its value. Tests cover the clamp and that unbanded() leaves its line alone; both fail with the old code put back. lib: +50 โˆ’42 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/airgradient/source.py | 4 +- lib/ecowitt/query.py | 8 ++-- lib/lines.py | 80 +++++++++++++++++++++----------------- tests/test_chart_labels.py | 2 + tests/test_lines.py | 7 ++++ 5 files changed, 59 insertions(+), 42 deletions(-) diff --git a/lib/airgradient/source.py b/lib/airgradient/source.py index 8f1b5ce..c6db0fd 100644 --- a/lib/airgradient/source.py +++ b/lib/airgradient/source.py @@ -25,7 +25,7 @@ from ..captions import CHART_HINT, wants_chart from ..config import Config from ..sources import Source -from ..lines import Plotted, build_line +from ..lines import Plotted, Sample, build_line from ..panels import panel_for from ..specs import Chart, Line from ..timeutil import day_bounds, day_text, local_date, local_epoch, parse_local, to_local, when @@ -385,7 +385,7 @@ def _line(self, name: str, rows: list[dict]) -> tuple[Line, Plotted] | None: if len(pts) < 2: return None gap = max(60, round(statistics.median(b[0] - a[0] for a, b in pairwise(pts)))) - plotted = build_line([(t, v, None, None, gap) for t, v in pts], self.tz, pts[-1][0] - pts[0][0]) + plotted = build_line([Sample(t, v, None, None, gap) for t, v in pts], self.tz, pts[-1][0] - pts[0][0]) if plotted is None: return None lo, hi = min(pts, key=lambda p: p[1]), max(pts, key=lambda p: p[1]) diff --git a/lib/ecowitt/query.py b/lib/ecowitt/query.py index 7cf210e..7edf0ba 100644 --- a/lib/ecowitt/query.py +++ b/lib/ecowitt/query.py @@ -17,7 +17,7 @@ from typing import NamedTuple from ..captions import AVERAGE_CHART_HINT, CHART_HINT, STACK_CHART_HINT, DIRECTION_CHART_HINT, wants_chart -from ..lines import build_line +from ..lines import Sample, build_line from ..daytable import grouping_in from ..rain import rain_bars, rain_slots from ..timeutil import daily_summary, day_bounds, day_text, local_date, now_local, parse_local, SLOT @@ -494,7 +494,7 @@ def _band_key(self, k: str) -> str | None: key = f"{group}.{reading.band_field}" if reading and reading.band_field and field == reading.field else None return key if key in self.store else None - def _series_readings(self, k: str) -> list: + def _series_readings(self, k: str) -> list[Sample]: """One series as readings for lines.build_line. A reading with a band series (wind) is its mean shaded up to that series.""" band = self._band_key(k) gust = self.store[band]["pts"] if band else {} @@ -505,7 +505,7 @@ def _series_readings(self, k: str) -> list: low, high = (r["low"][0] if "low" in r else None), (r["high"][0] if "high" in r else None) if band: low, high = low_of(r), max(high_of(gust[t]) if t in gust and "value" in gust[t] else high_of(r), v) - out.append((t, v, low, high, CYCLES[r["cycle"]].seconds)) + out.append(Sample(t, v, low, high, CYCLES[r["cycle"]].seconds)) return out def _series_entry(self, k: str, label: str | None = None) -> tuple[Line, str] | None: @@ -515,7 +515,7 @@ def _series_entry(self, k: str, label: str | None = None) -> tuple[Line, str] | if line is None: return None group = k.split(".", 1)[0] - line.unbanded(self.span.days + 1, keep=self._band_key(k) is not None) # a short period: a clean line, except wind's gusts + line = line.unbanded(self.span.days + 1, keep=self._band_key(k) is not None) # a short period: a clean line, except wind's gusts return line.spec(label or group.replace("_", " ").capitalize(), indoor=group == "indoor"), line.name async def _rain_bars(self) -> Bars: diff --git a/lib/lines.py b/lib/lines.py index 76875cf..ffc99de 100644 --- a/lib/lines.py +++ b/lib/lines.py @@ -1,26 +1,38 @@ """One rule for turning readings into the line a chart draws, whichever device they came from. -A reading is (epoch, value, low, high, seconds): the value, the lowest and highest reading it stands for (None when the -source gives no range) and how many seconds of time it covers (300 for a 5-minute reading, 1800 for a 30-minute one, 86400 -for one of Ecowitt's daily records). `build_line` picks how to draw a whole period of them: +A reading is a Sample (epoch, value, low, high, seconds): the value, the lowest and highest reading it stands for (None when +the source gives no range) and how many seconds of time it covers (300 for a 5-minute reading, 1800 for a 30-minute one, +86400 for one of Ecowitt's daily records). `build_line` picks how to draw a whole period of them: - up to about 500 points, the readings themselves (a slow 5-minute reading is lightly smoothed); - more than that, buckets of 30 minutes, an hour, 2 hours, 4 hours, and past that one point a day (the bucket's mean). Every line keeps its range as a band at every width: the lowest and highest reading in each bucket, or a reading's own range when the source gives one (Ecowitt's 30-minute lows and highs; wind: the average speed, shaded up to the gusts). The readings themselves (up to about 500 points) have a band only where the source gave them a range.""" -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import date, datetime, time +from typing import NamedTuple from .specs import Line from .timeutil import SLOT, WIDTH_NAMES, bucket_width, bucketed, daily_summary, local_date, local_epoch, rolling_mean DAY = 86400 SMOOTH_POINTS = 3 # a smoothed 5-minute line: each point is the mean of this many readings (15 minutes) -type Reading = tuple[int, float, float | None, float | None, int] SHORT_DAYS = 31 # up to this long a weather line is drawn with no shaded range (see Plotted.unbanded) +class Sample(NamedTuple): + t: int # epoch seconds + value: float + low: float | None # the lowest and highest reading it stands for, None when the source gives no range + high: float | None + seconds: int # the time it covers + + def bounds(self) -> tuple[float, float]: + """(low, high), the value itself for a side the source gave no range.""" + return (self.value if self.low is None else self.low), (self.value if self.high is None else self.high) + + @dataclass class Plotted: x: list[int] @@ -33,10 +45,8 @@ class Plotted: def unbanded(self, days: float, keep: bool = False) -> "Plotted": """The line without its shaded range when the period is SHORT_DAYS or less (a short period reads best as a clean line); `keep` - leaves it (wind: the mean shaded up to the gusts).""" - if not keep and days <= SHORT_DAYS: - self.low = self.high = None - return self + leaves it (wind: the mean shaded up to the gusts). A new line: this one is left as it is.""" + return self if keep or days > SHORT_DAYS else replace(self, low=None, high=None) @property def name(self) -> str: @@ -51,34 +61,32 @@ def spec(self, label: str, records: dict | None = None, reading: str = "", indoo def slot_readings(values: dict[int, float], lows: dict[int, float] | None = None, highs: dict[int, float] | None = None, - seconds: int = SLOT) -> list[Reading]: - """Readings from {epoch: value} maps, all covering `seconds`; the range is what the maps hold, else None.""" + seconds: int = SLOT) -> list[Sample]: + """Readings from {epoch: value} maps, all covering `seconds`; the range is what the maps hold (taken out to the value where it + falls short of it), else None.""" lows, highs = lows or {}, highs or {} - return [(t, v, lows.get(t), None if t not in highs else max(highs[t], v), seconds) for t, v in values.items()] - - -def _own_range(r: Reading) -> tuple[float, float]: - return (r[1] if r[2] is None else r[2]), (r[1] if r[3] is None else r[3]) + return [Sample(t, v, None if t not in lows else min(lows[t], v), None if t not in highs else max(highs[t], v), seconds) + for t, v in values.items()] -def build_line(readings: list[Reading], tz, span_seconds: float, *, smooth: bool = False, +def build_line(readings: list[Sample], tz, span_seconds: float, *, smooth: bool = False, force_daily: bool = False, until: date | None = None) -> Plotted | None: - """The line for these readings over a period of span_seconds; None when there is too little to draw. - force_daily: one point a day whatever the length (an average was asked for). until: with daily records in the mix, - days from this one on are not averaged from sub-daily readings (the day is not over).""" - rs = sorted(readings) - fine = [r for r in rs if r[4] < DAY] - records = [r for r in rs if r[4] >= DAY] + """The line for these readings (Samples, or tuples in their order) over a period of span_seconds; None when there is too + little to draw. force_daily: one point a day whatever the length (an average was asked for). until: with daily records in + the mix, days from this one on are not averaged from sub-daily readings (the day is not over).""" + rs = sorted(map(Sample._make, readings)) + fine = [r for r in rs if r.seconds < DAY] + records = [r for r in rs if r.seconds >= DAY] if not fine: # only daily records: as they are, each with its own range return _as_they_are(records) if len(records) >= 2 else None - source = min(r[4] for r in fine) + source = min(r.seconds for r in fine) width = DAY if records or force_daily else bucket_width(span_seconds, source) if width >= DAY: return _daily(fine, records, tz, until) - if width > source or len({r[4] for r in fine}) > 1: # too many for the chart, or two resolutions side by side - xs, mean, low, high = bucketed([(r[0], r[1], *_own_range(r), r[4] < SLOT) for r in fine], tz, max(width, SLOT)) + if width > source or len({r.seconds for r in fine}) > 1: # too many for the chart, or two resolutions side by side + xs, mean, low, high = bucketed([(r.t, r.value, *r.bounds(), r.seconds < SLOT) for r in fine], tz, max(width, SLOT)) return Plotted(xs, mean, max(width, SLOT), False, low, high) if len(xs) >= 2 else None - line = _as_they_are([r for r in fine if r[4] == source]) + line = _as_they_are([r for r in fine if r.seconds == source]) if line and smooth and source == 300: # slow readings come in 0.1-degree steps: a light average of the real readings smoothed = rolling_mean(dict(zip(line.x, line.y)), SMOOTH_POINTS * source // 2) if len(smoothed) == len(line.x): # every reading was a finite number @@ -87,25 +95,25 @@ def build_line(readings: list[Reading], tz, span_seconds: float, *, smooth: bool return line -def _as_they_are(rs: list[Reading]) -> Plotted | None: +def _as_they_are(rs: list[Sample]) -> Plotted | None: if len(rs) < 2: return None - ranges = [_own_range(r) for r in rs] - ranged = sum(1 for r in rs if r[2] is not None or r[3] is not None) >= len(rs) // 2 - return Plotted([r[0] for r in rs], [r[1] for r in rs], rs[0][4], True, + ranges = [r.bounds() for r in rs] + ranged = sum(1 for r in rs if r.low is not None or r.high is not None) >= len(rs) // 2 + return Plotted([r.t for r in rs], [r.value for r in rs], rs[0].seconds, True, [lo for lo, _ in ranges] if ranged else None, [hi for _, hi in ranges] if ranged else None) -def _daily(fine: list[Reading], records: list[Reading], tz, until: date | None) -> Plotted | None: +def _daily(fine: list[Sample], records: list[Sample], tz, until: date | None) -> Plotted | None: """One point a day. Days held as sub-daily readings get their own mean, lowest and highest over the local day; the older days come from the daily records, each with its own range.""" - keep = [r for r in fine if until is None or local_date(r[0], tz) < until] - days = daily_summary(((r[0], r[1], *_own_range(r), r[4] < SLOT) for r in keep), tz) + keep = [r for r in fine if until is None or local_date(r.t, tz) < until] + days = daily_summary(((r.t, r.value, *r.bounds(), r.seconds < SLOT) for r in keep), tz) hour = 10 if records else 12 # Ecowitt's daily records are 10am-10am windows: draw the exact days where they sit points = {local_epoch(datetime.combine(d, time(hour)), tz): v for d, v in days.items()} for r in records: - if local_date(r[0], tz) not in days: - points[r[0]] = (r[1], *_own_range(r)) if r[2] is not None and r[3] is not None else (r[1], None, None) + if local_date(r.t, tz) not in days: + points[r.t] = (r.value, *r.bounds()) if r.low is not None and r.high is not None else (r.value, None, None) xs = sorted(points) if len(xs) < 2: return None diff --git a/tests/test_chart_labels.py b/tests/test_chart_labels.py index 3260169..0e4e4d9 100644 --- a/tests/test_chart_labels.py +++ b/tests/test_chart_labels.py @@ -14,6 +14,8 @@ def banded(): assert banded().unbanded(7).low is None and banded().unbanded(SHORT_DAYS).high is None assert banded().unbanded(7, keep=True).low is not None # wind: the mean shaded up to the gusts assert banded().unbanded(SHORT_DAYS + 1).low is not None # a quarter keeps its daily range + line = banded() + assert line.unbanded(7).low is None and line.low is not None # a new line: the one it came from keeps its range def labels_of(chart): diff --git a/tests/test_lines.py b/tests/test_lines.py index 99a2fd4..bf7d5df 100644 --- a/tests/test_lines.py +++ b/tests/test_lines.py @@ -32,6 +32,13 @@ def test_a_bands_range_is_kept_at_every_width(): assert hourly.width == 3600 and hourly.high is not None +def test_a_slots_range_always_holds_its_value(): + (r,) = slot_readings({T0: 5.0}, lows={T0: 6.0}, highs={T0: 4.0}) # a low above the value, a high below it + assert (r.low, r.high) == (5.0, 5.0) + (r,) = slot_readings({T0: 5.0}, lows={T0: 3.0}, highs={T0: 8.0}) + assert (r.low, r.high) == (3.0, 8.0) + + def test_a_slow_five_minute_line_is_smoothed_but_ends_on_the_latest_reading(): rs = [(T0 + i * 300, 10.0 + (i % 2), None, None, 300) for i in range(50)] plain, smooth = build_line(rs, TZ, 50 * 300), build_line(rs, TZ, 50 * 300, smooth=True) From 9294137a24329f20c2e98b1a0e836dfd03a73b04 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 20:27:25 +0000 Subject: [PATCH 54/65] admins.py: the groups' admin lists asked for together, and one lookup per group at a time (M1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - allowed() asked for the groups' lists one after another. It now asks for them together (asyncio.gather), so a sender who is in no list waits for one round trip, not one per group. One difference: the first list holding the sender no longer stops the rest being fetched, so on a cold cache every group's list is fetched (each still once in ten minutes). - Messages that arrived together while the lists were not cached each fetched them again. Each group's lookup now holds a lock; a message that waited on it finds the list just fetched. A failed lookup is shared the same way (the old list stands and is retried after a minute). A test with a Telegram stub that answers slowly: four messages at once on a cold cache fetch each group's list once, with both groups at the same time. With the old code it made 8 fetches, one group at a time. lib: +22 โˆ’20 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/admins.py | 42 +++++++++++++++++++++------------------- tests/test_admin_only.py | 25 ++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 20 deletions(-) diff --git a/lib/admins.py b/lib/admins.py index ab7b0f3..207d525 100644 --- a/lib/admins.py +++ b/lib/admins.py @@ -4,9 +4,11 @@ added to it or someone speaks in it) or of a group named by a negative id in ADMIN_CHAT_IDS. The lists are asked for once and kept for ten minutes. ADMIN_CHAT_IDS is added to that, never instead of it: a positive id is a user (it bootstraps a bot that is in no group yet), a negative id is a group, whose own admins are then looked up and whose anonymous admins are let in: a message an admin sends "as the -group" carries the group as its `sender_chat` and no real user, so it is allowed only when that group's id is listed. +group" carries the group as its `sender_chat` and no real user, so it is allowed only when that group's id is listed. The groups' +lists are asked for together, and messages that arrive together while a list is being fetched wait for that one lookup. """ +import asyncio import logging import time @@ -21,6 +23,7 @@ def __init__(self, state, extra_ids=(), ttl: float = TTL_SECONDS, clock=time.mon self.state, self.extra, self.ttl, self.clock = state, frozenset(extra_ids), ttl, clock self._cache: dict[int, tuple[float, frozenset[int]]] = {} # group -> (when fetched, its admins) self._failing: set[int] = set() + self._looking: dict[int, asyncio.Lock] = {} # group -> held while its list is fetched def groups(self) -> list[int]: """The groups whose admins count: the ones the bot is in (not one that removed it), and the ones ADMIN_CHAT_IDS names.""" @@ -28,29 +31,28 @@ def groups(self) -> list[int]: return sorted(known | {i for i in self.extra if i < 0}) async def _admins_of(self, bot, chat_id: int) -> frozenset[int]: - cached = self._cache.get(chat_id) - if cached and self.clock() - cached[0] < self.ttl: - return cached[1] - try: - members = await bot.get_chat_administrators(chat_id) - ids = frozenset(m.user.id for m in members if not getattr(m.user, "is_bot", False)) - except Exception as e: # the bot was removed, Telegram is down: keep what was known, try again soon, say it once - if chat_id not in self._failing: - self._failing.add(chat_id) - log.warning("Couldn't list the admins of %s (%s: %s)", chat_id, type(e).__name__, e) - ids = cached[1] if cached else frozenset() - self._cache[chat_id] = (self.clock() - self.ttl + RETRY_SECONDS, ids) + async with self._looking.setdefault(chat_id, asyncio.Lock()): # one lookup at a time: who waited finds it done + cached = self._cache.get(chat_id) + if cached and self.clock() - cached[0] < self.ttl: + return cached[1] + try: + members = await bot.get_chat_administrators(chat_id) + ids = frozenset(m.user.id for m in members if not getattr(m.user, "is_bot", False)) + except Exception as e: # the bot was removed, Telegram is down: keep what was known, try again soon, say it once + if chat_id not in self._failing: + self._failing.add(chat_id) + log.warning("Couldn't list the admins of %s (%s: %s)", chat_id, type(e).__name__, e) + ids = cached[1] if cached else frozenset() + self._cache[chat_id] = (self.clock() - self.ttl + RETRY_SECONDS, ids) + return ids + self._failing.discard(chat_id) + self._cache[chat_id] = (self.clock(), ids) return ids - self._failing.discard(chat_id) - self._cache[chat_id] = (self.clock(), ids) - return ids async def allowed(self, bot, sender_ids) -> bool: """Is one of these ids (the sender's user id and, for a message sent as a chat, that chat's id) allowed?""" ids = {i for i in sender_ids if isinstance(i, int)} if ids & self.extra: return True - for chat_id in self.groups(): - if ids & await self._admins_of(bot, chat_id): - return True - return False + lists = await asyncio.gather(*(self._admins_of(bot, chat_id) for chat_id in self.groups())) + return any(ids & admins for admins in lists) diff --git a/tests/test_admin_only.py b/tests/test_admin_only.py index 7f331e3..04db4b8 100644 --- a/tests/test_admin_only.py +++ b/tests/test_admin_only.py @@ -1,5 +1,6 @@ """ADMIN_ONLY (who may use the bot), /alerts in groups, /usage, and the checks every button press goes through.""" +import asyncio import random from types import SimpleNamespace as NS @@ -158,6 +159,30 @@ async def test_a_failed_lookup_keeps_the_old_list_and_is_retried_soon_without_ra assert not await fresh.allowed(tg, [ADMIN]) # nothing known: nobody +class SlowTG(TG): + """A Telegram client whose admin lists take a moment to come back; `most` is the most it was asked for at once.""" + def __init__(self, admins): + super().__init__(admins) + self.at_once = self.most = 0 + + async def get_chat_administrators(self, chat_id): + self.at_once += 1 + self.most = max(self.most, self.at_once) + await asyncio.sleep(0.01) + self.at_once -= 1 + return await super().get_chat_administrators(chat_id) + + +async def test_the_groups_are_looked_up_together_and_messages_arriving_together_share_the_lookup(tmp_path): + state, tg = AlertState(tmp_path / "s.json"), SlowTG({GROUP: [ADMIN], OTHER_GROUP: [3]}) + state.add_chat(GROUP, "Home") + state.add_chat(OTHER_GROUP, "Away") + admins = Admins(state) + got = await asyncio.gather(*(admins.allowed(tg, [user]) for user in (ADMIN, 3, STRANGER, ADMIN))) + assert got == [True, True, False, True] + assert sorted(tg.fetches) == sorted([GROUP, OTHER_GROUP]) and tg.most == 2 # each list once, both at the same time + + async def test_the_admins_of_a_group_that_removed_the_bot_no_longer_count(tmp_path): state, tg = AlertState(tmp_path / "s.json"), TG({GROUP: [ADMIN]}) state.add_chat(GROUP, "Home") From 833439a60c6daa8219294003a236bb7360f779bd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 20:31:33 +0000 Subject: [PATCH 55/65] timeutil.py is time again: chart bucketing moves to lines.py, the token budget to llm.py; at_hour() for "this hour on this day" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The point budget, the bucket widths and their names, bucket_width, the daily and bucket summaries, bucketed and rolling_mean are how a chart line is drawn, and lines.py was their only user in lib (the Ecowitt query reads daily_summary from there now). They live in lines.py. - bucketed()'s daily branch went with them: build_line hands a day-wide line to _daily and never called bucketed with a day. Without it bucketed needs no time zone. - The tokens a data point costs, the characters a token holds and the heads-up threshold are the model's, and llm.py was their only user: they live there, the threshold still worked out from the chart's point budget (10,000 tokens). - at_hour(day, hour, tz), the epoch of a local clock hour on a day, replaces three spellings of local_epoch(datetime.combine(day, time(hour)), tz): rain's bar starts, a line's daily points, the start of the quiet hours. A test checks it either side of 5 April. The 7 sample charts and 16 dump_specs charts are byte-identical. lib: +86 โˆ’88 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/alerts/weather.py | 6 ++-- lib/ecowitt/query.py | 4 +-- lib/lines.py | 71 ++++++++++++++++++++++++++++++++++--- lib/llm.py | 7 +++- lib/rain.py | 7 ++-- lib/timeutil.py | 79 +++--------------------------------------- tests/test_agent.py | 2 +- tests/test_ecowitt.py | 10 +++--- tests/test_timeutil.py | 8 ++++- 9 files changed, 99 insertions(+), 95 deletions(-) diff --git a/lib/alerts/weather.py b/lib/alerts/weather.py index 8b61c67..4624957 100644 --- a/lib/alerts/weather.py +++ b/lib/alerts/weather.py @@ -15,14 +15,14 @@ """ import logging -from datetime import datetime, time, timedelta +from datetime import timedelta from ..ecowitt.glance import UVI_ALERT from . import confirm, pictures from .menu import kinds_of from .backoff import due, remember from ..ecowitt.rain import Rows, duration, rain_amount, wet_flags -from ..timeutil import clock_text, local_epoch, to_local +from ..timeutil import at_hour, clock_text, to_local log = logging.getLogger(__name__) @@ -60,7 +60,7 @@ def _quiet_began(self, ts: int) -> int: """When the quiet hours that `ts` falls in began (epoch): the evening before, for hours that run across midnight.""" local = to_local(ts, self.tz) day = local.date() - timedelta(days=1 if local.hour < self.quiet[0] else 0) - return local_epoch(datetime.combine(day, time(self.quiet[0])), self.tz) + return at_hour(day, self.quiet[0], self.tz) @property def tz(self): diff --git a/lib/ecowitt/query.py b/lib/ecowitt/query.py index 7edf0ba..e6e0403 100644 --- a/lib/ecowitt/query.py +++ b/lib/ecowitt/query.py @@ -17,10 +17,10 @@ from typing import NamedTuple from ..captions import AVERAGE_CHART_HINT, CHART_HINT, STACK_CHART_HINT, DIRECTION_CHART_HINT, wants_chart -from ..lines import Sample, build_line +from ..lines import Sample, build_line, daily_summary from ..daytable import grouping_in from ..rain import rain_bars, rain_slots -from ..timeutil import daily_summary, day_bounds, day_text, local_date, now_local, parse_local, SLOT +from ..timeutil import day_bounds, day_text, local_date, now_local, parse_local, SLOT from ..tools import json_result from ..series import WEATHER, derives_range, field_of, find, find_name from ..panels import panel_for diff --git a/lib/lines.py b/lib/lines.py index ffc99de..6ce02d2 100644 --- a/lib/lines.py +++ b/lib/lines.py @@ -9,16 +9,23 @@ when the source gives one (Ecowitt's 30-minute lows and highs; wind: the average speed, shaded up to the gusts). The readings themselves (up to about 500 points) have a band only where the source gave them a range.""" +import math +from bisect import bisect_left, bisect_right from dataclasses import dataclass, replace -from datetime import date, datetime, time +from datetime import date, tzinfo +from itertools import accumulate +from statistics import fmean from typing import NamedTuple from .specs import Line -from .timeutil import SLOT, WIDTH_NAMES, bucket_width, bucketed, daily_summary, local_date, local_epoch, rolling_mean +from .timeutil import SLOT, at_hour, local_date DAY = 86400 SMOOTH_POINTS = 3 # a smoothed 5-minute line: each point is the mean of this many readings (15 minutes) SHORT_DAYS = 31 # up to this long a weather line is drawn with no shaded range (see Plotted.unbanded) +POINT_BUDGET = 500 # about as many points as a chart can show legibly +WIDTHS = (300, SLOT, 3600, 7200, 14400, DAY) # bucket sizes a chart line can be drawn at: 5 min ... a day +WIDTH_NAMES = {300: "5-minute", 1800: "30-minute", 3600: "hourly", 7200: "2-hour", 14400: "4-hour", 86400: "daily"} class Sample(NamedTuple): @@ -60,6 +67,62 @@ def spec(self, label: str, records: dict | None = None, reading: str = "", indoo return Line(label, self.x, self.y, self.low, self.high, self.smoothed, records or {}, reading, indoor) +def bucket_width(span_seconds: float, source_seconds: float, budget: int = POINT_BUDGET) -> int: + """The finest bucket (never finer than the readings themselves) that keeps a line of this length within the + point budget; a day is the widest.""" + return next((w for w in WIDTHS if w >= source_seconds and span_seconds / w <= budget), WIDTHS[-1]) + + +def _summarise(points, key) -> dict: + """{bucket: (mean, lowest low, highest high)} from (ts, value, low, high, fine) readings. The mean takes one value + per 30-minute slot, so a stretch held as 5-minute readings (fine) counts no more than the same stretch held as + 30-minute ones: a slot's fine readings win where it has them.""" + slots: dict = {} + extremes: dict = {} + for ts, value, low, high, fine in points: + k = key(ts) + slots.setdefault((k, ts // SLOT), {}).setdefault(fine, []).append(value) + lo, hi = extremes.get(k, (low, high)) + extremes[k] = (min(lo, low), max(hi, high)) + means: dict = {} + for (k, _), got in slots.items(): + values = got.get(True) or got[False] + means.setdefault(k, []).append(fmean(values)) + return {k: (fmean(v), *extremes[k]) for k, v in means.items()} + + +def daily_summary(points, tz: tzinfo) -> dict[date, tuple[float, float, float]]: + """{local day: (mean, lowest low, highest high)}; see _summarise.""" + return _summarise(points, lambda ts: local_date(ts, tz)) + + +def bucket_summary(points, width: int) -> dict[int, tuple[float, float, float]]: + """{bucket start epoch: (mean, lowest low, highest high)} for buckets of `width` seconds; see _summarise.""" + return _summarise(points, lambda ts: ts // width * width) + + +def bucketed(points, width: int) -> tuple[list[int], list[float], list[float], list[float]]: + """(x, mean, low, high) lists for a chart line in epoch-aligned buckets of this width (under a day), each drawn at its + middle. A line of days is _daily's.""" + buckets = bucket_summary(points, width) + keys = sorted(buckets) + rows = [buckets[k] for k in keys] + return [k + width // 2 for k in keys], [r[0] for r in rows], [r[1] for r in rows], [r[2] for r in rows] + + +def rolling_mean(values: dict[int, float], half_window: int) -> dict[int, float]: + """Each reading replaced by the mean of the readings within half_window seconds either side of it (itself included): + a light smoothing of the drawn line. Timestamps are unchanged, nothing is interpolated, and a gap only means fewer + readings in the window. A reading that is not a finite number is left out.""" + ts = sorted(t for t, v in values.items() if math.isfinite(v)) + prefix = [0.0, *accumulate(values[t] for t in ts)] + out: dict[int, float] = {} + for t in ts: + lo, hi = bisect_left(ts, t - half_window), bisect_right(ts, t + half_window) + out[t] = (prefix[hi] - prefix[lo]) / (hi - lo) + return out + + def slot_readings(values: dict[int, float], lows: dict[int, float] | None = None, highs: dict[int, float] | None = None, seconds: int = SLOT) -> list[Sample]: """Readings from {epoch: value} maps, all covering `seconds`; the range is what the maps hold (taken out to the value where it @@ -84,7 +147,7 @@ def build_line(readings: list[Sample], tz, span_seconds: float, *, smooth: bool if width >= DAY: return _daily(fine, records, tz, until) if width > source or len({r.seconds for r in fine}) > 1: # too many for the chart, or two resolutions side by side - xs, mean, low, high = bucketed([(r.t, r.value, *r.bounds(), r.seconds < SLOT) for r in fine], tz, max(width, SLOT)) + xs, mean, low, high = bucketed([(r.t, r.value, *r.bounds(), r.seconds < SLOT) for r in fine], max(width, SLOT)) return Plotted(xs, mean, max(width, SLOT), False, low, high) if len(xs) >= 2 else None line = _as_they_are([r for r in fine if r.seconds == source]) if line and smooth and source == 300: # slow readings come in 0.1-degree steps: a light average of the real readings @@ -110,7 +173,7 @@ def _daily(fine: list[Sample], records: list[Sample], tz, until: date | None) -> keep = [r for r in fine if until is None or local_date(r.t, tz) < until] days = daily_summary(((r.t, r.value, *r.bounds(), r.seconds < SLOT) for r in keep), tz) hour = 10 if records else 12 # Ecowitt's daily records are 10am-10am windows: draw the exact days where they sit - points = {local_epoch(datetime.combine(d, time(hour)), tz): v for d, v in days.items()} + points = {at_hour(d, hour, tz): v for d, v in days.items()} for r in records: if local_date(r.t, tz) not in days: points[r.t] = (r.value, *r.bounds()) if r.low is not None and r.high is not None else (r.value, None, None) diff --git a/lib/llm.py b/lib/llm.py index 8efc35e..fd96175 100644 --- a/lib/llm.py +++ b/lib/llm.py @@ -7,13 +7,18 @@ from openai import AsyncOpenAI -from .timeutil import CHARS_PER_TOKEN, WARN_TOKENS +from .lines import POINT_BUDGET from .tools import Tools, Turn log = logging.getLogger(__name__) MAX_STEPS = 10 MAX_HISTORY = 16 # messages kept per chat (a follow-up needs the last few, every call resends them) +TOKENS_PER_POINT = 10 # a dated low/high row is about 10 tokens as the model reads it +CHARS_PER_TOKEN = 4 +# Tool results past twice a full query's data (10,000 tokens) get the chat a heads-up: about $0.0125 of input at Grok 4.3's +# $1.25 per million, and it rides on every later step +WARN_TOKENS = 2 * POINT_BUDGET * TOKENS_PER_POINT LIMIT_NOTICE = ("(System: tool limit reached. Answer now using only the data already fetched, " "and briefly say if anything is missing.)") diff --git a/lib/rain.py b/lib/rain.py index de14418..bb9cc63 100644 --- a/lib/rain.py +++ b/lib/rain.py @@ -2,10 +2,10 @@ spell, and the bars a chart draws. Shared by the analyses and the charts.""" from bisect import bisect_right -from datetime import date, datetime, time, timedelta, tzinfo +from datetime import date, timedelta, tzinfo from .specs import Bars, calendar_bars -from .timeutil import SLOT, day_bounds, local_epoch, to_local +from .timeutil import SLOT, at_hour, day_bounds, to_local SPELL_GAP = 6 # slots: 3 dry hours end a rain spell SPELL_MM = 1.0 # a spell needs this much rain to count as an event @@ -44,8 +44,7 @@ def bar_layout(tz: tzinfo, first: date, last: date) -> tuple[list[int], int, str to a month, daily beyond. The bars start at the same local clock times every day (midnight, 6am ...) from midnight of the first day, so a daylight-saving change doesn't move them (a day of 23 or 25 hours has one bar the shorter or longer).""" width = next(w for limit, w in CHART_BARS if (last - first).days + 1 <= limit) - starts = {local_epoch(datetime.combine(first + timedelta(days=n), time(hour)), tz) - for n in range((last - first).days + 1) for hour in range(0, 24, width // 3600)} + starts = {at_hour(first + timedelta(days=n), hour, tz) for n in range((last - first).days + 1) for hour in range(0, 24, width // 3600)} return sorted(starts), width, {3600: "hour", 6 * 3600: "6 hours", 86400: "day"}[width] diff --git a/lib/timeutil.py b/lib/timeutil.py index 3cfd437..cb138da 100644 --- a/lib/timeutil.py +++ b/lib/timeutil.py @@ -1,10 +1,6 @@ """Local-time helpers shared by the sources: naive local datetimes, epochs and day boundaries.""" -import math -from bisect import bisect_left, bisect_right from datetime import date, datetime, time, timedelta, tzinfo, UTC -from itertools import accumulate -from statistics import fmean def now_local(tz: tzinfo) -> datetime: @@ -66,25 +62,17 @@ def local_epoch(local: datetime, tz: tzinfo) -> int: return int(local.replace(tzinfo=tz).timestamp()) +def at_hour(day: date, hour: int, tz: tzinfo) -> int: + """The epoch of `hour` o'clock local time on `day`.""" + return local_epoch(datetime.combine(day, time(hour)), tz) + + def day_bounds(day: date, tz: tzinfo, last_second: bool = False) -> tuple[int, int]: """Epoch range of one local day: [start, next midnight), or up to its last second if last_second.""" start = datetime.combine(day, time()).replace(tzinfo=tz) return int(start.timestamp()), int((start + timedelta(days=1)).timestamp()) - last_second -def rolling_mean(values: dict[int, float], half_window: int) -> dict[int, float]: - """Each reading replaced by the mean of the readings within half_window seconds either side of it (itself included): - a light smoothing of the drawn line. Timestamps are unchanged, nothing is interpolated, and a gap only means fewer - readings in the window. A reading that is not a finite number is left out.""" - ts = sorted(t for t, v in values.items() if math.isfinite(v)) - prefix = [0.0, *accumulate(values[t] for t in ts)] - out: dict[int, float] = {} - for t in ts: - lo, hi = bisect_left(ts, t - half_window), bisect_right(ts, t + half_window) - out[t] = (prefix[hi] - prefix[lo]) / (hi - lo) - return out - - def parse_period(args: dict, today: date, default_days: int) -> tuple[date, date] | str: """(first, last) day from start_date / end_date, or an error text. The last day is at most yesterday: today is still settling. A missing start is default_days before the end.""" @@ -102,62 +90,5 @@ def period_parameters(default_days: int) -> dict: "end_date": {"type": "string", "description": "Last day, 'YYYY-MM-DD' (up to yesterday). Default: yesterday."}} -POINT_BUDGET = 500 # about as many points as a chart can show legibly -WARN_POINTS = 2 * POINT_BUDGET # a question that sends the model more than twice a 500-point query's data gets a heads-up -TOKENS_PER_POINT = 10 # a dated low/high row is about 10 tokens as the model reads it -CHARS_PER_TOKEN = 4 -WARN_TOKENS = WARN_POINTS * TOKENS_PER_POINT # 10,000 tokens: about $0.0125 of input at Grok 4.3's $1.25 per million, and it rides on every later step SLOT = 1800 # the 30-minute slots both devices are lined up on MIN_DAY_SLOTS = 24 # a day counts for a daily comparison with at least 12 hours of readings -WIDTHS = (300, SLOT, 3600, 7200, 14400, 86400) # bucket sizes a chart line can be drawn at: 5 min ... a day -WIDTH_NAMES = {300: "5-minute", 1800: "30-minute", 3600: "hourly", 7200: "2-hour", 14400: "4-hour", 86400: "daily"} - - -def bucket_width(span_seconds: float, source_seconds: float, budget: int = POINT_BUDGET) -> int: - """The finest bucket (never finer than the readings themselves) that keeps a line of this length within the - point budget; a day is the widest.""" - return next((w for w in WIDTHS if w >= source_seconds and span_seconds / w <= budget), WIDTHS[-1]) - - -def _summarise(points, key) -> dict: - """{bucket: (mean, lowest low, highest high)} from (ts, value, low, high, fine) readings. The mean takes one value - per 30-minute slot, so a stretch held as 5-minute readings (fine) counts no more than the same stretch held as - 30-minute ones: a slot's fine readings win where it has them.""" - slots: dict = {} - extremes: dict = {} - for ts, value, low, high, fine in points: - k = key(ts) - slots.setdefault((k, ts // SLOT), {}).setdefault(fine, []).append(value) - lo, hi = extremes.get(k, (low, high)) - extremes[k] = (min(lo, low), max(hi, high)) - means: dict = {} - for (k, _), got in slots.items(): - values = got.get(True) or got[False] - means.setdefault(k, []).append(fmean(values)) - return {k: (fmean(v), *extremes[k]) for k, v in means.items()} - - -def daily_summary(points, tz: tzinfo) -> dict[date, tuple[float, float, float]]: - """{local day: (mean, lowest low, highest high)}; see _summarise.""" - return _summarise(points, lambda ts: local_date(ts, tz)) - - -def bucket_summary(points, width: int) -> dict[int, tuple[float, float, float]]: - """{bucket start epoch: (mean, lowest low, highest high)} for buckets of `width` seconds; see _summarise.""" - return _summarise(points, lambda ts: ts // width * width) - - -def bucketed(points, tz: tzinfo, width: int) -> tuple[list[int], list[float], list[float], list[float]]: - """(x, mean, low, high) lists for a chart line in buckets of this width: local days (drawn at noon) for a day, - otherwise epoch-aligned buckets (drawn at their middle).""" - if width >= 86400: - days = daily_summary(points, tz) - keys = sorted(days) - xs = [local_epoch(datetime.combine(d, time(12)), tz) for d in keys] - rows = [days[d] for d in keys] - else: - buckets = bucket_summary(points, width) - keys = sorted(buckets) - xs = [k + width // 2 for k in keys] - rows = [buckets[k] for k in keys] - return xs, [r[0] for r in rows], [r[1] for r in rows], [r[2] for r in rows] diff --git a/tests/test_agent.py b/tests/test_agent.py index 5eed8a7..9515085 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -307,7 +307,7 @@ def test_the_prompt_leaves_the_heads_up_to_the_bot_and_points_at_the_count_tool( async def test_the_chat_is_warned_once_before_the_model_is_sent_more_than_twice_a_500_point_querys_data(): - from lib.timeutil import CHARS_PER_TOKEN, WARN_TOKENS + from lib.llm import CHARS_PER_TOKEN, WARN_TOKENS from lib.tools import Turn warned, asked_after = [], [] diff --git a/tests/test_ecowitt.py b/tests/test_ecowitt.py index e28db56..62c4c23 100644 --- a/tests/test_ecowitt.py +++ b/tests/test_ecowitt.py @@ -758,7 +758,7 @@ async def wind_chart(days, **extra): def test_the_rolling_mean_smooths_a_staircase_without_moving_timestamps_or_crossing_gaps(): - from lib.timeutil import rolling_mean + from lib.lines 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} assert rolling_mean({0: 1.0, 300: float('nan'), 600: 3.0}, 450) == {0: 1.0, 600: 3.0} # a bad reading is left out, it does not poison the rest stairs = {i * 300: 10 + 0.1 * (i // 3) + (0.1 if i % 2 else 0.0) for i in range(30)} # 0.1-degree steps with jitter @@ -1045,7 +1045,7 @@ def test_a_days_mean_counts_a_stretch_held_at_5_minutes_no_more_than_the_same_st def test_the_bucket_widens_as_the_period_grows_and_never_goes_below_the_readings(): - from lib.timeutil import bucket_width + from lib.lines import bucket_width day = 86400 assert [bucket_width(d * day, 300) for d in (1, 1.7, 2)] == [300, 300, 1800] # 5-minute readings, until they exceed the budget assert [bucket_width(d * day, 1800) for d in (2, 10, 11, 20, 21, 41, 42, 83, 84, 1460)] == [ @@ -1054,18 +1054,18 @@ def test_the_bucket_widens_as_the_period_grows_and_never_goes_below_the_readings def test_bucket_summary_is_the_mean_and_true_extremes_per_epoch_aligned_bucket(): - from lib.timeutil import bucket_summary, bucketed + from lib.lines import bucket_summary, bucketed from tests.fakes import TZ t0 = int(datetime(2026, 5, 1, tzinfo=TZ).timestamp()) // 7200 * 7200 points = [(t0 + i * 1800, float(i), i - 0.5, i + 0.5, False) for i in range(8)] # two 2-hour buckets of four 30-minute readings out = bucket_summary(points, 7200) assert out == {t0: (1.5, -0.5, 3.5), t0 + 7200: (5.5, 3.5, 7.5)} - xs, mean, low, high = bucketed(points, TZ, 7200) + xs, mean, low, high = bucketed(points, 7200) assert xs == [t0 + 3600, t0 + 10800] and mean == [1.5, 5.5] and low == [-0.5, 3.5] and high == [3.5, 7.5] def test_daily_summary_is_one_mean_and_true_extremes_per_local_day(): - from lib.timeutil import daily_summary + from lib.lines import daily_summary from tests.fakes import TZ t0 = int(datetime(2026, 5, 1, tzinfo=TZ).timestamp()) day1 = [(t0 + i * 1800, 10.0, 8.0, 12.0, False) for i in range(48)] diff --git a/tests/test_timeutil.py b/tests/test_timeutil.py index 4ab0347..9a06fda 100644 --- a/tests/test_timeutil.py +++ b/tests/test_timeutil.py @@ -1,6 +1,6 @@ """Local time across daylight saving: Melbourne skips 2am to 3am on 4 Oct 2026 and has 2am to 3am twice on 5 Apr 2026.""" -from datetime import date, datetime +from datetime import UTC, date, datetime from zoneinfo import ZoneInfo from lib.timeutil import local_epoch, naive_local, parse_local, seconds_until @@ -54,3 +54,9 @@ def test_a_day_and_a_time_of_day_are_written_one_way(): assert day_text(date(2026, 10, 3)) == "Sat 3 Oct 2026" and day_text(datetime(2026, 10, 3, 9), year=False) == "Sat 3 Oct" # never "03" assert [clock_text(datetime(2026, 10, 3, h, m)) for h, m in ((0, 5), (9, 0), (12, 30), (23, 45))] == ["12:05am", "9:00am", "12:30pm", "11:45pm"] assert clock_text(datetime(2026, 10, 3, 10), whole_hours=True) == "10am" and clock_text(datetime(2026, 10, 3, 10, 5), whole_hours=True) == "10:05am" + + +def test_at_hour_is_that_local_clock_time_on_either_side_of_a_daylight_saving_change(): + from lib.timeutil import at_hour + assert at_hour(date(2026, 4, 5), 12, TZ) == int(datetime(2026, 4, 5, 2, tzinfo=UTC).timestamp()) # standard time, UTC+10 + assert at_hour(date(2026, 4, 4), 12, TZ) == int(datetime(2026, 4, 4, 1, tzinfo=UTC).timestamp()) # daylight time, UTC+11 From 11b68f35afda66e8d0a790bdaa4eea14ecc10335 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 20:35:20 +0000 Subject: [PATCH 56/65] llm.py: one tool_choice for the model's first step, run()'s options by keyword, the client's timeout and retries stated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Agent.run took require_tool and no_tools, two flags for one choice with a precedence to remember. It takes tool_choice: "required" (fetch fresh data first), "auto" (chat may just reply) or "none" (about the bot: no tools). Intent.tool_choice decides it from the message, once, for the bot, the evals and show_request. Every later step stays "auto", as before. - Everything after the effort is keyword-only. tool_names and Tools.schemas_for, a per-question tool list nothing passed any more, go: every call sends every tool (the same list keeps the provider's cached prompt valid), and an agent test now checks that. The bot test that checked no list was passed now checks the tool choice the bot asks with. - The OpenAI client ran on the SDK's defaults, a 600-second timeout under the bot's own 90-second limit on a question. ecobot.py now states them: a request may run as long as a question may (TURN_SECONDS), a connection gets 5 seconds, and a dropped connection, a 429 or a 5xx is retried twice (the SDK's default, written out). The tool choice each step gets is the same as before for every combination of the two old flags: "none" when the message is about the bot, "required" on the first step when it needs data, else "auto". lib and ecobot.py: +26 โˆ’21 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- ecobot.py | 9 ++++++--- lib/bot.py | 4 ++-- lib/intent.py | 6 ++++++ lib/llm.py | 24 ++++++++++++------------ lib/tools.py | 4 ---- scripts/show_request.py | 2 +- tests/evalkit.py | 4 ++-- tests/test_agent.py | 11 ++++++----- tests/test_bot.py | 14 +++++++------- tests/test_intent.py | 4 ++++ 10 files changed, 46 insertions(+), 36 deletions(-) diff --git a/ecobot.py b/ecobot.py index 084b19a..56e8ade 100644 --- a/ecobot.py +++ b/ecobot.py @@ -18,7 +18,7 @@ # Set before anything imports Matplotlib; an MPLCONFIGDIR in the environment wins. os.environ.setdefault("MPLCONFIGDIR", str(data_dir() / "matplotlib")) -from openai import AsyncOpenAI +from openai import AsyncOpenAI, Timeout from telegram import Update from telegram.ext import Application, Defaults @@ -28,7 +28,7 @@ from lib.alerts import (PARTICULATES_CHECK_SECONDS, AlertState, ForecastMonitor, IrrigationMonitor, Notifier, ParticulatesMonitor, PollenAndAsthmaMonitor, WeatherMonitor) from lib.alerts.menu import LABELS, available_kinds -from lib.bot import PREDRAW_SECONDS, Bot, polling_error +from lib.bot import PREDRAW_SECONDS, TURN_SECONDS, Bot, polling_error from lib.ecowitt import Archive, Ecowitt from lib.forecast import Forecast from lib.irrigation import Tuya @@ -216,7 +216,10 @@ async def main(): eco, air = by_name.get("Ecowitt"), by_name.get("AirGradient") composer = Composer(eco, air) if eco and air else None # charts and comparisons across the two sources tools = Tools([t for s in sources for t in s.tools] + (composer.tools if composer else [])) - client = AsyncOpenAI(api_key=cfg.xai_api_key, base_url=cfg.xai_base_url) + # A model request may run as long as the bot gives a whole question (it then asks again, a reasoning step lower); a + # dropped connection, a 429 or a 5xx is tried again twice, at once, inside that time + client = AsyncOpenAI(api_key=cfg.xai_api_key, base_url=cfg.xai_base_url, timeout=Timeout(TURN_SECONDS, connect=5.0), + max_retries=2) stack.push_async_callback(safely, client.close) state = AlertState(cfg.state_path) if cfg.admin_only: diff --git a/lib/bot.py b/lib/bot.py index bc6acb2..957c1ef 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -819,8 +819,8 @@ async def _ask_model(self, working: list[dict], system: str, read, turn: Turn, d while True: try: return await asyncio.wait_for( - self.agent.run(working, system, effort, first_call=first, require_tool=read.needs_data, - no_tools=read.about_the_bot, turn=turn, on_heavy=on_heavy, conv_id=conv_id, + self.agent.run(working, system, effort, first_call=first, tool_choice=read.tool_choice, turn=turn, + on_heavy=on_heavy, conv_id=conv_id, **({"on_text": draft.update} if draft else {})), budget) except TimeoutError: lower = intent.lower_effort(effort) diff --git a/lib/intent.py b/lib/intent.py index 127423c..4c8027b 100644 --- a/lib/intent.py +++ b/lib/intent.py @@ -813,6 +813,12 @@ class Intent: lookup_arg: list[str] = field(default_factory=list) # the readings named ("reading", "air"; none: all) sides: list[str] = field(default_factory=list) # "indoor", "outdoor" or both, for a reading + @property + def tool_choice(self) -> str: + """What the model's first step may do with the tools (see Agent.run): none about the bot itself, a fetch it must make + when the answer needs data, else its own choice.""" + return "none" if self.about_the_bot else "required" if self.needs_data else "auto" + def plain_lookup(text: str, fast: tuple | None) -> tuple[str, list[str]]: """(kind, readings named) for a question answered in code from one tool result, else ("", []).""" diff --git a/lib/llm.py b/lib/llm.py index fd96175..c3aa573 100644 --- a/lib/llm.py +++ b/lib/llm.py @@ -72,17 +72,19 @@ async def _stream(self, kwargs: dict, on_text) -> tuple[str, list[dict], object] on_text(content) return content, [calls[i] for i in sorted(calls)], usage - async def run(self, messages: list[dict], system_prompt: str, effort: str, - first_call: tuple[str, dict] | list[tuple[str, dict]] | None = None, require_tool: bool = True, no_tools: bool = False, - turn: Turn | None = None, on_text=None, tool_names: list[str] | None = None, on_heavy=None, conv_id: str | None = None) -> str: + async def run(self, messages: list[dict], system_prompt: str, effort: str, *, + first_call: tuple[str, dict] | list[tuple[str, dict]] | None = None, tool_choice: str = "required", + turn: Turn | None = None, on_text=None, on_heavy=None, conv_id: str | None = None) -> str: """Runs the tool loop, appending assistant/tool turns to `messages` in place. The prompt is passed per question (not stored) so concurrent chats can't clash. first_call (tool name, args), or a list of them, is what the bot already worked out (the fast path): the calls run - straight away, together, and the model is only invoked once the data is in. require_tool forces a - fresh fetch on the first model call (weather questions); off for chat, so it can just reply. - on_text(text so far) is called as each answer streams in (private chats show it as a draft). conv_id names the conversation for the provider's prompt cache (see above); on_heavy(tokens) is called once, before the model is - sent a lot of data: when the tool results of a question pass WARN_TOKENS (twice a 500-point query), so the chat can be told + straight away, together, and the model is only invoked once the data is in. tool_choice is what the model's first + step may do with the tools: "required" makes it fetch fresh data first (weather questions), "auto" lets it just + reply (chat), "none" gives it no tools at all (a question about the bot itself). Every later step is "auto". + on_text(text so far) is called as each answer streams in (private chats show it as a draft). conv_id names the + conversation for the provider's prompt cache; on_heavy(tokens) is called once, before the model is sent a lot of + data: when the tool results of a question pass WARN_TOKENS (twice a 500-point query), so the chat can be told first. The work carries on; nothing waits for a reply.""" cache: dict = {} # identical tool calls within one question are only made once @@ -95,7 +97,6 @@ async def call(name: str, args: str) -> str: return ("[You already made this exact call - same result as before. Don't repeat it; " "change the request or answer now.]\n" + await cache[key]) - schemas = self.tools.schemas_for(tool_names) # only the tools the question can use: each definition is sent on every call warned = False async def heavy_check(): @@ -133,10 +134,9 @@ async def run_calls(calls: list[dict]): "extra_body": {"reasoning_effort": effort}} # Grok 4.3: none / low / medium / high if conv_id: # the same id for every call of a chat sends them to the server holding its cached prompt kwargs["extra_headers"] = {"x-grok-conv-id": conv_id} - if schemas: - kwargs["tools"] = schemas - kwargs["tool_choice"] = ("none" if final or no_tools else - "required" if step == 0 and require_tool else "auto") + if self.tools.schemas: # every tool, every time: the same list keeps the provider's cached prompt valid + kwargs["tools"] = self.tools.schemas + kwargs["tool_choice"] = "none" if final or tool_choice == "none" else tool_choice if step == 0 else "auto" if final: log.warning("Tool step limit (%d) reached - asking for a final answer", MAX_STEPS) kwargs["messages"].append({"role": "user", "content": LIMIT_NOTICE}) diff --git a/lib/tools.py b/lib/tools.py index 0dc7dfe..7b7877e 100644 --- a/lib/tools.py +++ b/lib/tools.py @@ -59,10 +59,6 @@ def __init__(self, tools: list[Tool]): self.by_name = {t.name: t for t in tools} self.schemas = [t.schema() for t in tools] - def schemas_for(self, names=None) -> list[dict]: - """The tool definitions to send: those named (that exist), or all when no names are given.""" - return self.schemas if names is None else [self.by_name[n].schema() for n in names if n in self.by_name] - async def call(self, name: str, raw_args: str, turn: Turn | None = None) -> str: """Run a tool for the model; failures come back as text the model can explain.""" tool = self.by_name.get(name) diff --git a/scripts/show_request.py b/scripts/show_request.py index 5205dda..155a542 100644 --- a/scripts/show_request.py +++ b/scripts/show_request.py @@ -90,7 +90,7 @@ async def unavailable(a): client = Recorder([call, "(the model's answer goes here)"]) messages = [{"role": "user", "content": text}] await Agent(client, "grok-4.3", tools).run(messages, system, effort, first_call=([fast[:2], *r.more] if r.more else fast[:2]) if fast else None, - require_tool=r.needs_data) + tool_choice=r.tool_choice) banner("DECISIONS MADE IN CODE") print(f"reasoning effort: {effort} fast path: {fast[2] if fast else 'no (the model chooses the tool)'} " diff --git a/tests/evalkit.py b/tests/evalkit.py index aec3b56..2fe9930 100644 --- a/tests/evalkit.py +++ b/tests/evalkit.py @@ -176,8 +176,8 @@ async def run_live(case: Case, client, model: str, effort: str | None = None) -> fast = r.fast messages = [*case.history, {"role": "user", "content": content(case)}] reply = await Agent(client, model, make_tools(calls)).run( - messages, system, effort or r.effort, first_call=[*([fast[:2], *r.more] if fast else []), *r.extra] or None, require_tool=r.needs_data, - no_tools=r.about_the_bot) + messages, system, effort or r.effort, first_call=[*([fast[:2], *r.more] if fast else []), *r.extra] or None, + tool_choice=r.tool_choice) if fast or r.extra: calls[0:0] = [*([(fast[0], fast[1]), *r.more] if fast else []), *r.extra] # the bot ran them itself, before the model return calls, reply diff --git a/tests/test_agent.py b/tests/test_agent.py index 9515085..7fb7408 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -49,8 +49,9 @@ async def test_weather_questions_must_fetch_first_and_reasoning_is_passed_throug t, seen = tools() client = FakeLLM([[("weather_now", {})], "Rain unlikely."]) agent = llm.Agent(client, "m", t) - reply = await agent.run([{"role": "user", "content": "will it rain?"}], "sys", "medium", require_tool=True) - assert reply == "Rain unlikely." and client.requests[0]["tool_choice"] == "required" + reply = await agent.run([{"role": "user", "content": "will it rain?"}], "sys", "medium", tool_choice="required") + assert reply == "Rain unlikely." and [r["tool_choice"] for r in client.requests] == ["required", "auto"] + assert all(r["tools"] == t.schemas for r in client.requests) # every tool on every call: the cached prompt holds assert client.requests[0]["extra_body"]["reasoning_effort"] == "medium" and len(seen) == 1 @@ -58,7 +59,7 @@ async def test_chat_needs_no_tools_and_identical_calls_are_made_once(): t, seen = tools() client = FakeLLM(["You're welcome!"]) assert await llm.Agent(client, "m", t).run([{"role": "user", "content": "thanks"}], "s", "none", - require_tool=False) == "You're welcome!" + tool_choice="auto") == "You're welcome!" assert client.requests[0]["tool_choice"] == "auto" client = FakeLLM([[("weather_now", {}), ("weather_now", {})], "done"]) await llm.Agent(client, "m", t).run([{"role": "user", "content": "x"}], "s", "none") @@ -189,7 +190,7 @@ async def test_a_question_about_the_bot_itself_gets_no_tools_and_the_hint(): t, seen = tools() client = FakeLLM(["I track temperature, humidity..."]) reply = await llm.Agent(client, "m", t).run([{"role": "user", "content": "list our metrics"}], "sys", "none", - require_tool=False, no_tools=True) + tool_choice="none") assert reply.startswith("I track") and client.requests[0]["tool_choice"] == "none" and seen == [] now = datetime(2026, 9, 29, 14, 5) assert "ABOUT THE BOT ITSELF" in prompt.build(now, {"Ecowitt": "Ecowitt"}, [], True) and "ABOUT THE BOT ITSELF" not in prompt.build(now, {"Ecowitt": "Ecowitt"}) @@ -345,7 +346,7 @@ async def test_every_call_of_a_chat_carries_the_same_conversation_id_for_the_pro await llm.Agent(client, "m", t).run([{"role": "user", "content": "q"}], "sys", "none", conv_id="ecobot-42") assert [r["extra_headers"] for r in client.requests] == [{"x-grok-conv-id": "ecobot-42"}] * 2 plain = FakeLLM(["Hi."]) - await llm.Agent(plain, "m", t).run([{"role": "user", "content": "q"}], "sys", "none", require_tool=False) + await llm.Agent(plain, "m", t).run([{"role": "user", "content": "q"}], "sys", "none", tool_choice="auto") assert "extra_headers" not in plain.requests[0] diff --git a/tests/test_bot.py b/tests/test_bot.py index ba743b4..d3a0151 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -16,7 +16,7 @@ async def run(self, *a, **k): class AnsweringAgent: - async def run(self, messages, system, effort, first_call=None, require_tool=True, no_tools=False, turn=None, **k): + async def run(self, messages, system, effort, *, first_call=None, turn=None, **k): self.first_call = first_call return "It is 12 degrees." @@ -108,7 +108,7 @@ async def send_chat_action(self, *a, **k): class StreamingAgent: - async def run(self, messages, system, effort, first_call=None, require_tool=True, no_tools=False, turn=None, on_text=None, tool_names=None, on_heavy=None, conv_id=None): + async def run(self, messages, system, effort, *, on_text=None, **k): await asyncio.sleep(0.05) # "thinking" on_text("It is") await asyncio.sleep(0.05) @@ -233,7 +233,7 @@ async def test_a_question_that_times_out_is_asked_again_one_reasoning_step_lower efforts = [] class SlowThenQuick: - async def run(self, messages, system, effort, first_call=None, require_tool=True, no_tools=False, turn=None, on_text=None, tool_names=None, on_heavy=None, conv_id=None): + async def run(self, messages, system, effort, **k): efforts.append(effort) messages.append({"role": "assistant", "content": "(unfinished)"}) if effort == "medium": @@ -528,14 +528,14 @@ async def test_rain_by_month_is_worked_out_in_code_with_no_model(alert_state): assert not asked and replies == ["Total rain: 1234.5 mm over 731 days (Fri 4 Oct 2024 โ€“ Sat 3 Oct 2026)."] -async def test_every_model_call_sends_the_full_tool_list_not_a_per_question_one(alert_state): +async def test_the_model_is_asked_with_the_tool_choice_the_message_calls_for(alert_state): seen = [] class Agent: async def run(self, messages, system, effort, **k): - seen.append(k.get("tool_names", "all")) + seen.append(k["tool_choice"]) return "ok" - for text in ("is it too cold to run", "which air quality day was worst when it also rained"): + for text in ("will it rain tomorrow?", "thanks!"): replies = [] async def reply_text(body, **kw): @@ -548,7 +548,7 @@ async def reply_text(body, **kw): async def send_chat_action(*a, **k): pass await Bot(NS(tz=TZ), Agent(), sources, alert_state).respond(update, NS(bot=NS(send_chat_action=send_chat_action, id=99)), text) - assert seen == ["all", "all"] + assert seen == ["required", "auto"] async def test_weather_all_week_and_a_plain_chart_are_sent_with_just_their_title(alert_state, monkeypatch): diff --git a/tests/test_intent.py b/tests/test_intent.py index 08722b8..613a4b0 100644 --- a/tests/test_intent.py +++ b/tests/test_intent.py @@ -582,3 +582,7 @@ def test_the_air_table_is_the_one_in_series_and_panels_imports_on_its_own(): assert list(LABELS) == list(AIR) == [m for _, m in intent.AIR_METRICS] and MARK_LOW == ("co2",) and RATINGS["pm2_5"] == (9.0, 55.4) run = subprocess.run([sys.executable, "-c", "import lib.panels"], capture_output=True, text=True) assert run.returncode == 0, run.stderr # no circular import through airgradient + + +def test_the_models_first_step_must_fetch_for_data_may_just_reply_to_chat_and_gets_no_tools_about_the_bot(): + assert [intent.read(t, NOW).tool_choice for t in ("will it rain tomorrow?", "thanks!", "what can you do")] == ["required", "auto", "none"] From 970bd518dd7f579a0e3667a7a5563cf77f41008a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 20:42:34 +0000 Subject: [PATCH 57/65] prompt.py: the "not available" list built once; the alerts it describes are the sources' own, with their own thresholds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - capabilities() wrote the never-available list twice, once for the short LIMITS and once for the full text, each with its own checks for a missing pollen or forecast source. It is built once, from NEVER_AVAILABLE and WITHOUT (what a source that is off leaves out). - The alerts line restated three thresholds by hand (gusts over 40 km/h, UV of 10, a forecast high moved by more than 2 degrees). ALERT_WORDS now takes them from GUST_ALERT_KMH, UVI_ALERT and TEMP_CHANGE_C, so the prompt follows the alerts if one is changed. - It also listed the weather station's four alerts and the air-quality mask alerts whatever the sources: a bot without an air sensor told the model it sends mask alerts. The line now lists the alert types (alerts.menu.TYPES) whose source is on, and has no line when none is. Over the question corpus and all 16 combinations of sources (48,912 entries: intent.read, the capabilities text and the prompt), the only differences are the alerts line of the 12 combinations without both Ecowitt and AirGradient. With both, the prompt is byte-identical. A test checks each source lists only its own alerts, with the code's threshold. lib: +17 โˆ’14 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/prompt.py | 31 +++++++++++++++++-------------- tests/test_agent.py | 8 ++++++++ 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/lib/prompt.py b/lib/prompt.py index 3ed7b8a..902c115 100644 --- a/lib/prompt.py +++ b/lib/prompt.py @@ -5,11 +5,22 @@ from datetime import datetime from .airgradient.metrics import ALL_METRICS, CHART_UNITS, LABELS +from .alerts.forecast import TEMP_CHANGE_C +from .alerts.menu import TYPES +from .alerts.weather import GUST_ALERT_KMH +from .ecowitt.glance import UVI_ALERT from .intent import period_ranges from .series import WEATHER from .timeutil import day_text WEATHER_NAMES = list(WEATHER) +NEVER_AVAILABLE = ["lightning", "soil or extra sensor channels", "indoor air quality", "other stations or places"] +WITHOUT = {"Pollen": "pollen and thunderstorm asthma", "Forecast": "forecasts (only a short read of the pressure trend)"} # what a source that is off leaves out +# Each alert type as the prompt describes it, with its own threshold; told only when the source that sends it is on (alerts.menu.TYPES) +ALERT_WORDS = {"rain": "rain starting or stopping", "gusts": f"wind gusts over {GUST_ALERT_KMH} km/h", + "uv": f"UV index of {UVI_ALERT} or more", "temps": "indoor/outdoor temperature crossing", + "particulates": "air-quality mask alerts", "pollen_and_asthma": "pollen or thunderstorm asthma risk reaching High", + "forecast": f"a forecast it sent being revised (rain, or max temperature over {TEMP_CHANGE_C:g} degrees)"} PROMPT = """\ You are a friendly weather bot on Telegram. @@ -159,11 +170,9 @@ def date_ranges(now: datetime) -> str: def capabilities(have: Collection[str], brief: bool = False) -> str: """What the bot can and can't do with the sources it has (by name), for questions about the bot itself ("what metrics do you have?") and so the model knows what is possible before it says something can't be done. Built from the code's own lists.""" + missing = ", ".join([*NEVER_AVAILABLE, *(what for name, what in WITHOUT.items() if name not in have)]) if brief: # most questions: only the limits, so it never promises what it can't do - missing = ["lightning", "soil or extra sensor channels", "indoor air quality", "other stations or places"] - missing += (["pollen and thunderstorm asthma"] if "Pollen" not in have else []) + ( - ["forecasts (only a short read of the pressure trend)"] if "Forecast" not in have else []) - return ("\nLIMITS: not available: " + ", ".join(missing) + ". Custom alerts (\"tell me when winds reach 100\") can't be added: say so. " + return (f"\nLIMITS: not available: {missing}. Custom alerts (\"tell me when winds reach 100\") can't be added: say so. " "A chart series or style the tools don't offer can't be drawn: say so.\n") lines = ["\nWHAT THIS BOT CAN AND CAN'T DO (answer questions about the bot from this, with no tool call)"] if "Ecowitt" in have: @@ -191,16 +200,10 @@ def capabilities(have: Collection[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, 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 "Pollen" in have else "") + ( - ", a forecast it sent being revised (rain, or max temperature over 2 degrees)" if "Forecast" 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.") - missing = ["lightning", "soil or extra sensor channels", "indoor air quality", "other stations or places"] - if "Pollen" not in have: - missing.append("pollen and thunderstorm asthma") - if "Forecast" not in have: - missing.append("forecasts (only a short read of the pressure trend)") - lines.append("- Not available: " + ", ".join(missing) + ".") + if alerts := ", ".join(words for kind, words in ALERT_WORDS.items() if TYPES[kind][1] in have): + lines.append(f"- Alerts, sent to chats automatically: {alerts}. 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.") + lines.append(f"- Not available: {missing}.") return "\n".join(lines) + "\n" diff --git a/tests/test_agent.py b/tests/test_agent.py index 7fb7408..b8b98bb 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -294,6 +294,14 @@ def test_the_capabilities_mention_forecast_revision_alerts_only_with_the_forecas assert "a forecast it sent being revised" in prompt.capabilities(["Ecowitt", "Forecast"]) +def test_the_capabilities_list_only_the_alerts_the_sources_send_with_their_own_thresholds(): + from lib import prompt + from lib.alerts.weather import GUST_ALERT_KMH + station, air = prompt.capabilities(["Ecowitt"]), prompt.capabilities(["AirGradient"]) + assert f"wind gusts over {GUST_ALERT_KMH} km/h" in station and "mask alerts" not in station + assert "mask alerts" in air and "rain starting" not in air and "Alerts, sent" not in prompt.capabilities([]) + + def test_the_capabilities_say_pollen_only_runs_october_to_december(): from lib import prompt assert "only run from October to December" in prompt.capabilities(["Ecowitt", "Pollen"]) From e1cb88050b1218ab1db324815262f2b5e4020ef7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 20:44:19 +0000 Subject: [PATCH 58/65] periods.py: the periods come from their names; one test for a chart that has expired; imports at the top MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PERIODS listed the days the NAMES table already holds, in the same order: it is tuple(NAMES) now, so a period is added in one place. - ImageCache wrote "is this chart still within its time to live" twice, as > in get() and <= in has_copy(): it is _kept() now, and ready() measures with age() like the rest. - `import time` and `from . import intent` sat inside the functions that use them; intent doesn't import periods, so they go to the top. No behaviour changes: the tests pass, and dropping the time-to-live from _kept() fails one. lib: +11 โˆ’8 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/periods.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/lib/periods.py b/lib/periods.py index 1677aac..b9da341 100644 --- a/lib/periods.py +++ b/lib/periods.py @@ -1,15 +1,17 @@ """The period buttons under a chart: 1D, 7D, 1M, 3M and 1Y (1, 7, 30, 90 and 365 days), always all five, the chart's own marked with โ—. Pressing one redraws the chart for that period (Bot.on_period_button); the question behind each chart is remembered for that.""" +import time from collections import OrderedDict from dataclasses import dataclass from telegram import InlineKeyboardMarkup +from . import intent from .buttons import button -PERIODS = (1, 7, 30, 90, 365) -NAMES = {1: "1D", 7: "7D", 30: "1M", 90: "3M", 365: "1Y"} # what the buttons say +NAMES = {1: "1D", 7: "7D", 30: "1M", 90: "3M", 365: "1Y"} # each period, in days, and what its button says +PERIODS = tuple(NAMES) PREFIX = "pd:" REMEMBERED = 300 # charts whose question is kept, newest first to stay @@ -88,28 +90,29 @@ class ImageCache: FRESH = 330 # a little over the time between two rounds of drawing ahead, so a press always finds one def __init__(self, clock=None): - import time self.clock = clock or time.monotonic self.items: OrderedDict = OrderedDict() @staticmethod def key(question: str, days: int, now) -> str: """The question with its period made explicit, so "Temperature chart 30d" and a button's "โ€ฆ30d" are the one chart.""" - from . import intent return intent.with_period(question, days, now).lower() @classmethod def canonical(cls, question: str, now) -> str | None: """The key of a question that is its own key, word for word ("Temperature chart 7d", a button's): its chart is the same whoever asks it. None for any other ("temperature chart today" is kept under the 1d key, but it is another chart).""" - from . import intent key = cls.key(question, intent.period_days(question, now), now) return key if key == " ".join(question.split()).lower() else None + def _kept(self, entry: Drawn | None) -> bool: + """Is there a chart, and is it still within its time to live?""" + return entry is not None and self.age(entry) <= entry.ttl + def get(self, key: str) -> tuple[bytes | str, str] | None: """(the picture: Telegram's file id once it has been sent, else the PNG; its title), or None.""" entry = self.items.get(key) - if entry is None or self.clock() - entry.at > entry.ttl: + if not self._kept(entry): self.items.pop(key, None) return None self.items.move_to_end(key) @@ -118,14 +121,14 @@ def get(self, key: str) -> tuple[bytes | str, str] | None: def has_copy(self, key: str) -> bool: """Does Telegram hold this chart (drawn lately and already sent, so it has a file id)?""" entry = self.items.get(key) - return entry is not None and entry.file_id is not None and self.clock() - entry.at <= entry.ttl + return self._kept(entry) and entry.file_id is not None def ready(self, question: str, now) -> Drawn | None: """The chart made in code for exactly these words in the last FRESH seconds (drawn ahead, or for someone's question), with the text that went with it; None if there is none.""" key = self.canonical(question, now) entry = self.items.get(key) if key else None - if entry is None or entry.caption is None or self.clock() - entry.at > self.FRESH: + if entry is None or entry.caption is None or self.age(entry) > self.FRESH: return None self.items.move_to_end(key) return entry From b5403755c62a4bcce829253c4a9a7d2b6d17a3b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 20:47:06 +0000 Subject: [PATCH 59/65] snapshots.py: each source opens its own connection to the file; the shared one and its reference count go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pollen and forecast sources shared one connection to conditions.sqlite through Snapshots.open(), a class-level registry and a count of users, so the last to close it closed it. The file is in WAL mode, where two connections take turns to write, so each source now opens its own (Snapshots(path)) and closes it when it stops: 18 lines and a lock fewer. Checked outside the tests' SQLite settings, on a real WAL file: two connections writing and reading in turn (as the bot does, both on the event loop) and from two threads at once, 16,000 operations, without a "database is locked". The test that the connection was shared now checks that each source's writes are read by the other's connection, and that one closing leaves the other working. The tests' fixture forces journal_mode=MEMORY on every connection; SQLite can't take a file out of WAL while another connection holds it, so the fixture now leaves such a file in WAL. lib: +4 โˆ’22 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/forecast/source.py | 2 +- lib/pollen/source.py | 2 +- lib/snapshots.py | 22 ++-------------------- tests/conftest.py | 4 +++- tests/test_snapshots.py | 16 +++++++++------- 5 files changed, 16 insertions(+), 30 deletions(-) diff --git a/lib/forecast/source.py b/lib/forecast/source.py index 30455c5..d373996 100644 --- a/lib/forecast/source.py +++ b/lib/forecast/source.py @@ -98,7 +98,7 @@ def __init__(self, cfg: Config, location: tuple[float, float] | None = None, tra self.source = "" self.fetched_at = 0.0 self.requests = 0 - self.store = Snapshots.open(cfg.conditions_cache_path) + self.store = Snapshots(cfg.conditions_cache_path) self._load() self.warmer = Warmer(self.warm, FORECAST_REFRESH_SECONDS) self.tools = [Tool("weather_forecast", DESCRIPTION, PARAMETERS, self.handle)] diff --git a/lib/pollen/source.py b/lib/pollen/source.py index 9117630..c240164 100644 --- a/lib/pollen/source.py +++ b/lib/pollen/source.py @@ -53,7 +53,7 @@ def __init__(self, cfg: Config, transport=None): self.fetched_at = 0.0 # when (epoch) self._validators: dict = {} # ETag / Last-Modified for the conditional request self.requests = 0 - self.store = Snapshots.open(cfg.conditions_cache_path) + self.store = Snapshots(cfg.conditions_cache_path) self._load() self.warmer = Warmer(self.warm, POLLEN_REFRESH_SECONDS) self.tools = [Tool("pollen_asthma", DESCRIPTION, PARAMETERS, self.handle)] diff --git a/lib/snapshots.py b/lib/snapshots.py index 8b9219d..cc1395d 100644 --- a/lib/snapshots.py +++ b/lib/snapshots.py @@ -13,23 +13,10 @@ class Snapshots: - """Use Snapshots.open(path): the pollen and forecast sources share one connection to the file (closed by the last user).""" - - _shared: dict = {} - _guard = threading.Lock() - - @classmethod - def open(cls, path) -> "Snapshots": - with cls._guard: - store = cls._shared.get(str(path)) - if store is None: - store = cls._shared[str(path)] = cls(path) - else: - store._users += 1 - return store + """One source's connection to the file: the pollen and forecast sources each open their own (the file is in WAL mode, + so two connections take turns to write) and close it when they stop.""" def __init__(self, path): - self.path, self._users = str(path), 1 self._lock = threading.Lock() self.db = sqlite3.connect(path, check_same_thread=False) self.db.execute("PRAGMA journal_mode=WAL") @@ -42,11 +29,6 @@ def __init__(self, path): self.db.commit() def close(self): - with self._guard: - self._users -= 1 - if self._users > 0: - return - self._shared.pop(self.path, None) with self._lock: self.db.close() diff --git a/tests/conftest.py b/tests/conftest.py index a190e36..75791df 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -47,6 +47,7 @@ async def _inline(func, /, *args, **kwargs): def no_request_spacing(monkeypatch): """Also closes every SQLite connection a test opened, so none is left for the garbage collector (a ResourceWarning).""" import asyncio + import contextlib import sqlite3 monkeypatch.setattr(asyncio, "to_thread", _inline) real_connect = sqlite3.connect @@ -55,7 +56,8 @@ def no_request_spacing(monkeypatch): def fast_connect(*args, **kwargs): # tests don't need the caches to survive a power cut db = real_connect(*args, **kwargs) db.execute("PRAGMA synchronous=OFF") - db.execute("PRAGMA journal_mode=MEMORY") + with contextlib.suppress(sqlite3.OperationalError): # a file another connection holds in WAL mode stays in it + db.execute("PRAGMA journal_mode=MEMORY") opened.append(db) return db monkeypatch.setattr(sqlite3, "connect", fast_connect) diff --git a/tests/test_snapshots.py b/tests/test_snapshots.py index 101f76a..013756c 100644 --- a/tests/test_snapshots.py +++ b/tests/test_snapshots.py @@ -15,7 +15,7 @@ def test_a_repeat_only_moves_last_seen_and_a_change_adds_a_row(tmp_path): - store = Snapshots.open(tmp_path / "s.sqlite") + store = Snapshots(tmp_path / "s.sqlite") assert store.save("pollen", {"level": "Low"}, now=100) is True assert store.save("pollen", {"level": "Low"}, now=200) is False assert store.latest("pollen") == (200, {"level": "Low"}) and store.count("pollen") == 1 @@ -28,13 +28,15 @@ def test_a_repeat_only_moves_last_seen_and_a_change_adds_a_row(tmp_path): store.close() -def test_the_connection_is_shared_and_closed_by_the_last_user(tmp_path): - a, b = Snapshots.open(tmp_path / "s.sqlite"), Snapshots.open(tmp_path / "s.sqlite") - assert a is b +def test_each_source_has_its_own_connection_to_the_one_file(tmp_path): + a, b = Snapshots(tmp_path / "s.sqlite"), Snapshots(tmp_path / "s.sqlite") + a.save("pollen", {"level": "Low"}, now=100) + b.save("forecast", {"days": []}, now=100) + assert b.latest("pollen") == (100, {"level": "Low"}) and a.latest("forecast") == (100, {"days": []}) # one file a.close() - b.put("k", "v") # still open for the other user + b.put("k", "v") # the other's is still open + assert b.get("k") == "v" b.close() - assert Snapshots.open(tmp_path / "s.sqlite") is not a async def test_a_restart_in_the_evening_starts_from_the_saved_pollen_page_without_fetching(tmp_path): @@ -113,7 +115,7 @@ async def test_the_report_always_uses_the_cache_for_pollen_and_forecast(tmp_path async def test_a_forecast_saved_by_the_older_bom_version_still_loads(tmp_path): from lib.snapshots import Snapshots - store = Snapshots.open(tmp_path / "conditions.sqlite") + store = Snapshots(tmp_path / "conditions.sqlite") today = datetime.now(TZ).date() store.save("forecast", {"source": "Bureau of Meteorology, Testville", "tag": "BOM", "days": [{"date": str(today), "min_c": 11, "max_c": 17, "summary": "Rain.", "rain_chance_pct": 90}]}) From a6701943107516422ffc7e80871735b3e97c3a93 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 20:48:27 +0000 Subject: [PATCH 60/65] bot.py's _short is logs.fit on one line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _short cut a question or a button's data for the log with its own rule (limit characters, then "..."), next to logs.fit, which every other log line is cut with (at most room characters, the last an ellipsis). It is now fit() on the text with its whitespace joined. Two log lines change by a hair: a stuck question's label ("Still working after 30s on ...") and an ignored button's data now end in "โ€ฆ" within their 40 or 60 characters, where they went 3 characters over with "...". The unused default limit goes. lib: +4 โˆ’4 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/bot.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/bot.py b/lib/bot.py index 957c1ef..065a55f 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -34,7 +34,7 @@ from .sources import Source from .timeutil import now_local from .llm import Agent, shorten_old, strip_tool_turns, trim_history -from .logs import QUOTES +from .logs import QUOTES, fit log = logging.getLogger(__name__) @@ -86,9 +86,9 @@ async def watchdog(label: str, seconds: float): log.warning("Still working after %ds on %s; tasks waiting:\n %s", seconds, label, "\n ".join(stacks) or "(none)") -def _short(text: str, limit: int = 200) -> str: - text = " ".join(text.split()) - return text if len(text) <= limit else text[:limit] + "..." +def _short(text: str, limit: int) -> str: + """The text on one line, cut to `limit` characters (logs.fit): a question or button's data as the log names it.""" + return fit(" ".join(text.split()), limit) def chart_times(times: dict, made: float | None, pngs: list[bytes], sending: float) -> str: From 2c6453303e8ef95e2d2615cab9bb7ff186840d65 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 20:54:44 +0000 Subject: [PATCH 61/65] Dead code (1 of 3): parameters nothing passes, and defaults no caller uses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - report: _weather_caption's args and _air_caption's turn were never read. - ecowitt.extremes.daily_readings: `before` and `tz` (a filter for days before a given one) were never passed. - HistoryQuery._series_entry: `label` was never passed; the group's name is always the label. - Forecast: _with_chance's `source` and _ensemble's `first` (the request asks for so many days from today) were never read. - charts._style_axis: its one call took every default (size, pad, grid, whether the grid sits below), so they are written in place and the two branches on them go. _pad_limits' defaults went unused by its one call, which passes all three; a test that leaned on them passes its own. The 7 sample charts and 16 dump_specs charts are byte-identical, and so are report.py's 6,991 outputs. (A correction to a670194's message: the watchdog's "Still working" line comes after 45 seconds, not the 30 it gives as an example.) lib: +19 โˆ’23 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/charts.py | 12 +++++------- lib/ecowitt/extremes.py | 11 +++++------ lib/ecowitt/query.py | 4 ++-- lib/forecast/source.py | 8 ++++---- lib/report.py | 7 +++---- tests/test_specs.py | 7 ++++--- 6 files changed, 23 insertions(+), 26 deletions(-) diff --git a/lib/charts.py b/lib/charts.py index 86ea0dd..a8dc5ce 100644 --- a/lib/charts.py +++ b/lib/charts.py @@ -189,16 +189,14 @@ def _end_of(bx, width: float, default: float) -> float: return float(bx.max() + width / 86400) if len(bx) else default -def _style_axis(ax, size: float = 7.5, pad: float = 5, grid: float = 0.8, below: bool = True): +def _style_axis(ax): """The plain look shared by every chart: no frame but the baseline, faint horizontal grid, small muted ticks.""" for side in ("top", "right", "left"): ax.spines[side].set_visible(False) ax.spines["bottom"].set_color(AXIS) - ax.tick_params(axis="both", length=0, labelsize=size, labelcolor=MUTED, pad=pad) - if grid: - ax.grid(axis="y", color=GRID, linewidth=grid) - if below: - ax.set_axisbelow(True) + ax.tick_params(axis="both", length=0, labelsize=7.5, labelcolor=MUTED, pad=5) + ax.grid(axis="y", color=GRID, linewidth=0.8) + ax.set_axisbelow(True) def _shade_zones(ax, zones, ybottom: float, ytop: float, alpha: float, zorder: float = 0): @@ -209,7 +207,7 @@ def _shade_zones(ax, zones, ybottom: float, ytop: float, alpha: float, zorder: f ax.axhspan(max(lo, ybottom), min(hi, ytop), color=colour, alpha=alpha, linewidth=0, zorder=zorder) -def _pad_limits(ax, lo: float, hi: float, top: float = 0.2, bottom: float = 0.16, floor: float | None = None): +def _pad_limits(ax, lo: float, hi: float, top: float, bottom: float, floor: float | None): """Room above and below the data; `floor`: the axis does not go below it when the data does not (no negative wind).""" span = max(hi - lo, 1.0) y0 = lo - span * bottom diff --git a/lib/ecowitt/extremes.py b/lib/ecowitt/extremes.py index e69618e..e4f2f63 100644 --- a/lib/ecowitt/extremes.py +++ b/lib/ecowitt/extremes.py @@ -1,9 +1,9 @@ """Turning Ecowitt readings into per-series points and finding their lowest and highest, with when each happened.""" -from datetime import date, datetime, tzinfo, UTC +from datetime import datetime, tzinfo, UTC from typing import NamedTuple -from ..timeutil import clock_text, day_text, local_date +from ..timeutil import clock_text, day_text from .api import CYCLES from .fetch import series_in @@ -68,10 +68,9 @@ def high_of(rec: dict) -> float: return rec["high"][0] if "high" in rec else rec["value"][0] -def daily_readings(pts: dict, before: date | None = None, tz: tzinfo | None = None) -> list[tuple]: - """(ts, value, low, high, fine) for the 5- and 30-minute readings (those before `before`), for daily_summary.""" - return [(t, r["value"][0], low_of(r), high_of(r), r["cycle"] == "5min") for t, r in pts.items() - if r["cycle"] != "1day" and "value" in r and (before is None or local_date(t, tz) < before)] +def daily_readings(pts: dict) -> list[tuple]: + """(ts, value, low, high, fine) for the 5- and 30-minute readings, for daily_summary.""" + return [(t, r["value"][0], low_of(r), high_of(r), r["cycle"] == "5min") for t, r in pts.items() if r["cycle"] != "1day" and "value" in r] _clock = lambda dt: clock_text(dt, whole_hours=True) # "7:05am", "3:30pm", "10am" diff --git a/lib/ecowitt/query.py b/lib/ecowitt/query.py index e6e0403..44de919 100644 --- a/lib/ecowitt/query.py +++ b/lib/ecowitt/query.py @@ -508,7 +508,7 @@ def _series_readings(self, k: str) -> list[Sample]: out.append(Sample(t, v, low, high, CYCLES[r["cycle"]].seconds)) return out - def _series_entry(self, k: str, label: str | None = None) -> tuple[Line, str] | None: + def _series_entry(self, k: str) -> tuple[Line, str] | None: """One series as a chart line and how it was drawn; None if there is too little to draw.""" line = build_line(self._series_readings(k), self.tz, self.span.total_seconds(), smooth=k in SMOOTH_SERIES, force_daily=self.turn.average_asked, until=now_local(self.tz).date()) @@ -516,7 +516,7 @@ def _series_entry(self, k: str, label: str | None = None) -> tuple[Line, str] | return None group = k.split(".", 1)[0] line = line.unbanded(self.span.days + 1, keep=self._band_key(k) is not None) # a short period: a clean line, except wind's gusts - return line.spec(label or group.replace("_", " ").capitalize(), indoor=group == "indoor"), line.name + return line.spec(group.replace("_", " ").capitalize(), indoor=group == "indoor"), line.name async def _rain_bars(self) -> Bars: """Rain for the chart, summed into bars. From the cached 30-minute readings and, after the last of them, the query's own diff --git a/lib/forecast/source.py b/lib/forecast/source.py index d373996..f545fcb 100644 --- a/lib/forecast/source.py +++ b/lib/forecast/source.py @@ -149,7 +149,7 @@ async def _open_meteo(self) -> tuple[str, list[dict]]: "summary": WMO.get(dd["weather_code"][i], ""), "rain_chance_pct": dd["precipitation_probability_max"][i]} for i in range(len(dd["time"]))] - async def _ensemble(self, first: date, days: int) -> dict[date, int]: + async def _ensemble(self, days: int) -> dict[date, int]: """{local day: % of ensemble members with at least RAIN_MM that day}, to the nearest 5; a day with no usable members is left out. Every `hourly` series named precipitation* is one member (the response names them by member and model).""" lat, lon = self.location @@ -171,10 +171,10 @@ async def _ensemble(self, first: date, days: int) -> dict[date, int]: return {date.fromisoformat(day): 5 * round(100 * sum(t >= RAIN_MM for t in totals) / len(totals) / 5) for day, totals in sums.items()} - async def _with_chance(self, source: str, days: list[dict]) -> list[dict]: + async def _with_chance(self, days: list[dict]) -> list[dict]: """The days with the ensemble's chance of at least 1 mm added; Open-Meteo's own figure stays where the ensemble has none.""" try: - chance = await self._ensemble(days[0]["date"], len(days)) if days else {} + chance = await self._ensemble(len(days)) if days else {} except Exception as e: # the forecast still works without it log.warning("Ensemble rain chance unavailable (%s: %s); using Open-Meteo's own", type(e).__name__, e) return days @@ -187,7 +187,7 @@ async def warm(self, fresh: bool = True) -> str: return "Forecast cached" before = self.requests self.source, self.days = await self._open_meteo() # a failure raises: the warmer logs it - self.days = await self._with_chance(self.source, self.days) + self.days = await self._with_chance(self.days) self.fetched_at = time.time() self._save() return f"Forecast {self.requests - before} req" diff --git a/lib/report.py b/lib/report.py index 502fe9f..9d0ad64 100644 --- a/lib/report.py +++ b/lib/report.py @@ -275,7 +275,7 @@ 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) -> list[str]: +def _weather_caption(result: 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] = [] @@ -298,7 +298,7 @@ def _weather_caption(result: dict, args: dict, turn, tz) -> list[str]: return lines -def _air_caption(result: dict, args: dict, turn) -> list[str]: +def _air_caption(result: dict, args: dict) -> list[str]: lines = [] for key, label in ((name, m.label) for name, m in AIR.items()): entry = result.get(key) @@ -317,8 +317,7 @@ def chart_caption(tool: str, args: dict, result: str | None, turn, tz) -> str | 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)) + lines = _air_caption(data, args) if tool == "air_quality" else _weather_caption(data, 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_specs.py b/tests/test_specs.py index 3c98aa2..495c6d7 100644 --- a/tests/test_specs.py +++ b/tests/test_specs.py @@ -73,11 +73,12 @@ def test_a_reading_that_cannot_be_negative_is_not_drawn_below_zero_and_temperatu from matplotlib.figure import Figure from lib import charts ax = Figure().add_axes([0, 0, 1, 1]) - charts._pad_limits(ax, 0.0, 8.0, floor=0) + pad = lambda lo, hi, floor: charts._pad_limits(ax, lo, hi, top=0.12, bottom=0.12, floor=floor) + pad(0.0, 8.0, 0) assert ax.get_ylim()[0] == 0 - charts._pad_limits(ax, 0.0, 8.0) + pad(0.0, 8.0, None) assert ax.get_ylim()[0] < 0 - charts._pad_limits(ax, -4.0, 8.0, floor=0) # real negatives keep their room + pad(-4.0, 8.0, 0) # real negatives keep their room assert ax.get_ylim()[0] < -4 From ed156ac45dcb352fbf7952b0aebf0c86a211f49b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 20:57:49 +0000 Subject: [PATCH 62/65] Dead code (2 of 3): methods and fields that only the tests used MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - intent.topics() found "forecast", "pollen" and "compose", which no prompt block reads (they chose the tools of a per-question tool list, gone since every call sends every tool). Intent.chart_all was set and never read; the tests ask wants_chart_all(), which chooses the readings, instead. - Snapshots.count, HistoryCache.newest and HistoryCache.missing were read by tests alone (lib asks uncovered() for every window at once). The tests count rows themselves and ask uncovered() through a small helper. - AlertState.alerts_on was subscribed() under another name; Charted's __getitem__, __contains__ and __len__ were only the tests' way in. The tests use subscribed(), get() and the store. - Pollen.summary() re-worded lines() for the one log line at start. That line now gives the lines themselves: "Pollen: Grass pollen: ๐ŸŸข Low; Thunderstorm asthma risk: ๐ŸŸข Low". Over the question corpus and all 16 combinations of sources, every decision and every prompt is the same (the corpus, drawn from the tests' words, lost the five questions built on "compose"). lib: +2 โˆ’39 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/alerts/notify.py | 3 --- lib/ecowitt/store.py | 7 ------- lib/intent.py | 6 +----- lib/periods.py | 9 --------- lib/pollen/source.py | 12 +----------- lib/snapshots.py | 4 ---- tests/evalkit.py | 2 +- tests/test_alert_menu.py | 10 +++++----- tests/test_alerts.py | 2 +- tests/test_intent.py | 15 +++++++-------- tests/test_periods.py | 6 +++--- tests/test_snapshots.py | 17 +++++++++++------ tests/test_store.py | 20 ++++++++++++-------- tests/test_templates.py | 3 ++- 14 files changed, 44 insertions(+), 72 deletions(-) diff --git a/lib/alerts/notify.py b/lib/alerts/notify.py index dcb06eb..1a3c87e 100644 --- a/lib/alerts/notify.py +++ b/lib/alerts/notify.py @@ -198,9 +198,6 @@ def subscribed(self, chat_id: int, kind: str | None = None) -> bool: muted = self.muted(chat_id) return kind not in muted if kind else bool(set(LABELS) - muted) - def alerts_on(self, chat_id: int) -> bool: - return self.subscribed(chat_id) - def alert_chats(self, kind: str | None = None) -> list[int]: """The chats that get alerts of this type (any type, if none is given).""" return [c for c in self.chats if self.subscribed(c, kind)] diff --git a/lib/ecowitt/store.py b/lib/ecowitt/store.py index 242bf77..8832665 100644 --- a/lib/ecowitt/store.py +++ b/lib/ecowitt/store.py @@ -122,10 +122,6 @@ def count(self, mac: str, cycle: str, grp: str, start: int, end: int) -> int: 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(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).""" return merge(self._query("SELECT start, end FROM coverage WHERE mac=? AND cycle=? AND grp=?", mac, cycle, grp)) @@ -143,9 +139,6 @@ def uncovered(self, mac: str, groups: list[str], windows: list[tuple[str, int, i held = {key: merge(spans) for key, spans in held.items()} return [{g: subtract((start, end), held[cycle, g]) for g in groups} for cycle, start, end in windows] - def missing(self, mac: str, cycle: str, grp: str, start: int, end: int) -> list[Interval]: - return subtract((start, end), self.coverage(mac, cycle, grp)) - 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 s.field IN ({','.join('?' * len(fields))})" if fields is not None else "" diff --git a/lib/intent.py b/lib/intent.py index 4c8027b..7a74b63 100644 --- a/lib/intent.py +++ b/lib/intent.py @@ -752,11 +752,8 @@ def topics(text: str, before: list[str] = ()) -> set[str]: ("air", mentions_air(said)), ("days", bool(DAYS_WORDS.search(said) or ANALYSIS.search(said) or SPECIFIC_MOMENT.search(said))), ("link", bool(LINK.search(said)) or bool(COMPARE.search(said)) and bool(OUTLOOK_WORDS.search(said))), ("outlook", bool(FORECAST.search(said)) or bool(OUTLOOK_WORDS.search(said))), - ("forecast", bool(FORECAST.search(said)) or bool(OUTLOOK_WORDS.search(said))), ("wind", bool(WIND_WORDS.search(said))), ("describe", bool(DESCRIBE.search(said))), - ("pollen", mentions_pollen(said)), ("bot", bool(BOT_WORDS.search(said)) or about_the_bot(said) or bool(COMMAND.search(said))), - ("compose", mentions_air(said) and bool(COMPARE.search(said) or GRAPH.search(said) or LINK.search(said) or ANALYSIS.search(said))), ) if hit} return found @@ -805,7 +802,6 @@ class Intent: 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 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 per_day: bool = False # figures day by day were asked for extra: list[tuple[str, dict]] = field(default_factory=list) # fetched with the model's first step (the forecast, for "will it rain?") @@ -867,5 +863,5 @@ def read(text: str, now: datetime, sources: Collection[str] = ("Ecowitt", "AirGr hints=period_hints(text, now), fast=fast, chart_asked=bool(GRAPH.search(text)), chart_field=chart_field(text), chart_fields=chart_fields(text), average_asked=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), readings=list(_named(text)), per_day=bool(PER_DAY.search(text)), + readings=list(_named(text)), per_day=bool(PER_DAY.search(text)), extra=model_prefetch(text, now, sources) if not fast else [], lookup=lookup, lookup_arg=named, sides=_sides(text)) diff --git a/lib/periods.py b/lib/periods.py index b9da341..d5643f4 100644 --- a/lib/periods.py +++ b/lib/periods.py @@ -44,15 +44,6 @@ def _key(chat_id: int, message_id: int) -> str: def get(self, chat_id: int, message_id: int) -> str | None: return self.store.get(self._key(chat_id, message_id)) - def __getitem__(self, ids: tuple[int, int]) -> str: - return self.store[self._key(*ids)] - - def __contains__(self, ids: tuple[int, int]) -> bool: - return self._key(*ids) in self.store - - def __len__(self) -> int: - return len(self.store) - def remember(self, chat_id: int, message_id: int, question: str): key = self._key(chat_id, message_id) self.store.pop(key, None) # re-added at the end: the newest diff --git a/lib/pollen/source.py b/lib/pollen/source.py index c240164..721028e 100644 --- a/lib/pollen/source.py +++ b/lib/pollen/source.py @@ -64,7 +64,7 @@ async def start(self): return try: await self.warm(True) - log.info("Pollen: %s", self.summary()) + log.info("Pollen: %s", "; ".join(self.lines()) or "no levels on the page") except Exception as e: # the site may just be briefly unreachable log.warning("Pollen not readable yet: %s", e) @@ -137,16 +137,6 @@ def current(self) -> dict: "asthma": {"level": asthma, "emoji": LEVEL_EMOJI[asthma], "updated": d.get("asthma_updated")} if asthma else None, "fetched_at": self.fetched_at} - def summary(self) -> str: - """The levels in a few words for the log: "grass ๐Ÿ”ด High (Sat 17 Oct), asthma ๐ŸŸข Low".""" - cur, parts = self.current(), [] - if g := cur["grass"]: - when = f" ({day_text(g['date'], year=False)})" if g["date"] and g["date"] != self.now().date() else "" - parts.append(f"grass {g['emoji']} {g['level']}{when}") - if a := cur["asthma"]: - parts.append(f"asthma {a['emoji']} {a['level']}") - return ", ".join(parts) or "no levels on the page" - def lines(self) -> list[str]: """The ready-made report lines; the asthma line is left out when there is no risk forecast (outside the season).""" cur, out = self.current(), [] diff --git a/lib/snapshots.py b/lib/snapshots.py index cc1395d..a4d2ca1 100644 --- a/lib/snapshots.py +++ b/lib/snapshots.py @@ -54,10 +54,6 @@ def latest(self, kind: str) -> tuple[float, dict] | None: row = self.db.execute("SELECT last_seen, payload FROM snapshots WHERE kind=? ORDER BY id DESC LIMIT 1", (kind,)).fetchone() return (row[0], json.loads(row[1])) if row else None - def count(self, kind: str) -> int: - with self._lock: - return self.db.execute("SELECT COUNT(*) FROM snapshots WHERE kind=?", (kind,)).fetchone()[0] - def get(self, key: str) -> str | None: with self._lock: row = self.db.execute("SELECT value FROM meta WHERE key=?", (key,)).fetchone() diff --git a/tests/evalkit.py b/tests/evalkit.py index 2fe9930..405a74c 100644 --- a/tests/evalkit.py +++ b/tests/evalkit.py @@ -172,7 +172,7 @@ async def run_live(case: Case, client, model: str, effort: str | None = None) -> system = prompt.build(NOW, {"Ecowitt": "Ecowitt weather station", "AirGradient": "AirGradient outdoor air-quality sensor", "Pollen": "Melbourne pollen forecast and thunderstorm asthma risk (melbournepollen.com.au)", "Forecast": "Weather forecast for the owner's location (Open-Meteo)"}, - r.hints, r.about_the_bot, r.rain_caption, found := intent.topics(content(case), [m["content"] for m in case.history if m["role"] == "user"][-2:])) + r.hints, r.about_the_bot, r.rain_caption, intent.topics(content(case), [m["content"] for m in case.history if m["role"] == "user"][-2:])) fast = r.fast messages = [*case.history, {"role": "user", "content": content(case)}] reply = await Agent(client, model, make_tools(calls)).run( diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index 40016f5..e216a28 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -88,15 +88,15 @@ def test_state_mutes_one_type_or_all(tmp_path): state.add_chat(2, "b") state.set_kind(1, "rain", False) state.set_kind(1, "bogus", False) - assert state.muted(1) == {"rain"} and state.alert_chats("rain") == [2] and state.alert_chats("uv") == [1, 2] and state.alerts_on(1) + assert state.muted(1) == {"rain"} and state.alert_chats("rain") == [2] and state.alert_chats("uv") == [1, 2] and state.subscribed(1) state.set_kind(1, ALL, False) - assert state.muted(1) == set(LABELS) and not state.alerts_on(1) and state.alert_chats() == [2] + assert state.muted(1) == set(LABELS) and not state.subscribed(1) and state.alert_chats() == [2] state.set_kind(1, ALL, True) assert state.muted(1) == set() and "muted" not in state.chats[1] state.set_kind(2, "gusts", False) assert AlertState(tmp_path / "s.json").muted(2) == {"gusts"} # saved state.set_alerts(1, "a", False) # the /alerts off shortcut - assert not state.alerts_on(1) + assert not state.subscribed(1) class Sent: @@ -246,9 +246,9 @@ async def reply_text(body, **kw): 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] == [("๐Ÿ”” Alerts โ–ธ", "al:open:alerts"), ("๐Ÿ”Š Sound โ–ธ", "al:open:sound")] await bot.on_alerts(update, NS(args=["off"])) - assert not state.alerts_on(1) and "On: nothing" in replies[1][0] + assert not state.subscribed(1) and "On: nothing" in replies[1][0] await bot.on_alerts(update, NS(args=["on"])) - assert state.alerts_on(1) and "๐Ÿ”• Off: nothing" in replies[2][0] + assert state.subscribed(1) and "๐Ÿ”• Off: nothing" in replies[2][0] async def test_every_monitors_alert_names_its_type(tmp_path): diff --git a/tests/test_alerts.py b/tests/test_alerts.py index 029d5d8..ba32c90 100644 --- a/tests/test_alerts.py +++ b/tests/test_alerts.py @@ -278,7 +278,7 @@ def test_alert_state_survives_restarts_and_opt_out(tmp_path): state.monitor["rain"] = {"alerted_state": "raining", "alerted_time": 5, "since": 5} state.save() again = AlertState(path) - assert again.alert_chats() == [1] and again.monitor["rain"]["alerted_state"] == "raining" and not again.alerts_on(2) + assert again.alert_chats() == [1] and again.monitor["rain"]["alerted_state"] == "raining" and not again.subscribed(2) def test_the_footer_is_the_links_label_in_italic_placed_in_utf16_units(): diff --git a/tests/test_intent.py b/tests/test_intent.py index 613a4b0..7a79503 100644 --- a/tests/test_intent.py +++ b/tests/test_intent.py @@ -414,9 +414,9 @@ def test_plain_lookups_are_answered_in_code_and_everything_else_is_left_to_the_m ("hottest day that also rained", [], {"days"}, {"air", "link"}), ("did it rain on Sat 5 Sep", [], {"days"}, {"air"}), ("is there a correlation between pressure and rainfall", [], {"link"}, {"air"}), - ("is the air ok for a run", [], {"air"}, {"days", "link", "compose"}), - ("plot air quality against rainfall", [], {"air", "compose"}, set()), - ("will it rain later", [], {"outlook", "forecast"}, {"air"}), + ("is the air ok for a run", [], {"air"}, {"days", "link"}), + ("plot air quality against rainfall", [], {"air"}, set()), + ("will it rain later", [], {"outlook"}, {"air"}), ("most common wind direction", [], {"wind"}, {"air"}), ("describe what it was like on Monday", [], {"describe"}, set()), ("add an alert when winds reach 100", [], {"bot"}, set()), @@ -521,16 +521,15 @@ def test_anything_beyond_one_reading_by_month_or_year_is_left_to_the_model(text) assert read.lookup == "" and (read.fast is None or read.fast[0] not in ("weather_days", "air_days")) -def test_weather_all_week_is_flagged_as_a_chart_with_no_caption_and_a_named_chart_is_not(): - now = datetime(2026, 10, 3, 12) - assert intent.read("weather all week", now).chart_all and intent.read("ecowitt all week", now).chart_all - assert not intent.read("plot temperature and rain", now).chart_all and not intent.read("temperature chart 7d", now).chart_all +def test_weather_all_week_is_every_reading_and_a_named_chart_is_not(): + assert intent.wants_chart_all("weather all week") and intent.wants_chart_all("ecowitt all week") + assert not intent.wants_chart_all("plot temperature and rain") and not intent.wants_chart_all("temperature chart 7d") @_pytest.mark.parametrize("ask", ["weather chart 90d", "chart all 90d", "chart 90d", "chart"]) def test_a_chart_that_names_no_reading_is_every_reading_not_temperature(ask): read = intent.read(ask, NOW) - assert read.chart_all and read.chart_fields[:3] == ["temperature", "humidity", "solar"] and read.chart_fields[-1] == "wind" + assert intent.wants_chart_all(ask) and read.chart_fields[:3] == ["temperature", "humidity", "solar"] and read.chart_fields[-1] == "wind" def test_a_named_reading_or_an_air_chart_is_not_every_reading_and_indoors_has_only_what_the_indoor_sensor_measures(): diff --git a/tests/test_periods.py b/tests/test_periods.py index f863f2e..348a829 100644 --- a/tests/test_periods.py +++ b/tests/test_periods.py @@ -36,7 +36,7 @@ def test_only_the_newest_questions_are_remembered(): charted = periods.Charted() for i in range(periods.REMEMBERED + 5): charted.remember(1, i, f"q{i}") - assert len(charted) == periods.REMEMBERED and (1, 0) not in charted and (1, periods.REMEMBERED + 4) in charted + assert len(charted.store) == periods.REMEMBERED and charted.get(1, 0) is None and charted.get(1, periods.REMEMBERED + 4) def test_the_questions_behind_charts_survive_a_restart(tmp_path): @@ -102,7 +102,7 @@ async def test_a_chart_is_sent_with_the_other_periods_under_it_and_its_question_ await bot.respond(update, CONTEXT, "Temperature chart 30d") caption, markup = sent[0] assert caption == "Temperature" and labels(markup) == [[("1D", "pd:1"), ("7D", "pd:7"), ("\u25cf 1M", "pd:30"), ("3M", "pd:90"), ("1Y", "pd:365")]] - assert bot.charted[(1, 101)] == "Temperature chart 30d" + assert bot.charted.get(1, 101) == "Temperature chart 30d" async def test_pressing_a_period_redraws_the_chart_in_place_with_that_period_left_out(alert_state, monkeypatch): @@ -120,7 +120,7 @@ async def answer(text=None): assert name == "weather_history" and args["start_date"].startswith("2026-") and answers == ["Drawing 3M..."] caption, markup = edits[0] assert caption == "Temperature" and [t for t, _ in labels(markup)[0]] == ["1D", "7D", "1M", "\u25cf 3M", "1Y"] - assert bot.charted[(1, 50)] == "Temperature chart 90d" + assert bot.charted.get(1, 50) == "Temperature chart 90d" async def test_a_button_on_a_chart_from_before_a_restart_says_so(alert_state, monkeypatch): diff --git a/tests/test_snapshots.py b/tests/test_snapshots.py index 013756c..e82fc39 100644 --- a/tests/test_snapshots.py +++ b/tests/test_snapshots.py @@ -14,17 +14,22 @@ from tests.test_pollen import SEASON_DAY, make as make_pollen, page, transport as pollen_transport +def rows(store, kind: str) -> int: + """How many rows the store holds for this kind.""" + return store.db.execute("SELECT COUNT(*) FROM snapshots WHERE kind=?", (kind,)).fetchone()[0] + + def test_a_repeat_only_moves_last_seen_and_a_change_adds_a_row(tmp_path): store = Snapshots(tmp_path / "s.sqlite") assert store.save("pollen", {"level": "Low"}, now=100) is True assert store.save("pollen", {"level": "Low"}, now=200) is False - assert store.latest("pollen") == (200, {"level": "Low"}) and store.count("pollen") == 1 + assert store.latest("pollen") == (200, {"level": "Low"}) and rows(store, "pollen") == 1 assert store.save("pollen", {"level": "High"}, now=300) is True - assert store.count("pollen") == 2 and store.latest("pollen") == (300, {"level": "High"}) and store.latest("forecast") is None + assert rows(store, "pollen") == 2 and store.latest("pollen") == (300, {"level": "High"}) and store.latest("forecast") is None store.put("k", "v") assert store.get("k") == "v" and store.get("missing") is None store.save("pollen", {"level": "Low"}, now=300 + KEEP_SECONDS + 1) # rows not seen for over a year are dropped - assert store.count("pollen") == 1 + assert rows(store, "pollen") == 1 store.close() @@ -60,11 +65,11 @@ async def test_the_pollen_history_keeps_one_row_per_change(tmp_path): pollen, _ = make_pollen(tmp_path, lambda: page(grass=level["now"]).replace('', f'')) await pollen.start() await pollen.warm(True) - assert pollen.store.count("pollen") == 1 + assert rows(pollen.store, "pollen") == 1 level["now"] = "High" pollen._validators = {} # the site has a new page await pollen.warm(True) - assert pollen.store.count("pollen") == 2 + assert rows(pollen.store, "pollen") == 2 await pollen.close() @@ -79,7 +84,7 @@ async def test_the_forecast_survives_a_restart_and_a_night_start_needs_no_reques again = Forecast(config(tmp_path, forecast=True, forecast_lat=-37.8, forecast_lon=144.9), None, forecast_transport(calls)) again.now = lambda: datetime.combine(datetime.now(TZ).date(), dtime(22, 0)) await again.start() - assert calls == [] and again.lines(3) == lines and again.source == "Open-Meteo" and again.store.count("forecast") == 1 + assert calls == [] and again.lines(3) == lines and again.source == "Open-Meteo" and rows(again.store, "forecast") == 1 await again.close() diff --git a/tests/test_store.py b/tests/test_store.py index bfc2aca..d9000a7 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -3,6 +3,11 @@ from lib.ecowitt import store + +def missing(cache, mac: str, cycle: str, grp: str, start: int, end: int): + """One group's gaps in one window (HistoryCache.uncovered, for a single one).""" + return cache.uncovered(mac, [grp], [(cycle, start, end)])[0][grp] + def test_subtract_and_merge(): assert store.subtract((0, 100), [(10, 20), (30, 40)]) == [(0, 9), (21, 29), (41, 100)] assert store.subtract((0, 10), [(0, 10)]) == [] @@ -23,8 +28,8 @@ def test_only_settled_readings_are_kept(tmp_path): cache.store("M", "5min", ["outdoor"], data(old, old + 300, fresh), old - 300, now) loaded = cache.load("M", "5min", ["outdoor"], 0, now) assert sorted(loaded["outdoor"]["temperature"]["list"]) == [str(old), str(old + 300)] - assert cache.missing("M", "5min", "outdoor", old, old + 300) == [] - assert cache.missing("M", "5min", "outdoor", fresh, now) # the unsettled tail is still to fetch + assert missing(cache, "M", "5min", "outdoor", old, old + 300) == [] + assert missing(cache, "M", "5min", "outdoor", fresh, now) # the unsettled tail is still to fetch def test_what_is_not_held_is_read_for_many_windows_resolutions_and_groups_at_once(tmp_path): @@ -36,7 +41,7 @@ def test_what_is_not_held_is_read_for_many_windows_resolutions_and_groups_at_onc cache.store("M", "5min", ["wind"], both, old, old + 3600) groups, windows = ["outdoor", "wind", "rainfall"], [("30min", old, old + 4 * 86400), ("5min", old, old + 3600)] thirty, five = cache.uncovered("M", groups, windows) - assert thirty == {g: cache.missing("M", "30min", g, old, old + 4 * 86400) for g in groups} # as each group's own read + assert thirty == {g: missing(cache, "M", "30min", g, old, old + 4 * 86400) for g in groups} # as each group's own read assert thirty["outdoor"] == [(old + 86401, old + 3 * 86400 - 1)] and thirty["rainfall"] == [(old, old + 4 * 86400)] assert five["wind"] == [] and five["outdoor"] == [(old, old + 3600)] # each window its own resolution @@ -45,7 +50,7 @@ def test_empty_old_ranges_are_remembered(tmp_path): cache, now = make(tmp_path), int(time.time()) start, end = now - 30 * 86400, now - 29 * 86400 cache.store("M", "30min", ["outdoor"], {}, start, end) - assert cache.missing("M", "30min", "outdoor", start, end) == [] + assert missing(cache, "M", "30min", "outdoor", start, end) == [] def test_changed_units_clear_the_cache(tmp_path): @@ -68,12 +73,12 @@ def test_a_metric_ecowitt_adds_later_makes_the_days_held_be_fetched_again(tmp_pa cache, now = make(tmp_path), int(time.time()) old = now - 10 * 86400 cache.store("M", "30min", ["outdoor"], data(old), old, old + 3600) - assert cache.missing("M", "30min", "outdoor", old, old + 3600) == [] # held + assert missing(cache, "M", "30min", "outdoor", old, old + 3600) == [] # held cache.store("M", "30min", ["outdoor"], data(old + 7200, field="temperature") | {"outdoor": { **data(old + 7200)["outdoor"], "vpd": {"unit": "kPa", "list": {str(old + 7200): "0.8"}}}}, old + 7200, old + 7300) - assert cache.missing("M", "30min", "outdoor", old, old + 3600) # the new metric: the earlier days are asked for again + assert missing(cache, "M", "30min", "outdoor", old, old + 3600) # the new metric: the earlier days are asked for again cache.store("M", "30min", ["outdoor"], data(old), old, old + 3600) - assert cache.missing("M", "30min", "outdoor", old, old + 3600) == [] # and once the vpd-less response repeats, no loop + assert missing(cache, "M", "30min", "outdoor", old, old + 3600) == [] # and once the vpd-less response repeats, no loop def test_vpd_reported_in_inhg_is_stored_and_read_in_kpa_and_old_rows_are_converted(tmp_path): @@ -113,7 +118,6 @@ def test_the_cache_says_where_it_is_and_how_much_it_holds(tmp_path): 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 def test_a_write_that_fails_part_way_leaves_nothing_behind(tmp_path, monkeypatch): diff --git a/tests/test_templates.py b/tests/test_templates.py index a97727d..6b46e6f 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -21,7 +21,8 @@ def read(label): return intent.read(templates.sentence(label), NOW) assert read("\U0001f4cb Status").report weather = read("\U0001f326๏ธ Weather") - assert weather.chart_all and weather.chart_in_code and weather.chart_fields[0] == "temperature" and weather.chart_fields[-1] == "wind" + assert intent.wants_chart_all(templates.sentence("\U0001f326๏ธ Weather")) and weather.chart_in_code + assert weather.chart_fields[0] == "temperature" and weather.chart_fields[-1] == "wind" for label, groups in (("\U0001f327๏ธ Rain", "rainfall"), ("\U0001f4c8 Temperature", "outdoor,indoor"), ("\U0001f4a7 Humidity", "outdoor,indoor"), ("\U0001f4a8 Wind", "wind")): name, args, _ = read(label).fast From c573a281cf394ced4fe62300590123fe91c9938d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 21:00:02 +0000 Subject: [PATCH 63/65] Dead code (3 of 3): code that can never run, and a default that stood in for a station's longitude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tools.call gave each tool 90 seconds (TOOL_TIMEOUT), inside the bot's own 90 seconds for the question, which start first, so it could never fire. It goes; a tool runs under the question's limit. - The pollen alert re-sent when a level rose above the one it had sent that day, but it only sends at High, the top of the site's scale, so nothing can rise above it. It sends once a day while the level stays High, as it did. - set_alerts created a chat it didn't know, but /alerts records the chat first: it takes no title now. - polling_error wrapped two log calls in a try/except that swallowed anything; logging doesn't raise. - plain_lookup tested for a judgement twice in a row and copied a list with a comprehension. - Ecowitt's longitude started as 145.0 ("Melbourne; only used to time the pressure tide", which nothing does), so a station whose device list gave a latitude but no longitude offered the forecast Melbourne's longitude. It starts as None, and the forecast is offered the station's location only when it has both. A test covers it. lib and ecobot.py: +16 โˆ’25 lines. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- ecobot.py | 2 +- lib/alerts/notify.py | 3 +-- lib/alerts/pollen_and_asthma.py | 8 +++----- lib/bot.py | 15 ++++++--------- lib/ecowitt/source.py | 2 +- lib/intent.py | 4 ++-- lib/tools.py | 7 ++----- tests/test_alert_menu.py | 2 +- tests/test_alerts.py | 2 +- tests/test_ecowitt.py | 18 ++++++++++++++++++ tests/test_forecast_alert.py | 2 +- 11 files changed, 37 insertions(+), 28 deletions(-) diff --git a/ecobot.py b/ecobot.py index 56e8ade..98e5001 100644 --- a/ecobot.py +++ b/ecobot.py @@ -83,7 +83,7 @@ async def start(kind: type[Source], *args): if cfg.pollen: await start(Pollen, cfg) if cfg.forecast: - await start(Forecast, cfg, (eco.latitude, eco.longitude) if eco and eco.latitude is not None else None) + await start(Forecast, cfg, (eco.latitude, eco.longitude) if eco and None not in (eco.latitude, eco.longitude) else None) return sources, cfg diff --git a/lib/alerts/notify.py b/lib/alerts/notify.py index 1a3c87e..cc769f2 100644 --- a/lib/alerts/notify.py +++ b/lib/alerts/notify.py @@ -93,9 +93,8 @@ def present(self) -> list[int]: """The chats the bot is in: known, and not gone.""" return [c for c, entry in self.chats.items() if not entry.get("gone")] - def set_alerts(self, chat_id: int, title: str, on: bool): + def set_alerts(self, chat_id: int, on: bool): """/alerts on and /alerts off: every type.""" - self.chats.setdefault(chat_id, {"title": title}) self.set_kind(chat_id, ALL, on) def migrate_chat(self, old: int, new: int): diff --git a/lib/alerts/pollen_and_asthma.py b/lib/alerts/pollen_and_asthma.py index 196f044..eb62d11 100644 --- a/lib/alerts/pollen_and_asthma.py +++ b/lib/alerts/pollen_and_asthma.py @@ -40,13 +40,11 @@ async def check(self): reading = cur[key] if reading is None: # not on the page (the asthma forecast only runs 1 Oct - 31 Dec): keep what we know continue - rank, day = LEVELS.index(reading["level"]), str(today) - if rank < WARN_FROM: + if LEVELS.index(reading["level"]) < WARN_FROM: m.pop(key, None) continue - last = m.get(key) - if last is None or last["rank"] < rank or last["day"] != day: - m[key] = {"rank": rank, "day": day} + if (m.get(key) or {}).get("day") != str(today): # once a day while it stays High (the top of the scale) + m[key] = {"day": str(today)} for_day = reading.get("date") when = f" (forecast for {day_text(for_day, year=False)})" 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_and_asthma") diff --git a/lib/bot.py b/lib/bot.py index 065a55f..b89ae27 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -153,14 +153,11 @@ def connection_problem(err) -> bool: def polling_error(error: TelegramError): """Errors while polling for new messages. The library retries by itself, so a dropped - connection is a one-line warning, not a traceback. Must not raise.""" - try: - if connection_problem(error): - log.warning("Telegram connection problem while polling (%s: %s) - retrying", type(error).__name__, error) - else: - log.error("Telegram polling error (%s: %s)", type(error).__name__, error) - except Exception: - pass + connection is a one-line warning, not a traceback.""" + if connection_problem(error): + log.warning("Telegram connection problem while polling (%s: %s) - retrying", type(error).__name__, error) + else: + log.error("Telegram polling error (%s: %s)", type(error).__name__, error) TYPING_MAX_SECONDS = 600 # a safety net: the indicator never outlives a stuck question @@ -408,7 +405,7 @@ async def on_alerts(self, update: Update, context: ContextTypes.DEFAULT_TYPE): if in_group and not (update.effective_user and await self._is_group_admin(context.bot, chat.id, update.effective_user.id)): await msg.reply_text("Only group admins can change alerts") # the menu below is still shown else: - self.state.set_alerts(chat.id, self._title(update), arg == "on") + self.state.set_alerts(chat.id, arg == "on") log.info("/alerts %s in %s", arg, describe_source(update)) muted, kinds = self.state.muted(chat.id), self._alert_kinds() loud = self.state.loud(chat.id) if chat.type == ChatType.PRIVATE else None # sound settings: private chats only diff --git a/lib/ecowitt/source.py b/lib/ecowitt/source.py index a2a024b..7301293 100644 --- a/lib/ecowitt/source.py +++ b/lib/ecowitt/source.py @@ -120,8 +120,8 @@ def __init__(self, cfg: Config, transport=None): self.mac = "" self.station_name = "" self.created: datetime | None = None - self.longitude = 145.0 # Melbourne; only used to time the pressure tide self.latitude: float | None = None # the station's own location (the forecast uses it unless one is configured) + self.longitude: float | None = None self.tools = [Tool("weather_now", REALTIME_DESCRIPTION, REALTIME_PARAMS, self._realtime), Tool("weather_history", HISTORY_DESCRIPTION, HISTORY_PARAMS, self._history), Tool("weather_days", DAYS_DESCRIPTION, DAYS_PARAMETERS, self._days), diff --git a/lib/intent.py b/lib/intent.py index 7a74b63..de53ce7 100644 --- a/lib/intent.py +++ b/lib/intent.py @@ -831,8 +831,8 @@ def plain_lookup(text: str, fast: tuple | None) -> tuple[str, list[str]]: return "forecast", [] if _judgement(text): return "", [] - if tool == "weather_history" and not args.get("chart") and not _judgement(text): - return "extremes", [n for n in _named(text)] + if tool == "weather_history" and not args.get("chart"): + return "extremes", list(_named(text)) if tool == "weather_now" and (names := reading_now(text)): return "reading", names if tool == "air_quality" and not args and len(text.split()) <= 6: diff --git a/lib/tools.py b/lib/tools.py index 7b7877e..0b0dcee 100644 --- a/lib/tools.py +++ b/lib/tools.py @@ -11,7 +11,6 @@ log = logging.getLogger(__name__) MAX_OUTPUT_CHARS = 60000 -TOOL_TIMEOUT = 90 def json_result(out) -> str: @@ -70,10 +69,8 @@ async def call(self, name: str, raw_args: str, turn: Turn | None = None) -> str: return f"Error: tool arguments were not valid JSON ({e})" level = logging.DEBUG if turn and turn.ahead else logging.INFO log.log(level, "Tool call %s %s", name, raw_args, extra=QUOTES) - try: - out = await asyncio.wait_for(tool.handler(args, turn or Turn()), TOOL_TIMEOUT) - except TimeoutError: - return f"Error: tool timed out after {TOOL_TIMEOUT}s" + try: # no time limit of its own: the question's (bot.TURN_SECONDS) runs from before the call + out = await tool.handler(args, turn or Turn()) except Exception as e: log.exception("Tool %s failed", name) return f"Error calling tool: {e}" diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index e216a28..4160978 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -95,7 +95,7 @@ def test_state_mutes_one_type_or_all(tmp_path): assert state.muted(1) == set() and "muted" not in state.chats[1] state.set_kind(2, "gusts", False) assert AlertState(tmp_path / "s.json").muted(2) == {"gusts"} # saved - state.set_alerts(1, "a", False) # the /alerts off shortcut + state.set_alerts(1, False) # the /alerts off shortcut assert not state.subscribed(1) diff --git a/tests/test_alerts.py b/tests/test_alerts.py index ba32c90..5bd630b 100644 --- a/tests/test_alerts.py +++ b/tests/test_alerts.py @@ -274,7 +274,7 @@ def test_alert_state_survives_restarts_and_opt_out(tmp_path): state = AlertState(path) state.add_chat(1, "Home") state.add_chat(2, "Work") - state.set_alerts(2, "Work", False) + state.set_alerts(2, False) state.monitor["rain"] = {"alerted_state": "raining", "alerted_time": 5, "since": 5} state.save() again = AlertState(path) diff --git a/tests/test_ecowitt.py b/tests/test_ecowitt.py index 62c4c23..7bc76c4 100644 --- a/tests/test_ecowitt.py +++ b/tests/test_ecowitt.py @@ -26,6 +26,24 @@ async def test_finds_the_station(station): assert "Fairleigh" in eco.describe() and "created" in eco.describe() +async def test_a_station_that_gives_no_longitude_has_none_not_melbournes(tmp_path): + import httpx + + class NoLongitude(FakeEcowitt): + def __call__(self, request): + response = super().__call__(request) + if request.url.path.endswith("/list"): + data = response.json() + del data["data"]["list"][0]["longitude"] + return httpx.Response(200, json=data) + return response + transport, _ = ecowitt_transport(NoLongitude()) + eco = Ecowitt(config(tmp_path), transport=transport) + await eco.start() + await eco.close() + assert eco.latitude == -37.8 and eco.longitude is None # so the forecast is offered no half location + + @pytest.mark.parametrize("zone, used", [("Australia/Perth", "Australia/Perth"), (None, "Australia/Melbourne"), ("Mars/Olympus", "Australia/Melbourne")]) async def test_the_station_names_its_own_time_zone(tmp_path, caplog, zone, used): diff --git a/tests/test_forecast_alert.py b/tests/test_forecast_alert.py index dfb0ff5..709435b 100644 --- a/tests/test_forecast_alert.py +++ b/tests/test_forecast_alert.py @@ -87,7 +87,7 @@ async def test_small_changes_past_days_muted_chats_and_chats_never_sent_a_foreca await mon.check() assert len(bot.sent) == 1 and "Yesterday" not in bot.sent[0][1] and bot.sent[0][1].count("โ€ข ") == 1 state.record_forecast(2, [day(0, pct=90)], TODAY) - state.set_alerts(2, "chat 2", False) + state.set_alerts(2, False) forecast.days = [day(0, pct=5), day(1)] await mon.check() assert [chat for chat, _ in bot.sent] == [1, 1] and state.forecast_sent(2)[TODAY]["rain_chance_pct"] == 90 # muted: not told, not reset From 096a46b1c85b7d235c7fc2cf5000e908b40ba529 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 03:06:06 +0000 Subject: [PATCH 64/65] Toggle buttons are blue (Telegram's primary style) while on, not green Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 2 +- lib/buttons.py | 6 +++--- lib/irrigation/menu.py | 4 ++-- tests/test_alert_menu.py | 10 +++++----- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index e41d7aa..efa3177 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ 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, gusts over 40 km/h (with the last day's wind chart), UV index of 10 or more in two readings in a row (with the last day's UV chart), indoor/outdoor temperatures crossing by more than 1ยฐC (held back for the cooldown, then announced if it still stands, with the last day's temperature chart), -and unhealthy air (two readings in a row; with when it is safe again; both with the last day's particulates chart). Every alert carries one ๐Ÿ”” button that opens up to two toggles for that alert type: Alert (on or off in this chat) and, while that is on and in a private chat, Sound (whether it makes a notification sound; alerts are silent unless you turn it on). Every toggle in the bot is a green button while on and a plain one with its name struck through while off. `/alerts` has two buttons, ๐Ÿ”” Alerts and (in a private chat) ๐Ÿ”Š Sound; each opens a list of alert types (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes; Sound lists only those that are on) as toggles, green while on and with the name struck through while off, while one is open its own button stays (press it to close the list) and the other is hidden until you do; `/alerts on|off` still subscribes or unsubscribes every type, and turning off every type one by one is the same as `/alerts off` (a type added later stays off too). A chat that blocks or removes the bot gets no more alerts but keeps its settings, which apply again when it next writes (or the bot is added back). `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`). +and unhealthy air (two readings in a row; with when it is safe again; both with the last day's particulates chart). Every alert carries one ๐Ÿ”” button that opens up to two toggles for that alert type: Alert (on or off in this chat) and, while that is on and in a private chat, Sound (whether it makes a notification sound; alerts are silent unless you turn it on). Every toggle in the bot is a blue button while on and a plain one with its name struck through while off. `/alerts` has two buttons, ๐Ÿ”” Alerts and (in a private chat) ๐Ÿ”Š Sound; each opens a list of alert types (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes; Sound lists only those that are on) as toggles, blue while on and with the name struck through while off, while one is open its own button stays (press it to close the list) and the other is hidden until you do; `/alerts on|off` still subscribes or unsubscribes every type, and turning off every type one by one is the same as `/alerts off` (a type added later stays off too). A chat that blocks or removes the bot gets no more alerts but keeps its settings, which apply again when it next writes (or the bot is added back). `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`). ## Run diff --git a/lib/buttons.py b/lib/buttons.py index 5a23e5d..11c109a 100644 --- a/lib/buttons.py +++ b/lib/buttons.py @@ -1,4 +1,4 @@ -"""Shared look of the bot's inline buttons: a plain button, a menu opener (โ–ธ closed, โ–พ open) and a toggle (green while on, struck +"""Shared look of the bot's inline buttons: a plain button, a menu opener (โ–ธ closed, โ–พ open) and a toggle (blue while on, struck through while off). Every menu builds its buttons with these.""" from collections.abc import Callable @@ -23,6 +23,6 @@ def struck(text: str) -> str: def toggle_button(word: str, on: bool, data: Callable[[bool], str]) -> InlineKeyboardButton: - """A button that flips a setting: its word on a green button while the setting is on, the word struck through on a plain button while it + """A button that flips a setting: its word on a blue button while the setting is on, the word struck through on a plain button while it is off. `data(turn_on)`: the callback data of pressing it, which sets the setting to `turn_on` (the opposite of how it is now).""" - return button(word if on else struck(word), data(not on), KeyboardButtonStyle.SUCCESS if on else None) + return button(word if on else struck(word), data(not on), KeyboardButtonStyle.PRIMARY if on else None) diff --git a/lib/irrigation/menu.py b/lib/irrigation/menu.py index 03a018c..0c616b7 100644 --- a/lib/irrigation/menu.py +++ b/lib/irrigation/menu.py @@ -3,7 +3,7 @@ Collapsed: [๐Ÿ’ง Water] [โธ Pause] / [๐Ÿ”” Alerts] [๐Ÿ”„ Refresh]. While the valve is on, a [Water off โŒ] row follows. While the timer is paused, Pause is [โ–ถ๏ธ Unpause] (one press ends the pause) and Water is hidden. A press on Water, Pause or Alerts opens its options under the rows (โ–ธ after the text marks a menu, โ–พ the open one; pressing it again closes them). Water lists 5, 10, 20 and 30 minutes (a run is always timed). Alerts has -one toggle per alert type, green while this chat gets it, struck through and uncoloured while it does not; pressing it flips it. +one toggle per alert type, blue while this chat gets it, struck through and uncoloured while it does not; pressing it flips it. Callback data (short, self-contained, so an old message's buttons still work after a restart): ir:open: | ir:close | ir:refresh (read the state again, change nothing) @@ -78,7 +78,7 @@ def status_text(status: dict, alerts: bool | None = None, pause_alerts: bool | N def keyboard(battery: bool, pause: bool, open: str | None = None, paused: bool = False, running: bool = False) -> InlineKeyboardMarkup: """The buttons under the status. `battery` and `pause`: this chat gets the irrigation battery / pause alerts; each Alerts button - is a toggle (green when on, struck through and uncoloured when off). `paused`: the timer is paused, so its + is a toggle (blue when on, struck through and uncoloured when off). `paused`: the timer is paused, so its button unpauses and Water is hidden. `running`: the valve is on, so Water off is a top-level row.""" head = lambda name, text: opener(text, open == name, f"ir:open:{name}", "ir:close") delay = button("โ–ถ๏ธ Unpause", "ir:delay:cancel") if paused else head("delay", "โธ Pause") diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index 4160978..9d5b7e0 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -22,9 +22,9 @@ def test_alerts_has_two_buttons_alerts_and_sound_and_a_group_has_only_the_first( assert rows(keyboard(set(), KINDS, "sound")) == rows(keyboard(set(), KINDS)) # a group cannot open Sound -def test_the_bell_toggles_are_green_when_on_and_struck_through_when_off(): +def test_the_bell_toggles_are_blue_when_on_and_struck_through_when_off(): styles = lambda m: {b.text: b.style for row in m.inline_keyboard for b in row} - assert styles(alert_keyboard("uv", set(), set(), "bell")) == {"๐Ÿ”” โ–พ": None, "Alert": "success", strike("Sound"): None} + assert styles(alert_keyboard("uv", set(), set(), "bell")) == {"๐Ÿ”” โ–พ": None, "Alert": "primary", strike("Sound"): None} assert styles(alert_keyboard("uv", {"uv"}, {"uv"}, "bell")) == {"๐Ÿ”” โ–พ": None, strike("Alert"): None} @@ -47,11 +47,11 @@ def test_every_button_of_every_keyboard_parses_and_builds_back_to_the_same_data( assert parse(junk) is None, junk -def test_a_type_that_is_on_is_a_green_button_and_one_that_is_off_is_struck_through_and_plain(): +def test_a_type_that_is_on_is_a_blue_button_and_one_that_is_off_is_struck_through_and_plain(): markup = keyboard({"gusts"}, KINDS, "alerts", loud=set()) styles = {b.text: b.api_kwargs.get("style") or b.style for row in markup.inline_keyboard for b in row} - assert styles["rain"] == "success" and styles[strike("gusts")] is None and not styles["๐Ÿ”” Alerts โ–พ"] - assert markup.inline_keyboard[1][0].to_dict()["style"] == "success" and "style" not in markup.inline_keyboard[1][1].to_dict() # and it is sent to Telegram + assert styles["rain"] == "primary" and styles[strike("gusts")] is None and not styles["๐Ÿ”” Alerts โ–พ"] + assert markup.inline_keyboard[1][0].to_dict()["style"] == "primary" and "style" not in markup.inline_keyboard[1][1].to_dict() # and it is sent to Telegram def test_each_menu_lists_its_types_struck_through_when_off_and_keeps_only_its_own_button(): From c5bb51d533b77c3d41fc191519334c4fd7c76820 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 03:26:05 +0000 Subject: [PATCH 65/65] Irrigation menus: an open menu shows only its own button as a heading, then its list Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/irrigation/menu.py | 16 +++++++++------- tests/test_irrigation.py | 13 +++++++------ 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/lib/irrigation/menu.py b/lib/irrigation/menu.py index 0c616b7..91fbc6b 100644 --- a/lib/irrigation/menu.py +++ b/lib/irrigation/menu.py @@ -79,18 +79,20 @@ def status_text(status: dict, alerts: bool | None = None, pause_alerts: bool | N def keyboard(battery: bool, pause: bool, open: str | None = None, paused: bool = False, running: bool = False) -> InlineKeyboardMarkup: """The buttons under the status. `battery` and `pause`: this chat gets the irrigation battery / pause alerts; each Alerts button is a toggle (blue when on, struck through and uncoloured when off). `paused`: the timer is paused, so its - button unpauses and Water is hidden. `running`: the valve is on, so Water off is a top-level row.""" + button unpauses and Water is hidden. `running`: the valve is on, so Water off is always shown. While a menu is open only its own button (the heading) and its list are shown.""" head = lambda name, text: opener(text, open == name, f"ir:open:{name}", "ir:close") delay = button("โ–ถ๏ธ Unpause", "ir:delay:cancel") if paused else head("delay", "โธ Pause") - rows = [[delay] if paused else [head("water", "๐Ÿ’ง Water"), delay], - [head("alerts", "๐Ÿ”” Alerts"), button("๐Ÿ”„ Refresh", "ir:refresh")]] - if running: # the valve is open: off is one press away, whatever else is open - rows.append([button("Water off โŒ", "ir:sw:off")]) if open == "water" and not paused: - rows.append([button(f"{m} min", f"ir:water:{m}") for m in WATER_MINUTES]) + rows = [[head("water", "๐Ÿ’ง Water")], [button(f"{m} min", f"ir:water:{m}") for m in WATER_MINUTES]] elif open == "delay" and not paused: - rows.append([button(d, f"ir:delay:{d}") for d in DELAYS if d != "cancel"]) + rows = [[delay], [button(d, f"ir:delay:{d}") for d in DELAYS if d != "cancel"]] elif open == "alerts": + rows = [[head("alerts", "๐Ÿ”” Alerts")]] for on, (word, (_, name)) in zip((battery, pause), ALERTS.items()): rows.append([toggle_button(f"{name} alerts", on, lambda turn_on, word=word: f"ir:{word}:{'on' if turn_on else 'off'}")]) + else: + rows = [[delay] if paused else [head("water", "๐Ÿ’ง Water"), delay], + [head("alerts", "๐Ÿ”” Alerts"), button("๐Ÿ”„ Refresh", "ir:refresh")]] + if running: # the valve is open: off is one press away, whatever else is open + rows.insert(1 if rows[0][0].text.endswith(("โ–พ",)) else len(rows), [button("Water off โŒ", "ir:sw:off")]) return InlineKeyboardMarkup(rows) diff --git a/tests/test_irrigation.py b/tests/test_irrigation.py index 37b5f27..bf52029 100644 --- a/tests/test_irrigation.py +++ b/tests/test_irrigation.py @@ -194,6 +194,8 @@ def test_the_buttons_open_one_section_at_a_time_and_every_callback_is_short(): assert rows(menu.keyboard(True, True, running=True)) == collapsed + [[("Water off โŒ", "ir:sw:off")]] # only while the valve is on assert rows(menu.keyboard(True, True, paused=True, running=True))[-1] == [("Water off โŒ", "ir:sw:off")] assert "ir:sw:on" not in [data for row in water for _, data in row] + assert [len(r) for r in water] == [1, 4] and rows(menu.keyboard(True, True, "delay"))[0] == [("โธ Pause โ–พ", "ir:close")] # an open menu hides the other buttons + assert rows(menu.keyboard(True, True, "water", running=True))[1] == [("Water off โŒ", "ir:sw:off")] # but Off stays assert rows(menu.keyboard(True, True, "delay"))[-1] == [("24h", "ir:delay:24h"), ("48h", "ir:delay:48h"), ("72h", "ir:delay:72h")] paused = rows(menu.keyboard(True, True, paused=True)) # paused: the button unpauses, in one press assert paused[0] == [("โ–ถ๏ธ Unpause", "ir:delay:cancel")] and paused[1] == collapsed[1] # no Water button while paused @@ -206,8 +208,7 @@ def test_the_buttons_open_one_section_at_a_time_and_every_callback_is_short(): (False, True, (strike("battery alerts"), "ir:batt:on"), ("pause alerts", "ir:pause:off")), (True, True, ("battery alerts", "ir:batt:off"), ("pause alerts", "ir:pause:off"))): alerts = rows(menu.keyboard(battery, pause, "alerts")) - assert alerts[:2] == collapsed[:1] + [[("๐Ÿ”” Alerts โ–พ", "ir:close"), ("๐Ÿ”„ Refresh", "ir:refresh")]] - assert alerts[2:] == [[battery_button], [pause_button]] # each names the action it takes + assert alerts == [[("๐Ÿ”” Alerts โ–พ", "ir:close")], [battery_button], [pause_button]] # the open menu's own button is its heading; the rest are hidden # each names the action it takes for section in (None, *menu.SECTIONS): for battery in (True, False): for pause in (True, False): @@ -559,14 +560,14 @@ async def test_watering_a_delay_and_the_switch_act_on_the_controller_and_refresh async def test_the_alerts_menu_toggles_each_alert_type_for_this_chat_alone_and_stays_open(tmp_path): bot, state, _ = irrigation_bot(tmp_path) 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:]] == ["battery alerts", "pause alerts"] + assert q.edits[0][0] == "markup" and [r[0][0] for r in rows(q.edits[0][1])[1:]] == ["battery alerts", "pause alerts"] q = await press(bot, "ir:batt:off") assert state.muted(1) == {"irrigation_battery"} and q.toast == "Irrigation battery alerts off in this chat" - assert rows(q.edits[0][2])[2:] == [[(strike("battery alerts"), "ir:batt:on")], [("pause alerts", "ir:pause:off")]] # still open + assert rows(q.edits[0][2])[1:] == [[(strike("battery alerts"), "ir:batt:on")], [("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_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] == [(strike("pause alerts"), "ir:pause:on")] + assert q.edits[0][1].endswith("pause: not paused (alerts: off)\nbattery: 100% ๐Ÿ”‹ (alerts: off)") and rows(q.edits[0][2])[2] == [(strike("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") @@ -574,7 +575,7 @@ async def test_the_alerts_menu_toggles_each_alert_type_for_this_chat_alone_and_s for kind, prefix in (("irrigation_battery", "batt"), ("irrigation_pause", "pause")): # a button shows the state, pressing it flips it for _ in range(2): 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}:")) + text, data = next(b for row in alerts[1:] for b in row if b[1].startswith(f"ir:{prefix}:")) await press(bot, data) assert state.subscribed(1, kind) is (text != text.replace("\u0336", "")), (text, data) other = await press(bot, "ir:water:5")