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:
parent
eff3382628
commit
d3b0420457
5 changed files with 139 additions and 47 deletions
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 """
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue