prop/lib/microwaveprop/ionosphere/giro_client.ex
Graham McIntire 4e6c87eca2
feat(telemetry): broaden Instrument span coverage
Adds spans to 15 previously-unmeasured hot paths so every question we
might ask while tuning has a histogram to answer it:

External I/O:
- iem.fetch_iemre (gridded weather reanalysis)
- mrms.list_latest / mrms.download (precip radar)
- rtma.fetch_observation
- ncei.fetch_metar (historical 5-min METAR backfill)
- solar.fetch_indices (GFZ solar indices)
- swpc.fetch (SWPC Kp/F10.7/X-ray)
- giro.fetch (ionosonde)
- qrz.request, geocoder.geocode (callsign enrichment)
- srtm.download_tile (terrain tile download + gunzip)
- hrrr.download_grib_ranges (parallel byte-range fetch phase)

Subprocess:
- wgrib2.extract_grid / extract_grid_from_file / extract_grid_from_file_mapped

LiveView hot paths:
- propagation.scores_at (map score fetch + cache hit/miss counter)
- propagation.point_forecast (sparkline)
- propagation.point_detail (click-to-inspect)
- propagation.daily_outlook_at (/map outlook strip)

Worker-level end-to-end:
- worker.terrain_profile
- worker.mechanism_classify
- worker.mrms_fetch

Each event is registered in Microwaveprop.PromEx.InstrumentPlugin as
a Prometheus histogram (default / long buckets as appropriate) plus
a counter for the scores_at cache hit/miss ratio. Prometheus at
10.0.15.25 will start seeing the new series on the next scrape after
deploy.
2026-04-18 17:25:33 -05:00

197 lines
6 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
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] ++ 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)
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