diff --git a/config/dev.exs b/config/dev.exs index 7d89f136..c1bc8821 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -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} - ]} - ] diff --git a/lib/microwaveprop/propagation.ex b/lib/microwaveprop/propagation.ex index 51f6fa8a..b2055716 100644 --- a/lib/microwaveprop/propagation.ex +++ b/lib/microwaveprop/propagation.ex @@ -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( diff --git a/lib/microwaveprop/propagation/band_config.ex b/lib/microwaveprop/propagation/band_config.ex index d2e25421..53256b25 100644 --- a/lib/microwaveprop/propagation/band_config.ex +++ b/lib/microwaveprop/propagation/band_config.ex @@ -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 diff --git a/lib/microwaveprop/propagation/model.ex b/lib/microwaveprop/propagation/model.ex index a5e834fc..fab9a59c 100644 --- a/lib/microwaveprop/propagation/model.ex +++ b/lib/microwaveprop/propagation/model.ex @@ -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 [ diff --git a/lib/microwaveprop/propagation/scorer.ex b/lib/microwaveprop/propagation/scorer.ex index 583c38dd..eea077cf 100644 --- a/lib/microwaveprop/propagation/scorer.ex +++ b/lib/microwaveprop/propagation/scorer.ex @@ -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) } diff --git a/lib/microwaveprop/workers/asos_adjustment_worker.ex b/lib/microwaveprop/workers/asos_adjustment_worker.ex index b818f956..a9509c9e 100644 --- a/lib/microwaveprop/workers/asos_adjustment_worker.ex +++ b/lib/microwaveprop/workers/asos_adjustment_worker.ex @@ -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, diff --git a/lib/microwaveprop/workers/propagation_grid_worker.ex b/lib/microwaveprop/workers/propagation_grid_worker.ex index 26d5ca65..754047ad 100644 --- a/lib/microwaveprop/workers/propagation_grid_worker.ex +++ b/lib/microwaveprop/workers/propagation_grid_worker.ex @@ -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 -> %{ diff --git a/lib/mix/tasks/propagation_train.ex b/lib/mix/tasks/propagation_train.ex new file mode 100644 index 00000000..594b9f8d --- /dev/null +++ b/lib/mix/tasks/propagation_train.ex @@ -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 diff --git a/test/microwaveprop/propagation/band_config_test.exs b/test/microwaveprop/propagation/band_config_test.exs index bfff3d5d..7cf229c2 100644 --- a/test/microwaveprop/propagation/band_config_test.exs +++ b/test/microwaveprop/propagation/band_config_test.exs @@ -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 diff --git a/test/microwaveprop/propagation/model_test.exs b/test/microwaveprop/propagation/model_test.exs index 5780cce5..f84e2770 100644 --- a/test/microwaveprop/propagation/model_test.exs +++ b/test/microwaveprop/propagation/model_test.exs @@ -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 diff --git a/test/microwaveprop/propagation/scorer_test.exs b/test/microwaveprop/propagation/scorer_test.exs index 06d74632..0790840a 100644 --- a/test/microwaveprop/propagation/scorer_test.exs +++ b/test/microwaveprop/propagation/scorer_test.exs @@ -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 diff --git a/test/microwaveprop/propagation_test.exs b/test/microwaveprop/propagation_test.exs index da437f1b..2b0711cc 100644 --- a/test/microwaveprop/propagation_test.exs +++ b/test/microwaveprop/propagation_test.exs @@ -4,7 +4,7 @@ defmodule Microwaveprop.PropagationTest do alias Microwaveprop.Propagation alias Microwaveprop.Propagation.GridScore - describe "score_grid_point/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