refactor: extract ProfileLookup module from weather.ex (2072→1696 lines, -376)

This commit is contained in:
Graham McInitre 2026-07-16 07:47:47 -05:00
parent 695bbf5cee
commit 5eeac23e83
6 changed files with 940 additions and 1109 deletions

View file

@ -11,12 +11,12 @@ defmodule Microwaveprop.Weather do
alias Microwaveprop.Repo
alias Microwaveprop.Weather.GefsProfile
alias Microwaveprop.Weather.GridCache
alias Microwaveprop.Weather.HrrrClient
alias Microwaveprop.Weather.HrrrNativeProfile
alias Microwaveprop.Weather.HrrrProfile
alias Microwaveprop.Weather.IemClient
alias Microwaveprop.Weather.IemreObservation
alias Microwaveprop.Weather.NarrProfile
alias Microwaveprop.Weather.ProfileLookup
alias Microwaveprop.Weather.ScalarFile
alias Microwaveprop.Weather.SolarIndex
alias Microwaveprop.Weather.Sounding
@ -30,6 +30,26 @@ defmodule Microwaveprop.Weather do
# Approximate km per degree latitude
@km_per_deg_lat 111.0
# Profile-lookup delegates — implementations live in ProfileLookup
defdelegate find_nearest_hrrr(lat, lon, timestamp), to: ProfileLookup
defdelegate hrrr_profiles_for_path(contact), to: ProfileLookup
defdelegate hrrr_profiles_for_contacts(contacts), to: ProfileLookup
defdelegate find_nearest_native_profile(lat, lon, timestamp), to: ProfileLookup
defdelegate narr_profiles_for_path(contact), to: ProfileLookup
defdelegate find_nearest_narr(lat, lon, timestamp), to: ProfileLookup
defdelegate best_profile_for_contact(contact), to: ProfileLookup
defdelegate hrrr_for_contact(contact), to: ProfileLookup
defdelegate narr_for_contact(contact), to: ProfileLookup
defdelegate hrrr_data_fully_present?(contact), to: ProfileLookup
defdelegate has_hrrr_profile?(lat, lon, valid_time), to: ProfileLookup
defdelegate hrrr_points_present_batch(points), to: ProfileLookup
defdelegate iemre_for_contact(contact), to: ProfileLookup
defdelegate iemre_for_path(contact), to: ProfileLookup
defdelegate find_nearest_iemre(lat, lon, timestamp), to: ProfileLookup
defdelegate round_to_hrrr_grid(lat, lon), to: ProfileLookup
defdelegate round_to_iemre_grid(lat, lon), to: ProfileLookup
defdelegate purge_grid_point_profiles(), to: ProfileLookup
@spec find_or_create_station(map()) :: {:ok, Station.t()} | {:error, Ecto.Changeset.t()}
def find_or_create_station(attrs) do
code = attrs[:station_code] || attrs["station_code"]
@ -1530,68 +1550,6 @@ defmodule Microwaveprop.Weather do
end)
end
@spec has_hrrr_profile?(float(), float(), DateTime.t()) :: boolean()
def has_hrrr_profile?(lat, lon, valid_time) do
dlat = 0.07
dlon = 0.07
HrrrProfile
|> where(
[h],
h.lat >= ^(lat - dlat) and h.lat <= ^(lat + dlat) and
h.lon >= ^(lon - dlon) and h.lon <= ^(lon + dlon) and
h.valid_time == ^valid_time
)
|> Repo.exists?()
end
@doc """
Returns `true` when every path point for `contact` (pos1 midpoint pos2,
or just pos1 for one-endpoint contacts) already has an HRRR profile at the
nearest HRRR hour. Lets the enrichment enqueuer skip :queued :queued
churn when the backing data is already present.
Returns `false` if `pos1` or `qso_timestamp` is nil a contact with no
position or no timestamp can't be looked up.
"""
@spec hrrr_data_fully_present?(
Contact.t()
| %{
required(:pos1) => term(),
required(:qso_timestamp) => DateTime.t() | nil,
optional(:pos2) => term()
}
) :: boolean()
def hrrr_data_fully_present?(%{pos1: nil}), do: false
def hrrr_data_fully_present?(%{qso_timestamp: nil}), do: false
def hrrr_data_fully_present?(contact) do
rounded = HrrrClient.nearest_hrrr_hour(contact.qso_timestamp)
case Radio.contact_path_points(contact) do
[] ->
false
points ->
Enum.all?(points, fn {lat, lon} ->
{rlat, rlon} = round_to_hrrr_grid(lat, lon)
has_hrrr_profile?(rlat, rlon, rounded)
end)
end
end
@spec hrrr_for_contact(map()) :: HrrrProfile.t() | nil
def hrrr_for_contact(%{pos1: nil}), do: nil
def hrrr_for_contact(contact) do
lat = contact.pos1["lat"]
lon = contact.pos1["lon"]
if lat && lon do
find_nearest_hrrr(lat, lon, contact.qso_timestamp)
end
end
@doc """
Returns just `hrrr_native_profiles.best_duct_band_ghz` near the
given cell. Convenience wrapper around `nearest_native_duct_info/3`
@ -1698,160 +1656,6 @@ defmodule Microwaveprop.Weather do
end
end
@spec find_nearest_hrrr(float(), float(), DateTime.t()) :: HrrrProfile.t() | nil
def find_nearest_hrrr(lat, lon, timestamp) do
dlat = 0.07
dlon = 0.07
time_start = DateTime.add(timestamp, -3600, :second)
time_end = DateTime.add(timestamp, 3600, :second)
HrrrProfile
|> where(
[h],
h.lat >= ^(lat - dlat) and h.lat <= ^(lat + dlat) and
h.lon >= ^(lon - dlon) and h.lon <= ^(lon + dlon) and
h.valid_time >= ^time_start and h.valid_time <= ^time_end
)
|> order_by([h],
asc:
fragment(
"ABS(? - ?) + ABS(? - ?) + ABS(EXTRACT(EPOCH FROM ? - ?))",
h.lat,
^lat,
h.lon,
^lon,
h.valid_time,
^timestamp
)
)
|> limit(1)
|> Repo.one()
end
@spec hrrr_profiles_for_path(map()) :: [HrrrProfile.t()]
def hrrr_profiles_for_path(%{pos1: nil}), do: []
def hrrr_profiles_for_path(contact) do
contact
|> Radio.contact_path_points()
|> Enum.map(fn {lat, lon} -> find_nearest_hrrr(lat, lon, contact.qso_timestamp) end)
|> Enum.reject(&is_nil/1)
end
@doc """
Batch version of `hrrr_profiles_for_path/1`. Queries all HRRR profiles
for a list of contacts in a single DB round-trip, then maps results
back per-contact. Returns a list of `{contact, [profile]}` tuples.
"""
@spec hrrr_profiles_for_contacts([map()]) :: [{map(), [HrrrProfile.t()]}]
def hrrr_profiles_for_contacts(contacts) do
for_result =
for c <- contacts, c.pos1 != nil, {lat, lon} <- Radio.contact_path_points(c) do
{{lat, lon, c.qso_timestamp}, c}
end
points =
Enum.uniq_by(for_result, fn {key, _} -> key end)
{keys, contact_map} =
Enum.reduce(points, {[], %{}}, fn {{lat, lon, ts} = key, c}, {ks, cm} ->
{[key | ks], Map.update(cm, c, [key], &[key | &1])}
end)
index = find_nearest_hrrrs_batch(keys)
Enum.map(contacts, fn c ->
point_keys = Map.get(contact_map, c, [])
profiles = point_keys |> Enum.map(&Map.get(index, &1)) |> Enum.reject(&is_nil/1)
{c, profiles}
end)
end
# Batch nearest-HRRR lookup: query all profiles in the union bounding
# box once, then match each point to its nearest profile in Elixir.
@spec find_nearest_hrrrs_batch([{float(), float(), DateTime.t()}]) :: %{
{float(), float(), DateTime.t()} => HrrrProfile.t()
}
defp find_nearest_hrrrs_batch([]), do: %{}
defp find_nearest_hrrrs_batch(points) do
d = 0.07
margin_s = 3600
{lats, lons, times} = Enum.unzip(points)
lat_min = Enum.min(lats) - d
lat_max = Enum.max(lats) + d
lon_min = Enum.min(lons) - d
lon_max = Enum.max(lons) + d
time_min = times |> Enum.min_by(&DateTime.to_unix/1) |> DateTime.add(-margin_s, :second)
time_max = times |> Enum.max_by(&DateTime.to_unix/1) |> DateTime.add(margin_s, :second)
profiles =
Repo.all(
from(h in HrrrProfile,
where:
h.lat >= ^lat_min and h.lat <= ^lat_max and
h.lon >= ^lon_min and h.lon <= ^lon_max and
h.valid_time >= ^time_min and h.valid_time <= ^time_max,
select: %{lat: h.lat, lon: h.lon, valid_time: h.valid_time, profile: h}
)
)
Map.new(points, fn {lat, lon, ts} ->
nearest =
Enum.min_by(
profiles,
fn p ->
abs(p.lat - lat) + abs(p.lon - lon) + abs(DateTime.diff(p.valid_time, ts))
end,
fn -> nil end
)
{{lat, lon, ts}, nearest && nearest.profile}
end)
end
@spec find_nearest_native_profile(float(), float(), DateTime.t()) ::
HrrrNativeProfile.t() | nil
def find_nearest_native_profile(lat, lon, timestamp) do
dlat = 0.07
dlon = 0.07
time_start = DateTime.add(timestamp, -3600, :second)
time_end = DateTime.add(timestamp, 3600, :second)
HrrrNativeProfile
|> where(
[n],
n.lat >= ^(lat - dlat) and n.lat <= ^(lat + dlat) and
n.lon >= ^(lon - dlon) and n.lon <= ^(lon + dlon) and
n.valid_time >= ^time_start and n.valid_time <= ^time_end
)
|> order_by([n],
asc:
fragment(
"ABS(? - ?) + ABS(? - ?) + ABS(EXTRACT(EPOCH FROM ? - ?))",
n.lat,
^lat,
n.lon,
^lon,
n.valid_time,
^timestamp
)
)
|> limit(1)
|> Repo.one()
end
@doc """
Find the best available atmospheric profile for a contact.
Tries HRRR first (3 km, hourly), falls back to NARR (32 km, 3-hourly).
Returns the profile struct or nil.
"""
@spec best_profile_for_contact(map()) :: HrrrProfile.t() | NarrProfile.t() | nil
def best_profile_for_contact(contact) do
hrrr_for_contact(contact) || narr_for_contact(contact)
end
@doc "Find all atmospheric profiles along a contact's path, from any source."
@spec profiles_along_path(map()) :: [HrrrProfile.t() | NarrProfile.t()]
def profiles_along_path(contact) do
@ -1864,61 +1668,6 @@ defmodule Microwaveprop.Weather do
end
end
@spec narr_for_contact(map()) :: NarrProfile.t() | nil
def narr_for_contact(%{pos1: nil}), do: nil
def narr_for_contact(contact) do
lat = contact.pos1["lat"]
lon = contact.pos1["lon"]
if lat && lon do
find_nearest_narr(lat, lon, contact.qso_timestamp)
end
end
@spec narr_profiles_for_path(map()) :: [NarrProfile.t()]
def narr_profiles_for_path(%{pos1: nil}), do: []
def narr_profiles_for_path(contact) do
contact
|> Radio.contact_path_points()
|> Enum.map(fn {lat, lon} -> find_nearest_narr(lat, lon, contact.qso_timestamp) end)
|> Enum.reject(&is_nil/1)
end
@spec find_nearest_narr(float(), float(), DateTime.t()) :: NarrProfile.t() | nil
def find_nearest_narr(lat, lon, timestamp) do
dlat = 0.15
dlon = 0.15
time_start = DateTime.add(timestamp, -1800, :second)
time_end = DateTime.add(timestamp, 1800, :second)
NarrProfile
|> where(
[p],
p.lat >= ^(lat - dlat) and p.lat <= ^(lat + dlat) and
p.lon >= ^(lon - dlon) and p.lon <= ^(lon + dlon) and
p.valid_time >= ^time_start and p.valid_time <= ^time_end
)
|> order_by([p],
asc:
fragment(
"ABS(? - ?) + ABS(? - ?)",
p.lat,
^lat,
p.lon,
^lon
)
)
|> limit(1)
|> Repo.one()
end
@spec round_to_hrrr_grid(float(), float()) :: {float(), float()}
def round_to_hrrr_grid(lat, lon) do
{Float.round(lat / 1.0, 2), Float.round(lon / 1.0, 2)}
end
@doc """
Delete every `is_grid_point = true` row from `hrrr_profiles`, regardless of
age. Grid-point profiles are historical the propagation grid now lives in
@ -1928,51 +1677,6 @@ defmodule Microwaveprop.Weather do
Preserves QSO-linked rows (`is_grid_point = false`), which remain the data
path for contact enrichment and the `/path` calculator.
"""
@spec purge_grid_point_profiles() :: non_neg_integer()
def purge_grid_point_profiles do
deleted =
Enum.reduce(hrrr_profile_partitions(), 0, fn partition, acc ->
%{num_rows: n} =
Repo.query!(
~s(DELETE FROM "#{partition}" WHERE is_grid_point = true),
[],
timeout: 600_000
)
if n > 0 do
Logger.info("Purged #{n} grid-point rows from #{partition}")
end
acc + n
end)
deleted
end
@spec hrrr_profile_partitions() :: [String.t()]
defp hrrr_profile_partitions do
{:ok, %{rows: rows}} =
Repo.query(
"""
SELECT child.relname
FROM pg_inherits
JOIN pg_class parent ON pg_inherits.inhparent = parent.oid
JOIN pg_class child ON pg_inherits.inhrelid = child.oid
WHERE parent.relname = 'hrrr_profiles'
ORDER BY child.relname
""",
[],
timeout: 60_000
)
Enum.map(rows, fn [name] -> name end)
end
@spec round_to_iemre_grid(float(), float()) :: {float(), float()}
def round_to_iemre_grid(lat, lon) do
{Float.round(lat * 8) / 8, Float.round(lon * 8) / 8}
end
@spec upsert_iemre_observation(map()) :: {:ok, IemreObservation.t()} | {:error, Ecto.Changeset.t()}
def upsert_iemre_observation(attrs) do
%IemreObservation{}
@ -1989,36 +1693,4 @@ defmodule Microwaveprop.Weather do
|> where([i], i.lat == ^lat and i.lon == ^lon and i.date == ^date)
|> Repo.exists?()
end
@spec iemre_for_contact(map()) :: IemreObservation.t() | nil
def iemre_for_contact(%{pos1: nil}), do: nil
def iemre_for_contact(contact) do
lat = contact.pos1["lat"]
lon = contact.pos1["lon"]
if lat && lon do
find_nearest_iemre(lat, lon, contact.qso_timestamp)
end
end
@spec find_nearest_iemre(float(), float(), DateTime.t()) :: IemreObservation.t() | nil
def find_nearest_iemre(lat, lon, timestamp) do
{rlat, rlon} = round_to_iemre_grid(lat, lon)
date = DateTime.to_date(timestamp)
IemreObservation
|> where([i], i.lat == ^rlat and i.lon == ^rlon and i.date == ^date)
|> Repo.one()
end
@spec iemre_for_path(map()) :: [IemreObservation.t()]
def iemre_for_path(%{pos1: nil}), do: []
def iemre_for_path(contact) do
contact
|> Radio.contact_path_points()
|> Enum.map(fn {lat, lon} -> find_nearest_iemre(lat, lon, contact.qso_timestamp) end)
|> Enum.reject(&is_nil/1)
end
end

View file

@ -0,0 +1,428 @@
defmodule Microwaveprop.Weather.ProfileLookup do
@moduledoc """
Profile-lookup functions extracted from `Microwaveprop.Weather` to reduce the
size of the main module. Each function is accessed via `defdelegate` on
`Microwaveprop.Weather`, so all existing callers continue to work unchanged.
"""
import Ecto.Query
alias Microwaveprop.Radio
alias Microwaveprop.Repo
alias Microwaveprop.Weather.HrrrClient
alias Microwaveprop.Weather.HrrrNativeProfile
alias Microwaveprop.Weather.HrrrProfile
alias Microwaveprop.Weather.IemreObservation
alias Microwaveprop.Weather.NarrProfile
require Logger
@spec has_hrrr_profile?(float(), float(), DateTime.t()) :: boolean()
def has_hrrr_profile?(lat, lon, valid_time) do
dlat = 0.07
dlon = 0.07
HrrrProfile
|> where(
[h],
h.lat >= ^(lat - dlat) and h.lat <= ^(lat + dlat) and
h.lon >= ^(lon - dlon) and h.lon <= ^(lon + dlon) and
h.valid_time == ^valid_time
)
|> Repo.exists?()
end
@doc """
Batch version of `has_hrrr_profile?/3`. Accepts a list of
`{{lat, lon}, valid_time}` tuples and returns a `MapSet` of the
tuples that have at least one matching HRRR profile. A single query
covers the union bounding box of all points dramatically cheaper
than N individual `EXISTS` queries when checking hundreds of contacts.
"""
@spec hrrr_points_present_batch([{{float(), float()}, DateTime.t()}]) :: MapSet.t()
def hrrr_points_present_batch([]), do: MapSet.new()
def hrrr_points_present_batch(points) do
d = 0.07
{lat_lons, times} = Enum.unzip(points)
{lats, lons} = Enum.unzip(lat_lons)
lat_min = Enum.min(lats) - d
lat_max = Enum.max(lats) + d
lon_min = Enum.min(lons) - d
lon_max = Enum.max(lons) + d
time_min = Enum.min_by(times, &DateTime.to_unix/1)
time_max = Enum.max_by(times, &DateTime.to_unix/1)
profiles =
Repo.all(
from(h in HrrrProfile,
where:
h.lat >= ^lat_min and h.lat <= ^lat_max and
h.lon >= ^lon_min and h.lon <= ^lon_max and
h.valid_time >= ^time_min and h.valid_time <= ^time_max,
select: {h.lat, h.lon, h.valid_time}
)
)
point_set = for {lat, lon, vt} <- profiles, into: MapSet.new(), do: {lat, lon, vt}
points
|> MapSet.new(fn {{lat, lon}, valid_time} ->
profiles
|> Enum.any?(fn {pl, pn, pvt} ->
abs(pl - lat) <= d and abs(pn - lon) <= d and pvt == valid_time
end)
|> then(fn found -> if found, do: {{lat, lon}, valid_time} end)
end)
|> Enum.reject(&is_nil/1)
|> MapSet.new()
end
@doc """
Returns `true` when every path point for `contact` (pos1 midpoint pos2,
or just pos1 for one-endpoint contacts) already has an HRRR profile at the
nearest HRRR hour. Lets the enrichment enqueuer skip :queued :queued
churn when the backing data is already present.
Returns `false` if `pos1` or `qso_timestamp` is nil a contact with no
position or no timestamp can't be looked up.
"""
@spec hrrr_data_fully_present?(term()) :: boolean()
def hrrr_data_fully_present?(%{pos1: nil}), do: false
def hrrr_data_fully_present?(%{qso_timestamp: nil}), do: false
def hrrr_data_fully_present?(contact) do
rounded = HrrrClient.nearest_hrrr_hour(contact.qso_timestamp)
case Radio.contact_path_points(contact) do
[] ->
false
points ->
Enum.all?(points, fn {lat, lon} ->
{rlat, rlon} = round_to_hrrr_grid(lat, lon)
has_hrrr_profile?(rlat, rlon, rounded)
end)
end
end
@spec hrrr_for_contact(map()) :: HrrrProfile.t() | nil
def hrrr_for_contact(%{pos1: nil}), do: nil
def hrrr_for_contact(contact) do
lat = contact.pos1["lat"]
lon = contact.pos1["lon"]
if lat && lon do
find_nearest_hrrr(lat, lon, contact.qso_timestamp)
end
end
@doc """
Nearest sounding to (`lat`, `lon`) within `radius_km` km and a ±3-hour
window around `timestamp`. Joins `weather_stations` to `soundings` so
the caller gets the raw sounding row (station_id set, derived duct
fields populated) back.
Returns `{:ok, sounding}` or `{:error, :not_found}`. Use this in the
path calculator to surface the nearest RAOB's `ducting_detected` flag
as an independent check on HRRR's pressure-level duct signal, which
under-reads thin surface ducts.
"""
@spec find_nearest_hrrr(float(), float(), DateTime.t()) :: HrrrProfile.t() | nil
def find_nearest_hrrr(lat, lon, timestamp) do
dlat = 0.07
dlon = 0.07
time_start = DateTime.add(timestamp, -3600, :second)
time_end = DateTime.add(timestamp, 3600, :second)
HrrrProfile
|> where(
[h],
h.lat >= ^(lat - dlat) and h.lat <= ^(lat + dlat) and
h.lon >= ^(lon - dlon) and h.lon <= ^(lon + dlon) and
h.valid_time >= ^time_start and h.valid_time <= ^time_end
)
|> order_by([h],
asc:
fragment(
"ABS(? - ?) + ABS(? - ?) + ABS(EXTRACT(EPOCH FROM ? - ?))",
h.lat,
^lat,
h.lon,
^lon,
h.valid_time,
^timestamp
)
)
|> limit(1)
|> Repo.one()
end
@spec hrrr_profiles_for_path(map()) :: [HrrrProfile.t()]
def hrrr_profiles_for_path(%{pos1: nil}), do: []
def hrrr_profiles_for_path(contact) do
contact
|> Radio.contact_path_points()
|> Enum.map(fn {lat, lon} -> find_nearest_hrrr(lat, lon, contact.qso_timestamp) end)
|> Enum.reject(&is_nil/1)
end
@doc """
Batch version of `hrrr_profiles_for_path/1`. Queries all HRRR profiles
for a list of contacts in a single DB round-trip, then maps results
back per-contact. Returns a list of `{contact, [profile]}` tuples.
"""
@spec hrrr_profiles_for_contacts([map()]) :: [{map(), [HrrrProfile.t()]}]
def hrrr_profiles_for_contacts(contacts) do
for_result =
for c <- contacts, c.pos1 != nil, {lat, lon} <- Radio.contact_path_points(c) do
{{lat, lon, c.qso_timestamp}, c}
end
points =
Enum.uniq_by(for_result, fn {key, _} -> key end)
{keys, contact_map} =
Enum.reduce(points, {[], %{}}, fn {{lat, lon, ts} = key, c}, {ks, cm} ->
{[key | ks], Map.update(cm, c, [key], &[key | &1])}
end)
index = find_nearest_hrrrs_batch(keys)
Enum.map(contacts, fn c ->
point_keys = Map.get(contact_map, c, [])
profiles = point_keys |> Enum.map(&Map.get(index, &1)) |> Enum.reject(&is_nil/1)
{c, profiles}
end)
end
# Batch nearest-HRRR lookup: query all profiles in the union bounding
# box once, then match each point to its nearest profile in Elixir.
@spec find_nearest_hrrrs_batch([{float(), float(), DateTime.t()}]) :: %{
{float(), float(), DateTime.t()} => HrrrProfile.t()
}
defp find_nearest_hrrrs_batch([]), do: %{}
defp find_nearest_hrrrs_batch(points) do
d = 0.07
margin_s = 3600
{lats, lons, times} = Enum.unzip(points)
lat_min = Enum.min(lats) - d
lat_max = Enum.max(lats) + d
lon_min = Enum.min(lons) - d
lon_max = Enum.max(lons) + d
time_min = times |> Enum.min_by(&DateTime.to_unix/1) |> DateTime.add(-margin_s, :second)
time_max = times |> Enum.max_by(&DateTime.to_unix/1) |> DateTime.add(margin_s, :second)
profiles =
Repo.all(
from(h in HrrrProfile,
where:
h.lat >= ^lat_min and h.lat <= ^lat_max and
h.lon >= ^lon_min and h.lon <= ^lon_max and
h.valid_time >= ^time_min and h.valid_time <= ^time_max,
select: %{lat: h.lat, lon: h.lon, valid_time: h.valid_time, profile: h}
)
)
Map.new(points, fn {lat, lon, ts} ->
nearest =
Enum.min_by(
profiles,
fn p ->
abs(p.lat - lat) + abs(p.lon - lon) + abs(DateTime.diff(p.valid_time, ts))
end,
fn -> nil end
)
{{lat, lon, ts}, nearest && nearest.profile}
end)
end
@spec find_nearest_native_profile(float(), float(), DateTime.t()) ::
HrrrNativeProfile.t() | nil
def find_nearest_native_profile(lat, lon, timestamp) do
dlat = 0.07
dlon = 0.07
time_start = DateTime.add(timestamp, -3600, :second)
time_end = DateTime.add(timestamp, 3600, :second)
HrrrNativeProfile
|> where(
[n],
n.lat >= ^(lat - dlat) and n.lat <= ^(lat + dlat) and
n.lon >= ^(lon - dlon) and n.lon <= ^(lon + dlon) and
n.valid_time >= ^time_start and n.valid_time <= ^time_end
)
|> order_by([n],
asc:
fragment(
"ABS(? - ?) + ABS(? - ?) + ABS(EXTRACT(EPOCH FROM ? - ?))",
n.lat,
^lat,
n.lon,
^lon,
n.valid_time,
^timestamp
)
)
|> limit(1)
|> Repo.one()
end
@doc """
Find the best available atmospheric profile for a contact.
Tries HRRR first (3 km, hourly), falls back to NARR (32 km, 3-hourly).
Returns the profile struct or nil.
"""
@spec best_profile_for_contact(map()) :: HrrrProfile.t() | NarrProfile.t() | nil
def best_profile_for_contact(contact) do
hrrr_for_contact(contact) || narr_for_contact(contact)
end
@spec narr_for_contact(map()) :: NarrProfile.t() | nil
def narr_for_contact(%{pos1: nil}), do: nil
def narr_for_contact(contact) do
lat = contact.pos1["lat"]
lon = contact.pos1["lon"]
if lat && lon do
find_nearest_narr(lat, lon, contact.qso_timestamp)
end
end
@spec narr_profiles_for_path(map()) :: [NarrProfile.t()]
def narr_profiles_for_path(%{pos1: nil}), do: []
def narr_profiles_for_path(contact) do
contact
|> Radio.contact_path_points()
|> Enum.map(fn {lat, lon} -> find_nearest_narr(lat, lon, contact.qso_timestamp) end)
|> Enum.reject(&is_nil/1)
end
@spec find_nearest_narr(float(), float(), DateTime.t()) :: NarrProfile.t() | nil
def find_nearest_narr(lat, lon, timestamp) do
dlat = 0.15
dlon = 0.15
time_start = DateTime.add(timestamp, -1800, :second)
time_end = DateTime.add(timestamp, 1800, :second)
NarrProfile
|> where(
[p],
p.lat >= ^(lat - dlat) and p.lat <= ^(lat + dlat) and
p.lon >= ^(lon - dlon) and p.lon <= ^(lon + dlon) and
p.valid_time >= ^time_start and p.valid_time <= ^time_end
)
|> order_by([p],
asc:
fragment(
"ABS(? - ?) + ABS(? - ?)",
p.lat,
^lat,
p.lon,
^lon
)
)
|> limit(1)
|> Repo.one()
end
@spec round_to_hrrr_grid(float(), float()) :: {float(), float()}
def round_to_hrrr_grid(lat, lon) do
{Float.round(lat / 1.0, 2), Float.round(lon / 1.0, 2)}
end
@doc """
Delete every `is_grid_point = true` row from `hrrr_profiles`, regardless of
age. Grid-point profiles are historical the propagation grid now lives in
`/data/scores` binary files, nothing reads `is_grid_point = true` rows
anymore, and forecast runs no longer write them. This purge walks each
partition directly so a single DELETE can't scan the whole parent table.
Preserves QSO-linked rows (`is_grid_point = false`), which remain the data
path for contact enrichment and the `/path` calculator.
"""
@spec purge_grid_point_profiles() :: non_neg_integer()
def purge_grid_point_profiles do
deleted =
Enum.reduce(hrrr_profile_partitions(), 0, fn partition, acc ->
%{num_rows: n} =
Repo.query!(
~s(DELETE FROM "#{partition}" WHERE is_grid_point = true),
[],
timeout: 600_000
)
if n > 0 do
Logger.info("Purged #{n} grid-point rows from #{partition}")
end
acc + n
end)
deleted
end
@spec hrrr_profile_partitions() :: [String.t()]
defp hrrr_profile_partitions do
{:ok, %{rows: rows}} =
Repo.query(
"""
SELECT child.relname
FROM pg_inherits
JOIN pg_class parent ON pg_inherits.inhparent = parent.oid
JOIN pg_class child ON pg_inherits.inhrelid = child.oid
WHERE parent.relname = 'hrrr_profiles'
ORDER BY child.relname
""",
[],
timeout: 60_000
)
Enum.map(rows, fn [name] -> name end)
end
@spec round_to_iemre_grid(float(), float()) :: {float(), float()}
def round_to_iemre_grid(lat, lon) do
{Float.round(lat * 8) / 8, Float.round(lon * 8) / 8}
end
@spec iemre_for_contact(map()) :: IemreObservation.t() | nil
def iemre_for_contact(%{pos1: nil}), do: nil
def iemre_for_contact(contact) do
lat = contact.pos1["lat"]
lon = contact.pos1["lon"]
if lat && lon do
find_nearest_iemre(lat, lon, contact.qso_timestamp)
end
end
@spec find_nearest_iemre(float(), float(), DateTime.t()) :: IemreObservation.t() | nil
def find_nearest_iemre(lat, lon, timestamp) do
{rlat, rlon} = round_to_iemre_grid(lat, lon)
date = DateTime.to_date(timestamp)
IemreObservation
|> where([i], i.lat == ^rlat and i.lon == ^rlon and i.date == ^date)
|> Repo.one()
end
@spec iemre_for_path(map()) :: [IemreObservation.t()]
def iemre_for_path(%{pos1: nil}), do: []
def iemre_for_path(contact) do
contact
|> Radio.contact_path_points()
|> Enum.map(fn {lat, lon} -> find_nearest_iemre(lat, lon, contact.qso_timestamp) end)
|> Enum.reject(&is_nil/1)
end
end

View file

@ -98,17 +98,49 @@ defmodule Microwaveprop.Workers.ContactWeatherEnqueueWorker do
# sees 0 missing points, inserts nothing, but the status stays
# :queued and the contact is picked up again next tick).
defp hrrr_placeholder_jobs(contacts) do
# Collect all unique HRRR grid points across all contacts, query
# presence once, then map back per-contact — avoids N+1 per-contact
# queries through hrrr_data_fully_present?/1.
{point_map, present_set} = build_hrrr_present_set(contacts)
Enum.flat_map(contacts, fn contact ->
cond do
is_nil(contact.pos1) -> []
not Grid.contains?(contact.pos1) -> []
NarrClient.in_coverage?(contact.qso_timestamp) -> []
Weather.hrrr_data_fully_present?(contact) -> []
true -> [contact.id]
is_nil(contact.pos1) ->
[]
not Grid.contains?(contact.pos1) ->
[]
NarrClient.in_coverage?(contact.qso_timestamp) ->
[]
true ->
points = Map.get(point_map, contact.id, [])
if Enum.all?(points, &MapSet.member?(present_set, &1)), do: [], else: [contact.id]
end
end)
end
defp build_hrrr_present_set(contacts) do
contacts
|> Enum.filter(&(&1.pos1 != nil and not NarrClient.in_coverage?(&1.qso_timestamp)))
|> Enum.reduce({%{}, []}, fn c, {pm, pts} ->
rounded = HrrrClient.nearest_hrrr_hour(c.qso_timestamp)
points =
c
|> Radio.contact_path_points()
|> Enum.map(fn {lat, lon} -> {Weather.round_to_hrrr_grid(lat, lon), rounded} end)
{Map.put(pm, c.id, points), points ++ pts}
end)
|> then(fn {pm, all_pts} ->
unique = Enum.uniq(all_pts)
present = Weather.hrrr_points_present_batch(unique)
{pm, present}
end)
end
defp mark_enrichment_statuses(contact, types, jobs_by_type) do
ids = [contact.id]

View file

@ -9,6 +9,7 @@ defmodule MicrowavepropWeb.WeatherCaMapLive do
alias Microwaveprop.Weather
alias Microwaveprop.Weather.MapLayers
alias MicrowavepropWeb.WeatherMapComponent
@layers MapLayers.all()
@default_layer MapLayers.default_id()
@ -51,7 +52,9 @@ defmodule MicrowavepropWeb.WeatherCaMapLive do
current_lon: lon,
current_zoom: zoom,
grid_visible: false,
radar_visible: false
radar_visible: false,
band_filter: nil,
cutoff_bands: []
)}
end
@ -243,358 +246,18 @@ defmodule MicrowavepropWeb.WeatherCaMapLive do
end
end
@group_order ["Surface", "Upper Air", "Ducting"]
defp group_layers(layers) do
layers
|> Enum.group_by(& &1.group)
|> Enum.sort_by(fn {group, _} -> Enum.find_index(@group_order, &(&1 == group)) || 99 end)
end
defp layer_description(layers, selected_id) do
case Enum.find(layers, &(&1.id == selected_id)) do
%{desc: desc} -> desc
_ -> nil
end
end
defp selected_layer_label(layers, selected_id) do
case Enum.find(layers, &(&1.id == selected_id)) do
%{label: label} -> label
_ -> ""
end
end
defp selected_layer_group_label(layers, selected_id) do
case Enum.find(layers, &(&1.id == selected_id)) do
%{group: group} -> group
_ -> ""
end
end
@impl true
def render(assigns) do
~H"""
<div class="flex w-screen h-screen overflow-hidden">
<div class="relative flex-1 min-w-0">
<div
id="weather-map"
phx-hook="WeatherMap"
phx-update="ignore"
data-layers={Jason.encode!(@layers)}
data-selected-layer={@selected_layer}
data-valid-times={@initial_valid_times_json}
data-selected-time={@initial_selected_time}
data-initial-lat={@initial_center_lat}
data-initial-lon={@initial_center_lon}
data-initial-zoom={@initial_zoom}
data-source="hrdps"
class="absolute inset-0 z-0"
>
</div>
assigns =
assign(assigns,
page_subtitle: "Canadian Weather Map",
data_source: "hrdps",
show_cutoff_bands: false,
show_other_weather_link: true,
other_weather_path: "/weather",
other_weather_label: "US Weather Map"
)
<div
id="weather-forecast-timeline"
phx-update="ignore"
class="absolute bottom-2 left-1/2 -translate-x-1/2 z-[1000] max-w-[calc(100vw-1rem)] md:bottom-4"
>
</div>
<div
id="weather-controls"
class="md:hidden absolute top-2 left-12 z-[1000] flex flex-col gap-2 max-w-[calc(100vw-4rem)]"
>
<div
data-theme="dark"
class="bg-neutral text-neutral-content shadow rounded-box border border-base-300 p-2 flex flex-col gap-2"
>
<div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
<div class="min-w-0">
<span>NTMS</span>
<div class="font-normal text-xs opacity-70">Canadian Weather Map</div>
<div class="font-normal text-[10px] opacity-60">
<%= if @valid_time do %>
{Calendar.strftime(@valid_time, "%Y-%m-%d %H:%M UTC")}
<% else %>
No data available
<% end %>
</div>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<div
id="weather-utc-clock-mobile"
phx-hook="UtcClock"
phx-update="ignore"
class="font-mono text-xs opacity-70 tabular-nums"
>
{@initial_utc_clock}
</div>
<button
id="weather-mobile-panel-toggle"
class="btn btn-xs btn-square btn-ghost"
onclick="document.getElementById('weather-panel-extras').classList.toggle('hidden')"
>
<.icon name="hero-bars-3" class="size-4" />
</button>
</div>
</div>
<div id="weather-panel-extras" class="hidden flex-col gap-2">
<div class="flex flex-col gap-1.5">
<div :for={{group, layers} <- group_layers(@layers)}>
<div class="text-[10px] font-semibold opacity-50 uppercase tracking-wider px-1 mb-0.5">
{group}
</div>
<div class="flex flex-wrap gap-1">
<button
:for={layer <- layers}
phx-click="select_layer"
phx-value-layer={layer.id}
class={[
"btn btn-xs rounded-full",
if(@selected_layer == layer.id, do: "btn-primary", else: "btn-ghost")
]}
>
{layer.label}
</button>
</div>
</div>
</div>
<div class="text-[11px] opacity-70 px-1 leading-snug">
{layer_description(@layers, @selected_layer)}
</div>
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
<label class="flex items-center gap-2 cursor-pointer px-1">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@grid_visible}
phx-click="toggle_grid"
/>
<span class="text-sm">Grid squares</span>
</label>
<label class="flex items-center gap-2 cursor-pointer px-1">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@radar_visible}
phx-click="toggle_radar"
/>
<span class="text-sm">Weather radar</span>
</label>
</div>
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
<.link navigate="/weather" class="btn btn-xs btn-ghost justify-start">
US Weather Map
</.link>
<.link navigate="/map" class="btn btn-xs btn-ghost justify-start">
Propagation Map
</.link>
<.link navigate="/path" class="btn btn-xs btn-ghost justify-start">
Path Calculator
</.link>
<.link navigate="/eme" class="btn btn-xs btn-ghost justify-start">
EME
</.link>
<.link navigate="/beacons" class="btn btn-xs btn-ghost justify-start">
Beacons
</.link>
<.link navigate="/submit" class="btn btn-xs btn-ghost justify-start">
Submit a Contact
</.link>
<.link navigate="/contacts" class="btn btn-xs btn-ghost justify-start">
Contact Training Data
</.link>
<.link navigate="/contacts/map" class="btn btn-xs btn-ghost justify-start">
Contact Map
</.link>
<.link navigate="/algo" class="btn btn-xs btn-ghost justify-start">
Scoring Algorithm
</.link>
<.link navigate="/about" class="btn btn-xs btn-ghost justify-start">
About
</.link>
</div>
</div>
</div>
</div>
<div
id="weather-detail-panel"
phx-update="ignore"
class={[
"bg-neutral text-neutral-content shadow-lg border border-base-300 overflow-hidden overflow-y-auto z-[1001]",
"fixed bottom-0 left-0 right-0 max-h-[50vh] rounded-t-2xl",
"md:absolute md:top-auto md:right-auto md:bottom-4 md:left-3 md:max-h-[70vh] md:w-80 md:rounded-box"
]}
style="display:none;"
>
</div>
<button
id="weather-sidebar-expand"
class="hidden md:hidden absolute top-3 right-3 z-[1000] btn btn-sm btn-neutral shadow-lg"
onclick="document.getElementById('weather-sidebar').style.display='flex';this.style.display='none';window.dispatchEvent(new Event('sidebar-toggle'));"
>
<.icon name="hero-bars-3" class="size-4" />
</button>
</div>
<div
id="weather-sidebar"
data-theme="dark"
class="hidden md:flex flex-col w-56 shrink-0 h-full bg-neutral text-neutral-content border-l border-base-300 overflow-hidden"
>
<div class="p-3 flex flex-col gap-2 shrink-0 overflow-y-auto flex-1">
<div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
<div class="min-w-0">
<span>NTMS</span>
<div class="font-normal text-xs opacity-70">Canadian Weather Map</div>
<div class="font-normal text-[10px] opacity-60">
<%= if @valid_time do %>
{Calendar.strftime(@valid_time, "%Y-%m-%d %H:%M UTC")}
<% else %>
No data available
<% end %>
</div>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<div
id="weather-utc-clock-desktop"
phx-hook="UtcClock"
phx-update="ignore"
class="font-mono text-xs opacity-70 tabular-nums"
>
{@initial_utc_clock}
</div>
<button
id="weather-sidebar-collapse"
class="btn btn-xs btn-square btn-ghost"
title="Collapse sidebar"
onclick="document.getElementById('weather-sidebar').style.display='none';document.getElementById('weather-sidebar-expand').style.display='flex';window.dispatchEvent(new Event('sidebar-toggle'));"
>
<.icon name="hero-chevron-right" class="size-4" />
</button>
</div>
</div>
<div class="flex flex-col gap-1.5">
<div :for={{group, layers} <- group_layers(@layers)}>
<div class="text-[10px] font-semibold opacity-50 uppercase tracking-wider px-1 mb-0.5">
{group}
</div>
<div class="flex flex-wrap gap-1">
<button
:for={layer <- layers}
phx-click="select_layer"
phx-value-layer={layer.id}
class={[
"btn btn-xs rounded-full",
if(@selected_layer == layer.id, do: "btn-primary", else: "btn-ghost")
]}
>
{layer.label}
</button>
</div>
</div>
</div>
<div class="px-1">
<div class="text-[10px] font-semibold uppercase tracking-wider opacity-60 mb-1">
{selected_layer_group_label(@layers, @selected_layer)} &middot; {selected_layer_label(
@layers,
@selected_layer
)}
</div>
<div class="text-[11px] opacity-70 leading-snug">
{layer_description(@layers, @selected_layer)}
</div>
</div>
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
<label class="flex items-center gap-2 cursor-pointer px-1">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@grid_visible}
phx-click="toggle_grid"
/>
<span class="text-sm">Grid squares</span>
</label>
<label class="flex items-center gap-2 cursor-pointer px-1">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@radar_visible}
phx-click="toggle_radar"
/>
<span class="text-sm">Weather radar</span>
</label>
</div>
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
<li>
<.link navigate="/weather">US Weather Map</.link>
</li>
<li>
<.link navigate="/map">Propagation Map</.link>
</li>
<li>
<.link navigate="/path">Path Calculator</.link>
</li>
<li>
<.link navigate="/eme">EME</.link>
</li>
<li>
<.link navigate="/beacons">Beacons</.link>
</li>
<li>
<.link navigate="/submit">Submit a Contact</.link>
</li>
<li>
<.link navigate="/contacts">Contact Training Data</.link>
</li>
<li>
<.link navigate="/contacts/map">Contact Map</.link>
</li>
<li>
<.link navigate="/algo">Scoring Algorithm</.link>
</li>
<li>
<.link navigate="/about">About</.link>
</li>
</ul>
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
<%= if @current_scope && @current_scope.user do %>
<li>
<.link navigate={~p"/u/#{@current_scope.user.callsign}"} class="font-semibold">
{@current_scope.user.callsign}
</.link>
</li>
<li>
<.link href={~p"/users/settings"}>Settings</.link>
</li>
<li>
<.link href={~p"/users/log-out"} method="delete">Log out</.link>
</li>
<% else %>
<li>
<.link href={~p"/users/register"}>Register</.link>
</li>
<li>
<.link href={~p"/users/log-in"}>Log in</.link>
</li>
<% end %>
</ul>
</div>
</div>
</div>
<Layouts.flash_group flash={@flash} />
"""
WeatherMapComponent.render(assigns)
end
end

View file

@ -0,0 +1,428 @@
defmodule MicrowavepropWeb.WeatherMapComponent do
@moduledoc false
use MicrowavepropWeb, :html
@group_order ["Surface", "Upper Air", "Ducting"]
attr :bands, :list, required: true
attr :selected, :any, default: nil
defp cutoff_band_picker(assigns) do
~H"""
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
<div class="text-[10px] font-semibold opacity-60 uppercase tracking-wider px-1">
Filter to band
</div>
<div class="flex flex-wrap gap-1">
<button
phx-click="clear_band_filter"
class={[
"btn btn-xs rounded-full",
if(@selected == nil, do: "btn-primary", else: "btn-ghost")
]}
>
All
</button>
<button
:for={band <- @bands}
phx-click="select_band_filter"
phx-value-band={band}
class={[
"btn btn-xs rounded-full font-mono tabular-nums",
if(@selected == band, do: "btn-primary", else: "btn-ghost")
]}
>
{band}G
</button>
</div>
</div>
"""
end
defp group_layers(layers) do
layers
|> Enum.group_by(& &1.group)
|> Enum.sort_by(fn {group, _} -> Enum.find_index(@group_order, &(&1 == group)) || 99 end)
end
defp layer_description(layers, selected_id) do
case Enum.find(layers, &(&1.id == selected_id)) do
%{desc: desc} -> desc
_ -> nil
end
end
defp selected_layer_label(layers, selected_id) do
case Enum.find(layers, &(&1.id == selected_id)) do
%{label: label} -> label
_ -> ""
end
end
defp selected_layer_group_label(layers, selected_id) do
case Enum.find(layers, &(&1.id == selected_id)) do
%{group: group} -> group
_ -> ""
end
end
@doc false
def render(assigns) do
~H"""
<div class="flex w-screen h-screen overflow-hidden">
<%!-- Map container --%>
<div class="relative flex-1 min-w-0">
<%!-- Full-page map --%>
<div
id="weather-map"
phx-hook="WeatherMap"
phx-update="ignore"
data-layers={Jason.encode!(@layers)}
data-selected-layer={@selected_layer}
data-band-filter={@band_filter}
data-valid-times={@initial_valid_times_json}
data-selected-time={@initial_selected_time}
data-source={@data_source}
data-initial-lat={@initial_center_lat}
data-initial-lon={@initial_center_lon}
data-initial-zoom={@initial_zoom}
class="absolute inset-0 z-0"
>
</div>
<%!-- Bottom forecast timeline --%>
<div
id="weather-forecast-timeline"
phx-update="ignore"
class="absolute bottom-2 left-1/2 -translate-x-1/2 z-[1000] max-w-[calc(100vw-1rem)] md:bottom-4"
>
</div>
<%!-- Mobile-only floating controls --%>
<div
id="weather-controls"
class="md:hidden absolute top-2 left-12 z-[1000] flex flex-col gap-2 max-w-[calc(100vw-4rem)]"
>
<div
data-theme="dark"
class="bg-neutral text-neutral-content shadow rounded-box border border-base-300 p-2 flex flex-col gap-2"
>
<div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
<div class="min-w-0">
<span>NTMS</span>
<div class="font-normal text-xs opacity-70">{@page_subtitle}</div>
<div class="font-normal text-[10px] opacity-60">
<%= if @valid_time do %>
{Calendar.strftime(@valid_time, "%Y-%m-%d %H:%M UTC")}
<% else %>
No data available
<% end %>
</div>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<div
id="weather-utc-clock-mobile"
phx-hook="UtcClock"
phx-update="ignore"
class="font-mono text-xs opacity-70 tabular-nums"
>
{@initial_utc_clock}
</div>
<button
id="weather-mobile-panel-toggle"
class="btn btn-xs btn-square btn-ghost"
onclick="document.getElementById('weather-panel-extras').classList.toggle('hidden')"
>
<.icon name="hero-bars-3" class="size-4" />
</button>
</div>
</div>
<div id="weather-panel-extras" class="hidden flex-col gap-2">
<%!-- Layer pill buttons grouped --%>
<div class="flex flex-col gap-1.5">
<div :for={{group, layers} <- group_layers(@layers)}>
<div class="text-[10px] font-semibold opacity-50 uppercase tracking-wider px-1 mb-0.5">
{group}
</div>
<div class="flex flex-wrap gap-1">
<button
:for={layer <- layers}
phx-click="select_layer"
phx-value-layer={layer.id}
class={[
"btn btn-xs rounded-full",
if(@selected_layer == layer.id, do: "btn-primary", else: "btn-ghost")
]}
>
{layer.label}
</button>
</div>
</div>
</div>
<div class="text-[11px] opacity-70 px-1 leading-snug">
{layer_description(@layers, @selected_layer)}
</div>
<%= if @show_cutoff_bands do %>
<.cutoff_band_picker
:if={@selected_layer == "duct_cutoff_ghz"}
bands={@cutoff_bands}
selected={@band_filter}
/>
<% end %>
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
<label class="flex items-center gap-2 cursor-pointer px-1">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@grid_visible}
phx-click="toggle_grid"
/>
<span class="text-sm">Grid squares</span>
</label>
<label class="flex items-center gap-2 cursor-pointer px-1">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@radar_visible}
phx-click="toggle_radar"
/>
<span class="text-sm">Weather radar</span>
</label>
</div>
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
<%= if @show_other_weather_link do %>
<.link navigate={@other_weather_path} class="btn btn-xs btn-ghost justify-start">
{@other_weather_label}
</.link>
<% end %>
<.link navigate="/map" class="btn btn-xs btn-ghost justify-start">
Propagation Map
</.link>
<.link navigate="/path" class="btn btn-xs btn-ghost justify-start">
Path Calculator
</.link>
<.link navigate="/eme" class="btn btn-xs btn-ghost justify-start">
EME
</.link>
<.link navigate="/beacons" class="btn btn-xs btn-ghost justify-start">
Beacons
</.link>
<.link navigate="/submit" class="btn btn-xs btn-ghost justify-start">
Submit a Contact
</.link>
<.link navigate="/contacts" class="btn btn-xs btn-ghost justify-start">
Contact Training Data
</.link>
<.link navigate="/contacts/map" class="btn btn-xs btn-ghost justify-start">
Contact Map
</.link>
<.link navigate="/algo" class="btn btn-xs btn-ghost justify-start">
Scoring Algorithm
</.link>
<.link navigate="/about" class="btn btn-xs btn-ghost justify-start">
About
</.link>
</div>
</div>
</div>
</div>
<%!-- Point detail panel --%>
<div
id="weather-detail-panel"
phx-update="ignore"
class={[
"bg-neutral text-neutral-content shadow-lg border border-base-300 overflow-hidden overflow-y-auto z-[1001]",
"fixed bottom-0 left-0 right-0 max-h-[50vh] rounded-t-2xl",
"md:absolute md:top-auto md:right-auto md:bottom-4 md:left-3 md:max-h-[70vh] md:w-80 md:rounded-box"
]}
style="display:none;"
>
</div>
<%!-- Sidebar expand button (visible when sidebar is collapsed) --%>
<button
id="weather-sidebar-expand"
class="hidden md:hidden absolute top-3 right-3 z-[1000] btn btn-sm btn-neutral shadow-lg"
onclick="document.getElementById('weather-sidebar').style.display='flex';this.style.display='none';window.dispatchEvent(new Event('sidebar-toggle'));"
>
<.icon name="hero-bars-3" class="size-4" />
</button>
</div>
<%!-- Desktop right sidebar --%>
<div
id="weather-sidebar"
data-theme="dark"
class="hidden md:flex flex-col w-56 shrink-0 h-full bg-neutral text-neutral-content border-l border-base-300 overflow-hidden"
>
<div class="p-3 flex flex-col gap-2 shrink-0 overflow-y-auto flex-1">
<div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
<div class="min-w-0">
<span>NTMS</span>
<div class="font-normal text-xs opacity-70">{@page_subtitle}</div>
<div class="font-normal text-[10px] opacity-60">
<%= if @valid_time do %>
{Calendar.strftime(@valid_time, "%Y-%m-%d %H:%M UTC")}
<% else %>
No data available
<% end %>
</div>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<div
id="weather-utc-clock-desktop"
phx-hook="UtcClock"
phx-update="ignore"
class="font-mono text-xs opacity-70 tabular-nums"
>
{@initial_utc_clock}
</div>
<button
id="weather-sidebar-collapse"
class="btn btn-xs btn-square btn-ghost"
title="Collapse sidebar"
onclick="document.getElementById('weather-sidebar').style.display='none';document.getElementById('weather-sidebar-expand').style.display='flex';window.dispatchEvent(new Event('sidebar-toggle'));"
>
<.icon name="hero-chevron-right" class="size-4" />
</button>
</div>
</div>
<%!-- Layer pill buttons grouped --%>
<div class="flex flex-col gap-1.5">
<div :for={{group, layers} <- group_layers(@layers)}>
<div class="text-[10px] font-semibold opacity-50 uppercase tracking-wider px-1 mb-0.5">
{group}
</div>
<div class="flex flex-wrap gap-1">
<button
:for={layer <- layers}
phx-click="select_layer"
phx-value-layer={layer.id}
class={[
"btn btn-xs rounded-full",
if(@selected_layer == layer.id, do: "btn-primary", else: "btn-ghost")
]}
>
{layer.label}
</button>
</div>
</div>
</div>
<%!-- Layer description (group · name header + body) --%>
<div class="px-1">
<div class="text-[10px] font-semibold uppercase tracking-wider opacity-60 mb-1">
{selected_layer_group_label(@layers, @selected_layer)} &middot; {selected_layer_label(
@layers,
@selected_layer
)}
</div>
<div class="text-[11px] opacity-70 leading-snug">
{layer_description(@layers, @selected_layer)}
</div>
</div>
<%= if @show_cutoff_bands do %>
<.cutoff_band_picker
:if={@selected_layer == "duct_cutoff_ghz"}
bands={@cutoff_bands}
selected={@band_filter}
/>
<% end %>
<%!-- Overlay toggles --%>
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
<label class="flex items-center gap-2 cursor-pointer px-1">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@grid_visible}
phx-click="toggle_grid"
/>
<span class="text-sm">Grid squares</span>
</label>
<label class="flex items-center gap-2 cursor-pointer px-1">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@radar_visible}
phx-click="toggle_radar"
/>
<span class="text-sm">Weather radar</span>
</label>
</div>
<%!-- Navigation --%>
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
<%= if @show_other_weather_link do %>
<li>
<.link navigate={@other_weather_path}>{@other_weather_label}</.link>
</li>
<% end %>
<li>
<.link navigate="/map">Propagation Map</.link>
</li>
<li>
<.link navigate="/path">Path Calculator</.link>
</li>
<li>
<.link navigate="/eme">EME</.link>
</li>
<li>
<.link navigate="/beacons">Beacons</.link>
</li>
<li>
<.link navigate="/submit">Submit a Contact</.link>
</li>
<li>
<.link navigate="/contacts">Contact Training Data</.link>
</li>
<li>
<.link navigate="/contacts/map">Contact Map</.link>
</li>
<li>
<.link navigate="/algo">Scoring Algorithm</.link>
</li>
<li>
<.link navigate="/about">About</.link>
</li>
</ul>
<%!-- Auth --%>
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
<%= if @current_scope && @current_scope.user do %>
<li>
<.link navigate={~p"/u/#{@current_scope.user.callsign}"} class="font-semibold">
{@current_scope.user.callsign}
</.link>
</li>
<li>
<.link href={~p"/users/settings"}>Settings</.link>
</li>
<li>
<.link href={~p"/users/log-out"} method="delete">Log out</.link>
</li>
<% else %>
<li>
<.link href={~p"/users/register"}>Register</.link>
</li>
<li>
<.link href={~p"/users/log-in"}>Log in</.link>
</li>
<% end %>
</ul>
</div>
</div>
</div>
<Layouts.flash_group flash={@flash} />
"""
end
end

View file

@ -4,6 +4,7 @@ defmodule MicrowavepropWeb.WeatherMapLive do
alias Microwaveprop.Weather
alias Microwaveprop.Weather.MapLayers
alias MicrowavepropWeb.WeatherMapComponent
@layers MapLayers.all()
@default_layer MapLayers.default_id()
@ -349,412 +350,19 @@ defmodule MicrowavepropWeb.WeatherMapLive do
end
end
@group_order ["Surface", "Upper Air", "Ducting"]
defp group_layers(layers) do
layers
|> Enum.group_by(& &1.group)
|> Enum.sort_by(fn {group, _} -> Enum.find_index(@group_order, &(&1 == group)) || 99 end)
end
defp layer_description(layers, selected_id) do
case Enum.find(layers, &(&1.id == selected_id)) do
%{desc: desc} -> desc
_ -> nil
end
end
defp selected_layer_label(layers, selected_id) do
case Enum.find(layers, &(&1.id == selected_id)) do
%{label: label} -> label
_ -> ""
end
end
defp selected_layer_group_label(layers, selected_id) do
case Enum.find(layers, &(&1.id == selected_id)) do
%{group: group} -> group
_ -> ""
end
end
attr :bands, :list, required: true
attr :selected, :any, default: nil
defp cutoff_band_picker(assigns) do
~H"""
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
<div class="text-[10px] font-semibold opacity-60 uppercase tracking-wider px-1">
Filter to band
</div>
<div class="flex flex-wrap gap-1">
<button
phx-click="clear_band_filter"
class={[
"btn btn-xs rounded-full",
if(@selected == nil, do: "btn-primary", else: "btn-ghost")
]}
>
All
</button>
<button
:for={band <- @bands}
phx-click="select_band_filter"
phx-value-band={band}
class={[
"btn btn-xs rounded-full font-mono tabular-nums",
if(@selected == band, do: "btn-primary", else: "btn-ghost")
]}
>
{band}G
</button>
</div>
</div>
"""
end
@impl true
def render(assigns) do
~H"""
<div class="flex w-screen h-screen overflow-hidden">
<%!-- Map container --%>
<div class="relative flex-1 min-w-0">
<%!-- Full-page map --%>
<div
id="weather-map"
phx-hook="WeatherMap"
phx-update="ignore"
data-layers={Jason.encode!(@layers)}
data-selected-layer={@selected_layer}
data-band-filter={@band_filter}
data-valid-times={@initial_valid_times_json}
data-selected-time={@initial_selected_time}
data-initial-lat={@initial_center_lat}
data-initial-lon={@initial_center_lon}
data-initial-zoom={@initial_zoom}
class="absolute inset-0 z-0"
>
</div>
assigns =
assign(assigns,
page_subtitle: "Weather Map",
data_source: nil,
cutoff_bands: @cutoff_bands,
show_cutoff_bands: true,
show_other_weather_link: false,
other_weather_path: "/weather-ca",
other_weather_label: "Canadian Weather Map"
)
<%!-- Bottom forecast timeline --%>
<div
id="weather-forecast-timeline"
phx-update="ignore"
class="absolute bottom-2 left-1/2 -translate-x-1/2 z-[1000] max-w-[calc(100vw-1rem)] md:bottom-4"
>
</div>
<%!-- Mobile-only floating controls --%>
<div
id="weather-controls"
class="md:hidden absolute top-2 left-12 z-[1000] flex flex-col gap-2 max-w-[calc(100vw-4rem)]"
>
<div
data-theme="dark"
class="bg-neutral text-neutral-content shadow rounded-box border border-base-300 p-2 flex flex-col gap-2"
>
<div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
<div class="min-w-0">
<span>NTMS</span>
<div class="font-normal text-xs opacity-70">Weather Map</div>
<div class="font-normal text-[10px] opacity-60">
<%= if @valid_time do %>
{Calendar.strftime(@valid_time, "%Y-%m-%d %H:%M UTC")}
<% else %>
No data available
<% end %>
</div>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<div
id="weather-utc-clock-mobile"
phx-hook="UtcClock"
phx-update="ignore"
class="font-mono text-xs opacity-70 tabular-nums"
>
{@initial_utc_clock}
</div>
<button
id="weather-mobile-panel-toggle"
class="btn btn-xs btn-square btn-ghost"
onclick="document.getElementById('weather-panel-extras').classList.toggle('hidden')"
>
<.icon name="hero-bars-3" class="size-4" />
</button>
</div>
</div>
<div id="weather-panel-extras" class="hidden flex-col gap-2">
<%!-- Layer pill buttons grouped --%>
<div class="flex flex-col gap-1.5">
<div :for={{group, layers} <- group_layers(@layers)}>
<div class="text-[10px] font-semibold opacity-50 uppercase tracking-wider px-1 mb-0.5">
{group}
</div>
<div class="flex flex-wrap gap-1">
<button
:for={layer <- layers}
phx-click="select_layer"
phx-value-layer={layer.id}
class={[
"btn btn-xs rounded-full",
if(@selected_layer == layer.id, do: "btn-primary", else: "btn-ghost")
]}
>
{layer.label}
</button>
</div>
</div>
</div>
<div class="text-[11px] opacity-70 px-1 leading-snug">
{layer_description(@layers, @selected_layer)}
</div>
<.cutoff_band_picker
:if={@selected_layer == "duct_cutoff_ghz"}
bands={@cutoff_bands}
selected={@band_filter}
/>
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
<label class="flex items-center gap-2 cursor-pointer px-1">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@grid_visible}
phx-click="toggle_grid"
/>
<span class="text-sm">Grid squares</span>
</label>
<label class="flex items-center gap-2 cursor-pointer px-1">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@radar_visible}
phx-click="toggle_radar"
/>
<span class="text-sm">Weather radar</span>
</label>
</div>
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
<.link navigate="/map" class="btn btn-xs btn-ghost justify-start">
Propagation Map
</.link>
<.link navigate="/path" class="btn btn-xs btn-ghost justify-start">
Path Calculator
</.link>
<.link navigate="/eme" class="btn btn-xs btn-ghost justify-start">
EME
</.link>
<.link navigate="/beacons" class="btn btn-xs btn-ghost justify-start">
Beacons
</.link>
<.link navigate="/submit" class="btn btn-xs btn-ghost justify-start">
Submit a Contact
</.link>
<.link navigate="/contacts" class="btn btn-xs btn-ghost justify-start">
Contact Training Data
</.link>
<.link navigate="/contacts/map" class="btn btn-xs btn-ghost justify-start">
Contact Map
</.link>
<.link navigate="/algo" class="btn btn-xs btn-ghost justify-start">
Scoring Algorithm
</.link>
<.link navigate="/about" class="btn btn-xs btn-ghost justify-start">
About
</.link>
</div>
</div>
</div>
</div>
<%!-- Point detail panel --%>
<div
id="weather-detail-panel"
phx-update="ignore"
class={[
"bg-neutral text-neutral-content shadow-lg border border-base-300 overflow-hidden overflow-y-auto z-[1001]",
"fixed bottom-0 left-0 right-0 max-h-[50vh] rounded-t-2xl",
"md:absolute md:top-auto md:right-auto md:bottom-4 md:left-3 md:max-h-[70vh] md:w-80 md:rounded-box"
]}
style="display:none;"
>
</div>
<%!-- Sidebar expand button (visible when sidebar is collapsed) --%>
<button
id="weather-sidebar-expand"
class="hidden md:hidden absolute top-3 right-3 z-[1000] btn btn-sm btn-neutral shadow-lg"
onclick="document.getElementById('weather-sidebar').style.display='flex';this.style.display='none';window.dispatchEvent(new Event('sidebar-toggle'));"
>
<.icon name="hero-bars-3" class="size-4" />
</button>
</div>
<%!-- Desktop right sidebar --%>
<div
id="weather-sidebar"
data-theme="dark"
class="hidden md:flex flex-col w-56 shrink-0 h-full bg-neutral text-neutral-content border-l border-base-300 overflow-hidden"
>
<div class="p-3 flex flex-col gap-2 shrink-0 overflow-y-auto flex-1">
<div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
<div class="min-w-0">
<span>NTMS</span>
<div class="font-normal text-xs opacity-70">Weather Map</div>
<div class="font-normal text-[10px] opacity-60">
<%= if @valid_time do %>
{Calendar.strftime(@valid_time, "%Y-%m-%d %H:%M UTC")}
<% else %>
No data available
<% end %>
</div>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<div
id="weather-utc-clock-desktop"
phx-hook="UtcClock"
phx-update="ignore"
class="font-mono text-xs opacity-70 tabular-nums"
>
{@initial_utc_clock}
</div>
<button
id="weather-sidebar-collapse"
class="btn btn-xs btn-square btn-ghost"
title="Collapse sidebar"
onclick="document.getElementById('weather-sidebar').style.display='none';document.getElementById('weather-sidebar-expand').style.display='flex';window.dispatchEvent(new Event('sidebar-toggle'));"
>
<.icon name="hero-chevron-right" class="size-4" />
</button>
</div>
</div>
<%!-- Layer pill buttons grouped --%>
<div class="flex flex-col gap-1.5">
<div :for={{group, layers} <- group_layers(@layers)}>
<div class="text-[10px] font-semibold opacity-50 uppercase tracking-wider px-1 mb-0.5">
{group}
</div>
<div class="flex flex-wrap gap-1">
<button
:for={layer <- layers}
phx-click="select_layer"
phx-value-layer={layer.id}
class={[
"btn btn-xs rounded-full",
if(@selected_layer == layer.id, do: "btn-primary", else: "btn-ghost")
]}
>
{layer.label}
</button>
</div>
</div>
</div>
<%!-- Layer description (group · name header + body) --%>
<div class="px-1">
<div class="text-[10px] font-semibold uppercase tracking-wider opacity-60 mb-1">
{selected_layer_group_label(@layers, @selected_layer)} &middot; {selected_layer_label(
@layers,
@selected_layer
)}
</div>
<div class="text-[11px] opacity-70 leading-snug">
{layer_description(@layers, @selected_layer)}
</div>
</div>
<.cutoff_band_picker
:if={@selected_layer == "duct_cutoff_ghz"}
bands={@cutoff_bands}
selected={@band_filter}
/>
<%!-- Overlay toggles --%>
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
<label class="flex items-center gap-2 cursor-pointer px-1">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@grid_visible}
phx-click="toggle_grid"
/>
<span class="text-sm">Grid squares</span>
</label>
<label class="flex items-center gap-2 cursor-pointer px-1">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@radar_visible}
phx-click="toggle_radar"
/>
<span class="text-sm">Weather radar</span>
</label>
</div>
<%!-- Navigation --%>
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
<li>
<.link navigate="/map">Propagation Map</.link>
</li>
<li>
<.link navigate="/path">Path Calculator</.link>
</li>
<li>
<.link navigate="/eme">EME</.link>
</li>
<li>
<.link navigate="/beacons">Beacons</.link>
</li>
<li>
<.link navigate="/submit">Submit a Contact</.link>
</li>
<li>
<.link navigate="/contacts">Contact Training Data</.link>
</li>
<li>
<.link navigate="/contacts/map">Contact Map</.link>
</li>
<li>
<.link navigate="/algo">Scoring Algorithm</.link>
</li>
<li>
<.link navigate="/about">About</.link>
</li>
</ul>
<%!-- Auth --%>
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
<%= if @current_scope && @current_scope.user do %>
<li>
<.link navigate={~p"/u/#{@current_scope.user.callsign}"} class="font-semibold">
{@current_scope.user.callsign}
</.link>
</li>
<li>
<.link href={~p"/users/settings"}>Settings</.link>
</li>
<li>
<.link href={~p"/users/log-out"} method="delete">Log out</.link>
</li>
<% else %>
<li>
<.link href={~p"/users/register"}>Register</.link>
</li>
<li>
<.link href={~p"/users/log-in"}>Log in</.link>
</li>
<% end %>
</ul>
</div>
</div>
</div>
<Layouts.flash_group flash={@flash} />
"""
WeatherMapComponent.render(assigns)
end
end