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