@dangahagan/weather-mcp 1.25.18 → 1.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -6
- package/dist/config/cache.d.ts +2 -0
- package/dist/config/cache.d.ts.map +1 -1
- package/dist/config/cache.js +16 -0
- package/dist/config/cache.js.map +1 -1
- package/dist/handlers/riverConditionsHandler.d.ts +26 -7
- package/dist/handlers/riverConditionsHandler.d.ts.map +1 -1
- package/dist/handlers/riverConditionsHandler.js +329 -15
- package/dist/handlers/riverConditionsHandler.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -5
- package/dist/index.js.map +1 -1
- package/dist/services/environmentAgency.d.ts +155 -0
- package/dist/services/environmentAgency.d.ts.map +1 -0
- package/dist/services/environmentAgency.js +344 -0
- package/dist/services/environmentAgency.js.map +1 -0
- package/dist/types/environmentAgency.d.ts +143 -0
- package/dist/types/environmentAgency.d.ts.map +1 -0
- package/dist/types/environmentAgency.js +12 -0
- package/dist/types/environmentAgency.js.map +1 -0
- package/dist/utils/eaGauges.d.ts +250 -0
- package/dist/utils/eaGauges.d.ts.map +1 -0
- package/dist/utils/eaGauges.js +349 -0
- package/dist/utils/eaGauges.js.map +1 -0
- package/dist/utils/geography.d.ts +46 -0
- package/dist/utils/geography.d.ts.map +1 -1
- package/dist/utils/geography.js +54 -0
- package/dist/utils/geography.js.map +1 -1
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/@dangahagan/weather-mcp)
|
|
4
4
|
[](https://registry.modelcontextprotocol.io/v0/servers?search=io.github.dgahagan/weather-mcp)
|
|
5
5
|
[](https://opensource.org/licenses/MIT)
|
|
6
|
-
[](./docs/testing/TEST_SUITE_README.md)
|
|
7
7
|
[](https://nodejs.org)
|
|
8
8
|
|
|
9
9
|
**Give your AI assistant real weather data — 17 tools, zero API keys, zero signup, zero cost.**
|
|
@@ -58,7 +58,7 @@ Choose this one if you want:
|
|
|
58
58
|
|
|
59
59
|
- **Genuinely free** — every data source is a free public API. No trial that expires, no credit card, no rate-limited "free tier" bait.
|
|
60
60
|
- **No API keys** — install to first forecast in under a minute. Nothing to configure, nothing to leak into a repo. ([Three optional keys](#optional-api-keys) add extras if you want them; the default configuration needs none.)
|
|
61
|
-
- **Fully open source** — MIT licensed, readable TypeScript,
|
|
61
|
+
- **Fully open source** — MIT licensed, readable TypeScript, 3,011 tests. Audit it, fork it, fix it.
|
|
62
62
|
- **Privacy-respecting** — your queries go directly from your machine to public weather APIs. No middleman server, no telemetry.
|
|
63
63
|
- **Breadth** — 17 tools covering weather, safety hazards (lightning, floods, wildfires), marine conditions, air quality, and historical data back to 1940. Most weather MCPs stop at forecasts.
|
|
64
64
|
|
|
@@ -80,7 +80,7 @@ All 17 tools, documented in detail in **[docs/TOOLS.md](./docs/TOOLS.md)**:
|
|
|
80
80
|
| `get_marine_conditions` | Wave height, swell, ocean currents, sea state on WMO Code Table 3700 (Douglas sea scale) with a severity marker and legend — includes Great Lakes and major US bays; forecast up to 16 days | 🌍 Global |
|
|
81
81
|
| `get_weather_imagery` | Precipitation radar (static or 2-hour animated loops) + GOES satellite imagery; `composite: true` returns a finished radar map over a base map as an image | 🌍 Global |
|
|
82
82
|
| `get_lightning_activity` | Real-time strike detection with 4-level proximity safety assessment | 🌍 Global |
|
|
83
|
-
| `get_river_conditions` | US: NWPS gauge levels, flood stages, streamflow, rise/fall trends, forecast series. Elsewhere: GloFAS modeled discharge snapped to the nearest river channel, with ensemble forecast | 🌍 Global |
|
|
83
|
+
| `get_river_conditions` | US: NWPS gauge levels, flood stages, streamflow, rise/fall trends, forecast series. Great Britain: Environment Agency observed river levels against each gauge's published typical range. Elsewhere: GloFAS modeled discharge snapped to the nearest river channel, with ensemble forecast | 🌍 Global |
|
|
84
84
|
| `get_wildfire_info` | US: named incidents with containment, size, and safety guidance from the nearest *uncontained* fire. Elsewhere: NASA FIRMS satellite heat detections (VIIRS, near real-time), clustered with distance, bearing, and intensity | 🌍 Global |
|
|
85
85
|
| `check_service_status` | Health checks for all upstream APIs plus cache statistics | — |
|
|
86
86
|
| `save_location` | Save places as aliases ("home", "cabin") with optional activity tags | — |
|
|
@@ -120,6 +120,7 @@ All free, all public, no authentication required:
|
|
|
120
120
|
| [NOAA Weather API](https://www.weather.gov/documentation/services-web-api) | US forecasts, current conditions, alerts, fire weather | US |
|
|
121
121
|
| [Open-Meteo](https://open-meteo.com/) | Global forecasts, historical data (1940+), air quality, marine, geocoding, climate normals, fire-weather inputs | Global |
|
|
122
122
|
| [NOAA NWPS](https://water.noaa.gov/) | River levels, streamflow, flood stages | US |
|
|
123
|
+
| [Environment Agency](https://environment.data.gov.uk/flood-monitoring/doc/reference) | Observed river levels from the real-time gauge network, with published typical ranges — Open Government Licence v3 | Great Britain |
|
|
123
124
|
| [RCC ACIS](https://www.rcc-acis.org/) | Daily record high/low temperatures | US |
|
|
124
125
|
| [EUMETNET MeteoAlarm](https://meteoalarm.org/) | Official national weather warnings (38 European countries) | Europe |
|
|
125
126
|
| [MSC GeoMet](https://api.weather.gc.ca/) | Environment and Climate Change Canada weather alerts | Canada |
|
|
@@ -250,7 +251,7 @@ Or per call — the AI can honor "in Celsius" on the fly:
|
|
|
250
251
|
{ "latitude": 47.6, "longitude": -122.3, "wind_speed_unit": "kn", "pressure_unit": "hPa" }
|
|
251
252
|
```
|
|
252
253
|
|
|
253
|
-
Supported on `get_forecast`, `get_current_conditions`, and `get_historical_weather`. Wind accepts `mph`/`kmh`/`ms`/`kn`; pressure `inHg`/`hPa`. Domain-specialized readings (fire-weather heights, river gauge stage, and the marine tool's dual-unit wave output) keep their conventional units.
|
|
254
|
+
Supported on `get_forecast`, `get_current_conditions`, and `get_historical_weather`. Wind accepts `mph`/`kmh`/`ms`/`kn`; pressure `inHg`/`hPa`. Domain-specialized readings (fire-weather heights, NWPS river gauge stage, and the marine tool's dual-unit wave output) keep their conventional units — NWPS stage is always feet. The Environment Agency river level is the exception: it is published in metres and follows the distance preference, so it renders in metres under `km` and feet under `mi`.
|
|
254
255
|
|
|
255
256
|
### Other settings
|
|
256
257
|
|
|
@@ -354,7 +355,7 @@ Being honest about what free public data can and can't do:
|
|
|
354
355
|
| Air quality, marine, radar, lightning | ✅ | — |
|
|
355
356
|
| Current conditions | ✅ (model data, or real station observations via `source="metar"`) | Station observations via NOAA (richer detail) |
|
|
356
357
|
| Weather alerts | ✅ US, Canada, 38 European countries, India, the Philippines, and Indonesia keyless; ~45+ more territories with an optional key | NWS zone-level precision; Europe and India country-level; Philippines and Indonesia polygon-level |
|
|
357
|
-
| River conditions | ✅ (GloFAS modeled discharge) | Gauge observations + official flood stages via NWPS |
|
|
358
|
+
| River conditions | ✅ (GloFAS modeled discharge; Environment Agency gauge observations in Great Britain) | Gauge observations + official flood stages via NWPS |
|
|
358
359
|
| Wildfires | ✅ (FIRMS satellite detections) | Named incidents with acreage + containment via NIFC |
|
|
359
360
|
| Fire weather | ✅ (computed Fosberg index + dryness context) | NOAA-published Haines, grassland, red-flag indices |
|
|
360
361
|
| Pollen | 🇪🇺 Europe keyless (CAMS, grains/m³); elsewhere needs an optional key | Universal Pollen Index with `GOOGLE_POLLEN_API_KEY` |
|
|
@@ -378,7 +379,7 @@ Being honest about what free public data can and can't do:
|
|
|
378
379
|
```bash
|
|
379
380
|
npm run build # Compile TypeScript
|
|
380
381
|
npm run dev # Run in development mode
|
|
381
|
-
npm test # Run all
|
|
382
|
+
npm test # Run all 3,011 tests
|
|
382
383
|
npm run test:coverage # Coverage report
|
|
383
384
|
npm run audit # Dependency vulnerability scan
|
|
384
385
|
```
|
package/dist/config/cache.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../../src/config/cache.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAkDH,eAAO,MAAM,WAAW;aAEtB,OAAO;aAIP,OAAO;aAIP,YAAY;aAGZ,GAAG;iBAGD,eAAe;iBAIf,SAAS;iBAIT,QAAQ;iBAIR,QAAQ;iBAIR,iBAAiB;iBAIjB,MAAM;iBAIN,gBAAgB;iBAIhB,cAAc;iBAKd,OAAO;iBAIP,aAAa;iBAMb,cAAc;iBAOd,eAAe;iBAMf,YAAY;iBAMZ,OAAO;iBAIP,cAAc;iBAMd,iBAAiB;iBAQjB,YAAY;iBAQZ,cAAc;iBAKd,cAAc;iBAKd,cAAc;iBAKd,iBAAiB;iBAOjB,WAAW;;
|
|
1
|
+
{"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../../src/config/cache.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAkDH,eAAO,MAAM,WAAW;aAEtB,OAAO;aAIP,OAAO;aAIP,YAAY;aAGZ,GAAG;iBAGD,eAAe;iBAIf,SAAS;iBAIT,QAAQ;iBAIR,QAAQ;iBAIR,iBAAiB;iBAIjB,MAAM;iBAIN,gBAAgB;iBAIhB,cAAc;iBAKd,OAAO;iBAIP,aAAa;iBAMb,cAAc;iBAOd,eAAe;iBAMf,YAAY;iBAMZ,OAAO;iBAIP,cAAc;iBAMd,iBAAiB;iBAQjB,YAAY;iBAQZ,cAAc;iBAKd,cAAc;iBAKd,cAAc;iBAKd,iBAAiB;iBAOjB,WAAW;iBAMX,gBAAgB;iBAYhB,eAAe;;CAET,CAAC;AAEX;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAYrE"}
|
package/dist/config/cache.js
CHANGED
|
@@ -153,6 +153,22 @@ export const CacheConfig = {
|
|
|
153
153
|
// 24h entry rather than Infinity so a long-running server's LRU is not
|
|
154
154
|
// pinned with dead alerts.
|
|
155
155
|
capDocument: 24 * HOUR,
|
|
156
|
+
// UK Environment Agency bulk latest-readings pull (GET /data/readings?latest&
|
|
157
|
+
// parameter=level). Every EA gauge measure publishes on a 15-minute period
|
|
158
|
+
// (period: 900), so a shorter TTL buys nothing real — the value cannot
|
|
159
|
+
// change between now and the next 15-minute tick.
|
|
160
|
+
eaLatestReadings: 15 * MINUTE,
|
|
161
|
+
// EA per-station detail (GET /id/stations/{ref}?_view=full) — mirrors
|
|
162
|
+
// nwpsGaugeDetail: read only for its stageScale threshold fields
|
|
163
|
+
// (datum, typicalRangeHigh/Low, scaleMax, min/maxOnRecord), which are
|
|
164
|
+
// gauge metadata revised on the order of years. This entry MUST carry the
|
|
165
|
+
// threshold fields only and never a reading — the same response body also
|
|
166
|
+
// carries each measure's 15-minute-volatile latestReading, and caching
|
|
167
|
+
// that whole would serve a day-old river level as current on a
|
|
168
|
+
// safety-critical output surface (see G7). The service builds a fresh
|
|
169
|
+
// copy each refresh and merges the volatile reading into that copy;
|
|
170
|
+
// only the threshold projection is cached at this TTL.
|
|
171
|
+
eaStationDetail: 24 * HOUR,
|
|
156
172
|
},
|
|
157
173
|
};
|
|
158
174
|
/**
|
package/dist/config/cache.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cache.js","sourceRoot":"","sources":["../../src/config/cache.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"cache.js","sourceRoot":"","sources":["../../src/config/cache.ts"],"names":[],"mappings":"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"}
|
|
@@ -6,6 +6,7 @@ import { OpenMeteoService } from '../services/openmeteo.js';
|
|
|
6
6
|
import { LocationStore } from '../services/locationStore.js';
|
|
7
7
|
import { GeocodingService } from '../services/geocoding.js';
|
|
8
8
|
import { NominatimService } from '../services/nominatim.js';
|
|
9
|
+
import { EnvironmentAgencyService } from '../services/environmentAgency.js';
|
|
9
10
|
import type { GaugeStatus, StageFlowDataPoint } from '../types/noaa.js';
|
|
10
11
|
/**
|
|
11
12
|
* True only for a real, present numeric reading (not null, not a missing-data sentinel).
|
|
@@ -39,14 +40,32 @@ export declare function formatStageTrend(trend: StageTrend): string;
|
|
|
39
40
|
/**
|
|
40
41
|
* Route a river request to gauge observations or to the global discharge model.
|
|
41
42
|
*
|
|
42
|
-
* `auto` sends US coordinates to NOAA's NWPS gauge network (unchanged)
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
43
|
+
* `auto` sends US coordinates to NOAA's NWPS gauge network (unchanged), Great
|
|
44
|
+
* Britain to the Environment Agency's real gauge network, and everywhere else
|
|
45
|
+
* to the Open-Meteo Flood API. An explicit `source` forces the branch. There is
|
|
46
|
+
* deliberately no cross-fallback: an observed river stage in feet against
|
|
47
|
+
* official flood categories, an observed level in metres against a published
|
|
48
|
+
* typical range, and a modeled discharge in m³/s against its own history are
|
|
49
|
+
* three different claims, and silently swapping one for another would
|
|
50
|
+
* misrepresent the data (design D1).
|
|
51
|
+
*
|
|
52
|
+
* **Why the GB arm is gated on a box before a country lookup.** Routing Great
|
|
53
|
+
* Britain on a country code alone would fire a Nominatim reverse-geocode on
|
|
54
|
+
* every `auto` river request on Earth outside the US boxes, against a 1 req/sec
|
|
55
|
+
* server-wide limit, to answer `'gb'` for a fraction of a percent of them.
|
|
56
|
+
* `isInGreatBritain` is the cheap pre-gate that decides whether the lookup is
|
|
57
|
+
* worth making; the country code is what actually decides. The box routes and
|
|
58
|
+
* never renders — no sentence below is derived from it (G53).
|
|
59
|
+
*
|
|
60
|
+
* **The EA arm requires a positively-resolved `'gb'`, never a negative test.**
|
|
61
|
+
* With no country signal at all — no saved `country_code` and no Nominatim
|
|
62
|
+
* service — `resolveCountryCode` returns `{ countryCode: null }`, which is not
|
|
63
|
+
* `'gb'`, so `auto` falls to GloFAS. That is a required behaviour, not an
|
|
64
|
+
* accident: 19 existing tests in `tests/unit/river-conditions-global.test.ts`
|
|
65
|
+
* pass London on `auto` with no Nominatim fake wired and assert the Open-Meteo
|
|
66
|
+
* path, and this condition is the single thing keeping all of them green.
|
|
48
67
|
*/
|
|
49
|
-
export declare function handleGetRiverConditions(args: unknown, noaaService: NOAAService, locationStore: LocationStore, geocodingService: GeocodingService, openMeteoService?: OpenMeteoService, nominatimService?: NominatimService): Promise<{
|
|
68
|
+
export declare function handleGetRiverConditions(args: unknown, noaaService: NOAAService, locationStore: LocationStore, geocodingService: GeocodingService, openMeteoService?: OpenMeteoService, nominatimService?: NominatimService, eaService?: EnvironmentAgencyService): Promise<{
|
|
50
69
|
content: Array<{
|
|
51
70
|
type: string;
|
|
52
71
|
text: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"riverConditionsHandler.d.ts","sourceRoot":"","sources":["../../src/handlers/riverConditionsHandler.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAClD,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAC5D,OAAO,EAAE,aAAa,EAAE,MAAM,8BAA8B,CAAC;AAC7D,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAE5D,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;
|
|
1
|
+
{"version":3,"file":"riverConditionsHandler.d.ts","sourceRoot":"","sources":["../../src/handlers/riverConditionsHandler.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAClD,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAC5D,OAAO,EAAE,aAAa,EAAE,MAAM,8BAA8B,CAAC;AAC7D,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAE5D,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAsB5D,OAAO,EAAE,wBAAwB,EAAE,MAAM,kCAAkC,CAAC;AAS5E,OAAO,KAAK,EAAa,WAAW,EAAyB,kBAAkB,EAAmB,MAAM,kBAAkB,CAAC;AAgC3H;;GAEG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,KAAK,IAAI,MAAM,CAE7E;AAcD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAE7D;AAoCD,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,QAAQ,GAAG,SAAS,GAAG,QAAQ,CAAC;IAC3C,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,kBAAkB,EAAE,GAAG,SAAS,EACxC,WAAW,GAAE,MAA2B,GACvC,UAAU,GAAG,SAAS,CA6BxB;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAO1D;AAaD;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAsB,wBAAwB,CAC5C,IAAI,EAAE,OAAO,EACb,WAAW,EAAE,WAAW,EACxB,aAAa,EAAE,aAAa,EAC5B,gBAAgB,EAAE,gBAAgB,EAClC,gBAAgB,CAAC,EAAE,gBAAgB,EACnC,gBAAgB,CAAC,EAAE,gBAAgB,EACnC,SAAS,CAAC,EAAE,wBAAwB,GACnC,OAAO,CAAC;IAAE,OAAO,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;CAAE,CAAC,CA6D7D"}
|
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { resolveCountryCode, resolveLocationAsync, prependLocationLine } from '../utils/locationResolver.js';
|
|
5
5
|
import { validateDetail, validatePositiveInteger } from '../utils/validation.js';
|
|
6
|
-
import { formatInTimezone, guessTimezoneFromCoords } from '../utils/timezone.js';
|
|
6
|
+
import { formatInTimezone, formatObservationAge, guessTimezoneFromCoords } from '../utils/timezone.js';
|
|
7
7
|
import { calculateDistance } from '../utils/distance.js';
|
|
8
8
|
import { displayValue } from '../utils/displayBanding.js';
|
|
9
|
-
import { isInUS } from '../utils/geography.js';
|
|
9
|
+
import { isInUS, isInGreatBritain } from '../utils/geography.js';
|
|
10
10
|
import { resolveUnitPreferences } from '../utils/unitPreferences.js';
|
|
11
11
|
import { cubicMetersPerSecondToCubicFeetPerSecond } from '../utils/units.js';
|
|
12
12
|
import { buildProbeGrid, pickChannelCell, findTodayIndex, pastWindowValues, recentWindowValues, classifyDischargeTrend, formatDischargeTrend, classifyAgainstRecentMean, describeMinorDrainage, formatSnapNote, PROBE_GRID_CENTER_INDEX } from '../utils/riverDischarge.js';
|
|
13
|
+
import { filterStationsWithRiverName, selectStageMeasure, formatStageLevel, bandRiverLevel } from '../utils/eaGauges.js';
|
|
13
14
|
import { RateLimitError } from '../errors/ApiError.js';
|
|
14
15
|
/**
|
|
15
16
|
* Countries NWPS actually gauges. Measured live 2026-08-28 against
|
|
@@ -133,26 +134,81 @@ export function formatStageTrend(trend) {
|
|
|
133
134
|
/**
|
|
134
135
|
* Route a river request to gauge observations or to the global discharge model.
|
|
135
136
|
*
|
|
136
|
-
* `auto` sends US coordinates to NOAA's NWPS gauge network (unchanged)
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
137
|
+
* `auto` sends US coordinates to NOAA's NWPS gauge network (unchanged), Great
|
|
138
|
+
* Britain to the Environment Agency's real gauge network, and everywhere else
|
|
139
|
+
* to the Open-Meteo Flood API. An explicit `source` forces the branch. There is
|
|
140
|
+
* deliberately no cross-fallback: an observed river stage in feet against
|
|
141
|
+
* official flood categories, an observed level in metres against a published
|
|
142
|
+
* typical range, and a modeled discharge in m³/s against its own history are
|
|
143
|
+
* three different claims, and silently swapping one for another would
|
|
144
|
+
* misrepresent the data (design D1).
|
|
145
|
+
*
|
|
146
|
+
* **Why the GB arm is gated on a box before a country lookup.** Routing Great
|
|
147
|
+
* Britain on a country code alone would fire a Nominatim reverse-geocode on
|
|
148
|
+
* every `auto` river request on Earth outside the US boxes, against a 1 req/sec
|
|
149
|
+
* server-wide limit, to answer `'gb'` for a fraction of a percent of them.
|
|
150
|
+
* `isInGreatBritain` is the cheap pre-gate that decides whether the lookup is
|
|
151
|
+
* worth making; the country code is what actually decides. The box routes and
|
|
152
|
+
* never renders — no sentence below is derived from it (G53).
|
|
153
|
+
*
|
|
154
|
+
* **The EA arm requires a positively-resolved `'gb'`, never a negative test.**
|
|
155
|
+
* With no country signal at all — no saved `country_code` and no Nominatim
|
|
156
|
+
* service — `resolveCountryCode` returns `{ countryCode: null }`, which is not
|
|
157
|
+
* `'gb'`, so `auto` falls to GloFAS. That is a required behaviour, not an
|
|
158
|
+
* accident: 19 existing tests in `tests/unit/river-conditions-global.test.ts`
|
|
159
|
+
* pass London on `auto` with no Nominatim fake wired and assert the Open-Meteo
|
|
160
|
+
* path, and this condition is the single thing keeping all of them green.
|
|
142
161
|
*/
|
|
143
|
-
export async function handleGetRiverConditions(args, noaaService, locationStore, geocodingService, openMeteoService, nominatimService) {
|
|
162
|
+
export async function handleGetRiverConditions(args, noaaService, locationStore, geocodingService, openMeteoService, nominatimService, eaService) {
|
|
144
163
|
// Resolve location from coordinates, a saved location name, or a geocoded city name
|
|
145
164
|
const resolved = await resolveLocationAsync(args, locationStore, geocodingService);
|
|
146
165
|
const { latitude, longitude } = resolved;
|
|
147
166
|
// Output verbosity: 'full' lifts the gauge/crest display caps to 25 (not unbounded).
|
|
148
167
|
const detail = validateDetail(args?.detail);
|
|
149
168
|
const requestedSource = args?.source || 'auto';
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
169
|
+
// A three-way decision resolved before any request is made. An *unrecognized*
|
|
170
|
+
// source string must still land on Open-Meteo exactly as it did before this
|
|
171
|
+
// arm existed: the pre-existing `|| 'auto'` above catches only falsy values,
|
|
172
|
+
// so an unknown string fell through `=== 'noaa'` being false. The final
|
|
173
|
+
// `else` preserves that byte-for-byte. Deliberately not the coerce-to-`auto`
|
|
174
|
+
// shape used in `wildfireHandler.ts` — that would change existing behaviour
|
|
175
|
+
// on a path this change may not touch.
|
|
176
|
+
let route;
|
|
177
|
+
if (requestedSource === 'noaa') {
|
|
178
|
+
route = 'noaa';
|
|
179
|
+
}
|
|
180
|
+
else if (requestedSource === 'ea') {
|
|
181
|
+
// Honoured anywhere, with a coverage disclosure when it returns nothing.
|
|
182
|
+
route = 'ea';
|
|
183
|
+
}
|
|
184
|
+
else if (requestedSource === 'auto') {
|
|
185
|
+
if (isInUS(latitude, longitude)) {
|
|
186
|
+
route = 'noaa';
|
|
187
|
+
}
|
|
188
|
+
else if (isInGreatBritain(latitude, longitude)) {
|
|
189
|
+
// Only inside the box, and only on `auto`, is the lookup worth making.
|
|
190
|
+
// `resolveCountryCode` lowercases, and a saved location's stored
|
|
191
|
+
// `country_code` (documented as e.g. `"GB"`) short-circuits it entirely.
|
|
192
|
+
const { countryCode } = await resolveCountryCode(resolved.country_code, latitude, longitude, nominatimService);
|
|
193
|
+
route = countryCode === 'gb' ? 'ea' : 'openmeteo';
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
route = 'openmeteo';
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
route = 'openmeteo';
|
|
201
|
+
}
|
|
202
|
+
let output;
|
|
203
|
+
if (route === 'noaa') {
|
|
204
|
+
output = await formatNOAARiverConditions(noaaService, latitude, longitude, args, detail, resolved.country_code, nominatimService);
|
|
205
|
+
}
|
|
206
|
+
else if (route === 'ea') {
|
|
207
|
+
output = await formatEARiverConditions(eaService, latitude, longitude, args, detail);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
output = await formatOpenMeteoRiverConditions(openMeteoService, latitude, longitude, args, detail);
|
|
211
|
+
}
|
|
156
212
|
return prependLocationLine({
|
|
157
213
|
content: [
|
|
158
214
|
{
|
|
@@ -340,6 +396,264 @@ async function formatNOAARiverConditions(noaaService, latitude, longitude, args,
|
|
|
340
396
|
output += `*River conditions are updated hourly. Always consult official sources for critical decisions.*\n`;
|
|
341
397
|
return output;
|
|
342
398
|
}
|
|
399
|
+
/** Header line naming the authority behind the Great Britain path. */
|
|
400
|
+
const EA_SOURCE_LINE = '**Source:** Environment Agency real-time river level gauges (observed, 15-minute)';
|
|
401
|
+
/**
|
|
402
|
+
* The attribution the Open Government Licence requires for this API, quoted
|
|
403
|
+
* **exactly** as the Environment Agency publishes it at
|
|
404
|
+
* `environment.data.gov.uk/flood-monitoring/doc/reference` (fetched
|
|
405
|
+
* 2026-09-02): "If you make use of this data please acknowledge this with the
|
|
406
|
+
* following attribution statement".
|
|
407
|
+
*
|
|
408
|
+
* A licence-mandated string is not ours to reword, reflow, or capitalise
|
|
409
|
+
* differently. Copy it, do not retype it.
|
|
410
|
+
*/
|
|
411
|
+
const EA_ATTRIBUTION = 'this uses Environment Agency flood and river level data from the real-time data API (Beta)';
|
|
412
|
+
/**
|
|
413
|
+
* How many gauges get their published typical range fetched, regardless of
|
|
414
|
+
* `detail`.
|
|
415
|
+
*
|
|
416
|
+
* Measured parallel fan-out for `/id/stations/{ref}?_view=full` is
|
|
417
|
+
* non-monotonic, with a local worst case at 8 concurrent requests (15.6 s) —
|
|
418
|
+
* not at the top of the sweep. So the bound is 5, and the remaining gauges
|
|
419
|
+
* render without a typical range rather than paying for it.
|
|
420
|
+
*
|
|
421
|
+
* **G8 — this is an enrichment bound and nothing else.** It must never change
|
|
422
|
+
* which gauges are listed, which are counted in the "found N gauges" line, or
|
|
423
|
+
* which are considered. A bound that trims a set used for exclusion is exactly
|
|
424
|
+
* how a defensive limit fabricates an all-clear.
|
|
425
|
+
*/
|
|
426
|
+
const EA_DETAIL_FAN_OUT = 5;
|
|
427
|
+
/**
|
|
428
|
+
* Default search radius for the EA path, in km. Deliberately smaller than the
|
|
429
|
+
* NOAA path's 50: the EA network is dense, and a 25 km search at York returns
|
|
430
|
+
* 80 stations of which 68 are river gauges (measured live 2026-09-02).
|
|
431
|
+
*/
|
|
432
|
+
const EA_DEFAULT_RADIUS_KM = 25;
|
|
433
|
+
/**
|
|
434
|
+
* What an EA level number is measured *from*, in words.
|
|
435
|
+
*
|
|
436
|
+
* This is not cosmetic. The EA publishes stage in more than one basis, and 21
|
|
437
|
+
* of the 68 river gauges around York publish theirs only in `mAOD` — metres
|
|
438
|
+
* Above Ordnance Datum, which is a water-surface **elevation above sea level**,
|
|
439
|
+
* not a river depth. Rendering "24.88 ft" for a gauge on the River Foss without
|
|
440
|
+
* saying so invites the reader to conclude the river is twenty-five feet deep.
|
|
441
|
+
* On a safety-critical surface that is the mislabelled-quantity defect this
|
|
442
|
+
* project has produced before, so the basis is always stated beside the number.
|
|
443
|
+
*
|
|
444
|
+
* `m` and `mASD` are heights above the gauge's own zero; `mAOD` is an absolute
|
|
445
|
+
* elevation. The published typical range is in the same basis as the station's
|
|
446
|
+
* own live stage measure, so the comparison stays coherent either way.
|
|
447
|
+
*/
|
|
448
|
+
function eaLevelBasis(unitName) {
|
|
449
|
+
switch (unitName) {
|
|
450
|
+
case 'm':
|
|
451
|
+
case 'mASD':
|
|
452
|
+
return 'above the gauge datum';
|
|
453
|
+
case 'mAOD':
|
|
454
|
+
return 'above Ordnance Datum (a water-surface elevation, not river depth)';
|
|
455
|
+
default:
|
|
456
|
+
return '';
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* The Great Britain path: Environment Agency gauge observations.
|
|
461
|
+
*
|
|
462
|
+
* **Contract, not garnish.** A failed EA fetch propagates rather than
|
|
463
|
+
* degrading to an empty gauge list, because an empty gauge list reads as "no
|
|
464
|
+
* flooding here". This function therefore does not wrap the fetch in a
|
|
465
|
+
* catch-all that returns a successful-looking empty render.
|
|
466
|
+
*
|
|
467
|
+
* The coverage claim this renders is **the EA river-gauge network**, and it is
|
|
468
|
+
* made true by construction by the `riverName` filter, not by any geographic
|
|
469
|
+
* box. A station carrying `riverName` is a river gauge wherever it sits —
|
|
470
|
+
* Sprouston on the Tweed is in Scotland and is correctly included; Leith is a
|
|
471
|
+
* tide gauge and is correctly excluded (G53).
|
|
472
|
+
*/
|
|
473
|
+
async function formatEARiverConditions(eaService, latitude, longitude, args, detail) {
|
|
474
|
+
// A missing service is a wiring fault, not an empty result. Reporting "no
|
|
475
|
+
// gauges" here would be the fabricated all-clear this whole path is built to
|
|
476
|
+
// avoid, so it is a contract failure with a message that names the cause.
|
|
477
|
+
if (!eaService) {
|
|
478
|
+
throw new Error('Environment Agency river gauge data is unavailable: the service is not configured');
|
|
479
|
+
}
|
|
480
|
+
let radius = args?.radius ?? EA_DEFAULT_RADIUS_KM;
|
|
481
|
+
if (typeof radius !== 'number' || isNaN(radius) || !isFinite(radius)) {
|
|
482
|
+
radius = EA_DEFAULT_RADIUS_KM;
|
|
483
|
+
}
|
|
484
|
+
radius = Math.max(1, Math.min(radius, 500));
|
|
485
|
+
const prefs = resolveUnitPreferences(args);
|
|
486
|
+
let output = `# River Conditions Report\n\n`;
|
|
487
|
+
output += `**Location:** ${latitude.toFixed(4)}, ${longitude.toFixed(4)}\n`;
|
|
488
|
+
output += `**Search Radius:** ${radius} km (${(radius * 0.621371).toFixed(1)} miles)\n`;
|
|
489
|
+
output += `${EA_SOURCE_LINE}\n\n`;
|
|
490
|
+
// Both fetches propagate on failure — see the contract note above.
|
|
491
|
+
const [stationsResult, readingsResult] = await Promise.all([
|
|
492
|
+
eaService.getStationsNear(latitude, longitude, radius),
|
|
493
|
+
eaService.getLatestLevelReadings()
|
|
494
|
+
]);
|
|
495
|
+
// G6 — the list is cached unfiltered; the filter that establishes the
|
|
496
|
+
// coverage claim runs here, at read time, on every return.
|
|
497
|
+
const riverStations = filterStationsWithRiverName(stationsResult.stations);
|
|
498
|
+
const withReadings = riverStations
|
|
499
|
+
.map(station => ({
|
|
500
|
+
station,
|
|
501
|
+
selected: selectStageMeasure(station, readingsResult.readings),
|
|
502
|
+
distance: station.lat != null && station.long != null
|
|
503
|
+
? calculateDistance(latitude, longitude, station.lat, station.long)
|
|
504
|
+
: undefined
|
|
505
|
+
}))
|
|
506
|
+
.sort((a, b) => (a.distance ?? Infinity) - (b.distance ?? Infinity));
|
|
507
|
+
if (withReadings.length === 0) {
|
|
508
|
+
// Two different empty results, told apart by the DATA rather than by any
|
|
509
|
+
// geographic box — the box routes and must never decide a rendered claim
|
|
510
|
+
// (G53). Zero stations of any kind means the network does not reach here;
|
|
511
|
+
// stations that are all tidal means it reaches here but publishes no river
|
|
512
|
+
// gauge. Those are different statements and a reader can act on the
|
|
513
|
+
// difference.
|
|
514
|
+
//
|
|
515
|
+
// G47 — a zero here is a real zero, not a throttled one: this service
|
|
516
|
+
// treats any non-2xx as a thrown error that propagates, so a rate-limited
|
|
517
|
+
// upstream can never arrive at this branch looking like an empty result.
|
|
518
|
+
if (stationsResult.stations.length === 0) {
|
|
519
|
+
output += `**No Environment Agency monitoring stations were found within ${radius} km.**\n\n`;
|
|
520
|
+
output += `That is an absence of coverage, not an all-clear. Rivers here may be in `;
|
|
521
|
+
output += `flood; the Environment Agency simply does not monitor them at this `;
|
|
522
|
+
output += `location.\n\n`;
|
|
523
|
+
output += `Try a larger \`radius\`, or use \`source: "openmeteo"\` for Open-Meteo `;
|
|
524
|
+
output += `Flood (GloFAS) modeled river discharge, which is global.\n`;
|
|
525
|
+
}
|
|
526
|
+
else {
|
|
527
|
+
output += `**No Environment Agency river gauges were found within ${radius} km.**\n\n`;
|
|
528
|
+
output += `${stationsResult.stations.length} Environment Agency monitoring `;
|
|
529
|
+
output += `station${stationsResult.stations.length > 1 ? 's were' : ' was'} found nearby, but `;
|
|
530
|
+
output += `${stationsResult.stations.length > 1 ? 'none of them publish' : 'it does not publish'} `;
|
|
531
|
+
output += `a river name — they are tidal or coastal gauges, which this tool does not `;
|
|
532
|
+
output += `report as river levels.\n\n`;
|
|
533
|
+
output += `That is an absence of river gauging, not an all-clear. Try a larger `;
|
|
534
|
+
output += `\`radius\`, or use \`source: "openmeteo"\` for Open-Meteo Flood (GloFAS) `;
|
|
535
|
+
output += `modeled river discharge, which is global.\n`;
|
|
536
|
+
}
|
|
537
|
+
output += `\n---\n`;
|
|
538
|
+
output += `*${EA_ATTRIBUTION}*\n`;
|
|
539
|
+
return output;
|
|
540
|
+
}
|
|
541
|
+
const maxGaugesToShow = detail === 'full' ? 25 : 5;
|
|
542
|
+
const gaugesToShow = withReadings.slice(0, maxGaugesToShow);
|
|
543
|
+
// G8 — the count is derived from the full filtered set, never from the
|
|
544
|
+
// display slice or the enrichment slice. A cap that changed the reported
|
|
545
|
+
// number would be a defensive limit quietly rewriting what the reader is
|
|
546
|
+
// told.
|
|
547
|
+
output += `📊 **Found ${withReadings.length} river gauge${withReadings.length > 1 ? 's' : ''}**\n\n`;
|
|
548
|
+
// The typical range is GARNISH inside a contract. It is fetched for at most
|
|
549
|
+
// EA_DETAIL_FAN_OUT of the *displayed* gauges, in parallel, in one try/catch
|
|
550
|
+
// with no retries — a failure here must add no latency and must never fail
|
|
551
|
+
// the request. A gauge without a range renders its level and says nothing
|
|
552
|
+
// more; it is still listed and still counted.
|
|
553
|
+
const enrichTargets = gaugesToShow.slice(0, EA_DETAIL_FAN_OUT);
|
|
554
|
+
const thresholdsByRef = new Map();
|
|
555
|
+
await Promise.all(enrichTargets.map(async ({ station }) => {
|
|
556
|
+
const ref = station.notation ?? station.stationReference;
|
|
557
|
+
if (!ref) {
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
try {
|
|
561
|
+
const thresholds = await eaService.getStationDetail(ref);
|
|
562
|
+
if (thresholds) {
|
|
563
|
+
thresholdsByRef.set(ref, thresholds);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
catch {
|
|
567
|
+
// Garnish: degrade to no range for this gauge, silently and without
|
|
568
|
+
// retrying. The level itself is already in hand.
|
|
569
|
+
}
|
|
570
|
+
}));
|
|
571
|
+
for (const { station, selected, distance } of gaugesToShow) {
|
|
572
|
+
const label = typeof station.label === 'string' ? station.label : station.riverName;
|
|
573
|
+
output += `### ${station.riverName}${label && label !== station.riverName ? ` — ${label}` : ''}\n`;
|
|
574
|
+
if (distance !== undefined) {
|
|
575
|
+
// Both units unconditionally, matching the NWPS gauge block below.
|
|
576
|
+
output += `- **Distance:** ${distance.toFixed(1)} km (${(distance * 0.621371).toFixed(1)} mi)\n`;
|
|
577
|
+
}
|
|
578
|
+
if (!selected) {
|
|
579
|
+
// Honest absence: the station exists and is a river gauge, but no
|
|
580
|
+
// qualifying level reading is currently published for it. Never a value,
|
|
581
|
+
// and never anything that could read as "the river is fine".
|
|
582
|
+
output += `- **Level:** not currently reported by this gauge\n`;
|
|
583
|
+
output += `\n`;
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
const level = formatStageLevel(selected.value, prefs);
|
|
587
|
+
const basis = eaLevelBasis(selected.unitName);
|
|
588
|
+
output += `- **Level:** ${level.formatted}${basis ? ` ${basis}` : ''}\n`;
|
|
589
|
+
output += `- **Observed:** ${formatObservationAge(selected.ageMinutes)}\n`;
|
|
590
|
+
const ref = station.notation ?? station.stationReference;
|
|
591
|
+
const thresholds = ref ? thresholdsByRef.get(ref) : undefined;
|
|
592
|
+
// Banded in display space with the same prefs used to print the level
|
|
593
|
+
// above, so the words and the figures cannot disagree at a rounding edge.
|
|
594
|
+
// A position report against the published range — never a flood category;
|
|
595
|
+
// the EA publishes no action/minor/moderate/major equivalent on this API
|
|
596
|
+
// and `typicalRangeHigh` is not a flood threshold.
|
|
597
|
+
const band = thresholds ? bandRiverLevel(selected, thresholds, prefs) : null;
|
|
598
|
+
// **The range is printed only when it is comparable to the level above it.**
|
|
599
|
+
// `bandRiverLevel` refuses a measure whose qualifier is not `Stage`, because
|
|
600
|
+
// `stageScale` belongs to the station's Stage measure — and some stations
|
|
601
|
+
// carry a `riverName` while publishing only a `Tidal Level` measure (the
|
|
602
|
+
// River Tweed at Berwick is one, live). Printing the range anyway and
|
|
603
|
+
// merely withholding our own verdict would be worse than useless: the two
|
|
604
|
+
// numbers sit adjacent and the reader makes the unsafe comparison instead
|
|
605
|
+
// of us. So when there is no band, there is no range either.
|
|
606
|
+
if (band && thresholds) {
|
|
607
|
+
const low = thresholds.typicalRangeLow === undefined
|
|
608
|
+
? undefined
|
|
609
|
+
: formatStageLevel(thresholds.typicalRangeLow, prefs);
|
|
610
|
+
const high = thresholds.typicalRangeHigh === undefined
|
|
611
|
+
? undefined
|
|
612
|
+
: formatStageLevel(thresholds.typicalRangeHigh, prefs);
|
|
613
|
+
if (low && high) {
|
|
614
|
+
output += `- **Typical range:** ${low.formatted} to ${high.formatted}\n`;
|
|
615
|
+
}
|
|
616
|
+
else if (high) {
|
|
617
|
+
output += `- **Typical range:** up to ${high.formatted}\n`;
|
|
618
|
+
}
|
|
619
|
+
else if (low) {
|
|
620
|
+
output += `- **Typical range:** from ${low.formatted}\n`;
|
|
621
|
+
}
|
|
622
|
+
output += `- **Against typical range:** ${band.description}\n`;
|
|
623
|
+
}
|
|
624
|
+
output += `\n`;
|
|
625
|
+
}
|
|
626
|
+
if (gaugesToShow.length > EA_DETAIL_FAN_OUT) {
|
|
627
|
+
// Say plainly why some gauges carry a range and others do not, so a
|
|
628
|
+
// missing range reads as "not fetched" rather than "none published".
|
|
629
|
+
const without = gaugesToShow.length - EA_DETAIL_FAN_OUT;
|
|
630
|
+
output += `*Note: typical ranges were fetched for the nearest ${EA_DETAIL_FAN_OUT} gauges only; `;
|
|
631
|
+
output += `the other ${without} are listed without one.*\n`;
|
|
632
|
+
}
|
|
633
|
+
if (withReadings.length > maxGaugesToShow) {
|
|
634
|
+
const remaining = withReadings.length - maxGaugesToShow;
|
|
635
|
+
const plural = remaining > 1 ? 's' : '';
|
|
636
|
+
if (detail === 'full') {
|
|
637
|
+
output += `*Note: ${remaining} additional gauge${plural} found within radius (showing nearest ${maxGaugesToShow})*\n`;
|
|
638
|
+
}
|
|
639
|
+
else {
|
|
640
|
+
output += `*Note: ${remaining} additional gauge${plural} found within radius (showing nearest ${maxGaugesToShow} only — use detail="full" for more)*\n`;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
if (stationsResult.truncated || readingsResult.truncated) {
|
|
644
|
+
output += `\n*Note: the upstream response exceeded this server's processing limit and was `;
|
|
645
|
+
output += `trimmed, so this list may be incomplete.*\n`;
|
|
646
|
+
}
|
|
647
|
+
output += `\n---\n`;
|
|
648
|
+
output += `*${EA_ATTRIBUTION}*\n`;
|
|
649
|
+
// The flood-monitoring API publishes observations only — no forecast. Say so,
|
|
650
|
+
// rather than letting the absence of a forecast section imply one was checked.
|
|
651
|
+
output += `*These are observed levels, not a forecast. The Environment Agency publishes `;
|
|
652
|
+
output += `no flood-stage categories on this API, so the typical range is a normal-conditions `;
|
|
653
|
+
output += `reference and not a flood threshold. Always consult official flood warnings for `;
|
|
654
|
+
output += `critical decisions.*\n`;
|
|
655
|
+
return output;
|
|
656
|
+
}
|
|
343
657
|
/** Header line naming the model behind the global path. */
|
|
344
658
|
const OPENMETEO_SOURCE_LINE = '**Source:** Open-Meteo Flood API (GloFAS v4, ~5 km model grid)';
|
|
345
659
|
/**
|