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=
|
TELEGRAM_QUERY_TOPIC_MAP=
|
||||||
POLYWEATHER_BOT_GROUP_INVITE_URL=
|
POLYWEATHER_BOT_GROUP_INVITE_URL=
|
||||||
POLYWEATHER_APP_URL=https://polyweather-pro.vercel.app
|
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.
|
# High-frequency airport push loop. Keep workers at 1 on shared VPS.
|
||||||
TELEGRAM_AIRPORT_PUSH_ENABLED=true
|
TELEGRAM_AIRPORT_PUSH_ENABLED=true
|
||||||
TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC=60
|
TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC=60
|
||||||
TELEGRAM_AIRPORT_PUSH_MAX_WORKERS=1
|
TELEGRAM_AIRPORT_PUSH_MAX_WORKERS=1
|
||||||
|
# Optional airport-only override.
|
||||||
|
TELEGRAM_AIRPORT_PUSH_LANGUAGE=both
|
||||||
# Docker-only safety limits for the bot service.
|
# Docker-only safety limits for the bot service.
|
||||||
POLYWEATHER_BOT_CPUS=0.75
|
POLYWEATHER_BOT_CPUS=0.75
|
||||||
POLYWEATHER_BOT_MEM_LIMIT=768m
|
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_ENABLED` | 启用机场推送 | `true` |
|
||||||
| `TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC` | 循环轮询间隔 | `60` |
|
| `TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC` | 循环轮询间隔 | `60` |
|
||||||
|
| `TELEGRAM_AIRPORT_PUSH_LANGUAGE` | 机场推送语言覆盖,可选 `both`/`en`/`zh` | `both` |
|
||||||
| `KNMI_API_KEY` | KNMI API 密钥(阿姆斯特丹必填) | — |
|
| `KNMI_API_KEY` | KNMI API 密钥(阿姆斯特丹必填) | — |
|
||||||
|
|
||||||
## 未接入城市
|
## 未接入城市
|
||||||
|
|||||||
@@ -438,6 +438,47 @@
|
|||||||
animation: markerGlow 2s ease-in-out infinite;
|
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 ── */
|
/* ── Scrollbar ── */
|
||||||
.custom-scrollbar::-webkit-scrollbar {
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
@@ -460,3 +501,35 @@
|
|||||||
box-shadow: 0 10px 26px rgba(0, 224, 164, 0.3);
|
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) => {
|
{visibleSlots.map((cityInSlot, slotIndex) => {
|
||||||
const isSlotActive = activeSlotIndex === slotIndex;
|
const isSlotActive = activeSlotIndex === slotIndex;
|
||||||
|
const rowForSlot = cityInSlot
|
||||||
|
? filteredRegionRows.find(
|
||||||
|
(r) => String(r.city || "").toLowerCase() === cityInSlot
|
||||||
|
) || null
|
||||||
|
: null;
|
||||||
|
|
||||||
if (!cityInSlot) {
|
if (!cityInSlot || !rowForSlot) {
|
||||||
return (
|
return (
|
||||||
<div key={slotIndex} className="relative h-full">
|
<div key={slotIndex} className="relative h-full">
|
||||||
<EmptySlotCard
|
<EmptySlotCard
|
||||||
@@ -850,10 +855,6 @@ function PolyWeatherTerminal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rowForSlot = filteredRegionRows.find(
|
|
||||||
(r) => String(r.city || "").toLowerCase() === cityInSlot
|
|
||||||
) || null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={slotIndex}
|
key={slotIndex}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
fetchHourlyForecastForCity,
|
fetchHourlyForecastForCity,
|
||||||
getActiveTemperatureSeries,
|
getActiveTemperatureSeries,
|
||||||
getDebPeakWindowRange,
|
getDebPeakWindowRange,
|
||||||
|
getPeakGlowState,
|
||||||
getLiveObservationLabels,
|
getLiveObservationLabels,
|
||||||
getObservationDisplayMetrics,
|
getObservationDisplayMetrics,
|
||||||
getVisibleTemperatureSeries,
|
getVisibleTemperatureSeries,
|
||||||
@@ -35,6 +36,46 @@ import {
|
|||||||
} from "@/components/dashboard/scan-terminal/temperature-chart-logic";
|
} from "@/components/dashboard/scan-terminal/temperature-chart-logic";
|
||||||
export { clearCityDetailCache } 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) {
|
function formatCityLocalDate(tzOffsetSeconds: number | null | undefined) {
|
||||||
const cityOffsetMs = (tzOffsetSeconds ?? 0) * 1000;
|
const cityOffsetMs = (tzOffsetSeconds ?? 0) * 1000;
|
||||||
const cityNow = new Date(Date.now() + cityOffsetMs + new Date().getTimezoneOffset() * 60_000);
|
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 chartLocalDate = chartHourly?.localDate || row?.local_date || currentCityLocalDate;
|
||||||
|
|
||||||
const { data, series } = useMemo(() => buildFullDayChartData(row, chartHourly, isEn), [row, chartHourly, isEn]);
|
const { data, series } = useMemo(() => buildFullDayChartData(row, chartHourly, isEn), [row, chartHourly, isEn]);
|
||||||
|
const peakGlow = useMemo(() => getPeakGlowState(row, data, series), [row, data, series]);
|
||||||
|
|
||||||
const autoWindowRange = useMemo(
|
const autoWindowRange = useMemo(
|
||||||
() => (viewMode === "auto" ? getDebPeakWindowRange(data, series) : null),
|
() => (viewMode === "auto" ? getDebPeakWindowRange(data, series) : null),
|
||||||
@@ -442,6 +484,17 @@ export function LiveTemperatureThresholdChart({
|
|||||||
</button>
|
</button>
|
||||||
<span className="text-slate-400 font-normal">·</span>
|
<span className="text-slate-400 font-normal">·</span>
|
||||||
<span className="text-slate-500 font-normal">{subtitle}</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>
|
</div>
|
||||||
) : isEn ? (
|
) : isEn ? (
|
||||||
"Temperature Chart"
|
"Temperature Chart"
|
||||||
@@ -559,7 +612,11 @@ export function LiveTemperatureThresholdChart({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex h-full min-h-[300px] flex-col">
|
||||||
<TemperatureStatsBars
|
<TemperatureStatsBars
|
||||||
isEn={isEn}
|
isEn={isEn}
|
||||||
@@ -641,5 +698,6 @@ export const __getActiveTemperatureSeriesForTest = getActiveTemperatureSeries;
|
|||||||
export const __getDebPeakWindowRangeForTest = getDebPeakWindowRange;
|
export const __getDebPeakWindowRangeForTest = getDebPeakWindowRange;
|
||||||
export const __getLiveObservationLabelsForTest = getLiveObservationLabels;
|
export const __getLiveObservationLabelsForTest = getLiveObservationLabels;
|
||||||
export const __getObservationDisplayMetricsForTest = getObservationDisplayMetrics;
|
export const __getObservationDisplayMetricsForTest = getObservationDisplayMetrics;
|
||||||
|
export const __getPeakGlowStateForTest = getPeakGlowState;
|
||||||
export const __shouldPollLiveChartForTest = shouldPollLiveChart;
|
export const __shouldPollLiveChartForTest = shouldPollLiveChart;
|
||||||
export const __mergePatchIntoHourlyForTest = mergePatchIntoHourly;
|
export const __mergePatchIntoHourlyForTest = mergePatchIntoHourly;
|
||||||
|
|||||||
+99
@@ -4,6 +4,7 @@ import {
|
|||||||
__getDebPeakWindowRangeForTest,
|
__getDebPeakWindowRangeForTest,
|
||||||
__getLiveObservationLabelsForTest,
|
__getLiveObservationLabelsForTest,
|
||||||
__getObservationDisplayMetricsForTest,
|
__getObservationDisplayMetricsForTest,
|
||||||
|
__getPeakGlowStateForTest,
|
||||||
__getVisibleTemperatureSeriesForTest,
|
__getVisibleTemperatureSeriesForTest,
|
||||||
__isTemperatureSeriesVisibleByDefaultForTest,
|
__isTemperatureSeriesVisibleByDefaultForTest,
|
||||||
__mergePatchIntoHourlyForTest,
|
__mergePatchIntoHourlyForTest,
|
||||||
@@ -22,6 +23,69 @@ function runwayKey(rwy: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function runTests() {
|
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 = {
|
const guangzhou = {
|
||||||
city: "guangzhou",
|
city: "guangzhou",
|
||||||
local_date: "2026-05-25",
|
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.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");
|
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(
|
const newYorkMinuteStream = __buildTemperatureChartDataForTest(
|
||||||
{
|
{
|
||||||
city: "new york",
|
city: "new york",
|
||||||
|
|||||||
@@ -41,4 +41,9 @@ export function runTests() {
|
|||||||
selectorSource.includes("grid grid-cols-3"),
|
selectorSource.includes("grid grid-cols-3"),
|
||||||
"grid selector must expose at most a 3 by 3 chart layout",
|
"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>;
|
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 = {
|
type RunwayHistorySeries = {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -435,6 +449,36 @@ function normObs(
|
|||||||
.slice(-limit);
|
.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>) {
|
function seriesStats(values: Array<number | null>) {
|
||||||
const nums = values.filter((v): v is number => validNumber(v) !== null);
|
const nums = values.filter((v): v is number => validNumber(v) !== null);
|
||||||
const latest = nums.length ? nums[nums.length - 1] : null;
|
const latest = nums.length ? nums[nums.length - 1] : null;
|
||||||
@@ -479,7 +523,12 @@ function getObservationDisplayMetrics(
|
|||||||
const localDateStr = resolveChartLocalDate(row, hourly);
|
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 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 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 latestSettlement = latestObservationValue(settlementObs);
|
||||||
const latestMetar = latestObservationValue(metarObs);
|
const latestMetar = latestObservationValue(metarObs);
|
||||||
const latestMadis = latestObservationValue(madisObs);
|
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),
|
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,
|
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(
|
const runwayHistorySeries = filterRunwayHistoryToLocalDay(
|
||||||
buildRunwayHistorySeries(row, hourly, tzOffset, localDateStr),
|
buildRunwayHistorySeries(row, hourly, tzOffset, localDateStr),
|
||||||
localDayBounds,
|
localDayBounds,
|
||||||
@@ -1535,6 +1592,163 @@ function latestLiveObservationTimestamp(
|
|||||||
return latest;
|
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(
|
function getDebPeakWindowRange(
|
||||||
data: Array<Record<string, any>>,
|
data: Array<Record<string, any>>,
|
||||||
series: EvidenceSeries[],
|
series: EvidenceSeries[],
|
||||||
@@ -1645,6 +1859,7 @@ export {
|
|||||||
buildChartDomain,
|
buildChartDomain,
|
||||||
buildFullDayChartData,
|
buildFullDayChartData,
|
||||||
getDebPeakWindowRange,
|
getDebPeakWindowRange,
|
||||||
|
getPeakGlowState,
|
||||||
buildIntDegreeTicks,
|
buildIntDegreeTicks,
|
||||||
buildModelSummaryCards,
|
buildModelSummaryCards,
|
||||||
buildRunwayPlates,
|
buildRunwayPlates,
|
||||||
@@ -1664,4 +1879,4 @@ export {
|
|||||||
validNumber,
|
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.database.db_manager import DBManager
|
||||||
from src.utils.telegram_chat_ids import get_telegram_chat_ids_from_env
|
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:
|
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)
|
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:
|
def _safe_week_key(dt: datetime) -> str:
|
||||||
iso_year, iso_week, _ = dt.isocalendar()
|
iso_year, iso_week, _ = dt.isocalendar()
|
||||||
return f"{iso_year}-W{iso_week:02d}"
|
return f"{iso_year}-W{iso_week:02d}"
|
||||||
@@ -177,10 +186,19 @@ def _render_settle_report(
|
|||||||
skipped: int,
|
skipped: int,
|
||||||
participation_count: int = 0,
|
participation_count: int = 0,
|
||||||
active_count: int = 0,
|
active_count: int = 0,
|
||||||
|
language: Optional[str] = None,
|
||||||
) -> str:
|
) -> 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:
|
if not winners:
|
||||||
lines.append("本周无上榜用户。")
|
lines.append(copy_text(language, "No ranked users this week.", "本周无上榜用户。"))
|
||||||
else:
|
else:
|
||||||
medals = {1: "🥇", 2: "🥈", 3: "🥉"}
|
medals = {1: "🥇", 2: "🥈", 3: "🥉"}
|
||||||
for row in winners:
|
for row in winners:
|
||||||
@@ -188,16 +206,39 @@ def _render_settle_report(
|
|||||||
name = str(row.get("username") or "unknown")[:16]
|
name = str(row.get("username") or "unknown")[:16]
|
||||||
points_bonus = int(row.get("points_bonus") or 0)
|
points_bonus = int(row.get("points_bonus") or 0)
|
||||||
pro_days = int(row.get("pro_days") 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}.")
|
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:
|
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:
|
if participation_count > 0 or active_count > 0:
|
||||||
lines.append("────────────────────")
|
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:
|
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)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ except Exception:
|
|||||||
|
|
||||||
from src.data_collection.city_registry import CITY_REGISTRY
|
from src.data_collection.city_registry import CITY_REGISTRY
|
||||||
from src.data_collection.weather_sources import WeatherDataCollector
|
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] = [
|
TARGET_CITIES: List[str] = [
|
||||||
"beijing",
|
"beijing",
|
||||||
@@ -73,6 +74,14 @@ def _env_int(name: str, default: int, min_val: int = 0) -> int:
|
|||||||
return default
|
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]]:
|
def _fetch_cma_forecast(city_key: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Scrape today's forecast from weather.com.cn (CMA)."""
|
"""Scrape today's forecast from weather.com.cn (CMA)."""
|
||||||
code = CMA_CITY_CODES.get(city_key)
|
code = CMA_CITY_CODES.get(city_key)
|
||||||
@@ -158,13 +167,14 @@ def _fetch_city_data(
|
|||||||
collector: WeatherDataCollector, city_key: str
|
collector: WeatherDataCollector, city_key: str
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
name = CITY_NAME_ZH.get(city_key, city_key)
|
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
|
# 1. Try CMA first for weather description
|
||||||
cma = _fetch_cma_forecast(city_key)
|
cma = _fetch_cma_forecast(city_key)
|
||||||
|
|
||||||
# 2. Get Open-Meteo data for temperature fallback
|
# 2. Get Open-Meteo data for temperature fallback
|
||||||
om_high = None
|
om_high = None
|
||||||
info = CITY_REGISTRY.get(city_key)
|
|
||||||
if info:
|
if info:
|
||||||
try:
|
try:
|
||||||
results = collector.fetch_all_sources(
|
results = collector.fetch_all_sources(
|
||||||
@@ -209,6 +219,7 @@ def _fetch_city_data(
|
|||||||
return {
|
return {
|
||||||
"city": city_key,
|
"city": city_key,
|
||||||
"name": name,
|
"name": name,
|
||||||
|
"name_en": name_en,
|
||||||
"weather": weather or "?",
|
"weather": weather or "?",
|
||||||
"forecast_high": forecast_high,
|
"forecast_high": forecast_high,
|
||||||
}
|
}
|
||||||
@@ -235,12 +246,40 @@ def _wmo_to_weather(code: Any) -> str:
|
|||||||
return "阴"
|
return "阴"
|
||||||
|
|
||||||
|
|
||||||
def _build_ai_prompt(cities_data: List[Dict[str, Any]], report_date: str) -> str:
|
def _build_ai_prompt(
|
||||||
lines = [f"今天是 {report_date}。请用自然亲切的中文写一段天气日报。\n"]
|
cities_data: List[Dict[str, Any]],
|
||||||
lines.append("城市天气数据:")
|
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:
|
for c in cities_data:
|
||||||
lines.append(f"{c['name']}:{c['weather']},最高{c['forecast_high']}度")
|
lines.append(
|
||||||
lines.append("\n要求:每城一行播报,城市名<b>加粗</b>,开头问候,禁止结尾废话。")
|
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)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
@@ -352,8 +391,9 @@ def _runner(bot: Any, config: Dict[str, Any]) -> None:
|
|||||||
time.sleep(60)
|
time.sleep(60)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
language = _daily_report_language()
|
||||||
report_date = now.strftime("%m月%d日")
|
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)
|
report_text = _call_ai(prompt)
|
||||||
|
|
||||||
if not report_text:
|
if not report_text:
|
||||||
@@ -363,7 +403,11 @@ def _runner(bot: Any, config: Dict[str, Any]) -> None:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
report_text += "\n\n⚠️ 以上为粗略预测,仅供参考。"
|
report_text += "\n\n" + copy_text(
|
||||||
|
language,
|
||||||
|
"⚠️ Rough forecast only. For reference.",
|
||||||
|
"⚠️ 以上为粗略预测,仅供参考。",
|
||||||
|
)
|
||||||
bot.send_message(
|
bot.send_message(
|
||||||
FORUM_CHAT_ID,
|
FORUM_CHAT_ID,
|
||||||
report_text,
|
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.data_collection.city_registry import CITY_REGISTRY
|
||||||
from src.utils.telegram_chat_ids import get_telegram_chat_ids_from_env
|
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.
|
# 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.
|
# 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
|
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]:
|
def _norm_prob(v: Any) -> Optional[float]:
|
||||||
if v is None:
|
if v is None:
|
||||||
return 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
|
# Legacy alias for backward compat with existing _select_focus_runway_obs / _focus_runway_pairs_for_city
|
||||||
FOCUS_RUNWAY_PAIRS = SETTLEMENT_RUNWAY_PAIRS
|
FOCUS_RUNWAY_PAIRS = SETTLEMENT_RUNWAY_PAIRS
|
||||||
|
|
||||||
_FUNCTION_HASHTAGS = {
|
_FUNCTION_HASHTAGS_ZH = {
|
||||||
"runway": "#跑道观测",
|
"runway": "#跑道观测",
|
||||||
"airport": "#机场观测",
|
"airport": "#机场观测",
|
||||||
"trade": "#交易机会",
|
"trade": "#交易机会",
|
||||||
}
|
}
|
||||||
|
_FUNCTION_HASHTAGS_EN = {
|
||||||
|
"runway": "#RunwayObs",
|
||||||
|
"airport": "#AirportObs",
|
||||||
|
"trade": "#TradeAlert",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _city_hashtag(city: Optional[str]) -> Optional[str]:
|
def _city_hashtag(city: Optional[str]) -> Optional[str]:
|
||||||
@@ -659,15 +679,27 @@ def _build_telegram_hashtag_line(
|
|||||||
city: Optional[str] = None,
|
city: Optional[str] = None,
|
||||||
station: Optional[str] = None,
|
station: Optional[str] = None,
|
||||||
extra: Optional[List[str]] = None,
|
extra: Optional[List[str]] = None,
|
||||||
|
language: Optional[str] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
tags: List[str] = []
|
tags: List[str] = []
|
||||||
primary = _FUNCTION_HASHTAGS.get(kind)
|
tag_maps = (
|
||||||
if primary:
|
(_FUNCTION_HASHTAGS_EN, _FUNCTION_HASHTAGS_ZH)
|
||||||
tags.append(primary)
|
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 []:
|
for item in extra or []:
|
||||||
tag = _FUNCTION_HASHTAGS.get(item, item)
|
added = False
|
||||||
if tag and tag not in tags:
|
for tag_map in tag_maps:
|
||||||
tags.append(tag)
|
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)
|
city_tag = _city_hashtag(city)
|
||||||
if city_tag and city_tag not in tags:
|
if city_tag and city_tag not in tags:
|
||||||
tags.append(city_tag)
|
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
|
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."""
|
"""Classify wind direction into a thermal regime label."""
|
||||||
if wind_dir is None:
|
if wind_dir is None:
|
||||||
return None
|
return None
|
||||||
@@ -760,9 +792,9 @@ def _wind_regime_label(city: str, wind_dir: Optional[int]) -> Optional[str]:
|
|||||||
sea = regimes.get("sea_breeze")
|
sea = regimes.get("sea_breeze")
|
||||||
warm = regimes.get("warm_advection")
|
warm = regimes.get("warm_advection")
|
||||||
if sea and sea[0] != sea[1] and sea[0] <= wind_dir <= sea[1]:
|
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]:
|
if warm and warm[0] != warm[1] and warm[0] <= wind_dir <= warm[1]:
|
||||||
return "暖平流增强"
|
return _copy(language, "warm advection", "暖平流增强")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -789,18 +821,21 @@ def _runway_heat_signal(
|
|||||||
slope_15m: Optional[float],
|
slope_15m: Optional[float],
|
||||||
wind_dir: Optional[int],
|
wind_dir: Optional[int],
|
||||||
city: str,
|
city: str,
|
||||||
|
language: Optional[str] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Compute a simple runway heat signal label."""
|
"""Compute a simple runway heat signal label."""
|
||||||
if slope_15m is None:
|
if slope_15m is None:
|
||||||
return ""
|
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:
|
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:
|
if slope_15m >= 0.5:
|
||||||
return "🔥 升温中"
|
return _copy(language, "🔥 Warming", "🔥 升温中")
|
||||||
if slope_15m >= -0.2:
|
if slope_15m >= -0.2:
|
||||||
return "⚠️ 高位观察" if regime == "海风降温" else "⏸️ 高位横盘"
|
return _copy(language, "⚠️ Watch sea-breeze cooling", "⚠️ 高位观察") if regime == sea_regime else _copy(language, "⏸️ Holding near high", "⏸️ 高位横盘")
|
||||||
return "🧊 过峰风险"
|
return _copy(language, "🧊 Peak-risk cooling", "🧊 过峰风险")
|
||||||
|
|
||||||
|
|
||||||
def _focused_runway_max(city: str, city_weather: Dict[str, Any]) -> Optional[float]:
|
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 = "",
|
source_label: str = "",
|
||||||
arome_temp: Optional[float] = None,
|
arome_temp: Optional[float] = None,
|
||||||
aeroweb_available: bool = False,
|
aeroweb_available: bool = False,
|
||||||
|
language: Optional[str] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
|
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",
|
"istanbul": "Airport", "paris": "Le Bourget",
|
||||||
@@ -1018,8 +1055,8 @@ def _build_airport_status_message(
|
|||||||
# ── Heat model ──
|
# ── Heat model ──
|
||||||
wind_dir = amos.get("wind_dir") if is_amsc else None
|
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
|
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 ""
|
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) if is_amsc and wind_dir is not None else None
|
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)
|
max_so_far, max_temp_time = _get_airport_daily_high(city_weather)
|
||||||
# ── Build message ──
|
# ── Build message ──
|
||||||
@@ -1029,6 +1066,7 @@ def _build_airport_status_message(
|
|||||||
hashtag_line = _build_telegram_hashtag_line(
|
hashtag_line = _build_telegram_hashtag_line(
|
||||||
"runway" if has_runway else "airport",
|
"runway" if has_runway else "airport",
|
||||||
city=city,
|
city=city,
|
||||||
|
language=language,
|
||||||
)
|
)
|
||||||
icao_display = f"{amos_icao} · " if amos_icao else ""
|
icao_display = f"{amos_icao} · " if amos_icao else ""
|
||||||
settlement_str = f" · ★{settlement_pair[0]}/{settlement_pair[1]}" if settlement_pair 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")
|
mid = pts.get("mid_temp")
|
||||||
end = pts.get("end_temp")
|
end = pts.get("end_temp")
|
||||||
is_settlement = _is_settlement_runway(city, r1, r2)
|
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")
|
tmax = pts.get("target_runway_max")
|
||||||
if tdz is not None or mid is not None or end is not None:
|
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)}"
|
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}"
|
line += f" max:{tmax:.1f}"
|
||||||
lines.append(line)
|
lines.append(line)
|
||||||
else:
|
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
|
# Summary stats
|
||||||
lines.append("")
|
lines.append("")
|
||||||
@@ -1072,36 +1111,43 @@ def _build_airport_status_message(
|
|||||||
|
|
||||||
if city == "paris":
|
if city == "paris":
|
||||||
if aeroweb_available and display_temp is not None:
|
if aeroweb_available and display_temp is not None:
|
||||||
lines.append(f"当前实况:{cur_str}")
|
lines.append(f"{_copy(language, 'Current observation', '当前实况')}: {cur_str}")
|
||||||
else:
|
else:
|
||||||
lines.append("当前实况:暂无")
|
lines.append(_copy(language, "Current observation: unavailable", "当前实况:暂无"))
|
||||||
else:
|
else:
|
||||||
if has_runway:
|
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:
|
else:
|
||||||
lines.append(f"当前:{cur_str}")
|
lines.append(f"{_copy(language, 'Current', '当前')}: {cur_str}")
|
||||||
|
|
||||||
if city == "paris":
|
if city == "paris":
|
||||||
if aeroweb_available and max_so_far is not None:
|
if aeroweb_available and 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 "")
|
||||||
lines.append(f"日高实况:{max_so_far:.1f}{temp_symbol}{time_str}")
|
lines.append(f"{_copy(language, 'Observed high', '日高实况')}: {max_so_far:.1f}{temp_symbol}{time_str}")
|
||||||
elif not aeroweb_available:
|
elif not aeroweb_available:
|
||||||
last_temp = _LAST_AEROWEB.get("temp")
|
last_temp = _LAST_AEROWEB.get("temp")
|
||||||
if last_temp is not None:
|
if last_temp is not None:
|
||||||
last_time = _LAST_AEROWEB.get("max_time", "")
|
last_time = _LAST_AEROWEB.get("max_time", "")
|
||||||
time_str = f"({last_time})" if last_time else ""
|
time_str = f" ({last_time})" if last_time and not _is_zh(language) else (f"({last_time})" if last_time else "")
|
||||||
lines.append(f"最近实况:{last_temp:.1f}{temp_symbol}{time_str}")
|
lines.append(f"{_copy(language, 'Latest observation', '最近实况')}: {last_temp:.1f}{temp_symbol}{time_str}")
|
||||||
elif max_so_far is not None:
|
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:
|
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:
|
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:
|
if slope_15m is not None:
|
||||||
sign = "+" if slope_15m >= 0 else ""
|
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:
|
if wind_dir is not None:
|
||||||
wind_str = f"风向:{wind_dir}°"
|
wind_str = f"{_copy(language, 'Wind dir', '风向')}: {wind_dir}°"
|
||||||
if wind_label:
|
if wind_label:
|
||||||
wind_str += f" {wind_label}"
|
wind_str += f" {wind_label}"
|
||||||
lines.append(wind_str)
|
lines.append(wind_str)
|
||||||
@@ -1127,7 +1173,7 @@ def _build_airport_status_message(
|
|||||||
bj_h = hh + 8
|
bj_h = hh + 8
|
||||||
if bj_h >= 24:
|
if bj_h >= 24:
|
||||||
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
|
break
|
||||||
if metar_temp or metar_time:
|
if metar_temp or metar_time:
|
||||||
bits = []
|
bits = []
|
||||||
@@ -1135,46 +1181,54 @@ def _build_airport_status_message(
|
|||||||
bits.append(f"{metar_temp}{temp_symbol}")
|
bits.append(f"{metar_temp}{temp_symbol}")
|
||||||
if metar_time:
|
if metar_time:
|
||||||
bits.append(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 deb_pred is not None:
|
||||||
if city == "paris":
|
if city == "paris":
|
||||||
if aeroweb_available and display_temp is not None:
|
if aeroweb_available and display_temp is not None:
|
||||||
diff = display_temp - deb_pred
|
diff = display_temp - deb_pred
|
||||||
sign = "+" if diff >= 0 else ""
|
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:
|
elif not aeroweb_available:
|
||||||
last_temp = _LAST_AEROWEB.get("temp")
|
last_temp = _LAST_AEROWEB.get("temp")
|
||||||
if last_temp is not None:
|
if last_temp is not None:
|
||||||
diff = last_temp - deb_pred
|
diff = last_temp - deb_pred
|
||||||
sign = "+" if diff >= 0 else ""
|
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:
|
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:
|
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:
|
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:
|
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":
|
if city == "paris":
|
||||||
lines.append("")
|
lines.append("")
|
||||||
if aeroweb_available and display_temp is not None:
|
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:
|
else:
|
||||||
lines.append("📡 AEROWEB 机场实况:暂不可用")
|
lines.append(_copy(language, "📡 AEROWEB airport obs: unavailable", "📡 AEROWEB 机场实况:暂不可用"))
|
||||||
if arome_temp is not None:
|
if arome_temp is not None:
|
||||||
if aeroweb_available and display_temp is not None:
|
if aeroweb_available and display_temp is not None:
|
||||||
d = arome_temp - display_temp
|
d = arome_temp - display_temp
|
||||||
sign = "+" if d >= 0 else ""
|
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:
|
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:
|
else:
|
||||||
lines.append("🕐 AROME HD 15分钟临近预报:--")
|
label = _copy(language, "AROME HD 15m nowcast", "AROME HD 15分钟临近预报")
|
||||||
|
lines.append(f"🕐 {label}: --")
|
||||||
if not aeroweb_available:
|
if not aeroweb_available:
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("提示:当前仅显示模型参考,不更新实况日高。")
|
lines.append(_copy(language, "Note: showing model reference only; observed high is not updating.", "提示:当前仅显示模型参考,不更新实况日高。"))
|
||||||
elif source_label:
|
elif source_label:
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append(f"📡 {source_label}")
|
lines.append(f"📡 {source_label}")
|
||||||
@@ -1186,8 +1240,14 @@ def _build_airport_status_message(
|
|||||||
if len(vals) >= 2:
|
if len(vals) >= 2:
|
||||||
lo, hi = vals[0][0], vals[-1][0]
|
lo, hi = vals[0][0], vals[-1][0]
|
||||||
spread = hi - lo
|
spread = hi - lo
|
||||||
spread_label = "低分歧" if spread <= 2.0 else ("中等分歧" if spread <= 4.0 else "高分歧")
|
if spread <= 2.0:
|
||||||
lines.append(f"模型区间:{lo:.1f}~{hi:.1f}{temp_symbol} {spread_label}")
|
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)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,16 @@ from src.utils.telegram_push import (
|
|||||||
HIGH_FREQ_AIRPORT_ICAO,
|
HIGH_FREQ_AIRPORT_ICAO,
|
||||||
_build_airport_status_message,
|
_build_airport_status_message,
|
||||||
_run_high_freq_airport_cycle,
|
_run_high_freq_airport_cycle,
|
||||||
|
_telegram_push_language,
|
||||||
)
|
)
|
||||||
from pathlib import Path
|
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(
|
text = _build_airport_status_message(
|
||||||
"qingdao",
|
"qingdao",
|
||||||
{
|
{
|
||||||
@@ -32,10 +37,13 @@ def test_airport_status_message_starts_with_runway_city_and_station_hashtags():
|
|||||||
)
|
)
|
||||||
|
|
||||||
first_line = text.splitlines()[0]
|
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 "Qingdao / Jiaodong" in text
|
||||||
assert "TDZ:23.0" 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():
|
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 "02L/20R" in text
|
||||||
assert "02R/20L" 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():
|
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