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.*
# Trained ML model weights (binary, large)
/priv/models/*.nx
# Trained ML model weights — tracked in git for Docker builds
# GRIB2 test fixtures (large binary files, downloaded on-demand)
/test/fixtures/grib2/*.grib2

View file

@ -71,12 +71,13 @@ config :microwaveprop, MicrowavepropWeb.Endpoint,
]
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: [
{Oban.Plugins.Pruner, max_age: 3600 * 24},
{Oban.Plugins.Lifeline, rescue_after: to_timeout(minute: 30)},
{Oban.Plugins.Cron,
crontab: [
{"5 * * * *", Microwaveprop.Workers.PropagationGridWorker},
{"0 8 * * *", Microwaveprop.Workers.SolarIndexWorker},
{"*/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
config :microwaveprop, srtm_tiles_dir: Path.expand("~/srtm/tiles")
# Disable propagation grid worker and freshness monitor in dev to let backfill run
config :microwaveprop, start_freshness_monitor: false
# Freshness monitor watches for stale propagation scores
config :microwaveprop, start_freshness_monitor: true
# Initialize plugs at runtime for faster development compilation
config :phoenix, :plug_init_mode, :runtime

View file

@ -23,7 +23,12 @@ defmodule Microwaveprop.Application do
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
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
# Tell Phoenix to update the endpoint configuration

View file

@ -6,17 +6,44 @@ defmodule Microwaveprop.Propagation do
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.Grid
alias Microwaveprop.Propagation.GridScore
alias Microwaveprop.Propagation.Model
alias Microwaveprop.Propagation.Scorer
alias Microwaveprop.Repo
alias Microwaveprop.Weather.SoundingParams
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 """
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.
"""
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)
temp_c = hrrr_profile.surface_temp_c
@ -27,11 +54,21 @@ defmodule Microwaveprop.Propagation do
dewpoint_c < -80 or dewpoint_c > 50 do
[]
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
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)
dewpoint_f = Scorer.c_to_f(dewpoint_c)
@ -59,6 +96,38 @@ defmodule Microwaveprop.Propagation do
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 """
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")
@default_path Path.join(@models_dir, "propagation_v1.nx")
def default_path, do: @default_path
@doc """
Builds the Axon model graph. Does not initialize parameters.
@ -174,19 +176,16 @@ defmodule Microwaveprop.Propagation.Model do
@doc """
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].
"""
def predict_score(params, conditions) do
def predict_score(predict_fn, params, conditions) do
features =
conditions
|> encode_features()
|> Nx.tensor(type: :f32)
|> Nx.reshape({1, @feature_count})
model = build()
{_init_fn, predict_fn} = Axon.build(model, compiler: EXLA)
params
|> predict_fn.(%{"features" => features})
|> Nx.squeeze()
@ -198,6 +197,40 @@ defmodule Microwaveprop.Propagation.Model do
|> min(100)
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 """
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
page = max(Keyword.get(opts, :page, 1), 1)
offset = (page - 1) * @per_page
search = Keyword.get(opts, :search)
{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)
entries =
Qso
base_query
|> order_by([q], [{^sort_dir, field(q, ^sort_field)}])
|> limit(^@per_page)
|> offset(^offset)
@ -28,12 +31,62 @@ defmodule Microwaveprop.Radio do
%{
entries: entries,
grouped_entries: group_reciprocals(entries),
page: page,
total_pages: total_pages,
total_entries: total_entries
}
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
sort_by = Keyword.get(opts, :sort_by, :qso_timestamp)
sort_order = Keyword.get(opts, :sort_order, :desc)

View file

@ -158,20 +158,25 @@ defmodule Microwaveprop.Workers.PropagationGridWorker do
end
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
|> Task.async_stream(
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 ->
%{
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,
max_concurrency: System.schedulers_online() * 2,
@ -182,4 +187,100 @@ defmodule Microwaveprop.Workers.PropagationGridWorker do
{:exit, _reason} -> []
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

View file

@ -31,6 +31,7 @@ defmodule MicrowavepropWeb.Layouts do
default: nil,
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
def app(assigns) do
@ -39,15 +40,9 @@ defmodule MicrowavepropWeb.Layouts do
<div class="flex-1 gap-4">
<a href="/" class="font-semibold">Microwaveprop</a>
<nav class="flex gap-2">
<.link navigate="/map" class="btn btn-ghost btn-sm">
<.icon name="hero-map" class="size-4" /> Map
</.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>
<.link navigate="/map" class="btn btn-ghost btn-sm">Map</.link>
<.link navigate="/qsos" class="btn btn-ghost btn-sm">QSOs</.link>
<.link navigate="/submit" class="btn btn-ghost btn-sm">Submit</.link>
</nav>
</div>
<div class="flex-none">
@ -56,7 +51,7 @@ defmodule MicrowavepropWeb.Layouts do
</header>
<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)}
</div>
</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">
<.icon name="hero-arrow-up-tray" class="size-3.5" /> Submit a QSO
</.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>

View file

@ -18,12 +18,14 @@ defmodule MicrowavepropWeb.QsoLive.Index do
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_order = validate_sort_order(Map.get(params, "sort_order", @default_sort_order))
search = Map.get(params, "search", "")
result =
Radio.list_qsos(
page: page,
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,
@ -33,11 +35,18 @@ defmodule MicrowavepropWeb.QsoLive.Index do
total_pages: result.total_pages,
total_entries: result.total_entries,
qsos: result.entries,
grouped_qsos: result.grouped_entries,
sort_by: sort_by,
sort_order: sort_order
sort_order: sort_order,
search: search
)}
end
@impl true
def handle_event("search", %{"search" => search}, socket) do
{:noreply, push_patch(socket, to: ~p"/qsos?search=#{search}")}
end
@impl true
def handle_event("sort", %{"field" => field}, socket) do
field = validate_sort_field(field)
@ -47,7 +56,9 @@ defmodule MicrowavepropWeb.QsoLive.Index do
do: "desc",
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
defp validate_sort_field(field) when field in @sortable_fields, do: field
@ -59,7 +70,7 @@ defmodule MicrowavepropWeb.QsoLive.Index do
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<Layouts.app flash={@flash} max_width="max-w-7xl">
<.header>
QSOs
<:subtitle>{@total_entries} contacts</:subtitle>
@ -70,33 +81,116 @@ defmodule MicrowavepropWeb.QsoLive.Index do
</:actions>
</.header>
<.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>
<form phx-submit="search" class="mb-4">
<div class="flex gap-2">
<.input
name="search"
value={@search}
placeholder="Search by callsign..."
type="text"
class="input input-bordered input-sm w-64"
/>
<button type="submit" class="btn btn-sm btn-outline">Search</button>
<.link :if={@search != ""} patch={~p"/qsos"} class="btn btn-sm btn-ghost">Clear</.link>
</div>
</form>
<%= if @search != "" do %>
<table class="table table-sm w-full">
<thead>
<tr>
<th class="w-8"></th>
<th>Station 1</th>
<th>Grid 1</th>
<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">
<.link
: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"
>
<.icon name="hero-chevron-left" class="w-4 h-4" /> Previous
@ -107,7 +201,9 @@ defmodule MicrowavepropWeb.QsoLive.Index do
<.link
: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"
>
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
describe "predict_score/2" do
describe "predict_score/3" do
test "returns integer between 0 and 100" do
params = Model.init()
predict_fn = Model.compile_predict()
score =
Model.predict_score(params, %{
Model.predict_score(predict_fn, params, %{
surface_temp_c: 25.0,
surface_dewpoint_c: 15.0,
surface_pressure_mb: 1015.0,
@ -179,9 +180,10 @@ defmodule Microwaveprop.Propagation.ModelTest do
test "returns different scores for different conditions" do
params = Model.init()
predict_fn = Model.compile_predict()
score_hot =
Model.predict_score(params, %{
Model.predict_score(predict_fn, params, %{
surface_temp_c: 35.0,
surface_dewpoint_c: 25.0,
surface_pressure_mb: 1010.0,
@ -194,7 +196,7 @@ defmodule Microwaveprop.Propagation.ModelTest do
})
score_cold =
Model.predict_score(params, %{
Model.predict_score(predict_fn, params, %{
surface_temp_c: -10.0,
surface_dewpoint_c: -20.0,
surface_pressure_mb: 1030.0,

View file

@ -4,7 +4,7 @@ defmodule Microwaveprop.PropagationTest do
alias Microwaveprop.Propagation
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
hrrr_profile = %{
surface_temp_c: 25.0,
@ -23,13 +23,13 @@ defmodule Microwaveprop.PropagationTest do
}
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
Enum.each(results, fn result ->
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)
end)
end