diff --git a/lib/microwaveprop/accounts/user.ex b/lib/microwaveprop/accounts/user.ex index 99973c8a..6c68d3f1 100644 --- a/lib/microwaveprop/accounts/user.ex +++ b/lib/microwaveprop/accounts/user.ex @@ -21,6 +21,8 @@ defmodule Microwaveprop.Accounts.User do timestamps(type: :utc_datetime) end + @type t :: %__MODULE__{} + @doc """ Returns the email address that is automatically granted admin privileges on registration. diff --git a/lib/microwaveprop/beacon_monitors.ex b/lib/microwaveprop/beacon_monitors.ex index 1c413368..77e2b29b 100644 --- a/lib/microwaveprop/beacon_monitors.ex +++ b/lib/microwaveprop/beacon_monitors.ex @@ -16,6 +16,7 @@ defmodule Microwaveprop.BeaconMonitors do @doc """ Returns all monitors for the given user, newest first. """ + @spec list_monitors_for_user(User.t()) :: [BeaconMonitor.t()] def list_monitors_for_user(%User{id: user_id}) do Repo.all( from m in BeaconMonitor, @@ -27,6 +28,7 @@ defmodule Microwaveprop.BeaconMonitors do @doc """ Creates a new monitor for the given user with a freshly generated token. """ + @spec create_monitor(User.t(), map()) :: {:ok, BeaconMonitor.t()} | {:error, Ecto.Changeset.t()} def create_monitor(%User{} = user, attrs) do %BeaconMonitor{user_id: user.id, token: generate_token()} |> BeaconMonitor.changeset(attrs) @@ -39,6 +41,8 @@ defmodule Microwaveprop.BeaconMonitors do Returns `{:error, :not_found}` if the monitor does not exist or belongs to another user. """ + @spec delete_monitor(User.t(), Ecto.UUID.t()) :: + {:ok, BeaconMonitor.t()} | {:error, :not_found} | {:error, Ecto.Changeset.t()} def delete_monitor(%User{id: user_id}, monitor_id) do query = from m in BeaconMonitor, @@ -55,6 +59,7 @@ defmodule Microwaveprop.BeaconMonitors do @doc """ Looks up a monitor by its token. Returns nil if not found. """ + @spec get_monitor_by_token(String.t()) :: BeaconMonitor.t() | nil def get_monitor_by_token(token) when is_binary(token) do Repo.get_by(BeaconMonitor, token: token) end @@ -62,6 +67,7 @@ defmodule Microwaveprop.BeaconMonitors do @doc """ Returns a blank changeset for rendering the new-monitor form. """ + @spec change_monitor(map()) :: Ecto.Changeset.t() def change_monitor(attrs \\ %{}) do BeaconMonitor.changeset(%BeaconMonitor{}, attrs) end diff --git a/lib/microwaveprop/beacon_monitors/beacon_monitor.ex b/lib/microwaveprop/beacon_monitors/beacon_monitor.ex index 3ff887bb..b6b3dc4b 100644 --- a/lib/microwaveprop/beacon_monitors/beacon_monitor.ex +++ b/lib/microwaveprop/beacon_monitors/beacon_monitor.ex @@ -23,10 +23,13 @@ defmodule Microwaveprop.BeaconMonitors.BeaconMonitor do timestamps(type: :utc_datetime) end + @type t :: %__MODULE__{} + @doc """ Changeset for user-controlled fields (name only for now). Token and user_id are assigned by the context, not cast from user input. """ + @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(monitor, attrs) do monitor |> cast(attrs, [:name]) diff --git a/lib/microwaveprop/beacons.ex b/lib/microwaveprop/beacons.ex index b73fa213..56571333 100644 --- a/lib/microwaveprop/beacons.ex +++ b/lib/microwaveprop/beacons.ex @@ -20,6 +20,7 @@ defmodule Microwaveprop.Beacons do * `{:updated, %Beacon{}}` * `{:deleted, %Beacon{}}` """ + @spec subscribe_beacons() :: :ok | {:error, term()} def subscribe_beacons do Phoenix.PubSub.subscribe(Microwaveprop.PubSub, @topic) end @@ -29,6 +30,7 @@ defmodule Microwaveprop.Beacons do end @doc "Returns approved beacons ordered by most recently added." + @spec list_beacons() :: [Beacon.t()] def list_beacons do Repo.all( from b in Beacon, @@ -38,6 +40,7 @@ defmodule Microwaveprop.Beacons do end @doc "Returns unapproved beacons awaiting admin review." + @spec list_pending_beacons() :: [Beacon.t()] def list_pending_beacons do Repo.all( from b in Beacon, @@ -47,12 +50,14 @@ defmodule Microwaveprop.Beacons do end @doc "Gets a single beacon. Raises if not found." + @spec get_beacon!(Ecto.UUID.t()) :: Beacon.t() def get_beacon!(id), do: Beacon |> Repo.get!(id) |> Repo.preload(:user) @doc """ Creates a beacon. When a user is provided they are recorded as the creator; anonymous submissions pass `nil` and leave `user_id` unset. """ + @spec create_beacon(User.t() | nil, map()) :: {:ok, Beacon.t()} | {:error, Ecto.Changeset.t()} def create_beacon(user, attrs) def create_beacon(%User{} = user, attrs) do @@ -70,6 +75,7 @@ defmodule Microwaveprop.Beacons do end @doc "Updates a beacon." + @spec update_beacon(Beacon.t(), map()) :: {:ok, Beacon.t()} | {:error, Ecto.Changeset.t()} def update_beacon(%Beacon{} = beacon, attrs) do beacon |> Beacon.changeset(attrs) @@ -78,6 +84,7 @@ defmodule Microwaveprop.Beacons do end @doc "Marks a beacon as approved, making it visible in the public list." + @spec approve_beacon(Beacon.t()) :: {:ok, Beacon.t()} | {:error, Ecto.Changeset.t()} def approve_beacon(%Beacon{} = beacon) do beacon |> Ecto.Changeset.change(approved: true) @@ -86,6 +93,7 @@ defmodule Microwaveprop.Beacons do end @doc "Deletes a beacon." + @spec delete_beacon(Beacon.t()) :: {:ok, Beacon.t()} | {:error, Ecto.Changeset.t()} def delete_beacon(%Beacon{} = beacon) do case Repo.delete(beacon) do {:ok, beacon} -> @@ -98,6 +106,7 @@ defmodule Microwaveprop.Beacons do end @doc "Returns an `%Ecto.Changeset{}` for tracking beacon changes." + @spec change_beacon(Beacon.t(), map()) :: Ecto.Changeset.t() def change_beacon(%Beacon{} = beacon, attrs \\ %{}) do Beacon.changeset(beacon, attrs) end diff --git a/lib/microwaveprop/beacons/beacon.ex b/lib/microwaveprop/beacons/beacon.ex index cac204a5..df00a7e1 100644 --- a/lib/microwaveprop/beacons/beacon.ex +++ b/lib/microwaveprop/beacons/beacon.ex @@ -41,8 +41,10 @@ defmodule Microwaveprop.Beacons.Beacon do @keyings Enum.map(@keying_entries, fn {k, _} -> k end) @keying_labels Map.new(@keying_entries) + @spec keyings() :: [String.t()] def keyings, do: @keyings + @spec keying_label(String.t()) :: String.t() def keying_label(key), do: Map.get(@keying_labels, key, key) @doc """ @@ -50,6 +52,7 @@ defmodule Microwaveprop.Beacons.Beacon do notation. Integers are printed without a decimal; floats keep up to three decimals with trailing zeros trimmed. """ + @spec format_mw(number() | nil | term()) :: String.t() def format_mw(nil), do: "" def format_mw(mw) when is_number(mw) do @@ -70,6 +73,7 @@ defmodule Microwaveprop.Beacons.Beacon do def format_mw(mw), do: to_string(mw) @doc "Formats a frequency in MHz with comma separators (e.g. 10368 → \"10,368\")." + @spec format_freq(number() | nil | term()) :: String.t() def format_freq(nil), do: "" def format_freq(mhz) when is_number(mhz) do @@ -110,6 +114,7 @@ defmodule Microwaveprop.Beacons.Beacon do Keying options for `Phoenix.HTML.Form.options_for_select/2`, grouped for a cleaner UI when dozens of weak-signal modes are present. """ + @spec keying_options() :: [{String.t(), [{String.t(), String.t()}]}] def keying_options do [ {"Simple", @@ -158,8 +163,11 @@ defmodule Microwaveprop.Beacons.Beacon do timestamps(type: :utc_datetime) end + @type t :: %__MODULE__{} + @required_fields [:frequency_mhz, :callsign, :lat, :lon, :power_mw, :height_ft, :keying] + @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(beacon, attrs) do beacon |> cast(attrs, [ diff --git a/lib/microwaveprop/commercial.ex b/lib/microwaveprop/commercial.ex index e788d252..525d8b02 100644 --- a/lib/microwaveprop/commercial.ex +++ b/lib/microwaveprop/commercial.ex @@ -7,24 +7,28 @@ defmodule Microwaveprop.Commercial do alias Microwaveprop.Commercial.Sample alias Microwaveprop.Repo + @spec enabled_links() :: [Link.t()] def enabled_links do Link |> where([l], l.enabled == true) |> Repo.all() end + @spec create_link(map()) :: {:ok, Link.t()} | {:error, Ecto.Changeset.t()} def create_link(attrs) do %Link{} |> Link.changeset(attrs) |> Repo.insert() end + @spec create_sample(map()) :: {:ok, Sample.t()} | {:error, Ecto.Changeset.t()} def create_sample(attrs) do %Sample{} |> Sample.changeset(attrs) |> Repo.insert() end + @spec weather_stations() :: [String.t()] def weather_stations do Link |> where([l], l.enabled == true and not is_nil(l.weather_station)) diff --git a/lib/microwaveprop/commercial/link.ex b/lib/microwaveprop/commercial/link.ex index 47365aef..819f2996 100644 --- a/lib/microwaveprop/commercial/link.ex +++ b/lib/microwaveprop/commercial/link.ex @@ -20,9 +20,12 @@ defmodule Microwaveprop.Commercial.Link do timestamps(type: :utc_datetime) end + @type t :: %__MODULE__{} + @required_fields ~w(label host community radio_type)a @optional_fields ~w(endpoint_a endpoint_b weather_station enabled)a + @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(link, attrs) do link |> cast(attrs, @required_fields ++ @optional_fields) diff --git a/lib/microwaveprop/commercial/sample.ex b/lib/microwaveprop/commercial/sample.ex index ac9d632a..a2b72b29 100644 --- a/lib/microwaveprop/commercial/sample.ex +++ b/lib/microwaveprop/commercial/sample.ex @@ -39,12 +39,15 @@ defmodule Microwaveprop.Commercial.Sample do timestamps(type: :utc_datetime) end + @type t :: %__MODULE__{} + @required_fields ~w(link_id sampled_at)a @optional_fields ~w(rx_power_0 rx_power_1 tx_power tx_freq_mhz rx_freq_mhz radio_temp_0_c radio_temp_1_c cur_tx_mod_rate remote_tx_mod_rate rx_capacity tx_capacity remote_tx_power remote_rx_power_0 remote_rx_power_1 link_state link_uptime link_distance_m)a + @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(sample, attrs) do sample |> cast(attrs, @required_fields ++ @optional_fields) diff --git a/lib/microwaveprop/geo.ex b/lib/microwaveprop/geo.ex index b0c430a7..09232851 100644 --- a/lib/microwaveprop/geo.ex +++ b/lib/microwaveprop/geo.ex @@ -4,6 +4,7 @@ defmodule Microwaveprop.Geo do @earth_radius_km 6371.0 @doc "Great-circle distance between two points in km." + @spec haversine_km(number(), number(), number(), number()) :: float() def haversine_km(lat1, lon1, lat2, lon2) do dlat = deg_to_rad(lat2 - lat1) dlon = deg_to_rad(lon2 - lon1) @@ -16,6 +17,7 @@ defmodule Microwaveprop.Geo do end @doc "Initial bearing from point 1 to point 2 in degrees (0-360)." + @spec bearing_deg(number(), number(), number(), number()) :: float() def bearing_deg(lat1, lon1, lat2, lon2) do lat1r = deg_to_rad(lat1) lat2r = deg_to_rad(lat2) @@ -27,6 +29,7 @@ defmodule Microwaveprop.Geo do end @doc "Center of a 4-character Maidenhead grid square." + @spec maidenhead_center(String.t()) :: {float(), float()} | nil def maidenhead_center(grid) when is_binary(grid) and byte_size(grid) >= 4 do case Microwaveprop.Radio.Maidenhead.to_latlon(grid) do {:ok, {lat, lon}} -> {lat, lon} @@ -35,6 +38,7 @@ defmodule Microwaveprop.Geo do end @doc "4-character Maidenhead grid for a lat/lon." + @spec latlon_to_grid4(number(), number()) :: String.t() def latlon_to_grid4(lat, lon) do lon_field = trunc((lon + 180) / 20) lat_field = trunc((lat + 90) / 10) diff --git a/lib/microwaveprop/markdown.ex b/lib/microwaveprop/markdown.ex index f44e538a..bb2d056a 100644 --- a/lib/microwaveprop/markdown.ex +++ b/lib/microwaveprop/markdown.ex @@ -7,6 +7,7 @@ defmodule Microwaveprop.Markdown do """ @doc "Converts a Markdown string to HTML." + @spec to_html!(String.t()) :: String.t() def to_html!(markdown) do markdown |> String.replace("\r\n", "\n") diff --git a/lib/microwaveprop/propagation.ex b/lib/microwaveprop/propagation.ex index ec60bbac..e2c26bef 100644 --- a/lib/microwaveprop/propagation.ex +++ b/lib/microwaveprop/propagation.ex @@ -19,6 +19,7 @@ defmodule Microwaveprop.Propagation do Loads the ML model from disk, compiles the predict function, and caches both in persistent_term. No-op if the model file doesn't exist or ML deps unavailable. """ + @spec load_ml_model() :: :ok def load_ml_model do if Code.ensure_loaded?(@ml_module) do case apply(@ml_module, :load, []) do @@ -39,6 +40,7 @@ defmodule Microwaveprop.Propagation do end @doc "Returns cached {predict_fn, params} tuple, or nil if not loaded." + @spec ml_model() :: {function(), term()} | nil def ml_model do :persistent_term.get(@ml_key, nil) end @@ -48,6 +50,8 @@ defmodule Microwaveprop.Propagation do Uses ML model if loaded, falls back to algorithm scorer. Returns a list of %{band_mhz, score, factors} maps. """ + @spec score_grid_point(map(), DateTime.t(), float(), float()) :: + [%{band_mhz: non_neg_integer(), score: non_neg_integer(), factors: map()}] def score_grid_point(hrrr_profile, valid_time, latitude, longitude) do derived = derive_from_hrrr(hrrr_profile) @@ -114,6 +118,7 @@ defmodule Microwaveprop.Propagation do Options: * `:prune` - whether to prune old scores after upsert (default true) """ + @spec upsert_scores(Enumerable.t(), keyword()) :: {:ok, non_neg_integer()} | {:error, term()} def upsert_scores(scores, opts \\ []) do now = DateTime.truncate(DateTime.utc_now(), :second) @@ -175,6 +180,7 @@ defmodule Microwaveprop.Propagation do timeout gives enough headroom for a catch-up run (potentially millions of rows across four indexes) after a stretch of failed compute jobs. """ + @spec prune_old_scores() :: :ok def prune_old_scores do cutoff = DateTime.add(DateTime.utc_now(), -2, :hour) @@ -191,6 +197,7 @@ defmodule Microwaveprop.Propagation do Filters out times more than 1 hour in the past, but always includes the most recent valid_time so there's always data to display. """ + @spec available_valid_times(non_neg_integer()) :: [DateTime.t()] def available_valid_times(band_mhz) do cutoff = DateTime.add(DateTime.utc_now(), -3600, :second) @@ -220,6 +227,8 @@ defmodule Microwaveprop.Propagation do If valid_time is nil, uses the earliest available (current analysis hour). Excludes factors for performance. """ + @spec scores_at(non_neg_integer(), DateTime.t() | nil, map() | nil) :: + [%{lat: float(), lon: float(), score: non_neg_integer(), valid_time: DateTime.t()}] def scores_at(band_mhz, valid_time, bounds \\ nil) do time = valid_time || earliest_valid_time(band_mhz) @@ -238,6 +247,8 @@ defmodule Microwaveprop.Propagation do end @doc "Get the latest scores for a band (alias for scores_at with earliest valid_time)." + @spec latest_scores(non_neg_integer(), map() | nil) :: + [%{lat: float(), lon: float(), score: non_neg_integer(), valid_time: DateTime.t()}] def latest_scores(band_mhz, bounds \\ nil) do scores_at(band_mhz, nil, bounds) end @@ -247,6 +258,8 @@ defmodule Microwaveprop.Propagation do end @doc "Get scores across all forecast hours for a single grid point (for sparkline)." + @spec point_forecast(non_neg_integer(), float(), float()) :: + [%{valid_time: DateTime.t(), score: non_neg_integer()}] def point_forecast(band_mhz, lat, lon) do step = Grid.step() snapped_lat = Float.round(Float.round(lat / step) * step, 3) @@ -267,6 +280,15 @@ defmodule Microwaveprop.Propagation do end @doc "Get the full score and factors for a specific grid point, snapped to nearest grid." + @spec point_detail(non_neg_integer(), float(), float(), DateTime.t() | nil) :: + %{ + lat: float(), + lon: float(), + score: non_neg_integer(), + factors: map(), + valid_time: DateTime.t() + } + | nil def point_detail(band_mhz, lat, lon, valid_time \\ nil) do step = Grid.step() snapped_lat = Float.round(Float.round(lat / step) * step, 3) @@ -307,11 +329,13 @@ defmodule Microwaveprop.Propagation do end @doc "Get the latest valid_time across all scores." + @spec latest_valid_time() :: DateTime.t() | nil def latest_valid_time do Repo.one(from(gs in GridScore, select: max(gs.valid_time))) end @doc "Get the latest valid_time for a specific band." + @spec latest_valid_time(non_neg_integer()) :: DateTime.t() | nil def latest_valid_time(band_mhz) do Repo.one(from(gs in GridScore, where: gs.band_mhz == ^band_mhz, select: max(gs.valid_time))) end diff --git a/lib/microwaveprop/propagation/duct.ex b/lib/microwaveprop/propagation/duct.ex index 8a9b984a..ede9e7dd 100644 --- a/lib/microwaveprop/propagation/duct.ex +++ b/lib/microwaveprop/propagation/duct.ex @@ -23,6 +23,7 @@ defmodule Microwaveprop.Propagation.Duct do Uses ITU-R P.453 with water vapor pressure derived from specific humidity. """ + @spec refractivity_profile(map()) :: [{float(), float()}] def refractivity_profile(%{heights_m: heights, temp_k: temps, spfh: spfhs, pressure_pa: pressures}) do [heights, temps, spfhs, pressures] |> Enum.zip() @@ -46,6 +47,7 @@ defmodule Microwaveprop.Propagation.Duct do In a standard atmosphere M always increases with height. A duct exists wherever M decreases. """ + @spec m_profile([{float(), float()}]) :: [{float(), float()}] def m_profile(n_profile) do Enum.map(n_profile, fn {h, n} -> {h, n + 157.0 * h / 1000.0} @@ -60,6 +62,8 @@ defmodule Microwaveprop.Propagation.Duct do where `m_deficit` is the total M decrease (positive number) across the duct — the strength of the trapping. """ + @spec detect_ducts([{float(), float()}]) :: + [%{base_m: float(), top_m: float(), thickness_m: float(), m_deficit: float()}] def detect_ducts(m_profile) when length(m_profile) < 2, do: [] def detect_ducts(m_profile) do @@ -114,6 +118,7 @@ defmodule Microwaveprop.Propagation.Duct do where d is duct thickness in meters, ΔM is the M-deficit. Returns a very high frequency (999 GHz) for degenerate ducts. """ + @spec min_trapped_frequency_ghz(%{thickness_m: float(), m_deficit: float()}) :: float() def min_trapped_frequency_ghz(%{thickness_m: d, m_deficit: delta_m}) when d > 0 and delta_m > 0 do # Maximum trapped wavelength (meters) lambda_max = 2.5 * d * :math.sqrt(delta_m * 1.0e-6) @@ -135,6 +140,19 @@ defmodule Microwaveprop.Propagation.Duct do where each duct_map includes `:min_freq_ghz` and `best_duct_band_ghz` is the lowest min_freq across all detected ducts. """ + @spec analyze(map()) :: + %{ + ducts: [ + %{ + base_m: float(), + top_m: float(), + thickness_m: float(), + m_deficit: float(), + min_freq_ghz: float() + } + ], + best_duct_band_ghz: float() | nil + } def analyze(profile) do ducts = profile diff --git a/lib/microwaveprop/propagation/freshness_monitor.ex b/lib/microwaveprop/propagation/freshness_monitor.ex index 71df9917..91e08280 100644 --- a/lib/microwaveprop/propagation/freshness_monitor.ex +++ b/lib/microwaveprop/propagation/freshness_monitor.ex @@ -15,6 +15,7 @@ defmodule Microwaveprop.Propagation.FreshnessMonitor do @check_interval to_timeout(minute: 5) @stale_threshold_minutes 120 + @spec start_link(keyword()) :: GenServer.on_start() | :ignore def start_link(_opts) do if Application.get_env(:microwaveprop, :start_freshness_monitor, true) do GenServer.start_link(__MODULE__, :ok, name: __MODULE__) diff --git a/lib/microwaveprop/propagation/grid.ex b/lib/microwaveprop/propagation/grid.ex index 2cdaf652..a99766e1 100644 --- a/lib/microwaveprop/propagation/grid.ex +++ b/lib/microwaveprop/propagation/grid.ex @@ -8,6 +8,7 @@ defmodule Microwaveprop.Propagation.Grid do @step 0.125 @doc "Returns all grid points as `{lat, lon}` tuples covering CONUS at 0.125 degree spacing." + @spec conus_points() :: [{float(), float()}] def conus_points do for lat <- float_range(@lat_min, @lat_max, @step), lon <- float_range(@lon_min, @lon_max, @step) do @@ -16,12 +17,22 @@ defmodule Microwaveprop.Propagation.Grid do end @doc "Returns the grid step size in degrees." + @spec step() :: float() def step, do: @step @doc "Returns the CONUS bounding box as a map." + @spec bounds() :: %{lat_min: float(), lat_max: float(), lon_min: float(), lon_max: float()} def bounds, do: %{lat_min: @lat_min, lat_max: @lat_max, lon_min: @lon_min, lon_max: @lon_max} @doc "Returns the grid specification for wgrib2 -lola extraction." + @spec wgrib2_grid_spec() :: %{ + lon_start: float(), + lon_count: non_neg_integer(), + lon_step: float(), + lat_start: float(), + lat_count: non_neg_integer(), + lat_step: float() + } def wgrib2_grid_spec do lon_count = round((@lon_max - @lon_min) / @step) + 1 lat_count = round((@lat_max - @lat_min) / @step) + 1 diff --git a/lib/microwaveprop/propagation/grid_score.ex b/lib/microwaveprop/propagation/grid_score.ex index a5adc6b5..46a9ffc2 100644 --- a/lib/microwaveprop/propagation/grid_score.ex +++ b/lib/microwaveprop/propagation/grid_score.ex @@ -17,8 +17,11 @@ defmodule Microwaveprop.Propagation.GridScore do timestamps(type: :utc_datetime) end + @type t :: %__MODULE__{} + @fields ~w(lat lon valid_time band_mhz score factors)a + @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(grid_score, attrs) do grid_score |> cast(attrs, @fields) diff --git a/lib/microwaveprop/propagation/inversion.ex b/lib/microwaveprop/propagation/inversion.ex index b6b159ce..5d103ff8 100644 --- a/lib/microwaveprop/propagation/inversion.ex +++ b/lib/microwaveprop/propagation/inversion.ex @@ -30,6 +30,15 @@ defmodule Microwaveprop.Propagation.Inversion do The profile map must have at least `:heights_m` and `:temp_k` arrays. """ + @spec find_inversion_top(map()) :: + {:ok, + %{ + height_m: float(), + level_idx: non_neg_integer(), + base_idx: non_neg_integer(), + strength_k: float() + }} + | :none def find_inversion_top(%{level_count: n}) when n < 2, do: :none def find_inversion_top(%{heights_m: heights, temp_k: temps}) do @@ -100,6 +109,7 @@ defmodule Microwaveprop.Propagation.Inversion do Returns `nil` if inputs are missing or shear is zero (infinite Ri is clamped to 100.0 for practical use). """ + @spec bulk_richardson(map(), non_neg_integer(), non_neg_integer()) :: float() | nil def bulk_richardson(profile, base_idx, top_idx) do with t_base when is_float(t_base) <- Enum.at(profile.temp_k, base_idx), t_top when is_float(t_top) <- Enum.at(profile.temp_k, top_idx), @@ -134,6 +144,7 @@ defmodule Microwaveprop.Propagation.Inversion do @doc """ Wind shear magnitude (m/s) across a layer from `base_idx` to `top_idx`. """ + @spec shear_magnitude(map(), non_neg_integer(), non_neg_integer()) :: float() | nil def shear_magnitude(profile, base_idx, top_idx) do with u_base when is_float(u_base) <- Enum.at(profile.u_wind_ms, base_idx), u_top when is_float(u_top) <- Enum.at(profile.u_wind_ms, top_idx), @@ -148,6 +159,7 @@ defmodule Microwaveprop.Propagation.Inversion do @doc """ Potential temperature θ = T * (P₀/P)^(R/cp) where P₀ = 100000 Pa. """ + @spec potential_temperature(float(), float()) :: float() def potential_temperature(temp_k, pressure_pa) do temp_k * :math.pow(100_000.0 / pressure_pa, 0.286) end diff --git a/lib/microwaveprop/propagation/rain_scatter.ex b/lib/microwaveprop/propagation/rain_scatter.ex index 510a7f54..9d31f836 100644 --- a/lib/microwaveprop/propagation/rain_scatter.ex +++ b/lib/microwaveprop/propagation/rain_scatter.ex @@ -33,6 +33,21 @@ defmodule Microwaveprop.Propagation.RainScatter do each with: lat, lon, dbz, distance_km, scatter_db (relative signal estimate), and bearing from the observer. """ + @spec find_scatter_cells( + [{float(), float(), float()}], + float(), + float(), + float() + ) :: [ + %{ + lat: float(), + lon: float(), + dbz: float(), + distance_km: float(), + bearing: float(), + scatter_db: float() + } + ] def find_scatter_cells(rain_cells, obs_lat, obs_lon, freq_ghz) do rain_cells |> Enum.filter(fn {_lat, _lon, dbz} -> dbz >= @min_dbz end) @@ -66,6 +81,7 @@ defmodule Microwaveprop.Propagation.RainScatter do Returns :excellent, :good, :marginal, or :none based on the best available scatter cell. """ + @spec classify([%{scatter_db: float()}]) :: :excellent | :good | :marginal | :none def classify(scatter_cells) do case scatter_cells do [] -> diff --git a/lib/microwaveprop/radio.ex b/lib/microwaveprop/radio.ex index 6f5717ae..4bb1025b 100644 --- a/lib/microwaveprop/radio.ex +++ b/lib/microwaveprop/radio.ex @@ -3,6 +3,7 @@ defmodule Microwaveprop.Radio do import Ecto.Query + alias Microwaveprop.Accounts.User alias Microwaveprop.Radio.Contact alias Microwaveprop.Radio.ContactEdit alias Microwaveprop.Radio.Maidenhead @@ -11,6 +12,15 @@ defmodule Microwaveprop.Radio do @per_page 20 @sortable_fields ~w(station1 station2 band mode distance_km qso_timestamp inserted_at)a + @type contact_page :: %{ + entries: [Contact.t()], + grouped_entries: [{Contact.t(), [Contact.t()]}], + page: pos_integer(), + total_pages: pos_integer(), + total_entries: non_neg_integer() + } + + @spec list_contacts(keyword()) :: contact_page() def list_contacts(opts \\ []) do page = max(Keyword.get(opts, :page, 1), 1) offset = (page - 1) * @per_page @@ -45,6 +55,7 @@ defmodule Microwaveprop.Radio do Returns a list of `{primary, reciprocals}` tuples where reciprocals is a (possibly empty) list of matching contacts. """ + @spec group_reciprocals([Contact.t()]) :: [{Contact.t(), [Contact.t()]}] def group_reciprocals(contacts) do {groups, _seen_ids} = Enum.reduce(contacts, {[], MapSet.new()}, fn contact, {groups, seen} -> @@ -118,6 +129,7 @@ defmodule Microwaveprop.Radio do @enrichable_statuses [:pending, :failed] + @spec contacts_needing_enrichment(atom(), non_neg_integer(), (Ecto.Query.t() -> Ecto.Query.t()) | nil) :: [Contact.t()] def contacts_needing_enrichment(field, limit \\ 500, extra_filter \\ nil) do query = Contact @@ -129,30 +141,43 @@ defmodule Microwaveprop.Radio do Repo.all(query) end + @spec unprocessed_contacts(non_neg_integer()) :: [Contact.t()] def unprocessed_contacts(limit \\ 500), do: contacts_needing_enrichment(:weather_status, limit) + @spec unprocessed_hrrr_contacts(non_neg_integer()) :: [Contact.t()] def unprocessed_hrrr_contacts(limit \\ 500), do: contacts_needing_enrichment(:hrrr_status, limit) + @spec unprocessed_terrain_contacts(non_neg_integer()) :: [Contact.t()] def unprocessed_terrain_contacts(limit \\ 500), do: contacts_needing_enrichment(:terrain_status, limit, &where(&1, [q], not is_nil(q.pos2))) + @spec unprocessed_iemre_contacts(non_neg_integer()) :: [Contact.t()] def unprocessed_iemre_contacts(limit \\ 500), do: contacts_needing_enrichment(:iemre_status, limit) + @spec set_enrichment_status!([Ecto.UUID.t()], atom(), atom()) :: {non_neg_integer(), nil | [any()]} def set_enrichment_status!(ids, field, status) do Contact |> where([q], q.id in ^ids and field(q, ^field) != ^status) |> Repo.update_all(set: [{field, status}]) end + @spec mark_weather_queued!([Ecto.UUID.t()]) :: {non_neg_integer(), nil | [any()]} def mark_weather_queued!(ids), do: set_enrichment_status!(ids, :weather_status, :queued) + + @spec mark_hrrr_queued!([Ecto.UUID.t()]) :: {non_neg_integer(), nil | [any()]} def mark_hrrr_queued!(ids), do: set_enrichment_status!(ids, :hrrr_status, :queued) + + @spec mark_terrain_queued!([Ecto.UUID.t()]) :: {non_neg_integer(), nil | [any()]} def mark_terrain_queued!(ids), do: set_enrichment_status!(ids, :terrain_status, :queued) + + @spec mark_iemre_queued!([Ecto.UUID.t()]) :: {non_neg_integer(), nil | [any()]} def mark_iemre_queued!(ids), do: set_enrichment_status!(ids, :iemre_status, :queued) @doc """ Returns a list of {lat, lon} points along the contact path: pos1, midpoint, pos2. Returns [pos1] if pos2 is nil, or [] if pos1 is nil. """ + @spec contact_path_points(Contact.t()) :: [{float(), float()}] def contact_path_points(%{pos1: nil}), do: [] def contact_path_points(%{pos1: pos1, pos2: nil}) do @@ -183,6 +208,7 @@ defmodule Microwaveprop.Radio do @earth_radius_km 6371.0 + @spec haversine_km(number(), number(), number(), number()) :: float() def haversine_km(lat1, lon1, lat2, lon2) do dlat = deg_to_rad(lat2 - lat1) dlon = deg_to_rad(lon2 - lon1) @@ -196,6 +222,7 @@ defmodule Microwaveprop.Radio do 2 * @earth_radius_km * :math.asin(:math.sqrt(a)) end + @spec backfill_distances([Contact.t()]) :: Postgrex.Result.t() | nil def backfill_distances(contacts) do updates = for contact <- contacts, @@ -230,16 +257,19 @@ defmodule Microwaveprop.Radio do defp deg_to_rad(deg), do: deg * :math.pi() / 180 + @spec get_contact!(Ecto.UUID.t()) :: Contact.t() def get_contact!(id) do Repo.get!(Contact, id) end + @spec toggle_flagged_invalid!(Contact.t()) :: Contact.t() def toggle_flagged_invalid!(contact) do contact |> Ecto.Changeset.change(flagged_invalid: !contact.flagged_invalid) |> Repo.update!() end + @spec change_contact(Contact.t(), map()) :: Ecto.Changeset.t() def change_contact(contact, attrs \\ %{}) do Contact.submission_changeset(contact, attrs) end @@ -248,6 +278,7 @@ defmodule Microwaveprop.Radio do Computes pos1/pos2/distance_km from grid squares if missing. Returns the updated contact, or the original if already computed or grids are missing. """ + @spec ensure_positions!(Contact.t()) :: Contact.t() def ensure_positions!(contact) do needs_pos1 = is_nil(contact.pos1) && contact.grid1 needs_pos2 = is_nil(contact.pos2) && contact.grid2 @@ -331,6 +362,8 @@ defmodule Microwaveprop.Radio do @dedup_window_seconds 3600 + @spec create_contact(map()) :: + {:ok, Contact.t()} | {:error, Ecto.Changeset.t()} | {:error, :duplicate, Contact.t()} def create_contact(attrs) do changeset = Contact.submission_changeset(%Contact{}, attrs) @@ -402,6 +435,8 @@ defmodule Microwaveprop.Radio do differ from the contact's current values. Normalizes callsigns and grids to uppercase. """ + @spec create_contact_edit(Contact.t(), User.t(), map()) :: + {:ok, ContactEdit.t()} | {:error, Ecto.Changeset.t()} def create_contact_edit(%Contact{} = contact, user, proposed_changes) when is_map(proposed_changes) do # Normalize and diff against current values normalized = normalize_proposed(proposed_changes) @@ -419,6 +454,7 @@ defmodule Microwaveprop.Radio do end @doc false + @spec normalize_proposed(map()) :: map() def normalize_proposed(changes) do changes |> normalize_string_field("station1") @@ -437,6 +473,7 @@ defmodule Microwaveprop.Radio do end @doc false + @spec diff_against_contact(Contact.t(), map()) :: map() def diff_against_contact(contact, proposed) do Enum.reduce(proposed, %{}, fn {key, new_val}, acc -> current = current_value(contact, key) @@ -475,6 +512,7 @@ defmodule Microwaveprop.Radio do defp to_integer(%Decimal{} = v), do: Decimal.to_integer(v) defp to_integer(v), do: v + @spec list_pending_edits() :: [ContactEdit.t()] def list_pending_edits do ContactEdit |> where([e], e.status == :pending) @@ -483,6 +521,7 @@ defmodule Microwaveprop.Radio do |> Repo.all() end + @spec list_contact_edits(Ecto.UUID.t()) :: [ContactEdit.t()] def list_contact_edits(contact_id) do ContactEdit |> where([e], e.contact_id == ^contact_id) @@ -491,24 +530,29 @@ defmodule Microwaveprop.Radio do |> Repo.all() end + @spec pending_edit_count() :: non_neg_integer() def pending_edit_count do ContactEdit |> where([e], e.status == :pending) |> Repo.aggregate(:count) end + @spec pending_edit_for_user(Ecto.UUID.t(), Ecto.UUID.t()) :: ContactEdit.t() | nil def pending_edit_for_user(contact_id, user_id) do ContactEdit |> where([e], e.contact_id == ^contact_id and e.user_id == ^user_id and e.status == :pending) |> Repo.one() end + @spec get_contact_edit!(Ecto.UUID.t()) :: ContactEdit.t() def get_contact_edit!(id) do ContactEdit |> preload([:user, :contact, :reviewed_by]) |> Repo.get!(id) end + @spec approve_edit(ContactEdit.t(), User.t(), String.t() | nil) :: + {:ok, ContactEdit.t()} | {:error, any()} def approve_edit(%ContactEdit{status: :pending} = edit, admin, note) do Repo.transaction(fn -> # Mark edit as approved @@ -530,6 +574,8 @@ defmodule Microwaveprop.Radio do end) end + @spec reject_edit(ContactEdit.t(), User.t(), String.t() | nil) :: + {:ok, ContactEdit.t()} | {:error, Ecto.Changeset.t()} def reject_edit(%ContactEdit{status: :pending} = edit, admin, note) do edit |> ContactEdit.review_changeset(%{ @@ -542,6 +588,7 @@ defmodule Microwaveprop.Radio do end @doc "Apply proposed changes directly to a contact (admin use)." + @spec apply_edit_to_contact(Contact.t(), map()) :: Contact.t() def apply_edit_to_contact(contact, proposed_changes) do changes = build_contact_changes(contact, proposed_changes) diff --git a/lib/microwaveprop/radio/adif_import.ex b/lib/microwaveprop/radio/adif_import.ex index e3f1cca0..fb5b0cc9 100644 --- a/lib/microwaveprop/radio/adif_import.ex +++ b/lib/microwaveprop/radio/adif_import.ex @@ -36,6 +36,15 @@ defmodule Microwaveprop.Radio.AdifImport do Parse ADIF string, validate, and deduplicate. Returns the same preview shape as `CsvImport.preview/2`. """ + @type preview_result :: %{ + valid: [map()], + invalid: [map()], + duplicates: [map()], + total_rows: non_neg_integer(), + submitter_email: String.t() + } + + @spec preview(String.t(), String.t()) :: {:ok, preview_result()} | {:error, :no_records} def preview(adif_string, submitter_email) do records = parse_adif(adif_string) diff --git a/lib/microwaveprop/radio/callsign_client.ex b/lib/microwaveprop/radio/callsign_client.ex index da4a0481..dadd089e 100644 --- a/lib/microwaveprop/radio/callsign_client.ex +++ b/lib/microwaveprop/radio/callsign_client.ex @@ -4,6 +4,14 @@ defmodule Microwaveprop.Radio.CallsignClient do @doc """ Fetch location for a callsign. Returns {:ok, %{callsign, gridsquare, lat, lon}} or {:error, reason}. """ + @type location :: %{ + callsign: String.t(), + gridsquare: String.t() | nil, + lat: number(), + lon: number() + } + + @spec locate(String.t()) :: {:ok, location()} | {:error, String.t()} def locate(callsign) do url = "https://gridmap.org/locate/#{URI.encode(callsign)}" diff --git a/lib/microwaveprop/radio/contact.ex b/lib/microwaveprop/radio/contact.ex index 87050aaa..801905e0 100644 --- a/lib/microwaveprop/radio/contact.ex +++ b/lib/microwaveprop/radio/contact.ex @@ -47,9 +47,12 @@ defmodule Microwaveprop.Radio.Contact do timestamps(type: :utc_datetime) end + @type t :: %__MODULE__{} + @required_fields ~w(station1 station2 qso_timestamp mode band)a @optional_fields ~w(grid1 grid2 pos1 pos2 distance_km user_id)a + @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(contact, attrs) do contact |> cast(attrs, @required_fields ++ @optional_fields) @@ -61,6 +64,7 @@ defmodule Microwaveprop.Radio.Contact do @allowed_modes ~w(CW SSB FM FT8 FT4 Q65) @allowed_bands Enum.map(~w(1296 2304 3456 5760 10000 24000 47000 68000 75000 122000 134000 241000), &Decimal.new/1) + @spec submission_changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def submission_changeset(contact, attrs) do contact |> cast(attrs, @submission_fields) diff --git a/lib/microwaveprop/radio/contact_edit.ex b/lib/microwaveprop/radio/contact_edit.ex index 85c98073..796a2576 100644 --- a/lib/microwaveprop/radio/contact_edit.ex +++ b/lib/microwaveprop/radio/contact_edit.ex @@ -27,8 +27,11 @@ defmodule Microwaveprop.Radio.ContactEdit do timestamps(type: :utc_datetime) end + @type t :: %__MODULE__{} + @editable_fields ~w(station1 station2 grid1 grid2 band mode qso_timestamp) + @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(edit, attrs) do edit |> cast(attrs, [:contact_id, :user_id, :proposed_changes]) @@ -38,6 +41,7 @@ defmodule Microwaveprop.Radio.ContactEdit do |> foreign_key_constraint(:user_id) end + @spec review_changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def review_changeset(edit, attrs) do edit |> cast(attrs, [:status, :admin_note, :reviewed_by_id, :reviewed_at]) diff --git a/lib/microwaveprop/radio/csv_import.ex b/lib/microwaveprop/radio/csv_import.ex index 65333223..2b01b900 100644 --- a/lib/microwaveprop/radio/csv_import.ex +++ b/lib/microwaveprop/radio/csv_import.ex @@ -36,6 +36,26 @@ defmodule Microwaveprop.Radio.CsvImport do Parse-and-insert in one shot. Kept for backward compatibility — does NOT perform duplicate detection. New code should use `preview/2` + `commit/1`. """ + @type preview_result :: %{ + valid: [map()], + invalid: [map()], + duplicates: [map()], + total_rows: non_neg_integer(), + submitter_email: String.t() + } + + @type import_result :: %{ + imported: [Contact.t()], + errors: [{pos_integer(), [String.t()]}] + } + + @type commit_result :: %{ + imported: [Contact.t()], + errors: [{pos_integer(), [String.t()]}] + } + + @spec import(String.t(), String.t()) :: + {:ok, import_result()} | {:error, :empty_csv} | {:error, :no_data_rows} def import(csv_string, submitter_email) do with {:ok, rows} <- parse_csv(csv_string, submitter_email) do {valid, invalid} = split_parsed(rows) @@ -66,6 +86,8 @@ defmodule Microwaveprop.Radio.CsvImport do the database (`:existing_contact`) or earlier in the uploaded file (`:earlier_in_upload`). """ + @spec preview(String.t(), String.t()) :: + {:ok, preview_result()} | {:error, :empty_csv} | {:error, :no_data_rows} def preview(csv_string, submitter_email) do with {:ok, rows} <- parse_csv(csv_string, submitter_email) do {valid, invalid} = split_parsed(rows) @@ -88,6 +110,7 @@ defmodule Microwaveprop.Radio.CsvImport do `Radio.create_contact/1` and its enrichment is enqueued on success. Returns `{:ok, %{imported: [...], errors: [{row_num, messages}, ...]}}`. """ + @spec commit([map()]) :: {:ok, commit_result()} def commit(valid_rows) when is_list(valid_rows) do result = Enum.reduce(valid_rows, %{imported: [], errors: []}, fn row, acc -> @@ -313,6 +336,7 @@ defmodule Microwaveprop.Radio.CsvImport do Parses various date/time formats into ISO 8601 UTC strings. All times are assumed UTC. """ + @spec normalize_timestamp(String.t()) :: {:ok, String.t()} | {:error, String.t()} def normalize_timestamp(raw) do trimmed = String.trim(raw) diff --git a/lib/microwaveprop/radio/enrichment_status.ex b/lib/microwaveprop/radio/enrichment_status.ex index ee063c21..2d0ea581 100644 --- a/lib/microwaveprop/radio/enrichment_status.ex +++ b/lib/microwaveprop/radio/enrichment_status.ex @@ -19,6 +19,9 @@ defmodule Microwaveprop.Radio.EnrichmentStatus do complete → pending (reset for re-enrichment) """ + @type status :: :pending | :queued | :processing | :complete | :failed | :unavailable + @type field_name :: :hrrr_status | :weather_status | :terrain_status | :iemre_status + @states [:pending, :queued, :processing, :complete, :failed, :unavailable] @fields [:hrrr_status, :weather_status, :terrain_status, :iemre_status] @@ -31,15 +34,20 @@ defmodule Microwaveprop.Radio.EnrichmentStatus do complete: [:pending] } + @spec states() :: [status()] def states, do: @states + + @spec fields() :: [field_name()] def fields, do: @fields @doc "Returns true if transitioning from `from` to `to` is valid." + @spec valid_transition?(status(), status()) :: boolean() def valid_transition?(from, to) do to in Map.get(@transitions, from, []) end @doc "Validates and applies a status transition on a changeset." + @spec transition(Ecto.Changeset.t(), field_name(), status()) :: Ecto.Changeset.t() def transition(changeset, field, new_status) when field in @fields and new_status in @states do current = Ecto.Changeset.get_field(changeset, field) @@ -51,6 +59,7 @@ defmodule Microwaveprop.Radio.EnrichmentStatus do end @doc "Force-sets status without transition validation (for migrations, resets)." + @spec force_status(Ecto.Changeset.t(), field_name(), status()) :: Ecto.Changeset.t() def force_status(changeset, field, status) when field in @fields and status in @states do Ecto.Changeset.put_change(changeset, field, status) end diff --git a/lib/microwaveprop/terrain.ex b/lib/microwaveprop/terrain.ex index 18af37ac..e53c4a8f 100644 --- a/lib/microwaveprop/terrain.ex +++ b/lib/microwaveprop/terrain.ex @@ -6,6 +6,7 @@ defmodule Microwaveprop.Terrain do alias Microwaveprop.Repo alias Microwaveprop.Terrain.TerrainProfile + @spec upsert_terrain_profile(map()) :: {:ok, TerrainProfile.t()} | {:error, Ecto.Changeset.t()} def upsert_terrain_profile(attrs) do %TerrainProfile{} |> TerrainProfile.changeset(attrs) @@ -15,10 +16,12 @@ defmodule Microwaveprop.Terrain do ) end + @spec get_terrain_profile(Ecto.UUID.t()) :: TerrainProfile.t() | nil def get_terrain_profile(contact_id) do Repo.get_by(TerrainProfile, contact_id: contact_id) end + @spec has_terrain_profile?(Ecto.UUID.t()) :: boolean() def has_terrain_profile?(contact_id) do TerrainProfile |> where([t], t.contact_id == ^contact_id) diff --git a/lib/microwaveprop/terrain/terrain_analysis.ex b/lib/microwaveprop/terrain/terrain_analysis.ex index f281a0fc..37ad9a0b 100644 --- a/lib/microwaveprop/terrain/terrain_analysis.ex +++ b/lib/microwaveprop/terrain/terrain_analysis.ex @@ -8,6 +8,38 @@ defmodule Microwaveprop.Terrain.TerrainAnalysis do - Dynamic k-factor from refractivity gradient (Section 2) """ + @type elevation_point :: %{lat: float(), lon: float(), elev: float(), d: float(), dist_km: float()} + + @type analysis_point :: %{ + lat: float(), + lon: float(), + d: float(), + elev: float(), + dist_km: float(), + idx: non_neg_integer(), + beam: float(), + eff_terrain: float(), + bulge: float(), + r1: float(), + d1_m: float(), + d2_m: float(), + clearance: float(), + f1_clear: float(), + obstructed: boolean(), + fresnel_penetrated: boolean() + } + + @type analysis_result :: %{ + points: [analysis_point()], + diffraction_db: number(), + verdict: String.t(), + k_factor: float(), + max_elevation_m: float(), + min_clearance_m: float(), + obstructed_count: non_neg_integer(), + fresnel_hit_count: non_neg_integer() + } + @earth_radius_m 6_371_000.0 @earth_radius_km 6_371.0 @@ -20,6 +52,7 @@ defmodule Microwaveprop.Terrain.TerrainAnalysis do * `:ant_ht_b` - antenna height B in meters (default 0.0) * `:k_factor` - effective earth radius factor (default 4/3, compute with `k_factor/1`) """ + @spec analyse([elevation_point()], float(), float(), keyword()) :: analysis_result() def analyse(profile, dist_km, freq_ghz, opts \\ []) do ant_ht_a = Keyword.get(opts, :ant_ht_a, 0.0) ant_ht_b = Keyword.get(opts, :ant_ht_b, 0.0) @@ -98,6 +131,7 @@ defmodule Microwaveprop.Terrain.TerrainAnalysis do end @doc "First Fresnel zone radius at a point between two antennas." + @spec fresnel_radius(number(), number(), number()) :: number() def fresnel_radius(d1_m, d2_m, lambda_m) do if d1_m <= 0 or d2_m <= 0 do 0 @@ -107,6 +141,7 @@ defmodule Microwaveprop.Terrain.TerrainAnalysis do end @doc "Earth bulge correction in meters at fractional distance along path." + @spec earth_bulge(float(), float(), float()) :: float() def earth_bulge(frac, dist_km, k \\ 4 / 3) do d1 = frac * dist_km * 1000 d2 = (1 - frac) * dist_km * 1000 @@ -119,6 +154,7 @@ defmodule Microwaveprop.Terrain.TerrainAnalysis do J(ν) = 6.9 + 20·log10(√((ν−0.1)² + 1) + ν − 0.1) for ν > −0.78 J(ν) = 0 for ν ≤ −0.78 """ + @spec knife_edge_loss(number()) :: number() def knife_edge_loss(v) when v <= -0.78, do: 0 def knife_edge_loss(v) do @@ -133,6 +169,7 @@ defmodule Microwaveprop.Terrain.TerrainAnalysis do where h is height above direct ray (positive = above/blocked, negative = below/clear). """ + @spec diffraction_param(number(), number(), number(), number()) :: float() def diffraction_param(h, d1_m, d2_m, lambda_m) do if d1_m <= 0 or d2_m <= 0 or lambda_m <= 0 do 0.0 @@ -149,6 +186,7 @@ defmodule Microwaveprop.Terrain.TerrainAnalysis do Standard atmosphere: dN/dh ≈ −39 → k ≈ 4/3. Returns 4/3 for nil input. """ + @spec k_factor(number() | nil) :: float() def k_factor(nil), do: 4 / 3 def k_factor(dn_dh) do diff --git a/lib/microwaveprop/terrain/terrain_profile.ex b/lib/microwaveprop/terrain/terrain_profile.ex index 5373ec4b..a7e5b3cb 100644 --- a/lib/microwaveprop/terrain/terrain_profile.ex +++ b/lib/microwaveprop/terrain/terrain_profile.ex @@ -21,9 +21,12 @@ defmodule Microwaveprop.Terrain.TerrainProfile do timestamps(type: :utc_datetime) end + @type t :: %__MODULE__{} + @required_fields ~w(contact_id sample_count path_points verdict)a @optional_fields ~w(max_elevation_m min_clearance_m diffraction_db fresnel_hit_count obstructed_count)a + @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(profile, attrs) do profile |> cast(attrs, @required_fields ++ @optional_fields) diff --git a/lib/microwaveprop/terrain/viewshed.ex b/lib/microwaveprop/terrain/viewshed.ex index a07f77d1..492342d0 100644 --- a/lib/microwaveprop/terrain/viewshed.ex +++ b/lib/microwaveprop/terrain/viewshed.ex @@ -4,11 +4,19 @@ defmodule Microwaveprop.Terrain.Viewshed do alias Microwaveprop.Terrain.Srtm alias Microwaveprop.Terrain.TerrainAnalysis + @type boundary_point :: %{bearing: non_neg_integer(), reach_km: float(), lat: float(), lon: float()} + + @type viewshed_result :: %{ + origin: %{lat: float(), lon: float()}, + boundary: [boundary_point()] + } + @earth_radius_km 6371.0 @default_angular_step 2 @default_max_range_km 50 @doc "Compute destination lat/lon given origin, bearing (degrees), and distance (km)." + @spec destination_point(float(), float(), float(), float()) :: {float(), float()} def destination_point(lat, lon, bearing_deg, dist_km) do lat_rad = deg_to_rad(lat) lon_rad = deg_to_rad(lon) @@ -36,6 +44,7 @@ defmodule Microwaveprop.Terrain.Viewshed do Skips endpoints (first/last), returns the dist_km of the last clear interior point before the first obstruction. """ + @spec find_reach_km([TerrainAnalysis.analysis_point()], float()) :: float() def find_reach_km(points, max_range_km) do interior = Enum.slice(points, 1..-2//1) @@ -56,6 +65,7 @@ defmodule Microwaveprop.Terrain.Viewshed do Higher propagation scores indicate ducting potential, which lets signals propagate beyond terrain obstructions via atmospheric waveguide. """ + @spec effective_reach_km(TerrainAnalysis.analysis_result(), float(), number()) :: float() def effective_reach_km(analysis, max_range_km, score) do case analysis.verdict do "CLEAR" -> @@ -104,6 +114,7 @@ defmodule Microwaveprop.Terrain.Viewshed do - :angular_step — degrees between rays (default 2) - :tiles_dir — SRTM tiles directory (default from config) """ + @spec compute(float(), float(), keyword()) :: viewshed_result() def compute(lat, lon, opts \\ []) do freq_ghz = Keyword.get(opts, :freq_ghz, 10.0) max_range_km = Keyword.get(opts, :max_range_km, @default_max_range_km) @@ -138,6 +149,8 @@ defmodule Microwaveprop.Terrain.Viewshed do Analyse a single ray's profile. Public for testing. Returns %{reach_km: float, verdict: string}. """ + @spec analyse_ray([TerrainAnalysis.elevation_point()], float(), float(), float(), float()) :: + %{reach_km: float(), verdict: String.t()} def analyse_ray(profile, dist_km, freq_ghz, ant_ht_a_m, ant_ht_b_m) do analysis = TerrainAnalysis.analyse(profile, dist_km, freq_ghz, ant_ht_a: ant_ht_a_m, ant_ht_b: ant_ht_b_m) reach_km = find_reach_km(analysis.points, dist_km) diff --git a/lib/microwaveprop/weather.ex b/lib/microwaveprop/weather.ex index 45e029b7..c8c5c4f3 100644 --- a/lib/microwaveprop/weather.ex +++ b/lib/microwaveprop/weather.ex @@ -21,6 +21,7 @@ defmodule Microwaveprop.Weather do # Approximate km per degree latitude @km_per_deg_lat 111.0 + @spec find_or_create_station(map()) :: {:ok, %Station{}} | {:error, Ecto.Changeset.t()} def find_or_create_station(attrs) do code = attrs[:station_code] || attrs["station_code"] type = attrs[:station_type] || attrs["station_type"] @@ -42,6 +43,7 @@ defmodule Microwaveprop.Weather do end end + @spec upsert_surface_observation(%Station{}, map()) :: {:ok, %SurfaceObservation{}} | {:error, Ecto.Changeset.t()} def upsert_surface_observation(%Station{} = station, attrs) do attrs = Map.put(attrs, :station_id, station.id) @@ -76,6 +78,7 @@ defmodule Microwaveprop.Weather do ) end + @spec upsert_sounding(%Station{}, map()) :: {:ok, %Sounding{}} | {:error, Ecto.Changeset.t()} def upsert_sounding(%Station{} = station, attrs) do attrs = Map.put(attrs, :station_id, station.id) @@ -113,6 +116,7 @@ defmodule Microwaveprop.Weather do ) end + @spec upsert_solar_index(map()) :: {:ok, %SolarIndex{}} | {:error, Ecto.Changeset.t()} def upsert_solar_index(attrs) do %SolarIndex{} |> SolarIndex.changeset(attrs) @@ -139,6 +143,7 @@ defmodule Microwaveprop.Weather do ) end + @spec has_surface_observations?(Ecto.UUID.t(), DateTime.t(), DateTime.t()) :: boolean() def has_surface_observations?(station_id, start_dt, end_dt) do SurfaceObservation |> where([o], o.station_id == ^station_id) @@ -147,6 +152,7 @@ defmodule Microwaveprop.Weather do end @doc "Returns a MapSet of station_ids that have surface observations in the time window." + @spec station_ids_with_surface_observations([Ecto.UUID.t()], DateTime.t(), DateTime.t()) :: MapSet.t(Ecto.UUID.t()) def station_ids_with_surface_observations(station_ids, start_dt, end_dt) do SurfaceObservation |> where([o], o.station_id in ^station_ids) @@ -157,6 +163,7 @@ defmodule Microwaveprop.Weather do |> MapSet.new() end + @spec has_sounding?(Ecto.UUID.t(), DateTime.t()) :: boolean() def has_sounding?(station_id, observed_at) do Sounding |> where([s], s.station_id == ^station_id and s.observed_at == ^observed_at) @@ -164,6 +171,7 @@ defmodule Microwaveprop.Weather do end @doc "Returns a MapSet of {station_id, observed_at} tuples that have soundings." + @spec station_ids_with_soundings([Ecto.UUID.t()], [DateTime.t()]) :: MapSet.t({Ecto.UUID.t(), DateTime.t()}) def station_ids_with_soundings(station_ids, sounding_times) do Sounding |> where([s], s.station_id in ^station_ids and s.observed_at in ^sounding_times) @@ -174,6 +182,7 @@ defmodule Microwaveprop.Weather do end @doc "Batch upsert solar indices using insert_all in chunks of 500." + @spec upsert_solar_indices_batch([map()]) :: non_neg_integer() def upsert_solar_indices_batch(records) do now = DateTime.truncate(DateTime.utc_now(), :second) @@ -220,10 +229,12 @@ defmodule Microwaveprop.Weather do end) end + @spec get_solar_index(Date.t()) :: %SolarIndex{} | nil def get_solar_index(date) do Repo.get_by(SolarIndex, date: date) end + @spec existing_solar_dates() :: MapSet.t(Date.t()) def existing_solar_dates do SolarIndex |> select([s], s.date) @@ -231,6 +242,7 @@ defmodule Microwaveprop.Weather do |> MapSet.new() end + @spec sync_stations!() :: :ok def sync_stations! do asos = for s <- @@ -262,6 +274,7 @@ defmodule Microwaveprop.Weather do :ok end + @spec nearby_stations(float(), float(), String.t(), number()) :: [%Station{}] def nearby_stations(lat, lon, station_type, radius_km) do dlat = radius_km / @km_per_deg_lat dlon = radius_km / (@km_per_deg_lat * :math.cos(lat * :math.pi() / 180)) @@ -276,6 +289,7 @@ defmodule Microwaveprop.Weather do |> Repo.all() end + @spec sounding_times_around(DateTime.t()) :: [DateTime.t()] def sounding_times_around(dt) do date = DateTime.to_date(dt) @@ -295,6 +309,10 @@ defmodule Microwaveprop.Weather do Enum.uniq(times) end + @spec weather_for_contact(map(), keyword()) :: %{ + surface_observations: [%SurfaceObservation{}], + soundings: [%Sounding{}] + } def weather_for_contact(contact_params, opts \\ []) do lat = contact_params[:lat] || contact_params.lat lon = contact_params[:lon] || contact_params.lon @@ -336,6 +354,7 @@ defmodule Microwaveprop.Weather do %{surface_observations: surface_observations, soundings: soundings} end + @spec latest_grid_valid_time() :: DateTime.t() | nil def latest_grid_valid_time do Repo.one( from(h in HrrrProfile, @@ -345,6 +364,7 @@ defmodule Microwaveprop.Weather do ) end + @spec latest_weather_grid(map()) :: [map()] def latest_weather_grid(bounds) do case latest_grid_valid_time() do nil -> @@ -380,6 +400,7 @@ defmodule Microwaveprop.Weather do end end + @spec weather_point_detail(float(), float(), DateTime.t()) :: map() | nil def weather_point_detail(lat, lon, valid_time) do step = 0.125 snapped_lat = Float.round(Float.round(lat / step) * step, 3) @@ -420,6 +441,7 @@ defmodule Microwaveprop.Weather do |> Map.drop([:profile, :duct_characteristics, :surface_temp_c, :surface_dewpoint_c]) end + @spec upsert_hrrr_profile(map()) :: {:ok, %HrrrProfile{}} | {:error, Ecto.Changeset.t()} def upsert_hrrr_profile(attrs) do %HrrrProfile{} |> HrrrProfile.changeset(attrs) @@ -429,6 +451,7 @@ defmodule Microwaveprop.Weather do ) end + @spec upsert_hrrr_profiles_batch([map()], keyword()) :: {non_neg_integer(), nil} def upsert_hrrr_profiles_batch(profiles, _opts \\ []) do now = DateTime.truncate(DateTime.utc_now(), :second) @@ -459,6 +482,7 @@ defmodule Microwaveprop.Weather do end) end + @spec has_hrrr_profile?(float(), float(), DateTime.t()) :: boolean() def has_hrrr_profile?(lat, lon, valid_time) do dlat = 0.07 dlon = 0.07 @@ -473,6 +497,7 @@ defmodule Microwaveprop.Weather do |> Repo.exists?() end + @spec hrrr_for_contact(map()) :: %HrrrProfile{} | nil def hrrr_for_contact(%{pos1: nil}), do: nil def hrrr_for_contact(contact) do @@ -484,6 +509,7 @@ defmodule Microwaveprop.Weather do end end + @spec find_nearest_hrrr(float(), float(), DateTime.t()) :: %HrrrProfile{} | nil def find_nearest_hrrr(lat, lon, timestamp) do dlat = 0.07 dlon = 0.07 @@ -513,6 +539,7 @@ defmodule Microwaveprop.Weather do |> Repo.one() end + @spec hrrr_profiles_for_path(map()) :: [%HrrrProfile{}] def hrrr_profiles_for_path(%{pos1: nil}), do: [] def hrrr_profiles_for_path(contact) do @@ -527,11 +554,13 @@ defmodule Microwaveprop.Weather do Tries HRRR first (3 km, hourly), falls back to ERA5 (0.25°, hourly). Returns the profile struct or nil. """ + @spec best_profile_for_contact(map()) :: %HrrrProfile{} | %Era5Profile{} | nil def best_profile_for_contact(contact) do hrrr_for_contact(contact) || era5_for_contact(contact) end @doc "Find all atmospheric profiles along a contact's path, from any source." + @spec profiles_along_path(map()) :: [%HrrrProfile{} | %Era5Profile{}] def profiles_along_path(contact) do hrrr_path = hrrr_profiles_for_path(contact) @@ -542,6 +571,7 @@ defmodule Microwaveprop.Weather do end end + @spec era5_for_contact(map()) :: %Era5Profile{} | nil def era5_for_contact(%{pos1: nil}), do: nil def era5_for_contact(contact) do @@ -553,6 +583,7 @@ defmodule Microwaveprop.Weather do end end + @spec era5_profiles_for_path(map()) :: [%Era5Profile{}] def era5_profiles_for_path(%{pos1: nil}), do: [] def era5_profiles_for_path(contact) do @@ -562,6 +593,7 @@ defmodule Microwaveprop.Weather do |> Enum.reject(&is_nil/1) end + @spec find_nearest_era5(float(), float(), DateTime.t()) :: %Era5Profile{} | nil def find_nearest_era5(lat, lon, timestamp) do dlat = 0.15 dlon = 0.15 @@ -589,6 +621,7 @@ defmodule Microwaveprop.Weather do |> Repo.one() end + @spec find_nearest_rtma(float(), float(), DateTime.t()) :: %RtmaObservation{} | nil def find_nearest_rtma(lat, lon, timestamp) do dlat = 0.05 dlon = 0.05 @@ -618,6 +651,7 @@ defmodule Microwaveprop.Weather do |> Repo.one() end + @spec round_to_hrrr_grid(float(), float()) :: {float(), float()} def round_to_hrrr_grid(lat, lon) do {Float.round(lat / 1.0, 2), Float.round(lon / 1.0, 2)} end @@ -627,6 +661,7 @@ defmodule Microwaveprop.Weather do Preserves QSO-linked profiles which are at arbitrary lat/lon positions. With 19 forecast hours per run, this keeps ~2 runs worth of grid profiles. """ + @spec prune_old_grid_profiles() :: non_neg_integer() def prune_old_grid_profiles do cutoff = DateTime.add(DateTime.utc_now(), -24, :hour) # Bound the scan to recent partitions only (grid profiles are at most ~48h old). @@ -655,10 +690,12 @@ defmodule Microwaveprop.Weather do deleted end + @spec round_to_iemre_grid(float(), float()) :: {float(), float()} def round_to_iemre_grid(lat, lon) do {Float.round(lat * 8) / 8, Float.round(lon * 8) / 8} end + @spec upsert_iemre_observation(map()) :: {:ok, %IemreObservation{}} | {:error, Ecto.Changeset.t()} def upsert_iemre_observation(attrs) do %IemreObservation{} |> IemreObservation.changeset(attrs) @@ -668,12 +705,14 @@ defmodule Microwaveprop.Weather do ) end + @spec has_iemre_observation?(float(), float(), Date.t()) :: boolean() def has_iemre_observation?(lat, lon, date) do IemreObservation |> where([i], i.lat == ^lat and i.lon == ^lon and i.date == ^date) |> Repo.exists?() end + @spec iemre_for_contact(map()) :: %IemreObservation{} | nil def iemre_for_contact(%{pos1: nil}), do: nil def iemre_for_contact(contact) do @@ -685,6 +724,7 @@ defmodule Microwaveprop.Weather do end end + @spec find_nearest_iemre(float(), float(), DateTime.t()) :: %IemreObservation{} | nil def find_nearest_iemre(lat, lon, timestamp) do {rlat, rlon} = round_to_iemre_grid(lat, lon) date = DateTime.to_date(timestamp) @@ -694,6 +734,7 @@ defmodule Microwaveprop.Weather do |> Repo.one() end + @spec iemre_for_path(map()) :: [%IemreObservation{}] def iemre_for_path(%{pos1: nil}), do: [] def iemre_for_path(contact) do @@ -712,6 +753,7 @@ defmodule Microwaveprop.Weather do `:observed_at`, etc. — the same shape regardless of which table the data came from. Returns `nil` if neither source has data. """ + @spec recent_surface_obs(float(), float(), DateTime.t()) :: %Metar5minObservation{} | %SurfaceObservation{} | nil def recent_surface_obs(lat, lon, timestamp) do dlat = 0.5 dlon = 0.5 diff --git a/lib/microwaveprop/weather/era5_batch_client.ex b/lib/microwaveprop/weather/era5_batch_client.ex index 12febd7b..eb713d3a 100644 --- a/lib/microwaveprop/weather/era5_batch_client.ex +++ b/lib/microwaveprop/weather/era5_batch_client.ex @@ -37,6 +37,7 @@ defmodule Microwaveprop.Weather.Era5BatchClient do Returns the integer tile coordinate `{tile_lat, tile_lon}` for a point, where `tile_lat` is the south edge and `tile_lon` is the west edge. """ + @spec tile_for_point(float(), float()) :: {integer(), integer()} def tile_for_point(lat, lon) do {floor_to_tile(lat), floor_to_tile(lon)} end @@ -50,6 +51,7 @@ defmodule Microwaveprop.Weather.Era5BatchClient do end @doc false + @spec tile_degrees() :: pos_integer() def tile_degrees, do: @tile_degrees @doc """ @@ -57,6 +59,12 @@ defmodule Microwaveprop.Weather.Era5BatchClient do resulting hourly profile in `era5_profiles`. Returns `{:ok, inserted_count}` or `{:error, reason}`. """ + @spec fetch_month_into_db(%{ + year: pos_integer(), + month: pos_integer(), + tile_lat: integer(), + tile_lon: integer() + }) :: {:ok, non_neg_integer()} | {:error, term()} def fetch_month_into_db(%{year: year, month: month, tile_lat: tile_lat, tile_lon: tile_lon}) do if month_tile_cached?(year, month, tile_lat, tile_lon) do Logger.info("ERA5Batch: #{year}-#{pad(month)} tile #{tile_lat},#{tile_lon} already cached") diff --git a/lib/microwaveprop/weather/era5_client.ex b/lib/microwaveprop/weather/era5_client.ex index 94c4fcfe..7e3ef0e4 100644 --- a/lib/microwaveprop/weather/era5_client.ex +++ b/lib/microwaveprop/weather/era5_client.ex @@ -32,16 +32,22 @@ defmodule Microwaveprop.Weather.Era5Client do @pressure_level_vars ~w(temperature dewpoint_temperature geopotential) @doc false + @spec pressure_levels() :: [String.t()] def pressure_levels, do: @pressure_levels @doc false + @spec single_level_vars() :: [String.t()] def single_level_vars, do: @single_level_vars @doc false + @spec pressure_level_vars() :: [String.t()] def pressure_level_vars, do: @pressure_level_vars @doc false + @spec cds_url() :: String.t() def cds_url, do: @cds_url @doc false + @spec poll_interval_ms() :: pos_integer() def poll_interval_ms, do: @poll_interval_ms @doc false + @spec max_poll_attempts() :: pos_integer() def max_poll_attempts, do: @max_poll_attempts @doc """ @@ -50,6 +56,7 @@ defmodule Microwaveprop.Weather.Era5Client do The profile_attrs map has the same shape as HRRR profiles for interoperability. """ + @spec fetch_profile(float(), float(), DateTime.t()) :: {:ok, map()} | {:error, term()} def fetch_profile(lat, lon, timestamp) do # Round to ERA5 grid (0.25°) rlat = Float.round(lat * 4) / 4 @@ -71,6 +78,7 @@ defmodule Microwaveprop.Weather.Era5Client do @doc """ Fetch ERA5 single-level (surface) data for a time and area. """ + @spec fetch_single_levels(DateTime.t(), [number()]) :: {:ok, binary()} | {:error, term()} def fetch_single_levels(valid_time, area) do request = %{ "product_type" => ["reanalysis"], @@ -89,6 +97,7 @@ defmodule Microwaveprop.Weather.Era5Client do @doc """ Fetch ERA5 pressure-level data for a time and area. """ + @spec fetch_pressure_levels(DateTime.t(), [number()]) :: {:ok, binary()} | {:error, term()} def fetch_pressure_levels(valid_time, area) do request = %{ "product_type" => ["reanalysis"], @@ -106,6 +115,7 @@ defmodule Microwaveprop.Weather.Era5Client do end @doc false + @spec submit_and_download(String.t(), map()) :: {:ok, binary()} | {:error, term()} def submit_and_download(dataset, request) do do_submit_and_download(dataset, request) end diff --git a/lib/microwaveprop/weather/era5_profile.ex b/lib/microwaveprop/weather/era5_profile.ex index e7681dff..35c56ee0 100644 --- a/lib/microwaveprop/weather/era5_profile.ex +++ b/lib/microwaveprop/weather/era5_profile.ex @@ -25,9 +25,12 @@ defmodule Microwaveprop.Weather.Era5Profile do timestamps(type: :utc_datetime) end + @type t :: %__MODULE__{} + @required_fields ~w(valid_time lat lon)a @optional_fields ~w(profile hpbl_m pwat_mm surface_temp_c surface_dewpoint_c surface_pressure_mb surface_refractivity min_refractivity_gradient ducting_detected duct_characteristics)a + @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(profile, attrs) do profile |> cast(attrs, @required_fields ++ @optional_fields) diff --git a/lib/microwaveprop/weather/frontal_analysis.ex b/lib/microwaveprop/weather/frontal_analysis.ex index ef9307de..dd255161 100644 --- a/lib/microwaveprop/weather/frontal_analysis.ex +++ b/lib/microwaveprop/weather/frontal_analysis.ex @@ -40,6 +40,12 @@ defmodule Microwaveprop.Weather.FrontalAnalysis do where `front_points` is a list of the strongest frontal pixels for easy lookup. """ + @spec detect_fronts(Nx.Tensor.t(), Nx.Tensor.t(), map()) :: %{ + tfp: Nx.Tensor.t(), + mask: Nx.Tensor.t(), + bearing_deg: Nx.Tensor.t(), + front_points: [{float(), float(), float()}] + } def detect_fronts(temp_grid, pressure_grid, grid_spec) do # Potential temperature: θ = T * (100000/P)^0.286 theta = Nx.multiply(temp_grid, Nx.pow(Nx.divide(100_000.0, pressure_grid), 0.286)) @@ -88,6 +94,8 @@ defmodule Microwaveprop.Weather.FrontalAnalysis do @doc """ Compute distance (km) and angle to the nearest front point from a given lat/lon. """ + @spec nearest_front([{float(), float(), float()}], float(), float()) :: + %{distance_km: float(), front_bearing_deg: float(), front_lat: float(), front_lon: float()} | nil def nearest_front(front_points, lat, lon) when is_list(front_points) do if front_points == [] do nil @@ -116,6 +124,7 @@ defmodule Microwaveprop.Weather.FrontalAnalysis do @doc """ Angle between a QSO path and the nearest front (0° = parallel, 90° = crosses). """ + @spec path_front_angle(float(), float()) :: float() def path_front_angle(path_bearing_deg, front_bearing_deg) do diff = abs(path_bearing_deg - front_bearing_deg) diff = if diff > 180, do: 360 - diff, else: diff diff --git a/lib/microwaveprop/weather/grib2/complex_packing.ex b/lib/microwaveprop/weather/grib2/complex_packing.ex index 85eb386e..2d71d639 100644 --- a/lib/microwaveprop/weather/grib2/complex_packing.ex +++ b/lib/microwaveprop/weather/grib2/complex_packing.ex @@ -8,6 +8,7 @@ defmodule Microwaveprop.Weather.Grib2.ComplexPacking do Decodes all values since spatial differencing requires sequential access, then returns the value at the target index. """ + @spec extract_value(map(), binary(), non_neg_integer()) :: {:ok, float()} | {:error, term()} def extract_value(params, data, index) do if index < 0 or index >= params.num_data_points do {:error, :index_out_of_range} @@ -27,6 +28,8 @@ defmodule Microwaveprop.Weather.Grib2.ComplexPacking do Returns `{:ok, %{index => value}}`. """ + @spec extract_values(map(), binary(), [non_neg_integer()]) :: + {:ok, %{non_neg_integer() => float()}} | {:error, term()} def extract_values(params, data, indices) do case decode_all(params, data) do {:ok, array} -> @@ -43,6 +46,7 @@ defmodule Microwaveprop.Weather.Grib2.ComplexPacking do Returns an Erlang array of floats for O(1) index access. """ + @spec decode_all(map(), binary()) :: {:ok, :array.array(float())} | {:error, String.t()} def decode_all(params, data) do %{ reference_value: ref, diff --git a/lib/microwaveprop/weather/grib2/extractor.ex b/lib/microwaveprop/weather/grib2/extractor.ex index b0a9b3e8..bd0a265a 100644 --- a/lib/microwaveprop/weather/grib2/extractor.ex +++ b/lib/microwaveprop/weather/grib2/extractor.ex @@ -11,6 +11,7 @@ defmodule Microwaveprop.Weather.Grib2.Extractor do Returns `{:ok, %{"VAR:LEVEL" => float}}` or `{:error, term}`. """ + @spec extract_points(binary(), float(), float()) :: {:ok, %{String.t() => float()}} | {:error, term()} def extract_points(binary, lat, lon) do messages = split_messages(binary) @@ -33,6 +34,8 @@ defmodule Microwaveprop.Weather.Grib2.Extractor do Takes a binary and a list of `{lat, lon}` tuples. Returns `{:ok, %{{lat, lon} => %{"VAR:LEVEL" => float}}}` or `{:error, term}`. """ + @spec extract_grid(binary(), [{float(), float()}]) :: + {:ok, %{{float(), float()} => %{String.t() => float()}}} | {:error, term()} def extract_grid(binary, points) do messages = split_messages(binary) @@ -83,6 +86,7 @@ defmodule Microwaveprop.Weather.Grib2.Extractor do Split a binary blob into individual GRIB2 messages by scanning for "GRIB" magic bytes and reading the total length from the indicator section. """ + @spec split_messages(binary()) :: [binary()] def split_messages(binary), do: split_messages(binary, []) @doc """ @@ -90,6 +94,8 @@ defmodule Microwaveprop.Weather.Grib2.Extractor do For HRRR scan_mode=64 (j positive, i positive, i consecutive): index = j * nx + i """ + @spec linear_index({non_neg_integer(), non_neg_integer()}, pos_integer(), non_neg_integer()) :: + non_neg_integer() def linear_index({i, j}, nx, _scan_mode) do j * nx + i end diff --git a/lib/microwaveprop/weather/grib2/lambert_conformal.ex b/lib/microwaveprop/weather/grib2/lambert_conformal.ex index 633ad995..7f48c68f 100644 --- a/lib/microwaveprop/weather/grib2/lambert_conformal.ex +++ b/lib/microwaveprop/weather/grib2/lambert_conformal.ex @@ -8,6 +8,8 @@ defmodule Microwaveprop.Weather.Grib2.LambertConformal do Returns `{:ok, {i, j}}` or `{:error, :outside_grid}`. """ + @spec to_grid_index(map(), float(), float()) :: + {:ok, {non_neg_integer(), non_neg_integer()}} | {:error, :outside_grid} def to_grid_index(grid, lat, lon) do %{ nx: nx, diff --git a/lib/microwaveprop/weather/grib2/section.ex b/lib/microwaveprop/weather/grib2/section.ex index 11171cef..96e35718 100644 --- a/lib/microwaveprop/weather/grib2/section.ex +++ b/lib/microwaveprop/weather/grib2/section.ex @@ -6,6 +6,16 @@ defmodule Microwaveprop.Weather.Grib2.Section do Returns `{:ok, %{grid_params, product, packing_params, data, bitmap}}`. """ + @spec parse_message(binary()) :: + {:ok, + %{ + grid_params: map(), + product: %{var: String.t(), level: String.t()}, + packing_params: map(), + data: binary(), + bitmap: :none | :present + }} + | {:error, String.t()} def parse_message(<<"GRIB", _reserved::16, _discipline::8, 2::8, total_length::64-big, rest::binary>> = msg) do discipline = :binary.at(msg, 6) # Skip indicator section (16 bytes), parse remaining sections @@ -22,6 +32,7 @@ defmodule Microwaveprop.Weather.Grib2.Section do @doc """ Identify variable name from discipline, parameter category, and parameter number. """ + @spec identify_var(non_neg_integer(), non_neg_integer(), non_neg_integer()) :: String.t() def identify_var(0, 0, 0), do: "TMP" def identify_var(0, 0, 6), do: "DPT" def identify_var(0, 3, 0), do: "PRES" @@ -39,6 +50,7 @@ defmodule Microwaveprop.Weather.Grib2.Section do @doc """ Identify level string from surface type and scaled value. """ + @spec identify_level(non_neg_integer(), non_neg_integer()) :: String.t() def identify_level(1, _value), do: "surface" def identify_level(100, value_pa), do: "#{div(value_pa, 100)} mb" def identify_level(103, value_m), do: "#{value_m} m above ground" @@ -52,6 +64,7 @@ defmodule Microwaveprop.Weather.Grib2.Section do @doc """ Decode a 16-bit sign-magnitude integer. """ + @spec sign_magnitude_16(<<_::16>>) :: integer() def sign_magnitude_16(<<0::1, value::15>>), do: value def sign_magnitude_16(<<1::1, value::15>>), do: -value diff --git a/lib/microwaveprop/weather/grib2/simple_packing.ex b/lib/microwaveprop/weather/grib2/simple_packing.ex index fa197d2d..505794f5 100644 --- a/lib/microwaveprop/weather/grib2/simple_packing.ex +++ b/lib/microwaveprop/weather/grib2/simple_packing.ex @@ -9,6 +9,7 @@ defmodule Microwaveprop.Weather.Grib2.SimplePacking do Where R = reference_value, E = binary_scale, D = decimal_scale, X = raw packed integer at the given index. """ + @spec extract_value(map(), binary(), non_neg_integer()) :: {:ok, float()} | {:error, :index_out_of_range} def extract_value(%{bits_per_value: 0} = params, _data, _index) do {:ok, params.reference_value * :math.pow(10, -params.decimal_scale)} end @@ -36,6 +37,7 @@ defmodule Microwaveprop.Weather.Grib2.SimplePacking do Returns `{:ok, %{index => value}}`. Out-of-range indices are silently skipped. """ + @spec extract_values(map(), binary(), [non_neg_integer()]) :: {:ok, %{non_neg_integer() => float()}} def extract_values(%{bits_per_value: 0} = params, _data, indices) do value = params.reference_value * :math.pow(10, -params.decimal_scale) {:ok, Map.new(indices, fn i -> {i, value} end)} diff --git a/lib/microwaveprop/weather/grib2/wgrib2.ex b/lib/microwaveprop/weather/grib2/wgrib2.ex index e1d8af4d..572d8c12 100644 --- a/lib/microwaveprop/weather/grib2/wgrib2.ex +++ b/lib/microwaveprop/weather/grib2/wgrib2.ex @@ -9,6 +9,17 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do require Logger + @typep grid_spec :: %{ + lon_start: float(), + lon_count: pos_integer(), + lon_step: float(), + lat_start: float(), + lat_count: pos_integer(), + lat_step: float() + } + + @typep point_grid :: %{{float(), float()} => %{String.t() => float()}} + @undefined_value 9.999e20 @doc """ @@ -19,6 +30,7 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do Returns `{:ok, %{{lat, lon} => %{"VAR:LEVEL" => float}}}` or `{:error, reason}`. """ + @spec extract_grid(binary(), String.t(), grid_spec()) :: {:ok, point_grid()} | {:error, term()} def extract_grid(grib_binary, match_pattern, grid_spec) do if available?() do extract_with_wgrib2(grib_binary, match_pattern, grid_spec) @@ -32,6 +44,8 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do of a binary. Avoids loading the entire file into memory — useful for large native-level HRRR files (~530 MB). """ + @spec extract_grid_from_file(Path.t(), String.t(), grid_spec()) :: + {:ok, point_grid()} | {:error, term()} def extract_grid_from_file(grib_path, match_pattern, grid_spec) do if available?() do extract_file_with_wgrib2(grib_path, match_pattern, grid_spec) @@ -52,6 +66,12 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do Peak memory: binary output (~76 MB for 200 msgs × 95k cells) + one cell's worth of data at a time + output map (~10 MB of scalars). """ + @spec extract_grid_from_file_mapped( + Path.t(), + String.t(), + grid_spec(), + (%{String.t() => float()} -> term()) + ) :: {:ok, %{{float(), float()} => term()}} | {:error, term()} def extract_grid_from_file_mapped(grib_path, match_pattern, grid_spec, cell_reducer) do if available?() do extract_file_mapped_with_wgrib2(grib_path, match_pattern, grid_spec, cell_reducer) @@ -61,6 +81,7 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do end @doc "Check if wgrib2 is available on the system." + @spec available?() :: boolean() def available?, do: wgrib2_path() != nil defp wgrib2_path, do: System.find_executable("wgrib2") @@ -312,6 +333,17 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do Each entry is `%{datetime: DateTime.t() | nil, var: String.t(), level: String.t(), values: %{{lat, lon} => float}}`. """ + @spec extract_grid_messages(binary(), String.t(), grid_spec()) :: + {:ok, + [ + %{ + datetime: DateTime.t() | nil, + var: String.t(), + level: String.t(), + values: %{{float(), float()} => float()} + } + ]} + | {:error, term()} def extract_grid_messages(grib_binary, match_pattern, grid_spec) do if available?() do extract_messages_with_wgrib2(grib_binary, match_pattern, grid_spec) @@ -475,6 +507,8 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do Returns `{:ok, %{{lat, lon} => %{"VAR:LEVEL" => float}}}` or `{:error, reason}`. """ + @spec extract_points_from_file(Path.t(), String.t(), [{float(), float()}]) :: + {:ok, point_grid()} | {:error, term()} def extract_points_from_file(grib_path, match_pattern, points) when is_list(points) do if available?() do extract_points_with_wgrib2(grib_path, match_pattern, points) diff --git a/lib/microwaveprop/weather/hrrr_client.ex b/lib/microwaveprop/weather/hrrr_client.ex index 8de3e9c0..d5463dd6 100644 --- a/lib/microwaveprop/weather/hrrr_client.ex +++ b/lib/microwaveprop/weather/hrrr_client.ex @@ -43,8 +43,11 @@ defmodule Microwaveprop.Weather.HrrrClient do # --- Public API --- + @spec surface_messages() :: [%{var: String.t(), level: String.t()}] def surface_messages, do: @surface_messages + @spec fetch_grid([{float(), float()}], DateTime.t(), keyword()) :: + {:ok, %{{float(), float()} => map()}} | {:error, term()} def fetch_grid(points, run_time, opts \\ []) do hour_dt = nearest_hrrr_hour(run_time) date = DateTime.to_date(hour_dt) @@ -77,6 +80,7 @@ defmodule Microwaveprop.Weather.HrrrClient do end end + @spec fetch_profile(float(), float(), DateTime.t()) :: {:ok, map()} | {:error, term()} def fetch_profile(lat, lon, valid_time) do hour_dt = nearest_hrrr_hour(valid_time) date = DateTime.to_date(hour_dt) @@ -103,6 +107,7 @@ defmodule Microwaveprop.Weather.HrrrClient do end end + @spec nearest_hrrr_hour(DateTime.t()) :: DateTime.t() def nearest_hrrr_hour(dt) do total_seconds = dt.minute * 60 + dt.second rounded_dt = DateTime.add(dt, -total_seconds, :second) @@ -114,6 +119,7 @@ defmodule Microwaveprop.Weather.HrrrClient do end end + @spec hrrr_url(Date.t(), non_neg_integer(), :surface | :pressure, non_neg_integer()) :: String.t() def hrrr_url(date, hour, product, forecast_hour \\ 0) do date_str = Calendar.strftime(date, "%Y%m%d") hour_str = hour |> Integer.to_string() |> String.pad_leading(2, "0") @@ -128,6 +134,7 @@ defmodule Microwaveprop.Weather.HrrrClient do "#{hrrr_base()}/hrrr.#{date_str}/conus/#{file}" end + @spec parse_idx(String.t()) :: [%{msg: integer(), offset: integer(), var: String.t(), level: String.t()}] def parse_idx(text) do text |> String.split("\n") @@ -144,6 +151,9 @@ defmodule Microwaveprop.Weather.HrrrClient do end) end + @spec byte_ranges_for_messages([map()], [%{var: String.t(), level: String.t()}]) :: [ + {non_neg_integer(), non_neg_integer()} + ] def byte_ranges_for_messages(idx_entries, wanted) do Enum.flat_map(wanted, fn w -> idx_entries @@ -164,12 +174,14 @@ defmodule Microwaveprop.Weather.HrrrClient do end) end + @spec merge_grid_data(map(), map()) :: map() def merge_grid_data(sfc_grid, prs_grid) do Map.merge(sfc_grid, prs_grid, fn _point, sfc_data, prs_data -> Map.merge(sfc_data, prs_data) end) end + @spec build_profile(map()) :: map() def build_profile(parsed) do sfc_temp_k = parsed["TMP:2 m above ground"] sfc_dpt_k = parsed["DPT:2 m above ground"] @@ -319,6 +331,8 @@ defmodule Microwaveprop.Weather.HrrrClient do Public so the native-level client can reuse it rather than duplicating the HTTP/parallelism/cache logic. """ + @spec download_grib_ranges(String.t(), [{non_neg_integer(), non_neg_integer()}]) :: + {:ok, binary()} | {:error, term()} def download_grib_ranges(_url, []), do: {:ok, <<>>} def download_grib_ranges(url, ranges) do @@ -349,6 +363,8 @@ defmodule Microwaveprop.Weather.HrrrClient do Returns `:ok` or `{:error, reason}`. """ + @spec download_grib_ranges_to_file(String.t(), [{non_neg_integer(), non_neg_integer()}], Path.t()) :: + :ok | {:error, term()} def download_grib_ranges_to_file(_url, [], _dest_path), do: :ok def download_grib_ranges_to_file(url, ranges, dest_path) do diff --git a/lib/microwaveprop/weather/hrrr_climatology.ex b/lib/microwaveprop/weather/hrrr_climatology.ex index e3b8ed95..9cae7949 100644 --- a/lib/microwaveprop/weather/hrrr_climatology.ex +++ b/lib/microwaveprop/weather/hrrr_climatology.ex @@ -27,6 +27,7 @@ defmodule Microwaveprop.Weather.HrrrClimatology do field :sample_count, :integer end + @spec changeset(%__MODULE__{}, map()) :: Ecto.Changeset.t() def changeset(record, attrs) do record |> cast(attrs, [:lat, :lon, :month, :hour, :mean_surface_temp_c, :stddev_surface_temp_c, :sample_count]) diff --git a/lib/microwaveprop/weather/hrrr_native_client.ex b/lib/microwaveprop/weather/hrrr_native_client.ex index 8d087f4d..b3efd94a 100644 --- a/lib/microwaveprop/weather/hrrr_native_client.ex +++ b/lib/microwaveprop/weather/hrrr_native_client.ex @@ -34,9 +34,11 @@ defmodule Microwaveprop.Weather.HrrrNativeClient do defp hrrr_base, do: Application.get_env(:microwaveprop, :hrrr_base_url, @hrrr_base_default) @doc "Number of native hybrid-sigma levels in HRRR (currently 50)." + @spec native_level_count() :: pos_integer() def native_level_count, do: Enum.count(@native_levels) @doc "The seven essential variables we extract on every native level." + @spec native_variables() :: [String.t()] def native_variables, do: @native_variables @doc """ @@ -44,6 +46,7 @@ defmodule Microwaveprop.Weather.HrrrNativeClient do file. 7 vars × 50 levels = 350 messages, matching the spike in Task 1.1. """ + @spec native_messages() :: [%{var: String.t(), level: String.t()}] def native_messages do for level <- @native_levels, var <- @native_variables do %{var: var, level: "#{level} hybrid level"} @@ -58,6 +61,7 @@ defmodule Microwaveprop.Weather.HrrrNativeClient do iex> Microwaveprop.Weather.HrrrNativeClient.hrrr_native_url(~D[2026-04-09], 12) "https://noaa-hrrr-bdp-pds.s3.amazonaws.com/hrrr.20260409/conus/hrrr.t12z.wrfnatf00.grib2" """ + @spec hrrr_native_url(Date.t(), non_neg_integer(), non_neg_integer()) :: String.t() def hrrr_native_url(date, hour, forecast_hour \\ 0) do date_str = Calendar.strftime(date, "%Y%m%d") hour_str = hour |> Integer.to_string() |> String.pad_leading(2, "0") @@ -75,6 +79,7 @@ defmodule Microwaveprop.Weather.HrrrNativeClient do lets us unit-test every invariant we care about (array lengths, ordering, surface scalar caching) without touching the network. """ + @spec build_native_profile(map()) :: map() def build_native_profile(parsed) when is_map(parsed) do levels = @native_levels @@ -121,6 +126,7 @@ defmodule Microwaveprop.Weather.HrrrNativeClient do Wraps `HrrrClient.byte_ranges_for_messages/2` with our native message list so callers don't have to know both. """ + @spec essential_byte_ranges([map()]) :: [{non_neg_integer(), non_neg_integer()}] def essential_byte_ranges(idx_entries) do HrrrClient.byte_ranges_for_messages(idx_entries, native_messages()) end @@ -133,6 +139,7 @@ defmodule Microwaveprop.Weather.HrrrNativeClient do Messages needed for duct detection only (4 vars × 50 levels = 200 messages). ~300 MB download vs ~530 MB for all 7 variables. """ + @spec duct_messages() :: [%{var: String.t(), level: String.t()}] def duct_messages do for level <- @native_levels, var <- @duct_variables do %{var: var, level: "#{level} hybrid level"} @@ -142,6 +149,7 @@ defmodule Microwaveprop.Weather.HrrrNativeClient do @doc """ Byte ranges for duct-detection variables only. """ + @spec duct_byte_ranges([map()]) :: [{non_neg_integer(), non_neg_integer()}] def duct_byte_ranges(idx_entries) do HrrrClient.byte_ranges_for_messages(idx_entries, duct_messages()) end @@ -155,6 +163,8 @@ defmodule Microwaveprop.Weather.HrrrNativeClient do Returns `{:ok, %{{lat, lon} => duct_metrics}}` or `{:error, reason}`. """ + @spec fetch_native_duct_grid(Date.t(), non_neg_integer(), map(), non_neg_integer()) :: + {:ok, %{{float(), float()} => map()}} | {:error, term()} def fetch_native_duct_grid(date, hour, grid_spec, forecast_hour \\ 0) do alias Microwaveprop.Weather.Grib2.Wgrib2 @@ -252,6 +262,8 @@ defmodule Microwaveprop.Weather.HrrrNativeClient do Returns `{:ok, %{{lat, lon} => native_profile_map}}` or `{:error, reason}`. """ + @spec extract_native_profiles_from_file(Path.t(), [{float(), float()}]) :: + {:ok, %{{float(), float()} => map()}} | {:error, term()} def extract_native_profiles_from_file(grib_path, points) when is_list(points) do alias Microwaveprop.Weather.Grib2.Wgrib2 @@ -289,6 +301,8 @@ defmodule Microwaveprop.Weather.HrrrNativeClient do Returns `%{{lat, lon} => native_profile_map}` where each profile map has the shape expected by `HrrrNativeProfile.changeset/2`. """ + @spec extract_native_profiles(binary(), [{float(), float()}]) :: + {:ok, %{{float(), float()} => map()}} | {:error, term()} def extract_native_profiles(grib_binary, points) when is_list(points) do alias Microwaveprop.Weather.Grib2.Wgrib2 diff --git a/lib/microwaveprop/weather/hrrr_native_profile.ex b/lib/microwaveprop/weather/hrrr_native_profile.ex index 1bd0b20d..f52e7eb3 100644 --- a/lib/microwaveprop/weather/hrrr_native_profile.ex +++ b/lib/microwaveprop/weather/hrrr_native_profile.ex @@ -54,6 +54,8 @@ defmodule Microwaveprop.Weather.HrrrNativeProfile do timestamps(type: :utc_datetime) end + @type t :: %__MODULE__{} + @required ~w(valid_time lat lon)a @optional ~w( run_time level_count heights_m temp_k spfh pressure_pa @@ -63,6 +65,7 @@ defmodule Microwaveprop.Weather.HrrrNativeProfile do ducts best_duct_band_ghz )a + @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(profile, attrs) do profile |> cast(attrs, @required ++ @optional) diff --git a/lib/microwaveprop/weather/hrrr_profile.ex b/lib/microwaveprop/weather/hrrr_profile.ex index cfb05b28..a9feb195 100644 --- a/lib/microwaveprop/weather/hrrr_profile.ex +++ b/lib/microwaveprop/weather/hrrr_profile.ex @@ -27,9 +27,12 @@ defmodule Microwaveprop.Weather.HrrrProfile do timestamps(type: :utc_datetime) end + @type t :: %__MODULE__{} + @required_fields ~w(valid_time lat lon)a @optional_fields ~w(run_time profile hpbl_m pwat_mm surface_temp_c surface_dewpoint_c surface_pressure_mb surface_refractivity min_refractivity_gradient ducting_detected duct_characteristics is_grid_point)a + @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(hrrr_profile, attrs) do hrrr_profile |> cast(attrs, @required_fields ++ @optional_fields) diff --git a/lib/microwaveprop/weather/iem_client.ex b/lib/microwaveprop/weather/iem_client.ex index 0c6f2a01..120d2791 100644 --- a/lib/microwaveprop/weather/iem_client.ex +++ b/lib/microwaveprop/weather/iem_client.ex @@ -5,10 +5,12 @@ defmodule Microwaveprop.Weather.IemClient do # --- URL builders --- + @spec network_url(String.t()) :: String.t() def network_url(network) do "#{@iem_base}/json/network.py?network=#{network}" end + @spec asos_url(String.t(), DateTime.t(), DateTime.t()) :: String.t() def asos_url(station_id, start_dt, end_dt) do "#{@iem_base}/cgi-bin/request/asos.py" <> "?station=#{station_id}" <> @@ -21,17 +23,20 @@ defmodule Microwaveprop.Weather.IemClient do "&format=onlycomma&latlon=no&elev=no&missing=null&trace=null&direct=no&report_type=3" end + @spec raob_url(String.t(), DateTime.t()) :: String.t() def raob_url(station_id, dt) do ts = format_iem_ts(dt) "#{@iem_base}/json/raob.py?ts=#{ts}&station=#{station_id}" end + @spec iemre_url(float(), float(), Date.t()) :: String.t() def iemre_url(lat, lon, date) do "#{@iem_base}/iemre/hourly/#{date}/#{lat}/#{lon}/json" end # --- HTTP fetchers --- + @spec fetch_network(String.t()) :: {:ok, [map()]} | {:error, term()} def fetch_network(network) do url = network_url(network) @@ -47,6 +52,7 @@ defmodule Microwaveprop.Weather.IemClient do end end + @spec fetch_asos(String.t(), DateTime.t(), DateTime.t()) :: {:ok, [map()]} | {:error, term()} def fetch_asos(station_id, start_dt, end_dt) do url = asos_url(station_id, start_dt, end_dt) @@ -62,6 +68,7 @@ defmodule Microwaveprop.Weather.IemClient do end end + @spec fetch_raob(String.t(), DateTime.t()) :: {:ok, [map()]} | {:error, term()} def fetch_raob(station_id, dt) do url = raob_url(station_id, dt) @@ -77,6 +84,7 @@ defmodule Microwaveprop.Weather.IemClient do end end + @spec fetch_iemre(float(), float(), Date.t()) :: {:ok, [map()]} | {:error, term()} def fetch_iemre(lat, lon, date) do url = iemre_url(lat, lon, date) @@ -116,6 +124,7 @@ defmodule Microwaveprop.Weather.IemClient do Fetch current ASOS observations for all stations in the given state networks. Returns `{:ok, [%{station_code, lat, lon, temp_f, dewpoint_f, ...}]}`. """ + @spec fetch_current_asos([String.t()]) :: {:ok, [map()]} def fetch_current_asos(state_networks) do results = state_networks @@ -175,10 +184,12 @@ defmodule Microwaveprop.Weather.IemClient do # --- Parsers --- + @spec parse_iemre_json(map()) :: [map()] def parse_iemre_json(json) when is_map(json) do Map.get(json, "data", []) end + @spec parse_network_json(map()) :: [map()] def parse_network_json(json) when is_map(json) do json |> Map.get("stations", []) @@ -192,6 +203,7 @@ defmodule Microwaveprop.Weather.IemClient do end) end + @spec parse_asos_csv(String.t()) :: [map()] def parse_asos_csv(csv_text) do csv_text |> String.split("\n") @@ -201,6 +213,7 @@ defmodule Microwaveprop.Weather.IemClient do |> Enum.map(&parse_asos_row/1) end + @spec parse_raob_json(map()) :: [map()] def parse_raob_json(json) when is_map(json) do json |> Map.get("profiles", []) diff --git a/lib/microwaveprop/weather/iemre_observation.ex b/lib/microwaveprop/weather/iemre_observation.ex index 8e9737ce..aeed3030 100644 --- a/lib/microwaveprop/weather/iemre_observation.ex +++ b/lib/microwaveprop/weather/iemre_observation.ex @@ -16,9 +16,12 @@ defmodule Microwaveprop.Weather.IemreObservation do timestamps(type: :utc_datetime) end + @type t :: %__MODULE__{} + @required_fields ~w(lat lon date)a @optional_fields ~w(hourly)a + @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(observation, attrs) do observation |> cast(attrs, @required_fields ++ @optional_fields) diff --git a/lib/microwaveprop/weather/metar_5min_observation.ex b/lib/microwaveprop/weather/metar_5min_observation.ex index bc860978..5303ae88 100644 --- a/lib/microwaveprop/weather/metar_5min_observation.ex +++ b/lib/microwaveprop/weather/metar_5min_observation.ex @@ -30,9 +30,12 @@ defmodule Microwaveprop.Weather.Metar5minObservation do timestamps(type: :utc_datetime) end + @type t :: %__MODULE__{} + @required_fields ~w(station_id observed_at)a @optional_fields ~w(temp_f dewpoint_f relative_humidity wind_speed_kts wind_direction_deg sea_level_pressure_mb altimeter_setting sky_condition precip_1h_in wx_codes)a + @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(observation, attrs) do observation |> cast(attrs, @required_fields ++ @optional_fields) diff --git a/lib/microwaveprop/weather/ncei_metar_client.ex b/lib/microwaveprop/weather/ncei_metar_client.ex index f2eb1002..7eb21039 100644 --- a/lib/microwaveprop/weather/ncei_metar_client.ex +++ b/lib/microwaveprop/weather/ncei_metar_client.ex @@ -21,6 +21,7 @@ defmodule Microwaveprop.Weather.NceiMetarClient do Returns `{:ok, [parsed_obs]}` or `{:error, reason}`. """ + @spec fetch(String.t(), pos_integer(), pos_integer()) :: {:ok, [map()]} | {:error, term()} def fetch(icao, year, month) do month_str = month |> Integer.to_string() |> String.pad_leading(2, "0") url = "#{@base_url}/#{year}/#{month_str}/asos-5min-#{icao}-#{year}#{month_str}.dat" @@ -44,6 +45,7 @@ defmodule Microwaveprop.Weather.NceiMetarClient do the key fields without a full METAR parser — this covers the data the scorer and backtest need (temp, dewpoint, wind, pressure, sky). """ + @spec parse(String.t()) :: [map()] def parse(text) when is_binary(text) do text |> String.split("\n") diff --git a/lib/microwaveprop/weather/nexrad_observation.ex b/lib/microwaveprop/weather/nexrad_observation.ex index 4457c7e6..f59924d2 100644 --- a/lib/microwaveprop/weather/nexrad_observation.ex +++ b/lib/microwaveprop/weather/nexrad_observation.ex @@ -19,9 +19,12 @@ defmodule Microwaveprop.Weather.NexradObservation do timestamps(type: :utc_datetime) end + @type t :: %__MODULE__{} + @required_fields ~w(observed_at lat lon)a @optional_fields ~w(mean_reflectivity_dbz max_reflectivity_dbz texture_variance pixel_count)a + @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(observation, attrs) do observation |> cast(attrs, @required_fields ++ @optional_fields) diff --git a/lib/microwaveprop/weather/rtma_client.ex b/lib/microwaveprop/weather/rtma_client.ex index 47ea65d0..79797e5c 100644 --- a/lib/microwaveprop/weather/rtma_client.ex +++ b/lib/microwaveprop/weather/rtma_client.ex @@ -33,6 +33,7 @@ defmodule Microwaveprop.Weather.RtmaClient do Fetch RTMA observation for a point at a specific time. Returns {:ok, attrs} or {:error, reason}. """ + @spec fetch_observation(float(), float(), DateTime.t()) :: {:ok, map()} | {:error, term()} def fetch_observation(lat, lon, timestamp) do # RTMA runs every hour with 15-min updates; round to nearest hour valid_time = %{DateTime.truncate(timestamp, :second) | minute: 0, second: 0} @@ -63,6 +64,7 @@ defmodule Microwaveprop.Weather.RtmaClient do end @doc "Build S3 URL for an RTMA analysis file." + @spec rtma_url(DateTime.t()) :: String.t() def rtma_url(valid_time) do date_str = Calendar.strftime(valid_time, "%Y%m%d") hour_str = valid_time.hour |> Integer.to_string() |> String.pad_leading(2, "0") diff --git a/lib/microwaveprop/weather/rtma_observation.ex b/lib/microwaveprop/weather/rtma_observation.ex index 53b6bee4..94e458e0 100644 --- a/lib/microwaveprop/weather/rtma_observation.ex +++ b/lib/microwaveprop/weather/rtma_observation.ex @@ -22,9 +22,12 @@ defmodule Microwaveprop.Weather.RtmaObservation do timestamps(type: :utc_datetime) end + @type t :: %__MODULE__{} + @required_fields ~w(valid_time lat lon)a @optional_fields ~w(temp_c dewpoint_c pressure_mb wind_u_ms wind_v_ms visibility_m precip_mm)a + @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(observation, attrs) do observation |> cast(attrs, @required_fields ++ @optional_fields) diff --git a/lib/microwaveprop/weather/solar_client.ex b/lib/microwaveprop/weather/solar_client.ex index 953057d9..d0500f9f 100644 --- a/lib/microwaveprop/weather/solar_client.ex +++ b/lib/microwaveprop/weather/solar_client.ex @@ -3,8 +3,10 @@ defmodule Microwaveprop.Weather.SolarClient do @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, []) @@ -20,10 +22,12 @@ defmodule Microwaveprop.Weather.SolarClient do 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") @@ -39,6 +43,7 @@ defmodule Microwaveprop.Weather.SolarClient do end) end + @spec parse_gfz_row(String.t()) :: {:ok, map()} | :error def parse_gfz_row(line) do fields = String.split(line) diff --git a/lib/microwaveprop/weather/solar_index.ex b/lib/microwaveprop/weather/solar_index.ex index c19cb276..6570c51f 100644 --- a/lib/microwaveprop/weather/solar_index.ex +++ b/lib/microwaveprop/weather/solar_index.ex @@ -17,9 +17,12 @@ defmodule Microwaveprop.Weather.SolarIndex do timestamps(type: :utc_datetime) end + @type t :: %__MODULE__{} + @required_fields ~w(date)a @optional_fields ~w(sfi sfi_adjusted sunspot_number ap_index kp_values)a + @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(solar_index, attrs) do solar_index |> cast(attrs, @required_fields ++ @optional_fields) diff --git a/lib/microwaveprop/weather/sounding.ex b/lib/microwaveprop/weather/sounding.ex index 82899c58..db00d3d9 100644 --- a/lib/microwaveprop/weather/sounding.ex +++ b/lib/microwaveprop/weather/sounding.ex @@ -27,9 +27,12 @@ defmodule Microwaveprop.Weather.Sounding do timestamps(type: :utc_datetime) end + @type t :: %__MODULE__{} + @required_fields ~w(station_id observed_at profile level_count)a @optional_fields ~w(surface_pressure_mb surface_temp_c surface_dewpoint_c surface_refractivity min_refractivity_gradient boundary_layer_depth_m precipitable_water_mm k_index lifted_index ducting_detected duct_characteristics)a + @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(sounding, attrs) do sounding |> cast(attrs, @required_fields ++ @optional_fields) diff --git a/lib/microwaveprop/weather/sounding_params.ex b/lib/microwaveprop/weather/sounding_params.ex index 7fd1e01b..f6242427 100644 --- a/lib/microwaveprop/weather/sounding_params.ex +++ b/lib/microwaveprop/weather/sounding_params.ex @@ -2,11 +2,13 @@ defmodule Microwaveprop.Weather.SoundingParams do @moduledoc false @doc "Buck equation for saturation vapor pressure (hPa) given temperature in °C." + @spec sat_vap_pres(float()) :: float() def sat_vap_pres(t_c) do 6.1121 * :math.exp((18.678 - t_c / 234.5) * (t_c / (257.14 + t_c))) end @doc "Mixing ratio (g/kg) from dewpoint (°C) and pressure (hPa)." + @spec mixing_ratio(float(), float()) :: float() def mixing_ratio(t_c, p_mb) do e = sat_vap_pres(t_c) 622.0 * e / (p_mb - e) @@ -18,6 +20,7 @@ defmodule Microwaveprop.Weather.SoundingParams do Profile is a list of maps with keys: "pres", "hght", "tmpc", "dwpc", "drct", "sknt". Returns nil if fewer than 3 valid levels. """ + @spec derive([map()]) :: map() | nil def derive(profile) when is_list(profile) do sorted = profile diff --git a/lib/microwaveprop/weather/station.ex b/lib/microwaveprop/weather/station.ex index 9491bdb4..efb2664a 100644 --- a/lib/microwaveprop/weather/station.ex +++ b/lib/microwaveprop/weather/station.ex @@ -20,9 +20,12 @@ defmodule Microwaveprop.Weather.Station do timestamps(type: :utc_datetime) end + @type t :: %__MODULE__{} + @required_fields ~w(station_code station_type name lat lon)a @optional_fields ~w(wmo_number elevation_m state)a + @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(station, attrs) do station |> cast(attrs, @required_fields ++ @optional_fields) diff --git a/lib/microwaveprop/weather/surface_observation.ex b/lib/microwaveprop/weather/surface_observation.ex index ce5e3025..50577aa1 100644 --- a/lib/microwaveprop/weather/surface_observation.ex +++ b/lib/microwaveprop/weather/surface_observation.ex @@ -24,9 +24,12 @@ defmodule Microwaveprop.Weather.SurfaceObservation do timestamps(type: :utc_datetime) end + @type t :: %__MODULE__{} + @required_fields ~w(station_id observed_at)a @optional_fields ~w(temp_f dewpoint_f relative_humidity wind_speed_kts wind_direction_deg sea_level_pressure_mb altimeter_setting sky_condition precip_1h_in wx_codes)a + @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(observation, attrs) do observation |> cast(attrs, @required_fields ++ @optional_fields) diff --git a/lib/microwaveprop/weather/weather_layers.ex b/lib/microwaveprop/weather/weather_layers.ex index 6325114c..d1a676a5 100644 --- a/lib/microwaveprop/weather/weather_layers.ex +++ b/lib/microwaveprop/weather/weather_layers.ex @@ -13,6 +13,7 @@ defmodule Microwaveprop.Weather.WeatherLayers do `:temp_850mb`, `:dewpoint_850mb`, `:lapse_rate`, `:inversion_strength`, `:inversion_base_m`, `:duct_base_m`, `:duct_strength`. """ + @spec derive(map()) :: map() def derive(row) do sorted = sort_profile(row.profile)