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:
parent
6ed10d11f4
commit
47fc071263
2 changed files with 329 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue