feat: add weekly reward loop, i18n support, and scan terminal dashboard components

This commit is contained in:
2569718930@qq.com
2026-05-27 07:50:05 +08:00
parent 2670e2f8ee
commit 79d94bed5f
14 changed files with 786 additions and 76 deletions
+4
View File
@@ -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
+2
View File
@@ -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 密钥(阿姆斯特丹必填) | — |
## 未接入城市
+73
View File
@@ -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;
@@ -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 };
+48 -7
View File
@@ -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)
+52 -8
View File
@@ -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,
+41
View File
@@ -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
View File
@@ -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)
+12 -4
View File
@@ -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():
+59
View File
@@ -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