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`
|
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) {
|
function buildLoadingHTML(detail) {
|
||||||
const tier = scoreTier(detail.score)
|
const tier = scoreTier(detail.score)
|
||||||
return `<div style="min-width:260px;">
|
return `<div style="min-width:260px;">
|
||||||
|
|
@ -481,6 +555,35 @@ export const PropagationMap = {
|
||||||
this.lastDetail = merged
|
this.lastDetail = merged
|
||||||
this.detailPanel.innerHTML = buildPopupHTML(merged, this.viewshedLoading)
|
this.detailPanel.innerHTML = buildPopupHTML(merged, this.viewshedLoading)
|
||||||
this.detailPanel.style.display = "block"
|
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
|
end
|
||||||
|
|
||||||
defp score_grid_point_with_data(hrrr_profile, valid_time, temp_c, dewpoint_c, derived, latitude, longitude) do
|
defp score_grid_point_with_data(hrrr_profile, valid_time, temp_c, dewpoint_c, derived, latitude, longitude) do
|
||||||
case ml_model() do
|
# Algorithm is the primary scorer — always used for the map score.
|
||||||
nil ->
|
# 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)
|
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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp score_with_algorithm(hrrr_profile, valid_time, temp_c, dewpoint_c, derived, _latitude, longitude) do
|
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)
|
||||||
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 """
|
@doc """
|
||||||
Upsert propagation scores in batches within a transaction so readers see all-or-nothing.
|
Upsert propagation scores in batches within a transaction so readers see all-or-nothing.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -158,15 +158,8 @@ defmodule Microwaveprop.Workers.PropagationGridWorker do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp compute_scores(grid_data, valid_time) do
|
defp compute_scores(grid_data, valid_time) do
|
||||||
case Propagation.ml_model() do
|
# Algorithm is the primary scorer. ML score stored in factors for comparison.
|
||||||
nil ->
|
compute_scores_algorithm(grid_data, valid_time)
|
||||||
# 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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp compute_scores_algorithm(grid_data, valid_time) do
|
defp compute_scores_algorithm(grid_data, valid_time) do
|
||||||
|
|
@ -187,100 +180,4 @@ defmodule Microwaveprop.Workers.PropagationGridWorker do
|
||||||
{:exit, _reason} -> []
|
{:exit, _reason} -> []
|
||||||
end)
|
end)
|
||||||
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
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue