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
|
||||||
.env.*
|
.env.*
|
||||||
|
|
||||||
# Trained ML model weights (binary, large)
|
# Trained ML model weights — tracked in git for Docker builds
|
||||||
/priv/models/*.nx
|
|
||||||
|
|
||||||
# GRIB2 test fixtures (large binary files, downloaded on-demand)
|
# GRIB2 test fixtures (large binary files, downloaded on-demand)
|
||||||
/test/fixtures/grib2/*.grib2
|
/test/fixtures/grib2/*.grib2
|
||||||
|
|
|
||||||
|
|
@ -71,12 +71,13 @@ config :microwaveprop, MicrowavepropWeb.Endpoint,
|
||||||
]
|
]
|
||||||
|
|
||||||
config :microwaveprop, Oban,
|
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: [
|
plugins: [
|
||||||
{Oban.Plugins.Pruner, max_age: 3600 * 24},
|
{Oban.Plugins.Pruner, max_age: 3600 * 24},
|
||||||
{Oban.Plugins.Lifeline, rescue_after: to_timeout(minute: 30)},
|
{Oban.Plugins.Lifeline, rescue_after: to_timeout(minute: 30)},
|
||||||
{Oban.Plugins.Cron,
|
{Oban.Plugins.Cron,
|
||||||
crontab: [
|
crontab: [
|
||||||
|
{"5 * * * *", Microwaveprop.Workers.PropagationGridWorker},
|
||||||
{"0 8 * * *", Microwaveprop.Workers.SolarIndexWorker},
|
{"0 8 * * *", Microwaveprop.Workers.SolarIndexWorker},
|
||||||
{"*/30 * * * *", Microwaveprop.Workers.QsoWeatherEnqueueWorker}
|
{"*/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
|
# Use local SRTM1 tiles for elevation lookups instead of the Open-Meteo API
|
||||||
config :microwaveprop, srtm_tiles_dir: Path.expand("~/srtm/tiles")
|
config :microwaveprop, srtm_tiles_dir: Path.expand("~/srtm/tiles")
|
||||||
|
|
||||||
# Disable propagation grid worker and freshness monitor in dev to let backfill run
|
# Freshness monitor watches for stale propagation scores
|
||||||
config :microwaveprop, start_freshness_monitor: false
|
config :microwaveprop, start_freshness_monitor: true
|
||||||
|
|
||||||
# Initialize plugs at runtime for faster development compilation
|
# Initialize plugs at runtime for faster development compilation
|
||||||
config :phoenix, :plug_init_mode, :runtime
|
config :phoenix, :plug_init_mode, :runtime
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,12 @@ defmodule Microwaveprop.Application do
|
||||||
# See https://hexdocs.pm/elixir/Supervisor.html
|
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||||
# for other strategies and supported options
|
# for other strategies and supported options
|
||||||
opts = [strategy: :one_for_one, name: Microwaveprop.Supervisor]
|
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
|
end
|
||||||
|
|
||||||
# Tell Phoenix to update the endpoint configuration
|
# Tell Phoenix to update the endpoint configuration
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,44 @@ defmodule Microwaveprop.Propagation do
|
||||||
alias Microwaveprop.Propagation.BandConfig
|
alias Microwaveprop.Propagation.BandConfig
|
||||||
alias Microwaveprop.Propagation.Grid
|
alias Microwaveprop.Propagation.Grid
|
||||||
alias Microwaveprop.Propagation.GridScore
|
alias Microwaveprop.Propagation.GridScore
|
||||||
|
alias Microwaveprop.Propagation.Model
|
||||||
alias Microwaveprop.Propagation.Scorer
|
alias Microwaveprop.Propagation.Scorer
|
||||||
alias Microwaveprop.Repo
|
alias Microwaveprop.Repo
|
||||||
alias Microwaveprop.Weather.SoundingParams
|
alias Microwaveprop.Weather.SoundingParams
|
||||||
|
|
||||||
require Logger
|
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 """
|
@doc """
|
||||||
Score a single grid point across all bands using HRRR profile data.
|
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.
|
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)
|
derived = derive_from_hrrr(hrrr_profile)
|
||||||
|
|
||||||
temp_c = hrrr_profile.surface_temp_c
|
temp_c = hrrr_profile.surface_temp_c
|
||||||
|
|
@ -27,11 +54,21 @@ defmodule Microwaveprop.Propagation do
|
||||||
dewpoint_c < -80 or dewpoint_c > 50 do
|
dewpoint_c < -80 or dewpoint_c > 50 do
|
||||||
[]
|
[]
|
||||||
else
|
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
|
||||||
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)
|
temp_f = Scorer.c_to_f(temp_c)
|
||||||
dewpoint_f = Scorer.c_to_f(dewpoint_c)
|
dewpoint_f = Scorer.c_to_f(dewpoint_c)
|
||||||
|
|
||||||
|
|
@ -59,6 +96,38 @@ defmodule Microwaveprop.Propagation do
|
||||||
end)
|
end)
|
||||||
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 """
|
@doc """
|
||||||
Upsert propagation scores in batches within a transaction so readers see all-or-nothing.
|
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")
|
@models_dir Path.join(:code.priv_dir(:microwaveprop), "models")
|
||||||
@default_path Path.join(@models_dir, "propagation_v1.nx")
|
@default_path Path.join(@models_dir, "propagation_v1.nx")
|
||||||
|
|
||||||
|
def default_path, do: @default_path
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Builds the Axon model graph. Does not initialize parameters.
|
Builds the Axon model graph. Does not initialize parameters.
|
||||||
|
|
||||||
|
|
@ -174,19 +176,16 @@ defmodule Microwaveprop.Propagation.Model do
|
||||||
@doc """
|
@doc """
|
||||||
Predicts a propagation score (0-100) from raw conditions.
|
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].
|
Returns an integer score clamped to [0, 100].
|
||||||
"""
|
"""
|
||||||
def predict_score(params, conditions) do
|
def predict_score(predict_fn, params, conditions) do
|
||||||
features =
|
features =
|
||||||
conditions
|
conditions
|
||||||
|> encode_features()
|
|> encode_features()
|
||||||
|> Nx.tensor(type: :f32)
|
|> Nx.tensor(type: :f32)
|
||||||
|> Nx.reshape({1, @feature_count})
|
|> Nx.reshape({1, @feature_count})
|
||||||
|
|
||||||
model = build()
|
|
||||||
{_init_fn, predict_fn} = Axon.build(model, compiler: EXLA)
|
|
||||||
|
|
||||||
params
|
params
|
||||||
|> predict_fn.(%{"features" => features})
|
|> predict_fn.(%{"features" => features})
|
||||||
|> Nx.squeeze()
|
|> Nx.squeeze()
|
||||||
|
|
@ -198,6 +197,40 @@ defmodule Microwaveprop.Propagation.Model do
|
||||||
|> min(100)
|
|> min(100)
|
||||||
end
|
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 """
|
@doc """
|
||||||
Runs a forward pass with the given parameters and input features.
|
Runs a forward pass with the given parameters and input features.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,17 @@ defmodule Microwaveprop.Radio do
|
||||||
def list_qsos(opts \\ []) do
|
def list_qsos(opts \\ []) do
|
||||||
page = max(Keyword.get(opts, :page, 1), 1)
|
page = max(Keyword.get(opts, :page, 1), 1)
|
||||||
offset = (page - 1) * @per_page
|
offset = (page - 1) * @per_page
|
||||||
|
search = Keyword.get(opts, :search)
|
||||||
|
|
||||||
{sort_field, sort_dir} = sort_opts(opts)
|
{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)
|
total_pages = max(ceil(total_entries / @per_page), 1)
|
||||||
|
|
||||||
entries =
|
entries =
|
||||||
Qso
|
base_query
|
||||||
|> order_by([q], [{^sort_dir, field(q, ^sort_field)}])
|
|> order_by([q], [{^sort_dir, field(q, ^sort_field)}])
|
||||||
|> limit(^@per_page)
|
|> limit(^@per_page)
|
||||||
|> offset(^offset)
|
|> offset(^offset)
|
||||||
|
|
@ -28,12 +31,62 @@ defmodule Microwaveprop.Radio do
|
||||||
|
|
||||||
%{
|
%{
|
||||||
entries: entries,
|
entries: entries,
|
||||||
|
grouped_entries: group_reciprocals(entries),
|
||||||
page: page,
|
page: page,
|
||||||
total_pages: total_pages,
|
total_pages: total_pages,
|
||||||
total_entries: total_entries
|
total_entries: total_entries
|
||||||
}
|
}
|
||||||
end
|
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
|
defp sort_opts(opts) do
|
||||||
sort_by = Keyword.get(opts, :sort_by, :qso_timestamp)
|
sort_by = Keyword.get(opts, :sort_by, :qso_timestamp)
|
||||||
sort_order = Keyword.get(opts, :sort_order, :desc)
|
sort_order = Keyword.get(opts, :sort_order, :desc)
|
||||||
|
|
|
||||||
|
|
@ -158,20 +158,25 @@ defmodule Microwaveprop.Workers.PropagationGridWorker do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp compute_scores(grid_data, valid_time) do
|
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
|
grid_data
|
||||||
|> Task.async_stream(
|
|> Task.async_stream(
|
||||||
fn {{lat, lon}, profile} ->
|
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 ->
|
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)
|
||||||
end,
|
end,
|
||||||
max_concurrency: System.schedulers_online() * 2,
|
max_concurrency: System.schedulers_online() * 2,
|
||||||
|
|
@ -182,4 +187,100 @@ defmodule Microwaveprop.Workers.PropagationGridWorker do
|
||||||
{:exit, _reason} -> []
|
{:exit, _reason} -> []
|
||||||
end)
|
end)
|
||||||
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ defmodule MicrowavepropWeb.Layouts do
|
||||||
default: nil,
|
default: nil,
|
||||||
doc: "the current [scope](https://hexdocs.pm/phoenix/scopes.html)"
|
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
|
slot :inner_block, required: true
|
||||||
|
|
||||||
def app(assigns) do
|
def app(assigns) do
|
||||||
|
|
@ -39,15 +40,9 @@ defmodule MicrowavepropWeb.Layouts do
|
||||||
<div class="flex-1 gap-4">
|
<div class="flex-1 gap-4">
|
||||||
<a href="/" class="font-semibold">Microwaveprop</a>
|
<a href="/" class="font-semibold">Microwaveprop</a>
|
||||||
<nav class="flex gap-2">
|
<nav class="flex gap-2">
|
||||||
<.link navigate="/map" class="btn btn-ghost btn-sm">
|
<.link navigate="/map" class="btn btn-ghost btn-sm">Map</.link>
|
||||||
<.icon name="hero-map" class="size-4" /> Map
|
<.link navigate="/qsos" class="btn btn-ghost btn-sm">QSOs</.link>
|
||||||
</.link>
|
<.link navigate="/submit" class="btn btn-ghost btn-sm">Submit</.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>
|
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-none">
|
<div class="flex-none">
|
||||||
|
|
@ -56,7 +51,7 @@ defmodule MicrowavepropWeb.Layouts do
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="px-4 py-20 sm:px-6 lg:px-8">
|
<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)}
|
{render_slot(@inner_block)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -335,6 +335,9 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
<.link navigate="/submit" class="btn btn-xs btn-ghost justify-start gap-1.5">
|
<.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
|
<.icon name="hero-arrow-up-tray" class="size-3.5" /> Submit a QSO
|
||||||
</.link>
|
</.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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,14 @@ defmodule MicrowavepropWeb.QsoLive.Index do
|
||||||
page = params |> Map.get("page", "1") |> String.to_integer() |> max(1)
|
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_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))
|
sort_order = validate_sort_order(Map.get(params, "sort_order", @default_sort_order))
|
||||||
|
search = Map.get(params, "search", "")
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Radio.list_qsos(
|
Radio.list_qsos(
|
||||||
page: page,
|
page: page,
|
||||||
sort_by: String.to_existing_atom(sort_by),
|
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,
|
{:noreply,
|
||||||
|
|
@ -33,11 +35,18 @@ defmodule MicrowavepropWeb.QsoLive.Index do
|
||||||
total_pages: result.total_pages,
|
total_pages: result.total_pages,
|
||||||
total_entries: result.total_entries,
|
total_entries: result.total_entries,
|
||||||
qsos: result.entries,
|
qsos: result.entries,
|
||||||
|
grouped_qsos: result.grouped_entries,
|
||||||
sort_by: sort_by,
|
sort_by: sort_by,
|
||||||
sort_order: sort_order
|
sort_order: sort_order,
|
||||||
|
search: search
|
||||||
)}
|
)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("search", %{"search" => search}, socket) do
|
||||||
|
{:noreply, push_patch(socket, to: ~p"/qsos?search=#{search}")}
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("sort", %{"field" => field}, socket) do
|
def handle_event("sort", %{"field" => field}, socket) do
|
||||||
field = validate_sort_field(field)
|
field = validate_sort_field(field)
|
||||||
|
|
@ -47,7 +56,9 @@ defmodule MicrowavepropWeb.QsoLive.Index do
|
||||||
do: "desc",
|
do: "desc",
|
||||||
else: "asc"
|
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
|
end
|
||||||
|
|
||||||
defp validate_sort_field(field) when field in @sortable_fields, do: field
|
defp validate_sort_field(field) when field in @sortable_fields, do: field
|
||||||
|
|
@ -59,7 +70,7 @@ defmodule MicrowavepropWeb.QsoLive.Index do
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash}>
|
<Layouts.app flash={@flash} max_width="max-w-7xl">
|
||||||
<.header>
|
<.header>
|
||||||
QSOs
|
QSOs
|
||||||
<:subtitle>{@total_entries} contacts</:subtitle>
|
<:subtitle>{@total_entries} contacts</:subtitle>
|
||||||
|
|
@ -70,33 +81,116 @@ defmodule MicrowavepropWeb.QsoLive.Index do
|
||||||
</:actions>
|
</:actions>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<.table
|
<form phx-submit="search" class="mb-4">
|
||||||
id="qsos"
|
<div class="flex gap-2">
|
||||||
rows={@qsos}
|
<.input
|
||||||
row_id={fn qso -> "qso-#{qso.id}" end}
|
name="search"
|
||||||
row_click={fn qso -> JS.navigate(~p"/qsos/#{qso.id}") end}
|
value={@search}
|
||||||
sort_by={@sort_by}
|
placeholder="Search by callsign..."
|
||||||
sort_order={@sort_order}
|
type="text"
|
||||||
>
|
class="input input-bordered input-sm w-64"
|
||||||
<:col :let={qso} label="Station 1" sort_field="station1">{qso.station1}</:col>
|
/>
|
||||||
<:col :let={qso} label="Grid 1">{qso.grid1 || "—"}</:col>
|
<button type="submit" class="btn btn-sm btn-outline">Search</button>
|
||||||
<:col :let={qso} label="Station 2" sort_field="station2">{qso.station2}</:col>
|
<.link :if={@search != ""} patch={~p"/qsos"} class="btn btn-sm btn-ghost">Clear</.link>
|
||||||
<:col :let={qso} label="Grid 2">{qso.grid2 || "—"}</:col>
|
</div>
|
||||||
<:col :let={qso} label="Band" sort_field="band">{qso.band}</:col>
|
</form>
|
||||||
<: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>
|
<%= if @search != "" do %>
|
||||||
<:col :let={qso} label="Timestamp" sort_field="qso_timestamp">
|
<table class="table table-sm w-full">
|
||||||
{Calendar.strftime(qso.qso_timestamp, "%Y-%m-%d %H:%M")}
|
<thead>
|
||||||
</:col>
|
<tr>
|
||||||
<:col :let={qso} label="Committed" sort_field="inserted_at">
|
<th class="w-8"></th>
|
||||||
{Calendar.strftime(qso.inserted_at, "%Y-%m-%d %H:%M")}
|
<th>Station 1</th>
|
||||||
</:col>
|
<th>Grid 1</th>
|
||||||
</.table>
|
<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">
|
<div class="flex items-center justify-between pt-4">
|
||||||
<.link
|
<.link
|
||||||
:if={@page > 1}
|
: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"
|
class="btn btn-sm btn-outline"
|
||||||
>
|
>
|
||||||
<.icon name="hero-chevron-left" class="w-4 h-4" /> Previous
|
<.icon name="hero-chevron-left" class="w-4 h-4" /> Previous
|
||||||
|
|
@ -107,7 +201,9 @@ defmodule MicrowavepropWeb.QsoLive.Index do
|
||||||
|
|
||||||
<.link
|
<.link
|
||||||
:if={@page < @total_pages}
|
: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"
|
class="btn btn-sm btn-outline"
|
||||||
>
|
>
|
||||||
Next <.icon name="hero-chevron-right" class="w-4 h-4" />
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "predict_score/2" do
|
describe "predict_score/3" do
|
||||||
test "returns integer between 0 and 100" do
|
test "returns integer between 0 and 100" do
|
||||||
params = Model.init()
|
params = Model.init()
|
||||||
|
predict_fn = Model.compile_predict()
|
||||||
|
|
||||||
score =
|
score =
|
||||||
Model.predict_score(params, %{
|
Model.predict_score(predict_fn, params, %{
|
||||||
surface_temp_c: 25.0,
|
surface_temp_c: 25.0,
|
||||||
surface_dewpoint_c: 15.0,
|
surface_dewpoint_c: 15.0,
|
||||||
surface_pressure_mb: 1015.0,
|
surface_pressure_mb: 1015.0,
|
||||||
|
|
@ -179,9 +180,10 @@ defmodule Microwaveprop.Propagation.ModelTest do
|
||||||
|
|
||||||
test "returns different scores for different conditions" do
|
test "returns different scores for different conditions" do
|
||||||
params = Model.init()
|
params = Model.init()
|
||||||
|
predict_fn = Model.compile_predict()
|
||||||
|
|
||||||
score_hot =
|
score_hot =
|
||||||
Model.predict_score(params, %{
|
Model.predict_score(predict_fn, params, %{
|
||||||
surface_temp_c: 35.0,
|
surface_temp_c: 35.0,
|
||||||
surface_dewpoint_c: 25.0,
|
surface_dewpoint_c: 25.0,
|
||||||
surface_pressure_mb: 1010.0,
|
surface_pressure_mb: 1010.0,
|
||||||
|
|
@ -194,7 +196,7 @@ defmodule Microwaveprop.Propagation.ModelTest do
|
||||||
})
|
})
|
||||||
|
|
||||||
score_cold =
|
score_cold =
|
||||||
Model.predict_score(params, %{
|
Model.predict_score(predict_fn, params, %{
|
||||||
surface_temp_c: -10.0,
|
surface_temp_c: -10.0,
|
||||||
surface_dewpoint_c: -20.0,
|
surface_dewpoint_c: -20.0,
|
||||||
surface_pressure_mb: 1030.0,
|
surface_pressure_mb: 1030.0,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ defmodule Microwaveprop.PropagationTest do
|
||||||
alias Microwaveprop.Propagation
|
alias Microwaveprop.Propagation
|
||||||
alias Microwaveprop.Propagation.GridScore
|
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
|
test "scores a single point for all 8 bands" do
|
||||||
hrrr_profile = %{
|
hrrr_profile = %{
|
||||||
surface_temp_c: 25.0,
|
surface_temp_c: 25.0,
|
||||||
|
|
@ -23,13 +23,13 @@ defmodule Microwaveprop.PropagationTest do
|
||||||
}
|
}
|
||||||
|
|
||||||
valid_time = ~U[2026-07-15 13:00:00Z]
|
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
|
assert length(results) == 8
|
||||||
|
|
||||||
Enum.each(results, fn result ->
|
Enum.each(results, fn result ->
|
||||||
assert result.score >= 0 and result.score <= 100
|
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)
|
assert is_integer(result.band_mhz)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue