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
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec fetch_profile(float(), float(), DateTime.t()) :: {:ok, map()} | {:error, term()}
|
@doc """
|
||||||
def fetch_profile(lat, lon, valid_time) do
|
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(
|
Microwaveprop.Instrument.span(
|
||||||
[:hrrr, :fetch_profile],
|
[:hrrr, :fetch_profile],
|
||||||
%{lat: lat, lon: lon},
|
%{lat: lat, lon: lon, forecast_hour: forecast_hour},
|
||||||
fn -> do_fetch_profile(lat, lon, valid_time) end
|
fn -> do_fetch_profile(lat, lon, run_time, forecast_hour) end
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp do_fetch_profile(lat, lon, valid_time) do
|
defp do_fetch_profile(lat, lon, run_time, forecast_hour) do
|
||||||
hour_dt = nearest_hrrr_hour(valid_time)
|
hour_dt = nearest_hrrr_hour(run_time)
|
||||||
date = DateTime.to_date(hour_dt)
|
date = DateTime.to_date(hour_dt)
|
||||||
hour = hour_dt.hour
|
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
|
# Pressure is optional — old HRRR data may have corrupt messages
|
||||||
prs_data =
|
prs_data =
|
||||||
case fetch_product(date, hour, :pressure, lat, lon) do
|
case fetch_product(date, hour, :pressure, lat, lon, forecast_hour) do
|
||||||
{:ok, data} ->
|
{:ok, data} ->
|
||||||
data
|
data
|
||||||
|
|
||||||
|
|
@ -173,7 +182,7 @@ defmodule Microwaveprop.Weather.HrrrClient do
|
||||||
|
|
||||||
merged = Map.merge(sfc_data, prs_data)
|
merged = Map.merge(sfc_data, prs_data)
|
||||||
result = build_profile(merged)
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -359,8 +368,8 @@ defmodule Microwaveprop.Weather.HrrrClient do
|
||||||
|
|
||||||
# --- Private ---
|
# --- Private ---
|
||||||
|
|
||||||
defp fetch_product(date, hour, product, lat, lon) do
|
defp fetch_product(date, hour, product, lat, lon, forecast_hour) do
|
||||||
url = hrrr_url(date, hour, product)
|
url = hrrr_url(date, hour, product, forecast_hour)
|
||||||
idx_url = url <> ".idx"
|
idx_url = url <> ".idx"
|
||||||
|
|
||||||
wanted =
|
wanted =
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ defmodule MicrowavepropWeb.SkewtLive do
|
||||||
use MicrowavepropWeb, :live_view
|
use MicrowavepropWeb, :live_view
|
||||||
|
|
||||||
alias Microwaveprop.Propagation.ProfilesFile
|
alias Microwaveprop.Propagation.ProfilesFile
|
||||||
|
alias Microwaveprop.Weather.HrrrClient
|
||||||
alias Microwaveprop.Weather.HrrrProfileLookup
|
alias Microwaveprop.Weather.HrrrProfileLookup
|
||||||
alias Microwaveprop.Weather.SkewtParams
|
alias Microwaveprop.Weather.SkewtParams
|
||||||
alias Microwaveprop.Weather.SoundingParams
|
alias Microwaveprop.Weather.SoundingParams
|
||||||
|
|
@ -38,9 +39,8 @@ defmodule MicrowavepropWeb.SkewtLive do
|
||||||
@impl true
|
@impl true
|
||||||
def handle_params(params, _uri, socket) do
|
def handle_params(params, _uri, socket) do
|
||||||
query = String.trim(params["q"] || "")
|
query = String.trim(params["q"] || "")
|
||||||
requested_time = parse_time(params["valid_time"])
|
|
||||||
|
|
||||||
{:noreply, refresh(socket, query, requested_time)}
|
{:noreply, refresh(socket, query)}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -48,9 +48,27 @@ defmodule MicrowavepropWeb.SkewtLive do
|
||||||
{:noreply, push_patch(socket, to: ~p"/skewt?#{[q: q]}")}
|
{:noreply, push_patch(socket, to: ~p"/skewt?#{[q: q]}")}
|
||||||
end
|
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
|
def handle_event("select_time", %{"valid_time" => iso}, socket) do
|
||||||
params = [q: socket.assigns.query, valid_time: iso]
|
case parse_time(iso) do
|
||||||
{:noreply, push_patch(socket, to: ~p"/skewt?#{params}")}
|
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
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -94,6 +112,24 @@ defmodule MicrowavepropWeb.SkewtLive do
|
||||||
)}
|
)}
|
||||||
end
|
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 ──────────────────────────────────────────────────
|
# ── Refresh logic ──────────────────────────────────────────────────
|
||||||
|
|
||||||
# The page chrome (header + search bar) renders synchronously on
|
# 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
|
# Repeated calls under the same key cancel any in-flight task, so
|
||||||
# rapid URL patches don't race.
|
# rapid URL patches don't race.
|
||||||
|
|
||||||
defp refresh(socket, "", _time) do
|
defp refresh(socket, "") do
|
||||||
assign(socket,
|
assign(socket,
|
||||||
query: "",
|
query: "",
|
||||||
location: nil,
|
location: nil,
|
||||||
|
|
@ -116,21 +152,17 @@ defmodule MicrowavepropWeb.SkewtLive do
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp refresh(socket, query, requested_time) do
|
defp refresh(socket, query) do
|
||||||
socket
|
socket
|
||||||
|> assign(
|
|> assign(query: query, error: nil, loading?: true)
|
||||||
query: query,
|
|> start_async(:load_skewt, fn -> resolve_and_load(query) end)
|
||||||
error: nil,
|
|
||||||
loading?: true
|
|
||||||
)
|
|
||||||
|> start_async(:load_skewt, fn -> resolve_and_load(query, requested_time) end)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp resolve_and_load(query, requested_time) do
|
defp resolve_and_load(query) do
|
||||||
case SkewtLocationResolver.resolve(query) do
|
case SkewtLocationResolver.resolve(query) do
|
||||||
{:ok, %{lat: lat, lon: lon} = location} ->
|
{:ok, %{lat: lat, lon: lon} = location} ->
|
||||||
valid_times = available_valid_times(lat, lon)
|
valid_times = available_valid_times(lat, lon)
|
||||||
chosen = pick_valid_time(valid_times, requested_time)
|
chosen = default_valid_time(valid_times)
|
||||||
|
|
||||||
{profile, derived, svg} =
|
{profile, derived, svg} =
|
||||||
case chosen do
|
case chosen do
|
||||||
|
|
@ -153,34 +185,84 @@ defmodule MicrowavepropWeb.SkewtLive do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp load_profile(valid_time, lat, lon) do
|
defp load_for_time(%DateTime{} = valid_time, %{lat: lat, lon: lon}) do
|
||||||
cell = ProfilesFile.read_point(valid_time, lat, lon) || HrrrProfileLookup.read_point_near(valid_time, lat, lon)
|
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, nil}
|
{nil, nil, nil}
|
||||||
|
|
||||||
%{} = cell ->
|
cell ->
|
||||||
profile = extract_profile(cell)
|
profile = extract_profile(cell)
|
||||||
|
build_profile_assigns(profile)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
if profile == [] do
|
defp build_profile_assigns([]), do: {nil, nil, nil}
|
||||||
{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)
|
|
||||||
|
|
||||||
svg =
|
defp build_profile_assigns(profile) do
|
||||||
SkewtSvg.render(profile,
|
sounding = SoundingParams.derive(profile)
|
||||||
parcel_trace: spc[:parcel_trace] || [],
|
spc = SkewtParams.derive(profile)
|
||||||
critical_levels: critical_levels(spc, 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}
|
svg =
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -263,12 +345,11 @@ defmodule MicrowavepropWeb.SkewtLive do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp pick_valid_time([], _requested), do: nil
|
defp default_valid_time([]), do: nil
|
||||||
|
|
||||||
defp pick_valid_time(times, nil) do
|
defp default_valid_time(times) do
|
||||||
# Default = the most recent valid_time at or before "now". If
|
# The most recent valid_time at or before "now". If everything is
|
||||||
# everything is in the future (cold start), fall back to the
|
# in the future (cold start), fall back to the earliest available.
|
||||||
# earliest available.
|
|
||||||
now = DateTime.utc_now()
|
now = DateTime.utc_now()
|
||||||
|
|
||||||
times
|
times
|
||||||
|
|
@ -279,13 +360,6 @@ defmodule MicrowavepropWeb.SkewtLive do
|
||||||
end
|
end
|
||||||
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(nil), do: nil
|
||||||
defp parse_time(""), 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 idx_count == 2, "idx should be cached — expected 2 total fetches, got #{idx_count}"
|
||||||
assert grib_count > 0, "grib fetches should still happen"
|
assert grib_count > 0, "grib fetches should still happen"
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
describe "build_profile/1" do
|
describe "build_profile/1" do
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue