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