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],
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 =

View file

@ -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),

View file

@ -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 """

View file

@ -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)

View file

@ -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.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
# Inverse sensor: when nearby commercial LOS links are fading below baseline,
# the *same* multipath that hurts them is the enhanced refractivity that