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 00000000..1ae786c2 Binary files /dev/null and b/priv/models/propagation_v1.nx differ 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