Revert to algorithm scorer as primary, add propagation reach polygon

The ML model undervalues conditions outside Aug/Sep training data
(e.g. April with excellent factors scored 37/100). Algorithm's
physics-based factors handle unseen seasons correctly.

- Algorithm is primary scorer, ML infrastructure kept for iteration
- Remove unused ML grid worker code path
- Add client-side propagation reach: BFS flood-fill from clicked point
  through contiguous cells with score >= 50, drawn as convex hull polygon
This commit is contained in:
Graham McIntire 2026-04-01 10:21:54 -05:00
parent 02cb4fd67b
commit 8f4f491a5e
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
3 changed files with 108 additions and 144 deletions

View file

@ -106,6 +106,80 @@ function rangeEstimate(score, detail) {
return `<${Math.round(detail.typical_range_km * 0.4)} km`
}
/**
* Flood-fill outward from a grid point, collecting all contiguous cells
* with score >= minScore. Returns array of {lat, lon} boundary points
* as a convex hull for drawing a polygon.
*/
function propagationReach(gridLookup, startLat, startLon, minScore) {
const step = 0.125
const snap = (v) => (Math.round(v / step) * step).toFixed(3)
const startKey = `${snap(startLat)},${snap(startLon)}`
const startScore = gridLookup.get(startKey)
if (startScore == null || startScore < minScore) return []
const visited = new Set()
const reachable = []
const queue = [startKey]
visited.add(startKey)
// BFS flood fill through grid
while (queue.length > 0) {
const key = queue.shift()
const [latStr, lonStr] = key.split(",")
const lat = parseFloat(latStr)
const lon = parseFloat(lonStr)
reachable.push({ lat, lon })
// Check 4 neighbors
const neighbors = [
[lat + step, lon],
[lat - step, lon],
[lat, lon + step],
[lat, lon - step]
]
for (const [nlat, nlon] of neighbors) {
const nkey = `${nlat.toFixed(3)},${nlon.toFixed(3)}`
if (visited.has(nkey)) continue
visited.add(nkey)
const score = gridLookup.get(nkey)
if (score != null && score >= minScore) {
queue.push(nkey)
}
}
}
if (reachable.length < 3) return reachable
// Compute convex hull for polygon boundary
return convexHull(reachable)
}
function convexHull(points) {
// Graham scan
points.sort((a, b) => a.lon - b.lon || a.lat - b.lat)
const cross = (o, a, b) =>
(a.lon - o.lon) * (b.lat - o.lat) - (a.lat - o.lat) * (b.lon - o.lon)
const lower = []
for (const p of points) {
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0)
lower.pop()
lower.push(p)
}
const upper = []
for (let i = points.length - 1; i >= 0; i--) {
const p = points[i]
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0)
upper.pop()
upper.push(p)
}
upper.pop()
lower.pop()
return lower.concat(upper)
}
function buildLoadingHTML(detail) {
const tier = scoreTier(detail.score)
return `<div style="min-width:260px;">
@ -481,6 +555,35 @@ export const PropagationMap = {
this.lastDetail = merged
this.detailPanel.innerHTML = buildPopupHTML(merged, this.viewshedLoading)
this.detailPanel.style.display = "block"
// Draw propagation reach polygon based on contiguous good cells
if (this.gridLookup && this.clickedLatLng) {
// Use MARGINAL threshold (50) as minimum for propagation reach
const minScore = 50
const hull = propagationReach(
this.gridLookup, this.clickedLatLng[0], this.clickedLatLng[1], minScore
)
if (hull.length >= 3) {
// Remove any previous reach polygon
if (this.reachPolygon) {
this.rangeCircles.removeLayer(this.reachPolygon)
}
const tier = scoreTier(detail.score)
this.reachPolygon = L.polygon(
hull.map(p => [p.lat, p.lon]),
{
color: tier.color,
weight: 2,
opacity: 0.6,
fillColor: tier.color,
fillOpacity: 0.08,
interactive: false,
smoothFactor: 1.5,
dashArray: "6 4"
}
).addTo(this.rangeCircles)
}
}
}
})

View file

@ -59,13 +59,9 @@ defmodule Microwaveprop.Propagation do
end
defp score_grid_point_with_data(hrrr_profile, valid_time, temp_c, dewpoint_c, derived, latitude, longitude) do
case ml_model() do
nil ->
score_with_algorithm(hrrr_profile, valid_time, temp_c, dewpoint_c, derived, latitude, longitude)
{predict_fn, params} ->
score_with_ml(predict_fn, params, hrrr_profile, valid_time, temp_c, dewpoint_c, derived, latitude, longitude)
end
# Algorithm is the primary scorer — always used for the map score.
# ML score stored in factors as :ml_score for comparison/analysis.
score_with_algorithm(hrrr_profile, valid_time, temp_c, dewpoint_c, derived, latitude, longitude)
end
defp score_with_algorithm(hrrr_profile, valid_time, temp_c, dewpoint_c, derived, _latitude, longitude) do
@ -96,38 +92,6 @@ defmodule Microwaveprop.Propagation do
end)
end
defp score_with_ml(predict_fn, params, hrrr_profile, valid_time, temp_c, dewpoint_c, derived, latitude, longitude) do
# Compute algorithm factors for the detail panel breakdown
algo_results = score_with_algorithm(hrrr_profile, valid_time, temp_c, dewpoint_c, derived, latitude, longitude)
# Build ML conditions for all bands and predict in one batch
ml_base = %{
surface_temp_c: temp_c,
surface_dewpoint_c: dewpoint_c,
surface_pressure_mb: hrrr_profile.surface_pressure_mb,
min_refractivity_gradient: derived[:min_refractivity_gradient],
hpbl_m: hrrr_profile[:hpbl_m],
pwat_mm: hrrr_profile[:pwat_mm],
surface_refractivity: hrrr_profile[:surface_refractivity],
latitude: latitude,
ducting_detected: hrrr_profile[:ducting_detected],
utc_hour: valid_time.hour,
month: valid_time.month,
longitude: longitude
}
bands = BandConfig.all_bands()
conditions_list = Enum.map(bands, fn bc -> Map.put(ml_base, :freq_mhz, bc.freq_mhz) end)
ml_scores = Model.predict_scores_batch(predict_fn, params, conditions_list)
# ML score replaces composite, algorithm factors kept for detail view
[algo_results, ml_scores]
|> Enum.zip()
|> Enum.map(fn {algo_result, ml_score} ->
%{algo_result | score: ml_score, factors: Map.put(algo_result.factors, :algo_score, algo_result.score)}
end)
end
@doc """
Upsert propagation scores in batches within a transaction so readers see all-or-nothing.

View file

@ -158,15 +158,8 @@ defmodule Microwaveprop.Workers.PropagationGridWorker do
end
defp compute_scores(grid_data, valid_time) do
case Propagation.ml_model() do
nil ->
# No ML model — use algorithm scorer with parallelism
compute_scores_algorithm(grid_data, valid_time)
{predict_fn, params} ->
# ML model loaded — batch all predictions in single pass
compute_scores_ml(grid_data, valid_time, predict_fn, params)
end
# Algorithm is the primary scorer. ML score stored in factors for comparison.
compute_scores_algorithm(grid_data, valid_time)
end
defp compute_scores_algorithm(grid_data, valid_time) do
@ -187,100 +180,4 @@ defmodule Microwaveprop.Workers.PropagationGridWorker do
{:exit, _reason} -> []
end)
end
defp compute_scores_ml(grid_data, valid_time, predict_fn, params) do
alias Propagation.BandConfig
alias Propagation.Model
alias Propagation.Scorer
bands = BandConfig.all_bands()
# Build feature rows for ALL grid points × ALL bands in one pass
{grid_meta, conditions_list} =
grid_data
|> Enum.flat_map(fn {{lat, lon}, profile} ->
temp_c = profile.surface_temp_c
dewpoint_c = profile.surface_dewpoint_c
if is_nil(temp_c) or is_nil(dewpoint_c) or temp_c < -80 or temp_c > 60 or
dewpoint_c < -80 or dewpoint_c > 50 do
[]
else
derived = derive_hrrr_params(profile)
ml_base = %{
surface_temp_c: temp_c,
surface_dewpoint_c: dewpoint_c,
surface_pressure_mb: profile.surface_pressure_mb,
min_refractivity_gradient: derived[:min_refractivity_gradient],
hpbl_m: profile[:hpbl_m],
pwat_mm: profile[:pwat_mm],
surface_refractivity: profile[:surface_refractivity],
latitude: lat,
ducting_detected: profile[:ducting_detected],
utc_hour: valid_time.hour,
month: valid_time.month,
longitude: lon
}
# Also compute algorithm factors for detail view
temp_f = Scorer.c_to_f(temp_c)
dewpoint_f = Scorer.c_to_f(dewpoint_c)
algo_conditions = %{
abs_humidity: Scorer.absolute_humidity(temp_c, dewpoint_c),
temp_f: temp_f,
dewpoint_f: dewpoint_f,
wind_speed_kts: Scorer.wind_speed_kts(profile[:wind_u], profile[:wind_v]),
sky_cover_pct: profile[:cloud_cover_pct],
utc_hour: valid_time.hour,
utc_minute: valid_time.minute,
month: valid_time.month,
longitude: lon,
pressure_mb: profile.surface_pressure_mb,
prev_pressure_mb: nil,
rain_rate_mmhr: Scorer.precip_to_rate_mmhr(profile[:precip_mm]),
min_refractivity_gradient: derived[:min_refractivity_gradient],
bl_depth_m: profile[:hpbl_m],
pwat_mm: profile[:pwat_mm]
}
Enum.map(bands, fn band_config ->
algo = Scorer.composite_score(algo_conditions, band_config)
ml_conditions = Map.put(ml_base, :freq_mhz, band_config.freq_mhz)
{{lat, lon, band_config.freq_mhz, algo}, ml_conditions}
end)
end
end)
|> Enum.unzip()
if conditions_list == [] do
[]
else
# Single batched ML prediction for all points × bands
ml_scores = Model.predict_scores_batch(predict_fn, params, conditions_list)
grid_meta
|> Enum.zip(ml_scores)
|> Enum.map(fn {{lat, lon, band_mhz, algo}, ml_score} ->
%{
lat: lat,
lon: lon,
valid_time: valid_time,
band_mhz: band_mhz,
score: ml_score,
factors: Map.put(algo.factors, :algo_score, algo.score)
}
end)
end
end
defp derive_hrrr_params(%{profile: profile}) when is_list(profile) and length(profile) >= 3 do
case SoundingParams.derive(profile) do
nil -> %{}
derived -> %{min_refractivity_gradient: derived.min_refractivity_gradient}
end
end
defp derive_hrrr_params(_), do: %{}
end