prop/docs/plans/2026-03-30-conus-propagation-map.md
Graham McIntire a283ad9c66
Refactor HRRR fetch to batch points per hour
QSO enrichment now groups all path points by HRRR hour and creates
one batch job per hour instead of one job per point. The batch job
downloads the GRIB2 data once and extracts all needed points from
the same binary. Legacy single-point jobs are still supported for
backward compatibility.
2026-03-30 17:21:47 -05:00

2042 lines
59 KiB
Markdown

# CONUS Propagation Map Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Build a Leaflet-based CONUS propagation map at `/map` showing per-band microwave propagation conditions, updated hourly from HRRR data, with a band selector to compare conditions across frequencies.
**Architecture:** HRRR GRIB2 data is downloaded once per hour for the entire CONUS grid, then scored at ~10,000 points (0.125° resolution) across all bands. Scores are stored in a `propagation_scores` table and served to a Leaflet map via a LiveView at `/map`. The scoring algorithm is fully data-driven — weights, thresholds, and band configs are module attributes that can be updated without touching scoring logic.
**Tech Stack:** Phoenix 1.8 + LiveView, Leaflet.js (vendored), Oban workers, existing GRIB2 decoder, PostgreSQL
**Key Design Constraint:** The scoring algorithm will evolve over time. All scoring parameters (weights, thresholds, band configs, seasonal tables) are defined as data in a single config module (`Microwaveprop.Propagation.BandConfig`). Scoring functions are generic — they read thresholds from the config, not hardcoded values. This means tuning the algorithm is a config change, not a logic change.
---
## Task 1: HRRR Multi-Point GRIB2 Extraction
Refactor the GRIB2 extractor to extract many points from a single download instead of one point per download. This is the foundation — currently every HRRR fetch job downloads the same ~50MB of GRIB2 data to extract a single lat/lon.
**Files:**
- Modify: `lib/microwaveprop/weather/grib2/extractor.ex`
- Modify: `lib/microwaveprop/weather/grib2/simple_packing.ex`
- Modify: `lib/microwaveprop/weather/grib2/complex_packing.ex`
- Test: `test/microwaveprop/weather/grib2/extractor_test.exs`
### Step 1: Write failing test for multi-point extraction
```elixir
# test/microwaveprop/weather/grib2/extractor_test.exs
defmodule Microwaveprop.Weather.Grib2.ExtractorTest do
use ExUnit.Case, async: true
alias Microwaveprop.Weather.Grib2.Extractor
# Use the existing single-point test as baseline, then add multi-point
describe "extract_grid/2" do
test "extracts multiple points from a single GRIB2 binary" do
# We'll need a small fixture GRIB2 binary for this test.
# For now, test that the function exists and returns the right shape.
# A real integration test will use a downloaded HRRR fixture.
points = [{35.0, -97.0}, {36.0, -96.0}, {37.0, -95.0}]
# This will be an integration test using a real GRIB2 fixture.
# For unit testing, verify the batch extraction calls through to
# single-point extraction correctly.
assert is_function(&Extractor.extract_grid/2, 2)
end
end
end
```
### Step 2: Run test to verify it fails
Run: `mix test test/microwaveprop/weather/grib2/extractor_test.exs --trace`
Expected: Compilation error — `extract_grid/2` doesn't exist
### Step 3: Add `extract_values/3` to SimplePacking for batch index extraction
The key optimization: for simple packing, extracting N values is N independent bit reads from the same binary — no sequential dependency. For complex packing, `decode_all/2` already decodes the entire grid, so we just index into the result array at multiple positions.
```elixir
# In simple_packing.ex, add:
def extract_values(params, data, indices) do
%{
reference_value: r,
binary_scale: e,
decimal_scale: d,
bits_per_value: n,
num_data_points: num
} = params
factor_2e = :math.pow(2, e)
factor_10d = :math.pow(10, -d)
results =
Enum.reduce(indices, %{}, fn index, acc ->
if index >= 0 and index < num do
x = extract_bits(data, index, n)
value = (r + x * factor_2e) * factor_10d
Map.put(acc, index, value)
else
acc
end
end)
{:ok, results}
end
```
```elixir
# In complex_packing.ex, add:
def extract_values(params, data, indices) do
if Enum.any?(indices, &(&1 < 0 or &1 >= params.num_data_points)) do
{:error, :index_out_of_range}
else
case decode_all(params, data) do
{:ok, array} ->
results =
Enum.reduce(indices, %{}, fn index, acc ->
Map.put(acc, index, :array.get(index, array))
end)
{:ok, results}
{:error, _} = err ->
err
end
end
end
```
### Step 4: Implement `extract_grid/2` in Extractor
```elixir
# In extractor.ex, add:
@doc """
Extract weather values from a GRIB2 binary at multiple lat/lon points.
Returns `{:ok, %{{lat, lon} => %{"VAR:LEVEL" => float}}}` or `{:error, term}`.
"""
def extract_grid(binary, points) when is_list(points) do
messages = split_messages(binary)
Enum.reduce_while(messages, {:ok, %{}}, fn msg, {:ok, acc} ->
case extract_grid_single(msg, points) do
{:ok, key, point_values} ->
merged =
Enum.reduce(point_values, acc, fn {point, value}, a ->
existing = Map.get(a, point, %{})
Map.put(a, point, Map.put(existing, key, value))
end)
{:cont, {:ok, merged}}
{:error, :outside_grid} ->
{:halt, {:error, :outside_grid}}
{:error, reason} ->
{:halt, {:error, reason}}
end
end)
end
defp extract_grid_single(msg, points) do
with {:ok, parsed} <- Section.parse_message(msg),
%{grid_params: grid, product: prod, packing_params: packing, data: data} = parsed,
key = "#{prod.var}:#{prod.level}" do
# Convert all lat/lons to grid indices
indexed_points =
Enum.flat_map(points, fn {lat, lon} = point ->
case LambertConformal.to_grid_index(grid, lat, lon) do
{:ok, {i, j}} ->
index = linear_index({i, j}, grid.nx, grid.scan_mode)
[{point, index}]
{:error, :outside_grid} ->
[] # Skip points outside HRRR grid
end
end)
indices = Enum.map(indexed_points, &elem(&1, 1))
case unpack_values(packing, data, indices) do
{:ok, index_values} ->
point_values =
Enum.flat_map(indexed_points, fn {point, index} ->
case Map.get(index_values, index) do
nil -> []
value -> [{point, value}]
end
end)
{:ok, key, point_values}
{:error, _} = err ->
err
end
end
rescue
e -> {:error, "GRIB2 grid extraction failed: #{inspect(e)}"}
end
defp unpack_values(%{template: 3} = params, data, indices) do
ComplexPacking.extract_values(params, data, indices)
end
defp unpack_values(params, data, indices) do
SimplePacking.extract_values(params, data, indices)
end
```
### Step 5: Run tests and verify they pass
Run: `mix test test/microwaveprop/weather/grib2/extractor_test.exs --trace`
Expected: PASS
### Step 6: Commit
```bash
git add lib/microwaveprop/weather/grib2/extractor.ex \
lib/microwaveprop/weather/grib2/simple_packing.ex \
lib/microwaveprop/weather/grib2/complex_packing.ex \
test/microwaveprop/weather/grib2/extractor_test.exs
git commit -m "Add multi-point GRIB2 extraction for batch grid queries"
```
---
## Task 2: HRRR Batch Fetch Client
Add a function to HrrrClient that downloads one HRRR hour and extracts data at a list of points. Also add the additional GRIB2 fields needed for full scoring (wind, cloud cover, precip).
**Files:**
- Modify: `lib/microwaveprop/weather/hrrr_client.ex`
- Test: `test/microwaveprop/weather/hrrr_client_test.exs`
### Step 1: Write failing test
```elixir
defmodule Microwaveprop.Weather.HrrrClientTest do
use ExUnit.Case, async: true
alias Microwaveprop.Weather.HrrrClient
describe "additional surface messages" do
test "surface_messages/0 includes wind, cloud cover, and precip fields" do
messages = HrrrClient.surface_messages()
vars = Enum.map(messages, & &1.var)
assert "UGRD" in vars
assert "VGRD" in vars
assert "TCDC" in vars
assert "APCP" in vars
end
end
describe "fetch_grid/3" do
test "function exists with correct arity" do
assert is_function(&HrrrClient.fetch_grid/3, 3)
end
end
end
```
### Step 2: Run test to verify it fails
Run: `mix test test/microwaveprop/weather/hrrr_client_test.exs --trace`
Expected: FAIL
### Step 3: Add new GRIB2 fields and `fetch_grid/3`
```elixir
# In hrrr_client.ex, update @surface_messages to add wind/cloud/precip:
@surface_messages [
%{var: "TMP", level: "2 m above ground"},
%{var: "DPT", level: "2 m above ground"},
%{var: "PRES", level: "surface"},
%{var: "HPBL", level: "surface"},
%{var: "PWAT", level: "entire atmosphere (considered as a single layer)"},
%{var: "UGRD", level: "10 m above ground"},
%{var: "VGRD", level: "10 m above ground"},
%{var: "TCDC", level: "entire atmosphere"},
%{var: "APCP", level: "surface"}
]
# Add public accessor for tests:
def surface_messages, do: @surface_messages
# Add fetch_grid/3:
@doc """
Download one HRRR hour and extract data at multiple lat/lon points.
Returns `{:ok, %{{lat, lon} => profile_map}}` or `{:error, term}`.
"""
def fetch_grid(points, valid_time, opts \\ []) do
hour_dt = nearest_hrrr_hour(valid_time)
date = DateTime.to_date(hour_dt)
hour = hour_dt.hour
include_pressure = Keyword.get(opts, :include_pressure, true)
with {:ok, sfc_data} <- fetch_product_grid(date, hour, :surface, points),
{:ok, prs_data} <- maybe_fetch_pressure_grid(include_pressure, date, hour, points) do
merged = merge_grid_data(sfc_data, prs_data)
profiles = Map.new(merged, fn {point, data} ->
{point, Map.put(build_profile(data), :run_time, hour_dt)}
end)
{:ok, profiles}
end
end
defp maybe_fetch_pressure_grid(false, _date, _hour, _points), do: {:ok, %{}}
defp maybe_fetch_pressure_grid(true, date, hour, points) do
fetch_product_grid(date, hour, :pressure, points)
end
defp fetch_product_grid(date, hour, product, points) do
url = hrrr_url(date, hour, product)
idx_url = url <> ".idx"
wanted =
case product do
:surface -> @surface_messages
:pressure -> pressure_messages()
end
Logger.info("HRRR grid fetching #{product} idx from #{idx_url}")
with {:ok, idx_text} <- fetch_idx(idx_url),
idx_entries = parse_idx(idx_text),
ranges = byte_ranges_for_messages(idx_entries, wanted),
{:ok, grib_binary} <- download_grib_ranges(url, ranges) do
Extractor.extract_grid(grib_binary, points)
end
end
defp merge_grid_data(sfc, prs) do
all_points = MapSet.union(MapSet.new(Map.keys(sfc)), MapSet.new(Map.keys(prs)))
Map.new(all_points, fn point ->
sfc_data = Map.get(sfc, point, %{})
prs_data = Map.get(prs, point, %{})
{point, Map.merge(sfc_data, prs_data)}
end)
end
```
Also update `build_profile/1` to include the new fields:
```elixir
# Add to the result map in build_profile/1:
wind_u: parsed["UGRD:10 m above ground"],
wind_v: parsed["VGRD:10 m above ground"],
cloud_cover_pct: parsed["TCDC:entire atmosphere"],
precip_mm: parsed["APCP:surface"],
```
### Step 4: Run tests
Run: `mix test test/microwaveprop/weather/hrrr_client_test.exs --trace`
Expected: PASS
### Step 5: Commit
```bash
git add lib/microwaveprop/weather/hrrr_client.ex \
test/microwaveprop/weather/hrrr_client_test.exs
git commit -m "Add HRRR batch grid fetch with wind, cloud, and precip fields"
```
---
## Task 3: Band Configuration Module
Create the data-driven band configuration module. All scoring parameters live here — weights, thresholds, seasonal tables, band-specific coefficients. When the algorithm evolves, this is the only file that changes.
**Files:**
- Create: `lib/microwaveprop/propagation/band_config.ex`
- Test: `test/microwaveprop/propagation/band_config_test.exs`
### Step 1: Write failing test
```elixir
defmodule Microwaveprop.Propagation.BandConfigTest do
use ExUnit.Case, async: true
alias Microwaveprop.Propagation.BandConfig
describe "get/1" do
test "returns config for 10 GHz" do
config = BandConfig.get(10_000)
assert config.label == "10 GHz"
assert config.humidity_effect == :beneficial
assert config.humidity_penalty == 0.0
assert map_size(config.seasonal_base) == 12
end
test "returns config for 24 GHz" do
config = BandConfig.get(24_000)
assert config.label == "24 GHz"
assert config.humidity_effect == :harmful
assert config.humidity_penalty == 1.6
end
test "returns nil for unknown band" do
assert BandConfig.get(99_999) == nil
end
end
describe "all_bands/0" do
test "returns all 8 band configs" do
bands = BandConfig.all_bands()
assert length(bands) == 8
freqs = Enum.map(bands, & &1.freq_mhz)
assert 10_000 in freqs
assert 241_000 in freqs
end
end
describe "weights/0" do
test "weights sum to 1.0" do
weights = BandConfig.weights()
total = Enum.reduce(weights, 0.0, fn {_k, v}, acc -> acc + v end)
assert_in_delta total, 1.0, 0.001
end
test "includes all 9 scoring factors" do
weights = BandConfig.weights()
expected = ~w(humidity time_of_day td_depression refractivity sky season wind rain pressure)a
for key <- expected, do: assert(Map.has_key?(weights, key))
end
end
end
```
### Step 2: Run test to verify it fails
Run: `mix test test/microwaveprop/propagation/band_config_test.exs --trace`
Expected: FAIL — module doesn't exist
### Step 3: Implement BandConfig
```elixir
defmodule Microwaveprop.Propagation.BandConfig do
@moduledoc """
Band-specific configuration for microwave propagation scoring.
All scoring parameters — weights, thresholds, seasonal tables, and
band-specific coefficients — are defined here as data. The scoring
functions in `Scorer` are generic and read from these configs.
To tune the algorithm, update the values in this module. No scoring
logic needs to change.
"""
@weights %{
humidity: 0.20,
time_of_day: 0.20,
td_depression: 0.12,
refractivity: 0.10,
sky: 0.10,
season: 0.10,
wind: 0.06,
rain: 0.08,
pressure: 0.04
}
@sunrise_table [7.4, 7.3, 7.0, 6.7, 6.35, 6.25,
6.35, 6.65, 6.9, 7.1, 7.35, 7.45]
# Score tiers and their colors
@tiers [
%{min: 80, label: "EXCELLENT", color: "#00ffa3"},
%{min: 65, label: "GOOD", color: "#7dffd4"},
%{min: 50, label: "MARGINAL", color: "#ffe566"},
%{min: 33, label: "POOR", color: "#ff9044"},
%{min: 0, label: "NEGLIGIBLE", color: "#ff4f4f"}
]
# Humidity scoring thresholds for :beneficial bands (10 GHz)
# {max_humidity, score} — evaluated in order, first match wins
@humidity_beneficial_thresholds [
{4, 55}, {7, 70}, {10, 82}, {14, 90}, {18, 95}, {22, 88}
]
@humidity_beneficial_default 75
# Refractivity gradient thresholds
# {max_gradient, score_beneficial, score_harmful}
@refractivity_thresholds [
{-500, 98, 85},
{-300, 92, 78},
{-200, 80, 80},
{-100, 65, 65},
{-60, 55, 55}
]
@refractivity_default {42, 42}
# BL depth threshold for shallow BL bonus
@shallow_bl_threshold_m 300
@shallow_bl_score 82
@band_configs %{
10_000 => %{
freq_mhz: 10_000,
label: "10 GHz",
o2_db_km: 0.008,
h2o_coeff: 0.0005,
humidity_effect: :beneficial,
humidity_penalty: 0.0,
rain_k: 0.010, rain_alpha: 1.28,
seasonal_base: %{1 => 38, 2 => 32, 3 => 22, 4 => 55, 5 => 68,
6 => 90, 7 => 95, 8 => 75, 9 => 78, 10 => 82,
11 => 78, 12 => 25},
seasonal_adj: %{},
typical_range_km: 200,
extended_range_km: 500,
exceptional_range_km: 1000
},
24_000 => %{
freq_mhz: 24_000,
label: "24 GHz",
o2_db_km: 0.015,
h2o_coeff: 0.012,
humidity_effect: :harmful,
humidity_penalty: 1.6,
rain_k: 0.070, rain_alpha: 1.07,
seasonal_base: %{1 => 88, 2 => 84, 3 => 68, 4 => 62, 5 => 51,
6 => 34, 7 => 18, 8 => 18, 9 => 48, 10 => 68,
11 => 96, 12 => 88},
seasonal_adj: %{5 => -4, 6 => -8, 7 => -10, 8 => -10, 9 => -4},
typical_range_km: 100,
extended_range_km: 250,
exceptional_range_km: 500
},
47_000 => %{
freq_mhz: 47_000,
label: "47 GHz",
o2_db_km: 0.045,
h2o_coeff: 0.003,
humidity_effect: :harmful,
humidity_penalty: 1.0,
rain_k: 0.187, rain_alpha: 0.93,
seasonal_base: %{1 => 90, 2 => 88, 3 => 78, 4 => 68, 5 => 55,
6 => 38, 7 => 22, 8 => 22, 9 => 48, 10 => 74,
11 => 96, 12 => 90},
seasonal_adj: %{},
typical_range_km: 70,
extended_range_km: 150,
exceptional_range_km: 300
},
68_000 => %{
freq_mhz: 68_000,
label: "68 GHz",
o2_db_km: 0.90,
h2o_coeff: 0.007,
humidity_effect: :harmful,
humidity_penalty: 1.4,
rain_k: 0.310, rain_alpha: 0.86,
seasonal_base: %{1 => 90, 2 => 88, 3 => 78, 4 => 65, 5 => 50,
6 => 32, 7 => 18, 8 => 18, 9 => 44, 10 => 70,
11 => 92, 12 => 90},
seasonal_adj: %{},
typical_range_km: 40,
extended_range_km: 80,
exceptional_range_km: 150
},
75_000 => %{
freq_mhz: 75_000,
label: "75 GHz",
o2_db_km: 0.012,
h2o_coeff: 0.006,
humidity_effect: :harmful,
humidity_penalty: 1.2,
rain_k: 0.345, rain_alpha: 0.84,
seasonal_base: %{1 => 90, 2 => 90, 3 => 80, 4 => 68, 5 => 55,
6 => 38, 7 => 22, 8 => 22, 9 => 48, 10 => 74,
11 => 96, 12 => 90},
seasonal_adj: %{},
typical_range_km: 50,
extended_range_km: 120,
exceptional_range_km: 250
},
122_000 => %{
freq_mhz: 122_000,
label: "122 GHz",
o2_db_km: 0.80,
h2o_coeff: 0.010,
humidity_effect: :harmful,
humidity_penalty: 1.0,
rain_k: 0.498, rain_alpha: 0.77,
seasonal_base: %{1 => 92, 2 => 90, 3 => 78, 4 => 62, 5 => 45,
6 => 28, 7 => 15, 8 => 15, 9 => 38, 10 => 68,
11 => 92, 12 => 92},
seasonal_adj: %{},
typical_range_km: 30,
extended_range_km: 80,
exceptional_range_km: 140
},
134_000 => %{
freq_mhz: 134_000,
label: "134 GHz",
o2_db_km: 0.08,
h2o_coeff: 0.015,
humidity_effect: :harmful,
humidity_penalty: 1.3,
rain_k: 0.520, rain_alpha: 0.75,
seasonal_base: %{1 => 92, 2 => 90, 3 => 78, 4 => 65, 5 => 48,
6 => 30, 7 => 18, 8 => 18, 9 => 42, 10 => 70,
11 => 92, 12 => 92},
seasonal_adj: %{},
typical_range_km: 40,
extended_range_km: 100,
exceptional_range_km: 160
},
241_000 => %{
freq_mhz: 241_000,
label: "241 GHz",
o2_db_km: 0.08,
h2o_coeff: 0.30,
humidity_effect: :harmful,
humidity_penalty: 3.0,
rain_k: 0.550, rain_alpha: 0.70,
seasonal_base: %{1 => 95, 2 => 92, 3 => 75, 4 => 55, 5 => 35,
6 => 15, 7 => 8, 8 => 8, 9 => 30, 10 => 65,
11 => 95, 12 => 95},
seasonal_adj: %{},
typical_range_km: 10,
extended_range_km: 50,
exceptional_range_km: 115
}
}
def get(freq_mhz), do: Map.get(@band_configs, freq_mhz)
def all_bands do
@band_configs
|> Map.values()
|> Enum.sort_by(& &1.freq_mhz)
end
def all_freqs, do: @band_configs |> Map.keys() |> Enum.sort()
def weights, do: @weights
def sunrise_table, do: @sunrise_table
def tiers, do: @tiers
def humidity_beneficial_thresholds, do: @humidity_beneficial_thresholds
def humidity_beneficial_default, do: @humidity_beneficial_default
def refractivity_thresholds, do: @refractivity_thresholds
def refractivity_default, do: @refractivity_default
def shallow_bl_threshold_m, do: @shallow_bl_threshold_m
def shallow_bl_score, do: @shallow_bl_score
end
```
### Step 4: Run tests
Run: `mix test test/microwaveprop/propagation/band_config_test.exs --trace`
Expected: PASS
### Step 5: Commit
```bash
git add lib/microwaveprop/propagation/band_config.ex \
test/microwaveprop/propagation/band_config_test.exs
git commit -m "Add data-driven band configuration module for propagation scoring"
```
---
## Task 4: Scoring Algorithm Module
Implement the 9 scoring functions + composite score. All functions read thresholds from BandConfig — no hardcoded magic numbers in the scoring logic itself. This makes the algorithm tunable by editing BandConfig only.
**Files:**
- Create: `lib/microwaveprop/propagation/scorer.ex`
- Test: `test/microwaveprop/propagation/scorer_test.exs`
### Step 1: Write failing tests
```elixir
defmodule Microwaveprop.Propagation.ScorerTest do
use ExUnit.Case, async: true
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.Scorer
describe "score_humidity/2" do
test "10 GHz — high humidity is beneficial" do
config = BandConfig.get(10_000)
score = Scorer.score_humidity(15.0, config)
assert score >= 90
end
test "10 GHz — low humidity scores lower" do
config = BandConfig.get(10_000)
score = Scorer.score_humidity(3.0, config)
assert score <= 60
end
test "24 GHz — high humidity is harmful" do
config = BandConfig.get(24_000)
high = Scorer.score_humidity(15.0, config)
low = Scorer.score_humidity(3.0, config)
assert low > high
end
end
describe "score_time_of_day/3" do
test "dawn scores highest" do
# January, 7 AM UTC = ~1 AM CST... let's use 13 UTC = 7 AM CST (near sunrise)
{score, _label} = Scorer.score_time_of_day(13, 0, 1)
assert score == 100
end
test "afternoon scores lowest" do
# January, 20 UTC = 2 PM CST
{score, _label} = Scorer.score_time_of_day(20, 0, 1)
assert score <= 40
end
end
describe "score_td_depression/3" do
test "10 GHz — moderate depression scores well" do
config = BandConfig.get(10_000)
score = Scorer.score_td_depression(70.0, 60.0, config)
assert score >= 75
end
test "24 GHz — large depression (dry) scores well" do
config = BandConfig.get(24_000)
score = Scorer.score_td_depression(80.0, 55.0, config)
assert score >= 80
end
end
describe "score_refractivity/3" do
test "strong ducting gradient scores high for 10 GHz" do
config = BandConfig.get(10_000)
score = Scorer.score_refractivity(-550.0, nil, config)
assert score >= 95
end
test "nil gradient returns neutral score" do
config = BandConfig.get(10_000)
score = Scorer.score_refractivity(nil, nil, config)
assert score == 50
end
end
describe "score_sky/1" do
test "clear sky scores 100" do
assert Scorer.score_sky(0.0) == 100
end
test "overcast scores low" do
assert Scorer.score_sky(95.0) <= 10
end
end
describe "score_season/2" do
test "10 GHz — July scores highest" do
config = BandConfig.get(10_000)
assert Scorer.score_season(7, config) == 95
end
test "24 GHz — November scores highest" do
config = BandConfig.get(24_000)
assert Scorer.score_season(11, config) == 96
end
end
describe "score_wind/1" do
test "calm wind scores 100" do
assert Scorer.score_wind(2.0) == 100
end
test "strong wind scores low" do
assert Scorer.score_wind(30.0) <= 20
end
end
describe "score_rain/2" do
test "no rain scores 100" do
config = BandConfig.get(24_000)
assert Scorer.score_rain(0.0, config) == 100
end
test "heavy rain at 24 GHz scores very low" do
config = BandConfig.get(24_000)
score = Scorer.score_rain(25.0, config)
assert score <= 25
end
end
describe "score_pressure/2" do
test "rising pressure scores well" do
score = Scorer.score_pressure(1018.0, 1015.0)
assert score >= 70
end
test "nil previous gives absolute-only scoring" do
score = Scorer.score_pressure(1018.0, nil)
assert score >= 50
end
end
describe "composite_score/2" do
test "returns score 0-100 and factor breakdown" do
config = BandConfig.get(10_000)
conditions = %{
abs_humidity: 12.0,
temp_f: 75.0,
dewpoint_f: 65.0,
wind_speed_kts: 5.0,
sky_cover_pct: 10.0,
utc_hour: 13,
utc_minute: 0,
month: 7,
pressure_mb: 1015.0,
prev_pressure_mb: nil,
rain_rate_mmhr: 0.0,
min_refractivity_gradient: -350.0,
bl_depth_m: nil
}
result = Scorer.composite_score(conditions, config)
assert result.score >= 0 and result.score <= 100
assert map_size(result.factors) == 9
end
end
end
```
### Step 2: Run test to verify it fails
Run: `mix test test/microwaveprop/propagation/scorer_test.exs --trace`
Expected: FAIL — module doesn't exist
### Step 3: Implement Scorer
```elixir
defmodule Microwaveprop.Propagation.Scorer do
@moduledoc """
Propagation scoring functions for microwave bands.
All scoring thresholds and parameters are read from `BandConfig`.
To tune the algorithm, update BandConfig — no changes needed here.
"""
alias Microwaveprop.Propagation.BandConfig
# --- Individual scoring functions ---
def score_humidity(abs_humidity_gm3, band_config) do
case band_config.humidity_effect do
:beneficial ->
BandConfig.humidity_beneficial_thresholds()
|> Enum.find_value(fn {threshold, score} ->
if abs_humidity_gm3 < threshold, do: score
end) || BandConfig.humidity_beneficial_default()
:harmful ->
r = abs_humidity_gm3 * band_config.humidity_penalty
cond do
r <= 6 -> 100
r <= 9 -> round(95 - (r - 6) / 3 * 20)
r <= 13 -> round(75 - (r - 9) / 4 * 30)
r <= 18 -> round(45 - (r - 13) / 5 * 35)
true -> max(0, round(10 - (r - 18) * 2))
end
end
end
def score_time_of_day(utc_hour, utc_minute, month) do
sunrise_table = BandConfig.sunrise_table()
offset = if month >= 3 and month <= 10, do: -5, else: -6
local = :math.fmod(utc_hour + utc_minute / 60 + offset + 24, 24)
sunrise = Enum.at(sunrise_table, month - 1)
d = local - sunrise
cond do
d >= -1.5 and d <= 1.5 -> {100, "Peak — inversion maximum"}
d > 1.5 and d <= 3.0 -> {78, "Good — inversion eroding"}
d > -3.0 and d < -1.5 -> {82, "Pre-dawn — inversion building"}
d > 3.0 and d <= 6.0 -> {38, "Marginal — boundary layer mixing"}
local >= 20.0 or local <= 1.0 -> {72, "Evening — cooling, inversion reforming"}
d > 6.0 -> {18, "Afternoon — full convective mixing"}
true -> {55, "Night — gradual cooling"}
end
end
def score_td_depression(temp_f, dewpoint_f, band_config) do
dep = temp_f - dewpoint_f
case band_config.humidity_effect do
:beneficial ->
cond do
dep < 3 -> 40
dep < 8 -> 75
dep < 14 -> 85
dep < 22 -> 70
true -> 55
end
:harmful ->
cond do
dep > 22 -> 96
dep > 14 -> 80
dep > 8 -> 60
dep > 4 -> 38
true -> 18
end
end
end
def score_refractivity(nil, _bl_depth_m, _band_config), do: 50
def score_refractivity(min_gradient, bl_depth_m, band_config) do
thresholds = BandConfig.refractivity_thresholds()
{default_beneficial, default_harmful} = BandConfig.refractivity_default()
found =
Enum.find_value(thresholds, fn {max_grad, score_b, score_h} ->
if min_gradient < max_grad do
case band_config.humidity_effect do
:beneficial -> score_b
:harmful -> score_h
end
end
end)
cond do
found != nil ->
found
bl_depth_m != nil and bl_depth_m < BandConfig.shallow_bl_threshold_m() ->
BandConfig.shallow_bl_score()
true ->
case band_config.humidity_effect do
:beneficial -> default_beneficial
:harmful -> default_harmful
end
end
end
def score_sky(pct) when is_number(pct) do
cond do
pct <= 6 -> 100
pct <= 25 -> 88
pct <= 50 -> 60
pct <= 87 -> 25
true -> 5
end
end
def score_sky(nil), do: 50
def score_season(month, band_config) do
base = Map.get(band_config.seasonal_base, month, 50)
adj = Map.get(band_config.seasonal_adj, month, 0)
max(0, min(100, base + adj))
end
def score_wind(speed_kts) when is_number(speed_kts) do
cond do
speed_kts < 5 -> 100
speed_kts < 10 -> 90
speed_kts < 15 -> 75
speed_kts < 20 -> 55
speed_kts < 25 -> 35
true -> 15
end
end
def score_wind(nil), do: 50
def score_rain(nil, _band_config), do: 100
def score_rain(rate, _band_config) when rate == 0, do: 100
def score_rain(rate, band_config) do
gamma = band_config.rain_k * :math.pow(rate, band_config.rain_alpha)
cond do
gamma < 0.1 -> 95
gamma < 0.5 -> 75
gamma < 1.0 -> 50
gamma < 2.0 -> 25
gamma < 5.0 -> 10
true -> 0
end
end
def score_pressure(current_mb, nil) do
cond do
current_mb > 1025 -> 55
current_mb > 1018 -> 65
current_mb > 1010 -> 60
current_mb > 1005 -> 55
true -> 40
end
end
def score_pressure(current_mb, previous_mb) do
delta = current_mb - previous_mb
cond do
delta > 2.5 -> 80
delta > 0.8 -> 70
delta > -0.5 -> 60
delta > -2.0 -> 65
true -> 45
end
end
# --- Composite ---
def composite_score(conditions, band_config) do
weights = BandConfig.weights()
{tod_score, _label} = score_time_of_day(
conditions.utc_hour, conditions.utc_minute, conditions.month
)
factors = %{
humidity: score_humidity(conditions.abs_humidity, band_config),
time_of_day: tod_score,
td_depression: score_td_depression(
conditions.temp_f, conditions.dewpoint_f, band_config
),
refractivity: score_refractivity(
conditions.min_refractivity_gradient,
conditions.bl_depth_m,
band_config
),
sky: score_sky(conditions.sky_cover_pct),
season: score_season(conditions.month, band_config),
wind: score_wind(conditions.wind_speed_kts),
rain: score_rain(conditions.rain_rate_mmhr, band_config),
pressure: score_pressure(
conditions.pressure_mb, conditions.prev_pressure_mb
)
}
score =
factors
|> Enum.reduce(0.0, fn {key, value}, acc ->
acc + value * Map.fetch!(weights, key)
end)
|> round()
|> max(0)
|> min(100)
%{score: score, factors: factors}
end
# --- Helpers ---
@doc "Compute absolute humidity (g/m³) from temp (°C) and dewpoint (°C)."
def absolute_humidity(temp_c, dewpoint_c) do
e_s = 6.112 * :math.exp(17.67 * dewpoint_c / (dewpoint_c + 243.5))
t_k = temp_c + 273.15
217.0 * e_s / t_k
end
@doc "Wind speed (kts) from U and V components (m/s)."
def wind_speed_kts(u_ms, v_ms) when is_number(u_ms) and is_number(v_ms) do
:math.sqrt(u_ms * u_ms + v_ms * v_ms) * 1.94384
end
def wind_speed_kts(_, _), do: nil
@doc "Convert precip accumulation (mm) to approximate rate (mm/hr)."
def precip_to_rate_mmhr(nil), do: 0.0
def precip_to_rate_mmhr(mm) when mm <= 0, do: 0.0
def precip_to_rate_mmhr(mm), do: mm
@doc "Convert temp F to C."
def f_to_c(nil), do: nil
def f_to_c(f), do: (f - 32) * 5 / 9
@doc "Convert temp C to F."
def c_to_f(nil), do: nil
def c_to_f(c), do: c * 9 / 5 + 32
end
```
### Step 4: Run tests
Run: `mix test test/microwaveprop/propagation/scorer_test.exs --trace`
Expected: PASS
### Step 5: Commit
```bash
git add lib/microwaveprop/propagation/scorer.ex \
test/microwaveprop/propagation/scorer_test.exs
git commit -m "Implement propagation scoring algorithm with data-driven thresholds"
```
---
## Task 5: CONUS Grid Definition and Score Schema
Define the CONUS grid (0.125° resolution) and the database schema to store computed scores.
**Files:**
- Create: `lib/microwaveprop/propagation/grid.ex`
- Create: `lib/microwaveprop/propagation/grid_score.ex`
- Create migration for `propagation_scores` table
- Test: `test/microwaveprop/propagation/grid_test.exs`
### Step 1: Write failing test
```elixir
defmodule Microwaveprop.Propagation.GridTest do
use ExUnit.Case, async: true
alias Microwaveprop.Propagation.Grid
describe "conus_points/0" do
test "generates grid at 0.125° resolution" do
points = Grid.conus_points()
assert length(points) > 5000
assert length(points) < 15000
# All points within CONUS bounds
Enum.each(points, fn {lat, lon} ->
assert lat >= 25.0 and lat <= 50.0
assert lon >= -125.0 and lon <= -66.0
end)
end
test "points are on 0.125° grid" do
[{lat, lon} | _] = Grid.conus_points()
assert Float.round(lat * 8, 0) == lat * 8
assert Float.round(lon * 8, 0) == lon * 8
end
end
end
```
### Step 2: Run test to verify it fails
Run: `mix test test/microwaveprop/propagation/grid_test.exs --trace`
Expected: FAIL
### Step 3: Implement Grid module
```elixir
defmodule Microwaveprop.Propagation.Grid do
@moduledoc """
CONUS grid definition for propagation scoring.
0.125° resolution (~14 km), covering 25-50°N, 125-66°W.
"""
@lat_min 25.0
@lat_max 50.0
@lon_min -125.0
@lon_max -66.0
@step 0.125
def conus_points do
for lat <- float_range(@lat_min, @lat_max, @step),
lon <- float_range(@lon_min, @lon_max, @step) do
{Float.round(lat, 3), Float.round(lon, 3)}
end
end
def step, do: @step
def bounds, do: %{lat_min: @lat_min, lat_max: @lat_max, lon_min: @lon_min, lon_max: @lon_max}
defp float_range(start, stop, step) do
count = round((stop - start) / step) + 1
Enum.map(0..(count - 1), fn i -> start + i * step end)
end
end
```
### Step 4: Generate migration and create GridScore schema
Run: `mix ecto.gen.migration create_propagation_scores`
Then populate the migration:
```elixir
defmodule Microwaveprop.Repo.Migrations.CreatePropagationScores do
use Ecto.Migration
def change do
create table(:propagation_scores, primary_key: false) do
add :id, :binary_id, primary_key: true
add :lat, :float, null: false
add :lon, :float, null: false
add :valid_time, :utc_datetime, null: false
add :band_mhz, :integer, null: false
add :score, :integer, null: false
add :factors, :map, null: false
timestamps(type: :utc_datetime)
end
create unique_index(:propagation_scores, [:lat, :lon, :valid_time, :band_mhz])
create index(:propagation_scores, [:valid_time])
create index(:propagation_scores, [:band_mhz, :valid_time])
end
end
```
Schema:
```elixir
defmodule Microwaveprop.Propagation.GridScore do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
schema "propagation_scores" do
field :lat, :float
field :lon, :float
field :valid_time, :utc_datetime
field :band_mhz, :integer
field :score, :integer
field :factors, :map
timestamps(type: :utc_datetime)
end
def changeset(grid_score, attrs) do
grid_score
|> cast(attrs, [:lat, :lon, :valid_time, :band_mhz, :score, :factors])
|> validate_required([:lat, :lon, :valid_time, :band_mhz, :score, :factors])
end
end
```
### Step 5: Run migration and tests
Run: `mix ecto.migrate && mix test test/microwaveprop/propagation/grid_test.exs --trace`
Expected: PASS
### Step 6: Commit
```bash
git add lib/microwaveprop/propagation/grid.ex \
lib/microwaveprop/propagation/grid_score.ex \
priv/repo/migrations/*_create_propagation_scores.exs \
test/microwaveprop/propagation/grid_test.exs
git commit -m "Add CONUS grid definition and propagation_scores schema"
```
---
## Task 6: Grid Score Computation Context
Create the context module that orchestrates: take HRRR data for a grid of points → derive atmospheric params → score all bands → upsert results.
**Files:**
- Create: `lib/microwaveprop/propagation.ex`
- Test: `test/microwaveprop/propagation_test.exs`
### Step 1: Write failing test
```elixir
defmodule Microwaveprop.PropagationTest do
use Microwaveprop.DataCase
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.GridScore
describe "score_grid_point/3" do
test "scores a single point for all bands" do
hrrr_profile = %{
surface_temp_c: 25.0,
surface_dewpoint_c: 18.0,
surface_pressure_mb: 1013.0,
hpbl_m: 500.0,
wind_u: 3.0,
wind_v: 2.0,
cloud_cover_pct: 15.0,
precip_mm: 0.0,
profile: [
%{"pres" => 1000.0, "tmpc" => 25.0, "dwpc" => 18.0, "hght" => 100.0},
%{"pres" => 975.0, "tmpc" => 22.0, "dwpc" => 15.0, "hght" => 350.0},
%{"pres" => 950.0, "tmpc" => 19.0, "dwpc" => 10.0, "hght" => 600.0}
]
}
valid_time = ~U[2026-07-15 13:00:00Z]
results = Propagation.score_grid_point(hrrr_profile, valid_time)
assert length(results) == 8
Enum.each(results, fn result ->
assert result.score >= 0 and result.score <= 100
assert map_size(result.factors) == 9
end)
end
end
describe "upsert_scores/1" do
test "inserts and upserts grid scores" do
valid_time = ~U[2026-07-15 13:00:00Z]
scores = [
%{lat: 35.0, lon: -97.0, valid_time: valid_time, band_mhz: 10_000,
score: 75, factors: %{humidity: 90, time_of_day: 100}},
%{lat: 35.0, lon: -97.0, valid_time: valid_time, band_mhz: 24_000,
score: 60, factors: %{humidity: 40, time_of_day: 100}}
]
assert {:ok, 2} = Propagation.upsert_scores(scores)
# Upsert same point — should update, not duplicate
updated = [
%{lat: 35.0, lon: -97.0, valid_time: valid_time, band_mhz: 10_000,
score: 80, factors: %{humidity: 95, time_of_day: 100}}
]
assert {:ok, 1} = Propagation.upsert_scores(updated)
assert Repo.aggregate(GridScore, :count) == 2
end
end
describe "latest_scores/1" do
test "returns latest scores for a band" do
valid_time = ~U[2026-07-15 13:00:00Z]
scores = [
%{lat: 35.0, lon: -97.0, valid_time: valid_time, band_mhz: 10_000,
score: 75, factors: %{}},
%{lat: 36.0, lon: -96.0, valid_time: valid_time, band_mhz: 10_000,
score: 80, factors: %{}}
]
Propagation.upsert_scores(scores)
results = Propagation.latest_scores(10_000)
assert length(results) == 2
end
end
end
```
### Step 2: Run test to verify it fails
Run: `mix test test/microwaveprop/propagation_test.exs --trace`
Expected: FAIL
### Step 3: Implement Propagation context
```elixir
defmodule Microwaveprop.Propagation do
@moduledoc false
import Ecto.Query
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.GridScore
alias Microwaveprop.Propagation.Scorer
alias Microwaveprop.Repo
alias Microwaveprop.Weather.SoundingParams
@doc """
Score a single grid point across all bands using HRRR profile data.
Returns a list of %{band_mhz, score, factors} maps.
"""
def score_grid_point(hrrr_profile, valid_time) do
# Derive refractivity gradient from profile
derived = derive_from_hrrr(hrrr_profile)
# Build conditions map from HRRR data
temp_c = hrrr_profile.surface_temp_c
dewpoint_c = hrrr_profile.surface_dewpoint_c
temp_f = Scorer.c_to_f(temp_c)
dewpoint_f = Scorer.c_to_f(dewpoint_c)
conditions = %{
abs_humidity: Scorer.absolute_humidity(temp_c, dewpoint_c),
temp_f: temp_f,
dewpoint_f: dewpoint_f,
wind_speed_kts: Scorer.wind_speed_kts(
hrrr_profile[:wind_u], hrrr_profile[:wind_v]
),
sky_cover_pct: hrrr_profile[:cloud_cover_pct],
utc_hour: valid_time.hour,
utc_minute: valid_time.minute,
month: valid_time.month,
pressure_mb: hrrr_profile.surface_pressure_mb,
prev_pressure_mb: nil,
rain_rate_mmhr: Scorer.precip_to_rate_mmhr(hrrr_profile[:precip_mm]),
min_refractivity_gradient: derived[:min_refractivity_gradient],
bl_depth_m: hrrr_profile[:hpbl_m]
}
Enum.map(BandConfig.all_bands(), fn band_config ->
result = Scorer.composite_score(conditions, band_config)
Map.put(result, :band_mhz, band_config.freq_mhz)
end)
end
@doc "Upsert propagation scores in batches."
def upsert_scores(scores) do
now = DateTime.utc_now() |> DateTime.truncate(:second)
entries =
Enum.map(scores, fn s ->
%{
id: Ecto.UUID.generate(),
lat: s.lat,
lon: s.lon,
valid_time: s.valid_time,
band_mhz: s.band_mhz,
score: s.score,
factors: s.factors,
inserted_at: now,
updated_at: now
}
end)
total =
entries
|> Enum.chunk_every(500)
|> Enum.reduce(0, fn chunk, acc ->
{count, _} =
Repo.insert_all(GridScore, chunk,
on_conflict: {:replace, [:score, :factors, :updated_at]},
conflict_target: [:lat, :lon, :valid_time, :band_mhz]
)
acc + count
end)
{:ok, total}
end
@doc "Get the latest scores for a band."
def latest_scores(band_mhz) do
latest_time_query =
from(gs in GridScore,
where: gs.band_mhz == ^band_mhz,
select: max(gs.valid_time)
)
case Repo.one(latest_time_query) do
nil ->
[]
latest_time ->
from(gs in GridScore,
where: gs.band_mhz == ^band_mhz and gs.valid_time == ^latest_time,
select: %{lat: gs.lat, lon: gs.lon, score: gs.score}
)
|> Repo.all()
end
end
@doc "Get the latest valid_time across all scores."
def latest_valid_time do
Repo.one(from gs in GridScore, select: max(gs.valid_time))
end
# Derive refractivity gradient from HRRR profile using SoundingParams
defp derive_from_hrrr(%{profile: profile}) when is_list(profile) and length(profile) >= 3 do
case SoundingParams.derive(profile) do
nil -> %{}
derived -> %{min_refractivity_gradient: derived.min_refractivity_gradient}
end
end
defp derive_from_hrrr(_), do: %{}
end
```
### Step 4: Run tests
Run: `mix test test/microwaveprop/propagation_test.exs --trace`
Expected: PASS
### Step 5: Commit
```bash
git add lib/microwaveprop/propagation.ex \
test/microwaveprop/propagation_test.exs
git commit -m "Add propagation context for grid scoring and score persistence"
```
---
## Task 7: Hourly Grid Worker
Oban worker that runs hourly: downloads HRRR, extracts CONUS grid, scores all bands, upserts results.
**Files:**
- Create: `lib/microwaveprop/workers/propagation_grid_worker.ex`
- Modify: `config/config.exs` (add cron entry + queue)
- Test: `test/microwaveprop/workers/propagation_grid_worker_test.exs`
### Step 1: Write failing test
```elixir
defmodule Microwaveprop.Workers.PropagationGridWorkerTest do
use Microwaveprop.DataCase
alias Microwaveprop.Workers.PropagationGridWorker
describe "new/1" do
test "creates a valid Oban job" do
job = PropagationGridWorker.new(%{})
assert job.args == %{}
end
end
# Integration tests would require mocking HRRR S3 — skip for unit tests.
# The worker delegates to tested modules (HrrrClient.fetch_grid, Propagation.score_grid_point).
end
```
### Step 2: Implement worker
```elixir
defmodule Microwaveprop.Workers.PropagationGridWorker do
@moduledoc """
Hourly Oban worker that downloads the latest HRRR data and computes
propagation scores across the CONUS grid for all bands.
Downloads GRIB2 data once per hour, extracts at ~10,000 grid points,
scores all 8 bands at each point, and upserts results.
"""
use Oban.Worker,
queue: :propagation,
max_attempts: 3,
unique: [period: 3600, states: [:available, :scheduled, :executing]]
require Logger
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.Grid
alias Microwaveprop.Weather.HrrrClient
@impl Oban.Worker
def perform(%Oban.Job{}) do
valid_time = HrrrClient.nearest_hrrr_hour(DateTime.utc_now())
points = Grid.conus_points()
Logger.info("PropagationGrid: fetching HRRR for #{valid_time}, #{length(points)} points")
with {:ok, grid_data} <- HrrrClient.fetch_grid(points, valid_time) do
scores =
grid_data
|> Task.async_stream(
fn {{lat, lon}, profile} ->
band_scores = Propagation.score_grid_point(profile, valid_time)
Enum.map(band_scores, fn result ->
%{
lat: lat,
lon: lon,
valid_time: valid_time,
band_mhz: result.band_mhz,
score: result.score,
factors: result.factors
}
end)
end,
max_concurrency: System.schedulers_online() * 2,
timeout: 30_000
)
|> Enum.flat_map(fn
{:ok, results} -> results
{:exit, _reason} -> []
end)
Logger.info("PropagationGrid: computed #{length(scores)} scores, upserting")
case Propagation.upsert_scores(scores) do
{:ok, count} ->
Logger.info("PropagationGrid: upserted #{count} scores for #{valid_time}")
:ok
error ->
Logger.error("PropagationGrid: upsert failed: #{inspect(error)}")
error
end
end
end
end
```
### Step 3: Update config.exs — add queue and cron
In `config/config.exs`, update the Oban config:
Add `:propagation` to the queues:
```elixir
queues: [solar: 1, weather: 3, enqueue: 1, hrrr: 20, terrain: 4, commercial: 2, iemre: 5, propagation: 1],
```
Add cron entry:
```elixir
{"5 * * * *", Microwaveprop.Workers.PropagationGridWorker}
```
(Runs at :05 past each hour, giving HRRR ~5 min to publish)
### Step 4: Run tests
Run: `mix test test/microwaveprop/workers/propagation_grid_worker_test.exs --trace`
Expected: PASS
### Step 5: Commit
```bash
git add lib/microwaveprop/workers/propagation_grid_worker.ex \
config/config.exs \
test/microwaveprop/workers/propagation_grid_worker_test.exs
git commit -m "Add hourly propagation grid worker with Oban cron scheduling"
```
---
## Task 8: Refactor Existing HRRR Fetch to Use Batch
Update the existing `HrrrFetchWorker` to leverage the new batch extraction when multiple QSO points need the same HRRR hour. This deduplicates downloads.
**Files:**
- Modify: `lib/microwaveprop/workers/hrrr_fetch_worker.ex`
- Modify: `lib/microwaveprop/workers/qso_weather_enqueue_worker.ex`
- Test: existing tests should still pass
### Step 1: Read and understand the existing workers
Read: `lib/microwaveprop/workers/hrrr_fetch_worker.ex` and `lib/microwaveprop/workers/qso_weather_enqueue_worker.ex`
### Step 2: Refactor approach
The key change: instead of one Oban job per (lat, lon, hour), group QSO points by HRRR hour and create one job per hour that extracts all points for that hour.
Update `QsoWeatherEnqueueWorker` to group HRRR points by hour before enqueuing. The new `HrrrFetchWorker` accepts a list of points instead of a single point.
**Note:** Keep backward compatibility — if `args` contains a single `lat`/`lon`, treat it as a single-point fetch. If `args` contains `points` list, use batch fetch.
```elixir
# In HrrrFetchWorker, update perform/1:
def perform(%Oban.Job{args: %{"points" => points, "valid_time" => vt_str} = _args}) do
# Batch mode: fetch once, extract many
valid_time = DateTime.from_iso8601(vt_str) |> elem(1)
point_tuples = Enum.map(points, fn %{"lat" => lat, "lon" => lon} -> {lat, lon} end)
case HrrrClient.fetch_grid(point_tuples, valid_time) do
{:ok, grid_data} ->
# Store each point as an hrrr_profile
Enum.each(grid_data, fn {{lat, lon}, profile} ->
store_hrrr_profile(lat, lon, valid_time, profile)
end)
:ok
{:error, reason} ->
{:error, reason}
end
end
# Keep existing single-point perform clause for backward compat
def perform(%Oban.Job{args: %{"lat" => lat, "lon" => lon, "valid_time" => vt_str}}) do
# Legacy single-point mode — delegates to batch with one point
# ... existing logic unchanged ...
end
```
### Step 3: Run all existing tests
Run: `mix test --trace`
Expected: All pass (backward compatible)
### Step 4: Commit
```bash
git add lib/microwaveprop/workers/hrrr_fetch_worker.ex \
lib/microwaveprop/workers/qso_weather_enqueue_worker.ex
git commit -m "Refactor HRRR fetch worker to batch points per hour, deduplicating downloads"
```
---
## Task 9: Leaflet Integration + Map LiveView
Add Leaflet to the project and build the `/map` LiveView with band selector and color-coded score overlay.
**Files:**
- Create: `assets/vendor/leaflet/` (vendored Leaflet JS + CSS)
- Modify: `assets/js/app.js` (import Leaflet)
- Modify: `assets/css/app.css` (import Leaflet CSS)
- Create: `lib/microwaveprop_web/live/map_live.ex`
- Create: `lib/microwaveprop_web/live/map_live.hooks.js`
- Modify: `lib/microwaveprop_web/router.ex` (add `/map` route)
### Step 1: Vendor Leaflet
Download Leaflet 1.9.4 (latest stable) JS and CSS into `assets/vendor/leaflet/`:
```bash
mkdir -p assets/vendor/leaflet
curl -sL https://unpkg.com/leaflet@1.9.4/dist/leaflet.js -o assets/vendor/leaflet/leaflet.js
curl -sL https://unpkg.com/leaflet@1.9.4/dist/leaflet.css -o assets/vendor/leaflet/leaflet.css
# Also need the images directory for markers
mkdir -p assets/vendor/leaflet/images
curl -sL https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png -o assets/vendor/leaflet/images/marker-icon.png
curl -sL https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png -o assets/vendor/leaflet/images/marker-shadow.png
```
### Step 2: Import in app.js and app.css
In `assets/js/app.js`, add:
```javascript
import L from "../vendor/leaflet/leaflet.js"
window.L = L
```
In `assets/css/app.css`, add:
```css
@import "../vendor/leaflet/leaflet.css";
```
### Step 3: Add route
In `lib/microwaveprop_web/router.ex`, inside the `scope "/", MicrowavepropWeb` block:
```elixir
live "/map", MapLive
```
### Step 4: Create MapLive
```elixir
defmodule MicrowavepropWeb.MapLive do
use MicrowavepropWeb, :live_view
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.BandConfig
@default_band 10_000
@impl true
def mount(_params, _session, socket) do
bands = BandConfig.all_bands()
scores = Propagation.latest_scores(@default_band)
valid_time = Propagation.latest_valid_time()
socket =
socket
|> assign(:bands, bands)
|> assign(:selected_band, @default_band)
|> assign(:scores, scores)
|> assign(:valid_time, valid_time)
|> assign(:page_title, "Propagation Map")
if connected?(socket) do
# Refresh every 5 minutes to pick up new hourly data
Process.send_after(self(), :refresh, :timer.minutes(5))
end
{:ok, socket}
end
@impl true
def handle_event("select_band", %{"band" => band_str}, socket) do
band = String.to_integer(band_str)
scores = Propagation.latest_scores(band)
socket =
socket
|> assign(:selected_band, band)
|> assign(:scores, scores)
|> push_event("update_scores", %{scores: scores})
{:noreply, socket}
end
@impl true
def handle_info(:refresh, socket) do
scores = Propagation.latest_scores(socket.assigns.selected_band)
valid_time = Propagation.latest_valid_time()
Process.send_after(self(), :refresh, :timer.minutes(5))
socket =
socket
|> assign(:scores, scores)
|> assign(:valid_time, valid_time)
|> push_event("update_scores", %{scores: scores})
{:noreply, socket}
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<div class="flex flex-col h-[calc(100vh-4rem)]">
<div class="flex items-center gap-4 p-3 bg-base-200 rounded-t-lg">
<h2 class="text-lg font-bold">CONUS Propagation Map</h2>
<div class="flex gap-1">
<button
:for={band <- @bands}
phx-click="select_band"
value={band.freq_mhz}
class={[
"btn btn-sm",
if(@selected_band == band.freq_mhz, do: "btn-primary", else: "btn-ghost")
]}
>
{band.label}
</button>
</div>
<div :if={@valid_time} class="ml-auto text-sm text-base-content/60">
Updated: {Calendar.strftime(@valid_time, "%Y-%m-%d %H:%M UTC")}
</div>
</div>
<div
id="propagation-map"
phx-hook="PropagationMap"
phx-update="ignore"
data-scores={Jason.encode!(@scores)}
class="flex-1 rounded-b-lg z-0"
>
</div>
</div>
</Layouts.app>
"""
end
end
```
### Step 5: Create colocated JS hook
Create `lib/microwaveprop_web/live/map_live.hooks.js`:
```javascript
export const PropagationMap = {
mounted() {
// Initialize Leaflet map centered on CONUS
this.map = L.map(this.el, {
center: [38.0, -96.0],
zoom: 5,
minZoom: 4,
maxZoom: 10
})
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: "&copy; OpenStreetMap contributors",
maxZoom: 19
}).addTo(this.map)
// Score layer
this.scoreLayer = L.layerGroup().addTo(this.map)
// Color scale
this.colorScale = [
{ min: 80, color: "#00ffa3" }, // EXCELLENT
{ min: 65, color: "#7dffd4" }, // GOOD
{ min: 50, color: "#ffe566" }, // MARGINAL
{ min: 33, color: "#ff9044" }, // POOR
{ min: 0, color: "#ff4f4f" } // NEGLIGIBLE
]
// Load initial scores from data attribute
const initialScores = JSON.parse(this.el.dataset.scores || "[]")
this.renderScores(initialScores)
// Listen for LiveView score updates
this.handleEvent("update_scores", ({ scores }) => {
this.renderScores(scores)
})
},
renderScores(scores) {
this.scoreLayer.clearLayers()
scores.forEach(({ lat, lon, score }) => {
const color = this.scoreColor(score)
const circle = L.circleMarker([lat, lon], {
radius: 6,
fillColor: color,
fillOpacity: 0.7,
color: color,
weight: 0,
opacity: 0.7
})
circle.bindPopup(
`<strong>Score: ${score}/100</strong><br>` +
`${this.scoreTier(score)}<br>` +
`<small>${lat.toFixed(3)}°N, ${Math.abs(lon).toFixed(3)}°W</small>`
)
this.scoreLayer.addLayer(circle)
})
},
scoreColor(score) {
for (const tier of this.colorScale) {
if (score >= tier.min) return tier.color
}
return "#ff4f4f"
},
scoreTier(score) {
if (score >= 80) return "EXCELLENT"
if (score >= 65) return "GOOD"
if (score >= 50) return "MARGINAL"
if (score >= 33) return "POOR"
return "NEGLIGIBLE"
},
destroyed() {
if (this.map) {
this.map.remove()
}
}
}
```
### Step 6: Run dev server and verify
Run: `mix phx.server`
Visit: `http://localhost:4000/map`
Expected: Leaflet map loads, band selector buttons render, map shows score overlay (empty until worker runs)
### Step 7: Commit
```bash
git add assets/vendor/leaflet/ \
assets/js/app.js \
assets/css/app.css \
lib/microwaveprop_web/live/map_live.ex \
lib/microwaveprop_web/live/map_live.hooks.js \
lib/microwaveprop_web/router.ex
git commit -m "Add Leaflet-based CONUS propagation map at /map with band selector"
```
---
## Task 10: Add Legend and Polish
Add a color legend overlay to the map and a link in the navigation.
**Files:**
- Modify: `lib/microwaveprop_web/live/map_live.ex` (add legend markup)
- Modify: `lib/microwaveprop_web/live/map_live.hooks.js` (add Leaflet legend control)
- Modify: `lib/microwaveprop_web/components/layouts.ex` or nav template (add /map link)
### Step 1: Add legend to the JS hook
In the `mounted()` function of `map_live.hooks.js`, add after the map initialization:
```javascript
// Add legend control
const legend = L.control({ position: "bottomright" })
legend.onAdd = () => {
const div = L.DomUtil.create("div", "leaflet-legend")
div.innerHTML = `
<div style="background: white; padding: 8px 12px; border-radius: 6px; box-shadow: 0 2px 6px rgba(0,0,0,0.3); font-size: 12px; line-height: 1.6;">
<strong>Propagation</strong><br>
<span style="background:#00ffa3;width:14px;height:14px;display:inline-block;border-radius:50%;vertical-align:middle;margin-right:4px;"></span> Excellent (80-100)<br>
<span style="background:#7dffd4;width:14px;height:14px;display:inline-block;border-radius:50%;vertical-align:middle;margin-right:4px;"></span> Good (65-79)<br>
<span style="background:#ffe566;width:14px;height:14px;display:inline-block;border-radius:50%;vertical-align:middle;margin-right:4px;"></span> Marginal (50-64)<br>
<span style="background:#ff9044;width:14px;height:14px;display:inline-block;border-radius:50%;vertical-align:middle;margin-right:4px;"></span> Poor (33-49)<br>
<span style="background:#ff4f4f;width:14px;height:14px;display:inline-block;border-radius:50%;vertical-align:middle;margin-right:4px;"></span> Negligible (0-32)
</div>
`
return div
}
legend.addTo(this.map)
```
### Step 2: Add nav link
In the layout/nav, add a link to `/map`:
```elixir
<.link navigate={~p"/map"} class="btn btn-ghost btn-sm">Map</.link>
```
### Step 3: Commit
```bash
git add lib/microwaveprop_web/live/map_live.ex \
lib/microwaveprop_web/live/map_live.hooks.js \
lib/microwaveprop_web/components/layouts.ex
git commit -m "Add map legend and navigation link"
```
---
## Task 11: Manual Trigger and End-to-End Test
Add a way to manually trigger the propagation grid computation (for testing without waiting for the cron), and write an integration test.
**Files:**
- Create: `lib/mix/tasks/propagation_grid.ex`
- Test: `test/microwaveprop/propagation/integration_test.exs`
### Step 1: Create mix task
```elixir
defmodule Mix.Tasks.PropagationGrid do
@moduledoc "Manually trigger propagation grid computation."
use Mix.Task
@shortdoc "Compute propagation scores for the CONUS grid"
@impl Mix.Task
def run(_args) do
Mix.Task.run("app.start")
IO.puts("Starting propagation grid computation...")
case Microwaveprop.Workers.PropagationGridWorker.perform(%Oban.Job{args: %{}}) do
:ok -> IO.puts("Done!")
{:error, reason} -> IO.puts("Error: #{inspect(reason)}")
end
end
end
```
### Step 2: Commit
```bash
git add lib/mix/tasks/propagation_grid.ex
git commit -m "Add mix task to manually trigger propagation grid computation"
```
---
## Task 12: Run precommit and fix issues
### Step 1: Format and check
Run: `mix format`
Run: `mix credo`
Run: `mix precommit`
Fix any issues that arise.
### Step 2: Final commit
```bash
git add -A
git commit -m "Fix formatting and credo issues"
```
---
## Summary of Key Design Decisions
1. **Algorithm is data-driven**: All weights, thresholds, band configs, and seasonal tables live in `BandConfig`. Updating the algorithm = editing one file. Scoring functions in `Scorer` are generic readers of that config.
2. **HRRR download-once-extract-many**: Single GRIB2 download per hour serves both the map grid and QSO enrichment. `Extractor.extract_grid/2` replaces per-point extraction.
3. **Pre-computed scores**: Scores are computed hourly by the Oban worker and stored in `propagation_scores`. The LiveView reads from the DB — no on-the-fly computation at page load.
4. **Simple overlay**: CircleMarkers at 0.125° grid points, colored by score tier. This is efficient for ~10k points and looks similar to the reference screenshot's blob visualization.
5. **Future extensibility**: Adding forecast hours = fetch HRRR f01-f18 products in the worker. Adding finer resolution = change `Grid.step`. Adding new bands = add entry to `BandConfig`. Adding new scoring factors = add to `BandConfig.weights` and `Scorer`.