fix: use settlement runway endpoint temperatures
This commit is contained in:
@@ -123,6 +123,7 @@ ankara, istanbul, helsinki, amsterdam, paris
|
|||||||
- 成都:02L/20R
|
- 成都:02L/20R
|
||||||
- 重庆:20R/02L
|
- 重庆:20R/02L
|
||||||
- 武汉:04/22
|
- 武汉:04/22
|
||||||
|
- 青岛:16/34
|
||||||
- 首尔:15R/33L
|
- 首尔:15R/33L
|
||||||
- **辅助跑道弱化**:同一机场下的其他非结算跑道,也会同时展示,但采用较细的虚线(线宽 1.2)以作陪衬区分。
|
- **辅助跑道弱化**:同一机场下的其他非结算跑道,也会同时展示,但采用较细的虚线(线宽 1.2)以作陪衬区分。
|
||||||
- **其他实测展示**:所有城市的 METAR 报文曲线、官方气象站实测(如 Hong Kong / Lau Fau Shan 的香港天文台曲线)均默认展示。
|
- **其他实测展示**:所有城市的 METAR 报文曲线、官方气象站实测(如 Hong Kong / Lau Fau Shan 的香港天文台曲线)均默认展示。
|
||||||
|
|||||||
+40
-30
@@ -438,37 +438,39 @@ export function runTests() {
|
|||||||
);
|
);
|
||||||
assert(hongKongHkoSeries?.label === "HKO", "Hong Kong HKO settlement observations should remain visible as the HKO curve");
|
assert(hongKongHkoSeries?.label === "HKO", "Hong Kong HKO settlement observations should remain visible as the HKO curve");
|
||||||
|
|
||||||
const chengduFromAmosSnapshot = __buildTemperatureChartDataForTest(
|
const chengduRow = {
|
||||||
{
|
city: "chengdu",
|
||||||
city: "chengdu",
|
local_date: "2026-05-26",
|
||||||
local_date: "2026-05-26",
|
local_time: "05:25",
|
||||||
local_time: "05:25",
|
tz_offset_seconds: 8 * 60 * 60,
|
||||||
tz_offset_seconds: 8 * 60 * 60,
|
airport: "ZUUU",
|
||||||
airport: "ZUUU",
|
} as any;
|
||||||
} as any,
|
const chengduSnapshotHourly = {
|
||||||
{
|
localTime: "05:25",
|
||||||
localTime: "05:25",
|
times: ["00:00", "06:00", "12:00", "18:00"],
|
||||||
times: ["00:00", "06:00", "12:00", "18:00"],
|
temps: [24, 28, 31, 27],
|
||||||
temps: [24, 28, 31, 27],
|
amos: {
|
||||||
amos: {
|
observation_time: "2026-05-25T21:25:00+00:00",
|
||||||
observation_time: "2026-05-25T21:25:00+00:00",
|
observation_time_local: "2026-05-26 05:25:00",
|
||||||
observation_time_local: "2026-05-26 05:25:00",
|
runway_obs: {
|
||||||
runway_obs: {
|
runway_pairs: [
|
||||||
runway_pairs: [
|
["02L", "20R"],
|
||||||
["02L", "20R"],
|
["02R", "20L"],
|
||||||
["02R", "20L"],
|
],
|
||||||
],
|
temperatures: [
|
||||||
temperatures: [
|
[24.4, null],
|
||||||
[24.4, null],
|
[24.2, null],
|
||||||
[24.2, null],
|
],
|
||||||
],
|
point_temperatures: [
|
||||||
point_temperatures: [
|
{ runway: "02L/20R", tdz_temp: 24.4, mid_temp: null, end_temp: 24.8 },
|
||||||
{ runway: "02L/20R", tdz_temp: 24.4, mid_temp: null, end_temp: 24.8 },
|
{ runway: "02R/20L", tdz_temp: 24.2, mid_temp: null, end_temp: 24.6 },
|
||||||
{ runway: "02R/20L", tdz_temp: 24.2, mid_temp: null, end_temp: 24.6 },
|
],
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
} as any,
|
},
|
||||||
|
} as any;
|
||||||
|
const chengduFromAmosSnapshot = __buildTemperatureChartDataForTest(
|
||||||
|
chengduRow,
|
||||||
|
chengduSnapshotHourly,
|
||||||
"1D",
|
"1D",
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -477,6 +479,14 @@ export function runTests() {
|
|||||||
assert(chengduSettlementRunway.color === "#009688", "AMOS snapshot settlement runway should use highlight cyan");
|
assert(chengduSettlementRunway.color === "#009688", "AMOS snapshot settlement runway should use highlight cyan");
|
||||||
assert(chengduSettlementRunway.featured === true, "AMOS snapshot settlement runway should be featured");
|
assert(chengduSettlementRunway.featured === true, "AMOS snapshot settlement runway should be featured");
|
||||||
assert(!chengduSettlementRunway.dashed, "AMOS snapshot settlement runway should be solid");
|
assert(!chengduSettlementRunway.dashed, "AMOS snapshot settlement runway should be solid");
|
||||||
|
const chengduEndpointMetrics = __getObservationDisplayMetricsForTest(
|
||||||
|
chengduRow,
|
||||||
|
chengduSnapshotHourly,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
chengduEndpointMetrics.currentRunwayTemp === 24.4,
|
||||||
|
"Chengdu 02L settlement should use TDZ temperature from 02L/20R, not runway max/end temperature",
|
||||||
|
);
|
||||||
|
|
||||||
const chengduAuxRunway = seriesByKey(chengduFromAmosSnapshot.series, "runway_02R_20L") as any;
|
const chengduAuxRunway = seriesByKey(chengduFromAmosSnapshot.series, "runway_02R_20L") as any;
|
||||||
assert(chengduAuxRunway, "AMOS runway_obs snapshot should create auxiliary runway chart lines");
|
assert(chengduAuxRunway, "AMOS runway_obs snapshot should create auxiliary runway chart lines");
|
||||||
|
|||||||
@@ -23,10 +23,21 @@ const SETTLEMENT_RUNWAY_PAIRS: Record<string, Array<[string, string]>> = {
|
|||||||
chengdu: [["02L", "20R"]],
|
chengdu: [["02L", "20R"]],
|
||||||
chongqing: [["20R", "02L"]],
|
chongqing: [["20R", "02L"]],
|
||||||
wuhan: [["04", "22"]],
|
wuhan: [["04", "22"]],
|
||||||
|
qingdao: [["16", "34"]],
|
||||||
seoul: [["15R", "33L"]],
|
seoul: [["15R", "33L"]],
|
||||||
busan: [["SR", "SL"]],
|
busan: [["SR", "SL"]],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SETTLEMENT_RUNWAY_TARGETS: Record<string, string> = {
|
||||||
|
shanghai: "35R",
|
||||||
|
beijing: "01",
|
||||||
|
guangzhou: "02L",
|
||||||
|
chengdu: "02L",
|
||||||
|
chongqing: "02L",
|
||||||
|
wuhan: "04",
|
||||||
|
qingdao: "34",
|
||||||
|
};
|
||||||
|
|
||||||
function normalizeRunwayLabel(value?: string | null) {
|
function normalizeRunwayLabel(value?: string | null) {
|
||||||
return String(value || "").trim().toUpperCase().replace(/\s+/g, "");
|
return String(value || "").trim().toUpperCase().replace(/\s+/g, "");
|
||||||
}
|
}
|
||||||
@@ -43,6 +54,21 @@ function pairKey(pair: [string, string]) {
|
|||||||
return pair.map(normalizeRunwayLabel).sort().join("/");
|
return pair.map(normalizeRunwayLabel).sort().join("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function settlementEndpointTempForPair(
|
||||||
|
cityKey: string,
|
||||||
|
pair: [string, string],
|
||||||
|
tdz: number | null,
|
||||||
|
end: number | null,
|
||||||
|
) {
|
||||||
|
const target = normalizeRunwayLabel(SETTLEMENT_RUNWAY_TARGETS[cityKey]);
|
||||||
|
if (!target) return null;
|
||||||
|
const first = normalizeRunwayLabel(pair[0]);
|
||||||
|
const second = normalizeRunwayLabel(pair[1]);
|
||||||
|
if (target === first) return tdz ?? end;
|
||||||
|
if (target === second) return end ?? tdz;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function runwaySeriesKey(rwy: string) {
|
function runwaySeriesKey(rwy: string) {
|
||||||
return `runway_${String(rwy || "unknown")
|
return `runway_${String(rwy || "unknown")
|
||||||
.split("/")
|
.split("/")
|
||||||
@@ -138,10 +164,16 @@ function buildRunwayPlates(
|
|||||||
const isSettlement = settlementKeys.has(pairKey(pair));
|
const isSettlement = settlementKeys.has(pairKey(pair));
|
||||||
|
|
||||||
const pointTemp = pointTemps[index] as any;
|
const pointTemp = pointTemps[index] as any;
|
||||||
const aggregateRunwayTemp = validNumber(pointTemp?.temp) ?? validNumber(pointTemp?.target_runway_max);
|
|
||||||
const tdz = validNumber(pointTemp?.tdz_temp);
|
const tdz = validNumber(pointTemp?.tdz_temp);
|
||||||
const mid = validNumber(pointTemp?.mid_temp);
|
const mid = validNumber(pointTemp?.mid_temp);
|
||||||
const end = validNumber(pointTemp?.end_temp);
|
const end = validNumber(pointTemp?.end_temp);
|
||||||
|
const endpointTemp = isSettlement
|
||||||
|
? settlementEndpointTempForPair(cityKey, pair, tdz, end)
|
||||||
|
: null;
|
||||||
|
const aggregateRunwayTemp =
|
||||||
|
endpointTemp ??
|
||||||
|
validNumber(pointTemp?.temp) ??
|
||||||
|
validNumber(pointTemp?.target_runway_max);
|
||||||
const isAmosTempDewTuple = String(amos.source || "").toLowerCase() === "amos";
|
const isAmosTempDewTuple = String(amos.source || "").toLowerCase() === "amos";
|
||||||
|
|
||||||
const historyVals = !isAmosTempDewTuple && Array.isArray(runwayTemps[index])
|
const historyVals = !isAmosTempDewTuple && Array.isArray(runwayTemps[index])
|
||||||
@@ -152,7 +184,9 @@ function buildRunwayPlates(
|
|||||||
const tdzVal = tdz !== null ? [tdz] : [];
|
const tdzVal = tdz !== null ? [tdz] : [];
|
||||||
const midVal = mid !== null ? [mid] : [];
|
const midVal = mid !== null ? [mid] : [];
|
||||||
const endVal = end !== null ? [end] : [];
|
const endVal = end !== null ? [end] : [];
|
||||||
const allVals = [...historyVals, ...aggregateVal, ...tdzVal, ...midVal, ...endVal];
|
const allVals = isSettlement && endpointTemp !== null
|
||||||
|
? [...historyVals, endpointTemp]
|
||||||
|
: [...historyVals, ...aggregateVal, ...tdzVal, ...midVal, ...endVal];
|
||||||
|
|
||||||
const maxTemp = allVals.length ? Math.max(...allVals) : null;
|
const maxTemp = allVals.length ? Math.max(...allVals) : null;
|
||||||
const dailyHigh = historyVals.length ? Math.max(...historyVals) : maxTemp;
|
const dailyHigh = historyVals.length ? Math.max(...historyVals) : maxTemp;
|
||||||
@@ -1167,18 +1201,29 @@ function buildRunwayHistorySeries(
|
|||||||
return runwayTemps
|
return runwayTemps
|
||||||
.map((rawTemps, index) => {
|
.map((rawTemps, index) => {
|
||||||
if (!Array.isArray(rawTemps)) return null;
|
if (!Array.isArray(rawTemps)) return null;
|
||||||
const rwy = runwayLabelFromPair(runwayPairs[index], index);
|
const rawPair = runwayPairs[index];
|
||||||
|
const rwy = runwayLabelFromPair(rawPair, index);
|
||||||
const isSettlement = isSettlementRunway(row, rwy);
|
const isSettlement = isSettlementRunway(row, rwy);
|
||||||
const pointTemp = Array.isArray(pointTemps) ? (pointTemps[index] as any) : null;
|
const pointTemp = Array.isArray(pointTemps) ? (pointTemps[index] as any) : null;
|
||||||
|
const pair = Array.isArray(rawPair) && rawPair.length >= 2
|
||||||
|
? [String(rawPair[0]), String(rawPair[1])] as [string, string]
|
||||||
|
: rwy.split("/").length >= 2
|
||||||
|
? [rwy.split("/")[0], rwy.split("/")[1]] as [string, string]
|
||||||
|
: [rwy, rwy] as [string, string];
|
||||||
|
const tdz = validNumber(pointTemp?.tdz_temp);
|
||||||
|
const mid = validNumber(pointTemp?.mid_temp);
|
||||||
|
const end = validNumber(pointTemp?.end_temp);
|
||||||
|
const endpointTemp = isSettlement
|
||||||
|
? settlementEndpointTempForPair(normalizeCityKey(row?.city), pair, tdz, end)
|
||||||
|
: null;
|
||||||
const aggregateRunwayTemp =
|
const aggregateRunwayTemp =
|
||||||
|
endpointTemp ??
|
||||||
validNumber(pointTemp?.temp) ??
|
validNumber(pointTemp?.temp) ??
|
||||||
validNumber(pointTemp?.target_runway_max) ??
|
validNumber(pointTemp?.target_runway_max) ??
|
||||||
(isAmosTempDewTuple ? runwayTemperatureFromPairTuple(rawTemps) : null);
|
(isAmosTempDewTuple ? runwayTemperatureFromPairTuple(rawTemps) : null);
|
||||||
const snapshotValues = [
|
const snapshotValues = [
|
||||||
aggregateRunwayTemp,
|
aggregateRunwayTemp,
|
||||||
validNumber(pointTemp?.tdz_temp),
|
...(isSettlement && endpointTemp !== null ? [] : [tdz, mid, end]),
|
||||||
validNumber(pointTemp?.mid_temp),
|
|
||||||
validNumber(pointTemp?.end_temp),
|
|
||||||
].filter((value): value is number => value !== null);
|
].filter((value): value is number => value !== null);
|
||||||
const samples = isAmosTempDewTuple
|
const samples = isAmosTempDewTuple
|
||||||
? []
|
? []
|
||||||
|
|||||||
@@ -33,11 +33,25 @@ AMSC_AWOS_AIRPORTS: Dict[str, Dict[str, str]] = {
|
|||||||
"qingdao": {"icao": "ZSQD", "label": "Qingdao Jiaodong"},
|
"qingdao": {"icao": "ZSQD", "label": "Qingdao Jiaodong"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AMSC_SETTLEMENT_RUNWAY_TARGETS: Dict[str, str] = {
|
||||||
|
"shanghai": "35R",
|
||||||
|
"chengdu": "02L",
|
||||||
|
"chongqing": "02L",
|
||||||
|
"guangzhou": "02L",
|
||||||
|
"wuhan": "04",
|
||||||
|
"beijing": "01",
|
||||||
|
"qingdao": "34",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _amsc_supported_city_codes() -> Dict[str, str]:
|
def _amsc_supported_city_codes() -> Dict[str, str]:
|
||||||
return {city: meta["icao"] for city, meta in AMSC_AWOS_AIRPORTS.items()}
|
return {city: meta["icao"] for city, meta in AMSC_AWOS_AIRPORTS.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _amsc_normalize_runway(value: Any) -> str:
|
||||||
|
return str(value or "").strip().upper().replace(" ", "")
|
||||||
|
|
||||||
|
|
||||||
def _amsc_safe_float(value: Any) -> Optional[float]:
|
def _amsc_safe_float(value: Any) -> Optional[float]:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
@@ -54,13 +68,43 @@ def _amsc_safe_float(value: Any) -> Optional[float]:
|
|||||||
|
|
||||||
|
|
||||||
def _amsc_split_runway_pair(label: str) -> tuple[str, str]:
|
def _amsc_split_runway_pair(label: str) -> tuple[str, str]:
|
||||||
parts = [part.strip() for part in str(label or "").split("/") if part.strip()]
|
parts = [_amsc_normalize_runway(part) for part in str(label or "").split("/") if part.strip()]
|
||||||
if len(parts) >= 2:
|
if len(parts) >= 2:
|
||||||
return parts[0], parts[1]
|
return parts[0], parts[1]
|
||||||
runway = str(label or "").strip() or "--"
|
runway = _amsc_normalize_runway(label) or "--"
|
||||||
return runway, runway
|
return runway, runway
|
||||||
|
|
||||||
|
|
||||||
|
def _amsc_settlement_endpoint(
|
||||||
|
city_key: str,
|
||||||
|
runway_pair: tuple[str, str],
|
||||||
|
*,
|
||||||
|
tdz_temp: Optional[float],
|
||||||
|
end_temp: Optional[float],
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
target = _amsc_normalize_runway(AMSC_SETTLEMENT_RUNWAY_TARGETS.get(city_key))
|
||||||
|
if not target:
|
||||||
|
return None
|
||||||
|
first = _amsc_normalize_runway(runway_pair[0])
|
||||||
|
second = _amsc_normalize_runway(runway_pair[1])
|
||||||
|
if target == first:
|
||||||
|
temp = tdz_temp if tdz_temp is not None else end_temp
|
||||||
|
position = "tdz" if tdz_temp is not None else "end_fallback"
|
||||||
|
elif target == second:
|
||||||
|
temp = end_temp if end_temp is not None else tdz_temp
|
||||||
|
position = "end" if end_temp is not None else "tdz_fallback"
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
if temp is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"runway": target,
|
||||||
|
"pair": f"{first}/{second}",
|
||||||
|
"position": position,
|
||||||
|
"temp": temp,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _amsc_wind_dir(*candidates: Any) -> Optional[int]:
|
def _amsc_wind_dir(*candidates: Any) -> Optional[int]:
|
||||||
"""Parse wind direction from AMSC fields (degrees)."""
|
"""Parse wind direction from AMSC fields (degrees)."""
|
||||||
for value in candidates:
|
for value in candidates:
|
||||||
@@ -138,8 +182,24 @@ def _amsc_parse_wind_plate_payload(
|
|||||||
if raw_metar is None and raw_row.get("METAR"):
|
if raw_metar is None and raw_row.get("METAR"):
|
||||||
raw_metar = str(raw_row.get("METAR"))
|
raw_metar = str(raw_row.get("METAR"))
|
||||||
|
|
||||||
runway_pairs.append(_amsc_split_runway_pair(runway_label))
|
runway_pair = _amsc_split_runway_pair(runway_label)
|
||||||
best_temp = tdz if tdz is not None else max(points)
|
settlement_endpoint = _amsc_settlement_endpoint(
|
||||||
|
city_key,
|
||||||
|
runway_pair,
|
||||||
|
tdz_temp=tdz,
|
||||||
|
end_temp=end,
|
||||||
|
)
|
||||||
|
runway_pairs.append(runway_pair)
|
||||||
|
target_temp = (
|
||||||
|
settlement_endpoint["temp"]
|
||||||
|
if settlement_endpoint is not None
|
||||||
|
else max(points)
|
||||||
|
)
|
||||||
|
best_temp = (
|
||||||
|
settlement_endpoint["temp"]
|
||||||
|
if settlement_endpoint is not None
|
||||||
|
else tdz if tdz is not None else max(points)
|
||||||
|
)
|
||||||
runway_temps.append((best_temp, None))
|
runway_temps.append((best_temp, None))
|
||||||
valid_values.extend(points)
|
valid_values.extend(points)
|
||||||
|
|
||||||
@@ -167,21 +227,29 @@ def _amsc_parse_wind_plate_payload(
|
|||||||
raw_row.get("TDZ_HUMID") or raw_row.get("END_HUMID") or raw_row.get("MID_HUMID")
|
raw_row.get("TDZ_HUMID") or raw_row.get("END_HUMID") or raw_row.get("MID_HUMID")
|
||||||
)
|
)
|
||||||
|
|
||||||
target_max = max(points)
|
point = {
|
||||||
point_temperatures.append(
|
"runway": f"{runway_pair[0]}/{runway_pair[1]}",
|
||||||
{
|
"temp": target_temp,
|
||||||
"runway": runway_label,
|
"tdz_temp": tdz,
|
||||||
"tdz_temp": tdz,
|
"mid_temp": mid,
|
||||||
"mid_temp": mid,
|
"end_temp": end,
|
||||||
"end_temp": end,
|
"target_runway_max": target_temp,
|
||||||
"target_runway_max": target_max,
|
"wind_dir": wind_dir,
|
||||||
"wind_dir": wind_dir,
|
"wind_speed": wind_speed,
|
||||||
"wind_speed": wind_speed,
|
"rvr": rvr,
|
||||||
"rvr": rvr,
|
"mor": mor,
|
||||||
"mor": mor,
|
"humidity": humidity,
|
||||||
"humidity": humidity,
|
}
|
||||||
}
|
if settlement_endpoint is not None:
|
||||||
)
|
point.update(
|
||||||
|
{
|
||||||
|
"is_settlement": True,
|
||||||
|
"settlement_runway": settlement_endpoint["runway"],
|
||||||
|
"settlement_runway_position": settlement_endpoint["position"],
|
||||||
|
"settlement_runway_temp": settlement_endpoint["temp"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
point_temperatures.append(point)
|
||||||
|
|
||||||
if not valid_values or not runway_pairs:
|
if not valid_values or not runway_pairs:
|
||||||
return None
|
return None
|
||||||
@@ -189,11 +257,28 @@ def _amsc_parse_wind_plate_payload(
|
|||||||
max_temp = round(max(valid_values), 1)
|
max_temp = round(max(valid_values), 1)
|
||||||
min_temp = round(min(valid_values), 1)
|
min_temp = round(min(valid_values), 1)
|
||||||
avg_temp = round(sum(valid_values) / len(valid_values), 1)
|
avg_temp = round(sum(valid_values) / len(valid_values), 1)
|
||||||
|
settlement_point = next(
|
||||||
|
(
|
||||||
|
point
|
||||||
|
for point in point_temperatures
|
||||||
|
if point.get("is_settlement") and point.get("settlement_runway_temp") is not None
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
display_temp = (
|
||||||
|
round(float(settlement_point["settlement_runway_temp"]), 1)
|
||||||
|
if settlement_point is not None
|
||||||
|
else max_temp
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"temp": max_temp,
|
"temp": display_temp,
|
||||||
"temp_c": max_temp,
|
"temp_c": display_temp,
|
||||||
"temp_source": "runway_max",
|
"temp_source": "settlement_runway_endpoint" if settlement_point else "runway_max",
|
||||||
|
"settlement_runway": settlement_point.get("settlement_runway") if settlement_point else None,
|
||||||
|
"settlement_runway_pair": settlement_point.get("runway") if settlement_point else None,
|
||||||
|
"settlement_runway_position": settlement_point.get("settlement_runway_position") if settlement_point else None,
|
||||||
|
"settlement_runway_temp": display_temp if settlement_point else None,
|
||||||
"runway_temps": runway_temps,
|
"runway_temps": runway_temps,
|
||||||
"runway_temp_range": (min_temp, max_temp),
|
"runway_temp_range": (min_temp, max_temp),
|
||||||
"runway_temp_avg": avg_temp,
|
"runway_temp_avg": avg_temp,
|
||||||
|
|||||||
+127
-22
@@ -619,9 +619,20 @@ SETTLEMENT_RUNWAY_PAIRS: Dict[str, Set[Tuple[str, str]]] = {
|
|||||||
"chengdu": {("02L", "20R")},
|
"chengdu": {("02L", "20R")},
|
||||||
"chongqing": {("02L", "20R")},
|
"chongqing": {("02L", "20R")},
|
||||||
"wuhan": {("04", "22")},
|
"wuhan": {("04", "22")},
|
||||||
|
"qingdao": {("16", "34")},
|
||||||
"seoul": {("15R", "33L")},
|
"seoul": {("15R", "33L")},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SETTLEMENT_RUNWAY_TARGETS: Dict[str, str] = {
|
||||||
|
"shanghai": "35R",
|
||||||
|
"chengdu": "02L",
|
||||||
|
"chongqing": "02L",
|
||||||
|
"guangzhou": "02L",
|
||||||
|
"wuhan": "04",
|
||||||
|
"beijing": "01",
|
||||||
|
"qingdao": "34",
|
||||||
|
}
|
||||||
|
|
||||||
# All cities with active runway observation data (AMSC AWOS / AMOS).
|
# All cities with active runway observation data (AMSC AWOS / AMOS).
|
||||||
RUNWAY_OBSERVATION_CITIES = {
|
RUNWAY_OBSERVATION_CITIES = {
|
||||||
"shanghai", "beijing", "guangzhou",
|
"shanghai", "beijing", "guangzhou",
|
||||||
@@ -736,6 +747,94 @@ def _focus_runway_pairs_for_city(city: str) -> Set[Tuple[str, str]]:
|
|||||||
return {_runway_pair_key(a, b) for a, b in FOCUS_RUNWAY_PAIRS.get(city, set())}
|
return {_runway_pair_key(a, b) for a, b in FOCUS_RUNWAY_PAIRS.get(city, set())}
|
||||||
|
|
||||||
|
|
||||||
|
def _settlement_runway_target_for_city(city: str) -> str:
|
||||||
|
city_key = (city or "").strip().lower()
|
||||||
|
return _normalize_runway_label(SETTLEMENT_RUNWAY_TARGETS.get(city_key))
|
||||||
|
|
||||||
|
|
||||||
|
def _runway_pair_from_point(pair: Any, point: Any) -> Tuple[str, str]:
|
||||||
|
if isinstance(point, dict):
|
||||||
|
rw = str(point.get("runway") or "")
|
||||||
|
parts = [_normalize_runway_label(p) for p in rw.split("/") if p.strip()]
|
||||||
|
if len(parts) >= 2:
|
||||||
|
return parts[0], parts[1]
|
||||||
|
try:
|
||||||
|
r1, r2 = pair
|
||||||
|
return _normalize_runway_label(r1), _normalize_runway_label(r2)
|
||||||
|
except Exception:
|
||||||
|
return "", ""
|
||||||
|
|
||||||
|
|
||||||
|
def _settlement_endpoint_for_point(
|
||||||
|
city: str,
|
||||||
|
pair: Any,
|
||||||
|
point: Any,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
if not isinstance(point, dict):
|
||||||
|
point = {}
|
||||||
|
r1, r2 = _runway_pair_from_point(pair, point)
|
||||||
|
if not r1 or not r2 or not _is_settlement_runway(city, r1, r2):
|
||||||
|
return None
|
||||||
|
|
||||||
|
target = _settlement_runway_target_for_city(city)
|
||||||
|
if target:
|
||||||
|
direct_temp = _safe_float(point.get("settlement_runway_temp"))
|
||||||
|
direct_runway = _normalize_runway_label(point.get("settlement_runway"))
|
||||||
|
if direct_temp is not None and (not direct_runway or direct_runway == target):
|
||||||
|
return {
|
||||||
|
"temp": direct_temp,
|
||||||
|
"pair": f"{r1}/{r2}",
|
||||||
|
"runway": target,
|
||||||
|
"position": point.get("settlement_runway_position") or "settlement",
|
||||||
|
"label": "settle",
|
||||||
|
}
|
||||||
|
|
||||||
|
tdz = _safe_float(point.get("tdz_temp"))
|
||||||
|
end = _safe_float(point.get("end_temp"))
|
||||||
|
if target == r1:
|
||||||
|
temp = tdz if tdz is not None else end
|
||||||
|
position = "tdz" if tdz is not None else "end_fallback"
|
||||||
|
elif target == r2:
|
||||||
|
temp = end if end is not None else tdz
|
||||||
|
position = "end" if end is not None else "tdz_fallback"
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
if temp is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"temp": temp,
|
||||||
|
"pair": f"{r1}/{r2}",
|
||||||
|
"runway": target,
|
||||||
|
"position": position,
|
||||||
|
"label": "settle",
|
||||||
|
}
|
||||||
|
|
||||||
|
tmax = _safe_float(point.get("target_runway_max"))
|
||||||
|
if tmax is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"temp": tmax,
|
||||||
|
"pair": f"{r1}/{r2}",
|
||||||
|
"runway": "",
|
||||||
|
"position": "max",
|
||||||
|
"label": "max",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _settlement_endpoint_from_obs(
|
||||||
|
city: str,
|
||||||
|
runway_pairs: List[Any],
|
||||||
|
point_temps: Optional[List[Any]] = None,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
points = point_temps or []
|
||||||
|
for i, pair in enumerate(runway_pairs or []):
|
||||||
|
point = points[i] if i < len(points) else {}
|
||||||
|
endpoint = _settlement_endpoint_for_point(city, pair, point)
|
||||||
|
if endpoint is not None:
|
||||||
|
return endpoint
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _select_focus_runway_obs(
|
def _select_focus_runway_obs(
|
||||||
city: str,
|
city: str,
|
||||||
runway_pairs: List[Any],
|
runway_pairs: List[Any],
|
||||||
@@ -849,6 +948,9 @@ def _focused_runway_max(city: str, city_weather: Dict[str, Any]) -> Optional[flo
|
|||||||
runway_temps,
|
runway_temps,
|
||||||
runway_obs.get("point_temperatures") or [],
|
runway_obs.get("point_temperatures") or [],
|
||||||
)
|
)
|
||||||
|
endpoint = _settlement_endpoint_from_obs(city, runway_pairs, _points)
|
||||||
|
if endpoint is not None:
|
||||||
|
return float(endpoint["temp"])
|
||||||
del runway_pairs
|
del runway_pairs
|
||||||
valid = [float(t) for (t, _d) in runway_temps if t is not None]
|
valid = [float(t) for (t, _d) in runway_temps if t is not None]
|
||||||
return max(valid) if valid else None
|
return max(valid) if valid else None
|
||||||
@@ -1016,21 +1118,12 @@ def _build_airport_status_message(
|
|||||||
has_runway = bool(runway_pairs and (runway_temps or point_temps))
|
has_runway = bool(runway_pairs and (runway_temps or point_temps))
|
||||||
amos_icao = amos.get("icao") or HIGH_FREQ_AIRPORT_ICAO.get(city, "")
|
amos_icao = amos.get("icao") or HIGH_FREQ_AIRPORT_ICAO.get(city, "")
|
||||||
settlement_pair = _settlement_runway_for_city(city)
|
settlement_pair = _settlement_runway_for_city(city)
|
||||||
|
settlement_endpoint = _settlement_endpoint_from_obs(city, runway_pairs, point_temps)
|
||||||
|
|
||||||
# ── Display temp: settlement runway max first, then airport temp ──
|
# ── Display temp: settlement endpoint first, then airport temp ──
|
||||||
settlement_temp: Optional[float] = None
|
|
||||||
display_temp: Optional[float] = None
|
display_temp: Optional[float] = None
|
||||||
if point_temps:
|
if settlement_endpoint is not None:
|
||||||
for pt in point_temps:
|
display_temp = float(settlement_endpoint["temp"])
|
||||||
rw = str(pt.get("runway") or "")
|
|
||||||
rw_parts = [p.strip() for p in rw.split("/") if p.strip()]
|
|
||||||
if settlement_pair and len(rw_parts) >= 2 and _runway_pair_key(rw_parts[0], rw_parts[1]) == _runway_pair_key(*settlement_pair):
|
|
||||||
tmax = pt.get("target_runway_max")
|
|
||||||
if tmax is not None:
|
|
||||||
settlement_temp = float(tmax)
|
|
||||||
break
|
|
||||||
if settlement_temp is not None:
|
|
||||||
display_temp = settlement_temp
|
|
||||||
if display_temp is None:
|
if display_temp is None:
|
||||||
if point_temps:
|
if point_temps:
|
||||||
valid_tmax = [float(p.get("target_runway_max")) for p in point_temps if p.get("target_runway_max") is not None]
|
valid_tmax = [float(p.get("target_runway_max")) for p in point_temps if p.get("target_runway_max") is not None]
|
||||||
@@ -1069,7 +1162,12 @@ def _build_airport_status_message(
|
|||||||
language=language,
|
language=language,
|
||||||
)
|
)
|
||||||
icao_display = f"{amos_icao} · " if amos_icao else ""
|
icao_display = f"{amos_icao} · " if amos_icao else ""
|
||||||
settlement_str = f" · ★{settlement_pair[0]}/{settlement_pair[1]}" if settlement_pair else ""
|
settlement_pair_label = (
|
||||||
|
str(settlement_endpoint.get("pair"))
|
||||||
|
if settlement_endpoint is not None and settlement_endpoint.get("pair")
|
||||||
|
else (f"{settlement_pair[0]}/{settlement_pair[1]}" if settlement_pair else "")
|
||||||
|
)
|
||||||
|
settlement_str = f" · ★{settlement_pair_label}" if settlement_pair_label else ""
|
||||||
header = f"{icao_display}{en_name} / {ap_name}{settlement_str}{time_suffix}" if ap_name else f"{icao_display}{en_name}{settlement_str}{time_suffix}"
|
header = f"{icao_display}{en_name} / {ap_name}{settlement_str}{time_suffix}" if ap_name else f"{icao_display}{en_name}{settlement_str}{time_suffix}"
|
||||||
lines.append(hashtag_line)
|
lines.append(hashtag_line)
|
||||||
lines.append("")
|
lines.append("")
|
||||||
@@ -1093,12 +1191,15 @@ def _build_airport_status_message(
|
|||||||
mid = pts.get("mid_temp")
|
mid = pts.get("mid_temp")
|
||||||
end = pts.get("end_temp")
|
end = pts.get("end_temp")
|
||||||
is_settlement = _is_settlement_runway(city, r1, r2)
|
is_settlement = _is_settlement_runway(city, r1, r2)
|
||||||
marker = _copy(language, " ★Settlement", " ★结算") if is_settlement else ""
|
marker = f" {_copy(language, '★Settlement', '★结算')}" if is_settlement else ""
|
||||||
tmax = pts.get("target_runway_max")
|
tmax = pts.get("target_runway_max")
|
||||||
if tdz is not None or mid is not None or end is not None:
|
if tdz is not None or mid is not None or end is not None:
|
||||||
line = f"{r1}/{r2}{marker} TDZ:{_fmt(tdz)} MID:{_fmt(mid)} END:{_fmt(end)}"
|
line = f"{r1}/{r2}{marker} TDZ:{_fmt(tdz)} MID:{_fmt(mid)} END:{_fmt(end)}"
|
||||||
if tmax is not None:
|
settlement_line_endpoint = _settlement_endpoint_for_point(city, (r1, r2), pts) if is_settlement else None
|
||||||
line += f" max:{tmax:.1f}"
|
if settlement_line_endpoint is not None:
|
||||||
|
line += f" settle:{float(settlement_line_endpoint['temp']):.1f}"
|
||||||
|
elif tmax is not None:
|
||||||
|
line += f" max:{float(tmax):.1f}"
|
||||||
lines.append(line)
|
lines.append(line)
|
||||||
else:
|
else:
|
||||||
temp_symbol = str(city_weather.get("temp_symbol") or "°C").strip()
|
temp_symbol = str(city_weather.get("temp_symbol") or "°C").strip()
|
||||||
@@ -1402,14 +1503,18 @@ def _process_airport_city(
|
|||||||
runway_obs = (amos.get("runway_obs") or {})
|
runway_obs = (amos.get("runway_obs") or {})
|
||||||
runway_pairs = runway_obs.get("runway_pairs") or []
|
runway_pairs = runway_obs.get("runway_pairs") or []
|
||||||
runway_temps = runway_obs.get("temperatures") or []
|
runway_temps = runway_obs.get("temperatures") or []
|
||||||
runway_pairs, runway_temps, _point_temps = _select_focus_runway_obs(
|
runway_pairs, runway_temps, point_temps = _select_focus_runway_obs(
|
||||||
city, runway_pairs, runway_temps,
|
city, runway_pairs, runway_temps,
|
||||||
runway_obs.get("point_temperatures") or [],
|
runway_obs.get("point_temperatures") or [],
|
||||||
)
|
)
|
||||||
if runway_temps:
|
if runway_pairs and (runway_temps or point_temps):
|
||||||
valid_temps = [t for (t, _d) in runway_temps if t is not None]
|
endpoint = _settlement_endpoint_from_obs(city, runway_pairs, point_temps)
|
||||||
if valid_temps:
|
if endpoint is not None:
|
||||||
station_temp = max(valid_temps)
|
station_temp = float(endpoint["temp"])
|
||||||
|
else:
|
||||||
|
valid_temps = [t for (t, _d) in runway_temps if t is not None]
|
||||||
|
if valid_temps:
|
||||||
|
station_temp = max(valid_temps)
|
||||||
amos_obs_time = amos.get("observation_time") or ""
|
amos_obs_time = amos.get("observation_time") or ""
|
||||||
if amos_obs_time:
|
if amos_obs_time:
|
||||||
current_obs_time = amos_obs_time
|
current_obs_time = amos_obs_time
|
||||||
|
|||||||
@@ -46,8 +46,11 @@ def test_parse_wind_plate_payload_normalizes_runway_point_temperatures():
|
|||||||
assert parsed["source_label"] == "AMSC AWOS Beijing Capital (ZBAA)"
|
assert parsed["source_label"] == "AMSC AWOS Beijing Capital (ZBAA)"
|
||||||
assert parsed["observation_source_zh"] == "AMSC AWOS 跑道观测气温"
|
assert parsed["observation_source_zh"] == "AMSC AWOS 跑道观测气温"
|
||||||
assert parsed["icao"] == "ZBAA"
|
assert parsed["icao"] == "ZBAA"
|
||||||
assert parsed["temp_c"] == 21.0
|
assert parsed["temp_c"] == 20.2
|
||||||
assert parsed["temp_source"] == "runway_max"
|
assert parsed["temp_source"] == "settlement_runway_endpoint"
|
||||||
|
assert parsed["settlement_runway"] == "01"
|
||||||
|
assert parsed["settlement_runway_pair"] == "19/01"
|
||||||
|
assert parsed["settlement_runway_position"] == "end"
|
||||||
assert parsed["runway_temp_range"] == (20.2, 21.0)
|
assert parsed["runway_temp_range"] == (20.2, 21.0)
|
||||||
assert parsed["observation_time"] == "2026-05-14T17:19:00+00:00"
|
assert parsed["observation_time"] == "2026-05-14T17:19:00+00:00"
|
||||||
assert parsed["observation_time_local"] == "2026-05-15 01:19:00"
|
assert parsed["observation_time_local"] == "2026-05-15 01:19:00"
|
||||||
@@ -67,6 +70,10 @@ def test_parse_wind_plate_payload_normalizes_runway_point_temperatures():
|
|||||||
assert pt0["rvr"] is None
|
assert pt0["rvr"] is None
|
||||||
assert pt0["mor"] is None
|
assert pt0["mor"] is None
|
||||||
assert pt0["humidity"] == 67.0
|
assert pt0["humidity"] == 67.0
|
||||||
|
settlement_pt = runway_obs["point_temperatures"][2]
|
||||||
|
assert settlement_pt["runway"] == "19/01"
|
||||||
|
assert settlement_pt["is_settlement"] is True
|
||||||
|
assert settlement_pt["target_runway_max"] == 20.2
|
||||||
|
|
||||||
|
|
||||||
def test_parse_wind_plate_payload_uses_settlement_runway_endpoint_temperature():
|
def test_parse_wind_plate_payload_uses_settlement_runway_endpoint_temperature():
|
||||||
@@ -154,5 +161,6 @@ def test_fetch_amsc_official_current_uses_domestic_city_whitelist(monkeypatch):
|
|||||||
|
|
||||||
assert data is not None
|
assert data is not None
|
||||||
assert data["icao"] == "ZBAA"
|
assert data["icao"] == "ZBAA"
|
||||||
|
assert data["temp_c"] == 20.2
|
||||||
assert data["runway_temp_range"] == (20.2, 21.0)
|
assert data["runway_temp_range"] == (20.2, 21.0)
|
||||||
assert FakeCollector().fetch_amsc_awos_current("new york") is None
|
assert FakeCollector().fetch_amsc_awos_current("new york") is None
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ def test_airport_status_message_defaults_to_bilingual_runway_copy(monkeypatch):
|
|||||||
assert first_line == "#RunwayObs #跑道观测 #Qingdao"
|
assert first_line == "#RunwayObs #跑道观测 #Qingdao"
|
||||||
assert "Qingdao / Jiaodong" in text
|
assert "Qingdao / Jiaodong" in text
|
||||||
assert "TDZ:23.0" in text
|
assert "TDZ:23.0" in text
|
||||||
assert "Runway now / 跑道当前:" in text
|
assert "Settlement runway now / 结算跑道当前:" in text
|
||||||
assert "Today's runway high / 今日跑道高点:" in text
|
assert "Today's runway high / 今日跑道高点:" in text
|
||||||
assert "DEB: 24.0°C" in text
|
assert "DEB: 24.0°C" in text
|
||||||
|
|
||||||
@@ -55,10 +55,10 @@ def test_airport_status_hides_non_focus_runways_for_key_airports():
|
|||||||
"amos": {
|
"amos": {
|
||||||
"source": "amsc_awos",
|
"source": "amsc_awos",
|
||||||
"runway_obs": {
|
"runway_obs": {
|
||||||
"runway_pairs": [("02L", "20R"), ("02R", "20L")],
|
"runway_pairs": [("20R", "02L"), ("02R", "20L")],
|
||||||
"temperatures": [(31.1, None), (34.9, None)],
|
"temperatures": [(31.1, None), (34.9, None)],
|
||||||
"point_temperatures": [
|
"point_temperatures": [
|
||||||
{"runway": "02L/20R", "tdz_temp": 31.0, "mid_temp": 31.1, "end_temp": 31.2, "target_runway_max": 31.2, "wind_dir": None, "wind_speed": None, "rvr": None, "mor": None, "humidity": None},
|
{"runway": "20R/02L", "tdz_temp": 33.8, "mid_temp": 34.5, "end_temp": 31.2, "target_runway_max": 34.5, "wind_dir": None, "wind_speed": None, "rvr": None, "mor": None, "humidity": None},
|
||||||
{"runway": "02R/20L", "tdz_temp": 34.8, "mid_temp": 34.9, "end_temp": 35.0, "target_runway_max": 35.0, "wind_dir": None, "wind_speed": None, "rvr": None, "mor": None, "humidity": None},
|
{"runway": "02R/20L", "tdz_temp": 34.8, "mid_temp": 34.9, "end_temp": 35.0, "target_runway_max": 35.0, "wind_dir": None, "wind_speed": None, "rvr": None, "mor": None, "humidity": None},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -68,9 +68,36 @@ def test_airport_status_hides_non_focus_runways_for_key_airports():
|
|||||||
"13:00",
|
"13:00",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert "02L/20R" in text
|
assert "20R/02L" in text
|
||||||
assert "02R/20L" in text
|
assert "02R/20L" in text
|
||||||
assert "Settlement runway now / 结算跑道当前: 31.2°C" in text
|
assert "Settlement runway now / 结算跑道当前: 31.2°C" in text
|
||||||
|
assert "max:34.5" not in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_airport_status_uses_tdz_when_settlement_target_is_first_runway():
|
||||||
|
text = _build_airport_status_message(
|
||||||
|
"chengdu",
|
||||||
|
{
|
||||||
|
"current": {"temp": 28.0},
|
||||||
|
"airport_current": {"max_so_far": 30.0, "max_temp_time": "13:00"},
|
||||||
|
"amos": {
|
||||||
|
"source": "amsc_awos",
|
||||||
|
"runway_obs": {
|
||||||
|
"runway_pairs": [("02L", "20R")],
|
||||||
|
"temperatures": [(27.9, None)],
|
||||||
|
"point_temperatures": [
|
||||||
|
{"runway": "02L/20R", "tdz_temp": 24.4, "mid_temp": 26.1, "end_temp": 27.9, "target_runway_max": 27.9, "wind_dir": None, "wind_speed": None, "rvr": None, "mor": None, "humidity": None},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
32.0,
|
||||||
|
"13:00",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "02L/20R ★Settlement / ★结算 TDZ:24.4 MID:26.1 END:27.9 settle:24.4" in text
|
||||||
|
assert "Settlement runway now / 结算跑道当前: 24.4°C" in text
|
||||||
|
assert "Settlement runway now / 结算跑道当前: 27.9°C" not in text
|
||||||
|
|
||||||
|
|
||||||
def test_singapore_is_in_telegram_push_city_lists():
|
def test_singapore_is_in_telegram_push_city_lists():
|
||||||
|
|||||||
Reference in New Issue
Block a user