Merge branch 'worktree-agent-ae6c8945' into feature/propagation-map
This commit is contained in:
commit
8dd289580d
5 changed files with 217 additions and 0 deletions
28
lib/microwaveprop/propagation/grid.ex
Normal file
28
lib/microwaveprop/propagation/grid.ex
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
defmodule Microwaveprop.Propagation.Grid do
|
||||
@moduledoc "CONUS grid definition for propagation scoring. 0.125 degree resolution (~14 km)."
|
||||
|
||||
@lat_min 25.0
|
||||
@lat_max 50.0
|
||||
@lon_min -125.0
|
||||
@lon_max -66.0
|
||||
@step 0.125
|
||||
|
||||
@doc "Returns all grid points as `{lat, lon}` tuples covering CONUS at 0.125 degree spacing."
|
||||
def conus_points do
|
||||
for lat <- float_range(@lat_min, @lat_max, @step),
|
||||
lon <- float_range(@lon_min, @lon_max, @step) do
|
||||
{Float.round(lat, 3), Float.round(lon, 3)}
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Returns the grid step size in degrees."
|
||||
def step, do: @step
|
||||
|
||||
@doc "Returns the CONUS bounding box as a map."
|
||||
def bounds, do: %{lat_min: @lat_min, lat_max: @lat_max, lon_min: @lon_min, lon_max: @lon_max}
|
||||
|
||||
defp float_range(start, stop, step) do
|
||||
count = round((stop - start) / step) + 1
|
||||
Enum.map(0..(count - 1), fn i -> start + i * step end)
|
||||
end
|
||||
end
|
||||
28
lib/microwaveprop/propagation/grid_score.ex
Normal file
28
lib/microwaveprop/propagation/grid_score.ex
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
defmodule Microwaveprop.Propagation.GridScore do
|
||||
@moduledoc false
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
|
||||
schema "propagation_scores" do
|
||||
field :lat, :float
|
||||
field :lon, :float
|
||||
field :valid_time, :utc_datetime
|
||||
field :band_mhz, :integer
|
||||
field :score, :integer
|
||||
field :factors, :map
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@fields ~w(lat lon valid_time band_mhz score factors)a
|
||||
|
||||
def changeset(grid_score, attrs) do
|
||||
grid_score
|
||||
|> cast(attrs, @fields)
|
||||
|> validate_required(@fields)
|
||||
|> unique_constraint([:lat, :lon, :valid_time, :band_mhz])
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
defmodule Microwaveprop.Repo.Migrations.CreatePropagationScores do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:propagation_scores, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :lat, :float, null: false
|
||||
add :lon, :float, null: false
|
||||
add :valid_time, :utc_datetime, null: false
|
||||
add :band_mhz, :integer, null: false
|
||||
add :score, :integer, null: false
|
||||
add :factors, :map, null: false
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create unique_index(:propagation_scores, [:lat, :lon, :valid_time, :band_mhz])
|
||||
create index(:propagation_scores, [:valid_time])
|
||||
create index(:propagation_scores, [:band_mhz, :valid_time])
|
||||
end
|
||||
end
|
||||
84
test/microwaveprop/propagation/grid_score_test.exs
Normal file
84
test/microwaveprop/propagation/grid_score_test.exs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
defmodule Microwaveprop.Propagation.GridScoreTest do
|
||||
use Microwaveprop.DataCase, async: true
|
||||
|
||||
alias Microwaveprop.Propagation.GridScore
|
||||
|
||||
@valid_attrs %{
|
||||
lat: 32.875,
|
||||
lon: -97.0,
|
||||
valid_time: ~U[2026-03-28 18:00:00Z],
|
||||
band_mhz: 10_000,
|
||||
score: 72,
|
||||
factors: %{
|
||||
"humidity" => 85,
|
||||
"time_of_day" => 60,
|
||||
"td_depression" => 70,
|
||||
"refractivity" => 55,
|
||||
"sky" => 80,
|
||||
"season" => 65,
|
||||
"wind" => 50,
|
||||
"rain" => 90,
|
||||
"pressure" => 45
|
||||
}
|
||||
}
|
||||
|
||||
describe "changeset/2" do
|
||||
test "valid attributes" do
|
||||
changeset = GridScore.changeset(%GridScore{}, @valid_attrs)
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "requires all fields" do
|
||||
changeset = GridScore.changeset(%GridScore{}, %{})
|
||||
|
||||
assert %{
|
||||
lat: ["can't be blank"],
|
||||
lon: ["can't be blank"],
|
||||
valid_time: ["can't be blank"],
|
||||
band_mhz: ["can't be blank"],
|
||||
score: ["can't be blank"],
|
||||
factors: ["can't be blank"]
|
||||
} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "persists to database" do
|
||||
changeset = GridScore.changeset(%GridScore{}, @valid_attrs)
|
||||
assert {:ok, score} = Repo.insert(changeset)
|
||||
assert score.lat == 32.875
|
||||
assert score.lon == -97.0
|
||||
assert score.band_mhz == 10_000
|
||||
assert score.score == 72
|
||||
assert score.factors["humidity"] == 85
|
||||
end
|
||||
|
||||
test "enforces unique lat + lon + valid_time + band_mhz" do
|
||||
assert {:ok, _} =
|
||||
%GridScore{} |> GridScore.changeset(@valid_attrs) |> Repo.insert()
|
||||
|
||||
assert {:error, changeset} =
|
||||
%GridScore{} |> GridScore.changeset(@valid_attrs) |> Repo.insert()
|
||||
|
||||
assert %{lat: ["has already been taken"]} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "allows same location at different times" do
|
||||
assert {:ok, _} =
|
||||
%GridScore{} |> GridScore.changeset(@valid_attrs) |> Repo.insert()
|
||||
|
||||
later_attrs = Map.put(@valid_attrs, :valid_time, ~U[2026-03-28 19:00:00Z])
|
||||
|
||||
assert {:ok, _} =
|
||||
%GridScore{} |> GridScore.changeset(later_attrs) |> Repo.insert()
|
||||
end
|
||||
|
||||
test "allows same location and time for different bands" do
|
||||
assert {:ok, _} =
|
||||
%GridScore{} |> GridScore.changeset(@valid_attrs) |> Repo.insert()
|
||||
|
||||
other_band_attrs = Map.put(@valid_attrs, :band_mhz, 24_000)
|
||||
|
||||
assert {:ok, _} =
|
||||
%GridScore{} |> GridScore.changeset(other_band_attrs) |> Repo.insert()
|
||||
end
|
||||
end
|
||||
end
|
||||
56
test/microwaveprop/propagation/grid_test.exs
Normal file
56
test/microwaveprop/propagation/grid_test.exs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
defmodule Microwaveprop.Propagation.GridTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Microwaveprop.Propagation.Grid
|
||||
|
||||
describe "conus_points/0" do
|
||||
test "generates grid at 0.125 degree resolution" do
|
||||
points = Grid.conus_points()
|
||||
assert length(points) > 5000
|
||||
assert length(points) < 100_000
|
||||
end
|
||||
|
||||
test "all points within CONUS bounds" do
|
||||
Enum.each(Grid.conus_points(), fn {lat, lon} ->
|
||||
assert lat >= 25.0 and lat <= 50.0
|
||||
assert lon >= -125.0 and lon <= -66.0
|
||||
end)
|
||||
end
|
||||
|
||||
test "points are on 0.125 degree grid" do
|
||||
Enum.each(Grid.conus_points(), fn {lat, lon} ->
|
||||
assert Float.round(lat * 8, 0) == lat * 8
|
||||
assert Float.round(lon * 8, 0) == lon * 8
|
||||
end)
|
||||
end
|
||||
|
||||
test "first point is at SW corner" do
|
||||
[{lat, lon} | _] = Grid.conus_points()
|
||||
assert lat == 25.0
|
||||
assert lon == -125.0
|
||||
end
|
||||
|
||||
test "last point is at NE corner" do
|
||||
points = Grid.conus_points()
|
||||
{lat, lon} = List.last(points)
|
||||
assert lat == 50.0
|
||||
assert lon == -66.0
|
||||
end
|
||||
end
|
||||
|
||||
describe "step/0" do
|
||||
test "returns 0.125" do
|
||||
assert Grid.step() == 0.125
|
||||
end
|
||||
end
|
||||
|
||||
describe "bounds/0" do
|
||||
test "returns CONUS bounding box" do
|
||||
bounds = Grid.bounds()
|
||||
assert bounds.lat_min == 25.0
|
||||
assert bounds.lat_max == 50.0
|
||||
assert bounds.lon_min == -125.0
|
||||
assert bounds.lon_max == -66.0
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue