@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.
- package/AGENTS.md +6 -4
- package/CLAUDE.md +6 -4
- package/README.md +26 -17
- package/changelog/0.1.x/0.1.14.md +32 -0
- package/changelog/0.2.x/0.2.0.md +21 -0
- package/dist/mcp-server/tools/definitions/get-alerts.tool.d.ts +19 -1
- package/dist/mcp-server/tools/definitions/get-alerts.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-alerts.tool.js +281 -41
- package/dist/mcp-server/tools/definitions/get-alerts.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-aurora-forecast.tool.d.ts +9 -1
- package/dist/mcp-server/tools/definitions/get-aurora-forecast.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-aurora-forecast.tool.js +91 -52
- package/dist/mcp-server/tools/definitions/get-aurora-forecast.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-conditions.tool.d.ts +27 -8
- package/dist/mcp-server/tools/definitions/get-conditions.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-conditions.tool.js +234 -38
- package/dist/mcp-server/tools/definitions/get-conditions.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-kp-index.tool.d.ts +7 -1
- package/dist/mcp-server/tools/definitions/get-kp-index.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-kp-index.tool.js +21 -6
- package/dist/mcp-server/tools/definitions/get-kp-index.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-solar-activity.tool.d.ts +32 -2
- package/dist/mcp-server/tools/definitions/get-solar-activity.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-solar-activity.tool.js +243 -39
- package/dist/mcp-server/tools/definitions/get-solar-activity.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-solar-wind.tool.d.ts +17 -1
- package/dist/mcp-server/tools/definitions/get-solar-wind.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-solar-wind.tool.js +150 -21
- package/dist/mcp-server/tools/definitions/get-solar-wind.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/index.d.ts +111 -14
- package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -1
- package/dist/services/space-weather/space-weather-service.d.ts +76 -3
- package/dist/services/space-weather/space-weather-service.d.ts.map +1 -1
- package/dist/services/space-weather/space-weather-service.js +539 -82
- package/dist/services/space-weather/space-weather-service.js.map +1 -1
- package/dist/services/space-weather/types.d.ts +177 -21
- package/dist/services/space-weather/types.d.ts.map +1 -1
- package/package.json +3 -3
- 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
|
-
/**
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (
|
|
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
|
-
*
|
|
86
|
-
*
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
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
|
|
124
|
-
return a
|
|
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
|
|
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:
|
|
291
|
-
text: r.G?.Text ??
|
|
292
|
-
minorProb: r.G?.Prob
|
|
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:
|
|
298
|
-
text: r.R?.Text ??
|
|
299
|
-
minorProb: r.R?.MinorProb
|
|
300
|
-
majorProb: r.R?.MajorProb
|
|
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:
|
|
305
|
-
text: r.S?.Text ??
|
|
306
|
-
minorProb: r.S?.Prob
|
|
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
|
|
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
|
|
361
|
-
//
|
|
362
|
-
//
|
|
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(
|
|
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(
|
|
795
|
+
.sort(byIsoAscending('timeTag'));
|
|
407
796
|
}
|
|
408
797
|
// ── Solar Activity ──────────────────────────────────────────────────────
|
|
409
|
-
/**
|
|
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-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
|
|
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
|
}
|