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:
parent
02cb4fd67b
commit
8f4f491a5e
3 changed files with 108 additions and 144 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue