From ca07c0288df445c4d3e962ccbe95de4a92d5f8f5 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 18 Apr 2026 15:48:05 -0500 Subject: [PATCH] feat(contacts): antenna height fields feed terrain analysis Add optional height1_ft/height2_ft to contact schema, submit form, and edit form (direct + suggest-edit paths). Heights flow through to the elevation-profile terrain analysis so clearance and diffraction are computed from the actual antenna height instead of a 10-ft default. Editing heights resets terrain_status and re-enqueues TerrainProfileWorker so the stored path analysis picks up the new geometry. Also fix mark_likely_duct/3: the pattern was grabbing the element instead of the index, so no duct ever got flagged as the likely propagation path even when extract_ducts returned a valid layer. --- lib/microwaveprop/radio.ex | 64 ++++++++++++++- lib/microwaveprop/radio/contact.ex | 18 ++++- lib/microwaveprop/radio/contact_edit.ex | 15 +++- .../live/contact_live/show.ex | 63 ++++++++++++--- lib/microwaveprop_web/live/submit_live.ex | 19 ++++- ...260418204008_add_height_ft_to_contacts.exs | 10 +++ .../microwaveprop/radio/contact_edit_test.exs | 77 +++++++++++++++++++ test/microwaveprop/radio/contact_test.exs | 30 ++++++++ 8 files changed, 277 insertions(+), 19 deletions(-) create mode 100644 priv/repo/migrations/20260418204008_add_height_ft_to_contacts.exs diff --git a/lib/microwaveprop/radio.ex b/lib/microwaveprop/radio.ex index d5cacd1b..7e21833d 100644 --- a/lib/microwaveprop/radio.ex +++ b/lib/microwaveprop/radio.ex @@ -10,6 +10,7 @@ defmodule Microwaveprop.Radio do alias Microwaveprop.Radio.ContactEdit alias Microwaveprop.Radio.Maidenhead alias Microwaveprop.Repo + alias Microwaveprop.Workers.ContactWeatherEnqueueWorker @per_page 20 @sortable_fields ~w(station1 station2 band mode distance_km qso_timestamp inserted_at)a @@ -772,6 +773,8 @@ defmodule Microwaveprop.Radio do |> normalize_string_field("grid1") |> normalize_string_field("grid2") |> normalize_string_field("mode") + |> normalize_integer_field("height1_ft") + |> normalize_integer_field("height2_ft") end defp normalize_string_field(map, key) do @@ -782,6 +785,33 @@ defmodule Microwaveprop.Radio do end end + # Forms deliver integer-shaped inputs as strings. Drop the field entirely + # on empty strings so the diff doesn't register "" != current_value. + defp normalize_integer_field(map, key) do + case Map.get(map, key, :not_provided) do + :not_provided -> + map + + nil -> + map + + val when is_integer(val) -> + map + + "" -> + Map.delete(map, key) + + val when is_binary(val) -> + case Integer.parse(String.trim(val)) do + {int, ""} -> Map.put(map, key, int) + _ -> map + end + + _ -> + map + end + end + @doc false @spec diff_against_contact(Contact.t(), map()) :: map() def diff_against_contact(contact, proposed) do @@ -807,6 +837,8 @@ defmodule Microwaveprop.Radio do end defp current_value(contact, "qso_timestamp"), do: contact.qso_timestamp + defp current_value(contact, "height1_ft"), do: contact.height1_ft + defp current_value(contact, "height2_ft"), do: contact.height2_ft defp current_value(_contact, _key), do: nil defp values_equal?(a, b) when is_binary(a) and is_binary(b) do @@ -951,10 +983,36 @@ defmodule Microwaveprop.Radio do def apply_edit_to_contact(contact, proposed_changes) do changes = build_contact_changes(contact, proposed_changes) changes = maybe_recompute_positions(changes, contact, proposed_changes) + changes = maybe_reset_terrain_for_heights(changes, proposed_changes) - contact - |> Ecto.Changeset.change(changes) - |> Repo.update!() + updated = + contact + |> Ecto.Changeset.change(changes) + |> Repo.update!() + + maybe_enqueue_terrain_for_heights(updated, proposed_changes) + + updated + end + + defp maybe_enqueue_terrain_for_heights(contact, proposed) do + if Map.has_key?(proposed, "height1_ft") or Map.has_key?(proposed, "height2_ft") do + ContactWeatherEnqueueWorker.enqueue_for_contact(contact, [:terrain]) + end + + :ok + end + + # Antenna heights feed directly into the elevation-profile terrain + # analysis (diffraction loss, clearance verdict). Changing them + # invalidates the stored terrain_profile, so reset the status to + # :pending and let TerrainProfileWorker re-run. + defp maybe_reset_terrain_for_heights(changes, proposed) do + if Map.has_key?(proposed, "height1_ft") or Map.has_key?(proposed, "height2_ft") do + Map.put(changes, :terrain_status, :pending) + else + changes + end end defp maybe_recompute_positions(changes, contact, proposed) do diff --git a/lib/microwaveprop/radio/contact.ex b/lib/microwaveprop/radio/contact.ex index 78138689..c2a83011 100644 --- a/lib/microwaveprop/radio/contact.ex +++ b/lib/microwaveprop/radio/contact.ex @@ -60,6 +60,12 @@ defmodule Microwaveprop.Radio.Contact do field :submitter_email, :string field :flagged_invalid, :boolean, default: false + # Antenna height above ground level (feet). Optional — when set, the + # elevation profile and terrain analysis use the actual heights + # instead of a default 10 ft. + field :height1_ft, :integer + field :height2_ft, :integer + belongs_to :user, User timestamps(type: :utc_datetime) @@ -68,7 +74,7 @@ defmodule Microwaveprop.Radio.Contact do @type t :: %__MODULE__{} @required_fields ~w(station1 station2 qso_timestamp band)a - @optional_fields ~w(grid1 grid2 pos1 pos2 distance_km user_id mode user_declared_prop_mode)a + @optional_fields ~w(grid1 grid2 pos1 pos2 distance_km user_id mode user_declared_prop_mode height1_ft height2_ft)a @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(contact, attrs) do @@ -77,7 +83,7 @@ defmodule Microwaveprop.Radio.Contact do |> validate_required(@required_fields) end - @submission_fields ~w(station1 station2 qso_timestamp mode band grid1 grid2 submitter_email user_declared_prop_mode)a + @submission_fields ~w(station1 station2 qso_timestamp mode band grid1 grid2 submitter_email user_declared_prop_mode height1_ft height2_ft)a @submission_required ~w(station1 station2 qso_timestamp band grid1 grid2)a @allowed_modes ~w(CW SSB FM FT8 FT4 Q65) # Keep in sync with Microwaveprop.Propagation.BandConfig.all_bands/0 and @@ -108,6 +114,14 @@ defmodule Microwaveprop.Radio.Contact do |> validate_length(:submitter_email, max: 254) |> validate_length(:station1, max: 20) |> validate_length(:station2, max: 20) + |> validate_height(:height1_ft) + |> validate_height(:height2_ft) + end + + # Sanity bounds on AGL antenna height. Negative heights and anything + # taller than ~1000 ft are almost certainly data-entry errors. + defp validate_height(changeset, field) do + validate_number(changeset, field, greater_than_or_equal_to: 0, less_than_or_equal_to: 1000) end # Treat empty / whitespace-only mode strings as "not provided" so the column diff --git a/lib/microwaveprop/radio/contact_edit.ex b/lib/microwaveprop/radio/contact_edit.ex index 520f5bed..55f58b49 100644 --- a/lib/microwaveprop/radio/contact_edit.ex +++ b/lib/microwaveprop/radio/contact_edit.ex @@ -29,7 +29,7 @@ defmodule Microwaveprop.Radio.ContactEdit do @type t :: %__MODULE__{} - @editable_fields ~w(station1 station2 grid1 grid2 band mode qso_timestamp) + @editable_fields ~w(station1 station2 grid1 grid2 band mode qso_timestamp height1_ft height2_ft) @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(edit, attrs) do @@ -118,8 +118,21 @@ defmodule Microwaveprop.Radio.ContactEdit do end end + defp validate_field_value("height1_ft", cs), do: validate_height_value(cs, "height1_ft") + defp validate_field_value("height2_ft", cs), do: validate_height_value(cs, "height2_ft") + defp validate_field_value(_, changeset), do: changeset + defp validate_height_value(changeset, field) do + val = get_field(changeset, :proposed_changes)[field] + + if is_integer(val) and val >= 0 and val <= 1000 do + changeset + else + add_error(changeset, :proposed_changes, "#{field} must be an integer between 0 and 1000") + end + end + defp validate_callsign_value(changeset, field) do val = get_field(changeset, :proposed_changes)[field] || "" val = val |> String.trim() |> String.upcase() diff --git a/lib/microwaveprop_web/live/contact_live/show.ex b/lib/microwaveprop_web/live/contact_live/show.ex index f085883c..9adcbd5c 100644 --- a/lib/microwaveprop_web/live/contact_live/show.ex +++ b/lib/microwaveprop_web/live/contact_live/show.ex @@ -262,7 +262,9 @@ defmodule MicrowavepropWeb.ContactLive.Show do "band" => if(contact.band, do: Decimal.to_string(contact.band), else: ""), "mode" => contact.mode, "qso_timestamp" => - if(contact.qso_timestamp, do: Calendar.strftime(contact.qso_timestamp, "%Y-%m-%d %H:%M"), else: "") + if(contact.qso_timestamp, do: Calendar.strftime(contact.qso_timestamp, "%Y-%m-%d %H:%M"), else: ""), + "height1_ft" => contact.height1_ft, + "height2_ft" => contact.height2_ft } {:noreply, assign(socket, editing: true, edit_form: to_form(form_data, as: "edit"))} @@ -836,7 +838,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do

{if direct_edit, - do: "Change any fields below. Changes will be applied immediately.", + do: "Change any fields below. Changes will be submitted for review.", else: "Change any fields below. Only fields you modify will be submitted for review."}

<.form @@ -846,11 +848,27 @@ defmodule MicrowavepropWeb.ContactLive.Show do phx-submit="submit_edit" class="space-y-4" > -
+
<.input field={@edit_form[:station1]} type="text" label="Station 1" /> <.input field={@edit_form[:grid1]} type="text" label="Grid 1" /> + <.input + field={@edit_form[:height1_ft]} + type="number" + label="Height 1 (ft AGL)" + min="0" + max="1000" + placeholder="Optional" + /> <.input field={@edit_form[:station2]} type="text" label="Station 2" /> <.input field={@edit_form[:grid2]} type="text" label="Grid 2" /> + <.input + field={@edit_form[:height2_ft]} + type="number" + label="Height 2 (ft AGL)" + min="0" + max="1000" + placeholder="Optional" + />
<.input field={@edit_form[:band]} type="select" label="Band" options={@band_options} /> @@ -1647,16 +1665,18 @@ defmodule MicrowavepropWeb.ContactLive.Show do {:ok, profile} <- safe_fetch_elevation(lat1, lon1, lat2, lon2) do freq_ghz = band_to_ghz(contact.band) dist_km = haversine_km(lat1, lon1, lat2, lon2) - # Assume both stations are at 10 ft (3.048 m) AGL so the path - # analysis doesn't pretend the antennas are sitting on the dirt. - ant_ht_m = 3.048 - analysis = TerrainAnalysis.analyse(profile, dist_km, freq_ghz, ant_ht_a: ant_ht_m, ant_ht_b: ant_ht_m) + # Default to 10 ft (3.048 m) AGL when the operator didn't record an + # antenna height — better than pretending the antenna is on the dirt. + default_ht_m = 3.048 + ant_ht_a_m = ft_to_m(contact.height1_ft) || default_ht_m + ant_ht_b_m = ft_to_m(contact.height2_ft) || default_ht_m + analysis = TerrainAnalysis.analyse(profile, dist_km, freq_ghz, ant_ht_a: ant_ht_a_m, ant_ht_b: ant_ht_b_m) fwd_az = initial_bearing(lat1, lon1, lat2, lon2) rev_az = initial_bearing(lat2, lon2, lat1, lon1) - tx_elev = hd(profile).elev + ant_ht_m - rx_elev = List.last(profile).elev + ant_ht_m + tx_elev = hd(profile).elev + ant_ht_a_m + rx_elev = List.last(profile).elev + ant_ht_b_m fwd_el = elevation_angle(tx_elev, rx_elev, dist_km * 1000) rev_el = elevation_angle(rx_elev, tx_elev, dist_km * 1000) @@ -1804,7 +1824,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do defp mark_likely_duct(ducts, tx_elev, rx_elev) do avg_elev = (tx_elev + rx_elev) / 2 - {likely_idx, _} = + {_, likely_idx} = ducts |> Enum.with_index() |> Enum.min_by(fn {d, _idx} -> @@ -1838,6 +1858,9 @@ defmodule MicrowavepropWeb.ContactLive.Show do |> Kernel./(1000) end + defp ft_to_m(nil), do: nil + defp ft_to_m(ft) when is_integer(ft), do: ft * 0.3048 + defp haversine_km(lat1, lon1, lat2, lon2) do dlat = deg_to_rad(lat2 - lat1) dlon = deg_to_rad(lon2 - lon1) @@ -1937,7 +1960,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do summary = build_summary(terrain, elevation_profile, hrrr, soundings, dist_km, band_mhz, contact) - details = build_details(hrrr, hrrr_path, soundings, elevation_profile, band_mhz) + details = build_details(hrrr, hrrr_path, soundings, elevation_profile, band_mhz, contact) %{ summary: summary, @@ -2137,9 +2160,10 @@ defmodule MicrowavepropWeb.ContactLive.Show do defp band_summary(_, _), do: nil - defp build_details(hrrr, hrrr_path, soundings, elevation_profile, _band_mhz) do + defp build_details(hrrr, hrrr_path, soundings, elevation_profile, _band_mhz, contact) do Enum.reject( [ + antenna_heights_detail(contact), refractivity_detail(hrrr), boundary_layer_detail(hrrr), path_duct_count_detail(hrrr_path), @@ -2150,6 +2174,21 @@ defmodule MicrowavepropWeb.ContactLive.Show do ) end + defp antenna_heights_detail(%{height1_ft: h1, height2_ft: h2} = contact) when is_integer(h1) or is_integer(h2) do + s1 = contact.station1 || "Station 1" + s2 = contact.station2 || "Station 2" + + parts = + Enum.reject( + [if(is_integer(h1), do: "#{s1} @ #{h1} ft AGL"), if(is_integer(h2), do: "#{s2} @ #{h2} ft AGL")], + &is_nil/1 + ) + + "Antenna heights: #{Enum.join(parts, ", ")}." + end + + defp antenna_heights_detail(_), do: nil + defp path_duct_count_detail(hrrr_path) when is_list(hrrr_path) and hrrr_path != [] do total = length(hrrr_path) ducting = Enum.count(hrrr_path, &(&1 && &1.ducting_detected)) diff --git a/lib/microwaveprop_web/live/submit_live.ex b/lib/microwaveprop_web/live/submit_live.ex index d4b59d99..77d37be2 100644 --- a/lib/microwaveprop_web/live/submit_live.ex +++ b/lib/microwaveprop_web/live/submit_live.ex @@ -324,7 +324,7 @@ defmodule MicrowavepropWeb.SubmitLive do defp single_contact_form(assigns) do ~H""" <.form for={@form} id="contact-form" phx-change="validate" phx-submit="save" class="space-y-4"> -
+
<.input field={@form[:station1]} type="text" label="Station 1" placeholder="W5XD" required /> <.input field={@form[:grid1]} @@ -334,6 +334,14 @@ defmodule MicrowavepropWeb.SubmitLive do phx-debounce="blur" required /> + <.input + field={@form[:height1_ft]} + type="number" + label="Height 1 (ft AGL)" + min="0" + max="1000" + placeholder="Optional" + /> <.input field={@form[:station2]} type="text" label="Station 2" placeholder="K5TR" required /> <.input field={@form[:grid2]} @@ -343,10 +351,19 @@ defmodule MicrowavepropWeb.SubmitLive do phx-debounce="blur" required /> + <.input + field={@form[:height2_ft]} + type="number" + label="Height 2 (ft AGL)" + min="0" + max="1000" + placeholder="Optional" + />

Be as specific as possible with grid squares (8 characters preferred, e.g. EM12kp37). + Antenna heights are optional but improve terrain-clearance analysis.

diff --git a/priv/repo/migrations/20260418204008_add_height_ft_to_contacts.exs b/priv/repo/migrations/20260418204008_add_height_ft_to_contacts.exs new file mode 100644 index 00000000..05b6691b --- /dev/null +++ b/priv/repo/migrations/20260418204008_add_height_ft_to_contacts.exs @@ -0,0 +1,10 @@ +defmodule Microwaveprop.Repo.Migrations.AddHeightFtToContacts do + use Ecto.Migration + + def change do + alter table(:contacts) do + add :height1_ft, :integer + add :height2_ft, :integer + end + end +end diff --git a/test/microwaveprop/radio/contact_edit_test.exs b/test/microwaveprop/radio/contact_edit_test.exs index 8688b073..191b7b5e 100644 --- a/test/microwaveprop/radio/contact_edit_test.exs +++ b/test/microwaveprop/radio/contact_edit_test.exs @@ -5,6 +5,30 @@ defmodule Microwaveprop.Radio.ContactEditTest do alias Microwaveprop.Radio alias Microwaveprop.Radio.Contact alias Microwaveprop.Radio.ContactEdit + alias Microwaveprop.Terrain.ElevationClient + alias Microwaveprop.Weather.HrrrClient + alias Microwaveprop.Weather.IemClient + + setup do + # Height edits auto-enqueue TerrainProfileWorker (which Oban runs + # inline in tests). Stub the external data sources so the job can + # complete without hitting the network. + Req.Test.stub(ElevationClient, fn conn -> + params = Plug.Conn.fetch_query_params(conn).query_params + lat_count = params["latitude"] |> String.split(",") |> length() + Req.Test.json(conn, %{"elevation" => List.duplicate(200.0, lat_count)}) + end) + + Req.Test.stub(HrrrClient, fn conn -> + Plug.Conn.send_resp(conn, 404, "not found") + end) + + Req.Test.stub(IemClient, fn conn -> + Plug.Conn.send_resp(conn, 404, "not found") + end) + + :ok + end @contact_attrs %{ station1: "W5ISP", @@ -120,6 +144,39 @@ defmodule Microwaveprop.Radio.ContactEditTest do refute changeset.valid? end + test "accepts integer height1_ft and height2_ft", %{contact: contact, user: user} do + changeset = + ContactEdit.changeset(%ContactEdit{}, %{ + contact_id: contact.id, + user_id: user.id, + proposed_changes: %{"height1_ft" => 25, "height2_ft" => 60} + }) + + assert changeset.valid? + end + + test "rejects non-integer height1_ft", %{contact: contact, user: user} do + changeset = + ContactEdit.changeset(%ContactEdit{}, %{ + contact_id: contact.id, + user_id: user.id, + proposed_changes: %{"height1_ft" => "tall"} + }) + + refute changeset.valid? + end + + test "rejects out-of-range height2_ft", %{contact: contact, user: user} do + changeset = + ContactEdit.changeset(%ContactEdit{}, %{ + contact_id: contact.id, + user_id: user.id, + proposed_changes: %{"height2_ft" => 9999} + }) + + refute changeset.valid? + end + test "validates mode value", %{contact: contact, user: user} do changeset = ContactEdit.changeset(%ContactEdit{}, %{ @@ -301,6 +358,26 @@ defmodule Microwaveprop.Radio.ContactEditTest do assert {:error, :no_changes} = Radio.apply_owner_edit(contact, user, %{"station1" => contact.station1}) end + + test "owner can set height1_ft and height2_ft", %{user: user} do + contact = owned_contact(user) + + assert {:ok, updated} = + Radio.apply_owner_edit(contact, user, %{"height1_ft" => 20, "height2_ft" => 75}) + + assert updated.height1_ft == 20 + assert updated.height2_ft == 75 + end + + test "height change resets terrain_status so terrain is re-analyzed", %{user: user} do + contact = owned_contact(user) + contact = contact |> Ecto.Changeset.change(terrain_status: :complete) |> Repo.update!() + + {:ok, updated} = Radio.apply_owner_edit(contact, user, %{"height1_ft" => 30}) + + assert updated.height1_ft == 30 + assert updated.terrain_status == :pending + end end describe "Radio.owner?/2" do diff --git a/test/microwaveprop/radio/contact_test.exs b/test/microwaveprop/radio/contact_test.exs index efa3279f..7526602c 100644 --- a/test/microwaveprop/radio/contact_test.exs +++ b/test/microwaveprop/radio/contact_test.exs @@ -42,5 +42,35 @@ defmodule Microwaveprop.Radio.ContactTest do assert Decimal.equal?(contact.band, Decimal.new("144.2")) assert Decimal.equal?(contact.distance_km, Decimal.new("623.4")) end + + test "accepts optional height1_ft and height2_ft" do + attrs = Map.merge(@valid_attrs, %{height1_ft: 25, height2_ft: 60}) + changeset = Contact.changeset(%Contact{}, attrs) + assert changeset.valid? + assert {:ok, contact} = Repo.insert(changeset) + assert contact.height1_ft == 25 + assert contact.height2_ft == 60 + end + end + + describe "submission_changeset/2" do + @submission_attrs %{ + "station1" => "W1AW", + "station2" => "K3LR", + "qso_timestamp" => "2026-03-28T12:00:00Z", + "grid1" => "FN31pr", + "grid2" => "EN91jm", + "mode" => "SSB", + "band" => Decimal.new("144"), + "submitter_email" => "test@example.com" + } + + test "accepts height1_ft and height2_ft" do + attrs = Map.merge(@submission_attrs, %{"height1_ft" => 10, "height2_ft" => 50}) + changeset = Contact.submission_changeset(%Contact{}, attrs) + assert changeset.valid?, inspect(errors_on(changeset)) + assert Ecto.Changeset.get_field(changeset, :height1_ft) == 10 + assert Ecto.Changeset.get_field(changeset, :height2_ft) == 50 + end end end