LiveView page for analyzing microwave propagation between two points. Features: - Source/destination input: callsign (via gridmap.org API) or Maidenhead grid - Band selector for all 8 microwave bands (10-241 GHz) - Leaflet map showing path line (reuses ContactMap hook) - Terrain elevation cross-section (reuses ElevationProfile hook) - Link summary: distance, bearing, terrain verdict - Loss budget: FSPL + O2 + H2O + rain + diffraction - Propagation score with 10-factor breakdown - Current atmospheric conditions from path-integrated HRRR New CallsignClient for gridmap.org location lookup. Navigation links added to main navbar and map control panel.
589 lines
19 KiB
Elixir
589 lines
19 KiB
Elixir
defmodule MicrowavepropWeb.PathLive do
|
|
@moduledoc false
|
|
use MicrowavepropWeb, :live_view
|
|
|
|
alias Microwaveprop.Propagation.BandConfig
|
|
alias Microwaveprop.Propagation.Scorer
|
|
alias Microwaveprop.Radio.CallsignClient
|
|
alias Microwaveprop.Radio.Maidenhead
|
|
alias Microwaveprop.Terrain.ElevationClient
|
|
alias Microwaveprop.Terrain.TerrainAnalysis
|
|
alias Microwaveprop.Weather
|
|
|
|
@band_options [
|
|
{"10 GHz", "10000"},
|
|
{"24 GHz", "24000"},
|
|
{"47 GHz", "47000"},
|
|
{"68 GHz", "68000"},
|
|
{"76 GHz", "75000"},
|
|
{"122 GHz", "122000"},
|
|
{"134 GHz", "134000"},
|
|
{"241 GHz", "241000"}
|
|
]
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
{:ok,
|
|
assign(socket,
|
|
page_title: "Path Calculator",
|
|
band_options: @band_options,
|
|
source: "",
|
|
destination: "",
|
|
band: "10000",
|
|
result: nil,
|
|
error: nil,
|
|
computing: false
|
|
)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("calculate", %{"source" => source, "destination" => dest, "band" => band}, socket) do
|
|
socket = assign(socket, source: source, destination: dest, band: band, error: nil, computing: true, result: nil)
|
|
|
|
send(self(), {:compute_path, source, dest, String.to_integer(band)})
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:compute_path, source, dest, band_mhz}, socket) do
|
|
case compute_path(source, dest, band_mhz) do
|
|
{:ok, result} ->
|
|
{:noreply, assign(socket, result: result, computing: false)}
|
|
|
|
{:error, reason} ->
|
|
{:noreply, assign(socket, error: reason, computing: false)}
|
|
end
|
|
end
|
|
|
|
defp compute_path(source, dest, band_mhz) do
|
|
with {:ok, src} <- resolve_location(source),
|
|
{:ok, dst} <- resolve_location(dest) do
|
|
band_config = BandConfig.get(band_mhz) || BandConfig.get(10_000)
|
|
freq_ghz = band_mhz / 1000
|
|
|
|
dist_km = haversine_km(src.lat, src.lon, dst.lat, dst.lon)
|
|
bearing = bearing_deg(src.lat, src.lon, dst.lat, dst.lon)
|
|
|
|
# Terrain profile
|
|
terrain_result =
|
|
case ElevationClient.fetch_elevation_profile(src.lat, src.lon, dst.lat, dst.lon, 64, download: true) do
|
|
{:ok, profile} ->
|
|
analysis = TerrainAnalysis.analyse(profile, dist_km, freq_ghz)
|
|
%{profile: profile, analysis: analysis}
|
|
|
|
{:error, _} ->
|
|
nil
|
|
end
|
|
|
|
# HRRR profiles along path
|
|
now = DateTime.utc_now()
|
|
midlat = (src.lat + dst.lat) / 2
|
|
midlon = (src.lon + dst.lon) / 2
|
|
|
|
hrrr_profiles =
|
|
[{src.lat, src.lon}, {midlat, midlon}, {dst.lat, dst.lon}]
|
|
|> Enum.map(fn {lat, lon} -> Weather.find_nearest_hrrr(lat, lon, now) end)
|
|
|> Enum.reject(&is_nil/1)
|
|
|
|
# Build conditions and score
|
|
{conditions, scoring} = build_scoring(hrrr_profiles, src, now, band_config)
|
|
|
|
# Loss budget
|
|
loss_budget = compute_loss_budget(dist_km, freq_ghz, band_config, terrain_result, conditions)
|
|
|
|
{:ok,
|
|
%{
|
|
source: src,
|
|
destination: dst,
|
|
band_config: band_config,
|
|
band_mhz: band_mhz,
|
|
freq_ghz: freq_ghz,
|
|
dist_km: dist_km,
|
|
bearing: bearing,
|
|
terrain: terrain_result,
|
|
conditions: conditions,
|
|
scoring: scoring,
|
|
loss_budget: loss_budget,
|
|
hrrr_count: length(hrrr_profiles)
|
|
}}
|
|
end
|
|
end
|
|
|
|
defp resolve_location(input) do
|
|
input = String.trim(input)
|
|
|
|
cond do
|
|
input == "" ->
|
|
{:error, "Location is required"}
|
|
|
|
maidenhead?(input) ->
|
|
case Maidenhead.to_latlon(input) do
|
|
{:ok, {lat, lon}} -> {:ok, %{lat: lat, lon: lon, label: String.upcase(input), type: :grid}}
|
|
:error -> {:error, "Invalid grid square: #{input}"}
|
|
end
|
|
|
|
true ->
|
|
case CallsignClient.locate(input) do
|
|
{:ok, info} ->
|
|
{:ok,
|
|
%{lat: info.lat, lon: info.lon, label: String.upcase(info.callsign), grid: info.gridsquare, type: :callsign}}
|
|
|
|
{:error, reason} ->
|
|
{:error, "Could not find #{input}: #{reason}"}
|
|
end
|
|
end
|
|
end
|
|
|
|
defp maidenhead?(input) do
|
|
String.length(input) >= 4 and String.length(input) <= 10 and
|
|
Regex.match?(~r/^[A-Ra-r]{2}[0-9]{2}/i, input)
|
|
end
|
|
|
|
defp build_scoring([], _src, _now, _band_config), do: {nil, nil}
|
|
|
|
defp build_scoring(profiles, src, now, band_config) 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)
|
|
|
|
if temps == [] or dewpoints == [] do
|
|
{nil, nil}
|
|
else
|
|
avg_temp_c = Enum.sum(temps) / length(temps)
|
|
avg_dewpoint_c = Enum.sum(dewpoints) / length(dewpoints)
|
|
|
|
pressures = profiles |> Enum.map(& &1.surface_pressure_mb) |> Enum.reject(&is_nil/1)
|
|
gradients = profiles |> Enum.map(& &1.min_refractivity_gradient) |> Enum.reject(&is_nil/1)
|
|
bl_depths = profiles |> Enum.map(& &1.hpbl_m) |> Enum.reject(&is_nil/1)
|
|
pwats = profiles |> Enum.map(& &1.pwat_mm) |> Enum.reject(&is_nil/1)
|
|
|
|
conditions = %{
|
|
abs_humidity: Scorer.absolute_humidity(avg_temp_c, avg_dewpoint_c),
|
|
temp_f: Scorer.c_to_f(avg_temp_c),
|
|
dewpoint_f: Scorer.c_to_f(avg_dewpoint_c),
|
|
temp_c: avg_temp_c,
|
|
dewpoint_c: avg_dewpoint_c,
|
|
wind_speed_kts: nil,
|
|
sky_cover_pct: nil,
|
|
utc_hour: now.hour,
|
|
utc_minute: now.minute,
|
|
month: now.month,
|
|
longitude: src.lon,
|
|
pressure_mb: if(pressures != [], do: Enum.min(pressures)),
|
|
prev_pressure_mb: nil,
|
|
rain_rate_mmhr: 0.0,
|
|
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))
|
|
}
|
|
|
|
scoring = Scorer.composite_score(conditions, band_config)
|
|
{conditions, scoring}
|
|
end
|
|
end
|
|
|
|
defp compute_loss_budget(dist_km, freq_ghz, band_config, terrain_result, conditions) do
|
|
freq_mhz = freq_ghz * 1000
|
|
fspl = 20 * :math.log10(max(dist_km, 0.001)) + 20 * :math.log10(freq_mhz) + 32.44
|
|
|
|
o2_loss = band_config.o2_db_km * dist_km
|
|
h2o_coeff = band_config.h2o_coeff
|
|
|
|
abs_humidity =
|
|
if conditions do
|
|
conditions.abs_humidity
|
|
else
|
|
7.5
|
|
end
|
|
|
|
h2o_loss = h2o_coeff * abs_humidity * dist_km
|
|
|
|
rain_loss =
|
|
if conditions && conditions.rain_rate_mmhr > 0 do
|
|
gamma = band_config.rain_k * :math.pow(conditions.rain_rate_mmhr, band_config.rain_alpha)
|
|
gamma * dist_km
|
|
else
|
|
0.0
|
|
end
|
|
|
|
diffraction_loss =
|
|
if terrain_result do
|
|
terrain_result.analysis.diffraction_db
|
|
else
|
|
0.0
|
|
end
|
|
|
|
total = fspl + o2_loss + h2o_loss + rain_loss + diffraction_loss
|
|
|
|
%{
|
|
fspl: Float.round(fspl, 1),
|
|
o2: Float.round(o2_loss, 2),
|
|
h2o: Float.round(h2o_loss, 2),
|
|
rain: Float.round(rain_loss, 2),
|
|
diffraction: Float.round(diffraction_loss, 1),
|
|
total: Float.round(total, 1)
|
|
}
|
|
end
|
|
|
|
# ── Geo helpers ──
|
|
|
|
defp haversine_km(lat1, lon1, lat2, lon2) do
|
|
r = 6371.0
|
|
dlat = deg_to_rad(lat2 - lat1)
|
|
dlon = deg_to_rad(lon2 - lon1)
|
|
a = :math.sin(dlat / 2) ** 2 + :math.cos(deg_to_rad(lat1)) * :math.cos(deg_to_rad(lat2)) * :math.sin(dlon / 2) ** 2
|
|
2 * r * :math.asin(:math.sqrt(a))
|
|
end
|
|
|
|
defp bearing_deg(lat1, lon1, lat2, lon2) do
|
|
lat1r = deg_to_rad(lat1)
|
|
lat2r = deg_to_rad(lat2)
|
|
dlonr = deg_to_rad(lon2 - lon1)
|
|
y = :math.sin(dlonr) * :math.cos(lat2r)
|
|
x = :math.cos(lat1r) * :math.sin(lat2r) - :math.sin(lat1r) * :math.cos(lat2r) * :math.cos(dlonr)
|
|
b = :math.atan2(y, x) * 180 / :math.pi()
|
|
Float.round(:math.fmod(b + 360, 360), 1)
|
|
end
|
|
|
|
defp deg_to_rad(d), do: d * :math.pi() / 180
|
|
|
|
# ── Score tier helpers ──
|
|
|
|
defp tier_label(score) when score >= 80, do: "EXCELLENT"
|
|
defp tier_label(score) when score >= 65, do: "GOOD"
|
|
defp tier_label(score) when score >= 50, do: "MARGINAL"
|
|
defp tier_label(score) when score >= 33, do: "POOR"
|
|
defp tier_label(_), do: "NEGLIGIBLE"
|
|
|
|
defp tier_color(score) when score >= 80, do: "#00ffa3"
|
|
defp tier_color(score) when score >= 65, do: "#7dffd4"
|
|
defp tier_color(score) when score >= 50, do: "#ffe566"
|
|
defp tier_color(score) when score >= 33, do: "#ff9044"
|
|
defp tier_color(_), do: "#ff4f4f"
|
|
|
|
defp terrain_verdict_class("CLEAR"), do: "badge badge-success"
|
|
defp terrain_verdict_class("FRESNEL_MINOR"), do: "badge badge-warning"
|
|
defp terrain_verdict_class("FRESNEL_PARTIAL"), do: "badge badge-warning"
|
|
defp terrain_verdict_class("BLOCKED"), do: "badge badge-error"
|
|
defp terrain_verdict_class(_), do: "badge"
|
|
|
|
defp format_number(n) when is_float(n), do: :erlang.float_to_binary(n, decimals: 1)
|
|
defp format_number(n), do: to_string(n)
|
|
|
|
# ── Render ──
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<Layouts.app flash={@flash} max_width="max-w-6xl">
|
|
<.header>
|
|
Path Calculator
|
|
<:subtitle>Analyze microwave propagation between two points</:subtitle>
|
|
</.header>
|
|
|
|
<form phx-submit="calculate" class="bg-base-200 rounded-box p-4 mb-6">
|
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
|
|
<div class="fieldset">
|
|
<label class="label text-sm font-medium">Source (callsign or grid)</label>
|
|
<input
|
|
type="text"
|
|
name="source"
|
|
value={@source}
|
|
placeholder="W5ISP or EM13sf"
|
|
class="input input-bordered w-full"
|
|
required
|
|
/>
|
|
</div>
|
|
<div class="fieldset">
|
|
<label class="label text-sm font-medium">Destination (callsign or grid)</label>
|
|
<input
|
|
type="text"
|
|
name="destination"
|
|
value={@destination}
|
|
placeholder="K5TR or EM00cd"
|
|
class="input input-bordered w-full"
|
|
required
|
|
/>
|
|
</div>
|
|
<div class="fieldset">
|
|
<label class="label text-sm font-medium">Band</label>
|
|
<select name="band" class="select select-bordered w-full">
|
|
<%= for {label, value} <- @band_options do %>
|
|
<option value={value} selected={value == @band}>{label}</option>
|
|
<% end %>
|
|
</select>
|
|
</div>
|
|
<button type="submit" class="btn btn-primary" disabled={@computing}>
|
|
<%= if @computing do %>
|
|
<span class="loading loading-spinner loading-sm"></span> Computing...
|
|
<% else %>
|
|
Calculate
|
|
<% end %>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
<%= if @error do %>
|
|
<div class="alert alert-error mb-6">{@error}</div>
|
|
<% end %>
|
|
|
|
<%= if @result do %>
|
|
<.path_results result={@result} />
|
|
<% end %>
|
|
</Layouts.app>
|
|
"""
|
|
end
|
|
|
|
defp path_results(assigns) do
|
|
~H"""
|
|
<%!-- Map --%>
|
|
<div
|
|
id="path-map"
|
|
phx-hook="ContactMap"
|
|
phx-update="ignore"
|
|
data-pos1={Jason.encode!(%{lat: @result.source.lat, lon: @result.source.lon})}
|
|
data-pos2={Jason.encode!(%{lat: @result.destination.lat, lon: @result.destination.lon})}
|
|
data-station1={@result.source.label}
|
|
data-station2={@result.destination.label}
|
|
class="w-full h-64 md:h-80 rounded-box overflow-hidden mb-4"
|
|
>
|
|
</div>
|
|
|
|
<%!-- Terrain profile --%>
|
|
<%= if @result.terrain do %>
|
|
<div
|
|
id="path-elevation"
|
|
phx-hook="ElevationProfile"
|
|
phx-update="ignore"
|
|
data-profile={Jason.encode!(elevation_data(@result))}
|
|
class="w-full h-48 md:h-56 rounded-box overflow-hidden mb-6 bg-base-200"
|
|
>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%!-- Summary cards --%>
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
|
<%!-- Link Summary --%>
|
|
<div class="bg-base-200 rounded-box p-4">
|
|
<div class="text-xs opacity-60 mb-2">Link Summary</div>
|
|
<div class="space-y-1 text-sm">
|
|
<div class="flex justify-between">
|
|
<span>Distance</span>
|
|
<span class="font-mono">{format_number(@result.dist_km)} km</span>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<span>Bearing</span>
|
|
<span class="font-mono">{format_number(@result.bearing)}°</span>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<span>Band</span>
|
|
<span class="font-mono">{@result.band_config.label}</span>
|
|
</div>
|
|
<%= if @result.terrain do %>
|
|
<div class="flex justify-between">
|
|
<span>Terrain</span>
|
|
<span class={terrain_verdict_class(@result.terrain.analysis.verdict)}>
|
|
{@result.terrain.analysis.verdict}
|
|
</span>
|
|
</div>
|
|
<% end %>
|
|
<div class="flex justify-between">
|
|
<span>HRRR Profiles</span>
|
|
<span class="font-mono">{@result.hrrr_count} / 3</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Loss Budget --%>
|
|
<div class="bg-base-200 rounded-box p-4">
|
|
<div class="text-xs opacity-60 mb-2">Loss Budget</div>
|
|
<div class="space-y-1 text-sm">
|
|
<div class="flex justify-between">
|
|
<span>Free Space</span>
|
|
<span class="font-mono">{@result.loss_budget.fspl} dB</span>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<span>O₂ Absorption</span>
|
|
<span class="font-mono">{@result.loss_budget.o2} dB</span>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<span>H₂O Absorption</span>
|
|
<span class="font-mono">{@result.loss_budget.h2o} dB</span>
|
|
</div>
|
|
<%= if @result.loss_budget.rain > 0 do %>
|
|
<div class="flex justify-between">
|
|
<span>Rain</span>
|
|
<span class="font-mono">{@result.loss_budget.rain} dB</span>
|
|
</div>
|
|
<% end %>
|
|
<%= if @result.loss_budget.diffraction > 0 do %>
|
|
<div class="flex justify-between">
|
|
<span>Diffraction</span>
|
|
<span class="font-mono">{@result.loss_budget.diffraction} dB</span>
|
|
</div>
|
|
<% end %>
|
|
<div class="divider my-1"></div>
|
|
<div class="flex justify-between font-bold">
|
|
<span>Total Path Loss</span>
|
|
<span class="font-mono">{@result.loss_budget.total} dB</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Propagation Score --%>
|
|
<div class="bg-base-200 rounded-box p-4">
|
|
<div class="text-xs opacity-60 mb-2">Propagation Score</div>
|
|
<%= if @result.scoring do %>
|
|
<div class="text-center mb-3">
|
|
<span
|
|
class="text-4xl font-bold"
|
|
style={"color: #{tier_color(@result.scoring.score)}"}
|
|
>
|
|
{@result.scoring.score}
|
|
</span>
|
|
<span class="text-sm opacity-60"> / 100</span>
|
|
<div
|
|
class="text-sm font-semibold mt-1"
|
|
style={"color: #{tier_color(@result.scoring.score)}"}
|
|
>
|
|
{tier_label(@result.scoring.score)}
|
|
</div>
|
|
</div>
|
|
<% else %>
|
|
<div class="text-center text-sm opacity-60 py-4">
|
|
No HRRR data available for current conditions
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Factor breakdown --%>
|
|
<%= if @result.scoring do %>
|
|
<div class="bg-base-200 rounded-box p-4 mb-6">
|
|
<div class="text-xs opacity-60 mb-3">Propagation Factors (current conditions)</div>
|
|
<.factor_table scoring={@result.scoring} />
|
|
</div>
|
|
<% end %>
|
|
|
|
<%!-- Atmospheric conditions --%>
|
|
<%= if @result.conditions do %>
|
|
<div class="bg-base-200 rounded-box p-4 mb-6">
|
|
<div class="text-xs opacity-60 mb-3">Path Atmospheric Conditions</div>
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
|
<div>
|
|
<span class="opacity-60">Temperature</span>
|
|
<div class="font-mono">{format_number(@result.conditions.temp_c)}°C</div>
|
|
</div>
|
|
<div>
|
|
<span class="opacity-60">Dewpoint</span>
|
|
<div class="font-mono">{format_number(@result.conditions.dewpoint_c)}°C</div>
|
|
</div>
|
|
<div>
|
|
<span class="opacity-60">T-Td Depression</span>
|
|
<div class="font-mono">
|
|
{format_number(@result.conditions.temp_c - @result.conditions.dewpoint_c)}°C
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<span class="opacity-60">Pressure</span>
|
|
<div class="font-mono">{format_number(@result.conditions.pressure_mb || 0)} mb</div>
|
|
</div>
|
|
<div>
|
|
<span class="opacity-60">PWAT</span>
|
|
<div class="font-mono">{format_number(@result.conditions.pwat_mm || 0)} mm</div>
|
|
</div>
|
|
<div>
|
|
<span class="opacity-60">BL Depth</span>
|
|
<div class="font-mono">{format_number(@result.conditions.bl_depth_m || 0)} m</div>
|
|
</div>
|
|
<div>
|
|
<span class="opacity-60">Refractivity Gradient</span>
|
|
<div class="font-mono">
|
|
{format_number(@result.conditions.min_refractivity_gradient || 0)} N/km
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<span class="opacity-60">Abs Humidity</span>
|
|
<div class="font-mono">{format_number(@result.conditions.abs_humidity)} g/m³</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
"""
|
|
end
|
|
|
|
defp factor_table(assigns) do
|
|
weights = BandConfig.weights()
|
|
|
|
rows =
|
|
assigns.scoring.factors
|
|
|> Enum.map(fn {key, score} ->
|
|
weight = Map.get(weights, key, 0)
|
|
|
|
%{
|
|
name: factor_name(key),
|
|
score: score,
|
|
weight: round(weight * 100),
|
|
contribution: Float.round(score * weight, 1)
|
|
}
|
|
end)
|
|
|> Enum.sort_by(& &1.contribution, :desc)
|
|
|
|
assigns = assign(assigns, :rows, rows)
|
|
|
|
~H"""
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th>Factor</th>
|
|
<th class="text-right">Score</th>
|
|
<th class="text-right">Weight</th>
|
|
<th class="text-right">Contribution</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<%= for row <- @rows do %>
|
|
<tr>
|
|
<td>{row.name}</td>
|
|
<td class="text-right font-mono">{row.score}</td>
|
|
<td class="text-right font-mono opacity-60">{row.weight}%</td>
|
|
<td class="text-right font-mono font-bold">{row.contribution}</td>
|
|
</tr>
|
|
<% end %>
|
|
</tbody>
|
|
</table>
|
|
"""
|
|
end
|
|
|
|
defp factor_name(:humidity), do: "Humidity"
|
|
defp factor_name(:time_of_day), do: "Time of Day"
|
|
defp factor_name(:td_depression), do: "T-Td Depression"
|
|
defp factor_name(:refractivity), do: "Refractivity"
|
|
defp factor_name(:sky), do: "Sky Cover"
|
|
defp factor_name(:season), do: "Season"
|
|
defp factor_name(:wind), do: "Wind"
|
|
defp factor_name(:rain), do: "Rain"
|
|
defp factor_name(:pwat), do: "PWAT"
|
|
defp factor_name(:pressure), do: "Pressure"
|
|
|
|
defp elevation_data(result) do
|
|
points =
|
|
Enum.map(result.terrain.analysis.points, fn p ->
|
|
%{
|
|
dist_km: p.dist_km,
|
|
elev_m: p.elev,
|
|
beam_m: p[:beam],
|
|
fresnel_top: if(p[:beam] && p[:r1], do: p.beam + p.r1),
|
|
fresnel_bottom: if(p[:beam] && p[:r1], do: p.beam - p.r1)
|
|
}
|
|
end)
|
|
|
|
%{
|
|
points: points,
|
|
freq_mhz: result.band_mhz,
|
|
dist_km: result.dist_km,
|
|
verdict: result.terrain.analysis.verdict,
|
|
ducts: []
|
|
}
|
|
end
|
|
end
|