291 lines
14 KiB
Bash
Executable File
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)
|
|
}'
|