Per-HRRR-cell beacon reception plot
Replace the idealised concentric-circle range rings with a realistic per-HRRR-grid-cell reception map. For every 0.125° grid point within the band's exceptional range, the estimator computes great-circle distance, FSPL + atmospheric loss, and applies a cell-specific score adjustment of (50 - score) * 0.3 dB — score 100 gives a 15 dB ducting boost, score 0 a 15 dB penalty. Cells below the -145 dBm detection floor are dropped. The beacon map hook now renders each surviving cell as a 0.125° filled rectangle with a tooltip showing tier, Rx dBm, distance, and HRRR score, so the coverage footprint bulges where ducting conditions are good and cuts off where they aren't, instead of being a perfect circle. Falls back to score 50 for cells that don't have HRRR data yet, so the plot is always useful. Also fixes the prior all-grey-tiles regression by restoring an explicit setView() at map creation and adding a post-mount invalidateSize() for when the map is placed inside a flex container.
This commit is contained in:
parent
e6e0ecde87
commit
cda54e3514
4 changed files with 218 additions and 122 deletions
|
|
@ -4,40 +4,46 @@ export const BeaconMap = {
|
||||||
const lon = parseFloat(this.el.dataset.lon)
|
const lon = parseFloat(this.el.dataset.lon)
|
||||||
const label = this.el.dataset.label || ""
|
const label = this.el.dataset.label || ""
|
||||||
const onTheAir = this.el.dataset.onTheAir === "true"
|
const onTheAir = this.el.dataset.onTheAir === "true"
|
||||||
const rings = JSON.parse(this.el.dataset.rings || "[]")
|
const cells = JSON.parse(this.el.dataset.cells || "[]")
|
||||||
|
const step = parseFloat(this.el.dataset.gridStep || "0.125")
|
||||||
|
|
||||||
const map = L.map(this.el, {
|
const map = L.map(this.el, {
|
||||||
zoomControl: true,
|
zoomControl: true,
|
||||||
attributionControl: false,
|
attributionControl: false,
|
||||||
scrollWheelZoom: false
|
scrollWheelZoom: false
|
||||||
})
|
}).setView([lat, lon], 9)
|
||||||
|
|
||||||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||||
maxZoom: 19
|
maxZoom: 19
|
||||||
}).addTo(map)
|
}).addTo(map)
|
||||||
|
|
||||||
// Draw rings outermost-first so stronger tiers stack on top.
|
// Draw each HRRR grid cell as a filled rectangle colored by its received
|
||||||
const sorted = [...rings].sort((a, b) => b.radius_km - a.radius_km)
|
// signal tier. Use a single layerGroup so zoom/pan stays fast.
|
||||||
const drawn = []
|
const cellLayer = L.layerGroup().addTo(map)
|
||||||
for (const ring of sorted) {
|
const half = step / 2.0
|
||||||
if (!ring.radius_km || ring.radius_km <= 0) continue
|
|
||||||
const circle = L.circle([lat, lon], {
|
const allBounds = []
|
||||||
radius: ring.radius_km * 1000,
|
for (const c of cells) {
|
||||||
color: ring.color,
|
const sw = [c.lat - half, c.lon - half]
|
||||||
weight: 1.5,
|
const ne = [c.lat + half, c.lon + half]
|
||||||
opacity: 0.9,
|
const rect = L.rectangle([sw, ne], {
|
||||||
fillColor: ring.color,
|
color: c.color,
|
||||||
fillOpacity: 0.12
|
weight: 0,
|
||||||
}).addTo(map)
|
fillColor: c.color,
|
||||||
circle.bindTooltip(`${ring.label}: ${ring.rx_dbm} dBm · ${ring.radius_km} km`, {
|
fillOpacity: 0.55,
|
||||||
sticky: true
|
interactive: true
|
||||||
})
|
})
|
||||||
drawn.push(circle)
|
rect.bindTooltip(
|
||||||
|
`${c.label}<br>${c.rx_dbm} dBm<br>${c.distance_km} km · score ${c.score}`,
|
||||||
|
{sticky: true}
|
||||||
|
)
|
||||||
|
rect.addTo(cellLayer)
|
||||||
|
allBounds.push(sw, ne)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Beacon marker on top.
|
||||||
const markerColor = onTheAir ? "#16a34a" : "#94a3b8"
|
const markerColor = onTheAir ? "#16a34a" : "#94a3b8"
|
||||||
const markerFill = onTheAir ? "#22c55e" : "#cbd5e1"
|
const markerFill = onTheAir ? "#22c55e" : "#cbd5e1"
|
||||||
|
|
||||||
L.circleMarker([lat, lon], {
|
L.circleMarker([lat, lon], {
|
||||||
radius: 7,
|
radius: 7,
|
||||||
color: markerColor,
|
color: markerColor,
|
||||||
|
|
@ -50,11 +56,11 @@ export const BeaconMap = {
|
||||||
offset: [0, -10]
|
offset: [0, -10]
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fit to the largest ring, or fall back to a reasonable zoom around the point.
|
// Fit to the rendered cells (initial view already set above).
|
||||||
if (drawn.length > 0) {
|
if (allBounds.length > 0) {
|
||||||
map.fitBounds(drawn[0].getBounds(), {padding: [20, 20]})
|
map.fitBounds(L.latLngBounds(allBounds), {padding: [20, 20]})
|
||||||
} else {
|
|
||||||
map.setView([lat, lon], 9)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setTimeout(() => map.invalidateSize(), 50)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,40 @@
|
||||||
defmodule Microwaveprop.Beacons.RangeEstimate do
|
defmodule Microwaveprop.Beacons.RangeEstimate do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Estimates a beacon's reception range at several signal-strength tiers.
|
Estimates a beacon's reception pattern across the HRRR propagation grid.
|
||||||
|
|
||||||
For each tier we solve a link budget of
|
For every 0.125° grid cell within range of the beacon we solve a link budget
|
||||||
|
|
||||||
Rx_dBm = EIRP_dBm + Rx_gain_dBi - FSPL(d, f) - atm_loss_per_km * d
|
Rx_dBm = EIRP_dBm + Rx_gain_dBi - FSPL(d, f) - atm_loss_per_km * d - score_adj_db
|
||||||
|
|
||||||
for the distance `d` at which the received power equals the tier threshold.
|
where `d` is the great-circle distance between the beacon and the cell,
|
||||||
`EIRP_dBm` comes directly from the beacon's stored `power_mw` — the field
|
`EIRP_dBm` comes from the beacon's stored `power_mw` (the field already
|
||||||
already represents EIRP (TX power × antenna gain). Free-space path loss uses
|
represents EIRP), and `score_adj_db` is derived from the HRRR propagation
|
||||||
the standard formula and atmospheric absorption comes from the band's
|
score at the cell: score 50 = no adjustment, score 100 = -15 dB (ducting
|
||||||
O₂/H₂O coefficients in `BandConfig`.
|
boost), score 0 = +15 dB (absorption / poor conditions). This yields a
|
||||||
|
realistic per-cell reception map instead of idealised concentric circles.
|
||||||
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
|
||||||
alias Microwaveprop.Propagation.BandConfig
|
alias Microwaveprop.Propagation.BandConfig
|
||||||
|
|
||||||
# Signal-strength tiers and their RX sensitivity thresholds (dBm).
|
# Signal-strength tiers and their RX sensitivity thresholds (dBm).
|
||||||
# Colors match the map_live / propagation_map_hook palette.
|
|
||||||
@tiers [
|
@tiers [
|
||||||
%{label: "Excellent", rx_dbm: -100, color: "#00ffa3"},
|
%{label: "Excellent", min_dbm: -100, color: "#00ffa3"},
|
||||||
%{label: "Good", rx_dbm: -115, color: "#7dffd4"},
|
%{label: "Good", min_dbm: -115, color: "#7dffd4"},
|
||||||
%{label: "Marginal", rx_dbm: -125, color: "#ffe566"},
|
%{label: "Marginal", min_dbm: -125, color: "#ffe566"},
|
||||||
%{label: "Weak CW", rx_dbm: -135, color: "#ff9044"},
|
%{label: "Weak CW", min_dbm: -135, color: "#ff9044"},
|
||||||
%{label: "Detection", rx_dbm: -145, color: "#ff4f4f"}
|
%{label: "Detection", min_dbm: -145, color: "#ff4f4f"}
|
||||||
]
|
]
|
||||||
|
|
||||||
# Assume the receiving station is an average amateur microwave station
|
# Minimum received signal to render a cell.
|
||||||
# (dish/horn + low-noise preamp).
|
@detection_floor_dbm -145
|
||||||
|
|
||||||
|
# Assume the receiving station is an average amateur microwave station.
|
||||||
@rx_gain_dbi 20.0
|
@rx_gain_dbi 20.0
|
||||||
|
|
||||||
|
# HRRR grid step (degrees).
|
||||||
|
@grid_step 0.125
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Convert a power in milliwatts to dBm. Returns `-999.9` for non-positive input.
|
Convert a power in milliwatts to dBm. Returns `-999.9` for non-positive input.
|
||||||
"""
|
"""
|
||||||
|
|
@ -53,10 +53,12 @@ defmodule Microwaveprop.Beacons.RangeEstimate do
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Estimate a beacon's reception range.
|
Estimate a beacon's reception footprint as a list of per-HRRR-cell samples.
|
||||||
|
|
||||||
Returns a map with band info, current score, and a list of rings sorted
|
Returns a map with band info, EIRP, the HRRR valid_time, the score at the
|
||||||
weakest-distance first (strongest RX tier → shortest radius).
|
beacon's own grid cell (for display), plus a `cells` list of all grid
|
||||||
|
points within range where the estimated received signal exceeds the
|
||||||
|
detection floor.
|
||||||
"""
|
"""
|
||||||
@spec estimate(Microwaveprop.Beacons.Beacon.t()) :: map()
|
@spec estimate(Microwaveprop.Beacons.Beacon.t()) :: map()
|
||||||
def estimate(beacon) do
|
def estimate(beacon) do
|
||||||
|
|
@ -64,48 +66,86 @@ defmodule Microwaveprop.Beacons.RangeEstimate do
|
||||||
band_config = BandConfig.get(band_mhz)
|
band_config = BandConfig.get(band_mhz)
|
||||||
|
|
||||||
detail = Propagation.point_detail(band_mhz, beacon.lat, beacon.lon)
|
detail = Propagation.point_detail(band_mhz, beacon.lat, beacon.lon)
|
||||||
score = (detail && detail.score) || 50
|
center_score = (detail && detail.score) || 50
|
||||||
valid_time = detail && detail.valid_time
|
valid_time = detail && detail.valid_time
|
||||||
|
|
||||||
f_mhz = beacon.frequency_mhz * 1.0
|
f_mhz = beacon.frequency_mhz * 1.0
|
||||||
eirp_dbm = mw_to_dbm(beacon.power_mw || 0.0)
|
eirp_dbm = mw_to_dbm(beacon.power_mw || 0.0)
|
||||||
|
|
||||||
atm_per_km = atm_loss_per_km(band_config)
|
atm_per_km = atm_loss_per_km(band_config)
|
||||||
score_mult = 0.5 + score / 100.0
|
|
||||||
|
|
||||||
rings =
|
# Pull scores within a bounding box big enough to cover the weakest tier
|
||||||
@tiers
|
# under ideal conditions. Use the band's exceptional range * 1.5 with a
|
||||||
|> Enum.map(fn tier ->
|
# hard floor so small beacons still get a sensible area.
|
||||||
d_phys = solve_range(eirp_dbm, @rx_gain_dbi, tier.rx_dbm, f_mhz, atm_per_km)
|
max_range_km = max((band_config && band_config.exceptional_range_km * 1.5) || 600.0, 150.0)
|
||||||
radius = Float.round(d_phys * score_mult, 1)
|
|
||||||
|
bounds = bbox(beacon.lat, beacon.lon, max_range_km)
|
||||||
|
score_map = fetch_score_map(band_mhz, valid_time, bounds)
|
||||||
|
|
||||||
|
cells =
|
||||||
|
bounds
|
||||||
|
|> grid_points()
|
||||||
|
|> Enum.map(fn {lat, lon} ->
|
||||||
|
key = {Float.round(lat, 3), Float.round(lon, 3)}
|
||||||
|
score = Map.get(score_map, key, 50)
|
||||||
|
d_km = haversine_km(beacon.lat, beacon.lon, lat, lon)
|
||||||
|
rx_dbm = received_dbm(eirp_dbm, f_mhz, atm_per_km, d_km, score)
|
||||||
|
|
||||||
|
{lat, lon, d_km, score, rx_dbm}
|
||||||
|
end)
|
||||||
|
|> Enum.filter(fn {_lat, _lon, _d, _score, rx_dbm} ->
|
||||||
|
rx_dbm >= @detection_floor_dbm
|
||||||
|
end)
|
||||||
|
|> Enum.map(fn {lat, lon, d_km, score, rx_dbm} ->
|
||||||
|
tier = tier_for(rx_dbm)
|
||||||
|
|
||||||
%{
|
%{
|
||||||
|
lat: Float.round(lat, 3),
|
||||||
|
lon: Float.round(lon, 3),
|
||||||
|
distance_km: Float.round(d_km, 1),
|
||||||
|
score: score,
|
||||||
|
rx_dbm: round(rx_dbm),
|
||||||
label: tier.label,
|
label: tier.label,
|
||||||
rx_dbm: tier.rx_dbm,
|
color: tier.color
|
||||||
color: tier.color,
|
|
||||||
radius_km: radius
|
|
||||||
}
|
}
|
||||||
end)
|
end)
|
||||||
|> Enum.filter(fn ring -> ring.radius_km > 0.5 end)
|
|
||||||
|> Enum.sort_by(& &1.radius_km)
|
|
||||||
|
|
||||||
%{
|
%{
|
||||||
beacon_id: beacon.id,
|
beacon_id: beacon.id,
|
||||||
band_mhz: band_mhz,
|
band_mhz: band_mhz,
|
||||||
band_label: band_config && band_config.label,
|
band_label: band_config && band_config.label,
|
||||||
score: score,
|
center_score: center_score,
|
||||||
score_mult: Float.round(score_mult, 2),
|
|
||||||
valid_time: valid_time,
|
valid_time: valid_time,
|
||||||
eirp_dbm: Float.round(eirp_dbm, 1),
|
eirp_dbm: Float.round(eirp_dbm, 1),
|
||||||
atm_per_km: Float.round(atm_per_km, 3),
|
atm_per_km: Float.round(atm_per_km, 3),
|
||||||
rings: rings
|
grid_step: @grid_step,
|
||||||
|
max_range_km: Float.round(max_range_km, 0),
|
||||||
|
cells: cells,
|
||||||
|
tiers: @tiers
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
# Total dB/km atmospheric attenuation from O2 + water vapor. Uses a moderate
|
# --- path loss ------------------------------------------------------------
|
||||||
# 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
|
defp received_dbm(_eirp, _f, _atm, +0.0, _score), do: 999.0
|
||||||
# physics clean.
|
|
||||||
|
defp received_dbm(eirp_dbm, f_mhz, atm_per_km, d_km, score) do
|
||||||
|
fspl = 20.0 * :math.log10(d_km) + 20.0 * :math.log10(f_mhz) + 32.44
|
||||||
|
atm = atm_per_km * d_km
|
||||||
|
# Score 50 baseline, ±0.3 dB per score point away from 50.
|
||||||
|
# score 100 → -15 dB (ducting), score 0 → +15 dB (poor).
|
||||||
|
score_adj = (50 - score) * 0.3
|
||||||
|
eirp_dbm + @rx_gain_dbi - fspl - atm - score_adj
|
||||||
|
end
|
||||||
|
|
||||||
|
defp tier_for(rx_dbm) do
|
||||||
|
Enum.find(@tiers, fn t -> rx_dbm >= t.min_dbm end) || List.last(@tiers)
|
||||||
|
end
|
||||||
|
|
||||||
|
# --- atmosphere -----------------------------------------------------------
|
||||||
|
|
||||||
|
# dB per km atmospheric attenuation from O2 + water vapor. The HRRR score
|
||||||
|
# already captures humidity variability, so we use a fixed absolute humidity
|
||||||
|
# of 10 g/m³ here to keep the physics layer clean.
|
||||||
defp atm_loss_per_km(nil), do: 0.0
|
defp atm_loss_per_km(nil), do: 0.0
|
||||||
|
|
||||||
defp atm_loss_per_km(band_config) do
|
defp atm_loss_per_km(band_config) do
|
||||||
|
|
@ -114,32 +154,61 @@ defmodule Microwaveprop.Beacons.RangeEstimate do
|
||||||
o2 + h2o_coeff * 10.0
|
o2 + h2o_coeff * 10.0
|
||||||
end
|
end
|
||||||
|
|
||||||
# Solve `FSPL(d) + atm_per_km * d = budget_db` for d via bisection.
|
# --- geometry -------------------------------------------------------------
|
||||||
# `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
|
@earth_radius_km 6371.0
|
||||||
budget <= log_f_const ->
|
|
||||||
# Even at 1 km the budget is already negative → ring is effectively 0.
|
|
||||||
0.0
|
|
||||||
|
|
||||||
true ->
|
defp haversine_km(lat1, lon1, lat2, lon2) do
|
||||||
bisect(0.01, 5000.0, budget, log_f_const, atm_per_km, 50)
|
dlat = :math.pi() * (lat2 - lat1) / 180.0
|
||||||
|
dlon = :math.pi() * (lon2 - lon1) / 180.0
|
||||||
|
rlat1 = :math.pi() * lat1 / 180.0
|
||||||
|
rlat2 = :math.pi() * lat2 / 180.0
|
||||||
|
|
||||||
|
a =
|
||||||
|
:math.sin(dlat / 2) * :math.sin(dlat / 2) +
|
||||||
|
:math.cos(rlat1) * :math.cos(rlat2) *
|
||||||
|
:math.sin(dlon / 2) * :math.sin(dlon / 2)
|
||||||
|
|
||||||
|
c = 2.0 * :math.atan2(:math.sqrt(a), :math.sqrt(1.0 - a))
|
||||||
|
@earth_radius_km * c
|
||||||
|
end
|
||||||
|
|
||||||
|
defp bbox(lat, lon, range_km) do
|
||||||
|
dlat = range_km / 111.0
|
||||||
|
dlon = range_km / (111.0 * :math.cos(:math.pi() * lat / 180.0))
|
||||||
|
|
||||||
|
%{
|
||||||
|
"south" => lat - dlat,
|
||||||
|
"north" => lat + dlat,
|
||||||
|
"west" => lon - dlon,
|
||||||
|
"east" => lon + dlon
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp grid_points(%{"south" => s, "north" => n, "west" => w, "east" => e}) do
|
||||||
|
# Snap bounds to the HRRR grid step so cells line up with propagation_scores rows.
|
||||||
|
lat_start = Float.round(s / @grid_step) * @grid_step
|
||||||
|
lon_start = Float.round(w / @grid_step) * @grid_step
|
||||||
|
|
||||||
|
lat_count = max(round((n - lat_start) / @grid_step) + 1, 1)
|
||||||
|
lon_count = max(round((e - lon_start) / @grid_step) + 1, 1)
|
||||||
|
|
||||||
|
for i <- 0..(lat_count - 1),
|
||||||
|
j <- 0..(lon_count - 1) do
|
||||||
|
{Float.round(lat_start + i * @grid_step, 3), Float.round(lon_start + j * @grid_step, 3)}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp bisect(lo, hi, _budget, _log_f, _atm, 0), do: (lo + hi) / 2.0
|
# --- score lookup ---------------------------------------------------------
|
||||||
|
|
||||||
defp bisect(lo, hi, budget, log_f, atm, iters) do
|
defp fetch_score_map(band_mhz, valid_time, bounds) do
|
||||||
mid = (lo + hi) / 2.0
|
scores =
|
||||||
val = 20.0 * :math.log10(mid) + log_f + atm * mid
|
if valid_time do
|
||||||
|
Propagation.scores_at(band_mhz, valid_time, bounds)
|
||||||
|
else
|
||||||
|
Propagation.latest_scores(band_mhz, bounds)
|
||||||
|
end
|
||||||
|
|
||||||
if val > budget do
|
Map.new(scores, fn s -> {{Float.round(s.lat, 3), Float.round(s.lon, 3)}, s.score} end)
|
||||||
bisect(lo, mid, budget, log_f, atm, iters - 1)
|
|
||||||
else
|
|
||||||
bisect(mid, hi, budget, log_f, atm, iters - 1)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -36,38 +36,39 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
|
||||||
data-lon={@beacon.lon}
|
data-lon={@beacon.lon}
|
||||||
data-label={"#{@beacon.callsign} · #{@beacon.frequency_mhz} MHz"}
|
data-label={"#{@beacon.callsign} · #{@beacon.frequency_mhz} MHz"}
|
||||||
data-on-the-air={to_string(@beacon.on_the_air)}
|
data-on-the-air={to_string(@beacon.on_the_air)}
|
||||||
data-rings={Jason.encode!(@estimate.rings)}
|
data-grid-step={@estimate.grid_step}
|
||||||
|
data-cells={Jason.encode!(@estimate.cells)}
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-xs opacity-70 mb-4 flex flex-wrap items-center gap-3">
|
<div class="text-xs opacity-70 mb-2 flex flex-wrap items-center gap-3">
|
||||||
<span>
|
<span>
|
||||||
Band <strong>{@estimate.band_label}</strong> ·
|
Band <strong>{@estimate.band_label}</strong> ·
|
||||||
EIRP <strong>{@estimate.eirp_dbm} dBm</strong> ·
|
EIRP <strong>{@estimate.eirp_dbm} dBm</strong> ·
|
||||||
Atm loss <strong>{@estimate.atm_per_km} dB/km</strong>
|
Atm loss <strong>{@estimate.atm_per_km} dB/km</strong>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
Prop score <strong>{@estimate.score}</strong>
|
Beacon grid score <strong>{@estimate.center_score}</strong>
|
||||||
(<strong>{@estimate.score_mult}×</strong> range)
|
|
||||||
<%= if @estimate.valid_time do %>
|
<%= if @estimate.valid_time do %>
|
||||||
at {Calendar.strftime(@estimate.valid_time, "%Y-%m-%d %H:%M UTC")}
|
at {Calendar.strftime(@estimate.valid_time, "%Y-%m-%d %H:%M UTC")}
|
||||||
<% else %>
|
<% else %>
|
||||||
(no HRRR data — using default 50)
|
(no HRRR data — defaulting cells to 50)
|
||||||
<% end %>
|
<% end %>
|
||||||
|
· <strong>{length(@estimate.cells)}</strong> cells rendered
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3 text-xs mb-4">
|
<div class="flex flex-wrap gap-3 text-xs mb-4">
|
||||||
<%= for ring <- @estimate.rings do %>
|
<%= for tier <- @estimate.tiers do %>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<span
|
<span
|
||||||
class="inline-block w-3 h-3 rounded-full border"
|
class="inline-block w-3 h-3 rounded-sm border"
|
||||||
style={"background-color: #{ring.color}; border-color: #{ring.color}"}
|
style={"background-color: #{tier.color}; border-color: #{tier.color}"}
|
||||||
>
|
>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<strong>{ring.label}</strong>
|
<strong>{tier.label}</strong>
|
||||||
({ring.rx_dbm} dBm) → {ring.radius_km} km
|
(≥ {tier.min_dbm} dBm)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
||||||
|
|
@ -49,48 +49,68 @@ defmodule Microwaveprop.Beacons.RangeEstimateTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "estimate/1" do
|
describe "estimate/1" do
|
||||||
test "returns a map with band info, score, and a list of rings" do
|
test "returns a map with band info, center score, and per-grid cells" do
|
||||||
result = RangeEstimate.estimate(beacon())
|
result = RangeEstimate.estimate(beacon())
|
||||||
|
|
||||||
assert result.band_mhz == 10_000
|
assert result.band_mhz == 10_000
|
||||||
assert is_integer(result.score) or is_float(result.score)
|
assert is_integer(result.center_score) or is_float(result.center_score)
|
||||||
assert is_float(result.eirp_dbm)
|
assert is_float(result.eirp_dbm)
|
||||||
assert is_list(result.rings)
|
assert is_list(result.cells)
|
||||||
assert length(result.rings) > 0
|
assert length(result.cells) > 0
|
||||||
|
assert result.grid_step == 0.125
|
||||||
|
assert is_list(result.tiers)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "rings are ordered strongest-to-weakest with increasing radius" do
|
test "each cell carries lat/lon, distance, rx_dbm, tier label/color" 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())
|
result = RangeEstimate.estimate(beacon())
|
||||||
|
|
||||||
for ring <- result.rings do
|
for cell <- result.cells do
|
||||||
assert is_binary(ring.label)
|
assert is_float(cell.lat)
|
||||||
assert is_binary(ring.color)
|
assert is_float(cell.lon)
|
||||||
assert is_number(ring.rx_dbm)
|
assert is_float(cell.distance_km)
|
||||||
assert is_float(ring.radius_km)
|
assert is_integer(cell.rx_dbm)
|
||||||
assert ring.radius_km >= 0.0
|
assert is_binary(cell.label)
|
||||||
|
assert is_binary(cell.color)
|
||||||
|
# Detection floor enforced
|
||||||
|
assert cell.rx_dbm >= -145
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "a more powerful beacon produces larger rings" do
|
test "cells closer to the beacon have stronger rx_dbm" do
|
||||||
|
result = RangeEstimate.estimate(beacon())
|
||||||
|
|
||||||
|
[closest | _] = Enum.sort_by(result.cells, & &1.distance_km)
|
||||||
|
[farthest | _] = Enum.sort_by(result.cells, &(-&1.distance_km))
|
||||||
|
|
||||||
|
assert closest.rx_dbm > farthest.rx_dbm
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a more powerful beacon produces more cells" do
|
||||||
weak = RangeEstimate.estimate(beacon(power_mw: 10.0))
|
weak = RangeEstimate.estimate(beacon(power_mw: 10.0))
|
||||||
strong = RangeEstimate.estimate(beacon(power_mw: 10_000.0))
|
strong = RangeEstimate.estimate(beacon(power_mw: 10_000.0))
|
||||||
|
assert length(strong.cells) > length(weak.cells)
|
||||||
weakest_weak = List.last(weak.rings).radius_km
|
|
||||||
weakest_strong = List.last(strong.rings).radius_km
|
|
||||||
assert weakest_strong > weakest_weak
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "uses a default score of 50 when no propagation data exists" do
|
test "uses a default score of 50 when no propagation data exists" do
|
||||||
result = RangeEstimate.estimate(beacon())
|
result = RangeEstimate.estimate(beacon())
|
||||||
# No scores in test DB → score defaults
|
# No scores in test DB → center defaults, cells all use 50.
|
||||||
assert result.score == 50
|
assert result.center_score == 50
|
||||||
assert result.valid_time == nil
|
assert result.valid_time == nil
|
||||||
|
|
||||||
|
# All cells should have score 50 when there's no HRRR data.
|
||||||
|
assert Enum.all?(result.cells, fn c -> c.score == 50 end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cells align with the 0.125° HRRR grid" do
|
||||||
|
result = RangeEstimate.estimate(beacon())
|
||||||
|
|
||||||
|
for cell <- result.cells do
|
||||||
|
# lat and lon should both be multiples of 0.125
|
||||||
|
lat_mod = abs(cell.lat / 0.125 - round(cell.lat / 0.125))
|
||||||
|
lon_mod = abs(cell.lon / 0.125 - round(cell.lon / 0.125))
|
||||||
|
assert lat_mod < 0.01
|
||||||
|
assert lon_mod < 0.01
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue