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.
This commit is contained in:
Graham McIntire 2026-04-18 15:48:05 -05:00
parent 67d4b2eca7
commit ca07c0288d
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
8 changed files with 277 additions and 19 deletions

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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
</h3>
<p class="text-sm opacity-70 mb-4">
{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."}
</p>
<.form
@ -846,11 +848,27 @@ defmodule MicrowavepropWeb.ContactLive.Show do
phx-submit="submit_edit"
class="space-y-4"
>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-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"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<.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))

View file

@ -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">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-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"
/>
</div>
<p class="text-sm text-base-content/60 -mt-2">
Be as specific as possible with grid squares (8 characters preferred, e.g. EM12kp37).
Antenna heights are optional but improve terrain-clearance analysis.
</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">

View file

@ -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

View file

@ -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

View file

@ -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