defmodule Microwaveprop.Ionosphere.GiroClient do @moduledoc """ Client for GIRO's `DIDBGetValues` tabular characteristic endpoint. GIRO (Global Ionospheric Radio Observatory, operated by LGDC at UMass Lowell) publishes real-time ionosonde measurements for stations worldwide. We poll the scaled characteristics for a handful of US stations every ~10 minutes so the propagation scorer has a live view of the ionosphere for 144 MHz sporadic-E and HF MUF predictions. The endpoint returns a plain-text tabular response, not JSON: # Global Ionospheric Radio Observatory # Location: GEO 42.6N 288.5E, URSI-Code MHJ45 MILLSTONE HILL # ...header comment block... #Time CS foF2 QD MUFD QD foEs QD foE QD hmF2 QD 2026-04-15T18:00:00.000Z 80 7.113 // 23.00 // 3.08 // 3.10 // 242.0 // Columns: - `Time` — ISO-8601 UTC - `CS` — autoscaling confidence score (0–100, 999 = manual, -1 = unknown) - `foF2`, `foE`, `foEs` — critical frequencies in MHz - `hmF2` — F2 peak height in km - `MUFD` — maximum usable frequency for the distance D requested Each characteristic is followed by a QD (quality descriptor) flag — we drop the QD column when indexing. Missing values are encoded as `---` with a `__` QD flag and become `nil` in the parsed map. """ @base_url "https://lgdc.uml.edu/common/DIDBGetValues" @default_chars ~w(foF2 foE foEs hmF2 MUFD) @default_dmuf 3000 @default_timeout_ms 15_000 @type observation :: %{ valid_time: DateTime.t(), confidence_score: integer() | nil, fo_f2_mhz: float() | nil, fo_e_mhz: float() | nil, fo_es_mhz: float() | nil, hm_f2_km: float() | nil, mufd_mhz: float() | nil } @doc """ Fetches scaled characteristics for a station between two UTC DateTimes. Returns `{:ok, [observation]}` or `{:error, reason}`. """ @spec fetch(String.t(), DateTime.t(), DateTime.t()) :: {:ok, [observation()]} | {:error, term()} def fetch(ursi_code, %DateTime{} = from_dt, %DateTime{} = to_dt) do Microwaveprop.Instrument.span([:giro, :fetch], %{station: ursi_code}, fn -> url = build_url(ursi_code, from_dt, to_dt) case Req.get(url, [receive_timeout: @default_timeout_ms] ++ merged_req_options()) do {:ok, %{status: 200, body: body}} when is_binary(body) -> parse_tabular(body) {:ok, %{status: 404}} -> {:error, :giro_api_unavailable} {:ok, %{status: status}} -> {:error, "GIRO HTTP #{status}"} {:error, reason} -> {:error, "GIRO request failed: #{inspect(reason)}"} end end) end @doc """ Parses a GIRO tabular response body into a list of observation maps. """ @spec parse_tabular(String.t()) :: {:ok, [observation()]} | {:error, atom()} def parse_tabular(body) when is_binary(body) do lines = String.split(body, ~r/\r?\n/, trim: true) case find_header(lines) do {:ok, columns} -> rows = lines |> Enum.reject(&header_or_comment?/1) |> Enum.flat_map(&parse_data_line(&1, columns)) {:ok, rows} :error -> {:error, :no_header} end end defp build_url(ursi_code, from_dt, to_dt) do params = [ {"ursiCode", ursi_code}, {"charName", Enum.join(@default_chars, ",")}, {"DMUF", Integer.to_string(@default_dmuf)}, {"fromDate", format_giro_date(from_dt)}, {"toDate", format_giro_date(to_dt)} ] query = Enum.map_join(params, "&", fn {k, v} -> "#{k}=#{URI.encode(v)}" end) "#{@base_url}?#{query}" end # GIRO wants `YYYY.MM.DD+HH:MM:SS` with a literal `+`. Using `/` or # url-escaping the space silently returns "no data". defp format_giro_date(%DateTime{} = dt) do dt |> DateTime.truncate(:second) |> Calendar.strftime("%Y.%m.%d+%H:%M:%S") end # Column header line starts with `#Time`. Returns the list of value # column names, stripping the `Time`/`CS` prefix columns and the `QD` # filler tokens (which are paired with each value). defp find_header(lines) do case Enum.find(lines, &String.starts_with?(&1, "#Time")) do nil -> :error line -> columns = line # Drop the leading "#" |> String.trim_leading("#") |> String.split(~r/\s+/, trim: true) |> Enum.reject(&(&1 in ["Time", "CS", "QD"])) {:ok, columns} end end defp header_or_comment?(line), do: String.starts_with?(line, "#") defp parse_data_line(line, columns) do tokens = String.split(line, ~r/\s+/, trim: true) # Expected: time + CS + 2 tokens per column (value + QD) expected = 2 + length(columns) * 2 if length(tokens) == expected do [time_str, cs_str | rest] = tokens case DateTime.from_iso8601(time_str) do {:ok, valid_time, _} -> values = take_values(rest) row = columns |> Enum.zip(values) |> Map.new() |> then(&build_observation(valid_time, cs_str, &1)) [row] _ -> [] end else [] end end # Drop the QD flags by keeping every other element. defp take_values([]), do: [] defp take_values([value, _qd | rest]), do: [value | take_values(rest)] defp take_values([value]), do: [value] defp build_observation(valid_time, cs_str, values_by_col) do %{ valid_time: DateTime.truncate(valid_time, :second), confidence_score: parse_int(cs_str), fo_f2_mhz: parse_float(values_by_col["foF2"]), fo_e_mhz: parse_float(values_by_col["foE"]), fo_es_mhz: parse_float(values_by_col["foEs"]), hm_f2_km: parse_float(values_by_col["hmF2"]), mufd_mhz: parse_float(values_by_col["MUFD"]) } end defp parse_int(s) do case Integer.parse(s) do {n, ""} -> n _ -> nil end end defp parse_float(nil), do: nil defp parse_float("---"), do: nil defp parse_float(s) do case Float.parse(s) do {f, ""} -> f _ -> nil end end defp req_options do Application.get_env(:microwaveprop, :giro_req_options, []) end # lgdc.uml.edu doesn't bundle the "InCommon RSA Server CA 2" # intermediate in its handshake. Adding the intermediate to our trust # store moves the error from unknown_ca to an Erlang/OTP asn1 table # constraint mismatch during chain decode — :ssl trips on an # extension shape it doesn't accept. Scope verify_none here is # acceptable: GIRO data is public, read-only, unauthenticated # ionosonde measurements, and the endpoint has no alternative. When # the test config overrides :giro_req_options with a Req.Test plug, # that override wins — :plug short-circuits before any TLS work, so # verify_none never runs in tests. defp merged_req_options do case req_options() do [] -> default_req_options() overrides -> overrides end end @doc false @spec default_req_options() :: keyword() def default_req_options do [connect_options: [transport_opts: [verify: :verify_none]]] end end