Phase 1 tasks 1.1-1.5: HRRR native hybrid-sigma ingestion
- Spike docs at docs/research/hrrr_native_levels.md confirming files
are on AWS S3 for 5+ years, 50 hybrid levels, and include TKE and
SPFH needed for Phase 2 turbulence features. Architectural finding:
per-point on-demand fetching is impractical (~530 MB/file), so
the ingestion worker batches per (date, hour) instead.
- hrrr_native_profiles schema: arrays per level plus cached surface
scalars and placeholder columns for Phase 2/4 derived fields.
Strictly additive — the existing hrrr_profiles table is untouched.
- HrrrNativeClient: pure URL/message-list helpers, build_native_profile/1
that turns a parsed wgrib2 map into the schema shape (TDD'd).
- Exposed HrrrClient.download_grib_ranges/2 so the native client
reuses the existing parallel byte-range download + disk cache.
- HrrrNativeGridWorker: Oban worker keyed on {year, month, day, hour},
unique at :infinity, pulls distinct (lat, lon) points from contacts
in the ±30 min window, downloads the native grib2, extracts per
point, bulk-upserts.
- mix hrrr_native_backfill --limit N enqueues the top-N hours by
contact count.
Phase 1 gate still pending Task 1.6 (sanity-check backtest after
live data lands).
This commit is contained in:
parent
7630934bcb
commit
900685aa06
10 changed files with 943 additions and 2 deletions
131
docs/research/hrrr_native_levels.md
Normal file
131
docs/research/hrrr_native_levels.md
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
# HRRR Hybrid-Sigma ("Native") Levels — Spike Findings
|
||||||
|
|
||||||
|
**Date:** 2026-04-09
|
||||||
|
**Purpose:** Task 1.1 of `docs/plans/2026-04-09-propagation-modeling-improvements.md`
|
||||||
|
**Question:** Can we practically ingest HRRR native hybrid-sigma profiles to replace the 25 hPa pressure-level product for boundary-layer propagation features?
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
**Yes — with one architecture change.** The files are available on AWS S3 going back at least 5 years and expose all the variables needed for Richardson number, θₑ, shear, and TKE computations on 50 hybrid-sigma levels. But the per-file download is too large to run on-demand per QSO point. Phase 1 should ingest native profiles in batch mode per HRRR run hour, not per QSO.
|
||||||
|
|
||||||
|
## File location
|
||||||
|
|
||||||
|
Same S3 bucket as the existing surface/pressure products:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://noaa-hrrr-bdp-pds.s3.amazonaws.com/hrrr.<YYYYMMDD>/conus/hrrr.t<HH>z.wrfnatf00.grib2
|
||||||
|
https://noaa-hrrr-bdp-pds.s3.amazonaws.com/hrrr.<YYYYMMDD>/conus/hrrr.t<HH>z.wrfnatf00.grib2.idx
|
||||||
|
```
|
||||||
|
|
||||||
|
`wrfnatf00` = native levels, 0-hour forecast. Forecast hours `wrfnatf01` through `wrfnatf18` also exist.
|
||||||
|
|
||||||
|
## Historical availability
|
||||||
|
|
||||||
|
Spot-checked on 2026-04-09 by HEAD-ing the `.idx` URL:
|
||||||
|
|
||||||
|
| Date | HTTP status |
|
||||||
|
|---|---|
|
||||||
|
| 2026-03-09 (30d) | 200 |
|
||||||
|
| 2026-02-09 (60d) | 200 |
|
||||||
|
| 2026-01-09 (~90d) | 200 |
|
||||||
|
| 2025-12-09 (~120d) | 200 |
|
||||||
|
| 2024-04-09 (~1y) | 200 |
|
||||||
|
| 2023-04-09 (~2y) | 200 |
|
||||||
|
| 2022-04-09 (~3y) | 200 |
|
||||||
|
| 2021-04-09 (~5y) | 200 |
|
||||||
|
|
||||||
|
No rotation problem. We can backtest against years of historical native data if we want to, which unblocks Phase 6 (climatology) as well.
|
||||||
|
|
||||||
|
## What the file contains
|
||||||
|
|
||||||
|
The `.idx` file for one HRRR run has **1133 GRIB2 messages**. **1000 of them are on hybrid levels 1-50**, plus 133 surface/2m/10m/composite entries.
|
||||||
|
|
||||||
|
Each of the 50 hybrid levels carries these 20 variables:
|
||||||
|
|
||||||
|
```
|
||||||
|
PRES pressure (Pa)
|
||||||
|
HGT geopotential height (m)
|
||||||
|
TMP temperature (K)
|
||||||
|
SPFH specific humidity (kg/kg) ← derive Td, θ, θₑ from SPFH + TMP + PRES
|
||||||
|
UGRD u wind (m/s)
|
||||||
|
VGRD v wind (m/s)
|
||||||
|
VVEL vertical velocity (Pa/s)
|
||||||
|
TKE turbulent kinetic energy (m²/s²) ← **exactly the turbulence signal Phase 2 needs**
|
||||||
|
CLMR cloud water mixing ratio
|
||||||
|
CIMIXR cloud ice mixing ratio
|
||||||
|
RWMR rain water mixing ratio
|
||||||
|
SNMR snow mixing ratio
|
||||||
|
GRLE graupel mixing ratio
|
||||||
|
NCONCD number concentration of cloud droplets
|
||||||
|
NCCICE number concentration of cloud ice
|
||||||
|
SPNCR number concentration of rain
|
||||||
|
PMTF ???
|
||||||
|
PMTC ???
|
||||||
|
FRACCC convective cloud fraction
|
||||||
|
MASSDEN particulate mass density
|
||||||
|
```
|
||||||
|
|
||||||
|
The seven we care about for Phase 2–4 are **TMP, SPFH, HGT, UGRD, VGRD, TKE, PRES** × 50 levels = 350 messages out of the 1133.
|
||||||
|
|
||||||
|
Note: **there is no DPT on hybrid levels.** The pressure-level product has dewpoint directly; the native product only has specific humidity. We will compute `Td` from `(T, SPFH, P)` using the Bolton (1980) or Magnus-Tetens formulation. This is standard and cheap.
|
||||||
|
|
||||||
|
## Vertical spacing
|
||||||
|
|
||||||
|
Hybrid levels are defined by the HRRR vertical coordinate function; level 1 is closest to the surface. The actual geometric height of each level varies by grid point (because hybrid coordinates conform to terrain). Typical spacing for a plains grid point:
|
||||||
|
|
||||||
|
- Level 1: ~8 m AGL
|
||||||
|
- Level 2: ~25 m AGL
|
||||||
|
- Level 3: ~50 m AGL
|
||||||
|
- Level 5: ~130 m AGL
|
||||||
|
- Level 10: ~460 m AGL
|
||||||
|
- Level 20: ~1.7 km AGL
|
||||||
|
- Level 30: ~3.8 km AGL
|
||||||
|
- Level 50: ~19 km AGL
|
||||||
|
|
||||||
|
**The first ~5 hybrid levels alone span the range where most surface-based ducts live.** That's the whole point: 25 hPa pressure levels (~250 m spacing at the surface) completely miss anything thinner than ~200 m, and the native product gives us 30-50 m resolution in the same band.
|
||||||
|
|
||||||
|
(Actual per-level heights must be read from the HGT message for each grid point, not from a table. We store `heights_m` as `{:array, :float}` on `hrrr_native_profiles` for exactly this reason.)
|
||||||
|
|
||||||
|
## Download size — the architectural gotcha
|
||||||
|
|
||||||
|
Downloaded one full `wrfnatf00.grib2.idx` and walked the offsets for the seven essential variables on all 50 levels:
|
||||||
|
|
||||||
|
- 350 GRIB2 messages
|
||||||
|
- Total byte span of the essential messages: **~530 MB**
|
||||||
|
- Full native file: ~566 MB (so essentials are almost the whole file)
|
||||||
|
|
||||||
|
That's **per HRRR run hour**, for the full CONUS grid.
|
||||||
|
|
||||||
|
This matters because the plan's draft of Task 1.4 has a per-QSO `HrrrNativeFetchWorker`. 530 MB × 58k QSOs = 29 TB of re-downloads, which is absurd. **Byte-range requests don't save us** — GRIB2 messages can only be decoded whole, so we'd have to pull ~350 × 1.5 MB per point, and AWS doesn't support server-side subsetting on this bucket.
|
||||||
|
|
||||||
|
### What to do instead
|
||||||
|
|
||||||
|
Two workable architectures:
|
||||||
|
|
||||||
|
1. **Batch per run hour.** A single worker downloads one full `wrfnatf00.grib2` (~566 MB), extracts native profiles at *all* points of interest for that hour in one pass (the grid of contacts + whatever points the live scorer wants), and bulk-upserts them into `hrrr_native_profiles`. This is how the existing `PropagationGridWorker` pipeline works for the pressure-level file; we mirror it for native levels.
|
||||||
|
|
||||||
|
2. **Per-day local cache.** Download one file per `(date, hour)`, stash it on local disk for the duration of a backfill job, extract all points of interest for that hour, delete the file. This is probably only worth the bandwidth for dense backfills and can be folded into option 1.
|
||||||
|
|
||||||
|
**Plan correction:** the `HrrrNativeFetchWorker` in Task 1.4 should be renamed `HrrrNativeGridWorker` and take `{date, hour}` as its unique key, not `{lat, lon, valid_time}`. The backfill mix task in Task 1.5 iterates over historical (date, hour) pairs where we have contacts and enqueues one grid worker per hour.
|
||||||
|
|
||||||
|
## Bandwidth budget
|
||||||
|
|
||||||
|
If we backfill 1 year of hourly native grids for every hour where we have at least one QSO, and we have ~58k QSOs spread over 5+ years, conservatively ~15k distinct (date, hour) hours. 15k × 566 MB ≈ **8.5 TB**. That's a lot. We don't need it all — Phase 2 only needs enough data to backtest. Realistic first backfill:
|
||||||
|
|
||||||
|
- Top 500 distinct hours by QSO count → ~280 GB (one-time, finishes overnight)
|
||||||
|
- Extract only the lat/lon points where we have QSOs in that hour plus the propagation grid points
|
||||||
|
- Store derived profiles only (< 5 KB per profile), not the raw GRIB2
|
||||||
|
|
||||||
|
Storage for derived profiles: 500 hours × ~300 points/hour × 5 KB ≈ 750 MB in the DB. Fine.
|
||||||
|
|
||||||
|
## Proposed Phase 1 plan adjustments
|
||||||
|
|
||||||
|
1. Rename worker: `HrrrNativeGridWorker` instead of `HrrrNativeFetchWorker`; unique keys `{date, hour}`; downloads the full file once per run hour and bulk-processes points.
|
||||||
|
2. Keep `hrrr_native_profiles` schema as planned (per-point, per-valid_time), just populated in batch.
|
||||||
|
3. Task 1.5 backfill mix task now enqueues grid workers for the top-N (date, hour) hours by QSO density.
|
||||||
|
4. Feature functions in later phases look up `hrrr_native_profiles` the same way the existing scorer looks up `hrrr_profiles` — no API change on the consuming side.
|
||||||
|
5. Computing Td from SPFH is a small Phase 2 helper; budget ~half a day for it.
|
||||||
|
|
||||||
|
## Gate decision
|
||||||
|
|
||||||
|
**Gate pass.** Files are available historically, all required variables are present, vertical resolution is 5-30× finer than the pressure-level product near the surface, and the ingestion architecture is a known pattern (matches the existing grid worker). Proceed to Task 1.2 (schema) with the batch-mode correction.
|
||||||
|
|
@ -308,9 +308,18 @@ defmodule Microwaveprop.Weather.HrrrClient do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp download_grib_ranges(_url, []), do: {:ok, <<>>}
|
@doc """
|
||||||
|
Downloads the given byte ranges from a GRIB2 URL and concatenates
|
||||||
|
them in offset order. Merges adjacent/overlapping ranges and runs
|
||||||
|
requests in parallel. Backed by a disk cache keyed on
|
||||||
|
`(url, ranges)` in dev.
|
||||||
|
|
||||||
defp download_grib_ranges(url, ranges) do
|
Public so the native-level client can reuse it rather than
|
||||||
|
duplicating the HTTP/parallelism/cache logic.
|
||||||
|
"""
|
||||||
|
def download_grib_ranges(_url, []), do: {:ok, <<>>}
|
||||||
|
|
||||||
|
def download_grib_ranges(url, ranges) do
|
||||||
# Check local cache first (dev only)
|
# Check local cache first (dev only)
|
||||||
cache_key = url_to_cache_key(url, ranges)
|
cache_key = url_to_cache_key(url, ranges)
|
||||||
|
|
||||||
|
|
|
||||||
126
lib/microwaveprop/weather/hrrr_native_client.ex
Normal file
126
lib/microwaveprop/weather/hrrr_native_client.ex
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
defmodule Microwaveprop.Weather.HrrrNativeClient do
|
||||||
|
@moduledoc """
|
||||||
|
Fetches HRRR native hybrid-sigma profiles from the AWS HRRR bucket.
|
||||||
|
|
||||||
|
This is the companion to `HrrrClient`, which works against the
|
||||||
|
surface and 25 hPa pressure-level products. The native file
|
||||||
|
(`wrfnatf00.grib2`) carries all variables on the 50 hybrid-sigma
|
||||||
|
levels native to the HRRR model grid. Vertical spacing near the
|
||||||
|
surface is ~10-50 m instead of the ~250 m the pressure-level
|
||||||
|
product gives us — crucial for resolving the ducts and
|
||||||
|
boundary-layer inversions discussed in
|
||||||
|
`docs/plans/2026-04-09-propagation-modeling-improvements.md`.
|
||||||
|
|
||||||
|
## Design: batch, not per-point
|
||||||
|
|
||||||
|
Each native-level HRRR file is ~566 MB. Essential variables (TMP,
|
||||||
|
SPFH, HGT, UGRD, VGRD, TKE, PRES on all 50 hybrid levels) span
|
||||||
|
~530 MB of that file. Per-point on-demand fetching is not viable.
|
||||||
|
Instead, the worker fetches the file once per `(date, hour)`,
|
||||||
|
extracts native profiles for every point of interest in one pass,
|
||||||
|
and bulk-inserts them.
|
||||||
|
|
||||||
|
See `docs/research/hrrr_native_levels.md` for the full analysis.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias Microwaveprop.Weather.HrrrClient
|
||||||
|
|
||||||
|
@native_levels 1..50
|
||||||
|
|
||||||
|
@native_variables ~w(TMP SPFH HGT UGRD VGRD TKE PRES)
|
||||||
|
|
||||||
|
@hrrr_base_default "https://noaa-hrrr-bdp-pds.s3.amazonaws.com"
|
||||||
|
|
||||||
|
defp hrrr_base, do: Application.get_env(:microwaveprop, :hrrr_base_url, @hrrr_base_default)
|
||||||
|
|
||||||
|
@doc "Number of native hybrid-sigma levels in HRRR (currently 50)."
|
||||||
|
def native_level_count, do: Enum.count(@native_levels)
|
||||||
|
|
||||||
|
@doc "The seven essential variables we extract on every native level."
|
||||||
|
def native_variables, do: @native_variables
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
The list of `%{var:, level:}` messages we extract from every native
|
||||||
|
file. 7 vars × 50 levels = 350 messages, matching the spike in
|
||||||
|
Task 1.1.
|
||||||
|
"""
|
||||||
|
def native_messages do
|
||||||
|
for level <- @native_levels, var <- @native_variables do
|
||||||
|
%{var: var, level: "#{level} hybrid level"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Builds the AWS S3 URL for a native-level HRRR grib2 file.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> Microwaveprop.Weather.HrrrNativeClient.hrrr_native_url(~D[2026-04-09], 12)
|
||||||
|
"https://noaa-hrrr-bdp-pds.s3.amazonaws.com/hrrr.20260409/conus/hrrr.t12z.wrfnatf00.grib2"
|
||||||
|
"""
|
||||||
|
def hrrr_native_url(date, hour, forecast_hour \\ 0) do
|
||||||
|
date_str = Calendar.strftime(date, "%Y%m%d")
|
||||||
|
hour_str = hour |> Integer.to_string() |> String.pad_leading(2, "0")
|
||||||
|
fh_str = forecast_hour |> Integer.to_string() |> String.pad_leading(2, "0")
|
||||||
|
"#{hrrr_base()}/hrrr.#{date_str}/conus/hrrr.t#{hour_str}z.wrfnatf#{fh_str}.grib2"
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Converts a parsed `%{"VAR:level" => value}` map into a
|
||||||
|
`HrrrNativeProfile`-shaped map with level arrays sorted by ascending
|
||||||
|
hybrid level (level 1 = surface).
|
||||||
|
|
||||||
|
This is the pure-function bit of the pipeline: the network/GRIB2
|
||||||
|
decoding lives elsewhere and just feeds `parsed` in. Isolating this
|
||||||
|
lets us unit-test every invariant we care about (array lengths,
|
||||||
|
ordering, surface scalar caching) without touching the network.
|
||||||
|
"""
|
||||||
|
def build_native_profile(parsed) when is_map(parsed) do
|
||||||
|
levels =
|
||||||
|
Enum.map(@native_levels, fn level ->
|
||||||
|
level_str = "#{level} hybrid level"
|
||||||
|
|
||||||
|
%{
|
||||||
|
level: level,
|
||||||
|
hgt: parsed["HGT:#{level_str}"],
|
||||||
|
tmp: parsed["TMP:#{level_str}"],
|
||||||
|
spfh: parsed["SPFH:#{level_str}"],
|
||||||
|
pres: parsed["PRES:#{level_str}"],
|
||||||
|
ugrd: parsed["UGRD:#{level_str}"],
|
||||||
|
vgrd: parsed["VGRD:#{level_str}"],
|
||||||
|
tke: parsed["TKE:#{level_str}"]
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|> Enum.reject(fn %{hgt: hgt, tmp: tmp} -> is_nil(hgt) or is_nil(tmp) end)
|
||||||
|
|> Enum.sort_by(& &1.hgt)
|
||||||
|
|
||||||
|
level_count = length(levels)
|
||||||
|
|
||||||
|
%{
|
||||||
|
level_count: level_count,
|
||||||
|
heights_m: Enum.map(levels, & &1.hgt),
|
||||||
|
temp_k: Enum.map(levels, & &1.tmp),
|
||||||
|
spfh: Enum.map(levels, & &1.spfh),
|
||||||
|
pressure_pa: Enum.map(levels, & &1.pres),
|
||||||
|
u_wind_ms: Enum.map(levels, & &1.ugrd),
|
||||||
|
v_wind_ms: Enum.map(levels, & &1.vgrd),
|
||||||
|
tke_m2s2: Enum.map(levels, & &1.tke),
|
||||||
|
surface_temp_k: parsed["TMP:surface"] || List.first(levels) |> safe_get(:tmp),
|
||||||
|
surface_spfh: parsed["SPFH:2 m above ground"] || List.first(levels) |> safe_get(:spfh),
|
||||||
|
surface_pressure_pa: parsed["PRES:surface"] || List.first(levels) |> safe_get(:pres)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp safe_get(nil, _key), do: nil
|
||||||
|
defp safe_get(map, key), do: Map.get(map, key)
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the list of byte ranges to download for the essentials in
|
||||||
|
one native HRRR file. Used by the (still-to-be-built) grid worker.
|
||||||
|
Wraps `HrrrClient.byte_ranges_for_messages/2` with our native
|
||||||
|
message list so callers don't have to know both.
|
||||||
|
"""
|
||||||
|
def essential_byte_ranges(idx_entries) do
|
||||||
|
HrrrClient.byte_ranges_for_messages(idx_entries, native_messages())
|
||||||
|
end
|
||||||
|
end
|
||||||
93
lib/microwaveprop/weather/hrrr_native_profile.ex
Normal file
93
lib/microwaveprop/weather/hrrr_native_profile.ex
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
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
|
||||||
164
lib/microwaveprop/workers/hrrr_native_grid_worker.ex
Normal file
164
lib/microwaveprop/workers/hrrr_native_grid_worker.ex
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
defmodule Microwaveprop.Workers.HrrrNativeGridWorker do
|
||||||
|
@moduledoc """
|
||||||
|
Fetches one hour's worth of HRRR native hybrid-sigma profiles for
|
||||||
|
every point of interest (contact location) and bulk-inserts them
|
||||||
|
into `hrrr_native_profiles`.
|
||||||
|
|
||||||
|
Jobs are unique on `{year, month, day, hour}` so a backfill sweep
|
||||||
|
that enqueues duplicate hours collapses automatically.
|
||||||
|
|
||||||
|
This is the batch companion to the existing `HrrrFetchWorker`. The
|
||||||
|
native-level product is ~566 MB per run hour, so per-point
|
||||||
|
on-demand fetching is impractical — see
|
||||||
|
`docs/research/hrrr_native_levels.md`. Instead, each job grabs one
|
||||||
|
file once and extracts native profiles for every point of interest
|
||||||
|
in a single pass.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Oban.Worker,
|
||||||
|
queue: :hrrr,
|
||||||
|
max_attempts: 3,
|
||||||
|
unique: [
|
||||||
|
period: :infinity,
|
||||||
|
states: [:available, :scheduled, :executing, :retryable],
|
||||||
|
keys: [:year, :month, :day, :hour]
|
||||||
|
]
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias Microwaveprop.Radio.Contact
|
||||||
|
alias Microwaveprop.Repo
|
||||||
|
alias Microwaveprop.Weather.Grib2.Extractor
|
||||||
|
alias Microwaveprop.Weather.HrrrClient
|
||||||
|
alias Microwaveprop.Weather.HrrrNativeClient
|
||||||
|
alias Microwaveprop.Weather.HrrrNativeProfile
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
@impl Oban.Worker
|
||||||
|
def perform(%Oban.Job{args: args}) do
|
||||||
|
%{"year" => year, "month" => month, "day" => day, "hour" => hour} = args
|
||||||
|
{:ok, date} = Date.new(year, month, day)
|
||||||
|
{:ok, valid_time} = DateTime.new(date, Time.new!(hour, 0, 0), "Etc/UTC")
|
||||||
|
|
||||||
|
points = points_of_interest_for_hour(valid_time)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
points == [] ->
|
||||||
|
Logger.info("HrrrNativeGridWorker: no points for #{valid_time}, skipping")
|
||||||
|
:ok
|
||||||
|
|
||||||
|
already_ingested?(points, valid_time) ->
|
||||||
|
Logger.info("HrrrNativeGridWorker: #{valid_time} already ingested, skipping")
|
||||||
|
:ok
|
||||||
|
|
||||||
|
true ->
|
||||||
|
fetch_and_upsert(date, hour, valid_time, points)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def points_of_interest_for_hour(valid_time) do
|
||||||
|
time_start = DateTime.add(valid_time, -1800, :second)
|
||||||
|
time_end = DateTime.add(valid_time, 1800, :second)
|
||||||
|
|
||||||
|
Contact
|
||||||
|
|> where([c], not is_nil(c.pos1))
|
||||||
|
|> where([c], c.qso_timestamp >= ^time_start and c.qso_timestamp <= ^time_end)
|
||||||
|
|> select([c], c.pos1)
|
||||||
|
|> Repo.all()
|
||||||
|
|> Enum.flat_map(fn pos ->
|
||||||
|
case {pos["lat"], pos["lon"] || pos["lng"]} do
|
||||||
|
{lat, lon} when is_number(lat) and is_number(lon) -> [{snap(lat), snap(lon)}]
|
||||||
|
_ -> []
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|> Enum.uniq()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp snap(x), do: Float.round(x * 1.0, 3)
|
||||||
|
|
||||||
|
defp already_ingested?(points, valid_time) do
|
||||||
|
# Short-circuit: if we already have a profile for every point at
|
||||||
|
# this valid_time, the worker is a no-op. Any missing point
|
||||||
|
# triggers a full download (partial fills are rare; the cost of
|
||||||
|
# re-downloading for a handful of gaps is acceptable).
|
||||||
|
count =
|
||||||
|
HrrrNativeProfile
|
||||||
|
|> where([p], p.valid_time == ^valid_time)
|
||||||
|
|> where(
|
||||||
|
[p],
|
||||||
|
fragment("ROW(?, ?) IN (SELECT * FROM UNNEST(?::float[], ?::float[]))",
|
||||||
|
p.lat,
|
||||||
|
p.lon,
|
||||||
|
^Enum.map(points, &elem(&1, 0)),
|
||||||
|
^Enum.map(points, &elem(&1, 1))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|> select([p], count(p.id))
|
||||||
|
|> Repo.one()
|
||||||
|
|
||||||
|
count >= length(points)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fetch_and_upsert(date, hour, valid_time, points) do
|
||||||
|
url = HrrrNativeClient.hrrr_native_url(date, hour)
|
||||||
|
idx_url = url <> ".idx"
|
||||||
|
|
||||||
|
with {:ok, idx_text} <- fetch_idx(idx_url),
|
||||||
|
idx_entries = HrrrClient.parse_idx(idx_text),
|
||||||
|
ranges = HrrrNativeClient.essential_byte_ranges(idx_entries),
|
||||||
|
_ = Logger.info("HRRR native downloading #{length(ranges)} ranges for #{valid_time}"),
|
||||||
|
{:ok, grib_binary} <- HrrrClient.download_grib_ranges(url, ranges),
|
||||||
|
_ = Logger.info("HRRR native downloaded #{byte_size(grib_binary)} bytes"),
|
||||||
|
{:ok, grid_data} <- Extractor.extract_grid(grib_binary, points) do
|
||||||
|
rows =
|
||||||
|
grid_data
|
||||||
|
|> Enum.flat_map(fn {{lat, lon}, parsed} ->
|
||||||
|
profile = HrrrNativeClient.build_native_profile(parsed)
|
||||||
|
|
||||||
|
if profile.level_count > 0 do
|
||||||
|
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||||
|
|
||||||
|
[
|
||||||
|
profile
|
||||||
|
|> Map.merge(%{
|
||||||
|
id: Ecto.UUID.bingenerate(),
|
||||||
|
valid_time: valid_time,
|
||||||
|
run_time: valid_time,
|
||||||
|
lat: lat,
|
||||||
|
lon: lon,
|
||||||
|
inserted_at: now,
|
||||||
|
updated_at: now
|
||||||
|
})
|
||||||
|
]
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
{inserted, _} =
|
||||||
|
Repo.insert_all(
|
||||||
|
HrrrNativeProfile,
|
||||||
|
rows,
|
||||||
|
on_conflict: {:replace_all_except, [:id, :inserted_at]},
|
||||||
|
conflict_target: [:lat, :lon, :valid_time]
|
||||||
|
)
|
||||||
|
|
||||||
|
Logger.info("HrrrNativeGridWorker: upserted #{inserted} profiles for #{valid_time}")
|
||||||
|
:ok
|
||||||
|
else
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning("HrrrNativeGridWorker failed for #{valid_time}: #{inspect(reason)}")
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fetch_idx(url) do
|
||||||
|
case Req.get(url, receive_timeout: 120_000) do
|
||||||
|
{:ok, %{status: 200, body: body}} -> {:ok, body}
|
||||||
|
{:ok, %{status: status}} -> {:error, "HRRR native idx HTTP #{status}"}
|
||||||
|
{:error, reason} -> {:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
69
lib/mix/tasks/hrrr_native_backfill.ex
Normal file
69
lib/mix/tasks/hrrr_native_backfill.ex
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
defmodule Mix.Tasks.HrrrNativeBackfill do
|
||||||
|
@shortdoc "Enqueue HrrrNativeGridWorker jobs for the top-N hours by contact count"
|
||||||
|
@moduledoc """
|
||||||
|
Backfills the `hrrr_native_profiles` table by enqueueing one
|
||||||
|
`HrrrNativeGridWorker` job per distinct `(year, month, day, hour)`
|
||||||
|
where we have contacts, prioritized by contact count so the most
|
||||||
|
data-dense hours land first.
|
||||||
|
|
||||||
|
Native HRRR files are ~566 MB each, so think about the total
|
||||||
|
bandwidth before running with a large limit. Typical plan:
|
||||||
|
|
||||||
|
mix hrrr_native_backfill --limit 50 # one-time smoke backfill
|
||||||
|
mix hrrr_native_backfill --limit 500 # full Phase 2 backfill (~280 GB)
|
||||||
|
|
||||||
|
Jobs are deduplicated by Oban's unique constraint on
|
||||||
|
`{year, month, day, hour}` so running the task twice is safe.
|
||||||
|
"""
|
||||||
|
use Mix.Task
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias Microwaveprop.Radio.Contact
|
||||||
|
alias Microwaveprop.Repo
|
||||||
|
alias Microwaveprop.Workers.HrrrNativeGridWorker
|
||||||
|
|
||||||
|
@impl Mix.Task
|
||||||
|
def run(argv) do
|
||||||
|
Mix.Task.run("app.start")
|
||||||
|
|
||||||
|
{opts, _, _} = OptionParser.parse(argv, switches: [limit: :integer])
|
||||||
|
limit = Keyword.get(opts, :limit, 50)
|
||||||
|
|
||||||
|
hours = top_hours_by_contact_count(limit)
|
||||||
|
Mix.shell().info("Enqueueing #{length(hours)} HrrrNativeGridWorker jobs")
|
||||||
|
|
||||||
|
Enum.each(hours, fn %{year: y, month: m, day: d, hour: h, contacts: n} ->
|
||||||
|
args = %{"year" => y, "month" => m, "day" => d, "hour" => h}
|
||||||
|
|
||||||
|
case Oban.insert(HrrrNativeGridWorker.new(args)) do
|
||||||
|
{:ok, _job} -> Mix.shell().info(" #{y}-#{pad(m)}-#{pad(d)} #{pad(h)}Z (#{n} contacts)")
|
||||||
|
{:error, reason} -> Mix.shell().error(" #{y}-#{pad(m)}-#{pad(d)} #{pad(h)}Z failed: #{inspect(reason)}")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp pad(n), do: n |> Integer.to_string() |> String.pad_leading(2, "0")
|
||||||
|
|
||||||
|
defp top_hours_by_contact_count(limit) do
|
||||||
|
from(c in Contact,
|
||||||
|
where: not is_nil(c.pos1),
|
||||||
|
select: %{
|
||||||
|
year: fragment("EXTRACT(YEAR FROM ?)::int", c.qso_timestamp),
|
||||||
|
month: fragment("EXTRACT(MONTH FROM ?)::int", c.qso_timestamp),
|
||||||
|
day: fragment("EXTRACT(DAY FROM ?)::int", c.qso_timestamp),
|
||||||
|
hour: fragment("EXTRACT(HOUR FROM ?)::int", c.qso_timestamp),
|
||||||
|
contacts: count(c.id)
|
||||||
|
},
|
||||||
|
group_by: [
|
||||||
|
fragment("EXTRACT(YEAR FROM ?)::int", c.qso_timestamp),
|
||||||
|
fragment("EXTRACT(MONTH FROM ?)::int", c.qso_timestamp),
|
||||||
|
fragment("EXTRACT(DAY FROM ?)::int", c.qso_timestamp),
|
||||||
|
fragment("EXTRACT(HOUR FROM ?)::int", c.qso_timestamp)
|
||||||
|
],
|
||||||
|
order_by: [desc: count(c.id)],
|
||||||
|
limit: ^limit
|
||||||
|
)
|
||||||
|
|> Repo.all()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
defmodule Microwaveprop.Repo.Migrations.CreateHrrrNativeProfiles do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
create table(:hrrr_native_profiles, primary_key: false) do
|
||||||
|
add :id, :binary_id, primary_key: true, null: false
|
||||||
|
add :valid_time, :utc_datetime, null: false
|
||||||
|
add :run_time, :utc_datetime
|
||||||
|
add :lat, :float, null: false
|
||||||
|
add :lon, :float, null: false
|
||||||
|
|
||||||
|
# Hybrid-sigma level data, sorted ascending by height (level 1 first).
|
||||||
|
# Each array has the same length (HRRR native has 50 hybrid levels);
|
||||||
|
# leaving them as arrays avoids a sidecar table and keeps per-profile
|
||||||
|
# queries cheap.
|
||||||
|
add :level_count, :integer
|
||||||
|
add :heights_m, {:array, :float}
|
||||||
|
add :temp_k, {:array, :float}
|
||||||
|
add :spfh, {:array, :float}
|
||||||
|
add :pressure_pa, {:array, :float}
|
||||||
|
add :u_wind_ms, {:array, :float}
|
||||||
|
add :v_wind_ms, {:array, :float}
|
||||||
|
add :tke_m2s2, {:array, :float}
|
||||||
|
|
||||||
|
# Cached surface-level scalars for quick lookup without unpacking arrays.
|
||||||
|
add :surface_temp_k, :float
|
||||||
|
add :surface_spfh, :float
|
||||||
|
add :surface_pressure_pa, :float
|
||||||
|
|
||||||
|
# Derived fields populated by Phase 2/4. Nullable so the schema
|
||||||
|
# migration stays additive; the derive task fills them in later.
|
||||||
|
add :inversion_top_m, :float
|
||||||
|
add :bulk_richardson, :float
|
||||||
|
add :theta_e_jump_k, :float
|
||||||
|
add :shear_at_top_ms, :float
|
||||||
|
add :ducts, :map
|
||||||
|
add :best_duct_band_ghz, :float
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
create unique_index(:hrrr_native_profiles, [:lat, :lon, :valid_time])
|
||||||
|
create index(:hrrr_native_profiles, [:valid_time])
|
||||||
|
end
|
||||||
|
end
|
||||||
167
test/microwaveprop/weather/hrrr_native_client_test.exs
Normal file
167
test/microwaveprop/weather/hrrr_native_client_test.exs
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
defmodule Microwaveprop.Weather.HrrrNativeClientTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias Microwaveprop.Weather.HrrrClient
|
||||||
|
alias Microwaveprop.Weather.HrrrNativeClient
|
||||||
|
|
||||||
|
describe "hrrr_native_url/3" do
|
||||||
|
test "builds the wrfnatf00 URL for a given date and hour" do
|
||||||
|
url = HrrrNativeClient.hrrr_native_url(~D[2026-04-09], 12)
|
||||||
|
|
||||||
|
assert url ==
|
||||||
|
"https://noaa-hrrr-bdp-pds.s3.amazonaws.com/hrrr.20260409/conus/hrrr.t12z.wrfnatf00.grib2"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "pads single-digit hours" do
|
||||||
|
url = HrrrNativeClient.hrrr_native_url(~D[2026-04-09], 6)
|
||||||
|
assert url =~ "hrrr.t06z."
|
||||||
|
end
|
||||||
|
|
||||||
|
test "supports non-zero forecast hours" do
|
||||||
|
url = HrrrNativeClient.hrrr_native_url(~D[2026-04-09], 12, 3)
|
||||||
|
assert url =~ "wrfnatf03.grib2"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "native_messages/0" do
|
||||||
|
test "lists 350 messages (7 vars × 50 levels)" do
|
||||||
|
messages = HrrrNativeClient.native_messages()
|
||||||
|
assert length(messages) == 350
|
||||||
|
end
|
||||||
|
|
||||||
|
test "every level appears with every essential variable" do
|
||||||
|
messages = HrrrNativeClient.native_messages()
|
||||||
|
|
||||||
|
# Pick level 5 — should have all 7 vars
|
||||||
|
level5 = Enum.filter(messages, fn %{level: l} -> l == "5 hybrid level" end)
|
||||||
|
vars = Enum.map(level5, & &1.var) |> Enum.sort()
|
||||||
|
|
||||||
|
assert vars == ~w(HGT PRES SPFH TKE TMP UGRD VGRD)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "covers levels 1 through 50" do
|
||||||
|
levels =
|
||||||
|
HrrrNativeClient.native_messages()
|
||||||
|
|> Enum.map(& &1.level)
|
||||||
|
|> Enum.uniq()
|
||||||
|
|> Enum.map(fn level_str ->
|
||||||
|
[digits, _] = String.split(level_str, " ", parts: 2)
|
||||||
|
String.to_integer(digits)
|
||||||
|
end)
|
||||||
|
|> Enum.sort()
|
||||||
|
|
||||||
|
assert levels == Enum.to_list(1..50)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "build_native_profile/1" do
|
||||||
|
test "assembles arrays in level order and caches surface scalars" do
|
||||||
|
parsed = %{
|
||||||
|
"HGT:1 hybrid level" => 8.0,
|
||||||
|
"TMP:1 hybrid level" => 295.0,
|
||||||
|
"SPFH:1 hybrid level" => 0.010,
|
||||||
|
"PRES:1 hybrid level" => 101_000.0,
|
||||||
|
"UGRD:1 hybrid level" => 2.0,
|
||||||
|
"VGRD:1 hybrid level" => 1.0,
|
||||||
|
"TKE:1 hybrid level" => 0.5,
|
||||||
|
"HGT:2 hybrid level" => 25.0,
|
||||||
|
"TMP:2 hybrid level" => 293.0,
|
||||||
|
"SPFH:2 hybrid level" => 0.008,
|
||||||
|
"PRES:2 hybrid level" => 99_800.0,
|
||||||
|
"UGRD:2 hybrid level" => 3.0,
|
||||||
|
"VGRD:2 hybrid level" => 1.5,
|
||||||
|
"TKE:2 hybrid level" => 0.3,
|
||||||
|
"TMP:surface" => 295.5,
|
||||||
|
"SPFH:2 m above ground" => 0.011,
|
||||||
|
"PRES:surface" => 101_325.0
|
||||||
|
}
|
||||||
|
|
||||||
|
profile = HrrrNativeClient.build_native_profile(parsed)
|
||||||
|
|
||||||
|
assert profile.level_count == 2
|
||||||
|
assert profile.heights_m == [8.0, 25.0]
|
||||||
|
assert profile.temp_k == [295.0, 293.0]
|
||||||
|
assert profile.spfh == [0.010, 0.008]
|
||||||
|
assert profile.u_wind_ms == [2.0, 3.0]
|
||||||
|
assert profile.surface_temp_k == 295.5
|
||||||
|
assert profile.surface_spfh == 0.011
|
||||||
|
assert profile.surface_pressure_pa == 101_325.0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "skips levels missing either HGT or TMP" do
|
||||||
|
parsed = %{
|
||||||
|
"HGT:1 hybrid level" => 8.0,
|
||||||
|
"TMP:1 hybrid level" => 295.0,
|
||||||
|
# level 2 has HGT but no TMP → skipped
|
||||||
|
"HGT:2 hybrid level" => 25.0,
|
||||||
|
"SPFH:2 hybrid level" => 0.008
|
||||||
|
}
|
||||||
|
|
||||||
|
profile = HrrrNativeClient.build_native_profile(parsed)
|
||||||
|
assert profile.level_count == 1
|
||||||
|
assert profile.heights_m == [8.0]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "orders levels by ascending height, not by hybrid level number" do
|
||||||
|
# Pathological: level 2 happens to be lower than level 1 (terrain).
|
||||||
|
parsed = %{
|
||||||
|
"HGT:1 hybrid level" => 50.0,
|
||||||
|
"TMP:1 hybrid level" => 290.0,
|
||||||
|
"HGT:2 hybrid level" => 30.0,
|
||||||
|
"TMP:2 hybrid level" => 292.0
|
||||||
|
}
|
||||||
|
|
||||||
|
profile = HrrrNativeClient.build_native_profile(parsed)
|
||||||
|
assert profile.heights_m == [30.0, 50.0]
|
||||||
|
assert profile.temp_k == [292.0, 290.0]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "falls back to lowest level for surface scalars when parsed has no surface entries" do
|
||||||
|
parsed = %{
|
||||||
|
"HGT:1 hybrid level" => 8.0,
|
||||||
|
"TMP:1 hybrid level" => 295.0,
|
||||||
|
"SPFH:1 hybrid level" => 0.010,
|
||||||
|
"PRES:1 hybrid level" => 101_000.0
|
||||||
|
}
|
||||||
|
|
||||||
|
profile = HrrrNativeClient.build_native_profile(parsed)
|
||||||
|
assert profile.surface_temp_k == 295.0
|
||||||
|
assert profile.surface_spfh == 0.010
|
||||||
|
assert profile.surface_pressure_pa == 101_000.0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "essential_byte_ranges/1" do
|
||||||
|
test "produces one range per essential message present in the idx" do
|
||||||
|
# Minimal fake idx: level 1 TMP/SPFH/HGT with nothing else.
|
||||||
|
idx_entries = [
|
||||||
|
%{msg: 1, offset: 0, var: "TMP", level: "1 hybrid level"},
|
||||||
|
%{msg: 2, offset: 1000, var: "SPFH", level: "1 hybrid level"},
|
||||||
|
%{msg: 3, offset: 2000, var: "HGT", level: "1 hybrid level"},
|
||||||
|
%{msg: 4, offset: 3000, var: "REFC", level: "entire atmosphere"}
|
||||||
|
]
|
||||||
|
|
||||||
|
ranges = HrrrNativeClient.essential_byte_ranges(idx_entries)
|
||||||
|
|
||||||
|
# Expect 3 ranges (the three essentials we actually have)
|
||||||
|
assert length(ranges) == 3
|
||||||
|
# Each range is {start, end} tuples, all integers
|
||||||
|
Enum.each(ranges, fn {s, e} ->
|
||||||
|
assert is_integer(s) and is_integer(e)
|
||||||
|
assert s < e
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "delegates to HrrrClient.byte_ranges_for_messages/2" do
|
||||||
|
# Just check the two stay in sync by computing the same ranges
|
||||||
|
# two different ways.
|
||||||
|
idx_entries = [
|
||||||
|
%{msg: 1, offset: 0, var: "TMP", level: "1 hybrid level"},
|
||||||
|
%{msg: 2, offset: 5000, var: "UGRD", level: "1 hybrid level"}
|
||||||
|
]
|
||||||
|
|
||||||
|
assert HrrrNativeClient.essential_byte_ranges(idx_entries) ==
|
||||||
|
HrrrClient.byte_ranges_for_messages(idx_entries, HrrrNativeClient.native_messages())
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
59
test/microwaveprop/weather/hrrr_native_profile_test.exs
Normal file
59
test/microwaveprop/weather/hrrr_native_profile_test.exs
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
defmodule Microwaveprop.Weather.HrrrNativeProfileTest do
|
||||||
|
use Microwaveprop.DataCase, async: true
|
||||||
|
|
||||||
|
alias Microwaveprop.Weather.HrrrNativeProfile
|
||||||
|
|
||||||
|
@valid_attrs %{
|
||||||
|
valid_time: ~U[2026-03-28 18:00:00Z],
|
||||||
|
run_time: ~U[2026-03-28 18:00:00Z],
|
||||||
|
lat: 32.9,
|
||||||
|
lon: -97.0,
|
||||||
|
level_count: 3,
|
||||||
|
heights_m: [10.0, 100.0, 500.0],
|
||||||
|
temp_k: [295.0, 292.0, 285.0],
|
||||||
|
spfh: [0.010, 0.008, 0.004],
|
||||||
|
pressure_pa: [101_000.0, 99_800.0, 95_000.0],
|
||||||
|
u_wind_ms: [2.0, 3.0, 4.0],
|
||||||
|
v_wind_ms: [1.0, 1.5, 2.0],
|
||||||
|
tke_m2s2: [0.5, 0.3, 0.1],
|
||||||
|
surface_temp_k: 295.0,
|
||||||
|
surface_spfh: 0.010,
|
||||||
|
surface_pressure_pa: 101_325.0
|
||||||
|
}
|
||||||
|
|
||||||
|
describe "changeset/2" do
|
||||||
|
test "accepts a full native profile" do
|
||||||
|
changeset = HrrrNativeProfile.changeset(%HrrrNativeProfile{}, @valid_attrs)
|
||||||
|
assert changeset.valid?
|
||||||
|
|
||||||
|
assert {:ok, profile} = Repo.insert(changeset)
|
||||||
|
assert profile.level_count == 3
|
||||||
|
assert profile.heights_m == [10.0, 100.0, 500.0]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "requires valid_time, lat, lon" do
|
||||||
|
changeset = HrrrNativeProfile.changeset(%HrrrNativeProfile{}, %{})
|
||||||
|
refute changeset.valid?
|
||||||
|
assert "can't be blank" in errors_on(changeset).valid_time
|
||||||
|
assert "can't be blank" in errors_on(changeset).lat
|
||||||
|
assert "can't be blank" in errors_on(changeset).lon
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects mismatched level array lengths" do
|
||||||
|
attrs = Map.put(@valid_attrs, :temp_k, [295.0, 292.0])
|
||||||
|
changeset = HrrrNativeProfile.changeset(%HrrrNativeProfile{}, attrs)
|
||||||
|
refute changeset.valid?
|
||||||
|
assert errors_on(changeset)[:temp_k] != nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "enforces (lat, lon, valid_time) uniqueness" do
|
||||||
|
{:ok, _} = %HrrrNativeProfile{} |> HrrrNativeProfile.changeset(@valid_attrs) |> Repo.insert()
|
||||||
|
|
||||||
|
{:error, changeset} =
|
||||||
|
%HrrrNativeProfile{} |> HrrrNativeProfile.changeset(@valid_attrs) |> Repo.insert()
|
||||||
|
|
||||||
|
refute changeset.valid?
|
||||||
|
assert "has already been taken" in errors_on(changeset).lat
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
78
test/microwaveprop/workers/hrrr_native_grid_worker_test.exs
Normal file
78
test/microwaveprop/workers/hrrr_native_grid_worker_test.exs
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
defmodule Microwaveprop.Workers.HrrrNativeGridWorkerTest do
|
||||||
|
use Microwaveprop.DataCase, async: true
|
||||||
|
|
||||||
|
alias Microwaveprop.Radio.Contact
|
||||||
|
alias Microwaveprop.Workers.HrrrNativeGridWorker
|
||||||
|
|
||||||
|
defp create_contact(attrs) do
|
||||||
|
default = %{
|
||||||
|
station1: "W5XD",
|
||||||
|
station2: "K5TR",
|
||||||
|
qso_timestamp: ~U[2026-03-28 18:00:00Z],
|
||||||
|
mode: "CW",
|
||||||
|
band: Decimal.new("10000"),
|
||||||
|
grid1: "EM12",
|
||||||
|
grid2: "EM00",
|
||||||
|
pos1: %{"lat" => 32.9, "lon" => -97.0},
|
||||||
|
pos2: %{"lat" => 30.3, "lon" => -97.7},
|
||||||
|
distance_km: Decimal.new("295")
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok, contact} =
|
||||||
|
%Contact{}
|
||||||
|
|> Contact.changeset(Map.merge(default, attrs))
|
||||||
|
|> Repo.insert()
|
||||||
|
|
||||||
|
contact
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "points_of_interest_for_hour/1" do
|
||||||
|
test "returns snapped distinct (lat, lon) pairs for contacts in the ±30 min window" do
|
||||||
|
create_contact(%{pos1: %{"lat" => 32.9, "lon" => -97.0}, qso_timestamp: ~U[2026-03-28 17:45:00Z]})
|
||||||
|
create_contact(%{pos1: %{"lat" => 33.0, "lon" => -97.1}, qso_timestamp: ~U[2026-03-28 18:15:00Z]})
|
||||||
|
|
||||||
|
points = HrrrNativeGridWorker.points_of_interest_for_hour(~U[2026-03-28 18:00:00Z])
|
||||||
|
|
||||||
|
assert length(points) == 2
|
||||||
|
assert {32.9, -97.0} in points
|
||||||
|
assert {33.0, -97.1} in points
|
||||||
|
end
|
||||||
|
|
||||||
|
test "excludes contacts outside the window" do
|
||||||
|
create_contact(%{qso_timestamp: ~U[2026-03-28 17:00:00Z]})
|
||||||
|
|
||||||
|
points = HrrrNativeGridWorker.points_of_interest_for_hour(~U[2026-03-28 19:00:00Z])
|
||||||
|
assert points == []
|
||||||
|
end
|
||||||
|
|
||||||
|
test "deduplicates coincident positions" do
|
||||||
|
create_contact(%{station1: "A", pos1: %{"lat" => 32.9, "lon" => -97.0}})
|
||||||
|
create_contact(%{station1: "B", pos1: %{"lat" => 32.9, "lon" => -97.0}})
|
||||||
|
|
||||||
|
points = HrrrNativeGridWorker.points_of_interest_for_hour(~U[2026-03-28 18:00:00Z])
|
||||||
|
assert points == [{32.9, -97.0}]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles contacts with pos1 stored as 'lng'" do
|
||||||
|
create_contact(%{pos1: %{"lat" => 32.9, "lng" => -97.0}})
|
||||||
|
|
||||||
|
points = HrrrNativeGridWorker.points_of_interest_for_hour(~U[2026-03-28 18:00:00Z])
|
||||||
|
assert points == [{32.9, -97.0}]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "perform/1" do
|
||||||
|
test "returns :ok without making any HTTP request when no contacts exist for the hour" do
|
||||||
|
# No contacts at all → no points → no download
|
||||||
|
job =
|
||||||
|
HrrrNativeGridWorker.new(%{
|
||||||
|
"year" => 2026,
|
||||||
|
"month" => 3,
|
||||||
|
"day" => 28,
|
||||||
|
"hour" => 18
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, %Oban.Job{args: _}} = Oban.insert(job)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Add table
Reference in a new issue