diff --git a/assets/js/propagation_map_hook.js b/assets/js/propagation_map_hook.js
index e315b2ef..f1965ede 100644
--- a/assets/js/propagation_map_hook.js
+++ b/assets/js/propagation_map_hook.js
@@ -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 `
@@ -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)
+ }
+ }
}
})
diff --git a/lib/microwaveprop/propagation.ex b/lib/microwaveprop/propagation.ex
index 16809ee8..982d74b4 100644
--- a/lib/microwaveprop/propagation.ex
+++ b/lib/microwaveprop/propagation.ex
@@ -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.
diff --git a/lib/microwaveprop/workers/propagation_grid_worker.ex b/lib/microwaveprop/workers/propagation_grid_worker.ex
index 92847ec1..248d7313 100644
--- a/lib/microwaveprop/workers/propagation_grid_worker.ex
+++ b/lib/microwaveprop/workers/propagation_grid_worker.ex
@@ -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