Batch scan terminal model forecasts

This commit is contained in:
2569718930@qq.com
2026-06-30 13:22:11 +08:00
parent a5f6bca527
commit 15de44767e
14 changed files with 668 additions and 134 deletions
+5 -18
View File
@@ -1,10 +1,8 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { proxyBackendJsonGet } from "@/lib/api-proxy"; import { proxyBackendJsonGet } from "@/lib/api-proxy";
import { import {
buildForceRefreshProxyCachePolicy, NO_STORE_CACHE_CONTROL,
buildScanTerminalResponseCacheControl,
} from "@/lib/proxy-cache-policy"; } from "@/lib/proxy-cache-policy";
import { DASHBOARD_REFRESH_POLICY_SEC } from "@/lib/refresh-policy";
import { import {
createProxyTimer, createProxyTimer,
finishProxyTimedResponse, finishProxyTimedResponse,
@@ -12,10 +10,10 @@ import {
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const SCAN_TERMINAL_PROXY_TIMEOUT_MS = Number( const SCAN_TERMINAL_PROXY_TIMEOUT_MS = Number(
process.env.POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS || "35000", process.env.POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS || "60000",
); );
export const maxDuration = 45; export const maxDuration = 70;
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const timer = createProxyTimer(req, "scan_terminal"); const timer = createProxyTimer(req, "scan_terminal");
@@ -31,7 +29,6 @@ export async function GET(req: NextRequest) {
} }
const params = new URLSearchParams(); const params = new URLSearchParams();
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
for (const key of [ for (const key of [
"scan_mode", "scan_mode",
"min_price", "min_price",
@@ -56,11 +53,6 @@ export async function GET(req: NextRequest) {
if (tradingRegion != null && tradingRegion !== "") { if (tradingRegion != null && tradingRegion !== "") {
params.set("region", tradingRegion); params.set("region", tradingRegion);
} }
const cachePolicy = buildForceRefreshProxyCachePolicy(
forceRefresh,
DASHBOARD_REFRESH_POLICY_SEC.scanRows,
);
const url = `${API_BASE}/api/scan/terminal?${params.toString()}`; const url = `${API_BASE}/api/scan/terminal?${params.toString()}`;
const controller = new AbortController(); const controller = new AbortController();
@@ -68,15 +60,10 @@ export async function GET(req: NextRequest) {
try { try {
return await proxyBackendJsonGet(req, { return await proxyBackendJsonGet(req, {
cacheControl: cachePolicy.responseCacheControl, cacheControl: NO_STORE_CACHE_CONTROL,
cacheControlForData: (data) => cacheControlForData: () => NO_STORE_CACHE_CONTROL,
buildScanTerminalResponseCacheControl(
data,
cachePolicy.responseCacheControl,
),
fetchCache: "no-store", fetchCache: "no-store",
publicMessage: "Failed to fetch scan terminal data", publicMessage: "Failed to fetch scan terminal data",
revalidateSeconds: cachePolicy.revalidateSeconds,
includeSupabaseIdentity: true, includeSupabaseIdentity: true,
signal: controller.signal, signal: controller.signal,
timeoutPublicMessage: "Scan terminal request timed out", timeoutPublicMessage: "Scan terminal request timed out",
@@ -3,7 +3,6 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { import {
buildCityListCacheControl, buildCityListCacheControl,
buildScanTerminalResponseCacheControl,
buildCityDetailProxyCachePolicy, buildCityDetailProxyCachePolicy,
buildForceRefreshProxyCachePolicy, buildForceRefreshProxyCachePolicy,
buildPublicEdgeCacheControl, buildPublicEdgeCacheControl,
@@ -40,42 +39,19 @@ export function runTests() {
const scanForced = buildForceRefreshProxyCachePolicy("true", 10); const scanForced = buildForceRefreshProxyCachePolicy("true", 10);
assert.equal(scanForced.fetchMode, "no-store"); assert.equal(scanForced.fetchMode, "no-store");
const normalScanCache = "public, max-age=0, s-maxage=300, stale-while-revalidate=900";
assert.equal(
buildScanTerminalResponseCacheControl({ status: "ready", stale: false }, normalScanCache),
normalScanCache,
);
assert.equal(
buildScanTerminalResponseCacheControl({ status: "failed", stale: false }, normalScanCache),
NO_STORE_CACHE_CONTROL,
);
assert.equal(
buildScanTerminalResponseCacheControl({ status: "partial", stale: false }, normalScanCache),
NO_STORE_CACHE_CONTROL,
);
assert.equal(
buildScanTerminalResponseCacheControl({ status: "ready", stale: true }, normalScanCache),
NO_STORE_CACHE_CONTROL,
);
const scanTerminalProxySource = fs.readFileSync( const scanTerminalProxySource = fs.readFileSync(
path.join(process.cwd(), "app", "api", "scan", "terminal", "route.ts"), path.join(process.cwd(), "app", "api", "scan", "terminal", "route.ts"),
"utf8", "utf8",
); );
assert.match(
scanTerminalProxySource,
/DASHBOARD_REFRESH_POLICY_SEC\.scanRows/,
"scan terminal proxy cache TTL should match the dashboard scan refresh cadence instead of a short literal TTL",
);
assert.doesNotMatch( assert.doesNotMatch(
scanTerminalProxySource, scanTerminalProxySource,
/buildForceRefreshProxyCachePolicy\(forceRefresh,\s*10\)/, /buildForceRefreshProxyCachePolicy/,
"scan terminal proxy must not use the old 10 second edge cache because it over-drives the slow scan endpoint", "scan terminal proxy must not use public edge cache because rows depend on user entitlement and live scan state",
); );
assert.match( assert.match(
scanTerminalProxySource, scanTerminalProxySource,
/cacheControlForData:\s*\(data\)\s*=>\s*buildScanTerminalResponseCacheControl/, /cacheControlForData:\s*\(\)\s*=>\s*NO_STORE_CACHE_CONTROL/,
"scan terminal proxy must not CDN-cache failed, stale, or partial business payloads", "scan terminal proxy must keep all scan payloads out of shared CDN caches",
); );
assert.match( assert.match(
scanTerminalProxySource, scanTerminalProxySource,
@@ -117,12 +117,12 @@ export function runTests() {
assert.match(scanTerminalProxy, /timing:\s*timer/); assert.match(scanTerminalProxy, /timing:\s*timer/);
assert.match( assert.match(
scanTerminalProxy, scanTerminalProxy,
/POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS\s*\|\|\s*"35000"/, /POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS\s*\|\|\s*"60000"/,
"scan terminal proxy should allow the production backend enough time to return before the 45 second route cap", "scan terminal proxy should allow the production backend enough time for batched model refreshes",
); );
assert.match( assert.match(
scanTerminalProxy, scanTerminalProxy,
/export const maxDuration = 45/, /export const maxDuration = 70/,
"scan terminal proxy timeout budget should remain below the Next route execution cap", "scan terminal proxy timeout budget should remain below the Next route execution cap",
); );
+2 -3
View File
@@ -64,8 +64,7 @@ SCAN_EXPRESSION = """(
http.host eq "polyweather.top" http.host eq "polyweather.top"
and http.request.method in {"GET" "HEAD"} and http.request.method in {"GET" "HEAD"}
and ( and (
http.request.uri.path eq "/api/scan/terminal" http.request.uri.path eq "/api/system/status"
or http.request.uri.path eq "/api/system/status"
) )
)""" )"""
@@ -135,7 +134,7 @@ def build_managed_cache_rules() -> List[Dict[str, Any]]:
_cache_rule("pages", "PolyWeather: cache public pages for ten minutes", PUBLIC_PAGES_EXPRESSION, 600), _cache_rule("pages", "PolyWeather: cache public pages for ten minutes", PUBLIC_PAGES_EXPRESSION, 600),
_cache_rule("cities", "PolyWeather: cache the public city list for five minutes", CITIES_EXPRESSION, 300), _cache_rule("cities", "PolyWeather: cache the public city list for five minutes", CITIES_EXPRESSION, 300),
_cache_rule("city_detail", "PolyWeather: cache public city detail when the origin allows it", CITY_DETAIL_EXPRESSION), _cache_rule("city_detail", "PolyWeather: cache public city detail when the origin allows it", CITY_DETAIL_EXPRESSION),
_cache_rule("scan", "PolyWeather: cache scan and system status when the origin allows it", SCAN_EXPRESSION), _cache_rule("system_status", "PolyWeather: cache system status when the origin allows it", SCAN_EXPRESSION),
{ {
"ref": f"{MANAGED_RULE_REF_PREFIX}bypass", "ref": f"{MANAGED_RULE_REF_PREFIX}bypass",
"description": "PolyWeather: bypass backend, sensitive, realtime, and force-refresh requests", "description": "PolyWeather: bypass backend, sensitive, realtime, and force-refresh requests",
-1
View File
@@ -253,7 +253,6 @@ main() {
check_cached_endpoint "/api/history/ankara" "history" check_cached_endpoint "/api/history/ankara" "history"
check_if_none_match_304 "/api/history/ankara" "history" check_if_none_match_304 "/api/history/ankara" "history"
check_cloudflare_cache_hit "/api/scan/terminal?limit=1" "scan terminal edge cache"
print_line "" print_line ""
if [ "$FAIL_COUNT" -gt 0 ]; then if [ "$FAIL_COUNT" -gt 0 ]; then
+1 -3
View File
@@ -144,9 +144,7 @@ def test_scan_terminal_response_includes_backend_server_timing(monkeypatch):
assert "scan_terminal_assert_entitlement" in server_timing assert "scan_terminal_assert_entitlement" in server_timing
assert "scan_terminal_build_payload" in server_timing assert "scan_terminal_build_payload" in server_timing
assert "scan_terminal_total" in server_timing assert "scan_terminal_total" in server_timing
assert response.headers["cache-control"] == ( assert response.headers["cache-control"] == "no-store, max-age=0"
"public, max-age=0, s-maxage=300, stale-while-revalidate=900"
)
assert response.headers["cloudflare-cdn-cache-control"] == response.headers["cache-control"] assert response.headers["cloudflare-cdn-cache-control"] == response.headers["cache-control"]
+1 -1
View File
@@ -14,4 +14,4 @@ def test_frontend_cache_validator_checks_cloudflare_edge_hits():
assert "MISS" in script assert "MISS" in script
assert "REVALIDATED" in script assert "REVALIDATED" in script
assert 'check_cloudflare_cache_hit "/api/cities" "cities edge cache"' in script assert 'check_cloudflare_cache_hit "/api/cities" "cities edge cache"' in script
assert 'check_cloudflare_cache_hit "/api/scan/terminal?limit=1" "scan terminal edge cache"' in script assert "/api/scan/terminal?limit=1" not in script
+4 -2
View File
@@ -14,7 +14,7 @@ def test_cloudflare_managed_rules_apply_path_specific_edge_ttls_then_bypass_sens
f"{MANAGED_RULE_REF_PREFIX}pages", f"{MANAGED_RULE_REF_PREFIX}pages",
f"{MANAGED_RULE_REF_PREFIX}cities", f"{MANAGED_RULE_REF_PREFIX}cities",
f"{MANAGED_RULE_REF_PREFIX}city_detail", f"{MANAGED_RULE_REF_PREFIX}city_detail",
f"{MANAGED_RULE_REF_PREFIX}scan", f"{MANAGED_RULE_REF_PREFIX}system_status",
f"{MANAGED_RULE_REF_PREFIX}bypass", f"{MANAGED_RULE_REF_PREFIX}bypass",
] ]
assert [ assert [
@@ -31,6 +31,8 @@ def test_cloudflare_managed_rules_apply_path_specific_edge_ttls_then_bypass_sens
"cache": True, "cache": True,
"browser_ttl": {"mode": "respect_origin"}, "browser_ttl": {"mode": "respect_origin"},
} }
assert 'http.request.uri.path eq "/api/system/status"' in rules[4]["expression"]
assert "/api/scan/terminal" not in rules[4]["expression"]
assert rules[-1]["action_parameters"]["cache"] is False assert rules[-1]["action_parameters"]["cache"] is False
assert 'http.host eq "api.polyweather.top"' in rules[-1]["expression"] assert 'http.host eq "api.polyweather.top"' in rules[-1]["expression"]
assert 'http.request.uri.query contains "force_refresh=true"' in rules[-1]["expression"] assert 'http.request.uri.query contains "force_refresh=true"' in rules[-1]["expression"]
@@ -64,7 +66,7 @@ def test_cloudflare_rule_merge_preserves_unmanaged_rules_and_puts_bypass_last():
f"{MANAGED_RULE_REF_PREFIX}pages", f"{MANAGED_RULE_REF_PREFIX}pages",
f"{MANAGED_RULE_REF_PREFIX}cities", f"{MANAGED_RULE_REF_PREFIX}cities",
f"{MANAGED_RULE_REF_PREFIX}city_detail", f"{MANAGED_RULE_REF_PREFIX}city_detail",
f"{MANAGED_RULE_REF_PREFIX}scan", f"{MANAGED_RULE_REF_PREFIX}system_status",
f"{MANAGED_RULE_REF_PREFIX}bypass", f"{MANAGED_RULE_REF_PREFIX}bypass",
] ]
assert merged[-1]["action_parameters"]["cache"] is False assert merged[-1]["action_parameters"]["cache"] is False
+1 -1
View File
@@ -193,7 +193,7 @@ def test_scan_terminal_backend_timeout_returns_before_next_proxy_abort():
ROOT / "web" / "services" / "scan_terminal_config.py" ROOT / "web" / "services" / "scan_terminal_config.py"
).read_text(encoding="utf-8") ).read_text(encoding="utf-8")
assert 'POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS || "35000"' in route_source assert 'POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS || "60000"' in route_source
assert '"POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC",\n 30,' in config_source assert '"POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC",\n 30,' in config_source
assert '"POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS",\n 1,' in config_source assert '"POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS",\n 1,' in config_source
assert ( assert (
+277
View File
@@ -59,6 +59,19 @@ def test_scan_terminal_cache_hydrates_success_payload_from_redis(monkeypatch):
assert cached["summary"]["candidate_total"] == 1 assert cached["summary"]["candidate_total"] == 1
def test_scan_terminal_redis_cache_prefix_skips_legacy_blank_snapshots(monkeypatch):
monkeypatch.delenv("POLYWEATHER_SCAN_TERMINAL_REDIS_CACHE_PREFIX", raising=False)
assert scan_terminal_cache._redis_cache_prefix() == "polyweather:scan_terminal:v2:"
monkeypatch.setenv(
"POLYWEATHER_SCAN_TERMINAL_REDIS_CACHE_PREFIX",
"polyweather:scan_terminal:v1:",
)
assert scan_terminal_cache._redis_cache_prefix() == "polyweather:scan_terminal:v2:"
def test_scan_terminal_failure_state_preserves_redis_success_payload(monkeypatch): def test_scan_terminal_failure_state_preserves_redis_success_payload(monkeypatch):
fake_redis = _FakeRedis() fake_redis = _FakeRedis()
monkeypatch.setenv("POLYWEATHER_SCAN_TERMINAL_REDIS_CACHE_ENABLED", "true") monkeypatch.setenv("POLYWEATHER_SCAN_TERMINAL_REDIS_CACHE_ENABLED", "true")
@@ -433,6 +446,270 @@ def test_scan_city_terminal_rows_refreshes_models_for_wrong_local_date_panel(mon
] ]
def test_scan_terminal_fetches_multi_model_daily_in_batches(monkeypatch):
today_paris = _local_date_for_offset(3600)
today_houston = _local_date_for_offset(-18000)
monkeypatch.setattr(
scan_terminal_city_row,
"CITIES",
{
"paris": {"lat": 48.85, "lon": 2.35, "tz": 3600},
"houston": {"lat": 29.76, "lon": -95.36, "tz": -18000, "f": True},
},
)
monkeypatch.setattr(
scan_terminal_city_row._weather,
"_multi_model_cache",
{},
raising=False,
)
monkeypatch.setattr(
scan_terminal_city_row._weather,
"_maybe_reload_open_meteo_disk_cache",
lambda: None,
raising=False,
)
monkeypatch.setattr(
scan_terminal_city_row._weather,
"_flush_open_meteo_disk_cache",
lambda: None,
raising=False,
)
requests = []
class _Response:
def __init__(self, payload):
self._payload = payload
@staticmethod
def raise_for_status():
return None
def json(self):
return self._payload
def _http_get(_url, *, params, timeout):
requests.append((params, timeout))
unit_is_f = params.get("temperature_unit") == "fahrenheit"
date = today_houston if unit_is_f else today_paris
value = 94.0 if unit_is_f else 24.0
return _Response(
[
{
"daily": {
"time": [date],
"temperature_2m_max_ecmwf_ifs025": [value],
"temperature_2m_max_gfs_seamless": [value + 1],
}
}
]
)
monkeypatch.setattr(scan_terminal_city_row._weather, "_http_get", _http_get, raising=False)
monkeypatch.setattr(
scan_terminal_city_row._weather,
"_wait_open_meteo_slot",
lambda _endpoint: None,
raising=False,
)
result = scan_terminal_city_row._fetch_scan_terminal_multi_model_batch(["paris", "houston"])
assert len(requests) == 2
assert requests[0][0]["latitude"] == "48.85"
assert requests[1][0]["temperature_unit"] == "fahrenheit"
assert result["paris"]["daily_forecasts"][today_paris]["ECMWF"] == 24.0
assert result["paris"]["daily_forecasts"][today_paris]["GFS"] == 25.0
assert result["houston"]["daily_forecasts"][today_houston]["ECMWF"] == 94.0
def test_scan_terminal_multi_model_batch_chunks_long_city_lists(monkeypatch):
today = _local_date_for_offset(0)
cities = {
f"city{i}": {"lat": float(i), "lon": float(i + 1), "tz": 0}
for i in range(45)
}
monkeypatch.setattr(scan_terminal_city_row, "CITIES", cities)
monkeypatch.setattr(
scan_terminal_city_row._weather,
"_multi_model_cache",
{},
raising=False,
)
monkeypatch.setattr(
scan_terminal_city_row._weather,
"_maybe_reload_open_meteo_disk_cache",
lambda: None,
raising=False,
)
monkeypatch.setattr(
scan_terminal_city_row._weather,
"_flush_open_meteo_disk_cache",
lambda: None,
raising=False,
)
monkeypatch.setattr(
scan_terminal_city_row._weather,
"_wait_open_meteo_slot",
lambda _endpoint: None,
raising=False,
)
requests = []
class _Response:
def __init__(self, count):
self.count = count
@staticmethod
def raise_for_status():
return None
def json(self):
return [
{
"daily": {
"time": [today],
"temperature_2m_max_ecmwf_ifs025": [20.0 + idx],
}
}
for idx in range(self.count)
]
def _http_get(_url, *, params, timeout):
latitude_count = len(str(params["latitude"]).split(","))
requests.append(latitude_count)
return _Response(latitude_count)
monkeypatch.setattr(scan_terminal_city_row._weather, "_http_get", _http_get, raising=False)
result = scan_terminal_city_row._fetch_scan_terminal_multi_model_batch(list(cities.keys()))
assert requests == [20, 20, 5]
assert len(result) == 45
assert result["city0"]["daily_forecasts"][today]["ECMWF"] == 20.0
assert result["city44"]["daily_forecasts"][today]["ECMWF"] == 24.0
def test_scan_terminal_uncached_passes_batch_multi_model_overrides(monkeypatch):
scan_terminal_cache._SCAN_TERMINAL_CACHE.clear()
monkeypatch.setattr(
scan_terminal_service,
"CITIES",
{"paris": {"tz": 3600}, "houston": {"tz": -18000}},
)
monkeypatch.setattr(scan_terminal_service, "SCAN_TERMINAL_MAX_WORKERS", 2)
monkeypatch.setattr(
scan_terminal_service,
"_fetch_scan_terminal_multi_model_batch",
lambda cities: {"paris": {"source": "batch-paris"}},
)
seen = []
def _scan_city(
city,
_filters,
*,
force_refresh=False,
multi_model_override=None,
allow_direct_fetch=True,
):
seen.append((city, force_refresh, multi_model_override, allow_direct_fetch))
return {
"city": city,
"candidate_total": 1,
"primary_scores": [1.0],
"rows": [
{
"id": f"{city}:today",
"market_key": f"{city}:today",
"final_score": 1.0,
"edge_percent": 0.0,
"volume": 0.0,
}
],
}
monkeypatch.setattr(scan_terminal_service, "_scan_city_terminal_rows", _scan_city)
payload = scan_terminal_service._build_scan_terminal_payload_uncached(
{"limit": 2},
force_refresh=True,
timeout_sec=5,
)
assert payload["status"] == "ready"
assert sorted(seen) == [
("paris", True, {"source": "batch-paris"}, False),
]
assert payload["summary"]["total_city_count"] == 2
assert payload["summary"]["scanned_city_count"] == 1
def test_scan_city_terminal_rows_builds_forecast_only_row_from_batch_override(monkeypatch):
today = _local_date_for_offset(3600)
class _Cache:
@staticmethod
def get_city_cache(*_args, **_kwargs):
raise AssertionError("batch forecast rows should not read panel cache")
@staticmethod
def get_canonical_temperature(*_args, **_kwargs):
raise AssertionError("batch forecast rows should not read canonical cache")
monkeypatch.setattr(scan_terminal_city_row, "_PANEL_CACHE_DB", _Cache())
monkeypatch.setattr(
scan_terminal_city_row,
"calculate_deb_prediction",
lambda city, forecasts, raw_calculator=None: {"prediction": 24.5},
)
result = scan_terminal_city_row._scan_city_terminal_rows(
"paris",
{"market_type": "maxtemp"},
force_refresh=True,
multi_model_override={
"daily_forecasts": {today: {"ECMWF": 24.0, "GFS": 25.0}},
"dates": [today],
"unit": "celsius",
},
allow_direct_fetch=False,
)
row = result["rows"][0]
assert row["local_date"] == today
assert row["deb_prediction"] == 24.5
assert row["model_cluster_sources"] == {"ECMWF": 24.0, "GFS": 25.0}
assert row["forecast_refreshed"] is True
def test_scan_timeout_prefers_partial_rows_with_models_over_blank_stale_cache(monkeypatch):
cached_entry = {
"success_payload": {
"rows": [
{"id": "old-1", "model_cluster_sources": {}},
{"id": "old-2", "model_cluster_sources": {}},
]
},
"last_failed_at": None,
}
stale = scan_terminal_service._build_stale_payload_for_timeout_if_better_cached(
filters={"limit": 2},
cached_entry=cached_entry,
ranked_rows=[
{
"id": "new-1",
"model_cluster_sources": {"ECMWF": 24.0},
}
],
timeout_message="scan terminal build timed out after 30s",
)
assert stale is None
def test_scan_city_terminal_rows_uses_canonical_without_analyze(monkeypatch): def test_scan_city_terminal_rows_uses_canonical_without_analyze(monkeypatch):
enqueued = [] enqueued = []
+3 -12
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from web.services.cache_headers import NO_STORE_CACHE_CONTROL, public_edge_cache_control from web.services.cache_headers import NO_STORE_CACHE_CONTROL
from web.services.scan_api import ( from web.services.scan_api import (
get_scan_terminal_overview_payload, get_scan_terminal_overview_payload,
get_scan_terminal_payload, get_scan_terminal_payload,
@@ -14,9 +14,6 @@ from web.services.request_timing import attach_server_timing_header
router = APIRouter(tags=["scan"]) router = APIRouter(tags=["scan"])
SCAN_TERMINAL_CACHE_CONTROL = public_edge_cache_control(300, 900)
@router.get("/api/scan/terminal") @router.get("/api/scan/terminal")
async def scan_terminal( async def scan_terminal(
request: Request, request: Request,
@@ -53,17 +50,11 @@ async def scan_terminal(
region=region or trading_region or None, region=region or trading_region or None,
timezone_offset_seconds=timezone_offset_seconds, timezone_offset_seconds=timezone_offset_seconds,
) )
status = str(payload.get("status") or "").strip().lower()
cache_control = (
SCAN_TERMINAL_CACHE_CONTROL
if not force_refresh and status == "ready" and payload.get("stale") is not True
else NO_STORE_CACHE_CONTROL
)
response = JSONResponse( response = JSONResponse(
content=payload, content=payload,
headers={ headers={
"Cache-Control": cache_control, "Cache-Control": NO_STORE_CACHE_CONTROL,
"Cloudflare-CDN-Cache-Control": cache_control, "Cloudflare-CDN-Cache-Control": NO_STORE_CACHE_CONTROL,
}, },
) )
attach_server_timing_header(response, request, "scan_terminal_server_timing") attach_server_timing_header(response, request, "scan_terminal_server_timing")
+5 -2
View File
@@ -43,10 +43,13 @@ def _redis_cache_ttl_sec() -> int:
def _redis_cache_prefix() -> str: def _redis_cache_prefix() -> str:
return os.getenv( prefix = os.getenv(
"POLYWEATHER_SCAN_TERMINAL_REDIS_CACHE_PREFIX", "POLYWEATHER_SCAN_TERMINAL_REDIS_CACHE_PREFIX",
"polyweather:scan_terminal:v1:", "polyweather:scan_terminal:v2:",
) )
if prefix.endswith(":v1:"):
return f"{prefix[:-4]}:v2:"
return prefix
def _redis_entry_key(cache_key: str) -> str: def _redis_entry_key(cache_key: str) -> str:
+269 -14
View File
@@ -8,6 +8,10 @@ from typing import Any, Dict, List, Optional
from src.analysis.deb_algorithm import calculate_deb_prediction, calculate_dynamic_weights from src.analysis.deb_algorithm import calculate_deb_prediction, calculate_dynamic_weights
from src.analysis.trend_engine import calculate_prob_distribution from src.analysis.trend_engine import calculate_prob_distribution
from src.data_collection.nws_open_meteo_sources import (
OPEN_METEO_MULTI_MODEL_ORDER,
_parse_open_meteo_multi_model_daily,
)
from src.data_collection.multi_model_freshness import multi_model_forecasts_for_local_date from src.data_collection.multi_model_freshness import multi_model_forecasts_for_local_date
from src.database.db_manager import DBManager from src.database.db_manager import DBManager
from src.utils.refresh_policy import SCAN_ROWS_REFRESH_SEC from src.utils.refresh_policy import SCAN_ROWS_REFRESH_SEC
@@ -22,6 +26,7 @@ from web.services.city_payloads import aggregate_runway_history
SCAN_ROW_RUNWAY_HISTORY_RESOLUTION = "10m" SCAN_ROW_RUNWAY_HISTORY_RESOLUTION = "10m"
SCAN_ROW_MAX_RUNWAY_POINTS = 144 SCAN_ROW_MAX_RUNWAY_POINTS = 144
SCAN_TERMINAL_MULTI_MODEL_BATCH_SIZE = 20
_PANEL_CACHE_DB = DBManager() _PANEL_CACHE_DB = DBManager()
_analyze = None # compatibility hook for tests that assert scan terminal stays cache-only. _analyze = None # compatibility hook for tests that assert scan terminal stays cache-only.
SCAN_PANEL_CACHE_MAX_AGE_SEC = max(300, int(SCAN_ROWS_REFRESH_SEC) * 3) SCAN_PANEL_CACHE_MAX_AGE_SEC = max(300, int(SCAN_ROWS_REFRESH_SEC) * 3)
@@ -101,9 +106,192 @@ def _model_spread_sigma(forecasts: Dict[str, float], temp_symbol: str) -> float:
return max(0.8 * scale, min(4.0 * scale, spread / 2.0)) return max(0.8 * scale, min(4.0 * scale, spread / 2.0))
def _multi_model_cache_key(
city: str,
lat: float,
lon: float,
*,
use_fahrenheit: bool,
) -> str:
version = str(getattr(_weather, "multi_model_cache_version", "v5") or "v5")
return (
f"{round(float(lat), 4)}:{round(float(lon), 4)}:{str(city or '').strip().lower()}:"
f"{'f' if use_fahrenheit else 'c'}:{version}"
)
def _read_cached_multi_model_for_today(
city: str,
*,
lat: float,
lon: float,
use_fahrenheit: bool,
local_date: str,
) -> Optional[Dict[str, Any]]:
maybe_reload = getattr(_weather, "_maybe_reload_open_meteo_disk_cache", None)
if callable(maybe_reload):
try:
maybe_reload()
except Exception:
pass
cache = getattr(_weather, "_multi_model_cache", None)
lock = getattr(_weather, "_multi_model_cache_lock", None)
if not isinstance(cache, dict) or lock is None:
return None
key = _multi_model_cache_key(city, lat, lon, use_fahrenheit=use_fahrenheit)
try:
with lock:
entry = cache.get(key)
data = entry.get("data") if isinstance(entry, dict) else None
except Exception:
return None
if isinstance(data, dict) and multi_model_forecasts_for_local_date(data, local_date):
return dict(data)
return None
def _store_multi_model_cache(
city: str,
payload: Dict[str, Any],
*,
lat: float,
lon: float,
use_fahrenheit: bool,
) -> None:
cache = getattr(_weather, "_multi_model_cache", None)
lock = getattr(_weather, "_multi_model_cache_lock", None)
if not isinstance(cache, dict) or lock is None:
return
key = _multi_model_cache_key(city, lat, lon, use_fahrenheit=use_fahrenheit)
try:
with lock:
cache[key] = {"t": time.time(), "data": dict(payload)}
except Exception:
return
def _fetch_scan_terminal_multi_model_batch(city_names: List[str]) -> Dict[str, Dict[str, Any]]:
"""Fetch daily max multi-model payloads for scan rows in batch.
The scan terminal only needs today's max per model. A batched daily request
avoids 50 per-city calls while still preserving city-local dates.
"""
grouped: Dict[bool, List[Dict[str, Any]]] = {False: [], True: []}
results: Dict[str, Dict[str, Any]] = {}
for city in city_names:
city_meta = CITIES.get(city) or {}
lat = _safe_float(city_meta.get("lat"))
lon = _safe_float(city_meta.get("lon"))
if lat is None or lon is None:
continue
use_fahrenheit = bool(city_meta.get("f"))
local_date = _city_local_date(city, _safe_int(city_meta.get("tz"), 0))
cached = _read_cached_multi_model_for_today(
city,
lat=lat,
lon=lon,
use_fahrenheit=use_fahrenheit,
local_date=local_date,
)
if cached:
results[city] = cached
continue
grouped[use_fahrenheit].append(
{
"city": city,
"lat": lat,
"lon": lon,
"local_date": local_date,
}
)
http_get = getattr(_weather, "_http_get", None)
if not callable(http_get):
return results
stored_any = False
for use_fahrenheit, unit_items in grouped.items():
if not unit_items:
continue
for start in range(0, len(unit_items), SCAN_TERMINAL_MULTI_MODEL_BATCH_SIZE):
items = unit_items[start : start + SCAN_TERMINAL_MULTI_MODEL_BATCH_SIZE]
if not items:
continue
try:
wait_slot = getattr(_weather, "_wait_open_meteo_slot", None)
if callable(wait_slot):
wait_slot("scan-terminal-multi-model-batch")
params: Dict[str, Any] = {
"latitude": ",".join(str(item["lat"]) for item in items),
"longitude": ",".join(str(item["lon"]) for item in items),
"daily": "temperature_2m_max",
"models": ",".join(OPEN_METEO_MULTI_MODEL_ORDER),
"timezone": "auto",
"forecast_days": 3,
}
if use_fahrenheit:
params["temperature_unit"] = "fahrenheit"
response = http_get(
"https://api.open-meteo.com/v1/forecast",
params=params,
timeout=max(10.0, float(getattr(_weather, "open_meteo_timeout_sec", 5.0))),
)
response.raise_for_status()
raw = response.json()
payloads = raw if isinstance(raw, list) else [raw]
for item, location_payload in zip(items, payloads):
daily = location_payload.get("daily", {}) if isinstance(location_payload, dict) else {}
if not isinstance(daily, dict):
continue
dates, daily_forecasts, model_metadata, model_keys = _parse_open_meteo_multi_model_daily(daily)
if not daily_forecasts:
continue
local_date = item["local_date"]
forecasts = daily_forecasts.get(local_date) or {}
if not forecasts:
continue
city = str(item["city"])
result = {
"source": "multi_model",
"provider": "open-meteo",
"forecasts": forecasts,
"daily_forecasts": daily_forecasts,
"hourly_times": [],
"hourly_forecasts": {},
"model_metadata": model_metadata,
"model_keys": model_keys,
"dates": dates,
"unit": "fahrenheit" if use_fahrenheit else "celsius",
"attribution": "Open-Meteo forecast model API; underlying models from ECMWF, DWD, ECCC, NOAA/NCEP, Google and JMA.",
"scan_terminal_batch": True,
}
results[city] = result
_store_multi_model_cache(
city,
result,
lat=float(item["lat"]),
lon=float(item["lon"]),
use_fahrenheit=use_fahrenheit,
)
stored_any = True
except Exception:
continue
if stored_any:
flush = getattr(_weather, "_flush_open_meteo_disk_cache", None)
if callable(flush):
try:
flush()
except Exception:
pass
return results
def _fetch_today_forecast_panel_payload( def _fetch_today_forecast_panel_payload(
city: str, city: str,
payload: Dict[str, Any], payload: Dict[str, Any],
*,
multi_model_override: Optional[Dict[str, Any]] = None,
allow_direct_fetch: bool = True,
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
city_meta = CITIES.get(city) or {} city_meta = CITIES.get(city) or {}
lat = _safe_float(city_meta.get("lat")) lat = _safe_float(city_meta.get("lat"))
@@ -120,15 +308,19 @@ def _fetch_today_forecast_panel_payload(
local_date = _city_local_date(city, tz_offset_int) local_date = _city_local_date(city, tz_offset_int)
local_time = _city_local_time(city, tz_offset_int) local_time = _city_local_time(city, tz_offset_int)
try: multi_model = multi_model_override
multi_model = _weather.fetch_multi_model( if not isinstance(multi_model, dict):
lat, if not allow_direct_fetch:
lon, return None
city=city, try:
use_fahrenheit=use_fahrenheit, multi_model = _weather.fetch_multi_model(
) lat,
except Exception: lon,
multi_model = None city=city,
use_fahrenheit=use_fahrenheit,
)
except Exception:
multi_model = None
forecasts = multi_model_forecasts_for_local_date(multi_model, local_date) forecasts = multi_model_forecasts_for_local_date(multi_model, local_date)
if not forecasts: if not forecasts:
return None return None
@@ -186,7 +378,34 @@ def _fetch_today_forecast_panel_payload(
} }
def _load_scan_panel_payload(city: str, *, force_refresh: bool) -> Optional[Dict[str, Any]]: def _build_forecast_only_panel_payload(
city: str,
multi_model: Dict[str, Any],
) -> Optional[Dict[str, Any]]:
city_meta = CITIES.get(city) or {}
temp_symbol = "°F" if bool(city_meta.get("f")) else "°C"
return _fetch_today_forecast_panel_payload(
city,
{
"display_name": city_meta.get("name") or city_meta.get("display_name") or city,
"current": {},
"risk": {},
"probabilities": {},
"temp_symbol": temp_symbol,
"utc_offset_seconds": _safe_int(city_meta.get("tz"), 0),
},
multi_model_override=multi_model,
allow_direct_fetch=False,
)
def _load_scan_panel_payload(
city: str,
*,
force_refresh: bool,
multi_model_override: Optional[Dict[str, Any]] = None,
allow_direct_fetch: bool = True,
) -> Optional[Dict[str, Any]]:
refresh_already_queued = False refresh_already_queued = False
cached_entry = _PANEL_CACHE_DB.get_city_cache("panel", city) cached_entry = _PANEL_CACHE_DB.get_city_cache("panel", city)
cached_payload = cached_entry.get("payload") if isinstance(cached_entry, dict) else None cached_payload = cached_entry.get("payload") if isinstance(cached_entry, dict) else None
@@ -196,7 +415,12 @@ def _load_scan_panel_payload(city: str, *, force_refresh: bool) -> Optional[Dict
return cached_payload return cached_payload
_enqueue_scan_terminal_refresh(city, reason=stale_reason or "scan_terminal_force_forecast_refresh") _enqueue_scan_terminal_refresh(city, reason=stale_reason or "scan_terminal_force_forecast_refresh")
refresh_already_queued = True refresh_already_queued = True
refreshed_payload = _fetch_today_forecast_panel_payload(city, cached_payload) refreshed_payload = _fetch_today_forecast_panel_payload(
city,
cached_payload,
multi_model_override=multi_model_override,
allow_direct_fetch=allow_direct_fetch,
)
if refreshed_payload: if refreshed_payload:
return refreshed_payload return refreshed_payload
@@ -212,7 +436,12 @@ def _load_scan_panel_payload(city: str, *, force_refresh: bool) -> Optional[Dict
city_meta = CITIES.get(city) or {} city_meta = CITIES.get(city) or {}
payload.setdefault("display_name", city_meta.get("name") or city_meta.get("display_name") or city) payload.setdefault("display_name", city_meta.get("name") or city_meta.get("display_name") or city)
payload.setdefault("temp_symbol", canonical.get("temp_symbol") or "°C") payload.setdefault("temp_symbol", canonical.get("temp_symbol") or "°C")
refreshed_payload = _fetch_today_forecast_panel_payload(city, payload) refreshed_payload = _fetch_today_forecast_panel_payload(
city,
payload,
multi_model_override=multi_model_override,
allow_direct_fetch=allow_direct_fetch,
)
if refreshed_payload: if refreshed_payload:
payload = refreshed_payload payload = refreshed_payload
_enqueue_scan_terminal_refresh(city, reason="scan_terminal_canonical_fallback") _enqueue_scan_terminal_refresh(city, reason="scan_terminal_canonical_fallback")
@@ -353,8 +582,16 @@ def _scan_city_terminal_rows(
filters: Dict[str, Any], filters: Dict[str, Any],
*, *,
force_refresh: bool = False, force_refresh: bool = False,
multi_model_override: Optional[Dict[str, Any]] = None,
allow_direct_fetch: bool = True,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
return _scan_city_terminal_rows_quick(city, filters, force_refresh=force_refresh) return _scan_city_terminal_rows_quick(
city,
filters,
force_refresh=force_refresh,
multi_model_override=multi_model_override,
allow_direct_fetch=allow_direct_fetch,
)
def _scan_city_terminal_rows_quick( def _scan_city_terminal_rows_quick(
@@ -362,10 +599,28 @@ def _scan_city_terminal_rows_quick(
filters: Dict[str, Any], filters: Dict[str, Any],
*, *,
force_refresh: bool = False, force_refresh: bool = False,
multi_model_override: Optional[Dict[str, Any]] = None,
allow_direct_fetch: bool = True,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Fast path that returns cached analysis rows only — returns a single row per city """Fast path that returns cached analysis rows only — returns a single row per city
with cached analysis data (Obs, DEB, probabilities) but no market prices.""" with cached analysis data (Obs, DEB, probabilities) but no market prices."""
data = _load_scan_panel_payload(city, force_refresh=force_refresh) if isinstance(multi_model_override, dict) and multi_model_override:
data = _build_forecast_only_panel_payload(city, multi_model_override)
if data:
row = _build_quick_row(city=city, data=data)
return {
"city": city,
"rows": [row] if row else [],
"candidate_total": 1,
"primary_scores": [float(row.get("final_score") or 0)] if row else [],
}
data = _load_scan_panel_payload(
city,
force_refresh=force_refresh,
multi_model_override=multi_model_override,
allow_direct_fetch=allow_direct_fetch,
)
if not data: if not data:
return { return {
"city": city, "city": city,
+93 -46
View File
@@ -29,7 +29,10 @@ from web.scan_terminal_cache import (
set_cached_scan_terminal_payload, set_cached_scan_terminal_payload,
set_scan_terminal_failure_state, set_scan_terminal_failure_state,
) )
from web.scan_terminal_city_row import _scan_city_terminal_rows from web.scan_terminal_city_row import (
_fetch_scan_terminal_multi_model_batch,
_scan_city_terminal_rows,
)
from web.scan_terminal_filters import ( from web.scan_terminal_filters import (
normalize_scan_terminal_filters as _normalize_scan_terminal_filters, normalize_scan_terminal_filters as _normalize_scan_terminal_filters,
) )
@@ -67,6 +70,19 @@ def _rows_count(payload: Dict[str, Any]) -> int:
return len(rows) if isinstance(rows, list) else 0 return len(rows) if isinstance(rows, list) else 0
def _model_bearing_rows_count(rows: Any) -> int:
if not isinstance(rows, list):
return 0
count = 0
for row in rows:
if not isinstance(row, dict):
continue
sources = row.get("model_cluster_sources")
if isinstance(sources, dict) and sources:
count += 1
return count
def _success_payload_within_stale_window(cached_entry: Dict[str, Any]) -> bool: def _success_payload_within_stale_window(cached_entry: Dict[str, Any]) -> bool:
timestamp = cached_entry.get("success_t") or cached_entry.get("t") timestamp = cached_entry.get("success_t") or cached_entry.get("t")
try: try:
@@ -116,6 +132,10 @@ def _build_stale_payload_for_timeout_if_better_cached(
success_payload = cached_entry.get("success_payload") success_payload = cached_entry.get("success_payload")
if not isinstance(success_payload, dict) or not success_payload.get("rows"): if not isinstance(success_payload, dict) or not success_payload.get("rows"):
return None return None
if _model_bearing_rows_count(ranked_rows) > _model_bearing_rows_count(
success_payload.get("rows")
):
return None
if _rows_count(success_payload) < len(ranked_rows): if _rows_count(success_payload) < len(ranked_rows):
return None return None
@@ -184,59 +204,86 @@ def _build_scan_terminal_payload_uncached(
return _tz_region(tz)["key"] == region_filter return _tz_region(tz)["key"] == region_filter
city_names = [c for c in city_names if _city_in_region(c)] city_names = [c for c in city_names if _city_in_region(c)]
max_workers = max(1, min(SCAN_TERMINAL_MAX_WORKERS, len(city_names)))
city_results: List[Dict[str, Any]] = [] city_results: List[Dict[str, Any]] = []
failed_cities: List[str] = [] failed_cities: List[str] = []
failed_reasons: List[str] = [] failed_reasons: List[str] = []
multi_model_overrides = _fetch_scan_terminal_multi_model_batch(city_names)
scan_city_names = (
[city_name for city_name in city_names if city_name in multi_model_overrides]
if multi_model_overrides
else city_names
)
max_workers = max(1, min(SCAN_TERMINAL_MAX_WORKERS, len(scan_city_names)))
timed_out = False timed_out = False
timeout_message: Optional[str] = None timeout_message: Optional[str] = None
executor = ThreadPoolExecutor(max_workers=max_workers) if multi_model_overrides:
future_map = { for city_name in scan_city_names:
executor.submit( try:
_scan_city_terminal_rows, city_results.append(
city_name, _scan_city_terminal_rows(
filters, city_name,
force_refresh=force_refresh, filters,
): city_name force_refresh=force_refresh,
for city_name in city_names multi_model_override=multi_model_overrides.get(city_name),
} allow_direct_fetch=False,
try:
try:
completed = as_completed(
future_map,
timeout=build_timeout_sec,
)
for future in completed:
city_name = future_map[future]
try:
city_results.append(future.result())
except Exception as exc:
failed_cities.append(city_name)
failed_reasons.append(str(exc))
logger.warning(
"scan terminal city failed city={}: {}", city_name, exc
) )
except FutureTimeoutError: )
timed_out = True except Exception as exc:
timeout_message = ( failed_cities.append(city_name)
f"scan terminal build timed out after {build_timeout_sec:g}s" failed_reasons.append(str(exc))
) logger.warning(
failed_reasons.append(timeout_message) "scan terminal city failed city={}: {}", city_name, exc
for future, city_name in future_map.items(): )
if not future.done(): else:
future.cancel() executor = ThreadPoolExecutor(max_workers=max_workers)
failed_cities.append(city_name) future_map = {
logger.warning( executor.submit(
"{}; completed={}/{}", _scan_city_terminal_rows,
timeout_message, city_name,
len(city_results), filters,
len(city_names), force_refresh=force_refresh,
) multi_model_override=multi_model_overrides.get(city_name),
finally: allow_direct_fetch=False,
executor.shutdown(wait=False) ): city_name
for city_name in scan_city_names
}
try:
try:
completed = as_completed(
future_map,
timeout=build_timeout_sec,
)
for future in completed:
city_name = future_map[future]
try:
city_results.append(future.result())
except Exception as exc:
failed_cities.append(city_name)
failed_reasons.append(str(exc))
logger.warning(
"scan terminal city failed city={}: {}", city_name, exc
)
except FutureTimeoutError:
timed_out = True
timeout_message = (
f"scan terminal build timed out after {build_timeout_sec:g}s"
)
failed_reasons.append(timeout_message)
for future, city_name in future_map.items():
if not future.done():
future.cancel()
failed_cities.append(city_name)
logger.warning(
"{}; completed={}/{}",
timeout_message,
len(city_results),
len(scan_city_names),
)
finally:
executor.shutdown(wait=False)
if city_names and len(failed_cities) >= len(city_names): if scan_city_names and len(failed_cities) >= len(scan_city_names):
error_message = ( error_message = (
failed_reasons[0] if failed_reasons else "all city market scans failed" failed_reasons[0] if failed_reasons else "all city market scans failed"
) )