Add CONUS grid definition and propagation score schema

Define 0.125-degree CONUS grid (25-50N, 125-66W) for propagation
scoring and create propagation_scores table with composite unique
index on lat/lon/valid_time/band_mhz for upsert support.
This commit is contained in:
Graham McIntire 2026-03-30 16:50:06 -05:00
parent 3228835636
commit 775dff4263
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
5 changed files with 217 additions and 0 deletions

View 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

View 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

View file

@ -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

View 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

View 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