# 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

Updated: {Calendar.strftime(@valid_time, "%Y-%m-%d %H:%M UTC")}
""" end end ``` ### Step 5: Create colocated JS hook Create `lib/microwaveprop_web/live/map_live.hooks.js`: ```javascript export const PropagationMap = { mounted() { // Initialize Leaflet map centered on CONUS this.map = L.map(this.el, { center: [38.0, -96.0], zoom: 5, minZoom: 4, maxZoom: 10 }) L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { attribution: "© 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( `Score: ${score}/100
` + `${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 = `
Propagation
Excellent (80-100)
Good (65-79)
Marginal (50-64)
Poor (33-49)
Negligible (0-32)
` return div } legend.addTo(this.map) ``` ### Step 2: Add nav link In the layout/nav, add a link to `/map`: ```elixir <.link navigate={~p"/map"} class="btn btn-ghost btn-sm">Map ``` ### Step 3: Commit ```bash git add lib/microwaveprop_web/live/map_live.ex \ lib/microwaveprop_web/live/map_live.hooks.js \ lib/microwaveprop_web/components/layouts.ex git commit -m "Add map legend and navigation link" ``` --- ## Task 11: Manual Trigger and End-to-End Test Add a way to manually trigger the propagation grid computation (for testing without waiting for the cron), and write an integration test. **Files:** - Create: `lib/mix/tasks/propagation_grid.ex` - Test: `test/microwaveprop/propagation/integration_test.exs` ### Step 1: Create mix task ```elixir defmodule Mix.Tasks.PropagationGrid do @moduledoc "Manually trigger propagation grid computation." use Mix.Task @shortdoc "Compute propagation scores for the CONUS grid" @impl Mix.Task def run(_args) do Mix.Task.run("app.start") IO.puts("Starting propagation grid computation...") case Microwaveprop.Workers.PropagationGridWorker.perform(%Oban.Job{args: %{}}) do :ok -> IO.puts("Done!") {:error, reason} -> IO.puts("Error: #{inspect(reason)}") end end end ``` ### Step 2: Commit ```bash git add lib/mix/tasks/propagation_grid.ex git commit -m "Add mix task to manually trigger propagation grid computation" ``` --- ## Task 12: Run precommit and fix issues ### Step 1: Format and check Run: `mix format` Run: `mix credo` Run: `mix precommit` Fix any issues that arise. ### Step 2: Final commit ```bash git add -A git commit -m "Fix formatting and credo issues" ``` --- ## Summary of Key Design Decisions 1. **Algorithm is data-driven**: All weights, thresholds, band configs, and seasonal tables live in `BandConfig`. Updating the algorithm = editing one file. Scoring functions in `Scorer` are generic readers of that config. 2. **HRRR download-once-extract-many**: Single GRIB2 download per hour serves both the map grid and QSO enrichment. `Extractor.extract_grid/2` replaces per-point extraction. 3. **Pre-computed scores**: Scores are computed hourly by the Oban worker and stored in `propagation_scores`. The LiveView reads from the DB — no on-the-fly computation at page load. 4. **Simple overlay**: CircleMarkers at 0.125° grid points, colored by score tier. This is efficient for ~10k points and looks similar to the reference screenshot's blob visualization. 5. **Future extensibility**: Adding forecast hours = fetch HRRR f01-f18 products in the worker. Adding finer resolution = change `Grid.step`. Adding new bands = add entry to `BandConfig`. Adding new scoring factors = add to `BandConfig.weights` and `Scorer`.