# 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@`. ```elixir # 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 ```elixir 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") ``` ```elixir # 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) ``` ```elixir # 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 ```elixir # 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)" ``` ```elixir # 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 ```elixir 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) ``` ```elixir 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) ``` ```elixir 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) ``` ```elixir 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 ```elixir 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 ```elixir 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)") ``` ```elixir 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 ```elixir 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") ``` ```elixir 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) ``` ## 7. Commercial Link Correlation ```elixir 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 ```elixir # 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"] || contact.pos1["lng"]) |> then(&(&1 || -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" ``` ```elixir # 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) ``` ```elixir # 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)") ``` ```elixir # 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") ``` ```elixir # 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. ```elixir 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)") ```