diff --git a/lib/microwaveprop/propagation.ex b/lib/microwaveprop/propagation.ex index 69e41e01..5f75541e 100644 --- a/lib/microwaveprop/propagation.ex +++ b/lib/microwaveprop/propagation.ex @@ -96,7 +96,8 @@ defmodule Microwaveprop.Propagation do min_refractivity_gradient: hrrr_profile[:native_min_gradient] || derived[:min_refractivity_gradient], bl_depth_m: hrrr_profile[:hpbl_m], pwat_mm: hrrr_profile[:pwat_mm], - best_duct_band_ghz: hrrr_profile[:best_duct_freq_ghz] || hrrr_profile[:best_duct_band_ghz] + best_duct_band_ghz: hrrr_profile[:best_duct_freq_ghz] || hrrr_profile[:best_duct_band_ghz], + bulk_richardson: hrrr_profile[:bulk_richardson] } duct_info = diff --git a/lib/microwaveprop/propagation/scorer.ex b/lib/microwaveprop/propagation/scorer.ex index 41e87d23..8e8641f5 100644 --- a/lib/microwaveprop/propagation/scorer.ex +++ b/lib/microwaveprop/propagation/scorer.ex @@ -202,9 +202,32 @@ defmodule Microwaveprop.Propagation.Scorer do band. """ @spec score_refractivity(number() | nil, number() | nil, number() | nil, map()) :: integer() - def score_refractivity(nil, _bl_depth_m, _best_duct_band_ghz, _band_config), do: 50 + def score_refractivity(min_gradient, bl_depth_m, best_duct_band_ghz, band_config) do + score_refractivity(min_gradient, bl_depth_m, best_duct_band_ghz, nil, band_config) + end - def score_refractivity(min_gradient, bl_depth_m, best_duct_band_ghz, %{freq_mhz: freq_mhz, humidity_effect: effect}) do + @doc """ + Scores refractivity with optional native-profile duct info, gated by + Bulk Richardson number. + + `bulk_richardson` comes from `hrrr_native_profiles` and measures + dynamic stability (lower = more stable). Native duct cells average + 8.7-18.4 for ducting vs 38.3 for non-ducting, so a low + `best_duct_band_ghz` reading under turbulent conditions + (Richardson ≥ 25) is likely a duct that would be broken up by + mechanical mixing — in that case the 1.15× boost is *not* applied. + + `nil` Richardson falls back to the 4-arity behaviour (boost applies + whenever the duct band supports the target frequency). + """ + @spec score_refractivity(number() | nil, number() | nil, number() | nil, number() | nil, map()) :: + integer() + def score_refractivity(nil, _bl_depth_m, _best_duct_band_ghz, _bulk_richardson, _band_config), do: 50 + + def score_refractivity(min_gradient, bl_depth_m, best_duct_band_ghz, bulk_richardson, %{ + freq_mhz: freq_mhz, + humidity_effect: effect + }) do thresholds = BandConfig.refractivity_thresholds() base = @@ -219,7 +242,7 @@ defmodule Microwaveprop.Propagation.Scorer do base |> apply_hpbl_multiplier(bl_depth_m) - |> apply_native_duct_boost(best_duct_band_ghz, freq_mhz) + |> apply_native_duct_boost(best_duct_band_ghz, bulk_richardson, freq_mhz) end # HPBL multiplier — applied to the base refractivity score. Calibrated to the @@ -234,22 +257,32 @@ defmodule Microwaveprop.Propagation.Scorer do defp apply_hpbl_multiplier(base, _hpbl), do: clamp_score(base * 0.78) # Native-profile duct boost — 1.15× when the cell's best duct supports the - # target band's frequency. HRRR pressure-level gradients systematically - # under-read thin ducts (~250m vertical spacing vs 50 native hybrid levels), - # so when hrrr_native_profiles reports a duct band at or above the target - # frequency, the base gradient score is under-estimating the real channel. - defp apply_native_duct_boost(base, nil, _freq_mhz), do: base + # target band's frequency AND the Bulk Richardson number is in the stable + # regime (< 25). HRRR pressure-level gradients systematically under-read + # thin ducts, so when hrrr_native_profiles reports a duct band at or above + # the target frequency under stable conditions, the base gradient score is + # under-estimating the real channel. A duct reading with high Richardson + # is evidence of mechanical mixing that would shred the trapping layer — + # don't boost in that case. + @bulk_richardson_stable_max 25.0 - defp apply_native_duct_boost(base, best_duct_band_ghz, freq_mhz) do + defp apply_native_duct_boost(base, nil, _bulk_richardson, _freq_mhz), do: base + + defp apply_native_duct_boost(base, best_duct_band_ghz, bulk_richardson, freq_mhz) do target_ghz = freq_mhz / 1_000 - if best_duct_band_ghz >= target_ghz do + if best_duct_band_ghz >= target_ghz and stable_atmosphere?(bulk_richardson) do clamp_score(base * 1.15) else base end end + # nil Richardson is treated as stable (no information → don't penalise). + # Explicit Richardson ≥ 25 gates the boost. + defp stable_atmosphere?(nil), do: true + defp stable_atmosphere?(r) when is_number(r), do: r < @bulk_richardson_stable_max + @doc """ Inverse-sensor boost from commercial LOS link degradation. @@ -477,6 +510,7 @@ defmodule Microwaveprop.Propagation.Scorer do conditions.min_refractivity_gradient, conditions.bl_depth_m, conditions[:best_duct_band_ghz], + conditions[:bulk_richardson], band_config ), sky: score_sky(conditions.sky_cover_pct), diff --git a/lib/microwaveprop/weather.ex b/lib/microwaveprop/weather.ex index c5f62701..fb1f4431 100644 --- a/lib/microwaveprop/weather.ex +++ b/lib/microwaveprop/weather.ex @@ -796,42 +796,61 @@ defmodule Microwaveprop.Weather do end @doc """ - Returns the nearest `hrrr_native_profiles.best_duct_band_ghz` to - (`lat`, `lon`) at `timestamp`, or nil if no native profile covers - the cell at that time. Uses the same ±0.07° / ±1h tolerance as - HRRR pressure-level lookups. - - The native product resolves thin trapping layers the pressure-level - product misses (see algo.md Part 2c), so this value is what the - refractivity scoring should consult when deciding whether to apply - the 1.15× native-duct boost. + Returns just `hrrr_native_profiles.best_duct_band_ghz` near the + given cell. Convenience wrapper around `nearest_native_duct_info/3` + for callers that don't need Richardson. """ @spec nearest_native_duct_ghz(float(), float(), DateTime.t()) :: float() | nil def nearest_native_duct_ghz(lat, lon, %DateTime{} = timestamp) do + case nearest_native_duct_info(lat, lon, timestamp) do + {:ok, %{best_duct_band_ghz: ghz}} -> ghz + _ -> nil + end + end + + @doc """ + Returns `{:ok, %{best_duct_band_ghz: ghz, bulk_richardson: r}}` for + the nearest `hrrr_native_profiles` cell to (`lat`, `lon`) at + `timestamp` within ±0.07° / ±1h, or `{:error, :not_found}`. + + Richardson gates the 1.15× refractivity boost in `Scorer` — a duct + band reading under turbulent conditions (high Richardson) is likely + to be mixed out before it supports the target path. The pair is + cheap to fetch together, so callers that score a cell prefer this + over the bare `nearest_native_duct_ghz/3`. + """ + @spec nearest_native_duct_info(float(), float(), DateTime.t()) :: + {:ok, %{best_duct_band_ghz: float() | nil, bulk_richardson: float() | nil}} + | {:error, :not_found} + def nearest_native_duct_info(lat, lon, %DateTime{} = timestamp) do dlat = 0.07 dlon = 0.07 time_start = DateTime.add(timestamp, -3600, :second) time_end = DateTime.add(timestamp, 3600, :second) - Repo.one( - from(h in HrrrNativeProfile, - where: - h.lat >= ^(lat - dlat) and h.lat <= ^(lat + dlat) and h.lon >= ^(lon - dlon) and h.lon <= ^(lon + dlon) and - h.valid_time >= ^time_start and h.valid_time <= ^time_end, - order_by: - fragment( - "ABS(? - ?) + ABS(? - ?) + ABS(EXTRACT(EPOCH FROM ? - ?))", - h.lat, - ^lat, - h.lon, - ^lon, - h.valid_time, - ^timestamp - ), - limit: 1, - select: h.best_duct_band_ghz - ) + from(h in HrrrNativeProfile, + where: + h.lat >= ^(lat - dlat) and h.lat <= ^(lat + dlat) and + h.lon >= ^(lon - dlon) and h.lon <= ^(lon + dlon) and + h.valid_time >= ^time_start and h.valid_time <= ^time_end, + order_by: + fragment( + "ABS(? - ?) + ABS(? - ?) + ABS(EXTRACT(EPOCH FROM ? - ?))", + h.lat, + ^lat, + h.lon, + ^lon, + h.valid_time, + ^timestamp + ), + limit: 1, + select: %{best_duct_band_ghz: h.best_duct_band_ghz, bulk_richardson: h.bulk_richardson} ) + |> Repo.one() + |> case do + nil -> {:error, :not_found} + row -> {:ok, row} + end end @doc """ diff --git a/lib/microwaveprop_web/live/path_live.ex b/lib/microwaveprop_web/live/path_live.ex index 32cc29ea..e762fbde 100644 --- a/lib/microwaveprop_web/live/path_live.ex +++ b/lib/microwaveprop_web/live/path_live.ex @@ -271,15 +271,19 @@ defmodule MicrowavepropWeb.PathLive do # surface ducts that sounding data resolves. sounding = build_sounding_readout(midlat, midlon, now) - # Native-resolution HRRR duct band near the midpoint. Resolves - # thin trapping layers HRRR's 13 pressure levels miss; feeds the - # 1.15× boost in Scorer.score_refractivity when the duct supports - # the target band. - native_duct_ghz = Weather.nearest_native_duct_ghz(midlat, midlon, now) + # Native-resolution HRRR duct band + Bulk Richardson near the + # midpoint. Resolves thin trapping layers HRRR's 13 pressure levels + # miss; feeds the 1.15× boost in Scorer.score_refractivity, gated on + # Richardson so turbulent duct readings don't inflate the score. + native_duct = + case Weather.nearest_native_duct_info(midlat, midlon, now) do + {:ok, info} -> info + {:error, _} -> %{best_duct_band_ghz: nil, bulk_richardson: nil} + end # Build conditions and score {conditions, scoring} = - build_scoring(hrrr_profiles, src, dst, now, band_config, native_duct_ghz) + build_scoring(hrrr_profiles, src, dst, now, band_config, native_duct) # Loss and power budgets loss_budget = compute_loss_budget(dist_km, freq_ghz, band_config, terrain_result, conditions) @@ -443,9 +447,9 @@ defmodule MicrowavepropWeb.PathLive do end end - defp build_scoring([], _src, _dst, _now, _band_config, _native_duct_ghz), do: {nil, nil} + defp build_scoring([], _src, _dst, _now, _band_config, _native_duct), do: {nil, nil} - defp build_scoring(profiles, src, dst, now, band_config, native_duct_ghz) do + defp build_scoring(profiles, src, dst, now, band_config, native_duct) do temps = profiles |> Enum.map(& &1.surface_temp_c) |> Enum.reject(&is_nil/1) dewpoints = profiles |> Enum.map(& &1.surface_dewpoint_c) |> Enum.reject(&is_nil/1) @@ -479,7 +483,8 @@ defmodule MicrowavepropWeb.PathLive do min_refractivity_gradient: if(gradients != [], do: Enum.min(gradients)), bl_depth_m: if(bl_depths != [], do: Enum.sum(bl_depths) / length(bl_depths)), pwat_mm: if(pwats != [], do: Enum.sum(pwats) / length(pwats)), - best_duct_band_ghz: native_duct_ghz + best_duct_band_ghz: native_duct[:best_duct_band_ghz], + bulk_richardson: native_duct[:bulk_richardson] } scoring = Scorer.composite_score(conditions, band_config) diff --git a/test/microwaveprop/propagation/scorer_test.exs b/test/microwaveprop/propagation/scorer_test.exs index ba0e6820..eeaa7981 100644 --- a/test/microwaveprop/propagation/scorer_test.exs +++ b/test/microwaveprop/propagation/scorer_test.exs @@ -361,6 +361,39 @@ defmodule Microwaveprop.Propagation.ScorerTest do end end + describe "score_refractivity/5 with bulk-Richardson gating" do + # 5-arity variant: the native-duct boost only applies when Bulk + # Richardson number is in the stable regime (< 25). Native duct + # cells average 8.7–18.4 for ducting vs 38.3 for non-ducting, so a + # low best_duct_band_ghz reading with high Richardson is likely a + # duct that would be shredded by mechanical mixing — we should not + # boost it (Part 2c of algo.md). + + test "stable Richardson (<25) + matching duct band → boost applies" do + boosted = Scorer.score_refractivity(-80, 800, 15.0, 12.0, @band_10g) + plain = Scorer.score_refractivity(-80, 800, nil, nil, @band_10g) + assert boosted > plain + end + + test "turbulent Richardson (≥25) + matching duct band → boost suppressed" do + gated = Scorer.score_refractivity(-80, 800, 15.0, 40.0, @band_10g) + plain = Scorer.score_refractivity(-80, 800, nil, nil, @band_10g) + assert gated == plain + end + + test "nil Richardson preserves 4-arity behaviour (boost still applies)" do + with_nil_r = Scorer.score_refractivity(-80, 800, 15.0, nil, @band_10g) + four_arity = Scorer.score_refractivity(-80, 800, 15.0, @band_10g) + assert with_nil_r == four_arity + end + + test "boundary case: Richardson at exactly 25 suppresses the boost" do + gated = Scorer.score_refractivity(-80, 800, 15.0, 25.0, @band_10g) + plain = Scorer.score_refractivity(-80, 800, nil, nil, @band_10g) + assert gated == plain + end + end + describe "commercial_link_boost/2" do # Inverse sensor: when nearby commercial LOS links are fading below baseline, # the *same* multipath that hurts them is the enhanced refractivity that