feat(scoring): Richardson-gated native-duct boost

Added 5-arity Scorer.score_refractivity/5 that takes bulk_richardson
alongside best_duct_band_ghz. The existing 1.15× boost now only
applies when Richardson is in the stable regime (< 25) — native-
profile duct cells average 8.7-18.4 for ducting vs 38.3 for
non-ducting, so a duct-band reading under turbulent conditions is
more likely to be torn up by mechanical mixing than to carry a
beyond-LOS signal.

Wiring:
- Weather.nearest_native_duct_info/3 returns {ghz, richardson}; the
  bare nearest_native_duct_ghz/3 now delegates.
- PathLive and Propagation.score_with_algorithm/7 both pull the
  Richardson value into the conditions map alongside best_duct_band.
- Scorer.composite_score/2 reads conditions[:bulk_richardson] and
  passes it into score_refractivity/5.
- Backward compat: 4-arity variant and nil Richardson keep the old
  unconditional-boost behaviour so existing callers don't regress.
This commit is contained in:
Graham McIntire 2026-04-18 12:40:55 -05:00
parent eff3382628
commit d3b0420457
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
5 changed files with 139 additions and 47 deletions

View file

@ -96,7 +96,8 @@ defmodule Microwaveprop.Propagation do
min_refractivity_gradient: hrrr_profile[:native_min_gradient] || derived[:min_refractivity_gradient], min_refractivity_gradient: hrrr_profile[:native_min_gradient] || derived[:min_refractivity_gradient],
bl_depth_m: hrrr_profile[:hpbl_m], bl_depth_m: hrrr_profile[:hpbl_m],
pwat_mm: hrrr_profile[:pwat_mm], 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 = duct_info =

View file

@ -202,9 +202,32 @@ defmodule Microwaveprop.Propagation.Scorer do
band. band.
""" """
@spec score_refractivity(number() | nil, number() | nil, number() | nil, map()) :: integer() @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() thresholds = BandConfig.refractivity_thresholds()
base = base =
@ -219,7 +242,7 @@ defmodule Microwaveprop.Propagation.Scorer do
base base
|> apply_hpbl_multiplier(bl_depth_m) |> 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 end
# HPBL multiplier — applied to the base refractivity score. Calibrated to the # 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) 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 # Native-profile duct boost — 1.15× when the cell's best duct supports the
# target band's frequency. HRRR pressure-level gradients systematically # target band's frequency AND the Bulk Richardson number is in the stable
# under-read thin ducts (~250m vertical spacing vs 50 native hybrid levels), # regime (< 25). HRRR pressure-level gradients systematically under-read
# so when hrrr_native_profiles reports a duct band at or above the target # thin ducts, so when hrrr_native_profiles reports a duct band at or above
# frequency, the base gradient score is under-estimating the real channel. # the target frequency under stable conditions, the base gradient score is
defp apply_native_duct_boost(base, nil, _freq_mhz), do: base # 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 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) clamp_score(base * 1.15)
else else
base base
end end
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 """ @doc """
Inverse-sensor boost from commercial LOS link degradation. Inverse-sensor boost from commercial LOS link degradation.
@ -477,6 +510,7 @@ defmodule Microwaveprop.Propagation.Scorer do
conditions.min_refractivity_gradient, conditions.min_refractivity_gradient,
conditions.bl_depth_m, conditions.bl_depth_m,
conditions[:best_duct_band_ghz], conditions[:best_duct_band_ghz],
conditions[:bulk_richardson],
band_config band_config
), ),
sky: score_sky(conditions.sky_cover_pct), sky: score_sky(conditions.sky_cover_pct),

View file

@ -796,42 +796,61 @@ defmodule Microwaveprop.Weather do
end end
@doc """ @doc """
Returns the nearest `hrrr_native_profiles.best_duct_band_ghz` to Returns just `hrrr_native_profiles.best_duct_band_ghz` near the
(`lat`, `lon`) at `timestamp`, or nil if no native profile covers given cell. Convenience wrapper around `nearest_native_duct_info/3`
the cell at that time. Uses the same ±0.07° / ±1h tolerance as for callers that don't need Richardson.
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.
""" """
@spec nearest_native_duct_ghz(float(), float(), DateTime.t()) :: float() | nil @spec nearest_native_duct_ghz(float(), float(), DateTime.t()) :: float() | nil
def nearest_native_duct_ghz(lat, lon, %DateTime{} = timestamp) do 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 dlat = 0.07
dlon = 0.07 dlon = 0.07
time_start = DateTime.add(timestamp, -3600, :second) time_start = DateTime.add(timestamp, -3600, :second)
time_end = DateTime.add(timestamp, 3600, :second) time_end = DateTime.add(timestamp, 3600, :second)
Repo.one( from(h in HrrrNativeProfile,
from(h in HrrrNativeProfile, where:
where: h.lat >= ^(lat - dlat) and h.lat <= ^(lat + dlat) and
h.lat >= ^(lat - dlat) and h.lat <= ^(lat + dlat) and h.lon >= ^(lon - dlon) and h.lon <= ^(lon + dlon) and h.lon >= ^(lon - dlon) and h.lon <= ^(lon + dlon) and
h.valid_time >= ^time_start and h.valid_time <= ^time_end, h.valid_time >= ^time_start and h.valid_time <= ^time_end,
order_by: order_by:
fragment( fragment(
"ABS(? - ?) + ABS(? - ?) + ABS(EXTRACT(EPOCH FROM ? - ?))", "ABS(? - ?) + ABS(? - ?) + ABS(EXTRACT(EPOCH FROM ? - ?))",
h.lat, h.lat,
^lat, ^lat,
h.lon, h.lon,
^lon, ^lon,
h.valid_time, h.valid_time,
^timestamp ^timestamp
), ),
limit: 1, limit: 1,
select: h.best_duct_band_ghz 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 end
@doc """ @doc """

View file

@ -271,15 +271,19 @@ defmodule MicrowavepropWeb.PathLive do
# surface ducts that sounding data resolves. # surface ducts that sounding data resolves.
sounding = build_sounding_readout(midlat, midlon, now) sounding = build_sounding_readout(midlat, midlon, now)
# Native-resolution HRRR duct band near the midpoint. Resolves # Native-resolution HRRR duct band + Bulk Richardson near the
# thin trapping layers HRRR's 13 pressure levels miss; feeds the # midpoint. Resolves thin trapping layers HRRR's 13 pressure levels
# 1.15× boost in Scorer.score_refractivity when the duct supports # miss; feeds the 1.15× boost in Scorer.score_refractivity, gated on
# the target band. # Richardson so turbulent duct readings don't inflate the score.
native_duct_ghz = Weather.nearest_native_duct_ghz(midlat, midlon, now) 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 # Build conditions and score
{conditions, scoring} = {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 and power budgets
loss_budget = compute_loss_budget(dist_km, freq_ghz, band_config, terrain_result, conditions) loss_budget = compute_loss_budget(dist_km, freq_ghz, band_config, terrain_result, conditions)
@ -443,9 +447,9 @@ defmodule MicrowavepropWeb.PathLive do
end end
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) 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) 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)), min_refractivity_gradient: if(gradients != [], do: Enum.min(gradients)),
bl_depth_m: if(bl_depths != [], do: Enum.sum(bl_depths) / length(bl_depths)), bl_depth_m: if(bl_depths != [], do: Enum.sum(bl_depths) / length(bl_depths)),
pwat_mm: if(pwats != [], do: Enum.sum(pwats) / length(pwats)), 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) scoring = Scorer.composite_score(conditions, band_config)

View file

@ -361,6 +361,39 @@ defmodule Microwaveprop.Propagation.ScorerTest do
end end
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.718.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 describe "commercial_link_boost/2" do
# Inverse sensor: when nearby commercial LOS links are fading below baseline, # Inverse sensor: when nearby commercial LOS links are fading below baseline,
# the *same* multipath that hurts them is the enhanced refractivity that # the *same* multipath that hurts them is the enhanced refractivity that