@metweave/parser 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1894 @@
1
+ import { MetarParseError } from "@metweave/core";
2
+ //#region src/index.ts
3
+ const PHENOMENA = [
4
+ "DZ",
5
+ "RA",
6
+ "SN",
7
+ "SG",
8
+ "IC",
9
+ "PL",
10
+ "GR",
11
+ "GS",
12
+ "UP",
13
+ "BR",
14
+ "FG",
15
+ "FU",
16
+ "VA",
17
+ "DU",
18
+ "SA",
19
+ "HZ",
20
+ "PY",
21
+ "PO",
22
+ "SQ",
23
+ "FC",
24
+ "SS",
25
+ "DS"
26
+ ];
27
+ const DESCRIPTORS = [
28
+ "MI",
29
+ "PR",
30
+ "BC",
31
+ "DR",
32
+ "BL",
33
+ "SH",
34
+ "TS",
35
+ "FZ"
36
+ ];
37
+ const TREND_KINDS = /* @__PURE__ */ new Set([
38
+ "NOSIG",
39
+ "BECMG",
40
+ "TEMPO"
41
+ ]);
42
+ /** 趋势段拖词容忍集(无 typed 语义的磨损尾/连接词——收进 trend.raw 静默保真,不产生噪声):
43
+ * 语料实弹 VNKT 120930Z「NOSIG CB TO NE AND E」(观测员补记磨损尾)与既有回归锁契约
44
+ * (拖词不告警、原文留 raw);含裸对流码 CB/TCU(无云量云高位的残缺云信息,无解码面)与
45
+ * 16 方位词、TO/AND 连接词。与「交回正文恢复 typed 语义」的收窄主判据互补:
46
+ * 有语义可恢复的(温露/QNH/$/WS/TX/TN/RVRNO)一律收口交回,纯噪声拖词保持原文静默。 */
47
+ const TREND_FILLER_WORDS = /* @__PURE__ */ new Set([
48
+ "TO",
49
+ "AND",
50
+ "CB",
51
+ "TCU",
52
+ "N",
53
+ "NNE",
54
+ "NE",
55
+ "ENE",
56
+ "E",
57
+ "ESE",
58
+ "SE",
59
+ "SSE",
60
+ "S",
61
+ "SSW",
62
+ "SW",
63
+ "WSW",
64
+ "W",
65
+ "WNW",
66
+ "NW",
67
+ "NNW"
68
+ ]);
69
+ /** 趋势段收组判据(封闭清单):只有趋势合法要素族(WMO 306 FM15 §15.14.2/§15.14.10:风/能见度/现在天气/云,
70
+ * 加趋势专属 NSW 与 CAVOK 顶替)与风组变化组拖带形(dddVddd)可收进趋势段;其余 token 一律收口
71
+ * 交回正文循环——正文各分支自带认组与 unknown-token 出声,语义无损恢复,冲突组自然触发重复组告警。
72
+ *
73
+ * 根因一(2026-09-12 复评实弹):旧收集循环只认趋势指示组,俄区报文「NOSIG RMK QFE749」把 RMK 段
74
+ * 整段吞进 trends(RU 61/65、KZ 18/18 条命中)、remarks 反而为空。
75
+ * 根因二(2026-09-14 复评实弹):收口改「白名单补洞」后正文专属组(温露/QNH/$/WS/TX/TN/RVRNO)
76
+ * 仍被静默吞进 trend.raw、typed 语义蒸发——RMK、Rxx/、$ 已是三次逃逸,补洞循环就此终结:
77
+ * 判据反转为规范封闭清单,未来一切新正文分支自动获得「交回正文 + 出声」兜底。
78
+ * 硬边界保留:RMK(含词身粘连 startsWith)与 R 组形态——R 组收口另有 trendCloseWarning 出声。 */
79
+ const isTrendCollectible = (t, next) => {
80
+ const text = t.text;
81
+ if (TREND_KINDS.has(text) || text.startsWith("RMK")) return false;
82
+ if (/^R(\d{2}[RLC]?\/|\/SNOCLO$)/.test(text)) return false;
83
+ if (text === "CAVOK" || text === "NSW" || text === "//") return true;
84
+ if (TREND_FILLER_WORDS.has(text)) return true;
85
+ if (/^(AT|TL|FM)\d{4}$/.test(text)) return true;
86
+ if (parseWindToken(t, next) !== null || /^\d{3}V\d{3}$/.test(text)) return true;
87
+ if (parseVisibilityToken(t, next) !== null) return true;
88
+ if (parseWeatherBody(text) !== null) return true;
89
+ if (CLOUD_LAYER_PATTERN.test(text) || VV_PATTERN.test(text) || isSkyClear(text)) return true;
90
+ return false;
91
+ };
92
+ const SKY_CLEAR_CODES = [
93
+ "SKC",
94
+ "NSC",
95
+ "NCD",
96
+ "CLR"
97
+ ];
98
+ const CLOUD_AMOUNTS = [
99
+ "FEW",
100
+ "SCT",
101
+ "BKN",
102
+ "OVC"
103
+ ];
104
+ const SPEED_UNITS = [
105
+ "kt",
106
+ "mps",
107
+ "kmh"
108
+ ];
109
+ /** QNH/A 物理可信范围:世界海平面气压极值 870(台风 Tip)–1083.8 hPa(蒙古高压 Agata 1968)——
110
+ * 上界取 1084(极值之上即物理不可能,复评实弹 Q1087 由此拦截),下界 800 留裕量;
111
+ * A 组(inHg)按同区间镜像换算外圆整为 23.5–32.5(800 hPa ≈ 23.62、1084 ≈ 32.02)。
112
+ * 越界 = 值不可信 → 判缺测 + value-out-of-range 告警(与 Q10054 位数超界同纪律,绝不静默留假值)。 */
113
+ const QNH_HPA_MIN = 800;
114
+ const QNH_HPA_MAX = 1084;
115
+ const ALT_INHG_MIN = 23.5;
116
+ const ALT_INHG_MAX = 32.5;
117
+ /** 温度物理可信范围:世界极值 +56.7°C(Death Valley 1913)/ −89.2°C(Vostok 1983),
118
+ * 取 60/−90 留裕量(与 QNH 世界极值门同款纪律)——两位数模板空间内的物理不可能值
119
+ * (89/85 构造实弹)不可信判缺测,绝不静默留假值。 */
120
+ const TEMP_C_MIN = -90;
121
+ const TEMP_C_MAX = 60;
122
+ /** LTG 尾随词表(FMH-1 雷电视词与方位):放电类型 IC/CG/CC/CA、位置 OHD/ALQDS/DSNT/VCNTY 与 16 方位点。
123
+ * 实弹(tgftp 全 cycle)LTG 后的方位词此前逐个散落成 unknown remarks(ALQDS/OHD/方位词一片)。 */
124
+ const LTG_TAIL_VOCAB = /* @__PURE__ */ new Set([
125
+ "IC",
126
+ "CG",
127
+ "CC",
128
+ "CA",
129
+ "OHD",
130
+ "ALQDS",
131
+ "DSNT",
132
+ "VCNTY",
133
+ "N",
134
+ "NNE",
135
+ "NE",
136
+ "ENE",
137
+ "E",
138
+ "ESE",
139
+ "SE",
140
+ "SSE",
141
+ "S",
142
+ "SSW",
143
+ "SW",
144
+ "WSW",
145
+ "W",
146
+ "WNW",
147
+ "NW",
148
+ "NNW"
149
+ ]);
150
+ /** 变化能见度尾组形态(RMK VIS 1/2V2 / VIS 1/4V1/2):两侧为整数或分数(可带 M/P 阈值前缀)。 */
151
+ const VIS_V_RANGE = /^[MP]?\d+(?:\/\d+)?V[MP]?\d+(?:\/\d+)?$/;
152
+ const M_WIND = 1;
153
+ const M_VIS = 2;
154
+ const M_R = 4;
155
+ const M_WEATHER = 8;
156
+ const M_CLOUD = 16;
157
+ const M_VV = 32;
158
+ const M_SKY_CLEAR = 64;
159
+ const M_TEMP = 128;
160
+ const M_QNH = 256;
161
+ const M_ALTIMETER_A = 512;
162
+ const M_VIS_RANGE = 1024;
163
+ const M_DOLLAR = 2048;
164
+ const M_WS_RWY = 4096;
165
+ /** 首字符(charCode)→ 候选分支位掩码;0 = 无分支可达(直接落 unknown-token) */
166
+ function maskOf(code) {
167
+ if (code >= 48 && code <= 57) return 131;
168
+ switch (code) {
169
+ case 47: return 155;
170
+ case 86: return 1065;
171
+ case 77: return 138;
172
+ case 80: return 10;
173
+ case 82: return 12;
174
+ case 81: return M_QNH;
175
+ case 65: return M_ALTIMETER_A;
176
+ case 66:
177
+ case 70: return 24;
178
+ case 79: return M_CLOUD;
179
+ case 83: return 88;
180
+ case 78:
181
+ case 67: return M_SKY_CLEAR;
182
+ case 68:
183
+ case 71:
184
+ case 72:
185
+ case 73:
186
+ case 84:
187
+ case 85:
188
+ case 45:
189
+ case 43: return M_WEATHER;
190
+ case 36: return M_DOLLAR;
191
+ case 87: return M_WS_RWY;
192
+ default: return 0;
193
+ }
194
+ }
195
+ const isPhenomenon = (s) => PHENOMENA.some((p) => p === s);
196
+ const isDescriptor = (s) => DESCRIPTORS.some((p) => p === s);
197
+ const isCloudAmount = (s) => CLOUD_AMOUNTS.some((p) => p === s);
198
+ const isSkyClear = (s) => SKY_CLEAR_CODES.some((p) => p === s);
199
+ const isSpeedUnit = (s) => SPEED_UNITS.some((p) => p === s);
200
+ const isConvective = (s) => s === "CB" || s === "TCU";
201
+ function tokenize(raw) {
202
+ const tokens = [];
203
+ const rx = /\S+/g;
204
+ let m;
205
+ while ((m = rx.exec(raw)) !== null) tokens.push({
206
+ text: m[0],
207
+ start: m.index,
208
+ end: m.index + m[0].length
209
+ });
210
+ return tokens;
211
+ }
212
+ const spanOf = (t) => ({
213
+ start: t.start,
214
+ end: t.end
215
+ });
216
+ /** 云层组形态(FEW023CB / /////01CB/// 缺测位形态)——正文循环与趋势段结构化共用一份正则防漂移 */
217
+ const CLOUD_LAYER_PATTERN = /^(FEW|SCT|BKN|OVC|\/\/\/)(\d{3}|\/\/\/)(CB|TCU)?(\/\/\/)?$/;
218
+ /** VV 组形态(VV / VV/// / VV002)——同上共用 */
219
+ const VV_PATTERN = /^VV(\/\/\/|\d{3})?$/;
220
+ /**
221
+ * 趋势段内要素结构化(WMO 306 FM15 §15.14.2:风/能见度/天气/云四族 + 趋势专属 NSW(§15.14.13)与 CAVOK(§15.10)顶替)。
222
+ * best-effort 且静默:认不出的组不产生任何告警(告警面受语料快照锁保护),原文在 raw 保真;
223
+ * 正文循环同款的 token 级解析函数直接复用(parseWindToken/parseVisibilityToken/parseWeatherBody),
224
+ * 组形正则取共享常量。返回 undefined = 没有任何组被识别(如裸 NOSIG)。
225
+ */
226
+ function structureTrendElements(tokens, start) {
227
+ let wind;
228
+ let visibility;
229
+ let cavok;
230
+ let nsw;
231
+ const weather = [];
232
+ let clouds;
233
+ let k = start;
234
+ while (k < tokens.length) {
235
+ const t = tokens[k];
236
+ if (t === void 0) break;
237
+ const next = tokens[k + 1];
238
+ if (t.text === "CAVOK") {
239
+ cavok = { span: spanOf(t) };
240
+ k += 1;
241
+ continue;
242
+ }
243
+ if (t.text === "NSW") {
244
+ nsw = { span: spanOf(t) };
245
+ k += 1;
246
+ continue;
247
+ }
248
+ if (wind === void 0) {
249
+ const w = parseWindToken(t, next);
250
+ if (w !== null && w !== "missing") {
251
+ wind = w.group;
252
+ k += w.consumed;
253
+ continue;
254
+ }
255
+ }
256
+ if (visibility === void 0) {
257
+ const vis = parseVisibilityToken(t, next);
258
+ if (vis !== null && vis.kind === "ok") {
259
+ visibility = vis.group;
260
+ k += vis.consumed;
261
+ continue;
262
+ }
263
+ }
264
+ const wx = parseWeatherBody(t.text);
265
+ if (wx !== null) {
266
+ const { outOfOrder: _outOfOrder, signWithVc: _signWithVc, ...group } = wx;
267
+ weather.push({
268
+ ...group,
269
+ span: spanOf(t)
270
+ });
271
+ k += 1;
272
+ continue;
273
+ }
274
+ const cloudM = CLOUD_LAYER_PATTERN.exec(t.text);
275
+ if (cloudM !== null) {
276
+ const amountRaw = cloudM[1];
277
+ const heightRaw = cloudM[2];
278
+ const convectiveRaw = cloudM[3];
279
+ clouds = {
280
+ elements: [...clouds?.elements ?? [], {
281
+ kind: "layer",
282
+ amount: amountRaw !== void 0 && isCloudAmount(amountRaw) ? amountRaw : null,
283
+ heightFt: {
284
+ value: heightRaw !== void 0 && heightRaw !== "///" ? Number.parseInt(heightRaw, 10) * 100 : null,
285
+ span: spanOf(t)
286
+ },
287
+ convective: convectiveRaw !== void 0 && isConvective(convectiveRaw) ? convectiveRaw : void 0,
288
+ span: spanOf(t)
289
+ }],
290
+ ...clouds?.clear !== void 0 ? { clear: clouds.clear } : {}
291
+ };
292
+ k += 1;
293
+ continue;
294
+ }
295
+ const vvM = VV_PATTERN.exec(t.text);
296
+ if (vvM !== null) {
297
+ const heightRaw = vvM[1];
298
+ clouds = {
299
+ elements: [...clouds?.elements ?? [], {
300
+ kind: "vertical-visibility",
301
+ heightFt: {
302
+ value: heightRaw === void 0 || heightRaw === "///" ? null : Number.parseInt(heightRaw, 10) * 100,
303
+ span: spanOf(t)
304
+ },
305
+ span: spanOf(t)
306
+ }],
307
+ ...clouds?.clear !== void 0 ? { clear: clouds.clear } : {}
308
+ };
309
+ k += 1;
310
+ continue;
311
+ }
312
+ if (isSkyClear(t.text)) {
313
+ clouds = {
314
+ elements: clouds?.elements ?? [],
315
+ clear: {
316
+ code: t.text,
317
+ span: spanOf(t)
318
+ }
319
+ };
320
+ k += 1;
321
+ continue;
322
+ }
323
+ k += 1;
324
+ }
325
+ if (wind === void 0 && visibility === void 0 && cavok === void 0 && nsw === void 0 && weather.length === 0 && clouds === void 0) return void 0;
326
+ return {
327
+ ...wind !== void 0 ? { wind } : {},
328
+ ...visibility !== void 0 ? { visibility } : {},
329
+ ...cavok !== void 0 ? { cavok } : {},
330
+ weather,
331
+ ...nsw !== void 0 ? { nsw } : {},
332
+ ...clouds !== void 0 ? { clouds } : {}
333
+ };
334
+ }
335
+ function joinSpan(tokens) {
336
+ const first = tokens[0];
337
+ const last = tokens[tokens.length - 1];
338
+ if (first === void 0 || last === void 0) return {
339
+ start: 0,
340
+ end: 0
341
+ };
342
+ return {
343
+ start: first.start,
344
+ end: last.end
345
+ };
346
+ }
347
+ /**
348
+ * 紧凑模式出口剥除(E1)v2——重建式:遍历构造全新对象(不含 span/cavokSpan 键),零 mutation。
349
+ * 设计权衡(实测驱动,两版对比):
350
+ * - 构造期逐点跳过 span 的方案在 full 模式引入 ~20% 退化(分支插入组构造热路径),否决;
351
+ * - 旧版出口就地删键(Reflect.deleteProperty)让 V8 把产物退化为 dictionary mode(慢属性):
352
+ * 实测紧凑吞吐仅全模式 ~32%(23 万 vs 72 万行/秒)、存活对象驻留反升 ~2.2×——此为本版根因修复对象;
353
+ * - 重建式出口一次遍历:full 模式与旧版逐字节同路径(零开销),紧凑产物保持 fast properties
354
+ * (隐藏类不丢),实测吞吐回到全模式 ~55% 量级(40.6 万 vs 72.8 万行/秒,旧版仅 32%)、
355
+ * 驻留与全模式持平(1.01×,旧版反升 2.21×——数字见 E 批性能冒烟锁注释)。
356
+ * 数组重建新数组、原语透传;对象均为本次 parse 新建(无共享引用),键序保持原插入序(JSON 面稳定)。
357
+ */
358
+ function compactNode(node) {
359
+ if (Array.isArray(node)) {
360
+ const out = [];
361
+ for (const item of node) out.push(compactNode(item));
362
+ return out;
363
+ }
364
+ if (node === null || typeof node !== "object") return node;
365
+ const out = {};
366
+ for (const [key, value] of Object.entries(node)) {
367
+ if (key === "span" || key === "cavokSpan") continue;
368
+ out[key] = compactNode(value);
369
+ }
370
+ return out;
371
+ }
372
+ /** 天气组合词切分:把「TSRA」拆成描述符 TS + 现象 RA;无法合法切分返回 null。
373
+ * outOfOrder = 能完整切解但语序不合电码表(描述符出现在现象之后,如 RATS=RA+TS)——
374
+ * tolerant 纪律下仍按切解结果收下,由调用方追加 invalid-format 告警。
375
+ * bare = 描述符单独编报(无现象)——TS 恒合法;SH 仅在 VC 邻近语境内合法(VCSH,
376
+ * WMO 306 表 4678 注:机场附近有阵性降水但降水类型不可辨),由 parseWeatherBody 收口。 */
377
+ function splitWeatherToken(body) {
378
+ const chunks = [];
379
+ for (let i = 0; i < body.length; i += 2) chunks.push(body.slice(i, i + 2));
380
+ if (chunks.some((c) => c.length !== 2)) return null;
381
+ const phenomena = [];
382
+ let descriptor;
383
+ let sawPhenomenon = false;
384
+ let outOfOrder = false;
385
+ for (const c of chunks) if (isPhenomenon(c)) {
386
+ phenomena.push(c);
387
+ sawPhenomenon = true;
388
+ } else if (isDescriptor(c) && descriptor === void 0) {
389
+ descriptor = c;
390
+ if (sawPhenomenon) outOfOrder = true;
391
+ } else return null;
392
+ if (phenomena.length === 0 && descriptor !== "TS" && descriptor !== "SH") return null;
393
+ return {
394
+ descriptor,
395
+ phenomena,
396
+ outOfOrder,
397
+ bare: phenomena.length === 0
398
+ };
399
+ }
400
+ /** 天气组词身解析:VC 邻近 / -+ 强度 / 双字母组合切分。返回 null 表示不是合法天气组。
401
+ * signWithVc = 强度符与 VC 并存(-VCTSRA 家族,NWS 自动站实弹):4678 限定槽四选一
402
+ * (light −/moderate 无符/heavy +/vicinity VC)、FAA AIM 明文互斥——tolerant 纪律下按切解
403
+ * 收下(强度+邻近+现象语义可无损恢复,与 RATS→outOfOrder 容忍同构),由调用方出声。 */
404
+ function parseWeatherBody(text) {
405
+ let rest = text;
406
+ let proximity = false;
407
+ let signWithVc = false;
408
+ if (rest.startsWith("VC")) {
409
+ proximity = true;
410
+ rest = rest.slice(2);
411
+ }
412
+ let intensity;
413
+ const sign = rest.slice(0, 1);
414
+ if (sign === "-" || sign === "+") {
415
+ intensity = sign;
416
+ rest = rest.slice(1);
417
+ if (rest.startsWith("VC")) {
418
+ proximity = true;
419
+ signWithVc = true;
420
+ rest = rest.slice(2);
421
+ }
422
+ }
423
+ if (rest.length < 2 || rest.length % 2 !== 0) return null;
424
+ const split = splitWeatherToken(rest);
425
+ if (split === null) return null;
426
+ if (split.bare && split.descriptor === "SH" && !proximity) return null;
427
+ return {
428
+ intensity,
429
+ proximity,
430
+ descriptor: split.descriptor,
431
+ phenomena: split.phenomena,
432
+ outOfOrder: split.outOfOrder,
433
+ signWithVc
434
+ };
435
+ }
436
+ /**
437
+ * 解析天气组 token(含 RE 前缀 / VC 邻近 / -+ 强度)。
438
+ * 返回 kind「current」仅当组进入当前天气列表;「recent」= RE 近期天气(另一组位,不参与 weather 三态);
439
+ * outOfOrder = 词身能切解但语序不合电码表;false = 不是天气组。
440
+ */
441
+ /** 强度符适用面(WMO 306 表 4678 注 4):降水全族可带 -/+;+SS/+FC/+DS 为 4678 表内合法电码
442
+ * (codes.wmo.int/306/4678 稳定概念:+DS heavy duststorm、+SS heavy sandstorm、+FC 发展完好
443
+ * 漏斗云;加拿大差异版另有官方能见度判据 <5/16 SM)——表内形态照常收下、零告警;
444
+ * FMH-1 仅对降水族定义强度测定(美国实务不产 +DS),出处为 4678 电码表本身而非 FMH-1 容错。
445
+ * (2026-09-14 口径归位:旧注释称「+DS 由调用方出声」与实现不符——它本就是表内电码。) */
446
+ const PRECIP_PHENOMENA = /* @__PURE__ */ new Set([
447
+ "RA",
448
+ "SN",
449
+ "SG",
450
+ "IC",
451
+ "PL",
452
+ "GR",
453
+ "GS",
454
+ "UP"
455
+ ]);
456
+ function intensityAllowedFor(intensity, phenomena) {
457
+ if (phenomena.length > 0 && phenomena.every((x) => PRECIP_PHENOMENA.has(x))) return true;
458
+ return intensity === "+" && phenomena.every((x) => x === "SS" || x === "FC" || x === "DS");
459
+ }
460
+ function tryWeatherToken(t, weather, recent) {
461
+ const current = parseWeatherBody(t.text);
462
+ if (current !== null) {
463
+ const { outOfOrder, signWithVc, ...group } = current;
464
+ weather.push({
465
+ ...group,
466
+ span: spanOf(t)
467
+ });
468
+ return {
469
+ kind: "current",
470
+ outOfOrder,
471
+ signWithVc,
472
+ ...current.intensity !== void 0 && !intensityAllowedFor(current.intensity, current.phenomena) ? { intensityMisuse: true } : {}
473
+ };
474
+ }
475
+ if (t.text.startsWith("RE")) {
476
+ const recentBody = parseWeatherBody(t.text.slice(2));
477
+ if (recentBody !== null) {
478
+ const { outOfOrder, signWithVc: _signWithVc, ...group } = recentBody;
479
+ recent.push({
480
+ ...group,
481
+ span: spanOf(t)
482
+ });
483
+ return {
484
+ kind: "recent",
485
+ outOfOrder,
486
+ signWithVc: false,
487
+ ...recentBody.intensity !== void 0 ? { intensityMisuse: true } : {}
488
+ };
489
+ }
490
+ }
491
+ return false;
492
+ }
493
+ /** 能见度:米制 4 位(可带 NDV 后缀)/ 美制 SM(含混分数跨 token 的预合并、P6SM 超上限)/ //// 缺测家族。 */
494
+ function parseVisibilityToken(t, next) {
495
+ if (t.text === "////" || t.text === "////SM") return {
496
+ kind: "missing",
497
+ consumed: 1
498
+ };
499
+ const mDir = /^(\d{4})(NE|NW|SE|SW|N|E|S|W)$/.exec(t.text);
500
+ if (mDir !== null) return {
501
+ kind: "directional",
502
+ group: {
503
+ value: Number.parseInt(mDir[1] ?? "0", 10),
504
+ direction: mDir[2] ?? "",
505
+ span: spanOf(t)
506
+ },
507
+ consumed: 1
508
+ };
509
+ const m4 = /^(\d{4})(NDV)?$/.exec(t.text);
510
+ if (m4 !== null) {
511
+ const digits = m4[1] ?? "";
512
+ const ceiling = digits === "9999";
513
+ const floor = digits === "0000";
514
+ return {
515
+ kind: "ok",
516
+ group: {
517
+ value: Number.parseInt(digits, 10),
518
+ unit: "m",
519
+ exact: !ceiling && !floor,
520
+ ...ceiling ? { beyond: "above" } : {},
521
+ ...floor ? { beyond: "below" } : {},
522
+ span: spanOf(t)
523
+ },
524
+ consumed: 1
525
+ };
526
+ }
527
+ if (/^\d$/.test(t.text) && next !== void 0 && /^\d\/\d+SM$/.test(next.text)) {
528
+ const whole = Number.parseInt(t.text, 10);
529
+ const parts = next.text.slice(0, -2).split("/");
530
+ const num = parts[0];
531
+ const den = parts[1];
532
+ if (num === void 0 || den === void 0) return null;
533
+ if (Number(den) === 0) return {
534
+ kind: "invalid",
535
+ message: `能见度混分数分母为零(${t.text} ${next.text})——解码不出有限值,值不可信判缺测,原码经 span 回溯`,
536
+ span: {
537
+ start: t.start,
538
+ end: next.end
539
+ },
540
+ consumed: 2
541
+ };
542
+ return {
543
+ kind: "ok",
544
+ group: {
545
+ value: whole + Number(num) / Number(den),
546
+ unit: "sm",
547
+ exact: true,
548
+ span: {
549
+ start: t.start,
550
+ end: next.end
551
+ }
552
+ },
553
+ consumed: 2
554
+ };
555
+ }
556
+ const sm = /^(M|P)?(\d+)\/(\d+)SM$|^(M|P)?(\d+)SM$/.exec(t.text);
557
+ if (sm !== null) {
558
+ const prefix = sm[1] ?? sm[4];
559
+ const below = prefix === "M";
560
+ const above = prefix === "P";
561
+ const den = sm[3];
562
+ if (den !== void 0 && Number(den) === 0) return {
563
+ kind: "invalid",
564
+ message: `能见度分数分母为零(${t.text})——解码不出有限值,值不可信判缺测,原码经 span 回溯`,
565
+ span: spanOf(t),
566
+ consumed: 1
567
+ };
568
+ return {
569
+ kind: "ok",
570
+ group: {
571
+ value: sm[2] !== void 0 ? Number(sm[2]) / Number(den) : Number.parseInt(sm[5] ?? "0", 10),
572
+ unit: "sm",
573
+ exact: !below && !above,
574
+ ...below ? { beyond: "below" } : {},
575
+ ...above ? { beyond: "above" } : {},
576
+ span: spanOf(t)
577
+ },
578
+ consumed: 1
579
+ };
580
+ }
581
+ return null;
582
+ }
583
+ /** 风:33002MPS / VRB02KT / 31015G25KT / /////KT 缺测形态 + 260V050 变化组。 */
584
+ function parseWindToken(t, next) {
585
+ if (/^\/{5}(KT|MPS|KMH)$/.test(t.text)) return "missing";
586
+ const m = /^(VRB|\d{3}|\/\/\/)(\d{2,3}|P\d{2}|\/\/\/|\/\/)(?:G(\d{2,3}|P\d{2}|\/\/\/|\/\/))?(KT|MPS|KMH)$/.exec(t.text);
587
+ if (m === null) return null;
588
+ const span = spanOf(t);
589
+ const dirRaw = m[1] ?? "";
590
+ const speedRaw = m[2] ?? "";
591
+ const unitRaw = (m[4] ?? "kt").toLowerCase();
592
+ if (!isSpeedUnit(unitRaw)) return null;
593
+ if (dirRaw === "///" || speedRaw === "///" || speedRaw === "//") return "missing";
594
+ const variable = dirRaw === "VRB";
595
+ const gustRaw = m[3];
596
+ const speedBeyond = speedRaw.startsWith("P") ? "above" : void 0;
597
+ const gustBeyond = gustRaw !== void 0 && gustRaw.startsWith("P") ? "above" : void 0;
598
+ const rangeFindings = [];
599
+ let direction = variable ? null : Number.parseInt(dirRaw, 10);
600
+ if (!variable && direction !== null && direction > 360) {
601
+ rangeFindings.push({
602
+ message: `风向越界(${dirRaw},须 0–360)——风向位判缺测,原码经 span 回溯`,
603
+ span
604
+ });
605
+ direction = null;
606
+ }
607
+ let consumed = 1;
608
+ let variation;
609
+ if (next !== void 0 && /^\d{3}V\d{3}$/.test(next.text)) {
610
+ const parts = next.text.split("V");
611
+ const min = parts[0];
612
+ const max = parts[1];
613
+ if (min !== void 0 && max !== void 0) {
614
+ const minDeg = Number.parseInt(min, 10);
615
+ const maxDeg = Number.parseInt(max, 10);
616
+ if (minDeg > 360 || maxDeg > 360) rangeFindings.push({
617
+ message: `风向变化组端点越界(${next.text},须 0–360)——变化组判缺测,原码经 span 回溯`,
618
+ span: spanOf(next)
619
+ });
620
+ else variation = {
621
+ min: minDeg,
622
+ max: maxDeg,
623
+ span: spanOf(next)
624
+ };
625
+ consumed = 2;
626
+ }
627
+ }
628
+ const gustMissing = gustRaw === "//" || gustRaw === "///";
629
+ return {
630
+ group: {
631
+ variable,
632
+ direction,
633
+ speed: {
634
+ value: Number.parseInt(speedRaw.startsWith("P") ? speedRaw.slice(1) : speedRaw, 10),
635
+ unit: unitRaw,
636
+ span,
637
+ ...speedBeyond !== void 0 ? { beyond: speedBeyond } : {}
638
+ },
639
+ gust: gustRaw !== void 0 && !gustMissing ? {
640
+ value: Number.parseInt(gustRaw.startsWith("P") ? gustRaw.slice(1) : gustRaw, 10),
641
+ unit: unitRaw,
642
+ span,
643
+ ...gustBeyond !== void 0 ? { beyond: gustBeyond } : {}
644
+ } : void 0,
645
+ variation
646
+ },
647
+ consumed,
648
+ rangeFindings,
649
+ ...gustMissing ? { gustMissing: true } : {}
650
+ };
651
+ }
652
+ /**
653
+ * Temperature/dewpoint group shape: always dd/dd (M = negative; // on either side = that side missing).
654
+ * 温度/露点组形态:恒为 dd/dd(M = 负;任一侧 // = 该侧缺测)——两位是形态定义本身,不是惯例:
655
+ * WMO 电码表每个符号字母严格对应一位数字(T' 十位 / T 个位,负值加 M),官方示例含补零(01/M12、
656
+ * 00/M00),任何规范版本无一位数形态。两侧 \d{1,2} 曾让裸分数 token(VIS 词丢失的 1/2、2/1——
657
+ * 上游脏输入)被误判为温露组:降序分数捏造假值且完全静默,升序触发误导性温露倒挂告警
658
+ * (2026-09-14 复评收紧);不匹配即落 unknown-token 出声,原码经 span 回溯。
659
+ * The regex requires the `/` separator — a bare "//" is the weather missing group (see the body loop) and must not be swallowed here.
660
+ * 正则必须有 `/` 分隔——裸 `//` 是天气缺测组(见正文循环),不得被本组吞掉。
661
+ * Exported for direct shape assertions in regression tests (red line: "//" must not match; "1/2"/"2/1" must not match either).
662
+ * 导出供回归测试直接对形态断言(红线:对 "//" 与裸分数均不得匹配)。
663
+ */
664
+ const TEMP_DEW_PATTERN = /^(M?\d{2}|\/\/)\/(M?\d{2}|\/\/)$/;
665
+ /** 温度/露点组:恒为 dd/dd 形态(M = 负;// = 缺测 → null,调用方补告警)。 */
666
+ function parseTempDewToken(t) {
667
+ const read = (raw) => {
668
+ if (raw === void 0 || raw === "//") return null;
669
+ const n = raw.startsWith("M") ? -Number.parseInt(raw.slice(1), 10) : Number.parseInt(raw, 10);
670
+ return {
671
+ celsius: n === 0 ? 0 : n,
672
+ span: spanOf(t)
673
+ };
674
+ };
675
+ const m = TEMP_DEW_PATTERN.exec(t.text);
676
+ if (m === null) {
677
+ const trailing = /^(M?\d{2})\/$/.exec(t.text);
678
+ if (trailing === null) return null;
679
+ return {
680
+ temperature: read(trailing[1]),
681
+ dewpoint: null,
682
+ span: spanOf(t)
683
+ };
684
+ }
685
+ return {
686
+ temperature: read(m[1]),
687
+ dewpoint: read(m[2]),
688
+ span: spanOf(t)
689
+ };
690
+ }
691
+ function beyondRangeOf(flag) {
692
+ if (flag === "P") return "above";
693
+ if (flag === "M") return "below";
694
+ }
695
+ /** RVR 组:R07R/1800V2200FT / R36/0500V0800D / P·M 超界 / 斜杠趋势(R13/3500FT/N、R07/P6000FT/U)。
696
+ * R/SNOCLO 与 R10L/SNOCLO 属跑道状态语义(15.13.6.1),由 parseRunwayStateToken 收口。 */
697
+ function parseRvrToken(t) {
698
+ const m = /^R(\d{2}[RLC]?)\/(P|M)?(\d{4})(?:V(P|M)?(\d{4}))?(FT)?(?:\/([DNU])|([DNU]))?$/.exec(t.text);
699
+ if (m === null) return null;
700
+ const minRaw = m[3] ?? "0";
701
+ const maxRaw = m[5];
702
+ const trendRaw = m[7] ?? m[8];
703
+ return {
704
+ runway: m[1] ?? "",
705
+ value: maxRaw === void 0 ? Number.parseInt(minRaw, 10) : void 0,
706
+ min: maxRaw !== void 0 ? Number.parseInt(minRaw, 10) : void 0,
707
+ max: maxRaw !== void 0 ? Number.parseInt(maxRaw, 10) : void 0,
708
+ beyondRange: beyondRangeOf(m[2] ?? m[4]),
709
+ unit: m[6] === "FT" ? "ft" : "m",
710
+ trend: trendRaw === "U" ? "up" : trendRaw === "D" ? "down" : trendRaw === "N" ? "no-change" : void 0,
711
+ span: spanOf(t)
712
+ };
713
+ }
714
+ /** 摩擦两位电码(15.13.6.1 表 0366)解码:01–90 → 摩擦系数 0.01–0.90;91–95 → 制动作用五档;
715
+ * 99 → unreliable;// 或 96–98(电码表未用)→ 两字段皆 undefined(原码经 span 回溯)。 */
716
+ function parseFrictionCode(code) {
717
+ if (code === void 0 || !/^\d{2}$/.test(code)) return {};
718
+ const f = Number.parseInt(code, 10);
719
+ if (f <= 90) return { frictionCoefficient: f / 100 };
720
+ if (f >= 91 && f <= 95) return { brakingAction: [
721
+ "poor",
722
+ "medium-poor",
723
+ "medium",
724
+ "medium-good",
725
+ "good"
726
+ ][f - 91] };
727
+ if (f === 99) return { brakingAction: "unreliable" };
728
+ return {};
729
+ }
730
+ /** 跑道状态组三形态(WMO 15.13.6):①六位状态电码(R21/490160:沉积/覆盖/深度/摩擦,位缺测 /);
731
+ * ②CLRD 清除家族(R07L/CLRD// 标准、CLRD62 俄区、CLRD/// 非标容忍);③SNOCLO 关闭(R/SNOCLO 全机场、R10L/SNOCLO 逐跑道)。
732
+ * 覆盖位非法电码(表 0519 外数字)不静默留值:该位判缺测 + findings 交调用方落 invalid-format 告警。 */
733
+ function parseRunwayStateToken(t) {
734
+ const snoclo = /^R(\d{2}[RLC]?)?\/SNOCLO$/.exec(t.text);
735
+ if (snoclo !== null) return {
736
+ group: {
737
+ runway: snoclo[1] ?? "",
738
+ closed: true,
739
+ cleared: false,
740
+ span: spanOf(t)
741
+ },
742
+ findings: []
743
+ };
744
+ const clrd = /^R(\d{2}[RLC]?)\/CLRD(\d{2}|\/\/|\/\/\/)?$/.exec(t.text);
745
+ if (clrd !== null) return {
746
+ group: {
747
+ runway: clrd[1] ?? "",
748
+ cleared: true,
749
+ ...parseFrictionCode(clrd[2]),
750
+ span: spanOf(t)
751
+ },
752
+ findings: []
753
+ };
754
+ const num = /^R(\d{2}[RLC]?)\/(\d|\/)(\d|\/)(\d{2}|\/\/)(\d{2}|\/\/)$/.exec(t.text);
755
+ if (num === null) return null;
756
+ const depthCode = num[4] ?? "";
757
+ const depthNum = /^\d{2}$/.test(depthCode) ? Number.parseInt(depthCode, 10) : null;
758
+ const depth = depthNum === null ? null : depthNum <= 90 ? depthNum : depthNum === 91 ? null : depthNum === 99 ? null : (depthNum - 90) * 50;
759
+ const coverageCode = num[3] ?? "/";
760
+ const coverageIllegal = coverageCode !== "/" && !"1259".includes(coverageCode);
761
+ const findings = coverageIllegal ? [{
762
+ message: `跑道状态覆盖位电码非法(${t.text} 覆盖位 ${coverageCode}——WMO 表 0519 仅用 1/2/5/9 与 /)——该位判缺测,原码经 span 回溯`,
763
+ span: spanOf(t)
764
+ }] : [];
765
+ return {
766
+ group: {
767
+ runway: num[1] ?? "",
768
+ closed: depthNum === 99 || void 0,
769
+ cleared: false,
770
+ deposit: num[2] === "/" ? null : Number(num[2]),
771
+ coverage: coverageCode === "/" || coverageIllegal ? null : Number(coverageCode),
772
+ depth,
773
+ ...parseFrictionCode(num[5]),
774
+ span: spanOf(t)
775
+ },
776
+ findings
777
+ };
778
+ }
779
+ /**
780
+ * Parse one METAR/SPECI report (tolerant mode) into the IR — the package's single entry.
781
+ * 解析单条 METAR/SPECI 报文(tolerant)为 IR——本包唯一入口。
782
+ *
783
+ * Throws MetarParseError (stable machine-readable `code`) on whole-report failure
784
+ * (non-string input / missing station / missing time / invalid time / unsupported mode); anything the
785
+ * parser does not recognize lands in `warnings[]` with its span — never dropped.
786
+ * 整体失败(输入非字符串/无站名/无时组/时组越界/未实现模式)抛 MetarParseError(code 稳定契约);
787
+ * 看不懂的组带 span 进 warnings[],绝不丢弃。
788
+ * @param raw - Report text, verbatim (kept on report.raw). 报文原文(原样保真于 report.raw)。
789
+ * @param options - See ParseOptions (kind override, compact spans). 见 ParseOptions(类型位注入、紧凑模式)。
790
+ */
791
+ function parse(raw, options) {
792
+ const compact = options?.spans === false;
793
+ const compactIfEnabled = (report) => compact ? compactNode(report) : report;
794
+ if (typeof raw !== "string") throw new MetarParseError("invalid-input", String(raw), `parse 需要一个 METAR/SPECI 报文字符串,收到 ${raw === null ? "null" : typeof raw}`);
795
+ if ((options?.mode ?? "tolerant") === "strict") throw new MetarParseError("unsupported-mode", raw, "strict 模式尚未实现(v0.1 仅 tolerant)——请省略 mode 或显式传 'tolerant'");
796
+ const warnings = [];
797
+ const tail = raw.at(-1);
798
+ const tokens = tokenize(tail === void 0 || tail === "=" || /\s/.test(tail) ? raw.replace(/[\s=]+$/, "") : raw);
799
+ const TOKEN_COUNT_LIMIT = 128;
800
+ if (tokens.length > TOKEN_COUNT_LIMIT) warnings.push({
801
+ code: "invalid-format",
802
+ severity: "warning",
803
+ message: `输入 token 数超上限(${tokens.length} > ${TOKEN_COUNT_LIMIT})——按异常输入标记,解析照常完整,原文经 raw 保真`
804
+ });
805
+ let i = 0;
806
+ const peek = (ahead = 0) => tokens[i + ahead];
807
+ const externalKind = options?.kind;
808
+ let kind = externalKind ?? "metar";
809
+ let corrected = false;
810
+ let auto = false;
811
+ const head = peek();
812
+ if (head !== void 0 && (head.text === "METAR" || head.text === "SPECI")) {
813
+ if (externalKind === void 0) kind = head.text === "SPECI" ? "speci" : "metar";
814
+ i += 1;
815
+ }
816
+ if (peek()?.text === "COR") {
817
+ corrected = true;
818
+ i += 1;
819
+ }
820
+ if (peek()?.text === "AMD") i += 1;
821
+ const stTok = peek();
822
+ if (stTok === void 0 || !/^[A-Z0-9]{4}$/.test(stTok.text)) throw new MetarParseError("missing-station", raw, `无法识别站名组——输入不是 METAR/SPECI 报文(${stTok?.text ?? "空输入"})`);
823
+ const station = stTok.text;
824
+ i += 1;
825
+ const driftTok = peek();
826
+ if (driftTok !== void 0 && /^(CC[A-Z]|COR)$/.test(driftTok.text)) {
827
+ const afterDrift = peek(1);
828
+ if (afterDrift !== void 0 && /^\d{2}\d{2}\d{2}Z$/.test(afterDrift.text)) {
829
+ corrected = true;
830
+ i += 1;
831
+ warnings.push({
832
+ code: "invalid-format",
833
+ severity: "info",
834
+ message: `更正标记槽位漂移(${driftTok.text} 出现在站名后/时组前——已消费并置更正标志)`,
835
+ span: spanOf(driftTok)
836
+ });
837
+ }
838
+ }
839
+ const tmTok = peek();
840
+ const tm = tmTok !== void 0 ? /^(\d{2})(\d{2})(\d{2})Z$/.exec(tmTok.text) : null;
841
+ if (tmTok === void 0 || tm === null) throw new MetarParseError("missing-time", raw, `无法识别时组——输入不是完整的 METAR/SPECI 报文(${tmTok?.text ?? "时组缺失"})`);
842
+ const time = {
843
+ day: Number.parseInt(tm[1] ?? "0", 10),
844
+ hour: Number.parseInt(tm[2] ?? "0", 10),
845
+ minute: Number.parseInt(tm[3] ?? "0", 10)
846
+ };
847
+ if (time.day < 1 || time.day > 31 || time.hour > 23 || time.minute > 59) throw new MetarParseError("invalid-time", raw, `时组数值越界(${tmTok.text}:须日 01–31 / 时 00–23 / 分 00–59)——输入不是完整的 METAR/SPECI 报文`);
848
+ i += 1;
849
+ if (peek()?.text === "AUTO") {
850
+ auto = true;
851
+ i += 1;
852
+ }
853
+ if (peek()?.text === "COR") {
854
+ corrected = true;
855
+ i += 1;
856
+ }
857
+ if (/^RR[ABC]$/.test(peek()?.text ?? "")) i += 1;
858
+ if (/^CC[A-Z]$/.test(peek()?.text ?? "")) {
859
+ corrected = true;
860
+ i += 1;
861
+ }
862
+ if (peek()?.text === "NIL") return compactIfEnabled({
863
+ kind,
864
+ raw,
865
+ nil: true,
866
+ station,
867
+ time,
868
+ flags: {
869
+ auto,
870
+ corrected
871
+ },
872
+ cavok: false,
873
+ trends: [],
874
+ runwayStates: [],
875
+ remarks: [],
876
+ warnings
877
+ });
878
+ let cavok = false;
879
+ let cavokSpan;
880
+ let wind;
881
+ let visibility;
882
+ let directionalAsPrimary = false;
883
+ let rvr;
884
+ const rvrList = [];
885
+ let rvrSpan;
886
+ let weather;
887
+ const weatherList = [];
888
+ const recentList = [];
889
+ let clouds;
890
+ let cloudSeen = false;
891
+ const cloudElements = [];
892
+ let clearCode;
893
+ let temperature;
894
+ let dewpoint;
895
+ let altimeter;
896
+ let altimeterSeen = false;
897
+ const setAltimeter = (reading) => {
898
+ if (altimeterSeen) dupGroupWarning("气压组", altimeter?.span, reading.span);
899
+ altimeterSeen = true;
900
+ altimeter = reading;
901
+ };
902
+ const trends = [];
903
+ const runwayStates = [];
904
+ const remarks = [];
905
+ const dupGroupWarning = (label, prevSpan, newSpan) => {
906
+ const prevText = prevSpan === void 0 ? `(原${label})` : raw.slice(prevSpan.start, prevSpan.end);
907
+ const newText = newSpan === void 0 ? label : raw.slice(newSpan.start, newSpan.end);
908
+ warnings.push({
909
+ code: "duplicate-group",
910
+ severity: "warning",
911
+ message: `重复${label}(前值 ${prevText},后值 ${newText})——报文只应有一组${label},以末组为准,前值经原文回溯`,
912
+ span: newSpan
913
+ });
914
+ };
915
+ const trendCloseWarning = () => {
916
+ const btTok = tokens[i];
917
+ const bt = btTok?.text;
918
+ if (bt === void 0 || btTok === void 0) return;
919
+ if (TREND_KINDS.has(bt) || bt.startsWith("RMK") || bt === "$") return;
920
+ if (/^(BECMG|TEMPO)(AT|TL|FM)\d{4}$/.test(bt)) return;
921
+ if (/^R(\d{2}[RLC]?\/|\/SNOCLO$)/.test(bt)) {
922
+ warnings.push({
923
+ code: "invalid-format",
924
+ severity: "info",
925
+ message: `趋势段收口于 R 组(${bt}——RVR/跑道状态不属趋势要素,按正文组处理,趋势语境存疑)`,
926
+ span: spanOf(btTok)
927
+ });
928
+ return;
929
+ }
930
+ warnings.push({
931
+ code: "invalid-format",
932
+ severity: "info",
933
+ message: `趋势段收口于非趋势组(${bt}——不属趋势要素族,交回正文认组,趋势语境存疑)`,
934
+ span: spanOf(btTok)
935
+ });
936
+ };
937
+ const collectTrendTokens = (collected) => {
938
+ for (;;) {
939
+ const inner = tokens[i];
940
+ if (inner === void 0 || !isTrendCollectible(inner, tokens[i + 1])) break;
941
+ collected.push(inner);
942
+ i += 1;
943
+ }
944
+ };
945
+ const cavokConflicts = (whenLabel) => {
946
+ const at = cavokSpan;
947
+ if (visibility?.kind === "value") {
948
+ const g = visibility.value;
949
+ const meters = g.unit === "m" ? g.value : g.value * 1609.344;
950
+ const visRaw = visibility.span === void 0 ? `${g.value} ${g.unit}` : raw.slice(visibility.span.start, visibility.span.end);
951
+ if (g.beyond === "below" || g.unit === "sm" && visRaw.startsWith("M") || g.exact && meters < 1e4) warnings.push({
952
+ code: "cross-check-conflict",
953
+ severity: "warning",
954
+ message: `CAVOK 与${whenLabel}能见度组矛盾(${visRaw},CAVOK 语义要求 ≥10km)——让位照旧,报文自洽性存疑`,
955
+ span: at
956
+ });
957
+ }
958
+ if (weatherList.length > 0) {
959
+ const wxRaw = weatherList.map((g) => `${g.proximity ? "VC" : ""}${g.intensity ?? ""}${g.descriptor ?? ""}${g.phenomena.join("")}`).join(" ");
960
+ warnings.push({
961
+ code: "cross-check-conflict",
962
+ severity: "warning",
963
+ message: `CAVOK 与${whenLabel}天气组矛盾(${wxRaw},CAVOK 语义要求无重要天气)——让位照旧,报文自洽性存疑`,
964
+ span: at
965
+ });
966
+ }
967
+ if (rvr !== void 0 && rvr.kind === "value") {
968
+ const rvrRaw = rvr.span === void 0 ? "" : raw.slice(rvr.span.start, rvr.span.end);
969
+ warnings.push({
970
+ code: "cross-check-conflict",
971
+ severity: "warning",
972
+ message: `CAVOK 与${whenLabel}RVR 组矛盾(${rvrRaw}——AP-117 第 140 条:CAVOK 代替能见度、跑道视程、现在天气和云)——让位照旧,报文自洽性存疑`,
973
+ span: at
974
+ });
975
+ }
976
+ if (cloudElements.some((e) => {
977
+ if (e.kind === "layer" && e.convective !== void 0) return true;
978
+ const h = e.heightFt.value;
979
+ return h !== null && h < 5e3;
980
+ })) {
981
+ const cloudRaw = cloudElements.map((e) => `${e.kind === "layer" ? e.amount ?? "///" : "VV"}${e.heightFt.value === null ? "///" : String(Math.round(e.heightFt.value / 100)).padStart(3, "0")}${e.kind === "layer" ? e.convective ?? "" : ""}`).join(" ");
982
+ warnings.push({
983
+ code: "cross-check-conflict",
984
+ severity: "warning",
985
+ message: `CAVOK 与${whenLabel}云组矛盾(${cloudRaw},CAVOK 语义要求 5000ft 以下无云且无 CB/TCU)——让位照旧,报文自洽性存疑`,
986
+ span: at
987
+ });
988
+ }
989
+ };
990
+ let windShear;
991
+ let wsRunways;
992
+ let wsAll = false;
993
+ let wsSpan;
994
+ while (i < tokens.length) {
995
+ const t = tokens[i];
996
+ if (t === void 0) break;
997
+ const text = t.text;
998
+ if (text === "RMK") {
999
+ i += 1;
1000
+ break;
1001
+ }
1002
+ if (text.startsWith("RMK")) break;
1003
+ if (TREND_KINDS.has(text)) {
1004
+ const kindText = text;
1005
+ const collected = [t];
1006
+ i += 1;
1007
+ let period;
1008
+ const periodTok = peek();
1009
+ if (kindText !== "NOSIG" && periodTok !== void 0 && /^(AT|FM|TL)\d{4}$/.test(periodTok.text)) {
1010
+ period = {
1011
+ text: periodTok.text,
1012
+ span: spanOf(periodTok)
1013
+ };
1014
+ collected.push(periodTok);
1015
+ i += 1;
1016
+ }
1017
+ collectTrendTokens(collected);
1018
+ trendCloseWarning();
1019
+ const elements = kindText === "NOSIG" ? void 0 : structureTrendElements(collected, period !== void 0 ? 2 : 1);
1020
+ trends.push({
1021
+ kind: kindText === "NOSIG" ? "nosig" : kindText === "BECMG" ? "becmg" : "tempo",
1022
+ period,
1023
+ ...elements !== void 0 ? { elements } : {},
1024
+ raw: collected.map((c) => c.text).join(" "),
1025
+ span: joinSpan(collected)
1026
+ });
1027
+ continue;
1028
+ }
1029
+ if (text.length === 6 && (text.charCodeAt(0) === 65 || text.charCodeAt(0) === 84 || text.charCodeAt(0) === 70) || text.startsWith("BECMG") || text.startsWith("TEMPO")) {
1030
+ const fused = /^(BECMG|TEMPO)(AT|TL|FM)\d{4}$/.exec(text);
1031
+ if (fused !== null) {
1032
+ const indicator = fused[1] ?? "";
1033
+ const collected = [t];
1034
+ i += 1;
1035
+ collectTrendTokens(collected);
1036
+ trendCloseWarning();
1037
+ const fusedElements = structureTrendElements(collected, 1);
1038
+ trends.push({
1039
+ kind: indicator === "BECMG" ? "becmg" : "tempo",
1040
+ period: {
1041
+ text: text.slice(indicator.length),
1042
+ span: {
1043
+ start: t.start + indicator.length,
1044
+ end: t.end
1045
+ }
1046
+ },
1047
+ ...fusedElements !== void 0 ? { elements: fusedElements } : {},
1048
+ raw: collected.map((c) => c.text).join(" "),
1049
+ span: joinSpan(collected)
1050
+ });
1051
+ warnings.push({
1052
+ code: "invalid-format",
1053
+ severity: "info",
1054
+ message: `趋势指示组与时段词粘连(${text}——传输磨损丢空格,BECMG/TEMPO 与 AT/TL/FM 时段语义完整可恢复)`,
1055
+ span: spanOf(t)
1056
+ });
1057
+ continue;
1058
+ }
1059
+ if (/^(AT|TL|FM)\d{4}$/.test(text)) {
1060
+ const collected = [t];
1061
+ i += 1;
1062
+ collectTrendTokens(collected);
1063
+ trendCloseWarning();
1064
+ const bareElements = structureTrendElements(collected, 1);
1065
+ trends.push({
1066
+ kind: "unspecified",
1067
+ period: {
1068
+ text,
1069
+ span: spanOf(t)
1070
+ },
1071
+ ...bareElements !== void 0 ? { elements: bareElements } : {},
1072
+ raw: collected.map((c) => c.text).join(" "),
1073
+ span: joinSpan(collected)
1074
+ });
1075
+ warnings.push({
1076
+ code: "invalid-format",
1077
+ severity: "warning",
1078
+ message: `趋势时段词缺指示组(${text}——§15.14.3 时段词须随 BECMG/TEMPO 出现)——按磨损趋势段收下,指示组类型不可辨`,
1079
+ span: spanOf(t)
1080
+ });
1081
+ continue;
1082
+ }
1083
+ }
1084
+ if (text === "CAVOK") {
1085
+ cavok = true;
1086
+ cavokSpan = spanOf(t);
1087
+ cavokConflicts("前序");
1088
+ visibility = void 0;
1089
+ directionalAsPrimary = false;
1090
+ weather = void 0;
1091
+ weatherList.length = 0;
1092
+ cloudSeen = false;
1093
+ cloudElements.length = 0;
1094
+ clearCode = void 0;
1095
+ i += 1;
1096
+ continue;
1097
+ }
1098
+ const mask = maskOf(text.charCodeAt(0));
1099
+ const windParsed = (mask & M_WIND) !== 0 ? parseWindToken(t, peek(1)) : null;
1100
+ if (windParsed !== null) {
1101
+ if (wind !== void 0) dupGroupWarning("风组", wind.span, spanOf(t));
1102
+ if (windParsed === "missing") {
1103
+ if (wind === void 0) {
1104
+ wind = {
1105
+ kind: "missing",
1106
+ span: spanOf(t)
1107
+ };
1108
+ warnings.push({
1109
+ code: "missing-expected",
1110
+ severity: "info",
1111
+ message: t.text.startsWith("/////") ? "风组缺测(/////KT,疑似自动站假报文形态)" : `风组缺测(${t.text},风速位缺测)`,
1112
+ span: spanOf(t)
1113
+ });
1114
+ }
1115
+ i += 1;
1116
+ } else {
1117
+ const wg = windParsed.group;
1118
+ if (!Number.isFinite(wg.speed.value) || wg.gust !== void 0 && !Number.isFinite(wg.gust.value)) {
1119
+ wind = {
1120
+ kind: "missing",
1121
+ span: spanOf(t)
1122
+ };
1123
+ warnings.push({
1124
+ code: "value-out-of-range",
1125
+ severity: "warning",
1126
+ message: `风组解码出非有限值(${t.text})——值不可信判缺测,原码经 span 回溯`,
1127
+ span: spanOf(t)
1128
+ });
1129
+ } else if (wg.speed.value > 199 || (wg.gust?.value ?? 0) > 199) {
1130
+ wind = {
1131
+ kind: "missing",
1132
+ span: spanOf(t)
1133
+ };
1134
+ warnings.push({
1135
+ code: "value-out-of-range",
1136
+ severity: "warning",
1137
+ message: `风速超出编码范围(${t.text},三位数模板上限 199 ${wg.speed.unit})——值不可信判缺测,原码经 span 回溯`,
1138
+ span: spanOf(t)
1139
+ });
1140
+ } else {
1141
+ wind = {
1142
+ kind: "value",
1143
+ value: wg,
1144
+ span: spanOf(t)
1145
+ };
1146
+ for (const finding of windParsed.rangeFindings) warnings.push({
1147
+ code: "value-out-of-range",
1148
+ severity: "warning",
1149
+ message: finding.message,
1150
+ span: finding.span
1151
+ });
1152
+ if (windParsed.gustMissing === true) warnings.push({
1153
+ code: "missing-expected",
1154
+ severity: "info",
1155
+ message: `阵风位缺测(${t.text}——G 后斜杠位缺测,组照常成立)`,
1156
+ span: spanOf(t)
1157
+ });
1158
+ if (wg.variable && wg.variation !== void 0) {
1159
+ const vs = wg.variation.span;
1160
+ warnings.push({
1161
+ code: "cross-check-conflict",
1162
+ severity: "info",
1163
+ message: `静风变向(VRB)与风向变化组(${vs === void 0 ? "" : raw.slice(vs.start, vs.end)})并存——VRB 本义方向不定,变化组冗余,报文自洽性存疑`,
1164
+ span: vs
1165
+ });
1166
+ }
1167
+ }
1168
+ i += windParsed.consumed;
1169
+ }
1170
+ continue;
1171
+ }
1172
+ const visParsed = (mask & M_VIS) !== 0 ? parseVisibilityToken(t, peek(1)) : null;
1173
+ if (visParsed !== null) {
1174
+ if (visParsed.kind === "directional") {
1175
+ if (visibility?.kind === "value") {
1176
+ if (visibility.value.minimum !== void 0 || directionalAsPrimary) dupGroupWarning("最低能见度方向组", visibility.value.minimum?.span, spanOf(t));
1177
+ visibility = {
1178
+ kind: "value",
1179
+ value: {
1180
+ ...visibility.value,
1181
+ minimum: {
1182
+ value: visParsed.group.value,
1183
+ direction: visParsed.group.direction,
1184
+ span: visParsed.group.span
1185
+ }
1186
+ },
1187
+ span: visibility.span
1188
+ };
1189
+ } else {
1190
+ visibility = {
1191
+ kind: "value",
1192
+ value: {
1193
+ value: visParsed.group.value,
1194
+ unit: "m",
1195
+ exact: true,
1196
+ span: visParsed.group.span
1197
+ }
1198
+ };
1199
+ directionalAsPrimary = true;
1200
+ warnings.push({
1201
+ code: "invalid-format",
1202
+ severity: "info",
1203
+ message: `最低能见度方向组脱离主导能见度(${t.text})——按能见度收下`,
1204
+ span: spanOf(t)
1205
+ });
1206
+ }
1207
+ i += visParsed.consumed;
1208
+ continue;
1209
+ }
1210
+ if (visibility !== void 0) dupGroupWarning("能见度组", visibility.span, spanOf(t));
1211
+ if (visParsed.kind === "missing") {
1212
+ if (visibility === void 0) {
1213
+ visibility = {
1214
+ kind: "missing",
1215
+ span: spanOf(t)
1216
+ };
1217
+ warnings.push({
1218
+ code: "missing-expected",
1219
+ severity: "info",
1220
+ message: `能见度组缺测(${t.text},无法观测能见度)`,
1221
+ span: spanOf(t)
1222
+ });
1223
+ }
1224
+ directionalAsPrimary = false;
1225
+ i += visParsed.consumed;
1226
+ continue;
1227
+ } else if (visParsed.kind === "invalid") {
1228
+ visibility = {
1229
+ kind: "missing",
1230
+ span: visParsed.span
1231
+ };
1232
+ directionalAsPrimary = false;
1233
+ warnings.push({
1234
+ code: "value-out-of-range",
1235
+ severity: "warning",
1236
+ message: visParsed.message,
1237
+ span: visParsed.span
1238
+ });
1239
+ } else {
1240
+ visibility = {
1241
+ kind: "value",
1242
+ value: visParsed.group,
1243
+ span: visParsed.group.span
1244
+ };
1245
+ directionalAsPrimary = false;
1246
+ }
1247
+ i += visParsed.consumed;
1248
+ continue;
1249
+ }
1250
+ if ((mask & M_R) !== 0 && text === "RVRNO") {
1251
+ if (rvr?.kind === "value") {
1252
+ const prevText = rvr.span === void 0 ? "" : raw.slice(rvr.span.start, rvr.span.end);
1253
+ warnings.push({
1254
+ code: "cross-check-conflict",
1255
+ severity: "info",
1256
+ message: `RVRNO(站级:应报而缺)与 RVR 值组并存(${prevText})——矛盾形态,值组按跑道级明细保留,RVRNO 经 remarks/raw 可回溯`,
1257
+ span: spanOf(t)
1258
+ });
1259
+ } else {
1260
+ rvr = {
1261
+ kind: "missing",
1262
+ span: spanOf(t)
1263
+ };
1264
+ warnings.push({
1265
+ code: "missing-expected",
1266
+ severity: "info",
1267
+ message: "RVR 设备在但明示不可用(RVRNO)",
1268
+ span: spanOf(t)
1269
+ });
1270
+ }
1271
+ remarks.push({
1272
+ kind: "rvr-no",
1273
+ raw: text,
1274
+ span: spanOf(t)
1275
+ });
1276
+ i += 1;
1277
+ continue;
1278
+ }
1279
+ if ((mask & M_R) !== 0 && text.startsWith("R") && text.includes("/")) {
1280
+ const rvrParsed = parseRvrToken(t);
1281
+ if (rvrParsed !== null) {
1282
+ if (rvr?.kind === "missing") {
1283
+ const prevText = rvr.span === void 0 ? "" : raw.slice(rvr.span.start, rvr.span.end);
1284
+ warnings.push({
1285
+ code: "cross-check-conflict",
1286
+ severity: "info",
1287
+ message: `RVRNO(站级:应报而缺)与后随 RVR 值组并存(${prevText})——矛盾形态,值组按跑道级明细保留(可解读为传感器恢复),RVRNO 经 remarks/raw 可回溯`,
1288
+ span: spanOf(t)
1289
+ });
1290
+ }
1291
+ rvrList.push(rvrParsed);
1292
+ const s = spanOf(t);
1293
+ rvrSpan = rvrSpan === void 0 ? s : {
1294
+ start: rvrSpan.start,
1295
+ end: s.end
1296
+ };
1297
+ rvr = {
1298
+ kind: "value",
1299
+ value: [...rvrList],
1300
+ span: rvrSpan
1301
+ };
1302
+ i += 1;
1303
+ continue;
1304
+ }
1305
+ const runwayState = parseRunwayStateToken(t);
1306
+ if (runwayState !== null) {
1307
+ runwayStates.push(runwayState.group);
1308
+ for (const finding of runwayState.findings) warnings.push({
1309
+ code: "invalid-format",
1310
+ severity: "warning",
1311
+ message: finding.message,
1312
+ span: finding.span
1313
+ });
1314
+ i += 1;
1315
+ continue;
1316
+ }
1317
+ const rvrSlash = /^R\d{2}[RLC]?(\/{4,5})$/.exec(text);
1318
+ if (rvrSlash !== null) {
1319
+ rvr = {
1320
+ kind: "missing",
1321
+ span: spanOf(t)
1322
+ };
1323
+ warnings.push({
1324
+ code: "missing-expected",
1325
+ severity: "info",
1326
+ message: `RVR 组缺测(${text}——跑道号在位、视程值位全斜杠)`,
1327
+ span: spanOf(t)
1328
+ });
1329
+ if ((rvrSlash[1] ?? "").length === 4) warnings.push({
1330
+ code: "invalid-format",
1331
+ severity: "info",
1332
+ message: `RVR 缺测段磨损(${text}——4 位斜杠对标准 5 位(分离符 + 四位值位),少一位;按缺测收下)`,
1333
+ span: spanOf(t)
1334
+ });
1335
+ i += 1;
1336
+ continue;
1337
+ }
1338
+ }
1339
+ if ((mask & M_WS_RWY) !== 0 && text === "WS") {
1340
+ const rwyTok = peek(1);
1341
+ const designator = rwyTok !== void 0 ? /^RWY(\d{2}[RLC]?)$/.exec(rwyTok.text) : null;
1342
+ const stdDesignator = rwyTok !== void 0 ? /^R(\d{2}[RLC]?)$/.exec(rwyTok.text) : null;
1343
+ const isAll = rwyTok?.text === "ALL" && peek(2)?.text === "RWY" || rwyTok?.text === "RWY" && peek(2)?.text === "ALL";
1344
+ if (designator !== null || isAll || stdDesignator !== null) {
1345
+ const endTok = isAll ? peek(2) : rwyTok;
1346
+ const startSpan = spanOf(t);
1347
+ const endSpan = endTok !== void 0 ? spanOf(endTok) : startSpan;
1348
+ wsRunways ??= [];
1349
+ if (designator !== null) wsRunways.push(designator[1] ?? "");
1350
+ if (stdDesignator !== null) wsRunways.push(stdDesignator[1] ?? "");
1351
+ if (isAll) wsAll = true;
1352
+ wsSpan = wsSpan === void 0 ? {
1353
+ start: startSpan.start,
1354
+ end: endSpan.end
1355
+ } : {
1356
+ start: wsSpan.start,
1357
+ end: endSpan.end
1358
+ };
1359
+ windShear = {
1360
+ runways: wsRunways,
1361
+ allRunways: wsAll,
1362
+ span: wsSpan
1363
+ };
1364
+ i += isAll ? 3 : 2;
1365
+ continue;
1366
+ }
1367
+ }
1368
+ if (((mask & M_WEATHER) !== 0 ? /^(TX|TN)M?\d{2}\/\d{4}Z$/.exec(text) : null) !== null) {
1369
+ remarks.push({
1370
+ kind: "temperature-forecast",
1371
+ raw: text,
1372
+ span: spanOf(t)
1373
+ });
1374
+ i += 1;
1375
+ continue;
1376
+ }
1377
+ const weatherParsed = (mask & M_WEATHER) !== 0 ? tryWeatherToken(t, weatherList, recentList) : false;
1378
+ if (weatherParsed !== false) {
1379
+ if (weatherParsed.outOfOrder) warnings.push({
1380
+ code: "invalid-format",
1381
+ severity: "warning",
1382
+ message: `天气组语序不合电码表(${text}:描述符须先于现象)——已按切解结果收下`,
1383
+ span: spanOf(t)
1384
+ });
1385
+ if (weatherParsed.signWithVc) warnings.push({
1386
+ code: "invalid-format",
1387
+ severity: "info",
1388
+ message: `强度符与 VC 邻近指示并存(${text}——强度符不与 VC 同组)——已按切解结果收下`,
1389
+ span: spanOf(t)
1390
+ });
1391
+ if (weatherParsed.intensityMisuse === true) warnings.push({
1392
+ code: "invalid-format",
1393
+ severity: "info",
1394
+ message: weatherParsed.kind === "recent" ? `RE 近期天气组带强度符(${text}——15.13.2.1 RE 组无强度位)——已收下` : `强度符超适用面(${text}——表 4678 注 4:-/+ 仅限降水族,非降水唯 +SS/+FC/+DS)——已收下`,
1395
+ span: spanOf(t)
1396
+ });
1397
+ i += 1;
1398
+ continue;
1399
+ }
1400
+ if ((mask & M_WEATHER) !== 0 && text === "//") {
1401
+ weather = {
1402
+ kind: "missing",
1403
+ span: spanOf(t)
1404
+ };
1405
+ warnings.push({
1406
+ code: "missing-expected",
1407
+ severity: "info",
1408
+ message: "天气组缺测(//,无法观测天气)",
1409
+ span: spanOf(t)
1410
+ });
1411
+ i += 1;
1412
+ continue;
1413
+ }
1414
+ const cloudM = (mask & M_CLOUD) !== 0 ? CLOUD_LAYER_PATTERN.exec(text) : null;
1415
+ if (cloudM !== null) {
1416
+ cloudSeen = true;
1417
+ const amountRaw = cloudM[1];
1418
+ const heightRaw = cloudM[2];
1419
+ const convectiveRaw = cloudM[3];
1420
+ const typeMissing = cloudM[4] !== void 0;
1421
+ const amountMissing = amountRaw === "///";
1422
+ if (heightRaw === "///") warnings.push({
1423
+ code: "missing-expected",
1424
+ severity: "info",
1425
+ message: "云高缺测(///),不捏造基高",
1426
+ span: spanOf(t)
1427
+ });
1428
+ if (amountMissing) warnings.push({
1429
+ code: "missing-expected",
1430
+ severity: "info",
1431
+ message: "云量位缺测(///),探测到云但云量无法观测",
1432
+ span: spanOf(t)
1433
+ });
1434
+ if (typeMissing) warnings.push({
1435
+ code: "missing-expected",
1436
+ severity: "info",
1437
+ message: "云型位缺测(///)",
1438
+ span: spanOf(t)
1439
+ });
1440
+ cloudElements.push({
1441
+ kind: "layer",
1442
+ amount: amountRaw !== void 0 && isCloudAmount(amountRaw) ? amountRaw : null,
1443
+ heightFt: {
1444
+ value: heightRaw !== void 0 && heightRaw !== "///" ? Number.parseInt(heightRaw, 10) * 100 : null,
1445
+ span: spanOf(t)
1446
+ },
1447
+ convective: convectiveRaw !== void 0 && isConvective(convectiveRaw) ? convectiveRaw : void 0,
1448
+ span: spanOf(t)
1449
+ });
1450
+ i += 1;
1451
+ continue;
1452
+ }
1453
+ const vvM = (mask & M_VV) !== 0 ? VV_PATTERN.exec(text) : null;
1454
+ if (vvM !== null) {
1455
+ cloudSeen = true;
1456
+ const heightRaw = vvM[1];
1457
+ if (heightRaw === void 0 || heightRaw === "///") warnings.push({
1458
+ code: "missing-expected",
1459
+ severity: "info",
1460
+ message: heightRaw === "///" ? "垂直能见度缺测(VV///,天空全遮蔽但垂直能见度不可测)" : "垂直能见度缺测(VV,兼容形态)",
1461
+ span: spanOf(t)
1462
+ });
1463
+ cloudElements.push({
1464
+ kind: "vertical-visibility",
1465
+ heightFt: {
1466
+ value: heightRaw === void 0 || heightRaw === "///" ? null : Number.parseInt(heightRaw, 10) * 100,
1467
+ span: spanOf(t)
1468
+ },
1469
+ span: spanOf(t)
1470
+ });
1471
+ i += 1;
1472
+ continue;
1473
+ }
1474
+ if ((mask & M_SKY_CLEAR) !== 0 && isSkyClear(text)) {
1475
+ cloudSeen = true;
1476
+ clearCode = {
1477
+ code: text,
1478
+ span: spanOf(t)
1479
+ };
1480
+ i += 1;
1481
+ continue;
1482
+ }
1483
+ const tempParsed = (mask & M_TEMP) !== 0 ? parseTempDewToken(t) : null;
1484
+ if (tempParsed !== null) {
1485
+ const dupTemp = temperature !== void 0 || dewpoint !== void 0;
1486
+ if (dupTemp) dupGroupWarning("温度组", temperature?.span ?? dewpoint?.span, tempParsed.span);
1487
+ if (dupTemp && tempParsed.temperature === null && tempParsed.dewpoint === null) {
1488
+ i += 1;
1489
+ continue;
1490
+ }
1491
+ const rawTemp = tempParsed.temperature;
1492
+ const rawDew = tempParsed.dewpoint;
1493
+ const outOfRange = (r) => r !== null && (r.celsius < TEMP_C_MIN || r.celsius > TEMP_C_MAX);
1494
+ const outT = outOfRange(rawTemp);
1495
+ const outD = outOfRange(rawDew);
1496
+ if (outT || outD) {
1497
+ const span = outT && outD ? tempParsed.span : (outT ? rawTemp : rawDew)?.span;
1498
+ const shown = span === void 0 ? `${TEMP_C_MIN}` : raw.slice(span.start, span.end);
1499
+ warnings.push({
1500
+ code: "value-out-of-range",
1501
+ severity: "warning",
1502
+ message: `温度超出可信范围(${shown},合理区间 ${TEMP_C_MIN}–${TEMP_C_MAX}°C)——值不可信判缺测,原码经 span 回溯`,
1503
+ span
1504
+ });
1505
+ }
1506
+ temperature = outT ? void 0 : rawTemp ?? void 0;
1507
+ dewpoint = outD ? void 0 : rawDew ?? void 0;
1508
+ if (rawTemp === null || rawDew === null) warnings.push({
1509
+ code: "missing-expected",
1510
+ severity: "info",
1511
+ message: /^M?\d{2}\/$/.test(t.text) ? "露点位缺测(24/ 形态,FMH-1 12.6.10)" : "温度/露点位缺测(//)",
1512
+ span: tempParsed.span
1513
+ });
1514
+ if (temperature !== void 0 && dewpoint !== void 0 && temperature.celsius < dewpoint.celsius) warnings.push({
1515
+ code: "cross-check-conflict",
1516
+ severity: "warning",
1517
+ message: `温度低于露点(${t.text})——物理不可能,疑似传感器故障`,
1518
+ span: tempParsed.span
1519
+ });
1520
+ i += 1;
1521
+ continue;
1522
+ }
1523
+ const qM = (mask & M_QNH) !== 0 ? /^Q(\d{4,5})$/.exec(text) : null;
1524
+ if (qM !== null) {
1525
+ const digits = qM[1] ?? "";
1526
+ const hpa = Number.parseInt(digits, 10);
1527
+ if (digits.length === 5 || hpa < QNH_HPA_MIN || hpa > QNH_HPA_MAX) {
1528
+ altimeter = void 0;
1529
+ warnings.push({
1530
+ code: "value-out-of-range",
1531
+ severity: "warning",
1532
+ message: `QNH 超出可信范围(${text},合理区间 ${QNH_HPA_MIN}–${QNH_HPA_MAX} hPa)——值不可信判缺测,原码经 span 回溯`,
1533
+ span: spanOf(t)
1534
+ });
1535
+ } else setAltimeter({
1536
+ value: hpa,
1537
+ unit: "hPa",
1538
+ span: spanOf(t)
1539
+ });
1540
+ i += 1;
1541
+ continue;
1542
+ }
1543
+ const aM = (mask & M_ALTIMETER_A) !== 0 ? /^A(\d{4})$/.exec(text) : null;
1544
+ if (aM !== null) {
1545
+ const inhg = Number.parseInt(aM[1] ?? "0", 10) / 100;
1546
+ if (inhg < ALT_INHG_MIN || inhg > ALT_INHG_MAX) {
1547
+ altimeter = void 0;
1548
+ warnings.push({
1549
+ code: "value-out-of-range",
1550
+ severity: "warning",
1551
+ message: `高度表设定超出可信范围(${text} → ${inhg} inHg,合理区间 ${ALT_INHG_MIN}–${ALT_INHG_MAX})——值不可信判缺测,原码经 span 回溯`,
1552
+ span: spanOf(t)
1553
+ });
1554
+ } else setAltimeter({
1555
+ value: inhg,
1556
+ unit: "inHg",
1557
+ span: spanOf(t)
1558
+ });
1559
+ i += 1;
1560
+ continue;
1561
+ }
1562
+ if ((mask & M_VIS_RANGE) !== 0 && text === "VIS" && VIS_V_RANGE.test(peek(1)?.text ?? "")) {
1563
+ const nextTok = peek(1);
1564
+ if (nextTok !== void 0) {
1565
+ remarks.push({
1566
+ kind: "variable-visibility",
1567
+ raw: `${text} ${nextTok.text}`,
1568
+ span: {
1569
+ start: t.start,
1570
+ end: nextTok.end
1571
+ }
1572
+ });
1573
+ i += 2;
1574
+ continue;
1575
+ }
1576
+ }
1577
+ if ((mask & M_DOLLAR) !== 0 && text === "$") {
1578
+ remarks.push({
1579
+ kind: "maintenance",
1580
+ raw: "$",
1581
+ span: spanOf(t)
1582
+ });
1583
+ i += 1;
1584
+ continue;
1585
+ }
1586
+ warnings.push({
1587
+ code: "unknown-token",
1588
+ severity: "info",
1589
+ message: `未识别的组(${text})——已如实收下`,
1590
+ span: spanOf(t)
1591
+ });
1592
+ i += 1;
1593
+ }
1594
+ if (weatherList.length > 0) {
1595
+ const first = weatherList[0];
1596
+ const last = weatherList[weatherList.length - 1];
1597
+ if (first !== void 0 && last !== void 0) weather = {
1598
+ kind: "value",
1599
+ value: [...weatherList],
1600
+ span: first.span !== void 0 && last.span !== void 0 ? {
1601
+ start: first.span.start,
1602
+ end: last.span.end
1603
+ } : void 0
1604
+ };
1605
+ }
1606
+ if (cloudSeen) clouds = {
1607
+ elements: cloudElements,
1608
+ clear: clearCode
1609
+ };
1610
+ if (cavok) cavokConflicts("后续");
1611
+ const hasVvElement = cloudElements.some((e) => e.kind === "vertical-visibility");
1612
+ const hasLayerElement = cloudElements.some((e) => e.kind === "layer");
1613
+ if (hasVvElement && hasLayerElement) warnings.push({
1614
+ code: "cross-check-conflict",
1615
+ severity: "warning",
1616
+ message: "VV 组与云层组并存(WMO 15.9.2:VV 顶替整个云组)——报文自洽性存疑",
1617
+ span: cloudElements[0]?.span
1618
+ });
1619
+ if (clearCode !== void 0 && hasLayerElement) warnings.push({
1620
+ code: "cross-check-conflict",
1621
+ severity: "warning",
1622
+ message: `${clearCode.code}(无云电码)与云层组并存——互斥形态,报文自洽性存疑`,
1623
+ span: clearCode.span
1624
+ });
1625
+ while (i < tokens.length) {
1626
+ const t = tokens[i];
1627
+ if (t === void 0) break;
1628
+ const text = t.text;
1629
+ const push = (remarkKind, consumed = 1) => {
1630
+ const slice = tokens.slice(i, i + consumed);
1631
+ remarks.push({
1632
+ kind: remarkKind,
1633
+ raw: slice.map((c) => c.text).join(" "),
1634
+ span: joinSpan(slice)
1635
+ });
1636
+ i += consumed;
1637
+ };
1638
+ if (text === "AO1" || text === "AO2" || text === "A01" || text === "A02") {
1639
+ push("auto-type");
1640
+ continue;
1641
+ }
1642
+ if (/^SLP(\d{3}|NO)$/.test(text)) {
1643
+ push("sea-level-pressure");
1644
+ continue;
1645
+ }
1646
+ if (/^T[01]\d{7}(T[01]\d{7})?$/.test(text) || /^T[01]\d{3}$/.test(text)) {
1647
+ push("precise-temperature");
1648
+ continue;
1649
+ }
1650
+ if (/^P\d{4}$/.test(text)) {
1651
+ push("precip-1h");
1652
+ continue;
1653
+ }
1654
+ if (/^[67](?:\d{4}|\/{4})$/.test(text)) {
1655
+ push("precip-window");
1656
+ continue;
1657
+ }
1658
+ if (/^4\/\d{3}$/.test(text)) {
1659
+ push("snow-depth");
1660
+ continue;
1661
+ }
1662
+ if (/^I[136]\d{3}$/.test(text)) {
1663
+ push("ice-accretion");
1664
+ continue;
1665
+ }
1666
+ if (/^5[0-8]\d{3}$/.test(text)) {
1667
+ push("pressure-tendency");
1668
+ continue;
1669
+ }
1670
+ if (/^4(?:[01]\d{3}[01]\d{3}|\d{7})$/.test(text)) {
1671
+ push("temp-extrema-24h");
1672
+ continue;
1673
+ }
1674
+ if (/^[12]\d{4}$/.test(text)) {
1675
+ push("temp-extrema-6h");
1676
+ continue;
1677
+ }
1678
+ if (text === "PK" && tokens[i + 1]?.text === "WND") {
1679
+ push("peak-wind", 3);
1680
+ continue;
1681
+ }
1682
+ if (text === "WSHFT" && tokens[i + 1] !== void 0) {
1683
+ push("wind-shift", 2);
1684
+ continue;
1685
+ }
1686
+ if (text === "PRESRR" || text === "PRESFR") {
1687
+ push("pressure-change");
1688
+ continue;
1689
+ }
1690
+ if (text === "VISNO") {
1691
+ push("vis-no");
1692
+ continue;
1693
+ }
1694
+ if (text === "TSNO") {
1695
+ push("thunderstorm-sensor");
1696
+ continue;
1697
+ }
1698
+ if (text === "RVRNO") {
1699
+ if (rvr?.kind === "value") {
1700
+ const prevText = rvr.span === void 0 ? "" : raw.slice(rvr.span.start, rvr.span.end);
1701
+ warnings.push({
1702
+ code: "cross-check-conflict",
1703
+ severity: "info",
1704
+ message: `RVRNO(站级:应报而缺)与 RVR 值组并存(${prevText})——矛盾形态,值组按跑道级明细保留,RVRNO 经 remarks/raw 可回溯`,
1705
+ span: spanOf(t)
1706
+ });
1707
+ } else rvr = {
1708
+ kind: "missing",
1709
+ span: spanOf(t)
1710
+ };
1711
+ push("rvr-no");
1712
+ continue;
1713
+ }
1714
+ if (text === "SFC" && tokens[i + 1]?.text === "VIS") {
1715
+ push("surface-visibility", 3 + (/^\d\/\d(SM)?$/.test(tokens[i + 3]?.text ?? "") ? 1 : 0));
1716
+ continue;
1717
+ }
1718
+ if (text === "TWR" && tokens[i + 1]?.text === "VIS") {
1719
+ push("twr-visibility", 3 + (/^\d\/\d(SM)?$/.test(tokens[i + 3]?.text ?? "") ? 1 : 0));
1720
+ continue;
1721
+ }
1722
+ const visDirNext = tokens[i + 1]?.text;
1723
+ const RMK_VIS_DIR = /^(N|NE|E|SE|S|SW|W|NW)$/;
1724
+ const RMK_VIS_VAL = /^[MP]?\d+(\/\d+)?(SM)?$/;
1725
+ if (text === "VIS" && visDirNext !== void 0 && RMK_VIS_DIR.test(visDirNext) && tokens[i + 2] !== void 0) {
1726
+ let consumed = 3 + (/^\d\/\d(SM)?$/.test(tokens[i + 3]?.text ?? "") ? 1 : 0);
1727
+ for (let j = i + consumed;;) {
1728
+ const dir = tokens[j]?.text;
1729
+ if (dir === void 0 || !RMK_VIS_DIR.test(dir)) break;
1730
+ const val = tokens[j + 1]?.text;
1731
+ if (val === void 0 || !RMK_VIS_VAL.test(val)) break;
1732
+ const pair = /^\d+$/.test(val) && /^\d\/\d(SM)?$/.test(tokens[j + 2]?.text ?? "") ? 3 : 2;
1733
+ consumed += pair;
1734
+ j += pair;
1735
+ }
1736
+ push("sectoral-visibility", consumed);
1737
+ continue;
1738
+ }
1739
+ if (text === "VIS" && visDirNext !== void 0 && RMK_VIS_VAL.test(visDirNext)) {
1740
+ let consumed = 2;
1741
+ if (/^\d+$/.test(visDirNext) && /^\d\/\d(SM)?$/.test(tokens[i + 2]?.text ?? "")) consumed += 1;
1742
+ if (tokens[i + consumed]?.text !== void 0 && RMK_VIS_DIR.test(tokens[i + consumed]?.text ?? "")) consumed += 1;
1743
+ push("sectoral-visibility", consumed);
1744
+ continue;
1745
+ }
1746
+ if (/^CIGNO$/.test(text)) {
1747
+ push("cig-not-available");
1748
+ continue;
1749
+ }
1750
+ const cigNext = tokens[i + 1]?.text;
1751
+ if (text === "CIG" && cigNext !== void 0) {
1752
+ const nxt = cigNext;
1753
+ if (/^\d{3}V\d{3}$/.test(nxt)) {
1754
+ push("ceiling-variation", 2);
1755
+ continue;
1756
+ }
1757
+ if (/^\d{3}$/.test(nxt)) {
1758
+ if (tokens[i + 2]?.text === "LOC") {
1759
+ push("ceiling-at-location", 3);
1760
+ continue;
1761
+ }
1762
+ push("ceiling", 2);
1763
+ continue;
1764
+ }
1765
+ }
1766
+ if (text === "PNO") {
1767
+ push("precip-not-available");
1768
+ continue;
1769
+ }
1770
+ if (text === "FZRANO") {
1771
+ push("fzr-not-available");
1772
+ continue;
1773
+ }
1774
+ if (text === "CHINO") {
1775
+ push("chino");
1776
+ continue;
1777
+ }
1778
+ if (/^8\/[0-9X/]{3}$/.test(text)) {
1779
+ push("cloud-type-8group");
1780
+ continue;
1781
+ }
1782
+ if (/^933\d{3}$/.test(text)) {
1783
+ push("snow-water-equivalent");
1784
+ continue;
1785
+ }
1786
+ if (text === "FUNNEL" && tokens[i + 1]?.text === "CLOUD") {
1787
+ push("phenomenon-began-ended", 2);
1788
+ continue;
1789
+ }
1790
+ if (text === "LTG") {
1791
+ let consumed = 1;
1792
+ let prevInVocab = false;
1793
+ let digitsAbsorbed = 0;
1794
+ for (;;) {
1795
+ const nextTok = tokens[i + consumed];
1796
+ if (nextTok === void 0) break;
1797
+ if (LTG_TAIL_VOCAB.has(nextTok.text)) {
1798
+ consumed += 1;
1799
+ prevInVocab = true;
1800
+ continue;
1801
+ }
1802
+ if (prevInVocab && digitsAbsorbed === 0 && /^\d{1,2}$/.test(nextTok.text)) {
1803
+ consumed += 1;
1804
+ digitsAbsorbed += 1;
1805
+ prevInVocab = false;
1806
+ continue;
1807
+ }
1808
+ break;
1809
+ }
1810
+ push("lightning", Math.min(consumed, tokens.length - i));
1811
+ continue;
1812
+ }
1813
+ if (text === "SNINCR" && /^\d+\/\d+$/.test(tokens[i + 1]?.text ?? "")) {
1814
+ push("snow-increase", 2);
1815
+ continue;
1816
+ }
1817
+ if (text === "VIS" && VIS_V_RANGE.test(tokens[i + 1]?.text ?? "")) {
1818
+ push("variable-visibility", 2);
1819
+ continue;
1820
+ }
1821
+ const beganMerged = /^([A-Z]{2,8})B\d{2,4}(?:E\d{2,4})?$/.exec(text);
1822
+ const beganSplit = /^([A-Z]{2})[BE]\d{2,4}$/.exec(text);
1823
+ const beganBodyOk = (m) => m !== null && splitWeatherToken(m[1] ?? "") !== null;
1824
+ if (beganBodyOk(beganMerged) || beganBodyOk(beganSplit) || /^[BE]\d{2,4}$/.test(text)) {
1825
+ push("phenomenon-began-ended");
1826
+ continue;
1827
+ }
1828
+ if (/^QBB\d{3}$/.test(text)) {
1829
+ push("cloud-base-height");
1830
+ continue;
1831
+ }
1832
+ if (/^QFE\d{3,4}(\/\d{3,4})?$/.test(text)) {
1833
+ push("aerodrome-pressure");
1834
+ continue;
1835
+ }
1836
+ if (text === "$") {
1837
+ push("maintenance");
1838
+ continue;
1839
+ }
1840
+ push("unknown");
1841
+ }
1842
+ return compactIfEnabled({
1843
+ kind,
1844
+ raw,
1845
+ station,
1846
+ time,
1847
+ flags: {
1848
+ auto,
1849
+ corrected
1850
+ },
1851
+ cavok,
1852
+ cavokSpan,
1853
+ wind,
1854
+ visibility,
1855
+ runwayVisualRange: rvr,
1856
+ weather,
1857
+ recentWeather: recentList.length > 0 ? recentList : void 0,
1858
+ clouds,
1859
+ temperature,
1860
+ dewpoint,
1861
+ altimeter,
1862
+ seaLevelPressure: void 0,
1863
+ trends,
1864
+ runwayStates,
1865
+ windShear,
1866
+ remarks,
1867
+ warnings
1868
+ });
1869
+ }
1870
+ /**
1871
+ * `parse` 的问题式变体:整体失败返回 `{ ok:false, error }`(MetarParseError,code 契约同 parse),
1872
+ * 成功返回 `{ ok:true, report }`。仅编程性意外(非 MetarParseError 的内部异常)原样抛出——
1873
+ * 不吞实现缺陷。语义与 parse 完全一致(同一实现,确定性继承)。
1874
+ * Result-style variant of `parse`: whole-report failures come back as `{ ok:false, error }`
1875
+ * with the same stable `code` contract; unexpected non-MetarParseError exceptions still throw.
1876
+ */
1877
+ function tryParse(raw, options) {
1878
+ try {
1879
+ return {
1880
+ ok: true,
1881
+ report: parse(raw, options)
1882
+ };
1883
+ } catch (err) {
1884
+ if (err instanceof MetarParseError) return {
1885
+ ok: false,
1886
+ error: err
1887
+ };
1888
+ throw err;
1889
+ }
1890
+ }
1891
+ //#endregion
1892
+ export { TEMP_DEW_PATTERN, parse, tryParse };
1893
+
1894
+ //# sourceMappingURL=index.js.map