diff --git a/lib/microwaveprop/propagation/path_compute.ex b/lib/microwaveprop/propagation/path_compute.ex new file mode 100644 index 00000000..fb92920c --- /dev/null +++ b/lib/microwaveprop/propagation/path_compute.ex @@ -0,0 +1,447 @@ +defmodule Microwaveprop.Propagation.PathCompute do + @moduledoc """ + Pure(ish) path-calculator engine extracted from `MicrowavepropWeb.PathLive`. + + Given a source / destination / band / station-params tuple, runs the + same pipeline `/path` does: terrain analysis (ITU-R P.526), HRRR + profile lookup at 9 evenly-spaced points, sounding & ionosphere + readouts, native HRRR duct info, composite scoring, loss + power + budgets, and the 18-hour propagation forecast at the path midpoint. + + Used by both: + * the live `MicrowavepropWeb.PathLive` page (live recompute) + * the rover-planning `RoverPathProfileWorker` (background cache — + result map is stored on the matching `RoverPlanning.Path`). + """ + + import Microwaveprop.Geo, only: [haversine_km: 4, bearing_deg: 4] + + alias Microwaveprop.Geo + alias Microwaveprop.Ionosphere + alias Microwaveprop.Propagation + alias Microwaveprop.Propagation.BandConfig + alias Microwaveprop.Propagation.ProfilesFile + alias Microwaveprop.Propagation.Scorer + alias Microwaveprop.Propagation.SporadicE + alias Microwaveprop.Repo + alias Microwaveprop.Terrain.ElevationClient + alias Microwaveprop.Terrain.TerrainAnalysis + alias Microwaveprop.Weather + alias Microwaveprop.Weather.Station + + require Logger + + @type station_params :: %{ + required(:src_height_m) => float(), + required(:dst_height_m) => float(), + required(:tx_power_dbm) => float(), + required(:src_gain_dbi) => float(), + required(:dst_gain_dbi) => float(), + optional(:src_height_ft) => float(), + optional(:dst_height_ft) => float(), + optional(:tx_power_mw) => float() + } + + @type result :: %{ + source: map(), + destination: map(), + station_params: station_params(), + band_config: map(), + band_mhz: integer(), + freq_ghz: float(), + dist_km: float(), + bearing: float(), + terrain: map() | nil, + conditions: map() | nil, + scoring: map() | nil, + loss_budget: map(), + power_budget: map(), + forecast: list(), + hrrr_count: integer(), + hrrr_points: list(), + ionosphere: map() | nil, + sounding: map() | nil + } + + @doc """ + Run the full path-calculator pipeline. Returns `{:ok, result}` on + success, `{:error, reason}` when either endpoint fails to resolve + (callsign / grid / coords). + """ + @spec compute(String.t(), String.t(), integer(), station_params()) :: + {:ok, result()} | {:error, term()} + def compute(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) + freq_ghz = band_mhz / 1000 + + dist_km = haversine_km(src.lat, src.lon, dst.lat, dst.lon) + bearing = bearing_deg(src.lat, src.lon, dst.lat, dst.lon) + + terrain_result = compute_terrain(src, dst, dist_km, freq_ghz, station_params) + + now = DateTime.utc_now() + midlat = (src.lat + dst.lat) / 2 + midlon = (src.lon + dst.lon) / 2 + + profile_valid_time = latest_profile_valid_time(now) + profile_grid = profile_grid_for(profile_valid_time) + sample_points = path_sample_points(src, dst, 9) + + {grid_hits, misses} = partition_grid_hits(sample_points, profile_grid, profile_valid_time) + fallback_hits = fallback_hits(misses, now) + + hrrr_points = Enum.reverse(grid_hits, fallback_hits) + hrrr_profiles = Enum.map(hrrr_points, & &1.profile) + + sounding = build_sounding_readout(midlat, midlon, now) + + native_duct = + case Weather.nearest_native_duct_info(midlat, midlon, now) do + {:ok, info} -> info + {:error, :not_found} -> %{best_duct_band_ghz: nil, bulk_richardson: nil} + end + + {conditions, scoring} = + build_scoring(hrrr_profiles, src, dst, now, band_config, native_duct) + + loss_budget = compute_loss_budget(dist_km, freq_ghz, band_config, terrain_result, conditions) + power_budget = compute_power_budget(station_params, loss_budget) + + forecast = Propagation.point_forecast(band_mhz, midlat, midlon) + + ionosphere = build_ionosphere_readout(band_mhz, midlat, midlon, dist_km) + + {:ok, + %{ + source: src, + destination: dst, + station_params: station_params, + band_config: band_config, + band_mhz: band_mhz, + freq_ghz: freq_ghz, + dist_km: dist_km, + bearing: bearing, + terrain: terrain_result, + conditions: conditions, + scoring: scoring, + loss_budget: loss_budget, + power_budget: power_budget, + forecast: forecast, + hrrr_count: length(hrrr_profiles), + hrrr_points: hrrr_points, + ionosphere: ionosphere, + sounding: sounding + }} + end + end + + defp compute_terrain(src, dst, dist_km, freq_ghz, station_params) do + 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, + ant_ht_a: station_params.src_height_m, + ant_ht_b: station_params.dst_height_m + ) + + %{profile: profile, analysis: analysis} + + {:error, reason} -> + Logger.warning( + "PathCompute terrain profile load failed: src=#{src.lat},#{src.lon} dst=#{dst.lat},#{dst.lon} reason=#{inspect(reason)}" + ) + + nil + end + end + + defp fallback_hits(misses, now) do + misses + |> Enum.reverse() + |> Task.async_stream(&fallback_hrrr_point(&1, now), + max_concurrency: 4, + timeout: 5_000, + on_timeout: :kill_task + ) + |> Enum.zip(Enum.reverse(misses)) + |> Enum.flat_map(fn + {{:ok, nil}, _} -> + [] + + {{:ok, point}, _} -> + [point] + + {{:exit, reason}, {label, lat, lon}} -> + Logger.error( + "PathCompute HRRR fallback lookup failed: label=#{inspect(label)} lat=#{lat} lon=#{lon} reason=#{inspect(reason)}" + ) + + [] + end) + end + + @doc "Public for PathLive's `path_forecast_detail` event." + @spec resolve_location(String.t()) :: {:ok, map()} | {:error, String.t()} + def resolve_location(input) do + case MicrowavepropWeb.LocationResolver.resolve(input) do + :empty -> {:error, "Location is required"} + other -> other + end + end + + @doc "Public for PathLive's `path_forecast_detail` event." + @spec build_ionosphere_readout(integer(), float(), float(), float()) :: map() | nil + def build_ionosphere_readout(band_mhz, midlat, midlon, dist_km) when band_mhz in [50, 144, 222, 432] do + case Ionosphere.nearest_foes(midlat, midlon) do + {:ok, obs} -> + es_score = SporadicE.es_score(obs.fo_es_mhz, band_mhz, dist_km) + muf = SporadicE.single_hop_muf(obs.fo_es_mhz, dist_km) + + %{ + station_code: obs.station_code, + valid_time: obs.valid_time, + fo_es_mhz: obs.fo_es_mhz, + fo_f2_mhz: obs.fo_f2_mhz, + mufd_mhz: obs.mufd_mhz, + es_score: es_score, + es_muf_mhz: muf, + es_in_range?: dist_km >= 500 and dist_km <= 2500 + } + + {:error, _reason} -> + nil + end + end + + def build_ionosphere_readout(_band_mhz, _lat, _lon, _dist), do: nil + + defp profile_grid_for(nil), do: nil + + defp profile_grid_for(valid_time) do + case ProfilesFile.read(valid_time) do + {:ok, grid} -> + grid + + {:error, reason} -> + Logger.warning( + "PathCompute ProfilesFile.read failed: valid_time=#{inspect(valid_time)} reason=#{inspect(reason)}" + ) + + nil + + other -> + Logger.warning( + "PathCompute ProfilesFile.read unexpected: valid_time=#{inspect(valid_time)} got=#{inspect(other)}" + ) + + nil + end + end + + defp lookup_profile_grid(grid, lat, lon) do + {snapped_lat, snapped_lon} = ProfilesFile.snap(lat, lon) + Map.get(grid, {snapped_lat, snapped_lon}) + end + + defp partition_grid_hits(sample_points, profile_grid, profile_valid_time) do + Enum.reduce(sample_points, {[], []}, fn pt, acc -> + classify_grid_hit(pt, profile_grid, profile_valid_time, acc) + end) + end + + defp classify_grid_hit(pt, nil, _vt, {hits, mss}), do: {hits, [pt | mss]} + + defp classify_grid_hit({label, lat, lon} = pt, grid, vt, {hits, mss}) do + case lookup_profile_grid(grid, lat, lon) do + nil -> {hits, [pt | mss]} + cell -> {[%{label: label, profile: profile_from_cell(cell, lat, lon, vt)} | hits], mss} + end + end + + defp fallback_hrrr_point({label, lat, lon}, now) do + case Weather.find_nearest_hrrr(lat, lon, now) do + nil -> nil + profile -> %{label: label, profile: profile} + end + end + + defp latest_profile_valid_time(now) do + case ProfilesFile.list_valid_times() do + [] -> nil + times -> pick_latest_at_or_before(times, now) + end + end + + defp pick_latest_at_or_before(times, now) do + case Enum.filter(times, fn t -> DateTime.compare(t, now) != :gt end) do + [] -> List.first(times) + past -> Enum.max(past, DateTime) + end + end + + defp profile_from_cell(cell, lat, lon, valid_time) do + cell + |> Map.put_new(:lat, lat) + |> Map.put_new(:lon, lon) + |> Map.put_new(:valid_time, valid_time) + |> Map.put_new(:min_refractivity_gradient, Map.get(cell, :native_min_gradient)) + |> Map.put_new(:surface_refractivity, nil) + |> Map.put_new(:ducting_detected, Map.get(cell, :duct_count, 0) > 0) + |> Map.put_new(:duct_characteristics, nil) + end + + defp path_sample_points(src, dst, count) when count >= 2 do + Enum.map(1..count, fn i -> + t = (i - 1) / (count - 1) + lat = src.lat + (dst.lat - src.lat) * t + lon = src.lon + (dst.lon - src.lon) * t + + label = + cond do + i == 1 -> "Source" + i == count -> "Destination" + i * 2 == count + 1 -> "Midpoint" + true -> "#{round(t * 100)}%" + end + + {label, lat, lon} + end) + end + + defp build_sounding_readout(midlat, midlon, now) do + case Weather.nearest_sounding_to(midlat, midlon, now) do + {:ok, sounding} -> + station = if Ecto.assoc_loaded?(sounding.station), do: sounding.station + station = station || Repo.get(Station, sounding.station_id) + + distance_km = + if station, do: haversine_km(midlat, midlon, station.lat, station.lon) + + %{ + station_code: station && station.station_code, + station_name: station && station.name, + observed_at: sounding.observed_at, + ducting_detected: sounding.ducting_detected, + min_refractivity_gradient: sounding.min_refractivity_gradient, + boundary_layer_depth_m: sounding.boundary_layer_depth_m, + precipitable_water_mm: sounding.precipitable_water_mm, + distance_km: distance_km + } + + {:error, :not_found} -> + nil + end + end + + defp build_scoring([], _src, _dst, _now, _band_config, _native_duct), do: {nil, nil} + + defp build_scoring(profiles, src, dst, now, band_config, native_duct) do + temps = profiles |> Enum.map(& &1.surface_temp_c) |> Enum.reject(&is_nil/1) + dewpoints = profiles |> Enum.map(& &1.surface_dewpoint_c) |> Enum.reject(&is_nil/1) + + if temps == [] or dewpoints == [] do + {nil, nil} + else + avg_temp_c = Enum.sum(temps) / length(temps) + avg_dewpoint_c = Enum.sum(dewpoints) / length(dewpoints) + + pressures = profiles |> Enum.map(& &1.surface_pressure_mb) |> Enum.reject(&is_nil/1) + gradients = profiles |> Enum.map(& &1.min_refractivity_gradient) |> Enum.reject(&is_nil/1) + bl_depths = profiles |> Enum.map(& &1.hpbl_m) |> Enum.reject(&is_nil/1) + pwats = profiles |> Enum.map(& &1.pwat_mm) |> Enum.reject(&is_nil/1) + + conditions = %{ + abs_humidity: Scorer.absolute_humidity(avg_temp_c, avg_dewpoint_c), + temp_f: Scorer.c_to_f(avg_temp_c), + dewpoint_f: Scorer.c_to_f(avg_dewpoint_c), + temp_c: avg_temp_c, + dewpoint_c: avg_dewpoint_c, + wind_speed_kts: nil, + sky_cover_pct: nil, + utc_hour: now.hour, + utc_minute: now.minute, + month: now.month, + latitude: (src.lat + dst.lat) / 2, + longitude: src.lon, + pressure_mb: if(pressures != [], do: Enum.min(pressures)), + prev_pressure_mb: nil, + rain_rate_mmhr: 0.0, + min_refractivity_gradient: if(gradients != [], do: Enum.min(gradients)), + bl_depth_m: if(bl_depths != [], do: Enum.sum(bl_depths) / length(bl_depths)), + pwat_mm: if(pwats != [], do: Enum.sum(pwats) / length(pwats)), + best_duct_band_ghz: native_duct[:best_duct_band_ghz], + bulk_richardson: native_duct[:bulk_richardson] + } + + scoring = Scorer.composite_score(conditions, band_config) + {conditions, scoring} + end + end + + defp compute_loss_budget(dist_km, freq_ghz, band_config, terrain_result, conditions) do + freq_mhz = freq_ghz * 1000 + fspl = 20 * :math.log10(max(dist_km, 0.001)) + 20 * :math.log10(freq_mhz) + 32.44 + + o2_loss = band_config.o2_db_km * dist_km + h2o_coeff = band_config.h2o_coeff + + abs_humidity = + if conditions do + conditions.abs_humidity + else + 7.5 + end + + h2o_loss = h2o_coeff * abs_humidity * dist_km + + rain_loss = + if conditions && conditions.rain_rate_mmhr > 0 do + gamma = band_config.rain_k * :math.pow(conditions.rain_rate_mmhr, band_config.rain_alpha) + gamma * dist_km + else + 0.0 + end + + diffraction_loss = + if terrain_result do + terrain_result.analysis.diffraction_db * 1.0 + else + 0.0 + end + + total = fspl + o2_loss + h2o_loss + rain_loss + diffraction_loss + + %{ + fspl: Float.round(fspl, 1), + o2: Float.round(o2_loss, 2), + h2o: Float.round(h2o_loss, 2), + rain: Float.round(rain_loss, 2), + diffraction: Float.round(diffraction_loss, 1), + total: Float.round(total, 1) + } + 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 + + 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 + + # Avoid unused-alias warnings for the import. + @compile {:no_warn_undefined, Geo} +end diff --git a/lib/microwaveprop/rover_planning/station.ex b/lib/microwaveprop/rover_planning/station.ex index 9535710c..e49ef250 100644 --- a/lib/microwaveprop/rover_planning/station.ex +++ b/lib/microwaveprop/rover_planning/station.ex @@ -41,21 +41,45 @@ defmodule Microwaveprop.RoverPlanning.Station do end # When `input` looks like a callsign or grid, geocode it and populate - # the matching field. Skips resolution when the user already provided - # explicit lat/lon — they may want a free-form coordinate that doesn't - # round-trip through callsign lookup. + # the matching field. If the user TYPED a new input (changeset has a + # `:input` change), wipe any previously-resolved callsign / grid / + # lat / lon first — otherwise an early prefix that resolved + # successfully (e.g. "AA5" matched a callsign) would lock those + # fields and the user's later keystrokes would never re-resolve. defp resolve_input(changeset) do cond do not changeset.valid? -> changeset + input_changed?(changeset) -> + changeset + |> clear_resolved() + |> resolve_from_input() + get_field(changeset, :lat) && get_field(changeset, :lon) -> changeset - input = get_field(changeset, :input) -> + true -> + resolve_from_input(changeset) + end + end + + defp input_changed?(changeset), do: not is_nil(get_change(changeset, :input)) + + defp clear_resolved(changeset) do + changeset + |> put_change(:callsign, nil) + |> put_change(:grid, nil) + |> put_change(:lat, nil) + |> put_change(:lon, nil) + end + + defp resolve_from_input(changeset) do + case get_field(changeset, :input) do + input when is_binary(input) and input != "" -> apply_resolution(changeset, LocationResolver.resolve(String.trim(input))) - true -> + _ -> changeset end end diff --git a/lib/microwaveprop/workers/rover_path_profile_worker.ex b/lib/microwaveprop/workers/rover_path_profile_worker.ex index 06675a26..2e7f6d9b 100644 --- a/lib/microwaveprop/workers/rover_path_profile_worker.ex +++ b/lib/microwaveprop/workers/rover_path_profile_worker.ex @@ -1,15 +1,19 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do @moduledoc """ - Computes a terrain path profile for one rover-location → station - pairing inside a `RoverPlanning.Mission`. Mirrors the synchronous - `MicrowavepropWeb.PathLive` compute pipeline (elevation profile + - ITU-R P.526 analysis at the mission's band + heights) and stores - the result as JSON on the matching `RoverPlanning.Path`. + Pre-computes one rover-planning path so the live `/path` page can + render instantly when the operator clicks through. Calls the same + `Microwaveprop.Propagation.PathCompute.compute/4` pipeline `/path` + uses (terrain analysis, 9 HRRR profile samples, sounding + + ionosphere readouts, scoring, link + power budgets, forecast) and + stores the full output on the matching `RoverPlanning.Path`. - The HRRR-along-path step from `/path` is intentionally skipped here: - it depends on real-time profile grids that aren't worth pinning to a - stored mission result. The rendered show page can re-fetch live - weather on demand if needed. + The map is persisted in two parallel forms: + * Flat string-keyed fields (`distance_km`, `verdict`, + `propagation_score`, `total_baseline_loss_db`, `path_points`, + …) — what the rover-planning show table reads at-a-glance. + * `term` — the entire atom-keyed compute output, encoded with + `:erlang.term_to_binary/1` + Base64. PathLive decodes this when + handed `?rover_path_id=UUID` and renders without recomputing. """ use Oban.Worker, queue: :terrain, @@ -22,22 +26,13 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do import Ecto.Query - alias Microwaveprop.Geo - alias Microwaveprop.Propagation.BandConfig + alias Microwaveprop.Propagation.PathCompute alias Microwaveprop.Propagation.ScoresFile alias Microwaveprop.Repo alias Microwaveprop.RoverPlanning.Path - alias Microwaveprop.Terrain.ElevationClient - alias Microwaveprop.Terrain.TerrainAnalysis - # Default surface absolute humidity (g/m³) — ~7.5 is the - # PathLive fallback when no live HRRR sample is available. Used to - # produce a "baseline" cached H₂O loss; the live `/path` page - # recomputes this from current weather. require Logger - @default_abs_humidity_gm3 7.5 - @impl Oban.Worker def backoff(%Oban.Job{attempt: attempt}) do min(60 * Integer.pow(2, attempt - 1), 3600) @@ -73,9 +68,6 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do |> Repo.one() end - # Legacy / malformed args (no band_mhz, or wrong types). Don't crash - # the queue — log enough context to debug and drop the job. New - # enqueues from `enqueue_paths_for/1` always include band_mhz. defp load_path(args) do Logger.warning("RoverPathProfileWorker: dropping job with unexpected args (no integer band_mhz): #{inspect(args)}") @@ -86,38 +78,33 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do mark_status(path, :computing) band_mhz = path.band_mhz || mission.band_mhz - rover_height_m = (mission.rover_height_ft || 8.0) * 0.3048 - station_height_m = (mission.station_height_ft || 30.0) * 0.3048 - freq_ghz = band_mhz / 1000 - dist_km = Geo.haversine_km(rover.lat, rover.lon, station.lat, station.lon) - bearing = Geo.bearing_deg(rover.lat, rover.lon, station.lat, station.lon) + src_height_ft = mission.rover_height_ft || 8.0 + dst_height_ft = mission.station_height_ft || 30.0 - case ElevationClient.fetch_elevation_profile( - rover.lat, - rover.lon, - station.lat, - station.lon, - 64, - download: true - ) do - {:ok, profile} -> - analysis = - TerrainAnalysis.analyse(profile, dist_km, freq_ghz, - ant_ht_a: rover_height_m, - ant_ht_b: station_height_m - ) + 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, + # Defaults match PathLive's form fallback (10 W TX, 30 dBi gains + # at each end). Operators can override on the live `/path` page; + # the cached snapshot uses these so power-budget numbers are + # representative for a typical microwave home station. + tx_power_dbm: 20.0, + tx_power_mw: :math.pow(10, 20.0 / 10), + src_gain_dbi: 30.0, + dst_gain_dbi: 30.0 + } - midlat = (rover.lat + station.lat) / 2 - midlon = (rover.lon + station.lon) / 2 - propagation_score = lookup_propagation_score(band_mhz, midlat, midlon) + src = "#{rover.lat},#{rover.lon}" + dst = "#{station.lat},#{station.lon}" - loss_budget = - compute_loss_budget(dist_km, band_mhz, freq_ghz, analysis.diffraction_db) - - store_complete(path, profile, analysis, dist_km, bearing, propagation_score, loss_budget) + case PathCompute.compute(src, dst, band_mhz, station_params) do + {:ok, result} -> + store_complete(path, result, band_mhz, rover, station) {:error, reason} -> - store_failed(path, "elevation profile failed: #{inspect(reason)}") + store_failed(path, "compute failed: #{inspect(reason)}") end rescue e -> @@ -126,47 +113,15 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do reraise e, __STACKTRACE__ end - defp store_complete(path, _profile, analysis, dist_km, bearing, propagation_score, loss_budget) do - # Store the FULL per-point analysis (beam height, Fresnel-zone radius, - # clearance) so the elevation-profile chart on the cached path-detail - # page can render the same way `/path` does — line-of-sight + Fresnel - # overlay — without recomputing TerrainAnalysis on click. - points = - Enum.map(analysis.points, fn p -> - %{ - "lat" => p.lat, - "lon" => p.lon, - "d" => p.d, - "dist_km" => p.dist_km, - "elev" => p.elev, - "beam" => Map.get(p, :beam, 0.0), - "r1" => Map.get(p, :r1, 0.0), - "clearance" => Map.get(p, :clearance, 0.0) - } - end) + defp store_complete(path, result, band_mhz, rover, station) do + flat = flat_summary(result, band_mhz, rover, station) - result = %{ - "distance_km" => dist_km, - "bearing_deg" => bearing, - "max_elevation_m" => analysis.max_elevation_m, - "min_clearance_m" => analysis.min_clearance_m, - "diffraction_db" => analysis.diffraction_db, - "fresnel_hit_count" => analysis.fresnel_hit_count, - "obstructed_count" => analysis.obstructed_count, - "verdict" => to_string(analysis.verdict), - "sample_count" => length(points), - "propagation_score" => propagation_score, - "free_space_loss_db" => loss_budget.free_space_loss_db, - "oxygen_loss_db" => loss_budget.oxygen_loss_db, - "humidity_loss_db_baseline" => loss_budget.humidity_loss_db_baseline, - "total_baseline_loss_db" => loss_budget.total_baseline_loss_db, - "path_points" => points - } + full_term = result |> :erlang.term_to_binary() |> Base.encode64() path |> Path.changeset(%{ status: :complete, - result: result, + result: Map.put(flat, "term", full_term), error: nil, computed_at: DateTime.truncate(DateTime.utc_now(), :second) }) @@ -181,6 +136,76 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do end end + # The handful of fields the rover-planning show table renders inline + # (distance, verdict, propagation score, total baseline loss). Lifted + # out of the full compute output so the table can read them directly + # without round-tripping the term blob. + defp flat_summary(result, band_mhz, rover, station) do + analysis = result.terrain && result.terrain.analysis + midlat = (rover.lat + station.lat) / 2 + midlon = (rover.lon + station.lon) / 2 + + result + |> geometry_summary(analysis) + |> Map.merge(loss_summary(result.loss_budget)) + |> Map.put("propagation_score", lookup_propagation_score(band_mhz, midlat, midlon)) + |> Map.put("path_points", path_points(analysis)) + end + + defp geometry_summary(result, nil) do + %{ + "distance_km" => result.dist_km, + "bearing_deg" => result.bearing, + "max_elevation_m" => nil, + "min_clearance_m" => nil, + "diffraction_db" => 0.0, + "fresnel_hit_count" => 0, + "obstructed_count" => 0, + "verdict" => nil, + "sample_count" => nil + } + end + + defp geometry_summary(result, analysis) do + %{ + "distance_km" => result.dist_km, + "bearing_deg" => result.bearing, + "max_elevation_m" => analysis.max_elevation_m, + "min_clearance_m" => analysis.min_clearance_m, + "diffraction_db" => analysis.diffraction_db, + "fresnel_hit_count" => analysis.fresnel_hit_count, + "obstructed_count" => analysis.obstructed_count, + "verdict" => to_string(analysis.verdict), + "sample_count" => length(analysis.points) + } + end + + defp loss_summary(loss) do + %{ + "free_space_loss_db" => loss.fspl, + "oxygen_loss_db" => loss.o2, + "humidity_loss_db_baseline" => loss.h2o, + "total_baseline_loss_db" => loss.total + } + end + + defp path_points(nil), do: [] + + defp path_points(analysis) do + Enum.map(analysis.points, fn p -> + %{ + "lat" => p.lat, + "lon" => p.lon, + "d" => p.d, + "dist_km" => p.dist_km, + "elev" => p.elev, + "beam" => Map.get(p, :beam, 0.0), + "r1" => Map.get(p, :r1, 0.0), + "clearance" => Map.get(p, :clearance, 0.0) + } + end) + end + defp store_failed(path, message) do path |> Path.changeset(%{ @@ -209,35 +234,6 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do end end - # Cached baseline link-loss components for a path. Mirrors the - # `compute_loss_budget` math in PathLive but without live HRRR - # weather: humidity defaults to @default_abs_humidity_gm3 and rain is - # treated as zero. The `/path` page recomputes these against current - # conditions; this snapshot is what the rover-planning show page - # renders without needing HRRR access. - defp compute_loss_budget(dist_km, band_mhz, freq_ghz, diffraction_db) do - band_config = BandConfig.get(band_mhz) || BandConfig.get(10_000) - freq_mhz = freq_ghz * 1000 - - fspl = 20 * :math.log10(max(dist_km, 0.001)) + 20 * :math.log10(freq_mhz) + 32.44 - o2_loss = (band_config.o2_db_km || 0.0) * dist_km - h2o_loss = (band_config.h2o_coeff || 0.0) * @default_abs_humidity_gm3 * dist_km - diffraction = diffraction_db * 1.0 - total = fspl + o2_loss + h2o_loss + diffraction - - %{ - free_space_loss_db: Float.round(fspl, 1), - oxygen_loss_db: Float.round(o2_loss, 2), - humidity_loss_db_baseline: Float.round(h2o_loss, 2), - total_baseline_loss_db: Float.round(total, 1) - } - end - - # Look up the propagation grid score (0-100) for the midpoint of a - # path at the most recent forecast time available on disk. Returns - # nil when no scores file exists for this band yet — callers (the - # show page, primarily) render that as "—" without distinguishing - # "no data" from "score really is 0". defp lookup_propagation_score(band_mhz, lat, lon) do case ScoresFile.list_valid_times(band_mhz) do [] -> diff --git a/lib/microwaveprop_web/live/path_live.ex b/lib/microwaveprop_web/live/path_live.ex index 807d9296..eb3628e1 100644 --- a/lib/microwaveprop_web/live/path_live.ex +++ b/lib/microwaveprop_web/live/path_live.ex @@ -8,17 +8,12 @@ defmodule MicrowavepropWeb.PathLive do alias Microwaveprop.Buildings.Index, as: BuildingsIndex alias Microwaveprop.Buildings.Loader, as: BuildingsLoader alias Microwaveprop.Canopy - alias Microwaveprop.Ionosphere alias Microwaveprop.Propagation alias Microwaveprop.Propagation.BandConfig - alias Microwaveprop.Propagation.ProfilesFile - alias Microwaveprop.Propagation.Scorer - alias Microwaveprop.Propagation.SporadicE + alias Microwaveprop.Propagation.PathCompute + alias Microwaveprop.Radio.Maidenhead alias Microwaveprop.Repo - alias Microwaveprop.Terrain.ElevationClient - alias Microwaveprop.Terrain.TerrainAnalysis - alias Microwaveprop.Weather - alias Microwaveprop.Weather.Station + alias Microwaveprop.RoverPlanning.Path, as: RoverPath require Logger @@ -63,6 +58,47 @@ defmodule MicrowavepropWeb.PathLive do end @impl true + def handle_params(%{"rover_path_id" => rover_path_id} = _params, _uri, socket) + when is_binary(rover_path_id) and rover_path_id != "" do + case load_cached_path(rover_path_id) do + {:ok, path, result} -> + params = params_from_cached(path, result) + + 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"], + source_is_gps: false, + result: result, + computing: false, + error: nil + ) + + {:noreply, socket} + + {:error, :not_found} -> + {:noreply, + socket + |> put_flash(:error, "Cached path profile not found.") + |> push_navigate(to: ~p"/path")} + + {:error, :stale_term} -> + # Cached blob can't be deserialized (e.g. old enrichment from + # before this code shipped). Fall through to live recompute + # using the path's stored source/dest/band. + case load_cached_path_for_redirect(rover_path_id) do + {:ok, params} -> {:noreply, push_patch(socket, to: ~p"/path?#{params}", replace: true)} + {:error, _} -> {:noreply, push_navigate(socket, to: ~p"/path")} + end + end + end + def handle_params(params, _uri, socket) do p = Map.merge(@defaults, Map.take(params, @url_params)) @@ -94,6 +130,82 @@ defmodule MicrowavepropWeb.PathLive do end end + # Loads a cached rover-planning Path and rebuilds the @result map + # PathLive's render expects. The blob lives under `result["term"]` + # (Base64-encoded `:erlang.term_to_binary/1` of PathCompute's full + # output). `:safe` mode rejects unknown atoms — every atom we + # roundtrip is one this codebase already references at compile time, + # so it's known. + defp load_cached_path(rover_path_id) do + with {:ok, uuid} <- Ecto.UUID.cast(rover_path_id), + %RoverPath{result: %{"term" => term}} <- Repo.get(RoverPath, uuid), + {:ok, decoded} <- decode_term(term) do + {:ok, nil, decoded} + else + %RoverPath{} -> {:error, :stale_term} + nil -> {:error, :not_found} + :error -> {:error, :not_found} + {:error, _} = err -> err + end + end + + # Fall-back: when the cached term is missing/stale, redirect to + # /path with the source/dest/band fields filled in from the Path's + # rover_location + station so the user gets a live recompute. + defp load_cached_path_for_redirect(rover_path_id) do + with {:ok, uuid} <- Ecto.UUID.cast(rover_path_id), + %RoverPath{} = path <- + RoverPath |> Repo.get(uuid) |> Repo.preload([:rover_location, :station, :mission]) do + {:ok, + %{ + "source" => Maidenhead.from_latlon(path.rover_location.lat, path.rover_location.lon, 10), + "destination" => + path.station.callsign || path.station.grid || + "#{path.station.lat},#{path.station.lon}", + "band" => Integer.to_string(path.band_mhz || path.mission.band_mhz), + "src_height_ft" => Float.to_string((path.mission.rover_height_ft || 8.0) * 1.0), + "dst_height_ft" => Float.to_string((path.mission.station_height_ft || 30.0) * 1.0) + }} + else + _ -> {:error, :not_found} + end + end + + defp decode_term(term) when is_binary(term) do + with {:ok, binary} <- Base.decode64(term), + decoded when is_map(decoded) <- safe_binary_to_term(binary) do + {:ok, decoded} + else + _ -> {:error, :stale_term} + end + rescue + _ -> {:error, :stale_term} + end + + defp safe_binary_to_term(binary) do + :erlang.binary_to_term(binary, [:safe]) + rescue + _ -> nil + end + + defp params_from_cached(_path, result) do + sp = result.station_params || %{} + + %{ + "source" => label_for(result.source), + "destination" => label_for(result.destination), + "band" => Integer.to_string(result.band_mhz), + "src_height_ft" => Float.to_string((Map.get(sp, :src_height_ft) || 30.0) * 1.0), + "dst_height_ft" => Float.to_string((Map.get(sp, :dst_height_ft) || 30.0) * 1.0), + "tx_power_dbm" => Float.to_string((Map.get(sp, :tx_power_dbm) || 20.0) * 1.0), + "src_gain_dbi" => Float.to_string((Map.get(sp, :src_gain_dbi) || 30.0) * 1.0), + "dst_gain_dbi" => Float.to_string((Map.get(sp, :dst_gain_dbi) || 30.0) * 1.0) + } + end + + defp label_for(%{label: label}) when is_binary(label), do: label + defp label_for(_), do: "" + defp auto_calculate(socket, p) do if p["source"] != "" and p["source"] != "gps" and p["destination"] != "" and not socket.assigns.computing and is_nil(socket.assigns.result) do @@ -263,420 +375,9 @@ defmodule MicrowavepropWeb.PathLive do end 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) - freq_ghz = band_mhz / 1000 - - 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 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, - ant_ht_a: station_params.src_height_m, - ant_ht_b: station_params.dst_height_m - ) - - %{profile: profile, analysis: analysis} - - {:error, reason} -> - Logger.warning( - "PathLive terrain profile load failed: src=#{src.lat},#{src.lon} dst=#{dst.lat},#{dst.lon} reason=#{inspect(reason)}" - ) - - nil - end - - # HRRR profiles along path — 9 evenly-spaced samples so mid-path - # ducts that endpoint-only queries miss show up in the duct count. - # Source preference: the on-disk grid profile store (full-CONUS - # coverage from the hourly chain) is checked first; the per-QSO - # `hrrr_profiles` DB table is only the fallback for points the - # current chain hasn't published yet. - now = DateTime.utc_now() - midlat = (src.lat + dst.lat) / 2 - midlon = (src.lon + dst.lon) / 2 - - profile_valid_time = latest_profile_valid_time(now) - # Load the full profile grid ONCE (single NFS read + gunzip + decode) - # instead of letting 9 async_stream workers each re-decode the same - # ~30 MB file. Each per-point lookup becomes a Map.get. - profile_grid = profile_grid_for(profile_valid_time) - - sample_points = path_sample_points(src, dst, 9) - - # Fast path: in-memory Map.get against the single decoded grid. - # Only points that miss the grid (e.g. just-rolled-over chain) take - # the slow DB-fallback path concurrently below. - {grid_hits, misses} = partition_grid_hits(sample_points, profile_grid, profile_valid_time) - - fallback_hits = - misses - |> Enum.reverse() - |> Task.async_stream(&fallback_hrrr_point(&1, now), max_concurrency: 4, timeout: 5_000, on_timeout: :kill_task) - |> Enum.zip(Enum.reverse(misses)) - |> Enum.flat_map(fn - {{:ok, nil}, _} -> - [] - - {{:ok, point}, _} -> - [point] - - {{:exit, reason}, {label, lat, lon}} -> - Logger.error( - "PathLive HRRR fallback lookup failed: label=#{inspect(label)} lat=#{lat} lon=#{lon} reason=#{inspect(reason)}" - ) - - [] - end) - - hrrr_points = Enum.reverse(grid_hits, fallback_hits) - - hrrr_profiles = Enum.map(hrrr_points, & &1.profile) - - # Independent duct signal: the nearest RAOB within 300 km / ±3 h of - # the midpoint. HRRR pressure levels systematically under-read thin - # surface ducts that sounding data resolves. - sounding = build_sounding_readout(midlat, midlon, now) - - # Native-resolution HRRR duct band + Bulk Richardson near the - # midpoint. Resolves thin trapping layers HRRR's 13 pressure levels - # miss; feeds the 1.15× boost in Scorer.score_refractivity, gated on - # Richardson so turbulent duct readings don't inflate the score. - # - # `:not_found` is the documented fallback when no native sample is - # within ±0.07° / ±1 h of the midpoint — the native profile table - # is sparsely populated (only points the per-QSO worker has - # touched). The empty-map fallback is correct behavior, not a - # failure, so we don't log it. - native_duct = - case Weather.nearest_native_duct_info(midlat, midlon, now) do - {:ok, info} -> info - {:error, :not_found} -> %{best_duct_band_ghz: nil, bulk_richardson: nil} - end - - # Build conditions and score - {conditions, scoring} = - build_scoring(hrrr_profiles, src, dst, now, band_config, native_duct) - - # 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) - - # 18-hour forecast from propagation grid (midpoint of path) - forecast = Propagation.point_forecast(band_mhz, midlat, midlon) - - # Ionosphere readout at the midpoint (sporadic-E potential). - # Only relevant for VHF where Es propagation is physically possible. - ionosphere = build_ionosphere_readout(band_mhz, midlat, midlon, dist_km) - - {:ok, - %{ - source: src, - destination: dst, - station_params: station_params, - band_config: band_config, - band_mhz: band_mhz, - freq_ghz: freq_ghz, - dist_km: dist_km, - bearing: bearing, - terrain: terrain_result, - conditions: conditions, - scoring: scoring, - loss_budget: loss_budget, - power_budget: power_budget, - forecast: forecast, - hrrr_count: length(hrrr_profiles), - hrrr_points: hrrr_points, - ionosphere: ionosphere, - sounding: sounding - }} - end + PathCompute.compute(source, dest, band_mhz, station_params) end - # Returns nil when there's no usable readout, or a map describing the - # nearest ionosonde's current foEs + the computed Es score for - # (band, distance). Only VHF bands get the readout. - defp build_ionosphere_readout(band_mhz, midlat, midlon, dist_km) when band_mhz in [50, 144, 222, 432] do - case Ionosphere.nearest_foes(midlat, midlon) do - {:ok, obs} -> - es_score = SporadicE.es_score(obs.fo_es_mhz, band_mhz, dist_km) - muf = SporadicE.single_hop_muf(obs.fo_es_mhz, dist_km) - - %{ - station_code: obs.station_code, - valid_time: obs.valid_time, - fo_es_mhz: obs.fo_es_mhz, - fo_f2_mhz: obs.fo_f2_mhz, - mufd_mhz: obs.mufd_mhz, - es_score: es_score, - es_muf_mhz: muf, - es_in_range?: dist_km >= 500 and dist_km <= 2500 - } - - {:error, _reason} -> - nil - end - end - - defp build_ionosphere_readout(_band_mhz, _lat, _lon, _dist), do: nil - - defp resolve_location(input) do - case MicrowavepropWeb.LocationResolver.resolve(input) do - :empty -> {:error, "Location is required"} - other -> other - end - end - - # The grid is the single decoded output of ProfilesFile.read/1 — keys are - # `{snapped_lat, snapped_lon}` tuples produced by ProfilesFile.snap/2. - # We mirror the same snapping locally instead of round-tripping through - # ProfilesFile.read_point/3 (which would re-fetch the cached grid). - defp profile_grid_for(nil), do: nil - - defp profile_grid_for(valid_time) do - case ProfilesFile.read(valid_time) do - {:ok, grid} -> - grid - - {:error, reason} -> - Logger.warning("PathLive ProfilesFile.read failed: valid_time=#{inspect(valid_time)} reason=#{inspect(reason)}") - - nil - - other -> - Logger.warning("PathLive ProfilesFile.read unexpected: valid_time=#{inspect(valid_time)} got=#{inspect(other)}") - - nil - end - end - - defp lookup_profile_grid(grid, lat, lon) do - {snapped_lat, snapped_lon} = ProfilesFile.snap(lat, lon) - Map.get(grid, {snapped_lat, snapped_lon}) - end - - defp partition_grid_hits(sample_points, profile_grid, profile_valid_time) do - Enum.reduce(sample_points, {[], []}, fn pt, acc -> - classify_grid_hit(pt, profile_grid, profile_valid_time, acc) - end) - end - - defp classify_grid_hit(pt, nil, _vt, {hits, mss}), do: {hits, [pt | mss]} - - defp classify_grid_hit({label, lat, lon} = pt, grid, vt, {hits, mss}) do - case lookup_profile_grid(grid, lat, lon) do - nil -> {hits, [pt | mss]} - cell -> {[%{label: label, profile: profile_from_cell(cell, lat, lon, vt)} | hits], mss} - end - end - - defp fallback_hrrr_point({label, lat, lon}, now) do - case Weather.find_nearest_hrrr(lat, lon, now) do - nil -> nil - profile -> %{label: label, profile: profile} - end - end - - # The most recent on-disk profile file at or before `now`. Falls back - # to the earliest available file if everything cached is in the future - # (cold-start state during a missed chain). Returns `nil` when the - # store is empty — caller then drops to the DB-table fallback. - defp latest_profile_valid_time(now) do - case ProfilesFile.list_valid_times() do - [] -> nil - times -> pick_latest_at_or_before(times, now) - end - end - - defp pick_latest_at_or_before(times, now) do - case Enum.filter(times, fn t -> DateTime.compare(t, now) != :gt end) do - [] -> List.first(times) - past -> Enum.max(past, DateTime) - end - end - - # ProfilesFile cells are already flat HrrrProfile-shaped maps — the - # `:profile` key holds the vertical pressure-level list, NOT a wrapper - # around the surface fields. Stamp `lat`/`lon` (from the sample point, - # since cells don't carry their own coordinates — they're the map key) - # and `valid_time` so downstream template + scoring code is shape-agnostic. - defp profile_from_cell(cell, lat, lon, valid_time) do - cell - |> Map.put_new(:lat, lat) - |> Map.put_new(:lon, lon) - |> Map.put_new(:valid_time, valid_time) - |> Map.put_new(:min_refractivity_gradient, Map.get(cell, :native_min_gradient)) - |> Map.put_new(:surface_refractivity, nil) - |> Map.put_new(:ducting_detected, Map.get(cell, :duct_count, 0) > 0) - |> Map.put_new(:duct_characteristics, nil) - end - - # Linear interpolation along the great-circle-approximate path. Good - # enough at the path-calculator's typical range (<2000 km); for longer - # paths the path deviates from a rhumb line but we're picking - # HRRR grid cells, not doing precise bearing. - defp path_sample_points(src, dst, count) when count >= 2 do - Enum.map(1..count, fn i -> - t = (i - 1) / (count - 1) - lat = src.lat + (dst.lat - src.lat) * t - lon = src.lon + (dst.lon - src.lon) * t - - label = - cond do - i == 1 -> "Source" - i == count -> "Destination" - i * 2 == count + 1 -> "Midpoint" - true -> "#{round(t * 100)}%" - end - - {label, lat, lon} - end) - end - - defp build_sounding_readout(midlat, midlon, now) do - case Weather.nearest_sounding_to(midlat, midlon, now) do - {:ok, sounding} -> - station = if Ecto.assoc_loaded?(sounding.station), do: sounding.station - station = station || Repo.get(Station, sounding.station_id) - - distance_km = - if station, do: haversine_km(midlat, midlon, station.lat, station.lon) - - %{ - station_code: station && station.station_code, - station_name: station && station.name, - observed_at: sounding.observed_at, - ducting_detected: sounding.ducting_detected, - min_refractivity_gradient: sounding.min_refractivity_gradient, - boundary_layer_depth_m: sounding.boundary_layer_depth_m, - precipitable_water_mm: sounding.precipitable_water_mm, - distance_km: distance_km - } - - {:error, :not_found} -> - nil - end - end - - defp build_scoring([], _src, _dst, _now, _band_config, _native_duct), do: {nil, nil} - - defp build_scoring(profiles, src, dst, now, band_config, native_duct) do - temps = profiles |> Enum.map(& &1.surface_temp_c) |> Enum.reject(&is_nil/1) - dewpoints = profiles |> Enum.map(& &1.surface_dewpoint_c) |> Enum.reject(&is_nil/1) - - if temps == [] or dewpoints == [] do - {nil, nil} - else - avg_temp_c = Enum.sum(temps) / length(temps) - avg_dewpoint_c = Enum.sum(dewpoints) / length(dewpoints) - - pressures = profiles |> Enum.map(& &1.surface_pressure_mb) |> Enum.reject(&is_nil/1) - gradients = profiles |> Enum.map(& &1.min_refractivity_gradient) |> Enum.reject(&is_nil/1) - bl_depths = profiles |> Enum.map(& &1.hpbl_m) |> Enum.reject(&is_nil/1) - pwats = profiles |> Enum.map(& &1.pwat_mm) |> Enum.reject(&is_nil/1) - - conditions = %{ - abs_humidity: Scorer.absolute_humidity(avg_temp_c, avg_dewpoint_c), - temp_f: Scorer.c_to_f(avg_temp_c), - dewpoint_f: Scorer.c_to_f(avg_dewpoint_c), - temp_c: avg_temp_c, - dewpoint_c: avg_dewpoint_c, - wind_speed_kts: nil, - sky_cover_pct: nil, - utc_hour: now.hour, - utc_minute: now.minute, - month: now.month, - latitude: (src.lat + dst.lat) / 2, - longitude: src.lon, - pressure_mb: if(pressures != [], do: Enum.min(pressures)), - prev_pressure_mb: nil, - rain_rate_mmhr: 0.0, - min_refractivity_gradient: if(gradients != [], do: Enum.min(gradients)), - bl_depth_m: if(bl_depths != [], do: Enum.sum(bl_depths) / length(bl_depths)), - pwat_mm: if(pwats != [], do: Enum.sum(pwats) / length(pwats)), - best_duct_band_ghz: native_duct[:best_duct_band_ghz], - bulk_richardson: native_duct[:bulk_richardson] - } - - scoring = Scorer.composite_score(conditions, band_config) - {conditions, scoring} - end - end - - defp compute_loss_budget(dist_km, freq_ghz, band_config, terrain_result, conditions) do - freq_mhz = freq_ghz * 1000 - fspl = 20 * :math.log10(max(dist_km, 0.001)) + 20 * :math.log10(freq_mhz) + 32.44 - - o2_loss = band_config.o2_db_km * dist_km - h2o_coeff = band_config.h2o_coeff - - abs_humidity = - if conditions do - conditions.abs_humidity - else - 7.5 - end - - h2o_loss = h2o_coeff * abs_humidity * dist_km - - rain_loss = - if conditions && conditions.rain_rate_mmhr > 0 do - gamma = band_config.rain_k * :math.pow(conditions.rain_rate_mmhr, band_config.rain_alpha) - gamma * dist_km - else - 0.0 - end - - diffraction_loss = - if terrain_result do - # TerrainAnalysis.deygout_diffraction returns integer 0 for clear - # paths; coerce to float so Float.round/2 below is happy. - terrain_result.analysis.diffraction_db * 1.0 - else - 0.0 - end - - total = fspl + o2_loss + h2o_loss + rain_loss + diffraction_loss - - %{ - fspl: Float.round(fspl, 1), - o2: Float.round(o2_loss, 2), - h2o: Float.round(h2o_loss, 2), - rain: Float.round(rain_loss, 2), - diffraction: Float.round(diffraction_loss, 1), - total: Float.round(total, 1) - } - 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 - - defdelegate haversine_km(lat1, lon1, lat2, lon2), to: Microwaveprop.Geo - defdelegate bearing_deg(lat1, lon1, lat2, lon2), to: Microwaveprop.Geo - # ── Score tier helpers ── defp tier_label(score) when score >= 80, do: "EXCELLENT" diff --git a/lib/microwaveprop_web/live/rover_planning_live/form.ex b/lib/microwaveprop_web/live/rover_planning_live/form.ex index 4b75d72c..5fec8c2b 100644 --- a/lib/microwaveprop_web/live/rover_planning_live/form.ex +++ b/lib/microwaveprop_web/live/rover_planning_live/form.ex @@ -334,6 +334,7 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Form do type="text" label="Callsign / grid / lat,lon" placeholder="W5LUA or EM13qc or 32.91, -97.06" + phx-debounce="600" />