@dangahagan/weather-mcp 1.19.0 → 1.21.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.
Files changed (63) hide show
  1. package/README.md +15 -9
  2. package/dist/config/api.d.ts +22 -0
  3. package/dist/config/api.d.ts.map +1 -1
  4. package/dist/config/api.js +24 -0
  5. package/dist/config/api.js.map +1 -1
  6. package/dist/config/cache.d.ts +3 -0
  7. package/dist/config/cache.d.ts.map +1 -1
  8. package/dist/config/cache.js +12 -0
  9. package/dist/config/cache.js.map +1 -1
  10. package/dist/handlers/currentConditionsHandler.d.ts.map +1 -1
  11. package/dist/handlers/currentConditionsHandler.js +98 -51
  12. package/dist/handlers/currentConditionsHandler.js.map +1 -1
  13. package/dist/handlers/forecastHandler.d.ts.map +1 -1
  14. package/dist/handlers/forecastHandler.js +586 -45
  15. package/dist/handlers/forecastHandler.js.map +1 -1
  16. package/dist/handlers/weatherSummaryHandler.d.ts.map +1 -1
  17. package/dist/handlers/weatherSummaryHandler.js +7 -0
  18. package/dist/handlers/weatherSummaryHandler.js.map +1 -1
  19. package/dist/handlers/wildfireHandler.d.ts +22 -3
  20. package/dist/handlers/wildfireHandler.d.ts.map +1 -1
  21. package/dist/handlers/wildfireHandler.js +398 -11
  22. package/dist/handlers/wildfireHandler.js.map +1 -1
  23. package/dist/index.js +36 -5
  24. package/dist/index.js.map +1 -1
  25. package/dist/services/firms.d.ts +76 -0
  26. package/dist/services/firms.d.ts.map +1 -0
  27. package/dist/services/firms.js +250 -0
  28. package/dist/services/firms.js.map +1 -0
  29. package/dist/services/openmeteo.d.ts +176 -9
  30. package/dist/services/openmeteo.d.ts.map +1 -1
  31. package/dist/services/openmeteo.js +436 -67
  32. package/dist/services/openmeteo.js.map +1 -1
  33. package/dist/types/firms.d.ts +110 -0
  34. package/dist/types/firms.d.ts.map +1 -0
  35. package/dist/types/firms.js +14 -0
  36. package/dist/types/firms.js.map +1 -0
  37. package/dist/types/openmeteo.d.ts +75 -3
  38. package/dist/types/openmeteo.d.ts.map +1 -1
  39. package/dist/utils/ensembleSpread.d.ts +201 -0
  40. package/dist/utils/ensembleSpread.d.ts.map +1 -0
  41. package/dist/utils/ensembleSpread.js +312 -0
  42. package/dist/utils/ensembleSpread.js.map +1 -0
  43. package/dist/utils/fireWeather.d.ts +59 -0
  44. package/dist/utils/fireWeather.d.ts.map +1 -1
  45. package/dist/utils/fireWeather.js +141 -0
  46. package/dist/utils/fireWeather.js.map +1 -1
  47. package/dist/utils/firmsHotspots.d.ts +93 -0
  48. package/dist/utils/firmsHotspots.d.ts.map +1 -0
  49. package/dist/utils/firmsHotspots.js +410 -0
  50. package/dist/utils/firmsHotspots.js.map +1 -0
  51. package/dist/utils/modelComparison.d.ts +195 -0
  52. package/dist/utils/modelComparison.d.ts.map +1 -0
  53. package/dist/utils/modelComparison.js +364 -0
  54. package/dist/utils/modelComparison.js.map +1 -0
  55. package/dist/utils/normals.d.ts +69 -18
  56. package/dist/utils/normals.d.ts.map +1 -1
  57. package/dist/utils/normals.js +147 -67
  58. package/dist/utils/normals.js.map +1 -1
  59. package/dist/utils/units.d.ts +4 -0
  60. package/dist/utils/units.d.ts.map +1 -1
  61. package/dist/utils/units.js +6 -0
  62. package/dist/utils/units.js.map +1 -1
  63. package/package.json +4 -4
package/README.md CHANGED
@@ -3,12 +3,12 @@
3
3
  [![npm version](https://badge.fury.io/js/@dangahagan%2Fweather-mcp.svg)](https://www.npmjs.com/package/@dangahagan/weather-mcp)
4
4
  [![MCP Registry](https://img.shields.io/badge/MCP-Registry-blue)](https://registry.modelcontextprotocol.io/v0/servers?search=io.github.dgahagan/weather-mcp)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
- [![Tests](https://img.shields.io/badge/tests-1%2C812%20passing-brightgreen)](./docs/testing/TEST_SUITE_README.md)
6
+ [![Tests](https://img.shields.io/badge/tests-2%2C161%20passing-brightgreen)](./docs/testing/TEST_SUITE_README.md)
7
7
  [![Node](https://img.shields.io/badge/node-%3E%3D18-339933?logo=node.js&logoColor=white)](https://nodejs.org)
8
8
 
9
9
  **Give your AI assistant real weather data — 17 tools, zero API keys, zero signup, zero cost.**
10
10
 
11
- Weather MCP is a [Model Context Protocol](https://modelcontextprotocol.io) server that connects AI assistants (Claude, Cursor, Cline, Zed, and any other MCP client) to live weather data: forecasts, current conditions, alerts, air quality, marine conditions, lightning, radar, rivers, wildfires, and 85+ years of historical weather. It's built entirely on free public data sources — NOAA, Open-Meteo, USGS, NIFC, RainViewer, and Blitzortung.org — so there is nothing to sign up for and no key to paste in.
11
+ Weather MCP is a [Model Context Protocol](https://modelcontextprotocol.io) server that connects AI assistants (Claude, Cursor, Cline, Zed, and any other MCP client) to live weather data: forecasts, current conditions, alerts, air quality, marine conditions, lightning, radar, rivers, wildfires, and 85+ years of historical weather. It's built entirely on free public data sources — NOAA, Open-Meteo, USGS, NIFC, NASA FIRMS, RainViewer, and Blitzortung.org — so there is nothing to sign up for and no key to paste in.
12
12
 
13
13
  ```bash
14
14
  claude mcp add weather -- npx -y @dangahagan/weather-mcp@latest
@@ -50,7 +50,7 @@ Choose this one if you want:
50
50
 
51
51
  - **Genuinely free** — every data source is a free public API. No trial that expires, no credit card, no rate-limited "free tier" bait.
52
52
  - **No API keys** — install to first forecast in under a minute. Nothing to configure, nothing to leak into a repo.
53
- - **Fully open source** — MIT licensed, readable TypeScript, 1,812 tests. Audit it, fork it, fix it.
53
+ - **Fully open source** — MIT licensed, readable TypeScript, 2,161 tests. Audit it, fork it, fix it.
54
54
  - **Privacy-respecting** — your queries go directly from your machine to public weather APIs. No middleman server, no telemetry.
55
55
  - **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.
56
56
 
@@ -62,8 +62,8 @@ All 17 tools, documented in detail in **[docs/TOOLS.md](./docs/TOOLS.md)**:
62
62
 
63
63
  | Tool | What it does | Coverage |
64
64
  |------|-------------|----------|
65
- | `get_forecast` | Daily/hourly forecasts up to 16 days by coordinates, saved location, or city name; sunrise/sunset, UV, precipitation probability, optional climate-normals comparison, optional moon phase & twilight almanac | 🌍 Global |
66
- | `get_current_conditions` | Current weather: temperature, wind, humidity, pressure; NOAA station observations in the US (plus heat index/wind chill, snow depth, optional fire-weather indices), Open-Meteo model data elsewhere, or real airport station observations worldwide with `source="metar"`. Always states the observation's age, and automatically falls back to a fresher nearby station when the nearest one has gone dark | 🌍 Global |
65
+ | `get_forecast` | Daily/hourly forecasts up to 16 days by coordinates, saved location, or city name; sunrise/sunset, UV, precipitation probability, optional climate-normals comparison, optional moon phase & twilight almanac, optional five-model agreement comparison | 🌍 Global |
66
+ | `get_current_conditions` | Current weather: temperature, wind, humidity, pressure; NOAA station observations in the US (plus heat index/wind chill, snow depth, optional NOAA fire-weather indices), Open-Meteo model data elsewhere (with an optional computed Fosberg fire-weather index), or real airport station observations worldwide with `source="metar"`. Always states the observation's age, and automatically falls back to a fresher nearby station when the nearest one has gone dark | 🌍 Global |
67
67
  | `get_alerts` | Active watches, warnings, and advisories sorted by severity; official national warnings for the US (NOAA), Canada (ECCC), and 38 European countries (MeteoAlarm) | 🇺🇸 🇨🇦 🇪🇺 |
68
68
  | `get_historical_weather` | Hourly/daily observations from 1940 to present | 🌍 Global |
69
69
  | `get_weather_summary` | One-call overview combining current conditions, forecast, and alerts (optionally air quality and lightning) | 🌍 Global |
@@ -73,7 +73,7 @@ All 17 tools, documented in detail in **[docs/TOOLS.md](./docs/TOOLS.md)**:
73
73
  | `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 |
74
74
  | `get_lightning_activity` | Real-time strike detection with 4-level proximity safety assessment | 🌍 Global |
75
75
  | `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 |
76
- | `get_wildfire_info` | Active fires, containment, size, safety guidance from the nearest *uncontained* fire (fully contained fires are listed but don't drive danger levels) | 🇺🇸 US |
76
+ | `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 |
77
77
  | `check_service_status` | Health checks for all upstream APIs plus cache statistics | — |
78
78
  | `save_location` | Save places as aliases ("home", "cabin") with optional activity tags | — |
79
79
  | `list_saved_locations` | List all saved locations | — |
@@ -91,6 +91,8 @@ All 17 tools, documented in detail in **[docs/TOOLS.md](./docs/TOOLS.md)**:
91
91
  - **Smart source selection** — US queries use NOAA (detailed, includes forecaster narratives); everywhere else uses Open-Meteo. You never pick; it just works.
92
92
  - **International weather alerts** — `get_alerts` routes by country: NOAA in the US, Environment and Climate Change Canada alerts in Canada, and the official national warnings of 38 European countries via EUMETNET MeteoAlarm — shown unmodified, with the issuing service credited. Border cities like Toronto get the right country's alerts, not the nearest bounding box's.
93
93
  - **Real observations worldwide** — ask for what a station is *actually reporting* and `get_current_conditions` will read the nearest airport's METAR (`source="metar"`): a genuine instrument reading anywhere on earth, with the station, its distance, and the observation age always stated. Outside the US this is the difference between a measurement and a model estimate.
94
+ - **Model agreement** — ask *"how confident is this forecast?"* and `compare_models=true` compares five global models (GFS, ECMWF, ICON, GEM, UKMO) in one request, summarizing where they agree and where they split rather than dumping five forecasts. Spread across models is a proxy for uncertainty, not a guarantee — a tight spread can still be wrong, and the output says so.
95
+ - **Ensemble spread** — the sibling question: *"how confident is the model itself?"* `ensemble_spread=true` summarizes ECMWF ENS's 50 perturbed members — per-day High/Moderate/Low confidence, interquartile temperature bands, and how many members produce rain — instead of one deterministic forecast. Member fractions are raw model output, **not calibrated probabilities**: a confident ensemble can still be wrong, and the output says so. Mutually exclusive with `compare_models`; the two answer different questions.
94
96
  - **Saved locations** — save "home", "work", or "cabin" once, then ask *"what's the weather at home?"* Locations persist in `~/.weather-mcp/locations.json` and can be tagged with activities ("boating", "skiing") so the AI highlights what matters to you.
95
97
  - **Climate context** — optional 30-year climate normals show how today compares: *"10°F warmer than normal for this date."* For US locations the normals also carry the record high/low for the date and the year it was set: *"is this a record high?"*
96
98
  - **Astronomy almanac** — opt-in moon phase, illumination, moonrise/moonset, civil/nautical/astronomical twilight, and next full/new moon dates on daily forecasts (`include_astronomy`). Computed locally — accurate to the arcminute, works at the poles, costs zero API calls: *"when does it get fully dark?"*
@@ -108,12 +110,13 @@ All free, all public, no authentication required:
108
110
  | Source | Provides | Coverage |
109
111
  |--------|----------|----------|
110
112
  | [NOAA Weather API](https://www.weather.gov/documentation/services-web-api) | US forecasts, current conditions, alerts, fire weather | US |
111
- | [Open-Meteo](https://open-meteo.com/) | Global forecasts, historical data (1940+), air quality, marine, geocoding, climate normals | Global |
113
+ | [Open-Meteo](https://open-meteo.com/) | Global forecasts, historical data (1940+), air quality, marine, geocoding, climate normals, fire-weather inputs | Global |
112
114
  | [NOAA NWPS](https://water.noaa.gov/) | River levels, streamflow, flood stages | US |
113
115
  | [RCC ACIS](https://www.rcc-acis.org/) | Daily record high/low temperatures | US |
114
116
  | [EUMETNET MeteoAlarm](https://meteoalarm.org/) | Official national weather warnings (38 European countries) | Europe |
115
117
  | [MSC GeoMet](https://api.weather.gc.ca/) | Environment and Climate Change Canada weather alerts | Canada |
116
118
  | [NIFC WFIGS](https://data-nifc.opendata.arcgis.com/) | Active wildfire perimeters and incidents | US |
119
+ | [NASA FIRMS](https://firms.modaps.eosdis.nasa.gov/) | Satellite fire detections (VIIRS, near real-time) | Global |
117
120
  | [RainViewer](https://www.rainviewer.com/api.html) | Precipitation radar imagery | Global |
118
121
  | [NASA GIBS](https://www.earthdata.nasa.gov/engage/open-data-services-software/earthdata-developer-portal/gibs-api) | GOES GeoColor satellite imagery; base map for composited radar | Western Hemisphere (satellite), Global (base map) |
119
122
  | [Blitzortung.org](https://www.blitzortung.org/) | Community lightning detection network | Global |
@@ -266,11 +269,14 @@ Being honest about what free public data can and can't do:
266
269
  | Current conditions | ✅ (model data, or real station observations via `source="metar"`) | Station observations via NOAA (richer detail) |
267
270
  | Weather alerts | ✅ US, Canada, and 38 European countries | NWS zone-level precision; Europe is matched at country level |
268
271
  | River conditions | ✅ (GloFAS modeled discharge) | Gauge observations + official flood stages via NWPS |
269
- | Wildfires | ❌ | ✅ |
272
+ | Wildfires | ✅ (FIRMS satellite detections) | Named incidents with acreage + containment via NIFC |
273
+ | Fire weather | ✅ (computed Fosberg index + dryness context) | NOAA-published Haines, grassland, red-flag indices |
270
274
 
271
275
  - European alerts are matched at **country level** — the keyless MeteoAlarm feeds carry no region polygons, so warnings for a large country may not affect the requested point; the output says so. Canadian alerts use a real bbox query with polygon-backed features.
272
276
  - International current conditions default to **model-interpolated** values at the exact coordinates. `source="metar"` returns a **real instrument reading** instead — but from the nearest airport, which may be tens of km away and up to an hour old. The output always names the station, its distance and bearing, and the observation age, so the tradeoff is visible rather than assumed. METAR coverage follows airports, so remote land and open ocean have real gaps.
273
277
  - Historical data older than 7 days comes from reanalysis models (9–25km grid), not direct station observations, and trails real time by ~5 days.
278
+ - Non-US wildfire results are **satellite heat detections, not managed incidents** — no fire names, sizes, or containment percentages exist in the data, detections can include industrial heat sources or agricultural burns, and a clear result is not an all-clear (cloud cover hides fires; small or new fires evade detection). The output frames all of this explicitly. Keyless data covers the last 24 hours; an optional free [`FIRMS_MAP_KEY`](https://firms.modaps.eosdis.nasa.gov/api/map_key/) unlocks targeted queries and up to 5 days of detection history (`day_range`).
279
+ - Non-US fire weather is a **Fosberg Fire Weather Index computed by this server** from current model values, not an agency-published rating — US locations get NOAA's own Haines/grassland/red-flag indices instead. The output says which it is and defers to national fire authorities; its category bands are a project heuristic.
274
280
  - Non-US river discharge is **modeled**, not observed, and has no official flood-stage thresholds — levels are shown relative to recent history and the forecast ensemble. The ~5km model grid means the reported channel may be a few km from the requested point; the output says so when it is.
275
281
  - Marine data has limited coastal accuracy and is **not suitable for navigation**.
276
282
  - Lightning coverage varies by region (community-operated detector network).
@@ -281,7 +287,7 @@ Being honest about what free public data can and can't do:
281
287
  ```bash
282
288
  npm run build # Compile TypeScript
283
289
  npm run dev # Run in development mode
284
- npm test # Run all 1,812 tests
290
+ npm test # Run all 2,161 tests
285
291
  npm run test:coverage # Coverage report
286
292
  npm run audit # Dependency vulnerability scan
287
293
  ```
@@ -27,4 +27,26 @@ export declare const NCEI_API_TOKEN: string | undefined;
27
27
  * Check if NCEI API is available (token configured)
28
28
  */
29
29
  export declare function isNCEIAvailable(): boolean;
30
+ /**
31
+ * FIRMS (Fire Information for Resource Management System) API map key
32
+ *
33
+ * OPTIONAL: Get a free key at https://firms.modaps.eosdis.nasa.gov/api/map_key/
34
+ *
35
+ * Benefits of providing a key:
36
+ * - Access to targeted NASA FIRMS Area-API bbox queries
37
+ * - Query wildfire data with custom day_range (1–5 days)
38
+ *
39
+ * If not provided:
40
+ * - Keyless 24-hour regional flat CSV files
41
+ * - Tool still works globally without the key
42
+ * - No setup required
43
+ *
44
+ * Rate limits with key:
45
+ * - 5,000 transactions per 10 minutes
46
+ */
47
+ export declare const FIRMS_MAP_KEY: string | undefined;
48
+ /**
49
+ * Check if FIRMS API key is available (key configured)
50
+ */
51
+ export declare function isFIRMSKeyAvailable(): boolean;
30
52
  //# sourceMappingURL=api.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../src/config/api.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,cAAc,oBAA6B,CAAC;AAEzD;;GAEG;AACH,wBAAgB,eAAe,IAAI,OAAO,CAEzC"}
1
+ {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../src/config/api.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,cAAc,oBAA6B,CAAC;AAEzD;;GAEG;AACH,wBAAgB,eAAe,IAAI,OAAO,CAEzC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,aAAa,oBAA4B,CAAC;AAEvD;;GAEG;AACH,wBAAgB,mBAAmB,IAAI,OAAO,CAE7C"}
@@ -29,4 +29,28 @@ export const NCEI_API_TOKEN = process.env.NCEI_API_TOKEN;
29
29
  export function isNCEIAvailable() {
30
30
  return !!NCEI_API_TOKEN && NCEI_API_TOKEN.trim().length > 0;
31
31
  }
32
+ /**
33
+ * FIRMS (Fire Information for Resource Management System) API map key
34
+ *
35
+ * OPTIONAL: Get a free key at https://firms.modaps.eosdis.nasa.gov/api/map_key/
36
+ *
37
+ * Benefits of providing a key:
38
+ * - Access to targeted NASA FIRMS Area-API bbox queries
39
+ * - Query wildfire data with custom day_range (1–5 days)
40
+ *
41
+ * If not provided:
42
+ * - Keyless 24-hour regional flat CSV files
43
+ * - Tool still works globally without the key
44
+ * - No setup required
45
+ *
46
+ * Rate limits with key:
47
+ * - 5,000 transactions per 10 minutes
48
+ */
49
+ export const FIRMS_MAP_KEY = process.env.FIRMS_MAP_KEY;
50
+ /**
51
+ * Check if FIRMS API key is available (key configured)
52
+ */
53
+ export function isFIRMSKeyAvailable() {
54
+ return !!FIRMS_MAP_KEY && FIRMS_MAP_KEY.trim().length > 0;
55
+ }
32
56
  //# sourceMappingURL=api.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"api.js","sourceRoot":"","sources":["../../src/config/api.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC;AAEzD;;GAEG;AACH,MAAM,UAAU,eAAe;IAC7B,OAAO,CAAC,CAAC,cAAc,IAAI,cAAc,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC;AAC9D,CAAC"}
1
+ {"version":3,"file":"api.js","sourceRoot":"","sources":["../../src/config/api.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC;AAEzD;;GAEG;AACH,MAAM,UAAU,eAAe;IAC7B,OAAO,CAAC,CAAC,cAAc,IAAI,cAAc,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC;AAC9D,CAAC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,OAAO,CAAC,GAAG,CAAC,aAAa,CAAC;AAEvD;;GAEG;AACH,MAAM,UAAU,mBAAmB;IACjC,OAAO,CAAC,CAAC,aAAa,IAAI,aAAa,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC;AAC5D,CAAC"}
@@ -20,6 +20,7 @@ export declare const CacheConfig: {
20
20
  readonly alerts: number;
21
21
  readonly recentHistorical: number;
22
22
  readonly historicalData: number;
23
+ readonly normals: number;
23
24
  readonly serviceStatus: number;
24
25
  readonly floodDischarge: number;
25
26
  readonly records: number;
@@ -28,6 +29,8 @@ export declare const CacheConfig: {
28
29
  readonly basemapTiles: number;
29
30
  readonly compositeImage: number;
30
31
  readonly reverseCountry: number;
32
+ readonly firmsAreaQuery: number;
33
+ readonly firmsRegionalFile: number;
31
34
  };
32
35
  };
33
36
  /**
@@ -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;iBAId,aAAa;iBAMb,cAAc;iBAMd,OAAO;iBAIP,cAAc;iBAMd,iBAAiB;iBAQjB,YAAY;iBAQZ,cAAc;iBAKd,cAAc;;CAER,CAAC;AAEX;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAYrE"}
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;iBAMd,OAAO;iBAIP,cAAc;iBAMd,iBAAiB;iBAQjB,YAAY;iBAQZ,cAAc;iBAKd,cAAc;iBAKd,cAAc;iBAKd,iBAAiB;;CAEX,CAAC;AAEX;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAYrE"}
@@ -85,6 +85,10 @@ export const CacheConfig = {
85
85
  // Historical data (> 1 day old from current time)
86
86
  // Historical data beyond 1 day is finalized and won't change
87
87
  historicalData: Infinity,
88
+ // Climate normals (1991–2020 baseline)
89
+ // Climate normals are static reference data that never change; the cached
90
+ // value is one full-year 366-slot table per location
91
+ normals: Infinity,
88
92
  // Service health check status
89
93
  // Check freshness periodically
90
94
  serviceStatus: 5 * MINUTE,
@@ -124,6 +128,14 @@ export const CacheConfig = {
124
128
  // on coordinates rounded to ~1.1km; countries don't move, so once
125
129
  // resolved (including a "no country" open-ocean result) it's permanent.
126
130
  reverseCountry: Infinity,
131
+ // NASA FIRMS Area API bbox query (keyed path). NRT detections land
132
+ // within ~3 h of overpass; 30 min matches the NIFC perimeter refresh
133
+ // cadence.
134
+ firmsAreaQuery: 30 * MINUTE,
135
+ // NASA FIRMS regional flat-file fetch (keyless path). Caches the parsed
136
+ // rows per region file (not per request), so repeated queries anywhere
137
+ // in a region cost one fetch per half hour.
138
+ firmsRegionalFile: 30 * MINUTE,
127
139
  },
128
140
  };
129
141
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"cache.js","sourceRoot":"","sources":["../../src/config/cache.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,iCAAiC;AACjC,MAAM,MAAM,GAAG,EAAE,GAAG,IAAI,CAAC;AACzB,MAAM,IAAI,GAAG,EAAE,GAAG,MAAM,CAAC;AACzB,MAAM,GAAG,GAAG,EAAE,GAAG,IAAI,CAAC;AAEtB;;;;;GAKG;AACH,SAAS,aAAa,CAAC,GAAW,EAAE,YAAqB;IACvD,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IAC/B,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO,YAAY,CAAC;IAC7C,OAAO,KAAK,KAAK,OAAO,IAAI,KAAK,KAAK,GAAG,CAAC;AAC5C,CAAC;AAED;;;;;;;GAOG;AACH,SAAS,YAAY,CAAC,GAAW,EAAE,YAAoB,EAAE,GAAY,EAAE,GAAY;IACjF,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IAC/B,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO,YAAY,CAAC;IAE7C,MAAM,MAAM,GAAG,QAAQ,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;IACnC,IAAI,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC;QAClB,OAAO,CAAC,IAAI,CAAC,WAAW,GAAG,MAAM,KAAK,qBAAqB,YAAY,EAAE,CAAC,CAAC;QAC3E,OAAO,YAAY,CAAC;IACtB,CAAC;IAED,IAAI,GAAG,KAAK,SAAS,IAAI,MAAM,GAAG,GAAG,EAAE,CAAC;QACtC,OAAO,CAAC,IAAI,CAAC,GAAG,GAAG,aAAa,MAAM,oBAAoB,GAAG,EAAE,CAAC,CAAC;QACjE,OAAO,GAAG,CAAC;IACb,CAAC;IAED,IAAI,GAAG,KAAK,SAAS,IAAI,MAAM,GAAG,GAAG,EAAE,CAAC;QACtC,OAAO,CAAC,IAAI,CAAC,GAAG,GAAG,cAAc,MAAM,oBAAoB,GAAG,EAAE,CAAC,CAAC;QAClE,OAAO,GAAG,CAAC;IACb,CAAC;IAED,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,MAAM,CAAC,MAAM,WAAW,GAAG;IACzB,kCAAkC;IAClC,OAAO,EAAE,aAAa,CAAC,eAAe,EAAE,IAAI,CAAC;IAE7C,yDAAyD;IACzD,sCAAsC;IACtC,OAAO,EAAE,YAAY,CAAC,gBAAgB,EAAE,IAAI,EAAE,GAAG,EAAE,KAAK,CAAC;IAEzD,4BAA4B;IAC5B,oFAAoF;IACpF,YAAY,EAAE,YAAY,CAAC,gBAAgB,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,CAAC;IAEjE,sCAAsC;IACtC,GAAG,EAAE;QACH,oDAAoD;QACpD,wCAAwC;QACxC,eAAe,EAAE,QAAQ;QAEzB,6CAA6C;QAC7C,+CAA+C;QAC/C,SAAS,EAAE,QAAQ;QAEnB,wBAAwB;QACxB,yBAAyB;QACzB,QAAQ,EAAE,EAAE,GAAG,IAAI;QAEnB,kBAAkB;QAClB,8CAA8C;QAC9C,QAAQ,EAAE,CAAC,GAAG,IAAI;QAElB,6BAA6B;QAC7B,oDAAoD;QACpD,iBAAiB,EAAE,EAAE,GAAG,MAAM;QAE9B,iBAAiB;QACjB,sDAAsD;QACtD,MAAM,EAAE,CAAC,GAAG,MAAM;QAElB,wCAAwC;QACxC,6CAA6C;QAC7C,gBAAgB,EAAE,CAAC,GAAG,IAAI;QAE1B,kDAAkD;QAClD,6DAA6D;QAC7D,cAAc,EAAE,QAAQ;QAExB,8BAA8B;QAC9B,+BAA+B;QAC/B,aAAa,EAAE,CAAC,GAAG,MAAM;QAEzB,qDAAqD;QACrD,uEAAuE;QACvE,oEAAoE;QACpE,qEAAqE;QACrE,cAAc,EAAE,CAAC,GAAG,IAAI;QAExB,0EAA0E;QAC1E,wEAAwE;QACxE,qEAAqE;QACrE,yCAAyC;QACzC,OAAO,EAAE,CAAC,GAAG,GAAG;QAEhB,uEAAuE;QACvE,qEAAqE;QACrE,cAAc,EAAE,EAAE,GAAG,GAAG;QAExB,yEAAyE;QACzE,sEAAsE;QACtE,yEAAyE;QACzE,mBAAmB;QACnB,iBAAiB,EAAE,EAAE,GAAG,MAAM;QAE9B,wEAAwE;QACxE,gEAAgE;QAChE,yEAAyE;QACzE,yEAAyE;QACzE,uEAAuE;QACvE,0DAA0D;QAC1D,YAAY,EAAE,EAAE,GAAG,IAAI;QAEvB,mEAAmE;QACnE,sEAAsE;QACtE,wEAAwE;QACxE,qEAAqE;QACrE,yEAAyE;QACzE,0CAA0C;QAC1C,cAAc,EAAE,EAAE,GAAG,MAAM;QAE3B,wEAAwE;QACxE,kEAAkE;QAClE,wEAAwE;QACxE,cAAc,EAAE,QAAQ;KACzB;CACO,CAAC;AAEX;;;;GAIG;AACH,MAAM,UAAU,oBAAoB,CAAC,SAAwB;IAC3D,MAAM,KAAK,GAAG,OAAO,SAAS,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;IAC9E,MAAM,GAAG,GAAG,IAAI,IAAI,EAAE,CAAC;IACvB,MAAM,QAAQ,GAAG,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,KAAK,CAAC,OAAO,EAAE,CAAC,GAAG,GAAG,CAAC;IAEzD,IAAI,QAAQ,GAAG,CAAC,EAAE,CAAC;QACjB,gEAAgE;QAChE,OAAO,WAAW,CAAC,GAAG,CAAC,cAAc,CAAC;IACxC,CAAC;SAAM,CAAC;QACN,mCAAmC;QACnC,OAAO,WAAW,CAAC,GAAG,CAAC,gBAAgB,CAAC;IAC1C,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"cache.js","sourceRoot":"","sources":["../../src/config/cache.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"currentConditionsHandler.d.ts","sourceRoot":"","sources":["../../src/handlers/currentConditionsHandler.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAClD,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAC5D,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAClD,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAClD,OAAO,EAAE,sBAAsB,EAAE,MAAM,gCAAgC,CAAC;AACxE,OAAO,EAAE,aAAa,EAAE,MAAM,8BAA8B,CAAC;AAC7D,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAwF5D,wBAAsB,0BAA0B,CAC9C,IAAI,EAAE,OAAO,EACb,WAAW,EAAE,WAAW,EACxB,gBAAgB,EAAE,gBAAgB,EAClC,WAAW,EAAE,WAAW,EACxB,aAAa,EAAE,aAAa,EAC5B,gBAAgB,EAAE,gBAAgB,EAClC,WAAW,CAAC,EAAE,WAAW,EACzB,sBAAsB,CAAC,EAAE,sBAAsB,GAC9C,OAAO,CAAC;IAAE,OAAO,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;CAAE,CAAC,CAkH7D"}
1
+ {"version":3,"file":"currentConditionsHandler.d.ts","sourceRoot":"","sources":["../../src/handlers/currentConditionsHandler.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAClD,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAC5D,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAClD,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAClD,OAAO,EAAE,sBAAsB,EAAE,MAAM,gCAAgC,CAAC;AACxE,OAAO,EAAE,aAAa,EAAE,MAAM,8BAA8B,CAAC;AAC7D,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAmG5D,wBAAsB,0BAA0B,CAC9C,IAAI,EAAE,OAAO,EACb,WAAW,EAAE,WAAW,EACxB,gBAAgB,EAAE,gBAAgB,EAClC,WAAW,EAAE,WAAW,EACxB,aAAa,EAAE,aAAa,EAC5B,gBAAgB,EAAE,gBAAgB,EAClC,WAAW,CAAC,EAAE,WAAW,EACzB,sBAAsB,CAAC,EAAE,sBAAsB,GAC9C,OAAO,CAAC;IAAE,OAAO,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;CAAE,CAAC,CAkH7D"}
@@ -8,15 +8,15 @@ import { resolveUnitPreferences } from '../utils/unitPreferences.js';
8
8
  import { formatTemperatureQV, formatWindSpeedQV, formatPressureQV, formatVisibilityQV, formatHeightFromFt, formatPrecipFromMm, formatPressureFromPa, formatTemperatureFromC, formatElevationFromM, convertWindFromMps, convertDistanceFromKm, temperatureLabel, windSpeedLabel, precipitationLabel, distanceLabel, withLabel, snowfallToPrecipUnit, } from '../utils/unitFormat.js';
9
9
  import { isInUS } from '../utils/geography.js';
10
10
  import { SEARCH_TIERS, pickNearestStation, parseVisibilityMiles, parseWindDirection } from '../utils/metarStation.js';
11
- import { windDirectionFromDegrees, relativeHumidityFromDewpoint } from '../utils/units.js';
11
+ import { windDirectionFromDegrees, relativeHumidityFromDewpoint, celsiusToFahrenheit, kphToMph, mpsToMph, knotsToMph } from '../utils/units.js';
12
12
  import { milesToKm } from '../utils/distance.js';
13
13
  import { DataNotFoundError, InvalidLocationError, ServiceUnavailableError } from '../errors/ApiError.js';
14
14
  import { logger } from '../utils/logger.js';
15
15
  import { DisplayThresholds } from '../config/displayThresholds.js';
16
- import { getHainesCategory, getGrasslandFireDangerCategory, getRedFlagCategory, getCurrentFireWeatherValue, formatMixingHeight, interpretTransportWind, getFireWeatherContext } from '../utils/fireWeather.js';
16
+ import { getHainesCategory, getGrasslandFireDangerCategory, getRedFlagCategory, getCurrentFireWeatherValue, formatMixingHeight, interpretTransportWind, getFireWeatherContext, calculateFosbergIndex, getFosbergCategory, describeVpd, describeTopsoilMoisture } from '../utils/fireWeather.js';
17
17
  import { extractSnowDepth, formatSnowData, hasWinterWeather } from '../utils/snow.js';
18
18
  import { formatInTimezone, guessTimezoneFromCoords, getTimezoneAbbreviation, formatObservationAge } from '../utils/timezone.js';
19
- import { getClimateNormals, formatNormals, getDateComponents } from '../utils/normals.js';
19
+ import { renderNormalsSection, getDateComponents } from '../utils/normals.js';
20
20
  import { getRecordsLine } from '../utils/records.js';
21
21
  /** METAR reports wind in knots; every project converter takes m/s. */
22
22
  const KNOTS_TO_MPS = 0.514444;
@@ -407,23 +407,13 @@ async function formatNOAACurrentConditions(noaaService, openMeteoService, nceiSe
407
407
  if (includeNormals) {
408
408
  // Get date components from observation timestamp
409
409
  const { month, day } = getDateComponents(props.timestamp);
410
- try {
411
- // Fetch climate normals using hybrid strategy
412
- const normals = await getClimateNormals(openMeteoService, nceiService, latitude, longitude, month, day);
413
- // Format and display normals with current temperature for comparison.
414
- // currentTemps must be in the caller's units to match formatNormals output.
415
- const toPref = (f) => prefs.temperature === 'C' ? Math.round(((f - 32) * 5) / 9) : Math.round(f);
416
- const currentTemps = {
417
- high: max24F !== null ? toPref(max24F) : undefined,
418
- low: min24F !== null ? toPref(min24F) : undefined
419
- };
420
- output += formatNormals(normals, currentTemps, prefs);
421
- }
422
- catch (error) {
423
- // If normals fetch fails, just skip it (don't error the whole request)
424
- output += `\n## Climate Normals\n\n`;
425
- output += `⚠️ Climate normals data not available for this location.\n`;
426
- }
410
+ // currentTemps must be in the caller's units to match the rendered normals.
411
+ const toPref = (f) => prefs.temperature === 'C' ? Math.round(((f - 32) * 5) / 9) : Math.round(f);
412
+ const currentTemps = {
413
+ high: max24F !== null ? toPref(max24F) : undefined,
414
+ low: min24F !== null ? toPref(min24F) : undefined
415
+ };
416
+ output += await renderNormalsSection(openMeteoService, nceiService, latitude, longitude, month, day, currentTemps, prefs);
427
417
  // US temperature records: independent of the normals fetch above (D4/A5)
428
418
  // — a records line can render even if normals failed, and vice versa.
429
419
  if (isInUS(latitude, longitude) && acisService) {
@@ -443,6 +433,77 @@ async function formatNOAACurrentConditions(noaaService, openMeteoService, nceiSe
443
433
  output += `*Data source: NOAA National Weather Service*\n`;
444
434
  return output;
445
435
  }
436
+ /**
437
+ * Normalize a temperature that arrived in the caller's preferred unit back to
438
+ * the fixed °F the Fosberg index requires.
439
+ *
440
+ * Open-Meteo converts server-side (`openMeteoUnitParams`), so the response
441
+ * carries the caller's units. Normalizing here rather than issuing a second
442
+ * fixed-unit fetch is design decision D4.
443
+ */
444
+ function prefsTempToFahrenheit(value, prefs) {
445
+ return prefs.temperature === 'C' ? celsiusToFahrenheit(value) : value;
446
+ }
447
+ /** Normalize a wind speed from the caller's preferred unit to fixed mph (D4). */
448
+ function prefsWindToMph(value, prefs) {
449
+ switch (prefs.windSpeed) {
450
+ case 'kmh':
451
+ return kphToMph(value);
452
+ case 'ms':
453
+ return mpsToMph(value);
454
+ case 'kn':
455
+ return knotsToMph(value);
456
+ default:
457
+ return value;
458
+ }
459
+ }
460
+ /**
461
+ * Render the Fire Weather section for the Open-Meteo (model) path: a Fosberg
462
+ * Fire Weather Index computed by this server from current values, plus dryness
463
+ * context (D5), carrying the derivation disclosure (D6).
464
+ *
465
+ * `getFireWeatherContext` — US geography boxes and northern-hemisphere
466
+ * seasonality — is deliberately never called here; the index reflects actual
467
+ * current conditions and is hemisphere-proof by construction.
468
+ *
469
+ * Sustained wind feeds the index; gusts are display context only and are
470
+ * already rendered above, so inputs are not repeated in this section.
471
+ */
472
+ function formatOpenMeteoFireWeather(temperature, relativeHumidity, windSpeed, vpdKPa, topsoilMoisture, prefs) {
473
+ let output = `\n## Fire Weather\n\n`;
474
+ const ffwi = temperature != null && relativeHumidity != null && windSpeed != null
475
+ ? calculateFosbergIndex(prefsTempToFahrenheit(temperature, prefs), relativeHumidity, prefsWindToMph(windSpeed, prefs))
476
+ : NaN;
477
+ if (!Number.isFinite(ffwi)) {
478
+ output += `⚠️ Fire weather inputs unavailable for this location.\n`;
479
+ return output;
480
+ }
481
+ // Categorize the displayed (rounded) value so the number and the label
482
+ // never disagree at a band edge.
483
+ const index = Math.round(ffwi);
484
+ const category = getFosbergCategory(index);
485
+ const emoji = category.level === 'Low' ? '🟢' :
486
+ category.level === 'Moderate' ? '🟡' :
487
+ category.level === 'High' ? '🟠' : '🔴';
488
+ output += `**${emoji} Fosberg Fire Weather Index:** ${index} (${category.level})\n`;
489
+ output += `Computed from current temperature, humidity, and sustained wind. Higher values mean faster potential fire spread in fine fuels.\n\n`;
490
+ // Open ocean returns HTTP 200 with null dryness fields (Flood-API
491
+ // precedent): omit the line, or the whole block when both are missing.
492
+ const hasVpd = vpdKPa !== undefined && vpdKPa !== null && Number.isFinite(vpdKPa);
493
+ const hasSoil = topsoilMoisture !== undefined && topsoilMoisture !== null && Number.isFinite(topsoilMoisture);
494
+ if (hasVpd || hasSoil) {
495
+ output += `**Dryness context:**\n`;
496
+ if (hasVpd) {
497
+ output += `- **Vapour-pressure deficit:** ${vpdKPa.toFixed(1)} kPa (${describeVpd(vpdKPa)})\n`;
498
+ }
499
+ if (hasSoil) {
500
+ output += `- **Topsoil moisture (top 1 cm):** ${topsoilMoisture.toFixed(2)} m³/m³ (${describeTopsoilMoisture(topsoilMoisture)})\n`;
501
+ }
502
+ output += `\n`;
503
+ }
504
+ output += `*Derived by this server from Open-Meteo model data — not an official fire-danger rating. Heed warnings from your national fire authority.*\n`;
505
+ return output;
506
+ }
446
507
  /**
447
508
  * Format current conditions from Open-Meteo model data (non-US locations).
448
509
  *
@@ -455,7 +516,7 @@ async function formatOpenMeteoCurrentConditions(openMeteoService, nceiService, l
455
516
  const tempU = temperatureLabel(prefs);
456
517
  const windU = windSpeedLabel(prefs);
457
518
  const precipU = precipitationLabel(prefs);
458
- const response = await openMeteoService.getCurrentConditions(latitude, longitude, prefs);
519
+ const response = await openMeteoService.getCurrentConditions(latitude, longitude, prefs, includeFireWeather);
459
520
  // getCurrentConditions rejects responses without a `current` block.
460
521
  const current = response.current;
461
522
  const timezone = response.timezone;
@@ -464,7 +525,7 @@ async function formatOpenMeteoCurrentConditions(openMeteoService, nceiService, l
464
525
  if (current.weather_code !== undefined) {
465
526
  output += `**Conditions:** ${openMeteoService.getWeatherDescription(current.weather_code)}\n`;
466
527
  }
467
- if (current.temperature_2m !== undefined) {
528
+ if (current.temperature_2m != null) {
468
529
  output += `**Temperature:** ${Math.round(current.temperature_2m)}${tempU}\n`;
469
530
  // Feels-like only earns a line when it diverges meaningfully from actual.
470
531
  // The gap is unit-keyed: these values are already in the caller's unit.
@@ -491,10 +552,10 @@ async function formatOpenMeteoCurrentConditions(openMeteoService, nceiService, l
491
552
  if (current.dew_point_2m !== undefined) {
492
553
  output += `**Dewpoint:** ${Math.round(current.dew_point_2m)}${tempU}\n`;
493
554
  }
494
- if (current.relative_humidity_2m !== undefined) {
555
+ if (current.relative_humidity_2m != null) {
495
556
  output += `**Humidity:** ${Math.round(current.relative_humidity_2m)}%\n`;
496
557
  }
497
- if (current.wind_speed_10m !== undefined) {
558
+ if (current.wind_speed_10m != null) {
498
559
  output += `**Wind:** ${Math.round(current.wind_speed_10m)} ${windU}`;
499
560
  if (current.wind_direction_10m !== undefined) {
500
561
  output += ` from ${Math.round(current.wind_direction_10m)}°`;
@@ -539,29 +600,20 @@ async function formatOpenMeteoCurrentConditions(openMeteoService, nceiService, l
539
600
  output += `**Snowfall:** ${withLabel(snowfall, precipU, precipDecimals)}\n`;
540
601
  }
541
602
  }
542
- // Fire Weather section (optional) — indices are US-only for now, so the
543
- // non-US path makes no NOAA call at all.
603
+ // Fire Weather section (optional) — a server-computed Fosberg index off the
604
+ // model values already in hand; this path never makes a NOAA call.
544
605
  if (includeFireWeather) {
545
- output += `\n## Fire Weather\n\n`;
546
- output += `Fire weather indices are currently available for US locations only.\n`;
606
+ output += formatOpenMeteoFireWeather(current.temperature_2m, current.relative_humidity_2m, current.wind_speed_10m, current.vapour_pressure_deficit, current.soil_moisture_0_to_1cm, prefs);
547
607
  }
548
608
  // Climate Normals section (optional)
549
609
  if (includeNormals) {
550
610
  const { month, day } = getDateComponents(current.time);
551
- try {
552
- const normals = await getClimateNormals(openMeteoService, nceiService, latitude, longitude, month, day);
553
- // Daily values are already in the caller's units — no conversion needed.
554
- const currentTemps = {
555
- high: high !== undefined ? Math.round(high) : undefined,
556
- low: low !== undefined ? Math.round(low) : undefined
557
- };
558
- output += formatNormals(normals, currentTemps, prefs);
559
- }
560
- catch (error) {
561
- // If normals fetch fails, just skip it (don't error the whole request)
562
- output += `\n## Climate Normals\n\n`;
563
- output += `⚠️ Climate normals data not available for this location.\n`;
564
- }
611
+ // Daily values are already in the caller's units — no conversion needed.
612
+ const currentTemps = {
613
+ high: high !== undefined ? Math.round(high) : undefined,
614
+ low: low !== undefined ? Math.round(low) : undefined
615
+ };
616
+ output += await renderNormalsSection(openMeteoService, nceiService, latitude, longitude, month, day, currentTemps, prefs);
565
617
  // US temperature records: independent of the normals fetch above (D4/A5)
566
618
  // — a records line can render even if normals failed, and vice versa.
567
619
  // This site is reachable for a US point via explicit source="openmeteo".
@@ -813,20 +865,15 @@ async function formatMetarCurrentConditions(aviationWeatherService, openMeteoSer
813
865
  if (includeFireWeather) {
814
866
  output += `\n## Fire Weather\n\n`;
815
867
  output += `Fire weather indices are not available on the METAR source — they `;
816
- output += `require NOAA gridpoint data. Use \`source: "noaa"\` for a US location.\n`;
868
+ output += `require NOAA gridpoint data. Use \`source: "noaa"\` for a US location, `;
869
+ output += `or omit \`source\` to get a server-computed Fosberg index from model data elsewhere.\n`;
817
870
  }
818
871
  // Climate normals (optional) — supported here exactly as on the other two
819
872
  // paths (D7): they need only the coordinates and the date.
820
873
  if (includeNormals) {
821
874
  const { month, day } = getDateComponents(observedIso);
822
- try {
823
- const normals = await getClimateNormals(openMeteoService, nceiService, latitude, longitude, month, day);
824
- output += formatNormals(normals, {}, prefs);
825
- }
826
- catch (error) {
827
- output += `\n## Climate Normals\n\n`;
828
- output += `⚠️ Climate normals data not available for this location.\n`;
829
- }
875
+ // No comparable station high/low on this path — normals render on their own.
876
+ output += await renderNormalsSection(openMeteoService, nceiService, latitude, longitude, month, day, {}, prefs);
830
877
  // US temperature records, independent of the normals fetch above — either
831
878
  // can render without the other. Non-US makes no ACIS request at all.
832
879
  if (isInUS(latitude, longitude) && acisService) {