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 Microwaveprop.Instrument.span([:solar, :fetch_indices], %{}, fn -> 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) 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