feat: add weekly reward loop, i18n support, and scan terminal dashboard components
This commit is contained in:
@@ -40,10 +40,14 @@ TELEGRAM_QUERY_TOPIC_ID=
|
||||
TELEGRAM_QUERY_TOPIC_MAP=
|
||||
POLYWEATHER_BOT_GROUP_INVITE_URL=
|
||||
POLYWEATHER_APP_URL=https://polyweather-pro.vercel.app
|
||||
# Global Telegram auto-push copy: both, en, or zh. Module-specific vars can override it.
|
||||
TELEGRAM_PUSH_LANGUAGE=both
|
||||
# High-frequency airport push loop. Keep workers at 1 on shared VPS.
|
||||
TELEGRAM_AIRPORT_PUSH_ENABLED=true
|
||||
TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC=60
|
||||
TELEGRAM_AIRPORT_PUSH_MAX_WORKERS=1
|
||||
# Optional airport-only override.
|
||||
TELEGRAM_AIRPORT_PUSH_LANGUAGE=both
|
||||
# Docker-only safety limits for the bot service.
|
||||
POLYWEATHER_BOT_CPUS=0.75
|
||||
POLYWEATHER_BOT_MEM_LIMIT=768m
|
||||
|
||||
@@ -78,8 +78,10 @@ Seoul / Incheon 16:03
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `TELEGRAM_PUSH_LANGUAGE` | Telegram 自动推送的全局语言,可选 `both`/`en`/`zh` | `both` |
|
||||
| `TELEGRAM_AIRPORT_PUSH_ENABLED` | 启用机场推送 | `true` |
|
||||
| `TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC` | 循环轮询间隔 | `60` |
|
||||
| `TELEGRAM_AIRPORT_PUSH_LANGUAGE` | 机场推送语言覆盖,可选 `both`/`en`/`zh` | `both` |
|
||||
| `KNMI_API_KEY` | KNMI API 密钥(阿姆斯特丹必填) | — |
|
||||
|
||||
## 未接入城市
|
||||
|
||||
@@ -438,6 +438,47 @@
|
||||
animation: markerGlow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.peak-glow-card {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.peak-glow-card::after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.peak-glow-watch::after {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(245, 158, 11, 0.42),
|
||||
inset 0 0 18px rgba(245, 158, 11, 0.12);
|
||||
}
|
||||
|
||||
.peak-glow-near::after {
|
||||
animation: peakGlowNear 3.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.peak-glow-breakout::after {
|
||||
animation: peakGlowBreakout 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.peak-glow-cooling::after {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(100, 116, 139, 0.38),
|
||||
inset 0 0 18px rgba(148, 163, 184, 0.16);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.peak-glow-near::after,
|
||||
.peak-glow-breakout::after {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Scrollbar ── */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
@@ -460,3 +501,35 @@
|
||||
box-shadow: 0 10px 26px rgba(0, 224, 164, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes peakGlowNear {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(249, 115, 22, 0.48),
|
||||
inset 0 0 18px rgba(249, 115, 22, 0.12),
|
||||
0 0 0 rgba(249, 115, 22, 0);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(249, 115, 22, 0.7),
|
||||
inset 0 0 28px rgba(249, 115, 22, 0.22),
|
||||
0 0 22px rgba(249, 115, 22, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes peakGlowBreakout {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(244, 63, 94, 0.54),
|
||||
inset 0 0 22px rgba(244, 63, 94, 0.16),
|
||||
0 0 0 rgba(244, 63, 94, 0);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(244, 63, 94, 0.78),
|
||||
inset 0 0 32px rgba(244, 63, 94, 0.26),
|
||||
0 0 24px rgba(244, 63, 94, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -822,8 +822,13 @@ function PolyWeatherTerminal({
|
||||
>
|
||||
{visibleSlots.map((cityInSlot, slotIndex) => {
|
||||
const isSlotActive = activeSlotIndex === slotIndex;
|
||||
const rowForSlot = cityInSlot
|
||||
? filteredRegionRows.find(
|
||||
(r) => String(r.city || "").toLowerCase() === cityInSlot
|
||||
) || null
|
||||
: null;
|
||||
|
||||
if (!cityInSlot) {
|
||||
if (!cityInSlot || !rowForSlot) {
|
||||
return (
|
||||
<div key={slotIndex} className="relative h-full">
|
||||
<EmptySlotCard
|
||||
@@ -850,10 +855,6 @@ function PolyWeatherTerminal({
|
||||
);
|
||||
}
|
||||
|
||||
const rowForSlot = filteredRegionRows.find(
|
||||
(r) => String(r.city || "").toLowerCase() === cityInSlot
|
||||
) || null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={slotIndex}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
fetchHourlyForecastForCity,
|
||||
getActiveTemperatureSeries,
|
||||
getDebPeakWindowRange,
|
||||
getPeakGlowState,
|
||||
getLiveObservationLabels,
|
||||
getObservationDisplayMetrics,
|
||||
getVisibleTemperatureSeries,
|
||||
@@ -35,6 +36,46 @@ import {
|
||||
} from "@/components/dashboard/scan-terminal/temperature-chart-logic";
|
||||
export { clearCityDetailCache } from "@/components/dashboard/scan-terminal/temperature-chart-logic";
|
||||
|
||||
const PEAK_GLOW_PANEL_CLASS = {
|
||||
none: "",
|
||||
watch: "peak-glow-card peak-glow-watch",
|
||||
near_peak: "peak-glow-card peak-glow-near",
|
||||
breakout: "peak-glow-card peak-glow-breakout",
|
||||
cooling: "peak-glow-card peak-glow-cooling",
|
||||
} as const;
|
||||
|
||||
const PEAK_GLOW_BADGE_CLASS = {
|
||||
none: "",
|
||||
watch: "border-amber-200 bg-amber-50 text-amber-700",
|
||||
near_peak: "border-orange-200 bg-orange-50 text-orange-700",
|
||||
breakout: "border-rose-200 bg-rose-50 text-rose-700",
|
||||
cooling: "border-slate-200 bg-slate-100 text-slate-500",
|
||||
} as const;
|
||||
|
||||
function peakGlowLabel(state: keyof typeof PEAK_GLOW_PANEL_CLASS, isEn: boolean) {
|
||||
if (state === "watch") return isEn ? "Watch" : "关注";
|
||||
if (state === "near_peak") return isEn ? "Near peak" : "接近峰值";
|
||||
if (state === "breakout") return isEn ? "Breakout" : "突破";
|
||||
if (state === "cooling") return isEn ? "Cooling" : "降温";
|
||||
return "";
|
||||
}
|
||||
|
||||
function peakGlowTitle(
|
||||
state: keyof typeof PEAK_GLOW_PANEL_CLASS,
|
||||
distanceToDeb: number | null,
|
||||
isEn: boolean,
|
||||
) {
|
||||
const label = peakGlowLabel(state, isEn);
|
||||
if (!label) return "";
|
||||
if (distanceToDeb === null) return label;
|
||||
const absDistance = Math.abs(distanceToDeb).toFixed(1);
|
||||
if (state === "breakout" && distanceToDeb <= 0) {
|
||||
return isEn ? `${label}: ${absDistance}° above DEB` : `${label}:高于 DEB ${absDistance}°`;
|
||||
}
|
||||
if (state === "cooling") return isEn ? `${label}: peak likely passed` : `${label}:峰值可能已过`;
|
||||
return isEn ? `${label}: ${absDistance}° below DEB` : `${label}:距 DEB ${absDistance}°`;
|
||||
}
|
||||
|
||||
function formatCityLocalDate(tzOffsetSeconds: number | null | undefined) {
|
||||
const cityOffsetMs = (tzOffsetSeconds ?? 0) * 1000;
|
||||
const cityNow = new Date(Date.now() + cityOffsetMs + new Date().getTimezoneOffset() * 60_000);
|
||||
@@ -273,6 +314,7 @@ export function LiveTemperatureThresholdChart({
|
||||
const chartLocalDate = chartHourly?.localDate || row?.local_date || currentCityLocalDate;
|
||||
|
||||
const { data, series } = useMemo(() => buildFullDayChartData(row, chartHourly, isEn), [row, chartHourly, isEn]);
|
||||
const peakGlow = useMemo(() => getPeakGlowState(row, data, series), [row, data, series]);
|
||||
|
||||
const autoWindowRange = useMemo(
|
||||
() => (viewMode === "auto" ? getDebPeakWindowRange(data, series) : null),
|
||||
@@ -442,6 +484,17 @@ export function LiveTemperatureThresholdChart({
|
||||
</button>
|
||||
<span className="text-slate-400 font-normal">·</span>
|
||||
<span className="text-slate-500 font-normal">{subtitle}</span>
|
||||
{peakGlow.state !== "none" && (
|
||||
<span
|
||||
className={clsx(
|
||||
"ml-1 rounded border px-1.5 py-0.5 text-[9px] font-black normal-case tracking-normal",
|
||||
PEAK_GLOW_BADGE_CLASS[peakGlow.state],
|
||||
)}
|
||||
title={peakGlowTitle(peakGlow.state, peakGlow.distanceToDeb, isEn)}
|
||||
>
|
||||
{peakGlowLabel(peakGlow.state, isEn)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : isEn ? (
|
||||
"Temperature Chart"
|
||||
@@ -559,7 +612,11 @@ export function LiveTemperatureThresholdChart({
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel title={panelTitle} actions={timeframeActions}>
|
||||
<Panel
|
||||
title={panelTitle}
|
||||
actions={timeframeActions}
|
||||
className={PEAK_GLOW_PANEL_CLASS[peakGlow.state]}
|
||||
>
|
||||
<div className="flex h-full min-h-[300px] flex-col">
|
||||
<TemperatureStatsBars
|
||||
isEn={isEn}
|
||||
@@ -641,5 +698,6 @@ export const __getActiveTemperatureSeriesForTest = getActiveTemperatureSeries;
|
||||
export const __getDebPeakWindowRangeForTest = getDebPeakWindowRange;
|
||||
export const __getLiveObservationLabelsForTest = getLiveObservationLabels;
|
||||
export const __getObservationDisplayMetricsForTest = getObservationDisplayMetrics;
|
||||
export const __getPeakGlowStateForTest = getPeakGlowState;
|
||||
export const __shouldPollLiveChartForTest = shouldPollLiveChart;
|
||||
export const __mergePatchIntoHourlyForTest = mergePatchIntoHourly;
|
||||
|
||||
+99
@@ -4,6 +4,7 @@ import {
|
||||
__getDebPeakWindowRangeForTest,
|
||||
__getLiveObservationLabelsForTest,
|
||||
__getObservationDisplayMetricsForTest,
|
||||
__getPeakGlowStateForTest,
|
||||
__getVisibleTemperatureSeriesForTest,
|
||||
__isTemperatureSeriesVisibleByDefaultForTest,
|
||||
__mergePatchIntoHourlyForTest,
|
||||
@@ -22,6 +23,69 @@ function runwayKey(rwy: string) {
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const peakGlowSeries = [
|
||||
{
|
||||
key: "hourly_forecast",
|
||||
label: "DEB Forecast",
|
||||
source: "DEB Hourly",
|
||||
color: "#f97316",
|
||||
values: [26, 29, 32, 32, 30],
|
||||
},
|
||||
{
|
||||
key: "madis",
|
||||
label: "METAR",
|
||||
source: "METAR",
|
||||
color: "#0284c7",
|
||||
values: [26.0, 29.0, null, 30.4, null],
|
||||
},
|
||||
] as any;
|
||||
const peakGlowData = [
|
||||
{ ts: Date.UTC(2026, 4, 27, 10, 0), hourly_forecast: 26, madis: 26.0 },
|
||||
{ ts: Date.UTC(2026, 4, 27, 11, 0), hourly_forecast: 29, madis: 29.0 },
|
||||
{ ts: Date.UTC(2026, 4, 27, 12, 0), hourly_forecast: 32, madis: null },
|
||||
{ ts: Date.UTC(2026, 4, 27, 13, 0), hourly_forecast: 32, madis: 30.4 },
|
||||
{ ts: Date.UTC(2026, 4, 27, 14, 0), hourly_forecast: 30, madis: null },
|
||||
] as any;
|
||||
|
||||
assert(
|
||||
__getPeakGlowStateForTest({ temp_symbol: "°C" } as any, peakGlowData, peakGlowSeries).state === "near_peak",
|
||||
"city chart should enter near-peak glow when live temperature is within 2C of DEB and not cooling",
|
||||
);
|
||||
assert(
|
||||
__getPeakGlowStateForTest({ temp_symbol: "°C" } as any, peakGlowData, [
|
||||
peakGlowSeries[0],
|
||||
{ ...peakGlowSeries[1], values: [26.0, 29.0, null, 29.2, null] },
|
||||
] as any).state === "watch",
|
||||
"city chart should enter watch glow when live temperature is within 3C of DEB but not near peak",
|
||||
);
|
||||
assert(
|
||||
__getPeakGlowStateForTest({ temp_symbol: "°C" } as any, peakGlowData, [
|
||||
peakGlowSeries[0],
|
||||
{ ...peakGlowSeries[1], values: [26.0, 29.0, null, 32.1, null] },
|
||||
] as any).state === "breakout",
|
||||
"city chart should use breakout glow when live temperature reaches or exceeds DEB peak",
|
||||
);
|
||||
assert(
|
||||
__getPeakGlowStateForTest({ temp_symbol: "°C" } as any, [
|
||||
{ ts: Date.UTC(2026, 4, 27, 10, 0), hourly_forecast: 26, madis: 26.0 },
|
||||
{ ts: Date.UTC(2026, 4, 27, 11, 0), hourly_forecast: 30, madis: 30.0 },
|
||||
{ ts: Date.UTC(2026, 4, 27, 12, 0), hourly_forecast: 32, madis: 31.8 },
|
||||
{ ts: Date.UTC(2026, 4, 27, 13, 0), hourly_forecast: 30, madis: 30.8 },
|
||||
{ ts: Date.UTC(2026, 4, 27, 14, 0), hourly_forecast: 27, madis: 30.2 },
|
||||
] as any, [
|
||||
{ ...peakGlowSeries[0], values: [26, 30, 32, 30, 27] },
|
||||
{ ...peakGlowSeries[1], values: [26.0, 30.0, 31.8, 30.8, 30.2] },
|
||||
] as any).state === "cooling",
|
||||
"city chart should stop hot glow and show cooling state after peak when live temperature rolls over",
|
||||
);
|
||||
assert(
|
||||
__getPeakGlowStateForTest({ temp_symbol: "°F" } as any, peakGlowData, [
|
||||
{ ...peakGlowSeries[0], values: [79, 84, 90, 90, 86] },
|
||||
{ ...peakGlowSeries[1], values: [80, 84, null, 86.2, null] },
|
||||
] as any).state === "watch",
|
||||
"US Fahrenheit charts should convert Celsius thresholds before deciding peak glow state",
|
||||
);
|
||||
|
||||
const guangzhou = {
|
||||
city: "guangzhou",
|
||||
local_date: "2026-05-25",
|
||||
@@ -433,6 +497,41 @@ export function runTests() {
|
||||
assert(madisSeries.label.includes("MADIS"), "US MADIS series should be labeled as NOAA MADIS instead of plain METAR");
|
||||
assert(madisSeries.values.filter((value: number | null) => value !== null).length >= 2, "MADIS series should keep sub-hourly observations");
|
||||
|
||||
const torontoWithLatestAirportReport = __buildTemperatureChartDataForTest(
|
||||
{
|
||||
city: "toronto",
|
||||
local_date: "2026-05-27",
|
||||
local_time: "19:16",
|
||||
tz_offset_seconds: -4 * 60 * 60,
|
||||
airport: "CYYZ",
|
||||
temp_symbol: "°C",
|
||||
} as any,
|
||||
{
|
||||
localTime: "19:16",
|
||||
times: ["10:00", "13:00", "16:00", "19:00"],
|
||||
temps: [23, 26, 27, 26],
|
||||
airportPrimary: {
|
||||
source_code: "metar",
|
||||
source_label: "METAR",
|
||||
temp: 26,
|
||||
obs_time: "2026-05-27T23:16:00Z",
|
||||
},
|
||||
airportPrimaryTodayObs: [
|
||||
["2026-05-27T21:00:00Z", 27],
|
||||
["2026-05-27T22:00:00Z", 28],
|
||||
["2026-05-27T23:00:00Z", 27],
|
||||
],
|
||||
} as any,
|
||||
"1D",
|
||||
);
|
||||
const latestAirportPoint = torontoWithLatestAirportReport.data.find(
|
||||
(point) => point.label === "19:16:00" && point.madis === 26,
|
||||
);
|
||||
assert(
|
||||
latestAirportPoint,
|
||||
"latest airport/METAR report should be appended to the live chart series even when history stops earlier",
|
||||
);
|
||||
|
||||
const newYorkMinuteStream = __buildTemperatureChartDataForTest(
|
||||
{
|
||||
city: "new york",
|
||||
|
||||
@@ -41,4 +41,9 @@ export function runTests() {
|
||||
selectorSource.includes("grid grid-cols-3"),
|
||||
"grid selector must expose at most a 3 by 3 chart layout",
|
||||
);
|
||||
assert(
|
||||
dashboardSource.includes("if (!cityInSlot || !rowForSlot)") &&
|
||||
dashboardSource.includes("handleSelectCityForSlot(slotIndex, null);"),
|
||||
"stale saved chart slots must render the empty city picker instead of a row=null Temperature Chart",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -187,6 +187,20 @@ type EvidenceSeries = {
|
||||
values: Array<number | null>;
|
||||
};
|
||||
|
||||
type PeakGlowState = "none" | "watch" | "near_peak" | "breakout" | "cooling";
|
||||
|
||||
type PeakGlowMeta = {
|
||||
state: PeakGlowState;
|
||||
currentTemp: number | null;
|
||||
debPeak: number | null;
|
||||
distanceToDeb: number | null;
|
||||
trend30m: number | null;
|
||||
trend60m: number | null;
|
||||
observedHigh: number | null;
|
||||
peakStartTs: number | null;
|
||||
peakEndTs: number | null;
|
||||
};
|
||||
|
||||
type RunwayHistorySeries = {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -435,6 +449,36 @@ function normObs(
|
||||
.slice(-limit);
|
||||
}
|
||||
|
||||
function appendLatestAirportObservation(
|
||||
points: RawObsPoint[] | null | undefined,
|
||||
...currentSources: Array<AirportCurrentConditions | null | undefined>
|
||||
): RawObsPoint[] {
|
||||
const merged = [...(points || [])];
|
||||
const seen = new Set(
|
||||
merged
|
||||
.map(normalizeRawObsPoint)
|
||||
.filter((point): point is ObsPoint => point !== null)
|
||||
.map((point) => `${String(point.time || "")}:${validNumber(point.temp) ?? ""}`),
|
||||
);
|
||||
|
||||
currentSources.forEach((source) => {
|
||||
const temp = validNumber(source?.temp);
|
||||
const time =
|
||||
(source as any)?.obs_time ??
|
||||
(source as any)?.observation_time ??
|
||||
(source as any)?.timestamp ??
|
||||
(source as any)?.time ??
|
||||
null;
|
||||
if (temp === null || !time) return;
|
||||
const key = `${String(time)}:${temp}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
merged.push({ time: String(time), temp });
|
||||
});
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function seriesStats(values: Array<number | null>) {
|
||||
const nums = values.filter((v): v is number => validNumber(v) !== null);
|
||||
const latest = nums.length ? nums[nums.length - 1] : null;
|
||||
@@ -479,7 +523,12 @@ function getObservationDisplayMetrics(
|
||||
const localDateStr = resolveChartLocalDate(row, hourly);
|
||||
const settlementObs = normObs(hourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset, MAX_OBS_POINTS, localDateStr);
|
||||
const metarObs = normObs(hourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, tzOffset, MAX_OBS_POINTS, localDateStr);
|
||||
const madisObs = normObs(hourly?.airportPrimaryTodayObs, tzOffset, MAX_OBS_POINTS, localDateStr);
|
||||
const madisObs = normObs(
|
||||
appendLatestAirportObservation(hourly?.airportPrimaryTodayObs, hourly?.airportPrimary, hourly?.airportCurrent),
|
||||
tzOffset,
|
||||
MAX_OBS_POINTS,
|
||||
localDateStr,
|
||||
);
|
||||
const latestSettlement = latestObservationValue(settlementObs);
|
||||
const latestMetar = latestObservationValue(metarObs);
|
||||
const latestMadis = latestObservationValue(madisObs);
|
||||
@@ -1236,7 +1285,15 @@ function buildFullDayChartData(
|
||||
normObs(hourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, tzOffset, MAX_OBS_POINTS, localDateStr),
|
||||
localDayBounds,
|
||||
);
|
||||
const madisObs = filterTimelinePointsToLocalDay(normObs(hourly?.airportPrimaryTodayObs, tzOffset, MAX_OBS_POINTS, localDateStr), localDayBounds);
|
||||
const madisObs = filterTimelinePointsToLocalDay(
|
||||
normObs(
|
||||
appendLatestAirportObservation(hourly?.airportPrimaryTodayObs, hourly?.airportPrimary, hourly?.airportCurrent),
|
||||
tzOffset,
|
||||
MAX_OBS_POINTS,
|
||||
localDateStr,
|
||||
),
|
||||
localDayBounds,
|
||||
);
|
||||
const runwayHistorySeries = filterRunwayHistoryToLocalDay(
|
||||
buildRunwayHistorySeries(row, hourly, tzOffset, localDateStr),
|
||||
localDayBounds,
|
||||
@@ -1535,6 +1592,163 @@ function latestLiveObservationTimestamp(
|
||||
return latest;
|
||||
}
|
||||
|
||||
function chartDeltaForCelsius(row: ScanOpportunityRow | null, deltaC: number) {
|
||||
const symbol = String(row?.temp_symbol || "").toUpperCase();
|
||||
return symbol.includes("F") ? deltaC * 1.8 : deltaC;
|
||||
}
|
||||
|
||||
function getLiveObservationPoints(
|
||||
data: Array<Record<string, any>>,
|
||||
series: EvidenceSeries[],
|
||||
) {
|
||||
const liveSeries = series.filter(isLiveObservationSeries);
|
||||
const points: Array<{ ts: number; temp: number }> = [];
|
||||
data.forEach((row, index) => {
|
||||
const ts = typeof row?.ts === "number" ? row.ts : null;
|
||||
if (ts === null) return;
|
||||
const values = liveSeries
|
||||
.map((item) => validNumber(item.values[index]))
|
||||
.filter((value): value is number => value !== null);
|
||||
if (!values.length) return;
|
||||
points.push({ ts, temp: Math.max(...values) });
|
||||
});
|
||||
return points.sort((left, right) => left.ts - right.ts);
|
||||
}
|
||||
|
||||
function getDebPeakProfile(
|
||||
row: ScanOpportunityRow | null,
|
||||
data: Array<Record<string, any>>,
|
||||
series: EvidenceSeries[],
|
||||
) {
|
||||
const debSeries = series.find((item) => item.key === "hourly_forecast");
|
||||
if (!debSeries) return null;
|
||||
const debPoints = debSeries.values
|
||||
.map((value, index) => {
|
||||
const ts = typeof data[index]?.ts === "number" ? data[index].ts : null;
|
||||
const temp = validNumber(value);
|
||||
return ts === null || temp === null ? null : { index, ts, temp };
|
||||
})
|
||||
.filter((point): point is { index: number; ts: number; temp: number } => point !== null);
|
||||
if (!debPoints.length) return null;
|
||||
const peak = debPoints.reduce((best, point) => (point.temp > best.temp ? point : best), debPoints[0]);
|
||||
let peakIndex = debPoints.findIndex((point) => point.index === peak.index);
|
||||
if (peakIndex < 0) peakIndex = 0;
|
||||
|
||||
const hotThreshold = peak.temp - chartDeltaForCelsius(row, 2);
|
||||
let hotStartIndex = peakIndex;
|
||||
let hotEndIndex = peakIndex;
|
||||
while (hotStartIndex > 0 && debPoints[hotStartIndex - 1].temp >= hotThreshold) {
|
||||
hotStartIndex -= 1;
|
||||
}
|
||||
while (hotEndIndex < debPoints.length - 1 && debPoints[hotEndIndex + 1].temp >= hotThreshold) {
|
||||
hotEndIndex += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
peak,
|
||||
hotStartTs: debPoints[hotStartIndex].ts,
|
||||
hotEndTs: debPoints[hotEndIndex].ts,
|
||||
};
|
||||
}
|
||||
|
||||
function pointAtOrBefore(
|
||||
points: Array<{ ts: number; temp: number }>,
|
||||
targetTs: number,
|
||||
): { ts: number; temp: number } | null {
|
||||
let match: { ts: number; temp: number } | null = null;
|
||||
for (const point of points) {
|
||||
if (point.ts <= targetTs) match = point;
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
function getPeakGlowState(
|
||||
row: ScanOpportunityRow | null,
|
||||
data: Array<Record<string, any>>,
|
||||
series: EvidenceSeries[],
|
||||
): PeakGlowMeta {
|
||||
const empty: PeakGlowMeta = {
|
||||
state: "none",
|
||||
currentTemp: null,
|
||||
debPeak: null,
|
||||
distanceToDeb: null,
|
||||
trend30m: null,
|
||||
trend60m: null,
|
||||
observedHigh: null,
|
||||
peakStartTs: null,
|
||||
peakEndTs: null,
|
||||
};
|
||||
const livePoints = getLiveObservationPoints(data, series);
|
||||
const latest = livePoints[livePoints.length - 1] || null;
|
||||
const debProfile = getDebPeakProfile(row, data, series);
|
||||
if (!latest || !debProfile) return empty;
|
||||
|
||||
const peakStartTs = debProfile.hotStartTs;
|
||||
const peakEndTs = debProfile.hotEndTs;
|
||||
const previousLivePoints = livePoints.filter((point) => point.ts < latest.ts);
|
||||
const previousHigh = previousLivePoints.length
|
||||
? Math.max(...previousLivePoints.map((point) => point.temp))
|
||||
: null;
|
||||
const observedHigh = Math.max(...livePoints.map((point) => point.temp));
|
||||
const trend30Base = pointAtOrBefore(livePoints, latest.ts - 30 * 60 * 1000);
|
||||
const trend60Base = pointAtOrBefore(livePoints, latest.ts - 60 * 60 * 1000);
|
||||
const trend30m = trend30Base ? latest.temp - trend30Base.temp : null;
|
||||
const trend60m = trend60Base ? latest.temp - trend60Base.temp : null;
|
||||
const distanceToDeb = debProfile.peak.temp - latest.temp;
|
||||
|
||||
const metaBase = {
|
||||
currentTemp: latest.temp,
|
||||
debPeak: debProfile.peak.temp,
|
||||
distanceToDeb,
|
||||
trend30m,
|
||||
trend60m,
|
||||
observedHigh,
|
||||
peakStartTs,
|
||||
peakEndTs,
|
||||
};
|
||||
|
||||
const nearThreshold = chartDeltaForCelsius(row, 2);
|
||||
const watchThreshold = chartDeltaForCelsius(row, 3);
|
||||
const breakoutNearThreshold = chartDeltaForCelsius(row, 0.5);
|
||||
const flatTrendFloor = -chartDeltaForCelsius(row, 0.2);
|
||||
const coolingDrop = -chartDeltaForCelsius(row, 0.5);
|
||||
const breakoutStep = chartDeltaForCelsius(row, 0.1);
|
||||
const phase = String((row as any)?.window_phase || "").toLowerCase();
|
||||
const afterPeak =
|
||||
phase === "post_peak" ||
|
||||
(peakEndTs !== null && latest.ts > peakEndTs);
|
||||
const isCooling =
|
||||
afterPeak &&
|
||||
((trend60m !== null && trend60m <= coolingDrop) ||
|
||||
(previousHigh !== null && latest.temp <= previousHigh + coolingDrop));
|
||||
if (isCooling) return { state: "cooling", ...metaBase };
|
||||
|
||||
const isBreakout =
|
||||
latest.temp >= debProfile.peak.temp ||
|
||||
(distanceToDeb <= breakoutNearThreshold &&
|
||||
previousHigh !== null &&
|
||||
latest.temp > previousHigh + breakoutStep);
|
||||
if (isBreakout) return { state: "breakout", ...metaBase };
|
||||
|
||||
if (
|
||||
distanceToDeb <= nearThreshold &&
|
||||
(trend30m === null || trend30m >= flatTrendFloor)
|
||||
) {
|
||||
return { state: "near_peak", ...metaBase };
|
||||
}
|
||||
|
||||
const isNearPeakTime =
|
||||
peakStartTs !== null &&
|
||||
peakEndTs !== null &&
|
||||
latest.ts <= peakEndTs &&
|
||||
peakStartTs - latest.ts <= 3 * 60 * 60 * 1000;
|
||||
if (distanceToDeb <= watchThreshold || isNearPeakTime) {
|
||||
return { state: "watch", ...metaBase };
|
||||
}
|
||||
|
||||
return { state: "none", ...metaBase };
|
||||
}
|
||||
|
||||
function getDebPeakWindowRange(
|
||||
data: Array<Record<string, any>>,
|
||||
series: EvidenceSeries[],
|
||||
@@ -1645,6 +1859,7 @@ export {
|
||||
buildChartDomain,
|
||||
buildFullDayChartData,
|
||||
getDebPeakWindowRange,
|
||||
getPeakGlowState,
|
||||
buildIntDegreeTicks,
|
||||
buildModelSummaryCards,
|
||||
buildRunwayPlates,
|
||||
@@ -1664,4 +1879,4 @@ export {
|
||||
validNumber,
|
||||
};
|
||||
|
||||
export type { EvidenceSeries, HourlyForecast };
|
||||
export type { EvidenceSeries, HourlyForecast, PeakGlowMeta, PeakGlowState };
|
||||
|
||||
@@ -21,6 +21,7 @@ from src.bot.settings import (
|
||||
)
|
||||
from src.database.db_manager import DBManager
|
||||
from src.utils.telegram_chat_ids import get_telegram_chat_ids_from_env
|
||||
from src.utils.telegram_i18n import copy_text, normalize_push_language, telegram_push_language
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
@@ -41,6 +42,14 @@ def _env_int(name: str, default: int, min_value: int = 0) -> int:
|
||||
return max(min_value, value)
|
||||
|
||||
|
||||
def _weekly_reward_language() -> str:
|
||||
return telegram_push_language(
|
||||
"POLYWEATHER_WEEKLY_REWARD_LANGUAGE",
|
||||
"TELEGRAM_PUSH_LANGUAGE",
|
||||
"POLYWEATHER_TELEGRAM_PUSH_LANGUAGE",
|
||||
)
|
||||
|
||||
|
||||
def _safe_week_key(dt: datetime) -> str:
|
||||
iso_year, iso_week, _ = dt.isocalendar()
|
||||
return f"{iso_year}-W{iso_week:02d}"
|
||||
@@ -177,10 +186,19 @@ def _render_settle_report(
|
||||
skipped: int,
|
||||
participation_count: int = 0,
|
||||
active_count: int = 0,
|
||||
language: Optional[str] = None,
|
||||
) -> str:
|
||||
lines = [f"🏆 <b>PolyWeather 周榜奖励已结算 ({week_key})</b>", "────────────────────"]
|
||||
language = normalize_push_language(language or _weekly_reward_language())
|
||||
lines = [
|
||||
copy_text(
|
||||
language,
|
||||
f"🏆 <b>PolyWeather weekly rewards settled ({week_key})</b>",
|
||||
f"🏆 <b>PolyWeather 周榜奖励已结算 ({week_key})</b>",
|
||||
),
|
||||
"────────────────────",
|
||||
]
|
||||
if not winners:
|
||||
lines.append("本周无上榜用户。")
|
||||
lines.append(copy_text(language, "No ranked users this week.", "本周无上榜用户。"))
|
||||
else:
|
||||
medals = {1: "🥇", 2: "🥈", 3: "🥉"}
|
||||
for row in winners:
|
||||
@@ -188,16 +206,39 @@ def _render_settle_report(
|
||||
name = str(row.get("username") or "unknown")[:16]
|
||||
points_bonus = int(row.get("points_bonus") or 0)
|
||||
pro_days = int(row.get("pro_days") or 0)
|
||||
pro_text = f" + {pro_days}天Pro" if pro_days > 0 else ""
|
||||
pro_text = (
|
||||
copy_text(language, f" + {pro_days}d Pro", f" + {pro_days}天Pro")
|
||||
if pro_days > 0
|
||||
else ""
|
||||
)
|
||||
medal = medals.get(rank, f"{rank}.")
|
||||
lines.append(f"{medal} {name}: +{points_bonus} 积分{pro_text}")
|
||||
points_label = copy_text(language, "points", "积分")
|
||||
lines.append(f"{medal} {name}: +{points_bonus} {points_label}{pro_text}")
|
||||
if skipped > 0:
|
||||
lines.append(f"(重复发放保护已生效,跳过 {skipped} 条)")
|
||||
lines.append(
|
||||
copy_text(
|
||||
language,
|
||||
f"Duplicate payout guard skipped {skipped} rows.",
|
||||
f"(重复发放保护已生效,跳过 {skipped} 条)",
|
||||
)
|
||||
)
|
||||
if participation_count > 0 or active_count > 0:
|
||||
lines.append("────────────────────")
|
||||
lines.append(f"🎁 参与奖 +{WEEKLY_PARTICIPATION_BONUS} 积分: {participation_count} 人")
|
||||
lines.append(
|
||||
copy_text(
|
||||
language,
|
||||
f"🎁 Participation bonus +{WEEKLY_PARTICIPATION_BONUS} points: {participation_count} users",
|
||||
f"🎁 参与奖 +{WEEKLY_PARTICIPATION_BONUS} 积分: {participation_count} 人",
|
||||
)
|
||||
)
|
||||
if active_count > 0:
|
||||
lines.append(f"🔥 活跃奖 +{WEEKLY_ACTIVE_BONUS} 积分 (周积分≥{WEEKLY_ACTIVE_THRESHOLD}): {active_count} 人")
|
||||
lines.append(
|
||||
copy_text(
|
||||
language,
|
||||
f"🔥 Active bonus +{WEEKLY_ACTIVE_BONUS} points (weekly points >= {WEEKLY_ACTIVE_THRESHOLD}): {active_count} users",
|
||||
f"🔥 活跃奖 +{WEEKLY_ACTIVE_BONUS} 积分 (周积分≥{WEEKLY_ACTIVE_THRESHOLD}): {active_count} 人",
|
||||
)
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ except Exception:
|
||||
|
||||
from src.data_collection.city_registry import CITY_REGISTRY
|
||||
from src.data_collection.weather_sources import WeatherDataCollector
|
||||
from src.utils.telegram_i18n import copy_text, normalize_push_language, telegram_push_language
|
||||
|
||||
TARGET_CITIES: List[str] = [
|
||||
"beijing",
|
||||
@@ -73,6 +74,14 @@ def _env_int(name: str, default: int, min_val: int = 0) -> int:
|
||||
return default
|
||||
|
||||
|
||||
def _daily_report_language() -> str:
|
||||
return telegram_push_language(
|
||||
"DAILY_WEATHER_REPORT_LANGUAGE",
|
||||
"TELEGRAM_PUSH_LANGUAGE",
|
||||
"POLYWEATHER_TELEGRAM_PUSH_LANGUAGE",
|
||||
)
|
||||
|
||||
|
||||
def _fetch_cma_forecast(city_key: str) -> Optional[Dict[str, Any]]:
|
||||
"""Scrape today's forecast from weather.com.cn (CMA)."""
|
||||
code = CMA_CITY_CODES.get(city_key)
|
||||
@@ -158,13 +167,14 @@ def _fetch_city_data(
|
||||
collector: WeatherDataCollector, city_key: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
name = CITY_NAME_ZH.get(city_key, city_key)
|
||||
info = CITY_REGISTRY.get(city_key)
|
||||
name_en = str((info or {}).get("name") or city_key.title())
|
||||
|
||||
# 1. Try CMA first for weather description
|
||||
cma = _fetch_cma_forecast(city_key)
|
||||
|
||||
# 2. Get Open-Meteo data for temperature fallback
|
||||
om_high = None
|
||||
info = CITY_REGISTRY.get(city_key)
|
||||
if info:
|
||||
try:
|
||||
results = collector.fetch_all_sources(
|
||||
@@ -209,6 +219,7 @@ def _fetch_city_data(
|
||||
return {
|
||||
"city": city_key,
|
||||
"name": name,
|
||||
"name_en": name_en,
|
||||
"weather": weather or "?",
|
||||
"forecast_high": forecast_high,
|
||||
}
|
||||
@@ -235,12 +246,40 @@ def _wmo_to_weather(code: Any) -> str:
|
||||
return "阴"
|
||||
|
||||
|
||||
def _build_ai_prompt(cities_data: List[Dict[str, Any]], report_date: str) -> str:
|
||||
lines = [f"今天是 {report_date}。请用自然亲切的中文写一段天气日报。\n"]
|
||||
lines.append("城市天气数据:")
|
||||
def _build_ai_prompt(
|
||||
cities_data: List[Dict[str, Any]],
|
||||
report_date: str,
|
||||
language: Optional[str] = None,
|
||||
) -> str:
|
||||
language = normalize_push_language(language or _daily_report_language())
|
||||
if language == "zh":
|
||||
lines = [f"今天是 {report_date}。请用自然亲切的中文写一段天气日报。\n"]
|
||||
lines.append("城市天气数据:")
|
||||
for c in cities_data:
|
||||
lines.append(f"{c['name']}:{c['weather']},最高{c['forecast_high']}度")
|
||||
lines.append("\n要求:每城一行播报,城市名<b>加粗</b>,开头问候,禁止结尾废话。")
|
||||
return "\n".join(lines)
|
||||
|
||||
if language == "en":
|
||||
lines = [f"Today is {report_date}. Write a concise Telegram weather briefing in English.\n"]
|
||||
lines.append("City weather data:")
|
||||
for c in cities_data:
|
||||
lines.append(
|
||||
f"{c.get('name_en') or c['city'].title()}: weather={c['weather']}, high={c['forecast_high']}°C"
|
||||
)
|
||||
lines.append("\nRequirements: one line per city, bold city names with <b>, start with a short greeting, no generic closing.")
|
||||
return "\n".join(lines)
|
||||
|
||||
lines = [f"Today is {report_date}. Write a bilingual Telegram weather briefing.\n"]
|
||||
lines.append("City weather data:")
|
||||
for c in cities_data:
|
||||
lines.append(f"{c['name']}:{c['weather']},最高{c['forecast_high']}度")
|
||||
lines.append("\n要求:每城一行播报,城市名<b>加粗</b>,开头问候,禁止结尾废话。")
|
||||
lines.append(
|
||||
f"{c.get('name_en') or c['city'].title()} / {c['name']}: weather={c['weather']}, high={c['forecast_high']}°C"
|
||||
)
|
||||
lines.append(
|
||||
"\nRequirements: one line per city, English first then Chinese in the same line, "
|
||||
"bold city names with <b>, start with a short bilingual greeting, no generic closing."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -352,8 +391,9 @@ def _runner(bot: Any, config: Dict[str, Any]) -> None:
|
||||
time.sleep(60)
|
||||
continue
|
||||
|
||||
language = _daily_report_language()
|
||||
report_date = now.strftime("%m月%d日")
|
||||
prompt = _build_ai_prompt(cities_data, report_date)
|
||||
prompt = _build_ai_prompt(cities_data, report_date, language=language)
|
||||
report_text = _call_ai(prompt)
|
||||
|
||||
if not report_text:
|
||||
@@ -363,7 +403,11 @@ def _runner(bot: Any, config: Dict[str, Any]) -> None:
|
||||
continue
|
||||
|
||||
try:
|
||||
report_text += "\n\n⚠️ 以上为粗略预测,仅供参考。"
|
||||
report_text += "\n\n" + copy_text(
|
||||
language,
|
||||
"⚠️ Rough forecast only. For reference.",
|
||||
"⚠️ 以上为粗略预测,仅供参考。",
|
||||
)
|
||||
bot.send_message(
|
||||
FORUM_CHAT_ID,
|
||||
report_text,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def normalize_push_language(raw: Optional[str]) -> str:
|
||||
value = str(raw or "").strip().lower().replace("_", "-")
|
||||
if value in {"zh", "zh-cn", "cn", "chinese"}:
|
||||
return "zh"
|
||||
if value in {"en", "en-us", "english"}:
|
||||
return "en"
|
||||
if value in {"both", "bilingual", "dual", "en-zh", "zh-en"}:
|
||||
return "both"
|
||||
return "both"
|
||||
|
||||
|
||||
def telegram_push_language(*env_names: str, default: str = "both") -> str:
|
||||
names = env_names or (
|
||||
"TELEGRAM_PUSH_LANGUAGE",
|
||||
"POLYWEATHER_TELEGRAM_PUSH_LANGUAGE",
|
||||
)
|
||||
for name in names:
|
||||
raw = os.getenv(name)
|
||||
if raw:
|
||||
return normalize_push_language(raw)
|
||||
return normalize_push_language(default)
|
||||
|
||||
|
||||
def is_zh(language: Optional[str]) -> bool:
|
||||
return normalize_push_language(language) == "zh"
|
||||
|
||||
|
||||
def is_bilingual(language: Optional[str]) -> bool:
|
||||
return normalize_push_language(language) == "both"
|
||||
|
||||
|
||||
def copy_text(language: Optional[str], en: str, zh: str) -> str:
|
||||
if is_zh(language):
|
||||
return zh
|
||||
if is_bilingual(language):
|
||||
return f"{en} / {zh}"
|
||||
return en
|
||||
+108
-48
@@ -19,6 +19,13 @@ from src.database.runtime_state import (
|
||||
)
|
||||
from src.data_collection.city_registry import CITY_REGISTRY
|
||||
from src.utils.telegram_chat_ids import get_telegram_chat_ids_from_env
|
||||
from src.utils.telegram_i18n import (
|
||||
copy_text as _copy,
|
||||
is_bilingual as _is_bilingual,
|
||||
is_zh as _is_zh,
|
||||
normalize_push_language as _normalize_push_language,
|
||||
telegram_push_language as _resolve_telegram_push_language,
|
||||
)
|
||||
|
||||
# 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.
|
||||
@@ -144,6 +151,14 @@ def _env_float(name: str, default: float) -> float:
|
||||
return default
|
||||
|
||||
|
||||
def _telegram_push_language() -> str:
|
||||
return _resolve_telegram_push_language(
|
||||
"TELEGRAM_AIRPORT_PUSH_LANGUAGE",
|
||||
"TELEGRAM_PUSH_LANGUAGE",
|
||||
"POLYWEATHER_TELEGRAM_PUSH_LANGUAGE",
|
||||
)
|
||||
|
||||
|
||||
def _norm_prob(v: Any) -> Optional[float]:
|
||||
if v is None:
|
||||
return None
|
||||
@@ -631,11 +646,16 @@ WIND_REGIME: Dict[str, Dict[str, Tuple[int, int]]] = {
|
||||
# Legacy alias for backward compat with existing _select_focus_runway_obs / _focus_runway_pairs_for_city
|
||||
FOCUS_RUNWAY_PAIRS = SETTLEMENT_RUNWAY_PAIRS
|
||||
|
||||
_FUNCTION_HASHTAGS = {
|
||||
_FUNCTION_HASHTAGS_ZH = {
|
||||
"runway": "#跑道观测",
|
||||
"airport": "#机场观测",
|
||||
"trade": "#交易机会",
|
||||
}
|
||||
_FUNCTION_HASHTAGS_EN = {
|
||||
"runway": "#RunwayObs",
|
||||
"airport": "#AirportObs",
|
||||
"trade": "#TradeAlert",
|
||||
}
|
||||
|
||||
|
||||
def _city_hashtag(city: Optional[str]) -> Optional[str]:
|
||||
@@ -659,15 +679,27 @@ def _build_telegram_hashtag_line(
|
||||
city: Optional[str] = None,
|
||||
station: Optional[str] = None,
|
||||
extra: Optional[List[str]] = None,
|
||||
language: Optional[str] = None,
|
||||
) -> str:
|
||||
tags: List[str] = []
|
||||
primary = _FUNCTION_HASHTAGS.get(kind)
|
||||
if primary:
|
||||
tags.append(primary)
|
||||
tag_maps = (
|
||||
(_FUNCTION_HASHTAGS_EN, _FUNCTION_HASHTAGS_ZH)
|
||||
if _is_bilingual(language)
|
||||
else ((_FUNCTION_HASHTAGS_ZH,) if _is_zh(language) else (_FUNCTION_HASHTAGS_EN,))
|
||||
)
|
||||
for tag_map in tag_maps:
|
||||
primary = tag_map.get(kind)
|
||||
if primary and primary not in tags:
|
||||
tags.append(primary)
|
||||
for item in extra or []:
|
||||
tag = _FUNCTION_HASHTAGS.get(item, item)
|
||||
if tag and tag not in tags:
|
||||
tags.append(tag)
|
||||
added = False
|
||||
for tag_map in tag_maps:
|
||||
tag = tag_map.get(item)
|
||||
if tag and tag not in tags:
|
||||
tags.append(tag)
|
||||
added = True
|
||||
if not added and item and item not in tags:
|
||||
tags.append(item)
|
||||
city_tag = _city_hashtag(city)
|
||||
if city_tag and city_tag not in tags:
|
||||
tags.append(city_tag)
|
||||
@@ -752,7 +784,7 @@ def _is_settlement_runway(city: str, r1: str, r2: str) -> bool:
|
||||
return _runway_pair_key(r1, r2) in pair_set
|
||||
|
||||
|
||||
def _wind_regime_label(city: str, wind_dir: Optional[int]) -> Optional[str]:
|
||||
def _wind_regime_label(city: str, wind_dir: Optional[int], language: Optional[str] = None) -> Optional[str]:
|
||||
"""Classify wind direction into a thermal regime label."""
|
||||
if wind_dir is None:
|
||||
return None
|
||||
@@ -760,9 +792,9 @@ def _wind_regime_label(city: str, wind_dir: Optional[int]) -> Optional[str]:
|
||||
sea = regimes.get("sea_breeze")
|
||||
warm = regimes.get("warm_advection")
|
||||
if sea and sea[0] != sea[1] and sea[0] <= wind_dir <= sea[1]:
|
||||
return "海风降温"
|
||||
return _copy(language, "sea-breeze cooling", "海风降温")
|
||||
if warm and warm[0] != warm[1] and warm[0] <= wind_dir <= warm[1]:
|
||||
return "暖平流增强"
|
||||
return _copy(language, "warm advection", "暖平流增强")
|
||||
return None
|
||||
|
||||
|
||||
@@ -789,18 +821,21 @@ def _runway_heat_signal(
|
||||
slope_15m: Optional[float],
|
||||
wind_dir: Optional[int],
|
||||
city: str,
|
||||
language: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Compute a simple runway heat signal label."""
|
||||
if slope_15m is None:
|
||||
return ""
|
||||
regime = _wind_regime_label(city, wind_dir)
|
||||
regime = _wind_regime_label(city, wind_dir, language)
|
||||
warm_regime = _copy(language, "warm advection", "暖平流增强")
|
||||
sea_regime = _copy(language, "sea-breeze cooling", "海风降温")
|
||||
if slope_15m >= 1.0:
|
||||
return "🚀 冲顶增强" if regime == "暖平流增强" else "🔥 升温中"
|
||||
return _copy(language, "🚀 Peak push strengthening", "🚀 冲顶增强") if regime == warm_regime else _copy(language, "🔥 Warming", "🔥 升温中")
|
||||
if slope_15m >= 0.5:
|
||||
return "🔥 升温中"
|
||||
return _copy(language, "🔥 Warming", "🔥 升温中")
|
||||
if slope_15m >= -0.2:
|
||||
return "⚠️ 高位观察" if regime == "海风降温" else "⏸️ 高位横盘"
|
||||
return "🧊 过峰风险"
|
||||
return _copy(language, "⚠️ Watch sea-breeze cooling", "⚠️ 高位观察") if regime == sea_regime else _copy(language, "⏸️ Holding near high", "⏸️ 高位横盘")
|
||||
return _copy(language, "🧊 Peak-risk cooling", "🧊 过峰风险")
|
||||
|
||||
|
||||
def _focused_runway_max(city: str, city_weather: Dict[str, Any]) -> Optional[float]:
|
||||
@@ -953,7 +988,9 @@ def _build_airport_status_message(
|
||||
source_label: str = "",
|
||||
arome_temp: Optional[float] = None,
|
||||
aeroweb_available: bool = False,
|
||||
language: Optional[str] = None,
|
||||
) -> str:
|
||||
language = _normalize_push_language(language or _telegram_push_language())
|
||||
_AIRPORT_EN = {"seoul": "Incheon", "singapore": "Changi", "busan": "Gimhae", "tokyo": "Haneda",
|
||||
"ankara": "Esenboğa", "helsinki": "Vantaa", "amsterdam": "Schiphol",
|
||||
"istanbul": "Airport", "paris": "Le Bourget",
|
||||
@@ -1018,8 +1055,8 @@ def _build_airport_status_message(
|
||||
# ── Heat model ──
|
||||
wind_dir = amos.get("wind_dir") if is_amsc else None
|
||||
slope_15m = _compute_slope_15m(amos_icao, display_temp) if is_amsc and display_temp is not None else None
|
||||
heat_signal = _runway_heat_signal(display_temp or 0, slope_15m, wind_dir, city) if is_amsc else ""
|
||||
wind_label = _wind_regime_label(city, wind_dir) if is_amsc and wind_dir is not None else None
|
||||
heat_signal = _runway_heat_signal(display_temp or 0, slope_15m, wind_dir, city, language) if is_amsc else ""
|
||||
wind_label = _wind_regime_label(city, wind_dir, language) if is_amsc and wind_dir is not None else None
|
||||
|
||||
max_so_far, max_temp_time = _get_airport_daily_high(city_weather)
|
||||
# ── Build message ──
|
||||
@@ -1029,6 +1066,7 @@ def _build_airport_status_message(
|
||||
hashtag_line = _build_telegram_hashtag_line(
|
||||
"runway" if has_runway else "airport",
|
||||
city=city,
|
||||
language=language,
|
||||
)
|
||||
icao_display = f"{amos_icao} · " if amos_icao else ""
|
||||
settlement_str = f" · ★{settlement_pair[0]}/{settlement_pair[1]}" if settlement_pair else ""
|
||||
@@ -1055,7 +1093,7 @@ def _build_airport_status_message(
|
||||
mid = pts.get("mid_temp")
|
||||
end = pts.get("end_temp")
|
||||
is_settlement = _is_settlement_runway(city, r1, r2)
|
||||
marker = " ★结算" if is_settlement else ""
|
||||
marker = _copy(language, " ★Settlement", " ★结算") if is_settlement else ""
|
||||
tmax = pts.get("target_runway_max")
|
||||
if tdz is not None or mid is not None or end is not None:
|
||||
line = f"{r1}/{r2}{marker} TDZ:{_fmt(tdz)} MID:{_fmt(mid)} END:{_fmt(end)}"
|
||||
@@ -1063,7 +1101,8 @@ def _build_airport_status_message(
|
||||
line += f" max:{tmax:.1f}"
|
||||
lines.append(line)
|
||||
else:
|
||||
lines.append(f"{r1}/{r2}{marker} {t:.1f}°C")
|
||||
temp_symbol = str(city_weather.get("temp_symbol") or "°C").strip()
|
||||
lines.append(f"{r1}/{r2}{marker} {t:.1f}{temp_symbol}")
|
||||
|
||||
# Summary stats
|
||||
lines.append("")
|
||||
@@ -1072,36 +1111,43 @@ def _build_airport_status_message(
|
||||
|
||||
if city == "paris":
|
||||
if aeroweb_available and display_temp is not None:
|
||||
lines.append(f"当前实况:{cur_str}")
|
||||
lines.append(f"{_copy(language, 'Current observation', '当前实况')}: {cur_str}")
|
||||
else:
|
||||
lines.append("当前实况:暂无")
|
||||
lines.append(_copy(language, "Current observation: unavailable", "当前实况:暂无"))
|
||||
else:
|
||||
if has_runway:
|
||||
lines.append(f"结算跑道当前:{cur_str}")
|
||||
current_label = (
|
||||
_copy(language, "Settlement runway now", "结算跑道当前")
|
||||
if settlement_pair
|
||||
else _copy(language, "Runway now", "跑道当前")
|
||||
)
|
||||
lines.append(f"{current_label}: {cur_str}")
|
||||
else:
|
||||
lines.append(f"当前:{cur_str}")
|
||||
lines.append(f"{_copy(language, 'Current', '当前')}: {cur_str}")
|
||||
|
||||
if city == "paris":
|
||||
if aeroweb_available and max_so_far is not None:
|
||||
time_str = f"({max_temp_time})" if max_temp_time else ""
|
||||
lines.append(f"日高实况:{max_so_far:.1f}{temp_symbol}{time_str}")
|
||||
time_str = f" ({max_temp_time})" if max_temp_time and not _is_zh(language) else (f"({max_temp_time})" if max_temp_time else "")
|
||||
lines.append(f"{_copy(language, 'Observed high', '日高实况')}: {max_so_far:.1f}{temp_symbol}{time_str}")
|
||||
elif not aeroweb_available:
|
||||
last_temp = _LAST_AEROWEB.get("temp")
|
||||
if last_temp is not None:
|
||||
last_time = _LAST_AEROWEB.get("max_time", "")
|
||||
time_str = f"({last_time})" if last_time else ""
|
||||
lines.append(f"最近实况:{last_temp:.1f}{temp_symbol}{time_str}")
|
||||
time_str = f" ({last_time})" if last_time and not _is_zh(language) else (f"({last_time})" if last_time else "")
|
||||
lines.append(f"{_copy(language, 'Latest observation', '最近实况')}: {last_temp:.1f}{temp_symbol}{time_str}")
|
||||
elif max_so_far is not None:
|
||||
time_str = f"({max_temp_time})" if max_temp_time else ""
|
||||
time_str = f" ({max_temp_time})" if max_temp_time and not _is_zh(language) else (f"({max_temp_time})" if max_temp_time else "")
|
||||
if has_runway:
|
||||
lines.append(f"今日跑道高点:{max_so_far:.1f}{temp_symbol}{time_str}")
|
||||
high_label = _copy(language, "Today's runway high", "今日跑道高点")
|
||||
lines.append(f"{high_label}: {max_so_far:.1f}{temp_symbol}{time_str}")
|
||||
else:
|
||||
lines.append(f"日高:{max_so_far:.1f}{temp_symbol}{time_str}")
|
||||
high_label = _copy(language, "Today's high", "日高")
|
||||
lines.append(f"{high_label}: {max_so_far:.1f}{temp_symbol}{time_str}")
|
||||
if slope_15m is not None:
|
||||
sign = "+" if slope_15m >= 0 else ""
|
||||
lines.append(f"15分钟趋势:{sign}{slope_15m:.1f}°C")
|
||||
lines.append(f"{_copy(language, '15m trend', '15分钟趋势')}: {sign}{slope_15m:.1f}°")
|
||||
if wind_dir is not None:
|
||||
wind_str = f"风向:{wind_dir}°"
|
||||
wind_str = f"{_copy(language, 'Wind dir', '风向')}: {wind_dir}°"
|
||||
if wind_label:
|
||||
wind_str += f" {wind_label}"
|
||||
lines.append(wind_str)
|
||||
@@ -1127,7 +1173,7 @@ def _build_airport_status_message(
|
||||
bj_h = hh + 8
|
||||
if bj_h >= 24:
|
||||
bj_h -= 24
|
||||
metar_time = f"北京时 {bj_h:02d}:{mm}"
|
||||
metar_time = f"{_copy(language, 'Beijing time', '北京时')} {bj_h:02d}:{mm}"
|
||||
break
|
||||
if metar_temp or metar_time:
|
||||
bits = []
|
||||
@@ -1135,46 +1181,54 @@ def _build_airport_status_message(
|
||||
bits.append(f"{metar_temp}{temp_symbol}")
|
||||
if metar_time:
|
||||
bits.append(metar_time)
|
||||
lines.append(f"报文: {' '.join(bits)}")
|
||||
lines.append(f"{_copy(language, 'METAR', '报文')}: {' '.join(bits)}")
|
||||
if deb_pred is not None:
|
||||
if city == "paris":
|
||||
if aeroweb_available and display_temp is not None:
|
||||
diff = display_temp - deb_pred
|
||||
sign = "+" if diff >= 0 else ""
|
||||
lines.append(f"DEB:{deb_pred:.1f}{temp_symbol}(距实况 {sign}{diff:.1f}°)")
|
||||
suffix = _copy(language, f" (vs obs {sign}{diff:.1f}°)", f"(距实况 {sign}{diff:.1f}°)")
|
||||
lines.append(f"DEB: {deb_pred:.1f}{temp_symbol}{suffix}" if not _is_zh(language) else f"DEB:{deb_pred:.1f}{temp_symbol}{suffix}")
|
||||
elif not aeroweb_available:
|
||||
last_temp = _LAST_AEROWEB.get("temp")
|
||||
if last_temp is not None:
|
||||
diff = last_temp - deb_pred
|
||||
sign = "+" if diff >= 0 else ""
|
||||
lines.append(f"DEB:{deb_pred:.1f}{temp_symbol}(距最近实况 {sign}{diff:.1f}°)")
|
||||
suffix = _copy(language, f" (vs latest obs {sign}{diff:.1f}°)", f"(距最近实况 {sign}{diff:.1f}°)")
|
||||
lines.append(f"DEB: {deb_pred:.1f}{temp_symbol}{suffix}" if not _is_zh(language) else f"DEB:{deb_pred:.1f}{temp_symbol}{suffix}")
|
||||
else:
|
||||
lines.append(f"DEB:{deb_pred:.1f}{temp_symbol}")
|
||||
lines.append(f"DEB: {deb_pred:.1f}{temp_symbol}" if not _is_zh(language) else f"DEB:{deb_pred:.1f}{temp_symbol}")
|
||||
else:
|
||||
lines.append(f"DEB:{deb_pred:.1f}{temp_symbol}")
|
||||
lines.append(f"DEB: {deb_pred:.1f}{temp_symbol}" if not _is_zh(language) else f"DEB:{deb_pred:.1f}{temp_symbol}")
|
||||
elif display_temp is not None and display_temp > deb_pred:
|
||||
lines.append(f"DEB:{deb_pred:.1f}{temp_symbol}(已突破 +{display_temp - deb_pred:.1f}°)")
|
||||
suffix = _copy(language, f" (already above +{display_temp - deb_pred:.1f}°)", f"(已突破 +{display_temp - deb_pred:.1f}°)")
|
||||
lines.append(f"DEB: {deb_pred:.1f}{temp_symbol}{suffix}" if not _is_zh(language) else f"DEB:{deb_pred:.1f}{temp_symbol}{suffix}")
|
||||
else:
|
||||
lines.append(f"DEB:{deb_pred:.1f}{temp_symbol}")
|
||||
lines.append(f"DEB: {deb_pred:.1f}{temp_symbol}" if not _is_zh(language) else f"DEB:{deb_pred:.1f}{temp_symbol}")
|
||||
|
||||
if city == "paris":
|
||||
lines.append("")
|
||||
if aeroweb_available and display_temp is not None:
|
||||
lines.append(f"📡 AEROWEB 机场实况:{display_temp:.1f}{temp_symbol} · Météo-France")
|
||||
label = _copy(language, "AEROWEB airport obs", "AEROWEB 机场实况")
|
||||
lines.append(f"📡 {label}: {display_temp:.1f}{temp_symbol} · Météo-France")
|
||||
else:
|
||||
lines.append("📡 AEROWEB 机场实况:暂不可用")
|
||||
lines.append(_copy(language, "📡 AEROWEB airport obs: unavailable", "📡 AEROWEB 机场实况:暂不可用"))
|
||||
if arome_temp is not None:
|
||||
if aeroweb_available and display_temp is not None:
|
||||
d = arome_temp - display_temp
|
||||
sign = "+" if d >= 0 else ""
|
||||
lines.append(f"🕐 AROME HD 15分钟临近预报:{arome_temp:.1f}{temp_symbol}(较实况 {sign}{d:.1f}°)")
|
||||
label = _copy(language, "AROME HD 15m nowcast", "AROME HD 15分钟临近预报")
|
||||
suffix = _copy(language, f" (vs obs {sign}{d:.1f}°)", f"(较实况 {sign}{d:.1f}°)")
|
||||
lines.append(f"🕐 {label}: {arome_temp:.1f}{temp_symbol}{suffix}")
|
||||
else:
|
||||
lines.append(f"🕐 AROME HD 15分钟临近预报:{arome_temp:.1f}{temp_symbol}")
|
||||
label = _copy(language, "AROME HD 15m nowcast", "AROME HD 15分钟临近预报")
|
||||
lines.append(f"🕐 {label}: {arome_temp:.1f}{temp_symbol}")
|
||||
else:
|
||||
lines.append("🕐 AROME HD 15分钟临近预报:--")
|
||||
label = _copy(language, "AROME HD 15m nowcast", "AROME HD 15分钟临近预报")
|
||||
lines.append(f"🕐 {label}: --")
|
||||
if not aeroweb_available:
|
||||
lines.append("")
|
||||
lines.append("提示:当前仅显示模型参考,不更新实况日高。")
|
||||
lines.append(_copy(language, "Note: showing model reference only; observed high is not updating.", "提示:当前仅显示模型参考,不更新实况日高。"))
|
||||
elif source_label:
|
||||
lines.append("")
|
||||
lines.append(f"📡 {source_label}")
|
||||
@@ -1186,8 +1240,14 @@ def _build_airport_status_message(
|
||||
if len(vals) >= 2:
|
||||
lo, hi = vals[0][0], vals[-1][0]
|
||||
spread = hi - lo
|
||||
spread_label = "低分歧" if spread <= 2.0 else ("中等分歧" if spread <= 4.0 else "高分歧")
|
||||
lines.append(f"模型区间:{lo:.1f}~{hi:.1f}{temp_symbol} {spread_label}")
|
||||
if spread <= 2.0:
|
||||
spread_label = _copy(language, "low dispersion", "低分歧")
|
||||
elif spread <= 4.0:
|
||||
spread_label = _copy(language, "moderate dispersion", "中等分歧")
|
||||
else:
|
||||
spread_label = _copy(language, "high dispersion", "高分歧")
|
||||
range_label = _copy(language, "Model range", "模型区间")
|
||||
lines.append(f"{range_label}: {lo:.1f}~{hi:.1f}{temp_symbol} {spread_label}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -3,11 +3,16 @@ from src.utils.telegram_push import (
|
||||
HIGH_FREQ_AIRPORT_ICAO,
|
||||
_build_airport_status_message,
|
||||
_run_high_freq_airport_cycle,
|
||||
_telegram_push_language,
|
||||
)
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_airport_status_message_starts_with_runway_city_and_station_hashtags():
|
||||
def test_airport_status_message_defaults_to_bilingual_runway_copy(monkeypatch):
|
||||
monkeypatch.delenv("TELEGRAM_AIRPORT_PUSH_LANGUAGE", raising=False)
|
||||
monkeypatch.delenv("TELEGRAM_PUSH_LANGUAGE", raising=False)
|
||||
monkeypatch.delenv("POLYWEATHER_TELEGRAM_PUSH_LANGUAGE", raising=False)
|
||||
|
||||
text = _build_airport_status_message(
|
||||
"qingdao",
|
||||
{
|
||||
@@ -32,10 +37,13 @@ def test_airport_status_message_starts_with_runway_city_and_station_hashtags():
|
||||
)
|
||||
|
||||
first_line = text.splitlines()[0]
|
||||
assert first_line == "#跑道观测 #Qingdao"
|
||||
assert _telegram_push_language() == "both"
|
||||
assert first_line == "#RunwayObs #跑道观测 #Qingdao"
|
||||
assert "Qingdao / Jiaodong" in text
|
||||
assert "TDZ:23.0" in text
|
||||
assert "DEB" in text and "24.0" in text
|
||||
assert "Runway now / 跑道当前:" in text
|
||||
assert "Today's runway high / 今日跑道高点:" in text
|
||||
assert "DEB: 24.0°C" in text
|
||||
|
||||
|
||||
def test_airport_status_hides_non_focus_runways_for_key_airports():
|
||||
@@ -62,7 +70,7 @@ def test_airport_status_hides_non_focus_runways_for_key_airports():
|
||||
|
||||
assert "02L/20R" in text
|
||||
assert "02R/20L" in text
|
||||
assert "结算跑道当前:31.2°C" in text
|
||||
assert "Settlement runway now / 结算跑道当前: 31.2°C" in text
|
||||
|
||||
|
||||
def test_singapore_is_in_telegram_push_city_lists():
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
from src.bot.weekly_reward_loop import _render_settle_report
|
||||
from src.utils.daily_weather_report import _build_ai_prompt
|
||||
from src.utils.telegram_i18n import copy_text, telegram_push_language
|
||||
|
||||
|
||||
def test_telegram_push_language_defaults_to_bilingual(monkeypatch):
|
||||
monkeypatch.delenv("TELEGRAM_PUSH_LANGUAGE", raising=False)
|
||||
monkeypatch.delenv("POLYWEATHER_TELEGRAM_PUSH_LANGUAGE", raising=False)
|
||||
|
||||
assert telegram_push_language() == "both"
|
||||
assert copy_text("both", "Current", "当前") == "Current / 当前"
|
||||
|
||||
|
||||
def test_daily_weather_report_prompt_defaults_to_bilingual(monkeypatch):
|
||||
monkeypatch.delenv("DAILY_WEATHER_REPORT_LANGUAGE", raising=False)
|
||||
monkeypatch.delenv("TELEGRAM_PUSH_LANGUAGE", raising=False)
|
||||
|
||||
prompt = _build_ai_prompt(
|
||||
[
|
||||
{
|
||||
"city": "beijing",
|
||||
"name": "北京",
|
||||
"name_en": "Beijing",
|
||||
"weather": "晴",
|
||||
"forecast_high": 28.0,
|
||||
}
|
||||
],
|
||||
"05月27日",
|
||||
)
|
||||
|
||||
assert "bilingual Telegram weather briefing" in prompt
|
||||
assert "Beijing / 北京" in prompt
|
||||
assert "English first then Chinese" in prompt
|
||||
|
||||
|
||||
def test_weekly_reward_report_defaults_to_bilingual(monkeypatch):
|
||||
monkeypatch.delenv("POLYWEATHER_WEEKLY_REWARD_LANGUAGE", raising=False)
|
||||
monkeypatch.delenv("TELEGRAM_PUSH_LANGUAGE", raising=False)
|
||||
|
||||
text = _render_settle_report(
|
||||
week_key="2026-W21",
|
||||
winners=[
|
||||
{
|
||||
"rank": 1,
|
||||
"username": "ada",
|
||||
"points_bonus": 200,
|
||||
"pro_days": 7,
|
||||
}
|
||||
],
|
||||
skipped=1,
|
||||
participation_count=3,
|
||||
active_count=1,
|
||||
)
|
||||
|
||||
assert "weekly rewards settled" in text
|
||||
assert "周榜奖励已结算" in text
|
||||
assert "points / 积分" in text
|
||||
assert "Participation bonus" in text
|
||||
assert "参与奖" in text
|
||||
Reference in New Issue
Block a user