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