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.
140 lines
4.4 KiB
Elixir
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
|