Add JMA Haneda temps and fix stale detail loading
This commit is contained in:
@@ -124,3 +124,5 @@
|
|||||||
{"city": "shanghai", "timestamp": "2026-03-29T15:00:00.000Z", "date": "2026-03-29", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.23736458333333335, "deb_prediction": 18.4, "ensemble": {"p10": 16.1, "median": 16.5, "p90": 16.9}, "multi_model": {"Open-Meteo": 17.0, "ECMWF": 19.2, "GFS": 19.1, "ICON": 17.0, "GEM": 17.6, "JMA": 15.8}, "max_so_far": 18.0, "observation": {"current_temp": 14.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": 3.11, "local_hour": 23.25}, "peak_status": "past", "prob_snapshot": [{"v": 18, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
{"city": "shanghai", "timestamp": "2026-03-29T15:00:00.000Z", "date": "2026-03-29", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.23736458333333335, "deb_prediction": 18.4, "ensemble": {"p10": 16.1, "median": 16.5, "p90": 16.9}, "multi_model": {"Open-Meteo": 17.0, "ECMWF": 19.2, "GFS": 19.1, "ICON": 17.0, "GEM": 17.6, "JMA": 15.8}, "max_so_far": 18.0, "observation": {"current_temp": 14.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": 3.11, "local_hour": 23.25}, "peak_status": "past", "prob_snapshot": [{"v": 18, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||||
{"city": "ankara", "timestamp": "2026-03-29T15:01:00.000Z", "date": "2026-03-29", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.25176666666666664, "deb_prediction": 9.7, "ensemble": {"p10": 9.2, "median": 9.5, "p90": 10.2}, "multi_model": {"Open-Meteo": 9.2, "ECMWF": 9.5, "GFS": 10.1, "ICON": 9.2, "GEM": 11.0, "JMA": 10.0}, "max_so_far": 10.0, "observation": {"current_temp": 7.0, "humidity": null, "wind_speed_kt": 12.0, "visibility_mi": null, "local_hour": 18.25}, "peak_status": "past", "prob_snapshot": [{"v": 10, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
{"city": "ankara", "timestamp": "2026-03-29T15:01:00.000Z", "date": "2026-03-29", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.25176666666666664, "deb_prediction": 9.7, "ensemble": {"p10": 9.2, "median": 9.5, "p90": 10.2}, "multi_model": {"Open-Meteo": 9.2, "ECMWF": 9.5, "GFS": 10.1, "ICON": 9.2, "GEM": 11.0, "JMA": 10.0}, "max_so_far": 10.0, "observation": {"current_temp": 7.0, "humidity": null, "wind_speed_kt": 12.0, "visibility_mi": null, "local_hour": 18.25}, "peak_status": "past", "prob_snapshot": [{"v": 10, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||||
{"city": "chengdu", "timestamp": "2026-04-08T07:00:00.000Z", "date": "2026-04-08", "temp_symbol": "°C", "raw_mu": 24.3, "raw_sigma": 1.1821289062499998, "deb_prediction": 23.1, "ensemble": {"p10": 21.8, "median": 23.0, "p90": 24.5}, "multi_model": {"Open-Meteo": 22.5, "ECMWF": 23.9, "GFS": 23.0, "ICON": 22.5, "GEM": 23.7, "JMA": 23.2}, "max_so_far": 24.0, "observation": {"current_temp": 24.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": null, "local_hour": 15.183333333333334}, "peak_status": "before", "prob_snapshot": [{"v": 24, "p": 0.442}, {"v": 25, "p": 0.386}, {"v": 26, "p": 0.172}], "shadow_prob_snapshot": [{"v": 24, "p": 0.394}, {"v": 25, "p": 0.355}, {"v": 26, "p": 0.19}, {"v": 27, "p": 0.06}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 24.3, "calibrated_sigma": 1.354078466151897}
|
{"city": "chengdu", "timestamp": "2026-04-08T07:00:00.000Z", "date": "2026-04-08", "temp_symbol": "°C", "raw_mu": 24.3, "raw_sigma": 1.1821289062499998, "deb_prediction": 23.1, "ensemble": {"p10": 21.8, "median": 23.0, "p90": 24.5}, "multi_model": {"Open-Meteo": 22.5, "ECMWF": 23.9, "GFS": 23.0, "ICON": 22.5, "GEM": 23.7, "JMA": 23.2}, "max_so_far": 24.0, "observation": {"current_temp": 24.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": null, "local_hour": 15.183333333333334}, "peak_status": "before", "prob_snapshot": [{"v": 24, "p": 0.442}, {"v": 25, "p": 0.386}, {"v": 26, "p": 0.172}], "shadow_prob_snapshot": [{"v": 24, "p": 0.394}, {"v": 25, "p": 0.355}, {"v": 26, "p": 0.19}, {"v": 27, "p": 0.06}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 24.3, "calibrated_sigma": 1.354078466151897}
|
||||||
|
{"city": "chengdu", "timestamp": "2026-04-08T08:00:00.000Z", "date": "2026-04-08", "temp_symbol": "°C", "raw_mu": 24.3, "raw_sigma": 0.7283767361111106, "deb_prediction": 23.1, "ensemble": {"p10": 21.8, "median": 22.9, "p90": 24.2}, "multi_model": {"Open-Meteo": 22.5, "ECMWF": 23.9, "GFS": 22.5, "ICON": 22.5, "GEM": 23.7, "JMA": 23.2}, "max_so_far": 24.0, "observation": {"current_temp": 23.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": null, "local_hour": 16.133333333333333}, "peak_status": "in_window", "prob_snapshot": [{"v": 24, "p": 0.547}, {"v": 25, "p": 0.397}, {"v": 26, "p": 0.056}], "shadow_prob_snapshot": [{"v": 24, "p": 0.521}, {"v": 25, "p": 0.399}, {"v": 26, "p": 0.08}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 24.3, "calibrated_sigma": 0.8140063140878262}
|
||||||
|
{"city": "tokyo", "timestamp": "2026-04-08T08:00:00.000Z", "date": "2026-04-08", "temp_symbol": "°C", "raw_mu": 17.810000000000002, "raw_sigma": 0.23200683593750038, "deb_prediction": 17.1, "ensemble": {"p10": 17.4, "median": 18.3, "p90": 19.1}, "multi_model": {"Open-Meteo": 16.1, "ECMWF": 16.3, "GFS": 17.6, "ICON": 18.2, "GEM": 18.3, "JMA": 16.1}, "max_so_far": 17.0, "observation": {"current_temp": 16.0, "humidity": null, "wind_speed_kt": 17.0, "visibility_mi": null, "local_hour": 17.133333333333333}, "peak_status": "past", "prob_snapshot": [{"v": 18, "p": 0.909}, {"v": 17, "p": 0.091}], "shadow_prob_snapshot": [{"v": 18, "p": 0.892}, {"v": 17, "p": 0.108}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 17.810000000000002, "calibrated_sigma": 0.25}
|
||||||
|
|||||||
@@ -961,6 +961,10 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.root :global(.forecast-inline-note) {
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
.root :global(.forecast-day) {
|
.root :global(.forecast-day) {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, 0.03);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
@@ -1000,6 +1004,31 @@
|
|||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.root :global(.forecast-table) {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
scroll-snap-type: x proximity;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.forecast-table::-webkit-scrollbar) {
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.forecast-table::-webkit-scrollbar-thumb) {
|
||||||
|
background: rgba(148, 163, 184, 0.32);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.forecast-day) {
|
||||||
|
flex: 0 0 112px;
|
||||||
|
min-width: 112px;
|
||||||
|
scroll-snap-align: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.root :global(.sun-info) {
|
.root :global(.sun-info) {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -724,6 +724,7 @@ export function ForecastTable() {
|
|||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
const daily = data.forecast?.daily || [];
|
const daily = data.forecast?.daily || [];
|
||||||
|
const isSparseDaily = daily.length <= 1;
|
||||||
const resolveForecastTemp = (date: string, fallback: number | null | undefined) => {
|
const resolveForecastTemp = (date: string, fallback: number | null | undefined) => {
|
||||||
const debPrediction = data.multi_model_daily?.[date]?.deb?.prediction;
|
const debPrediction = data.multi_model_daily?.[date]?.deb?.prediction;
|
||||||
return debPrediction ?? fallback ?? null;
|
return debPrediction ?? fallback ?? null;
|
||||||
@@ -731,6 +732,20 @@ export function ForecastTable() {
|
|||||||
return (
|
return (
|
||||||
<section className="forecast-section">
|
<section className="forecast-section">
|
||||||
<h3>{t("forecast.title")}</h3>
|
<h3>{t("forecast.title")}</h3>
|
||||||
|
{isSparseDaily && (
|
||||||
|
<div
|
||||||
|
className="forecast-inline-note"
|
||||||
|
style={{
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
fontSize: "12px",
|
||||||
|
marginBottom: "10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{store.loadingState.cityDetail
|
||||||
|
? "多日预报同步中,正在刷新完整日序列。"
|
||||||
|
: "当前只收到当日预报,其他日期结果暂未回传。"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="forecast-table">
|
<div className="forecast-table">
|
||||||
{daily.length === 0 ? (
|
{daily.length === 0 ? (
|
||||||
<EmptyState text={t("forecast.empty")} />
|
<EmptyState text={t("forecast.empty")} />
|
||||||
|
|||||||
@@ -77,7 +77,8 @@ function DashboardScreen() {
|
|||||||
// Avoid full-page flashing on initial load; only show this overlay for manual refresh.
|
// Avoid full-page flashing on initial load; only show this overlay for manual refresh.
|
||||||
const showLoading =
|
const showLoading =
|
||||||
store.loadingState.cities ||
|
store.loadingState.cities ||
|
||||||
store.loadingState.refresh;
|
store.loadingState.refresh ||
|
||||||
|
store.loadingState.cityDetail;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ function countAvailableModels(
|
|||||||
).length;
|
).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function countForecastDays(detail?: CityDetail | null): number {
|
||||||
|
const daily = detail?.forecast?.daily;
|
||||||
|
return Array.isArray(daily) ? daily.length : 0;
|
||||||
|
}
|
||||||
|
|
||||||
function hasSparseModelCoverage(
|
function hasSparseModelCoverage(
|
||||||
detail?: CityDetail | null,
|
detail?: CityDetail | null,
|
||||||
targetDate?: string | null,
|
targetDate?: string | null,
|
||||||
@@ -113,6 +118,16 @@ function hasSparseModelCoverage(
|
|||||||
return countAvailableModels(detail, targetDate) <= 1;
|
return countAvailableModels(detail, targetDate) <= 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasSparseDetailCoverage(
|
||||||
|
detail?: CityDetail | null,
|
||||||
|
targetDate?: string | null,
|
||||||
|
): boolean {
|
||||||
|
if (!detail) return true;
|
||||||
|
return (
|
||||||
|
hasSparseModelCoverage(detail, targetDate) || countForecastDays(detail) <= 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function DashboardStoreProvider({
|
export function DashboardStoreProvider({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
@@ -244,7 +259,13 @@ export function DashboardStoreProvider({
|
|||||||
const ensureCityDetail = async (cityName: string, force = false) => {
|
const ensureCityDetail = async (cityName: string, force = false) => {
|
||||||
const cached = cityDetailsByName[cityName];
|
const cached = cityDetailsByName[cityName];
|
||||||
const cachedMeta = cityDetailMetaByName[cityName];
|
const cachedMeta = cityDetailMetaByName[cityName];
|
||||||
if (!force && cached && dashboardClient.isCityDetailFresh(cachedMeta)) {
|
const cachedIsSparse = hasSparseDetailCoverage(cached, cached?.local_date);
|
||||||
|
if (
|
||||||
|
!force &&
|
||||||
|
cached &&
|
||||||
|
!cachedIsSparse &&
|
||||||
|
dashboardClient.isCityDetailFresh(cachedMeta)
|
||||||
|
) {
|
||||||
scheduleBackgroundDetailRefresh(cityName, cached, cachedMeta);
|
scheduleBackgroundDetailRefresh(cityName, cached, cachedMeta);
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
@@ -254,6 +275,28 @@ export function DashboardStoreProvider({
|
|||||||
const summary = await dashboardClient.getCitySummary(cityName);
|
const summary = await dashboardClient.getCitySummary(cityName);
|
||||||
const revision = getCityRevision(summary);
|
const revision = getCityRevision(summary);
|
||||||
if (revision && revision === cachedMeta?.revision) {
|
if (revision && revision === cachedMeta?.revision) {
|
||||||
|
if (cachedIsSparse) {
|
||||||
|
const latestDetail = await dashboardClient.getCityDetail(cityName, {
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
const detail = latestDetail;
|
||||||
|
setCityDetailsByName((current) => ({
|
||||||
|
...current,
|
||||||
|
[cityName]: detail,
|
||||||
|
}));
|
||||||
|
setCitySummariesByName((current) => ({
|
||||||
|
...current,
|
||||||
|
[cityName]: toCitySummary(detail),
|
||||||
|
}));
|
||||||
|
setCityDetailMetaByName((current) => ({
|
||||||
|
...current,
|
||||||
|
[cityName]: {
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
revision: getCityRevision(detail),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
setCityDetailMetaByName((current) => ({
|
setCityDetailMetaByName((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[cityName]: {
|
[cityName]: {
|
||||||
@@ -472,9 +515,14 @@ export function DashboardStoreProvider({
|
|||||||
await refreshProAccess();
|
await refreshProAccess();
|
||||||
}
|
}
|
||||||
const access = proAccessRef.current;
|
const access = proAccessRef.current;
|
||||||
|
const cachedDetail = cityDetailsByName[cityName];
|
||||||
|
const needsDetailRefresh = hasSparseDetailCoverage(
|
||||||
|
cachedDetail,
|
||||||
|
cachedDetail?.local_date,
|
||||||
|
);
|
||||||
setLoadingState((current) => ({ ...current, cityDetail: true }));
|
setLoadingState((current) => ({ ...current, cityDetail: true }));
|
||||||
try {
|
try {
|
||||||
const detail = await ensureCityDetail(cityName);
|
const detail = await ensureCityDetail(cityName, needsDetailRefresh);
|
||||||
setSelectedForecastDate(detail.local_date);
|
setSelectedForecastDate(detail.local_date);
|
||||||
if (access.authenticated && access.subscriptionActive) {
|
if (access.authenticated && access.subscriptionActive) {
|
||||||
// 预热市场数据,不做 await 阻塞,后台静默拉取
|
// 预热市场数据,不做 await 阻塞,后台静默拉取
|
||||||
@@ -621,9 +669,9 @@ export function DashboardStoreProvider({
|
|||||||
setFutureModalDate(dateStr);
|
setFutureModalDate(dateStr);
|
||||||
if (!selectedCity || !proAccess.subscriptionActive) return;
|
if (!selectedCity || !proAccess.subscriptionActive) return;
|
||||||
const cachedDetail = cityDetailsByName[selectedCity];
|
const cachedDetail = cityDetailsByName[selectedCity];
|
||||||
const needsModelRefresh =
|
const needsDetailRefresh =
|
||||||
!forceRefresh && hasSparseModelCoverage(cachedDetail, dateStr);
|
!forceRefresh && hasSparseDetailCoverage(cachedDetail, dateStr);
|
||||||
if (needsModelRefresh) {
|
if (needsDetailRefresh) {
|
||||||
void ensureCityDetail(selectedCity, true).catch(() => {});
|
void ensureCityDetail(selectedCity, true).catch(() => {});
|
||||||
}
|
}
|
||||||
const cacheKey = getMarketScanCacheKey(selectedCity, dateStr);
|
const cacheKey = getMarketScanCacheKey(selectedCity, dateStr);
|
||||||
@@ -652,8 +700,9 @@ export function DashboardStoreProvider({
|
|||||||
setFutureModalDate(cachedDetail.local_date);
|
setFutureModalDate(cachedDetail.local_date);
|
||||||
}
|
}
|
||||||
if (!proAccess.subscriptionActive) return;
|
if (!proAccess.subscriptionActive) return;
|
||||||
const needsModelRefresh =
|
const needsDetailRefresh =
|
||||||
!forceRefresh && hasSparseModelCoverage(cachedDetail, cachedDetail?.local_date);
|
!forceRefresh &&
|
||||||
|
hasSparseDetailCoverage(cachedDetail, cachedDetail?.local_date);
|
||||||
|
|
||||||
setLoadingState((current) => ({
|
setLoadingState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@@ -664,7 +713,7 @@ export function DashboardStoreProvider({
|
|||||||
try {
|
try {
|
||||||
const detail = await ensureCityDetail(
|
const detail = await ensureCityDetail(
|
||||||
selectedCity,
|
selectedCity,
|
||||||
Boolean(forceRefresh || needsModelRefresh),
|
Boolean(forceRefresh || needsDetailRefresh),
|
||||||
);
|
);
|
||||||
setSelectedForecastDate(detail.local_date);
|
setSelectedForecastDate(detail.local_date);
|
||||||
setFutureModalDate(detail.local_date);
|
setFutureModalDate(detail.local_date);
|
||||||
|
|||||||
@@ -342,9 +342,18 @@ export function useLeafletMap({
|
|||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
|
|
||||||
marker.on("click", () => {
|
marker.on("click", () => {
|
||||||
map.stop();
|
const currentMap = mapRef.current;
|
||||||
// Reset lastMovedCity so we can re-fly if needed
|
currentMap?.stop();
|
||||||
lastMovedCityRef.current = null;
|
if (currentMap && !suspendMotion) {
|
||||||
|
currentMap.flyTo([city.lat, city.lon], 11, {
|
||||||
|
animate: true,
|
||||||
|
duration: 1.05,
|
||||||
|
easeLinearity: 0.22,
|
||||||
|
});
|
||||||
|
lastMovedCityRef.current = city.name;
|
||||||
|
} else {
|
||||||
|
lastMovedCityRef.current = null;
|
||||||
|
}
|
||||||
onSelectCityRef.current(city.name);
|
onSelectCityRef.current(city.name);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ export function getCityRevision(source?: CityDetail | CitySummary | null) {
|
|||||||
? source.multi_model_daily?.[source.local_date || ""]
|
? source.multi_model_daily?.[source.local_date || ""]
|
||||||
: null;
|
: null;
|
||||||
const modelFootprint = modelDaily?.models || ("multi_model" in source ? source.multi_model : null);
|
const modelFootprint = modelDaily?.models || ("multi_model" in source ? source.multi_model : null);
|
||||||
|
const forecastFootprint =
|
||||||
|
"forecast" in source && Array.isArray(source.forecast?.daily)
|
||||||
|
? source.forecast.daily
|
||||||
|
.map((item) => `${normalizeRevisionPart(item?.date)}:${normalizeRevisionPart(item?.max_temp)}`)
|
||||||
|
.join("|")
|
||||||
|
: "";
|
||||||
return [
|
return [
|
||||||
normalizeRevisionPart(source.updated_at),
|
normalizeRevisionPart(source.updated_at),
|
||||||
normalizeRevisionPart(source.current?.obs_time),
|
normalizeRevisionPart(source.current?.obs_time),
|
||||||
@@ -70,6 +76,7 @@ export function getCityRevision(source?: CityDetail | CitySummary | null) {
|
|||||||
.join("|")
|
.join("|")
|
||||||
: "",
|
: "",
|
||||||
),
|
),
|
||||||
|
normalizeRevisionPart(forecastFootprint),
|
||||||
].join("|");
|
].join("|");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ CHINA_CMA_CITIES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _japan_jma_cities() -> set[str]:
|
||||||
|
return {"tokyo"}
|
||||||
|
|
||||||
|
|
||||||
def _safe_float(value: Any) -> Optional[float]:
|
def _safe_float(value: Any) -> Optional[float]:
|
||||||
try:
|
try:
|
||||||
if value is None or value == "":
|
if value is None or value == "":
|
||||||
@@ -39,6 +43,8 @@ def _provider_code_for_city(city: str) -> str:
|
|||||||
return "hongkong_hko"
|
return "hongkong_hko"
|
||||||
if settlement_source == "cwa":
|
if settlement_source == "cwa":
|
||||||
return "taiwan_cwa"
|
return "taiwan_cwa"
|
||||||
|
if normalized in _japan_jma_cities():
|
||||||
|
return "japan_jma"
|
||||||
if normalized in CHINA_CMA_CITIES:
|
if normalized in CHINA_CMA_CITIES:
|
||||||
return "china_cma"
|
return "china_cma"
|
||||||
return "global_metar"
|
return "global_metar"
|
||||||
@@ -175,6 +181,30 @@ def _nmc_rows(raw: Dict[str, Any], city: str) -> List[Dict[str, Any]]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _jma_rows(raw: Dict[str, Any], city: str) -> List[Dict[str, Any]]:
|
||||||
|
rows = raw.get("jma_official_nearby") or []
|
||||||
|
out: List[Dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
out.append(
|
||||||
|
_normalize_station_row(
|
||||||
|
station_code=row.get("icao") or row.get("istNo"),
|
||||||
|
station_label=row.get("name"),
|
||||||
|
temp=row.get("temp"),
|
||||||
|
lat=row.get("lat"),
|
||||||
|
lon=row.get("lon"),
|
||||||
|
obs_time=row.get("obs_time"),
|
||||||
|
source_code="jma",
|
||||||
|
source_label="JMA",
|
||||||
|
is_official=True,
|
||||||
|
is_airport_station=False,
|
||||||
|
is_settlement_anchor=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _mgm_rows(raw: Dict[str, Any], city: str) -> List[Dict[str, Any]]:
|
def _mgm_rows(raw: Dict[str, Any], city: str) -> List[Dict[str, Any]]:
|
||||||
meta = _city_meta(city)
|
meta = _city_meta(city)
|
||||||
rows = raw.get("mgm_nearby") or []
|
rows = raw.get("mgm_nearby") or []
|
||||||
@@ -393,6 +423,28 @@ class ChinaCmaNetworkProvider(CountryNetworkProvider):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class JapanJmaNetworkProvider(CountryNetworkProvider):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__("japan_jma", "JMA")
|
||||||
|
|
||||||
|
def official_nearby_current(self, city: str, raw: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||||
|
rows = _jma_rows(raw, city)
|
||||||
|
if rows:
|
||||||
|
return rows
|
||||||
|
return _metar_cluster_rows(raw)
|
||||||
|
|
||||||
|
def official_network_status(self, city: str, raw: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
rows = self.official_nearby_current(city, raw)
|
||||||
|
has_jma = bool(_jma_rows(raw, city))
|
||||||
|
return {
|
||||||
|
"provider_code": self.provider_code,
|
||||||
|
"provider_label": self.provider_label,
|
||||||
|
"available": has_jma,
|
||||||
|
"mode": "official_active" if has_jma else ("fallback_metar_cluster" if rows else "reference_only"),
|
||||||
|
"row_count": len(rows),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class HongKongHkoNetworkProvider(CountryNetworkProvider):
|
class HongKongHkoNetworkProvider(CountryNetworkProvider):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__("hongkong_hko", "HKO")
|
super().__init__("hongkong_hko", "HKO")
|
||||||
@@ -415,6 +467,8 @@ def get_country_network_provider(city: str) -> CountryNetworkProvider:
|
|||||||
provider_code = _provider_code_for_city(city)
|
provider_code = _provider_code_for_city(city)
|
||||||
if provider_code == "turkey_mgm":
|
if provider_code == "turkey_mgm":
|
||||||
return TurkeyMgmNetworkProvider()
|
return TurkeyMgmNetworkProvider()
|
||||||
|
if provider_code == "japan_jma":
|
||||||
|
return JapanJmaNetworkProvider()
|
||||||
if provider_code == "china_cma":
|
if provider_code == "china_cma":
|
||||||
return ChinaCmaNetworkProvider()
|
return ChinaCmaNetworkProvider()
|
||||||
if provider_code == "hongkong_hko":
|
if provider_code == "hongkong_hko":
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from src.utils.metrics import record_source_call
|
||||||
|
|
||||||
|
|
||||||
|
JMA_AMEDAS_STATIONS: Dict[str, Dict[str, Any]] = {
|
||||||
|
"tokyo": {
|
||||||
|
"station_code": "44166",
|
||||||
|
"station_label": "羽田 10分实况 (JMA)",
|
||||||
|
"lat": 35.5533,
|
||||||
|
"lon": 139.78,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class JmaAmedasSourceMixin:
|
||||||
|
def _jma_http_get_text(self, url: str) -> str:
|
||||||
|
getter = getattr(self, "_http_get", None)
|
||||||
|
if callable(getter):
|
||||||
|
response = getter(url)
|
||||||
|
else:
|
||||||
|
response = self.session.get(url, timeout=self.timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.text
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _jma_safe_float(value: Any) -> Optional[float]:
|
||||||
|
try:
|
||||||
|
if value in (None, "", "///"):
|
||||||
|
return None
|
||||||
|
return float(value)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fetch_jma_amedas_current(
|
||||||
|
self,
|
||||||
|
city: str,
|
||||||
|
use_fahrenheit: bool = False,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
started = time.perf_counter()
|
||||||
|
city_key = str(city or "").strip().lower()
|
||||||
|
meta = JMA_AMEDAS_STATIONS.get(city_key) or {}
|
||||||
|
if not meta:
|
||||||
|
record_source_call("jma_amedas", "current", "unsupported_city", (time.perf_counter() - started) * 1000.0)
|
||||||
|
return None
|
||||||
|
|
||||||
|
cache_key = f"{city_key}:{use_fahrenheit}"
|
||||||
|
now_ts = time.time()
|
||||||
|
with self._jma_cache_lock:
|
||||||
|
cached = self._jma_cache.get(cache_key)
|
||||||
|
if cached and now_ts - cached["t"] < self.jma_cache_ttl_sec:
|
||||||
|
record_source_call("jma_amedas", "current", "cache_hit", (time.perf_counter() - started) * 1000.0)
|
||||||
|
return cached["d"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
latest_time_text = self._jma_http_get_text(
|
||||||
|
"https://www.jma.go.jp/bosai/amedas/data/latest_time.txt"
|
||||||
|
).strip()
|
||||||
|
latest_dt = datetime.fromisoformat(latest_time_text)
|
||||||
|
bucket_hour = (latest_dt.hour // 3) * 3
|
||||||
|
bucket_key = f"{latest_dt.strftime('%Y%m%d')}_{bucket_hour:02d}"
|
||||||
|
station_code = str(meta.get("station_code") or "").strip()
|
||||||
|
url = f"https://www.jma.go.jp/bosai/amedas/data/point/{station_code}/{bucket_key}.json"
|
||||||
|
|
||||||
|
getter = getattr(self, "_http_get_json", None)
|
||||||
|
if callable(getter):
|
||||||
|
payload = getter(url)
|
||||||
|
else:
|
||||||
|
response = self.session.get(url, timeout=self.timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
|
||||||
|
if not isinstance(payload, dict) or not payload:
|
||||||
|
record_source_call("jma_amedas", "current", "empty", (time.perf_counter() - started) * 1000.0)
|
||||||
|
return None
|
||||||
|
|
||||||
|
latest_key = sorted(payload.keys())[-1]
|
||||||
|
row = payload.get(latest_key) or {}
|
||||||
|
temp_pair = row.get("temp") or []
|
||||||
|
temp_c = self._jma_safe_float(temp_pair[0] if isinstance(temp_pair, list) and temp_pair else None)
|
||||||
|
if temp_c is None:
|
||||||
|
record_source_call("jma_amedas", "current", "no_temperature", (time.perf_counter() - started) * 1000.0)
|
||||||
|
return None
|
||||||
|
|
||||||
|
temp = round(temp_c * 9 / 5 + 32, 1) if use_fahrenheit else round(temp_c, 1)
|
||||||
|
obs_time = None
|
||||||
|
try:
|
||||||
|
obs_time = datetime.strptime(str(latest_key), "%Y%m%d%H%M%S").isoformat()
|
||||||
|
except Exception:
|
||||||
|
obs_time = str(latest_key)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"source": "jma_amedas",
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
"station_code": station_code,
|
||||||
|
"station_name": meta.get("station_label") or "羽田 10分实况 (JMA)",
|
||||||
|
"obs_time": obs_time,
|
||||||
|
"current": {
|
||||||
|
"temp": temp,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
with self._jma_cache_lock:
|
||||||
|
self._jma_cache[cache_key] = {"d": result, "t": now_ts}
|
||||||
|
record_source_call("jma_amedas", "current", "success", (time.perf_counter() - started) * 1000.0)
|
||||||
|
return result
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("JMA AMeDAS current fetch failed city={} error={}", city_key, exc)
|
||||||
|
with self._jma_cache_lock:
|
||||||
|
stale = self._jma_cache.get(cache_key)
|
||||||
|
if stale:
|
||||||
|
record_source_call("jma_amedas", "current", "stale_cache", (time.perf_counter() - started) * 1000.0)
|
||||||
|
return stale["d"]
|
||||||
|
record_source_call("jma_amedas", "current", "error", (time.perf_counter() - started) * 1000.0)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fetch_jma_amedas_official_nearby(
|
||||||
|
self,
|
||||||
|
city: str,
|
||||||
|
use_fahrenheit: bool = False,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
current = self.fetch_jma_amedas_current(city, use_fahrenheit=use_fahrenheit)
|
||||||
|
if not current:
|
||||||
|
return []
|
||||||
|
meta = JMA_AMEDAS_STATIONS.get(str(city or "").strip().lower()) or {}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": meta.get("station_label") or "羽田 10分实况 (JMA)",
|
||||||
|
"station_label": meta.get("station_label") or "羽田 10分实况 (JMA)",
|
||||||
|
"lat": meta.get("lat"),
|
||||||
|
"lon": meta.get("lon"),
|
||||||
|
"temp": (current.get("current") or {}).get("temp"),
|
||||||
|
"icao": current.get("station_code"),
|
||||||
|
"istNo": current.get("station_code"),
|
||||||
|
"source": "jma",
|
||||||
|
"source_label": "JMA",
|
||||||
|
"obs_time": current.get("obs_time"),
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -10,11 +10,12 @@ from src.data_collection.open_meteo_cache import OpenMeteoCacheMixin
|
|||||||
from src.data_collection.settlement_sources import SettlementSourceMixin
|
from src.data_collection.settlement_sources import SettlementSourceMixin
|
||||||
from src.data_collection.metar_sources import MetarSourceMixin
|
from src.data_collection.metar_sources import MetarSourceMixin
|
||||||
from src.data_collection.mgm_sources import MgmSourceMixin
|
from src.data_collection.mgm_sources import MgmSourceMixin
|
||||||
|
from src.data_collection.jma_amedas_sources import JmaAmedasSourceMixin
|
||||||
from src.data_collection.nmc_sources import NmcSourceMixin
|
from src.data_collection.nmc_sources import NmcSourceMixin
|
||||||
from src.data_collection.nws_open_meteo_sources import NwsOpenMeteoSourceMixin
|
from src.data_collection.nws_open_meteo_sources import NwsOpenMeteoSourceMixin
|
||||||
|
|
||||||
|
|
||||||
class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSourceMixin, MgmSourceMixin, NmcSourceMixin, NwsOpenMeteoSourceMixin):
|
class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSourceMixin, MgmSourceMixin, JmaAmedasSourceMixin, NmcSourceMixin, NwsOpenMeteoSourceMixin):
|
||||||
"""
|
"""
|
||||||
Multi-source weather data collector
|
Multi-source weather data collector
|
||||||
|
|
||||||
@@ -167,6 +168,11 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
)
|
)
|
||||||
self._nmc_cache: Dict[str, Dict] = {}
|
self._nmc_cache: Dict[str, Dict] = {}
|
||||||
self._nmc_cache_lock = threading.Lock()
|
self._nmc_cache_lock = threading.Lock()
|
||||||
|
self.jma_cache_ttl_sec = int(
|
||||||
|
os.getenv("JMA_AMEDAS_CACHE_TTL_SEC", "300")
|
||||||
|
)
|
||||||
|
self._jma_cache: Dict[str, Dict] = {}
|
||||||
|
self._jma_cache_lock = threading.Lock()
|
||||||
self.settlement_cache_ttl_sec = int(
|
self.settlement_cache_ttl_sec = int(
|
||||||
os.getenv("SETTLEMENT_SOURCE_CACHE_TTL_SEC", "120")
|
os.getenv("SETTLEMENT_SOURCE_CACHE_TTL_SEC", "120")
|
||||||
)
|
)
|
||||||
@@ -756,6 +762,21 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
results["mgm_nearby"] = official_rows
|
results["mgm_nearby"] = official_rows
|
||||||
results["nearby_source"] = "nmc"
|
results["nearby_source"] = "nmc"
|
||||||
|
|
||||||
|
def _attach_japan_official_nearby(
|
||||||
|
self, results: Dict, city_lower: str, use_fahrenheit: bool
|
||||||
|
) -> None:
|
||||||
|
if city_lower != "tokyo":
|
||||||
|
return
|
||||||
|
official_rows = self.fetch_jma_amedas_official_nearby(
|
||||||
|
city_lower, use_fahrenheit=use_fahrenheit
|
||||||
|
)
|
||||||
|
if not official_rows:
|
||||||
|
return
|
||||||
|
results["jma_official_nearby"] = official_rows
|
||||||
|
if "mgm_nearby" not in results:
|
||||||
|
results["mgm_nearby"] = official_rows
|
||||||
|
results["nearby_source"] = "jma"
|
||||||
|
|
||||||
def _attach_warsaw_official_nearby(
|
def _attach_warsaw_official_nearby(
|
||||||
self, results: Dict, use_fahrenheit: bool
|
self, results: Dict, use_fahrenheit: bool
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -851,6 +872,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
|
|
||||||
self._attach_turkish_mgm_data(results, city_lower)
|
self._attach_turkish_mgm_data(results, city_lower)
|
||||||
self._attach_china_official_nearby(results, city_lower, use_fahrenheit)
|
self._attach_china_official_nearby(results, city_lower, use_fahrenheit)
|
||||||
|
self._attach_japan_official_nearby(results, city_lower, use_fahrenheit)
|
||||||
if city_lower == "warsaw":
|
if city_lower == "warsaw":
|
||||||
self._attach_warsaw_official_nearby(results, use_fahrenheit)
|
self._attach_warsaw_official_nearby(results, use_fahrenheit)
|
||||||
self._attach_global_nearby_cluster(
|
self._attach_global_nearby_cluster(
|
||||||
@@ -878,6 +900,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
|
|
||||||
self._attach_turkish_mgm_data(results, city_lower)
|
self._attach_turkish_mgm_data(results, city_lower)
|
||||||
self._attach_china_official_nearby(results, city_lower, use_fahrenheit)
|
self._attach_china_official_nearby(results, city_lower, use_fahrenheit)
|
||||||
|
self._attach_japan_official_nearby(results, city_lower, use_fahrenheit)
|
||||||
if city_lower == "warsaw":
|
if city_lower == "warsaw":
|
||||||
self._attach_warsaw_official_nearby(results, use_fahrenheit)
|
self._attach_warsaw_official_nearby(results, use_fahrenheit)
|
||||||
self._attach_global_nearby_cluster(
|
self._attach_global_nearby_cluster(
|
||||||
|
|||||||
Reference in New Issue
Block a user