diff --git a/assets/js/app.js b/assets/js/app.js index 85ebc662..e6b5d63f 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -37,6 +37,7 @@ import {ElevationProfile} from "./elevation_profile_hook" import {WeatherMap} from "./weather_map_hook" import {LocateMe, CopyLink} from "./locate_me_hook" import {RoverMap} from "./rover_map_hook" +import {BeaconMap} from "./beacon_map_hook" const UtcClock = { mounted() { @@ -58,7 +59,7 @@ const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute const liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: initLiveStash({_csrf_token: csrfToken}), - hooks: {...colocatedHooks, PropagationMap, UtcClock, ContactMap, ContactsMap, ElevationProfile, WeatherMap, LocateMe, CopyLink, RoverMap}, + hooks: {...colocatedHooks, PropagationMap, UtcClock, ContactMap, ContactsMap, ElevationProfile, WeatherMap, LocateMe, CopyLink, RoverMap, BeaconMap}, }) // Show progress bar on live navigation and form submits diff --git a/assets/js/beacon_map_hook.js b/assets/js/beacon_map_hook.js new file mode 100644 index 00000000..0afa8f98 --- /dev/null +++ b/assets/js/beacon_map_hook.js @@ -0,0 +1,60 @@ +export const BeaconMap = { + mounted() { + const lat = parseFloat(this.el.dataset.lat) + const lon = parseFloat(this.el.dataset.lon) + const label = this.el.dataset.label || "" + const onTheAir = this.el.dataset.onTheAir === "true" + const rings = JSON.parse(this.el.dataset.rings || "[]") + + const map = L.map(this.el, { + zoomControl: true, + attributionControl: false, + scrollWheelZoom: false + }) + + L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { + maxZoom: 19 + }).addTo(map) + + // Draw rings outermost-first so stronger tiers stack on top. + const sorted = [...rings].sort((a, b) => b.radius_km - a.radius_km) + const drawn = [] + for (const ring of sorted) { + if (!ring.radius_km || ring.radius_km <= 0) continue + const circle = L.circle([lat, lon], { + radius: ring.radius_km * 1000, + color: ring.color, + weight: 1.5, + opacity: 0.9, + fillColor: ring.color, + fillOpacity: 0.12 + }).addTo(map) + circle.bindTooltip(`${ring.label}: ${ring.rx_dbm} dBm · ${ring.radius_km} km`, { + sticky: true + }) + drawn.push(circle) + } + + const markerColor = onTheAir ? "#16a34a" : "#94a3b8" + const markerFill = onTheAir ? "#22c55e" : "#cbd5e1" + + L.circleMarker([lat, lon], { + radius: 7, + color: markerColor, + fillColor: markerFill, + fillOpacity: 1.0, + weight: 2 + }).addTo(map).bindTooltip(label, { + permanent: true, + direction: "top", + offset: [0, -10] + }) + + // Fit to the largest ring, or fall back to a reasonable zoom around the point. + if (drawn.length > 0) { + map.fitBounds(drawn[0].getBounds(), {padding: [20, 20]}) + } else { + map.setView([lat, lon], 9) + } + } +} diff --git a/config/dev.exs b/config/dev.exs index 8ba0df86..e73fd674 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -87,7 +87,7 @@ config :microwaveprop, Oban, {Oban.Plugins.Lifeline, rescue_after: to_timeout(minute: 30)}, {Oban.Plugins.Cron, crontab: [ - # {"5 * * * *", Microwaveprop.Workers.PropagationGridWorker}, + {"5 * * * *", Microwaveprop.Workers.PropagationGridWorker}, {"0 8 * * *", Microwaveprop.Workers.SolarIndexWorker} ]} ] @@ -106,8 +106,8 @@ config :microwaveprop, load_ml_model: true # Use local SRTM1 tiles for elevation lookups instead of the Open-Meteo API config :microwaveprop, srtm_tiles_dir: Path.expand("~/srtm/tiles") -# Freshness monitor disabled in dev (no hourly propagation grid running) -config :microwaveprop, start_freshness_monitor: false +# Freshness monitor enabled (hourly PropagationGridWorker runs in dev) +config :microwaveprop, start_freshness_monitor: true # Initialize plugs at runtime for faster development compilation config :phoenix, :plug_init_mode, :runtime diff --git a/lib/microwaveprop/beacons/beacon.ex b/lib/microwaveprop/beacons/beacon.ex index de6db988..a7a4b355 100644 --- a/lib/microwaveprop/beacons/beacon.ex +++ b/lib/microwaveprop/beacons/beacon.ex @@ -21,6 +21,7 @@ defmodule Microwaveprop.Beacons.Beacon do field :lon, :float field :power_mw, :float field :height_ft, :float + field :on_the_air, :boolean, default: true field :user_id, :binary_id timestamps(type: :utc_datetime) @@ -30,7 +31,7 @@ defmodule Microwaveprop.Beacons.Beacon do def changeset(beacon, attrs) do beacon - |> cast(attrs, [:frequency_mhz, :callsign, :grid, :lat, :lon, :power_mw, :height_ft]) + |> cast(attrs, [:frequency_mhz, :callsign, :grid, :lat, :lon, :power_mw, :height_ft, :on_the_air]) |> update_change(:callsign, fn cs -> cs && String.upcase(String.trim(cs)) end) |> maybe_fill_latlon() |> maybe_fill_grid() diff --git a/lib/microwaveprop/beacons/range_estimate.ex b/lib/microwaveprop/beacons/range_estimate.ex new file mode 100644 index 00000000..c26dd15b --- /dev/null +++ b/lib/microwaveprop/beacons/range_estimate.ex @@ -0,0 +1,145 @@ +defmodule Microwaveprop.Beacons.RangeEstimate do + @moduledoc """ + Estimates a beacon's reception range at several signal-strength tiers. + + For each tier we solve a link budget of + + Rx_dBm = EIRP_dBm + Rx_gain_dBi - FSPL(d, f) - atm_loss_per_km * d + + for the distance `d` at which the received power equals the tier threshold. + Free-space path loss uses the standard formula and atmospheric absorption + comes from the band's O₂/H₂O coefficients in `BandConfig`. + + The result is then multiplied by a propagation-score factor derived from the + latest `propagation_scores` row at the beacon's lat/lon — score 50 → 1.0x, + score 0 → 0.5x, score 100 → 1.5x — so current HRRR conditions shift the + rings in or out. + """ + + alias Microwaveprop.Propagation + alias Microwaveprop.Propagation.BandConfig + + # Signal-strength tiers and their RX sensitivity thresholds (dBm). + # Colors match the map_live / propagation_map_hook palette. + @tiers [ + %{label: "Excellent", rx_dbm: -100, color: "#00ffa3"}, + %{label: "Good", rx_dbm: -115, color: "#7dffd4"}, + %{label: "Marginal", rx_dbm: -125, color: "#ffe566"}, + %{label: "Weak CW", rx_dbm: -135, color: "#ff9044"}, + %{label: "Detection", rx_dbm: -145, color: "#ff4f4f"} + ] + + # Assume the receiving station is an average amateur microwave station + # (dish/horn + low-noise preamp). + @rx_gain_dbi 20.0 + # Beacons are typically omnidirectional. + @tx_gain_dbi 0.0 + + @doc """ + Convert a power in milliwatts to dBm. Returns `-999.9` for non-positive input. + """ + @spec mw_to_dbm(number()) :: float() + def mw_to_dbm(mw) when is_number(mw) and mw > 0, do: 10.0 * :math.log10(mw) + def mw_to_dbm(_), do: -999.9 + + @doc """ + Returns the closest configured band frequency (in MHz) to the given beacon + frequency. e.g. `nearest_band_mhz(10368.1) == 10_000`. + """ + @spec nearest_band_mhz(number()) :: integer() + def nearest_band_mhz(freq_mhz) when is_number(freq_mhz) do + BandConfig.all_freqs() + |> Enum.min_by(fn b -> abs(b - freq_mhz) end) + end + + @doc """ + Estimate a beacon's reception range. + + Returns a map with band info, current score, and a list of rings sorted + weakest-distance first (strongest RX tier → shortest radius). + """ + @spec estimate(Microwaveprop.Beacons.Beacon.t()) :: map() + def estimate(beacon) do + band_mhz = nearest_band_mhz(beacon.frequency_mhz) + band_config = BandConfig.get(band_mhz) + + detail = Propagation.point_detail(band_mhz, beacon.lat, beacon.lon) + score = (detail && detail.score) || 50 + valid_time = detail && detail.valid_time + + f_mhz = beacon.frequency_mhz * 1.0 + eirp_dbm = mw_to_dbm(beacon.power_mw || 0.0) + @tx_gain_dbi + + atm_per_km = atm_loss_per_km(band_config) + score_mult = 0.5 + score / 100.0 + + rings = + @tiers + |> Enum.map(fn tier -> + d_phys = solve_range(eirp_dbm, @rx_gain_dbi, tier.rx_dbm, f_mhz, atm_per_km) + radius = Float.round(d_phys * score_mult, 1) + + %{ + label: tier.label, + rx_dbm: tier.rx_dbm, + color: tier.color, + radius_km: radius + } + end) + |> Enum.filter(fn ring -> ring.radius_km > 0.5 end) + |> Enum.sort_by(& &1.radius_km) + + %{ + beacon_id: beacon.id, + band_mhz: band_mhz, + band_label: band_config && band_config.label, + score: score, + score_mult: Float.round(score_mult, 2), + valid_time: valid_time, + eirp_dbm: Float.round(eirp_dbm, 1), + atm_per_km: Float.round(atm_per_km, 3), + rings: rings + } + end + + # Total dB/km atmospheric attenuation from O2 + water vapor. Uses a moderate + # absolute humidity of 10 g/m³ as a default — the HRRR-derived score already + # captures humidity variability, so using a fixed value here keeps the + # physics clean. + defp atm_loss_per_km(nil), do: 0.0 + + defp atm_loss_per_km(band_config) do + o2 = Map.get(band_config, :o2_db_km, 0.0) + h2o_coeff = Map.get(band_config, :h2o_coeff, 0.0) + o2 + h2o_coeff * 10.0 + end + + # Solve `FSPL(d) + atm_per_km * d = budget_db` for d via bisection. + # `budget_db = EIRP + Rx_gain - threshold`. + defp solve_range(eirp_dbm, rx_gain, threshold_dbm, f_mhz, atm_per_km) do + budget = eirp_dbm + rx_gain - threshold_dbm + log_f_const = 20.0 * :math.log10(f_mhz) + 32.44 + + cond do + budget <= log_f_const -> + # Even at 1 km the budget is already negative → ring is effectively 0. + 0.0 + + true -> + bisect(0.01, 5000.0, budget, log_f_const, atm_per_km, 50) + end + end + + defp bisect(lo, hi, _budget, _log_f, _atm, 0), do: (lo + hi) / 2.0 + + defp bisect(lo, hi, budget, log_f, atm, iters) do + mid = (lo + hi) / 2.0 + val = 20.0 * :math.log10(mid) + log_f + atm * mid + + if val > budget do + bisect(lo, mid, budget, log_f, atm, iters - 1) + else + bisect(mid, hi, budget, log_f, atm, iters - 1) + end + end +end diff --git a/lib/microwaveprop_web/live/beacon_live/form.ex b/lib/microwaveprop_web/live/beacon_live/form.ex index 46d72c65..b1161a2b 100644 --- a/lib/microwaveprop_web/live/beacon_live/form.ex +++ b/lib/microwaveprop_web/live/beacon_live/form.ex @@ -43,6 +43,7 @@ defmodule MicrowavepropWeb.BeaconLive.Form do step="any" required /> + <.input field={@form[:on_the_air]} type="checkbox" label="On the air" />