Add app_settings metadata to notebook and set LIVEBOOK_APPS_PATH so it shows up under Apps on the Livebook home page.
480 lines
15 KiB
Markdown
480 lines
15 KiB
Markdown
<!-- livebook:{"app_settings":{"slug":"analysis","auto_shutdown_ms":3600000}} -->
|
|
|
|
# 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>`.
|
|
|
|
```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)")
|
|
```
|