Add dialyzer specs and types across the codebase

277 @spec/@type annotations added to 58 files covering all public
APIs: contexts (propagation, radio, weather, terrain, beacons,
commercial), GRIB2 decoders, terrain analysis, duct detection,
rain scatter, CSV/ADIF import, weather clients, and all Ecto
schemas. Dialyzer passes with 0 errors.
This commit is contained in:
Graham McIntire 2026-04-12 08:54:49 -05:00
parent 7a31d730b4
commit 51e390959c
58 changed files with 510 additions and 0 deletions

View file

@ -21,6 +21,8 @@ defmodule Microwaveprop.Accounts.User do
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@type t :: %__MODULE__{}
@doc """ @doc """
Returns the email address that is automatically granted admin Returns the email address that is automatically granted admin
privileges on registration. privileges on registration.

View file

@ -16,6 +16,7 @@ defmodule Microwaveprop.BeaconMonitors do
@doc """ @doc """
Returns all monitors for the given user, newest first. 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 def list_monitors_for_user(%User{id: user_id}) do
Repo.all( Repo.all(
from m in BeaconMonitor, from m in BeaconMonitor,
@ -27,6 +28,7 @@ defmodule Microwaveprop.BeaconMonitors do
@doc """ @doc """
Creates a new monitor for the given user with a freshly generated token. 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 def create_monitor(%User{} = user, attrs) do
%BeaconMonitor{user_id: user.id, token: generate_token()} %BeaconMonitor{user_id: user.id, token: generate_token()}
|> BeaconMonitor.changeset(attrs) |> BeaconMonitor.changeset(attrs)
@ -39,6 +41,8 @@ defmodule Microwaveprop.BeaconMonitors do
Returns `{:error, :not_found}` if the monitor does not exist or Returns `{:error, :not_found}` if the monitor does not exist or
belongs to another user. 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 def delete_monitor(%User{id: user_id}, monitor_id) do
query = query =
from m in BeaconMonitor, from m in BeaconMonitor,
@ -55,6 +59,7 @@ defmodule Microwaveprop.BeaconMonitors do
@doc """ @doc """
Looks up a monitor by its token. Returns nil if not found. 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 def get_monitor_by_token(token) when is_binary(token) do
Repo.get_by(BeaconMonitor, token: token) Repo.get_by(BeaconMonitor, token: token)
end end
@ -62,6 +67,7 @@ defmodule Microwaveprop.BeaconMonitors do
@doc """ @doc """
Returns a blank changeset for rendering the new-monitor form. Returns a blank changeset for rendering the new-monitor form.
""" """
@spec change_monitor(map()) :: Ecto.Changeset.t()
def change_monitor(attrs \\ %{}) do def change_monitor(attrs \\ %{}) do
BeaconMonitor.changeset(%BeaconMonitor{}, attrs) BeaconMonitor.changeset(%BeaconMonitor{}, attrs)
end end

View file

@ -23,10 +23,13 @@ defmodule Microwaveprop.BeaconMonitors.BeaconMonitor do
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@type t :: %__MODULE__{}
@doc """ @doc """
Changeset for user-controlled fields (name only for now). Changeset for user-controlled fields (name only for now).
Token and user_id are assigned by the context, not cast from user input. 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 def changeset(monitor, attrs) do
monitor monitor
|> cast(attrs, [:name]) |> cast(attrs, [:name])

View file

@ -20,6 +20,7 @@ defmodule Microwaveprop.Beacons do
* `{:updated, %Beacon{}}` * `{:updated, %Beacon{}}`
* `{:deleted, %Beacon{}}` * `{:deleted, %Beacon{}}`
""" """
@spec subscribe_beacons() :: :ok | {:error, term()}
def subscribe_beacons do def subscribe_beacons do
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, @topic) Phoenix.PubSub.subscribe(Microwaveprop.PubSub, @topic)
end end
@ -29,6 +30,7 @@ defmodule Microwaveprop.Beacons do
end end
@doc "Returns approved beacons ordered by most recently added." @doc "Returns approved beacons ordered by most recently added."
@spec list_beacons() :: [Beacon.t()]
def list_beacons do def list_beacons do
Repo.all( Repo.all(
from b in Beacon, from b in Beacon,
@ -38,6 +40,7 @@ defmodule Microwaveprop.Beacons do
end end
@doc "Returns unapproved beacons awaiting admin review." @doc "Returns unapproved beacons awaiting admin review."
@spec list_pending_beacons() :: [Beacon.t()]
def list_pending_beacons do def list_pending_beacons do
Repo.all( Repo.all(
from b in Beacon, from b in Beacon,
@ -47,12 +50,14 @@ defmodule Microwaveprop.Beacons do
end end
@doc "Gets a single beacon. Raises if not found." @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) def get_beacon!(id), do: Beacon |> Repo.get!(id) |> Repo.preload(:user)
@doc """ @doc """
Creates a beacon. When a user is provided they are recorded as the Creates a beacon. When a user is provided they are recorded as the
creator; anonymous submissions pass `nil` and leave `user_id` unset. 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, attrs)
def create_beacon(%User{} = user, attrs) do def create_beacon(%User{} = user, attrs) do
@ -70,6 +75,7 @@ defmodule Microwaveprop.Beacons do
end end
@doc "Updates a beacon." @doc "Updates a beacon."
@spec update_beacon(Beacon.t(), map()) :: {:ok, Beacon.t()} | {:error, Ecto.Changeset.t()}
def update_beacon(%Beacon{} = beacon, attrs) do def update_beacon(%Beacon{} = beacon, attrs) do
beacon beacon
|> Beacon.changeset(attrs) |> Beacon.changeset(attrs)
@ -78,6 +84,7 @@ defmodule Microwaveprop.Beacons do
end end
@doc "Marks a beacon as approved, making it visible in the public list." @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 def approve_beacon(%Beacon{} = beacon) do
beacon beacon
|> Ecto.Changeset.change(approved: true) |> Ecto.Changeset.change(approved: true)
@ -86,6 +93,7 @@ defmodule Microwaveprop.Beacons do
end end
@doc "Deletes a beacon." @doc "Deletes a beacon."
@spec delete_beacon(Beacon.t()) :: {:ok, Beacon.t()} | {:error, Ecto.Changeset.t()}
def delete_beacon(%Beacon{} = beacon) do def delete_beacon(%Beacon{} = beacon) do
case Repo.delete(beacon) do case Repo.delete(beacon) do
{:ok, beacon} -> {:ok, beacon} ->
@ -98,6 +106,7 @@ defmodule Microwaveprop.Beacons do
end end
@doc "Returns an `%Ecto.Changeset{}` for tracking beacon changes." @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 def change_beacon(%Beacon{} = beacon, attrs \\ %{}) do
Beacon.changeset(beacon, attrs) Beacon.changeset(beacon, attrs)
end end

View file

@ -41,8 +41,10 @@ defmodule Microwaveprop.Beacons.Beacon do
@keyings Enum.map(@keying_entries, fn {k, _} -> k end) @keyings Enum.map(@keying_entries, fn {k, _} -> k end)
@keying_labels Map.new(@keying_entries) @keying_labels Map.new(@keying_entries)
@spec keyings() :: [String.t()]
def keyings, do: @keyings def keyings, do: @keyings
@spec keying_label(String.t()) :: String.t()
def keying_label(key), do: Map.get(@keying_labels, key, key) def keying_label(key), do: Map.get(@keying_labels, key, key)
@doc """ @doc """
@ -50,6 +52,7 @@ defmodule Microwaveprop.Beacons.Beacon do
notation. Integers are printed without a decimal; floats keep up to three notation. Integers are printed without a decimal; floats keep up to three
decimals with trailing zeros trimmed. decimals with trailing zeros trimmed.
""" """
@spec format_mw(number() | nil | term()) :: String.t()
def format_mw(nil), do: "" def format_mw(nil), do: ""
def format_mw(mw) when is_number(mw) 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) def format_mw(mw), do: to_string(mw)
@doc "Formats a frequency in MHz with comma separators (e.g. 10368 → \"10,368\")." @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(nil), do: ""
def format_freq(mhz) when is_number(mhz) 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 Keying options for `Phoenix.HTML.Form.options_for_select/2`, grouped for a
cleaner UI when dozens of weak-signal modes are present. cleaner UI when dozens of weak-signal modes are present.
""" """
@spec keying_options() :: [{String.t(), [{String.t(), String.t()}]}]
def keying_options do def keying_options do
[ [
{"Simple", {"Simple",
@ -158,8 +163,11 @@ defmodule Microwaveprop.Beacons.Beacon do
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@type t :: %__MODULE__{}
@required_fields [:frequency_mhz, :callsign, :lat, :lon, :power_mw, :height_ft, :keying] @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 def changeset(beacon, attrs) do
beacon beacon
|> cast(attrs, [ |> cast(attrs, [

View file

@ -7,24 +7,28 @@ defmodule Microwaveprop.Commercial do
alias Microwaveprop.Commercial.Sample alias Microwaveprop.Commercial.Sample
alias Microwaveprop.Repo alias Microwaveprop.Repo
@spec enabled_links() :: [Link.t()]
def enabled_links do def enabled_links do
Link Link
|> where([l], l.enabled == true) |> where([l], l.enabled == true)
|> Repo.all() |> Repo.all()
end end
@spec create_link(map()) :: {:ok, Link.t()} | {:error, Ecto.Changeset.t()}
def create_link(attrs) do def create_link(attrs) do
%Link{} %Link{}
|> Link.changeset(attrs) |> Link.changeset(attrs)
|> Repo.insert() |> Repo.insert()
end end
@spec create_sample(map()) :: {:ok, Sample.t()} | {:error, Ecto.Changeset.t()}
def create_sample(attrs) do def create_sample(attrs) do
%Sample{} %Sample{}
|> Sample.changeset(attrs) |> Sample.changeset(attrs)
|> Repo.insert() |> Repo.insert()
end end
@spec weather_stations() :: [String.t()]
def weather_stations do def weather_stations do
Link Link
|> where([l], l.enabled == true and not is_nil(l.weather_station)) |> where([l], l.enabled == true and not is_nil(l.weather_station))

View file

@ -20,9 +20,12 @@ defmodule Microwaveprop.Commercial.Link do
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@type t :: %__MODULE__{}
@required_fields ~w(label host community radio_type)a @required_fields ~w(label host community radio_type)a
@optional_fields ~w(endpoint_a endpoint_b weather_station enabled)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 def changeset(link, attrs) do
link link
|> cast(attrs, @required_fields ++ @optional_fields) |> cast(attrs, @required_fields ++ @optional_fields)

View file

@ -39,12 +39,15 @@ defmodule Microwaveprop.Commercial.Sample do
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@type t :: %__MODULE__{}
@required_fields ~w(link_id sampled_at)a @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 @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 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 rx_capacity tx_capacity remote_tx_power remote_rx_power_0 remote_rx_power_1
link_state link_uptime link_distance_m)a link_state link_uptime link_distance_m)a
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(sample, attrs) do def changeset(sample, attrs) do
sample sample
|> cast(attrs, @required_fields ++ @optional_fields) |> cast(attrs, @required_fields ++ @optional_fields)

View file

@ -4,6 +4,7 @@ defmodule Microwaveprop.Geo do
@earth_radius_km 6371.0 @earth_radius_km 6371.0
@doc "Great-circle distance between two points in km." @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 def haversine_km(lat1, lon1, lat2, lon2) do
dlat = deg_to_rad(lat2 - lat1) dlat = deg_to_rad(lat2 - lat1)
dlon = deg_to_rad(lon2 - lon1) dlon = deg_to_rad(lon2 - lon1)
@ -16,6 +17,7 @@ defmodule Microwaveprop.Geo do
end end
@doc "Initial bearing from point 1 to point 2 in degrees (0-360)." @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 def bearing_deg(lat1, lon1, lat2, lon2) do
lat1r = deg_to_rad(lat1) lat1r = deg_to_rad(lat1)
lat2r = deg_to_rad(lat2) lat2r = deg_to_rad(lat2)
@ -27,6 +29,7 @@ defmodule Microwaveprop.Geo do
end end
@doc "Center of a 4-character Maidenhead grid square." @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 def maidenhead_center(grid) when is_binary(grid) and byte_size(grid) >= 4 do
case Microwaveprop.Radio.Maidenhead.to_latlon(grid) do case Microwaveprop.Radio.Maidenhead.to_latlon(grid) do
{:ok, {lat, lon}} -> {lat, lon} {:ok, {lat, lon}} -> {lat, lon}
@ -35,6 +38,7 @@ defmodule Microwaveprop.Geo do
end end
@doc "4-character Maidenhead grid for a lat/lon." @doc "4-character Maidenhead grid for a lat/lon."
@spec latlon_to_grid4(number(), number()) :: String.t()
def latlon_to_grid4(lat, lon) do def latlon_to_grid4(lat, lon) do
lon_field = trunc((lon + 180) / 20) lon_field = trunc((lon + 180) / 20)
lat_field = trunc((lat + 90) / 10) lat_field = trunc((lat + 90) / 10)

View file

@ -7,6 +7,7 @@ defmodule Microwaveprop.Markdown do
""" """
@doc "Converts a Markdown string to HTML." @doc "Converts a Markdown string to HTML."
@spec to_html!(String.t()) :: String.t()
def to_html!(markdown) do def to_html!(markdown) do
markdown markdown
|> String.replace("\r\n", "\n") |> String.replace("\r\n", "\n")

View file

@ -19,6 +19,7 @@ defmodule Microwaveprop.Propagation do
Loads the ML model from disk, compiles the predict function, and caches both 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. 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 def load_ml_model do
if Code.ensure_loaded?(@ml_module) do if Code.ensure_loaded?(@ml_module) do
case apply(@ml_module, :load, []) do case apply(@ml_module, :load, []) do
@ -39,6 +40,7 @@ defmodule Microwaveprop.Propagation do
end end
@doc "Returns cached {predict_fn, params} tuple, or nil if not loaded." @doc "Returns cached {predict_fn, params} tuple, or nil if not loaded."
@spec ml_model() :: {function(), term()} | nil
def ml_model do def ml_model do
:persistent_term.get(@ml_key, nil) :persistent_term.get(@ml_key, nil)
end end
@ -48,6 +50,8 @@ defmodule Microwaveprop.Propagation do
Uses ML model if loaded, falls back to algorithm scorer. Uses ML model if loaded, falls back to algorithm scorer.
Returns a list of %{band_mhz, score, factors} maps. 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 def score_grid_point(hrrr_profile, valid_time, latitude, longitude) do
derived = derive_from_hrrr(hrrr_profile) derived = derive_from_hrrr(hrrr_profile)
@ -114,6 +118,7 @@ defmodule Microwaveprop.Propagation do
Options: Options:
* `:prune` - whether to prune old scores after upsert (default true) * `: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 def upsert_scores(scores, opts \\ []) do
now = DateTime.truncate(DateTime.utc_now(), :second) 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 timeout gives enough headroom for a catch-up run (potentially millions of
rows across four indexes) after a stretch of failed compute jobs. rows across four indexes) after a stretch of failed compute jobs.
""" """
@spec prune_old_scores() :: :ok
def prune_old_scores do def prune_old_scores do
cutoff = DateTime.add(DateTime.utc_now(), -2, :hour) 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 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. 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 def available_valid_times(band_mhz) do
cutoff = DateTime.add(DateTime.utc_now(), -3600, :second) 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). If valid_time is nil, uses the earliest available (current analysis hour).
Excludes factors for performance. 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 def scores_at(band_mhz, valid_time, bounds \\ nil) do
time = valid_time || earliest_valid_time(band_mhz) time = valid_time || earliest_valid_time(band_mhz)
@ -238,6 +247,8 @@ defmodule Microwaveprop.Propagation do
end end
@doc "Get the latest scores for a band (alias for scores_at with earliest valid_time)." @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 def latest_scores(band_mhz, bounds \\ nil) do
scores_at(band_mhz, nil, bounds) scores_at(band_mhz, nil, bounds)
end end
@ -247,6 +258,8 @@ defmodule Microwaveprop.Propagation do
end end
@doc "Get scores across all forecast hours for a single grid point (for sparkline)." @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 def point_forecast(band_mhz, lat, lon) do
step = Grid.step() step = Grid.step()
snapped_lat = Float.round(Float.round(lat / step) * step, 3) snapped_lat = Float.round(Float.round(lat / step) * step, 3)
@ -267,6 +280,15 @@ defmodule Microwaveprop.Propagation do
end end
@doc "Get the full score and factors for a specific grid point, snapped to nearest grid." @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 def point_detail(band_mhz, lat, lon, valid_time \\ nil) do
step = Grid.step() step = Grid.step()
snapped_lat = Float.round(Float.round(lat / step) * step, 3) snapped_lat = Float.round(Float.round(lat / step) * step, 3)
@ -307,11 +329,13 @@ defmodule Microwaveprop.Propagation do
end end
@doc "Get the latest valid_time across all scores." @doc "Get the latest valid_time across all scores."
@spec latest_valid_time() :: DateTime.t() | nil
def latest_valid_time do def latest_valid_time do
Repo.one(from(gs in GridScore, select: max(gs.valid_time))) Repo.one(from(gs in GridScore, select: max(gs.valid_time)))
end end
@doc "Get the latest valid_time for a specific band." @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 def latest_valid_time(band_mhz) do
Repo.one(from(gs in GridScore, where: gs.band_mhz == ^band_mhz, select: max(gs.valid_time))) Repo.one(from(gs in GridScore, where: gs.band_mhz == ^band_mhz, select: max(gs.valid_time)))
end end

View file

@ -23,6 +23,7 @@ defmodule Microwaveprop.Propagation.Duct do
Uses ITU-R P.453 with water vapor pressure derived from specific Uses ITU-R P.453 with water vapor pressure derived from specific
humidity. humidity.
""" """
@spec refractivity_profile(map()) :: [{float(), float()}]
def refractivity_profile(%{heights_m: heights, temp_k: temps, spfh: spfhs, pressure_pa: pressures}) do def refractivity_profile(%{heights_m: heights, temp_k: temps, spfh: spfhs, pressure_pa: pressures}) do
[heights, temps, spfhs, pressures] [heights, temps, spfhs, pressures]
|> Enum.zip() |> Enum.zip()
@ -46,6 +47,7 @@ defmodule Microwaveprop.Propagation.Duct do
In a standard atmosphere M always increases with height. A duct In a standard atmosphere M always increases with height. A duct
exists wherever M decreases. exists wherever M decreases.
""" """
@spec m_profile([{float(), float()}]) :: [{float(), float()}]
def m_profile(n_profile) do def m_profile(n_profile) do
Enum.map(n_profile, fn {h, n} -> Enum.map(n_profile, fn {h, n} ->
{h, n + 157.0 * h / 1000.0} {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 where `m_deficit` is the total M decrease (positive number) across the
duct the strength of the trapping. 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) when length(m_profile) < 2, do: []
def detect_ducts(m_profile) 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. where d is duct thickness in meters, ΔM is the M-deficit.
Returns a very high frequency (999 GHz) for degenerate ducts. 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 def min_trapped_frequency_ghz(%{thickness_m: d, m_deficit: delta_m}) when d > 0 and delta_m > 0 do
# Maximum trapped wavelength (meters) # Maximum trapped wavelength (meters)
lambda_max = 2.5 * d * :math.sqrt(delta_m * 1.0e-6) 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 where each duct_map includes `:min_freq_ghz` and
`best_duct_band_ghz` is the lowest min_freq across all detected ducts. `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 def analyze(profile) do
ducts = ducts =
profile profile

View file

@ -15,6 +15,7 @@ defmodule Microwaveprop.Propagation.FreshnessMonitor do
@check_interval to_timeout(minute: 5) @check_interval to_timeout(minute: 5)
@stale_threshold_minutes 120 @stale_threshold_minutes 120
@spec start_link(keyword()) :: GenServer.on_start() | :ignore
def start_link(_opts) do def start_link(_opts) do
if Application.get_env(:microwaveprop, :start_freshness_monitor, true) do if Application.get_env(:microwaveprop, :start_freshness_monitor, true) do
GenServer.start_link(__MODULE__, :ok, name: __MODULE__) GenServer.start_link(__MODULE__, :ok, name: __MODULE__)

View file

@ -8,6 +8,7 @@ defmodule Microwaveprop.Propagation.Grid do
@step 0.125 @step 0.125
@doc "Returns all grid points as `{lat, lon}` tuples covering CONUS at 0.125 degree spacing." @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 def conus_points do
for lat <- float_range(@lat_min, @lat_max, @step), for lat <- float_range(@lat_min, @lat_max, @step),
lon <- float_range(@lon_min, @lon_max, @step) do lon <- float_range(@lon_min, @lon_max, @step) do
@ -16,12 +17,22 @@ defmodule Microwaveprop.Propagation.Grid do
end end
@doc "Returns the grid step size in degrees." @doc "Returns the grid step size in degrees."
@spec step() :: float()
def step, do: @step def step, do: @step
@doc "Returns the CONUS bounding box as a map." @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} 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." @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 def wgrib2_grid_spec do
lon_count = round((@lon_max - @lon_min) / @step) + 1 lon_count = round((@lon_max - @lon_min) / @step) + 1
lat_count = round((@lat_max - @lat_min) / @step) + 1 lat_count = round((@lat_max - @lat_min) / @step) + 1

View file

@ -17,8 +17,11 @@ defmodule Microwaveprop.Propagation.GridScore do
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@type t :: %__MODULE__{}
@fields ~w(lat lon valid_time band_mhz score factors)a @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 def changeset(grid_score, attrs) do
grid_score grid_score
|> cast(attrs, @fields) |> cast(attrs, @fields)

View file

@ -30,6 +30,15 @@ defmodule Microwaveprop.Propagation.Inversion do
The profile map must have at least `:heights_m` and `:temp_k` arrays. 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(%{level_count: n}) when n < 2, do: :none
def find_inversion_top(%{heights_m: heights, temp_k: temps}) do 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 Returns `nil` if inputs are missing or shear is zero (infinite Ri
is clamped to 100.0 for practical use). 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 def bulk_richardson(profile, base_idx, top_idx) do
with t_base when is_float(t_base) <- Enum.at(profile.temp_k, base_idx), 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), t_top when is_float(t_top) <- Enum.at(profile.temp_k, top_idx),
@ -134,6 +144,7 @@ defmodule Microwaveprop.Propagation.Inversion do
@doc """ @doc """
Wind shear magnitude (m/s) across a layer from `base_idx` to `top_idx`. 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 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), 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), 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 """ @doc """
Potential temperature θ = T * (P/P)^(R/cp) where P = 100000 Pa. 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 def potential_temperature(temp_k, pressure_pa) do
temp_k * :math.pow(100_000.0 / pressure_pa, 0.286) temp_k * :math.pow(100_000.0 / pressure_pa, 0.286)
end end

View file

@ -33,6 +33,21 @@ defmodule Microwaveprop.Propagation.RainScatter do
each with: lat, lon, dbz, distance_km, scatter_db (relative signal estimate), each with: lat, lon, dbz, distance_km, scatter_db (relative signal estimate),
and bearing from the observer. 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 def find_scatter_cells(rain_cells, obs_lat, obs_lon, freq_ghz) do
rain_cells rain_cells
|> Enum.filter(fn {_lat, _lon, dbz} -> dbz >= @min_dbz end) |> 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 Returns :excellent, :good, :marginal, or :none based on
the best available scatter cell. the best available scatter cell.
""" """
@spec classify([%{scatter_db: float()}]) :: :excellent | :good | :marginal | :none
def classify(scatter_cells) do def classify(scatter_cells) do
case scatter_cells do case scatter_cells do
[] -> [] ->

View file

@ -3,6 +3,7 @@ defmodule Microwaveprop.Radio do
import Ecto.Query import Ecto.Query
alias Microwaveprop.Accounts.User
alias Microwaveprop.Radio.Contact alias Microwaveprop.Radio.Contact
alias Microwaveprop.Radio.ContactEdit alias Microwaveprop.Radio.ContactEdit
alias Microwaveprop.Radio.Maidenhead alias Microwaveprop.Radio.Maidenhead
@ -11,6 +12,15 @@ defmodule Microwaveprop.Radio do
@per_page 20 @per_page 20
@sortable_fields ~w(station1 station2 band mode distance_km qso_timestamp inserted_at)a @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 def list_contacts(opts \\ []) do
page = max(Keyword.get(opts, :page, 1), 1) page = max(Keyword.get(opts, :page, 1), 1)
offset = (page - 1) * @per_page offset = (page - 1) * @per_page
@ -45,6 +55,7 @@ defmodule Microwaveprop.Radio do
Returns a list of `{primary, reciprocals}` tuples where reciprocals is Returns a list of `{primary, reciprocals}` tuples where reciprocals is
a (possibly empty) list of matching contacts. a (possibly empty) list of matching contacts.
""" """
@spec group_reciprocals([Contact.t()]) :: [{Contact.t(), [Contact.t()]}]
def group_reciprocals(contacts) do def group_reciprocals(contacts) do
{groups, _seen_ids} = {groups, _seen_ids} =
Enum.reduce(contacts, {[], MapSet.new()}, fn contact, {groups, seen} -> Enum.reduce(contacts, {[], MapSet.new()}, fn contact, {groups, seen} ->
@ -118,6 +129,7 @@ defmodule Microwaveprop.Radio do
@enrichable_statuses [:pending, :failed] @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 def contacts_needing_enrichment(field, limit \\ 500, extra_filter \\ nil) do
query = query =
Contact Contact
@ -129,30 +141,43 @@ defmodule Microwaveprop.Radio do
Repo.all(query) Repo.all(query)
end end
@spec unprocessed_contacts(non_neg_integer()) :: [Contact.t()]
def unprocessed_contacts(limit \\ 500), do: contacts_needing_enrichment(:weather_status, limit) 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) 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), def unprocessed_terrain_contacts(limit \\ 500),
do: contacts_needing_enrichment(:terrain_status, limit, &where(&1, [q], not is_nil(q.pos2))) 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) 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 def set_enrichment_status!(ids, field, status) do
Contact Contact
|> where([q], q.id in ^ids and field(q, ^field) != ^status) |> where([q], q.id in ^ids and field(q, ^field) != ^status)
|> Repo.update_all(set: [{field, status}]) |> Repo.update_all(set: [{field, status}])
end 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) 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) 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) 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) def mark_iemre_queued!(ids), do: set_enrichment_status!(ids, :iemre_status, :queued)
@doc """ @doc """
Returns a list of {lat, lon} points along the contact path: pos1, midpoint, pos2. 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. 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: nil}), do: []
def contact_path_points(%{pos1: pos1, pos2: nil}) do def contact_path_points(%{pos1: pos1, pos2: nil}) do
@ -183,6 +208,7 @@ defmodule Microwaveprop.Radio do
@earth_radius_km 6371.0 @earth_radius_km 6371.0
@spec haversine_km(number(), number(), number(), number()) :: float()
def haversine_km(lat1, lon1, lat2, lon2) do def haversine_km(lat1, lon1, lat2, lon2) do
dlat = deg_to_rad(lat2 - lat1) dlat = deg_to_rad(lat2 - lat1)
dlon = deg_to_rad(lon2 - lon1) dlon = deg_to_rad(lon2 - lon1)
@ -196,6 +222,7 @@ defmodule Microwaveprop.Radio do
2 * @earth_radius_km * :math.asin(:math.sqrt(a)) 2 * @earth_radius_km * :math.asin(:math.sqrt(a))
end end
@spec backfill_distances([Contact.t()]) :: Postgrex.Result.t() | nil
def backfill_distances(contacts) do def backfill_distances(contacts) do
updates = updates =
for contact <- contacts, for contact <- contacts,
@ -230,16 +257,19 @@ defmodule Microwaveprop.Radio do
defp deg_to_rad(deg), do: deg * :math.pi() / 180 defp deg_to_rad(deg), do: deg * :math.pi() / 180
@spec get_contact!(Ecto.UUID.t()) :: Contact.t()
def get_contact!(id) do def get_contact!(id) do
Repo.get!(Contact, id) Repo.get!(Contact, id)
end end
@spec toggle_flagged_invalid!(Contact.t()) :: Contact.t()
def toggle_flagged_invalid!(contact) do def toggle_flagged_invalid!(contact) do
contact contact
|> Ecto.Changeset.change(flagged_invalid: !contact.flagged_invalid) |> Ecto.Changeset.change(flagged_invalid: !contact.flagged_invalid)
|> Repo.update!() |> Repo.update!()
end end
@spec change_contact(Contact.t(), map()) :: Ecto.Changeset.t()
def change_contact(contact, attrs \\ %{}) do def change_contact(contact, attrs \\ %{}) do
Contact.submission_changeset(contact, attrs) Contact.submission_changeset(contact, attrs)
end end
@ -248,6 +278,7 @@ defmodule Microwaveprop.Radio do
Computes pos1/pos2/distance_km from grid squares if missing. Computes pos1/pos2/distance_km from grid squares if missing.
Returns the updated contact, or the original if already computed or grids are 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 def ensure_positions!(contact) do
needs_pos1 = is_nil(contact.pos1) && contact.grid1 needs_pos1 = is_nil(contact.pos1) && contact.grid1
needs_pos2 = is_nil(contact.pos2) && contact.grid2 needs_pos2 = is_nil(contact.pos2) && contact.grid2
@ -331,6 +362,8 @@ defmodule Microwaveprop.Radio do
@dedup_window_seconds 3600 @dedup_window_seconds 3600
@spec create_contact(map()) ::
{:ok, Contact.t()} | {:error, Ecto.Changeset.t()} | {:error, :duplicate, Contact.t()}
def create_contact(attrs) do def create_contact(attrs) do
changeset = Contact.submission_changeset(%Contact{}, attrs) 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 differ from the contact's current values. Normalizes callsigns and grids
to uppercase. 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 def create_contact_edit(%Contact{} = contact, user, proposed_changes) when is_map(proposed_changes) do
# Normalize and diff against current values # Normalize and diff against current values
normalized = normalize_proposed(proposed_changes) normalized = normalize_proposed(proposed_changes)
@ -419,6 +454,7 @@ defmodule Microwaveprop.Radio do
end end
@doc false @doc false
@spec normalize_proposed(map()) :: map()
def normalize_proposed(changes) do def normalize_proposed(changes) do
changes changes
|> normalize_string_field("station1") |> normalize_string_field("station1")
@ -437,6 +473,7 @@ defmodule Microwaveprop.Radio do
end end
@doc false @doc false
@spec diff_against_contact(Contact.t(), map()) :: map()
def diff_against_contact(contact, proposed) do def diff_against_contact(contact, proposed) do
Enum.reduce(proposed, %{}, fn {key, new_val}, acc -> Enum.reduce(proposed, %{}, fn {key, new_val}, acc ->
current = current_value(contact, key) 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(%Decimal{} = v), do: Decimal.to_integer(v)
defp to_integer(v), do: v defp to_integer(v), do: v
@spec list_pending_edits() :: [ContactEdit.t()]
def list_pending_edits do def list_pending_edits do
ContactEdit ContactEdit
|> where([e], e.status == :pending) |> where([e], e.status == :pending)
@ -483,6 +521,7 @@ defmodule Microwaveprop.Radio do
|> Repo.all() |> Repo.all()
end end
@spec list_contact_edits(Ecto.UUID.t()) :: [ContactEdit.t()]
def list_contact_edits(contact_id) do def list_contact_edits(contact_id) do
ContactEdit ContactEdit
|> where([e], e.contact_id == ^contact_id) |> where([e], e.contact_id == ^contact_id)
@ -491,24 +530,29 @@ defmodule Microwaveprop.Radio do
|> Repo.all() |> Repo.all()
end end
@spec pending_edit_count() :: non_neg_integer()
def pending_edit_count do def pending_edit_count do
ContactEdit ContactEdit
|> where([e], e.status == :pending) |> where([e], e.status == :pending)
|> Repo.aggregate(:count) |> Repo.aggregate(:count)
end 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 def pending_edit_for_user(contact_id, user_id) do
ContactEdit ContactEdit
|> where([e], e.contact_id == ^contact_id and e.user_id == ^user_id and e.status == :pending) |> where([e], e.contact_id == ^contact_id and e.user_id == ^user_id and e.status == :pending)
|> Repo.one() |> Repo.one()
end end
@spec get_contact_edit!(Ecto.UUID.t()) :: ContactEdit.t()
def get_contact_edit!(id) do def get_contact_edit!(id) do
ContactEdit ContactEdit
|> preload([:user, :contact, :reviewed_by]) |> preload([:user, :contact, :reviewed_by])
|> Repo.get!(id) |> Repo.get!(id)
end 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 def approve_edit(%ContactEdit{status: :pending} = edit, admin, note) do
Repo.transaction(fn -> Repo.transaction(fn ->
# Mark edit as approved # Mark edit as approved
@ -530,6 +574,8 @@ defmodule Microwaveprop.Radio do
end) end)
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 def reject_edit(%ContactEdit{status: :pending} = edit, admin, note) do
edit edit
|> ContactEdit.review_changeset(%{ |> ContactEdit.review_changeset(%{
@ -542,6 +588,7 @@ defmodule Microwaveprop.Radio do
end end
@doc "Apply proposed changes directly to a contact (admin use)." @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 def apply_edit_to_contact(contact, proposed_changes) do
changes = build_contact_changes(contact, proposed_changes) changes = build_contact_changes(contact, proposed_changes)

View file

@ -36,6 +36,15 @@ defmodule Microwaveprop.Radio.AdifImport do
Parse ADIF string, validate, and deduplicate. Returns the same preview Parse ADIF string, validate, and deduplicate. Returns the same preview
shape as `CsvImport.preview/2`. 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 def preview(adif_string, submitter_email) do
records = parse_adif(adif_string) records = parse_adif(adif_string)

View file

@ -4,6 +4,14 @@ defmodule Microwaveprop.Radio.CallsignClient do
@doc """ @doc """
Fetch location for a callsign. Returns {:ok, %{callsign, gridsquare, lat, lon}} or {:error, reason}. 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 def locate(callsign) do
url = "https://gridmap.org/locate/#{URI.encode(callsign)}" url = "https://gridmap.org/locate/#{URI.encode(callsign)}"

View file

@ -47,9 +47,12 @@ defmodule Microwaveprop.Radio.Contact do
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@type t :: %__MODULE__{}
@required_fields ~w(station1 station2 qso_timestamp mode band)a @required_fields ~w(station1 station2 qso_timestamp mode band)a
@optional_fields ~w(grid1 grid2 pos1 pos2 distance_km user_id)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 def changeset(contact, attrs) do
contact contact
|> cast(attrs, @required_fields ++ @optional_fields) |> 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_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) @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 def submission_changeset(contact, attrs) do
contact contact
|> cast(attrs, @submission_fields) |> cast(attrs, @submission_fields)

View file

@ -27,8 +27,11 @@ defmodule Microwaveprop.Radio.ContactEdit do
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@type t :: %__MODULE__{}
@editable_fields ~w(station1 station2 grid1 grid2 band mode qso_timestamp) @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 def changeset(edit, attrs) do
edit edit
|> cast(attrs, [:contact_id, :user_id, :proposed_changes]) |> cast(attrs, [:contact_id, :user_id, :proposed_changes])
@ -38,6 +41,7 @@ defmodule Microwaveprop.Radio.ContactEdit do
|> foreign_key_constraint(:user_id) |> foreign_key_constraint(:user_id)
end end
@spec review_changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def review_changeset(edit, attrs) do def review_changeset(edit, attrs) do
edit edit
|> cast(attrs, [:status, :admin_note, :reviewed_by_id, :reviewed_at]) |> cast(attrs, [:status, :admin_note, :reviewed_by_id, :reviewed_at])

View file

@ -36,6 +36,26 @@ defmodule Microwaveprop.Radio.CsvImport do
Parse-and-insert in one shot. Kept for backward compatibility does NOT Parse-and-insert in one shot. Kept for backward compatibility does NOT
perform duplicate detection. New code should use `preview/2` + `commit/1`. 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 def import(csv_string, submitter_email) do
with {:ok, rows} <- parse_csv(csv_string, submitter_email) do with {:ok, rows} <- parse_csv(csv_string, submitter_email) do
{valid, invalid} = split_parsed(rows) {valid, invalid} = split_parsed(rows)
@ -66,6 +86,8 @@ defmodule Microwaveprop.Radio.CsvImport do
the database (`:existing_contact`) or earlier in the uploaded file the database (`:existing_contact`) or earlier in the uploaded file
(`:earlier_in_upload`). (`: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 def preview(csv_string, submitter_email) do
with {:ok, rows} <- parse_csv(csv_string, submitter_email) do with {:ok, rows} <- parse_csv(csv_string, submitter_email) do
{valid, invalid} = split_parsed(rows) {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. `Radio.create_contact/1` and its enrichment is enqueued on success.
Returns `{:ok, %{imported: [...], errors: [{row_num, messages}, ...]}}`. Returns `{:ok, %{imported: [...], errors: [{row_num, messages}, ...]}}`.
""" """
@spec commit([map()]) :: {:ok, commit_result()}
def commit(valid_rows) when is_list(valid_rows) do def commit(valid_rows) when is_list(valid_rows) do
result = result =
Enum.reduce(valid_rows, %{imported: [], errors: []}, fn row, acc -> 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. Parses various date/time formats into ISO 8601 UTC strings.
All times are assumed UTC. All times are assumed UTC.
""" """
@spec normalize_timestamp(String.t()) :: {:ok, String.t()} | {:error, String.t()}
def normalize_timestamp(raw) do def normalize_timestamp(raw) do
trimmed = String.trim(raw) trimmed = String.trim(raw)

View file

@ -19,6 +19,9 @@ defmodule Microwaveprop.Radio.EnrichmentStatus do
complete pending (reset for re-enrichment) 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] @states [:pending, :queued, :processing, :complete, :failed, :unavailable]
@fields [:hrrr_status, :weather_status, :terrain_status, :iemre_status] @fields [:hrrr_status, :weather_status, :terrain_status, :iemre_status]
@ -31,15 +34,20 @@ defmodule Microwaveprop.Radio.EnrichmentStatus do
complete: [:pending] complete: [:pending]
} }
@spec states() :: [status()]
def states, do: @states def states, do: @states
@spec fields() :: [field_name()]
def fields, do: @fields def fields, do: @fields
@doc "Returns true if transitioning from `from` to `to` is valid." @doc "Returns true if transitioning from `from` to `to` is valid."
@spec valid_transition?(status(), status()) :: boolean()
def valid_transition?(from, to) do def valid_transition?(from, to) do
to in Map.get(@transitions, from, []) to in Map.get(@transitions, from, [])
end end
@doc "Validates and applies a status transition on a changeset." @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 def transition(changeset, field, new_status) when field in @fields and new_status in @states do
current = Ecto.Changeset.get_field(changeset, field) current = Ecto.Changeset.get_field(changeset, field)
@ -51,6 +59,7 @@ defmodule Microwaveprop.Radio.EnrichmentStatus do
end end
@doc "Force-sets status without transition validation (for migrations, resets)." @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 def force_status(changeset, field, status) when field in @fields and status in @states do
Ecto.Changeset.put_change(changeset, field, status) Ecto.Changeset.put_change(changeset, field, status)
end end

View file

@ -6,6 +6,7 @@ defmodule Microwaveprop.Terrain do
alias Microwaveprop.Repo alias Microwaveprop.Repo
alias Microwaveprop.Terrain.TerrainProfile alias Microwaveprop.Terrain.TerrainProfile
@spec upsert_terrain_profile(map()) :: {:ok, TerrainProfile.t()} | {:error, Ecto.Changeset.t()}
def upsert_terrain_profile(attrs) do def upsert_terrain_profile(attrs) do
%TerrainProfile{} %TerrainProfile{}
|> TerrainProfile.changeset(attrs) |> TerrainProfile.changeset(attrs)
@ -15,10 +16,12 @@ defmodule Microwaveprop.Terrain do
) )
end end
@spec get_terrain_profile(Ecto.UUID.t()) :: TerrainProfile.t() | nil
def get_terrain_profile(contact_id) do def get_terrain_profile(contact_id) do
Repo.get_by(TerrainProfile, contact_id: contact_id) Repo.get_by(TerrainProfile, contact_id: contact_id)
end end
@spec has_terrain_profile?(Ecto.UUID.t()) :: boolean()
def has_terrain_profile?(contact_id) do def has_terrain_profile?(contact_id) do
TerrainProfile TerrainProfile
|> where([t], t.contact_id == ^contact_id) |> where([t], t.contact_id == ^contact_id)

View file

@ -8,6 +8,38 @@ defmodule Microwaveprop.Terrain.TerrainAnalysis do
- Dynamic k-factor from refractivity gradient (Section 2) - 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_m 6_371_000.0
@earth_radius_km 6_371.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) * `: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`) * `: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 def analyse(profile, dist_km, freq_ghz, opts \\ []) do
ant_ht_a = Keyword.get(opts, :ant_ht_a, 0.0) ant_ht_a = Keyword.get(opts, :ant_ht_a, 0.0)
ant_ht_b = Keyword.get(opts, :ant_ht_b, 0.0) ant_ht_b = Keyword.get(opts, :ant_ht_b, 0.0)
@ -98,6 +131,7 @@ defmodule Microwaveprop.Terrain.TerrainAnalysis do
end end
@doc "First Fresnel zone radius at a point between two antennas." @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 def fresnel_radius(d1_m, d2_m, lambda_m) do
if d1_m <= 0 or d2_m <= 0 do if d1_m <= 0 or d2_m <= 0 do
0 0
@ -107,6 +141,7 @@ defmodule Microwaveprop.Terrain.TerrainAnalysis do
end end
@doc "Earth bulge correction in meters at fractional distance along path." @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 def earth_bulge(frac, dist_km, k \\ 4 / 3) do
d1 = frac * dist_km * 1000 d1 = frac * dist_km * 1000
d2 = (1 - 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(ν) = 6.9 + 20·log10(((ν0.1)² + 1) + ν 0.1) for ν > 0.78
J(ν) = 0 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) when v <= -0.78, do: 0
def knife_edge_loss(v) do 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). 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 def diffraction_param(h, d1_m, d2_m, lambda_m) do
if d1_m <= 0 or d2_m <= 0 or lambda_m <= 0 do if d1_m <= 0 or d2_m <= 0 or lambda_m <= 0 do
0.0 0.0
@ -149,6 +186,7 @@ defmodule Microwaveprop.Terrain.TerrainAnalysis do
Standard atmosphere: dN/dh 39 k 4/3. Standard atmosphere: dN/dh 39 k 4/3.
Returns 4/3 for nil input. Returns 4/3 for nil input.
""" """
@spec k_factor(number() | nil) :: float()
def k_factor(nil), do: 4 / 3 def k_factor(nil), do: 4 / 3
def k_factor(dn_dh) do def k_factor(dn_dh) do

View file

@ -21,9 +21,12 @@ defmodule Microwaveprop.Terrain.TerrainProfile do
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@type t :: %__MODULE__{}
@required_fields ~w(contact_id sample_count path_points verdict)a @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 @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 def changeset(profile, attrs) do
profile profile
|> cast(attrs, @required_fields ++ @optional_fields) |> cast(attrs, @required_fields ++ @optional_fields)

View file

@ -4,11 +4,19 @@ defmodule Microwaveprop.Terrain.Viewshed do
alias Microwaveprop.Terrain.Srtm alias Microwaveprop.Terrain.Srtm
alias Microwaveprop.Terrain.TerrainAnalysis 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 @earth_radius_km 6371.0
@default_angular_step 2 @default_angular_step 2
@default_max_range_km 50 @default_max_range_km 50
@doc "Compute destination lat/lon given origin, bearing (degrees), and distance (km)." @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 def destination_point(lat, lon, bearing_deg, dist_km) do
lat_rad = deg_to_rad(lat) lat_rad = deg_to_rad(lat)
lon_rad = deg_to_rad(lon) 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 Skips endpoints (first/last), returns the dist_km of the last clear
interior point before the first obstruction. interior point before the first obstruction.
""" """
@spec find_reach_km([TerrainAnalysis.analysis_point()], float()) :: float()
def find_reach_km(points, max_range_km) do def find_reach_km(points, max_range_km) do
interior = Enum.slice(points, 1..-2//1) 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 Higher propagation scores indicate ducting potential, which lets signals
propagate beyond terrain obstructions via atmospheric waveguide. 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 def effective_reach_km(analysis, max_range_km, score) do
case analysis.verdict do case analysis.verdict do
"CLEAR" -> "CLEAR" ->
@ -104,6 +114,7 @@ defmodule Microwaveprop.Terrain.Viewshed do
- :angular_step degrees between rays (default 2) - :angular_step degrees between rays (default 2)
- :tiles_dir SRTM tiles directory (default from config) - :tiles_dir SRTM tiles directory (default from config)
""" """
@spec compute(float(), float(), keyword()) :: viewshed_result()
def compute(lat, lon, opts \\ []) do def compute(lat, lon, opts \\ []) do
freq_ghz = Keyword.get(opts, :freq_ghz, 10.0) freq_ghz = Keyword.get(opts, :freq_ghz, 10.0)
max_range_km = Keyword.get(opts, :max_range_km, @default_max_range_km) 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. Analyse a single ray's profile. Public for testing.
Returns %{reach_km: float, verdict: string}. 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 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) 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) reach_km = find_reach_km(analysis.points, dist_km)

View file

@ -21,6 +21,7 @@ defmodule Microwaveprop.Weather do
# Approximate km per degree latitude # Approximate km per degree latitude
@km_per_deg_lat 111.0 @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 def find_or_create_station(attrs) do
code = attrs[:station_code] || attrs["station_code"] code = attrs[:station_code] || attrs["station_code"]
type = attrs[:station_type] || attrs["station_type"] type = attrs[:station_type] || attrs["station_type"]
@ -42,6 +43,7 @@ defmodule Microwaveprop.Weather do
end end
end end
@spec upsert_surface_observation(%Station{}, map()) :: {:ok, %SurfaceObservation{}} | {:error, Ecto.Changeset.t()}
def upsert_surface_observation(%Station{} = station, attrs) do def upsert_surface_observation(%Station{} = station, attrs) do
attrs = Map.put(attrs, :station_id, station.id) attrs = Map.put(attrs, :station_id, station.id)
@ -76,6 +78,7 @@ defmodule Microwaveprop.Weather do
) )
end end
@spec upsert_sounding(%Station{}, map()) :: {:ok, %Sounding{}} | {:error, Ecto.Changeset.t()}
def upsert_sounding(%Station{} = station, attrs) do def upsert_sounding(%Station{} = station, attrs) do
attrs = Map.put(attrs, :station_id, station.id) attrs = Map.put(attrs, :station_id, station.id)
@ -113,6 +116,7 @@ defmodule Microwaveprop.Weather do
) )
end end
@spec upsert_solar_index(map()) :: {:ok, %SolarIndex{}} | {:error, Ecto.Changeset.t()}
def upsert_solar_index(attrs) do def upsert_solar_index(attrs) do
%SolarIndex{} %SolarIndex{}
|> SolarIndex.changeset(attrs) |> SolarIndex.changeset(attrs)
@ -139,6 +143,7 @@ defmodule Microwaveprop.Weather do
) )
end end
@spec has_surface_observations?(Ecto.UUID.t(), DateTime.t(), DateTime.t()) :: boolean()
def has_surface_observations?(station_id, start_dt, end_dt) do def has_surface_observations?(station_id, start_dt, end_dt) do
SurfaceObservation SurfaceObservation
|> where([o], o.station_id == ^station_id) |> where([o], o.station_id == ^station_id)
@ -147,6 +152,7 @@ defmodule Microwaveprop.Weather do
end end
@doc "Returns a MapSet of station_ids that have surface observations in the time window." @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 def station_ids_with_surface_observations(station_ids, start_dt, end_dt) do
SurfaceObservation SurfaceObservation
|> where([o], o.station_id in ^station_ids) |> where([o], o.station_id in ^station_ids)
@ -157,6 +163,7 @@ defmodule Microwaveprop.Weather do
|> MapSet.new() |> MapSet.new()
end end
@spec has_sounding?(Ecto.UUID.t(), DateTime.t()) :: boolean()
def has_sounding?(station_id, observed_at) do def has_sounding?(station_id, observed_at) do
Sounding Sounding
|> where([s], s.station_id == ^station_id and s.observed_at == ^observed_at) |> where([s], s.station_id == ^station_id and s.observed_at == ^observed_at)
@ -164,6 +171,7 @@ defmodule Microwaveprop.Weather do
end end
@doc "Returns a MapSet of {station_id, observed_at} tuples that have soundings." @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 def station_ids_with_soundings(station_ids, sounding_times) do
Sounding Sounding
|> where([s], s.station_id in ^station_ids and s.observed_at in ^sounding_times) |> where([s], s.station_id in ^station_ids and s.observed_at in ^sounding_times)
@ -174,6 +182,7 @@ defmodule Microwaveprop.Weather do
end end
@doc "Batch upsert solar indices using insert_all in chunks of 500." @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 def upsert_solar_indices_batch(records) do
now = DateTime.truncate(DateTime.utc_now(), :second) now = DateTime.truncate(DateTime.utc_now(), :second)
@ -220,10 +229,12 @@ defmodule Microwaveprop.Weather do
end) end)
end end
@spec get_solar_index(Date.t()) :: %SolarIndex{} | nil
def get_solar_index(date) do def get_solar_index(date) do
Repo.get_by(SolarIndex, date: date) Repo.get_by(SolarIndex, date: date)
end end
@spec existing_solar_dates() :: MapSet.t(Date.t())
def existing_solar_dates do def existing_solar_dates do
SolarIndex SolarIndex
|> select([s], s.date) |> select([s], s.date)
@ -231,6 +242,7 @@ defmodule Microwaveprop.Weather do
|> MapSet.new() |> MapSet.new()
end end
@spec sync_stations!() :: :ok
def sync_stations! do def sync_stations! do
asos = asos =
for s <- for s <-
@ -262,6 +274,7 @@ defmodule Microwaveprop.Weather do
:ok :ok
end end
@spec nearby_stations(float(), float(), String.t(), number()) :: [%Station{}]
def nearby_stations(lat, lon, station_type, radius_km) do def nearby_stations(lat, lon, station_type, radius_km) do
dlat = radius_km / @km_per_deg_lat dlat = radius_km / @km_per_deg_lat
dlon = radius_km / (@km_per_deg_lat * :math.cos(lat * :math.pi() / 180)) dlon = radius_km / (@km_per_deg_lat * :math.cos(lat * :math.pi() / 180))
@ -276,6 +289,7 @@ defmodule Microwaveprop.Weather do
|> Repo.all() |> Repo.all()
end end
@spec sounding_times_around(DateTime.t()) :: [DateTime.t()]
def sounding_times_around(dt) do def sounding_times_around(dt) do
date = DateTime.to_date(dt) date = DateTime.to_date(dt)
@ -295,6 +309,10 @@ defmodule Microwaveprop.Weather do
Enum.uniq(times) Enum.uniq(times)
end end
@spec weather_for_contact(map(), keyword()) :: %{
surface_observations: [%SurfaceObservation{}],
soundings: [%Sounding{}]
}
def weather_for_contact(contact_params, opts \\ []) do def weather_for_contact(contact_params, opts \\ []) do
lat = contact_params[:lat] || contact_params.lat lat = contact_params[:lat] || contact_params.lat
lon = contact_params[:lon] || contact_params.lon lon = contact_params[:lon] || contact_params.lon
@ -336,6 +354,7 @@ defmodule Microwaveprop.Weather do
%{surface_observations: surface_observations, soundings: soundings} %{surface_observations: surface_observations, soundings: soundings}
end end
@spec latest_grid_valid_time() :: DateTime.t() | nil
def latest_grid_valid_time do def latest_grid_valid_time do
Repo.one( Repo.one(
from(h in HrrrProfile, from(h in HrrrProfile,
@ -345,6 +364,7 @@ defmodule Microwaveprop.Weather do
) )
end end
@spec latest_weather_grid(map()) :: [map()]
def latest_weather_grid(bounds) do def latest_weather_grid(bounds) do
case latest_grid_valid_time() do case latest_grid_valid_time() do
nil -> nil ->
@ -380,6 +400,7 @@ defmodule Microwaveprop.Weather do
end end
end end
@spec weather_point_detail(float(), float(), DateTime.t()) :: map() | nil
def weather_point_detail(lat, lon, valid_time) do def weather_point_detail(lat, lon, valid_time) do
step = 0.125 step = 0.125
snapped_lat = Float.round(Float.round(lat / step) * step, 3) 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]) |> Map.drop([:profile, :duct_characteristics, :surface_temp_c, :surface_dewpoint_c])
end end
@spec upsert_hrrr_profile(map()) :: {:ok, %HrrrProfile{}} | {:error, Ecto.Changeset.t()}
def upsert_hrrr_profile(attrs) do def upsert_hrrr_profile(attrs) do
%HrrrProfile{} %HrrrProfile{}
|> HrrrProfile.changeset(attrs) |> HrrrProfile.changeset(attrs)
@ -429,6 +451,7 @@ defmodule Microwaveprop.Weather do
) )
end end
@spec upsert_hrrr_profiles_batch([map()], keyword()) :: {non_neg_integer(), nil}
def upsert_hrrr_profiles_batch(profiles, _opts \\ []) do def upsert_hrrr_profiles_batch(profiles, _opts \\ []) do
now = DateTime.truncate(DateTime.utc_now(), :second) now = DateTime.truncate(DateTime.utc_now(), :second)
@ -459,6 +482,7 @@ defmodule Microwaveprop.Weather do
end) end)
end end
@spec has_hrrr_profile?(float(), float(), DateTime.t()) :: boolean()
def has_hrrr_profile?(lat, lon, valid_time) do def has_hrrr_profile?(lat, lon, valid_time) do
dlat = 0.07 dlat = 0.07
dlon = 0.07 dlon = 0.07
@ -473,6 +497,7 @@ defmodule Microwaveprop.Weather do
|> Repo.exists?() |> Repo.exists?()
end end
@spec hrrr_for_contact(map()) :: %HrrrProfile{} | nil
def hrrr_for_contact(%{pos1: nil}), do: nil def hrrr_for_contact(%{pos1: nil}), do: nil
def hrrr_for_contact(contact) do def hrrr_for_contact(contact) do
@ -484,6 +509,7 @@ defmodule Microwaveprop.Weather do
end end
end end
@spec find_nearest_hrrr(float(), float(), DateTime.t()) :: %HrrrProfile{} | nil
def find_nearest_hrrr(lat, lon, timestamp) do def find_nearest_hrrr(lat, lon, timestamp) do
dlat = 0.07 dlat = 0.07
dlon = 0.07 dlon = 0.07
@ -513,6 +539,7 @@ defmodule Microwaveprop.Weather do
|> Repo.one() |> Repo.one()
end end
@spec hrrr_profiles_for_path(map()) :: [%HrrrProfile{}]
def hrrr_profiles_for_path(%{pos1: nil}), do: [] def hrrr_profiles_for_path(%{pos1: nil}), do: []
def hrrr_profiles_for_path(contact) 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). Tries HRRR first (3 km, hourly), falls back to ERA5 (0.25°, hourly).
Returns the profile struct or nil. Returns the profile struct or nil.
""" """
@spec best_profile_for_contact(map()) :: %HrrrProfile{} | %Era5Profile{} | nil
def best_profile_for_contact(contact) do def best_profile_for_contact(contact) do
hrrr_for_contact(contact) || era5_for_contact(contact) hrrr_for_contact(contact) || era5_for_contact(contact)
end end
@doc "Find all atmospheric profiles along a contact's path, from any source." @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 def profiles_along_path(contact) do
hrrr_path = hrrr_profiles_for_path(contact) hrrr_path = hrrr_profiles_for_path(contact)
@ -542,6 +571,7 @@ defmodule Microwaveprop.Weather do
end end
end end
@spec era5_for_contact(map()) :: %Era5Profile{} | nil
def era5_for_contact(%{pos1: nil}), do: nil def era5_for_contact(%{pos1: nil}), do: nil
def era5_for_contact(contact) do def era5_for_contact(contact) do
@ -553,6 +583,7 @@ defmodule Microwaveprop.Weather do
end end
end end
@spec era5_profiles_for_path(map()) :: [%Era5Profile{}]
def era5_profiles_for_path(%{pos1: nil}), do: [] def era5_profiles_for_path(%{pos1: nil}), do: []
def era5_profiles_for_path(contact) do def era5_profiles_for_path(contact) do
@ -562,6 +593,7 @@ defmodule Microwaveprop.Weather do
|> Enum.reject(&is_nil/1) |> Enum.reject(&is_nil/1)
end end
@spec find_nearest_era5(float(), float(), DateTime.t()) :: %Era5Profile{} | nil
def find_nearest_era5(lat, lon, timestamp) do def find_nearest_era5(lat, lon, timestamp) do
dlat = 0.15 dlat = 0.15
dlon = 0.15 dlon = 0.15
@ -589,6 +621,7 @@ defmodule Microwaveprop.Weather do
|> Repo.one() |> Repo.one()
end end
@spec find_nearest_rtma(float(), float(), DateTime.t()) :: %RtmaObservation{} | nil
def find_nearest_rtma(lat, lon, timestamp) do def find_nearest_rtma(lat, lon, timestamp) do
dlat = 0.05 dlat = 0.05
dlon = 0.05 dlon = 0.05
@ -618,6 +651,7 @@ defmodule Microwaveprop.Weather do
|> Repo.one() |> Repo.one()
end end
@spec round_to_hrrr_grid(float(), float()) :: {float(), float()}
def round_to_hrrr_grid(lat, lon) do def round_to_hrrr_grid(lat, lon) do
{Float.round(lat / 1.0, 2), Float.round(lon / 1.0, 2)} {Float.round(lat / 1.0, 2), Float.round(lon / 1.0, 2)}
end end
@ -627,6 +661,7 @@ defmodule Microwaveprop.Weather do
Preserves QSO-linked profiles which are at arbitrary lat/lon positions. 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. 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 def prune_old_grid_profiles do
cutoff = DateTime.add(DateTime.utc_now(), -24, :hour) cutoff = DateTime.add(DateTime.utc_now(), -24, :hour)
# Bound the scan to recent partitions only (grid profiles are at most ~48h old). # Bound the scan to recent partitions only (grid profiles are at most ~48h old).
@ -655,10 +690,12 @@ defmodule Microwaveprop.Weather do
deleted deleted
end end
@spec round_to_iemre_grid(float(), float()) :: {float(), float()}
def round_to_iemre_grid(lat, lon) do def round_to_iemre_grid(lat, lon) do
{Float.round(lat * 8) / 8, Float.round(lon * 8) / 8} {Float.round(lat * 8) / 8, Float.round(lon * 8) / 8}
end end
@spec upsert_iemre_observation(map()) :: {:ok, %IemreObservation{}} | {:error, Ecto.Changeset.t()}
def upsert_iemre_observation(attrs) do def upsert_iemre_observation(attrs) do
%IemreObservation{} %IemreObservation{}
|> IemreObservation.changeset(attrs) |> IemreObservation.changeset(attrs)
@ -668,12 +705,14 @@ defmodule Microwaveprop.Weather do
) )
end end
@spec has_iemre_observation?(float(), float(), Date.t()) :: boolean()
def has_iemre_observation?(lat, lon, date) do def has_iemre_observation?(lat, lon, date) do
IemreObservation IemreObservation
|> where([i], i.lat == ^lat and i.lon == ^lon and i.date == ^date) |> where([i], i.lat == ^lat and i.lon == ^lon and i.date == ^date)
|> Repo.exists?() |> Repo.exists?()
end end
@spec iemre_for_contact(map()) :: %IemreObservation{} | nil
def iemre_for_contact(%{pos1: nil}), do: nil def iemre_for_contact(%{pos1: nil}), do: nil
def iemre_for_contact(contact) do def iemre_for_contact(contact) do
@ -685,6 +724,7 @@ defmodule Microwaveprop.Weather do
end end
end end
@spec find_nearest_iemre(float(), float(), DateTime.t()) :: %IemreObservation{} | nil
def find_nearest_iemre(lat, lon, timestamp) do def find_nearest_iemre(lat, lon, timestamp) do
{rlat, rlon} = round_to_iemre_grid(lat, lon) {rlat, rlon} = round_to_iemre_grid(lat, lon)
date = DateTime.to_date(timestamp) date = DateTime.to_date(timestamp)
@ -694,6 +734,7 @@ defmodule Microwaveprop.Weather do
|> Repo.one() |> Repo.one()
end end
@spec iemre_for_path(map()) :: [%IemreObservation{}]
def iemre_for_path(%{pos1: nil}), do: [] def iemre_for_path(%{pos1: nil}), do: []
def iemre_for_path(contact) 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 `:observed_at`, etc. the same shape regardless of which table the
data came from. Returns `nil` if neither source has data. 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 def recent_surface_obs(lat, lon, timestamp) do
dlat = 0.5 dlat = 0.5
dlon = 0.5 dlon = 0.5

View file

@ -37,6 +37,7 @@ defmodule Microwaveprop.Weather.Era5BatchClient do
Returns the integer tile coordinate `{tile_lat, tile_lon}` for a point, 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. 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 def tile_for_point(lat, lon) do
{floor_to_tile(lat), floor_to_tile(lon)} {floor_to_tile(lat), floor_to_tile(lon)}
end end
@ -50,6 +51,7 @@ defmodule Microwaveprop.Weather.Era5BatchClient do
end end
@doc false @doc false
@spec tile_degrees() :: pos_integer()
def tile_degrees, do: @tile_degrees def tile_degrees, do: @tile_degrees
@doc """ @doc """
@ -57,6 +59,12 @@ defmodule Microwaveprop.Weather.Era5BatchClient do
resulting hourly profile in `era5_profiles`. Returns resulting hourly profile in `era5_profiles`. Returns
`{:ok, inserted_count}` or `{:error, reason}`. `{: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 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 if month_tile_cached?(year, month, tile_lat, tile_lon) do
Logger.info("ERA5Batch: #{year}-#{pad(month)} tile #{tile_lat},#{tile_lon} already cached") Logger.info("ERA5Batch: #{year}-#{pad(month)} tile #{tile_lat},#{tile_lon} already cached")

View file

@ -32,16 +32,22 @@ defmodule Microwaveprop.Weather.Era5Client do
@pressure_level_vars ~w(temperature dewpoint_temperature geopotential) @pressure_level_vars ~w(temperature dewpoint_temperature geopotential)
@doc false @doc false
@spec pressure_levels() :: [String.t()]
def pressure_levels, do: @pressure_levels def pressure_levels, do: @pressure_levels
@doc false @doc false
@spec single_level_vars() :: [String.t()]
def single_level_vars, do: @single_level_vars def single_level_vars, do: @single_level_vars
@doc false @doc false
@spec pressure_level_vars() :: [String.t()]
def pressure_level_vars, do: @pressure_level_vars def pressure_level_vars, do: @pressure_level_vars
@doc false @doc false
@spec cds_url() :: String.t()
def cds_url, do: @cds_url def cds_url, do: @cds_url
@doc false @doc false
@spec poll_interval_ms() :: pos_integer()
def poll_interval_ms, do: @poll_interval_ms def poll_interval_ms, do: @poll_interval_ms
@doc false @doc false
@spec max_poll_attempts() :: pos_integer()
def max_poll_attempts, do: @max_poll_attempts def max_poll_attempts, do: @max_poll_attempts
@doc """ @doc """
@ -50,6 +56,7 @@ defmodule Microwaveprop.Weather.Era5Client do
The profile_attrs map has the same shape as HRRR profiles for interoperability. 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 def fetch_profile(lat, lon, timestamp) do
# Round to ERA5 grid (0.25°) # Round to ERA5 grid (0.25°)
rlat = Float.round(lat * 4) / 4 rlat = Float.round(lat * 4) / 4
@ -71,6 +78,7 @@ defmodule Microwaveprop.Weather.Era5Client do
@doc """ @doc """
Fetch ERA5 single-level (surface) data for a time and area. 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 def fetch_single_levels(valid_time, area) do
request = %{ request = %{
"product_type" => ["reanalysis"], "product_type" => ["reanalysis"],
@ -89,6 +97,7 @@ defmodule Microwaveprop.Weather.Era5Client do
@doc """ @doc """
Fetch ERA5 pressure-level data for a time and area. 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 def fetch_pressure_levels(valid_time, area) do
request = %{ request = %{
"product_type" => ["reanalysis"], "product_type" => ["reanalysis"],
@ -106,6 +115,7 @@ defmodule Microwaveprop.Weather.Era5Client do
end end
@doc false @doc false
@spec submit_and_download(String.t(), map()) :: {:ok, binary()} | {:error, term()}
def submit_and_download(dataset, request) do def submit_and_download(dataset, request) do
do_submit_and_download(dataset, request) do_submit_and_download(dataset, request)
end end

View file

@ -25,9 +25,12 @@ defmodule Microwaveprop.Weather.Era5Profile do
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@type t :: %__MODULE__{}
@required_fields ~w(valid_time lat lon)a @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 @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 def changeset(profile, attrs) do
profile profile
|> cast(attrs, @required_fields ++ @optional_fields) |> cast(attrs, @required_fields ++ @optional_fields)

View file

@ -40,6 +40,12 @@ defmodule Microwaveprop.Weather.FrontalAnalysis do
where `front_points` is a list of the strongest frontal pixels where `front_points` is a list of the strongest frontal pixels
for easy lookup. 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 def detect_fronts(temp_grid, pressure_grid, grid_spec) do
# Potential temperature: θ = T * (100000/P)^0.286 # Potential temperature: θ = T * (100000/P)^0.286
theta = Nx.multiply(temp_grid, Nx.pow(Nx.divide(100_000.0, pressure_grid), 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 """ @doc """
Compute distance (km) and angle to the nearest front point from a given lat/lon. 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 def nearest_front(front_points, lat, lon) when is_list(front_points) do
if front_points == [] do if front_points == [] do
nil nil
@ -116,6 +124,7 @@ defmodule Microwaveprop.Weather.FrontalAnalysis do
@doc """ @doc """
Angle between a QSO path and the nearest front (0° = parallel, 90° = crosses). 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 def path_front_angle(path_bearing_deg, front_bearing_deg) do
diff = abs(path_bearing_deg - front_bearing_deg) diff = abs(path_bearing_deg - front_bearing_deg)
diff = if diff > 180, do: 360 - diff, else: diff diff = if diff > 180, do: 360 - diff, else: diff

View file

@ -8,6 +8,7 @@ defmodule Microwaveprop.Weather.Grib2.ComplexPacking do
Decodes all values since spatial differencing requires sequential access, Decodes all values since spatial differencing requires sequential access,
then returns the value at the target index. 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 def extract_value(params, data, index) do
if index < 0 or index >= params.num_data_points do if index < 0 or index >= params.num_data_points do
{:error, :index_out_of_range} {:error, :index_out_of_range}
@ -27,6 +28,8 @@ defmodule Microwaveprop.Weather.Grib2.ComplexPacking do
Returns `{:ok, %{index => value}}`. 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 def extract_values(params, data, indices) do
case decode_all(params, data) do case decode_all(params, data) do
{:ok, array} -> {:ok, array} ->
@ -43,6 +46,7 @@ defmodule Microwaveprop.Weather.Grib2.ComplexPacking do
Returns an Erlang array of floats for O(1) index access. 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 def decode_all(params, data) do
%{ %{
reference_value: ref, reference_value: ref,

View file

@ -11,6 +11,7 @@ defmodule Microwaveprop.Weather.Grib2.Extractor do
Returns `{:ok, %{"VAR:LEVEL" => float}}` or `{:error, term}`. 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 def extract_points(binary, lat, lon) do
messages = split_messages(binary) messages = split_messages(binary)
@ -33,6 +34,8 @@ defmodule Microwaveprop.Weather.Grib2.Extractor do
Takes a binary and a list of `{lat, lon}` tuples. Takes a binary and a list of `{lat, lon}` tuples.
Returns `{:ok, %{{lat, lon} => %{"VAR:LEVEL" => float}}}` or `{:error, term}`. 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 def extract_grid(binary, points) do
messages = split_messages(binary) 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 Split a binary blob into individual GRIB2 messages by scanning for "GRIB" magic bytes
and reading the total length from the indicator section. and reading the total length from the indicator section.
""" """
@spec split_messages(binary()) :: [binary()]
def split_messages(binary), do: split_messages(binary, []) def split_messages(binary), do: split_messages(binary, [])
@doc """ @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 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 def linear_index({i, j}, nx, _scan_mode) do
j * nx + i j * nx + i
end end

View file

@ -8,6 +8,8 @@ defmodule Microwaveprop.Weather.Grib2.LambertConformal do
Returns `{:ok, {i, j}}` or `{:error, :outside_grid}`. 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 def to_grid_index(grid, lat, lon) do
%{ %{
nx: nx, nx: nx,

View file

@ -6,6 +6,16 @@ defmodule Microwaveprop.Weather.Grib2.Section do
Returns `{:ok, %{grid_params, product, packing_params, data, bitmap}}`. 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 def parse_message(<<"GRIB", _reserved::16, _discipline::8, 2::8, total_length::64-big, rest::binary>> = msg) do
discipline = :binary.at(msg, 6) discipline = :binary.at(msg, 6)
# Skip indicator section (16 bytes), parse remaining sections # Skip indicator section (16 bytes), parse remaining sections
@ -22,6 +32,7 @@ defmodule Microwaveprop.Weather.Grib2.Section do
@doc """ @doc """
Identify variable name from discipline, parameter category, and parameter number. 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, 0), do: "TMP"
def identify_var(0, 0, 6), do: "DPT" def identify_var(0, 0, 6), do: "DPT"
def identify_var(0, 3, 0), do: "PRES" def identify_var(0, 3, 0), do: "PRES"
@ -39,6 +50,7 @@ defmodule Microwaveprop.Weather.Grib2.Section do
@doc """ @doc """
Identify level string from surface type and scaled value. 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(1, _value), do: "surface"
def identify_level(100, value_pa), do: "#{div(value_pa, 100)} mb" def identify_level(100, value_pa), do: "#{div(value_pa, 100)} mb"
def identify_level(103, value_m), do: "#{value_m} m above ground" def identify_level(103, value_m), do: "#{value_m} m above ground"
@ -52,6 +64,7 @@ defmodule Microwaveprop.Weather.Grib2.Section do
@doc """ @doc """
Decode a 16-bit sign-magnitude integer. 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(<<0::1, value::15>>), do: value
def sign_magnitude_16(<<1::1, value::15>>), do: -value def sign_magnitude_16(<<1::1, value::15>>), do: -value

View file

@ -9,6 +9,7 @@ defmodule Microwaveprop.Weather.Grib2.SimplePacking do
Where R = reference_value, E = binary_scale, D = decimal_scale, Where R = reference_value, E = binary_scale, D = decimal_scale,
X = raw packed integer at the given index. 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 def extract_value(%{bits_per_value: 0} = params, _data, _index) do
{:ok, params.reference_value * :math.pow(10, -params.decimal_scale)} {:ok, params.reference_value * :math.pow(10, -params.decimal_scale)}
end end
@ -36,6 +37,7 @@ defmodule Microwaveprop.Weather.Grib2.SimplePacking do
Returns `{:ok, %{index => value}}`. Out-of-range indices are silently skipped. 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 def extract_values(%{bits_per_value: 0} = params, _data, indices) do
value = params.reference_value * :math.pow(10, -params.decimal_scale) value = params.reference_value * :math.pow(10, -params.decimal_scale)
{:ok, Map.new(indices, fn i -> {i, value} end)} {:ok, Map.new(indices, fn i -> {i, value} end)}

View file

@ -9,6 +9,17 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do
require Logger 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 @undefined_value 9.999e20
@doc """ @doc """
@ -19,6 +30,7 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do
Returns `{:ok, %{{lat, lon} => %{"VAR:LEVEL" => float}}}` or `{:error, reason}`. 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 def extract_grid(grib_binary, match_pattern, grid_spec) do
if available?() do if available?() do
extract_with_wgrib2(grib_binary, match_pattern, grid_spec) 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 of a binary. Avoids loading the entire file into memory useful for
large native-level HRRR files (~530 MB). 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 def extract_grid_from_file(grib_path, match_pattern, grid_spec) do
if available?() do if available?() do
extract_file_with_wgrib2(grib_path, match_pattern, grid_spec) 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 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). 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 def extract_grid_from_file_mapped(grib_path, match_pattern, grid_spec, cell_reducer) do
if available?() do if available?() do
extract_file_mapped_with_wgrib2(grib_path, match_pattern, grid_spec, cell_reducer) extract_file_mapped_with_wgrib2(grib_path, match_pattern, grid_spec, cell_reducer)
@ -61,6 +81,7 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do
end end
@doc "Check if wgrib2 is available on the system." @doc "Check if wgrib2 is available on the system."
@spec available?() :: boolean()
def available?, do: wgrib2_path() != nil def available?, do: wgrib2_path() != nil
defp wgrib2_path, do: System.find_executable("wgrib2") 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(), Each entry is `%{datetime: DateTime.t() | nil, var: String.t(),
level: String.t(), values: %{{lat, lon} => float}}`. 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 def extract_grid_messages(grib_binary, match_pattern, grid_spec) do
if available?() do if available?() do
extract_messages_with_wgrib2(grib_binary, match_pattern, grid_spec) 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}`. 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 def extract_points_from_file(grib_path, match_pattern, points) when is_list(points) do
if available?() do if available?() do
extract_points_with_wgrib2(grib_path, match_pattern, points) extract_points_with_wgrib2(grib_path, match_pattern, points)

View file

@ -43,8 +43,11 @@ defmodule Microwaveprop.Weather.HrrrClient do
# --- Public API --- # --- Public API ---
@spec surface_messages() :: [%{var: String.t(), level: String.t()}]
def surface_messages, do: @surface_messages 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 def fetch_grid(points, run_time, opts \\ []) do
hour_dt = nearest_hrrr_hour(run_time) hour_dt = nearest_hrrr_hour(run_time)
date = DateTime.to_date(hour_dt) date = DateTime.to_date(hour_dt)
@ -77,6 +80,7 @@ defmodule Microwaveprop.Weather.HrrrClient do
end end
end end
@spec fetch_profile(float(), float(), DateTime.t()) :: {:ok, map()} | {:error, term()}
def fetch_profile(lat, lon, valid_time) do def fetch_profile(lat, lon, valid_time) do
hour_dt = nearest_hrrr_hour(valid_time) hour_dt = nearest_hrrr_hour(valid_time)
date = DateTime.to_date(hour_dt) date = DateTime.to_date(hour_dt)
@ -103,6 +107,7 @@ defmodule Microwaveprop.Weather.HrrrClient do
end end
end end
@spec nearest_hrrr_hour(DateTime.t()) :: DateTime.t()
def nearest_hrrr_hour(dt) do def nearest_hrrr_hour(dt) do
total_seconds = dt.minute * 60 + dt.second total_seconds = dt.minute * 60 + dt.second
rounded_dt = DateTime.add(dt, -total_seconds, :second) rounded_dt = DateTime.add(dt, -total_seconds, :second)
@ -114,6 +119,7 @@ defmodule Microwaveprop.Weather.HrrrClient do
end end
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 def hrrr_url(date, hour, product, forecast_hour \\ 0) do
date_str = Calendar.strftime(date, "%Y%m%d") date_str = Calendar.strftime(date, "%Y%m%d")
hour_str = hour |> Integer.to_string() |> String.pad_leading(2, "0") 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}" "#{hrrr_base()}/hrrr.#{date_str}/conus/#{file}"
end end
@spec parse_idx(String.t()) :: [%{msg: integer(), offset: integer(), var: String.t(), level: String.t()}]
def parse_idx(text) do def parse_idx(text) do
text text
|> String.split("\n") |> String.split("\n")
@ -144,6 +151,9 @@ defmodule Microwaveprop.Weather.HrrrClient do
end) end)
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 def byte_ranges_for_messages(idx_entries, wanted) do
Enum.flat_map(wanted, fn w -> Enum.flat_map(wanted, fn w ->
idx_entries idx_entries
@ -164,12 +174,14 @@ defmodule Microwaveprop.Weather.HrrrClient do
end) end)
end end
@spec merge_grid_data(map(), map()) :: map()
def merge_grid_data(sfc_grid, prs_grid) do def merge_grid_data(sfc_grid, prs_grid) do
Map.merge(sfc_grid, prs_grid, fn _point, sfc_data, prs_data -> Map.merge(sfc_grid, prs_grid, fn _point, sfc_data, prs_data ->
Map.merge(sfc_data, prs_data) Map.merge(sfc_data, prs_data)
end) end)
end end
@spec build_profile(map()) :: map()
def build_profile(parsed) do def build_profile(parsed) do
sfc_temp_k = parsed["TMP:2 m above ground"] sfc_temp_k = parsed["TMP:2 m above ground"]
sfc_dpt_k = parsed["DPT: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 Public so the native-level client can reuse it rather than
duplicating the HTTP/parallelism/cache logic. 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, []), do: {:ok, <<>>}
def download_grib_ranges(url, ranges) do def download_grib_ranges(url, ranges) do
@ -349,6 +363,8 @@ defmodule Microwaveprop.Weather.HrrrClient do
Returns `:ok` or `{:error, reason}`. 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, [], _dest_path), do: :ok
def download_grib_ranges_to_file(url, ranges, dest_path) do def download_grib_ranges_to_file(url, ranges, dest_path) do

View file

@ -27,6 +27,7 @@ defmodule Microwaveprop.Weather.HrrrClimatology do
field :sample_count, :integer field :sample_count, :integer
end end
@spec changeset(%__MODULE__{}, map()) :: Ecto.Changeset.t()
def changeset(record, attrs) do def changeset(record, attrs) do
record record
|> cast(attrs, [:lat, :lon, :month, :hour, :mean_surface_temp_c, :stddev_surface_temp_c, :sample_count]) |> cast(attrs, [:lat, :lon, :month, :hour, :mean_surface_temp_c, :stddev_surface_temp_c, :sample_count])

View file

@ -34,9 +34,11 @@ defmodule Microwaveprop.Weather.HrrrNativeClient do
defp hrrr_base, do: Application.get_env(:microwaveprop, :hrrr_base_url, @hrrr_base_default) 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)." @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) def native_level_count, do: Enum.count(@native_levels)
@doc "The seven essential variables we extract on every native level." @doc "The seven essential variables we extract on every native level."
@spec native_variables() :: [String.t()]
def native_variables, do: @native_variables def native_variables, do: @native_variables
@doc """ @doc """
@ -44,6 +46,7 @@ defmodule Microwaveprop.Weather.HrrrNativeClient do
file. 7 vars × 50 levels = 350 messages, matching the spike in file. 7 vars × 50 levels = 350 messages, matching the spike in
Task 1.1. Task 1.1.
""" """
@spec native_messages() :: [%{var: String.t(), level: String.t()}]
def native_messages do def native_messages do
for level <- @native_levels, var <- @native_variables do for level <- @native_levels, var <- @native_variables do
%{var: var, level: "#{level} hybrid level"} %{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) 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" "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 def hrrr_native_url(date, hour, forecast_hour \\ 0) do
date_str = Calendar.strftime(date, "%Y%m%d") date_str = Calendar.strftime(date, "%Y%m%d")
hour_str = hour |> Integer.to_string() |> String.pad_leading(2, "0") 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, lets us unit-test every invariant we care about (array lengths,
ordering, surface scalar caching) without touching the network. ordering, surface scalar caching) without touching the network.
""" """
@spec build_native_profile(map()) :: map()
def build_native_profile(parsed) when is_map(parsed) do def build_native_profile(parsed) when is_map(parsed) do
levels = levels =
@native_levels @native_levels
@ -121,6 +126,7 @@ defmodule Microwaveprop.Weather.HrrrNativeClient do
Wraps `HrrrClient.byte_ranges_for_messages/2` with our native Wraps `HrrrClient.byte_ranges_for_messages/2` with our native
message list so callers don't have to know both. 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 def essential_byte_ranges(idx_entries) do
HrrrClient.byte_ranges_for_messages(idx_entries, native_messages()) HrrrClient.byte_ranges_for_messages(idx_entries, native_messages())
end end
@ -133,6 +139,7 @@ defmodule Microwaveprop.Weather.HrrrNativeClient do
Messages needed for duct detection only (4 vars × 50 levels = 200 messages). Messages needed for duct detection only (4 vars × 50 levels = 200 messages).
~300 MB download vs ~530 MB for all 7 variables. ~300 MB download vs ~530 MB for all 7 variables.
""" """
@spec duct_messages() :: [%{var: String.t(), level: String.t()}]
def duct_messages do def duct_messages do
for level <- @native_levels, var <- @duct_variables do for level <- @native_levels, var <- @duct_variables do
%{var: var, level: "#{level} hybrid level"} %{var: var, level: "#{level} hybrid level"}
@ -142,6 +149,7 @@ defmodule Microwaveprop.Weather.HrrrNativeClient do
@doc """ @doc """
Byte ranges for duct-detection variables only. 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 def duct_byte_ranges(idx_entries) do
HrrrClient.byte_ranges_for_messages(idx_entries, duct_messages()) HrrrClient.byte_ranges_for_messages(idx_entries, duct_messages())
end end
@ -155,6 +163,8 @@ defmodule Microwaveprop.Weather.HrrrNativeClient do
Returns `{:ok, %{{lat, lon} => duct_metrics}}` or `{:error, reason}`. 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 def fetch_native_duct_grid(date, hour, grid_spec, forecast_hour \\ 0) do
alias Microwaveprop.Weather.Grib2.Wgrib2 alias Microwaveprop.Weather.Grib2.Wgrib2
@ -252,6 +262,8 @@ defmodule Microwaveprop.Weather.HrrrNativeClient do
Returns `{:ok, %{{lat, lon} => native_profile_map}}` or `{:error, reason}`. 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 def extract_native_profiles_from_file(grib_path, points) when is_list(points) do
alias Microwaveprop.Weather.Grib2.Wgrib2 alias Microwaveprop.Weather.Grib2.Wgrib2
@ -289,6 +301,8 @@ defmodule Microwaveprop.Weather.HrrrNativeClient do
Returns `%{{lat, lon} => native_profile_map}` where each profile Returns `%{{lat, lon} => native_profile_map}` where each profile
map has the shape expected by `HrrrNativeProfile.changeset/2`. 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 def extract_native_profiles(grib_binary, points) when is_list(points) do
alias Microwaveprop.Weather.Grib2.Wgrib2 alias Microwaveprop.Weather.Grib2.Wgrib2

View file

@ -54,6 +54,8 @@ defmodule Microwaveprop.Weather.HrrrNativeProfile do
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@type t :: %__MODULE__{}
@required ~w(valid_time lat lon)a @required ~w(valid_time lat lon)a
@optional ~w( @optional ~w(
run_time level_count heights_m temp_k spfh pressure_pa 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 ducts best_duct_band_ghz
)a )a
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(profile, attrs) do def changeset(profile, attrs) do
profile profile
|> cast(attrs, @required ++ @optional) |> cast(attrs, @required ++ @optional)

View file

@ -27,9 +27,12 @@ defmodule Microwaveprop.Weather.HrrrProfile do
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@type t :: %__MODULE__{}
@required_fields ~w(valid_time lat lon)a @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 @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 def changeset(hrrr_profile, attrs) do
hrrr_profile hrrr_profile
|> cast(attrs, @required_fields ++ @optional_fields) |> cast(attrs, @required_fields ++ @optional_fields)

View file

@ -5,10 +5,12 @@ defmodule Microwaveprop.Weather.IemClient do
# --- URL builders --- # --- URL builders ---
@spec network_url(String.t()) :: String.t()
def network_url(network) do def network_url(network) do
"#{@iem_base}/json/network.py?network=#{network}" "#{@iem_base}/json/network.py?network=#{network}"
end end
@spec asos_url(String.t(), DateTime.t(), DateTime.t()) :: String.t()
def asos_url(station_id, start_dt, end_dt) do def asos_url(station_id, start_dt, end_dt) do
"#{@iem_base}/cgi-bin/request/asos.py" <> "#{@iem_base}/cgi-bin/request/asos.py" <>
"?station=#{station_id}" <> "?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" "&format=onlycomma&latlon=no&elev=no&missing=null&trace=null&direct=no&report_type=3"
end end
@spec raob_url(String.t(), DateTime.t()) :: String.t()
def raob_url(station_id, dt) do def raob_url(station_id, dt) do
ts = format_iem_ts(dt) ts = format_iem_ts(dt)
"#{@iem_base}/json/raob.py?ts=#{ts}&station=#{station_id}" "#{@iem_base}/json/raob.py?ts=#{ts}&station=#{station_id}"
end end
@spec iemre_url(float(), float(), Date.t()) :: String.t()
def iemre_url(lat, lon, date) do def iemre_url(lat, lon, date) do
"#{@iem_base}/iemre/hourly/#{date}/#{lat}/#{lon}/json" "#{@iem_base}/iemre/hourly/#{date}/#{lat}/#{lon}/json"
end end
# --- HTTP fetchers --- # --- HTTP fetchers ---
@spec fetch_network(String.t()) :: {:ok, [map()]} | {:error, term()}
def fetch_network(network) do def fetch_network(network) do
url = network_url(network) url = network_url(network)
@ -47,6 +52,7 @@ defmodule Microwaveprop.Weather.IemClient do
end end
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 def fetch_asos(station_id, start_dt, end_dt) do
url = asos_url(station_id, start_dt, end_dt) url = asos_url(station_id, start_dt, end_dt)
@ -62,6 +68,7 @@ defmodule Microwaveprop.Weather.IemClient do
end end
end end
@spec fetch_raob(String.t(), DateTime.t()) :: {:ok, [map()]} | {:error, term()}
def fetch_raob(station_id, dt) do def fetch_raob(station_id, dt) do
url = raob_url(station_id, dt) url = raob_url(station_id, dt)
@ -77,6 +84,7 @@ defmodule Microwaveprop.Weather.IemClient do
end end
end end
@spec fetch_iemre(float(), float(), Date.t()) :: {:ok, [map()]} | {:error, term()}
def fetch_iemre(lat, lon, date) do def fetch_iemre(lat, lon, date) do
url = iemre_url(lat, lon, date) 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. Fetch current ASOS observations for all stations in the given state networks.
Returns `{:ok, [%{station_code, lat, lon, temp_f, dewpoint_f, ...}]}`. 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 def fetch_current_asos(state_networks) do
results = results =
state_networks state_networks
@ -175,10 +184,12 @@ defmodule Microwaveprop.Weather.IemClient do
# --- Parsers --- # --- Parsers ---
@spec parse_iemre_json(map()) :: [map()]
def parse_iemre_json(json) when is_map(json) do def parse_iemre_json(json) when is_map(json) do
Map.get(json, "data", []) Map.get(json, "data", [])
end end
@spec parse_network_json(map()) :: [map()]
def parse_network_json(json) when is_map(json) do def parse_network_json(json) when is_map(json) do
json json
|> Map.get("stations", []) |> Map.get("stations", [])
@ -192,6 +203,7 @@ defmodule Microwaveprop.Weather.IemClient do
end) end)
end end
@spec parse_asos_csv(String.t()) :: [map()]
def parse_asos_csv(csv_text) do def parse_asos_csv(csv_text) do
csv_text csv_text
|> String.split("\n") |> String.split("\n")
@ -201,6 +213,7 @@ defmodule Microwaveprop.Weather.IemClient do
|> Enum.map(&parse_asos_row/1) |> Enum.map(&parse_asos_row/1)
end end
@spec parse_raob_json(map()) :: [map()]
def parse_raob_json(json) when is_map(json) do def parse_raob_json(json) when is_map(json) do
json json
|> Map.get("profiles", []) |> Map.get("profiles", [])

View file

@ -16,9 +16,12 @@ defmodule Microwaveprop.Weather.IemreObservation do
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@type t :: %__MODULE__{}
@required_fields ~w(lat lon date)a @required_fields ~w(lat lon date)a
@optional_fields ~w(hourly)a @optional_fields ~w(hourly)a
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(observation, attrs) do def changeset(observation, attrs) do
observation observation
|> cast(attrs, @required_fields ++ @optional_fields) |> cast(attrs, @required_fields ++ @optional_fields)

View file

@ -30,9 +30,12 @@ defmodule Microwaveprop.Weather.Metar5minObservation do
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@type t :: %__MODULE__{}
@required_fields ~w(station_id observed_at)a @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 @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 def changeset(observation, attrs) do
observation observation
|> cast(attrs, @required_fields ++ @optional_fields) |> cast(attrs, @required_fields ++ @optional_fields)

View file

@ -21,6 +21,7 @@ defmodule Microwaveprop.Weather.NceiMetarClient do
Returns `{:ok, [parsed_obs]}` or `{:error, reason}`. 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 def fetch(icao, year, month) do
month_str = month |> Integer.to_string() |> String.pad_leading(2, "0") month_str = month |> Integer.to_string() |> String.pad_leading(2, "0")
url = "#{@base_url}/#{year}/#{month_str}/asos-5min-#{icao}-#{year}#{month_str}.dat" 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 key fields without a full METAR parser this covers the data
the scorer and backtest need (temp, dewpoint, wind, pressure, sky). the scorer and backtest need (temp, dewpoint, wind, pressure, sky).
""" """
@spec parse(String.t()) :: [map()]
def parse(text) when is_binary(text) do def parse(text) when is_binary(text) do
text text
|> String.split("\n") |> String.split("\n")

View file

@ -19,9 +19,12 @@ defmodule Microwaveprop.Weather.NexradObservation do
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@type t :: %__MODULE__{}
@required_fields ~w(observed_at lat lon)a @required_fields ~w(observed_at lat lon)a
@optional_fields ~w(mean_reflectivity_dbz max_reflectivity_dbz texture_variance pixel_count)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 def changeset(observation, attrs) do
observation observation
|> cast(attrs, @required_fields ++ @optional_fields) |> cast(attrs, @required_fields ++ @optional_fields)

View file

@ -33,6 +33,7 @@ defmodule Microwaveprop.Weather.RtmaClient do
Fetch RTMA observation for a point at a specific time. Fetch RTMA observation for a point at a specific time.
Returns {:ok, attrs} or {:error, reason}. Returns {:ok, attrs} or {:error, reason}.
""" """
@spec fetch_observation(float(), float(), DateTime.t()) :: {:ok, map()} | {:error, term()}
def fetch_observation(lat, lon, timestamp) do def fetch_observation(lat, lon, timestamp) do
# RTMA runs every hour with 15-min updates; round to nearest hour # RTMA runs every hour with 15-min updates; round to nearest hour
valid_time = %{DateTime.truncate(timestamp, :second) | minute: 0, second: 0} valid_time = %{DateTime.truncate(timestamp, :second) | minute: 0, second: 0}
@ -63,6 +64,7 @@ defmodule Microwaveprop.Weather.RtmaClient do
end end
@doc "Build S3 URL for an RTMA analysis file." @doc "Build S3 URL for an RTMA analysis file."
@spec rtma_url(DateTime.t()) :: String.t()
def rtma_url(valid_time) do def rtma_url(valid_time) do
date_str = Calendar.strftime(valid_time, "%Y%m%d") date_str = Calendar.strftime(valid_time, "%Y%m%d")
hour_str = valid_time.hour |> Integer.to_string() |> String.pad_leading(2, "0") hour_str = valid_time.hour |> Integer.to_string() |> String.pad_leading(2, "0")

View file

@ -22,9 +22,12 @@ defmodule Microwaveprop.Weather.RtmaObservation do
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@type t :: %__MODULE__{}
@required_fields ~w(valid_time lat lon)a @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 @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 def changeset(observation, attrs) do
observation observation
|> cast(attrs, @required_fields ++ @optional_fields) |> cast(attrs, @required_fields ++ @optional_fields)

View file

@ -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" @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 def data_url, do: @gfz_url
@spec fetch_solar_indices() :: {:ok, [map()]} | {:error, term()}
def fetch_solar_indices do def fetch_solar_indices do
req_options = Application.get_env(:microwaveprop, :solar_req_options, []) req_options = Application.get_env(:microwaveprop, :solar_req_options, [])
@ -20,10 +22,12 @@ defmodule Microwaveprop.Weather.SolarClient do
end end
end end
@spec filter_since([map()], Date.t()) :: [map()]
def filter_since(records, since_date) do def filter_since(records, since_date) do
Enum.filter(records, fn r -> Date.compare(r.date, since_date) != :lt end) Enum.filter(records, fn r -> Date.compare(r.date, since_date) != :lt end)
end end
@spec parse_gfz_file(String.t()) :: [map()]
def parse_gfz_file(text) do def parse_gfz_file(text) do
text text
|> String.split("\n") |> String.split("\n")
@ -39,6 +43,7 @@ defmodule Microwaveprop.Weather.SolarClient do
end) end)
end end
@spec parse_gfz_row(String.t()) :: {:ok, map()} | :error
def parse_gfz_row(line) do def parse_gfz_row(line) do
fields = String.split(line) fields = String.split(line)

View file

@ -17,9 +17,12 @@ defmodule Microwaveprop.Weather.SolarIndex do
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@type t :: %__MODULE__{}
@required_fields ~w(date)a @required_fields ~w(date)a
@optional_fields ~w(sfi sfi_adjusted sunspot_number ap_index kp_values)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 def changeset(solar_index, attrs) do
solar_index solar_index
|> cast(attrs, @required_fields ++ @optional_fields) |> cast(attrs, @required_fields ++ @optional_fields)

View file

@ -27,9 +27,12 @@ defmodule Microwaveprop.Weather.Sounding do
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@type t :: %__MODULE__{}
@required_fields ~w(station_id observed_at profile level_count)a @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 @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 def changeset(sounding, attrs) do
sounding sounding
|> cast(attrs, @required_fields ++ @optional_fields) |> cast(attrs, @required_fields ++ @optional_fields)

View file

@ -2,11 +2,13 @@ defmodule Microwaveprop.Weather.SoundingParams do
@moduledoc false @moduledoc false
@doc "Buck equation for saturation vapor pressure (hPa) given temperature in °C." @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 def sat_vap_pres(t_c) do
6.1121 * :math.exp((18.678 - t_c / 234.5) * (t_c / (257.14 + t_c))) 6.1121 * :math.exp((18.678 - t_c / 234.5) * (t_c / (257.14 + t_c)))
end end
@doc "Mixing ratio (g/kg) from dewpoint (°C) and pressure (hPa)." @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 def mixing_ratio(t_c, p_mb) do
e = sat_vap_pres(t_c) e = sat_vap_pres(t_c)
622.0 * e / (p_mb - e) 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". Profile is a list of maps with keys: "pres", "hght", "tmpc", "dwpc", "drct", "sknt".
Returns nil if fewer than 3 valid levels. Returns nil if fewer than 3 valid levels.
""" """
@spec derive([map()]) :: map() | nil
def derive(profile) when is_list(profile) do def derive(profile) when is_list(profile) do
sorted = sorted =
profile profile

View file

@ -20,9 +20,12 @@ defmodule Microwaveprop.Weather.Station do
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@type t :: %__MODULE__{}
@required_fields ~w(station_code station_type name lat lon)a @required_fields ~w(station_code station_type name lat lon)a
@optional_fields ~w(wmo_number elevation_m state)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 def changeset(station, attrs) do
station station
|> cast(attrs, @required_fields ++ @optional_fields) |> cast(attrs, @required_fields ++ @optional_fields)

View file

@ -24,9 +24,12 @@ defmodule Microwaveprop.Weather.SurfaceObservation do
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@type t :: %__MODULE__{}
@required_fields ~w(station_id observed_at)a @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 @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 def changeset(observation, attrs) do
observation observation
|> cast(attrs, @required_fields ++ @optional_fields) |> cast(attrs, @required_fields ++ @optional_fields)

View file

@ -13,6 +13,7 @@ defmodule Microwaveprop.Weather.WeatherLayers do
`:temp_850mb`, `:dewpoint_850mb`, `:lapse_rate`, `:inversion_strength`, `:temp_850mb`, `:dewpoint_850mb`, `:lapse_rate`, `:inversion_strength`,
`:inversion_base_m`, `:duct_base_m`, `:duct_strength`. `:inversion_base_m`, `:duct_base_m`, `:duct_strength`.
""" """
@spec derive(map()) :: map()
def derive(row) do def derive(row) do
sorted = sort_profile(row.profile) sorted = sort_profile(row.profile)