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