Duct visualization, flag invalid contacts, fix HRRR pruning

- Overlay HRRR duct layers on elevation profile chart (green bands)
- Add flagged_invalid field to contacts with toggle button on detail page
- Fix prune_old_grid_profiles to only delete 0.125° grid profiles,
  preserving QSO-linked HRRR data at arbitrary positions
- Auto-enqueue HRRR fetch when viewing contact without HRRR data (<48h)
- Compact solar conditions and contact details layout
- Add band/mode/distance info line to elevation profile section
This commit is contained in:
Graham McIntire 2026-04-01 13:09:09 -05:00
parent f7a753626d
commit 7ba82f4601
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
6 changed files with 208 additions and 46 deletions

View file

@ -3,6 +3,49 @@ import Chart from "../vendor/chart.js"
const M2FT = 3.28084
const KM2MI = 0.621371
// Chart.js plugin to draw horizontal duct bands
const ductPlugin = {
id: "ductBands",
beforeDraw(chart) {
const ducts = chart.options.plugins.ductBands?.ducts
if (!ducts || ducts.length === 0) return
const {ctx, chartArea: {left, right, top, bottom}, scales: {y}} = chart
ctx.save()
for (const duct of ducts) {
const yTop = y.getPixelForValue(duct.top_ft)
const yBase = y.getPixelForValue(duct.base_ft)
// Clamp to chart area
const drawTop = Math.max(yTop, top)
const drawBase = Math.min(yBase, bottom)
if (drawTop >= drawBase) continue
ctx.fillStyle = "rgba(34, 197, 94, 0.12)"
ctx.fillRect(left, drawTop, right - left, drawBase - drawTop)
ctx.strokeStyle = "rgba(34, 197, 94, 0.5)"
ctx.lineWidth = 1
ctx.setLineDash([4, 3])
ctx.beginPath()
ctx.moveTo(left, drawTop)
ctx.lineTo(right, drawTop)
ctx.moveTo(left, drawBase)
ctx.lineTo(right, drawBase)
ctx.stroke()
ctx.setLineDash([])
// Label
const labelY = Math.max(drawTop + 12, top + 12)
ctx.fillStyle = "rgba(34, 197, 94, 0.8)"
ctx.font = "9px sans-serif"
ctx.fillText(`Duct ${Math.round(duct.base_ft)}${Math.round(duct.top_ft)} ft (${duct.strength} M-units)`, left + 4, labelY)
}
ctx.restore()
}
}
export const ElevationProfile = {
mounted() {
const data = JSON.parse(this.el.dataset.profile)
@ -18,15 +61,27 @@ export const ElevationProfile = {
const fresnelLower = points.map(p => (p.beam - p.r1) * M2FT)
const showFresnel = data.freq_mhz >= 1000
const ducts = (data.ducts || []).map(d => ({
base_ft: d.base_m_msl * M2FT,
top_ft: d.top_m_msl * M2FT,
strength: d.strength
}))
const allElevs = [...elevations, ...losLine]
const minElev = Math.min(...allElevs) - 60
const maxElev = Math.max(...allElevs) + 120
let minElev = Math.min(...allElevs) - 60
let maxElev = Math.max(...allElevs) + 120
// Extend Y axis to include duct tops if needed
for (const d of ducts) {
if (d.top_ft + 40 > maxElev) maxElev = d.top_ft + 40
}
const canvas = this.el.querySelector("canvas")
const ctx = canvas.getContext("2d")
this.chart = new Chart(ctx, {
type: "line",
plugins: [ductPlugin],
data: {
labels: distances,
datasets: [
@ -71,7 +126,18 @@ export const ElevationProfile = {
fill: false,
order: 2
}
] : [])
] : []),
// Invisible dataset for duct legend entry
...(ducts.length > 0 ? [{
label: "Duct Layer",
data: [],
borderColor: "rgba(34, 197, 94, 0.5)",
backgroundColor: "rgba(34, 197, 94, 0.12)",
borderWidth: 2,
borderDash: [4, 3],
pointRadius: 0,
fill: false
}] : [])
]
},
options: {
@ -79,6 +145,7 @@ export const ElevationProfile = {
maintainAspectRatio: false,
animation: {duration: 300},
plugins: {
ductBands: {ducts},
legend: {
display: true,
position: "top",

View file

@ -220,6 +220,12 @@ defmodule Microwaveprop.Radio do
Repo.get!(Contact, id)
end
def toggle_flagged_invalid!(contact) do
contact
|> Ecto.Changeset.change(flagged_invalid: !contact.flagged_invalid)
|> Repo.update!()
end
def change_contact(contact, attrs \\ %{}) do
Contact.submission_changeset(contact, attrs)
end

View file

@ -26,6 +26,7 @@ defmodule Microwaveprop.Radio.Contact do
field :iemre_queued, :boolean, default: false
field :user_submitted, :boolean, default: false
field :submitter_email, :string
field :flagged_invalid, :boolean, default: false
timestamps(type: :utc_datetime)
end

View file

@ -264,10 +264,16 @@ defmodule Microwaveprop.Weather do
def prune_old_grid_profiles do
cutoff = DateTime.add(DateTime.utc_now(), -24, :hour)
# Partitioned table: DELETE only scans relevant partitions via partition pruning.
# Only delete profiles on the 0.125° propagation grid, preserving QSO-linked
# profiles which sit at arbitrary lat/lon positions (HRRR's native ~3km grid).
%{num_rows: deleted} =
Repo.query!(
"DELETE FROM hrrr_profiles WHERE valid_time < $1",
"""
DELETE FROM hrrr_profiles
WHERE valid_time < $1
AND (lat * 1000)::int % 125 = 0
AND (lon * 1000)::int % 125 = 0
""",
[cutoff],
timeout: 120_000
)

View file

@ -7,6 +7,8 @@ defmodule MicrowavepropWeb.ContactLive.Show do
alias Microwaveprop.Terrain.ElevationClient
alias Microwaveprop.Terrain.TerrainAnalysis
alias Microwaveprop.Weather
alias Microwaveprop.Weather.HrrrClient
alias Microwaveprop.Workers.HrrrFetchWorker
@earth_radius_m 6_371_000.0
@ -17,8 +19,9 @@ defmodule MicrowavepropWeb.ContactLive.Show do
weather = load_weather(contact)
solar = load_solar(contact)
hrrr = Weather.hrrr_for_contact(contact)
{hrrr, hrrr_status} = maybe_enqueue_hrrr(hrrr, contact)
terrain = Terrain.get_terrain_profile(contact.id)
elevation_profile = compute_elevation_profile(contact)
elevation_profile = compute_elevation_profile(contact, hrrr)
{:ok,
assign(socket,
@ -28,6 +31,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
soundings: weather.soundings,
solar: solar,
hrrr: hrrr,
hrrr_status: hrrr_status,
terrain: terrain,
elevation_profile: elevation_profile,
terrain_expanded: false,
@ -63,6 +67,11 @@ defmodule MicrowavepropWeb.ContactLive.Show do
{:noreply, assign(socket, terrain_expanded: !socket.assigns.terrain_expanded)}
end
def handle_event("toggle_flag", _params, socket) do
contact = Radio.toggle_flagged_invalid!(socket.assigns.contact)
{:noreply, assign(socket, contact: contact)}
end
def handle_event("toggle_profile", %{"id" => id}, socket) do
expanded =
if MapSet.member?(socket.assigns.expanded_soundings, id) do
@ -136,6 +145,38 @@ defmodule MicrowavepropWeb.ContactLive.Show do
end
end
defp maybe_enqueue_hrrr(hrrr, _contact) when not is_nil(hrrr), do: {hrrr, :loaded}
defp maybe_enqueue_hrrr(nil, contact) do
lat = contact.pos1 && contact.pos1["lat"]
lon = contact.pos1 && (contact.pos1["lon"] || contact.pos1["lng"])
if lat && lon && hrrr_likely_available?(contact.qso_timestamp) do
valid_time = HrrrClient.nearest_hrrr_hour(contact.qso_timestamp)
{rlat, rlon} = Weather.round_to_hrrr_grid(lat, lon)
Oban.insert(
HrrrFetchWorker.new(%{
"lat" => rlat,
"lon" => rlon,
"valid_time" => DateTime.to_iso8601(valid_time)
})
)
{nil, :queued}
else
{nil, :unavailable}
end
end
# HRRR data is only available from ~2016 onward and kept for ~2 days on NOAA S3.
# Historical re-analysis archives go back further but the worker only fetches from the live feed.
# For contacts older than 48 hours, HRRR GRIB2 files are no longer on S3.
defp hrrr_likely_available?(timestamp) do
hours_ago = DateTime.diff(DateTime.utc_now(), timestamp, :hour)
hours_ago < 48
end
defp load_solar(contact) do
contact.qso_timestamp
|> DateTime.to_date()
@ -147,9 +188,23 @@ defmodule MicrowavepropWeb.ContactLive.Show do
~H"""
<Layouts.app flash={@flash}>
<.header>
{@contact.station1} / {@contact.station2}
<:subtitle>{Calendar.strftime(@contact.qso_timestamp, "%Y-%m-%d %H:%M UTC")}</:subtitle>
<span class={[@contact.flagged_invalid && "line-through opacity-50"]}>
{@contact.station1} / {@contact.station2}
</span>
<:subtitle>
{Calendar.strftime(@contact.qso_timestamp, "%Y-%m-%d %H:%M UTC")}
<%= if @contact.flagged_invalid do %>
<span class="badge badge-error badge-sm ml-2">Flagged Invalid</span>
<% end %>
</:subtitle>
<:actions>
<button phx-click="toggle_flag" class={[
"btn btn-sm",
if(@contact.flagged_invalid, do: "btn-warning", else: "btn-ghost")
]}>
<.icon name="hero-flag" class="w-4 h-4" />
{if @contact.flagged_invalid, do: "Unflag", else: "Flag Invalid"}
</button>
<.link navigate={~p"/contacts"} class="btn btn-sm btn-ghost">
<.icon name="hero-arrow-left" class="w-4 h-4" /> Back to Contacts
</.link>
@ -174,20 +229,18 @@ defmodule MicrowavepropWeb.ContactLive.Show do
<%= if @elevation_profile do %>
<div class="mb-4">
<div class="text-sm font-mono mb-2 text-center">
{format_band_ghz(@contact.band)} &middot; {@contact.mode}
&middot; {format_dist(@elevation_profile.dist_km)}
</div>
<div class="grid grid-cols-2 gap-x-8 text-sm font-mono mb-2">
<div>
{@contact.station1} &rarr; {@contact.station2}
<span class="ml-4">
{format_dist(@elevation_profile.dist_km)}
</span>
<span class="ml-4">Az: {format_bearing(@elevation_profile.fwd_az)}</span>
<span class="ml-4">El: {@elevation_profile.fwd_el}&deg;</span>
</div>
<div>
{@contact.station2} &rarr; {@contact.station1}
<span class="ml-4">
{format_dist(@elevation_profile.dist_km)}
</span>
<span class="ml-4">Az: {format_bearing(@elevation_profile.rev_az)}</span>
<span class="ml-4">El: {@elevation_profile.rev_el}&deg;</span>
</div>
@ -218,29 +271,6 @@ defmodule MicrowavepropWeb.ContactLive.Show do
<div class="divider" />
<h2 class="text-base font-semibold mb-2">Contact Details</h2>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-x-6 gap-y-1 text-sm">
<div><span class="font-semibold">Station 1:</span> {@contact.station1}</div>
<div><span class="font-semibold">Station 2:</span> {@contact.station2}</div>
<div><span class="font-semibold">Band:</span> {@contact.band} MHz</div>
<div><span class="font-semibold">Mode:</span> {@contact.mode}</div>
<div><span class="font-semibold">Grid 1:</span> {@contact.grid1 || ""}</div>
<div><span class="font-semibold">Grid 2:</span> {@contact.grid2 || ""}</div>
<div><span class="font-semibold">Distance:</span> {@contact.distance_km || ""} km</div>
<div>
<span class="font-semibold">Timestamp:</span>
{Calendar.strftime(@contact.qso_timestamp, "%Y-%m-%d %H:%M UTC")}
</div>
<div :if={@contact.user_submitted}>
<span class="badge badge-info badge-sm">User Submitted</span>
</div>
<div :if={@contact.user_submitted && @contact.submitter_email}>
<span class="font-semibold">Submitter:</span> {@contact.submitter_email}
</div>
</div>
<div class="divider" />
<h2 class="text-base font-semibold mb-2" id="terrain-heading">Terrain Profile</h2>
<%= if @terrain do %>
<div class="mb-6 border border-base-300 rounded-lg" id="terrain-profile">
@ -380,12 +410,12 @@ defmodule MicrowavepropWeb.ContactLive.Show do
<h2 class="text-base font-semibold mb-2">Solar Conditions</h2>
<%= if @solar do %>
<.list>
<:item title="SFI">{@solar.sfi || ""}</:item>
<:item title="Sunspot Number">{@solar.sunspot_number || ""}</:item>
<:item title="Ap Index">{@solar.ap_index || ""}</:item>
<:item title="Kp Values">{format_kp(@solar.kp_values)}</:item>
</.list>
<div class="flex flex-wrap gap-x-6 gap-y-1 text-sm">
<div><span class="font-semibold">SFI:</span> {@solar.sfi || ""}</div>
<div><span class="font-semibold">Sunspot #:</span> {@solar.sunspot_number || "—"}</div>
<div><span class="font-semibold">Ap:</span> {@solar.ap_index || ""}</div>
<div><span class="font-semibold">Kp:</span> {format_kp(@solar.kp_values)}</div>
</div>
<% else %>
<p class="text-sm text-base-content/50 italic">No solar data available for this date.</p>
<% end %>
@ -458,7 +488,16 @@ defmodule MicrowavepropWeb.ContactLive.Show do
<% end %>
</div>
<% else %>
<p class="text-sm text-base-content/50 italic">No HRRR profile available.</p>
<p class="text-sm text-base-content/50 italic">
<%= case @hrrr_status do %>
<% :queued -> %>
HRRR fetch queued reload in a few minutes.
<% :unavailable -> %>
HRRR data unavailable for this contact time.
<% _ -> %>
No HRRR profile available.
<% end %>
</p>
<% end %>
<div class="divider" />
@ -514,6 +553,19 @@ defmodule MicrowavepropWeb.ContactLive.Show do
"#{String.pad_leading(to_string(whole), 3, "0")}.#{frac}\u00B0"
end
defp format_band_ghz(nil), do: ""
defp format_band_ghz(band) do
mhz = Decimal.to_float(band)
ghz = mhz / 1000
if ghz == Float.round(ghz, 0) do
"#{trunc(ghz)} GHz"
else
"#{:erlang.float_to_binary(ghz, decimals: 1)} GHz"
end
end
defp format_wind(obs) do
case {obs.wind_direction_deg, obs.wind_speed_kts} do
{nil, nil} -> ""
@ -536,7 +588,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
defp terrain_verdict_class("BLOCKED"), do: "badge-error"
defp terrain_verdict_class(_), do: "badge-ghost"
defp compute_elevation_profile(contact) do
defp compute_elevation_profile(contact, hrrr) do
with %{"lat" => lat1} <- contact.pos1,
lon1 when is_number(lon1) <- contact.pos1["lon"] || contact.pos1["lng"],
%{"lat" => lat2} <- contact.pos2,
@ -560,6 +612,8 @@ defmodule MicrowavepropWeb.ContactLive.Show do
p -> p.dist_km
end
ducts = extract_ducts(hrrr, tx_elev)
%{
points:
Enum.map(analysis.points, fn p ->
@ -572,13 +626,32 @@ defmodule MicrowavepropWeb.ContactLive.Show do
fwd_el: fwd_el,
rev_el: rev_el,
verdict: analysis.verdict,
first_obstruction_km: first_obs
first_obstruction_km: first_obs,
ducts: ducts
}
else
_ -> nil
end
end
defp extract_ducts(nil, _surface_elev), do: []
defp extract_ducts(hrrr, surface_elev) do
case hrrr.duct_characteristics do
ducts when is_list(ducts) and ducts != [] ->
Enum.map(ducts, fn d ->
%{
base_m_msl: surface_elev + (d["base"] || 0),
top_m_msl: surface_elev + (d["top"] || 0),
strength: d["strength"]
}
end)
_ ->
[]
end
end
defp band_to_ghz(nil), do: 10.0
defp band_to_ghz(band) do

View file

@ -0,0 +1,9 @@
defmodule Microwaveprop.Repo.Migrations.AddFlaggedInvalidToContacts do
use Ecto.Migration
def change do
alter table(:qsos) do
add :flagged_invalid, :boolean, default: false, null: false
end
end
end