diff --git a/lib/microwaveprop/propagation/grid.ex b/lib/microwaveprop/propagation/grid.ex new file mode 100644 index 00000000..724a198f --- /dev/null +++ b/lib/microwaveprop/propagation/grid.ex @@ -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 diff --git a/lib/microwaveprop/propagation/grid_score.ex b/lib/microwaveprop/propagation/grid_score.ex new file mode 100644 index 00000000..a5adc6b5 --- /dev/null +++ b/lib/microwaveprop/propagation/grid_score.ex @@ -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 diff --git a/priv/repo/migrations/20260330214829_create_propagation_scores.exs b/priv/repo/migrations/20260330214829_create_propagation_scores.exs new file mode 100644 index 00000000..6315c676 --- /dev/null +++ b/priv/repo/migrations/20260330214829_create_propagation_scores.exs @@ -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 diff --git a/test/microwaveprop/propagation/grid_score_test.exs b/test/microwaveprop/propagation/grid_score_test.exs new file mode 100644 index 00000000..2945938c --- /dev/null +++ b/test/microwaveprop/propagation/grid_score_test.exs @@ -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 diff --git a/test/microwaveprop/propagation/grid_test.exs b/test/microwaveprop/propagation/grid_test.exs new file mode 100644 index 00000000..91e51190 --- /dev/null +++ b/test/microwaveprop/propagation/grid_test.exs @@ -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