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.
219 lines
6.2 KiB
Elixir
219 lines
6.2 KiB
Elixir
defmodule Microwaveprop.Propagation.ModelTest do
|
|
use ExUnit.Case, async: true
|
|
|
|
alias Microwaveprop.Propagation.Model
|
|
|
|
describe "build/0" do
|
|
test "returns an Axon model" do
|
|
model = Model.build()
|
|
assert %Axon{} = model
|
|
end
|
|
end
|
|
|
|
describe "init/0" do
|
|
test "returns initialized model state with parameters" do
|
|
params = Model.init()
|
|
assert %Axon.ModelState{} = params
|
|
assert Map.has_key?(params.data, "hidden_1")
|
|
assert Map.has_key?(params.data, "hidden_2")
|
|
assert Map.has_key?(params.data, "output")
|
|
end
|
|
end
|
|
|
|
describe "save/2 and load/1" do
|
|
@tag :tmp_dir
|
|
test "round-trips parameters to disk", %{tmp_dir: tmp_dir} do
|
|
path = Path.join(tmp_dir, "test_model.nx")
|
|
params = Model.init()
|
|
|
|
assert :ok = Model.save(params, path)
|
|
assert File.exists?(path)
|
|
|
|
assert {:ok, loaded} = Model.load(path)
|
|
|
|
# Predict with both and compare
|
|
input = Nx.broadcast(0.5, {1, 13})
|
|
original = Model.predict(params, input)
|
|
reloaded = Model.predict(loaded, input)
|
|
|
|
assert Nx.to_flat_list(original) == Nx.to_flat_list(reloaded)
|
|
end
|
|
|
|
test "load returns :error for missing file" do
|
|
assert :error = Model.load("/tmp/nonexistent_model.nx")
|
|
end
|
|
end
|
|
|
|
describe "load_or_init/1" do
|
|
@tag :tmp_dir
|
|
test "loads from file when it exists", %{tmp_dir: tmp_dir} do
|
|
path = Path.join(tmp_dir, "test_model.nx")
|
|
params = Model.init()
|
|
Model.save(params, path)
|
|
|
|
loaded = Model.load_or_init(path)
|
|
input = Nx.broadcast(0.5, {1, 13})
|
|
assert Nx.to_flat_list(Model.predict(params, input)) == Nx.to_flat_list(Model.predict(loaded, input))
|
|
end
|
|
|
|
test "initializes when file missing" do
|
|
params = Model.load_or_init("/tmp/nonexistent_model.nx")
|
|
assert %Axon.ModelState{} = params
|
|
end
|
|
end
|
|
|
|
describe "predict/2" do
|
|
test "produces output in [0, 1] range" do
|
|
params = Model.init()
|
|
# Single sample, 13 features
|
|
input = Nx.broadcast(0.5, {1, 13})
|
|
output = Model.predict(params, input)
|
|
|
|
assert Nx.shape(output) == {1, 1}
|
|
|
|
value = output |> Nx.to_flat_list() |> hd()
|
|
assert value >= 0.0 and value <= 1.0
|
|
end
|
|
|
|
test "handles batch of multiple samples" do
|
|
params = Model.init()
|
|
input = Nx.broadcast(0.5, {4, 13})
|
|
output = Model.predict(params, input)
|
|
|
|
assert Nx.shape(output) == {4, 1}
|
|
end
|
|
end
|
|
|
|
describe "encode_features/1" do
|
|
test "returns 13-element list" do
|
|
features =
|
|
Model.encode_features(%{
|
|
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 length(features) == 13
|
|
assert Enum.all?(features, &is_float/1)
|
|
end
|
|
|
|
test "uses defaults for missing values" do
|
|
features = Model.encode_features(%{})
|
|
assert length(features) == 13
|
|
assert Enum.all?(features, &is_float/1)
|
|
end
|
|
|
|
test "cyclical encoding: hour 0 and hour 24 produce same sin/cos" do
|
|
f0 = Model.encode_features(%{utc_hour: 0})
|
|
f24 = Model.encode_features(%{utc_hour: 24})
|
|
|
|
# sin/cos at indices 8, 9
|
|
assert_in_delta Enum.at(f0, 8), Enum.at(f24, 8), 0.001
|
|
assert_in_delta Enum.at(f0, 9), Enum.at(f24, 9), 0.001
|
|
end
|
|
|
|
test "log_freq_mhz is last feature" do
|
|
features = Model.encode_features(%{freq_mhz: 10_000})
|
|
assert_in_delta List.last(features), :math.log(10_000), 0.001
|
|
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
|
|
end
|
|
end
|
|
end
|