diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-13 13:04:10 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-13 13:04:10 +0200 |
| commit | e14a7db4ffe3c4e0f15f6b37a980501a8d74d26b (patch) | |
| tree | 670ef0897839871a64d3a3bb2e17e242e7d6c385 /internal/imgw/imgw.go | |
| download | prognosis-e14a7db4ffe3c4e0f15f6b37a980501a8d74d26b.tar.gz prognosis-e14a7db4ffe3c4e0f15f6b37a980501a8d74d26b.zip | |
Initial commit: prognosis, the Go implementation
An hour-by-hour forecast for the terminal, with official IMGW warnings for
Polish locations. Replaces the Python version, whose cache file format it
keeps so the two can coexist until this reaches parity.
Open-Meteo provides the forecast, geocoding and pollen; GUGiK turns
coordinates into a TERYT powiat code; IMGW supplies the warnings, filtered
to that powiat rather than the whole country. Only the two lookups that
never change are cached. Forecasts never are.
Silence is never allowed to read as all-clear: "no warnings in force" and
"the check failed" are reported as distinct states.
Place names are resolved without guessing. A name matching several places
is refused with a numbered list carrying each candidate's region and
coordinates, and -pick N chooses one and remembers it. A stray positional
beside -l is an error, so an unquoted "Wiry, PL" cannot silently resolve to
somewhere else.
The cache is written one entry per line with sorted keys, and treated as
disposable but not worthless: an entry that will not parse is skipped and
the rest kept, and a file that will not parse at all is moved to
cache.json.bad rather than overwritten.
No third-party dependencies. `make ci` is the gate: gofmt clean, vet, tests.
Diffstat (limited to 'internal/imgw/imgw.go')
| -rw-r--r-- | internal/imgw/imgw.go | 156 |
1 files changed, 156 insertions, 0 deletions
diff --git a/internal/imgw/imgw.go b/internal/imgw/imgw.go new file mode 100644 index 0000000..48ffe19 --- /dev/null +++ b/internal/imgw/imgw.go @@ -0,0 +1,156 @@ +// Package imgw fetches official Polish meteorological warnings and resolves a +// point to the powiat code those warnings are tagged with. +// +// Both services are public and need no key. +package imgw + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" +) + +// Endpoints are variables so tests can serve recorded fixtures locally. +var ( + warningsURL = "https://danepubliczne.imgw.pl/api/data/warningsmeteo" + gugikURL = "https://services.gugik.gov.pl/uug/" +) + +// now is the clock, replaceable in tests: whether a warning has expired depends +// on it. +var now = time.Now + +// Timeout bounds every request. +var Timeout = 15 * time.Second + +// Status is the outcome of resolving a point to a powiat. +type Status int + +const ( + // StatusOK means the point resolved to a powiat code. + StatusOK Status = iota + // StatusOutside means GUGiK answered but knows no address there. It covers + // Poland only, so the point is abroad. + StatusOutside + // StatusError means the service could not be asked. + // + // This must never be conflated with StatusOutside: one means "no warnings + // apply here", the other "I do not know whether any apply". + StatusError +) + +// Warning is one IMGW warning in force. +type Warning struct { + Event string `json:"nazwa_zdarzenia"` + Level string `json:"stopien"` + Probability string `json:"prawdopodobienstwo"` + From string `json:"obowiazuje_od"` + To string `json:"obowiazuje_do"` + Text string `json:"tresc"` + Teryt []any `json:"teryt"` +} + +func fetch(rawURL string, params url.Values, into any) error { + host := "" + if u, err := url.Parse(rawURL); err == nil { + host = u.Host + } + full := rawURL + if len(params) > 0 { + full += "?" + params.Encode() + } + req, err := http.NewRequest("GET", full, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", "prognosis/1.0") + resp, err := (&http.Client{Timeout: Timeout}).Do(req) + if err != nil { + return fmt.Errorf("cannot reach %s: %w", host, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%s returned HTTP %d", host, resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + return json.Unmarshal(body, into) +} + +type gugikResponse struct { + Results map[string]struct { + Teryt string `json:"teryt"` + } `json:"results"` +} + +// Powiat resolves a point to its 4-digit TERYT powiat code via GUGiK. +// +// IMGW tags every warning with the powiat codes it covers, so this is what +// makes "warnings for my area" mean this area rather than the whole country. +func Powiat(lat, lon float64) (string, Status, error) { + var r gugikResponse + err := fetch(gugikURL, url.Values{ + "request": {"GetAddressReverse"}, + "location": {fmt.Sprintf("POINT(%.6f %.6f)", lon, lat)}, + "srid": {"4326"}, + // The default 100 m radius finds nothing in the mountains or deep + // countryside, which looks identical to being abroad and would suppress + // real warnings. GUGiK clamps this to its own 5 km maximum. + "radius": {"10000"}, + }, &r) + if err != nil { + return "", StatusError, err + } + for _, entry := range r.Results { + if len(entry.Teryt) >= 4 { + return entry.Teryt[:4], StatusOK, nil + } + } + return "", StatusOutside, nil +} + +// Warnings returns the warnings in force for a powiat. +func Warnings(powiat string) ([]Warning, error) { + var all []Warning + if err := fetch(warningsURL, nil, &all); err != nil { + return nil, err + } + cut := now() + var live []Warning + for _, w := range all { + if !w.covers(powiat) { + continue + } + // An expired warning is dropped; one whose date will not parse is kept, + // because showing a stale warning beats hiding a live one. + if to, err := time.ParseInLocation("2006-01-02 15:04:05", w.To, time.Local); err == nil { + if to.Before(cut) { + continue + } + } + live = append(live, w) + } + return live, nil +} + +func (w Warning) covers(powiat string) bool { + for _, a := range w.Teryt { + switch v := a.(type) { + case string: + if v == powiat { + return true + } + case float64: + if strconv.Itoa(int(v)) == powiat { + return true + } + } + } + return false +} |
