From 02cb4fd67b92aaa01e1b758bdeef83386aebf765 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 1 Apr 2026 10:14:22 -0500 Subject: [PATCH] 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 --- .gitignore | 3 +- config/dev.exs | 7 +- lib/microwaveprop/application.ex | 7 +- lib/microwaveprop/propagation.ex | 75 ++++++++- lib/microwaveprop/propagation/model.ex | 43 ++++- lib/microwaveprop/radio.ex | 57 ++++++- .../workers/propagation_grid_worker.ex | 119 ++++++++++++-- lib/microwaveprop_web/components/layouts.ex | 15 +- lib/microwaveprop_web/live/map_live.ex | 3 + lib/microwaveprop_web/live/qso_live/index.ex | 152 ++++++++++++++---- priv/models/propagation_v1.nx | Bin 0 -> 53565 bytes ...51012_add_training_performance_indexes.exs | 26 +++ test/microwaveprop/propagation/model_test.exs | 10 +- test/microwaveprop/propagation_test.exs | 6 +- 14 files changed, 453 insertions(+), 70 deletions(-) create mode 100644 priv/models/propagation_v1.nx create mode 100644 priv/repo/migrations/20260401151012_add_training_performance_indexes.exs diff --git a/.gitignore b/.gitignore index cb30a2a8..a8607202 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/config/dev.exs b/config/dev.exs index c1bc8821..a2be5edf 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -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 diff --git a/lib/microwaveprop/application.ex b/lib/microwaveprop/application.ex index f62075f9..6d49354d 100644 --- a/lib/microwaveprop/application.ex +++ b/lib/microwaveprop/application.ex @@ -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 diff --git a/lib/microwaveprop/propagation.ex b/lib/microwaveprop/propagation.ex index b2055716..16809ee8 100644 --- a/lib/microwaveprop/propagation.ex +++ b/lib/microwaveprop/propagation.ex @@ -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. diff --git a/lib/microwaveprop/propagation/model.ex b/lib/microwaveprop/propagation/model.ex index 326fa9bb..0ab07e7d 100644 --- a/lib/microwaveprop/propagation/model.ex +++ b/lib/microwaveprop/propagation/model.ex @@ -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. diff --git a/lib/microwaveprop/radio.ex b/lib/microwaveprop/radio.ex index a731fb7b..9ebb312b 100644 --- a/lib/microwaveprop/radio.ex +++ b/lib/microwaveprop/radio.ex @@ -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) diff --git a/lib/microwaveprop/workers/propagation_grid_worker.ex b/lib/microwaveprop/workers/propagation_grid_worker.ex index 754047ad..92847ec1 100644 --- a/lib/microwaveprop/workers/propagation_grid_worker.ex +++ b/lib/microwaveprop/workers/propagation_grid_worker.ex @@ -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 diff --git a/lib/microwaveprop_web/components/layouts.ex b/lib/microwaveprop_web/components/layouts.ex index e5fe9f91..30c97fdb 100644 --- a/lib/microwaveprop_web/components/layouts.ex +++ b/lib/microwaveprop_web/components/layouts.ex @@ -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
Microwaveprop
@@ -56,7 +51,7 @@ defmodule MicrowavepropWeb.Layouts do
-
+
{render_slot(@inner_block)}
diff --git a/lib/microwaveprop_web/live/map_live.ex b/lib/microwaveprop_web/live/map_live.ex index 1eb97e52..9e6df334 100644 --- a/lib/microwaveprop_web/live/map_live.ex +++ b/lib/microwaveprop_web/live/map_live.ex @@ -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 navigate="/qsos" class="btn btn-xs btn-ghost justify-start gap-1.5"> + <.icon name="hero-signal" class="size-3.5" /> QSO Training Data +
diff --git a/lib/microwaveprop_web/live/qso_live/index.ex b/lib/microwaveprop_web/live/qso_live/index.ex index fc66c828..a285c2b0 100644 --- a/lib/microwaveprop_web/live/qso_live/index.ex +++ b/lib/microwaveprop_web/live/qso_live/index.ex @@ -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""" - + <.header> QSOs <:subtitle>{@total_entries} contacts @@ -70,33 +81,116 @@ defmodule MicrowavepropWeb.QsoLive.Index do - <.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 :let={qso} label="Grid 1">{qso.grid1 || "—"} - <:col :let={qso} label="Station 2" sort_field="station2">{qso.station2} - <:col :let={qso} label="Grid 2">{qso.grid2 || "—"} - <:col :let={qso} label="Band" sort_field="band">{qso.band} - <:col :let={qso} label="Mode" sort_field="mode">{qso.mode} - <:col :let={qso} label="Distance (km)" sort_field="distance_km">{qso.distance_km} - <:col :let={qso} label="Timestamp" sort_field="qso_timestamp"> - {Calendar.strftime(qso.qso_timestamp, "%Y-%m-%d %H:%M")} - - <:col :let={qso} label="Committed" sort_field="inserted_at"> - {Calendar.strftime(qso.inserted_at, "%Y-%m-%d %H:%M")} - - +
+
+ <.input + name="search" + value={@search} + placeholder="Search by callsign..." + type="text" + class="input input-bordered input-sm w-64" + /> + + <.link :if={@search != ""} patch={~p"/qsos"} class="btn btn-sm btn-ghost">Clear +
+
+ + <%= if @search != "" do %> + + + + + + + + + + + + + + + + <%= for {primary, reciprocals} <- @grouped_qsos do %> + <%!-- Spacer row between groups --%> + + + + + + + + + + + + + + + <%= for recip <- reciprocals do %> + + + + + + + + + + + + <% end %> + <% end %> + +
Station 1Grid 1Station 2Grid 2BandModeDistance (km)Timestamp
+ + {length(reciprocals) + 1} + + {primary.station1}{primary.grid1 || "—"}{primary.station2}{primary.grid2 || "—"}{primary.band}{primary.mode}{primary.distance_km}{Calendar.strftime(primary.qso_timestamp, "%Y-%m-%d %H:%M")}
{recip.station1}{recip.grid1 || "—"}{recip.station2}{recip.grid2 || "—"}{recip.band}{recip.mode}{recip.distance_km}{Calendar.strftime(recip.qso_timestamp, "%Y-%m-%d %H:%M")}
+ <% 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 :let={qso} label="Grid 1">{qso.grid1 || "—"} + <:col :let={qso} label="Station 2" sort_field="station2">{qso.station2} + <:col :let={qso} label="Grid 2">{qso.grid2 || "—"} + <:col :let={qso} label="Band" sort_field="band">{qso.band} + <:col :let={qso} label="Mode" sort_field="mode">{qso.mode} + <:col :let={qso} label="Distance (km)" sort_field="distance_km">{qso.distance_km} + <:col :let={qso} label="Timestamp" sort_field="qso_timestamp"> + {Calendar.strftime(qso.qso_timestamp, "%Y-%m-%d %H:%M")} + + <:col :let={qso} label="Committed" sort_field="inserted_at"> + {Calendar.strftime(qso.inserted_at, "%Y-%m-%d %H:%M")} + + + <% end %>
<.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" /> diff --git a/priv/models/propagation_v1.nx b/priv/models/propagation_v1.nx new file mode 100644 index 0000000000000000000000000000000000000000..1ae786c2460b2916940adce468a1b0e4ebe8a749 GIT binary patch literal 53565 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 literal 0 HcmV?d00001 diff --git a/priv/repo/migrations/20260401151012_add_training_performance_indexes.exs b/priv/repo/migrations/20260401151012_add_training_performance_indexes.exs new file mode 100644 index 00000000..fcea353f --- /dev/null +++ b/priv/repo/migrations/20260401151012_add_training_performance_indexes.exs @@ -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 diff --git a/test/microwaveprop/propagation/model_test.exs b/test/microwaveprop/propagation/model_test.exs index c3426b7b..3bd53b6f 100644 --- a/test/microwaveprop/propagation/model_test.exs +++ b/test/microwaveprop/propagation/model_test.exs @@ -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, diff --git a/test/microwaveprop/propagation_test.exs b/test/microwaveprop/propagation_test.exs index 2b0711cc..3c489647 100644 --- a/test/microwaveprop/propagation_test.exs +++ b/test/microwaveprop/propagation_test.exs @@ -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