架构重构:拆分 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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user