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 # 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