未分類

==========================================

気象GPV 局地分析ツール ビューワー (App B)

VERSION INFO: 110.1 (Ver 110.0 + ガイダンス機能統合・完全停止ボタン実装版)

==========================================

import sys, os, glob, re, gc, time
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)
from PyQt6.QtCore import Qt, QTimer, QSettings
from PyQt6.QtGui import QColor, QFont

os.environ[‘QT_API’] = ‘pyqt6’
import matplotlib
matplotlib.use(‘qtagg’)
import matplotlib.colors as mcolors
import matplotlib as mpl
import matplotlib.path as mpath

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

WEB用ファイル名変換辞書(文字化け防止用)

ELEM_EN_MAP = {
“地上気圧”: “slp”, “地上風向風速”: “wind”, “地上降水”: “precip”,
“全雲量”: “tcc”, “上層雲量”: “hcc”, “中層雲量”: “mcc”, “下層雲量”: “lcc”,
“300hpa高度”: “hgt300”, “300hpa風向・風速”: “wind300”,
“500hpa高度”: “hgt500”, “500hpa風向・風速”: “wind500”, “500hpa渦度”: “vort500”, “500hpa気温”: “t500”,
“700hpa湿数・風”: “rh_wind700”, “700hpa鉛直流”: “w700”,
“850hpa気温・風”: “t_wind850”, “850hpa相当温位”: “ept850”, “850hpa湿数・風”: “rh_wind850”,
“925hpa相当温位”: “ept925”, “925hpa湿数・風”: “rh_wind925”,
“975hpa相当温位”: “ept975”, “975hpa湿数・風”: “rh_wind975”,
“北極域500hpa高度”: “np_hgt500”, “南極域500hpa高度”: “sp_hgt500”,
“降水確率”: “pop”, “降雪確率”: “pos”, “発雷確率”: “pol”, “卓越天気”: “wx”
}

STATIONS = [
(“札幌”, 43.060, 141.328), (“函館”, 41.817, 140.753), (“室蘭”, 42.315, 140.975),
(“帯広”, 42.922, 143.212), (“釧路”, 42.985, 144.380), (“網走”, 44.018, 144.279),
(“稚内”, 45.415, 141.680), (“旭川”, 43.760, 142.360),
(“青森”, 40.824, 140.740), (“仙台”, 38.268, 140.871), (“東京”, 35.689, 139.691),
(“新潟”, 37.916, 139.036), (“名古屋”, 35.181, 136.906), (“大阪”, 34.693, 135.502),
(“広島”, 34.385, 132.455), (“高知”, 33.559, 133.531), (“福岡”, 33.590, 130.401),
(“鹿児島”, 31.596, 130.557), (“那覇”, 26.212, 127.681)
]

CACHED_SHAPES = {‘other_cities’: None, ‘sapporo_districts’: None, ‘sapporo_city’: None, ‘kamikawa’: None}
SHAPEFILE_FOUND = False

def search_shapefile():
paths = glob.glob(‘*/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: 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()
        pixmap.save(path)
        QMessageBox.information(self, "保存完了", f"デスクトップ等に保存しました:\n{os.path.basename(path)}")

class AdminAuthDialog(QDialog):
def init(self, parent=None):
super().init(parent)
self.setWindowTitle(“管理者認証”)
self.setFixedSize(320, 160)
self.setStyleSheet(“””
QDialog { background-color: #0A192F; color: #E0E0E0;}
QLabel { color: #E0E0E0; font-size: 12pt; font-weight: bold; }
QLineEdit { background: #112240; border: 1px solid #64FFDA; color: #64FFDA; font-size: 14pt; padding: 5px; border-radius: 4px; }
QPushButton { background-color: #1D3557; color: white; padding: 8px; border-radius: 5px; font-weight: bold; }
QPushButton:hover { background-color: #457B9D; }
“””)
layout = QVBoxLayout(self)
layout.addWidget(QLabel(“管理者パスワードを入力してください:”))
self.pw_input = QLineEdit()
self.pw_input.setEchoMode(QLineEdit.EchoMode.Password)
layout.addWidget(self.pw_input)

    btn_layout = QHBoxLayout()
    login_btn = QPushButton("ログイン")
    login_btn.clicked.connect(self.check_pw)
    cancel_btn = QPushButton("キャンセル")
    cancel_btn.clicked.connect(self.reject)
    btn_layout.addWidget(login_btn)
    btn_layout.addWidget(cancel_btn)
    layout.addLayout(btn_layout)
    self.authenticated = False

def check_pw(self):
    if self.pw_input.text() == "weather":
        self.authenticated = True
        self.accept()
    else:
        QMessageBox.warning(self, "認証エラー", "パスワードが違います")
        self.pw_input.clear()

class SyslogDialog(QDialog):
def init(self, parent_app):
super().init(parent_app)
self.setWindowTitle(“📝 システム・ログ表示”)
self.resize(800, 500)
self.setStyleSheet(“QDialog { background-color: #0A192F; color: #E0E0E0; } QTextEdit { background: #112240; color: #64FFDA; font-family: Consolas, monospace; font-size: 11pt; padding: 10px; border: 1px solid #457B9D; }”)
self.parent_app = parent_app
layout = QVBoxLayout(self)

    self.text_edit = QTextEdit()
    self.text_edit.setReadOnly(True)
    layout.addWidget(self.text_edit)

    btn_layout = QHBoxLayout()
    copy_btn = QPushButton("ログをコピー")
    copy_btn.setStyleSheet("background-color: #2980B9; color: white; padding: 8px; font-weight: bold;")
    copy_btn.clicked.connect(self.copy_log)

    close_btn = QPushButton("閉じる")
    close_btn.setStyleSheet("background-color: #C0392B; color: white; padding: 8px; font-weight: bold;")
    close_btn.clicked.connect(self.accept)

    btn_layout.addWidget(copy_btn)
    btn_layout.addWidget(close_btn)
    layout.addLayout(btn_layout)

    self.timer = QTimer(self)
    self.timer.timeout.connect(self.read_syslog)
    self.timer.start(2000)
    self.read_syslog()

def read_syslog(self):
    if os.path.exists(self.parent_app.log_file):
        try:
            with open(self.parent_app.log_file, "r", encoding="utf-8") as f:
                lines = f.readlines()
                self.text_edit.setText("".join(lines[-100:]))
                self.text_edit.verticalScrollBar().setValue(self.text_edit.verticalScrollBar().maximum())
        except: pass

def copy_log(self):
    QApplication.clipboard().setText(self.text_edit.toPlainText())
    QMessageBox.information(self, "コピー完了", "クリップボードにコピーしました")

class AdminPanelDialog(QDialog):
def init(self, parent_app):
super().init(parent_app)
self.setWindowTitle(“⚙️ 管理者コントロールパネル”)
self.setFixedSize(500, 350)
self.setStyleSheet(“””
QDialog { background-color: #0A192F; color: #E0E0E0;}
QLabel { color: #E0E0E0; font-size: 11pt; }
QPushButton { background-color: #1D3557; color: white; padding: 12px; border-radius: 5px; font-weight: bold; font-size: 12pt; margin: 5px; border: 1px solid #457B9D;}
QPushButton:hover { background-color: #457B9D; }
“””)
layout = QVBoxLayout(self)
self.parent_app = parent_app

    lbl = QLabel("【管理者メニュー】")
    lbl.setStyleSheet("font-size: 14pt; font-weight: bold; color: #64FFDA;")
    layout.addWidget(lbl)

    self.dir_lbl = QLabel(f"現在の読込先:\n{self.parent_app.cache_dir}")
    self.dir_lbl.setWordWrap(True)
    self.dir_lbl.setStyleSheet("color: #8892B0; font-size: 10pt;")
    layout.addWidget(self.dir_lbl)

    dir_btn = QPushButton("📁 読込先フォルダを変更")
    dir_btn.clicked.connect(self.change_dir)
    layout.addWidget(dir_btn)

    sync_btn = QPushButton("🔄 画面手動強制同期")
    sync_btn.clicked.connect(self.manual_sync)
    layout.addWidget(sync_btn)

    chk_btn = QPushButton("🔍 内部データ確認 (NPZチェッカー)")
    chk_btn.clicked.connect(self.parent_app.show_npz_inspector)
    layout.addWidget(chk_btn)

    log_btn = QPushButton("📝 システム・ログ表示")
    log_btn.clicked.connect(self.show_syslog)
    layout.addWidget(log_btn)

    close_btn = QPushButton("閉じる")
    close_btn.setStyleSheet("background-color: #C0392B;")
    close_btn.clicked.connect(self.accept)
    layout.addWidget(close_btn)

def change_dir(self):
    directory = QFileDialog.getExistingDirectory(self, "GPVデータ(npz)が保存される共有フォルダを選択", self.parent_app.cache_dir)
    if directory:
        self.parent_app.cache_dir = directory
        self.parent_app.settings.setValue('cache_dir', directory)
        self.parent_app.log_file = os.path.join(directory, "system_log.txt")
        self.dir_lbl.setText(f"現在の読込先:\n{directory}")
        QMessageBox.information(self, "設定完了", "読込先フォルダを更新しました")

def manual_sync(self):
    self.parent_app.scan_cache_dir(force_draw=True)
    QMessageBox.information(self, "同期完了", "画面の強制同期を実行しました")

def show_syslog(self):
    syslog = SyslogDialog(self.parent_app)
    syslog.exec()

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 局地分析ツール")
    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.log_file = os.path.join(self.cache_dir, “system_log.txt”)

    self.setWindowTitle("気象GPV 局地分析ツール (Ver 110.1 - GUID統合・停止ボタン版)")
    self.resize(1850, 1050)
    self.model_init_times = {"MSM": None, "GSM_JP": None, "GSM_GL": None, "GUID": 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; }
        .NavButton:hover { background-color: #3498DB; }
        .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;}
        .AdminButton:hover { background-color: #2C3E50; }
        .WebExportButton { background-color: #9B59B6; color: white; font-weight: bold; border-radius: 5px; padding: 5px 15px; border: 2px solid #8E44AD;}
        .WebExportButton:hover { background-color: #8E44AD; }
    """)

    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.admin_btn = QPushButton("⚙️ 設定")
    self.admin_btn.setProperty("class", "AdminButton")
    self.admin_btn.clicked.connect(self.open_admin_panel)

    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.admin_btn)
    corner_layout.addWidget(self.exit_app_btn)

    self.tabs.setCornerWidget(corner_widget, Qt.Corner.TopRightCorner)

    self.setup_model_tab("MSM")
    self.setup_model_tab("GSM_JP")
    self.setup_model_tab("GSM_GL")
    self.setup_guidance_tab("GUID")
    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 show_npz_inspector(self):
    current_tab = self.tabs.tabText(self.tabs.currentIndex())
    model_name = "MSM" if "MSM" in current_tab else ("GSM_JP" if "日本域" in current_tab else ("GUID" if "ガイダンス" in current_tab else "GSM_GL"))
    if model_name not in self.tab_ui: return
    ui = self.tab_ui[model_name]
    val = ui['slider'].value()
    it_str = self.model_init_times.get(model_name)
    if not it_str: return

    target_file = os.path.join(self.cache_dir, f"{model_name}_{it_str}_FT{val:02d}.npz")
    if not os.path.exists(target_file): return

    try:
        data = np.load(target_file)
        info = f"【ファイル名】\n{os.path.basename(target_file)}\n\n"
        info += f"【収録されている変数一覧 (Total: {len(data.files)}個)】\n"
        info += "-" * 50 + "\n"
        for k in data.files:
            shape_str = str(data[k].shape)
            dtype_str = str(data[k].dtype)
            min_v = np.nanmin(data[k])
            max_v = np.nanmax(data[k])
            info += f"■ 変数名: {k}\n   ・形状: {shape_str}\n   ・型: {dtype_str}\n   ・値域: {min_v:.2f} ~ {max_v:.2f}\n\n"
        data.close()

        dlg = QDialog(self)
        dlg.setWindowTitle("NPZ内部データ構造チェッカー")
        dlg.resize(600, 600)
        dlg.setStyleSheet("QDialog { background-color: #0A192F; color: #E0E0E0; } QTextEdit { background: #112240; color: #64FFDA; font-family: Consolas, monospace; font-size: 11pt; padding: 10px; border: 1px solid #457B9D; }")
        ly = QVBoxLayout(dlg)
        te = QTextEdit()
        te.setReadOnly(True)
        te.setText(info)
        ly.addWidget(te)
        btn = QPushButton("閉じる")
        btn.setStyleSheet("background-color: #1D3557; color: white; padding: 10px; font-weight: bold;")
        btn.clicked.connect(dlg.accept)
        ly.addWidget(btn)
        dlg.exec()
    except: pass

def open_admin_panel(self):
    auth = AdminAuthDialog(self)
    if auth.exec() == QDialog.DialogCode.Accepted and auth.authenticated:
        panel = AdminPanelDialog(self)
        panel.exec()

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):
            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 export_web_images(self, model_name):
    ui = self.tab_ui[model_name]
    it_str = self.model_init_times.get(model_name)
    if not it_str:
        QMessageBox.warning(self, "エラー", "データが存在しません。")
        return

    elem_name = ui['list'].currentItem().text()

    export_dir = QFileDialog.getExistingDirectory(self, "WEB用画像の一括出力先フォルダを選択", os.path.join(os.getcwd(), "web_export"))
    if not export_dir: return

    step = ui['step']
    max_ft = ui['slider'].maximum()

    self.status_banner.setText(f"{model_name} {elem_name} の一括画像出力を開始します...")
    QApplication.processEvents()

    original_val = ui['slider'].value()
    saved_count = 0

    for val in range(0, max_ft + 1, step):
        if "降水" in elem_name and val == 0: continue 

        ui['slider'].blockSignals(True)
        ui['slider'].setValue(val)
        ui['slider'].blockSignals(False)

        self.draw_frame(model_name, val)
        QApplication.processEvents()

        dt = datetime.strptime(it_str, "%Y%m%d%H%M%S")
        init_z = (dt - timedelta(hours=9)).strftime("%m%d%H") + "Z"

        en_elem_name = ELEM_EN_MAP.get(elem_name, elem_name.replace("・", "").replace(" ", "_"))
        filename = f"{model_name}_{en_elem_name}_{init_z}_FT{val:02d}.png"
        filepath = os.path.join(export_dir, filename)

        ui['fig'].savefig(filepath, dpi=150, bbox_inches='tight', facecolor='white')
        saved_count += 1
        self.status_banner.setText(f"出力中... {saved_count}枚保存完了 (FT={val}h)")
        QApplication.processEvents()

    ui['slider'].blockSignals(True)
    ui['slider'].setValue(original_val)
    ui['slider'].blockSignals(False)
    self.draw_frame(model_name, original_val)

    self.status_banner.setText(f"出力完了!計 {saved_count} 枚の画像を保存しました。")
    QMessageBox.information(self, "出力完了", f"{saved_count}枚の画像を一括出力しました。\n保存先: {export_dir}")

def save_current_image(self):
    current_tab = self.tabs.tabText(self.tabs.currentIndex())
    model_name = "MSM" if "MSM" in current_tab else ("GSM_JP" if "日本域" in current_tab else ("GUID" if "ガイダンス" in current_tab else "GSM_GL"))
    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 = datetime.strptime(it_str, "%Y%m%d%H%M%S")
    init_z = (dt - timedelta(hours=9)).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 get_extent(self, area_index, model_name):
    if model_name == "GSM_GL": return [30, 240, -10, 60] 
    if area_index == 0: return [120, 150, 27.5, 48] if "MSM" in model_name or model_name == "GUID" 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]  
    else: return [141.15, 141.55, 42.92, 43.20]              

def crop_data(self, lon, lat, data, extent, model_name="", polar_state=0):
    if lon is None or lat is None or data is None:
        return np.array([]), np.array([]), np.array([])
    if model_name == "GSM_GL":
        if polar_state == 1 or polar_state == 2: 
            lat_mask = (lat >= 40) if polar_state == 1 else (lat <= -40)
            lat_c = lat[lat_mask]
            data_c = data[lat_mask, :] if data.ndim == 2 else data
            lon_c = lon.copy()
            if lon_c.ndim == 1 and lon_c.max() > 180:
                lon_c = np.where(lon_c > 180, lon_c - 360, lon_c)
                sort_idx = np.argsort(lon_c)
                lon_c = lon_c[sort_idx]
                if data_c.ndim == 2: data_c = data_c[:, sort_idx]
            return lon_c, lat_c, data_c
        else:
            buffer = 2.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 lon, lat, data
                return lon[m_lon], lat[m_lat], data[np.ix_(m_lat, m_lon)]
        return lon, lat, data

    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, model_name="", polar_state=0):
    SEA_COLOR = '#A4C2E4'; LAND_COLOR = '#F5F5F0'; COASTLINE_COLOR = '#333333'
    ax.set_facecolor(SEA_COLOR); ax.add_feature(cfeature.LAND.with_scale('10m' if model_name != "GSM_GL" else '110m'), facecolor=LAND_COLOR, zorder=0)
    ax.add_feature(cfeature.OCEAN.with_scale('10m' if model_name != "GSM_GL" else '110m'), facecolor=SEA_COLOR, zorder=0)
    if model_name != "GSM_GL":
        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)
    ax.add_feature(cfeature.COASTLINE.with_scale('110m' if model_name == "GSM_GL" else '10m'), linewidth=0.8 if model_name == "GSM_GL" else 0.6, edgecolor=COASTLINE_COLOR, zorder=2)

    if model_name != "GSM_GL":
        other_cities_g, sapporo_districts_g, sapporo_city_g, kamikawa_g = get_simplified_geometries()
        if area_index >= 1 and other_cities_g: ax.add_geometries(other_cities_g, ccrs.PlateCarree(), edgecolor='#888888', facecolor='none', linewidth=0.6, linestyle=':', zorder=3)
        if sapporo_city_g and area_index == 3: ax.add_geometries(sapporo_city_g, ccrs.PlateCarree(), edgecolor='#FF0000', facecolor='#FF8C00', alpha=0.3, linewidth=1.5, 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)
        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)

    if polar_state > 0:
        try: gl = ax.gridlines(draw_labels=True, dms=True, x_inline=False, y_inline=False, linewidth=0.5, color='gray', alpha=0.8, linestyle='--', zorder=6)
        except: gl = ax.gridlines(draw_labels=False, linewidth=0.5, color='gray', alpha=0.8, linestyle='--', zorder=6)
    else:
        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 model_name == "GSM_GL":
            gl.xlocator = mpl.ticker.MultipleLocator(30.0); gl.ylocator = mpl.ticker.MultipleLocator(15.0)
        else:
            if 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 model_name == "GSM_GL" 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 ""
    info = f"[{loc_name}現況]\n" 

    if "気圧" in elem_name: info += f"気圧: {s_val:.1f} hPa"
    elif "降水確率" in elem_name: info += f"降水確率: {s_val:.0f} %"
    elif "降雪確率" in elem_name: info += f"降雪確率: {s_val:.0f} %"
    elif "発雷確率" in elem_name: info += f"発雷確率: {s_val:.0f} %"
    elif "卓越天気" in elem_name: 
        wx_map = {0: "晴", 1: "曇", 2: "雨", 3: "雪", 4: "不明"}
        info += f"卓越天気: {wx_map.get(int(s_val), '不明')}"
    elif "降水" in elem_name: info += f"降水量: {s_val:.1f} mm"
    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 = (model_name in ["MSM", "GSM_JP", "GUID"] and idx == len(ui['area_btns'])-1 and "メタグラム" in ui['area_btns'][-1].text())
    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 model_name in ["MSM", "GSM_JP", "GUID"] and len(ui['area_btns']) > 4 and ui['area_btns'][-2].isChecked(): return # meta check

        elem_name = ui['list'].currentItem().text()
        val = ui['slider'].value()
        if "降水" in elem_name and val == 0 and "確率" not in elem_name:
            val = ui['step']
            ui['slider'].blockSignals(True)
            ui['slider'].setValue(val)
            ui['slider'].blockSignals(False)

        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 == "GUID": default_max = 78 if hh in ['00', '12'] else 39; step = 3; tick_step = 6
    else: default_max = 264 if hh in ['00', '12'] else 132; step = 6; tick_step = 24

    final_max = max(default_max, max_ft_found); final_max = (final_max // step) * step

    ui['slider'].setMinimum(0); 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)
    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 == "MSM" else (3 if model_name in ["GSM_JP", "GUID"] else 6)

    left_panel.addWidget(QLabel(f"📡 {model_name} 初期時刻:", 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 model_name == "GUID":
        elems = ["降水確率", "降雪確率", "発雷確率", "卓越天気"]
    else:
        elems = ["地上気圧","地上風向風速", "地上降水", "全雲量", "上層雲量", "中層雲量", "下層雲量", "300hpa高度", "300hpa風向・風速", "500hpa高度", "500hpa風向・風速", "500hpa渦度", "500hpa気温", "700hpa湿数・風", "700hpa鉛直流", "850hpa気温・風", "850hpa相当温位", "850hpa湿数・風", "925hpa相当温位", "925hpa湿数・風", "975hpa相当温位", "975hpa湿数・風"]
        if model_name == "GSM_GL":
            elems = [e for e in elems if "925" not in e and "975" not in e]
            elems.extend(["北極域500hpa高度", "南極域500hpa高度"])

    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_model_tab(self, model_name):
    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("表示領域:"))

    if model_name == "GSM_GL": area_btns = [QPushButton("全球域\n(E30~W120 / S10~N60)")]
    else: area_btns = [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 model_name in ["MSM", "GSM_JP"]:
        btn_meta_draw = QPushButton(f"{model_name}メタグラム")
        btn_meta_draw.setProperty("class", "MetaButton")
        btn_meta_draw.setCheckable(True)
        area_layout.addWidget(btn_meta_draw); area_btns.append(btn_meta_draw)
        btn_meta_draw.clicked.connect(lambda checked, m=model_name: self.on_area_btn_clicked(m, len(area_btns)-1))

    area_layout.addStretch()
    btn_export = QPushButton("🌐 WEB用 一括画像出力")
    btn_export.setProperty("class", "WebExportButton")
    btn_export.clicked.connect(lambda checked, m=model_name: self.export_web_images(m))
    area_layout.addWidget(btn_export)

    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)
    btn_generate_meta = QPushButton("表示 (図解メタグラム生成)")
    btn_generate_meta.setProperty("class", "SyncButton")

    if 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 == "MSM" else (3 if model_name == "GSM_JP" else 6)

    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, 'current_polar_state': -1
    }

    def step_slider(val_change, m=model_name):
        ui = self.tab_ui[m]; s_step = ui['step']
        new_val = ui['slider'].value() + (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)

    init_combo.currentIndexChanged.connect(lambda idx, m=model_name: self.on_init_changed(m, idx))
    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))
    element_list.itemSelectionChanged.connect(lambda m=model_name: self.on_slider_changed(m, self.tab_ui[m]['slider'].value()))
    btn_play.clicked.connect(lambda: self.start_animation(model_name)); btn_stop.clicked.connect(self.stop_animation)

    title = "MSM" if model_name == "MSM" else ("GSM 日本域" if model_name == "GSM_JP" else "GSM 全球")
    self.tabs.addTab(tab, f"{title} GPV")

def setup_guidance_tab(self, model_name):
    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("札幌近郊")]
    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)

    btn_meta_draw = QPushButton("ガイダンスメタグラム")
    btn_meta_draw.setProperty("class", "MetaButton")
    btn_meta_draw.setCheckable(True)
    area_layout.addWidget(btn_meta_draw); area_btns.append(btn_meta_draw)
    btn_meta_draw.clicked.connect(lambda checked, m=model_name: self.on_area_btn_clicked(m, len(area_btns)-1))

    area_layout.addStretch()
    btn_export = QPushButton("🌐 WEB用 一括画像出力")
    btn_export.setProperty("class", "WebExportButton")
    btn_export.clicked.connect(lambda checked, m=model_name: self.export_web_images(m))
    area_layout.addWidget(btn_export)

    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)
    btn_generate_meta = QPushButton("表示 (確率・天候時系列生成)")
    btn_generate_meta.setProperty("class", "SyncButton")

    btn_generate_meta.clicked.connect(lambda: self.draw_guidance_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 = 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, 'current_polar_state': -1
    }

    def step_slider(val_change, m=model_name):
        ui = self.tab_ui[m]; s_step = ui['step']
        new_val = ui['slider'].value() + (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)

    init_combo.currentIndexChanged.connect(lambda idx, m=model_name: self.on_init_changed(m, idx))
    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))
    element_list.itemSelectionChanged.connect(lambda m=model_name: self.on_slider_changed(m, self.tab_ui[m]['slider'].value()))
    btn_play.clicked.connect(lambda: self.start_animation(model_name)); btn_stop.clicked.connect(self.stop_animation)

    self.tabs.addTab(tab, "ガイダンス (GUID)")

# =======================================================
# 抽出・判定エンジン
# =======================================================
def get_precip_key(self, data):
    exact_matches = ['pr', 'tp', 'apcp', 'prate', 'precip', 'rain', 'rn']
    for k in data.files:
        if k.lower() in exact_matches: return k

    excludes = ['prmsl', 'pres', 'sp', 'msl', 'tmp', 'hgt', 'u10', 'v10', 'rh', 'cl', 'vis', '気圧', '気温', '風', '雲', '湿', '高度', '渦度']
    partial_matches = ['pr', 'apcp', 'tp', 'prate', 'precip', 'rain', 'rn', '降水', '0_1_8', '0_1_52']
    for k in data.files:
        kl = k.lower()
        if any(ex in kl for ex in excludes): continue
        if any(cand in kl for cand in partial_matches): return k

    for k in data.files:
        if 'unknown' in k.lower():
            arr = data[k]
            if np.nanmin(arr) >= -0.01 and np.nanmax(arr) > 0.01:
                return k
    return None

def get_w_key(self, data):
    exact_keys = ['w', 'w700', 'vvel', 'vvel700', 'dzdt', 'dzdt700']
    for k in data.files:
        if k.lower() in exact_keys: return k
    for k in data.files:
        kl = k.lower()
        if 'w' in kl and 'unknown' not in kl and 'rain' not in kl and 'pr' not in kl: return k

    for k in data.files:
        if 'unknown' in k.lower():
            arr = data[k]
            if np.nanmin(arr) < -0.01: 
                return k
    return None

def fuzzy_key_search(self, data_obj, keywords, exclude=None):
    if exclude is None: exclude = ['lon', 'lat', 'time']
    for k in data_obj.files:
        kl = k.lower()
        if any(ex in kl for ex in exclude): continue
        if any(kw in kl for kw in keywords): return k
    return None

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: return np.nan

    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 = None
    for f in data.files:
        fl = f.lower()
        if fl in keys:
            k = f; break
    if not k:
        for f in data.files:
            fl = f.lower()
            for kw in keys:
                if kw in fl:
                    if ('pr' in kw or 'rain' in kw or 'tp' in kw) and any(x in fl for x in ['prmsl', 'pres', 'sp', 'msl']): continue
                    k = f; break
            if k: break
    if not k: return np.nan
    return self.extract_val_with_key(data, k, stat_lon, stat_lat)

# --- MSM用メタグラム ---
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()
    init_dt = datetime.strptime(it_str, "%Y%m%d%H%M%S") + timedelta(hours=9)
    utc_dt = init_dt - timedelta(hours=9); init_z_str = f"{utc_dt.strftime('%m%d%H')}Z" 
    title_time_str = f"{utc_dt.strftime('%m月%d日%H')}Z / {init_dt.strftime('%H')}I" 

    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_850_series = np.full(len(fts_prof), np.nan); SSI_700_series = np.full(len(fts_prof), np.nan); 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)

    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: pass

    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}'], 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}'], 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}'], stat_lon, stat_lat)
                V_prof[idx, c_idx] = self.extract_val_robust(d, [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]

            if not np.isnan(t500):
                if not np.isnan(t850) and not np.isnan(rh850):
                    SSI_850_series[c_idx] = t500 - get_t_from_ept(calc_ept(t850, rh850, 850), 500)
                if not np.isnan(t700) and not np.isnan(rh700):
                    SSI_700_series[c_idx] = t500 - get_t_from_ept(calc_ept(t700, rh700, 700), 500)
            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=(10, 16)); fig.patch.set_facecolor('white')
    fig.subplots_adjust(left=0.08, right=0.98, top=0.93, bottom=0.08, hspace=0.65) 
    fig.suptitle(f"MSM 図解メタグラム - {stat_name} (初期時: {title_time_str})", fontsize=16, weight='bold')

    gs = fig.add_gridspec(14, 1)
    ax_cross = fig.add_subplot(gs[0:5, 0]); ax_slp = fig.add_subplot(gs[5:7, 0], sharex=ax_cross)
    ax_t2m = 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_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=10, va='center')
    ax_slp.legend(loc='upper left')

    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_t2m.legend(loc='upper left')

    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)

    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 (700-500)")
    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_slp, ax_t2m, 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:
            import matplotlib.pyplot as plt
            plt.setp(ax.get_xticklabels(), visible=False)

    ax_top = ax_cross.twiny()
    ax_top.set_xlim(ax_cross.get_xlim())
    ax_top.set_xticks(range(0, fts_surf[-1]+1, 6))
    ax_top.set_xticklabels([f"FT{x}" for x in range(0, fts_surf[-1]+1, 6)], fontsize=10, weight='bold')

    import matplotlib.pyplot as plt
    plt.setp(ax_ssi.get_xticklabels(), rotation=45, ha='right', fontsize=11)

    fig.align_ylabels([ax_cross, ax_slp, ax_t2m, ax_pr, ax_ssi]) 
    canvas = FigureCanvas(fig)
    win = SingleImageWindow(self, f"MSM 図解メタグラム - {stat_name}", "MSM", stat_name, init_z_str, canvas)
    win.exec()

# --- GSM用メタグラム ---
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()
    init_dt = datetime.strptime(it_str, "%Y%m%d%H%M%S") + timedelta(hours=9)
    utc_dt = init_dt - timedelta(hours=9); init_z_str = f"{utc_dt.strftime('%m%d%H')}Z" 
    title_time_str = f"{utc_dt.strftime('%m月%d日%H')}Z / {init_dt.strftime('%H')}I" 

    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)
    SSI_850_series = np.full(len(fts), np.nan); SSI_700_series = np.full(len(fts), np.nan); 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)

    for c_idx, ft in enumerate(fts):
        prof_ft = ft if ft <= 132 or ft % 6 == 0 else ft - (ft % 6)
        gpv_file_surf = os.path.join(self.cache_dir, f"GSM_JP_{it_str}_FT{ft:02d}.npz")
        gpv_file_prof = os.path.join(self.cache_dir, f"GSM_JP_{it_str}_FT{prof_ft:02d}.npz")

        if os.path.exists(gpv_file_surf):
            try:
                d = np.load(gpv_file_surf)
                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: pass

        if os.path.exists(gpv_file_prof):
            try:
                d = np.load(gpv_file_prof)
                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}'], 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}'], 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}'], stat_lon, stat_lat)
                    V_prof[idx, c_idx] = self.extract_val_robust(d, [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]

                if not np.isnan(t500):
                    if not np.isnan(t850) and not np.isnan(rh850):
                        SSI_850_series[c_idx] = t500 - get_t_from_ept(calc_ept(t850, rh850, 850), 500)
                    if not np.isnan(t700) and not np.isnan(rh700):
                        SSI_700_series[c_idx] = t500 - get_t_from_ept(calc_ept(t700, rh700, 700), 500)
                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=(15, 12)); fig.patch.set_facecolor('white')
    fig.subplots_adjust(left=0.08, right=0.98, top=0.91, bottom=0.08, hspace=0.65) 
    fig.suptitle(f"GSM_JP 図解メタグラム - {stat_name} (初期時: {title_time_str})", fontsize=16, weight='bold')

    gs = fig.add_gridspec(12, 1); ax_cross = fig.add_subplot(gs[0:4, 0]); ax_slp = fig.add_subplot(gs[4:6, 0], sharex=ax_cross)
    ax_t2m = fig.add_subplot(gs[6:8, 0], sharex=ax_cross); ax_pr = fig.add_subplot(gs[8:10, 0], sharex=ax_cross); ax_ssi = fig.add_subplot(gs[10:12, 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_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=10, va='center')
    ax_slp.legend(loc='upper left')

    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_t2m.legend(loc='upper left')

    ax_pr.bar(fts, Pr_surf_series, width=1.8, color='#3498DB', alpha=0.9, label="期間降水量 (3時間積算)")
    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)

    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 (700-500)")
    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_slp, ax_t2m, 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:
            import matplotlib.pyplot as plt
            plt.setp(ax.get_xticklabels(), visible=False)

    ax_top = ax_cross.twiny()
    ax_top.set_xlim(ax_cross.get_xlim())
    ax_top.set_xticks(range(0, fts[-1]+1, 12))
    ax_top.set_xticklabels([f"FT{x}" for x in range(0, fts[-1]+1, 12)], fontsize=10, weight='bold')

    import matplotlib.pyplot as plt
    plt.setp(ax_ssi.get_xticklabels(), rotation=45, ha='right', fontsize=11)

    fig.align_ylabels([ax_cross, ax_slp, ax_t2m, 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()

# --- GUID用メタグラム ---
def draw_guidance_metagram(self, station_combo):
    it_str = self.model_init_times.get("GUID")
    if not it_str:
        QMessageBox.warning(self, "エラー", "データが存在しません。"); return

    stat_name = station_combo.currentText(); stat_lat, stat_lon = station_combo.currentData()
    init_dt = datetime.strptime(it_str, "%Y%m%d%H%M%S") + timedelta(hours=9)
    utc_dt = init_dt - timedelta(hours=9); init_z_str = f"{utc_dt.strftime('%m%d%H')}Z" 
    title_time_str = f"{utc_dt.strftime('%m月%d日%H')}Z / {init_dt.strftime('%H')}I" 

    max_ft = self.tab_ui['GUID']['slider'].maximum()
    fts = list(range(0, max_ft + 1, 3))

    POP_series = np.full(len(fts), np.nan)
    POS_series = np.full(len(fts), np.nan)
    POL_series = np.full(len(fts), np.nan)

    for c_idx, ft in enumerate(fts):
        gpv_file = os.path.join(self.cache_dir, f"GUID_{it_str}_FT{ft:02d}.npz")
        if not os.path.exists(gpv_file): continue
        try:
            d = np.load(gpv_file)
            POP_series[c_idx] = self.extract_val_robust(d, ['pop', 'prob_precip'], stat_lon, stat_lat)
            POS_series[c_idx] = self.extract_val_robust(d, ['pos', 'prob_snow'], stat_lon, stat_lat)
            POL_series[c_idx] = self.extract_val_robust(d, ['pol', 'prob_lightning'], stat_lon, stat_lat)
            d.close()
        except: pass

    fig = Figure(figsize=(12, 10)); fig.patch.set_facecolor('white')
    fig.subplots_adjust(left=0.08, right=0.98, top=0.93, bottom=0.1, hspace=0.4) 
    fig.suptitle(f"ガイダンス 図解メタグラム - {stat_name} (初期時: {title_time_str})", fontsize=16, weight='bold')

    ax_pop = fig.add_subplot(3, 1, 1)
    ax_pos = fig.add_subplot(3, 1, 2, sharex=ax_pop)
    ax_pol = fig.add_subplot(3, 1, 3, sharex=ax_pop)

    ax_pop.bar(fts, POP_series, width=1.5, color='#3498DB', alpha=0.8, label="降水確率")
    ax_pop.set_ylim(0, 100); ax_pop.set_ylabel("降水確率\n[%]", weight='bold', rotation=0, labelpad=30, va='center')
    ax_pop.legend(loc='upper right')

    ax_pos.bar(fts, POS_series, width=1.5, color='#9B59B6', alpha=0.8, label="降雪確率")
    ax_pos.set_ylim(0, 100); ax_pos.set_ylabel("降雪確率\n[%]", weight='bold', rotation=0, labelpad=30, va='center')
    ax_pos.legend(loc='upper right')

    ax_pol.bar(fts, POL_series, width=1.5, color='#F39C12', alpha=0.8, label="発雷確率")
    ax_pol.set_ylim(0, 100); ax_pol.set_ylabel("発雷確率\n[%]", weight='bold', rotation=0, labelpad=30, va='center')
    ax_pol.legend(loc='upper right')
    ax_pol.set_xlabel("予報時間 (FT)", weight='bold', fontsize=12)

    for ax in [ax_pop, ax_pos, ax_pol]:
        ax.set_xlim(fts[0]-1, fts[-1]+1)
        ax.set_xticks(range(0, fts[-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_pol:
            import matplotlib.pyplot as plt
            plt.setp(ax.get_xticklabels(), visible=False)

    canvas = FigureCanvas(fig)
    win = SingleImageWindow(self, f"ガイダンス メタグラム - {stat_name}", "GUID", stat_name, init_z_str, canvas)
    win.exec()

def start_animation(self, model_name):
    ui = self.tab_ui[model_name]; self.current_anim_model = model_name
    self.anim_timer.start([2000, 1000, 500, 200, 50][ui['combo_speed'].currentIndex()])

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]
    new_val = ui['slider'].value() + ui['step']
    if new_val > ui['slider'].maximum(): new_val = ui['slider'].minimum()
    ui['slider'].setValue(new_val)

def on_slider_changed(self, model_name, val):
    ui = self.tab_ui[model_name]; elem_name = ui['list'].currentItem().text()
    if model_name in ["MSM", "GSM_JP", "GUID"] and len(ui['area_btns']) > 4 and ui['area_btns'][-2].isChecked(): return
    step = ui['step']

    if "降水" in elem_name and val == 0 and "確率" not in elem_name:
        val = step
        ui['slider'].blockSignals(True)
        ui['slider'].setValue(val)
        ui['slider'].blockSignals(False)

    if val % step != 0: 
        val = round(val / step) * step; ui['slider'].blockSignals(True); ui['slider'].setValue(val); ui['slider'].blockSignals(False)
    self.draw_frame(model_name, val)

def init_map(self, fig, area_index, model_name, polar_state=0):
    fig.clear()
    left_margin = 0.08 if model_name == "GSM_GL" else 0.06

    if polar_state == 1:
        ax = fig.add_subplot(1,1,1, projection=ccrs.NorthPolarStereo())
        ax.set_extent([-180, 180, 40, 90], crs=ccrs.PlateCarree()) 
        theta = np.linspace(0, 2*np.pi, 100)
        center, radius = [0.5, 0.5], 0.5
        verts = np.vstack([np.sin(theta), np.cos(theta)]).T
        circle = mpath.Path(verts * radius + center)
        ax.set_boundary(circle, transform=ax.transAxes)
        self.draw_base_map(ax, area_index, model_name, polar_state)
        fig.subplots_adjust(left=left_margin, right=0.88, top=0.95, bottom=0.05)
    elif polar_state == 2:
        ax = fig.add_subplot(1,1,1, projection=ccrs.SouthPolarStereo())
        ax.set_extent([-180, 180, -90, -40], crs=ccrs.PlateCarree()) 
        theta = np.linspace(0, 2*np.pi, 100)
        center, radius = [0.5, 0.5], 0.5
        verts = np.vstack([np.sin(theta), np.cos(theta)]).T
        circle = mpath.Path(verts * radius + center)
        ax.set_boundary(circle, transform=ax.transAxes)
        self.draw_base_map(ax, area_index, model_name, polar_state)
        fig.subplots_adjust(left=left_margin, right=0.88, top=0.95, bottom=0.05)
    elif model_name == "GSM_GL":
        ax = fig.add_subplot(1,1,1, projection=ccrs.PlateCarree(central_longitude=140.0))
        extent = [30, 240, -10, 60]
        ax.set_extent(extent, crs=ccrs.PlateCarree())
        self.draw_base_map(ax, area_index, model_name, polar_state)
        fig.subplots_adjust(left=0.04, right=0.94, top=0.95, bottom=0.05)
    else:
        ax = fig.add_subplot(1,1,1, projection=ccrs.PlateCarree())
        extent = self.get_extent(area_index, model_name)
        ax.set_extent(extent, crs=ccrs.PlateCarree())
        self.draw_base_map(ax, area_index, model_name, polar_state)
        fig.subplots_adjust(left=left_margin, right=0.88, top=0.95, bottom=0.05)

    return ax

def plot_contour(self, ax, lon, lat, data, ui, levels, color, extent, model_name="", polar_state=0, linewidths=1.5):
    lon_c, lat_c, data_c = self.crop_data(lon, lat, data, extent, model_name, polar_state=polar_state)
    if lon_c.size == 0 or lat_c.size == 0 or data_c.size == 0: return None

    if model_name == "GSM_GL" and polar_state == 0:
        stride = 3
        if lon_c.ndim == 1:
            lon_c = lon_c[::stride]; lat_c = lat_c[::stride]
            data_c = data_c[::stride, ::stride] if data_c.ndim == 2 else data_c
        else:
            lon_c = lon_c[::stride, ::stride]; lat_c = lat_c[::stride, ::stride]
            data_c = data_c[::stride, ::stride] if data_c.ndim == 2 else data_c

    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, model_name="", norm=None, polar_state=0):
    lon_c, lat_c, data_c = self.crop_data(lon, lat, data, extent, model_name, polar_state=polar_state)
    if lon_c.size == 0 or lat_c.size == 0 or data_c.size == 0: return None

    if model_name == "GSM_GL" and polar_state == 0:
        stride = 3
        if lon_c.ndim == 1:
            lon_c = lon_c[::stride]; lat_c = lat_c[::stride]
            data_c = data_c[::stride, ::stride] if data_c.ndim == 2 else data_c
        else:
            lon_c = lon_c[::stride, ::stride]; lat_c = lat_c[::stride, ::stride]
            data_c = data_c[::stride, ::stride] if data_c.ndim == 2 else data_c

    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_barbs(self, ax, lon, lat, u, v, ui, area_index, extent, model_name="", polar_state=0):
    lon_c, lat_c, u_c = self.crop_data(lon, lat, u, extent, model_name, polar_state=polar_state)
    _, _, v_c = self.crop_data(lon, lat, v, extent, model_name, polar_state=polar_state)
    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 model_name == "GSM_GL": skip = 5
    elif area_index == 3: skip = 1
    elif area_index == 2: skip = 1
    elif "MSM" in model_name: skip = 10 if area_index == 0 else 4
    else: skip = 6 if area_index == 0 else 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 get_rh(self, data_obj, level, target_shape=None):
    k = self.fuzzy_key_search(data_obj, [f'rh{level}', f'r{level}'])
    if k and (target_shape is None or data_obj[k].shape == target_shape): return data_obj[k]
    for check_k in data_obj.files:
        if check_k.startswith('r') or 'rh' in check_k:
            if target_shape is not None and data_obj[check_k].shape == target_shape:
                if 'rain' not in check_k.lower() and 'rhavg' not in check_k.lower(): return data_obj[check_k]
    return None

def draw_guidance(self, ax, ui, data, elem_name, extent, area_index, polar_state):
    drawn_flag = False; info_text = ""
    def _cl(arr, minv, maxv, fill=0.0):
        if arr is None: return None
        a = np.array(arr, dtype=float)
        a = np.nan_to_num(a, nan=fill, posinf=fill, neginf=fill)
        return np.clip(a, minv, maxv)

    def add_vertical_colorbar(mappable, label_text):
        cax = ui['fig'].add_axes([0.89, 0.15, 0.02, 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 elem_name == "降水確率":
        key = self.fuzzy_key_search(data, ['pop', 'prob_precip'])
        if key:
            val = _cl(data[key], 0, 100, 0.0); lon_c, lat_c = self.get_coords_for_data(data, val)
            levels = np.arange(10, 110, 10)
            cf = self.plot_contourf(ax, lon_c, lat_c, val, ui, levels, 'Blues', extent, extend='max', alpha=0.7, model_name="GUID")
            if cf: add_vertical_colorbar(cf, '降水確率 [%]'); info_text = self.get_local_info(elem_name, lon_c, lat_c, val, area_index=area_index, model_name="GUID"); drawn_flag = True

    elif elem_name == "降雪確率":
        key = self.fuzzy_key_search(data, ['pos', 'prob_snow'])
        if key:
            val = _cl(data[key], 0, 100, 0.0); lon_c, lat_c = self.get_coords_for_data(data, val)
            levels = np.arange(10, 110, 10)
            cf = self.plot_contourf(ax, lon_c, lat_c, val, ui, levels, 'Purples', extent, extend='max', alpha=0.7, model_name="GUID")
            if cf: add_vertical_colorbar(cf, '降雪確率 [%]'); info_text = self.get_local_info(elem_name, lon_c, lat_c, val, area_index=area_index, model_name="GUID"); drawn_flag = True

    elif elem_name == "発雷確率":
        key = self.fuzzy_key_search(data, ['pol', 'prob_lightning'])
        if key:
            val = _cl(data[key], 0, 100, 0.0); lon_c, lat_c = self.get_coords_for_data(data, val)
            levels = np.arange(10, 110, 10)
            cf = self.plot_contourf(ax, lon_c, lat_c, val, ui, levels, 'Oranges', extent, extend='max', alpha=0.7, model_name="GUID")
            if cf: add_vertical_colorbar(cf, '発雷確率 [%]'); info_text = self.get_local_info(elem_name, lon_c, lat_c, val, area_index=area_index, model_name="GUID"); drawn_flag = True

    elif elem_name == "卓越天気":
        key = self.fuzzy_key_search(data, ['wx', 'weather'])
        if key:
            val = _cl(data[key], 0, 4, 0.0); lon_c, lat_c = self.get_coords_for_data(data, val)
            levels = [0, 1, 2, 3, 4] 
            colors = ['#FFD700', '#B0C4DE', '#1E90FF', '#FFFFFF']
            cmap_wx = mcolors.ListedColormap(colors)
            norm_wx = mcolors.BoundaryNorm(levels, cmap_wx.N)
            cf = self.plot_contourf(ax, lon_c, lat_c, val, ui, levels, cmap_wx, extent, extend='neither', alpha=0.8, model_name="GUID", norm=norm_wx)
            if cf: add_vertical_colorbar(cf, '卓越天気 (0:晴 1:曇 2:雨 3:雪)'); info_text = self.get_local_info(elem_name, lon_c, lat_c, val, area_index=area_index, model_name="GUID"); drawn_flag = True

    return drawn_flag, info_text

def draw_frame(self, model_name, val):
    ui = self.tab_ui[model_name]; it_str = self.model_init_times.get(model_name)
    if not it_str: 
        self.show_error_msg(ui['ax'] if ui['ax'] else self.init_map(ui['fig'], 0, model_name), 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" and val > 132 and val % 6 != 0:
        surface_elems = ["地上気圧", "地上風向風速", "地上降水", "全雲量", "上層雲量", "中層雲量", "下層雲量"]
        if not any(se in elem_name for se in surface_elems):
            display_val = val - (val % 6)
            fallback_msg = f" (※上空データ6時間間隔のため FT={display_val}h を代替表示)"

    init_dt = datetime.strptime(it_str, "%Y%m%d%H%M%S") + timedelta(hours=9)
    target_time = init_dt + timedelta(hours=val)
    ui['time_label'].setText(f"FT={val:02d}h\n({target_time.strftime('%m/%d %H:00')})")

    area_index = 0 if model_name == "GSM_GL" else next(i for i, btn in enumerate(ui['area_btns']) if btn.isChecked())
    polar_state = 1 if "北極域" in elem_name else (2 if "南極域" in elem_name else 0)

    if ui['ax'] is None or ui['current_area_index'] != area_index or ui.get('current_polar_state', -1) != polar_state:
        ui['ax'] = self.init_map(ui['fig'], area_index, model_name, polar_state)
        ui['current_area_index'] = area_index; ui['current_polar_state'] = polar_state

    ax = ui['ax']

    if polar_state == 1: extent = [-180, 180, 40, 90]
    elif polar_state == 2: extent = [-180, 180, -90, -40]
    else: extent = self.get_extent(area_index, model_name)

    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()

    fast_tag = "[全球高速描画] " if model_name == "GSM_GL" and polar_state == 0 else ""
    ax.title.set_text(f"{fast_tag}[{model_name}] {elem_name} / 初期時刻: {init_dt.strftime('%m/%d %H:00')} / FT={val}h ({target_time.strftime('%m/%d %H:00')}){fallback_msg}")

    target_file = os.path.join(self.cache_dir, f"{model_name}_{it_str}_FT{display_val:02d}.npz")
    if not os.path.exists(target_file):
        if "降水" in elem_name and val == 0 and "確率" not in elem_name: self.show_error_msg(ax, ui, "【初期時のためデータなし】")
        else: self.show_error_msg(ax, ui, f"データが存在しません (FT={display_val}h)")
        ui['canvas'].draw_idle(); return

    try:
        data = np.load(target_file)
        lon_key = self.fuzzy_key_search(data, ['lon_surf', 'lon'], exclude=[])
        lat_key = self.fuzzy_key_search(data, ['lat_surf', 'lat'], exclude=[])

        if not lon_key or not lat_key:
            self.show_error_msg(ax, ui, f"[!] {elem_name} の座標データがありません。")
            data.close(); ui['canvas'].draw_idle(); return

        def add_vertical_colorbar(mappable, label_text):
            cb_x = 0.95 if model_name == "GSM_GL" else 0.89
            cax = ui['fig'].add_axes([cb_x, 0.15, 0.02, 0.7]) 
            cb = ui['fig'].colorbar(mappable, cax=cax, orientation='vertical')
            cb.set_label(label_text, fontsize=12, 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)
            a = np.nan_to_num(a, nan=fill, posinf=fill, neginf=fill)
            return np.clip(a, minv, maxv)

        drawn_flag = False; info_text = ""

        if model_name == "GUID":
            drawn_flag, info_text = self.draw_guidance(ax, ui, data, elem_name, extent, area_index, polar_state)

        elif elem_name == "地上気圧":
            p_key = self.fuzzy_key_search(data, ['prmsl', 'mslet', 'msl', 'slp'])
            if p_key:
                slp = np.array(data[p_key], dtype=float)
                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)
                levels = np.arange(940, 1060, 4)
                cs = self.plot_contour(ax, lon_c, lat_c, slp_smooth, ui, levels, '#2980B9', extent, model_name=model_name, polar_state=polar_state)
                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_key = self.fuzzy_key_search(data, ['u10', '10u', 'u_10m'])
            v_key = self.fuzzy_key_search(data, ['v10', '10v', 'v_10m'])
            if u_key and v_key:
                u_val = _cl(data[u_key], -200, 200, 0.0); v_val = _cl(data[v_key], -200, 200, 0.0)
                spd = np.hypot(u_val, v_val); lon_c, lat_c = self.get_coords_for_data(data, spd)
                levels = [2, 4, 6, 8, 10, 15, 20, 25]; colors = ['#E0F7FA', '#B2EBF2', '#4DD0E1', '#00BCD4', '#00838F', '#9C27B0', '#4A148C']
                cf = self.plot_contourf(ax, lon_c, lat_c, spd, ui, levels, colors, extent, extend='max', alpha=0.5, model_name=model_name, polar_state=polar_state)
                if cf:
                    add_vertical_colorbar(cf, '地上風速 [m/s]')
                    self.plot_barbs(ax, lon_c, lat_c, u_val, v_val, ui, area_index, extent, model_name, polar_state=polar_state)
                    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)
                lon_c, lat_c = self.get_coords_for_data(data, pval)
                if pval.ndim == 1 and lon_c.ndim == 1 and lat_c.ndim == 1 and pval.size == len(lat_c) * len(lon_c):
                    pval = pval.reshape((len(lat_c), len(lon_c)))
                pval_smooth = ndimage.gaussian_filter(pval, sigma=0.2)
                levels = [0.1, 1, 5, 10, 20, 30, 50, 80]
                colors = ['#A0E8A0', '#D4F05A', '#FFFF00', '#FFA500', '#FF5500', '#FF0000', '#FF00FF'] 
                cmap_precip = mcolors.ListedColormap(colors)
                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, model_name=model_name, norm=norm_precip, polar_state=polar_state)
                if cf:
                    self.plot_contour(ax, lon_c, lat_c, pval_smooth, ui, levels, '#444444', extent, model_name=model_name, polar_state=polar_state, linewidths=0.5)
                    label_time = "1時間" if model_name == "MSM" else ("3時間" if model_name == "GSM_JP" else "積算")
                    add_vertical_colorbar(cf, f'降水量({label_time}) [mm]')
                    info_text = self.get_local_info("降水", lon_c, lat_c, pval, area_index=area_index, model_name=model_name); drawn_flag = True
            else:
                if val != 0:
                    self.show_error_msg(ax, ui, f"[!] 降水キー不明。\n初期時刻、またはデータが未収録の可能性があります。")
                    drawn_flag = True

        elif "雲量" in elem_name:
            c_key = None
            if elem_name == "全雲量": c_key = self.fuzzy_key_search(data, ['tcc', 'tcdc', 'lcdc', 'cloud'])
            elif elem_name == "上層雲量": c_key = self.fuzzy_key_search(data, ['hcc', 'hcdc', 'high'])
            elif elem_name == "中層雲量": c_key = self.fuzzy_key_search(data, ['mcc', 'mcdc', 'mid'])
            elif elem_name == "下層雲量": c_key = self.fuzzy_key_search(data, ['lcc', 'lcdc', 'low'])

            if c_key:
                c_val = _cl(data[c_key], 0, 150, 0.0)
                lon_c, lat_c = self.get_coords_for_data(data, c_val)
                if np.nanmax(c_val) <= 1.0 and np.nanmax(c_val) > 0: c_val = c_val * 100.0

                levels = [20, 30, 40, 50, 60, 70, 80, 90, 100]
                grey_colors = ['#D9D9D9', '#BDBDBD', '#969696', '#737373', '#525252', '#252525', '#0F0F0F', '#000000']
                cmap_cloud = mcolors.ListedColormap(grey_colors); 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, model_name=model_name, norm=norm_cloud, polar_state=polar_state)
                if cf:
                    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

        elif "300hpa高度" in elem_name:
            k = self.fuzzy_key_search(data, ['gh300', 'hgt300'])
            if k:
                h_val = _cl(data[k], -500, 20000, 0.0); lon_p, lat_p = self.get_coords_for_data(data, h_val)
                levels = np.arange(8400, 10800, 60); cf = self.plot_contourf(ax, lon_p, lat_p, h_val, ui, levels, 'PuBu', extent, alpha=0.6, model_name=model_name, polar_state=polar_state)
                self.plot_contour(ax, lon_p, lat_p, h_val, ui, levels, 'darkblue', extent, model_name=model_name, polar_state=polar_state)
                if cf:
                    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:
            ku = self.fuzzy_key_search(data, ['u300']); kv = self.fuzzy_key_search(data, ['v300'])
            if ku and kv:
                u_val = _cl(data[ku], -300, 300, 0.0); v_val = _cl(data[kv], -300, 300, 0.0)
                spd = np.hypot(u_val, v_val); lon_p, lat_p = self.get_coords_for_data(data, spd)
                levels = [20, 30, 40, 50, 60, 80, 100]
                cf = self.plot_contourf(ax, lon_p, lat_p, spd, ui, levels, 'YlOrRd', extent, extend='max', alpha=0.35, model_name=model_name, polar_state=polar_state)
                self.plot_barbs(ax, lon_p, lat_p, u_val, v_val, ui, area_index, extent, model_name, polar_state=polar_state)
                if cf:
                    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 or "500hPa高度" in elem_name:
            k = self.fuzzy_key_search(data, ['gh500', 'hgt500'])
            if k:
                h_val = _cl(data[k], -500, 20000, 0.0); lon_p, lat_p = self.get_coords_for_data(data, h_val)
                levels = np.arange(4800, 6000, 60); cf = self.plot_contourf(ax, lon_p, lat_p, h_val, ui, levels, 'Greens', extent, alpha=0.6, model_name=model_name, polar_state=polar_state)
                self.plot_contour(ax, lon_p, lat_p, h_val, ui, levels, 'darkgreen', extent, model_name=model_name, polar_state=polar_state)
                if cf:
                    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:
            ku = self.fuzzy_key_search(data, ['u500']); kv = self.fuzzy_key_search(data, ['v500'])
            if ku and kv: 
                u_val = _cl(data[ku], -300, 300, 0.0); v_val = _cl(data[kv], -300, 300, 0.0)
                spd = np.hypot(u_val, v_val); lon_p, lat_p = self.get_coords_for_data(data, spd)
                levels = [10, 20, 30, 40, 50, 60, 80]
                cf = self.plot_contourf(ax, lon_p, lat_p, spd, ui, levels, 'YlOrRd', extent, extend='max', alpha=0.35, model_name=model_name, polar_state=polar_state)
                self.plot_barbs(ax, lon_p, lat_p, u_val, v_val, ui, area_index, extent, model_name, polar_state=polar_state)
                if cf:
                    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:
            v_key = self.fuzzy_key_search(data, ['vort500', 'vort', 'abs_vort'])
            if v_key:
                vort = _cl(data[v_key], -1000, 1000, 0.0); lon_p, lat_p = self.get_coords_for_data(data, vort)
                levels = [-50, -20, 0, 20, 40, 60, 80, 100, 150, 200]
                colors = ['#ffffff', '#fdf0e6', '#fad7a1', '#f8c471', '#f39c12', '#d35400', '#c0392b', '#922b21', '#641e16']
                cf = self.plot_contourf(ax, lon_p, lat_p, vort, ui, levels, colors, extent, extend='both', alpha=0.5, model_name=model_name, polar_state=polar_state)
                self.plot_contour(ax, lon_p, lat_p, vort, ui, levels, '#4A235A', extent, model_name=model_name, polar_state=polar_state)
                if cf:
                    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 "500hpa気温" in elem_name:
            t_key = self.fuzzy_key_search(data, ['t500', 'tmp500'])
            if t_key:
                t_val = np.array(data[t_key], dtype=float)
                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, model_name=model_name, polar_state=polar_state)
                self.plot_contour(ax, lon_p, lat_p, t_val, ui, np.arange(-54, 15, 3), 'blue', extent, model_name=model_name, polar_state=polar_state)
                if cf:
                    add_vertical_colorbar(cf, '500hPa 気温 [℃]')
                    lon_c, lat_c, t_c = self.crop_data(lon_p, lat_p, t_val, extent, model_name=model_name, polar_state=polar_state)
                    if lon_c.size > 0:
                        stride = 3 if model_name == "GSM_GL" and polar_state == 0 else 1
                        cs = ax.contour(lon_c[::stride] if lon_c.ndim==1 else lon_c[::stride,::stride], 
                                        lat_c[::stride] if lat_c.ndim==1 else lat_c[::stride,::stride], 
                                        t_c[::stride,::stride], levels=[-36, 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')
                    info_text = self.get_local_info(elem_name, lon_p, lat_p, t_val, area_index=area_index, model_name=model_name); drawn_flag = True

        elif "700hpa湿数" in elem_name:
            t_key = self.fuzzy_key_search(data, ['t700', 'tmp700'])
            t = np.array(data[t_key], dtype=float) if t_key else None
            if t is not None and np.nanmax(t) > 100: t = t - 273.15
            rh_raw = self.get_rh(data, 700, target_shape=t.shape if t is not None else None)
            rh = _cl(rh_raw, 0, 150, 0.0) if rh_raw is not None else None
            if rh is not None and np.nanmax(rh) <= 1.0 and np.nanmax(rh) > 0: rh = rh * 100.0

            if t is not None and rh is not None:
                lon_p, lat_p = self.get_coords_for_data(data, t); t_td = calc_t_td(t, rh)
                levels = [0, 3, 6, 9, 12, 15]; colors = ['#27AE60', '#82E0AA', '#D5F5E3', '#F9E79F', '#F5CBA7', '#E74C3C']
                cf = self.plot_contourf(ax, lon_p, lat_p, t_td, ui, levels, colors, extent, extend='max', alpha=0.35, model_name=model_name, polar_state=polar_state) 
                u_key = self.fuzzy_key_search(data, ['u700']); v_key = self.fuzzy_key_search(data, ['v700'])
                u_val = _cl(data[u_key], -200, 200, 0.0) if u_key else None
                v_val = _cl(data[v_key], -200, 200, 0.0) if v_key else None
                if cf:
                    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, polar_state=polar_state)

        elif "700hpa鉛直流" in elem_name:
            w_key = self.get_w_key(data)
            if w_key:
                w_val = np.array(data[w_key], dtype=float)
                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 = ndimage.gaussian_filter(w_val, sigma=0.8)
                w_val_masked = np.ma.masked_inside(w_val, -4.9, 4.9)
                levels = [-50, -30, -20, -10, -5, 5, 10, 20, 30, 50]
                cf = self.plot_contourf(ax, lon_p, lat_p, w_val_masked, ui, levels, 'PiYG', extent, extend='both', alpha=0.8, model_name=model_name, polar_state=polar_state)
                if cf:
                    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:
            t_key = self.fuzzy_key_search(data, ['t850', 'tmp850'])
            if t_key:
                t_val = np.array(data[t_key], dtype=float)
                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(-30, 40, 3), 'jet', extent, extend='both', alpha=0.3, model_name=model_name, polar_state=polar_state)
                self.plot_contour(ax, lon_p, lat_p, t_val, ui, np.arange(-30, 40, 3), 'blue', extent, model_name=model_name, polar_state=polar_state)
                if cf:
                    add_vertical_colorbar(cf, '850hPa 気温 [℃]')
                    lon_c, lat_c, t_c = self.crop_data(lon_p, lat_p, t_val, extent, model_name=model_name, polar_state=polar_state)
                    if lon_c.size > 0:
                        stride = 3 if model_name == "GSM_GL" and polar_state == 0 else 1
                        cs = ax.contour(lon_c[::stride] if lon_c.ndim==1 else lon_c[::stride,::stride], 
                                        lat_c[::stride] if lat_c.ndim==1 else lat_c[::stride,::stride], 
                                        t_c[::stride,::stride], levels=[-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_key = self.fuzzy_key_search(data, ['u850']); v_key = self.fuzzy_key_search(data, ['v850'])
                    u_val = _cl(data[u_key], -200, 200, 0.0) if u_key else None
                    v_val = _cl(data[v_key], -200, 200, 0.0) if v_key else None
                    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, polar_state=polar_state)

        elif "850hpa相当温位" in elem_name:
            t_key = self.fuzzy_key_search(data, ['t850', 'tmp850'])
            t = np.array(data[t_key], dtype=float) if t_key else None
            if t is not None and np.nanmax(t) > 100: t = t - 273.15
            rh_raw = self.get_rh(data, 850, target_shape=t.shape if t is not None else None)
            rh = _cl(rh_raw, 0, 150, 0.0) if rh_raw is not None else None
            if rh is not None and np.nanmax(rh) <= 1.0 and np.nanmax(rh) > 0: rh = rh * 100.0
            if t is not None and rh is not None:
                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, model_name=model_name, polar_state=polar_state) 
                self.plot_contour(ax, lon_p, lat_p, ept, ui, levels, 'black', extent, model_name=model_name, polar_state=polar_state)
                if cf:
                    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:
            t_key = self.fuzzy_key_search(data, ['t850', 'tmp850'])
            t = np.array(data[t_key], dtype=float) if t_key else None
            if t is not None and np.nanmax(t) > 100: t = t - 273.15
            rh_raw = self.get_rh(data, 850, target_shape=t.shape if t is not None else None)
            rh = _cl(rh_raw, 0, 150, 0.0) if rh_raw is not None else None
            if rh is not None and np.nanmax(rh) <= 1.0 and np.nanmax(rh) > 0: rh = rh * 100.0
            if t is not None and rh is not None:
                lon_p, lat_p = self.get_coords_for_data(data, t); t_td = calc_t_td(t, rh)
                levels = [0, 3, 6, 9, 12, 15]; colors = ['#27AE60', '#82E0AA', '#D5F5E3', '#F9E79F', '#F5CBA7', '#E74C3C']
                cf = self.plot_contourf(ax, lon_p, lat_p, t_td, ui, levels, colors, extent, extend='max', alpha=0.35, model_name=model_name, polar_state=polar_state)
                u_key = self.fuzzy_key_search(data, ['u850']); v_key = self.fuzzy_key_search(data, ['v850'])
                u_val = _cl(data[u_key], -200, 200, 0.0) if u_key else None
                v_val = _cl(data[v_key], -200, 200, 0.0) if v_key else None
                if cf:
                    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, polar_state=polar_state)

        elif "925hpa" in elem_name or "975hpa" in elem_name:
            lvl = 925 if "925" in elem_name else 975
            t_key = self.fuzzy_key_search(data, [f't{lvl}', f'tmp{lvl}'])
            t = np.array(data[t_key], dtype=float) if t_key else None
            if t is not None and np.nanmax(t) > 100: t = t - 273.15
            rh_raw = self.get_rh(data, lvl, target_shape=t.shape if t is not None else None)
            rh = _cl(rh_raw, 0, 150, 0.0) if rh_raw is not None else None
            if rh is not None and np.nanmax(rh) <= 1.0 and np.nanmax(rh) > 0: rh = rh * 100.0
            if t is not None and rh is not None:
                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, model_name=model_name, polar_state=polar_state)
                    self.plot_contour(ax, lon_p, lat_p, ept, ui, levels, 'black', extent, model_name=model_name, polar_state=polar_state)
                    if cf:
                        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]; colors = ['#27AE60', '#82E0AA', '#D5F5E3', '#F9E79F', '#F5CBA7', '#E74C3C']
                    cf = self.plot_contourf(ax, lon_p, lat_p, t_td, ui, levels, colors, extent, extend='max', alpha=0.35, model_name=model_name, polar_state=polar_state)
                    u_key = self.fuzzy_key_search(data, [f'u{lvl}']); v_key = self.fuzzy_key_search(data, [f'v{lvl}'])
                    u_val = _cl(data[u_key], -200, 200, 0.0) if u_key else None
                    v_val = _cl(data[v_key], -200, 200, 0.0) if v_key else None
                    if cf:
                        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, polar_state=polar_state)

        data.close(); del data 

        if info_text:
            ui['info_panel'].setText(info_text); ui['info_panel'].adjustSize(); ui['info_panel'].move(10, 10); ui['info_panel'].show()
        if not drawn_flag:
            if "降水" in elem_name and val == 0 and "確率" not in elem_name: self.show_error_msg(ax, ui, "【初期時のためデータなし。次画像から表示】")
            elif not ("降水" in elem_name and val != 0 and "確率" not in elem_name): 
                self.show_error_msg(ax, ui, f"[!] {elem_name} のデータがありません。")

    except Exception as e: print(f"Draw Error: {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 110.1 (ガイダンス統合・完全停止ボタン実装版)</b><br>
    1. <b>完全停止機能</b>: 画面右上の「🛑 完全停止」ボタンから、システム及びバックグラウンドプロセスを安全に一括終了できるようになりました。<br>
    2. <b>ガイダンス機能統合</b>: 降水確率・降雪確率・発雷確率・卓越天気の描画とメタグラム機能を統合しました。<br>
    3. <b>WEB用一括画像出力</b>: ガイダンスデータもWEB用画像出力に対応しました。
    <li><b>Ver 110.0 (WEB用一括画像出力機能追加・相当温位透明度調整版)</b><br>
    </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", "GSM_GL", "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 = datetime.strptime(t, "%Y%m%d%H%M%S") + timedelta(hours=9)
                        combo.addItem(dt.strftime('%m/%d %H:%M') + (" (最新)" if t == times[0] else ""), 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

    if (updated or force_draw) and not quiet:
        self.status_banner.setText("最新データを画面に同期しました")
        current_tab_name = self.tabs.tabText(self.tabs.currentIndex())
        current_m = "MSM" if "MSM" in current_tab_name else ("GSM_JP" if "日本域" in current_tab_name else ("GUID" if "ガイダンス" in current_tab_name else "GSM_GL"))
        if current_m in self.tab_ui: 
            if current_m in ["MSM", "GSM_JP", "GUID"] and len(self.tab_ui[current_m]['area_btns']) > 4 and self.tab_ui[current_m]['area_btns'][-2].isChecked(): pass
            else: self.draw_frame(current_m, self.tab_ui[current_m]['slider'].value())

if name == ‘main‘:
app = QApplication(sys.argv); window = WeatherApp(); sys.exit(app.exec())

コメント