feat: implement Telegram push utility and temperature threshold visualization components
This commit is contained in:
@@ -28,6 +28,7 @@ import {
|
|||||||
isTemperatureSeriesVisibleByDefault,
|
isTemperatureSeriesVisibleByDefault,
|
||||||
mergePatchIntoHourly,
|
mergePatchIntoHourly,
|
||||||
normObs,
|
normObs,
|
||||||
|
prefersHighFrequencyRunwayResolution,
|
||||||
readSessionCache,
|
readSessionCache,
|
||||||
seedHourlyForecastFromRow,
|
seedHourlyForecastFromRow,
|
||||||
shouldPollLiveChart,
|
shouldPollLiveChart,
|
||||||
@@ -130,7 +131,9 @@ export function LiveTemperatureThresholdChart({
|
|||||||
const [refAreaLeft, setRefAreaLeft] = useState<number | null>(null);
|
const [refAreaLeft, setRefAreaLeft] = useState<number | null>(null);
|
||||||
const [refAreaRight, setRefAreaRight] = useState<number | null>(null);
|
const [refAreaRight, setRefAreaRight] = useState<number | null>(null);
|
||||||
const [zoomRange, setZoomRange] = useState<[number, number] | null>(null);
|
const [zoomRange, setZoomRange] = useState<[number, number] | null>(null);
|
||||||
const [targetResolution, setTargetResolution] = useState<string>("10m");
|
const [targetResolution, setTargetResolution] = useState<string>(() =>
|
||||||
|
prefersHighFrequencyRunwayResolution(row, null) ? "1m" : "10m",
|
||||||
|
);
|
||||||
const [currentCityLocalDate, setCurrentCityLocalDate] = useState(() =>
|
const [currentCityLocalDate, setCurrentCityLocalDate] = useState(() =>
|
||||||
formatCityLocalDate(row?.tz_offset_seconds),
|
formatCityLocalDate(row?.tz_offset_seconds),
|
||||||
);
|
);
|
||||||
@@ -138,8 +141,9 @@ export function LiveTemperatureThresholdChart({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUserToggledKeys({});
|
setUserToggledKeys({});
|
||||||
setZoomRange(null);
|
setZoomRange(null);
|
||||||
setViewMode("auto");
|
setViewMode("full");
|
||||||
setShowRunwayDetails(true);
|
setShowRunwayDetails(true);
|
||||||
|
setTargetResolution(prefersHighFrequencyRunwayResolution(row, null) ? "1m" : "10m");
|
||||||
setHourly(seedHourlyForecastFromRow(row));
|
setHourly(seedHourlyForecastFromRow(row));
|
||||||
setLiveTemp(null);
|
setLiveTemp(null);
|
||||||
setIsHourlyLoading(Boolean(city));
|
setIsHourlyLoading(Boolean(city));
|
||||||
@@ -246,7 +250,7 @@ export function LiveTemperatureThresholdChart({
|
|||||||
const refreshFullDetail = () => {
|
const refreshFullDetail = () => {
|
||||||
lastPatchAtRef.current = Date.now();
|
lastPatchAtRef.current = Date.now();
|
||||||
|
|
||||||
fetchHourlyForecastForCity(city, { ignoreCache: true })
|
fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (cancelled || !data) return;
|
if (cancelled || !data) return;
|
||||||
hasLoadedHourlyDetailRef.current = true;
|
hasLoadedHourlyDetailRef.current = true;
|
||||||
@@ -279,7 +283,7 @@ export function LiveTemperatureThresholdChart({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
clearInterval(id);
|
clearInterval(id);
|
||||||
};
|
};
|
||||||
}, [city, compact, isActive, isMaximized]);
|
}, [city, compact, isActive, isMaximized, targetResolution]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!city || !currentCityLocalDate) return;
|
if (!city || !currentCityLocalDate) return;
|
||||||
@@ -323,6 +327,10 @@ export function LiveTemperatureThresholdChart({
|
|||||||
);
|
);
|
||||||
const visibleRange = zoomRange ?? autoWindowRange;
|
const visibleRange = zoomRange ?? autoWindowRange;
|
||||||
const visibleRangeKey = visibleRange ? `${visibleRange[0]}:${visibleRange[1]}` : "full";
|
const visibleRangeKey = visibleRange ? `${visibleRange[0]}:${visibleRange[1]}` : "full";
|
||||||
|
const shouldUseRunwayResolution = useMemo(
|
||||||
|
() => prefersHighFrequencyRunwayResolution(row, chartHourly),
|
||||||
|
[row, chartHourly],
|
||||||
|
);
|
||||||
|
|
||||||
const zoomedData = useMemo(() => {
|
const zoomedData = useMemo(() => {
|
||||||
if (!visibleRange || data.length === 0) return data;
|
if (!visibleRange || data.length === 0) return data;
|
||||||
@@ -331,6 +339,9 @@ export function LiveTemperatureThresholdChart({
|
|||||||
}, [data, visibleRangeKey]);
|
}, [data, visibleRangeKey]);
|
||||||
|
|
||||||
const nextTargetResolution = useMemo(() => {
|
const nextTargetResolution = useMemo(() => {
|
||||||
|
if (shouldUseRunwayResolution) {
|
||||||
|
return "1m";
|
||||||
|
}
|
||||||
if (visibleRange && data.length > 0) {
|
if (visibleRange && data.length > 0) {
|
||||||
const zoomedData = data.slice(visibleRange[0], visibleRange[1] + 1);
|
const zoomedData = data.slice(visibleRange[0], visibleRange[1] + 1);
|
||||||
if (zoomedData.length > 0) {
|
if (zoomedData.length > 0) {
|
||||||
@@ -342,7 +353,7 @@ export function LiveTemperatureThresholdChart({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "10m";
|
return "10m";
|
||||||
}, [data, visibleRangeKey]);
|
}, [data, visibleRangeKey, shouldUseRunwayResolution]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (targetResolution !== nextTargetResolution) {
|
if (targetResolution !== nextTargetResolution) {
|
||||||
|
|||||||
@@ -114,6 +114,13 @@ export function TemperatureChartCanvas({
|
|||||||
const canRenderChart = chartSize.width > 0 && chartSize.height > 0;
|
const canRenderChart = chartSize.width > 0 && chartSize.height > 0;
|
||||||
const chartWidth = Math.max(1, chartSize.width);
|
const chartWidth = Math.max(1, chartSize.width);
|
||||||
const chartHeight = Math.max(220, chartSize.height);
|
const chartHeight = Math.max(220, chartSize.height);
|
||||||
|
const individualRunwaySeriesCount = chartSeries.filter(
|
||||||
|
(series) => series.key.startsWith("runway_") && series.key !== "runway_max",
|
||||||
|
).length;
|
||||||
|
const canToggleRunwayDetails =
|
||||||
|
hasRunwayData &&
|
||||||
|
individualRunwaySeriesCount > 1 &&
|
||||||
|
chartSeries.some((series) => series.key === "runway_max");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-[240px] flex-1 flex-col p-2">
|
<div className="relative flex min-h-[240px] flex-1 flex-col p-2">
|
||||||
@@ -142,7 +149,7 @@ export function TemperatureChartCanvas({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{hasRunwayData && (
|
{canToggleRunwayDetails && (
|
||||||
<label className="inline-flex items-center gap-1.5 ml-auto cursor-pointer text-slate-600 hover:text-slate-800 font-semibold select-none">
|
<label className="inline-flex items-center gap-1.5 ml-auto cursor-pointer text-slate-600 hover:text-slate-800 font-semibold select-none">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
@@ -61,9 +61,9 @@ export async function runTests() {
|
|||||||
"selected city chart should consume SSE patches and use a 2-minute no-patch fallback",
|
"selected city chart should consume SSE patches and use a 2-minute no-patch fallback",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
chartSource.includes("fetchHourlyForecastForCity(city, { ignoreCache: true })") &&
|
chartSource.includes("fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })") &&
|
||||||
chartSource.includes("setHourly(data)"),
|
chartSource.includes("setHourly(data)"),
|
||||||
"visible chart fallback must refresh the full city detail payload when SSE patches stop",
|
"visible chart fallback must refresh the full city detail payload at the current chart resolution when SSE patches stop",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
__shouldPollLiveChartForTest({ city: "shanghai", compact: true, isActive: false, isMaximized: false }) === true,
|
__shouldPollLiveChartForTest({ city: "shanghai", compact: true, isActive: false, isMaximized: false }) === true,
|
||||||
|
|||||||
@@ -154,6 +154,10 @@ export function runTests() {
|
|||||||
);
|
);
|
||||||
assert(chart.includes("viewMode"), "temperature chart must expose a view mode for DEB-peak auto view versus full-day view");
|
assert(chart.includes("viewMode"), "temperature chart must expose a view mode for DEB-peak auto view versus full-day view");
|
||||||
assert(chart.includes('useState<"auto" | "full">("full")'), "temperature chart must default every city panel to the all-day view");
|
assert(chart.includes('useState<"auto" | "full">("full")'), "temperature chart must default every city panel to the all-day view");
|
||||||
|
assert(
|
||||||
|
chart.includes('setViewMode("full")') && !chart.includes('setViewMode("auto")'),
|
||||||
|
"temperature chart must reset city changes to the all-day view instead of silently switching back to the DEB peak window",
|
||||||
|
);
|
||||||
assert(chart.includes("getDebPeakWindowRange"), "temperature chart must still derive the optional Peak view from the DEB peak window");
|
assert(chart.includes("getDebPeakWindowRange"), "temperature chart must still derive the optional Peak view from the DEB peak window");
|
||||||
assert(
|
assert(
|
||||||
chart.includes('isEn ? "Peak" : "高温"') && chart.includes('isEn ? "All Day" : "全天"'),
|
chart.includes('isEn ? "Peak" : "高温"') && chart.includes('isEn ? "All Day" : "全天"'),
|
||||||
@@ -168,6 +172,10 @@ export function runTests() {
|
|||||||
chart.includes("targetResolution !== nextTargetResolution"),
|
chart.includes("targetResolution !== nextTargetResolution"),
|
||||||
"temperature chart must guard target-resolution state updates to prevent render/update loops",
|
"temperature chart must guard target-resolution state updates to prevent render/update loops",
|
||||||
);
|
);
|
||||||
|
assert(
|
||||||
|
chart.includes("prefersHighFrequencyRunwayResolution") && chart.includes('return "1m";'),
|
||||||
|
"runway charts must request 1-minute detail resolution so historical runway lines match live SSE patch cadence",
|
||||||
|
);
|
||||||
assert(!chartCanvas.includes("ResponsiveContainer"), "temperature chart canvas must not mount Recharts through ResponsiveContainer at 0x0");
|
assert(!chartCanvas.includes("ResponsiveContainer"), "temperature chart canvas must not mount Recharts through ResponsiveContainer at 0x0");
|
||||||
assert(chartCanvas.includes("ResizeObserver"), "temperature chart canvas must measure its host with ResizeObserver");
|
assert(chartCanvas.includes("ResizeObserver"), "temperature chart canvas must measure its host with ResizeObserver");
|
||||||
assert(
|
assert(
|
||||||
@@ -178,6 +186,10 @@ export function runTests() {
|
|||||||
chartCanvas.includes("width={chartWidth}") && chartCanvas.includes("height={chartHeight}"),
|
chartCanvas.includes("width={chartWidth}") && chartCanvas.includes("height={chartHeight}"),
|
||||||
"temperature chart canvas must pass explicit positive width/height to Recharts",
|
"temperature chart canvas must pass explicit positive width/height to Recharts",
|
||||||
);
|
);
|
||||||
|
assert(
|
||||||
|
chartCanvas.includes("canToggleRunwayDetails") && chartCanvas.includes("individualRunwaySeriesCount > 1"),
|
||||||
|
"single-runway charts must not show the runway-detail toggle because aggregate and individual views are visually redundant",
|
||||||
|
);
|
||||||
assert(!chart.includes("3D"), "temperature chart UI must not expose a 3D/future-forecast mode");
|
assert(!chart.includes("3D"), "temperature chart UI must not expose a 3D/future-forecast mode");
|
||||||
assert(!chart.includes("build3DayChartData"), "temperature chart component must not render future prediction curves");
|
assert(!chart.includes("build3DayChartData"), "temperature chart component must not render future prediction curves");
|
||||||
assert(
|
assert(
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ function normalizeCityKey(value?: string | null) {
|
|||||||
return String(value || "").trim().toLowerCase().replace(/[\s_-]+/g, "");
|
return String(value || "").trim().toLowerCase().replace(/[\s_-]+/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasRecordEntries(value: unknown) {
|
||||||
|
return Boolean(value && typeof value === "object" && Object.keys(value as Record<string, unknown>).length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
function pairKey(pair: [string, string]) {
|
function pairKey(pair: [string, string]) {
|
||||||
return pair.map(normalizeRunwayLabel).sort().join("/");
|
return pair.map(normalizeRunwayLabel).sort().join("/");
|
||||||
}
|
}
|
||||||
@@ -52,6 +56,19 @@ function isTemperatureSeriesVisibleByDefault(city: string, seriesKey: string) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function prefersHighFrequencyRunwayResolution(
|
||||||
|
row: ScanOpportunityRow | null,
|
||||||
|
hourly: HourlyForecast,
|
||||||
|
) {
|
||||||
|
const cityKey = normalizeCityKey(row?.city);
|
||||||
|
if ((SETTLEMENT_RUNWAY_PAIRS[cityKey] || []).length > 0) return true;
|
||||||
|
if (hasRecordEntries((row as any)?.runway_plate_history)) return true;
|
||||||
|
if (hasRecordEntries(hourly?.runwayPlateHistory)) return true;
|
||||||
|
if ((hourly?.runwayBandHistory || []).length > 0) return true;
|
||||||
|
if (((hourly?.amos?.runway_obs as any)?.runway_pairs || []).length > 0) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function getVisibleTemperatureSeries(
|
function getVisibleTemperatureSeries(
|
||||||
city: string,
|
city: string,
|
||||||
series: EvidenceSeries[],
|
series: EvidenceSeries[],
|
||||||
@@ -1888,6 +1905,7 @@ export {
|
|||||||
mergePatchIntoHourly,
|
mergePatchIntoHourly,
|
||||||
normObs,
|
normObs,
|
||||||
normalizeCityKey,
|
normalizeCityKey,
|
||||||
|
prefersHighFrequencyRunwayResolution,
|
||||||
readSessionCache,
|
readSessionCache,
|
||||||
seedHourlyForecastFromRow,
|
seedHourlyForecastFromRow,
|
||||||
seriesStats,
|
seriesStats,
|
||||||
|
|||||||
+50
-88
@@ -61,80 +61,6 @@ _AIRPORT_EXECUTOR_LOCK = threading.Lock()
|
|||||||
_AIRPORT_EXECUTOR_MAX_WORKERS: int = 0
|
_AIRPORT_EXECUTOR_MAX_WORKERS: int = 0
|
||||||
|
|
||||||
|
|
||||||
def _get_airport_executor(max_workers: int) -> ThreadPoolExecutor:
|
|
||||||
global _AIRPORT_EXECUTOR, _AIRPORT_EXECUTOR_MAX_WORKERS
|
|
||||||
if _AIRPORT_EXECUTOR is None or _AIRPORT_EXECUTOR_MAX_WORKERS != max_workers:
|
|
||||||
with _AIRPORT_EXECUTOR_LOCK:
|
|
||||||
if _AIRPORT_EXECUTOR is None or _AIRPORT_EXECUTOR_MAX_WORKERS != max_workers:
|
|
||||||
if _AIRPORT_EXECUTOR is not None:
|
|
||||||
_AIRPORT_EXECUTOR.shutdown(wait=False)
|
|
||||||
_AIRPORT_EXECUTOR = ThreadPoolExecutor(max_workers=max_workers)
|
|
||||||
_AIRPORT_EXECUTOR_MAX_WORKERS = max_workers
|
|
||||||
return _AIRPORT_EXECUTOR
|
|
||||||
|
|
||||||
|
|
||||||
def _rate_limited_send(bot: Any, chat_id: str, message: str, **kwargs: Any) -> None:
|
|
||||||
"""Throttle bot.send_message calls to avoid hitting Telegram rate limits."""
|
|
||||||
global _SEND_MSG_LAST_TS
|
|
||||||
with _SEND_MSG_LOCK:
|
|
||||||
now = time.time()
|
|
||||||
wait = _SEND_MSG_MIN_INTERVAL_SEC - (now - _SEND_MSG_LAST_TS)
|
|
||||||
if wait > 0:
|
|
||||||
time.sleep(wait)
|
|
||||||
_SEND_MSG_LAST_TS = time.time()
|
|
||||||
bot.send_message(chat_id, message, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_city_thread_ids() -> dict:
|
|
||||||
global _city_thread_ids
|
|
||||||
if _city_thread_ids:
|
|
||||||
return _city_thread_ids
|
|
||||||
paths = [
|
|
||||||
_CITY_THREAD_IDS_PATH,
|
|
||||||
"/var/lib/polyweather/city_thread_ids.json",
|
|
||||||
"/app/data/city_thread_ids.json",
|
|
||||||
]
|
|
||||||
for path in paths:
|
|
||||||
if os.path.isfile(path):
|
|
||||||
try:
|
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
|
||||||
_city_thread_ids = json.load(f)
|
|
||||||
logger.info("loaded city_thread_ids from {}: {} cities", path, len(_city_thread_ids))
|
|
||||||
|
|
||||||
# Forum topic routing: maps city_key -> message_thread_id for the push forum group.
|
|
||||||
# Created by scripts/create_forum_topics.py, stored in the runtime data dir.
|
|
||||||
_CITY_THREAD_IDS_PATH = os.path.join(
|
|
||||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
|
||||||
"data", "city_thread_ids.json",
|
|
||||||
)
|
|
||||||
_FORUM_CHAT_ID = "-1003927451869"
|
|
||||||
_city_thread_ids: dict = {}
|
|
||||||
|
|
||||||
# Shared HTTP session for AROME and auxiliary queries (connection reuse)
|
|
||||||
_HTTP_SESSION: Optional[requests_lib.Session] = None
|
|
||||||
_HTTP_SESSION_LOCK = threading.Lock()
|
|
||||||
|
|
||||||
# Bot send_message rate limiter: max N messages per second across all threads
|
|
||||||
_SEND_MSG_LOCK = threading.Lock()
|
|
||||||
_SEND_MSG_LAST_TS: float = 0.0
|
|
||||||
_SEND_MSG_MIN_INTERVAL_SEC = float(os.getenv("TELEGRAM_SEND_RATE_LIMIT_SEC", "0.05"))
|
|
||||||
|
|
||||||
|
|
||||||
def _get_http_session() -> requests_lib.Session:
|
|
||||||
global _HTTP_SESSION
|
|
||||||
if _HTTP_SESSION is None:
|
|
||||||
with _HTTP_SESSION_LOCK:
|
|
||||||
if _HTTP_SESSION is None:
|
|
||||||
_HTTP_SESSION = requests_lib.Session()
|
|
||||||
return _HTTP_SESSION
|
|
||||||
|
|
||||||
|
|
||||||
# Reusable executor for airport push cycles (avoids thread pool churn)
|
|
||||||
_AIRPORT_EXECUTOR: Optional[ThreadPoolExecutor] = None
|
|
||||||
_AIRPORT_EXECUTOR_LOCK = threading.Lock()
|
|
||||||
_AIRPORT_EXECUTOR_MAX_WORKERS: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
def _get_airport_executor(max_workers: int) -> ThreadPoolExecutor:
|
def _get_airport_executor(max_workers: int) -> ThreadPoolExecutor:
|
||||||
global _AIRPORT_EXECUTOR, _AIRPORT_EXECUTOR_MAX_WORKERS
|
global _AIRPORT_EXECUTOR, _AIRPORT_EXECUTOR_MAX_WORKERS
|
||||||
if _AIRPORT_EXECUTOR is None or _AIRPORT_EXECUTOR_MAX_WORKERS != max_workers:
|
if _AIRPORT_EXECUTOR is None or _AIRPORT_EXECUTOR_MAX_WORKERS != max_workers:
|
||||||
@@ -271,7 +197,7 @@ def _bucket_value(row: Dict[str, Any]) -> Optional[float]:
|
|||||||
n = _safe_float(row.get(key))
|
n = _safe_float(row.get(key))
|
||||||
if n is not None:
|
if n is not None:
|
||||||
return n
|
return n
|
||||||
label = (row.get("label") or "").strip()
|
label = str(row.get("label") or "").strip()
|
||||||
m = re.search(r"(-?\d+(?:\.\d+)?)", label)
|
m = re.search(r"(-?\d+(?:\.\d+)?)", label)
|
||||||
if not m:
|
if not m:
|
||||||
return None
|
return None
|
||||||
@@ -282,7 +208,7 @@ def _bucket_bounds(row: Dict[str, Any]) -> Optional[Tuple[Optional[float], Optio
|
|||||||
value = _bucket_value(row)
|
value = _bucket_value(row)
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
label = (row.get("label") or "").strip().lower()
|
label = str(row.get("label") or "").strip().lower()
|
||||||
is_upper_tail = any(key in label for key in ("+", "or higher", "or above", "and above"))
|
is_upper_tail = any(key in label for key in ("+", "or higher", "or above", "and above"))
|
||||||
is_lower_tail = any(key in label for key in ("<=", "or lower", "or below", "and below"))
|
is_lower_tail = any(key in label for key in ("<=", "or lower", "or below", "and below"))
|
||||||
if is_upper_tail and not is_lower_tail:
|
if is_upper_tail and not is_lower_tail:
|
||||||
@@ -443,7 +369,7 @@ def _severity_ok(alert_payload: Dict[str, Any], min_severity: str, min_trigger_c
|
|||||||
trigger_count = int(alert_payload.get("trigger_count") or 0)
|
trigger_count = int(alert_payload.get("trigger_count") or 0)
|
||||||
if trigger_count < min_trigger_count:
|
if trigger_count < min_trigger_count:
|
||||||
return False
|
return False
|
||||||
severity = (alert_payload.get("severity") or "none").lower()
|
severity = str(alert_payload.get("severity") or "none").lower()
|
||||||
return SEVERITY_RANK.get(severity, 0) >= SEVERITY_RANK.get(min_severity, 0)
|
return SEVERITY_RANK.get(severity, 0) >= SEVERITY_RANK.get(min_severity, 0)
|
||||||
|
|
||||||
|
|
||||||
@@ -458,8 +384,8 @@ def _market_price_cap_ok(
|
|||||||
if not isinstance(primary_market, dict):
|
if not isinstance(primary_market, dict):
|
||||||
primary_market = {}
|
primary_market = {}
|
||||||
market_slug = (
|
market_slug = (
|
||||||
(market.get("selected_slug") or "").strip()
|
str(market.get("selected_slug") or "").strip()
|
||||||
or (primary_market.get("slug") or "").strip()
|
or str(primary_market.get("slug") or "").strip()
|
||||||
or "--"
|
or "--"
|
||||||
)
|
)
|
||||||
active = market.get("market_active")
|
active = market.get("market_active")
|
||||||
@@ -478,12 +404,12 @@ def _market_price_cap_ok(
|
|||||||
)
|
)
|
||||||
accepting_orders = _optional_bool(accepting_orders)
|
accepting_orders = _optional_bool(accepting_orders)
|
||||||
market_tradable = _optional_bool(market.get("market_tradable"))
|
market_tradable = _optional_bool(market.get("market_tradable"))
|
||||||
tradable_reason = (
|
tradable_reason = str(
|
||||||
market.get("market_tradable_reason")
|
market.get("market_tradable_reason")
|
||||||
or primary_market.get("tradable_reason")
|
or primary_market.get("tradable_reason")
|
||||||
or ""
|
or ""
|
||||||
).strip()
|
).strip()
|
||||||
ended_at = (
|
ended_at = str(
|
||||||
market.get("market_ended_at_utc")
|
market.get("market_ended_at_utc")
|
||||||
or primary_market.get("ended_at_utc")
|
or primary_market.get("ended_at_utc")
|
||||||
or ""
|
or ""
|
||||||
@@ -516,12 +442,12 @@ def _market_price_cap_ok(
|
|||||||
settle_ref = market.get("anchor_settlement")
|
settle_ref = market.get("anchor_settlement")
|
||||||
if settle_ref is None:
|
if settle_ref is None:
|
||||||
settle_ref = market.get("open_meteo_settlement")
|
settle_ref = market.get("open_meteo_settlement")
|
||||||
anchor_model = (market.get("anchor_model") or "").strip() or "--"
|
anchor_model = str(market.get("anchor_model") or "").strip() or "--"
|
||||||
yes_buy = None
|
yes_buy = None
|
||||||
bucket_label = None
|
bucket_label = None
|
||||||
if isinstance(forecast_bucket, dict):
|
if isinstance(forecast_bucket, dict):
|
||||||
yes_buy = _norm_prob(forecast_bucket.get("yes_buy"))
|
yes_buy = _norm_prob(forecast_bucket.get("yes_buy"))
|
||||||
bucket_label = (forecast_bucket.get("label") or "").strip() or None
|
bucket_label = str(forecast_bucket.get("label") or "").strip() or None
|
||||||
|
|
||||||
observed_floor = _observed_settlement_floor(alert_payload)
|
observed_floor = _observed_settlement_floor(alert_payload)
|
||||||
bucket_bounds = _bucket_bounds(forecast_bucket) if isinstance(forecast_bucket, dict) else None
|
bucket_bounds = _bucket_bounds(forecast_bucket) if isinstance(forecast_bucket, dict) else None
|
||||||
@@ -556,14 +482,14 @@ def _market_price_cap_ok(
|
|||||||
|
|
||||||
def _trigger_type_key(alert_payload: Dict[str, Any]) -> str:
|
def _trigger_type_key(alert_payload: Dict[str, Any]) -> str:
|
||||||
trigger_types = sorted(
|
trigger_types = sorted(
|
||||||
(alert.get("type") or "").strip()
|
str(alert.get("type") or "").strip()
|
||||||
for alert in (alert_payload.get("triggered_alerts") or [])
|
for alert in (alert_payload.get("triggered_alerts") or [])
|
||||||
if alert.get("type")
|
if alert.get("type")
|
||||||
)
|
)
|
||||||
market = alert_payload.get("market_snapshot") or {}
|
market = alert_payload.get("market_snapshot") or {}
|
||||||
if isinstance(market, dict) and market.get("available"):
|
if isinstance(market, dict) and market.get("available"):
|
||||||
signal = (market.get("signal_label") or "").strip()
|
signal = str(market.get("signal_label") or "").strip()
|
||||||
bucket = (market.get("selected_bucket") or "").strip()
|
bucket = str(market.get("selected_bucket") or "").strip()
|
||||||
if signal:
|
if signal:
|
||||||
trigger_types.append(f"mkt:{signal}:{bucket}")
|
trigger_types.append(f"mkt:{signal}:{bucket}")
|
||||||
return "|".join(trigger_types)
|
return "|".join(trigger_types)
|
||||||
@@ -604,7 +530,7 @@ def _evidence_brief(alert_payload: Dict[str, Any]) -> str:
|
|||||||
|
|
||||||
forecast_bucket = market.get("forecast_bucket") or {}
|
forecast_bucket = market.get("forecast_bucket") or {}
|
||||||
if isinstance(forecast_bucket, dict):
|
if isinstance(forecast_bucket, dict):
|
||||||
label = (forecast_bucket.get("label") or "").strip()
|
label = str(forecast_bucket.get("label") or "").strip()
|
||||||
yes_buy = forecast_bucket.get("yes_buy")
|
yes_buy = forecast_bucket.get("yes_buy")
|
||||||
if label:
|
if label:
|
||||||
parts.append(f"bucket={label}")
|
parts.append(f"bucket={label}")
|
||||||
@@ -797,7 +723,7 @@ def _fmt(value: Any) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _normalize_runway_label(value: Any) -> str:
|
def _normalize_runway_label(value: Any) -> str:
|
||||||
return re.sub(r"[^0-9A-Z]+", "", (value or "").strip().upper())
|
return re.sub(r"[^0-9A-Z]+", "", str(value or "").strip().upper())
|
||||||
|
|
||||||
|
|
||||||
def _runway_pair_key(r1: Any, r2: Any) -> Tuple[str, str]:
|
def _runway_pair_key(r1: Any, r2: Any) -> Tuple[str, str]:
|
||||||
@@ -1067,6 +993,42 @@ def _build_airport_status_message(
|
|||||||
language = _normalize_push_language(language or _telegram_push_language())
|
language = _normalize_push_language(language or _telegram_push_language())
|
||||||
_AIRPORT_EN = {"seoul": "Incheon", "singapore": "Changi", "busan": "Gimhae", "tokyo": "Haneda",
|
_AIRPORT_EN = {"seoul": "Incheon", "singapore": "Changi", "busan": "Gimhae", "tokyo": "Haneda",
|
||||||
"ankara": "Esenboğa", "helsinki": "Vantaa", "amsterdam": "Schiphol",
|
"ankara": "Esenboğa", "helsinki": "Vantaa", "amsterdam": "Schiphol",
|
||||||
|
"istanbul": "Airport", "paris": "Le Bourget",
|
||||||
|
"hong kong": "Observatory", "shenzhen": "LFS Observatory",
|
||||||
|
"taipei": "Songshan", "beijing": "Capital", "shanghai": "Pudong",
|
||||||
|
"guangzhou": "Baiyun", "qingdao": "Jiaodong",
|
||||||
|
"chengdu": "Shuangliu", "chongqing": "Jiangbei", "wuhan": "Tianhe",
|
||||||
|
"new york": "LaGuardia", "los angeles": "LAX", "chicago": "O'Hare",
|
||||||
|
"denver": "Buckley", "atlanta": "Hartsfield", "miami": "Intl",
|
||||||
|
"san francisco": "SFO", "houston": "Hobby", "dallas": "Love Field",
|
||||||
|
"austin": "Bergstrom", "seattle": "Sea-Tac",
|
||||||
|
"tel aviv": "Ben Gurion"}
|
||||||
|
en_name = city.title()
|
||||||
|
ap_name = _AIRPORT_EN.get(city, "")
|
||||||
|
time_suffix = f" · {local_time}" if local_time else ""
|
||||||
|
|
||||||
|
amos = city_weather.get("amos") or {}
|
||||||
|
runway_data = amos.get("runway_obs") or {}
|
||||||
|
runway_pairs = runway_data.get("runway_pairs") or []
|
||||||
|
runway_temps = runway_data.get("temperatures") or []
|
||||||
|
point_temps = runway_data.get("point_temperatures") or []
|
||||||
|
is_amsc = amos.get("source") in ("amsc_awos", "amos")
|
||||||
|
has_runway = bool(runway_pairs and (runway_temps or point_temps))
|
||||||
|
amos_icao = amos.get("icao") or HIGH_FREQ_AIRPORT_ICAO.get(city, "")
|
||||||
|
settlement_pair = _settlement_runway_for_city(city)
|
||||||
|
|
||||||
|
# ── Display temp: settlement runway max first, then airport temp ──
|
||||||
|
settlement_temp: Optional[float] = None
|
||||||
|
display_temp: Optional[float] = None
|
||||||
|
if point_temps:
|
||||||
|
for pt in point_temps:
|
||||||
|
rw = str(pt.get("runway") or "")
|
||||||
|
rw_parts = [p.strip() for p in rw.split("/") if p.strip()]
|
||||||
|
if settlement_pair and len(rw_parts) >= 2 and _runway_pair_key(rw_parts[0], rw_parts[1]) == _runway_pair_key(*settlement_pair):
|
||||||
|
tmax = pt.get("target_runway_max")
|
||||||
|
if tmax is not None:
|
||||||
|
settlement_temp = float(tmax)
|
||||||
|
break
|
||||||
if settlement_temp is not None:
|
if settlement_temp is not None:
|
||||||
display_temp = settlement_temp
|
display_temp = settlement_temp
|
||||||
if display_temp is None:
|
if display_temp is None:
|
||||||
|
|||||||
Reference in New Issue
Block a user