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