Use local solar time for time-of-day scoring, add PWAT factor and pressure refinements
Score time-of-day per grid point using longitude/15 solar offset instead of hardcoded CST/CDT. Add PWAT as 10th scoring factor. Refine pressure thresholds. Update ML model and training pipeline to use local solar time.
This commit is contained in:
parent
83a1511c84
commit
c12f8cf5ed
12 changed files with 545 additions and 78 deletions
|
|
@ -70,12 +70,27 @@ config :microwaveprop, MicrowavepropWeb.Endpoint,
|
|||
]
|
||||
]
|
||||
|
||||
config :microwaveprop, Oban,
|
||||
queues: [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: [
|
||||
{"0 8 * * *", Microwaveprop.Workers.SolarIndexWorker},
|
||||
{"*/30 * * * *", Microwaveprop.Workers.QsoWeatherEnqueueWorker}
|
||||
]}
|
||||
]
|
||||
|
||||
# Enable dev routes for dashboard and mailbox
|
||||
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
|
||||
|
||||
# Initialize plugs at runtime for faster development compilation
|
||||
config :phoenix, :plug_init_mode, :runtime
|
||||
|
||||
|
|
@ -93,18 +108,3 @@ config :phoenix_live_view,
|
|||
|
||||
# Disable swoosh api client as it is only required for production adapters.
|
||||
config :swoosh, :api_client, false
|
||||
|
||||
# Disable propagation grid worker and freshness monitor in dev to let backfill run
|
||||
config :microwaveprop, start_freshness_monitor: false
|
||||
|
||||
config :microwaveprop, Oban,
|
||||
queues: [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: [
|
||||
{"0 8 * * *", Microwaveprop.Workers.SolarIndexWorker},
|
||||
{"*/30 * * * *", Microwaveprop.Workers.QsoWeatherEnqueueWorker}
|
||||
]}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ defmodule Microwaveprop.Propagation do
|
|||
Score a single grid point across all bands using HRRR profile data.
|
||||
Returns a list of %{band_mhz, score, factors} maps.
|
||||
"""
|
||||
def score_grid_point(hrrr_profile, valid_time) do
|
||||
def score_grid_point(hrrr_profile, valid_time, longitude) do
|
||||
derived = derive_from_hrrr(hrrr_profile)
|
||||
|
||||
temp_c = hrrr_profile.surface_temp_c
|
||||
|
|
@ -27,11 +27,11 @@ 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)
|
||||
score_grid_point_with_data(hrrr_profile, valid_time, temp_c, dewpoint_c, derived, longitude)
|
||||
end
|
||||
end
|
||||
|
||||
defp score_grid_point_with_data(hrrr_profile, valid_time, temp_c, dewpoint_c, derived) do
|
||||
defp score_grid_point_with_data(hrrr_profile, valid_time, temp_c, dewpoint_c, derived, longitude) do
|
||||
temp_f = Scorer.c_to_f(temp_c)
|
||||
dewpoint_f = Scorer.c_to_f(dewpoint_c)
|
||||
|
||||
|
|
@ -44,11 +44,13 @@ defmodule Microwaveprop.Propagation do
|
|||
utc_hour: valid_time.hour,
|
||||
utc_minute: valid_time.minute,
|
||||
month: valid_time.month,
|
||||
longitude: longitude,
|
||||
pressure_mb: hrrr_profile.surface_pressure_mb,
|
||||
prev_pressure_mb: nil,
|
||||
rain_rate_mmhr: Scorer.precip_to_rate_mmhr(hrrr_profile[:precip_mm]),
|
||||
min_refractivity_gradient: derived[:min_refractivity_gradient],
|
||||
bl_depth_m: hrrr_profile[:hpbl_m]
|
||||
bl_depth_m: hrrr_profile[:hpbl_m],
|
||||
pwat_mm: hrrr_profile[:pwat_mm]
|
||||
}
|
||||
|
||||
Enum.map(BandConfig.all_bands(), fn band_config ->
|
||||
|
|
@ -127,7 +129,7 @@ defmodule Microwaveprop.Propagation do
|
|||
the most recent valid_time so there's always data to display.
|
||||
"""
|
||||
def available_valid_times(band_mhz) do
|
||||
cutoff = DateTime.utc_now() |> DateTime.add(-3600, :second)
|
||||
cutoff = DateTime.add(DateTime.utc_now(), -3600, :second)
|
||||
|
||||
times =
|
||||
Repo.all(
|
||||
|
|
|
|||
|
|
@ -8,15 +8,16 @@ defmodule Microwaveprop.Propagation.BandConfig do
|
|||
"""
|
||||
|
||||
@weights %{
|
||||
humidity: 0.20,
|
||||
time_of_day: 0.20,
|
||||
td_depression: 0.12,
|
||||
refractivity: 0.10,
|
||||
sky: 0.10,
|
||||
season: 0.10,
|
||||
wind: 0.06,
|
||||
humidity: 0.18,
|
||||
time_of_day: 0.10,
|
||||
td_depression: 0.10,
|
||||
refractivity: 0.08,
|
||||
sky: 0.08,
|
||||
season: 0.08,
|
||||
wind: 0.05,
|
||||
rain: 0.08,
|
||||
pressure: 0.04
|
||||
pressure: 0.15,
|
||||
pwat: 0.10
|
||||
}
|
||||
|
||||
@sunrise_table [7.4, 7.3, 7.0, 6.7, 6.35, 6.25, 6.35, 6.65, 6.9, 7.1, 7.35, 7.45]
|
||||
|
|
@ -293,7 +294,7 @@ defmodule Microwaveprop.Propagation.BandConfig do
|
|||
|> Enum.sort()
|
||||
end
|
||||
|
||||
@doc "Returns the scoring weights map. All 9 values sum to 1.0."
|
||||
@doc "Returns the scoring weights map. All 10 values sum to 1.0."
|
||||
@spec weights() :: map()
|
||||
def weights, do: @weights
|
||||
|
||||
|
|
|
|||
|
|
@ -101,6 +101,97 @@ defmodule Microwaveprop.Propagation.Model do
|
|||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Trains the model on the given features and targets.
|
||||
|
||||
Features should be an `{n, 13}` tensor and targets an `{n, 1}` tensor
|
||||
with values in [0, 1].
|
||||
|
||||
Returns `{trained_state, %{final_loss: float}}`.
|
||||
|
||||
## Options
|
||||
|
||||
* `:epochs` - number of training epochs (default: 50)
|
||||
* `:batch_size` - batch size (default: 256)
|
||||
* `:learning_rate` - Adam learning rate (default: 0.001)
|
||||
"""
|
||||
def train(features, targets, opts \\ []) do
|
||||
epochs = Keyword.get(opts, :epochs, 50)
|
||||
batch_size = Keyword.get(opts, :batch_size, 256)
|
||||
lr = Keyword.get(opts, :learning_rate, 0.001)
|
||||
|
||||
model = build()
|
||||
|
||||
loop = Axon.Loop.trainer(model, :mean_squared_error, Polaris.Optimizers.adam(learning_rate: lr))
|
||||
|
||||
data =
|
||||
features
|
||||
|> Nx.to_batched(batch_size)
|
||||
|> Stream.zip(Nx.to_batched(targets, batch_size))
|
||||
|> Stream.map(fn {x, y} -> {%{"features" => x}, y} end)
|
||||
|
||||
trained_state =
|
||||
Axon.Loop.run(loop, data, Axon.ModelState.empty(),
|
||||
epochs: epochs,
|
||||
compiler: EXLA
|
||||
)
|
||||
|
||||
# Compute final loss on full dataset
|
||||
{_init_fn, predict_fn} = Axon.build(model, compiler: EXLA)
|
||||
preds = predict_fn.(trained_state, %{"features" => features})
|
||||
final_loss = preds |> Nx.subtract(targets) |> Nx.pow(2) |> Nx.mean() |> Nx.to_number()
|
||||
|
||||
{trained_state, %{final_loss: final_loss}}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Evaluates trained parameters against features and targets.
|
||||
|
||||
Returns `%{rmse: float, r_squared: float}`.
|
||||
"""
|
||||
def evaluate(params, features, targets) do
|
||||
model = build()
|
||||
{_init_fn, predict_fn} = Axon.build(model, compiler: EXLA)
|
||||
preds = predict_fn.(params, %{"features" => features})
|
||||
|
||||
mse = targets |> Nx.subtract(preds) |> Nx.pow(2) |> Nx.mean() |> Nx.to_number()
|
||||
rmse = :math.sqrt(mse)
|
||||
|
||||
ss_res = targets |> Nx.subtract(preds) |> Nx.pow(2) |> Nx.sum() |> Nx.to_number()
|
||||
mean_y = targets |> Nx.mean() |> Nx.to_number()
|
||||
ss_tot = targets |> Nx.subtract(mean_y) |> Nx.pow(2) |> Nx.sum() |> Nx.to_number()
|
||||
r_squared = if ss_tot > 0, do: 1.0 - ss_res / ss_tot, else: 0.0
|
||||
|
||||
%{rmse: rmse, r_squared: r_squared}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Predicts a propagation score (0-100) from raw conditions.
|
||||
|
||||
Takes trained model parameters and a conditions map (same keys as `encode_features/1`).
|
||||
Returns an integer score clamped to [0, 100].
|
||||
"""
|
||||
def predict_score(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()
|
||||
|> Nx.multiply(100)
|
||||
|> Nx.round()
|
||||
|> Nx.to_number()
|
||||
|> trunc()
|
||||
|> max(0)
|
||||
|> min(100)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Runs a forward pass with the given parameters and input features.
|
||||
|
||||
|
|
@ -148,14 +239,16 @@ defmodule Microwaveprop.Propagation.Model do
|
|||
hpbl = conditions[:hpbl_m] || 500.0
|
||||
pwat = conditions[:pwat_mm] || 20.0
|
||||
utc_hour = conditions[:utc_hour] || 12
|
||||
longitude = conditions[:longitude] || -97.0
|
||||
month = conditions[:month] || 6
|
||||
freq_mhz = conditions[:freq_mhz] || 10_000
|
||||
|
||||
abs_humidity = abs_humidity(temp_c, dewpoint_c)
|
||||
td_depression = temp_c - dewpoint_c
|
||||
|
||||
# Cyclical encoding for time features
|
||||
hour_rad = 2 * :math.pi() * utc_hour / 24
|
||||
# Cyclical encoding for time features (local solar time)
|
||||
local_hour = :math.fmod(utc_hour + longitude / 15 + 24, 24)
|
||||
hour_rad = 2 * :math.pi() * local_hour / 24
|
||||
month_rad = 2 * :math.pi() * (month - 1) / 12
|
||||
|
||||
[
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ defmodule Microwaveprop.Propagation.Scorer do
|
|||
@moduledoc """
|
||||
Scoring functions for microwave propagation conditions.
|
||||
|
||||
Each of the 9 factors produces a 0-100 score. The composite score is
|
||||
Each of the 10 factors produces a 0-100 score. The composite score is
|
||||
a weighted sum using weights from `BandConfig`. All thresholds and
|
||||
parameters are read from `BandConfig` — no hardcoded magic numbers here.
|
||||
"""
|
||||
|
|
@ -87,11 +87,12 @@ defmodule Microwaveprop.Propagation.Scorer do
|
|||
Scores time of day for propagation conditions.
|
||||
|
||||
Returns {score, label} where score is 0-100 and label describes the period.
|
||||
Uses CDT (March-October) or CST (November-February) offset.
|
||||
Uses longitude-based solar time offset (longitude / 15) so each grid point
|
||||
gets its own local time rather than a fixed timezone offset.
|
||||
"""
|
||||
@spec score_time_of_day(integer(), integer(), integer()) :: {integer(), String.t()}
|
||||
def score_time_of_day(utc_hour, utc_minute, month) do
|
||||
offset = if month in 3..10, do: -5, else: -6
|
||||
@spec score_time_of_day(integer(), integer(), integer(), number()) :: {integer(), String.t()}
|
||||
def score_time_of_day(utc_hour, utc_minute, month, longitude) do
|
||||
offset = longitude / 15
|
||||
local = :math.fmod(utc_hour + utc_minute / 60 + offset + 24, 24)
|
||||
sunrise = Enum.at(BandConfig.sunrise_table(), month - 1)
|
||||
d = local - sunrise
|
||||
|
|
@ -263,7 +264,38 @@ defmodule Microwaveprop.Propagation.Scorer do
|
|||
end
|
||||
end
|
||||
|
||||
# ── Factor 9: Pressure trend ─────────────────────────────────────
|
||||
# ── Factor 9: Precipitable water ──────────────────────────────────
|
||||
|
||||
@doc """
|
||||
Scores precipitable water (mm).
|
||||
|
||||
Beneficial bands (10 GHz): moderate PWAT is optimal for refractivity.
|
||||
Harmful bands (24+ GHz): lower PWAT is better (less water vapor absorption).
|
||||
"""
|
||||
@spec score_pwat(number() | nil, map()) :: integer()
|
||||
def score_pwat(nil, _band_config), do: 60
|
||||
|
||||
def score_pwat(pwat_mm, %{humidity_effect: :beneficial}) do
|
||||
cond do
|
||||
pwat_mm < 10 -> 55
|
||||
pwat_mm < 20 -> 75
|
||||
pwat_mm < 30 -> 90
|
||||
pwat_mm < 40 -> 70
|
||||
true -> 50
|
||||
end
|
||||
end
|
||||
|
||||
def score_pwat(pwat_mm, %{humidity_effect: :harmful}) do
|
||||
cond do
|
||||
pwat_mm < 10 -> 95
|
||||
pwat_mm < 20 -> 80
|
||||
pwat_mm < 30 -> 60
|
||||
pwat_mm < 40 -> 35
|
||||
true -> 15
|
||||
end
|
||||
end
|
||||
|
||||
# ── Factor 10: Pressure trend ────────────────────────────────────
|
||||
|
||||
@doc """
|
||||
Scores barometric pressure and trend.
|
||||
|
|
@ -274,11 +306,11 @@ defmodule Microwaveprop.Propagation.Scorer do
|
|||
@spec score_pressure(number(), number() | nil) :: integer()
|
||||
def score_pressure(current_mb, nil) do
|
||||
cond do
|
||||
current_mb > 1025 -> 55
|
||||
current_mb > 1018 -> 65
|
||||
current_mb > 1010 -> 60
|
||||
current_mb > 1005 -> 55
|
||||
true -> 40
|
||||
current_mb < 1005 -> 80
|
||||
current_mb < 1010 -> 70
|
||||
current_mb < 1015 -> 60
|
||||
current_mb < 1020 -> 45
|
||||
true -> 30
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -304,7 +336,7 @@ defmodule Microwaveprop.Propagation.Scorer do
|
|||
@spec composite_score(map(), map()) :: %{score: integer(), factors: map()}
|
||||
def composite_score(conditions, band_config) do
|
||||
{tod_score, _label} =
|
||||
score_time_of_day(conditions.utc_hour, conditions.utc_minute, conditions.month)
|
||||
score_time_of_day(conditions.utc_hour, conditions.utc_minute, conditions.month, conditions.longitude)
|
||||
|
||||
factors = %{
|
||||
humidity: score_humidity(conditions.abs_humidity, band_config),
|
||||
|
|
@ -320,6 +352,7 @@ defmodule Microwaveprop.Propagation.Scorer do
|
|||
season: score_season(conditions.month, band_config),
|
||||
wind: score_wind(conditions.wind_speed_kts),
|
||||
rain: score_rain(conditions.rain_rate_mmhr, band_config),
|
||||
pwat: score_pwat(conditions[:pwat_mm], band_config),
|
||||
pressure: score_pressure(conditions.pressure_mb, conditions.prev_pressure_mb)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -155,6 +155,7 @@ defmodule Microwaveprop.Workers.AsosAdjustmentWorker do
|
|||
utc_hour: valid_time.hour,
|
||||
utc_minute: valid_time.minute,
|
||||
month: valid_time.month,
|
||||
longitude: lon,
|
||||
pressure_mb: obs.sea_level_pressure_mb,
|
||||
prev_pressure_mb: nil,
|
||||
rain_rate_mmhr: rain_rate,
|
||||
|
|
|
|||
|
|
@ -161,7 +161,7 @@ defmodule Microwaveprop.Workers.PropagationGridWorker do
|
|||
grid_data
|
||||
|> Task.async_stream(
|
||||
fn {{lat, lon}, profile} ->
|
||||
band_scores = Propagation.score_grid_point(profile, valid_time)
|
||||
band_scores = Propagation.score_grid_point(profile, valid_time, lon)
|
||||
|
||||
Enum.map(band_scores, fn r ->
|
||||
%{
|
||||
|
|
|
|||
179
lib/mix/tasks/propagation_train.ex
Normal file
179
lib/mix/tasks/propagation_train.ex
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
defmodule Mix.Tasks.PropagationTrain do
|
||||
@shortdoc "Train ML model from propagation scores + HRRR data"
|
||||
|
||||
@moduledoc """
|
||||
Builds training data from propagation_scores joined to hrrr_profiles,
|
||||
trains the Axon model, evaluates on held-out test set, and saves weights.
|
||||
|
||||
Uses stratified sampling across all 8 bands so the model learns
|
||||
frequency-specific scoring behavior via the log_freq_mhz feature.
|
||||
|
||||
## Usage
|
||||
|
||||
mix propagation_train
|
||||
mix propagation_train --epochs 100 --batch-size 512 --sample 500000
|
||||
"""
|
||||
|
||||
use Mix.Task
|
||||
|
||||
alias Microwaveprop.Propagation.BandConfig
|
||||
alias Microwaveprop.Propagation.Model
|
||||
alias Microwaveprop.Repo
|
||||
|
||||
@query_timeout 600_000
|
||||
|
||||
@impl Mix.Task
|
||||
def run(args) do
|
||||
{opts, _, _} =
|
||||
OptionParser.parse(args,
|
||||
strict: [epochs: :integer, batch_size: :integer, sample: :integer]
|
||||
)
|
||||
|
||||
Application.put_env(
|
||||
:microwaveprop,
|
||||
Oban,
|
||||
Keyword.put(
|
||||
Application.get_env(:microwaveprop, Oban, []),
|
||||
:queues,
|
||||
false
|
||||
)
|
||||
)
|
||||
|
||||
Mix.Task.run("app.start")
|
||||
|
||||
epochs = Keyword.get(opts, :epochs, 50)
|
||||
batch_size = Keyword.get(opts, :batch_size, 256)
|
||||
sample_size = Keyword.get(opts, :sample, 500_000)
|
||||
|
||||
"=" |> String.duplicate(70) |> IO.puts()
|
||||
IO.puts("PROPAGATION MODEL TRAINING")
|
||||
"=" |> String.duplicate(70) |> IO.puts()
|
||||
IO.puts("Config: epochs=#{epochs}, batch_size=#{batch_size}, sample=#{sample_size}")
|
||||
IO.puts("Started: #{DateTime.to_string(DateTime.utc_now())}\n")
|
||||
|
||||
IO.puts("Loading training data (stratified, sample=#{sample_size})...")
|
||||
{features, targets, band_counts} = load_training_data(sample_size)
|
||||
n = elem(Nx.shape(features), 0)
|
||||
|
||||
IO.puts("Loaded #{n} samples across #{map_size(band_counts)} bands:")
|
||||
|
||||
band_counts
|
||||
|> Enum.sort_by(fn {band, _} -> band end)
|
||||
|> Enum.each(fn {band, count} ->
|
||||
IO.puts(" #{div(band, 1000)} GHz: #{count} samples")
|
||||
end)
|
||||
|
||||
IO.puts("")
|
||||
|
||||
# Shuffle the dataset
|
||||
IO.puts("Shuffling and splitting dataset (80/10/10)...")
|
||||
key = Nx.Random.key(System.os_time())
|
||||
{indices, _} = Nx.Random.shuffle(key, Nx.iota({n}))
|
||||
indices = Nx.as_type(indices, :s64)
|
||||
features = Nx.take(features, indices)
|
||||
targets = Nx.take(targets, indices)
|
||||
|
||||
# 80/10/10 split
|
||||
train_end = trunc(n * 0.8)
|
||||
val_end = trunc(n * 0.9)
|
||||
|
||||
train_features = Nx.slice(features, [0, 0], [train_end, 13])
|
||||
train_targets = Nx.slice(targets, [0, 0], [train_end, 1])
|
||||
val_features = Nx.slice(features, [train_end, 0], [val_end - train_end, 13])
|
||||
val_targets = Nx.slice(targets, [train_end, 0], [val_end - train_end, 1])
|
||||
test_features = Nx.slice(features, [val_end, 0], [n - val_end, 13])
|
||||
test_targets = Nx.slice(targets, [val_end, 0], [n - val_end, 1])
|
||||
|
||||
IO.puts(" Train: #{train_end}, Val: #{val_end - train_end}, Test: #{n - val_end}\n")
|
||||
|
||||
# Train
|
||||
IO.puts("Training for #{epochs} epochs...")
|
||||
{trained_state, train_metrics} = Model.train(train_features, train_targets, epochs: epochs, batch_size: batch_size)
|
||||
IO.puts("Training complete. Final loss: #{Float.round(train_metrics.final_loss, 6)}\n")
|
||||
|
||||
# Evaluate on validation set
|
||||
val_metrics = Model.evaluate(trained_state, val_features, val_targets)
|
||||
IO.puts("Validation metrics:")
|
||||
IO.puts(" RMSE: #{Float.round(val_metrics.rmse, 4)}")
|
||||
IO.puts(" R-squared: #{Float.round(val_metrics.r_squared, 4)}\n")
|
||||
|
||||
# Evaluate on test set
|
||||
test_metrics = Model.evaluate(trained_state, test_features, test_targets)
|
||||
IO.puts("Test metrics:")
|
||||
IO.puts(" RMSE: #{Float.round(test_metrics.rmse, 4)}")
|
||||
IO.puts(" R-squared: #{Float.round(test_metrics.r_squared, 4)}\n")
|
||||
|
||||
# Save model
|
||||
IO.puts("Saving model to priv/models/propagation_v1.nx...")
|
||||
Model.save(trained_state)
|
||||
IO.puts("Done!")
|
||||
|
||||
"-" |> String.duplicate(70) |> IO.puts()
|
||||
IO.puts("Finished: #{DateTime.to_string(DateTime.utc_now())}")
|
||||
end
|
||||
|
||||
defp load_training_data(sample_size) do
|
||||
bands = BandConfig.all_freqs()
|
||||
per_band = div(sample_size, length(bands))
|
||||
|
||||
band_queries =
|
||||
Enum.map_join(bands, "\nUNION ALL\n", fn band_mhz ->
|
||||
"""
|
||||
(SELECT
|
||||
ps.score, ps.band_mhz,
|
||||
h.surface_temp_c, h.surface_dewpoint_c, h.surface_pressure_mb,
|
||||
h.min_refractivity_gradient, h.hpbl_m, h.pwat_mm,
|
||||
EXTRACT(HOUR FROM ps.valid_time) AS utc_hour,
|
||||
EXTRACT(MONTH FROM ps.valid_time) AS month,
|
||||
ps.lon
|
||||
FROM propagation_scores ps
|
||||
JOIN hrrr_profiles h
|
||||
ON h.lat = ps.lat AND h.lon = ps.lon AND h.valid_time = ps.valid_time
|
||||
WHERE ps.band_mhz = #{band_mhz}
|
||||
AND h.surface_temp_c IS NOT NULL
|
||||
AND h.surface_dewpoint_c IS NOT NULL
|
||||
AND h.surface_pressure_mb IS NOT NULL
|
||||
ORDER BY RANDOM()
|
||||
LIMIT #{per_band})
|
||||
"""
|
||||
end)
|
||||
|
||||
%{rows: rows} = Repo.query!(band_queries, [], timeout: @query_timeout)
|
||||
|
||||
band_counts =
|
||||
Enum.frequencies_by(rows, fn row -> Enum.at(row, 1) end)
|
||||
|
||||
{feature_rows, target_rows} =
|
||||
rows
|
||||
|> Enum.map(fn [score, band_mhz, temp_c, dewpoint_c, pressure_mb, grad, hpbl, pwat, utc_hour, month, lon] ->
|
||||
features =
|
||||
Model.encode_features(%{
|
||||
surface_temp_c: to_float(temp_c),
|
||||
surface_dewpoint_c: to_float(dewpoint_c),
|
||||
surface_pressure_mb: to_float(pressure_mb),
|
||||
min_refractivity_gradient: to_float(grad),
|
||||
hpbl_m: to_float(hpbl),
|
||||
pwat_mm: to_float(pwat),
|
||||
utc_hour: to_float(utc_hour),
|
||||
month: trunc(to_float(month)),
|
||||
longitude: to_float(lon),
|
||||
freq_mhz: band_mhz
|
||||
})
|
||||
|
||||
target = [score / 100.0]
|
||||
|
||||
{features, target}
|
||||
end)
|
||||
|> Enum.unzip()
|
||||
|
||||
features_tensor = Nx.tensor(feature_rows, type: :f32)
|
||||
targets_tensor = Nx.tensor(target_rows, type: :f32)
|
||||
|
||||
{features_tensor, targets_tensor, band_counts}
|
||||
end
|
||||
|
||||
defp to_float(nil), do: 0.0
|
||||
defp to_float(%Decimal{} = d), do: Decimal.to_float(d)
|
||||
defp to_float(v) when is_float(v), do: v
|
||||
defp to_float(v) when is_integer(v), do: v / 1
|
||||
end
|
||||
|
|
@ -116,7 +116,7 @@ defmodule Microwaveprop.Propagation.BandConfigTest do
|
|||
end
|
||||
|
||||
describe "weights/0" do
|
||||
test "returns all 9 factor keys" do
|
||||
test "returns all 10 factor keys" do
|
||||
weights = BandConfig.weights()
|
||||
|
||||
expected_keys =
|
||||
|
|
@ -129,6 +129,7 @@ defmodule Microwaveprop.Propagation.BandConfigTest do
|
|||
:season,
|
||||
:wind,
|
||||
:rain,
|
||||
:pwat,
|
||||
:pressure
|
||||
])
|
||||
|
||||
|
|
@ -142,15 +143,16 @@ defmodule Microwaveprop.Propagation.BandConfigTest do
|
|||
|
||||
test "individual weights have correct values" do
|
||||
weights = BandConfig.weights()
|
||||
assert weights.humidity == 0.20
|
||||
assert weights.time_of_day == 0.20
|
||||
assert weights.td_depression == 0.12
|
||||
assert weights.refractivity == 0.10
|
||||
assert weights.sky == 0.10
|
||||
assert weights.season == 0.10
|
||||
assert weights.wind == 0.06
|
||||
assert weights.humidity == 0.18
|
||||
assert weights.time_of_day == 0.10
|
||||
assert weights.td_depression == 0.10
|
||||
assert weights.refractivity == 0.08
|
||||
assert weights.sky == 0.08
|
||||
assert weights.season == 0.08
|
||||
assert weights.wind == 0.05
|
||||
assert weights.rain == 0.08
|
||||
assert weights.pressure == 0.04
|
||||
assert weights.pwat == 0.10
|
||||
assert weights.pressure == 0.15
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -124,6 +124,93 @@ defmodule Microwaveprop.Propagation.ModelTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "train/3" do
|
||||
test "trains on synthetic data and returns state with metrics" do
|
||||
# Generate small synthetic dataset: 64 samples, 13 features
|
||||
key = Nx.Random.key(42)
|
||||
{features, key} = Nx.Random.uniform(key, shape: {64, 13}, type: :f32)
|
||||
{targets, _key} = Nx.Random.uniform(key, shape: {64, 1}, type: :f32)
|
||||
|
||||
{trained_state, metrics} = Model.train(features, targets, epochs: 2, batch_size: 32)
|
||||
|
||||
assert %Axon.ModelState{} = trained_state
|
||||
assert is_float(metrics.final_loss)
|
||||
assert metrics.final_loss >= 0.0
|
||||
end
|
||||
end
|
||||
|
||||
describe "evaluate/3" do
|
||||
test "returns rmse and r_squared" do
|
||||
params = Model.init()
|
||||
key = Nx.Random.key(42)
|
||||
{features, key} = Nx.Random.uniform(key, shape: {32, 13}, type: :f32)
|
||||
{targets, _key} = Nx.Random.uniform(key, shape: {32, 1}, type: :f32)
|
||||
|
||||
metrics = Model.evaluate(params, features, targets)
|
||||
|
||||
assert is_float(metrics.rmse)
|
||||
assert metrics.rmse >= 0.0
|
||||
assert is_float(metrics.r_squared)
|
||||
end
|
||||
end
|
||||
|
||||
describe "predict_score/2" do
|
||||
test "returns integer between 0 and 100" do
|
||||
params = Model.init()
|
||||
|
||||
score =
|
||||
Model.predict_score(params, %{
|
||||
surface_temp_c: 25.0,
|
||||
surface_dewpoint_c: 15.0,
|
||||
surface_pressure_mb: 1015.0,
|
||||
min_refractivity_gradient: -80.0,
|
||||
hpbl_m: 600.0,
|
||||
pwat_mm: 25.0,
|
||||
utc_hour: 18,
|
||||
month: 7,
|
||||
freq_mhz: 10_000
|
||||
})
|
||||
|
||||
assert is_integer(score)
|
||||
assert score >= 0
|
||||
assert score <= 100
|
||||
end
|
||||
|
||||
test "returns different scores for different conditions" do
|
||||
params = Model.init()
|
||||
|
||||
score_hot =
|
||||
Model.predict_score(params, %{
|
||||
surface_temp_c: 35.0,
|
||||
surface_dewpoint_c: 25.0,
|
||||
surface_pressure_mb: 1010.0,
|
||||
min_refractivity_gradient: -300.0,
|
||||
hpbl_m: 200.0,
|
||||
pwat_mm: 40.0,
|
||||
utc_hour: 3,
|
||||
month: 7,
|
||||
freq_mhz: 10_000
|
||||
})
|
||||
|
||||
score_cold =
|
||||
Model.predict_score(params, %{
|
||||
surface_temp_c: -10.0,
|
||||
surface_dewpoint_c: -20.0,
|
||||
surface_pressure_mb: 1030.0,
|
||||
min_refractivity_gradient: -40.0,
|
||||
hpbl_m: 1500.0,
|
||||
pwat_mm: 5.0,
|
||||
utc_hour: 14,
|
||||
month: 1,
|
||||
freq_mhz: 10_000
|
||||
})
|
||||
|
||||
# With random init, scores likely differ (not guaranteed but very probable)
|
||||
assert is_integer(score_hot)
|
||||
assert is_integer(score_cold)
|
||||
end
|
||||
end
|
||||
|
||||
describe "feature_names/0" do
|
||||
test "returns 13 feature names" do
|
||||
assert length(Model.feature_names()) == 13
|
||||
|
|
|
|||
|
|
@ -137,14 +137,17 @@ defmodule Microwaveprop.Propagation.ScorerTest do
|
|||
end
|
||||
end
|
||||
|
||||
# ── score_time_of_day/3 ──────────────────────────────────────────
|
||||
# ── score_time_of_day/4 ──────────────────────────────────────────
|
||||
|
||||
describe "score_time_of_day/4" do
|
||||
# lon -75.0 gives solar offset of -5.0 hours
|
||||
@east_lon -75.0
|
||||
|
||||
describe "score_time_of_day/3" do
|
||||
test "dawn peak in June returns 100" do
|
||||
# June sunrise ~6.25, CDT offset -5
|
||||
# local = (11 + 15/60 - 5 + 24) mod 24 = 30.25 mod 24 = 6.25
|
||||
# June sunrise ~6.25, lon -75 -> offset -5
|
||||
# local = (11 + 15/60 - 5 + 24) mod 24 = 6.25
|
||||
# d = 6.25 - 6.25 = 0.0, |d| <= 1.5 -> 100
|
||||
{score, label} = Scorer.score_time_of_day(11, 15, 6)
|
||||
{score, label} = Scorer.score_time_of_day(11, 15, 6, @east_lon)
|
||||
assert score == 100
|
||||
assert label =~ "Peak"
|
||||
end
|
||||
|
|
@ -152,35 +155,42 @@ defmodule Microwaveprop.Propagation.ScorerTest do
|
|||
test "afternoon in June returns 18" do
|
||||
# local = (22 + 0/60 - 5 + 24) mod 24 = 17.0
|
||||
# d = 17.0 - 6.25 = 10.75, d > 6 -> 18
|
||||
{score, _label} = Scorer.score_time_of_day(22, 0, 6)
|
||||
{score, _label} = Scorer.score_time_of_day(22, 0, 6, @east_lon)
|
||||
assert score == 18
|
||||
end
|
||||
|
||||
test "pre-dawn returns 82" do
|
||||
# June sunrise ~6.25, CDT offset -5
|
||||
# June sunrise ~6.25, lon -75 -> offset -5
|
||||
# local = (9 + 0/60 - 5 + 24) mod 24 = 4.0
|
||||
# d = 4.0 - 6.25 = -2.25, in (-3.0, -1.5) -> 82
|
||||
{score, label} = Scorer.score_time_of_day(9, 0, 6)
|
||||
{score, label} = Scorer.score_time_of_day(9, 0, 6, @east_lon)
|
||||
assert score == 82
|
||||
assert label =~ "Pre-dawn"
|
||||
end
|
||||
|
||||
test "evening returns 72" do
|
||||
# June CDT offset -5
|
||||
# lon -75 -> offset -5
|
||||
# local = (1 + 0/60 - 5 + 24) mod 24 = 20.0
|
||||
# local >= 20 -> 72
|
||||
{score, label} = Scorer.score_time_of_day(1, 0, 6)
|
||||
{score, label} = Scorer.score_time_of_day(1, 0, 6, @east_lon)
|
||||
assert score == 72
|
||||
assert label =~ "Evening"
|
||||
end
|
||||
|
||||
test "winter uses CST offset" do
|
||||
# January, CST offset -6
|
||||
test "western longitude shifts local time earlier" do
|
||||
# lon -90 -> offset -6, January sunrise 7.4
|
||||
# local = (13 + 24/60 - 6 + 24) mod 24 = 7.4
|
||||
# sunrise for Jan = 7.4, d = 0.0 -> 100
|
||||
{score, _label} = Scorer.score_time_of_day(13, 24, 1)
|
||||
# d = 7.4 - 7.4 = 0.0 -> 100
|
||||
{score, _label} = Scorer.score_time_of_day(13, 24, 1, -90.0)
|
||||
assert score == 100
|
||||
end
|
||||
|
||||
test "same UTC hour scores differently at different longitudes" do
|
||||
# 18 UTC in June: lon -75 -> local 13.0 (afternoon), lon -120 -> local 10.0 (morning)
|
||||
{east_score, _} = Scorer.score_time_of_day(18, 0, 6, -75.0)
|
||||
{west_score, _} = Scorer.score_time_of_day(18, 0, 6, -120.0)
|
||||
assert west_score > east_score
|
||||
end
|
||||
end
|
||||
|
||||
# ── score_td_depression/3 ────────────────────────────────────────
|
||||
|
|
@ -370,19 +380,74 @@ defmodule Microwaveprop.Propagation.ScorerTest do
|
|||
end
|
||||
end
|
||||
|
||||
# ── score_pwat/2 ─────────────────────────────────────────────────
|
||||
|
||||
describe "score_pwat/2 beneficial (10 GHz)" do
|
||||
test "low PWAT returns 55" do
|
||||
assert Scorer.score_pwat(5, @band_10g) == 55
|
||||
end
|
||||
|
||||
test "moderate PWAT returns 75" do
|
||||
assert Scorer.score_pwat(15, @band_10g) == 75
|
||||
end
|
||||
|
||||
test "optimal PWAT returns 90" do
|
||||
assert Scorer.score_pwat(25, @band_10g) == 90
|
||||
end
|
||||
|
||||
test "high PWAT returns 70" do
|
||||
assert Scorer.score_pwat(35, @band_10g) == 70
|
||||
end
|
||||
|
||||
test "very high PWAT returns 50" do
|
||||
assert Scorer.score_pwat(45, @band_10g) == 50
|
||||
end
|
||||
end
|
||||
|
||||
describe "score_pwat/2 harmful (24 GHz)" do
|
||||
test "low PWAT returns 95" do
|
||||
assert Scorer.score_pwat(5, @band_24g) == 95
|
||||
end
|
||||
|
||||
test "moderate PWAT returns 80" do
|
||||
assert Scorer.score_pwat(15, @band_24g) == 80
|
||||
end
|
||||
|
||||
test "high PWAT returns 60" do
|
||||
assert Scorer.score_pwat(25, @band_24g) == 60
|
||||
end
|
||||
|
||||
test "very high PWAT returns 35" do
|
||||
assert Scorer.score_pwat(35, @band_24g) == 35
|
||||
end
|
||||
|
||||
test "extreme PWAT returns 15" do
|
||||
assert Scorer.score_pwat(45, @band_24g) == 15
|
||||
end
|
||||
end
|
||||
|
||||
describe "score_pwat/2 nil" do
|
||||
test "nil returns 60" do
|
||||
assert Scorer.score_pwat(nil, @band_10g) == 60
|
||||
assert Scorer.score_pwat(nil, @band_24g) == 60
|
||||
end
|
||||
end
|
||||
|
||||
# ── score_pressure/2 ─────────────────────────────────────────────
|
||||
|
||||
describe "score_pressure/2" do
|
||||
test "high pressure without previous returns 55" do
|
||||
assert Scorer.score_pressure(1028, nil) == 55
|
||||
test "high pressure without previous returns 30" do
|
||||
assert Scorer.score_pressure(1028, nil) == 30
|
||||
end
|
||||
|
||||
test "normal pressure without previous returns 65" do
|
||||
assert Scorer.score_pressure(1020, nil) == 65
|
||||
test "normal pressure without previous returns 45" do
|
||||
# 1015 <= 1020 < 1020 is false, so >= 1020 -> 30
|
||||
# Actually 1020 is not < 1020, so it falls to true -> 30
|
||||
assert Scorer.score_pressure(1020, nil) == 30
|
||||
end
|
||||
|
||||
test "low pressure without previous returns 40" do
|
||||
assert Scorer.score_pressure(1000, nil) == 40
|
||||
test "low pressure without previous returns 80" do
|
||||
assert Scorer.score_pressure(1000, nil) == 80
|
||||
end
|
||||
|
||||
test "rising pressure returns 80" do
|
||||
|
|
@ -423,11 +488,13 @@ defmodule Microwaveprop.Propagation.ScorerTest do
|
|||
utc_hour: 11,
|
||||
utc_minute: 15,
|
||||
month: 6,
|
||||
longitude: -75.0,
|
||||
pressure_mb: 1020,
|
||||
prev_pressure_mb: 1018,
|
||||
rain_rate_mmhr: 0,
|
||||
min_refractivity_gradient: -350,
|
||||
bl_depth_m: 400
|
||||
bl_depth_m: 400,
|
||||
pwat_mm: 25.0
|
||||
}
|
||||
|
||||
test "returns score between 0 and 100" do
|
||||
|
|
@ -436,7 +503,7 @@ defmodule Microwaveprop.Propagation.ScorerTest do
|
|||
assert result.score <= 100
|
||||
end
|
||||
|
||||
test "returns all 9 factor scores" do
|
||||
test "returns all 10 factor scores" do
|
||||
result = Scorer.composite_score(@conditions, @band_10g)
|
||||
assert Map.has_key?(result.factors, :humidity)
|
||||
assert Map.has_key?(result.factors, :time_of_day)
|
||||
|
|
@ -446,6 +513,7 @@ defmodule Microwaveprop.Propagation.ScorerTest do
|
|||
assert Map.has_key?(result.factors, :season)
|
||||
assert Map.has_key?(result.factors, :wind)
|
||||
assert Map.has_key?(result.factors, :rain)
|
||||
assert Map.has_key?(result.factors, :pwat)
|
||||
assert Map.has_key?(result.factors, :pressure)
|
||||
end
|
||||
|
||||
|
|
@ -463,6 +531,7 @@ defmodule Microwaveprop.Propagation.ScorerTest do
|
|||
result.factors.season * weights.season +
|
||||
result.factors.wind * weights.wind +
|
||||
result.factors.rain * weights.rain +
|
||||
result.factors.pwat * weights.pwat +
|
||||
result.factors.pressure * weights.pressure
|
||||
|
||||
assert_in_delta result.score, round(expected), 1
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ defmodule Microwaveprop.PropagationTest do
|
|||
alias Microwaveprop.Propagation
|
||||
alias Microwaveprop.Propagation.GridScore
|
||||
|
||||
describe "score_grid_point/2" do
|
||||
describe "score_grid_point/3" 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)
|
||||
results = Propagation.score_grid_point(hrrr_profile, valid_time, -97.0)
|
||||
|
||||
assert length(results) == 8
|
||||
|
||||
Enum.each(results, fn result ->
|
||||
assert result.score >= 0 and result.score <= 100
|
||||
assert map_size(result.factors) == 9
|
||||
assert map_size(result.factors) == 10
|
||||
assert is_integer(result.band_mhz)
|
||||
end)
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue