prop/lib/microwaveprop/weather/solar_client.ex
Graham McIntire 51e390959c Add dialyzer specs and types across the codebase
277 @spec/@type annotations added to 58 files covering all public
APIs: contexts (propagation, radio, weather, terrain, beacons,
commercial), GRIB2 decoders, terrain analysis, duct detection,
rain scatter, CSV/ADIF import, weather clients, and all Ecto
schemas. Dialyzer passes with 0 errors.
2026-04-12 08:55:04 -05:00

99 lines
2.6 KiB
Elixir

defmodule Microwaveprop.Weather.SolarClient do
@moduledoc false
@gfz_url "https://kp.gfz.de/app/files/Kp_ap_Ap_SN_F107_since_1932.txt"
@spec data_url() :: String.t()
def data_url, do: @gfz_url
@spec fetch_solar_indices() :: {:ok, [map()]} | {:error, term()}
def fetch_solar_indices do
req_options = Application.get_env(:microwaveprop, :solar_req_options, [])
case Req.get(@gfz_url, [receive_timeout: 120_000, retry: false] ++ req_options) do
{:ok, %{status: 200, body: body}} ->
{:ok, parse_gfz_file(body)}
{:ok, %{status: status}} ->
{:error, "HTTP #{status}"}
{:error, reason} ->
{:error, reason}
end
end
@spec filter_since([map()], Date.t()) :: [map()]
def filter_since(records, since_date) do
Enum.filter(records, fn r -> Date.compare(r.date, since_date) != :lt end)
end
@spec parse_gfz_file(String.t()) :: [map()]
def parse_gfz_file(text) do
text
|> String.split("\n")
|> Enum.reject(fn line ->
trimmed = String.trim(line)
trimmed == "" or String.starts_with?(trimmed, "#")
end)
|> Enum.flat_map(fn line ->
case parse_gfz_row(line) do
{:ok, record} -> [record]
:error -> []
end
end)
end
@spec parse_gfz_row(String.t()) :: {:ok, map()} | :error
def parse_gfz_row(line) do
fields = String.split(line)
if length(fields) >= 28 do
[yyyy, mm, dd | rest] = fields
# Skip days, days_m, Bsr, dB (indices 0-3 of rest)
# Kp1-Kp8 at rest indices 4-11
# ap1-ap8 at rest indices 12-19
# Ap at rest index 20
# SN at rest index 21
# F10.7obs at rest index 22
# F10.7adj at rest index 23
kp_raw = Enum.slice(rest, 4, 8)
ap_daily = Enum.at(rest, 20)
sn = Enum.at(rest, 21)
f107_obs = Enum.at(rest, 22)
f107_adj = Enum.at(rest, 23)
{:ok,
%{
date: Date.new!(String.to_integer(yyyy), String.to_integer(mm), String.to_integer(dd)),
sfi: parse_float_or_nil(f107_obs),
sfi_adjusted: parse_float_or_nil(f107_adj),
sunspot_number: parse_int_or_nil(sn),
ap_index: parse_int_or_nil(ap_daily),
kp_values: Enum.map(kp_raw, &parse_float_or_nil/1)
}}
else
:error
end
end
defp parse_float_or_nil(str) do
val = String.to_float(str)
if val < 0, do: nil, else: val
rescue
ArgumentError ->
case Integer.parse(str) do
{n, _} when n < 0 -> nil
{n, _} -> n / 1
:error -> nil
end
end
defp parse_int_or_nil(str) do
case Integer.parse(str) do
{n, _} when n < 0 -> nil
{n, _} -> n
:error -> nil
end
end
end