==========================================
気象GPV 局地分析ツール ビューワー (App B)
VERSION INFO: 120.0 (修正・完全版)
==========================================
import sys, os, glob, re, gc, time, logging
import numpy as np
import textwrap
from datetime import datetime, timedelta
import scipy.ndimage as ndimage
from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
QHBoxLayout, QSlider, QPushButton, QLabel,
QTabWidget, QListWidget, QTextEdit, QComboBox, QFileDialog, QDialog,
QMessageBox, QLineEdit, QTableWidget, QTableWidgetItem, QHeaderView)
from PyQt6.QtCore import Qt, QTimer, QSettings
from PyQt6.QtGui import QColor, QFont, QCloseEvent
os.environ[‘QT_API’] = ‘pyqt6’
import matplotlib
matplotlib.use(‘qtagg’)
import matplotlib.colors as mcolors
import matplotlib as mpl
import matplotlib.path as mpath
import matplotlib.pyplot as plt
mpl.rcParams[‘font.family’] = ‘MS Gothic’
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
import cartopy.crs as ccrs
import cartopy.feature as cfeature
import cartopy.io.shapereader as shpreader
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)
]
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 as e:
logging.error(f”Shapefile processing error: {e}”)
return [], [], [], []
def calc_t_td(t, rh):
rh_s = np.clip(rh, 1.0, 100.0)
e = 6.112 * np.exp((17.67 * t) / (t + 243.5)) * (rh_s / 100.0)
e = np.clip(e, 0.001, 1000)
td = (243.5 * np.log(e / 6.112)) / (17.67 – np.log(e / 6.112))
return t – td
def rh_from_t_td(t, t_td):
td = t – t_td
e = 6.112 * np.exp((17.67 * td) / (td + 243.5))
es = 6.112 * np.exp((17.67 * t) / (t + 243.5))
rh = (e / es) * 100.0
return np.clip(rh, 0.0, 100.0)
def calc_ept(t, rh, p):
t_k = t + 273.15
rh_s = np.clip(rh, 1.0, 100.0)
e = 6.112 * np.exp((17.67 * t) / (t + 243.5)) * (rh_s / 100.0)
e = np.clip(e, 0.001, p – 0.1)
q = 0.622 * e / (p – e)
tlcl = 2840.0 / (3.5 * np.log(t_k) – np.log(e) – 4.805) + 55.0
theta = t_k * (1000.0 / p) ** 0.2854
ept = theta * np.exp(((3376.0 / tlcl) – 2.54) * q * (1.0 + 0.81 * q))
return ept
def get_t_from_ept(target_ept, p):
t_min, t_max = -80.0, 50.0
for _ in range(15):
t_mid = (t_min + t_max) / 2.0
ept_mid = calc_ept(t_mid, 100.0, p)
if ept_mid < target_ept: t_min = t_mid
else: t_max = t_mid
return (t_min + t_max) / 2.0
class SingleImageWindow(QDialog):
def init(self, parent, title, model_name, stat_name, init_z_str, canvas):
super().init(parent)
self.model_name = model_name; self.stat_name = stat_name; self.init_z_str = init_z_str
self.setWindowTitle(title)
self.resize(1100, 950)
self.setStyleSheet(“QDialog { background-color: #0A192F; color: #E0E0E0; }”)
layout = QVBoxLayout(self)
layout.setContentsMargins(10, 10, 10, 10)
layout.addWidget(canvas, stretch=1)
btn_layout = QHBoxLayout()
btn_layout.setContentsMargins(10, 10, 10, 10)
save_btn = QPushButton("📷 画像として保存")
save_btn.setStyleSheet("background-color: #27AE60; color: white; font-size: 14pt; font-weight: bold; padding: 12px; border-radius: 5px;")
save_btn.clicked.connect(self.save_image)
close_btn = QPushButton("閉じる")
close_btn.setStyleSheet("background-color: #C0392B; color: white; font-size: 14pt; font-weight: bold; padding: 12px; border-radius: 5px;")
close_btn.clicked.connect(self.accept)
btn_layout.addStretch()
btn_layout.addWidget(save_btn)
btn_layout.addWidget(close_btn)
btn_layout.addStretch()
layout.addLayout(btn_layout)
def save_image(self):
desktop = os.path.expanduser("~/Desktop")
filename = f"{self.model_name}_{self.stat_name}_{self.init_z_str}.png"
path, _ = QFileDialog.getSaveFileName(self, "画像を保存", os.path.join(desktop, filename), "Images (*.png)")
if path:
canvas_widget = self.layout().itemAt(0).widget()
pixmap = canvas_widget.grab() if hasattr(canvas_widget, 'grab') else None
if pixmap: pixmap.save(path)
else: self.layout().itemAt(0).widget().figure.savefig(path, dpi=150, bbox_inches='tight', facecolor='white')
QMessageBox.information(self, "保存完了", f"保存しました:\n{os.path.basename(path)}")
class AdminPanelDialog(QDialog):
def init(self, parent_app):
super().init(parent_app)
self.setWindowTitle(“⚙️ コントロールパネル”)
self.setFixedSize(500, 300)
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)
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.setup_logging()
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, "同期完了", "画面の強制同期を実行しました")
class SplashWindow(QWidget):
def init(self):
super().init()
self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint)
self.setFixedSize(480, 250)
self.setStyleSheet(“background-color: #0A192F; border: 3px solid #64FFDA; border-radius: 12px;”)
layout = QVBoxLayout(self)
title_lbl = QLabel("気象GPV 局地分析ツール\n(Ver 118.0)")
title_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
title_lbl.setStyleSheet("font-size: 18pt; font-weight: bold; color: #E0E0E0; border: none; margin-top: 15px;")
layout.addWidget(title_lbl)
msg_lbl = QLabel("最新の解析データをロードしています...\n(約5秒で自動起動します)")
msg_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
msg_lbl.setStyleSheet("font-size: 14pt; color: #64FFDA; border: none;")
layout.addWidget(msg_lbl)
class WeatherApp(QMainWindow):
def init(self):
super().init()
self.settings = QSettings(‘WeatherApp’, ‘Viewer’)
self.cache_dir = self.settings.value(‘cache_dir’, os.path.join(os.getcwd(), “gpv_cache_npz”))
self.setup_logging()
self.setWindowTitle("気象GPV 局地分析ツール (Ver 120.0 - 修正対応完了版)")
self.resize(1850, 1050)
self.model_init_times = {"MSM": None, "GSM_JP": None, "MSM_GUID": None, "GSM_GUID": None, "ANAL": None}
self.tab_ui = {}
self.monitor_timer = QTimer(self); self.monitor_timer.timeout.connect(self.scan_cache_dir)
self.anim_timer = QTimer(self); self.anim_timer.timeout.connect(self.anim_step)
self.current_anim_model = None
self.setStyleSheet("""
* { font-size: 13pt; font-family: "MS Gothic", sans-serif; color: #E0E0E0; }
QMainWindow, QDialog, QMessageBox { background-color: #0A192F; color: #E0E0E0; }
QMessageBox QLabel { color: #E0E0E0; }
QMessageBox QPushButton { background-color: #1D3557; color: white; padding: 5px 15px; border-radius: 3px; font-weight: bold; }
QMessageBox QPushButton:hover { background-color: #457B9D; }
QTabWidget::pane { border: 1px solid #444; background: #112240; }
QTabBar::tab { background: #1B2B4A; color: #8892B0; padding: 12px 25px; border: 1px solid #333; border-bottom: none; }
QTabBar::tab:selected { background: #112240; color: #64FFDA; font-weight: bold; border-top: 3px solid #64FFDA; }
QSlider::groove:vertical { border: 1px solid #444; background: #0A192F; width: 14px; border-radius: 7px; }
QSlider::handle:vertical { background: #64FFDA; border: 2px solid #000; height: 32px; margin: 0 -10px; border-radius: 16px; }
QPushButton { background-color: #1D3557; border: 1px solid #457B9D; padding: 8px; border-radius: 4px; color: #F1FAEE; }
QPushButton:hover { background-color: #457B9D; }
QPushButton::checked { background-color: #E63946; color: white; font-weight: bold; }
QListWidget { background: #112240; border: 1px solid #444; color: #E0E0E0; }
QListWidget::item { padding: 3px 5px; margin: 1px 5px; background: #1D3557; border: 1px solid #333; border-radius: 6px; }
QListWidget::item:selected { background: #E63946; color: white; font-weight: bold; }
QListWidget::item:hover:!selected { background: #457B9D; }
QComboBox { background: #112240; border: 1px solid #8892B0; color: white; padding: 5px; }
QComboBox QAbstractItemView { background: #112240; color: white; }
QLabel { background: transparent; }
QTextEdit { background-color: #112240; color: #64FFDA; border: 1px solid #444; }
.NavButton { background-color: #2980B9; color: white; font-weight: bold; padding: 5px; border-radius: 5px; }
.PlayButton { background-color: #27AE60; color: white; font-weight: bold; padding: 5px 15px; border-radius: 5px; }
.StopButton { background-color: #C0392B; color: white; font-weight: bold; padding: 5px 15px; border-radius: 5px; }
.SyncButton { background-color: #F39C12; color: white; font-weight: bold; border: 2px solid #D68910; border-radius: 5px; padding: 5px 15px;}
.SaveButton { background-color: #8E44AD; color: white; font-weight: bold; border-radius: 5px; padding: 4px 10px; font-size: 10pt;}
.MetaButton { background-color: #16A085; color: white; font-weight: bold; border: 2px solid #0E6655; border-radius: 5px; padding: 5px 15px; }
.MetaButton:checked { background-color: #0E6655; border: 2px solid #1ABC9C; }
.AdminButton { background-color: #34495E; color: white; font-weight: bold; border-radius: 5px; padding: 4px 10px; font-size: 10pt;}
""")
main_widget = QWidget(); self.setCentralWidget(main_widget); main_layout = QVBoxLayout(main_widget)
self.tabs = QTabWidget()
corner_widget = QWidget()
corner_layout = QHBoxLayout(corner_widget)
corner_layout.setContentsMargins(0, 0, 5, 0)
self.status_banner = QLabel("待機中...")
self.status_banner.setStyleSheet("color: white; font-weight: bold; background-color: #E63946; padding: 4px 10px; border-radius: 4px; font-size: 10pt;")
self.save_img_btn = QPushButton("📷 画面保存")
self.save_img_btn.setProperty("class", "SaveButton")
self.save_img_btn.clicked.connect(self.save_current_image)
self.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_tab("MSM", "MSM GPV", is_guidance=False, is_anal=False)
self.setup_tab("GSM_JP", "GSM 日本域 GPV", is_guidance=False, is_anal=False)
self.setup_tab("ANAL", "毎時大気解析", is_guidance=False, is_anal=True)
self.setup_tab("MSM_GUID", "MSM ガイダンス", is_guidance=True, is_anal=False)
self.setup_tab("GSM_GUID", "GSM ガイダンス", is_guidance=True, is_anal=False)
self.setup_history_tab()
main_layout.addWidget(self.tabs)
get_simplified_geometries()
self.splash = SplashWindow()
self.splash.show()
QTimer.singleShot(5000, self.finish_startup_loading)
def closeEvent(self, event: QCloseEvent):
reply = QMessageBox.question(self, '終了の確認', 'システムを完全に停止してよろしいですか?',
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No)
if reply == QMessageBox.StandardButton.Yes:
event.accept()
else:
event.ignore()
def setup_logging(self):
self.log_file = os.path.join(self.cache_dir, "system_log.txt")
logging.basicConfig(filename=self.log_file, level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s', encoding='utf-8')
def open_admin_panel(self):
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) and "ガイダンス" not in self.tabs.tabText(i):
self.tabs.setCurrentIndex(i); break
if "MSM" in self.tab_ui:
items = self.tab_ui["MSM"]['list'].findItems("地上気圧", Qt.MatchFlag.MatchExactly)
if items: self.tab_ui["MSM"]['list'].setCurrentItem(items[0])
self.show()
def save_current_image(self):
current_tab = self.tabs.tabText(self.tabs.currentIndex())
if "MSM ガイダンス" in current_tab: model_name = "MSM_GUID"
elif "GSM ガイダンス" in current_tab: model_name = "GSM_GUID"
elif "毎時大気" in current_tab: model_name = "ANAL"
elif "MSM" in current_tab: model_name = "MSM"
elif "日本域" in current_tab: model_name = "GSM_JP"
else: return
if model_name not in self.tab_ui or self.tab_ui[model_name]['ax'] is None: return
ui = self.tab_ui[model_name]
val = ui['slider'].value(); elem = ui['list'].currentItem().text()
it_str = self.model_init_times.get(model_name)
if not it_str: return
dt_utc = datetime.strptime(it_str, "%Y%m%d%H%M%S")
init_z = dt_utc.strftime("%m%d%H") + "Z"
filename = f"{model_name}_{elem}_{init_z}_FT{val:02d}.png"
desktop = os.path.expanduser("~/Desktop")
path, _ = QFileDialog.getSaveFileName(self, "画像を保存", os.path.join(desktop, filename), "Images (*.png *.jpg)")
if path:
ui['fig'].savefig(path, dpi=150, bbox_inches='tight', facecolor='white')
self.status_banner.setText(f"保存完了: {os.path.basename(path)}")
def init_map(self, fig, area_idx, model):
"""地図の初期化とベースマップの描画を行う"""
fig.clear()
ax = fig.add_subplot(1, 1, 1, projection=ccrs.PlateCarree())
ax.set_extent(self.get_extent(area_idx, model), crs=ccrs.PlateCarree())
self.draw_base_map(ax, area_idx)
fig.subplots_adjust(left=0.05, right=0.9, top=0.92, bottom=0.05)
return ax
def get_extent(self, area_index, model_name):
if area_index == 0: return [120, 150, 27.5, 48] if "MSM" in model_name or "ANAL" in model_name else [120, 150, 20, 50]
elif area_index == 1: return [138.5, 146.5, 40.5, 46.5]
elif area_index == 2: return [141.6, 143.6, 42.8, 45.0]
else: return [141.15, 141.55, 42.92, 43.20]
def crop_data(self, lon, lat, data, extent):
if lon is None or lat is None or data is None:
return np.array([]), np.array([]), np.array([])
buffer = 1.0; lon_min, lon_max = extent[0] - buffer, extent[1] + buffer; lat_min, lat_max = extent[2] - buffer, extent[3] + buffer
if lon.ndim == 1:
m_lon = (lon >= lon_min) & (lon <= lon_max); m_lat = (lat >= lat_min) & (lat <= lat_max)
if not np.any(m_lon) or not np.any(m_lat): return np.array([]), np.array([]), np.array([])
return lon[m_lon], lat[m_lat], data[np.ix_(m_lat, m_lon)]
else:
mask = (lon >= lon_min) & (lon <= lon_max) & (lat >= lat_min) & (lat <= lat_max)
j, i = np.where(mask)
if len(j) == 0: return np.array([]), np.array([]), np.array([])
return lon[j.min():j.max()+1, i.min():i.max()+1], lat[j.min():j.max()+1, i.min():i.max()+1], data[j.min():j.max()+1, i.min():i.max()+1]
def draw_base_map(self, ax, area_index):
SEA_COLOR = '#A4C2E4'; LAND_COLOR = '#F5F5F0'; COASTLINE_COLOR = '#333333'
ax.set_facecolor(SEA_COLOR); ax.add_feature(cfeature.LAND.with_scale('10m'), facecolor=LAND_COLOR, zorder=0)
ax.add_feature(cfeature.OCEAN.with_scale('10m'), facecolor=SEA_COLOR, zorder=0)
ax.add_feature(cfeature.LAKES.with_scale('10m'), edgecolor='#4682B4', facecolor=SEA_COLOR, zorder=1)
ax.add_feature(cfeature.RIVERS.with_scale('10m'), linewidth=0.8, edgecolor='#4682B4', zorder=1)
ax.add_feature(cfeature.COASTLINE.with_scale('10m'), linewidth=0.6, edgecolor=COASTLINE_COLOR, zorder=2)
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 area_index == 3 and sapporo_districts_g:
for d_name, geoms in sapporo_districts_g.items():
if geoms: ax.add_geometries(geoms, ccrs.PlateCarree(), edgecolor='#FF0000', facecolor='none', linewidth=1.5, zorder=10)
if sapporo_city_g and area_index == 3: ax.add_geometries(sapporo_city_g, ccrs.PlateCarree(), edgecolor='#FF0000', facecolor='#FF8C00', alpha=0.3, linewidth=2.0, linestyle='-', zorder=4)
elif sapporo_city_g and area_index >= 1: ax.add_geometries(sapporo_city_g, ccrs.PlateCarree(), edgecolor='#FF0000', facecolor='#FF8C00', alpha=0.2, linewidth=1.0, linestyle='-', zorder=4)
if area_index >= 1 and kamikawa_g: ax.add_geometries(kamikawa_g, ccrs.PlateCarree(), edgecolor='#FF0000', facecolor='#FF8C00', alpha=0.25 if area_index == 2 else 0.15, linewidth=1.5, linestyle='-', zorder=5)
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)
gl = ax.gridlines(crs=ccrs.PlateCarree(), draw_labels=True, linewidth=0.5, color='gray', alpha=0.5, linestyle='--', zorder=6)
gl.top_labels = False; gl.right_labels = False
gl.xlabel_style = {'size': 11, 'color': 'black', 'fontname': 'MS Gothic'}; gl.ylabel_style = {'size': 11, 'color': 'black', 'fontname': 'MS Gothic'}
if area_index == 3: gl.xlocator = mpl.ticker.MultipleLocator(0.05); gl.ylocator = mpl.ticker.MultipleLocator(0.05)
elif area_index == 2: gl.xlocator = mpl.ticker.MultipleLocator(0.5); gl.ylocator = mpl.ticker.MultipleLocator(0.5)
elif area_index == 1: gl.xlocator = mpl.ticker.MultipleLocator(1.0); gl.ylocator = mpl.ticker.MultipleLocator(1.0)
else: gl.xlocator = mpl.ticker.MultipleLocator(5.0) if area_index == 0 else mpl.ticker.MultipleLocator(1.0); gl.ylocator = mpl.ticker.MultipleLocator(5.0) if area_index == 0 else mpl.ticker.MultipleLocator(1.0)
def get_local_info(self, elem_name, lon, lat, data, u=None, v=None, area_index=0, model_name=""):
if data is None or lon is None or lat is None or data.size == 0: return ""
target_lon, target_lat, loc_name = (142.368, 43.760, "旭川") if area_index == 2 else (141.35, 43.06, "札幌")
if lon.ndim == 1: lon_2d, lat_2d = np.meshgrid(lon, lat)
else: lon_2d, lat_2d = lon, lat
dist = (lon_2d - target_lon)**2 + (lat_2d - target_lat)**2
sy, sx = np.unravel_index(np.argmin(dist), dist.shape)
if sy >= data.shape[0] or sx >= data.shape[1]: return ""
s_val = data[sy, sx]
if np.isnan(s_val): return ""
# ガイダンスの降水量の場合は左上の表記を修正
if "降水量" in elem_name or "降水" in elem_name: info = f"[{loc_name}現況]\n降水量: {s_val:.1f} mm"
elif "降雪量" in elem_name or "降雪" in elem_name: info = f"[{loc_name}現況]\n降雪量: {s_val:.1f} cm"
else: info = f"[{loc_name}現況]\n"
if "降水" not in elem_name and "降雪" not in elem_name:
if "気圧" in elem_name: info += f"気圧: {s_val:.1f} hPa"
elif "確率" in elem_name and "発雷" not in elem_name: info += f"{elem_name}: {s_val:.0f} %"
elif "発雷確率" in elem_name: info += f"発雷確率: {s_val:.0f} %"
elif "天気" in elem_name:
wx_map = {0: "晴", 1: "曇", 2: "雨", 3: "雪", 4: "不明"}
info += f"卓越天気: {wx_map.get(int(s_val), '不明')}"
elif "雲量" in elem_name: info += f"{elem_name}: {s_val:.1f} %"
elif "高度" in elem_name: info += f"{elem_name[:6]}高度: {s_val:.0f} m"
elif "渦度" in elem_name: info += f"正渦度(10^-5/s): {s_val:.1f}"
elif "気温" in elem_name: info += f"{elem_name[:6]}気温: {s_val:.1f} ℃"
elif "鉛直流" in elem_name: info += f"鉛直流: {s_val:.1f} hPa/h"
elif "湿数" in elem_name: info += f"{elem_name[:6]}湿数: {s_val:.1f} ℃"
elif "相当温位" in elem_name: info += f"{elem_name[:6]}相当温位: {s_val:.1f} K"
else: info += f"値: {s_val:.1f}"
if u is not None and v is not None and sy < u.shape[0] and sx < u.shape[1]: info += f"\n風速: {np.hypot(u[sy, sx], v[sy, sx]):.1f} m/s"
return info
def on_area_btn_clicked(self, model_name, idx):
ui = self.tab_ui[model_name]
for i, btn in enumerate(ui['area_btns']): btn.setChecked(i == idx)
is_meta_btn = ("メタグラム" in ui['area_btns'][-1].text() and idx == len(ui['area_btns'])-1)
if is_meta_btn:
ui['canvas'].hide()
if 'info_panel' in ui: ui['info_panel'].hide()
if 'meta_ctrl' in ui: ui['meta_ctrl'].show()
else:
if 'meta_ctrl' in ui: ui['meta_ctrl'].hide()
ui['canvas'].show()
self.draw_frame(model_name, ui['slider'].value())
def on_init_changed(self, model_name, idx):
ui = self.tab_ui[model_name]
if idx < 0: return
selected_time_str = ui['init_combo'].itemData(idx)
if selected_time_str:
self.model_init_times[model_name] = selected_time_str
self.update_slider_max(model_name, selected_time_str)
if len(ui['area_btns']) > 4 and ui['area_btns'][-2].isChecked(): return
elem_name = ui['list'].currentItem().text()
val = ui['slider'].value()
if "降水" in elem_name and val == 0 and "確率" not in elem_name and "時間降水" not in elem_name and model_name != "ANAL":
val = ui['step']
ui['slider'].blockSignals(True)
ui['slider'].setValue(val)
ui['slider'].blockSignals(False)
self.draw_frame(model_name, val)
def on_element_changed(self, model_name):
ui = self.tab_ui[model_name]
elem_name = ui['list'].currentItem().text()
if "GUID" in model_name:
min_ft = 0
if "6時間" in elem_name: min_ft = 6
elif "24時間" in elem_name: min_ft = 24
elif "3時間" in elem_name: min_ft = 3
ui['slider'].setMinimum(min_ft)
if ui['slider'].value() < min_ft:
ui['slider'].blockSignals(True)
ui['slider'].setValue(min_ft)
ui['slider'].blockSignals(False)
else:
ui['slider'].setMinimum(0)
self.on_slider_changed(model_name, ui['slider'].value())
def on_slider_changed(self, model_name, val):
"""スライダーや要素が変更されたときに描画を更新する"""
self.draw_frame(model_name, val)
def update_slider_max(self, ui_key, init_time_str):
ui = self.tab_ui.get(ui_key)
if not ui: return
files = glob.glob(os.path.join(self.cache_dir, f"{ui_key}_{init_time_str}_FT*.npz"))
max_ft_found = max([0] + [int(re.search(r'_FT(\d+)\.npz', f).group(1)) for f in files if re.search(r'_FT(\d+)\.npz', f)])
hh = init_time_str[8:10] if len(init_time_str) >= 10 else '00'
if ui_key == "MSM": default_max = 78 if hh in ['00', '12'] else 39; step = 1; tick_step = 6
elif ui_key == "GSM_JP": default_max = 264 if hh in ['00', '12'] else 132; step = 3; tick_step = 12
elif ui_key == "ANAL": default_max = 0; step = 1; tick_step = 1
elif ui_key == "MSM_GUID": default_max = 84; step = 1; tick_step = 6
elif ui_key == "GSM_GUID": default_max = 84; step = 3; tick_step = 12
else: default_max = 132; step = 6; tick_step = 24
final_max = max(default_max, max_ft_found); final_max = (final_max // step) * step
ui['slider'].setMaximum(final_max)
ui['slider'].setTickInterval(step); ui['slider'].setSingleStep(step); ui['slider'].setPageStep(step)
ticks_layout = ui['ticks_layout']
for i in reversed(range(ticks_layout.count())):
w = ticks_layout.itemAt(i).widget()
if w: w.setParent(None)
if ui_key != "ANAL":
for t in range(0, final_max + 1, tick_step):
lbl = QLabel(f"{t}h"); lbl.setStyleSheet("font-size: 8pt; color: #8892B0;"); lbl.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter); ticks_layout.addWidget(lbl)
def create_compressed_left_panel(self, model_name):
left_panel = QVBoxLayout(); left_panel.setContentsMargins(5, 5, 5, 5)
step = 1 if model_name in ["MSM", "ANAL", "MSM_GUID"] else 3
left_panel.addWidget(QLabel(f"📡 {model_name} 初期時刻(UTC):", styleSheet="font-weight: bold; font-size: 11pt; color: #E0E0E0;"))
init_combo = QComboBox(); init_combo.addItem("(データ待機中...)", None); left_panel.addWidget(init_combo)
interval_label = QLabel(f"【{step}時間間隔表示】", styleSheet="color: #64FFDA; font-weight: bold; font-size: 11pt;"); left_panel.addWidget(interval_label)
mid_hbox = QHBoxLayout(); elem_vbox = QVBoxLayout(); elem_vbox.addWidget(QLabel("■ 要素選択"))
element_list = QListWidget(); element_list.setFixedWidth(200)
if "GUID" in model_name:
elems = ["卓越天気", "3時間降水量", "6時間降水量", "24時間降水量", "3時間降雪量", "6時間降雪量", "24時間降雪量", "発雷確率"]
elif model_name == "ANAL":
elems = ["地上気温", "地上風向風速", "300hpa風向・風速", "500hpa風向・風速", "500hpa気温", "700hpa気温・風", "850hpa気温・風", "925hpa気温・風", "975hpa気温・風"]
else:
elems = ["地上気圧","地上風向風速", "地上降水", "全雲量", "上層雲量", "中層雲量", "下層雲量", "300hpa高度", "300hpa風向・風速", "500hpa高度", "500hpa風向・風速", "500hpa渦度", "500hpa気温", "700hpa湿数・風", "700hpa鉛直流", "850hpa気温・風", "850hpa相当温位", "850hpa湿数・風", "925hpa相当温位", "925hpa湿数・風", "975hpa相当温位", "975hpa湿数・風"]
element_list.addItems(elems); element_list.setCurrentRow(0); elem_vbox.addWidget(element_list); mid_hbox.addLayout(elem_vbox)
slider_vbox = QVBoxLayout()
time_label = QLabel("FT=0h\n(--/--)"); time_label.setStyleSheet("font-weight: bold; color: #64FFDA; font-size: 11pt; background: #1D3557; padding: 5px; border: 2px solid #457B9D; border-radius: 5px;"); time_label.setAlignment(Qt.AlignmentFlag.AlignCenter); slider_vbox.addWidget(time_label)
slider_control_hbox = QHBoxLayout()
btn_prev = QPushButton("▲"); btn_prev.setProperty("class", "NavButton"); btn_prev.setFixedWidth(30)
btn_next = QPushButton("▼"); btn_next.setProperty("class", "NavButton"); btn_next.setFixedWidth(30)
slider = QSlider(Qt.Orientation.Vertical); slider.setRange(0, 39); slider.setTickPosition(QSlider.TickPosition.TicksLeft); slider.setTickInterval(step); slider.setSingleStep(step); slider.setPageStep(step); slider.setInvertedAppearance(True)
btn_vbox = QVBoxLayout(); btn_vbox.addWidget(slider, stretch=1); btn_vbox.setAlignment(Qt.AlignmentFlag.AlignHCenter)
nav_hbox = QHBoxLayout(); nav_hbox.addWidget(btn_prev); nav_hbox.addWidget(btn_next)
inner_slider_vbox = QVBoxLayout(); inner_slider_vbox.addLayout(nav_hbox); inner_slider_vbox.addLayout(btn_vbox); slider_control_hbox.addLayout(inner_slider_vbox)
ticks_vbox = QVBoxLayout(); ticks_vbox.setContentsMargins(0, 30, 0, 10)
slider_control_hbox.addLayout(ticks_vbox); slider_vbox.addLayout(slider_control_hbox, stretch=1)
play_hbox = QHBoxLayout()
btn_play = QPushButton("▶"); btn_play.setProperty("class", "PlayButton"); btn_play.setFixedWidth(40)
btn_stop = QPushButton("■"); btn_stop.setProperty("class", "StopButton"); btn_stop.setFixedWidth(40)
play_hbox.addWidget(btn_play); play_hbox.addWidget(btn_stop); slider_vbox.addLayout(play_hbox)
combo_speed = QComboBox(); combo_speed.addItems(["速1", "速2", "速3", "速4", "速5"]); combo_speed.setCurrentIndex(2); slider_vbox.addWidget(combo_speed)
mid_hbox.addLayout(slider_vbox); left_panel.addLayout(mid_hbox, stretch=1)
return left_panel, init_combo, interval_label, element_list, time_label, btn_prev, btn_next, slider, btn_play, btn_stop, combo_speed, ticks_vbox
def setup_tab(self, model_name, tab_title, is_guidance=False, is_anal=False):
tab = QWidget(); main_hbox = QHBoxLayout(tab)
left_panel, init_combo, interval_label, element_list, time_label, btn_prev, btn_next, slider, btn_play, btn_stop, combo_speed, ticks_layout = self.create_compressed_left_panel(model_name)
main_hbox.addLayout(left_panel, stretch=2)
right_panel = QVBoxLayout()
area_layout = QHBoxLayout(); area_layout.addWidget(QLabel("表示領域:"))
area_btns = [QPushButton("日本周辺"), QPushButton("北海道全域"), QPushButton("上川地方"), QPushButton("札幌近郊")]
for i, btn in enumerate(area_btns):
btn.setCheckable(True); area_layout.addWidget(btn); btn.clicked.connect(lambda checked, idx=i, m=model_name: self.on_area_btn_clicked(m, idx))
area_btns[0].setChecked(True)
if not is_anal:
btn_meta_text = "メタグラム表(A4画像化)" if is_guidance else f"{model_name}メタグラム"
btn_meta_draw = QPushButton(btn_meta_text)
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()
right_panel.addLayout(area_layout)
fig = Figure(figsize=(14, 13)); fig.patch.set_facecolor('white'); canvas = FigureCanvas(fig)
meta_ctrl_widget = QWidget(); meta_ctrl_layout = QVBoxLayout(meta_ctrl_widget)
h_ctrl = QHBoxLayout()
h_ctrl.addWidget(QLabel("地点選択:")); station_combo = QComboBox()
for name, lat, lon in STATIONS: station_combo.addItem(name, (lat, lon))
h_ctrl.addWidget(station_combo)
if not is_anal:
btn_text = "表示 (A4横サイズ テーブル画像生成)" if is_guidance else "表示 (図解メタグラム生成)"
btn_generate_meta = QPushButton(btn_text)
btn_generate_meta.setProperty("class", "SyncButton")
if is_guidance: btn_generate_meta.clicked.connect(lambda: self.draw_guidance_metagram(station_combo, model_name))
elif model_name == "GSM_JP": btn_generate_meta.clicked.connect(lambda: self.draw_gsm_visual_metagram(station_combo))
elif model_name == "MSM": btn_generate_meta.clicked.connect(lambda: self.draw_msm_visual_metagram(station_combo))
h_ctrl.addWidget(btn_generate_meta)
h_ctrl.addStretch()
meta_ctrl_layout.addLayout(h_ctrl); meta_ctrl_layout.addStretch(); meta_ctrl_widget.hide()
right_panel.addWidget(canvas, stretch=10)
right_panel.addWidget(meta_ctrl_widget, stretch=10)
main_hbox.addLayout(right_panel, stretch=8)
info_panel = QLabel(canvas)
info_panel.setStyleSheet("background-color: rgba(17, 34, 64, 0.85); border: 2px solid #64FFDA; border-radius: 5px; padding: 5px; font-weight: bold; font-size: 12pt; color: #E0E0E0;"); info_panel.hide()
step = 1 if model_name in ["MSM", "ANAL", "MSM_GUID"] else 3
self.tab_ui[model_name] = {
'list': element_list, 'area_btns': area_btns, 'slider': slider, 'time_label': time_label, 'init_combo': init_combo,
'interval_label': interval_label, 'fig': fig, 'canvas': canvas, 'meta_ctrl': meta_ctrl_widget, 'ax': None, 'current_area_index': -1,
'dynamic_artists': [], 'colorbars': [], 'info_panel': info_panel, 'combo_speed': combo_speed, 'ticks_layout': ticks_layout,
'step': step
}
if is_anal:
btn_play.hide(); btn_stop.hide(); combo_speed.hide()
btn_prev.hide(); btn_next.hide()
slider.hide()
for i in range(ticks_layout.count()):
w = ticks_layout.itemAt(i).widget()
if w: w.hide()
interval_label.setText("【現況解析のみ(FT=0)】")
def step_slider(val_change, m=model_name):
ui = self.tab_ui[m]; 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)
if not is_anal:
btn_prev.clicked.connect(lambda: step_slider(-1)); btn_next.clicked.connect(lambda: step_slider(1))
slider.valueChanged.connect(lambda val, m=model_name: self.on_slider_changed(m, val))
btn_play.clicked.connect(lambda: self.start_animation(model_name)); btn_stop.clicked.connect(self.stop_animation)
element_list.itemSelectionChanged.connect(lambda m=model_name: self.on_element_changed(m))
self.tabs.addTab(tab, tab_title)
def get_exact_key(self, data_obj, exact_candidates):
for c in exact_candidates:
if c in data_obj.files: return c
for k in data_obj.files:
if k.lower() in [c.lower() for c in exact_candidates]: return k
return None
def get_precip_key(self, data):
return self.get_exact_key(data, ['precip', 'pr', 'tp', 'apcp', 'prate', 'rain', 'rn', 'pop', 'var_0_1_8', 'var_0_1_52'])
def get_w_key(self, data):
return self.get_exact_key(data, ['w', 'w700', 'vvel', 'vvel700', 'dzdt', 'dzdt700'])
def get_coords_for_data(self, data_obj, data_array):
if data_array is None: return None, None
ny = data_array.shape[0]; nx = data_array.shape[1] if data_array.ndim > 1 else len(data_array)
for k in data_obj.files:
if 'lon' in k.lower():
if (data_obj[k].ndim == 1 and data_obj[k].shape[0] == nx) or (data_obj[k].ndim == 2 and data_obj[k].shape == data_array.shape):
lat_k = k.replace('lon', 'lat').replace('Lon', 'Lat')
if lat_k in data_obj.files: return data_obj[k], data_obj[lat_k]
for lk in data_obj.files:
if 'lat' in lk.lower() and ((data_obj[lk].ndim == 1 and data_obj[lk].shape[0] == ny) or data_obj[lk].shape == data_array.shape):
return data_obj[k], data_obj[lk]
return None, None
def extract_val_with_key(self, data, key, stat_lon, stat_lat):
if key not in data.files: return np.nan
arr = data[key]
lon_arr, lat_arr = self.get_coords_for_data(data, arr)
if lon_arr is None or lat_arr is None: 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 = self.get_exact_key(data, keys)
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()
dt_utc = datetime.strptime(it_str, "%Y%m%d%H%M%S") # UTC
dt_jst = dt_utc + timedelta(hours=9)
init_z_str = f"{dt_utc.strftime('%m%d%H')}Z"
title_time_str = f"{dt_utc.strftime('%m月%d日%H')}Z / {dt_jst.strftime('%H')}JST"
max_ft = self.tab_ui['MSM']['slider'].maximum()
fts_surf = list(range(0, max_ft + 1))
fts_prof = [ft for ft in fts_surf if ft % 3 == 0]
plevs = [1000, 975, 950, 925, 850, 700, 600, 500, 300]; y_coords = [0.0, 0.05, 0.1, 0.15, 0.25, 0.5, 0.625, 0.75, 1.0]
T_prof = np.full((len(plevs), len(fts_prof)), np.nan); RH_prof = np.full((len(plevs), len(fts_prof)), np.nan)
U_prof = np.full((len(plevs), len(fts_prof)), np.nan); V_prof = np.full((len(plevs), len(fts_prof)), np.nan)
SSI_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 Exception as e: logging.error(f"Error reading {gpv_file}: {e}")
for c_idx, ft in enumerate(fts_prof):
gpv_file = os.path.join(self.cache_dir, f"MSM_{it_str}_FT{ft:02d}.npz")
if not os.path.exists(gpv_file): continue
try:
d = np.load(gpv_file)
t2m = self.extract_val_robust(d, ['t2m', 'tmp2m', 'temp2m'], stat_lon, stat_lat)
if not np.isnan(t2m) and t2m > 100: t2m -= 273.15
T_prof[0, c_idx] = t2m
RH_prof[0, c_idx] = self.extract_val_robust(d, ['rh2m', 'r2m'], stat_lon, stat_lat)
U_prof[0, c_idx] = self.extract_val_robust(d, ['u10', '10u', 'u_10m'], stat_lon, stat_lat)
V_prof[0, c_idx] = self.extract_val_robust(d, ['v10', '10v', 'v_10m'], stat_lon, stat_lat)
h_map = {975:1, 950:2, 925:3, 850:4, 700:5, 600:6, 500:7, 300:8}
for p_lvl, idx in h_map.items():
t_val = self.extract_val_robust(d, [f't{p_lvl}', f'tmp{p_lvl}', f't_{p_lvl}', f'tmp_{p_lvl}'], stat_lon, stat_lat)
if not np.isnan(t_val) and t_val > 100: t_val -= 273.15
rh_val = self.extract_val_robust(d, [f'rh{p_lvl}', f'r{p_lvl}', f'rh_{p_lvl}', f'r_{p_lvl}'], stat_lon, stat_lat)
if np.isnan(rh_val) and not np.isnan(t_val):
t_td = self.extract_val_robust(d, [f't_td{p_lvl}', f'dew{p_lvl}'], stat_lon, stat_lat)
if not np.isnan(t_td): rh_val = rh_from_t_td(t_val, t_td)
T_prof[idx, c_idx] = rh_val if not np.isnan(rh_val) else 0.0
T_prof[idx, c_idx] = t_val
RH_prof[idx, c_idx] = rh_val if not np.isnan(rh_val) else 0.0
U_prof[idx, c_idx] = self.extract_val_robust(d, [f'u{p_lvl}', f'u_{p_lvl}'], stat_lon, stat_lat)
V_prof[idx, c_idx] = self.extract_val_robust(d, [f'v{p_lvl}', f'v_{p_lvl}'], stat_lon, stat_lat)
t925 = T_prof[3, c_idx]; rh925 = RH_prof[3, c_idx]
t850 = T_prof[4, c_idx]; rh850 = RH_prof[4, c_idx]
t700 = T_prof[5, c_idx]; rh700 = RH_prof[5, c_idx]
t500 = T_prof[7, c_idx]
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.92, bottom=0.08, hspace=0.65)
fig.suptitle(f"MSM 図解メタグラム - {stat_name} (初期時: {title_time_str})", fontsize=14, weight='bold', y=0.97)
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')
# JST時間の併記 (海面気圧の上の軸に追加)
ax_top_jst = ax_slp.twiny()
ax_top_jst.set_xlim(ax_slp.get_xlim())
ax_top_jst.set_xticks(range(0, fts_surf[-1]+1, 6))
jsts = [(dt_jst + timedelta(hours=x)).strftime("%d日%H時") for x in range(0, fts_surf[-1]+1, 6)]
ax_top_jst.set_xticklabels(jsts, fontsize=9, color='#333333', weight='bold')
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:
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')
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()
dt_utc = datetime.strptime(it_str, "%Y%m%d%H%M%S") # UTC
dt_jst = dt_utc + timedelta(hours=9)
init_z_str = f"{dt_utc.strftime('%m%d%H')}Z"
title_time_str = f"{dt_utc.strftime('%m月%d日%H')}Z / {dt_jst.strftime('%H')}JST"
max_ft = self.tab_ui['GSM_JP']['slider'].maximum()
fts = list(range(0, max_ft + 1, 3))
plevs = [1000, 975, 950, 925, 850, 700, 600, 500, 300]; y_coords = [0.0, 0.05, 0.1, 0.15, 0.25, 0.5, 0.625, 0.75, 1.0]
T_prof = np.full((len(plevs), len(fts)), np.nan); RH_prof = np.full((len(plevs), len(fts)), np.nan)
U_prof = np.full((len(plevs), len(fts)), np.nan); V_prof = np.full((len(plevs), len(fts)), np.nan)
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)
if ft >= 3:
prev_file = os.path.join(self.cache_dir, f"GSM_JP_{it_str}_FT{ft-3:02d}.npz")
if os.path.exists(prev_file):
try:
with np.load(prev_file) as d_prev:
prev_p_key = self.get_precip_key(d_prev)
if prev_p_key:
prev_pr = self.extract_val_with_key(d_prev, prev_p_key, stat_lon, stat_lat)
if not np.isnan(prev_pr): pr = max(0, pr - prev_pr)
except: pass
Pr_surf_series[c_idx] = pr if not np.isnan(pr) else 0.0
else:
Pr_surf_series[c_idx] = 0.0
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}', f't_{p_lvl}'], stat_lon, stat_lat)
if not np.isnan(t_val) and t_val > 100: t_val -= 273.15
rh_val = self.extract_val_robust(d, [f'rh{p_lvl}', f'r{p_lvl}', f'rh_{p_lvl}'], stat_lon, stat_lat)
if np.isnan(rh_val) and not np.isnan(t_val):
t_td = self.extract_val_robust(d, [f't_td{p_lvl}', f'dew{p_lvl}'], stat_lon, stat_lat)
if not np.isnan(t_td): rh_val = rh_from_t_td(t_val, t_td)
T_prof[idx, c_idx] = t_val
RH_prof[idx, c_idx] = rh_val if not np.isnan(rh_val) else 0.0
U_prof[idx, c_idx] = self.extract_val_robust(d, [f'u{p_lvl}', f'u_{p_lvl}'], stat_lon, stat_lat)
V_prof[idx, c_idx] = self.extract_val_robust(d, [f'v{p_lvl}', f'v_{p_lvl}'], stat_lon, stat_lat)
t925 = T_prof[3, c_idx]; rh925 = RH_prof[3, c_idx]
t850 = T_prof[4, c_idx]; rh850 = RH_prof[4, c_idx]
t700 = T_prof[5, c_idx]; rh700 = RH_prof[5, c_idx]
t500 = T_prof[7, c_idx]
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=14, weight='bold', y=0.97)
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')
# JST時間の併記 (海面気圧の上の軸に追加)
ax_top_jst = ax_slp.twiny()
ax_top_jst.set_xlim(ax_slp.get_xlim())
ax_top_jst.set_xticks(range(0, fts[-1]+1, 12))
jsts = [(dt_jst + timedelta(hours=x)).strftime("%d日%H時") for x in range(0, fts[-1]+1, 12)]
ax_top_jst.set_xticklabels(jsts, fontsize=9, color='#333333', weight='bold')
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:
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')
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用メタグラム (A4横サイズのテーブル描画) ---
def draw_guidance_metagram(self, station_combo, model_name):
it_str = self.model_init_times.get(model_name)
if not it_str:
QMessageBox.warning(self, "エラー", "データが存在しません。"); return
stat_name = station_combo.currentText(); stat_lat, stat_lon = station_combo.currentData()
dt_utc = datetime.strptime(it_str, "%Y%m%d%H%M%S") # UTC
# A4横サイズ比率のFigureを作成
fig = Figure(figsize=(11.69, 8.27)); fig.patch.set_facecolor('white')
ax = fig.add_subplot(111); ax.axis('off')
table_data = []
headers = ["FT", "日時(JST)", "卓越天気", "降水量", "降雪量", "発雷確率", "気温(℃)", "風向", "風速(m/s)"]
max_ft = self.tab_ui[model_name]['slider'].maximum()
fts = list(range(0, max_ft + 1, 3))
gpv_model = "MSM" if model_name == "MSM_GUID" else "GSM_JP"
gpv_it_str = self.model_init_times.get(gpv_model)
for ft in fts:
target_jst = dt_utc + timedelta(hours=9+ft)
row = [f"FT={ft:02d}", target_jst.strftime("%m/%d %H:00"), "-", "-", "-", "-", "-", "-", "-"]
guid_file = os.path.join(self.cache_dir, f"{model_name}_{it_str}_FT{ft:02d}.npz")
if os.path.exists(guid_file):
try:
d = np.load(guid_file)
pop = self.extract_val_robust(d, ['precip', 'tp', 'apcp', 'pop', 'var_0_1_8', 'var_0_1_52'], stat_lon, stat_lat)
pos = self.extract_val_robust(d, ['snow', 'weasd', 'snod', 'var_0_1_11', 'var_0_1_13', 'var_0_1_29'], stat_lon, stat_lat)
# ↓ 以下の2行に 'tstm' と 'var_-1_-1_-1' を追加します
pol = self.extract_val_robust(d, ['thund', 'pol', 'lig', 'thunder', 'tstm', 'var_0_19_193'], stat_lon, stat_lat)
wx = self.extract_val_robust(d, ['wea', 'weather', 'wx', 'var_-1_-1_-1', 'var_0_19_192'], stat_lon, stat_lat)
if not np.isnan(wx): row[2] = {0:"晴", 1:"曇", 2:"雨", 3:"雪"}.get(int(wx), "不明")
if not np.isnan(pop): row[3] = f"{pop:.1f} mm"
if not np.isnan(pos): row[4] = f"{pos:.1f} cm"
if not np.isnan(pol): row[5] = f"{pol:.0f} %"
d.close()
except: pass
if gpv_it_str:
gpv_ft = ft
if gpv_model == "GSM_JP" and ft > 132:
gpv_ft = ft - (ft % 6) if ft % 6 != 0 else ft
gpv_file = os.path.join(self.cache_dir, f"{gpv_model}_{gpv_it_str}_FT{gpv_ft:02d}.npz")
if os.path.exists(gpv_file):
try:
d = np.load(gpv_file)
t = self.extract_val_robust(d, ['t2m', 'tmp2m', 'temp2m'], stat_lon, stat_lat)
if not np.isnan(t): row[6] = f"{(t - 273.15 if t > 100 else t):.1f}"
u = self.extract_val_robust(d, ['u10', '10u', 'u_10m'], stat_lon, stat_lat)
v = self.extract_val_robust(d, ['v10', '10v', 'v_10m'], stat_lon, stat_lat)
if not np.isnan(u) and not np.isnan(v):
row[8] = f"{np.hypot(u, v):.1f}"
deg = (np.degrees(np.arctan2(u, v)) + 180) % 360
dirs = ["北", "北北東", "北東", "東北東", "東", "東南東", "南東", "南南東", "南", "南南西", "南西", "西南西", "西", "西北西", "北西", "北北西", "北"]
row[7] = dirs[int(round(deg / 22.5)) % 16]
d.close()
except: pass
table_data.append(row)
table = ax.table(cellText=table_data, colLabels=headers, loc='center', cellLoc='center')
table.auto_set_font_size(False)
table.set_fontsize(10)
table.scale(1, 1.4)
# ヘッダーの色付け
for j in range(len(headers)):
cell = table[0, j]
cell.set_facecolor('#1D3557')
cell.get_text().set_color('white')
cell.get_text().set_weight('bold')
fig.suptitle(f"{stat_name} 統合ガイダンス時系列表 (初期時: {dt_utc.strftime('%m/%d %H:00Z')})", fontsize=15, weight='bold', y=0.96)
canvas = FigureCanvas(fig)
win = SingleImageWindow(self, f"GUID_{stat_name}", model_name, stat_name, dt_utc.strftime("%m%d%H") + "Z", canvas)
win.exec()
def plot_contour(self, ax, lon, lat, data, ui, levels, color, extent, linewidths=1.5):
lon_c, lat_c, data_c = self.crop_data(lon, lat, data, extent)
if lon_c.size == 0 or lat_c.size == 0 or data_c.size == 0: return None
cs = ax.contour(lon_c, lat_c, data_c, levels=levels, colors=color, linewidths=linewidths, transform=ccrs.PlateCarree(), zorder=7)
ui['dynamic_artists'].append(cs); ax.clabel(cs, inline=True, fontsize=11, fmt='%1.0f'); return cs
def plot_contourf(self, ax, lon, lat, data, ui, levels, cmap_or_colors, extent, extend='both', alpha=0.6, norm=None):
lon_c, lat_c, data_c = self.crop_data(lon, lat, data, extent)
if lon_c.size == 0 or lat_c.size == 0 or data_c.size == 0: return None
kwargs = {'levels': levels, 'extend': extend, 'transform': ccrs.PlateCarree(), 'alpha': alpha, 'zorder': 6}
if isinstance(cmap_or_colors, str) or isinstance(cmap_or_colors, mcolors.Colormap): kwargs['cmap'] = cmap_or_colors
else: kwargs['colors'] = cmap_or_colors
if norm is not None: kwargs['norm'] = norm
cf = ax.contourf(lon_c, lat_c, data_c, **kwargs)
ui['dynamic_artists'].append(cf)
return cf
def plot_blocky(self, ax, lon, lat, data, ui, cmap_or_colors, extent, norm=None, alpha=0.85):
lon_c, lat_c, data_c = self.crop_data(lon, lat, data, extent)
if lon_c.size == 0 or lat_c.size == 0 or data_c.size == 0: return None
if lon_c.ndim == 1:
lon_c, lat_c = np.meshgrid(lon_c, lat_c)
kwargs = {'transform': ccrs.PlateCarree(), 'alpha': alpha, 'zorder': 6, 'shading': 'nearest'}
if isinstance(cmap_or_colors, str) or isinstance(cmap_or_colors, mcolors.Colormap): kwargs['cmap'] = cmap_or_colors
else: kwargs['cmap'] = mcolors.ListedColormap(cmap_or_colors)
if norm is not None: kwargs['norm'] = norm
pm = ax.pcolormesh(lon_c, lat_c, data_c, **kwargs)
ui['dynamic_artists'].append(pm)
return pm
def plot_barbs(self, ax, lon, lat, u, v, ui, area_index, extent, model_name=""):
lon_c, lat_c, u_c = self.crop_data(lon, lat, u, extent)
_, _, v_c = self.crop_data(lon, lat, v, extent)
if lon_c.size == 0 or lat_c.size == 0 or u_c.size == 0 or v_c.size == 0: return None
u_c = np.nan_to_num(u_c, nan=0.0); v_c = np.nan_to_num(v_c, nan=0.0)
if lon_c.ndim == 1: lon_2d, lat_2d = np.meshgrid(lon_c, lat_c)
else: lon_2d, lat_2d = lon_c, lat_c
if lon_2d.shape != u_c.shape: return None
# 矢羽根の間引きロジック
if area_index == 0: skip = 20 # 日本周辺は大幅に間引く
elif area_index == 3: skip = 1
elif area_index == 2: skip = 1
elif "MSM" in model_name or "ANAL" in model_name: skip = 4
else: skip = 3
q = ax.barbs(lon_2d[::skip,::skip], lat_2d[::skip,::skip], u_c[::skip,::skip], v_c[::skip,::skip], length=5.5, color='black', transform=ccrs.PlateCarree(), zorder=10)
ui['dynamic_artists'].append(q)
def show_error_msg(self, ax, ui, msg):
wrapped_msg = "\n".join(textwrap.wrap(msg, width=50))
txt = ax.text(0.5, 0.5, wrapped_msg, transform=ax.transAxes, color='red', fontsize=12, ha='center', va='center', weight='bold', bbox=dict(facecolor='white', alpha=0.9, edgecolor='red'))
ui['dynamic_artists'].append(txt)
#
def draw_guidance(self, ax, ui, data, elem_name, val, extent, area_index, model_name, it_str):
drawn_flag = False; info_text = ""
def _cl(arr, minv, maxv, fill=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)
# 降水/降雪の積算ロジック (6時間・24時間対応&フリーズ防止)
if "降水量" in elem_name or "降雪量" in elem_name:
accum_hours = 3
if "6時間" in elem_name: accum_hours = 6
elif "24時間" in elem_name: accum_hours = 24
accum_val = None; valid_data = False; lon_c = None; lat_c = None
for hr_offset in range(0, accum_hours, 3):
target_ft = val - hr_offset
if target_ft <= 0: continue # FT=0の降水は存在しないため無視して計算継続
d_curr = None
if target_ft == val: d_curr = data
else:
target_file = os.path.join(self.cache_dir, f"{model_name}_{it_str}_FT{target_ft:02d}.npz")
if os.path.exists(target_file):
try: d_curr = np.load(target_file)
except: pass
if d_curr is not None:
if "降水量" in elem_name: key = self.get_exact_key(d_curr, ['precip', 'tp', 'apcp', 'pop', 'var_0_1_8', 'var_0_1_52'])
else: key = self.get_exact_key(d_curr, ['snow', 'weasd', 'snod', 'var_0_1_11', 'var_0_1_13', 'var_0_1_29'])
if key:
v = _cl(d_curr[key], 0, 1000, 0.0)
if accum_val is None:
accum_val = v.copy()
lon_c, lat_c = self.get_coords_for_data(d_curr, v)
valid_data = True
else:
accum_val += v
if target_ft != val: d_curr.close()
if valid_data and accum_val is not None:
accum_val_masked = np.ma.masked_less(accum_val, 0.1)
levels = [0.1, 1, 5, 10, 20, 30, 50, 80]
if "降水量" in elem_name:
cmap = mcolors.ListedColormap(['#A0E8A0', '#D4F05A', '#FFFF00', '#FFA500', '#FF5500', '#FF0000', '#FF00FF'])
cmap.set_under('none'); cmap.set_over('#800080')
label_text = f'降水量 ({accum_hours}時間積算) [mm]'
else:
cmap = mcolors.ListedColormap(['#E8EAF6', '#C5CAE9', '#9FA8DA', '#7986CB', '#5C6BC0', '#3F51B5', '#303F9F'])
cmap.set_under('none'); cmap.set_over('#1A237E')
label_text = f'降雪量 ({accum_hours}時間積算) [cm等]'
norm = mcolors.BoundaryNorm(levels, cmap.N)
pm = self.plot_blocky(ax, lon_c, lat_c, accum_val_masked, ui, cmap, extent, norm=norm, alpha=0.45)
if pm:
add_vertical_colorbar(pm, label_text)
stat_lon = 141.35; stat_lat = 43.06
if lon_c.ndim == 1: lon_2d, lat_2d = np.meshgrid(lon_c, lat_c)
else: lon_2d, lat_2d = lon_c, lat_c
dist = (lon_2d - stat_lon)**2 + (lat_2d - stat_lat)**2
sy, sx = np.unravel_index(np.argmin(dist), dist.shape)
local_val = accum_val[sy, sx] if (sy < accum_val.shape[0] and sx < accum_val.shape[1]) else np.nan
info_text = f"[札幌現況]\n降水量({accum_hours}h): {local_val:.1f} mm" if "降水" in elem_name else f"[札幌現況]\n降雪量({accum_hours}h): {local_val:.1f} cm"
drawn_flag = True
# ★ 札幌近郊:内挿(2.5km相当)&元格子点に枠を付ける&枠外はみ出し消去
if area_index == 3:
lc, lac, dc = self.crop_data(lon_c, lat_c, accum_val, extent)
if lc.size > 0:
l2, la2 = np.meshgrid(lc, lac) if lc.ndim == 1 else (lc, lac)
min_lon, max_lon, min_lat, max_lat = extent # 枠外はみ出し防止用
try:
import scipy.ndimage as ndimage
import matplotlib.patheffects as pe
# MSM(約5km)なら2倍、GSM(約20km)なら8倍に解像度を上げて約2.5km間隔を作成
zoom_factor = 8 if "GSM" in model_name else 2
dc_z = ndimage.zoom(dc, zoom_factor, order=1)
if lc.ndim == 1:
lc_z = ndimage.zoom(lc, zoom_factor, order=1)
lac_z = ndimage.zoom(lac, zoom_factor, order=1)
l2_z, la2_z = np.meshgrid(lc_z, lac_z)
else:
l2_z = ndimage.zoom(l2, zoom_factor, order=1)
la2_z = ndimage.zoom(la2, zoom_factor, order=1)
# ① まず内挿した点(枠なし、青色、白フチ付き)を描画
for r in range(dc_z.shape[0]):
for c in range(dc_z.shape[1]):
lon_v, lat_v, val_p = l2_z[r, c], la2_z[r, c], dc_z[r, c]
# 元の格子点と重なる場所はスキップ
if r % zoom_factor == 0 and c % zoom_factor == 0: continue
# 枠内&0.1mm以上の時だけ描画
if val_p >= 0.1 and min_lon <= lon_v <= max_lon and min_lat <= lat_v <= max_lat:
txt = ax.text(lon_v, lat_v, f"{val_p:.1f}", color='#1f77b4', fontsize=10, ha='center', va='center', transform=ccrs.PlateCarree(), zorder=12, weight='bold', clip_on=True, path_effects=[pe.withStroke(linewidth=2, foreground='white')])
ui['dynamic_artists'].append(txt)
# ② 次に元の格子点(四角枠あり、黒色)を上に重ねて描画
for r in range(dc.shape[0]):
for c in range(dc.shape[1]):
lon_v, lat_v, val_p = l2[r, c], la2[r, c], dc[r, c]
if val_p >= 0.1 and min_lon <= lon_v <= max_lon and min_lat <= lat_v <= max_lat:
txt = ax.text(lon_v, lat_v, f"{val_p:.1f}", color='black', fontsize=11, ha='center', va='center', transform=ccrs.PlateCarree(), zorder=13, weight='bold', bbox=dict(facecolor='white', edgecolor='black', boxstyle='square,pad=0.2', alpha=0.9), clip_on=True)
ui['dynamic_artists'].append(txt)
except Exception as e:
print(f"内挿描画エラー: {e}")
elif "発雷" in elem_name:
# 判明した 'tstm' キーを追加!
key = None
for k in data.files:
kl = k.lower()
if any(x in kl for x in ['thund', 'pol', 'lig', 'ts', 'tstm', 'var_0_19_193']): key = k; break
if key:
try:
val_data = _cl(data[key], 0, 100, 0.0)
# 発雷確率(tstm)専用の座標キー(lon_surf/lat_surf等)を動的に再取得してサイズ不一致を回避
lon_c, lat_c = self.get_coords_for_data(data, val_data)
if lon_c is not None and lat_c is not None:
if lon_c.ndim == 1 and val_data.shape == (len(lon_c), len(lat_c)):
val_data = val_data.T
if lon_c is None or lat_c is None:
# 万が一自動取得に失敗した場合のセーフティネット
lon_c = data['lon_surf'] if 'lon_surf' in data.files else data['lon']
lat_c = data['lat_surf'] if 'lat_surf' in data.files else data['lat']
# 1%未満をマスクしてすっきり表示
val_masked = np.ma.masked_less(val_data, 1.0)
levels = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
# 雷らしい鮮やかな暖色系のカラーマップ
cmap = mcolors.ListedColormap(['#FFF3E0', '#FFE0B2', '#FFCC80', '#FFB74D', '#FFA726', '#FF9800', '#FB8C00', '#F57C00', '#EF6C00'])
cmap.set_under('none')
norm = mcolors.BoundaryNorm(levels, cmap.N)
# 描画実行
pm = self.plot_blocky(ax, lon_c, lat_c, val_masked, ui, cmap, extent, norm=norm, alpha=0.6)
if pm:
add_vertical_colorbar(pm, '発雷確率 [%]')
info_text = self.get_local_info(elem_name, lon_c, lat_c, val_data, area_index=area_index, model_name=model_name)
drawn_flag = True
except Exception as e:
print(f"平面地図発雷描画エラー: {e}")
elif "天気" in elem_name:
# 判明した 'var_-1_-1_-1' キーを追加!
key = None
for k in data.files:
kl = k.lower()
if any(x in kl for x in ['wea', 'wx', 'var_-1_-1_-1', 'var_0_19_192']): key = k; break
if key:
val_data = _cl(data[key], 0, 4, 0.0); lon_c, lat_c = self.get_coords_for_data(data, val_data)
if lon_c is not None and lat_c is not None:
if lon_c.ndim == 1 and val_data.shape == (len(lon_c), len(lat_c)):
val_data = val_data.T
if lon_c is None or lat_c is None:
lon_c = data['lon_surf'] if 'lon_surf' in data.files else data['lon']
lat_c = data['lat_surf'] if 'lat_surf' in data.files else data['lat']
levels = [-0.5, 0.5, 1.5, 2.5, 3.5]
colors = ['#FFD700', '#B0C4DE', '#1E90FF', '#FFFFFF']
cmap_wx = mcolors.ListedColormap(colors); norm_wx = mcolors.BoundaryNorm(levels, cmap_wx.N)
pm = self.plot_blocky(ax, lon_c, lat_c, val_data, ui, cmap_wx, extent, norm=norm_wx, alpha=0.6)
if pm: add_vertical_colorbar(pm, '卓越天気 (0:晴 1:曇 2:雨 3:雪)'); info_text = self.get_local_info(elem_name, lon_c, lat_c, val_data, area_index=area_index, model_name=model_name); drawn_flag = True
return drawn_flag, info_text
def extract_2d_array(self, data, exact_keys, target_level=None, fuzzy_keys=None):
"""3D配列(MSM上空など)や名前の揺れを強力に吸収して2D配列を取り出す"""
for k in exact_keys:
if k in data.files:
arr = np.squeeze(data[k])
if arr.ndim == 2: return arr
if fuzzy_keys:
for k in data.files:
kl = k.lower()
if any(fk in kl for fk in fuzzy_keys) and not any(ex in kl for ex in ['lon', 'lat', 'time', 'valid']):
arr = np.squeeze(data[k])
if arr.ndim == 2: return arr
if arr.ndim == 3 and target_level is not None:
levels_16 = [1000, 925, 850, 700, 500, 400, 300, 250, 200, 150, 100, 70, 50, 30, 20, 10]
levels_msm = [1000, 975, 950, 925, 900, 850, 800, 700, 600, 500, 400, 300]
axis = 0 if arr.shape[0] < arr.shape[1] else 2
sz = arr.shape[axis]
if sz == len(levels_16) and target_level in levels_16:
return arr[levels_16.index(target_level),:,:] if axis == 0 else arr[:,:,levels_16.index(target_level)]
elif sz == len(levels_msm) and target_level in levels_msm:
return arr[levels_msm.index(target_level),:,:] if axis == 0 else arr[:,:,levels_msm.index(target_level)]
return None
#
def draw_frame(self, model_name, val):
ui = self.tab_ui[model_name]; it_str = self.model_init_times.get(model_name)
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 = ""
# MSM・GSMの上空データ間引き自動回避ロジック
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 を代替表示)"
elif model_name == "MSM" and "hpa" in elem_name.lower() and val % 3 != 0:
display_val = (val // 3) * 3
fallback_msg = f" (※上空データ3時間間隔のため FT={display_val}h を代替表示)"
if "GUID" in model_name and "気温" not in elem_name and "風" not in elem_name:
if val % 3 != 0:
display_val = (val // 3) * 3
fallback_msg += f" (※3時間間隔データのため FT={display_val}h を代替表示)"
dt_utc = datetime.strptime(it_str, "%Y%m%d%H%M%S")
dt_jst = dt_utc + timedelta(hours=9)
target_time = dt_jst + timedelta(hours=val)
ui['time_label'].setText(f"FT={val:02d}h\n({target_time.strftime('%m/%d %H:00')})")
area_index = next(i for i, btn in enumerate(ui['area_btns']) if btn.isChecked())
if ui['ax'] is None or ui['current_area_index'] != area_index:
ui['ax'] = self.init_map(ui['fig'], area_index, model_name)
ui['current_area_index'] = area_index
ax = ui['ax']
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()
ax.title.set_text(f"[{model_name}] {elem_name} / 初期時刻(UTC): {dt_utc.strftime('%m/%d %H:00Z')} / FT={val}h ({target_time.strftime('%m/%d %H:00')} JST){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 and "時間降水" not in elem_name and model_name != "ANAL": 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.get_exact_key(data, ['lon_surf', 'lon', 'lon_p', 'lon_pall'])
lat_key = self.get_exact_key(data, ['lat_surf', 'lat', 'lat_p', 'lat_pall'])
if not lon_key or not lat_key:
self.show_error_msg(ax, ui, f"[!] {elem_name} の座標データが見つかりません。(キー: {data.files})")
data.close(); ui['canvas'].draw_idle(); return
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)
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 "GUID" in model_name:
drawn_flag, info_text = self.draw_guidance(ax, ui, data, elem_name, val, extent, area_index, model_name, it_str)
elif elem_name == "地上気温":
t_val = self.extract_2d_array(data, ['t2m', 'tmp2m', 'temp2m'], fuzzy_keys=['tmp', 'temp', 't'])
if t_val is not None:
if np.nanmax(t_val) > 100: t_val = t_val - 273.15
t_val = _cl(t_val, -50, 40, 0.0)
lon_c, lat_c = self.get_coords_for_data(data, t_val)
cf = self.plot_contourf(ax, lon_c, lat_c, t_val, ui, np.arange(-30, 40, 3), 'jet', extent, extend='both', alpha=0.4)
self.plot_contour(ax, lon_c, lat_c, t_val, ui, np.arange(-30, 40, 3), 'black', extent, linewidths=0.5)
if cf:
add_vertical_colorbar(cf, '地上気温 [℃]')
info_text = self.get_local_info(elem_name, lon_c, lat_c, t_val, area_index=area_index, model_name=model_name); drawn_flag = True
elif elem_name == "地上気圧":
slp = self.extract_2d_array(data, ['prmsl', 'mslet', 'msl', 'slp'], fuzzy_keys=['msl', 'prmsl'])
if slp is not None:
if np.nanmax(slp) > 2000: slp = slp / 100.0
slp_hpa = _cl(slp, 800, 1150, 1013.0)
lon_c, lat_c = self.get_coords_for_data(data, slp_hpa)
import scipy.ndimage as ndimage
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)
if cs: info_text += self.get_local_info("気圧", lon_c, lat_c, slp, area_index=area_index, model_name=model_name); drawn_flag = True
elif elem_name == "地上風向風速":
u_val = self.extract_2d_array(data, ['u10', '10u', 'u_10m'], fuzzy_keys=['u10', 'u_wind'])
v_val = self.extract_2d_array(data, ['v10', '10v', 'v_10m'], fuzzy_keys=['v10', 'v_wind'])
if u_val is not None and v_val is not None:
u_val = _cl(u_val, -200, 200, 0.0); v_val = _cl(v_val, -200, 200, 0.0)
spd = np.hypot(u_val, v_val); lon_c, lat_c = self.get_coords_for_data(data, spd)
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)
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)
info_text = self.get_local_info("風向風速", lon_c, lat_c, spd, u_val, v_val, area_index=area_index, model_name=model_name); drawn_flag = True
elif "地上降水" in elem_name:
p_key = self.get_precip_key(data)
if p_key:
pval = _cl(data[p_key], 0, 1000, 0.0)
if model_name == "GSM_JP" and val >= 3:
prev_file = os.path.join(self.cache_dir, f"{model_name}_{it_str}_FT{val-3:02d}.npz")
if os.path.exists(prev_file):
try:
with np.load(prev_file) as d_prev:
prev_p_key = self.get_precip_key(d_prev)
if prev_p_key:
prev_pval = _cl(d_prev[prev_p_key], 0, 1000, 0.0)
if prev_pval.shape == pval.shape: pval = np.clip(pval - prev_pval, 0, None)
except: pass
lon_c, lat_c = self.get_coords_for_data(data, pval)
if 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)))
import scipy.ndimage as ndimage
pval_smooth = ndimage.gaussian_filter(pval, sigma=0.2)
pval_smooth = np.ma.masked_less(pval_smooth, 0.1)
levels = [0.1, 1, 5, 10, 20, 30, 50, 80]
cmap_precip = mcolors.ListedColormap(['#A0E8A0', '#D4F05A', '#FFFF00', '#FFA500', '#FF5500', '#FF0000', '#FF00FF'])
cmap_precip.set_under('none'); cmap_precip.set_over('#800080')
norm_precip = mcolors.BoundaryNorm(levels, cmap_precip.N)
cf = self.plot_contourf(ax, lon_c, lat_c, pval_smooth, ui, levels, cmap_precip, extent, extend='max', alpha=0.85, norm=norm_precip)
if cf:
self.plot_contour(ax, lon_c, lat_c, pval_smooth, ui, levels, '#444444', extent, linewidths=0.5)
add_vertical_colorbar(cf, f'降水量 [mm]')
info_text = self.get_local_info("降水", lon_c, lat_c, pval, area_index=area_index, model_name=model_name); drawn_flag = True
else:
if val != 0 or model_name == "ANAL":
self.show_error_msg(ax, ui, f"[!] 降水キー不明。\nデータが未収録の可能性があります。")
drawn_flag = True
elif "雲量" in elem_name:
c_val = None
if elem_name == "全雲量": c_val = self.extract_2d_array(data, ['tcc', 'tcdc', 'lcdc', 'cloud', 'hcc', 'mcc', 'lcc'], fuzzy_keys=['tcc'])
elif elem_name == "上層雲量": c_val = self.extract_2d_array(data, ['hcc', 'hcdc', 'high', 'tcc'], fuzzy_keys=['hcc', 'high'])
elif elem_name == "中層雲量": c_val = self.extract_2d_array(data, ['mcc', 'mcdc', 'mid', 'tcc'], fuzzy_keys=['mcc', 'mid'])
elif elem_name == "下層雲量": c_val = self.extract_2d_array(data, ['lcc', 'lcdc', 'low', 'tcc'], fuzzy_keys=['lcc', 'low'])
if c_val is not None:
c_val = _cl(c_val, 0, 150, 0.0)
lon_c, lat_c = self.get_coords_for_data(data, c_val)
if 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]
cmap_cloud = mcolors.ListedColormap(['#D9D9D9', '#BDBDBD', '#969696', '#737373', '#525252', '#252525', '#0F0F0F', '#000000'])
cmap_cloud.set_under('none'); cmap_cloud.set_over('#000000'); norm_cloud = mcolors.BoundaryNorm(levels, cmap_cloud.N)
cf = self.plot_contourf(ax, lon_c, lat_c, c_val, ui, levels, cmap_cloud, extent, extend='max', alpha=0.7, norm=norm_cloud)
if cf:
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:
h_val = self.extract_2d_array(data, ['gh300', 'hgt300', 'hgt_300'], 300, ['hgt', 'gh', 'z'])
if h_val is not None:
h_val = _cl(h_val, -500, 20000, 0.0); lon_p, lat_p = self.get_coords_for_data(data, h_val)
levels = np.arange(8400, 10800, 60); cf = self.plot_contourf(ax, lon_p, lat_p, h_val, ui, levels, 'PuBu', extent, alpha=0.6)
self.plot_contour(ax, lon_p, lat_p, h_val, ui, levels, 'darkblue', extent)
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:
u_val = self.extract_2d_array(data, ['u300', 'u_300'], 300, ['u', 'ugrd'])
v_val = self.extract_2d_array(data, ['v300', 'v_300'], 300, ['v', 'vgrd'])
if u_val is not None and v_val is not None:
u_val = _cl(u_val, -300, 300, 0.0); v_val = _cl(v_val, -300, 300, 0.0)
spd = np.hypot(u_val, v_val); lon_p, lat_p = self.get_coords_for_data(data, spd)
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)
self.plot_barbs(ax, lon_p, lat_p, u_val, v_val, ui, area_index, extent, model_name)
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:
h_val = self.extract_2d_array(data, ['gh500', 'hgt500', 'hgt_500'], 500, ['hgt', 'gh', 'z'])
if h_val is not None:
h_val = _cl(h_val, -500, 20000, 0.0); lon_p, lat_p = self.get_coords_for_data(data, h_val)
levels = np.arange(4800, 6000, 60); cf = self.plot_contourf(ax, lon_p, lat_p, h_val, ui, levels, 'Greens', extent, alpha=0.6)
self.plot_contour(ax, lon_p, lat_p, h_val, ui, levels, 'darkgreen', extent)
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:
u_val = self.extract_2d_array(data, ['u500', 'u_500'], 500, ['u', 'ugrd'])
v_val = self.extract_2d_array(data, ['v500', 'v_500'], 500, ['v', 'vgrd'])
if u_val is not None and v_val is not None:
u_val = _cl(u_val, -300, 300, 0.0); v_val = _cl(v_val, -300, 300, 0.0)
spd = np.hypot(u_val, v_val); lon_p, lat_p = self.get_coords_for_data(data, spd)
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)
self.plot_barbs(ax, lon_p, lat_p, u_val, v_val, ui, area_index, extent, model_name)
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:
vort = self.extract_2d_array(data, ['vort500', 'vort', 'abs_vort'], 500, ['vort', 'abs_vort'])
if vort is not None:
vort = _cl(vort, -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)
self.plot_contour(ax, lon_p, lat_p, vort, ui, levels, '#4A235A', extent)
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 "hpa気温" in elem_name.lower():
w = re.search(r'(\d+)hpa', elem_name.lower())
if w:
lvl = int(w.group(1))
t_val = self.extract_2d_array(data, [f't{lvl}', f'tmp{lvl}', f't_{lvl}', f'tmp_{lvl}'], lvl, ['tmp', 'temp', 't'])
if t_val is not None:
if np.nanmax(t_val) > 100: t_val = t_val - 273.15
t_val = _cl(t_val, -100, 60, 0.0); lon_p, lat_p = self.get_coords_for_data(data, t_val)
cf = self.plot_contourf(ax, lon_p, lat_p, t_val, ui, np.arange(-54, 40, 3), 'jet', extent, extend='both', alpha=0.3)
self.plot_contour(ax, lon_p, lat_p, t_val, ui, np.arange(-54, 40, 3), 'blue', extent, linewidths=0.5)
if cf:
add_vertical_colorbar(cf, f'{lvl}hPa 気温 [℃]')
lon_c, lat_c, t_c = self.crop_data(lon_p, lat_p, t_val, extent)
if lon_c.size > 0:
cs = ax.contour(lon_c if lon_c.ndim==1 else lon_c,
lat_c if lat_c.ndim==1 else lat_c,
t_c, levels=[-36, -6, 0, 15, 30], colors='red', linewidths=2.0, transform=ccrs.PlateCarree(), zorder=8)
ui['dynamic_artists'].append(cs); ax.clabel(cs, inline=True, fontsize=12, fmt='%1.0f')
u_val = self.extract_2d_array(data, [f'u{lvl}', f'u_{lvl}'], lvl, ['u', 'ugrd'])
v_val = self.extract_2d_array(data, [f'v{lvl}', f'v_{lvl}'], lvl, ['v', 'vgrd'])
if u_val is not None: u_val = _cl(u_val, -200, 200, 0.0)
if v_val is not None: v_val = _cl(v_val, -200, 200, 0.0)
info_text = self.get_local_info(elem_name, lon_p, lat_p, t_val, u_val, v_val, area_index=area_index, model_name=model_name)
drawn_flag = True
if u_val is not None and v_val is not None: self.plot_barbs(ax, lon_p, lat_p, u_val, v_val, ui, area_index, extent, model_name)
elif "700hpa湿数" in elem_name:
t = self.extract_2d_array(data, ['t700', 'tmp700', 't_700'], 700, ['tmp', 'temp', 't'])
rh_raw = self.extract_2d_array(data, ['rh700', 'r700', 'rh_700'], 700, ['rh', 'hum', 'r'])
if t is not None and rh_raw is not None:
if np.nanmax(t) > 100: t = t - 273.15
rh = _cl(rh_raw, 0, 150, 0.0)
if np.nanmax(rh) <= 1.0 and np.nanmax(rh) > 0: rh = rh * 100.0
lon_p, lat_p = self.get_coords_for_data(data, t); t_td = calc_t_td(t, rh)
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)
u_val = self.extract_2d_array(data, ['u700', 'u_700'], 700, ['u', 'ugrd'])
v_val = self.extract_2d_array(data, ['v700', 'v_700'], 700, ['v', 'vgrd'])
if u_val is not None: u_val = _cl(u_val, -200, 200, 0.0)
if v_val is not None: v_val = _cl(v_val, -200, 200, 0.0)
if cf:
add_vertical_colorbar(cf, '700hPa 湿数 [℃]')
info_text = self.get_local_info("湿数", lon_p, lat_p, t_td, u_val, v_val, area_index=area_index, model_name=model_name); drawn_flag = True
if u_val is not None and v_val is not None: self.plot_barbs(ax, lon_p, lat_p, u_val, v_val, ui, area_index, extent, model_name)
elif "700hpa鉛直流" in elem_name:
w_val = self.extract_2d_array(data, ['w', 'w700', 'vvel', 'vvel700', 'dzdt', 'dzdt700'], 700, ['w', 'vvel', 'dzdt'])
if w_val is not None:
if np.nanmax(np.abs(w_val)) < 15.0: w_val = w_val * 36.0
w_val = _cl(w_val, -500, 500, 0.0); lon_p, lat_p = self.get_coords_for_data(data, w_val)
import scipy.ndimage as ndimage
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)
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 = self.extract_2d_array(data, ['t850', 'tmp850', 't_850'], 850, ['tmp', 'temp', 't'])
rh_raw = self.extract_2d_array(data, ['rh850', 'r850', 'rh_850'], 850, ['rh', 'hum', 'r'])
if t is not None and rh_raw is not None:
if np.nanmax(t) > 100: t = t - 273.15
rh = _cl(rh_raw, 0, 150, 0.0)
if np.nanmax(rh) <= 1.0 and np.nanmax(rh) > 0: rh = rh * 100.0
lon_p, lat_p = self.get_coords_for_data(data, t); ept = calc_ept(t, rh, 850); levels = np.arange(270, 360, 6)
cf = self.plot_contourf(ax, lon_p, lat_p, ept, ui, levels, 'rainbow', extent, extend='both', alpha=0.6)
self.plot_contour(ax, lon_p, lat_p, ept, ui, levels, 'black', extent)
if cf: 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 = self.extract_2d_array(data, ['t850', 'tmp850', 't_850'], 850, ['tmp', 'temp', 't'])
rh_raw = self.extract_2d_array(data, ['rh850', 'r850', 'rh_850'], 850, ['rh', 'hum', 'r'])
if t is not None and rh_raw is not None:
if np.nanmax(t) > 100: t = t - 273.15
rh = _cl(rh_raw, 0, 150, 0.0)
if np.nanmax(rh) <= 1.0 and np.nanmax(rh) > 0: rh = rh * 100.0
lon_p, lat_p = self.get_coords_for_data(data, t); t_td = calc_t_td(t, rh)
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)
u_val = self.extract_2d_array(data, ['u850', 'u_850'], 850, ['u', 'ugrd'])
v_val = self.extract_2d_array(data, ['v850', 'v_850'], 850, ['v', 'vgrd'])
if u_val is not None: u_val = _cl(u_val, -200, 200, 0.0)
if v_val is not None: v_val = _cl(v_val, -200, 200, 0.0)
if cf:
add_vertical_colorbar(cf, '850hPa 湿数 [℃]')
info_text = self.get_local_info("湿数", lon_p, lat_p, t_td, u_val, v_val, area_index=area_index, model_name=model_name); drawn_flag = True
if u_val is not None and v_val is not None: self.plot_barbs(ax, lon_p, lat_p, u_val, v_val, ui, area_index, extent, model_name)
elif "925hpa" in elem_name or "975hpa" in elem_name:
lvl = 925 if "925" in elem_name else 975
t = self.extract_2d_array(data, [f't{lvl}', f'tmp{lvl}', f't_{lvl}'], lvl, ['tmp', 'temp', 't'])
rh_raw = self.extract_2d_array(data, [f'rh{lvl}', f'r{lvl}', f'rh_{lvl}'], lvl, ['rh', 'hum', 'r'])
if t is not None and rh_raw is not None:
if np.nanmax(t) > 100: t = t - 273.15
rh = _cl(rh_raw, 0, 150, 0.0)
if np.nanmax(rh) <= 1.0 and np.nanmax(rh) > 0: rh = rh * 100.0
lon_p, lat_p = self.get_coords_for_data(data, t)
if "相当温位" in elem_name:
ept = calc_ept(t, rh, lvl); levels = np.arange(270, 360, 6)
cf = self.plot_contourf(ax, lon_p, lat_p, ept, ui, levels, 'rainbow', extent, extend='both', alpha=0.6)
self.plot_contour(ax, lon_p, lat_p, ept, ui, levels, 'black', extent)
if cf: 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)
u_val = self.extract_2d_array(data, [f'u{lvl}', f'u_{lvl}'], lvl, ['u', 'ugrd'])
v_val = self.extract_2d_array(data, [f'v{lvl}', f'v_{lvl}'], lvl, ['v', 'vgrd'])
if u_val is not None: u_val = _cl(u_val, -200, 200, 0.0)
if v_val is not None: v_val = _cl(v_val, -200, 200, 0.0)
if cf:
add_vertical_colorbar(cf, f'{lvl}hPa 湿数 [℃]')
info_text = self.get_local_info("湿数", lon_p, lat_p, t_td, u_val, v_val, area_index=area_index, model_name=model_name); drawn_flag = True
if u_val is not None and v_val is not None: self.plot_barbs(ax, lon_p, lat_p, u_val, v_val, ui, area_index, extent, model_name)
data.close(); del data
if info_text:
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 and "時間降水" not in elem_name and model_name != "ANAL": self.show_error_msg(ax, ui, "【初期時のためデータなし。次画像から表示】")
elif not ("降水" in elem_name and val != 0 and "確率" not in elem_name and "時間降水" not in elem_name):
self.show_error_msg(ax, ui, f"[!] {elem_name} のデータがありません。\n保存時の変数名が異なっている可能性があります。")
except Exception as e: logging.error(f"Draw Error for {elem_name} at FT={val}: {e}")
ui['canvas'].draw_idle(); gc.collect()
#
def setup_history_tab(self):
tab = QWidget(); layout = QVBoxLayout(tab); history_text = QTextEdit(); history_text.setReadOnly(True)
history_text.setStyleSheet("background-color: #112240; color: #E0E0E0; font-family: 'MS Gothic'; font-size: 12pt; padding: 10px; border: none;")
html_content = """
<h2 style='color: #64FFDA;'>気象GPV 局地分析ツール 更新履歴</h2><hr style='border-color: #457B9D;'><ul>
<li><b>Ver 118.0 (修正対応完了版)</b><br>
1. <b>MSM上空要素対応</b>: t300等の検索キーのゆらぎに対応し、上空要素を正常描画。<br>
2. <b>JST併記とレイアウト改善</b>: GPVメタグラムの気圧軸上部に日本時間(JST)を併記。タイトルフォントサイズを調整。<br>
3. <b>矢羽根の間引き</b>: 日本周辺表示時の風向・風速矢羽根を大幅に間引き、視認性を向上。<br>
4. <b>ガイダンスの表示改善</b>: 発雷確率・天気を表示。降水量の透過度を調整し、札幌近郊表示時には降水量の数値をブロック上に直接描画。<br>
5. <b>統合メタグラム表のA4画像化</b>: ダイアログ表示からMatplotlibによるTable描画へ変更し、画像として保存可能に。<br>
6. <b>その他</b>: 終了確認ダイアログの追加、札幌近郊の区境界(赤線)を追加。
</ul>"""
history_text.setHtml(html_content); layout.addWidget(history_text); self.tabs.addTab(tab, "更新履歴")
def scan_cache_dir(self, quiet=False, force_draw=False):
if not os.path.exists(self.cache_dir): return
files = glob.glob(os.path.join(self.cache_dir, "*.npz"))
updated = False
for m in ["MSM", "GSM_JP", "ANAL", "MSM_GUID", "GSM_GUID"]:
m_files = [f for f in files if os.path.basename(f).startswith(f"{m}_")]
times = sorted(list(set(re.search(r'_(\d{14})_', f).group(1) for f in m_files if re.search(r'_(\d{14})_', f))), reverse=True)
if times:
combo = self.tab_ui.get(m, {}).get('init_combo')
if combo:
current_items = [combo.itemData(i) for i in range(combo.count())]
if current_items != times:
combo.blockSignals(True); combo.clear()
for t in times:
dt_utc = datetime.strptime(t, "%Y%m%d%H%M%S") # UTC完全準拠
dt_jst = dt_utc + timedelta(hours=9)
combo.addItem(dt_utc.strftime('%m/%d %H:%M Z') + (" (最新)", "")[t != times[0]], t)
combo.blockSignals(False)
if self.model_init_times.get(m) not in times:
self.model_init_times[m] = times[0];
self.update_slider_max(m, times[0])
self.tab_ui[m]['slider'].setValue(0); combo.setCurrentIndex(0); updated = True
if (updated or force_draw) and not quiet:
self.status_banner.setText("最新データを画面に同期しました")
current_tab_name = self.tabs.tabText(self.tabs.currentIndex())
if "MSM ガイダンス" in current_tab_name: current_m = "MSM_GUID"
elif "GSM ガイダンス" in current_tab_name: current_m = "GSM_GUID"
elif "毎時大気" in current_tab_name: current_m = "ANAL"
elif "MSM" in current_tab_name: current_m = "MSM"
elif "日本域" in current_tab_name: current_m = "GSM_JP"
else: current_m = None
if current_m in self.tab_ui:
if len(self.tab_ui[current_m]['area_btns']) > 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())
def start_animation(self, model_name):
self.current_anim_model = model_name
speeds = [2000, 1000, 500, 200, 50]
self.anim_timer.start(speeds[self.tab_ui[model_name]['combo_speed'].currentIndex()])
def 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']
ui['slider'].setValue(new_val if new_val <= ui['slider'].maximum() else ui['slider'].minimum())
if name == ‘main‘:
app = QApplication(sys.argv); window = WeatherApp(); sys.exit(app.exec())


コメント