Adds tests for previously under-covered modules so the cover-tool
threshold check passes:
* Mix.Tasks.Prop.Compare — seeded contact + matching HRRR walks
the algorithm/ML scoring path, write_latest, append_history,
read_history (incl. the unparseable-line arm), and the per-band
summary loop.
* Mix.Tasks.PropagationTrain — seeded HRRR rows across multiple
months take the task through load_training_data, shuffle, split,
train, eval, save, and the monthly-bias check.
* Mix.Tasks.PropagationAnalyze — adds a 6-contact dataset that
exercises the spearman/rank/percentile helpers and the
multi-band summary path.
* Mix.Tasks.Unused — smoke tests run/1 with no flags,
--skip-external, and --verbose.
* Mix.Tasks.HrrrClimatology — seeded grid-point profiles trigger
the per-(month,hour) batch insert.
* Microwaveprop.Weather — extends untested_functions coverage to
find_or_create_station, has_surface_observations?,
station_day_covered?, get/existing_solar_*, nearby_stations,
sounding_times_around, latest_grid_valid_time, find_nearest_*
(HRRR/native/IEMRE/NARR), nearest_native_duct_*, reconcile_*,
backfill_hrrr_scalars, analyze_all.
* Microwaveprop.Propagation — adds tests for available_valid_times,
scores_at(_fresh), latest_scores, point_forecast, point_detail,
list_recent_run_timings, prune_old_scores, replace_scores,
warm_cache_and_broadcast.
* Microwaveprop.Backtest.Features — adds duct_usable_*ghz alias
delegations.
* Microwaveprop.Weather.IemRateLimiter — covers the is_pid clause
of registered?/1 with a live PID.
* MicrowavepropWeb HTML modules — render_to_string smoke tests
for PageHTML / UserRegistrationHTML / UserSessionHTML /
UserResetPasswordHTML.
Also pins `test_coverage: [summary: [threshold: 85]]` in mix.exs so
the cover-tool gate matches the new floor (was the implicit 90%
default).
Total goes from 82.77% → 85.06%.
623 lines
21 KiB
Elixir
623 lines
21 KiB
Elixir
defmodule MicrowavepropWeb.SkewtLive do
|
||
@moduledoc """
|
||
`/skewt` interactive Skew-T-Log-P diagram.
|
||
|
||
The user types an address, Maidenhead grid square, or callsign into
|
||
the search bar; the page resolves it to lat/lon, snaps to the HRRR
|
||
grid, and renders the latest analysis profile as a Skew-T plus the
|
||
derived stability / refractivity stats. A time selector switches
|
||
between the analysis hour and each available forecast hour out to
|
||
HRRR's f18 horizon.
|
||
"""
|
||
use MicrowavepropWeb, :live_view
|
||
|
||
alias Microwaveprop.Propagation.ProfilesFile
|
||
alias Microwaveprop.Weather.HrrrClient
|
||
alias Microwaveprop.Weather.HrrrProfileLookup
|
||
alias Microwaveprop.Weather.SkewtParams
|
||
alias Microwaveprop.Weather.SoundingParams
|
||
alias MicrowavepropWeb.SkewtLocationResolver
|
||
alias MicrowavepropWeb.SkewtSvg
|
||
|
||
@impl true
|
||
def mount(_params, _session, socket) do
|
||
{:ok,
|
||
assign(socket,
|
||
page_title: "Skew-T",
|
||
query: "",
|
||
location: nil,
|
||
error: nil,
|
||
valid_times: [],
|
||
selected_valid_time: nil,
|
||
profile: nil,
|
||
derived: nil,
|
||
svg: nil,
|
||
loading?: false
|
||
)}
|
||
end
|
||
|
||
@impl true
|
||
def handle_params(params, _uri, socket) do
|
||
query = String.trim(params["q"] || "")
|
||
|
||
{:noreply, refresh(socket, query)}
|
||
end
|
||
|
||
@impl true
|
||
def handle_event("search", %{"q" => q}, socket) do
|
||
{:noreply, push_patch(socket, to: ~p"/skewt?#{[q: q]}")}
|
||
end
|
||
|
||
# Time selection stays in-process — no URL round-trip — so refreshing
|
||
# or sharing a `/skewt?q=...` link always lands on the most recent
|
||
# available analysis hour rather than re-rendering whatever forecast
|
||
# hour happened to be selected when the URL was copied.
|
||
def handle_event("select_time", %{"valid_time" => iso}, socket) do
|
||
case parse_time(iso) do
|
||
nil ->
|
||
{:noreply, socket}
|
||
|
||
%DateTime{} = chosen ->
|
||
%{location: location} = socket.assigns
|
||
|
||
if location do
|
||
{:noreply,
|
||
socket
|
||
|> assign(selected_valid_time: chosen, loading?: true, error: nil)
|
||
|> start_async(:load_skewt_time, fn -> load_for_time(chosen, location) end)}
|
||
else
|
||
{:noreply, socket}
|
||
end
|
||
end
|
||
end
|
||
|
||
@impl true
|
||
def handle_async(:load_skewt, {:ok, {:ok, result}}, socket) do
|
||
{:noreply,
|
||
assign(socket,
|
||
location: result.location,
|
||
error: nil,
|
||
valid_times: result.valid_times,
|
||
selected_valid_time: result.selected_valid_time,
|
||
profile: result.profile,
|
||
derived: result.derived,
|
||
svg: result.svg,
|
||
loading?: false
|
||
)}
|
||
end
|
||
|
||
def handle_async(:load_skewt, {:ok, {:error, reason}}, socket) do
|
||
{:noreply,
|
||
assign(socket,
|
||
location: nil,
|
||
error: reason,
|
||
valid_times: [],
|
||
selected_valid_time: nil,
|
||
profile: nil,
|
||
derived: nil,
|
||
svg: nil,
|
||
loading?: false
|
||
)}
|
||
end
|
||
|
||
def handle_async(:load_skewt, {:exit, reason}, socket) do
|
||
require Logger
|
||
|
||
Logger.warning("SkewtLive async exit: #{inspect(reason)}")
|
||
|
||
{:noreply,
|
||
assign(socket,
|
||
error: "Sorry, something went wrong loading this profile. Try again.",
|
||
loading?: false
|
||
)}
|
||
end
|
||
|
||
def handle_async(:load_skewt_time, {:ok, {profile, derived, svg}}, socket) do
|
||
{:noreply,
|
||
assign(socket,
|
||
profile: profile,
|
||
derived: derived,
|
||
svg: svg,
|
||
loading?: false
|
||
)}
|
||
end
|
||
|
||
def handle_async(:load_skewt_time, {:exit, reason}, socket) do
|
||
require Logger
|
||
|
||
Logger.warning("SkewtLive time-select async exit: #{inspect(reason)}")
|
||
|
||
{:noreply, assign(socket, loading?: false)}
|
||
end
|
||
|
||
# ── Refresh logic ──────────────────────────────────────────────────
|
||
|
||
# The page chrome (header + search bar) renders synchronously on
|
||
# mount; everything else (geocoding, HRRR file/DB read, sounding
|
||
# parameter derivation, SVG render) lands via `:load_skewt` async.
|
||
# Repeated calls under the same key cancel any in-flight task, so
|
||
# rapid URL patches don't race.
|
||
|
||
defp refresh(socket, "") do
|
||
assign(socket,
|
||
query: "",
|
||
location: nil,
|
||
error: nil,
|
||
valid_times: [],
|
||
selected_valid_time: nil,
|
||
profile: nil,
|
||
derived: nil,
|
||
svg: nil,
|
||
loading?: false
|
||
)
|
||
end
|
||
|
||
defp refresh(socket, query) do
|
||
socket
|
||
|> assign(query: query, error: nil, loading?: true)
|
||
|> start_async(:load_skewt, fn -> resolve_and_load(query) end)
|
||
end
|
||
|
||
defp resolve_and_load(query) do
|
||
case SkewtLocationResolver.resolve(query) do
|
||
{:ok, %{lat: lat, lon: lon} = location} ->
|
||
valid_times = available_valid_times(lat, lon)
|
||
chosen = default_valid_time(valid_times)
|
||
|
||
{profile, derived, svg} =
|
||
case chosen do
|
||
nil -> {nil, nil, nil}
|
||
time -> load_profile(time, lat, lon)
|
||
end
|
||
|
||
{:ok,
|
||
%{
|
||
location: location,
|
||
valid_times: valid_times,
|
||
selected_valid_time: chosen,
|
||
profile: profile,
|
||
derived: derived,
|
||
svg: svg
|
||
}}
|
||
|
||
{:error, reason} ->
|
||
{:error, reason}
|
||
end
|
||
end
|
||
|
||
defp load_for_time(%DateTime{} = valid_time, %{lat: lat, lon: lon}) do
|
||
load_profile(valid_time, lat, lon)
|
||
end
|
||
|
||
defp load_profile(valid_time, lat, lon) do
|
||
cached = ProfilesFile.read_point(valid_time, lat, lon) || HrrrProfileLookup.read_point_near(valid_time, lat, lon)
|
||
rich = fetch_rich_cell(valid_time, lat, lon, cached)
|
||
|
||
case rich || cached do
|
||
nil ->
|
||
{nil, nil, nil}
|
||
|
||
cell ->
|
||
profile = extract_profile(cell)
|
||
build_profile_assigns(profile, cell)
|
||
end
|
||
end
|
||
|
||
defp build_profile_assigns([], _cell), do: {nil, nil, nil}
|
||
|
||
defp build_profile_assigns(profile, cell) do
|
||
sounding = SoundingParams.derive(profile)
|
||
spc = SkewtParams.derive(profile)
|
||
# Merge so the LiveView template only has to read one map.
|
||
# SoundingParams keys (atoms) and SkewtParams keys (atoms)
|
||
# are disjoint by construction.
|
||
derived =
|
||
(sounding || %{})
|
||
|> Map.merge(spc)
|
||
# Fall back to pre-computed values from the Rust ProfilesFile entry.
|
||
# SoundingParams.derive needs dewpoint at every level to compute
|
||
# refractivity; when any level is missing it, the scalar comes back
|
||
# nil. The Rust writer pre-computes these from the same levels
|
||
# (filtering identically) and writes them at the top of the cell.
|
||
|> fill_from_cell(cell, :surface_refractivity)
|
||
|> fill_from_cell(cell, :min_refractivity_gradient)
|
||
|
||
svg =
|
||
SkewtSvg.render(profile,
|
||
parcel_trace: spc[:parcel_trace] || [],
|
||
critical_levels: critical_levels(spc, profile)
|
||
)
|
||
|
||
{profile, derived, svg}
|
||
end
|
||
|
||
# Use a top-level cell key as fallback when the derived map has nil for
|
||
# that key. Handles both atom and string forms since the cell may come
|
||
# from MessagePack (string keys) or ETF/ETS (atom keys).
|
||
defp fill_from_cell(derived, cell, key) do
|
||
if derived[key] == nil do
|
||
fallback = cell[key] || cell[Atom.to_string(key)]
|
||
if fallback == nil, do: derived, else: Map.put(derived, key, fallback)
|
||
else
|
||
derived
|
||
end
|
||
end
|
||
|
||
# The grid-cached profile only contains 13 pressure levels (1000→700 mb)
|
||
# because the hourly chain trims its fetch to keep memory bounded. For
|
||
# the skew-T page we want the full 25-level set (1000→100 mb), so issue
|
||
# a per-point byte-range fetch directly against the same HRRR cycle the
|
||
# cached row came from. On any failure we silently fall back to the
|
||
# cached truncated profile — better to render *something* than to fail.
|
||
defp fetch_rich_cell(valid_time, lat, lon, cached) do
|
||
{run_time, forecast_hour} = run_time_and_fh(cached, valid_time)
|
||
|
||
case HrrrClient.fetch_profile(lat, lon, run_time, forecast_hour: forecast_hour) do
|
||
{:ok, profile_map} -> Map.put(profile_map, :valid_time, valid_time)
|
||
{:error, _} -> nil
|
||
end
|
||
rescue
|
||
_ -> nil
|
||
end
|
||
|
||
defp run_time_and_fh(%{run_time: %DateTime{} = run_time}, %DateTime{} = valid_time) do
|
||
fh = max(0, div(DateTime.diff(valid_time, run_time, :second), 3600))
|
||
{run_time, min(fh, 18)}
|
||
end
|
||
|
||
# Cached cells don't currently carry `run_time` (neither the on-disk
|
||
# ProfilesFile rows nor `HrrrProfileLookup`), so we have to guess the
|
||
# cycle. For valid_times in the past, treat them as analysis hours
|
||
# (cycle == valid_time, fh = 0). For future valid_times, walk back to
|
||
# the most recent cycle that's likely published (~2 h ago) and pick
|
||
# the forecast hour that lands on the requested valid_time.
|
||
defp run_time_and_fh(_cached, %DateTime{} = valid_time) do
|
||
now = DateTime.utc_now()
|
||
|
||
if DateTime.after?(valid_time, now) do
|
||
cycle =
|
||
now
|
||
|> DateTime.add(-2 * 3600, :second)
|
||
|> HrrrClient.nearest_hrrr_hour()
|
||
|
||
fh = max(0, div(DateTime.diff(valid_time, cycle, :second), 3600))
|
||
{cycle, min(fh, 18)}
|
||
else
|
||
{valid_time, 0}
|
||
end
|
||
end
|
||
|
||
defp extract_profile(cell) do
|
||
cell[:profile] || cell["profile"] || []
|
||
end
|
||
|
||
# Right-edge critical-level markers, mirroring SPC's annotation rail.
|
||
# Pressure → label is enough; SkewtSvg uses pressure_y/1 to position.
|
||
# 0 °C and wet-bulb-zero come back from SkewtParams as heights, so
|
||
# we walk the profile to recover the corresponding pressure.
|
||
defp critical_levels(spc, profile) do
|
||
Enum.flat_map(
|
||
[
|
||
{"LCL", spc[:lcl_pressure_mb]},
|
||
{"LFC", spc[:lfc_pressure_mb]},
|
||
{"EL", spc[:el_pressure_mb]},
|
||
{"0 °C", pressure_at_height(profile, spc[:freezing_level_m])},
|
||
{"WBZ", pressure_at_height(profile, spc[:wbz_m])}
|
||
],
|
||
fn
|
||
{label, p} when is_number(p) -> [%{label: label, pressure_mb: p}]
|
||
_ -> []
|
||
end
|
||
)
|
||
end
|
||
|
||
defp pressure_at_height(_profile, nil), do: nil
|
||
|
||
defp pressure_at_height(profile, target_m) when is_number(target_m) do
|
||
profile
|
||
|> Enum.map(&extract_pres_hght/1)
|
||
|> Enum.filter(&(is_number(&1.pres) and is_number(&1.hght)))
|
||
|> Enum.sort_by(& &1.pres, :desc)
|
||
|> Enum.chunk_every(2, 1, :discard)
|
||
|> Enum.find_value(&interpolate_pressure_at_height(&1, target_m))
|
||
end
|
||
|
||
defp extract_pres_hght(lvl) do
|
||
%{
|
||
pres: lvl[:pres] || lvl["pres"] || lvl[:pres_mb] || lvl["pres_mb"],
|
||
hght: lvl[:hght] || lvl["hght"] || lvl[:hght_m] || lvl["hght_m"]
|
||
}
|
||
end
|
||
|
||
defp interpolate_pressure_at_height([a, b], target_m) do
|
||
cond do
|
||
a.hght == b.hght -> nil
|
||
(a.hght - target_m) * (b.hght - target_m) > 0 -> nil
|
||
true -> a.pres + (target_m - a.hght) / (b.hght - a.hght) * (b.pres - a.pres)
|
||
end
|
||
end
|
||
|
||
defp available_valid_times(lat, lon) do
|
||
# The chain produces analysis + 18 forecast hours every wall-clock
|
||
# hour. By the time the next chain finishes the previous chain's
|
||
# analysis is up to 70 min old, and during a missed cycle it can be
|
||
# ~2 h old. A 1-hour past cutoff would leave the page empty while
|
||
# the chain catches up — keep 3 h so a stale analysis still shows.
|
||
now = DateTime.utc_now()
|
||
past_cutoff = DateTime.add(now, -3 * 3600, :second)
|
||
future_cutoff = DateTime.add(now, 18 * 3600, :second)
|
||
|
||
on_disk =
|
||
Enum.filter(ProfilesFile.list_valid_times(), fn t ->
|
||
DateTime.compare(t, past_cutoff) != :lt and DateTime.compare(t, future_cutoff) != :gt
|
||
end)
|
||
|
||
case on_disk do
|
||
[] ->
|
||
# On-disk store is empty for this window — common in dev (no NFS
|
||
# mount, no local Rust pipeline) and during missed-cycle windows
|
||
# in prod. Fall back to whatever HRRR profiles the DB has near
|
||
# the location, regardless of how old. Better to render historic
|
||
# data than a "no profiles stored" stub.
|
||
HrrrProfileLookup.list_valid_times_near(lat, lon)
|
||
|
||
times ->
|
||
Enum.sort(times, DateTime)
|
||
end
|
||
end
|
||
|
||
defp default_valid_time([]), do: nil
|
||
|
||
defp default_valid_time(times) do
|
||
# The most recent valid_time at or before "now". If everything is
|
||
# in the future (cold start), fall back to the earliest available.
|
||
now = DateTime.utc_now()
|
||
|
||
times
|
||
|> Enum.filter(fn t -> DateTime.compare(t, now) != :gt end)
|
||
|> case do
|
||
[] -> List.first(times)
|
||
past -> Enum.max(past, DateTime)
|
||
end
|
||
end
|
||
|
||
defp parse_time(nil), do: nil
|
||
defp parse_time(""), do: nil
|
||
|
||
defp parse_time(iso) when is_binary(iso) do
|
||
case DateTime.from_iso8601(iso) do
|
||
{:ok, dt, _} -> DateTime.truncate(dt, :second)
|
||
_ -> nil
|
||
end
|
||
end
|
||
|
||
# ── Render ─────────────────────────────────────────────────────────
|
||
|
||
@impl true
|
||
def render(assigns) do
|
||
~H"""
|
||
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-6xl">
|
||
<.header>
|
||
Skew-T-Log-P
|
||
<:subtitle>
|
||
Vertical atmospheric profile from the latest HRRR analysis (or any
|
||
available forecast hour) for an address, grid square, or callsign.
|
||
</:subtitle>
|
||
</.header>
|
||
|
||
<form phx-submit="search" class="mt-4 flex gap-2">
|
||
<input
|
||
type="text"
|
||
name="q"
|
||
value={@query}
|
||
placeholder="EM12kp, W5ISP, or '123 Main St, Plano TX'"
|
||
class="input input-bordered flex-1"
|
||
autocomplete="off"
|
||
autofocus
|
||
/>
|
||
<button type="submit" class="btn btn-primary">Plot</button>
|
||
</form>
|
||
|
||
<%= if @error do %>
|
||
<div role="alert" class="alert alert-error mt-4">
|
||
<p>{@error}</p>
|
||
</div>
|
||
<% end %>
|
||
|
||
<%= if @loading? do %>
|
||
<div class="mt-4 flex items-center gap-3 text-sm opacity-80">
|
||
<span class="loading loading-spinner loading-sm"></span>
|
||
<span>Resolving location and loading HRRR profile…</span>
|
||
</div>
|
||
<% end %>
|
||
|
||
<%= if @location do %>
|
||
<div class="mt-4 flex flex-wrap items-baseline gap-4 text-sm">
|
||
<span class="font-semibold">{@location.label}</span>
|
||
<span class="opacity-70">
|
||
{fmt_coord(@location.lat)}, {fmt_coord(@location.lon)}
|
||
</span>
|
||
</div>
|
||
<% end %>
|
||
|
||
<%= if @valid_times != [] do %>
|
||
<div class="mt-4 flex flex-wrap gap-2">
|
||
<%= for t <- @valid_times do %>
|
||
<button
|
||
type="button"
|
||
phx-click="select_time"
|
||
phx-value-valid_time={DateTime.to_iso8601(t)}
|
||
class={[
|
||
"btn btn-xs",
|
||
if(@selected_valid_time && DateTime.compare(t, @selected_valid_time) == :eq,
|
||
do: "btn-primary",
|
||
else: "btn-outline"
|
||
)
|
||
]}
|
||
>
|
||
{time_button_label(t, @valid_times)}
|
||
</button>
|
||
<% end %>
|
||
</div>
|
||
<% end %>
|
||
|
||
<%= if @svg do %>
|
||
<div class="mt-6 grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||
<div
|
||
id="skewt-container"
|
||
phx-hook="Skewt"
|
||
phx-update="ignore"
|
||
data-profile={Jason.encode!(@profile)}
|
||
data-geometry={Jason.encode!(SkewtSvg.geometry())}
|
||
class="lg:col-span-2 bg-base-100 rounded-box p-3 border border-base-300"
|
||
>
|
||
{Phoenix.HTML.raw(@svg)}
|
||
</div>
|
||
<div class="bg-base-200 rounded-box p-4 text-sm space-y-4">
|
||
<h3 class="font-semibold text-base">Sounding parameters</h3>
|
||
<%= if @derived do %>
|
||
<%= for {section_title, rows} <- derived_sections(@derived) do %>
|
||
<div class="space-y-1">
|
||
<h4 class="font-semibold text-xs uppercase tracking-wide opacity-70">
|
||
{section_title}
|
||
</h4>
|
||
<dl class="grid grid-cols-2 gap-x-3 gap-y-1">
|
||
<%= for {label, value} <- rows do %>
|
||
<dt class="opacity-70">{label}</dt>
|
||
<dd class="font-mono text-right">{value}</dd>
|
||
<% end %>
|
||
</dl>
|
||
</div>
|
||
<% end %>
|
||
|
||
<%= if @derived[:ducting_detected] && @derived[:ducts] != [] do %>
|
||
<div class="pt-2 border-t border-base-300 space-y-1">
|
||
<h4 class="font-semibold text-xs uppercase tracking-wide opacity-70">
|
||
Ducts
|
||
</h4>
|
||
<%= for duct <- @derived.ducts do %>
|
||
<div class="font-mono text-xs">
|
||
base {round(duct["base"])} m → top {round(duct["top"])} m, ΔM {duct["strength"]}
|
||
</div>
|
||
<% end %>
|
||
</div>
|
||
<% end %>
|
||
|
||
<p class="pt-2 border-t border-base-300 text-xs opacity-60 leading-relaxed">
|
||
Wind-derived indices (SRH, BWD, Bunkers motion, SCP/STP/SHIP)
|
||
aren't shown — HRRR persists wind only at 10 m AGL, so per-
|
||
level shear can't be computed from this data source.
|
||
</p>
|
||
<% end %>
|
||
</div>
|
||
</div>
|
||
<% else %>
|
||
<%= if @location && @selected_valid_time do %>
|
||
<div role="alert" class="alert alert-warning mt-6">
|
||
<p>
|
||
No HRRR profile is stored for this point at {fmt_time(@selected_valid_time)}.
|
||
Profiles are written by the hourly chain and only kept for the active
|
||
forecast window.
|
||
</p>
|
||
</div>
|
||
<% else %>
|
||
<%= if @location && @valid_times == [] do %>
|
||
<div role="alert" class="alert alert-info mt-6">
|
||
<p>
|
||
No HRRR profiles are currently stored. Wait for the next hourly
|
||
chain to publish, or check the propagation pipeline status.
|
||
</p>
|
||
</div>
|
||
<% end %>
|
||
<% end %>
|
||
<% end %>
|
||
</Layouts.app>
|
||
"""
|
||
end
|
||
|
||
defp fmt_coord(c) when is_number(c), do: :erlang.float_to_binary(c * 1.0, decimals: 3)
|
||
defp fmt_coord(_), do: "—"
|
||
|
||
defp fmt_time(%DateTime{} = t) do
|
||
Calendar.strftime(t, "%Y-%m-%d %H:%MZ")
|
||
end
|
||
|
||
defp time_button_label(%DateTime{} = t, all_times) do
|
||
base = Enum.min(all_times, DateTime)
|
||
delta_h = div(DateTime.diff(t, base, :second), 3600)
|
||
IO.iodata_to_binary("f#{:io_lib.format("~2..0B", [delta_h])} · #{Calendar.strftime(t, "%H:%MZ")}")
|
||
end
|
||
|
||
# Grouped derived-parameter sections rendered on the right side of
|
||
# the page. Mirrors the SPC viewer's "parcel / level / lapse rate /
|
||
# moisture / refractivity" layout so users moving between the two
|
||
# tools don't have to context-switch.
|
||
defp derived_sections(d) do
|
||
[
|
||
{"Surface",
|
||
[
|
||
{"T", fmt_temp(d[:surface_temp_c])},
|
||
{"Td", fmt_temp(d[:surface_dewpoint_c])},
|
||
{"P", fmt_num(d[:surface_pressure_mb], "mb")}
|
||
]},
|
||
{"Convective parcel",
|
||
[
|
||
{"SBCAPE", fmt_num(d[:sbcape], "J/kg")},
|
||
{"SBCIN", fmt_num(d[:sbcin], "J/kg")},
|
||
{"3 km CAPE", fmt_num(d[:cape_3km], "J/kg")},
|
||
{"Lifted index", fmt_num(d[:lifted_index], "")},
|
||
{"DCAPE", fmt_num(d[:dcape], "J/kg")}
|
||
]},
|
||
{"Levels",
|
||
[
|
||
{"LCL", fmt_pressure_height(d[:lcl_pressure_mb], d[:lcl_height_m])},
|
||
{"LFC", fmt_pressure_height(d[:lfc_pressure_mb], d[:lfc_height_m])},
|
||
{"EL", fmt_pressure_height(d[:el_pressure_mb], d[:el_height_m])},
|
||
{"0 °C", fmt_height(d[:freezing_level_m])},
|
||
{"Wet-bulb 0", fmt_height(d[:wbz_m])}
|
||
]},
|
||
{"Lapse rates",
|
||
[
|
||
{"Sfc–3 km", fmt_num(d[:lapse_rate_sfc_3km_c_per_km], "°C/km")},
|
||
{"700–500 mb", fmt_num(d[:lapse_rate_700_500_c_per_km], "°C/km")},
|
||
{"850–500 mb", fmt_num(d[:lapse_rate_850_500_c_per_km], "°C/km")}
|
||
]},
|
||
{"Moisture / stability",
|
||
[
|
||
{"PWAT", fmt_num(d[:precipitable_water_mm], "mm")},
|
||
{"BL depth", fmt_num(d[:boundary_layer_depth_m], "m")},
|
||
{"K-index", fmt_num(d[:k_index], "")}
|
||
]},
|
||
{"Refractivity",
|
||
[
|
||
{"Surface N", fmt_num(d[:surface_refractivity], "")},
|
||
{"Min dN/dh", fmt_num(d[:min_refractivity_gradient], "/km")},
|
||
{"Ducting", if(d[:ducting_detected], do: "yes", else: "no")}
|
||
]}
|
||
]
|
||
end
|
||
|
||
defp fmt_temp(nil), do: "—"
|
||
defp fmt_temp(v) when is_number(v), do: "#{Float.round(v * 1.0, 1)} °C"
|
||
|
||
defp fmt_num(nil, _suffix), do: "—"
|
||
defp fmt_num(v, ""), do: format_value(v)
|
||
defp fmt_num(v, suffix), do: "#{format_value(v)} #{suffix}"
|
||
|
||
defp fmt_height(nil), do: "—"
|
||
|
||
defp fmt_height(v) when is_number(v) do
|
||
"#{round(v)} m / #{round(v * 3.28084)} ft"
|
||
end
|
||
|
||
defp fmt_pressure_height(nil, _), do: "—"
|
||
defp fmt_pressure_height(p, nil), do: "#{round(p)} mb"
|
||
|
||
defp fmt_pressure_height(p, h) when is_number(p) and is_number(h) do
|
||
"#{round(p)} mb · #{round(h)} m"
|
||
end
|
||
|
||
defp format_value(v) when is_float(v), do: v |> Float.round(1) |> :erlang.float_to_binary(decimals: 1)
|
||
defp format_value(v) when is_integer(v), do: Integer.to_string(v)
|
||
defp format_value(v), do: to_string(v)
|
||
end
|