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)
end
@type t :: %__MODULE__{}
@doc """
Returns the email address that is automatically granted admin
privileges on registration.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -39,12 +39,15 @@ defmodule Microwaveprop.Commercial.Sample do
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{}
@required_fields ~w(link_id sampled_at)a
@optional_fields ~w(rx_power_0 rx_power_1 tx_power tx_freq_mhz rx_freq_mhz
radio_temp_0_c radio_temp_1_c cur_tx_mod_rate remote_tx_mod_rate
rx_capacity tx_capacity remote_tx_power remote_rx_power_0 remote_rx_power_1
link_state link_uptime link_distance_m)a
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(sample, attrs) do
sample
|> cast(attrs, @required_fields ++ @optional_fields)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -8,6 +8,7 @@ defmodule Microwaveprop.Propagation.Grid do
@step 0.125
@doc "Returns all grid points as `{lat, lon}` tuples covering CONUS at 0.125 degree spacing."
@spec conus_points() :: [{float(), float()}]
def conus_points do
for lat <- float_range(@lat_min, @lat_max, @step),
lon <- float_range(@lon_min, @lon_max, @step) do
@ -16,12 +17,22 @@ defmodule Microwaveprop.Propagation.Grid do
end
@doc "Returns the grid step size in degrees."
@spec step() :: float()
def step, do: @step
@doc "Returns the CONUS bounding box as a map."
@spec bounds() :: %{lat_min: float(), lat_max: float(), lon_min: float(), lon_max: float()}
def bounds, do: %{lat_min: @lat_min, lat_max: @lat_max, lon_min: @lon_min, lon_max: @lon_max}
@doc "Returns the grid specification for wgrib2 -lola extraction."
@spec wgrib2_grid_spec() :: %{
lon_start: float(),
lon_count: non_neg_integer(),
lon_step: float(),
lat_start: float(),
lat_count: non_neg_integer(),
lat_step: float()
}
def wgrib2_grid_spec do
lon_count = round((@lon_max - @lon_min) / @step) + 1
lat_count = round((@lat_max - @lat_min) / @step) + 1

View file

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

View file

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

View file

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

View file

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

View file

@ -36,6 +36,15 @@ defmodule Microwaveprop.Radio.AdifImport do
Parse ADIF string, validate, and deduplicate. Returns the same preview
shape as `CsvImport.preview/2`.
"""
@type preview_result :: %{
valid: [map()],
invalid: [map()],
duplicates: [map()],
total_rows: non_neg_integer(),
submitter_email: String.t()
}
@spec preview(String.t(), String.t()) :: {:ok, preview_result()} | {:error, :no_records}
def preview(adif_string, submitter_email) do
records = parse_adif(adif_string)

View file

@ -4,6 +4,14 @@ defmodule Microwaveprop.Radio.CallsignClient do
@doc """
Fetch location for a callsign. Returns {:ok, %{callsign, gridsquare, lat, lon}} or {:error, reason}.
"""
@type location :: %{
callsign: String.t(),
gridsquare: String.t() | nil,
lat: number(),
lon: number()
}
@spec locate(String.t()) :: {:ok, location()} | {:error, String.t()}
def locate(callsign) do
url = "https://gridmap.org/locate/#{URI.encode(callsign)}"

View file

@ -47,9 +47,12 @@ defmodule Microwaveprop.Radio.Contact do
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{}
@required_fields ~w(station1 station2 qso_timestamp mode band)a
@optional_fields ~w(grid1 grid2 pos1 pos2 distance_km user_id)a
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(contact, attrs) do
contact
|> cast(attrs, @required_fields ++ @optional_fields)
@ -61,6 +64,7 @@ defmodule Microwaveprop.Radio.Contact do
@allowed_modes ~w(CW SSB FM FT8 FT4 Q65)
@allowed_bands Enum.map(~w(1296 2304 3456 5760 10000 24000 47000 68000 75000 122000 134000 241000), &Decimal.new/1)
@spec submission_changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def submission_changeset(contact, attrs) do
contact
|> cast(attrs, @submission_fields)

View file

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

View file

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

View file

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

View file

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

View file

@ -8,6 +8,38 @@ defmodule Microwaveprop.Terrain.TerrainAnalysis do
- Dynamic k-factor from refractivity gradient (Section 2)
"""
@type elevation_point :: %{lat: float(), lon: float(), elev: float(), d: float(), dist_km: float()}
@type analysis_point :: %{
lat: float(),
lon: float(),
d: float(),
elev: float(),
dist_km: float(),
idx: non_neg_integer(),
beam: float(),
eff_terrain: float(),
bulge: float(),
r1: float(),
d1_m: float(),
d2_m: float(),
clearance: float(),
f1_clear: float(),
obstructed: boolean(),
fresnel_penetrated: boolean()
}
@type analysis_result :: %{
points: [analysis_point()],
diffraction_db: number(),
verdict: String.t(),
k_factor: float(),
max_elevation_m: float(),
min_clearance_m: float(),
obstructed_count: non_neg_integer(),
fresnel_hit_count: non_neg_integer()
}
@earth_radius_m 6_371_000.0
@earth_radius_km 6_371.0
@ -20,6 +52,7 @@ defmodule Microwaveprop.Terrain.TerrainAnalysis do
* `:ant_ht_b` - antenna height B in meters (default 0.0)
* `:k_factor` - effective earth radius factor (default 4/3, compute with `k_factor/1`)
"""
@spec analyse([elevation_point()], float(), float(), keyword()) :: analysis_result()
def analyse(profile, dist_km, freq_ghz, opts \\ []) do
ant_ht_a = Keyword.get(opts, :ant_ht_a, 0.0)
ant_ht_b = Keyword.get(opts, :ant_ht_b, 0.0)
@ -98,6 +131,7 @@ defmodule Microwaveprop.Terrain.TerrainAnalysis do
end
@doc "First Fresnel zone radius at a point between two antennas."
@spec fresnel_radius(number(), number(), number()) :: number()
def fresnel_radius(d1_m, d2_m, lambda_m) do
if d1_m <= 0 or d2_m <= 0 do
0
@ -107,6 +141,7 @@ defmodule Microwaveprop.Terrain.TerrainAnalysis do
end
@doc "Earth bulge correction in meters at fractional distance along path."
@spec earth_bulge(float(), float(), float()) :: float()
def earth_bulge(frac, dist_km, k \\ 4 / 3) do
d1 = frac * dist_km * 1000
d2 = (1 - frac) * dist_km * 1000
@ -119,6 +154,7 @@ defmodule Microwaveprop.Terrain.TerrainAnalysis do
J(ν) = 6.9 + 20·log10(((ν0.1)² + 1) + ν 0.1) for ν > 0.78
J(ν) = 0 for ν 0.78
"""
@spec knife_edge_loss(number()) :: number()
def knife_edge_loss(v) when v <= -0.78, do: 0
def knife_edge_loss(v) do
@ -133,6 +169,7 @@ defmodule Microwaveprop.Terrain.TerrainAnalysis do
where h is height above direct ray (positive = above/blocked, negative = below/clear).
"""
@spec diffraction_param(number(), number(), number(), number()) :: float()
def diffraction_param(h, d1_m, d2_m, lambda_m) do
if d1_m <= 0 or d2_m <= 0 or lambda_m <= 0 do
0.0
@ -149,6 +186,7 @@ defmodule Microwaveprop.Terrain.TerrainAnalysis do
Standard atmosphere: dN/dh 39 k 4/3.
Returns 4/3 for nil input.
"""
@spec k_factor(number() | nil) :: float()
def k_factor(nil), do: 4 / 3
def k_factor(dn_dh) do

View file

@ -21,9 +21,12 @@ defmodule Microwaveprop.Terrain.TerrainProfile do
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{}
@required_fields ~w(contact_id sample_count path_points verdict)a
@optional_fields ~w(max_elevation_m min_clearance_m diffraction_db fresnel_hit_count obstructed_count)a
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(profile, attrs) do
profile
|> cast(attrs, @required_fields ++ @optional_fields)

View file

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

View file

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

View file

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

View file

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

View file

@ -25,9 +25,12 @@ defmodule Microwaveprop.Weather.Era5Profile do
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{}
@required_fields ~w(valid_time lat lon)a
@optional_fields ~w(profile hpbl_m pwat_mm surface_temp_c surface_dewpoint_c surface_pressure_mb surface_refractivity min_refractivity_gradient ducting_detected duct_characteristics)a
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(profile, attrs) do
profile
|> cast(attrs, @required_fields ++ @optional_fields)

View file

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

View file

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

View file

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

View file

@ -8,6 +8,8 @@ defmodule Microwaveprop.Weather.Grib2.LambertConformal do
Returns `{:ok, {i, j}}` or `{:error, :outside_grid}`.
"""
@spec to_grid_index(map(), float(), float()) ::
{:ok, {non_neg_integer(), non_neg_integer()}} | {:error, :outside_grid}
def to_grid_index(grid, lat, lon) do
%{
nx: nx,

View file

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

View file

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

View file

@ -9,6 +9,17 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do
require Logger
@typep grid_spec :: %{
lon_start: float(),
lon_count: pos_integer(),
lon_step: float(),
lat_start: float(),
lat_count: pos_integer(),
lat_step: float()
}
@typep point_grid :: %{{float(), float()} => %{String.t() => float()}}
@undefined_value 9.999e20
@doc """
@ -19,6 +30,7 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do
Returns `{:ok, %{{lat, lon} => %{"VAR:LEVEL" => float}}}` or `{:error, reason}`.
"""
@spec extract_grid(binary(), String.t(), grid_spec()) :: {:ok, point_grid()} | {:error, term()}
def extract_grid(grib_binary, match_pattern, grid_spec) do
if available?() do
extract_with_wgrib2(grib_binary, match_pattern, grid_spec)
@ -32,6 +44,8 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do
of a binary. Avoids loading the entire file into memory useful for
large native-level HRRR files (~530 MB).
"""
@spec extract_grid_from_file(Path.t(), String.t(), grid_spec()) ::
{:ok, point_grid()} | {:error, term()}
def extract_grid_from_file(grib_path, match_pattern, grid_spec) do
if available?() do
extract_file_with_wgrib2(grib_path, match_pattern, grid_spec)
@ -52,6 +66,12 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do
Peak memory: binary output (~76 MB for 200 msgs × 95k cells) + one
cell's worth of data at a time + output map (~10 MB of scalars).
"""
@spec extract_grid_from_file_mapped(
Path.t(),
String.t(),
grid_spec(),
(%{String.t() => float()} -> term())
) :: {:ok, %{{float(), float()} => term()}} | {:error, term()}
def extract_grid_from_file_mapped(grib_path, match_pattern, grid_spec, cell_reducer) do
if available?() do
extract_file_mapped_with_wgrib2(grib_path, match_pattern, grid_spec, cell_reducer)
@ -61,6 +81,7 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do
end
@doc "Check if wgrib2 is available on the system."
@spec available?() :: boolean()
def available?, do: wgrib2_path() != nil
defp wgrib2_path, do: System.find_executable("wgrib2")
@ -312,6 +333,17 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do
Each entry is `%{datetime: DateTime.t() | nil, var: String.t(),
level: String.t(), values: %{{lat, lon} => float}}`.
"""
@spec extract_grid_messages(binary(), String.t(), grid_spec()) ::
{:ok,
[
%{
datetime: DateTime.t() | nil,
var: String.t(),
level: String.t(),
values: %{{float(), float()} => float()}
}
]}
| {:error, term()}
def extract_grid_messages(grib_binary, match_pattern, grid_spec) do
if available?() do
extract_messages_with_wgrib2(grib_binary, match_pattern, grid_spec)
@ -475,6 +507,8 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do
Returns `{:ok, %{{lat, lon} => %{"VAR:LEVEL" => float}}}` or `{:error, reason}`.
"""
@spec extract_points_from_file(Path.t(), String.t(), [{float(), float()}]) ::
{:ok, point_grid()} | {:error, term()}
def extract_points_from_file(grib_path, match_pattern, points) when is_list(points) do
if available?() do
extract_points_with_wgrib2(grib_path, match_pattern, points)

View file

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

View file

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

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

View file

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

View file

@ -27,9 +27,12 @@ defmodule Microwaveprop.Weather.HrrrProfile do
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{}
@required_fields ~w(valid_time lat lon)a
@optional_fields ~w(run_time profile hpbl_m pwat_mm surface_temp_c surface_dewpoint_c surface_pressure_mb surface_refractivity min_refractivity_gradient ducting_detected duct_characteristics is_grid_point)a
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(hrrr_profile, attrs) do
hrrr_profile
|> cast(attrs, @required_fields ++ @optional_fields)

View file

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

View file

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

View file

@ -30,9 +30,12 @@ defmodule Microwaveprop.Weather.Metar5minObservation do
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{}
@required_fields ~w(station_id observed_at)a
@optional_fields ~w(temp_f dewpoint_f relative_humidity wind_speed_kts wind_direction_deg sea_level_pressure_mb altimeter_setting sky_condition precip_1h_in wx_codes)a
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(observation, attrs) do
observation
|> cast(attrs, @required_fields ++ @optional_fields)

View file

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

View file

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

View file

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

View file

@ -22,9 +22,12 @@ defmodule Microwaveprop.Weather.RtmaObservation do
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{}
@required_fields ~w(valid_time lat lon)a
@optional_fields ~w(temp_c dewpoint_c pressure_mb wind_u_ms wind_v_ms visibility_m precip_mm)a
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(observation, attrs) do
observation
|> cast(attrs, @required_fields ++ @optional_fields)

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

View file

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

View file

@ -27,9 +27,12 @@ defmodule Microwaveprop.Weather.Sounding do
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{}
@required_fields ~w(station_id observed_at profile level_count)a
@optional_fields ~w(surface_pressure_mb surface_temp_c surface_dewpoint_c surface_refractivity min_refractivity_gradient boundary_layer_depth_m precipitable_water_mm k_index lifted_index ducting_detected duct_characteristics)a
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(sounding, attrs) do
sounding
|> cast(attrs, @required_fields ++ @optional_fields)

View file

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

View file

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

View file

@ -24,9 +24,12 @@ defmodule Microwaveprop.Weather.SurfaceObservation do
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{}
@required_fields ~w(station_id observed_at)a
@optional_fields ~w(temp_f dewpoint_f relative_humidity wind_speed_kts wind_direction_deg sea_level_pressure_mb altimeter_setting sky_condition precip_1h_in wx_codes)a
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(observation, attrs) do
observation
|> cast(attrs, @required_fields ++ @optional_fields)

View file

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