- Add 17 missing @spec annotations (layouts, error_json, error_html, skewt_svg) - Move 12+ nested alias/import/require to module top level - Add phx-change/id attributes to 11 raw HTML <form> tags - Remove 4 unused LiveView assigns (:bounds, :data_provider) - Add 3 missing doctest references (HrrrNativeClient, BulkFetch, Accounts) - Break 2 long lines (path_compute.ex:382) - Strengthen weak test assertions (is_binary→byte_size, is_list→!=[]) - Replace Module.concat with Module.safe_concat (2 occurrences) - Replace length/1 > 0 with list != [] (9 occurrences) - Remove no-op assert true, fix no-assertion tests Remaining: 24 socket.assigns introspection warnings (deliberate test pattern for observable behavior testing), 1 formatter-resistant long line, 3 app-code usage warnings.
306 lines
9 KiB
Elixir
306 lines
9 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, "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
|
|
|
|
describe "explain_prediction/3" do
|
|
test "returns score and one contribution entry per feature" do
|
|
params = Model.init()
|
|
predict_fn = Model.compile_predict()
|
|
|
|
result =
|
|
Model.explain_prediction(predict_fn, params, %{
|
|
surface_temp_c: 25.0,
|
|
surface_dewpoint_c: 18.0,
|
|
surface_pressure_mb: 1013.0,
|
|
min_refractivity_gradient: -250.0,
|
|
hpbl_m: 800.0,
|
|
pwat_mm: 30.0,
|
|
surface_refractivity: 340.0,
|
|
latitude: 32.5,
|
|
longitude: -97.0,
|
|
sfi: 130.0,
|
|
kp_max: 2.0,
|
|
ducting_detected: true,
|
|
k_index: 25.0,
|
|
lifted_index: -2.0,
|
|
utc_hour: 23,
|
|
month: 7,
|
|
freq_mhz: 10_000
|
|
})
|
|
|
|
assert is_integer(result.score)
|
|
assert result.score >= 0 and result.score <= 100
|
|
assert length(result.contributions) == 20
|
|
|
|
Enum.each(result.contributions, fn c ->
|
|
assert byte_size(to_string(c.feature)) > 0
|
|
assert c.normalized >= -10_000 and c.normalized <= 10_000
|
|
assert is_float(c.sensitivity)
|
|
assert is_float(c.contribution)
|
|
end)
|
|
end
|
|
|
|
test "contributions are sorted by absolute contribution descending" do
|
|
params = Model.init()
|
|
predict_fn = Model.compile_predict()
|
|
|
|
result =
|
|
Model.explain_prediction(predict_fn, params, %{
|
|
surface_temp_c: 15.0,
|
|
surface_dewpoint_c: 5.0,
|
|
freq_mhz: 24_000
|
|
})
|
|
|
|
magnitudes = Enum.map(result.contributions, &abs(&1.contribution))
|
|
assert magnitudes == Enum.sort(magnitudes, :desc)
|
|
end
|
|
|
|
test "every emitted feature name matches feature_names/0" do
|
|
params = Model.init()
|
|
predict_fn = Model.compile_predict()
|
|
|
|
result = Model.explain_prediction(predict_fn, params, %{freq_mhz: 47_000})
|
|
|
|
emitted = result.contributions |> Enum.map(& &1.feature) |> Enum.sort()
|
|
expected = Enum.sort(Model.feature_names())
|
|
assert emitted == expected
|
|
end
|
|
|
|
test "perturbing a feature changes the predicted score" do
|
|
# Verify the sensitivity output is non-trivial: the model must
|
|
# actually move when at least one feature moves. With random init
|
|
# this is essentially guaranteed unless every weight collapses.
|
|
params = Model.init()
|
|
predict_fn = Model.compile_predict()
|
|
|
|
result =
|
|
Model.explain_prediction(predict_fn, params, %{
|
|
surface_temp_c: 20.0,
|
|
surface_dewpoint_c: 10.0,
|
|
surface_pressure_mb: 1013.0,
|
|
freq_mhz: 10_000
|
|
})
|
|
|
|
max_sensitivity = result.contributions |> Enum.map(&abs(&1.sensitivity)) |> Enum.max()
|
|
assert max_sensitivity > 0.0
|
|
end
|
|
end
|
|
end
|