prop/lib/microwaveprop/ionosphere/giro_client.ex
Graham McIntire 361b9dec5a
Ionosphere: GIRO ionosonde ingestion (foF2/foEs/hmF2/MUFD)
First step toward physics-based HF / sporadic-E scoring. Polls GIRO's
DIDBase tabular endpoint every 10 minutes for two US ionosondes
(Millstone Hill MHJ45, Alpena AL945), parses the plain-text response
into observation rows, and upserts into a new ionosonde_observations
table keyed on (station_code, valid_time).

This gets foEs (E-layer sporadic critical frequency) into the database
as a direct measurement — the key input for predicting 144 MHz Es
openings via ITU-R P.534-6. foF2 + MUFD are the F2-layer inputs for
HF MUF predictions.

Not yet wired into scoring. Boulder/Wallops/Austin/Idaho/Point Arguello
are in the GIRO catalog but were silent when probed — add them back
if/when they come online.

Next steps: SWPC JSON (Kp, F10.7, sunspot), GOES X-ray flux, D-RAP text,
and the P.534-6 Es scoring factor that uses foEs at midpoint for the
144/440 band configs.
2026-04-15 14:37:43 -05:00

195 lines
5.9 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (0100, 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.
"""
require Logger
@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
url = build_url(ursi_code, from_dt, to_dt)
case Req.get(url, [receive_timeout: @default_timeout_ms] ++ req_options()) do
{:ok, %{status: 200, body: body}} when is_binary(body) ->
parse_tabular(body)
{:ok, %{status: status, body: body}} ->
{:error, "GIRO HTTP #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, "GIRO request failed: #{inspect(reason)}"}
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(nil), do: nil
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
end