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, "hidden_3") 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, 20}) 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, 20}) 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, 20 features input = Nx.broadcast(0.5, {1, 20}) 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, 20}) output = Model.predict(params, input) assert Nx.shape(output) == {4, 1} end end describe "encode_features/1" do test "returns 20-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) == 20 assert Enum.all?(features, &is_float/1) end test "uses defaults for missing values" do features = Model.encode_features(%{}) assert length(features) == 20 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) / 13.0, 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, 20}, 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, 20}, 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/3" do test "returns integer between 0 and 100" do params = Model.init() predict_fn = Model.compile_predict() score = Model.predict_score(predict_fn, 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() predict_fn = Model.compile_predict() score_hot = Model.predict_score(predict_fn, 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(predict_fn, 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 20 feature names" do assert length(Model.feature_names()) == 20 end end end