diff --git a/assets/js/elevation_profile_hook.js b/assets/js/elevation_profile_hook.js index bcca51d4..e3351ddc 100644 --- a/assets/js/elevation_profile_hook.js +++ b/assets/js/elevation_profile_hook.js @@ -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", diff --git a/lib/microwaveprop/radio.ex b/lib/microwaveprop/radio.ex index bad33f4e..98478bd4 100644 --- a/lib/microwaveprop/radio.ex +++ b/lib/microwaveprop/radio.ex @@ -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 diff --git a/lib/microwaveprop/radio/contact.ex b/lib/microwaveprop/radio/contact.ex index ddf88543..299b747b 100644 --- a/lib/microwaveprop/radio/contact.ex +++ b/lib/microwaveprop/radio/contact.ex @@ -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 diff --git a/lib/microwaveprop/weather.ex b/lib/microwaveprop/weather.ex index ceb0177f..a400b1f1 100644 --- a/lib/microwaveprop/weather.ex +++ b/lib/microwaveprop/weather.ex @@ -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 ) diff --git a/lib/microwaveprop_web/live/contact_live/show.ex b/lib/microwaveprop_web/live/contact_live/show.ex index 69740fcf..52daea54 100644 --- a/lib/microwaveprop_web/live/contact_live/show.ex +++ b/lib/microwaveprop_web/live/contact_live/show.ex @@ -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""" <.header> - {@contact.station1} / {@contact.station2} - <:subtitle>{Calendar.strftime(@contact.qso_timestamp, "%Y-%m-%d %H:%M UTC")} + + {@contact.station1} / {@contact.station2} + + <:subtitle> + {Calendar.strftime(@contact.qso_timestamp, "%Y-%m-%d %H:%M UTC")} + <%= if @contact.flagged_invalid do %> + Flagged Invalid + <% end %> + <:actions> + <.link navigate={~p"/contacts"} class="btn btn-sm btn-ghost"> <.icon name="hero-arrow-left" class="w-4 h-4" /> Back to Contacts @@ -174,20 +229,18 @@ defmodule MicrowavepropWeb.ContactLive.Show do <%= if @elevation_profile do %>
+
+ {format_band_ghz(@contact.band)} · {@contact.mode} + · {format_dist(@elevation_profile.dist_km)} +
{@contact.station1} → {@contact.station2} - - {format_dist(@elevation_profile.dist_km)} - Az: {format_bearing(@elevation_profile.fwd_az)} El: {@elevation_profile.fwd_el}°
{@contact.station2} → {@contact.station1} - - {format_dist(@elevation_profile.dist_km)} - Az: {format_bearing(@elevation_profile.rev_az)} El: {@elevation_profile.rev_el}°
@@ -218,29 +271,6 @@ defmodule MicrowavepropWeb.ContactLive.Show do
-

Contact Details

-
-
Station 1: {@contact.station1}
-
Station 2: {@contact.station2}
-
Band: {@contact.band} MHz
-
Mode: {@contact.mode}
-
Grid 1: {@contact.grid1 || "—"}
-
Grid 2: {@contact.grid2 || "—"}
-
Distance: {@contact.distance_km || "—"} km
-
- Timestamp: - {Calendar.strftime(@contact.qso_timestamp, "%Y-%m-%d %H:%M UTC")} -
-
- User Submitted -
-
- Submitter: {@contact.submitter_email} -
-
- -
-

Terrain Profile

<%= if @terrain do %>
@@ -380,12 +410,12 @@ defmodule MicrowavepropWeb.ContactLive.Show do

Solar Conditions

<%= if @solar do %> - <.list> - <:item title="SFI">{@solar.sfi || "—"} - <:item title="Sunspot Number">{@solar.sunspot_number || "—"} - <:item title="Ap Index">{@solar.ap_index || "—"} - <:item title="Kp Values">{format_kp(@solar.kp_values)} - +
+
SFI: {@solar.sfi || "—"}
+
Sunspot #: {@solar.sunspot_number || "—"}
+
Ap: {@solar.ap_index || "—"}
+
Kp: {format_kp(@solar.kp_values)}
+
<% else %>

No solar data available for this date.

<% end %> @@ -458,7 +488,16 @@ defmodule MicrowavepropWeb.ContactLive.Show do <% end %>
<% else %> -

No HRRR profile available.

+

+ <%= 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 %> +

<% end %>
@@ -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 diff --git a/priv/repo/migrations/20260401180056_add_flagged_invalid_to_contacts.exs b/priv/repo/migrations/20260401180056_add_flagged_invalid_to_contacts.exs new file mode 100644 index 00000000..75b44bc4 --- /dev/null +++ b/priv/repo/migrations/20260401180056_add_flagged_invalid_to_contacts.exs @@ -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