From 332308b9c3a7d01ba52dd7665ad470f910f0a2d7 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 25 Apr 2026 15:19:12 -0500 Subject: [PATCH] feat(skewt): fetch full 25-level profile and drop valid_time URL param MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/microwaveprop/weather/hrrr_client.ex | 31 ++-- lib/microwaveprop_web/live/skewt_live.ex | 166 +++++++++++++----- .../weather/hrrr_client_test.exs | 40 +++++ 3 files changed, 180 insertions(+), 57 deletions(-) diff --git a/lib/microwaveprop/weather/hrrr_client.ex b/lib/microwaveprop/weather/hrrr_client.ex index fcf7858e..20277302 100644 --- a/lib/microwaveprop/weather/hrrr_client.ex +++ b/lib/microwaveprop/weather/hrrr_client.ex @@ -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 = diff --git a/lib/microwaveprop_web/live/skewt_live.ex b/lib/microwaveprop_web/live/skewt_live.ex index 920fbb6e..72bd9f7e 100644 --- a/lib/microwaveprop_web/live/skewt_live.ex +++ b/lib/microwaveprop_web/live/skewt_live.ex @@ -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 diff --git a/test/microwaveprop/weather/hrrr_client_test.exs b/test/microwaveprop/weather/hrrr_client_test.exs index 84940e9a..660efd41 100644 --- a/test/microwaveprop/weather/hrrr_client_test.exs +++ b/test/microwaveprop/weather/hrrr_client_test.exs @@ -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