From 47fc07126342717269cbf23b6662ddd355dcebf8 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 22 Mar 2026 11:03:12 -0500 Subject: [PATCH] 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) --- lib/aprsme/packets/prepared_queries.ex | 103 ++++++++ test/aprsme/packets/prepared_queries_test.exs | 226 ++++++++++++++++++ 2 files changed, 329 insertions(+) diff --git a/lib/aprsme/packets/prepared_queries.ex b/lib/aprsme/packets/prepared_queries.ex index 4ef6654..7aa3420 100644 --- a/lib/aprsme/packets/prepared_queries.ex +++ b/lib/aprsme/packets/prepared_queries.ex @@ -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 diff --git a/test/aprsme/packets/prepared_queries_test.exs b/test/aprsme/packets/prepared_queries_test.exs index dafd6ef..3713e0c 100644 --- a/test/aprsme/packets/prepared_queries_test.exs +++ b/test/aprsme/packets/prepared_queries_test.exs @@ -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