@aguacerowx/javascript-sdk 0.0.23 → 0.0.24
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/package.json +1 -1
- package/src/AguaceroCore.js +1623 -1623
- package/src/nexrad_support.js +306 -306
package/src/nexrad_support.js
CHANGED
|
@@ -1,306 +1,306 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* NEXRAD Level-II / Level-III time listings (aligned with aguacero-frontend nexradTimes.ts).
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
formatTiltForApi,
|
|
7
|
-
clampNexradTiltForVariable,
|
|
8
|
-
getDefaultRadarTilt,
|
|
9
|
-
getRadarTilts,
|
|
10
|
-
} from './nexradTilts.js';
|
|
11
|
-
import { coalesceNexradTiltOptionsForDisplay } from './nexradTiltCoalesce.js';
|
|
12
|
-
import {
|
|
13
|
-
NEXRAD_LEVEL3_ELEV,
|
|
14
|
-
NEXRAD_LEVEL3_MOTION_PRODUCT,
|
|
15
|
-
getNexradLevel3EntryByRadarKey,
|
|
16
|
-
} from './nexrad_level3_catalog.js';
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Whether listings and archive fetches for this product use Level-II sweep lambda vs Level-III S3 products.
|
|
20
|
-
* AguaceroCore derives this from the product key only (not user-configurable).
|
|
21
|
-
* @param {string} [nexradProduct] - Radar variable key (e.g. REF, KDP, VEL).
|
|
22
|
-
* @returns {'level2'|'level3'}
|
|
23
|
-
*/
|
|
24
|
-
export function inferNexradDataSourceForProduct(nexradProduct) {
|
|
25
|
-
const p = (nexradProduct || 'REF').toUpperCase();
|
|
26
|
-
return getNexradLevel3EntryByRadarKey(p) ? 'level3' : 'level2';
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Colormap / DICTIONARIES.fld key for customColormaps (matches frontend userDefaultColormap). */
|
|
30
|
-
export function nexradColormapFldKey(nexradDataSource, nexradProduct) {
|
|
31
|
-
const p = (nexradProduct || 'REF').toUpperCase();
|
|
32
|
-
if (nexradDataSource === 'level3') {
|
|
33
|
-
const entry = getNexradLevel3EntryByRadarKey(p);
|
|
34
|
-
return entry?.fldKey ?? `nexrad_l3_${p.toLowerCase()}`;
|
|
35
|
-
}
|
|
36
|
-
const map = {
|
|
37
|
-
REF: 'nexrad_ref',
|
|
38
|
-
PHI: 'nexrad_phi',
|
|
39
|
-
ZDR: 'nexrad_zdr',
|
|
40
|
-
RHO: 'nexrad_rho',
|
|
41
|
-
KDP: 'nexrad_kdp',
|
|
42
|
-
VEL: 'nexrad_vel',
|
|
43
|
-
SW: 'nexrad_sw',
|
|
44
|
-
};
|
|
45
|
-
return map[p] ?? `nexrad_${p.toLowerCase()}`;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export const NEXRAD_SWEEP_LAMBDA_URL = 'https://ddknicwcw2wyov7v5bzxmbqu440tzntf.lambda-url.us-east-2.on.aws/';
|
|
49
|
-
export const NEXRAD_LEVEL3_BASE_URL = 'https://unidata-nexrad-level3.s3.amazonaws.com';
|
|
50
|
-
|
|
51
|
-
const NEXRAD_GROUP_G1 = ['REF', 'PHI', 'ZDR', 'RHO'];
|
|
52
|
-
const NEXRAD_GROUP_G2 = ['VEL', 'SW'];
|
|
53
|
-
|
|
54
|
-
export function variableToNexradGroup(variable) {
|
|
55
|
-
const v = (variable || 'REF').toUpperCase();
|
|
56
|
-
if (NEXRAD_GROUP_G2.includes(v)) return 'g2';
|
|
57
|
-
return 'g1';
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export function nexradBinGroupIdForKey(cacheGroup) {
|
|
61
|
-
return cacheGroup === 'g1' ? 1 : 2;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function getLevel3StationPrefix(stationId) {
|
|
65
|
-
const upper = (stationId || '').toUpperCase();
|
|
66
|
-
if (upper.startsWith('K') && upper.length === 4) return upper.slice(1);
|
|
67
|
-
return upper.slice(-3);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function parseS3KeyTimeToUnix(key) {
|
|
71
|
-
const m = key.match(/_(\d{4})_(\d{2})_(\d{2})_(\d{2})_(\d{2})_(\d{2})$/);
|
|
72
|
-
if (!m) return null;
|
|
73
|
-
const [, y, mo, d, h, mi, s] = m;
|
|
74
|
-
const unix = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s)) / 1000;
|
|
75
|
-
return Number.isFinite(unix) ? unix : null;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export async function fetchLevel3ProductTimesForStation(stationId, productCode, listWindowHours) {
|
|
79
|
-
const stationPrefix = getLevel3StationPrefix(stationId);
|
|
80
|
-
const nowMs = Date.now();
|
|
81
|
-
const nowSec = Math.floor(nowMs / 1000);
|
|
82
|
-
const hourSec = 3600;
|
|
83
|
-
const windowSec = Math.max(hourSec, Math.ceil(listWindowHours) * hourSec);
|
|
84
|
-
|
|
85
|
-
const dayPrefixForOffset = (dayOff) => {
|
|
86
|
-
const dt = new Date(nowMs - dayOff * 86400000);
|
|
87
|
-
const y = dt.getUTCFullYear();
|
|
88
|
-
const mo = String(dt.getUTCMonth() + 1).padStart(2, '0');
|
|
89
|
-
const day = String(dt.getUTCDate()).padStart(2, '0');
|
|
90
|
-
return `${stationPrefix}_${productCode}_${y}_${mo}_${day}_`;
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
const dayPrefixes = [dayPrefixForOffset(0), dayPrefixForOffset(1)];
|
|
94
|
-
const byUnix = new Map();
|
|
95
|
-
|
|
96
|
-
const listKeysForPrefix = async (prefix) => {
|
|
97
|
-
let continuationToken = null;
|
|
98
|
-
for (let page = 0; page < 8; page++) {
|
|
99
|
-
const params = new URLSearchParams({
|
|
100
|
-
'list-type': '2',
|
|
101
|
-
prefix,
|
|
102
|
-
'max-keys': '1000',
|
|
103
|
-
});
|
|
104
|
-
if (continuationToken) params.set('continuation-token', continuationToken);
|
|
105
|
-
const res = await fetch(`${NEXRAD_LEVEL3_BASE_URL}/?${params.toString()}`);
|
|
106
|
-
if (!res.ok) throw new Error(`Level3 list HTTP ${res.status}`);
|
|
107
|
-
const xml = await res.text();
|
|
108
|
-
const keyMatches = [...xml.matchAll(/<Key>([^<]+)<\/Key>/g)];
|
|
109
|
-
keyMatches.forEach((match) => {
|
|
110
|
-
const objectKey = match[1];
|
|
111
|
-
const unix = parseS3KeyTimeToUnix(objectKey);
|
|
112
|
-
if (unix == null) return;
|
|
113
|
-
byUnix.set(unix, objectKey);
|
|
114
|
-
});
|
|
115
|
-
const tokenMatch = xml.match(/<NextContinuationToken>([^<]+)<\/NextContinuationToken>/);
|
|
116
|
-
continuationToken = tokenMatch?.[1] || null;
|
|
117
|
-
if (!continuationToken) break;
|
|
118
|
-
}
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
for (const prefix of dayPrefixes) {
|
|
122
|
-
await listKeysForPrefix(prefix);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const sorted = [...byUnix.keys()].sort((a, b) => a - b);
|
|
126
|
-
const windowStart = nowSec - windowSec;
|
|
127
|
-
let inWindow = sorted.filter((t) => t >= windowStart);
|
|
128
|
-
if (inWindow.length === 0 && sorted.length > 0) {
|
|
129
|
-
inWindow = sorted.filter((t) => t >= nowSec - Math.max(6 * hourSec, windowSec));
|
|
130
|
-
}
|
|
131
|
-
if (inWindow.length === 0 && sorted.length > 0) {
|
|
132
|
-
inWindow = sorted.slice(-48);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const timeToKeyMap = {};
|
|
136
|
-
inWindow.forEach((t) => {
|
|
137
|
-
const k = byUnix.get(t);
|
|
138
|
-
if (k) timeToKeyMap[String(t)] = k;
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
return { unixTimes: inWindow, timeToKeyMap };
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* @param {object} opts
|
|
146
|
-
* @param {string} opts.stationId
|
|
147
|
-
* @param {string} [opts.variable]
|
|
148
|
-
* @param {string} [opts.elev]
|
|
149
|
-
* @param {'level2'|'level3'} [opts.source]
|
|
150
|
-
* @param {boolean} [opts.level3StormRelative]
|
|
151
|
-
* @param {number} opts.listingWindowHours
|
|
152
|
-
*/
|
|
153
|
-
export async function fetchNexradTimesListing(opts) {
|
|
154
|
-
const {
|
|
155
|
-
stationId,
|
|
156
|
-
variable = 'REF',
|
|
157
|
-
elev,
|
|
158
|
-
source = 'level2',
|
|
159
|
-
level3StormRelative,
|
|
160
|
-
listingWindowHours,
|
|
161
|
-
} = opts;
|
|
162
|
-
if (!stationId) {
|
|
163
|
-
return { unixTimes: [], timeToKeyMap: {}, level3MotionUnixTimes: [], level3MotionTimeToKeyMap: {} };
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const dataSource = source === 'level3' ? 'level3' : 'level2';
|
|
167
|
-
let tiltNum = Number(elev);
|
|
168
|
-
if (!Number.isFinite(tiltNum)) tiltNum = getDefaultRadarTilt(stationId);
|
|
169
|
-
const elevNormUse =
|
|
170
|
-
dataSource === 'level3'
|
|
171
|
-
? NEXRAD_LEVEL3_ELEV
|
|
172
|
-
: formatTiltForApi(clampNexradTiltForVariable(stationId, variable, tiltNum));
|
|
173
|
-
const group = dataSource === 'level3' ? 'l3' : variableToNexradGroup(variable);
|
|
174
|
-
const l3Product =
|
|
175
|
-
dataSource === 'level3'
|
|
176
|
-
? opts.level3Product ??
|
|
177
|
-
getNexradLevel3EntryByRadarKey(variable)?.product ??
|
|
178
|
-
(variable === 'VEL' ? 'N0G' : variable)
|
|
179
|
-
: '';
|
|
180
|
-
const fetchL3Motion =
|
|
181
|
-
dataSource === 'level3' &&
|
|
182
|
-
l3Product === 'N0G' &&
|
|
183
|
-
variable === 'VEL' &&
|
|
184
|
-
level3StormRelative === true;
|
|
185
|
-
const fetchL2VelMotion =
|
|
186
|
-
dataSource === 'level2' && variable === 'VEL' && level3StormRelative === true;
|
|
187
|
-
|
|
188
|
-
const key =
|
|
189
|
-
dataSource === 'level3'
|
|
190
|
-
? `${stationId}_l3_${l3Product}_${elevNormUse}`
|
|
191
|
-
: `${stationId}_${group}_${elevNormUse}`;
|
|
192
|
-
const level3MotionKey =
|
|
193
|
-
dataSource === 'level3' && fetchL3Motion
|
|
194
|
-
? `${stationId}_l3_${NEXRAD_LEVEL3_MOTION_PRODUCT}_${elevNormUse}`
|
|
195
|
-
: '';
|
|
196
|
-
const l2StormMotionListKey =
|
|
197
|
-
dataSource === 'level2' && fetchL2VelMotion
|
|
198
|
-
? `${stationId}_l3_${NEXRAD_LEVEL3_MOTION_PRODUCT}_${NEXRAD_LEVEL3_ELEV}`
|
|
199
|
-
: '';
|
|
200
|
-
|
|
201
|
-
let times = [];
|
|
202
|
-
let timeToKeyMap = {};
|
|
203
|
-
let l3MotionUnixTimes = [];
|
|
204
|
-
let l3MotionTimeToKeyMap = {};
|
|
205
|
-
|
|
206
|
-
if (dataSource === 'level3') {
|
|
207
|
-
const l3Primary = await fetchLevel3ProductTimesForStation(stationId, l3Product, listingWindowHours);
|
|
208
|
-
times = l3Primary.unixTimes;
|
|
209
|
-
timeToKeyMap = l3Primary.timeToKeyMap;
|
|
210
|
-
if (fetchL3Motion) {
|
|
211
|
-
const l3Motion = await fetchLevel3ProductTimesForStation(
|
|
212
|
-
stationId,
|
|
213
|
-
NEXRAD_LEVEL3_MOTION_PRODUCT,
|
|
214
|
-
listingWindowHours,
|
|
215
|
-
);
|
|
216
|
-
l3MotionUnixTimes = l3Motion.unixTimes;
|
|
217
|
-
l3MotionTimeToKeyMap = l3Motion.timeToKeyMap;
|
|
218
|
-
}
|
|
219
|
-
} else {
|
|
220
|
-
const params = new URLSearchParams({
|
|
221
|
-
station: stationId,
|
|
222
|
-
field: group,
|
|
223
|
-
elev: elevNormUse,
|
|
224
|
-
hours: String(listingWindowHours),
|
|
225
|
-
});
|
|
226
|
-
const url = `${NEXRAD_SWEEP_LAMBDA_URL}?${params.toString()}`;
|
|
227
|
-
const res = await fetch(url);
|
|
228
|
-
if (!res.ok) throw new Error(`NEXRAD lambda HTTP ${res.status}`);
|
|
229
|
-
const data = await res.json();
|
|
230
|
-
if (Array.isArray(data?.times)) {
|
|
231
|
-
times = data.times;
|
|
232
|
-
} else if (data?.body) {
|
|
233
|
-
const body = typeof data.body === 'string' ? JSON.parse(data.body) : data.body;
|
|
234
|
-
times = Array.isArray(body?.times) ? body.times : [];
|
|
235
|
-
}
|
|
236
|
-
const groupId = nexradBinGroupIdForKey(group);
|
|
237
|
-
const elevNorm = elevNormUse;
|
|
238
|
-
times.forEach((t) => {
|
|
239
|
-
timeToKeyMap[String(t)] = `${stationId}_${elevNorm}_${t}_g${groupId}.bin`;
|
|
240
|
-
});
|
|
241
|
-
if (fetchL2VelMotion) {
|
|
242
|
-
const l3Motion = await fetchLevel3ProductTimesForStation(
|
|
243
|
-
stationId,
|
|
244
|
-
NEXRAD_LEVEL3_MOTION_PRODUCT,
|
|
245
|
-
listingWindowHours,
|
|
246
|
-
);
|
|
247
|
-
l3MotionUnixTimes = l3Motion.unixTimes;
|
|
248
|
-
l3MotionTimeToKeyMap = l3Motion.timeToKeyMap;
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
const out = {
|
|
253
|
-
cacheKey: key,
|
|
254
|
-
unixTimes: times,
|
|
255
|
-
timeToKeyMap,
|
|
256
|
-
level3MotionKey: level3MotionKey || null,
|
|
257
|
-
level3MotionUnixTimes: l3MotionUnixTimes,
|
|
258
|
-
level3MotionTimeToKeyMap: l3MotionTimeToKeyMap,
|
|
259
|
-
l2StormMotionListKey: l2StormMotionListKey || null,
|
|
260
|
-
};
|
|
261
|
-
return out;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
const L3_TILT_INDEX_MANIFEST_SLOTS = 6;
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* Raw manifest tilt list before coalescing (same rules as aguacero-frontend `radarTiltOptionsRaw`).
|
|
268
|
-
* Use {@link coalesceNexradTiltOptionsForDisplay} or {@link getAvailableNexradTilts} for UI controls.
|
|
269
|
-
*
|
|
270
|
-
* @param {string} siteId
|
|
271
|
-
* @param {'level2'|'level3'} nexradDataSource
|
|
272
|
-
* @param {string} [nexradProduct]
|
|
273
|
-
* @returns {number[]}
|
|
274
|
-
*/
|
|
275
|
-
export function getRawNexradTiltsForCoalesce(siteId, nexradDataSource, nexradProduct) {
|
|
276
|
-
if (!siteId) return [];
|
|
277
|
-
const v = (nexradProduct || 'REF').toUpperCase();
|
|
278
|
-
const ds = nexradDataSource === 'level3' ? 'level3' : 'level2';
|
|
279
|
-
const tilts = getRadarTilts(siteId, v);
|
|
280
|
-
/** L3 super-res VEL (N*G) uses the same first-N manifest slots as KDP/N0H — not the full L2 g2-only list. */
|
|
281
|
-
if (v === 'VEL') {
|
|
282
|
-
return tilts.slice(0, L3_TILT_INDEX_MANIFEST_SLOTS);
|
|
283
|
-
}
|
|
284
|
-
if (ds === 'level2') {
|
|
285
|
-
return [...tilts];
|
|
286
|
-
}
|
|
287
|
-
if (v === 'KDP' || v === 'N0H') {
|
|
288
|
-
return tilts.slice(0, L3_TILT_INDEX_MANIFEST_SLOTS);
|
|
289
|
-
}
|
|
290
|
-
return [];
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* Tilt angles for the UI: manifest tilts with adjacent 0.1° steps merged like aguacero-frontend
|
|
295
|
-
* ({@link coalesceNexradTiltOptionsForDisplay}).
|
|
296
|
-
*
|
|
297
|
-
* @param {string} siteId
|
|
298
|
-
* @param {'level2'|'level3'} nexradDataSource
|
|
299
|
-
* @param {string} [nexradProduct]
|
|
300
|
-
* @returns {number[]}
|
|
301
|
-
*/
|
|
302
|
-
export function getAvailableNexradTilts(siteId, nexradDataSource, nexradProduct) {
|
|
303
|
-
const raw = getRawNexradTiltsForCoalesce(siteId, nexradDataSource, nexradProduct);
|
|
304
|
-
if (!raw.length) return [];
|
|
305
|
-
return coalesceNexradTiltOptionsForDisplay(raw);
|
|
306
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* NEXRAD Level-II / Level-III time listings (aligned with aguacero-frontend nexradTimes.ts).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
formatTiltForApi,
|
|
7
|
+
clampNexradTiltForVariable,
|
|
8
|
+
getDefaultRadarTilt,
|
|
9
|
+
getRadarTilts,
|
|
10
|
+
} from './nexradTilts.js';
|
|
11
|
+
import { coalesceNexradTiltOptionsForDisplay } from './nexradTiltCoalesce.js';
|
|
12
|
+
import {
|
|
13
|
+
NEXRAD_LEVEL3_ELEV,
|
|
14
|
+
NEXRAD_LEVEL3_MOTION_PRODUCT,
|
|
15
|
+
getNexradLevel3EntryByRadarKey,
|
|
16
|
+
} from './nexrad_level3_catalog.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Whether listings and archive fetches for this product use Level-II sweep lambda vs Level-III S3 products.
|
|
20
|
+
* AguaceroCore derives this from the product key only (not user-configurable).
|
|
21
|
+
* @param {string} [nexradProduct] - Radar variable key (e.g. REF, KDP, VEL).
|
|
22
|
+
* @returns {'level2'|'level3'}
|
|
23
|
+
*/
|
|
24
|
+
export function inferNexradDataSourceForProduct(nexradProduct) {
|
|
25
|
+
const p = (nexradProduct || 'REF').toUpperCase();
|
|
26
|
+
return getNexradLevel3EntryByRadarKey(p) ? 'level3' : 'level2';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Colormap / DICTIONARIES.fld key for customColormaps (matches frontend userDefaultColormap). */
|
|
30
|
+
export function nexradColormapFldKey(nexradDataSource, nexradProduct) {
|
|
31
|
+
const p = (nexradProduct || 'REF').toUpperCase();
|
|
32
|
+
if (nexradDataSource === 'level3') {
|
|
33
|
+
const entry = getNexradLevel3EntryByRadarKey(p);
|
|
34
|
+
return entry?.fldKey ?? `nexrad_l3_${p.toLowerCase()}`;
|
|
35
|
+
}
|
|
36
|
+
const map = {
|
|
37
|
+
REF: 'nexrad_ref',
|
|
38
|
+
PHI: 'nexrad_phi',
|
|
39
|
+
ZDR: 'nexrad_zdr',
|
|
40
|
+
RHO: 'nexrad_rho',
|
|
41
|
+
KDP: 'nexrad_kdp',
|
|
42
|
+
VEL: 'nexrad_vel',
|
|
43
|
+
SW: 'nexrad_sw',
|
|
44
|
+
};
|
|
45
|
+
return map[p] ?? `nexrad_${p.toLowerCase()}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const NEXRAD_SWEEP_LAMBDA_URL = 'https://ddknicwcw2wyov7v5bzxmbqu440tzntf.lambda-url.us-east-2.on.aws/';
|
|
49
|
+
export const NEXRAD_LEVEL3_BASE_URL = 'https://unidata-nexrad-level3.s3.amazonaws.com';
|
|
50
|
+
|
|
51
|
+
const NEXRAD_GROUP_G1 = ['REF', 'PHI', 'ZDR', 'RHO'];
|
|
52
|
+
const NEXRAD_GROUP_G2 = ['VEL', 'SW'];
|
|
53
|
+
|
|
54
|
+
export function variableToNexradGroup(variable) {
|
|
55
|
+
const v = (variable || 'REF').toUpperCase();
|
|
56
|
+
if (NEXRAD_GROUP_G2.includes(v)) return 'g2';
|
|
57
|
+
return 'g1';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function nexradBinGroupIdForKey(cacheGroup) {
|
|
61
|
+
return cacheGroup === 'g1' ? 1 : 2;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getLevel3StationPrefix(stationId) {
|
|
65
|
+
const upper = (stationId || '').toUpperCase();
|
|
66
|
+
if (upper.startsWith('K') && upper.length === 4) return upper.slice(1);
|
|
67
|
+
return upper.slice(-3);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseS3KeyTimeToUnix(key) {
|
|
71
|
+
const m = key.match(/_(\d{4})_(\d{2})_(\d{2})_(\d{2})_(\d{2})_(\d{2})$/);
|
|
72
|
+
if (!m) return null;
|
|
73
|
+
const [, y, mo, d, h, mi, s] = m;
|
|
74
|
+
const unix = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s)) / 1000;
|
|
75
|
+
return Number.isFinite(unix) ? unix : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function fetchLevel3ProductTimesForStation(stationId, productCode, listWindowHours) {
|
|
79
|
+
const stationPrefix = getLevel3StationPrefix(stationId);
|
|
80
|
+
const nowMs = Date.now();
|
|
81
|
+
const nowSec = Math.floor(nowMs / 1000);
|
|
82
|
+
const hourSec = 3600;
|
|
83
|
+
const windowSec = Math.max(hourSec, Math.ceil(listWindowHours) * hourSec);
|
|
84
|
+
|
|
85
|
+
const dayPrefixForOffset = (dayOff) => {
|
|
86
|
+
const dt = new Date(nowMs - dayOff * 86400000);
|
|
87
|
+
const y = dt.getUTCFullYear();
|
|
88
|
+
const mo = String(dt.getUTCMonth() + 1).padStart(2, '0');
|
|
89
|
+
const day = String(dt.getUTCDate()).padStart(2, '0');
|
|
90
|
+
return `${stationPrefix}_${productCode}_${y}_${mo}_${day}_`;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const dayPrefixes = [dayPrefixForOffset(0), dayPrefixForOffset(1)];
|
|
94
|
+
const byUnix = new Map();
|
|
95
|
+
|
|
96
|
+
const listKeysForPrefix = async (prefix) => {
|
|
97
|
+
let continuationToken = null;
|
|
98
|
+
for (let page = 0; page < 8; page++) {
|
|
99
|
+
const params = new URLSearchParams({
|
|
100
|
+
'list-type': '2',
|
|
101
|
+
prefix,
|
|
102
|
+
'max-keys': '1000',
|
|
103
|
+
});
|
|
104
|
+
if (continuationToken) params.set('continuation-token', continuationToken);
|
|
105
|
+
const res = await fetch(`${NEXRAD_LEVEL3_BASE_URL}/?${params.toString()}`);
|
|
106
|
+
if (!res.ok) throw new Error(`Level3 list HTTP ${res.status}`);
|
|
107
|
+
const xml = await res.text();
|
|
108
|
+
const keyMatches = [...xml.matchAll(/<Key>([^<]+)<\/Key>/g)];
|
|
109
|
+
keyMatches.forEach((match) => {
|
|
110
|
+
const objectKey = match[1];
|
|
111
|
+
const unix = parseS3KeyTimeToUnix(objectKey);
|
|
112
|
+
if (unix == null) return;
|
|
113
|
+
byUnix.set(unix, objectKey);
|
|
114
|
+
});
|
|
115
|
+
const tokenMatch = xml.match(/<NextContinuationToken>([^<]+)<\/NextContinuationToken>/);
|
|
116
|
+
continuationToken = tokenMatch?.[1] || null;
|
|
117
|
+
if (!continuationToken) break;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
for (const prefix of dayPrefixes) {
|
|
122
|
+
await listKeysForPrefix(prefix);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const sorted = [...byUnix.keys()].sort((a, b) => a - b);
|
|
126
|
+
const windowStart = nowSec - windowSec;
|
|
127
|
+
let inWindow = sorted.filter((t) => t >= windowStart);
|
|
128
|
+
if (inWindow.length === 0 && sorted.length > 0) {
|
|
129
|
+
inWindow = sorted.filter((t) => t >= nowSec - Math.max(6 * hourSec, windowSec));
|
|
130
|
+
}
|
|
131
|
+
if (inWindow.length === 0 && sorted.length > 0) {
|
|
132
|
+
inWindow = sorted.slice(-48);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const timeToKeyMap = {};
|
|
136
|
+
inWindow.forEach((t) => {
|
|
137
|
+
const k = byUnix.get(t);
|
|
138
|
+
if (k) timeToKeyMap[String(t)] = k;
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return { unixTimes: inWindow, timeToKeyMap };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @param {object} opts
|
|
146
|
+
* @param {string} opts.stationId
|
|
147
|
+
* @param {string} [opts.variable]
|
|
148
|
+
* @param {string} [opts.elev]
|
|
149
|
+
* @param {'level2'|'level3'} [opts.source]
|
|
150
|
+
* @param {boolean} [opts.level3StormRelative]
|
|
151
|
+
* @param {number} opts.listingWindowHours
|
|
152
|
+
*/
|
|
153
|
+
export async function fetchNexradTimesListing(opts) {
|
|
154
|
+
const {
|
|
155
|
+
stationId,
|
|
156
|
+
variable = 'REF',
|
|
157
|
+
elev,
|
|
158
|
+
source = 'level2',
|
|
159
|
+
level3StormRelative,
|
|
160
|
+
listingWindowHours,
|
|
161
|
+
} = opts;
|
|
162
|
+
if (!stationId) {
|
|
163
|
+
return { unixTimes: [], timeToKeyMap: {}, level3MotionUnixTimes: [], level3MotionTimeToKeyMap: {} };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const dataSource = source === 'level3' ? 'level3' : 'level2';
|
|
167
|
+
let tiltNum = Number(elev);
|
|
168
|
+
if (!Number.isFinite(tiltNum)) tiltNum = getDefaultRadarTilt(stationId);
|
|
169
|
+
const elevNormUse =
|
|
170
|
+
dataSource === 'level3'
|
|
171
|
+
? NEXRAD_LEVEL3_ELEV
|
|
172
|
+
: formatTiltForApi(clampNexradTiltForVariable(stationId, variable, tiltNum));
|
|
173
|
+
const group = dataSource === 'level3' ? 'l3' : variableToNexradGroup(variable);
|
|
174
|
+
const l3Product =
|
|
175
|
+
dataSource === 'level3'
|
|
176
|
+
? opts.level3Product ??
|
|
177
|
+
getNexradLevel3EntryByRadarKey(variable)?.product ??
|
|
178
|
+
(variable === 'VEL' ? 'N0G' : variable)
|
|
179
|
+
: '';
|
|
180
|
+
const fetchL3Motion =
|
|
181
|
+
dataSource === 'level3' &&
|
|
182
|
+
l3Product === 'N0G' &&
|
|
183
|
+
variable === 'VEL' &&
|
|
184
|
+
level3StormRelative === true;
|
|
185
|
+
const fetchL2VelMotion =
|
|
186
|
+
dataSource === 'level2' && variable === 'VEL' && level3StormRelative === true;
|
|
187
|
+
|
|
188
|
+
const key =
|
|
189
|
+
dataSource === 'level3'
|
|
190
|
+
? `${stationId}_l3_${l3Product}_${elevNormUse}`
|
|
191
|
+
: `${stationId}_${group}_${elevNormUse}`;
|
|
192
|
+
const level3MotionKey =
|
|
193
|
+
dataSource === 'level3' && fetchL3Motion
|
|
194
|
+
? `${stationId}_l3_${NEXRAD_LEVEL3_MOTION_PRODUCT}_${elevNormUse}`
|
|
195
|
+
: '';
|
|
196
|
+
const l2StormMotionListKey =
|
|
197
|
+
dataSource === 'level2' && fetchL2VelMotion
|
|
198
|
+
? `${stationId}_l3_${NEXRAD_LEVEL3_MOTION_PRODUCT}_${NEXRAD_LEVEL3_ELEV}`
|
|
199
|
+
: '';
|
|
200
|
+
|
|
201
|
+
let times = [];
|
|
202
|
+
let timeToKeyMap = {};
|
|
203
|
+
let l3MotionUnixTimes = [];
|
|
204
|
+
let l3MotionTimeToKeyMap = {};
|
|
205
|
+
|
|
206
|
+
if (dataSource === 'level3') {
|
|
207
|
+
const l3Primary = await fetchLevel3ProductTimesForStation(stationId, l3Product, listingWindowHours);
|
|
208
|
+
times = l3Primary.unixTimes;
|
|
209
|
+
timeToKeyMap = l3Primary.timeToKeyMap;
|
|
210
|
+
if (fetchL3Motion) {
|
|
211
|
+
const l3Motion = await fetchLevel3ProductTimesForStation(
|
|
212
|
+
stationId,
|
|
213
|
+
NEXRAD_LEVEL3_MOTION_PRODUCT,
|
|
214
|
+
listingWindowHours,
|
|
215
|
+
);
|
|
216
|
+
l3MotionUnixTimes = l3Motion.unixTimes;
|
|
217
|
+
l3MotionTimeToKeyMap = l3Motion.timeToKeyMap;
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
const params = new URLSearchParams({
|
|
221
|
+
station: stationId,
|
|
222
|
+
field: group,
|
|
223
|
+
elev: elevNormUse,
|
|
224
|
+
hours: String(listingWindowHours),
|
|
225
|
+
});
|
|
226
|
+
const url = `${NEXRAD_SWEEP_LAMBDA_URL}?${params.toString()}`;
|
|
227
|
+
const res = await fetch(url);
|
|
228
|
+
if (!res.ok) throw new Error(`NEXRAD lambda HTTP ${res.status}`);
|
|
229
|
+
const data = await res.json();
|
|
230
|
+
if (Array.isArray(data?.times)) {
|
|
231
|
+
times = data.times;
|
|
232
|
+
} else if (data?.body) {
|
|
233
|
+
const body = typeof data.body === 'string' ? JSON.parse(data.body) : data.body;
|
|
234
|
+
times = Array.isArray(body?.times) ? body.times : [];
|
|
235
|
+
}
|
|
236
|
+
const groupId = nexradBinGroupIdForKey(group);
|
|
237
|
+
const elevNorm = elevNormUse;
|
|
238
|
+
times.forEach((t) => {
|
|
239
|
+
timeToKeyMap[String(t)] = `${stationId}_${elevNorm}_${t}_g${groupId}.bin`;
|
|
240
|
+
});
|
|
241
|
+
if (fetchL2VelMotion) {
|
|
242
|
+
const l3Motion = await fetchLevel3ProductTimesForStation(
|
|
243
|
+
stationId,
|
|
244
|
+
NEXRAD_LEVEL3_MOTION_PRODUCT,
|
|
245
|
+
listingWindowHours,
|
|
246
|
+
);
|
|
247
|
+
l3MotionUnixTimes = l3Motion.unixTimes;
|
|
248
|
+
l3MotionTimeToKeyMap = l3Motion.timeToKeyMap;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const out = {
|
|
253
|
+
cacheKey: key,
|
|
254
|
+
unixTimes: times,
|
|
255
|
+
timeToKeyMap,
|
|
256
|
+
level3MotionKey: level3MotionKey || null,
|
|
257
|
+
level3MotionUnixTimes: l3MotionUnixTimes,
|
|
258
|
+
level3MotionTimeToKeyMap: l3MotionTimeToKeyMap,
|
|
259
|
+
l2StormMotionListKey: l2StormMotionListKey || null,
|
|
260
|
+
};
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const L3_TILT_INDEX_MANIFEST_SLOTS = 6;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Raw manifest tilt list before coalescing (same rules as aguacero-frontend `radarTiltOptionsRaw`).
|
|
268
|
+
* Use {@link coalesceNexradTiltOptionsForDisplay} or {@link getAvailableNexradTilts} for UI controls.
|
|
269
|
+
*
|
|
270
|
+
* @param {string} siteId
|
|
271
|
+
* @param {'level2'|'level3'} nexradDataSource
|
|
272
|
+
* @param {string} [nexradProduct]
|
|
273
|
+
* @returns {number[]}
|
|
274
|
+
*/
|
|
275
|
+
export function getRawNexradTiltsForCoalesce(siteId, nexradDataSource, nexradProduct) {
|
|
276
|
+
if (!siteId) return [];
|
|
277
|
+
const v = (nexradProduct || 'REF').toUpperCase();
|
|
278
|
+
const ds = nexradDataSource === 'level3' ? 'level3' : 'level2';
|
|
279
|
+
const tilts = getRadarTilts(siteId, v);
|
|
280
|
+
/** L3 super-res VEL (N*G) uses the same first-N manifest slots as KDP/N0H — not the full L2 g2-only list. */
|
|
281
|
+
if (v === 'VEL') {
|
|
282
|
+
return tilts.slice(0, L3_TILT_INDEX_MANIFEST_SLOTS);
|
|
283
|
+
}
|
|
284
|
+
if (ds === 'level2') {
|
|
285
|
+
return [...tilts];
|
|
286
|
+
}
|
|
287
|
+
if (v === 'KDP' || v === 'N0H') {
|
|
288
|
+
return tilts.slice(0, L3_TILT_INDEX_MANIFEST_SLOTS);
|
|
289
|
+
}
|
|
290
|
+
return [];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Tilt angles for the UI: manifest tilts with adjacent 0.1° steps merged like aguacero-frontend
|
|
295
|
+
* ({@link coalesceNexradTiltOptionsForDisplay}).
|
|
296
|
+
*
|
|
297
|
+
* @param {string} siteId
|
|
298
|
+
* @param {'level2'|'level3'} nexradDataSource
|
|
299
|
+
* @param {string} [nexradProduct]
|
|
300
|
+
* @returns {number[]}
|
|
301
|
+
*/
|
|
302
|
+
export function getAvailableNexradTilts(siteId, nexradDataSource, nexradProduct) {
|
|
303
|
+
const raw = getRawNexradTiltsForCoalesce(siteId, nexradDataSource, nexradProduct);
|
|
304
|
+
if (!raw.length) return [];
|
|
305
|
+
return coalesceNexradTiltOptionsForDisplay(raw);
|
|
306
|
+
}
|