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