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],
|
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 =
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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 """
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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.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
|
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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue