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.
59 KiB
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
# 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.
# 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
# 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
# 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
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
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
# 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:
# 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
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
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
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
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
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
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
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_scorestable - Test:
test/microwaveprop/propagation/grid_test.exs
Step 1: Write failing test
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
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:
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:
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
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
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
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
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
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
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:
queues: [solar: 1, weather: 3, enqueue: 1, hrrr: 20, terrain: 4, commercial: 2, iemre: 5, propagation: 1],
Add cron entry:
{"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
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.
# 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
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/maproute)
Step 1: Vendor Leaflet
Download Leaflet 1.9.4 (latest stable) JS and CSS into assets/vendor/leaflet/:
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:
import L from "../vendor/leaflet/leaflet.js"
window.L = L
In assets/css/app.css, add:
@import "../vendor/leaflet/leaflet.css";
Step 3: Add route
In lib/microwaveprop_web/router.ex, inside the scope "/", MicrowavepropWeb block:
live "/map", MapLive
Step 4: Create MapLive
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:
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: "© 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
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.exor 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:
// 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:
<.link navigate={~p"/map"} class="btn btn-ghost btn-sm">Map</.link>
Step 3: Commit
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
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
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
git add -A
git commit -m "Fix formatting and credo issues"
Summary of Key Design Decisions
-
Algorithm is data-driven: All weights, thresholds, band configs, and seasonal tables live in
BandConfig. Updating the algorithm = editing one file. Scoring functions inScorerare generic readers of that config. -
HRRR download-once-extract-many: Single GRIB2 download per hour serves both the map grid and QSO enrichment.
Extractor.extract_grid/2replaces per-point extraction. -
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. -
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.
-
Future extensibility: Adding forecast hours = fetch HRRR f01-f18 products in the worker. Adding finer resolution = change
Grid.step. Adding new bands = add entry toBandConfig. Adding new scoring factors = add toBandConfig.weightsandScorer.