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:
parent
489e188956
commit
02cb4fd67b
14 changed files with 453 additions and 70 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
BIN
priv/models/propagation_v1.nx
Normal file
BIN
priv/models/propagation_v1.nx
Normal file
Binary file not shown.
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue