prop/lib/microwaveprop/ionosphere/giro_client.ex
Graham McIntire 828814e767
fix: resolve all compiler warnings except framework-generated phoenix_component_verify
- 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
2026-05-29 10:18:52 -05:00

216 lines
6.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.
"""
@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: 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(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