未分類

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