@jablum/weather-mcp 1.7.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 (235) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1319 -0
  3. package/dist/analytics/anonymizer.d.ts +37 -0
  4. package/dist/analytics/anonymizer.d.ts.map +1 -0
  5. package/dist/analytics/anonymizer.js +112 -0
  6. package/dist/analytics/anonymizer.js.map +1 -0
  7. package/dist/analytics/collector.d.ts +72 -0
  8. package/dist/analytics/collector.d.ts.map +1 -0
  9. package/dist/analytics/collector.js +282 -0
  10. package/dist/analytics/collector.js.map +1 -0
  11. package/dist/analytics/config.d.ts +15 -0
  12. package/dist/analytics/config.d.ts.map +1 -0
  13. package/dist/analytics/config.js +172 -0
  14. package/dist/analytics/config.js.map +1 -0
  15. package/dist/analytics/index.d.ts +8 -0
  16. package/dist/analytics/index.d.ts.map +1 -0
  17. package/dist/analytics/index.js +7 -0
  18. package/dist/analytics/index.js.map +1 -0
  19. package/dist/analytics/middleware.d.ts +33 -0
  20. package/dist/analytics/middleware.d.ts.map +1 -0
  21. package/dist/analytics/middleware.js +99 -0
  22. package/dist/analytics/middleware.js.map +1 -0
  23. package/dist/analytics/transport.d.ts +11 -0
  24. package/dist/analytics/transport.d.ts.map +1 -0
  25. package/dist/analytics/transport.js +92 -0
  26. package/dist/analytics/transport.js.map +1 -0
  27. package/dist/analytics/types.d.ts +74 -0
  28. package/dist/analytics/types.d.ts.map +1 -0
  29. package/dist/analytics/types.js +6 -0
  30. package/dist/analytics/types.js.map +1 -0
  31. package/dist/config/api.d.ts +30 -0
  32. package/dist/config/api.d.ts.map +1 -0
  33. package/dist/config/api.js +32 -0
  34. package/dist/config/api.js.map +1 -0
  35. package/dist/config/cache.d.ts +31 -0
  36. package/dist/config/cache.d.ts.map +1 -0
  37. package/dist/config/cache.js +108 -0
  38. package/dist/config/cache.js.map +1 -0
  39. package/dist/config/displayThresholds.d.ts +83 -0
  40. package/dist/config/displayThresholds.d.ts.map +1 -0
  41. package/dist/config/displayThresholds.js +83 -0
  42. package/dist/config/displayThresholds.js.map +1 -0
  43. package/dist/config/tools.d.ts +44 -0
  44. package/dist/config/tools.d.ts.map +1 -0
  45. package/dist/config/tools.js +269 -0
  46. package/dist/config/tools.js.map +1 -0
  47. package/dist/errors/ApiError.d.ts +62 -0
  48. package/dist/errors/ApiError.d.ts.map +1 -0
  49. package/dist/errors/ApiError.js +171 -0
  50. package/dist/errors/ApiError.js.map +1 -0
  51. package/dist/handlers/airQualityHandler.d.ts +11 -0
  52. package/dist/handlers/airQualityHandler.d.ts.map +1 -0
  53. package/dist/handlers/airQualityHandler.js +154 -0
  54. package/dist/handlers/airQualityHandler.js.map +1 -0
  55. package/dist/handlers/alertsHandler.d.ts +11 -0
  56. package/dist/handlers/alertsHandler.d.ts.map +1 -0
  57. package/dist/handlers/alertsHandler.js +98 -0
  58. package/dist/handlers/alertsHandler.js.map +1 -0
  59. package/dist/handlers/currentConditionsHandler.d.ts +13 -0
  60. package/dist/handlers/currentConditionsHandler.d.ts.map +1 -0
  61. package/dist/handlers/currentConditionsHandler.js +296 -0
  62. package/dist/handlers/currentConditionsHandler.js.map +1 -0
  63. package/dist/handlers/forecastHandler.d.ts +16 -0
  64. package/dist/handlers/forecastHandler.d.ts.map +1 -0
  65. package/dist/handlers/forecastHandler.js +454 -0
  66. package/dist/handlers/forecastHandler.js.map +1 -0
  67. package/dist/handlers/historicalWeatherHandler.d.ts +12 -0
  68. package/dist/handlers/historicalWeatherHandler.d.ts.map +1 -0
  69. package/dist/handlers/historicalWeatherHandler.js +188 -0
  70. package/dist/handlers/historicalWeatherHandler.js.map +1 -0
  71. package/dist/handlers/lightningHandler.d.ts +14 -0
  72. package/dist/handlers/lightningHandler.d.ts.map +1 -0
  73. package/dist/handlers/lightningHandler.js +258 -0
  74. package/dist/handlers/lightningHandler.js.map +1 -0
  75. package/dist/handlers/locationHandler.d.ts +12 -0
  76. package/dist/handlers/locationHandler.d.ts.map +1 -0
  77. package/dist/handlers/locationHandler.js +149 -0
  78. package/dist/handlers/locationHandler.js.map +1 -0
  79. package/dist/handlers/marineConditionsHandler.d.ts +13 -0
  80. package/dist/handlers/marineConditionsHandler.d.ts.map +1 -0
  81. package/dist/handlers/marineConditionsHandler.js +270 -0
  82. package/dist/handlers/marineConditionsHandler.js.map +1 -0
  83. package/dist/handlers/riverConditionsHandler.d.ts +11 -0
  84. package/dist/handlers/riverConditionsHandler.d.ts.map +1 -0
  85. package/dist/handlers/riverConditionsHandler.js +176 -0
  86. package/dist/handlers/riverConditionsHandler.js.map +1 -0
  87. package/dist/handlers/savedLocationsHandler.d.ts +50 -0
  88. package/dist/handlers/savedLocationsHandler.d.ts.map +1 -0
  89. package/dist/handlers/savedLocationsHandler.js +397 -0
  90. package/dist/handlers/savedLocationsHandler.js.map +1 -0
  91. package/dist/handlers/statusHandler.d.ts +12 -0
  92. package/dist/handlers/statusHandler.d.ts.map +1 -0
  93. package/dist/handlers/statusHandler.js +115 -0
  94. package/dist/handlers/statusHandler.js.map +1 -0
  95. package/dist/handlers/weatherImageryHandler.d.ts +14 -0
  96. package/dist/handlers/weatherImageryHandler.d.ts.map +1 -0
  97. package/dist/handlers/weatherImageryHandler.js +143 -0
  98. package/dist/handlers/weatherImageryHandler.js.map +1 -0
  99. package/dist/handlers/wildfireHandler.d.ts +11 -0
  100. package/dist/handlers/wildfireHandler.d.ts.map +1 -0
  101. package/dist/handlers/wildfireHandler.js +186 -0
  102. package/dist/handlers/wildfireHandler.js.map +1 -0
  103. package/dist/index.d.ts +7 -0
  104. package/dist/index.d.ts.map +1 -0
  105. package/dist/index.js +735 -0
  106. package/dist/index.js.map +1 -0
  107. package/dist/services/blitzortung.d.ts +67 -0
  108. package/dist/services/blitzortung.d.ts.map +1 -0
  109. package/dist/services/blitzortung.js +475 -0
  110. package/dist/services/blitzortung.js.map +1 -0
  111. package/dist/services/geocoding.d.ts +57 -0
  112. package/dist/services/geocoding.d.ts.map +1 -0
  113. package/dist/services/geocoding.js +393 -0
  114. package/dist/services/geocoding.js.map +1 -0
  115. package/dist/services/locationStore.d.ts +62 -0
  116. package/dist/services/locationStore.d.ts.map +1 -0
  117. package/dist/services/locationStore.js +201 -0
  118. package/dist/services/locationStore.js.map +1 -0
  119. package/dist/services/ncei.d.ts +61 -0
  120. package/dist/services/ncei.d.ts.map +1 -0
  121. package/dist/services/ncei.js +126 -0
  122. package/dist/services/ncei.js.map +1 -0
  123. package/dist/services/nifc.d.ts +44 -0
  124. package/dist/services/nifc.d.ts.map +1 -0
  125. package/dist/services/nifc.js +159 -0
  126. package/dist/services/nifc.js.map +1 -0
  127. package/dist/services/noaa.d.ts +161 -0
  128. package/dist/services/noaa.d.ts.map +1 -0
  129. package/dist/services/noaa.js +681 -0
  130. package/dist/services/noaa.js.map +1 -0
  131. package/dist/services/nominatim.d.ts +62 -0
  132. package/dist/services/nominatim.d.ts.map +1 -0
  133. package/dist/services/nominatim.js +254 -0
  134. package/dist/services/nominatim.js.map +1 -0
  135. package/dist/services/openmeteo.d.ts +189 -0
  136. package/dist/services/openmeteo.d.ts.map +1 -0
  137. package/dist/services/openmeteo.js +936 -0
  138. package/dist/services/openmeteo.js.map +1 -0
  139. package/dist/services/rainviewer.d.ts +37 -0
  140. package/dist/services/rainviewer.d.ts.map +1 -0
  141. package/dist/services/rainviewer.js +115 -0
  142. package/dist/services/rainviewer.js.map +1 -0
  143. package/dist/types/imagery.d.ts +82 -0
  144. package/dist/types/imagery.d.ts.map +1 -0
  145. package/dist/types/imagery.js +6 -0
  146. package/dist/types/imagery.js.map +1 -0
  147. package/dist/types/lightning.d.ts +89 -0
  148. package/dist/types/lightning.d.ts.map +1 -0
  149. package/dist/types/lightning.js +6 -0
  150. package/dist/types/lightning.js.map +1 -0
  151. package/dist/types/noaa.d.ts +535 -0
  152. package/dist/types/noaa.d.ts.map +1 -0
  153. package/dist/types/noaa.js +5 -0
  154. package/dist/types/noaa.js.map +1 -0
  155. package/dist/types/nominatim.d.ts +72 -0
  156. package/dist/types/nominatim.d.ts.map +1 -0
  157. package/dist/types/nominatim.js +6 -0
  158. package/dist/types/nominatim.js.map +1 -0
  159. package/dist/types/openmeteo.d.ts +583 -0
  160. package/dist/types/openmeteo.d.ts.map +1 -0
  161. package/dist/types/openmeteo.js +6 -0
  162. package/dist/types/openmeteo.js.map +1 -0
  163. package/dist/types/savedLocations.d.ts +58 -0
  164. package/dist/types/savedLocations.d.ts.map +1 -0
  165. package/dist/types/savedLocations.js +5 -0
  166. package/dist/types/savedLocations.js.map +1 -0
  167. package/dist/types/wildfire.d.ts +83 -0
  168. package/dist/types/wildfire.d.ts.map +1 -0
  169. package/dist/types/wildfire.js +5 -0
  170. package/dist/types/wildfire.js.map +1 -0
  171. package/dist/utils/airQuality.d.ts +54 -0
  172. package/dist/utils/airQuality.d.ts.map +1 -0
  173. package/dist/utils/airQuality.js +251 -0
  174. package/dist/utils/airQuality.js.map +1 -0
  175. package/dist/utils/cache.d.ts +69 -0
  176. package/dist/utils/cache.d.ts.map +1 -0
  177. package/dist/utils/cache.js +164 -0
  178. package/dist/utils/cache.js.map +1 -0
  179. package/dist/utils/distance.d.ts +25 -0
  180. package/dist/utils/distance.d.ts.map +1 -0
  181. package/dist/utils/distance.js +40 -0
  182. package/dist/utils/distance.js.map +1 -0
  183. package/dist/utils/fireWeather.d.ts +76 -0
  184. package/dist/utils/fireWeather.d.ts.map +1 -0
  185. package/dist/utils/fireWeather.js +243 -0
  186. package/dist/utils/fireWeather.js.map +1 -0
  187. package/dist/utils/geography.d.ts +79 -0
  188. package/dist/utils/geography.d.ts.map +1 -0
  189. package/dist/utils/geography.js +266 -0
  190. package/dist/utils/geography.js.map +1 -0
  191. package/dist/utils/geohash.d.ts +62 -0
  192. package/dist/utils/geohash.d.ts.map +1 -0
  193. package/dist/utils/geohash.js +146 -0
  194. package/dist/utils/geohash.js.map +1 -0
  195. package/dist/utils/locationResolver.d.ts +34 -0
  196. package/dist/utils/locationResolver.d.ts.map +1 -0
  197. package/dist/utils/locationResolver.js +120 -0
  198. package/dist/utils/locationResolver.js.map +1 -0
  199. package/dist/utils/logger.d.ts +75 -0
  200. package/dist/utils/logger.d.ts.map +1 -0
  201. package/dist/utils/logger.js +153 -0
  202. package/dist/utils/logger.js.map +1 -0
  203. package/dist/utils/marine.d.ts +59 -0
  204. package/dist/utils/marine.d.ts.map +1 -0
  205. package/dist/utils/marine.js +215 -0
  206. package/dist/utils/marine.js.map +1 -0
  207. package/dist/utils/normals.d.ts +86 -0
  208. package/dist/utils/normals.d.ts.map +1 -0
  209. package/dist/utils/normals.js +223 -0
  210. package/dist/utils/normals.js.map +1 -0
  211. package/dist/utils/snow.d.ts +45 -0
  212. package/dist/utils/snow.d.ts.map +1 -0
  213. package/dist/utils/snow.js +144 -0
  214. package/dist/utils/snow.js.map +1 -0
  215. package/dist/utils/temperatureConversion.d.ts +12 -0
  216. package/dist/utils/temperatureConversion.d.ts.map +1 -0
  217. package/dist/utils/temperatureConversion.js +17 -0
  218. package/dist/utils/temperatureConversion.js.map +1 -0
  219. package/dist/utils/timezone.d.ts +56 -0
  220. package/dist/utils/timezone.d.ts.map +1 -0
  221. package/dist/utils/timezone.js +167 -0
  222. package/dist/utils/timezone.js.map +1 -0
  223. package/dist/utils/units.d.ts +69 -0
  224. package/dist/utils/units.d.ts.map +1 -0
  225. package/dist/utils/units.js +158 -0
  226. package/dist/utils/units.js.map +1 -0
  227. package/dist/utils/validation.d.ts +89 -0
  228. package/dist/utils/validation.d.ts.map +1 -0
  229. package/dist/utils/validation.js +177 -0
  230. package/dist/utils/validation.js.map +1 -0
  231. package/dist/utils/version.d.ts +15 -0
  232. package/dist/utils/version.d.ts.map +1 -0
  233. package/dist/utils/version.js +24 -0
  234. package/dist/utils/version.js.map +1 -0
  235. package/package.json +74 -0
@@ -0,0 +1,936 @@
1
+ /**
2
+ * Service for interacting with the Open-Meteo APIs
3
+ * Documentation:
4
+ * - Historical Weather: https://open-meteo.com/en/docs/historical-weather-api
5
+ * - Forecast: https://open-meteo.com/en/docs
6
+ * - Geocoding: https://open-meteo.com/en/docs/geocoding-api
7
+ * - Air Quality: https://open-meteo.com/en/docs/air-quality-api
8
+ * - Marine: https://open-meteo.com/en/docs/marine-weather-api
9
+ */
10
+ import axios from 'axios';
11
+ import { Cache } from '../utils/cache.js';
12
+ import { CacheConfig, getHistoricalDataTTL } from '../config/cache.js';
13
+ import { validateLatitude, validateLongitude } from '../utils/validation.js';
14
+ import { logger, redactCoordinatesForLogging } from '../utils/logger.js';
15
+ import { computeNormalsFrom30YearData, getNormalsCacheKey } from '../utils/normals.js';
16
+ import { getUserAgent } from '../utils/version.js';
17
+ import { RateLimitError, ServiceUnavailableError, InvalidLocationError, DataNotFoundError, ApiError } from '../errors/ApiError.js';
18
+ export class OpenMeteoService {
19
+ client;
20
+ geocodingClient;
21
+ forecastClient;
22
+ airQualityClient;
23
+ marineClient;
24
+ maxRetries;
25
+ cache;
26
+ constructor(config = {}) {
27
+ const { baseURL = 'https://archive-api.open-meteo.com/v1', geocodingURL = 'https://geocoding-api.open-meteo.com/v1', forecastURL = 'https://api.open-meteo.com/v1', airQualityURL = 'https://air-quality-api.open-meteo.com/v1', marineURL = 'https://marine-api.open-meteo.com/v1', timeout = CacheConfig.apiTimeoutMs, maxRetries = 3 } = config;
28
+ this.maxRetries = maxRetries;
29
+ this.cache = new Cache(CacheConfig.maxSize);
30
+ // Historical weather client
31
+ this.client = axios.create({
32
+ baseURL,
33
+ timeout,
34
+ headers: {
35
+ 'Accept': 'application/json',
36
+ 'User-Agent': getUserAgent()
37
+ }
38
+ });
39
+ // Geocoding client
40
+ this.geocodingClient = axios.create({
41
+ baseURL: geocodingURL,
42
+ timeout,
43
+ headers: {
44
+ 'Accept': 'application/json',
45
+ 'User-Agent': getUserAgent()
46
+ }
47
+ });
48
+ // Forecast client
49
+ this.forecastClient = axios.create({
50
+ baseURL: forecastURL,
51
+ timeout,
52
+ headers: {
53
+ 'Accept': 'application/json',
54
+ 'User-Agent': getUserAgent()
55
+ }
56
+ });
57
+ // Air quality client
58
+ this.airQualityClient = axios.create({
59
+ baseURL: airQualityURL,
60
+ timeout,
61
+ headers: {
62
+ 'Accept': 'application/json',
63
+ 'User-Agent': getUserAgent()
64
+ }
65
+ });
66
+ // Marine client
67
+ this.marineClient = axios.create({
68
+ baseURL: marineURL,
69
+ timeout,
70
+ headers: {
71
+ 'Accept': 'application/json',
72
+ 'User-Agent': getUserAgent()
73
+ }
74
+ });
75
+ // Add response interceptor for error handling
76
+ this.client.interceptors.response.use(response => response, error => this.handleError(error));
77
+ this.geocodingClient.interceptors.response.use(response => response, error => this.handleError(error));
78
+ this.forecastClient.interceptors.response.use(response => response, error => this.handleError(error));
79
+ this.airQualityClient.interceptors.response.use(response => response, error => this.handleError(error));
80
+ this.marineClient.interceptors.response.use(response => response, error => this.handleError(error));
81
+ }
82
+ /**
83
+ * Handle API errors with helpful status information
84
+ */
85
+ async handleError(error) {
86
+ if (error.response) {
87
+ const status = error.response.status;
88
+ const data = error.response.data;
89
+ // Bad request
90
+ if (status === 400) {
91
+ const reason = data.reason || 'Invalid request parameters';
92
+ logger.warn('Invalid request parameters', {
93
+ service: 'OpenMeteo',
94
+ reason,
95
+ securityEvent: true
96
+ });
97
+ throw new InvalidLocationError('OpenMeteo', `${reason}\n\nPlease verify:\n` +
98
+ `- Coordinates are valid (latitude: -90 to 90, longitude: -180 to 180)\n` +
99
+ `- Date range is valid (1940 to 5 days ago)\n` +
100
+ `- Parameters are correctly formatted`);
101
+ }
102
+ // Rate limit error
103
+ if (status === 429) {
104
+ logger.warn('Rate limit exceeded', {
105
+ service: 'OpenMeteo',
106
+ securityEvent: true
107
+ });
108
+ throw new RateLimitError('OpenMeteo');
109
+ }
110
+ // Server errors
111
+ if (status >= 500) {
112
+ throw new ServiceUnavailableError('OpenMeteo', error);
113
+ }
114
+ // Other errors
115
+ throw new ApiError(`Open-Meteo API error (${status})`, status, 'OpenMeteo', data.reason || 'Request failed', [
116
+ 'https://open-meteo.com/en/docs',
117
+ 'https://github.com/open-meteo/open-meteo/issues'
118
+ ]);
119
+ }
120
+ // Network errors
121
+ if (error.code === 'ECONNABORTED') {
122
+ throw new ServiceUnavailableError('OpenMeteo', error);
123
+ }
124
+ if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') {
125
+ throw new ServiceUnavailableError('OpenMeteo', error);
126
+ }
127
+ // Generic error
128
+ throw new ApiError(`Open-Meteo API request failed: ${error.message}`, 500, 'OpenMeteo', `Request failed: ${error.message}`, [
129
+ 'https://github.com/open-meteo/open-meteo/issues',
130
+ 'https://open-meteo.com/en/docs'
131
+ ], true);
132
+ }
133
+ /**
134
+ * Make request with retry logic
135
+ */
136
+ async makeRequest(url, params, retries = 0) {
137
+ try {
138
+ const response = await this.client.get(url, { params });
139
+ return response.data;
140
+ }
141
+ catch (error) {
142
+ // Retry on rate limit or server errors
143
+ if (retries < this.maxRetries) {
144
+ const shouldRetry = error.message.includes('rate limit') ||
145
+ error.message.includes('server error') ||
146
+ error.message.includes('timed out');
147
+ if (shouldRetry) {
148
+ // Exponential backoff with jitter to prevent thundering herd
149
+ const baseDelay = Math.pow(2, retries) * 1000;
150
+ const delay = baseDelay * (0.5 + Math.random() * 0.5);
151
+ await new Promise(resolve => setTimeout(resolve, delay));
152
+ return this.makeRequest(url, params, retries + 1);
153
+ }
154
+ }
155
+ throw error;
156
+ }
157
+ }
158
+ /**
159
+ * Get cache statistics
160
+ */
161
+ getCacheStats() {
162
+ return this.cache.getStats();
163
+ }
164
+ /**
165
+ * Clear the cache
166
+ */
167
+ clearCache() {
168
+ this.cache.clear();
169
+ }
170
+ /**
171
+ * Check if the Open-Meteo API is operational
172
+ * Performs a lightweight health check by requesting a simple query
173
+ * @returns Object with status information
174
+ */
175
+ async checkServiceStatus() {
176
+ try {
177
+ // Use a simple request for a recent date at a known location (London, UK)
178
+ // Using a 1-day range from 30 days ago to avoid the 5-day delay issue
179
+ const testDate = new Date();
180
+ testDate.setDate(testDate.getDate() - 30);
181
+ const dateStr = testDate.toISOString().split('T')[0];
182
+ const response = await this.client.get('/archive', {
183
+ params: {
184
+ latitude: 51.5074,
185
+ longitude: -0.1278,
186
+ start_date: dateStr,
187
+ end_date: dateStr,
188
+ daily: 'temperature_2m_max',
189
+ timezone: 'UTC'
190
+ },
191
+ timeout: 10000 // Shorter timeout for health check
192
+ });
193
+ if (response.status === 200 && response.data) {
194
+ return {
195
+ operational: true,
196
+ message: 'Open-Meteo API is operational',
197
+ statusPage: 'https://open-meteo.com/en/docs/model-updates',
198
+ timestamp: new Date().toISOString()
199
+ };
200
+ }
201
+ return {
202
+ operational: false,
203
+ message: `Open-Meteo API returned unexpected status: ${response.status}`,
204
+ statusPage: 'https://open-meteo.com/en/docs/model-updates',
205
+ timestamp: new Date().toISOString()
206
+ };
207
+ }
208
+ catch (error) {
209
+ const axiosError = error;
210
+ let message = 'Open-Meteo API may be experiencing issues';
211
+ let operational = false;
212
+ if (axiosError.response) {
213
+ const status = axiosError.response.status;
214
+ if (status === 429) {
215
+ operational = true; // API is up, just rate limited
216
+ message = 'Open-Meteo API is operational but rate limited';
217
+ }
218
+ else if (status >= 500) {
219
+ message = 'Open-Meteo API is experiencing server errors (possible outage)';
220
+ }
221
+ else if (status === 400) {
222
+ operational = true; // Bad request might indicate API is up but our test is wrong
223
+ message = 'Open-Meteo API is responding (health check may need adjustment)';
224
+ }
225
+ }
226
+ else if (axiosError.code === 'ECONNABORTED') {
227
+ message = 'Open-Meteo API is not responding (timeout)';
228
+ }
229
+ else if (axiosError.code === 'ENOTFOUND' || axiosError.code === 'ECONNREFUSED') {
230
+ message = 'Cannot connect to Open-Meteo API (DNS or connection failure)';
231
+ }
232
+ return {
233
+ operational,
234
+ message,
235
+ statusPage: 'https://open-meteo.com/en/docs/model-updates',
236
+ timestamp: new Date().toISOString()
237
+ };
238
+ }
239
+ }
240
+ /**
241
+ * Get historical weather data for a location
242
+ *
243
+ * @param latitude - Latitude coordinate (-90 to 90)
244
+ * @param longitude - Longitude coordinate (-180 to 180)
245
+ * @param startDate - Start date in ISO format (YYYY-MM-DD)
246
+ * @param endDate - End date in ISO format (YYYY-MM-DD)
247
+ * @param useHourly - Whether to request hourly data (default: true)
248
+ * @returns Historical weather data
249
+ */
250
+ async getHistoricalWeather(latitude, longitude, startDate, endDate, useHourly = true) {
251
+ // Validate coordinates (checks for NaN, Infinity, and range)
252
+ validateLatitude(latitude);
253
+ validateLongitude(longitude);
254
+ // Build parameters once
255
+ const params = this.buildHistoricalParams(latitude, longitude, startDate, endDate, useHourly);
256
+ // Check cache first (if enabled)
257
+ if (CacheConfig.enabled) {
258
+ const cacheKey = Cache.generateKey('openmeteo-historical', latitude, longitude, startDate, endDate, useHourly);
259
+ const cached = this.cache.get(cacheKey);
260
+ if (cached) {
261
+ return cached;
262
+ }
263
+ const response = await this.makeRequest('/archive', params);
264
+ this.validateResponse(response, startDate, endDate, useHourly);
265
+ // Use smart TTL based on date range
266
+ const ttl = getHistoricalDataTTL(startDate);
267
+ this.cache.set(cacheKey, response, ttl);
268
+ return response;
269
+ }
270
+ // No caching
271
+ const response = await this.makeRequest('/archive', params);
272
+ this.validateResponse(response, startDate, endDate, useHourly);
273
+ return response;
274
+ }
275
+ /**
276
+ * Build request parameters for historical weather data
277
+ * @private
278
+ */
279
+ buildHistoricalParams(latitude, longitude, startDate, endDate, useHourly) {
280
+ const params = {
281
+ latitude,
282
+ longitude,
283
+ start_date: startDate,
284
+ end_date: endDate,
285
+ temperature_unit: 'fahrenheit',
286
+ wind_speed_unit: 'mph',
287
+ precipitation_unit: 'inch',
288
+ timezone: 'auto'
289
+ };
290
+ // Request appropriate data granularity
291
+ if (useHourly) {
292
+ // Hourly data for detailed observations
293
+ params.hourly = [
294
+ 'temperature_2m',
295
+ 'relative_humidity_2m',
296
+ 'dewpoint_2m',
297
+ 'apparent_temperature',
298
+ 'precipitation',
299
+ 'rain',
300
+ 'snowfall',
301
+ 'weather_code',
302
+ 'pressure_msl',
303
+ 'cloud_cover',
304
+ 'wind_speed_10m',
305
+ 'wind_direction_10m',
306
+ 'wind_gusts_10m'
307
+ ].join(',');
308
+ }
309
+ else {
310
+ // Daily summaries for longer time periods
311
+ params.daily = [
312
+ 'temperature_2m_max',
313
+ 'temperature_2m_min',
314
+ 'temperature_2m_mean',
315
+ 'apparent_temperature_max',
316
+ 'apparent_temperature_min',
317
+ 'precipitation_sum',
318
+ 'rain_sum',
319
+ 'snowfall_sum',
320
+ 'precipitation_hours',
321
+ 'weather_code',
322
+ 'wind_speed_10m_max',
323
+ 'wind_gusts_10m_max',
324
+ 'wind_direction_10m_dominant'
325
+ ].join(',');
326
+ }
327
+ return params;
328
+ }
329
+ /**
330
+ * Validate that the response contains the expected data
331
+ * @private
332
+ */
333
+ validateResponse(response, startDate, endDate, useHourly) {
334
+ if (useHourly && (!response.hourly || !response.hourly.time || response.hourly.time.length === 0)) {
335
+ throw new Error(`No historical weather data available for the specified date range (${startDate} to ${endDate}).\n\n` +
336
+ 'This may occur because:\n' +
337
+ '- The dates are too recent (data has a 5-day delay for most models)\n' +
338
+ '- The dates are before 1940 (earliest available data)\n\n' +
339
+ 'Please try adjusting your date range.');
340
+ }
341
+ if (!useHourly && (!response.daily || !response.daily.time || response.daily.time.length === 0)) {
342
+ throw new Error(`No historical weather data available for the specified date range (${startDate} to ${endDate}).\n\n` +
343
+ 'Please try adjusting your date range.');
344
+ }
345
+ }
346
+ /**
347
+ * Get weather description from WMO weather code
348
+ * WMO Weather interpretation codes (WW): https://open-meteo.com/en/docs
349
+ */
350
+ getWeatherDescription(code) {
351
+ const weatherCodes = {
352
+ 0: 'Clear sky',
353
+ 1: 'Mainly clear',
354
+ 2: 'Partly cloudy',
355
+ 3: 'Overcast',
356
+ 45: 'Foggy',
357
+ 48: 'Depositing rime fog',
358
+ 51: 'Light drizzle',
359
+ 53: 'Moderate drizzle',
360
+ 55: 'Dense drizzle',
361
+ 56: 'Light freezing drizzle',
362
+ 57: 'Dense freezing drizzle',
363
+ 61: 'Slight rain',
364
+ 63: 'Moderate rain',
365
+ 65: 'Heavy rain',
366
+ 66: 'Light freezing rain',
367
+ 67: 'Heavy freezing rain',
368
+ 71: 'Slight snow',
369
+ 73: 'Moderate snow',
370
+ 75: 'Heavy snow',
371
+ 77: 'Snow grains',
372
+ 80: 'Slight rain showers',
373
+ 81: 'Moderate rain showers',
374
+ 82: 'Violent rain showers',
375
+ 85: 'Slight snow showers',
376
+ 86: 'Heavy snow showers',
377
+ 95: 'Thunderstorm',
378
+ 96: 'Thunderstorm with slight hail',
379
+ 99: 'Thunderstorm with heavy hail'
380
+ };
381
+ return weatherCodes[code] || `Unknown (code: ${code})`;
382
+ }
383
+ /**
384
+ * Search for locations by name using the Open-Meteo Geocoding API
385
+ *
386
+ * @param query - Location name to search for (e.g., "Paris", "New York, NY", "Tokyo")
387
+ * @param limit - Maximum number of results to return (default: 5, max: 100)
388
+ * @param language - Language for results (default: 'en')
389
+ * @returns Geocoding results with coordinates and metadata
390
+ */
391
+ async searchLocation(query, limit = 5, language = 'en') {
392
+ if (!query || query.trim().length === 0) {
393
+ throw new InvalidLocationError('OpenMeteo', 'Search query cannot be empty');
394
+ }
395
+ if (query.trim().length === 1) {
396
+ throw new InvalidLocationError('OpenMeteo', 'Search query must be at least 2 characters long');
397
+ }
398
+ // Validate limit
399
+ if (limit < 1 || limit > 100) {
400
+ throw new InvalidLocationError('OpenMeteo', 'Limit must be between 1 and 100');
401
+ }
402
+ // Check cache first (locations don't move, so cache indefinitely)
403
+ if (CacheConfig.enabled) {
404
+ const cacheKey = Cache.generateKey('openmeteo-geocoding', query, limit, language);
405
+ const cached = this.cache.get(cacheKey);
406
+ if (cached) {
407
+ return cached;
408
+ }
409
+ const params = {
410
+ name: query.trim(),
411
+ count: limit,
412
+ language,
413
+ format: 'json'
414
+ };
415
+ const response = await this.geocodingClient.get('/search', { params });
416
+ // Cache indefinitely (locations don't change)
417
+ // Using 30 days as TTL to keep cache from growing unbounded
418
+ this.cache.set(cacheKey, response.data, 30 * 24 * 60 * 60 * 1000);
419
+ return response.data;
420
+ }
421
+ // No caching
422
+ const params = {
423
+ name: query.trim(),
424
+ count: limit,
425
+ language,
426
+ format: 'json'
427
+ };
428
+ const response = await this.geocodingClient.get('/search', { params });
429
+ return response.data;
430
+ }
431
+ /**
432
+ * Get weather forecast from Open-Meteo Forecast API
433
+ *
434
+ * @param latitude - Latitude coordinate (-90 to 90)
435
+ * @param longitude - Longitude coordinate (-180 to 180)
436
+ * @param days - Number of forecast days (1-16, default: 7)
437
+ * @param hourly - Whether to include hourly data (default: false)
438
+ * @returns Weather forecast data
439
+ */
440
+ async getForecast(latitude, longitude, days = 7, hourly = false) {
441
+ // Validate coordinates
442
+ validateLatitude(latitude);
443
+ validateLongitude(longitude);
444
+ // Validate days
445
+ if (days < 1 || days > 16) {
446
+ throw new InvalidLocationError('OpenMeteo', 'Forecast days must be between 1 and 16');
447
+ }
448
+ // Build parameters
449
+ const params = this.buildForecastParams(latitude, longitude, days, hourly);
450
+ // Check cache first
451
+ if (CacheConfig.enabled) {
452
+ const cacheKey = Cache.generateKey('openmeteo-forecast', latitude, longitude, days, hourly);
453
+ const cached = this.cache.get(cacheKey);
454
+ if (cached) {
455
+ return cached;
456
+ }
457
+ const response = await this.makeRequestToForecast('/forecast', params);
458
+ this.validateForecastResponse(response, hourly);
459
+ // Cache for 2 hours (forecasts update regularly)
460
+ this.cache.set(cacheKey, response, 2 * 60 * 60 * 1000);
461
+ return response;
462
+ }
463
+ // No caching
464
+ const response = await this.makeRequestToForecast('/forecast', params);
465
+ this.validateForecastResponse(response, hourly);
466
+ return response;
467
+ }
468
+ /**
469
+ * Build request parameters for forecast data
470
+ * @private
471
+ */
472
+ buildForecastParams(latitude, longitude, days, hourly) {
473
+ const params = {
474
+ latitude,
475
+ longitude,
476
+ forecast_days: days,
477
+ temperature_unit: 'fahrenheit',
478
+ wind_speed_unit: 'mph',
479
+ precipitation_unit: 'inch',
480
+ timezone: 'auto'
481
+ };
482
+ // Always include daily data with sunrise/sunset
483
+ params.daily = [
484
+ 'weather_code',
485
+ 'temperature_2m_max',
486
+ 'temperature_2m_min',
487
+ 'apparent_temperature_max',
488
+ 'apparent_temperature_min',
489
+ 'sunrise',
490
+ 'sunset',
491
+ 'daylight_duration',
492
+ 'sunshine_duration',
493
+ 'uv_index_max',
494
+ 'precipitation_sum',
495
+ 'rain_sum',
496
+ 'showers_sum',
497
+ 'snowfall_sum',
498
+ 'precipitation_hours',
499
+ 'precipitation_probability_max',
500
+ 'wind_speed_10m_max',
501
+ 'wind_gusts_10m_max',
502
+ 'wind_direction_10m_dominant'
503
+ ].join(',');
504
+ // Optionally include hourly data
505
+ if (hourly) {
506
+ params.hourly = [
507
+ 'temperature_2m',
508
+ 'relative_humidity_2m',
509
+ 'dewpoint_2m',
510
+ 'apparent_temperature',
511
+ 'precipitation_probability',
512
+ 'precipitation',
513
+ 'rain',
514
+ 'showers',
515
+ 'snowfall',
516
+ 'snow_depth',
517
+ 'weather_code',
518
+ 'pressure_msl',
519
+ 'cloud_cover',
520
+ 'visibility',
521
+ 'wind_speed_10m',
522
+ 'wind_direction_10m',
523
+ 'wind_gusts_10m',
524
+ 'uv_index',
525
+ 'is_day'
526
+ ].join(',');
527
+ }
528
+ return params;
529
+ }
530
+ /**
531
+ * Make request to forecast API with retry logic
532
+ * @private
533
+ */
534
+ async makeRequestToForecast(url, params, retries = 0) {
535
+ try {
536
+ const response = await this.forecastClient.get(url, { params });
537
+ return response.data;
538
+ }
539
+ catch (error) {
540
+ // Retry on rate limit or server errors
541
+ if (retries < this.maxRetries) {
542
+ const shouldRetry = error.message.includes('rate limit') ||
543
+ error.message.includes('server error') ||
544
+ error.message.includes('timed out');
545
+ if (shouldRetry) {
546
+ const baseDelay = Math.pow(2, retries) * 1000;
547
+ const delay = baseDelay * (0.5 + Math.random() * 0.5);
548
+ await new Promise(resolve => setTimeout(resolve, delay));
549
+ return this.makeRequestToForecast(url, params, retries + 1);
550
+ }
551
+ }
552
+ throw error;
553
+ }
554
+ }
555
+ /**
556
+ * Validate that the forecast response contains the expected data
557
+ * @private
558
+ */
559
+ validateForecastResponse(response, hourly) {
560
+ if (hourly && (!response.hourly || !response.hourly.time || response.hourly.time.length === 0)) {
561
+ throw new DataNotFoundError('OpenMeteo', 'No hourly forecast data available for the specified location');
562
+ }
563
+ if (!response.daily || !response.daily.time || response.daily.time.length === 0) {
564
+ throw new DataNotFoundError('OpenMeteo', 'No daily forecast data available for the specified location');
565
+ }
566
+ }
567
+ /**
568
+ * Get air quality data from Open-Meteo Air Quality API
569
+ *
570
+ * @param latitude - Latitude coordinate (-90 to 90)
571
+ * @param longitude - Longitude coordinate (-180 to 180)
572
+ * @param forecast - Whether to include hourly forecast (default: false, returns current only)
573
+ * @param forecastDays - Number of forecast days (1-7, default: 5)
574
+ * @returns Air quality data including AQI, pollutants, and UV index
575
+ */
576
+ async getAirQuality(latitude, longitude, forecast = false, forecastDays = 5) {
577
+ // Validate coordinates
578
+ validateLatitude(latitude);
579
+ validateLongitude(longitude);
580
+ // Validate forecast days
581
+ if (forecastDays < 1 || forecastDays > 7) {
582
+ throw new InvalidLocationError('OpenMeteo', 'Air quality forecast days must be between 1 and 7');
583
+ }
584
+ // Build parameters
585
+ const params = this.buildAirQualityParams(latitude, longitude, forecast, forecastDays);
586
+ // Check cache first
587
+ if (CacheConfig.enabled) {
588
+ const cacheKey = Cache.generateKey('openmeteo-airquality', latitude, longitude, forecast, forecastDays);
589
+ const cached = this.cache.get(cacheKey);
590
+ if (cached) {
591
+ return cached;
592
+ }
593
+ const response = await this.makeRequestToAirQuality('/air-quality', params);
594
+ this.validateAirQualityResponse(response, forecast);
595
+ // Cache for 1 hour (air quality updates hourly)
596
+ this.cache.set(cacheKey, response, 60 * 60 * 1000);
597
+ return response;
598
+ }
599
+ // No caching
600
+ const response = await this.makeRequestToAirQuality('/air-quality', params);
601
+ this.validateAirQualityResponse(response, forecast);
602
+ return response;
603
+ }
604
+ /**
605
+ * Build request parameters for air quality data
606
+ * @private
607
+ */
608
+ buildAirQualityParams(latitude, longitude, forecast, forecastDays) {
609
+ const params = {
610
+ latitude,
611
+ longitude,
612
+ timezone: 'auto'
613
+ };
614
+ // Always include current data
615
+ params.current = [
616
+ 'pm10',
617
+ 'pm2_5',
618
+ 'carbon_monoxide',
619
+ 'nitrogen_dioxide',
620
+ 'sulphur_dioxide',
621
+ 'ozone',
622
+ 'aerosol_optical_depth',
623
+ 'dust',
624
+ 'uv_index',
625
+ 'uv_index_clear_sky',
626
+ 'ammonia',
627
+ 'european_aqi',
628
+ 'european_aqi_pm2_5',
629
+ 'european_aqi_pm10',
630
+ 'european_aqi_nitrogen_dioxide',
631
+ 'european_aqi_ozone',
632
+ 'european_aqi_sulphur_dioxide',
633
+ 'us_aqi',
634
+ 'us_aqi_pm2_5',
635
+ 'us_aqi_pm10',
636
+ 'us_aqi_nitrogen_dioxide',
637
+ 'us_aqi_ozone',
638
+ 'us_aqi_sulphur_dioxide',
639
+ 'us_aqi_carbon_monoxide'
640
+ ].join(',');
641
+ // Optionally include hourly forecast data
642
+ if (forecast) {
643
+ params.forecast_days = forecastDays;
644
+ params.hourly = [
645
+ 'pm10',
646
+ 'pm2_5',
647
+ 'carbon_monoxide',
648
+ 'nitrogen_dioxide',
649
+ 'sulphur_dioxide',
650
+ 'ozone',
651
+ 'aerosol_optical_depth',
652
+ 'dust',
653
+ 'uv_index',
654
+ 'uv_index_clear_sky',
655
+ 'ammonia',
656
+ 'european_aqi',
657
+ 'european_aqi_pm2_5',
658
+ 'european_aqi_pm10',
659
+ 'european_aqi_nitrogen_dioxide',
660
+ 'european_aqi_ozone',
661
+ 'european_aqi_sulphur_dioxide',
662
+ 'us_aqi',
663
+ 'us_aqi_pm2_5',
664
+ 'us_aqi_pm10',
665
+ 'us_aqi_nitrogen_dioxide',
666
+ 'us_aqi_ozone',
667
+ 'us_aqi_sulphur_dioxide',
668
+ 'us_aqi_carbon_monoxide'
669
+ ].join(',');
670
+ }
671
+ return params;
672
+ }
673
+ /**
674
+ * Make request to air quality API with retry logic
675
+ * @private
676
+ */
677
+ async makeRequestToAirQuality(url, params, retries = 0) {
678
+ try {
679
+ const response = await this.airQualityClient.get(url, { params });
680
+ return response.data;
681
+ }
682
+ catch (error) {
683
+ // Retry on rate limit or server errors
684
+ if (retries < this.maxRetries) {
685
+ const shouldRetry = error.message.includes('rate limit') ||
686
+ error.message.includes('server error') ||
687
+ error.message.includes('timed out');
688
+ if (shouldRetry) {
689
+ const baseDelay = Math.pow(2, retries) * 1000;
690
+ const delay = baseDelay * (0.5 + Math.random() * 0.5);
691
+ await new Promise(resolve => setTimeout(resolve, delay));
692
+ return this.makeRequestToAirQuality(url, params, retries + 1);
693
+ }
694
+ }
695
+ throw error;
696
+ }
697
+ }
698
+ /**
699
+ * Validate that the air quality response contains the expected data
700
+ * @private
701
+ */
702
+ validateAirQualityResponse(response, forecast) {
703
+ if (!response.current || !response.current.time) {
704
+ throw new DataNotFoundError('OpenMeteo', 'No current air quality data available for the specified location');
705
+ }
706
+ if (forecast && (!response.hourly || !response.hourly.time || response.hourly.time.length === 0)) {
707
+ throw new DataNotFoundError('OpenMeteo', 'No hourly air quality forecast data available for the specified location');
708
+ }
709
+ }
710
+ /**
711
+ * Get marine conditions data from Open-Meteo Marine API
712
+ *
713
+ * @param latitude - Latitude coordinate (-90 to 90)
714
+ * @param longitude - Longitude coordinate (-180 to 180)
715
+ * @param forecast - Whether to include hourly forecast (default: false, returns current only)
716
+ * @param forecastDays - Number of forecast days (1-7, default: 5)
717
+ * @returns Marine conditions including waves, swell, and currents
718
+ */
719
+ async getMarine(latitude, longitude, forecast = false, forecastDays = 5) {
720
+ // Validate coordinates
721
+ validateLatitude(latitude);
722
+ validateLongitude(longitude);
723
+ // Validate forecast days
724
+ if (forecastDays < 1 || forecastDays > 7) {
725
+ throw new InvalidLocationError('OpenMeteo', 'Marine forecast days must be between 1 and 7');
726
+ }
727
+ // Build parameters
728
+ const params = this.buildMarineParams(latitude, longitude, forecast, forecastDays);
729
+ // Check cache first
730
+ if (CacheConfig.enabled) {
731
+ const cacheKey = Cache.generateKey('openmeteo-marine', latitude, longitude, forecast, forecastDays);
732
+ const cached = this.cache.get(cacheKey);
733
+ if (cached) {
734
+ return cached;
735
+ }
736
+ const response = await this.makeRequestToMarine('/marine', params);
737
+ this.validateMarineResponse(response, forecast);
738
+ // Cache for 1 hour (marine conditions update hourly)
739
+ this.cache.set(cacheKey, response, 60 * 60 * 1000);
740
+ return response;
741
+ }
742
+ // No caching
743
+ const response = await this.makeRequestToMarine('/marine', params);
744
+ this.validateMarineResponse(response, forecast);
745
+ return response;
746
+ }
747
+ /**
748
+ * Build request parameters for marine data
749
+ * @private
750
+ */
751
+ buildMarineParams(latitude, longitude, forecast, forecastDays) {
752
+ const params = {
753
+ latitude,
754
+ longitude,
755
+ timezone: 'auto'
756
+ };
757
+ // Always include current data
758
+ params.current = [
759
+ 'wave_height',
760
+ 'wave_direction',
761
+ 'wave_period',
762
+ 'wind_wave_height',
763
+ 'wind_wave_direction',
764
+ 'wind_wave_period',
765
+ 'wind_wave_peak_period',
766
+ 'swell_wave_height',
767
+ 'swell_wave_direction',
768
+ 'swell_wave_period',
769
+ 'swell_wave_peak_period',
770
+ 'ocean_current_velocity',
771
+ 'ocean_current_direction'
772
+ ].join(',');
773
+ // Optionally include hourly forecast data
774
+ if (forecast) {
775
+ params.forecast_days = forecastDays;
776
+ params.hourly = [
777
+ 'wave_height',
778
+ 'wave_direction',
779
+ 'wave_period',
780
+ 'wind_wave_height',
781
+ 'wind_wave_direction',
782
+ 'wind_wave_period',
783
+ 'wind_wave_peak_period',
784
+ 'swell_wave_height',
785
+ 'swell_wave_direction',
786
+ 'swell_wave_period',
787
+ 'swell_wave_peak_period',
788
+ 'ocean_current_velocity',
789
+ 'ocean_current_direction'
790
+ ].join(',');
791
+ // Also include daily aggregates
792
+ params.daily = [
793
+ 'wave_height_max',
794
+ 'wave_direction_dominant',
795
+ 'wave_period_max',
796
+ 'wind_wave_height_max',
797
+ 'wind_wave_direction_dominant',
798
+ 'wind_wave_period_max',
799
+ 'wind_wave_peak_period_max',
800
+ 'swell_wave_height_max',
801
+ 'swell_wave_direction_dominant',
802
+ 'swell_wave_period_max',
803
+ 'swell_wave_peak_period_max'
804
+ ].join(',');
805
+ }
806
+ return params;
807
+ }
808
+ /**
809
+ * Make request to marine API with retry logic
810
+ * @private
811
+ */
812
+ async makeRequestToMarine(url, params, retries = 0) {
813
+ try {
814
+ const response = await this.marineClient.get(url, { params });
815
+ return response.data;
816
+ }
817
+ catch (error) {
818
+ // Retry on rate limit or server errors
819
+ if (retries < this.maxRetries) {
820
+ const shouldRetry = error.message.includes('rate limit') ||
821
+ error.message.includes('server error') ||
822
+ error.message.includes('timed out');
823
+ if (shouldRetry) {
824
+ const baseDelay = Math.pow(2, retries) * 1000;
825
+ const delay = baseDelay * (0.5 + Math.random() * 0.5);
826
+ await new Promise(resolve => setTimeout(resolve, delay));
827
+ return this.makeRequestToMarine(url, params, retries + 1);
828
+ }
829
+ }
830
+ throw error;
831
+ }
832
+ }
833
+ /**
834
+ * Validate that the marine response contains the expected data
835
+ * @private
836
+ */
837
+ validateMarineResponse(response, forecast) {
838
+ if (!response.current || !response.current.time) {
839
+ throw new DataNotFoundError('OpenMeteo', 'No current marine conditions data available for the specified location');
840
+ }
841
+ if (forecast && (!response.hourly || !response.hourly.time || response.hourly.time.length === 0)) {
842
+ throw new DataNotFoundError('OpenMeteo', 'No hourly marine forecast data available for the specified location');
843
+ }
844
+ }
845
+ /**
846
+ * Compute climate normals (30-year averages) for a specific date
847
+ *
848
+ * Fetches 30 years of historical data (1991-2020) and computes averages
849
+ * for the specified month/day. Results are cached indefinitely since
850
+ * climate normals don't change.
851
+ *
852
+ * @param latitude - Latitude (-90 to 90)
853
+ * @param longitude - Longitude (-180 to 180)
854
+ * @param month - Month (1-12)
855
+ * @param day - Day of month (1-31)
856
+ * @returns Climate normals (30-year averages) in Fahrenheit and inches
857
+ * @throws {InvalidLocationError} If coordinates are invalid
858
+ * @throws {DataNotFoundError} If no historical data available
859
+ * @throws {ServiceUnavailableError} If Open-Meteo API is unavailable
860
+ */
861
+ async getClimateNormals(latitude, longitude, month, day) {
862
+ validateLatitude(latitude);
863
+ validateLongitude(longitude);
864
+ // Check cache first (normals don't change, so cache forever)
865
+ const cacheKey = getNormalsCacheKey(latitude, longitude, month, day);
866
+ if (CacheConfig.enabled) {
867
+ const cached = this.cache.get(cacheKey);
868
+ if (cached) {
869
+ const redacted = redactCoordinatesForLogging(latitude, longitude);
870
+ logger.info('Climate normals cache hit', { ...redacted, month, day });
871
+ return cached;
872
+ }
873
+ }
874
+ const redacted = redactCoordinatesForLogging(latitude, longitude);
875
+ logger.info('Computing climate normals from 30-year historical data', {
876
+ ...redacted,
877
+ month,
878
+ day
879
+ });
880
+ // Fetch 30 years of historical data (1991-2020 climate normals period)
881
+ // Optimization: Fetch only the target month ±1 month across 30 years
882
+ // to reduce data transfer while ensuring we capture all occurrences
883
+ const startYear = 1991;
884
+ const endYear = 2020;
885
+ // Determine month range to fetch (target month ±1 to handle edge cases)
886
+ const startMonth = month === 1 ? 12 : month - 1;
887
+ const endMonth = month === 12 ? 1 : month + 1;
888
+ const startDate = `${startYear}-${String(startMonth).padStart(2, '0')}-01`;
889
+ const endDate = `${endYear}-${String(endMonth).padStart(2, '0')}-${this.getLastDayOfMonth(endYear, endMonth)}`;
890
+ // Build request parameters
891
+ const params = {
892
+ latitude,
893
+ longitude,
894
+ start_date: startDate,
895
+ end_date: endDate,
896
+ daily: 'temperature_2m_max,temperature_2m_min,precipitation_sum',
897
+ timezone: 'UTC' // Use UTC for consistency
898
+ };
899
+ try {
900
+ // Make request to historical API
901
+ const response = await this.makeRequest('/archive', params);
902
+ // Compute normals from 30-year data
903
+ const normals = computeNormalsFrom30YearData(response, month, day);
904
+ // Cache indefinitely (normals don't change) if caching is enabled
905
+ if (CacheConfig.enabled) {
906
+ this.cache.set(cacheKey, normals, Infinity);
907
+ }
908
+ const successRedacted = redactCoordinatesForLogging(latitude, longitude);
909
+ logger.info('Climate normals computed successfully', {
910
+ ...successRedacted,
911
+ month,
912
+ day,
913
+ tempHigh: normals.tempHigh,
914
+ tempLow: normals.tempLow,
915
+ precipitation: normals.precipitation
916
+ });
917
+ return normals;
918
+ }
919
+ catch (error) {
920
+ logger.error('Failed to compute climate normals', error);
921
+ throw error;
922
+ }
923
+ }
924
+ /**
925
+ * Get the last day of a given month
926
+ * @private
927
+ */
928
+ getLastDayOfMonth(year, month) {
929
+ // Create date at start of next month, then go back 1 day
930
+ const nextMonth = month === 12 ? 1 : month + 1;
931
+ const nextYear = month === 12 ? year + 1 : year;
932
+ const lastDay = new Date(nextYear, nextMonth - 1, 0).getDate();
933
+ return String(lastDay).padStart(2, '0');
934
+ }
935
+ }
936
+ //# sourceMappingURL=openmeteo.js.map