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:
parent
7a31d730b4
commit
51e390959c
58 changed files with 510 additions and 0 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, [
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
[] ->
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}"
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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", [])
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue