import sys
import os
import subprocess
import pandas as pd
import math
import glob
import io
import re
from datetime import datetime
from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
QHBoxLayout, QPushButton, QLabel, QFileDialog,
QGroupBox, QMessageBox, QRadioButton, QDateEdit,
QButtonGroup, QFrame)
from PyQt6.QtCore import Qt, QDate
import matplotlib
matplotlib.use('QtAgg')
# 文字化け防止フォント
matplotlib.rc('font', family=['Meiryo', 'MS Gothic', 'Yu Gothic', 'sans-serif'])
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.backends.backend_qtagg import NavigationToolbar2QT as NavigationToolbar
from matplotlib.figure import Figure
try:
import contextily as cx
HAS_CX = True
except ImportError:
HAS_CX = False
class GribMapExtractorApp(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("気象予報精度検証システム - GRIB2 抽出&マッピング (Ver 5.0)")
self.resize(1400, 950) # 横幅を広げる
self.wgrib2_path = "wgrib2.exe"
# 札幌圏の範囲設定
self.lat_min = 42.0 + (40.0 / 60.0)
self.lat_max = 43.0 + (20.0 / 60.0)
self.lon_min = 141.0 + (0.0 / 60.0)
self.lon_max = 141.0 + (40.0 / 60.0)
self.current_df = None
self.current_data_name = ""
self.target_points = []
self.selected_point = None
self.map_aspect_ratio = 1.0 / math.cos(math.radians(43.0))
self.init_ui()
self.plot_initial_map()
def init_ui(self):
main_widget = QWidget()
# ★ 左(操作)と右(地図)の水平分割レイアウト
main_layout = QHBoxLayout(main_widget)
# ==========================================
# 【左パネル】 操作・設定エリア
# ==========================================
left_panel = QWidget()
left_layout = QVBoxLayout(left_panel)
left_panel.setFixedWidth(450) # 左パネルの幅を固定し、残りをすべて地図に回す
# --- モード選択 & 期間指定 ---
group_mode = QGroupBox("1. データ取得モード & 期間設定")
mode_layout = QVBoxLayout()
self.radio_latest = QRadioButton("🌟 最新データを取得し続ける (自動取得)")
self.radio_latest.setChecked(True)
self.radio_range = QRadioButton("📅 過去データを期間指定して一括抽出")
self.mode_group = QButtonGroup()
self.mode_group.addButton(self.radio_latest)
self.mode_group.addButton(self.radio_range)
# カレンダー設定
date_layout = QHBoxLayout()
self.date_start = QDateEdit(QDate.currentDate().addDays(-7))
self.date_start.setCalendarPopup(True); self.date_start.setEnabled(False)
self.date_end = QDateEdit(QDate.currentDate())
self.date_end.setCalendarPopup(True); self.date_end.setEnabled(False)
date_layout.addWidget(QLabel("開始:"))
date_layout.addWidget(self.date_start)
date_layout.addWidget(QLabel("〜 終了:"))
date_layout.addWidget(self.date_end)
# ラジオボタン切り替え時の連動
self.radio_range.toggled.connect(lambda checked: self.date_start.setEnabled(checked))
self.radio_range.toggled.connect(lambda checked: self.date_end.setEnabled(checked))
mode_layout.addWidget(self.radio_latest)
mode_layout.addWidget(self.radio_range)
mode_layout.addLayout(date_layout)
group_mode.setLayout(mode_layout)
left_layout.addWidget(group_mode)
# --- フォルダ設定 & 抽出実行 ---
group_folders = QGroupBox("2. 解析値データ フォルダ設定 & 抽出")
f_layout = QVBoxLayout()
self.folder_paths = {"rain": "", "snowfall": "", "snowdepth": ""}
self.labels = {}
for key, icon, name in [("rain", "🌧️", "解析雨量"), ("snowfall", "❄️", "解析降雪量"), ("snowdepth", "⛄", "解析積雪深")]:
row = QVBoxLayout()
lbl = QLabel(f"{icon} {name} フォルダ: 未設定")
self.labels[key] = lbl
btn_layout = QHBoxLayout()
btn_set = QPushButton("フォルダ選択"); btn_set.clicked.connect(lambda _, k=key: self.select_folder(k))
btn_ext = QPushButton(f"{name} を抽出"); btn_ext.setStyleSheet("background-color: #0366D6; color: white; font-weight: bold;")
btn_ext.clicked.connect(lambda _, k=key, n=name: self.run_extraction(k, n))
btn_layout.addWidget(btn_set); btn_layout.addWidget(btn_ext)
row.addWidget(lbl); row.addLayout(btn_layout)
# 区切り線
line = QFrame(); line.setFrameShape(QFrame.Shape.HLine); line.setFrameShadow(QFrame.Shadow.Sunken)
row.addWidget(line)
f_layout.addLayout(row)
group_folders.setLayout(f_layout)
left_layout.addWidget(group_folders)
# --- 検証対象 抽出結果表示 ---
group_result = QGroupBox("3. 選択地点のデータ (DB登録プレビュー)")
r_layout = QVBoxLayout()
self.lbl_selected = QLabel("📍 地図上の赤い点をクリックして\n検証対象を選択してください。")
self.lbl_selected.setStyleSheet("color: #D73A49; font-weight: bold; font-size: 15px; padding: 15px; background-color: #FFF0F0; border: 2px solid #D73A49; border-radius: 5px;")
r_layout.addWidget(self.lbl_selected)
group_result.setLayout(r_layout)
left_layout.addWidget(group_result)
left_layout.addStretch(1) # 下に詰める
main_layout.addWidget(left_panel)
# ==========================================
# 【右パネル】 地図エリア (極大化)
# ==========================================
right_panel = QWidget()
right_layout = QVBoxLayout(right_panel)
self.figure = Figure()
self.canvas = FigureCanvas(self.figure)
self.toolbar = NavigationToolbar(self.canvas, self)
self.toolbar.hide() # デフォルトツールバー隠す
self.canvas.mpl_connect('button_press_event', self.on_map_click)
# ★ カスタム操作ボタン (アクティブ状態がわかるように)
map_controls = QHBoxLayout()
self.btn_select = QPushButton("👆 地点選択 (クリック)"); self.btn_select.clicked.connect(self.set_mode_select)
self.btn_zoom = QPushButton("🔍 ズーム (範囲選択)"); self.btn_zoom.clicked.connect(self.set_mode_zoom)
self.btn_pan = QPushButton("✋ 移動 (ドラッグ)"); self.btn_pan.clicked.connect(self.set_mode_pan)
btn_home = QPushButton("🏠 全体表示に戻す"); btn_home.clicked.connect(self.toolbar.home)
for btn in [self.btn_select, self.btn_zoom, self.btn_pan, btn_home]:
btn.setStyleSheet("padding: 10px; font-weight: bold; font-size: 14px;")
self.set_mode_select() # 初期状態は選択モード
map_controls.addWidget(self.btn_select); map_controls.addWidget(self.btn_zoom)
map_controls.addWidget(self.btn_pan); map_controls.addWidget(btn_home)
map_controls.addStretch(1)
right_layout.addLayout(map_controls)
right_layout.addWidget(self.canvas)
main_layout.addWidget(right_panel, stretch=1) # 右パネルに残りすべての幅を与える
self.setCentralWidget(main_widget)
# --- 地図操作モードの切り替え制御 ---
def reset_button_styles(self):
base_style = "padding: 10px; font-weight: bold; font-size: 14px; background-color: #f0f0f0;"
self.btn_select.setStyleSheet(base_style); self.btn_zoom.setStyleSheet(base_style); self.btn_pan.setStyleSheet(base_style)
def set_mode_select(self):
if self.toolbar.mode == 'zoom rect': self.toolbar.zoom()
if self.toolbar.mode == 'pan/zoom': self.toolbar.pan()
self.reset_button_styles()
self.btn_select.setStyleSheet("padding: 10px; font-weight: bold; font-size: 14px; background-color: #28A745; color: white;")
def set_mode_zoom(self):
if self.toolbar.mode != 'zoom rect': self.toolbar.zoom()
self.reset_button_styles()
self.btn_zoom.setStyleSheet("padding: 10px; font-weight: bold; font-size: 14px; background-color: #0366D6; color: white;")
def set_mode_pan(self):
if self.toolbar.mode != 'pan/zoom': self.toolbar.pan()
self.reset_button_styles()
self.btn_pan.setStyleSheet("padding: 10px; font-weight: bold; font-size: 14px; background-color: #0366D6; color: white;")
def select_folder(self, key):
folder = QFileDialog.getExistingDirectory(self, "フォルダを選択")
if folder:
self.folder_paths[key] = folder
self.labels[key].setText(f"📁 {folder}")
# ==========================================
# 地図描画・操作系
# ==========================================
def plot_initial_map(self):
self.ax = self.figure.add_subplot(111)
self.ax.clear()
self.ax.set_xlim(self.lon_min - 0.05, self.lon_max + 0.05)
self.ax.set_ylim(self.lat_min - 0.05, self.lat_max + 0.05)
self.ax.set_aspect(self.map_aspect_ratio, adjustable='box')
self.ax.set_title("札幌圏 グリッドマップ")
if HAS_CX:
# ★ 高解像度化 (zoom=13 に引き上げ。非常に綺麗になります)
try: cx.add_basemap(self.ax, crs="EPSG:4326", source=cx.providers.OpenStreetMap.Mapnik, alpha=0.8, zoom=13)
except: pass
self.ax.grid(True, linestyle='--', alpha=0.5)
self.canvas.draw()
def update_map_points(self, lons, lats):
self.target_points = list(zip(lons, lats))
self.ax.clear()
self.ax.set_xlim(self.lon_min - 0.05, self.lon_max + 0.05)
self.ax.set_ylim(self.lat_min - 0.05, self.lat_max + 0.05)
self.ax.set_aspect(self.map_aspect_ratio, adjustable='box')
self.ax.set_title(f"札幌圏 グリッドマップ (抽出完了: {len(lons)}地点)")
if HAS_CX:
try: cx.add_basemap(self.ax, crs="EPSG:4326", source=cx.providers.OpenStreetMap.Mapnik, alpha=0.8, zoom=13)
except: pass
self.ax.grid(True, linestyle='--', alpha=0.5)
point_size = 5 if len(lons) > 1000 else 40
point_alpha = 0.6 if len(lons) > 1000 else 1.0
self.ax.scatter(lons, lats, c='red', marker='o', s=point_size, alpha=point_alpha, edgecolors='none' if len(lons) > 1000 else 'black')
if self.selected_point:
self.ax.scatter([self.selected_point[0]], [self.selected_point[1]], c='blue', marker='*', s=300, edgecolors='white', zorder=5)
self.canvas.draw()
def on_map_click(self, event):
# 選択モードでない時は無効
if self.toolbar.mode != '': return
if event.xdata is None or event.ydata is None or not self.target_points: return
click_lon, click_lat = event.xdata, event.ydata
closest_point = None
min_dist = float('inf')
for lon, lat in self.target_points:
dist = (lon - click_lon)**2 + (lat - click_lat)**2
if dist < min_dist:
min_dist = dist
closest_point = (lon, lat)
if closest_point:
self.selected_point = closest_point
# DBに登録する一連番号(PointID)や値を抽出
if self.current_df is not None:
target_row = self.current_df[
(self.current_df["Lon"].round(4) == round(closest_point[0], 4)) &
(self.current_df["Lat"].round(4) == round(closest_point[1], 4))
]
if not target_row.empty:
val = target_row.iloc[0]["Value"]
point_id = target_row.iloc[0]["PointID"]
lat_deg = int(closest_point[1]); lat_min = (closest_point[1] - lat_deg) * 60
lon_deg = int(closest_point[0]); lon_min = (closest_point[0] - lon_deg) * 60
# 情報を表示
self.lbl_selected.setText(
f"【選択された地点データ】\n\n"
f"🆔 地点一連番号 (PointID): {point_id}\n"
f"📍 北緯 {lat_deg}°{lat_min:.1f}' / 東経 {lon_deg}°{lon_min:.1f}'\n\n"
f"📊 【{self.current_data_name}】: {val}"
)
self.update_map_points([p[0] for p in self.target_points], [p[1] for p in self.target_points])
# ==========================================
# データ抽出メインロジック & PointID付与
# ==========================================
def run_extraction(self, data_type_key, data_name):
folder_path = self.folder_paths[data_type_key]
if not folder_path or not os.path.exists(folder_path):
QMessageBox.warning(self, "警告", f"{data_name} のフォルダが設定されていません。")
return
if data_type_key == "rain": pattern = "*Prr60lv*.bin"
elif data_type_key == "snowfall": pattern = "*Psflv*.bin"
elif data_type_key == "snowdepth": pattern = "*Psdlv*.bin"
bin_files = glob.glob(os.path.join(folder_path, pattern))
if not bin_files:
QMessageBox.warning(self, "ファイルなし", "対象ファイルが見つかりません。")
return
bin_files.sort()
target_files_to_process = []
# モードに応じたファイル選択
if self.radio_latest.isChecked():
target_files_to_process = [bin_files[-1]] # 最新1件
msg_mode = "最新データを取得"
else:
# 期間指定の場合(ファイル名の日付でフィルタリング)
start_date = self.date_start.date().toString("yyyyMMdd")
end_date = self.date_end.date().addDays(1).toString("yyyyMMdd") # 終了日の翌日0時まで
for f in bin_files:
match = re.search(r'(20\d{6})', os.path.basename(f))
if match:
f_date = match.group(1)
if start_date <= f_date < end_date:
target_files_to_process.append(f)
if not target_files_to_process:
QMessageBox.warning(self, "該当なし", "指定された期間内にファイルが存在しません。")
return
msg_mode = f"期間内 {len(target_files_to_process)}件 を一括取得"
# 抽出処理 (GUIでのマッププレビュー用には、選択されたうちの最新のファイルを使用します)
target_file = target_files_to_process[-1]
temp_csv = os.path.abspath("grib_temp_export.csv")
try:
cmd = [self.wgrib2_path, target_file, "-csv", temp_csv]
subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
with open(temp_csv, 'r', encoding='utf-8', errors='replace') as f:
csv_lines = [line for line in f.readlines() if "," in line]
if not csv_lines:
raise Exception("データが抽出されませんでした。")
csv_data = "".join(csv_lines)
df = pd.read_csv(io.StringIO(csv_data), header=None)
raw_lon = pd.to_numeric(df.iloc[:, -3], errors='coerce')
raw_lat = pd.to_numeric(df.iloc[:, -2], errors='coerce')
df["Value"] = pd.to_numeric(df.iloc[:, -1], errors='coerce')
if raw_lat.mean() > raw_lon.mean():
df["Lon"], df["Lat"] = raw_lat, raw_lon
else:
df["Lon"], df["Lat"] = raw_lon, raw_lat
df = df.dropna(subset=["Lon", "Lat", "Value"])
filtered_df = df[(df["Lat"] >= self.lat_min) & (df["Lat"] <= self.lat_max) &
(df["Lon"] >= self.lon_min) & (df["Lon"] <= self.lon_max)]
if filtered_df.empty:
QMessageBox.warning(self, "警告", "指定範囲内にデータが見つかりませんでした。")
return
# ==========================================
# ★ 絶対的「地点一連番号(PointID)」の付与
# 北から南へ、西から東へ向かって 1, 2, 3... と番号を振る
# ==========================================
# 緯度(Lat)の降順(北から)、経度(Lon)の昇順(西から)で並び替え
unique_points_df = filtered_df[['Lon', 'Lat']].drop_duplicates().sort_values(by=['Lat', 'Lon'], ascending=[False, True]).reset_index(drop=True)
# 行番号(index) + 1 をそのままIDとする
unique_points_df['PointID'] = unique_points_df.index + 1
# 元のデータフレームに紐付け
filtered_df = pd.merge(filtered_df, unique_points_df, on=['Lon', 'Lat'], how='left')
self.current_df = filtered_df
self.current_data_name = data_name
self.selected_point = None
self.set_mode_select() # 抽出完了後は自動的に「選択モード」にする
self.lbl_selected.setText("📍 地図上の赤い点をクリックして\n検証対象を選択してください。")
lons = unique_points_df['Lon'].tolist()
lats = unique_points_df['Lat'].tolist()
self.update_map_points(lons, lats)
QMessageBox.information(self, "抽出完了",
f"【{data_name}】の抽出に成功しました!\n\n"
f"モード: {msg_mode}\n"
f"地点数: {len(lons)} 地点\n\n"
f"※全地点に1から{len(lons)}までの一連番号(PointID)が自動付与されました。\n"
f"(今後のDB挿入・精度評価のベースとして使用されます)")
except Exception as e:
QMessageBox.critical(self, "エラー", f"抽出処理エラー:\n{e}")
if __name__ == "__main__":
app = QApplication(sys.argv)
window = GribMapExtractorApp()
window.show()
sys.exit(app.exec())