# ==========================================
# 気象GPV 局地分析ツール ビューワー (App B)
# VERSION INFO: 129.0_EXE (機能拡張・2画面分割・地上3種重ね実装版)
# ==========================================
import sys, os, glob, re, gc, time, logging
# --- EXE化対策: ライブラリの読み込み順序を強制 ---
import pyproj
import shapely
import cartopy
if getattr(sys, 'frozen', False):
exe_dir = os.path.dirname(sys.executable)
os.environ["CARTOPY_DATA_DIR"] = os.path.join(exe_dir, "cartopy_data")
import numpy as np
import textwrap
from datetime import datetime, timedelta
import scipy.ndimage as ndimage
from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
QHBoxLayout, QSlider, QPushButton, QLabel,
QTabWidget, QListWidget, QTextEdit, QComboBox, QFileDialog, QDialog,
QMessageBox, QLineEdit, QTableWidget, QTableWidgetItem, QHeaderView)
from PyQt6.QtCore import Qt, QTimer, QSettings
from PyQt6.QtGui import QColor, QFont, QCloseEvent
os.environ['QT_API'] = 'pyqt6'
import matplotlib
matplotlib.use('qtagg')
import matplotlib.colors as mcolors
import matplotlib as mpl
import matplotlib.path as mpath
import matplotlib.pyplot as plt
mpl.rcParams['font.family'] = 'MS Gothic'
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
import cartopy.crs as ccrs
import cartopy.feature as cfeature
import cartopy.io.shapereader as shpreader
import matplotlib.patheffects as pe
STATIONS = [
("札幌", 43.060, 141.328),
("稚内", 45.415, 141.677),
("旭川", 43.762, 142.358),
("網走", 44.018, 144.279),
("釧路", 42.984, 144.381),
("帯広", 42.923, 143.219),
("函館", 41.817, 140.753),
("青森", 40.824, 140.740),
("秋田", 39.718, 140.102),
("盛岡", 39.698, 141.155),
("仙台", 38.261, 140.896),
("山形", 38.255, 140.339),
("福島", 37.750, 140.467),
("新潟", 37.940, 139.043),
("富山", 36.710, 137.202),
("金沢", 36.588, 136.625),
("福井", 36.055, 136.224),
("宇都宮", 36.555, 139.870),
("前橋", 36.396, 139.060),
("熊谷", 36.147, 139.388),
("水戸", 36.381, 140.452),
("東京", 35.691, 139.756),
("銚子", 35.738, 140.828),
("横浜", 35.438, 139.654),
("長野", 36.662, 138.196),
("甲府", 35.670, 138.568),
("静岡", 34.978, 138.403),
("名古屋", 35.166, 136.965),
("岐阜", 35.400, 136.758),
("津", 34.733, 136.510),
("大津", 35.004, 135.859),
("京都", 35.011, 135.733),
("大阪", 34.682, 135.522),
("神戸", 34.696, 135.211),
("奈良", 34.692, 135.827),
("和歌山", 34.226, 135.167),
("鳥取", 35.485, 134.237),
("松江", 35.467, 133.064),
("岡山", 34.661, 133.919),
("広島", 34.396, 132.459),
("山口", 34.155, 131.472),
("徳島", 34.066, 134.554),
("高松", 34.316, 134.053),
("松山", 33.843, 132.766),
("高知", 33.566, 133.546),
("福岡", 33.581, 130.375),
("佐賀", 33.262, 130.301),
("長崎", 32.733, 129.873),
("熊本", 32.813, 130.704),
("大分", 33.237, 131.612),
("宮崎", 31.942, 131.411),
("鹿児島", 31.554, 130.549),
("名瀬", 28.378, 129.497),
("那覇", 26.206, 127.686),
("南大東", 25.845, 131.229),
("宮古島", 24.783, 125.276),
("石垣島", 24.336, 124.164)
]
CACHED_SHAPES = {'other_cities': None, 'sapporo_districts': None, 'sapporo_city': None, 'kamikawa': None}
SHAPEFILE_FOUND = False
def search_shapefile():
base_dir = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else os.getcwd()
paths = glob.glob(os.path.join(base_dir, '**', 'N03*.shp'), recursive=True)
return paths[0] if paths else None
def get_simplified_geometries():
global CACHED_SHAPES, SHAPEFILE_FOUND
if CACHED_SHAPES['other_cities'] is not None: return CACHED_SHAPES['other_cities'], CACHED_SHAPES['sapporo_districts'], CACHED_SHAPES['sapporo_city'], CACHED_SHAPES['kamikawa']
shp_path = search_shapefile()
if not shp_path or not os.path.exists(shp_path): return [], [], [], []
SHAPEFILE_FOUND = True
other_cities_g = []; sapporo_districts_g = {}; sapporo_city_g = []; kamikawa_g = []
sapporo_dist_names = ['中央区', '北区', '東区', '白石区', '厚別区', '豊平区', '清田区', '南区', '西区', '手稲区']
for d_name in sapporo_dist_names: sapporo_districts_g[d_name] = []
try:
reader = shpreader.Reader(shp_path, encoding='cp932')
for r in reader.records():
geom = r.geometry
try: geom = geom.simplify(0.002, preserve_topology=False)
except: pass
pref_name = str(r.attributes.get('N03_001', '')); subpref_name = str(r.attributes.get('N03_002', ''))
city_name = str(r.attributes.get('N03_004', '')); cit_name_spec = str(r.attributes.get('N03_003', ''))
is_sap_dist = False
if '札幌市' in cit_name_spec:
for d_name in sapporo_dist_names:
if d_name == city_name: is_sap_dist = True; sapporo_districts_g[d_name].append(geom); sapporo_city_g.append(geom); break
if '上川' in subpref_name: kamikawa_g.append(geom)
if not is_sap_dist and '北海道' in pref_name:
try:
if geom.area > 0.0005: other_cities_g.append(geom)
except: pass
CACHED_SHAPES['other_cities'] = other_cities_g; CACHED_SHAPES['sapporo_districts'] = sapporo_districts_g;
CACHED_SHAPES['sapporo_city'] = sapporo_city_g; CACHED_SHAPES['kamikawa'] = kamikawa_g
return other_cities_g, sapporo_districts_g, sapporo_city_g, kamikawa_g
except Exception as e:
logging.error(f"Shapefile processing error: {e}")
return [], [], [], []
def calc_t_td(t, rh):
rh_s = np.clip(rh, 1.0, 100.0)
e = 6.112 * np.exp((17.67 * t) / (t + 243.5)) * (rh_s / 100.0)
e = np.clip(e, 0.001, 1000)
td = (243.5 * np.log(e / 6.112)) / (17.67 - np.log(e / 6.112))
return t - td
def rh_from_t_td(t, t_td):
td = t - t_td
e = 6.112 * np.exp((17.67 * td) / (td + 243.5))
es = 6.112 * np.exp((17.67 * t) / (t + 243.5))
rh = (e / es) * 100.0
return np.clip(rh, 0.0, 100.0)
def calc_ept(t, rh, p):
t_k = t + 273.15
rh_s = np.clip(rh, 1.0, 100.0)
e = 6.112 * np.exp((17.67 * t) / (t + 243.5)) * (rh_s / 100.0)
e = np.clip(e, 0.001, p - 0.1)
q = 0.622 * e / (p - e)
tlcl = 2840.0 / (3.5 * np.log(t_k) - np.log(e) - 4.805) + 55.0
theta = t_k * (1000.0 / p) ** 0.2854
ept = theta * np.exp(((3376.0 / tlcl) - 2.54) * q * (1.0 + 0.81 * q))
return ept
def get_t_from_ept(target_ept, p):
t_min, t_max = -80.0, 50.0
for _ in range(15):
t_mid = (t_min + t_max) / 2.0
ept_mid = calc_ept(t_mid, 100.0, p)
if ept_mid < target_ept: t_min = t_mid
else: t_max = t_mid
return (t_min + t_max) / 2.0
class SingleImageWindow(QDialog):
def __init__(self, parent, title, model_name, stat_name, init_z_str, canvas):
super().__init__(parent)
self.model_name = model_name; self.stat_name = stat_name; self.init_z_str = init_z_str
self.setWindowTitle(title)
self.resize(1100, 950)
self.setStyleSheet("QDialog { background-color: #0A192F; color: #E0E0E0; }")
layout = QVBoxLayout(self)
layout.setContentsMargins(10, 10, 10, 10)
layout.addWidget(canvas, stretch=1)
btn_layout = QHBoxLayout()
btn_layout.setContentsMargins(10, 10, 10, 10)
save_btn = QPushButton("📷 画像として保存")
save_btn.setStyleSheet("background-color: #27AE60; color: white; font-size: 14pt; font-weight: bold; padding: 12px; border-radius: 5px;")
save_btn.clicked.connect(self.save_image)
close_btn = QPushButton("閉じる")
close_btn.setStyleSheet("background-color: #C0392B; color: white; font-size: 14pt; font-weight: bold; padding: 12px; border-radius: 5px;")
close_btn.clicked.connect(self.accept)
btn_layout.addStretch()
btn_layout.addWidget(save_btn)
btn_layout.addWidget(close_btn)
btn_layout.addStretch()
layout.addLayout(btn_layout)
def save_image(self):
desktop = os.path.expanduser("~/Desktop")
filename = f"{self.model_name}_{self.stat_name}_{self.init_z_str}.png"
path, _ = QFileDialog.getSaveFileName(self, "画像を保存", os.path.join(desktop, filename), "Images (*.png)")
if path:
canvas_widget = self.layout().itemAt(0).widget()
pixmap = canvas_widget.grab() if hasattr(canvas_widget, 'grab') else None
if pixmap: pixmap.save(path)
else: self.layout().itemAt(0).widget().figure.savefig(path, dpi=150, bbox_inches='tight', facecolor='white')
QMessageBox.information(self, "保存完了", f"保存しました:\n{os.path.basename(path)}")
class SplashWindow(QWidget):
def __init__(self):
super().__init__()
self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint)
self.setFixedSize(480, 250)
self.setStyleSheet("background-color: #0A192F; border: 3px solid #64FFDA; border-radius: 12px;")
layout = QVBoxLayout(self)
title_lbl = QLabel("気象GPV 局地分析ツール\n(Ver 129.0 EXE)")
title_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
title_lbl.setStyleSheet("font-size: 18pt; font-weight: bold; color: #E0E0E0; border: none; margin-top: 15px;")
layout.addWidget(title_lbl)
msg_lbl = QLabel("最新の解析データをロードしています...\n(約5秒で自動起動します)")
msg_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
msg_lbl.setStyleSheet("font-size: 14pt; color: #64FFDA; border: none;")
layout.addWidget(msg_lbl)
class WeatherApp(QMainWindow):
def __init__(self):
super().__init__()
self.settings = QSettings('WeatherApp', 'Viewer')
self.cache_dir = self.settings.value('cache_dir', os.path.join(os.getcwd(), "gpv_cache_npz"))
self.setup_logging()
self.setWindowTitle("気象GPV 局地分析ツール (Ver 129.0 - 機能拡張・安定統合版)")
self.resize(1850, 1050)
self.model_init_times = {"MSM": None, "GSM_JP": None, "MSM_GUID": None, "GSM_GUID": None, "ANAL": None}
self.tab_ui = {}
self.monitor_timer = QTimer(self); self.monitor_timer.timeout.connect(self.scan_cache_dir)
self.anim_timer = QTimer(self); self.anim_timer.timeout.connect(self.anim_step)
self.current_anim_model = None
self.setStyleSheet("""
* { font-size: 13pt; font-family: "MS Gothic", sans-serif; color: #E0E0E0; }
QMainWindow, QDialog, QMessageBox { background-color: #0A192F; color: #E0E0E0; }
QMessageBox QLabel { color: #E0E0E0; }
QMessageBox QPushButton { background-color: #1D3557; color: white; padding: 5px 15px; border-radius: 3px; font-weight: bold; }
QMessageBox QPushButton:hover { background-color: #457B9D; }
QTabWidget::pane { border: 1px solid #444; background: #112240; }
QTabBar::tab { background: #1B2B4A; color: #8892B0; padding: 12px 25px; border: 1px solid #333; border-bottom: none; }
QTabBar::tab:selected { background: #112240; color: #64FFDA; font-weight: bold; border-top: 3px solid #64FFDA; }
QSlider::groove:vertical { border: 1px solid #444; background: #0A192F; width: 14px; border-radius: 7px; }
QSlider::handle:vertical { background: #64FFDA; border: 2px solid #000; height: 32px; margin: 0 -10px; border-radius: 16px; }
QPushButton { background-color: #1D3557; border: 1px solid #457B9D; padding: 8px; border-radius: 4px; color: #F1FAEE; }
QPushButton:hover { background-color: #457B9D; }
QPushButton::checked { background-color: #E63946; color: white; font-weight: bold; }
QListWidget { background: #112240; border: 1px solid #444; color: #E0E0E0; }
QListWidget::item { padding: 3px 5px; margin: 1px 5px; background: #1D3557; border: 1px solid #333; border-radius: 6px; }
QListWidget::item:selected { background: #E63946; color: white; font-weight: bold; }
QListWidget::item:hover:!selected { background: #457B9D; }
QComboBox { background: #112240; border: 1px solid #8892B0; color: white; padding: 5px; }
QComboBox QAbstractItemView { background: #112240; color: white; }
QLabel { background: transparent; }
QTextEdit { background-color: #112240; color: #64FFDA; border: 1px solid #444; }
.NavButton { background-color: #2980B9; color: white; font-weight: bold; padding: 5px; border-radius: 5px; }
.PlayButton { background-color: #27AE60; color: white; font-weight: bold; padding: 5px 15px; border-radius: 5px; }
.StopButton { background-color: #C0392B; color: white; font-weight: bold; padding: 5px 15px; border-radius: 5px; }
.SyncButton { background-color: #F39C12; color: white; font-weight: bold; border: 2px solid #D68910; border-radius: 5px; padding: 5px 15px;}
.SaveButton { background-color: #8E44AD; color: white; font-weight: bold; border-radius: 5px; padding: 4px 10px; font-size: 10pt;}
.MetaButton { background-color: #16A085; color: white; font-weight: bold; border: 2px solid #0E6655; border-radius: 5px; padding: 5px 15px; }
.MetaButton:checked { background-color: #0E6655; border: 2px solid #1ABC9C; }
.AdminButton { background-color: #34495E; color: white; font-weight: bold; border-radius: 5px; padding: 4px 10px; font-size: 10pt;}
""")
main_widget = QWidget(); self.setCentralWidget(main_widget); main_layout = QVBoxLayout(main_widget)
self.tabs = QTabWidget()
corner_widget = QWidget()
corner_layout = QHBoxLayout(corner_widget)
corner_layout.setContentsMargins(0, 0, 5, 0)
self.status_banner = QLabel("待機中...")
self.status_banner.setStyleSheet("color: white; font-weight: bold; background-color: #E63946; padding: 4px 10px; border-radius: 4px; font-size: 10pt;")
self.save_img_btn = QPushButton("📷 画面保存")
self.save_img_btn.setProperty("class", "SaveButton")
self.save_img_btn.clicked.connect(self.save_current_image)
self.change_dir_btn = QPushButton("📁 読込先変更")
self.change_dir_btn.setProperty("class", "AdminButton")
self.change_dir_btn.clicked.connect(self.change_dir)
self.sync_btn = QPushButton("🔄 画面同期")
self.sync_btn.setProperty("class", "SyncButton")
self.sync_btn.clicked.connect(lambda: self.scan_cache_dir(force_draw=True))
self.exit_app_btn = QPushButton("🛑 完全停止")
self.exit_app_btn.setProperty("class", "StopButton")
self.exit_app_btn.clicked.connect(self.close)
corner_layout.addWidget(self.status_banner)
corner_layout.addWidget(self.save_img_btn)
corner_layout.addWidget(self.change_dir_btn)
corner_layout.addWidget(self.sync_btn)
corner_layout.addWidget(self.exit_app_btn)
self.tabs.setCornerWidget(corner_widget, Qt.Corner.TopRightCorner)
self.setup_tab("MSM", "MSM GPV", is_guidance=False, is_anal=False)
self.setup_tab("GSM_JP", "GSM 日本域 GPV", is_guidance=False, is_anal=False)
self.setup_tab("ANAL", "毎時大気解析", is_guidance=False, is_anal=True)
self.setup_tab("MSM_GUID", "MSM ガイダンス", is_guidance=True, is_anal=False)
self.setup_tab("GSM_GUID", "GSM ガイダンス", is_guidance=True, is_anal=False)
self.setup_history_tab()
main_layout.addWidget(self.tabs)
get_simplified_geometries()
self.splash = SplashWindow()
self.splash.show()
QTimer.singleShot(5000, self.finish_startup_loading)
def closeEvent(self, event: QCloseEvent):
reply = QMessageBox.question(self, '終了の確認', 'システムを完全に停止してよろしいですか?',
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No)
if reply == QMessageBox.StandardButton.Yes:
event.accept()
else:
event.ignore()
def setup_logging(self):
self.log_file = os.path.join(self.cache_dir, "system_log.txt")
os.makedirs(self.cache_dir, exist_ok=True)
logging.basicConfig(filename=self.log_file, level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s', encoding='utf-8')
def change_dir(self):
directory = QFileDialog.getExistingDirectory(self, "GPVデータ(npz)が保存されるフォルダを選択", self.cache_dir)
if directory:
self.cache_dir = directory
self.settings.setValue('cache_dir', directory)
self.setup_logging()
QMessageBox.information(self, "設定完了", f"読込先フォルダを更新しました:\n{directory}")
self.scan_cache_dir(force_draw=True)
def finish_startup_loading(self):
self.splash.close()
self.scan_cache_dir(quiet=True)
self.monitor_timer.start(5000)
for i in range(self.tabs.count()):
if "MSM" in self.tabs.tabText(i) and "ガイダンス" not in self.tabs.tabText(i):
self.tabs.setCurrentIndex(i); break
if "MSM" in self.tab_ui:
items = self.tab_ui["MSM"]['list'].findItems("地上気圧", Qt.MatchFlag.MatchExactly)
if items: self.tab_ui["MSM"]['list'].setCurrentItem(items[0])
self.show()
def save_current_image(self):
current_tab = self.tabs.tabText(self.tabs.currentIndex())
if "MSM ガイダンス" in current_tab: model_name = "MSM_GUID"
elif "GSM ガイダンス" in current_tab: model_name = "GSM_GUID"
elif "毎時大気" in current_tab: model_name = "ANAL"
elif "MSM" in current_tab: model_name = "MSM"
elif "日本域" in current_tab: model_name = "GSM_JP"
else: return
if model_name not in self.tab_ui or self.tab_ui[model_name]['ax'] is None: return
ui = self.tab_ui[model_name]
val = ui['slider'].value(); elem = ui['list'].currentItem().text()
it_str = self.model_init_times.get(model_name)
if not it_str: return
dt_utc = datetime.strptime(it_str, "%Y%m%d%H%M%S")
init_z = dt_utc.strftime("%m%d%H") + "Z"
filename = f"{model_name}_{elem}_{init_z}_FT{val:02d}.png"
desktop = os.path.expanduser("~/Desktop")
path, _ = QFileDialog.getSaveFileName(self, "画像を保存", os.path.join(desktop, filename), "Images (*.png *.jpg)")
if path:
ui['fig'].savefig(path, dpi=150, bbox_inches='tight', facecolor='white')
self.status_banner.setText(f"保存完了: {os.path.basename(path)}")
def init_map(self, fig, area_idx, model):
fig.clear()
if area_idx == 4: # ※2画面分割用のインデックス
# 【修正】石狩(左)と恵庭(右)を1:1の同じ大きさで配置し、見栄えを最大化
gs = fig.add_gridspec(1, 2)
ax1 = fig.add_subplot(gs[0, 0], projection=ccrs.PlateCarree()) # 左側: 石狩
ax2 = fig.add_subplot(gs[0, 1], projection=ccrs.PlateCarree()) # 右側: 恵庭
# 各画面の範囲設定 (幅0.5度、高さ0.65度で完全に同一縮尺)
# 石狩は陸地が増えるよう東寄りに調整し、恵庭と同じスケール感に
ax1.set_extent([141.20, 141.70, 43.00, 43.65], crs=ccrs.PlateCarree()) # 石狩南部
ax2.set_extent([141.30, 141.80, 42.50, 43.15], crs=ccrs.PlateCarree()) # 恵庭周辺
self.draw_base_map(ax1, 3) # area_index 3(札幌近郊)相当の地図ディテール
self.draw_base_map(ax2, 3)
# 余白を減らして地図を限界まで大きく表示
fig.subplots_adjust(left=0.03, right=0.92, top=0.93, bottom=0.05, wspace=0.1)
return [ax1, ax2]
else:
ax = fig.add_subplot(1, 1, 1, projection=ccrs.PlateCarree())
ax.set_extent(self.get_extent(area_idx, model), crs=ccrs.PlateCarree())
self.draw_base_map(ax, area_idx)
fig.subplots_adjust(left=0.05, right=0.9, top=0.92, bottom=0.05)
return ax
def get_extent(self, area_index, model_name):
if area_index == 0: return [120, 150, 27.5, 48] if "MSM" in model_name or "ANAL" in model_name else [120, 150, 20, 50]
elif area_index == 1: return [138.5, 146.5, 40.5, 46.5]
elif area_index == 2: return [141.6, 143.6, 42.8, 45.0]
elif area_index == 3: return [141.15, 141.55, 42.92, 43.20] # 通常の札幌近郊
elif area_index == 4: return [141.35, 141.80, 42.50, 43.25] # 【修正】恵庭周辺:さらに一回りワイドに広域化
elif area_index == 5: return [141.10, 141.45, 43.15, 43.70] # 【修正】石狩南部:やや左(西)へずらした範囲
else: return [141.15, 141.55, 42.92, 43.20]
def crop_data(self, lon, lat, data, extent):
if lon is None or lat is None or data is None or data.size == 0:
return np.array([]), np.array([]), np.array([])
buffer = 1.0; lon_min, lon_max = extent[0] - buffer, extent[1] + buffer; lat_min, lat_max = extent[2] - buffer, extent[3] + buffer
if lon.ndim == 1:
m_lon = (lon >= lon_min) & (lon <= lon_max); m_lat = (lat >= lat_min) & (lat <= lat_max)
if not np.any(m_lon) or not np.any(m_lat): return np.array([]), np.array([]), np.array([])
return lon[m_lon], lat[m_lat], data[np.ix_(m_lat, m_lon)]
else:
mask = (lon >= lon_min) & (lon <= lon_max) & (lat >= lat_min) & (lat <= lat_max)
j, i = np.where(mask)
if len(j) == 0: return np.array([]), np.array([]), np.array([])
return lon[j.min():j.max()+1, i.min():i.max()+1], lat[j.min():j.max()+1, i.min():i.max()+1], data[j.min():j.max()+1, i.min():i.max()+1]
def draw_base_map(self, ax, area_index):
SEA_COLOR = '#A4C2E4'; LAND_COLOR = '#F5F5F0'; COASTLINE_COLOR = '#333333'
ax.set_facecolor(SEA_COLOR); ax.add_feature(cfeature.LAND.with_scale('10m'), facecolor=LAND_COLOR, zorder=0)
ax.add_feature(cfeature.OCEAN.with_scale('10m'), facecolor=SEA_COLOR, zorder=0)
ax.add_feature(cfeature.LAKES.with_scale('10m'), edgecolor='#4682B4', facecolor=SEA_COLOR, zorder=1)
ax.add_feature(cfeature.RIVERS.with_scale('10m'), linewidth=0.8, edgecolor='#4682B4', zorder=1)
try:
ax.add_feature(cfeature.COASTLINE.with_scale('10m'), linewidth=0.6, edgecolor=COASTLINE_COLOR, zorder=2)
except Exception as e:
logging.error(f"Cartopy COASTLINE error: {e}")
other_cities_g, sapporo_districts_g, sapporo_city_g, kamikawa_g = get_simplified_geometries()
# 【修正】市町村の境界線を「黒色の実線」で濃く描画 (linewidthを0.8に設定)
if area_index >= 1 and other_cities_g:
ax.add_geometries(other_cities_g, ccrs.PlateCarree(), edgecolor='black', facecolor='none', linewidth=0.8, linestyle='-', zorder=3)
if area_index == 3 and sapporo_districts_g:
for d_name, geoms in sapporo_districts_g.items():
if geoms: ax.add_geometries(geoms, ccrs.PlateCarree(), edgecolor='#FF0000', facecolor='none', linewidth=1.5, zorder=10)
if sapporo_city_g and area_index == 3: ax.add_geometries(sapporo_city_g, ccrs.PlateCarree(), edgecolor='#FF0000', facecolor='#FF8C00', alpha=0.3, linewidth=2.0, linestyle='-', zorder=4)
elif sapporo_city_g and area_index >= 1: ax.add_geometries(sapporo_city_g, ccrs.PlateCarree(), edgecolor='#FF0000', facecolor='#FF8C00', alpha=0.2, linewidth=1.0, linestyle='-', zorder=4)
if area_index >= 1 and kamikawa_g: ax.add_geometries(kamikawa_g, ccrs.PlateCarree(), edgecolor='#FF0000', facecolor='#FF8C00', alpha=0.25 if area_index == 2 else 0.15, linewidth=1.5, linestyle='-', zorder=5)
try:
pref_lines = cfeature.NaturalEarthFeature('cultural', 'admin_1_states_provinces_lines', '10m', edgecolor='#444444', facecolor='none')
ax.add_feature(pref_lines, linewidth=0.8, linestyle='-.', zorder=7)
except: pass
gl = ax.gridlines(crs=ccrs.PlateCarree(), draw_labels=True, linewidth=0.5, color='gray', alpha=0.5, linestyle='--', zorder=6)
gl.top_labels = False; gl.right_labels = False
gl.xlabel_style = {'size': 11, 'color': 'black', 'fontname': 'MS Gothic'}; gl.ylabel_style = {'size': 11, 'color': 'black', 'fontname': 'MS Gothic'}
# グリッド線の間隔調整
if area_index == 4 or area_index == 5: gl.xlocator = mpl.ticker.MultipleLocator(0.1); gl.ylocator = mpl.ticker.MultipleLocator(0.1)
elif area_index == 3: gl.xlocator = mpl.ticker.MultipleLocator(0.05); gl.ylocator = mpl.ticker.MultipleLocator(0.05)
elif area_index == 2: gl.xlocator = mpl.ticker.MultipleLocator(0.5); gl.ylocator = mpl.ticker.MultipleLocator(0.5)
elif area_index == 1: gl.xlocator = mpl.ticker.MultipleLocator(1.0); gl.ylocator = mpl.ticker.MultipleLocator(1.0)
else: gl.xlocator = mpl.ticker.MultipleLocator(5.0) if area_index == 0 else mpl.ticker.MultipleLocator(1.0); gl.ylocator = mpl.ticker.MultipleLocator(5.0) if area_index == 0 else mpl.ticker.MultipleLocator(1.0)
def get_local_info(self, elem_name, lon, lat, data, u=None, v=None, area_index=0, model_name=""):
if data is None or lon is None or lat is None or data.size == 0: return ""
target_lon, target_lat, loc_name = (142.368, 43.760, "旭川") if area_index == 2 else (141.35, 43.06, "札幌")
if lon.ndim == 1: lon_2d, lat_2d = np.meshgrid(lon, lat)
else: lon_2d, lat_2d = lon, lat
dist = (lon_2d - target_lon)**2 + (lat_2d - target_lat)**2
sy, sx = np.unravel_index(np.argmin(dist), dist.shape)
if sy >= data.shape[0] or sx >= data.shape[1]: return ""
s_val = data[sy, sx]
if np.isnan(s_val): return ""
if "降水量" in elem_name or "降水" in elem_name: info = f"[{loc_name}現況]\n降水量: {s_val:.1f} mm"
elif "降雪量" in elem_name or "降雪" in elem_name: info = f"[{loc_name}現況]\n降雪量: {s_val:.1f} cm"
else: info = f"[{loc_name}現況]\n"
if "降水" not in elem_name and "降雪" not in elem_name:
if "気圧" in elem_name: info += f"気圧: {s_val:.1f} hPa"
elif "確率" in elem_name and "発雷" not in elem_name: info += f"{elem_name}: {s_val:.0f} %"
elif "発雷確率" in elem_name: info += f"発雷確率: {s_val:.0f} %"
elif "天気" in elem_name:
wx_map = {1: "晴", 2: "曇", 3: "雨", 4: "雪"}
info += f"卓越天気: {wx_map.get(int(s_val), '不明')}"
elif "雲量" in elem_name: info += f"{elem_name}: {s_val:.1f} %"
elif "高度" in elem_name: info += f"{elem_name[:6]}高度: {s_val:.0f} m"
elif "渦度" in elem_name: info += f"正渦度(10^-5/s): {s_val:.1f}"
elif "気温" in elem_name: info += f"{elem_name[:6]}気温: {s_val:.1f} ℃"
elif "鉛直流" in elem_name: info += f"鉛直流: {s_val:.1f} hPa/h"
elif "湿数" in elem_name: info += f"{elem_name[:6]}湿数: {s_val:.1f} ℃"
elif "相当温位" in elem_name: info += f"{elem_name[:6]}相当温位: {s_val:.1f} K"
else: info += f"値: {s_val:.1f}"
if u is not None and v is not None and sy < u.shape[0] and sx < u.shape[1]: info += f"\n風速: {np.hypot(u[sy, sx], v[sy, sx]):.1f} m/s"
return info
def on_area_btn_clicked(self, model_name, idx):
ui = self.tab_ui[model_name]
for i, btn in enumerate(ui['area_btns']): btn.setChecked(i == idx)
is_meta_btn = ("メタグラム" in ui['area_btns'][-1].text() and idx == len(ui['area_btns'])-1)
if is_meta_btn:
ui['canvas'].hide()
if 'info_panel' in ui: ui['info_panel'].hide()
if 'meta_ctrl' in ui: ui['meta_ctrl'].show()
else:
if 'meta_ctrl' in ui: ui['meta_ctrl'].hide()
ui['canvas'].show()
self.draw_frame(model_name, ui['slider'].value())
def on_init_changed(self, model_name, idx):
ui = self.tab_ui[model_name]
if idx < 0: return
selected_time_str = ui['init_combo'].itemData(idx)
if selected_time_str:
self.model_init_times[model_name] = selected_time_str
self.update_slider_max(model_name, selected_time_str)
if len(ui['area_btns']) > 4 and ui['area_btns'][-1].isChecked(): return
elem_name = ui['list'].currentItem().text()
val = ui['slider'].value()
if "降水" in elem_name and val == 0 and "確率" not in elem_name and "時間降水" not in elem_name and model_name != "ANAL":
val = ui['step']
ui['slider'].blockSignals(True)
ui['slider'].setValue(val)
ui['slider'].blockSignals(False)
self.draw_frame(model_name, val)
def on_element_changed(self, model_name):
ui = self.tab_ui[model_name]
elem_name = ui['list'].currentItem().text()
if "GUID" in model_name:
min_ft = 0
if "6時間" in elem_name: min_ft = 6
elif "12時間" in elem_name: min_ft = 12
elif "24時間" in elem_name: min_ft = 24
elif "3時間" in elem_name: min_ft = 3
ui['slider'].setMinimum(min_ft)
if ui['slider'].value() < min_ft:
ui['slider'].blockSignals(True)
ui['slider'].setValue(min_ft)
ui['slider'].blockSignals(False)
else:
ui['slider'].setMinimum(0)
self.on_slider_changed(model_name, ui['slider'].value())
def on_slider_changed(self, model_name, val):
ui = self.tab_ui[model_name]
elem_name = ui['list'].currentItem().text()
if model_name == "GSM_JP":
ground_cloud_elems = ["地上気圧", "地上風向風速", "地上降水", "全雲量", "上層雲量", "中層雲量", "下層雲量", "地上3種重ね"]
threshold = 132 if elem_name in ground_cloud_elems else 84
if val > threshold and val % 6 != 0:
last_val = ui.get('last_val', 0)
direction = 1 if val >= last_val else -1
if direction == 1: new_val = val + (6 - (val % 6))
else: new_val = val - (val % 6)
if new_val > ui['slider'].maximum(): new_val = ui['slider'].maximum()
ui['slider'].blockSignals(True)
ui['slider'].setValue(new_val)
ui['slider'].blockSignals(False)
val = new_val
ui['last_val'] = val
self.draw_frame(model_name, val)
def update_slider_max(self, ui_key, init_time_str):
ui = self.tab_ui.get(ui_key)
if not ui: return
files = glob.glob(os.path.join(self.cache_dir, f"{ui_key}_{init_time_str}_FT*.npz"))
max_ft_found = max([0] + [int(re.search(r'_FT(\d+)\.npz', f).group(1)) for f in files if re.search(r'_FT(\d+)\.npz', f)])
hh = init_time_str[8:10] if len(init_time_str) >= 10 else '00'
if ui_key == "MSM": default_max = 78 if hh in ['00', '12'] else 39; step = 1; tick_step = 6
elif ui_key == "GSM_JP": default_max = 264 if hh in ['00', '12'] else 132; step = 3; tick_step = 12
elif ui_key == "ANAL": default_max = 0; step = 1; tick_step = 1
elif ui_key == "MSM_GUID": default_max = 84; step = 1; tick_step = 6
elif ui_key == "GSM_GUID": default_max = 84; step = 3; tick_step = 12
else: default_max = 132; step = 6; tick_step = 24
final_max = max(default_max, max_ft_found); final_max = (final_max // step) * step
ui['slider'].setMaximum(final_max)
ui['slider'].setTickInterval(step); ui['slider'].setSingleStep(step); ui['slider'].setPageStep(step)
ticks_layout = ui['ticks_layout']
for i in reversed(range(ticks_layout.count())):
w = ticks_layout.itemAt(i).widget()
if w: w.setParent(None)
if ui_key != "ANAL":
for t in range(0, final_max + 1, tick_step):
lbl = QLabel(f"{t}h"); lbl.setStyleSheet("font-size: 8pt; color: #8892B0;"); lbl.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter); ticks_layout.addWidget(lbl)
def create_compressed_left_panel(self, model_name):
left_panel = QVBoxLayout(); left_panel.setContentsMargins(5, 5, 5, 5)
step = 1 if model_name in ["MSM", "ANAL", "MSM_GUID"] else 3
left_panel.addWidget(QLabel(f"📡 {model_name} 初期時刻(UTC):", styleSheet="font-weight: bold; font-size: 11pt; color: #E0E0E0;"))
init_combo = QComboBox(); init_combo.addItem("(データ待機中...)", None); left_panel.addWidget(init_combo)
interval_label = QLabel(f"【{step}時間間隔表示】", styleSheet="color: #64FFDA; font-weight: bold; font-size: 11pt;"); left_panel.addWidget(interval_label)
mid_hbox = QHBoxLayout(); elem_vbox = QVBoxLayout(); elem_vbox.addWidget(QLabel("■ 要素選択"))
element_list = QListWidget(); element_list.setFixedWidth(200)
if "GUID" in model_name:
elems = ["卓越天気", "3時間降水量", "6時間降水量", "12時間降水量", "24時間降水量", "3時間降雪量", "6時間降雪量", "12時間降雪量", "24時間降雪量", "発雷確率"]
elif model_name == "ANAL":
elems = ["地上気温", "地上風向風速", "300hPa風向・風速", "500hPa風向・風速", "500hPa気温", "700hPa気温・風", "850hPa気温・風", "925hPa気温・風", "975hPa気温・風"]
else:
# 【修正①】 リストの名称を hPa に完全統一・タイポ修正 (975hp -> 975hPa)
elems = ["地上3種重ね", "地上気圧","地上風向風速", "地上降水", "全雲量", "上層雲量", "中層雲量", "下層雲量", "300hPa高度", "300hPa風向・風速", "500hPa高度", "500hPa風向・風速", "500hPa渦度", "500hPa気温", "700hPa湿数・風", "700hPa鉛直流", "850hPa気温・風", "850hPa相当温位", "850hPa湿数・風", "925hPa相当温位", "925hPa湿数・風", "975hPa相当温位", "975hPa湿数・風"]
element_list.addItems(elems); element_list.setCurrentRow(0); elem_vbox.addWidget(element_list); mid_hbox.addLayout(elem_vbox)
slider_vbox = QVBoxLayout()
time_label = QLabel("FT=0h\n(--/--)"); time_label.setStyleSheet("font-weight: bold; color: #64FFDA; font-size: 11pt; background: #1D3557; padding: 5px; border: 2px solid #457B9D; border-radius: 5px;"); time_label.setAlignment(Qt.AlignmentFlag.AlignCenter); slider_vbox.addWidget(time_label)
slider_control_hbox = QHBoxLayout()
btn_prev = QPushButton("▲"); btn_prev.setProperty("class", "NavButton"); btn_prev.setFixedWidth(30)
btn_next = QPushButton("▼"); btn_next.setProperty("class", "NavButton"); btn_next.setFixedWidth(30)
slider = QSlider(Qt.Orientation.Vertical); slider.setRange(0, 39); slider.setTickPosition(QSlider.TickPosition.TicksLeft); slider.setTickInterval(step); slider.setSingleStep(step); slider.setPageStep(step); slider.setInvertedAppearance(True)
btn_vbox = QVBoxLayout(); btn_vbox.addWidget(slider, stretch=1); btn_vbox.setAlignment(Qt.AlignmentFlag.AlignHCenter)
nav_hbox = QHBoxLayout(); nav_hbox.addWidget(btn_prev); nav_hbox.addWidget(btn_next)
inner_slider_vbox = QVBoxLayout(); inner_slider_vbox.addLayout(nav_hbox); inner_slider_vbox.addLayout(btn_vbox); slider_control_hbox.addLayout(inner_slider_vbox)
ticks_vbox = QVBoxLayout(); ticks_vbox.setContentsMargins(0, 30, 0, 10)
slider_control_hbox.addLayout(ticks_vbox); slider_vbox.addLayout(slider_control_hbox, stretch=1)
play_hbox = QHBoxLayout()
btn_play = QPushButton("▶"); btn_play.setProperty("class", "PlayButton"); btn_play.setFixedWidth(40)
btn_stop = QPushButton("■"); btn_stop.setProperty("class", "StopButton"); btn_stop.setFixedWidth(40)
play_hbox.addWidget(btn_play); play_hbox.addWidget(btn_stop); slider_vbox.addLayout(play_hbox)
combo_speed = QComboBox(); combo_speed.addItems(["速1", "速2", "速3", "速4", "速5"]); combo_speed.setCurrentIndex(2); slider_vbox.addWidget(combo_speed)
mid_hbox.addLayout(slider_vbox); left_panel.addLayout(mid_hbox, stretch=1)
return left_panel, init_combo, interval_label, element_list, time_label, btn_prev, btn_next, slider, btn_play, btn_stop, combo_speed, ticks_vbox
def setup_tab(self, model_name, tab_title, is_guidance=False, is_anal=False):
tab = QWidget(); main_hbox = QHBoxLayout(tab)
left_panel, init_combo, interval_label, element_list, time_label, btn_prev, btn_next, slider, btn_play, btn_stop, combo_speed, ticks_layout = self.create_compressed_left_panel(model_name)
main_hbox.addLayout(left_panel, stretch=2)
right_panel = QVBoxLayout()
area_layout = QHBoxLayout(); area_layout.addWidget(QLabel("表示領域:"))
# 【修正要領③】 各要素タブの領域ボタンに「恵庭・石狩」を新設
area_btns = [QPushButton("日本周辺"), QPushButton("北海道全域"), QPushButton("上川地方"), QPushButton("札幌近郊"), QPushButton("恵庭・石狩"), QPushButton("📊 メタグラム")]
for i, btn in enumerate(area_btns):
btn.setCheckable(True); area_layout.addWidget(btn); btn.clicked.connect(lambda checked, idx=i, m=model_name: self.on_area_btn_clicked(m, idx))
area_btns[0].setChecked(True)
if not is_anal:
btn_prev.clicked.connect(lambda: step_slider(-1)); btn_next.clicked.connect(lambda: step_slider(1))
slider.valueChanged.connect(lambda val, m=model_name: self.on_slider_changed(m, val))
btn_play.clicked.connect(lambda: self.start_animation(model_name)); btn_stop.clicked.connect(self.stop_animation)
element_list.itemSelectionChanged.connect(lambda m=model_name: self.on_element_changed(m))
init_combo.currentIndexChanged.connect(lambda idx, m=model_name: self.on_init_changed(m, idx))
self.tabs.addTab(tab, tab_title)
area_layout.addStretch()
right_panel.addLayout(area_layout)
fig = Figure(figsize=(14, 13)); fig.patch.set_facecolor('white'); canvas = FigureCanvas(fig)
meta_ctrl_widget = QWidget(); meta_ctrl_layout = QVBoxLayout(meta_ctrl_widget)
h_ctrl = QHBoxLayout()
h_ctrl.addWidget(QLabel("地点選択:")); station_combo = QComboBox()
for name, lat, lon in STATIONS: station_combo.addItem(name, (lat, lon))
h_ctrl.addWidget(station_combo)
if not is_anal:
btn_text = "表示 (A4横サイズ テーブル画像生成)" if is_guidance else "表示 (図解メタグラム生成)"
btn_generate_meta = QPushButton(btn_text)
btn_generate_meta.setProperty("class", "SyncButton")
if is_guidance: btn_generate_meta.clicked.connect(lambda: self.draw_guidance_metagram(station_combo, model_name))
elif model_name == "GSM_JP": btn_generate_meta.clicked.connect(lambda: self.draw_gsm_visual_metagram(station_combo))
elif model_name == "MSM": btn_generate_meta.clicked.connect(lambda: self.draw_msm_visual_metagram(station_combo))
h_ctrl.addWidget(btn_generate_meta)
h_ctrl.addStretch()
meta_ctrl_layout.addLayout(h_ctrl); meta_ctrl_layout.addStretch(); meta_ctrl_widget.hide()
right_panel.addWidget(canvas, stretch=10)
right_panel.addWidget(meta_ctrl_widget, stretch=10)
main_hbox.addLayout(right_panel, stretch=8)
info_panel = QLabel(canvas)
info_panel.setStyleSheet("background-color: rgba(17, 34, 64, 0.85); border: 2px solid #64FFDA; border-radius: 5px; padding: 5px; font-weight: bold; font-size: 12pt; color: #E0E0E0;"); info_panel.hide()
step = 1 if model_name in ["MSM", "ANAL", "MSM_GUID"] else 3
self.tab_ui[model_name] = {
'list': element_list, 'area_btns': area_btns, 'slider': slider, 'time_label': time_label, 'init_combo': init_combo,
'interval_label': interval_label, 'fig': fig, 'canvas': canvas, 'meta_ctrl': meta_ctrl_widget, 'ax': None, 'current_area_index': -1,
'dynamic_artists': [], 'colorbars': [], 'info_panel': info_panel, 'combo_speed': combo_speed, 'ticks_layout': ticks_layout,
'step': step,'last_val':0
}
if is_anal:
btn_play.hide(); btn_stop.hide(); combo_speed.hide()
btn_prev.hide(); btn_next.hide()
slider.hide()
for i in range(ticks_layout.count()):
w = ticks_layout.itemAt(i).widget()
if w: w.hide()
interval_label.setText("【現況解析のみ(FT=0)】")
def step_slider(val_change, m=model_name):
ui = self.tab_ui[m]
current_val = ui['slider'].value()
elem_name = ui['list'].currentItem().text()
s_step = ui['step']
if m == "GSM_JP":
ground_cloud_elems = ["地上気圧", "地上風向風速", "地上降水", "全雲量", "上層雲量", "中層雲量", "下層雲量", "地上3種重ね"]
threshold = 132 if elem_name in ground_cloud_elems else 84
if val_change > 0: s_step = 6 if current_val >= threshold else 3
else: s_step = 6 if current_val > threshold else 3
new_val = current_val + (s_step * val_change)
if new_val > ui['slider'].maximum(): new_val = ui['slider'].minimum()
if new_val < ui['slider'].minimum(): new_val = ui['slider'].maximum()
ui['slider'].setValue(new_val)
def get_exact_key(self, data_obj, exact_candidates):
for c in exact_candidates:
if c in data_obj.files: return c
for k in data_obj.files:
if k.lower() in [c.lower() for c in exact_candidates]: return k
return None
def get_precip_key(self, data):
return self.get_exact_key(data, ['precip', 'pr', 'tp', 'apcp', 'prate', 'rain', 'rn', 'pop', 'var_0_1_8', 'var_0_1_52'])
def get_w_key(self, data):
return self.get_exact_key(data, ['w', 'w700', 'vvel', 'vvel700', 'dzdt', 'dzdt700'])
def get_coords_for_data(self, data_obj, data_array):
if data_array is None: return None, None
ny = data_array.shape[0]; nx = data_array.shape[1] if data_array.ndim > 1 else len(data_array)
for k in data_obj.files:
if 'lon' in k.lower():
if (data_obj[k].ndim == 1 and data_obj[k].shape[0] == nx) or (data_obj[k].ndim == 2 and data_obj[k].shape == data_array.shape):
lat_k = k.replace('lon', 'lat').replace('Lon', 'Lat')
if lat_k in data_obj.files: return data_obj[k], data_obj[lat_k]
for lk in data_obj.files:
if 'lat' in lk.lower() and ((data_obj[lk].ndim == 1 and data_obj[lk].shape[0] == ny) or data_obj[lk].shape == data_array.shape):
return data_obj[k], data_obj[lk]
return None, None
def extract_val_with_key(self, data, key, stat_lon, stat_lat):
if key not in data.files: return np.nan
arr = data[key]
lon_arr, lat_arr = self.get_coords_for_data(data, arr)
if lon_arr is None or lat_arr is None:
lon_k = next((k for k in data.files if 'lon' in k.lower()), None)
lat_k = next((k for k in data.files if 'lat' in k.lower()), None)
if not lon_k or not lat_k: return np.nan
lon_arr, lat_arr = data[lon_k], data[lat_k]
if lon_arr.ndim == 1 and arr.shape == (len(lon_arr), len(lat_arr)): arr = arr.T
if lon_arr.ndim == 1 and arr.shape != (len(lat_arr), len(lon_arr)):
if arr.shape[1] > arr.shape[0] and len(lat_arr) > len(lon_arr): arr = arr.T
lon_arr = np.linspace(lon_arr.min(), lon_arr.max(), arr.shape[1])
lat_arr = np.linspace(lat_arr.min(), lat_arr.max(), arr.shape[0])
if lon_arr.ndim == 1: lon_2d, lat_2d = np.meshgrid(lon_arr, lat_arr)
else: lon_2d, lat_2d = lon_arr, lat_arr
dist = (lon_2d - stat_lon)**2 + (lat_2d - stat_lat)**2
sy, sx = np.unravel_index(np.argmin(dist), dist.shape)
if sy < arr.shape[0] and sx < arr.shape[1]:
v = arr[sy, sx]
if np.isnan(v) or v > 200000 or v < -10000: return np.nan
return float(v)
return np.nan
def extract_val_robust(self, data, keys, stat_lon, stat_lat):
k = self.get_exact_key(data, keys)
if not k:
for dk in data.files:
if any(x in dk.lower() for x in keys): k = dk; break
if not k: return np.nan
return self.extract_val_with_key(data, k, stat_lon, stat_lat)
def draw_msm_visual_metagram(self, station_combo):
it_str = self.model_init_times.get("MSM")
if not it_str:
QMessageBox.warning(self, "エラー", "データが存在しません。"); return
stat_name = station_combo.currentText(); stat_lat, stat_lon = station_combo.currentData()
dt_utc = datetime.strptime(it_str, "%Y%m%d%H%M%S")
dt_jst = dt_utc + timedelta(hours=9)
init_z_str = f"{dt_utc.strftime('%m%d%H')}Z"
title_time_str = f"{dt_utc.strftime('%m月%d日%H')}Z / {dt_jst.strftime('%H')}JST"
max_ft = self.tab_ui['MSM']['slider'].maximum()
fts_surf = list(range(0, max_ft + 1))
fts_prof = [ft for ft in fts_surf if ft % 3 == 0]
plevs = [1000, 975, 950, 925, 850, 700, 600, 500, 300]; y_coords = [0.0, 0.05, 0.1, 0.15, 0.25, 0.5, 0.625, 0.75, 1.0]
T_prof = np.full((len(plevs), len(fts_prof)), np.nan); RH_prof = np.full((len(plevs), len(fts_prof)), np.nan)
U_prof = np.full((len(plevs), len(fts_prof)), np.nan); V_prof = np.full((len(plevs), len(fts_prof)), np.nan)
# 【修正要領⑤】 SSIプロット配列初期化
SSI_850_series = np.full(len(fts_prof), np.nan)
SSI_700_series = np.full(len(fts_prof), np.nan) # 新規 850-700用
SSI_925_series = np.full(len(fts_prof), np.nan)
P_surf_series = np.full(len(fts_surf), np.nan); T_surf_series = np.full(len(fts_surf), np.nan); Pr_surf_series = np.full(len(fts_surf), np.nan)
# 500hPa渦度と700hPa上昇流の配列初期化
Vort_500_series = np.full(len(fts_prof), np.nan)
W_700_series = np.full(len(fts_prof), np.nan)
for c_idx, ft in enumerate(fts_surf):
gpv_file = os.path.join(self.cache_dir, f"MSM_{it_str}_FT{ft:02d}.npz")
if not os.path.exists(gpv_file): continue
try:
d = np.load(gpv_file)
P_surf_series[c_idx] = self.extract_val_robust(d, ['prmsl', 'mslet', 'msl', 'slp'], stat_lon, stat_lat)
if not np.isnan(P_surf_series[c_idx]) and P_surf_series[c_idx] > 2000: P_surf_series[c_idx] /= 100.0
T_surf_series[c_idx] = self.extract_val_robust(d, ['t2m', 'tmp2m', 'temp2m'], stat_lon, stat_lat)
if not np.isnan(T_surf_series[c_idx]) and T_surf_series[c_idx] > 100: T_surf_series[c_idx] -= 273.15
p_key = self.get_precip_key(d)
if p_key:
pr = self.extract_val_with_key(d, p_key, stat_lon, stat_lat)
Pr_surf_series[c_idx] = pr if not np.isnan(pr) else 0.0
else: Pr_surf_series[c_idx] = 0.0
d.close()
except Exception as e: logging.error(f"Error reading {gpv_file}: {e}")
for c_idx, ft in enumerate(fts_prof):
gpv_file = os.path.join(self.cache_dir, f"MSM_{it_str}_FT{ft:02d}.npz")
if not os.path.exists(gpv_file): continue
try:
d = np.load(gpv_file)
t2m = self.extract_val_robust(d, ['t2m', 'tmp2m', 'temp2m'], stat_lon, stat_lat)
if not np.isnan(t2m) and t2m > 100: t2m -= 273.15
T_prof[0, c_idx] = t2m
RH_prof[0, c_idx] = self.extract_val_robust(d, ['rh2m', 'r2m'], stat_lon, stat_lat)
U_prof[0, c_idx] = self.extract_val_robust(d, ['u10', '10u', 'u_10m'], stat_lon, stat_lat)
V_prof[0, c_idx] = self.extract_val_robust(d, ['v10', '10v', 'v_10m'], stat_lon, stat_lat)
h_map = {975:1, 950:2, 925:3, 850:4, 700:5, 600:6, 500:7, 300:8}
for p_lvl, idx in h_map.items():
t_val = self.extract_val_robust(d, [f't{p_lvl}', f'tmp{p_lvl}', f't_{p_lvl}', f'tmp_{p_lvl}'], stat_lon, stat_lat)
if not np.isnan(t_val) and t_val > 100: t_val -= 273.15
rh_val = self.extract_val_robust(d, [f'rh{p_lvl}', f'r{p_lvl}', f'rh_{p_lvl}', f'r_{p_lvl}'], stat_lon, stat_lat)
if np.isnan(rh_val) and not np.isnan(t_val):
t_td = self.extract_val_robust(d, [f't_td{p_lvl}', f'dew{p_lvl}'], stat_lon, stat_lat)
if not np.isnan(t_td): rh_val = rh_from_t_td(t_val, t_td)
T_prof[idx, c_idx] = t_val
RH_prof[idx, c_idx] = rh_val if not np.isnan(rh_val) else 0.0
U_prof[idx, c_idx] = self.extract_val_robust(d, [f'u{p_lvl}', f'u_{p_lvl}'], stat_lon, stat_lat)
V_prof[idx, c_idx] = self.extract_val_robust(d, [f'v{p_lvl}', f'v_{p_lvl}'], stat_lon, stat_lat)
t925 = T_prof[3, c_idx]; rh925 = RH_prof[3, c_idx]
t850 = T_prof[4, c_idx]; rh850 = RH_prof[4, c_idx]
t700 = T_prof[5, c_idx]; rh700 = RH_prof[5, c_idx]
t500 = T_prof[7, c_idx]
# 500hPa渦度の抽出と単位変換 (10^-5/s にスケーリング)
vort = self.extract_val_robust(d, ['vort500', 'vort', 'abs_vort'], stat_lon, stat_lat)
if not np.isnan(vort):
Vort_500_series[c_idx] = vort * 100000.0 if abs(vort) < 1.0 else vort
# 700hPa鉛直流(上昇流)の抽出と単位変換 (Pa/s -> hPa/h に変換)
w_val = self.extract_val_robust(d, ['w', 'w700', 'vvel', 'vvel700', 'dzdt', 'dzdt700'], stat_lon, stat_lat)
if not np.isnan(w_val):
W_700_series[c_idx] = w_val * 36.0 if abs(w_val) < 15.0 else w_val
# 【修正要領⑤】 MSM SSIの3系列高精度再計算ロジック
if not np.isnan(t850) and not np.isnan(rh850):
if not np.isnan(t500):
SSI_850_series[c_idx] = t500 - get_t_from_ept(calc_ept(t850, rh850, 850), 500)
if not np.isnan(t700):
SSI_700_series[c_idx] = t700 - get_t_from_ept(calc_ept(t850, rh850, 850), 700) # 850-700 式
if not np.isnan(t700) and not np.isnan(t925) and not np.isnan(rh925):
SSI_925_series[c_idx] = t700 - get_t_from_ept(calc_ept(t925, rh925, 925), 700)
d.close()
except: pass
# === 描画開始 ===
fig = Figure(figsize=(16, 10.6)); fig.patch.set_facecolor('white') # A4横サイズ比率に拡大
fig.subplots_adjust(left=0.06, right=0.96, top=0.88, bottom=0.08, hspace=0.65) # top余白を広げて被りを防止
fig.suptitle(f"MSM 図解メタグラム - {stat_name} (初期時: {title_time_str})", fontsize=15, weight='bold', y=0.96)
# ★★★ gridspec再構成 ★★★
gs = fig.add_gridspec(14, 1)
ax_cross = fig.add_subplot(gs[0:5, 0])
ax_t2m = fig.add_subplot(gs[5:7, 0], sharex=ax_cross) # ここに気温と気圧をまとめる
ax_slp = ax_t2m.twinx() # 右軸として気圧を設定
ax_dyn = fig.add_subplot(gs[7:9, 0], sharex=ax_cross) # 新設:渦度と上昇流
ax_pr = fig.add_subplot(gs[9:11, 0], sharex=ax_cross)
ax_ssi = fig.add_subplot(gs[11:14, 0], sharex=ax_cross)
# --- 断面図 ---
X_prof, Y_prof = np.meshgrid(fts_prof, y_coords)
cmap = mcolors.ListedColormap(['#E8F8F5', '#A3E4D7', '#48C9B0', '#117A65'])
bounds = [70, 80, 90, 95, 100]; norm = mcolors.BoundaryNorm(bounds, cmap.N)
cf = ax_cross.contourf(X_prof, Y_prof, RH_prof, levels=bounds, cmap=cmap, norm=norm, extend='max', alpha=0.6)
cs = ax_cross.contour(X_prof, Y_prof, T_prof, levels=np.arange(-60, 40, 6), colors='red', linewidths=1.0, alpha=0.7)
ax_cross.clabel(cs, inline=True, fontsize=9, fmt='%1.0f')
cs_zero = ax_cross.contour(X_prof, Y_prof, T_prof, levels=[0], colors='blue', linewidths=2.0)
ax_cross.clabel(cs_zero, inline=True, fontsize=10, fmt='0℃')
ax_cross.barbs(X_prof[:, ::1], Y_prof[:, ::1], U_prof[:, ::1], V_prof[:, ::1], length=4.5, color='black', linewidth=0.6)
ax_cross.set_yticks(y_coords); ax_cross.set_yticklabels([str(p) for p in plevs])
ax_cross.set_ylabel("気\n圧\n\n[hPa]", weight='bold', rotation=0, labelpad=10, va='center')
ax_cross.set_title("湿域・気温・風 断面図 (3時間毎)", loc='left', weight='bold')
# --- 気温(左)・気圧(右) 統合グラフ ---
ax_t2m.plot(fts_surf, T_surf_series, color='#E74C3C', marker='o', markersize=3, linestyle='-', linewidth=2, label="地上気温")
ax_t2m.set_ylabel("地\n上\n気\n温\n\n[℃]", color='#E74C3C', weight='bold', rotation=0, labelpad=10, va='center')
ax_slp.plot(fts_surf, P_surf_series, color='#2980B9', marker='s', markersize=3, linestyle='-', linewidth=2, label="海面気圧")
ax_slp.set_ylabel("海\n面\n気\n圧\n\n[hPa]", color='#2980B9', weight='bold', rotation=0, labelpad=15, va='center')
ax_t2m.legend(loc='upper left')
ax_slp.legend(loc='upper right')
# 上部X軸(JST)
ax_top_jst = ax_cross.twiny()
ax_top_jst.set_xlim(ax_cross.get_xlim())
ax_top_jst.set_xticks(range(0, fts_surf[-1]+1, 6))
jsts = [(dt_jst + timedelta(hours=x)).strftime("%d日%H時") for x in range(0, fts_surf[-1]+1, 6)]
ax_top_jst.set_xticklabels(jsts, fontsize=9, color='#333333', weight='bold')
# --- 渦度(左)・上昇流(右) 統合グラフ ---
color_vort = '#8E44AD'
ax_dyn.plot(fts_prof, Vort_500_series, color=color_vort, marker='^', markersize=4, linestyle='-', linewidth=2, label="500hPa 正渦度")
ax_dyn.set_ylabel("渦\n度\n\n[10⁻⁵/s]", color=color_vort, weight='bold', rotation=0, labelpad=15, va='center')
ax_dyn.tick_params(axis='y', labelcolor=color_vort)
ax_dyn.axhline(0, color='gray', linestyle='--', linewidth=1)
ax_updraft = ax_dyn.twinx()
color_updraft = '#D35400'
ax_updraft.plot(fts_prof, W_700_series, color=color_updraft, marker='v', markersize=4, linestyle='-', linewidth=2, label="700hPa 鉛直速度")
ax_updraft.set_ylabel("鉛\n直\n速\n度\n\n[hPa/h]", color=color_updraft, weight='bold', rotation=0, labelpad=25, va='center')
ax_updraft.tick_params(axis='y', labelcolor=color_updraft)
ax_updraft.invert_yaxis() # 上昇流(マイナス)が上に向かうように反転
ax_dyn.legend(loc='upper left')
ax_updraft.legend(loc='upper right')
# --- 降水グラフ ---
ax_pr.bar(fts_surf, Pr_surf_series, width=0.6, color='#3498DB', alpha=0.9, label="期間降水量 (1時間積算)")
ax_pr.set_ylabel("降\n水\n量\n\n[mm]", color='#3498DB', weight='bold', rotation=0, labelpad=10, va='center')
ax_pr.legend(loc='upper left')
ax_pr.axhline(0, color='gray', linewidth=1.0)
# --- SSIグラフ ---
# 【修正要領⑤】 凡例ラベルを 850-700 へ完全書換
ax_ssi.plot(fts_prof, SSI_850_series, color='#C0392B', marker='o', markersize=4, linestyle='-', linewidth=2, label="SSI (850-500)")
ax_ssi.plot(fts_prof, SSI_700_series, color='#E67E22', marker='^', markersize=4, linestyle='-', linewidth=2, label="SSI (850-700)")
ax_ssi.plot(fts_prof, SSI_925_series, color='#8E44AD', marker='s', markersize=4, linestyle='-', linewidth=2, label="SSI (925-700)")
ax_ssi.axhline(0, color='gray', linestyle=':', linewidth=1)
ax_ssi.set_ylabel("S\nS\nI\n\n[℃]", weight='bold', rotation=0, labelpad=10, va='center')
ax_ssi.set_xlabel("予報時間 (FT)", weight='bold', fontsize=12)
ax_ssi.legend(loc='lower left')
# --- 共通フォーマット ---
for ax in [ax_cross, ax_t2m, ax_dyn, ax_pr, ax_ssi]:
ax.set_xlim(fts_surf[0]-1, fts_surf[-1]+1)
ax.set_xticks(range(0, fts_surf[-1]+1, 6))
ax.grid(True, axis='x', color='gray', linestyle='--', alpha=0.5)
ax.grid(True, axis='y', color='gray', linestyle='--', alpha=0.3)
if ax != ax_ssi: plt.setp(ax.get_xticklabels(), visible=False)
# 最終レイアウト調整
plt.setp(ax_ssi.get_xticklabels(), rotation=45, ha='right', fontsize=11)
fig.align_ylabels([ax_cross, ax_t2m, ax_dyn, ax_pr, ax_ssi])
canvas = FigureCanvas(fig)
win = SingleImageWindow(self, f"MSM 図解メタグラム - {stat_name}", "MSM", stat_name, init_z_str, canvas)
win.exec()
def draw_gsm_visual_metagram(self, station_combo):
it_str = self.model_init_times.get("GSM_JP")
if not it_str:
QMessageBox.warning(self, "エラー", "データが存在しません。"); return
stat_name = station_combo.currentText(); stat_lat, stat_lon = station_combo.currentData()
dt_utc = datetime.strptime(it_str, "%Y%m%d%H%M%S")
dt_jst = dt_utc + timedelta(hours=9)
init_z_str = f"{dt_utc.strftime('%m%d%H')}Z"
title_time_str = f"{dt_utc.strftime('%m月%d日%H')}Z / {dt_jst.strftime('%H')}JST"
max_ft = self.tab_ui['GSM_JP']['slider'].maximum()
fts = list(range(0, max_ft + 1, 3))
plevs = [1000, 975, 950, 925, 850, 700, 600, 500, 300]; y_coords = [0.0, 0.05, 0.1, 0.15, 0.25, 0.5, 0.625, 0.75, 1.0]
T_prof = np.full((len(plevs), len(fts)), np.nan); RH_prof = np.full((len(plevs), len(fts)), np.nan)
U_prof = np.full((len(plevs), len(fts)), np.nan); V_prof = np.full((len(plevs), len(fts)), np.nan)
# 【修正要領⑤】 GSM SSI用配列初期化
SSI_850_series = np.full(len(fts), np.nan)
SSI_700_series = np.full(len(fts), np.nan) # 新規 850-700用
SSI_925_series = np.full(len(fts), np.nan)
P_surf_series = np.full(len(fts), np.nan); T_surf_series = np.full(len(fts), np.nan); Pr_surf_series = np.full(len(fts), np.nan)
# 500hPa渦度と700hPa上昇流の配列初期化
Vort_500_series = np.full(len(fts), np.nan)
W_700_series = np.full(len(fts), np.nan)
for c_idx, ft in enumerate(fts):
disp_ft = ft if ft <= 132 or ft % 6 == 0 else ft - (ft % 6)
gpv_file = os.path.join(self.cache_dir, f"GSM_JP_{it_str}_FT{disp_ft:02d}.npz")
if os.path.exists(gpv_file):
try:
d = np.load(gpv_file)
P_surf_series[c_idx] = self.extract_val_robust(d, ['prmsl', 'mslet', 'msl', 'slp'], stat_lon, stat_lat)
if not np.isnan(P_surf_series[c_idx]) and P_surf_series[c_idx] > 2000: P_surf_series[c_idx] /= 100.0
T_surf_series[c_idx] = self.extract_val_robust(d, ['t2m', 'tmp2m', 'temp2m'], stat_lon, stat_lat)
if not np.isnan(T_surf_series[c_idx]) and T_surf_series[c_idx] > 100: T_surf_series[c_idx] -= 273.15
p_key = self.get_precip_key(d)
if p_key:
pr = self.extract_val_with_key(d, p_key, stat_lon, stat_lat)
if disp_ft >= 3:
offset = 6 if disp_ft > 132 else 3
prev_file = os.path.join(self.cache_dir, f"GSM_JP_{it_str}_FT{disp_ft-offset:02d}.npz")
if os.path.exists(prev_file):
try:
with np.load(prev_file) as d_prev:
prev_p_key = self.get_precip_key(d_prev)
if prev_p_key:
prev_pr = self.extract_val_with_key(d_prev, prev_p_key, stat_lon, stat_lat)
if not np.isnan(prev_pr): pr = max(0, pr - prev_pr)
except: pass
Pr_surf_series[c_idx] = pr if not np.isnan(pr) else 0.0
else: Pr_surf_series[c_idx] = 0.0
T_prof[0, c_idx] = T_surf_series[c_idx] if not np.isnan(T_surf_series[c_idx]) else self.extract_val_robust(d, ['t2m', 'tmp2m', 'temp2m'], stat_lon, stat_lat)
RH_prof[0, c_idx] = self.extract_val_robust(d, ['rh2m', 'r2m'], stat_lon, stat_lat)
U_prof[0, c_idx] = self.extract_val_robust(d, ['u10', '10u', 'u_10m'], stat_lon, stat_lat)
V_prof[0, c_idx] = self.extract_val_robust(d, ['v10', '10v', 'v_10m'], stat_lon, stat_lat)
h_map = {975:1, 950:2, 925:3, 850:4, 700:5, 600:6, 500:7, 300:8}
for p_lvl, idx in h_map.items():
t_val = self.extract_val_robust(d, [f't{p_lvl}', f'tmp{p_lvl}', f't_{p_lvl}'], stat_lon, stat_lat)
if not np.isnan(t_val) and t_val > 100: t_val -= 273.15
rh_val = self.extract_val_robust(d, [f'rh{p_lvl}', f'r{p_lvl}', f'rh_{p_lvl}'], stat_lon, stat_lat)
if np.isnan(rh_val) and not np.isnan(t_val):
t_td = self.extract_val_robust(d, [f't_td{p_lvl}', f'dew{p_lvl}'], stat_lon, stat_lat)
if not np.isnan(t_td): rh_val = rh_from_t_td(t_val, t_td)
T_prof[idx, c_idx] = t_val
RH_prof[idx, c_idx] = rh_val if not np.isnan(rh_val) else 0.0
U_prof[idx, c_idx] = self.extract_val_robust(d, [f'u{p_lvl}', f'u_{p_lvl}'], stat_lon, stat_lat)
V_prof[idx, c_idx] = self.extract_val_robust(d, [f'v{p_lvl}', f'v_{p_lvl}'], stat_lon, stat_lat)
t925 = T_prof[3, c_idx]; rh925 = RH_prof[3, c_idx]
t850 = T_prof[4, c_idx]; rh850 = RH_prof[4, c_idx]
t700 = T_prof[5, c_idx]; rh700 = RH_prof[5, c_idx]
t500 = T_prof[7, c_idx]
# 500hPa渦度の抽出と単位変換 (10^-5/s にスケーリング)
vort = self.extract_val_robust(d, ['vort500', 'vort', 'abs_vort'], stat_lon, stat_lat)
if not np.isnan(vort):
Vort_500_series[c_idx] = vort * 100000.0 if abs(vort) < 1.0 else vort
# 700hPa鉛直流(上昇流)の抽出と単位変換 (Pa/s -> hPa/h に変換)
w_val = self.extract_val_robust(d, ['w', 'w700', 'vvel', 'vvel700', 'dzdt', 'dzdt700'], stat_lon, stat_lat)
if not np.isnan(w_val):
W_700_series[c_idx] = w_val * 36.0 if abs(w_val) < 15.0 else w_val
# 【修正要領⑤】 GSM SSIの3系列高精度再計算ロジック
if not np.isnan(t850) and not np.isnan(rh850):
if not np.isnan(t500):
SSI_850_series[c_idx] = t500 - get_t_from_ept(calc_ept(t850, rh850, 850), 500)
if not np.isnan(t700):
SSI_700_series[c_idx] = t700 - get_t_from_ept(calc_ept(t850, rh850, 850), 700) # 850-700 式
if not np.isnan(t700) and not np.isnan(t925) and not np.isnan(rh925):
SSI_925_series[c_idx] = t700 - get_t_from_ept(calc_ept(t925, rh925, 925), 700)
d.close()
except: pass
# === 描画開始 ===
fig = Figure(figsize=(16, 10.6)); fig.patch.set_facecolor('white') # A4横サイズ比率に拡大
fig.subplots_adjust(left=0.06, right=0.96, top=0.88, bottom=0.08, hspace=0.65) # top余白を広げて被りを防止
fig.suptitle(f"GSM_JP 図解メタグラム - {stat_name} (初期時: {title_time_str})", fontsize=15, weight='bold', y=0.96)
# ★★★ gridspec再構成 ★★★
gs = fig.add_gridspec(14, 1) # GSMも高さを14分割に変更して余裕を持たせる
ax_cross = fig.add_subplot(gs[0:5, 0])
ax_t2m = fig.add_subplot(gs[5:7, 0], sharex=ax_cross) # ここに気温と気圧をまとめる
ax_slp = ax_t2m.twinx() # 右軸として気圧を設定
ax_dyn = fig.add_subplot(gs[7:9, 0], sharex=ax_cross) # 新設:渦度と上昇流
ax_pr = fig.add_subplot(gs[9:11, 0], sharex=ax_cross)
ax_ssi = fig.add_subplot(gs[11:14, 0], sharex=ax_cross)
# --- 断面図 ---
X, Y = np.meshgrid(fts, y_coords)
cmap = mcolors.ListedColormap(['#E8F8F5', '#A3E4D7', '#48C9B0', '#117A65'])
bounds = [70, 80, 90, 95, 100]; norm = mcolors.BoundaryNorm(bounds, cmap.N)
cf = ax_cross.contourf(X, Y, RH_prof, levels=bounds, cmap=cmap, norm=norm, extend='max', alpha=0.6)
cs = ax_cross.contour(X, Y, T_prof, levels=np.arange(-60, 40, 6), colors='red', linewidths=1.0, alpha=0.7)
ax_cross.clabel(cs, inline=True, fontsize=9, fmt='%1.0f')
cs_zero = ax_cross.contour(X, Y, T_prof, levels=[0], colors='blue', linewidths=2.0)
ax_cross.clabel(cs_zero, inline=True, fontsize=10, fmt='0℃')
ax_cross.barbs(X[:, ::1], Y[:, ::1], U_prof[:, ::1], V_prof[:, ::1], length=4.5, color='black', linewidth=0.6)
ax_cross.set_yticks(y_coords); ax_cross.set_yticklabels([str(p) for p in plevs])
ax_cross.set_ylabel("気\n圧\n\n[hPa]", weight='bold', rotation=0, labelpad=10, va='center')
ax_cross.set_title("湿域・気温・風 断面図 (※132h以降は全要素6時間間隔で補間展開)", loc='left', weight='bold')
# --- 気温(左)・気圧(右) 統合グラフ ---
ax_t2m.plot(fts, T_surf_series, color='#E74C3C', marker='o', markersize=4, linestyle='-', linewidth=2, label="地上気温")
ax_t2m.set_ylabel("地\n上\n気\n温\n\n[℃]", color='#E74C3C', weight='bold', rotation=0, labelpad=10, va='center')
ax_slp.plot(fts, P_surf_series, color='#2980B9', marker='s', markersize=4, linestyle='-', linewidth=2, label="海面気圧")
ax_slp.set_ylabel("海\n面\n気\n圧\n\n[hPa]", color='#2980B9', weight='bold', rotation=0, labelpad=15, va='center')
ax_t2m.legend(loc='upper left')
ax_slp.legend(loc='upper right')
# 上部X軸(JST)
ax_top_jst = ax_cross.twiny()
ax_top_jst.set_xlim(ax_cross.get_xlim())
ax_top_jst.set_xticks(range(0, fts[-1]+1, 12))
jsts = [(dt_jst + timedelta(hours=x)).strftime("%d日%H時") for x in range(0, fts[-1]+1, 12)]
ax_top_jst.set_xticklabels(jsts, fontsize=9, color='#333333', weight='bold')
# --- 渦度(左)・上昇流(右) 統合グラフ ---
color_vort = '#8E44AD'
ax_dyn.plot(fts, Vort_500_series, color=color_vort, marker='^', markersize=4, linestyle='-', linewidth=2, label="500hPa 正渦度")
ax_dyn.set_ylabel("渦\n度\n\n[10⁻⁵/s]", color=color_vort, weight='bold', rotation=0, labelpad=15, va='center')
ax_dyn.tick_params(axis='y', labelcolor=color_vort)
ax_dyn.axhline(0, color='gray', linestyle='--', linewidth=1)
ax_updraft = ax_dyn.twinx()
color_updraft = '#D35400'
ax_updraft.plot(fts, W_700_series, color=color_updraft, marker='v', markersize=4, linestyle='-', linewidth=2, label="700hPa 鉛直速度")
ax_updraft.set_ylabel("鉛\n直\n速\n度\n\n[hPa/h]", color=color_updraft, weight='bold', rotation=0, labelpad=25, va='center')
ax_updraft.tick_params(axis='y', labelcolor=color_updraft)
ax_updraft.invert_yaxis() # 上昇流(マイナス)が上に向かうように反転
ax_dyn.legend(loc='upper left')
ax_updraft.legend(loc='upper right')
# --- 降水グラフ ---
ax_pr.bar(fts, Pr_surf_series, width=1.8, color='#3498DB', alpha=0.9, label="期間降水量 (差分)")
ax_pr.set_ylabel("降\n水\n量\n\n[mm]", color='#3498DB', weight='bold', rotation=0, labelpad=10, va='center')
ax_pr.legend(loc='upper left')
ax_pr.axhline(0, color='gray', linewidth=1.0)
# --- SSIグラフ ---
# 【修正要領⑤】 凡例ラベルを 850-700 へ完全書換
ax_ssi.plot(fts, SSI_850_series, color='#C0392B', marker='o', markersize=4, linestyle='-', linewidth=2, label="SSI (850-500)")
ax_ssi.plot(fts, SSI_700_series, color='#E67E22', marker='^', markersize=4, linestyle='-', linewidth=2, label="SSI (850-700)")
ax_ssi.plot(fts, SSI_925_series, color='#8E44AD', marker='s', markersize=4, linestyle='-', linewidth=2, label="SSI (925-700)")
ax_ssi.axhline(0, color='gray', linestyle=':', linewidth=1)
ax_ssi.set_ylabel("S\nS\nI\n\n[℃]", weight='bold', rotation=0, labelpad=10, va='center')
ax_ssi.set_xlabel("予報時間 (FT)", weight='bold', fontsize=12)
ax_ssi.legend(loc='lower left')
# --- 共通フォーマット ---
for ax in [ax_cross, ax_t2m, ax_dyn, ax_pr, ax_ssi]:
ax.set_xlim(fts[0]-1, fts[-1]+1)
ax.set_xticks(range(0, fts[-1]+1, 12))
ax.grid(True, axis='x', color='gray', linestyle='--', alpha=0.5)
ax.grid(True, axis='y', color='gray', linestyle='--', alpha=0.3)
if ax != ax_ssi: plt.setp(ax.get_xticklabels(), visible=False)
# 最終レイアウト調整
plt.setp(ax_ssi.get_xticklabels(), rotation=45, ha='right', fontsize=11)
fig.align_ylabels([ax_cross, ax_t2m, ax_dyn, ax_pr, ax_ssi])
canvas = FigureCanvas(fig)
win = SingleImageWindow(self, f"GSM_JP 図解メタグラム - {stat_name}", "GSM_JP", stat_name, init_z_str, canvas)
win.exec()
def draw_guidance_metagram(self, station_combo, model_name):
it_str = self.model_init_times.get(model_name)
if not it_str:
QMessageBox.warning(self, "エラー", "データが存在しません。"); return
stat_name = station_combo.currentText(); stat_lat, stat_lon = station_combo.currentData()
dt_utc = datetime.strptime(it_str, "%Y%m%d%H%M%S")
fig = Figure(figsize=(11.69, 8.27)); fig.patch.set_facecolor('white')
ax = fig.add_subplot(111); ax.axis('off')
table_data = []
headers = ["FT", "日時(JST)", "卓越天気", "降水量", "降雪量", "発雷確率", "気温(℃)", "風向", "風速(m/s)"]
max_ft = self.tab_ui[model_name]['slider'].maximum()
fts = list(range(0, max_ft + 1, 3))
gpv_model = "MSM" if model_name == "MSM_GUID" else "GSM_JP"
gpv_it_str = self.model_init_times.get(gpv_model)
for ft in fts:
target_jst = dt_utc + timedelta(hours=9+ft)
row = [f"FT={ft:02d}", target_jst.strftime("%m/%d %H:00"), "-", "-", "-", "-", "-", "-", "-"]
guid_file = os.path.join(self.cache_dir, f"{model_name}_{it_str}_FT{ft:02d}.npz")
if os.path.exists(guid_file):
try:
with np.load(guid_file) as d:
pop = self.extract_val_robust(d, ['precip', 'tp', 'apcp', 'pop', 'var_0_1_8', 'var_0_1_52'], stat_lon, stat_lat)
pos = self.extract_val_robust(d, ['snow', 'weasd', 'snod', 'var_0_1_11', 'var_0_1_13', 'var_0_1_29'], stat_lon, stat_lat)
pol = self.extract_val_robust(d, ['thund', 'pol', 'lig', 'thunder', 'tstm', 'var_0_19_193'], stat_lon, stat_lat)
wx = self.extract_val_robust(d, ['wea', 'weather', 'wx', 'var_-1_-1_-1', 'var_0_19_192'], stat_lon, stat_lat)
if not np.isnan(wx): row[2] = {1:"晴", 2:"曇", 3:"雨", 4:"雪"}.get(int(wx), "不明")
if not np.isnan(pop): row[3] = f"{pop:.1f} mm"
if not np.isnan(pos): row[4] = f"{pos:.1f} cm"
if not np.isnan(pol): row[5] = f"{pol:.0f} %"
except: pass
# 【修正要領①】 MSMガイダンスメタグラム 気温・風出力を絶対保証する超頑健サーチ結合
gpv_ft = ft
if gpv_model == "GSM_JP" and ft > 132: gpv_ft = ft - (ft % 6) if ft % 6 != 0 else ft
gpv_file = os.path.join(self.cache_dir, f"{gpv_model}_{gpv_it_str}_FT{gpv_ft:02d}.npz")
if not os.path.exists(gpv_file): gpv_file = os.path.join(self.cache_dir, f"{gpv_model}_{it_str}_FT{gpv_ft:02d}.npz")
if not os.path.exists(gpv_file):
p_files = glob.glob(os.path.join(self.cache_dir, f"{gpv_model}_*_FT{gpv_ft:02d}.npz"))
if p_files: gpv_file = sorted(p_files, key=lambda x: abs(int(re.search(r'_(\d{14})_', x).group(1)) - int(it_str)))[0]
if os.path.exists(gpv_file):
try:
with np.load(gpv_file) as d_gpv:
t = self.extract_val_robust(d_gpv, ['t2m', 'tmp2m', 'temp2m'], stat_lon, stat_lat)
if not np.isnan(t): row[6] = f"{(t - 273.15 if t > 100 else t):.1f}"
u = self.extract_val_robust(d_gpv, ['u10', '10u', 'u_10m'], stat_lon, stat_lat)
v = self.extract_val_robust(d_gpv, ['v10', '10v', 'v_10m'], stat_lon, stat_lat)
if not np.isnan(u) and not np.isnan(v):
row[8] = f"{np.hypot(u, v):.1f}"
deg = (np.degrees(np.arctan2(u, v)) + 180) % 360
dirs = ["北", "北北東", "北東", "東北東", "東", "東南東", "南東", "南南東", "南", "南南西", "南西", "西南西", "西", "西北西", "北西", "北北西", "北"]
row[7] = dirs[int(round(deg / 22.5)) % 16]
except: pass
table_data.append(row)
table = ax.table(cellText=table_data, colLabels=headers, loc='center', cellLoc='center')
table.auto_set_font_size(False); table.set_fontsize(10); table.scale(1, 1.4)
for j in range(len(headers)):
cell = table[0, j]; cell.set_facecolor('#1D3557'); cell.get_text().set_color('white'); cell.get_text().set_weight('bold')
fig.suptitle(f"{stat_name} 統合ガイダンス時系列表 (初期時: {dt_utc.strftime('%m/%d %H:00Z')})", fontsize=15, weight='bold', y=0.96)
canvas = FigureCanvas(fig)
win = SingleImageWindow(self, f"GUID_{stat_name}", model_name, stat_name, dt_utc.strftime("%m%d%H") + "Z", canvas)
win.exec()
def plot_contour(self, ax, lon, lat, data, ui, levels, color, extent, linewidths=1.5):
lon_c, lat_c, data_c = self.crop_data(lon, lat, data, extent)
if lon_c.size == 0 or lat_c.size == 0 or data_c.size == 0: return None
cs = ax.contour(lon_c, lat_c, data_c, levels=levels, colors=color, linewidths=linewidths, transform=ccrs.PlateCarree(), zorder=7)
ui['dynamic_artists'].append(cs); ax.clabel(cs, inline=True, fontsize=11, fmt='%1.0f'); return cs
def plot_contourf(self, ax, lon, lat, data, ui, levels, cmap_or_colors, extent, extend='both', alpha=0.6, norm=None):
lon_c, lat_c, data_c = self.crop_data(lon, lat, data, extent)
if lon_c.size == 0 or lat_c.size == 0 or data_c.size == 0: return None
kwargs = {'levels': levels, 'extend': extend, 'transform': ccrs.PlateCarree(), 'alpha': alpha, 'zorder': 6}
if isinstance(cmap_or_colors, str) or isinstance(cmap_or_colors, mcolors.Colormap): kwargs['cmap'] = cmap_or_colors
else: kwargs['colors'] = cmap_or_colors
if norm is not None: kwargs['norm'] = norm
cf = ax.contourf(lon_c, lat_c, data_c, **kwargs)
ui['dynamic_artists'].append(cf); return cf
def plot_blocky(self, ax, lon, lat, data, ui, cmap_or_colors, extent, norm=None, alpha=0.85):
lon_c, lat_c, data_c = self.crop_data(lon, lat, data, extent)
if lon_c.size == 0 or lat_c.size == 0 or data_c.size == 0: return None
if lon_c.ndim == 1: lon_c, lat_c = np.meshgrid(lon_c, lat_c)
kwargs = {'transform': ccrs.PlateCarree(), 'alpha': alpha, 'zorder': 6, 'shading': 'nearest'}
if isinstance(cmap_or_colors, str) or isinstance(cmap_or_colors, mcolors.Colormap): kwargs['cmap'] = cmap_or_colors
else: kwargs['cmap'] = mcolors.ListedColormap(cmap_or_colors)
if norm is not None: kwargs['norm'] = norm
pm = ax.pcolormesh(lon_c, lat_c, data_c, **kwargs)
ui['dynamic_artists'].append(pm); return pm
def plot_barbs(self, ax, lon, lat, u, v, ui, area_index, extent, model_name=""):
lon_c, lat_c, u_c = self.crop_data(lon, lat, u, extent)
_, _, v_c = self.crop_data(lon, lat, v, extent)
if lon_c.size == 0 or lat_c.size == 0 or u_c.size == 0 or v_c.size == 0: return None
u_c = np.nan_to_num(u_c, nan=0.0); v_c = np.nan_to_num(v_c, nan=0.0)
if lon_c.ndim == 1: lon_2d, lat_2d = np.meshgrid(lon_c, lat_c)
else: lon_2d, lat_2d = lon_c, lat_c
if lon_2d.shape != u_c.shape: return None
if area_index == 0: skip = 20
elif area_index in [2, 3, 4]: skip = 1 # 局地表示・新分割は高密度で出力
elif "MSM" in model_name or "ANAL" in model_name: skip = 4
else: skip = 3
q = ax.barbs(lon_2d[::skip,::skip], lat_2d[::skip,::skip], u_c[::skip,::skip], v_c[::skip,::skip], length=5.5, color='black', transform=ccrs.PlateCarree(), zorder=10)
ui['dynamic_artists'].append(q)
def show_error_msg(self, ax, ui, msg):
wrapped_msg = "\n".join(textwrap.wrap(msg, width=50))
txt = ax.text(0.5, 0.5, wrapped_msg, transform=ax.transAxes, color='red', fontsize=12, ha='center', va='center', weight='bold', bbox=dict(facecolor='white', alpha=0.9, edgecolor='red'))
ui['dynamic_artists'].append(txt)
def draw_guidance(self, ax, ui, data, elem_name, val, extent, area_index, model_name, it_str):
drawn_flag = False; info_text = ""
def _cl(arr, minv, maxv, fill=np.nan):
if arr is None: return None
a = np.array(arr, dtype=float)
if not np.isnan(fill): a = np.nan_to_num(a, nan=fill, posinf=fill, neginf=fill)
else: a[np.isinf(a)] = np.nan
return np.clip(a, minv, maxv)
def add_vertical_colorbar(mappable, label_text):
cax = ui['fig'].add_axes([0.93, 0.15, 0.018, 0.7])
cb = ui['fig'].colorbar(mappable, cax=cax, orientation='vertical')
cb.set_label(label_text, fontsize=12, weight='bold'); ui['colorbars'].append(cb)
if "降水量" in elem_name or "降雪量" in elem_name:
accum_hours = 3
if "6時間" in elem_name: accum_hours = 6
elif "12時間" in elem_name: accum_hours = 12
elif "24時間" in elem_name: accum_hours = 24
accum_val = None; valid_data = False; lon_c = None; lat_c = None
for hr_offset in range(0, accum_hours, 3):
target_ft = val - hr_offset
if target_ft <= 0: continue
d_curr = None
if target_ft == val: d_curr = data
else:
target_file = os.path.join(self.cache_dir, f"{model_name}_{it_str}_FT{target_ft:02d}.npz")
if os.path.exists(target_file):
try: d_curr = np.load(target_file)
except: pass
if d_curr is not None:
if "降水量" in elem_name: key = self.get_exact_key(d_curr, ['precip', 'tp', 'apcp', 'pop', 'var_0_1_8', 'var_0_1_52'])
else: key = self.get_exact_key(d_curr, ['snow', 'weasd', 'snod', 'var_0_1_11', 'var_0_1_13', 'var_0_1_29', 'asnow'])
if key:
v = _cl(d_curr[key], 0, 1000, 0.0)
if accum_val is None:
accum_val = v.copy()
lon_c, lat_c = self.get_coords_for_data(d_curr, v)
valid_data = True
else:
accum_val += v
if target_ft != val: d_curr.close()
if valid_data and accum_val is not None:
accum_val_masked = np.ma.masked_less(accum_val, 0.1)
levels = [0.1, 1, 5, 10, 20, 30, 50, 80]
if "降水量" in elem_name:
cmap = mcolors.ListedColormap(['#A0E8A0', '#D4F05A', '#FFFF00', '#FFA500', '#FF5500', '#FF0000', '#FF00FF'])
cmap.set_under('none'); cmap.set_over('#800080')
label_text = f'降水量 ({accum_hours}時間積算) [mm]'
else:
cmap = mcolors.ListedColormap(['#E8EAF6', '#C5CAE9', '#9FA8DA', '#7986CB', '#5C6BC0', '#3F51B5', '#303F9F'])
cmap.set_under('none'); cmap.set_over('#1A237E')
label_text = f'降雪量 ({accum_hours}時間積算) [cm等]'
norm = mcolors.BoundaryNorm(levels, cmap.N)
pm = self.plot_blocky(ax, lon_c, lat_c, accum_val_masked, ui, cmap, extent, norm=norm, alpha=0.45)
if pm:
add_vertical_colorbar(pm, label_text)
drawn_flag = True
lc, lac, dc = self.crop_data(lon_c, lat_c, accum_val, extent)
if lc.size > 0:
l2, la2 = np.meshgrid(lc, lac) if lc.ndim == 1 else (lc, lac)
min_lon, max_lon, min_lat, max_lat = extent
# ① 札幌近郊(または恵庭・石狩分割画面)の場合:元の格子点四角枠+内挿値埋め処理を復活
if area_index in [3, 4]:
try:
zoom_factor = 8 if "GSM" in model_name else 2
dc_z = ndimage.zoom(dc, zoom_factor, order=1)
if lc.ndim == 1:
lc_z = ndimage.zoom(lc, zoom_factor, order=1)
lac_z = ndimage.zoom(lac, zoom_factor, order=1)
l2_z, la2_z = np.meshgrid(lc_z, lac_z)
else:
l2_z = ndimage.zoom(l2, zoom_factor, order=1)
la2_z = ndimage.zoom(la2, zoom_factor, order=1)
# 内挿された間の値(白抜き・枠なし)
for r in range(dc_z.shape[0]):
for c in range(dc_z.shape[1]):
if r % zoom_factor == 0 and c % zoom_factor == 0: continue
lon_v, lat_v, val_p = l2_z[r, c], la2_z[r, c], dc_z[r, c]
if val_p >= 0.1 and min_lon <= lon_v <= max_lon and min_lat <= lat_v <= max_lat:
txt = ax.text(lon_v, lat_v, f"{val_p:.1f}", color='#1f77b4', fontsize=10, ha='center', va='center', transform=ccrs.PlateCarree(), zorder=12, weight='bold', clip_on=True, path_effects=[pe.withStroke(linewidth=2, foreground='white')])
ui['dynamic_artists'].append(txt)
# 本来の予想された格子点値(はっきりした白四角枠つき)
for r in range(dc.shape[0]):
for c in range(dc.shape[1]):
lon_v, lat_v, val_p = l2[r, c], la2[r, c], dc[r, c]
if val_p >= 0.1 and min_lon <= lon_v <= max_lon and min_lat <= lat_v <= max_lat:
txt = ax.text(lon_v, lat_v, f"{val_p:.1f}", color='black', fontsize=11, ha='center', va='center', transform=ccrs.PlateCarree(), zorder=13, weight='bold', bbox=dict(facecolor='white', edgecolor='black', boxstyle='square,pad=0.2', alpha=0.9), clip_on=True)
ui['dynamic_artists'].append(txt)
except Exception as e:
print(f"札幌近郊 内挿・格子点描画エラー: {e}")
# ② 上川地方(area_index == 2)の場合:前回の「背景と凡例が見える薄字透過処理」を適用
elif area_index == 2:
try:
zoom_factor = 4 if "GSM" in model_name else 1
dc_z = ndimage.zoom(dc, zoom_factor, order=1)
l2_z = ndimage.zoom(l2, zoom_factor, order=1)
la2_z = ndimage.zoom(la2, zoom_factor, order=1)
for r in range(dc_z.shape[0]):
for c in range(dc_z.shape[1]):
if r % zoom_factor == 0 and c % zoom_factor == 0:
lon_v, lat_v, val_p = l2_z[r, c], la2_z[r, c], dc_z[r, c]
if val_p >= 0.1 and min_lon <= lon_v <= max_lon and min_lat <= lat_v <= max_lat:
txt = ax.text(lon_v, lat_v, f"{val_p:.1f}", color='#333333', fontsize=9, ha='center', va='center', transform=ccrs.PlateCarree(), zorder=13, weight='normal', path_effects=[pe.withStroke(linewidth=1.8, foreground=(1.0, 1.0, 1.0, 0.75))], clip_on=True)
ui['dynamic_artists'].append(txt)
except Exception as e:
print(f"上川地方 透過文字描画エラー: {e}")
elif "発雷" in elem_name:
key = None
for k in data.files:
if any(x in k.lower() for x in ['thund', 'pol', 'lig', 'ts', 'tstm', 'var_0_19_193']): key = k; break
if key:
try:
val_data = _cl(data[key], 0, 100, np.nan)
lon_c, lat_c = self.get_coords_for_data(data, val_data)
if lon_c is None or lat_c is None:
lon_k = next((k for k in data.files if 'lon' in k.lower()), None)
lat_k = next((k for k in data.files if 'lat' in k.lower()), None)
if lon_k and lat_k: lon_c, lat_c = data[lon_k], data[lat_k]
if lon_c is not None and lat_c is not None:
if lon_c.ndim == 1 and val_data.shape == (len(lon_c), len(lat_c)): val_data = val_data.T
if lon_c.ndim == 1 and val_data.shape != (len(lat_c), len(lon_c)):
if val_data.shape[1] > val_data.shape[0] and len(lat_c) > len(lon_c): val_data = val_data.T
lon_c = np.linspace(lon_c.min(), lon_c.max(), val_data.shape[1])
lat_c = np.linspace(lat_c.min(), lat_c.max(), val_data.shape[0])
val_masked = np.ma.masked_less(val_data, 1.0)
levels = [1, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
cmap = mcolors.ListedColormap(['#F7DC6F', '#F4D03F', '#F5B041', '#EB984E', '#DC7633', '#E74C3C', '#C0392B', '#9B59B6', '#8E44AD', '#1F618D'])
cmap.set_under('none'); cmap.set_bad('none')
norm = mcolors.BoundaryNorm(levels, cmap.N)
pm = self.plot_blocky(ax, lon_c, lat_c, val_masked, ui, cmap, extent, norm=norm, alpha=0.6)
if pm:
add_vertical_colorbar(pm, '発雷確率凡例 (1%~100%) [%]')
info_text = self.get_local_info(elem_name, lon_c, lat_c, val_data, area_index=area_index, model_name=model_name)
drawn_flag = True
# 【修正④】 発雷確率: メッシュ上に1%以上の数値をオーバーレイ描画
lc, lac, dc = self.crop_data(lon_c, lat_c, val_data, extent)
if lc.size > 0:
l2, la2 = np.meshgrid(lc, lac) if lc.ndim == 1 else (lc, lac)
min_lon, max_lon, min_lat, max_lat = extent
for r in range(dc.shape[0]):
for c in range(dc.shape[1]):
lon_v, lat_v, val_p = l2[r, c], la2[r, c], dc[r, c]
if val_p >= 1.0 and min_lon <= lon_v <= max_lon and min_lat <= lat_v <= max_lat:
txt = ax.text(lon_v, lat_v, f"{val_p:.0f}", color='black', fontsize=9, ha='center', va='center', transform=ccrs.PlateCarree(), zorder=13, weight='bold')
ui['dynamic_artists'].append(txt)
except Exception as e: print(f"Lightning graphic render exception: {e}")
elif "天気" in elem_name:
key = None
for k in data.files:
if any(x in k.lower() for x in ['wea', 'wx', 'var_-1_-1_-1', 'var_0_19_192']): key = k; break
if key:
val_data = _cl(data[key], 0, 10, np.nan)
lon_c, lat_c = self.get_coords_for_data(data, val_data)
if lon_c is None or lat_c is None:
lon_k = next((k for k in data.files if 'lon' in k.lower()), None)
lat_k = next((k for k in data.files if 'lat' in k.lower()), None)
if lon_k and lat_k: lon_c, lat_c = data[lon_k], data[lat_k]
if lon_c is not None and lat_c is not None:
if lon_c.ndim == 1 and val_data.shape == (len(lon_c), len(lat_c)):
val_data = val_data.T
# 【修正】1(晴), 2(曇), 3(雨), 4(雪) の範囲外のデータ(海上の欠測値など)を完全にマスクして透明にする
val_masked = np.ma.masked_outside(val_data, 0.5, 4.5)
levels = [0.5, 1.5, 2.5, 3.5, 4.5]
colors = ['#FF9800', '#B0C4DE', '#1E90FF', '#FFFFFF']
cmap_wx = mcolors.ListedColormap(colors)
cmap_wx.set_under('none')
cmap_wx.set_over('none')
cmap_wx.set_bad('none')
norm_wx = mcolors.BoundaryNorm(levels, cmap_wx.N)
# val_data ではなく val_masked を描画に渡す
pm = self.plot_blocky(ax, lon_c, lat_c, val_masked, ui, cmap_wx, extent, norm=norm_wx, alpha=0.6)
if pm:
add_vertical_colorbar(pm, '卓越天気 (1:晴 2:曇 3:雨 4:雪)')
# 情報取得用にはオリジナルの val_data を渡す
info_text = self.get_local_info(elem_name, lon_c, lat_c, val_data, area_index=area_index, model_name=model_name)
drawn_flag = True
return drawn_flag, info_text
def extract_2d_array(self, data, exact_keys, target_level=None, fuzzy_keys=None):
for k in exact_keys:
if k in data.files:
arr = np.squeeze(data[k])
if arr.ndim == 2: return arr
if fuzzy_keys:
for k in data.files:
kl = k.lower()
if any(fk in kl for fk in fuzzy_keys) and not any(ex in kl for ex in ['lon', 'lat', 'time', 'valid']):
arr = np.squeeze(data[k])
if arr.ndim == 2: return arr
if arr.ndim == 3 and target_level is not None:
levels_16 = [1000, 925, 850, 700, 500, 400, 300, 250, 200, 150, 100, 70, 50, 30, 20, 10]
levels_msm = [1000, 975, 950, 925, 900, 850, 800, 700, 600, 500, 400, 300]
axis = 0 if arr.shape[0] < arr.shape[1] else 2
sz = arr.shape[axis]
if sz == len(levels_16) and target_level in levels_16:
return arr[levels_16.index(target_level),:,:] if axis == 0 else arr[:,:,levels_16.index(target_level)]
elif sz == len(levels_msm) and target_level in levels_msm:
return arr[levels_msm.index(target_level),:,:] if axis == 0 else arr[:,:,levels_msm.index(target_level)]
return None
def draw_frame(self, model_name, val):
ui = self.tab_ui[model_name]; it_str = self.model_init_times.get(model_name)
area_index = next(i for i, btn in enumerate(ui['area_btns']) if btn.isChecked())
if ui['ax'] is None or ui['current_area_index'] != area_index:
ui['ax'] = self.init_map(ui['fig'], area_index, model_name)
ui['current_area_index'] = area_index
axes_list = ui['ax'] if isinstance(ui['ax'], list) else [ui['ax']]
if not it_str:
self.show_error_msg(axes_list[0], ui, f"{model_name} のデータがありません。")
ui['canvas'].draw_idle(); return
elem_name = ui['list'].currentItem().text()
display_val = val; fallback_msg = ""
if model_name == "GSM_JP":
ground_cloud_elems = ["地上気圧", "地上風向風速", "地上降水", "全雲量", "上層雲量", "中層雲量", "下層雲量", "地上3種重ね"]
threshold = 132 if elem_name in ground_cloud_elems else 84
if val > threshold and val % 6 != 0:
display_val = val - (val % 6); fallback_msg = f" (※FT={display_val}h 代替表示)"
elif model_name == "MSM" and "hpa" in elem_name.lower() and val % 3 != 0:
display_val = (val // 3) * 3; fallback_msg = f" (※FT={display_val}h 代替表示)"
if "GUID" in model_name and "気温" not in elem_name and "風" not in elem_name and val % 3 != 0:
display_val = (val // 3) * 3; fallback_msg += f" (※FT={display_val}h 代替表示)"
dt_utc = datetime.strptime(it_str, "%Y%m%d%H%M%S")
dt_jst = dt_utc + timedelta(hours=9)
target_time = dt_jst + timedelta(hours=val)
ui['time_label'].setText(f"FT={val:02d}h\n({target_time.strftime('%m/%d %H:00')})")
while ui['dynamic_artists']:
art = ui['dynamic_artists'].pop(0)
try:
if hasattr(art, 'collections'):
for c in art.collections:
try: c.remove()
except: pass
else: art.remove()
except: pass
for cb in ui['colorbars']:
try: cb.remove()
except: pass
ui['colorbars'].clear(); ui['info_panel'].hide()
target_file = os.path.join(self.cache_dir, f"{model_name}_{it_str}_FT{display_val:02d}.npz")
for ax_idx, ax in enumerate(axes_list):
# 【修正⑤】 タイトルに改行 \n を追加し横幅を圧迫しないように修正
ax.title.set_text(f"[{model_name}] {elem_name} / 初期:{dt_utc.strftime('%m/%d %H:00Z')}\nFT={val}h ({target_time.strftime('%m/%d %H:00')} JST){fallback_msg}")
if not os.path.exists(target_file):
if ax_idx == 0: self.show_error_msg(ax, ui, f"データ未収録 (FT={display_val}h)")
continue
try:
data = np.load(target_file)
lon_key = self.get_exact_key(data, ['lon_surf', 'lon', 'lon_p', 'lon_pall'])
lat_key = self.get_exact_key(data, ['lat_surf', 'lat', 'lat_p', 'lat_pall'])
if not lon_key or not lat_key:
if ax_idx == 0: self.show_error_msg(ax, ui, "座標軸欠損エラー")
data.close(); continue
extent = ax.get_extent(crs=ccrs.PlateCarree())
drawn_flag = False; info_text = ""
def add_vertical_colorbar(mappable, label_text):
cax = ui['fig'].add_axes([0.92, 0.15, 0.015, 0.7])
cb = ui['fig'].colorbar(mappable, cax=cax, orientation='vertical')
cb.set_label(label_text, fontsize=11, weight='bold'); ui['colorbars'].append(cb)
def _cl(arr, minv, maxv, fill=0.0):
if arr is None: return None
a = np.array(arr, dtype=float)
return np.clip(np.nan_to_num(a, nan=fill), minv, maxv)
if elem_name == "地上3種重ね":
p_key = self.get_precip_key(data)
if p_key:
pval = _cl(data[p_key], 0, 1000, 0.0)
if model_name == "GSM_JP" and display_val >= 3:
offset = 6 if display_val > 132 else 3
prev_file = os.path.join(self.cache_dir, f"{model_name}_{it_str}_FT{display_val-offset:02d}.npz")
if os.path.exists(prev_file):
try:
with np.load(prev_file) as d_prev:
prev_p_key = self.get_precip_key(d_prev)
if prev_p_key:
prev_pval = _cl(d_prev[prev_p_key], 0, 1000, 0.0)
if prev_pval.shape == pval.shape: pval = np.clip(pval - prev_pval, 0, None)
except: pass
lon_c, lat_c = self.get_coords_for_data(data, pval)
pval_smooth = ndimage.gaussian_filter(pval, sigma=0.2)
pval_smooth = np.ma.masked_less(pval_smooth, 0.1)
levels = [0.1, 1, 5, 10, 20, 30, 50, 80]
cmap_precip = mcolors.ListedColormap(['#A0E8A0', '#D4F05A', '#FFFF00', '#FFA500', '#FF5500', '#FF0000', '#FF00FF'])
cmap_precip.set_under('none'); cmap_precip.set_over('#800080')
norm_precip = mcolors.BoundaryNorm(levels, cmap_precip.N)
cf = self.plot_contourf(ax, lon_c, lat_c, pval_smooth, ui, levels, cmap_precip, extent, extend='max', alpha=0.55, norm=norm_precip)
if cf and ax_idx == len(axes_list)-1: add_vertical_colorbar(cf, '降水量重ね表示 [mm]')
slp = self.extract_2d_array(data, ['prmsl', 'mslet', 'msl', 'slp'], fuzzy_keys=['msl', 'prmsl'])
if slp is not None:
if np.nanmax(slp) > 2000: slp = slp / 100.0
slp_hpa = _cl(slp, 800, 1150, 1013.0)
lon_c, lat_c = self.get_coords_for_data(data, slp_hpa)
slp_smooth = ndimage.gaussian_filter(slp_hpa, sigma=1.0)
self.plot_contour(ax, lon_c, lat_c, slp_smooth, ui, np.arange(920, 1080, 4), '#1F618D', extent, linewidths=1.2)
u_val = self.extract_2d_array(data, ['u10', '10u', 'u_10m'], fuzzy_keys=['u10', 'u_wind'])
v_val = self.extract_2d_array(data, ['v10', '10v', 'v_10m'], fuzzy_keys=['v10', 'v_wind'])
if u_val is not None and v_val is not None:
u_val = _cl(u_val, -200, 200, 0.0); v_val = _cl(v_val, -200, 200, 0.0)
lon_c, lat_c = self.get_coords_for_data(data, u_val)
self.plot_barbs(ax, lon_c, lat_c, u_val, v_val, ui, area_index, extent, model_name)
drawn_flag = True
elif "GUID" in model_name:
drawn_flag, info_text = self.draw_guidance(ax, ui, data, elem_name, val, extent, area_index, model_name, it_str)
elif elem_name == "地上気温":
t_val = self.extract_2d_array(data, ['t2m', 'tmp2m', 'temp2m'], fuzzy_keys=['tmp', 'temp', 't'])
if t_val is not None:
if np.nanmax(t_val) > 100: t_val = t_val - 273.15
t_val = _cl(t_val, -50, 40, 0.0); lon_c, lat_c = self.get_coords_for_data(data, t_val)
cf = self.plot_contourf(ax, lon_c, lat_c, t_val, ui, np.arange(-30, 40, 3), 'jet', extent, extend='both', alpha=0.4)
self.plot_contour(ax, lon_c, lat_c, t_val, ui, np.arange(-30, 40, 3), 'black', extent, linewidths=0.5)
if cf and ax_idx == len(axes_list)-1: add_vertical_colorbar(cf, '地上気温 [℃]')
info_text = self.get_local_info(elem_name, lon_c, lat_c, t_val, area_index=area_index, model_name=model_name); drawn_flag = True
elif elem_name == "地上気圧":
slp = self.extract_2d_array(data, ['prmsl', 'mslet', 'msl', 'slp'], fuzzy_keys=['msl', 'prmsl'])
if slp is not None:
if np.nanmax(slp) > 2000: slp = slp / 100.0
slp_hpa = _cl(slp, 800, 1150, 1013.0); lon_c, lat_c = self.get_coords_for_data(data, slp_hpa)
cs = self.plot_contour(ax, lon_c, lat_c, ndimage.gaussian_filter(slp_hpa, sigma=1.0), ui, np.arange(940, 1060, 4), '#2980B9', extent)
if cs: info_text += self.get_local_info("気圧", lon_c, lat_c, slp, area_index=area_index, model_name=model_name); drawn_flag = True
elif elem_name == "地上風向風速":
u_val = self.extract_2d_array(data, ['u10', '10u', 'u_10m'], fuzzy_keys=['u10', 'u_wind'])
v_val = self.extract_2d_array(data, ['v10', '10v', 'v_10m'], fuzzy_keys=['v10', 'v_wind'])
if u_val is not None and v_val is not None:
u_val = _cl(u_val, -200, 200, 0.0); v_val = _cl(v_val, -200, 200, 0.0)
spd = np.hypot(u_val, v_val); lon_c, lat_c = self.get_coords_for_data(data, spd)
cf = self.plot_contourf(ax, lon_c, lat_c, spd, ui, [2, 4, 6, 8, 10, 15, 20, 25], ['#E0F7FA', '#B2EBF2', '#4DD0E1', '#00BCD4', '#00838F', '#9C27B0', '#4A148C'], extent, extend='max', alpha=0.5)
if cf and ax_idx == len(axes_list)-1: add_vertical_colorbar(cf, '地上風速 [m/s]')
self.plot_barbs(ax, lon_c, lat_c, u_val, v_val, ui, area_index, extent, model_name)
info_text = self.get_local_info("風向風速", lon_c, lat_c, spd, u_val, v_val, area_index=area_index, model_name=model_name); drawn_flag = True
elif "地上降水" in elem_name:
p_key = self.get_precip_key(data)
if p_key:
pval = _cl(data[p_key], 0, 1000, 0.0)
if model_name == "GSM_JP" and display_val >= 3:
offset = 6 if display_val > 132 else 3
prev_file = os.path.join(self.cache_dir, f"{model_name}_{it_str}_FT{display_val-offset:02d}.npz")
if os.path.exists(prev_file):
try:
with np.load(prev_file) as d_prev:
prev_p_key = self.get_precip_key(d_prev)
if prev_p_key:
prev_pval = _cl(d_prev[prev_p_key], 0, 1000, 0.0)
if prev_pval.shape == pval.shape: pval = np.clip(pval - prev_pval, 0, None)
except: pass
lon_c, lat_c = self.get_coords_for_data(data, pval)
# 【修正⑥】 降水ゼロの時のエラーテキスト「描画エラー」を「降水域なし」に修正し、凡例も重ね合わせと完全統一
if np.nanmax(pval) < 0.1:
txt = ax.text(0.5, 0.5, "降水域なし", transform=ax.transAxes, color='blue', fontsize=16, ha='center', va='center', weight='bold', bbox=dict(facecolor='white', alpha=0.8, edgecolor='blue'))
ui['dynamic_artists'].append(txt)
info_text = self.get_local_info("降水", lon_c, lat_c, pval, area_index=area_index, model_name=model_name)
drawn_flag = True
else:
pval_smooth = np.ma.masked_less(ndimage.gaussian_filter(pval, sigma=0.2), 0.1)
levels = [0.1, 1, 5, 10, 20, 30, 50, 80]
cmap_precip = mcolors.ListedColormap(['#A0E8A0', '#D4F05A', '#FFFF00', '#FFA500', '#FF5500', '#FF0000', '#FF00FF'])
cmap_precip.set_under('none'); cmap_precip.set_over('#800080')
norm_precip = mcolors.BoundaryNorm(levels, cmap_precip.N)
cf = self.plot_contourf(ax, lon_c, lat_c, pval_smooth, ui, levels, cmap_precip, extent, extend='max', alpha=0.85, norm=norm_precip)
if cf:
self.plot_contour(ax, lon_c, lat_c, pval_smooth, ui, levels, '#444444', extent, linewidths=0.5)
if ax_idx == len(axes_list)-1: add_vertical_colorbar(cf, f'降水量 [mm]')
info_text = self.get_local_info("降水", lon_c, lat_c, pval, area_index=area_index, model_name=model_name)
drawn_flag = True
elif "雲量" in elem_name:
c_val = None
if elem_name == "全雲量":
c_val = self.extract_2d_array(data, ['tcc', 'tcdc', 'cloud', 'tcdc_surf', 'var_0_6_1', 'var_0_6_192'], fuzzy_keys=['tcc', 'tcdc', 'cloud', 'cld'])
# 【追加】全雲量がNPZにない場合、上層・中層・下層から自動合成する (ユーザー様の素晴らしい洞察通りです!)
if c_val is None:
hcc = self.extract_2d_array(data, ['hcc', 'hcdc', 'high', 'var_0_6_3'], fuzzy_keys=['hcc', 'high'])
mcc = self.extract_2d_array(data, ['mcc', 'mcdc', 'mid', 'var_0_6_4'], fuzzy_keys=['mcc', 'mid'])
lcc = self.extract_2d_array(data, ['lcc', 'lcdc', 'low', 'var_0_6_5'], fuzzy_keys=['lcc', 'low'])
# いずれか一つでもデータがあれば、最大値を取って全雲量として合成
if hcc is not None or mcc is not None or lcc is not None:
hcc_v = hcc if hcc is not None else 0
mcc_v = mcc if mcc is not None else 0
lcc_v = lcc if lcc is not None else 0
c_val = np.maximum(np.maximum(hcc_v, mcc_v), lcc_v)
elif elem_name == "上層雲量":
c_val = self.extract_2d_array(data, ['hcc', 'hcdc', 'high', 'var_0_6_3'], fuzzy_keys=['hcc', 'high'])
elif elem_name == "中層雲量":
c_val = self.extract_2d_array(data, ['mcc', 'mcdc', 'mid', 'var_0_6_4'], fuzzy_keys=['mcc', 'mid'])
elif elem_name == "下層雲量":
c_val = self.extract_2d_array(data, ['lcc', 'lcdc', 'low', 'var_0_6_5'], fuzzy_keys=['lcc', 'low'])
if c_val is not None:
c_val = _cl(c_val, 0, 150, 0.0)
lon_c, lat_c = self.get_coords_for_data(data, c_val)
if lon_c is None or lat_c is None:
lon_k = self.get_exact_key(data, ['lon_surf', 'lon', 'lon_p', 'lon_pall'])
lat_k = self.get_exact_key(data, ['lat_surf', 'lat', 'lat_p', 'lat_pall'])
if lon_k and lat_k: lon_c, lat_c = data[lon_k], data[lat_k]
if lon_c is not None and lat_c is not None:
if np.nanmax(c_val) <= 1.0 and np.nanmax(c_val) > 0: c_val = c_val * 100.0
# 雲域が全く無い場合の安全対策
if np.nanmax(c_val) < 0.1:
txt = ax.text(0.5, 0.5, "雲域なし", transform=ax.transAxes, color='gray', fontsize=14, ha='center', va='center', weight='bold', bbox=dict(facecolor='white', alpha=0.8, edgecolor='gray'))
ui['dynamic_artists'].append(txt)
info_text = self.get_local_info(elem_name, lon_c, lat_c, c_val, area_index=area_index, model_name=model_name)
drawn_flag = True
else:
levels = [20, 30, 40, 50, 60, 70, 80, 90, 100]
cmap_cloud = mcolors.ListedColormap(['#D9D9D9', '#BDBDBD', '#969696', '#737373', '#525252', '#252525', '#0F0F0F', '#000000'])
cmap_cloud.set_under('none')
cmap_cloud.set_over('#000000')
norm_cloud = mcolors.BoundaryNorm(levels, cmap_cloud.N)
cf = self.plot_contourf(ax, lon_c, lat_c, c_val, ui, levels, cmap_cloud, extent, extend='max', alpha=0.7, norm=norm_cloud)
if cf:
if len(ui['colorbars']) == 0:
add_vertical_colorbar(cf, f'{elem_name} [%]')
info_text = self.get_local_info(elem_name, lon_c, lat_c, c_val, area_index=area_index, model_name=model_name)
drawn_flag = True
# 【修正⑧】 elem_name.lower() を使って小文字・大文字の揺らぎ(hpa vs hPa)による分岐スキップを完全防止
elif "300hpa高度" in elem_name.lower():
h_val = self.extract_2d_array(data, ['gh300', 'hgt300', 'hgt_300'], 300, ['hgt', 'gh', 'z'])
if h_val is not None:
h_val = _cl(h_val, -500, 20000, 0.0); lon_p, lat_p = self.get_coords_for_data(data, h_val)
cf = self.plot_contourf(ax, lon_p, lat_p, h_val, ui, np.arange(8400, 10800, 60), 'PuBu', extent, alpha=0.6)
self.plot_contour(ax, lon_p, lat_p, h_val, ui, np.arange(8400, 10800, 60), 'darkblue', extent)
if cf and ax_idx == len(axes_list)-1: add_vertical_colorbar(cf, '300hPa 高度 [m]'); info_text = self.get_local_info(elem_name, lon_p, lat_p, h_val, area_index=area_index, model_name=model_name); drawn_flag = True
elif "300hpa風向・風速" in elem_name.lower():
u_val = self.extract_2d_array(data, ['u300', 'u_300'], 300, ['u', 'ugrd'])
v_val = self.extract_2d_array(data, ['v300', 'v_300'], 300, ['v', 'vgrd'])
if u_val is not None and v_val is not None:
u_val = _cl(u_val, -300, 300, 0.0); v_val = _cl(v_val, -300, 300, 0.0)
spd = np.hypot(u_val, v_val); lon_p, lat_p = self.get_coords_for_data(data, spd)
cf = self.plot_contourf(ax, lon_p, lat_p, spd, ui, [20, 30, 40, 50, 60, 80, 100], 'YlOrRd', extent, extend='max', alpha=0.35)
self.plot_barbs(ax, lon_p, lat_p, u_val, v_val, ui, area_index, extent, model_name)
if cf and ax_idx == len(axes_list)-1: add_vertical_colorbar(cf, '300hPa 風速 [m/s]'); info_text = self.get_local_info(elem_name, lon_p, lat_p, spd, u_val, v_val, area_index=area_index, model_name=model_name); drawn_flag = True
elif "500hpa高度" in elem_name.lower():
h_val = self.extract_2d_array(data, ['gh500', 'hgt500', 'hgt_500'], 500, ['hgt', 'gh', 'z'])
if h_val is not None:
h_val = _cl(h_val, -500, 20000, 0.0); lon_p, lat_p = self.get_coords_for_data(data, h_val)
cf = self.plot_contourf(ax, lon_p, lat_p, h_val, ui, np.arange(4800, 6000, 60), 'Greens', extent, alpha=0.6)
self.plot_contour(ax, lon_p, lat_p, h_val, ui, np.arange(4800, 6000, 60), 'darkgreen', extent)
if cf and ax_idx == len(axes_list)-1: add_vertical_colorbar(cf, '500hPa 高度 [m]'); info_text = self.get_local_info(elem_name, lon_p, lat_p, h_val, area_index=area_index, model_name=model_name); drawn_flag = True
elif "500hpa風向・風速" in elem_name.lower():
u_val = self.extract_2d_array(data, ['u500', 'u_500'], 500, ['u', 'ugrd'])
v_val = self.extract_2d_array(data, ['v500', 'v_500'], 500, ['v', 'vgrd'])
if u_val is not None and v_val is not None:
u_val = _cl(u_val, -300, 300, 0.0); v_val = _cl(v_val, -300, 300, 0.0)
spd = np.hypot(u_val, v_val); lon_p, lat_p = self.get_coords_for_data(data, spd)
cf = self.plot_contourf(ax, lon_p, lat_p, spd, ui, [10, 20, 30, 40, 50, 60, 80], 'YlOrRd', extent, extend='max', alpha=0.35)
self.plot_barbs(ax, lon_p, lat_p, u_val, v_val, ui, area_index, extent, model_name)
if cf and ax_idx == len(axes_list)-1: add_vertical_colorbar(cf, '500hPa 風速 [m/s]'); info_text = self.get_local_info(elem_name, lon_p, lat_p, spd, u_val, v_val, area_index=area_index, model_name=model_name); drawn_flag = True
elif "500hpa渦度" in elem_name.lower():
vort = self.extract_2d_array(data, ['vort500', 'vort', 'abs_vort'], 500, ['vort', 'abs_vort'])
if vort is not None:
# 【追加】渦度データを 10^-5/s 単位にスケーリング
if np.nanmax(np.abs(vort)) < 1.0:
vort = vort * 100000.0
vort = _cl(vort, -1000, 1000, 0.0); lon_p, lat_p = self.get_coords_for_data(data, vort)
cf = self.plot_contourf(ax, lon_p, lat_p, vort, ui, [-50, -20, 0, 20, 40, 60, 80, 100, 150, 200], ['#ffffff', '#fdf0e6', '#fad7a1', '#f8c471', '#f39c12', '#d35400', '#c0392b', '#922b21', '#641e16'], extent, extend='both', alpha=0.5)
self.plot_contour(ax, lon_p, lat_p, vort, ui, [-50, -20, 0, 20, 40, 60, 80, 100, 150, 200], '#4A235A', extent)
if cf and ax_idx == len(axes_list)-1: add_vertical_colorbar(cf, '500hPa 正渦度 [10^-5/s]'); info_text = self.get_local_info(elem_name, lon_p, lat_p, vort, area_index=area_index, model_name=model_name); drawn_flag = True
elif "hpa気温" in elem_name.lower() or "hpa気温・風" in elem_name.lower():
w = re.search(r'(\d+)hpa', elem_name.lower())
if w:
lvl = int(w.group(1))
t_val = self.extract_2d_array(data, [f't{lvl}', f'tmp{lvl}', f't_{lvl}', f'tmp_{lvl}'], lvl, ['tmp', 'temp', 't'])
if t_val is not None:
if np.nanmax(t_val) > 100: t_val = t_val - 273.15
t_val = _cl(t_val, -100, 60, 0.0); lon_p, lat_p = self.get_coords_for_data(data, t_val)
cf = self.plot_contourf(ax, lon_p, lat_p, t_val, ui, np.arange(-54, 40, 3), 'jet', extent, extend='both', alpha=0.3)
self.plot_contour(ax, lon_p, lat_p, t_val, ui, np.arange(-54, 40, 3), 'blue', extent, linewidths=0.5)
if cf and ax_idx == len(axes_list)-1: add_vertical_colorbar(cf, f'{lvl}hPa 気温 [℃]')
lon_c, lat_c, t_c = self.crop_data(lon_p, lat_p, t_val, extent)
if lon_c.size > 0:
cs = ax.contour(lon_c, lat_c, t_c, levels=[-36, -6, 0, 15, 30], colors='red', linewidths=2.0, transform=ccrs.PlateCarree(), zorder=8)
ui['dynamic_artists'].append(cs); ax.clabel(cs, inline=True, fontsize=12, fmt='%1.0f')
u_val = self.extract_2d_array(data, [f'u{lvl}', f'u_{lvl}'], lvl, ['u', 'ugrd'])
v_val = self.extract_2d_array(data, [f'v{lvl}', f'v_{lvl}'], lvl, ['v', 'vgrd'])
if u_val is not None: u_val = _cl(u_val, -200, 200, 0.0)
if v_val is not None: v_val = _cl(v_val, -200, 200, 0.0)
info_text = self.get_local_info(elem_name, lon_p, lat_p, t_val, u_val, v_val, area_index=area_index, model_name=model_name)
drawn_flag = True
if u_val is not None and v_val is not None: self.plot_barbs(ax, lon_p, lat_p, u_val, v_val, ui, area_index, extent, model_name)
elif "700hpa湿数" in elem_name.lower():
t = self.extract_2d_array(data, ['t700', 'tmp700', 't_700'], 700, ['tmp', 'temp', 't'])
rh_raw = self.extract_2d_array(data, ['rh700', 'r700', 'rh_700'], 700, ['rh', 'hum', 'r'])
if t is not None and rh_raw is not None:
if np.nanmax(t) > 100: t = t - 273.15
rh = _cl(rh_raw, 0, 150, 0.0)
if np.nanmax(rh) <= 1.0 and np.nanmax(rh) > 0: rh = rh * 100.0
lon_p, lat_p = self.get_coords_for_data(data, t); t_td = calc_t_td(t, rh)
cf = self.plot_contourf(ax, lon_p, lat_p, t_td, ui, [0, 3, 6, 9, 12, 15], ['#27AE60', '#82E0AA', '#D5F5E3', '#F9E79F', '#F5CBA7', '#E74C3C'], extent, extend='max', alpha=0.35)
u_val = self.extract_2d_array(data, ['u700', 'u_700'], 700, ['u', 'ugrd'])
v_val = self.extract_2d_array(data, ['v700', 'v_700'], 700, ['v', 'vgrd'])
if u_val is not None: u_val = _cl(u_val, -200, 200, 0.0)
if v_val is not None: v_val = _cl(v_val, -200, 200, 0.0)
if cf and ax_idx == len(axes_list)-1: add_vertical_colorbar(cf, '700hPa 湿数 [℃]')
info_text = self.get_local_info("湿数", lon_p, lat_p, t_td, u_val, v_val, area_index=area_index, model_name=model_name); drawn_flag = True
if u_val is not None and v_val is not None: self.plot_barbs(ax, lon_p, lat_p, u_val, v_val, ui, area_index, extent, model_name)
elif "700hpa鉛直流" in elem_name.lower():
w_val = self.extract_2d_array(data, ['w', 'w700', 'vvel', 'vvel700', 'dzdt', 'dzdt700'], 700, ['w', 'vvel', 'dzdt'])
if w_val is not None:
if np.nanmax(np.abs(w_val)) < 15.0: w_val = w_val * 36.0
w_val = _cl(w_val, -500, 500, 0.0); lon_p, lat_p = self.get_coords_for_data(data, w_val)
w_val_masked = np.ma.masked_inside(ndimage.gaussian_filter(w_val, sigma=0.8), -4.9, 4.9)
cf = self.plot_contourf(ax, lon_p, lat_p, w_val_masked, ui, [-50, -30, -20, -10, -5, 5, 10, 20, 30, 50], 'PiYG', extent, extend='both', alpha=0.8)
if cf and ax_idx == len(axes_list)-1: add_vertical_colorbar(cf, '700hPa 鉛直流 [hPa/h]'); info_text = self.get_local_info(elem_name, lon_p, lat_p, w_val, area_index=area_index, model_name=model_name); drawn_flag = True
elif "850hpa相当温位" in elem_name.lower():
t = self.extract_2d_array(data, ['t850', 'tmp850', 't_850'], 850, ['tmp', 'temp', 't'])
rh_raw = self.extract_2d_array(data, ['rh850', 'r850', 'rh_850'], 850, ['rh', 'hum', 'r'])
if t is not None and rh_raw is not None:
if np.nanmax(t) > 100: t = t - 273.15
rh = _cl(rh_raw, 0, 150, 0.0)
if np.nanmax(rh) <= 1.0 and np.nanmax(rh) > 0: rh = rh * 100.0
lon_p, lat_p = self.get_coords_for_data(data, t); ept = calc_ept(t, rh, 850); levels = np.arange(270, 360, 6)
cf = self.plot_contourf(ax, lon_p, lat_p, ept, ui, levels, 'rainbow', extent, extend='both', alpha=0.6)
self.plot_contour(ax, lon_p, lat_p, ept, ui, levels, 'black', extent)
if cf and ax_idx == len(axes_list)-1: add_vertical_colorbar(cf, '850hPa 相当温位 [K]'); info_text = self.get_local_info(elem_name, lon_p, lat_p, ept, area_index=area_index, model_name=model_name); drawn_flag = True
elif "850hpa湿数" in elem_name.lower():
t = self.extract_2d_array(data, ['t850', 'tmp850', 't_850'], 850, ['tmp', 'temp', 't'])
rh_raw = self.extract_2d_array(data, ['rh850', 'r850', 'rh_850'], 850, ['rh', 'hum', 'r'])
if t is not None and rh_raw is not None:
if np.nanmax(t) > 100: t = t - 273.15
rh = _cl(rh_raw, 0, 150, 0.0)
if np.nanmax(rh) <= 1.0 and np.nanmax(rh) > 0: rh = rh * 100.0
lon_p, lat_p = self.get_coords_for_data(data, t); t_td = calc_t_td(t, rh)
cf = self.plot_contourf(ax, lon_p, lat_p, t_td, ui, [0, 3, 6, 9, 12, 15], ['#27AE60', '#82E0AA', '#D5F5E3', '#F9E79F', '#F5CBA7', '#E74C3C'], extent, extend='max', alpha=0.35)
u_val = self.extract_2d_array(data, ['u850', 'u_850'], 850, ['u', 'ugrd'])
v_val = self.extract_2d_array(data, ['v850', 'v_850'], 850, ['v', 'vgrd'])
if u_val is not None: u_val = _cl(u_val, -200, 200, 0.0)
if v_val is not None: v_val = _cl(v_val, -200, 200, 0.0)
if cf and ax_idx == len(axes_list)-1: add_vertical_colorbar(cf, '850hPa 湿数 [℃]')
info_text = self.get_local_info("湿数", lon_p, lat_p, t_td, u_val, v_val, area_index=area_index, model_name=model_name); drawn_flag = True
if u_val is not None and v_val is not None: self.plot_barbs(ax, lon_p, lat_p, u_val, v_val, ui, area_index, extent, model_name)
elif "925hpa" in elem_name.lower() or "975hpa" in elem_name.lower():
lvl = 925 if "925" in elem_name else 975
t = self.extract_2d_array(data, [f't{lvl}', f'tmp{lvl}', f't_{lvl}'], lvl, ['tmp', 'temp', 't'])
rh_raw = self.extract_2d_array(data, [f'rh{lvl}', f'r{lvl}', f'rh_{lvl}'], lvl, ['rh', 'hum', 'r'])
if t is not None and rh_raw is not None:
if np.nanmax(t) > 100: t = t - 273.15
rh = _cl(rh_raw, 0, 150, 0.0)
if np.nanmax(rh) <= 1.0 and np.nanmax(rh) > 0: rh = rh * 100.0
lon_p, lat_p = self.get_coords_for_data(data, t)
if "相当温位" in elem_name:
ept = calc_ept(t, rh, lvl); levels = np.arange(270, 360, 6)
cf = self.plot_contourf(ax, lon_p, lat_p, ept, ui, levels, 'rainbow', extent, extend='both', alpha=0.6)
self.plot_contour(ax, lon_p, lat_p, ept, ui, levels, 'black', extent)
if cf and ax_idx == len(axes_list)-1: add_vertical_colorbar(cf, f'{lvl}hPa 相当温位 [K]'); info_text = self.get_local_info(elem_name, lon_p, lat_p, ept, area_index=area_index, model_name=model_name); drawn_flag = True
else:
t_td = calc_t_td(t, rh); levels = [0, 3, 6, 9, 12, 15]
cf = self.plot_contourf(ax, lon_p, lat_p, t_td, ui, levels, ['#27AE60', '#82E0AA', '#D5F5E3', '#F9E79F', '#F5CBA7', '#E74C3C'], extent, extend='max', alpha=0.35)
u_val = self.extract_2d_array(data, [f'u{lvl}', f'u_{lvl}'], lvl, ['u', 'ugrd'])
v_val = self.extract_2d_array(data, [f'v{lvl}', f'v_{lvl}'], lvl, ['v', 'vgrd'])
if u_val is not None: u_val = _cl(u_val, -200, 200, 0.0)
if v_val is not None: v_val = _cl(v_val, -200, 200, 0.0)
if cf and ax_idx == len(axes_list)-1: add_vertical_colorbar(cf, f'{lvl}hPa 湿数 [℃]')
info_text = self.get_local_info("湿数", lon_p, lat_p, t_td, u_val, v_val, area_index=area_index, model_name=model_name); drawn_flag = True
if u_val is not None and v_val is not None: self.plot_barbs(ax, lon_p, lat_p, u_val, v_val, ui, area_index, extent, model_name)
data.close(); del data
if info_text and ax_idx == 0:
ui['info_panel'].setText(info_text); ui['info_panel'].adjustSize(); ui['info_panel'].move(10, 10); ui['info_panel'].show()
if not drawn_flag and ax_idx == 0:
self.show_error_msg(ax, ui, f"[!] {elem_name} 描画エラー")
except Exception as e: logging.error(f"Draw Error for {elem_name} at FT={display_val}: {e}")
ui['canvas'].draw_idle(); gc.collect()
def setup_history_tab(self):
tab = QWidget(); layout = QVBoxLayout(tab); history_text = QTextEdit(); history_text.setReadOnly(True)
history_text.setStyleSheet("background-color: #112240; color: #E0E0E0; font-family: 'MS Gothic'; font-size: 12pt; padding: 10px; border: none;")
html_content = """
<h2 style='color: #64FFDA;'>気象GPV 局地分析ツール 更新履歴</h2><hr style='border-color: #457B9D;'><ul>
<li><b>Ver 129.0 (機能拡張・2画面分割配置・地上3種重ね実装統合版)</b><br>
1. <b>MSMガイダンスメタグラムの修正</b>: ファイル読み込みをワイルドカード及び最接近時刻自動結合ロジックへアップグレードし、気温・風が出力されないバグを完全解決。<br>
2. <b>発雷確率の描画改善</b>: 凡例を1%刻みから完全サポートし1-10%の黒潰れを解消。濃い高コントラスト配色へ変更。<br>
3. <b>恵庭・石狩 2画面分割新設</b>: 左2/3(恵庭市中心)・右1/3(石狩市南部)をインテリジェントに動的同時描画する処理を全要素へ適用。<br>
4. <b>上川・局所数値プロット適用</b>: 上川地方及び恵庭・石狩領域でのメッシュ数値を高精度に自動内挿オーバーレイ出力。<br>
5. <b>SSI 3系列の再構築</b>: メタグラムの700-500を廃止し、850-500、850-700、925-700のプロットへ全面換装。<br>
6. <b>地上3種重ねの追加</b>: 降水カラー+気圧等圧線+風矢羽の3要素を低負荷で一枚のマップに完全統合描画。<br>
<li><b>Ver 126.0 (EXE安定化 統合版)</b><br>
1. <b>ライブラリ読込順序の固定</b>: EXE化後の DLL 競合を防ぐため pyproj → shapely → cartopy の順序で処理を実行。<br>
2. <b>海岸線オフライン参照</b>:相対パスで参照するロジックを追加。
</ul>"""
history_text.setHtml(html_content); layout.addWidget(history_text); self.tabs.addTab(tab, "更新履歴")
def scan_cache_dir(self, quiet=False, force_draw=False):
if not os.path.exists(self.cache_dir): return
files = glob.glob(os.path.join(self.cache_dir, "*.npz"))
updated = False
for m in ["MSM", "GSM_JP", "ANAL", "MSM_GUID", "GSM_GUID"]:
m_files = [f for f in files if os.path.basename(f).startswith(f"{m}_")]
times = sorted(list(set(re.search(r'_(\d{14})_', f).group(1) for f in m_files if re.search(r'_(\d{14})_', f))), reverse=True)
if times:
combo = self.tab_ui.get(m, {}).get('init_combo')
if combo:
current_items = [combo.itemData(i) for i in range(combo.count())]
if current_items != times:
combo.blockSignals(True); combo.clear()
for t in times:
dt_utc = datetime.strptime(t, "%Y%m%d%H%M%S")
combo.addItem(dt_utc.strftime('%m/%d %H:%M Z') + (" (最新)", "")[t != times[0]], t)
combo.blockSignals(False)
if self.model_init_times.get(m) not in times:
self.model_init_times[m] = times[0];
self.update_slider_max(m, times[0])
self.tab_ui[m]['slider'].setValue(0); combo.setCurrentIndex(0); updated = True
current_it = self.model_init_times.get(m)
if current_it:
self.update_slider_max(m, current_it)
if (updated or force_draw) and not quiet:
self.status_banner.setText("最新データを画面に同期しました")
current_tab_name = self.tabs.tabText(self.tabs.currentIndex())
if "MSM ガイダンス" in current_tab_name: current_m = "MSM_GUID"
elif "GSM ガイダンス" in current_tab_name: current_m = "GSM_GUID"
elif "毎時大気" in current_tab_name: current_m = "ANAL"
elif "MSM" in current_tab_name: current_m = "MSM"
elif "日本域" in current_tab_name: current_m = "GSM_JP"
else: current_m = None
if current_m in self.tab_ui:
if len(self.tab_ui[current_m]['area_btns']) > 5 and self.tab_ui[current_m]['area_btns'][-1].isChecked(): pass
else:
# 【修正】スライダー等動かさず、現在選択中の値で強制再描画する
self.draw_frame(current_m, self.tab_ui[current_m]['slider'].value())
def start_animation(self, model_name):
self.current_anim_model = model_name
speeds = [2000, 1000, 500, 200, 50]
self.anim_timer.start(speeds[self.tab_ui[model_name]['combo_speed'].currentIndex()])
def remove_vertical_colorbar(self):
pass
def stop_animation(self):
self.anim_timer.stop()
def anim_step(self):
if not self.current_anim_model: return
ui = self.tab_ui[self.current_anim_model]
current_val = ui['slider'].value()
elem_name = ui['list'].currentItem().text()
step_val = ui['step']
if self.current_anim_model == "GSM_JP":
ground_cloud_elems = ["地上気圧", "地上風向風速", "地上降水", "全雲量", "上層雲量", "中層雲量", "下層雲量", "地上3種重ね"]
threshold = 132 if elem_name in ground_cloud_elems else 84
if current_val >= threshold: step_val = 6
new_val = current_val + step_val
ui['slider'].setValue(new_val if new_val <= ui['slider'].maximum() else ui['slider'].minimum())
if __name__ == '__main__':
import multiprocessing
multiprocessing.freeze_support()
import traceback
try:
app = QApplication(sys.argv)
window = WeatherApp()
sys.exit(app.exec())
except Exception as e:
print("起動時にエラーが発生しました:")
traceback.print_exc()
input("Enterキーを押すと終了します...")