prop/lib/microwaveprop/weather/ncei_metar_client.ex
Graham McIntire e7a7ae073d Phase 9.3, 9.4, and Phase 3 NEXRAD pipeline
Task 9.3 - Weight recalibration via gradient descent:
- Recalibrator module fits logistic regression weights using Nx
- Trains on QSO positives vs random baseline negatives
- Cross-validates by month, normalizes weights to sum to 1.0
- Mix task: mix recalibrate_scorer --sample 5000 --epochs 2000

Task 9.4 - Side-by-side scorer comparison:
- ScorerDiff.compare/3 re-scores grid with old vs new weights
- Reports mean diff, regressions, improvements, per-band breakdown
- Mix task: mix scorer_diff --new-weights '{...}'

Phase 3 - NEXRAD ingestion pipeline:
- NexradClient fetches IEM n0q composite PNGs, extracts per-point
  box statistics (mean/max dBZ, texture variance)
- NexradObservation schema with unique (lat, lon, observed_at)
- NexradWorker on :nexrad queue for background processing
- nexrad_texture backtest feature in Features module
- mix nexrad_backfill --limit 200

All tasks added to AdminTaskWorker and Release for production use.
1116 tests, 0 failures.
2026-04-10 12:48:36 -05:00

201 lines
6.1 KiB
Elixir

defmodule Microwaveprop.Weather.NceiMetarClient do
@moduledoc """
Fetches 5-minute ASOS observations from NCEI's C00418 dataset.
Data URL pattern:
```
https://www.ncei.noaa.gov/data/automated-surface-observing-system-five-minute/access/YYYY/MM/asos-5min-KXXX-YYYYMM.dat
```
Each file is one station-month of 5-minute observations in a
fixed-width METAR-like format. See the meteorologist's recommendation
in the April 2026 review.
"""
require Logger
@base_url "https://www.ncei.noaa.gov/data/automated-surface-observing-system-five-minute/access"
@doc """
Fetch and parse 5-minute observations for a given station, year, and month.
Returns `{:ok, [parsed_obs]}` or `{:error, reason}`.
"""
def fetch(icao, year, month) do
month_str = month |> Integer.to_string() |> String.pad_leading(2, "0")
url = "#{@base_url}/#{year}/#{month_str}/asos-5min-#{icao}-#{year}#{month_str}.dat"
case Req.get(url, receive_timeout: 60_000) do
{:ok, %{status: 200, body: body}} ->
{:ok, parse(body)}
{:ok, %{status: status}} ->
{:error, "NCEI 5-min HTTP #{status}"}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Parse the fixed-width NCEI 5-minute ASOS format into maps.
Each line has a METAR embedded in a fixed-width wrapper. We extract
the key fields without a full METAR parser — this covers the data
the scorer and backtest need (temp, dewpoint, wind, pressure, sky).
"""
def parse(text) when is_binary(text) do
text
|> String.split("\n")
|> Enum.flat_map(&parse_line/1)
end
defp parse_line(line) when byte_size(line) < 60, do: []
defp parse_line(line) do
# Fixed-width header: WBAN(5) ICAO(4) FAA(4) DATE(8) HHMM(4) ...
# Then METAR text after the "5-MIN" marker
with <<_wban::binary-5, icao::binary-4, _faa::binary-4, date::binary-8, hhmm::binary-4, _rest::binary>> <- line,
{:ok, observed_at} <- parse_datetime(date, hhmm) do
metar_text = extract_metar(line)
obs = parse_metar_fields(metar_text)
[Map.merge(obs, %{icao: String.trim(icao), observed_at: observed_at})]
else
_ -> []
end
end
defp parse_datetime(<<y::binary-4, m::binary-2, d::binary-2>>, <<hh::binary-2, mm::binary-2>>) do
with {year, ""} <- Integer.parse(y),
{month, ""} <- Integer.parse(m),
{day, ""} <- Integer.parse(d),
{hour, ""} <- Integer.parse(hh),
{minute, ""} <- Integer.parse(mm),
{:ok, date} <- Date.new(year, month, day),
{:ok, time} <- Time.new(hour, minute, 0),
{:ok, dt} <- DateTime.new(date, time, "Etc/UTC") do
{:ok, DateTime.truncate(dt, :second)}
else
_ -> :error
end
end
defp extract_metar(line) do
case String.split(line, "5-MIN ", parts: 2) do
[_, metar] -> metar
_ -> ""
end
end
defp parse_metar_fields(metar) do
parts = String.split(metar)
%{
temp_f: extract_temp_f(parts),
dewpoint_f: extract_dewpoint_f(parts),
wind_speed_kts: extract_wind_speed(parts),
wind_direction_deg: extract_wind_dir(parts),
altimeter_setting: extract_altimeter(parts),
sky_condition: extract_sky(parts),
relative_humidity: nil,
sea_level_pressure_mb: nil,
precip_1h_in: nil,
wx_codes: nil
}
end
# Extract precise temp/dew from RMK T-group: T02110094 → 21.1°C / 9.4°C
defp extract_temp_f(parts) do
case Enum.find(parts, &String.starts_with?(&1, "T0")) do
t_group when is_binary(t_group) ->
parse_t_group_temp(t_group)
_ ->
# Fall back to temp/dew field: "21/09"
case Enum.find(parts, &Regex.match?(~r/^M?\d+\/M?\d+$/, &1)) do
td when is_binary(td) ->
[t, _d] = String.split(td, "/")
t |> parse_metar_temp() |> c_to_f()
_ ->
nil
end
end
end
defp extract_dewpoint_f(parts) do
case Enum.find(parts, &String.starts_with?(&1, "T0")) do
t_group when is_binary(t_group) ->
parse_t_group_dew(t_group)
_ ->
case Enum.find(parts, &Regex.match?(~r/^M?\d+\/M?\d+$/, &1)) do
td when is_binary(td) ->
[_t, d] = String.split(td, "/")
d |> parse_metar_temp() |> c_to_f()
_ ->
nil
end
end
end
# T02110094 → temp = +21.1°C, dew = +9.4°C (leading 0=+, 1=-)
defp parse_t_group_temp(<<"T", sign::binary-1, d1::binary-2, d2::binary-1, _rest::binary>>) do
val = String.to_integer(d1) + String.to_integer(d2) / 10.0
if_result = if sign == "1", do: -val, else: val
c_to_f(if_result)
end
defp parse_t_group_temp(_), do: nil
defp parse_t_group_dew(<<"T", _::binary-4, sign::binary-1, d1::binary-2, d2::binary-1, _rest::binary>>) do
val = String.to_integer(d1) + String.to_integer(d2) / 10.0
if_result = if sign == "1", do: -val, else: val
c_to_f(if_result)
end
defp parse_t_group_dew(_), do: nil
defp parse_metar_temp("M" <> rest), do: -String.to_integer(rest) * 1.0
defp parse_metar_temp(s), do: String.to_integer(s) * 1.0
defp c_to_f(c) when is_number(c), do: c * 9.0 / 5.0 + 32.0
defp c_to_f(_), do: nil
defp extract_wind_speed(parts) do
with wind when is_binary(wind) <- Enum.find(parts, &Regex.match?(~r/^\d{3}\d{2,3}KT/, &1)),
{speed, _} <- Integer.parse(String.slice(wind, 3..4)) do
speed * 1.0
else
_ -> nil
end
end
defp extract_wind_dir(parts) do
with wind when is_binary(wind) <- Enum.find(parts, &Regex.match?(~r/^\d{3}\d{2,3}KT/, &1)),
{dir, _} <- Integer.parse(String.slice(wind, 0..2)) do
dir
else
_ -> nil
end
end
defp extract_altimeter(parts) do
case Enum.find(parts, &String.starts_with?(&1, "A2")) do
alt when is_binary(alt) ->
case Float.parse(String.slice(alt, 1..-1//1)) do
{val, _} -> val / 100.0
:error -> nil
end
_ ->
nil
end
end
defp extract_sky(parts) do
sky_tokens = Enum.filter(parts, &Regex.match?(~r/^(CLR|FEW|SCT|BKN|OVC|VV)\d*/, &1))
if sky_tokens == [], do: nil, else: Enum.join(sky_tokens, " ")
end
end