From 79b82a34cba41db0f27e5254a40f7306e69cd4ed Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Thu, 28 May 2026 10:55:32 +0800 Subject: [PATCH] fix: use settlement runway endpoint temperatures --- docs/CITY_DATA_SOURCES.md | 1 + ...temperatureDefaultVisibilityPolicy.test.ts | 70 ++++---- .../scan-terminal/temperature-chart-logic.ts | 57 ++++++- src/data_collection/amsc_awos_sources.py | 129 ++++++++++++--- src/utils/telegram_push.py | 149 +++++++++++++++--- tests/test_amsc_awos_sources.py | 12 +- tests/test_telegram_hashtags.py | 35 +++- 7 files changed, 367 insertions(+), 86 deletions(-) diff --git a/docs/CITY_DATA_SOURCES.md b/docs/CITY_DATA_SOURCES.md index c227e6a6..7677300d 100644 --- a/docs/CITY_DATA_SOURCES.md +++ b/docs/CITY_DATA_SOURCES.md @@ -123,6 +123,7 @@ ankara, istanbul, helsinki, amsterdam, paris - 成都:02L/20R - 重庆:20R/02L - 武汉:04/22 + - 青岛:16/34 - 首尔:15R/33L - **辅助跑道弱化**:同一机场下的其他非结算跑道,也会同时展示,但采用较细的虚线(线宽 1.2)以作陪衬区分。 - **其他实测展示**:所有城市的 METAR 报文曲线、官方气象站实测(如 Hong Kong / Lau Fau Shan 的香港天文台曲线)均默认展示。 diff --git a/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts index 9023e569..6fdf6508 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts @@ -438,37 +438,39 @@ export function runTests() { ); assert(hongKongHkoSeries?.label === "HKO", "Hong Kong HKO settlement observations should remain visible as the HKO curve"); - const chengduFromAmosSnapshot = __buildTemperatureChartDataForTest( - { - city: "chengdu", - local_date: "2026-05-26", - local_time: "05:25", - tz_offset_seconds: 8 * 60 * 60, - airport: "ZUUU", - } as any, - { - localTime: "05:25", - times: ["00:00", "06:00", "12:00", "18:00"], - temps: [24, 28, 31, 27], - amos: { - observation_time: "2026-05-25T21:25:00+00:00", - observation_time_local: "2026-05-26 05:25:00", - runway_obs: { - runway_pairs: [ - ["02L", "20R"], - ["02R", "20L"], - ], - temperatures: [ - [24.4, null], - [24.2, null], - ], - point_temperatures: [ - { 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 }, - ], - }, + const chengduRow = { + city: "chengdu", + local_date: "2026-05-26", + local_time: "05:25", + tz_offset_seconds: 8 * 60 * 60, + airport: "ZUUU", + } as any; + const chengduSnapshotHourly = { + localTime: "05:25", + times: ["00:00", "06:00", "12:00", "18:00"], + temps: [24, 28, 31, 27], + amos: { + observation_time: "2026-05-25T21:25:00+00:00", + observation_time_local: "2026-05-26 05:25:00", + runway_obs: { + runway_pairs: [ + ["02L", "20R"], + ["02R", "20L"], + ], + temperatures: [ + [24.4, null], + [24.2, null], + ], + point_temperatures: [ + { 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 }, + ], }, - } as any, + }, + } as any; + const chengduFromAmosSnapshot = __buildTemperatureChartDataForTest( + chengduRow, + chengduSnapshotHourly, "1D", ); @@ -477,6 +479,14 @@ export function runTests() { 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.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; assert(chengduAuxRunway, "AMOS runway_obs snapshot should create auxiliary runway chart lines"); diff --git a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts index f9190e73..6ca22587 100644 --- a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts +++ b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts @@ -23,10 +23,21 @@ const SETTLEMENT_RUNWAY_PAIRS: Record> = { chengdu: [["02L", "20R"]], chongqing: [["20R", "02L"]], wuhan: [["04", "22"]], + qingdao: [["16", "34"]], seoul: [["15R", "33L"]], busan: [["SR", "SL"]], }; +const SETTLEMENT_RUNWAY_TARGETS: Record = { + shanghai: "35R", + beijing: "01", + guangzhou: "02L", + chengdu: "02L", + chongqing: "02L", + wuhan: "04", + qingdao: "34", +}; + function normalizeRunwayLabel(value?: string | null) { return String(value || "").trim().toUpperCase().replace(/\s+/g, ""); } @@ -43,6 +54,21 @@ function pairKey(pair: [string, string]) { 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) { return `runway_${String(rwy || "unknown") .split("/") @@ -138,10 +164,16 @@ function buildRunwayPlates( const isSettlement = settlementKeys.has(pairKey(pair)); const pointTemp = pointTemps[index] as any; - const aggregateRunwayTemp = validNumber(pointTemp?.temp) ?? validNumber(pointTemp?.target_runway_max); const tdz = validNumber(pointTemp?.tdz_temp); const mid = validNumber(pointTemp?.mid_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 historyVals = !isAmosTempDewTuple && Array.isArray(runwayTemps[index]) @@ -152,7 +184,9 @@ function buildRunwayPlates( const tdzVal = tdz !== null ? [tdz] : []; const midVal = mid !== null ? [mid] : []; 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 dailyHigh = historyVals.length ? Math.max(...historyVals) : maxTemp; @@ -1167,18 +1201,29 @@ function buildRunwayHistorySeries( return runwayTemps .map((rawTemps, index) => { 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 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 = + endpointTemp ?? validNumber(pointTemp?.temp) ?? validNumber(pointTemp?.target_runway_max) ?? (isAmosTempDewTuple ? runwayTemperatureFromPairTuple(rawTemps) : null); const snapshotValues = [ aggregateRunwayTemp, - validNumber(pointTemp?.tdz_temp), - validNumber(pointTemp?.mid_temp), - validNumber(pointTemp?.end_temp), + ...(isSettlement && endpointTemp !== null ? [] : [tdz, mid, end]), ].filter((value): value is number => value !== null); const samples = isAmosTempDewTuple ? [] diff --git a/src/data_collection/amsc_awos_sources.py b/src/data_collection/amsc_awos_sources.py index a3d3f029..f8edf85d 100644 --- a/src/data_collection/amsc_awos_sources.py +++ b/src/data_collection/amsc_awos_sources.py @@ -33,11 +33,25 @@ AMSC_AWOS_AIRPORTS: Dict[str, Dict[str, str]] = { "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]: 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]: if value is 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]: - 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: return parts[0], parts[1] - runway = str(label or "").strip() or "--" + runway = _amsc_normalize_runway(label) or "--" 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]: """Parse wind direction from AMSC fields (degrees).""" for value in candidates: @@ -138,8 +182,24 @@ def _amsc_parse_wind_plate_payload( if raw_metar is None and raw_row.get("METAR"): raw_metar = str(raw_row.get("METAR")) - runway_pairs.append(_amsc_split_runway_pair(runway_label)) - best_temp = tdz if tdz is not None else max(points) + runway_pair = _amsc_split_runway_pair(runway_label) + 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)) 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") ) - target_max = max(points) - point_temperatures.append( - { - "runway": runway_label, - "tdz_temp": tdz, - "mid_temp": mid, - "end_temp": end, - "target_runway_max": target_max, - "wind_dir": wind_dir, - "wind_speed": wind_speed, - "rvr": rvr, - "mor": mor, - "humidity": humidity, - } - ) + point = { + "runway": f"{runway_pair[0]}/{runway_pair[1]}", + "temp": target_temp, + "tdz_temp": tdz, + "mid_temp": mid, + "end_temp": end, + "target_runway_max": target_temp, + "wind_dir": wind_dir, + "wind_speed": wind_speed, + "rvr": rvr, + "mor": mor, + "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: return None @@ -189,11 +257,28 @@ def _amsc_parse_wind_plate_payload( max_temp = round(max(valid_values), 1) min_temp = round(min(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 { - "temp": max_temp, - "temp_c": max_temp, - "temp_source": "runway_max", + "temp": display_temp, + "temp_c": display_temp, + "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_temp_range": (min_temp, max_temp), "runway_temp_avg": avg_temp, diff --git a/src/utils/telegram_push.py b/src/utils/telegram_push.py index f9ec34c9..4901e78f 100644 --- a/src/utils/telegram_push.py +++ b/src/utils/telegram_push.py @@ -619,9 +619,20 @@ SETTLEMENT_RUNWAY_PAIRS: Dict[str, Set[Tuple[str, str]]] = { "chengdu": {("02L", "20R")}, "chongqing": {("02L", "20R")}, "wuhan": {("04", "22")}, + "qingdao": {("16", "34")}, "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). RUNWAY_OBSERVATION_CITIES = { "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())} +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( city: str, runway_pairs: List[Any], @@ -849,6 +948,9 @@ def _focused_runway_max(city: str, city_weather: Dict[str, Any]) -> Optional[flo runway_temps, 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 valid = [float(t) for (t, _d) in runway_temps if t is not 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)) amos_icao = amos.get("icao") or HIGH_FREQ_AIRPORT_ICAO.get(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 ── - settlement_temp: Optional[float] = None + # ── Display temp: settlement endpoint first, then airport temp ── display_temp: Optional[float] = None - if point_temps: - for pt in point_temps: - 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 settlement_endpoint is not None: + display_temp = float(settlement_endpoint["temp"]) if display_temp is None: 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] @@ -1069,7 +1162,12 @@ def _build_airport_status_message( language=language, ) 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}" lines.append(hashtag_line) lines.append("") @@ -1093,12 +1191,15 @@ def _build_airport_status_message( mid = pts.get("mid_temp") end = pts.get("end_temp") 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") 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)}" - if tmax is not None: - line += f" max:{tmax:.1f}" + settlement_line_endpoint = _settlement_endpoint_for_point(city, (r1, r2), pts) if is_settlement else None + 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) else: 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_pairs = runway_obs.get("runway_pairs") 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, runway_obs.get("point_temperatures") or [], ) - if runway_temps: - valid_temps = [t for (t, _d) in runway_temps if t is not None] - if valid_temps: - station_temp = max(valid_temps) + if runway_pairs and (runway_temps or point_temps): + endpoint = _settlement_endpoint_from_obs(city, runway_pairs, point_temps) + if endpoint is not None: + 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 "" if amos_obs_time: current_obs_time = amos_obs_time diff --git a/tests/test_amsc_awos_sources.py b/tests/test_amsc_awos_sources.py index d8b92f02..0fd1ca19 100644 --- a/tests/test_amsc_awos_sources.py +++ b/tests/test_amsc_awos_sources.py @@ -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["observation_source_zh"] == "AMSC AWOS 跑道观测气温" assert parsed["icao"] == "ZBAA" - assert parsed["temp_c"] == 21.0 - assert parsed["temp_source"] == "runway_max" + assert parsed["temp_c"] == 20.2 + 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["observation_time"] == "2026-05-14T17:19:00+00: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["mor"] is None 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(): @@ -154,5 +161,6 @@ def test_fetch_amsc_official_current_uses_domestic_city_whitelist(monkeypatch): assert data is not None assert data["icao"] == "ZBAA" + assert data["temp_c"] == 20.2 assert data["runway_temp_range"] == (20.2, 21.0) assert FakeCollector().fetch_amsc_awos_current("new york") is None diff --git a/tests/test_telegram_hashtags.py b/tests/test_telegram_hashtags.py index b0dc63d4..291bce68 100644 --- a/tests/test_telegram_hashtags.py +++ b/tests/test_telegram_hashtags.py @@ -41,7 +41,7 @@ def test_airport_status_message_defaults_to_bilingual_runway_copy(monkeypatch): assert first_line == "#RunwayObs #跑道观测 #Qingdao" assert "Qingdao / Jiaodong" 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 "DEB: 24.0°C" in text @@ -55,10 +55,10 @@ def test_airport_status_hides_non_focus_runways_for_key_airports(): "amos": { "source": "amsc_awos", "runway_obs": { - "runway_pairs": [("02L", "20R"), ("02R", "20L")], + "runway_pairs": [("20R", "02L"), ("02R", "20L")], "temperatures": [(31.1, None), (34.9, None)], "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}, ], }, @@ -68,9 +68,36 @@ def test_airport_status_hides_non_focus_runways_for_key_airports(): "13:00", ) - assert "02L/20R" in text + assert "20R/02L" in text assert "02R/20L" 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():