@meri-imperiumi/signalk-passage-briefing 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/.editorconfig +5 -0
- package/.github/workflows/publish.yml +34 -0
- package/.github/workflows/signalk-ci.yml +11 -0
- package/.github/workflows/test.yml +21 -0
- package/CHANGELOG.md +423 -0
- package/README.md +86 -0
- package/SPEC.md +488 -0
- package/bin/backfill-report.js +218 -0
- package/bin/backtest-cli.js +132 -0
- package/biome.json +6 -0
- package/doc/here-brief.png +0 -0
- package/package.json +48 -0
- package/plugin/backtest.js +742 -0
- package/plugin/brief-ext.js +173 -0
- package/plugin/bulletin-engine.js +746 -0
- package/plugin/bulletin-source.js +150 -0
- package/plugin/celestial-source.js +417 -0
- package/plugin/fetch-engine.js +717 -0
- package/plugin/history-backfill.js +540 -0
- package/plugin/index.js +1479 -0
- package/plugin/logbook-source.js +463 -0
- package/plugin/notes-publisher.js +228 -0
- package/plugin/notes-store.js +241 -0
- package/plugin/raster-convert.js +168 -0
- package/plugin/sails-configuration.js +111 -0
- package/plugin/spool-watcher.js +218 -0
- package/plugin/sqlite-db.js +378 -0
- package/plugin/state-machine.js +249 -0
- package/plugin/statustilesexamples.js +101 -0
- package/plugin/synoptic-map.json +45 -0
- package/plugin/synoptic-source.js +227 -0
- package/plugin/zone-source.js +339 -0
- package/public/app.js +18 -0
- package/public/brief-ext-model.js +64 -0
- package/public/brief-ext-widget.html +14 -0
- package/public/brief-ext-widget.js +294 -0
- package/public/components/backfill-controls.js +100 -0
- package/public/components/comfort-info.js +94 -0
- package/public/components/conditions-here.js +234 -0
- package/public/components/horizon-sparkline.js +77 -0
- package/public/components/models.mjs +372 -0
- package/public/components/passage-outlook.js +368 -0
- package/public/components/sk-api.js +146 -0
- package/public/components/sk-base-css.js +177 -0
- package/public/components/strategic-outlook.js +248 -0
- package/public/components/synoptic-chart.js +48 -0
- package/public/components/tactical-dashboard.js +172 -0
- package/public/css/visuals.css +292 -0
- package/public/gmdss-zones-min.json +287 -0
- package/public/icon.png +0 -0
- package/public/index.html +13 -0
- package/public/polar.mjs +303 -0
- package/public/route-sim.mjs +750 -0
- package/public/sereno-physics.mjs +592 -0
- package/public/tack-gybe.js +174 -0
- package/public/vendor/plotterext-bus/LICENSE +21 -0
- package/public/vendor/plotterext-bus/README.md +17 -0
- package/public/vendor/plotterext-bus/chunk-4W6N34SD.js +333 -0
- package/public/vendor/plotterext-bus/chunk-7XRFPDQL.js +263 -0
- package/public/vendor/plotterext-bus/chunk-RED55KML.js +117 -0
- package/public/vendor/plotterext-bus/extension.js +29 -0
- package/public/vendor/plotterext-bus/host.js +28 -0
- package/public/vendor/utif/LICENSE +21 -0
- package/public/vendor/utif/UTIF.js +1171 -0
- package/public/worker.js +35 -0
- package/status-tiles-examples.json +55 -0
- package/tests/backtest.test.js +461 -0
- package/tests/brief-ext.test.js +194 -0
- package/tests/bulletin-engine.test.js +361 -0
- package/tests/celestial-source.test.js +265 -0
- package/tests/fetch-engine.test.js +297 -0
- package/tests/history-backfill.test.js +341 -0
- package/tests/logbook-source.test.js +324 -0
- package/tests/notes-publisher.test.js +161 -0
- package/tests/notes-store.test.js +138 -0
- package/tests/openmeteo-mock.js +100 -0
- package/tests/plugin.test.js +1344 -0
- package/tests/route-sim.test.js +533 -0
- package/tests/sereno-physics.test.js +333 -0
- package/tests/sqlite-db.test.js +164 -0
- package/tests/state-machine.test.js +182 -0
- package/tests/statustilesexamples.test.js +86 -0
- package/tests/synoptic-source.test.js +195 -0
- package/tests/tack-gybe.test.js +211 -0
- package/tests/webapp.test.js +257 -0
- package/tests/zone-source.test.js +226 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bulletin source: internet ingestion of high-seas text bulletins
|
|
3
|
+
* (work doc #4 §1) with the same offline constraints as the weather
|
|
4
|
+
* fetches — internet only while the link is up, everything cached on
|
|
5
|
+
* disk so the briefing survives the other 23 offline hours.
|
|
6
|
+
*
|
|
7
|
+
* The configured URLs are pulled verbatim (plain text or RSS/XML —
|
|
8
|
+
* anything text-shaped runs through the same filtering pipeline).
|
|
9
|
+
* Raw texts persist in `weather/bulletins.json` keyed by URL; the
|
|
10
|
+
* newest entry per URL wins, and the cache keeps the freshest
|
|
11
|
+
* `KEEP_BULLETINS` entries overall.
|
|
12
|
+
*
|
|
13
|
+
* The radio paths (VARA HF spool files, Winlink chunks) plug into
|
|
14
|
+
* the same pipeline later via the spool watcher.
|
|
15
|
+
*
|
|
16
|
+
* @file bulletin-source.js
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const { mkdir, readFile, writeFile } = require("node:fs/promises");
|
|
20
|
+
const { join } = require("node:path");
|
|
21
|
+
const { resolveBulletinSource } = require("./bulletin-engine.js");
|
|
22
|
+
|
|
23
|
+
/** Maximum cached bulletin entries across all URLs. */
|
|
24
|
+
const KEEP_BULLETINS = 20;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {{url: string, fetchedAt: string, text: string, source?: "api"|"spool"}} BulletinEntry
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The cache file path under the plugin data directory.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} dataDir
|
|
34
|
+
* @returns {string}
|
|
35
|
+
*/
|
|
36
|
+
function cachePath(dataDir) {
|
|
37
|
+
return join(dataDir, "weather", "bulletins.json");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Loads the bulletin cache. Missing or corrupt file → empty cache
|
|
42
|
+
* (the next online refresh repopulates it).
|
|
43
|
+
*
|
|
44
|
+
* @param {string} dataDir
|
|
45
|
+
* @returns {Promise<BulletinEntry[]>} Newest first
|
|
46
|
+
*/
|
|
47
|
+
async function loadBulletinCache(dataDir) {
|
|
48
|
+
try {
|
|
49
|
+
const raw = JSON.parse(await readFile(cachePath(dataDir), "utf8"));
|
|
50
|
+
const entries = Array.isArray(raw.entries) ? raw.entries : [];
|
|
51
|
+
return entries
|
|
52
|
+
.filter(
|
|
53
|
+
(e) =>
|
|
54
|
+
e &&
|
|
55
|
+
typeof e.url === "string" &&
|
|
56
|
+
typeof e.fetchedAt === "string" &&
|
|
57
|
+
typeof e.text === "string",
|
|
58
|
+
)
|
|
59
|
+
.sort((a, b) => b.fetchedAt.localeCompare(a.fetchedAt));
|
|
60
|
+
} catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Persists the bulletin cache (newest first, pruned).
|
|
67
|
+
*
|
|
68
|
+
* @param {string} dataDir
|
|
69
|
+
* @param {BulletinEntry[]} entries
|
|
70
|
+
* @returns {Promise<void>}
|
|
71
|
+
*/
|
|
72
|
+
async function saveBulletinCache(dataDir, entries) {
|
|
73
|
+
const pruned = [...entries]
|
|
74
|
+
.sort((a, b) => b.fetchedAt.localeCompare(a.fetchedAt))
|
|
75
|
+
.slice(0, KEEP_BULLETINS);
|
|
76
|
+
await mkdir(join(dataDir, "weather"), { recursive: true });
|
|
77
|
+
await writeFile(
|
|
78
|
+
cachePath(dataDir),
|
|
79
|
+
`${JSON.stringify({ entries: pruned }, null, 2)}\n`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Fetches every configured bulletin URL and merges the results into
|
|
85
|
+
* the cache. Per-URL failures skip that URL without failing the
|
|
86
|
+
* batch — a 404 on one source must not lose the others. Callers
|
|
87
|
+
* gate on the online state (SPEC §2.2), exactly like the weather
|
|
88
|
+
* fetches. Pre-fetched zone bulletins (from zone-source's ladder)
|
|
89
|
+
* merge without a second fetch.
|
|
90
|
+
*
|
|
91
|
+
* @param {object} params
|
|
92
|
+
* @param {string} params.dataDir - Plugin data directory
|
|
93
|
+
* @param {string[]} params.urls - Bulletin source URLs
|
|
94
|
+
* @param {Array<{url: string, text: string, source: string, zone: number}>}
|
|
95
|
+
* [params.zoneBulletins] - Already-fetched zone bulletins
|
|
96
|
+
* @param {typeof fetch} [params.fetchImpl]
|
|
97
|
+
* @param {number} [params.timeoutMs]
|
|
98
|
+
* @returns {Promise<{fetched: string[], failed: string[], entries: BulletinEntry[]}>}
|
|
99
|
+
* Newly fetched URLs (failures listed separately) and the merged
|
|
100
|
+
* cache
|
|
101
|
+
*/
|
|
102
|
+
async function refreshBulletins({
|
|
103
|
+
dataDir,
|
|
104
|
+
urls,
|
|
105
|
+
zoneBulletins = [],
|
|
106
|
+
fetchImpl = fetch,
|
|
107
|
+
timeoutMs,
|
|
108
|
+
}) {
|
|
109
|
+
const cached = await loadBulletinCache(dataDir);
|
|
110
|
+
const fetched = [];
|
|
111
|
+
const failed = [];
|
|
112
|
+
for (const entry of zoneBulletins) {
|
|
113
|
+
cached.unshift({
|
|
114
|
+
url: entry.url,
|
|
115
|
+
fetchedAt: new Date().toISOString(),
|
|
116
|
+
text: entry.text,
|
|
117
|
+
source: entry.source,
|
|
118
|
+
zone: entry.zone,
|
|
119
|
+
...(entry.format ? { format: entry.format } : {}),
|
|
120
|
+
});
|
|
121
|
+
fetched.push(entry.url);
|
|
122
|
+
}
|
|
123
|
+
for (const url of urls) {
|
|
124
|
+
try {
|
|
125
|
+
const { text, source } = await resolveBulletinSource(url, {
|
|
126
|
+
fetchImpl,
|
|
127
|
+
...(timeoutMs != null ? { timeoutMs } : {}),
|
|
128
|
+
});
|
|
129
|
+
cached.unshift({
|
|
130
|
+
url,
|
|
131
|
+
fetchedAt: new Date().toISOString(),
|
|
132
|
+
text,
|
|
133
|
+
source,
|
|
134
|
+
});
|
|
135
|
+
fetched.push(url);
|
|
136
|
+
} catch {
|
|
137
|
+
failed.push(url);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
await saveBulletinCache(dataDir, cached);
|
|
141
|
+
return { fetched, failed, entries: cached };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = {
|
|
145
|
+
KEEP_BULLETINS,
|
|
146
|
+
cachePath,
|
|
147
|
+
loadBulletinCache,
|
|
148
|
+
saveBulletinCache,
|
|
149
|
+
refreshBulletins,
|
|
150
|
+
};
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Celestial & space weather source (work doc #3, Phase 1):
|
|
3
|
+
* ultra-lightweight fetch-driven events with coarse gating —
|
|
4
|
+
*
|
|
5
|
+
* - Aurora: NOAA SWPC 3-day planetary K-index forecast; an alert is
|
|
6
|
+
* emitted when the predicted Kp reaches storm level (≥ 5) and the
|
|
7
|
+
* vessel's magnetic latitude is far enough south/north for that Kp
|
|
8
|
+
* to be observable.
|
|
9
|
+
* - Naked-eye comets: JPL Small-Body Database query for comets with
|
|
10
|
+
* predicted apparent magnitude brighter than 6.0.
|
|
11
|
+
*
|
|
12
|
+
* Everything else (twilight, moon, planets, meteors, satellite
|
|
13
|
+
* passes) is Phase 2 and deliberately not attempted here.
|
|
14
|
+
*
|
|
15
|
+
* Both endpoints degrade independently: a failure nulls its half, so
|
|
16
|
+
* a blocked JPL host never costs the aurora alert.
|
|
17
|
+
*
|
|
18
|
+
* @file celestial-source.js
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Kp at which a geomagnetic storm (and mid-latitude aurora chance)
|
|
23
|
+
* begins (NOAA G-scale G1).
|
|
24
|
+
*/
|
|
25
|
+
const STORM_KP = 5;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Naked-eye visibility limit for comets.
|
|
29
|
+
*/
|
|
30
|
+
const NAKED_EYE_MAGNITUDE = 6.0;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Position of the geomagnetic north pole (2025 epoch approximation).
|
|
34
|
+
* Good enough for coarse aurora gating; not for navigation.
|
|
35
|
+
*/
|
|
36
|
+
const GEOMAGNETIC_POLE = { lat: 80.0, lon: -72.0 };
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Coarse equatorward magnetic-latitude reach of the auroral oval for
|
|
40
|
+
* a given Kp: about 65° at Kp 5, stretching to ~45° at Kp 9.
|
|
41
|
+
*
|
|
42
|
+
* @param {number} kp
|
|
43
|
+
* @returns {number} Absolute magnetic-latitude threshold in degrees
|
|
44
|
+
*/
|
|
45
|
+
function auroraMagLatThreshold(kp) {
|
|
46
|
+
return Math.max(40, 90 - 5 * kp);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Magnetic (dipole) latitude for a geographic position, using a
|
|
51
|
+
* fixed geomagnetic pole approximation.
|
|
52
|
+
*
|
|
53
|
+
* @param {number} lat - Geographic latitude, degrees
|
|
54
|
+
* @param {number} lon - Geographic longitude, degrees east
|
|
55
|
+
* @returns {number} Magnetic latitude, degrees
|
|
56
|
+
*/
|
|
57
|
+
function magneticLatitudeDeg(lat, lon) {
|
|
58
|
+
const toRad = Math.PI / 180;
|
|
59
|
+
const φ = lat * toRad;
|
|
60
|
+
const λ = lon * toRad;
|
|
61
|
+
const φ0 = GEOMAGNETIC_POLE.lat * toRad;
|
|
62
|
+
const λ0 = GEOMAGNETIC_POLE.lon * toRad;
|
|
63
|
+
return (
|
|
64
|
+
Math.asin(
|
|
65
|
+
Math.sin(φ) * Math.sin(φ0) +
|
|
66
|
+
Math.cos(φ) * Math.cos(φ0) * Math.cos(λ - λ0),
|
|
67
|
+
) / toRad
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Parses an SWPC planetary K-index forecast product and returns the
|
|
73
|
+
* entries inside the forward window, sorted by time.
|
|
74
|
+
*
|
|
75
|
+
* @param {Array<{time_tag: string, kp: number, observed?: string}>|
|
|
76
|
+
* null} json - Parsed SWPC product (3-hour steps)
|
|
77
|
+
* @param {object} [options]
|
|
78
|
+
* @param {Date} [options.from] - Window start (default now)
|
|
79
|
+
* @param {number} [options.hours=24] - Window length
|
|
80
|
+
* @returns {Array<{timestamp: string, kp: number, predicted: boolean}>}
|
|
81
|
+
*/
|
|
82
|
+
function parseKpForecast(json, { from = new Date(), hours = 24 } = {}) {
|
|
83
|
+
if (!Array.isArray(json)) {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
const start = from.getTime();
|
|
87
|
+
const end = start + hours * 3600000;
|
|
88
|
+
const entries = [];
|
|
89
|
+
for (const row of json) {
|
|
90
|
+
const t = new Date(row?.time_tag).getTime();
|
|
91
|
+
const kp = row?.kp;
|
|
92
|
+
if (!Number.isFinite(t) || typeof kp !== "number" || !Number.isFinite(kp)) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (t < start || t >= end) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
entries.push({
|
|
99
|
+
timestamp: new Date(t).toISOString(),
|
|
100
|
+
kp,
|
|
101
|
+
predicted: row.observed !== "observed",
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return entries.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Apparent magnitude of a comet from the standard total-magnitude
|
|
109
|
+
* formula m = M1 + 5·log10(Δ) + K1·log10(r).
|
|
110
|
+
*
|
|
111
|
+
* @param {object} p
|
|
112
|
+
* @param {number} p.M1 - Total magnitude parameter
|
|
113
|
+
* @param {number} p.K1 - Brightness slope parameter
|
|
114
|
+
* @param {number} p.r - Heliocentric distance, au
|
|
115
|
+
* @param {number} p.delta - Geocentric distance, au
|
|
116
|
+
* @returns {number|null}
|
|
117
|
+
*/
|
|
118
|
+
function cometApparentMagnitude({ M1, K1, r, delta }) {
|
|
119
|
+
if (
|
|
120
|
+
[M1, K1, r, delta].some(
|
|
121
|
+
(v) => typeof v !== "number" || !Number.isFinite(v),
|
|
122
|
+
) ||
|
|
123
|
+
r <= 0 ||
|
|
124
|
+
delta <= 0
|
|
125
|
+
) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
return M1 + 5 * Math.log10(delta) + K1 * Math.log10(r);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Parses an SBDB query response down to the naked-eye comets.
|
|
133
|
+
*
|
|
134
|
+
* @param {{fields?: string[], rows?: Array<Array<string|number>>}|
|
|
135
|
+
* null} json - Parsed SBDB query API response
|
|
136
|
+
* @param {object} [options]
|
|
137
|
+
* @param {number} [options.maxMagnitude] - Brightness limit
|
|
138
|
+
* @returns {Array<{name: string, magnitude: number, M1: number,
|
|
139
|
+
* K1: number, r: number, delta: number}>}
|
|
140
|
+
*/
|
|
141
|
+
function parseComets(json, { maxMagnitude = NAKED_EYE_MAGNITUDE } = {}) {
|
|
142
|
+
const fields = json?.fields;
|
|
143
|
+
const rows = json?.rows;
|
|
144
|
+
if (!Array.isArray(fields) || !Array.isArray(rows)) {
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
const index = (name) =>
|
|
148
|
+
fields.findIndex((f) => String(f).toLowerCase() === name);
|
|
149
|
+
const iName = index("full_name");
|
|
150
|
+
const iM1 = index("m1");
|
|
151
|
+
const iK1 = index("k1");
|
|
152
|
+
const iR = index("r");
|
|
153
|
+
const iDelta = index("dist");
|
|
154
|
+
const iQ = index("q");
|
|
155
|
+
const iTpCal = index("tp_cal");
|
|
156
|
+
if (iName < 0 || iM1 < 0 || iK1 < 0) {
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
const comets = [];
|
|
160
|
+
for (const row of rows) {
|
|
161
|
+
const M1 = Number(row[iM1]);
|
|
162
|
+
const K1 = Number(row[iK1]);
|
|
163
|
+
const name = row[iName];
|
|
164
|
+
if (
|
|
165
|
+
![M1, K1].every((v) => Number.isFinite(v)) ||
|
|
166
|
+
typeof name !== "string"
|
|
167
|
+
) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (iR >= 0 && iDelta >= 0) {
|
|
171
|
+
// Current distances available: apparent magnitude now
|
|
172
|
+
const r = Number(row[iR]);
|
|
173
|
+
const delta = Number(row[iDelta]);
|
|
174
|
+
const magnitude = cometApparentMagnitude({ M1, K1, r, delta });
|
|
175
|
+
if (magnitude == null) {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (magnitude <= maxMagnitude) {
|
|
179
|
+
comets.push({
|
|
180
|
+
name: name.trim(),
|
|
181
|
+
magnitude: Math.round(magnitude * 10) / 10,
|
|
182
|
+
estimate: "now",
|
|
183
|
+
M1,
|
|
184
|
+
K1,
|
|
185
|
+
r,
|
|
186
|
+
delta,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
// No current distances from this endpoint: screen on the
|
|
192
|
+
// perihelion brightness instead (r = delta = q is the comet at
|
|
193
|
+
// its brightest). The result is a peak-brightness estimate, not
|
|
194
|
+
// a now-cast — flagged so the display can say so.
|
|
195
|
+
if (iQ < 0) {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const q = Number(row[iQ]);
|
|
199
|
+
if (!Number.isFinite(q) || q <= 0) {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const magnitude = cometApparentMagnitude({ M1, K1, r: q, delta: q });
|
|
203
|
+
if (magnitude == null) {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (magnitude <= maxMagnitude) {
|
|
207
|
+
comets.push({
|
|
208
|
+
name: name.trim(),
|
|
209
|
+
magnitude: Math.round(magnitude * 10) / 10,
|
|
210
|
+
estimate: "perihelion",
|
|
211
|
+
perihelion: iTpCal >= 0 ? row[iTpCal] : null,
|
|
212
|
+
M1,
|
|
213
|
+
K1,
|
|
214
|
+
q,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return comets.sort((a, b) => a.magnitude - b.magnitude);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const SWPC_KP_URL =
|
|
222
|
+
"https://services.swpc.noaa.gov/products/noaa-planetary-k-index-forecast.json";
|
|
223
|
+
|
|
224
|
+
/** Field list the SBDB comet query is attempted with. The API rejects
|
|
225
|
+
* unknown/unsupported fields one at a time (400 "invalid field
|
|
226
|
+
* specified: 'x'"), so fetchComets negotiates the list down to what
|
|
227
|
+
* the live endpoint accepts. `r`/`dist` (current heliocentric and
|
|
228
|
+
* geocentric distance) are not documented query fields for comets —
|
|
229
|
+
* `q` (perihelion distance) is, and powers the fallback estimate. */
|
|
230
|
+
const SBDB_COMET_FIELDS = ["full_name", "M1", "K1", "r", "dist", "q", "tp_cal"];
|
|
231
|
+
|
|
232
|
+
function sbdbCometsUrl(fields) {
|
|
233
|
+
return `https://ssd-api.jpl.nasa.gov/sbdb_query.api?fields=${fields.join(",")}&sb-kind=c`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Builds the space-event list from parsed inputs (all gating that
|
|
238
|
+
* needs no network happens here).
|
|
239
|
+
*
|
|
240
|
+
* @param {object} params
|
|
241
|
+
* @param {Array<{timestamp: string, kp: number}>} [params.kpEntries]
|
|
242
|
+
* @param {Array<{name: string, magnitude: number}>} [params.comets]
|
|
243
|
+
* @param {number} params.lat - Vessel latitude
|
|
244
|
+
* @param {number} params.lon - Vessel longitude
|
|
245
|
+
* @param {Date} [params.now]
|
|
246
|
+
* @param {(date: Date, lat: number, lon: number) => boolean} [params.isNightFn]
|
|
247
|
+
* Night test (default: the shared Sereno solar geometry)
|
|
248
|
+
* @returns {Array<{kind: string, timestamp: string, tactical: boolean, description: string, kp?: number, name?: string, magnitude?: number}>}
|
|
249
|
+
*/
|
|
250
|
+
async function buildSpaceEvents({
|
|
251
|
+
kpEntries = [],
|
|
252
|
+
comets = [],
|
|
253
|
+
lat,
|
|
254
|
+
lon,
|
|
255
|
+
now = new Date(),
|
|
256
|
+
isNightFn,
|
|
257
|
+
}) {
|
|
258
|
+
const isNight =
|
|
259
|
+
isNightFn ?? (await import("../public/sereno-physics.mjs")).isNight;
|
|
260
|
+
const events = [];
|
|
261
|
+
|
|
262
|
+
// Aurora: strongest predicted Kp in the window, gated by magnetic
|
|
263
|
+
// latitude and a local-night check at the vessel
|
|
264
|
+
const peak = kpEntries.reduce(
|
|
265
|
+
(best, e) => (best == null || e.kp > best.kp ? e : best),
|
|
266
|
+
null,
|
|
267
|
+
);
|
|
268
|
+
if (peak && peak.kp >= STORM_KP) {
|
|
269
|
+
const magLat = magneticLatitudeDeg(lat, lon);
|
|
270
|
+
if (Math.abs(magLat) >= auroraMagLatThreshold(peak.kp)) {
|
|
271
|
+
const look = lat >= 0 ? "north" : "south";
|
|
272
|
+
if (isNight(new Date(peak.timestamp), lat, lon)) {
|
|
273
|
+
events.push({
|
|
274
|
+
kind: "aurora",
|
|
275
|
+
timestamp: peak.timestamp,
|
|
276
|
+
tactical: true,
|
|
277
|
+
kp: peak.kp,
|
|
278
|
+
description: `Aurora possible: Kp ${peak.kp.toFixed(0)} predicted tonight. Look ${look}.`,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Naked-eye comets: strategic items, no precision claimed
|
|
285
|
+
for (const comet of comets) {
|
|
286
|
+
events.push({
|
|
287
|
+
kind: "comet",
|
|
288
|
+
timestamp: now.toISOString(),
|
|
289
|
+
tactical: false,
|
|
290
|
+
name: comet.name,
|
|
291
|
+
magnitude: comet.magnitude,
|
|
292
|
+
description:
|
|
293
|
+
comet.estimate === "perihelion"
|
|
294
|
+
? `Comet ${comet.name} peaks near mag ${comet.magnitude.toFixed(1)} at perihelion${comet.perihelion ? ` (${comet.perihelion})` : ""} — ephemeris check advised`
|
|
295
|
+
: `Naked-eye comet ${comet.name} (mag ${comet.magnitude.toFixed(1)}) in range`,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
return events;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Fetches both sources and builds the event list. Each source fails
|
|
303
|
+
* independently; a total failure yields an empty list.
|
|
304
|
+
*
|
|
305
|
+
* @param {object} params
|
|
306
|
+
* @param {number} params.lat
|
|
307
|
+
* @param {number} params.lon
|
|
308
|
+
* @param {Date} [params.now]
|
|
309
|
+
* @param {typeof fetch} [params.fetchImpl]
|
|
310
|
+
* @param {number} [params.timeoutMs]
|
|
311
|
+
* @param {(date: Date, lat: number, lon: number) => boolean} [params.isNightFn]
|
|
312
|
+
* Night test override (tests)
|
|
313
|
+
* @returns {Promise<Array<object>>} Space events (possibly empty)
|
|
314
|
+
*/
|
|
315
|
+
/**
|
|
316
|
+
* Fetches the SBDB comet query, negotiating the field list against
|
|
317
|
+
* the live endpoint: each 400 names one invalid field, which is
|
|
318
|
+
* dropped and the query retried until it answers or the fields run
|
|
319
|
+
* out.
|
|
320
|
+
*
|
|
321
|
+
* @param {object} params
|
|
322
|
+
* @param {typeof fetch} params.fetchImpl
|
|
323
|
+
* @param {number} params.timeoutMs
|
|
324
|
+
* @returns {Promise<object|null>} Parsed `{fields, rows}` response,
|
|
325
|
+
* null when every attempt failed
|
|
326
|
+
*/
|
|
327
|
+
async function fetchComets({ fetchImpl, timeoutMs }) {
|
|
328
|
+
let fields = [...SBDB_COMET_FIELDS];
|
|
329
|
+
while (fields.length >= 2) {
|
|
330
|
+
const controller = new AbortController();
|
|
331
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
332
|
+
let response;
|
|
333
|
+
try {
|
|
334
|
+
response = await fetchImpl(sbdbCometsUrl(fields), {
|
|
335
|
+
signal: controller.signal,
|
|
336
|
+
});
|
|
337
|
+
} catch (_error) {
|
|
338
|
+
return null;
|
|
339
|
+
} finally {
|
|
340
|
+
clearTimeout(timer);
|
|
341
|
+
}
|
|
342
|
+
if (!response.ok) {
|
|
343
|
+
const detail = await response.text().catch(() => "");
|
|
344
|
+
const invalid = detail.match(/invalid field specified: '(\w+)'/);
|
|
345
|
+
if (response.status === 400 && invalid) {
|
|
346
|
+
fields = fields.filter(
|
|
347
|
+
(field) => field.toLowerCase() !== invalid[1].toLowerCase(),
|
|
348
|
+
);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
return await response.json();
|
|
354
|
+
}
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function fetchSpaceEvents({
|
|
359
|
+
lat,
|
|
360
|
+
lon,
|
|
361
|
+
now = new Date(),
|
|
362
|
+
fetchImpl = fetch,
|
|
363
|
+
timeoutMs = 15000,
|
|
364
|
+
isNightFn,
|
|
365
|
+
}) {
|
|
366
|
+
const grab = async (url) => {
|
|
367
|
+
const controller = new AbortController();
|
|
368
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
369
|
+
try {
|
|
370
|
+
const response = await fetchImpl(url, { signal: controller.signal });
|
|
371
|
+
if (!response.ok) {
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
return await response.json();
|
|
375
|
+
} catch (_error) {
|
|
376
|
+
return null;
|
|
377
|
+
} finally {
|
|
378
|
+
clearTimeout(timer);
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
const [kpJson, cometJson] = await Promise.all([
|
|
383
|
+
grab(SWPC_KP_URL),
|
|
384
|
+
fetchComets({ fetchImpl, timeoutMs }),
|
|
385
|
+
]);
|
|
386
|
+
const kpEntries = kpJson ? parseKpForecast(kpJson, { from: now }) : [];
|
|
387
|
+
const comets = cometJson ? parseComets(cometJson) : [];
|
|
388
|
+
if (kpEntries.length === 0 && comets.length === 0) {
|
|
389
|
+
return [];
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
return await buildSpaceEvents({
|
|
393
|
+
kpEntries,
|
|
394
|
+
comets,
|
|
395
|
+
lat,
|
|
396
|
+
lon,
|
|
397
|
+
now,
|
|
398
|
+
isNightFn,
|
|
399
|
+
});
|
|
400
|
+
} catch (_error) {
|
|
401
|
+
return [];
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
module.exports = {
|
|
406
|
+
STORM_KP,
|
|
407
|
+
NAKED_EYE_MAGNITUDE,
|
|
408
|
+
auroraMagLatThreshold,
|
|
409
|
+
magneticLatitudeDeg,
|
|
410
|
+
parseKpForecast,
|
|
411
|
+
cometApparentMagnitude,
|
|
412
|
+
parseComets,
|
|
413
|
+
fetchComets,
|
|
414
|
+
sbdbCometsUrl,
|
|
415
|
+
buildSpaceEvents,
|
|
416
|
+
fetchSpaceEvents,
|
|
417
|
+
};
|