feat: Introduce PolyWeather web application with multi-source weather data collection from various APIs.
This commit is contained in:
@@ -490,7 +490,7 @@ class WeatherDataCollector:
|
|||||||
"station_name": latest.get("istasyonAd")
|
"station_name": latest.get("istasyonAd")
|
||||||
or latest.get("adi")
|
or latest.get("adi")
|
||||||
or latest.get("merkezAd")
|
or latest.get("merkezAd")
|
||||||
or "Ankara Esenboğa",
|
or "Ankara Bölge",
|
||||||
}
|
}
|
||||||
|
|
||||||
# 2. 每日预报(尝试两个可能的 API 路径)
|
# 2. 每日预报(尝试两个可能的 API 路径)
|
||||||
@@ -535,6 +535,27 @@ class WeatherDataCollector:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"MGM forecast URL {forecast_url} failed: {e}")
|
logger.debug(f"MGM forecast URL {forecast_url} failed: {e}")
|
||||||
|
|
||||||
|
# 3. 小时预报
|
||||||
|
try:
|
||||||
|
hourly_resp = self.session.get(
|
||||||
|
f"{base_url}/tahminler/saatlik?istno={istno}",
|
||||||
|
headers=headers,
|
||||||
|
timeout=self.timeout
|
||||||
|
)
|
||||||
|
if hourly_resp.status_code == 200:
|
||||||
|
h_data = hourly_resp.json()
|
||||||
|
if h_data and isinstance(h_data, list):
|
||||||
|
tahmin_list = h_data[0].get("tahmin", [])
|
||||||
|
results["hourly"] = []
|
||||||
|
for t_data in tahmin_list:
|
||||||
|
if "tarih" in t_data and "sicaklik" in t_data:
|
||||||
|
results["hourly"].append({
|
||||||
|
"time": t_data["tarih"],
|
||||||
|
"temp": t_data["sicaklik"]
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"MGM hourly failed: {e}")
|
||||||
|
|
||||||
return results if "current" in results else None
|
return results if "current" in results else None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"MGM API 请求失败 ({istno}): {e}")
|
logger.error(f"MGM API 请求失败 ({istno}): {e}")
|
||||||
@@ -1168,9 +1189,9 @@ class WeatherDataCollector:
|
|||||||
if metar_data:
|
if metar_data:
|
||||||
results["metar"] = metar_data
|
results["metar"] = metar_data
|
||||||
|
|
||||||
# 对安卡拉,额外获取 MGM 官方数据
|
# 对安卡拉,额外获取 MGM 官方数据 (17130 为 Ankara Bölge 市区测站)
|
||||||
if city_lower == "ankara":
|
if city_lower == "ankara":
|
||||||
mgm_data = self.fetch_from_mgm("17128")
|
mgm_data = self.fetch_from_mgm("17130")
|
||||||
if mgm_data:
|
if mgm_data:
|
||||||
results["mgm"] = mgm_data
|
results["mgm"] = mgm_data
|
||||||
|
|
||||||
|
|||||||
+26
-1
@@ -353,9 +353,18 @@ def _analyze(city: str) -> Dict[str, Any]:
|
|||||||
mgm_data = {}
|
mgm_data = {}
|
||||||
if mgm:
|
if mgm:
|
||||||
mgc = mgm.get("current", {})
|
mgc = mgm.get("current", {})
|
||||||
|
mgm_time_str = mgc.get("time", "")
|
||||||
|
if mgm_time_str and "T" in mgm_time_str:
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(mgm_time_str.replace("Z", "+00:00"))
|
||||||
|
local_dt = dt.astimezone(timezone(timedelta(seconds=utc_offset)))
|
||||||
|
mgm_time_str = local_dt.strftime("%H:%M")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
mgm_data = {
|
mgm_data = {
|
||||||
"temp": _sf(mgc.get("temp")),
|
"temp": _sf(mgc.get("temp")),
|
||||||
"time": mgc.get("time"),
|
"time": mgm_time_str,
|
||||||
"feels_like": _sf(mgc.get("feels_like")),
|
"feels_like": _sf(mgc.get("feels_like")),
|
||||||
"humidity": _sf(mgc.get("humidity")),
|
"humidity": _sf(mgc.get("humidity")),
|
||||||
"wind_dir": _sf(mgc.get("wind_dir")),
|
"wind_dir": _sf(mgc.get("wind_dir")),
|
||||||
@@ -363,8 +372,24 @@ def _analyze(city: str) -> Dict[str, Any]:
|
|||||||
"pressure": _sf(mgc.get("pressure")),
|
"pressure": _sf(mgc.get("pressure")),
|
||||||
"cloud_cover": mgc.get("cloud_cover"),
|
"cloud_cover": mgc.get("cloud_cover"),
|
||||||
"rain_24h": _sf(mgc.get("rain_24h")),
|
"rain_24h": _sf(mgc.get("rain_24h")),
|
||||||
|
"hourly": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mgm_hourly = mgm.get("hourly", [])
|
||||||
|
for h in mgm_hourly:
|
||||||
|
dt_str = h.get("time")
|
||||||
|
val = _sf(h.get("temp"))
|
||||||
|
if dt_str and "T" in dt_str and val is not None:
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
|
||||||
|
local_dt = dt.astimezone(timezone(timedelta(seconds=utc_offset)))
|
||||||
|
mgm_data["hourly"].append({
|
||||||
|
"time": local_dt.strftime("%Y-%m-%dT%H:%M"),
|
||||||
|
"temp": val
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ── 15. Extended Multi-Model Daily ──
|
# ── 15. Extended Multi-Model Daily ──
|
||||||
multi_model_daily = {}
|
multi_model_daily = {}
|
||||||
|
|||||||
+72
-22
@@ -538,10 +538,33 @@ function renderChart(data) {
|
|||||||
const ctx = document.getElementById("tempChart").getContext("2d");
|
const ctx = document.getElementById("tempChart").getContext("2d");
|
||||||
if (tempChart) tempChart.destroy();
|
if (tempChart) tempChart.destroy();
|
||||||
|
|
||||||
|
// MGM Hourly Forecast (Ankara specific)
|
||||||
|
const mgmHourlyPoints = new Array(times.length).fill(null);
|
||||||
|
let hasMgmHourly = false;
|
||||||
|
if (data.mgm?.hourly?.length > 0) {
|
||||||
|
data.mgm.hourly.forEach((hData) => {
|
||||||
|
const match = hData.time.match(/T?(\d{2}):(\d{2})/);
|
||||||
|
if (match) {
|
||||||
|
const hourKey = match[1] + ":00";
|
||||||
|
const idx = times.indexOf(hourKey);
|
||||||
|
if (idx >= 0) {
|
||||||
|
mgmHourlyPoints[idx] = hData.temp;
|
||||||
|
hasMgmHourly = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const validDebTemps = debTemps.filter((t) => t != null);
|
const validDebTemps = debTemps.filter((t) => t != null);
|
||||||
const validMetar = metarPoints.filter((t) => t != null);
|
const validMetar = metarPoints.filter((t) => t != null);
|
||||||
const validMgm = mgmPoints.filter((t) => t != null);
|
const validMgm = mgmPoints.filter((t) => t != null);
|
||||||
const allVals = [...validDebTemps, ...validMetar, ...validMgm];
|
const validMgmHourly = mgmHourlyPoints.filter((t) => t != null);
|
||||||
|
const allVals = [
|
||||||
|
...validDebTemps,
|
||||||
|
...validMetar,
|
||||||
|
...validMgm,
|
||||||
|
...validMgmHourly,
|
||||||
|
];
|
||||||
if (allVals.length === 0) {
|
if (allVals.length === 0) {
|
||||||
document.getElementById("chartLegend").textContent = "暂无数据";
|
document.getElementById("chartLegend").textContent = "暂无数据";
|
||||||
return;
|
return;
|
||||||
@@ -550,8 +573,25 @@ function renderChart(data) {
|
|||||||
const maxTemp = Math.ceil(Math.max(...allVals)) + 1;
|
const maxTemp = Math.ceil(Math.max(...allVals)) + 1;
|
||||||
|
|
||||||
// Build datasets
|
// Build datasets
|
||||||
const datasets = [
|
const datasets = [];
|
||||||
{
|
|
||||||
|
if (hasMgmHourly) {
|
||||||
|
// If MGM is available, replace DEB curve with MGM hourly curve
|
||||||
|
datasets.push({
|
||||||
|
label: "MGM预报",
|
||||||
|
data: mgmHourlyPoints,
|
||||||
|
borderColor: "rgba(234, 179, 8, 0.8)", // Yellow
|
||||||
|
backgroundColor: "rgba(234, 179, 8, 0.05)",
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 3,
|
||||||
|
pointHoverRadius: 6,
|
||||||
|
fill: false,
|
||||||
|
tension: 0.3,
|
||||||
|
spanGaps: true, // Connect gaps because MGM is every 3 hours
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Standard DEB curves
|
||||||
|
datasets.push({
|
||||||
label: "DEB预期",
|
label: "DEB预期",
|
||||||
data: debPast,
|
data: debPast,
|
||||||
borderColor: "rgba(52, 211, 153, 0.6)",
|
borderColor: "rgba(52, 211, 153, 0.6)",
|
||||||
@@ -562,8 +602,8 @@ function renderChart(data) {
|
|||||||
fill: true,
|
fill: true,
|
||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
spanGaps: false,
|
spanGaps: false,
|
||||||
},
|
});
|
||||||
{
|
datasets.push({
|
||||||
label: "DEB预报",
|
label: "DEB预报",
|
||||||
data: debFuture,
|
data: debFuture,
|
||||||
borderColor: "rgba(52, 211, 153, 0.35)",
|
borderColor: "rgba(52, 211, 153, 0.35)",
|
||||||
@@ -573,20 +613,22 @@ function renderChart(data) {
|
|||||||
fill: false,
|
fill: false,
|
||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
spanGaps: false,
|
spanGaps: false,
|
||||||
},
|
});
|
||||||
{
|
}
|
||||||
label: "METAR实测",
|
|
||||||
data: metarPoints,
|
// Add METAR
|
||||||
borderColor: "#22d3ee",
|
datasets.push({
|
||||||
backgroundColor: "#22d3ee",
|
label: "METAR实测",
|
||||||
borderWidth: 0,
|
data: metarPoints,
|
||||||
pointRadius: 5,
|
borderColor: "#22d3ee",
|
||||||
pointHoverRadius: 7,
|
backgroundColor: "#22d3ee",
|
||||||
pointStyle: "circle",
|
borderWidth: 0,
|
||||||
fill: false,
|
pointRadius: 5,
|
||||||
order: 0,
|
pointHoverRadius: 7,
|
||||||
},
|
pointStyle: "circle",
|
||||||
];
|
fill: false,
|
||||||
|
order: 0,
|
||||||
|
});
|
||||||
|
|
||||||
if (validMgm.length > 0) {
|
if (validMgm.length > 0) {
|
||||||
datasets.push({
|
datasets.push({
|
||||||
@@ -604,8 +646,8 @@ function renderChart(data) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add subtle OM reference line if DEB offset is significant
|
// Add subtle OM reference line if DEB offset is significant, ONLY if we aren't replacing with MGM
|
||||||
if (Math.abs(offset) > 0.3) {
|
if (!hasMgmHourly && Math.abs(offset) > 0.3) {
|
||||||
datasets.push({
|
datasets.push({
|
||||||
label: "OM原始",
|
label: "OM原始",
|
||||||
data: temps,
|
data: temps,
|
||||||
@@ -695,10 +737,18 @@ function renderChart(data) {
|
|||||||
if (data.mgm?.temp != null) {
|
if (data.mgm?.temp != null) {
|
||||||
legendParts.push(`MGM: ${data.mgm.temp}${data.temp_symbol}`);
|
legendParts.push(`MGM: ${data.mgm.temp}${data.temp_symbol}`);
|
||||||
}
|
}
|
||||||
if (debMax != null && omMax != null && Math.abs(offset) > 0.3) {
|
if (
|
||||||
|
!hasMgmHourly &&
|
||||||
|
debMax != null &&
|
||||||
|
omMax != null &&
|
||||||
|
Math.abs(offset) > 0.3
|
||||||
|
) {
|
||||||
const sign = offset > 0 ? "+" : "";
|
const sign = offset > 0 ? "+" : "";
|
||||||
legendParts.push(`DEB偏移 ${sign}${offset.toFixed(1)}° vs OM`);
|
legendParts.push(`DEB偏移 ${sign}${offset.toFixed(1)}° vs OM`);
|
||||||
}
|
}
|
||||||
|
if (hasMgmHourly) {
|
||||||
|
legendParts.push(`已使用MGM高精预报替换DEB分析曲线`);
|
||||||
|
}
|
||||||
if (data.trend?.recent?.length) {
|
if (data.trend?.recent?.length) {
|
||||||
const recentStr = [...data.trend.recent]
|
const recentStr = [...data.trend.recent]
|
||||||
.slice(0, 4)
|
.slice(0, 4)
|
||||||
|
|||||||
Reference in New Issue
Block a user