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 label = this.el.dataset.label || ""
|
||||
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, {
|
||||
zoomControl: true,
|
||||
attributionControl: false,
|
||||
scrollWheelZoom: false
|
||||
})
|
||||
}).setView([lat, lon], 9)
|
||||
|
||||
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
|
||||
// Draw each HRRR grid cell as a filled rectangle colored by its received
|
||||
// signal tier. Use a single layerGroup so zoom/pan stays fast.
|
||||
const cellLayer = L.layerGroup().addTo(map)
|
||||
const half = step / 2.0
|
||||
|
||||
const allBounds = []
|
||||
for (const c of cells) {
|
||||
const sw = [c.lat - half, c.lon - half]
|
||||
const ne = [c.lat + half, c.lon + half]
|
||||
const rect = L.rectangle([sw, ne], {
|
||||
color: c.color,
|
||||
weight: 0,
|
||||
fillColor: c.color,
|
||||
fillOpacity: 0.55,
|
||||
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 markerFill = onTheAir ? "#22c55e" : "#cbd5e1"
|
||||
|
||||
L.circleMarker([lat, lon], {
|
||||
radius: 7,
|
||||
color: markerColor,
|
||||
|
|
@ -50,11 +56,11 @@ export const BeaconMap = {
|
|||
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)
|
||||
// Fit to the rendered cells (initial view already set above).
|
||||
if (allBounds.length > 0) {
|
||||
map.fitBounds(L.latLngBounds(allBounds), {padding: [20, 20]})
|
||||
}
|
||||
|
||||
setTimeout(() => map.invalidateSize(), 50)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,40 +1,40 @@
|
|||
defmodule Microwaveprop.Beacons.RangeEstimate do
|
||||
@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.
|
||||
`EIRP_dBm` comes directly from the beacon's stored `power_mw` — the field
|
||||
already represents EIRP (TX power × antenna gain). 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.
|
||||
where `d` is the great-circle distance between the beacon and the cell,
|
||||
`EIRP_dBm` comes from the beacon's stored `power_mw` (the field already
|
||||
represents EIRP), and `score_adj_db` is derived from the HRRR propagation
|
||||
score at the cell: score 50 = no adjustment, score 100 = -15 dB (ducting
|
||||
boost), score 0 = +15 dB (absorption / poor conditions). This yields a
|
||||
realistic per-cell reception map instead of idealised concentric circles.
|
||||
"""
|
||||
|
||||
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"}
|
||||
%{label: "Excellent", min_dbm: -100, color: "#00ffa3"},
|
||||
%{label: "Good", min_dbm: -115, color: "#7dffd4"},
|
||||
%{label: "Marginal", min_dbm: -125, color: "#ffe566"},
|
||||
%{label: "Weak CW", min_dbm: -135, color: "#ff9044"},
|
||||
%{label: "Detection", min_dbm: -145, color: "#ff4f4f"}
|
||||
]
|
||||
|
||||
# Assume the receiving station is an average amateur microwave station
|
||||
# (dish/horn + low-noise preamp).
|
||||
# Minimum received signal to render a cell.
|
||||
@detection_floor_dbm -145
|
||||
|
||||
# Assume the receiving station is an average amateur microwave station.
|
||||
@rx_gain_dbi 20.0
|
||||
|
||||
# HRRR grid step (degrees).
|
||||
@grid_step 0.125
|
||||
|
||||
@doc """
|
||||
Convert a power in milliwatts to dBm. Returns `-999.9` for non-positive input.
|
||||
"""
|
||||
|
|
@ -53,10 +53,12 @@ defmodule Microwaveprop.Beacons.RangeEstimate do
|
|||
end
|
||||
|
||||
@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
|
||||
weakest-distance first (strongest RX tier → shortest radius).
|
||||
Returns a map with band info, EIRP, the HRRR valid_time, the score at the
|
||||
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()
|
||||
def estimate(beacon) do
|
||||
|
|
@ -64,48 +66,86 @@ defmodule Microwaveprop.Beacons.RangeEstimate do
|
|||
band_config = BandConfig.get(band_mhz)
|
||||
|
||||
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
|
||||
|
||||
f_mhz = beacon.frequency_mhz * 1.0
|
||||
eirp_dbm = mw_to_dbm(beacon.power_mw || 0.0)
|
||||
|
||||
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)
|
||||
# Pull scores within a bounding box big enough to cover the weakest tier
|
||||
# under ideal conditions. Use the band's exceptional range * 1.5 with a
|
||||
# hard floor so small beacons still get a sensible area.
|
||||
max_range_km = max((band_config && band_config.exceptional_range_km * 1.5) || 600.0, 150.0)
|
||||
|
||||
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,
|
||||
rx_dbm: tier.rx_dbm,
|
||||
color: tier.color,
|
||||
radius_km: radius
|
||||
color: tier.color
|
||||
}
|
||||
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),
|
||||
center_score: center_score,
|
||||
valid_time: valid_time,
|
||||
eirp_dbm: Float.round(eirp_dbm, 1),
|
||||
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
|
||||
|
||||
# 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.
|
||||
# --- path loss ------------------------------------------------------------
|
||||
|
||||
defp received_dbm(_eirp, _f, _atm, +0.0, _score), do: 999.0
|
||||
|
||||
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(band_config) do
|
||||
|
|
@ -114,32 +154,61 @@ defmodule Microwaveprop.Beacons.RangeEstimate do
|
|||
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
|
||||
# --- geometry -------------------------------------------------------------
|
||||
|
||||
cond do
|
||||
budget <= log_f_const ->
|
||||
# Even at 1 km the budget is already negative → ring is effectively 0.
|
||||
0.0
|
||||
@earth_radius_km 6371.0
|
||||
|
||||
true ->
|
||||
bisect(0.01, 5000.0, budget, log_f_const, atm_per_km, 50)
|
||||
defp haversine_km(lat1, lon1, lat2, lon2) do
|
||||
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
|
||||
|
||||
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
|
||||
mid = (lo + hi) / 2.0
|
||||
val = 20.0 * :math.log10(mid) + log_f + atm * mid
|
||||
defp fetch_score_map(band_mhz, valid_time, bounds) do
|
||||
scores =
|
||||
if valid_time do
|
||||
Propagation.scores_at(band_mhz, valid_time, bounds)
|
||||
else
|
||||
Propagation.latest_scores(band_mhz, bounds)
|
||||
end
|
||||
|
||||
if val > budget do
|
||||
bisect(lo, mid, budget, log_f, atm, iters - 1)
|
||||
else
|
||||
bisect(mid, hi, budget, log_f, atm, iters - 1)
|
||||
end
|
||||
Map.new(scores, fn s -> {{Float.round(s.lat, 3), Float.round(s.lon, 3)}, s.score} end)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -36,38 +36,39 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
|
|||
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)}
|
||||
data-grid-step={@estimate.grid_step}
|
||||
data-cells={Jason.encode!(@estimate.cells)}
|
||||
>
|
||||
</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>
|
||||
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)
|
||||
Beacon grid score <strong>{@estimate.center_score}</strong>
|
||||
<%= if @estimate.valid_time do %>
|
||||
at {Calendar.strftime(@estimate.valid_time, "%Y-%m-%d %H:%M UTC")}
|
||||
<% else %>
|
||||
(no HRRR data — using default 50)
|
||||
(no HRRR data — defaulting cells to 50)
|
||||
<% end %>
|
||||
· <strong>{length(@estimate.cells)}</strong> cells rendered
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<span
|
||||
class="inline-block w-3 h-3 rounded-full border"
|
||||
style={"background-color: #{ring.color}; border-color: #{ring.color}"}
|
||||
class="inline-block w-3 h-3 rounded-sm border"
|
||||
style={"background-color: #{tier.color}; border-color: #{tier.color}"}
|
||||
>
|
||||
</span>
|
||||
<span>
|
||||
<strong>{ring.label}</strong>
|
||||
({ring.rx_dbm} dBm) → {ring.radius_km} km
|
||||
<strong>{tier.label}</strong>
|
||||
(≥ {tier.min_dbm} dBm)
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -49,48 +49,68 @@ defmodule Microwaveprop.Beacons.RangeEstimateTest do
|
|||
end
|
||||
|
||||
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())
|
||||
|
||||
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_list(result.rings)
|
||||
assert length(result.rings) > 0
|
||||
assert is_list(result.cells)
|
||||
assert length(result.cells) > 0
|
||||
assert result.grid_step == 0.125
|
||||
assert is_list(result.tiers)
|
||||
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
|
||||
test "each cell carries lat/lon, distance, rx_dbm, tier label/color" 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
|
||||
for cell <- result.cells do
|
||||
assert is_float(cell.lat)
|
||||
assert is_float(cell.lon)
|
||||
assert is_float(cell.distance_km)
|
||||
assert is_integer(cell.rx_dbm)
|
||||
assert is_binary(cell.label)
|
||||
assert is_binary(cell.color)
|
||||
# Detection floor enforced
|
||||
assert cell.rx_dbm >= -145
|
||||
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))
|
||||
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
|
||||
assert length(strong.cells) > length(weak.cells)
|
||||
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
|
||||
# No scores in test DB → center defaults, cells all use 50.
|
||||
assert result.center_score == 50
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue