|
| 1 | +# NOAA NWS API Integration Plan |
| 2 | + |
| 3 | +## Current State: IEM/ASOS |
| 4 | + |
| 5 | +**Issues:** |
| 6 | +- Iowa Environmental Mesonet ASOS API frequently returns 503 errors |
| 7 | +- Regional service (Iowa State University) not as reliable as federal sources |
| 8 | +- No SLA or guaranteed uptime |
| 9 | + |
| 10 | +**Usage:** |
| 11 | +- Precipitation data for flood modeling |
| 12 | +- Currently monitoring: PIA (Peoria), BMI (Bloomington), SPI (Springfield), ORD (Chicago), PWK (Wheeling), GBG (Galesburg) |
| 13 | + |
| 14 | +## Proposed: NOAA NWS API |
| 15 | + |
| 16 | +**Advantages:** |
| 17 | +- Federal service with high uptime (>99.5%) |
| 18 | +- Modern RESTful API with JSON responses |
| 19 | +- No API key required (request User-Agent header only) |
| 20 | +- Comprehensive weather data including precipitation, warnings, forecasts |
| 21 | + |
| 22 | +**Base URL:** `https://api.weather.gov` |
| 23 | + |
| 24 | +**Documentation:** https://www.weather.gov/documentation/services-web-api |
| 25 | + |
| 26 | +### Relevant Endpoints |
| 27 | + |
| 28 | +#### 1. Observation Stations by State/Zone |
| 29 | +``` |
| 30 | +GET /stations?state=IL |
| 31 | +``` |
| 32 | +Returns list of all observation stations in Illinois. Use to discover station IDs for our monitoring region. |
| 33 | + |
| 34 | +#### 2. Latest Observation |
| 35 | +``` |
| 36 | +GET /stations/{stationId}/observations/latest |
| 37 | +``` |
| 38 | +Returns most recent observation including: |
| 39 | +- `precipitationLastHour` (in meters, convert to inches) |
| 40 | +- `temperature` |
| 41 | +- `windSpeed` |
| 42 | +- `timestamp` |
| 43 | + |
| 44 | +Example for Peoria (KPIA): |
| 45 | +```bash |
| 46 | +curl -H "User-Agent: FloodMonitor/0.1 (flomon@example.com)" \ |
| 47 | + https://api.weather.gov/stations/KPIA/observations/latest |
| 48 | +``` |
| 49 | + |
| 50 | +Response excerpt: |
| 51 | +```json |
| 52 | +{ |
| 53 | + "properties": { |
| 54 | + "timestamp": "2026-04-06T16:54:00+00:00", |
| 55 | + "temperature": { |
| 56 | + "value": 15.6, |
| 57 | + "unitCode": "wmoUnit:degC" |
| 58 | + }, |
| 59 | + "precipitationLastHour": { |
| 60 | + "value": 0.0, |
| 61 | + "unitCode": "wmoUnit:m", |
| 62 | + "qualityControl": "qc:V" |
| 63 | + } |
| 64 | + } |
| 65 | +} |
| 66 | +``` |
| 67 | + |
| 68 | +#### 3. Observation History |
| 69 | +``` |
| 70 | +GET /stations/{stationId}/observations?start={ISO8601}&end={ISO8601} |
| 71 | +``` |
| 72 | +For backfilling gaps in precipitation data (limited to 7 days history). |
| 73 | + |
| 74 | +### Implementation Plan |
| 75 | + |
| 76 | +#### Phase 1: Add NWS Module (Parallel to ASOS) |
| 77 | +- Create `flomon_service/src/ingest/nws.rs` |
| 78 | +- Implement `fetch_nws_observation(station_id)` -> `WeatherObservation` |
| 79 | +- Add to daemon polling loop alongside ASOS |
| 80 | +- Compare data quality for 7 days before switching |
| 81 | + |
| 82 | +#### Phase 2: Database Schema |
| 83 | +```sql |
| 84 | +-- Option A: Reuse existing asos_observations table (rename to weather_observations) |
| 85 | +ALTER TABLE public.asos_observations RENAME TO weather_observations; |
| 86 | +ALTER TABLE public.weather_observations ADD COLUMN source TEXT DEFAULT 'ASOS'; |
| 87 | +CREATE INDEX idx_weather_observations_source ON weather_observations(source); |
| 88 | + |
| 89 | +-- Option B: New nws_observations table (if schema differs significantly) |
| 90 | +CREATE TABLE public.nws_observations ( |
| 91 | + id SERIAL PRIMARY KEY, |
| 92 | + station_id TEXT NOT NULL, |
| 93 | + observation_time TIMESTAMPTZ NOT NULL, |
| 94 | + temperature_c NUMERIC(5,2), |
| 95 | + precip_1hr_mm NUMERIC(6,2), -- Store in mm, convert to inches in query |
| 96 | + wind_speed_ms NUMERIC(5,2), |
| 97 | + raw_json JSONB, -- Store full response for debugging |
| 98 | + UNIQUE(station_id, observation_time) |
| 99 | +); |
| 100 | +``` |
| 101 | + |
| 102 | +#### Phase 3: Migrate Configuration |
| 103 | +Update `iem_asos.toml` (or create `nws_stations.toml`): |
| 104 | +```toml |
| 105 | +[[stations]] |
| 106 | +station_id = "KPIA" |
| 107 | +location = "Peoria, IL" |
| 108 | +relevance = "PRIMARY" |
| 109 | +zone_assignments = [2] # Upper Peoria Lake zone |
| 110 | + |
| 111 | +[[stations]] |
| 112 | +station_id = "KBMI" |
| 113 | +location = "Bloomington, IL" |
| 114 | +relevance = "UPSTREAM" |
| 115 | +zone_assignments = [3] # Mackinaw tributary zone |
| 116 | +``` |
| 117 | + |
| 118 | +#### Phase 4: Update Endpoint Query |
| 119 | +Modify `flomon_service/src/endpoint.rs` to query from new table/column: |
| 120 | +```rust |
| 121 | +let rows = client.query( |
| 122 | + "SELECT |
| 123 | + CASE |
| 124 | + WHEN precip_1hr_mm IS NOT NULL THEN precip_1hr_mm * 0.0393701 -- mm to inches |
| 125 | + ELSE precip_1hr_in -- Fallback to old ASOS column |
| 126 | + END AS precip_1hr_in, |
| 127 | + observation_time |
| 128 | + FROM public.weather_observations |
| 129 | + WHERE station_id = $1 |
| 130 | + ORDER BY observation_time DESC |
| 131 | + LIMIT 1", |
| 132 | + &[station_id] |
| 133 | +)?; |
| 134 | +``` |
| 135 | + |
| 136 | +#### Phase 5: Deprecate ASOS |
| 137 | +- After 30 days of successful NWS operation, remove IEM/ASOS code |
| 138 | +- Archive `iem_asos.toml` as `iem_asos.toml.deprecated` |
| 139 | +- Update documentation to reflect NWS as primary weather source |
| 140 | + |
| 141 | +### Code Skeleton |
| 142 | + |
| 143 | +**`flomon_service/src/ingest/nws.rs`:** |
| 144 | +```rust |
| 145 | +use serde::Deserialize; |
| 146 | +use chrono::{DateTime, Utc}; |
| 147 | + |
| 148 | +#[derive(Deserialize)] |
| 149 | +struct NwsObservationResponse { |
| 150 | + properties: NwsProperties, |
| 151 | +} |
| 152 | + |
| 153 | +#[derive(Deserialize)] |
| 154 | +struct NwsProperties { |
| 155 | + timestamp: String, |
| 156 | + #[serde(rename = "precipitationLastHour")] |
| 157 | + precipitation_last_hour: Option<NwsValue>, |
| 158 | + temperature: Option<NwsValue>, |
| 159 | +} |
| 160 | + |
| 161 | +#[derive(Deserialize)] |
| 162 | +struct NwsValue { |
| 163 | + value: Option<f64>, |
| 164 | + #[serde(rename = "unitCode")] |
| 165 | + unit_code: String, |
| 166 | +} |
| 167 | + |
| 168 | +pub struct NwsObservation { |
| 169 | + pub station_id: String, |
| 170 | + pub observation_time: DateTime<Utc>, |
| 171 | + pub precip_1hr_mm: Option<f64>, |
| 172 | + pub temperature_c: Option<f64>, |
| 173 | +} |
| 174 | + |
| 175 | +pub fn fetch_nws_observation(station_id: &str) -> Result<NwsObservation, Box<dyn std::error::Error>> { |
| 176 | + let url = format!("https://api.weather.gov/stations/{}/observations/latest", station_id); |
| 177 | + |
| 178 | + let client = reqwest::blocking::Client::builder() |
| 179 | + .user_agent("FloodMonitor/0.1 (contact@example.com)") |
| 180 | + .timeout(std::time::Duration::from_secs(15)) |
| 181 | + .build()?; |
| 182 | + |
| 183 | + let response = client.get(&url).send()?; |
| 184 | + |
| 185 | + if !response.status().is_success() { |
| 186 | + return Err(format!("NWS API returned status {}", response.status()).into()); |
| 187 | + } |
| 188 | + |
| 189 | + let body = response.text()?; |
| 190 | + let data: NwsObservationResponse = serde_json::from_str(&body)?; |
| 191 | + |
| 192 | + let observation_time = DateTime::parse_from_rfc3339(&data.properties.timestamp)? |
| 193 | + .with_timezone(&Utc); |
| 194 | + |
| 195 | + let precip_1hr_mm = data.properties.precipitation_last_hour |
| 196 | + .and_then(|v| v.value) |
| 197 | + .map(|m| m * 1000.0); // Convert meters to millimeters |
| 198 | + |
| 199 | + let temperature_c = data.properties.temperature |
| 200 | + .and_then(|v| v.value); |
| 201 | + |
| 202 | + Ok(NwsObservation { |
| 203 | + station_id: station_id.to_string(), |
| 204 | + observation_time, |
| 205 | + precip_1hr_mm, |
| 206 | + temperature_c, |
| 207 | + }) |
| 208 | +} |
| 209 | +``` |
| 210 | + |
| 211 | +### Rate Limiting |
| 212 | + |
| 213 | +NWS API guidelines: |
| 214 | +- No hard rate limit enforced |
| 215 | +- Recommended: <1000 requests/hour per IP |
| 216 | +- Current load: 6 stations × 4 polls/hour = 24 requests/hour (well within limits) |
| 217 | + |
| 218 | +### Error Handling |
| 219 | + |
| 220 | +NWS API is highly reliable but can have temporary outages. Implement same retry strategy as USGS: |
| 221 | +- 3 attempts with exponential backoff |
| 222 | +- Gracefully degrade (skip precipitation sensors if unavailable) |
| 223 | +- Alert if >24 hours without successful weather data |
| 224 | + |
| 225 | +### Testing Plan |
| 226 | + |
| 227 | +1. **Endpoint Validation**: Verify each station ID returns data |
| 228 | + ```bash |
| 229 | + for station in KPIA KBMI KSPI KORD KPWK KGBG; do |
| 230 | + echo "Testing $station..." |
| 231 | + curl -s -H "User-Agent: Test/1.0" \ |
| 232 | + "https://api.weather.gov/stations/$station/observations/latest" \ |
| 233 | + | jq '.properties.precipitationLastHour' |
| 234 | + done |
| 235 | + ``` |
| 236 | + |
| 237 | +2. **Integration Test**: Add to `tests/data_source_integration.rs` |
| 238 | + ```rust |
| 239 | + #[test] |
| 240 | + fn nws_api_returns_recent_observation() { |
| 241 | + let obs = fetch_nws_observation("KPIA").unwrap(); |
| 242 | + assert!(obs.observation_time > Utc::now() - Duration::hours(1)); |
| 243 | + } |
| 244 | + ``` |
| 245 | + |
| 246 | +3. **Comparison Test**: Run both ASOS and NWS for 7 days, compare: |
| 247 | + - Data freshness (latency) |
| 248 | + - Uptime (successful polls %) |
| 249 | + - Precipitation values (should be identical) |
| 250 | + |
| 251 | +### Migration Timeline |
| 252 | + |
| 253 | +- **Week 1**: Implement NWS module, add to daemon (parallel) |
| 254 | +- **Week 2-3**: Monitor data quality, resolve any issues |
| 255 | +- **Week 4**: Switch zones.toml to use NWS stations, keep ASOS as fallback |
| 256 | +- **Week 5**: Remove ASOS polling if NWS stable |
| 257 | +- **Week 6**: Clean up code, update documentation |
| 258 | + |
| 259 | +### Rollback Plan |
| 260 | + |
| 261 | +If NWS API proves unreliable: |
| 262 | +1. Revert zones.toml to ASOS station_ids |
| 263 | +2. Re-enable IEM ASOS polling in daemon |
| 264 | +3. Consider hybrid approach (both NWS and ASOS, use whichever is fresher) |
| 265 | + |
| 266 | +## References |
| 267 | + |
| 268 | +- NWS API Docs: https://www.weather.gov/documentation/services-web-api |
| 269 | +- NWS API Specification: https://api.weather.gov/openapi.json |
| 270 | +- Station List: https://api.weather.gov/stations?state=IL |
| 271 | +- Example Station: https://api.weather.gov/stations/KPIA/observations/latest |
0 commit comments