diff --git a/assets/js/app.js b/assets/js/app.js index 7a4ae0fc..09bee7f5 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -35,6 +35,7 @@ import {ContactMap} from "./contact_map_hook" import {ContactsMap} from "./contacts_map_hook" import {ElevationProfile} from "./elevation_profile_hook" import {WeatherMap} from "./weather_map_hook" +import {LocateMe} from "./locate_me_hook" const UtcClock = { mounted() { @@ -56,7 +57,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}, + hooks: {...colocatedHooks, PropagationMap, UtcClock, ContactMap, ContactsMap, ElevationProfile, WeatherMap, LocateMe}, }) // Show progress bar on live navigation and form submits diff --git a/assets/js/locate_me_hook.js b/assets/js/locate_me_hook.js new file mode 100644 index 00000000..e57d7ae4 --- /dev/null +++ b/assets/js/locate_me_hook.js @@ -0,0 +1,27 @@ +export const LocateMe = { + mounted() { + this.el.addEventListener("click", () => { + if (!navigator.geolocation) { + alert("Geolocation is not supported by this browser") + return + } + + this.el.classList.add("loading", "loading-spinner") + + navigator.geolocation.getCurrentPosition( + (pos) => { + this.el.classList.remove("loading", "loading-spinner") + this.pushEvent("gps_location", { + lat: pos.coords.latitude, + lon: pos.coords.longitude + }) + }, + (err) => { + this.el.classList.remove("loading", "loading-spinner") + alert("Could not get location: " + err.message) + }, + { enableHighAccuracy: true, timeout: 10000 } + ) + }) + } +} diff --git a/lib/microwaveprop_web/live/path_live.ex b/lib/microwaveprop_web/live/path_live.ex index 4fcf2934..f0b0a9ff 100644 --- a/lib/microwaveprop_web/live/path_live.ex +++ b/lib/microwaveprop_web/live/path_live.ex @@ -1,6 +1,7 @@ defmodule MicrowavepropWeb.PathLive do @moduledoc false use MicrowavepropWeb, :live_view + use LiveStash alias Microwaveprop.Propagation.BandConfig alias Microwaveprop.Propagation.Scorer @@ -21,33 +22,106 @@ defmodule MicrowavepropWeb.PathLive do {"241 GHz", "241000"} ] + @stash_fields [ + :source, + :destination, + :band, + :src_height_ft, + :dst_height_ft, + :tx_power_dbm, + :src_gain_dbi, + :dst_gain_dbi + ] + @impl true def mount(_params, _session, socket) do + defaults = %{ + source: "", + destination: "", + band: "10000", + src_height_ft: "30", + dst_height_ft: "30", + tx_power_dbm: "20", + src_gain_dbi: "30", + dst_gain_dbi: "30" + } + + recovered = + case LiveStash.recover_state(socket) do + {:recovered, socket} -> + socket.assigns + |> Map.take(@stash_fields) + |> then(&Map.merge(defaults, &1)) + + _ -> + defaults + end + {:ok, - assign(socket, - page_title: "Path Calculator", - band_options: @band_options, - source: "", - destination: "", - band: "10000", - result: nil, - error: nil, - computing: false + assign( + socket, + [ + {:page_title, "Path Calculator"}, + {:band_options, @band_options}, + {:result, nil}, + {:error, nil}, + {:computing, false} + ] ++ + Map.to_list(recovered) )} end @impl true - def handle_event("calculate", %{"source" => source, "destination" => dest, "band" => band}, socket) do - socket = assign(socket, source: source, destination: dest, band: band, error: nil, computing: true, result: nil) + def handle_event("calculate", params, socket) do + src_height_ft = parse_float(params["src_height_ft"], 30.0) + dst_height_ft = parse_float(params["dst_height_ft"], 30.0) - send(self(), {:compute_path, source, dest, String.to_integer(band)}) + tx_power_dbm = parse_float(params["tx_power_dbm"], 20.0) + tx_power_mw = :math.pow(10, tx_power_dbm / 10) - {:noreply, socket} + station_params = %{ + src_height_ft: src_height_ft, + dst_height_ft: dst_height_ft, + src_height_m: src_height_ft * 0.3048, + dst_height_m: dst_height_ft * 0.3048, + tx_power_dbm: tx_power_dbm, + tx_power_mw: tx_power_mw, + src_gain_dbi: parse_float(params["src_gain_dbi"], 30.0), + dst_gain_dbi: parse_float(params["dst_gain_dbi"], 30.0) + } + + socket = + assign(socket, + source: params["source"], + destination: params["destination"], + band: params["band"], + src_height_ft: params["src_height_ft"], + dst_height_ft: params["dst_height_ft"], + tx_power_dbm: params["tx_power_dbm"], + src_gain_dbi: params["src_gain_dbi"], + dst_gain_dbi: params["dst_gain_dbi"], + error: nil, + computing: true, + result: nil + ) + + send( + self(), + {:compute_path, params["source"], params["destination"], String.to_integer(params["band"]), station_params} + ) + + {:noreply, LiveStash.stash_assigns(socket, @stash_fields)} + end + + def handle_event("gps_location", %{"lat" => lat, "lon" => lon}, socket) do + # Use coordinate string as source — resolve_location handles lat,lon format + source = "#{Float.round(lat / 1, 6)},#{Float.round(lon / 1, 6)}" + {:noreply, assign(socket, source: source)} end @impl true - def handle_info({:compute_path, source, dest, band_mhz}, socket) do - case compute_path(source, dest, band_mhz) do + def handle_info({:compute_path, source, dest, band_mhz, station_params}, socket) do + case compute_path(source, dest, band_mhz, station_params) do {:ok, result} -> {:noreply, assign(socket, result: result, computing: false)} @@ -56,7 +130,7 @@ defmodule MicrowavepropWeb.PathLive do end end - defp compute_path(source, dest, band_mhz) do + defp compute_path(source, dest, band_mhz, station_params) do with {:ok, src} <- resolve_location(source), {:ok, dst} <- resolve_location(dest) do band_config = BandConfig.get(band_mhz) || BandConfig.get(10_000) @@ -65,11 +139,16 @@ defmodule MicrowavepropWeb.PathLive do dist_km = haversine_km(src.lat, src.lon, dst.lat, dst.lon) bearing = bearing_deg(src.lat, src.lon, dst.lat, dst.lon) - # Terrain profile + # Terrain profile with antenna heights terrain_result = case ElevationClient.fetch_elevation_profile(src.lat, src.lon, dst.lat, dst.lon, 64, download: true) do {:ok, profile} -> - analysis = TerrainAnalysis.analyse(profile, dist_km, freq_ghz) + analysis = + TerrainAnalysis.analyse(profile, dist_km, freq_ghz, + ant_ht_a: station_params.src_height_m, + ant_ht_b: station_params.dst_height_m + ) + %{profile: profile, analysis: analysis} {:error, _} -> @@ -89,13 +168,15 @@ defmodule MicrowavepropWeb.PathLive do # Build conditions and score {conditions, scoring} = build_scoring(hrrr_profiles, src, now, band_config) - # Loss budget + # Loss and power budgets loss_budget = compute_loss_budget(dist_km, freq_ghz, band_config, terrain_result, conditions) + power_budget = compute_power_budget(station_params, loss_budget) {:ok, %{ source: src, destination: dst, + station_params: station_params, band_config: band_config, band_mhz: band_mhz, freq_ghz: freq_ghz, @@ -105,6 +186,7 @@ defmodule MicrowavepropWeb.PathLive do conditions: conditions, scoring: scoring, loss_budget: loss_budget, + power_budget: power_budget, hrrr_count: length(hrrr_profiles) }} end @@ -117,6 +199,12 @@ defmodule MicrowavepropWeb.PathLive do input == "" -> {:error, "Location is required"} + coordinate_pair?(input) -> + [lat_s, lon_s] = String.split(input, ",", parts: 2) + {lat, _} = Float.parse(String.trim(lat_s)) + {lon, _} = Float.parse(String.trim(lon_s)) + {:ok, %{lat: lat, lon: lon, label: "#{Float.round(lat, 3)}, #{Float.round(lon, 3)}", type: :coordinates}} + maidenhead?(input) -> case Maidenhead.to_latlon(input) do {:ok, {lat, lon}} -> {:ok, %{lat: lat, lon: lon, label: String.upcase(input), type: :grid}} @@ -135,6 +223,16 @@ defmodule MicrowavepropWeb.PathLive do end end + defp coordinate_pair?(input) do + case String.split(input, ",", parts: 2) do + [a, b] -> + match?({_, _}, Float.parse(String.trim(a))) and match?({_, _}, Float.parse(String.trim(b))) + + _ -> + false + end + end + defp maidenhead?(input) do String.length(input) >= 4 and String.length(input) <= 10 and Regex.match?(~r/^[A-Ra-r]{2}[0-9]{2}/i, input) @@ -225,6 +323,37 @@ defmodule MicrowavepropWeb.PathLive do } end + defp compute_power_budget(station_params, loss_budget) do + tx_power_dbm = station_params.tx_power_dbm + eirp_dbm = tx_power_dbm + station_params.src_gain_dbi + rx_power_dbm = eirp_dbm - loss_budget.total + station_params.dst_gain_dbi + + # Typical receiver sensitivities by mode (dBm) + # CW ~-140, SSB ~-130, FM ~-120 + rx_sensitivity_cw = -140.0 + rx_sensitivity_ssb = -130.0 + margin_cw = rx_power_dbm - rx_sensitivity_cw + margin_ssb = rx_power_dbm - rx_sensitivity_ssb + + %{ + tx_power_dbm: Float.round(tx_power_dbm, 1), + eirp_dbm: Float.round(eirp_dbm, 1), + rx_power_dbm: Float.round(rx_power_dbm, 1), + margin_cw: Float.round(margin_cw, 1), + margin_ssb: Float.round(margin_ssb, 1) + } + end + + defp parse_float(nil, default), do: default + defp parse_float("", default), do: default + + defp parse_float(str, default) do + case Float.parse(str) do + {val, _} -> val + :error -> default + end + end + # ── Geo helpers ── defp haversine_km(lat1, lon1, lat2, lon2) do @@ -270,6 +399,10 @@ defmodule MicrowavepropWeb.PathLive do defp format_number(n) when is_float(n), do: :erlang.float_to_binary(n, decimals: 1) defp format_number(n), do: to_string(n) + defp format_mw(mw) when mw >= 1000, do: "#{format_number(mw / 1000)} W" + defp format_mw(mw) when mw >= 1, do: "#{format_number(mw)} mW" + defp format_mw(mw), do: "#{format_number(mw * 1000)} uW" + # ── Render ── @impl true @@ -285,14 +418,26 @@ defmodule MicrowavepropWeb.PathLive do
| Factor | -Score | -Weight | -Contribution | -
|---|---|---|---|
| {row.name} | -{row.score} | -{row.weight}% | -{row.contribution} | -