Beacon detail map with range estimate and on_the_air flag
- Add on_the_air boolean to beacons (default true); surfaced as a checkbox on the form, a badge on the index, and in the detail list. - Render a Leaflet map on the beacon show page with a marker at the beacon's lat/lon tooltipped with callsign + frequency. Marker is green when on air, gray when off. - Compute and draw a reception-range estimate as concentric signal- strength rings. New Microwaveprop.Beacons.RangeEstimate solves a link budget (FSPL + O2/H2O absorption from BandConfig) at five RX thresholds (-100 to -145 dBm), then scales by 0.5 + score/100 from the latest Propagation.point_detail at the beacon's grid square, so current HRRR conditions shift the rings in or out. - Re-enable the hourly PropagationGridWorker cron and freshness monitor in dev.exs so dev actually has HRRR-backed scores to feed the new estimator.
This commit is contained in:
parent
374f9b106a
commit
a5b3f1f3da
11 changed files with 391 additions and 7 deletions
|
|
@ -37,6 +37,7 @@ import {ElevationProfile} from "./elevation_profile_hook"
|
|||
import {WeatherMap} from "./weather_map_hook"
|
||||
import {LocateMe, CopyLink} from "./locate_me_hook"
|
||||
import {RoverMap} from "./rover_map_hook"
|
||||
import {BeaconMap} from "./beacon_map_hook"
|
||||
|
||||
const UtcClock = {
|
||||
mounted() {
|
||||
|
|
@ -58,7 +59,7 @@ const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute
|
|||
const liveSocket = new LiveSocket("/live", Socket, {
|
||||
longPollFallbackMs: 2500,
|
||||
params: initLiveStash({_csrf_token: csrfToken}),
|
||||
hooks: {...colocatedHooks, PropagationMap, UtcClock, ContactMap, ContactsMap, ElevationProfile, WeatherMap, LocateMe, CopyLink, RoverMap},
|
||||
hooks: {...colocatedHooks, PropagationMap, UtcClock, ContactMap, ContactsMap, ElevationProfile, WeatherMap, LocateMe, CopyLink, RoverMap, BeaconMap},
|
||||
})
|
||||
|
||||
// Show progress bar on live navigation and form submits
|
||||
|
|
|
|||
60
assets/js/beacon_map_hook.js
Normal file
60
assets/js/beacon_map_hook.js
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
export const BeaconMap = {
|
||||
mounted() {
|
||||
const lat = parseFloat(this.el.dataset.lat)
|
||||
const lon = parseFloat(this.el.dataset.lon)
|
||||
const label = this.el.dataset.label || ""
|
||||
const onTheAir = this.el.dataset.onTheAir === "true"
|
||||
const rings = JSON.parse(this.el.dataset.rings || "[]")
|
||||
|
||||
const map = L.map(this.el, {
|
||||
zoomControl: true,
|
||||
attributionControl: false,
|
||||
scrollWheelZoom: false
|
||||
})
|
||||
|
||||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
maxZoom: 19
|
||||
}).addTo(map)
|
||||
|
||||
// Draw rings outermost-first so stronger tiers stack on top.
|
||||
const sorted = [...rings].sort((a, b) => b.radius_km - a.radius_km)
|
||||
const drawn = []
|
||||
for (const ring of sorted) {
|
||||
if (!ring.radius_km || ring.radius_km <= 0) continue
|
||||
const circle = L.circle([lat, lon], {
|
||||
radius: ring.radius_km * 1000,
|
||||
color: ring.color,
|
||||
weight: 1.5,
|
||||
opacity: 0.9,
|
||||
fillColor: ring.color,
|
||||
fillOpacity: 0.12
|
||||
}).addTo(map)
|
||||
circle.bindTooltip(`${ring.label}: ${ring.rx_dbm} dBm · ${ring.radius_km} km`, {
|
||||
sticky: true
|
||||
})
|
||||
drawn.push(circle)
|
||||
}
|
||||
|
||||
const markerColor = onTheAir ? "#16a34a" : "#94a3b8"
|
||||
const markerFill = onTheAir ? "#22c55e" : "#cbd5e1"
|
||||
|
||||
L.circleMarker([lat, lon], {
|
||||
radius: 7,
|
||||
color: markerColor,
|
||||
fillColor: markerFill,
|
||||
fillOpacity: 1.0,
|
||||
weight: 2
|
||||
}).addTo(map).bindTooltip(label, {
|
||||
permanent: true,
|
||||
direction: "top",
|
||||
offset: [0, -10]
|
||||
})
|
||||
|
||||
// Fit to the largest ring, or fall back to a reasonable zoom around the point.
|
||||
if (drawn.length > 0) {
|
||||
map.fitBounds(drawn[0].getBounds(), {padding: [20, 20]})
|
||||
} else {
|
||||
map.setView([lat, lon], 9)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -87,7 +87,7 @@ config :microwaveprop, Oban,
|
|||
{Oban.Plugins.Lifeline, rescue_after: to_timeout(minute: 30)},
|
||||
{Oban.Plugins.Cron,
|
||||
crontab: [
|
||||
# {"5 * * * *", Microwaveprop.Workers.PropagationGridWorker},
|
||||
{"5 * * * *", Microwaveprop.Workers.PropagationGridWorker},
|
||||
{"0 8 * * *", Microwaveprop.Workers.SolarIndexWorker}
|
||||
]}
|
||||
]
|
||||
|
|
@ -106,8 +106,8 @@ config :microwaveprop, load_ml_model: true
|
|||
# Use local SRTM1 tiles for elevation lookups instead of the Open-Meteo API
|
||||
config :microwaveprop, srtm_tiles_dir: Path.expand("~/srtm/tiles")
|
||||
|
||||
# Freshness monitor disabled in dev (no hourly propagation grid running)
|
||||
config :microwaveprop, start_freshness_monitor: false
|
||||
# Freshness monitor enabled (hourly PropagationGridWorker runs in dev)
|
||||
config :microwaveprop, start_freshness_monitor: true
|
||||
|
||||
# Initialize plugs at runtime for faster development compilation
|
||||
config :phoenix, :plug_init_mode, :runtime
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ defmodule Microwaveprop.Beacons.Beacon do
|
|||
field :lon, :float
|
||||
field :power_mw, :float
|
||||
field :height_ft, :float
|
||||
field :on_the_air, :boolean, default: true
|
||||
field :user_id, :binary_id
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
|
|
@ -30,7 +31,7 @@ defmodule Microwaveprop.Beacons.Beacon do
|
|||
|
||||
def changeset(beacon, attrs) do
|
||||
beacon
|
||||
|> cast(attrs, [:frequency_mhz, :callsign, :grid, :lat, :lon, :power_mw, :height_ft])
|
||||
|> cast(attrs, [:frequency_mhz, :callsign, :grid, :lat, :lon, :power_mw, :height_ft, :on_the_air])
|
||||
|> update_change(:callsign, fn cs -> cs && String.upcase(String.trim(cs)) end)
|
||||
|> maybe_fill_latlon()
|
||||
|> maybe_fill_grid()
|
||||
|
|
|
|||
145
lib/microwaveprop/beacons/range_estimate.ex
Normal file
145
lib/microwaveprop/beacons/range_estimate.ex
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
defmodule Microwaveprop.Beacons.RangeEstimate do
|
||||
@moduledoc """
|
||||
Estimates a beacon's reception range at several signal-strength tiers.
|
||||
|
||||
For each tier we solve a link budget of
|
||||
|
||||
Rx_dBm = EIRP_dBm + Rx_gain_dBi - FSPL(d, f) - atm_loss_per_km * d
|
||||
|
||||
for the distance `d` at which the received power equals the tier threshold.
|
||||
Free-space path loss uses the standard formula and atmospheric absorption
|
||||
comes from the band's O₂/H₂O coefficients in `BandConfig`.
|
||||
|
||||
The result is then multiplied by a propagation-score factor derived from the
|
||||
latest `propagation_scores` row at the beacon's lat/lon — score 50 → 1.0x,
|
||||
score 0 → 0.5x, score 100 → 1.5x — so current HRRR conditions shift the
|
||||
rings in or out.
|
||||
"""
|
||||
|
||||
alias Microwaveprop.Propagation
|
||||
alias Microwaveprop.Propagation.BandConfig
|
||||
|
||||
# Signal-strength tiers and their RX sensitivity thresholds (dBm).
|
||||
# Colors match the map_live / propagation_map_hook palette.
|
||||
@tiers [
|
||||
%{label: "Excellent", rx_dbm: -100, color: "#00ffa3"},
|
||||
%{label: "Good", rx_dbm: -115, color: "#7dffd4"},
|
||||
%{label: "Marginal", rx_dbm: -125, color: "#ffe566"},
|
||||
%{label: "Weak CW", rx_dbm: -135, color: "#ff9044"},
|
||||
%{label: "Detection", rx_dbm: -145, color: "#ff4f4f"}
|
||||
]
|
||||
|
||||
# Assume the receiving station is an average amateur microwave station
|
||||
# (dish/horn + low-noise preamp).
|
||||
@rx_gain_dbi 20.0
|
||||
# Beacons are typically omnidirectional.
|
||||
@tx_gain_dbi 0.0
|
||||
|
||||
@doc """
|
||||
Convert a power in milliwatts to dBm. Returns `-999.9` for non-positive input.
|
||||
"""
|
||||
@spec mw_to_dbm(number()) :: float()
|
||||
def mw_to_dbm(mw) when is_number(mw) and mw > 0, do: 10.0 * :math.log10(mw)
|
||||
def mw_to_dbm(_), do: -999.9
|
||||
|
||||
@doc """
|
||||
Returns the closest configured band frequency (in MHz) to the given beacon
|
||||
frequency. e.g. `nearest_band_mhz(10368.1) == 10_000`.
|
||||
"""
|
||||
@spec nearest_band_mhz(number()) :: integer()
|
||||
def nearest_band_mhz(freq_mhz) when is_number(freq_mhz) do
|
||||
BandConfig.all_freqs()
|
||||
|> Enum.min_by(fn b -> abs(b - freq_mhz) end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Estimate a beacon's reception range.
|
||||
|
||||
Returns a map with band info, current score, and a list of rings sorted
|
||||
weakest-distance first (strongest RX tier → shortest radius).
|
||||
"""
|
||||
@spec estimate(Microwaveprop.Beacons.Beacon.t()) :: map()
|
||||
def estimate(beacon) do
|
||||
band_mhz = nearest_band_mhz(beacon.frequency_mhz)
|
||||
band_config = BandConfig.get(band_mhz)
|
||||
|
||||
detail = Propagation.point_detail(band_mhz, beacon.lat, beacon.lon)
|
||||
score = (detail && detail.score) || 50
|
||||
valid_time = detail && detail.valid_time
|
||||
|
||||
f_mhz = beacon.frequency_mhz * 1.0
|
||||
eirp_dbm = mw_to_dbm(beacon.power_mw || 0.0) + @tx_gain_dbi
|
||||
|
||||
atm_per_km = atm_loss_per_km(band_config)
|
||||
score_mult = 0.5 + score / 100.0
|
||||
|
||||
rings =
|
||||
@tiers
|
||||
|> Enum.map(fn tier ->
|
||||
d_phys = solve_range(eirp_dbm, @rx_gain_dbi, tier.rx_dbm, f_mhz, atm_per_km)
|
||||
radius = Float.round(d_phys * score_mult, 1)
|
||||
|
||||
%{
|
||||
label: tier.label,
|
||||
rx_dbm: tier.rx_dbm,
|
||||
color: tier.color,
|
||||
radius_km: radius
|
||||
}
|
||||
end)
|
||||
|> Enum.filter(fn ring -> ring.radius_km > 0.5 end)
|
||||
|> Enum.sort_by(& &1.radius_km)
|
||||
|
||||
%{
|
||||
beacon_id: beacon.id,
|
||||
band_mhz: band_mhz,
|
||||
band_label: band_config && band_config.label,
|
||||
score: score,
|
||||
score_mult: Float.round(score_mult, 2),
|
||||
valid_time: valid_time,
|
||||
eirp_dbm: Float.round(eirp_dbm, 1),
|
||||
atm_per_km: Float.round(atm_per_km, 3),
|
||||
rings: rings
|
||||
}
|
||||
end
|
||||
|
||||
# Total dB/km atmospheric attenuation from O2 + water vapor. Uses a moderate
|
||||
# absolute humidity of 10 g/m³ as a default — the HRRR-derived score already
|
||||
# captures humidity variability, so using a fixed value here keeps the
|
||||
# physics clean.
|
||||
defp atm_loss_per_km(nil), do: 0.0
|
||||
|
||||
defp atm_loss_per_km(band_config) do
|
||||
o2 = Map.get(band_config, :o2_db_km, 0.0)
|
||||
h2o_coeff = Map.get(band_config, :h2o_coeff, 0.0)
|
||||
o2 + h2o_coeff * 10.0
|
||||
end
|
||||
|
||||
# Solve `FSPL(d) + atm_per_km * d = budget_db` for d via bisection.
|
||||
# `budget_db = EIRP + Rx_gain - threshold`.
|
||||
defp solve_range(eirp_dbm, rx_gain, threshold_dbm, f_mhz, atm_per_km) do
|
||||
budget = eirp_dbm + rx_gain - threshold_dbm
|
||||
log_f_const = 20.0 * :math.log10(f_mhz) + 32.44
|
||||
|
||||
cond do
|
||||
budget <= log_f_const ->
|
||||
# Even at 1 km the budget is already negative → ring is effectively 0.
|
||||
0.0
|
||||
|
||||
true ->
|
||||
bisect(0.01, 5000.0, budget, log_f_const, atm_per_km, 50)
|
||||
end
|
||||
end
|
||||
|
||||
defp bisect(lo, hi, _budget, _log_f, _atm, 0), do: (lo + hi) / 2.0
|
||||
|
||||
defp bisect(lo, hi, budget, log_f, atm, iters) do
|
||||
mid = (lo + hi) / 2.0
|
||||
val = 20.0 * :math.log10(mid) + log_f + atm * mid
|
||||
|
||||
if val > budget do
|
||||
bisect(lo, mid, budget, log_f, atm, iters - 1)
|
||||
else
|
||||
bisect(mid, hi, budget, log_f, atm, iters - 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -43,6 +43,7 @@ defmodule MicrowavepropWeb.BeaconLive.Form do
|
|||
step="any"
|
||||
required
|
||||
/>
|
||||
<.input field={@form[:on_the_air]} type="checkbox" label="On the air" />
|
||||
<footer>
|
||||
<.button phx-disable-with="Saving..." variant="primary">Save Beacon</.button>
|
||||
<.button navigate={return_path(@return_to, @beacon)}>Cancel</.button>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,11 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
|
|||
<:col :let={{_id, beacon}} label="Lon">{beacon.lon}</:col>
|
||||
<:col :let={{_id, beacon}} label="Power (mW)">{beacon.power_mw}</:col>
|
||||
<:col :let={{_id, beacon}} label="Height AGL (ft)">{beacon.height_ft}</:col>
|
||||
<:col :let={{_id, beacon}} label="On air">
|
||||
<span class={["badge badge-sm", beacon.on_the_air && "badge-success" || "badge-ghost"]}>
|
||||
{if beacon.on_the_air, do: "Yes", else: "No"}
|
||||
</span>
|
||||
</:col>
|
||||
<:action :let={{_id, beacon}}>
|
||||
<div class="sr-only">
|
||||
<.link navigate={~p"/beacons/#{beacon}"}>Show</.link>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
|
|||
|
||||
alias Microwaveprop.Beacons
|
||||
alias Microwaveprop.Beacons.Beacon
|
||||
alias Microwaveprop.Beacons.RangeEstimate
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
|
|
@ -26,6 +27,52 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
|
|||
</:actions>
|
||||
</.header>
|
||||
|
||||
<div
|
||||
id={"beacon-map-" <> @beacon.id}
|
||||
phx-hook="BeaconMap"
|
||||
phx-update="ignore"
|
||||
class="h-96 w-full rounded-lg border border-base-300 mb-2"
|
||||
data-lat={@beacon.lat}
|
||||
data-lon={@beacon.lon}
|
||||
data-label={"#{@beacon.callsign} · #{@beacon.frequency_mhz} MHz"}
|
||||
data-on-the-air={to_string(@beacon.on_the_air)}
|
||||
data-rings={Jason.encode!(@estimate.rings)}
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="text-xs opacity-70 mb-4 flex flex-wrap items-center gap-3">
|
||||
<span>
|
||||
Band <strong>{@estimate.band_label}</strong> ·
|
||||
EIRP <strong>{@estimate.eirp_dbm} dBm</strong> ·
|
||||
Atm loss <strong>{@estimate.atm_per_km} dB/km</strong>
|
||||
</span>
|
||||
<span>
|
||||
Prop score <strong>{@estimate.score}</strong>
|
||||
(<strong>{@estimate.score_mult}×</strong> range)
|
||||
<%= if @estimate.valid_time do %>
|
||||
at {Calendar.strftime(@estimate.valid_time, "%Y-%m-%d %H:%M UTC")}
|
||||
<% else %>
|
||||
(no HRRR data — using default 50)
|
||||
<% end %>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3 text-xs mb-4">
|
||||
<%= for ring <- @estimate.rings do %>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span
|
||||
class="inline-block w-3 h-3 rounded-full border"
|
||||
style={"background-color: #{ring.color}; border-color: #{ring.color}"}
|
||||
>
|
||||
</span>
|
||||
<span>
|
||||
<strong>{ring.label}</strong>
|
||||
({ring.rx_dbm} dBm) → {ring.radius_km} km
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<.list>
|
||||
<:item title="Frequency (MHz)">{@beacon.frequency_mhz}</:item>
|
||||
<:item title="Callsign">{@beacon.callsign}</:item>
|
||||
|
|
@ -34,6 +81,7 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
|
|||
<:item title="Longitude">{@beacon.lon}</:item>
|
||||
<:item title="Power (mW)">{@beacon.power_mw}</:item>
|
||||
<:item title="Height above ground (ft)">{@beacon.height_ft}</:item>
|
||||
<:item title="On the air">{if @beacon.on_the_air, do: "Yes", else: "No"}</:item>
|
||||
</.list>
|
||||
</Layouts.app>
|
||||
"""
|
||||
|
|
@ -43,15 +91,21 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
|
|||
def mount(%{"id" => id}, _session, socket) do
|
||||
if connected?(socket), do: Beacons.subscribe_beacons()
|
||||
|
||||
beacon = Beacons.get_beacon!(id)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Beacon")
|
||||
|> assign(:beacon, Beacons.get_beacon!(id))}
|
||||
|> assign(:beacon, beacon)
|
||||
|> assign(:estimate, RangeEstimate.estimate(beacon))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:updated, %Beacon{id: id} = beacon}, %{assigns: %{beacon: %{id: id}}} = socket) do
|
||||
{:noreply, assign(socket, :beacon, beacon)}
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:beacon, beacon)
|
||||
|> assign(:estimate, RangeEstimate.estimate(beacon))}
|
||||
end
|
||||
|
||||
def handle_info({:deleted, %Beacon{id: id}}, %{assigns: %{beacon: %{id: id}}} = socket) do
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
defmodule Microwaveprop.Repo.Migrations.AddOnTheAirToBeacons do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:beacons) do
|
||||
add :on_the_air, :boolean, null: false, default: true
|
||||
end
|
||||
end
|
||||
end
|
||||
96
test/microwaveprop/beacons/range_estimate_test.exs
Normal file
96
test/microwaveprop/beacons/range_estimate_test.exs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
defmodule Microwaveprop.Beacons.RangeEstimateTest do
|
||||
use Microwaveprop.DataCase
|
||||
|
||||
alias Microwaveprop.Beacons.Beacon
|
||||
alias Microwaveprop.Beacons.RangeEstimate
|
||||
|
||||
defp beacon(overrides \\ %{}) do
|
||||
base = %Beacon{
|
||||
id: "00000000-0000-0000-0000-000000000000",
|
||||
frequency_mhz: 10_368.1,
|
||||
callsign: "W5HN",
|
||||
grid: "EM12",
|
||||
lat: 32.897,
|
||||
lon: -97.038,
|
||||
power_mw: 10_000.0,
|
||||
height_ft: 100.0,
|
||||
on_the_air: true
|
||||
}
|
||||
|
||||
struct!(base, overrides)
|
||||
end
|
||||
|
||||
describe "mw_to_dbm/1" do
|
||||
test "1 mW is 0 dBm" do
|
||||
assert_in_delta RangeEstimate.mw_to_dbm(1.0), 0.0, 0.001
|
||||
end
|
||||
|
||||
test "1 W (1000 mW) is 30 dBm" do
|
||||
assert_in_delta RangeEstimate.mw_to_dbm(1000.0), 30.0, 0.001
|
||||
end
|
||||
|
||||
test "10 W (10_000 mW) is 40 dBm" do
|
||||
assert_in_delta RangeEstimate.mw_to_dbm(10_000.0), 40.0, 0.001
|
||||
end
|
||||
end
|
||||
|
||||
describe "nearest_band_mhz/1" do
|
||||
test "10368.1 MHz maps to 10000 (10 GHz band)" do
|
||||
assert RangeEstimate.nearest_band_mhz(10_368.1) == 10_000
|
||||
end
|
||||
|
||||
test "24192.1 MHz maps to 24000" do
|
||||
assert RangeEstimate.nearest_band_mhz(24_192.1) == 24_000
|
||||
end
|
||||
|
||||
test "47088 MHz maps to 47000" do
|
||||
assert RangeEstimate.nearest_band_mhz(47_088.0) == 47_000
|
||||
end
|
||||
end
|
||||
|
||||
describe "estimate/1" do
|
||||
test "returns a map with band info, score, and a list of rings" do
|
||||
result = RangeEstimate.estimate(beacon())
|
||||
|
||||
assert result.band_mhz == 10_000
|
||||
assert is_integer(result.score) or is_float(result.score)
|
||||
assert is_float(result.eirp_dbm)
|
||||
assert is_list(result.rings)
|
||||
assert length(result.rings) > 0
|
||||
end
|
||||
|
||||
test "rings are ordered strongest-to-weakest with increasing radius" do
|
||||
result = RangeEstimate.estimate(beacon())
|
||||
radii = Enum.map(result.rings, & &1.radius_km)
|
||||
assert radii == Enum.sort(radii)
|
||||
end
|
||||
|
||||
test "each ring carries a label, color, threshold, and radius_km" do
|
||||
result = RangeEstimate.estimate(beacon())
|
||||
|
||||
for ring <- result.rings do
|
||||
assert is_binary(ring.label)
|
||||
assert is_binary(ring.color)
|
||||
assert is_number(ring.rx_dbm)
|
||||
assert is_float(ring.radius_km)
|
||||
assert ring.radius_km >= 0.0
|
||||
end
|
||||
end
|
||||
|
||||
test "a more powerful beacon produces larger rings" do
|
||||
weak = RangeEstimate.estimate(beacon(power_mw: 10.0))
|
||||
strong = RangeEstimate.estimate(beacon(power_mw: 10_000.0))
|
||||
|
||||
weakest_weak = List.last(weak.rings).radius_km
|
||||
weakest_strong = List.last(strong.rings).radius_km
|
||||
assert weakest_strong > weakest_weak
|
||||
end
|
||||
|
||||
test "uses a default score of 50 when no propagation data exists" do
|
||||
result = RangeEstimate.estimate(beacon())
|
||||
# No scores in test DB → score defaults
|
||||
assert result.score == 50
|
||||
assert result.valid_time == nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -85,6 +85,18 @@ defmodule Microwaveprop.BeaconsTest do
|
|||
assert "is not a valid Maidenhead grid" in errors_on(changeset).grid
|
||||
end
|
||||
|
||||
test "defaults on_the_air to true" do
|
||||
user = user_fixture()
|
||||
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs())
|
||||
assert beacon.on_the_air == true
|
||||
end
|
||||
|
||||
test "accepts on_the_air: false" do
|
||||
user = user_fixture()
|
||||
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(on_the_air: false))
|
||||
assert beacon.on_the_air == false
|
||||
end
|
||||
|
||||
test "upcases the callsign" do
|
||||
user = user_fixture()
|
||||
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(callsign: "w5hn"))
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue