diff --git a/algo3.md b/docs/algo_commercial.md
similarity index 100%
rename from algo3.md
rename to docs/algo_commercial.md
diff --git a/algo1.md b/docs/algo_original.md
similarity index 100%
rename from algo1.md
rename to docs/algo_original.md
diff --git a/algo2.md b/docs/algo_updated_with_data.md
similarity index 100%
rename from algo2.md
rename to docs/algo_updated_with_data.md
diff --git a/docs/plans/2026-03-30-conus-propagation-map.md b/docs/plans/2026-03-30-conus-propagation-map.md
new file mode 100644
index 00000000..eed709ac
--- /dev/null
+++ b/docs/plans/2026-03-30-conus-propagation-map.md
@@ -0,0 +1,2042 @@
+# 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"""
+ CONUS Propagation Map
+
+
` +
+ `${this.scoreTier(score)}
` +
+ `${lat.toFixed(3)}°N, ${Math.abs(lon).toFixed(3)}°W`
+ )
+
+ 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 = `
+