prop/lib/microwaveprop/ionosphere/giro_client.ex
Graham McIntire d61fbd346e
fix(dialyzer): clear 125+ warnings under strict flags
Enabled :error_handling, :unknown, :unmatched_returns, :extra_return,
:missing_return in an earlier commit and landed a 129-warning baseline.
Four parallel agents each fixed a directory slice:

- Core contexts (29): Radio, Release, Weather, Beacons, Cache,
  Backtest.Features, Terrain.Srtm, Ionosphere.GiroClient,
  Propagation.RunTiming, Accounts.Scope, RepoListener. Fixes were
  (a) prefix side-effect calls (Task.start, Phoenix.PubSub,
  Logger, :ets.new) with _ = ; (b) tighten/widen specs that didn't
  match actual returns; (c) add missing @type t declarations;
  (d) drop dead parse_int(nil) clause.

- Propagation + weather subdirs (15): FreshnessMonitor, NotifyListener,
  ScoreCache, ScoreCacheReconciler, Weather.FrontalAnalysis,
  Weather.Grib2.Extractor, Weather.Grib2.Wgrib2, GridCache,
  HrrrPointEnqueuer, NexradCache. Same patterns — mostly _ = on
  PubSub / :ets / Repo.insert_all; widened two specs (float ->
  number) where integer returns were reachable.

- Workers (35): BackfillEnqueue, CanadianSoundingFetch,
  ContactImport, ContactWeatherEnqueue, GefsFetch, IemreFetch,
  NarrFetch, SolarIndex, TerrainProfile, WeatherFetch. Prefixed
  Repo.update_all / Radio.set_enrichment_status! / Weather.upsert_*
  side-effect calls. Fixed one :pattern_match in
  CanadianSoundingFetch.most_recent_sounding_time/1 where a
  tautological cond guard generated unreachable code.

- Web + Mix tasks + lib_ml (46 of 50): controllers, LiveViews,
  UserAuth, and 11 mix tasks. Same prefix strategy. 4 remaining
  warnings originate in LiveTable.LiveResource dep macro expansion
  and can't be fixed without forking the dep — added .dialyzer_ignore.exs
  to suppress just those specific file:line pairs.

Also wired ignore_warnings in mix.exs dialyzer config.

mix dialyzer --format short | grep ^lib/ | wc -l -> 0
mix test: 2163 tests, 3 pre-existing flakes, 0 regressions.
2026-04-21 10:30:06 -05:00

195 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(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