Add dashboard prewarm worker and cache visibility

This commit is contained in:
2569718930@qq.com
2026-04-08 06:53:49 +08:00
parent 0bb3b573e1
commit c3da29c09c
21 changed files with 1120 additions and 149 deletions
+11
View File
@@ -34,6 +34,17 @@ services:
# UID/GID are mainly useful on Linux hosts to avoid root-owned output files. # UID/GID are mainly useful on Linux hosts to avoid root-owned output files.
user: "${UID:-1000}:${GID:-1000}" user: "${UID:-1000}:${GID:-1000}"
polyweather_prewarm:
<<: *polyweather-base
container_name: polyweather_prewarm
restart: unless-stopped
profiles: ["workers"]
command: python scripts/prewarm_dashboard_worker.py --include-detail --include-market
volumes:
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
user: "${UID:-1000}:${GID:-1000}"
polyweather_prometheus: polyweather_prometheus:
image: prom/prometheus:v3.4.1 image: prom/prometheus:v3.4.1
container_name: polyweather_prometheus container_name: polyweather_prometheus
+5
View File
@@ -0,0 +1,5 @@
import { DashboardShellSkeleton } from "@/components/dashboard/DashboardShellSkeleton";
export default function Loading() {
return <DashboardShellSkeleton />;
}
@@ -1,6 +1,7 @@
"use client"; "use client";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { DashboardShellSkeleton } from "@/components/dashboard/DashboardShellSkeleton";
const PolyWeatherDashboard = dynamic( const PolyWeatherDashboard = dynamic(
() => () =>
@@ -9,6 +10,7 @@ const PolyWeatherDashboard = dynamic(
), ),
{ {
ssr: false, ssr: false,
loading: () => <DashboardShellSkeleton />,
}, },
); );
@@ -0,0 +1,105 @@
"use client";
import { Skeleton } from "@/components/ui/skeleton";
export function DashboardShellSkeleton() {
return (
<div
style={{
background:
"radial-gradient(circle at top, rgba(30,41,59,0.45), rgba(2,6,23,0.98) 55%)",
height: "100vh",
overflow: "hidden",
position: "relative",
width: "100vw",
}}
>
<div
style={{
alignItems: "center",
backdropFilter: "blur(16px)",
background: "rgba(10,14,26,0.78)",
borderBottom: "1px solid rgba(99,102,241,0.15)",
display: "flex",
height: 56,
justifyContent: "space-between",
left: 0,
padding: "0 24px",
position: "fixed",
right: 0,
top: 0,
zIndex: 20,
}}
>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<Skeleton className="h-6 w-40 bg-zinc-700/60" />
<Skeleton className="h-3 w-28 bg-zinc-800/70" />
</div>
<div style={{ display: "flex", gap: 10 }}>
<Skeleton className="h-8 w-20 rounded-full bg-zinc-800/70" />
<Skeleton className="h-8 w-28 rounded-full bg-zinc-800/70" />
</div>
</div>
<div
style={{
bottom: 24,
display: "flex",
gap: 24,
left: 24,
position: "absolute",
right: 24,
top: 80,
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 14,
maxWidth: 280,
width: "22vw",
}}
>
<Skeleton className="h-10 w-40 rounded-xl bg-zinc-800/80" />
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton
key={index}
className="h-14 w-full rounded-2xl bg-zinc-900/75"
/>
))}
</div>
<div style={{ flex: 1, position: "relative" }}>
<Skeleton className="h-full w-full rounded-[28px] bg-zinc-950/55" />
{Array.from({ length: 8 }).map((_, index) => (
<Skeleton
key={index}
className="absolute rounded-full bg-cyan-500/20"
style={{
height: 18,
left: `${10 + index * 10}%`,
top: `${20 + ((index * 9) % 45)}%`,
width: 18,
}}
/>
))}
</div>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 14,
maxWidth: 420,
width: "30vw",
}}
>
<Skeleton className="h-16 w-full rounded-3xl bg-zinc-900/80" />
<Skeleton className="h-48 w-full rounded-3xl bg-zinc-900/70" />
<Skeleton className="h-32 w-full rounded-3xl bg-zinc-900/70" />
</div>
</div>
</div>
);
}
+128
View File
@@ -23,6 +23,27 @@ type SystemStatusPayload = {
db?: { ok?: boolean; db_path?: string }; db?: { ok?: boolean; db_path?: string };
features?: Record<string, unknown>; features?: Record<string, unknown>;
metrics?: Record<string, unknown>; metrics?: Record<string, unknown>;
cache?: {
api_cache_entries?: number;
open_meteo_forecast_entries?: number;
open_meteo_ensemble_entries?: number;
open_meteo_multi_model_entries?: number;
metar_entries?: number;
taf_entries?: number;
nmc_entries?: number;
settlement_entries?: number;
analysis?: {
total_requests?: number;
cache_hits?: number;
cache_misses?: number;
force_refresh_requests?: number;
last_cache_hit_at?: string | null;
last_cache_miss_at?: string | null;
last_city?: string | null;
hit_rate?: number | null;
miss_rate?: number | null;
};
};
probability?: { probability?: {
engine_mode?: string; engine_mode?: string;
rollout?: { rollout?: {
@@ -133,6 +154,39 @@ type SystemStatusPayload = {
} }
>; >;
}; };
prewarm?: {
enabled?: boolean;
base_url?: string;
configured_cities?: string[];
configured_city_count?: number;
interval_sec?: number;
jitter_sec?: number;
include_detail?: boolean;
include_market?: boolean;
force_refresh?: boolean;
thread_alive?: boolean;
runtime?: {
cycle_count?: number;
success_count?: number;
failure_count?: number;
last_started_at?: string | null;
last_finished_at?: string | null;
last_duration_sec?: number | null;
last_success?: boolean | null;
last_http_status?: number | null;
last_error?: string | null;
last_requested_cities?: string[];
last_requested_city_count?: number;
last_include_detail?: boolean;
last_include_market?: boolean;
last_force_refresh?: boolean;
last_warmed_count?: number;
last_summary_ok?: number;
last_detail_ok?: number;
last_market_ok?: number;
last_failed_count?: number;
};
};
}; };
type PaymentRuntimePayload = { type PaymentRuntimePayload = {
@@ -433,6 +487,10 @@ export function OpsDashboard() {
const modelCityCoverage = trainingData?.model_city_coverage; const modelCityCoverage = trainingData?.model_city_coverage;
const trainingArtifacts = trainingData?.artifacts; const trainingArtifacts = trainingData?.artifacts;
const stationNetworks = status?.station_networks; const stationNetworks = status?.station_networks;
const cacheSummary = status?.cache;
const analysisCache = cacheSummary?.analysis;
const prewarm = status?.prewarm;
const prewarmRuntime = prewarm?.runtime;
const truthSources = Object.entries(truthRecords?.source_counts || {}); const truthSources = Object.entries(truthRecords?.source_counts || {});
const providerRows = useMemo( const providerRows = useMemo(
() => () =>
@@ -585,6 +643,7 @@ export function OpsDashboard() {
const opsNav = [ const opsNav = [
{ id: "overview", label: "总览" }, { id: "overview", label: "总览" },
{ id: "station-networks", label: "站网覆盖" }, { id: "station-networks", label: "站网覆盖" },
{ id: "prewarm", label: "预热缓存" },
{ id: "funnel", label: "转化漏斗" }, { id: "funnel", label: "转化漏斗" },
{ id: "probability", label: "EMOS 门禁" }, { id: "probability", label: "EMOS 门禁" },
{ id: "training-data", label: "训练数据" }, { id: "training-data", label: "训练数据" },
@@ -746,6 +805,75 @@ export function OpsDashboard() {
</Card> </Card>
</section> </section>
<section id="prewarm" className="grid gap-4 xl:grid-cols-[1fr_1fr]">
<Card>
<CardHeader>
<CardTitle> Worker</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4 text-sm text-slate-300">
<div className="grid gap-3 sm:grid-cols-2">
<MobileField label="enabled" value={prewarm?.enabled ? "true" : "false"} mono />
<MobileField label="thread_alive" value={prewarm?.thread_alive ? "true" : "false"} mono />
<MobileField label="interval_sec" value={String(prewarm?.interval_sec ?? 0)} mono />
<MobileField label="configured cities" value={String(prewarm?.configured_city_count ?? 0)} mono />
</div>
<div className="space-y-2 rounded-2xl border border-slate-800 bg-slate-950/70 p-3">
<div className="flex justify-between gap-3"><span></span><span>{formatDateTime(prewarmRuntime?.last_started_at)}</span></div>
<div className="flex justify-between gap-3"><span></span><span>{formatDateTime(prewarmRuntime?.last_finished_at)}</span></div>
<div className="flex justify-between gap-3"><span></span><span>{prewarmRuntime?.last_duration_sec ?? "-"} s</span></div>
<div className="flex justify-between gap-3"><span></span><span>{prewarmRuntime?.last_success === true ? "success" : prewarmRuntime?.last_success === false ? "failed" : "-"}</span></div>
<div className="flex justify-between gap-3"><span> HTTP</span><span>{prewarmRuntime?.last_http_status ?? "-"}</span></div>
<div className="flex justify-between gap-3"><span></span><span>{prewarmRuntime?.last_requested_city_count ?? 0}</span></div>
<div className="flex justify-between gap-3"><span></span><span>{prewarmRuntime?.last_warmed_count ?? 0}</span></div>
<div className="flex justify-between gap-3"><span>summary / detail / market</span><span>{prewarmRuntime?.last_summary_ok ?? 0} / {prewarmRuntime?.last_detail_ok ?? 0} / {prewarmRuntime?.last_market_ok ?? 0}</span></div>
<div className="flex justify-between gap-3"><span></span><span>{prewarmRuntime?.last_failed_count ?? 0}</span></div>
<div className="flex justify-between gap-3"><span> cycle</span><span>{prewarmRuntime?.cycle_count ?? 0}</span></div>
<div className="flex justify-between gap-3"><span>success / failed</span><span>{prewarmRuntime?.success_count ?? 0} / {prewarmRuntime?.failure_count ?? 0}</span></div>
</div>
{prewarmRuntime?.last_error ? (
<div className="rounded-2xl border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-xs text-rose-300">
{prewarmRuntime.last_error}
</div>
) : null}
<div className="rounded-2xl border border-dashed border-slate-700 bg-slate-950/40 px-3 py-2 text-xs text-slate-500">
{(prewarmRuntime?.last_requested_cities || prewarm?.configured_cities || []).slice(0, 12).join(", ") || "未配置"}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4 text-sm text-slate-300">
<div className="grid gap-3 sm:grid-cols-2">
<MobileField label="api cache" value={String(cacheSummary?.api_cache_entries ?? 0)} mono />
<MobileField label="metar" value={String(cacheSummary?.metar_entries ?? 0)} mono />
<MobileField label="taf" value={String(cacheSummary?.taf_entries ?? 0)} mono />
<MobileField label="nmc" value={String(cacheSummary?.nmc_entries ?? 0)} mono />
<MobileField label="settlement" value={String(cacheSummary?.settlement_entries ?? 0)} mono />
<MobileField label="om forecast" value={String(cacheSummary?.open_meteo_forecast_entries ?? 0)} mono />
<MobileField label="om ensemble" value={String(cacheSummary?.open_meteo_ensemble_entries ?? 0)} mono />
<MobileField label="om multi" value={String(cacheSummary?.open_meteo_multi_model_entries ?? 0)} mono />
</div>
<div className="space-y-2 rounded-2xl border border-slate-800 bg-slate-950/70 p-3">
<div className="flex justify-between gap-3"><span>summary </span><span>{analysisCache?.total_requests ?? 0}</span></div>
<div className="flex justify-between gap-3"><span>cache hit / miss</span><span>{analysisCache?.cache_hits ?? 0} / {analysisCache?.cache_misses ?? 0}</span></div>
<div className="flex justify-between gap-3"><span>hit rate</span><span>{typeof analysisCache?.hit_rate === "number" ? `${(analysisCache.hit_rate * 100).toFixed(1)}%` : "-"}</span></div>
<div className="flex justify-between gap-3"><span>force refresh</span><span>{analysisCache?.force_refresh_requests ?? 0}</span></div>
<div className="flex justify-between gap-3"><span> hit</span><span>{formatDateTime(analysisCache?.last_cache_hit_at)}</span></div>
<div className="flex justify-between gap-3"><span> miss</span><span>{formatDateTime(analysisCache?.last_cache_miss_at)}</span></div>
<div className="flex justify-between gap-3"><span></span><span>{analysisCache?.last_city || "-"}</span></div>
</div>
<div className="rounded-2xl border border-dashed border-slate-700 bg-slate-950/40 px-3 py-2 text-xs text-slate-500">
summary cache hit/missdetail / market
</div>
</CardContent>
</Card>
</section>
{error ? ( {error ? (
<Card className="border-rose-500/30 bg-rose-500/10"> <Card className="border-rose-500/30 bg-rose-500/10">
<CardHeader> <CardHeader>
+1
View File
@@ -1,4 +1,5 @@
requests requests
httpx
loguru loguru
pyTelegramBotAPI pyTelegramBotAPI
python-dotenv python-dotenv
+49
View File
@@ -0,0 +1,49 @@
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
def parse_args() -> argparse.Namespace:
from src.utils.prewarm_dashboard import DEFAULT_CITIES
parser = argparse.ArgumentParser(
description="Prewarm PolyWeather summary/detail/market caches for selected cities.",
)
parser.add_argument(
"--base-url",
default=os.getenv("POLYWEATHER_BACKEND_URL", "http://127.0.0.1:8000"),
help="Backend base URL, defaults to POLYWEATHER_BACKEND_URL or http://127.0.0.1:8000",
)
parser.add_argument(
"--cities",
default=",".join(DEFAULT_CITIES),
help="Comma-separated city names to prewarm",
)
parser.add_argument("--force-refresh", action="store_true")
parser.add_argument("--include-detail", action="store_true")
parser.add_argument("--include-market", action="store_true")
return parser.parse_args()
def main() -> int:
args = parse_args()
from src.utils.prewarm_dashboard import run_prewarm
return run_prewarm(
base_url=args.base_url,
cities=args.cities,
force_refresh=bool(args.force_refresh),
include_detail=bool(args.include_detail),
include_market=bool(args.include_market),
)
if __name__ == "__main__":
raise SystemExit(main())
+65
View File
@@ -0,0 +1,65 @@
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
def parse_args() -> argparse.Namespace:
from src.utils.prewarm_dashboard import DEFAULT_CITIES
parser = argparse.ArgumentParser(
description="Run a background dashboard prewarm worker for hot PolyWeather cities.",
)
parser.add_argument(
"--base-url",
default=os.getenv("POLYWEATHER_BACKEND_URL", "http://127.0.0.1:8000"),
help="Backend base URL, defaults to POLYWEATHER_BACKEND_URL or http://127.0.0.1:8000",
)
parser.add_argument(
"--cities",
default=",".join(DEFAULT_CITIES),
help="Comma-separated city names to prewarm",
)
parser.add_argument(
"--interval-sec",
type=int,
default=int(os.getenv("POLYWEATHER_PREWARM_INTERVAL_SEC", "300")),
help="Worker interval in seconds",
)
parser.add_argument(
"--jitter-sec",
type=int,
default=int(os.getenv("POLYWEATHER_PREWARM_JITTER_SEC", "20")),
help="Random jitter added to each loop in seconds",
)
parser.add_argument("--force-refresh", action="store_true")
parser.add_argument("--include-detail", action="store_true")
parser.add_argument("--include-market", action="store_true")
parser.add_argument("--once", action="store_true")
return parser.parse_args()
def main() -> int:
args = parse_args()
from src.utils.prewarm_dashboard import run_worker_loop
return run_worker_loop(
base_url=args.base_url,
cities=args.cities,
interval_sec=args.interval_sec,
jitter_sec=args.jitter_sec,
force_refresh=bool(args.force_refresh),
include_detail=bool(args.include_detail),
include_market=bool(args.include_market),
once=bool(args.once),
)
if __name__ == "__main__":
raise SystemExit(main())
+27
View File
@@ -84,6 +84,7 @@ class StartupCoordinator:
def start_all(self) -> RuntimeStatus: def start_all(self) -> RuntimeStatus:
loops = [ loops = [
self._start_trade_alert_loop(), self._start_trade_alert_loop(),
self._start_dashboard_prewarm_loop(),
self._start_polygon_wallet_loop(), self._start_polygon_wallet_loop(),
self._start_polymarket_wallet_activity_loop(), self._start_polymarket_wallet_activity_loop(),
self._start_weekly_reward_loop(), self._start_weekly_reward_loop(),
@@ -179,6 +180,32 @@ class StartupCoordinator:
), ),
) )
def _start_dashboard_prewarm_loop(self) -> LoopStatus:
enabled = _env_bool("POLYWEATHER_DASHBOARD_PREWARM_ENABLED", False)
interval = max(30, _env_int("POLYWEATHER_PREWARM_INTERVAL_SEC", 300))
jitter = max(0, _env_int("POLYWEATHER_PREWARM_JITTER_SEC", 20))
cities_count = _parse_csv_count(os.getenv("POLYWEATHER_PREWARM_CITIES")) or 14
details = {
"interval_sec": interval,
"jitter_sec": jitter,
"cities_count": cities_count,
"include_detail": _env_bool("POLYWEATHER_PREWARM_INCLUDE_DETAIL", True),
"include_market": _env_bool("POLYWEATHER_PREWARM_INCLUDE_MARKET", True),
"force_refresh": _env_bool("POLYWEATHER_PREWARM_FORCE_REFRESH", False),
"base_url": str(os.getenv("POLYWEATHER_BACKEND_URL") or "http://127.0.0.1:8000").strip(),
}
validation_error = None
if not str(os.getenv("POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN") or "").strip():
validation_error = "missing_POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN"
return self._start_with_validation(
key="dashboard_prewarm",
label="站点面板预热",
configured_enabled=enabled,
details=details,
validation_error=validation_error,
starter=lambda: import_module("src.utils.prewarm_dashboard").start_prewarm_worker_thread(),
)
def _start_polygon_wallet_loop(self) -> LoopStatus: def _start_polygon_wallet_loop(self) -> LoopStatus:
enabled = _env_bool("POLYGON_WALLET_WATCH_ENABLED", False) enabled = _env_bool("POLYGON_WALLET_WATCH_ENABLED", False)
chat_ids = get_telegram_chat_ids_from_env() chat_ids = get_telegram_chat_ids_from_env()
+3 -3
View File
@@ -5,7 +5,7 @@ import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional from typing import Dict, List, Optional
import requests import httpx
from loguru import logger from loguru import logger
from src.utils.metrics import record_source_call from src.utils.metrics import record_source_call
@@ -209,7 +209,7 @@ class MetarSourceMixin:
record_source_call("metar", "current", "success", (time.perf_counter() - started) * 1000.0) record_source_call("metar", "current", "success", (time.perf_counter() - started) * 1000.0)
return result return result
except requests.exceptions.RequestException as exc: except httpx.HTTPError as exc:
logger.error(f"METAR 请求失败 ({icao}): {exc}") logger.error(f"METAR 请求失败 ({icao}): {exc}")
with self._metar_cache_lock: with self._metar_cache_lock:
stale = self._metar_cache.get(cache_key) stale = self._metar_cache.get(cache_key)
@@ -269,7 +269,7 @@ class MetarSourceMixin:
self._taf_cache[cache_key] = {"d": result, "t": now_ts} self._taf_cache[cache_key] = {"d": result, "t": now_ts}
record_source_call("taf", "current", "success", (time.perf_counter() - started) * 1000.0) record_source_call("taf", "current", "success", (time.perf_counter() - started) * 1000.0)
return result return result
except requests.exceptions.RequestException as exc: except httpx.HTTPError as exc:
logger.error(f"TAF 请求失败 ({icao}): {exc}") logger.error(f"TAF 请求失败 ({icao}): {exc}")
with self._taf_cache_lock: with self._taf_cache_lock:
stale = self._taf_cache.get(cache_key) stale = self._taf_cache.get(cache_key)
+9 -5
View File
@@ -32,7 +32,7 @@ class MgmSourceMixin:
# 1. 实时数据 (添加时间戳防止 CDN 缓存) # 1. 实时数据 (添加时间戳防止 CDN 缓存)
import time import time
obs_resp = self.session.get( obs_resp = self._http_get(
f"{base_url}/sondurumlar?istno={istno}&_={int(time.time() * 1000)}", f"{base_url}/sondurumlar?istno={istno}&_={int(time.time() * 1000)}",
headers=headers, headers=headers,
timeout=self.timeout, timeout=self.timeout,
@@ -95,7 +95,7 @@ class MgmSourceMixin:
] ]
for forecast_url in forecast_urls: for forecast_url in forecast_urls:
try: try:
daily_resp = self.session.get( daily_resp = self._http_get(
forecast_url, headers=headers, timeout=self.timeout forecast_url, headers=headers, timeout=self.timeout
) )
if daily_resp.status_code == 200: if daily_resp.status_code == 200:
@@ -132,7 +132,7 @@ class MgmSourceMixin:
# 3. 小时预报 # 3. 小时预报
try: try:
hourly_resp = self.session.get( hourly_resp = self._http_get(
f"{base_url}/tahminler/saatlik?istno={istno}", f"{base_url}/tahminler/saatlik?istno={istno}",
headers=headers, headers=headers,
timeout=self.timeout timeout=self.timeout
@@ -246,7 +246,11 @@ class MgmSourceMixin:
try: try:
# 1. 加载测站元数据 (缓存到实例中),用于过滤属于该省份的站点 # 1. 加载测站元数据 (缓存到实例中),用于过滤属于该省份的站点
if not getattr(self, "mgm_stations_meta", None): if not getattr(self, "mgm_stations_meta", None):
meta_resp = self.session.get(f"{base_url}/istasyonlar", headers=headers, timeout=self.timeout) meta_resp = self._http_get(
f"{base_url}/istasyonlar",
headers=headers,
timeout=self.timeout,
)
if meta_resp.status_code == 200: if meta_resp.status_code == 200:
meta_json = meta_resp.json() meta_json = meta_resp.json()
if isinstance(meta_json, list): if isinstance(meta_json, list):
@@ -293,7 +297,7 @@ class MgmSourceMixin:
try: try:
# sondurumlar?istno={ist_no} 是目前最稳的获取多站数据的办法 # sondurumlar?istno={ist_no} 是目前最稳的获取多站数据的办法
url = f"{base_url}/sondurumlar?istno={ist_no}&_={int(time.time() * 1000)}" url = f"{base_url}/sondurumlar?istno={ist_no}&_={int(time.time() * 1000)}"
resp = self.session.get(url, headers=headers, timeout=5) resp = self._http_get(url, headers=headers, timeout=5)
if resp.status_code == 200: if resp.status_code == 200:
obs_list = resp.json() obs_list = resp.json()
if obs_list: if obs_list:
+16 -4
View File
@@ -45,6 +45,20 @@ NMC_CITY_REFERENCES: Dict[str, Dict[str, Any]] = {
class NmcSourceMixin: class NmcSourceMixin:
def _nmc_http_get(self, url: str):
getter = getattr(self, "_http_get", None)
if callable(getter):
return getter(url)
return self.session.get(url, timeout=self.timeout)
def _nmc_http_get_json(self, url: str):
getter = getattr(self, "_http_get_json", None)
if callable(getter):
return getter(url)
response = self.session.get(url, timeout=self.timeout)
response.raise_for_status()
return response.json()
@staticmethod @staticmethod
def _nmc_optional_text(value: Any) -> Optional[str]: def _nmc_optional_text(value: Any) -> Optional[str]:
text = str(value or "").strip() text = str(value or "").strip()
@@ -73,7 +87,7 @@ class NmcSourceMixin:
return None return None
try: try:
resp = self.session.get(page_url, timeout=self.timeout) resp = self._nmc_http_get(page_url)
resp.raise_for_status() resp.raise_for_status()
match = re.search( match = re.search(
r"renderWeatherRealPanel\('([^']+)',\s*'([^']+)'\)", r"renderWeatherRealPanel\('([^']+)',\s*'([^']+)'\)",
@@ -116,9 +130,7 @@ class NmcSourceMixin:
try: try:
url = f"https://www.nmc.cn/rest/real/{station_code}" url = f"https://www.nmc.cn/rest/real/{station_code}"
resp = self.session.get(url, timeout=self.timeout) payload = self._nmc_http_get_json(url)
resp.raise_for_status()
payload = resp.json()
if not isinstance(payload, dict) or not isinstance(payload.get("weather"), dict): if not isinstance(payload, dict) or not isinstance(payload.get("weather"), dict):
record_source_call("nmc", "current", "empty", (time.perf_counter() - started) * 1000.0) record_source_call("nmc", "current", "empty", (time.perf_counter() - started) * 1000.0)
return None return None
@@ -21,7 +21,7 @@ class NwsOpenMeteoSourceMixin:
points_url = f"https://api.weather.gov/points/{lat},{lon}" points_url = f"https://api.weather.gov/points/{lat},{lon}"
headers = {"User-Agent": "PolyWeather/1.0 (weather-bot)"} headers = {"User-Agent": "PolyWeather/1.0 (weather-bot)"}
points_resp = self.session.get( points_resp = self._http_get(
points_url, headers=headers, timeout=self.timeout points_url, headers=headers, timeout=self.timeout
) )
points_resp.raise_for_status() points_resp.raise_for_status()
@@ -35,7 +35,7 @@ class NwsOpenMeteoSourceMixin:
return None return None
# 2. 获取预报 # 2. 获取预报
forecast_resp = self.session.get( forecast_resp = self._http_get(
forecast_url, headers=headers, timeout=self.timeout forecast_url, headers=headers, timeout=self.timeout
) )
forecast_resp.raise_for_status() forecast_resp.raise_for_status()
@@ -48,7 +48,7 @@ class NwsOpenMeteoSourceMixin:
hourly_periods = [] hourly_periods = []
if hourly_url: if hourly_url:
hourly_resp = self.session.get( hourly_resp = self._http_get(
hourly_url, headers=headers, timeout=self.timeout hourly_url, headers=headers, timeout=self.timeout
) )
hourly_resp.raise_for_status() hourly_resp.raise_for_status()
@@ -57,7 +57,7 @@ class NwsOpenMeteoSourceMixin:
active_alerts = [] active_alerts = []
try: try:
alerts_resp = self.session.get( alerts_resp = self._http_get(
"https://api.weather.gov/alerts/active", "https://api.weather.gov/alerts/active",
params={"point": f"{lat},{lon}"}, params={"point": f"{lat},{lon}"},
headers=headers, headers=headers,
@@ -205,7 +205,7 @@ class NwsOpenMeteoSourceMixin:
params["temperature_unit"] = "celsius" params["temperature_unit"] = "celsius"
self._wait_open_meteo_slot("forecast") self._wait_open_meteo_slot("forecast")
response = self.session.get( response = self._http_get(
url, url,
params=params, params=params,
timeout=self.timeout, timeout=self.timeout,
@@ -366,7 +366,7 @@ class NwsOpenMeteoSourceMixin:
params["temperature_unit"] = "celsius" params["temperature_unit"] = "celsius"
self._wait_open_meteo_slot("ensemble") self._wait_open_meteo_slot("ensemble")
response = self.session.get( response = self._http_get(
url, url,
params=params, params=params,
timeout=self.timeout, timeout=self.timeout,
@@ -523,7 +523,7 @@ class NwsOpenMeteoSourceMixin:
params["temperature_unit"] = "fahrenheit" params["temperature_unit"] = "fahrenheit"
self._wait_open_meteo_slot("multi-model") self._wait_open_meteo_slot("multi-model")
response = self.session.get( response = self._http_get(
url, url,
params=params, params=params,
timeout=self.timeout, timeout=self.timeout,
+5 -2
View File
@@ -19,7 +19,7 @@ import unicodedata
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
import requests import httpx
from loguru import logger from loguru import logger
from src.data_collection.city_registry import ALIASES, CITY_REGISTRY from src.data_collection.city_registry import ALIASES, CITY_REGISTRY
@@ -383,7 +383,10 @@ class PolymarketReadOnlyLayer:
) )
self.edge_threshold = _safe_float(os.getenv("POLYMARKET_SIGNAL_EDGE_PCT")) or 2.0 self.edge_threshold = _safe_float(os.getenv("POLYMARKET_SIGNAL_EDGE_PCT")) or 2.0
self._session = requests.Session() self._session = httpx.Client(
timeout=self.http_timeout,
follow_redirects=True,
)
self._markets_cache: Dict[str, Dict[str, Any]] = {} self._markets_cache: Dict[str, Dict[str, Any]] = {}
self._active_markets_cache: Dict[str, Any] = {"data": [], "t": 0.0} self._active_markets_cache: Dict[str, Any] = {"data": [], "t": 0.0}
self._broad_markets_cache: Dict[str, Any] = {"data": [], "t": 0.0} self._broad_markets_cache: Dict[str, Any] = {"data": [], "t": 0.0}
+10 -10
View File
@@ -226,13 +226,13 @@ class SettlementSourceMixin:
try: try:
base = "https://data.weather.gov.hk/weatherAPI/hko_data/regional-weather" base = "https://data.weather.gov.hk/weatherAPI/hko_data/regional-weather"
temp_csv = self.session.get(f"{base}/latest_1min_temperature.csv", timeout=self.timeout) temp_csv = self._http_get(f"{base}/latest_1min_temperature.csv", timeout=self.timeout)
temp_csv.raise_for_status() temp_csv.raise_for_status()
maxmin_csv = self.session.get(f"{base}/latest_since_midnight_maxmin.csv", timeout=self.timeout) maxmin_csv = self._http_get(f"{base}/latest_since_midnight_maxmin.csv", timeout=self.timeout)
maxmin_csv.raise_for_status() maxmin_csv.raise_for_status()
humidity_csv = self.session.get(f"{base}/latest_1min_humidity.csv", timeout=self.timeout) humidity_csv = self._http_get(f"{base}/latest_1min_humidity.csv", timeout=self.timeout)
humidity_csv.raise_for_status() humidity_csv.raise_for_status()
wind_csv = self.session.get(f"{base}/latest_10min_wind.csv", timeout=self.timeout) wind_csv = self._http_get(f"{base}/latest_10min_wind.csv", timeout=self.timeout)
wind_csv.raise_for_status() wind_csv.raise_for_status()
temp_rows = self._csv_rows(temp_csv.text) temp_rows = self._csv_rows(temp_csv.text)
@@ -302,7 +302,7 @@ class SettlementSourceMixin:
try: try:
url = "https://opendata.cwa.gov.tw/api/v1/rest/datastore/O-A0003-001" url = "https://opendata.cwa.gov.tw/api/v1/rest/datastore/O-A0003-001"
response = self.session.get( response = self._http_get(
url, url,
params={"Authorization": self.cwa_open_data_auth, "format": "JSON", "StationId": "466920"}, params={"Authorization": self.cwa_open_data_auth, "format": "JSON", "StationId": "466920"},
timeout=self.timeout, timeout=self.timeout,
@@ -361,7 +361,7 @@ class SettlementSourceMixin:
def fetch_hko_forecast(self) -> Optional[float]: def fetch_hko_forecast(self) -> Optional[float]:
try: try:
url = "https://data.weather.gov.hk/weatherAPI/opendata/weather.php?dataType=fnd&lang=tc" url = "https://data.weather.gov.hk/weatherAPI/opendata/weather.php?dataType=fnd&lang=tc"
res = self.session.get(url, timeout=self.timeout).json() res = self._http_get_json(url, timeout=self.timeout)
return float(res["weatherForecast"][0]["forecastMaxtemp"]["value"]) return float(res["weatherForecast"][0]["forecastMaxtemp"]["value"])
except Exception as exc: except Exception as exc:
logger.warning(f"HKO Forecast request failed: {exc}") logger.warning(f"HKO Forecast request failed: {exc}")
@@ -372,11 +372,11 @@ class SettlementSourceMixin:
if not self.cwa_open_data_auth: if not self.cwa_open_data_auth:
return None return None
url = "https://opendata.cwa.gov.tw/api/v1/rest/datastore/F-D0047-061" url = "https://opendata.cwa.gov.tw/api/v1/rest/datastore/F-D0047-061"
res = self.session.get( res = self._http_get_json(
url, url,
params={"Authorization": self.cwa_open_data_auth, "format": "JSON", "elementName": "MaxT"}, params={"Authorization": self.cwa_open_data_auth, "format": "JSON", "elementName": "MaxT"},
timeout=self.timeout, timeout=self.timeout,
).json() )
locs = res.get("records", {}).get("Locations", [])[0].get("Location", []) locs = res.get("records", {}).get("Locations", [])[0].get("Location", [])
if not locs: if not locs:
return None return None
@@ -402,7 +402,7 @@ class SettlementSourceMixin:
return cached return cached
try: try:
response = self.session.get( response = self._http_get(
"https://api.synopticdata.com/v2/stations/timeseries", "https://api.synopticdata.com/v2/stations/timeseries",
params={ params={
"STID": normalized_station_code, "STID": normalized_station_code,
@@ -519,7 +519,7 @@ class SettlementSourceMixin:
query = {"token": self.IMGW_METEO_API_TOKEN} query = {"token": self.IMGW_METEO_API_TOKEN}
if isinstance(params, dict): if isinstance(params, dict):
query.update(params) query.update(params)
response = self.session.get( response = self._http_get(
f"{self.IMGW_METEO_API_BASE}/{path}", f"{self.IMGW_METEO_API_BASE}/{path}",
params=query, params=query,
timeout=self.timeout, timeout=self.timeout,
+82 -9
View File
@@ -1,7 +1,8 @@
import os import os
import requests import httpx
import re import re
import threading import threading
import time
from typing import Optional, Dict, List from typing import Optional, Dict, List
from datetime import datetime, timedelta from datetime import datetime, timedelta
from loguru import logger from loguru import logger
@@ -110,7 +111,17 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
self.config = config self.config = config
self.timeout = 30 # 增加超时以支持高延迟 VPS self.timeout = 30 # 增加超时以支持高延迟 VPS
self.session = requests.Session() self.http_retry_count = max(
0, int(os.getenv("POLYWEATHER_HTTP_RETRY_COUNT", "1"))
)
self.http_retry_backoff_sec = max(
0.0, float(os.getenv("POLYWEATHER_HTTP_RETRY_BACKOFF_SEC", "0.35"))
)
self.session = httpx.Client(
timeout=self.timeout,
follow_redirects=True,
limits=httpx.Limits(max_connections=50, max_keepalive_connections=20),
)
self.open_meteo_cache_ttl_sec = int( self.open_meteo_cache_ttl_sec = int(
os.getenv("OPEN_METEO_CACHE_TTL_SEC", "900") os.getenv("OPEN_METEO_CACHE_TTL_SEC", "900")
) )
@@ -186,11 +197,73 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
if proxy: if proxy:
if not proxy.startswith("http"): if not proxy.startswith("http"):
proxy = f"http://{proxy}" proxy = f"http://{proxy}"
self.session.proxies = {"http": proxy, "https": proxy} self.session = httpx.Client(
timeout=self.timeout,
follow_redirects=True,
proxy=proxy,
limits=httpx.Limits(max_connections=50, max_keepalive_connections=20),
)
logger.info(f"正在使用天气数据代理: {proxy}") logger.info(f"正在使用天气数据代理: {proxy}")
logger.info("天气数据采集器初始化完成。") logger.info("天气数据采集器初始化完成。")
@staticmethod
def _is_retryable_status(status_code: int) -> bool:
return status_code in {408, 500, 502, 503, 504}
def _http_get(self, url: str, **kwargs) -> httpx.Response:
if "timeout" not in kwargs:
kwargs["timeout"] = self.timeout
last_exc: Optional[Exception] = None
last_response: Optional[httpx.Response] = None
attempts = self.http_retry_count + 1
for attempt in range(attempts):
try:
response = self.session.get(url, **kwargs)
last_response = response
if (
attempt < attempts - 1
and self._is_retryable_status(response.status_code)
):
wait_for = self.http_retry_backoff_sec * (attempt + 1)
logger.debug(
"HTTP GET retrying url={} status={} attempt={}/{} wait={}s",
url,
response.status_code,
attempt + 1,
attempts,
round(wait_for, 2),
)
if wait_for > 0:
time.sleep(wait_for)
continue
return response
except (httpx.TimeoutException, httpx.NetworkError) as exc:
last_exc = exc
if attempt >= attempts - 1:
break
wait_for = self.http_retry_backoff_sec * (attempt + 1)
logger.debug(
"HTTP GET retrying url={} error={} attempt={}/{} wait={}s",
url,
type(exc).__name__,
attempt + 1,
attempts,
round(wait_for, 2),
)
if wait_for > 0:
time.sleep(wait_for)
if last_exc is not None:
raise last_exc
if last_response is not None:
return last_response
raise RuntimeError(f"HTTP GET failed without response: {url}")
def _http_get_json(self, url: str, **kwargs):
response = self._http_get(url, **kwargs)
response.raise_for_status()
return response.json()
def fetch_from_openweather(self, city: str, country: str = None) -> Optional[Dict]: def fetch_from_openweather(self, city: str, country: str = None) -> Optional[Dict]:
""" """
Fetch current weather and forecast from OpenWeatherMap Fetch current weather and forecast from OpenWeatherMap
@@ -210,7 +283,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
try: try:
# Current weather # Current weather
current_url = "https://api.openweathermap.org/data/2.5/weather" current_url = "https://api.openweathermap.org/data/2.5/weather"
current_response = self.session.get( current_response = self._http_get(
current_url, current_url,
params={"q": query, "appid": self.openweather_key, "units": "metric"}, params={"q": query, "appid": self.openweather_key, "units": "metric"},
timeout=self.timeout, timeout=self.timeout,
@@ -220,7 +293,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
# 5-day forecast # 5-day forecast
forecast_url = "https://api.openweathermap.org/data/2.5/forecast" forecast_url = "https://api.openweathermap.org/data/2.5/forecast"
forecast_response = self.session.get( forecast_response = self._http_get(
forecast_url, forecast_url,
params={"q": query, "appid": self.openweather_key, "units": "metric"}, params={"q": query, "appid": self.openweather_key, "units": "metric"},
timeout=self.timeout, timeout=self.timeout,
@@ -245,7 +318,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
"forecast": self._parse_openweather_forecast(forecast_data), "forecast": self._parse_openweather_forecast(forecast_data),
} }
except requests.exceptions.RequestException as e: except httpx.HTTPError as e:
logger.error(f"OpenWeatherMap request failed: {e}") logger.error(f"OpenWeatherMap request failed: {e}")
return None return None
@@ -290,7 +363,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
try: try:
url = f"https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline/{city}/{start_date}/{end_date}" url = f"https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline/{city}/{start_date}/{end_date}"
response = self.session.get( response = self._http_get(
url, url,
params={ params={
"unitGroup": "metric", "unitGroup": "metric",
@@ -322,7 +395,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
], ],
} }
except requests.exceptions.RequestException as e: except httpx.HTTPError as e:
logger.error(f"Visual Crossing request failed: {e}") logger.error(f"Visual Crossing request failed: {e}")
return None return None
@@ -399,7 +472,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
try: try:
url = "https://geocoding-api.open-meteo.com/v1/search" url = "https://geocoding-api.open-meteo.com/v1/search"
response = self.session.get( response = self._http_get(
url, url,
params={"name": city, "count": 1, "language": "en", "format": "json"}, params={"name": city, "count": 1, "language": "en", "format": "json"},
timeout=15, # 增加超时时间到 15s timeout=15, # 增加超时时间到 15s
+305
View File
@@ -0,0 +1,305 @@
from __future__ import annotations
import json
import os
import random
import threading
import time
from datetime import datetime
from typing import Any, Dict, Optional
import httpx
from loguru import logger
DEFAULT_CITIES = [
"ankara",
"istanbul",
"shanghai",
"beijing",
"shenzhen",
"wuhan",
"chengdu",
"chongqing",
"hong kong",
"taipei",
"london",
"paris",
"new york",
"los angeles",
]
_RUNTIME_LOCK = threading.Lock()
_WORKER_THREAD: Optional[threading.Thread] = None
_RUNTIME_STATE: Dict[str, Any] = {
"cycle_count": 0,
"success_count": 0,
"failure_count": 0,
"last_started_at": None,
"last_finished_at": None,
"last_duration_sec": None,
"last_success": None,
"last_http_status": None,
"last_error": None,
"last_requested_cities": [],
"last_requested_city_count": 0,
"last_include_detail": False,
"last_include_market": False,
"last_force_refresh": False,
"last_warmed_count": 0,
"last_summary_ok": 0,
"last_detail_ok": 0,
"last_market_ok": 0,
"last_failed_count": 0,
}
def _truthy_env(value: str, default: bool = False) -> bool:
raw = str(os.getenv(value) or "").strip().lower()
if not raw:
return default
return raw in {"1", "true", "yes", "on"}
def _parse_cities(value: str) -> list[str]:
items = [item.strip() for item in str(value or "").split(",")]
return [item for item in items if item]
def _update_runtime_state(**kwargs: Any) -> None:
with _RUNTIME_LOCK:
_RUNTIME_STATE.update(kwargs)
def _record_prewarm_result(
*,
ok: bool,
duration_sec: float,
http_status: Optional[int],
error: Optional[str],
warmed_count: int,
summary_ok: int,
detail_ok: int,
market_ok: int,
failed_count: int,
) -> None:
with _RUNTIME_LOCK:
_RUNTIME_STATE["cycle_count"] = int(_RUNTIME_STATE.get("cycle_count") or 0) + 1
if ok:
_RUNTIME_STATE["success_count"] = int(_RUNTIME_STATE.get("success_count") or 0) + 1
else:
_RUNTIME_STATE["failure_count"] = int(_RUNTIME_STATE.get("failure_count") or 0) + 1
_RUNTIME_STATE["last_finished_at"] = datetime.now().isoformat(timespec="seconds")
_RUNTIME_STATE["last_duration_sec"] = round(float(duration_sec), 2)
_RUNTIME_STATE["last_success"] = bool(ok)
_RUNTIME_STATE["last_http_status"] = http_status
_RUNTIME_STATE["last_error"] = error
_RUNTIME_STATE["last_warmed_count"] = int(warmed_count or 0)
_RUNTIME_STATE["last_summary_ok"] = int(summary_ok or 0)
_RUNTIME_STATE["last_detail_ok"] = int(detail_ok or 0)
_RUNTIME_STATE["last_market_ok"] = int(market_ok or 0)
_RUNTIME_STATE["last_failed_count"] = int(failed_count or 0)
def get_prewarm_runtime_summary() -> Dict[str, Any]:
configured_cities = _parse_cities(str(os.getenv("POLYWEATHER_PREWARM_CITIES") or ",".join(DEFAULT_CITIES)))
with _RUNTIME_LOCK:
runtime = dict(_RUNTIME_STATE)
return {
"enabled": _truthy_env("POLYWEATHER_DASHBOARD_PREWARM_ENABLED", False),
"base_url": str(os.getenv("POLYWEATHER_BACKEND_URL") or "http://127.0.0.1:8000").strip(),
"configured_cities": configured_cities,
"configured_city_count": len(configured_cities),
"interval_sec": max(30, int(os.getenv("POLYWEATHER_PREWARM_INTERVAL_SEC", "300"))),
"jitter_sec": max(0, int(os.getenv("POLYWEATHER_PREWARM_JITTER_SEC", "20"))),
"include_detail": _truthy_env("POLYWEATHER_PREWARM_INCLUDE_DETAIL", True),
"include_market": _truthy_env("POLYWEATHER_PREWARM_INCLUDE_MARKET", True),
"force_refresh": _truthy_env("POLYWEATHER_PREWARM_FORCE_REFRESH", False),
"thread_alive": bool(_WORKER_THREAD and _WORKER_THREAD.is_alive()),
"runtime": runtime,
}
def run_prewarm(
*,
base_url: str,
cities: str,
force_refresh: bool,
include_detail: bool,
include_market: bool,
) -> int:
token = str(os.getenv("POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN") or "").strip()
requested_cities = _parse_cities(cities)
started = time.perf_counter()
_update_runtime_state(
last_started_at=datetime.now().isoformat(timespec="seconds"),
last_requested_cities=requested_cities,
last_requested_city_count=len(requested_cities),
last_include_detail=bool(include_detail),
last_include_market=bool(include_market),
last_force_refresh=bool(force_refresh),
last_error=None,
last_http_status=None,
)
if not token:
_record_prewarm_result(
ok=False,
duration_sec=time.perf_counter() - started,
http_status=None,
error="missing_backend_token",
warmed_count=0,
summary_ok=0,
detail_ok=0,
market_ok=0,
failed_count=0,
)
print(
json.dumps(
{
"ok": False,
"reason": "missing_backend_token",
"detail": "POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN is required",
},
ensure_ascii=False,
)
)
return 1
try:
with httpx.Client(timeout=180, follow_redirects=True) as client:
response = client.post(
f"{base_url.rstrip('/')}/api/system/prewarm",
params={
"cities": cities,
"force_refresh": str(bool(force_refresh)).lower(),
"include_detail": str(bool(include_detail)).lower(),
"include_market": str(bool(include_market)).lower(),
},
headers={
"Accept": "application/json",
"x-polyweather-entitlement": token,
},
)
try:
payload = response.json()
except Exception:
payload = {"ok": False, "raw": (response.text or "")[:500]}
warmed_count = int((payload or {}).get("count") or 0) if isinstance(payload, dict) else 0
summary_ok = int((payload or {}).get("summary_ok") or 0) if isinstance(payload, dict) else 0
detail_ok = int((payload or {}).get("detail_ok") or 0) if isinstance(payload, dict) else 0
market_ok = int((payload or {}).get("market_ok") or 0) if isinstance(payload, dict) else 0
failed_count = int((payload or {}).get("failed_count") or 0) if isinstance(payload, dict) else 0
ok = bool(response.is_success and (not isinstance(payload, dict) or payload.get("ok", True)))
_record_prewarm_result(
ok=ok,
duration_sec=time.perf_counter() - started,
http_status=response.status_code,
error=None if ok else str((payload or {}).get("detail") or (payload or {}).get("reason") or response.text[:200]),
warmed_count=warmed_count,
summary_ok=summary_ok,
detail_ok=detail_ok,
market_ok=market_ok,
failed_count=failed_count,
)
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 0 if response.is_success else 1
except Exception as exc:
_record_prewarm_result(
ok=False,
duration_sec=time.perf_counter() - started,
http_status=None,
error=str(exc),
warmed_count=0,
summary_ok=0,
detail_ok=0,
market_ok=0,
failed_count=0,
)
print(json.dumps({"ok": False, "reason": "request_failed", "detail": str(exc)}, ensure_ascii=False, indent=2))
return 1
def run_worker_loop(
*,
base_url: str,
cities: str,
interval_sec: int,
jitter_sec: int,
force_refresh: bool,
include_detail: bool,
include_market: bool,
once: bool = False,
) -> int:
interval_sec = max(30, int(interval_sec))
jitter_sec = max(0, int(jitter_sec))
logger.info(
"dashboard prewarm worker started base_url={} cities={} interval_sec={} jitter_sec={} include_detail={} include_market={} force_refresh={} once={}",
base_url,
cities,
interval_sec,
jitter_sec,
bool(include_detail),
bool(include_market),
bool(force_refresh),
bool(once),
)
while True:
started = time.perf_counter()
exit_code = run_prewarm(
base_url=base_url,
cities=cities,
force_refresh=bool(force_refresh),
include_detail=bool(include_detail),
include_market=bool(include_market),
)
elapsed = time.perf_counter() - started
logger.info(
"dashboard prewarm worker cycle exit_code={} elapsed_sec={} finished_at={}",
exit_code,
round(elapsed, 2),
datetime.now().isoformat(timespec="seconds"),
)
if once:
return exit_code
sleep_sec = max(5.0, interval_sec - elapsed)
if jitter_sec > 0:
sleep_sec += random.randint(0, jitter_sec)
logger.info("dashboard prewarm worker sleeping sleep_sec={}", round(sleep_sec, 2))
time.sleep(sleep_sec)
def start_prewarm_worker_thread() -> Optional[threading.Thread]:
enabled = str(os.getenv("POLYWEATHER_DASHBOARD_PREWARM_ENABLED") or "").strip().lower()
if enabled not in {"1", "true", "yes", "on"}:
return None
base_url = str(os.getenv("POLYWEATHER_BACKEND_URL") or "http://127.0.0.1:8000").strip()
cities = str(os.getenv("POLYWEATHER_PREWARM_CITIES") or ",".join(DEFAULT_CITIES)).strip()
interval_sec = int(os.getenv("POLYWEATHER_PREWARM_INTERVAL_SEC", "300"))
jitter_sec = int(os.getenv("POLYWEATHER_PREWARM_JITTER_SEC", "20"))
force_refresh = str(os.getenv("POLYWEATHER_PREWARM_FORCE_REFRESH") or "").strip().lower() in {"1", "true", "yes", "on"}
include_detail = str(os.getenv("POLYWEATHER_PREWARM_INCLUDE_DETAIL", "true")).strip().lower() in {"1", "true", "yes", "on"}
include_market = str(os.getenv("POLYWEATHER_PREWARM_INCLUDE_MARKET", "true")).strip().lower() in {"1", "true", "yes", "on"}
thread = threading.Thread(
target=run_worker_loop,
kwargs={
"base_url": base_url,
"cities": cities,
"interval_sec": interval_sec,
"jitter_sec": jitter_sec,
"force_refresh": force_refresh,
"include_detail": include_detail,
"include_market": include_market,
"once": False,
},
name="dashboard-prewarm-worker",
daemon=True,
)
thread.start()
global _WORKER_THREAD
_WORKER_THREAD = thread
return thread
+6
View File
@@ -28,16 +28,22 @@ def test_system_status_returns_summary_shape():
assert 'features' in payload assert 'features' in payload
assert 'integrations' in payload assert 'integrations' in payload
assert 'cache' in payload assert 'cache' in payload
assert 'analysis' in payload['cache']
assert 'probability' in payload assert 'probability' in payload
assert 'rollout' in payload['probability'] assert 'rollout' in payload['probability']
assert payload['probability']['rollout']['decision']['decision'] in {'hold', 'observe', 'promote'} assert payload['probability']['rollout']['decision']['decision'] in {'hold', 'observe', 'promote'}
assert 'training_data' in payload assert 'training_data' in payload
assert 'station_networks' in payload assert 'station_networks' in payload
assert 'prewarm' in payload
assert 'truth_records' in payload['training_data'] assert 'truth_records' in payload['training_data']
assert 'training_features' in payload['training_data'] assert 'training_features' in payload['training_data']
assert 'city_coverage' in payload['training_data'] assert 'city_coverage' in payload['training_data']
assert 'model_city_coverage' in payload['training_data'] assert 'model_city_coverage' in payload['training_data']
assert 'artifacts' in payload['training_data'] assert 'artifacts' in payload['training_data']
assert 'metar_entries' in payload['cache']
assert 'nmc_entries' in payload['cache']
assert 'runtime' in payload['prewarm']
assert 'last_summary_ok' in payload['prewarm']['runtime']
assert 'cities_count' in payload assert 'cities_count' in payload
+41
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import re import re
import time as _time import time as _time
import threading
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
@@ -27,6 +28,44 @@ from src.data_collection.city_registry import ALIASES
from src.models.lgbm_daily_high import predict_lgbm_daily_high from src.models.lgbm_daily_high import predict_lgbm_daily_high
TURKISH_MGM_CITIES = {"ankara", "istanbul"} TURKISH_MGM_CITIES = {"ankara", "istanbul"}
_ANALYSIS_CACHE_STATS_LOCK = threading.Lock()
_ANALYSIS_CACHE_STATS: Dict[str, Any] = {
"total_requests": 0,
"cache_hits": 0,
"cache_misses": 0,
"force_refresh_requests": 0,
"last_cache_hit_at": None,
"last_cache_miss_at": None,
"last_city": None,
}
def _record_analysis_cache_event(*, city: str, hit: bool, force_refresh: bool) -> None:
now = datetime.now(timezone.utc).isoformat()
with _ANALYSIS_CACHE_STATS_LOCK:
_ANALYSIS_CACHE_STATS["total_requests"] = int(_ANALYSIS_CACHE_STATS.get("total_requests") or 0) + 1
_ANALYSIS_CACHE_STATS["last_city"] = str(city or "")
if force_refresh:
_ANALYSIS_CACHE_STATS["force_refresh_requests"] = int(_ANALYSIS_CACHE_STATS.get("force_refresh_requests") or 0) + 1
if hit:
_ANALYSIS_CACHE_STATS["cache_hits"] = int(_ANALYSIS_CACHE_STATS.get("cache_hits") or 0) + 1
_ANALYSIS_CACHE_STATS["last_cache_hit_at"] = now
else:
_ANALYSIS_CACHE_STATS["cache_misses"] = int(_ANALYSIS_CACHE_STATS.get("cache_misses") or 0) + 1
_ANALYSIS_CACHE_STATS["last_cache_miss_at"] = now
def get_analysis_cache_stats() -> Dict[str, Any]:
with _ANALYSIS_CACHE_STATS_LOCK:
stats = dict(_ANALYSIS_CACHE_STATS)
hits = int(stats.get("cache_hits") or 0)
misses = int(stats.get("cache_misses") or 0)
eligible = hits + misses
hit_rate = (hits / eligible) if eligible > 0 else None
miss_rate = (misses / eligible) if eligible > 0 else None
stats["hit_rate"] = round(hit_rate, 4) if hit_rate is not None else None
stats["miss_rate"] = round(miss_rate, 4) if miss_rate is not None else None
return stats
def _interpolate_hourly_value( def _interpolate_hourly_value(
@@ -780,7 +819,9 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
if not force_refresh: if not force_refresh:
cached = _cache.get(city) cached = _cache.get(city)
if cached and _time.time() - cached["t"] < ttl: if cached and _time.time() - cached["t"] < ttl:
_record_analysis_cache_event(city=city, hit=True, force_refresh=False)
return cached["d"] return cached["d"]
_record_analysis_cache_event(city=city, hit=False, force_refresh=force_refresh)
info = CITIES[city] info = CITIES[city]
lat, lon, is_f = info["lat"], info["lon"], info["f"] lat, lon, is_f = info["lat"], info["lon"], info["f"]
+27 -15
View File
@@ -21,6 +21,7 @@ from src.data_collection.country_networks import provider_coverage_summary
from src.data_collection.city_risk_profiles import CITY_RISK_PROFILES # noqa: F401 from src.data_collection.city_risk_profiles import CITY_RISK_PROFILES # noqa: F401
from src.data_collection.polymarket_readonly import PolymarketReadOnlyLayer from src.data_collection.polymarket_readonly import PolymarketReadOnlyLayer
from src.auth.supabase_entitlement import SUPABASE_ENTITLEMENT, extract_bearer_token from src.auth.supabase_entitlement import SUPABASE_ENTITLEMENT, extract_bearer_token
from src.utils.prewarm_dashboard import get_prewarm_runtime_summary
from src.utils.metrics import ( from src.utils.metrics import (
build_metrics_summary, build_metrics_summary,
counter_inc, counter_inc,
@@ -391,24 +392,34 @@ def _sqlite_health() -> Dict[str, Any]:
def _cache_summary() -> Dict[str, Any]: def _cache_summary() -> Dict[str, Any]:
from web.analysis_service import get_analysis_cache_stats
open_meteo_forecast_entries = len(getattr(_weather, "_open_meteo_cache", {}) or {})
open_meteo_ensemble_entries = len(getattr(_weather, "_ensemble_cache", {}) or {})
open_meteo_multi_model_entries = len(getattr(_weather, "_multi_model_cache", {}) or {})
metar_entries = len(getattr(_weather, "_metar_cache", {}) or {})
taf_entries = len(getattr(_weather, "_taf_cache", {}) or {})
nmc_entries = len(getattr(_weather, "_nmc_cache", {}) or {})
settlement_entries = len(getattr(_weather, "_settlement_cache", {}) or {})
gauge_set("polyweather_api_cache_entries", len(_cache)) gauge_set("polyweather_api_cache_entries", len(_cache))
gauge_set( gauge_set("polyweather_open_meteo_forecast_cache_entries", open_meteo_forecast_entries)
"polyweather_open_meteo_forecast_cache_entries", gauge_set("polyweather_open_meteo_ensemble_cache_entries", open_meteo_ensemble_entries)
len(getattr(_weather, "_open_meteo_cache", {}) or {}), gauge_set("polyweather_open_meteo_multi_model_cache_entries", open_meteo_multi_model_entries)
) gauge_set("polyweather_metar_cache_entries", metar_entries)
gauge_set( gauge_set("polyweather_taf_cache_entries", taf_entries)
"polyweather_open_meteo_ensemble_cache_entries", gauge_set("polyweather_nmc_cache_entries", nmc_entries)
len(getattr(_weather, "_ensemble_cache", {}) or {}), gauge_set("polyweather_settlement_cache_entries", settlement_entries)
)
gauge_set(
"polyweather_open_meteo_multi_model_cache_entries",
len(getattr(_weather, "_multi_model_cache", {}) or {}),
)
return { return {
"api_cache_entries": len(_cache), "api_cache_entries": len(_cache),
"open_meteo_forecast_entries": len(getattr(_weather, "_open_meteo_cache", {}) or {}), "open_meteo_forecast_entries": open_meteo_forecast_entries,
"open_meteo_ensemble_entries": len(getattr(_weather, "_ensemble_cache", {}) or {}), "open_meteo_ensemble_entries": open_meteo_ensemble_entries,
"open_meteo_multi_model_entries": len(getattr(_weather, "_multi_model_cache", {}) or {}), "open_meteo_multi_model_entries": open_meteo_multi_model_entries,
"metar_entries": metar_entries,
"taf_entries": taf_entries,
"nmc_entries": nmc_entries,
"settlement_entries": settlement_entries,
"analysis": get_analysis_cache_stats(),
} }
@@ -750,5 +761,6 @@ def build_system_status_payload() -> Dict[str, Any]:
"probability": _probability_summary(), "probability": _probability_summary(),
"training_data": _training_data_summary(), "training_data": _training_data_summary(),
"station_networks": provider_coverage_summary(), "station_networks": provider_coverage_summary(),
"prewarm": get_prewarm_runtime_summary(),
"cities_count": len(CITIES), "cities_count": len(CITIES),
} }
+216 -94
View File
@@ -1,9 +1,11 @@
from __future__ import annotations from __future__ import annotations
import os import os
import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional from typing import Optional
from fastapi.concurrency import run_in_threadpool
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import PlainTextResponse from fastapi.responses import PlainTextResponse
from loguru import logger from loguru import logger
@@ -66,6 +68,23 @@ TRACKABLE_ANALYTICS_EVENTS = {
"checkout_succeeded", "checkout_succeeded",
} }
DEFAULT_PREWARM_CITIES = [
"ankara",
"istanbul",
"shanghai",
"beijing",
"shenzhen",
"wuhan",
"chengdu",
"chongqing",
"hong kong",
"taipei",
"london",
"paris",
"new york",
"los angeles",
]
def _parse_snapshot_dt(value: object) -> Optional[datetime]: def _parse_snapshot_dt(value: object) -> Optional[datetime]:
raw = str(value or "").strip() raw = str(value or "").strip()
@@ -178,6 +197,20 @@ def _normalize_city_or_404(name: str) -> str:
return city return city
def _normalize_city_list(raw: Optional[str]) -> list[str]:
if not raw:
return list(DEFAULT_PREWARM_CITIES)
out: list[str] = []
for part in str(raw).split(","):
city = str(part or "").strip().lower().replace("-", " ")
if not city:
continue
city = ALIASES.get(city, city)
if city in CITIES and city not in out:
out.append(city)
return out
def _history_file_path() -> str: def _history_file_path() -> str:
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
return os.path.join(project_root, "data", "daily_records.json") return os.path.join(project_root, "data", "daily_records.json")
@@ -251,7 +284,85 @@ async def healthz():
@router.get("/api/system/status") @router.get("/api/system/status")
async def system_status(): async def system_status():
return build_system_status_payload() return await run_in_threadpool(build_system_status_payload)
@router.post("/api/system/prewarm")
async def system_prewarm(
request: Request,
cities: Optional[str] = None,
force_refresh: bool = False,
include_detail: bool = False,
include_market: bool = False,
):
_assert_entitlement(request)
selected = _normalize_city_list(cities)
if not selected:
raise HTTPException(status_code=400, detail="No valid cities to prewarm")
started = time.perf_counter()
warmed: list[dict[str, object]] = []
failed: list[dict[str, object]] = []
summary_ok = 0
detail_ok = 0
market_ok = 0
for city in selected:
city_started = time.perf_counter()
try:
data = _analyze(city, force_refresh=force_refresh)
entry: dict[str, object] = {
"city": city,
"summary": True,
"duration_ms": round((time.perf_counter() - city_started) * 1000.0, 1),
}
summary_ok += 1
if include_detail:
_build_city_summary_payload(data)
_build_city_detail_payload(
data,
target_date=str(data.get("local_date") or "").strip() or None,
)
entry["detail"] = True
detail_ok += 1
if include_market:
_build_city_detail_payload(
data,
target_date=str(data.get("local_date") or "").strip() or None,
)
entry["market"] = True
market_ok += 1
warmed.append(entry)
except Exception as exc:
failed.append(
{
"city": city,
"error": str(exc),
"duration_ms": round((time.perf_counter() - city_started) * 1000.0, 1),
}
)
total_ms = round((time.perf_counter() - started) * 1000.0, 1)
logger.info(
"system prewarm finished count={} failed={} force_refresh={} include_detail={} include_market={} duration_ms={}",
len(warmed),
len(failed),
force_refresh,
include_detail,
include_market,
total_ms,
)
return {
"ok": len(failed) == 0,
"cities": selected,
"warmed": warmed,
"failed": failed,
"summary_ok": summary_ok,
"detail_ok": detail_ok,
"market_ok": market_ok,
"failed_count": len(failed),
"duration_ms": total_ms,
}
@router.get("/metrics", response_class=PlainTextResponse) @router.get("/metrics", response_class=PlainTextResponse)
@@ -264,7 +375,7 @@ async def metrics():
@router.get("/api/cities") @router.get("/api/cities")
async def list_cities(request: Request): async def list_cities(request: Request):
try: def _build_payload():
out = [] out = []
deb_recent_index = _build_recent_deb_performance_index() deb_recent_index = _build_recent_deb_performance_index()
for name, info in CITIES.items(): for name, info in CITIES.items():
@@ -302,6 +413,9 @@ async def list_cities(request: Request):
} }
) )
return {"cities": out} return {"cities": out}
try:
return await run_in_threadpool(_build_payload)
except Exception as exc: except Exception as exc:
logger.error(f"Error in list_cities: {exc}") logger.error(f"Error in list_cities: {exc}")
raise HTTPException(status_code=500, detail=str(exc)) from exc raise HTTPException(status_code=500, detail=str(exc)) from exc
@@ -310,105 +424,110 @@ async def list_cities(request: Request):
@router.get("/api/city/{name}") @router.get("/api/city/{name}")
async def city_detail(request: Request, name: str, force_refresh: bool = False): async def city_detail(request: Request, name: str, force_refresh: bool = False):
_assert_entitlement(request) _assert_entitlement(request)
return _analyze(_normalize_city_or_404(name), force_refresh=force_refresh) city = _normalize_city_or_404(name)
return await run_in_threadpool(_analyze, city, force_refresh)
@router.get("/api/history/{name}") @router.get("/api/history/{name}")
async def city_history(request: Request, name: str): async def city_history(request: Request, name: str):
_assert_entitlement(request) _assert_entitlement(request)
city = _normalize_city_or_404(name) city = _normalize_city_or_404(name)
source = str(CITIES.get(city, {}).get("settlement_source") or "metar").strip().lower()
truth_rows = _truth_record_repo.load_city(city)
feature_rows = _training_feature_repo.load_city(city)
if not truth_rows and not feature_rows: def _build_history_payload():
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) source = str(CITIES.get(city, {}).get("settlement_source") or "metar").strip().lower()
history_file = os.path.join(project_root, "data", "daily_records.json") truth_rows = _truth_record_repo.load_city(city)
data = load_history(history_file) feature_rows = _training_feature_repo.load_city(city)
city_data = data.get(city, {}) if isinstance(data.get(city, {}), dict) else {}
else: if not truth_rows and not feature_rows:
all_dates = sorted(set(truth_rows.keys()) | set(feature_rows.keys())) project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
city_data = {} history_file = os.path.join(project_root, "data", "daily_records.json")
for day in all_dates: data = load_history(history_file)
record: dict[str, object] = {} city_data = data.get(city, {}) if isinstance(data.get(city, {}), dict) else {}
truth = truth_rows.get(day) or {} else:
features = feature_rows.get(day) or {} all_dates = sorted(set(truth_rows.keys()) | set(feature_rows.keys()))
if truth.get("actual_high") is not None: city_data = {}
record["actual_high"] = truth.get("actual_high") for day in all_dates:
record["settlement_source"] = truth.get("settlement_source") record: dict[str, object] = {}
record["settlement_station_code"] = truth.get("settlement_station_code") truth = truth_rows.get(day) or {}
record["settlement_station_label"] = truth.get("settlement_station_label") features = feature_rows.get(day) or {}
record["truth_version"] = truth.get("truth_version") if truth.get("actual_high") is not None:
record["updated_by"] = truth.get("updated_by") record["actual_high"] = truth.get("actual_high")
record["truth_updated_at"] = truth.get("truth_updated_at") record["settlement_source"] = truth.get("settlement_source")
if isinstance(features, dict): record["settlement_station_code"] = truth.get("settlement_station_code")
if features.get("deb_prediction") is not None: record["settlement_station_label"] = truth.get("settlement_station_label")
record["deb_prediction"] = features.get("deb_prediction") record["truth_version"] = truth.get("truth_version")
if features.get("mu") is not None: record["updated_by"] = truth.get("updated_by")
record["mu"] = features.get("mu") record["truth_updated_at"] = truth.get("truth_updated_at")
if isinstance(features.get("forecasts"), dict): if isinstance(features, dict):
record["forecasts"] = features.get("forecasts") if features.get("deb_prediction") is not None:
city_data[day] = record record["deb_prediction"] = features.get("deb_prediction")
if features.get("mu") is not None:
record["mu"] = features.get("mu")
if isinstance(features.get("forecasts"), dict):
record["forecasts"] = features.get("forecasts")
city_data[day] = record
if not city_data:
return {
"history": [],
"settlement_source": source,
"settlement_source_label": SETTLEMENT_SOURCE_LABELS.get(source, source.upper()),
}
out = []
for day, rec in sorted(city_data.items()):
if not isinstance(rec, dict):
rec = {}
act = rec.get("actual_high")
deb = rec.get("deb_prediction")
mu = rec.get("mu")
snapshots = load_snapshot_rows_for_day(city, day)
peak_ref = _build_peak_minus_12h_reference(
actual_high=act,
snapshots=snapshots,
)
forecasts_raw = rec.get("forecasts", {}) or {}
forecasts = {}
if isinstance(forecasts_raw, dict):
for model_name, model_value in forecasts_raw.items():
if _is_excluded_model_name(str(model_name)):
continue
fv = _sf(model_value)
forecasts[str(model_name)] = fv if fv is not None else None
forecasts = _merge_missing_history_forecasts_from_snapshots(
forecasts,
snapshots,
)
mgm = forecasts.get("MGM")
out.append(
{
"date": day,
"actual": float(act) if act is not None else None,
"deb": float(deb) if deb is not None else None,
"mu": float(mu) if mu is not None else None,
"mgm": float(mgm) if mgm is not None else None,
"forecasts": forecasts,
"settlement_source": rec.get("settlement_source"),
"settlement_station_code": rec.get("settlement_station_code"),
"settlement_station_label": rec.get("settlement_station_label"),
"truth_version": rec.get("truth_version"),
"updated_by": rec.get("updated_by"),
"truth_updated_at": rec.get("truth_updated_at"),
"actual_peak_time": peak_ref.get("actual_peak_time"),
"deb_at_peak_minus_12h": peak_ref.get("deb_at_peak_minus_12h"),
"deb_at_peak_minus_12h_time": peak_ref.get("deb_at_peak_minus_12h_time"),
"deb_at_peak_minus_12h_error": peak_ref.get("deb_at_peak_minus_12h_error"),
}
)
if not city_data:
return { return {
"history": [], "history": out,
"settlement_source": source, "settlement_source": source,
"settlement_source_label": SETTLEMENT_SOURCE_LABELS.get(source, source.upper()), "settlement_source_label": SETTLEMENT_SOURCE_LABELS.get(source, source.upper()),
} }
out = [] return await run_in_threadpool(_build_history_payload)
for day, rec in sorted(city_data.items()):
if not isinstance(rec, dict):
rec = {}
act = rec.get("actual_high")
deb = rec.get("deb_prediction")
mu = rec.get("mu")
snapshots = load_snapshot_rows_for_day(city, day)
peak_ref = _build_peak_minus_12h_reference(
actual_high=act,
snapshots=snapshots,
)
forecasts_raw = rec.get("forecasts", {}) or {}
forecasts = {}
if isinstance(forecasts_raw, dict):
for model_name, model_value in forecasts_raw.items():
if _is_excluded_model_name(str(model_name)):
continue
fv = _sf(model_value)
forecasts[str(model_name)] = fv if fv is not None else None
forecasts = _merge_missing_history_forecasts_from_snapshots(
forecasts,
snapshots,
)
mgm = forecasts.get("MGM")
out.append(
{
"date": day,
"actual": float(act) if act is not None else None,
"deb": float(deb) if deb is not None else None,
"mu": float(mu) if mu is not None else None,
"mgm": float(mgm) if mgm is not None else None,
"forecasts": forecasts,
"settlement_source": rec.get("settlement_source"),
"settlement_station_code": rec.get("settlement_station_code"),
"settlement_station_label": rec.get("settlement_station_label"),
"truth_version": rec.get("truth_version"),
"updated_by": rec.get("updated_by"),
"truth_updated_at": rec.get("truth_updated_at"),
"actual_peak_time": peak_ref.get("actual_peak_time"),
"deb_at_peak_minus_12h": peak_ref.get("deb_at_peak_minus_12h"),
"deb_at_peak_minus_12h_time": peak_ref.get("deb_at_peak_minus_12h_time"),
"deb_at_peak_minus_12h_error": peak_ref.get("deb_at_peak_minus_12h_error"),
}
)
return {
"history": out,
"settlement_source": source,
"settlement_source_label": SETTLEMENT_SOURCE_LABELS.get(source, source.upper()),
}
@router.get("/api/auth/me") @router.get("/api/auth/me")
@@ -898,8 +1017,9 @@ async def payment_reconcile_latest(request: Request):
@router.get("/api/city/{name}/summary") @router.get("/api/city/{name}/summary")
async def city_summary(request: Request, name: str, force_refresh: bool = False): async def city_summary(request: Request, name: str, force_refresh: bool = False):
data = _analyze(_normalize_city_or_404(name), force_refresh=force_refresh) city = _normalize_city_or_404(name)
return _build_city_summary_payload(data) data = await run_in_threadpool(_analyze, city, force_refresh)
return await run_in_threadpool(_build_city_summary_payload, data)
@router.get("/api/city/{name}/detail") @router.get("/api/city/{name}/detail")
@@ -911,9 +1031,11 @@ async def city_detail_aggregate(
target_date: Optional[str] = None, target_date: Optional[str] = None,
): ):
_assert_entitlement(request) _assert_entitlement(request)
data = _analyze(_normalize_city_or_404(name), force_refresh=force_refresh) city = _normalize_city_or_404(name)
return _build_city_detail_payload( data = await run_in_threadpool(_analyze, city, force_refresh)
return await run_in_threadpool(
_build_city_detail_payload,
data, data,
market_slug=market_slug, market_slug,
target_date=target_date, target_date,
) )