- 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
216 lines
6.9 KiB
Elixir
216 lines
6.9 KiB
Elixir
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: 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
|