Add get_nearby_weather_stations/4 query function with spatial filtering

Implements PostGIS-based query to find weather stations within a radius:
- Uses ST_DWithin for efficient spatial filtering within radius (miles to meters)
- Orders results by ST_Distance (closest first)
- Filters by has_weather, has_position, and time window (default 6h)
- Deduplicates by base_callsign using DISTINCT ON (avoids duplicate SSIDs)
- Returns all weather fields: temp, humidity, pressure, wind, rain
- Configurable limit (default 50) and hours options

Comprehensive test coverage (10 tests):
- Spatial filtering within/outside radius
- Time window filtering
- Base callsign deduplication (most recent SSID)
- Distance ordering verification
- Required field validation
- Edge cases (empty results, no weather data)
This commit is contained in:
Graham McIntire 2026-03-22 11:03:12 -05:00
parent 6ed10d11f4
commit 47fc071263
No known key found for this signature in database
2 changed files with 329 additions and 0 deletions

View file

@ -208,4 +208,107 @@ defmodule Aprsme.Packets.PreparedQueries do
Repo.one(query) || 0
end
@doc """
Get nearby weather stations within a radius.
Returns weather stations within the specified radius (in miles) that have reported
weather data within the time window. Results are ordered by distance (closest first)
and deduplicated by base_callsign to avoid duplicate SSIDs.
## Parameters
* `lat` - Latitude of center point
* `lon` - Longitude of center point
* `radius_miles` - Search radius in miles
* `opts` - Options keyword list
* `:hours` - Time window in hours (default: 6)
* `:limit` - Maximum number of results (default: 50)
## Returns
List of maps with fields:
* `callsign` - Station callsign with SSID
* `base_callsign` - Base callsign without SSID
* `lat` - Latitude
* `lon` - Longitude
* `distance_miles` - Distance from center point in miles
* `temperature` - Temperature in Fahrenheit (may be nil)
* `humidity` - Humidity percentage (may be nil)
* `pressure` - Atmospheric pressure in hPa (may be nil)
* `wind_speed` - Wind speed in MPH (may be nil)
* `wind_direction` - Wind direction in degrees (may be nil)
* `wind_gust` - Wind gust in MPH (may be nil)
* `rain_1h` - Rain in last hour in inches (may be nil)
* `rain_24h` - Rain in last 24 hours in inches (may be nil)
* `rain_since_midnight` - Rain since midnight in inches (may be nil)
* `symbol_table_id` - APRS symbol table ID
* `symbol_code` - APRS symbol code
* `comment` - Station comment
* `received_at` - Time packet was received
"""
@spec get_nearby_weather_stations(float(), float(), float(), keyword()) :: [map()]
def get_nearby_weather_stations(lat, lon, radius_miles, opts \\ []) do
hours = Keyword.get(opts, :hours, 6)
limit = Keyword.get(opts, :limit, 50)
# Convert miles to meters for PostGIS (1 mile = 1609.34 meters)
radius_meters = radius_miles * 1609.34
cutoff_time = DateTime.add(DateTime.utc_now(), -hours * 3600, :second)
# Build point for spatial query
point = %Geo.Point{coordinates: {lon, lat}, srid: 4326}
# Query: Find weather stations within radius and time window
# Use DISTINCT ON base_callsign to deduplicate SSIDs (gets most recent)
query =
from(p in Packet,
where: p.has_weather == true,
where: p.has_position == true,
where: p.received_at >= ^cutoff_time,
where:
fragment(
"ST_DWithin(?::geography, ?::geography, ?)",
p.location,
^point,
^radius_meters
),
distinct: p.base_callsign,
order_by: [asc: p.base_callsign, desc: p.received_at]
)
# Subquery to get most recent per base_callsign, then order by distance
subquery =
from(p in subquery(query),
order_by: fragment("ST_Distance(?::geography, ?::geography)", p.location, ^point),
limit: ^limit,
select: %{
callsign: p.sender,
base_callsign: p.base_callsign,
lat: fragment("ST_Y(?)", p.location),
lon: fragment("ST_X(?)", p.location),
distance_miles:
fragment(
"ST_Distance(?::geography, ?::geography) / 1609.34",
p.location,
^point
),
temperature: p.temperature,
humidity: p.humidity,
pressure: p.pressure,
wind_speed: p.wind_speed,
wind_direction: p.wind_direction,
wind_gust: p.wind_gust,
rain_1h: p.rain_1h,
rain_24h: p.rain_24h,
rain_since_midnight: p.rain_since_midnight,
symbol_table_id: p.symbol_table_id,
symbol_code: p.symbol_code,
comment: p.comment,
received_at: p.received_at
}
)
Repo.all(subquery)
end
end

View file

@ -186,4 +186,230 @@ defmodule Aprsme.Packets.PreparedQueriesTest do
assert_in_delta position.lng, -97.0, 0.01
end
end
describe "get_nearby_weather_stations/4" do
setup do
# San Francisco center: 37.7749, -122.4194
center_lat = 37.7749
center_lon = -122.4194
# Station A: ~0.5 miles north, weather 1h ago (should be included)
{:ok, station_a} =
create_positioned_packet(%{
sender: "WX-A",
base_callsign: "WX-A",
ssid: "0",
lat: Decimal.new("37.7822"),
lon: Decimal.new("-122.4194"),
temperature: 72.5,
humidity: 65.0,
pressure: 1013.25,
wind_speed: 5.0,
wind_direction: 180,
symbol_table_id: "/",
symbol_code: "_",
comment: "Weather Station A",
received_at: DateTime.add(DateTime.utc_now(), -3600, :second)
})
# Station B: ~10 miles east, weather 2h ago (should be included)
{:ok, station_b} =
create_positioned_packet(%{
sender: "WX-B-1",
base_callsign: "WX-B",
ssid: "1",
lat: Decimal.new("37.7749"),
lon: Decimal.new("-122.2700"),
temperature: 68.0,
humidity: 70.0,
wind_gust: 12.0,
symbol_table_id: "/",
symbol_code: "_",
comment: "Weather Station B",
received_at: DateTime.add(DateTime.utc_now(), -7200, :second)
})
# Station C: ~50 miles south, weather 1h ago (should be excluded - outside radius)
{:ok, _station_c} =
create_positioned_packet(%{
sender: "WX-C",
base_callsign: "WX-C",
ssid: "0",
lat: Decimal.new("37.0500"),
lon: Decimal.new("-122.4194"),
temperature: 75.0,
humidity: 60.0,
symbol_table_id: "/",
symbol_code: "_",
comment: "Weather Station C",
received_at: DateTime.add(DateTime.utc_now(), -3600, :second)
})
# Station D: ~5 miles west, weather 8h ago (should be excluded - outside time window)
{:ok, _station_d} =
create_positioned_packet(%{
sender: "WX-D",
base_callsign: "WX-D",
ssid: "0",
lat: Decimal.new("37.7749"),
lon: Decimal.new("-122.4900"),
temperature: 70.0,
humidity: 68.0,
symbol_table_id: "/",
symbol_code: "_",
comment: "Weather Station D",
received_at: DateTime.add(DateTime.utc_now(), -28_800, :second)
})
# Station E: ~5 miles southeast, no weather data (should be excluded)
{:ok, _station_e} =
create_positioned_packet(%{
sender: "WX-E",
base_callsign: "WX-E",
ssid: "0",
lat: Decimal.new("37.7400"),
lon: Decimal.new("-122.3800"),
symbol_table_id: "/",
symbol_code: "-",
comment: "Regular Station",
received_at: DateTime.add(DateTime.utc_now(), -3600, :second)
})
# Duplicate SSID for Station B (should be deduplicated)
{:ok, _station_b2} =
create_positioned_packet(%{
sender: "WX-B-2",
base_callsign: "WX-B",
ssid: "2",
lat: Decimal.new("37.7749"),
lon: Decimal.new("-122.2700"),
temperature: 69.0,
humidity: 72.0,
symbol_table_id: "/",
symbol_code: "_",
comment: "Weather Station B SSID 2",
received_at: DateTime.add(DateTime.utc_now(), -1800, :second)
})
%{
center_lat: center_lat,
center_lon: center_lon,
station_a: station_a,
station_b: station_b
}
end
test "returns nearby weather stations within radius and time window", %{
center_lat: lat,
center_lon: lon
} do
result = PreparedQueries.get_nearby_weather_stations(lat, lon, 15.0)
assert length(result) == 2
callsigns = Enum.map(result, & &1.callsign)
assert "WX-A" in callsigns
assert "WX-B-2" in callsigns
end
test "returns results ordered by distance (closest first)", %{center_lat: lat, center_lon: lon} do
result = PreparedQueries.get_nearby_weather_stations(lat, lon, 15.0)
assert length(result) == 2
# Station A (~0.5 miles) should be first
assert hd(result).callsign == "WX-A"
assert hd(result).distance_miles < 1.0
# Station B (~8-10 miles) should be second
second = Enum.at(result, 1)
assert second.callsign == "WX-B-2"
assert second.distance_miles > 7.0
assert second.distance_miles < 10.0
end
test "excludes stations outside radius", %{center_lat: lat, center_lon: lon} do
# Use 5 mile radius - should only get Station A
result = PreparedQueries.get_nearby_weather_stations(lat, lon, 5.0)
assert length(result) == 1
assert hd(result).callsign == "WX-A"
end
test "excludes stations outside time window", %{center_lat: lat, center_lon: lon} do
# Use 1 hour window - should get both A (1h ago) and B-2 (30min ago)
result = PreparedQueries.get_nearby_weather_stations(lat, lon, 15.0, hours: 1)
assert length(result) == 2
callsigns = Enum.map(result, & &1.callsign)
assert "WX-A" in callsigns
assert "WX-B-2" in callsigns
end
test "excludes stations without weather data", %{center_lat: lat, center_lon: lon} do
result = PreparedQueries.get_nearby_weather_stations(lat, lon, 15.0)
callsigns = Enum.map(result, & &1.callsign)
refute "WX-E" in callsigns
end
test "deduplicates by base_callsign and returns most recent", %{
center_lat: lat,
center_lon: lon
} do
result = PreparedQueries.get_nearby_weather_stations(lat, lon, 15.0)
base_callsigns = Enum.map(result, & &1.base_callsign)
assert length(base_callsigns) == length(Enum.uniq(base_callsigns))
# Should get WX-B-2 (most recent) not WX-B-1
wx_b = Enum.find(result, &(&1.base_callsign == "WX-B"))
assert wx_b.callsign == "WX-B-2"
end
test "respects limit option", %{center_lat: lat, center_lon: lon} do
result = PreparedQueries.get_nearby_weather_stations(lat, lon, 15.0, limit: 1)
assert length(result) == 1
assert hd(result).callsign == "WX-A"
end
test "returns all required fields", %{center_lat: lat, center_lon: lon} do
result = PreparedQueries.get_nearby_weather_stations(lat, lon, 15.0)
station = hd(result)
assert is_binary(station.callsign)
assert is_binary(station.base_callsign)
assert is_float(station.lat)
assert is_float(station.lon)
assert is_float(station.distance_miles)
assert is_binary(station.symbol_table_id)
assert is_binary(station.symbol_code)
assert is_binary(station.comment)
assert %DateTime{} = station.received_at
# Weather fields (may be nil)
assert is_nil(station.temperature) or is_float(station.temperature)
assert is_nil(station.humidity) or is_float(station.humidity)
assert is_nil(station.pressure) or is_float(station.pressure)
assert is_nil(station.wind_speed) or is_float(station.wind_speed)
assert is_nil(station.wind_direction) or is_integer(station.wind_direction)
assert is_nil(station.wind_gust) or is_float(station.wind_gust)
assert is_nil(station.rain_1h) or is_float(station.rain_1h)
assert is_nil(station.rain_24h) or is_float(station.rain_24h)
assert is_nil(station.rain_since_midnight) or is_float(station.rain_since_midnight)
end
test "returns empty list when no stations in radius", %{center_lat: _lat, center_lon: _lon} do
# Use coordinates far from any station
result = PreparedQueries.get_nearby_weather_stations(40.0, -100.0, 5.0)
assert result == []
end
test "handles custom hours option", %{center_lat: lat, center_lon: lon} do
# Use 3 hour window - should get both A and B
result = PreparedQueries.get_nearby_weather_stations(lat, lon, 15.0, hours: 3)
assert length(result) == 2
end
end
end