MRMS
----
Layer the NOAA MRMS PrecipRate product onto the score grid so rain fade
updates every 2 minutes instead of every hour alongside HRRR. New modules:
- Microwaveprop.Weather.MrmsClient: fetches the latest .grib2.gz off the
NCEP mirror (Req auto-decompresses so no gunzip step), writes the raw
GRIB2 to a temp file, and calls the existing wgrib2 wrapper with the
0.125 propagation grid spec to get interpolated cells. Returns a
%{{lat, lon} => mm_per_hour} map with missing-value sentinels dropped.
- Microwaveprop.Weather.MrmsCache: ETS-backed GenServer mirroring
ScoreCache/GridCache. Caches a single "current" entry keyed by
valid_time with PubSub broadcast so peer nodes stay in sync and only
the Oban leader pays the fetch + regrid cost.
- Microwaveprop.Workers.MrmsFetchWorker: cron every 2 minutes, short-
circuits when the cached valid_time already matches the newest file.
Microwaveprop.Propagation.AsosNudge.compute/4 now takes an optional
rain_grid. When a cell has MRMS rain >= 0.1 mm/hr it gets patched onto
the HRRR profile's `precip_mm` field (the scorer already reads it there)
and the cell is re-scored even with no ASOS station nearby. Cells with
MRMS rain below the threshold aren't touched so dry cells keep their
raw HRRR scores (which have the wind/sky/native-gradient signal that
isn't persisted on HrrrProfile rows and would otherwise be lost).
AsosAdjustmentWorker pulls MrmsCache on every tick and passes the grid
through to AsosNudge.compute/4. Also skips the IemClient error branch
that can never happen and handles the ASOS-empty + MRMS-empty case
explicitly. MrmsCache wired into the supervision tree; MrmsFetchWorker
cron entry added to config.exs and dev.exs.
Four new AsosNudge cases cover MRMS-only re-scoring, threshold gating,
and the wet/dry score delta.
Beacons 500
-----------
Beacon.format_freq/1 and format_mw/1 crashed on whole-number floats
(e.g. 24192.0) because `frac == 0.0` could become false under float
rounding while `trim_trailing_zeros/1` stripped the decimal point,
leaving a 1-element list that couldn't be destructured as [_, frac].
Shared format_number/1 helper handles integer input directly and
pattern-matches both the "int-only" and "int + frac" shapes.
Added stream_data property tests covering the whole microwave range for
both integers and floats to catch this class of bug before prod.
UTC clock flash
---------------
The /weather and /map UTC clocks were empty until the JS hook mounted
post-WebSocket, producing a several-second blank spot on initial load
and a clobber risk on sidebar re-renders. Mount now computes a
server-rendered `initial_utc_clock` string and the template seeds the
element with that plus `phx-update="ignore"` so LiveView morphdom won't
overwrite what the hook writes.
319 lines
8.9 KiB
Elixir
319 lines
8.9 KiB
Elixir
defmodule Microwaveprop.Beacons.Beacon do
|
|
@moduledoc """
|
|
A beacon transmitter. Frequency in MHz, coordinates as lat/lon,
|
|
grid as Maidenhead (auto-computed from lat/lon when not supplied),
|
|
power in milliwatts, height above ground in feet.
|
|
"""
|
|
|
|
use Ecto.Schema
|
|
|
|
import Ecto.Changeset
|
|
|
|
alias Microwaveprop.Radio.Maidenhead
|
|
|
|
@keying_entries [
|
|
{"on_off", "On/Off"},
|
|
{"fsk", "FSK"},
|
|
{"fm_voice", "FM Voice"},
|
|
{"wspr", "WSPR"},
|
|
{"q65a_15", "Q65A-15"},
|
|
{"q65a_30", "Q65A-30"},
|
|
{"q65a_60", "Q65A-60"},
|
|
{"q65a_120", "Q65A-120"},
|
|
{"q65b_15", "Q65B-15"},
|
|
{"q65b_30", "Q65B-30"},
|
|
{"q65b_60", "Q65B-60"},
|
|
{"q65b_120", "Q65B-120"},
|
|
{"q65c_15", "Q65C-15"},
|
|
{"q65c_30", "Q65C-30"},
|
|
{"q65c_60", "Q65C-60"},
|
|
{"q65c_120", "Q65C-120"},
|
|
{"q65d_15", "Q65D-15"},
|
|
{"q65d_30", "Q65D-30"},
|
|
{"q65d_60", "Q65D-60"},
|
|
{"q65d_120", "Q65D-120"},
|
|
{"q65e_15", "Q65E-15"},
|
|
{"q65e_30", "Q65E-30"},
|
|
{"q65e_60", "Q65E-60"},
|
|
{"q65e_120", "Q65E-120"}
|
|
]
|
|
|
|
@keyings Enum.map(@keying_entries, fn {k, _} -> k end)
|
|
@keying_labels Map.new(@keying_entries)
|
|
|
|
@spec keyings() :: [String.t()]
|
|
def keyings, do: @keyings
|
|
|
|
@spec keying_label(String.t()) :: String.t()
|
|
def keying_label(key), do: Map.get(@keying_labels, key, key)
|
|
|
|
@doc """
|
|
Formats a milliwatt power value as a plain decimal string, never scientific
|
|
notation. Integers are printed without a decimal; floats keep up to three
|
|
decimals with trailing zeros trimmed.
|
|
"""
|
|
@spec format_mw(number() | nil | term()) :: String.t()
|
|
def format_mw(nil), do: ""
|
|
|
|
def format_mw(mw) when is_number(mw) do
|
|
format_number(mw)
|
|
end
|
|
|
|
def format_mw(mw), do: to_string(mw)
|
|
|
|
@doc "Formats a frequency in MHz with comma separators (e.g. 10368 → \"10,368\")."
|
|
@spec format_freq(number() | nil | term()) :: String.t()
|
|
def format_freq(nil), do: ""
|
|
|
|
def format_freq(mhz) when is_number(mhz) do
|
|
format_number(mhz)
|
|
end
|
|
|
|
def format_freq(mhz), do: to_string(mhz)
|
|
|
|
# Shared formatter for format_mw/1 and format_freq/1. Unconditionally
|
|
# renders to 3 decimal places, trims trailing zeros, and handles the
|
|
# edge case where `trim_trailing_zeros/1` strips the decimal point
|
|
# entirely (e.g. 24192.0 → "24192.000" → "24192", which previously
|
|
# crashed the `[_int, frac_part] = ...` pattern match when
|
|
# `frac != 0.0` due to float rounding error).
|
|
defp format_number(n) when is_integer(n), do: add_commas(n)
|
|
|
|
defp format_number(n) when is_float(n) do
|
|
formatted_int = n |> trunc() |> add_commas()
|
|
decimal = n |> :erlang.float_to_binary(decimals: 3) |> trim_trailing_zeros()
|
|
|
|
case String.split(decimal, ".") do
|
|
[_int_only] -> formatted_int
|
|
[_int, frac_part] -> "#{formatted_int}.#{frac_part}"
|
|
end
|
|
end
|
|
|
|
defp add_commas(int) do
|
|
int
|
|
|> Integer.to_string()
|
|
|> String.reverse()
|
|
|> String.replace(~r/.{3}/, "\\0,")
|
|
|> String.trim_trailing(",")
|
|
|> String.reverse()
|
|
end
|
|
|
|
defp trim_trailing_zeros(str) do
|
|
if String.contains?(str, ".") do
|
|
str |> String.trim_trailing("0") |> String.trim_trailing(".")
|
|
else
|
|
str
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Keying options for `Phoenix.HTML.Form.options_for_select/2`, grouped for a
|
|
cleaner UI when dozens of weak-signal modes are present.
|
|
"""
|
|
@spec keying_options() :: [{String.t(), [{String.t(), String.t()}]}]
|
|
def keying_options do
|
|
[
|
|
{"Simple",
|
|
[
|
|
{"On/Off", "on_off"},
|
|
{"FSK", "fsk"}
|
|
]},
|
|
{"Voice / Digital",
|
|
[
|
|
{"FM Voice", "fm_voice"},
|
|
{"WSPR", "wspr"}
|
|
]},
|
|
{"Q65A", q65_group("a")},
|
|
{"Q65B", q65_group("b")},
|
|
{"Q65C", q65_group("c")},
|
|
{"Q65D", q65_group("d")},
|
|
{"Q65E", q65_group("e")}
|
|
]
|
|
end
|
|
|
|
defp q65_group(letter) do
|
|
for period <- ~w(15 30 60 120) do
|
|
{"Q65#{String.upcase(letter)}-#{period}", "q65#{letter}_#{period}"}
|
|
end
|
|
end
|
|
|
|
@primary_key {:id, :binary_id, autogenerate: true}
|
|
@foreign_key_type :binary_id
|
|
schema "beacons" do
|
|
field :frequency_mhz, :float
|
|
field :callsign, :string
|
|
field :grid, :string
|
|
field :lat, :float
|
|
field :lon, :float
|
|
field :power_mw, :float
|
|
field :height_ft, :integer
|
|
field :on_the_air, :boolean, default: true
|
|
field :approved, :boolean, default: false
|
|
field :keying, :string, default: "on_off"
|
|
field :bearing, :string, default: "omni"
|
|
field :beamwidth_deg, :float
|
|
field :notes, :string
|
|
|
|
belongs_to :user, Microwaveprop.Accounts.User
|
|
|
|
timestamps(type: :utc_datetime)
|
|
end
|
|
|
|
@type t :: %__MODULE__{}
|
|
|
|
@required_fields [:frequency_mhz, :callsign, :lat, :lon, :power_mw, :height_ft, :keying]
|
|
|
|
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
|
def changeset(beacon, attrs) do
|
|
beacon
|
|
|> cast(attrs, [
|
|
:frequency_mhz,
|
|
:callsign,
|
|
:grid,
|
|
:lat,
|
|
:lon,
|
|
:power_mw,
|
|
:height_ft,
|
|
:on_the_air,
|
|
:keying,
|
|
:bearing,
|
|
:beamwidth_deg,
|
|
:notes
|
|
])
|
|
|> update_change(:callsign, fn cs -> cs && String.upcase(String.trim(cs)) end)
|
|
|> update_change(:lat, &round_coord/1)
|
|
|> update_change(:lon, &round_coord/1)
|
|
|> update_change(:height_ft, &round_int/1)
|
|
|> maybe_fill_latlon()
|
|
|> maybe_fill_grid()
|
|
|> normalize_bearing_change()
|
|
|> validate_required(@required_fields)
|
|
|> validate_inclusion(:keying, @keyings)
|
|
|> validate_bearing()
|
|
|> validate_number(:beamwidth_deg, greater_than: 0, less_than_or_equal_to: 360)
|
|
|> validate_number(:frequency_mhz, greater_than: 0)
|
|
|> validate_number(:power_mw, greater_than_or_equal_to: 0)
|
|
|> validate_number(:height_ft, greater_than_or_equal_to: 0)
|
|
|> validate_number(:lat, greater_than_or_equal_to: -90, less_than_or_equal_to: 90)
|
|
|> validate_number(:lon, greater_than_or_equal_to: -180, less_than_or_equal_to: 180)
|
|
|> validate_length(:callsign, min: 3, max: 10)
|
|
|> validate_grid_format()
|
|
end
|
|
|
|
defp round_coord(nil), do: nil
|
|
defp round_coord(v) when is_float(v), do: Float.round(v, 6)
|
|
defp round_coord(v), do: v
|
|
|
|
defp round_int(nil), do: nil
|
|
defp round_int(v) when is_float(v), do: round(v)
|
|
defp round_int(v) when is_integer(v), do: v
|
|
defp round_int(v), do: v
|
|
|
|
# If grid is blank but lat/lon are valid, derive it.
|
|
defp maybe_fill_grid(changeset) do
|
|
grid = get_field(changeset, :grid)
|
|
lat = get_field(changeset, :lat)
|
|
lon = get_field(changeset, :lon)
|
|
|
|
if grid in [nil, ""] and is_number(lat) and is_number(lon) do
|
|
put_change(changeset, :grid, Maidenhead.from_latlon(lat, lon, 6))
|
|
else
|
|
changeset
|
|
end
|
|
end
|
|
|
|
# If lat/lon are blank but grid is a valid Maidenhead, derive them.
|
|
defp maybe_fill_latlon(changeset) do
|
|
grid = get_field(changeset, :grid)
|
|
lat = get_field(changeset, :lat)
|
|
lon = get_field(changeset, :lon)
|
|
|
|
if is_binary(grid) and grid != "" and (lat in [nil, ""] or lon in [nil, ""]) do
|
|
case Maidenhead.to_latlon(grid) do
|
|
{:ok, {new_lat, new_lon}} ->
|
|
changeset
|
|
|> put_change(:lat, Float.round(new_lat * 1.0, 6))
|
|
|> put_change(:lon, Float.round(new_lon * 1.0, 6))
|
|
|
|
:error ->
|
|
changeset
|
|
end
|
|
else
|
|
changeset
|
|
end
|
|
end
|
|
|
|
# Normalize bearing: trim, treat nil/blank/"omni" (any case) as "omni".
|
|
# Anything else is left for validate_bearing to check as a number.
|
|
defp normalize_bearing_change(changeset) do
|
|
current = get_field(changeset, :bearing)
|
|
normalized = normalize_bearing(current)
|
|
|
|
if normalized == current do
|
|
changeset
|
|
else
|
|
put_change(changeset, :bearing, normalized)
|
|
end
|
|
end
|
|
|
|
defp normalize_bearing(nil), do: "omni"
|
|
|
|
defp normalize_bearing(value) when is_binary(value) do
|
|
trimmed = String.trim(value)
|
|
|
|
cond do
|
|
trimmed == "" -> "omni"
|
|
String.downcase(trimmed) == "omni" -> "omni"
|
|
true -> trimmed
|
|
end
|
|
end
|
|
|
|
defp normalize_bearing(value), do: value
|
|
|
|
defp validate_bearing(changeset) do
|
|
case get_field(changeset, :bearing) do
|
|
"omni" ->
|
|
changeset
|
|
|
|
value when is_binary(value) ->
|
|
case Float.parse(value) do
|
|
{n, ""} when n >= 0 and n <= 360 ->
|
|
changeset
|
|
|
|
_ ->
|
|
add_error(changeset, :bearing, ~s(must be "omni" or a number between 0 and 360))
|
|
end
|
|
|
|
_ ->
|
|
changeset
|
|
end
|
|
end
|
|
|
|
defp validate_grid_format(changeset) do
|
|
case get_field(changeset, :grid) do
|
|
nil ->
|
|
add_error(changeset, :grid, "can't be blank")
|
|
|
|
grid ->
|
|
if Maidenhead.valid?(grid) do
|
|
update_change(changeset, :grid, &normalize_grid/1)
|
|
else
|
|
add_error(changeset, :grid, "is not a valid Maidenhead grid")
|
|
end
|
|
end
|
|
end
|
|
|
|
# Field uppercase, square digits, subsquare lowercase (standard form)
|
|
defp normalize_grid(nil), do: nil
|
|
|
|
defp normalize_grid(grid) do
|
|
grid = String.trim(grid)
|
|
len = String.length(grid)
|
|
|
|
if len >= 6 do
|
|
grid |> String.slice(0, 4) |> String.upcase() |> Kernel.<>(String.downcase(String.slice(grid, 4, len - 4)))
|
|
else
|
|
String.upcase(grid)
|
|
end
|
|
end
|
|
end
|