架构重构:拆分 core/ops/DBManager,统一 SQLite 锁,DEB 改进,新增注意力模型

- web/core.py 858→236行,拆出 schemas/middleware/auth/diagnostics
- ops_api.py 2876→4个 domain 模块 (users/payments/health/config)
- DBManager Supabase HTTP 调用提取到 SupabaseAdminClient
- 新增 LockedSQLiteConnection 统一多进程读写锁
- 新增 WeatherCacheManager 替代 12 个独立缓存字典
- METAR 缓存迁移至统一缓存管理器
- analysis_service 提取 _build_intraday_meteorology 到独立模块
- DEB 改进:偏差惩罚、分歧回退、自适应 lookback (MAE ↓12.6%)
- 新增 PyTorch 注意力模型 deb_attention.py (数据积累后启用)
- 新增 torch 到 requirements.lock
This commit is contained in:
2569718930@qq.com
2026-06-16 02:00:22 +08:00
parent ed0447f408
commit dca4f2d618
26 changed files with 5010 additions and 3974 deletions
+47 -6
View File
@@ -1159,11 +1159,24 @@ def calculate_dynamic_weight_components(
city_data = data[city_name]
sorted_dates = sorted(city_data.keys(), reverse=True)
today_str = datetime.now().strftime("%Y-%m-%d")
available_days = sum(
1 for d in sorted_dates
if d != today_str and city_data[d].get("actual_high") is not None
)
# ── 改进3: 自适应 lookback — 数据多的城市用更多历史 ──
adaptive_lookback = min(14, max(7, available_days // 4))
effective_lookback = max(lookback_days, adaptive_lookback) if lookback_days <= 7 else lookback_days
# ── 改进1: 偏差惩罚 — per-model signed bias ──
model_biases: dict = {model: 0.0 for model in forecasts.keys()}
bias_samples: dict = {model: 0 for model in forecasts.keys()}
errors: dict = {model: [] for model in forecasts.keys()}
days_used = 0
for date_str in sorted_dates:
if date_str == datetime.now().strftime("%Y-%m-%d"):
if date_str == today_str:
continue
record = city_data[date_str]
@@ -1174,8 +1187,6 @@ def calculate_dynamic_weight_components(
if actual is None:
continue
decay_weight = decay_factor ** days_used
for model in forecasts.keys():
if model in past_forecasts and past_forecasts[model] is not None:
try:
@@ -1183,6 +1194,10 @@ def calculate_dynamic_weight_components(
av = float(actual)
except (TypeError, ValueError):
continue
# Track signed error for bias
model_biases[model] += (pv - av) # positive = model overpredicts
bias_samples[model] += 1
# Track absolute error for MAE
daily_error = abs(pv - av)
h_err = (
past_hourly_error.get(model)
@@ -1190,15 +1205,17 @@ def calculate_dynamic_weight_components(
else None
)
blended_error = _blend_mae(daily_error, h_err)
decay_weight = decay_factor ** days_used
errors[model].append((blended_error, decay_weight))
days_used += 1
if days_used >= lookback_days:
if days_used >= effective_lookback:
break
if days_used < 2:
return _equal_weight_result(f"等权平均(由于仅{days_used}天历史)", days_used)
# Compute weighted MAEs
maes = {}
for model, err_weighted in errors.items():
if err_weighted:
@@ -1210,8 +1227,14 @@ def calculate_dynamic_weight_components(
else:
maes[model] = 2.0
# Finalize per-model biases — only trust when enough samples
for model in forecasts.keys():
n = bias_samples.get(model, 0)
model_biases[model] = model_biases[model] / n if n >= 5 else 0.0
# ── 改进1: 偏差惩罚进逆误差 ──
inverse_errors = {
m: 1.0 / (mae + 0.1)
m: 1.0 / (mae + abs(model_biases[m]) * 0.5 + 0.1)
for m, mae in maes.items()
if forecasts.get(m) is not None
}
@@ -1229,12 +1252,30 @@ def calculate_dynamic_weight_components(
}
weights = {m: inv / total_inv for m, inv in inverse_errors.items()}
# ── 改进2: 分歧感知的权重回退 ──
forecast_values = [v for v in forecasts.values() if v is not None]
if len(forecast_values) >= 2:
spread = max(forecast_values) - min(forecast_values)
if spread > 3.0:
trust_factor = 3.0 / spread # 分歧>3°F时才回退
n = len(weights)
weights = {
m: w * trust_factor + (1.0 - trust_factor) / n
for m, w in weights.items()
}
blended_high = sum(forecasts[m] * weights[m] for m in weights)
sorted_models = sorted(weights.items(), key=lambda x: x[1], reverse=True)
weight_str_parts = []
for m, w in sorted_models[:3]:
weight_str_parts.append(f"{m}({w * 100:.0f}%,MAE:{maes[m]:.1f}°)")
parts = [f"{m}({w * 100:.0f}%"]
if maes.get(m):
parts.append(f"MAE:{maes[m]:.1f}°")
if abs(model_biases.get(m, 0.0)) >= 0.3:
parts.append(f"bias:{model_biases[m]:+.1f}")
weight_str_parts.append(",".join(parts) + ")")
if dedup_note:
weight_str_parts.append(dedup_note)
+276
View File
@@ -0,0 +1,276 @@
"""PyTorch learnable attention ensemble for DEB.
Tiny MLP (~5K params) that learns per-city model weighting from
cross-city training data. Trains in seconds on CPU, <1ms inference.
Usage:
python -m src.analysis.deb_attention --train
python -m src.analysis.deb_attention --eval
"""
from __future__ import annotations
import json
import math
import os
import random
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
import torch
import torch.nn as nn
import torch.nn.functional as F
def _load_training_data(history, min_models=3):
samples = []
for city, days in (history or {}).items():
for date_str, record in days.items():
forecasts = record.get("forecasts", {})
actual = record.get("actual_high")
if actual is None:
continue
valid = {m: float(v) for m, v in forecasts.items() if v is not None}
if len(valid) < min_models:
continue
samples.append({"city": city, "forecasts": valid, "actual": float(actual), "date": date_str})
return samples
def _build_vocabs(samples, min_model_freq=2):
from collections import Counter
mf = Counter[str]()
cities = set()
for s in samples:
cities.add(s["city"])
for m in s["forecasts"]:
mf[m] += 1
mv = {m: i for i, m in enumerate(sorted([m for m, c in mf.items() if c >= min_model_freq], key=lambda m: (-mf[m], m)))}
cv = {c: i for i, c in enumerate(sorted(cities))}
return mv, cv
class DebAttention(nn.Module):
"""Learnable attention: per-model scorer + city bias correction.
Input: per-model forecast values + city id
Output: weighted prediction (learned attention weights) + city bias
"""
def __init__(self, n_models, n_cities, d_hidden=32, dropout=0.1):
super().__init__()
self.n_models = n_models
self.n_cities = n_cities
# City embedding for context
self.city_emb = nn.Embedding(n_cities + 1, 16, padding_idx=0)
# Per-model scorer: [fc_norm, city_emb] → attention score
self.scorer = nn.Sequential(
nn.Linear(3 + 16, d_hidden), nn.GELU(), nn.Dropout(dropout),
nn.Linear(d_hidden, d_hidden // 2), nn.GELU(),
nn.Linear(d_hidden // 2, 1),
)
# City-level bias
self.city_bias = nn.Embedding(n_cities + 1, 1, padding_idx=0)
self.register_buffer("fc_mean", torch.tensor(0.0))
self.register_buffer("fc_std", torch.tensor(10.0))
def forward(self, forecasts, city_idx, mask):
"""(B, M), (B,), (B, M) → (B,), (B, M)"""
batch, max_m = forecasts.shape
fc_norm = (forecasts - self.fc_mean) / self.fc_std.clamp_min(0.1)
# Consensus: trimmed mean of valid forecasts
fc_masked = forecasts * mask.float()
n_valid = mask.sum(dim=1, keepdim=True).clamp_min(1)
consensus = fc_masked.sum(dim=1) / n_valid.squeeze() # (B,)
# Deviation from consensus
dev = fc_norm - consensus.unsqueeze(1) * mask.float() / self.fc_std.clamp_min(0.1)
# Feature: [fc_norm, dev_from_consensus, is_extreme]
is_extreme = (dev.abs() > 1.5).float() # flag if >1.5 std away
feats = torch.stack([fc_norm, dev, is_extreme], dim=-1) # (B, M, 3)
city = self.city_emb(city_idx.clamp(0, self.n_cities)) # (B, 16)
city_tiled = city.unsqueeze(1).expand(-1, max_m, -1) # (B, M, 16)
feats = torch.cat([feats, city_tiled], dim=-1) # (B, M, 19)
scores = self.scorer(feats).squeeze(-1) # (B, M)
scores = scores.masked_fill(~mask, float("-inf"))
attn = F.softmax(scores, dim=-1)
pred = (forecasts * attn).sum(dim=-1)
bias = self.city_bias(city_idx.clamp(0, self.n_cities)).squeeze(-1)
pred = pred + bias * 0.3
return pred, attn
def train_model(history, *, epochs=200, batch_size=128, lr=0.002, patience=30, checkpoint_dir="data", seed=42):
torch.manual_seed(seed)
random.seed(seed)
samples = _load_training_data(history)
if len(samples) < 50:
raise ValueError(f"Need >=50 samples, got {len(samples)}")
mv, cv = _build_vocabs(samples)
max_models = len(mv) + 4
samples.sort(key=lambda s: s["date"])
split = int(len(samples) * 0.8)
train_s = samples[:split]
val_s = samples[split:]
all_fc = [v for s in train_s for v in s["forecasts"].values()]
fc_mean = sum(all_fc) / len(all_fc)
fc_std = max(1.0, math.sqrt(sum((v - fc_mean) ** 2 for v in all_fc) / len(all_fc)))
def precompute(s_list):
X_fc, X_cid, X_mask, Y = [], [], [], []
for s in s_list:
fc_list = [s["forecasts"][m] for m in sorted(s["forecasts"]) if m in mv]
n = len(fc_list)
X_fc.append(fc_list + [0.0] * (max_models - n))
X_cid.append(cv.get(s["city"], 0))
X_mask.append([1.0] * n + [0.0] * (max_models - n))
Y.append(s["actual"])
return (
torch.tensor(X_fc, dtype=torch.float32),
torch.tensor(X_cid, dtype=torch.long),
torch.tensor(X_mask, dtype=torch.bool),
torch.tensor(Y, dtype=torch.float32),
)
train_X = precompute(train_s)
val_X = precompute(val_s)
train_loader = torch.utils.data.DataLoader(torch.utils.data.TensorDataset(*train_X), batch_size=batch_size, shuffle=True)
val_loader = torch.utils.data.DataLoader(torch.utils.data.TensorDataset(*val_X), batch_size=batch_size * 2, shuffle=False)
model = DebAttention(max_models, len(cv) + 1)
model.fc_mean.fill_(fc_mean)
model.fc_std.fill_(fc_std)
opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-5)
sched = torch.optim.lr_scheduler.ReduceLROnPlateau(opt, mode="min", factor=0.5, patience=10)
best_val = float("inf")
best_state = None
no_imp = 0
print(f"Training: {len(train_s)} train, {len(val_s)} val, models={len(mv)}, cities={len(cv)}")
for epoch in range(epochs):
model.train()
tr_loss = 0.0
for fc, cid, mask, y in train_loader:
pred, _ = model(fc, cid, mask)
loss = F.mse_loss(pred, y)
opt.zero_grad()
loss.backward()
opt.step()
tr_loss += loss.item() * fc.size(0)
tr_loss /= len(train_X[0])
model.eval()
val_loss = 0.0
with torch.no_grad():
for fc, cid, mask, y in val_loader:
pred, _ = model(fc, cid, mask)
loss = F.mse_loss(pred, y)
val_loss += loss.item() * fc.size(0)
val_loss /= len(val_X[0])
sched.step(val_loss)
if val_loss < best_val:
best_val = val_loss
best_state = {k: v.cpu().clone() for k, v in model.state_dict().items()}
no_imp = 0
else:
no_imp += 1
if epoch % 20 == 0 or epoch == epochs - 1:
print(f" epoch {epoch:3d} tr={math.sqrt(tr_loss):.3f} val={math.sqrt(val_loss):.3f} lr={opt.param_groups[0]['lr']:.1e}")
if no_imp >= patience:
print(f" early stop {epoch}")
break
if best_state:
model.load_state_dict(best_state)
os.makedirs(checkpoint_dir, exist_ok=True)
ckpt = os.path.join(checkpoint_dir, "deb_attention.pt")
torch.save({"state": model.state_dict(), "mv": mv, "cv": cv, "fc_mean": fc_mean, "fc_std": fc_std, "max_m": max_models}, ckpt)
print(f" saved {ckpt}")
return model, mv, cv
def load_model(path):
ckpt = torch.load(path, map_location="cpu", weights_only=False)
model = DebAttention(ckpt["max_m"], len(ckpt["cv"]) + 1)
model.load_state_dict(ckpt["state"])
model.fc_mean.fill_(ckpt["fc_mean"])
model.fc_std.fill_(ckpt["fc_std"])
model.eval()
return model, ckpt["mv"], ckpt["cv"]
def predict(model, forecasts, city, mv, cv):
model.eval()
fc_list = [forecasts[m] for m in sorted(forecasts) if m in mv]
if not fc_list:
return None, {}
n = len(fc_list)
max_m = model.n_models
fc_t = torch.zeros(max_m)
mask_t = torch.zeros(max_m, dtype=torch.bool)
fc_t[:n] = torch.tensor(fc_list)
mask_t[:n] = True
cid = cv.get(city, 0)
with torch.no_grad():
pred, attn = model(fc_t.unsqueeze(0), torch.tensor([cid]), mask_t.unsqueeze(0))
pred = round(pred.item(), 1)
models = [m for m in sorted(forecasts) if m in mv]
weights = {models[i]: attn[0, i].item() for i in range(n)}
total = sum(weights.values())
if total > 0:
weights = {m: w / total for m, w in weights.items()}
return pred, weights
if __name__ == "__main__":
import sys, statistics
from src.analysis.deb_algorithm import load_history as lh, get_deb_accuracy, calculate_dynamic_weights
from src.data_collection.city_registry import CITY_REGISTRY
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
hp = os.path.join(root, "data", "daily_records.json")
history = lh(hp)
if "--train" in sys.argv:
m, mv, cv = train_model(history)
print("Done.")
elif "--eval" in sys.argv:
ckpt = os.path.join(root, "data", "deb_attention.pt")
if not os.path.exists(ckpt):
print("No checkpoint. Run --train first.")
sys.exit(1)
m, mv, cv = load_model(ckpt)
base, attn_errs = [], []
for city in [c for c in CITY_REGISTRY if c in history]:
cd = history.get(city, {})
for d in sorted(cd.keys(), reverse=True)[:5]:
rec = cd[d]; fc = rec.get("forecasts", {}); actual = rec.get("actual_high")
if not fc or actual is None: continue
try:
bp, _ = calculate_dynamic_weights(city, fc)
if bp: base.append(abs(bp - float(actual)))
except: pass
try:
ap, _ = predict(m, fc, city, mv, cv)
if ap: attn_errs.append(abs(ap - float(actual)))
except: pass
if base and attn_errs:
print(f"Baseline MAE: {statistics.mean(base):.3f}")
print(f"Attention MAE: {statistics.mean(attn_errs):.3f}")
else:
print("python -m src.analysis.deb_attention --train | --eval")
+149
View File
@@ -0,0 +1,149 @@
"""Thin Supabase Admin HTTP client — extracted from DBManager.
Handles URL construction, auth headers, and the actual HTTP PATCH calls
to Supabase REST / Admin APIs. Cache-cooldown and local SQLite lookups
remain in DBManager.
"""
from __future__ import annotations
import os
from typing import Any, Dict, Optional
import requests
from loguru import logger
class SupabaseAdminClient:
"""Stateless HTTP client for Supabase Management API calls."""
def __init__(self) -> None:
self._supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/")
self._service_role_key = str(
os.getenv("SUPABASE_SERVICE_ROLE_KEY") or ""
).strip()
@property
def configured(self) -> bool:
return bool(self._supabase_url and self._service_role_key)
# ------------------------------------------------------------------
# URL builders
# ------------------------------------------------------------------
def profiles_endpoint(self) -> str:
if not self._supabase_url:
return ""
return f"{self._supabase_url}/rest/v1/profiles"
def admin_users_endpoint(self) -> str:
if not self._supabase_url:
return ""
return f"{self._supabase_url}/auth/v1/admin/users"
# ------------------------------------------------------------------
# Headers
# ------------------------------------------------------------------
def _service_headers(self, *, prefer: str = "return=minimal") -> Dict[str, str]:
return {
"apikey": self._service_role_key,
"Authorization": f"Bearer {self._service_role_key}",
"Accept": "application/json",
"Content-Type": "application/json",
"Prefer": prefer,
}
# ------------------------------------------------------------------
# Operations
# ------------------------------------------------------------------
def patch_user_metadata(
self,
supabase_user_id: str,
metadata: Dict[str, Any],
*,
timeout: float = 8.0,
) -> bool:
"""PATCH /auth/v1/admin/users/{id} with user_metadata."""
if not self.configured:
return False
endpoint = self.admin_users_endpoint()
if not endpoint:
return False
try:
resp = requests.patch(
f"{endpoint}/{supabase_user_id}",
json={"user_metadata": metadata},
headers=self._service_headers(),
timeout=timeout,
)
ok = resp.status_code in (200, 204)
if not ok:
logger.warning(
"supabase admin patch user_metadata failed "
"user_id={} status={} body={}",
supabase_user_id,
resp.status_code,
(resp.text or "")[:200],
)
return ok
except Exception as exc:
logger.warning(
"supabase admin patch user_metadata error user_id={}: {}",
supabase_user_id,
exc,
)
return False
def patch_profile(
self,
supabase_user_id: str,
fields: Dict[str, Any],
*,
timeout: float = 8.0,
) -> bool:
"""PATCH /rest/v1/profiles?id=eq.{user_id} with telegram fields."""
if not self.configured:
return False
endpoint = self.profiles_endpoint()
if not endpoint:
return False
try:
resp = requests.patch(
endpoint,
params={"id": f"eq.{supabase_user_id}"},
json=fields,
headers=self._service_headers(),
timeout=timeout,
)
ok = resp.status_code in (200, 204)
if not ok:
logger.warning(
"supabase profiles patch failed "
"user_id={} status={} body={}",
supabase_user_id,
resp.status_code,
(resp.text or "")[:240],
)
return ok
except Exception as exc:
logger.warning(
"supabase profiles patch error user_id={}: {}",
supabase_user_id,
exc,
)
return False
# Module-level singleton — mirrors the pattern used by DBManager.
_supabase_admin_client: Optional[SupabaseAdminClient] = None
def get_supabase_admin_client() -> SupabaseAdminClient:
global _supabase_admin_client
if _supabase_admin_client is None:
_supabase_admin_client = SupabaseAdminClient()
return _supabase_admin_client
+11 -14
View File
@@ -97,12 +97,11 @@ class MetarSourceMixin:
cache_key = f"{icao}:{utc_offset}:{use_fahrenheit}"
now_ts = time.time()
cache_ttl_sec = self._metar_cache_ttl_for_city(city, icao)
with self._metar_cache_lock:
cached = self._metar_cache.get(cache_key)
if cached and now_ts - cached["t"] < cache_ttl_sec:
logger.debug(f"METAR cache hit {icao} age={int(now_ts - cached['t'])}s")
record_source_call("metar", "current", "cache_hit", (time.perf_counter() - started) * 1000.0)
return cached["d"]
cached = self.cache.get_ttl("metar", cache_key, cache_ttl_sec)
if cached is not None:
logger.debug(f"METAR cache hit {icao} age=...")
record_source_call("metar", "current", "cache_hit", (time.perf_counter() - started) * 1000.0)
return cached
try:
url = "https://aviationweather.gov/api/data/metar"
@@ -331,19 +330,17 @@ class MetarSourceMixin:
)
else:
logger.info(f"✈️ METAR {icao}: temperature unavailable (obs: {obs_time})")
with self._metar_cache_lock:
self._metar_cache[cache_key] = {"d": result, "t": now_ts}
self.cache.set_ttl("metar", cache_key, result)
record_source_call("metar", "current", "success", (time.perf_counter() - started) * 1000.0)
return result
except httpx.HTTPError as exc:
logger.error(f"METAR 请求失败 ({icao}): {exc}")
with self._metar_cache_lock:
stale = self._metar_cache.get(cache_key)
if stale:
logger.warning(f"METAR {icao} 请求失败,使用缓存回退")
record_source_call("metar", "current", "stale_cache", (time.perf_counter() - started) * 1000.0)
return stale["d"]
stale = self.cache.get("metar", cache_key)
if stale:
logger.warning(f"METAR {icao} 请求失败,使用缓存回退")
record_source_call("metar", "current", "stale_cache", (time.perf_counter() - started) * 1000.0)
return stale.get("d") if isinstance(stale, dict) else None
record_source_call("metar", "current", "error", (time.perf_counter() - started) * 1000.0)
return None
except (KeyError, IndexError, TypeError) as exc:
+120
View File
@@ -0,0 +1,120 @@
"""Thread-safe TTL cache manager for weather data collector sources.
Replaces the per-source cache dictionaries and locks scattered across
WeatherDataCollector Mixins with a single unified store.
"""
from __future__ import annotations
import threading
import time
from typing import Any, Callable, Dict, Optional
class WeatherCacheManager:
"""Key-value store with per-entry TTL and automatic trimming."""
def __init__(self, trim_interval_writes: int = 200) -> None:
self._lock = threading.Lock()
self._stores: Dict[str, Dict[str, Dict[str, Any]]] = {}
self._write_count = 0
self._trim_interval = max(1, int(trim_interval_writes))
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def get(self, store: str, key: str) -> Optional[Dict[str, Any]]:
with self._lock:
entries = self._stores.get(store)
if entries is None:
return None
return entries.get(key)
def set(self, store: str, key: str, value: Dict[str, Any]) -> None:
with self._lock:
if store not in self._stores:
self._stores[store] = {}
self._stores[store][key] = value
self._write_count += 1
self._maybe_trim()
def get_ttl(
self,
store: str,
key: str,
ttl_sec: float,
) -> Optional[Dict[str, Any]]:
"""Return the cached value if it exists and is younger than *ttl_sec*."""
now = time.time()
with self._lock:
entries = self._stores.get(store)
if entries is None:
return None
entry = entries.get(key)
if entry is None:
return None
cached_ts = float(entry.get("_ts") or 0)
if now - cached_ts > ttl_sec:
return None
return entry.get("d")
def set_ttl(self, store: str, key: str, value: Dict[str, Any]) -> None:
"""Store a value with an automatic timestamp for TTL lookups."""
self.set(store, key, {"_ts": time.time(), "d": value})
def store_size(self, store: str) -> int:
with self._lock:
entries = self._stores.get(store)
return len(entries) if entries else 0
# ------------------------------------------------------------------
# Trimming
# ------------------------------------------------------------------
def _maybe_trim(self) -> None:
if self._write_count % self._trim_interval != 0:
return
self.trim_stale()
def trim_stale(
self,
ttl_map: Optional[Dict[str, float]] = None,
) -> None:
"""Remove entries older than their store's TTL.
*ttl_map* maps store name → ttl_sec. Stores not in the map are skipped.
"""
if not ttl_map:
return
now = time.time()
with self._lock:
for store_name, ttl_sec in ttl_map.items():
entries = self._stores.get(store_name)
if not entries:
continue
stale = [
key
for key, entry in entries.items()
if now - float(entry.get("_ts") or 0) > ttl_sec
]
for key in stale:
del entries[key]
def clear_store(self, store: str) -> None:
with self._lock:
self._stores.pop(store, None)
# Module-level singleton for the default cache used by WeatherDataCollector.
_weather_cache: Optional[WeatherCacheManager] = None
_weather_cache_lock = threading.Lock()
def get_weather_cache() -> WeatherCacheManager:
global _weather_cache
if _weather_cache is None:
with _weather_cache_lock:
if _weather_cache is None:
_weather_cache = WeatherCacheManager()
return _weather_cache
+15 -7
View File
@@ -212,8 +212,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
os.getenv("METAR_FAST_CACHE_TTL_SEC", str(OBSERVATION_REFRESH_SEC))
)
self.metar_fast_cache_ttl_sec = min(self.metar_fast_cache_ttl_sec, OBSERVATION_REFRESH_SEC)
self._metar_cache: Dict[str, Dict] = {}
self._metar_cache_lock = threading.Lock()
# METAR cache migrated to self.cache (WeatherCacheManager)
self.taf_cache_ttl_sec = int(
os.getenv("TAF_CACHE_TTL_SEC", "900")
)
@@ -300,6 +299,11 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
self._CACHE_TRIM_EVERY_N_WRITES = int(
os.getenv("POLYWEATHER_CACHE_TRIM_INTERVAL", "200")
)
# Unified cache manager — new sources should use this instead of per-source dicts.
from src.data_collection.weather_cache import WeatherCacheManager
self.cache = WeatherCacheManager(
trim_interval_writes=self._CACHE_TRIM_EVERY_N_WRITES,
)
# 设置代理
proxy = config.get("proxy")
@@ -344,7 +348,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
(self._open_meteo_cache, self._open_meteo_cache_lock, 3600.0),
(self._ensemble_cache, self._ensemble_cache_lock, 7200.0),
(self._multi_model_cache, self._multi_model_cache_lock, 7200.0),
(self._metar_cache, self._metar_cache_lock, float(self.metar_cache_ttl_sec * 2)),
# metar cache migrated to self.cache
(self._taf_cache, self._taf_cache_lock, float(self.taf_cache_ttl_sec * 2)),
(self._jma_cache, self._jma_cache_lock, float(self.jma_cache_ttl_sec * 2)),
(self._settlement_cache, self._settlement_cache_lock, float(self.settlement_cache_ttl_sec * 2)),
@@ -367,6 +371,8 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
with lock:
for key in stale:
cache.pop(key, None)
# Unified cache stores
self.cache.trim_stale({"metar": float(self.metar_cache_ttl_sec * 2)})
def _post_temperature_patch_payload(
self,
@@ -960,10 +966,12 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
icao = self.get_icao_code(city)
if icao:
prefix = f"{icao}:"
with self._metar_cache_lock:
for key in list(self._metar_cache.keys()):
if key.startswith(prefix):
self._metar_cache.pop(key, None)
# Clear matching entries from unified cache
with self.cache._lock:
entries = self.cache._stores.get("metar", {})
stale = [k for k in entries if k.startswith(prefix)]
for key in stale:
del entries[key]
normalized = str(city or "").strip().lower()
with self._jma_cache_lock:
self._jma_cache.pop(f"{normalized}:{use_fahrenheit}", None)
+16 -66
View File
@@ -14,6 +14,7 @@ import requests
from loguru import logger
from src.database.sqlite_connection import connect_sqlite
from src.auth.supabase_admin_client import get_supabase_admin_client
class DBManager:
@@ -54,28 +55,16 @@ class DBManager:
self._initialized_paths.add(cache_key)
def _supabase_profiles_endpoint(self) -> str:
supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/")
if not supabase_url:
return ""
return f"{supabase_url}/rest/v1/profiles"
return get_supabase_admin_client().profiles_endpoint()
def _supabase_service_headers(self) -> Dict[str, str]:
service_role_key = str(os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "").strip()
if not service_role_key:
client = get_supabase_admin_client()
if not client.configured:
return {}
return {
"apikey": service_role_key,
"Authorization": f"Bearer {service_role_key}",
"Accept": "application/json",
"Content-Type": "application/json",
"Prefer": "return=minimal",
}
return client._service_headers()
def _supabase_admin_users_endpoint(self) -> str:
supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/")
if not supabase_url:
return ""
return f"{supabase_url}/auth/v1/admin/users"
return get_supabase_admin_client().admin_users_endpoint()
def _profile_sync_min_interval_sec(self) -> float:
raw = str(
@@ -249,35 +238,16 @@ class DBManager:
):
return False
try:
resp = requests.patch(
f"{endpoint}/{supabase_user_id}",
json={"user_metadata": {"points": points}},
headers={**headers, "Prefer": "return=minimal"},
timeout=8,
)
if resp.status_code not in (200, 204):
logger.warning(
"supabase points sync failed tg={} suid={} status={} body={}",
telegram_id,
supabase_user_id,
resp.status_code,
(resp.text or "")[:200],
)
return False
admin = get_supabase_admin_client()
if not admin.configured:
return False
ok = admin.patch_user_metadata(supabase_user_id, {"points": points})
if ok:
self._remember_points_metadata_sync(
telegram_id=int(telegram_id),
points=points,
)
return True
except Exception as exc:
logger.warning(
"supabase points sync error tg={} suid={}: {}",
telegram_id,
supabase_user_id,
exc,
)
return False
return ok
def _sync_supabase_profile_telegram_fields(
self,
@@ -305,35 +275,15 @@ class DBManager:
"telegram_username": str(telegram_username or "").strip() or None,
"updated_at": datetime.now().isoformat(),
}
try:
response = requests.patch(
endpoint,
params={"id": f"eq.{normalized_uid}"},
json=payload,
headers=headers,
timeout=8,
)
if response.status_code not in {200, 204}:
logger.warning(
"supabase profiles telegram sync failed user_id={} status={} body={}",
normalized_uid,
response.status_code,
(response.text or "")[:240],
)
return False
admin = get_supabase_admin_client()
ok = admin.patch_profile(normalized_uid, payload)
if ok:
self._remember_profile_sync(
supabase_user_id=normalized_uid,
telegram_id=telegram_id,
telegram_username=telegram_username,
)
return True
except Exception as exc:
logger.warning(
"supabase profiles telegram sync error user_id={}: {}",
normalized_uid,
exc,
)
return False
return ok
def _sync_bound_supabase_profiles_for_telegram(
self,