# prognosis in Go — design **Date:** 2026-08-10 **Status:** implemented and in service on both machines. The Python implementation was retired on 2026-08-10 once the Go packages had offline tests of their own; a copy is at ~/.local/share/backups/2026-08-10-prognosis-py/. **Supersedes:** the Python implementation, retired 2026-08-10. ## Why Two reasons, neither of them "Go is nicer". 1. **Deployment.** The phone currently runs a copied Python file and depends on Termux's `python3` plus `termux-exec` rewriting a `#!/usr/bin/env` shebang that cannot resolve on Android, which has no `/usr/bin`. A static `android/arm64` binary removes that whole chain. 2. **Configurability.** The display is hardcoded. Choosing columns, toggling the chart and picking pollen species wants a config file, and the Python version has no structure for it. A rewrite of something that already works carries one real risk: the dozen small behaviours that took a day to find are easy to drop silently. The parity harness below exists to make that impossible, and is built early rather than last. ## Layout Follows `bread-calc`, the house convention for a Go tool. ``` prognosis/ ├── cmd/prognosis/main.go flag parsing, wiring, exit codes ├── internal/ │ ├── config/ KEY=VALUE parser, defaults, column set │ ├── openmeteo/ forecast, air-quality, geocoding clients │ ├── imgw/ warnings + GUGiK TERYT lookup │ ├── cache/ geo/teryt cache, atomic write │ └── render/ table, chart, colour ├── testdata/ recorded JSON fixtures, no network in tests ├── bin/prognosis-py the Python implementation, until parity ├── go.mod github.com/lukaszkasprzak/prognosis ├── Makefile build install test vet fmt ci cross clean ├── LICENSE GPLv3 └── README.md ``` ## Config `~/.config/prognosis/config`, `KEY=VALUE`, `#` comments, parsed by our own code. No dependency. Written with commented defaults on first run, so the file documents itself — the same trick wego's `ingo` uses, without the library. ``` # ~/.config/prognosis/config location=Krakow # required (see the note below) hours=12 # default span; -n / -d override units=metric # metric | imperial | si columns=hour,icon,temp,feels,conditions,rain icons=nerd # nerd | emoji | none graph=true graph_height=5 warnings=true pollen=all # species to show; a list, "all" or "none" color=auto # auto | always | never display_lang=en # en | pl ``` `display_lang` covers everything prognosis writes itself: column headers, condition names, section labels, weekday and month names, pollen species and bands. **IMGW publishes its warning text in Polish only**, so that text stays Polish in either language; translating an official warning would mean inventing its wording. Precedence: **flags > config file > built-in defaults**. Location additionally falls back to `~/.wegorc` so the two weather tools never disagree about where you are. > **Superseded in v0.1.1.** The `~/.wegorc` fallback was removed: reading > another program's configuration made prognosis useless without wego installed > and hid the fact that it has no way to know where you are. `location=` is now > required in prognosis' own config. This document is kept as the record of the > original design, not as current documentation. `units` is passed to Open-Meteo as `temperature_unit` / `wind_speed_unit` / `precipitation_unit` rather than converted locally, so rounding matches the provider: `metric` = °C, km/h, mm; `imperial` = °F, mph, inch; `si` = °C, m/s, mm. The IMGW temperature thresholds are defined in °C and are compared against the Celsius value regardless of display units — a warning threshold does not move because you changed how numbers are printed. Flag/config mapping, so both spellings exist for every knob: | flag | config key | |---|---| | `-l`, `--location` | `location` | | `-n`, `--hours` / `-d`, `--days` | `hours` (days × 24) | | `--no-graph` | `graph=false` | | `--no-color` | `color=never` | | `--columns` | `columns` | | `--icons` | `icons` | | `--lang` | `display_lang` | `color=auto` means colour when stdout is a terminal and `TERM` is not `dumb`, which is the current behaviour; `always` forces it on for piping into a pager. ### Columns An ordered, named set. Unknown names are a startup error naming the offender and listing what is valid — never a silently blank column. | name | source field | notes | |---|---|---| | `hour` | derived | current hour emphasised | | `icon` | `weather_code` | glyph for the conditions, see below | | `temp` | `temperature_2m` | coloured by IMGW bands | | `feels` | `apparent_temperature` | shown only when it differs by ≥1° | | `conditions` | `weather_code` | shown only when it changes | | `mm` | `precipitation` | | | `rain` | `precipitation_probability` | | | `wind` | `wind_speed_10m` | | | `gusts` | `wind_gusts_10m` | | | `dir` | `wind_direction_10m` | rendered as an arrow | | `humidity` | `relative_humidity_2m` | | | `dew` | `dew_point_2m` | | | `uv` | `uv_index` | | | `cloud` | `cloud_cover` | | | `pressure` | `pressure_msl` | | | `visibility` | `visibility` | metres → km | Only the fields actually selected are requested from the API, so a narrow column set costs a smaller response. ### Icons The `icon` column renders the weather code as a glyph. `icons=` picks the set: - `emoji` — ☀ ⛅ ☁ 🌧 ⛈ 🌨 🌫. Colour glyphs, drawn by a fallback font. - `nerd` — Nerd Font weather glyphs. Single-width, monochrome, so they take the terminal's foreground colour like any other text. - `none` — the column renders empty (kept so `columns=` need not change). **The right default differs per machine, which is why this is config.** The first version of this spec defaulted to `nerd` on the grounds that both machines have Nerd Fonts installed. Testing disproved that: *installed* is not *reachable*. - **t480 (st):** the primary font is Terminus, which has no private-use glyphs, so icons go through Xft's fallback path — which `~/.config/fontconfig/conf.d/60-st-terminus-fallback.conf` deliberately steers to DejaVu Sans Mono, to stop fallback glyphs being sheared to the Terminus cell. DejaVu Sans Mono contains U+2601 (emoji cloud) but neither U+26C5 (emoji sun-behind-cloud) nor the Nerd range at U+E3xx. So on st, **`nerd` draws nothing and `emoji` is only partially covered**; `icons=none` is the honest setting there unless the fallback is extended. - **pixel (Termux):** the *primary* font is MesloLGS NF, so the Nerd range is covered directly with no fallback involved. `nerd` is right there, and it is also the only set that respects the deliberately monochrome green palette — a colour emoji would be the one non-green thing on screen. The default stays `nerd`: it is correct on the machine where a glyph column earns its place, and it fails blank rather than wrong. Per-machine config is the mechanism for the difference. Making `nerd` work on st would mean adding the Nerd range to the fallback — an additive fontconfig file beside the existing one, or st's `font2[]` — which touches a carefully tuned working setup and is deliberately out of scope here. **Width must be measured, not counted.** Weather emoji do not share a width: `⛅` is East-Asian Wide (2 cells), `⛈` is Ambiguous, `☀ ☁ ❄` are Neutral, and `☀️` is two runes because of variation selector U+FE0F, which pushes most terminals to double-width. Neither `len()` nor `utf8.RuneCountInString` gives the display width. The renderer needs a `displayWidth(string) int` that accounts for combining marks, variation selectors and East-Asian width, and **every column pad must go through it**. This is the emoji-shaped version of the pad-before-colour rule: get it wrong and the whole table shears, but only in a real terminal, never when piped. The existing rule that rain columns vanish on a dry window becomes conditional on them being selected at all: if `mm`/`rain` are in `columns` and the window is dry (no precipitation and no hour at or above 20% probability), they are hidden, and the day line says `dry`. ## Parity contract Every item below is behaviour the Python version has, each of which cost something to discover. The Go version must reproduce all of them, and the parity harness must cover each one. 1. **Four warning states, kept distinct.** Warnings shown; nothing shown (checked, none in force); `could not check IMGW` (the check failed); `IMGW covers Poland only` (location abroad). Silence must never be mistaken for all-clear. 2. **Powiat filtering.** GUGiK reverse-geocode → 6-digit TERYT → first 4 digits → match against each warning's `teryt` array. Warnings whose `obowiazuje_do` has passed are dropped; an unparseable date is kept rather than dropped. 3. **GUGiK radius.** Request a wide radius (the service clamps to its own 5 km maximum). The 100 m default finds nothing in the mountains, which is indistinguishable from being abroad and would suppress real warnings. 4. **Temperature colours from IMGW criteria**, with exact edges: `Tmin ≤ -15` bright blue, `Tmax ≥ 30` red, `Tmax > 35` bright red. Note `≤` and `>` — both edges were wrong in a first attempt. Intermediate splits at 0/10/20 are round numbers and are documented as such. 5. **Colour computed from the rounded value**, so a reading of −14.6 that prints as `-15°` gets the same colour as a true −15°. 6. **ANSI slots 0–15 only.** No 256-colour indices: the phone's palette remaps the low slots to shades of green and a hardcoded index would be the one non-green thing on screen. 7. **Pad cells to width before colouring.** Escape sequences have no visible width; padding a coloured string misaligns the column. Invisible when piped, obvious in a terminal. 8. **Colour runs are grouped**, one escape per colour change rather than per character. 9. **Display width is measured, not counted** -- East-Asian width, combining marks and variation selectors -- and every pad goes through it. Emoji are not all one cell wide; a rune count shears the table in a real terminal while looking correct when piped. 10. **Pollen bands** with per-species evidence strength: grass four bands (20/50/65/120), birch and mugwort a two-way split on a single anchor (80, 70), alder/olive/ragweed unbanded. Grass is never hidden even at zero; other species are hidden when absent. 11. **Pollen window looks forward** from the current hour, at least 12 hours, on arrays that begin at 00:00 local. 12. **Chart** over `graph_height` rows using half-block cells; columns widen to fill narrow spans and downsample on long ones, with `Nh/col` shown when they do; axis labels are skipped rather than truncated when they would overrun. 13. **Terminal width** read at runtime, honouring `COLUMNS`; header folds sun times onto one line only when it fits. 14. **Atomic cache writes** (temp file + rename), so concurrent runs cannot leave truncated JSON. 15. **Pipe-safe**: colour off when stdout is not a terminal; notes and failures on stderr; `note: N hours available, not M` when the API returns fewer hours than requested. 16. **Exit codes**: 0 success, 1 fetch failure, 2 usage error. ## Testing Unit tests, table-driven, no network: - config parsing: precedence, unknown keys, unknown column names, malformed lines - column selection: order preserved, dry-window hiding, only-selected-fields requested - thresholds: temperature band edges (−15, 30, 35 exactly), pollen bands per species - chart: scaling, widening, downsampling, axis label placement and skipping - render: pad-before-colour width invariants, colour-run grouping, ANSI slots used - width: display width of emoji (wide, ambiguous, VS16 pairs), Nerd Font glyphs and plain ASCII, and that every column stays aligned across all three icon sets HTTP clients are tested against recorded JSON in `testdata/`, captured from the live APIs once. This also documents the response shapes. ## Parity harness `make parity` runs both implementations over a fixed argument matrix and diffs stdout, stderr and exit code: ``` --no-color / -n 1 / -n 30 / -d 1 / -d 2 / -d 5 / --no-graph -l Bergen -n 6 (wet window: rain columns appear) -l Ushuaia -n 4 (cold bands, negative-zero formatting) 52.52,13.40 -n 3 (abroad by coordinates) 49.23,19.98 -n 3 (remote Polish point: GUGiK radius) -d 16 / -n 0 / -d 0 / -n 5 -d 2 (usage errors and exit codes) COLUMNS=53 (narrow rendering, header fold, wrap) --icons=emoji / --icons=nerd / --icons=none (column alignment per set) ``` Live data changes between runs, so both binaries are invoked back to back and the harness compares structure: line count, column positions, which sections are present, and the stderr/exit code exactly. Numeric drift between two calls seconds apart is tolerated; layout differences are not. **A warning learned the hard way:** the shell here is zsh, which does **not** word-split unquoted variables. A harness looping over argument strings must use `${=args}` or an array, or every case silently degrades into an argparse error and the comparison passes while testing nothing. ## Out of scope - Air quality (PM2.5/PM10/AQI) — the endpoint is already called for pollen, so it is cheap to add later, but it is not in this rewrite. - Cron/mail integration. Output is already pipe-safe; no code needed. - Retiring the Python implementation. That happens after parity, as a separate decision.