- Overlay HRRR duct layers on elevation profile chart (green bands) - Add flagged_invalid field to contacts with toggle button on detail page - Fix prune_old_grid_profiles to only delete 0.125° grid profiles, preserving QSO-linked HRRR data at arbitrary positions - Auto-enqueue HRRR fetch when viewing contact without HRRR data (<48h) - Compact solar conditions and contact details layout - Add band/mode/distance info line to elevation profile section
700 lines
25 KiB
Elixir
700 lines
25 KiB
Elixir
defmodule MicrowavepropWeb.ContactLive.Show do
|
|
@moduledoc false
|
|
use MicrowavepropWeb, :live_view
|
|
|
|
alias Microwaveprop.Radio
|
|
alias Microwaveprop.Terrain
|
|
alias Microwaveprop.Terrain.ElevationClient
|
|
alias Microwaveprop.Terrain.TerrainAnalysis
|
|
alias Microwaveprop.Weather
|
|
alias Microwaveprop.Weather.HrrrClient
|
|
alias Microwaveprop.Workers.HrrrFetchWorker
|
|
|
|
@earth_radius_m 6_371_000.0
|
|
|
|
@impl true
|
|
def mount(%{"id" => id}, _session, socket) do
|
|
contact = Radio.get_contact!(id)
|
|
|
|
weather = load_weather(contact)
|
|
solar = load_solar(contact)
|
|
hrrr = Weather.hrrr_for_contact(contact)
|
|
{hrrr, hrrr_status} = maybe_enqueue_hrrr(hrrr, contact)
|
|
terrain = Terrain.get_terrain_profile(contact.id)
|
|
elevation_profile = compute_elevation_profile(contact, hrrr)
|
|
|
|
{:ok,
|
|
assign(socket,
|
|
page_title: "#{contact.station1} / #{contact.station2}",
|
|
contact: contact,
|
|
surface_observations: weather.surface_observations,
|
|
soundings: weather.soundings,
|
|
solar: solar,
|
|
hrrr: hrrr,
|
|
hrrr_status: hrrr_status,
|
|
terrain: terrain,
|
|
elevation_profile: elevation_profile,
|
|
terrain_expanded: false,
|
|
hrrr_profile_expanded: false,
|
|
obs_sort_by: "station_name",
|
|
obs_sort_order: "asc",
|
|
sounding_sort_by: "station_name",
|
|
sounding_sort_order: "asc",
|
|
expanded_soundings: MapSet.new()
|
|
)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("sort", %{"field" => field, "table" => "obs"}, socket) do
|
|
{sort_by, sort_order} =
|
|
toggle_sort(socket.assigns.obs_sort_by, socket.assigns.obs_sort_order, field)
|
|
|
|
sorted = sort_observations(socket.assigns.surface_observations, sort_by, sort_order)
|
|
|
|
{:noreply,
|
|
assign(socket,
|
|
surface_observations: sorted,
|
|
obs_sort_by: sort_by,
|
|
obs_sort_order: sort_order
|
|
)}
|
|
end
|
|
|
|
def handle_event("toggle_hrrr_profile", _params, socket) do
|
|
{:noreply, assign(socket, hrrr_profile_expanded: !socket.assigns.hrrr_profile_expanded)}
|
|
end
|
|
|
|
def handle_event("toggle_terrain", _params, socket) do
|
|
{:noreply, assign(socket, terrain_expanded: !socket.assigns.terrain_expanded)}
|
|
end
|
|
|
|
def handle_event("toggle_flag", _params, socket) do
|
|
contact = Radio.toggle_flagged_invalid!(socket.assigns.contact)
|
|
{:noreply, assign(socket, contact: contact)}
|
|
end
|
|
|
|
def handle_event("toggle_profile", %{"id" => id}, socket) do
|
|
expanded =
|
|
if MapSet.member?(socket.assigns.expanded_soundings, id) do
|
|
MapSet.delete(socket.assigns.expanded_soundings, id)
|
|
else
|
|
MapSet.put(socket.assigns.expanded_soundings, id)
|
|
end
|
|
|
|
{:noreply, assign(socket, expanded_soundings: expanded)}
|
|
end
|
|
|
|
def handle_event("sort", %{"field" => field, "table" => "soundings"}, socket) do
|
|
{sort_by, sort_order} =
|
|
toggle_sort(socket.assigns.sounding_sort_by, socket.assigns.sounding_sort_order, field)
|
|
|
|
sorted = sort_soundings(socket.assigns.soundings, sort_by, sort_order)
|
|
|
|
{:noreply,
|
|
assign(socket,
|
|
soundings: sorted,
|
|
sounding_sort_by: sort_by,
|
|
sounding_sort_order: sort_order
|
|
)}
|
|
end
|
|
|
|
defp toggle_sort(current_field, current_order, new_field) do
|
|
if current_field == new_field && current_order == "asc",
|
|
do: {new_field, "desc"},
|
|
else: {new_field, "asc"}
|
|
end
|
|
|
|
defp sort_observations(observations, sort_by, sort_order) do
|
|
sorter = obs_sort_key(sort_by)
|
|
sorted = Enum.sort_by(observations, sorter)
|
|
if sort_order == "desc", do: Enum.reverse(sorted), else: sorted
|
|
end
|
|
|
|
defp obs_sort_key("station_name"), do: fn obs -> obs.station.name || obs.station.station_code end
|
|
defp obs_sort_key("observed_at"), do: & &1.observed_at
|
|
defp obs_sort_key("temp_f"), do: &(&1.temp_f || 0)
|
|
defp obs_sort_key("dewpoint_f"), do: &(&1.dewpoint_f || 0)
|
|
defp obs_sort_key("relative_humidity"), do: &(&1.relative_humidity || 0)
|
|
defp obs_sort_key("sea_level_pressure_mb"), do: &(&1.sea_level_pressure_mb || 0)
|
|
defp obs_sort_key(_), do: fn obs -> obs.station.name || obs.station.station_code end
|
|
|
|
defp sort_soundings(soundings, sort_by, sort_order) do
|
|
sorter = sounding_sort_key(sort_by)
|
|
sorted = Enum.sort_by(soundings, sorter)
|
|
if sort_order == "desc", do: Enum.reverse(sorted), else: sorted
|
|
end
|
|
|
|
defp sounding_sort_key("station_name"), do: fn s -> s.station.name || s.station.station_code end
|
|
|
|
defp sounding_sort_key("observed_at"), do: & &1.observed_at
|
|
defp sounding_sort_key("surface_temp_c"), do: &(&1.surface_temp_c || 0)
|
|
defp sounding_sort_key("surface_refractivity"), do: &(&1.surface_refractivity || 0)
|
|
defp sounding_sort_key("k_index"), do: &(&1.k_index || 0)
|
|
defp sounding_sort_key("lifted_index"), do: &(&1.lifted_index || 0)
|
|
defp sounding_sort_key(_), do: fn s -> s.station.name || s.station.station_code end
|
|
|
|
defp load_weather(contact) do
|
|
lat = contact.pos1 && contact.pos1["lat"]
|
|
lon = contact.pos1 && (contact.pos1["lon"] || contact.pos1["lng"])
|
|
|
|
if lat && lon do
|
|
Weather.weather_for_contact(%{lat: lat, lon: lon, timestamp: contact.qso_timestamp},
|
|
radius_km: 300
|
|
)
|
|
else
|
|
%{surface_observations: [], soundings: []}
|
|
end
|
|
end
|
|
|
|
defp maybe_enqueue_hrrr(hrrr, _contact) when not is_nil(hrrr), do: {hrrr, :loaded}
|
|
|
|
defp maybe_enqueue_hrrr(nil, contact) do
|
|
lat = contact.pos1 && contact.pos1["lat"]
|
|
lon = contact.pos1 && (contact.pos1["lon"] || contact.pos1["lng"])
|
|
|
|
if lat && lon && hrrr_likely_available?(contact.qso_timestamp) do
|
|
valid_time = HrrrClient.nearest_hrrr_hour(contact.qso_timestamp)
|
|
{rlat, rlon} = Weather.round_to_hrrr_grid(lat, lon)
|
|
|
|
Oban.insert(
|
|
HrrrFetchWorker.new(%{
|
|
"lat" => rlat,
|
|
"lon" => rlon,
|
|
"valid_time" => DateTime.to_iso8601(valid_time)
|
|
})
|
|
)
|
|
|
|
{nil, :queued}
|
|
else
|
|
{nil, :unavailable}
|
|
end
|
|
end
|
|
|
|
# HRRR data is only available from ~2016 onward and kept for ~2 days on NOAA S3.
|
|
# Historical re-analysis archives go back further but the worker only fetches from the live feed.
|
|
# For contacts older than 48 hours, HRRR GRIB2 files are no longer on S3.
|
|
defp hrrr_likely_available?(timestamp) do
|
|
hours_ago = DateTime.diff(DateTime.utc_now(), timestamp, :hour)
|
|
hours_ago < 48
|
|
end
|
|
|
|
defp load_solar(contact) do
|
|
contact.qso_timestamp
|
|
|> DateTime.to_date()
|
|
|> Weather.get_solar_index()
|
|
end
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<Layouts.app flash={@flash}>
|
|
<.header>
|
|
<span class={[@contact.flagged_invalid && "line-through opacity-50"]}>
|
|
{@contact.station1} / {@contact.station2}
|
|
</span>
|
|
<:subtitle>
|
|
{Calendar.strftime(@contact.qso_timestamp, "%Y-%m-%d %H:%M UTC")}
|
|
<%= if @contact.flagged_invalid do %>
|
|
<span class="badge badge-error badge-sm ml-2">Flagged Invalid</span>
|
|
<% end %>
|
|
</:subtitle>
|
|
<:actions>
|
|
<button phx-click="toggle_flag" class={[
|
|
"btn btn-sm",
|
|
if(@contact.flagged_invalid, do: "btn-warning", else: "btn-ghost")
|
|
]}>
|
|
<.icon name="hero-flag" class="w-4 h-4" />
|
|
{if @contact.flagged_invalid, do: "Unflag", else: "Flag Invalid"}
|
|
</button>
|
|
<.link navigate={~p"/contacts"} class="btn btn-sm btn-ghost">
|
|
<.icon name="hero-arrow-left" class="w-4 h-4" /> Back to Contacts
|
|
</.link>
|
|
</:actions>
|
|
</.header>
|
|
|
|
<%= if @contact.pos1 && @contact.pos2 do %>
|
|
<p class="text-xs text-base-content/50 italic mb-1">
|
|
Locations approximate, in the center of the grid squares
|
|
</p>
|
|
<div
|
|
id="contact-map"
|
|
phx-hook="ContactMap"
|
|
phx-update="ignore"
|
|
data-pos1={Jason.encode!(@contact.pos1)}
|
|
data-pos2={Jason.encode!(@contact.pos2)}
|
|
data-station1={@contact.station1}
|
|
data-station2={@contact.station2}
|
|
class="w-full h-64 rounded-lg border border-base-300 mb-4"
|
|
/>
|
|
<% end %>
|
|
|
|
<%= if @elevation_profile do %>
|
|
<div class="mb-4">
|
|
<div class="text-sm font-mono mb-2 text-center">
|
|
{format_band_ghz(@contact.band)} · {@contact.mode}
|
|
· {format_dist(@elevation_profile.dist_km)}
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-x-8 text-sm font-mono mb-2">
|
|
<div>
|
|
{@contact.station1} → {@contact.station2}
|
|
<span class="ml-4">Az: {format_bearing(@elevation_profile.fwd_az)}</span>
|
|
<span class="ml-4">El: {@elevation_profile.fwd_el}°</span>
|
|
</div>
|
|
<div>
|
|
{@contact.station2} → {@contact.station1}
|
|
<span class="ml-4">Az: {format_bearing(@elevation_profile.rev_az)}</span>
|
|
<span class="ml-4">El: {@elevation_profile.rev_el}°</span>
|
|
</div>
|
|
</div>
|
|
|
|
<%= if @elevation_profile.verdict == "BLOCKED" && @elevation_profile.first_obstruction_km do %>
|
|
<div class="bg-error/10 text-error font-semibold px-3 py-1 rounded mb-2 text-sm">
|
|
Obstructed at {format_dist(@elevation_profile.first_obstruction_km)}
|
|
</div>
|
|
<% end %>
|
|
<%= if @elevation_profile.verdict == "CLEAR" do %>
|
|
<div class="bg-success/10 text-success font-semibold px-3 py-1 rounded mb-2 text-sm">
|
|
Line of sight clear
|
|
</div>
|
|
<% end %>
|
|
|
|
<div
|
|
id="elevation-profile"
|
|
phx-hook="ElevationProfile"
|
|
phx-update="ignore"
|
|
data-profile={Jason.encode!(@elevation_profile)}
|
|
class="w-full h-64 border border-base-300 rounded-lg"
|
|
>
|
|
<canvas></canvas>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
|
|
<div class="divider" />
|
|
|
|
<h2 class="text-base font-semibold mb-2" id="terrain-heading">Terrain Profile</h2>
|
|
<%= if @terrain do %>
|
|
<div class="mb-6 border border-base-300 rounded-lg" id="terrain-profile">
|
|
<button
|
|
phx-click="toggle_terrain"
|
|
class="w-full flex items-center justify-between p-4 hover:bg-base-200 transition-colors"
|
|
>
|
|
<div class="flex items-center gap-4 text-sm flex-wrap">
|
|
<span class={[
|
|
"badge badge-sm",
|
|
terrain_verdict_class(@terrain.verdict)
|
|
]}>
|
|
{@terrain.verdict}
|
|
</span>
|
|
<span>{@terrain.sample_count} samples</span>
|
|
<span>Max elev: {format_number(@terrain.max_elevation_m)} m</span>
|
|
<span>Min clearance: {format_number(@terrain.min_clearance_m)} m</span>
|
|
<%= if @terrain.diffraction_db && @terrain.diffraction_db > 0 do %>
|
|
<span>Diffraction: {format_number(@terrain.diffraction_db)} dB</span>
|
|
<% end %>
|
|
<%= if @terrain.obstructed_count && @terrain.obstructed_count > 0 do %>
|
|
<span class="text-error">{@terrain.obstructed_count} obstructed</span>
|
|
<% end %>
|
|
<%= if @terrain.fresnel_hit_count && @terrain.fresnel_hit_count > 0 do %>
|
|
<span class="text-warning">{@terrain.fresnel_hit_count} Fresnel hits</span>
|
|
<% end %>
|
|
</div>
|
|
<.icon
|
|
name={if @terrain_expanded, do: "hero-chevron-up", else: "hero-chevron-down"}
|
|
class="w-5 h-5"
|
|
/>
|
|
</button>
|
|
|
|
<%= if @terrain_expanded do %>
|
|
<div class="px-4 pb-4">
|
|
<div class="overflow-x-auto">
|
|
<table class="table table-xs table-zebra">
|
|
<thead>
|
|
<tr>
|
|
<th>#</th>
|
|
<th>Lat</th>
|
|
<th>Lon</th>
|
|
<th>Dist (km)</th>
|
|
<th>Elev (m)</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<%= for {pt, idx} <- Enum.with_index(@terrain.path_points) do %>
|
|
<tr>
|
|
<td>{idx}</td>
|
|
<td>{format_number(pt["lat"])}</td>
|
|
<td>{format_number(pt["lon"])}</td>
|
|
<td>{format_number(pt["dist_km"])}</td>
|
|
<td>{format_number(pt["elev"])}</td>
|
|
</tr>
|
|
<% end %>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% else %>
|
|
<p class="text-sm text-base-content/50 italic">No terrain profile available.</p>
|
|
<% end %>
|
|
|
|
<div class="divider" />
|
|
|
|
<h2 class="text-base font-semibold mb-2">Soundings</h2>
|
|
<%= if @soundings == [] do %>
|
|
<p class="text-sm text-base-content/50 italic">No soundings found nearby.</p>
|
|
<% else %>
|
|
<%= for s <- @soundings do %>
|
|
<div class="mb-6 border border-base-300 rounded-lg">
|
|
<button
|
|
phx-click="toggle_profile"
|
|
phx-value-id={s.id}
|
|
class="w-full flex items-center justify-between p-4 hover:bg-base-200 transition-colors"
|
|
>
|
|
<div class="flex items-center gap-4 text-sm">
|
|
<span class="font-semibold">{s.station.name || s.station.station_code}</span>
|
|
<span>{Calendar.strftime(s.observed_at, "%H:%M UTC")}</span>
|
|
<span>{s.level_count} levels</span>
|
|
<span>Sfc: {format_number(s.surface_temp_c)}°C</span>
|
|
<span>N: {format_number(s.surface_refractivity)}</span>
|
|
<span>K: {format_number(s.k_index)}</span>
|
|
<span>LI: {format_number(s.lifted_index)}</span>
|
|
<%= if s.ducting_detected do %>
|
|
<span class="badge badge-warning badge-sm">Ducting</span>
|
|
<% end %>
|
|
</div>
|
|
<.icon
|
|
name={
|
|
if MapSet.member?(@expanded_soundings, s.id),
|
|
do: "hero-chevron-up",
|
|
else: "hero-chevron-down"
|
|
}
|
|
class="w-5 h-5"
|
|
/>
|
|
</button>
|
|
|
|
<%= if MapSet.member?(@expanded_soundings, s.id) do %>
|
|
<div class="px-4 pb-4">
|
|
<div class="overflow-x-auto">
|
|
<table class="table table-xs table-zebra">
|
|
<thead>
|
|
<tr>
|
|
<th>Pressure (mb)</th>
|
|
<th>Height (m)</th>
|
|
<th>Temp (°C)</th>
|
|
<th>Dewpoint (°C)</th>
|
|
<th>Wind Dir (°)</th>
|
|
<th>Wind Spd (kts)</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<%= for level <- filter_profile(s.profile) do %>
|
|
<tr>
|
|
<td>{format_number(level["pres"])}</td>
|
|
<td>{format_number(level["hght"])}</td>
|
|
<td>{format_number(level["tmpc"])}</td>
|
|
<td>{format_number(level["dwpc"])}</td>
|
|
<td>{format_number(level["drct"])}</td>
|
|
<td>{format_number(level["sknt"])}</td>
|
|
</tr>
|
|
<% end %>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
<% end %>
|
|
|
|
<div class="divider" />
|
|
|
|
<h2 class="text-base font-semibold mb-2">Solar Conditions</h2>
|
|
<%= if @solar do %>
|
|
<div class="flex flex-wrap gap-x-6 gap-y-1 text-sm">
|
|
<div><span class="font-semibold">SFI:</span> {@solar.sfi || "—"}</div>
|
|
<div><span class="font-semibold">Sunspot #:</span> {@solar.sunspot_number || "—"}</div>
|
|
<div><span class="font-semibold">Ap:</span> {@solar.ap_index || "—"}</div>
|
|
<div><span class="font-semibold">Kp:</span> {format_kp(@solar.kp_values)}</div>
|
|
</div>
|
|
<% else %>
|
|
<p class="text-sm text-base-content/50 italic">No solar data available for this date.</p>
|
|
<% end %>
|
|
|
|
<div class="divider" />
|
|
|
|
<h2 class="text-base font-semibold mb-2">HRRR Model Profile</h2>
|
|
<%= if @hrrr do %>
|
|
<div class="mb-6 border border-base-300 rounded-lg">
|
|
<button
|
|
phx-click="toggle_hrrr_profile"
|
|
class="w-full flex items-center justify-between p-4 hover:bg-base-200 transition-colors"
|
|
>
|
|
<div class="flex items-center gap-4 text-sm">
|
|
<span class="font-semibold">
|
|
{format_number(@hrrr.lat)}°, {format_number(@hrrr.lon)}°
|
|
</span>
|
|
<span>{Calendar.strftime(@hrrr.valid_time, "%H:%M UTC")}</span>
|
|
<span>HPBL: {format_number(@hrrr.hpbl_m)} m</span>
|
|
<span>PWAT: {format_number(@hrrr.pwat_mm)} mm</span>
|
|
<span>N: {format_number(@hrrr.surface_refractivity)}</span>
|
|
<span>dN/dh: {format_number(@hrrr.min_refractivity_gradient)}</span>
|
|
<%= if @hrrr.ducting_detected do %>
|
|
<span class="badge badge-warning badge-sm">Ducting</span>
|
|
<% end %>
|
|
</div>
|
|
<.icon
|
|
name={if @hrrr_profile_expanded, do: "hero-chevron-up", else: "hero-chevron-down"}
|
|
class="w-5 h-5"
|
|
/>
|
|
</button>
|
|
|
|
<%= if @hrrr_profile_expanded do %>
|
|
<div class="px-4 pb-4">
|
|
<div class="grid grid-cols-2 gap-x-8 gap-y-1 text-sm mb-4">
|
|
<div>Surface Temp: {format_number(@hrrr.surface_temp_c)}°C</div>
|
|
<div>Surface Dewpoint: {format_number(@hrrr.surface_dewpoint_c)}°C</div>
|
|
<div>Surface Pressure: {format_number(@hrrr.surface_pressure_mb)} mb</div>
|
|
<div>
|
|
Run Time: {if @hrrr.run_time,
|
|
do: Calendar.strftime(@hrrr.run_time, "%Y-%m-%d %H:%M UTC"),
|
|
else: "—"}
|
|
</div>
|
|
</div>
|
|
<%= if @hrrr.profile && @hrrr.profile != [] do %>
|
|
<div class="overflow-x-auto">
|
|
<table class="table table-xs table-zebra">
|
|
<thead>
|
|
<tr>
|
|
<th>Pressure (mb)</th>
|
|
<th>Height (m)</th>
|
|
<th>Temp (°C)</th>
|
|
<th>Dewpoint (°C)</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<%= for level <- @hrrr.profile do %>
|
|
<tr>
|
|
<td>{format_number(level["pres"])}</td>
|
|
<td>{format_number(level["hght"])}</td>
|
|
<td>{format_number(level["tmpc"])}</td>
|
|
<td>{format_number(level["dwpc"])}</td>
|
|
</tr>
|
|
<% end %>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% else %>
|
|
<p class="text-sm text-base-content/50 italic">
|
|
<%= case @hrrr_status do %>
|
|
<% :queued -> %>
|
|
HRRR fetch queued — reload in a few minutes.
|
|
<% :unavailable -> %>
|
|
HRRR data unavailable for this contact time.
|
|
<% _ -> %>
|
|
No HRRR profile available.
|
|
<% end %>
|
|
</p>
|
|
<% end %>
|
|
|
|
<div class="divider" />
|
|
|
|
<h2 class="text-base font-semibold mb-2">Surface Observations</h2>
|
|
<%= if @surface_observations == [] do %>
|
|
<p class="text-sm text-base-content/50 italic">No surface observations found nearby.</p>
|
|
<% else %>
|
|
<.table
|
|
id="surface-obs"
|
|
rows={@surface_observations}
|
|
row_id={fn obs -> "obs-#{obs.id}" end}
|
|
sort_by={@obs_sort_by}
|
|
sort_order={@obs_sort_order}
|
|
sort_target="obs"
|
|
>
|
|
<:col :let={obs} label="Station" sort_field="station_name">
|
|
{obs.station.name || obs.station.station_code}
|
|
</:col>
|
|
<:col :let={obs} label="Time" sort_field="observed_at">
|
|
{Calendar.strftime(obs.observed_at, "%H:%M")}
|
|
</:col>
|
|
<:col :let={obs} label="Temp (F)" sort_field="temp_f">{obs.temp_f || "—"}</:col>
|
|
<:col :let={obs} label="Dewpoint (F)" sort_field="dewpoint_f">
|
|
{obs.dewpoint_f || "—"}
|
|
</:col>
|
|
<:col :let={obs} label="RH%" sort_field="relative_humidity">
|
|
{format_number(obs.relative_humidity)}
|
|
</:col>
|
|
<:col :let={obs} label="Wind">{format_wind(obs)}</:col>
|
|
<:col :let={obs} label="Pressure (mb)" sort_field="sea_level_pressure_mb">
|
|
{obs.sea_level_pressure_mb || "—"}
|
|
</:col>
|
|
<:col :let={obs} label="Sky">{obs.sky_condition || "—"}</:col>
|
|
</.table>
|
|
<% end %>
|
|
</Layouts.app>
|
|
"""
|
|
end
|
|
|
|
defp format_number(nil), do: "—"
|
|
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_dist(km) do
|
|
mi = km * 0.621371
|
|
"#{:erlang.float_to_binary(mi, decimals: 1)} mi (#{:erlang.float_to_binary(km, decimals: 1)} km)"
|
|
end
|
|
|
|
defp format_bearing(deg) do
|
|
whole = trunc(deg)
|
|
frac = round((deg - whole) * 10)
|
|
"#{String.pad_leading(to_string(whole), 3, "0")}.#{frac}\u00B0"
|
|
end
|
|
|
|
defp format_band_ghz(nil), do: "—"
|
|
|
|
defp format_band_ghz(band) do
|
|
mhz = Decimal.to_float(band)
|
|
ghz = mhz / 1000
|
|
|
|
if ghz == Float.round(ghz, 0) do
|
|
"#{trunc(ghz)} GHz"
|
|
else
|
|
"#{:erlang.float_to_binary(ghz, decimals: 1)} GHz"
|
|
end
|
|
end
|
|
|
|
defp format_wind(obs) do
|
|
case {obs.wind_direction_deg, obs.wind_speed_kts} do
|
|
{nil, nil} -> "—"
|
|
{dir, speed} -> "#{dir || "?"}° @ #{speed || "?"} kts"
|
|
end
|
|
end
|
|
|
|
defp filter_profile(profile) do
|
|
Enum.filter(profile, fn level ->
|
|
level["tmpc"] != nil || level["dwpc"] != nil
|
|
end)
|
|
end
|
|
|
|
defp format_kp(nil), do: "—"
|
|
defp format_kp(values) when is_list(values), do: Enum.map_join(values, ", ", &format_number/1)
|
|
|
|
defp terrain_verdict_class("CLEAR"), do: "badge-success"
|
|
defp terrain_verdict_class("FRESNEL_MINOR"), do: "badge-warning"
|
|
defp terrain_verdict_class("FRESNEL_PARTIAL"), do: "badge-warning"
|
|
defp terrain_verdict_class("BLOCKED"), do: "badge-error"
|
|
defp terrain_verdict_class(_), do: "badge-ghost"
|
|
|
|
defp compute_elevation_profile(contact, hrrr) do
|
|
with %{"lat" => lat1} <- contact.pos1,
|
|
lon1 when is_number(lon1) <- contact.pos1["lon"] || contact.pos1["lng"],
|
|
%{"lat" => lat2} <- contact.pos2,
|
|
lon2 when is_number(lon2) <- contact.pos2["lon"] || contact.pos2["lng"],
|
|
{:ok, profile} <- ElevationClient.fetch_elevation_profile(lat1, lon1, lat2, lon2, 256) do
|
|
freq_ghz = band_to_ghz(contact.band)
|
|
dist_km = haversine_km(lat1, lon1, lat2, lon2)
|
|
analysis = TerrainAnalysis.analyse(profile, dist_km, freq_ghz)
|
|
|
|
fwd_az = initial_bearing(lat1, lon1, lat2, lon2)
|
|
rev_az = initial_bearing(lat2, lon2, lat1, lon1)
|
|
|
|
tx_elev = hd(profile).elev
|
|
rx_elev = List.last(profile).elev
|
|
fwd_el = elevation_angle(tx_elev, rx_elev, dist_km * 1000)
|
|
rev_el = elevation_angle(rx_elev, tx_elev, dist_km * 1000)
|
|
|
|
first_obs =
|
|
case Enum.find(analysis.points, & &1.obstructed) do
|
|
nil -> nil
|
|
p -> p.dist_km
|
|
end
|
|
|
|
ducts = extract_ducts(hrrr, tx_elev)
|
|
|
|
%{
|
|
points:
|
|
Enum.map(analysis.points, fn p ->
|
|
%{elev: p.elev, beam: p.beam, r1: p.r1, dist_km: p.dist_km}
|
|
end),
|
|
freq_mhz: freq_ghz * 1000,
|
|
dist_km: dist_km,
|
|
fwd_az: fwd_az,
|
|
rev_az: rev_az,
|
|
fwd_el: fwd_el,
|
|
rev_el: rev_el,
|
|
verdict: analysis.verdict,
|
|
first_obstruction_km: first_obs,
|
|
ducts: ducts
|
|
}
|
|
else
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
defp extract_ducts(nil, _surface_elev), do: []
|
|
|
|
defp extract_ducts(hrrr, surface_elev) do
|
|
case hrrr.duct_characteristics do
|
|
ducts when is_list(ducts) and ducts != [] ->
|
|
Enum.map(ducts, fn d ->
|
|
%{
|
|
base_m_msl: surface_elev + (d["base"] || 0),
|
|
top_m_msl: surface_elev + (d["top"] || 0),
|
|
strength: d["strength"]
|
|
}
|
|
end)
|
|
|
|
_ ->
|
|
[]
|
|
end
|
|
end
|
|
|
|
defp band_to_ghz(nil), do: 10.0
|
|
|
|
defp band_to_ghz(band) do
|
|
band
|
|
|> Decimal.to_float()
|
|
|> Kernel./(1000)
|
|
end
|
|
|
|
defp haversine_km(lat1, lon1, lat2, lon2) do
|
|
dlat = deg_to_rad(lat2 - lat1)
|
|
dlon = deg_to_rad(lon2 - lon1)
|
|
rlat1 = deg_to_rad(lat1)
|
|
rlat2 = deg_to_rad(lat2)
|
|
|
|
a =
|
|
:math.sin(dlat / 2) ** 2 +
|
|
:math.cos(rlat1) * :math.cos(rlat2) * :math.sin(dlon / 2) ** 2
|
|
|
|
2 * 6371.0 * :math.asin(:math.sqrt(a))
|
|
end
|
|
|
|
defp initial_bearing(lat1, lon1, lat2, lon2) do
|
|
rlat1 = deg_to_rad(lat1)
|
|
rlat2 = deg_to_rad(lat2)
|
|
dlon = deg_to_rad(lon2 - lon1)
|
|
|
|
y = :math.sin(dlon) * :math.cos(rlat2)
|
|
|
|
x =
|
|
:math.cos(rlat1) * :math.sin(rlat2) -
|
|
:math.sin(rlat1) * :math.cos(rlat2) * :math.cos(dlon)
|
|
|
|
bearing = y |> :math.atan2(x) |> rad_to_deg()
|
|
Float.round(rem_float(bearing + 360, 360), 1)
|
|
end
|
|
|
|
defp elevation_angle(h_tx, h_rx, dist_m) do
|
|
k_r = 4 / 3 * @earth_radius_m
|
|
angle = :math.atan2(h_rx - h_tx, dist_m) - dist_m / (2 * k_r)
|
|
Float.round(rad_to_deg(angle), 2)
|
|
end
|
|
|
|
defp deg_to_rad(deg), do: deg * :math.pi() / 180
|
|
defp rad_to_deg(rad), do: rad * 180 / :math.pi()
|
|
defp rem_float(a, b), do: a - Float.floor(a / b) * b
|
|
end
|