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.Repo
alias Microwaveprop.Weather.GefsProfile alias Microwaveprop.Weather.GefsProfile
alias Microwaveprop.Weather.GridCache alias Microwaveprop.Weather.GridCache
alias Microwaveprop.Weather.HrrrClient
alias Microwaveprop.Weather.HrrrNativeProfile alias Microwaveprop.Weather.HrrrNativeProfile
alias Microwaveprop.Weather.HrrrProfile alias Microwaveprop.Weather.HrrrProfile
alias Microwaveprop.Weather.IemClient alias Microwaveprop.Weather.IemClient
alias Microwaveprop.Weather.IemreObservation alias Microwaveprop.Weather.IemreObservation
alias Microwaveprop.Weather.NarrProfile alias Microwaveprop.Weather.NarrProfile
alias Microwaveprop.Weather.ProfileLookup
alias Microwaveprop.Weather.ScalarFile alias Microwaveprop.Weather.ScalarFile
alias Microwaveprop.Weather.SolarIndex alias Microwaveprop.Weather.SolarIndex
alias Microwaveprop.Weather.Sounding alias Microwaveprop.Weather.Sounding
@ -30,6 +30,26 @@ 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
# 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()} @spec find_or_create_station(map()) :: {:ok, Station.t()} | {: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"]
@ -1530,68 +1550,6 @@ 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
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 """ @doc """
Returns just `hrrr_native_profiles.best_duct_band_ghz` near the Returns just `hrrr_native_profiles.best_duct_band_ghz` near the
given cell. Convenience wrapper around `nearest_native_duct_info/3` given cell. Convenience wrapper around `nearest_native_duct_info/3`
@ -1698,160 +1656,6 @@ defmodule Microwaveprop.Weather do
end end
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." @doc "Find all atmospheric profiles along a contact's path, from any source."
@spec profiles_along_path(map()) :: [HrrrProfile.t() | NarrProfile.t()] @spec profiles_along_path(map()) :: [HrrrProfile.t() | NarrProfile.t()]
def profiles_along_path(contact) do def profiles_along_path(contact) do
@ -1864,61 +1668,6 @@ defmodule Microwaveprop.Weather do
end end
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 """ @doc """
Delete every `is_grid_point = true` row from `hrrr_profiles`, regardless of Delete every `is_grid_point = true` row from `hrrr_profiles`, regardless of
age. Grid-point profiles are historical the propagation grid now lives in 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 Preserves QSO-linked rows (`is_grid_point = false`), which remain the data
path for contact enrichment and the `/path` calculator. 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()} @spec upsert_iemre_observation(map()) :: {:ok, IemreObservation.t()} | {:error, Ecto.Changeset.t()}
def upsert_iemre_observation(attrs) do def upsert_iemre_observation(attrs) do
%IemreObservation{} %IemreObservation{}
@ -1989,36 +1693,4 @@ defmodule Microwaveprop.Weather do
|> 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.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 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 # sees 0 missing points, inserts nothing, but the status stays
# :queued and the contact is picked up again next tick). # :queued and the contact is picked up again next tick).
defp hrrr_placeholder_jobs(contacts) do 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 -> Enum.flat_map(contacts, fn contact ->
cond do cond do
is_nil(contact.pos1) -> [] is_nil(contact.pos1) ->
not Grid.contains?(contact.pos1) -> [] []
NarrClient.in_coverage?(contact.qso_timestamp) -> []
Weather.hrrr_data_fully_present?(contact) -> [] not Grid.contains?(contact.pos1) ->
true -> [contact.id] []
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) 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 defp mark_enrichment_statuses(contact, types, jobs_by_type) do
ids = [contact.id] ids = [contact.id]

View file

@ -9,6 +9,7 @@ defmodule MicrowavepropWeb.WeatherCaMapLive do
alias Microwaveprop.Weather alias Microwaveprop.Weather
alias Microwaveprop.Weather.MapLayers alias Microwaveprop.Weather.MapLayers
alias MicrowavepropWeb.WeatherMapComponent
@layers MapLayers.all() @layers MapLayers.all()
@default_layer MapLayers.default_id() @default_layer MapLayers.default_id()
@ -51,7 +52,9 @@ defmodule MicrowavepropWeb.WeatherCaMapLive do
current_lon: lon, current_lon: lon,
current_zoom: zoom, current_zoom: zoom,
grid_visible: false, grid_visible: false,
radar_visible: false radar_visible: false,
band_filter: nil,
cutoff_bands: []
)} )}
end end
@ -243,358 +246,18 @@ defmodule MicrowavepropWeb.WeatherCaMapLive do
end end
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 @impl true
def render(assigns) do def render(assigns) do
~H""" assigns =
<div class="flex w-screen h-screen overflow-hidden"> assign(assigns,
<div class="relative flex-1 min-w-0"> page_subtitle: "Canadian Weather Map",
<div data_source: "hrdps",
id="weather-map" show_cutoff_bands: false,
phx-hook="WeatherMap" show_other_weather_link: true,
phx-update="ignore" other_weather_path: "/weather",
data-layers={Jason.encode!(@layers)} other_weather_label: "US Weather Map"
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>
<div WeatherMapComponent.render(assigns)
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} />
"""
end end
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
alias Microwaveprop.Weather.MapLayers alias Microwaveprop.Weather.MapLayers
alias MicrowavepropWeb.WeatherMapComponent
@layers MapLayers.all() @layers MapLayers.all()
@default_layer MapLayers.default_id() @default_layer MapLayers.default_id()
@ -349,412 +350,19 @@ defmodule MicrowavepropWeb.WeatherMapLive do
end end
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 @impl true
def render(assigns) do def render(assigns) do
~H""" assigns =
<div class="flex w-screen h-screen overflow-hidden"> assign(assigns,
<%!-- Map container --%> page_subtitle: "Weather Map",
<div class="relative flex-1 min-w-0"> data_source: nil,
<%!-- Full-page map --%> cutoff_bands: @cutoff_bands,
<div show_cutoff_bands: true,
id="weather-map" show_other_weather_link: false,
phx-hook="WeatherMap" other_weather_path: "/weather-ca",
phx-update="ignore" other_weather_label: "Canadian Weather Map"
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>
<%!-- Bottom forecast timeline --%> WeatherMapComponent.render(assigns)
<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} />
"""
end end
end end