Add localized meteorology text and filter implausible METAR temps
This commit is contained in:
@@ -205,6 +205,32 @@ function signalTone(signal?: IntradayMeteorologySignal | null) {
|
|||||||
return "blue";
|
return "blue";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function localizedText(
|
||||||
|
locale: string,
|
||||||
|
primary?: string | null,
|
||||||
|
english?: string | null,
|
||||||
|
) {
|
||||||
|
const en = String(english || "").trim();
|
||||||
|
const value = String(primary || "").trim();
|
||||||
|
if (locale === "en-US" && en) return en;
|
||||||
|
return value || en;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localizedList(
|
||||||
|
locale: string,
|
||||||
|
primary?: string[] | null,
|
||||||
|
english?: string[] | null,
|
||||||
|
) {
|
||||||
|
const en = Array.isArray(english)
|
||||||
|
? english.filter((item) => String(item || "").trim())
|
||||||
|
: [];
|
||||||
|
const value = Array.isArray(primary)
|
||||||
|
? primary.filter((item) => String(item || "").trim())
|
||||||
|
: [];
|
||||||
|
if (locale === "en-US" && en.length) return en;
|
||||||
|
return value.length ? value : en;
|
||||||
|
}
|
||||||
|
|
||||||
function getTrendMetricVisual(metric: {
|
function getTrendMetricVisual(metric: {
|
||||||
label?: string;
|
label?: string;
|
||||||
value?: string;
|
value?: string;
|
||||||
@@ -1115,14 +1141,22 @@ export function FutureForecastModal() {
|
|||||||
const meteorologySignals = Array.isArray(intradayMeteorology.signal_contributions)
|
const meteorologySignals = Array.isArray(intradayMeteorology.signal_contributions)
|
||||||
? intradayMeteorology.signal_contributions
|
? intradayMeteorology.signal_contributions
|
||||||
: [];
|
: [];
|
||||||
const invalidationRules = Array.isArray(intradayMeteorology.invalidation_rules)
|
const invalidationRules = localizedList(
|
||||||
? intradayMeteorology.invalidation_rules
|
locale,
|
||||||
: [];
|
intradayMeteorology.invalidation_rules,
|
||||||
const confirmationRules = Array.isArray(intradayMeteorology.confirmation_rules)
|
intradayMeteorology.invalidation_rules_en,
|
||||||
? intradayMeteorology.confirmation_rules
|
);
|
||||||
: [];
|
const confirmationRules = localizedList(
|
||||||
|
locale,
|
||||||
|
intradayMeteorology.confirmation_rules,
|
||||||
|
intradayMeteorology.confirmation_rules_en,
|
||||||
|
);
|
||||||
const meteorologyHeadline =
|
const meteorologyHeadline =
|
||||||
String(intradayMeteorology.headline || "").trim() ||
|
localizedText(
|
||||||
|
locale,
|
||||||
|
intradayMeteorology.headline,
|
||||||
|
intradayMeteorology.headline_en,
|
||||||
|
) ||
|
||||||
todayTradeSummaryLines[0] ||
|
todayTradeSummaryLines[0] ||
|
||||||
(locale === "en-US"
|
(locale === "en-US"
|
||||||
? "Intraday meteorology layers are still syncing; use the next observation as the anchor."
|
? "Intraday meteorology layers are still syncing; use the next observation as the anchor."
|
||||||
@@ -1650,13 +1684,17 @@ export function FutureForecastModal() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="future-v2-evidence-head">
|
<div className="future-v2-evidence-head">
|
||||||
<strong>{signal.label || "--"}</strong>
|
<strong>
|
||||||
|
{localizedText(locale, signal.label, signal.label_en) || "--"}
|
||||||
|
</strong>
|
||||||
<span>
|
<span>
|
||||||
{formatSignalDirection(signal.direction, locale)} ·{" "}
|
{formatSignalDirection(signal.direction, locale)} ·{" "}
|
||||||
{formatSignalStrength(signal.strength, locale)}
|
{formatSignalStrength(signal.strength, locale)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p>{signal.summary || "--"}</p>
|
<p>
|
||||||
|
{localizedText(locale, signal.summary, signal.summary_en) || "--"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -318,13 +318,16 @@ export interface MarketScan {
|
|||||||
|
|
||||||
export interface IntradayMeteorologySignal {
|
export interface IntradayMeteorologySignal {
|
||||||
label?: string | null;
|
label?: string | null;
|
||||||
|
label_en?: string | null;
|
||||||
direction?: "support" | "suppress" | "neutral" | string | null;
|
direction?: "support" | "suppress" | "neutral" | string | null;
|
||||||
strength?: "weak" | "medium" | "strong" | string | null;
|
strength?: "weak" | "medium" | "strong" | string | null;
|
||||||
summary?: string | null;
|
summary?: string | null;
|
||||||
|
summary_en?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IntradayMeteorology {
|
export interface IntradayMeteorology {
|
||||||
headline?: string | null;
|
headline?: string | null;
|
||||||
|
headline_en?: string | null;
|
||||||
confidence?: "low" | "medium" | "high" | string | null;
|
confidence?: "low" | "medium" | "high" | string | null;
|
||||||
base_case_bucket?: string | null;
|
base_case_bucket?: string | null;
|
||||||
upside_bucket?: string | null;
|
upside_bucket?: string | null;
|
||||||
@@ -332,7 +335,9 @@ export interface IntradayMeteorology {
|
|||||||
next_observation_time?: string | null;
|
next_observation_time?: string | null;
|
||||||
peak_window?: string | null;
|
peak_window?: string | null;
|
||||||
invalidation_rules?: string[] | null;
|
invalidation_rules?: string[] | null;
|
||||||
|
invalidation_rules_en?: string[] | null;
|
||||||
confirmation_rules?: string[] | null;
|
confirmation_rules?: string[] | null;
|
||||||
|
confirmation_rules_en?: string[] | null;
|
||||||
signal_contributions?: IntradayMeteorologySignal[] | null;
|
signal_contributions?: IntradayMeteorologySignal[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -696,15 +696,34 @@ export function getTemperatureChartData(
|
|||||||
: []
|
: []
|
||||||
: [];
|
: [];
|
||||||
const currentObservationFallback = buildCurrentObservationFallback(detail);
|
const currentObservationFallback = buildCurrentObservationFallback(detail);
|
||||||
const metarObservationSource = detail.metar_today_obs?.length
|
const minPlausibleObservationTemp = (() => {
|
||||||
? detail.metar_today_obs
|
const name = String(detail.name || "").trim().toLowerCase();
|
||||||
: detail.trend?.recent?.length
|
const icao = String(detail.risk?.icao || "").trim().toUpperCase();
|
||||||
? detail.trend.recent
|
if (name === "karachi" || name === "masroor air base" || icao === "OPKC" || icao === "OPMR") {
|
||||||
: currentObservationFallback;
|
return detail.temp_symbol === "°F" ? 41 : 5;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})();
|
||||||
|
const filterPlausibleObservations = <T extends { temp?: number | null }>(
|
||||||
|
rows?: T[] | null,
|
||||||
|
) =>
|
||||||
|
(Array.isArray(rows) ? rows : []).filter((row) => {
|
||||||
|
const value = Number(row?.temp);
|
||||||
|
if (!Number.isFinite(value)) return false;
|
||||||
|
return minPlausibleObservationTemp == null || value >= minPlausibleObservationTemp;
|
||||||
|
});
|
||||||
|
const plausibleMetarTodayObs = filterPlausibleObservations(detail.metar_today_obs);
|
||||||
|
const plausibleTrendRecent = filterPlausibleObservations(detail.trend?.recent);
|
||||||
|
const plausibleCurrentFallback = filterPlausibleObservations(currentObservationFallback);
|
||||||
|
const metarObservationSource = plausibleMetarTodayObs.length
|
||||||
|
? plausibleMetarTodayObs
|
||||||
|
: plausibleTrendRecent.length
|
||||||
|
? plausibleTrendRecent
|
||||||
|
: plausibleCurrentFallback;
|
||||||
const usingCurrentObservationFallback =
|
const usingCurrentObservationFallback =
|
||||||
!detail.metar_today_obs?.length &&
|
!plausibleMetarTodayObs.length &&
|
||||||
!detail.trend?.recent?.length &&
|
!plausibleTrendRecent.length &&
|
||||||
currentObservationFallback.length > 0;
|
plausibleCurrentFallback.length > 0;
|
||||||
const currentFallbackTag =
|
const currentFallbackTag =
|
||||||
currentObservationFallback[0]?.sourceLabel ||
|
currentObservationFallback[0]?.sourceLabel ||
|
||||||
getObservationSourceTag(detail);
|
getObservationSourceTag(detail);
|
||||||
|
|||||||
@@ -262,6 +262,7 @@ CITY_REGISTRY = {
|
|||||||
"risk_emoji": "🟡",
|
"risk_emoji": "🟡",
|
||||||
"airport_name": "真纳国际机场",
|
"airport_name": "真纳国际机场",
|
||||||
"distance_km": 15.0,
|
"distance_km": 15.0,
|
||||||
|
"min_plausible_metar_temp_c": 5.0,
|
||||||
"warning": "市场现按 Wunderground 真纳国际机场站整度°C口径结算;海风锋、干热输送与尘霾会影响峰值兑现时间。",
|
"warning": "市场现按 Wunderground 真纳国际机场站整度°C口径结算;海风锋、干热输送与尘霾会影响峰值兑现时间。",
|
||||||
},
|
},
|
||||||
"masroor air base": {
|
"masroor air base": {
|
||||||
@@ -278,6 +279,7 @@ CITY_REGISTRY = {
|
|||||||
"risk_emoji": "🟡",
|
"risk_emoji": "🟡",
|
||||||
"airport_name": "Masroor Air Base",
|
"airport_name": "Masroor Air Base",
|
||||||
"distance_km": 0.0,
|
"distance_km": 0.0,
|
||||||
|
"min_plausible_metar_temp_c": 5.0,
|
||||||
"warning": "军事机场 METAR 锚点;公开报文可能不连续,若 OPMR 缺报则不要自动替换为 OPKC 结算口径。",
|
"warning": "军事机场 METAR 锚点;公开报文可能不连续,若 OPMR 缺报则不要自动替换为 OPKC 结算口径。",
|
||||||
},
|
},
|
||||||
"tokyo": {
|
"tokyo": {
|
||||||
|
|||||||
@@ -12,6 +12,45 @@ from src.utils.metrics import record_source_call
|
|||||||
|
|
||||||
|
|
||||||
class MetarSourceMixin:
|
class MetarSourceMixin:
|
||||||
|
def _get_min_plausible_metar_temp_c(
|
||||||
|
self, city: str, icao: Optional[str] = None
|
||||||
|
) -> Optional[float]:
|
||||||
|
normalized = str(city or "").strip().lower()
|
||||||
|
city_meta = (getattr(self, "CITY_REGISTRY", {}) or {}).get(normalized) or {}
|
||||||
|
if not city_meta and icao:
|
||||||
|
icao_upper = str(icao or "").strip().upper()
|
||||||
|
for candidate in (getattr(self, "CITY_REGISTRY", {}) or {}).values():
|
||||||
|
if str(candidate.get("icao") or "").strip().upper() == icao_upper:
|
||||||
|
city_meta = candidate
|
||||||
|
break
|
||||||
|
raw = city_meta.get("min_plausible_metar_temp_c")
|
||||||
|
try:
|
||||||
|
value = float(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return value if value > -80 else None
|
||||||
|
|
||||||
|
def _is_plausible_metar_temp_c(
|
||||||
|
self, temp_c: object, city: str, icao: Optional[str] = None
|
||||||
|
) -> bool:
|
||||||
|
try:
|
||||||
|
value = float(temp_c)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
minimum = self._get_min_plausible_metar_temp_c(city, icao)
|
||||||
|
if minimum is None:
|
||||||
|
return True
|
||||||
|
if value < minimum:
|
||||||
|
logger.warning(
|
||||||
|
"Discarding implausible METAR temperature city={} icao={} temp_c={} min_c={}",
|
||||||
|
city,
|
||||||
|
icao,
|
||||||
|
value,
|
||||||
|
minimum,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
def get_icao_code(self, city: str) -> Optional[str]:
|
def get_icao_code(self, city: str) -> Optional[str]:
|
||||||
"""根据城市名获取对应的 ICAO 机场代码"""
|
"""根据城市名获取对应的 ICAO 机场代码"""
|
||||||
normalized = city.lower().strip()
|
normalized = city.lower().strip()
|
||||||
@@ -83,6 +122,8 @@ class MetarSourceMixin:
|
|||||||
|
|
||||||
latest = data[0]
|
latest = data[0]
|
||||||
temp_c = latest.get("temp")
|
temp_c = latest.get("temp")
|
||||||
|
if temp_c is not None and not self._is_plausible_metar_temp_c(temp_c, city, icao):
|
||||||
|
temp_c = None
|
||||||
dewp_c = latest.get("dewp")
|
dewp_c = latest.get("dewp")
|
||||||
|
|
||||||
def _parse_rawob_time(obs):
|
def _parse_rawob_time(obs):
|
||||||
@@ -136,7 +177,11 @@ class MetarSourceMixin:
|
|||||||
try:
|
try:
|
||||||
if obs_dt_iter >= utc_midnight:
|
if obs_dt_iter >= utc_midnight:
|
||||||
temp_value = obs.get("temp")
|
temp_value = obs.get("temp")
|
||||||
if temp_value is not None and temp_value > max_so_far_c:
|
if (
|
||||||
|
temp_value is not None
|
||||||
|
and self._is_plausible_metar_temp_c(temp_value, city, icao)
|
||||||
|
and temp_value > max_so_far_c
|
||||||
|
):
|
||||||
max_so_far_c = temp_value
|
max_so_far_c = temp_value
|
||||||
local_report = obs_dt_iter + timedelta(seconds=utc_offset)
|
local_report = obs_dt_iter + timedelta(seconds=utc_offset)
|
||||||
max_temp_time = local_report.strftime("%H:%M")
|
max_temp_time = local_report.strftime("%H:%M")
|
||||||
@@ -157,7 +202,11 @@ class MetarSourceMixin:
|
|||||||
for index, obs in enumerate(data):
|
for index, obs in enumerate(data):
|
||||||
obs_temp = obs.get("temp")
|
obs_temp = obs.get("temp")
|
||||||
obs_dt_iter = _parse_rawob_time(obs)
|
obs_dt_iter = _parse_rawob_time(obs)
|
||||||
if obs_temp is not None and obs_dt_iter:
|
if (
|
||||||
|
obs_temp is not None
|
||||||
|
and obs_dt_iter
|
||||||
|
and self._is_plausible_metar_temp_c(obs_temp, city, icao)
|
||||||
|
):
|
||||||
local_rt = obs_dt_iter + timedelta(seconds=utc_offset)
|
local_rt = obs_dt_iter + timedelta(seconds=utc_offset)
|
||||||
time_str = local_rt.strftime("%H:%M")
|
time_str = local_rt.strftime("%H:%M")
|
||||||
if obs_dt_iter >= utc_midnight:
|
if obs_dt_iter >= utc_midnight:
|
||||||
@@ -226,9 +275,12 @@ class MetarSourceMixin:
|
|||||||
"history_hours": history_hours,
|
"history_hours": history_hours,
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
if temp is not None:
|
||||||
f"✈️ METAR {icao}: {temp:.1f}°{'F' if use_fahrenheit else 'C'} (obs: {obs_time})"
|
logger.info(
|
||||||
)
|
f"✈️ METAR {icao}: {temp:.1f}°{'F' if use_fahrenheit else 'C'} (obs: {obs_time})"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(f"✈️ METAR {icao}: temperature unavailable (obs: {obs_time})")
|
||||||
with self._metar_cache_lock:
|
with self._metar_cache_lock:
|
||||||
self._metar_cache[cache_key] = {"d": result, "t": now_ts}
|
self._metar_cache[cache_key] = {"d": result, "t": now_ts}
|
||||||
record_source_call("metar", "current", "success", (time.perf_counter() - started) * 1000.0)
|
record_source_call("metar", "current", "success", (time.perf_counter() - started) * 1000.0)
|
||||||
|
|||||||
@@ -223,10 +223,12 @@ def test_intraday_meteorology_supportive_heating_case():
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert "上修空间" in payload["headline"]
|
assert "上修空间" in payload["headline"]
|
||||||
|
assert "upside" in payload["headline_en"].lower()
|
||||||
assert payload["confidence"] == "high"
|
assert payload["confidence"] == "high"
|
||||||
assert payload["base_case_bucket"] == "40°C"
|
assert payload["base_case_bucket"] == "40°C"
|
||||||
assert payload["next_observation_time"] == "12:30"
|
assert payload["next_observation_time"] == "12:30"
|
||||||
assert any(item["direction"] == "support" for item in payload["signal_contributions"])
|
assert any(item["direction"] == "support" for item in payload["signal_contributions"])
|
||||||
|
assert all(item.get("summary_en") for item in payload["signal_contributions"])
|
||||||
|
|
||||||
|
|
||||||
def test_intraday_meteorology_suppressed_cloud_rain_case():
|
def test_intraday_meteorology_suppressed_cloud_rain_case():
|
||||||
@@ -245,8 +247,10 @@ def test_intraday_meteorology_suppressed_cloud_rain_case():
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert "压制" in payload["headline"]
|
assert "压制" in payload["headline"]
|
||||||
|
assert "capping" in payload["headline_en"].lower()
|
||||||
assert payload["confidence"] == "high"
|
assert payload["confidence"] == "high"
|
||||||
assert any("云雨" in rule for rule in payload["invalidation_rules"])
|
assert any("云雨" in rule for rule in payload["invalidation_rules"])
|
||||||
|
assert any("cloud" in rule.lower() for rule in payload["invalidation_rules_en"])
|
||||||
assert any(item["direction"] == "suppress" for item in payload["signal_contributions"])
|
assert any(item["direction"] == "suppress" for item in payload["signal_contributions"])
|
||||||
|
|
||||||
|
|
||||||
@@ -266,6 +270,7 @@ def test_intraday_meteorology_handles_sparse_observations():
|
|||||||
assert payload["confidence"] == "low"
|
assert payload["confidence"] == "low"
|
||||||
assert payload["next_observation_time"] == "08:30"
|
assert payload["next_observation_time"] == "08:30"
|
||||||
assert payload["signal_contributions"][0]["label"] == "数据完整性"
|
assert payload["signal_contributions"][0]["label"] == "数据完整性"
|
||||||
|
assert payload["signal_contributions"][0]["label_en"] == "Data completeness"
|
||||||
|
|
||||||
|
|
||||||
def test_intraday_meteorology_past_peak_case():
|
def test_intraday_meteorology_past_peak_case():
|
||||||
@@ -281,5 +286,7 @@ def test_intraday_meteorology_past_peak_case():
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert "峰值窗口已过" in payload["headline"]
|
assert "峰值窗口已过" in payload["headline"]
|
||||||
|
assert "passed" in payload["headline_en"].lower()
|
||||||
assert payload["base_case_bucket"] == "39°C"
|
assert payload["base_case_bucket"] == "39°C"
|
||||||
assert any("最终高点" in rule for rule in payload["invalidation_rules"])
|
assert any("最终高点" in rule for rule in payload["invalidation_rules"])
|
||||||
|
assert any("final" in rule.lower() for rule in payload["invalidation_rules_en"])
|
||||||
|
|||||||
+85
-1
@@ -86,6 +86,18 @@ def _fetch_nmc_current_fallback(city: str, *, use_fahrenheit: bool) -> Dict[str,
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_plausible_city_temp(city: str, value: Any, unit: str = "°C") -> bool:
|
||||||
|
temp = _sf(value)
|
||||||
|
if temp is None:
|
||||||
|
return False
|
||||||
|
meta = CITY_REGISTRY.get(str(city or "").strip().lower(), {}) or {}
|
||||||
|
min_c = _sf(meta.get("min_plausible_metar_temp_c"))
|
||||||
|
if min_c is None:
|
||||||
|
return True
|
||||||
|
min_value = min_c * 9 / 5 + 32 if str(unit or "").upper().endswith("F") else min_c
|
||||||
|
return temp >= min_value
|
||||||
|
|
||||||
|
|
||||||
def _record_analysis_cache_event(*, city: str, hit: bool, force_refresh: bool) -> None:
|
def _record_analysis_cache_event(*, city: str, hit: bool, force_refresh: bool) -> None:
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
with _ANALYSIS_CACHE_STATS_LOCK:
|
with _ANALYSIS_CACHE_STATS_LOCK:
|
||||||
@@ -1180,13 +1192,17 @@ def _add_signal(
|
|||||||
direction: str,
|
direction: str,
|
||||||
strength: str,
|
strength: str,
|
||||||
summary: str,
|
summary: str,
|
||||||
|
label_en: Optional[str] = None,
|
||||||
|
summary_en: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
signals.append(
|
signals.append(
|
||||||
{
|
{
|
||||||
"label": label,
|
"label": label,
|
||||||
|
"label_en": label_en or label,
|
||||||
"direction": direction,
|
"direction": direction,
|
||||||
"strength": strength,
|
"strength": strength,
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
|
"summary_en": summary_en or summary,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1237,26 +1253,32 @@ def _build_intraday_meteorology(data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
_add_signal(
|
_add_signal(
|
||||||
signals,
|
signals,
|
||||||
label="日内节奏",
|
label="日内节奏",
|
||||||
|
label_en="Intraday pace",
|
||||||
direction="support",
|
direction="support",
|
||||||
strength=strength,
|
strength=strength,
|
||||||
summary=f"实测较预期路径偏高 {abs(delta or 0):.1f}{unit},峰值仍有上修空间。",
|
summary=f"实测较预期路径偏高 {abs(delta or 0):.1f}{unit},峰值仍有上修空间。",
|
||||||
|
summary_en=f"Observed temperature is running {abs(delta or 0):.1f}{unit} above the expected path; the peak still has upside room.",
|
||||||
)
|
)
|
||||||
elif direction == "cold":
|
elif direction == "cold":
|
||||||
suppress_score += 2 if strength == "strong" else 1
|
suppress_score += 2 if strength == "strong" else 1
|
||||||
_add_signal(
|
_add_signal(
|
||||||
signals,
|
signals,
|
||||||
label="日内节奏",
|
label="日内节奏",
|
||||||
|
label_en="Intraday pace",
|
||||||
direction="suppress",
|
direction="suppress",
|
||||||
strength=strength,
|
strength=strength,
|
||||||
summary=f"实测较预期路径偏低 {abs(delta or 0):.1f}{unit},追更高温档需要等待后续观测确认。",
|
summary=f"实测较预期路径偏低 {abs(delta or 0):.1f}{unit},追更高温档需要等待后续观测确认。",
|
||||||
|
summary_en=f"Observed temperature is running {abs(delta or 0):.1f}{unit} below the expected path; higher buckets need confirmation from later observations.",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
_add_signal(
|
_add_signal(
|
||||||
signals,
|
signals,
|
||||||
label="日内节奏",
|
label="日内节奏",
|
||||||
|
label_en="Intraday pace",
|
||||||
direction="neutral",
|
direction="neutral",
|
||||||
strength="weak",
|
strength="weak",
|
||||||
summary="实测大体贴近当前预期路径,下一步主要看峰值窗口内是否继续抬升。",
|
summary="实测大体贴近当前预期路径,下一步主要看峰值窗口内是否继续抬升。",
|
||||||
|
summary_en="Observed temperature is broadly tracking the expected path; the next question is whether it keeps lifting through the peak window.",
|
||||||
)
|
)
|
||||||
|
|
||||||
heating_setup = str(vertical.get("heating_setup") or "").lower()
|
heating_setup = str(vertical.get("heating_setup") or "").lower()
|
||||||
@@ -1268,26 +1290,32 @@ def _build_intraday_meteorology(data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
_add_signal(
|
_add_signal(
|
||||||
signals,
|
signals,
|
||||||
label="边界层结构",
|
label="边界层结构",
|
||||||
|
label_en="Boundary-layer setup",
|
||||||
direction="support",
|
direction="support",
|
||||||
strength="strong",
|
strength="strong",
|
||||||
summary=str(vertical.get("summary_zh") or "边界层结构支持白天继续混合升温。"),
|
summary=str(vertical.get("summary_zh") or "边界层结构支持白天继续混合升温。"),
|
||||||
|
summary_en=str(vertical.get("summary_en") or "The boundary-layer setup supports continued daytime mixing and warming."),
|
||||||
)
|
)
|
||||||
elif heating_setup == "suppressed" or suppression_risk == "high":
|
elif heating_setup == "suppressed" or suppression_risk == "high":
|
||||||
suppress_score += 2
|
suppress_score += 2
|
||||||
_add_signal(
|
_add_signal(
|
||||||
signals,
|
signals,
|
||||||
label="边界层结构",
|
label="边界层结构",
|
||||||
|
label_en="Boundary-layer setup",
|
||||||
direction="suppress",
|
direction="suppress",
|
||||||
strength="strong",
|
strength="strong",
|
||||||
summary=str(vertical.get("summary_zh") or "边界层或云雨结构对午后峰值形成压制。"),
|
summary=str(vertical.get("summary_zh") or "边界层或云雨结构对午后峰值形成压制。"),
|
||||||
|
summary_en=str(vertical.get("summary_en") or "Boundary-layer or cloud/rain structure is capping the afternoon peak."),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
_add_signal(
|
_add_signal(
|
||||||
signals,
|
signals,
|
||||||
label="边界层结构",
|
label="边界层结构",
|
||||||
|
label_en="Boundary-layer setup",
|
||||||
direction="neutral",
|
direction="neutral",
|
||||||
strength="medium",
|
strength="medium",
|
||||||
summary=str(vertical.get("summary_zh") or "边界层结构暂未给出单边信号。"),
|
summary=str(vertical.get("summary_zh") or "边界层结构暂未给出单边信号。"),
|
||||||
|
summary_en=str(vertical.get("summary_en") or "The boundary-layer setup does not yet provide a one-sided signal."),
|
||||||
)
|
)
|
||||||
|
|
||||||
taf_suppression = str(taf_signal.get("suppression_level") or "").lower()
|
taf_suppression = str(taf_signal.get("suppression_level") or "").lower()
|
||||||
@@ -1309,9 +1337,11 @@ def _build_intraday_meteorology(data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
_add_signal(
|
_add_signal(
|
||||||
signals,
|
signals,
|
||||||
label="TAF 云雨扰动",
|
label="TAF 云雨扰动",
|
||||||
|
label_en="TAF cloud/rain disruption",
|
||||||
direction=direction_value,
|
direction=direction_value,
|
||||||
strength=strength,
|
strength=strength,
|
||||||
summary=str(taf_signal.get("summary_zh") or "TAF 暂未提示强云雨压温信号。"),
|
summary=str(taf_signal.get("summary_zh") or "TAF 暂未提示强云雨压温信号。"),
|
||||||
|
summary_en=str(taf_signal.get("summary_en") or "TAF does not yet flag a strong cloud/rain temperature cap."),
|
||||||
)
|
)
|
||||||
|
|
||||||
airport_delta = _sf(data.get("airport_vs_network_delta"))
|
airport_delta = _sf(data.get("airport_vs_network_delta"))
|
||||||
@@ -1324,26 +1354,32 @@ def _build_intraday_meteorology(data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
_add_signal(
|
_add_signal(
|
||||||
signals,
|
signals,
|
||||||
label="站网对比",
|
label="站网对比",
|
||||||
|
label_en="Station-network comparison",
|
||||||
direction="support",
|
direction="support",
|
||||||
strength="medium",
|
strength="medium",
|
||||||
summary=f"周边站网较机场锚点偏热 {abs(airport_delta):.1f}{unit}{f',领先点位 {leader}' if leader else ''}。",
|
summary=f"周边站网较机场锚点偏热 {abs(airport_delta):.1f}{unit}{f',领先点位 {leader}' if leader else ''}。",
|
||||||
|
summary_en=f"Nearby stations are {abs(airport_delta):.1f}{unit} warmer than the airport anchor{f'; leading site: {leader}' if leader else ''}.",
|
||||||
)
|
)
|
||||||
elif airport_delta >= 0.4:
|
elif airport_delta >= 0.4:
|
||||||
suppress_score += 1
|
suppress_score += 1
|
||||||
_add_signal(
|
_add_signal(
|
||||||
signals,
|
signals,
|
||||||
label="站网对比",
|
label="站网对比",
|
||||||
|
label_en="Station-network comparison",
|
||||||
direction="suppress",
|
direction="suppress",
|
||||||
strength="medium",
|
strength="medium",
|
||||||
summary=f"机场锚点较周边站网偏热 {abs(airport_delta):.1f}{unit},继续上修需要机场自身后续报文确认。",
|
summary=f"机场锚点较周边站网偏热 {abs(airport_delta):.1f}{unit},继续上修需要机场自身后续报文确认。",
|
||||||
|
summary_en=f"The airport anchor is {abs(airport_delta):.1f}{unit} warmer than nearby stations; further upside needs confirmation from later airport reports.",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
_add_signal(
|
_add_signal(
|
||||||
signals,
|
signals,
|
||||||
label="站网对比",
|
label="站网对比",
|
||||||
|
label_en="Station-network comparison",
|
||||||
direction="neutral",
|
direction="neutral",
|
||||||
strength="weak",
|
strength="weak",
|
||||||
summary="机场锚点与周边站网基本同步,暂不构成单独上修或下修理由。",
|
summary="机场锚点与周边站网基本同步,暂不构成单独上修或下修理由。",
|
||||||
|
summary_en="The airport anchor and nearby station network are broadly aligned, so this layer does not independently argue for upside or downside.",
|
||||||
)
|
)
|
||||||
|
|
||||||
peak_status = str(peak.get("status") or "").lower()
|
peak_status = str(peak.get("status") or "").lower()
|
||||||
@@ -1356,46 +1392,62 @@ def _build_intraday_meteorology(data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
)
|
)
|
||||||
if peak_status == "past":
|
if peak_status == "past":
|
||||||
headline = "峰值窗口已过,后续更偏向确认最终高点而非继续上修。"
|
headline = "峰值窗口已过,后续更偏向确认最终高点而非继续上修。"
|
||||||
|
headline_en = "The peak window has passed; the read now shifts toward confirming the final high rather than chasing further upside."
|
||||||
confidence = "high" if available_layers >= 2 else "medium"
|
confidence = "high" if available_layers >= 2 else "medium"
|
||||||
elif suppress_score >= support_score + 2:
|
elif suppress_score >= support_score + 2:
|
||||||
headline = "峰值存在云雨或结构压制,当前更偏防守高温上修。"
|
headline = "峰值存在云雨或结构压制,当前更偏防守高温上修。"
|
||||||
|
headline_en = "Cloud/rain or structural suppression is capping the peak; defend against aggressive high-temperature upside for now."
|
||||||
confidence = "high" if available_layers >= 3 else "medium"
|
confidence = "high" if available_layers >= 3 else "medium"
|
||||||
elif support_score >= suppress_score + 2:
|
elif support_score >= suppress_score + 2:
|
||||||
headline = "峰值仍有上修空间,后续重点看峰值窗口内报文能否继续抬升。"
|
headline = "峰值仍有上修空间,后续重点看峰值窗口内报文能否继续抬升。"
|
||||||
|
headline_en = "The peak still has upside room; the next check is whether reports keep lifting through the peak window."
|
||||||
confidence = "high" if available_layers >= 3 else "medium"
|
confidence = "high" if available_layers >= 3 else "medium"
|
||||||
elif available_layers == 0:
|
elif available_layers == 0:
|
||||||
headline = "关键日内层仍在补齐,先以观测锚点和下一次报文为主。"
|
headline = "关键日内层仍在补齐,先以观测锚点和下一次报文为主。"
|
||||||
|
headline_en = "Key intraday layers are still filling in; anchor the read on observations and the next report."
|
||||||
confidence = "low"
|
confidence = "low"
|
||||||
else:
|
else:
|
||||||
headline = "当前处于分歧判断区,峰值窗口内的下一组观测将决定方向。"
|
headline = "当前处于分歧判断区,峰值窗口内的下一组观测将决定方向。"
|
||||||
|
headline_en = "The setup is in a split-decision zone; the next observations inside the peak window should decide direction."
|
||||||
confidence = "medium" if available_layers >= 2 else "low"
|
confidence = "medium" if available_layers >= 2 else "low"
|
||||||
|
|
||||||
next_observation = _next_observation_clock(data.get("local_time") or current.get("obs_time"))
|
next_observation = _next_observation_clock(data.get("local_time") or current.get("obs_time"))
|
||||||
threshold = base_value
|
threshold = base_value
|
||||||
invalidation_rules = []
|
invalidation_rules = []
|
||||||
|
invalidation_rules_en = []
|
||||||
confirmation_rules = []
|
confirmation_rules = []
|
||||||
|
confirmation_rules_en = []
|
||||||
if peak_status == "past":
|
if peak_status == "past":
|
||||||
invalidation_rules.append("若后续官方结算源补录更高值,以结算源最终高点为准。")
|
invalidation_rules.append("若后续官方结算源补录更高值,以结算源最终高点为准。")
|
||||||
|
invalidation_rules_en.append("If the official settlement source later backfills a higher reading, defer to the final settlement-source high.")
|
||||||
confirmation_rules.append("若峰值窗口后连续两次观测不再创新高,当前高点基本确认。")
|
confirmation_rules.append("若峰值窗口后连续两次观测不再创新高,当前高点基本确认。")
|
||||||
|
confirmation_rules_en.append("If two consecutive post-peak observations fail to make a new high, the current high is broadly confirmed.")
|
||||||
else:
|
else:
|
||||||
watch_clock = _format_clock_minutes(int(first_h or 13) * 60 + 30)
|
watch_clock = _format_clock_minutes(int(first_h or 13) * 60 + 30)
|
||||||
if threshold is not None:
|
if threshold is not None:
|
||||||
invalidation_rules.append(f"{watch_clock} 前若仍未接近 {threshold:.0f}{unit},上修路径降级。")
|
invalidation_rules.append(f"{watch_clock} 前若仍未接近 {threshold:.0f}{unit},上修路径降级。")
|
||||||
|
invalidation_rules_en.append(f"If observations are still not near {threshold:.0f}{unit} before {watch_clock}, downgrade the upside path.")
|
||||||
confirmation_rules.append(f"峰值窗口内任一结算源观测触达或超过 {threshold:.0f}{unit},基准路径确认度上升。")
|
confirmation_rules.append(f"峰值窗口内任一结算源观测触达或超过 {threshold:.0f}{unit},基准路径确认度上升。")
|
||||||
|
confirmation_rules_en.append(f"If any settlement-source observation reaches or exceeds {threshold:.0f}{unit} inside the peak window, confidence in the base path rises.")
|
||||||
invalidation_rules.append("若 TAF 或实况报文出现阵雨、雷暴或低云/云雨压制,高温上沿需要下调。")
|
invalidation_rules.append("若 TAF 或实况报文出现阵雨、雷暴或低云/云雨压制,高温上沿需要下调。")
|
||||||
|
invalidation_rules_en.append("If TAF or live reports show showers, thunderstorms, or low-cloud/cloud-rain suppression, lower the upper temperature bound.")
|
||||||
confirmation_rules.append("若实测继续贴近 DEB 曲线且云雨信号不增强,维持当前主路径。")
|
confirmation_rules.append("若实测继续贴近 DEB 曲线且云雨信号不增强,维持当前主路径。")
|
||||||
|
confirmation_rules_en.append("If observations keep tracking the DEB curve and cloud/rain signals do not strengthen, maintain the current main path.")
|
||||||
|
|
||||||
if not signals:
|
if not signals:
|
||||||
_add_signal(
|
_add_signal(
|
||||||
signals,
|
signals,
|
||||||
label="数据完整性",
|
label="数据完整性",
|
||||||
|
label_en="Data completeness",
|
||||||
direction="neutral",
|
direction="neutral",
|
||||||
strength="weak",
|
strength="weak",
|
||||||
summary="当前缺少足够的日内结构层,等待下一次观测刷新后再提高判断权重。",
|
summary="当前缺少足够的日内结构层,等待下一次观测刷新后再提高判断权重。",
|
||||||
|
summary_en="There are not enough intraday structure layers yet; wait for the next observation refresh before raising confidence.",
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"headline": headline,
|
"headline": headline,
|
||||||
|
"headline_en": headline_en,
|
||||||
"confidence": confidence,
|
"confidence": confidence,
|
||||||
"base_case_bucket": base_case_bucket,
|
"base_case_bucket": base_case_bucket,
|
||||||
"upside_bucket": upside_bucket,
|
"upside_bucket": upside_bucket,
|
||||||
@@ -1403,7 +1455,9 @@ def _build_intraday_meteorology(data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
"next_observation_time": next_observation,
|
"next_observation_time": next_observation,
|
||||||
"peak_window": peak_window,
|
"peak_window": peak_window,
|
||||||
"invalidation_rules": invalidation_rules[:4],
|
"invalidation_rules": invalidation_rules[:4],
|
||||||
|
"invalidation_rules_en": invalidation_rules_en[:4],
|
||||||
"confirmation_rules": confirmation_rules[:3],
|
"confirmation_rules": confirmation_rules[:3],
|
||||||
|
"confirmation_rules_en": confirmation_rules_en[:3],
|
||||||
"signal_contributions": signals[:5],
|
"signal_contributions": signals[:5],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1505,10 +1559,16 @@ def _analyze(
|
|||||||
current_station_code = settlement_current.get("station_code")
|
current_station_code = settlement_current.get("station_code")
|
||||||
current_station_name = settlement_current.get("station_name")
|
current_station_name = settlement_current.get("station_name")
|
||||||
cur_temp = _sf(primary_current.get("temp"))
|
cur_temp = _sf(primary_current.get("temp"))
|
||||||
|
if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym):
|
||||||
|
cur_temp = None
|
||||||
if cur_temp is None:
|
if cur_temp is None:
|
||||||
cur_temp = _sf(mc.get("temp"))
|
cur_temp = _sf(mc.get("temp"))
|
||||||
|
if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym):
|
||||||
|
cur_temp = None
|
||||||
if cur_temp is None:
|
if cur_temp is None:
|
||||||
cur_temp = _sf(mg_cur.get("temp"))
|
cur_temp = _sf(mg_cur.get("temp"))
|
||||||
|
if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym):
|
||||||
|
cur_temp = None
|
||||||
if cur_temp is None:
|
if cur_temp is None:
|
||||||
nmc_fallback = _fetch_nmc_current_fallback(city, use_fahrenheit=is_f)
|
nmc_fallback = _fetch_nmc_current_fallback(city, use_fahrenheit=is_f)
|
||||||
nmc_cur = nmc_fallback.get("current") or {}
|
nmc_cur = nmc_fallback.get("current") or {}
|
||||||
@@ -1521,10 +1581,16 @@ def _analyze(
|
|||||||
current_station_name = nmc_fallback.get("station_name")
|
current_station_name = nmc_fallback.get("station_name")
|
||||||
|
|
||||||
max_so_far = _sf(primary_current.get("max_temp_so_far"))
|
max_so_far = _sf(primary_current.get("max_temp_so_far"))
|
||||||
|
if max_so_far is not None and not _is_plausible_city_temp(city, max_so_far, sym):
|
||||||
|
max_so_far = None
|
||||||
if max_so_far is None:
|
if max_so_far is None:
|
||||||
max_so_far = _sf(mc.get("max_temp_so_far"))
|
max_so_far = _sf(mc.get("max_temp_so_far"))
|
||||||
|
if max_so_far is not None and not _is_plausible_city_temp(city, max_so_far, sym):
|
||||||
|
max_so_far = None
|
||||||
if max_so_far is None:
|
if max_so_far is None:
|
||||||
max_so_far = _sf(mg_cur.get("mgm_max_temp"))
|
max_so_far = _sf(mg_cur.get("mgm_max_temp"))
|
||||||
|
if max_so_far is not None and not _is_plausible_city_temp(city, max_so_far, sym):
|
||||||
|
max_so_far = None
|
||||||
if max_so_far is None:
|
if max_so_far is None:
|
||||||
max_so_far = cur_temp
|
max_so_far = cur_temp
|
||||||
|
|
||||||
@@ -1615,8 +1681,14 @@ def _analyze(
|
|||||||
metar_today_obs_payload = [
|
metar_today_obs_payload = [
|
||||||
{"time": t, "temp": v}
|
{"time": t, "temp": v}
|
||||||
for t, v in (metar.get("today_obs", []) if metar else [])
|
for t, v in (metar.get("today_obs", []) if metar else [])
|
||||||
|
if _is_plausible_city_temp(city, v, sym)
|
||||||
|
]
|
||||||
|
metar_recent_obs_payload = [
|
||||||
|
point
|
||||||
|
for point in (metar.get("recent_obs", []) if metar else [])
|
||||||
|
if isinstance(point, dict)
|
||||||
|
and _is_plausible_city_temp(city, point.get("temp"), sym)
|
||||||
]
|
]
|
||||||
metar_recent_obs_payload = metar.get("recent_obs", []) if metar else []
|
|
||||||
airport_max_so_far = None
|
airport_max_so_far = None
|
||||||
airport_max_temp_time = None
|
airport_max_temp_time = None
|
||||||
for point in metar_today_obs_payload:
|
for point in metar_today_obs_payload:
|
||||||
@@ -2384,10 +2456,16 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]:
|
|||||||
current_source_label = settlement_source_label
|
current_source_label = settlement_source_label
|
||||||
nmc_fallback: Dict[str, Any] = {}
|
nmc_fallback: Dict[str, Any] = {}
|
||||||
cur_temp = _sf(primary_current.get("temp"))
|
cur_temp = _sf(primary_current.get("temp"))
|
||||||
|
if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym):
|
||||||
|
cur_temp = None
|
||||||
if cur_temp is None:
|
if cur_temp is None:
|
||||||
cur_temp = _sf(mc.get("temp"))
|
cur_temp = _sf(mc.get("temp"))
|
||||||
|
if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym):
|
||||||
|
cur_temp = None
|
||||||
if cur_temp is None:
|
if cur_temp is None:
|
||||||
cur_temp = _sf(mg_cur.get("temp"))
|
cur_temp = _sf(mg_cur.get("temp"))
|
||||||
|
if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym):
|
||||||
|
cur_temp = None
|
||||||
if cur_temp is None:
|
if cur_temp is None:
|
||||||
nmc_fallback = _fetch_nmc_current_fallback(city, use_fahrenheit=is_f)
|
nmc_fallback = _fetch_nmc_current_fallback(city, use_fahrenheit=is_f)
|
||||||
nmc_cur = nmc_fallback.get("current") or {}
|
nmc_cur = nmc_fallback.get("current") or {}
|
||||||
@@ -2398,10 +2476,16 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]:
|
|||||||
current_source_label = "NMC"
|
current_source_label = "NMC"
|
||||||
|
|
||||||
max_so_far = _sf(primary_current.get("max_temp_so_far"))
|
max_so_far = _sf(primary_current.get("max_temp_so_far"))
|
||||||
|
if max_so_far is not None and not _is_plausible_city_temp(city, max_so_far, sym):
|
||||||
|
max_so_far = None
|
||||||
if max_so_far is None:
|
if max_so_far is None:
|
||||||
max_so_far = _sf(mc.get("max_temp_so_far"))
|
max_so_far = _sf(mc.get("max_temp_so_far"))
|
||||||
|
if max_so_far is not None and not _is_plausible_city_temp(city, max_so_far, sym):
|
||||||
|
max_so_far = None
|
||||||
if max_so_far is None:
|
if max_so_far is None:
|
||||||
max_so_far = _sf(mg_cur.get("mgm_max_temp"))
|
max_so_far = _sf(mg_cur.get("mgm_max_temp"))
|
||||||
|
if max_so_far is not None and not _is_plausible_city_temp(city, max_so_far, sym):
|
||||||
|
max_so_far = None
|
||||||
if max_so_far is None:
|
if max_so_far is None:
|
||||||
max_so_far = cur_temp
|
max_so_far = cur_temp
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user