@cyanheads/noaa-spaceweather-mcp-server 0.1.13 → 0.2.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 (39) hide show
  1. package/AGENTS.md +6 -4
  2. package/CLAUDE.md +6 -4
  3. package/README.md +26 -17
  4. package/changelog/0.1.x/0.1.14.md +32 -0
  5. package/changelog/0.2.x/0.2.0.md +21 -0
  6. package/dist/mcp-server/tools/definitions/get-alerts.tool.d.ts +19 -1
  7. package/dist/mcp-server/tools/definitions/get-alerts.tool.d.ts.map +1 -1
  8. package/dist/mcp-server/tools/definitions/get-alerts.tool.js +281 -41
  9. package/dist/mcp-server/tools/definitions/get-alerts.tool.js.map +1 -1
  10. package/dist/mcp-server/tools/definitions/get-aurora-forecast.tool.d.ts +9 -1
  11. package/dist/mcp-server/tools/definitions/get-aurora-forecast.tool.d.ts.map +1 -1
  12. package/dist/mcp-server/tools/definitions/get-aurora-forecast.tool.js +91 -52
  13. package/dist/mcp-server/tools/definitions/get-aurora-forecast.tool.js.map +1 -1
  14. package/dist/mcp-server/tools/definitions/get-conditions.tool.d.ts +27 -8
  15. package/dist/mcp-server/tools/definitions/get-conditions.tool.d.ts.map +1 -1
  16. package/dist/mcp-server/tools/definitions/get-conditions.tool.js +234 -38
  17. package/dist/mcp-server/tools/definitions/get-conditions.tool.js.map +1 -1
  18. package/dist/mcp-server/tools/definitions/get-kp-index.tool.d.ts +7 -1
  19. package/dist/mcp-server/tools/definitions/get-kp-index.tool.d.ts.map +1 -1
  20. package/dist/mcp-server/tools/definitions/get-kp-index.tool.js +21 -6
  21. package/dist/mcp-server/tools/definitions/get-kp-index.tool.js.map +1 -1
  22. package/dist/mcp-server/tools/definitions/get-solar-activity.tool.d.ts +32 -2
  23. package/dist/mcp-server/tools/definitions/get-solar-activity.tool.d.ts.map +1 -1
  24. package/dist/mcp-server/tools/definitions/get-solar-activity.tool.js +243 -39
  25. package/dist/mcp-server/tools/definitions/get-solar-activity.tool.js.map +1 -1
  26. package/dist/mcp-server/tools/definitions/get-solar-wind.tool.d.ts +17 -1
  27. package/dist/mcp-server/tools/definitions/get-solar-wind.tool.d.ts.map +1 -1
  28. package/dist/mcp-server/tools/definitions/get-solar-wind.tool.js +150 -21
  29. package/dist/mcp-server/tools/definitions/get-solar-wind.tool.js.map +1 -1
  30. package/dist/mcp-server/tools/definitions/index.d.ts +111 -14
  31. package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -1
  32. package/dist/services/space-weather/space-weather-service.d.ts +76 -3
  33. package/dist/services/space-weather/space-weather-service.d.ts.map +1 -1
  34. package/dist/services/space-weather/space-weather-service.js +539 -82
  35. package/dist/services/space-weather/space-weather-service.js.map +1 -1
  36. package/dist/services/space-weather/types.d.ts +177 -21
  37. package/dist/services/space-weather/types.d.ts.map +1 -1
  38. package/package.json +3 -3
  39. package/server.json +3 -3
@@ -5,7 +5,7 @@
5
5
  * records, and exposes per-feed methods used by all tools.
6
6
  * @module services/space-weather/space-weather-service
7
7
  */
8
- import { serviceUnavailable } from '@cyanheads/mcp-ts-core/errors';
8
+ import { JsonRpcErrorCode, McpError, serviceUnavailable } from '@cyanheads/mcp-ts-core/errors';
9
9
  import { fetchWithTimeout, withRetry } from '@cyanheads/mcp-ts-core/utils';
10
10
  // ── Constants ──────────────────────────────────────────────────────────────
11
11
  const BASE_URL = 'https://services.swpc.noaa.gov';
@@ -21,77 +21,245 @@ function buildUserAgent(version) {
21
21
  /** Missing/fill value used in many SWPC feeds for sensor failures. */
22
22
  const FILL_VALUE = -9999;
23
23
  // ── NOAA scale helpers ──────────────────────────────────────────────────────
24
- /** Maps Kp value (0–9) to NOAA G-scale level (0–5). Shared with get-kp-index tool. */
24
+ /**
25
+ * Maps a Kp value (0–9) to its NOAA G-scale level (0–5). Shared with the
26
+ * get-kp-index tool.
27
+ *
28
+ * SWPC publishes Kp in thirds and starts each G level at that level's "minus"
29
+ * value, so G1 begins at 5− (4.67) rather than 5. Rounding to thirds
30
+ * (`Math.round(kp * 3)`) maps every spelling of a third — 4.67, 4.66 — onto one
31
+ * integer, which makes the bands exact integer comparisons instead of float
32
+ * compares needing a tuned epsilon per band. The top boundary follows the NOAA
33
+ * scales page, which gives G4 as "Kp = 8, including a 9-" and G5 as "Kp = 9": 8.67
34
+ * is G4 and only 9.00 is G5.
35
+ */
25
36
  export function kpToGScale(kp) {
26
- if (kp >= 9)
27
- return 5;
28
- if (kp >= 8)
29
- return 4;
30
- if (kp >= 7)
31
- return 3;
32
- if (kp >= 6)
33
- return 2;
34
- if (kp >= 5)
35
- return 1;
37
+ const thirds = Math.round(kp * 3);
38
+ if (thirds >= 27)
39
+ return 5; // 9.00
40
+ if (thirds >= 23)
41
+ return 4; // 7.67
42
+ if (thirds >= 20)
43
+ return 3; // 6.67
44
+ if (thirds >= 17)
45
+ return 2; // 5.67
46
+ if (thirds >= 14)
47
+ return 1; // 4.67
36
48
  return 0;
37
49
  }
50
+ /**
51
+ * Aurora reachability by geomagnetic latitude, keyed on NOAA G level, ordered
52
+ * poleward-first. The single table for the whole server: aurora-latitude guidance
53
+ * on the Kp tools and the Kp threshold the aurora tool reports for a location both
54
+ * read it, so the two can no longer state different thresholds for one place.
55
+ *
56
+ * Latitudes for G2–G5 are the NOAA scales page figures. The 60° G1 row is this
57
+ * server's interpolation between that page's G2 row and the oval edge — the page
58
+ * states no G1 latitude. The 65° row is not a storm level at all: it is the
59
+ * quiet-time equatorward edge of the auroral oval, where aurora needs no elevated
60
+ * Kp. Below the 40° G5 row no level on the scale reaches.
61
+ *
62
+ * Each Kp floor is its level's "minus" third — the value at which SWPC starts the
63
+ * level, per {@link kpToGScale} — except G5, which begins at a whole Kp 9.
64
+ */
65
+ export const AURORA_BANDS = [
66
+ { geomagneticLatitude: 65, gScale: 0, minKp: 0 },
67
+ { geomagneticLatitude: 60, gScale: 1, minKp: 4.67 },
68
+ { geomagneticLatitude: 55, gScale: 2, minKp: 5.67 },
69
+ { geomagneticLatitude: 50, gScale: 3, minKp: 6.67 },
70
+ { geomagneticLatitude: 45, gScale: 4, minKp: 7.67 },
71
+ { geomagneticLatitude: 40, gScale: 5, minKp: 9.0 },
72
+ ];
73
+ /**
74
+ * Resolve the aurora band a geomagnetic latitude sits in, north or south. Returns
75
+ * null equatorward of the 40° G5 edge, where no NOAA storm level reaches.
76
+ */
77
+ export function auroraBandForGeomagneticLatitude(geomagneticLatitude) {
78
+ const abs = Math.abs(geomagneticLatitude);
79
+ return AURORA_BANDS.find((band) => abs >= band.geomagneticLatitude) ?? null;
80
+ }
38
81
  /** Returns aurora visibility latitude guidance for a G-scale level. */
39
82
  function gScaleToAuroraLatitude(gScale) {
40
- switch (gScale) {
41
- case 5:
42
- return 'Aurora possible to ~40° geomagnetic latitude';
43
- case 4:
44
- return 'Aurora possible to ~45° geomagnetic latitude';
45
- case 3:
46
- return 'Aurora possible to ~50° geomagnetic latitude';
47
- case 2:
48
- return 'Aurora possible to ~55° geomagnetic latitude';
49
- case 1:
50
- return 'Aurora possible to ~60° geomagnetic latitude';
51
- default:
52
- return 'No significant aurora expected at mid-latitudes';
53
- }
83
+ // G0 is "no storm", not the 65° oval row that also carries gScale 0.
84
+ const band = gScale > 0 ? AURORA_BANDS.find((b) => b.gScale === gScale) : undefined;
85
+ return band
86
+ ? `Aurora possible to ~${band.geomagneticLatitude}° geomagnetic latitude`
87
+ : 'No significant aurora expected at mid-latitudes';
54
88
  }
55
89
  // ── Shared fetch helper ─────────────────────────────────────────────────────
56
- function fetchFeed(path, ctx, userAgent) {
90
+ /**
91
+ * Matches a JSON string literal, or a bare non-finite numeric token outside one.
92
+ * The string alternative comes first so a quoted value or a key name is consumed
93
+ * whole and never rewritten — including one whose text contains "NaN".
94
+ */
95
+ const NON_FINITE_TOKEN_RE = /"(?:[^"\\]|\\.)*"|-?\bInfinity\b|\bNaN\b/g;
96
+ /**
97
+ * Replace bare `NaN` / `Infinity` / `-Infinity` tokens in numeric value positions
98
+ * with JSON `null`, leaving every quoted string byte-identical. SWPC emits these
99
+ * non-standard tokens for a failed sensor reading, which is what `null` already
100
+ * means to {@link parseNum}.
101
+ */
102
+ function nullOutNonFiniteTokens(text) {
103
+ return text.replace(NON_FINITE_TOKEN_RE, (match) => (match.startsWith('"') ? match : 'null'));
104
+ }
105
+ /** Parse JSON, returning null instead of throwing so a caller can try a repair. */
106
+ function tryParseJson(text) {
107
+ try {
108
+ return { value: JSON.parse(text) };
109
+ }
110
+ catch {
111
+ return null;
112
+ }
113
+ }
114
+ /**
115
+ * Wire-shaped feed failure: the caller's tool contract supplies the recovery hint,
116
+ * `path` names the feed on every class (an HTTP-origin error carries a status but no
117
+ * path of its own), and `data` from the underlying rejection is preserved so `status`,
118
+ * `statusText`, `retryAfter`, `retryAttempts`, and `available` survive.
119
+ *
120
+ * Both reasons map to `ServiceUnavailable`: the failure is upstream, and no 4xx from
121
+ * these keyless feeds can be caused by caller input. `feed_moved` also carries
122
+ * `retryable: false`, so a caller — or any outer retry — can see that re-attempting a
123
+ * path SWPC no longer serves cannot succeed.
124
+ */
125
+ function feedFailure(reason, message, path, ctx, data, cause) {
126
+ return new McpError(JsonRpcErrorCode.ServiceUnavailable, message, {
127
+ ...data,
128
+ ...(reason === 'feed_moved' ? { retryable: false } : {}),
129
+ path,
130
+ reason,
131
+ ...ctx.recoveryFor(reason),
132
+ }, cause !== undefined ? { cause } : undefined);
133
+ }
134
+ /**
135
+ * The codes the framework's retry predicate treats as transient, so a rejection
136
+ * carrying one has already been through all four attempts.
137
+ */
138
+ const RETRIED_CODES = new Set([
139
+ JsonRpcErrorCode.ServiceUnavailable,
140
+ JsonRpcErrorCode.Timeout,
141
+ JsonRpcErrorCode.RateLimited,
142
+ ]);
143
+ /**
144
+ * Which reason an upstream rejection belongs to, or null when it is not a feed failure
145
+ * at all and must pass through untouched.
146
+ *
147
+ * A 4xx the framework treats as permanent means the feed path itself no longer resolves
148
+ * — these tools take no upstream identifier, so nothing a caller sent can produce one
149
+ * (#21 was SWPC deleting a feed outright). Keying on the retry verdict rather than the
150
+ * status range alone is what keeps `feed_moved` synonymous with "failed on the first
151
+ * attempt": a 408 or 425 is a 4xx the framework classifies as `Timeout` and retries, so
152
+ * it belongs with the transient set. Everything else — 5xx, 429, network error, client
153
+ * deadline, unparseable or HTML body — clears on its own and stays retryable.
154
+ */
155
+ function feedFailureReason(error) {
156
+ if (error instanceof McpError) {
157
+ if (error.code === JsonRpcErrorCode.RequestCancelled)
158
+ return null;
159
+ const status = error.data?.status;
160
+ const permanentStatus = typeof status === 'number' && status >= 400 && status < 500;
161
+ if (permanentStatus && !RETRIED_CODES.has(error.code))
162
+ return 'feed_moved';
163
+ }
164
+ return 'feed_unavailable';
165
+ }
166
+ /** Matches a body whose first markup is an HTML document rather than feed content. */
167
+ const HTML_BODY_RE = /^\s*<(!DOCTYPE\s+html|html[\s>])/i;
168
+ /**
169
+ * The single funnel every feed call passes through — JSON or plain text — so it is also
170
+ * the one place a rejection is classified against the tools' declared error contract.
171
+ * A second fetch path with its own retry and error handling would lose `data.reason`,
172
+ * `data.path`, and the contract recovery hint.
173
+ *
174
+ * Classification sits **outside** the `withRetry` boundary on purpose. Both reasons
175
+ * surface as `ServiceUnavailable`, which is in the framework's transient set — rewriting
176
+ * the code inside the retry closure would turn a permanent 404 into four attempts and
177
+ * seven seconds spent on a feed SWPC no longer serves. Out here the retry decision has
178
+ * already been made from the upstream code, so attempt counts are untouched.
179
+ */
180
+ function withFeedClassification(path, ctx, run) {
57
181
  // Cast ctx to RequestContext for framework utils — Context is structurally
58
182
  // compatible but lacks the index signature the type expects.
59
183
  const reqCtx = ctx;
60
- return withRetry(async () => {
61
- const url = `${BASE_URL}${path}`;
62
- const response = await fetchWithTimeout(url, FETCH_TIMEOUT_MS, reqCtx, {
63
- signal: ctx.signal,
64
- headers: { 'User-Agent': userAgent },
65
- });
66
- const text = await response.text();
67
- if (/^\s*<(!DOCTYPE\s+html|html[\s>])/i.test(text)) {
184
+ return withRetry(() => run(reqCtx), {
185
+ operation: `fetchFeed:${path}`,
186
+ context: reqCtx,
187
+ baseDelayMs: 1000,
188
+ signal: ctx.signal,
189
+ }).catch((error) => {
190
+ // The caller withdrew the request; nothing about the feed failed.
191
+ if (ctx.signal.aborted)
192
+ throw error;
193
+ const reason = feedFailureReason(error);
194
+ if (reason === null)
195
+ throw error;
196
+ throw feedFailure(reason, error instanceof Error ? error.message : `SWPC feed request failed for ${path}.`, path, ctx, error instanceof McpError ? error.data : undefined, error);
197
+ });
198
+ }
199
+ /** One request for `path`, returning the body as text. */
200
+ async function requestBody(path, reqCtx, signal, userAgent) {
201
+ const response = await fetchWithTimeout(`${BASE_URL}${path}`, FETCH_TIMEOUT_MS, reqCtx, {
202
+ signal,
203
+ headers: { 'User-Agent': userAgent },
204
+ });
205
+ return response.text();
206
+ }
207
+ /** Fetch and parse a JSON feed, through the shared retry-plus-classify funnel. */
208
+ function fetchFeed(path, ctx, userAgent) {
209
+ return withFeedClassification(path, ctx, async (reqCtx) => {
210
+ const text = await requestBody(path, reqCtx, ctx.signal, userAgent);
211
+ if (HTML_BODY_RE.test(text)) {
68
212
  throw serviceUnavailable(`SWPC feed returned HTML instead of JSON — likely rate-limited or unavailable.`, { path });
69
213
  }
70
214
  try {
71
215
  return JSON.parse(text);
72
216
  }
73
217
  catch (err) {
218
+ // A bare NaN/Infinity token is a lexical error: the parse aborts before any
219
+ // value exists, so a reviver can never reach it. Repair only a body that has
220
+ // already failed — the happy path never pays for the scan, and these bodies
221
+ // reach 4.5 MB.
222
+ const repaired = nullOutNonFiniteTokens(text);
223
+ if (repaired !== text) {
224
+ const parsed = tryParseJson(repaired);
225
+ if (parsed)
226
+ return parsed.value;
227
+ }
74
228
  throw serviceUnavailable(`Failed to parse SWPC feed JSON from ${path}.`, { path }, { cause: err });
75
229
  }
76
- }, {
77
- operation: `fetchFeed:${path}`,
78
- context: reqCtx,
79
- baseDelayMs: 1000,
80
- signal: ctx.signal,
81
230
  });
82
231
  }
83
- // ── Normalization helpers ───────────────────────────────────────────────────
84
232
  /**
85
- * Normalize a null/string/number scale value to a number.
86
- * SWPC returns null for unavailable forecasts — treat as 0 (no storm).
233
+ * Fetch a plain-text SWPC product, through the same funnel as {@link fetchFeed}. The
234
+ * HTML guard applies unchanged: an HTML body from these paths is the rate-limited or
235
+ * unavailable page, not a product, and it clears on its own.
236
+ *
237
+ * Whether the body is the *shape* the product is documented to have is checked by the
238
+ * caller, after this resolves — the same placement as the scales feed's missing-`"0"`
239
+ * check, and for the same reason: a `feed_moved` raised in here would be a
240
+ * `ServiceUnavailable` inside the retry closure and cost four attempts on a break no
241
+ * retry can fix.
87
242
  */
88
- function coerceScale(v) {
89
- if (v == null)
90
- return 0;
91
- const n = Number(v);
92
- return Number.isFinite(n) ? n : 0;
243
+ function fetchText(path, ctx, userAgent) {
244
+ return withFeedClassification(path, ctx, async (reqCtx) => {
245
+ const text = await requestBody(path, reqCtx, ctx.signal, userAgent);
246
+ if (HTML_BODY_RE.test(text)) {
247
+ throw serviceUnavailable(`SWPC product returned HTML instead of text — likely rate-limited or unavailable.`, { path });
248
+ }
249
+ return text;
250
+ });
93
251
  }
94
- /** Parse numeric string, returning null if the value is the fill value or NaN. */
252
+ // ── Normalization helpers ───────────────────────────────────────────────────
253
+ /**
254
+ * Parse numeric string, returning null if the value is the fill value or NaN.
255
+ *
256
+ * The single numeric parse for the scales feed, levels and probabilities alike. SWPC
257
+ * sends `null` for a period it issues no level for — every forecast period's R and S,
258
+ * where the forecast is a probability instead — and that null is upstream saying "no
259
+ * level here", a different claim from level 0. Returning null rather than 0 is what
260
+ * keeps the two apart (#23); an unparseable value is not a level or a percentage
261
+ * either and reads the same way.
262
+ */
95
263
  function parseNum(s) {
96
264
  if (s == null)
97
265
  return null;
@@ -116,12 +284,18 @@ function normalizeSwpcTime(tag) {
116
284
  return `${tag.replace(' ', 'T')}Z`;
117
285
  }
118
286
  /**
119
- * Order two records oldest-first by ISO 8601 time tag. The RTSW feeds serve
120
- * newest-first; the solar wind domain records are a chronological series, so
121
- * ordering is normalized here instead of depending on upstream's.
287
+ * Comparator ordering records oldest-first on one explicit-UTC ISO 8601 field —
288
+ * lexicographic compare is chronological once every tag ends in 'Z'.
289
+ *
290
+ * Every series this service emits is oldest-first regardless of the order upstream
291
+ * serves it — the RTSW and F10.7 feeds both serve newest-first — so ordering is an
292
+ * invariant of the domain types rather than an accident of the feed. Each series names
293
+ * its own key: solar-wind records carry `timeTag`, a flare event carries no tag of its
294
+ * own and keys on `beginTime` (the feed's `time_tag` equals `begin_time` on every
295
+ * record), and an F10.7 report keys on `observedTime`.
122
296
  */
123
- function byTimeTagAscending(a, b) {
124
- return a.timeTag < b.timeTag ? -1 : a.timeTag > b.timeTag ? 1 : 0;
297
+ function byIsoAscending(key) {
298
+ return (a, b) => (a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0);
125
299
  }
126
300
  /** Three-letter month abbreviations used in SWPC product datetime lines. */
127
301
  const SWPC_MONTHS = {
@@ -157,6 +331,103 @@ function parseSwpcDatetime(raw) {
157
331
  const day = dayRaw.padStart(2, '0');
158
332
  return `${year}-${month}-${day}T${hhmm.slice(0, 2)}:${hhmm.slice(2, 4)}:00Z`;
159
333
  }
334
+ // ── Forecast Discussion parsing ─────────────────────────────────────────────
335
+ /** Matches the ":Issued:" directive of a SWPC text product and captures its value. */
336
+ const ISSUED_LINE_RE = /^:Issued:\s*(.+)$/im;
337
+ /**
338
+ * Opens a topic section. Every section of the discussion leads with this block, so it
339
+ * is the anchor the section scan keys on — the heading itself is an unprefixed line
340
+ * with nothing to distinguish it from body prose except its position above this.
341
+ */
342
+ const SUMMARY_HEADER_RE = /^\.\s*24\s*hr\s+summary/i;
343
+ /** Opens the forecast block inside a topic section. */
344
+ const FORECAST_HEADER_RE = /^\.forecast/i;
345
+ /** Any block header — what ends the preceding block's text. */
346
+ const BLOCK_HEADER_RE = /^\./;
347
+ /**
348
+ * True for a line that could be a topic heading: SWPC writes headings unprefixed,
349
+ * while every other structural line carries a ":directive", "#comment", or ".header"
350
+ * marker.
351
+ */
352
+ function isTopicHeading(line) {
353
+ const trimmed = line.trim();
354
+ return (trimmed.length > 0 &&
355
+ !trimmed.startsWith(':') &&
356
+ !trimmed.startsWith('#') &&
357
+ !trimmed.startsWith('.'));
358
+ }
359
+ /**
360
+ * The text of one block, from its header to the next header or the section's end.
361
+ * Interior blank lines survive — a summary can run several paragraphs, so splitting on
362
+ * one would truncate it — and the padding at either end does not. Null when the block
363
+ * is absent or carries no text.
364
+ */
365
+ function blockText(lines, headerRe) {
366
+ const start = lines.findIndex((line) => headerRe.test(line));
367
+ if (start === -1)
368
+ return null;
369
+ const collected = [];
370
+ for (const line of lines.slice(start + 1)) {
371
+ if (BLOCK_HEADER_RE.test(line))
372
+ break;
373
+ collected.push(line);
374
+ }
375
+ while (collected[0]?.trim() === '')
376
+ collected.shift();
377
+ while (collected.at(-1)?.trim() === '')
378
+ collected.pop();
379
+ return collected.length > 0 ? collected.join('\n') : null;
380
+ }
381
+ /**
382
+ * Parse the SWPC Forecast Discussion product into its topic sections. Returns null
383
+ * when the body carries no topic section at all, which the caller reports as a shape
384
+ * break rather than passing off as a discussion with nothing in it: the sections are
385
+ * the product, and an ":Issued:" line is no evidence of one — every SWPC text product
386
+ * opens with that directive, so a body carrying it and no section is as likely to be a
387
+ * different product served on this path as an empty discussion. An absent issue line
388
+ * alongside real sections is the opposite case and parses, with `issued` null.
389
+ *
390
+ * Sections are located from their ".24 hr Summary..." headers: the last non-blank line
391
+ * above one is the topic heading, and the section runs from there to the next topic
392
+ * heading. Scanning for unprefixed lines directly would also match every line of body
393
+ * prose.
394
+ */
395
+ function parseForecastDiscussion(raw) {
396
+ const body = raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
397
+ const lines = body.split('\n');
398
+ const issuedRaw = body.match(ISSUED_LINE_RE)?.[1]?.trim();
399
+ const starts = [];
400
+ lines.forEach((line, index) => {
401
+ if (!SUMMARY_HEADER_RE.test(line))
402
+ return;
403
+ for (let above = index - 1; above >= 0; above--) {
404
+ const candidate = lines[above] ?? '';
405
+ if (candidate.trim().length === 0)
406
+ continue;
407
+ // The first non-blank line above decides: anything marked as a directive,
408
+ // comment, or another block header means this block opens no new section.
409
+ if (isTopicHeading(candidate))
410
+ starts.push({ topic: candidate.trim(), topicIndex: above });
411
+ break;
412
+ }
413
+ });
414
+ if (starts.length === 0)
415
+ return null;
416
+ return {
417
+ // The ":Issued:" value is the same "YYYY Mon DD HHMM UTC" shape the alert bodies
418
+ // use; fall back to its raw text when it does not match, as the alert parsing does.
419
+ issued: issuedRaw === undefined ? null : (parseSwpcDatetime(issuedRaw) ?? issuedRaw),
420
+ sections: starts.map(({ topic, topicIndex }, position) => {
421
+ const end = starts[position + 1]?.topicIndex ?? lines.length;
422
+ const sectionLines = lines.slice(topicIndex + 1, end);
423
+ return {
424
+ topic,
425
+ summary: blockText(sectionLines, SUMMARY_HEADER_RE),
426
+ forecast: blockText(sectionLines, FORECAST_HEADER_RE),
427
+ };
428
+ }),
429
+ };
430
+ }
160
431
  /** Parse a product code prefix to a product type. */
161
432
  function parseProductType(id) {
162
433
  const upper = id.toUpperCase();
@@ -194,6 +465,41 @@ const CATEGORY_RE = /category\s+([GRS]\d)/i;
194
465
  * carried by every cancellation from triggering it.
195
466
  */
196
467
  const CANCEL_RE = /^CANCEL\s+(?:WARNING|WATCH|ALERT):/im;
468
+ /**
469
+ * Matches the record's own serial number. Line-anchored on purpose: every cancellation,
470
+ * extension, and continuation also carries a "<prefix> Serial Number:" field naming a
471
+ * *different* record, and an unanchored pattern would read one of those as this
472
+ * record's own serial.
473
+ */
474
+ const SERIAL_RE = /^Serial\s+Number:\s*(\S+)/im;
475
+ /** Matches the serial a cancellation names as its target. */
476
+ const CANCEL_SERIAL_RE = /^Cancel\s+Serial\s+Number:\s*(\S+)/im;
477
+ /**
478
+ * Matches the issue time a cancellation restates for its target. Only a cancellation
479
+ * carries this line, so it never collides with the record's own "Issue Time:".
480
+ */
481
+ const ORIGINAL_ISSUE_TIME_RE = /^Original\s+Issue\s+Time:\s*([^\r\n]+)/im;
482
+ /**
483
+ * Matches the supersede claim every in-force Watch carries ("THIS SUPERSEDES ANY/ALL
484
+ * PRIOR WATCHES IN EFFECT"). Line-anchored and keyed on the opening words so a body
485
+ * mentioning the word in prose cannot trigger it.
486
+ */
487
+ const SUPERSEDES_RE = /^THIS\s+SUPERSEDES\b/im;
488
+ /**
489
+ * Matches the header above a Watch's per-day storm outlook, consuming the rest of its
490
+ * line so the entry scan starts on the first day line. A cancellation states its days
491
+ * under "Cancelled Level Predicted:", which deliberately does not match — that list
492
+ * describes what was called off, not a period the product covers.
493
+ */
494
+ const PREDICTED_DAY_HEADER_RE = /^Highest\s+Storm\s+Level\s+Predicted\s+by\s+Day:[^\n]*\n/im;
495
+ /**
496
+ * Matches one "<Mon> <DD>: <Level> (<Descriptor>)" entry of a predicted-day line.
497
+ * The colon must follow the day with no space: the cancellation list writes
498
+ * "Sep 08 : None (Bellow G1)", a second guard against parsing one.
499
+ */
500
+ const PREDICTED_DAY_ENTRY_RE = /\b([A-Za-z]{3})\s+(\d{1,2}):\s+(\S+)/g;
501
+ /** The level SWPC writes for a day it forecasts no storm on. */
502
+ const NO_STORM_LEVEL = 'none';
197
503
  /**
198
504
  * Extract the NOAA scale a product body states, e.g. "G1", "R2", "S1". Prefers the
199
505
  * explicit "NOAA Scale:" label and falls back to a Watch headline's "Category G<n>".
@@ -265,6 +571,60 @@ function parseValidity(message, labelRe) {
265
571
  return null;
266
572
  return parseSwpcDatetime(value) ?? value;
267
573
  }
574
+ /**
575
+ * Derive a validity end from a Watch's "Highest Storm Level Predicted by Day:" list,
576
+ * as ISO 8601 UTC. No `WAT*` product carries a validity label, so this list is the
577
+ * only thing in the body that states how far the Watch reaches — without it, every
578
+ * Watch's end reads as unknown and an elapsed-end filter can never drop one.
579
+ *
580
+ * The end is the *end* of the last listed UTC day whose level is not "None", expressed
581
+ * as the start of the following day. A trailing "None" day forecasts quiet rather than
582
+ * extending coverage, so taking the last listed day instead over-extends by a full day
583
+ * on most live Watches; a list that is "None" throughout covers nothing and yields null.
584
+ *
585
+ * The list states no year. It is taken from `issueDatetime`, rolled forward for a
586
+ * January day listed by a December Watch — the only boundary a forward-looking
587
+ * three-day outlook can cross.
588
+ *
589
+ * Returns null when the body carries no such list, when its days are all quiet, or when
590
+ * the issue time cannot be read (there is then no year to resolve the days against).
591
+ */
592
+ function parsePredictedDayEnd(message, issueDatetime) {
593
+ const header = message.match(PREDICTED_DAY_HEADER_RE);
594
+ if (header?.index === undefined)
595
+ return null;
596
+ const issueMs = Date.parse(issueDatetime);
597
+ if (Number.isNaN(issueMs))
598
+ return null;
599
+ const issued = new Date(issueMs);
600
+ let lastStormDay = null;
601
+ // Day entries sit on the line(s) directly after the header, with no blank line before
602
+ // whatever follows them (a live Watch runs straight into its supersede line), so the
603
+ // scan stops at the first line carrying no entry rather than at a paragraph break.
604
+ for (const line of message.slice(header.index + header[0].length).split('\n')) {
605
+ const entries = [...line.matchAll(PREDICTED_DAY_ENTRY_RE)];
606
+ if (entries.length === 0)
607
+ break;
608
+ for (const [, monthAbbr, dayRaw, level] of entries) {
609
+ if (!(monthAbbr && dayRaw && level))
610
+ continue;
611
+ const month = SWPC_MONTHS[monthAbbr.toLowerCase()];
612
+ if (!month || level.toLowerCase() === NO_STORM_LEVEL)
613
+ continue;
614
+ lastStormDay = { month: Number(month), day: Number(dayRaw) };
615
+ }
616
+ }
617
+ if (!lastStormDay)
618
+ return null;
619
+ const year = issued.getUTCMonth() + 1 === 12 && lastStormDay.month === 1
620
+ ? issued.getUTCFullYear() + 1
621
+ : issued.getUTCFullYear();
622
+ // Date.UTC rolls a day past the month's length into the next month — and a December
623
+ // 32nd into the next year — so the day-after arithmetic needs no calendar guard.
624
+ const end = new Date(Date.UTC(year, lastStormDay.month - 1, lastStormDay.day + 1));
625
+ // Match the label-parsed values, which carry no milliseconds.
626
+ return end.toISOString().replace(/\.000Z$/, 'Z');
627
+ }
268
628
  // ── SpaceWeatherService ─────────────────────────────────────────────────────
269
629
  /** NOAA SWPC public feeds client. Initialized once; accessed via accessor. */
270
630
  export class SpaceWeatherService {
@@ -281,35 +641,46 @@ export class SpaceWeatherService {
281
641
  // ── NOAA Scales ────────────────────────────────────────────────────────
282
642
  /** Fetch current NOAA storm scales (today + 3-day forecast). */
283
643
  async getNoaaScales(ctx) {
284
- const raw = await fetchFeed('/products/noaa-scales.json', ctx, this.userAgent);
644
+ const path = '/products/noaa-scales.json';
645
+ const raw = await fetchFeed(path, ctx, this.userAgent);
646
+ // Levels and probabilities both go through parseNum, so an absent or unparseable
647
+ // value stays null instead of reading as level 0 or a 0% chance. SWPC names the
648
+ // probability fields per category: R carries MinorProb (R1–R2) and MajorProb
649
+ // (R3 or greater), S and G carry a single Prob.
285
650
  const normalizePeriod = (r) => ({
286
651
  date: r.DateStamp ?? '',
287
652
  time: r.TimeStamp ?? '',
653
+ // The feed splits the period stamp across two Z-less fields; joined they are the
654
+ // space-separated shape normalizeSwpcTime() exists for, and every other
655
+ // timestamp this service emits is explicit ISO 8601 UTC.
656
+ observedAt: r.DateStamp && r.TimeStamp ? normalizeSwpcTime(`${r.DateStamp} ${r.TimeStamp}`) : '',
288
657
  G: {
289
658
  category: 'G',
290
- scale: coerceScale(r.G?.Scale),
291
- text: r.G?.Text ?? '',
292
- minorProb: r.G?.Prob != null ? coerceScale(r.G.Prob) : null,
659
+ scale: parseNum(r.G?.Scale),
660
+ text: r.G?.Text ?? null,
661
+ minorProb: parseNum(r.G?.Prob),
293
662
  majorProb: null,
294
663
  },
295
664
  R: {
296
665
  category: 'R',
297
- scale: coerceScale(r.R?.Scale),
298
- text: r.R?.Text ?? '',
299
- minorProb: r.R?.MinorProb != null ? coerceScale(r.R.MinorProb) : null,
300
- majorProb: r.R?.MajorProb != null ? coerceScale(r.R.MajorProb) : null,
666
+ scale: parseNum(r.R?.Scale),
667
+ text: r.R?.Text ?? null,
668
+ minorProb: parseNum(r.R?.MinorProb),
669
+ majorProb: parseNum(r.R?.MajorProb),
301
670
  },
302
671
  S: {
303
672
  category: 'S',
304
- scale: coerceScale(r.S?.Scale),
305
- text: r.S?.Text ?? '',
306
- minorProb: r.S?.Prob != null ? coerceScale(r.S.Prob) : null,
673
+ scale: parseNum(r.S?.Scale),
674
+ text: r.S?.Text ?? null,
675
+ minorProb: parseNum(r.S?.Prob),
307
676
  majorProb: null,
308
677
  },
309
678
  });
310
679
  const today = raw['0'];
680
+ // The feed answered, but not with the shape it is documented to have — the same
681
+ // class of break as a path that no longer resolves, and equally unfixable by a retry.
311
682
  if (!today)
312
- throw serviceUnavailable('SWPC scales feed missing key "0" (today).', {
683
+ throw feedFailure('feed_moved', 'SWPC scales feed missing key "0" (today).', path, ctx, {
313
684
  available: Object.keys(raw),
314
685
  });
315
686
  return {
@@ -320,6 +691,22 @@ export class SpaceWeatherService {
320
691
  .map((p) => normalizePeriod(p)),
321
692
  };
322
693
  }
694
+ // ── Forecast Discussion ─────────────────────────────────────────────────
695
+ /**
696
+ * Fetch the SWPC Forecast Discussion — the forecaster-written narrative explaining
697
+ * what is driving the storm scales, split into its topic sections.
698
+ */
699
+ async getForecastDiscussion(ctx) {
700
+ const path = '/text/discussion.txt';
701
+ const text = await fetchText(path, ctx, this.userAgent);
702
+ const discussion = parseForecastDiscussion(text);
703
+ // The product answered, but not with the shape it is documented to have — the same
704
+ // class of break as the scales feed losing its "0" key, and equally unfixable by a
705
+ // retry. Raised out here rather than inside fetchText so it costs one attempt.
706
+ if (!discussion)
707
+ throw feedFailure('feed_moved', 'SWPC forecast discussion carried no topic section.', path, ctx, { bytes: text.length });
708
+ return discussion;
709
+ }
323
710
  // ── Kp Index ────────────────────────────────────────────────────────────
324
711
  /** Fetch observed Kp index history. */
325
712
  async getKpObserved(ctx) {
@@ -357,9 +744,11 @@ export class SpaceWeatherService {
357
744
  forecastTime: raw['Forecast Time'] ?? '',
358
745
  },
359
746
  grid: (raw.coordinates ?? []).map(([lon, lat, aurora]) => ({
360
- // OVATION grid uses 0–360 longitude. Normalize to −180..179 so user
361
- // coordinates (WGS84 standard −180..180) map to the same range for
362
- // nearest-grid-point search.
747
+ // OVATION grid uses 0–360 longitude. Normalize so user coordinates (WGS84
748
+ // standard −180..180) map to the same range for nearest-grid-point search.
749
+ // The result runs −179..180: the 180 column stays put and there is no −180
750
+ // column, so the search has to compare longitudes with an antimeridian wrap
751
+ // rather than a raw difference.
363
752
  longitude: lon > 180 ? lon - 360 : lon,
364
753
  latitude: lat,
365
754
  auroraPercent: aurora,
@@ -384,7 +773,7 @@ export class SpaceWeatherService {
384
773
  speedKmS: parseNum(r.proton_speed),
385
774
  temperatureK: parseNum(r.proton_temperature),
386
775
  }))
387
- .sort(byTimeTagAscending);
776
+ .sort(byIsoAscending('timeTag'));
388
777
  }
389
778
  /**
390
779
  * Fetch solar wind magnetic field from the RTSW feed (roughly the last 24 hours
@@ -403,12 +792,19 @@ export class SpaceWeatherService {
403
792
  bzGsm: parseNum(r.bz_gsm),
404
793
  bt: parseNum(r.bt),
405
794
  }))
406
- .sort(byTimeTagAscending);
795
+ .sort(byIsoAscending('timeTag'));
407
796
  }
408
797
  // ── Solar Activity ──────────────────────────────────────────────────────
409
- /** Fetch GOES X-ray flux (7-day, long-channel 0.1-0.8nm only). */
798
+ /**
799
+ * Fetch GOES X-ray flux (6-hour, long-channel 0.1-0.8nm only).
800
+ *
801
+ * The 6-hour feed's records are byte-identical to the newest 710 of the 7-day
802
+ * feed — same seven keys, same two energy channels, same 1-minute cadence, same
803
+ * newest time tag — so the past-hour slice its only caller takes is the same 61
804
+ * records either way, for ~4.36 MB less per call.
805
+ */
410
806
  async getXrayFlux(ctx) {
411
- const raw = await fetchFeed('/json/goes/primary/xrays-7-day.json', ctx, this.userAgent);
807
+ const raw = await fetchFeed('/json/goes/primary/xrays-6-hour.json', ctx, this.userAgent);
412
808
  return raw
413
809
  .filter((r) => r.energy === '0.1-0.8nm')
414
810
  .map((r) => ({
@@ -418,6 +814,55 @@ export class SpaceWeatherService {
418
814
  energy: r.energy,
419
815
  }));
420
816
  }
817
+ /**
818
+ * Fetch discrete GOES X-ray flare events (rolling 7 days), oldest-first.
819
+ *
820
+ * Classes are read as published, not derived from the flux. Ordering is
821
+ * normalized here rather than depending on upstream's, the same as the solar-wind
822
+ * series: it makes oldest-first an invariant of the domain type instead of an
823
+ * accident of the feed.
824
+ */
825
+ async getXrayFlares(ctx) {
826
+ const raw = await fetchFeed('/json/goes/primary/xray-flares-7-day.json', ctx, this.userAgent);
827
+ return raw
828
+ .map((r) => ({
829
+ beginTime: normalizeSwpcTime(r.begin_time),
830
+ maxTime: normalizeSwpcTime(r.max_time),
831
+ endTime: r.end_time == null ? null : normalizeSwpcTime(r.end_time),
832
+ beginClass: r.begin_class,
833
+ maxClass: r.max_class,
834
+ endClass: r.end_class ?? null,
835
+ peakFluxWm2: r.max_xrlong,
836
+ satellite: r.satellite,
837
+ }))
838
+ .sort(byIsoAscending('beginTime'));
839
+ }
840
+ /**
841
+ * Fetch the latest daily F10.7 solar radio flux report, or null when the feed
842
+ * carries no Noon record.
843
+ *
844
+ * The newest record is not the answer: the feed serves three reports per UTC day
845
+ * and its index 0 is often that day's Afternoon report, while SWPC's own one-value
846
+ * summary product reports the Noon one — which is also the only schedule carrying
847
+ * `ninety_day_mean`. Selection is by time among the Noon records, so it does not
848
+ * depend on the order upstream serves.
849
+ */
850
+ async getF107(ctx) {
851
+ const raw = await fetchFeed('/json/f107_cm_flux.json', ctx, this.userAgent);
852
+ const noonReports = raw
853
+ .filter((r) => r.reporting_schedule === 'Noon')
854
+ .map((r) => ({
855
+ observedTime: normalizeSwpcTime(r.time_tag),
856
+ fluxSfu: r.flux,
857
+ ninetyDayMeanSfu: parseNum(r.ninety_day_mean),
858
+ reportingSchedule: r.reporting_schedule,
859
+ }))
860
+ .sort(byIsoAscending('observedTime'));
861
+ // Sorted oldest-first, so the last element is the latest Noon report. Ordered by
862
+ // time rather than read off a position: the feed serves newest-first today, and
863
+ // its index 0 is usually that day's Morning or Afternoon report.
864
+ return noonReports.at(-1) ?? null;
865
+ }
421
866
  /** Fetch active solar regions (most recent observed date only). */
422
867
  async getSolarRegions(ctx) {
423
868
  const raw = await fetchFeed('/json/solar_regions.json', ctx, this.userAgent);
@@ -463,10 +908,13 @@ export class SpaceWeatherService {
463
908
  // Normalize to UTC: the date field is "2026-06-04T00:00:00" without a 'Z',
464
909
  // so new Date() would interpret it as local time. Append 'Z' to force UTC.
465
910
  const rawDate = latest.date.endsWith('Z') ? latest.date : `${latest.date}Z`;
466
- const baseDate = new Date(rawDate);
911
+ // Advance the epoch by whole UTC days. `setDate`/`getDate` read the process
912
+ // timezone's calendar, so a window crossing a DST transition there shifts the
913
+ // emitted instant by the offset change — duplicating one forecast day at a
914
+ // spring-forward and dropping every date off midnight UTC at a fall-back.
915
+ const baseMs = new Date(rawDate).getTime();
467
916
  return [0, 1, 2].map((dayOffset) => {
468
- const d = new Date(baseDate);
469
- d.setDate(d.getDate() + dayOffset);
917
+ const date = new Date(baseMs + dayOffset * 86_400_000);
470
918
  const suffix = dayOffset === 0 ? '1_day' : dayOffset === 1 ? '2_day' : '3_day';
471
919
  // Parse each probability once, then expose it under both the legacy
472
920
  // date-specific name and the date-neutral alias (#16) so the two never drift.
@@ -475,7 +923,7 @@ export class SpaceWeatherService {
475
923
  const xClass = parseNum(latest[`x_class_${suffix}`]) ?? 0;
476
924
  const protons = parseNum(latest[`10mev_protons_${suffix}`]) ?? 0;
477
925
  return {
478
- date: d.toISOString(),
926
+ date: date.toISOString(),
479
927
  cClass1Day: cClass,
480
928
  cClassProbability: cClass,
481
929
  mClass1Day: mClass,
@@ -518,6 +966,11 @@ export class SpaceWeatherService {
518
966
  // The body's NOAA scale drives both level and phenomenon — the message code's
519
967
  // suffix and prefix shape misreport both for most live products.
520
968
  const noaaScale = parseNoaaScale(message);
969
+ // Normalize SWPC's space-separated datetime ("2026-06-06 22:11:17") to ISO 8601 so
970
+ // downstream Date comparisons work correctly (the SpaceWeatherAlert.issueDatetime
971
+ // contract says ISO 8601; raw feed values break string comparisons with ISO cutoffs).
972
+ // The predicted-day derivation below also reads it, for the year its days omit.
973
+ const issueDatetime = normalizeSwpcTime(r.issue_datetime ?? '');
521
974
  return {
522
975
  productId: id,
523
976
  messageCode: msgCode,
@@ -525,10 +978,11 @@ export class SpaceWeatherService {
525
978
  level: parseLevel(msgCode, noaaScale),
526
979
  noaaScale,
527
980
  cancelled: CANCEL_RE.test(message),
528
- // Normalize SWPC's space-separated datetime ("2026-06-06 22:11:17") to ISO 8601 so
529
- // downstream Date comparisons work correctly (the SpaceWeatherAlert.issueDatetime
530
- // contract says ISO 8601; raw feed values break string comparisons with ISO cutoffs).
531
- issueDatetime: normalizeSwpcTime(r.issue_datetime ?? ''),
981
+ serialNumber: message.match(SERIAL_RE)?.[1] ?? null,
982
+ cancelsSerialNumber: message.match(CANCEL_SERIAL_RE)?.[1] ?? null,
983
+ cancelsOriginalIssueDatetime: parseValidity(message, ORIGINAL_ISSUE_TIME_RE),
984
+ supersedes: SUPERSEDES_RE.test(message),
985
+ issueDatetime,
532
986
  message,
533
987
  phenomenon: parsePhenomenon(msgCode, noaaScale),
534
988
  // Validity window parsed from the message body, normalized to ISO 8601.
@@ -537,7 +991,10 @@ export class SpaceWeatherService {
537
991
  // and Alerts/Summaries use "Begin/End Time". Products with no such line
538
992
  // keep null.
539
993
  validFrom: parseValidity(message, /(?:Valid\s+From|Begin\s+Time):\s*([^\r\n]+)/i),
540
- validTo: parseValidity(message, /(?:Valid\s+To|Now\s+Valid\s+Until|End\s+Time):\s*([^\r\n]+)/i),
994
+ // A Watch carries no end label at all, so fall back to the end its per-day
995
+ // storm outlook implies. A stated label always wins over the derivation.
996
+ validTo: parseValidity(message, /(?:Valid\s+To|Now\s+Valid\s+Until|End\s+Time):\s*([^\r\n]+)/i) ??
997
+ parsePredictedDayEnd(message, issueDatetime),
541
998
  };
542
999
  });
543
1000
  }