feat: implement scan terminal dashboard system and supporting services

This commit is contained in:
2569718930@qq.com
2026-05-28 08:24:50 +08:00
parent 725727d763
commit 5d784f78b9
27 changed files with 263 additions and 961 deletions
-19
View File
@@ -3,7 +3,6 @@ from __future__ import annotations
from datetime import datetime, timezone, timedelta
from typing import Any, Dict, List, Optional, Tuple
from src.analysis.metar_narrator import describe_metar_report
from src.analysis.trend_engine import analyze_weather_trend
from src.data_collection.city_registry import ALIASES, CITY_REGISTRY
from src.data_collection.city_risk_profiles import get_city_risk_profile
@@ -578,23 +577,5 @@ def build_city_query_report(
for line in feature_str.split("\n"):
if line.strip():
msg_lines.append(f"- {line.strip()}")
metar_narrative = describe_metar_report(
raw_metar=str(amos_raw_metar or primary_current.get("raw_metar") or metar_current.get("raw_metar") or ""),
temp_symbol=temp_symbol,
fallback={
"icao": metar.get("icao"),
"station_name": metar.get("station_name"),
"temp": cur_temp,
"wind_speed_kt": _sf(primary_current.get("wind_speed_kt")),
"wind_dir": _sf(primary_current.get("wind_dir")),
"altimeter": _sf(primary_current.get("altimeter")),
"wx_desc": primary_current.get("wx_desc"),
"clouds": primary_current.get("clouds", []),
},
)
if metar_narrative:
msg_lines.append("\n🛰️ <b>机场报文解读</b>:")
msg_lines.append(metar_narrative)
msg_lines.append(f"\n💸 本次消耗 <b>{city_query_cost}</b> 积分。")
return "\n".join(msg_lines)
-307
View File
@@ -1,307 +0,0 @@
from __future__ import annotations
import re
from typing import Any, Dict, Iterable, Optional, Tuple
_WIND_TOKEN_RE = re.compile(r"^(VRB|\d{3})(\d{2,3})(G(\d{2,3}))?KT$")
_WIND_VAR_RE = re.compile(r"^(\d{3})V(\d{3})$")
_TEMP_DEW_RE = re.compile(r"^(M?\d{2}|//)/(M?\d{2}|//)$")
_PRESSURE_Q_RE = re.compile(r"^Q(\d{4})$")
_PRESSURE_A_RE = re.compile(r"^A(\d{4})$")
_CLOUD_RE = re.compile(r"^(FEW|SCT|BKN|OVC|VV|SKC|CLR|NSC)(\d{3})?$")
_WX_CODE_RE = re.compile(r"^[-+]?([A-Z]{2,})$")
_WIND_DIR_16 = [
"北方",
"北偏东北方向",
"东北方向",
"东偏东北方向",
"东方",
"东偏东南方向",
"东南方向",
"南偏东南方向",
"南方",
"南偏西南方向",
"西南方向",
"西偏西南方向",
"西方",
"西偏西北方向",
"西北方向",
"北偏西北方向",
]
_CLOUD_DESC = {
"CLR": "晴空",
"SKC": "晴空",
"NSC": "晴空",
"FEW": "少云",
"SCT": "多变云天",
"BKN": "多云",
"OVC": "阴天",
"VV": "低云压顶",
}
_WEATHER_DESC = {
"RA": "有降雨",
"DZ": "有毛毛雨",
"SN": "有降雪",
"TS": "有雷暴",
"TSRA": "有雷阵雨",
"FG": "有雾",
"BR": "有轻雾",
"HZ": "有霾",
"SHRA": "有阵雨",
"FZRA": "有冻雨",
}
def _safe_float(value: Any) -> Optional[float]:
if value is None:
return None
try:
return float(value)
except Exception:
return None
def _parse_metar_signed_temp(raw: str) -> Optional[float]:
if raw in {"", "//"}:
return None
sign = -1.0 if raw.startswith("M") else 1.0
value = raw[1:] if raw.startswith("M") else raw
try:
return sign * float(int(value))
except Exception:
return None
def _pick_station(tokens: Iterable[str]) -> str:
token_list = list(tokens)
if not token_list:
return ""
first = token_list[0]
if first in {"METAR", "SPECI"} and len(token_list) >= 2:
first = token_list[1]
if re.fullmatch(r"[A-Z]{4}", first):
return first
return ""
def _direction_desc(direction_deg: float) -> str:
idx = int(((direction_deg % 360) + 11.25) // 22.5) % 16
return _WIND_DIR_16[idx]
def _wind_level_desc(ms: float) -> str:
if ms < 0.3:
return "静风"
if ms < 1.6:
return "软风"
if ms < 3.4:
return "轻风"
if ms < 5.5:
return "微风"
if ms < 8.0:
return "和风"
if ms < 10.8:
return "清劲风"
if ms < 13.9:
return "强风"
if ms < 17.2:
return "疾风"
return "大风"
def _format_temp(temp: float, symbol: str) -> str:
rounded = round(temp, 1)
if abs(rounded - round(rounded)) < 0.05:
body = str(int(round(rounded)))
else:
body = f"{rounded:.1f}"
if rounded > 0:
body = f"+{body}"
return f"{body}{symbol}"
def _format_ms(ms: float) -> str:
rounded = round(ms, 1)
if abs(rounded - round(rounded)) < 0.05:
return str(int(round(rounded)))
return f"{rounded:.1f}"
def _pressure_desc(hpa: float) -> str:
hp = round(hpa)
if hp < 1000:
return f"偏低气压({hp} hPa)"
if hp > 1030:
return f"偏高气压({hp} hPa)"
return f"在正常范围内的大气压({hp} hPa)"
def _best_cloud_code(tokens: Iterable[str], fallback_clouds: Any) -> str:
best = ""
rank = {"CLR": 0, "SKC": 0, "NSC": 0, "FEW": 1, "SCT": 2, "BKN": 3, "OVC": 4, "VV": 5}
best_rank = -1
for token in tokens:
m = _CLOUD_RE.match(token)
if not m:
continue
code = m.group(1)
score = rank.get(code, -1)
if score > best_rank:
best_rank = score
best = code
if best:
return best
if isinstance(fallback_clouds, list):
for row in fallback_clouds:
if not isinstance(row, dict):
continue
code = str(row.get("cover") or "").upper().strip()
if not code:
continue
score = rank.get(code, -1)
if score > best_rank:
best_rank = score
best = code
return best
def describe_metar_report(
raw_metar: str,
temp_symbol: str = "°C",
fallback: Optional[Dict[str, Any]] = None,
) -> str:
"""
Convert METAR bulletin into deterministic human-language description.
Style is inspired by rp5 bulletin narration: temperature, cloud, pressure, wind.
"""
fallback = fallback or {}
raw = str(raw_metar or "").strip().upper()
tokens = [token for token in raw.split() if token]
if not tokens and not fallback:
return ""
station = _pick_station(tokens) or str(fallback.get("icao") or "").upper().strip()
station_name = str(fallback.get("station_name") or "").strip()
wind_dir = _safe_float(fallback.get("wind_dir"))
wind_kt = _safe_float(fallback.get("wind_speed_kt"))
wind_var: Optional[Tuple[float, float]] = None
for token in tokens:
m = _WIND_TOKEN_RE.match(token)
if not m:
continue
dir_token = m.group(1)
spd_token = m.group(2)
if dir_token != "VRB":
wind_dir = _safe_float(dir_token)
wind_kt = _safe_float(spd_token)
break
for token in tokens:
mv = _WIND_VAR_RE.match(token)
if mv:
left = _safe_float(mv.group(1))
right = _safe_float(mv.group(2))
if left is not None and right is not None:
wind_var = (left, right)
break
temp_c = None
for token in tokens:
tm = _TEMP_DEW_RE.match(token)
if tm:
temp_c = _parse_metar_signed_temp(tm.group(1))
break
fallback_temp = _safe_float(fallback.get("temp"))
if temp_c is None and fallback_temp is not None:
temp_c = fallback_temp if temp_symbol == "°C" else (fallback_temp - 32.0) * 5.0 / 9.0
pressure_hpa = None
for token in tokens:
qm = _PRESSURE_Q_RE.match(token)
if qm:
pressure_hpa = _safe_float(qm.group(1))
break
am = _PRESSURE_A_RE.match(token)
if am:
inhg = _safe_float(am.group(1))
if inhg is not None:
pressure_hpa = (inhg / 100.0) * 33.8639
break
if pressure_hpa is None:
altim = _safe_float(fallback.get("altimeter"))
if altim is not None:
pressure_hpa = altim * 33.8639 if altim < 200 else altim
cloud_code = _best_cloud_code(tokens, fallback.get("clouds"))
cloud_desc = _CLOUD_DESC.get(cloud_code, "")
wx_desc = ""
wx_raw = str(fallback.get("wx_desc") or "").upper().strip()
if wx_raw:
for key, value in _WEATHER_DESC.items():
if key in wx_raw:
wx_desc = value
break
if not wx_desc:
for token in tokens:
if not _WX_CODE_RE.match(token):
continue
for key, value in _WEATHER_DESC.items():
if key in token:
wx_desc = value
break
if wx_desc:
break
station_label = ""
if station:
station_label = f"{station} 机场"
elif station_name:
station_label = station_name
else:
station_label = "机场"
parts = []
if temp_c is not None:
display_temp = temp_c if temp_symbol == "°C" else temp_c * 9.0 / 5.0 + 32.0
parts.append(f"{station_label} {_format_temp(display_temp, temp_symbol)}")
else:
parts.append(station_label)
if cloud_desc:
parts.append(cloud_desc)
if pressure_hpa is not None:
parts.append(_pressure_desc(pressure_hpa))
if wind_kt is not None:
wind_ms = float(wind_kt) * 0.514444
wind_level = _wind_level_desc(wind_ms)
if wind_dir is not None:
wind_sentence = (
f"{_direction_desc(wind_dir)}吹来的{wind_level}"
f"({_format_ms(wind_ms)}米/秒)"
)
else:
wind_sentence = f"{wind_level}({_format_ms(wind_ms)}米/秒)"
if wind_var is not None:
left, right = wind_var
wind_sentence += (
f",风向在{_direction_desc(left)}{_direction_desc(right)}之间摆动"
)
parts.append(wind_sentence)
if wx_desc:
parts.append(wx_desc)
if "NOSIG" in tokens:
parts.append("短时无显著变化")
text = "".join([p for p in parts if str(p or "").strip()])
return f"{text}" if text else ""