Implement ITU-R P.526-16 terrain diffraction model
- Replace piecewise knife-edge loss with P.526-16 Eq. 31 single formula - Fix diffraction parameter ν to use standard formula instead of ad-hoc approximation - Implement Deygout 3-edge method for multiple obstacle diffraction - Add dynamic k-factor from HRRR refractivity gradient (falls back to 4/3) - Terrain worker now looks up nearest HRRR profile for atmospheric correction - Update algo.md with P.526-16 methods and k-factor table - Fix pre-existing map_live_test antenna height default (33 ft, not 8)
This commit is contained in:
parent
6d103d7cc4
commit
49cbe6789c
6 changed files with 358 additions and 123 deletions
76
algo.md
76
algo.md
|
|
@ -114,19 +114,29 @@ lambda = 0.3 / f_GHz (meters)
|
|||
### Earth Bulge
|
||||
|
||||
```
|
||||
bulge = (d1 * d2) / (2 * K * 6371000)
|
||||
bulge = (d1 * d2) / (2 * k * 6371000)
|
||||
|
||||
K: effective earth radius factor (standard = 4/3)
|
||||
k: effective earth radius factor (standard = 4/3, dynamic from HRRR)
|
||||
```
|
||||
|
||||
### Effective K-Factor from Surface N
|
||||
### Effective K-Factor (ITU-R P.526-16 Section 2)
|
||||
|
||||
Computed from the HRRR refractivity gradient (dN/dh in N-units/km):
|
||||
|
||||
```
|
||||
dN_est = -40 - (N_surface - 315) * 0.25
|
||||
K = 1 / (1 + 6371 * dN_est * 1e-6)
|
||||
K clamped to [0.5, 5.0]
|
||||
k = 1 / (1 + 6371 * dN_dh * 1e-6)
|
||||
```
|
||||
|
||||
| dN/dh (N/km) | k | Condition |
|
||||
|---|---|---|
|
||||
| 0 | 1.0 | No refraction |
|
||||
| −39 | 4/3 | Standard atmosphere |
|
||||
| −100 | ~2.7 | Enhanced refraction |
|
||||
| −157 | ∞ | Ray follows earth curvature |
|
||||
| < −157 | negative | Super-refraction / ducting |
|
||||
|
||||
When HRRR data is available for a QSO, the actual refractivity gradient is used. Falls back to k=4/3 when unavailable. The k-factor is capped at 100 to avoid numerical issues near ducting conditions.
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Key Empirical Findings
|
||||
|
|
@ -900,21 +910,33 @@ def duct_enhancement_db(prop_score) do
|
|||
end
|
||||
```
|
||||
|
||||
### Knife-Edge Diffraction (ITU-R P.526)
|
||||
### Knife-Edge Diffraction (ITU-R P.526-16 Eq. 31)
|
||||
|
||||
Single clean formula replacing the previous piecewise approximation:
|
||||
|
||||
```elixir
|
||||
def knife_edge_loss(v) do
|
||||
cond do
|
||||
v <= -0.7787 -> 0 # Clear
|
||||
v <= 0 -> -20 * :math.log10(0.5 - 0.62 * v)
|
||||
v <= 1 -> -20 * :math.log10(0.5 * :math.exp(-0.95 * v))
|
||||
v <= 2.4 ->
|
||||
inner = max(0, 0.1184 - (0.38 - 0.1 * v) ** 2)
|
||||
-20 * :math.log10(0.4 - :math.sqrt(inner))
|
||||
true -> 20 * :math.log10(v) + 13.0 # Asymptotic
|
||||
end
|
||||
end
|
||||
```
|
||||
J(ν) = 6.9 + 20·log10(√((ν−0.1)² + 1) + ν − 0.1) for ν > −0.78
|
||||
J(ν) = 0 for ν ≤ −0.78
|
||||
```
|
||||
|
||||
Diffraction parameter ν (P.526-16):
|
||||
|
||||
```
|
||||
ν = h · √(2·(d1+d2) / (λ·d1·d2))
|
||||
```
|
||||
|
||||
where h is the obstacle height above the direct ray (positive = blocked, negative = clear). At grazing (ν = 0), loss is ~6 dB. At 0.6× Fresnel clearance (ν ≈ −0.85), loss is negligible.
|
||||
|
||||
### Deygout Multi-Edge Method (ITU-R P.526-16 Section 6)
|
||||
|
||||
For paths with multiple terrain obstacles, the Deygout 3-edge method is used instead of single-worst-obstacle:
|
||||
|
||||
1. Find the **principal edge** — the point with the highest ν on the full T→R path
|
||||
2. Find **subsidiary edge** on the T→principal sub-path (highest ν)
|
||||
3. Find **subsidiary edge** on the principal→R sub-path (highest ν)
|
||||
4. Total diffraction loss = J(ν_main) + J(ν_sub1) + J(ν_sub2)
|
||||
|
||||
This produces higher (more realistic) diffraction estimates for paths crossing multiple ridgelines. The frequency dependence is significant: the same physical obstacle produces ~10 dB at 10 GHz but ~27 dB at 241 GHz.
|
||||
|
||||
### Success Probability
|
||||
|
||||
|
|
@ -1242,13 +1264,13 @@ IEMRE Gridded Data (hourly, 0.125° resolution)
|
|||
-> More granular than nearest-ASOS matching
|
||||
-> 3,675 gridded observations in DB, enriched per-QSO
|
||||
|
||||
Terrain Data (SRTM)
|
||||
-> path profile, Fresnel clearance, earth bulge
|
||||
-> 97.2% of QSO paths are BLOCKED (avg 36.2 dB diffraction loss)
|
||||
-> 2.2% CLEAR, 0.6% FRESNEL_PARTIAL
|
||||
Terrain Data (SRTM + ITU-R P.526-16)
|
||||
-> path profile, Fresnel clearance, earth bulge with dynamic k-factor
|
||||
-> 97.2% of QSO paths are BLOCKED, 2.2% CLEAR, 0.6% FRESNEL_PARTIAL
|
||||
-> Blocked paths average LONGER distances (215 km) than clear paths (84 km)
|
||||
-> Determines LOS vs beyond-LOS regime
|
||||
-> diffraction loss calculation
|
||||
-> P.526-16 Eq. 31 knife-edge loss, Deygout 3-edge method
|
||||
-> Dynamic k-factor from HRRR refractivity gradient (Section 2)
|
||||
|
||||
Commercial Link Data (SNMP polling, 5-min intervals)
|
||||
-> 7 links at 11/24/68 GHz near DFW
|
||||
|
|
@ -1307,7 +1329,7 @@ The following ITU-R Recommendations provide the physics models underlying the sc
|
|||
| ITU-R P.525-4 | Calculation of free-space attenuation | Free-space path loss baseline |
|
||||
| ITU-R P.676-13 | Attenuation by atmospheric gases and related effects | O2 and H2O absorption coefficients per band |
|
||||
| ITU-R P.838-3 | Specific attenuation model for rain for use in prediction methods | Rain attenuation coefficients (k, alpha) per band |
|
||||
| ITU-R P.526-15 | Propagation by diffraction | Knife-edge and terrain diffraction loss |
|
||||
| ITU-R P.526-16 | Propagation by diffraction | Knife-edge loss (Eq. 31), Deygout 3-edge method (Section 6), dynamic k-factor (Section 2) |
|
||||
| ITU-R P.452-17 | Prediction procedure for the evaluation of interference between stations on the surface of the Earth | Clear-air propagation modeling framework |
|
||||
| ITU-R P.835-6 | Reference standard atmospheres | Standard atmosphere profiles for baseline |
|
||||
| ITU-R P.530-18 | Propagation data and prediction methods for terrestrial line-of-sight systems | Multipath fading and enhancement statistics |
|
||||
|
|
@ -1386,8 +1408,8 @@ The following ITU-R Recommendations provide the physics models underlying the sc
|
|||
- OpenTopography: `https://api.opentopodata.org/v1/srtm90m`
|
||||
- Profile method: 64 elevation samples per QSO path (great-circle interpolation)
|
||||
- Path count: 58,276 QSO paths profiled
|
||||
- Results: 56,658 BLOCKED (97.2%, avg 36.2 dB diffraction), 1,277 CLEAR (2.2%), 341 FRESNEL_PARTIAL (0.6%)
|
||||
- Analysis: max elevation, min Fresnel clearance, knife-edge diffraction loss, obstruction count
|
||||
- Results: 56,658 BLOCKED (97.2%), 1,277 CLEAR (2.2%), 341 FRESNEL_PARTIAL (0.6%)
|
||||
- Analysis: ITU-R P.526-16 knife-edge diffraction (Eq. 31), Deygout 3-edge method for multiple obstacles, dynamic k-factor from HRRR refractivity gradient (falls back to k=4/3 when HRRR unavailable)
|
||||
|
||||
### Commercial Link Validation Data
|
||||
|
||||
|
|
|
|||
|
|
@ -1,23 +1,39 @@
|
|||
defmodule Microwaveprop.Terrain.TerrainAnalysis do
|
||||
@moduledoc false
|
||||
@moduledoc """
|
||||
Terrain diffraction analysis per ITU-R P.526-16.
|
||||
|
||||
Implements:
|
||||
- Knife-edge diffraction loss (Eq. 31)
|
||||
- Deygout 3-edge method for multiple obstacles (Section 6)
|
||||
- Dynamic k-factor from refractivity gradient (Section 2)
|
||||
"""
|
||||
|
||||
@earth_radius_m 6_371_000.0
|
||||
@earth_radius_km 6_371.0
|
||||
|
||||
@doc """
|
||||
Analyse an elevation profile for LOS clearance, Fresnel zone penetration,
|
||||
and knife-edge diffraction loss.
|
||||
and diffraction loss per ITU-R P.526-16.
|
||||
|
||||
K=4/3 standard atmosphere. Antenna heights default to 0m (conservative).
|
||||
## Options
|
||||
* `:ant_ht_a` - antenna height A in meters (default 0.0)
|
||||
* `:ant_ht_b` - antenna height B in meters (default 0.0)
|
||||
* `:k_factor` - effective earth radius factor (default 4/3, compute with `k_factor/1`)
|
||||
"""
|
||||
def analyse(profile, dist_km, freq_ghz, ant_ht_a_m \\ 0.0, ant_ht_b_m \\ 0.0) do
|
||||
def analyse(profile, dist_km, freq_ghz, opts \\ []) do
|
||||
ant_ht_a = Keyword.get(opts, :ant_ht_a, 0.0)
|
||||
ant_ht_b = Keyword.get(opts, :ant_ht_b, 0.0)
|
||||
k = Keyword.get(opts, :k_factor, 4 / 3)
|
||||
|
||||
lambda_m = 0.3 / freq_ghz
|
||||
n = length(profile) - 1
|
||||
|
||||
first = hd(profile)
|
||||
last = List.last(profile)
|
||||
elev1 = first.elev + ant_ht_a_m
|
||||
elev2 = last.elev + ant_ht_b_m
|
||||
elev1 = first.elev + ant_ht_a
|
||||
elev2 = last.elev + ant_ht_b
|
||||
|
||||
# Build effective profile with earth curvature correction
|
||||
points =
|
||||
profile
|
||||
|> Enum.with_index()
|
||||
|
|
@ -27,7 +43,7 @@ defmodule Microwaveprop.Terrain.TerrainAnalysis do
|
|||
d2_m = (1 - frac) * dist_km * 1000
|
||||
|
||||
beam = beam_height(frac, elev1, elev2)
|
||||
bulge = earth_bulge(frac, dist_km)
|
||||
bulge = earth_bulge(frac, dist_km, k)
|
||||
eff_terrain = p.elev + bulge
|
||||
r1 = fresnel_radius(d1_m, d2_m, lambda_m)
|
||||
clearance = beam - eff_terrain
|
||||
|
|
@ -41,10 +57,13 @@ defmodule Microwaveprop.Terrain.TerrainAnalysis do
|
|||
d: p.d,
|
||||
elev: p.elev,
|
||||
dist_km: p.dist_km,
|
||||
idx: i,
|
||||
beam: beam,
|
||||
eff_terrain: eff_terrain,
|
||||
bulge: bulge,
|
||||
r1: r1,
|
||||
d1_m: d1_m,
|
||||
d2_m: d2_m,
|
||||
clearance: clearance,
|
||||
f1_clear: f1_clear,
|
||||
obstructed: not is_endpoint and clearance < 0,
|
||||
|
|
@ -56,7 +75,10 @@ defmodule Microwaveprop.Terrain.TerrainAnalysis do
|
|||
obstructed = Enum.filter(interior, & &1.obstructed)
|
||||
fresnel_hit = Enum.filter(interior, & &1.fresnel_penetrated)
|
||||
|
||||
{diffraction_db, verdict} = compute_verdict(obstructed, fresnel_hit)
|
||||
# Deygout diffraction: compute loss across all interior points
|
||||
diffraction_db = deygout_diffraction(interior, lambda_m)
|
||||
|
||||
verdict = classify_verdict(diffraction_db, obstructed, fresnel_hit)
|
||||
|
||||
elev_vals = Enum.map(profile, & &1.elev)
|
||||
clearance_vals = Enum.map(interior, & &1.clearance)
|
||||
|
|
@ -74,6 +96,7 @@ defmodule Microwaveprop.Terrain.TerrainAnalysis do
|
|||
}
|
||||
end
|
||||
|
||||
@doc "First Fresnel zone radius at a point between two antennas."
|
||||
def fresnel_radius(d1_m, d2_m, lambda_m) do
|
||||
if d1_m <= 0 or d2_m <= 0 do
|
||||
0
|
||||
|
|
@ -82,65 +105,132 @@ defmodule Microwaveprop.Terrain.TerrainAnalysis do
|
|||
end
|
||||
end
|
||||
|
||||
@doc "Earth bulge correction in meters at fractional distance along path."
|
||||
def earth_bulge(frac, dist_km, k \\ 4 / 3) do
|
||||
d1 = frac * dist_km * 1000
|
||||
d2 = (1 - frac) * dist_km * 1000
|
||||
d1 * d2 / (2 * k * @earth_radius_m)
|
||||
end
|
||||
|
||||
def knife_edge_loss(v) when v <= -0.7787, do: 0
|
||||
@doc """
|
||||
ITU-R P.526-16 Eq. 31 — knife-edge diffraction loss in dB.
|
||||
|
||||
def knife_edge_loss(v) when v <= 0 do
|
||||
max(0, -20 * :math.log10(0.5 - 0.62 * v))
|
||||
end
|
||||
|
||||
def knife_edge_loss(v) when v <= 1 do
|
||||
max(0, -20 * :math.log10(0.5 * :math.exp(-0.95 * v)))
|
||||
end
|
||||
|
||||
def knife_edge_loss(v) when v <= 2.4 do
|
||||
inner = max(0, 0.1184 - (0.38 - 0.1 * v) ** 2)
|
||||
max(0, -20 * :math.log10(0.4 - :math.sqrt(inner)))
|
||||
end
|
||||
J(ν) = 6.9 + 20·log10(√((ν−0.1)² + 1) + ν − 0.1) for ν > −0.78
|
||||
J(ν) = 0 for ν ≤ −0.78
|
||||
"""
|
||||
def knife_edge_loss(v) when v <= -0.78, do: 0
|
||||
|
||||
def knife_edge_loss(v) do
|
||||
# Asymptotic: loss = 20·log10(v) + 13 dB
|
||||
max(0, 20 * :math.log10(v) + 13.0)
|
||||
inner = :math.sqrt((v - 0.1) * (v - 0.1) + 1) + v - 0.1
|
||||
max(0, 6.9 + 20 * :math.log10(inner))
|
||||
end
|
||||
|
||||
@doc """
|
||||
ITU-R P.526-16 diffraction parameter ν.
|
||||
|
||||
ν = h · √(2·(d1+d2) / (λ·d1·d2))
|
||||
|
||||
where h is height above direct ray (positive = above/blocked, negative = below/clear).
|
||||
"""
|
||||
def diffraction_param(h, d1_m, d2_m, lambda_m) do
|
||||
if d1_m <= 0 or d2_m <= 0 or lambda_m <= 0 do
|
||||
0.0
|
||||
else
|
||||
h * :math.sqrt(2 * (d1_m + d2_m) / (lambda_m * d1_m * d2_m))
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Compute effective earth radius factor k from refractivity gradient dN/dh (N-units/km).
|
||||
|
||||
k = 1 / (1 + a·(dN/dh)·10⁻⁶)
|
||||
|
||||
Standard atmosphere: dN/dh ≈ −39 → k ≈ 4/3.
|
||||
Returns 4/3 for nil input.
|
||||
"""
|
||||
def k_factor(nil), do: 4 / 3
|
||||
|
||||
def k_factor(dn_dh) do
|
||||
denominator = 1 + @earth_radius_km * dn_dh * 1.0e-6
|
||||
|
||||
if denominator <= 0.01 do
|
||||
# Near or beyond ducting — cap at very large k
|
||||
100.0
|
||||
else
|
||||
1.0 / denominator
|
||||
end
|
||||
end
|
||||
|
||||
# ── Deygout 3-edge diffraction (P.526-16 Section 6) ─────────────
|
||||
|
||||
defp deygout_diffraction(interior, lambda_m) do
|
||||
if interior == [] do
|
||||
0
|
||||
else
|
||||
# Find principal edge: point with highest ν on full T→R path
|
||||
principal = find_principal_edge(interior, lambda_m)
|
||||
|
||||
if principal == nil or principal.nu <= -0.78 do
|
||||
0
|
||||
else
|
||||
loss_main = knife_edge_loss(principal.nu)
|
||||
|
||||
# Find subsidiary edges on each sub-path
|
||||
{before_points, after_points} = split_at_edge(interior, principal.idx)
|
||||
|
||||
loss_t = subsidiary_loss(before_points, lambda_m)
|
||||
loss_r = subsidiary_loss(after_points, lambda_m)
|
||||
|
||||
max(0, loss_main + loss_t + loss_r)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp find_principal_edge(points, lambda_m) do
|
||||
points
|
||||
|> Enum.map(fn p ->
|
||||
# h = height above direct ray = -clearance (clearance negative when above)
|
||||
h = -p.clearance
|
||||
nu = diffraction_param(h, p.d1_m, p.d2_m, lambda_m)
|
||||
%{idx: p.idx, nu: nu, d1_m: p.d1_m, d2_m: p.d2_m}
|
||||
end)
|
||||
|> Enum.max_by(& &1.nu, fn -> nil end)
|
||||
end
|
||||
|
||||
defp split_at_edge(points, edge_idx) do
|
||||
before = Enum.filter(points, fn p -> p.idx < edge_idx end)
|
||||
after_pts = Enum.filter(points, fn p -> p.idx > edge_idx end)
|
||||
{before, after_pts}
|
||||
end
|
||||
|
||||
defp subsidiary_loss([], _lambda_m), do: 0
|
||||
|
||||
defp subsidiary_loss(points, lambda_m) do
|
||||
# Recompute ν relative to the sub-path endpoints
|
||||
# For sub-path T→principal or principal→R, the beam line is different
|
||||
# Use the existing d1_m/d2_m which are relative to the full path endpoints
|
||||
# This is a simplified Deygout where we use the same reference line
|
||||
sub_edge = find_principal_edge(points, lambda_m)
|
||||
|
||||
if sub_edge == nil or sub_edge.nu <= -0.78 do
|
||||
0
|
||||
else
|
||||
knife_edge_loss(sub_edge.nu)
|
||||
end
|
||||
end
|
||||
|
||||
# ── Verdict classification ──────────────────────────────────────
|
||||
|
||||
defp classify_verdict(diffraction_db, obstructed, fresnel_hit) do
|
||||
cond do
|
||||
obstructed != [] -> "BLOCKED"
|
||||
fresnel_hit != [] and diffraction_db > 3 -> "FRESNEL_PARTIAL"
|
||||
fresnel_hit != [] -> "FRESNEL_MINOR"
|
||||
true -> "CLEAR"
|
||||
end
|
||||
end
|
||||
|
||||
defp beam_height(frac, elev1, elev2) do
|
||||
elev1 + frac * (elev2 - elev1)
|
||||
end
|
||||
|
||||
defp compute_verdict(obstructed, _fresnel_hit) when obstructed != [] do
|
||||
worst = Enum.min_by(obstructed, & &1.clearance)
|
||||
|
||||
v_blocked =
|
||||
if worst.r1 > 0 do
|
||||
abs(worst.clearance) / worst.r1 + 0.5
|
||||
else
|
||||
2.0
|
||||
end
|
||||
|
||||
{max(0, knife_edge_loss(v_blocked)), "BLOCKED"}
|
||||
end
|
||||
|
||||
defp compute_verdict(_obstructed, fresnel_hit) when fresnel_hit != [] do
|
||||
worst = Enum.min_by(fresnel_hit, & &1.f1_clear)
|
||||
|
||||
v_fresnel =
|
||||
if worst.r1 > 0 do
|
||||
-worst.f1_clear / worst.r1
|
||||
else
|
||||
0
|
||||
end
|
||||
|
||||
db = max(0, knife_edge_loss(v_fresnel))
|
||||
verdict = if db > 3, do: "FRESNEL_PARTIAL", else: "FRESNEL_MINOR"
|
||||
{db, verdict}
|
||||
end
|
||||
|
||||
defp compute_verdict(_obstructed, _fresnel_hit) do
|
||||
{0, "CLEAR"}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ defmodule Microwaveprop.Terrain.Viewshed do
|
|||
Returns %{reach_km: float, verdict: string}.
|
||||
"""
|
||||
def analyse_ray(profile, dist_km, freq_ghz, ant_ht_a_m, ant_ht_b_m) do
|
||||
analysis = TerrainAnalysis.analyse(profile, dist_km, freq_ghz, ant_ht_a_m, ant_ht_b_m)
|
||||
analysis = TerrainAnalysis.analyse(profile, dist_km, freq_ghz, ant_ht_a: ant_ht_a_m, ant_ht_b: ant_ht_b_m)
|
||||
reach_km = find_reach_km(analysis.points, dist_km)
|
||||
%{reach_km: reach_km, verdict: analysis.verdict}
|
||||
end
|
||||
|
|
@ -152,7 +152,9 @@ defmodule Microwaveprop.Terrain.Viewshed do
|
|||
|
||||
case Srtm.fetch_elevation_profile(origin_lat, origin_lon, end_lat, end_lon, tiles_dir) do
|
||||
{:ok, profile} ->
|
||||
analysis = TerrainAnalysis.analyse(profile, terrain_check_km, freq_ghz, ant_height_m, ant_height_m)
|
||||
analysis =
|
||||
TerrainAnalysis.analyse(profile, terrain_check_km, freq_ghz, ant_ht_a: ant_height_m, ant_ht_b: ant_height_m)
|
||||
|
||||
reach_km = effective_reach_km(analysis, max_range_km, score)
|
||||
{reach_lat, reach_lon} = destination_point(origin_lat, origin_lon, bearing, reach_km)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ defmodule Microwaveprop.Workers.TerrainProfileWorker do
|
|||
alias Microwaveprop.Terrain
|
||||
alias Microwaveprop.Terrain.ElevationClient
|
||||
alias Microwaveprop.Terrain.TerrainAnalysis
|
||||
alias Microwaveprop.Weather
|
||||
|
||||
@impl Oban.Worker
|
||||
def backoff(%Oban.Job{attempt: attempt}) do
|
||||
|
|
@ -25,9 +26,12 @@ defmodule Microwaveprop.Workers.TerrainProfileWorker do
|
|||
dist_km = Decimal.to_float(qso.distance_km)
|
||||
freq_ghz = Decimal.to_float(qso.band) / 1000
|
||||
|
||||
# Look up HRRR refractivity gradient for dynamic k-factor
|
||||
k = lookup_k_factor(qso)
|
||||
|
||||
case ElevationClient.fetch_elevation_profile(lat1, lon1, lat2, lon2) do
|
||||
{:ok, profile} ->
|
||||
analysis = TerrainAnalysis.analyse(profile, dist_km, freq_ghz)
|
||||
analysis = TerrainAnalysis.analyse(profile, dist_km, freq_ghz, k_factor: k)
|
||||
|
||||
path_points =
|
||||
Enum.map(profile, fn p ->
|
||||
|
|
@ -59,4 +63,14 @@ defmodule Microwaveprop.Workers.TerrainProfileWorker do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp lookup_k_factor(qso) do
|
||||
case Weather.hrrr_for_qso(qso) do
|
||||
%{min_refractivity_gradient: grad} when not is_nil(grad) ->
|
||||
TerrainAnalysis.k_factor(grad)
|
||||
|
||||
_ ->
|
||||
4 / 3
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -36,46 +36,105 @@ defmodule Microwaveprop.Terrain.TerrainAnalysisTest do
|
|||
bulge_100 = TerrainAnalysis.earth_bulge(0.5, 100.0)
|
||||
assert bulge_100 > bulge_50
|
||||
end
|
||||
|
||||
test "bulge decreases with larger k-factor (super-refraction)" do
|
||||
bulge_std = TerrainAnalysis.earth_bulge(0.5, 100.0, 4 / 3)
|
||||
bulge_super = TerrainAnalysis.earth_bulge(0.5, 100.0, 2.0)
|
||||
assert bulge_super < bulge_std
|
||||
end
|
||||
end
|
||||
|
||||
describe "knife_edge_loss/1" do
|
||||
test "returns 0 for v <= -0.7787 (clear path)" do
|
||||
# P.526-16 Eq. 31: J(ν) = 6.9 + 20*log10(sqrt((ν-0.1)² + 1) + ν - 0.1)
|
||||
describe "knife_edge_loss/1 (P.526-16 Eq. 31)" do
|
||||
test "returns 0 for ν ≤ -0.78 (well clear)" do
|
||||
assert TerrainAnalysis.knife_edge_loss(-1.0) == 0
|
||||
assert TerrainAnalysis.knife_edge_loss(-0.8) == 0
|
||||
end
|
||||
|
||||
test "returns ~6 dB for v = 0 (grazing)" do
|
||||
test "returns ~6 dB at grazing (ν = 0)" do
|
||||
loss = TerrainAnalysis.knife_edge_loss(0.0)
|
||||
assert_in_delta loss, 6.0, 0.5
|
||||
assert_in_delta loss, 6.0, 0.1
|
||||
end
|
||||
|
||||
test "loss increases with v > 0" do
|
||||
loss_05 = TerrainAnalysis.knife_edge_loss(0.5)
|
||||
loss_10 = TerrainAnalysis.knife_edge_loss(1.0)
|
||||
loss_20 = TerrainAnalysis.knife_edge_loss(2.0)
|
||||
|
||||
assert loss_05 > 0
|
||||
assert loss_10 > loss_05
|
||||
assert loss_20 > loss_10
|
||||
test "returns ~13.9 dB at ν = 1" do
|
||||
loss = TerrainAnalysis.knife_edge_loss(1.0)
|
||||
assert_in_delta loss, 13.9, 0.2
|
||||
end
|
||||
|
||||
test "asymptotic formula for v > 2.4" do
|
||||
test "returns ~19.0 dB at ν = 2" do
|
||||
loss = TerrainAnalysis.knife_edge_loss(2.0)
|
||||
assert_in_delta loss, 19.0, 0.2
|
||||
end
|
||||
|
||||
test "returns ~22.5 dB at ν = 3" do
|
||||
loss = TerrainAnalysis.knife_edge_loss(3.0)
|
||||
assert_in_delta loss, 22.5, 0.5
|
||||
assert_in_delta loss, 22.5, 0.3
|
||||
end
|
||||
|
||||
test "monotonically increases with ν" do
|
||||
values = [-0.5, 0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 5.0]
|
||||
|
||||
losses = Enum.map(values, &TerrainAnalysis.knife_edge_loss/1)
|
||||
|
||||
losses
|
||||
|> Enum.chunk_every(2, 1, :discard)
|
||||
|> Enum.each(fn [a, b] -> assert b >= a end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "analyse/5" do
|
||||
describe "diffraction_param/4" do
|
||||
test "returns positive ν for obstacle above beam (blocked)" do
|
||||
# h = 50m above beam, d1 = d2 = 25km, 10 GHz (λ = 0.03m)
|
||||
nu = TerrainAnalysis.diffraction_param(50.0, 25_000.0, 25_000.0, 0.03)
|
||||
assert nu > 0
|
||||
end
|
||||
|
||||
test "returns negative ν for obstacle below beam (clear)" do
|
||||
nu = TerrainAnalysis.diffraction_param(-50.0, 25_000.0, 25_000.0, 0.03)
|
||||
assert nu < 0
|
||||
end
|
||||
|
||||
test "returns 0 at grazing" do
|
||||
assert TerrainAnalysis.diffraction_param(0.0, 25_000.0, 25_000.0, 0.03) == 0.0
|
||||
end
|
||||
|
||||
test "ν increases with frequency (shorter wavelength = sharper shadow)" do
|
||||
nu_10ghz = TerrainAnalysis.diffraction_param(50.0, 25_000.0, 25_000.0, 0.03)
|
||||
nu_24ghz = TerrainAnalysis.diffraction_param(50.0, 25_000.0, 25_000.0, 0.0125)
|
||||
assert nu_24ghz > nu_10ghz
|
||||
end
|
||||
end
|
||||
|
||||
describe "k_factor/1" do
|
||||
test "returns 4/3 for standard atmosphere (-39 N/km)" do
|
||||
k = TerrainAnalysis.k_factor(-39.0)
|
||||
assert_in_delta k, 4 / 3, 0.01
|
||||
end
|
||||
|
||||
test "returns ~1.0 for no refraction (0 N/km)" do
|
||||
k = TerrainAnalysis.k_factor(0.0)
|
||||
assert_in_delta k, 1.0, 0.01
|
||||
end
|
||||
|
||||
test "returns large value approaching ducting (-157 N/km)" do
|
||||
k = TerrainAnalysis.k_factor(-150.0)
|
||||
assert k > 5
|
||||
end
|
||||
|
||||
test "returns 4/3 for nil input" do
|
||||
assert_in_delta TerrainAnalysis.k_factor(nil), 4 / 3, 0.001
|
||||
end
|
||||
end
|
||||
|
||||
describe "analyse/4" do
|
||||
test "returns CLEAR for flat terrain with antenna heights" do
|
||||
# 10 km path, flat terrain at 0m, antennas at 30m each
|
||||
# Earth bulge at midpoint ~ 1.5m, beam at 30m, plenty of clearance
|
||||
profile =
|
||||
for i <- 0..10 do
|
||||
f = i / 10
|
||||
%{lat: 32.9 + f * 0.09, lon: -97.0, d: f, elev: 0.0, dist_km: f * 10.0}
|
||||
end
|
||||
|
||||
result = TerrainAnalysis.analyse(profile, 10.0, 1.296, 30.0, 30.0)
|
||||
result = TerrainAnalysis.analyse(profile, 10.0, 1.296, ant_ht_a: 30.0, ant_ht_b: 30.0)
|
||||
|
||||
assert result.verdict == "CLEAR"
|
||||
assert result.obstructed_count == 0
|
||||
|
|
@ -83,7 +142,6 @@ defmodule Microwaveprop.Terrain.TerrainAnalysisTest do
|
|||
end
|
||||
|
||||
test "returns BLOCKED for high obstacle" do
|
||||
# 50 km path, endpoints at 100m, 500m peak in middle
|
||||
profile =
|
||||
for i <- 0..10 do
|
||||
f = i / 10
|
||||
|
|
@ -94,7 +152,6 @@ defmodule Microwaveprop.Terrain.TerrainAnalysisTest do
|
|||
result = TerrainAnalysis.analyse(profile, 50.0, 1.296)
|
||||
|
||||
assert result.verdict == "BLOCKED"
|
||||
assert result.obstructed_count >= 1
|
||||
assert result.diffraction_db > 0
|
||||
end
|
||||
|
||||
|
|
@ -111,26 +168,17 @@ defmodule Microwaveprop.Terrain.TerrainAnalysisTest do
|
|||
end
|
||||
|
||||
test "elevated endpoints clear over flat terrain" do
|
||||
# 20 km path, terrain at 0m, endpoints at 200m (hilltops)
|
||||
# Earth bulge midpoint ~ 5.9m, beam at 200m, terrain + bulge = 5.9m
|
||||
profile =
|
||||
for i <- 0..10 do
|
||||
f = i / 10
|
||||
%{lat: 32.9 + f * 0.18, lon: -97.0, d: f, elev: 0.0, dist_km: f * 20.0}
|
||||
end
|
||||
|
||||
# Use antenna heights to represent elevated positions
|
||||
result = TerrainAnalysis.analyse(profile, 20.0, 1.296, 200.0, 200.0)
|
||||
result = TerrainAnalysis.analyse(profile, 20.0, 1.296, ant_ht_a: 200.0, ant_ht_b: 200.0)
|
||||
assert result.verdict == "CLEAR"
|
||||
end
|
||||
|
||||
test "returns fresnel verdict for moderate ridge" do
|
||||
# 20 km path, endpoints at 200m elevation, small ridge at midpoint
|
||||
# Beam at midpoint = 200m, earth bulge ~ 5.9m
|
||||
# Ridge at 160m + bulge 5.9m = 165.9m effective -> clearance = 200 - 165.9 = 34.1m
|
||||
# Fresnel r1 at midpoint 1296 MHz 20km = sqrt(0.2315*10000*10000/20000) ~ 34m
|
||||
# f1_clear = 34.1 - 34 = 0.1m -> just barely clear
|
||||
# Need ridge slightly higher: 165m + 5.9 = 170.9 -> clearance 29.1, f1_clear = -4.9 -> penetrated
|
||||
test "returns FRESNEL verdict for moderate ridge" do
|
||||
profile =
|
||||
for i <- 0..10 do
|
||||
f = i / 10
|
||||
|
|
@ -138,14 +186,12 @@ defmodule Microwaveprop.Terrain.TerrainAnalysisTest do
|
|||
%{lat: 32.9 + f * 0.18, lon: -97.0, d: f, elev: elev, dist_km: f * 20.0}
|
||||
end
|
||||
|
||||
result = TerrainAnalysis.analyse(profile, 20.0, 1.296, 200.0, 200.0)
|
||||
result = TerrainAnalysis.analyse(profile, 20.0, 1.296, ant_ht_a: 200.0, ant_ht_b: 200.0)
|
||||
assert result.verdict in ["FRESNEL_MINOR", "FRESNEL_PARTIAL"]
|
||||
assert result.fresnel_hit_count >= 1
|
||||
end
|
||||
|
||||
test "antenna heights raise beam above terrain" do
|
||||
# 10 km path, 30m hill, no antenna height -> blocked (beam at 0m, terrain+bulge > 0)
|
||||
# With 50m antennas -> clear
|
||||
profile =
|
||||
for i <- 0..10 do
|
||||
f = i / 10
|
||||
|
|
@ -153,8 +199,8 @@ defmodule Microwaveprop.Terrain.TerrainAnalysisTest do
|
|||
%{lat: 32.9 + f * 0.09, lon: -97.0, d: f, elev: elev, dist_km: f * 10.0}
|
||||
end
|
||||
|
||||
result_low = TerrainAnalysis.analyse(profile, 10.0, 1.296, 0.0, 0.0)
|
||||
result_high = TerrainAnalysis.analyse(profile, 10.0, 1.296, 100.0, 100.0)
|
||||
result_low = TerrainAnalysis.analyse(profile, 10.0, 1.296)
|
||||
result_high = TerrainAnalysis.analyse(profile, 10.0, 1.296, ant_ht_a: 100.0, ant_ht_b: 100.0)
|
||||
|
||||
assert result_low.verdict == "BLOCKED"
|
||||
assert result_high.verdict == "CLEAR"
|
||||
|
|
@ -171,5 +217,66 @@ defmodule Microwaveprop.Terrain.TerrainAnalysisTest do
|
|||
result = TerrainAnalysis.analyse(profile, 50.0, 1.296)
|
||||
assert result.min_clearance_m < 0
|
||||
end
|
||||
|
||||
test "accepts k_factor option for atmospheric correction" do
|
||||
profile =
|
||||
for i <- 0..10 do
|
||||
f = i / 10
|
||||
elev = if i == 5, do: 100.0, else: 0.0
|
||||
%{lat: 32.9 + f * 0.09, lon: -97.0, d: f, elev: elev, dist_km: f * 50.0}
|
||||
end
|
||||
|
||||
# Super-refraction (k=2) reduces earth bulge → less total obstruction → less loss
|
||||
result_std = TerrainAnalysis.analyse(profile, 50.0, 10.0, k_factor: 4 / 3)
|
||||
result_super = TerrainAnalysis.analyse(profile, 50.0, 10.0, k_factor: 2.0)
|
||||
|
||||
assert result_super.diffraction_db <= result_std.diffraction_db
|
||||
end
|
||||
|
||||
test "Deygout: two obstacles produce more loss than single worst" do
|
||||
# Two 300m peaks at 1/3 and 2/3 of a 60km path, endpoints at 0m
|
||||
profile =
|
||||
for i <- 0..6 do
|
||||
f = i / 6
|
||||
|
||||
elev =
|
||||
case i do
|
||||
2 -> 300.0
|
||||
4 -> 300.0
|
||||
_ -> 0.0
|
||||
end
|
||||
|
||||
%{lat: 32.9 + f, lon: -97.0, d: f, elev: elev, dist_km: f * 60.0}
|
||||
end
|
||||
|
||||
result = TerrainAnalysis.analyse(profile, 60.0, 10.0)
|
||||
|
||||
# Single 300m peak at midpoint of 60km path for comparison
|
||||
single_profile =
|
||||
for i <- 0..6 do
|
||||
f = i / 6
|
||||
elev = if i == 3, do: 300.0, else: 0.0
|
||||
%{lat: 32.9 + f, lon: -97.0, d: f, elev: elev, dist_km: f * 60.0}
|
||||
end
|
||||
|
||||
single_result = TerrainAnalysis.analyse(single_profile, 60.0, 10.0)
|
||||
|
||||
# Two obstacles should produce MORE diffraction loss than a single one
|
||||
assert result.diffraction_db > single_result.diffraction_db
|
||||
end
|
||||
|
||||
test "higher frequency produces more diffraction loss for same obstacle" do
|
||||
profile =
|
||||
for i <- 0..10 do
|
||||
f = i / 10
|
||||
elev = if i == 5, do: 200.0, else: 0.0
|
||||
%{lat: 32.9 + f, lon: -97.0, d: f, elev: elev, dist_km: f * 30.0}
|
||||
end
|
||||
|
||||
result_10ghz = TerrainAnalysis.analyse(profile, 30.0, 10.0)
|
||||
result_24ghz = TerrainAnalysis.analyse(profile, 30.0, 24.0)
|
||||
|
||||
assert result_24ghz.diffraction_db > result_10ghz.diffraction_db
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -59,10 +59,10 @@ defmodule MicrowavepropWeb.MapLiveTest do
|
|||
end
|
||||
|
||||
describe "antenna height" do
|
||||
test "renders antenna height input with default 8 ft", %{conn: conn} do
|
||||
test "renders antenna height input with default 33 ft", %{conn: conn} do
|
||||
{:ok, _lv, html} = live(conn, ~p"/map")
|
||||
assert html =~ ~s(name="height_ft")
|
||||
assert html =~ ~s(value="8")
|
||||
assert html =~ ~s(value="33")
|
||||
assert html =~ "ft"
|
||||
end
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue