Skip to content

Module clean-ups: charts, intent, report, llm, snapshots and dead-code removal - #48

Merged
robdevops merged 65 commits into
mainfrom
claude/envirobot-next
Oct 19, 2026
Merged

robdevops merged 65 commits into
mainfrom
claude/envirobot-next

Conversation

@robdevops

@robdevops robdevops commented Oct 19, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Module-by-module refactors: charts (drawn without pyplot, panel kinds split), intent, compose, report, daytable, lines, admins, timeutil, llm, prompt, periods, snapshots, bot
  • ecobot.main made safe: whatever started is closed, however the bot stops
  • Alert types declared once; alert repetition simplified
  • Three dead-code passes: unused parameters/defaults, test-only methods and fields, unreachable code
  • Toggle buttons that are on are blue (Telegram's primary style) instead of green
  • Irrigation menus: an open menu shows only its own button as a heading, then its list (Water off stays visible while the valve is on); the /alerts menus already behaved this way
  • New and extended tests alongside the changes

Test plan

  • python -m pytest -q (895 passed)
  • python scripts/eval_prompts.py (87 of 87)

🤖 Generated with Claude Code

https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf

claude added 30 commits October 18, 2026 13:43
…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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
… default

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…t day

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…t the archive less

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…ep the evening's rain

- "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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
… stops the rest

- 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-<date and time>, 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…eport; a run is watched from the start

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…r decides

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…ever shown; a heading dates stale data

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…ands without the history; spikes go once

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…' edge cases

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…ttings; a migrated group keeps its forecast

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…s dates from the chart

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
claude added 29 commits October 18, 2026 18:40
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…hes, one load

- "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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
_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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…hive on every()

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…olded in, one re-arm rule

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…se test in one place

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
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 ("<name>
  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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…s for the numbers that must agree

- _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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…; every reading's words need data

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…le, each lookup kind its own function

- "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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…om one load, its notes returned

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…s; one builder for a bar per month or year

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
… a new line; a slot's low is clamped too (M7)

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
… per group at a time (M1)

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…en budget to llm.py; at_hour() for "this hour on this day"

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…y keyword, the client's timeout and retries stated

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…es are the sources' own, with their own thresholds

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…hat has expired; imports at the top

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…hared one and its reference count go

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
_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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
… uses

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
… in for a station's longitude

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…, then its list

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
@robdevops
robdevops merged commit 343e7fe into main Oct 19, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants