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:
parent
67d4b2eca7
commit
ca07c0288d
8 changed files with 277 additions and 19 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue