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:
parent
f7a753626d
commit
7ba82f4601
6 changed files with 208 additions and 46 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)} · {@contact.mode}
|
||||
· {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} → {@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}°</span>
|
||||
</div>
|
||||
<div>
|
||||
{@contact.station2} → {@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}°</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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue