#!/usr/bin/env bash # High/low temperature, total rainfall, growing degree days, solar energy, and # freezing exposure for one local day, read from a public Tempest (WeatherFlow) # station. Prints one JSON object on stdout. # Usage: weather-day [YYYY-MM-DD] (default: yesterday) # # The station is public, so the key below is WeatherFlow's own web-app key rather # than a secret — it is what tempestwx.com sends, works for any public station, and # is documented as the default in the weather-stats repo's .env.example. Nothing # here belongs in .env: swap the two constants and rebuild to point at a different # station. # # Reaches swd.weatherflow.com, which must be on the `acl allowed` line in # config/squid.conf or every call here fails with a 403 from the proxy. That # coupling cannot be asserted at build time — squid.conf is mounted into the squid # sidecar and never exists inside this image. set -euo pipefail STATION_ID=173994 API_KEY=6bff2f89-84ab-463c-886e-fc0f443da4cf API_BASE=https://swd.weatherflow.com/swd/rest BUILD=169 # Everything below the high/low is an integral, and an integral fails quietly: a # wrong high looks wrong, a wrong degree-day total looks like a number. So this # one constant guards both ways a day's samples can misrepresent the day. # # As the *fetch pad*, it is how far either side of midnight the observations query # reaches, so the samples bracketing each boundary exist and the day can be # interpolated to its true edges instead of truncated to the first and last # reading inside it. # # As the *gap limit*, it is the widest hole between consecutive samples the # integrals will cross. This endpoint answers a day-long range on a 5-minute grid # — 2026-08-18 came back as 287 observations spanning 23.8 h, which is 300.0 s # apart — so an hour-long hole is a dozen consecutive reports missing, an outage # rather than a few dropped ones. A straight line drawn across one, through a # summer afternoon especially, moves the day's totals a long way while looking # entirely ordinary in the note. Better to refuse the day. # # Measured rather than assumed, and worth re-measuring before leaning on it: the # station itself reports far more often than this, so the 5 minutes is the API's # resolution for a range this wide, not the hardware's. # # One number for both on purpose: a neighbouring observation further out than the # pad is one the gap limit would refuse to interpolate across anyway, so widening # either alone buys nothing. MAX_GAP_SECONDS=3600 # Both headers are load-bearing, not politeness, and the key is why: it was lifted # from the tempestwx.com JavaScript bundle, and WeatherFlow gates it on the request # resembling that web app. Sending neither returns 401, observed — these are the # two values the working weather-stats client sends on every call # (src/lib/server/collectors/tempest-api.ts), and `build` is part of the same act. # Drop any of the three and expect the 401 back. ORIGIN=https://tempestwx.com USER_AGENT='Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0' if [ $# -gt 1 ]; then echo 'usage: weather-day [YYYY-MM-DD] (default: yesterday)' >&2 exit 2 fi day=${1:-$(date -d yesterday +%F)} # Rejected here rather than passed through: `date -d` accepts a great deal that is # not a calendar date ("now", "next friday", "1 day ago"), and any of them would # produce a plausible-looking result filed under a nonsense `date` key. if ! [[ $day =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then echo "weather-day: '$day' is not a date in YYYY-MM-DD form" >&2 exit 2 fi if ! date -d "$day" >/dev/null 2>&1; then echo "weather-day: '$day' is not a real calendar date" >&2 exit 2 fi # The container's TZ decides where the day starts, which is what makes this the # user's day rather than UTC's. `+ 1 day` is calendar arithmetic rather than # +86400, so a DST transition gives a 23- or 25-hour day instead of one shifted by # an hour — verified at both 2026 transitions. # # The bare date, with no `00:00:00`, is load-bearing. GNU date parses a `+N` that # follows a *time* as a numeric timezone, so `"$day 00:00:00 + 1 day"` yields # 16:00 the same day and silently loses a third of it. start=$(date -d "$day" +%s) end=$(date -d "$day + 1 day" +%s) # Shared by both calls: check the HTTP status before handing the body to jq, so a # proxy denial or an API outage reports itself instead of surfacing as "no # observations" for a day the station was up. tempest_get() { local endpoint=$1 resp code json resp=$(curl -sS -w $'\n%{http_code}' -G "${API_BASE}/${endpoint}" \ -A "${USER_AGENT}" \ -H "Origin: ${ORIGIN}" \ --data-urlencode "api_key=${API_KEY}" \ --data-urlencode "build=${BUILD}" \ "${@:2}") code=${resp##*$'\n'} json=${resp%$'\n'*} if [ "$code" != "200" ]; then echo "weather-day: ${endpoint} failed (HTTP $code)" >&2 echo " $json" >&2 exit 1 fi printf '%s' "$json" } # A station has one ST (Tempest) device and usually an HB (hub) alongside it; only # the ST reports weather. locations=$(tempest_get "locations/${STATION_ID}" --data-urlencode 'include_arbitrary_locations=true') device_id=$(printf '%s' "$locations" | jq -r ' if .status.status_code != 0 then "ERR:\(.status.status_message)" else (.locations[0].devices[]? | select(.device_type == "ST") | .device_id) // "ERR:no ST device" end' | head -1) case "$device_id" in ERR:*) echo "weather-day: station ${STATION_ID}: ${device_id#ERR:}" >&2 exit 1 ;; ''|*[!0-9]*) echo "weather-day: station ${STATION_ID}: could not resolve a Tempest device id" >&2 exit 1 ;; esac # Deliberately wider than the day on both sides — MAX_GAP_SECONDS of padding — so # the observations either side of each midnight come back and the boundaries can # be interpolated. Without them the first integral segment would start at whenever # the station happened to report after 00:00 and the day would be quietly short. # # The padding is *only* for interpolation. The jq filter below re-asserts the # half-open window itself, so temp_high, temp_low, rainfall, observations and # coverage_hours still describe the requested day and nothing else — which also # makes time_end's inclusivity on the API side stop mattering here. observations=$(tempest_get "observations/device/${device_id}" \ --data-urlencode "time_start=$((start - MAX_GAP_SECONDS))" \ --data-urlencode "time_end=$((end + MAX_GAP_SECONDS))") # Positional obs_st layout, per the OBS_IDX table in weather-stats # (src/lib/server/collectors/tempest-api.ts): [0] epoch, [7] air temperature in C, # [11] solar radiation in W/m², [12] precipitation in mm accumulated over the # report interval. # # Rainfall is the sum of [12] rather than the station's own # precip_accum_local_day, which resets at the station's midnight — summing keeps # the total tied to the window actually requested. # # The four derived numbers are integrals over the day rather than statistics over # the samples, and the difference is not academic. The spacing between reports is # nothing this code should assume — it is whatever the API returns for the range # asked for, and reports go missing — so a per-sample average silently weights a # stretch the station was chatty the same as a stretch it was quiet; and clipping # each *sample* at a threshold charges a whole interval to whichever side its # endpoints landed on. Both errors are invisible in the output and both compound # when a season of these gets summed. So: piecewise-linear between real # timestamps, thresholds crossed at the exact instant the line crosses them. printf '%s' "$observations" | jq \ --arg date "$day" \ --argjson station "$STATION_ID" \ --argjson start "$start" \ --argjson end "$end" \ --argjson maxgap "$MAX_GAP_SECONDS" ' def mag: if . < 0 then - . else . end; def fahrenheit: . * 9 / 5 + 32; def r1: . * 10 | round / 10; def r2: . * 100 | round / 100; # Every def below takes a series: [epoch, value] pairs, ascending, one per # observation that actually carried the field. # The value of the segment [$a,$b] at time $t. def at($a; $b; $t): $a[1] + ($b[1] - $a[1]) * ($t - $a[0]) / ($b[0] - $a[0]); # $p clipped to the day, with the two boundary values interpolated from the # observations just outside it — which is what the padded fetch above is for. # A neighbour further out than $maxgap is not used: that is a hole, not a # boundary, so the series just starts (or ends) at the nearest real sample and # coverage_hours is left to report the short day. def day_series($p): [$p[] | select(.[0] >= $start and .[0] < $end)] as $in | if ($in | length) == 0 then [] else ([$p[] | select(.[0] < $start)] | last) as $pre | ([$p[] | select(.[0] >= $end)] | first) as $post | (if $pre != null and $in[0][0] > $start and ($in[0][0] - $pre[0]) <= $maxgap then [[$start, at($pre; $in[0]; $start)]] else [] end) + $in + (if $post != null and $in[-1][0] < $end and ($post[0] - $in[-1][0]) <= $maxgap then [[$end, at($in[-1]; $post; $end)]] else [] end) end; # ∫max(v,0)·dt in value-hours. A segment whose endpoints straddle zero # contributes only the triangle on the positive side of the crossing, and # solving for that crossing is the entire reason this is not a trapezoid sum: # a trapezoid of the clipped endpoints charges the whole interval to the # positive side, which is how an hour at 31°F becomes an hour of thaw. def positive_area($s): reduce range(1; $s | length) as $i (0; $s[$i - 1][1] as $a | $s[$i][1] as $b | (($s[$i][0] - $s[$i - 1][0]) / 3600) as $h | . + (if $a >= 0 and $b >= 0 then ($a + $b) / 2 * $h elif $a <= 0 and $b <= 0 then 0 else (([$a, 0] | max) as $pa | ([$b, 0] | max) as $pb | ($pa * $pa + $pb * $pb) / (2 * (($a - $b) | mag)) * $h) end)); # Hours for which the interpolated value is strictly positive, same crossing. def positive_hours($s): reduce range(1; $s | length) as $i (0; $s[$i - 1][1] as $a | $s[$i][1] as $b | (($s[$i][0] - $s[$i - 1][0]) / 3600) as $h | . + (if $a > 0 and $b > 0 then $h elif $a <= 0 and $b <= 0 then 0 else $h * (([$a, 0] | max) + ([$b, 0] | max)) / (($a - $b) | mag) end)); # The widest interval between consecutive samples, as [from, to, seconds]. def widest($s): [range(1; $s | length) | [$s[. - 1][0], $s[.][0], ($s[.][0] - $s[. - 1][0])]] | max_by(.[2]); # Refuse rather than draw a straight line across an outage. See MAX_GAP_SECONDS. def no_gaps($s; $what): widest($s) as $g | if $g != null and $g[2] > $maxgap then "weather-day: \($what) for \($date) stops for \($g[2] / 3600 | r1) h (\($g[0] | localtime | strftime("%H:%M")) to \($g[1] | localtime | strftime("%H:%M"))) — a day total interpolated across a hole that size would look entirely normal and be wrong\n" | halt_error(1) else . end; if .status.status_code != 0 then "weather-day: device observations: \(.status.status_message)\n" | halt_error(1) else . end # Sorted and one-per-timestamp because the integrals below walk consecutive # pairs; the API answers in order, but nothing here should depend on that. | ([.obs[]? | select(type == "array" and (.[0] | type) == "number")] | unique_by(.[0])) as $all | [$all[] | select(.[0] >= $start and .[0] < $end)] as $o | if ($o | length) == 0 then "weather-day: no observations for \($date) — station offline, or the date is outside its history\n" | halt_error(1) else . end | [$o[] | .[7] | numbers] as $temps | [$o[] | .[12] | numbers] as $precip | (if ($temps | length) == 0 then "weather-day: observations for \($date) carry no temperature readings\n" | halt_error(1) else . end) | day_series([$all[] | select((.[7] | type) == "number") | [.[0], (.[7] | fahrenheit)]]) as $tempF | day_series([$all[] | select((.[11] | type) == "number") | [.[0], .[11]]]) as $solar # A dead pyranometer integrates to a perfectly plausible 0.0 that would be # written into a note and summed forever, so it is an error, not a zero. Zero # readings are different, and stay zero. | (if ($solar | length) == 0 then "weather-day: observations for \($date) carry no solar radiation readings\n" | halt_error(1) else . end) | no_gaps($tempF; "temperature") | no_gaps($solar; "solar radiation") # Omitted, not zeroed, on a day that never froze: absent says "no exposure", # whereas 0.0 is also what a broken calculation says. Tested on the series # minimum rather than the rounded temp_low, so a midnight boundary that # interpolates just under 32°F counts — it is inside the day. | (if ([$tempF[] | .[1]] | min) < 32 then ([$tempF[] | [.[0], (32 - .[1])]] as $freezing | { hours_below_freezing: (positive_hours($freezing) | r1), freezing_degree_hours: (positive_area($freezing) | r1) }) else {} end) as $cold | { date: $date, station_id: $station, temp_high: (($temps | max) | fahrenheit | r1), temp_low: (($temps | min) | fahrenheit | r1), rainfall: (($precip | add // 0) / 25.4 | r2), # Degree-days, so the °F-hours above the base divided by 24 — one day at a # steady 60°F is 10, not 240. A daily figure, meant to be summed. gdd_base50: (positive_area([$tempF[] | [.[0], (.[1] - 50)]]) / 24 | r1), # W/m² integrated over hours is Wh/m². positive_area rather than a plain # trapezoid because irradiance cannot be negative, and a pyranometer reading # a little below zero on a clear night should contribute nothing, not debt. solar_energy_kwh_m2: (positive_area($solar) / 1000 | r2) } + $cold + { observations: ($o | length), coverage_hours: ((($o | last | .[0]) - ($o | first | .[0])) / 3600 | r1) }'