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