Add design doc for weather nearby API endpoint
Documents new GET /api/v1/weather/nearby endpoint for finding weather stations within a radius of a lat/lon point. Uses PostGIS ST_DWithin for efficient spatial queries with configurable time window and result limits.
This commit is contained in:
parent
f142b273d1
commit
bc6fdcd9c2
1 changed files with 220 additions and 0 deletions
220
docs/plans/2026-03-22-weather-nearby-api-design.md
Normal file
220
docs/plans/2026-03-22-weather-nearby-api-design.md
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
# Weather Nearby API Endpoint Design
|
||||
|
||||
**Date:** 2026-03-22
|
||||
**Status:** Approved
|
||||
|
||||
## Overview
|
||||
|
||||
API endpoint to find weather stations within a specified radius of a geographic point, returning stations with recent weather data.
|
||||
|
||||
## Specification
|
||||
|
||||
**Endpoint:** `GET /api/v1/weather/nearby`
|
||||
|
||||
**Purpose:** Find weather stations within a specified radius of a geographic point.
|
||||
|
||||
**Query Parameters:**
|
||||
- `lat` (required) - Latitude (-90 to 90)
|
||||
- `lon` (required) - Longitude (-180 to 180)
|
||||
- `radius` (required) - Search radius in miles (> 0, max 1000)
|
||||
- `hours` (optional) - Time window for "recent" weather data in hours (default: 6, max: 168 for 7 days)
|
||||
- `limit` (optional) - Maximum number of results (default: 50, max: 100)
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/v1/weather/nearby?lat=37.7749&lon=-122.4194&radius=25&hours=6&limit=50
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Database Query Strategy
|
||||
|
||||
Use PostGIS geography type with `ST_DWithin` for efficient spatial filtering:
|
||||
|
||||
```sql
|
||||
-- Conceptual query structure
|
||||
SELECT DISTINCT ON (base_callsign) *
|
||||
FROM packets
|
||||
WHERE has_weather = true
|
||||
AND received_at >= (NOW() - interval '6 hours')
|
||||
AND ST_DWithin(
|
||||
location::geography,
|
||||
ST_SetSRID(ST_MakePoint(lon, lat), 4326)::geography,
|
||||
radius_meters
|
||||
)
|
||||
ORDER BY base_callsign, received_at DESC
|
||||
LIMIT 50
|
||||
```
|
||||
|
||||
Then order the results by distance using `ST_Distance` in a subquery.
|
||||
|
||||
**Performance Notes:**
|
||||
- Leverages existing spatial index on `location` column
|
||||
- `has_weather` boolean index for fast filtering
|
||||
- `DISTINCT ON` prevents duplicate SSIDs
|
||||
- Subquery pattern keeps distance calculation efficient
|
||||
- Miles converted to meters internally (1 mile = 1609.34 meters)
|
||||
|
||||
### Code Structure
|
||||
|
||||
1. **Controller:** `lib/aprsme_web/controllers/api/v1/weather_controller.ex`
|
||||
- Parameter validation (lat/lon ranges, radius > 0, etc.)
|
||||
- Call context function
|
||||
- Render JSON response
|
||||
|
||||
2. **Context Function:** Add to `lib/aprsme/packets/prepared_queries.ex`
|
||||
- `get_nearby_weather_stations(lat, lon, radius_miles, opts)`
|
||||
- Uses prepared query pattern like existing `get_nearby_stations_knn`
|
||||
- Converts miles to meters for PostGIS
|
||||
|
||||
3. **JSON View:** `lib/aprsme_web/controllers/api/v1/weather_json.ex`
|
||||
- Format response structure
|
||||
- Handle null weather fields
|
||||
- Include metadata
|
||||
|
||||
4. **Router:** Add route to `/api/v1` scope in `router.ex`
|
||||
|
||||
## Response Format
|
||||
|
||||
### Success Response (200 OK)
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"callsign": "N0CALL-13",
|
||||
"base_callsign": "N0CALL",
|
||||
"position": {
|
||||
"lat": 37.7849,
|
||||
"lon": -122.4094
|
||||
},
|
||||
"distance_miles": 0.87,
|
||||
"weather": {
|
||||
"temperature": 72.5,
|
||||
"humidity": 65.0,
|
||||
"pressure": 1013.2,
|
||||
"wind_speed": 8.5,
|
||||
"wind_direction": 270,
|
||||
"wind_gust": 12.0,
|
||||
"rain_1h": 0.0,
|
||||
"rain_24h": 0.5,
|
||||
"rain_since_midnight": 0.3
|
||||
},
|
||||
"symbol": {
|
||||
"table_id": "/",
|
||||
"code": "_"
|
||||
},
|
||||
"comment": "Davis Vantage Pro2",
|
||||
"last_report": "2026-03-22T15:30:00Z"
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
"count": 12,
|
||||
"params": {
|
||||
"lat": 37.7749,
|
||||
"lon": -122.4194,
|
||||
"radius_miles": 25,
|
||||
"hours": 6,
|
||||
"limit": 50
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Weather fields are `null` if not reported by the station
|
||||
- Distance calculated as straight-line (great circle) distance
|
||||
- Times in ISO 8601 UTC format
|
||||
|
||||
### Error Responses
|
||||
|
||||
- `400 Bad Request` - Invalid/missing parameters
|
||||
- `422 Unprocessable Entity` - Parameters out of valid range
|
||||
- `500 Internal Server Error` - Database/server error
|
||||
|
||||
## Error Handling & Validation
|
||||
|
||||
### Parameter Validation
|
||||
|
||||
```elixir
|
||||
# Latitude: -90 to 90
|
||||
validate_lat(lat) when lat >= -90 and lat <= 90
|
||||
|
||||
# Longitude: -180 to 180
|
||||
validate_lon(lon) when lon >= -180 and lon <= 180
|
||||
|
||||
# Radius: > 0, reasonable max (e.g., 1000 miles)
|
||||
validate_radius(radius) when radius > 0 and radius <= 1000
|
||||
|
||||
# Hours: 1 to 168 (7 days max)
|
||||
validate_hours(hours) when hours >= 1 and hours <= 168
|
||||
|
||||
# Limit: 1 to 100
|
||||
validate_limit(limit) when limit >= 1 and limit <= 100
|
||||
```
|
||||
|
||||
### Error Messages
|
||||
|
||||
- Missing required param: `"Missing required parameter: lat"`
|
||||
- Invalid format: `"Invalid latitude: must be a number between -90 and 90"`
|
||||
- Out of range: `"Radius must be between 0 and 1000 miles"`
|
||||
|
||||
### Fallback Controller
|
||||
|
||||
Uses existing `AprsmeWeb.Api.V1.FallbackController` to handle:
|
||||
- Validation errors → 400/422 responses
|
||||
- Database errors → 500 with generic message (details logged)
|
||||
- Rate limiting handled by existing `RateLimiter` plug
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- No weather stations found → Return empty array with 200 OK
|
||||
- Antimeridian crossing (longitude wrap) → PostGIS geography handles automatically
|
||||
- Invalid callsign characters → Sanitized by database layer
|
||||
- Timeout on large queries → Database query timeout (existing)
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests (Controller)
|
||||
- Valid parameters return 200 with correct structure
|
||||
- Missing required params return 400
|
||||
- Out-of-range params return 422
|
||||
- Parameter type coercion (string to float/int)
|
||||
- Default values applied correctly
|
||||
|
||||
### Integration Tests (Database)
|
||||
- Find stations within radius (seed test data with known positions)
|
||||
- Exclude stations outside radius
|
||||
- Exclude stations without recent weather data
|
||||
- Exclude stations without `has_weather == true`
|
||||
- Respect time window (hours parameter)
|
||||
- Respect result limit
|
||||
- Order by distance (closest first)
|
||||
- Handle antimeridian crossing
|
||||
- DISTINCT ON base_callsign (no duplicate SSIDs)
|
||||
|
||||
### Test Data Setup
|
||||
```elixir
|
||||
# Center point: 37.7749, -122.4194 (San Francisco)
|
||||
# Station A: 0.5 miles away, weather 1 hour ago ✓
|
||||
# Station B: 10 miles away, weather 2 hours ago ✓
|
||||
# Station C: 50 miles away, weather 1 hour ago ✗ (outside radius)
|
||||
# Station D: 5 miles away, weather 8 hours ago ✗ (outside time window)
|
||||
# Station E: 5 miles away, no weather data ✗ (has_weather == false)
|
||||
```
|
||||
|
||||
### Performance Tests
|
||||
- Query execution time < 100ms for typical queries
|
||||
- Index usage verified with EXPLAIN ANALYZE
|
||||
- Large radius (1000 miles, dense area) completes reasonably
|
||||
|
||||
### Test File Location
|
||||
- `test/aprsme_web/controllers/api/v1/weather_controller_test.exs`
|
||||
|
||||
## Implementation Files
|
||||
|
||||
1. `lib/aprsme_web/controllers/api/v1/weather_controller.ex` (new)
|
||||
2. `lib/aprsme_web/controllers/api/v1/weather_json.ex` (new)
|
||||
3. `lib/aprsme/packets/prepared_queries.ex` (add function)
|
||||
4. `lib/aprsme_web/router.ex` (add route)
|
||||
5. `test/aprsme_web/controllers/api/v1/weather_controller_test.exs` (new)
|
||||
Loading…
Add table
Reference in a new issue