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.ContactEdit
alias Microwaveprop.Radio.Maidenhead alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Repo alias Microwaveprop.Repo
alias Microwaveprop.Workers.ContactWeatherEnqueueWorker
@per_page 20 @per_page 20
@sortable_fields ~w(station1 station2 band mode distance_km qso_timestamp inserted_at)a @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("grid1")
|> normalize_string_field("grid2") |> normalize_string_field("grid2")
|> normalize_string_field("mode") |> normalize_string_field("mode")
|> normalize_integer_field("height1_ft")
|> normalize_integer_field("height2_ft")
end end
defp normalize_string_field(map, key) do defp normalize_string_field(map, key) do
@ -782,6 +785,33 @@ defmodule Microwaveprop.Radio do
end end
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 @doc false
@spec diff_against_contact(Contact.t(), map()) :: map() @spec diff_against_contact(Contact.t(), map()) :: map()
def diff_against_contact(contact, proposed) do def diff_against_contact(contact, proposed) do
@ -807,6 +837,8 @@ defmodule Microwaveprop.Radio do
end end
defp current_value(contact, "qso_timestamp"), do: contact.qso_timestamp 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 current_value(_contact, _key), do: nil
defp values_equal?(a, b) when is_binary(a) and is_binary(b) do 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 def apply_edit_to_contact(contact, proposed_changes) do
changes = build_contact_changes(contact, proposed_changes) changes = build_contact_changes(contact, proposed_changes)
changes = maybe_recompute_positions(changes, contact, proposed_changes) changes = maybe_recompute_positions(changes, contact, proposed_changes)
changes = maybe_reset_terrain_for_heights(changes, proposed_changes)
contact updated =
|> Ecto.Changeset.change(changes) contact
|> Repo.update!() |> 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 end
defp maybe_recompute_positions(changes, contact, proposed) do defp maybe_recompute_positions(changes, contact, proposed) do

View file

@ -60,6 +60,12 @@ defmodule Microwaveprop.Radio.Contact do
field :submitter_email, :string field :submitter_email, :string
field :flagged_invalid, :boolean, default: false 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 belongs_to :user, User
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
@ -68,7 +74,7 @@ defmodule Microwaveprop.Radio.Contact do
@type t :: %__MODULE__{} @type t :: %__MODULE__{}
@required_fields ~w(station1 station2 qso_timestamp band)a @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() @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(contact, attrs) do def changeset(contact, attrs) do
@ -77,7 +83,7 @@ defmodule Microwaveprop.Radio.Contact do
|> validate_required(@required_fields) |> validate_required(@required_fields)
end 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 @submission_required ~w(station1 station2 qso_timestamp band grid1 grid2)a
@allowed_modes ~w(CW SSB FM FT8 FT4 Q65) @allowed_modes ~w(CW SSB FM FT8 FT4 Q65)
# Keep in sync with Microwaveprop.Propagation.BandConfig.all_bands/0 and # 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(:submitter_email, max: 254)
|> validate_length(:station1, max: 20) |> validate_length(:station1, max: 20)
|> validate_length(:station2, 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 end
# Treat empty / whitespace-only mode strings as "not provided" so the column # 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__{} @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() @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(edit, attrs) do def changeset(edit, attrs) do
@ -118,8 +118,21 @@ defmodule Microwaveprop.Radio.ContactEdit do
end end
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_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 defp validate_callsign_value(changeset, field) do
val = get_field(changeset, :proposed_changes)[field] || "" val = get_field(changeset, :proposed_changes)[field] || ""
val = val |> String.trim() |> String.upcase() 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: ""), "band" => if(contact.band, do: Decimal.to_string(contact.band), else: ""),
"mode" => contact.mode, "mode" => contact.mode,
"qso_timestamp" => "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"))} {:noreply, assign(socket, editing: true, edit_form: to_form(form_data, as: "edit"))}
@ -836,7 +838,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
</h3> </h3>
<p class="text-sm opacity-70 mb-4"> <p class="text-sm opacity-70 mb-4">
{if direct_edit, {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."} else: "Change any fields below. Only fields you modify will be submitted for review."}
</p> </p>
<.form <.form
@ -846,11 +848,27 @@ defmodule MicrowavepropWeb.ContactLive.Show do
phx-submit="submit_edit" phx-submit="submit_edit"
class="space-y-4" 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[:station1]} type="text" label="Station 1" />
<.input field={@edit_form[:grid1]} type="text" label="Grid 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[:station2]} type="text" label="Station 2" />
<.input field={@edit_form[:grid2]} type="text" label="Grid 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>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<.input field={@edit_form[:band]} type="select" label="Band" options={@band_options} /> <.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 {:ok, profile} <- safe_fetch_elevation(lat1, lon1, lat2, lon2) do
freq_ghz = band_to_ghz(contact.band) freq_ghz = band_to_ghz(contact.band)
dist_km = haversine_km(lat1, lon1, lat2, lon2) dist_km = haversine_km(lat1, lon1, lat2, lon2)
# Assume both stations are at 10 ft (3.048 m) AGL so the path # Default to 10 ft (3.048 m) AGL when the operator didn't record an
# analysis doesn't pretend the antennas are sitting on the dirt. # antenna height — better than pretending the antenna is on the dirt.
ant_ht_m = 3.048 default_ht_m = 3.048
analysis = TerrainAnalysis.analyse(profile, dist_km, freq_ghz, ant_ht_a: ant_ht_m, ant_ht_b: ant_ht_m) 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) fwd_az = initial_bearing(lat1, lon1, lat2, lon2)
rev_az = initial_bearing(lat2, lon2, lat1, lon1) rev_az = initial_bearing(lat2, lon2, lat1, lon1)
tx_elev = hd(profile).elev + ant_ht_m tx_elev = hd(profile).elev + ant_ht_a_m
rx_elev = List.last(profile).elev + ant_ht_m rx_elev = List.last(profile).elev + ant_ht_b_m
fwd_el = elevation_angle(tx_elev, rx_elev, dist_km * 1000) fwd_el = elevation_angle(tx_elev, rx_elev, dist_km * 1000)
rev_el = elevation_angle(rx_elev, tx_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 defp mark_likely_duct(ducts, tx_elev, rx_elev) do
avg_elev = (tx_elev + rx_elev) / 2 avg_elev = (tx_elev + rx_elev) / 2
{likely_idx, _} = {_, likely_idx} =
ducts ducts
|> Enum.with_index() |> Enum.with_index()
|> Enum.min_by(fn {d, _idx} -> |> Enum.min_by(fn {d, _idx} ->
@ -1838,6 +1858,9 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|> Kernel./(1000) |> Kernel./(1000)
end 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 defp haversine_km(lat1, lon1, lat2, lon2) do
dlat = deg_to_rad(lat2 - lat1) dlat = deg_to_rad(lat2 - lat1)
dlon = deg_to_rad(lon2 - lon1) dlon = deg_to_rad(lon2 - lon1)
@ -1937,7 +1960,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
summary = summary =
build_summary(terrain, elevation_profile, hrrr, soundings, dist_km, band_mhz, contact) 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, summary: summary,
@ -2137,9 +2160,10 @@ defmodule MicrowavepropWeb.ContactLive.Show do
defp band_summary(_, _), do: nil 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( Enum.reject(
[ [
antenna_heights_detail(contact),
refractivity_detail(hrrr), refractivity_detail(hrrr),
boundary_layer_detail(hrrr), boundary_layer_detail(hrrr),
path_duct_count_detail(hrrr_path), path_duct_count_detail(hrrr_path),
@ -2150,6 +2174,21 @@ defmodule MicrowavepropWeb.ContactLive.Show do
) )
end 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 defp path_duct_count_detail(hrrr_path) when is_list(hrrr_path) and hrrr_path != [] do
total = length(hrrr_path) total = length(hrrr_path)
ducting = Enum.count(hrrr_path, &(&1 && &1.ducting_detected)) 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 defp single_contact_form(assigns) do
~H""" ~H"""
<.form for={@form} id="contact-form" phx-change="validate" phx-submit="save" class="space-y-4"> <.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[:station1]} type="text" label="Station 1" placeholder="W5XD" required />
<.input <.input
field={@form[:grid1]} field={@form[:grid1]}
@ -334,6 +334,14 @@ defmodule MicrowavepropWeb.SubmitLive do
phx-debounce="blur" phx-debounce="blur"
required 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[:station2]} type="text" label="Station 2" placeholder="K5TR" required />
<.input <.input
field={@form[:grid2]} field={@form[:grid2]}
@ -343,10 +351,19 @@ defmodule MicrowavepropWeb.SubmitLive do
phx-debounce="blur" phx-debounce="blur"
required required
/> />
<.input
field={@form[:height2_ft]}
type="number"
label="Height 2 (ft AGL)"
min="0"
max="1000"
placeholder="Optional"
/>
</div> </div>
<p class="text-sm text-base-content/60 -mt-2"> <p class="text-sm text-base-content/60 -mt-2">
Be as specific as possible with grid squares (8 characters preferred, e.g. EM12kp37). Be as specific as possible with grid squares (8 characters preferred, e.g. EM12kp37).
Antenna heights are optional but improve terrain-clearance analysis.
</p> </p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <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
alias Microwaveprop.Radio.Contact alias Microwaveprop.Radio.Contact
alias Microwaveprop.Radio.ContactEdit 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 %{ @contact_attrs %{
station1: "W5ISP", station1: "W5ISP",
@ -120,6 +144,39 @@ defmodule Microwaveprop.Radio.ContactEditTest do
refute changeset.valid? refute changeset.valid?
end 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 test "validates mode value", %{contact: contact, user: user} do
changeset = changeset =
ContactEdit.changeset(%ContactEdit{}, %{ ContactEdit.changeset(%ContactEdit{}, %{
@ -301,6 +358,26 @@ defmodule Microwaveprop.Radio.ContactEditTest do
assert {:error, :no_changes} = assert {:error, :no_changes} =
Radio.apply_owner_edit(contact, user, %{"station1" => contact.station1}) Radio.apply_owner_edit(contact, user, %{"station1" => contact.station1})
end 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 end
describe "Radio.owner?/2" do 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.band, Decimal.new("144.2"))
assert Decimal.equal?(contact.distance_km, Decimal.new("623.4")) assert Decimal.equal?(contact.distance_km, Decimal.new("623.4"))
end 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
end end