postgis
This commit is contained in:
parent
b277f98f26
commit
506e5550b4
10 changed files with 649 additions and 16 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
87
lib/aprs/geometry_type.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
9
lib/aprs/postgres_types.ex
Normal file
9
lib/aprs/postgres_types.ex
Normal 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
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
4
mix.exs
4
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"},
|
||||
|
|
|
|||
|
|
@ -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
170
scripts/test_postgis.exs
Normal 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()
|
||||
Loading…
Add table
Reference in a new issue