Duct visualization, flag invalid contacts, fix HRRR pruning
- 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
This commit is contained in:
parent
f7a753626d
commit
7ba82f4601
6 changed files with 208 additions and 46 deletions
|
|
@ -3,6 +3,49 @@ import Chart from "../vendor/chart.js"
|
||||||
const M2FT = 3.28084
|
const M2FT = 3.28084
|
||||||
const KM2MI = 0.621371
|
const KM2MI = 0.621371
|
||||||
|
|
||||||
|
// Chart.js plugin to draw horizontal duct bands
|
||||||
|
const ductPlugin = {
|
||||||
|
id: "ductBands",
|
||||||
|
beforeDraw(chart) {
|
||||||
|
const ducts = chart.options.plugins.ductBands?.ducts
|
||||||
|
if (!ducts || ducts.length === 0) return
|
||||||
|
|
||||||
|
const {ctx, chartArea: {left, right, top, bottom}, scales: {y}} = chart
|
||||||
|
|
||||||
|
ctx.save()
|
||||||
|
for (const duct of ducts) {
|
||||||
|
const yTop = y.getPixelForValue(duct.top_ft)
|
||||||
|
const yBase = y.getPixelForValue(duct.base_ft)
|
||||||
|
|
||||||
|
// Clamp to chart area
|
||||||
|
const drawTop = Math.max(yTop, top)
|
||||||
|
const drawBase = Math.min(yBase, bottom)
|
||||||
|
if (drawTop >= drawBase) continue
|
||||||
|
|
||||||
|
ctx.fillStyle = "rgba(34, 197, 94, 0.12)"
|
||||||
|
ctx.fillRect(left, drawTop, right - left, drawBase - drawTop)
|
||||||
|
|
||||||
|
ctx.strokeStyle = "rgba(34, 197, 94, 0.5)"
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
ctx.setLineDash([4, 3])
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(left, drawTop)
|
||||||
|
ctx.lineTo(right, drawTop)
|
||||||
|
ctx.moveTo(left, drawBase)
|
||||||
|
ctx.lineTo(right, drawBase)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.setLineDash([])
|
||||||
|
|
||||||
|
// Label
|
||||||
|
const labelY = Math.max(drawTop + 12, top + 12)
|
||||||
|
ctx.fillStyle = "rgba(34, 197, 94, 0.8)"
|
||||||
|
ctx.font = "9px sans-serif"
|
||||||
|
ctx.fillText(`Duct ${Math.round(duct.base_ft)}–${Math.round(duct.top_ft)} ft (${duct.strength} M-units)`, left + 4, labelY)
|
||||||
|
}
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const ElevationProfile = {
|
export const ElevationProfile = {
|
||||||
mounted() {
|
mounted() {
|
||||||
const data = JSON.parse(this.el.dataset.profile)
|
const data = JSON.parse(this.el.dataset.profile)
|
||||||
|
|
@ -18,15 +61,27 @@ export const ElevationProfile = {
|
||||||
const fresnelLower = points.map(p => (p.beam - p.r1) * M2FT)
|
const fresnelLower = points.map(p => (p.beam - p.r1) * M2FT)
|
||||||
const showFresnel = data.freq_mhz >= 1000
|
const showFresnel = data.freq_mhz >= 1000
|
||||||
|
|
||||||
|
const ducts = (data.ducts || []).map(d => ({
|
||||||
|
base_ft: d.base_m_msl * M2FT,
|
||||||
|
top_ft: d.top_m_msl * M2FT,
|
||||||
|
strength: d.strength
|
||||||
|
}))
|
||||||
|
|
||||||
const allElevs = [...elevations, ...losLine]
|
const allElevs = [...elevations, ...losLine]
|
||||||
const minElev = Math.min(...allElevs) - 60
|
let minElev = Math.min(...allElevs) - 60
|
||||||
const maxElev = Math.max(...allElevs) + 120
|
let maxElev = Math.max(...allElevs) + 120
|
||||||
|
|
||||||
|
// Extend Y axis to include duct tops if needed
|
||||||
|
for (const d of ducts) {
|
||||||
|
if (d.top_ft + 40 > maxElev) maxElev = d.top_ft + 40
|
||||||
|
}
|
||||||
|
|
||||||
const canvas = this.el.querySelector("canvas")
|
const canvas = this.el.querySelector("canvas")
|
||||||
const ctx = canvas.getContext("2d")
|
const ctx = canvas.getContext("2d")
|
||||||
|
|
||||||
this.chart = new Chart(ctx, {
|
this.chart = new Chart(ctx, {
|
||||||
type: "line",
|
type: "line",
|
||||||
|
plugins: [ductPlugin],
|
||||||
data: {
|
data: {
|
||||||
labels: distances,
|
labels: distances,
|
||||||
datasets: [
|
datasets: [
|
||||||
|
|
@ -71,7 +126,18 @@ export const ElevationProfile = {
|
||||||
fill: false,
|
fill: false,
|
||||||
order: 2
|
order: 2
|
||||||
}
|
}
|
||||||
] : [])
|
] : []),
|
||||||
|
// Invisible dataset for duct legend entry
|
||||||
|
...(ducts.length > 0 ? [{
|
||||||
|
label: "Duct Layer",
|
||||||
|
data: [],
|
||||||
|
borderColor: "rgba(34, 197, 94, 0.5)",
|
||||||
|
backgroundColor: "rgba(34, 197, 94, 0.12)",
|
||||||
|
borderWidth: 2,
|
||||||
|
borderDash: [4, 3],
|
||||||
|
pointRadius: 0,
|
||||||
|
fill: false
|
||||||
|
}] : [])
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
|
|
@ -79,6 +145,7 @@ export const ElevationProfile = {
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
animation: {duration: 300},
|
animation: {duration: 300},
|
||||||
plugins: {
|
plugins: {
|
||||||
|
ductBands: {ducts},
|
||||||
legend: {
|
legend: {
|
||||||
display: true,
|
display: true,
|
||||||
position: "top",
|
position: "top",
|
||||||
|
|
|
||||||
|
|
@ -220,6 +220,12 @@ defmodule Microwaveprop.Radio do
|
||||||
Repo.get!(Contact, id)
|
Repo.get!(Contact, id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def toggle_flagged_invalid!(contact) do
|
||||||
|
contact
|
||||||
|
|> Ecto.Changeset.change(flagged_invalid: !contact.flagged_invalid)
|
||||||
|
|> Repo.update!()
|
||||||
|
end
|
||||||
|
|
||||||
def change_contact(contact, attrs \\ %{}) do
|
def change_contact(contact, attrs \\ %{}) do
|
||||||
Contact.submission_changeset(contact, attrs)
|
Contact.submission_changeset(contact, attrs)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ defmodule Microwaveprop.Radio.Contact do
|
||||||
field :iemre_queued, :boolean, default: false
|
field :iemre_queued, :boolean, default: false
|
||||||
field :user_submitted, :boolean, default: false
|
field :user_submitted, :boolean, default: false
|
||||||
field :submitter_email, :string
|
field :submitter_email, :string
|
||||||
|
field :flagged_invalid, :boolean, default: false
|
||||||
|
|
||||||
timestamps(type: :utc_datetime)
|
timestamps(type: :utc_datetime)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -264,10 +264,16 @@ defmodule Microwaveprop.Weather do
|
||||||
def prune_old_grid_profiles do
|
def prune_old_grid_profiles do
|
||||||
cutoff = DateTime.add(DateTime.utc_now(), -24, :hour)
|
cutoff = DateTime.add(DateTime.utc_now(), -24, :hour)
|
||||||
|
|
||||||
# Partitioned table: DELETE only scans relevant partitions via partition pruning.
|
# Only delete profiles on the 0.125° propagation grid, preserving QSO-linked
|
||||||
|
# profiles which sit at arbitrary lat/lon positions (HRRR's native ~3km grid).
|
||||||
%{num_rows: deleted} =
|
%{num_rows: deleted} =
|
||||||
Repo.query!(
|
Repo.query!(
|
||||||
"DELETE FROM hrrr_profiles WHERE valid_time < $1",
|
"""
|
||||||
|
DELETE FROM hrrr_profiles
|
||||||
|
WHERE valid_time < $1
|
||||||
|
AND (lat * 1000)::int % 125 = 0
|
||||||
|
AND (lon * 1000)::int % 125 = 0
|
||||||
|
""",
|
||||||
[cutoff],
|
[cutoff],
|
||||||
timeout: 120_000
|
timeout: 120_000
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
alias Microwaveprop.Terrain.ElevationClient
|
alias Microwaveprop.Terrain.ElevationClient
|
||||||
alias Microwaveprop.Terrain.TerrainAnalysis
|
alias Microwaveprop.Terrain.TerrainAnalysis
|
||||||
alias Microwaveprop.Weather
|
alias Microwaveprop.Weather
|
||||||
|
alias Microwaveprop.Weather.HrrrClient
|
||||||
|
alias Microwaveprop.Workers.HrrrFetchWorker
|
||||||
|
|
||||||
@earth_radius_m 6_371_000.0
|
@earth_radius_m 6_371_000.0
|
||||||
|
|
||||||
|
|
@ -17,8 +19,9 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
weather = load_weather(contact)
|
weather = load_weather(contact)
|
||||||
solar = load_solar(contact)
|
solar = load_solar(contact)
|
||||||
hrrr = Weather.hrrr_for_contact(contact)
|
hrrr = Weather.hrrr_for_contact(contact)
|
||||||
|
{hrrr, hrrr_status} = maybe_enqueue_hrrr(hrrr, contact)
|
||||||
terrain = Terrain.get_terrain_profile(contact.id)
|
terrain = Terrain.get_terrain_profile(contact.id)
|
||||||
elevation_profile = compute_elevation_profile(contact)
|
elevation_profile = compute_elevation_profile(contact, hrrr)
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
assign(socket,
|
assign(socket,
|
||||||
|
|
@ -28,6 +31,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
soundings: weather.soundings,
|
soundings: weather.soundings,
|
||||||
solar: solar,
|
solar: solar,
|
||||||
hrrr: hrrr,
|
hrrr: hrrr,
|
||||||
|
hrrr_status: hrrr_status,
|
||||||
terrain: terrain,
|
terrain: terrain,
|
||||||
elevation_profile: elevation_profile,
|
elevation_profile: elevation_profile,
|
||||||
terrain_expanded: false,
|
terrain_expanded: false,
|
||||||
|
|
@ -63,6 +67,11 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
{:noreply, assign(socket, terrain_expanded: !socket.assigns.terrain_expanded)}
|
{:noreply, assign(socket, terrain_expanded: !socket.assigns.terrain_expanded)}
|
||||||
end
|
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
|
def handle_event("toggle_profile", %{"id" => id}, socket) do
|
||||||
expanded =
|
expanded =
|
||||||
if MapSet.member?(socket.assigns.expanded_soundings, id) do
|
if MapSet.member?(socket.assigns.expanded_soundings, id) do
|
||||||
|
|
@ -136,6 +145,38 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
end
|
end
|
||||||
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
|
defp load_solar(contact) do
|
||||||
contact.qso_timestamp
|
contact.qso_timestamp
|
||||||
|> DateTime.to_date()
|
|> DateTime.to_date()
|
||||||
|
|
@ -147,9 +188,23 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash}>
|
<Layouts.app flash={@flash}>
|
||||||
<.header>
|
<.header>
|
||||||
{@contact.station1} / {@contact.station2}
|
<span class={[@contact.flagged_invalid && "line-through opacity-50"]}>
|
||||||
<:subtitle>{Calendar.strftime(@contact.qso_timestamp, "%Y-%m-%d %H:%M UTC")}</:subtitle>
|
{@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>
|
<: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">
|
<.link navigate={~p"/contacts"} class="btn btn-sm btn-ghost">
|
||||||
<.icon name="hero-arrow-left" class="w-4 h-4" /> Back to Contacts
|
<.icon name="hero-arrow-left" class="w-4 h-4" /> Back to Contacts
|
||||||
</.link>
|
</.link>
|
||||||
|
|
@ -174,20 +229,18 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
|
|
||||||
<%= if @elevation_profile do %>
|
<%= if @elevation_profile do %>
|
||||||
<div class="mb-4">
|
<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 class="grid grid-cols-2 gap-x-8 text-sm font-mono mb-2">
|
||||||
<div>
|
<div>
|
||||||
{@contact.station1} → {@contact.station2}
|
{@contact.station1} → {@contact.station2}
|
||||||
<span class="ml-4">
|
|
||||||
{format_dist(@elevation_profile.dist_km)}
|
|
||||||
</span>
|
|
||||||
<span class="ml-4">Az: {format_bearing(@elevation_profile.fwd_az)}</span>
|
<span class="ml-4">Az: {format_bearing(@elevation_profile.fwd_az)}</span>
|
||||||
<span class="ml-4">El: {@elevation_profile.fwd_el}°</span>
|
<span class="ml-4">El: {@elevation_profile.fwd_el}°</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{@contact.station2} → {@contact.station1}
|
{@contact.station2} → {@contact.station1}
|
||||||
<span class="ml-4">
|
|
||||||
{format_dist(@elevation_profile.dist_km)}
|
|
||||||
</span>
|
|
||||||
<span class="ml-4">Az: {format_bearing(@elevation_profile.rev_az)}</span>
|
<span class="ml-4">Az: {format_bearing(@elevation_profile.rev_az)}</span>
|
||||||
<span class="ml-4">El: {@elevation_profile.rev_el}°</span>
|
<span class="ml-4">El: {@elevation_profile.rev_el}°</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -218,29 +271,6 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
|
|
||||||
<div class="divider" />
|
<div class="divider" />
|
||||||
|
|
||||||
<h2 class="text-base font-semibold mb-2">Contact Details</h2>
|
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-x-6 gap-y-1 text-sm">
|
|
||||||
<div><span class="font-semibold">Station 1:</span> {@contact.station1}</div>
|
|
||||||
<div><span class="font-semibold">Station 2:</span> {@contact.station2}</div>
|
|
||||||
<div><span class="font-semibold">Band:</span> {@contact.band} MHz</div>
|
|
||||||
<div><span class="font-semibold">Mode:</span> {@contact.mode}</div>
|
|
||||||
<div><span class="font-semibold">Grid 1:</span> {@contact.grid1 || "—"}</div>
|
|
||||||
<div><span class="font-semibold">Grid 2:</span> {@contact.grid2 || "—"}</div>
|
|
||||||
<div><span class="font-semibold">Distance:</span> {@contact.distance_km || "—"} km</div>
|
|
||||||
<div>
|
|
||||||
<span class="font-semibold">Timestamp:</span>
|
|
||||||
{Calendar.strftime(@contact.qso_timestamp, "%Y-%m-%d %H:%M UTC")}
|
|
||||||
</div>
|
|
||||||
<div :if={@contact.user_submitted}>
|
|
||||||
<span class="badge badge-info badge-sm">User Submitted</span>
|
|
||||||
</div>
|
|
||||||
<div :if={@contact.user_submitted && @contact.submitter_email}>
|
|
||||||
<span class="font-semibold">Submitter:</span> {@contact.submitter_email}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divider" />
|
|
||||||
|
|
||||||
<h2 class="text-base font-semibold mb-2" id="terrain-heading">Terrain Profile</h2>
|
<h2 class="text-base font-semibold mb-2" id="terrain-heading">Terrain Profile</h2>
|
||||||
<%= if @terrain do %>
|
<%= if @terrain do %>
|
||||||
<div class="mb-6 border border-base-300 rounded-lg" id="terrain-profile">
|
<div class="mb-6 border border-base-300 rounded-lg" id="terrain-profile">
|
||||||
|
|
@ -380,12 +410,12 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
|
|
||||||
<h2 class="text-base font-semibold mb-2">Solar Conditions</h2>
|
<h2 class="text-base font-semibold mb-2">Solar Conditions</h2>
|
||||||
<%= if @solar do %>
|
<%= if @solar do %>
|
||||||
<.list>
|
<div class="flex flex-wrap gap-x-6 gap-y-1 text-sm">
|
||||||
<:item title="SFI">{@solar.sfi || "—"}</:item>
|
<div><span class="font-semibold">SFI:</span> {@solar.sfi || "—"}</div>
|
||||||
<:item title="Sunspot Number">{@solar.sunspot_number || "—"}</:item>
|
<div><span class="font-semibold">Sunspot #:</span> {@solar.sunspot_number || "—"}</div>
|
||||||
<:item title="Ap Index">{@solar.ap_index || "—"}</:item>
|
<div><span class="font-semibold">Ap:</span> {@solar.ap_index || "—"}</div>
|
||||||
<:item title="Kp Values">{format_kp(@solar.kp_values)}</:item>
|
<div><span class="font-semibold">Kp:</span> {format_kp(@solar.kp_values)}</div>
|
||||||
</.list>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<p class="text-sm text-base-content/50 italic">No solar data available for this date.</p>
|
<p class="text-sm text-base-content/50 italic">No solar data available for this date.</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
@ -458,7 +488,16 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<p class="text-sm text-base-content/50 italic">No HRRR profile available.</p>
|
<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 %>
|
<% end %>
|
||||||
|
|
||||||
<div class="divider" />
|
<div class="divider" />
|
||||||
|
|
@ -514,6 +553,19 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
"#{String.pad_leading(to_string(whole), 3, "0")}.#{frac}\u00B0"
|
"#{String.pad_leading(to_string(whole), 3, "0")}.#{frac}\u00B0"
|
||||||
end
|
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
|
defp format_wind(obs) do
|
||||||
case {obs.wind_direction_deg, obs.wind_speed_kts} do
|
case {obs.wind_direction_deg, obs.wind_speed_kts} do
|
||||||
{nil, nil} -> "—"
|
{nil, nil} -> "—"
|
||||||
|
|
@ -536,7 +588,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
defp terrain_verdict_class("BLOCKED"), do: "badge-error"
|
defp terrain_verdict_class("BLOCKED"), do: "badge-error"
|
||||||
defp terrain_verdict_class(_), do: "badge-ghost"
|
defp terrain_verdict_class(_), do: "badge-ghost"
|
||||||
|
|
||||||
defp compute_elevation_profile(contact) do
|
defp compute_elevation_profile(contact, hrrr) do
|
||||||
with %{"lat" => lat1} <- contact.pos1,
|
with %{"lat" => lat1} <- contact.pos1,
|
||||||
lon1 when is_number(lon1) <- contact.pos1["lon"] || contact.pos1["lng"],
|
lon1 when is_number(lon1) <- contact.pos1["lon"] || contact.pos1["lng"],
|
||||||
%{"lat" => lat2} <- contact.pos2,
|
%{"lat" => lat2} <- contact.pos2,
|
||||||
|
|
@ -560,6 +612,8 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
p -> p.dist_km
|
p -> p.dist_km
|
||||||
end
|
end
|
||||||
|
|
||||||
|
ducts = extract_ducts(hrrr, tx_elev)
|
||||||
|
|
||||||
%{
|
%{
|
||||||
points:
|
points:
|
||||||
Enum.map(analysis.points, fn p ->
|
Enum.map(analysis.points, fn p ->
|
||||||
|
|
@ -572,13 +626,32 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
fwd_el: fwd_el,
|
fwd_el: fwd_el,
|
||||||
rev_el: rev_el,
|
rev_el: rev_el,
|
||||||
verdict: analysis.verdict,
|
verdict: analysis.verdict,
|
||||||
first_obstruction_km: first_obs
|
first_obstruction_km: first_obs,
|
||||||
|
ducts: ducts
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
_ -> nil
|
_ -> nil
|
||||||
end
|
end
|
||||||
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(nil), do: 10.0
|
||||||
|
|
||||||
defp band_to_ghz(band) do
|
defp band_to_ghz(band) do
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
defmodule Microwaveprop.Repo.Migrations.AddFlaggedInvalidToContacts do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:qsos) do
|
||||||
|
add :flagged_invalid, :boolean, default: false, null: false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Add table
Reference in a new issue