Task 9.3 - Weight recalibration via gradient descent:
- Recalibrator module fits logistic regression weights using Nx
- Trains on QSO positives vs random baseline negatives
- Cross-validates by month, normalizes weights to sum to 1.0
- Mix task: mix recalibrate_scorer --sample 5000 --epochs 2000
Task 9.4 - Side-by-side scorer comparison:
- ScorerDiff.compare/3 re-scores grid with old vs new weights
- Reports mean diff, regressions, improvements, per-band breakdown
- Mix task: mix scorer_diff --new-weights '{...}'
Phase 3 - NEXRAD ingestion pipeline:
- NexradClient fetches IEM n0q composite PNGs, extracts per-point
box statistics (mean/max dBZ, texture variance)
- NexradObservation schema with unique (lat, lon, observed_at)
- NexradWorker on :nexrad queue for background processing
- nexrad_texture backtest feature in Features module
- mix nexrad_backfill --limit 200
All tasks added to AdminTaskWorker and Release for production use.
1116 tests, 0 failures.
145 lines
4.7 KiB
Elixir
145 lines
4.7 KiB
Elixir
defmodule Microwaveprop.Propagation.RecalibratorTest do
|
|
use Microwaveprop.DataCase, async: true
|
|
|
|
alias Microwaveprop.Propagation.Recalibrator
|
|
alias Microwaveprop.Radio.Contact
|
|
alias Microwaveprop.Weather.HrrrProfile
|
|
|
|
@factor_keys ~w(humidity time_of_day td_depression refractivity sky season wind rain pwat pressure)a
|
|
|
|
defp create_contact(attrs) do
|
|
ts = Map.get(attrs, :qso_timestamp, ~U[2024-07-15 06:00:00Z])
|
|
lat = Map.get(attrs, :lat, 32.9)
|
|
lon = Map.get(attrs, :lon, -97.0)
|
|
|
|
contact_attrs = %{
|
|
station1: Map.fetch!(attrs, :station1),
|
|
station2: "K5TR",
|
|
qso_timestamp: ts,
|
|
mode: "CW",
|
|
band: Decimal.new("10000"),
|
|
grid1: "EM12",
|
|
grid2: "EM00",
|
|
pos1: %{"lat" => lat, "lon" => lon},
|
|
pos2: %{"lat" => 30.3, "lon" => -97.7},
|
|
distance_km: Decimal.new("295")
|
|
}
|
|
|
|
{:ok, contact} =
|
|
%Contact{}
|
|
|> Contact.changeset(contact_attrs)
|
|
|> Repo.insert()
|
|
|
|
contact
|
|
end
|
|
|
|
defp create_hrrr_profile(attrs) do
|
|
{:ok, profile} =
|
|
%HrrrProfile{}
|
|
|> HrrrProfile.changeset(%{
|
|
valid_time: Map.fetch!(attrs, :valid_time),
|
|
lat: Map.fetch!(attrs, :lat),
|
|
lon: Map.fetch!(attrs, :lon),
|
|
surface_temp_c: Map.get(attrs, :surface_temp_c, 28.0),
|
|
surface_dewpoint_c: Map.get(attrs, :surface_dewpoint_c, 22.0),
|
|
surface_pressure_mb: Map.get(attrs, :surface_pressure_mb, 1005.0),
|
|
min_refractivity_gradient: Map.get(attrs, :min_refractivity_gradient, -120.0),
|
|
hpbl_m: Map.get(attrs, :hpbl_m, 800.0),
|
|
pwat_mm: Map.get(attrs, :pwat_mm, 35.0)
|
|
})
|
|
|> Repo.insert()
|
|
|
|
profile
|
|
end
|
|
|
|
# Synthetic factor vectors for training tests.
|
|
# Positives: high scores (good propagation conditions).
|
|
# Negatives: low scores (poor propagation conditions).
|
|
defp synthetic_positives do
|
|
for _ <- 1..20 do
|
|
[85, 90, 80, 75, 88, 70, 95, 100, 75, 82]
|
|
|> Enum.map(fn base -> base + :rand.uniform(10) - 5 end)
|
|
|> Enum.map(&min(100, max(0, &1)))
|
|
end
|
|
end
|
|
|
|
defp synthetic_negatives do
|
|
for _ <- 1..20 do
|
|
[40, 35, 45, 50, 30, 55, 50, 60, 50, 45]
|
|
|> Enum.map(fn base -> base + :rand.uniform(10) - 5 end)
|
|
|> Enum.map(&min(100, max(0, &1)))
|
|
end
|
|
end
|
|
|
|
describe "train/3" do
|
|
test "returns a weights map with all 10 factor keys summing to ~1.0" do
|
|
result = Recalibrator.train(synthetic_positives(), synthetic_negatives(), epochs: 50, learning_rate: 0.01)
|
|
|
|
assert is_map(result.weights)
|
|
|
|
for key <- @factor_keys do
|
|
assert Map.has_key?(result.weights, key), "missing weight for #{key}"
|
|
weight = result.weights[key]
|
|
assert is_float(weight), "weight for #{key} should be float, got #{inspect(weight)}"
|
|
assert weight >= 0.0, "weight for #{key} should be non-negative"
|
|
end
|
|
|
|
sum = result.weights |> Map.values() |> Enum.sum()
|
|
assert_in_delta sum, 1.0, 0.001, "weights should sum to 1.0, got #{sum}"
|
|
end
|
|
|
|
test "train_loss decreases from initial loss" do
|
|
result = Recalibrator.train(synthetic_positives(), synthetic_negatives(), epochs: 200, learning_rate: 0.01)
|
|
|
|
assert is_float(result.train_loss)
|
|
assert is_float(result.val_loss)
|
|
assert is_float(result.initial_loss)
|
|
|
|
assert result.train_loss < result.initial_loss,
|
|
"train_loss (#{result.train_loss}) should be less than initial_loss (#{result.initial_loss})"
|
|
end
|
|
end
|
|
|
|
describe "fit/1" do
|
|
test "returns valid result with insufficient HRRR data (falls back to current weights)" do
|
|
# Contacts without matching HRRR profiles for baselines
|
|
create_contact(%{station1: "W5AA"})
|
|
create_contact(%{station1: "W5BB", lat: 33.0, lon: -96.0, qso_timestamp: ~U[2024-07-16 06:00:00Z]})
|
|
|
|
result = Recalibrator.fit(sample_size: 5, epochs: 20, learning_rate: 0.01)
|
|
|
|
assert is_map(result.weights)
|
|
assert map_size(result.weights) == 10
|
|
|
|
sum = result.weights |> Map.values() |> Enum.sum()
|
|
assert_in_delta sum, 1.0, 0.01
|
|
end
|
|
end
|
|
|
|
describe "compute_factors/2" do
|
|
test "builds a factor vector from an HRRR profile and timestamp" do
|
|
profile =
|
|
create_hrrr_profile(%{
|
|
valid_time: ~U[2024-07-15 06:00:00Z],
|
|
lat: 32.9,
|
|
lon: -97.0,
|
|
surface_temp_c: 28.0,
|
|
surface_dewpoint_c: 22.0,
|
|
surface_pressure_mb: 1005.0,
|
|
min_refractivity_gradient: -120.0,
|
|
hpbl_m: 800.0,
|
|
pwat_mm: 35.0
|
|
})
|
|
|
|
factors = Recalibrator.compute_factors(profile, ~U[2024-07-15 06:00:00Z])
|
|
|
|
assert is_list(factors)
|
|
assert length(factors) == 10
|
|
|
|
Enum.each(factors, fn f ->
|
|
assert is_number(f)
|
|
assert f >= 0 and f <= 100
|
|
end)
|
|
end
|
|
end
|
|
end
|