Skill: rokokol/py-math-viz (version 0.1.1)

--- name: py-math-viz description: Create clear math/data visualizations in Python and export to PNG (matplotlib/seaborn; optional OpenCV post-processing). Use when the user asks to plot functions, draw graphs, visualize simulations/trajectories, compare models, make publication-quality plots, or needs a PNG chart for Telegram/notes. Includes scripts to render plots from a JSON spec and to tile/annotate images. --- # Py Math Viz Use Python to produce **clean, readable plots** and **export to PNG by default**. Assumptions (this workspace): - Preferred interpreter: `/root/.openclaw/workspace/.venv-math/bin/python` - Libraries: `matplotlib`, `seaborn`, `numpy` (already), `opencv-python-headless` (installed) ## Defaults (quality rules) - Output: PNG, `dpi>=200`, white background - Always label axes; add title if it helps - Use legend when multiple series exist (and place it so it doesn’t cover data) - Avoid overlap: - prefer `constrained_layout=True` and/or `plt.tight_layout()` - rotate dense x-ticks, shorten tick labels - increase figure size instead of cramming - Use consistent styling: - `seaborn.set_theme(style="whitegrid")` - colorblind-friendly palette if unsure ## Quick start (script) Render a plot from a JSON spec. To keep the workspace root clean, **write outputs into `out/`** (or `out/_scratch/` for throwaway experiments): ```bash /root/.openclaw/workspace/.venv-math/bin/python \ skills/py-math-viz/scripts/plot_from_spec.py \ --spec spec.json --out out/plots/plot.png ``` Then send `out/plots/plot.png` (Telegram: use the `message` tool with `filePath`). ## Plot spec The spec is intentionally small. Read: - `references/spec.md` — schema + examples - `references/quick-recipes.md` — recipes index (points to JSON specs / seaborn snippets / image tiling) ## OpenCV helpers Tile multiple PNGs into one (for comparisons): ```bash /root/.openclaw/workspace/.venv-math/bin/python \ skills/py-math-viz/scripts/tile_images.py \ --out tiled.png img1.png img2.png img3.png ``` ## When NOT to use - Interactive dashboards (Plotly/Dash) unless explicitly asked - 3D/mesh-heavy rendering (use specialized tools if needed)

path: references/quick-recipes.md
content: # Quick recipes (index)

This folder intentionally splits recipes by style.

- `recipes-spec-json.md` — JSON specs for `plot_from_spec.py` (CSV → hist/scatter/line/bar/heatmap)
- `recipes-seaborn-snippets.md` — one-off richer stats plots (KDE/violin/corr/pairplot)
- `recipes-images.md` — tiling plots into one image

See also:
- `spec.md` — the JSON schema reference


path: references/recipes-images.md
content: # Recipes: Images (OpenCV tiling)

## Tile multiple plots into a single image

```bash
/root/.openclaw/workspace/.venv-math/bin/python \
  skills/py-math-viz/scripts/tile_images.py \
  --out out/plots/tiled.png --cols 2 --cell 1200x800 \
  out/plots/plot1.png out/plots/plot2.png out/plots/plot3.png out/plots/plot4.png
```

Tips:
- Increase `--cell` if plots look cramped
- Use `--cols 3` for 6–9 small plots


path: references/recipes-seaborn-snippets.md
content: # Recipes: Seaborn snippets (one-off, not in JSON spec)

These are quick scripts when you want richer statistical plots.

All snippets assume:

```python
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
sns.set_theme(style="whitegrid")
df = pd.read_csv("data.csv")
```

## Distribution: histogram + KDE

```python
plt.figure(figsize=(10,6), dpi=250)
sns.histplot(df["col"], bins=50, kde=True)
plt.title("col distribution (hist + KDE)")
plt.xlabel("col"); plt.ylabel("count")
plt.tight_layout(); plt.savefig("out/plots/dist_kde.png", bbox_inches="tight", facecolor="white")
```

## Distribution by group: KDE curves

```python
plt.figure(figsize=(10,6), dpi=250)
sns.kdeplot(data=df, x="value", hue="group", common_norm=False)
plt.title("value distribution by group (KDE)")
plt.tight_layout(); plt.savefig("out/plots/kde_by_group.png", bbox_inches="tight", facecolor="white")
```

## Box / violin by group

```python
plt.figure(figsize=(10,6), dpi=250)
sns.violinplot(data=df, x="group", y="value", inner="quartile")
# or: sns.boxplot(data=df, x="group", y="value")
plt.title("value by group")
plt.xticks(rotation=30, ha="right")
plt.tight_layout(); plt.savefig("out/plots/violin.png", bbox_inches="tight", facecolor="white")
```

## Correlation heatmap

```python
sns.set_theme(style="white")
num = df.select_dtypes(include=["number"]).corr()
plt.figure(figsize=(9,7), dpi=250)
sns.heatmap(num, cmap="vlag", center=0, square=True)
plt.title("correlation")
plt.tight_layout(); plt.savefig("out/plots/corr.png", bbox_inches="tight", facecolor="white")
```

## Pairplot (quick overview)

```python
# Beware: can be slow on big tables
pp = sns.pairplot(df.select_dtypes(include=["number"]).sample(min(len(df), 500)))
pp.fig.suptitle("pairplot", y=1.02)
pp.savefig("out/plots/pairplot.png", dpi=200)
```


path: references/recipes-spec-json.md
content: # Recipes: JSON spec (plot_from_spec.py)

All recipes use:

```bash
/root/.openclaw/workspace/.venv-math/bin/python \
  skills/py-math-viz/scripts/plot_from_spec.py \
  --spec spec.json --out out/plots/out.png
```

## CSV → histogram (distribution of one column)

```json
{
  "kind": "hist",
  "title": "col distribution",
  "xlabel": "col",
  "ylabel": "count",
  "data": {"csv": "data.csv"},
  "series": [
    {"x": "col", "bins": 50, "label": "col", "alpha": 0.7}
  ],
  "legend": {"show": true, "loc": "best"},
  "dpi": 250,
  "figsize": [10, 6]
}
```

## CSV → scatter (two columns)

```json
{
  "kind": "scatter",
  "title": "y vs x",
  "xlabel": "xcol",
  "ylabel": "ycol",
  "data": {"csv": "data.csv"},
  "series": [
    {"x": "xcol", "y": "ycol", "label": "data", "alpha": 0.7, "ms": 30}
  ],
  "legend": {"show": true, "loc": "best"},
  "dpi": 250,
  "figsize": [10, 6]
}
```

## CSV → line (time series)

```json
{
  "kind": "line",
  "title": "value over time",
  "xlabel": "t",
  "ylabel": "value",
  "data": {"csv": "data.csv"},
  "series": [
    {"x": "t", "y": "value", "label": "value", "style": "-", "lw": 2.0}
  ],
  "legend": {"show": true, "loc": "best"},
  "dpi": 250,
  "figsize": [10, 6]
}
```

## CSV → multiple lines (compare series)

```json
{
  "kind": "line",
  "title": "compare series",
  "xlabel": "t",
  "ylabel": "value",
  "data": {"csv": "data.csv"},
  "series": [
    {"x": "t", "y": "y1", "label": "y1", "style": "-", "color": "C0"},
    {"x": "t", "y": "y2", "label": "y2", "style": "--", "color": "C1"}
  ],
  "legend": {"show": true, "loc": "best"},
  "dpi": 250,
  "figsize": [10, 6]
}
```

## CSV → bar (category → value)

```json
{
  "kind": "bar",
  "title": "val by cat",
  "xlabel": "cat",
  "ylabel": "val",
  "data": {"csv": "data.csv"},
  "series": [
    {"x": "cat", "y": "val", "label": "val", "alpha": 0.9}
  ],
  "legend": {"show": false},
  "dpi": 250,
  "figsize": [10, 6],
  "xtick_rotation": 30,
  "xtick_ha": "right"
}
```

## CSV → grouped bar (two series per category)

```json
{
  "kind": "bar",
  "bar_mode": "grouped",
  "title": "grouped bars",
  "xlabel": "cat",
  "ylabel": "value",
  "data": {"csv": "data.csv"},
  "series": [
    {"x": "cat", "y": "v1", "label": "v1", "color": "C0"},
    {"x": "cat", "y": "v2", "label": "v2", "color": "C1"}
  ],
  "legend": {"show": true, "loc": "best"},
  "dpi": 250,
  "figsize": [10, 6],
  "xtick_rotation": 30,
  "xtick_ha": "right"
}
```

## CSV → stacked bar (composition per category)

```json
{
  "kind": "bar",
  "bar_mode": "stacked",
  "title": "stacked bars",
  "xlabel": "cat",
  "ylabel": "value",
  "data": {"csv": "data.csv"},
  "series": [
    {"x": "cat", "y": "base", "label": "base", "color": "C2"},
    {"x": "cat", "y": "add", "label": "add", "color": "C3"}
  ],
  "legend": {"show": true, "loc": "best"},
  "dpi": 250,
  "figsize": [10, 6],
  "xtick_rotation": 30,
  "xtick_ha": "right"
}
```

## Heatmap (matrix)

Inline `z`:

```json
{
  "kind": "heatmap",
  "title": "heatmap",
  "xlabel": "x",
  "ylabel": "y",
  "data": {"z": [[1,2,3],[2,3,4],[3,4,5]]},
  "dpi": 250,
  "figsize": [8, 6]
}
```

Numeric CSV matrix (no headers) via `data.csv` (see `references/spec.md`).


path: references/spec.md
content: # Plot spec (JSON)

Goal: a minimal schema that covers 80% of “plot this” requests and produces a clean PNG.

## Top-level

```json
{
  "kind": "line|scatter|hist|heatmap|bar",
  "title": "optional",
  "xlabel": "optional",
  "ylabel": "optional",
  "figsize": [10, 6],
  "dpi": 250,
  "grid": true,

  "data": { ... },
  "series": [ ... ],

  "legend": { "show": true, "loc": "best" }
}
```

## Data sources

### Inline arrays

```json
"data": {"x": [0,1,2], "y": [0,1,4]}
```

### CSV

```json
"data": {"csv": "path/to/data.csv"}
```

CSV expects column names. Series can reference columns.

## Series

### Bar

```json
"kind": "bar",
"data": {"cat": ["A","B"], "count": [10, 12]},
"series": [
  {"x": "cat", "y": "count", "label": "n", "color": "C2", "alpha": 0.9}
],
"legend": {"show": false}
```

Options:
- `bar_mode`: `"grouped"` (default) or `"stacked"`
- `bar_total_width` (default 0.8): total width reserved per category (only for grouped)
- `xtick_rotation` (deg), `xtick_ha` (center/right/left)
- per-series: `width`, `edgecolor`

### Line / scatter

```json
"series": [
  {"x": "x", "y": "y", "label": "y(x)", "style": "-", "color": "C0"}
]
```

- `x`/`y` can be:
  - a key in inline `data` (`"x"`, `"y"`)
  - a column name (if `data.csv` is used)

### Hist

```json
"series": [
  {"x": "samples", "label": "samples", "bins": 50, "alpha": 0.7}
]
```

### Heatmap

For heatmap, `data.z` is a 2D array or a CSV with numeric matrix.

```json
"data": {"z": [[1,2],[3,4]]}
```


path: scripts/plot_from_spec.py
content: #!/usr/bin/env python3
"""Render common plots from a small JSON spec.

Design goals:
- Always produce a clean PNG (no overlaps)
- Sensible defaults (labels, grid, legend)
- Minimal schema; easy to extend

Usage:
  python plot_from_spec.py --spec spec.json --out out.png
"""

from __future__ import annotations

import argparse
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

import numpy as np
import matplotlib as mpl
import matplotlib.pyplot as plt

try:
    import seaborn as sns
except Exception:
    sns = None


def _maybe_set_theme():
    if sns is not None:
        sns.set_theme(style="whitegrid")
    else:
        mpl.rcParams.update(
            {
                "axes.grid": True,
                "grid.alpha": 0.25,
            }
        )


def _load_csv(path: str) -> Dict[str, np.ndarray]:
    import csv

    p = Path(path)
    if not p.exists():
        raise FileNotFoundError(f"CSV not found: {path}")

    with p.open("r", newline="") as f:
        reader = csv.DictReader(f)
        rows = list(reader)

    if not rows:
        raise ValueError(f"CSV is empty: {path}")

    cols = reader.fieldnames or []
    out: Dict[str, List[float]] = {c: [] for c in cols}

    for r in rows:
        for c in cols:
            v = r.get(c)
            if v is None:
                out[c].append(np.nan)
            else:
                out[c].append(float(v))

    return {k: np.asarray(v, dtype=float) for k, v in out.items()}


def _as_array(value: Any, data: Dict[str, Any]) -> np.ndarray:
    if isinstance(value, str):
        if value not in data:
            raise KeyError(f"Unknown data key/column: {value}")
        return np.asarray(data[value], dtype=float)
    return np.asarray(value, dtype=float)


def render(spec: Dict[str, Any], out_path: str):
    kind = spec.get("kind", "line")

    figsize = tuple(spec.get("figsize", [10, 6]))
    dpi = int(spec.get("dpi", 250))

    fig, ax = plt.subplots(figsize=figsize, dpi=dpi, constrained_layout=True)

    title = spec.get("title")
    xlabel = spec.get("xlabel")
    ylabel = spec.get("ylabel")

    if title:
        ax.set_title(str(title))
    if xlabel:
        ax.set_xlabel(str(xlabel))
    if ylabel:
        ax.set_ylabel(str(ylabel))

    grid = spec.get("grid", True)
    ax.grid(bool(grid))

    data_spec = spec.get("data", {})
    data: Dict[str, Any] = {}

    if isinstance(data_spec, dict) and "csv" in data_spec:
        data = {**_load_csv(str(data_spec["csv"]))}
    elif isinstance(data_spec, dict):
        # inline arrays
        data = {k: v for k, v in data_spec.items()}

    series = spec.get("series", []) or []

    if kind in ("line", "scatter"):
        for s in series:
            x = _as_array(s.get("x"), data)
            y = _as_array(s.get("y"), data)
            label = s.get("label")
            color = s.get("color")
            style = s.get("style", "-")
            alpha = s.get("alpha", 1.0)
            lw = s.get("lw", 2.0)
            ms = s.get("ms", 30)

            if kind == "line":
                ax.plot(x, y, style, label=label, color=color, alpha=alpha, linewidth=lw)
            else:
                ax.scatter(x, y, label=label, color=color, alpha=alpha, s=ms)

    elif kind == "hist":
        for s in series:
            x = _as_array(s.get("x"), data)
            label = s.get("label")
            bins = int(s.get("bins", 50))
            alpha = float(s.get("alpha", 0.7))
            ax.hist(x, bins=bins, alpha=alpha, label=label)

    elif kind == "heatmap":
        z = None
        if "z" in data:
            z = np.asarray(data["z"], dtype=float)
        elif "csv" in data_spec:
            # If CSV used: treat as numeric matrix with no headers
            z = np.loadtxt(str(data_spec["csv"]), delimiter=",")
        if z is None:
            raise ValueError("heatmap requires data.z (2D array) or data.csv")

        cmap = spec.get("cmap", "viridis")
        im = ax.imshow(z, aspect="auto", cmap=cmap)
        fig.colorbar(im, ax=ax, shrink=0.85)

    elif kind == "bar":
        # Bar chart: categorical x with numeric y.
        # Supports multiple series:
        # - bar_mode="grouped" (default)
        # - bar_mode="stacked"
        bar_mode = str(spec.get("bar_mode", "grouped")).lower()

        if not series:
            raise ValueError("bar requires non-empty series")

        # x values: take from the first series
        x0_raw = series[0].get("x")
        if isinstance(x0_raw, str):
            if x0_raw not in data:
                raise KeyError(f"Unknown data key/column: {x0_raw}")
            x_vals = list(data[x0_raw])
        else:
            x_vals = list(x0_raw)

        idx = np.arange(len(x_vals), dtype=float)
        ax.set_xticks(idx)
        ax.set_xticklabels([str(v) for v in x_vals])

        # Grouped layout params
        default_total_width = float(spec.get("bar_total_width", 0.8))
        nser = len(series)
        default_width = default_total_width / max(1, nser) if bar_mode == "grouped" else default_total_width

        if bar_mode == "stacked":
            bottom = np.zeros(len(x_vals), dtype=float)
            for s in series:
                # allow per-series x but must match categories length
                y = _as_array(s.get("y"), data)
                if len(y) != len(x_vals):
                    raise ValueError("All bar series must have the same length as x")
                label = s.get("label")
                color = s.get("color")
                alpha = float(s.get("alpha", 0.9))
                width = float(s.get("width", default_width))
                edgecolor = s.get("edgecolor", None)
                ax.bar(idx, y, width=width, bottom=bottom, label=label, color=color, alpha=alpha, edgecolor=edgecolor)
                bottom = bottom + y

        else:  # grouped
            offsets = (np.arange(nser) - (nser - 1) / 2.0) * default_width
            for j, s in enumerate(series):
                y = _as_array(s.get("y"), data)
                if len(y) != len(x_vals):
                    raise ValueError("All bar series must have the same length as x")
                label = s.get("label")
                color = s.get("color")
                alpha = float(s.get("alpha", 0.9))
                width = float(s.get("width", default_width))
                edgecolor = s.get("edgecolor", None)
                ax.bar(idx + offsets[j], y, width=width, label=label, color=color, alpha=alpha, edgecolor=edgecolor)

        # Improve readability for long category labels
        for tick in ax.get_xticklabels():
            tick.set_rotation(int(spec.get("xtick_rotation", 0)))
            tick.set_ha(spec.get("xtick_ha", "center"))

    else:
        raise ValueError(f"Unknown kind: {kind}")

    legend = spec.get("legend", {}) or {}
    show_legend = bool(legend.get("show", True))
    if show_legend and any(s.get("label") for s in series):
        loc = legend.get("loc", "best")
        ax.legend(loc=loc)

    # Final overlap guard
    try:
        fig.tight_layout()
    except Exception:
        pass

    out = Path(out_path)
    out.parent.mkdir(parents=True, exist_ok=True)
    fig.savefig(out, bbox_inches="tight", facecolor="white")
    plt.close(fig)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--spec", required=True, help="Path to JSON spec")
    ap.add_argument("--out", required=True, help="Output PNG path")
    args = ap.parse_args()

    _maybe_set_theme()

    spec = json.loads(Path(args.spec).read_text(encoding="utf-8"))
    render(spec, args.out)


if __name__ == "__main__":
    main()


path: scripts/tile_images.py
content: #!/usr/bin/env python3
"""Tile multiple PNG/JPG images into a single PNG (for model/plot comparisons).

Usage:
  python tile_images.py --out tiled.png img1.png img2.png ...

Notes:
- Uses OpenCV; keeps aspect ratio; pads with white.
"""

from __future__ import annotations

import argparse
from pathlib import Path
from typing import List, Tuple

import cv2
import numpy as np


def read_img(p: Path) -> np.ndarray:
    img = cv2.imread(str(p), cv2.IMREAD_UNCHANGED)
    if img is None:
        raise FileNotFoundError(f"Cannot read image: {p}")
    if img.ndim == 2:
        img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
    if img.shape[2] == 4:
        # alpha -> white background
        alpha = img[:, :, 3:4].astype(np.float32) / 255.0
        rgb = img[:, :, :3].astype(np.float32)
        white = np.full_like(rgb, 255.0)
        rgb = rgb * alpha + white * (1.0 - alpha)
        img = rgb.astype(np.uint8)
    return img


def fit_to(img: np.ndarray, target_wh: Tuple[int, int]) -> np.ndarray:
    tw, th = target_wh
    h, w = img.shape[:2]
    scale = min(tw / w, th / h)
    nw, nh = int(round(w * scale)), int(round(h * scale))
    resized = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_AREA)
    canvas = np.full((th, tw, 3), 255, dtype=np.uint8)
    x0 = (tw - nw) // 2
    y0 = (th - nh) // 2
    canvas[y0 : y0 + nh, x0 : x0 + nw] = resized
    return canvas


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--out", required=True)
    ap.add_argument("--cols", type=int, default=2)
    ap.add_argument("--cell", type=str, default="1200x800", help="WxH")
    ap.add_argument("images", nargs="+")
    args = ap.parse_args()

    cw, ch = (int(x) for x in args.cell.lower().split("x"))
    cols = max(1, args.cols)

    imgs = [read_img(Path(p)) for p in args.images]
    n = len(imgs)
    rows = (n + cols - 1) // cols

    cells = [fit_to(im, (cw, ch)) for im in imgs]

    # pad last row
    while len(cells) < rows * cols:
        cells.append(np.full((ch, cw, 3), 255, dtype=np.uint8))

    grid_rows = []
    for r in range(rows):
        row = np.concatenate(cells[r * cols : (r + 1) * cols], axis=1)
        grid_rows.append(row)
    grid = np.concatenate(grid_rows, axis=0)

    out = Path(args.out)
    out.parent.mkdir(parents=True, exist_ok=True)
    cv2.imwrite(str(out), grid)


if __name__ == "__main__":
    main()


path: scripts/weather.py
content: #!/usr/bin/env python3
"""Render a single Pan-Weather sprite (temp/precip/wind) via py-math-viz."""

from __future__ import annotations

import argparse
import json
from dataclasses import dataclass
from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict
from urllib.request import urlopen

from zoneinfo import ZoneInfo

import matplotlib.pyplot as plt
import seaborn as sns


API_URL = "https://api.open-meteo.com/v1/forecast"


@dataclass
class HourlyWindow:
    time: list[str]
    temperature_2m: list[float]
    precipitation: list[float]
    precipitation_probability: list[float]
    windspeed_10m: list[float]
    cloudcover: list[float]


def fetch_hourly_data(latitude: float, longitude: float, timezone: str) -> Dict[str, list[float]]:
    params = {
        "latitude": latitude,
        "longitude": longitude,
        "hourly": "temperature_2m,precipitation,precipitation_probability,windspeed_10m,cloudcover",
        "forecast_days": 2,
        "timezone": timezone,
    }
    query = "&".join(f"{key}={value}" for key, value in params.items())
    with urlopen(f"{API_URL}?{query}") as resp:
        payload = json.load(resp)
    return payload["hourly"]


def build_window(hourly: Dict[str, list[float]], timezone: str) -> HourlyWindow:
    zone = ZoneInfo(timezone)
    now = datetime.now(zone)
    end = now + timedelta(hours=24)
    selected = {key: [] for key in hourly}
    for i, timestr in enumerate(hourly["time"]):
        dt = datetime.fromisoformat(timestr)
        if dt.tzinfo is None:
            dt = dt.replace(tzinfo=zone)
        if now <= dt < end:
            for key in hourly:
                selected[key].append(hourly[key][i])
        if len(selected["time"]) >= 24:
            break
    return HourlyWindow(
        time=selected["time"],
        temperature_2m=selected["temperature_2m"],
        precipitation=selected["precipitation"],
        precipitation_probability=selected["precipitation_probability"],
        windspeed_10m=selected["windspeed_10m"],
        cloudcover=selected["cloudcover"],
    )


def render(window: HourlyWindow, city_label: str, output: str, lang: str = "ru") -> None:
    sns.set_theme(style="whitegrid")
    fig, axes = plt.subplots(3, 1, figsize=(10, 8), constrained_layout=True)
    fig.patch.set_facecolor("#fbfbfb")

    labels = [datetime.fromisoformat(t).strftime("%H:%M") for t in window.time]

    if lang == "en":
        title_temp = f"Temperature, °C ({city_label}, next 24h)"
        title_precip = "Precipitation (mm) and probability (%)"
        title_wind = "Wind and cloud cover"
        ylabel_temp = "°C"
        ylabel_mm = "mm"
        ylabel_prob = "%"
        ylabel_wind = "m/s"
        ylabel_cloud = "%"
    else:
        title_temp = f"Температура, °C ({city_label}, след. 24ч)"
        title_precip = "Осадки (мм) и вероятность (%)"
        title_wind = "Ветер и облачность"
        ylabel_temp = "°C"
        ylabel_mm = "мм"
        ylabel_prob = "%"
        ylabel_wind = "м/с"
        ylabel_cloud = "%"

    axes[0].plot(labels, window.temperature_2m, color="#c82c2c", marker="o", linewidth=2)
    axes[0].fill_between(labels, window.temperature_2m, color="#dda0a0", alpha=0.35)
    axes[0].set_title(title_temp, loc="left")
    axes[0].set_ylabel(ylabel_temp, color="#c82c2c")
    axes[0].tick_params(axis="y", colors="#c82c2c")
    axes[0].grid(axis="y", linestyle="--", alpha=0.45)

    axes[1].bar(labels, window.precipitation, color="#5aa9f7", alpha=0.8)
    axes[1].set_ylabel(ylabel_mm, color="#5aa9f7")
    axes[1].tick_params(axis="y", colors="#5aa9f7")
    axes[1].set_title(title_precip, loc="left")
    axes[1].grid(axis="y", linestyle="--", alpha=0.45)
    twin_prob = axes[1].twinx()
    twin_prob.plot(labels, window.precipitation_probability, color="#8a2be2", marker="o", linewidth=2)
    twin_prob.set_ylabel(ylabel_prob, color="#8a2be2")
    twin_prob.tick_params(axis="y", colors="#8a2be2")
    twin_prob.set_ylim(0, max(max(window.precipitation_probability, default=0), 100) * 1.05)

    axes[2].plot(labels, window.windspeed_10m, color="#2c8a2c", marker="o", linewidth=2)
    axes[2].set_ylabel(ylabel_wind, color="#2c8a2c")
    axes[2].tick_params(axis="y", colors="#2c8a2c")
    axes[2].set_title(title_wind, loc="left")
    axes[2].grid(axis="y", linestyle="--", alpha=0.45)
    twin_cloud = axes[2].twinx()
    twin_cloud.plot(labels, window.cloudcover, color="#7f7f7f", linestyle="--", marker="s", linewidth=2)
    twin_cloud.set_ylabel(ylabel_cloud, color="#7f7f7f")
    twin_cloud.tick_params(axis="y", colors="#7f7f7f")
    twin_cloud.set_ylim(0, 110)

    for ax in axes:
        ax.set_xticks(labels[::3])
        ax.set_xticklabels(labels[::3], rotation=45)
        for spine in ("top", "right"):
            ax.spines[spine].set_visible(False)

    twin_prob.spines["top"].set_visible(False)
    twin_cloud.spines["top"].set_visible(False)

    out_path = Path(output)
    out_path.parent.mkdir(parents=True, exist_ok=True)
    fig.savefig(out_path, dpi=200, facecolor="white")
    plt.close(fig)


def summarize(window: HourlyWindow, city_label: str, lang: str = "ru") -> str:
    min_temp = min(window.temperature_2m)
    max_temp = max(window.temperature_2m)
    min_temp_time = datetime.fromisoformat(window.time[window.temperature_2m.index(min_temp)]).strftime("%H:%M")
    max_temp_time = datetime.fromisoformat(window.time[window.temperature_2m.index(max_temp)]).strftime("%H:%M")
    max_precip = max(window.precipitation)
    max_precip_time = datetime.fromisoformat(window.time[window.precipitation.index(max_precip)]).strftime("%H:%M")
    max_precip_prob = max(window.precipitation_probability)
    max_wind = max(window.windspeed_10m)
    max_wind_time = datetime.fromisoformat(window.time[window.windspeed_10m.index(max_wind)]).strftime("%H:%M")

    if lang == "en":
        return (
            f"{city_label}: {min_temp:.1f}° ({min_temp_time}) → {max_temp:.1f}° ({max_temp_time}); "
            f"max rain {max_precip:.1f} mm ({max_precip_time}, {max_precip_prob:.0f}%); "
            f"wind up to {max_wind:.1f} m/s ({max_wind_time})"
        )
    return (
        f"{city_label}: {min_temp:.1f}° ({min_temp_time}) → {max_temp:.1f}° ({max_temp_time}); "
        f"max дождь {max_precip:.1f} мм ({max_precip_time}, {max_precip_prob:.0f}%); "
        f"ветер до {max_wind:.1f} м/с ({max_wind_time})"
    )


def main() -> None:
    parser = argparse.ArgumentParser(description="Render a 24h weather sprite via py-math-viz")
    parser.add_argument("--output", "-o", default="out/weather_all.png", help="PNG path")
    parser.add_argument("--summary", "-s", action="store_true", help="Print text summary")
    parser.add_argument("--latitude", type=float, default=55.7887)
    parser.add_argument("--longitude", type=float, default=49.1221)
    parser.add_argument("--timezone", default="Europe/Moscow")
    parser.add_argument("--city-label", default="Казань")
    parser.add_argument("--lang", choices=["ru", "en"], default="ru", help="Label language (ru/en)")
    args = parser.parse_args()

    hourly = fetch_hourly_data(args.latitude, args.longitude, args.timezone)
    window = build_window(hourly, args.timezone)
    render(window, args.city_label, args.output, lang=args.lang)
    if args.summary:
        print(summarize(window, args.city_label, lang=args.lang))


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)