@aguacerowx/mapsgl 0.0.57 → 0.0.59

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.
@@ -1,1974 +1,1987 @@
1
- /* eslint-disable */
2
- // Vendored from aguacero-frontend RadarLayer.tsx — fetch/decode only (no React).
3
- import { Buffer } from 'buffer';
4
- import * as bzip from 'seek-bzip';
5
- import {
6
- getNexradLevel3EntryByRadarKey,
7
- level3EetHeightKmFromLevel,
8
- level3ValuePacking,
9
- nexradLevel3IsHydrometeorClassification,
10
- nexradLevel3UsesTiltIndexedS3Products,
11
- nexradLevel3S3ProductForSiteTilt,
12
- type Level3DecodeMode,
13
- } from './nexradLevel3Products.js';
14
- import {
15
- applyLevel3StormRelativeToFrame,
16
- parseLevel3StormMotionFromBuffer,
17
- pickNearestLevel3ObjectKey,
18
- } from './level3StormRelative.js';
19
- import type { NexradSite } from './PreprocessedSweepParser.js';
20
- import { archiveCache, setArchiveCache, type DecodedRadarFrame } from './nexradArchiveCache.js';
21
- import { loadNexradSites } from './loadNexradSites.js';
22
- export { setNexradSitesJsonUrl, setNexradSitesFetchAuth } from './loadNexradSites.js';
23
- import { sampleNexradFrameAtLatLon } from './nexradCrossSectionSampleAtLatLon.js';
24
- import { decodeRadarSlotMessage, type DecodeSlotRequest } from './radarDecodeSlot.js';
25
- import { nexradArchiveDiag, redactApiKeyFromUrl } from './nexradArchiveDiag.js';
26
-
27
- // seek-bzip pulls in node-bzip paths that use `new Buffer()` as a global; browsers have no Buffer.
28
- if (typeof (globalThis as { Buffer?: typeof Buffer }).Buffer === 'undefined') {
29
- (globalThis as { Buffer: typeof Buffer }).Buffer = Buffer;
30
- }
31
-
32
- /** RN/Hermes has no global {@link DOMException}; keep AbortError semantics for fetch abort paths. */
33
- function createAbortError(message = 'Aborted'): Error {
34
- const g = globalThis as typeof globalThis & { DOMException?: typeof DOMException };
35
- if (typeof g.DOMException !== 'undefined') {
36
- return new g.DOMException(message, 'AbortError');
37
- }
38
- const err = new Error(message);
39
- err.name = 'AbortError';
40
- return err;
41
- }
42
-
43
- function isAbortError(err: unknown): boolean {
44
- if (err == null || typeof err !== 'object') return false;
45
- return (err as Error).name === 'AbortError';
46
- }
47
-
48
- let NEXRAD_ARCHIVE_API_KEY = '';
49
- /** Same as {@link AguaceroCore} grid fetches: `x-app-identifier` on React Native when set. */
50
- let NEXRAD_ARCHIVE_BUNDLE_ID = '';
51
-
52
- export function setNexradArchiveApiKey(k: string) {
53
- NEXRAD_ARCHIVE_API_KEY = k || '';
54
- }
55
-
56
- export function setNexradArchiveBundleId(bundleId: string) {
57
- NEXRAD_ARCHIVE_BUNDLE_ID = bundleId || '';
58
- }
59
-
60
- /** Match {@link AguaceroCore} `urlWithApiKeyParam` for the same CloudFront distribution. */
61
- function cloudFrontUrlWithApiKeyQuery(baseUrl: string): string {
62
- if (!NEXRAD_ARCHIVE_API_KEY) return baseUrl;
63
- const sep = baseUrl.includes('?') ? '&' : '?';
64
- return `${baseUrl}${sep}apiKey=${NEXRAD_ARCHIVE_API_KEY}`;
65
- }
66
-
67
- /** Level-II: match AguaceroCore grid `fetch` headers (x-api-key, x-app-identifier on RN when bundleId set) plus Range. */
68
- function level2CloudFrontFetchHeaders(range: string | undefined): Record<string, string> {
69
- const headers: Record<string, string> = {
70
- 'x-api-key': NEXRAD_ARCHIVE_API_KEY,
71
- };
72
- if (range !== undefined) {
73
- headers['Range'] = range;
74
- }
75
- const g = globalThis as typeof globalThis & { navigator?: { product?: string } };
76
- if (g.navigator?.product === 'ReactNative' && NEXRAD_ARCHIVE_BUNDLE_ID) {
77
- headers['x-app-identifier'] = NEXRAD_ARCHIVE_BUNDLE_ID;
78
- }
79
- return headers;
80
- }
81
-
82
- type RadarDecodeWorkerResponse = {
83
- type: 'DECODE_RESULT';
84
- requestId: number;
85
- gateData: Uint8Array | null;
86
- nRays?: number;
87
- nGates?: number;
88
- stationLat?: number;
89
- stationLon?: number;
90
- firstGateKm?: number;
91
- gateWidthKm?: number;
92
- valueScale?: number;
93
- valueOffset?: number;
94
- rayBoundariesDeg?: Float32Array;
95
- error?: string;
96
- };
97
-
98
- function shouldDecodeRadarOnMainThread(): boolean {
99
- const g = globalThis as typeof globalThis & { navigator?: { product?: string } };
100
- return typeof Worker === 'undefined' || g.navigator?.product === 'ReactNative';
101
- }
102
-
103
- /** Hermes/RN has no web Workers; mirror Worker.postMessage/onmessage on the JS thread. */
104
- class MainThreadRadarDecodeWorker implements Pick<Worker, 'postMessage' | 'onmessage' | 'onerror' | 'terminate'> {
105
- onmessage: ((ev: MessageEvent<RadarDecodeWorkerResponse>) => void) | null = null;
106
- onerror: ((ev: ErrorEvent) => void) | null = null;
107
-
108
- postMessage(data: unknown): void {
109
- const msg = data as DecodeSlotRequest;
110
- if (!msg || msg.type !== 'DECODE_SLOT') return;
111
- queueMicrotask(() => {
112
- try {
113
- const response = decodeRadarSlotMessage(msg);
114
- this.onmessage?.({ data: response } as MessageEvent<RadarDecodeWorkerResponse>);
115
- } catch (error) {
116
- this.onmessage?.({
117
- data: {
118
- type: 'DECODE_RESULT',
119
- requestId: msg.requestId,
120
- gateData: null,
121
- error: error instanceof Error ? error.message : 'Main-thread radar decode failed',
122
- },
123
- } as MessageEvent<RadarDecodeWorkerResponse>);
124
- }
125
- });
126
- }
127
-
128
- terminate(): void {}
129
- }
130
-
131
- function createRadarDecodeWorker(): Worker {
132
- if (shouldDecodeRadarOnMainThread()) {
133
- return new MainThreadRadarDecodeWorker() as unknown as Worker;
134
- }
135
- return new Worker(new URL('./radarDecode.worker.bundled.js', import.meta.url), { type: 'module' });
136
- }
137
-
138
- const LEVEL2_BASE_URL = 'https://d3dc62msmxkrd7.cloudfront.net/level-2';
139
- const LEVEL3_BASE_URL = 'https://unidata-nexrad-level3.s3.amazonaws.com';
140
-
141
- const PREFETCH_DELAY_MS = 0;
142
- const PREFETCH_CONCURRENCY = 6;
143
-
144
- /** Underscore-style throttle for no-arg work — coalesces bursts (slider scrub) without delaying the first call. */
145
- function throttleVoid(fn: () => void, waitMs: number): () => void {
146
- let timeout: ReturnType<typeof setTimeout> | null = null;
147
- let previous = 0;
148
- return () => {
149
- const now = Date.now();
150
- const remaining = waitMs - (now - previous);
151
- if (remaining <= 0 || remaining > waitMs) {
152
- if (timeout) {
153
- clearTimeout(timeout);
154
- timeout = null;
155
- }
156
- previous = now;
157
- fn();
158
- } else if (!timeout) {
159
- timeout = setTimeout(() => {
160
- timeout = null;
161
- previous = Date.now();
162
- fn();
163
- }, remaining);
164
- }
165
- };
166
- }
167
-
168
- /**
169
- * Level-III KDP/N0H: throttle runSlot so rapid slider motion coalesces **fetches** (~10/s max) while a trailing
170
- * edge still applies the final time. MapboxRadarLayer caches the polar mesh on layout + coarse ray-boundary
171
- * fingerprint so scrubbing is fast while sweep registration stays geographic.
172
- */
173
- function createLatestPayloadThrottle(waitMs: number): (run: () => void) => void {
174
- let latest: (() => void) | null = null;
175
- const runLatest = () => {
176
- const fn = latest;
177
- latest = null;
178
- fn?.();
179
- };
180
- const throttled = throttleVoid(runLatest, waitMs);
181
- return (run: () => void) => {
182
- latest = run;
183
- throttled();
184
- };
185
- }
186
-
187
- const azimuthKeyedRunSlotThrottleBySlot = new Map<string, ReturnType<typeof createLatestPayloadThrottle>>();
188
-
189
- function getAzimuthKeyedRunSlotThrottle(slotLayerId: string): (run: () => void) => void {
190
- let t = azimuthKeyedRunSlotThrottleBySlot.get(slotLayerId);
191
- if (!t) {
192
- t = createLatestPayloadThrottle(110);
193
- azimuthKeyedRunSlotThrottleBySlot.set(slotLayerId, t);
194
- }
195
- return t;
196
- }
197
- const DECODE_WORKER_POOL_SIZE = 2;
198
- const PREFETCH_WORKER_START_INDEX = 1;
199
-
200
- // ─── Fetch / parse with deduplication ────────────────────────────────────────
201
-
202
- // Tracks in-flight fetches so concurrent calls for the same URL share one request.
203
- const inflightFetches = new Map<string, Promise<DecodedRadarFrame | null>>();
204
- const inflightFetchMeta = new Map<
205
- string,
206
- {
207
- requestId: number;
208
- startedAt: number;
209
- callers: number;
210
- objectKey: string;
211
- radarVariable: string;
212
- radarSource: 'level2' | 'level3';
213
- priority: 'display' | 'prefetch';
214
- }
215
- >();
216
- let radarFetchRequestSeq = 0;
217
-
218
- let radarDecodeWorkers: Worker[] = [];
219
- let radarDecodeRequestId = 0;
220
- const radarDecodePending = new Map<number, {
221
- resolve: (frame: DecodedRadarFrame | null) => void;
222
- reject: (error: Error) => void;
223
- }>();
224
- const radarDecodeRequestMeta = new Map<number, { objectKey: string; workerIndex: number }>();
225
- let radarDecodePrefetchWorkerIndex = PREFETCH_WORKER_START_INDEX;
226
-
227
- function createDecodeWorker(workerIndex: number): Worker {
228
- const worker = createRadarDecodeWorker();
229
- worker.onmessage = (event: MessageEvent<RadarDecodeWorkerResponse>) => {
230
- const message = event.data;
231
- if (!message || message.type !== 'DECODE_RESULT') return;
232
- const pending = radarDecodePending.get(message.requestId);
233
- if (!pending) return;
234
- radarDecodePending.delete(message.requestId);
235
- radarDecodeRequestMeta.delete(message.requestId);
236
- if (message.error) {
237
- pending.reject(new Error(message.error));
238
- return;
239
- }
240
- if (
241
- !message.gateData
242
- || typeof message.nRays !== 'number'
243
- || typeof message.nGates !== 'number'
244
- || typeof message.stationLat !== 'number'
245
- || typeof message.stationLon !== 'number'
246
- || typeof message.firstGateKm !== 'number'
247
- || typeof message.gateWidthKm !== 'number'
248
- || typeof message.valueScale !== 'number'
249
- || typeof message.valueOffset !== 'number'
250
- || !(message.rayBoundariesDeg instanceof Float32Array)
251
- ) {
252
- pending.resolve(null);
253
- return;
254
- }
255
- pending.resolve({
256
- gateData: message.gateData,
257
- nRays: message.nRays,
258
- nGates: message.nGates,
259
- stationLat: message.stationLat,
260
- stationLon: message.stationLon,
261
- firstGateKm: message.firstGateKm,
262
- gateWidthKm: message.gateWidthKm,
263
- valueScale: message.valueScale,
264
- valueOffset: message.valueOffset,
265
- rayBoundariesDeg: message.rayBoundariesDeg,
266
- });
267
- };
268
- worker.onerror = (error) => {
269
- radarDecodePending.forEach(({ reject }) => reject(new Error(error.message || 'Radar decode worker failed')));
270
- radarDecodePending.clear();
271
- radarDecodeRequestMeta.clear();
272
- };
273
- return worker;
274
- }
275
-
276
- function getRadarDecodeWorkers(): Worker[] {
277
- if (radarDecodeWorkers.length > 0) return radarDecodeWorkers;
278
- radarDecodeWorkers = Array.from({ length: DECODE_WORKER_POOL_SIZE }, (_, idx) => createDecodeWorker(idx));
279
- return radarDecodeWorkers;
280
- }
281
-
282
- function getDecodeWorkerForPriority(priority: 'display' | 'prefetch'): { worker: Worker; workerIndex: number } {
283
- const workers = getRadarDecodeWorkers();
284
- if (priority === 'display' || workers.length === 1) {
285
- return { worker: workers[0], workerIndex: 0 };
286
- }
287
- const prefetchWorkerCount = Math.max(workers.length - PREFETCH_WORKER_START_INDEX, 1);
288
- const offset = (radarDecodePrefetchWorkerIndex - PREFETCH_WORKER_START_INDEX + prefetchWorkerCount) % prefetchWorkerCount;
289
- const workerIndex = PREFETCH_WORKER_START_INDEX + offset;
290
- radarDecodePrefetchWorkerIndex = PREFETCH_WORKER_START_INDEX + ((offset + 1) % prefetchWorkerCount);
291
- return { worker: workers[Math.min(workerIndex, workers.length - 1)], workerIndex: Math.min(workerIndex, workers.length - 1) };
292
- }
293
-
294
- /** Get physical value at lat/lon from a decoded radar frame. Returns null if outside coverage or no data. */
295
- function getSampleAtLatLonForRadarFrame(
296
- frame: DecodedRadarFrame,
297
- lat: number,
298
- lon: number,
299
- smoothPolar: boolean,
300
- ): { value: number; groundRangeKm: number } | null {
301
- return sampleNexradFrameAtLatLon(frame, lat, lon, { smoothPolar });
302
- }
303
-
304
- export function objectKeyToUrl(objectKey: string, radarSource: 'level2' | 'level3' = 'level2'): string {
305
- if (objectKey.startsWith('http')) return objectKey;
306
- const baseUrl = radarSource === 'level3' ? LEVEL3_BASE_URL : LEVEL2_BASE_URL;
307
- return `${baseUrl.replace(/\/$/, '')}/${objectKey}`;
308
- }
309
-
310
- class Level3Raf {
311
- private offset = 0;
312
- private view: DataView;
313
- private bytes: Uint8Array;
314
-
315
- constructor(input: Uint8Array | ArrayBuffer) {
316
- this.bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
317
- this.view = new DataView(this.bytes.buffer, this.bytes.byteOffset, this.bytes.byteLength);
318
- }
319
-
320
- getPos(): number { return this.offset; }
321
- getLength(): number { return this.bytes.length; }
322
- seek(pos: number): void { this.offset = pos; }
323
- skip(delta: number): void { this.offset += delta; }
324
- read(bytes = 1): Uint8Array {
325
- const out = this.bytes.subarray(this.offset, this.offset + bytes);
326
- this.offset += bytes;
327
- return out;
328
- }
329
- readByte(): number {
330
- const v = this.view.getUint8(this.offset);
331
- this.offset += 1;
332
- return v;
333
- }
334
- readShort(): number {
335
- const v = this.view.getInt16(this.offset, false);
336
- this.offset += 2;
337
- return v;
338
- }
339
- readUShort(): number {
340
- const v = this.view.getUint16(this.offset, false);
341
- this.offset += 2;
342
- return v;
343
- }
344
- readInt(): number {
345
- const v = this.view.getInt32(this.offset, false);
346
- this.offset += 4;
347
- return v;
348
- }
349
- readUInt32(): number {
350
- const v = this.view.getUint32(this.offset, false);
351
- this.offset += 4;
352
- return v;
353
- }
354
- readString(bytes: number): string {
355
- const slice = this.read(bytes);
356
- return String.fromCharCode(...slice);
357
- }
358
- }
359
-
360
- function concatUint8(a: Uint8Array, b: Uint8Array): Uint8Array {
361
- const out = new Uint8Array(a.length + b.length);
362
- out.set(a, 0);
363
- out.set(b, a.length);
364
- return out;
365
- }
366
-
367
- /**
368
- * NEXRAD Level-III: range to gate g (m) = g * range_scale_symbology * multiplier[message_code] + first_bin (m).
369
- * Without multipliers, super-res products (e.g. message 153 reflectivity, 154 velocity) decode ~4× too coarse in range.
370
- * Aligned with Py-ART / jjhelmus `PRODUCT_RANGE_RESOLUTION`.
371
- */
372
- const LEVEL3_PRODUCT_RANGE_SCALE_MUL: Record<number, number> = {
373
- 19: 1, 20: 2, 25: 0.25, 27: 1, 28: 0.25, 30: 1, 32: 1, 34: 1, 56: 1,
374
- 57: 1000,
375
- 78: 1, 79: 1, 80: 1, 94: 1, 99: 0.25,
376
- 134: 1000, 135: 1000, 138: 1,
377
- 153: 0.25, 154: 0.25, 155: 0.25, 159: 0.25, 161: 0.25, 163: 0.25, 165: 0.25,
378
- 166: 0.25, 167: 0.25, 168: 0.25,
379
- 169: 1000, 170: 1000, 171: 1000, 172: 1000, 173: 1000, 174: 1000, 175: 1000,
380
- /** Py-ART `PRODUCT_RANGE_RESOLUTION`: 176/177 are digital-style radials (0.25×), not 1 km like 169–175. */
381
- 176: 0.25, 177: 0.25,
382
- 181: 150, 182: 150, 186: 300,
383
- };
384
-
385
- /**
386
- * MetPy `GenericDigitalMapper` products: symbology levels map as (L - offset) / scale
387
- * where scale/offset are big-endian float32 from PDB thr1–thr2 and thr3–thr4 (see MetPy `nexrad.float32`).
388
- * DAA (170) was incorrectly decoded as min + L*inc, producing bogus inches and fan artifacts.
389
- */
390
- const LEVEL3_GENERIC_DIGITAL_PRODUCT_CODES = new Set([
391
- 159, 161, 163, 167, 168,
392
- 170, 172, 173, 174, 175, 176,
393
- ]);
394
-
395
- /**
396
- * NWS digital accumulation (MetPy `GenericDigitalMapper`) carries **depth in millimeters**.
397
- * Layer colormap / readout expect **inches** (see `nexradLevel3Products` precip packing).
398
- */
399
- const LEVEL3_GENERIC_DIGITAL_DEPTH_MM_TO_IN_PRODUCTS = new Set([170, 172, 173, 174, 175]);
400
-
401
- /**
402
- * MetPy `DigitalHMCMapper`: digital / hybrid hydrometeor classification (165, 177).
403
- * 8-bit radial (packet 0x0010): `lut[i] = i // 10` is the **1-based** NWS category (1=BI … 6=RA … 12=GH).
404
- * Fill colormap / `NEXRAD_HYDROMETEOR_CLASS_LABELS` use **0-based** indices in the same order → `floor(level/10) - 1`.
405
- * 150 = range-fold.
406
- * 4-bit raster / RLE (0xBA07, 0xAF1F): levels 2–15 → 0-based class index `level − 2`.
407
- */
408
- const LEVEL3_DIGITAL_HMC_PRODUCT_CODES = new Set([165, 177]);
409
-
410
- function level3PhysicalFromDigitalHmc(level: number, precision: 'byte' | 'nibble'): number | null {
411
- if (!Number.isFinite(level)) return null;
412
- if (precision === 'nibble') {
413
- if (level < 2 || level > 15) return null;
414
- return level - 2;
415
- }
416
- if (level === 150) return null;
417
- if (level < 10 || level >= 256) return null;
418
- const cls = Math.floor(level / 10) - 1;
419
- if (cls < 0 || cls > 11) return null;
420
- return cls;
421
- }
422
-
423
- /**
424
- * Per-product mm calibration before ÷25.4 for generic digital accumulation (170, 172–175).
425
- * 170 (DAA) field‑checked at 0.125. 172–175 were too low at 0.125, too high at 1.0; 0.5 still ~2× high
426
- * in the field → 0.25 for those products (halve again if needed).
427
- */
428
- function level3GenericDigitalAccumMmFactor(productCode: number): number {
429
- switch (productCode) {
430
- case 170: // DAA — 1‑hour digital accumulation array
431
- return 0.125;
432
- case 172: // Digital storm total accumulation (generic)
433
- case 173: // DU3 — user‑selectable (e.g. 3‑hour)
434
- case 174: // Digital one‑hour difference accumulation
435
- case 175: // Digital storm total difference accumulation
436
- return 0.25;
437
- default:
438
- return 1;
439
- }
440
- }
441
-
442
- type Level3GenericDigitalParams = {
443
- scale: number;
444
- offset: number;
445
- leadingFlags: number;
446
- trailingFlags: number;
447
- maxDataVal: number;
448
- };
449
-
450
- function level3UnpackFloat32FromThresholdPair(short0: number, short1: number): number {
451
- const buf = new ArrayBuffer(4);
452
- const view = new DataView(buf);
453
- view.setUint16(0, short0 & 0xffff, false);
454
- view.setUint16(2, short1 & 0xffff, false);
455
- return view.getFloat32(0, false);
456
- }
457
-
458
- function parseLevel3GenericDigitalParams(dep: number[]): Level3GenericDigitalParams | null {
459
- /**
460
- * `raf.read(48)` is read **after** PDB fields through `el_num`, so this 24-short block is:
461
- * dep[0]=dep3, dep[1]=thr1, dep[2]=thr2, … dep[16]=thr16, dep[17]=dep4, …
462
- * MetPy GenericDigitalMapper: scale=float32(thr1,thr2), offset=float32(thr3,thr4),
463
- * max=thr6, leading=thr7, trailing=thr8 → dep indices 1..8.
464
- */
465
- if (dep.length < 9) return null;
466
- const scale = level3UnpackFloat32FromThresholdPair(dep[1], dep[2]);
467
- const offset = level3UnpackFloat32FromThresholdPair(dep[3], dep[4]);
468
- const maxDataVal = dep[6] & 0xffff;
469
- const leadingFlags = dep[7];
470
- const trailingFlags = dep[8];
471
- if (!Number.isFinite(scale) || scale === 0 || !Number.isFinite(offset)) return null;
472
- if (leadingFlags < 0 || leadingFlags > 255 || trailingFlags < 0 || trailingFlags > 255) return null;
473
- if (maxDataVal < leadingFlags) return null;
474
- return { scale, offset, leadingFlags, trailingFlags, maxDataVal };
475
- }
476
-
477
- function level3PhysicalFromGenericDigital(
478
- level: number,
479
- p: Level3GenericDigitalParams,
480
- productCode: number,
481
- ): number | null {
482
- if (!Number.isFinite(level)) return null;
483
- if (level < p.leadingFlags) return null;
484
- const upper = p.maxDataVal - p.trailingFlags;
485
- if (level > upper) return null;
486
- let out = (level - p.offset) / p.scale;
487
- if (!Number.isFinite(out)) return null;
488
- if (LEVEL3_GENERIC_DIGITAL_DEPTH_MM_TO_IN_PRODUCTS.has(productCode)) {
489
- out *= level3GenericDigitalAccumMmFactor(productCode);
490
- out /= 25.4;
491
- }
492
- return out;
493
- }
494
-
495
- type Level3SymbologyMode =
496
- | 'reflectivity'
497
- /**
498
- * MetPy `DigitalMapper` linear products (precip inches, etc.): phys = min + (L - 2) * inc.
499
- * Kept separate from `reflectivity` so legacy/category-style symbology is unchanged.
500
- */
501
- | 'standard_digital'
502
- /** Packet 0x0010: full-byte data levels; NEXRAD digital velocity uses threshold + (L-2)*inc. */
503
- | 'digital_velocity_byte'
504
- /**
505
- * Packet 0xAF1F: 4-bit levels in RLE nibbles. Threshold+increment from the PDB only spans a
506
- * small negative band (~−64…−57 m/s), so values fall entirely outside typical −30…+30 m/s
507
- * colormaps and the map looks empty. Use a centered mapping (0 m/s ≈ level 8, ~4 m/s per step).
508
- */
509
- | 'digital_velocity_rle4';
510
-
511
- /**
512
- * Map symbology data level to a physical value (dBZ, m/s, etc.) depending on product/packet mode.
513
- */
514
- const LEVEL3_MSG134_PRODUCTS = new Set([134]);
515
-
516
- // Replace the reflectivity branch in level3PhysicalFromDataLevel:
517
- function level3PhysicalFromDataLevel(
518
- level: number,
519
- minimumDataValue: number,
520
- dataIncrement: number,
521
- mode: Level3SymbologyMode,
522
- msg134Params?: { linearScale: number; linearOffset: number; logStart: number; logScale: number; logOffset: number } | null,
523
- ): number | null {
524
- if (!Number.isFinite(level)) return null;
525
- if (mode === 'digital_velocity_byte') {
526
- if (level < 2) return null;
527
- return minimumDataValue + (level - 2) * dataIncrement;
528
- }
529
- if (mode === 'digital_velocity_rle4') {
530
- if (level < 2) return null;
531
- const MS_PER_STEP = 4;
532
- const CENTER_LEVEL = 8;
533
- return (level - CENTER_LEVEL) * MS_PER_STEP;
534
- }
535
- // Product 134 (Digital VIL): piecewise linear + log, params from threshold halfwords
536
- if (msg134Params) {
537
- if (level < 2) return null;
538
- const { linearScale, linearOffset, logStart, logScale, logOffset } = msg134Params;
539
- if (level < logStart) {
540
- return (level - linearOffset) / linearScale;
541
- } else {
542
- return Math.exp((level - logOffset) / logScale);
543
- }
544
- }
545
- if (mode === 'standard_digital') {
546
- if (level < 2) return null;
547
- return minimumDataValue + (level - 2) * dataIncrement;
548
- }
549
- if (level <= 1) return null;
550
- return minimumDataValue + level * dataIncrement;
551
- }
552
-
553
- function parseLevel3Packet0010(
554
- raf: Level3Raf,
555
- minimumDataValue: number,
556
- dataIncrement: number,
557
- levelMode: Level3SymbologyMode,
558
- l3ProductCode: number,
559
- msg134Params?: { linearScale: number; linearOffset: number; logStart: number; logScale: number; logOffset: number } | null,
560
- eetThresholds?: { dataMask: number; scale: number; offset: number } | null,
561
- genericDigital?: Level3GenericDigitalParams | null,
562
- ) {
563
- const packetCode = raf.readUShort();
564
- if (packetCode !== 16) throw new Error(`Unexpected packet code ${packetCode}`);
565
- const firstBinMeters = raf.readShort();
566
- let numberBins = raf.readShort();
567
- raf.readShort(); // iSweepCenter
568
- raf.readShort(); // jSweepCenter
569
- const rangeScaleRaw = raf.readShort();
570
- const numberRadials = raf.readShort();
571
- // Py-ART: packet 16 sometimes has nbins ≠ per-radial nbytes; true gate count follows first radial header.
572
- if (numberRadials > 0) {
573
- const peek = raf.getPos();
574
- const nbytesFirst = raf.readShort();
575
- raf.seek(peek);
576
- if (nbytesFirst !== numberBins) {
577
- numberBins = nbytesFirst;
578
- }
579
- }
580
- const radials: Array<{ startAngle: number; angleDelta: number; bins: Array<number | null> }> = [];
581
- for (let r = 0; r < numberRadials; r++) {
582
- const bytesInRadial = raf.readShort();
583
- const startAngle = raf.readShort() / 10;
584
- const angleDelta = raf.readShort() / 10;
585
- const bins: Array<number | null> = [];
586
- for (let i = 0; i < numberBins; i++) {
587
- const level = raf.readByte();
588
- if (eetThresholds) {
589
- bins.push(
590
- level3EetHeightKmFromLevel(level, eetThresholds.dataMask, eetThresholds.scale, eetThresholds.offset),
591
- );
592
- } else if (LEVEL3_DIGITAL_HMC_PRODUCT_CODES.has(l3ProductCode)) {
593
- bins.push(level3PhysicalFromDigitalHmc(level, 'byte'));
594
- } else if (genericDigital) {
595
- bins.push(level3PhysicalFromGenericDigital(level, genericDigital, l3ProductCode));
596
- } else {
597
- bins.push(level3PhysicalFromDataLevel(level, minimumDataValue, dataIncrement, levelMode, msg134Params));
598
- }
599
- }
600
- if (bytesInRadial > numberBins) raf.skip(bytesInRadial - numberBins);
601
- radials.push({ startAngle, angleDelta, bins });
602
- }
603
- return { firstBinMeters, numberBins, rangeScaleRaw, numberRadials, radials };
604
- }
605
- function parseLevel3PacketAF1F(
606
- raf: Level3Raf,
607
- minimumDataValue: number,
608
- dataIncrement: number,
609
- levelMode: Level3SymbologyMode,
610
- l3ProductCode: number,
611
- msg134Params?: { linearScale: number; linearOffset: number; logStart: number; logScale: number; logOffset: number } | null,
612
- eetThresholds?: { dataMask: number; scale: number; offset: number } | null,
613
- genericDigital?: Level3GenericDigitalParams | null,
614
- ) {
615
- const packetCode = raf.readUShort();
616
- if (packetCode !== 0xAF1F) throw new Error(`Unexpected packet code ${packetCode}`);
617
- const firstBinMeters = raf.readShort();
618
- const numberBins = raf.readShort();
619
- raf.readShort(); // iSweepCenter
620
- raf.readShort(); // jSweepCenter
621
- const rangeScaleRaw = raf.readShort();
622
- const numRadials = raf.readShort();
623
- const radials: Array<{ startAngle: number; angleDelta: number; bins: Array<number | null> }> = [];
624
- for (let r = 0; r < numRadials; r++) {
625
- const rleBytes = raf.readShort() * 2;
626
- const startAngle = raf.readShort() / 10;
627
- const angleDelta = raf.readShort() / 10;
628
- const bins: Array<number | null> = [];
629
- for (let i = 0; i < rleBytes; i++) {
630
- const byte = raf.readByte();
631
- const run = byte >> 4;
632
- const level = byte & 0x0f;
633
- const value = eetThresholds
634
- ? level3EetHeightKmFromLevel(level, eetThresholds.dataMask, eetThresholds.scale, eetThresholds.offset)
635
- : LEVEL3_DIGITAL_HMC_PRODUCT_CODES.has(l3ProductCode)
636
- ? level3PhysicalFromDigitalHmc(level, 'nibble')
637
- : genericDigital
638
- ? level3PhysicalFromGenericDigital(level, genericDigital, l3ProductCode)
639
- : level3PhysicalFromDataLevel(level, minimumDataValue, dataIncrement, levelMode, msg134Params);
640
- for (let j = 0; j < run; j++) bins.push(value);
641
- }
642
- if (bins.length > numberBins) bins.length = numberBins;
643
- while (bins.length < numberBins) bins.push(null);
644
- radials.push({ startAngle, angleDelta, bins });
645
- }
646
- return { firstBinMeters, numberBins, rangeScaleRaw, numberRadials: numRadials, radials };
647
- }
648
-
649
- function buildRayBoundariesFromLevel3Radials(radials: any[]): Float32Array {
650
- const n = radials.length;
651
- const boundaries = new Float32Array(n + 1);
652
- let prev = -Infinity;
653
- for (let i = 0; i < n; i++) {
654
- const startAngle = Number(radials[i]?.startAngle);
655
- const angleDelta = Number(radials[i]?.angleDelta);
656
- const start = Number.isFinite(startAngle) ? startAngle : 0;
657
- const delta = Number.isFinite(angleDelta) && angleDelta > 0 ? angleDelta : 1;
658
- let lower = start - delta * 0.5;
659
- while (lower <= prev) lower += 360;
660
- boundaries[i] = lower;
661
- prev = lower;
662
- }
663
- const lastDeltaRaw = Number(radials[n - 1]?.angleDelta);
664
- const lastDelta = Number.isFinite(lastDeltaRaw) && lastDeltaRaw > 0 ? lastDeltaRaw : 1;
665
- boundaries[n] = boundaries[n - 1] + lastDelta;
666
- return boundaries;
667
- }
668
-
669
- function encodeSigned16ToHiLo(value: number): [number, number] {
670
- const signed = Math.max(-32768, Math.min(32767, value));
671
- const raw = signed < 0 ? signed + 65536 : signed;
672
- return [(raw >> 8) & 0xff, raw & 0xff];
673
- }
674
-
675
- function level3RadialSpatialScore(p: { numberBins: number; numberRadials: number } | null | undefined): number {
676
- if (!p) return -1;
677
- const nb = Number(p.numberBins);
678
- const nr = Number(p.numberRadials);
679
- if (!Number.isFinite(nb) || !Number.isFinite(nr) || nb <= 0 || nr <= 0) return -1;
680
- return nb * nr;
681
- }
682
-
683
- type Level3RadialPick = { kind: 0x10 | 0xaf1f; packet: any };
684
-
685
- function level3RadialPickIsBetter(cand: Level3RadialPick, prev: Level3RadialPick | null): boolean {
686
- const sC = level3RadialSpatialScore(cand.packet);
687
- const sP = level3RadialSpatialScore(prev?.packet);
688
- if (sC > sP) return true;
689
- if (sC < sP) return false;
690
- if (!prev) return true;
691
- /** Same footprint: prefer full-byte digital radial (0x10) over 4-bit RLE (0xAF1F) for smoother velocity. */
692
- if (cand.kind === 0x10 && prev.kind === 0xaf1f) return true;
693
- return false;
694
- }
695
-
696
- /**
697
- * Advance past a non-radial symbology packet so later packets in the same layer can be read.
698
- * Caller must position RAF at the packet code; we consume code + body.
699
- * Uniform text (1, 8): u16 payload length then that many bytes (MetPy / ICD).
700
- */
701
- function trySkipLevel3OverlayPacket(raf: Level3Raf, packetCode: number, layerEnd: number): boolean {
702
- const pos0 = raf.getPos();
703
- if (packetCode !== 1 && packetCode !== 2 && packetCode !== 8) return false;
704
- if (layerEnd - pos0 < 4) return false;
705
- const codeRead = raf.readUShort();
706
- if (codeRead !== packetCode) {
707
- raf.seek(pos0);
708
- return false;
709
- }
710
- const payloadBytes = raf.readUShort();
711
- if (payloadBytes > layerEnd - raf.getPos()) {
712
- raf.seek(pos0);
713
- return false;
714
- }
715
- raf.skip(payloadBytes);
716
- return true;
717
- }
718
-
719
- /** Symbology packet 0xBA07: raster image (RLE rows). MetPy `packet_map[0xba07]`. Used by NVL (57), EET, etc. */
720
- const LEVEL3_PACKET_RASTER_BA07 = 0xba07;
721
- /** First u32 after packet code; MetPy asserts this for raster payload. */
722
- const LEVEL3_RASTER_INNER_CODE = 0x800000c0;
723
-
724
- function unpackLevel3RasterRle(rowBytes: Uint8Array): number[] {
725
- const unpacked: number[] = [];
726
- for (let i = 0; i < rowBytes.length; i++) {
727
- const b = rowBytes[i];
728
- const run = b >> 4;
729
- const val = b & 0x0f;
730
- for (let j = 0; j < run; j++) unpacked.push(val);
731
- }
732
- return unpacked;
733
- }
734
-
735
- function level3CombineRasterScale(int16: number, frac16: number): number {
736
- const i = Math.abs(int16);
737
- const f = Math.abs(frac16) / 10000;
738
- const s = i + f;
739
- return s > 0 ? s : 1;
740
- }
741
-
742
- type Level3ParsedRaster = {
743
- iStart: number;
744
- jStart: number;
745
- cellKmX: number;
746
- cellKmY: number;
747
- startXKm: number;
748
- startYKm: number;
749
- nCols: number;
750
- nRows: number;
751
- rows: number[][];
752
- };
753
-
754
- /**
755
- * Parse raster symbology (packet 0xBA07). RAF must be positioned at the packet code (first u16).
756
- * Leaves RAF at `layerEnd` on success or after skipping on failure.
757
- */
758
- function parseLevel3PacketRasterBA07(raf: Level3Raf, layerEnd: number, objectKey: string): Level3ParsedRaster | null {
759
- const pos0 = raf.getPos();
760
- const packetCode = raf.readUShort();
761
- if (packetCode !== LEVEL3_PACKET_RASTER_BA07) {
762
- raf.seek(pos0);
763
- return null;
764
- }
765
- const room = layerEnd - raf.getPos();
766
- if (room < 20) {
767
- raf.seek(layerEnd);
768
- return null;
769
- }
770
- const inner = raf.readUInt32();
771
- if (inner !== LEVEL3_RASTER_INNER_CODE) {
772
- raf.seek(layerEnd);
773
- return null;
774
- }
775
- const iStart = raf.readShort();
776
- const jStart = raf.readShort();
777
- const xscaleInt = raf.readShort();
778
- const xscaleFrac = raf.readShort();
779
- const yscaleInt = raf.readShort();
780
- const yscaleFrac = raf.readShort();
781
- const numRows = raf.readUShort();
782
- const packing = raf.readUShort();
783
- if (packing !== 2) {
784
- console.warn('[aguacero][nexrad-l3][raster-ba07-packing]', { objectKey, packing, hint: 'MetPy expects packing=2; raster parse skipped for this layer' });
785
- raf.seek(layerEnd);
786
- return null;
787
- }
788
- if (numRows <= 0 || numRows > 2000 || raf.getPos() > layerEnd) {
789
- raf.seek(layerEnd);
790
- return null;
791
- }
792
- const cellKmX = level3CombineRasterScale(xscaleInt, xscaleFrac);
793
- const cellKmY = level3CombineRasterScale(yscaleInt, yscaleFrac);
794
- /** Match MetPy: start corner from integer scales only (see `metpy.io.nexrad` `_unpack_packet_raster_data`). */
795
- const startXKm = iStart * xscaleInt;
796
- const startYKm = jStart * yscaleInt;
797
- const rows: number[][] = [];
798
- let nCols = -1;
799
- for (let r = 0; r < numRows; r++) {
800
- if (raf.getPos() + 2 > layerEnd) {
801
- raf.seek(layerEnd);
802
- return null;
803
- }
804
- const rowNumBytes = raf.readUShort();
805
- if (rowNumBytes === 0 || raf.getPos() + rowNumBytes > layerEnd) {
806
- raf.seek(layerEnd);
807
- return null;
808
- }
809
- const rowBytes = raf.read(rowNumBytes);
810
- const expanded = unpackLevel3RasterRle(rowBytes);
811
- if (nCols < 0) nCols = expanded.length;
812
- else if (expanded.length !== nCols) {
813
- raf.seek(layerEnd);
814
- return null;
815
- }
816
- rows.push(expanded);
817
- }
818
- if (nCols <= 0) {
819
- raf.seek(layerEnd);
820
- return null;
821
- }
822
- if (raf.getPos() < layerEnd) {
823
- raf.seek(layerEnd);
824
- }
825
- return {
826
- iStart,
827
- jStart,
828
- cellKmX,
829
- cellKmY,
830
- startXKm,
831
- startYKm,
832
- nCols,
833
- nRows: numRows,
834
- rows,
835
- };
836
- }
837
-
838
- function level3RasterPdbScales(
839
- decodeMode: Level3DecodeMode,
840
- productCode: number,
841
- minimumDataValue: number,
842
- dataIncrement: number,
843
- depProductDescriptionShorts: number[] | null,
844
- objectKey: string,
845
- genericDigital: Level3GenericDigitalParams | null,
846
- ): {
847
- minT: number;
848
- dataInc: number;
849
- symMode: Level3SymbologyMode;
850
- vilLevelThresholds: number[] | null;
851
- } {
852
- const minBad =
853
- !Number.isFinite(minimumDataValue) || Math.abs(minimumDataValue) > 500;
854
- const incBad = !Number.isFinite(dataIncrement) || dataIncrement <= 0;
855
- const bad = minBad || incBad;
856
- let minT = minimumDataValue;
857
- let dataInc = dataIncrement;
858
- let vilLevelThresholds: number[] | null = null;
859
- /**
860
- * NVL often encodes an invalid linear min (e.g. -32766 sentinel), while real level thresholds
861
- * are present in product-description shorts 33..46 (values like 5,10,...,70 kg/m²).
862
- * Prefer those thresholds when present so data-level 2..15 map to physically meaningful VIL.
863
- */
864
- if (
865
- decodeMode === 'vil' &&
866
- productCode === 57 &&
867
- Array.isArray(depProductDescriptionShorts) &&
868
- depProductDescriptionShorts.length >= 17 &&
869
- depProductDescriptionShorts[1] <= -30000
870
- ) {
871
- const byLevel = new Array<number>(16).fill(Number.NaN);
872
- let validCount = 0;
873
- for (let level = 2; level <= 15; level++) {
874
- const raw = depProductDescriptionShorts[level + 1];
875
- if (Number.isFinite(raw) && raw > 0) {
876
- byLevel[level] = raw;
877
- validCount += 1;
878
- }
879
- }
880
- if (validCount >= 8) {
881
- vilLevelThresholds = byLevel;
882
- }
883
- }
884
- if (bad) {
885
- /** VIL: PDB min is often garbage; increment 0.1 is still valid. phys = minT + level*inc with level≥2 → use minT = -inc so level 2 → +inc (e.g. 0.1). */
886
- if (decodeMode === 'vil' && minBad && !incBad && dataIncrement >= 0.05 && dataIncrement <= 2) {
887
- minT = -dataIncrement;
888
- dataInc = dataIncrement;
889
- } else if (decodeMode === 'vil') {
890
- minT = 0;
891
- dataInc = 1;
892
- } else if (decodeMode === 'precip') {
893
- minT = 0;
894
- dataInc = 0.25;
895
- } else if (decodeMode === 'tops_kft') {
896
- minT = 0;
897
- dataInc = 1;
898
- } else if (decodeMode === 'categorical') {
899
- minT = 0;
900
- dataInc = 1;
901
- } else if (decodeMode === 'generic_physical') {
902
- minT = -2;
903
- dataInc = 0.05;
904
- } else {
905
- minT = -32;
906
- dataInc = 0.5;
907
- }
908
- }
909
- const symMode: Level3SymbologyMode =
910
- decodeMode === 'velocity'
911
- ? 'digital_velocity_byte'
912
- : decodeMode === 'precip' && !genericDigital
913
- ? 'standard_digital'
914
- : 'reflectivity';
915
- return { minT, dataInc, symMode, vilLevelThresholds };
916
- }
917
-
918
- /** Resample Cartesian raster (km east/north from radar) into the polar texture the Mapbox radar shader expects. */
919
- function level3RasterToPolarFrame(
920
- raster: Level3ParsedRaster,
921
- stationLat: number,
922
- stationLon: number,
923
- decodeMode: Level3DecodeMode,
924
- minT: number,
925
- dataInc: number,
926
- symMode: Level3SymbologyMode,
927
- vilLevelThresholds: number[] | null,
928
- eetThresholds: { dataMask: number; scale: number; offset: number } | null,
929
- valueScale: number,
930
- valueOffset: number,
931
- objectKey: string,
932
- genericDigital: Level3GenericDigitalParams | null = null,
933
- l3ProductCode = 0,
934
- ): DecodedRadarFrame | null {
935
- const { rows, nCols, nRows, startXKm, startYKm, cellKmX, cellKmY, iStart, jStart } = raster;
936
- const widthKm = nCols * cellKmX;
937
- const heightKm = nRows * cellKmY;
938
- /**
939
- * ICD raster I/J origin 0 with a square grid is a box **centered** on the radar (±half extent in km).
940
- * Treating (0,0) as the southwest corner only covers the northeast quadrant — data appears displaced
941
- * toward +x,+y (e.g. Atlantic for East Coast sites) and few polar samples hit the grid.
942
- */
943
- let originXK = startXKm;
944
- let originYK = startYKm;
945
- if (iStart === 0 && jStart === 0 && nCols === nRows && nCols >= 16) {
946
- originXK = startXKm - widthKm / 2;
947
- originYK = startYKm - heightKm / 2;
948
- }
949
- const xMin = originXK;
950
- const xMax = originXK + widthKm;
951
- const yMin = originYK;
952
- const yMax = originYK + heightKm;
953
- const corner = (x: number, y: number) => Math.hypot(x, y);
954
- let maxR = corner(xMin, yMin);
955
- maxR = Math.max(maxR, corner(xMax, yMin));
956
- maxR = Math.max(maxR, corner(xMin, yMax));
957
- maxR = Math.max(maxR, corner(xMax, yMax));
958
- maxR = Math.min(460, Math.max(50, maxR * 1.02));
959
-
960
- const nRays = 720;
961
- const gateWidthKm = 0.5;
962
- const nGates = Math.min(920, Math.max(64, Math.ceil(maxR / gateWidthKm)));
963
- const firstGateKm = 0;
964
-
965
- const gateData = new Uint8Array(nRays * nGates * 2);
966
- const rayBoundariesDeg = new Float32Array(nRays + 1);
967
- const deltaAz = 360 / nRays;
968
- for (let i = 0; i <= nRays; i++) {
969
- rayBoundariesDeg[i] = i * deltaAz - deltaAz * 0.5;
970
- }
971
-
972
- let samples = 0;
973
- let nodata = 0;
974
- for (let rayIdx = 0; rayIdx < nRays; rayIdx++) {
975
- const azDeg = (rayIdx + 0.5) * deltaAz;
976
- const azRad = (azDeg * Math.PI) / 180;
977
- const sinAz = Math.sin(azRad);
978
- const cosAz = Math.cos(azRad);
979
- for (let g = 0; g < nGates; g++) {
980
- const rKm = firstGateKm + (g + 0.5) * gateWidthKm;
981
- const xKm = rKm * sinAz;
982
- const yKm = rKm * cosAz;
983
- const fc = (xKm - originXK) / cellKmX;
984
- const fr = (yKm - originYK) / cellKmY;
985
- const c = Math.floor(fc);
986
- const r = Math.floor(fr);
987
- let level: number | null = null;
988
- if (c >= 0 && c < nCols && r >= 0 && r < nRows) {
989
- level = rows[r][c] ?? null;
990
- }
991
- let phys: number | null = null;
992
- if (level != null && Number.isFinite(level)) {
993
- if (
994
- decodeMode === 'vil' &&
995
- vilLevelThresholds &&
996
- level >= 0 &&
997
- level < vilLevelThresholds.length &&
998
- Number.isFinite(vilLevelThresholds[level])
999
- ) {
1000
- phys = vilLevelThresholds[level];
1001
- } else if (decodeMode === 'tops_kft' && eetThresholds) {
1002
- phys = level3EetHeightKmFromLevel(
1003
- level,
1004
- eetThresholds.dataMask,
1005
- eetThresholds.scale,
1006
- eetThresholds.offset,
1007
- );
1008
- } else if (LEVEL3_DIGITAL_HMC_PRODUCT_CODES.has(l3ProductCode)) {
1009
- phys = level3PhysicalFromDigitalHmc(level, 'nibble');
1010
- } else if (genericDigital) {
1011
- phys = level3PhysicalFromGenericDigital(level, genericDigital, l3ProductCode);
1012
- } else {
1013
- phys = level3PhysicalFromDataLevel(level, minT, dataInc, symMode);
1014
- }
1015
- }
1016
- if (phys == null || !Number.isFinite(phys)) {
1017
- nodata++;
1018
- const offset = (rayIdx * nGates + g) * 2;
1019
- gateData[offset] = 0x80;
1020
- gateData[offset + 1] = 0x00;
1021
- continue;
1022
- }
1023
- samples++;
1024
- const rawSigned = Math.round((phys - valueOffset) / valueScale);
1025
- const [hi, lo] = encodeSigned16ToHiLo(rawSigned);
1026
- const offset = (rayIdx * nGates + g) * 2;
1027
- gateData[offset] = hi;
1028
- gateData[offset + 1] = lo;
1029
- }
1030
- }
1031
- if (samples < 8) {
1032
- console.warn('[aguacero][nexrad-l3][raster-nearly-empty]', { objectKey, samples, nodata });
1033
- return null;
1034
- }
1035
- return {
1036
- gateData,
1037
- nRays,
1038
- nGates,
1039
- stationLat,
1040
- stationLon,
1041
- firstGateKm,
1042
- gateWidthKm,
1043
- valueScale,
1044
- valueOffset,
1045
- rayBoundariesDeg,
1046
- };
1047
- }
1048
-
1049
- /**
1050
- * Validate Level-III Product Description Block (PDB) start at `pos`.
1051
- *
1052
- * Two valid layouts are seen in the wild:
1053
- * 1) Divider-first PDB (`0xFFFF`, then lat/lon)
1054
- * 2) Message-header-first (`productCode`, ... , divider at +18, then lat/lon)
1055
- *
1056
- * Use strict plausibility checks so we don't lock onto random halfwords.
1057
- */
1058
- function isValidLevel3PdbStart(bytes: Uint8Array, pos: number): boolean {
1059
- const len = bytes.length;
1060
- if (pos + 14 > len) return false;
1061
- const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
1062
- const latLonPlausible = (latMilli: number, lonMilli: number): boolean => {
1063
- const lat = latMilli / 1000;
1064
- const lon = lonMilli / 1000;
1065
- return (
1066
- lat >= -60 &&
1067
- lat <= 72 &&
1068
- lon >= -180 &&
1069
- lon <= 180 &&
1070
- Math.abs(lat) > 1 &&
1071
- Math.abs(lon) > 1
1072
- );
1073
- };
1074
-
1075
- // Layout A: divider-first
1076
- if (bytes[pos] === 0xff && bytes[pos + 1] === 0xff) {
1077
- const latMilli = view.getInt32(pos + 2, false);
1078
- const lonMilli = view.getInt32(pos + 6, false);
1079
- return latLonPlausible(latMilli, lonMilli);
1080
- }
1081
-
1082
- // Layout B: message-header-first (divider at +18, lat/lon at +20/+24)
1083
- if (pos + 28 <= len) {
1084
- const productCode = view.getUint16(pos, false);
1085
- const divider = view.getInt16(pos + 18, false);
1086
- if (productCode >= 1 && productCode <= 499 && divider === -1) {
1087
- const latMilli = view.getInt32(pos + 20, false);
1088
- const lonMilli = view.getInt32(pos + 24, false);
1089
- if (latLonPlausible(latMilli, lonMilli)) return true;
1090
- }
1091
- }
1092
-
1093
- return false;
1094
- }
1095
-
1096
- /**
1097
- * PDB byte offset within the Level-III file (after optional SDUS trim).
1098
- * NOAAPort/Unidata objects use a short ASCII preamble (SDUS line + product/site line) then binary.
1099
- * A naive scan for 0xFFFF+lat/lon matches an *internal* digital divider (e.g. DVL at byte ~48) and
1100
- * mis-parses symbology offsets — only consider candidates that start immediately after a line break.
1101
- */
1102
- function findLevel3PdbByteOffset(bytes: Uint8Array): number {
1103
- const len = bytes.length;
1104
- let pos = 0;
1105
- while (pos < len) {
1106
- let lineEnd = pos;
1107
- while (lineEnd < len && bytes[lineEnd] !== 0x0a && bytes[lineEnd] !== 0x0d) lineEnd++;
1108
- while (lineEnd < len && (bytes[lineEnd] === 0x0a || bytes[lineEnd] === 0x0d)) lineEnd++;
1109
- const nextStart = lineEnd;
1110
- if (nextStart >= len - 2) break;
1111
- if (isValidLevel3PdbStart(bytes, nextStart)) return nextStart;
1112
- pos = nextStart;
1113
- }
1114
- if (len < 30) return 30;
1115
- const end = Math.min(len - 14, 900);
1116
- for (let i = 16; i <= end; i++) {
1117
- if (isValidLevel3PdbStart(bytes, i)) return i;
1118
- }
1119
- return 30;
1120
- }
1121
-
1122
- /** Decode Level-III radial raster (symbology block, packets 0x0010 / 0xAF1F) or raster image 0xBA07. */
1123
- function decodeLevel3RasterProduct(buffer: ArrayBuffer, objectKey: string, radarVariable: string): DecodedRadarFrame | null {
1124
- try {
1125
- const bytes = new Uint8Array(buffer);
1126
- const marker = [83, 68, 85, 83]; // SDUS
1127
- let startIndex = 0;
1128
- for (let i = 0; i <= bytes.length - marker.length; i++) {
1129
- if (
1130
- bytes[i] === marker[0] &&
1131
- bytes[i + 1] === marker[1] &&
1132
- bytes[i + 2] === marker[2] &&
1133
- bytes[i + 3] === marker[3]
1134
- ) {
1135
- startIndex = i;
1136
- break;
1137
- }
1138
- }
1139
- const trimmed = startIndex > 0 ? bytes.subarray(startIndex) : bytes;
1140
- const raf = new Level3Raf(trimmed);
1141
-
1142
- const fileType = raf.readString(6);
1143
- if (!fileType.startsWith('SDUS')) {
1144
- throw new Error(`Unexpected Level3 header ${fileType}`);
1145
- }
1146
- const pdbByteOffset = findLevel3PdbByteOffset(trimmed);
1147
- raf.seek(pdbByteOffset);
1148
- const headerWord0 = raf.readShort();
1149
- let stationLat: number;
1150
- let stationLon: number;
1151
- let productCode: number;
1152
- if (headerWord0 === -1) {
1153
- stationLat = raf.readInt() / 1000;
1154
- stationLon = raf.readInt() / 1000;
1155
- raf.readShort(); // height
1156
- productCode = raf.readUShort();
1157
- } else {
1158
- raf.seek(pdbByteOffset);
1159
- productCode = raf.readUShort();
1160
- raf.readShort(); // julianDate
1161
- raf.readInt(); // seconds
1162
- raf.readInt(); // length
1163
- raf.readShort(); // source
1164
- raf.readShort(); // dest
1165
- raf.readShort(); // blocks
1166
- const divider = raf.readShort();
1167
- if (divider !== -1) throw new Error(`Invalid product divider ${divider}`);
1168
- stationLat = raf.readInt() / 1000;
1169
- stationLon = raf.readInt() / 1000;
1170
- raf.readShort(); // height
1171
- raf.readShort(); // code
1172
- }
1173
- raf.readShort(); // mode
1174
- raf.readShort(); // vcp
1175
- raf.readShort(); // sequenceNumber
1176
- raf.readShort(); // volumeScanNumber
1177
- raf.readShort(); // volumeScanDate
1178
- raf.readInt(); // volumeScanTime
1179
- raf.readShort(); // productDate
1180
- raf.readInt(); // productTime
1181
- raf.read(4); // dependent27_28
1182
- raf.readShort(); // elevationNumber
1183
-
1184
- const dep30_53 = new Level3Raf(raf.read(48));
1185
- const depProductDescriptionShorts: number[] = [];
1186
- for (let i = 0; i < 24; i++) {
1187
- depProductDescriptionShorts.push(dep30_53.readShort());
1188
- }
1189
- const minimumDataValue = depProductDescriptionShorts[1] / 10;
1190
- const dataIncrement = depProductDescriptionShorts[2] / 10;
1191
- const compressionMethod = depProductDescriptionShorts[21];
1192
- const genericDigitalParams = LEVEL3_GENERIC_DIGITAL_PRODUCT_CODES.has(productCode)
1193
- ? parseLevel3GenericDigitalParams(depProductDescriptionShorts)
1194
- : null;
1195
-
1196
- if (LEVEL3_GENERIC_DIGITAL_PRODUCT_CODES.has(productCode) && !genericDigitalParams) {
1197
- console.warn('[aguacero][nexrad-l3][generic-digital-params-null]', {
1198
- objectKey,
1199
- radarVariable,
1200
- productCode,
1201
- depHead: depProductDescriptionShorts.slice(0, 12),
1202
- hint: 'Continuing with legacy min/inc; precip values may be wrong — check dep alignment / PDB offset.',
1203
- });
1204
- }
1205
-
1206
- // Product 134 (Digital VIL) uses a piecewise linear+log scale encoded
1207
- // in threshold halfwords 31-35, not the standard min+level*inc formula.
1208
- let msg134Params: { linearScale: number; linearOffset: number; logStart: number; logScale: number; logOffset: number } | null = null;
1209
- if (productCode === 134 && depProductDescriptionShorts.length >= 6) {
1210
- const hw31 = depProductDescriptionShorts[1];
1211
- const hw32 = depProductDescriptionShorts[2];
1212
- const hw33 = depProductDescriptionShorts[3];
1213
- const hw34 = depProductDescriptionShorts[4];
1214
- const hw35 = depProductDescriptionShorts[5];
1215
- msg134Params = {
1216
- linearScale: int16ToFloat16(hw31),
1217
- linearOffset: int16ToFloat16(hw32),
1218
- logStart: hw33,
1219
- logScale: int16ToFloat16(hw34),
1220
- logOffset: int16ToFloat16(hw35),
1221
- };
1222
- }
1223
- const l3Entry = getNexradLevel3EntryByRadarKey(radarVariable);
1224
- const decodeMode: Level3DecodeMode =
1225
- l3Entry?.decodeMode ??
1226
- (radarVariable === 'VEL' ||
1227
- radarVariable === 'N0G' ||
1228
- radarVariable === 'SW' ||
1229
- radarVariable === 'N0W'
1230
- ? 'velocity'
1231
- : 'dbz');
1232
- const isVelSw = decodeMode === 'velocity';
1233
-
1234
- let minThreshold = minimumDataValue;
1235
- let dataInc = dataIncrement;
1236
- if (isVelSw) {
1237
- const pdbLooksInvalid =
1238
- !Number.isFinite(dataInc) ||
1239
- dataInc <= 0 ||
1240
- dataInc > 25 ||
1241
- !Number.isFinite(minThreshold) ||
1242
- Math.abs(minThreshold) > 130;
1243
- if (pdbLooksInvalid) {
1244
- minThreshold = -63.5;
1245
- dataInc = 0.5;
1246
- }
1247
- }
1248
- /** Product 138 (DTA): MetPy `DigitalStormPrecipMapper` uses thr2 × 0.01 in, not × 0.1. */
1249
- if (decodeMode === 'precip' && productCode === 138) {
1250
- dataInc = depProductDescriptionShorts[2] * 0.01;
1251
- }
1252
-
1253
- /** Product 135 (EET) only: DigitalEET threshold triple in PDB. Classic echo tops (41 / NET) use min+inc like reflectivity. */
1254
- const eetRadialThresholds =
1255
- decodeMode === 'tops_kft' && productCode === 135
1256
- ? {
1257
- dataMask: depProductDescriptionShorts[1],
1258
- scale: depProductDescriptionShorts[2],
1259
- offset: depProductDescriptionShorts[3],
1260
- }
1261
- : null;
1262
- raf.readByte(); // version
1263
- raf.readByte(); // spotBlank
1264
- const offsetSymbology = raf.readInt();
1265
- raf.readInt(); // offsetGraphic
1266
- raf.readInt(); // offsetTabular
1267
-
1268
- let parseBytes = trimmed;
1269
- if (compressionMethod > 0) {
1270
- const headerBytes = trimmed.subarray(0, raf.getPos());
1271
- const compressedTail = trimmed.subarray(raf.getPos());
1272
- const decompressedTail = bzip.decode(compressedTail) as Uint8Array;
1273
- parseBytes = concatUint8(headerBytes, decompressedTail);
1274
- }
1275
- const parseRaf = new Level3Raf(parseBytes);
1276
- const symbologyOffsetBytes = pdbByteOffset + offsetSymbology * 2;
1277
-
1278
- parseRaf.seek(symbologyOffsetBytes);
1279
-
1280
- const blockDivider = parseRaf.readShort();
1281
- const blockId = parseRaf.readShort();
1282
- if (blockDivider !== -1 || blockId !== 1) {
1283
- const oob = symbologyOffsetBytes < 0 || symbologyOffsetBytes >= parseBytes.length;
1284
- console.warn('[aguacero][nexrad-l3][symbology-header-bad]', {
1285
- objectKey,
1286
- radarVariable,
1287
- productCode,
1288
- blockDivider,
1289
- blockId,
1290
- symbologyOffsetBytes,
1291
- parseBytesLength: parseBytes.length,
1292
- seekOob: oob,
1293
- hint: 'Usually wrong PDB byte offset; check [sym-offset] and findLevel3PdbByteOffset.',
1294
- });
1295
- throw new Error(`Invalid symbology header ${blockDivider}/${blockId}`);
1296
- }
1297
- parseRaf.readInt(); // blockLength
1298
- const numberLayers = parseRaf.readShort();
1299
-
1300
- /** Scan every layer and every packet (MetPy-style): prefer largest gate×ray footprint, then 0x10 over RLE. */
1301
- let bestRadial: Level3RadialPick | null = null;
1302
- let rasterParsed: Level3ParsedRaster | null = null;
1303
- const symbologyPacketCodes: number[] = [];
1304
- for (let layerIndex = 0; layerIndex < numberLayers; layerIndex++) {
1305
- const layerStart = parseRaf.getPos();
1306
- const layerDivider = parseRaf.readShort();
1307
- /** Bytes of symbology payload following this 4-byte field (ICD; same as MetPy `layer_hdr.length`). */
1308
- const layerPayloadLength = parseRaf.readInt();
1309
- if (layerDivider !== -1) {
1310
- parseRaf.seek(layerStart + 6 + layerPayloadLength);
1311
- continue;
1312
- }
1313
- /** End of layer = after 6-byte layer record (divider + length) + payload (MetPy marks `layer_start` after header). */
1314
- const layerEnd = parseRaf.getPos() + layerPayloadLength;
1315
- while (parseRaf.getPos() < layerEnd) {
1316
- const packetPos = parseRaf.getPos();
1317
- const packetCode = parseRaf.readUShort();
1318
- symbologyPacketCodes.push(packetCode);
1319
- parseRaf.seek(packetPos);
1320
- if (packetCode === 16) {
1321
- const symMode: Level3SymbologyMode = isVelSw
1322
- ? 'digital_velocity_byte'
1323
- : decodeMode === 'precip' && !genericDigitalParams
1324
- ? 'standard_digital'
1325
- : 'reflectivity';
1326
- const p = parseLevel3Packet0010(
1327
- parseRaf,
1328
- minThreshold,
1329
- dataInc,
1330
- symMode,
1331
- productCode,
1332
- msg134Params,
1333
- eetRadialThresholds,
1334
- genericDigitalParams,
1335
- );
1336
- const cand: Level3RadialPick = { kind: 0x10, packet: p };
1337
- if (level3RadialPickIsBetter(cand, bestRadial)) bestRadial = cand;
1338
- continue;
1339
- }
1340
- if (packetCode === 0xAF1F) {
1341
- const symMode: Level3SymbologyMode = isVelSw
1342
- ? 'digital_velocity_rle4'
1343
- : decodeMode === 'precip' && !genericDigitalParams
1344
- ? 'standard_digital'
1345
- : 'reflectivity';
1346
- const p = parseLevel3PacketAF1F(
1347
- parseRaf,
1348
- minThreshold,
1349
- dataInc,
1350
- symMode,
1351
- productCode,
1352
- msg134Params,
1353
- eetRadialThresholds,
1354
- genericDigitalParams,
1355
- );
1356
- const cand: Level3RadialPick = { kind: 0xaf1f, packet: p };
1357
- if (level3RadialPickIsBetter(cand, bestRadial)) bestRadial = cand;
1358
- continue;
1359
- }
1360
- if (packetCode === LEVEL3_PACKET_RASTER_BA07) {
1361
- parseRaf.seek(packetPos);
1362
- const parsed = parseLevel3PacketRasterBA07(parseRaf, layerEnd, objectKey);
1363
- if (parsed && rasterParsed === null) rasterParsed = parsed;
1364
- else if (!parsed) parseRaf.seek(layerEnd);
1365
- continue;
1366
- }
1367
- if (trySkipLevel3OverlayPacket(parseRaf, packetCode, layerEnd)) {
1368
- continue;
1369
- }
1370
- parseRaf.seek(layerEnd);
1371
- break;
1372
- }
1373
- parseRaf.seek(layerEnd);
1374
- }
1375
-
1376
- /**
1377
- * EET (135): ICD uses up to **256** data levels on digital radials (packet 0x0010 full bytes).
1378
- * Raster 0xBA07 RLE is **4-bit** (levels 0–15 only), which caps echo-top heights near ~10 km (~33 kft)
1379
- * even though the radial product carries the full dynamic range. Prefer radials when both exist.
1380
- *
1381
- * Digital precip: raster grid avoids radial fan artifacts.
1382
- * Do **not** prefer raster for dual-pol generic-digital products (e.g. 163 N0K KDP): 0xBA07 RLE is **4-bit**
1383
- * (levels 0–15) while 0x0010 radials are **8-bit** — using the raster makes KDP/ZDR-style fields one flat color.
1384
- */
1385
- const shouldPreferRaster =
1386
- rasterParsed != null &&
1387
- ((decodeMode === 'tops_kft' && productCode !== 135) || decodeMode === 'precip');
1388
-
1389
- if (bestRadial && !shouldPreferRaster) {
1390
- const radialPacket = bestRadial.packet;
1391
- const radials = radialPacket?.radials;
1392
- const nRays = Number(radialPacket?.numberRadials);
1393
- const nGates = Number(radialPacket?.numberBins);
1394
- if (!Array.isArray(radials) || !Number.isFinite(nRays) || !Number.isFinite(nGates) || !Number.isFinite(stationLat) || !Number.isFinite(stationLon)) {
1395
- console.warn('[aguacero][nexrad-l3][invalid-radial-packet]', { objectKey, radarVariable, nRays, nGates, stationLat, stationLon });
1396
- } else {
1397
- const gateData = new Uint8Array(nRays * nGates * 2);
1398
- const { valueScale, valueOffset } = level3ValuePacking(decodeMode);
1399
- for (let r = 0; r < nRays; r++) {
1400
- const bins = radials[r]?.bins;
1401
- for (let g = 0; g < nGates; g++) {
1402
- const v = bins?.[g];
1403
- const isNoData = v == null || !Number.isFinite(v);
1404
- const rawSigned = isNoData
1405
- ? -32768
1406
- : Math.round((Number(v) - valueOffset) / valueScale);
1407
- const [hi, lo] = encodeSigned16ToHiLo(rawSigned);
1408
- const offset = (r * nGates + g) * 2;
1409
- gateData[offset] = hi;
1410
- gateData[offset + 1] = lo;
1411
- }
1412
- }
1413
-
1414
- const rangeMul = LEVEL3_PRODUCT_RANGE_SCALE_MUL[productCode] ?? 1;
1415
- const rangeScaleRaw = Number(radialPacket?.rangeScaleRaw);
1416
- const gateSpacingM =
1417
- Number.isFinite(rangeScaleRaw) && rangeScaleRaw > 0 ? rangeScaleRaw * rangeMul : 250;
1418
- let gateWidthKm = gateSpacingM / 1000;
1419
- const firstBinM = Number(radialPacket?.firstBinMeters);
1420
- let firstGateKm = Number.isFinite(firstBinM) && firstBinM >= 0 ? firstBinM / 1000 : 0;
1421
- const decodedCoverageKm = firstGateKm + nGates * gateWidthKm;
1422
- if (decodedCoverageKm > 0 && decodedCoverageKm < 20 && nGates >= 100) {
1423
- gateWidthKm *= 1000;
1424
- firstGateKm *= 1000;
1425
- }
1426
- // Guard against km↔m mismatch producing global wedges for precip products.
1427
- if (decodeMode === 'precip' && decodedCoverageKm > 1500) {
1428
- gateWidthKm /= 1000;
1429
- firstGateKm /= 1000;
1430
- }
1431
- const rayBoundariesDeg = buildRayBoundariesFromLevel3Radials(radials);
1432
-
1433
- return {
1434
- gateData,
1435
- nRays,
1436
- nGates,
1437
- stationLat,
1438
- stationLon,
1439
- firstGateKm,
1440
- gateWidthKm,
1441
- valueScale,
1442
- valueOffset,
1443
- rayBoundariesDeg,
1444
- };
1445
- }
1446
- }
1447
-
1448
- if (rasterParsed) {
1449
- const { valueScale, valueOffset } = level3ValuePacking(decodeMode);
1450
- const { minT, dataInc: rasterDataInc, symMode, vilLevelThresholds } = level3RasterPdbScales(
1451
- decodeMode,
1452
- productCode,
1453
- minThreshold,
1454
- dataInc,
1455
- depProductDescriptionShorts,
1456
- objectKey,
1457
- genericDigitalParams,
1458
- );
1459
- const eetThresholds =
1460
- decodeMode === 'tops_kft' && productCode === 135
1461
- ? {
1462
- dataMask: depProductDescriptionShorts[1],
1463
- scale: depProductDescriptionShorts[2],
1464
- offset: depProductDescriptionShorts[3],
1465
- }
1466
- : null;
1467
- const rasterFrame = level3RasterToPolarFrame(
1468
- rasterParsed,
1469
- stationLat,
1470
- stationLon,
1471
- decodeMode,
1472
- minT,
1473
- rasterDataInc,
1474
- symMode,
1475
- vilLevelThresholds,
1476
- eetThresholds,
1477
- valueScale,
1478
- valueOffset,
1479
- objectKey,
1480
- genericDigitalParams,
1481
- productCode,
1482
- );
1483
- if (rasterFrame) {
1484
- return rasterFrame;
1485
- }
1486
- }
1487
-
1488
- const hex = symbologyPacketCodes.map((c) => `0x${c.toString(16)}`);
1489
- if (!bestRadial) {
1490
- console.warn('[aguacero][nexrad-l3][no-radial-symbology]', {
1491
- objectKey,
1492
- radarVariable,
1493
- messageCode: productCode,
1494
- numberLayers,
1495
- symbologyPacketCodes,
1496
- symbologyPacketCodesHex: hex,
1497
- hint: 'Radial: packets 0x10 / 0xAF1F. Raster: 0xBA07 is supported; if rasterParsed failed, check parseLevel3PacketRasterBA07 / inner code logs.',
1498
- });
1499
- }
1500
- return null;
1501
- } catch (err) {
1502
- console.warn('[aguacero][nexrad-l3][decode-throw]', { objectKey, radarVariable, err });
1503
- return null;
1504
- }
1505
- }
1506
-
1507
- const GROUP2_VARS = ['SW'];
1508
- /** g2 split-cut in clear-air mode (VCP 35): REF + VEL + SW in one file (legacy archives only). */
1509
- const GROUP2_COMBINED_3_VARS = ['REF', 'VEL', 'SW'];
1510
- /** g1 dual-pol + REF; order must match writer. KDP is Level-III (N0K), not in g1 bins. */
1511
- const GROUP1_VARS = ['REF', 'ZDR', 'RHO', 'PHI'] as const;
1512
- /** g2 combined tilt with KDP (7 slots); order must match writer. */
1513
- const GROUP2_COMBINED_7_VARS = ['REF', 'ZDR', 'RHO', 'PHI', 'KDP', 'VEL', 'SW'];
1514
- const GROUP2_COMBINED_VARS = ['REF', 'ZDR', 'RHO', 'PHI', 'VEL', 'SW'];
1515
-
1516
- // Fixed sizes matching the Python writer (`lambda_function.py` MAX_RAYS / AZ_BLOCK_BYTES).
1517
- const FILE_HDR_BYTES = 64;
1518
- const LEVEL2_AZ_BLOCK_RAYS = 720;
1519
- const LEVEL2_AZ_BLOCK_BYTES = LEVEL2_AZ_BLOCK_RAYS * 4;
1520
- /** Per-sweep Nyquist (m/s), big-endian float32 after azimuth block and before slot index (writer `nyquist_bytes`). */
1521
- const LEVEL2_FILE_NYQUIST_BYTES = 4;
1522
- const SLOT_INDEX_ENTRY = 18;
1523
- const MAX_SLOTS = 7; // g2 combined tilt can write 7 fields (GROUP2_COMBINED_7_VARS)
1524
-
1525
- interface FileHeader {
1526
- unixTime: number;
1527
- nRays: number;
1528
- nGates: number;
1529
- elevAngle: number;
1530
- firstGateKm: number;
1531
- gateWidthKm: number;
1532
- nSlots: number;
1533
- azimuthsBuffer: ArrayBuffer; // raw big-endian float32 bytes (720*4)
1534
- slots: Array<{ offset: number; compressedSize: number; uncompressedSize: number }>;
1535
- /** Nyquist velocity (m/s) from file header after azimuths; null if non-finite / out of band. */
1536
- embeddedNyquistMs: number | null;
1537
- }
1538
-
1539
- function int16ToFloat16(val: number): number {
1540
- const sign = (val & 0b1000000000000000) / 0b1000000000000000;
1541
- const exponent = (val & 0b0111110000000000) / 0b0000010000000000;
1542
- const fraction = val & 0b0000001111111111;
1543
- if (exponent === 0) {
1544
- return Math.pow(-1, sign) * 2 * (0 + fraction / Math.pow(2, 10));
1545
- }
1546
- return Math.pow(-1, sign) * Math.pow(2, exponent - 16) * (1 + fraction / Math.pow(2, 10));
1547
- }
1548
-
1549
- /**
1550
- * Total Level-II object size when known.
1551
- * For 206 partial responses, only `Content-Range: bytes a-b/total` gives `total` — `Content-Length` is the segment size.
1552
- */
1553
- function nexradLevel2ResourceTotalBytes(resp: Response): number | null {
1554
- const cr = resp.headers.get('Content-Range');
1555
- if (cr) {
1556
- const m = cr.trim().match(/\/(\d+)\s*$/);
1557
- if (m) {
1558
- const n = Number(m[1]);
1559
- return Number.isFinite(n) && n > 0 ? n : null;
1560
- }
1561
- }
1562
- if (resp.status === 200) {
1563
- const cl = resp.headers.get('Content-Length');
1564
- if (cl) {
1565
- const n = Number(cl);
1566
- return Number.isFinite(n) && n > 0 ? n : null;
1567
- }
1568
- }
1569
- return null;
1570
- }
1571
-
1572
- function parseFileHeader(buffer: ArrayBuffer, azBlockBytes: number): FileHeader {
1573
- const view = new DataView(buffer);
1574
- const magic = view.getUint32(0, false);
1575
- if (magic !== 0x4E584244) throw new Error(`Bad magic: 0x${magic.toString(16)}`);
1576
-
1577
- const unixTime = view.getUint32(4, false);
1578
- const nRays = view.getUint16(8, false);
1579
- const nGates = view.getUint16(10, false);
1580
- const elevAngle = view.getFloat32(12, false);
1581
- const firstGateKm = view.getFloat32(16, false);
1582
- const gateWidthKm = view.getFloat32(20, false);
1583
- const nSlots = view.getUint16(24, false);
1584
-
1585
- const azimuthsBuffer = buffer.slice(FILE_HDR_BYTES, FILE_HDR_BYTES + azBlockBytes);
1586
-
1587
- const azEnd = FILE_HDR_BYTES + azBlockBytes;
1588
- const idxStart = azEnd + LEVEL2_FILE_NYQUIST_BYTES;
1589
- const needBytes = idxStart + nSlots * SLOT_INDEX_ENTRY;
1590
- if (buffer.byteLength < needBytes) {
1591
- throw new Error(`level2 header buffer too small: ${buffer.byteLength} < ${needBytes}`);
1592
- }
1593
-
1594
- const nyqCandidate = view.getFloat32(azEnd, false);
1595
- const embeddedNyquistMs =
1596
- Number.isFinite(nyqCandidate) && nyqCandidate > 0.5 && nyqCandidate < 128 ? nyqCandidate : null;
1597
-
1598
- const slots: Array<{ offset: number; compressedSize: number; uncompressedSize: number }> = [];
1599
- for (let i = 0; i < nSlots; i++) {
1600
- const base = idxStart + i * SLOT_INDEX_ENTRY;
1601
- const offsetHigh = view.getUint32(base, false);
1602
- const offsetLow = view.getUint32(base + 4, false);
1603
- const offset = offsetHigh * 2 ** 32 + offsetLow;
1604
- const compressedSize = view.getUint32(base + 8, false);
1605
- const uncompressedSize = view.getUint32(base + 12, false);
1606
- slots.push({ offset, compressedSize, uncompressedSize });
1607
- }
1608
-
1609
- return {
1610
- unixTime,
1611
- nRays,
1612
- nGates,
1613
- elevAngle,
1614
- firstGateKm,
1615
- gateWidthKm,
1616
- nSlots,
1617
- azimuthsBuffer,
1618
- slots,
1619
- embeddedNyquistMs,
1620
- };
1621
- }
1622
-
1623
- function decodeSweepInWorker(
1624
- objectKey: string,
1625
- slotBuffer: ArrayBuffer,
1626
- header: FileHeader,
1627
- sites: NexradSite[],
1628
- priority: 'display' | 'prefetch',
1629
- signal?: AbortSignal,
1630
- ): Promise<DecodedRadarFrame | null> {
1631
- if (signal?.aborted) return Promise.reject(createAbortError());
1632
-
1633
- const { worker } = getDecodeWorkerForPriority(priority);
1634
- const requestId = ++radarDecodeRequestId;
1635
-
1636
- // Transfer a copy of azimuthsBuffer so we don't detach the cached header
1637
- const azCopy = header.azimuthsBuffer.slice(0);
1638
-
1639
- return new Promise((resolve, reject) => {
1640
- let isSettled = false;
1641
- const onAbort = () => {
1642
- if (isSettled) return;
1643
- isSettled = true;
1644
- radarDecodePending.delete(requestId);
1645
- radarDecodeRequestMeta.delete(requestId);
1646
- signal?.removeEventListener('abort', onAbort);
1647
- reject(createAbortError());
1648
- };
1649
-
1650
- radarDecodePending.set(requestId, {
1651
- resolve: (frame) => { if (!isSettled) { isSettled = true; signal?.removeEventListener('abort', onAbort); resolve(frame); } },
1652
- reject: (err) => { if (!isSettled) { isSettled = true; signal?.removeEventListener('abort', onAbort); reject(err); } },
1653
- });
1654
-
1655
- if (signal) signal.addEventListener('abort', onAbort, { once: true });
1656
-
1657
- worker.postMessage({
1658
- type: 'DECODE_SLOT',
1659
- requestId,
1660
- objectKey,
1661
- slotBuffer,
1662
- nRays: header.nRays,
1663
- nGates: header.nGates,
1664
- firstGateKm: header.firstGateKm,
1665
- gateWidthKm: header.gateWidthKm,
1666
- azimuthsBuffer: azCopy,
1667
- sites,
1668
- }, [slotBuffer, azCopy]);
1669
- });
1670
- }
1671
-
1672
- function resolveLevel3SrvMotionObjectKey(
1673
- unixTime: number | null | undefined,
1674
- motionMap: Record<string, string> | undefined,
1675
- radarSource: 'level2' | 'level3',
1676
- radarVar: string,
1677
- useStormRelativeMotion: boolean,
1678
- ): string | null {
1679
- if (radarVar !== 'VEL' || !useStormRelativeMotion || unixTime == null || !motionMap) {
1680
- return null;
1681
- }
1682
- return pickNearestLevel3ObjectKey(unixTime, motionMap);
1683
- }
1684
-
1685
- /** Reuse parsed N0S motion across time steps (SRV: same motion key is common while scrubbing). */
1686
- const N0S_MOTION_CACHE_MAX = 128;
1687
- const n0sMotionVectorCache = new Map<string, { speedMs: number; directionDeg: number }>();
1688
-
1689
- function getCachedN0sMotion(motionObjectKey: string) {
1690
- return n0sMotionVectorCache.get(motionObjectKey);
1691
- }
1692
- function setCachedN0sMotion(
1693
- motionObjectKey: string,
1694
- motion: { speedMs: number; directionDeg: number },
1695
- ) {
1696
- n0sMotionVectorCache.set(motionObjectKey, motion);
1697
- while (n0sMotionVectorCache.size > N0S_MOTION_CACHE_MAX) {
1698
- const first = n0sMotionVectorCache.keys().next().value as string | undefined;
1699
- if (first === undefined) break;
1700
- n0sMotionVectorCache.delete(first);
1701
- }
1702
- }
1703
-
1704
- async function applyStormMotionFromN0sObjectKey(
1705
- frame: DecodedRadarFrame,
1706
- motionObjectKey: string,
1707
- logLabel: string,
1708
- ): Promise<DecodedRadarFrame> {
1709
- const cached = getCachedN0sMotion(motionObjectKey);
1710
- if (cached) {
1711
- return applyLevel3StormRelativeToFrame(frame, cached.speedMs, cached.directionDeg);
1712
- }
1713
- const motionUrl = objectKeyToUrl(motionObjectKey, 'level3');
1714
- try {
1715
- const motionResp = await fetch(motionUrl);
1716
- if (motionResp.ok) {
1717
- const motionBuf = await motionResp.arrayBuffer();
1718
- const motion = parseLevel3StormMotionFromBuffer(motionBuf);
1719
- if (motion) {
1720
- setCachedN0sMotion(motionObjectKey, motion);
1721
- return applyLevel3StormRelativeToFrame(frame, motion.speedMs, motion.directionDeg);
1722
- }
1723
- }
1724
- } catch (e) {
1725
- console.log(`${logLabel}:srvMotionFetchError`, { motionObjectKey, err: e });
1726
- }
1727
- return frame;
1728
- }
1729
-
1730
- export async function fetchAndParseArchive(
1731
- url: string,
1732
- objectKey: string,
1733
- radarVariable: string,
1734
- groupId: number,
1735
- radarSource: 'level2' | 'level3',
1736
- options?: {
1737
- signal?: AbortSignal;
1738
- priority?: 'display' | 'prefetch';
1739
- /** N0S S3 object key — with VEL, apply SRV (L3: N0G+motion; L2: super-res velocity + nearest-time N0S motion). */
1740
- level3MotionObjectKey?: string | null;
1741
- },
1742
- ): Promise<DecodedRadarFrame | null> {
1743
- const mot = options?.level3MotionObjectKey ? `|${options.level3MotionObjectKey}` : '';
1744
- const cacheKey = `${url}:${radarVariable}:${radarSource}${mot}`;
1745
- const priority = options?.priority ?? 'display';
1746
-
1747
- if (archiveCache.has(cacheKey)) {
1748
- const cached = archiveCache.get(cacheKey)!;
1749
- setArchiveCache(cacheKey, cached);
1750
- return cached;
1751
- }
1752
- const existingInflight = inflightFetches.get(cacheKey);
1753
- if (existingInflight) {
1754
- const meta = inflightFetchMeta.get(cacheKey);
1755
- if (meta) {
1756
- meta.callers += 1;
1757
- }
1758
- return existingInflight;
1759
- }
1760
-
1761
- if (options?.signal?.aborted) {
1762
- return null;
1763
- }
1764
-
1765
- const requestId = ++radarFetchRequestSeq;
1766
- const startedAt = performance.now();
1767
- inflightFetchMeta.set(cacheKey, {
1768
- requestId,
1769
- startedAt,
1770
- callers: 1,
1771
- objectKey,
1772
- radarVariable,
1773
- radarSource,
1774
- priority,
1775
- });
1776
-
1777
- const promise = (async (): Promise<DecodedRadarFrame | null> => {
1778
- try {
1779
- if (radarSource === 'level3') {
1780
- const response = await fetch(url);
1781
- if (!response.ok) throw new Error(`HTTP ${response.status} fetching level3 ${url}`);
1782
- const buffer = await response.arrayBuffer();
1783
- let decoded = decodeLevel3RasterProduct(buffer, objectKey, radarVariable);
1784
- const motionKey = options?.level3MotionObjectKey;
1785
- if (decoded && motionKey && radarVariable === 'VEL') {
1786
- decoded = await applyStormMotionFromN0sObjectKey(
1787
- decoded,
1788
- motionKey,
1789
- 'fetchAndParseArchive:level3',
1790
- );
1791
- }
1792
- if (!decoded) {
1793
- console.warn('[aguacero][nexrad-l3][fetch-ok-decode-null]', {
1794
- objectKey,
1795
- radarVariable,
1796
- byteLength: buffer.byteLength,
1797
- filterConsole: 'Also check [aguacero][nexrad-l3][no-radial-symbology] or decode catch logs',
1798
- });
1799
- } else {
1800
- setArchiveCache(cacheKey, decoded);
1801
- }
1802
- return decoded;
1803
- }
1804
-
1805
- // ── Level-2 two-request range path ───────────────────────────────────────
1806
- const level2Url = cloudFrontUrlWithApiKeyQuery(url);
1807
- nexradArchiveDiag('level2.pipeline.start', {
1808
- objectKey,
1809
- radarVariable,
1810
- groupId,
1811
- apiKeyLen: NEXRAD_ARCHIVE_API_KEY.length,
1812
- bundleIdLen: NEXRAD_ARCHIVE_BUNDLE_ID.length,
1813
- urlNoSecret: redactApiKeyFromUrl(level2Url),
1814
- });
1815
- // Request 1: fetch just the header + slot index to find byte offsets
1816
- const azBlockBytes = LEVEL2_AZ_BLOCK_BYTES;
1817
- const INDEX_FETCH_BYTES =
1818
- FILE_HDR_BYTES + azBlockBytes + LEVEL2_FILE_NYQUIST_BYTES + MAX_SLOTS * SLOT_INDEX_ENTRY;
1819
- nexradArchiveDiag('level2.index.fetch', {
1820
- objectKey,
1821
- byteRangeEnd: INDEX_FETCH_BYTES - 1,
1822
- });
1823
- const indexResp = await fetch(level2Url, {
1824
- headers: level2CloudFrontFetchHeaders(`bytes=0-${INDEX_FETCH_BYTES - 1}`),
1825
- });
1826
- nexradArchiveDiag('level2.index.response', {
1827
- objectKey,
1828
- status: indexResp.status,
1829
- ok: indexResp.ok,
1830
- });
1831
- if (!indexResp.ok && indexResp.status !== 206) {
1832
- throw new Error(`HTTP ${indexResp.status} fetching level2 index ${url}`);
1833
- }
1834
- const indexBuffer = await indexResp.arrayBuffer();
1835
- const indexResourceTotal = nexradLevel2ResourceTotalBytes(indexResp);
1836
- const header = parseFileHeader(indexBuffer, azBlockBytes);
1837
-
1838
- const slotIndexBytes =
1839
- FILE_HDR_BYTES + azBlockBytes + LEVEL2_FILE_NYQUIST_BYTES + header.nSlots * SLOT_INDEX_ENTRY;
1840
- if (indexBuffer.byteLength < slotIndexBytes) {
1841
- console.warn('[RadarLayer] level2 index response shorter than slot table', {
1842
- objectKey,
1843
- byteLength: indexBuffer.byteLength,
1844
- need: slotIndexBytes,
1845
- nSlots: header.nSlots,
1846
- });
1847
- setArchiveCache(cacheKey, null);
1848
- return null;
1849
- }
1850
-
1851
- // ── Step 2: find slot for this variable ─────────────────────
1852
- let varList: string[];
1853
- if (groupId === 2 && header.nSlots === 7) {
1854
- varList = GROUP2_COMBINED_7_VARS;
1855
- } else if (groupId === 2 && header.nSlots === 6) {
1856
- varList = GROUP2_COMBINED_VARS;
1857
- } else if (groupId === 2 && header.nSlots === 3) {
1858
- varList = GROUP2_COMBINED_3_VARS;
1859
- } else if (groupId === 2) {
1860
- varList = GROUP2_VARS;
1861
- } else {
1862
- varList = [...GROUP1_VARS];
1863
- }
1864
-
1865
- const slotIdx = varList.indexOf(radarVariable);
1866
- if (slotIdx < 0 || slotIdx >= header.slots.length) {
1867
- console.warn(`[RadarLayer] ${radarVariable} not in group ${groupId} (nSlots=${header.nSlots})`);
1868
- setArchiveCache(cacheKey, null);
1869
- return null;
1870
- }
1871
-
1872
- const slot = header.slots[slotIdx];
1873
- if (slot.compressedSize === 0) {
1874
- console.warn(`[RadarLayer] ${radarVariable} slot ${slotIdx} has compressedSize=0`);
1875
- setArchiveCache(cacheKey, null);
1876
- return null;
1877
- }
1878
-
1879
- // ── Step 3: fetch exactly the slot bytes ────────────────────
1880
- const slotEndExclusive = slot.offset + slot.compressedSize;
1881
- if (indexResourceTotal != null && slotEndExclusive > indexResourceTotal) {
1882
- console.warn('[RadarLayer] level2 slot extends past object size (bad index or stale CDN)', {
1883
- objectKey,
1884
- radarVariable,
1885
- slotIdx,
1886
- offset: slot.offset,
1887
- compressedSize: slot.compressedSize,
1888
- indexResourceTotal,
1889
- });
1890
- setArchiveCache(cacheKey, null);
1891
- return null;
1892
- }
1893
-
1894
- const slotRangeEnd = slotEndExclusive - 1;
1895
- const slotHeaders = level2CloudFrontFetchHeaders(`bytes=${slot.offset}-${slotRangeEnd}`);
1896
- nexradArchiveDiag('level2.slot.fetch', {
1897
- objectKey,
1898
- slotIdx,
1899
- compressedSize: slot.compressedSize,
1900
- offset: slot.offset,
1901
- });
1902
- let slotResp = await fetch(level2Url, { headers: slotHeaders });
1903
- nexradArchiveDiag('level2.slot.response', {
1904
- objectKey,
1905
- status: slotResp.status,
1906
- ok: slotResp.ok,
1907
- });
1908
- let slotBuffer: ArrayBuffer;
1909
- if (slotResp.ok || slotResp.status === 206) {
1910
- slotBuffer = await slotResp.arrayBuffer();
1911
- } else if (slotResp.status === 416) {
1912
- const fullResp = await fetch(level2Url, { headers: level2CloudFrontFetchHeaders(undefined) });
1913
- if (!fullResp.ok) {
1914
- throw new Error(`HTTP ${fullResp.status} full fetch after 416 for level2 ${url}`);
1915
- }
1916
- const fullBuf = await fullResp.arrayBuffer();
1917
- if (slot.offset >= fullBuf.byteLength || slotEndExclusive > fullBuf.byteLength) {
1918
- throw new Error(
1919
- `level2 slot out of bounds after 416 fallback (offset=${slot.offset}, end=${slotEndExclusive}, file=${fullBuf.byteLength}) for ${url}`,
1920
- );
1921
- }
1922
- slotBuffer = fullBuf.slice(slot.offset, slotEndExclusive);
1923
- } else {
1924
- throw new Error(`HTTP ${slotResp.status} fetching level2 slot ${url}`);
1925
- }
1926
-
1927
- // ── Step 4: decode in worker ────────────────────────────────
1928
- nexradArchiveDiag('level2.decode.start', { objectKey, radarVariable });
1929
- const sites = await loadNexradSites();
1930
- let decoded = await decodeSweepInWorker(
1931
- objectKey, slotBuffer, header, sites,
1932
- options?.priority ?? 'display',
1933
- undefined,
1934
- );
1935
-
1936
- if (!decoded) { setArchiveCache(cacheKey, null); return null; }
1937
- decoded = { ...decoded, embeddedNyquistMs: header.embeddedNyquistMs };
1938
- const l2MotionKey = options?.level3MotionObjectKey;
1939
- if (l2MotionKey && radarVariable === 'VEL') {
1940
- decoded = await applyStormMotionFromN0sObjectKey(
1941
- decoded,
1942
- l2MotionKey,
1943
- 'fetchAndParseArchive:level2',
1944
- );
1945
- }
1946
- setArchiveCache(cacheKey, decoded);
1947
- nexradArchiveDiag('level2.decode.done', {
1948
- objectKey,
1949
- radarVariable,
1950
- nRays: decoded.nRays ?? null,
1951
- nGates: decoded.nGates ?? null,
1952
- });
1953
- return decoded;
1954
- } catch (err) {
1955
- if (isAbortError(err)) {
1956
- return null;
1957
- }
1958
- nexradArchiveDiag('fetchAndParseArchive.failed', {
1959
- objectKey,
1960
- radarSource,
1961
- message: err instanceof Error ? err.message : String(err),
1962
- });
1963
- console.error(`[RadarLayer] fetchAndParseArchive failed for ${objectKey}:`, err);
1964
- return null;
1965
- } finally {
1966
- inflightFetches.delete(cacheKey);
1967
- inflightFetchMeta.delete(cacheKey);
1968
- }
1969
- })();
1970
-
1971
- inflightFetches.set(cacheKey, promise);
1972
- return promise;
1973
- }
1974
-
1
+ /* eslint-disable */
2
+ // Vendored from aguacero-frontend RadarLayer.tsx — fetch/decode only (no React).
3
+ import { Buffer } from 'buffer';
4
+ import * as bzip from 'seek-bzip';
5
+ import {
6
+ getNexradLevel3EntryByRadarKey,
7
+ level3EetHeightKmFromLevel,
8
+ level3ValuePacking,
9
+ nexradLevel3IsHydrometeorClassification,
10
+ nexradLevel3UsesTiltIndexedS3Products,
11
+ nexradLevel3S3ProductForSiteTilt,
12
+ type Level3DecodeMode,
13
+ } from './nexradLevel3Products.js';
14
+ import {
15
+ applyLevel3StormRelativeToFrame,
16
+ parseLevel3StormMotionFromBuffer,
17
+ pickNearestLevel3ObjectKey,
18
+ } from './level3StormRelative.js';
19
+ import type { NexradSite } from './PreprocessedSweepParser.js';
20
+ import { archiveCache, setArchiveCache, type DecodedRadarFrame } from './nexradArchiveCache.js';
21
+ import { loadNexradSites } from './loadNexradSites.js';
22
+ export { setNexradSitesJsonUrl, setNexradSitesFetchAuth } from './loadNexradSites.js';
23
+ import { sampleNexradFrameAtLatLon } from './nexradCrossSectionSampleAtLatLon.js';
24
+ import { decodeRadarSlotMessage, type DecodeSlotRequest } from './radarDecodeSlot.js';
25
+ import { nexradArchiveDiag, redactApiKeyFromUrl } from './nexradArchiveDiag.js';
26
+
27
+ // seek-bzip pulls in node-bzip paths that use `new Buffer()` as a global; browsers have no Buffer.
28
+ if (typeof (globalThis as { Buffer?: typeof Buffer }).Buffer === 'undefined') {
29
+ (globalThis as { Buffer: typeof Buffer }).Buffer = Buffer;
30
+ }
31
+
32
+ /** RN/Hermes has no global {@link DOMException}; keep AbortError semantics for fetch abort paths. */
33
+ function createAbortError(message = 'Aborted'): Error {
34
+ const g = globalThis as typeof globalThis & { DOMException?: typeof DOMException };
35
+ if (typeof g.DOMException !== 'undefined') {
36
+ return new g.DOMException(message, 'AbortError');
37
+ }
38
+ const err = new Error(message);
39
+ err.name = 'AbortError';
40
+ return err;
41
+ }
42
+
43
+ function isAbortError(err: unknown): boolean {
44
+ if (err == null || typeof err !== 'object') return false;
45
+ return (err as Error).name === 'AbortError';
46
+ }
47
+
48
+ let NEXRAD_ARCHIVE_API_KEY = '';
49
+ /** Same as {@link AguaceroCore} grid fetches: `x-app-identifier` on React Native when set. */
50
+ let NEXRAD_ARCHIVE_BUNDLE_ID = '';
51
+ /** Same as {@link AguaceroCore#gridRequestSiteOrigin}: CloudFront often allowlists Origin/Referer (RN has no browser default). */
52
+ let NEXRAD_ARCHIVE_SITE_ORIGIN = '';
53
+
54
+ export function setNexradArchiveApiKey(k: string) {
55
+ NEXRAD_ARCHIVE_API_KEY = k || '';
56
+ }
57
+
58
+ export function setNexradArchiveBundleId(bundleId: string) {
59
+ NEXRAD_ARCHIVE_BUNDLE_ID = bundleId || '';
60
+ }
61
+
62
+ export function setNexradArchiveSiteOrigin(origin: string | null | undefined) {
63
+ const raw = typeof origin === 'string' ? origin.trim() : '';
64
+ NEXRAD_ARCHIVE_SITE_ORIGIN = raw ? raw.replace(/\/+$/, '') : '';
65
+ }
66
+
67
+ /** Match {@link AguaceroCore} `urlWithApiKeyParam` / grid & satellite fetches: `?apiKey=` on CloudFront URLs. */
68
+ function cloudFrontUrlWithApiKeyQuery(baseUrl: string): string {
69
+ if (!NEXRAD_ARCHIVE_API_KEY) return baseUrl;
70
+ if (/[?&]apiKey=/i.test(baseUrl)) return baseUrl;
71
+ const sep = baseUrl.includes('?') ? '&' : '?';
72
+ return `${baseUrl}${sep}apiKey=${encodeURIComponent(NEXRAD_ARCHIVE_API_KEY)}`;
73
+ }
74
+
75
+ /** Level-II: match AguaceroCore grid `fetch` headers (x-api-key, x-app-identifier on RN when bundleId set) plus Range. */
76
+ function level2CloudFrontFetchHeaders(range: string | undefined): Record<string, string> {
77
+ const headers: Record<string, string> = {
78
+ 'x-api-key': NEXRAD_ARCHIVE_API_KEY,
79
+ };
80
+ if (range !== undefined) {
81
+ headers['Range'] = range;
82
+ }
83
+ const g = globalThis as typeof globalThis & { navigator?: { product?: string } };
84
+ if (g.navigator?.product === 'ReactNative' && NEXRAD_ARCHIVE_BUNDLE_ID) {
85
+ headers['x-app-identifier'] = NEXRAD_ARCHIVE_BUNDLE_ID;
86
+ }
87
+ if (NEXRAD_ARCHIVE_SITE_ORIGIN) {
88
+ headers['Origin'] = NEXRAD_ARCHIVE_SITE_ORIGIN;
89
+ headers['Referer'] = `${NEXRAD_ARCHIVE_SITE_ORIGIN}/`;
90
+ }
91
+ return headers;
92
+ }
93
+
94
+ type RadarDecodeWorkerResponse = {
95
+ type: 'DECODE_RESULT';
96
+ requestId: number;
97
+ gateData: Uint8Array | null;
98
+ nRays?: number;
99
+ nGates?: number;
100
+ stationLat?: number;
101
+ stationLon?: number;
102
+ firstGateKm?: number;
103
+ gateWidthKm?: number;
104
+ valueScale?: number;
105
+ valueOffset?: number;
106
+ rayBoundariesDeg?: Float32Array;
107
+ error?: string;
108
+ };
109
+
110
+ function shouldDecodeRadarOnMainThread(): boolean {
111
+ const g = globalThis as typeof globalThis & { navigator?: { product?: string } };
112
+ return typeof Worker === 'undefined' || g.navigator?.product === 'ReactNative';
113
+ }
114
+
115
+ /** Hermes/RN has no web Workers; mirror Worker.postMessage/onmessage on the JS thread. */
116
+ class MainThreadRadarDecodeWorker implements Pick<Worker, 'postMessage' | 'onmessage' | 'onerror' | 'terminate'> {
117
+ onmessage: ((ev: MessageEvent<RadarDecodeWorkerResponse>) => void) | null = null;
118
+ onerror: ((ev: ErrorEvent) => void) | null = null;
119
+
120
+ postMessage(data: unknown): void {
121
+ const msg = data as DecodeSlotRequest;
122
+ if (!msg || msg.type !== 'DECODE_SLOT') return;
123
+ queueMicrotask(() => {
124
+ try {
125
+ const response = decodeRadarSlotMessage(msg);
126
+ this.onmessage?.({ data: response } as MessageEvent<RadarDecodeWorkerResponse>);
127
+ } catch (error) {
128
+ this.onmessage?.({
129
+ data: {
130
+ type: 'DECODE_RESULT',
131
+ requestId: msg.requestId,
132
+ gateData: null,
133
+ error: error instanceof Error ? error.message : 'Main-thread radar decode failed',
134
+ },
135
+ } as MessageEvent<RadarDecodeWorkerResponse>);
136
+ }
137
+ });
138
+ }
139
+
140
+ terminate(): void {}
141
+ }
142
+
143
+ function createRadarDecodeWorker(): Worker {
144
+ if (shouldDecodeRadarOnMainThread()) {
145
+ return new MainThreadRadarDecodeWorker() as unknown as Worker;
146
+ }
147
+ return new Worker(new URL('./radarDecode.worker.bundled.js', import.meta.url), { type: 'module' });
148
+ }
149
+
150
+ const LEVEL2_BASE_URL = 'https://d3dc62msmxkrd7.cloudfront.net/level-2';
151
+ const LEVEL3_BASE_URL = 'https://unidata-nexrad-level3.s3.amazonaws.com';
152
+
153
+ const PREFETCH_DELAY_MS = 0;
154
+ const PREFETCH_CONCURRENCY = 6;
155
+
156
+ /** Underscore-style throttle for no-arg work — coalesces bursts (slider scrub) without delaying the first call. */
157
+ function throttleVoid(fn: () => void, waitMs: number): () => void {
158
+ let timeout: ReturnType<typeof setTimeout> | null = null;
159
+ let previous = 0;
160
+ return () => {
161
+ const now = Date.now();
162
+ const remaining = waitMs - (now - previous);
163
+ if (remaining <= 0 || remaining > waitMs) {
164
+ if (timeout) {
165
+ clearTimeout(timeout);
166
+ timeout = null;
167
+ }
168
+ previous = now;
169
+ fn();
170
+ } else if (!timeout) {
171
+ timeout = setTimeout(() => {
172
+ timeout = null;
173
+ previous = Date.now();
174
+ fn();
175
+ }, remaining);
176
+ }
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Level-III KDP/N0H: throttle runSlot so rapid slider motion coalesces **fetches** (~10/s max) while a trailing
182
+ * edge still applies the final time. MapboxRadarLayer caches the polar mesh on layout + coarse ray-boundary
183
+ * fingerprint so scrubbing is fast while sweep registration stays geographic.
184
+ */
185
+ function createLatestPayloadThrottle(waitMs: number): (run: () => void) => void {
186
+ let latest: (() => void) | null = null;
187
+ const runLatest = () => {
188
+ const fn = latest;
189
+ latest = null;
190
+ fn?.();
191
+ };
192
+ const throttled = throttleVoid(runLatest, waitMs);
193
+ return (run: () => void) => {
194
+ latest = run;
195
+ throttled();
196
+ };
197
+ }
198
+
199
+ const azimuthKeyedRunSlotThrottleBySlot = new Map<string, ReturnType<typeof createLatestPayloadThrottle>>();
200
+
201
+ function getAzimuthKeyedRunSlotThrottle(slotLayerId: string): (run: () => void) => void {
202
+ let t = azimuthKeyedRunSlotThrottleBySlot.get(slotLayerId);
203
+ if (!t) {
204
+ t = createLatestPayloadThrottle(110);
205
+ azimuthKeyedRunSlotThrottleBySlot.set(slotLayerId, t);
206
+ }
207
+ return t;
208
+ }
209
+ const DECODE_WORKER_POOL_SIZE = 2;
210
+ const PREFETCH_WORKER_START_INDEX = 1;
211
+
212
+ // ─── Fetch / parse with deduplication ────────────────────────────────────────
213
+
214
+ // Tracks in-flight fetches so concurrent calls for the same URL share one request.
215
+ const inflightFetches = new Map<string, Promise<DecodedRadarFrame | null>>();
216
+ const inflightFetchMeta = new Map<
217
+ string,
218
+ {
219
+ requestId: number;
220
+ startedAt: number;
221
+ callers: number;
222
+ objectKey: string;
223
+ radarVariable: string;
224
+ radarSource: 'level2' | 'level3';
225
+ priority: 'display' | 'prefetch';
226
+ }
227
+ >();
228
+ let radarFetchRequestSeq = 0;
229
+
230
+ let radarDecodeWorkers: Worker[] = [];
231
+ let radarDecodeRequestId = 0;
232
+ const radarDecodePending = new Map<number, {
233
+ resolve: (frame: DecodedRadarFrame | null) => void;
234
+ reject: (error: Error) => void;
235
+ }>();
236
+ const radarDecodeRequestMeta = new Map<number, { objectKey: string; workerIndex: number }>();
237
+ let radarDecodePrefetchWorkerIndex = PREFETCH_WORKER_START_INDEX;
238
+
239
+ function createDecodeWorker(workerIndex: number): Worker {
240
+ const worker = createRadarDecodeWorker();
241
+ worker.onmessage = (event: MessageEvent<RadarDecodeWorkerResponse>) => {
242
+ const message = event.data;
243
+ if (!message || message.type !== 'DECODE_RESULT') return;
244
+ const pending = radarDecodePending.get(message.requestId);
245
+ if (!pending) return;
246
+ radarDecodePending.delete(message.requestId);
247
+ radarDecodeRequestMeta.delete(message.requestId);
248
+ if (message.error) {
249
+ pending.reject(new Error(message.error));
250
+ return;
251
+ }
252
+ if (
253
+ !message.gateData
254
+ || typeof message.nRays !== 'number'
255
+ || typeof message.nGates !== 'number'
256
+ || typeof message.stationLat !== 'number'
257
+ || typeof message.stationLon !== 'number'
258
+ || typeof message.firstGateKm !== 'number'
259
+ || typeof message.gateWidthKm !== 'number'
260
+ || typeof message.valueScale !== 'number'
261
+ || typeof message.valueOffset !== 'number'
262
+ || !(message.rayBoundariesDeg instanceof Float32Array)
263
+ ) {
264
+ pending.resolve(null);
265
+ return;
266
+ }
267
+ pending.resolve({
268
+ gateData: message.gateData,
269
+ nRays: message.nRays,
270
+ nGates: message.nGates,
271
+ stationLat: message.stationLat,
272
+ stationLon: message.stationLon,
273
+ firstGateKm: message.firstGateKm,
274
+ gateWidthKm: message.gateWidthKm,
275
+ valueScale: message.valueScale,
276
+ valueOffset: message.valueOffset,
277
+ rayBoundariesDeg: message.rayBoundariesDeg,
278
+ });
279
+ };
280
+ worker.onerror = (error) => {
281
+ radarDecodePending.forEach(({ reject }) => reject(new Error(error.message || 'Radar decode worker failed')));
282
+ radarDecodePending.clear();
283
+ radarDecodeRequestMeta.clear();
284
+ };
285
+ return worker;
286
+ }
287
+
288
+ function getRadarDecodeWorkers(): Worker[] {
289
+ if (radarDecodeWorkers.length > 0) return radarDecodeWorkers;
290
+ radarDecodeWorkers = Array.from({ length: DECODE_WORKER_POOL_SIZE }, (_, idx) => createDecodeWorker(idx));
291
+ return radarDecodeWorkers;
292
+ }
293
+
294
+ function getDecodeWorkerForPriority(priority: 'display' | 'prefetch'): { worker: Worker; workerIndex: number } {
295
+ const workers = getRadarDecodeWorkers();
296
+ if (priority === 'display' || workers.length === 1) {
297
+ return { worker: workers[0], workerIndex: 0 };
298
+ }
299
+ const prefetchWorkerCount = Math.max(workers.length - PREFETCH_WORKER_START_INDEX, 1);
300
+ const offset = (radarDecodePrefetchWorkerIndex - PREFETCH_WORKER_START_INDEX + prefetchWorkerCount) % prefetchWorkerCount;
301
+ const workerIndex = PREFETCH_WORKER_START_INDEX + offset;
302
+ radarDecodePrefetchWorkerIndex = PREFETCH_WORKER_START_INDEX + ((offset + 1) % prefetchWorkerCount);
303
+ return { worker: workers[Math.min(workerIndex, workers.length - 1)], workerIndex: Math.min(workerIndex, workers.length - 1) };
304
+ }
305
+
306
+ /** Get physical value at lat/lon from a decoded radar frame. Returns null if outside coverage or no data. */
307
+ function getSampleAtLatLonForRadarFrame(
308
+ frame: DecodedRadarFrame,
309
+ lat: number,
310
+ lon: number,
311
+ smoothPolar: boolean,
312
+ ): { value: number; groundRangeKm: number } | null {
313
+ return sampleNexradFrameAtLatLon(frame, lat, lon, { smoothPolar });
314
+ }
315
+
316
+ export function objectKeyToUrl(objectKey: string, radarSource: 'level2' | 'level3' = 'level2'): string {
317
+ if (objectKey.startsWith('http')) return objectKey;
318
+ const baseUrl = radarSource === 'level3' ? LEVEL3_BASE_URL : LEVEL2_BASE_URL;
319
+ const url = `${baseUrl.replace(/\/$/, '')}/${objectKey}`;
320
+ return radarSource === 'level2' ? cloudFrontUrlWithApiKeyQuery(url) : url;
321
+ }
322
+
323
+ class Level3Raf {
324
+ private offset = 0;
325
+ private view: DataView;
326
+ private bytes: Uint8Array;
327
+
328
+ constructor(input: Uint8Array | ArrayBuffer) {
329
+ this.bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
330
+ this.view = new DataView(this.bytes.buffer, this.bytes.byteOffset, this.bytes.byteLength);
331
+ }
332
+
333
+ getPos(): number { return this.offset; }
334
+ getLength(): number { return this.bytes.length; }
335
+ seek(pos: number): void { this.offset = pos; }
336
+ skip(delta: number): void { this.offset += delta; }
337
+ read(bytes = 1): Uint8Array {
338
+ const out = this.bytes.subarray(this.offset, this.offset + bytes);
339
+ this.offset += bytes;
340
+ return out;
341
+ }
342
+ readByte(): number {
343
+ const v = this.view.getUint8(this.offset);
344
+ this.offset += 1;
345
+ return v;
346
+ }
347
+ readShort(): number {
348
+ const v = this.view.getInt16(this.offset, false);
349
+ this.offset += 2;
350
+ return v;
351
+ }
352
+ readUShort(): number {
353
+ const v = this.view.getUint16(this.offset, false);
354
+ this.offset += 2;
355
+ return v;
356
+ }
357
+ readInt(): number {
358
+ const v = this.view.getInt32(this.offset, false);
359
+ this.offset += 4;
360
+ return v;
361
+ }
362
+ readUInt32(): number {
363
+ const v = this.view.getUint32(this.offset, false);
364
+ this.offset += 4;
365
+ return v;
366
+ }
367
+ readString(bytes: number): string {
368
+ const slice = this.read(bytes);
369
+ return String.fromCharCode(...slice);
370
+ }
371
+ }
372
+
373
+ function concatUint8(a: Uint8Array, b: Uint8Array): Uint8Array {
374
+ const out = new Uint8Array(a.length + b.length);
375
+ out.set(a, 0);
376
+ out.set(b, a.length);
377
+ return out;
378
+ }
379
+
380
+ /**
381
+ * NEXRAD Level-III: range to gate g (m) = g * range_scale_symbology * multiplier[message_code] + first_bin (m).
382
+ * Without multipliers, super-res products (e.g. message 153 reflectivity, 154 velocity) decode ~4× too coarse in range.
383
+ * Aligned with Py-ART / jjhelmus `PRODUCT_RANGE_RESOLUTION`.
384
+ */
385
+ const LEVEL3_PRODUCT_RANGE_SCALE_MUL: Record<number, number> = {
386
+ 19: 1, 20: 2, 25: 0.25, 27: 1, 28: 0.25, 30: 1, 32: 1, 34: 1, 56: 1,
387
+ 57: 1000,
388
+ 78: 1, 79: 1, 80: 1, 94: 1, 99: 0.25,
389
+ 134: 1000, 135: 1000, 138: 1,
390
+ 153: 0.25, 154: 0.25, 155: 0.25, 159: 0.25, 161: 0.25, 163: 0.25, 165: 0.25,
391
+ 166: 0.25, 167: 0.25, 168: 0.25,
392
+ 169: 1000, 170: 1000, 171: 1000, 172: 1000, 173: 1000, 174: 1000, 175: 1000,
393
+ /** Py-ART `PRODUCT_RANGE_RESOLUTION`: 176/177 are digital-style radials (0.25×), not 1 km like 169–175. */
394
+ 176: 0.25, 177: 0.25,
395
+ 181: 150, 182: 150, 186: 300,
396
+ };
397
+
398
+ /**
399
+ * MetPy `GenericDigitalMapper` products: symbology levels map as (L - offset) / scale
400
+ * where scale/offset are big-endian float32 from PDB thr1–thr2 and thr3–thr4 (see MetPy `nexrad.float32`).
401
+ * DAA (170) was incorrectly decoded as min + L*inc, producing bogus inches and fan artifacts.
402
+ */
403
+ const LEVEL3_GENERIC_DIGITAL_PRODUCT_CODES = new Set([
404
+ 159, 161, 163, 167, 168,
405
+ 170, 172, 173, 174, 175, 176,
406
+ ]);
407
+
408
+ /**
409
+ * NWS digital accumulation (MetPy `GenericDigitalMapper`) carries **depth in millimeters**.
410
+ * Layer colormap / readout expect **inches** (see `nexradLevel3Products` precip packing).
411
+ */
412
+ const LEVEL3_GENERIC_DIGITAL_DEPTH_MM_TO_IN_PRODUCTS = new Set([170, 172, 173, 174, 175]);
413
+
414
+ /**
415
+ * MetPy `DigitalHMCMapper`: digital / hybrid hydrometeor classification (165, 177).
416
+ * 8-bit radial (packet 0x0010): `lut[i] = i // 10` is the **1-based** NWS category (1=BI … 6=RA … 12=GH).
417
+ * Fill colormap / `NEXRAD_HYDROMETEOR_CLASS_LABELS` use **0-based** indices in the same order → `floor(level/10) - 1`.
418
+ * 150 = range-fold.
419
+ * 4-bit raster / RLE (0xBA07, 0xAF1F): levels 2–15 → 0-based class index `level − 2`.
420
+ */
421
+ const LEVEL3_DIGITAL_HMC_PRODUCT_CODES = new Set([165, 177]);
422
+
423
+ function level3PhysicalFromDigitalHmc(level: number, precision: 'byte' | 'nibble'): number | null {
424
+ if (!Number.isFinite(level)) return null;
425
+ if (precision === 'nibble') {
426
+ if (level < 2 || level > 15) return null;
427
+ return level - 2;
428
+ }
429
+ if (level === 150) return null;
430
+ if (level < 10 || level >= 256) return null;
431
+ const cls = Math.floor(level / 10) - 1;
432
+ if (cls < 0 || cls > 11) return null;
433
+ return cls;
434
+ }
435
+
436
+ /**
437
+ * Per-product mm calibration before ÷25.4 for generic digital accumulation (170, 172–175).
438
+ * 170 (DAA) field‑checked at 0.125. 172–175 were too low at 0.125, too high at 1.0; 0.5 still ~2× high
439
+ * in the field → 0.25 for those products (halve again if needed).
440
+ */
441
+ function level3GenericDigitalAccumMmFactor(productCode: number): number {
442
+ switch (productCode) {
443
+ case 170: // DAA — 1‑hour digital accumulation array
444
+ return 0.125;
445
+ case 172: // Digital storm total accumulation (generic)
446
+ case 173: // DU3 — user‑selectable (e.g. 3‑hour)
447
+ case 174: // Digital one‑hour difference accumulation
448
+ case 175: // Digital storm total difference accumulation
449
+ return 0.25;
450
+ default:
451
+ return 1;
452
+ }
453
+ }
454
+
455
+ type Level3GenericDigitalParams = {
456
+ scale: number;
457
+ offset: number;
458
+ leadingFlags: number;
459
+ trailingFlags: number;
460
+ maxDataVal: number;
461
+ };
462
+
463
+ function level3UnpackFloat32FromThresholdPair(short0: number, short1: number): number {
464
+ const buf = new ArrayBuffer(4);
465
+ const view = new DataView(buf);
466
+ view.setUint16(0, short0 & 0xffff, false);
467
+ view.setUint16(2, short1 & 0xffff, false);
468
+ return view.getFloat32(0, false);
469
+ }
470
+
471
+ function parseLevel3GenericDigitalParams(dep: number[]): Level3GenericDigitalParams | null {
472
+ /**
473
+ * `raf.read(48)` is read **after** PDB fields through `el_num`, so this 24-short block is:
474
+ * dep[0]=dep3, dep[1]=thr1, dep[2]=thr2, … dep[16]=thr16, dep[17]=dep4, …
475
+ * MetPy GenericDigitalMapper: scale=float32(thr1,thr2), offset=float32(thr3,thr4),
476
+ * max=thr6, leading=thr7, trailing=thr8 → dep indices 1..8.
477
+ */
478
+ if (dep.length < 9) return null;
479
+ const scale = level3UnpackFloat32FromThresholdPair(dep[1], dep[2]);
480
+ const offset = level3UnpackFloat32FromThresholdPair(dep[3], dep[4]);
481
+ const maxDataVal = dep[6] & 0xffff;
482
+ const leadingFlags = dep[7];
483
+ const trailingFlags = dep[8];
484
+ if (!Number.isFinite(scale) || scale === 0 || !Number.isFinite(offset)) return null;
485
+ if (leadingFlags < 0 || leadingFlags > 255 || trailingFlags < 0 || trailingFlags > 255) return null;
486
+ if (maxDataVal < leadingFlags) return null;
487
+ return { scale, offset, leadingFlags, trailingFlags, maxDataVal };
488
+ }
489
+
490
+ function level3PhysicalFromGenericDigital(
491
+ level: number,
492
+ p: Level3GenericDigitalParams,
493
+ productCode: number,
494
+ ): number | null {
495
+ if (!Number.isFinite(level)) return null;
496
+ if (level < p.leadingFlags) return null;
497
+ const upper = p.maxDataVal - p.trailingFlags;
498
+ if (level > upper) return null;
499
+ let out = (level - p.offset) / p.scale;
500
+ if (!Number.isFinite(out)) return null;
501
+ if (LEVEL3_GENERIC_DIGITAL_DEPTH_MM_TO_IN_PRODUCTS.has(productCode)) {
502
+ out *= level3GenericDigitalAccumMmFactor(productCode);
503
+ out /= 25.4;
504
+ }
505
+ return out;
506
+ }
507
+
508
+ type Level3SymbologyMode =
509
+ | 'reflectivity'
510
+ /**
511
+ * MetPy `DigitalMapper` linear products (precip inches, etc.): phys = min + (L - 2) * inc.
512
+ * Kept separate from `reflectivity` so legacy/category-style symbology is unchanged.
513
+ */
514
+ | 'standard_digital'
515
+ /** Packet 0x0010: full-byte data levels; NEXRAD digital velocity uses threshold + (L-2)*inc. */
516
+ | 'digital_velocity_byte'
517
+ /**
518
+ * Packet 0xAF1F: 4-bit levels in RLE nibbles. Threshold+increment from the PDB only spans a
519
+ * small negative band (~−64…−57 m/s), so values fall entirely outside typical −30…+30 m/s
520
+ * colormaps and the map looks empty. Use a centered mapping (0 m/s ≈ level 8, ~4 m/s per step).
521
+ */
522
+ | 'digital_velocity_rle4';
523
+
524
+ /**
525
+ * Map symbology data level to a physical value (dBZ, m/s, etc.) depending on product/packet mode.
526
+ */
527
+ const LEVEL3_MSG134_PRODUCTS = new Set([134]);
528
+
529
+ // Replace the reflectivity branch in level3PhysicalFromDataLevel:
530
+ function level3PhysicalFromDataLevel(
531
+ level: number,
532
+ minimumDataValue: number,
533
+ dataIncrement: number,
534
+ mode: Level3SymbologyMode,
535
+ msg134Params?: { linearScale: number; linearOffset: number; logStart: number; logScale: number; logOffset: number } | null,
536
+ ): number | null {
537
+ if (!Number.isFinite(level)) return null;
538
+ if (mode === 'digital_velocity_byte') {
539
+ if (level < 2) return null;
540
+ return minimumDataValue + (level - 2) * dataIncrement;
541
+ }
542
+ if (mode === 'digital_velocity_rle4') {
543
+ if (level < 2) return null;
544
+ const MS_PER_STEP = 4;
545
+ const CENTER_LEVEL = 8;
546
+ return (level - CENTER_LEVEL) * MS_PER_STEP;
547
+ }
548
+ // Product 134 (Digital VIL): piecewise linear + log, params from threshold halfwords
549
+ if (msg134Params) {
550
+ if (level < 2) return null;
551
+ const { linearScale, linearOffset, logStart, logScale, logOffset } = msg134Params;
552
+ if (level < logStart) {
553
+ return (level - linearOffset) / linearScale;
554
+ } else {
555
+ return Math.exp((level - logOffset) / logScale);
556
+ }
557
+ }
558
+ if (mode === 'standard_digital') {
559
+ if (level < 2) return null;
560
+ return minimumDataValue + (level - 2) * dataIncrement;
561
+ }
562
+ if (level <= 1) return null;
563
+ return minimumDataValue + level * dataIncrement;
564
+ }
565
+
566
+ function parseLevel3Packet0010(
567
+ raf: Level3Raf,
568
+ minimumDataValue: number,
569
+ dataIncrement: number,
570
+ levelMode: Level3SymbologyMode,
571
+ l3ProductCode: number,
572
+ msg134Params?: { linearScale: number; linearOffset: number; logStart: number; logScale: number; logOffset: number } | null,
573
+ eetThresholds?: { dataMask: number; scale: number; offset: number } | null,
574
+ genericDigital?: Level3GenericDigitalParams | null,
575
+ ) {
576
+ const packetCode = raf.readUShort();
577
+ if (packetCode !== 16) throw new Error(`Unexpected packet code ${packetCode}`);
578
+ const firstBinMeters = raf.readShort();
579
+ let numberBins = raf.readShort();
580
+ raf.readShort(); // iSweepCenter
581
+ raf.readShort(); // jSweepCenter
582
+ const rangeScaleRaw = raf.readShort();
583
+ const numberRadials = raf.readShort();
584
+ // Py-ART: packet 16 sometimes has nbins ≠ per-radial nbytes; true gate count follows first radial header.
585
+ if (numberRadials > 0) {
586
+ const peek = raf.getPos();
587
+ const nbytesFirst = raf.readShort();
588
+ raf.seek(peek);
589
+ if (nbytesFirst !== numberBins) {
590
+ numberBins = nbytesFirst;
591
+ }
592
+ }
593
+ const radials: Array<{ startAngle: number; angleDelta: number; bins: Array<number | null> }> = [];
594
+ for (let r = 0; r < numberRadials; r++) {
595
+ const bytesInRadial = raf.readShort();
596
+ const startAngle = raf.readShort() / 10;
597
+ const angleDelta = raf.readShort() / 10;
598
+ const bins: Array<number | null> = [];
599
+ for (let i = 0; i < numberBins; i++) {
600
+ const level = raf.readByte();
601
+ if (eetThresholds) {
602
+ bins.push(
603
+ level3EetHeightKmFromLevel(level, eetThresholds.dataMask, eetThresholds.scale, eetThresholds.offset),
604
+ );
605
+ } else if (LEVEL3_DIGITAL_HMC_PRODUCT_CODES.has(l3ProductCode)) {
606
+ bins.push(level3PhysicalFromDigitalHmc(level, 'byte'));
607
+ } else if (genericDigital) {
608
+ bins.push(level3PhysicalFromGenericDigital(level, genericDigital, l3ProductCode));
609
+ } else {
610
+ bins.push(level3PhysicalFromDataLevel(level, minimumDataValue, dataIncrement, levelMode, msg134Params));
611
+ }
612
+ }
613
+ if (bytesInRadial > numberBins) raf.skip(bytesInRadial - numberBins);
614
+ radials.push({ startAngle, angleDelta, bins });
615
+ }
616
+ return { firstBinMeters, numberBins, rangeScaleRaw, numberRadials, radials };
617
+ }
618
+ function parseLevel3PacketAF1F(
619
+ raf: Level3Raf,
620
+ minimumDataValue: number,
621
+ dataIncrement: number,
622
+ levelMode: Level3SymbologyMode,
623
+ l3ProductCode: number,
624
+ msg134Params?: { linearScale: number; linearOffset: number; logStart: number; logScale: number; logOffset: number } | null,
625
+ eetThresholds?: { dataMask: number; scale: number; offset: number } | null,
626
+ genericDigital?: Level3GenericDigitalParams | null,
627
+ ) {
628
+ const packetCode = raf.readUShort();
629
+ if (packetCode !== 0xAF1F) throw new Error(`Unexpected packet code ${packetCode}`);
630
+ const firstBinMeters = raf.readShort();
631
+ const numberBins = raf.readShort();
632
+ raf.readShort(); // iSweepCenter
633
+ raf.readShort(); // jSweepCenter
634
+ const rangeScaleRaw = raf.readShort();
635
+ const numRadials = raf.readShort();
636
+ const radials: Array<{ startAngle: number; angleDelta: number; bins: Array<number | null> }> = [];
637
+ for (let r = 0; r < numRadials; r++) {
638
+ const rleBytes = raf.readShort() * 2;
639
+ const startAngle = raf.readShort() / 10;
640
+ const angleDelta = raf.readShort() / 10;
641
+ const bins: Array<number | null> = [];
642
+ for (let i = 0; i < rleBytes; i++) {
643
+ const byte = raf.readByte();
644
+ const run = byte >> 4;
645
+ const level = byte & 0x0f;
646
+ const value = eetThresholds
647
+ ? level3EetHeightKmFromLevel(level, eetThresholds.dataMask, eetThresholds.scale, eetThresholds.offset)
648
+ : LEVEL3_DIGITAL_HMC_PRODUCT_CODES.has(l3ProductCode)
649
+ ? level3PhysicalFromDigitalHmc(level, 'nibble')
650
+ : genericDigital
651
+ ? level3PhysicalFromGenericDigital(level, genericDigital, l3ProductCode)
652
+ : level3PhysicalFromDataLevel(level, minimumDataValue, dataIncrement, levelMode, msg134Params);
653
+ for (let j = 0; j < run; j++) bins.push(value);
654
+ }
655
+ if (bins.length > numberBins) bins.length = numberBins;
656
+ while (bins.length < numberBins) bins.push(null);
657
+ radials.push({ startAngle, angleDelta, bins });
658
+ }
659
+ return { firstBinMeters, numberBins, rangeScaleRaw, numberRadials: numRadials, radials };
660
+ }
661
+
662
+ function buildRayBoundariesFromLevel3Radials(radials: any[]): Float32Array {
663
+ const n = radials.length;
664
+ const boundaries = new Float32Array(n + 1);
665
+ let prev = -Infinity;
666
+ for (let i = 0; i < n; i++) {
667
+ const startAngle = Number(radials[i]?.startAngle);
668
+ const angleDelta = Number(radials[i]?.angleDelta);
669
+ const start = Number.isFinite(startAngle) ? startAngle : 0;
670
+ const delta = Number.isFinite(angleDelta) && angleDelta > 0 ? angleDelta : 1;
671
+ let lower = start - delta * 0.5;
672
+ while (lower <= prev) lower += 360;
673
+ boundaries[i] = lower;
674
+ prev = lower;
675
+ }
676
+ const lastDeltaRaw = Number(radials[n - 1]?.angleDelta);
677
+ const lastDelta = Number.isFinite(lastDeltaRaw) && lastDeltaRaw > 0 ? lastDeltaRaw : 1;
678
+ boundaries[n] = boundaries[n - 1] + lastDelta;
679
+ return boundaries;
680
+ }
681
+
682
+ function encodeSigned16ToHiLo(value: number): [number, number] {
683
+ const signed = Math.max(-32768, Math.min(32767, value));
684
+ const raw = signed < 0 ? signed + 65536 : signed;
685
+ return [(raw >> 8) & 0xff, raw & 0xff];
686
+ }
687
+
688
+ function level3RadialSpatialScore(p: { numberBins: number; numberRadials: number } | null | undefined): number {
689
+ if (!p) return -1;
690
+ const nb = Number(p.numberBins);
691
+ const nr = Number(p.numberRadials);
692
+ if (!Number.isFinite(nb) || !Number.isFinite(nr) || nb <= 0 || nr <= 0) return -1;
693
+ return nb * nr;
694
+ }
695
+
696
+ type Level3RadialPick = { kind: 0x10 | 0xaf1f; packet: any };
697
+
698
+ function level3RadialPickIsBetter(cand: Level3RadialPick, prev: Level3RadialPick | null): boolean {
699
+ const sC = level3RadialSpatialScore(cand.packet);
700
+ const sP = level3RadialSpatialScore(prev?.packet);
701
+ if (sC > sP) return true;
702
+ if (sC < sP) return false;
703
+ if (!prev) return true;
704
+ /** Same footprint: prefer full-byte digital radial (0x10) over 4-bit RLE (0xAF1F) for smoother velocity. */
705
+ if (cand.kind === 0x10 && prev.kind === 0xaf1f) return true;
706
+ return false;
707
+ }
708
+
709
+ /**
710
+ * Advance past a non-radial symbology packet so later packets in the same layer can be read.
711
+ * Caller must position RAF at the packet code; we consume code + body.
712
+ * Uniform text (1, 8): u16 payload length then that many bytes (MetPy / ICD).
713
+ */
714
+ function trySkipLevel3OverlayPacket(raf: Level3Raf, packetCode: number, layerEnd: number): boolean {
715
+ const pos0 = raf.getPos();
716
+ if (packetCode !== 1 && packetCode !== 2 && packetCode !== 8) return false;
717
+ if (layerEnd - pos0 < 4) return false;
718
+ const codeRead = raf.readUShort();
719
+ if (codeRead !== packetCode) {
720
+ raf.seek(pos0);
721
+ return false;
722
+ }
723
+ const payloadBytes = raf.readUShort();
724
+ if (payloadBytes > layerEnd - raf.getPos()) {
725
+ raf.seek(pos0);
726
+ return false;
727
+ }
728
+ raf.skip(payloadBytes);
729
+ return true;
730
+ }
731
+
732
+ /** Symbology packet 0xBA07: raster image (RLE rows). MetPy `packet_map[0xba07]`. Used by NVL (57), EET, etc. */
733
+ const LEVEL3_PACKET_RASTER_BA07 = 0xba07;
734
+ /** First u32 after packet code; MetPy asserts this for raster payload. */
735
+ const LEVEL3_RASTER_INNER_CODE = 0x800000c0;
736
+
737
+ function unpackLevel3RasterRle(rowBytes: Uint8Array): number[] {
738
+ const unpacked: number[] = [];
739
+ for (let i = 0; i < rowBytes.length; i++) {
740
+ const b = rowBytes[i];
741
+ const run = b >> 4;
742
+ const val = b & 0x0f;
743
+ for (let j = 0; j < run; j++) unpacked.push(val);
744
+ }
745
+ return unpacked;
746
+ }
747
+
748
+ function level3CombineRasterScale(int16: number, frac16: number): number {
749
+ const i = Math.abs(int16);
750
+ const f = Math.abs(frac16) / 10000;
751
+ const s = i + f;
752
+ return s > 0 ? s : 1;
753
+ }
754
+
755
+ type Level3ParsedRaster = {
756
+ iStart: number;
757
+ jStart: number;
758
+ cellKmX: number;
759
+ cellKmY: number;
760
+ startXKm: number;
761
+ startYKm: number;
762
+ nCols: number;
763
+ nRows: number;
764
+ rows: number[][];
765
+ };
766
+
767
+ /**
768
+ * Parse raster symbology (packet 0xBA07). RAF must be positioned at the packet code (first u16).
769
+ * Leaves RAF at `layerEnd` on success or after skipping on failure.
770
+ */
771
+ function parseLevel3PacketRasterBA07(raf: Level3Raf, layerEnd: number, objectKey: string): Level3ParsedRaster | null {
772
+ const pos0 = raf.getPos();
773
+ const packetCode = raf.readUShort();
774
+ if (packetCode !== LEVEL3_PACKET_RASTER_BA07) {
775
+ raf.seek(pos0);
776
+ return null;
777
+ }
778
+ const room = layerEnd - raf.getPos();
779
+ if (room < 20) {
780
+ raf.seek(layerEnd);
781
+ return null;
782
+ }
783
+ const inner = raf.readUInt32();
784
+ if (inner !== LEVEL3_RASTER_INNER_CODE) {
785
+ raf.seek(layerEnd);
786
+ return null;
787
+ }
788
+ const iStart = raf.readShort();
789
+ const jStart = raf.readShort();
790
+ const xscaleInt = raf.readShort();
791
+ const xscaleFrac = raf.readShort();
792
+ const yscaleInt = raf.readShort();
793
+ const yscaleFrac = raf.readShort();
794
+ const numRows = raf.readUShort();
795
+ const packing = raf.readUShort();
796
+ if (packing !== 2) {
797
+ console.warn('[aguacero][nexrad-l3][raster-ba07-packing]', { objectKey, packing, hint: 'MetPy expects packing=2; raster parse skipped for this layer' });
798
+ raf.seek(layerEnd);
799
+ return null;
800
+ }
801
+ if (numRows <= 0 || numRows > 2000 || raf.getPos() > layerEnd) {
802
+ raf.seek(layerEnd);
803
+ return null;
804
+ }
805
+ const cellKmX = level3CombineRasterScale(xscaleInt, xscaleFrac);
806
+ const cellKmY = level3CombineRasterScale(yscaleInt, yscaleFrac);
807
+ /** Match MetPy: start corner from integer scales only (see `metpy.io.nexrad` `_unpack_packet_raster_data`). */
808
+ const startXKm = iStart * xscaleInt;
809
+ const startYKm = jStart * yscaleInt;
810
+ const rows: number[][] = [];
811
+ let nCols = -1;
812
+ for (let r = 0; r < numRows; r++) {
813
+ if (raf.getPos() + 2 > layerEnd) {
814
+ raf.seek(layerEnd);
815
+ return null;
816
+ }
817
+ const rowNumBytes = raf.readUShort();
818
+ if (rowNumBytes === 0 || raf.getPos() + rowNumBytes > layerEnd) {
819
+ raf.seek(layerEnd);
820
+ return null;
821
+ }
822
+ const rowBytes = raf.read(rowNumBytes);
823
+ const expanded = unpackLevel3RasterRle(rowBytes);
824
+ if (nCols < 0) nCols = expanded.length;
825
+ else if (expanded.length !== nCols) {
826
+ raf.seek(layerEnd);
827
+ return null;
828
+ }
829
+ rows.push(expanded);
830
+ }
831
+ if (nCols <= 0) {
832
+ raf.seek(layerEnd);
833
+ return null;
834
+ }
835
+ if (raf.getPos() < layerEnd) {
836
+ raf.seek(layerEnd);
837
+ }
838
+ return {
839
+ iStart,
840
+ jStart,
841
+ cellKmX,
842
+ cellKmY,
843
+ startXKm,
844
+ startYKm,
845
+ nCols,
846
+ nRows: numRows,
847
+ rows,
848
+ };
849
+ }
850
+
851
+ function level3RasterPdbScales(
852
+ decodeMode: Level3DecodeMode,
853
+ productCode: number,
854
+ minimumDataValue: number,
855
+ dataIncrement: number,
856
+ depProductDescriptionShorts: number[] | null,
857
+ objectKey: string,
858
+ genericDigital: Level3GenericDigitalParams | null,
859
+ ): {
860
+ minT: number;
861
+ dataInc: number;
862
+ symMode: Level3SymbologyMode;
863
+ vilLevelThresholds: number[] | null;
864
+ } {
865
+ const minBad =
866
+ !Number.isFinite(minimumDataValue) || Math.abs(minimumDataValue) > 500;
867
+ const incBad = !Number.isFinite(dataIncrement) || dataIncrement <= 0;
868
+ const bad = minBad || incBad;
869
+ let minT = minimumDataValue;
870
+ let dataInc = dataIncrement;
871
+ let vilLevelThresholds: number[] | null = null;
872
+ /**
873
+ * NVL often encodes an invalid linear min (e.g. -32766 sentinel), while real level thresholds
874
+ * are present in product-description shorts 33..46 (values like 5,10,...,70 kg/m²).
875
+ * Prefer those thresholds when present so data-level 2..15 map to physically meaningful VIL.
876
+ */
877
+ if (
878
+ decodeMode === 'vil' &&
879
+ productCode === 57 &&
880
+ Array.isArray(depProductDescriptionShorts) &&
881
+ depProductDescriptionShorts.length >= 17 &&
882
+ depProductDescriptionShorts[1] <= -30000
883
+ ) {
884
+ const byLevel = new Array<number>(16).fill(Number.NaN);
885
+ let validCount = 0;
886
+ for (let level = 2; level <= 15; level++) {
887
+ const raw = depProductDescriptionShorts[level + 1];
888
+ if (Number.isFinite(raw) && raw > 0) {
889
+ byLevel[level] = raw;
890
+ validCount += 1;
891
+ }
892
+ }
893
+ if (validCount >= 8) {
894
+ vilLevelThresholds = byLevel;
895
+ }
896
+ }
897
+ if (bad) {
898
+ /** VIL: PDB min is often garbage; increment 0.1 is still valid. phys = minT + level*inc with level≥2 → use minT = -inc so level 2 → +inc (e.g. 0.1). */
899
+ if (decodeMode === 'vil' && minBad && !incBad && dataIncrement >= 0.05 && dataIncrement <= 2) {
900
+ minT = -dataIncrement;
901
+ dataInc = dataIncrement;
902
+ } else if (decodeMode === 'vil') {
903
+ minT = 0;
904
+ dataInc = 1;
905
+ } else if (decodeMode === 'precip') {
906
+ minT = 0;
907
+ dataInc = 0.25;
908
+ } else if (decodeMode === 'tops_kft') {
909
+ minT = 0;
910
+ dataInc = 1;
911
+ } else if (decodeMode === 'categorical') {
912
+ minT = 0;
913
+ dataInc = 1;
914
+ } else if (decodeMode === 'generic_physical') {
915
+ minT = -2;
916
+ dataInc = 0.05;
917
+ } else {
918
+ minT = -32;
919
+ dataInc = 0.5;
920
+ }
921
+ }
922
+ const symMode: Level3SymbologyMode =
923
+ decodeMode === 'velocity'
924
+ ? 'digital_velocity_byte'
925
+ : decodeMode === 'precip' && !genericDigital
926
+ ? 'standard_digital'
927
+ : 'reflectivity';
928
+ return { minT, dataInc, symMode, vilLevelThresholds };
929
+ }
930
+
931
+ /** Resample Cartesian raster (km east/north from radar) into the polar texture the Mapbox radar shader expects. */
932
+ function level3RasterToPolarFrame(
933
+ raster: Level3ParsedRaster,
934
+ stationLat: number,
935
+ stationLon: number,
936
+ decodeMode: Level3DecodeMode,
937
+ minT: number,
938
+ dataInc: number,
939
+ symMode: Level3SymbologyMode,
940
+ vilLevelThresholds: number[] | null,
941
+ eetThresholds: { dataMask: number; scale: number; offset: number } | null,
942
+ valueScale: number,
943
+ valueOffset: number,
944
+ objectKey: string,
945
+ genericDigital: Level3GenericDigitalParams | null = null,
946
+ l3ProductCode = 0,
947
+ ): DecodedRadarFrame | null {
948
+ const { rows, nCols, nRows, startXKm, startYKm, cellKmX, cellKmY, iStart, jStart } = raster;
949
+ const widthKm = nCols * cellKmX;
950
+ const heightKm = nRows * cellKmY;
951
+ /**
952
+ * ICD raster I/J origin 0 with a square grid is a box **centered** on the radar (±half extent in km).
953
+ * Treating (0,0) as the southwest corner only covers the northeast quadrant — data appears displaced
954
+ * toward +x,+y (e.g. Atlantic for East Coast sites) and few polar samples hit the grid.
955
+ */
956
+ let originXK = startXKm;
957
+ let originYK = startYKm;
958
+ if (iStart === 0 && jStart === 0 && nCols === nRows && nCols >= 16) {
959
+ originXK = startXKm - widthKm / 2;
960
+ originYK = startYKm - heightKm / 2;
961
+ }
962
+ const xMin = originXK;
963
+ const xMax = originXK + widthKm;
964
+ const yMin = originYK;
965
+ const yMax = originYK + heightKm;
966
+ const corner = (x: number, y: number) => Math.hypot(x, y);
967
+ let maxR = corner(xMin, yMin);
968
+ maxR = Math.max(maxR, corner(xMax, yMin));
969
+ maxR = Math.max(maxR, corner(xMin, yMax));
970
+ maxR = Math.max(maxR, corner(xMax, yMax));
971
+ maxR = Math.min(460, Math.max(50, maxR * 1.02));
972
+
973
+ const nRays = 720;
974
+ const gateWidthKm = 0.5;
975
+ const nGates = Math.min(920, Math.max(64, Math.ceil(maxR / gateWidthKm)));
976
+ const firstGateKm = 0;
977
+
978
+ const gateData = new Uint8Array(nRays * nGates * 2);
979
+ const rayBoundariesDeg = new Float32Array(nRays + 1);
980
+ const deltaAz = 360 / nRays;
981
+ for (let i = 0; i <= nRays; i++) {
982
+ rayBoundariesDeg[i] = i * deltaAz - deltaAz * 0.5;
983
+ }
984
+
985
+ let samples = 0;
986
+ let nodata = 0;
987
+ for (let rayIdx = 0; rayIdx < nRays; rayIdx++) {
988
+ const azDeg = (rayIdx + 0.5) * deltaAz;
989
+ const azRad = (azDeg * Math.PI) / 180;
990
+ const sinAz = Math.sin(azRad);
991
+ const cosAz = Math.cos(azRad);
992
+ for (let g = 0; g < nGates; g++) {
993
+ const rKm = firstGateKm + (g + 0.5) * gateWidthKm;
994
+ const xKm = rKm * sinAz;
995
+ const yKm = rKm * cosAz;
996
+ const fc = (xKm - originXK) / cellKmX;
997
+ const fr = (yKm - originYK) / cellKmY;
998
+ const c = Math.floor(fc);
999
+ const r = Math.floor(fr);
1000
+ let level: number | null = null;
1001
+ if (c >= 0 && c < nCols && r >= 0 && r < nRows) {
1002
+ level = rows[r][c] ?? null;
1003
+ }
1004
+ let phys: number | null = null;
1005
+ if (level != null && Number.isFinite(level)) {
1006
+ if (
1007
+ decodeMode === 'vil' &&
1008
+ vilLevelThresholds &&
1009
+ level >= 0 &&
1010
+ level < vilLevelThresholds.length &&
1011
+ Number.isFinite(vilLevelThresholds[level])
1012
+ ) {
1013
+ phys = vilLevelThresholds[level];
1014
+ } else if (decodeMode === 'tops_kft' && eetThresholds) {
1015
+ phys = level3EetHeightKmFromLevel(
1016
+ level,
1017
+ eetThresholds.dataMask,
1018
+ eetThresholds.scale,
1019
+ eetThresholds.offset,
1020
+ );
1021
+ } else if (LEVEL3_DIGITAL_HMC_PRODUCT_CODES.has(l3ProductCode)) {
1022
+ phys = level3PhysicalFromDigitalHmc(level, 'nibble');
1023
+ } else if (genericDigital) {
1024
+ phys = level3PhysicalFromGenericDigital(level, genericDigital, l3ProductCode);
1025
+ } else {
1026
+ phys = level3PhysicalFromDataLevel(level, minT, dataInc, symMode);
1027
+ }
1028
+ }
1029
+ if (phys == null || !Number.isFinite(phys)) {
1030
+ nodata++;
1031
+ const offset = (rayIdx * nGates + g) * 2;
1032
+ gateData[offset] = 0x80;
1033
+ gateData[offset + 1] = 0x00;
1034
+ continue;
1035
+ }
1036
+ samples++;
1037
+ const rawSigned = Math.round((phys - valueOffset) / valueScale);
1038
+ const [hi, lo] = encodeSigned16ToHiLo(rawSigned);
1039
+ const offset = (rayIdx * nGates + g) * 2;
1040
+ gateData[offset] = hi;
1041
+ gateData[offset + 1] = lo;
1042
+ }
1043
+ }
1044
+ if (samples < 8) {
1045
+ console.warn('[aguacero][nexrad-l3][raster-nearly-empty]', { objectKey, samples, nodata });
1046
+ return null;
1047
+ }
1048
+ return {
1049
+ gateData,
1050
+ nRays,
1051
+ nGates,
1052
+ stationLat,
1053
+ stationLon,
1054
+ firstGateKm,
1055
+ gateWidthKm,
1056
+ valueScale,
1057
+ valueOffset,
1058
+ rayBoundariesDeg,
1059
+ };
1060
+ }
1061
+
1062
+ /**
1063
+ * Validate Level-III Product Description Block (PDB) start at `pos`.
1064
+ *
1065
+ * Two valid layouts are seen in the wild:
1066
+ * 1) Divider-first PDB (`0xFFFF`, then lat/lon)
1067
+ * 2) Message-header-first (`productCode`, ... , divider at +18, then lat/lon)
1068
+ *
1069
+ * Use strict plausibility checks so we don't lock onto random halfwords.
1070
+ */
1071
+ function isValidLevel3PdbStart(bytes: Uint8Array, pos: number): boolean {
1072
+ const len = bytes.length;
1073
+ if (pos + 14 > len) return false;
1074
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
1075
+ const latLonPlausible = (latMilli: number, lonMilli: number): boolean => {
1076
+ const lat = latMilli / 1000;
1077
+ const lon = lonMilli / 1000;
1078
+ return (
1079
+ lat >= -60 &&
1080
+ lat <= 72 &&
1081
+ lon >= -180 &&
1082
+ lon <= 180 &&
1083
+ Math.abs(lat) > 1 &&
1084
+ Math.abs(lon) > 1
1085
+ );
1086
+ };
1087
+
1088
+ // Layout A: divider-first
1089
+ if (bytes[pos] === 0xff && bytes[pos + 1] === 0xff) {
1090
+ const latMilli = view.getInt32(pos + 2, false);
1091
+ const lonMilli = view.getInt32(pos + 6, false);
1092
+ return latLonPlausible(latMilli, lonMilli);
1093
+ }
1094
+
1095
+ // Layout B: message-header-first (divider at +18, lat/lon at +20/+24)
1096
+ if (pos + 28 <= len) {
1097
+ const productCode = view.getUint16(pos, false);
1098
+ const divider = view.getInt16(pos + 18, false);
1099
+ if (productCode >= 1 && productCode <= 499 && divider === -1) {
1100
+ const latMilli = view.getInt32(pos + 20, false);
1101
+ const lonMilli = view.getInt32(pos + 24, false);
1102
+ if (latLonPlausible(latMilli, lonMilli)) return true;
1103
+ }
1104
+ }
1105
+
1106
+ return false;
1107
+ }
1108
+
1109
+ /**
1110
+ * PDB byte offset within the Level-III file (after optional SDUS trim).
1111
+ * NOAAPort/Unidata objects use a short ASCII preamble (SDUS line + product/site line) then binary.
1112
+ * A naive scan for 0xFFFF+lat/lon matches an *internal* digital divider (e.g. DVL at byte ~48) and
1113
+ * mis-parses symbology offsets — only consider candidates that start immediately after a line break.
1114
+ */
1115
+ function findLevel3PdbByteOffset(bytes: Uint8Array): number {
1116
+ const len = bytes.length;
1117
+ let pos = 0;
1118
+ while (pos < len) {
1119
+ let lineEnd = pos;
1120
+ while (lineEnd < len && bytes[lineEnd] !== 0x0a && bytes[lineEnd] !== 0x0d) lineEnd++;
1121
+ while (lineEnd < len && (bytes[lineEnd] === 0x0a || bytes[lineEnd] === 0x0d)) lineEnd++;
1122
+ const nextStart = lineEnd;
1123
+ if (nextStart >= len - 2) break;
1124
+ if (isValidLevel3PdbStart(bytes, nextStart)) return nextStart;
1125
+ pos = nextStart;
1126
+ }
1127
+ if (len < 30) return 30;
1128
+ const end = Math.min(len - 14, 900);
1129
+ for (let i = 16; i <= end; i++) {
1130
+ if (isValidLevel3PdbStart(bytes, i)) return i;
1131
+ }
1132
+ return 30;
1133
+ }
1134
+
1135
+ /** Decode Level-III radial raster (symbology block, packets 0x0010 / 0xAF1F) or raster image 0xBA07. */
1136
+ function decodeLevel3RasterProduct(buffer: ArrayBuffer, objectKey: string, radarVariable: string): DecodedRadarFrame | null {
1137
+ try {
1138
+ const bytes = new Uint8Array(buffer);
1139
+ const marker = [83, 68, 85, 83]; // SDUS
1140
+ let startIndex = 0;
1141
+ for (let i = 0; i <= bytes.length - marker.length; i++) {
1142
+ if (
1143
+ bytes[i] === marker[0] &&
1144
+ bytes[i + 1] === marker[1] &&
1145
+ bytes[i + 2] === marker[2] &&
1146
+ bytes[i + 3] === marker[3]
1147
+ ) {
1148
+ startIndex = i;
1149
+ break;
1150
+ }
1151
+ }
1152
+ const trimmed = startIndex > 0 ? bytes.subarray(startIndex) : bytes;
1153
+ const raf = new Level3Raf(trimmed);
1154
+
1155
+ const fileType = raf.readString(6);
1156
+ if (!fileType.startsWith('SDUS')) {
1157
+ throw new Error(`Unexpected Level3 header ${fileType}`);
1158
+ }
1159
+ const pdbByteOffset = findLevel3PdbByteOffset(trimmed);
1160
+ raf.seek(pdbByteOffset);
1161
+ const headerWord0 = raf.readShort();
1162
+ let stationLat: number;
1163
+ let stationLon: number;
1164
+ let productCode: number;
1165
+ if (headerWord0 === -1) {
1166
+ stationLat = raf.readInt() / 1000;
1167
+ stationLon = raf.readInt() / 1000;
1168
+ raf.readShort(); // height
1169
+ productCode = raf.readUShort();
1170
+ } else {
1171
+ raf.seek(pdbByteOffset);
1172
+ productCode = raf.readUShort();
1173
+ raf.readShort(); // julianDate
1174
+ raf.readInt(); // seconds
1175
+ raf.readInt(); // length
1176
+ raf.readShort(); // source
1177
+ raf.readShort(); // dest
1178
+ raf.readShort(); // blocks
1179
+ const divider = raf.readShort();
1180
+ if (divider !== -1) throw new Error(`Invalid product divider ${divider}`);
1181
+ stationLat = raf.readInt() / 1000;
1182
+ stationLon = raf.readInt() / 1000;
1183
+ raf.readShort(); // height
1184
+ raf.readShort(); // code
1185
+ }
1186
+ raf.readShort(); // mode
1187
+ raf.readShort(); // vcp
1188
+ raf.readShort(); // sequenceNumber
1189
+ raf.readShort(); // volumeScanNumber
1190
+ raf.readShort(); // volumeScanDate
1191
+ raf.readInt(); // volumeScanTime
1192
+ raf.readShort(); // productDate
1193
+ raf.readInt(); // productTime
1194
+ raf.read(4); // dependent27_28
1195
+ raf.readShort(); // elevationNumber
1196
+
1197
+ const dep30_53 = new Level3Raf(raf.read(48));
1198
+ const depProductDescriptionShorts: number[] = [];
1199
+ for (let i = 0; i < 24; i++) {
1200
+ depProductDescriptionShorts.push(dep30_53.readShort());
1201
+ }
1202
+ const minimumDataValue = depProductDescriptionShorts[1] / 10;
1203
+ const dataIncrement = depProductDescriptionShorts[2] / 10;
1204
+ const compressionMethod = depProductDescriptionShorts[21];
1205
+ const genericDigitalParams = LEVEL3_GENERIC_DIGITAL_PRODUCT_CODES.has(productCode)
1206
+ ? parseLevel3GenericDigitalParams(depProductDescriptionShorts)
1207
+ : null;
1208
+
1209
+ if (LEVEL3_GENERIC_DIGITAL_PRODUCT_CODES.has(productCode) && !genericDigitalParams) {
1210
+ console.warn('[aguacero][nexrad-l3][generic-digital-params-null]', {
1211
+ objectKey,
1212
+ radarVariable,
1213
+ productCode,
1214
+ depHead: depProductDescriptionShorts.slice(0, 12),
1215
+ hint: 'Continuing with legacy min/inc; precip values may be wrong — check dep alignment / PDB offset.',
1216
+ });
1217
+ }
1218
+
1219
+ // Product 134 (Digital VIL) uses a piecewise linear+log scale encoded
1220
+ // in threshold halfwords 31-35, not the standard min+level*inc formula.
1221
+ let msg134Params: { linearScale: number; linearOffset: number; logStart: number; logScale: number; logOffset: number } | null = null;
1222
+ if (productCode === 134 && depProductDescriptionShorts.length >= 6) {
1223
+ const hw31 = depProductDescriptionShorts[1];
1224
+ const hw32 = depProductDescriptionShorts[2];
1225
+ const hw33 = depProductDescriptionShorts[3];
1226
+ const hw34 = depProductDescriptionShorts[4];
1227
+ const hw35 = depProductDescriptionShorts[5];
1228
+ msg134Params = {
1229
+ linearScale: int16ToFloat16(hw31),
1230
+ linearOffset: int16ToFloat16(hw32),
1231
+ logStart: hw33,
1232
+ logScale: int16ToFloat16(hw34),
1233
+ logOffset: int16ToFloat16(hw35),
1234
+ };
1235
+ }
1236
+ const l3Entry = getNexradLevel3EntryByRadarKey(radarVariable);
1237
+ const decodeMode: Level3DecodeMode =
1238
+ l3Entry?.decodeMode ??
1239
+ (radarVariable === 'VEL' ||
1240
+ radarVariable === 'N0G' ||
1241
+ radarVariable === 'SW' ||
1242
+ radarVariable === 'N0W'
1243
+ ? 'velocity'
1244
+ : 'dbz');
1245
+ const isVelSw = decodeMode === 'velocity';
1246
+
1247
+ let minThreshold = minimumDataValue;
1248
+ let dataInc = dataIncrement;
1249
+ if (isVelSw) {
1250
+ const pdbLooksInvalid =
1251
+ !Number.isFinite(dataInc) ||
1252
+ dataInc <= 0 ||
1253
+ dataInc > 25 ||
1254
+ !Number.isFinite(minThreshold) ||
1255
+ Math.abs(minThreshold) > 130;
1256
+ if (pdbLooksInvalid) {
1257
+ minThreshold = -63.5;
1258
+ dataInc = 0.5;
1259
+ }
1260
+ }
1261
+ /** Product 138 (DTA): MetPy `DigitalStormPrecipMapper` uses thr2 × 0.01 in, not × 0.1. */
1262
+ if (decodeMode === 'precip' && productCode === 138) {
1263
+ dataInc = depProductDescriptionShorts[2] * 0.01;
1264
+ }
1265
+
1266
+ /** Product 135 (EET) only: DigitalEET threshold triple in PDB. Classic echo tops (41 / NET) use min+inc like reflectivity. */
1267
+ const eetRadialThresholds =
1268
+ decodeMode === 'tops_kft' && productCode === 135
1269
+ ? {
1270
+ dataMask: depProductDescriptionShorts[1],
1271
+ scale: depProductDescriptionShorts[2],
1272
+ offset: depProductDescriptionShorts[3],
1273
+ }
1274
+ : null;
1275
+ raf.readByte(); // version
1276
+ raf.readByte(); // spotBlank
1277
+ const offsetSymbology = raf.readInt();
1278
+ raf.readInt(); // offsetGraphic
1279
+ raf.readInt(); // offsetTabular
1280
+
1281
+ let parseBytes = trimmed;
1282
+ if (compressionMethod > 0) {
1283
+ const headerBytes = trimmed.subarray(0, raf.getPos());
1284
+ const compressedTail = trimmed.subarray(raf.getPos());
1285
+ const decompressedTail = bzip.decode(compressedTail) as Uint8Array;
1286
+ parseBytes = concatUint8(headerBytes, decompressedTail);
1287
+ }
1288
+ const parseRaf = new Level3Raf(parseBytes);
1289
+ const symbologyOffsetBytes = pdbByteOffset + offsetSymbology * 2;
1290
+
1291
+ parseRaf.seek(symbologyOffsetBytes);
1292
+
1293
+ const blockDivider = parseRaf.readShort();
1294
+ const blockId = parseRaf.readShort();
1295
+ if (blockDivider !== -1 || blockId !== 1) {
1296
+ const oob = symbologyOffsetBytes < 0 || symbologyOffsetBytes >= parseBytes.length;
1297
+ console.warn('[aguacero][nexrad-l3][symbology-header-bad]', {
1298
+ objectKey,
1299
+ radarVariable,
1300
+ productCode,
1301
+ blockDivider,
1302
+ blockId,
1303
+ symbologyOffsetBytes,
1304
+ parseBytesLength: parseBytes.length,
1305
+ seekOob: oob,
1306
+ hint: 'Usually wrong PDB byte offset; check [sym-offset] and findLevel3PdbByteOffset.',
1307
+ });
1308
+ throw new Error(`Invalid symbology header ${blockDivider}/${blockId}`);
1309
+ }
1310
+ parseRaf.readInt(); // blockLength
1311
+ const numberLayers = parseRaf.readShort();
1312
+
1313
+ /** Scan every layer and every packet (MetPy-style): prefer largest gate×ray footprint, then 0x10 over RLE. */
1314
+ let bestRadial: Level3RadialPick | null = null;
1315
+ let rasterParsed: Level3ParsedRaster | null = null;
1316
+ const symbologyPacketCodes: number[] = [];
1317
+ for (let layerIndex = 0; layerIndex < numberLayers; layerIndex++) {
1318
+ const layerStart = parseRaf.getPos();
1319
+ const layerDivider = parseRaf.readShort();
1320
+ /** Bytes of symbology payload following this 4-byte field (ICD; same as MetPy `layer_hdr.length`). */
1321
+ const layerPayloadLength = parseRaf.readInt();
1322
+ if (layerDivider !== -1) {
1323
+ parseRaf.seek(layerStart + 6 + layerPayloadLength);
1324
+ continue;
1325
+ }
1326
+ /** End of layer = after 6-byte layer record (divider + length) + payload (MetPy marks `layer_start` after header). */
1327
+ const layerEnd = parseRaf.getPos() + layerPayloadLength;
1328
+ while (parseRaf.getPos() < layerEnd) {
1329
+ const packetPos = parseRaf.getPos();
1330
+ const packetCode = parseRaf.readUShort();
1331
+ symbologyPacketCodes.push(packetCode);
1332
+ parseRaf.seek(packetPos);
1333
+ if (packetCode === 16) {
1334
+ const symMode: Level3SymbologyMode = isVelSw
1335
+ ? 'digital_velocity_byte'
1336
+ : decodeMode === 'precip' && !genericDigitalParams
1337
+ ? 'standard_digital'
1338
+ : 'reflectivity';
1339
+ const p = parseLevel3Packet0010(
1340
+ parseRaf,
1341
+ minThreshold,
1342
+ dataInc,
1343
+ symMode,
1344
+ productCode,
1345
+ msg134Params,
1346
+ eetRadialThresholds,
1347
+ genericDigitalParams,
1348
+ );
1349
+ const cand: Level3RadialPick = { kind: 0x10, packet: p };
1350
+ if (level3RadialPickIsBetter(cand, bestRadial)) bestRadial = cand;
1351
+ continue;
1352
+ }
1353
+ if (packetCode === 0xAF1F) {
1354
+ const symMode: Level3SymbologyMode = isVelSw
1355
+ ? 'digital_velocity_rle4'
1356
+ : decodeMode === 'precip' && !genericDigitalParams
1357
+ ? 'standard_digital'
1358
+ : 'reflectivity';
1359
+ const p = parseLevel3PacketAF1F(
1360
+ parseRaf,
1361
+ minThreshold,
1362
+ dataInc,
1363
+ symMode,
1364
+ productCode,
1365
+ msg134Params,
1366
+ eetRadialThresholds,
1367
+ genericDigitalParams,
1368
+ );
1369
+ const cand: Level3RadialPick = { kind: 0xaf1f, packet: p };
1370
+ if (level3RadialPickIsBetter(cand, bestRadial)) bestRadial = cand;
1371
+ continue;
1372
+ }
1373
+ if (packetCode === LEVEL3_PACKET_RASTER_BA07) {
1374
+ parseRaf.seek(packetPos);
1375
+ const parsed = parseLevel3PacketRasterBA07(parseRaf, layerEnd, objectKey);
1376
+ if (parsed && rasterParsed === null) rasterParsed = parsed;
1377
+ else if (!parsed) parseRaf.seek(layerEnd);
1378
+ continue;
1379
+ }
1380
+ if (trySkipLevel3OverlayPacket(parseRaf, packetCode, layerEnd)) {
1381
+ continue;
1382
+ }
1383
+ parseRaf.seek(layerEnd);
1384
+ break;
1385
+ }
1386
+ parseRaf.seek(layerEnd);
1387
+ }
1388
+
1389
+ /**
1390
+ * EET (135): ICD uses up to **256** data levels on digital radials (packet 0x0010 full bytes).
1391
+ * Raster 0xBA07 RLE is **4-bit** (levels 0–15 only), which caps echo-top heights near ~10 km (~33 kft)
1392
+ * even though the radial product carries the full dynamic range. Prefer radials when both exist.
1393
+ *
1394
+ * Digital precip: raster grid avoids radial fan artifacts.
1395
+ * Do **not** prefer raster for dual-pol generic-digital products (e.g. 163 N0K KDP): 0xBA07 RLE is **4-bit**
1396
+ * (levels 0–15) while 0x0010 radials are **8-bit** — using the raster makes KDP/ZDR-style fields one flat color.
1397
+ */
1398
+ const shouldPreferRaster =
1399
+ rasterParsed != null &&
1400
+ ((decodeMode === 'tops_kft' && productCode !== 135) || decodeMode === 'precip');
1401
+
1402
+ if (bestRadial && !shouldPreferRaster) {
1403
+ const radialPacket = bestRadial.packet;
1404
+ const radials = radialPacket?.radials;
1405
+ const nRays = Number(radialPacket?.numberRadials);
1406
+ const nGates = Number(radialPacket?.numberBins);
1407
+ if (!Array.isArray(radials) || !Number.isFinite(nRays) || !Number.isFinite(nGates) || !Number.isFinite(stationLat) || !Number.isFinite(stationLon)) {
1408
+ console.warn('[aguacero][nexrad-l3][invalid-radial-packet]', { objectKey, radarVariable, nRays, nGates, stationLat, stationLon });
1409
+ } else {
1410
+ const gateData = new Uint8Array(nRays * nGates * 2);
1411
+ const { valueScale, valueOffset } = level3ValuePacking(decodeMode);
1412
+ for (let r = 0; r < nRays; r++) {
1413
+ const bins = radials[r]?.bins;
1414
+ for (let g = 0; g < nGates; g++) {
1415
+ const v = bins?.[g];
1416
+ const isNoData = v == null || !Number.isFinite(v);
1417
+ const rawSigned = isNoData
1418
+ ? -32768
1419
+ : Math.round((Number(v) - valueOffset) / valueScale);
1420
+ const [hi, lo] = encodeSigned16ToHiLo(rawSigned);
1421
+ const offset = (r * nGates + g) * 2;
1422
+ gateData[offset] = hi;
1423
+ gateData[offset + 1] = lo;
1424
+ }
1425
+ }
1426
+
1427
+ const rangeMul = LEVEL3_PRODUCT_RANGE_SCALE_MUL[productCode] ?? 1;
1428
+ const rangeScaleRaw = Number(radialPacket?.rangeScaleRaw);
1429
+ const gateSpacingM =
1430
+ Number.isFinite(rangeScaleRaw) && rangeScaleRaw > 0 ? rangeScaleRaw * rangeMul : 250;
1431
+ let gateWidthKm = gateSpacingM / 1000;
1432
+ const firstBinM = Number(radialPacket?.firstBinMeters);
1433
+ let firstGateKm = Number.isFinite(firstBinM) && firstBinM >= 0 ? firstBinM / 1000 : 0;
1434
+ const decodedCoverageKm = firstGateKm + nGates * gateWidthKm;
1435
+ if (decodedCoverageKm > 0 && decodedCoverageKm < 20 && nGates >= 100) {
1436
+ gateWidthKm *= 1000;
1437
+ firstGateKm *= 1000;
1438
+ }
1439
+ // Guard against km↔m mismatch producing global wedges for precip products.
1440
+ if (decodeMode === 'precip' && decodedCoverageKm > 1500) {
1441
+ gateWidthKm /= 1000;
1442
+ firstGateKm /= 1000;
1443
+ }
1444
+ const rayBoundariesDeg = buildRayBoundariesFromLevel3Radials(radials);
1445
+
1446
+ return {
1447
+ gateData,
1448
+ nRays,
1449
+ nGates,
1450
+ stationLat,
1451
+ stationLon,
1452
+ firstGateKm,
1453
+ gateWidthKm,
1454
+ valueScale,
1455
+ valueOffset,
1456
+ rayBoundariesDeg,
1457
+ };
1458
+ }
1459
+ }
1460
+
1461
+ if (rasterParsed) {
1462
+ const { valueScale, valueOffset } = level3ValuePacking(decodeMode);
1463
+ const { minT, dataInc: rasterDataInc, symMode, vilLevelThresholds } = level3RasterPdbScales(
1464
+ decodeMode,
1465
+ productCode,
1466
+ minThreshold,
1467
+ dataInc,
1468
+ depProductDescriptionShorts,
1469
+ objectKey,
1470
+ genericDigitalParams,
1471
+ );
1472
+ const eetThresholds =
1473
+ decodeMode === 'tops_kft' && productCode === 135
1474
+ ? {
1475
+ dataMask: depProductDescriptionShorts[1],
1476
+ scale: depProductDescriptionShorts[2],
1477
+ offset: depProductDescriptionShorts[3],
1478
+ }
1479
+ : null;
1480
+ const rasterFrame = level3RasterToPolarFrame(
1481
+ rasterParsed,
1482
+ stationLat,
1483
+ stationLon,
1484
+ decodeMode,
1485
+ minT,
1486
+ rasterDataInc,
1487
+ symMode,
1488
+ vilLevelThresholds,
1489
+ eetThresholds,
1490
+ valueScale,
1491
+ valueOffset,
1492
+ objectKey,
1493
+ genericDigitalParams,
1494
+ productCode,
1495
+ );
1496
+ if (rasterFrame) {
1497
+ return rasterFrame;
1498
+ }
1499
+ }
1500
+
1501
+ const hex = symbologyPacketCodes.map((c) => `0x${c.toString(16)}`);
1502
+ if (!bestRadial) {
1503
+ console.warn('[aguacero][nexrad-l3][no-radial-symbology]', {
1504
+ objectKey,
1505
+ radarVariable,
1506
+ messageCode: productCode,
1507
+ numberLayers,
1508
+ symbologyPacketCodes,
1509
+ symbologyPacketCodesHex: hex,
1510
+ hint: 'Radial: packets 0x10 / 0xAF1F. Raster: 0xBA07 is supported; if rasterParsed failed, check parseLevel3PacketRasterBA07 / inner code logs.',
1511
+ });
1512
+ }
1513
+ return null;
1514
+ } catch (err) {
1515
+ console.warn('[aguacero][nexrad-l3][decode-throw]', { objectKey, radarVariable, err });
1516
+ return null;
1517
+ }
1518
+ }
1519
+
1520
+ const GROUP2_VARS = ['SW'];
1521
+ /** g2 split-cut in clear-air mode (VCP 35): REF + VEL + SW in one file (legacy archives only). */
1522
+ const GROUP2_COMBINED_3_VARS = ['REF', 'VEL', 'SW'];
1523
+ /** g1 dual-pol + REF; order must match writer. KDP is Level-III (N0K), not in g1 bins. */
1524
+ const GROUP1_VARS = ['REF', 'ZDR', 'RHO', 'PHI'] as const;
1525
+ /** g2 combined tilt with KDP (7 slots); order must match writer. */
1526
+ const GROUP2_COMBINED_7_VARS = ['REF', 'ZDR', 'RHO', 'PHI', 'KDP', 'VEL', 'SW'];
1527
+ const GROUP2_COMBINED_VARS = ['REF', 'ZDR', 'RHO', 'PHI', 'VEL', 'SW'];
1528
+
1529
+ // Fixed sizes matching the Python writer (`lambda_function.py` MAX_RAYS / AZ_BLOCK_BYTES).
1530
+ const FILE_HDR_BYTES = 64;
1531
+ const LEVEL2_AZ_BLOCK_RAYS = 720;
1532
+ const LEVEL2_AZ_BLOCK_BYTES = LEVEL2_AZ_BLOCK_RAYS * 4;
1533
+ /** Per-sweep Nyquist (m/s), big-endian float32 after azimuth block and before slot index (writer `nyquist_bytes`). */
1534
+ const LEVEL2_FILE_NYQUIST_BYTES = 4;
1535
+ const SLOT_INDEX_ENTRY = 18;
1536
+ const MAX_SLOTS = 7; // g2 combined tilt can write 7 fields (GROUP2_COMBINED_7_VARS)
1537
+
1538
+ interface FileHeader {
1539
+ unixTime: number;
1540
+ nRays: number;
1541
+ nGates: number;
1542
+ elevAngle: number;
1543
+ firstGateKm: number;
1544
+ gateWidthKm: number;
1545
+ nSlots: number;
1546
+ azimuthsBuffer: ArrayBuffer; // raw big-endian float32 bytes (720*4)
1547
+ slots: Array<{ offset: number; compressedSize: number; uncompressedSize: number }>;
1548
+ /** Nyquist velocity (m/s) from file header after azimuths; null if non-finite / out of band. */
1549
+ embeddedNyquistMs: number | null;
1550
+ }
1551
+
1552
+ function int16ToFloat16(val: number): number {
1553
+ const sign = (val & 0b1000000000000000) / 0b1000000000000000;
1554
+ const exponent = (val & 0b0111110000000000) / 0b0000010000000000;
1555
+ const fraction = val & 0b0000001111111111;
1556
+ if (exponent === 0) {
1557
+ return Math.pow(-1, sign) * 2 * (0 + fraction / Math.pow(2, 10));
1558
+ }
1559
+ return Math.pow(-1, sign) * Math.pow(2, exponent - 16) * (1 + fraction / Math.pow(2, 10));
1560
+ }
1561
+
1562
+ /**
1563
+ * Total Level-II object size when known.
1564
+ * For 206 partial responses, only `Content-Range: bytes a-b/total` gives `total` — `Content-Length` is the segment size.
1565
+ */
1566
+ function nexradLevel2ResourceTotalBytes(resp: Response): number | null {
1567
+ const cr = resp.headers.get('Content-Range');
1568
+ if (cr) {
1569
+ const m = cr.trim().match(/\/(\d+)\s*$/);
1570
+ if (m) {
1571
+ const n = Number(m[1]);
1572
+ return Number.isFinite(n) && n > 0 ? n : null;
1573
+ }
1574
+ }
1575
+ if (resp.status === 200) {
1576
+ const cl = resp.headers.get('Content-Length');
1577
+ if (cl) {
1578
+ const n = Number(cl);
1579
+ return Number.isFinite(n) && n > 0 ? n : null;
1580
+ }
1581
+ }
1582
+ return null;
1583
+ }
1584
+
1585
+ function parseFileHeader(buffer: ArrayBuffer, azBlockBytes: number): FileHeader {
1586
+ const view = new DataView(buffer);
1587
+ const magic = view.getUint32(0, false);
1588
+ if (magic !== 0x4E584244) throw new Error(`Bad magic: 0x${magic.toString(16)}`);
1589
+
1590
+ const unixTime = view.getUint32(4, false);
1591
+ const nRays = view.getUint16(8, false);
1592
+ const nGates = view.getUint16(10, false);
1593
+ const elevAngle = view.getFloat32(12, false);
1594
+ const firstGateKm = view.getFloat32(16, false);
1595
+ const gateWidthKm = view.getFloat32(20, false);
1596
+ const nSlots = view.getUint16(24, false);
1597
+
1598
+ const azimuthsBuffer = buffer.slice(FILE_HDR_BYTES, FILE_HDR_BYTES + azBlockBytes);
1599
+
1600
+ const azEnd = FILE_HDR_BYTES + azBlockBytes;
1601
+ const idxStart = azEnd + LEVEL2_FILE_NYQUIST_BYTES;
1602
+ const needBytes = idxStart + nSlots * SLOT_INDEX_ENTRY;
1603
+ if (buffer.byteLength < needBytes) {
1604
+ throw new Error(`level2 header buffer too small: ${buffer.byteLength} < ${needBytes}`);
1605
+ }
1606
+
1607
+ const nyqCandidate = view.getFloat32(azEnd, false);
1608
+ const embeddedNyquistMs =
1609
+ Number.isFinite(nyqCandidate) && nyqCandidate > 0.5 && nyqCandidate < 128 ? nyqCandidate : null;
1610
+
1611
+ const slots: Array<{ offset: number; compressedSize: number; uncompressedSize: number }> = [];
1612
+ for (let i = 0; i < nSlots; i++) {
1613
+ const base = idxStart + i * SLOT_INDEX_ENTRY;
1614
+ const offsetHigh = view.getUint32(base, false);
1615
+ const offsetLow = view.getUint32(base + 4, false);
1616
+ const offset = offsetHigh * 2 ** 32 + offsetLow;
1617
+ const compressedSize = view.getUint32(base + 8, false);
1618
+ const uncompressedSize = view.getUint32(base + 12, false);
1619
+ slots.push({ offset, compressedSize, uncompressedSize });
1620
+ }
1621
+
1622
+ return {
1623
+ unixTime,
1624
+ nRays,
1625
+ nGates,
1626
+ elevAngle,
1627
+ firstGateKm,
1628
+ gateWidthKm,
1629
+ nSlots,
1630
+ azimuthsBuffer,
1631
+ slots,
1632
+ embeddedNyquistMs,
1633
+ };
1634
+ }
1635
+
1636
+ function decodeSweepInWorker(
1637
+ objectKey: string,
1638
+ slotBuffer: ArrayBuffer,
1639
+ header: FileHeader,
1640
+ sites: NexradSite[],
1641
+ priority: 'display' | 'prefetch',
1642
+ signal?: AbortSignal,
1643
+ ): Promise<DecodedRadarFrame | null> {
1644
+ if (signal?.aborted) return Promise.reject(createAbortError());
1645
+
1646
+ const { worker } = getDecodeWorkerForPriority(priority);
1647
+ const requestId = ++radarDecodeRequestId;
1648
+
1649
+ // Transfer a copy of azimuthsBuffer so we don't detach the cached header
1650
+ const azCopy = header.azimuthsBuffer.slice(0);
1651
+
1652
+ return new Promise((resolve, reject) => {
1653
+ let isSettled = false;
1654
+ const onAbort = () => {
1655
+ if (isSettled) return;
1656
+ isSettled = true;
1657
+ radarDecodePending.delete(requestId);
1658
+ radarDecodeRequestMeta.delete(requestId);
1659
+ signal?.removeEventListener('abort', onAbort);
1660
+ reject(createAbortError());
1661
+ };
1662
+
1663
+ radarDecodePending.set(requestId, {
1664
+ resolve: (frame) => { if (!isSettled) { isSettled = true; signal?.removeEventListener('abort', onAbort); resolve(frame); } },
1665
+ reject: (err) => { if (!isSettled) { isSettled = true; signal?.removeEventListener('abort', onAbort); reject(err); } },
1666
+ });
1667
+
1668
+ if (signal) signal.addEventListener('abort', onAbort, { once: true });
1669
+
1670
+ worker.postMessage({
1671
+ type: 'DECODE_SLOT',
1672
+ requestId,
1673
+ objectKey,
1674
+ slotBuffer,
1675
+ nRays: header.nRays,
1676
+ nGates: header.nGates,
1677
+ firstGateKm: header.firstGateKm,
1678
+ gateWidthKm: header.gateWidthKm,
1679
+ azimuthsBuffer: azCopy,
1680
+ sites,
1681
+ }, [slotBuffer, azCopy]);
1682
+ });
1683
+ }
1684
+
1685
+ function resolveLevel3SrvMotionObjectKey(
1686
+ unixTime: number | null | undefined,
1687
+ motionMap: Record<string, string> | undefined,
1688
+ radarSource: 'level2' | 'level3',
1689
+ radarVar: string,
1690
+ useStormRelativeMotion: boolean,
1691
+ ): string | null {
1692
+ if (radarVar !== 'VEL' || !useStormRelativeMotion || unixTime == null || !motionMap) {
1693
+ return null;
1694
+ }
1695
+ return pickNearestLevel3ObjectKey(unixTime, motionMap);
1696
+ }
1697
+
1698
+ /** Reuse parsed N0S motion across time steps (SRV: same motion key is common while scrubbing). */
1699
+ const N0S_MOTION_CACHE_MAX = 128;
1700
+ const n0sMotionVectorCache = new Map<string, { speedMs: number; directionDeg: number }>();
1701
+
1702
+ function getCachedN0sMotion(motionObjectKey: string) {
1703
+ return n0sMotionVectorCache.get(motionObjectKey);
1704
+ }
1705
+ function setCachedN0sMotion(
1706
+ motionObjectKey: string,
1707
+ motion: { speedMs: number; directionDeg: number },
1708
+ ) {
1709
+ n0sMotionVectorCache.set(motionObjectKey, motion);
1710
+ while (n0sMotionVectorCache.size > N0S_MOTION_CACHE_MAX) {
1711
+ const first = n0sMotionVectorCache.keys().next().value as string | undefined;
1712
+ if (first === undefined) break;
1713
+ n0sMotionVectorCache.delete(first);
1714
+ }
1715
+ }
1716
+
1717
+ async function applyStormMotionFromN0sObjectKey(
1718
+ frame: DecodedRadarFrame,
1719
+ motionObjectKey: string,
1720
+ logLabel: string,
1721
+ ): Promise<DecodedRadarFrame> {
1722
+ const cached = getCachedN0sMotion(motionObjectKey);
1723
+ if (cached) {
1724
+ return applyLevel3StormRelativeToFrame(frame, cached.speedMs, cached.directionDeg);
1725
+ }
1726
+ const motionUrl = objectKeyToUrl(motionObjectKey, 'level3');
1727
+ try {
1728
+ const motionResp = await fetch(motionUrl);
1729
+ if (motionResp.ok) {
1730
+ const motionBuf = await motionResp.arrayBuffer();
1731
+ const motion = parseLevel3StormMotionFromBuffer(motionBuf);
1732
+ if (motion) {
1733
+ setCachedN0sMotion(motionObjectKey, motion);
1734
+ return applyLevel3StormRelativeToFrame(frame, motion.speedMs, motion.directionDeg);
1735
+ }
1736
+ }
1737
+ } catch (e) {
1738
+ console.log(`${logLabel}:srvMotionFetchError`, { motionObjectKey, err: e });
1739
+ }
1740
+ return frame;
1741
+ }
1742
+
1743
+ export async function fetchAndParseArchive(
1744
+ url: string,
1745
+ objectKey: string,
1746
+ radarVariable: string,
1747
+ groupId: number,
1748
+ radarSource: 'level2' | 'level3',
1749
+ options?: {
1750
+ signal?: AbortSignal;
1751
+ priority?: 'display' | 'prefetch';
1752
+ /** N0S S3 object key — with VEL, apply SRV (L3: N0G+motion; L2: super-res velocity + nearest-time N0S motion). */
1753
+ level3MotionObjectKey?: string | null;
1754
+ },
1755
+ ): Promise<DecodedRadarFrame | null> {
1756
+ const mot = options?.level3MotionObjectKey ? `|${options.level3MotionObjectKey}` : '';
1757
+ const cacheKey = `${url}:${radarVariable}:${radarSource}${mot}`;
1758
+ const priority = options?.priority ?? 'display';
1759
+
1760
+ if (archiveCache.has(cacheKey)) {
1761
+ const cached = archiveCache.get(cacheKey)!;
1762
+ setArchiveCache(cacheKey, cached);
1763
+ return cached;
1764
+ }
1765
+ const existingInflight = inflightFetches.get(cacheKey);
1766
+ if (existingInflight) {
1767
+ const meta = inflightFetchMeta.get(cacheKey);
1768
+ if (meta) {
1769
+ meta.callers += 1;
1770
+ }
1771
+ return existingInflight;
1772
+ }
1773
+
1774
+ if (options?.signal?.aborted) {
1775
+ return null;
1776
+ }
1777
+
1778
+ const requestId = ++radarFetchRequestSeq;
1779
+ const startedAt = performance.now();
1780
+ inflightFetchMeta.set(cacheKey, {
1781
+ requestId,
1782
+ startedAt,
1783
+ callers: 1,
1784
+ objectKey,
1785
+ radarVariable,
1786
+ radarSource,
1787
+ priority,
1788
+ });
1789
+
1790
+ const promise = (async (): Promise<DecodedRadarFrame | null> => {
1791
+ try {
1792
+ if (radarSource === 'level3') {
1793
+ const response = await fetch(url);
1794
+ if (!response.ok) throw new Error(`HTTP ${response.status} fetching level3 ${url}`);
1795
+ const buffer = await response.arrayBuffer();
1796
+ let decoded = decodeLevel3RasterProduct(buffer, objectKey, radarVariable);
1797
+ const motionKey = options?.level3MotionObjectKey;
1798
+ if (decoded && motionKey && radarVariable === 'VEL') {
1799
+ decoded = await applyStormMotionFromN0sObjectKey(
1800
+ decoded,
1801
+ motionKey,
1802
+ 'fetchAndParseArchive:level3',
1803
+ );
1804
+ }
1805
+ if (!decoded) {
1806
+ console.warn('[aguacero][nexrad-l3][fetch-ok-decode-null]', {
1807
+ objectKey,
1808
+ radarVariable,
1809
+ byteLength: buffer.byteLength,
1810
+ filterConsole: 'Also check [aguacero][nexrad-l3][no-radial-symbology] or decode catch logs',
1811
+ });
1812
+ } else {
1813
+ setArchiveCache(cacheKey, decoded);
1814
+ }
1815
+ return decoded;
1816
+ }
1817
+
1818
+ // ── Level-2 two-request range path ───────────────────────────────────────
1819
+ const level2Url = cloudFrontUrlWithApiKeyQuery(url);
1820
+ nexradArchiveDiag('level2.pipeline.start', {
1821
+ objectKey,
1822
+ radarVariable,
1823
+ groupId,
1824
+ apiKeyLen: NEXRAD_ARCHIVE_API_KEY.length,
1825
+ bundleIdLen: NEXRAD_ARCHIVE_BUNDLE_ID.length,
1826
+ urlNoSecret: redactApiKeyFromUrl(level2Url),
1827
+ });
1828
+ // Request 1: fetch just the header + slot index to find byte offsets
1829
+ const azBlockBytes = LEVEL2_AZ_BLOCK_BYTES;
1830
+ const INDEX_FETCH_BYTES =
1831
+ FILE_HDR_BYTES + azBlockBytes + LEVEL2_FILE_NYQUIST_BYTES + MAX_SLOTS * SLOT_INDEX_ENTRY;
1832
+ nexradArchiveDiag('level2.index.fetch', {
1833
+ objectKey,
1834
+ byteRangeEnd: INDEX_FETCH_BYTES - 1,
1835
+ });
1836
+ const indexResp = await fetch(level2Url, {
1837
+ headers: level2CloudFrontFetchHeaders(`bytes=0-${INDEX_FETCH_BYTES - 1}`),
1838
+ });
1839
+ nexradArchiveDiag('level2.index.response', {
1840
+ objectKey,
1841
+ status: indexResp.status,
1842
+ ok: indexResp.ok,
1843
+ });
1844
+ if (!indexResp.ok && indexResp.status !== 206) {
1845
+ throw new Error(`HTTP ${indexResp.status} fetching level2 index ${url}`);
1846
+ }
1847
+ const indexBuffer = await indexResp.arrayBuffer();
1848
+ const indexResourceTotal = nexradLevel2ResourceTotalBytes(indexResp);
1849
+ const header = parseFileHeader(indexBuffer, azBlockBytes);
1850
+
1851
+ const slotIndexBytes =
1852
+ FILE_HDR_BYTES + azBlockBytes + LEVEL2_FILE_NYQUIST_BYTES + header.nSlots * SLOT_INDEX_ENTRY;
1853
+ if (indexBuffer.byteLength < slotIndexBytes) {
1854
+ console.warn('[RadarLayer] level2 index response shorter than slot table', {
1855
+ objectKey,
1856
+ byteLength: indexBuffer.byteLength,
1857
+ need: slotIndexBytes,
1858
+ nSlots: header.nSlots,
1859
+ });
1860
+ setArchiveCache(cacheKey, null);
1861
+ return null;
1862
+ }
1863
+
1864
+ // ── Step 2: find slot for this variable ─────────────────────
1865
+ let varList: string[];
1866
+ if (groupId === 2 && header.nSlots === 7) {
1867
+ varList = GROUP2_COMBINED_7_VARS;
1868
+ } else if (groupId === 2 && header.nSlots === 6) {
1869
+ varList = GROUP2_COMBINED_VARS;
1870
+ } else if (groupId === 2 && header.nSlots === 3) {
1871
+ varList = GROUP2_COMBINED_3_VARS;
1872
+ } else if (groupId === 2) {
1873
+ varList = GROUP2_VARS;
1874
+ } else {
1875
+ varList = [...GROUP1_VARS];
1876
+ }
1877
+
1878
+ const slotIdx = varList.indexOf(radarVariable);
1879
+ if (slotIdx < 0 || slotIdx >= header.slots.length) {
1880
+ console.warn(`[RadarLayer] ${radarVariable} not in group ${groupId} (nSlots=${header.nSlots})`);
1881
+ setArchiveCache(cacheKey, null);
1882
+ return null;
1883
+ }
1884
+
1885
+ const slot = header.slots[slotIdx];
1886
+ if (slot.compressedSize === 0) {
1887
+ console.warn(`[RadarLayer] ${radarVariable} slot ${slotIdx} has compressedSize=0`);
1888
+ setArchiveCache(cacheKey, null);
1889
+ return null;
1890
+ }
1891
+
1892
+ // ── Step 3: fetch exactly the slot bytes ────────────────────
1893
+ const slotEndExclusive = slot.offset + slot.compressedSize;
1894
+ if (indexResourceTotal != null && slotEndExclusive > indexResourceTotal) {
1895
+ console.warn('[RadarLayer] level2 slot extends past object size (bad index or stale CDN)', {
1896
+ objectKey,
1897
+ radarVariable,
1898
+ slotIdx,
1899
+ offset: slot.offset,
1900
+ compressedSize: slot.compressedSize,
1901
+ indexResourceTotal,
1902
+ });
1903
+ setArchiveCache(cacheKey, null);
1904
+ return null;
1905
+ }
1906
+
1907
+ const slotRangeEnd = slotEndExclusive - 1;
1908
+ const slotHeaders = level2CloudFrontFetchHeaders(`bytes=${slot.offset}-${slotRangeEnd}`);
1909
+ nexradArchiveDiag('level2.slot.fetch', {
1910
+ objectKey,
1911
+ slotIdx,
1912
+ compressedSize: slot.compressedSize,
1913
+ offset: slot.offset,
1914
+ });
1915
+ let slotResp = await fetch(level2Url, { headers: slotHeaders });
1916
+ nexradArchiveDiag('level2.slot.response', {
1917
+ objectKey,
1918
+ status: slotResp.status,
1919
+ ok: slotResp.ok,
1920
+ });
1921
+ let slotBuffer: ArrayBuffer;
1922
+ if (slotResp.ok || slotResp.status === 206) {
1923
+ slotBuffer = await slotResp.arrayBuffer();
1924
+ } else if (slotResp.status === 416) {
1925
+ const fullResp = await fetch(level2Url, { headers: level2CloudFrontFetchHeaders(undefined) });
1926
+ if (!fullResp.ok) {
1927
+ throw new Error(`HTTP ${fullResp.status} full fetch after 416 for level2 ${url}`);
1928
+ }
1929
+ const fullBuf = await fullResp.arrayBuffer();
1930
+ if (slot.offset >= fullBuf.byteLength || slotEndExclusive > fullBuf.byteLength) {
1931
+ throw new Error(
1932
+ `level2 slot out of bounds after 416 fallback (offset=${slot.offset}, end=${slotEndExclusive}, file=${fullBuf.byteLength}) for ${url}`,
1933
+ );
1934
+ }
1935
+ slotBuffer = fullBuf.slice(slot.offset, slotEndExclusive);
1936
+ } else {
1937
+ throw new Error(`HTTP ${slotResp.status} fetching level2 slot ${url}`);
1938
+ }
1939
+
1940
+ // ── Step 4: decode in worker ────────────────────────────────
1941
+ nexradArchiveDiag('level2.decode.start', { objectKey, radarVariable });
1942
+ const sites = await loadNexradSites();
1943
+ let decoded = await decodeSweepInWorker(
1944
+ objectKey, slotBuffer, header, sites,
1945
+ options?.priority ?? 'display',
1946
+ undefined,
1947
+ );
1948
+
1949
+ if (!decoded) { setArchiveCache(cacheKey, null); return null; }
1950
+ decoded = { ...decoded, embeddedNyquistMs: header.embeddedNyquistMs };
1951
+ const l2MotionKey = options?.level3MotionObjectKey;
1952
+ if (l2MotionKey && radarVariable === 'VEL') {
1953
+ decoded = await applyStormMotionFromN0sObjectKey(
1954
+ decoded,
1955
+ l2MotionKey,
1956
+ 'fetchAndParseArchive:level2',
1957
+ );
1958
+ }
1959
+ setArchiveCache(cacheKey, decoded);
1960
+ nexradArchiveDiag('level2.decode.done', {
1961
+ objectKey,
1962
+ radarVariable,
1963
+ nRays: decoded.nRays ?? null,
1964
+ nGates: decoded.nGates ?? null,
1965
+ });
1966
+ return decoded;
1967
+ } catch (err) {
1968
+ if (isAbortError(err)) {
1969
+ return null;
1970
+ }
1971
+ nexradArchiveDiag('fetchAndParseArchive.failed', {
1972
+ objectKey,
1973
+ radarSource,
1974
+ message: err instanceof Error ? err.message : String(err),
1975
+ });
1976
+ console.error(`[RadarLayer] fetchAndParseArchive failed for ${objectKey}:`, err);
1977
+ return null;
1978
+ } finally {
1979
+ inflightFetches.delete(cacheKey);
1980
+ inflightFetchMeta.delete(cacheKey);
1981
+ }
1982
+ })();
1983
+
1984
+ inflightFetches.set(cacheKey, promise);
1985
+ return promise;
1986
+ }
1987
+