aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--lib/weather.lua309
-rw-r--r--test/fixtures/weather.json1
-rw-r--r--test/fixtures/weather.txt9
-rw-r--r--test/fixtures/weather_truncated.json1
-rw-r--r--test/test_weather.lua358
5 files changed, 335 insertions, 343 deletions
diff --git a/lib/weather.lua b/lib/weather.lua
index 8ecd273..aea630e 100644
--- a/lib/weather.lua
+++ b/lib/weather.lua
@@ -1,98 +1,146 @@
--- OpenWeatherMap domain knowledge and cache parsing.
+-- Open-Meteo domain knowledge and cache parsing.
--
--- Separate from lib/data.lua: that reads /proc and /sys, this reads a cached
--- HTTP response and carries its own tables. Same testable shape though, every
--- function takes a string or numbers and returns a value, so the tests need no
--- filesystem and no network.
+-- Separate from lib/data.lua: that reads /proc and /sys, this reads the cache
+-- bin/weather-fetch.sh flattens Open-Meteo's responses into. Same testable
+-- shape though, every function takes a string or numbers and returns a value,
+-- so the tests need no filesystem and no network.
+--
+-- The forecast cards built on this (hourly, week, details, sky) are adapted
+-- from the Conky-Mirage theme by alexsson-xexpanderx:
+-- https://github.com/alexsson-xexpanderx/Conky-themes/tree/master/Conky-Mirage
--
-- Nothing here raises. A Lua error in this project is a blank screen, so every
-- parse path returns nil and lets the widget draw its "no data" state.
local M = {}
+M.CACHE = (os.getenv('XDG_CACHE_HOME') or ((os.getenv('HOME') or '') .. '/.cache'))
+ .. '/udt/weather.txt'
+
-- Nerd Font codepoints, all present in Inconsolata Nerd Font (card.FONT_MONO).
-- Verified by rendering the glyphs and looking at them, not by fontconfig
-- alone: a missing glyph draws as an invisible blank rather than an error.
M.ICON = {
- thunder_day = '\u{E30F}',
- thunder_night = '\u{E32A}',
- drizzle_day = '\u{E306}',
- drizzle_night = '\u{E326}',
- drizzle_heavy_day = '\u{E308}',
- drizzle_heavy_night = '\u{E325}',
- rain_day = '\u{E308}',
- rain_night = '\u{E325}',
- snow = '\u{E31A}',
- fog = '\u{E313}',
- tornado = '\u{E351}',
- clear_day = '\u{E30D}',
- clear_night = '\u{E32B}',
- few_day = '\u{E302}',
- few_night = '\u{E379}',
- overcast = '\u{E312}',
- unknown = '\u{E374}',
- sunrise = '\u{E34C}',
- sunset = '\u{E34D}',
+ thunder_day = '\u{E30F}',
+ thunder_night = '\u{E32A}',
+ drizzle_day = '\u{E306}',
+ drizzle_night = '\u{E326}',
+ rain_day = '\u{E308}',
+ rain_night = '\u{E325}',
+ snow = '\u{E31A}',
+ fog = '\u{E313}',
+ clear_day = '\u{E30D}',
+ clear_night = '\u{E32B}',
+ few_day = '\u{E302}',
+ few_night = '\u{E379}',
+ overcast = '\u{E312}',
+ unknown = '\u{E374}',
}
--- Condition id to icon, by upper bound. Ported from the polybar script's
--- accumulated knowledge; the ranges are not round hundreds.
--- `now`, `sunrise` and `sunset` are unix timestamps and pick the day or night
--- face for the conditions that have both.
-function M.icon(id, now, sunrise, sunset)
- if type(id) ~= 'number' then return M.ICON.unknown end
- -- `now` is guarded alongside sunrise and sunset, not just them: comparing a
- -- nil now against a number raises, and a raise here is a blank dashboard.
- -- Missing any of the three means the sun is unknown, so the day face is
- -- drawn, which is what is_day() falls back to as well.
- local day = true
- if now and sunrise and sunset then day = (now >= sunrise and now <= sunset) end
- local function pick(d, n) return day and d or n end
-
- if id <= 232 then return pick(M.ICON.thunder_day, M.ICON.thunder_night)
- elseif id <= 311 then return pick(M.ICON.drizzle_day, M.ICON.drizzle_night)
- elseif id <= 321 then return pick(M.ICON.drizzle_heavy_day, M.ICON.drizzle_heavy_night)
- elseif id <= 531 then return pick(M.ICON.rain_day, M.ICON.rain_night)
- elseif id <= 622 then return M.ICON.snow
- elseif id <= 771 then return M.ICON.fog
- elseif id == 781 then return M.ICON.tornado
- elseif id == 800 then return pick(M.ICON.clear_day, M.ICON.clear_night)
- elseif id == 801 then return pick(M.ICON.few_day, M.ICON.few_night)
- elseif id <= 804 then return M.ICON.overcast
+-- WMO weather code to glyph. The codes are sparse groups, not ranges, so a
+-- code in a gap is unknown rather than borrowed from a neighbour.
+-- `is_day` false picks the night face; nil (unknown) draws the day face, which
+-- is what is_day() falls back to as well.
+function M.icon(code, is_day)
+ if type(code) ~= 'number' then return M.ICON.unknown end
+ local d = is_day ~= false
+ local I = M.ICON
+ if code == 0 then return d and I.clear_day or I.clear_night
+ elseif code == 1 or code == 2 then return d and I.few_day or I.few_night
+ elseif code == 3 then return I.overcast
+ elseif code == 45 or code == 48 then return I.fog
+ elseif code >= 51 and code <= 57 then return d and I.drizzle_day or I.drizzle_night
+ elseif (code >= 61 and code <= 67) or (code >= 80 and code <= 82) then
+ return d and I.rain_day or I.rain_night
+ elseif (code >= 71 and code <= 77) or code == 85 or code == 86 then return I.snow
+ elseif code >= 95 and code <= 99 then return d and I.thunder_day or I.thunder_night
end
- return M.ICON.unknown
+ return I.unknown
end
--- OWM's metric units report wind in m/s. The card shows km/h.
-function M.kmh(ms)
- if type(ms) ~= 'number' then return nil end
- return ms * 3.6
+local DESCRIBE = {
+ [0] = 'Clear sky', [1] = 'Mainly clear', [2] = 'Partly cloudy', [3] = 'Overcast',
+ [45] = 'Fog', [48] = 'Rime fog',
+ [51] = 'Light drizzle', [53] = 'Drizzle', [55] = 'Dense drizzle',
+ [56] = 'Freezing drizzle', [57] = 'Freezing drizzle',
+ [61] = 'Light rain', [63] = 'Rain', [65] = 'Heavy rain',
+ [66] = 'Freezing rain', [67] = 'Freezing rain',
+ [71] = 'Light snow', [73] = 'Snow', [75] = 'Heavy snow', [77] = 'Snow grains',
+ [80] = 'Rain showers', [81] = 'Rain showers', [82] = 'Violent showers',
+ [85] = 'Snow showers', [86] = 'Snow showers',
+ [95] = 'Thunderstorm', [96] = 'Thunderstorm, hail', [99] = 'Thunderstorm, hail',
+}
+
+-- WMO code in words. Reading a nil key from a table is not an error.
+function M.describe(code)
+ return DESCRIBE[code] or 'Unknown'
end
--- Beaufort force from km/h. Upper bounds carried over from the polybar
--- script; a value equal to a bound stays in the lower force.
---
--- The scale starts at 0 and ipairs starts at 1, so force N's upper bound is
--- BEAUFORT[N + 1] and the loop index is already N + 1. Hence `force - 1`
--- below. The scale tops out at 12, which is why the fall-through returns it.
-local BEAUFORT = { 1, 5, 11, 19, 28, 38, 49, 61, 74, 88, 102, 117 }
-
-function M.beaufort(kmh)
- if type(kmh) ~= 'number' then return nil end
- for force, bound in ipairs(BEAUFORT) do
- if kmh <= bound then return force - 1 end
- end
- return 12
+local function round(v) return math.floor(v + 0.5) end
+
+-- A temperature as the cards print it. Missing is '--', never '0': zero is a
+-- legitimate reading. The '-0' a value just under zero would round to is
+-- folded into '0'.
+function M.deg(v)
+ if type(v) ~= 'number' then return '--' end
+ local r = round(v)
+ if r == 0 then r = 0 end
+ return string.format('%d\u{00B0}', r)
+end
+
+-- A rounded number with an optional suffix, or '--'.
+function M.num(v, suffix)
+ if type(v) ~= 'number' then return '--' end
+ return string.format('%d', round(v)) .. (suffix or '')
+end
+
+-- The fetcher asks Open-Meteo for km/h or mph to match UNITS.
+function M.wind_unit(units)
+ return units == 'imperial' and 'mph' or 'km/h'
+end
+
+-- Seconds as '10h 28m'.
+function M.duration(s)
+ if type(s) ~= 'number' or s < 0 then return '--' end
+ return string.format('%dh %02dm', math.floor(s / 3600), math.floor(s % 3600 / 60))
+end
+
+-- Threshold role names, which index the palette (colors.ok and so on).
+-- UV: WHO bands, low under 3, very high from 8.
+function M.uv_role(uv)
+ if type(uv) ~= 'number' then return nil end
+ if uv >= 8 then return 'critical' elseif uv >= 3 then return 'warning' end
+ return 'ok'
+end
+
+-- European AQI: good and fair up to 40, moderate and poor up to 80.
+function M.aqi_role(aqi)
+ if type(aqi) ~= 'number' then return nil end
+ if aqi > 80 then return 'critical' elseif aqi > 40 then return 'warning' end
+ return 'ok'
+end
+
+-- Moon phase from the mean synodic month against a known new moon
+-- (2000-01-06 18:14 UTC). Returns phase 0..1 (0 new, 0.5 full) and the lit
+-- fraction 0..1. The mean month is off the true one by under a day, which a
+-- 28-step glyph cannot show anyway.
+local SYNODIC = 29.530588853
+local NEW_MOON = 947182440
+
+function M.moon(epoch)
+ if type(epoch) ~= 'number' then return nil end
+ local phase = ((epoch - NEW_MOON) / 86400 / SYNODIC) % 1
+ return phase, (1 - math.cos(2 * math.pi * phase)) / 2
+end
+
+-- One of the 28 Nerd Font moon glyphs, U+E38D (new) to U+E3A8, in phase order.
+function M.moon_icon(phase)
+ if type(phase) ~= 'number' then return M.ICON.unknown end
+ return utf8.char(0xE38D + round(phase * 28) % 28)
end
-- Eight compass points. The half-step offset is what makes north straddle
--- zero: without it, 350 degrees would land in NW and 10 in NE, leaving north
--- with only half its arc.
---
--- Nothing calls compass() yet: the card draws arrow() instead. It is kept
--- because it shares arrow()'s binning exactly, so testing both pins that
--- shared logic from two angles, and swapping the card to read "S 9 km/h"
--- instead of an arrow is then a one-word change rather than new code.
+-- zero: without it, 350 degrees would land in NW and 10 in NE.
local POINTS = { 'N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW' }
function M.compass(deg)
@@ -101,109 +149,82 @@ function M.compass(deg)
return POINTS[i + 1]
end
--- Arrow glyphs, in the same order as POINTS but rotated half a turn: OWM
--- reports the direction the wind comes FROM, and an arrow reads as the
--- direction it goes TO.
-local ARROWS = { '\u{2193}', '\u{2199}', '\u{2190}', '\u{2196}',
- '\u{2191}', '\u{2197}', '\u{2192}', '\u{2198}' }
-
-function M.arrow(deg)
- if type(deg) ~= 'number' then return '' end
- local i = math.floor(((deg % 360) + 22.5) / 45) % 8
- return ARROWS[i + 1]
-end
-
-- Fraction of daylight elapsed, clamped to 0..1.
--
--- OWM returns TODAY's sunrise and sunset, so before dawn this is negative and
--- after dusk it is over 1. Clamping parks the dot at the corresponding end of
--- the arc, which is the cheap correct-enough behaviour; multi-day astronomy
--- buys nothing for a dot on a curve.
+-- The cache carries TODAY's sunrise and sunset, so before dawn this is
+-- negative and after dusk it is over 1. Clamping parks the dot at the
+-- corresponding end of the arc.
function M.sun_t(now, sunrise, sunset)
if type(now) ~= 'number' or type(sunrise) ~= 'number'
or type(sunset) ~= 'number' then return nil end
local span = sunset - sunrise
if span <= 0 then return 0 end -- polar day/night or bad data: no division
- -- ponytail: a NaN timestamp slips through, since NaN is a number and every
- -- comparison against it is false, so both clamps below fall through and this
- -- returns NaN. Unreachable today: parse() matches %d+ for the sun times and
- -- `now` is os.time(). Cairo ignores a NaN coordinate, so the cost would be a
- -- missing dot, not a crash. Guard it here if a caller ever computes `now`.
local t = (now - sunrise) / span
if t < 0 then return 0 elseif t > 1 then return 1 end
return t
end
--- Whether the sun is up. Inclusive at both ends, matching the polybar script's
--- comparison, so the instant of sunrise draws the day face.
+-- Whether the sun is up. Inclusive at both ends; unknown falls back to day.
function M.is_day(now, sunrise, sunset)
if type(now) ~= 'number' or type(sunrise) ~= 'number'
or type(sunset) ~= 'number' then return true end
return now >= sunrise and now <= sunset
end
--- Parse the cached OWM response.
+-- Parse the cache: tab-separated records, first field the record type.
--
--- Lua patterns, not a JSON library: the current-weather response is flat and
--- its shape is known, so six scalars do not justify a dependency. Returns nil
--- on anything unreadable, never an error.
+-- units metric
+-- current epoch temp feels code is_day humidity wind wind_deg
+-- sun sunrise sunset
+-- air european_aqi
+-- hour epoch temp code precip_prob is_day uv (24 of them)
+-- day epoch code min max precip_prob (7 of them)
+--
+-- A field Open-Meteo left null arrives as the word 'null', which tonumber
+-- turns into nil. Returns nil when current, sun or every hour is missing:
+-- a partial card is worse than an honest "no data".
function M.parse(src)
if type(src) ~= 'string' or src == '' then return nil end
-
- -- Scalars are matched inside their own object where the key would otherwise
- -- be ambiguous. "temp" appears as a prefix of "temp_min" and "temp_max", so
- -- it is anchored to the character that follows it.
- local function num(pat)
- return tonumber(src:match(pat))
+ local w = { hours = {}, days = {} }
+ for line in src:gmatch('[^\n]+') do
+ local f = {}
+ for field in (line .. '\t'):gmatch('([^\t]*)\t') do f[#f + 1] = field end
+ local function n(i) return tonumber(f[i]) end
+ local kind = f[1]
+ if kind == 'units' then
+ w.units = f[2]
+ elseif kind == 'current' then
+ w.current = { dt = n(2), temp = n(3), feels = n(4), code = n(5),
+ is_day = n(6) ~= 0, humidity = n(7), wind = n(8),
+ wind_deg = n(9) }
+ elseif kind == 'sun' then
+ w.sun = { rise = n(2), set = n(3) }
+ elseif kind == 'air' then
+ w.air = { aqi = n(2) }
+ elseif kind == 'hour' then
+ w.hours[#w.hours + 1] = { t = n(2), temp = n(3), code = n(4),
+ precip = n(5), is_day = n(6) ~= 0, uv = n(7) }
+ elseif kind == 'day' then
+ w.days[#w.days + 1] = { t = n(2), code = n(3), min = n(4), max = n(5),
+ precip = n(6) }
+ end
+ end
+ if not (w.current and w.current.temp and w.sun and w.sun.rise and w.sun.set
+ and #w.hours > 0) then
+ return nil
end
-
- local main = src:match('"main"%s*:%s*(%b{})') or ''
- local wind = src:match('"wind"%s*:%s*(%b{})') or ''
- local sys = src:match('"sys"%s*:%s*(%b{})') or ''
- local cond = src:match('"weather"%s*:%s*%[%s*(%b{})') or ''
-
- local w = {
- id = tonumber(cond:match('"id"%s*:%s*(%-?%d+)')),
- description = cond:match('"description"%s*:%s*"([^"]*)"'),
- -- The exponent is accepted although OWM has never been seen to emit one:
- -- a pattern stopping at the 'e' would read 1.8e1 as 1.8 and draw 2 degrees
- -- instead of 18, silently and plausibly. Cheaper to accept than to detect.
- temp = tonumber(main:match('"temp"%s*:%s*(%-?[%d%.eE%+%-]+)')),
- feels_like = tonumber(main:match('"feels_like"%s*:%s*(%-?[%d%.eE%+%-]+)')),
- humidity = tonumber(main:match('"humidity"%s*:%s*(%d+)')),
- wind_speed = tonumber(wind:match('"speed"%s*:%s*([%d%.eE%+%-]+)')),
- wind_deg = tonumber(wind:match('"deg"%s*:%s*(%d+)')),
- sunrise = tonumber(sys:match('"sunrise"%s*:%s*(%d+)')),
- sunset = tonumber(sys:match('"sunset"%s*:%s*(%d+)')),
- city = src:match('"name"%s*:%s*"([^"]*)"'),
- dt = num('"dt"%s*:%s*(%d+)'),
- }
-
- -- Without these the card has nothing to draw, and a partial card is worse
- -- than an honest "no data". An API error body fails here too: it carries a
- -- cod and a message, no weather.
- if not (w.id and w.temp and w.sunrise and w.sunset) then return nil end
return w
end
-- Three missed fetches at the 15-minute interval.
M.STALE_AFTER = 45 * 60
--- `now` is guarded alongside `dt` although every caller passes os.time(),
--- which cannot be nil: these were the only two functions in the module that
--- did arithmetic on an unchecked argument, and the module's promise is that
--- nothing raises. A raise here is a blank dashboard.
function M.is_stale(dt, now)
if type(dt) ~= 'number' or type(now) ~= 'number' then return true end
return (now - dt) > M.STALE_AFTER
end
--- Short age for the marker beside the city: "12m", "3h", "2d".
---
--- A negative age is clamped to zero rather than printed. An observation
--- timestamped in the future means a clock skew somewhere, and "stale -17m" on
--- the card reads as a broken widget; treating it as fresh is both tidier and
--- truer, since weather from the future is not old.
+-- Short age: "12m", "3h", "2d". A negative age (clock skew) reads as fresh.
function M.age_str(dt, now)
if type(dt) ~= 'number' or type(now) ~= 'number' then return '?' end
local s = now - dt
diff --git a/test/fixtures/weather.json b/test/fixtures/weather.json
deleted file mode 100644
index 12493b0..0000000
--- a/test/fixtures/weather.json
+++ /dev/null
@@ -1 +0,0 @@
-{"coord":{"lon":11.0,"lat":45.0},"weather":[{"id":501,"main":"Rain","description":"moderate rain","icon":"10d"}],"base":"stations","main":{"temp":18.34,"feels_like":18.11,"temp_min":16.67,"temp_max":19.44,"pressure":1014,"humidity":72},"visibility":10000,"wind":{"speed":4.12,"deg":230},"clouds":{"all":75},"dt":1789625000,"sys":{"type":2,"id":2004688,"country":"XX","sunrise":1789600000,"sunset":1789646000},"timezone":7200,"id":1,"name":"Example City","cod":200}
diff --git a/test/fixtures/weather.txt b/test/fixtures/weather.txt
new file mode 100644
index 0000000..b1a1af8
--- /dev/null
+++ b/test/fixtures/weather.txt
@@ -0,0 +1,9 @@
+units metric
+current 1792325700 13.2 11.5 3 1 77 10.2 72
+sun 1792301818 1792339510
+air 37
+hour 1792324800 13.1 3 0 1 1.15
+hour 1792328400 13.4 61 40 1 null
+hour 1792332000 null 3 0 0 0.95
+day 1792274400 3 8.1 14.6 10
+day 1792360800 61 7.4 12.0 80
diff --git a/test/fixtures/weather_truncated.json b/test/fixtures/weather_truncated.json
deleted file mode 100644
index eb0099a..0000000
--- a/test/fixtures/weather_truncated.json
+++ /dev/null
@@ -1 +0,0 @@
-{"coord":{"lon":11.0,"lat":45.0},"weather":[{"id":501,"main":"Rain","descrip
diff --git a/test/test_weather.lua b/test/test_weather.lua
index cdcdc2d..cea4645 100644
--- a/test/test_weather.lua
+++ b/test/test_weather.lua
@@ -1,236 +1,200 @@
-- Checks for lib/weather.lua.
-- Run from the repo root: lua test/test_weather.lua
-- The domain tables are what silently misreport when a boundary is off by one,
--- so every range boundary gets an assertion. The card itself is verified by
--- screenshot.
+-- so every range boundary gets an assertion. The cards themselves are verified
+-- by rendering them.
package.path = './?.lua;' .. package.path
local weather = require 'lib.weather'
--- === Condition id to icon =================================================
--- OWM ids group by hundreds, but the boundaries are not round numbers: the
--- ranges come from the polybar script that ran against this API for years.
--- Each assertion pairs the last id in a range with the first id of the next,
--- which is where an off-by-one would hide.
--- Sunrise 1500, sunset 1900, so DAY must sit INSIDE that window and NIGHT
--- outside it. Naming them without checking them against the window is how the
--- first draft of this test made every timestamp evaluate as night.
-local DAY, NIGHT = 1700, 2000
-
-local function icon(id, now)
- return weather.icon(id, now, 1500, 1900) -- sunrise 1500, sunset 1900
+local function slurp(path)
+ local f = assert(io.open(path, 'r'))
+ local s = f:read('*a')
+ f:close()
+ return s
end
--- Thunderstorm: everything up to 232.
-assert(icon(200, DAY) == weather.ICON.thunder_day, 'id 200 day')
-assert(icon(232, DAY) == weather.ICON.thunder_day, 'id 232 is still thunder')
-assert(icon(232, NIGHT) == weather.ICON.thunder_night, 'id 232 night')
-
--- Light drizzle: 233..311.
-assert(icon(233, DAY) == weather.ICON.drizzle_day, 'id 233 leaves thunder')
-assert(icon(311, DAY) == weather.ICON.drizzle_day, 'id 311 is still light drizzle')
-
--- Heavy drizzle: 312..321.
-assert(icon(312, DAY) == weather.ICON.drizzle_heavy_day, 'id 312 is heavy drizzle')
-assert(icon(321, DAY) == weather.ICON.drizzle_heavy_day, 'id 321 is still heavy drizzle')
-
--- Rain: 322..531.
-assert(icon(322, DAY) == weather.ICON.rain_day, 'id 322 is rain')
-assert(icon(531, DAY) == weather.ICON.rain_day, 'id 531 is still rain')
-
--- Snow: 532..622. One icon, no day/night variant.
-assert(icon(600, DAY) == weather.ICON.snow, 'id 600 is snow')
-assert(icon(622, NIGHT) == weather.ICON.snow, 'snow is the same at night')
-
--- Fog: 623..771.
-assert(icon(741, DAY) == weather.ICON.fog, 'id 741 is fog')
-assert(icon(771, DAY) == weather.ICON.fog, 'id 771 is still fog')
-
--- Tornado is a single id, not a range.
-assert(icon(781, DAY) == weather.ICON.tornado, 'id 781 is tornado')
-
--- Clear and few clouds each have a day and a night face.
-assert(icon(800, DAY) == weather.ICON.clear_day, 'id 800 day is the sun')
-assert(icon(800, NIGHT) == weather.ICON.clear_night, 'id 800 night is the moon')
-assert(icon(801, DAY) == weather.ICON.few_day, 'id 801 day')
-assert(icon(801, NIGHT) == weather.ICON.few_night, 'id 801 night')
-
--- Overcast: 802..804, no night variant.
-assert(icon(804, DAY) == weather.ICON.overcast, 'id 804 is overcast')
-
--- Anything outside the known ids must be visibly wrong, not silently sunny.
-assert(icon(999, DAY) == weather.ICON.unknown, 'unknown id gets the error glyph')
-assert(weather.icon(nil, DAY, 1500, 1900) == weather.ICON.unknown, 'nil id')
-
--- Nothing may raise. A missing timestamp with a known sun, or a known
--- timestamp with no sun, both have to return a glyph rather than an error,
--- because an error here is a blank dashboard with no message.
-assert(weather.icon(800, nil, 1500, 1900) == weather.ICON.clear_day,
- 'a nil now must not raise, and falls back to the day face')
-assert(weather.icon(800, DAY, nil, nil) == weather.ICON.clear_day,
- 'no sun times must not raise')
-
--- The codepoint VALUES are deliberately not asserted. A test comparing
--- M.ICON.snow against a literal would only prove the same escape was typed
--- twice; whether E31A actually draws a snow cloud in the target font is not
--- something an assertion can see. That check is the glyph sheet rendered in
--- the plan's Step 5, and it is how a wrong-but-present codepoint was caught
--- before this file existed.
+-- === WMO code to glyph ====================================================
+-- Open-Meteo reports WMO weather codes. The groups are sparse (0-3, 45, 48,
+-- 51-57, ...), so each group's first and last code is asserted, plus a code
+-- in a gap, which must read as unknown rather than silently sunny.
+local I = weather.ICON
+assert(weather.icon(0, true) == I.clear_day, 'code 0 day is the sun')
+assert(weather.icon(0, false) == I.clear_night, 'code 0 night is the moon')
+assert(weather.icon(1, true) == I.few_day, 'code 1 is few clouds')
+assert(weather.icon(2, false) == I.few_night, 'code 2 night')
+assert(weather.icon(3, false) == I.overcast, 'code 3 is overcast, no night face')
+assert(weather.icon(45, true) == I.fog, 'code 45 is fog')
+assert(weather.icon(48, true) == I.fog, 'code 48 is fog')
+assert(weather.icon(51, true) == I.drizzle_day, 'code 51 is drizzle')
+assert(weather.icon(57, false) == I.drizzle_night, 'code 57 night')
+assert(weather.icon(61, true) == I.rain_day, 'code 61 is rain')
+assert(weather.icon(67, true) == I.rain_day, 'code 67 is still rain')
+assert(weather.icon(80, false) == I.rain_night, 'code 80 showers are rain')
+assert(weather.icon(82, true) == I.rain_day, 'code 82 is rain')
+assert(weather.icon(71, true) == I.snow, 'code 71 is snow')
+assert(weather.icon(77, false) == I.snow, 'code 77 is snow at night too')
+assert(weather.icon(85, true) == I.snow, 'code 85 snow showers')
+assert(weather.icon(86, true) == I.snow, 'code 86 snow showers')
+assert(weather.icon(95, true) == I.thunder_day, 'code 95 is thunder')
+assert(weather.icon(99, false) == I.thunder_night, 'code 99 night')
+
+-- Gaps and junk.
+assert(weather.icon(4, true) == I.unknown, 'code 4 is not a WMO code')
+assert(weather.icon(50, true) == I.unknown, 'code 50 sits in a gap')
+assert(weather.icon(-1, true) == I.unknown, 'negative code')
+assert(weather.icon(nil, true) == I.unknown, 'nil code')
+assert(weather.icon('3', true) == I.unknown, 'string code')
+
+-- An unknown day/night falls back to the day face, matching is_day().
+assert(weather.icon(0, nil) == I.clear_day, 'nil is_day draws the day face')
+
+-- The codepoint VALUES are deliberately not asserted. Whether a codepoint
+-- draws a snow cloud in the target font is not something an assertion can
+-- see; the glyph sheet in Task 3 is that check.
+
+-- === Descriptions =========================================================
+assert(weather.describe(0) == 'Clear sky', 'code 0 words')
+assert(weather.describe(63) == 'Rain', 'code 63 words')
+assert(weather.describe(99) == 'Thunderstorm, hail', 'code 99 words')
+assert(weather.describe(4) == 'Unknown', 'gap code words')
+assert(weather.describe(nil) == 'Unknown', 'nil code words')
+
+-- === Formatting ===========================================================
+assert(weather.deg(13.2) == '13\u{00B0}', 'rounds down')
+assert(weather.deg(13.5) == '14\u{00B0}', 'rounds half up')
+assert(weather.deg(-0.4) == '0\u{00B0}', 'just below zero reads 0')
+assert(weather.deg(-2.6) == '-3\u{00B0}', 'negative rounds away from zero')
+assert(weather.deg(nil) == '--', 'missing temperature is --, never 0')
+
+assert(weather.num(77) == '77', 'plain number')
+assert(weather.num(39.6, '%') == '40%', 'rounded with suffix')
+assert(weather.num(0, '%') == '0%', 'zero is a real reading')
+assert(weather.num(nil, '%') == '--', 'missing number is --')
+
+assert(weather.wind_unit('metric') == 'km/h', 'metric wind')
+assert(weather.wind_unit('imperial') == 'mph', 'imperial wind')
+assert(weather.wind_unit(nil) == 'km/h', 'unknown units default to metric')
+
+assert(weather.duration(37692) == '10h 28m', 'day length')
+assert(weather.duration(0) == '0h 00m', 'zero length')
+assert(weather.duration(-5) == '--', 'negative length is bad data')
+assert(weather.duration(nil) == '--', 'missing length')
+
+-- === Thresholds ===========================================================
+-- Each boundary and the value just under it.
+assert(weather.uv_role(2.9) == 'ok', 'UV under 3 is ok')
+assert(weather.uv_role(3) == 'warning', 'UV 3 is warning')
+assert(weather.uv_role(7.9) == 'warning', 'UV under 8 is warning')
+assert(weather.uv_role(8) == 'critical', 'UV 8 is critical')
+assert(weather.uv_role(nil) == nil, 'missing UV has no role')
+
+assert(weather.aqi_role(40) == 'ok', 'AQI 40 is ok')
+assert(weather.aqi_role(41) == 'warning', 'AQI 41 is warning')
+assert(weather.aqi_role(80) == 'warning', 'AQI 80 is warning')
+assert(weather.aqi_role(81) == 'critical', 'AQI 81 is critical')
+assert(weather.aqi_role(nil) == nil, 'missing AQI has no role')
+
+-- === Moon =================================================================
+-- Known 2024 phases (UTC): new 01-11 11:57, full 01-25 17:54, new 02-09 22:59.
+-- The mean synodic month drifts from the true one by up to about half a day,
+-- so the tolerance is one day of phase.
+local DAY_PHASE = 1 / 29.530588853
+local function circ(a, b) local d = math.abs(a - b) % 1; return math.min(d, 1 - d) end
+local p_new = weather.moon(1704974220)
+local p_full, lit_full = weather.moon(1706205240)
+local p_new2, lit_new2 = weather.moon(1707519540)
+assert(circ(p_new, 0) < DAY_PHASE, 'new moon phase near 0, got ' .. p_new)
+assert(circ(p_full, 0.5) < DAY_PHASE, 'full moon phase near 0.5, got ' .. p_full)
+assert(circ(p_new2, 0) < DAY_PHASE, 'next new moon near 0, got ' .. p_new2)
+assert(lit_full > 0.98, 'full moon is lit, got ' .. lit_full)
+assert(lit_new2 < 0.02, 'new moon is dark, got ' .. lit_new2)
+assert(weather.moon(nil) == nil, 'missing epoch')
+
+-- 28 glyphs from U+E38D. Phase 0 and phase 1 are both the new moon.
+assert(weather.moon_icon(0) == utf8.char(0xE38D), 'phase 0 is new moon')
+assert(weather.moon_icon(0.5) == utf8.char(0xE38D + 14), 'phase 0.5 is full moon')
+assert(weather.moon_icon(0.999) == utf8.char(0xE38D), 'phase near 1 wraps to new')
+assert(weather.moon_icon(nil) == I.unknown, 'missing phase')
-- === Wind =================================================================
--- OWM metric gives m/s; the card shows km/h. The conversion is where a wrong
--- factor would look plausible but read 3.6x off.
-assert(weather.kmh(10) == 36, '10 m/s is 36 km/h')
-assert(weather.kmh(0) == 0, 'calm')
-assert(weather.kmh(nil) == nil, 'missing wind speed stays missing')
-
--- Beaufort thresholds, in km/h, from the polybar script. Each assertion sits
--- on a boundary and just past it, which is where an inclusive/exclusive
--- mistake hides.
-assert(weather.beaufort(0) == 0, 'calm is force 0')
-assert(weather.beaufort(1) == 0, '1 km/h is still force 0')
-assert(weather.beaufort(2) == 1, 'just over 1 is force 1')
-assert(weather.beaufort(5) == 1, '5 is still force 1')
-assert(weather.beaufort(6) == 2, 'just over 5 is force 2')
-assert(weather.beaufort(11) == 2, '11 is still force 2')
-assert(weather.beaufort(12) == 3, 'just over 11 is force 3')
-assert(weather.beaufort(19) == 3, '19 is still force 3')
-assert(weather.beaufort(28) == 4, '28 is force 4')
-assert(weather.beaufort(38) == 5, '38 is force 5')
-assert(weather.beaufort(49) == 6, '49 is force 6')
-assert(weather.beaufort(61) == 7, '61 is force 7')
-assert(weather.beaufort(74) == 8, '74 is force 8')
-assert(weather.beaufort(88) == 9, '88 is force 9')
-assert(weather.beaufort(102) == 10, '102 is force 10')
-assert(weather.beaufort(117) == 11, '117 is force 11')
-assert(weather.beaufort(118) == 12, 'over 117 is hurricane force')
-
-- Compass: eight points, each spanning 45 degrees, offset by half a step so
--- north straddles 0 rather than starting at it. The wraparound is the case
--- that a naive floor(deg/45) gets wrong.
+-- north straddles 0 rather than starting at it.
assert(weather.compass(0) == 'N', '0 is north')
assert(weather.compass(360) == 'N', '360 wraps to north')
-assert(weather.compass(11) == 'N', 'just under the NE boundary')
+assert(weather.compass(22) == 'N', 'just under the NE boundary')
+assert(weather.compass(23) == 'NE', 'just over it')
assert(weather.compass(349) == 'N', 'just over the NW boundary wraps to north')
-assert(weather.compass(45) == 'NE', '45 is northeast')
assert(weather.compass(90) == 'E', '90 is east')
-assert(weather.compass(135) == 'SE', '135 is southeast')
assert(weather.compass(180) == 'S', '180 is south')
-assert(weather.compass(225) == 'SW', '225 is southwest')
assert(weather.compass(270) == 'W', '270 is west')
assert(weather.compass(315) == 'NW', '315 is northwest')
assert(weather.compass(nil) == nil, 'missing direction stays missing')
--- The arrow points where the wind is going, and OWM reports where it comes
--- from, so a north wind draws a downward arrow.
-assert(weather.arrow(0) == '\u{2193}', 'a north wind blows southward')
-assert(weather.arrow(180) == '\u{2191}', 'a south wind blows northward')
-assert(weather.arrow(nil) == '', 'no direction draws nothing')
-
-- === Sun position =========================================================
--- t is the fraction of daylight elapsed, and the widget uses it to place the
--- dot along the arc. Sunrise 1000, sunset 2000, so midday is 1500.
+-- t is the fraction of daylight elapsed. Sunrise 1000, sunset 2000.
assert(weather.sun_t(1000, 1000, 2000) == 0, 'at sunrise t is 0')
assert(weather.sun_t(1500, 1000, 2000) == 0.5, 'at midday t is half')
assert(weather.sun_t(2000, 1000, 2000) == 1, 'at sunset t is 1')
-
--- OWM reports today's sunrise and sunset, so between midnight and sunrise the
--- numerator is negative. The clamp is the whole handling; without it the dot
--- would be drawn off the left end of the arc.
assert(weather.sun_t(500, 1000, 2000) == 0, 'before dawn clamps to 0')
assert(weather.sun_t(9999, 1000, 2000) == 1, 'after dusk clamps to 1')
-
--- Degenerate input must not divide by zero.
assert(weather.sun_t(1500, 2000, 2000) == 0, 'zero-length day gives 0, not nan')
assert(weather.sun_t(nil, 1000, 2000) == nil, 'missing now gives nil')
-assert(weather.sun_t(1500, nil, 2000) == nil, 'missing sunrise gives nil')
--- is_day drives both the icon face and the dot's colour: a dot parked at the
--- end of the arc must not read as a sun that is still up.
assert(weather.is_day(1500, 1000, 2000) == true, 'midday is day')
assert(weather.is_day(1000, 1000, 2000) == true, 'sunrise counts as day')
-assert(weather.is_day(2000, 1000, 2000) == true, 'sunset counts as day')
assert(weather.is_day(500, 1000, 2000) == false, 'before dawn is night')
-assert(weather.is_day(2500, 1000, 2000) == false, 'after dusk is night')
+assert(weather.is_day(nil, 1000, 2000) == true, 'unknown falls back to day')
-- === Parsing the cache ====================================================
--- The parser reads scalars by key with Lua patterns rather than pulling in a
--- JSON library: the response is flat and known, and six numbers do not justify
--- a dependency. Caching the whole response still costs nothing, since a field
--- added later is already on disk.
-local function read(path)
- local f = assert(io.open(path, 'r'))
- local s = f:read('*a')
- f:close()
- return s
-end
-
-local w = weather.parse(read('test/fixtures/weather.json'))
-assert(w, 'the fixture must parse')
-assert(w.id == 501, 'condition id, got ' .. tostring(w.id))
-assert(w.description == 'moderate rain', 'description, got ' .. tostring(w.description))
-assert(w.temp == 18.34, 'temp, got ' .. tostring(w.temp))
-assert(w.feels_like == 18.11, 'feels like, got ' .. tostring(w.feels_like))
-assert(w.humidity == 72, 'humidity, got ' .. tostring(w.humidity))
-assert(w.wind_speed == 4.12, 'wind m/s, got ' .. tostring(w.wind_speed))
-assert(w.wind_deg == 230, 'wind direction, got ' .. tostring(w.wind_deg))
-assert(w.sunrise == 1789600000, 'sunrise, got ' .. tostring(w.sunrise))
-assert(w.sunset == 1789646000, 'sunset, got ' .. tostring(w.sunset))
-assert(w.city == 'Example City', 'city, got ' .. tostring(w.city))
-assert(w.dt == 1789625000, 'observation time, got ' .. tostring(w.dt))
-
--- temp comes before feels_like in the response and both live under "main",
--- so a lazy pattern would read one for the other. They differ in the fixture
--- precisely so this is checkable.
-assert(w.temp ~= w.feels_like, 'temp and feels_like must not collapse')
-
--- Failure modes all return nil rather than raising: a Lua error here is a
--- blank dashboard, not a message.
-assert(weather.parse(read('test/fixtures/weather_truncated.json')) == nil,
- 'a truncated response must give nil')
-assert(weather.parse('') == nil, 'empty input gives nil')
-assert(weather.parse('not json at all') == nil, 'garbage gives nil')
-assert(weather.parse(nil) == nil, 'nil input gives nil')
-
--- An error body carries cod 401 and no weather. It must not parse as data.
-assert(weather.parse('{"cod":401,"message":"Invalid API key."}') == nil,
- 'an API error body must give nil')
-
--- Negative temperatures are ordinary here for half the year, and a number in
--- exponent form must not be truncated at the 'e': reading 1.8e1 as 1.8 would
--- draw 2 degrees instead of 18, wrong in a way that still looks like weather.
-local cold = weather.parse(
- '{"weather":[{"id":600,"description":"snow"}],"main":{"temp":-12.5},' ..
- '"sys":{"sunrise":100,"sunset":200},"name":"X","dt":150}')
-assert(cold and cold.temp == -12.5, 'a negative temp, got ' .. tostring(cold and cold.temp))
-local exp = weather.parse(
- '{"weather":[{"id":800}],"main":{"temp":1.8e1},' ..
- '"sys":{"sunrise":100,"sunset":200}}')
-assert(exp and exp.temp == 18, 'exponent form, got ' .. tostring(exp and exp.temp))
+local w = weather.parse(slurp('test/fixtures/weather.txt'))
+assert(w, 'the fixture parses')
+assert(w.units == 'metric', 'units')
+assert(w.current.dt == 1792325700, 'current time')
+assert(w.current.temp == 13.2, 'current temperature')
+assert(w.current.feels == 11.5, 'feels like')
+assert(w.current.code == 3, 'current code')
+assert(w.current.is_day == true, 'is_day 1 is day')
+assert(w.current.humidity == 77, 'humidity')
+assert(w.current.wind == 10.2, 'wind')
+assert(w.current.wind_deg == 72, 'wind direction')
+assert(w.sun.rise == 1792301818 and w.sun.set == 1792339510, 'sun times')
+assert(w.air.aqi == 37, 'air quality')
+assert(#w.hours == 3, 'three hours, got ' .. #w.hours)
+assert(w.hours[1].uv == 1.15, 'first hour UV')
+assert(w.hours[2].uv == nil, 'null UV reads as nil')
+assert(w.hours[2].precip == 40, 'rain chance')
+assert(w.hours[3].temp == nil, 'null temperature reads as nil')
+assert(w.hours[3].is_day == false, 'is_day 0 is night')
+assert(#w.days == 2, 'two days')
+assert(w.days[1].min == 8.1 and w.days[1].max == 14.6, 'today min and max')
+assert(w.days[2].precip == 80, 'tomorrow rain chance')
+
+-- No air line: still parses, air is nil, the card shows --.
+local no_air = slurp('test/fixtures/weather.txt'):gsub('air\t37\n', '')
+local wa = weather.parse(no_air)
+assert(wa and wa.air == nil, 'missing air line is not fatal')
+
+-- Required records missing: nil, so the card draws its notice.
+assert(weather.parse((slurp('test/fixtures/weather.txt'):gsub('current\t[^\n]*\n', ''))) == nil,
+ 'no current line is nil')
+assert(weather.parse((slurp('test/fixtures/weather.txt'):gsub('sun\t[^\n]*\n', ''))) == nil,
+ 'no sun line is nil')
+assert(weather.parse((slurp('test/fixtures/weather.txt'):gsub('hour\t[^\n]*\n', ''))) == nil,
+ 'no hour lines is nil')
+assert(weather.parse('') == nil, 'empty string')
+assert(weather.parse(nil) == nil, 'nil')
+assert(weather.parse('{"cod":401,"message":"bad"}') == nil, 'garbage')
-- === Staleness ============================================================
--- 45 minutes is three missed fetches at the 15-minute interval, so one
--- transient failure does not flag the card.
-assert(weather.is_stale(1000, 1000) == false, 'a fresh reading is not stale')
-assert(weather.is_stale(1000, 1000 + 45 * 60) == false, 'exactly 45 min is the boundary')
-assert(weather.is_stale(1000, 1000 + 45 * 60 + 1) == true, 'past 45 min is stale')
-assert(weather.is_stale(nil, 1000) == true, 'no observation time counts as stale')
-
--- The age string is what the card prints beside the city, so it must be short.
-assert(weather.age_str(1000, 1000 + 90) == '1m', 'ninety seconds reads as 1m')
-assert(weather.age_str(1000, 1000 + 3600) == '1h', 'an hour reads as 1h')
-assert(weather.age_str(1000, 1000 + 7200) == '2h', 'two hours')
-assert(weather.age_str(1000, 1000 + 86400 * 2) == '2d', 'days, once it gets that bad')
-
--- Neither takes `now` on faith: a nil there used to raise, and a raise is a
--- blank dashboard rather than a message.
-assert(weather.is_stale(1000, nil) == true, 'a nil now must not raise')
-assert(weather.age_str(1000, nil) == '?', 'a nil now must not raise')
-
--- A dt in the future means a clock skew. "stale -17m" reads as a broken
--- widget, so the age floors at zero and the reading counts as fresh.
-assert(weather.age_str(2000, 1000) == '0m', 'a future dt reads as 0m, got ' ..
- tostring(weather.age_str(2000, 1000)))
-assert(weather.is_stale(2000, 1000) == false, 'a future dt is not stale')
+-- Three missed fetches at the 15-minute interval.
+assert(weather.is_stale(1000, 1000 + 45 * 60) == false, 'exactly 45m is not stale')
+assert(weather.is_stale(1000, 1000 + 45 * 60 + 1) == true, 'over 45m is stale')
+assert(weather.is_stale(nil, 1000) == true, 'no timestamp is stale')
+assert(weather.age_str(1000, 1000 + 12 * 60) == '12m', 'minutes')
+assert(weather.age_str(1000, 1000 + 3 * 3600) == '3h', 'hours')
+assert(weather.age_str(1000, 1000 + 2 * 86400) == '2d', 'days')
+assert(weather.age_str(2000, 1000) == '0m', 'a future dt reads as 0m')
+assert(weather.age_str(nil, 1000) == '?', 'missing dt')
print('test_weather: all assertions passed')