From a95d2ada52b206f68252261837ee5e2cc0ff8fae Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 9 Apr 2026 09:12:21 -0500 Subject: [PATCH] Compact beacon detail page, toggleable coverage, About page formatting - Beacon map defaults to just the marker at zoom 11. A daisyUI toggle switch shows/hides the estimated-coverage cell layer; the coverage info line and tier legend are hidden unless the toggle is on. - Beacon detail list replaced with a compact dl grid (2-4 columns). Lat/lon rounded to 5 decimals, power/height/beamwidth formatted via Beacon.format_mw/1 so no more "2.5e3" scientific notation or "280.0". format_mw/1 hoisted onto the schema so index and show share it. - About page no longer uses prose classes (this project doesn't load @tailwindcss/typography). Headings, lists, and paragraphs now use explicit utility classes so they render as intended. --- assets/js/beacon_map_hook.js | 37 ++- lib/microwaveprop/beacons/beacon.ex | 25 ++ lib/microwaveprop_web/live/about_live.ex | 260 +++++++++--------- .../live/beacon_live/index.ex | 26 +- .../live/beacon_live/show.ex | 95 +++++-- 5 files changed, 264 insertions(+), 179 deletions(-) diff --git a/assets/js/beacon_map_hook.js b/assets/js/beacon_map_hook.js index 9302d57d..a118ebfa 100644 --- a/assets/js/beacon_map_hook.js +++ b/assets/js/beacon_map_hook.js @@ -6,23 +6,24 @@ export const BeaconMap = { const onTheAir = this.el.dataset.onTheAir === "true" const cells = JSON.parse(this.el.dataset.cells || "[]") const step = parseFloat(this.el.dataset.gridStep || "0.125") + const initialShowCoverage = this.el.dataset.showCoverage === "true" const map = L.map(this.el, { zoomControl: true, attributionControl: false, scrollWheelZoom: false - }).setView([lat, lon], 9) + }).setView([lat, lon], 11) L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19 }).addTo(map) - // Draw each HRRR grid cell as a filled rectangle colored by its received - // signal tier. Use a single layerGroup so zoom/pan stays fast. - const cellLayer = L.layerGroup().addTo(map) + // Build the coverage cell layer up-front but keep it off the map until + // the user toggles it on. Using a single layerGroup keeps pan/zoom fast. + const cellLayer = L.layerGroup() const half = step / 2.0 - const allBounds = [] + for (const c of cells) { const sw = [c.lat - half, c.lon - half] const ne = [c.lat + half, c.lon + half] @@ -56,13 +57,29 @@ export const BeaconMap = { offset: [0, -10] }) - // Fit to the rendered cells (initial view already set above), then zoom - // in two steps so the beacon's immediate surroundings are visible. - if (allBounds.length > 0) { - map.fitBounds(L.latLngBounds(allBounds), {padding: [20, 20]}) - map.setZoom(map.getZoom() + 2) + const showLayer = () => { + if (!map.hasLayer(cellLayer)) cellLayer.addTo(map) + if (allBounds.length > 0) { + map.fitBounds(L.latLngBounds(allBounds), {padding: [20, 20]}) + map.setZoom(map.getZoom() + 2) + } } + const hideLayer = () => { + if (map.hasLayer(cellLayer)) map.removeLayer(cellLayer) + map.setView([lat, lon], 11) + } + + if (initialShowCoverage) showLayer() + + this.handleEvent("toggle_coverage", ({show}) => { + if (show) { + showLayer() + } else { + hideLayer() + } + }) + setTimeout(() => map.invalidateSize(), 50) } } diff --git a/lib/microwaveprop/beacons/beacon.ex b/lib/microwaveprop/beacons/beacon.ex index 1c271a47..1de2056c 100644 --- a/lib/microwaveprop/beacons/beacon.ex +++ b/lib/microwaveprop/beacons/beacon.ex @@ -45,6 +45,31 @@ defmodule Microwaveprop.Beacons.Beacon do def keying_label(key), do: Map.get(@keying_labels, key, key) + @doc """ + Formats a milliwatt power value as a plain decimal string, never scientific + notation. Integers are printed without a decimal; floats keep up to three + decimals with trailing zeros trimmed. + """ + def format_mw(nil), do: "" + + def format_mw(mw) when is_float(mw) do + if mw == trunc(mw) do + Integer.to_string(trunc(mw)) + else + mw |> :erlang.float_to_binary(decimals: 3) |> trim_trailing_zeros() + end + end + + def format_mw(mw), do: to_string(mw) + + defp trim_trailing_zeros(str) do + if String.contains?(str, ".") do + str |> String.trim_trailing("0") |> String.trim_trailing(".") + else + str + end + end + @doc """ Keying options for `Phoenix.HTML.Form.options_for_select/2`, grouped for a cleaner UI when dozens of weak-signal modes are present. diff --git a/lib/microwaveprop_web/live/about_live.ex b/lib/microwaveprop_web/live/about_live.ex index b71c33ab..71aa6a60 100644 --- a/lib/microwaveprop_web/live/about_live.ex +++ b/lib/microwaveprop_web/live/about_live.ex @@ -21,135 +21,145 @@ defmodule MicrowavepropWeb.AboutLive do <:subtitle>What we're building and why. -
-

What we're trying to do

-

- Microwave propagation above 10 GHz is dominated by the lower atmosphere: - humidity gradients, temperature inversions, ducting layers, rain cells, - and hyper-local refractivity structure. At 10, 24, 47, 76, 122, and 241 GHz - these effects decide whether a contact happens at 50 km or 500 km — and the - window often lasts minutes, not hours. -

-

- Traditionally microwave contacts are ruled by line of sight, but we've seen that is incorrect. This project is an attempt to build a data-driven - propagation prediction model specifically for the amateur microwave bands, - using hourly numerical weather forecasts, historical contact records, and - eventually calibrated beacon measurements. -

- -

The approach

-

- We pair two things that most propagation tools keep separate: -

-
    -
  1. - Atmospheric state — hourly 3 km HRRR forecasts - (surface fields plus pressure-level profiles), hourly surface - observations from ASOS, 12-hourly upper-air soundings, and gridded - IEMRE reanalysis. From these we derive refractivity profiles, - ducting detection, minimum refractivity gradient, precipitable water, - boundary-layer depth, and a 9-factor composite score for every - 0.125° cell on a CONUS grid, for each hour of an 18-hour forecast. -
  2. -
  3. - Historical contacts — 58k+ amateur microwave QSOs - tagged with the atmospheric conditions at both ends and along the - path at the exact time they happened. That gives us a ground-truth - dataset we can use to calibrate the scoring weights and, eventually, - train a machine learning model that predicts contact success given - conditions. -
  4. -
-

- The current scoring algorithm is a weighted sum of nine factors - (humidity, time of day, T–Td depression, refractivity gradient, sky - cover, season, rain, wind, pressure) with band-dependent weights — - humidity helps at 10 GHz via enhanced refractivity but hurts at - 24+ GHz via absorption, for example. Every coefficient in that - formula is a hypothesis waiting to be tested against the contact - and beacon data. -

- -

What we've collected

-

Live counts from the production database:

- -
- <.stat_card label="Contacts" value={@stats.contacts} /> - <.stat_card label="Weather stations" value={@stats.weather_stations} /> - <.stat_card label="Surface observations" value={@stats.surface_observations} /> - <.stat_card label="Upper-air soundings" value={@stats.soundings} /> - <.stat_card label="HRRR profiles" value={@stats.hrrr_profiles} /> - <.stat_card label="IEMRE gridded obs" value={@stats.iemre_observations} /> - <.stat_card label="Terrain profiles" value={@stats.terrain_profiles} /> - <.stat_card label="Propagation scores" value={@stats.propagation_scores} /> - <.stat_card label="Beacons" value={@stats.beacons} /> - <.stat_card label="Commercial link samples" value={@stats.commercial_samples} /> +
+
+

What we're trying to do

+

+ Microwave propagation above 10 GHz is dominated by the lower atmosphere: + humidity gradients, temperature inversions, ducting layers, rain cells, + and hyper-local refractivity structure. At 10, 24, 47, 76, 122, and 241 GHz + these effects decide whether a contact happens at 50 km or 500 km — and the + window often lasts minutes, not hours. +

+

+ Traditionally microwave contacts are ruled by line of sight, but we've + seen that is incorrect. This project is an attempt to build a data-driven + propagation prediction model specifically for the amateur microwave bands, + using hourly numerical weather forecasts, historical contact records, and + eventually calibrated beacon measurements. +

-

The stack

-
    -
  • - Backend: Elixir / Phoenix 1.8 with LiveView for the - real-time map, Ecto on PostgreSQL for storage, Oban for the - background data pipelines (HRRR fetch, terrain, ASOS, soundings, - IEMRE, solar indices, commercial links), Bandit as the HTTP server. -
  • -
  • - Frontend: LiveView with Leaflet for maps, Canvas - tile layers for the HRRR heatmap (we render 0.125° cells at - interactive frame rates), Tailwind v4 + daisyUI for layout, esbuild - for JS bundling. -
  • -
  • - Physics: ITU-R P.526-16 knife-edge + Deygout - 3-edge terrain diffraction, ITU-R P.838-3 rain attenuation, - dynamic k-factor from live HRRR refractivity gradients, great-circle - geometry for link budgets, Free-Space Path Loss with frequency- - dependent O₂ and H₂O absorption per band. -
  • -
  • - Machine learning: Nx / Axon / EXLA scaffolding for - a 13-feature (8 atmospheric + 4 cyclical temporal + 1 log-frequency) - feed-forward network, 64 → 32 → 1 sigmoid. Not trained yet — - waiting on a larger calibration dataset. -
  • -
  • - Data sources: NOAA HRRR model (AWS S3, hourly - analysis + 18 h forecasts), Iowa Environmental Mesonet (ASOS & - upper-air soundings), IEMRE gridded reanalysis, SRTM 90 m terrain - tiles, SNMP polling of seven commercial microwave links near DFW - at 5-minute intervals, Copernicus ERA5 for pre-2014 contact - enrichment. -
  • -
+
+

The approach

+

We pair two things that most propagation tools keep separate:

+
    +
  1. + Atmospheric state — hourly 3 km HRRR forecasts + (surface fields plus pressure-level profiles), hourly surface + observations from ASOS, 12-hourly upper-air soundings, and gridded + IEMRE reanalysis. From these we derive refractivity profiles, + ducting detection, minimum refractivity gradient, precipitable water, + boundary-layer depth, and a 9-factor composite score for every + 0.125° cell on a CONUS grid, for each hour of an 18-hour forecast. +
  2. +
  3. + Historical contacts — 58k+ amateur microwave QSOs + tagged with the atmospheric conditions at both ends and along the + path at the exact time they happened. That gives us a ground-truth + dataset we can use to calibrate the scoring weights and, eventually, + train a machine learning model that predicts contact success given + conditions. +
  4. +
+

+ The current scoring algorithm is a weighted sum of nine factors + (humidity, time of day, T–Td depression, refractivity gradient, sky + cover, season, rain, wind, pressure) with band-dependent weights — + humidity helps at 10 GHz via enhanced refractivity but hurts at + 24+ GHz via absorption, for example. Every coefficient in that + formula is a hypothesis waiting to be tested against the contact + and beacon data. +

+
-

Where this goes next

-
    -
  • - Beacon calibration. A distributed network of - amateur receivers continuously reporting CW beacon signal levels, - feeding ground-truth measurements back into the scoring algorithm. - Beacon submissions are now open to anyone via the Beacons page. -
  • -
  • - Scoring weight calibration. Fit the 9-factor - weights against recorded QSO distances / counts per band so the - composite score reflects real propagation rather than intuition. -
  • -
  • - ML model training. Once we have enough labeled - contact+condition pairs, train the Axon model to replace or augment - the hand-tuned scoring function. -
  • -
  • - Better inputs. MRMS for precipitation at - 24+ GHz (where rain attenuation dominates), RTMA/URMA surface - analysis blending, GOES total precipitable water. -
  • -
+
+

What we've collected

+

Live counts from the production database:

+
+ <.stat_card label="Contacts" value={@stats.contacts} /> + <.stat_card label="Weather stations" value={@stats.weather_stations} /> + <.stat_card label="Surface observations" value={@stats.surface_observations} /> + <.stat_card label="Upper-air soundings" value={@stats.soundings} /> + <.stat_card label="HRRR profiles" value={@stats.hrrr_profiles} /> + <.stat_card label="IEMRE gridded obs" value={@stats.iemre_observations} /> + <.stat_card label="Terrain profiles" value={@stats.terrain_profiles} /> + <.stat_card label="Propagation scores" value={@stats.propagation_scores} /> + <.stat_card label="Beacons" value={@stats.beacons} /> + <.stat_card label="Commercial link samples" value={@stats.commercial_samples} /> +
+
-

- Built by and for the North Texas Microwave Society. +

+

The stack

+
    +
  • + Backend: Elixir / Phoenix 1.8 with LiveView for the + real-time map, Ecto on PostgreSQL for storage, Oban for the + background data pipelines (HRRR fetch, terrain, ASOS, soundings, + IEMRE, solar indices, commercial links), Bandit as the HTTP server. +
  • +
  • + Frontend: LiveView with Leaflet for maps, Canvas + tile layers for the HRRR heatmap (we render 0.125° cells at + interactive frame rates), Tailwind v4 + daisyUI for layout, esbuild + for JS bundling. +
  • +
  • + Physics: ITU-R P.526-16 knife-edge + Deygout + 3-edge terrain diffraction, ITU-R P.838-3 rain attenuation, + dynamic k-factor from live HRRR refractivity gradients, great-circle + geometry for link budgets, Free-Space Path Loss with frequency- + dependent O₂ and H₂O absorption per band. +
  • +
  • + Machine learning: Nx / Axon / EXLA scaffolding for + a 13-feature (8 atmospheric + 4 cyclical temporal + 1 log-frequency) + feed-forward network, 64 → 32 → 1 sigmoid. Not trained yet — + waiting on a larger calibration dataset. +
  • +
  • + Data sources: NOAA HRRR model (AWS S3, hourly + analysis + 18 h forecasts), Iowa Environmental Mesonet (ASOS & + upper-air soundings), IEMRE gridded reanalysis, SRTM 90 m terrain + tiles, SNMP polling of seven commercial microwave links near DFW + at 5-minute intervals, Copernicus ERA5 for pre-2014 contact + enrichment. +
  • +
+
+ +
+

Where this goes next

+
    +
  • + Beacon calibration. A distributed network of + amateur receivers continuously reporting CW beacon signal levels, + feeding ground-truth measurements back into the scoring algorithm. + Beacon submissions are now open to anyone via the Beacons page. +
  • +
  • + Scoring weight calibration. Fit the 9-factor + weights against recorded QSO distances / counts per band so the + composite score reflects real propagation rather than intuition. +
  • +
  • + ML model training. Once we have enough labeled + contact+condition pairs, train the Axon model to replace or augment + the hand-tuned scoring function. +
  • +
  • + Better inputs. MRMS for precipitation at + 24+ GHz (where rain attenuation dominates), RTMA/URMA surface + analysis blending, GOES total precipitable water. +
  • +
+
+ +

+ Built by and for the + North Texas Microwave Society + .

diff --git a/lib/microwaveprop_web/live/beacon_live/index.ex b/lib/microwaveprop_web/live/beacon_live/index.ex index 720cd131..27b38939 100644 --- a/lib/microwaveprop_web/live/beacon_live/index.ex +++ b/lib/microwaveprop_web/live/beacon_live/index.ex @@ -29,7 +29,7 @@ defmodule MicrowavepropWeb.BeaconLive.Index do <:col :let={{_id, beacon}} label="Grid">{beacon.grid} <:col :let={{_id, beacon}} label="Lat">{beacon.lat} <:col :let={{_id, beacon}} label="Lon">{beacon.lon} - <:col :let={{_id, beacon}} label="EIRP (mW)">{format_mw(beacon.power_mw)} + <:col :let={{_id, beacon}} label="EIRP (mW)">{Beacon.format_mw(beacon.power_mw)} <:col :let={{_id, beacon}} label="Height AGL (ft)">{beacon.height_ft} <:col :let={{_id, beacon}} label="Keying">{Beacon.keying_label(beacon.keying)} <:col :let={{_id, beacon}} label="On air"> @@ -68,7 +68,7 @@ defmodule MicrowavepropWeb.BeaconLive.Index do <:col :let={{_id, beacon}} label="Frequency (MHz)">{beacon.frequency_mhz} <:col :let={{_id, beacon}} label="Call">{beacon.callsign} <:col :let={{_id, beacon}} label="Grid">{beacon.grid} - <:col :let={{_id, beacon}} label="EIRP (mW)">{format_mw(beacon.power_mw)} + <:col :let={{_id, beacon}} label="EIRP (mW)">{Beacon.format_mw(beacon.power_mw)} <:col :let={{_id, beacon}} label="Height AGL (ft)">{beacon.height_ft} <:col :let={{_id, beacon}} label="Submitted"> {Calendar.strftime(beacon.inserted_at, "%Y-%m-%d %H:%M UTC")} @@ -162,26 +162,4 @@ defmodule MicrowavepropWeb.BeaconLive.Index do defp admin?(%{user: %{is_admin: true}}), do: true defp admin?(_), do: false - - # Format a milliwatt power value without scientific notation. - # Keeps up to 3 decimal places, drops trailing zeros. - defp format_mw(nil), do: "" - - defp format_mw(mw) when is_float(mw) do - if mw == trunc(mw) do - Integer.to_string(trunc(mw)) - else - mw |> :erlang.float_to_binary(decimals: 3) |> trim_trailing_zeros() - end - end - - defp format_mw(mw), do: to_string(mw) - - defp trim_trailing_zeros(str) do - if String.contains?(str, ".") do - str |> String.trim_trailing("0") |> String.trim_trailing(".") - else - str - end - end end diff --git a/lib/microwaveprop_web/live/beacon_live/show.ex b/lib/microwaveprop_web/live/beacon_live/show.ex index 479e9c90..f5a4f214 100644 --- a/lib/microwaveprop_web/live/beacon_live/show.ex +++ b/lib/microwaveprop_web/live/beacon_live/show.ex @@ -50,10 +50,23 @@ defmodule MicrowavepropWeb.BeaconLive.Show do data-on-the-air={to_string(@beacon.on_the_air)} data-grid-step={@estimate.grid_step} data-cells={Jason.encode!(@estimate.cells)} + data-show-coverage={to_string(@show_coverage)} >
-
+
+ +
+ +
Band {@estimate.band_label} · EIRP {@estimate.eirp_dbm} dBm · @@ -71,7 +84,7 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
-
+
<%= for tier <- @estimate.tiers do %>
- <.list> - <:item title="Frequency (MHz)">{@beacon.frequency_mhz} - <:item title="Callsign">{@beacon.callsign} - <:item title="Grid">{@beacon.grid} - <:item title="Latitude">{@beacon.lat} - <:item title="Longitude">{@beacon.lon} - <:item title="TX power (EIRP) (mW)">{@beacon.power_mw} - <:item title="Height above ground (ft)">{@beacon.height_ft} - <:item title="Bearing">{bearing_label(@beacon.bearing)} - <:item :if={@beacon.beamwidth_deg} title="Beamwidth"> - {@beacon.beamwidth_deg}° - - <:item title="Keying">{Beacon.keying_label(@beacon.keying)} - <:item title="On the air">{if @beacon.on_the_air, do: "Yes", else: "No"} - <:item :if={@beacon.notes && @beacon.notes != ""} title="Notes"> -
{@beacon.notes}
- - +
+
+ <.stat_field label="Frequency">{@beacon.frequency_mhz} MHz + <.stat_field label="Callsign">{@beacon.callsign} + <.stat_field label="Keying">{Beacon.keying_label(@beacon.keying)} + <.stat_field label="On the air"> + + {if @beacon.on_the_air, do: "Yes", else: "No"} + + + + <.stat_field label="Grid">{@beacon.grid} + <.stat_field label="Latitude">{format_coord(@beacon.lat)} + <.stat_field label="Longitude">{format_coord(@beacon.lon)} + <.stat_field label="EIRP">{Beacon.format_mw(@beacon.power_mw)} mW + + <.stat_field label="Height AGL">{Beacon.format_mw(@beacon.height_ft)} ft + <.stat_field label="Bearing">{bearing_label(@beacon.bearing)} + <.stat_field :if={@beacon.beamwidth_deg} label="Beamwidth"> + {Beacon.format_mw(@beacon.beamwidth_deg)}° + +
+ +
+
+ Notes +
+
{@beacon.notes}
+
+
""" end @@ -118,10 +146,20 @@ defmodule MicrowavepropWeb.BeaconLive.Show do socket |> assign(:page_title, "Beacon") |> assign(:beacon, beacon) + |> assign(:show_coverage, false) |> assign(:estimate, RangeEstimate.estimate(beacon))} end @impl true + def handle_event("toggle_coverage", _params, socket) do + show = not socket.assigns.show_coverage + + {:noreply, + socket + |> assign(:show_coverage, show) + |> push_event("toggle_coverage", %{show: show})} + end + def handle_event("approve", _params, socket) do if admin?(socket.assigns.current_scope) do {:ok, approved} = Beacons.approve_beacon(socket.assigns.beacon) @@ -160,4 +198,21 @@ defmodule MicrowavepropWeb.BeaconLive.Show do defp bearing_label(nil), do: "Omni" defp bearing_label("omni"), do: "Omni" defp bearing_label(value), do: "#{value}°" + + defp format_coord(value) when is_float(value), do: :erlang.float_to_binary(value, decimals: 5) + defp format_coord(value), do: to_string(value) + + attr :label, :string, required: true + slot :inner_block, required: true + + defp stat_field(assigns) do + ~H""" +
+
+ {@label} +
+
{render_slot(@inner_block)}
+
+ """ + end end