prop/lib/microwaveprop/weather/hrrr_native_profile.ex
Graham McIntire 30c1018400
Extend skew-T plot to cover the full troposphere
Historical contacts showed a skew-T log-P diagram that stopped at 700 mb
because HrrrClient and Era5Client only fetched pressure-level data down
to the top of the boundary layer. The chart canvas already ran up to
100 mb, so the trace clipped mid-atmosphere.

Two complementary fixes:

1. Extend @pressure_levels in HrrrClient and Era5Client with
   650/600/550/500/450/400/350/300/250/200/150/100 mb so new fetches
   cover the full troposphere + lower stratosphere.

2. Prefer the native hybrid-sigma profile for the contact-detail
   skew-T when one has been backfilled for the contact's hour. The
   native profile already stores all 50 hybrid levels up to ~19 km,
   so historical contacts covered by the native backfill get a full
   trace without re-hitting S3. A new HrrrNativeProfile.to_skew_t_profile/1
   converts the parallel arrays into the %{"pres","tmpc","dwpc","hght"}
   list shape the renderer expects, deriving dewpoint from SPFH via the
   Magnus inverse. Weather.find_nearest_native_profile/3 mirrors
   find_nearest_hrrr/3 for the lookup.
2026-04-14 17:25:58 -05:00

140 lines
4.4 KiB
Elixir

defmodule Microwaveprop.Weather.HrrrNativeProfile do
@moduledoc """
A single HRRR profile extracted on the native hybrid-sigma vertical
coordinate (50 levels), as opposed to `HrrrProfile` which is
interpolated to a coarse set of pressure levels.
This schema is intentionally additive: it does not replace
`HrrrProfile`. The legacy scorer keeps running against
`hrrr_profiles` until Phase 9 swaps it out.
Columns marked "derived (Phase 2/4)" are written by the
boundary-layer turbulence and duct-geometry phases — they stay
nullable so the ingestion worker can populate just the raw profile
columns and a later `mix hrrr_native_derive` task fills the rest in.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "hrrr_native_profiles" do
field :valid_time, :utc_datetime
field :run_time, :utc_datetime
field :lat, :float
field :lon, :float
# Raw native-level columns, level 1 (surface) first, strictly
# monotone in height.
field :level_count, :integer
field :heights_m, {:array, :float}
field :temp_k, {:array, :float}
field :spfh, {:array, :float}
field :pressure_pa, {:array, :float}
field :u_wind_ms, {:array, :float}
field :v_wind_ms, {:array, :float}
field :tke_m2s2, {:array, :float}
# Cached surface scalars — convenient for backtests that only need
# the lowest level without unpacking the whole profile.
field :surface_temp_k, :float
field :surface_spfh, :float
field :surface_pressure_pa, :float
# Derived (Phase 2/4).
field :inversion_top_m, :float
field :bulk_richardson, :float
field :theta_e_jump_k, :float
field :shear_at_top_ms, :float
field :ducts, {:array, :map}
field :best_duct_band_ghz, :float
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{}
@required ~w(valid_time lat lon)a
@optional ~w(
run_time level_count heights_m temp_k spfh pressure_pa
u_wind_ms v_wind_ms tke_m2s2
surface_temp_k surface_spfh surface_pressure_pa
inversion_top_m bulk_richardson theta_e_jump_k shear_at_top_ms
ducts best_duct_band_ghz
)a
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(profile, attrs) do
profile
|> cast(attrs, @required ++ @optional)
|> validate_required(@required)
|> validate_array_lengths()
|> unique_constraint([:lat, :lon, :valid_time])
end
@doc """
Convert a native profile's parallel arrays into the list-of-maps
shape the skew-T renderer expects (`%{"pres", "tmpc", "dwpc", "hght"}`).
Dewpoint is derived from specific humidity, pressure, and temperature
via the Magnus inverse so the chart gets a full-atmosphere trace even
though the native file only carries SPFH. The result is sorted
surface-first (descending pressure).
"""
@spec to_skew_t_profile(t()) :: [map()]
def to_skew_t_profile(%__MODULE__{} = profile) do
heights = profile.heights_m || []
temps = profile.temp_k || []
spfhs = profile.spfh || []
pressures = profile.pressure_pa || []
[heights, temps, spfhs, pressures]
|> Enum.zip()
|> Enum.flat_map(&level_to_skew_t/1)
|> Enum.sort_by(& &1["pres"], :desc)
end
defp level_to_skew_t({h, t_k, q, p_pa}) when is_number(t_k) and is_number(p_pa) do
p_hpa = p_pa / 100.0
[
%{
"pres" => p_hpa,
"tmpc" => t_k - 273.15,
"dwpc" => dewpoint_c(q, p_pa),
"hght" => h
}
]
end
defp level_to_skew_t(_), do: []
defp dewpoint_c(q, p_pa) when is_number(q) and q > 0 and is_number(p_pa) do
e_hpa = q * p_pa / (0.622 + 0.378 * q) / 100.0
ln = :math.log(e_hpa / 6.112)
243.5 * ln / (17.67 - ln)
end
defp dewpoint_c(_, _), do: nil
# All level arrays must have the same length, matching level_count.
defp validate_array_lengths(changeset) do
level_count = get_field(changeset, :level_count)
arrays =
[:heights_m, :temp_k, :spfh, :pressure_pa, :u_wind_ms, :v_wind_ms, :tke_m2s2]
|> Enum.map(&{&1, get_field(changeset, &1)})
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
expected = level_count || arrays |> Enum.map(fn {_, v} -> length(v) end) |> Enum.max(fn -> 0 end)
Enum.reduce(arrays, changeset, fn {key, values}, acc ->
if length(values) == expected do
acc
else
add_error(acc, key, "length #{length(values)} does not match expected #{expected}")
end
end)
end
end