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
| + | Station 1 | +Grid 1 | +Station 2 | +Grid 2 | +Band | +Mode | +Distance (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")} | +