prop/test/microwaveprop/propagation/model_test.exs
Graham McIntire 8949920b7f
Add Nx/Axon/EXLA ML model skeleton for propagation prediction
13-feature feed-forward network (atmospheric + temporal + frequency).
Includes build, init, predict, encode_features, save/load to disk.
Model weights saved to priv/models/propagation_v1.nx (gitignored).
Not yet trained — scaffolding only.
2026-03-31 16:26:34 -05:00

132 lines
3.7 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 "feature_names/0" do
test "returns 13 feature names" do
assert length(Model.feature_names()) == 13
end
end
end