This commit is contained in:
Graham McIntire 2025-06-16 09:58:17 -05:00
parent b277f98f26
commit 506e5550b4
No known key found for this signature in database
10 changed files with 649 additions and 16 deletions

View file

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

View file

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

87
lib/aprs/geometry_type.ex Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"},

View file

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

170
scripts/test_postgis.exs Normal file
View file

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