Files

291 lines
14 KiB
Bash
Executable File

#!/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)
}'