prop/lib/microwaveprop/terrain/terrain_analysis.ex
Graham McIntire 2da74c5cd8
feat(telemetry): wide instrumentation + bump hrrr to 2 per pod
Config:
- runtime.exs hrrr queue 1 → 2 (6 concurrent HRRR jobs across 3 pods)

New helper Microwaveprop.Instrument.span/3 wraps :telemetry.span with
a [:microwaveprop | event_suffix] prefix so metrics can key off it.

Spans added (each emits duration + result tag):
- HrrrClient.fetch_grid / fetch_profile / fetch_idx
- NexradClient.fetch_frame + decode_png
- IemClient.fetch_asos / fetch_raob
- GefsClient.fetch_grid_profiles
- NarrClient.fetch_profile_at
- UwyoSoundingClient.fetch_sounding
- ElevationClient.fetch_elevation_profile
- Weather.upsert_hrrr_profiles_batch / upsert_gefs_profiles_batch
- Propagation.replace_scores
- PropagationGridWorker.compute_scores_algorithm
- TerrainAnalysis.analyse
- CommonVolumeRadarWorker.aggregate_stats

Telemetry catalog (MicrowavepropWeb.Telemetry):
- Oban job duration / queue_time / count / exception by (worker, queue, state)
- Per-span summary metrics for every instrumented phase above
- Periodic (10s) poller emits oban queue depth by (queue, state) — drops
  into the /admin/dashboard Metrics tab immediately

Also drops the now-redundant "fetching n0q frame" and "fetching <url>"
info lines from CommonVolumeRadarWorker / NexradClient; the span events
cover that and the worker's "ingested" line stays for per-job signal.
2026-04-18 16:33:34 -05:00

283 lines
8.6 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule Microwaveprop.Terrain.TerrainAnalysis do
@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)
"""
@type elevation_point :: %{lat: float(), lon: float(), elev: float(), d: float(), dist_km: float()}
@type analysis_point :: %{
lat: float(),
lon: float(),
d: float(),
elev: float(),
dist_km: float(),
idx: non_neg_integer(),
beam: float(),
eff_terrain: float(),
bulge: float(),
r1: float(),
d1_m: float(),
d2_m: float(),
clearance: float(),
f1_clear: float(),
obstructed: boolean(),
fresnel_penetrated: boolean()
}
@type analysis_result :: %{
points: [analysis_point()],
diffraction_db: number(),
verdict: String.t(),
k_factor: float(),
max_elevation_m: float(),
min_clearance_m: float(),
obstructed_count: non_neg_integer(),
fresnel_hit_count: non_neg_integer()
}
@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 diffraction loss per ITU-R P.526-16.
## 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`)
"""
@spec analyse([elevation_point()], float(), float(), keyword()) :: analysis_result()
def analyse(profile, dist_km, freq_ghz, opts \\ []) do
Microwaveprop.Instrument.span(
[:terrain, :analyse],
%{point_count: length(profile), dist_km: dist_km, freq_ghz: freq_ghz},
fn -> do_analyse(profile, dist_km, freq_ghz, opts) end
)
end
defp do_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
elev2 = last.elev + ant_ht_b
# Build effective profile with earth curvature correction
points =
profile
|> Enum.with_index()
|> Enum.map(fn {p, i} ->
frac = i / n
d1_m = frac * dist_km * 1000
d2_m = (1 - frac) * dist_km * 1000
beam = beam_height(frac, elev1, elev2)
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
f1_clear = if r1 > 0, do: clearance - r1, else: clearance
is_endpoint = i == 0 or i == n
%{
lat: p.lat,
lon: p.lon,
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,
fresnel_penetrated: not is_endpoint and r1 > 0 and f1_clear < 0 and clearance >= 0
}
end)
interior = Enum.slice(points, 1..-2//1)
obstructed = Enum.filter(interior, & &1.obstructed)
fresnel_hit = Enum.filter(interior, & &1.fresnel_penetrated)
# 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)
max_elevation_m = Enum.max(elev_vals, fn -> 0.0 end)
min_clearance_m = if clearance_vals == [], do: 999.0, else: Enum.min(clearance_vals)
%{
points: points,
diffraction_db: diffraction_db,
verdict: verdict,
k_factor: k,
max_elevation_m: max_elevation_m,
min_clearance_m: min_clearance_m,
obstructed_count: length(obstructed),
fresnel_hit_count: length(fresnel_hit)
}
end
@doc "First Fresnel zone radius at a point between two antennas."
@spec fresnel_radius(number(), number(), number()) :: number()
def fresnel_radius(d1_m, d2_m, lambda_m) do
if d1_m <= 0 or d2_m <= 0 do
0
else
:math.sqrt(lambda_m * d1_m * d2_m / (d1_m + d2_m))
end
end
@doc "Earth bulge correction in meters at fractional distance along path."
@spec earth_bulge(float(), float(), float()) :: float()
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
@doc """
ITU-R P.526-16 Eq. 31 — knife-edge diffraction loss in dB.
J(ν) = 6.9 + 20·log10(√((ν0.1)² + 1) + ν 0.1) for ν > 0.78
J(ν) = 0 for ν0.78
"""
@spec knife_edge_loss(number()) :: number()
def knife_edge_loss(v) when v <= -0.78, do: 0
def knife_edge_loss(v) do
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).
"""
@spec diffraction_param(number(), number(), number(), number()) :: float()
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.
"""
@spec k_factor(number() | nil) :: float()
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
end