- 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
243 lines
7.2 KiB
Elixir
243 lines
7.2 KiB
Elixir
defmodule Microwaveprop.SpaceWeather.SwpcClient do
|
||
@moduledoc """
|
||
Client for NOAA SWPC's public JSON services (https://services.swpc.noaa.gov/).
|
||
|
||
All three products we care about for HF / Es scoring are free, no-auth
|
||
JSON endpoints:
|
||
|
||
- `/json/planetary_k_index_1m.json` — planetary K 1-min cadence
|
||
- `/json/f107_cm_flux.json` — 10.7 cm solar flux (hourly)
|
||
- `/json/goes/primary/xrays-1-day.json` — GOES X-ray flux 1-min, 2 bands
|
||
|
||
Each product has its own wire format so each has its own `fetch_*` +
|
||
`parse_*` pair. Parsed rows are shaped for direct insert into the
|
||
corresponding schemas via `Microwaveprop.SpaceWeather` context.
|
||
"""
|
||
|
||
@base_url "https://services.swpc.noaa.gov"
|
||
@default_timeout_ms 15_000
|
||
|
||
# ---------- Kp ----------
|
||
|
||
@doc """
|
||
Fetch and parse the planetary K 1-min feed. Returns `{:ok, [row]}` where
|
||
each row is `%{valid_time, kp_index, estimated_kp}`.
|
||
"""
|
||
@spec fetch_kp() :: {:ok, [map()]} | {:error, term()}
|
||
def fetch_kp, do: fetch_and_parse("/json/planetary_k_index_1m.json", &parse_kp/1)
|
||
|
||
@doc false
|
||
@spec parse_kp(String.t()) :: {:ok, [map()]} | {:error, term()}
|
||
def parse_kp(body) do
|
||
with {:ok, decoded} <- decode_array(body) do
|
||
rows =
|
||
decoded
|
||
|> Enum.map(&parse_kp_row/1)
|
||
|> Enum.reject(&is_nil/1)
|
||
|
||
{:ok, rows}
|
||
end
|
||
end
|
||
|
||
defp parse_kp_row(%{"time_tag" => t, "kp_index" => kp_index} = row) do
|
||
case parse_naive_iso(t) do
|
||
{:ok, valid_time} ->
|
||
%{
|
||
valid_time: valid_time,
|
||
kp_index: to_integer(kp_index),
|
||
estimated_kp: to_float(Map.get(row, "estimated_kp"))
|
||
}
|
||
|
||
:error ->
|
||
nil
|
||
end
|
||
end
|
||
|
||
defp parse_kp_row(_), do: nil
|
||
|
||
# ---------- F10.7 ----------
|
||
|
||
@doc """
|
||
Fetch and parse the F10.7 cm flux feed. Returns `{:ok, [row]}` where
|
||
each row is `%{valid_time, frequency_mhz, flux_sfu, reporting_schedule,
|
||
ninety_day_mean}`.
|
||
"""
|
||
@spec fetch_f107() :: {:ok, [map()]} | {:error, term()}
|
||
def fetch_f107, do: fetch_and_parse("/json/f107_cm_flux.json", &parse_f107/1)
|
||
|
||
@doc false
|
||
@spec parse_f107(String.t()) :: {:ok, [map()]} | {:error, term()}
|
||
def parse_f107(body) do
|
||
with {:ok, decoded} <- decode_array(body) do
|
||
rows =
|
||
decoded
|
||
|> Enum.map(&parse_f107_row/1)
|
||
|> Enum.reject(&is_nil/1)
|
||
|
||
{:ok, rows}
|
||
end
|
||
end
|
||
|
||
defp parse_f107_row(%{"time_tag" => t, "flux" => flux} = row) do
|
||
case parse_naive_iso(t) do
|
||
{:ok, valid_time} ->
|
||
%{
|
||
valid_time: valid_time,
|
||
frequency_mhz: to_integer(Map.get(row, "frequency")),
|
||
flux_sfu: to_float(flux),
|
||
reporting_schedule: Map.get(row, "reporting_schedule"),
|
||
ninety_day_mean: to_float(Map.get(row, "ninety_day_mean"))
|
||
}
|
||
|
||
:error ->
|
||
nil
|
||
end
|
||
end
|
||
|
||
defp parse_f107_row(_), do: nil
|
||
|
||
# ---------- GOES X-ray ----------
|
||
|
||
@long_band "0.1-0.8nm"
|
||
|
||
@doc """
|
||
Fetch and parse the GOES primary 1-day X-ray feed. Only keeps the
|
||
0.1–0.8nm long-wavelength band (the standard SWF / D-layer absorption
|
||
proxy). Returns `{:ok, [row]}` where each row is `%{valid_time,
|
||
satellite, flux_wm2, energy_band, electron_contaminated}`.
|
||
"""
|
||
@spec fetch_xrays() :: {:ok, [map()]} | {:error, term()}
|
||
def fetch_xrays, do: fetch_and_parse("/json/goes/primary/xrays-1-day.json", &parse_xrays/1)
|
||
|
||
@doc false
|
||
@spec parse_xrays(String.t()) :: {:ok, [map()]} | {:error, term()}
|
||
def parse_xrays(body) do
|
||
with {:ok, decoded} <- decode_array(body) do
|
||
rows =
|
||
decoded
|
||
|> Enum.filter(&long_band?/1)
|
||
|> Enum.map(&parse_xray_row/1)
|
||
|> Enum.reject(&is_nil/1)
|
||
|
||
{:ok, rows}
|
||
end
|
||
end
|
||
|
||
defp long_band?(%{"energy" => @long_band}), do: true
|
||
defp long_band?(_), do: false
|
||
|
||
defp parse_xray_row(%{"time_tag" => t, "flux" => flux} = row) do
|
||
case parse_iso_z(t) do
|
||
{:ok, valid_time} ->
|
||
%{
|
||
valid_time: valid_time,
|
||
satellite: to_integer(Map.get(row, "satellite")),
|
||
flux_wm2: to_float(flux),
|
||
energy_band: Map.get(row, "energy"),
|
||
# SWPC's field name is misspelled "electron_contaminaton" upstream.
|
||
electron_contaminated: !!Map.get(row, "electron_contaminaton")
|
||
}
|
||
|
||
:error ->
|
||
nil
|
||
end
|
||
end
|
||
|
||
defp parse_xray_row(_), do: nil
|
||
|
||
# ---------- Shared HTTP / parsing helpers ----------
|
||
|
||
defp fetch_and_parse(path, parser) do
|
||
Microwaveprop.Instrument.span([:swpc, :fetch], %{path: path}, fn ->
|
||
url = @base_url <> path
|
||
|
||
case Req.get(url, [receive_timeout: @default_timeout_ms] ++ req_options()) do
|
||
{:ok, %{status: 200, body: body}} ->
|
||
parser.(body)
|
||
|
||
{:ok, %{status: status, body: body}} ->
|
||
{:error, "SWPC HTTP #{status}: #{inspect(body, printable_limit: 200)}"}
|
||
|
||
{:error, reason} ->
|
||
{:error, "SWPC request failed: #{format_req_error(reason)}"}
|
||
end
|
||
end)
|
||
end
|
||
|
||
defp decode_array(body) when is_binary(body) do
|
||
case Jason.decode(body) do
|
||
{:ok, list} when is_list(list) -> {:ok, list}
|
||
{:ok, _other} -> {:error, :not_a_list}
|
||
{:error, reason} -> {:error, reason}
|
||
end
|
||
end
|
||
|
||
# Req's JSON auto-decode may already have parsed the body if the
|
||
# upstream set Content-Type: application/json. In that case body is a
|
||
# list, not a binary.
|
||
defp decode_array(list) when is_list(list), do: {:ok, list}
|
||
defp decode_array(_), do: {:error, :not_a_list}
|
||
|
||
# SWPC's Kp/F10.7 time_tag is naive ISO ("2026-04-15T13:49:00") —
|
||
# documented UTC but without a Z suffix. Add the Z so DateTime.from_iso8601
|
||
# parses it as UTC.
|
||
defp parse_naive_iso(s) when is_binary(s) do
|
||
case DateTime.from_iso8601(s <> "Z") do
|
||
{:ok, dt, _} -> {:ok, DateTime.truncate(dt, :second)}
|
||
_ -> :error
|
||
end
|
||
end
|
||
|
||
defp parse_naive_iso(_), do: :error
|
||
|
||
defp parse_iso_z(s) when is_binary(s) do
|
||
case DateTime.from_iso8601(s) do
|
||
{:ok, dt, _} -> {:ok, DateTime.truncate(dt, :second)}
|
||
_ -> :error
|
||
end
|
||
end
|
||
|
||
defp parse_iso_z(_), do: :error
|
||
|
||
defp to_integer(nil), do: nil
|
||
defp to_integer(n) when is_integer(n), do: n
|
||
defp to_integer(n) when is_float(n), do: trunc(n)
|
||
|
||
defp to_integer(s) when is_binary(s) do
|
||
case Integer.parse(s) do
|
||
{n, ""} -> n
|
||
_ -> nil
|
||
end
|
||
end
|
||
|
||
defp to_float(nil), do: nil
|
||
defp to_float(n) when is_float(n), do: n
|
||
defp to_float(n) when is_integer(n), do: n * 1.0
|
||
|
||
defp to_float(s) when is_binary(s) do
|
||
case Float.parse(s) do
|
||
{f, ""} -> f
|
||
_ -> nil
|
||
end
|
||
end
|
||
|
||
defp req_options do
|
||
Application.get_env(:microwaveprop, :swpc_req_options, [])
|
||
end
|
||
|
||
# SWPC's CDN occasionally truncates the response mid-stream. Req's
|
||
# auto-JSON-decoder then returns `{:error, %Jason.DecodeError{data: body}}`
|
||
# where `:data` is the full raw body (up to ~650 KB for xrays-1-day).
|
||
# Inspecting that struct at warn level spams the log aggregator. Keep
|
||
# enough context to debug (position + a short preview) and drop the body.
|
||
defp format_req_error(%Jason.DecodeError{position: pos, data: data}) do
|
||
preview =
|
||
data
|
||
|> to_string()
|
||
|> binary_part(0, min(80, byte_size(to_string(data))))
|
||
|
||
"Jason.DecodeError at byte #{pos} (truncated upstream response; preview=#{inspect(preview)})"
|
||
end
|
||
|
||
defp format_req_error(reason), do: inspect(reason, printable_limit: 200)
|
||
end
|