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:
parent
e2bb79bcf6
commit
332308b9c3
3 changed files with 180 additions and 57 deletions
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue