defmodule MicrowavepropWeb.PathLive do @moduledoc false use MicrowavepropWeb, :live_view alias Microwaveprop.Propagation 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 BandConfig.band_options() @url_params ~w(source destination band src_height_ft dst_height_ft tx_power_dbm src_gain_dbi dst_gain_dbi) @defaults %{ "source" => "", "destination" => "", "band" => "10000", "src_height_ft" => "30", "dst_height_ft" => "30", "tx_power_dbm" => "20", "src_gain_dbi" => "30", "dst_gain_dbi" => "30" } @impl true def mount(_params, _session, socket) do {:ok, assign(socket, page_title: "Path Calculator", band_options: @band_options, result: nil, error: nil, computing: false, source: "", destination: "", band: "10000", src_height_ft: "30", dst_height_ft: "30", tx_power_dbm: "20", src_gain_dbi: "30", dst_gain_dbi: "30", source_is_gps: false )} end @impl true def handle_params(params, _uri, socket) do p = Map.merge(@defaults, Map.take(params, @url_params)) is_gps = p["source"] == "gps" socket = assign(socket, source: if(is_gps, do: socket.assigns.source, else: p["source"]), destination: p["destination"], band: p["band"], src_height_ft: p["src_height_ft"], dst_height_ft: p["dst_height_ft"], tx_power_dbm: p["tx_power_dbm"], src_gain_dbi: p["src_gain_dbi"], dst_gain_dbi: p["dst_gain_dbi"], source_is_gps: is_gps ) # If source=gps and we already have coords from a previous fix, just recalculate. # Only request GPS from the device on the initial page load (no coords yet). if p["source"] == "gps" and connected?(socket) do if socket.assigns.source != "" and socket.assigns.source != nil do auto_calculate(socket, Map.put(p, "source", socket.assigns.source)) else {:noreply, push_event(socket, "request_gps", %{})} end else auto_calculate(socket, p) end end defp auto_calculate(socket, p) do if p["source"] != "" and p["source"] != "gps" and p["destination"] != "" and not socket.assigns.computing and is_nil(socket.assigns.result) do src_ht = parse_float(p["src_height_ft"], 30.0) dst_ht = parse_float(p["dst_height_ft"], 30.0) tx_dbm = parse_float(p["tx_power_dbm"], 20.0) station_params = %{ src_height_ft: src_ht, dst_height_ft: dst_ht, src_height_m: src_ht * 0.3048, dst_height_m: dst_ht * 0.3048, tx_power_dbm: tx_dbm, tx_power_mw: :math.pow(10, tx_dbm / 10), src_gain_dbi: parse_float(p["src_gain_dbi"], 30.0), dst_gain_dbi: parse_float(p["dst_gain_dbi"], 30.0) } send(self(), {:compute_path, p["source"], p["destination"], String.to_integer(p["band"]), station_params}) {:noreply, assign(socket, computing: true)} else {:noreply, socket} end end @impl true def handle_event("update_form", params, socket) do {:noreply, assign(socket, source: params["source"] || "", destination: params["destination"] || "", band: params["band"] || "10000", src_height_ft: params["src_height_ft"] || "30", dst_height_ft: params["dst_height_ft"] || "30", tx_power_dbm: params["tx_power_dbm"] || "20", src_gain_dbi: params["src_gain_dbi"] || "30", dst_gain_dbi: params["dst_gain_dbi"] || "30" )} end def handle_event("calculate", params, socket) do src_height_ft = parse_float(params["src_height_ft"], 30.0) dst_height_ft = parse_float(params["dst_height_ft"], 30.0) tx_power_dbm = parse_float(params["tx_power_dbm"], 20.0) tx_power_mw = :math.pow(10, tx_power_dbm / 10) station_params = %{ src_height_ft: src_height_ft, dst_height_ft: dst_height_ft, src_height_m: src_height_ft * 0.3048, dst_height_m: dst_height_ft * 0.3048, tx_power_dbm: tx_power_dbm, tx_power_mw: tx_power_mw, src_gain_dbi: parse_float(params["src_gain_dbi"], 30.0), dst_gain_dbi: parse_float(params["dst_gain_dbi"], 30.0) } socket = assign(socket, source: params["source"], destination: params["destination"], band: params["band"], src_height_ft: params["src_height_ft"], dst_height_ft: params["dst_height_ft"], tx_power_dbm: params["tx_power_dbm"], src_gain_dbi: params["src_gain_dbi"], dst_gain_dbi: params["dst_gain_dbi"], error: nil, computing: true, result: nil ) send( self(), {:compute_path, params["source"], params["destination"], String.to_integer(params["band"]), station_params} ) url_params = params |> Map.take(@url_params) |> then(fn p -> if socket.assigns.source_is_gps, do: Map.put(p, "source", "gps"), else: p end) |> Enum.reject(fn {_k, v} -> v == "" end) |> Map.new() {:noreply, push_patch(socket, to: ~p"/path?#{url_params}", replace: true)} end def handle_event("gps_location", %{"lat" => lat, "lon" => lon}, socket) do coords = "#{Float.round(lat / 1, 6)},#{Float.round(lon / 1, 6)}" # Show coords in the input but keep source=gps in the URL socket = assign(socket, source: coords, source_is_gps: true, result: nil) url_params = %{ "source" => "gps", "destination" => socket.assigns.destination, "band" => socket.assigns.band, "src_height_ft" => socket.assigns.src_height_ft, "dst_height_ft" => socket.assigns.dst_height_ft, "tx_power_dbm" => socket.assigns.tx_power_dbm, "src_gain_dbi" => socket.assigns.src_gain_dbi, "dst_gain_dbi" => socket.assigns.dst_gain_dbi } |> Enum.reject(fn {_k, v} -> v == "" end) |> Map.new() {:noreply, push_patch(socket, to: ~p"/path?#{url_params}", replace: true)} end @impl true def handle_info({:compute_path, source, dest, band_mhz, station_params}, socket) do case compute_path(source, dest, band_mhz, station_params) 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, station_params) 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 with antenna heights 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, ant_ht_a: station_params.src_height_m, ant_ht_b: station_params.dst_height_m ) %{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 and power budgets loss_budget = compute_loss_budget(dist_km, freq_ghz, band_config, terrain_result, conditions) power_budget = compute_power_budget(station_params, loss_budget) # 18-hour forecast from propagation grid (midpoint of path) forecast = Propagation.point_forecast(band_mhz, midlat, midlon) {:ok, %{ source: src, destination: dst, station_params: station_params, 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, power_budget: power_budget, forecast: forecast, hrrr_count: length(hrrr_profiles) }} end end defp resolve_location(input) do input = String.trim(input) cond do input == "" -> {:error, "Location is required"} coordinate_pair?(input) -> [lat_s, lon_s] = String.split(input, ",", parts: 2) {lat, _} = Float.parse(String.trim(lat_s)) {lon, _} = Float.parse(String.trim(lon_s)) {:ok, %{lat: lat, lon: lon, label: "#{Float.round(lat, 3)}, #{Float.round(lon, 3)}", type: :coordinates}} 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 coordinate_pair?(input) do case String.split(input, ",", parts: 2) do [a, b] -> match?({_, _}, Float.parse(String.trim(a))) and match?({_, _}, Float.parse(String.trim(b))) _ -> false 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 # TerrainAnalysis.deygout_diffraction returns integer 0 for clear # paths; coerce to float so Float.round/2 below is happy. terrain_result.analysis.diffraction_db * 1.0 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 defp compute_power_budget(station_params, loss_budget) do tx_power_dbm = station_params.tx_power_dbm eirp_dbm = tx_power_dbm + station_params.src_gain_dbi rx_power_dbm = eirp_dbm - loss_budget.total + station_params.dst_gain_dbi # Typical receiver sensitivities by mode (dBm) # CW ~-140, SSB ~-130, FM ~-120 rx_sensitivity_cw = -140.0 rx_sensitivity_ssb = -130.0 margin_cw = rx_power_dbm - rx_sensitivity_cw margin_ssb = rx_power_dbm - rx_sensitivity_ssb %{ tx_power_dbm: Float.round(tx_power_dbm, 1), eirp_dbm: Float.round(eirp_dbm, 1), rx_power_dbm: Float.round(rx_power_dbm, 1), margin_cw: Float.round(margin_cw, 1), margin_ssb: Float.round(margin_ssb, 1) } end defp parse_float(nil, default), do: default defp parse_float("", default), do: default defp parse_float(str, default) do case Float.parse(str) do {val, _} -> val :error -> default end end defdelegate haversine_km(lat1, lon1, lat2, lon2), to: Microwaveprop.Geo defdelegate bearing_deg(lat1, lon1, lat2, lon2), to: Microwaveprop.Geo # ── 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: "#059669" defp tier_color(score) when score >= 65, do: "#0d9488" defp tier_color(score) when score >= 50, do: "#ca8a04" defp tier_color(score) when score >= 33, do: "#ea580c" defp tier_color(_), do: "#dc2626" 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) defp format_utc_hour(%DateTime{} = dt), do: Calendar.strftime(dt, "%H:%M") defp format_utc_hour(_), do: "?" defp format_mw(mw) when mw >= 1000, do: "#{format_number(mw / 1000)} W" defp format_mw(mw) when mw >= 1, do: "#{format_number(mw)} mW" defp format_mw(mw), do: "#{format_number(mw * 1000)} uW" # ── Render ── @impl true def render(assigns) do ~H""" <.header> Path Calculator <:subtitle>Analyze microwave propagation between two points
<%= if @error do %>
{@error}
<% end %> <%= if @result do %> <.path_results result={@result} /> <% end %>
""" end defp path_results(assigns) do ~H""" <%!-- Map --%>
<%!-- Terrain profile --%> <%= if @result.terrain do %>
<% end %> <%!-- Link Summary --%>
Link Summary · {@result.band_config.label}
Distance
{format_number(@result.dist_km)} km
Bearing
{format_number(@result.bearing)}°
<%= if @result.terrain do %>
Terrain
{@result.terrain.analysis.verdict}
<% end %> <%= if @result.scoring do %>
Propagation
{@result.scoring.score}/100
{tier_label(@result.scoring.score)}
<% end %>
HRRR Data
{@result.hrrr_count} / 3 points
<%!-- Loss Budget --%>
Loss Budget · {@result.band_config.label}
<.budget_row label="FSPL (free-space)" value={@result.loss_budget.fspl} unit="dB" /> <.budget_row label="Gaseous (O\u2082+H\u2082O)" value={Float.round(@result.loss_budget.o2 + @result.loss_budget.h2o, 2)} unit="dB" /> <%= if @result.loss_budget.diffraction > 0 do %> <.budget_row label="Diffraction loss" value={@result.loss_budget.diffraction} unit="dB" highlight /> <% end %> <%= if @result.loss_budget.rain > 0 do %> <.budget_row label="Rain attenuation" value={@result.loss_budget.rain} unit="dB" /> <% end %>
<.budget_row label="TOTAL PATH LOSS" value={@result.loss_budget.total} unit="dB" bold />
Gas rate: {format_number( Float.round( (@result.loss_budget.o2 + @result.loss_budget.h2o) / max(@result.dist_km, 0.001), 4 ) )} dB/km
H₂O: {format_number(@result.band_config.h2o_coeff)} dB/km/g · O₂: {format_number(@result.band_config.o2_db_km)} dB/km
<%= if @result.conditions do %>
Abs humidity avg: {format_number(@result.conditions.abs_humidity)} g/m³
<% end %>
<%!-- Power Budget --%>
Power Budget
<.budget_row label="TX Power" value={@result.power_budget.tx_power_dbm} unit="dBm" note={"(#{format_mw(@result.station_params.tx_power_mw)})"} /> <.budget_row label="TX Ant Gain" value={@result.station_params.src_gain_dbi} unit="dBi" positive /> <.budget_row label="Feed Loss (×2)" value={-2.0} unit="dB" />
<.budget_row label="EIRP" value={@result.power_budget.eirp_dbm - 2.0} unit="dBm" bold /> <.budget_row label="Path Loss (w/FF)" value={-@result.loss_budget.total} unit="dB" highlight /> <.budget_row label="RX Ant Gain" value={@result.station_params.dst_gain_dbi} unit="dBi" positive />
<.budget_row label="RX Power" value={@result.power_budget.rx_power_dbm - 2.0} unit="dBm" bold /> <.budget_row label="Sensitivity (CW)" value={-141.0} unit="dBm" />
<% margin = @result.power_budget.margin_cw - 2.0 %> <.budget_row label="MARGIN" value={Float.round(margin, 1)} unit="dB" bold color={if margin > 0, do: "text-success", else: "text-error"} />
<%!-- Path Weather --%> <%= if @result.conditions do %>
Path Weather Avg
Temperature
{format_number(@result.conditions.temp_f)}°F
{format_number(@result.conditions.temp_c)}°C
Dewpoint
{format_number(@result.conditions.dewpoint_f)}°F
{format_number(@result.conditions.dewpoint_c)}°C
T/Td Gap
{format_number(@result.conditions.temp_f - @result.conditions.dewpoint_f)}°F
Abs Humidity
{format_number(@result.conditions.abs_humidity)} g/m³
Pressure
{format_number(@result.conditions.pressure_mb || 0)} mb
PWAT
{format_number(@result.conditions.pwat_mm || 0)} mm
BL Depth
{format_number(@result.conditions.bl_depth_m || 0)} m
Refrac Gradient
{format_number(@result.conditions.min_refractivity_gradient || 0)} N/km
<% end %> <%!-- Propagation Factors --%> <%= if @result.scoring do %>
Propagation Factors · {@result.band_config.label}
<.factor_bars scoring={@result.scoring} />
<% end %>
<%!-- 18-Hour Forecast --%> <%= if @result.forecast != [] do %> <.forecast_chart forecast={@result.forecast} band_config={@result.band_config} /> <% end %> <%!-- Share link --%>
""" end attr :forecast, :list, required: true attr :band_config, :map, required: true defp forecast_chart(assigns) do points = build_forecast_points(assigns.forecast) assigns = assigns |> assign(:points, points) |> assign(:polyline, Enum.map_join(points, " ", fn p -> "#{p.x},#{p.y}" end)) |> assign(:now_pt, Enum.find(points, & &1.now?) || hd(points)) |> assign(:best_pt, Enum.max_by(points, & &1.score)) |> assign(:worst_pt, Enum.min_by(points, & &1.score)) |> assign(:trend, forecast_trend(points)) |> assign(:x_labels, forecast_x_labels(points)) ~H"""
{length(@forecast)}-Hour Propagation Forecast · {@band_config.label}
{Phoenix.HTML.raw(@trend)}
<%= for s <- [0, 50, 100] do %> <% y = 6 + (100 - s) / 100 * 70 %> {s} <% end %> <%= for pt <- @points do %> <% end %> {@best_pt.score} <%= for {x, label} <- @x_labels do %> {label} <% end %>
Best: {@best_pt.score} at {@best_pt.label} UTC Worst: {@worst_pt.score} at {@worst_pt.label} UTC
""" end defp build_forecast_points(forecast) do n = length(forecast) now = DateTime.utc_now() # Map each forecast point to SVG coords (viewBox 0 0 300 96) # plot area: x in [28, 294], y in [6, 76] indexed = Enum.with_index(forecast) now_idx = indexed |> Enum.min_by(fn {p, _i} -> abs(DateTime.diff(p.valid_time, now, :second)) end) |> elem(1) Enum.map(indexed, fn {p, i} -> x = if n <= 1 do 161 else 28 + i / (n - 1) * 266 end y = 6 + (100 - p.score) / 100 * 70 %{ x: Float.round(x * 1.0, 2), y: Float.round(y * 1.0, 2), score: p.score, valid_time: p.valid_time, label: format_utc_hour(p.valid_time), now?: i == now_idx } end) end defp forecast_trend(points) do now_idx = Enum.find_index(points, & &1.now?) || 0 future = Enum.drop(points, now_idx) case future do [first | _] -> last = List.last(future) diff = last.score - first.score cond do diff > 5 -> ~s(▲ Improving) diff < -5 -> ~s(▼ Declining) true -> ~s(→ Steady) end _ -> ~s(→ Steady) end end defp forecast_x_labels(points) do n = length(points) indices = if n <= 3 do Enum.to_list(0..(n - 1)) else [0, div(n - 1, 2), n - 1] end Enum.map(indices, fn i -> p = Enum.at(points, i) {p.x, p.label} end) end attr :label, :string, required: true attr :value, :any, required: true attr :unit, :string, required: true attr :note, :string, default: nil attr :bold, :boolean, default: false attr :highlight, :boolean, default: false attr :positive, :boolean, default: false attr :color, :string, default: nil defp budget_row(assigns) do ~H"""
{@label} {format_value(@value)} {@unit} <%= if @note do %> {@note} <% end %>
""" end defp format_value(v) when is_float(v), do: :erlang.float_to_binary(v, decimals: 1) defp format_value(v), do: to_string(v) defp factor_bars(assigns) do weights = BandConfig.weights() rows = assigns.scoring.factors |> Enum.map(fn {key, score} -> %{name: factor_name(key), score: score, weight: Map.get(weights, key, 0)} end) |> Enum.sort_by(& &1.score, :desc) assigns = assign(assigns, :rows, rows) ~H"""
<%= for row <- @rows do %>
{row.name} {row.score}
<% end %>
""" end defp score_bar_color(s) when s >= 80, do: "#059669" defp score_bar_color(s) when s >= 65, do: "#0d9488" defp score_bar_color(s) when s >= 50, do: "#ca8a04" defp score_bar_color(s) when s >= 33, do: "#ea580c" defp score_bar_color(_), do: "#dc2626" 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 # ElevationProfile hook expects: points with dist_km, elev, beam, r1 points = Enum.map(result.terrain.analysis.points, fn p -> %{ dist_km: p.dist_km, elev: p.elev, beam: p[:beam] || 0, r1: p[:r1] || 0 } end) %{ points: points, freq_mhz: result.band_mhz, dist_km: result.dist_km, verdict: result.terrain.analysis.verdict, ducts: [] } end end