prop/notebooks/algorithm_analysis.livemd
Graham McIntire 8a969e315c
refactor: normalize pos1/pos2 JSONB key to 'lon' everywhere
57,186 prod contacts stored pos1/pos2 with 'lng'; 1,133 used 'lon'.
Every Elixir caller carried a `pos["lon"] || pos["lng"]` fallback
— which just caused a SQL widget to silently miscount 98% of contacts
(count_narr_done used `pos1->>'lon'` directly, no fallback, so every
lng-keyed row returned NULL and failed the coverage check).

- Migration rewrites every pos1/pos2 JSONB in place, renaming 'lng' to
  'lon' and dropping 'lng'.
- Removes all 20+ `|| pos["lng"]` fallbacks across lib/, workers,
  scorer, weather, radio.ex, contact show view, and recalibrator.
- lib_ml/propagation_analyze.ex SQL now reads pos1->>'lon' directly
  (was reading 'lng' only, which would have broken after migration).
- priv/repo/import_contacts.exs one-time seed script now emits 'lon'
  with string keys, matching production shape.
- Test fixtures in 4 test files normalized to 'lon'.
- Two lng-characterization tests deleted — nonsensical post-normalize.
- Updated notebook + old import_weather script to match.
- JS hook contact_map_hook.ts TypeScript type narrowed to 'lon'.
2026-04-17 09:10:32 -05:00

15 KiB

Propagation Algorithm Analysis

Setup — Attach to Running Node

Start your dev server with a node name:

iex --sname prop -S mix phx.server

Then in Livebook, use Runtime > Configure > Attached node and connect to prop@<hostname>.

# Verify we have access to the app
alias Microwaveprop.Repo
alias Microwaveprop.Radio
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Weather
alias Microwaveprop.Weather.HrrrProfile
alias Microwaveprop.Terrain
alias Microwaveprop.Terrain.TerrainAnalysis
alias Microwaveprop.Propagation.Scorer
alias Microwaveprop.Propagation.BandConfig

import Ecto.Query

Repo.aggregate(Contact, :count) |> then(&"Connected — #{&1} contacts in DB")

1. Contact Overview

band_summary =
  Contact
  |> where([c], c.distance_km < 3000)
  |> group_by([c], c.band)
  |> select([c], %{
    band_mhz: c.band,
    count: count(),
    avg_dist_km: type(avg(c.distance_km), :float),
    max_dist_km: type(max(c.distance_km), :float)
  })
  |> order_by([c], c.band)
  |> Repo.all()

Kino.DataTable.new(band_summary, name: "Contacts by Band")
# Contacts by month
monthly =
  Contact
  |> where([c], c.distance_km < 3000)
  |> group_by([c], fragment("EXTRACT(MONTH FROM ?)", c.qso_timestamp))
  |> select([c], %{
    month: fragment("EXTRACT(MONTH FROM ?)::integer", c.qso_timestamp),
    count: count()
  })
  |> order_by([c], fragment("1"))
  |> Repo.all()

VegaLite.new(width: 600, height: 300, title: "Tropo Contacts by Month")
|> VegaLite.data_from_values(monthly)
|> VegaLite.mark(:bar)
|> VegaLite.encode_field(:x, "month", type: :ordinal)
|> VegaLite.encode_field(:y, "count", type: :quantitative)
# Contacts by hour
hourly =
  Contact
  |> where([c], c.distance_km < 3000)
  |> group_by([c], fragment("EXTRACT(HOUR FROM ?)", c.qso_timestamp))
  |> select([c], %{
    utc_hour: fragment("EXTRACT(HOUR FROM ?)::integer", c.qso_timestamp),
    count: count()
  })
  |> order_by([c], fragment("1"))
  |> Repo.all()

VegaLite.new(width: 600, height: 300, title: "Tropo Contacts by UTC Hour")
|> VegaLite.data_from_values(hourly)
|> VegaLite.mark(:bar)
|> VegaLite.encode_field(:x, "utc_hour", type: :ordinal)
|> VegaLite.encode_field(:y, "count", type: :quantitative)

2. HRRR Conditions at Contact Time

# Load tropo contacts with HRRR data via the app's existing lookup
contacts =
  Contact
  |> where([c], c.distance_km < 3000 and not is_nil(c.pos1))
  |> order_by([c], desc: c.qso_timestamp)
  |> Repo.all()

contact_hrrr =
  contacts
  |> Task.async_stream(
    fn contact ->
      hrrr = Weather.hrrr_for_contact(contact)
      if hrrr, do: {contact, hrrr}, else: nil
    end,
    max_concurrency: 8,
    timeout: 10_000
  )
  |> Enum.flat_map(fn
    {:ok, nil} -> []
    {:ok, pair} -> [pair]
    _ -> []
  end)

"#{length(contact_hrrr)} contacts matched with HRRR data (of #{length(contacts)} tropo contacts)"
# Build analysis rows
analysis_rows =
  Enum.map(contact_hrrr, fn {contact, hrrr} ->
    %{
      id: contact.id,
      band_mhz: Decimal.to_integer(contact.band),
      distance_km: Decimal.to_float(contact.distance_km),
      month: contact.qso_timestamp.month,
      utc_hour: contact.qso_timestamp.hour,
      temp_c: hrrr.surface_temp,
      dewpoint_c: hrrr.surface_dewpoint,
      td_depression_c: if(hrrr.surface_temp && hrrr.surface_dewpoint,
        do: hrrr.surface_temp - hrrr.surface_dewpoint, else: nil),
      pressure_mb: hrrr.surface_pressure,
      surface_refractivity: hrrr.surface_refractivity,
      min_refractivity_gradient: hrrr.min_refractivity_gradient,
      bl_depth_m: hrrr.hpbl_m,
      pwat_mm: hrrr.pwat_mm,
      ducting_detected: hrrr.ducting_detected
    }
  end)

Kino.DataTable.new(analysis_rows, name: "Contacts + HRRR")

3. Factor Distributions

VegaLite.new(width: 600, height: 300, title: "Refractivity Gradient at Contact Time")
|> VegaLite.data_from_values(analysis_rows)
|> VegaLite.mark(:bar)
|> VegaLite.encode_field(:x, "min_refractivity_gradient",
  type: :quantitative, bin: %{step: 20}, title: "Min Refractivity Gradient (N-units/km)"
)
|> VegaLite.encode(:y, aggregate: :count, type: :quantitative)
VegaLite.new(width: 600, height: 300, title: "Td Depression at Contact Time")
|> VegaLite.data_from_values(analysis_rows)
|> VegaLite.mark(:bar)
|> VegaLite.encode_field(:x, "td_depression_c",
  type: :quantitative, bin: %{step: 1}, title: "T - Td (C)"
)
|> VegaLite.encode(:y, aggregate: :count, type: :quantitative)
VegaLite.new(width: 600, height: 300, title: "Boundary Layer Depth at Contact Time")
|> VegaLite.data_from_values(analysis_rows)
|> VegaLite.mark(:bar)
|> VegaLite.encode_field(:x, "bl_depth_m",
  type: :quantitative, bin: %{step: 100}, title: "BL Depth (m)"
)
|> VegaLite.encode(:y, aggregate: :count, type: :quantitative)
VegaLite.new(width: 600, height: 300, title: "Precipitable Water at Contact Time")
|> VegaLite.data_from_values(analysis_rows)
|> VegaLite.mark(:bar)
|> VegaLite.encode_field(:x, "pwat_mm",
  type: :quantitative, bin: %{step: 3}, title: "PWAT (mm)"
)
|> VegaLite.encode(:y, aggregate: :count, type: :quantitative)

4. Ducting Analysis

ducting_monthly =
  analysis_rows
  |> Enum.group_by(& &1.month)
  |> Enum.map(fn {month, rows} ->
    total = length(rows)
    ducting = Enum.count(rows, & &1.ducting_detected)
    %{month: month, total: total, ducting: ducting, ducting_pct: Float.round(100.0 * ducting / max(total, 1), 1)}
  end)
  |> Enum.sort_by(& &1.month)

VegaLite.new(width: 600, height: 300, title: "Ducting Detection Rate by Month (at contact time)")
|> VegaLite.data_from_values(ducting_monthly)
|> VegaLite.mark(:bar)
|> VegaLite.encode_field(:x, "month", type: :ordinal)
|> VegaLite.encode_field(:y, "ducting_pct", type: :quantitative, title: "% Ducting")

5. Distance vs Atmospheric Conditions

VegaLite.new(width: 600, height: 400, title: "Distance vs Refractivity Gradient")
|> VegaLite.data_from_values(analysis_rows)
|> VegaLite.mark(:circle, opacity: 0.3, size: 15)
|> VegaLite.encode_field(:x, "min_refractivity_gradient",
  type: :quantitative, title: "Min Refractivity Gradient"
)
|> VegaLite.encode_field(:y, "distance_km", type: :quantitative, title: "Distance (km)")
|> VegaLite.encode_field(:color, "band_mhz", type: :nominal, title: "Band (MHz)")
VegaLite.new(width: 600, height: 400, title: "Distance vs Td Depression")
|> VegaLite.data_from_values(analysis_rows)
|> VegaLite.mark(:circle, opacity: 0.3, size: 15)
|> VegaLite.encode_field(:x, "td_depression_c", type: :quantitative, title: "T - Td (C)")
|> VegaLite.encode_field(:y, "distance_km", type: :quantitative, title: "Distance (km)")
|> VegaLite.encode_field(:color, "band_mhz", type: :nominal, title: "Band (MHz)")

6. Terrain Impact

terrain_stats =
  Repo.all(
    from tp in "terrain_profiles",
      join: c in Contact, on: c.id == tp.contact_id,
      where: c.distance_km < 3000,
      group_by: tp.verdict,
      select: %{
        verdict: tp.verdict,
        count: count(),
        avg_dist_km: type(avg(c.distance_km), :float),
        avg_diffraction_db: type(avg(tp.diffraction_db), :float),
        avg_clearance_m: type(avg(tp.min_clearance_m), :float)
      },
      order_by: [desc: count()]
  )

Kino.DataTable.new(terrain_stats, name: "Terrain Verdicts")
terrain_scatter =
  Repo.all(
    from tp in "terrain_profiles",
      join: c in Contact, on: c.id == tp.contact_id,
      where: c.distance_km < 3000,
      select: %{
        band_mhz: type(c.band, :integer),
        distance_km: type(c.distance_km, :float),
        diffraction_db: type(tp.diffraction_db, :float),
        verdict: tp.verdict
      }
  )

VegaLite.new(width: 600, height: 400, title: "Diffraction Loss vs Distance")
|> VegaLite.data_from_values(terrain_scatter)
|> VegaLite.mark(:circle, opacity: 0.2, size: 10)
|> VegaLite.encode_field(:x, "distance_km", type: :quantitative, title: "Distance (km)")
|> VegaLite.encode_field(:y, "diffraction_db", type: :quantitative, title: "Diffraction Loss (dB)")
|> VegaLite.encode_field(:color, "verdict", type: :nominal)
commercial =
  Repo.all(
    from cs in "commercial_samples",
      join: cl in "commercial_links", on: cl.id == cs.link_id,
      where: not is_nil(cs.rx_power_0),
      select: %{
        name: cl.name,
        frequency_ghz: cl.frequency_ghz,
        sampled_at: cs.sampled_at,
        utc_hour: fragment("EXTRACT(HOUR FROM ?)::integer", cs.sampled_at),
        rx_power_dbm: type(cs.rx_power_0, :float)
      },
      order_by: cs.sampled_at
  )

VegaLite.new(width: 600, height: 300, title: "Commercial Link Signal by Hour of Day")
|> VegaLite.data_from_values(commercial)
|> VegaLite.mark(:line, point: true)
|> VegaLite.encode_field(:x, "utc_hour", type: :ordinal)
|> VegaLite.encode_field(:y, "rx_power_dbm",
  type: :quantitative, aggregate: :mean, title: "Mean RX Power (dBm)"
)
|> VegaLite.encode_field(:color, "name", type: :nominal)

8. Score Historical Contacts with the Real Scorer

# Score every contact using the actual Scorer.composite_score/2
scored =
  contact_hrrr
  |> Task.async_stream(
    fn {contact, hrrr} ->
      band_mhz = Decimal.to_integer(contact.band)
      band_config = BandConfig.for_band(band_mhz)

      temp_c = hrrr.surface_temp
      dewpoint_c = hrrr.surface_dewpoint
      temp_f = Scorer.c_to_f(temp_c)
      dewpoint_f = Scorer.c_to_f(dewpoint_c)

      abs_hum =
        if temp_c && dewpoint_c,
          do: Scorer.absolute_humidity(temp_c, dewpoint_c),
          else: 10.0

      conditions = %{
        abs_humidity: abs_hum,
        utc_hour: contact.qso_timestamp.hour,
        utc_minute: contact.qso_timestamp.minute,
        month: contact.qso_timestamp.month,
        longitude: contact.pos1["lon"] || -97.0,
        temp_f: temp_f,
        dewpoint_f: dewpoint_f,
        min_refractivity_gradient: hrrr.min_refractivity_gradient,
        bl_depth_m: hrrr.hpbl_m,
        sky_cover_pct: nil,
        wind_speed_kts: nil,
        rain_rate_mmhr: 0.0,
        pwat_mm: hrrr.pwat_mm,
        pressure_mb: hrrr.surface_pressure,
        prev_pressure_mb: nil
      }

      %{score: score, factors: factors} = Scorer.composite_score(conditions, band_config)

      %{
        id: contact.id,
        band_mhz: band_mhz,
        distance_km: Decimal.to_float(contact.distance_km),
        month: contact.qso_timestamp.month,
        utc_hour: contact.qso_timestamp.hour,
        score: score,
        humidity: factors.humidity,
        time_of_day: factors.time_of_day,
        td_depression: factors.td_depression,
        refractivity: factors.refractivity,
        season: factors.season,
        wind: factors.wind,
        rain: factors.rain,
        pwat: factors.pwat,
        pressure: factors.pressure
      }
    end,
    max_concurrency: System.schedulers_online(),
    timeout: 5_000
  )
  |> Enum.flat_map(fn
    {:ok, row} -> [row]
    _ -> []
  end)

"Scored #{length(scored)} contacts"
# Score distribution
VegaLite.new(width: 600, height: 300, title: "Composite Score Distribution (at contact time)")
|> VegaLite.data_from_values(scored)
|> VegaLite.mark(:bar)
|> VegaLite.encode_field(:x, "score", type: :quantitative, bin: %{step: 5}, title: "Score")
|> VegaLite.encode(:y, aggregate: :count, type: :quantitative)
# Score vs distance — does a higher score predict longer contacts?
VegaLite.new(width: 600, height: 400, title: "Score vs Distance Achieved")
|> VegaLite.data_from_values(scored)
|> VegaLite.mark(:circle, opacity: 0.3, size: 15)
|> VegaLite.encode_field(:x, "score", type: :quantitative, title: "Composite Score")
|> VegaLite.encode_field(:y, "distance_km", type: :quantitative, title: "Distance (km)")
|> VegaLite.encode_field(:color, "band_mhz", type: :nominal, title: "Band (MHz)")
# Score by band — box plot
VegaLite.new(width: 600, height: 300, title: "Score Distribution by Band")
|> VegaLite.data_from_values(scored)
|> VegaLite.mark(:boxplot)
|> VegaLite.encode_field(:x, "band_mhz", type: :nominal, title: "Band (MHz)")
|> VegaLite.encode_field(:y, "score", type: :quantitative, title: "Composite Score")
# Factor contribution heatmap — which factors drive the score?
factor_avgs =
  [:humidity, :time_of_day, :td_depression, :refractivity, :season, :wind, :rain, :pwat, :pressure]
  |> Enum.flat_map(fn factor ->
    scored
    |> Enum.group_by(& &1.band_mhz)
    |> Enum.map(fn {band, rows} ->
      avg = Enum.map(rows, &Map.get(&1, factor)) |> Enum.sum() |> then(&(&1 / length(rows)))
      %{factor: Atom.to_string(factor), band_mhz: band, avg_score: Float.round(avg, 1)}
    end)
  end)

VegaLite.new(width: 600, height: 300, title: "Average Factor Scores by Band")
|> VegaLite.data_from_values(factor_avgs)
|> VegaLite.mark(:rect)
|> VegaLite.encode_field(:x, "band_mhz", type: :nominal, title: "Band (MHz)")
|> VegaLite.encode_field(:y, "factor", type: :nominal, title: "Factor")
|> VegaLite.encode_field(:color, "avg_score", type: :quantitative,
  scale: %{scheme: "redyellowgreen", domain: [0, 100]}, title: "Avg Score"
)

9. Weight Sensitivity Analysis

Test different weight configurations against the scored dataset.

weights = BandConfig.weights()

# Recompute composite with custom weights
rescore = fn rows, custom_weights ->
  Enum.map(rows, fn row ->
    score =
      Enum.reduce(custom_weights, 0.0, fn {factor, weight}, acc ->
        acc + Map.get(row, factor, 50) * weight
      end)
      |> round()

    Map.put(row, :custom_score, score)
  end)
end

# Define alternative weight sets
alternatives = %{
  current: weights,

  refractivity_heavy: %{
    humidity: 0.15, time_of_day: 0.08, td_depression: 0.10,
    refractivity: 0.18, sky: 0.06, season: 0.08,
    wind: 0.04, rain: 0.06, pressure: 0.12, pwat: 0.13
  },

  pressure_reduced: %{
    humidity: 0.20, time_of_day: 0.12, td_depression: 0.12,
    refractivity: 0.10, sky: 0.08, season: 0.08,
    wind: 0.05, rain: 0.08, pressure: 0.07, pwat: 0.10
  }
}

# Compare: correlation of score with distance for each weight set
comparisons =
  Enum.map(alternatives, fn {name, w} ->
    rescored = rescore.(scored, w)
    scores = Enum.map(rescored, & &1.custom_score)
    distances = Enum.map(rescored, & &1.distance_km)
    n = length(scores)

    mean_s = Enum.sum(scores) / n
    mean_d = Enum.sum(distances) / n

    cov = Enum.zip(scores, distances) |> Enum.map(fn {s, d} -> (s - mean_s) * (d - mean_d) end) |> Enum.sum()
    var_s = Enum.map(scores, fn s -> (s - mean_s) ** 2 end) |> Enum.sum()
    var_d = Enum.map(distances, fn d -> (d - mean_d) ** 2 end) |> Enum.sum()
    r = if var_s > 0 and var_d > 0, do: cov / :math.sqrt(var_s * var_d), else: 0.0

    %{weights: name, correlation_r: Float.round(r, 4), mean_score: Float.round(mean_s, 1)}
  end)

Kino.DataTable.new(comparisons, name: "Weight Set Comparison (score-distance correlation)")