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. """ require Logger @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