Integrate ML model into grid worker, QSO search, and UI improvements

ML Integration:
- Load trained model at app startup, cache compiled predict fn in persistent_term
- Grid worker uses batched ML prediction (10K chunks) when model loaded,
  falls back to algorithm scorer when not
- ML score replaces composite, algorithm factor scores preserved for detail view
- Fix process explosion: single EXLA call per chunk instead of per-grid-point

QSO Features:
- Callsign search (ILIKE on station1/station2) with trigram indexes
- Reciprocal QSO grouping (same pair, same band, same hour)
- Wider layout (max-w-7xl) for data table pages
- QSO Training Data link on map page

Infrastructure:
- Re-enable hourly propagation grid worker in dev
- Track ML model weights in git for Docker builds
- Add btree indexes on qsos (timestamp, band, distance_km)
- Remove nav icons from layout header
This commit is contained in:
Graham McIntire 2026-04-01 10:14:22 -05:00
parent 489e188956
commit 02cb4fd67b
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
14 changed files with 453 additions and 70 deletions

3
.gitignore vendored
View file

@ -31,8 +31,7 @@ microwaveprop-*.tar
.env .env
.env.* .env.*
# Trained ML model weights (binary, large) # Trained ML model weights — tracked in git for Docker builds
/priv/models/*.nx
# GRIB2 test fixtures (large binary files, downloaded on-demand) # GRIB2 test fixtures (large binary files, downloaded on-demand)
/test/fixtures/grib2/*.grib2 /test/fixtures/grib2/*.grib2

View file

@ -71,12 +71,13 @@ config :microwaveprop, MicrowavepropWeb.Endpoint,
] ]
config :microwaveprop, Oban, config :microwaveprop, Oban,
queues: [solar: 1, weather: 20, enqueue: 1, hrrr: 5, terrain: 4, commercial: 2, iemre: 10], queues: [propagation: 1, solar: 1, weather: 20, enqueue: 1, hrrr: 5, terrain: 4, commercial: 2, iemre: 10],
plugins: [ plugins: [
{Oban.Plugins.Pruner, max_age: 3600 * 24}, {Oban.Plugins.Pruner, max_age: 3600 * 24},
{Oban.Plugins.Lifeline, rescue_after: to_timeout(minute: 30)}, {Oban.Plugins.Lifeline, rescue_after: to_timeout(minute: 30)},
{Oban.Plugins.Cron, {Oban.Plugins.Cron,
crontab: [ crontab: [
{"5 * * * *", Microwaveprop.Workers.PropagationGridWorker},
{"0 8 * * *", Microwaveprop.Workers.SolarIndexWorker}, {"0 8 * * *", Microwaveprop.Workers.SolarIndexWorker},
{"*/30 * * * *", Microwaveprop.Workers.QsoWeatherEnqueueWorker} {"*/30 * * * *", Microwaveprop.Workers.QsoWeatherEnqueueWorker}
]} ]}
@ -88,8 +89,8 @@ config :microwaveprop, dev_routes: true
# Use local SRTM1 tiles for elevation lookups instead of the Open-Meteo API # Use local SRTM1 tiles for elevation lookups instead of the Open-Meteo API
config :microwaveprop, srtm_tiles_dir: Path.expand("~/srtm/tiles") config :microwaveprop, srtm_tiles_dir: Path.expand("~/srtm/tiles")
# Disable propagation grid worker and freshness monitor in dev to let backfill run # Freshness monitor watches for stale propagation scores
config :microwaveprop, start_freshness_monitor: false config :microwaveprop, start_freshness_monitor: true
# Initialize plugs at runtime for faster development compilation # Initialize plugs at runtime for faster development compilation
config :phoenix, :plug_init_mode, :runtime config :phoenix, :plug_init_mode, :runtime

View file

@ -23,7 +23,12 @@ defmodule Microwaveprop.Application do
# See https://hexdocs.pm/elixir/Supervisor.html # See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options # for other strategies and supported options
opts = [strategy: :one_for_one, name: Microwaveprop.Supervisor] opts = [strategy: :one_for_one, name: Microwaveprop.Supervisor]
Supervisor.start_link(children, opts) result = Supervisor.start_link(children, opts)
# Load ML model after supervision tree is up (non-blocking, no-op if file missing)
Microwaveprop.Propagation.load_ml_model()
result
end end
# Tell Phoenix to update the endpoint configuration # Tell Phoenix to update the endpoint configuration

View file

@ -6,17 +6,44 @@ defmodule Microwaveprop.Propagation do
alias Microwaveprop.Propagation.BandConfig alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.Grid alias Microwaveprop.Propagation.Grid
alias Microwaveprop.Propagation.GridScore alias Microwaveprop.Propagation.GridScore
alias Microwaveprop.Propagation.Model
alias Microwaveprop.Propagation.Scorer alias Microwaveprop.Propagation.Scorer
alias Microwaveprop.Repo alias Microwaveprop.Repo
alias Microwaveprop.Weather.SoundingParams alias Microwaveprop.Weather.SoundingParams
require Logger require Logger
@ml_key :propagation_ml
@doc """
Loads the ML model from disk, compiles the predict function, and caches both
in persistent_term. No-op if the model file doesn't exist.
"""
def load_ml_model do
case Model.load() do
{:ok, params} ->
predict_fn = Model.compile_predict()
:persistent_term.put(@ml_key, {predict_fn, params})
Logger.info("PropagationML: model loaded and compiled from #{inspect(Model.default_path())}")
:ok
:error ->
Logger.info("PropagationML: no model file found, using algorithm scorer only")
:ok
end
end
@doc "Returns cached {predict_fn, params} tuple, or nil if not loaded."
def ml_model do
:persistent_term.get(@ml_key, nil)
end
@doc """ @doc """
Score a single grid point across all bands using HRRR profile data. Score a single grid point across all bands using HRRR profile data.
Uses ML model if loaded, falls back to algorithm scorer.
Returns a list of %{band_mhz, score, factors} maps. Returns a list of %{band_mhz, score, factors} maps.
""" """
def score_grid_point(hrrr_profile, valid_time, longitude) do def score_grid_point(hrrr_profile, valid_time, latitude, longitude) do
derived = derive_from_hrrr(hrrr_profile) derived = derive_from_hrrr(hrrr_profile)
temp_c = hrrr_profile.surface_temp_c temp_c = hrrr_profile.surface_temp_c
@ -27,11 +54,21 @@ defmodule Microwaveprop.Propagation do
dewpoint_c < -80 or dewpoint_c > 50 do dewpoint_c < -80 or dewpoint_c > 50 do
[] []
else else
score_grid_point_with_data(hrrr_profile, valid_time, temp_c, dewpoint_c, derived, longitude) score_grid_point_with_data(hrrr_profile, valid_time, temp_c, dewpoint_c, derived, latitude, longitude)
end end
end end
defp score_grid_point_with_data(hrrr_profile, valid_time, temp_c, dewpoint_c, derived, longitude) do defp score_grid_point_with_data(hrrr_profile, valid_time, temp_c, dewpoint_c, derived, latitude, longitude) do
case ml_model() do
nil ->
score_with_algorithm(hrrr_profile, valid_time, temp_c, dewpoint_c, derived, latitude, longitude)
{predict_fn, params} ->
score_with_ml(predict_fn, params, hrrr_profile, valid_time, temp_c, dewpoint_c, derived, latitude, longitude)
end
end
defp score_with_algorithm(hrrr_profile, valid_time, temp_c, dewpoint_c, derived, _latitude, longitude) do
temp_f = Scorer.c_to_f(temp_c) temp_f = Scorer.c_to_f(temp_c)
dewpoint_f = Scorer.c_to_f(dewpoint_c) dewpoint_f = Scorer.c_to_f(dewpoint_c)
@ -59,6 +96,38 @@ defmodule Microwaveprop.Propagation do
end) end)
end end
defp score_with_ml(predict_fn, params, hrrr_profile, valid_time, temp_c, dewpoint_c, derived, latitude, longitude) do
# Compute algorithm factors for the detail panel breakdown
algo_results = score_with_algorithm(hrrr_profile, valid_time, temp_c, dewpoint_c, derived, latitude, longitude)
# Build ML conditions for all bands and predict in one batch
ml_base = %{
surface_temp_c: temp_c,
surface_dewpoint_c: dewpoint_c,
surface_pressure_mb: hrrr_profile.surface_pressure_mb,
min_refractivity_gradient: derived[:min_refractivity_gradient],
hpbl_m: hrrr_profile[:hpbl_m],
pwat_mm: hrrr_profile[:pwat_mm],
surface_refractivity: hrrr_profile[:surface_refractivity],
latitude: latitude,
ducting_detected: hrrr_profile[:ducting_detected],
utc_hour: valid_time.hour,
month: valid_time.month,
longitude: longitude
}
bands = BandConfig.all_bands()
conditions_list = Enum.map(bands, fn bc -> Map.put(ml_base, :freq_mhz, bc.freq_mhz) end)
ml_scores = Model.predict_scores_batch(predict_fn, params, conditions_list)
# ML score replaces composite, algorithm factors kept for detail view
[algo_results, ml_scores]
|> Enum.zip()
|> Enum.map(fn {algo_result, ml_score} ->
%{algo_result | score: ml_score, factors: Map.put(algo_result.factors, :algo_score, algo_result.score)}
end)
end
@doc """ @doc """
Upsert propagation scores in batches within a transaction so readers see all-or-nothing. Upsert propagation scores in batches within a transaction so readers see all-or-nothing.

View file

@ -44,6 +44,8 @@ defmodule Microwaveprop.Propagation.Model do
@models_dir Path.join(:code.priv_dir(:microwaveprop), "models") @models_dir Path.join(:code.priv_dir(:microwaveprop), "models")
@default_path Path.join(@models_dir, "propagation_v1.nx") @default_path Path.join(@models_dir, "propagation_v1.nx")
def default_path, do: @default_path
@doc """ @doc """
Builds the Axon model graph. Does not initialize parameters. Builds the Axon model graph. Does not initialize parameters.
@ -174,19 +176,16 @@ defmodule Microwaveprop.Propagation.Model do
@doc """ @doc """
Predicts a propagation score (0-100) from raw conditions. Predicts a propagation score (0-100) from raw conditions.
Takes trained model parameters and a conditions map (same keys as `encode_features/1`). Takes a compiled predict function, model parameters, and a conditions map.
Returns an integer score clamped to [0, 100]. Returns an integer score clamped to [0, 100].
""" """
def predict_score(params, conditions) do def predict_score(predict_fn, params, conditions) do
features = features =
conditions conditions
|> encode_features() |> encode_features()
|> Nx.tensor(type: :f32) |> Nx.tensor(type: :f32)
|> Nx.reshape({1, @feature_count}) |> Nx.reshape({1, @feature_count})
model = build()
{_init_fn, predict_fn} = Axon.build(model, compiler: EXLA)
params params
|> predict_fn.(%{"features" => features}) |> predict_fn.(%{"features" => features})
|> Nx.squeeze() |> Nx.squeeze()
@ -198,6 +197,40 @@ defmodule Microwaveprop.Propagation.Model do
|> min(100) |> min(100)
end end
@doc """
Predicts scores for multiple condition maps in a single batched forward pass.
Takes a compiled predict function, model parameters, and a list of conditions maps.
Returns a list of integer scores (0-100).
"""
@batch_chunk_size 10_000
def predict_scores_batch(predict_fn, params, conditions_list) do
conditions_list
|> Enum.chunk_every(@batch_chunk_size)
|> Enum.flat_map(fn chunk ->
feature_rows = Enum.map(chunk, &encode_features/1)
batch = Nx.tensor(feature_rows, type: :f32)
params
|> predict_fn.(%{"features" => batch})
|> Nx.multiply(100)
|> Nx.round()
|> Nx.squeeze(axes: [1])
|> Nx.to_flat_list()
|> Enum.map(fn score -> score |> trunc() |> max(0) |> min(100) end)
end)
end
@doc """
Compiles the predict function for reuse. Call once at load time.
"""
def compile_predict do
model = build()
{_init_fn, predict_fn} = Axon.build(model, compiler: EXLA)
predict_fn
end
@doc """ @doc """
Runs a forward pass with the given parameters and input features. Runs a forward pass with the given parameters and input features.

View file

@ -13,14 +13,17 @@ defmodule Microwaveprop.Radio do
def list_qsos(opts \\ []) do def list_qsos(opts \\ []) do
page = max(Keyword.get(opts, :page, 1), 1) page = max(Keyword.get(opts, :page, 1), 1)
offset = (page - 1) * @per_page offset = (page - 1) * @per_page
search = Keyword.get(opts, :search)
{sort_field, sort_dir} = sort_opts(opts) {sort_field, sort_dir} = sort_opts(opts)
total_entries = Repo.aggregate(Qso, :count) base_query = maybe_search(Qso, search)
total_entries = Repo.aggregate(base_query, :count)
total_pages = max(ceil(total_entries / @per_page), 1) total_pages = max(ceil(total_entries / @per_page), 1)
entries = entries =
Qso base_query
|> order_by([q], [{^sort_dir, field(q, ^sort_field)}]) |> order_by([q], [{^sort_dir, field(q, ^sort_field)}])
|> limit(^@per_page) |> limit(^@per_page)
|> offset(^offset) |> offset(^offset)
@ -28,12 +31,62 @@ defmodule Microwaveprop.Radio do
%{ %{
entries: entries, entries: entries,
grouped_entries: group_reciprocals(entries),
page: page, page: page,
total_pages: total_pages, total_pages: total_pages,
total_entries: total_entries total_entries: total_entries
} }
end end
@doc """
Groups reciprocal QSOs (same station pair swapped, same band, same timestamp).
Returns a list of `{primary_qso, reciprocals}` tuples where reciprocals is
a (possibly empty) list of matching QSOs.
"""
def group_reciprocals(qsos) do
{groups, _seen_ids} =
Enum.reduce(qsos, {[], MapSet.new()}, fn qso, {groups, seen} ->
if MapSet.member?(seen, qso.id) do
{groups, seen}
else
reciprocals =
Enum.filter(qsos, fn other ->
other.id != qso.id and
not MapSet.member?(seen, other.id) and
other.band == qso.band and
reciprocal_pair?(qso, other)
end)
new_seen = Enum.reduce(reciprocals, MapSet.put(seen, qso.id), &MapSet.put(&2, &1.id))
{[{qso, reciprocals} | groups], new_seen}
end
end)
Enum.reverse(groups)
end
defp reciprocal_pair?(a, b) do
same_stations =
(a.station1 == b.station2 and a.station2 == b.station1) or
(a.station1 == b.station1 and a.station2 == b.station2)
same_stations and same_hour?(a.qso_timestamp, b.qso_timestamp)
end
defp same_hour?(t1, t2) do
t1 |> NaiveDateTime.truncate(:second) |> Map.take([:year, :month, :day, :hour]) ==
t2 |> NaiveDateTime.truncate(:second) |> Map.take([:year, :month, :day, :hour])
end
defp maybe_search(query, nil), do: query
defp maybe_search(query, ""), do: query
defp maybe_search(query, search) do
pattern = "%" <> String.upcase(search) <> "%"
where(query, [q], ilike(q.station1, ^pattern) or ilike(q.station2, ^pattern))
end
defp sort_opts(opts) do defp sort_opts(opts) do
sort_by = Keyword.get(opts, :sort_by, :qso_timestamp) sort_by = Keyword.get(opts, :sort_by, :qso_timestamp)
sort_order = Keyword.get(opts, :sort_order, :desc) sort_order = Keyword.get(opts, :sort_order, :desc)

View file

@ -158,20 +158,25 @@ defmodule Microwaveprop.Workers.PropagationGridWorker do
end end
defp compute_scores(grid_data, valid_time) do defp compute_scores(grid_data, valid_time) do
case Propagation.ml_model() do
nil ->
# No ML model — use algorithm scorer with parallelism
compute_scores_algorithm(grid_data, valid_time)
{predict_fn, params} ->
# ML model loaded — batch all predictions in single pass
compute_scores_ml(grid_data, valid_time, predict_fn, params)
end
end
defp compute_scores_algorithm(grid_data, valid_time) do
grid_data grid_data
|> Task.async_stream( |> Task.async_stream(
fn {{lat, lon}, profile} -> fn {{lat, lon}, profile} ->
band_scores = Propagation.score_grid_point(profile, valid_time, lon) band_scores = Propagation.score_grid_point(profile, valid_time, lat, lon)
Enum.map(band_scores, fn r -> Enum.map(band_scores, fn r ->
%{ %{lat: lat, lon: lon, valid_time: valid_time, band_mhz: r.band_mhz, score: r.score, factors: r.factors}
lat: lat,
lon: lon,
valid_time: valid_time,
band_mhz: r.band_mhz,
score: r.score,
factors: r.factors
}
end) end)
end, end,
max_concurrency: System.schedulers_online() * 2, max_concurrency: System.schedulers_online() * 2,
@ -182,4 +187,100 @@ defmodule Microwaveprop.Workers.PropagationGridWorker do
{:exit, _reason} -> [] {:exit, _reason} -> []
end) end)
end end
defp compute_scores_ml(grid_data, valid_time, predict_fn, params) do
alias Propagation.BandConfig
alias Propagation.Model
alias Propagation.Scorer
bands = BandConfig.all_bands()
# Build feature rows for ALL grid points × ALL bands in one pass
{grid_meta, conditions_list} =
grid_data
|> Enum.flat_map(fn {{lat, lon}, profile} ->
temp_c = profile.surface_temp_c
dewpoint_c = profile.surface_dewpoint_c
if is_nil(temp_c) or is_nil(dewpoint_c) or temp_c < -80 or temp_c > 60 or
dewpoint_c < -80 or dewpoint_c > 50 do
[]
else
derived = derive_hrrr_params(profile)
ml_base = %{
surface_temp_c: temp_c,
surface_dewpoint_c: dewpoint_c,
surface_pressure_mb: profile.surface_pressure_mb,
min_refractivity_gradient: derived[:min_refractivity_gradient],
hpbl_m: profile[:hpbl_m],
pwat_mm: profile[:pwat_mm],
surface_refractivity: profile[:surface_refractivity],
latitude: lat,
ducting_detected: profile[:ducting_detected],
utc_hour: valid_time.hour,
month: valid_time.month,
longitude: lon
}
# Also compute algorithm factors for detail view
temp_f = Scorer.c_to_f(temp_c)
dewpoint_f = Scorer.c_to_f(dewpoint_c)
algo_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(profile[:wind_u], profile[:wind_v]),
sky_cover_pct: profile[:cloud_cover_pct],
utc_hour: valid_time.hour,
utc_minute: valid_time.minute,
month: valid_time.month,
longitude: lon,
pressure_mb: profile.surface_pressure_mb,
prev_pressure_mb: nil,
rain_rate_mmhr: Scorer.precip_to_rate_mmhr(profile[:precip_mm]),
min_refractivity_gradient: derived[:min_refractivity_gradient],
bl_depth_m: profile[:hpbl_m],
pwat_mm: profile[:pwat_mm]
}
Enum.map(bands, fn band_config ->
algo = Scorer.composite_score(algo_conditions, band_config)
ml_conditions = Map.put(ml_base, :freq_mhz, band_config.freq_mhz)
{{lat, lon, band_config.freq_mhz, algo}, ml_conditions}
end)
end
end)
|> Enum.unzip()
if conditions_list == [] do
[]
else
# Single batched ML prediction for all points × bands
ml_scores = Model.predict_scores_batch(predict_fn, params, conditions_list)
grid_meta
|> Enum.zip(ml_scores)
|> Enum.map(fn {{lat, lon, band_mhz, algo}, ml_score} ->
%{
lat: lat,
lon: lon,
valid_time: valid_time,
band_mhz: band_mhz,
score: ml_score,
factors: Map.put(algo.factors, :algo_score, algo.score)
}
end)
end
end
defp derive_hrrr_params(%{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_hrrr_params(_), do: %{}
end end

View file

@ -31,6 +31,7 @@ defmodule MicrowavepropWeb.Layouts do
default: nil, default: nil,
doc: "the current [scope](https://hexdocs.pm/phoenix/scopes.html)" doc: "the current [scope](https://hexdocs.pm/phoenix/scopes.html)"
attr :max_width, :string, default: "max-w-2xl", doc: "max width class for the content container"
slot :inner_block, required: true slot :inner_block, required: true
def app(assigns) do def app(assigns) do
@ -39,15 +40,9 @@ defmodule MicrowavepropWeb.Layouts do
<div class="flex-1 gap-4"> <div class="flex-1 gap-4">
<a href="/" class="font-semibold">Microwaveprop</a> <a href="/" class="font-semibold">Microwaveprop</a>
<nav class="flex gap-2"> <nav class="flex gap-2">
<.link navigate="/map" class="btn btn-ghost btn-sm"> <.link navigate="/map" class="btn btn-ghost btn-sm">Map</.link>
<.icon name="hero-map" class="size-4" /> Map <.link navigate="/qsos" class="btn btn-ghost btn-sm">QSOs</.link>
</.link> <.link navigate="/submit" class="btn btn-ghost btn-sm">Submit</.link>
<.link navigate="/qsos" class="btn btn-ghost btn-sm">
<.icon name="hero-signal" class="size-4" /> QSOs
</.link>
<.link navigate="/submit" class="btn btn-ghost btn-sm">
<.icon name="hero-plus-circle" class="size-4" /> Submit
</.link>
</nav> </nav>
</div> </div>
<div class="flex-none"> <div class="flex-none">
@ -56,7 +51,7 @@ defmodule MicrowavepropWeb.Layouts do
</header> </header>
<main class="px-4 py-20 sm:px-6 lg:px-8"> <main class="px-4 py-20 sm:px-6 lg:px-8">
<div class="mx-auto max-w-2xl space-y-4"> <div class={["mx-auto space-y-4", @max_width]}>
{render_slot(@inner_block)} {render_slot(@inner_block)}
</div> </div>
</main> </main>

View file

@ -335,6 +335,9 @@ defmodule MicrowavepropWeb.MapLive do
<.link navigate="/submit" class="btn btn-xs btn-ghost justify-start gap-1.5"> <.link navigate="/submit" class="btn btn-xs btn-ghost justify-start gap-1.5">
<.icon name="hero-arrow-up-tray" class="size-3.5" /> Submit a QSO <.icon name="hero-arrow-up-tray" class="size-3.5" /> Submit a QSO
</.link> </.link>
<.link navigate="/qsos" class="btn btn-xs btn-ghost justify-start gap-1.5">
<.icon name="hero-signal" class="size-3.5" /> QSO Training Data
</.link>
</div> </div>
</div> </div>

View file

@ -18,12 +18,14 @@ defmodule MicrowavepropWeb.QsoLive.Index do
page = params |> Map.get("page", "1") |> String.to_integer() |> max(1) page = params |> Map.get("page", "1") |> String.to_integer() |> max(1)
sort_by = validate_sort_field(Map.get(params, "sort_by", @default_sort_by)) sort_by = validate_sort_field(Map.get(params, "sort_by", @default_sort_by))
sort_order = validate_sort_order(Map.get(params, "sort_order", @default_sort_order)) sort_order = validate_sort_order(Map.get(params, "sort_order", @default_sort_order))
search = Map.get(params, "search", "")
result = result =
Radio.list_qsos( Radio.list_qsos(
page: page, page: page,
sort_by: String.to_existing_atom(sort_by), sort_by: String.to_existing_atom(sort_by),
sort_order: String.to_existing_atom(sort_order) sort_order: String.to_existing_atom(sort_order),
search: search
) )
{:noreply, {:noreply,
@ -33,11 +35,18 @@ defmodule MicrowavepropWeb.QsoLive.Index do
total_pages: result.total_pages, total_pages: result.total_pages,
total_entries: result.total_entries, total_entries: result.total_entries,
qsos: result.entries, qsos: result.entries,
grouped_qsos: result.grouped_entries,
sort_by: sort_by, sort_by: sort_by,
sort_order: sort_order sort_order: sort_order,
search: search
)} )}
end end
@impl true
def handle_event("search", %{"search" => search}, socket) do
{:noreply, push_patch(socket, to: ~p"/qsos?search=#{search}")}
end
@impl true @impl true
def handle_event("sort", %{"field" => field}, socket) do def handle_event("sort", %{"field" => field}, socket) do
field = validate_sort_field(field) field = validate_sort_field(field)
@ -47,7 +56,9 @@ defmodule MicrowavepropWeb.QsoLive.Index do
do: "desc", do: "desc",
else: "asc" else: "asc"
{:noreply, push_patch(socket, to: ~p"/qsos?sort_by=#{field}&sort_order=#{new_order}")} params = %{sort_by: field, sort_order: new_order}
params = if socket.assigns.search == "", do: params, else: Map.put(params, :search, socket.assigns.search)
{:noreply, push_patch(socket, to: ~p"/qsos?#{params}")}
end end
defp validate_sort_field(field) when field in @sortable_fields, do: field defp validate_sort_field(field) when field in @sortable_fields, do: field
@ -59,7 +70,7 @@ defmodule MicrowavepropWeb.QsoLive.Index do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<Layouts.app flash={@flash}> <Layouts.app flash={@flash} max_width="max-w-7xl">
<.header> <.header>
QSOs QSOs
<:subtitle>{@total_entries} contacts</:subtitle> <:subtitle>{@total_entries} contacts</:subtitle>
@ -70,33 +81,116 @@ defmodule MicrowavepropWeb.QsoLive.Index do
</:actions> </:actions>
</.header> </.header>
<.table <form phx-submit="search" class="mb-4">
id="qsos" <div class="flex gap-2">
rows={@qsos} <.input
row_id={fn qso -> "qso-#{qso.id}" end} name="search"
row_click={fn qso -> JS.navigate(~p"/qsos/#{qso.id}") end} value={@search}
sort_by={@sort_by} placeholder="Search by callsign..."
sort_order={@sort_order} type="text"
> class="input input-bordered input-sm w-64"
<:col :let={qso} label="Station 1" sort_field="station1">{qso.station1}</:col> />
<:col :let={qso} label="Grid 1">{qso.grid1 || ""}</:col> <button type="submit" class="btn btn-sm btn-outline">Search</button>
<:col :let={qso} label="Station 2" sort_field="station2">{qso.station2}</:col> <.link :if={@search != ""} patch={~p"/qsos"} class="btn btn-sm btn-ghost">Clear</.link>
<:col :let={qso} label="Grid 2">{qso.grid2 || ""}</:col> </div>
<:col :let={qso} label="Band" sort_field="band">{qso.band}</:col> </form>
<:col :let={qso} label="Mode" sort_field="mode">{qso.mode}</:col>
<:col :let={qso} label="Distance (km)" sort_field="distance_km">{qso.distance_km}</:col> <%= if @search != "" do %>
<:col :let={qso} label="Timestamp" sort_field="qso_timestamp"> <table class="table table-sm w-full">
{Calendar.strftime(qso.qso_timestamp, "%Y-%m-%d %H:%M")} <thead>
</:col> <tr>
<:col :let={qso} label="Committed" sort_field="inserted_at"> <th class="w-8"></th>
{Calendar.strftime(qso.inserted_at, "%Y-%m-%d %H:%M")} <th>Station 1</th>
</:col> <th>Grid 1</th>
</.table> <th>Station 2</th>
<th>Grid 2</th>
<th>Band</th>
<th>Mode</th>
<th>Distance (km)</th>
<th>Timestamp</th>
</tr>
</thead>
<tbody>
<%= for {primary, reciprocals} <- @grouped_qsos do %>
<%!-- Spacer row between groups --%>
<tr class="h-2">
<td colspan="9"></td>
</tr>
<tr
class={[
"hover:bg-base-200 cursor-pointer",
reciprocals != [] && "bg-primary/5"
]}
phx-click={JS.navigate(~p"/qsos/#{primary.id}")}
>
<td class="w-8 text-center">
<span
:if={reciprocals != []}
class="badge badge-primary badge-xs"
title="Has reciprocal QSO"
>
{length(reciprocals) + 1}
</span>
</td>
<td class="font-mono font-semibold">{primary.station1}</td>
<td>{primary.grid1 || ""}</td>
<td class="font-mono font-semibold">{primary.station2}</td>
<td>{primary.grid2 || ""}</td>
<td>{primary.band}</td>
<td>{primary.mode}</td>
<td>{primary.distance_km}</td>
<td>{Calendar.strftime(primary.qso_timestamp, "%Y-%m-%d %H:%M")}</td>
</tr>
<%= for recip <- reciprocals do %>
<tr
class="hover:bg-base-200 cursor-pointer bg-primary/5 opacity-70 border-t-0"
phx-click={JS.navigate(~p"/qsos/#{recip.id}")}
>
<td class="w-8 text-center text-primary"></td>
<td class="font-mono">{recip.station1}</td>
<td>{recip.grid1 || ""}</td>
<td class="font-mono">{recip.station2}</td>
<td>{recip.grid2 || ""}</td>
<td>{recip.band}</td>
<td>{recip.mode}</td>
<td>{recip.distance_km}</td>
<td>{Calendar.strftime(recip.qso_timestamp, "%Y-%m-%d %H:%M")}</td>
</tr>
<% end %>
<% end %>
</tbody>
</table>
<% else %>
<.table
id="qsos"
rows={@qsos}
row_id={fn qso -> "qso-#{qso.id}" end}
row_click={fn qso -> JS.navigate(~p"/qsos/#{qso.id}") end}
sort_by={@sort_by}
sort_order={@sort_order}
>
<:col :let={qso} label="Station 1" sort_field="station1">{qso.station1}</:col>
<:col :let={qso} label="Grid 1">{qso.grid1 || ""}</:col>
<:col :let={qso} label="Station 2" sort_field="station2">{qso.station2}</:col>
<:col :let={qso} label="Grid 2">{qso.grid2 || ""}</:col>
<:col :let={qso} label="Band" sort_field="band">{qso.band}</:col>
<:col :let={qso} label="Mode" sort_field="mode">{qso.mode}</:col>
<:col :let={qso} label="Distance (km)" sort_field="distance_km">{qso.distance_km}</:col>
<:col :let={qso} label="Timestamp" sort_field="qso_timestamp">
{Calendar.strftime(qso.qso_timestamp, "%Y-%m-%d %H:%M")}
</:col>
<:col :let={qso} label="Committed" sort_field="inserted_at">
{Calendar.strftime(qso.inserted_at, "%Y-%m-%d %H:%M")}
</:col>
</.table>
<% end %>
<div class="flex items-center justify-between pt-4"> <div class="flex items-center justify-between pt-4">
<.link <.link
:if={@page > 1} :if={@page > 1}
patch={~p"/qsos?page=#{@page - 1}&sort_by=#{@sort_by}&sort_order=#{@sort_order}"} patch={
~p"/qsos?page=#{@page - 1}&sort_by=#{@sort_by}&sort_order=#{@sort_order}&search=#{@search}"
}
class="btn btn-sm btn-outline" class="btn btn-sm btn-outline"
> >
<.icon name="hero-chevron-left" class="w-4 h-4" /> Previous <.icon name="hero-chevron-left" class="w-4 h-4" /> Previous
@ -107,7 +201,9 @@ defmodule MicrowavepropWeb.QsoLive.Index do
<.link <.link
:if={@page < @total_pages} :if={@page < @total_pages}
patch={~p"/qsos?page=#{@page + 1}&sort_by=#{@sort_by}&sort_order=#{@sort_order}"} patch={
~p"/qsos?page=#{@page + 1}&sort_by=#{@sort_by}&sort_order=#{@sort_order}&search=#{@search}"
}
class="btn btn-sm btn-outline" class="btn btn-sm btn-outline"
> >
Next <.icon name="hero-chevron-right" class="w-4 h-4" /> Next <.icon name="hero-chevron-right" class="w-4 h-4" />

Binary file not shown.

View file

@ -0,0 +1,26 @@
defmodule Microwaveprop.Repo.Migrations.AddTrainingPerformanceIndexes do
use Ecto.Migration
def change do
# QSO training data queries filter on timestamp + distance + positions
create_if_not_exists index(:qsos, [:qso_timestamp])
create_if_not_exists index(:qsos, [:band])
create_if_not_exists index(:qsos, [:distance_km])
# Callsign search: trigram index for ILIKE on station1/station2
execute(
"CREATE EXTENSION IF NOT EXISTS pg_trgm",
"SELECT 1"
)
execute(
"CREATE INDEX IF NOT EXISTS qsos_station1_trgm_index ON qsos USING gin (station1 gin_trgm_ops)",
"DROP INDEX IF EXISTS qsos_station1_trgm_index"
)
execute(
"CREATE INDEX IF NOT EXISTS qsos_station2_trgm_index ON qsos USING gin (station2 gin_trgm_ops)",
"DROP INDEX IF EXISTS qsos_station2_trgm_index"
)
end
end

View file

@ -155,12 +155,13 @@ defmodule Microwaveprop.Propagation.ModelTest do
end end
end end
describe "predict_score/2" do describe "predict_score/3" do
test "returns integer between 0 and 100" do test "returns integer between 0 and 100" do
params = Model.init() params = Model.init()
predict_fn = Model.compile_predict()
score = score =
Model.predict_score(params, %{ Model.predict_score(predict_fn, params, %{
surface_temp_c: 25.0, surface_temp_c: 25.0,
surface_dewpoint_c: 15.0, surface_dewpoint_c: 15.0,
surface_pressure_mb: 1015.0, surface_pressure_mb: 1015.0,
@ -179,9 +180,10 @@ defmodule Microwaveprop.Propagation.ModelTest do
test "returns different scores for different conditions" do test "returns different scores for different conditions" do
params = Model.init() params = Model.init()
predict_fn = Model.compile_predict()
score_hot = score_hot =
Model.predict_score(params, %{ Model.predict_score(predict_fn, params, %{
surface_temp_c: 35.0, surface_temp_c: 35.0,
surface_dewpoint_c: 25.0, surface_dewpoint_c: 25.0,
surface_pressure_mb: 1010.0, surface_pressure_mb: 1010.0,
@ -194,7 +196,7 @@ defmodule Microwaveprop.Propagation.ModelTest do
}) })
score_cold = score_cold =
Model.predict_score(params, %{ Model.predict_score(predict_fn, params, %{
surface_temp_c: -10.0, surface_temp_c: -10.0,
surface_dewpoint_c: -20.0, surface_dewpoint_c: -20.0,
surface_pressure_mb: 1030.0, surface_pressure_mb: 1030.0,

View file

@ -4,7 +4,7 @@ defmodule Microwaveprop.PropagationTest do
alias Microwaveprop.Propagation alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.GridScore alias Microwaveprop.Propagation.GridScore
describe "score_grid_point/3" do describe "score_grid_point/4" do
test "scores a single point for all 8 bands" do test "scores a single point for all 8 bands" do
hrrr_profile = %{ hrrr_profile = %{
surface_temp_c: 25.0, surface_temp_c: 25.0,
@ -23,13 +23,13 @@ defmodule Microwaveprop.PropagationTest do
} }
valid_time = ~U[2026-07-15 13:00:00Z] valid_time = ~U[2026-07-15 13:00:00Z]
results = Propagation.score_grid_point(hrrr_profile, valid_time, -97.0) results = Propagation.score_grid_point(hrrr_profile, valid_time, 33.0, -97.0)
assert length(results) == 8 assert length(results) == 8
Enum.each(results, fn result -> Enum.each(results, fn result ->
assert result.score >= 0 and result.score <= 100 assert result.score >= 0 and result.score <= 100
assert map_size(result.factors) == 10 assert is_map(result.factors)
assert is_integer(result.band_mhz) assert is_integer(result.band_mhz)
end) end)
end end