feat(skewt): fetch full 25-level profile and drop valid_time URL param

The grid-cached profile is trimmed to 13 levels (1000→700 mb) for
memory reasons, so the skew-T plot showed nothing above 700 mb.
HrrrClient.fetch_profile/4 now accepts a forecast_hour opt; SkewtLive
issues a per-point fetch against the same cycle the cached row came
from to get the full 1000→100 mb set, falling back to the cached
truncated cell on any failure.

Time selection no longer round-trips through the URL — `select_time`
updates state in-process and triggers a focused :load_skewt_time
async, so refreshing or sharing a /skewt?q=... link always lands on
the most recent analysis hour.
This commit is contained in:
Graham McIntire 2026-04-25 15:19:12 -05:00
parent e2bb79bcf6
commit 332308b9c3
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
3 changed files with 180 additions and 57 deletions

View file

@ -142,24 +142,33 @@ defmodule Microwaveprop.Weather.HrrrClient do
end
end
@spec fetch_profile(float(), float(), DateTime.t()) :: {:ok, map()} | {:error, term()}
def fetch_profile(lat, lon, valid_time) do
@doc """
Per-point HRRR profile fetch returning the full 25 pressure levels
from 1000 100 mb. Treats `run_time` as the cycle hour; pass
`forecast_hour: N` to fetch the F+N forecast file from that cycle
(so `valid_time = run_time + N hours`). Default `forecast_hour: 0`
matches the analysis-only behaviour of the original 3-arg form.
"""
@spec fetch_profile(float(), float(), DateTime.t(), keyword()) :: {:ok, map()} | {:error, term()}
def fetch_profile(lat, lon, run_time, opts \\ []) do
forecast_hour = Keyword.get(opts, :forecast_hour, 0)
Microwaveprop.Instrument.span(
[:hrrr, :fetch_profile],
%{lat: lat, lon: lon},
fn -> do_fetch_profile(lat, lon, valid_time) end
%{lat: lat, lon: lon, forecast_hour: forecast_hour},
fn -> do_fetch_profile(lat, lon, run_time, forecast_hour) end
)
end
defp do_fetch_profile(lat, lon, valid_time) do
hour_dt = nearest_hrrr_hour(valid_time)
defp do_fetch_profile(lat, lon, run_time, forecast_hour) do
hour_dt = nearest_hrrr_hour(run_time)
date = DateTime.to_date(hour_dt)
hour = hour_dt.hour
with {:ok, sfc_data} <- fetch_product(date, hour, :surface, lat, lon) do
with {:ok, sfc_data} <- fetch_product(date, hour, :surface, lat, lon, forecast_hour) do
# Pressure is optional — old HRRR data may have corrupt messages
prs_data =
case fetch_product(date, hour, :pressure, lat, lon) do
case fetch_product(date, hour, :pressure, lat, lon, forecast_hour) do
{:ok, data} ->
data
@ -173,7 +182,7 @@ defmodule Microwaveprop.Weather.HrrrClient do
merged = Map.merge(sfc_data, prs_data)
result = build_profile(merged)
{:ok, Map.put(result, :run_time, hour_dt)}
{:ok, Map.merge(result, %{run_time: hour_dt, forecast_hour: forecast_hour})}
end
end
@ -359,8 +368,8 @@ defmodule Microwaveprop.Weather.HrrrClient do
# --- Private ---
defp fetch_product(date, hour, product, lat, lon) do
url = hrrr_url(date, hour, product)
defp fetch_product(date, hour, product, lat, lon, forecast_hour) do
url = hrrr_url(date, hour, product, forecast_hour)
idx_url = url <> ".idx"
wanted =

View file

@ -12,6 +12,7 @@ defmodule MicrowavepropWeb.SkewtLive do
use MicrowavepropWeb, :live_view
alias Microwaveprop.Propagation.ProfilesFile
alias Microwaveprop.Weather.HrrrClient
alias Microwaveprop.Weather.HrrrProfileLookup
alias Microwaveprop.Weather.SkewtParams
alias Microwaveprop.Weather.SoundingParams
@ -38,9 +39,8 @@ defmodule MicrowavepropWeb.SkewtLive do
@impl true
def handle_params(params, _uri, socket) do
query = String.trim(params["q"] || "")
requested_time = parse_time(params["valid_time"])
{:noreply, refresh(socket, query, requested_time)}
{:noreply, refresh(socket, query)}
end
@impl true
@ -48,9 +48,27 @@ defmodule MicrowavepropWeb.SkewtLive 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
params = [q: socket.assigns.query, valid_time: iso]
{:noreply, push_patch(socket, to: ~p"/skewt?#{params}")}
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
@ -94,6 +112,24 @@ defmodule MicrowavepropWeb.SkewtLive do
)}
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
@ -102,7 +138,7 @@ defmodule MicrowavepropWeb.SkewtLive do
# Repeated calls under the same key cancel any in-flight task, so
# rapid URL patches don't race.
defp refresh(socket, "", _time) do
defp refresh(socket, "") do
assign(socket,
query: "",
location: nil,
@ -116,21 +152,17 @@ defmodule MicrowavepropWeb.SkewtLive do
)
end
defp refresh(socket, query, requested_time) do
defp refresh(socket, query) do
socket
|> assign(
query: query,
error: nil,
loading?: true
)
|> start_async(:load_skewt, fn -> resolve_and_load(query, requested_time) end)
|> assign(query: query, error: nil, loading?: true)
|> start_async(:load_skewt, fn -> resolve_and_load(query) end)
end
defp resolve_and_load(query, requested_time) do
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 = pick_valid_time(valid_times, requested_time)
chosen = default_valid_time(valid_times)
{profile, derived, svg} =
case chosen do
@ -153,34 +185,84 @@ defmodule MicrowavepropWeb.SkewtLive do
end
end
defp load_profile(valid_time, lat, lon) do
cell = ProfilesFile.read_point(valid_time, lat, lon) || HrrrProfileLookup.read_point_near(valid_time, lat, lon)
defp load_for_time(%DateTime{} = valid_time, %{lat: lat, lon: lon}) do
load_profile(valid_time, lat, lon)
end
case cell do
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 ->
cell ->
profile = extract_profile(cell)
build_profile_assigns(profile)
end
end
if profile == [] do
{nil, nil, nil}
else
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 = Map.merge(sounding || %{}, spc)
defp build_profile_assigns([]), do: {nil, nil, nil}
svg =
SkewtSvg.render(profile,
parcel_trace: spc[:parcel_trace] || [],
critical_levels: critical_levels(spc, profile)
)
defp build_profile_assigns(profile) 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 = Map.merge(sounding || %{}, spc)
{profile, derived, svg}
end
svg =
SkewtSvg.render(profile,
parcel_trace: spc[:parcel_trace] || [],
critical_levels: critical_levels(spc, profile)
)
{profile, derived, svg}
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
@ -263,12 +345,11 @@ defmodule MicrowavepropWeb.SkewtLive do
end
end
defp pick_valid_time([], _requested), do: nil
defp default_valid_time([]), do: nil
defp pick_valid_time(times, nil) do
# Default = the most recent valid_time at or before "now". If
# everything is in the future (cold start), fall back to the
# earliest available.
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
@ -279,13 +360,6 @@ defmodule MicrowavepropWeb.SkewtLive do
end
end
defp pick_valid_time(times, %DateTime{} = requested) do
case Enum.find(times, &(DateTime.compare(&1, requested) == :eq)) do
nil -> pick_valid_time(times, nil)
match -> match
end
end
defp parse_time(nil), do: nil
defp parse_time(""), do: nil

View file

@ -261,6 +261,46 @@ defmodule Microwaveprop.Weather.HrrrClientTest do
assert idx_count == 2, "idx should be cached — expected 2 total fetches, got #{idx_count}"
assert grib_count > 0, "grib fetches should still happen"
end
test "forecast_hour opt rewrites the URL to the requested f-hour file" do
# The skew-T page passes through the cycle's run_time and a positive
# forecast_hour to render a future valid_time. Verify the request
# actually targets `wrfprsf06.grib2` (not `wrfprsf00.grib2`).
grib_data = File.read!("test/fixtures/grib2/hrrr_tmp_2m.grib2")
grib_size = byte_size(grib_data)
idx_text = """
1:0:d=2026032818:TMP:2 m above ground:6 hour fcst:
2:#{grib_size}:d=2026032818:DPT:2 m above ground:6 hour fcst:
3:#{grib_size * 2}:d=2026032818:PRES:surface:6 hour fcst:
"""
{:ok, paths} = Agent.start_link(fn -> [] end)
Req.Test.stub(HrrrClient, fn conn ->
Agent.update(paths, fn list -> [conn.request_path | list] end)
if String.ends_with?(conn.request_path, ".idx") do
Plug.Conn.send_resp(conn, 200, idx_text)
else
[_range] = Plug.Conn.get_req_header(conn, "range")
Plug.Conn.send_resp(conn, 206, grib_data)
end
end)
assert {:ok, profile} =
HrrrClient.fetch_profile(32.90, -97.04, ~U[2026-03-28 18:00:00Z], forecast_hour: 6)
requested = Agent.get(paths, & &1)
assert Enum.any?(requested, &String.contains?(&1, "wrfprsf06.grib2")),
"expected wrfprsf06.grib2 in #{inspect(requested)}"
refute Enum.any?(requested, &String.contains?(&1, "wrfprsf00.grib2")),
"did not expect wrfprsf00.grib2 in #{inspect(requested)}"
assert profile.forecast_hour == 6
end
end
describe "build_profile/1" do