prop/lib/microwaveprop/weather/ncei_metar_client.ex
Graham McIntire 828814e767
fix: resolve all compiler warnings except framework-generated phoenix_component_verify
- Replace deprecated xref:exclude with elixirc_options in vendor/oban_pro
- Remove unused require Logger from 8 files
- Pin bitstring size variables with ^ in simple_packing, wgrib2,
  complex_packing, section, nexrad_client, mqtt, and radar worker
- Remove dead defp clauses (format/1 nil, depression/2 nil)
- Rename Buildings.Parser type record/0 -> building_record/0 to avoid
  overriding built-in type
- Remove redundant catch-all in candidate_detail.ex
- Simplify contact_live/show.ex conditional based on type narrowing
- Fix DateTime.from_iso8601 return pattern in vendor/oban_web
- Upgrade phoenix_live_view to 1.1.31
- Drop --warnings-as-errors from precommit alias; known false positives
  from @after_verify-generated code in Elixir 1.20.0-rc.6 + PLV 1.1.x
- Add credo --strict to precommit as replacement static-analysis gate
2026-05-29 10:18:52 -05:00

202 lines
6.2 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.
"""
@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}`.
"""
@spec fetch(String.t(), pos_integer(), pos_integer()) :: {:ok, [map()]} | {:error, term()}
def fetch(icao, year, month) do
Microwaveprop.Instrument.span([:ncei, :fetch_metar], %{station: icao}, fn ->
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)
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).
"""
@spec parse(String.t()) :: [map()]
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 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