@metweave/parser 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  let _metweave_core = require("@metweave/core");
3
- //#region src/index.ts
3
+ //#region src/groups.ts
4
4
  const PHENOMENA = [
5
5
  "DZ",
6
6
  "RA",
@@ -150,17 +150,8 @@ const LTG_TAIL_VOCAB = /* @__PURE__ */ new Set([
150
150
  ]);
151
151
  /** 变化能见度尾组形态(RMK VIS 1/2V2 / VIS 1/4V1/2):两侧为整数或分数(可带 M/P 阈值前缀)。 */
152
152
  const VIS_V_RANGE = /^[MP]?\d+(?:\/\d+)?V[MP]?\d+(?:\/\d+)?$/;
153
- const M_WIND = 1;
154
- const M_VIS = 2;
155
- const M_R = 4;
156
- const M_WEATHER = 8;
157
- const M_CLOUD = 16;
158
- const M_VV = 32;
159
- const M_SKY_CLEAR = 64;
160
- const M_TEMP = 128;
161
- const M_QNH = 256;
162
- const M_ALTIMETER_A = 512;
163
- const M_VIS_RANGE = 1024;
153
+ /** 温度预告组形态(TAF TX/TN 混入 METAR 通路:TX25/0907Z = 最高 25°C、09 日 07Z 到达,M 前缀 = 负值)。 */
154
+ const TX_TN_PATTERN = /^(TX|TN)M?\d{2}\/\d{4}Z$/;
164
155
  const M_DOLLAR = 2048;
165
156
  const M_WS_RWY = 4096;
166
157
  /** 首字符(charCode)→ 候选分支位掩码;0 = 无分支可达(直接落 unknown-token) */
@@ -172,14 +163,14 @@ function maskOf(code) {
172
163
  case 77: return 138;
173
164
  case 80: return 10;
174
165
  case 82: return 12;
175
- case 81: return M_QNH;
176
- case 65: return M_ALTIMETER_A;
166
+ case 81: return 256;
167
+ case 65: return 512;
177
168
  case 66:
178
169
  case 70: return 24;
179
- case 79: return M_CLOUD;
170
+ case 79: return 16;
180
171
  case 83: return 88;
181
172
  case 78:
182
- case 67: return M_SKY_CLEAR;
173
+ case 67: return 64;
183
174
  case 68:
184
175
  case 71:
185
176
  case 72:
@@ -187,7 +178,7 @@ function maskOf(code) {
187
178
  case 84:
188
179
  case 85:
189
180
  case 45:
190
- case 43: return M_WEATHER;
181
+ case 43: return 8;
191
182
  case 36: return M_DOLLAR;
192
183
  case 87: return M_WS_RWY;
193
184
  default: return 0;
@@ -777,223 +768,1883 @@ function parseRunwayStateToken(t) {
777
768
  findings
778
769
  };
779
770
  }
780
- /**
781
- * Parse one METAR/SPECI report (tolerant mode) into the IR — the package's single entry.
782
- * 解析单条 METAR/SPECI 报文(tolerant)为 IR——本包唯一入口。
783
- *
784
- * Throws MetarParseError (stable machine-readable `code`) on whole-report failure
785
- * (non-string input / missing station / missing time / invalid time / unsupported mode); anything the
786
- * parser does not recognize lands in `warnings[]` with its span — never dropped.
787
- * 整体失败(输入非字符串/无站名/无时组/时组越界/未实现模式)抛 MetarParseError(code 稳定契约);
788
- * 看不懂的组带 span 进 warnings[],绝不丢弃。
789
- * @param raw - Report text, verbatim (kept on report.raw). 报文原文(原样保真于 report.raw)。
790
- * @param options - See ParseOptions (kind override, compact spans). 见 ParseOptions(类型位注入、紧凑模式)。
791
- */
792
- function parse(raw, options) {
793
- const compact = options?.spans === false;
794
- const compactIfEnabled = (report) => compact ? compactNode(report) : report;
795
- if (typeof raw !== "string") throw new _metweave_core.MetarParseError("invalid-input", String(raw), `parse 需要一个 METAR/SPECI 报文字符串,收到 ${raw === null ? "null" : typeof raw}`);
796
- if ((options?.mode ?? "tolerant") === "strict") throw new _metweave_core.MetarParseError("unsupported-mode", raw, "strict 模式尚未实现(v0.1 仅 tolerant)——请省略 mode 或显式传 'tolerant'");
797
- const warnings = [];
798
- const tail = raw.at(-1);
799
- const tokens = tokenize(tail === void 0 || tail === "=" || /\s/.test(tail) ? raw.replace(/[\s=]+$/, "") : raw);
800
- const TOKEN_COUNT_LIMIT = 128;
801
- if (tokens.length > TOKEN_COUNT_LIMIT) warnings.push({
802
- code: "invalid-format",
771
+ /** 重复组告警(原 parse() 内 dupGroupWarning 闭包):前值后值原文都进 message——
772
+ * last-wins 会覆盖 IR 里的前值 span,前值唯一可回溯通道就是这条告警。 */
773
+ function warnDuplicateGroup(raw, warnings, label, prevSpan, newSpan) {
774
+ const prevText = prevSpan === void 0 ? `(原${label})` : raw.slice(prevSpan.start, prevSpan.end);
775
+ const newText = newSpan === void 0 ? label : raw.slice(newSpan.start, newSpan.end);
776
+ warnings.push({
777
+ code: "duplicate-group",
803
778
  severity: "warning",
804
- message: `输入 token 数超上限(${tokens.length} > ${TOKEN_COUNT_LIMIT})——按异常输入标记,解析照常完整,原文经 raw 保真`
779
+ message: `重复${label}(前值 ${prevText},后值 ${newText})——报文只应有一组${label},以末组为准,前值经原文回溯`,
780
+ span: newSpan
805
781
  });
806
- let i = 0;
807
- const peek = (ahead = 0) => tokens[i + ahead];
808
- const externalKind = options?.kind;
809
- let kind = externalKind ?? "metar";
810
- let corrected = false;
811
- let auto = false;
812
- const head = peek();
813
- if (head !== void 0 && (head.text === "METAR" || head.text === "SPECI")) {
814
- if (externalKind === void 0) kind = head.text === "SPECI" ? "speci" : "metar";
815
- i += 1;
782
+ }
783
+ /** 气压组落位(原 parse() 内 setAltimeter 闭包):末组为准(last-wins),重复组追加告警不静默;
784
+ * 返回新的(altimeter, altimeterSeen)二元组交调用方落位。 */
785
+ function applyAltimeterReading(altimeter, altimeterSeen, reading, raw, warnings) {
786
+ if (altimeterSeen) warnDuplicateGroup(raw, warnings, "气压组", altimeter?.span, reading.span);
787
+ return {
788
+ altimeter: reading,
789
+ altimeterSeen: true
790
+ };
791
+ }
792
+ /** Q(hPa)组:5 位数 QNH 与物理范围外值 = 脏值(Q10054 家族)——绝不静默留假值。 */
793
+ function parseQnhToken(t) {
794
+ const qM = /^Q(\d{4,5})$/.exec(t.text);
795
+ if (qM === null) return null;
796
+ const digits = qM[1] ?? "";
797
+ const hpa = Number.parseInt(digits, 10);
798
+ if (digits.length === 5 || hpa < QNH_HPA_MIN || hpa > QNH_HPA_MAX) return {
799
+ kind: "out-of-range",
800
+ message: `QNH 超出可信范围(${t.text},合理区间 ${QNH_HPA_MIN}–${QNH_HPA_MAX} hPa)——值不可信判缺测,原码经 span 回溯`,
801
+ span: spanOf(t)
802
+ };
803
+ return {
804
+ kind: "ok",
805
+ reading: {
806
+ value: hpa,
807
+ unit: "hPa",
808
+ span: spanOf(t)
809
+ }
810
+ };
811
+ }
812
+ /** A(inHg,隐含小数点)组:物理范围外值判缺测,同 QNH 纪律。 */
813
+ function parseAltimeterAToken(t) {
814
+ const aM = /^A(\d{4})$/.exec(t.text);
815
+ if (aM === null) return null;
816
+ const inhg = Number.parseInt(aM[1] ?? "0", 10) / 100;
817
+ if (inhg < ALT_INHG_MIN || inhg > ALT_INHG_MAX) return {
818
+ kind: "out-of-range",
819
+ message: `高度表设定超出可信范围(${t.text} → ${inhg} inHg,合理区间 ${ALT_INHG_MIN}–${ALT_INHG_MAX})——值不可信判缺测,原码经 span 回溯`,
820
+ span: spanOf(t)
821
+ };
822
+ return {
823
+ kind: "ok",
824
+ reading: {
825
+ value: inhg,
826
+ unit: "inHg",
827
+ span: spanOf(t)
828
+ }
829
+ };
830
+ }
831
+ /** 风组落位与值域校验(原正文循环风组分支内联块,非缺测路径):NaN 终结防线、三位数
832
+ * 模板上限门(>199 判缺测)、风向/变化组端点越界 findings、阵风缺测 info、VRB 与变化组
833
+ * 并存 cross-check——告警顺序与原分支逐条一致(rangeFindings → gustMissing → VRB 变化组)。 */
834
+ function validateWindGroup(t, windParsed, raw) {
835
+ const wg = windParsed.group;
836
+ if (!Number.isFinite(wg.speed.value) || wg.gust !== void 0 && !Number.isFinite(wg.gust.value)) return {
837
+ wind: {
838
+ kind: "missing",
839
+ span: spanOf(t)
840
+ },
841
+ warnings: [{
842
+ code: "value-out-of-range",
843
+ severity: "warning",
844
+ message: `风组解码出非有限值(${t.text})——值不可信判缺测,原码经 span 回溯`,
845
+ span: spanOf(t)
846
+ }]
847
+ };
848
+ if (wg.speed.value > 199 || (wg.gust?.value ?? 0) > 199) return {
849
+ wind: {
850
+ kind: "missing",
851
+ span: spanOf(t)
852
+ },
853
+ warnings: [{
854
+ code: "value-out-of-range",
855
+ severity: "warning",
856
+ message: `风速超出编码范围(${t.text},三位数模板上限 199 ${wg.speed.unit})——值不可信判缺测,原码经 span 回溯`,
857
+ span: spanOf(t)
858
+ }]
859
+ };
860
+ const found = [];
861
+ for (const finding of windParsed.rangeFindings) found.push({
862
+ code: "value-out-of-range",
863
+ severity: "warning",
864
+ message: finding.message,
865
+ span: finding.span
866
+ });
867
+ if (windParsed.gustMissing === true) found.push({
868
+ code: "missing-expected",
869
+ severity: "info",
870
+ message: `阵风位缺测(${t.text}——G 后斜杠位缺测,组照常成立)`,
871
+ span: spanOf(t)
872
+ });
873
+ if (wg.variable && wg.variation !== void 0) {
874
+ const vs = wg.variation.span;
875
+ found.push({
876
+ code: "cross-check-conflict",
877
+ severity: "info",
878
+ message: `静风变向(VRB)与风向变化组(${vs === void 0 ? "" : raw.slice(vs.start, vs.end)})并存——VRB 本义方向不定,变化组冗余,报文自洽性存疑`,
879
+ span: vs
880
+ });
816
881
  }
817
- if (peek()?.text === "COR") {
818
- corrected = true;
819
- i += 1;
882
+ return {
883
+ wind: {
884
+ kind: "value",
885
+ value: wg,
886
+ span: spanOf(t)
887
+ },
888
+ warnings: found
889
+ };
890
+ }
891
+ /** 能见度组落位(原正文循环能见度分支内联块):方向组挂靠/脱离主导、缺测不顶替在场值、
892
+ * 零分母 invalid 判缺测、ok 落值——返回新的(visibility, directionalAsPrimary)二元组。 */
893
+ function applyVisibilityToken(t, visParsed, state, raw, warnings) {
894
+ const visibility = state.visibility;
895
+ const directionalAsPrimary = state.directionalAsPrimary;
896
+ if (visParsed.kind === "directional") {
897
+ if (visibility?.kind === "value") {
898
+ if (visibility.value.minimum !== void 0 || directionalAsPrimary) warnDuplicateGroup(raw, warnings, "最低能见度方向组", visibility.value.minimum?.span, spanOf(t));
899
+ return {
900
+ visibility: {
901
+ kind: "value",
902
+ value: {
903
+ ...visibility.value,
904
+ minimum: {
905
+ value: visParsed.group.value,
906
+ direction: visParsed.group.direction,
907
+ span: visParsed.group.span
908
+ }
909
+ },
910
+ span: visibility.span
911
+ },
912
+ directionalAsPrimary
913
+ };
914
+ }
915
+ warnings.push({
916
+ code: "invalid-format",
917
+ severity: "info",
918
+ message: `最低能见度方向组脱离主导能见度(${t.text})——按能见度收下`,
919
+ span: spanOf(t)
920
+ });
921
+ return {
922
+ visibility: {
923
+ kind: "value",
924
+ value: {
925
+ value: visParsed.group.value,
926
+ unit: "m",
927
+ exact: true,
928
+ span: visParsed.group.span
929
+ }
930
+ },
931
+ directionalAsPrimary: true
932
+ };
820
933
  }
821
- if (peek()?.text === "AMD") i += 1;
822
- const stTok = peek();
823
- if (stTok === void 0 || !/^[A-Z0-9]{4}$/.test(stTok.text)) throw new _metweave_core.MetarParseError("missing-station", raw, `无法识别站名组——输入不是 METAR/SPECI 报文(${stTok?.text ?? "空输入"})`);
824
- const station = stTok.text;
825
- i += 1;
826
- const driftTok = peek();
827
- if (driftTok !== void 0 && /^(CC[A-Z]|COR)$/.test(driftTok.text)) {
828
- const afterDrift = peek(1);
829
- if (afterDrift !== void 0 && /^\d{2}\d{2}\d{2}Z$/.test(afterDrift.text)) {
830
- corrected = true;
831
- i += 1;
934
+ if (visibility !== void 0) warnDuplicateGroup(raw, warnings, "能见度组", visibility.span, spanOf(t));
935
+ if (visParsed.kind === "missing") {
936
+ if (visibility === void 0) {
832
937
  warnings.push({
833
- code: "invalid-format",
938
+ code: "missing-expected",
834
939
  severity: "info",
835
- message: `更正标记槽位漂移(${driftTok.text} 出现在站名后/时组前——已消费并置更正标志)`,
836
- span: spanOf(driftTok)
940
+ message: `能见度组缺测(${t.text},无法观测能见度)`,
941
+ span: spanOf(t)
837
942
  });
943
+ return {
944
+ visibility: {
945
+ kind: "missing",
946
+ span: spanOf(t)
947
+ },
948
+ directionalAsPrimary: false
949
+ };
838
950
  }
951
+ return {
952
+ visibility,
953
+ directionalAsPrimary: false
954
+ };
839
955
  }
840
- const tmTok = peek();
841
- const tm = tmTok !== void 0 ? /^(\d{2})(\d{2})(\d{2})Z$/.exec(tmTok.text) : null;
842
- if (tmTok === void 0 || tm === null) throw new _metweave_core.MetarParseError("missing-time", raw, `无法识别时组——输入不是完整的 METAR/SPECI 报文(${tmTok?.text ?? "时组缺失"})`);
843
- const time = {
844
- day: Number.parseInt(tm[1] ?? "0", 10),
845
- hour: Number.parseInt(tm[2] ?? "0", 10),
846
- minute: Number.parseInt(tm[3] ?? "0", 10)
847
- };
848
- if (time.day < 1 || time.day > 31 || time.hour > 23 || time.minute > 59) throw new _metweave_core.MetarParseError("invalid-time", raw, `时组数值越界(${tmTok.text}:须日 01–31 / 时 00–23 / 分 00–59)——输入不是完整的 METAR/SPECI 报文`);
849
- i += 1;
850
- if (peek()?.text === "AUTO") {
851
- auto = true;
852
- i += 1;
853
- }
854
- if (peek()?.text === "COR") {
855
- corrected = true;
856
- i += 1;
857
- }
858
- if (/^RR[ABC]$/.test(peek()?.text ?? "")) i += 1;
859
- if (/^CC[A-Z]$/.test(peek()?.text ?? "")) {
860
- corrected = true;
861
- i += 1;
956
+ if (visParsed.kind === "invalid") {
957
+ warnings.push({
958
+ code: "value-out-of-range",
959
+ severity: "warning",
960
+ message: visParsed.message,
961
+ span: visParsed.span
962
+ });
963
+ return {
964
+ visibility: {
965
+ kind: "missing",
966
+ span: visParsed.span
967
+ },
968
+ directionalAsPrimary: false
969
+ };
862
970
  }
863
- if (peek()?.text === "NIL") return compactIfEnabled({
864
- kind,
865
- raw,
866
- nil: true,
867
- station,
868
- time,
869
- flags: {
870
- auto,
871
- corrected
971
+ return {
972
+ visibility: {
973
+ kind: "value",
974
+ value: visParsed.group,
975
+ span: visParsed.group.span
872
976
  },
873
- cavok: false,
874
- trends: [],
875
- runwayStates: [],
876
- remarks: [],
877
- warnings
878
- });
879
- let cavok = false;
880
- let cavokSpan;
881
- let wind;
882
- let visibility;
883
- let directionalAsPrimary = false;
884
- let rvr;
885
- const rvrList = [];
886
- let rvrSpan;
887
- let weather;
888
- const weatherList = [];
889
- const recentList = [];
890
- let clouds;
891
- let cloudSeen = false;
892
- const cloudElements = [];
893
- let clearCode;
894
- let temperature;
895
- let dewpoint;
896
- let altimeter;
897
- let altimeterSeen = false;
898
- const setAltimeter = (reading) => {
899
- if (altimeterSeen) dupGroupWarning("气压组", altimeter?.span, reading.span);
900
- altimeterSeen = true;
901
- altimeter = reading;
977
+ directionalAsPrimary: false
902
978
  };
903
- const trends = [];
904
- const runwayStates = [];
905
- const remarks = [];
906
- const dupGroupWarning = (label, prevSpan, newSpan) => {
907
- const prevText = prevSpan === void 0 ? `(原${label})` : raw.slice(prevSpan.start, prevSpan.end);
908
- const newText = newSpan === void 0 ? label : raw.slice(newSpan.start, newSpan.end);
979
+ }
980
+ /** 温度读数物理极值门(世界极值 ±裕量,与 QNH 世界极值门同款纪律)。 */
981
+ const tempOutOfRange = (r) => r !== null && (r.celsius < TEMP_C_MIN || r.celsius > TEMP_C_MAX);
982
+ /** 温度/露点组落位(原正文循环温露分支内联块):重复组告警、全缺测不顶替在场值、
983
+ * 物理极值门(双侧同超界合并一条告警)、显式缺测 info、温露倒挂 cross-check——
984
+ * 返回新的(temperature, dewpoint)二元组。 */
985
+ function applyTempDewToken(t, tempParsed, state, raw, warnings) {
986
+ const temperature = state.temperature;
987
+ const dewpoint = state.dewpoint;
988
+ const dupTemp = temperature !== void 0 || dewpoint !== void 0;
989
+ if (dupTemp) warnDuplicateGroup(raw, warnings, "温度组", temperature?.span ?? dewpoint?.span, tempParsed.span);
990
+ if (dupTemp && tempParsed.temperature === null && tempParsed.dewpoint === null) return {
991
+ temperature,
992
+ dewpoint
993
+ };
994
+ const rawTemp = tempParsed.temperature;
995
+ const rawDew = tempParsed.dewpoint;
996
+ const outT = tempOutOfRange(rawTemp);
997
+ const outD = tempOutOfRange(rawDew);
998
+ if (outT || outD) {
999
+ const span = outT && outD ? tempParsed.span : (outT ? rawTemp : rawDew)?.span;
1000
+ const shown = span === void 0 ? `${TEMP_C_MIN}` : raw.slice(span.start, span.end);
909
1001
  warnings.push({
910
- code: "duplicate-group",
1002
+ code: "value-out-of-range",
911
1003
  severity: "warning",
912
- message: `重复${label}(前值 ${prevText},后值 ${newText})——报文只应有一组${label},以末组为准,前值经原文回溯`,
913
- span: newSpan
1004
+ message: `温度超出可信范围(${shown},合理区间 ${TEMP_C_MIN}–${TEMP_C_MAX}°C)——值不可信判缺测,原码经 span 回溯`,
1005
+ span
914
1006
  });
1007
+ }
1008
+ const gatedTemperature = outT ? void 0 : rawTemp ?? void 0;
1009
+ const gatedDewpoint = outD ? void 0 : rawDew ?? void 0;
1010
+ if (rawTemp === null || rawDew === null) warnings.push({
1011
+ code: "missing-expected",
1012
+ severity: "info",
1013
+ message: /^M?\d{2}\/$/.test(t.text) ? "露点位缺测(24/ 形态,FMH-1 12.6.10)" : "温度/露点位缺测(//)",
1014
+ span: tempParsed.span
1015
+ });
1016
+ if (gatedTemperature !== void 0 && gatedDewpoint !== void 0 && gatedTemperature.celsius < gatedDewpoint.celsius) warnings.push({
1017
+ code: "cross-check-conflict",
1018
+ severity: "warning",
1019
+ message: `温度低于露点(${t.text})——物理不可能,疑似传感器故障`,
1020
+ span: tempParsed.span
1021
+ });
1022
+ return {
1023
+ temperature: gatedTemperature,
1024
+ dewpoint: gatedDewpoint
915
1025
  };
916
- const trendCloseWarning = () => {
917
- const btTok = tokens[i];
918
- const bt = btTok?.text;
919
- if (bt === void 0 || btTok === void 0) return;
920
- if (TREND_KINDS.has(bt) || bt.startsWith("RMK") || bt === "$") return;
921
- if (/^(BECMG|TEMPO)(AT|TL|FM)\d{4}$/.test(bt)) return;
922
- if (/^R(\d{2}[RLC]?\/|\/SNOCLO$)/.test(bt)) {
923
- warnings.push({
924
- code: "invalid-format",
925
- severity: "info",
926
- message: `趋势段收口于 R 组(${bt}——RVR/跑道状态不属趋势要素,按正文组处理,趋势语境存疑)`,
927
- span: spanOf(btTok)
928
- });
929
- return;
930
- }
1026
+ }
1027
+ /** 趋势段收口出声(原 parse() 内 trendCloseWarning 闭包):R 组收口(RVR/跑道状态不属趋势
1028
+ * 要素)沿用既有口径出声;其余非趋势组收口(温露/QNH/WS/TX/TN/RVRNO/无法认领 token 等)
1029
+ * 同样出声 info:该组交回正文循环认组(typed 语义无损恢复,冲突自然触发重复组告警),
1030
+ * 但趋势语境存疑须可观测——不静默。RMK/RMK 粘连与趋势指示组切换(含粘连形
1031
+ * BECMGAT0130——它本身就是趋势指示组,下一步由正文粘连分支认领)、以及规范报尾位 $
1032
+ * (FMH-1:$ 为整报最后一组,趋势段后随 $ 属规范位置)不出声(2026-09-14 独立复评补豁免)。 */
1033
+ function warnTrendClose(tokens, pos, warnings) {
1034
+ const btTok = tokens[pos];
1035
+ const bt = btTok?.text;
1036
+ if (bt === void 0 || btTok === void 0) return;
1037
+ if (TREND_KINDS.has(bt) || bt.startsWith("RMK") || bt === "$") return;
1038
+ if (/^(BECMG|TEMPO)(AT|TL|FM)\d{4}$/.test(bt)) return;
1039
+ if (/^R(\d{2}[RLC]?\/|\/SNOCLO$)/.test(bt)) {
931
1040
  warnings.push({
932
1041
  code: "invalid-format",
933
1042
  severity: "info",
934
- message: `趋势段收口于非趋势组(${bt}——不属趋势要素族,交回正文认组,趋势语境存疑)`,
1043
+ message: `趋势段收口于 R 组(${bt}——RVR/跑道状态不属趋势要素,按正文组处理,趋势语境存疑)`,
935
1044
  span: spanOf(btTok)
936
1045
  });
1046
+ return;
1047
+ }
1048
+ warnings.push({
1049
+ code: "invalid-format",
1050
+ severity: "info",
1051
+ message: `趋势段收口于非趋势组(${bt}——不属趋势要素族,交回正文认组,趋势语境存疑)`,
1052
+ span: spanOf(btTok)
1053
+ });
1054
+ }
1055
+ /** 趋势段收组(原 parse() 内 collectTrendTokens 闭包):仅封闭清单内 token 收进 collected
1056
+ * (判据见 isTrendCollectible),其余留在收口位置交回正文循环——三处收集入口
1057
+ * (指示组/粘连/裸时段词)共用一份循环防漂移;返回收口位置。 */
1058
+ function collectTrendSegment(tokens, pos, collected) {
1059
+ let idx = pos;
1060
+ for (;;) {
1061
+ const inner = tokens[idx];
1062
+ if (inner === void 0 || !isTrendCollectible(inner, tokens[idx + 1])) break;
1063
+ collected.push(inner);
1064
+ idx += 1;
1065
+ }
1066
+ return idx;
1067
+ }
1068
+ /** 趋势段构造(三形态共用尾半段:标准指示组/粘连/裸时段词分支):要素结构化复用
1069
+ * structureTrendElements;NOSIG 不做要素结构化(原分支口径),elementsStart 为
1070
+ * 结构化起始下标(跳过已收下的指示组/时段词 token)。 */
1071
+ function buildTrendGroup(kind, period, collected, elementsStart) {
1072
+ const elements = kind === "nosig" ? void 0 : structureTrendElements(collected, elementsStart);
1073
+ return {
1074
+ kind,
1075
+ period,
1076
+ ...elements !== void 0 ? { elements } : {},
1077
+ raw: collected.map((c) => c.text).join(" "),
1078
+ span: joinSpan(collected)
937
1079
  };
938
- const collectTrendTokens = (collected) => {
939
- for (;;) {
940
- const inner = tokens[i];
941
- if (inner === void 0 || !isTrendCollectible(inner, tokens[i + 1])) break;
942
- collected.push(inner);
943
- i += 1;
944
- }
1080
+ }
1081
+ /** 云层组元素构造(原正文循环云层分支内联块):云量/云高/云型缺测位告警随构造直出;
1082
+ * null = 非云层组形态。趋势段的云层构造为静默口径(structureTrendElements)——
1083
+ * 两处行为刻意不同(正文出声、趋势保真静默),不合并。 */
1084
+ function cloudLayerElementOf(t, warnings) {
1085
+ const cloudM = CLOUD_LAYER_PATTERN.exec(t.text);
1086
+ if (cloudM === null) return null;
1087
+ const amountRaw = cloudM[1];
1088
+ const heightRaw = cloudM[2];
1089
+ const convectiveRaw = cloudM[3];
1090
+ const typeMissing = cloudM[4] !== void 0;
1091
+ const amountMissing = amountRaw === "///";
1092
+ if (heightRaw === "///") warnings.push({
1093
+ code: "missing-expected",
1094
+ severity: "info",
1095
+ message: "云高缺测(///),不捏造基高",
1096
+ span: spanOf(t)
1097
+ });
1098
+ if (amountMissing) warnings.push({
1099
+ code: "missing-expected",
1100
+ severity: "info",
1101
+ message: "云量位缺测(///),探测到云但云量无法观测",
1102
+ span: spanOf(t)
1103
+ });
1104
+ if (typeMissing) warnings.push({
1105
+ code: "missing-expected",
1106
+ severity: "info",
1107
+ message: "云型位缺测(///)",
1108
+ span: spanOf(t)
1109
+ });
1110
+ return {
1111
+ kind: "layer",
1112
+ amount: amountRaw !== void 0 && isCloudAmount(amountRaw) ? amountRaw : null,
1113
+ heightFt: {
1114
+ value: heightRaw !== void 0 && heightRaw !== "///" ? Number.parseInt(heightRaw, 10) * 100 : null,
1115
+ span: spanOf(t)
1116
+ },
1117
+ convective: convectiveRaw !== void 0 && isConvective(convectiveRaw) ? convectiveRaw : void 0,
1118
+ span: spanOf(t)
945
1119
  };
946
- const cavokConflicts = (whenLabel) => {
947
- const at = cavokSpan;
948
- if (visibility?.kind === "value") {
949
- const g = visibility.value;
950
- const meters = g.unit === "m" ? g.value : g.value * 1609.344;
951
- const visRaw = visibility.span === void 0 ? `${g.value} ${g.unit}` : raw.slice(visibility.span.start, visibility.span.end);
952
- if (g.beyond === "below" || g.unit === "sm" && visRaw.startsWith("M") || g.exact && meters < 1e4) warnings.push({
953
- code: "cross-check-conflict",
954
- severity: "warning",
955
- message: `CAVOK 与${whenLabel}能见度组矛盾(${visRaw},CAVOK 语义要求 ≥10km)——让位照旧,报文自洽性存疑`,
956
- span: at
957
- });
958
- }
959
- if (weatherList.length > 0) {
960
- const wxRaw = weatherList.map((g) => `${g.proximity ? "VC" : ""}${g.intensity ?? ""}${g.descriptor ?? ""}${g.phenomena.join("")}`).join(" ");
961
- warnings.push({
962
- code: "cross-check-conflict",
963
- severity: "warning",
964
- message: `CAVOK 与${whenLabel}天气组矛盾(${wxRaw},CAVOK 语义要求无重要天气)——让位照旧,报文自洽性存疑`,
965
- span: at
966
- });
967
- }
968
- if (rvr !== void 0 && rvr.kind === "value") {
969
- const rvrRaw = rvr.span === void 0 ? "" : raw.slice(rvr.span.start, rvr.span.end);
970
- warnings.push({
971
- code: "cross-check-conflict",
972
- severity: "warning",
973
- message: `CAVOK 与${whenLabel}RVR 组矛盾(${rvrRaw}——AP-117 第 140 条:CAVOK 代替能见度、跑道视程、现在天气和云)——让位照旧,报文自洽性存疑`,
974
- span: at
975
- });
976
- }
977
- if (cloudElements.some((e) => {
978
- if (e.kind === "layer" && e.convective !== void 0) return true;
979
- const h = e.heightFt.value;
980
- return h !== null && h < 5e3;
981
- })) {
982
- 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(" ");
983
- warnings.push({
984
- code: "cross-check-conflict",
985
- severity: "warning",
986
- message: `CAVOK 与${whenLabel}云组矛盾(${cloudRaw},CAVOK 语义要求 5000ft 以下无云且无 CB/TCU)——让位照旧,报文自洽性存疑`,
987
- span: at
988
- });
989
- }
1120
+ }
1121
+ /** VV(垂直能见度)元素构造(原正文循环 VV 分支内联块):缺测电码形态告警、合法数值组
1122
+ * (VV002 = 垂直能见度 200ft)零告警;null = 非 VV 组形态。 */
1123
+ function verticalVisibilityElementOf(t, warnings) {
1124
+ const vvM = VV_PATTERN.exec(t.text);
1125
+ if (vvM === null) return null;
1126
+ const heightRaw = vvM[1];
1127
+ if (heightRaw === void 0 || heightRaw === "///") warnings.push({
1128
+ code: "missing-expected",
1129
+ severity: "info",
1130
+ message: heightRaw === "///" ? "垂直能见度缺测(VV///,天空全遮蔽但垂直能见度不可测)" : "垂直能见度缺测(VV,兼容形态)",
1131
+ span: spanOf(t)
1132
+ });
1133
+ return {
1134
+ kind: "vertical-visibility",
1135
+ heightFt: {
1136
+ value: heightRaw === void 0 || heightRaw === "///" ? null : Number.parseInt(heightRaw, 10) * 100,
1137
+ span: spanOf(t)
1138
+ },
1139
+ span: spanOf(t)
990
1140
  };
991
- let windShear;
992
- let wsRunways;
993
- let wsAll = false;
994
- let wsSpan;
995
- while (i < tokens.length) {
996
- const t = tokens[i];
1141
+ }
1142
+ function parseWindShearSequence(t, tok1, tok2) {
1143
+ const designator = tok1 !== void 0 ? /^RWY(\d{2}[RLC]?)$/.exec(tok1.text) : null;
1144
+ const stdDesignator = tok1 !== void 0 ? /^R(\d{2}[RLC]?)$/.exec(tok1.text) : null;
1145
+ const isAll = tok1?.text === "ALL" && tok2?.text === "RWY" || tok1?.text === "RWY" && tok2?.text === "ALL";
1146
+ if (designator === null && stdDesignator === null && !isAll) return null;
1147
+ const endTok = isAll ? tok2 : tok1;
1148
+ const startSpan = spanOf(t);
1149
+ const endSpan = endTok !== void 0 ? spanOf(endTok) : startSpan;
1150
+ return {
1151
+ runway: designator !== null ? designator[1] ?? "" : stdDesignator !== null ? stdDesignator[1] ?? "" : null,
1152
+ all: isAll,
1153
+ span: {
1154
+ start: startSpan.start,
1155
+ end: endSpan.end
1156
+ },
1157
+ consumed: isAll ? 3 : 2
1158
+ };
1159
+ }
1160
+ /** RVRNO 落位(正文位,原正文循环 RVRNO 分支内联块):与 RVR 值组并存 = 矛盾形态出声、
1161
+ * 值组按跑道级明细保留;否则 rvr 显式缺测 + missing-expected;remarks 一律留痕 rvr-no。
1162
+ * 返回新的 rvr 值。 */
1163
+ function applyRvrNoBodyToken(t, rvr, raw, warnings, remarks) {
1164
+ let next = rvr;
1165
+ if (rvr?.kind === "value") {
1166
+ const prevText = rvr.span === void 0 ? "" : raw.slice(rvr.span.start, rvr.span.end);
1167
+ warnings.push({
1168
+ code: "cross-check-conflict",
1169
+ severity: "info",
1170
+ message: `RVRNO(站级:应报而缺)与 RVR 值组并存(${prevText})——矛盾形态,值组按跑道级明细保留,RVRNO 经 remarks/raw 可回溯`,
1171
+ span: spanOf(t)
1172
+ });
1173
+ } else {
1174
+ warnings.push({
1175
+ code: "missing-expected",
1176
+ severity: "info",
1177
+ message: "RVR 设备在但明示不可用(RVRNO)",
1178
+ span: spanOf(t)
1179
+ });
1180
+ next = {
1181
+ kind: "missing",
1182
+ span: spanOf(t)
1183
+ };
1184
+ }
1185
+ remarks.push({
1186
+ kind: "rvr-no",
1187
+ raw: t.text,
1188
+ span: spanOf(t)
1189
+ });
1190
+ return next;
1191
+ }
1192
+ /** 反向次序矛盾出声(RVRNO 在前、值组在后,原 RVR 值组分支内联块):可解读为传感器恢复,
1193
+ * 值组按明细保留——与正序矛盾并存同一文案口径(见 applyRvrNoBodyToken 注释)。 */
1194
+ function warnRvrNoThenValues(t, prevSpan, raw, warnings) {
1195
+ const prevText = prevSpan === void 0 ? "" : raw.slice(prevSpan.start, prevSpan.end);
1196
+ warnings.push({
1197
+ code: "cross-check-conflict",
1198
+ severity: "info",
1199
+ message: `RVRNO(站级:应报而缺)与后随 RVR 值组并存(${prevText})——矛盾形态,值组按跑道级明细保留(可解读为传感器恢复),RVRNO 经 remarks/raw 可回溯`,
1200
+ span: spanOf(t)
1201
+ });
1202
+ }
1203
+ /** RVR 全斜杠缺测(R##/#### 标准 5 斜杠与磨损 4 斜杠形态,原正文循环内联块):判缺测 +
1204
+ * missing-expected(4 斜杠另附磨损 info);null = 非本组形态。
1205
+ * 依据:WMO 306 FM15 RVR 值位恒四位(VRVRVRVR),全斜杠即数值缺测(斜杠逐位填充的缺测惯例)。 */
1206
+ function applyRvrSlashToken(t, warnings) {
1207
+ const rvrSlash = /^R\d{2}[RLC]?(\/{4,5})$/.exec(t.text);
1208
+ if (rvrSlash === null) return null;
1209
+ warnings.push({
1210
+ code: "missing-expected",
1211
+ severity: "info",
1212
+ message: `RVR 组缺测(${t.text}——跑道号在位、视程值位全斜杠)`,
1213
+ span: spanOf(t)
1214
+ });
1215
+ if ((rvrSlash[1] ?? "").length === 4) warnings.push({
1216
+ code: "invalid-format",
1217
+ severity: "info",
1218
+ message: `RVR 缺测段磨损(${t.text}——4 位斜杠对标准 5 位(分离符 + 四位值位),少一位;按缺测收下)`,
1219
+ span: spanOf(t)
1220
+ });
1221
+ return {
1222
+ kind: "missing",
1223
+ span: spanOf(t)
1224
+ };
1225
+ }
1226
+ /** 当前天气三态收口(原正文循环后内联块):仅有实组时给值;RE-only 报文 weather 保持
1227
+ * undefined(组省略 ≠ 缺测);组级 span 首组至末组。 */
1228
+ function observedWeatherOf(weatherList) {
1229
+ if (weatherList.length === 0) return void 0;
1230
+ const first = weatherList[0];
1231
+ const last = weatherList[weatherList.length - 1];
1232
+ if (first !== void 0 && last !== void 0) return {
1233
+ kind: "value",
1234
+ value: [...weatherList],
1235
+ span: first.span !== void 0 && last.span !== void 0 ? {
1236
+ start: first.span.start,
1237
+ end: last.span.end
1238
+ } : void 0
1239
+ };
1240
+ }
1241
+ /** CAVOK 三面交叉校验(能见度/天气/云,原 parse() 内 cavokConflicts 闭包)。词位时对
1242
+ * 「前序」组校验;正文循环收口后对「后续」组再校验——CAVOK 让位发生在词位,其后再出现
1243
+ * 的矛盾组此前静默并存(CAVOK BKN012 形态)。判据词位/词后完全一致:确定矛盾才告警
1244
+ * (下界编码/真值天气/低云或对流云;缺测云高不判)。告警 span 统一指 CAVOK 词位
1245
+ * (既有契约:矛盾双方中「主张」所在)。 */
1246
+ function cavokCrossCheck(state, whenLabel, raw, warnings) {
1247
+ const at = state.cavokSpan;
1248
+ const visibility = state.visibility;
1249
+ if (visibility?.kind === "value") {
1250
+ const g = visibility.value;
1251
+ const meters = g.unit === "m" ? g.value : g.value * 1609.344;
1252
+ const visRaw = visibility.span === void 0 ? `${g.value} ${g.unit}` : raw.slice(visibility.span.start, visibility.span.end);
1253
+ if (g.beyond === "below" || g.unit === "sm" && visRaw.startsWith("M") || g.exact && meters < 1e4) warnings.push({
1254
+ code: "cross-check-conflict",
1255
+ severity: "warning",
1256
+ message: `CAVOK 与${whenLabel}能见度组矛盾(${visRaw},CAVOK 语义要求 ≥10km)——让位照旧,报文自洽性存疑`,
1257
+ span: at
1258
+ });
1259
+ }
1260
+ if (state.weatherList.length > 0) {
1261
+ const wxRaw = state.weatherList.map((g) => `${g.proximity ? "VC" : ""}${g.intensity ?? ""}${g.descriptor ?? ""}${g.phenomena.join("")}`).join(" ");
1262
+ warnings.push({
1263
+ code: "cross-check-conflict",
1264
+ severity: "warning",
1265
+ message: `CAVOK 与${whenLabel}天气组矛盾(${wxRaw},CAVOK 语义要求无重要天气)——让位照旧,报文自洽性存疑`,
1266
+ span: at
1267
+ });
1268
+ }
1269
+ if (state.rvr !== void 0 && state.rvr.kind === "value") {
1270
+ const rvrRaw = state.rvr.span === void 0 ? "" : raw.slice(state.rvr.span.start, state.rvr.span.end);
1271
+ warnings.push({
1272
+ code: "cross-check-conflict",
1273
+ severity: "warning",
1274
+ message: `CAVOK 与${whenLabel}RVR 组矛盾(${rvrRaw}——AP-117 第 140 条:CAVOK 代替能见度、跑道视程、现在天气和云)——让位照旧,报文自洽性存疑`,
1275
+ span: at
1276
+ });
1277
+ }
1278
+ if (state.cloudElements.some((e) => {
1279
+ if (e.kind === "layer" && e.convective !== void 0) return true;
1280
+ const h = e.heightFt.value;
1281
+ return h !== null && h < 5e3;
1282
+ })) {
1283
+ const cloudRaw = state.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(" ");
1284
+ warnings.push({
1285
+ code: "cross-check-conflict",
1286
+ severity: "warning",
1287
+ message: `CAVOK 与${whenLabel}云组矛盾(${cloudRaw},CAVOK 语义要求 5000ft 以下无云且无 CB/TCU)——让位照旧,报文自洽性存疑`,
1288
+ span: at
1289
+ });
1290
+ }
1291
+ }
1292
+ /** RMK 段整体(原 parse() 内 RMK while 循环迁出):认组粒度收下 FMH-1 附加信息与俄区
1293
+ * 国家组,未知 ≠ 错误(不进 warnings,收 'unknown' remark);RVRNO 在 RMK 位与正文位
1294
+ * 同口径(并存矛盾出声、值组明细保留)。游标(tokens+start)与累加器(remarks/warnings)
1295
+ * 显式传参;返回 rvr 终态与段末位置。 */
1296
+ function parseRemarkSegment(tokens, start, raw, remarks, warnings, rvrIn) {
1297
+ let rvr = rvrIn;
1298
+ let i = start;
1299
+ while (i < tokens.length) {
1300
+ const t = tokens[i];
1301
+ if (t === void 0) break;
1302
+ const text = t.text;
1303
+ const push = (remarkKind, consumed = 1) => {
1304
+ const slice = tokens.slice(i, i + consumed);
1305
+ remarks.push({
1306
+ kind: remarkKind,
1307
+ raw: slice.map((c) => c.text).join(" "),
1308
+ span: joinSpan(slice)
1309
+ });
1310
+ i += consumed;
1311
+ };
1312
+ if (text === "AO1" || text === "AO2" || text === "A01" || text === "A02") {
1313
+ push("auto-type");
1314
+ continue;
1315
+ }
1316
+ if (/^SLP(\d{3}|NO)$/.test(text)) {
1317
+ push("sea-level-pressure");
1318
+ continue;
1319
+ }
1320
+ if (/^T[01]\d{7}(T[01]\d{7})?$/.test(text) || /^T[01]\d{3}$/.test(text)) {
1321
+ push("precise-temperature");
1322
+ continue;
1323
+ }
1324
+ if (/^P\d{4}$/.test(text)) {
1325
+ push("precip-1h");
1326
+ continue;
1327
+ }
1328
+ if (/^[67](?:\d{4}|\/{4})$/.test(text)) {
1329
+ push("precip-window");
1330
+ continue;
1331
+ }
1332
+ if (/^4\/\d{3}$/.test(text)) {
1333
+ push("snow-depth");
1334
+ continue;
1335
+ }
1336
+ if (/^I[136]\d{3}$/.test(text)) {
1337
+ push("ice-accretion");
1338
+ continue;
1339
+ }
1340
+ if (/^5[0-8]\d{3}$/.test(text)) {
1341
+ push("pressure-tendency");
1342
+ continue;
1343
+ }
1344
+ if (/^4(?:[01]\d{3}[01]\d{3}|\d{7})$/.test(text)) {
1345
+ push("temp-extrema-24h");
1346
+ continue;
1347
+ }
1348
+ if (/^[12]\d{4}$/.test(text)) {
1349
+ push("temp-extrema-6h");
1350
+ continue;
1351
+ }
1352
+ if (text === "PK" && tokens[i + 1]?.text === "WND") {
1353
+ push("peak-wind", 3);
1354
+ continue;
1355
+ }
1356
+ if (text === "WSHFT" && tokens[i + 1] !== void 0) {
1357
+ push("wind-shift", 2);
1358
+ continue;
1359
+ }
1360
+ if (text === "PRESRR" || text === "PRESFR") {
1361
+ push("pressure-change");
1362
+ continue;
1363
+ }
1364
+ if (text === "VISNO") {
1365
+ push("vis-no");
1366
+ continue;
1367
+ }
1368
+ if (text === "TSNO") {
1369
+ push("thunderstorm-sensor");
1370
+ continue;
1371
+ }
1372
+ if (text === "RVRNO") {
1373
+ if (rvr?.kind === "value") {
1374
+ const prevText = rvr.span === void 0 ? "" : raw.slice(rvr.span.start, rvr.span.end);
1375
+ warnings.push({
1376
+ code: "cross-check-conflict",
1377
+ severity: "info",
1378
+ message: `RVRNO(站级:应报而缺)与 RVR 值组并存(${prevText})——矛盾形态,值组按跑道级明细保留,RVRNO 经 remarks/raw 可回溯`,
1379
+ span: spanOf(t)
1380
+ });
1381
+ } else rvr = {
1382
+ kind: "missing",
1383
+ span: spanOf(t)
1384
+ };
1385
+ push("rvr-no");
1386
+ continue;
1387
+ }
1388
+ if (text === "SFC" && tokens[i + 1]?.text === "VIS") {
1389
+ push("surface-visibility", 3 + (/^\d\/\d(SM)?$/.test(tokens[i + 3]?.text ?? "") ? 1 : 0));
1390
+ continue;
1391
+ }
1392
+ if (text === "TWR" && tokens[i + 1]?.text === "VIS") {
1393
+ push("twr-visibility", 3 + (/^\d\/\d(SM)?$/.test(tokens[i + 3]?.text ?? "") ? 1 : 0));
1394
+ continue;
1395
+ }
1396
+ const visDirNext = tokens[i + 1]?.text;
1397
+ const RMK_VIS_DIR = /^(N|NE|E|SE|S|SW|W|NW)$/;
1398
+ const RMK_VIS_VAL = /^[MP]?\d+(\/\d+)?(SM)?$/;
1399
+ if (text === "VIS" && visDirNext !== void 0 && RMK_VIS_DIR.test(visDirNext) && tokens[i + 2] !== void 0) {
1400
+ let consumed = 3 + (/^\d\/\d(SM)?$/.test(tokens[i + 3]?.text ?? "") ? 1 : 0);
1401
+ for (let j = i + consumed;;) {
1402
+ const dir = tokens[j]?.text;
1403
+ if (dir === void 0 || !RMK_VIS_DIR.test(dir)) break;
1404
+ const val = tokens[j + 1]?.text;
1405
+ if (val === void 0 || !RMK_VIS_VAL.test(val)) break;
1406
+ const pair = /^\d+$/.test(val) && /^\d\/\d(SM)?$/.test(tokens[j + 2]?.text ?? "") ? 3 : 2;
1407
+ consumed += pair;
1408
+ j += pair;
1409
+ }
1410
+ push("sectoral-visibility", consumed);
1411
+ continue;
1412
+ }
1413
+ if (text === "VIS" && visDirNext !== void 0 && RMK_VIS_VAL.test(visDirNext)) {
1414
+ let consumed = 2;
1415
+ if (/^\d+$/.test(visDirNext) && /^\d\/\d(SM)?$/.test(tokens[i + 2]?.text ?? "")) consumed += 1;
1416
+ if (tokens[i + consumed]?.text !== void 0 && RMK_VIS_DIR.test(tokens[i + consumed]?.text ?? "")) consumed += 1;
1417
+ push("sectoral-visibility", consumed);
1418
+ continue;
1419
+ }
1420
+ if (/^CIGNO$/.test(text)) {
1421
+ push("cig-not-available");
1422
+ continue;
1423
+ }
1424
+ const cigNext = tokens[i + 1]?.text;
1425
+ if (text === "CIG" && cigNext !== void 0) {
1426
+ const nxt = cigNext;
1427
+ if (/^\d{3}V\d{3}$/.test(nxt)) {
1428
+ push("ceiling-variation", 2);
1429
+ continue;
1430
+ }
1431
+ if (/^\d{3}$/.test(nxt)) {
1432
+ if (tokens[i + 2]?.text === "LOC") {
1433
+ push("ceiling-at-location", 3);
1434
+ continue;
1435
+ }
1436
+ push("ceiling", 2);
1437
+ continue;
1438
+ }
1439
+ }
1440
+ if (text === "PNO") {
1441
+ push("precip-not-available");
1442
+ continue;
1443
+ }
1444
+ if (text === "FZRANO") {
1445
+ push("fzr-not-available");
1446
+ continue;
1447
+ }
1448
+ if (text === "CHINO") {
1449
+ push("chino");
1450
+ continue;
1451
+ }
1452
+ if (/^8\/[0-9X/]{3}$/.test(text)) {
1453
+ push("cloud-type-8group");
1454
+ continue;
1455
+ }
1456
+ if (/^933\d{3}$/.test(text)) {
1457
+ push("snow-water-equivalent");
1458
+ continue;
1459
+ }
1460
+ if (text === "FUNNEL" && tokens[i + 1]?.text === "CLOUD") {
1461
+ push("phenomenon-began-ended", 2);
1462
+ continue;
1463
+ }
1464
+ if (text === "LTG") {
1465
+ let consumed = 1;
1466
+ let prevInVocab = false;
1467
+ let digitsAbsorbed = 0;
1468
+ for (;;) {
1469
+ const nextTok = tokens[i + consumed];
1470
+ if (nextTok === void 0) break;
1471
+ if (LTG_TAIL_VOCAB.has(nextTok.text)) {
1472
+ consumed += 1;
1473
+ prevInVocab = true;
1474
+ continue;
1475
+ }
1476
+ if (prevInVocab && digitsAbsorbed === 0 && /^\d{1,2}$/.test(nextTok.text)) {
1477
+ consumed += 1;
1478
+ digitsAbsorbed += 1;
1479
+ prevInVocab = false;
1480
+ continue;
1481
+ }
1482
+ break;
1483
+ }
1484
+ push("lightning", Math.min(consumed, tokens.length - i));
1485
+ continue;
1486
+ }
1487
+ if (text === "SNINCR" && /^\d+\/\d+$/.test(tokens[i + 1]?.text ?? "")) {
1488
+ push("snow-increase", 2);
1489
+ continue;
1490
+ }
1491
+ if (text === "VIS" && VIS_V_RANGE.test(tokens[i + 1]?.text ?? "")) {
1492
+ push("variable-visibility", 2);
1493
+ continue;
1494
+ }
1495
+ const beganMerged = /^([A-Z]{2,8})B\d{2,4}(?:E\d{2,4})?$/.exec(text);
1496
+ const beganSplit = /^([A-Z]{2})[BE]\d{2,4}$/.exec(text);
1497
+ const beganBodyOk = (m) => m !== null && splitWeatherToken(m[1] ?? "") !== null;
1498
+ if (beganBodyOk(beganMerged) || beganBodyOk(beganSplit) || /^[BE]\d{2,4}$/.test(text)) {
1499
+ push("phenomenon-began-ended");
1500
+ continue;
1501
+ }
1502
+ if (/^QBB\d{3}$/.test(text)) {
1503
+ push("cloud-base-height");
1504
+ continue;
1505
+ }
1506
+ if (/^QFE\d{3,4}(\/\d{3,4})?$/.test(text)) {
1507
+ push("aerodrome-pressure");
1508
+ continue;
1509
+ }
1510
+ if (text === "$") {
1511
+ push("maintenance");
1512
+ continue;
1513
+ }
1514
+ push("unknown");
1515
+ }
1516
+ return {
1517
+ rvr,
1518
+ next: i
1519
+ };
1520
+ }
1521
+ //#endregion
1522
+ //#region src/validate.ts
1523
+ /**
1524
+ * @metweave/parser — TAF 判据校验层(/validate,v0.2 补齐批:其他全部修复」指令)。
1525
+ * The TAF rule-validation layer:条文判据在解析层五处被显式移交至此(C2/C3/C5/B7 注释),本层收口。
1526
+ *
1527
+ * 判据清单(来源=学习线补全清单 + taf-tac §3,条款号核自 WMO 306 FM 51):
1528
+ * - C2 VRB 阈值两源(§51.3):WMO 风速 <1.5 m/s 才可编 VRB / CAAC <2 m/s 或雷暴——机器只判风速侧,
1529
+ * 「无法预报单一风向/雷暴」逃逸条款不由电码承载,明示不可判;
1530
+ * - C3 阵风阈值(§51.3):阵风超出平均 ≥5 m/s(10 kt)才合法编 Gfmfm——严格不等式按条文(D 节纪律);
1531
+ * - C5 天气白名单双层(§51.5.1):国际白名单为基(冻降水/中大降水含阵雨/尘沙暴/雷暴/冻雾/吹尘沙雪/飑/漏斗云),
1532
+ * 中国扩展层(弱档 -、BR、HZ——与 parser C5 注记同一判式)按 standard 取合法(caac)或违例(wmo);
1533
+ * - C7 三层选取(§51.6.1.4):第 1 组任意量、第 2 组 >2 oktas(SCT 起)、第 3 组起 >4 oktas(BKN 起)——
1534
+ * 仅全重报语境(基况段/FM 段)适用;BECMG/TEMPO 组内所列为局部清单不适用(B5);CB/TCU 缺报侧不可判;
1535
+ * - B7 间歇判据为空集:发作每次 <1h、累计 <半窗不由电码承载,机器不可判(判卷场景靠出题纪律,明示)。
1536
+ */
1537
+ const MPS_OF = {
1538
+ mps: 1,
1539
+ kt: .514444,
1540
+ kmh: 1 / 3.6
1541
+ };
1542
+ const mpsOf = (value, unit) => value * MPS_OF[unit];
1543
+ /** 国际白名单内的中/大降水现象码(弱档 - 一律出层;DZ 毛毛雨不在 TAF 白名单——教材 §3.5) */
1544
+ const INTL_PRECIP = /* @__PURE__ */ new Set([
1545
+ "RA",
1546
+ "SN",
1547
+ "SG",
1548
+ "PL",
1549
+ "GS",
1550
+ "GR"
1551
+ ]);
1552
+ /** 独立成组即白名单的现象码:尘暴/沙暴/飑/漏斗云(任意强度含 +) */
1553
+ const INTL_STANDALONE = /* @__PURE__ */ new Set([
1554
+ "DS",
1555
+ "SS",
1556
+ "SQ",
1557
+ "FC"
1558
+ ]);
1559
+ /**
1560
+ * C5 国际白名单判定(§51.5.1 教材口径):冻降水(FZRA/FZDZ)/ 雷暴族(TS 起头)/ 中大降水(含 SH 阵雨族,
1561
+ * 强度非弱档)/ 尘暴 DS / 沙暴 SS / 冻雾 FZFG / 吹尘沙雪(BLDU/BLSA/BLSN)/ 飑 SQ / 漏斗云 FC。
1562
+ * VC 邻近(METAR 语汇)一律出层。
1563
+ */
1564
+ const inIntlWhitelist = (g) => {
1565
+ if (g.proximity) return false;
1566
+ const codes = g.phenomena;
1567
+ const has = (p) => codes.includes(p);
1568
+ if (g.descriptor === "TS") return true;
1569
+ if (g.descriptor === "FZ" && (has("RA") || has("DZ") || has("FG"))) return true;
1570
+ if (g.descriptor === "BL" && (has("DU") || has("SA") || has("SN"))) return true;
1571
+ if (codes.some((p) => INTL_STANDALONE.has(p))) return true;
1572
+ if (codes.length > 0 && codes.every((p) => INTL_PRECIP.has(p))) return g.intensity !== "-";
1573
+ return false;
1574
+ };
1575
+ /** 中国扩展层(与 parser C5 注记同一判式,单一真相):弱档 - 或 BR/HZ */
1576
+ const isCnExtension = (g) => {
1577
+ const codes = g.phenomena;
1578
+ return g.intensity === "-" || codes.includes("BR") || codes.includes("HZ");
1579
+ };
1580
+ const weatherCodeOf = (g) => {
1581
+ const i = g.intensity ?? "";
1582
+ const d = g.descriptor ?? "";
1583
+ return `${g.proximity ? "VC" : ""}${i}${d}${g.phenomena.join("")}`;
1584
+ };
1585
+ const UNIT_WORD = {
1586
+ mps: "MPS",
1587
+ kt: "KT",
1588
+ kmh: "KMH"
1589
+ };
1590
+ /** 风组两判据(C2/C3):VRB 阈值与阵风增量(各自单位换算米/秒后比较) */
1591
+ const windViolations = (wind, where, standard) => {
1592
+ const out = [];
1593
+ const threshold = standard === "caac" ? 2 : 1.5;
1594
+ if (wind.variable && mpsOf(wind.speed.value, wind.speed.unit) >= threshold) out.push({
1595
+ code: "vrb-over-threshold",
1596
+ severity: "warning",
1597
+ where,
1598
+ span: wind.speed.span,
1599
+ message: `VRB 风速 ${wind.speed.value}${UNIT_WORD[wind.speed.unit]} ≥ ${threshold} m/s(${standard === "caac" ? "CAAC" : "WMO"} 口径)——不应编 VRB(C2;「无法预报单一风向/雷暴」逃逸条款不由电码承载,本判不覆盖)`
1600
+ });
1601
+ const gust = wind.gust;
1602
+ if (gust !== void 0 && mpsOf(gust.value, gust.unit) - mpsOf(wind.speed.value, wind.speed.unit) < 5) out.push({
1603
+ code: "gust-below-threshold",
1604
+ severity: "warning",
1605
+ where,
1606
+ span: gust.span,
1607
+ message: `阵风超出平均不足 5 m/s(10 kt)——G 组不合法(C3,严格 ≥5 m/s 才可编)`
1608
+ });
1609
+ return out;
1610
+ };
1611
+ /** C7 三层选取(§51.6.1.4,仅全重报语境):第 2 组须 >2 oktas、第 3 组起须 >4 oktas(CB/TCU 缺报侧不可判) */
1612
+ const cloudViolations = (clouds, where) => {
1613
+ if (clouds === void 0) return [];
1614
+ const layers = clouds.elements.filter((e) => e.kind === "layer");
1615
+ const out = [];
1616
+ layers.forEach((layer, idx) => {
1617
+ if (layer.amount === null) return;
1618
+ const illegal = idx === 1 && layer.amount === "FEW" ? "第 2 组须 >2 oktas(SCT 起步)" : idx >= 2 && (layer.amount === "FEW" || layer.amount === "SCT") ? "第 3 组起须 >4 oktas(BKN 起步)" : void 0;
1619
+ if (illegal !== void 0) out.push({
1620
+ code: "cloud-layer-selection",
1621
+ severity: "warning",
1622
+ where,
1623
+ span: layer.span,
1624
+ message: `三层选取违例:第 ${idx + 1} 组编 ${layer.amount}——${illegal}(C7/§51.6.1.4;CB/TCU「未入前三必补」的缺报侧不由电码承载)`
1625
+ });
1626
+ });
1627
+ return out;
1628
+ };
1629
+ /**
1630
+ * 校验一份已解析的 TAF:返回全部条文违例(空数组=通过)。
1631
+ * 判据与边界见模块头注(C2/C3/C5/C7;B7 机器判据空集)。不修改报告、不抛错——
1632
+ * 严判入口是 parseTaf({mode:"strict"}),本函数供质控/判卷侧按需取用。
1633
+ */
1634
+ function validateTaf(report, options = {}) {
1635
+ const standard = options.standard ?? "wmo";
1636
+ const out = [];
1637
+ const baseWind = report.wind?.kind === "value" ? report.wind.value : void 0;
1638
+ if (baseWind !== void 0) out.push(...windViolations(baseWind, "基况段", standard));
1639
+ out.push(...cloudViolations(report.clouds, "基况段"));
1640
+ const wxCheck = (g, where) => {
1641
+ if (inIntlWhitelist(g)) return;
1642
+ if (standard === "caac" && isCnExtension(g)) return;
1643
+ const layer = isCnExtension(g) ? "中国扩展层,非 WMO 白名单" : "国际白名单外";
1644
+ out.push({
1645
+ code: "wx-outside-list",
1646
+ severity: "warning",
1647
+ where,
1648
+ span: g.span,
1649
+ message: `天气组 ${weatherCodeOf(g)} 不在 TAF 预报白名单(${layer};C5/§51.5.1——caac 标准下中国扩展层合法)`
1650
+ });
1651
+ };
1652
+ if (report.weather?.kind === "value") for (const g of report.weather.value) wxCheck(g, "基况段");
1653
+ for (const change of report.changes) {
1654
+ const where = change.window !== void 0 ? `${change.kind} ${change.window.raw}` : change.kind;
1655
+ const e = change.elements;
1656
+ if (e === void 0) continue;
1657
+ if (e.wind !== void 0) out.push(...windViolations(e.wind, where, standard));
1658
+ if (change.kind === "FM") out.push(...cloudViolations(e.clouds, where));
1659
+ for (const g of e.weather) wxCheck(g, where);
1660
+ }
1661
+ return out;
1662
+ }
1663
+ /**
1664
+ * strict 门(parseTaf mode:"strict" 的判卷侧):任一条文违例(validateTaf)或 warning 级解析告警
1665
+ * (重复组/概率越界/组合违例等)即整体不通过——聚合为稳定错误码 strict-violation。
1666
+ * info 级(方言收编/缺测注记/中国扩展注记)不拦:真实世界被接受的形态。
1667
+ */
1668
+ function strictGate(report, raw, standard = "wmo") {
1669
+ const violations = validateTaf(report, { standard }).map((v) => `${v.where}:${v.message}`);
1670
+ const warnings = report.warnings.filter((w) => w.severity === "warning").map((w) => w.message);
1671
+ const all = [...violations, ...warnings];
1672
+ if (all.length === 0) return;
1673
+ throw new _metweave_core.MetarParseError("strict-violation", raw, `strict 校验未通过(${all.length} 项)——${all.slice(0, 3).join(";")}${all.length > 3 ? "……" : ""}`);
1674
+ }
1675
+ //#endregion
1676
+ //#region src/taf.ts
1677
+ /**
1678
+ * @metweave/parser — TAF 解析层(FM 51,v0.2 批 1 骨架)。
1679
+ * The TAF parsing layer (FM 51): header + validity skeleton.
1680
+ *
1681
+ * 批 1 范围:电头 token 序列(清单 A4——TAF 词可省(剥词源同理)、AMD/COR 不写死槽位、
1682
+ * COR 时组后位)、发布时组 ddHHMMZ、有效期组 ddHH/ddHH(止时 24 = 午夜合法特例)、
1683
+ * 传输层终止符 `=` 剥离(A1)、NIL/CNL 位置判别(A2)、AAA/CCA 族仅容错(A3)、
1684
+ * 基况段四要素 + CAVOK(复用 groups 共享件,组装语义沿 METAR)。
1685
+ * 变化组与气温组(批 2/3.4)界后 token 暂一律 unknown-token 出声(不静默纪律)。
1686
+ * strict 模式(v0.2 补齐批):tolerant 解析后经 strictGate(validate.ts 四判据 + warning 级告警聚合)。
1687
+ * 纪律与 METAR 侧同源:不静默、span 保真、错误码只增不改(ParseError 别名自 v0.2 起)。
1688
+ */
1689
+ const STATION_PATTERN = /^[A-Z0-9]{4}$/;
1690
+ const ISSUE_TIME_PATTERN = /^(\d{2})(\d{2})(\d{2})Z$/;
1691
+ const VALIDITY_PATTERN = /^(\d{2})(\d{2})\/(\d{2})(\d{2})$/;
1692
+ /** 基况段右界(批 2/3.4 接管前的停靠点):变化组 FM####/BECMG/TEMPO/PROB30|40 与气温组 TX/TN */
1693
+ const isChangeBoundary = (text) => /^FM\d{4}$/.test(text) || text === "BECMG" || text === "TEMPO" || /^PROB\d{2}$/.test(text) || text.startsWith("TX") || text.startsWith("TN");
1694
+ /**
1695
+ * 有效期时长(小时)=有效期组差值(清单 B1★):`(止日−起日)×24 + (止时−起时)`,
1696
+ * **只看有效期组,禁用发布钟点**(钟点 03/09/15/21Z 与版本解耦、代际切换——taf-tac §3.1 v1.2)。
1697
+ * 止时 24(B2 午夜特例)自然进算术;起日 > 止日(B3 跨月回绕)按所跨月长度回绕天数——
1698
+ * 有效期组不含月信息,缺省按 31 天(保守缺省),带月锚的精确回绕由展开层(B3)负责。
1699
+ * 注意:wrapDaysInMonth 须 ≥ 起日(2 月锚 + 31 日组属不自洽输入,算术结果为负由调用方甄别)。
1700
+ */
1701
+ function tafDurationHours(validity, wrapDaysInMonth = 31) {
1702
+ return (validity.endDay >= validity.startDay ? validity.endDay - validity.startDay : validity.endDay + wrapDaysInMonth - validity.startDay) * 24 + (validity.endHour - validity.startHour);
1703
+ }
1704
+ /**
1705
+ * Parse one TAF report (tolerant mode) into the TAF IR — the forecast-side entry.
1706
+ * 解析单条 TAF 报文(tolerant)为预报侧 IR。
1707
+ * 整体失败(输入非字符串/无站名/无发布时组/时组或有效期越界/未实现模式)抛 MetarParseError
1708
+ * (code 稳定契约,与 parse 同族;v0.2 起别名 ParseError)。
1709
+ */
1710
+ function parseTaf(raw, options) {
1711
+ if (typeof raw !== "string") throw new _metweave_core.MetarParseError("invalid-input", raw, `parseTaf 需要一个 TAF 报文字符串,收到 ${raw === null ? "null" : typeof raw}`);
1712
+ const compact = options?.spans === false;
1713
+ const compactIfEnabled = (node) => compact ? compactNode(node) : node;
1714
+ const finish = (node) => {
1715
+ if (options?.mode === "strict") strictGate(node, raw, options.validateStandard ?? "wmo");
1716
+ return compactIfEnabled(node);
1717
+ };
1718
+ const tokens = tokenize(raw.replace(/[=\s]+$/, ""));
1719
+ const warnings = [];
1720
+ let i = 0;
1721
+ const peek = (ahead = 0) => tokens[i + ahead];
1722
+ let amended = false;
1723
+ let corrected = false;
1724
+ if (peek()?.text === "TAF") i += 1;
1725
+ for (let guard = 0; guard < 4; guard++) {
1726
+ const t = peek();
1727
+ if (t === void 0) break;
1728
+ if (t.text === "AMD") {
1729
+ if (amended) warnings.push({
1730
+ code: "duplicate-group",
1731
+ severity: "warning",
1732
+ message: "电头 AMD 重复出现——首枚已置修订位,重复枚出声不静默",
1733
+ span: spanOf(t)
1734
+ });
1735
+ amended = true;
1736
+ i += 1;
1737
+ } else if (t.text === "COR") {
1738
+ if (corrected) warnings.push({
1739
+ code: "duplicate-group",
1740
+ severity: "warning",
1741
+ message: "电头 COR 重复出现——首枚已置更正位,重复枚出声不静默",
1742
+ span: spanOf(t)
1743
+ });
1744
+ corrected = true;
1745
+ i += 1;
1746
+ } else break;
1747
+ }
1748
+ const stTok = peek();
1749
+ if (stTok === void 0 || !STATION_PATTERN.test(stTok.text)) throw new _metweave_core.MetarParseError("missing-station", raw, `无法识别站名组——输入不是 TAF 报文(${stTok?.text ?? "空输入"})`);
1750
+ const station = stTok.text;
1751
+ i += 1;
1752
+ if (peek()?.text === "NIL") {
1753
+ i += 1;
1754
+ collectTailAsUnknown(tokens, i, warnings);
1755
+ return finish({
1756
+ kind: "taf",
1757
+ raw,
1758
+ station,
1759
+ nil: true,
1760
+ flags: {
1761
+ amended,
1762
+ corrected
1763
+ },
1764
+ cavok: false,
1765
+ changes: [],
1766
+ temperatures: [],
1767
+ remarks: [],
1768
+ warnings
1769
+ });
1770
+ }
1771
+ const tmTok = peek();
1772
+ const tm = tmTok !== void 0 ? ISSUE_TIME_PATTERN.exec(tmTok.text) : null;
1773
+ if (tmTok === void 0 || tm === null) throw new _metweave_core.MetarParseError("missing-time", raw, `无法识别发布时组——输入不是完整的 TAF 报文(${tmTok?.text ?? "时组缺失"})`);
1774
+ const day = Number(tm[1]);
1775
+ const hour = Number(tm[2]);
1776
+ const minute = Number(tm[3]);
1777
+ if (day < 1 || day > 31 || hour > 23 || minute > 59) throw new _metweave_core.MetarParseError("invalid-time", raw, `发布时组数值越界(${tmTok.text})——日 01–31 / 时 00–23 / 分 00–59`);
1778
+ const issueTime = {
1779
+ day,
1780
+ hour,
1781
+ minute
1782
+ };
1783
+ i += 1;
1784
+ for (let guard = 0; guard < 2; guard++) {
1785
+ const annotation = peek();
1786
+ if (annotation === void 0 || !/^(AAA|AAB|CCA|CCB)$/.test(annotation.text)) break;
1787
+ if (annotation.text.startsWith("A")) amended = true;
1788
+ else corrected = true;
1789
+ i += 1;
1790
+ warnings.push({
1791
+ code: "invalid-format",
1792
+ severity: "info",
1793
+ message: `AP-117 加注形态(${annotation.text} 于发布时组后——主路径为 TAF AMD/时组后 COR,条文自有形态已消费并按族置位)`,
1794
+ span: spanOf(annotation)
1795
+ });
1796
+ }
1797
+ const corAfterTime = peek();
1798
+ if (corAfterTime !== void 0 && corAfterTime.text === "COR") {
1799
+ if (corrected) warnings.push({
1800
+ code: "duplicate-group",
1801
+ severity: "warning",
1802
+ message: "COR 更正位重复出现(类型词位已置)——重复枚出声不静默",
1803
+ span: spanOf(corAfterTime)
1804
+ });
1805
+ corrected = true;
1806
+ i += 1;
1807
+ }
1808
+ const vTok = peek();
1809
+ if (vTok?.text === "NIL") {
1810
+ i += 1;
1811
+ collectTailAsUnknown(tokens, i, warnings);
1812
+ return finish({
1813
+ kind: "taf",
1814
+ raw,
1815
+ station,
1816
+ issueTime,
1817
+ nil: true,
1818
+ flags: {
1819
+ amended,
1820
+ corrected
1821
+ },
1822
+ cavok: false,
1823
+ changes: [],
1824
+ temperatures: [],
1825
+ remarks: [],
1826
+ warnings
1827
+ });
1828
+ }
1829
+ const vStd = vTok !== void 0 ? VALIDITY_PATTERN.exec(vTok.text) : null;
1830
+ let dialectValidity = false;
1831
+ let gv = vStd;
1832
+ if (gv === null && vTok !== void 0) {
1833
+ const d6 = /^(\d{2})(\d{2})(\d{2})$/.exec(vTok.text);
1834
+ if (d6 !== null) {
1835
+ gv = [
1836
+ d6[0],
1837
+ d6[1],
1838
+ d6[2],
1839
+ d6[1],
1840
+ d6[3]
1841
+ ];
1842
+ dialectValidity = true;
1843
+ }
1844
+ }
1845
+ if (vTok === void 0 || gv === null) throw new _metweave_core.MetarParseError("missing-validity", raw, `无法识别有效期组——TAF 发布时组后须为 ddHH/ddHH(NIL 缺报除外)(${vTok?.text ?? "组缺失"})`);
1846
+ const startDay = Number(gv[1]);
1847
+ const startHour = Number(gv[2]);
1848
+ const endDay = Number(gv[3]);
1849
+ const endHour = Number(gv[4]);
1850
+ if (startDay < 1 || startDay > 31 || startHour > 23 || endDay < 1 || endDay > 31 || endHour > 24) throw new _metweave_core.MetarParseError("invalid-validity", raw, `有效期组数值越界(${vTok.text})——日 01–31 / 起时 00–23 / 止时 00–24(24=午夜合法特例)`);
1851
+ const validity = {
1852
+ startDay,
1853
+ startHour,
1854
+ endDay,
1855
+ endHour,
1856
+ raw: vTok.text,
1857
+ span: spanOf(vTok)
1858
+ };
1859
+ if (dialectValidity) warnings.push({
1860
+ code: "invalid-format",
1861
+ severity: "info",
1862
+ message: `有效期无斜杠方言形态(${vTok.text}=同日起止)——已按 dddd/24 语义收下(非 WMO 标准形)`,
1863
+ span: spanOf(vTok)
1864
+ });
1865
+ i += 1;
1866
+ if (peek()?.text === "CNL") {
1867
+ i += 1;
1868
+ collectTailAsUnknown(tokens, i, warnings);
1869
+ return finish({
1870
+ kind: "taf",
1871
+ raw,
1872
+ station,
1873
+ issueTime,
1874
+ validity,
1875
+ cancelled: true,
1876
+ flags: {
1877
+ amended,
1878
+ corrected
1879
+ },
1880
+ cavok: false,
1881
+ changes: [],
1882
+ temperatures: [],
1883
+ remarks: [],
1884
+ warnings
1885
+ });
1886
+ }
1887
+ let wind;
1888
+ let visibility;
1889
+ let weather;
1890
+ const weatherList = [];
1891
+ let directionalAsPrimary = false;
1892
+ const cloudElements = [];
1893
+ let clearCode;
1894
+ let cavok = false;
1895
+ let cavokSpan;
1896
+ for (; i < tokens.length;) {
1897
+ const t = tokens[i];
1898
+ if (t === void 0 || isChangeBoundary(t.text)) break;
1899
+ const text = t.text;
1900
+ if (text === "CAVOK") {
1901
+ cavok = true;
1902
+ cavokSpan = spanOf(t);
1903
+ visibility = void 0;
1904
+ directionalAsPrimary = false;
1905
+ weather = void 0;
1906
+ weatherList.length = 0;
1907
+ cloudElements.length = 0;
1908
+ clearCode = void 0;
1909
+ i += 1;
1910
+ continue;
1911
+ }
1912
+ const windParsed = parseWindToken(t, tokens[i + 1]);
1913
+ if (windParsed !== null) {
1914
+ if (wind !== void 0) warnDuplicateGroup(raw, warnings, "风组", wind.span, spanOf(t));
1915
+ if (windParsed === "missing") {
1916
+ if (wind === void 0) {
1917
+ wind = {
1918
+ kind: "missing",
1919
+ span: spanOf(t)
1920
+ };
1921
+ warnings.push({
1922
+ code: "missing-expected",
1923
+ severity: "info",
1924
+ message: `风组缺测(${t.text})`,
1925
+ span: spanOf(t)
1926
+ });
1927
+ }
1928
+ i += 1;
1929
+ } else {
1930
+ const applied = validateWindGroup(t, windParsed, raw);
1931
+ wind = applied.wind;
1932
+ warnings.push(...applied.warnings);
1933
+ i += windParsed.consumed;
1934
+ }
1935
+ continue;
1936
+ }
1937
+ const visParsed = parseVisibilityToken(t, tokens[i + 1]);
1938
+ if (visParsed !== null) {
1939
+ const applied = applyVisibilityToken(t, visParsed, {
1940
+ visibility,
1941
+ directionalAsPrimary
1942
+ }, raw, warnings);
1943
+ visibility = applied.visibility;
1944
+ directionalAsPrimary = applied.directionalAsPrimary;
1945
+ i += visParsed.consumed;
1946
+ continue;
1947
+ }
1948
+ const weatherParsed = tryWeatherToken(t, weatherList, []);
1949
+ if (weatherParsed !== false) {
1950
+ if (weatherParsed.outOfOrder) warnings.push({
1951
+ code: "invalid-format",
1952
+ severity: "warning",
1953
+ message: `天气组语序不合电码表(${text}:描述符须先于现象)——已按切解结果收下`,
1954
+ span: spanOf(t)
1955
+ });
1956
+ if (weatherParsed.signWithVc) warnings.push({
1957
+ code: "invalid-format",
1958
+ severity: "info",
1959
+ message: `强度符与 VC 邻近指示并存(${text}——强度符不与 VC 同组)——已按切解结果收下`,
1960
+ span: spanOf(t)
1961
+ });
1962
+ i += 1;
1963
+ continue;
1964
+ }
1965
+ if (text === "//") {
1966
+ weather = {
1967
+ kind: "missing",
1968
+ span: spanOf(t)
1969
+ };
1970
+ warnings.push({
1971
+ code: "missing-expected",
1972
+ severity: "info",
1973
+ message: "天气组缺测(//,无法观测天气)",
1974
+ span: spanOf(t)
1975
+ });
1976
+ i += 1;
1977
+ continue;
1978
+ }
1979
+ const cloudElem = cloudLayerElementOf(t, warnings);
1980
+ if (cloudElem !== null) {
1981
+ cloudElements.push(cloudElem);
1982
+ i += 1;
1983
+ continue;
1984
+ }
1985
+ const vvElem = verticalVisibilityElementOf(t, warnings);
1986
+ if (vvElem !== null) {
1987
+ cloudElements.push(vvElem);
1988
+ i += 1;
1989
+ continue;
1990
+ }
1991
+ if (isSkyClear(text)) {
1992
+ clearCode = {
1993
+ code: text,
1994
+ span: spanOf(t)
1995
+ };
1996
+ cloudElements.length = 0;
1997
+ i += 1;
1998
+ continue;
1999
+ }
2000
+ warnings.push({
2001
+ code: "unknown-token",
2002
+ severity: "info",
2003
+ message: `TAF 基况段未识别组(${text})——TAF 无此组位(RVR/温露对/QNH/RMK 属 METAR 语汇),保留原文`,
2004
+ span: spanOf(t)
2005
+ });
2006
+ i += 1;
2007
+ }
2008
+ const changes = [];
2009
+ const temperatures = [];
2010
+ while (i < tokens.length) {
2011
+ const t = tokens[i];
2012
+ if (t === void 0) break;
2013
+ const text = t.text;
2014
+ const txTn = /^(TX|TN)(M?\d{2})\/(?:(\d{2})(\d{2})|(\d{2}))Z$/.exec(text);
2015
+ if (txTn !== null) {
2016
+ temperatures.push({
2017
+ extremum: txTn[1] === "TX" ? "max" : "min",
2018
+ celsius: Number(txTn[2]?.replace("M", "-")),
2019
+ ...txTn[3] !== void 0 ? { at: {
2020
+ day: Number(txTn[3]),
2021
+ hour: Number(txTn[4])
2022
+ } } : { at: { hour: Number(txTn[5]) } },
2023
+ raw: text,
2024
+ span: spanOf(t)
2025
+ });
2026
+ if (temperatures.length > 4) warnings.push({
2027
+ code: "invalid-format",
2028
+ severity: "warning",
2029
+ message: `气温组超 WMO 上限 4 组(第 ${temperatures.length} 组 ${text})——照收不丢弃,超出属编报违规(清单 C6)`,
2030
+ span: spanOf(t)
2031
+ });
2032
+ i += 1;
2033
+ continue;
2034
+ }
2035
+ const fm = /^FM(\d{2})(\d{2})$/.exec(text);
2036
+ const prob = /^PROB(\d{2})$/.exec(text);
2037
+ if (fm === null && prob === null && text !== "BECMG" && text !== "TEMPO") {
2038
+ warnings.push({
2039
+ code: "unknown-token",
2040
+ severity: "info",
2041
+ message: `TAF 变化组段未识别组(${text})`,
2042
+ span: spanOf(t)
2043
+ });
2044
+ i += 1;
2045
+ continue;
2046
+ }
2047
+ const startTok = t;
2048
+ i += 1;
2049
+ if (fm !== null) {
2050
+ const at = {
2051
+ hour: Number(fm[1]),
2052
+ minute: Number(fm[2]),
2053
+ raw: text,
2054
+ span: spanOf(startTok)
2055
+ };
2056
+ const [elements, next] = collectChangeElements(tokens, i);
2057
+ changes.push({
2058
+ kind: "FM",
2059
+ at,
2060
+ ...elements !== void 0 ? { elements } : {},
2061
+ ...groupExtent(raw, tokens, startTok, next)
2062
+ });
2063
+ i = next;
2064
+ continue;
2065
+ }
2066
+ let kind;
2067
+ let probability;
2068
+ let withTempo = false;
2069
+ if (prob !== null) {
2070
+ kind = "PROB";
2071
+ const p = Number(prob[1]);
2072
+ if (p === 30 || p === 40) probability = p;
2073
+ else warnings.push({
2074
+ code: "invalid-format",
2075
+ severity: "warning",
2076
+ message: `PROB 概率越界(${text}——C2C2 须为 30 或 40,清单 B8):概率位省略,组照常解析`,
2077
+ span: spanOf(startTok)
2078
+ });
2079
+ const nx = tokens[i];
2080
+ if (nx?.text === "TEMPO") {
2081
+ withTempo = true;
2082
+ i += 1;
2083
+ } else if (nx?.text === "BECMG" || /^FM\d{4}$/.test(nx?.text ?? "")) {
2084
+ warnings.push({
2085
+ code: "invalid-format",
2086
+ severity: "warning",
2087
+ message: `PROB 组合违例(PROB 与 ${nx?.text} 并用——只可独立或连 TEMPO,清单 B8):PROB 段空收,后者独立成组`,
2088
+ span: spanOf(startTok)
2089
+ });
2090
+ changes.push({
2091
+ kind: "PROB",
2092
+ ...probability !== void 0 ? { probability } : {},
2093
+ raw: text,
2094
+ span: spanOf(startTok)
2095
+ });
2096
+ continue;
2097
+ }
2098
+ } else kind = text === "TEMPO" ? "TEMPO" : "BECMG";
2099
+ let window;
2100
+ const winTok = tokens[i];
2101
+ if (winTok !== void 0) {
2102
+ const w = /^(\d{2})(\d{2})\/(\d{2})(\d{2})$/.exec(winTok.text);
2103
+ const s = /^(\d{2})(\d{2})$/.exec(winTok.text);
2104
+ if (w !== null) {
2105
+ window = {
2106
+ startDay: Number(w[1]),
2107
+ startHour: Number(w[2]),
2108
+ endDay: Number(w[3]),
2109
+ endHour: Number(w[4]),
2110
+ raw: winTok.text,
2111
+ span: spanOf(winTok)
2112
+ };
2113
+ i += 1;
2114
+ } else if (s !== null && validity !== void 0 && Number(s[2]) > Number(s[1]) && Number(s[2]) <= 24 && Number(s[1]) <= 23) {
2115
+ window = {
2116
+ startDay: validity.startDay,
2117
+ startHour: Number(s[1]),
2118
+ endDay: validity.startDay,
2119
+ endHour: Number(s[2]),
2120
+ raw: winTok.text,
2121
+ span: spanOf(winTok)
2122
+ };
2123
+ i += 1;
2124
+ }
2125
+ }
2126
+ if (window === void 0 && kind !== "PROB") warnings.push({
2127
+ code: "invalid-format",
2128
+ severity: "info",
2129
+ message: `${kind} 组缺窗(变化词后未随 ddHH/ddHH 窗口组)——要素照常收`,
2130
+ span: spanOf(startTok)
2131
+ });
2132
+ const [elements, next] = collectChangeElements(tokens, i);
2133
+ changes.push({
2134
+ kind,
2135
+ ...probability !== void 0 ? { probability } : {},
2136
+ ...withTempo ? { withTempo } : {},
2137
+ ...window !== void 0 ? { window } : {},
2138
+ ...elements !== void 0 ? { elements } : {},
2139
+ ...groupExtent(raw, tokens, startTok, next)
2140
+ });
2141
+ i = next;
2142
+ }
2143
+ const clouds = cloudElements.length > 0 || clearCode !== void 0 ? {
2144
+ elements: [...cloudElements],
2145
+ ...clearCode !== void 0 ? { clear: clearCode } : {}
2146
+ } : void 0;
2147
+ const cnExtensionGroups = [];
2148
+ const baseWeather = weather ?? observedWeatherOf(weatherList);
2149
+ if (baseWeather?.kind === "value") for (const g of baseWeather.value) cnExtensionGroups.push({
2150
+ group: g,
2151
+ where: "基况段"
2152
+ });
2153
+ for (const change of changes) for (const g of change.elements?.weather ?? []) cnExtensionGroups.push({
2154
+ group: g,
2155
+ where: `${change.kind} 组`
2156
+ });
2157
+ for (const { group, where } of cnExtensionGroups) {
2158
+ if (!(group.intensity === "-" || group.phenomena.some((p) => p === "BR" || p === "HZ"))) continue;
2159
+ warnings.push({
2160
+ code: "invalid-format",
2161
+ severity: "info",
2162
+ message: `TAF 国际白名单外天气组(${where}——弱档/BR/HZ 属中国扩展层,清单 C5):已收下`,
2163
+ span: group.span
2164
+ });
2165
+ }
2166
+ return finish({
2167
+ kind: "taf",
2168
+ raw,
2169
+ station,
2170
+ issueTime,
2171
+ validity,
2172
+ flags: {
2173
+ amended,
2174
+ corrected
2175
+ },
2176
+ wind,
2177
+ visibility,
2178
+ weather: weather ?? observedWeatherOf(weatherList),
2179
+ clouds,
2180
+ cavok,
2181
+ cavokSpan,
2182
+ changes,
2183
+ temperatures,
2184
+ remarks: [],
2185
+ warnings
2186
+ });
2187
+ }
2188
+ /** 变化组要素收集:从 from 起收到下一个组界(变化词/气温组/末尾),交 structureTrendElements 结构化 */
2189
+ function collectChangeElements(tokens, from) {
2190
+ const collected = [];
2191
+ let k = from;
2192
+ while (k < tokens.length) {
2193
+ const t = tokens[k];
2194
+ if (t === void 0 || isChangeBoundary(t.text)) break;
2195
+ collected.push(t);
2196
+ k += 1;
2197
+ }
2198
+ return [structureTrendElements(collected, 0), k];
2199
+ }
2200
+ /** 组的外包络(raw 切片 + span):变化词 token 起至 next 前一 token 止 */
2201
+ function groupExtent(raw, tokens, startTok, next) {
2202
+ const last = tokens[next - 1] ?? startTok;
2203
+ const span = {
2204
+ start: startTok.start,
2205
+ end: last.end
2206
+ };
2207
+ return {
2208
+ raw: raw.slice(span.start, span.end),
2209
+ span
2210
+ };
2211
+ }
2212
+ /** NIL/CNL/批 1 骨架共用的尾部处理:剩余 token 一律 unknown-token 出声(不静默纪律) */
2213
+ function collectTailAsUnknown(tokens, from, warnings) {
2214
+ for (let k = from; k < tokens.length; k++) {
2215
+ const t = tokens[k];
2216
+ if (t === void 0) break;
2217
+ warnings.push({
2218
+ code: "unknown-token",
2219
+ severity: "info",
2220
+ message: `TAF 正文组暂未解析(${t.text})——批 1 骨架覆盖电头与有效期,该组保留原文待后续版本`,
2221
+ span: spanOf(t)
2222
+ });
2223
+ }
2224
+ }
2225
+ function tryParseTaf(raw, options) {
2226
+ try {
2227
+ return {
2228
+ ok: true,
2229
+ report: parseTaf(raw, options)
2230
+ };
2231
+ } catch (err) {
2232
+ if (err instanceof _metweave_core.MetarParseError) return {
2233
+ ok: false,
2234
+ error: err
2235
+ };
2236
+ throw err;
2237
+ }
2238
+ }
2239
+ //#endregion
2240
+ //#region src/expand.ts
2241
+ const minutesOf = (day, hour, minute) => day * 1440 + hour * 60 + minute;
2242
+ /** 未列要素沿链回溯合成(云例外:最近一次列云处整体取——BECMG 云必全重报,天然满足) */
2243
+ function resolveConditions(chain, upto) {
2244
+ let wind;
2245
+ let visibility;
2246
+ let weather;
2247
+ let clouds;
2248
+ let cavok = false;
2249
+ let sawWeather = false;
2250
+ let visVoid = false;
2251
+ let weatherVoid = false;
2252
+ let cloudsVoid = false;
2253
+ for (let k = upto; k >= 0; k--) {
2254
+ const seg = chain[k];
2255
+ if (seg === void 0) continue;
2256
+ const e = seg.elements;
2257
+ if (e === void 0) continue;
2258
+ if (wind === void 0 && e.wind !== void 0) wind = e.wind;
2259
+ if (visibility === void 0 && e.visibility !== void 0) visibility = e.visibility;
2260
+ if (!sawWeather && (e.weather.length > 0 || e.nsw !== void 0)) {
2261
+ weather = e.nsw !== void 0 && e.weather.length === 0 ? [] : e.weather;
2262
+ sawWeather = true;
2263
+ }
2264
+ if (clouds === void 0 && (e.clouds !== void 0 || e.cavok !== void 0)) {
2265
+ if (e.cavok !== void 0) cavok = true;
2266
+ clouds = e.clouds;
2267
+ }
2268
+ if (e.cavok !== void 0) {
2269
+ if (visibility === void 0) visVoid = true;
2270
+ if (!sawWeather) {
2271
+ weatherVoid = true;
2272
+ sawWeather = true;
2273
+ }
2274
+ if (clouds === void 0) cloudsVoid = true;
2275
+ }
2276
+ if (seg.hard) break;
2277
+ }
2278
+ return {
2279
+ ...wind !== void 0 ? { wind } : {},
2280
+ ...visibility !== void 0 && !visVoid ? { visibility } : {},
2281
+ weather: weatherVoid ? [] : weather ?? [],
2282
+ ...clouds !== void 0 && !cloudsVoid ? { clouds } : {},
2283
+ cavok
2284
+ };
2285
+ }
2286
+ /** 变化组窗口/时刻 → 绝对分(跨月回绕按锚月天数折算日序) */
2287
+ function absOfChange(change, anchorDay, daysIn) {
2288
+ const wrap = (day) => day < anchorDay ? day + daysIn : day;
2289
+ if (change.at !== void 0) {
2290
+ const sameDay = minutesOf(anchorDay, change.at.hour, change.at.minute);
2291
+ return {
2292
+ from: sameDay,
2293
+ end: sameDay
2294
+ };
2295
+ }
2296
+ const w = change.window;
2297
+ if (w === void 0) return void 0;
2298
+ return {
2299
+ from: minutesOf(wrap(w.startDay), w.startHour, 0),
2300
+ end: minutesOf(wrap(w.endDay), w.endHour, 0)
2301
+ };
2302
+ }
2303
+ /** 主导段切点构建(expandTaf 与 tafSegments 共用——切段规则单一来源):
2304
+ * base 起步;FM 硬分页(日归属按「不早于当前页起点」推断);BECMG 以窗终为段起点(保守约定) */
2305
+ function prevailingSegments(report, daysIn, validFrom) {
2306
+ const v = report.validity;
2307
+ const segments = [{
2308
+ from: validFrom,
2309
+ source: { kind: "base" },
2310
+ hard: false,
2311
+ elements: baseElements(report)
2312
+ }];
2313
+ let pageStart = validFrom;
2314
+ for (const [idx, change] of report.changes.entries()) {
2315
+ const kind = change.kind;
2316
+ if (kind === "TEMPO" || kind === "PROB") continue;
2317
+ if (kind === "FM" && change.at !== void 0) {
2318
+ let from = v !== void 0 ? minutesOf(v.startDay, change.at.hour, change.at.minute) : 0;
2319
+ while (from < pageStart) from += 1440;
2320
+ pageStart = from;
2321
+ segments.push({
2322
+ from,
2323
+ source: {
2324
+ kind,
2325
+ index: idx
2326
+ },
2327
+ hard: true,
2328
+ elements: change.elements
2329
+ });
2330
+ } else if (kind === "BECMG") {
2331
+ const abs = v !== void 0 ? absOfChange(change, v.startDay, daysIn) : void 0;
2332
+ if (abs === void 0) continue;
2333
+ segments.push({
2334
+ from: abs.end,
2335
+ source: {
2336
+ kind,
2337
+ index: idx
2338
+ },
2339
+ hard: false,
2340
+ elements: change.elements
2341
+ });
2342
+ }
2343
+ }
2344
+ return segments;
2345
+ }
2346
+ /**
2347
+ * Expand a parsed TAF at one instant — the five-step algorithm (切段→挂载→绑段→合成→叠加).
2348
+ * 在给定时刻展开已解析的 TAF(五步算法,黄金基准=taf-timeline §3 三例逐时刻表)。
2349
+ * `anchor` 为有效期起日所在月的天数(大小月/闰年由调用方给定,B3);`at.day` 为锚月内日序
2350
+ * (跨月后的日按小日号传入,内部回绕)。
2351
+ */
2352
+ function expandTaf(report, at, anchor) {
2353
+ if (report.validity === void 0 || report.nil === true || report.cancelled === true) throw new Error("expandTaf 需要带有效期的完整 TAF(NIL/CNL 报无可展开时间线)");
2354
+ const v = report.validity;
2355
+ const wrapDay = (day) => day < v.startDay ? day + anchor.daysIn : day;
2356
+ const t = minutesOf(wrapDay(at.day), at.hour, at.minute);
2357
+ const validFrom = minutesOf(v.startDay, v.startHour, 0);
2358
+ const segments = prevailingSegments(report, anchor.daysIn, validFrom);
2359
+ if (t < validFrom) return {
2360
+ conditions: resolveConditions([segments[0] ?? {
2361
+ from: validFrom,
2362
+ source: { kind: "base" },
2363
+ hard: false,
2364
+ elements: void 0
2365
+ }], 0),
2366
+ uncertain: false,
2367
+ boundSegment: { kind: "base" }
2368
+ };
2369
+ let bound = 0;
2370
+ let uncertain = false;
2371
+ for (const [k, seg] of segments.entries()) if (seg.from <= t) bound = k;
2372
+ for (const change of report.changes) {
2373
+ if (change.kind !== "BECMG") continue;
2374
+ const abs = absOfChange(change, v.startDay, anchor.daysIn);
2375
+ if (abs !== void 0 && t >= abs.from && t < abs.end) uncertain = true;
2376
+ }
2377
+ const conditions = resolveConditions(segments, bound);
2378
+ let tempo;
2379
+ for (const change of report.changes) {
2380
+ if (change.kind !== "TEMPO" && change.kind !== "PROB") continue;
2381
+ const abs = absOfChange(change, v.startDay, anchor.daysIn);
2382
+ if (abs === void 0 || t < abs.from || t >= abs.end) continue;
2383
+ const e = change.elements;
2384
+ if (e === void 0) continue;
2385
+ tempo = {
2386
+ conditions: {
2387
+ ...e.wind !== void 0 ? { wind: e.wind } : {},
2388
+ ...e.visibility !== void 0 ? { visibility: e.visibility } : {},
2389
+ weather: e.nsw !== void 0 && e.weather.length === 0 ? [] : e.weather,
2390
+ ...e.clouds !== void 0 ? { clouds: e.clouds } : {},
2391
+ cavok: e.cavok !== void 0
2392
+ },
2393
+ ...change.probability !== void 0 ? { probability: change.probability } : {},
2394
+ withTempo: change.withTempo === true
2395
+ };
2396
+ break;
2397
+ }
2398
+ return {
2399
+ conditions,
2400
+ ...tempo !== void 0 ? { tempo } : {},
2401
+ uncertain,
2402
+ boundSegment: segments[bound]?.source ?? { kind: "base" }
2403
+ };
2404
+ }
2405
+ /** 绝对分 → 时刻(日为绝对序,可直接喂 expandTaf——≥有效期起日不再回绕) */
2406
+ const atOfAbs = (abs) => ({
2407
+ day: Math.floor(abs / 1440),
2408
+ hour: Math.floor(abs % 1440 / 60),
2409
+ minute: abs % 60
2410
+ });
2411
+ /** 分段视图行窗口:绝对分 → 时刻(显示序)——日按锚月回绕折回小日号(绝对日 32 在 31 天锚月 → 01) */
2412
+ const atOfDisplay = (abs, daysIn) => {
2413
+ const raw = atOfAbs(abs);
2414
+ return {
2415
+ ...raw,
2416
+ day: (raw.day - 1) % daysIn + 1
2417
+ };
2418
+ };
2419
+ /**
2420
+ * Derive the period-by-period view of a TAF: prevailing rows (base / FM / after-BECMG) with the
2421
+ * BECMG transition band as its own uncertain row, plus TEMPO/PROB overlay rows — chronological.
2422
+ * 把 TAF 切成分段明细行(指令「按拆分时间段给具体天气」):主导段行 + BECMG 过渡带行 +
2423
+ * TEMPO/PROB 挂载行,按窗口起点升序稳定合并(同刻主导段在前);每行携带段中点展开结果——
2424
+ * 切段规则与 expandTaf 同一来源(prevailingSegments),黄金基准互通。
2425
+ */
2426
+ function tafSegments(report, anchor = { daysIn: 31 }) {
2427
+ if (report.validity === void 0 || report.nil === true || report.cancelled === true) throw new Error("tafSegments 需要带有效期的完整 TAF(NIL/CNL 报无可分段)");
2428
+ const v = report.validity;
2429
+ const validFrom = minutesOf(v.startDay, v.startHour, 0);
2430
+ const validEnd = minutesOf(v.endDay < v.startDay ? v.endDay + anchor.daysIn : v.endDay, v.endHour, 0);
2431
+ const rows = [];
2432
+ const segments = prevailingSegments(report, anchor.daysIn, validFrom);
2433
+ for (const [k, seg] of segments.entries()) {
2434
+ const next = segments[k + 1];
2435
+ const to = next !== void 0 ? next.from : validEnd;
2436
+ if (to <= seg.from) continue;
2437
+ const mid = seg.from + Math.floor((to - seg.from) / 2);
2438
+ const exp = expandTaf(report, atOfAbs(mid), anchor);
2439
+ rows.push({
2440
+ abs: seg.from,
2441
+ row: {
2442
+ kind: seg.source.kind,
2443
+ from: atOfDisplay(seg.from, anchor.daysIn),
2444
+ to: atOfDisplay(to, anchor.daysIn),
2445
+ uncertain: false,
2446
+ conditions: exp.conditions,
2447
+ ...seg.source.kind !== "base" ? { sourceIndex: seg.source.index } : {}
2448
+ }
2449
+ });
2450
+ }
2451
+ for (const [idx, change] of report.changes.entries()) {
2452
+ if (change.kind === "FM") continue;
2453
+ const abs = absOfChange(change, v.startDay, anchor.daysIn);
2454
+ if (abs === void 0 || abs.end <= abs.from) continue;
2455
+ const mid = abs.from + Math.floor((abs.end - abs.from) / 2);
2456
+ const exp = expandTaf(report, atOfAbs(mid), anchor);
2457
+ if (change.kind === "BECMG") {
2458
+ rows.push({
2459
+ abs: abs.from,
2460
+ row: {
2461
+ kind: "BECMG",
2462
+ from: atOfDisplay(abs.from, anchor.daysIn),
2463
+ to: atOfDisplay(abs.end, anchor.daysIn),
2464
+ uncertain: true,
2465
+ conditions: exp.conditions,
2466
+ sourceIndex: idx
2467
+ }
2468
+ });
2469
+ continue;
2470
+ }
2471
+ const e = change.elements;
2472
+ if (e === void 0) continue;
2473
+ rows.push({
2474
+ abs: abs.from,
2475
+ row: {
2476
+ kind: change.kind,
2477
+ from: atOfDisplay(abs.from, anchor.daysIn),
2478
+ to: atOfDisplay(abs.end, anchor.daysIn),
2479
+ uncertain: false,
2480
+ conditions: exp.conditions,
2481
+ overlay: {
2482
+ conditions: {
2483
+ ...e.wind !== void 0 ? { wind: e.wind } : {},
2484
+ ...e.visibility !== void 0 ? { visibility: e.visibility } : {},
2485
+ weather: e.nsw !== void 0 && e.weather.length === 0 ? [] : e.weather,
2486
+ ...e.clouds !== void 0 ? { clouds: e.clouds } : {},
2487
+ cavok: e.cavok !== void 0
2488
+ },
2489
+ ...change.probability !== void 0 ? { probability: change.probability } : {},
2490
+ withTempo: change.withTempo === true
2491
+ },
2492
+ sourceIndex: idx
2493
+ }
2494
+ });
2495
+ }
2496
+ const sorted = [];
2497
+ for (const item of rows) {
2498
+ let pos = sorted.length;
2499
+ while (pos > 0) {
2500
+ const prev = sorted[pos - 1];
2501
+ if (prev === void 0 || prev.abs <= item.abs) break;
2502
+ pos -= 1;
2503
+ }
2504
+ sorted.splice(pos, 0, item);
2505
+ }
2506
+ return sorted.map((x) => x.row);
2507
+ }
2508
+ /** 基况段 → TrendElements 形态(展开链的统一要素视图) */
2509
+ function baseElements(report) {
2510
+ if (!(report.wind?.kind === "value" || report.visibility?.kind === "value" || report.weather?.kind === "value" || report.clouds !== void 0 || report.cavok)) return void 0;
2511
+ return {
2512
+ ...report.wind?.kind === "value" ? { wind: report.wind.value } : {},
2513
+ ...report.visibility?.kind === "value" ? { visibility: report.visibility.value } : {},
2514
+ weather: report.weather?.kind === "value" ? report.weather.value : [],
2515
+ ...report.clouds !== void 0 ? { clouds: report.clouds } : {},
2516
+ ...report.cavok ? { cavok: report.cavokSpan !== void 0 ? { span: report.cavokSpan } : {} } : {}
2517
+ };
2518
+ }
2519
+ //#endregion
2520
+ //#region src/index.ts
2521
+ /**
2522
+ * Parse one METAR/SPECI report (tolerant mode) into the IR — the package's single entry.
2523
+ * 解析单条 METAR/SPECI 报文(tolerant)为 IR——本包唯一入口。
2524
+ *
2525
+ * Throws MetarParseError (stable machine-readable `code`) on whole-report failure
2526
+ * (non-string input / missing station / missing time / invalid time / unsupported mode); anything the
2527
+ * parser does not recognize lands in `warnings[]` with its span — never dropped.
2528
+ * 整体失败(输入非字符串/无站名/无时组/时组越界/未实现模式)抛 MetarParseError(code 稳定契约);
2529
+ * 看不懂的组带 span 进 warnings[],绝不丢弃。
2530
+ * @param raw - Report text, verbatim (kept on report.raw). 报文原文(原样保真于 report.raw)。
2531
+ * @param options - See ParseOptions (kind override, compact spans). 见 ParseOptions(类型位注入、紧凑模式)。
2532
+ */
2533
+ function parse(raw, options) {
2534
+ const compact = options?.spans === false;
2535
+ const compactIfEnabled = (report) => compact ? compactNode(report) : report;
2536
+ if (typeof raw !== "string") throw new _metweave_core.MetarParseError("invalid-input", String(raw), `parse 需要一个 METAR/SPECI 报文字符串,收到 ${raw === null ? "null" : typeof raw}`);
2537
+ if ((options?.mode ?? "tolerant") === "strict") throw new _metweave_core.MetarParseError("unsupported-mode", raw, "METAR 侧 strict 模式尚未实现(TAF 侧 parseTaf 已支持 strict)——请省略 mode 或显式传 'tolerant'");
2538
+ const warnings = [];
2539
+ const tail = raw.at(-1);
2540
+ const tokens = tokenize(tail === void 0 || tail === "=" || /\s/.test(tail) ? raw.replace(/[\s=]+$/, "") : raw);
2541
+ const TOKEN_COUNT_LIMIT = 128;
2542
+ if (tokens.length > TOKEN_COUNT_LIMIT) warnings.push({
2543
+ code: "invalid-format",
2544
+ severity: "warning",
2545
+ message: `输入 token 数超上限(${tokens.length} > ${TOKEN_COUNT_LIMIT})——按异常输入标记,解析照常完整,原文经 raw 保真`
2546
+ });
2547
+ let i = 0;
2548
+ const peek = (ahead = 0) => tokens[i + ahead];
2549
+ const externalKind = options?.kind;
2550
+ let kind = externalKind ?? "metar";
2551
+ let corrected = false;
2552
+ let auto = false;
2553
+ const head = peek();
2554
+ if (head !== void 0 && (head.text === "METAR" || head.text === "SPECI")) {
2555
+ if (externalKind === void 0) kind = head.text === "SPECI" ? "speci" : "metar";
2556
+ i += 1;
2557
+ }
2558
+ if (peek()?.text === "COR") {
2559
+ corrected = true;
2560
+ i += 1;
2561
+ }
2562
+ if (peek()?.text === "AMD") i += 1;
2563
+ const stTok = peek();
2564
+ if (stTok === void 0 || !/^[A-Z0-9]{4}$/.test(stTok.text)) throw new _metweave_core.MetarParseError("missing-station", raw, `无法识别站名组——输入不是 METAR/SPECI 报文(${stTok?.text ?? "空输入"})`);
2565
+ const station = stTok.text;
2566
+ i += 1;
2567
+ const driftTok = peek();
2568
+ if (driftTok !== void 0 && /^(CC[A-Z]|COR)$/.test(driftTok.text)) {
2569
+ const afterDrift = peek(1);
2570
+ if (afterDrift !== void 0 && /^\d{2}\d{2}\d{2}Z$/.test(afterDrift.text)) {
2571
+ corrected = true;
2572
+ i += 1;
2573
+ warnings.push({
2574
+ code: "invalid-format",
2575
+ severity: "info",
2576
+ message: `更正标记槽位漂移(${driftTok.text} 出现在站名后/时组前——已消费并置更正标志)`,
2577
+ span: spanOf(driftTok)
2578
+ });
2579
+ }
2580
+ }
2581
+ const tmTok = peek();
2582
+ const tm = tmTok !== void 0 ? /^(\d{2})(\d{2})(\d{2})Z$/.exec(tmTok.text) : null;
2583
+ if (tmTok === void 0 || tm === null) throw new _metweave_core.MetarParseError("missing-time", raw, `无法识别时组——输入不是完整的 METAR/SPECI 报文(${tmTok?.text ?? "时组缺失"})`);
2584
+ const time = {
2585
+ day: Number.parseInt(tm[1] ?? "0", 10),
2586
+ hour: Number.parseInt(tm[2] ?? "0", 10),
2587
+ minute: Number.parseInt(tm[3] ?? "0", 10)
2588
+ };
2589
+ if (time.day < 1 || time.day > 31 || time.hour > 23 || time.minute > 59) throw new _metweave_core.MetarParseError("invalid-time", raw, `时组数值越界(${tmTok.text}:须日 01–31 / 时 00–23 / 分 00–59)——输入不是完整的 METAR/SPECI 报文`);
2590
+ i += 1;
2591
+ if (peek()?.text === "AUTO") {
2592
+ auto = true;
2593
+ i += 1;
2594
+ }
2595
+ if (peek()?.text === "COR") {
2596
+ corrected = true;
2597
+ i += 1;
2598
+ }
2599
+ if (/^RR[ABC]$/.test(peek()?.text ?? "")) i += 1;
2600
+ if (/^CC[A-Z]$/.test(peek()?.text ?? "")) {
2601
+ corrected = true;
2602
+ i += 1;
2603
+ }
2604
+ if (peek()?.text === "NIL") return compactIfEnabled({
2605
+ kind,
2606
+ raw,
2607
+ nil: true,
2608
+ station,
2609
+ time,
2610
+ flags: {
2611
+ auto,
2612
+ corrected
2613
+ },
2614
+ cavok: false,
2615
+ trends: [],
2616
+ runwayStates: [],
2617
+ remarks: [],
2618
+ warnings
2619
+ });
2620
+ let cavok = false;
2621
+ let cavokSpan;
2622
+ let wind;
2623
+ let visibility;
2624
+ let directionalAsPrimary = false;
2625
+ let rvr;
2626
+ const rvrList = [];
2627
+ let rvrSpan;
2628
+ let weather;
2629
+ const weatherList = [];
2630
+ const recentList = [];
2631
+ let clouds;
2632
+ let cloudSeen = false;
2633
+ const cloudElements = [];
2634
+ let clearCode;
2635
+ let temperature;
2636
+ let dewpoint;
2637
+ let altimeter;
2638
+ let altimeterSeen = false;
2639
+ const trends = [];
2640
+ const runwayStates = [];
2641
+ const remarks = [];
2642
+ let windShear;
2643
+ let wsRunways;
2644
+ let wsAll = false;
2645
+ let wsSpan;
2646
+ while (i < tokens.length) {
2647
+ const t = tokens[i];
997
2648
  if (t === void 0) break;
998
2649
  const text = t.text;
999
2650
  if (text === "RMK") {
@@ -1015,16 +2666,9 @@ function parse(raw, options) {
1015
2666
  collected.push(periodTok);
1016
2667
  i += 1;
1017
2668
  }
1018
- collectTrendTokens(collected);
1019
- trendCloseWarning();
1020
- const elements = kindText === "NOSIG" ? void 0 : structureTrendElements(collected, period !== void 0 ? 2 : 1);
1021
- trends.push({
1022
- kind: kindText === "NOSIG" ? "nosig" : kindText === "BECMG" ? "becmg" : "tempo",
1023
- period,
1024
- ...elements !== void 0 ? { elements } : {},
1025
- raw: collected.map((c) => c.text).join(" "),
1026
- span: joinSpan(collected)
1027
- });
2669
+ i = collectTrendSegment(tokens, i, collected);
2670
+ warnTrendClose(tokens, i, warnings);
2671
+ trends.push(buildTrendGroup(kindText === "NOSIG" ? "nosig" : kindText === "BECMG" ? "becmg" : "tempo", period, collected, period !== void 0 ? 2 : 1));
1028
2672
  continue;
1029
2673
  }
1030
2674
  if (text.length === 6 && (text.charCodeAt(0) === 65 || text.charCodeAt(0) === 84 || text.charCodeAt(0) === 70) || text.startsWith("BECMG") || text.startsWith("TEMPO")) {
@@ -1033,22 +2677,15 @@ function parse(raw, options) {
1033
2677
  const indicator = fused[1] ?? "";
1034
2678
  const collected = [t];
1035
2679
  i += 1;
1036
- collectTrendTokens(collected);
1037
- trendCloseWarning();
1038
- const fusedElements = structureTrendElements(collected, 1);
1039
- trends.push({
1040
- kind: indicator === "BECMG" ? "becmg" : "tempo",
1041
- period: {
1042
- text: text.slice(indicator.length),
1043
- span: {
1044
- start: t.start + indicator.length,
1045
- end: t.end
1046
- }
1047
- },
1048
- ...fusedElements !== void 0 ? { elements: fusedElements } : {},
1049
- raw: collected.map((c) => c.text).join(" "),
1050
- span: joinSpan(collected)
1051
- });
2680
+ i = collectTrendSegment(tokens, i, collected);
2681
+ warnTrendClose(tokens, i, warnings);
2682
+ trends.push(buildTrendGroup(indicator === "BECMG" ? "becmg" : "tempo", {
2683
+ text: text.slice(indicator.length),
2684
+ span: {
2685
+ start: t.start + indicator.length,
2686
+ end: t.end
2687
+ }
2688
+ }, collected, 1));
1052
2689
  warnings.push({
1053
2690
  code: "invalid-format",
1054
2691
  severity: "info",
@@ -1060,19 +2697,12 @@ function parse(raw, options) {
1060
2697
  if (/^(AT|TL|FM)\d{4}$/.test(text)) {
1061
2698
  const collected = [t];
1062
2699
  i += 1;
1063
- collectTrendTokens(collected);
1064
- trendCloseWarning();
1065
- const bareElements = structureTrendElements(collected, 1);
1066
- trends.push({
1067
- kind: "unspecified",
1068
- period: {
1069
- text,
1070
- span: spanOf(t)
1071
- },
1072
- ...bareElements !== void 0 ? { elements: bareElements } : {},
1073
- raw: collected.map((c) => c.text).join(" "),
1074
- span: joinSpan(collected)
1075
- });
2700
+ i = collectTrendSegment(tokens, i, collected);
2701
+ warnTrendClose(tokens, i, warnings);
2702
+ trends.push(buildTrendGroup("unspecified", {
2703
+ text,
2704
+ span: spanOf(t)
2705
+ }, collected, 1));
1076
2706
  warnings.push({
1077
2707
  code: "invalid-format",
1078
2708
  severity: "warning",
@@ -1085,19 +2715,12 @@ function parse(raw, options) {
1085
2715
  if (text.length === 9 && text.charCodeAt(4) === 47 && /^\d{4}\/\d{4}$/.test(text)) {
1086
2716
  const collected = [t];
1087
2717
  i += 1;
1088
- collectTrendTokens(collected);
1089
- trendCloseWarning();
1090
- const bareElements = structureTrendElements(collected, 1);
1091
- trends.push({
1092
- kind: "unspecified",
1093
- period: {
1094
- text,
1095
- span: spanOf(t)
1096
- },
1097
- ...bareElements !== void 0 ? { elements: bareElements } : {},
1098
- raw: collected.map((c) => c.text).join(" "),
1099
- span: joinSpan(collected)
1100
- });
2718
+ i = collectTrendSegment(tokens, i, collected);
2719
+ warnTrendClose(tokens, i, warnings);
2720
+ trends.push(buildTrendGroup("unspecified", {
2721
+ text,
2722
+ span: spanOf(t)
2723
+ }, collected, 1));
1101
2724
  warnings.push({
1102
2725
  code: "invalid-format",
1103
2726
  severity: "warning",
@@ -1109,7 +2732,13 @@ function parse(raw, options) {
1109
2732
  if (text === "CAVOK") {
1110
2733
  cavok = true;
1111
2734
  cavokSpan = spanOf(t);
1112
- cavokConflicts("前序");
2735
+ cavokCrossCheck({
2736
+ cavokSpan,
2737
+ visibility,
2738
+ weatherList,
2739
+ rvr,
2740
+ cloudElements
2741
+ }, "前序", raw, warnings);
1113
2742
  visibility = void 0;
1114
2743
  directionalAsPrimary = false;
1115
2744
  weather = void 0;
@@ -1121,9 +2750,9 @@ function parse(raw, options) {
1121
2750
  continue;
1122
2751
  }
1123
2752
  const mask = maskOf(text.charCodeAt(0));
1124
- const windParsed = (mask & M_WIND) !== 0 ? parseWindToken(t, peek(1)) : null;
2753
+ const windParsed = (mask & 1) !== 0 ? parseWindToken(t, peek(1)) : null;
1125
2754
  if (windParsed !== null) {
1126
- if (wind !== void 0) dupGroupWarning("风组", wind.span, spanOf(t));
2755
+ if (wind !== void 0) warnDuplicateGroup(raw, warnings, "风组", wind.span, spanOf(t));
1127
2756
  if (windParsed === "missing") {
1128
2757
  if (wind === void 0) {
1129
2758
  wind = {
@@ -1139,180 +2768,33 @@ function parse(raw, options) {
1139
2768
  }
1140
2769
  i += 1;
1141
2770
  } else {
1142
- const wg = windParsed.group;
1143
- if (!Number.isFinite(wg.speed.value) || wg.gust !== void 0 && !Number.isFinite(wg.gust.value)) {
1144
- wind = {
1145
- kind: "missing",
1146
- span: spanOf(t)
1147
- };
1148
- warnings.push({
1149
- code: "value-out-of-range",
1150
- severity: "warning",
1151
- message: `风组解码出非有限值(${t.text})——值不可信判缺测,原码经 span 回溯`,
1152
- span: spanOf(t)
1153
- });
1154
- } else if (wg.speed.value > 199 || (wg.gust?.value ?? 0) > 199) {
1155
- wind = {
1156
- kind: "missing",
1157
- span: spanOf(t)
1158
- };
1159
- warnings.push({
1160
- code: "value-out-of-range",
1161
- severity: "warning",
1162
- message: `风速超出编码范围(${t.text},三位数模板上限 199 ${wg.speed.unit})——值不可信判缺测,原码经 span 回溯`,
1163
- span: spanOf(t)
1164
- });
1165
- } else {
1166
- wind = {
1167
- kind: "value",
1168
- value: wg,
1169
- span: spanOf(t)
1170
- };
1171
- for (const finding of windParsed.rangeFindings) warnings.push({
1172
- code: "value-out-of-range",
1173
- severity: "warning",
1174
- message: finding.message,
1175
- span: finding.span
1176
- });
1177
- if (windParsed.gustMissing === true) warnings.push({
1178
- code: "missing-expected",
1179
- severity: "info",
1180
- message: `阵风位缺测(${t.text}——G 后斜杠位缺测,组照常成立)`,
1181
- span: spanOf(t)
1182
- });
1183
- if (wg.variable && wg.variation !== void 0) {
1184
- const vs = wg.variation.span;
1185
- warnings.push({
1186
- code: "cross-check-conflict",
1187
- severity: "info",
1188
- message: `静风变向(VRB)与风向变化组(${vs === void 0 ? "" : raw.slice(vs.start, vs.end)})并存——VRB 本义方向不定,变化组冗余,报文自洽性存疑`,
1189
- span: vs
1190
- });
1191
- }
1192
- }
2771
+ const applied = validateWindGroup(t, windParsed, raw);
2772
+ wind = applied.wind;
2773
+ warnings.push(...applied.warnings);
1193
2774
  i += windParsed.consumed;
1194
2775
  }
1195
2776
  continue;
1196
2777
  }
1197
- const visParsed = (mask & M_VIS) !== 0 ? parseVisibilityToken(t, peek(1)) : null;
2778
+ const visParsed = (mask & 2) !== 0 ? parseVisibilityToken(t, peek(1)) : null;
1198
2779
  if (visParsed !== null) {
1199
- if (visParsed.kind === "directional") {
1200
- if (visibility?.kind === "value") {
1201
- if (visibility.value.minimum !== void 0 || directionalAsPrimary) dupGroupWarning("最低能见度方向组", visibility.value.minimum?.span, spanOf(t));
1202
- visibility = {
1203
- kind: "value",
1204
- value: {
1205
- ...visibility.value,
1206
- minimum: {
1207
- value: visParsed.group.value,
1208
- direction: visParsed.group.direction,
1209
- span: visParsed.group.span
1210
- }
1211
- },
1212
- span: visibility.span
1213
- };
1214
- } else {
1215
- visibility = {
1216
- kind: "value",
1217
- value: {
1218
- value: visParsed.group.value,
1219
- unit: "m",
1220
- exact: true,
1221
- span: visParsed.group.span
1222
- }
1223
- };
1224
- directionalAsPrimary = true;
1225
- warnings.push({
1226
- code: "invalid-format",
1227
- severity: "info",
1228
- message: `最低能见度方向组脱离主导能见度(${t.text})——按能见度收下`,
1229
- span: spanOf(t)
1230
- });
1231
- }
1232
- i += visParsed.consumed;
1233
- continue;
1234
- }
1235
- if (visibility !== void 0) dupGroupWarning("能见度组", visibility.span, spanOf(t));
1236
- if (visParsed.kind === "missing") {
1237
- if (visibility === void 0) {
1238
- visibility = {
1239
- kind: "missing",
1240
- span: spanOf(t)
1241
- };
1242
- warnings.push({
1243
- code: "missing-expected",
1244
- severity: "info",
1245
- message: `能见度组缺测(${t.text},无法观测能见度)`,
1246
- span: spanOf(t)
1247
- });
1248
- }
1249
- directionalAsPrimary = false;
1250
- i += visParsed.consumed;
1251
- continue;
1252
- } else if (visParsed.kind === "invalid") {
1253
- visibility = {
1254
- kind: "missing",
1255
- span: visParsed.span
1256
- };
1257
- directionalAsPrimary = false;
1258
- warnings.push({
1259
- code: "value-out-of-range",
1260
- severity: "warning",
1261
- message: visParsed.message,
1262
- span: visParsed.span
1263
- });
1264
- } else {
1265
- visibility = {
1266
- kind: "value",
1267
- value: visParsed.group,
1268
- span: visParsed.group.span
1269
- };
1270
- directionalAsPrimary = false;
1271
- }
2780
+ const applied = applyVisibilityToken(t, visParsed, {
2781
+ visibility,
2782
+ directionalAsPrimary
2783
+ }, raw, warnings);
2784
+ visibility = applied.visibility;
2785
+ directionalAsPrimary = applied.directionalAsPrimary;
1272
2786
  i += visParsed.consumed;
1273
2787
  continue;
1274
2788
  }
1275
- if ((mask & M_R) !== 0 && text === "RVRNO") {
1276
- if (rvr?.kind === "value") {
1277
- const prevText = rvr.span === void 0 ? "" : raw.slice(rvr.span.start, rvr.span.end);
1278
- warnings.push({
1279
- code: "cross-check-conflict",
1280
- severity: "info",
1281
- message: `RVRNO(站级:应报而缺)与 RVR 值组并存(${prevText})——矛盾形态,值组按跑道级明细保留,RVRNO 经 remarks/raw 可回溯`,
1282
- span: spanOf(t)
1283
- });
1284
- } else {
1285
- rvr = {
1286
- kind: "missing",
1287
- span: spanOf(t)
1288
- };
1289
- warnings.push({
1290
- code: "missing-expected",
1291
- severity: "info",
1292
- message: "RVR 设备在但明示不可用(RVRNO)",
1293
- span: spanOf(t)
1294
- });
1295
- }
1296
- remarks.push({
1297
- kind: "rvr-no",
1298
- raw: text,
1299
- span: spanOf(t)
1300
- });
2789
+ if ((mask & 4) !== 0 && text === "RVRNO") {
2790
+ rvr = applyRvrNoBodyToken(t, rvr, raw, warnings, remarks);
1301
2791
  i += 1;
1302
2792
  continue;
1303
2793
  }
1304
- if ((mask & M_R) !== 0 && text.startsWith("R") && text.includes("/")) {
2794
+ if ((mask & 4) !== 0 && text.startsWith("R") && text.includes("/")) {
1305
2795
  const rvrParsed = parseRvrToken(t);
1306
2796
  if (rvrParsed !== null) {
1307
- if (rvr?.kind === "missing") {
1308
- const prevText = rvr.span === void 0 ? "" : raw.slice(rvr.span.start, rvr.span.end);
1309
- warnings.push({
1310
- code: "cross-check-conflict",
1311
- severity: "info",
1312
- message: `RVRNO(站级:应报而缺)与后随 RVR 值组并存(${prevText})——矛盾形态,值组按跑道级明细保留(可解读为传感器恢复),RVRNO 经 remarks/raw 可回溯`,
1313
- span: spanOf(t)
1314
- });
1315
- }
2797
+ if (rvr?.kind === "missing") warnRvrNoThenValues(t, rvr.span, raw, warnings);
1316
2798
  rvrList.push(rvrParsed);
1317
2799
  const s = spanOf(t);
1318
2800
  rvrSpan = rvrSpan === void 0 ? s : {
@@ -1339,58 +2821,33 @@ function parse(raw, options) {
1339
2821
  i += 1;
1340
2822
  continue;
1341
2823
  }
1342
- const rvrSlash = /^R\d{2}[RLC]?(\/{4,5})$/.exec(text);
1343
- if (rvrSlash !== null) {
1344
- rvr = {
1345
- kind: "missing",
1346
- span: spanOf(t)
1347
- };
1348
- warnings.push({
1349
- code: "missing-expected",
1350
- severity: "info",
1351
- message: `RVR 组缺测(${text}——跑道号在位、视程值位全斜杠)`,
1352
- span: spanOf(t)
1353
- });
1354
- if ((rvrSlash[1] ?? "").length === 4) warnings.push({
1355
- code: "invalid-format",
1356
- severity: "info",
1357
- message: `RVR 缺测段磨损(${text}——4 位斜杠对标准 5 位(分离符 + 四位值位),少一位;按缺测收下)`,
1358
- span: spanOf(t)
1359
- });
2824
+ const rvrMissing = applyRvrSlashToken(t, warnings);
2825
+ if (rvrMissing !== null) {
2826
+ rvr = rvrMissing;
1360
2827
  i += 1;
1361
2828
  continue;
1362
2829
  }
1363
2830
  }
1364
- if ((mask & M_WS_RWY) !== 0 && text === "WS") {
1365
- const rwyTok = peek(1);
1366
- const designator = rwyTok !== void 0 ? /^RWY(\d{2}[RLC]?)$/.exec(rwyTok.text) : null;
1367
- const stdDesignator = rwyTok !== void 0 ? /^R(\d{2}[RLC]?)$/.exec(rwyTok.text) : null;
1368
- const isAll = rwyTok?.text === "ALL" && peek(2)?.text === "RWY" || rwyTok?.text === "RWY" && peek(2)?.text === "ALL";
1369
- if (designator !== null || isAll || stdDesignator !== null) {
1370
- const endTok = isAll ? peek(2) : rwyTok;
1371
- const startSpan = spanOf(t);
1372
- const endSpan = endTok !== void 0 ? spanOf(endTok) : startSpan;
2831
+ if ((mask & 4096) !== 0 && text === "WS") {
2832
+ const wsMatch = parseWindShearSequence(t, peek(1), peek(2));
2833
+ if (wsMatch !== null) {
1373
2834
  wsRunways ??= [];
1374
- if (designator !== null) wsRunways.push(designator[1] ?? "");
1375
- if (stdDesignator !== null) wsRunways.push(stdDesignator[1] ?? "");
1376
- if (isAll) wsAll = true;
1377
- wsSpan = wsSpan === void 0 ? {
1378
- start: startSpan.start,
1379
- end: endSpan.end
1380
- } : {
2835
+ if (wsMatch.runway !== null) wsRunways.push(wsMatch.runway);
2836
+ if (wsMatch.all) wsAll = true;
2837
+ wsSpan = wsSpan === void 0 ? wsMatch.span : {
1381
2838
  start: wsSpan.start,
1382
- end: endSpan.end
2839
+ end: wsMatch.span.end
1383
2840
  };
1384
2841
  windShear = {
1385
2842
  runways: wsRunways,
1386
2843
  allRunways: wsAll,
1387
2844
  span: wsSpan
1388
2845
  };
1389
- i += isAll ? 3 : 2;
2846
+ i += wsMatch.consumed;
1390
2847
  continue;
1391
2848
  }
1392
2849
  }
1393
- if (((mask & M_WEATHER) !== 0 ? /^(TX|TN)M?\d{2}\/\d{4}Z$/.exec(text) : null) !== null) {
2850
+ if ((mask & 8) !== 0 && TX_TN_PATTERN.test(text)) {
1394
2851
  remarks.push({
1395
2852
  kind: "temperature-forecast",
1396
2853
  raw: text,
@@ -1399,7 +2856,7 @@ function parse(raw, options) {
1399
2856
  i += 1;
1400
2857
  continue;
1401
2858
  }
1402
- const weatherParsed = (mask & M_WEATHER) !== 0 ? tryWeatherToken(t, weatherList, recentList) : false;
2859
+ const weatherParsed = (mask & 8) !== 0 ? tryWeatherToken(t, weatherList, recentList) : false;
1403
2860
  if (weatherParsed !== false) {
1404
2861
  if (weatherParsed.outOfOrder) warnings.push({
1405
2862
  code: "invalid-format",
@@ -1422,7 +2879,7 @@ function parse(raw, options) {
1422
2879
  i += 1;
1423
2880
  continue;
1424
2881
  }
1425
- if ((mask & M_WEATHER) !== 0 && text === "//") {
2882
+ if ((mask & 8) !== 0 && text === "//") {
1426
2883
  weather = {
1427
2884
  kind: "missing",
1428
2885
  span: spanOf(t)
@@ -1430,73 +2887,27 @@ function parse(raw, options) {
1430
2887
  warnings.push({
1431
2888
  code: "missing-expected",
1432
2889
  severity: "info",
1433
- message: "天气组缺测(//,无法观测天气)",
1434
- span: spanOf(t)
1435
- });
1436
- i += 1;
1437
- continue;
1438
- }
1439
- const cloudM = (mask & M_CLOUD) !== 0 ? CLOUD_LAYER_PATTERN.exec(text) : null;
1440
- if (cloudM !== null) {
1441
- cloudSeen = true;
1442
- const amountRaw = cloudM[1];
1443
- const heightRaw = cloudM[2];
1444
- const convectiveRaw = cloudM[3];
1445
- const typeMissing = cloudM[4] !== void 0;
1446
- const amountMissing = amountRaw === "///";
1447
- if (heightRaw === "///") warnings.push({
1448
- code: "missing-expected",
1449
- severity: "info",
1450
- message: "云高缺测(///),不捏造基高",
1451
- span: spanOf(t)
1452
- });
1453
- if (amountMissing) warnings.push({
1454
- code: "missing-expected",
1455
- severity: "info",
1456
- message: "云量位缺测(///),探测到云但云量无法观测",
1457
- span: spanOf(t)
1458
- });
1459
- if (typeMissing) warnings.push({
1460
- code: "missing-expected",
1461
- severity: "info",
1462
- message: "云型位缺测(///)",
1463
- span: spanOf(t)
1464
- });
1465
- cloudElements.push({
1466
- kind: "layer",
1467
- amount: amountRaw !== void 0 && isCloudAmount(amountRaw) ? amountRaw : null,
1468
- heightFt: {
1469
- value: heightRaw !== void 0 && heightRaw !== "///" ? Number.parseInt(heightRaw, 10) * 100 : null,
1470
- span: spanOf(t)
1471
- },
1472
- convective: convectiveRaw !== void 0 && isConvective(convectiveRaw) ? convectiveRaw : void 0,
2890
+ message: "天气组缺测(//,无法观测天气)",
1473
2891
  span: spanOf(t)
1474
2892
  });
1475
2893
  i += 1;
1476
2894
  continue;
1477
2895
  }
1478
- const vvM = (mask & M_VV) !== 0 ? VV_PATTERN.exec(text) : null;
1479
- if (vvM !== null) {
2896
+ const cloudElem = (mask & 16) !== 0 ? cloudLayerElementOf(t, warnings) : null;
2897
+ if (cloudElem !== null) {
1480
2898
  cloudSeen = true;
1481
- const heightRaw = vvM[1];
1482
- if (heightRaw === void 0 || heightRaw === "///") warnings.push({
1483
- code: "missing-expected",
1484
- severity: "info",
1485
- message: heightRaw === "///" ? "垂直能见度缺测(VV///,天空全遮蔽但垂直能见度不可测)" : "垂直能见度缺测(VV,兼容形态)",
1486
- span: spanOf(t)
1487
- });
1488
- cloudElements.push({
1489
- kind: "vertical-visibility",
1490
- heightFt: {
1491
- value: heightRaw === void 0 || heightRaw === "///" ? null : Number.parseInt(heightRaw, 10) * 100,
1492
- span: spanOf(t)
1493
- },
1494
- span: spanOf(t)
1495
- });
2899
+ cloudElements.push(cloudElem);
2900
+ i += 1;
2901
+ continue;
2902
+ }
2903
+ const vvElem = (mask & 32) !== 0 ? verticalVisibilityElementOf(t, warnings) : null;
2904
+ if (vvElem !== null) {
2905
+ cloudSeen = true;
2906
+ cloudElements.push(vvElem);
1496
2907
  i += 1;
1497
2908
  continue;
1498
2909
  }
1499
- if ((mask & M_SKY_CLEAR) !== 0 && isSkyClear(text)) {
2910
+ if ((mask & 64) !== 0 && isSkyClear(text)) {
1500
2911
  cloudSeen = true;
1501
2912
  clearCode = {
1502
2913
  code: text,
@@ -1505,86 +2916,46 @@ function parse(raw, options) {
1505
2916
  i += 1;
1506
2917
  continue;
1507
2918
  }
1508
- const tempParsed = (mask & M_TEMP) !== 0 ? parseTempDewToken(t) : null;
2919
+ const tempParsed = (mask & 128) !== 0 ? parseTempDewToken(t) : null;
1509
2920
  if (tempParsed !== null) {
1510
- const dupTemp = temperature !== void 0 || dewpoint !== void 0;
1511
- if (dupTemp) dupGroupWarning("温度组", temperature?.span ?? dewpoint?.span, tempParsed.span);
1512
- if (dupTemp && tempParsed.temperature === null && tempParsed.dewpoint === null) {
1513
- i += 1;
1514
- continue;
1515
- }
1516
- const rawTemp = tempParsed.temperature;
1517
- const rawDew = tempParsed.dewpoint;
1518
- const outOfRange = (r) => r !== null && (r.celsius < TEMP_C_MIN || r.celsius > TEMP_C_MAX);
1519
- const outT = outOfRange(rawTemp);
1520
- const outD = outOfRange(rawDew);
1521
- if (outT || outD) {
1522
- const span = outT && outD ? tempParsed.span : (outT ? rawTemp : rawDew)?.span;
1523
- const shown = span === void 0 ? `${TEMP_C_MIN}` : raw.slice(span.start, span.end);
1524
- warnings.push({
1525
- code: "value-out-of-range",
1526
- severity: "warning",
1527
- message: `温度超出可信范围(${shown},合理区间 ${TEMP_C_MIN}–${TEMP_C_MAX}°C)——值不可信判缺测,原码经 span 回溯`,
1528
- span
1529
- });
1530
- }
1531
- temperature = outT ? void 0 : rawTemp ?? void 0;
1532
- dewpoint = outD ? void 0 : rawDew ?? void 0;
1533
- if (rawTemp === null || rawDew === null) warnings.push({
1534
- code: "missing-expected",
1535
- severity: "info",
1536
- message: /^M?\d{2}\/$/.test(t.text) ? "露点位缺测(24/ 形态,FMH-1 12.6.10)" : "温度/露点位缺测(//)",
1537
- span: tempParsed.span
1538
- });
1539
- if (temperature !== void 0 && dewpoint !== void 0 && temperature.celsius < dewpoint.celsius) warnings.push({
1540
- code: "cross-check-conflict",
1541
- severity: "warning",
1542
- message: `温度低于露点(${t.text})——物理不可能,疑似传感器故障`,
1543
- span: tempParsed.span
1544
- });
2921
+ const applied = applyTempDewToken(t, tempParsed, {
2922
+ temperature,
2923
+ dewpoint
2924
+ }, raw, warnings);
2925
+ temperature = applied.temperature;
2926
+ dewpoint = applied.dewpoint;
1545
2927
  i += 1;
1546
2928
  continue;
1547
2929
  }
1548
- const qM = (mask & M_QNH) !== 0 ? /^Q(\d{4,5})$/.exec(text) : null;
1549
- if (qM !== null) {
1550
- const digits = qM[1] ?? "";
1551
- const hpa = Number.parseInt(digits, 10);
1552
- if (digits.length === 5 || hpa < QNH_HPA_MIN || hpa > QNH_HPA_MAX) {
2930
+ const qnhParsed = (mask & 256) !== 0 ? parseQnhToken(t) : null;
2931
+ if (qnhParsed !== null) {
2932
+ if (qnhParsed.kind === "out-of-range") {
1553
2933
  altimeter = void 0;
1554
2934
  warnings.push({
1555
2935
  code: "value-out-of-range",
1556
2936
  severity: "warning",
1557
- message: `QNH 超出可信范围(${text},合理区间 ${QNH_HPA_MIN}–${QNH_HPA_MAX} hPa)——值不可信判缺测,原码经 span 回溯`,
1558
- span: spanOf(t)
2937
+ message: qnhParsed.message,
2938
+ span: qnhParsed.span
1559
2939
  });
1560
- } else setAltimeter({
1561
- value: hpa,
1562
- unit: "hPa",
1563
- span: spanOf(t)
1564
- });
2940
+ } else ({altimeter, altimeterSeen} = applyAltimeterReading(altimeter, altimeterSeen, qnhParsed.reading, raw, warnings));
1565
2941
  i += 1;
1566
2942
  continue;
1567
2943
  }
1568
- const aM = (mask & M_ALTIMETER_A) !== 0 ? /^A(\d{4})$/.exec(text) : null;
1569
- if (aM !== null) {
1570
- const inhg = Number.parseInt(aM[1] ?? "0", 10) / 100;
1571
- if (inhg < ALT_INHG_MIN || inhg > ALT_INHG_MAX) {
2944
+ const altAParsed = (mask & 512) !== 0 ? parseAltimeterAToken(t) : null;
2945
+ if (altAParsed !== null) {
2946
+ if (altAParsed.kind === "out-of-range") {
1572
2947
  altimeter = void 0;
1573
2948
  warnings.push({
1574
2949
  code: "value-out-of-range",
1575
2950
  severity: "warning",
1576
- message: `高度表设定超出可信范围(${text} → ${inhg} inHg,合理区间 ${ALT_INHG_MIN}–${ALT_INHG_MAX})——值不可信判缺测,原码经 span 回溯`,
1577
- span: spanOf(t)
2951
+ message: altAParsed.message,
2952
+ span: altAParsed.span
1578
2953
  });
1579
- } else setAltimeter({
1580
- value: inhg,
1581
- unit: "inHg",
1582
- span: spanOf(t)
1583
- });
2954
+ } else ({altimeter, altimeterSeen} = applyAltimeterReading(altimeter, altimeterSeen, altAParsed.reading, raw, warnings));
1584
2955
  i += 1;
1585
2956
  continue;
1586
2957
  }
1587
- if ((mask & M_VIS_RANGE) !== 0 && text === "VIS" && VIS_V_RANGE.test(peek(1)?.text ?? "")) {
2958
+ if ((mask & 1024) !== 0 && text === "VIS" && VIS_V_RANGE.test(peek(1)?.text ?? "")) {
1588
2959
  const nextTok = peek(1);
1589
2960
  if (nextTok !== void 0) {
1590
2961
  remarks.push({
@@ -1599,7 +2970,7 @@ function parse(raw, options) {
1599
2970
  continue;
1600
2971
  }
1601
2972
  }
1602
- if ((mask & M_DOLLAR) !== 0 && text === "$") {
2973
+ if ((mask & 2048) !== 0 && text === "$") {
1603
2974
  remarks.push({
1604
2975
  kind: "maintenance",
1605
2976
  raw: "$",
@@ -1616,23 +2987,18 @@ function parse(raw, options) {
1616
2987
  });
1617
2988
  i += 1;
1618
2989
  }
1619
- if (weatherList.length > 0) {
1620
- const first = weatherList[0];
1621
- const last = weatherList[weatherList.length - 1];
1622
- if (first !== void 0 && last !== void 0) weather = {
1623
- kind: "value",
1624
- value: [...weatherList],
1625
- span: first.span !== void 0 && last.span !== void 0 ? {
1626
- start: first.span.start,
1627
- end: last.span.end
1628
- } : void 0
1629
- };
1630
- }
2990
+ weather = observedWeatherOf(weatherList) ?? weather;
1631
2991
  if (cloudSeen) clouds = {
1632
2992
  elements: cloudElements,
1633
2993
  clear: clearCode
1634
2994
  };
1635
- if (cavok) cavokConflicts("后续");
2995
+ if (cavok) cavokCrossCheck({
2996
+ cavokSpan,
2997
+ visibility,
2998
+ weatherList,
2999
+ rvr,
3000
+ cloudElements
3001
+ }, "后续", raw, warnings);
1636
3002
  const hasVvElement = cloudElements.some((e) => e.kind === "vertical-visibility");
1637
3003
  const hasLayerElement = cloudElements.some((e) => e.kind === "layer");
1638
3004
  if (hasVvElement && hasLayerElement) warnings.push({
@@ -1647,223 +3013,7 @@ function parse(raw, options) {
1647
3013
  message: `${clearCode.code}(无云电码)与云层组并存——互斥形态,报文自洽性存疑`,
1648
3014
  span: clearCode.span
1649
3015
  });
1650
- while (i < tokens.length) {
1651
- const t = tokens[i];
1652
- if (t === void 0) break;
1653
- const text = t.text;
1654
- const push = (remarkKind, consumed = 1) => {
1655
- const slice = tokens.slice(i, i + consumed);
1656
- remarks.push({
1657
- kind: remarkKind,
1658
- raw: slice.map((c) => c.text).join(" "),
1659
- span: joinSpan(slice)
1660
- });
1661
- i += consumed;
1662
- };
1663
- if (text === "AO1" || text === "AO2" || text === "A01" || text === "A02") {
1664
- push("auto-type");
1665
- continue;
1666
- }
1667
- if (/^SLP(\d{3}|NO)$/.test(text)) {
1668
- push("sea-level-pressure");
1669
- continue;
1670
- }
1671
- if (/^T[01]\d{7}(T[01]\d{7})?$/.test(text) || /^T[01]\d{3}$/.test(text)) {
1672
- push("precise-temperature");
1673
- continue;
1674
- }
1675
- if (/^P\d{4}$/.test(text)) {
1676
- push("precip-1h");
1677
- continue;
1678
- }
1679
- if (/^[67](?:\d{4}|\/{4})$/.test(text)) {
1680
- push("precip-window");
1681
- continue;
1682
- }
1683
- if (/^4\/\d{3}$/.test(text)) {
1684
- push("snow-depth");
1685
- continue;
1686
- }
1687
- if (/^I[136]\d{3}$/.test(text)) {
1688
- push("ice-accretion");
1689
- continue;
1690
- }
1691
- if (/^5[0-8]\d{3}$/.test(text)) {
1692
- push("pressure-tendency");
1693
- continue;
1694
- }
1695
- if (/^4(?:[01]\d{3}[01]\d{3}|\d{7})$/.test(text)) {
1696
- push("temp-extrema-24h");
1697
- continue;
1698
- }
1699
- if (/^[12]\d{4}$/.test(text)) {
1700
- push("temp-extrema-6h");
1701
- continue;
1702
- }
1703
- if (text === "PK" && tokens[i + 1]?.text === "WND") {
1704
- push("peak-wind", 3);
1705
- continue;
1706
- }
1707
- if (text === "WSHFT" && tokens[i + 1] !== void 0) {
1708
- push("wind-shift", 2);
1709
- continue;
1710
- }
1711
- if (text === "PRESRR" || text === "PRESFR") {
1712
- push("pressure-change");
1713
- continue;
1714
- }
1715
- if (text === "VISNO") {
1716
- push("vis-no");
1717
- continue;
1718
- }
1719
- if (text === "TSNO") {
1720
- push("thunderstorm-sensor");
1721
- continue;
1722
- }
1723
- if (text === "RVRNO") {
1724
- if (rvr?.kind === "value") {
1725
- const prevText = rvr.span === void 0 ? "" : raw.slice(rvr.span.start, rvr.span.end);
1726
- warnings.push({
1727
- code: "cross-check-conflict",
1728
- severity: "info",
1729
- message: `RVRNO(站级:应报而缺)与 RVR 值组并存(${prevText})——矛盾形态,值组按跑道级明细保留,RVRNO 经 remarks/raw 可回溯`,
1730
- span: spanOf(t)
1731
- });
1732
- } else rvr = {
1733
- kind: "missing",
1734
- span: spanOf(t)
1735
- };
1736
- push("rvr-no");
1737
- continue;
1738
- }
1739
- if (text === "SFC" && tokens[i + 1]?.text === "VIS") {
1740
- push("surface-visibility", 3 + (/^\d\/\d(SM)?$/.test(tokens[i + 3]?.text ?? "") ? 1 : 0));
1741
- continue;
1742
- }
1743
- if (text === "TWR" && tokens[i + 1]?.text === "VIS") {
1744
- push("twr-visibility", 3 + (/^\d\/\d(SM)?$/.test(tokens[i + 3]?.text ?? "") ? 1 : 0));
1745
- continue;
1746
- }
1747
- const visDirNext = tokens[i + 1]?.text;
1748
- const RMK_VIS_DIR = /^(N|NE|E|SE|S|SW|W|NW)$/;
1749
- const RMK_VIS_VAL = /^[MP]?\d+(\/\d+)?(SM)?$/;
1750
- if (text === "VIS" && visDirNext !== void 0 && RMK_VIS_DIR.test(visDirNext) && tokens[i + 2] !== void 0) {
1751
- let consumed = 3 + (/^\d\/\d(SM)?$/.test(tokens[i + 3]?.text ?? "") ? 1 : 0);
1752
- for (let j = i + consumed;;) {
1753
- const dir = tokens[j]?.text;
1754
- if (dir === void 0 || !RMK_VIS_DIR.test(dir)) break;
1755
- const val = tokens[j + 1]?.text;
1756
- if (val === void 0 || !RMK_VIS_VAL.test(val)) break;
1757
- const pair = /^\d+$/.test(val) && /^\d\/\d(SM)?$/.test(tokens[j + 2]?.text ?? "") ? 3 : 2;
1758
- consumed += pair;
1759
- j += pair;
1760
- }
1761
- push("sectoral-visibility", consumed);
1762
- continue;
1763
- }
1764
- if (text === "VIS" && visDirNext !== void 0 && RMK_VIS_VAL.test(visDirNext)) {
1765
- let consumed = 2;
1766
- if (/^\d+$/.test(visDirNext) && /^\d\/\d(SM)?$/.test(tokens[i + 2]?.text ?? "")) consumed += 1;
1767
- if (tokens[i + consumed]?.text !== void 0 && RMK_VIS_DIR.test(tokens[i + consumed]?.text ?? "")) consumed += 1;
1768
- push("sectoral-visibility", consumed);
1769
- continue;
1770
- }
1771
- if (/^CIGNO$/.test(text)) {
1772
- push("cig-not-available");
1773
- continue;
1774
- }
1775
- const cigNext = tokens[i + 1]?.text;
1776
- if (text === "CIG" && cigNext !== void 0) {
1777
- const nxt = cigNext;
1778
- if (/^\d{3}V\d{3}$/.test(nxt)) {
1779
- push("ceiling-variation", 2);
1780
- continue;
1781
- }
1782
- if (/^\d{3}$/.test(nxt)) {
1783
- if (tokens[i + 2]?.text === "LOC") {
1784
- push("ceiling-at-location", 3);
1785
- continue;
1786
- }
1787
- push("ceiling", 2);
1788
- continue;
1789
- }
1790
- }
1791
- if (text === "PNO") {
1792
- push("precip-not-available");
1793
- continue;
1794
- }
1795
- if (text === "FZRANO") {
1796
- push("fzr-not-available");
1797
- continue;
1798
- }
1799
- if (text === "CHINO") {
1800
- push("chino");
1801
- continue;
1802
- }
1803
- if (/^8\/[0-9X/]{3}$/.test(text)) {
1804
- push("cloud-type-8group");
1805
- continue;
1806
- }
1807
- if (/^933\d{3}$/.test(text)) {
1808
- push("snow-water-equivalent");
1809
- continue;
1810
- }
1811
- if (text === "FUNNEL" && tokens[i + 1]?.text === "CLOUD") {
1812
- push("phenomenon-began-ended", 2);
1813
- continue;
1814
- }
1815
- if (text === "LTG") {
1816
- let consumed = 1;
1817
- let prevInVocab = false;
1818
- let digitsAbsorbed = 0;
1819
- for (;;) {
1820
- const nextTok = tokens[i + consumed];
1821
- if (nextTok === void 0) break;
1822
- if (LTG_TAIL_VOCAB.has(nextTok.text)) {
1823
- consumed += 1;
1824
- prevInVocab = true;
1825
- continue;
1826
- }
1827
- if (prevInVocab && digitsAbsorbed === 0 && /^\d{1,2}$/.test(nextTok.text)) {
1828
- consumed += 1;
1829
- digitsAbsorbed += 1;
1830
- prevInVocab = false;
1831
- continue;
1832
- }
1833
- break;
1834
- }
1835
- push("lightning", Math.min(consumed, tokens.length - i));
1836
- continue;
1837
- }
1838
- if (text === "SNINCR" && /^\d+\/\d+$/.test(tokens[i + 1]?.text ?? "")) {
1839
- push("snow-increase", 2);
1840
- continue;
1841
- }
1842
- if (text === "VIS" && VIS_V_RANGE.test(tokens[i + 1]?.text ?? "")) {
1843
- push("variable-visibility", 2);
1844
- continue;
1845
- }
1846
- const beganMerged = /^([A-Z]{2,8})B\d{2,4}(?:E\d{2,4})?$/.exec(text);
1847
- const beganSplit = /^([A-Z]{2})[BE]\d{2,4}$/.exec(text);
1848
- const beganBodyOk = (m) => m !== null && splitWeatherToken(m[1] ?? "") !== null;
1849
- if (beganBodyOk(beganMerged) || beganBodyOk(beganSplit) || /^[BE]\d{2,4}$/.test(text)) {
1850
- push("phenomenon-began-ended");
1851
- continue;
1852
- }
1853
- if (/^QBB\d{3}$/.test(text)) {
1854
- push("cloud-base-height");
1855
- continue;
1856
- }
1857
- if (/^QFE\d{3,4}(\/\d{3,4})?$/.test(text)) {
1858
- push("aerodrome-pressure");
1859
- continue;
1860
- }
1861
- if (text === "$") {
1862
- push("maintenance");
1863
- continue;
1864
- }
1865
- push("unknown");
1866
- }
3016
+ rvr = parseRemarkSegment(tokens, i, raw, remarks, warnings, rvr).rvr;
1867
3017
  return compactIfEnabled({
1868
3018
  kind,
1869
3019
  raw,
@@ -1915,7 +3065,13 @@ function tryParse(raw, options) {
1915
3065
  }
1916
3066
  //#endregion
1917
3067
  exports.TEMP_DEW_PATTERN = TEMP_DEW_PATTERN;
3068
+ exports.expandTaf = expandTaf;
1918
3069
  exports.parse = parse;
3070
+ exports.parseTaf = parseTaf;
3071
+ exports.tafDurationHours = tafDurationHours;
3072
+ exports.tafSegments = tafSegments;
1919
3073
  exports.tryParse = tryParse;
3074
+ exports.tryParseTaf = tryParseTaf;
3075
+ exports.validateTaf = validateTaf;
1920
3076
 
1921
3077
  //# sourceMappingURL=index.cjs.map