prop/lib/microwaveprop/weather/hrrr_native_profile.ex
Graham McIntire e7a7ae073d Phase 9.3, 9.4, and Phase 3 NEXRAD pipeline
Task 9.3 - Weight recalibration via gradient descent:
- Recalibrator module fits logistic regression weights using Nx
- Trains on QSO positives vs random baseline negatives
- Cross-validates by month, normalizes weights to sum to 1.0
- Mix task: mix recalibrate_scorer --sample 5000 --epochs 2000

Task 9.4 - Side-by-side scorer comparison:
- ScorerDiff.compare/3 re-scores grid with old vs new weights
- Reports mean diff, regressions, improvements, per-band breakdown
- Mix task: mix scorer_diff --new-weights '{...}'

Phase 3 - NEXRAD ingestion pipeline:
- NexradClient fetches IEM n0q composite PNGs, extracts per-point
  box statistics (mean/max dBZ, texture variance)
- NexradObservation schema with unique (lat, lon, observed_at)
- NexradWorker on :nexrad queue for background processing
- nexrad_texture backtest feature in Features module
- mix nexrad_backfill --limit 200

All tasks added to AdminTaskWorker and Release for production use.
1116 tests, 0 failures.
2026-04-10 12:48:36 -05:00

93 lines
3 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
@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
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