From 66c4c116dba0a86e107ab1b38bae46c973ee6799 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 16:14:38 +0000 Subject: [PATCH 01/22] Draw charts with fewer matplotlib objects, same pictures (the 7 samples in 74% of the time) lib/charts.py: a stacked panel's tint is a rectangle on the figure, not an axes; rain behind a line is drawn in its own panel (x on the time axis, y as a share of the height, its scale's labels where a right-hand axis puts them) instead of a twin axes and a grid axes; rain, count and rating bars are one collection each, not a patch per bar; the wind rose's 48 wedges are one collection with the radius matplotlib gave the bars (_rose_radius, tested against it); dots are one-point markers, not scatters; upper panels hide their unused time axis and titles are placed, not searched for. Pixel for pixel the same, the rose's anti-aliasing aside (at most 3/255). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/charts.py | 120 ++++++++++++++++++++----------------- tests/test_chart_labels.py | 25 ++++++++ 2 files changed, 91 insertions(+), 54 deletions(-) diff --git a/lib/charts.py b/lib/charts.py index 1098c97..cea80b2 100644 --- a/lib/charts.py +++ b/lib/charts.py @@ -6,7 +6,7 @@ - one panel of lines is drawn large, with the records marked as pills, an end dot and (for wind) the compass beside it, at exactly 1280x720, the size Telegram displays photos at, so nothing is rescaled; - several panels are stacked, the figure growing a little taller with each one past two (four panels: 1280x1160). -Rain (bars) sits behind the lines of the panel it belongs to, on its own right-hand axis. +Rain (bars) sits behind the lines of the panel it belongs to, on a scale of its own read on the right. """ import glob @@ -24,10 +24,12 @@ 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 # 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 .specs import Chart, Compass, Line, Panel # noqa: E402 from .timeutil import to_local # noqa: E402 @@ -150,8 +152,10 @@ def _box(colour: str, pad: float = 0.25, rounding: float = 0.6) -> dict: 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.""" - ax.scatter([x], [y], s=size, color=colour, edgecolors="white", linewidths=edge, zorder=z) + """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.""" + ax.add_line(Line2D([x], [y], marker="o", linestyle="", markersize=math.sqrt(size), color=colour, markeredgecolor="white", + markeredgewidth=edge, zorder=z)) def _to_dt(tz: tzinfo): @@ -182,12 +186,12 @@ def _style_axis(ax, size: float = 7.5, pad: float = 5, grid: float = 0.8, below: ax.set_axisbelow(True) -def _shade_zones(ax, zones, ybottom: float, ytop: float, alpha: float): +def _shade_zones(ax, zones, ybottom: float, ytop: float, alpha: float, zorder: float = 0): """Faint rating zones behind a line: good up to z1, poor up to z2, very poor above.""" z1, z2 = zones for lo, hi, colour in ((ybottom, z1, ZONE_COLOURS[0]), (z1, z2, ZONE_COLOURS[1]), (z2, ytop, ZONE_COLOURS[2])): if hi > ybottom and lo < ytop: - ax.axhspan(max(lo, ybottom), min(hi, ytop), color=colour, alpha=alpha, linewidth=0, zorder=0) + 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): @@ -219,42 +223,38 @@ def _draw_lines(ax, lines: list[Line], tz: tzinfo, look: Look, first: int = 0, r return drawn -def _draw_bars(ax, bx, ys, width: float, axis_top: float, zorder: int, alpha: float = 0.5): +def _columns(ax, x, heights, width: float, bottom=0.0, **style): + """Bars from each x to x + width, as one collection of rectangles: what ax.bar draws, without a patch (and its bookkeeping) + for every bar. The axes' limits are the caller's to set.""" + x = np.asarray(x, dtype=float) + low = np.broadcast_to(np.asarray(bottom, dtype=float), x.shape) + high = low + np.asarray(heights, dtype=float) + corners = np.stack([np.column_stack(xy) for xy in ((x, low), (x + width, low), (x + width, high), (x, high))], axis=1) + ax.add_collection(PolyCollection(corners, edgecolors="none", linewidths=0, **style), autolim=False) + + +def _draw_bars(ax, bx, ys, width: float, axis_top: float, zorder: int, alpha: float = 0.5, **kw): """Rain bars: a pale body (RAIN blended with white by `alpha`, so solid and nothing behind it shows through) with a - brighter cap, so each reads as a little column.""" + 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) - ax.bar(bx, ys, width=width, align="edge", color=_mix(RAIN, 1 - alpha), linewidth=0, zorder=zorder) + _columns(ax, bx, ys, width, facecolors=_mix(RAIN, 1 - alpha), zorder=zorder, **kw) cap = axis_top * 0.014 - ax.bar(bx, np.minimum(cap, ys), bottom=np.maximum(ys - cap, 0), width=width, align="edge", color=RAIN, alpha=0.95, - linewidth=0, zorder=zorder) + _columns(ax, bx, np.minimum(cap, ys), width, np.maximum(ys - cap, 0), facecolors=RAIN, alpha=0.95, zorder=zorder, **kw) def _bars_behind(ax, bars, tz: tzinfo) -> float: - """Bars (rain) on their own right-hand axis, behind the panel's lines and never taller than BARS_SHARE of it. Returns - where the last bar ends (matplotlib date number).""" + """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) - ax2 = ax.twinx() - weight = ax.yaxis.get_gridlines()[0].get_linewidth() if ax.yaxis.get_gridlines() else 0.8 - ax.grid(False) # the grid is drawn by an axes of its own under the rain (sharing this one's scale): grid, rain, lines - under = ax.figure.add_axes(ax.get_position(), sharex=ax, sharey=ax, facecolor=ax.get_facecolor()) - for side in ("top", "right", "left", "bottom"): - under.spines[side].set_visible(False) - under.tick_params(axis="both", length=0, labelleft=False, labelbottom=False) - under.grid(axis="y", color=GRID, linewidth=weight) - under.set_axisbelow(True) - under.set_zorder(ax2.get_zorder() - 1) top = max([*bars.y, 1.0]) - _draw_bars(ax2, bx, bars.y, bars.width / 86400 * 0.85, top / BARS_SHARE, 1) - ax2.set_ylim(0, top / BARS_SHARE) - ax2.axhline(top, color=RAIN, linewidth=0.7, linestyle=(0, (1, 2)), alpha=0.8, zorder=1) # the top of the rain scale, on its own - ax2.yaxis.set_major_locator(FixedLocator([0, top])) - ax2.yaxis.set_major_formatter(FuncFormatter(lambda v, _: f"{v:g} {bars.unit}" if v else "0")) - for side in ("top", "right", "left", "bottom"): - ax2.spines[side].set_visible(False) - ax2.tick_params(axis="y", length=0, labelsize=7, labelcolor=RAIN_TEXT, pad=4) - ax.set_zorder(ax2.get_zorder() + 1) # the lines above the bars - ax.patch.set_visible(False) - ax2.patch.set_visible(False) # so the grid beneath shows between the bars (set last: the line above re-shows it) + _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()) + 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 + beside = ax.transAxes + ScaledTranslation(4 / 72, 0, ax.figure.dpi_scale_trans) # 4 pt right of the panel, as tick labels sit + for y, label in ((0, "0"), (BARS_SHARE, f"{top:g} {bars.unit}")): + ax.text(1, y, label, transform=beside, ha="left", va="center_baseline", fontsize=7, color=RAIN_TEXT) return _end_of(bx, bars.width, 0.0) @@ -361,17 +361,32 @@ def _pills(ax, lines: list[Line], drawn: list[tuple], tz: tzinfo, x0: float, x1: bbox=_box(colour, *look.pill_box), zorder=look.pill_z + 1) +def _rose_radius(outer: np.ndarray) -> float: + """The rose's radius for wedges ending at `outer` (16 compass points x 3 speed steps, out from the centre): the one matplotlib + gives bars that long, 5% beyond the longest wedge, or right at its end when that wedge ends in an empty step (the base of a + bar is a sticky edge). Worked out as matplotlib does, so the rose is the size it always was.""" + longest = float(outer[:, -1].max()) + if not longest: + return 1.0 # nothing to draw + ends = outer[:, :-1][outer[:, :-1] >= longest - 1e-5 * longest] # a step's base at the end of a longest wedge: nothing beyond it + return float(ends.min()) if ends.size else longest + longest * 0.05 + + def _render_rose(fig, compass: Compass): """The whole period as a wind rose (N up, clockwise): each of the 16 wedges is the share of readings from that - way, stacked by wind speed. It sits beside the wind speed line.""" + 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") - theta = np.deg2rad(np.arange(16) * 22.5) - percent = counts / max(counts.sum(), 1) * 100 - bottom = np.zeros(16) - for i, colour in enumerate(WIND_STEPS): - ax.bar(theta, percent[:, i], width=np.deg2rad(20), bottom=bottom, color=colour, edgecolor=BG, linewidth=0.4) - bottom += percent[:, i] + 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 + half = 0.5 / radius + steps = [(k, i) for i in range(3) for k in range(16) if outer[k, i]] # step by step, each over the one before; nothing at the centre + wedges = [Wedge((0.5, 0.5), outer[k, i] * half, 80 - 22.5 * k, 100 - 22.5 * k, width=(outer[k, i] - (outer[k, i - 1] if i else 0)) * half) + for k, i in steps] # 20° wide around each compass point, clockwise from N + ax.add_collection(PatchCollection(wedges, facecolors=[WIND_STEPS[i] for _, i in steps], edgecolors=BG, linewidths=0.4, + transform=ax.transAxes), autolim=False) ax.set_theta_zero_location("N") ax.set_theta_direction(-1) ax.set_xticks(np.deg2rad([0, 90, 180, 270])) @@ -484,7 +499,7 @@ def _draw_panel(ax, p: Panel, tz: tzinfo, first: int, x0: float, x1: float, look bx = _nums(tz, s.x) base = np.zeros(len(bx)) for column, colour in zip((s.good, s.poor, s.very_poor), ZONE_COLOURS): - ax.bar(bx, column, bottom=base, width=s.width / 86400 * 0.85, align="edge", color=colour, linewidth=0, zorder=3) + _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], @@ -511,8 +526,8 @@ def _draw_panel(ax, p: Panel, tz: tzinfo, first: int, x0: float, x1: float, look floor=0) 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 - _shade_zones(ax, p.zones, ybottom, ytop, 0.07) + 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) @@ -541,21 +556,18 @@ def _render(chart: Chart, tz: tzinfo) -> 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]) + xs = _nums(tz, {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: - card = fig.add_axes(rect, facecolor=CARD, zorder=-1) # the faint tint behind the panel - card.set_xticks([]) - card.set_yticks([]) - for side in card.spines.values(): - side.set_visible(False) + 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, grid=0 if p.bars and p.lines else 0.8) # with rain behind, the grid is drawn under it (_bars_behind) + _style_axis(ax) count, x1, entries = _draw_panel(ax, p, tz, 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 @@ -570,9 +582,9 @@ def _render(chart: Chart, tz: tzinfo) -> bytes: 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) + 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: - plt.setp(ax.get_xticklabels(), visible=False) + 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) diff --git a/tests/test_chart_labels.py b/tests/test_chart_labels.py index a541605..7af43f9 100644 --- a/tests/test_chart_labels.py +++ b/tests/test_chart_labels.py @@ -51,6 +51,31 @@ def test_a_latest_value_of_zero_is_not_labelled_for_any_of_them_and_other_readin assert "52.3" not in labels_of(one_line("humidity", [50.0 + (i % 7) for i in range(40)] + [52.3])) # a peak or low is tagged, the latest is not +def test_the_wind_rose_is_scaled_as_matplotlib_scales_its_bars(): + """The rose is one collection of wedges, not polar bars, so its radius is set by hand: it must be the one the bars got (5% room + past the longest wedge, none when that wedge ends in an empty step), so the rose keeps its size whatever the readings.""" + import random + + import matplotlib.pyplot as plt + import numpy as np + rng = random.Random(3) + roses = [[[4, 2, 1]] * 16, [[3 + k % 5, 0, 0] for k in range(16)], [[0, 0, 0]] * 3 + [[5, 0, 2]] + [[0, 0, 0]] * 12, + [[4, 2, 1]] * 5 + [[9, 4, 0]] + [[4, 2, 1]] * 10] + for _ in range(20): + rose = [[rng.choice([0, 0, rng.randint(1, 40)]) for _ in range(3)] for _ in range(16)] + rose[rng.randrange(16)][0] += 1 # a rose is only drawn when there are readings + roses.append(rose) + for rose in roses: + percent = np.asarray(rose, dtype=float) / np.sum(rose) * 100 + ax = plt.figure().add_axes([0, 0, 1, 1], projection="polar") + bottom = np.zeros(16) + for i in range(3): # the rose as it was drawn before + ax.bar(np.deg2rad(np.arange(16) * 22.5), percent[:, i], width=np.deg2rad(20), bottom=bottom) + bottom += percent[:, i] + assert charts._rose_radius(np.cumsum(percent, axis=1)) == pytest.approx(ax.get_ylim()[1], rel=1e-12), rose + plt.close(ax.figure) + + def test_an_end_value_is_never_written_in_exponent_form(): assert charts._figure(1014.8) == "1014.8" and charts._figure(1015.0) == "1015" and charts._figure(67.53) == "67.5" assert charts._figure(0.858) == "0.858" and charts._figure(2.0) == "2" and charts._figure(0.0) == "0" From a025049836158a5be310243af16031933ccf9448 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 16:18:23 +0000 Subject: [PATCH 02/22] Charts as 256-colour PNGs: 38% of the size, written faster, looking the same lib/charts.py _png: a chart has a few thousand colours, nearly all of them the in-between shades on the edges of lines and letters. _palette keeps 256: from the commonest, each next one the colour the palette so far draws worst (its pixels times its squared distance to the nearest kept colour), so every colour that covers an area stays exact and no pixel of the seven samples moves by more than 25/255 (mean under 0.06). Pillow's own quantisers were slower or shifted the white and merged the panels' faint tint into it. The 7 samples go from 1004 to 380 KB, and writing them takes less time than savefig did. tests/charts: regenerated (scripts/update_charts.py). The comparison's own cut to 64 colours picks a little differently from the new pictures (up to 0.77% of pixels), though at full size they match the old ones to within a few levels. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/charts.py | 35 ++++++++++++++++++++++++++++++++- tests/charts/air.png | Bin 24691 -> 24390 bytes tests/charts/all_week.png | Bin 36288 -> 36580 bytes tests/charts/counts.png | Bin 7379 -> 7363 bytes tests/charts/humidity_rain.png | Bin 15691 -> 15774 bytes tests/charts/quarter.png | Bin 11867 -> 11930 bytes tests/charts/temperature.png | Bin 13842 -> 13899 bytes tests/test_specs.py | 26 ++++++++++++++++++++++++ 8 files changed, 60 insertions(+), 1 deletion(-) diff --git a/lib/charts.py b/lib/charts.py index cea80b2..14c90ee 100644 --- a/lib/charts.py +++ b/lib/charts.py @@ -30,6 +30,7 @@ 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 from .timeutil import to_local # noqa: E402 @@ -598,10 +599,42 @@ def _render(chart: Chart, tz: tzinfo) -> bytes: return _png(fig) +def _palette(rgb: np.ndarray, counts: np.ndarray, size: int = 256) -> tuple[list[int], np.ndarray]: + """Up to `size` of these colours (rows of `rgb`, each with its count of pixels) for a palette, and the nearest of them for every + colour. It starts from the commonest; each next one is the colour the palette so far draws worst (its pixels times its squared + distance to the nearest colour taken), so the colours that cover an area are kept exactly and the in-between shades of + anti-aliased edges come out within a few levels. With `size` colours or fewer, every one is kept.""" + taken = [int(np.argmax(counts))] + error = ((rgb - rgb[taken[0]]) ** 2).sum(axis=1) # each colour's squared distance to its nearest taken one + nearest = np.zeros(len(rgb), np.uint8) + weight = counts.astype(float) + while len(taken) < min(size, len(rgb)): + pick = int(np.argmax(weight * error)) + if not error[pick]: + break + distance = ((rgb - rgb[pick]) ** 2).sum(axis=1) + closer = distance < error + error[closer], nearest[closer] = distance[closer], len(taken) + taken.append(pick) + return taken, nearest + + def _png(fig) -> 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.""" try: + fig.canvas.draw() + width, height = fig.canvas.get_width_height(physical=True) + pixels = np.frombuffer(fig.canvas.buffer_rgba(), "> 8 & 255, colours >> 16], axis=1).astype(float) + 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() - fig.savefig(buf, format="png", dpi=DPI, facecolor=BG) + image.save(buf, format="png", dpi=(DPI, DPI)) return buf.getvalue() finally: plt.close(fig) diff --git a/tests/charts/air.png b/tests/charts/air.png index a6072bb3aaaf3060985ead4e1a55c9261ea6c8b7..a164156836851315154c98d340f713b6a51138ba 100644 GIT binary patch literal 24390 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 literal 24691 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�H88B!{#K@*0=b$?CM&> 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 diff --git a/tests/charts/all_week.png b/tests/charts/all_week.png index 589979f6faa95a10cda02d85d786d387a6a6d38a..396a970cc08437700a0e715bd1fc0b5e7520089a 100644 GIT binary patch literal 36580 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Ċ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 delta 7069 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 literal 15691 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 W5G@MQ)U`hp7U-O@(^t`!# zMOXw+0Q(HWT6SBa_@ie!Ca_v?S@}9*tF-rNMMX7H-{Y>C;v}AhiC52MmE!Vy_r2%H?$%e^ z9mVf%tZCMz^|=yc$2jSlXh4Z(ahxnUUBxFFRun(9sGAW*<6F;`R>}R+}|03 zK*gD^rQS5@KjQ`s?U-;Cr`I)jEUHsh6?-P-@5wwK^S=wS_2h@;`qNJUZ(yb_P8Ktj zQP^{OCjTsy?KyYOS$rK@sa+(!k|Wi9gDjm#8bB~3c1m!)%)c=(c~(ZdXr@CEKXL>l z(G~jNuL@Is&?sj9D={EnM*&o(CL~80VuyqwU?LgUwL;Sa zan6qoT5=?#kF6{aU{{eZ^>>*0IhBm!Y(5`2)JH23A96UZxO~AGC2Zl2X;K}xJjP6W z-*-?&&ABHA=wO?jCK_kMeT7`zn|u>aR@xz|1^KL2=3+Np4l8J(dl7iy(&3JRg3vWA zm-QuE)!66iw4{HVhxu`;`W?y%HaW4D%#6sz|1>K}qy}zUe#OT7=j>4B&9pU=5hM4` z1DBxV(}Ka4boIglhuu?ia>XRsaI?6gn0}SNG+(NzyAR{bRzA+z9&73EjZgTM%;Y0u zT(B8%^@`XvTlCbeLXQXj&{*0w1jOH6e6Fd3fR1Tnq5WlKM-PM#Hq^$ zD9}heQy`Q$)v~T!&&C-B5C2_^6&zx8uH5`jW4wxZMjWup`ub;e3jx&^>x#8&QS zBDmoX;xb-Q&|DM2WP-uK@Pu-7`RK0d5249)Nh-~&fnx23HDv%*Wtd1OeI?zuOK@swvPc}<{OQwJ(XJbXa z!@D7|?H2XHO$?IWW$F=cr8xUbylCZ6(^r|U87)~oy!1AFZ9A#{Fo2wi?{*nbfa`8q z6fZqrn+>5!BJLGZ98Ebz!^QhNIpA8;>tcX1RBBsf;eQJDVcMh={zp|sw)qfiF*0?n9*oZp zW#8o+WMw~`Su)C%`4sORRx~`FsqQ`gkDD-^&K#jf{Fs5wTWVC;5 zt*M5;&B$Qk`&iJ>SoE1=#xz}1TZ7K`1cUZ2%AB9ElFfTnT{9rPf-^0j^-Nfy&`Lp{ za^v6BOMd$Vij*Pd&s4;!JoZMXBjKnE+~)rp`p;)mx&Nm_;pP7_rQFl49G^P;MzPv? Q?2ZOX3TpBd|CxmTKRm-YBme*a diff --git a/tests/charts/quarter.png b/tests/charts/quarter.png index 4078747d4687984c1ffb562ef9624901921ce16d..4b76313c56d23cc1bb84ea79197683d8ae359efd 100644 GIT binary patch delta 11781 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 N002ovPDHLkV1kJwJtzPG diff --git a/tests/test_specs.py b/tests/test_specs.py index 3895888..7f3a4b9 100644 --- a/tests/test_specs.py +++ b/tests/test_specs.py @@ -100,6 +100,32 @@ def test_a_taller_chart_for_more_panels(): assert sizes[0] == sizes[1] == (1280, 720) and sizes[2][1] > 720 and sizes[3][1] > sizes[2][1] +def test_a_chart_is_a_png_of_at_most_256_colours_and_its_own_colours_are_exact(): + from PIL import Image + import io + image = Image.open(io.BytesIO(render(Chart("One", "x", [Panel("Temperature", "°C", [line("Outdoor", reading="temperature")])]), TZ))) + colours = image.convert("RGB").getcolors(256) + assert image.mode == "P" and colours is not None # None: more than 256 + assert image.convert("RGB").getpixel((0, 0)) == (255, 255, 255) # the background is pure white + assert (0xFF, 0x98, 0x30) in {rgb for _, rgb in colours} # and the line its own orange + + +def test_the_palette_keeps_every_colour_when_there_are_few_and_the_common_ones_when_there_are_many(): + import numpy as np + from lib.charts import _palette + rng = np.random.default_rng(1) + few = rng.integers(0, 256, (200, 3)).astype(float) + taken, nearest = _palette(few, rng.integers(1, 1000, 200)) + assert sorted(taken) == list(range(200)) and [taken[i] for i in nearest] == list(range(200)) # each colour is its own entry + many = rng.integers(0, 256, (3000, 3)).astype(float) + counts = rng.integers(1, 10, 3000) + counts[:20] = 100_000 # a few colours cover areas + taken, nearest = _palette(many, counts) + assert len(taken) == 256 and set(range(20)) <= set(taken) # they are kept exactly + distance = ((many[:, None, :] - many[taken][None, :, :]) ** 2).sum(axis=2) + assert np.array_equal(distance[np.arange(len(many)), nearest], distance.min(axis=1)) # and every colour is drawn in its nearest + + def test_a_daily_air_line_has_its_range_band_alone_and_in_a_multi_panel_chart(): class Air: tz = TZ From bc6b928171411496215e6c46e77234a12d61b350 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:22:18 +0000 Subject: [PATCH 03/22] Log how long each chart reply took, step by step After a chart is sent, one line says where the time went: "Chart times: data 12 ms, calculation 6 ms, drawing 112 ms, PNG 41 ms (22 KB), sending 812 ms". - data: the time the history tools spent reading (cache, memory, Ecowitt; waits included), kept on the question's Turn. - calculation: the tool's time less its data, shown when the chart is made in code. - drawing and PNG: timed by charts.render, which now takes the Turn's times. - sending: delivering the reply (or redrawing it in place). The period button's cached chart and the alert sends also log how long sending took. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/airgradient/source.py | 3 ++- lib/alerts/notify.py | 4 +++- lib/bot.py | 26 ++++++++++++++++++++++--- lib/charts.py | 22 ++++++++++++++------- lib/ecowitt/fetch.py | 22 +++++++++++++++++++++ lib/ecowitt/query.py | 17 +++++++++++----- lib/tools.py | 1 + tests/test_bot.py | 41 +++++++++++++++++++++++++++++++++++++-- tests/test_ecowitt.py | 10 ++++++---- tests/test_periods.py | 2 +- 10 files changed, 124 insertions(+), 24 deletions(-) diff --git a/lib/airgradient/source.py b/lib/airgradient/source.py index 9aa1a51..9fa7dcc 100644 --- a/lib/airgradient/source.py +++ b/lib/airgradient/source.py @@ -322,8 +322,9 @@ async def history(self, start: str | None, end: str | None, chart: bool = False, if t0 >= t1: return {"error": "start must be before end"} chart = wants_chart({"chart": chart}, turn, t0, t1) - before = self.requests + 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 log.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}", diff --git a/lib/alerts/notify.py b/lib/alerts/notify.py index 66f8a41..7f81d08 100644 --- a/lib/alerts/notify.py +++ b/lib/alerts/notify.py @@ -9,6 +9,7 @@ import json import logging import os +import time from datetime import date from telegram import MessageEntity @@ -199,8 +200,9 @@ async def __call__(self, text: str, link: tuple[str, str] | None = None, kind: s longer post to. 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).""" text, entities = with_footer(text, link) + started = time.monotonic() sent = sum([await self._send(chat_id, text, entities, kind, photo) for chat_id in self.state.alert_chats(kind)]) - log.info("Alert sent to %d chat(s): %s", sent, text.replace("\n", " ")) + log.info("Alert sent to %d chat(s) in %.1fs: %s", sent, time.monotonic() - started, text.replace("\n", " ")) 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. diff --git a/lib/bot.py b/lib/bot.py index 259008a..8b23c6c 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -74,6 +74,19 @@ def _short(text: str, limit: int = 200) -> str: return text if len(text) <= limit else text[:limit] + "..." +def chart_times(times: dict, made: float | None, pngs: list[bytes], sending: float) -> str: + """A reply's chart timings for the log: "data 12 ms, calculation 3 ms, drawing 98 ms, PNG 41 ms (23 KB), sending 812 ms". times is + the question's Turn.times; made is how long the tool took when the chart was made in code (the calculation is that less its + data), else None. A step that wasn't timed is left out.""" + ms = lambda seconds: f"{seconds * 1000:.0f} ms" + parts = [f"data {ms(times['data'])}"] if "data" in times else [] + if made is not None: + parts.append(f"calculation {ms(max(0.0, made - times.get('data', 0.0)))}") + if "draw" in times: + parts += [f"drawing {ms(times['draw'])}", f"PNG {ms(times['png'])} ({sum(map(len, pngs)) / 1024:.0f} KB)"] + return ", ".join([*parts, f"sending {ms(sending)}"]) + + def split_message(text: str, limit: int = TG_LIMIT) -> list[str]: chunks = [] while len(text) > limit: @@ -594,9 +607,10 @@ async def on_period_button(self, update: Update, context: ContextTypes.DEFAULT_T if cached := self.images.get(self.images.key(asked, days, now)): # drawn lately: no new question png, title = cached try: + began = time.monotonic() await message.edit_media(InputMediaPhoto(png, caption=title or None), reply_markup=periods.keyboard(days)) self.charted.remember(message.chat_id, message.message_id, asked) - log.info("Period button: chart from the cache") + log.info("Period button: chart from the cache, sent in %.0f ms", (time.monotonic() - began) * 1000) return except TelegramError as e: log.warning("Couldn't edit the chart in place (%s); drawing it again", e) @@ -837,13 +851,16 @@ async def respond(self, update: Update, context: ContextTypes.DEFAULT_TYPE, text 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(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) try: reply = None if 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: @@ -862,7 +879,7 @@ async def respond(self, update: Update, context: ContextTypes.DEFAULT_TYPE, text 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)) + photos.append(await asyncio.to_thread(render_chart, spec, self.cfg.tz, turn.times)) titles.append(spec.caption(now.date())) except Exception: log.exception("Chart failed; sending the answer without it") @@ -891,16 +908,19 @@ async def respond(self, update: Update, context: ContextTypes.DEFAULT_TYPE, text 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 = [] + sent_photo, drawn = [], list(photos) if row and ok and not reply.strip(): # a chart and nothing else: keep it for the period buttons self.images.put(self.images.key(text, intent.period_days(text, now), now), photos[0], titles[0]) try: + began = time.monotonic() if redraw is not None and row and await self._redraw(redraw, reply, photos[0], titles[0], row, text): reply, photos, row = "", [], None carried_keyboard = await deliver(msg, reply, photos, link=air.link if air and used_air else None, markup=markup 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 drawn: + log.info("Chart times: %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) if self.state and ok and turn.forecast_shown: # it was sent: a later revision of these days is worth telling this chat diff --git a/lib/charts.py b/lib/charts.py index 14c90ee..c32fad0 100644 --- a/lib/charts.py +++ b/lib/charts.py @@ -13,6 +13,7 @@ import io import math import threading +import time from dataclasses import dataclass from datetime import tzinfo from statistics import fmean @@ -544,9 +545,10 @@ def _draw_panel(ax, p: Panel, tz: tzinfo, first: int, x0: float, x1: float, look return len(lines), x1, entries -def _render(chart: Chart, tz: tzinfo) -> bytes: +def _render(chart: Chart, tz: tzinfo, times: dict | None = None) -> bytes: """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) @@ -596,7 +598,7 @@ def _render(chart: Chart, tz: tzinfo) -> bytes: except Exception: plt.close(fig) raise - return _png(fig) + return _png(fig, times, started) def _palette(rgb: np.ndarray, counts: np.ndarray, size: int = 256) -> tuple[list[int], np.ndarray]: @@ -619,11 +621,13 @@ def _palette(rgb: np.ndarray, counts: np.ndarray, size: int = 256) -> tuple[list return taken, nearest -def _png(fig) -> bytes: +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.""" + 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(), " bytes: 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) @@ -643,7 +650,8 @@ def _png(fig) -> bytes: _DRAWING = threading.Lock() # pyplot keeps global state (current figure, rcParams): one chart is drawn at a time -def render(chart: Chart, tz: tzinfo) -> bytes: - """PNG bytes for one chart (1280x720; a stack of more than two panels is taller). Safe to call from several threads.""" +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".""" with _DRAWING: - return _render(chart, tz) + return _render(chart, tz, times) diff --git a/lib/ecowitt/fetch.py b/lib/ecowitt/fetch.py index 2753841..997f47a 100644 --- a/lib/ecowitt/fetch.py +++ b/lib/ecowitt/fetch.py @@ -2,7 +2,9 @@ or from Ecowitt, within Ecowitt's per-request limits.""" import asyncio +import functools import logging +import time from datetime import datetime, timedelta, tzinfo, UTC from ..timeutil import local_epoch, to_local @@ -41,6 +43,18 @@ def merge_data(into: dict, new: dict, window: tuple[int, int] | None = None): REJECTIONS_STOP = 2 # requests Ecowitt refuses in one job before the rest are not sent +def timed(method): + """A Fetcher method whose time (waits included) is added to the fetcher's `seconds`.""" + @functools.wraps(method) + async def run(self, *args, **kwargs): + started = time.perf_counter() + try: + return await method(self, *args, **kwargs) + finally: + self.seconds += time.perf_counter() - started + return run + + class Fetcher: """History for one job (a question, a refresh, an alert check): from the disk cache where possible, recent readings from memory if fresh, the rest from Ecowitt.""" @@ -52,6 +66,7 @@ def __init__(self, api, cache: HistoryCache, hot: HotStore, mac: str, groups: li self.ranges = self.from_cache = self.from_memory = self.calls = 0 self.errors: list[str] = [] self.rejected = 0 # requests Ecowitt itself refused (not network trouble or a busy server) + self.seconds = 0.0 # time spent reading (the cache, memory, Ecowitt), for the reply's timing line def epoch(self, local: datetime) -> int: return local_epoch(local, self.tz) @@ -59,6 +74,7 @@ def epoch(self, local: datetime) -> int: def local(self, epoch: int) -> datetime: return to_local(epoch, self.tz).replace(tzinfo=None) + @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 the keep-warm refresh). load=False only makes sure the range is cached and in memory, and @@ -105,6 +121,12 @@ async def _fetch(self, cycle: str, start: datetime, end: datetime, groups: list[ self.rejected += not e.transient return None + @timed + async def read(self, fn, *args): + """fn(*args), a read of the cache's own (slots, slot ranges), in a thread.""" + return await asyncio.to_thread(fn, *args) + + @timed async def covered(self, cycle: str, start: int, end: int) -> bool: """True if the cache holds every group for [start, end] (epoch seconds).""" for g in self.groups: diff --git a/lib/ecowitt/query.py b/lib/ecowitt/query.py index 9b76ad8..24d66cc 100644 --- a/lib/ecowitt/query.py +++ b/lib/ecowitt/query.py @@ -77,8 +77,15 @@ def __init__(self, fetcher: Fetcher, args: dict, turn): self.rain_bars = Bars("Rain", "mm", [], [], 86400, "day") # for a stacked chart with rain self.rain_from_daily = False # some of its days come from daily records (older than the 30-minute readings) 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 async def run(self) -> str: + try: + return await self._run() + finally: # for the reply's timing line + self.turn.times["data"] = self.turn.times.get("data", 0.0) + self.f.seconds + (self.wind.seconds if self.wind else 0.0) + + async def _run(self) -> str: today = self.now.date() try: start = datetime.fromisoformat(str(self.args.get("start_date") or today).replace("T", " ")) @@ -130,8 +137,8 @@ async def _fine_ranges(self): pts = series["pts"] if derives_range(field) and pts and not any(r["cycle"] != "30min" or "low" in r or "high" in r for r in pts.values()): wanted[key] = (group, field, {t: r["value"][0] for t, r in pts.items() if "value" in r}) - ranges = await asyncio.to_thread(lambda: {key: self.f.cache.slot_ranges(self.f.mac, group, field, values, lo, hi) - for key, (group, field, values) in wanted.items()}) + ranges = await self.f.read(lambda: {key: self.f.cache.slot_ranges(self.f.mac, group, field, values, lo, hi) + for key, (group, field, values) in wanted.items()}) for key, (lows, highs) in ranges.items(): pts = self.store[key]["pts"] for t in lows: @@ -186,7 +193,7 @@ async def _summarise_direction(self): if not keys: return f = self.f - wind = Fetcher(f.api, f.cache, f.hot, f.mac, ["wind"], self.tz) + wind = self.wind = Fetcher(f.api, f.cache, f.hot, f.mac, ["wind"], self.tz) first = max(self.start, self.end - timedelta(days=DIRECTION_DAYS)) for key in keys: fine: dict = {} @@ -481,10 +488,10 @@ async def _rain_bars(self) -> Bars: 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 asyncio.to_thread(self.f.cache.slots, self.f.mac, "30min", "rainfall", ["daily"], lo, hi) + (daily,) = await self.f.read(self.f.cache.slots, self.f.mac, "30min", "rainfall", ["daily"], lo, hi) rain = rain_slots(daily) held = {local_date(t, self.tz) for t in rain} - (old,) = await asyncio.to_thread(self.f.cache.slots, self.f.mac, "1day", "rainfall", ["daily"], lo - 86400, hi + 86400) + (old,) = await self.f.read(self.f.cache.slots, self.f.mac, "1day", "rainfall", ["daily"], lo - 86400, hi + 86400) for t, mm in old.items(): # a daily bucket is dated by the local day its 10am start falls on day = local_date(t, self.tz) if day not in held and self.start.date() <= day <= self.end.date(): diff --git a/lib/tools.py b/lib/tools.py index 123f19e..899885a 100644 --- a/lib/tools.py +++ b/lib/tools.py @@ -25,6 +25,7 @@ class Turn: per_day: bool = False # day by day figures were asked for: a long period keeps every day, not only its extremes result_chars: int = 0 # the size of every tool result so far this question (what the model will be sent) forecast_shown: list = field(default_factory=list) # the forecast days the answer includes, remembered once it is sent + times: dict = field(default_factory=dict) # seconds spent by step ("data", "draw", "png"), for the reply's timing line in the log @dataclass diff --git a/tests/test_bot.py b/tests/test_bot.py index f655542..840a9ba 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -290,7 +290,7 @@ async def run(self, *a, **k): async def reply_photo(photo, caption=None, **kw): sent.append(caption) - monkeypatch.setattr(botmod, "render_chart", lambda spec, tz: b"png") + 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")] msg = NS(chat_id=1, message_thread_id=None, is_topic_message=False, reply_photo=reply_photo, chat=NS(type="private", title=None), from_user=NS(full_name="Rob"), reply_to_message=None) @@ -304,6 +304,43 @@ async def send_chat_action(*a, **k): assert sent == ["Humidity"] # the chart's title is its whole caption +async def test_a_chart_reply_logs_how_long_each_step_took(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 + from lib.tools import Tools + from tests.fakes import config, ecowitt_transport + monkeypatch.setattr(api, "MIN_GAP_SECONDS", 0) + eco = Ecowitt(config(tmp_path), transport=ecowitt_transport()[0]) + await eco.start() + + class Agent: + tools = Tools(eco.tools) + + async def run(self, *a, **k): + raise AssertionError("no model for a plain chart") + sent = [] + + async def reply_photo(photo, caption=None, **kw): + sent.append(photo) + return NS(message_id=7) + sources = [NS(name="Ecowitt", wants=lambda t: True, poke=lambda: None, describe=lambda: "Ecowitt")] + msg = NS(chat_id=1, message_thread_id=None, is_topic_message=False, reply_photo=reply_photo, + chat=NS(type="private", title=None), from_user=NS(full_name="Rob"), reply_to_message=None) + update = NS(effective_message=msg, effective_chat=msg.chat, effective_user=NS(username="rob", full_name="Rob")) + + async def send_chat_action(*a, **k): + pass + with caplog.at_level("INFO"): + await Bot(NS(tz=TZ), Agent(), sources, None).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 times:")] + assert len(sent) == 1 and len(lines) == 1, lines + kb = round(len(sent[0]) / 1024) + assert re.fullmatch(rf"Chart times: data \d+ ms, calculation \d+ ms, drawing \d+ ms, PNG \d+ ms \({kb} KB\), sending \d+ ms", lines[0]) + + async def _ask_with(tool_result, text, monkeypatch=None): """Ask `text` of a bot whose one tool returns `tool_result`; returns (replies, whether the model was asked).""" asked = [] @@ -498,7 +535,7 @@ async def run(self, *a, **k): async def reply_photo(photo, caption=None, **kw): sent.append(caption) - monkeypatch.setattr(botmod, "render_chart", lambda spec, tz: b"png") + 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")] for text, caption_wanted in (("ecowitt all week", True), ("temperature chart 7d", True)): sent.clear() diff --git a/tests/test_ecowitt.py b/tests/test_ecowitt.py index fdee33e..f48b272 100644 --- a/tests/test_ecowitt.py +++ b/tests/test_ecowitt.py @@ -1017,7 +1017,7 @@ async def test_each_weather_chart_carries_its_reading_so_it_is_drawn_in_that_rea async def test_thirty_minute_readings_without_a_range_get_one_from_the_cached_five_minute_readings(tmp_path): import time as _time - from types import SimpleNamespace as NS + from lib.ecowitt.fetch import Fetcher from lib.ecowitt.query import HistoryQuery from lib.ecowitt.store import HistoryCache from tests.fakes import TZ @@ -1027,7 +1027,9 @@ async def test_thirty_minute_readings_without_a_range_get_one_from_the_cached_fi five = {"solar_and_uvi": {"solar": {"unit": "W/m2", "list": {str(base + 300 * i): str(10.0 + i) for i in range(12)}}}} # two slots of six cache.store("M", "5min", ["solar_and_uvi"], five, base, base + 3600) start, end = datetime(2026, 1, 1), datetime(2026, 1, 2) - q = HistoryQuery(NS(tz=TZ, epoch=lambda d: base if d == start else base + 7200, cache=cache, mac="M"), {}, Turn()) + fetcher = Fetcher(None, cache, None, "M", [], TZ) + fetcher.epoch = lambda d: base if d == start else base + 7200 + q = HistoryQuery(fetcher, {}, Turn()) q.start, q.end = start, end q.store = {"solar_and_uvi.solar": {"unit": "W/m²", "pts": {base: {"cycle": "30min", "value": (12.0, "12")}, base + 1800: {"cycle": "30min", "value": (18.0, "18")}}}, @@ -1168,6 +1170,7 @@ def test_naming_one_of_temperature_and_humidity_leaves_the_other_out_of_what_the async def test_rain_older_than_the_30_minute_readings_is_drawn_from_the_daily_records_and_by_month_when_asked(tmp_path): + from lib.ecowitt.fetch import Fetcher from lib.ecowitt.query import HistoryQuery from lib.ecowitt.store import HistoryCache from lib.tools import Turn @@ -1179,8 +1182,7 @@ async def test_rain_older_than_the_30_minute_readings_is_drawn_from_the_daily_re days = [old + timedelta(days=k) for k in range(60)] cache.store("M", "1day", ["rainfall"], {"rainfall": {"daily": {"unit": "mm", "list": {str(day_ts(d)): "2.0" for d in days}}}}, day_ts(days[0]), day_ts(days[-1]) + 86400) - f = type("F", (), {})() - f.cache, f.mac, f.tz = cache, "M", TZ + f = Fetcher(None, cache, None, "M", [], TZ) f.epoch = lambda dt: int(dt.replace(tzinfo=TZ).timestamp()) q = HistoryQuery.__new__(HistoryQuery) q.f, q.tz, q.turn = f, TZ, Turn() diff --git a/tests/test_periods.py b/tests/test_periods.py index aed2bf9..27291c4 100644 --- a/tests/test_periods.py +++ b/tests/test_periods.py @@ -63,7 +63,7 @@ class Agent: async def run(self, *a, **k): raise AssertionError("no model for a plain chart") - monkeypatch.setattr(botmod, "render_chart", lambda spec, tz: b"png") + 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) From ad0dcecbbbf703639ec24d4969600b0e68a5a075 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:26:24 +0000 Subject: [PATCH 04/22] Pick the chart palette in whole numbers, a channel at a time: same PNGs, PNG step in 64% of the time The greedy palette now keeps each colour's red, green and blue as separate integer arrays, and its score (pixels x squared distance) up to date only where a colour got closer, instead of recomputing both over every colour for each of the 256 picks. The values are the same whole numbers as before, so the picks and the PNG bytes are identical (checked on all 15 layouts). The PNG step for the 7 samples: 268 -> 171 ms (palette alone 2.7-5x faster). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/charts.py | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/lib/charts.py b/lib/charts.py index c32fad0..934faa0 100644 --- a/lib/charts.py +++ b/lib/charts.py @@ -605,18 +605,29 @@ def _palette(rgb: np.ndarray, counts: np.ndarray, size: int = 256) -> tuple[list """Up to `size` of these colours (rows of `rgb`, each with its count of pixels) for a palette, and the nearest of them for every colour. It starts from the commonest; each next one is the colour the palette so far draws worst (its pixels times its squared distance to the nearest colour taken), so the colours that cover an area are kept exactly and the in-between shades of - anti-aliased edges come out within a few levels. With `size` colours or fewer, every one is kept.""" - taken = [int(np.argmax(counts))] - error = ((rgb - rgb[taken[0]]) ** 2).sum(axis=1) # each colour's squared distance to its nearest taken one + anti-aliased edges come out within a few levels. With `size` colours or fewer, every one is kept. Whole numbers throughout, so + the picks are exact (ties go to the first colour).""" + red, green, blue = (np.ascontiguousarray(rgb[:, i], dtype=np.int64) for i in range(3)) + weight = np.asarray(counts, dtype=np.int64) + + def distance_to(k: int) -> np.ndarray: # every colour's squared distance to colour k + distance = (red - red[k]) ** 2 + distance += (green - green[k]) ** 2 + distance += (blue - blue[k]) ** 2 + return distance + taken = [int(np.argmax(weight))] + error = distance_to(taken[0]) # each colour's squared distance to its nearest taken one + score = weight * error # how badly the palette so far draws its pixels nearest = np.zeros(len(rgb), np.uint8) - weight = counts.astype(float) while len(taken) < min(size, len(rgb)): - pick = int(np.argmax(weight * error)) + pick = int(np.argmax(score)) if not error[pick]: break - distance = ((rgb - rgb[pick]) ** 2).sum(axis=1) - closer = distance < error - error[closer], nearest[closer] = distance[closer], len(taken) + distance = distance_to(pick) + closer = np.flatnonzero(distance < error) # only these colours' error and score change + error[closer] = distance[closer] + score[closer] = weight[closer] * distance[closer] + nearest[closer] = len(taken) taken.append(pick) return taken, nearest @@ -631,7 +642,7 @@ def _png(fig, times: dict | None = None, started: float = 0.0) -> bytes: width, height = fig.canvas.get_width_height(physical=True) pixels = np.frombuffer(fig.canvas.buffer_rgba(), "> 8 & 255, colours >> 16], axis=1).astype(float) + 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 lookup[colours] = nearest From 59299653a6c8434b46d956d13c0d200d6e7e86e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:34:50 +0000 Subject: [PATCH 05/22] Questions never wait for the refresh, and their Ecowitt requests go first - Reading without waiting: a history read first checks the cache and memory without the refresh's lock, and returns at once when they hold it all (the usual case: the refresh keeps memory under a minute old). Only a read that needs Ecowitt waits for a fetch in flight, as before. Counters count only the pass that answered. - Urgent requests: a question's history requests (and its wind rose's) are marked urgent, so they go before the background refresh's queued requests. - No refresh for charts made in code: such a chart no longer starts a refresh beside its own reads. Questions for the model still do. - Less work for the refresh: it no longer merges the readings it fetches into a result it never reads. Measured through the bot's chart path with Ecowitt answering in 0.6 s: - A chart asked while the minute's refresh is fetching: Temperature 1d 2.0 s -> 0.16 s, Weather 1d 2.0 -> 0.34 s, Wind 1d 1.9 -> 0.17 s (it waited ~1.8 s for the refresh). - With memory stale: Weather 1d and Wind 1d 5.0 s -> 1.0 s (one Ecowitt request instead of five). - The history tools' results and chart specs for fixed past periods are unchanged (21 cases). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/bot.py | 2 +- lib/ecowitt/api.py | 6 ++--- lib/ecowitt/fetch.py | 63 ++++++++++++++++++++++++++----------------- lib/ecowitt/query.py | 2 +- lib/ecowitt/source.py | 7 ++--- tests/test_bot.py | 36 +++++++++++++++++++++++++ tests/test_ecowitt.py | 54 ++++++++++++++++++++++++++++++++++++- 7 files changed, 137 insertions(+), 33 deletions(-) diff --git a/lib/bot.py b/lib/bot.py index 8b23c6c..8f404f8 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -835,7 +835,7 @@ async def respond(self, update: Update, context: ContextTypes.DEFAULT_TYPE, text log.info("%s %s%s", describe_source(update), f"(reasoning: {read.effort}) " if read.effort != intent.EFFORT_DEFAULT else "", _short(text)) for source in self.sources: # fetch recent readings while the model thinks - if source.wants(text): + 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 source.poke() if read.fast: log.info("Fast path: %s", read.fast[2]) diff --git a/lib/ecowitt/api.py b/lib/ecowitt/api.py index 11f1e87..fbf7055 100644 --- a/lib/ecowitt/api.py +++ b/lib/ecowitt/api.py @@ -172,8 +172,8 @@ async def devices(self) -> list[dict]: async def realtime(self, mac: str, groups: str, urgent: bool = False) -> dict: return fix_units(await self._get("real_time", urgent=urgent, mac=mac, call_back=groups, **UNIT_IDS)) - async def history(self, mac: str, cycle: str, start: datetime, end: datetime, groups: str) -> dict: - """{group: {field: {"unit", "list": {epoch: value}}}}; empty when there is no data.""" - data = await self._get("history", mac=mac, call_back=groups, cycle_type=cycle, + async def history(self, mac: str, cycle: str, start: datetime, end: datetime, groups: str, urgent: bool = False) -> dict: + """{group: {field: {"unit", "list": {epoch: value}}}}; empty when there is no data. urgent: as for _get.""" + data = await self._get("history", urgent=urgent, mac=mac, call_back=groups, cycle_type=cycle, start_date=start.strftime(FMT), end_date=end.strftime(FMT), **UNIT_IDS) return fix_units(data) if isinstance(data, dict) else {} diff --git a/lib/ecowitt/fetch.py b/lib/ecowitt/fetch.py index 997f47a..6360f32 100644 --- a/lib/ecowitt/fetch.py +++ b/lib/ecowitt/fetch.py @@ -59,8 +59,9 @@ class Fetcher: """History for one job (a question, a refresh, an alert check): from the disk cache where possible, recent readings from memory if fresh, the rest from Ecowitt.""" - def __init__(self, api, cache: HistoryCache, hot: HotStore, mac: str, groups: list[str], tz: tzinfo): - self.api, self.cache, self.hot, self.tz = api, cache, hot, tz + def __init__(self, api, cache: HistoryCache, hot: HotStore, mac: str, groups: list[str], tz: tzinfo, urgent: bool = False): + """urgent: someone is waiting for the answer, so its requests go before the background ones queued for Ecowitt.""" + self.api, self.cache, self.hot, self.tz, self.urgent = api, cache, hot, tz, urgent self.mac = mac.strip().upper() self.groups = [g.split(".")[0].strip() for g in groups if g.strip()] # plain names, never "outdoor.temp" self.ranges = self.from_cache = self.from_memory = self.calls = 0 @@ -78,32 +79,46 @@ def local(self, epoch: int) -> datetime: 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 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).""" + 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 + it waits for any fetch of this cycle in flight (which may bring it), so the same thing isn't asked of Ecowitt twice.""" self.ranges += 1 s, e = self.epoch(start), self.epoch(end) + if not refresh and (held := await self._read(cycle, s, e, load, fetch=False)) is not None: + return held async with self.hot.lock(self.mac, cycle): - missing = {g: await asyncio.to_thread(self.cache.missing, self.mac, cycle, g, s, e) for g in self.groups} - gaps = merge_intervals([iv for ivs in missing.values() for iv in ivs]) - if not gaps: - self.from_cache += 1 - fresh: dict = {} - for gap_start, gap_end in gaps: - need = [g for g in self.groups if any(a <= gap_end and b >= gap_start for a, b in missing[g])] - hot = {} if refresh else {g: self.hot.get(self.mac, cycle, g, gap_start, gap_end) for g in need} - if hot and all(v is not None for v in hot.values()): - self.from_memory += 1 + return await self._read(cycle, s, e, load, fetch=True, refresh=refresh) + + async def _read(self, cycle: str, s: int, e: int, load: bool, fetch: bool, refresh: bool = False) -> dict | None: + """[s, e] (epoch seconds) from the cache, its newest readings from memory, and (fetch) what neither holds from Ecowitt, which + is then cached and kept in memory. None, with nothing counted, when fetch is False and the cache and memory lack some of it.""" + missing = {g: await asyncio.to_thread(self.cache.missing, self.mac, cycle, g, s, e) for g in self.groups} + gaps = merge_intervals([iv for ivs in missing.values() for iv in ivs]) + fresh: dict = {} + from_memory = 0 + for gap_start, gap_end in gaps: + need = [g for g in self.groups if any(a <= gap_end and b >= gap_start for a, b in missing[g])] + hot = {} if refresh else {g: self.hot.get(self.mac, cycle, g, gap_start, gap_end) for g in need} + if hot and all(v is not None for v in hot.values()): + from_memory += 1 + if load: merge_data(fresh, hot, (gap_start, gap_end)) - continue - 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 {}) + 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) - if not load: - return {} - result = await asyncio.to_thread(self.cache.load, self.mac, cycle, self.groups, s, e) + self.from_cache += not gaps + self.from_memory += from_memory + if not load: + return {} + result = await asyncio.to_thread(self.cache.load, self.mac, cycle, self.groups, s, e) merge_data(result, fresh, (s, e)) # recent readings aren't on disk; use the fresh ones return result @@ -113,7 +128,7 @@ async def _fetch(self, cycle: str, start: datetime, end: datetime, groups: list[ return None self.calls += 1 try: - return await self.api.history(self.mac, cycle, start, end, ",".join(groups)) + return await self.api.history(self.mac, cycle, start, end, ",".join(groups), urgent=self.urgent) except EcowittError as e: label = f"{cycle} {start:%d %b %Y} - {end:%d %b %Y}" log.warning("History request failed (%s): %s", label, e) diff --git a/lib/ecowitt/query.py b/lib/ecowitt/query.py index 24d66cc..70cdf77 100644 --- a/lib/ecowitt/query.py +++ b/lib/ecowitt/query.py @@ -193,7 +193,7 @@ async def _summarise_direction(self): if not keys: return f = self.f - wind = self.wind = Fetcher(f.api, f.cache, f.hot, f.mac, ["wind"], self.tz) + wind = self.wind = Fetcher(f.api, f.cache, f.hot, f.mac, ["wind"], self.tz, f.urgent) first = max(self.start, self.end - timedelta(days=DIRECTION_DAYS)) for key in keys: fine: dict = {} diff --git a/lib/ecowitt/source.py b/lib/ecowitt/source.py index b48ccd7..3a2a1c0 100644 --- a/lib/ecowitt/source.py +++ b/lib/ecowitt/source.py @@ -164,8 +164,9 @@ def describe(self) -> str: created = f", created {self.created:%Y-%m-%d %H:%M}" if self.created else "" return f"Ecowitt weather station '{self.station_name}'{created} (people say \"ecowitt\" or \"weather\")" - def fetcher(self, groups: list[str]) -> Fetcher: - return Fetcher(self.api, self.cache, self.hot, self.mac, groups, self.tz) + 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) @@ -178,7 +179,7 @@ async def _history(self, args: dict, turn: Turn | None = None) -> str: 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: groups.append(r.group) - return await HistoryQuery(self.fetcher(groups), args, turn).run() + return await HistoryQuery(self.fetcher(groups, urgent=True), args, turn).run() async def _days(self, args: dict, turn: Turn | None = None) -> str: return await days_tool(self.cache, self.mac, self.tz, args, turn) diff --git a/tests/test_bot.py b/tests/test_bot.py index 840a9ba..87bf083 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -304,6 +304,42 @@ async def send_chat_action(*a, **k): 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): + """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 + from lib.specs import Chart, Line, Panel + pokes = [] + + class Tools: + 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": { + "outdoor.temperature": {"unit": "℃", "low": "1", "high": "2"}}}) + + class Agent: + tools = Tools() + + async def run(self, *a, **k): + return "It is 12 degrees." + + async def reply(*a, **k): + return NS(message_id=7) + monkeypatch.setattr(botmod, "render_chart", lambda spec, tz, times=None: b"png") + sources = [NS(name="Ecowitt", wants=lambda t: True, poke=lambda: pokes.append(1), describe=lambda: "Ecowitt")] + msg = NS(chat_id=1, message_thread_id=None, is_topic_message=False, reply_photo=reply, reply_text=reply, + chat=NS(type="private", title=None), from_user=NS(full_name="Rob"), reply_to_message=None) + update = NS(effective_message=msg, effective_chat=msg.chat, effective_user=NS(username="rob", full_name="Rob")) + + async def send_chat_action(*a, **k): + pass + bot, context = Bot(NS(tz=TZ), Agent(), sources, None), 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): """One line per chart reply: reading the data, the calculation, drawing, the PNG (and its size) and sending it.""" import re diff --git a/tests/test_ecowitt.py b/tests/test_ecowitt.py index f48b272..240e8c3 100644 --- a/tests/test_ecowitt.py +++ b/tests/test_ecowitt.py @@ -152,6 +152,58 @@ async def test_warm_then_question_needs_no_new_requests_for_the_tail(station): assert fake.calls == [] # recent tail came from memory +async def test_a_question_the_cache_and_memory_can_answer_does_not_wait_for_a_refresh_that_is_fetching(tmp_path): + import httpx + fake, answering = FakeEcowitt(), asyncio.Event() + answering.set() + + async def handler(request): + await answering.wait() + return fake(request) + eco = Ecowitt(config(tmp_path), transport=httpx.MockTransport(handler)) + await eco.start() + await eco.warm(True) # memory holds the newest readings + answering.clear() # Ecowitt goes quiet + refresh = asyncio.create_task(eco.warm(True)) + await asyncio.sleep(0.05) # the refresh's requests are out, holding the 5-minute and 30-minute fetches + now = eco.now() + question = eco.fetcher(["outdoor"], urgent=True) + data = await asyncio.wait_for(question.get("5min", now - timedelta(hours=3), now), 2) + assert data["outdoor"]["temperature"]["list"] and question.calls == 0 and question.from_memory == 1 + assert not refresh.done() + answering.set() + await refresh + await eco.close() + + +async def test_a_questions_requests_go_before_the_background_ones_and_the_refreshes_wait_their_turn(station, monkeypatch): + eco, _ = station + marked = [] # each history request's urgent flag + send = eco.api._get + + async def watch(path, urgent=False, **params): + if path == "history": + marked.append(urgent) + return await send(path, urgent, **params) + monkeypatch.setattr(eco.api, "_get", watch) + await eco.warm(True) + assert marked and not any(marked) + marked.clear() + today = eco.now().date() + await eco.tools[1].handler({"groups": "outdoor,wind", "start_date": f"{today - timedelta(days=20)} 00:00:00", + "end_date": f"{today - timedelta(days=10)} 23:59:59"}, Turn()) + assert marked and all(marked) # the question's own, and its wind rose's + + +async def test_the_refresh_builds_no_result_it_would_not_read(station, monkeypatch): + from lib.ecowitt import fetch + eco, fake = station + merged = [] + monkeypatch.setattr(fetch, "merge_data", lambda *a, **k: merged.append(a)) + await eco.warm(True) + assert any(c["path"] == "history" for c in fake.calls) and merged == [] + + # ---------- the whole history is cached ---------- async def test_archive_caches_every_cycle_so_long_questions_need_no_requests(tmp_path, monkeypatch): from lib.ecowitt import Archive, archive as archive_mod @@ -989,7 +1041,7 @@ async def test_a_job_stops_asking_once_ecowitt_has_refused_twice(tmp_path): class Refusing: calls = 0 - async def history(self, *a): + async def history(self, *a, **k): Refusing.calls += 1 raise EcowittError("humidity is invalid", transient=False) From 898a0589c55d611744ba8b05f75bab0d208fd2cd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:46:13 +0000 Subject: [PATCH 06/22] Long periods: one coverage read per check, the wind rose's days read in runs, months labelled once a day - One read of the coverage: a history read, a "held in the cache?" check and the wind rose's day-by-day check each read every group's coverage in one query and one thread hop (HistoryCache.coverage_of), instead of one hop per group and window. A hop costs more than the query, and far more while a chart is being drawn in another thread. - The wind rose's 5-minute readings: consecutive days are read in one go. A 30-day wind chart reads them once instead of 31 times; a 30-day weather chart makes 178 cache queries instead of 387. - Monthly figures: each reading's month comes from its local day, worked out once a day, not a date conversion and strftime per reading. The readings themselves are still read per group: every combined form was 10-33% slower. History tool times (best of 3, before -> after): - Wind 365d 433-458 -> 279-356 ms - Weather 30d 289-312 -> 237-246 ms - Wind 30d 177-199 -> 142-167 ms - Temperature 90d 201-245 -> 176-183 ms - Weather 7d 73-82 -> 58-68 ms - Temperature 365d and Rain 365d: unchanged The results and chart specs for 21 fixed past periods are identical. So are the wind questions through now, checked against the previous code in the same process. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/ecowitt/fetch.py | 21 ++++++++++++++------- lib/ecowitt/query.py | 42 ++++++++++++++++++++++++++++-------------- lib/ecowitt/store.py | 8 ++++++++ tests/test_ecowitt.py | 23 +++++++++++++++++++++++ tests/test_store.py | 12 ++++++++++++ 5 files changed, 85 insertions(+), 21 deletions(-) diff --git a/lib/ecowitt/fetch.py b/lib/ecowitt/fetch.py index 6360f32..648035c 100644 --- a/lib/ecowitt/fetch.py +++ b/lib/ecowitt/fetch.py @@ -9,7 +9,7 @@ from ..timeutil import local_epoch, to_local from .api import EcowittError, MAX_SPAN, RETENTION -from .store import HistoryCache, HotStore, merge as merge_intervals +from .store import HistoryCache, HotStore, merge as merge_intervals, subtract log = logging.getLogger(__name__) @@ -92,7 +92,7 @@ async def get(self, cycle: str, start: datetime, end: datetime, refresh: bool = async def _read(self, cycle: str, s: int, e: int, load: bool, fetch: bool, refresh: bool = False) -> dict | None: """[s, e] (epoch seconds) from the cache, its newest readings from memory, and (fetch) what neither holds from Ecowitt, which is then cached and kept in memory. None, with nothing counted, when fetch is False and the cache and memory lack some of it.""" - missing = {g: await asyncio.to_thread(self.cache.missing, self.mac, cycle, g, s, e) for g in self.groups} + (missing,) = await self._missing(cycle, [(s, e)]) gaps = merge_intervals([iv for ivs in missing.values() for iv in ivs]) fresh: dict = {} from_memory = 0 @@ -141,13 +141,20 @@ async def read(self, fn, *args): """fn(*args), a read of the cache's own (slots, slot ranges), in a thread.""" return await asyncio.to_thread(fn, *args) - @timed + 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] + async def covered(self, cycle: str, start: int, end: int) -> bool: """True if the cache holds every group for [start, end] (epoch seconds).""" - for g in self.groups: - if await asyncio.to_thread(self.cache.missing, self.mac, cycle, g, start, end): - return False - return bool(self.groups) + return (await self.covered_each(cycle, [(start, end)]))[0] + + @timed + async def covered_each(self, cycle: str, windows: list[tuple[int, int]]) -> list[bool]: + """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: """Finest cycle finer than `current` available for this window: still kept by Ecowitt, diff --git a/lib/ecowitt/query.py b/lib/ecowitt/query.py index 70cdf77..6052a0b 100644 --- a/lib/ecowitt/query.py +++ b/lib/ecowitt/query.py @@ -13,7 +13,7 @@ import asyncio import json import logging -from datetime import datetime, time, timedelta, UTC +from datetime import date, datetime, time, timedelta, UTC from statistics import fmean from ..captions import AVERAGE_CHART_HINT, CHART_HINT, STACK_CHART_HINT, DIRECTION_CHART_HINT, wants_chart @@ -51,6 +51,17 @@ DERIVED = ("feels_like", "app_temp", "app_tempin", "dew_point", "vpd") +def consecutive(days: list[date]) -> list[list[date]]: + """Sorted days in runs of consecutive ones: [1, 2, 3, 5, 6] -> [[1, 2, 3], [5, 6]].""" + runs: list[list[date]] = [] + for day in days: + if runs and day - runs[-1][-1] == timedelta(days=1): + runs[-1].append(day) + else: + runs.append([day]) + return runs + + 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 [] @@ -150,10 +161,7 @@ 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 - for t, e in spans("30min", self.start, self.end - timedelta(days=1)): - if not await f.covered("30min", f.epoch(t), f.epoch(e)): - return False - return True + 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))])) async def _chunks(self, cycle: str, t: datetime, until: datetime): for a, b in spans(cycle, t, until): @@ -195,15 +203,18 @@ async def _summarise_direction(self): f = self.f wind = self.wind = Fetcher(f.api, f.cache, f.hot, f.mac, ["wind"], self.tz, f.urgent) first = max(self.start, self.end - timedelta(days=DIRECTION_DAYS)) + bounds = {} # each day of the period: its part of it, local times + for n in range((self.end.date() - first.date()).days + 1): + day = first.date() + timedelta(days=n) + a, b = max(first, datetime.combine(day, time())), min(self.end, datetime.combine(day, time(23, 59, 59))) + 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: 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") for key in keys: - fine: dict = {} - fine_days = set() - for n in range((self.end.date() - first.date()).days + 1): - day = first.date() + timedelta(days=n) - a, b = max(first, datetime.combine(day, time())), min(self.end, datetime.combine(day, time(23, 59, 59))) - if a < b and (day == self.end.date() or await wind.covered("5min", wind.epoch(a), wind.epoch(b))): - collect(fine, await wind.get("5min", a, b), "5min") # today's comes from memory or one request - fine_days.add(day) pts = self.store.pop(key)["pts"] speed_key = key[:-len("wind_direction")] + "wind_speed" speeds = {t: r["value"][0] for src in (self.store, fine) for t, r in src.get(speed_key, {}).get("pts", {}).items() @@ -228,8 +239,11 @@ def _monthly_extremes(self) -> dict: out = {} for key, series in self.store.items(): months: dict = {} + until = None # the end of the local day the reading falls in (epoch), its month worked out once a day for ts, rec in sorted(series["pts"].items()): - month = datetime.fromtimestamp(ts, UTC).astimezone(self.tz).strftime("%b %Y") + 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] fold(months.setdefault(month, {}), ts, rec) out[key] = months return out diff --git a/lib/ecowitt/store.py b/lib/ecowitt/store.py index 80262d6..27dc46d 100644 --- a/lib/ecowitt/store.py +++ b/lib/ecowitt/store.py @@ -133,6 +133,14 @@ 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 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 240e8c3..557865b 100644 --- a/tests/test_ecowitt.py +++ b/tests/test_ecowitt.py @@ -492,6 +492,29 @@ async def test_history_reports_direction_by_compass_point_not_a_range(tmp_path, await eco.close() +async def test_the_wind_rose_reads_its_5_minute_readings_in_runs_of_days_not_a_day_at_a_time(tmp_path, archived_cache, monkeypatch): + from lib.ecowitt import query + from lib.ecowitt.query import consecutive + eco, _ = await archived_station(tmp_path, archived_cache) + made = [] + + class Recorded(query.Fetcher): # the wind rose's own fetcher, whose reads are counted + def __init__(self, *a, **k): + super().__init__(*a, **k) + made.append(self) + monkeypatch.setattr(query, "Fetcher", Recorded) + today = eco.now().date() + turn = Turn(chart_field="wind") + out = json.loads(await eco.tools[1].handler({"groups": "wind", "chart": True, "chart_field": "wind", + "start_date": f"{today - timedelta(days=30)} 00:00:00"}, turn)) + assert out["series"]["wind.wind_direction"] and turn.charts[0].compass + assert len(made) == 1 and made[0].ranges == 1 # the archived days and today in one read, not one for each of 31 days + await eco.close() + day = lambda n: today + timedelta(days=n) + assert consecutive([day(1), day(2), day(3), day(5), day(6), day(9)]) == [[day(1), day(2), day(3)], [day(5), day(6)], [day(9)]] + assert consecutive([]) == [] + + async def test_the_wind_chart_carries_the_compass_beside_the_speed_line(tmp_path, archived_cache): from lib.charts import render eco, _ = await archived_station(tmp_path, archived_cache) diff --git a/tests/test_store.py b/tests/test_store.py index 7872a5b..ce5053f 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -27,6 +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): + 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"] == [] + + 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 From eb2c922e207260dfb3a12126e2c6405c8419d23f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:50:00 +0000 Subject: [PATCH 07/22] Send each chart picture once: Telegram's copy (its file id) for every later send - Alerts: a chart alert is uploaded to the first chat only; the others are sent the file id Telegram gave it, so one upload serves every chat. The text-alone fallback is unchanged. - Period buttons: the image cache keeps each chart's file id once it has been sent (the reply, or a redraw in place). Toggling back to a period drawn lately edits the message by that id, no upload. If Telegram refuses, it is drawn again, as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/alerts/notify.py | 32 ++++++++++++++++++-------------- lib/bot.py | 24 ++++++++++++++---------- lib/periods.py | 27 ++++++++++++++++++++++----- tests/test_alert_menu.py | 12 ++++++++++++ tests/test_periods.py | 23 +++++++++++++++++++++++ 5 files changed, 89 insertions(+), 29 deletions(-) diff --git a/lib/alerts/notify.py b/lib/alerts/notify.py index 7f81d08..dc2bbf0 100644 --- a/lib/alerts/notify.py +++ b/lib/alerts/notify.py @@ -195,13 +195,18 @@ def __init__(self, bot, state: AlertState, kinds: list[str] | None = None): """kinds: the alert types this bot sends (the settings buttons under each alert list them).""" self.bot, self.state, self.kinds = bot, state, kinds or list(LABELS) - async def __call__(self, text: str, link: tuple[str, str] | None = None, kind: str | None = None, photo: bytes | None = None): + 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. - photo: a picture the text is the caption of (the text alone if Telegram refuses the picture).""" + 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) - started = time.monotonic() - sent = sum([await self._send(chat_id, text, entities, kind, photo) for chat_id in self.state.alert_chats(kind)]) + 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) + 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.replace("\n", " ")) async def to_chat(self, chat_id: int, text: str, link: tuple[str, str] | None = None, kind: str | None = None) -> bool: @@ -210,13 +215,13 @@ async def to_chat(self, chat_id: int, text: str, link: tuple[str, str] | None = if not self.state.subscribed(chat_id, kind): return False text, entities = with_footer(text, link) - sent = await self._send(chat_id, text, entities, kind) + 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.replace("\n", " ")) return sent - async def _post(self, chat_id: int, text: str, entities, kind: str | None, photo: bytes | None): - """The message itself: silent unless the chat turned sound on for this type, with the settings buttons (which highlight this alert's own - type). With a photo it is the picture with the text as its caption; a picture Telegram refuses falls back to the text alone.""" + async def _post(self, chat_id: int, text: str, entities, kind: str | None, photo: bytes | str | None): + """The message itself (returned): silent unless the chat turned sound on for this type, with the settings buttons (which highlight this + alert's own type). With a photo it is the picture with the text as its caption; a picture Telegram refuses falls back to the text alone.""" common = dict(disable_notification=kind not in self.state.loud(chat_id), reply_markup=keyboard(self.state.muted(chat_id), self.kinds, parent=kind, loud=self.state.loud(chat_id) if chat_id > 0 else None)) if photo: @@ -228,14 +233,13 @@ 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 | None = None) -> bool: - """Send an alert (see _post); forget a chat the bot can no longer post to.""" + 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)""" try: - await self._post(chat_id, text, entities, kind, photo) - return True + 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 + 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: @@ -245,4 +249,4 @@ async def _send(self, chat_id: int, text: str, entities, kind: str | None = None log.warning("Alert to %s failed: %s", chat_id, e) except TelegramError as e: log.warning("Alert to %s failed: %s", chat_id, e) - return False + return False, None diff --git a/lib/bot.py b/lib/bot.py index 8f404f8..f6879ad 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -604,23 +604,25 @@ async def on_period_button(self, update: Update, context: ContextTypes.DEFAULT_T await query.answer(f"Drawing {periods.NAMES[days]}...") asked = intent.with_period(question, days, now) log.info("Period button: %s -> %s", _short(question, 40), _short(asked, 40)) - if cached := self.images.get(self.images.key(asked, days, now)): # drawn lately: no new question - png, title = cached + key = self.images.key(asked, days, now) + if cached := self.images.get(key): # drawn lately: no new question + photo, title = cached try: began = time.monotonic() - await message.edit_media(InputMediaPhoto(png, caption=title or None), reply_markup=periods.keyboard(days)) + self.images.sent(key, await message.edit_media(InputMediaPhoto(photo, caption=title or None), reply_markup=periods.keyboard(days))) self.charted.remember(message.chat_id, message.message_id, asked) - log.info("Period button: chart from the cache, sent in %.0f ms", (time.monotonic() - began) * 1000) + log.info("Period button: chart from the cache, sent in %.0f ms%s", (time.monotonic() - began) * 1000, + " (Telegram's copy)" if isinstance(photo, str) else "") return except TelegramError as e: log.warning("Couldn't edit the chart in place (%s); drawing it again", e) await self.respond(update, context, asked, redraw=message) - async def _redraw(self, message: Message, reply: str, photo: bytes, title: str, row, question: str) -> bool: + async def _redraw(self, message: Message, reply: str, photo: bytes, title: str, row, question: str, key: str | None = None) -> bool: """Put a new chart into the message a period button was pressed under (its answer text, if any, follows as a message of - its own). False when Telegram refuses, and the chart is then sent as a new message.""" + its own). False when Telegram refuses, and the chart is then sent as a new message. key: where the image cache keeps it.""" try: - await message.edit_media(InputMediaPhoto(photo, caption=title or None), reply_markup=row) + self.images.sent(key, await message.edit_media(InputMediaPhoto(photo, caption=title or None), reply_markup=row)) except BadRequest as e: if "not modified" not in str(e).lower(): log.warning("Couldn't edit the chart in place (%s); sending a new one", e) @@ -909,11 +911,12 @@ async def respond(self, update: Update, context: ContextTypes.DEFAULT_TYPE, text 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) - if row and ok and not reply.strip(): # a chart and nothing else: keep it for the period buttons - self.images.put(self.images.key(text, intent.period_days(text, now), now), photos[0], titles[0]) + kept = self.images.key(text, intent.period_days(text, now), now) if row and ok and not reply.strip() else None + if kept: # a chart and nothing else: keep it for the period buttons + self.images.put(kept, photos[0], titles[0]) try: began = time.monotonic() - if redraw is not None and row and await self._redraw(redraw, reply, photos[0], titles[0], row, text): + 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), @@ -923,6 +926,7 @@ async def respond(self, update: Update, context: ContextTypes.DEFAULT_TYPE, text log.info("Chart times: %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 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) if carried_keyboard: diff --git a/lib/periods.py b/lib/periods.py index edc13ff..45a704a 100644 --- a/lib/periods.py +++ b/lib/periods.py @@ -2,6 +2,7 @@ for that period (Bot.on_period_button); the question behind each chart is remembered for that.""" from collections import OrderedDict +from dataclasses import dataclass from telegram import InlineKeyboardButton, InlineKeyboardMarkup @@ -59,9 +60,19 @@ def remember(self, chat_id: int, message_id: int, question: str): self.save() +@dataclass +class Drawn: + """A chart in the ImageCache.""" + at: float # when it was drawn (the cache's clock) + png: bytes + title: str + file_id: str | None = None # Telegram's copy, once the chart has been sent: it is sent again by this, not uploaded + + class ImageCache: """The charts drawn lately, by question: toggling between the period buttons brings a chart back at once instead of drawing it - again. A chart is kept CHART_TTL seconds (the readings move slowly) and the newest CHART_IMAGES stay.""" + again, and once it has been sent, as Telegram's copy instead of an upload. A chart is kept CHART_TTL seconds (the readings move + slowly) and the newest CHART_IMAGES stay.""" CHART_TTL = 10 * 60 CHART_IMAGES = 60 @@ -77,16 +88,22 @@ def key(question: str, days: int, now) -> str: from . import intent return intent.with_period(question, days, now).lower() - def get(self, key: str) -> tuple[bytes, str] | None: + 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[0] > self.CHART_TTL: + if entry is None or self.clock() - entry.at > self.CHART_TTL: self.items.pop(key, None) return None self.items.move_to_end(key) - return entry[1], entry[2] + return entry.file_id or entry.png, entry.title def put(self, key: str, png: bytes, title: str): - self.items[key] = (self.clock(), png, title) + self.items[key] = Drawn(self.clock(), png, title) self.items.move_to_end(key) while len(self.items) > self.CHART_IMAGES: self.items.popitem(last=False) + + def sent(self, key: str | None, message): + """`message` (as Telegram returned it) carries the chart kept under `key`: later sends of it use Telegram's copy.""" + if (entry := self.items.get(key)) is not None and (sizes := getattr(message, "photo", None)): + entry.file_id = sizes[-1].file_id # the largest size: the picture as it was sent diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index 19a9e5f..582e483 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -489,6 +489,18 @@ async def test_an_alert_with_a_picture_is_the_picture_with_the_text_as_its_capti assert rows(kw["reply_markup"])[0][0] == ("➕ Sub ▸", "al:open:sub:temps") # the alert's own buttons ride along +async def test_an_alerts_picture_is_uploaded_once_and_the_other_chats_are_sent_telegrams_copy(tmp_path): + class Keeps(SentWithPhotos): # Telegram answers with the photo it now holds, in its sizes (the largest last) + async def send_photo(self, chat_id, photo, **kw): + await super().send_photo(chat_id, photo, **kw) + return NS(photo=(NS(file_id="small"), NS(file_id="large"))) + state, bot = AlertState(tmp_path / "s.json"), Keeps() + for chat in (1, 2, 3): + state.add_chat(chat, str(chat)) + await Notifier(bot, state, KINDS)("It's warmer outside", kind="temps", photo=b"png") + 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): from telegram.error import BadRequest state, bot = AlertState(tmp_path / "s.json"), SentWithPhotos(refuse=BadRequest("Wrong file identifier")) diff --git a/tests/test_periods.py b/tests/test_periods.py index 27291c4..b23b27c 100644 --- a/tests/test_periods.py +++ b/tests/test_periods.py @@ -169,6 +169,29 @@ 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 reply_photo(photo, caption=None, **kw): + uploads.append(photo) + return NS(message_id=101, photo=(NS(file_id="small"), NS(file_id="7d"))) + + async def edit_media(media, reply_markup=None): + edits.append(media.media if isinstance(media.media, str) else "upload") + return NS(photo=(NS(file_id="small"), NS(file_id=f"edit {len(edits)}"))) + msg = NS(chat_id=1, message_id=101, message_thread_id=None, is_topic_message=False, reply_photo=reply_photo, edit_media=edit_media, + 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")) + await bot.respond(update, CONTEXT, "Temperature chart 7d") + + async def answer(text=None): + pass + for days in (30, 7, 30): + await bot.on_period_button(NS(callback_query=NS(data=f"pd:{days}", message=msg, answer=answer), effective_message=msg, + effective_chat=msg.chat, effective_user=NS(username="rob", full_name="Rob")), CONTEXT) + 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) actions = [] From b8bf45704309bf16011ff79fb722befd48c0cff7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:59:43 +0000 Subject: [PATCH 08/22] Draw the button charts ahead every 5 minutes: a press is sent one at once - Bot.predraw draws every chart the buttons ask for (made in code) and keeps it in the image cache with its write-up. It runs every 5 minutes after the startup warm-up, one chart at a time, and never while a question is being answered, so it can't slow one down. - Answering from it: a chart question whose words are exactly its key ("Temperature chart 1d", "Wind chart 7d") is sent the chart drawn for those words in the last 5.5 minutes (ahead, or for someone's same question), by Telegram's copy once it has one. Its write-up goes into the chat's memory as a drawn one's does. Other words for the same period ("temperature chart today") are drawn as before. - Background turns (Turn.ahead): their Ecowitt requests aren't urgent, and their routine log lines (tool calls, the history summaries) are at DEBUG. The first round logs one line; a chart that can't be drawn ahead is logged once, until it can again. - Alerts still draw their own chart: a picture up to 5 minutes old could miss the reading the alert is about. A button press drawn ahead takes 4-8 ms of the bot's time instead of 120-340 ms; the log says "Chart times: drawn 40s before, sending 6 ms (Telegram's copy)". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 4 ++- ecobot.py | 3 +- lib/airgradient/source.py | 4 +-- lib/bot.py | 68 ++++++++++++++++++++++++++++++++++++--- lib/ecowitt/query.py | 6 ++-- lib/ecowitt/source.py | 2 +- lib/periods.py | 37 ++++++++++++++++++--- lib/tools.py | 6 ++-- tests/test_ecowitt.py | 4 +++ tests/test_periods.py | 63 ++++++++++++++++++++++++++++++++++++ 10 files changed, 178 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 342d0a8..fd4b03c 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,9 @@ Both are off by default. They are websites, not APIs (the pollen page is parsed) - The model only reasons for predictions ("will it rain?") and "describe it" questions, or when asked to think, try, reason, predict, estimate, grind or whirl. - Simple highs/lows, chart and "air quality now" questions are fetched by the bot first, so the model is called once, at the end, just to word the answer. -- Recent readings are prefetched when a question arrives and refreshed every 4 minutes. +- Recent readings are kept in memory, refreshed every minute (the weather station) and every 4 minutes (the air sensor); a question that may need them starts a refresh as it arrives (a chart made in code doesn't: it reads just what it needs). A question never waits for a refresh in flight when the cache and memory already hold what it needs, and its own Ecowitt requests go before the background ones. +- The chart buttons' charts are drawn ahead every 5 minutes, one at a time and never while a question is being answered: a press is sent the one drawn in the last 5½ minutes at once (its write-up kept in the chat's memory, as for one drawn on the spot); an alert's chart is still drawn when it goes out, so it shows the reading the alert is about. A chart is uploaded once: sending it again (to the next chat an alert goes to, toggling back to a period drawn lately, the next press of a button) uses Telegram's copy. +- Each chart reply logs where its time went: `Chart times: data 12 ms, calculation 6 ms, drawing 112 ms, PNG 41 ms (22 KB), sending 812 ms`, or `drawn 40s before, sending 210 ms (Telegram's copy)` for one drawn ahead. - The whole history of both sensors is kept in SQLite (`ecowitt_cache.sqlite`, `airgradient_cache.sqlite`): a background archive copies every Ecowitt cycle (5-minute, 30-minute, 4-hour, daily) before Ecowitt expires it, and backfills AirGradient day by day to where the sensor's diff --git a/ecobot.py b/ecobot.py index 903eda0..ffd9a1c 100644 --- a/ecobot.py +++ b/ecobot.py @@ -25,7 +25,7 @@ from lib.alerts import (PARTICULATES_CHECK_SECONDS, AlertState, ForecastMonitor, IrrigationMonitor, Notifier, ParticulatesMonitor, PollenAndAsthmaMonitor, WeatherMonitor) from lib.alerts.menu import LABELS, kinds_for -from lib.bot import Bot, polling_error +from lib.bot import PREDRAW_SECONDS, Bot, polling_error from lib.ecowitt import Archive, Ecowitt from lib.forecast import Forecast from lib.irrigation import Tuya @@ -223,6 +223,7 @@ async def air_backfill(): log.exception("Startup warm-up failed (the regular refreshes will catch up)") log.info("Startup warm-up: %s, %.1fs", ", ".join(parts) or "nothing to fetch", time.monotonic() - started) + 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("%s", wrapped("Alerts: ", [LABELS[k] for k in on] or ["none"], f": {len(state.alert_chats())} chats")) if missing := [LABELS[k] for k in kinds if k not in running]: diff --git a/lib/airgradient/source.py b/lib/airgradient/source.py index 9fa7dcc..b765079 100644 --- a/lib/airgradient/source.py +++ b/lib/airgradient/source.py @@ -325,8 +325,8 @@ 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 - log.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) + 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}", "readings": len(rows)} for name, (_, unit) in METRICS.items(): diff --git a/lib/bot.py b/lib/bot.py index f6879ad..ea95821 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -47,6 +47,7 @@ STALE_BUTTON = "That button is out of date, please ask again" MAX_CALLBACK_BYTES = 64 # Telegram's own limit on a button's data WATCHDOG_SECONDS = 45 # a question still running after this many seconds logs where everything is waiting +PREDRAW_SECONDS = 300 # the button charts are drawn ahead this often (a press finds one drawn within periods.ImageCache.FRESH) HELP = ("Hi! Message me directly, or in groups @mention me or reply to me.\n" "/reset clears this chat's memory, /alerts opens the alert settings (buttons to subscribe or unsubscribe), /usage shows what I can do.\n" @@ -243,7 +244,12 @@ def __init__(self, cfg: Config, agent: Agent, sources: list, state: AlertState | self.admins = Admins(state, getattr(cfg, "admin_chat_ids", ())) self.chats: dict[tuple, ChatState] = defaultdict(ChatState) self.by_name = {s.name: s for s in sources} - self.images = periods.ImageCache() # charts drawn lately, so toggling the period buttons is instant + self.images = periods.ImageCache() # charts drawn lately, so toggling the period buttons is instant (and the buttons' drawn ahead) + self.answering = 0 # questions being answered right now: charts are drawn ahead only when there are none + self.idle = asyncio.Event() + self.idle.set() + self.predrawn = False # the first round of charts drawn ahead has been logged + self.predraw_failing: set[str] = set() # button charts that couldn't be drawn ahead (said once, until they can again) 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) def register(self, app): @@ -822,7 +828,52 @@ async def _ask_model(self, working: list[dict], system: str, read, turn: Turn, d working[:] = before # the unfinished attempt's turns are dropped effort, budget, retried = lower, RETRY_SECONDS, True + async def predraw(self): + """Draw the charts the buttons ask for ahead of the press: one pressed in the next ImageCache.FRESH seconds is sent at once. + One chart at a time, and never while a question is being answered (its own drawing would wait behind these). Run every + PREDRAW_SECONDS; the first round is logged, and a chart that can't be drawn once, until it can again.""" + started, done = time.monotonic(), [] + for text in dict.fromkeys(question for row in templates.rows(self.irrigation is not None) for _, question in row if question): + await self.idle.wait() + 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) + 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 + continue + 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) + try: + 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") + png = await asyncio.to_thread(render_chart, turn.charts[0], self.cfg.tz) + except Exception as e: + if text not in self.predraw_failing: + self.predraw_failing.add(text) + log.warning("Couldn't draw %r ahead (%s: %s); pressing it draws it", text, type(e).__name__, e) + continue + self.images.put(key, png, turn.charts[0].caption(now.date()), caption) + done.append(text) + if text in self.predraw_failing: + self.predraw_failing.discard(text) + log.info("Drawn ahead again: %s", text) + if not self.predrawn: + self.predrawn = True + log.info("Button charts drawn ahead every %ds: %s (%.1fs)", PREDRAW_SECONDS, ", ".join(done) or "none", time.monotonic() - started) + async def respond(self, update: Update, context: ContextTypes.DEFAULT_TYPE, text: str, redraw: Message | None = None): + """Answer a question (_respond), counted while it runs: no chart is drawn ahead meanwhile.""" + self.answering += 1 + self.idle.clear() + try: + await self._respond(update, context, text, redraw) + finally: + self.answering -= 1 + if not self.answering: + self.idle.set() + + async def _respond(self, update: Update, context: ContextTypes.DEFAULT_TYPE, text: str, redraw: Message | None = None): msg = update.effective_message text = text.strip() if not text: @@ -856,9 +907,12 @@ async def respond(self, update: Update, context: ContextTypes.DEFAULT_TYPE, text 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) + ready = self.images.ready(text, now) if read.fast and read.chart_in_code and redraw is None else None try: reply = None - if 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) + 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 @@ -897,6 +951,7 @@ async def respond(self, update: Update, context: ContextTypes.DEFAULT_TYPE, text 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: reply = "" if ok and not reply.strip() and not photos: # a chart with no text that could not be drawn @@ -912,8 +967,8 @@ async def respond(self, update: Update, context: ContextTypes.DEFAULT_TYPE, text 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: # a chart and nothing else: keep it for the period buttons - self.images.put(kept, photos[0], titles[0]) + 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) + 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): @@ -922,7 +977,10 @@ async def respond(self, update: Update, context: ContextTypes.DEFAULT_TYPE, text 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 drawn: + if ready: + log.info("Chart times: drawn %.0fs before, sending %.0f ms%s", self.images.age(ready), (time.monotonic() - began) * 1000, + " (Telegram's copy)" if isinstance(drawn[0], str) else "") + elif drawn: log.info("Chart times: %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) diff --git a/lib/ecowitt/query.py b/lib/ecowitt/query.py index 6052a0b..e23b4e0 100644 --- a/lib/ecowitt/query.py +++ b/lib/ecowitt/query.py @@ -319,9 +319,9 @@ def _answer(self, refined: int) -> str: if key.split(".", 1)[-1] not in DERIVED or key.split(".", 1)[-1] in wanted} # the rest: cached, not sent series_out.update(self.direction) shown = {k: v for k, v in series_out.items() if self._in_focus(k)} # what the model is sent (charts use all of series_out) - log.info("%s to %s: %d ranges, %d cached, %d in mem, %d req%s, %d series, %d refined", - f"{self.start:%Y-%m-%d}", f"{self.end:%m-%d %H:%M}", f.ranges, f.from_cache, f.from_memory, f.calls, - f", {len(f.errors)} failed" if f.errors else "", len(series_out), refined) + log.log(logging.DEBUG if self.turn.ahead else logging.INFO, "%s to %s: %d ranges, %d cached, %d in mem, %d req%s, %d series, %d refined", + f"{self.start:%Y-%m-%d}", f"{self.end:%m-%d %H:%M}", f.ranges, f.from_cache, f.from_memory, f.calls, + f", {len(f.errors)} failed" if f.errors else "", len(series_out), 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 " diff --git a/lib/ecowitt/source.py b/lib/ecowitt/source.py index 3a2a1c0..a3e74cb 100644 --- a/lib/ecowitt/source.py +++ b/lib/ecowitt/source.py @@ -179,7 +179,7 @@ async def _history(self, args: dict, turn: Turn | None = None) -> str: 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: groups.append(r.group) - return await HistoryQuery(self.fetcher(groups, urgent=True), args, turn).run() + return await HistoryQuery(self.fetcher(groups, urgent=not turn.ahead), args, turn).run() async def _days(self, args: dict, turn: Turn | None = None) -> str: return await days_tool(self.cache, self.mac, self.tz, args, turn) diff --git a/lib/periods.py b/lib/periods.py index 45a704a..b07531d 100644 --- a/lib/periods.py +++ b/lib/periods.py @@ -67,15 +67,22 @@ class Drawn: png: bytes title: str file_id: str | None = None # Telegram's copy, once the chart has been sent: it is sent again by this, not uploaded + caption: str | None = None # the answer's text (kept in the chat's history), when it was made in code for exactly the key's words + + @property + def photo(self) -> bytes | str: + return self.file_id or self.png class ImageCache: """The charts drawn lately, by question: toggling between the period buttons brings a chart back at once instead of drawing it again, and once it has been sent, as Telegram's copy instead of an upload. A chart is kept CHART_TTL seconds (the readings move - slowly) and the newest CHART_IMAGES stay.""" + slowly) and the newest CHART_IMAGES stay. The button charts are also drawn ahead (Bot.predraw): one drawn in the last FRESH + seconds is the answer to its question (ready).""" CHART_TTL = 10 * 60 CHART_IMAGES = 60 + 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 @@ -88,6 +95,14 @@ def key(question: str, days: int, now) -> str: 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 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) @@ -95,10 +110,24 @@ def get(self, key: str) -> tuple[bytes | str, str] | None: self.items.pop(key, None) return None self.items.move_to_end(key) - return entry.file_id or entry.png, entry.title + return entry.photo, entry.title + + 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: + return None + self.items.move_to_end(key) + return entry + + def age(self, entry: Drawn) -> float: + return self.clock() - entry.at - def put(self, key: str, png: bytes, title: str): - self.items[key] = Drawn(self.clock(), png, title) + def put(self, key: str, png: bytes, title: str, caption: str | None = None): + """caption: the answer's text, only for a chart made in code for its canonical question (then it can be ready).""" + self.items[key] = Drawn(self.clock(), png, title, caption=caption) self.items.move_to_end(key) while len(self.items) > self.CHART_IMAGES: self.items.popitem(last=False) diff --git a/lib/tools.py b/lib/tools.py index 899885a..dad7516 100644 --- a/lib/tools.py +++ b/lib/tools.py @@ -26,6 +26,7 @@ class Turn: result_chars: int = 0 # the size of every tool result so far this question (what the model will be sent) forecast_shown: list = field(default_factory=list) # the forecast days the answer includes, remembered once it is sent 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 @dataclass @@ -58,7 +59,8 @@ async def call(self, name: str, raw_args: str, turn: Turn | None = None) -> str: args = json.loads(raw_args) if raw_args else {} except json.JSONDecodeError as e: return f"Error: tool arguments were not valid JSON ({e})" - log.info("Tool call %s %s", name, raw_args[:300]) + level = logging.DEBUG if turn and turn.ahead else logging.INFO + log.log(level, "Tool call %s %s", name, raw_args[:300]) try: out = await asyncio.wait_for(tool.handler(args, turn or Turn()), TOOL_TIMEOUT) except TimeoutError: @@ -70,7 +72,7 @@ async def call(self, name: str, raw_args: str, turn: Turn | None = None) -> str: if len(out) > MAX_OUTPUT_CHARS: log.warning("Tool %s output truncated: %d chars", name, len(out)) out = out[:MAX_OUTPUT_CHARS] + "\n...[truncated - request a shorter range]" - log.info("Tool result %s: %s", name, out if len(out) < 90 else f"{len(out)} chars") + log.log(level, "Tool result %s: %s", name, out if len(out) < 90 else f"{len(out)} chars") if turn: turn.result_chars += len(out) return out diff --git a/tests/test_ecowitt.py b/tests/test_ecowitt.py index 557865b..bfc0d10 100644 --- a/tests/test_ecowitt.py +++ b/tests/test_ecowitt.py @@ -193,6 +193,10 @@ async def watch(path, urgent=False, **params): await eco.tools[1].handler({"groups": "outdoor,wind", "start_date": f"{today - timedelta(days=20)} 00:00:00", "end_date": f"{today - timedelta(days=10)} 23:59:59"}, Turn()) assert marked and all(marked) # the question's own, and its wind rose's + marked.clear() + await eco.tools[1].handler({"groups": "outdoor,wind", "start_date": f"{today - timedelta(days=40)} 00:00:00", + "end_date": f"{today - timedelta(days=30)} 23:59:59"}, Turn(ahead=True)) + assert marked and not any(marked) # a chart drawn ahead: nobody is waiting for it async def test_the_refresh_builds_no_result_it_would_not_read(station, monkeypatch): diff --git a/tests/test_periods.py b/tests/test_periods.py index b23b27c..8e5210a 100644 --- a/tests/test_periods.py +++ b/tests/test_periods.py @@ -209,3 +209,66 @@ async def draft(*a, **k): assert actions == [] and drafts == [] await bot.respond(update, context, "Temperature chart 7d") # a question of its own still does assert actions + + +def pressed(bot, sent): + """A private chat's message whose photos land in `sent` as (photo, caption).""" + async def reply_photo(photo, caption=None, **kw): + sent.append((photo, caption)) + return NS(message_id=100 + len(sent), photo=(NS(file_id="small"), NS(file_id=f"copy {len(sent)}"))) + msg = NS(chat_id=1, message_id=50, message_thread_id=None, is_topic_message=False, reply_photo=reply_photo, + chat=NS(type="private", title=None), from_user=NS(full_name="Rob"), reply_to_message=None) + 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), [] + with caplog.at_level("INFO"): + await bot.predraw() + calls = len(bot.agent.tools.calls) + assert calls >= 1 and all(name == "weather_history" for name, _ in bot.agent.tools.calls) # the station's buttons (no air sensor here) + assert [r.getMessage() for r in caplog.records if r.getMessage().startswith("Tool ")] == [] # quiet: nobody asked + assert any(r.getMessage().startswith("Button charts drawn ahead every 300s: ") for r in caplog.records) + update = pressed(bot, sent) + await bot.respond(update, CONTEXT, "Temperature chart 1d") + assert len(bot.agent.tools.calls) == calls and sent == [(b"png", "Temperature")] # no new question: the one drawn ahead + history = bot.chats[(1, None)].history + assert history[-1]["role"] == "assistant" and history[-1]["content"] # its text is kept, as a drawn one's is + await bot.respond(update, CONTEXT, "Temperature chart 1d") + assert len(bot.agent.tools.calls) == calls and sent[-1] == ("copy 1", "Temperature") # and once sent, by Telegram's copy + caplog.clear() + with caplog.at_level("INFO"): + await bot.predraw() + 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): + clock = [0.0] + bot, sent = make_bot(monkeypatch), [] + bot.images = periods.ImageCache(lambda: clock[0]) + await bot.predraw() + calls = len(bot.agent.tools.calls) + await bot.respond(pressed(bot, sent), CONTEXT, "temperature chart today") # kept under the 1d key, but since midnight: drawn + assert len(bot.agent.tools.calls) == calls + 1 + await bot.predraw() + calls = len(bot.agent.tools.calls) + clock[0] = periods.ImageCache.FRESH + 1 + await bot.respond(pressed(bot, sent), CONTEXT, "Temperature chart 1d") # too old to send as it is + assert len(bot.agent.tools.calls) == calls + 1 + + +async def test_charts_are_not_drawn_ahead_while_a_question_is_being_answered(monkeypatch): + import asyncio + bot = make_bot(monkeypatch) + bot.answering = 1 + bot.idle.clear() + drawing = asyncio.create_task(bot.predraw()) + await asyncio.sleep(0.05) + assert bot.agent.tools.calls == [] + bot.answering = 0 + bot.idle.set() + await drawing + assert bot.agent.tools.calls + await bot.respond(pressed(bot, []), CONTEXT, "Temperature chart 7d") + assert bot.answering == 0 and bot.idle.is_set() # counted while it ran, and let go after + From 68fbfc429ff96c4850152d8a9d3378e6ea08732a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 18:03:36 +0000 Subject: [PATCH 09/22] Ecowitt's request queue: a request cancelled just as the one ahead finishes is a clean cancel If a queued request was cancelled (its question timed out, or shutdown) and the request ahead finished before it had seen the cancel, the release had already dropped it from the queue, and removing it again raised "ValueError: is not in deque" instead of the cancel. Now it is only removed when it is still there. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/ecowitt/api.py | 2 +- tests/test_ecowitt.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/ecowitt/api.py b/lib/ecowitt/api.py index fbf7055..8c9e6aa 100644 --- a/lib/ecowitt/api.py +++ b/lib/ecowitt/api.py @@ -107,7 +107,7 @@ async def acquire(self, urgent: bool = False): except asyncio.CancelledError: if waiter.done() and not waiter.cancelled(): # the turn was already handed to us: pass it on self.release() - else: + elif waiter in self._queues[urgent]: # (a release since the cancel has already dropped it) self._queues[urgent].remove(waiter) raise diff --git a/tests/test_ecowitt.py b/tests/test_ecowitt.py index bfc0d10..9a9c5ad 100644 --- a/tests/test_ecowitt.py +++ b/tests/test_ecowitt.py @@ -1206,6 +1206,20 @@ async def test_a_cancelled_waiter_does_not_block_the_lock(): lock.release() +async def test_a_waiter_cancelled_just_as_the_request_ahead_finishes_is_a_clean_cancel(): + from lib.ecowitt.api import _TurnLock + lock = _TurnLock() + await lock.acquire() # a request in flight + waiting = asyncio.create_task(lock.acquire()) + await asyncio.sleep(0) # one queued behind it + waiting.cancel() # its question gives up ... + lock.release() # ... and the request ahead finishes before it has seen that + with pytest.raises(asyncio.CancelledError): + await waiting # cancelled, as asked (not a ValueError from the queue) + await asyncio.wait_for(lock.acquire(), 1) # and the lock is free + lock.release() + + async def test_weather_now_is_served_from_the_minute_old_live_reading_and_asks_afresh_when_it_cannot(station): import time eco, fake = station From 5d96d6997f5115f035d39bf3068e1fbe1637c9b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 18:19:36 +0000 Subject: [PATCH 10/22] LOG_WIDTH: long log lines quoting a request or a chat message are cut to 71 characters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A log line that quotes something (what was asked, the tool request and its short result, the reply, an alert, the heads-up) is cut to LOG_WIDTH characters, default 71, an ellipsis (…) marking the cut; LOG_WIDTH=0 cuts none. - Only the quote is cut. What comes before it on the line is kept whole, and so is every other line (timings, counts, warnings, tracebacks). - The quote is put on one line. - The fixed cuts these lines had (200, 300, 40, 30 characters) are gone, so 0 really shows everything. - The period button's line now names the press before the question ("Period button 3M: Temperature chart 30d"), so the cut can't take the new period. The log formatter moves to lib/logs.py with the cut; ecobot.py gives it the width once the settings are read. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 2 ++ ecobot.py | 10 ++-------- lib/alerts/notify.py | 5 +++-- lib/bot.py | 13 +++++++------ lib/config.py | 7 +++++++ lib/logs.py | 31 +++++++++++++++++++++++++++++++ lib/tools.py | 6 ++++-- tests/test_startup.py | 35 ++++++++++++++++++++++++++++++++++- 8 files changed, 90 insertions(+), 19 deletions(-) create mode 100644 lib/logs.py diff --git a/README.md b/README.md index fd4b03c..e764e1f 100644 --- a/README.md +++ b/README.md @@ -36,12 +36,14 @@ Under systemd see `ecobot.service` (it loads the environment file; the clone's l | `CHART_ALL_FEELS_LIKE=on` | optional: "weather all week" (every reading, a panel each, sent with just its title; UV is left out as it has the same shape as solar) also draws the feels-like panel; off by default, as it nearly repeats temperature. Naming either ("plot feels like", "plot solar and uv") always works | | `TUYA_DEVICE_ID`, `TUYA_CLIENT_ID`, `TUYA_CLIENT_SECRET` | enables the irrigation controller (a Tuya smart irrigation timer, through the Tuya cloud API): an Irrigation button in private chats (it replaces Wind) with its status and buttons to water for 5 to 30 minutes or switch the valve off, pause for 24 to 72 hours (the Pause button becomes Unpause while paused), refresh, and an Alerts menu to turn the chat's irrigation battery and irrigation pause alerts on or off (or type irrigation, water, tap or sprinkler); a daily check at 18:00 that delays it 24 hours when more than 1 mm fell in the last 24 hours or Open-Meteo gives today or tomorrow a 50% or better chance of 1 mm (needs Ecowitt and `FORECAST=on` for each half); an "irrigation pause" alert when it does, and an "irrigation battery" alert below 5%. `TUYA_BASE_URL` (default `https://openapi.tuyaeu.com`, the EU data centre) and `IRRIGATION_CHECK_HOUR` (default `18`) are optional | | `CHART_ALL_VPD=on` | optional: "weather all week" also draws the vapour pressure deficit panel; off by default, as it is temperature and humidity combined (the station reports it for the outdoor sensor only). Naming it ("plot vpd") always works | +| `LOG_WIDTH` | optional: the most characters of a long log line (a request, a chat message), cut with an ellipsis (…); `0` cuts none. Default 71. Only the quoted text is cut (what was asked, the tool request, the reply, an alert): what comes before it on the line, and every other line (timings, counts, warnings), is always whole | ## Layout ``` ecobot.py wiring and lifecycle lib/config.py settings +lib/logs.py how log lines look: the module that spoke, and quoted requests and chat messages cut to LOG_WIDTH lib/bot.py Telegram handlers and replies; every button press comes in through one entry point that checks it first lib/admins.py who may use the bot with ADMIN_ONLY: the admins of the groups it is in, plus ADMIN_CHAT_IDS lib/llm.py, prompt.py tool-calling loop; system prompt diff --git a/ecobot.py b/ecobot.py index ffd9a1c..21baa65 100644 --- a/ecobot.py +++ b/ecobot.py @@ -31,20 +31,13 @@ from lib.irrigation import Tuya from lib.irrigation.watchdog import CHECK_SECONDS as WATCHDOG_SECONDS from lib.llm import Agent +from lib.logs import LogFormat from lib.pollen import Pollen from lib.compose import Composer from lib.tools import Tools from lib.warm import every, safely -class LogFormat(logging.Formatter): - """"INFO message" for this file's own lines, "INFO lib.bot: message" for the rest: the module name says who spoke.""" - - def format(self, record): - record.where = "" if record.name == "ecobot" else f"{record.name}: " - return super().format(record) - - _handler = logging.StreamHandler() _handler.setFormatter(LogFormat("%(levelname)s %(where)s%(message)s")) logging.basicConfig(level=logging.INFO, handlers=[_handler]) @@ -116,6 +109,7 @@ def wrapped(head: str, items: list[str], tail: str = "", width: int = 90) -> str async def main(): log.info("Starting ecobot %s", version()) cfg = Config.from_env() + _handler.formatter.width = cfg.log_width # lines quoting a request or a chat message are cut to it from here on intent.FEELS_LIKE_IN_ALL = cfg.chart_all_feels_like intent.VPD_IN_ALL = cfg.chart_all_vpd sources = await start_sources(cfg) diff --git a/lib/alerts/notify.py b/lib/alerts/notify.py index dc2bbf0..437e32b 100644 --- a/lib/alerts/notify.py +++ b/lib/alerts/notify.py @@ -15,6 +15,7 @@ from telegram import MessageEntity from telegram.error import BadRequest, ChatMigrated, Forbidden, TelegramError +from ..logs import QUOTES from .menu import ALL, LABELS, keyboard log = logging.getLogger(__name__) @@ -207,7 +208,7 @@ async def __call__(self, text: str, link: tuple[str, str] | None = None, kind: s 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.replace("\n", " ")) + 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. @@ -216,7 +217,7 @@ async def to_chat(self, chat_id: int, text: str, link: tuple[str, str] | None = return False text, entities = with_footer(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.replace("\n", " ")) + 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/bot.py b/lib/bot.py index ea95821..08a0c20 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -31,6 +31,7 @@ from .config import Config from .timeutil import now_local from .llm import Agent, shorten_old, strip_tool_turns, trim_history +from .logs import QUOTES log = logging.getLogger(__name__) @@ -609,7 +610,7 @@ async def on_period_button(self, update: Update, context: ContextTypes.DEFAULT_T return await query.answer(f"Drawing {periods.NAMES[days]}...") asked = intent.with_period(question, days, now) - log.info("Period button: %s -> %s", _short(question, 40), _short(asked, 40)) + log.info("Period button %s: %s", periods.NAMES[days], question, extra=QUOTES) key = self.images.key(asked, days, now) if cached := self.images.get(key): # drawn lately: no new question photo, title = cached @@ -732,7 +733,7 @@ async def on_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE): return waited = (datetime.now(UTC) - msg.date).total_seconds() if getattr(msg, "date", None) else 0 if waited > PENDING_MAX_SECONDS: - log.info("Ignored a message %d minutes old from %s: %s", waited // 60, describe_source(update), _short(msg.text, 40)) + log.info("Ignored a message %d minutes old from %s: %s", waited // 60, describe_source(update), msg.text, extra=QUOTES) return private = msg.chat.type == ChatType.PRIVATE if private and not await self._allowed(update, context): # before remember_chat: a stranger never becomes an alert chat @@ -784,7 +785,7 @@ async def _written_in_code(self, read, turn: Turn) -> str: async def _warn(self, msg: Message, tokens: int): """A heads-up before a big job: the question has pulled in a lot of data. Sent now, silently; the answer follows by itself.""" text = f"\u26a0\ufe0f That's pulling in about {tokens / 1000:.0f}k tokens of data, so it will take a little longer. Working on it..." - log.info("Heads-up sent: %s", text) + log.info("Heads-up sent: %s", text, extra=QUOTES) with contextlib.suppress(TelegramError): await msg.reply_text(text) @@ -886,7 +887,7 @@ async def _respond(self, update: Update, context: ContextTypes.DEFAULT_TYPE, tex 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) log.info("%s %s%s", describe_source(update), f"(reasoning: {read.effort}) " if read.effort != intent.EFFORT_DEFAULT else "", - _short(text)) + 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 source.poke() @@ -940,7 +941,7 @@ async def _respond(self, update: Update, context: ContextTypes.DEFAULT_TYPE, tex except Exception: log.exception("Chart failed; sending the answer without it") except TimeoutError: - log.error("Gave up after %ds on: %s", TURN_SECONDS, _short(text, 60)) + 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." except Exception as e: log.exception("Agent error") @@ -958,7 +959,7 @@ async def _respond(self, update: Update, context: ContextTypes.DEFAULT_TYPE, tex 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, - time.monotonic() - started, len(used), len(photos), len(reply), _short(reply, 30)) + 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 []) air = self.by_name.get("AirGradient") diff --git a/lib/config.py b/lib/config.py index a8784ec..e39b00e 100644 --- a/lib/config.py +++ b/lib/config.py @@ -65,6 +65,11 @@ def _ids(value: str) -> frozenset[int]: return frozenset(found) +def _whole(value: str, default: int) -> 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 _number(value: str) -> float | None: try: return float(value) @@ -105,6 +110,7 @@ class Config: 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) + 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 @property def ecowitt(self) -> bool: @@ -158,6 +164,7 @@ def from_env(cls) -> "Config": admin_only=_flag(env("ADMIN_ONLY"), True), admin_chat_ids=_ids(env("ADMIN_CHAT_IDS")), alert_cooldown_minutes=int(min(150, max(5, _number(env("ALERT_COOLDOWN_MINUTES")) or 30))), + log_width=_whole(env("LOG_WIDTH"), 71), state_path=data / "bot_state.json", cache_path=data / "ecowitt_cache.sqlite", air_cache_path=data / "airgradient_cache.sqlite", diff --git a/lib/logs.py b/lib/logs.py new file mode 100644 index 0000000..2d7ccd2 --- /dev/null +++ b/lib/logs.py @@ -0,0 +1,31 @@ +"""How the bot's log lines look. A line that quotes a request or a chat message (the quote last on the line) has the quote cut to +fit the log width (LOG_WIDTH), an ellipsis (…) marking the cut; the rest of a line, and every other line, is never cut.""" + +import logging + +QUOTES = {"quotes": True} # extra= for a line whose last argument is a request or a chat message + + +def fit(text: str, room: int) -> str: + """The text in at most `room` characters, an ellipsis marking a cut (just the ellipsis when there is no room).""" + return text if len(text) <= room else text[:max(room - 1, 0)] + "…" + + +class LogFormat(logging.Formatter): + """"INFO message" for the main script's own lines, "INFO lib.bot: message" for the rest: the module name says who spoke.""" + + def __init__(self, fmt: str, width: int = 0): + """width: the most characters of a line that quotes (QUOTES); 0 cuts none.""" + super().__init__(fmt) + self.width = width + + def format(self, record): + record.where = "" if record.name == "ecobot" else f"{record.name}: " + line = super().format(record) + if not getattr(record, "quotes", False) or not isinstance(record.args, tuple) or not record.args: + return line + quote = str(record.args[-1]) + if not line.endswith(quote): # a traceback or stack after it: left as it is + return line + head, quote = line[:len(line) - len(quote)], " ".join(quote.split()) # the quote on one line + return head + (fit(quote, self.width - len(head)) if self.width else quote) diff --git a/lib/tools.py b/lib/tools.py index dad7516..b69f719 100644 --- a/lib/tools.py +++ b/lib/tools.py @@ -6,6 +6,8 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass, field +from .logs import QUOTES + log = logging.getLogger(__name__) MAX_OUTPUT_CHARS = 60000 @@ -60,7 +62,7 @@ async def call(self, name: str, raw_args: str, turn: Turn | None = None) -> str: except json.JSONDecodeError as e: 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[:300]) + 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: @@ -72,7 +74,7 @@ async def call(self, name: str, raw_args: str, turn: Turn | None = None) -> str: if len(out) > MAX_OUTPUT_CHARS: log.warning("Tool %s output truncated: %d chars", name, len(out)) out = out[:MAX_OUTPUT_CHARS] + "\n...[truncated - request a shorter range]" - log.log(level, "Tool result %s: %s", name, out if len(out) < 90 else f"{len(out)} chars") + log.log(level, "Tool result %s: %s", name, out if len(out) < 90 else f"{len(out)} chars", extra=QUOTES) if turn: turn.result_chars += len(out) return out diff --git a/tests/test_startup.py b/tests/test_startup.py index 5ef1f26..efb4be3 100644 --- a/tests/test_startup.py +++ b/tests/test_startup.py @@ -51,7 +51,8 @@ def __getattr__(self, name): async def test_main_starts_warms_and_shuts_down_cleanly(tmp_path, monkeypatch, caplog): caplog.set_level("INFO") - cfg = config(tmp_path) + cfg = config(tmp_path, log_width=55) + monkeypatch.setattr(ecobot._handler.formatter, "width", 0) # put back after the test eco_t, _ = ecowitt_transport(history_days=45) air_t, _ = air_transport(oldest=datetime.now(timezone.utc) - timedelta(days=5)) app = FakeApp() @@ -79,6 +80,7 @@ async def stop_when_archived(): # the background archives finish, then we shut assert "failed" not in final and "days (5min/30min/4h/1d)" in final assert len(app.handlers) == 10 # start+help, reset, keyboard, alerts, usage, the one button handler, other updates, membership, messages, errors assert "Alerts: rain, gusts, UV, temperature crossing, particulates: 0 chats" in log and "Alerts NOT running" not in log + assert ecobot._handler.formatter.width == 55 # LOG_WIDTH reaches the bot's own log lines async def test_the_alerts_line_leaves_out_the_weather_alerts_when_they_could_not_start(tmp_path, monkeypatch, caplog): @@ -261,3 +263,34 @@ def test_log_lines_name_their_module_except_the_main_script(): fmt = ecobot.LogFormat("%(levelname)s %(where)s%(message)s") record = lambda name: logging.LogRecord(name, logging.INFO, __file__, 1, "hello", None, None) assert fmt.format(record("ecobot")) == "INFO hello" and fmt.format(record("lib.bot")) == "INFO lib.bot: hello" + + +def test_a_line_quoting_a_request_or_a_message_is_cut_to_the_log_width_and_no_other_line_is(): + from lib.logs import QUOTES, LogFormat + fmt = LogFormat("%(levelname)s %(where)s%(message)s", width=40) + + def line(msg, *args, quotes=True): + record = logging.LogRecord("lib.bot", logging.INFO, __file__, 1, msg, args, None) + if quotes: + record.__dict__.update(QUOTES) + return fmt.format(record) + asked = "what was the hottest it got last week, and when?" + assert line("[@rob] %s", "how hot\nis it") == "INFO lib.bot: [@rob] how hot is it" # short enough: whole, on one line + assert line("[@rob] %s", asked) == "INFO lib.bot: [@rob] what was the hotte…" # 40 characters, the ellipsis included + assert line("Replied -1001234567890 in 12.3s, tools 2, charts 0, 1234 chars: %s", "It was warm").endswith( + ", 1234 chars: …") # only the quote is cut, never what comes before it + timings = "Chart times: data 12 ms, calculation 6 ms, drawing 112 ms, PNG 41 ms" + assert line(timings, quotes=False) == "INFO lib.bot: " + timings # a line that quotes nothing is never cut + fmt.width = 0 + assert line("[@rob] %s", asked) == "INFO lib.bot: [@rob] " + asked # 0 cuts none + + +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")): + monkeypatch.setenv(k, v) + monkeypatch.delenv("LOG_WIDTH", raising=False) + assert Config.from_env().log_width == 71 + for value, want in (("120", 120), ("0", 0), ("junk", 71), ("-5", 71), ("7.5", 71), ("²", 71)): + monkeypatch.setenv("LOG_WIDTH", value) + assert Config.from_env().log_width == want, value From f767184cc9e9fcb6a0e4d5daa7127a28649111ba Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 18:42:12 +0000 Subject: [PATCH 11/22] TELEGRAM_PRELOAD_CHAT: upload a chart's other periods ahead, so a period button uploads nothing With TELEGRAM_PRELOAD_CHAT set to a chat id, each chart sent (or switched) with period buttons, when made in code, has its other periods drawn in the background and sent silently to that chat. The file ids go in the image cache, so pressing a period button edits the message with Telegram's copy. - One chart at a time, never while a question is being answered; not urgent Ecowitt requests; quiet logs. - Used for 30 minutes (PRELOAD_TTL), then drawn afresh. - If Telegram refuses an upload, one warning, and no more until restart. Unset: nothing changes. - The drawing step of predraw is shared (Bot._draw_ahead). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 1 + lib/bot.py | 85 ++++++++++++++++++++++++++++++++++++------- lib/config.py | 13 +++++++ lib/periods.py | 13 +++++-- tests/test_periods.py | 71 ++++++++++++++++++++++++++++++++++++ tests/test_startup.py | 11 ++++++ 6 files changed, 178 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index e764e1f..883039d 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Under systemd see `ecobot.service` (it loads the environment file; the clone's l | `CHART_ALL_FEELS_LIKE=on` | optional: "weather all week" (every reading, a panel each, sent with just its title; UV is left out as it has the same shape as solar) also draws the feels-like panel; off by default, as it nearly repeats temperature. Naming either ("plot feels like", "plot solar and uv") always works | | `TUYA_DEVICE_ID`, `TUYA_CLIENT_ID`, `TUYA_CLIENT_SECRET` | enables the irrigation controller (a Tuya smart irrigation timer, through the Tuya cloud API): an Irrigation button in private chats (it replaces Wind) with its status and buttons to water for 5 to 30 minutes or switch the valve off, pause for 24 to 72 hours (the Pause button becomes Unpause while paused), refresh, and an Alerts menu to turn the chat's irrigation battery and irrigation pause alerts on or off (or type irrigation, water, tap or sprinkler); a daily check at 18:00 that delays it 24 hours when more than 1 mm fell in the last 24 hours or Open-Meteo gives today or tomorrow a 50% or better chance of 1 mm (needs Ecowitt and `FORECAST=on` for each half); an "irrigation pause" alert when it does, and an "irrigation battery" alert below 5%. `TUYA_BASE_URL` (default `https://openapi.tuyaeu.com`, the EU data centre) and `IRRIGATION_CHECK_HOUR` (default `18`) are optional | | `CHART_ALL_VPD=on` | optional: "weather all week" also draws the vapour pressure deficit panel; off by default, as it is temperature and humidity combined (the station reports it for the outdoor sensor only). Naming it ("plot vpd") always works | +| `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 (one at a time, never while a question 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. Unset: off | | `LOG_WIDTH` | optional: the most characters of a long log line (a request, a chat message), cut with an ellipsis (…); `0` cuts none. Default 71. Only the quoted text is cut (what was asked, the tool request, the reply, an alert): what comes before it on the line, and every other line (timings, counts, warnings), is always whole | ## Layout diff --git a/lib/bot.py b/lib/bot.py index 08a0c20..d08114d 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -251,6 +251,9 @@ def __init__(self, cfg: Config, agent: Agent, sources: list, state: AlertState | self.idle.set() self.predrawn = False # the first round of charts drawn ahead has been logged self.predraw_failing: set[str] = set() # button charts that couldn't be drawn ahead (said once, until they can again) + self.preloading: set[str] = set() # questions whose other periods are being uploaded (TELEGRAM_PRELOAD_CHAT) + self.preload_off = False # Telegram refused an upload to that chat: no more are tried until restart + 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) def register(self, app): @@ -618,6 +621,7 @@ async def on_period_button(self, update: Update, context: ContextTypes.DEFAULT_T began = time.monotonic() self.images.sent(key, await message.edit_media(InputMediaPhoto(photo, caption=title or None), reply_markup=periods.keyboard(days))) self.charted.remember(message.chat_id, message.message_id, asked) + self.preload_soon(context.bot, asked) log.info("Period button: chart from the cache, sent in %.0f ms%s", (time.monotonic() - began) * 1000, " (Telegram's copy)" if isinstance(photo, str) else "") return @@ -829,6 +833,67 @@ async def _ask_model(self, working: list[dict], system: str, read, turn: Turn, d working[:] = before # the unfinished attempt's turns are dropped effort, budget, retried = lower, RETRY_SECONDS, True + 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) + 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) + 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") + png = await asyncio.to_thread(render_chart, turn.charts[0], self.cfg.tz) + return key, png, turn.charts[0].caption(now.date()), caption + + def preload_soon(self, bot, question: str): + """With TELEGRAM_PRELOAD_CHAT set, upload the other periods of this chart's question in the background.""" + chat = getattr(self.cfg, "telegram_preload_chat", None) + if chat is None or self.preload_off or question in self.preloading: + return + self.preloading.add(question) + task = asyncio.create_task(self.preload(bot, chat, question)) + self.background.add(task) + task.add_done_callback(self.background.discard) + + async def preload(self, bot, chat: int, question: str): + """Upload (silently, to `chat`) the chart for each other period of `question`, so a press of a period button edits the message by + Telegram's copy and uploads nothing. One at a time and never while a question is being answered.""" + started, done = time.monotonic(), [] + try: + for days in periods.PERIODS: + await self.idle.wait() + now = now_local(self.cfg.tz) + if days == intent.period_days(question, now): + continue + asked = intent.with_period(question, days, now) + if self.images.has_copy(self.images.key(asked, days, now)): + continue + try: + drawn = await self._draw_ahead(asked, now) + except Exception as e: + log.debug("Couldn't draw %r to preload: %s: %s", asked, type(e).__name__, e) + continue + if drawn is None: + return + key, png, title, caption = drawn + self.images.put(key, png, title, caption, ttl=self.images.PRELOAD_TTL) + try: + self.images.sent(key, await bot.send_photo(chat, png, caption=title or None, disable_notification=True)) + except TelegramError as e: + self.preload_off = True + log.warning("Couldn't upload to TELEGRAM_PRELOAD_CHAT %s (%s: %s); not preloading charts", chat, type(e).__name__, e) + return + done.append(periods.NAMES[days]) + finally: + self.preloading.discard(question) + if done: + log.info("Preloaded %s of %r to chat %s in %.1fs", ", ".join(done), question, chat, time.monotonic() - started, + extra=QUOTES) + async def predraw(self): """Draw the charts the buttons ask for ahead of the press: one pressed in the next ImageCache.FRESH seconds is sent at once. One chart at a time, and never while a question is being answered (its own drawing would wait behind these). Run every @@ -836,25 +901,17 @@ async def predraw(self): started, done = time.monotonic(), [] for text in dict.fromkeys(question for row in templates.rows(self.irrigation is not None) for _, question in row if question): await self.idle.wait() - 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) - 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 - continue - 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) try: - 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") - png = await asyncio.to_thread(render_chart, turn.charts[0], self.cfg.tz) + drawn = await self._draw_ahead(text, now_local(self.cfg.tz)) + if drawn is None: + continue except Exception as e: if text not in self.predraw_failing: self.predraw_failing.add(text) log.warning("Couldn't draw %r ahead (%s: %s); pressing it draws it", text, type(e).__name__, e) continue - self.images.put(key, png, turn.charts[0].caption(now.date()), caption) + key, png, title, caption = drawn + self.images.put(key, png, title, caption) done.append(text) if text in self.predraw_failing: self.predraw_failing.discard(text) @@ -986,6 +1043,8 @@ async def _respond(self, update: Update, context: ContextTypes.DEFAULT_TYPE, tex 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 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) if carried_keyboard: diff --git a/lib/config.py b/lib/config.py index e39b00e..713127c 100644 --- a/lib/config.py +++ b/lib/config.py @@ -70,6 +70,17 @@ def _whole(value: str, default: int) -> int: return int(value) if value.isdecimal() else default +def _chat_id(value: str, 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 + try: + return int(value) + except ValueError: + log.warning("%s: ignoring %r (not a chat id, a whole number)", name, value) + return None + + def _number(value: str) -> float | None: try: return float(value) @@ -110,6 +121,7 @@ class Config: 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) 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 @property @@ -165,6 +177,7 @@ def from_env(cls) -> "Config": 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), + 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", diff --git a/lib/periods.py b/lib/periods.py index b07531d..ed34a03 100644 --- a/lib/periods.py +++ b/lib/periods.py @@ -68,6 +68,7 @@ class Drawn: title: str file_id: str | None = None # Telegram's copy, once the chart has been sent: it is sent again by this, not uploaded caption: str | None = None # the answer's text (kept in the chat's history), when it was made in code for exactly the key's words + ttl: float = 10 * 60 # how long it is kept (ImageCache.CHART_TTL; PRELOAD_TTL for one uploaded ahead) @property def photo(self) -> bytes | str: @@ -82,6 +83,7 @@ class ImageCache: CHART_TTL = 10 * 60 CHART_IMAGES = 60 + PRELOAD_TTL = 30 * 60 # a chart uploaded ahead (TELEGRAM_PRELOAD_CHAT) is used for this long, then drawn afresh FRESH = 330 # a little over the time between two rounds of drawing ahead, so a press always finds one def __init__(self, clock=None): @@ -106,12 +108,17 @@ def canonical(cls, question: str, now) -> str | None: 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 > self.CHART_TTL: + if entry is None or self.clock() - entry.at > entry.ttl: self.items.pop(key, None) return None self.items.move_to_end(key) return entry.photo, entry.title + 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 + 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.""" @@ -125,9 +132,9 @@ def ready(self, question: str, now) -> Drawn | None: def age(self, entry: Drawn) -> float: return self.clock() - entry.at - def put(self, key: str, png: bytes, title: str, caption: str | None = None): + def put(self, key: str, png: bytes, title: str, caption: str | None = None, ttl: float | None = None): """caption: the answer's text, only for a chart made in code for its canonical question (then it can be ready).""" - self.items[key] = Drawn(self.clock(), png, title, caption=caption) + self.items[key] = Drawn(self.clock(), png, title, caption=caption, ttl=ttl or self.CHART_TTL) self.items.move_to_end(key) while len(self.items) > self.CHART_IMAGES: self.items.popitem(last=False) diff --git a/tests/test_periods.py b/tests/test_periods.py index 8e5210a..d87fadd 100644 --- a/tests/test_periods.py +++ b/tests/test_periods.py @@ -272,3 +272,74 @@ async def test_charts_are_not_drawn_ahead_while_a_question_is_being_answered(mon await bot.respond(pressed(bot, []), CONTEXT, "Temperature chart 7d") assert bot.answering == 0 and bot.idle.is_set() # counted while it ran, and let go after + + +def preloading_bot(monkeypatch, refuse=None, clock=None): + """A bot with TELEGRAM_PRELOAD_CHAT set, and the (chat, photo, silent) of every upload to it.""" + bot, uploads = make_bot(monkeypatch), [] + bot.cfg = NS(tz=TZ, telegram_preload_chat=-1001) + if clock: + bot.images = periods.ImageCache(lambda: clock[0]) + + async def send_photo(chat, photo, caption=None, disable_notification=False): + if refuse: + raise refuse + uploads.append((chat, photo, disable_notification)) + return NS(photo=(NS(file_id="small"), NS(file_id=f"file {len(uploads)}"))) + return bot, uploads, NS(bot=NS(send_chat_action=send_chat_action, send_photo=send_photo, id=99)) + + +async def settle(bot): + import asyncio + 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) + sent, edits = [], [] + + async def edit_media(media, reply_markup=None): + edits.append(media.media) + return NS(photo=()) + msg = chat_message(sent, edits) + msg.edit_media, msg.message_id = edit_media, 101 + await bot.respond(NS(effective_message=msg, effective_chat=msg.chat, effective_user=NS(username="rob", full_name="Rob")), + context, "Temperature chart 7d") + await settle(bot) + assert [(c, p, silent) for c, p, silent in uploads] == [(-1001, b"png", True)] * 4 # 1D, 1M, 3M, 1Y: not the 7D just sent + calls = len(bot.agent.tools.calls) + + async def answer(text=None): + pass + await bot.on_period_button(NS(callback_query=NS(data="pd:90", message=msg, answer=answer), effective_message=msg, + effective_chat=msg.chat, effective_user=NS(username="rob", full_name="Rob")), context) + assert len(bot.agent.tools.calls) == calls and edits[0].startswith("file ") # Telegram's copy, 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) + 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")) + with caplog.at_level("WARNING"): + await bot.respond(pressed(bot, []), context, "Temperature chart 7d") + await settle(bot) + await bot.respond(pressed(bot, []), context, "Temperature chart 30d") + await settle(bot) + assert [r for r in caplog.records if "TELEGRAM_PRELOAD_CHAT" in r.getMessage()].__len__() == 1 and uploads == [] + + +async def test_a_chart_uploaded_ahead_is_used_for_30_minutes_and_then_drawn_afresh(monkeypatch): + clock = [0.0] + bot, uploads, context = preloading_bot(monkeypatch, clock=clock) + await bot.respond(pressed(bot, []), context, "Temperature chart 7d") + await settle(bot) + key = bot.images.key("Temperature chart 30d", 30, NOW) + clock[0] = 29 * 60 + assert bot.images.has_copy(key) and isinstance(bot.images.get(key)[0], str) + clock[0] = 31 * 60 + assert not bot.images.has_copy(key) and bot.images.get(key) is None diff --git a/tests/test_startup.py b/tests/test_startup.py index efb4be3..d480803 100644 --- a/tests/test_startup.py +++ b/tests/test_startup.py @@ -294,3 +294,14 @@ def test_the_log_width_is_71_unless_set_and_0_cuts_none(monkeypatch): for value, want in (("120", 120), ("0", 0), ("junk", 71), ("-5", 71), ("7.5", 71), ("²", 71)): monkeypatch.setenv("LOG_WIDTH", value) assert Config.from_env().log_width == want, value + + +def test_the_preload_chat_is_a_chat_id_or_off(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")): + monkeypatch.setenv(k, v) + monkeypatch.delenv("TELEGRAM_PRELOAD_CHAT", raising=False) + assert Config.from_env().telegram_preload_chat is None + for value, want in (("-1001234567890", -1001234567890), ("42", 42), ("", None), ("junk", None)): + monkeypatch.setenv("TELEGRAM_PRELOAD_CHAT", value) + assert Config.from_env().telegram_preload_chat == want, value From a2943373b36c8faff6132964eab02ae5ed8c2f53 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 18:48:23 +0000 Subject: [PATCH 12/22] LOG_WIDTH applies to every log line, and the startup Alerts list wraps at it Every line of the log is cut to LOG_WIDTH (default 71, 0 cuts none), an ellipsis marking the cut, including each line of a multi-line entry such as a traceback. A request, chat message, reply or alert is still put on one line first. The startup "Alerts:" list wraps at the width (90 when LOG_WIDTH=0). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 2 +- ecobot.py | 2 +- lib/logs.py | 17 +++++++---------- tests/test_startup.py | 18 ++++++++++++------ 4 files changed, 21 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 883039d..6a92c60 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Under systemd see `ecobot.service` (it loads the environment file; the clone's l | `TUYA_DEVICE_ID`, `TUYA_CLIENT_ID`, `TUYA_CLIENT_SECRET` | enables the irrigation controller (a Tuya smart irrigation timer, through the Tuya cloud API): an Irrigation button in private chats (it replaces Wind) with its status and buttons to water for 5 to 30 minutes or switch the valve off, pause for 24 to 72 hours (the Pause button becomes Unpause while paused), refresh, and an Alerts menu to turn the chat's irrigation battery and irrigation pause alerts on or off (or type irrigation, water, tap or sprinkler); a daily check at 18:00 that delays it 24 hours when more than 1 mm fell in the last 24 hours or Open-Meteo gives today or tomorrow a 50% or better chance of 1 mm (needs Ecowitt and `FORECAST=on` for each half); an "irrigation pause" alert when it does, and an "irrigation battery" alert below 5%. `TUYA_BASE_URL` (default `https://openapi.tuyaeu.com`, the EU data centre) and `IRRIGATION_CHECK_HOUR` (default `18`) are optional | | `CHART_ALL_VPD=on` | optional: "weather all week" also draws the vapour pressure deficit panel; off by default, as it is temperature and humidity combined (the station reports it for the outdoor sensor only). Naming it ("plot vpd") always works | | `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 (one at a time, never while a question 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. Unset: off | -| `LOG_WIDTH` | optional: the most characters of a long log line (a request, a chat message), cut with an ellipsis (…); `0` cuts none. Default 71. Only the quoted text is cut (what was asked, the tool request, the reply, an alert): what comes before it on the line, and every other line (timings, counts, warnings), is always whole | +| `LOG_WIDTH` | optional: the most characters of a log line, cut with an ellipsis (…); `0` cuts none. Default 71. Every line obeys it (each line of a multi-line entry, such as a traceback, too); a request, chat message, reply or alert is also put on one line, and the startup "Alerts:" list wraps at it (at 90 when `0`) | ## Layout diff --git a/ecobot.py b/ecobot.py index 21baa65..32b7474 100644 --- a/ecobot.py +++ b/ecobot.py @@ -219,7 +219,7 @@ async def air_backfill(): 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("%s", wrapped("Alerts: ", [LABELS[k] for k in on] or ["none"], f": {len(state.alert_chats())} chats")) + log.info("%s", wrapped("Alerts: ", [LABELS[k] for k in on] or ["none"], f": {len(state.alert_chats())} chats", cfg.log_width or 90)) 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)) diff --git a/lib/logs.py b/lib/logs.py index 2d7ccd2..f184df2 100644 --- a/lib/logs.py +++ b/lib/logs.py @@ -1,5 +1,5 @@ -"""How the bot's log lines look. A line that quotes a request or a chat message (the quote last on the line) has the quote cut to -fit the log width (LOG_WIDTH), an ellipsis (…) marking the cut; the rest of a line, and every other line, is never cut.""" +"""How the bot's log lines look. Every line of the log is cut to the log width (LOG_WIDTH), an ellipsis (…) marking the cut. 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 @@ -15,17 +15,14 @@ class LogFormat(logging.Formatter): """"INFO message" for the main script's own lines, "INFO lib.bot: message" for the rest: the module name says who spoke.""" def __init__(self, fmt: str, width: int = 0): - """width: the most characters of a line that quotes (QUOTES); 0 cuts none.""" + """width: the most characters of a line of the log; 0 cuts none.""" super().__init__(fmt) self.width = width def format(self, record): record.where = "" if record.name == "ecobot" else f"{record.name}: " line = super().format(record) - if not getattr(record, "quotes", False) or not isinstance(record.args, tuple) or not record.args: - return line - quote = str(record.args[-1]) - if not line.endswith(quote): # a traceback or stack after it: left as it is - return line - head, quote = line[:len(line) - len(quote)], " ".join(quote.split()) # the quote on one line - return head + (fit(quote, self.width - len(head)) if self.width else quote) + 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) + line = line[:len(line) - len(quote)] + " ".join(quote.split()) # the quote on one line + return "\n".join(fit(part, self.width) for part in line.split("\n")) if self.width else line diff --git a/tests/test_startup.py b/tests/test_startup.py index d480803..679960b 100644 --- a/tests/test_startup.py +++ b/tests/test_startup.py @@ -51,7 +51,7 @@ def __getattr__(self, name): async def test_main_starts_warms_and_shuts_down_cleanly(tmp_path, monkeypatch, caplog): caplog.set_level("INFO") - cfg = config(tmp_path, log_width=55) + cfg = config(tmp_path, log_width=120) monkeypatch.setattr(ecobot._handler.formatter, "width", 0) # put back after the test eco_t, _ = ecowitt_transport(history_days=45) air_t, _ = air_transport(oldest=datetime.now(timezone.utc) - timedelta(days=5)) @@ -80,7 +80,7 @@ async def stop_when_archived(): # the background archives finish, then we shut assert "failed" not in final and "days (5min/30min/4h/1d)" in final assert len(app.handlers) == 10 # start+help, reset, keyboard, alerts, usage, the one button handler, other updates, membership, messages, errors assert "Alerts: rain, gusts, UV, temperature crossing, particulates: 0 chats" in log and "Alerts NOT running" not in log - assert ecobot._handler.formatter.width == 55 # LOG_WIDTH reaches the bot's own log lines + assert ecobot._handler.formatter.width == 120 # LOG_WIDTH reaches the bot's own log lines async def test_the_alerts_line_leaves_out_the_weather_alerts_when_they_could_not_start(tmp_path, monkeypatch, caplog): @@ -265,7 +265,7 @@ def test_log_lines_name_their_module_except_the_main_script(): assert fmt.format(record("ecobot")) == "INFO hello" and fmt.format(record("lib.bot")) == "INFO lib.bot: hello" -def test_a_line_quoting_a_request_or_a_message_is_cut_to_the_log_width_and_no_other_line_is(): +def test_every_log_line_is_cut_to_the_log_width_and_a_quote_is_put_on_one_line(): from lib.logs import QUOTES, LogFormat fmt = LogFormat("%(levelname)s %(where)s%(message)s", width=40) @@ -277,12 +277,12 @@ def line(msg, *args, quotes=True): asked = "what was the hottest it got last week, and when?" assert line("[@rob] %s", "how hot\nis it") == "INFO lib.bot: [@rob] how hot is it" # short enough: whole, on one line assert line("[@rob] %s", asked) == "INFO lib.bot: [@rob] what was the hotte…" # 40 characters, the ellipsis included - assert line("Replied -1001234567890 in 12.3s, tools 2, charts 0, 1234 chars: %s", "It was warm").endswith( - ", 1234 chars: …") # only the quote is cut, never what comes before it timings = "Chart times: data 12 ms, calculation 6 ms, drawing 112 ms, PNG 41 ms" - assert line(timings, quotes=False) == "INFO lib.bot: " + timings # a line that quotes nothing is never cut + assert line(timings, quotes=False) == "INFO lib.bot: Chart times: data 12 ms, …" # a line that quotes nothing is cut too + assert line("a\n b " + "x" * 50, quotes=False).split("\n")[1] == " b " + "x" * 33 + "…" # each line of a long message fmt.width = 0 assert line("[@rob] %s", asked) == "INFO lib.bot: [@rob] " + asked # 0 cuts none + assert line(timings, quotes=False) == "INFO lib.bot: " + timings def test_the_log_width_is_71_unless_set_and_0_cuts_none(monkeypatch): @@ -305,3 +305,9 @@ def test_the_preload_chat_is_a_chat_id_or_off(monkeypatch): for value, want in (("-1001234567890", -1001234567890), ("42", 42), ("", None), ("junk", None)): monkeypatch.setenv("TELEGRAM_PRELOAD_CHAT", value) assert Config.from_env().telegram_preload_chat == want, value + + +def test_the_alerts_list_wraps_at_the_log_width(): + from lib.alerts.menu import LABELS + lines = ecobot.wrapped("Alerts: ", list(LABELS.values()), ": 3 chats", 40).splitlines() + assert len(lines) > 1 and all(len(line) <= 40 for line in lines) From 5f0c7638e71c9b8041534a622ca36b628f19298e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 18:56:18 +0000 Subject: [PATCH 13/22] Log lines no longer start with the module name "INFO Replied 1 in 0.1s ..." instead of "INFO lib.bot: Replied 1 in 0.1s ...". Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- ecobot.py | 2 +- lib/logs.py | 3 +-- tests/test_startup.py | 18 +++++++++--------- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/ecobot.py b/ecobot.py index 32b7474..c33f7bc 100644 --- a/ecobot.py +++ b/ecobot.py @@ -39,7 +39,7 @@ _handler = logging.StreamHandler() -_handler.setFormatter(LogFormat("%(levelname)s %(where)s%(message)s")) +_handler.setFormatter(LogFormat("%(levelname)s %(message)s")) logging.basicConfig(level=logging.INFO, handlers=[_handler]) logging.getLogger("httpx").setLevel(logging.WARNING) logging.getLogger("telegram.ext.Application").setLevel(logging.WARNING) # "Application started" etc. diff --git a/lib/logs.py b/lib/logs.py index f184df2..cf93be6 100644 --- a/lib/logs.py +++ b/lib/logs.py @@ -12,7 +12,7 @@ def fit(text: str, room: int) -> str: class LogFormat(logging.Formatter): - """"INFO message" for the main script's own lines, "INFO lib.bot: message" for the rest: the module name says who spoke.""" + """"INFO message": the level and the message, with no module name. Cuts each line to the width.""" def __init__(self, fmt: str, width: int = 0): """width: the most characters of a line of the log; 0 cuts none.""" @@ -20,7 +20,6 @@ def __init__(self, fmt: str, width: int = 0): self.width = width def format(self, record): - record.where = "" if record.name == "ecobot" else f"{record.name}: " line = super().format(record) 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) diff --git a/tests/test_startup.py b/tests/test_startup.py index 679960b..c6ddc1e 100644 --- a/tests/test_startup.py +++ b/tests/test_startup.py @@ -259,15 +259,15 @@ def test_a_long_alerts_line_continues_indented_at_90_characters(): assert all(line.startswith(" " * 13) and not line.startswith(" " * 14) for line in lines[1:]) -def test_log_lines_name_their_module_except_the_main_script(): - fmt = ecobot.LogFormat("%(levelname)s %(where)s%(message)s") +def test_log_lines_do_not_name_their_module(): + fmt = ecobot.LogFormat("%(levelname)s %(message)s") record = lambda name: logging.LogRecord(name, logging.INFO, __file__, 1, "hello", None, None) - assert fmt.format(record("ecobot")) == "INFO hello" and fmt.format(record("lib.bot")) == "INFO lib.bot: hello" + assert fmt.format(record("ecobot")) == "INFO hello" and fmt.format(record("lib.bot")) == "INFO hello" def test_every_log_line_is_cut_to_the_log_width_and_a_quote_is_put_on_one_line(): from lib.logs import QUOTES, LogFormat - fmt = LogFormat("%(levelname)s %(where)s%(message)s", width=40) + fmt = LogFormat("%(levelname)s %(message)s", width=40) def line(msg, *args, quotes=True): record = logging.LogRecord("lib.bot", logging.INFO, __file__, 1, msg, args, None) @@ -275,14 +275,14 @@ def line(msg, *args, quotes=True): record.__dict__.update(QUOTES) return fmt.format(record) asked = "what was the hottest it got last week, and when?" - assert line("[@rob] %s", "how hot\nis it") == "INFO lib.bot: [@rob] how hot is it" # short enough: whole, on one line - assert line("[@rob] %s", asked) == "INFO lib.bot: [@rob] what was the hotte…" # 40 characters, the ellipsis included + assert line("[@rob] %s", "how hot\nis it") == "INFO [@rob] how hot is it" # short enough: whole, on one line + assert line("[@rob] %s", asked) == "INFO [@rob] what was the hottest it got…" # 40 characters, the ellipsis included timings = "Chart times: data 12 ms, calculation 6 ms, drawing 112 ms, PNG 41 ms" - assert line(timings, quotes=False) == "INFO lib.bot: Chart times: data 12 ms, …" # a line that quotes nothing is cut too + assert line(timings, quotes=False) == "INFO Chart times: data 12 ms, calculation …" # a line that quotes nothing is cut too assert line("a\n b " + "x" * 50, quotes=False).split("\n")[1] == " b " + "x" * 33 + "…" # each line of a long message fmt.width = 0 - assert line("[@rob] %s", asked) == "INFO lib.bot: [@rob] " + asked # 0 cuts none - assert line(timings, quotes=False) == "INFO lib.bot: " + timings + assert line("[@rob] %s", asked) == "INFO [@rob] " + asked # 0 cuts none + assert line(timings, quotes=False) == "INFO " + timings def test_the_log_width_is_71_unless_set_and_0_cuts_none(monkeypatch): From 686c2f980f652eecc305829dd7fc4c1f299e1367 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 18:57:18 +0000 Subject: [PATCH 14/22] Fix the log cut test's expected text Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- tests/test_startup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_startup.py b/tests/test_startup.py index c6ddc1e..c133754 100644 --- a/tests/test_startup.py +++ b/tests/test_startup.py @@ -278,7 +278,7 @@ def line(msg, *args, quotes=True): assert line("[@rob] %s", "how hot\nis it") == "INFO [@rob] how hot is it" # short enough: whole, on one line assert line("[@rob] %s", asked) == "INFO [@rob] what was the hottest it got…" # 40 characters, the ellipsis included timings = "Chart times: data 12 ms, calculation 6 ms, drawing 112 ms, PNG 41 ms" - assert line(timings, quotes=False) == "INFO Chart times: data 12 ms, calculation …" # a line that quotes nothing is cut too + assert line(timings, quotes=False) == "INFO Chart times: data 12 ms, calculati…" # a line that quotes nothing is cut too assert line("a\n b " + "x" * 50, quotes=False).split("\n")[1] == " b " + "x" * 33 + "…" # each line of a long message fmt.width = 0 assert line("[@rob] %s", asked) == "INFO [@rob] " + asked # 0 cuts none From b8ad38f28dbb7193cc2f1987b60e16bad461f591 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 19:01:46 +0000 Subject: [PATCH 15/22] Shorten the log messages that LOG_WIDTH was cutting Startup: Pollen (grass/asthma in a few words), ADMIN_ONLY, "@bot + model" and a separate Sources line, Keeping warm (wraps like Alerts, both at the log width), AirGradient and Ecowitt archive lines, the live reading summary. Per chart: "Chart ms: data 12, calc 6, draw 112, PNG 41 (22 KB), send 812", the history summary ("09-19..10-18: 13 reads (12 cache, 1 mem), 0 req, 8 refined"), the period button and drawn-ahead lines. Two warnings are shorter. Lines that quote a request or reply are still cut, as they should be. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 2 +- ecobot.py | 15 ++++++++------- lib/bot.py | 20 ++++++++++---------- lib/ecowitt/archive.py | 2 +- lib/ecowitt/query.py | 6 +++--- lib/ecowitt/source.py | 7 ++++--- lib/pollen/source.py | 12 +++++++++++- tests/test_bot.py | 4 ++-- tests/test_ecowitt.py | 6 +++--- tests/test_startup.py | 12 ++++++------ 10 files changed, 49 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 6a92c60..310c849 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ Both are off by default. They are websites, not APIs (the pollen page is parsed) model is called once, at the end, just to word the answer. - Recent readings are kept in memory, refreshed every minute (the weather station) and every 4 minutes (the air sensor); a question that may need them starts a refresh as it arrives (a chart made in code doesn't: it reads just what it needs). A question never waits for a refresh in flight when the cache and memory already hold what it needs, and its own Ecowitt requests go before the background ones. - The chart buttons' charts are drawn ahead every 5 minutes, one at a time and never while a question is being answered: a press is sent the one drawn in the last 5½ minutes at once (its write-up kept in the chat's memory, as for one drawn on the spot); an alert's chart is still drawn when it goes out, so it shows the reading the alert is about. A chart is uploaded once: sending it again (to the next chat an alert goes to, toggling back to a period drawn lately, the next press of a button) uses Telegram's copy. -- Each chart reply logs where its time went: `Chart times: data 12 ms, calculation 6 ms, drawing 112 ms, PNG 41 ms (22 KB), sending 812 ms`, or `drawn 40s before, sending 210 ms (Telegram's copy)` for one drawn ahead. +- Each chart reply logs where its time went: `Chart ms: data 12, calc 6, draw 112, PNG 41 (22 KB), send 812`, or `Chart ms: drawn 40s ago, send 210 (Telegram's copy)` for one drawn ahead. - The whole history of both sensors is kept in SQLite (`ecowitt_cache.sqlite`, `airgradient_cache.sqlite`): a background archive copies every Ecowitt cycle (5-minute, 30-minute, 4-hour, daily) before Ecowitt expires it, and backfills AirGradient day by day to where the sensor's diff --git a/ecobot.py b/ecobot.py index c33f7bc..beb3116 100644 --- a/ecobot.py +++ b/ecobot.py @@ -109,7 +109,8 @@ def wrapped(head: str, items: list[str], tail: str = "", width: int = 90) -> str async def main(): log.info("Starting ecobot %s", version()) cfg = Config.from_env() - _handler.formatter.width = cfg.log_width # lines quoting a request or a chat message are cut to it from here on + _handler.formatter.width = cfg.log_width # every log line is cut to it from here on + WRAP = max(cfg.log_width - len("INFO "), 30) if cfg.log_width else 90 # where the lists in the log wrap intent.FEELS_LIKE_IN_ALL = cfg.chart_all_feels_like intent.VPD_IN_ALL = cfg.chart_all_vpd sources = await start_sources(cfg) @@ -125,7 +126,7 @@ async def main(): agent = Agent(AsyncOpenAI(api_key=cfg.xai_api_key, base_url=cfg.xai_base_url), cfg.xai_model, tools) state = AlertState(cfg.state_path) if cfg.admin_only: - log.info("ADMIN_ONLY=on the bot only answers admins of known groups; set ADMIN_CHAT_IDS for more") + log.info("ADMIN_ONLY=on: only admins are answered (more: 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") @@ -151,8 +152,8 @@ async def main(): 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 (sources: %s)", app.bot.username, cfg.xai_model, - ", ".join(s.name for s in sources)) + 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) notify = Notifier(app.bot, state, kinds) await bot.refresh_keyboards(app.bot) # chats whose buttons are out of date are told, with the new ones @@ -179,7 +180,7 @@ async def main(): # 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)) + log.info("%s", wrapped("Keeping warm: ", [f"{s.name} {s.warmer.interval:.0f}s" for s in sources], "", WRAP)) async def startup_warmup(): """Fetch everything once, together, and log one summary line.""" @@ -208,7 +209,7 @@ async def air_part(): async def air_backfill(): started = time.monotonic() fetched, failed, total = await air.backfill() - log.info("AirGradient archive: %d day(s) cached, %d failed, %d day(s) held, %.0fs", + log.info("AirGradient archive: %d days cached, %d failed, %d held, %.0fs", fetched, failed, total, time.monotonic() - started) try: @@ -219,7 +220,7 @@ async def air_backfill(): 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("%s", wrapped("Alerts: ", [LABELS[k] for k in on] or ["none"], f": {len(state.alert_chats())} chats", cfg.log_width or 90)) + log.info("%s", wrapped("Alerts: ", [LABELS[k] for k in on] or ["none"], f": {len(state.alert_chats())} chats", WRAP)) 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)) diff --git a/lib/bot.py b/lib/bot.py index d08114d..a8ac48c 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -77,16 +77,16 @@ def _short(text: str, limit: int = 200) -> str: def chart_times(times: dict, made: float | None, pngs: list[bytes], sending: float) -> str: - """A reply's chart timings for the log: "data 12 ms, calculation 3 ms, drawing 98 ms, PNG 41 ms (23 KB), sending 812 ms". times is + """A reply's chart timings for the log: "data 12, calc 3, draw 98, PNG 41 (23 KB), send 812" (ms). times is the question's Turn.times; made is how long the tool took when the chart was made in code (the calculation is that less its data), else None. A step that wasn't timed is left out.""" - ms = lambda seconds: f"{seconds * 1000:.0f} ms" + ms = lambda seconds: f"{seconds * 1000:.0f}" parts = [f"data {ms(times['data'])}"] if "data" in times else [] if made is not None: - parts.append(f"calculation {ms(max(0.0, made - times.get('data', 0.0)))}") + parts.append(f"calc {ms(max(0.0, made - times.get('data', 0.0)))}") if "draw" in times: - parts += [f"drawing {ms(times['draw'])}", f"PNG {ms(times['png'])} ({sum(map(len, pngs)) / 1024:.0f} KB)"] - return ", ".join([*parts, f"sending {ms(sending)}"]) + parts += [f"draw {ms(times['draw'])}", f"PNG {ms(times['png'])} ({sum(map(len, pngs)) / 1024:.0f} KB)"] + return ", ".join([*parts, f"send {ms(sending)}"]) def split_message(text: str, limit: int = TG_LIMIT) -> list[str]: @@ -622,7 +622,7 @@ async def on_period_button(self, update: Update, context: ContextTypes.DEFAULT_T self.images.sent(key, await message.edit_media(InputMediaPhoto(photo, caption=title or None), reply_markup=periods.keyboard(days))) self.charted.remember(message.chat_id, message.message_id, asked) self.preload_soon(context.bot, asked) - log.info("Period button: chart from the cache, sent in %.0f ms%s", (time.monotonic() - began) * 1000, + log.info("Period button: cached chart sent in %.0f ms%s", (time.monotonic() - began) * 1000, " (Telegram's copy)" if isinstance(photo, str) else "") return except TelegramError as e: @@ -908,7 +908,7 @@ async def predraw(self): except Exception as e: if text not in self.predraw_failing: self.predraw_failing.add(text) - log.warning("Couldn't draw %r ahead (%s: %s); pressing it draws it", text, type(e).__name__, e) + log.warning("Can't draw %r ahead: %s: %s", text, type(e).__name__, e) continue key, png, title, caption = drawn self.images.put(key, png, title, caption) @@ -918,7 +918,7 @@ async def predraw(self): log.info("Drawn ahead again: %s", text) if not self.predrawn: self.predrawn = True - log.info("Button charts drawn ahead every %ds: %s (%.1fs)", PREDRAW_SECONDS, ", ".join(done) or "none", time.monotonic() - started) + log.info("Button charts drawn ahead every %ds: %d in %.1fs", PREDRAW_SECONDS, len(done), time.monotonic() - started) async def respond(self, update: Update, context: ContextTypes.DEFAULT_TYPE, text: str, redraw: Message | None = None): """Answer a question (_respond), counted while it runs: no chart is drawn ahead meanwhile.""" @@ -1036,10 +1036,10 @@ async def _respond(self, update: Update, context: ContextTypes.DEFAULT_TYPE, tex 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.info("Chart times: drawn %.0fs before, sending %.0f ms%s", self.images.age(ready), (time.monotonic() - began) * 1000, + log.info("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 "") elif drawn: - log.info("Chart times: %s", chart_times(turn.times, made, drawn, time.monotonic() - began)) + log.info("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]) diff --git a/lib/ecowitt/archive.py b/lib/ecowitt/archive.py index 8602968..c8432cc 100644 --- a/lib/ecowitt/archive.py +++ b/lib/ecowitt/archive.py @@ -114,7 +114,7 @@ def held(self) -> str: """Days of history held per resolution, e.g. "88/363/728/1456 days (5min/30min/4h/1d)" """ cycles = ("5min", "30min", "4hour", "1day") days = "/".join(str(self.station.cache.days_held(self.station.mac, c, self.groups)) for c in cycles) - return f"{days} days (5min/30min/4h/1d)" + return f"{days} days (5m/30m/4h/1d)" def _seconds_until_next_run(self) -> float: now = datetime.now(self.station.tz) diff --git a/lib/ecowitt/query.py b/lib/ecowitt/query.py index e23b4e0..16cd1d4 100644 --- a/lib/ecowitt/query.py +++ b/lib/ecowitt/query.py @@ -319,9 +319,9 @@ def _answer(self, refined: int) -> str: if key.split(".", 1)[-1] not in DERIVED or key.split(".", 1)[-1] in wanted} # the rest: cached, not sent series_out.update(self.direction) shown = {k: v for k, v in series_out.items() if self._in_focus(k)} # what the model is sent (charts use all of series_out) - log.log(logging.DEBUG if self.turn.ahead else logging.INFO, "%s to %s: %d ranges, %d cached, %d in mem, %d req%s, %d series, %d refined", - f"{self.start:%Y-%m-%d}", f"{self.end:%m-%d %H:%M}", f.ranges, f.from_cache, f.from_memory, f.calls, - f", {len(f.errors)} failed" if f.errors else "", len(series_out), refined) + 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} 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 " diff --git a/lib/ecowitt/source.py b/lib/ecowitt/source.py index a3e74cb..ff4c0e6 100644 --- a/lib/ecowitt/source.py +++ b/lib/ecowitt/source.py @@ -100,10 +100,11 @@ def get(group: str, field: str) -> str | None: obj = (data.get(group) or {}).get(field) if not isinstance(obj, dict) or "value" not in obj: return None - return f"{obj['value']} {obj.get('unit', '')}".replace("℃", "°C").replace("mm/hr", "mm/h").strip() + unit = obj.get("unit", "").replace("℃", "°C").replace("mm/hr", "mm/h") + 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, - f"{daily} today" if (daily := get("rainfall", "daily")) else None, + f"{daily}/day" if (daily := get("rainfall", "daily")) else None, get("outdoor", "temperature"), get("outdoor", "humidity"), f"wind {wind}" if wind else None, get("pressure", "relative")] return ", ".join(p for p in parts if p) or "no readings" @@ -229,7 +230,7 @@ async def live_rain(self) -> tuple[int, dict] | None: raise ValueError("no rain reading in the response") except Exception as e: # the alert still works from the history if self._live_ok is not False: # once per outage, not every minute - log.warning("Live readings failed (%s); the rain alert uses the 5-minute history", e) + log.warning("Live readings failed: %s; rain alert uses history", e) self._live_ok = False return None if self._live_ok is None: diff --git a/lib/pollen/source.py b/lib/pollen/source.py index 0022131..6337c0a 100644 --- a/lib/pollen/source.py +++ b/lib/pollen/source.py @@ -54,7 +54,7 @@ async def start(self): return try: await self.warm(True) - log.info("Pollen: %s", "; ".join(self.lines()) or "no levels on the page") + log.info("Pollen: %s", self.summary()) except Exception as e: # the site may just be briefly unreachable log.warning("Pollen not readable yet: %s", e) @@ -127,6 +127,16 @@ 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" ({g['date']:%a %d %b})" 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/tests/test_bot.py b/tests/test_bot.py index 87bf083..b4efff9 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -371,10 +371,10 @@ async def send_chat_action(*a, **k): await Bot(NS(tz=TZ), Agent(), sources, None).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 times:")] + lines = [r.getMessage() for r in caplog.records if r.getMessage().startswith("Chart ms:")] assert len(sent) == 1 and len(lines) == 1, lines kb = round(len(sent[0]) / 1024) - assert re.fullmatch(rf"Chart times: data \d+ ms, calculation \d+ ms, drawing \d+ ms, PNG \d+ ms \({kb} KB\), sending \d+ ms", lines[0]) + 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): diff --git a/tests/test_ecowitt.py b/tests/test_ecowitt.py index 9a9c5ad..767a91d 100644 --- a/tests/test_ecowitt.py +++ b/tests/test_ecowitt.py @@ -319,7 +319,7 @@ async def test_archive_reports_how_much_history_is_held(tmp_path, monkeypatch): eco = Ecowitt(config(tmp_path), transport=transport) await eco.start() arch = Archive(eco) - assert arch.held() == "0/0/0/0 days (5min/30min/4h/1d)" + assert arch.held() == "0/0/0/0 days (5m/30m/4h/1d)" await arch.run_once() held = {c: eco.cache.days_held(eco.mac, c, arch.groups) for c in ("5min", "30min", "4hour", "1day")} assert all(55 <= held[c] <= 62 for c in ("5min", "30min", "4hour", "1day")) @@ -1315,8 +1315,8 @@ def test_the_live_summary_gives_rain_first_then_a_few_readings_and_skips_what_is cell = lambda v, u: {"value": v, "unit": u} data = {"rainfall": {"rain_rate": cell("0.0", "mm/hr"), "daily": cell("0.8", "mm")}, "outdoor": {"temperature": cell("20.3", "℃"), "humidity": cell("62", "%")}, "wind": {"wind_speed": cell("12", "km/h")}, "pressure": {"relative": cell("1013.2", "hPa")}} - assert live_summary(data) == "rain 0.0 mm/h, 0.8 mm today, 20.3 °C, 62 %, wind 12 km/h, 1013.2 hPa" - assert live_summary({"rainfall": {"daily": cell("0.8", "mm")}}) == "0.8 mm today" and live_summary({}) == "no readings" + assert live_summary(data) == "rain 0.0 mm/h, 0.8 mm/day, 20.3°C, 62%, wind 12 km/h, 1013.2 hPa" + assert live_summary({"rainfall": {"daily": cell("0.8", "mm")}}) == "0.8 mm/day" and live_summary({}) == "no readings" async def test_a_single_reading_chart_is_labelled_with_the_reading_not_its_group(tmp_path, archived_cache): diff --git a/tests/test_startup.py b/tests/test_startup.py index c133754..17d01db 100644 --- a/tests/test_startup.py +++ b/tests/test_startup.py @@ -66,7 +66,7 @@ async def test_main_starts_warms_and_shuts_down_cleanly(tmp_path, monkeypatch, c async def stop_when_archived(): # the background archives finish, then we shut down for _ in range(300): - if " req, held " in caplog.text and "day(s) cached" in caplog.text: + if " req, held " in caplog.text and "days cached" in caplog.text: break await asyncio.sleep(0.1) os.kill(os.getpid(), signal.SIGTERM) @@ -77,7 +77,7 @@ async def stop_when_archived(): # the background archives finish, then we shut assert "Weather station: Ecowitt 'Fairleigh'" in log and "@testbot + " in log assert "Startup warm-up:" in log and " req to fetch" in log and "AirGradient archive:" in log final = next(line for line in log.splitlines() if "Ecowitt archive:" in line and " req, held " in line) - assert "failed" not in final and "days (5min/30min/4h/1d)" in final + assert "failed" not in final and "days (5m/30m/4h/1d)" in final assert len(app.handlers) == 10 # start+help, reset, keyboard, alerts, usage, the one button handler, other updates, membership, messages, errors assert "Alerts: rain, gusts, UV, temperature crossing, particulates: 0 chats" in log and "Alerts NOT running" not in log assert ecobot._handler.formatter.width == 120 # LOG_WIDTH reaches the bot's own log lines @@ -103,7 +103,7 @@ async def broken(self): async def stop_when_archived(): for _ in range(300): - if " req, held " in caplog.text and "day(s) cached" in caplog.text: + if " req, held " in caplog.text and "days cached" in caplog.text: break await asyncio.sleep(0.1) os.kill(os.getpid(), signal.SIGTERM) @@ -241,7 +241,7 @@ def tuya(request): async def stop_when_archived(): for _ in range(300): - if " req, held " in caplog.text and "day(s) cached" in caplog.text: + if " req, held " in caplog.text and "days cached" in caplog.text: break await asyncio.sleep(0.1) os.kill(os.getpid(), signal.SIGTERM) @@ -277,8 +277,8 @@ def line(msg, *args, quotes=True): asked = "what was the hottest it got last week, and when?" assert line("[@rob] %s", "how hot\nis it") == "INFO [@rob] how hot is it" # short enough: whole, on one line assert line("[@rob] %s", asked) == "INFO [@rob] what was the hottest it got…" # 40 characters, the ellipsis included - timings = "Chart times: data 12 ms, calculation 6 ms, drawing 112 ms, PNG 41 ms" - assert line(timings, quotes=False) == "INFO Chart times: data 12 ms, calculati…" # a line that quotes nothing is cut too + timings = "Chart ms: data 12, calc 6, draw 112, PNG 41, send 9, more words" + assert line(timings, quotes=False) == "INFO Chart ms: data 12, calc 6, draw 11…" # a line that quotes nothing is cut too assert line("a\n b " + "x" * 50, quotes=False).split("\n")[1] == " b " + "x" * 33 + "…" # each line of a long message fmt.width = 0 assert line("[@rob] %s", asked) == "INFO [@rob] " + asked # 0 cuts none From a9ffaea190be74836bea545e40de321a2ac871e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 19:07:30 +0000 Subject: [PATCH 16/22] Log WARNING as WARN and never cut a WARN or an ERROR to LOG_WIDTH The ADMIN_ONLY startup line says "see: ADMIN_CHAT_IDS". Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 2 +- ecobot.py | 2 +- lib/logs.py | 19 ++++++++++++++----- tests/test_startup.py | 3 +++ 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 310c849..1ec5a96 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Under systemd see `ecobot.service` (it loads the environment file; the clone's l | `TUYA_DEVICE_ID`, `TUYA_CLIENT_ID`, `TUYA_CLIENT_SECRET` | enables the irrigation controller (a Tuya smart irrigation timer, through the Tuya cloud API): an Irrigation button in private chats (it replaces Wind) with its status and buttons to water for 5 to 30 minutes or switch the valve off, pause for 24 to 72 hours (the Pause button becomes Unpause while paused), refresh, and an Alerts menu to turn the chat's irrigation battery and irrigation pause alerts on or off (or type irrigation, water, tap or sprinkler); a daily check at 18:00 that delays it 24 hours when more than 1 mm fell in the last 24 hours or Open-Meteo gives today or tomorrow a 50% or better chance of 1 mm (needs Ecowitt and `FORECAST=on` for each half); an "irrigation pause" alert when it does, and an "irrigation battery" alert below 5%. `TUYA_BASE_URL` (default `https://openapi.tuyaeu.com`, the EU data centre) and `IRRIGATION_CHECK_HOUR` (default `18`) are optional | | `CHART_ALL_VPD=on` | optional: "weather all week" also draws the vapour pressure deficit panel; off by default, as it is temperature and humidity combined (the station reports it for the outdoor sensor only). Naming it ("plot vpd") always works | | `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 (one at a time, never while a question 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. Unset: off | -| `LOG_WIDTH` | optional: the most characters of a log line, cut with an ellipsis (…); `0` cuts none. Default 71. Every line obeys it (each line of a multi-line entry, such as a traceback, too); a request, chat message, reply or alert is also put on one line, and the startup "Alerts:" list wraps at it (at 90 when `0`) | +| `LOG_WIDTH` | optional: the most characters of a log line, cut with an ellipsis (…); `0` cuts none. Default 71. Every INFO line obeys it; a `WARN` or `ERROR` entry is never cut (a warning is printed as `WARN`); a request, chat message, reply or alert is also put on one line, and the startup "Alerts:" list wraps at it (at 90 when `0`) | ## Layout diff --git a/ecobot.py b/ecobot.py index beb3116..6a835b7 100644 --- a/ecobot.py +++ b/ecobot.py @@ -126,7 +126,7 @@ async def main(): agent = Agent(AsyncOpenAI(api_key=cfg.xai_api_key, base_url=cfg.xai_base_url), cfg.xai_model, tools) state = AlertState(cfg.state_path) if cfg.admin_only: - log.info("ADMIN_ONLY=on: only admins are answered (more: ADMIN_CHAT_IDS)") + 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") diff --git a/lib/logs.py b/lib/logs.py index cf93be6..e347ed9 100644 --- a/lib/logs.py +++ b/lib/logs.py @@ -1,5 +1,6 @@ -"""How the bot's log lines look. Every line of the log is cut to the log width (LOG_WIDTH), an ellipsis (…) marking the cut. 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 an INFO or DEBUG entry is cut to the log width (LOG_WIDTH), an ellipsis (…) marking the +cut; a WARN or an ERROR is never cut. 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 @@ -12,7 +13,8 @@ def fit(text: str, room: int) -> str: class LogFormat(logging.Formatter): - """"INFO message": the level 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 to the width, + except for a WARN or an ERROR.""" def __init__(self, fmt: str, width: int = 0): """width: the most characters of a line of the log; 0 cuts none.""" @@ -20,8 +22,15 @@ def __init__(self, fmt: str, width: int = 0): self.width = width def format(self, record): - line = super().format(record) + shown = record.levelname + record.levelname = "WARN" if shown == "WARNING" else shown + try: + line = super().format(record) + finally: + record.levelname = shown 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) line = line[:len(line) - len(quote)] + " ".join(quote.split()) # the quote on one line - return "\n".join(fit(part, self.width) for part in line.split("\n")) if self.width else line + if not self.width or record.levelno >= logging.WARNING: + return line + return "\n".join(fit(part, self.width) for part in line.split("\n")) diff --git a/tests/test_startup.py b/tests/test_startup.py index 17d01db..231403b 100644 --- a/tests/test_startup.py +++ b/tests/test_startup.py @@ -280,6 +280,9 @@ def line(msg, *args, quotes=True): timings = "Chart ms: data 12, calc 6, draw 112, PNG 41, send 9, more words" assert line(timings, quotes=False) == "INFO Chart ms: data 12, calc 6, draw 11…" # a line that quotes nothing is cut too assert line("a\n b " + "x" * 50, quotes=False).split("\n")[1] == " b " + "x" * 33 + "…" # each line of a long message + for level, name in ((logging.WARNING, "WARN"), (logging.ERROR, "ERROR")): # a warning or an error is shown whole + record = logging.LogRecord("lib.bot", level, __file__, 1, timings, None, None) + assert fmt.format(record) == f"{name} " + timings and record.levelname == logging.getLevelName(level) fmt.width = 0 assert line("[@rob] %s", asked) == "INFO [@rob] " + asked # 0 cuts none assert line(timings, quotes=False) == "INFO " + timings From 031e5519fbfe827888647b1e24460692e35951be Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 19:08:25 +0000 Subject: [PATCH 17/22] Ecowitt archive log lines start "Ecowitt:" without the word "archive" Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/ecowitt/archive.py | 6 +++--- tests/test_startup.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/ecowitt/archive.py b/lib/ecowitt/archive.py index c8432cc..88fc674 100644 --- a/lib/ecowitt/archive.py +++ b/lib/ecowitt/archive.py @@ -76,7 +76,7 @@ async def run_once(self) -> tuple[int, int]: # 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) if seconds >= 60: # a short run only gets the line when it finishes - log.info("Ecowitt archive: %d req to fetch (about %s)", len(todo), + 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 for i, (cycle, start, end) in enumerate(todo, 1): @@ -87,7 +87,7 @@ async def run_once(self) -> tuple[int, int]: else: fetched += 1 if i % 20 == 0 and i < len(todo): - log.info("Ecowitt archive: %d of %d req done", i, len(todo)) + log.info("Ecowitt: %d of %d req done", i, len(todo)) await asyncio.sleep(self.pace) return fetched, failed @@ -129,7 +129,7 @@ async def loop(self): try: started = datetime.now() fetched, failed = await self.run_once() - log.info("Ecowitt archive: %d req, %sheld %s, %.0fs", fetched, f"{failed} failed, " if failed else "", + log.info("Ecowitt: %d req, %sheld %s, %.0fs", fetched, f"{failed} failed, " if failed else "", self.held(), (datetime.now() - started).total_seconds()) except asyncio.CancelledError: raise diff --git a/tests/test_startup.py b/tests/test_startup.py index 231403b..cdfffc7 100644 --- a/tests/test_startup.py +++ b/tests/test_startup.py @@ -76,7 +76,7 @@ async def stop_when_archived(): # the background archives finish, then we shut log = caplog.text assert "Weather station: Ecowitt 'Fairleigh'" in log and "@testbot + " in log assert "Startup warm-up:" in log and " req to fetch" in log and "AirGradient archive:" in log - final = next(line for line in log.splitlines() if "Ecowitt archive:" in line and " req, held " in line) + final = next(line for line in log.splitlines() if "Ecowitt: " in line and " req, held " in line) assert "failed" not in final and "days (5m/30m/4h/1d)" in final assert len(app.handlers) == 10 # start+help, reset, keyboard, alerts, usage, the one button handler, other updates, membership, messages, errors assert "Alerts: rain, gusts, UV, temperature crossing, particulates: 0 chats" in log and "Alerts NOT running" not in log From e4a81f61a328a04c0f9be4c32c8aef9a3b595ad4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 19:09:52 +0000 Subject: [PATCH 18/22] Log the HTTP client's lines without the "HTTP Request:" prefix Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/logs.py | 6 ++++-- tests/test_startup.py | 9 +++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/logs.py b/lib/logs.py index e347ed9..4b09aee 100644 --- a/lib/logs.py +++ b/lib/logs.py @@ -22,12 +22,14 @@ def __init__(self, fmt: str, width: int = 0): self.width = width def format(self, record): - shown = record.levelname + shown, text = record.levelname, record.msg record.levelname = "WARN" if shown == "WARNING" else shown + 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) finally: - record.levelname = shown + 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) line = line[:len(line) - len(quote)] + " ".join(quote.split()) # the quote on one line diff --git a/tests/test_startup.py b/tests/test_startup.py index cdfffc7..c47ca0e 100644 --- a/tests/test_startup.py +++ b/tests/test_startup.py @@ -310,6 +310,15 @@ def test_the_preload_chat_is_a_chat_id_or_off(monkeypatch): assert Config.from_env().telegram_preload_chat == want, value +def test_the_http_clients_lines_do_not_start_with_http_request(): + from lib.logs import LogFormat + fmt = LogFormat("%(levelname)s %(message)s", width=80) + record = logging.LogRecord("httpx2", logging.INFO, __file__, 1, 'HTTP Request: %s %s "%s %d %s"', + ("POST", "https://api.x.ai/v1/chat/completions", "HTTP/1.1", 200, "OK"), None) + assert fmt.format(record) == 'INFO POST https://api.x.ai/v1/chat/completions "HTTP/1.1 200 OK"' + assert record.msg.startswith("HTTP Request: ") # the record itself is left as it was + + def test_the_alerts_list_wraps_at_the_log_width(): from lib.alerts.menu import LABELS lines = ecobot.wrapped("Alerts: ", list(LABELS.values()), ": 3 chats", 40).splitlines() From def4796e0abad710868d2b9110aa4e60f19a977d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 19:17:55 +0000 Subject: [PATCH 19/22] Leave single-reading spikes of the particles out of PM charts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A PM1, PM2.5 or PM10 reading that is at least 2.5 times, and 5 µg/m³ above, both readings next to it (within 15 minutes) is a glitch of the sensor: that moment's particle readings are left out of the drawing and of its peak labels. A rise lasting two readings or more is the air and stays. The first and last readings are kept. Only charts change; the figures the tool gives are as they were. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/airgradient/metrics.py | 20 ++++++++++++++++++++ lib/airgradient/source.py | 6 ++++-- tests/test_airgradient.py | 25 ++++++++++++++++++++++--- 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/lib/airgradient/metrics.py b/lib/airgradient/metrics.py index 06286b8..af33864 100644 --- a/lib/airgradient/metrics.py +++ b/lib/airgradient/metrics.py @@ -37,6 +37,26 @@ # is left out. AIR_PANELS = (("pm1", "pm2_5", "pm10"), ("co2",), ("voc_index",), ("nox_index",)) +PARTICLES = AIR_PANELS[0] +SPIKE_RATIO = 2.5 # a particle reading that is this many times the readings either side of it ... +SPIKE_RISE = 5.0 # ... and this far (µg/m³) above both, with nothing like it next to it, is a glitch of the sensor, not the air +SPIKE_NEIGHBOUR = 900 # the readings either side must be within this many seconds + + +def without_spikes(rows: list[dict]) -> list[dict]: + """The readings with each single-reading spike of the particles taken out, for drawing: a reading of PM1, PM2.5 or PM10 that is at + least SPIKE_RATIO times, and SPIKE_RISE above, both the readings next to it. All the particle readings of that moment go (they spike + together). A rise that lasts two readings or more is the air, and stays. The first and last readings are kept: they have one side.""" + drop: set[int] = set() + for name in PARTICLES: + have = [(i, r["ts"], r[name]) for i, r in enumerate(rows) if name in r] + for (_, t0, before), (i, t, v), (_, t1, after) in zip(have, have[1:], have[2:]): + near = max(before, after) + if v >= SPIKE_RATIO * near and v - near >= SPIKE_RISE and t - t0 <= SPIKE_NEIGHBOUR and t1 - t <= SPIKE_NEIGHBOUR: + drop.add(i) + return [{k: v for k, v in r.items() if k not in PARTICLES} if i in drop else r for i, r in enumerate(rows)] + + # US EPA 2024 PM2.5 breakpoints: (conc low, conc high, AQI low, AQI high, band) PM25_AQI = [ (0.0, 9.0, 0, 50, "good"), diff --git a/lib/airgradient/source.py b/lib/airgradient/source.py index b765079..50c96c2 100644 --- a/lib/airgradient/source.py +++ b/lib/airgradient/source.py @@ -31,7 +31,7 @@ from ..tools import Tool, Turn from ..warm import Warmer from .days import DESCRIPTION as DAYS_DESCRIPTION, PARAMETERS as DAYS_PARAMETERS, air_days_tool -from .metrics import AIR_PANELS, ALL_METRICS, MARK_LOW, LABELS, METRICS, epoch, normalise, pm25_aqi, rating, value_of +from .metrics import AIR_PANELS, ALL_METRICS, MARK_LOW, LABELS, METRICS, epoch, normalise, pm25_aqi, rating, value_of, without_spikes from .store import AirStore log = logging.getLogger(__name__) @@ -376,7 +376,9 @@ 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).""" + shared time axis, grouped by AIR_PANELS (the particles together, the rest each alone). A single-reading spike of the particles is + left out of the drawing (and of its records), as a glitch of the sensor.""" + rows = without_spikes(rows) drawn = {n: got for n in names if (got := self._line(n, rows))} if not drawn: return None diff --git a/tests/test_airgradient.py b/tests/test_airgradient.py index dde7a9b..b9c0e61 100644 --- a/tests/test_airgradient.py +++ b/tests/test_airgradient.py @@ -137,10 +137,10 @@ async def test_missing_days_are_fetched_inline_only_up_to_a_limit(tmp_path, monk async def test_long_charts_are_averaged_but_keep_the_true_peak(tmp_path): air, _ = await make(tmp_path) ts = list(range(1_780_000_000, 1_780_000_000 + 40 * 86400, 60)) # 40 days at 1-minute readings - rows = [{"ts": t, "pm2_5": 5.0 + (300.0 if t == ts[5000] else 0.0)} for t in ts] + rows = [{"ts": t, "pm2_5": 5.0 + (300.0 if ts[5000] <= t <= ts[5002] else 0.0)} for t in ts] # three readings: the air, not a glitch line = air._chart(["pm2_5"], rows, "period").panels[0].lines[0] assert len(line.x) <= 500 - assert line.records["high"] == (ts[5000], 305.0) and "low" not in line.records # PM's lowest is often 0: not marked + assert line.records["high"][1] == 305.0 and "low" not in line.records # PM's lowest is often 0: not marked rows = [{"ts": t, "co2": 450.0 + (300.0 if t == ts[5000] else 0.0)} for t in ts] assert air._chart(["co2"], rows, "period").panels[0].lines[0].records["low"][1] == 450.0 # CO2's lowest is await air.close() @@ -150,7 +150,7 @@ async def test_bucketed_air_lines_carry_a_band_and_the_peak_label_sits_on_its_to from lib.charts import _extreme air, _ = await make(tmp_path) ts = list(range(1_780_000_000, 1_780_000_000 + 40 * 86400, 60)) - rows = [{"ts": t, "pm2_5": 5.0 + (300.0 if t == ts[5000] else 0.0)} for t in ts] + rows = [{"ts": t, "pm2_5": 5.0 + (300.0 if ts[5000] <= t <= ts[5002] else 0.0)} for t in ts] # three readings: the air, not a glitch line = air._chart(["pm2_5"], rows, "period").panels[0].lines[0] # 40 days: 4-hour buckets assert line.low is not None and max(line.y) < 305.0 and max(line.high) == 305.0 assert _extreme(line, "high", range(len(line.x)))[1] == 305.0 # the label is the true peak, at the band's top @@ -348,3 +348,22 @@ async def test_air_days_counts_days_by_a_metric_and_draws_the_months_as_bars(tmp assert [r["pm2_5_max"] for r in worst["days"]] == [40.0, 40.0] bad = json.loads(await air_days_tool(store, TZ, dict(start_date=str(start), end_date=str(end), where=[{"field": "uv_max", "op": ">", "value": 1}]))) assert "unusable condition" in bad["error"] + + +def test_a_single_reading_spike_of_the_particles_is_left_out_of_a_chart_and_a_lasting_rise_is_not(): + from lib.airgradient.metrics import without_spikes + base = 1_790_000_000 + row = lambda i, pm1, pm25, pm10, co2=450: {"ts": base + 300 * i, "pm1": pm1, "pm2_5": pm25, "pm10": pm10, "co2": co2} + rows = [row(0, 1, 2, 3), row(1, 1, 2, 4), row(2, 15.3, 14.2, 28.7, 900), row(3, 3, 8, 10), row(4, 2, 4, 5), row(5, 2, 3, 4)] + kept = without_spikes(rows) + assert [r["ts"] for r in kept] == [r["ts"] for r in rows] # the moment stays, for CO2 + assert "pm10" not in kept[2] and "pm1" not in kept[2] and "pm2_5" not in kept[2] and kept[2]["co2"] == 900 + assert all(r == o for i, (r, o) in enumerate(zip(kept, rows)) if i != 2) # nothing else is touched + lasting = [row(0, 1, 2, 3), row(1, 1, 2, 4), row(2, 20, 20, 30), row(3, 22, 21, 32), row(4, 2, 4, 5)] + assert without_spikes(lasting) == lasting # two readings in a row: the air + gentle = [row(0, 3, 4, 5), row(1, 3, 4, 9), row(2, 3, 4, 5)] + assert without_spikes(gentle) == gentle # high, but not by 5 µg/m³ and 2.5 times + ends = [row(0, 1, 2, 40), row(1, 1, 2, 3), row(2, 1, 2, 3)] + assert without_spikes(ends) == ends # the first reading has one side only + apart = [row(0, 1, 2, 3), {**row(1, 1, 2, 60), "ts": base + 3600}, {**row(2, 1, 2, 3), "ts": base + 7200}] + assert without_spikes(apart) == apart # hours from its neighbours: not a spike in a series From 57154fd7a4e042bdd87fe96364a48605eca54d58 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 19:23:14 +0000 Subject: [PATCH 20/22] Single-reading particle spikes are in none of the air figures either The air history's highs, lows, averages and ratings, and the day statistics (days over a limit, a day's highest), now leave out the single-reading spikes that charts already leave out, so the numbers and the pictures agree. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/airgradient/days.py | 4 ++-- lib/airgradient/source.py | 6 +++--- tests/test_airgradient.py | 25 +++++++++++++++++++++++-- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/lib/airgradient/days.py b/lib/airgradient/days.py index d0bcd3d..9776b4e 100644 --- a/lib/airgradient/days.py +++ b/lib/airgradient/days.py @@ -10,7 +10,7 @@ from ..daytable import analyse, grouped_as_asked, parameters, validate from ..timeutil import day_bounds, local_date, now_local -from .metrics import ALL_METRICS, CHART_UNITS +from .metrics import ALL_METRICS, CHART_UNITS, without_spikes log = logging.getLogger(__name__) @@ -31,7 +31,7 @@ def daily_values(rows: list[dict], shown: list[str], tz: tzinfo) -> dict[str, dict[date, float]]: """{field: {local day: that day's highest, lowest or mean}} from the stored readings.""" by_day: dict[date, list[dict]] = {} - for r in rows: + for r in without_spikes(rows): # a single-reading spike of the particles is a glitch of the sensor, not a day's highest by_day.setdefault(local_date(r["ts"], tz), []).append(r) values: dict[str, dict[date, float]] = {n: {} for n in shown} for name in shown: diff --git a/lib/airgradient/source.py b/lib/airgradient/source.py index 50c96c2..97a0ff7 100644 --- a/lib/airgradient/source.py +++ b/lib/airgradient/source.py @@ -325,6 +325,7 @@ 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}", @@ -376,9 +377,8 @@ 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). A single-reading spike of the particles is - left out of the drawing (and of its records), as a glitch of the sensor.""" - rows = without_spikes(rows) + 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/tests/test_airgradient.py b/tests/test_airgradient.py index b9c0e61..427fb53 100644 --- a/tests/test_airgradient.py +++ b/tests/test_airgradient.py @@ -326,8 +326,8 @@ def _air_store(tmp_path): for k in range(40): day = start + timedelta(days=k) lo = int(datetime.combine(day, datetime.min.time()).replace(tzinfo=TZ).timestamp()) - spike = 40.0 if k % 10 == 0 else 8.0 # every tenth day particles reach 40 µg/m³ - store.save_day(day, [{"ts": lo + 300 * i, "pm2_5": spike if i == 100 else 5.0, "co2": 420.0 + i} for i in range(288)]) + spike = 40.0 if k % 10 == 0 else 8.0 # every tenth day particles reach 40 µg/m³ (for three readings: the air, not a glitch) + store.save_day(day, [{"ts": lo + 300 * i, "pm2_5": spike if 100 <= i <= 102 else 5.0, "co2": 420.0 + i} for i in range(288)]) return store, start @@ -367,3 +367,24 @@ def test_a_single_reading_spike_of_the_particles_is_left_out_of_a_chart_and_a_la assert without_spikes(ends) == ends # the first reading has one side only apart = [row(0, 1, 2, 3), {**row(1, 1, 2, 60), "ts": base + 3600}, {**row(2, 1, 2, 3), "ts": base + 7200}] assert without_spikes(apart) == apart # hours from its neighbours: not a spike in a series + + +async def test_a_single_reading_spike_is_in_none_of_the_figures_either(tmp_path): + from datetime import timedelta + from lib.airgradient.days import daily_values + 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()) + 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 + 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 + assert daily_values(rows, ["pm10_max", "pm2_5_max"], TZ)["pm10_max"][local_date(t0, TZ)] == 10 + await air.close() From fd5da17d2425e1b86dfc6f75930bda7914956709 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 19:27:07 +0000 Subject: [PATCH 21/22] The unhealthy-air alert counts a check only when the sensor's last two readings are both that high The alert already needed two checks in a row, but each check looked at the latest reading alone, so a spike at a check counted as one of the two. air.current() now also gives each metric's lower value of the last two readings (_both, for the alerts), and a check is "bad" only when that is at or over the mask level. The "safe again" check is unchanged. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/airgradient/source.py | 3 +++ lib/alerts/particulates.py | 7 +++++-- tests/test_alerts.py | 21 +++++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/lib/airgradient/source.py b/lib/airgradient/source.py index 97a0ff7..d764f1b 100644 --- a/lib/airgradient/source.py +++ b/lib/airgradient/source.py @@ -253,6 +253,7 @@ async def handle(self, args: dict, turn: Turn | None = None) -> str: ensure_ascii=False) reading = await self.current() reading.pop("_time_utc", None) + reading.pop("_both", None) return json.dumps(reading, ensure_ascii=False) except Exception as e: log.warning("AirGradient request failed: %s", e) @@ -289,9 +290,11 @@ async def current(self) -> dict: if "pm2_5" in out: aqi, band = pm25_aqi(out["pm2_5"]["_rated"]) out["pm2_5"].update(aqi_us=aqi, band=band) + both = {name: entry["_rated"] for name, entry in out.items() if isinstance(entry, dict) and "_rated" in entry} for entry in out.values(): if isinstance(entry, dict): entry.pop("_rated", None) + out["_both"] = both # each metric's lower value of the last two readings, for the alerts (removed before the model sees it) return out async def rows(self, t0: datetime, t1: datetime) -> tuple[list[dict], int, list[date]]: diff --git a/lib/alerts/particulates.py b/lib/alerts/particulates.py index 0c1059d..64e8f21 100644 --- a/lib/alerts/particulates.py +++ b/lib/alerts/particulates.py @@ -1,7 +1,8 @@ """Particulates alerts (PM2.5 and PM10) from the AirGradient sensor, checked every 30 minutes. "Wear a mask outside" when PM2.5 or PM10 reaches US AQI 151+ ("unhealthy", the official level -at which masks are advised for everyone) in two readings in a row; "back to safe" when both are at +at which masks are advised for everyone) in two readings in a row, each check counting only when the sensor's last two readings +are both that high (a single-reading spike never does); "back to safe" when both are at AQI 100 or below in two readings in a row (confirm.py: a check that brings no new reading counts for nothing). Every episode gets the pair. Particles only: masks filter particles, not gases like CO2/VOC/NOx. """ @@ -39,7 +40,9 @@ 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 - bad = (pm25 is not None and pm25 >= MASK_PM25) or (pm10 is not None and pm10 >= MASK_PM10) + both = reading.get("_both") or {} # the lower of the last two readings: one reading alone, a spike, can't make the air unhealthy + high25, high10 = both.get("pm2_5", pm25), both.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) confirm.run(m, "above", bad) confirm.run(m, "below", good) diff --git a/tests/test_alerts.py b/tests/test_alerts.py index 9bf0d2e..59fe6b7 100644 --- a/tests/test_alerts.py +++ b/tests/test_alerts.py @@ -754,3 +754,24 @@ async def notify(text, link=None, **kw): await again.check(air_reading(90)) await again.check(air_reading(90)) assert len(sent) == 1 and "mask" in sent[0] + + +async def test_a_single_spike_never_makes_the_air_unhealthy_but_two_readings_in_a_row_do(tmp_path): + state, sent = AlertState(tmp_path / "s.json"), [] + + async def notify(text, link=None, **kw): + sent.append(text) + 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["_both"] = {"pm2_5": min(latest, before)} # what air.current() gives: the lower of the last two readings + return r + 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 + await mon.check(reading(60, 90, 80)) # two in a row at this check ... + await mon.check(reading(90, 85, 90)) # ... and at the next + assert len(sent) == 1 and "Unhealthy air" in sent[0] From a5fe6e08fe9ca9e0f27a379aa90a61ba4f1d640f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 19:30:52 +0000 Subject: [PATCH 22/22] ANTISPIKE_REQUIRE_CONSECUTIVE: the readings in a row a rise of the particles needs (default 2, 1 or off: no rule) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One setting for the three behaviours: PM charts, the air figures (highs, lows, averages, days over a limit) and the unhealthy-air alert. A stretch of fewer than N readings in a row, each at least 2.5 times and 5 µg/m3 above the readings either side, is a glitch and is left out; with N = 3 two-reading spikes go too. The alert counts a check only when the sensor's last N readings are all over the mask level (air.current() gives that as _sustained, hidden from the model). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 1 + ecobot.py | 2 ++ lib/airgradient/metrics.py | 25 +++++++++++++++++-------- lib/airgradient/source.py | 23 ++++++++++++++++++++--- lib/alerts/particulates.py | 8 ++++---- lib/config.py | 9 +++++++++ tests/test_airgradient.py | 38 ++++++++++++++++++++++++++++++++++++++ tests/test_alerts.py | 2 +- 8 files changed, 92 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 1ec5a96..d688572 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Under systemd see `ecobot.service` (it loads the environment file; the clone's l | `TUYA_DEVICE_ID`, `TUYA_CLIENT_ID`, `TUYA_CLIENT_SECRET` | enables the irrigation controller (a Tuya smart irrigation timer, through the Tuya cloud API): an Irrigation button in private chats (it replaces Wind) with its status and buttons to water for 5 to 30 minutes or switch the valve off, pause for 24 to 72 hours (the Pause button becomes Unpause while paused), refresh, and an Alerts menu to turn the chat's irrigation battery and irrigation pause alerts on or off (or type irrigation, water, tap or sprinkler); a daily check at 18:00 that delays it 24 hours when more than 1 mm fell in the last 24 hours or Open-Meteo gives today or tomorrow a 50% or better chance of 1 mm (needs Ecowitt and `FORECAST=on` for each half); an "irrigation pause" alert when it does, and an "irrigation battery" alert below 5%. `TUYA_BASE_URL` (default `https://openapi.tuyaeu.com`, the EU data centre) and `IRRIGATION_CHECK_HOUR` (default `18`) are optional | | `CHART_ALL_VPD=on` | optional: "weather all week" also draws the vapour pressure deficit panel; off by default, as it is temperature and humidity combined (the station reports it for the outdoor sensor only). Naming it ("plot vpd") always works | | `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 (one at a time, never while a question 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. 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 INFO line obeys it; a `WARN` or `ERROR` entry is never cut (a warning is printed as `WARN`); a request, chat message, reply or alert is also put on one line, and the startup "Alerts:" list wraps at it (at 90 when `0`) | ## Layout diff --git a/ecobot.py b/ecobot.py index 6a835b7..aa35baf 100644 --- a/ecobot.py +++ b/ecobot.py @@ -22,6 +22,7 @@ from lib import intent from lib.airgradient import AirGradient +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 @@ -111,6 +112,7 @@ async def main(): cfg = Config.from_env() _handler.formatter.width = cfg.log_width # every log line is cut to it from here on WRAP = max(cfg.log_width - len("INFO "), 30) if cfg.log_width else 90 # where the lists in the log wrap + 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) diff --git a/lib/airgradient/metrics.py b/lib/airgradient/metrics.py index af33864..ac082ce 100644 --- a/lib/airgradient/metrics.py +++ b/lib/airgradient/metrics.py @@ -41,19 +41,28 @@ SPIKE_RATIO = 2.5 # a particle reading that is this many times the readings either side of it ... SPIKE_RISE = 5.0 # ... and this far (µg/m³) above both, with nothing like it next to it, is a glitch of the sensor, not the air SPIKE_NEIGHBOUR = 900 # the readings either side must be within this many seconds +REQUIRE_CONSECUTIVE = 2 # ANTISPIKE_REQUIRE_CONSECUTIVE: readings in a row a rise needs to be the air (set at startup); 1 turns the rule off -def without_spikes(rows: list[dict]) -> list[dict]: - """The readings with each single-reading spike of the particles taken out, for drawing: a reading of PM1, PM2.5 or PM10 that is at - least SPIKE_RATIO times, and SPIKE_RISE above, both the readings next to it. All the particle readings of that moment go (they spike - together). A rise that lasts two readings or more is the air, and stays. The first and last readings are kept: they have one side.""" +def without_spikes(rows: list[dict], require: int | None = None) -> list[dict]: + """The readings with each spike of the particles taken out, for drawing and for the figures: a stretch of fewer than `require` + readings in a row (REQUIRE_CONSECUTIVE) of PM1, PM2.5 or PM10 that are each at least SPIKE_RATIO times, and SPIKE_RISE above, the readings + either side of the stretch. All the particle readings of those moments go (they spike together). A rise that lasts `require` + readings or more is the air, and stays; so do the first and last readings (they have one side). require 1: nothing is taken out.""" + require = REQUIRE_CONSECUTIVE if require is None else require drop: set[int] = set() for name in PARTICLES: have = [(i, r["ts"], r[name]) for i, r in enumerate(rows) if name in r] - for (_, t0, before), (i, t, v), (_, t1, after) in zip(have, have[1:], have[2:]): - near = max(before, after) - if v >= SPIKE_RATIO * near and v - near >= SPIKE_RISE and t - t0 <= SPIKE_NEIGHBOUR and t1 - t <= SPIKE_NEIGHBOUR: - drop.add(i) + for a in range(1, len(have) - 1): + for length in range(1, require): + b = a + length - 1 # the stretch is have[a..b] + if b + 1 >= len(have): + break + (_, t0, before), (_, t1, after) = have[a - 1], have[b + 1] + near = max(before, after) + if have[a][1] - t0 <= SPIKE_NEIGHBOUR and t1 - have[b][1] <= SPIKE_NEIGHBOUR and all( + v >= SPIKE_RATIO * near and v - near >= SPIKE_RISE for _, _, v in have[a:b + 1]): + drop.update(i for i, _, _ in have[a:b + 1]) return [{k: v for k, v in r.items() if k not in PARTICLES} if i in drop else r for i, r in enumerate(rows)] diff --git a/lib/airgradient/source.py b/lib/airgradient/source.py index d764f1b..294cbbc 100644 --- a/lib/airgradient/source.py +++ b/lib/airgradient/source.py @@ -31,6 +31,7 @@ from ..tools import Tool, Turn 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 .store import AirStore @@ -253,7 +254,7 @@ async def handle(self, args: dict, turn: Turn | None = None) -> str: ensure_ascii=False) reading = await self.current() reading.pop("_time_utc", None) - reading.pop("_both", None) + reading.pop("_sustained", None) return json.dumps(reading, ensure_ascii=False) except Exception as e: log.warning("AirGradient request failed: %s", e) @@ -269,6 +270,17 @@ async def _previous_row(self, ts: int) -> dict | None: 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() + earlier, got, after = [r for r in await self._day_rows(today) if r["ts"] < ts], [], ts + for r in reversed(earlier): + if len(got) >= count or after - r["ts"] > MAX_GAP_SECONDS: + break + got.insert(0, r) + after = r["ts"] + return got + async def current(self) -> dict: """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.""" @@ -290,11 +302,16 @@ async def current(self) -> dict: if "pm2_5" in out: aqi, band = pm25_aqi(out["pm2_5"]["_rated"]) out["pm2_5"].update(aqi_us=aqi, band=band) - both = {name: entry["_rated"] for name, entry in out.items() if isinstance(entry, dict) and "_rated" in entry} + 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["_both"] = both # each metric's lower value of the last two readings, for the alerts (removed before the model sees it) + out["_sustained"] = sustained # for the alerts (removed before the model sees it) return out async def rows(self, t0: datetime, t1: datetime) -> tuple[list[dict], int, list[date]]: diff --git a/lib/alerts/particulates.py b/lib/alerts/particulates.py index 64e8f21..e75f7f7 100644 --- a/lib/alerts/particulates.py +++ b/lib/alerts/particulates.py @@ -1,8 +1,8 @@ """Particulates alerts (PM2.5 and PM10) from the AirGradient sensor, checked every 30 minutes. "Wear a mask outside" when PM2.5 or PM10 reaches US AQI 151+ ("unhealthy", the official level -at which masks are advised for everyone) in two readings in a row, each check counting only when the sensor's last two readings -are both that high (a single-reading spike never does); "back to safe" when both are at +at which masks are advised for everyone) in two readings in a row, each check counting only when the sensor's last +ANTISPIKE_REQUIRE_CONSECUTIVE (2) readings are all that high (a spike never does); "back to safe" when both are at AQI 100 or below in two readings in a row (confirm.py: a check that brings no new reading counts for nothing). Every episode gets the pair. Particles only: masks filter particles, not gases like CO2/VOC/NOx. """ @@ -40,8 +40,8 @@ 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 - both = reading.get("_both") or {} # the lower of the last two readings: one reading alone, a spike, can't make the air unhealthy - high25, high10 = both.get("pm2_5", pm25), both.get("pm10", pm10) + held = reading.get("_sustained") or {} # the lowest of the last readings (ANTISPIKE_REQUIRE_CONSECUTIVE): 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) confirm.run(m, "above", bad) diff --git a/lib/config.py b/lib/config.py index 713127c..d048e5c 100644 --- a/lib/config.py +++ b/lib/config.py @@ -70,6 +70,13 @@ def _whole(value: str, default: int) -> int: return int(value) if value.isdecimal() else default +def _readings_in_a_row(value: str, default: int) -> 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: """A Telegram chat id (negative for a group or channel); None, with a warning, when it is not a whole number.""" if not value: @@ -122,6 +129,7 @@ class Config: 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 @property @@ -177,6 +185,7 @@ def from_env(cls) -> "Config": 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", diff --git a/tests/test_airgradient.py b/tests/test_airgradient.py index 427fb53..a9caccb 100644 --- a/tests/test_airgradient.py +++ b/tests/test_airgradient.py @@ -388,3 +388,41 @@ async def held(t0_, t1_): assert out["pm10"]["average"] == round((3 + 4 + 10 + 5 + 4) / 5, 1) # and not in the mean assert daily_values(rows, ["pm10_max", "pm2_5_max"], TZ)["pm10_max"][local_date(t0, TZ)] == 10 await air.close() + + +def test_the_readings_a_rise_needs_are_set_by_the_antispike_setting(): + from lib.airgradient import metrics + from lib.airgradient.metrics import without_spikes + base = 1_790_000_000 + row = lambda i, v: {"ts": base + 300 * i, "pm10": v} + two = [row(0, 3), row(1, 4), row(2, 30), row(3, 31), row(4, 4), row(5, 3)] # two readings in a row + one = [row(0, 3), row(1, 4), row(2, 30), row(3, 4), row(4, 3)] + assert without_spikes(two) == two # the default, 2: two in a row is the air + assert [r.get("pm10") for r in without_spikes(two, require=3)] == [3, 4, None, None, 4, 3] + assert without_spikes(one, require=1) == one # 1: the rule is off + assert [r.get("pm10") for r in without_spikes(one)] == [3, 4, None, 4, 3] + metrics.REQUIRE_CONSECUTIVE = 3 + try: + assert [r.get("pm10") for r in without_spikes(two)] == [3, 4, None, None, 4, 3] # the module's setting is the default + finally: + 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 + 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)) + await air.close() + + +def test_the_antispike_setting_is_a_number_of_readings_default_2_and_off_is_1(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")): + monkeypatch.setenv(k, v) + monkeypatch.delenv("ANTISPIKE_REQUIRE_CONSECUTIVE", raising=False) + assert Config.from_env().antispike_require_consecutive == 2 + 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 diff --git a/tests/test_alerts.py b/tests/test_alerts.py index 59fe6b7..ee058fd 100644 --- a/tests/test_alerts.py +++ b/tests/test_alerts.py @@ -767,7 +767,7 @@ async def notify(text, link=None, **kw): def reading(minutes, latest, before): r = air_reading(latest) r["_time_utc"] = stamp + timedelta(minutes=minutes) - r["_both"] = {"pm2_5": min(latest, before)} # what air.current() gives: the lower of the last two readings + r["_sustained"] = {"pm2_5": min(latest, before)} # what air.current() gives: the lowest of the last readings return r 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