未分類

import sys
import os
import sqlite3
import pandas as pd
import numpy as np
from datetime import datetime, timedelta, timezone
import re
import pyodbc
import shutil

from PyQt6.QtWidgets import (QApplication, QMainWindow, QTabWidget, QWidget, 
                             QVBoxLayout, QHBoxLayout, QPushButton, QLabel, 
                             QFileDialog, QTableWidget, QTableWidgetItem, QComboBox, 
                             QDateEdit, QMessageBox, QGroupBox, QHeaderView, QTextEdit, 
                             QLineEdit, QCheckBox, QFormLayout, QScrollArea, QMenu, QGridLayout,
                             QSystemTrayIcon, QStyle)
from PyQt6.QtCore import QDate, Qt, QPoint, QTimer
from PyQt6.QtGui import QFont, QColor, QAction

class FilterableTableWidget(QTableWidget):
    def __init__(self, rows, columns):
        super().__init__(rows, columns)
        self.horizontalHeader().sectionClicked.connect(self.show_filter_menu)
        self.filters = {}
        self.setStyleSheet("QHeaderView::section { background-color: #F6F8FA; padding: 5px; border: 1px solid #D0D7DE; }")

    def keyPressEvent(self, event):
        if event.key() == Qt.Key.Key_C and (event.modifiers() & Qt.KeyboardModifier.ControlModifier):
            self.copy_selection()
        else:
            super().keyPressEvent(event)

    def copy_selection(self):
        selection = self.selectedIndexes()
        if not selection: return
        rows = sorted(list(set(idx.row() for idx in selection)))
        cols = sorted(list(set(idx.column() for idx in selection)))
        copy_text = ""
        for r in rows:
            row_data = []
            for c in cols:
                item = self.item(r, c)
                if item and self.isItemSelected(item):
                    row_data.append(item.text().replace("\n", " "))
                else:
                    row_data.append("")
            copy_text += "\t".join(row_data) + "\n"
        QApplication.clipboard().setText(copy_text)

    def show_filter_menu(self, logical_index):
        menu = QMenu(self)
        values = set()
        for row in range(self.rowCount()):
            item = self.item(row, logical_index)
            if item: values.add(item.text())
        values = sorted(list(values))
        
        action_all = menu.addAction("すべて表示 (フィルタ解除)")
        menu.addSeparator()
        for val in values: menu.addAction(val)
            
        header_pos = self.horizontalHeader().sectionViewportPosition(logical_index)
        global_pos = self.mapToGlobal(self.horizontalHeader().pos() + QPoint(header_pos, self.horizontalHeader().height()))
        
        selected_action = menu.exec(global_pos)
        if not selected_action: return
        
        if selected_action == action_all:
            if logical_index in self.filters: del self.filters[logical_index]
        else:
            self.filters[logical_index] = selected_action.text()
        self.apply_filters()

    def apply_filters(self):
        for row in range(self.rowCount()):
            hidden = False
            for col, filter_val in self.filters.items():
                item = self.item(row, col)
                if item and item.text() != filter_val:
                    hidden = True; break
            self.setRowHidden(row, hidden)


class WeatherVerificationApp(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("気象予報精度検証システム (Ver 16.0 - DB管理/MSバイパス復活/抽出位置補正/総合評価対応)")
        self.resize(1450, 950)
        self.really_quit = False 
        
        self.setStyleSheet("""
            QMainWindow { background-color: #F4F6F9; font-family: 'Segoe UI', 'Meiryo', sans-serif; }
            QTabWidget::pane { border: 1px solid #D0D7DE; background: white; border-radius: 8px; }
            QTabBar::tab { background: #FFD180; color: #555; padding: 10px 20px; margin-right: 2px; border-top-left-radius: 8px; border-top-right-radius: 8px; font-weight: bold; }
            QTabBar::tab:selected { background: #FF8C00; color: white; border-bottom: 2px solid #E65100; }
            QGroupBox { font-weight: bold; color: #E65100; border: 1px solid #D0D7DE; border-radius: 6px; margin-top: 15px; background-color: #FFFFFF; }
            QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 5px; }
            QPushButton { background-color: #FF8C00; color: white; border: none; border-radius: 5px; padding: 8px 16px; font-weight: bold; }
            QPushButton:hover { background-color: #F57C00; }
            QPushButton:pressed { background-color: #E65100; }
            QPushButton:disabled { background-color: #A0A0A0; color: #E0E0E0; }
            QPushButton#ActionBtn { background-color: #28A745; }
            QPushButton#ActionBtn:hover { background-color: #218838; }
            QPushButton#TestBtn { background-color: #007BFF; }
            QPushButton#TestBtn:hover { background-color: #0069D9; }
            QPushButton#AutoBtn { background-color: #D73A49; }
            QPushButton#QuitBtn { background-color: #CB2431; }
            QLineEdit, QComboBox, QDateEdit { border: 1px solid #D0D7DE; border-radius: 4px; padding: 5px; background-color: #FAFBFC; min-height: 25px; }
            QDateEdit { min-width: 130px; font-size: 13px; }
            QComboBox { min-width: 120px; }
            QTableWidget { gridline-color: #E1E4E8; border: 1px solid #D0D7DE; font-size: 13px; }
            QScrollArea { border: none; background-color: transparent; }
        """)

        self.tray_icon = QSystemTrayIcon(self)
        self.tray_icon.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ComputerIcon))
        self.tray_icon.setToolTip("気象検証システム - 継続抽出・計算中")
        
        tray_menu = QMenu()
        show_action = QAction("画面を表示する", self)
        show_action.triggered.connect(self.showNormal)
        quit_action = QAction("完全に終了する", self)
        quit_action.triggered.connect(self.force_quit)
        
        tray_menu.addAction(show_action)
        tray_menu.addSeparator()
        tray_menu.addAction(quit_action)
        self.tray_icon.setContextMenu(tray_menu)
        self.tray_icon.show()
        self.tray_icon.activated.connect(self.on_tray_icon_activated)

        self.db_path = "weather_verification(削除禁止).db"
        self.last_forecast_folder = "未設定"
        
        self.t1_auto_timer = QTimer(self); self.t1_auto_timer.timeout.connect(self.extract_and_aggregate_obs)
        self.t3_auto_timer = QTimer(self); self.t3_auto_timer.timeout.connect(self.auto_load_forecast_model_folder)

        self.init_db()
        self.init_ui()

    def on_tray_icon_activated(self, reason):
        if reason == QSystemTrayIcon.ActivationReason.DoubleClick:
            self.showNormal(); self.activateWindow()

    def closeEvent(self, event):
        if not self.really_quit:
            event.ignore(); self.hide()
            self.tray_icon.showMessage("バックグラウンドで実行中", "自動抽出・計算処理は継続しています。\n完全に終了する場合は右クリックまたは完全終了ボタンから終了してください。", QSystemTrayIcon.MessageIcon.Information, 3000)
        else:
            event.accept()

    def force_quit(self):
        self.really_quit = True
        QApplication.quit()

    def select_db(self):
        path, _ = QFileDialog.getOpenFileName(self, "SQLiteデータベースを選択", "", "SQLite DB (*.db);;すべてのファイル (*)")
        if path:
            self.db_path = path
            self.lbl_db_path.setText(f"📁 現在のDB: {os.path.basename(path)}")
            try:
                self.conn.close(); self.init_db()
                self.update_tab1_station_combo(); self.update_tab5_station_combo()
                QMessageBox.information(self, "完了", f"データベースを切り替えました。\n{path}")
            except Exception as e: QMessageBox.critical(self, "エラー", f"データベースの読み込みに失敗しました:\n{e}")

    def init_db(self):
        self.conn = sqlite3.connect(self.db_path)
        cursor = self.conn.cursor()

        cursor.execute("PRAGMA table_info(OBS)")
        obs_cols = [r[1] for r in cursor.fetchall()]
        if obs_cols and "MsCd" not in obs_cols: cursor.execute("DROP TABLE OBS")
        
        # 既存DBにSnowDepth列がなければ安全に追加
        if obs_cols and "SnowDepth" not in obs_cols:
            try:
                cursor.execute("ALTER TABLE OBS ADD COLUMN SnowDepth REAL")
            except Exception as e:
                pass

        cursor.execute("PRAGMA table_info(FCST)")
        fcst_cols = [r[1] for r in cursor.fetchall()]
        if fcst_cols and "MsCd" not in fcst_cols: cursor.execute("DROP TABLE FCST")

        cursor.execute("PRAGMA table_info(JMA_FCST)")
        jma_cols = [r[1] for r in cursor.fetchall()]
        if jma_cols and "MinVal" not in jma_cols: cursor.execute("DROP TABLE IF EXISTS JMA_FCST")

        cursor.execute('''CREATE TABLE IF NOT EXISTS OBS (ObsDay TEXT, ObsTime TEXT, MsCd TEXT, RainfallP1 REAL, SnowfallP1 REAL, SnowDepth REAL, PRIMARY KEY (ObsDay, ObsTime, MsCd))''')
        cursor.execute('''CREATE TABLE IF NOT EXISTS FCST (IssueDay TEXT, IssueTime TEXT, TargetDay TEXT, TargetTime TEXT, MsCd TEXT, ForecastStep INTEGER, FcstRainfall REAL, FcstSnowfall REAL, PRIMARY KEY (IssueDay, IssueTime, TargetDay, TargetTime, MsCd, ForecastStep))''')
        cursor.execute('''CREATE TABLE IF NOT EXISTS Mapping (ObsCd TEXT PRIMARY KEY, FcstName TEXT)''')
        cursor.execute('''CREATE TABLE IF NOT EXISTS JMA_FCST (TargetStart TEXT, TargetEnd TEXT, Region TEXT, Element TEXT, RawText TEXT, MinVal REAL, MaxVal REAL, PRIMARY KEY (TargetStart, TargetEnd, Region, Element))''')

        cursor.execute("PRAGMA table_info(TargetStations)")
        if "TemplateId" not in [r[1] for r in cursor.fetchall()]:
            cursor.execute("DROP TABLE IF EXISTS TargetStations")
            cursor.execute('''CREATE TABLE TargetStations (TemplateId INTEGER, FcstName TEXT, PRIMARY KEY (TemplateId, FcstName))''')
            targets = [(1, "大通り・円山・山鼻"), (1, "屯田・篠路"), (1, "苗穂・元町・栄町"), (1, "菊水・白石・南郷"), (1, "大谷地・新札幌"), (1, "豊平・平岸・月寒"), (1, "真駒内・澄川・藻岩下"), (1, "琴似・発寒"), (1, "前田・山口"), (1, "北野・清田・里塚"), (1, "花川・花畔・新港"), (1, "恵庭"), (1, "石山・常盤"), (1, "野幌・江別")]
            cursor.executemany("INSERT INTO TargetStations VALUES (?,?)", targets)

        cursor.execute("SELECT COUNT(*) FROM Mapping")
        if cursor.fetchone()[0] == 0:
            defaults = [("MS1", "大通り・円山・山鼻"), ("MS2", "屯田・篠路"), ("MS3", "苗穂・元町・栄町"), ("MS4", "菊水・白石・南郷"), ("MS5", "大谷地・新札幌"), ("MS6", "豊平・平岸・月寒"), ("MS7", "真駒内・澄川・藻岩下"), ("MS8", "琴似・発寒"), ("MS9", "前田・山口"), ("MS25", "北野・清田・里塚"), ("アメダス:札幌", "大通り・円山・山鼻"), ("アメダス:石狩", "花川・花畔・新港"), ("アメダス:恵庭島松", "恵庭"), ("アメダス:小金湯", "石山・常盤"), ("アメダス:江別", "野幌・江別"), ("アメダス:手稲山口", "前田・山口")]
            cursor.executemany("INSERT INTO Mapping VALUES (?,?)", defaults)
        self.conn.commit()

    def create_scroll_tab(self):
        scroll = QScrollArea(); scroll.setWidgetResizable(True); inner_widget = QWidget(); scroll.setWidget(inner_widget); return scroll, inner_widget

    def init_ui(self):
        main_widget = QWidget()
        main_layout = QVBoxLayout(main_widget)
        
        top_header = QHBoxLayout()
        self.lbl_db_path = QLabel(f"📁 現在のDB: {os.path.basename(self.db_path)}")
        self.lbl_db_path.setStyleSheet("font-weight: bold; color: #555;")
        
        btn_db = QPushButton("⚙️ DBを選択 / 変更")
        btn_db.clicked.connect(self.select_db)
        
        btn_quit = QPushButton("❌ 完全に終了する")
        btn_quit.setObjectName("QuitBtn")
        btn_quit.clicked.connect(self.force_quit)
        
        top_header.addWidget(self.lbl_db_path); top_header.addStretch()
        top_header.addWidget(btn_db); top_header.addWidget(btn_quit)
        main_layout.addLayout(top_header)

        self.tabs = QTabWidget()
        main_layout.addWidget(self.tabs)
        self.setCentralWidget(main_widget)

        self.setup_tab1(); self.setup_tab2(); self.setup_tab3(); self.setup_tab4(); self.setup_tab5(); self.setup_tab6(); self.setup_tab7()

    # ==========================================
    # タブ1: MS観測値 (導通点検付き)
    # ==========================================
    def setup_tab1(self):
        scroll, inner_widget = self.create_scroll_tab(); layout = QVBoxLayout(inner_widget); layout.setSpacing(15)
        
        group_db = QGroupBox("SQL Server 接続設定 & データ抽出"); db_layout = QVBoxLayout(); db_layout.setContentsMargins(15, 25, 15, 15)
        
        header_layout = QHBoxLayout()
        self.lbl_t1_auto = QLabel(""); self.lbl_t1_auto.setStyleSheet("color: #D73A49; font-weight: bold; font-size: 14px;")
        header_layout.addStretch(); header_layout.addWidget(self.lbl_t1_auto); db_layout.addLayout(header_layout)

        form_layout = QFormLayout()
        self.cmb_driver = QComboBox(); self.cmb_driver.setEditable(True); self.cmb_driver.addItems(["SQL Server", "ODBC Driver 17 for SQL Server", "ODBC Driver 18 for SQL Server", "SQL Server Native Client 11.0"])
        self.txt_server = QLineEdit("172."); self.txt_db = QLineEdit("S"); self.txt_user = QLineEdit("sa"); self.txt_pwd = QLineEdit("")
        self.txt_pwd.setEchoMode(QLineEdit.EchoMode.Password)
        form_layout.addRow("ODBCドライバー:", self.cmb_driver); form_layout.addRow("サーバー名 (Host):", self.txt_server)
        form_layout.addRow("データベース名:", self.txt_db); form_layout.addRow("ユーザーID:", self.txt_user); form_layout.addRow("パスワード:", self.txt_pwd)
        db_layout.addLayout(form_layout)
        
        filter_layout = QHBoxLayout(); self.t1_date_from = QDateEdit(QDate.currentDate().addDays(-7)); self.t1_date_from.setCalendarPopup(True)
        self.t1_date_to = QDateEdit(QDate.currentDate()); self.t1_date_to.setCalendarPopup(True)
        filter_layout.addWidget(QLabel("抽出期間:")); filter_layout.addWidget(self.t1_date_from); filter_layout.addWidget(QLabel("")); filter_layout.addWidget(self.t1_date_to); filter_layout.addStretch()
        db_layout.addLayout(filter_layout)
        
        action_layout = QHBoxLayout()
        btn_test_conn = QPushButton("🔌 接続テスト (導通点検)"); btn_test_conn.setObjectName("TestBtn"); btn_test_conn.clicked.connect(self.test_sql_connection)
        btn_extract = QPushButton("手動で抽出して保存"); btn_extract.setObjectName("ActionBtn"); btn_extract.clicked.connect(self.extract_and_aggregate_obs)
        self.btn_t1_auto = QPushButton("MS自動取得を開始 (10分毎)"); self.btn_t1_auto.setObjectName("AutoBtn"); self.btn_t1_auto.clicked.connect(self.toggle_t1_auto)
        action_layout.addWidget(btn_test_conn); action_layout.addWidget(btn_extract); action_layout.addWidget(self.btn_t1_auto); action_layout.addStretch()
        db_layout.addLayout(action_layout); group_db.setLayout(db_layout)

        group_view = QGroupBox("ローカルDB (OBS) 検索・閲覧ビューア"); view_layout = QVBoxLayout(); view_layout.setContentsMargins(15, 25, 15, 15)
        search_layout = QHBoxLayout(); self.v_date_from = QDateEdit(QDate.currentDate().addDays(-7)); self.v_date_from.setCalendarPopup(True)
        self.v_date_to = QDateEdit(QDate.currentDate()); self.v_date_to.setCalendarPopup(True)
        self.v_cmb_station = QComboBox(); self.update_tab1_station_combo()
        btn_view_search = QPushButton("OBSテーブルを検索"); btn_view_search.clicked.connect(self.search_obs_table)
        search_layout.addWidget(QLabel("表示期間:")); search_layout.addWidget(self.v_date_from); search_layout.addWidget(QLabel("")); search_layout.addWidget(self.v_date_to)
        search_layout.addWidget(QLabel("地点:")); search_layout.addWidget(self.v_cmb_station); search_layout.addWidget(btn_view_search); search_layout.addStretch()
        view_layout.addLayout(search_layout)

        self.tbl_obs = FilterableTableWidget(0, 6)
        self.tbl_obs.setHorizontalHeaderLabels(["観測日▽", "観測時間▽", "地点コード▽", "降水量(mm)▽", "降雪量(cm)▽", "積雪深(cm)▽"])
        self.tbl_obs.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
        self.tbl_obs.setMinimumHeight(350); view_layout.addWidget(self.tbl_obs); group_view.setLayout(view_layout)

        layout.addWidget(group_db); layout.addWidget(group_view); self.tabs.addTab(scroll, "1. 観測値設定① (MS:DB)"); self.search_obs_table()

    def test_sql_connection(self):
        try:
            conn_str = self.get_sql_connection_string(); conn = pyodbc.connect(conn_str, timeout=5); conn.close()
            QMessageBox.information(self, "導通点検成功", "SQL Server への接続に成功しました!\n通信状態およびログイン認証は正常です。")
        except Exception as e: QMessageBox.critical(self, "導通点検エラー", f"SQL Server への接続に失敗しました。\n設定値またはネットワークを確認してください。\n\n【詳細原因】:\n{e}")

    def toggle_t1_auto(self):
        if self.t1_auto_timer.isActive(): self.t1_auto_timer.stop(); self.lbl_t1_auto.setText(""); self.btn_t1_auto.setText("MS自動取得を開始 (10分毎)")
        else: self.t1_auto_timer.start(600000); self.lbl_t1_auto.setText("🔴 MS自動取得中"); self.btn_t1_auto.setText("MS自動取得を停止"); self.extract_and_aggregate_obs()

    def update_tab1_station_combo(self):
        self.v_cmb_station.clear(); self.v_cmb_station.addItem("すべて")
        cursor = self.conn.cursor(); cursor.execute("SELECT DISTINCT MsCd FROM OBS ORDER BY MsCd"); self.v_cmb_station.addItems([str(r[0]) for r in cursor.fetchall()])

    def search_obs_table(self):
        d_from = self.v_date_from.date().toString("yyyy-MM-dd"); d_to = self.v_date_to.date().toString("yyyy-MM-dd"); st = self.v_cmb_station.currentText()
        cursor = self.conn.cursor()
        if st == "すべて": cursor.execute("SELECT ObsDay, ObsTime, MsCd, RainfallP1, SnowfallP1, SnowDepth FROM OBS WHERE ObsDay BETWEEN ? AND ? ORDER BY ObsDay DESC, ObsTime DESC LIMIT 300", (d_from, d_to))
        else: cursor.execute("SELECT ObsDay, ObsTime, MsCd, RainfallP1, SnowfallP1, SnowDepth FROM OBS WHERE MsCd=? AND ObsDay BETWEEN ? AND ? ORDER BY ObsDay DESC, ObsTime DESC LIMIT 300", (st, d_from, d_to))
        rows = cursor.fetchall(); self.tbl_obs.setRowCount(len(rows)); col_count = self.tbl_obs.columnCount()
        for r_i, r_data in enumerate(rows):
            for c_i, c_data in enumerate(r_data): 
                if c_i < col_count: self.tbl_obs.setItem(r_i, c_i, QTableWidgetItem(str(c_data) if c_data is not None else ""))

    def get_sql_connection_string(self):
        return f"DRIVER={{{self.cmb_driver.currentText()}}};SERVER={self.txt_server.text()};DATABASE={self.txt_db.text()};UID={self.txt_user.text()};PWD={self.txt_pwd.text()}"

    def extract_and_aggregate_obs(self):
        d_from = self.t1_date_from.date().toString("yyyy-MM-dd")
        d_to = self.t1_date_to.date().toString("yyyy-MM-dd")
        try:
            conn_sql = pyodbc.connect(self.get_sql_connection_string())
            
            # ★ 修正: MSを外してクエリを投げエラーを回避する
            query = f"SELECT ObsDay, ObsTime, MsCd, RainfallP1, SnowDepth FROM T_MS_Statistics WHERE MsCd IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 25) AND ObsDay BETWEEN '{d_from}' AND '{d_to}'"
            
            import warnings
            with warnings.catch_warnings():
                warnings.simplefilter("ignore")
                df = pd.read_sql(query, conn_sql)
            
            conn_sql.close()
            
            if df.empty: return QMessageBox.warning(self, "通知", "指定期間のデータが存在しません。")

            # ★ 修正: MSを再度付与する
            df.loc[:, 'MsCd'] = 'MS' + df['MsCd'].astype(str)

            df['datetime_str'] = df['ObsDay'].astype(str) + ' ' + df['ObsTime'].astype(str)
            df['datetime'] = pd.to_datetime(df['datetime_str'], errors='coerce')
            df = df.dropna(subset=['datetime'])
            df['HourlyTime'] = df['datetime'].dt.ceil('1h')

            # 雨量はsum積雪深は毎正時の値(last)として取得
            df_hourly = df.groupby(['MsCd', 'HourlyTime']).agg({
                'RainfallP1': 'sum',
                'SnowDepth': 'last'
            }).reset_index()

            # ★ 修正: 積雪深差から降雪量を導き出す
            df_hourly = df_hourly.sort_values(['MsCd', 'HourlyTime'])
            df_hourly['SnowfallP1'] = df_hourly.groupby('MsCd')['SnowDepth'].diff().clip(lower=0)
            
            df_hourly['ObsDay'] = df_hourly['HourlyTime'].dt.strftime('%Y-%m-%d')
            df_hourly['ObsTime'] = df_hourly['HourlyTime'].dt.strftime('%H:%M')

            cursor = self.conn.cursor()
            inserted = 0
            for _, row in df_hourly.iterrows():
                sd = float(row['SnowDepth']) if not pd.isna(row['SnowDepth']) else None
                sf = float(row['SnowfallP1']) if not pd.isna(row['SnowfallP1']) else 0.0
                cursor.execute("INSERT OR REPLACE INTO OBS (ObsDay, ObsTime, MsCd, RainfallP1, SnowfallP1, SnowDepth) VALUES (?, ?, ?, ?, ?, ?)", 
                               (str(row['ObsDay']), str(row['ObsTime']), str(row['MsCd']), float(row['RainfallP1']), sf, sd))
                inserted += 1
            self.conn.commit()
            
            self.update_tab1_station_combo(); self.search_obs_table()
            if not self.t1_auto_timer.isActive(): QMessageBox.information(self, "完了", f"抽出が完了しました。\n集約後データ数: {inserted} 件")
        except Exception as e:
            if not self.t1_auto_timer.isActive(): QMessageBox.critical(self, "エラー", f"データ処理中にエラーが発生しました:\n{e}")

    # ==========================================
    # タブ2: アメダス抽出
    # ==========================================
    def setup_tab2(self):
        scroll, inner_widget = self.create_scroll_tab(); layout = QVBoxLayout(inner_widget); layout.setSpacing(15)
        group_csv = QGroupBox("気象庁アメダス CSV取り込み"); csv_layout = QHBoxLayout(); csv_layout.setContentsMargins(15, 25, 15, 15)
        btn_csv = QPushButton("JMA CSVを選択してOBSへ取り込み"); btn_csv.setObjectName("ActionBtn"); btn_csv.clicked.connect(self.import_jma_csv)
        csv_layout.addWidget(btn_csv); csv_layout.addStretch(); group_csv.setLayout(csv_layout)
        self.txt_log2 = QTextEdit(); self.txt_log2.setReadOnly(True); self.txt_log2.setText("ここにCSVの読込ログが表示されます..."); self.txt_log2.setMinimumHeight(400)
        layout.addWidget(group_csv); layout.addWidget(self.txt_log2); layout.addStretch(); self.tabs.addTab(scroll, "2. 観測値設定② (アメダスCSV等)")

    def import_jma_csv(self):
        path, _ = QFileDialog.getOpenFileName(self, "気象庁アメダスCSVを選択", "", "CSV Files (*.csv)")
        if not path: return
        try:
            with open(path, 'r', encoding='shift_jis', errors='replace') as f: lines = f.readlines()
            header_idx = -1
            for i, line in enumerate(lines[:15]):
                if "年月日時" in line: header_idx = i; break
            if header_idx == -1 or header_idx == 0: return QMessageBox.critical(self, "エラー", "CSVから「年月日時」の行が見つかりません。")

            stations_raw = lines[header_idx - 1].strip('\n').split(','); elements_raw = lines[header_idx].strip('\n').split(',')
            current_st = ""; stations = []
            for s in stations_raw:
                if s.strip(): current_st = s.strip()
                stations.append(current_st)

            inserted = 0; cursor = self.conn.cursor(); self.txt_log2.append(f"--- 読込開始: {os.path.basename(path)} ---")
            
            for line in lines[header_idx + 1:]:
                cols = line.strip('\n').split(',')
                if not cols[0].startswith("20"): continue 
                try: dt = pd.to_datetime(cols[0].strip()); obs_day = dt.strftime("%Y-%m-%d"); obs_time = dt.strftime("%H:%M")
                except: continue
                
                station_data = {}
                for col_idx in range(1, min(len(cols), len(stations), len(elements_raw))):
                    st = stations[col_idx]; el = elements_raw[col_idx]; val_str = cols[col_idx].strip()
                    if not st or not el: continue
                    if st not in station_data: station_data[st] = {'rain': None, 'snow': None, 'snow_depth': None}
                    try: val = float(val_str) if val_str else 0.0
                    except: val = 0.0
                    if "降水量" in el and station_data[st]['rain'] is None: station_data[st]['rain'] = val
                    elif "降雪" in el and station_data[st]['snow'] is None: station_data[st]['snow'] = val
                    elif "積雪" in el and station_data[st]['snow_depth'] is None: station_data[st]['snow_depth'] = val

                for st, vals in station_data.items():
                    r = vals['rain'] if vals['rain'] is not None else 0.0
                    s = vals['snow'] if vals['snow'] is not None else 0.0
                    sd = vals['snow_depth']
                    cursor.execute("INSERT OR REPLACE INTO OBS (ObsDay, ObsTime, MsCd, RainfallP1, SnowfallP1, SnowDepth) VALUES (?, ?, ?, ?, ?, ?)", (obs_day, obs_time, f"アメダス:{st}", float(r), float(s), sd))
                    inserted += 1
            self.conn.commit(); self.update_tab1_station_combo(); self.txt_log2.append(f"✅ 完了: {inserted} 件の観測データをOBSに保存しました。\n")
            QMessageBox.information(self, "完了", f"CSV取り込みが完了しました。\n保存データ数: {inserted} 件")
        except Exception as e: QMessageBox.critical(self, "エラー", f"CSV読込失敗:\n{e}")

    # ==========================================
    # タブ3: 予報Excel抽出
    # ==========================================
    def setup_tab3(self):
        scroll, inner_widget = self.create_scroll_tab(); layout = QVBoxLayout(inner_widget); layout.setSpacing(15)
        
        top_layout = QHBoxLayout()
        left_panel = QVBoxLayout()
        group_targets = QGroupBox("抽出対象の予報地点設定 (テンプレート機能)"); target_layout = QVBoxLayout(); target_layout.setContentsMargins(15, 20, 15, 15)
        tgt_header_layout = QHBoxLayout(); self.cmb_template = QComboBox(); self.cmb_template.addItems([f"テンプレート{i}" for i in range(1, 6)])
        self.cmb_template.currentIndexChanged.connect(self.load_target_stations)
        tgt_header_layout.addWidget(QLabel("選択中:")); tgt_header_layout.addWidget(self.cmb_template); tgt_header_layout.addStretch()
        self.lbl_t3_auto = QLabel(""); self.lbl_t3_auto.setStyleSheet("color: #D73A49; font-weight: bold; font-size: 14px;")
        tgt_header_layout.addWidget(self.lbl_t3_auto); target_layout.addLayout(tgt_header_layout)

        tbl_layout = QHBoxLayout(); self.tbl_targets = FilterableTableWidget(20, 1)
        self.tbl_targets.setHorizontalHeaderLabels(["抽出対象に含める予報地点名▽"]); self.tbl_targets.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
        self.tbl_targets.setMinimumHeight(200)
        tgt_btn_layout = QVBoxLayout(); tgt_btn_layout.addWidget(QLabel("現在のテンプレートに保存")); btn_save_tgt = QPushButton("保存")
        btn_save_tgt.setObjectName("ActionBtn"); btn_save_tgt.clicked.connect(self.save_target_stations)
        tgt_btn_layout.addWidget(btn_save_tgt); tgt_btn_layout.addStretch()
        tbl_layout.addWidget(self.tbl_targets); tbl_layout.addLayout(tgt_btn_layout); target_layout.addLayout(tbl_layout); group_targets.setLayout(target_layout)
        
        group_op = QGroupBox("最適モデルExcel フォルダ一括抽出"); op_layout = QVBoxLayout(); op_layout.setContentsMargins(15, 20, 15, 15)
        filter_layout = QHBoxLayout(); self.t3_date_from = QDateEdit(QDate.currentDate().addDays(-7)); self.t3_date_from.setCalendarPopup(True)
        self.t3_date_to = QDateEdit(QDate.currentDate()); self.t3_date_to.setCalendarPopup(True)
        filter_layout.addWidget(QLabel("発表日:")); filter_layout.addWidget(self.t3_date_from); filter_layout.addWidget(QLabel("")); filter_layout.addWidget(self.t3_date_to); filter_layout.addStretch()
        action_layout = QHBoxLayout(); btn_folder = QPushButton("手動でフォルダを指定")
        btn_folder.setObjectName("ActionBtn"); btn_folder.clicked.connect(self.manual_load_forecast_model_folder)
        self.btn_t3_auto = QPushButton("予報自動取得を開始"); self.btn_t3_auto.setObjectName("AutoBtn"); self.btn_t3_auto.clicked.connect(self.toggle_t3_auto)
        action_layout.addWidget(btn_folder); action_layout.addWidget(self.btn_t3_auto); action_layout.addStretch(); op_layout.addLayout(filter_layout); op_layout.addLayout(action_layout); group_op.setLayout(op_layout)

        left_panel.addWidget(group_targets); left_panel.addWidget(group_op)
        
        right_panel = QVBoxLayout()
        group_info = QGroupBox("現在の抽出状況と要領"); info_layout = QVBoxLayout(); info_layout.setContentsMargins(15, 20, 15, 15)
        self.lbl_folder_path = QLabel("📁 抽出先フォルダ: 未設定"); self.lbl_folder_path.setStyleSheet("font-weight: bold; color: #0366D6; padding-bottom: 10px;"); self.lbl_folder_path.setWordWrap(True)
        
        info_text = "<b>【抽出元エクセルの対象範囲 (Ver 16.0)】</b><br>以下の表名を自動検知します。<br><br><b>🌧️ 降水量の抽出:</b><br>「(1)3時間降水量」という表から抽出を開始します。<br><br><b>❄️ 降雪量の抽出:</b><br>「天気整合」という文字に反応して抽出モードを切り替え、<b>その1行上のデータから確実に捕捉</b>を開始します。"
        lbl_info = QLabel(info_text); lbl_info.setWordWrap(True); lbl_info.setStyleSheet("background-color: #F6F8FA; border: 1px solid #D0D7DE; padding: 10px; border-radius: 5px;")
        
        info_layout.addWidget(self.lbl_folder_path); info_layout.addWidget(lbl_info); info_layout.addStretch(); group_info.setLayout(info_layout); right_panel.addWidget(group_info)
        top_layout.addLayout(left_panel, 6); top_layout.addLayout(right_panel, 4)
        
        group_view = QGroupBox("データプレビュー (FCST)"); view_layout = QVBoxLayout(); view_layout.setContentsMargins(15, 20, 15, 15)
        self.tbl_fcst = FilterableTableWidget(0, 7)
        self.tbl_fcst.setHorizontalHeaderLabels(["発表日▽", "発表時間▽", "対象日▽", "対象時間▽", "予報地点名▽", "予報降水量▽", "予報降雪量▽"])
        self.tbl_fcst.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch); self.tbl_fcst.setMinimumHeight(250); view_layout.addWidget(self.tbl_fcst); group_view.setLayout(view_layout)

        layout.addLayout(top_layout); layout.addWidget(group_view); self.tabs.addTab(scroll, "3. 予報値抽出設定"); self.load_target_stations(); self.load_preview_data("FCST", self.tbl_fcst)

    def load_target_stations(self):
        tid = self.cmb_template.currentIndex() + 1
        cursor = self.conn.cursor(); cursor.execute("SELECT FcstName FROM TargetStations WHERE TemplateId = ?", (tid,))
        rows = cursor.fetchall(); self.tbl_targets.clearContents()
        for i in range(20): self.tbl_targets.setItem(i, 0, QTableWidgetItem(str(rows[i][0]) if i < len(rows) else ""))

    def save_target_stations(self):
        tid = self.cmb_template.currentIndex() + 1
        cursor = self.conn.cursor(); cursor.execute("DELETE FROM TargetStations WHERE TemplateId = ?", (tid,))
        for i in range(self.tbl_targets.rowCount()):
            item = self.tbl_targets.item(i, 0)
            if item and item.text().strip(): cursor.execute("INSERT INTO TargetStations VALUES (?, ?)", (tid, item.text().strip()))
        self.conn.commit(); QMessageBox.information(self, "完了", f"テンプレート{tid} を保存しました。")

    def toggle_t3_auto(self):
        if self.t3_auto_timer.isActive(): self.t3_auto_timer.stop(); self.lbl_t3_auto.setText(""); self.btn_t3_auto.setText("予報自動取得を開始")
        else:
            if self.last_forecast_folder == "未設定": return QMessageBox.warning(self, "警告", "まずは「手バックアップ先を設定」を実行し、フォルダを記憶させてください。")
            self.t3_auto_timer.start(600000); self.lbl_t3_auto.setText("🔴 予報自動取得中"); self.btn_t3_auto.setText("予報自動取得を停止"); self.auto_load_forecast_model_folder()

    def manual_load_forecast_model_folder(self):
        folder = QFileDialog.getExistingDirectory(self, "予報Excelフォルダを選択")
        if folder: self.last_forecast_folder = folder; self.lbl_folder_path.setText(f"📁 抽出先フォルダ:\n{folder}"); self.process_forecast_folder(folder, silent=False)

    def auto_load_forecast_model_folder(self):
        if self.last_forecast_folder != "未設定": self.process_forecast_folder(self.last_forecast_folder, silent=True)

    def process_forecast_folder(self, folder_path, silent=False):
        d_from = self.t3_date_from.date().toString("yyyy-MM-dd"); d_to = self.t3_date_to.date().toString("yyyy-MM-dd")
        period_3h_hours = 36; cols_3h = period_3h_hours // 3
        
        tid = self.cmb_template.currentIndex() + 1; cursor = self.conn.cursor()
        cursor.execute("SELECT FcstName FROM TargetStations WHERE TemplateId = ?", (tid,))
        valid_targets = [str(r[0]) for r in cursor.fetchall()]
        if not valid_targets:
            if not silent: QMessageBox.warning(self, "警告", "現在のテンプレートの抽出対象リストが空です。")
            return

        total_inserted = 0
        for filename in os.listdir(folder_path):
            if not filename.endswith(".xlsx") or filename.startswith("~"): continue
            file_path = os.path.join(folder_path, filename)
            try:
                d_match = re.search(r'(20\d{6})', filename)
                issue_day = f"{d_match.group(1)[:4]}-{d_match.group(1)[4:6]}-{d_match.group(1)[6:8]}" if d_match else datetime.now(timezone.utc).strftime("%Y-%m-%d")
                if not (d_from <= issue_day <= d_to): continue

                t_match = re.search(r'((\d{2})時用)', filename)
                issue_time = f"{t_match.group(1)}:00" if t_match else "00:00"
                issue_dt = datetime.strptime(f"{issue_day} {issue_time}", "%Y-%m-%d %H:%M")

                excel_file = pd.ExcelFile(file_path)
                target_sheet = "①最適モデル" if "①最適モデル" in excel_file.sheet_names else excel_file.sheet_names[0]
                df = pd.read_excel(file_path, header=None, sheet_name=target_sheet)

                current_mode = None
                seen_rain_stations, seen_snow_stations = set(), set()
                
                for row_idx in range(len(df)):
                    cat_str = str(df.iloc[row_idx, 0]).strip()
                    is_weather_align = "天気整合" in cat_str
                    
                    if is_weather_align:
                        current_mode = "snow"
                    elif "3時間降水量" in cat_str and not any(x in cat_str for x in ["最終", "(6)", "(6)"]): 
                        current_mode = "rain"
                    elif any(x in cat_str for x in ["風向", "気温", "湿度", "気圧", "最終", "(5)", "(5)", "(6)", "(6)", "(7)", "(7)"]): 
                        if not is_weather_align: current_mode = None 
                        
                    if not current_mode: continue

                    # ★ 修正: 天気整合が来たらその1行上降雪量がある行)」から処理を実行する
                    rows_to_process = [row_idx]
                    if is_weather_align and current_mode == "snow" and row_idx > 0:
                        rows_to_process = [row_idx - 1, row_idx]

                    for r_idx in rows_to_process:
                        raw_station = str(df.iloc[r_idx, 1]).strip()
                        if not raw_station or raw_station == "nan": continue
                        
                        clean_name = re.sub(r'^.*?[))]','', raw_station).strip()
                        if clean_name not in valid_targets: continue

                        if current_mode == "rain":
                            if clean_name in seen_rain_stations: continue
                            seen_rain_stations.add(clean_name)
                        elif current_mode == "snow":
                            if clean_name in seen_snow_stations: continue
                            seen_snow_stations.add(clean_name)

                        for i in range(cols_3h):
                            col_idx = 2 + i
                            if col_idx >= len(df.columns): break
                            try: val = float(df.iloc[r_idx, col_idx]) if not pd.isna(df.iloc[r_idx, col_idx]) else 0.0
                            except ValueError: val = 0.0
                            
                            target_dt = issue_dt + timedelta(hours=i*3)
                            t_day, t_time = target_dt.strftime("%Y-%m-%d"), target_dt.strftime("%H:%M")

                            if current_mode == "rain": cursor.execute("INSERT OR REPLACE INTO FCST (IssueDay, IssueTime, TargetDay, TargetTime, MsCd, ForecastStep, FcstRainfall, FcstSnowfall) VALUES (?,?,?,?,?,?,?, COALESCE((SELECT FcstSnowfall FROM FCST WHERE TargetDay=? AND TargetTime=? AND MsCd=? AND IssueDay=? AND IssueTime=? AND ForecastStep=?), 0.0))", (issue_day, issue_time, t_day, t_time, clean_name, 3, val, t_day, t_time, clean_name, issue_day, issue_time, 3))
                            else: cursor.execute("INSERT OR REPLACE INTO FCST (IssueDay, IssueTime, TargetDay, TargetTime, MsCd, ForecastStep, FcstSnowfall, FcstRainfall) VALUES (?,?,?,?,?,?,?, COALESCE((SELECT FcstRainfall FROM FCST WHERE TargetDay=? AND TargetTime=? AND MsCd=? AND IssueDay=? AND IssueTime=? AND ForecastStep=?), 0.0))", (issue_day, issue_time, t_day, t_time, clean_name, 3, val, t_day, t_time, clean_name, issue_day, issue_time, 3))
                            total_inserted += 1

                        base_12h_col = 2 + cols_3h
                        for j in range(3):
                            col_idx = base_12h_col + j
                            if col_idx >= len(df.columns): break
                            if issue_time in ["09:00", "12:00"] and j == 0: continue 
                                
                            try: val = float(df.iloc[r_idx, col_idx]) if not pd.isna(df.iloc[r_idx, col_idx]) else 0.0
                            except ValueError: val = 0.0

                            if issue_time == "18:00": target_dt = issue_dt + timedelta(hours=12*(j+1))
                            else: target_dt = datetime.strptime(f"{issue_day} 18:00", "%Y-%m-%d %H:%M") + timedelta(hours=12*j)

                            t_day, t_time = target_dt.strftime("%Y-%m-%d"), target_dt.strftime("%H:%M")

                            if current_mode == "rain": cursor.execute("INSERT OR REPLACE INTO FCST (IssueDay, IssueTime, TargetDay, TargetTime, MsCd, ForecastStep, FcstRainfall, FcstSnowfall) VALUES (?,?,?,?,?,?,?, COALESCE((SELECT FcstSnowfall FROM FCST WHERE TargetDay=? AND TargetTime=? AND MsCd=? AND IssueDay=? AND IssueTime=? AND ForecastStep=?), 0.0))", (issue_day, issue_time, t_day, t_time, clean_name, 12, val, t_day, t_time, clean_name, issue_day, issue_time, 12))
                            else: cursor.execute("INSERT OR REPLACE INTO FCST (IssueDay, IssueTime, TargetDay, TargetTime, MsCd, ForecastStep, FcstSnowfall, FcstRainfall) VALUES (?,?,?,?,?,?,?, COALESCE((SELECT FcstRainfall FROM FCST WHERE TargetDay=? AND TargetTime=? AND MsCd=? AND IssueDay=? AND IssueTime=? AND ForecastStep=?), 0.0))", (issue_day, issue_time, t_day, t_time, clean_name, 12, val, t_day, t_time, clean_name, issue_day, issue_time, 12))
                            total_inserted += 1
            except Exception as e: print(f"ファイル {filename} の処理エラー: {e}")

        self.conn.commit(); self.load_preview_data("FCST", self.tbl_fcst)
        if not silent: QMessageBox.information(self, "完了", f"抽出が完了しました。\n処理要素数: {total_inserted} 件")

    def load_preview_data(self, table_name, widget):
        cursor = self.conn.cursor()
        if table_name == "FCST": cursor.execute("SELECT IssueDay, IssueTime, TargetDay, TargetTime, MsCd, FcstRainfall, FcstSnowfall FROM FCST LIMIT 50")
        else: cursor.execute(f"SELECT * FROM {table_name} LIMIT 50")
        rows = cursor.fetchall(); widget.setRowCount(len(rows)); col_count = widget.columnCount()
        for r_i, r_data in enumerate(rows):
            for c_i, c_data in enumerate(r_data): 
                if c_i < col_count: widget.setItem(r_i, c_i, QTableWidgetItem(str(c_data) if c_data is not None else ""))

    # ==========================================
    # タブ4: 気象庁予報 直打ち入力フォーマット
    # ==========================================
    def setup_tab4(self):
        scroll, inner_widget = self.create_scroll_tab(); layout = QVBoxLayout(inner_widget); layout.setSpacing(15)
        
        lbl_desc = QLabel("<b>【気象庁予報 (降雪量) 手入力フォーマット】</b><br>対象日(基準日)を選択すると、自動で北部・中部・南部の夜間(18-06)と日中(06-18)の入力枠が生成されます。<br>「10-20」「00」「00-03」のように範囲をそのまま入力し、保存を押してください。")
        lbl_desc.setStyleSheet("color: #0366D6; font-size: 14px; padding: 10px; background-color: #F1F8FF; border-radius: 5px;")
        layout.addWidget(lbl_desc)

        group_input = QGroupBox("対象日の指定 & 降雪量入力"); input_layout = QVBoxLayout(); input_layout.setContentsMargins(15, 20, 15, 15)
        
        date_layout = QHBoxLayout()
        self.jma_base_date = QDateEdit(QDate.currentDate()); self.jma_base_date.setCalendarPopup(True)
        self.jma_base_date.dateChanged.connect(self.update_jma_table_dates)
        date_layout.addWidget(QLabel("【対象日(夕方発表の直近の夜)】:")); date_layout.addWidget(self.jma_base_date); date_layout.addStretch()
        input_layout.addLayout(date_layout)

        self.tbl_jma = QTableWidget(6, 5)
        self.tbl_jma.setHorizontalHeaderLabels(["地域", "対象期間", "開始日時", "終了日時", "降雪量予報(cm) 手入力"])
        self.tbl_jma.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
        self.tbl_jma.setAlternatingRowColors(True)
        
        regions = ["石狩地方北部", "石狩地方中部", "石狩地方南部"]
        periods = ["18:00〜06:00 (夜間)", "06:00〜18:00 (日中)"]
        for i in range(3):
            for j in range(2):
                row = i * 2 + j
                item_reg = QTableWidgetItem(regions[i]); item_reg.setFlags(item_reg.flags() & ~Qt.ItemFlag.ItemIsEditable); self.tbl_jma.setItem(row, 0, item_reg)
                item_per = QTableWidgetItem(periods[j]); item_per.setFlags(item_per.flags() & ~Qt.ItemFlag.ItemIsEditable); self.tbl_jma.setItem(row, 1, item_per)
                item_start = QTableWidgetItem(""); item_start.setFlags(item_start.flags() & ~Qt.ItemFlag.ItemIsEditable); self.tbl_jma.setItem(row, 2, item_start)
                item_end = QTableWidgetItem(""); item_end.setFlags(item_end.flags() & ~Qt.ItemFlag.ItemIsEditable); self.tbl_jma.setItem(row, 3, item_end)
                self.tbl_jma.setItem(row, 4, QTableWidgetItem(""))
                
        self.update_jma_table_dates()
        input_layout.addWidget(self.tbl_jma)

        btn_save_jma = QPushButton("JMA_FCST テーブルに保存する"); btn_save_jma.setObjectName("ActionBtn"); btn_save_jma.clicked.connect(self.save_jma_manual_data)
        input_layout.addWidget(btn_save_jma); group_input.setLayout(input_layout); layout.addWidget(group_input)

        group_preview = QGroupBox("保存済みデータ (JMA_FCST)"); prev_layout = QVBoxLayout(); prev_layout.setContentsMargins(15, 20, 15, 15)
        self.tbl_jma_prev = FilterableTableWidget(0, 6)
        self.tbl_jma_prev.setHorizontalHeaderLabels(["開始日時▽", "終了日時▽", "地域▽", "生テキスト(範囲)▽", "内部最小値▽", "内部最大値▽"])
        self.tbl_jma_prev.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
        prev_layout.addWidget(self.tbl_jma_prev); group_preview.setLayout(prev_layout); layout.addWidget(group_preview); layout.addStretch()
        
        self.tabs.addTab(scroll, "4. 気象庁予報(手入力)")
        self.refresh_jma_preview()

    def update_jma_table_dates(self):
        base_dt = self.jma_base_date.date().toPyDate()
        for i in range(3):
            r0 = i * 2; s0 = datetime(base_dt.year, base_dt.month, base_dt.day, 18, 0); e0 = s0 + timedelta(hours=12)
            self.tbl_jma.item(r0, 2).setText(s0.strftime("%Y-%m-%d %H:%M")); self.tbl_jma.item(r0, 3).setText(e0.strftime("%Y-%m-%d %H:%M"))
            r1 = i * 2 + 1; s1 = e0; e1 = s1 + timedelta(hours=12)
            self.tbl_jma.item(r1, 2).setText(s1.strftime("%Y-%m-%d %H:%M")); self.tbl_jma.item(r1, 3).setText(e1.strftime("%Y-%m-%d %H:%M"))

    def save_jma_manual_data(self):
        cursor = self.conn.cursor()
        saved = 0
        for row in range(self.tbl_jma.rowCount()):
            region = self.tbl_jma.item(row, 0).text()
            start_str = self.tbl_jma.item(row, 2).text()
            end_str = self.tbl_jma.item(row, 3).text()
            val_str = self.tbl_jma.item(row, 4).text().strip()
            
            if not val_str: continue 
            
            nums = re.findall(r'\d+', val_str)
            if not nums: continue
            
            min_v = float(nums[0]); max_v = float(nums[-1]) 
            
            cursor.execute("INSERT OR REPLACE INTO JMA_FCST (TargetStart, TargetEnd, Region, Element, RawText, MinVal, MaxVal) VALUES (?, ?, ?, ?, ?, ?, ?)", 
                           (start_str, end_str, region, "降雪量", val_str, min_v, max_v))
            saved += 1
            
        self.conn.commit()
        self.refresh_jma_preview()
        QMessageBox.information(self, "保存完了", f"{saved}件の気象庁手入力データを保存しました。")

    def refresh_jma_preview(self):
        cursor = self.conn.cursor()
        cursor.execute("SELECT TargetStart, TargetEnd, Region, RawText, MinVal, MaxVal FROM JMA_FCST ORDER BY TargetStart DESC LIMIT 50")
        rows = cursor.fetchall(); self.tbl_jma_prev.setRowCount(len(rows))
        for r_i, r_data in enumerate(rows):
            for c_i, c_data in enumerate(r_data): self.tbl_jma_prev.setItem(r_i, c_i, QTableWidgetItem(str(c_data) if c_data is not None else ""))

    # ==========================================
    # タブ5: 精度評価マッピング (新指標対応JMA特殊計算対応)
    # ==========================================
    def setup_tab5(self):
        scroll, inner_widget = self.create_scroll_tab(); layout = QVBoxLayout(inner_widget); layout.setSpacing(15)

        group_mapping = QGroupBox("地点マッピング設定 (観測地点 ⇔ 予報地点/PDF推定地域)"); mapping_layout = QHBoxLayout(); mapping_layout.setContentsMargins(15, 20, 15, 10)
        self.tbl_mapping = FilterableTableWidget(16, 3); self.tbl_mapping.setHorizontalHeaderLabels(["観測地点 (OBS)▽", "連携▽", "予報地点名/地域 (FCST/JMA)▽"])
        self.tbl_mapping.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents); self.tbl_mapping.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
        self.tbl_mapping.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch); self.tbl_mapping.setMinimumHeight(200); self.load_mapping_data()
        
        map_btn_layout = QVBoxLayout(); lbl_help = QLabel("※ 気象庁予報の検証時は、ここの「予報地点名」を\nタブ4の地域(石狩地方北部 など)に\n書き換えてください。")
        btn_save_map = QPushButton("保存して更新"); btn_save_map.setObjectName("ActionBtn"); btn_save_map.clicked.connect(self.save_mapping_data)
        map_btn_layout.addWidget(lbl_help); map_btn_layout.addWidget(btn_save_map); map_btn_layout.addStretch()
        mapping_layout.addWidget(self.tbl_mapping); mapping_layout.addLayout(map_btn_layout); group_mapping.setLayout(mapping_layout)

        group_filter = QGroupBox("検証条件の指定")
        filter_layout_main = QVBoxLayout(); filter_layout_main.setContentsMargins(15, 15, 15, 15)
        
        row_src = QHBoxLayout()
        self.cmb_source = QComboBox(); self.cmb_source.addItems(["最適モデルExcel (FCST)", "気象庁手入力フォーマット (JMA_FCST)"])
        self.cmb_source.setStyleSheet("background-color: #FFF3E0; font-weight:bold; border: 2px solid #FF8C00; font-size: 14px;")
        row_src.addWidget(QLabel("【評価対象データ】:")); row_src.addWidget(self.cmb_source); row_src.addStretch()
        filter_layout_main.addLayout(row_src); filter_layout_main.addSpacing(10)

        row1 = QHBoxLayout()
        self.issue_from = QDateEdit(QDate.currentDate().addDays(-7)); self.issue_from.setCalendarPopup(True)
        self.issue_to = QDateEdit(QDate.currentDate()); self.issue_to.setCalendarPopup(True)
        self.target_from = QDateEdit(QDate.currentDate().addDays(-7)); self.target_from.setCalendarPopup(True)
        self.target_to = QDateEdit(QDate.currentDate().addDays(2)); self.target_to.setCalendarPopup(True)
        row1.addWidget(QLabel("【発表日】(Excel用):")); row1.addWidget(self.issue_from); row1.addWidget(QLabel("")); row1.addWidget(self.issue_to)
        row1.addSpacing(20)
        row1.addWidget(QLabel("【対象日】:")); row1.addWidget(self.target_from); row1.addWidget(QLabel("")); row1.addWidget(self.target_to); row1.addStretch()

        row2 = QHBoxLayout()
        self.cmb_station = QComboBox(); self.update_tab5_station_combo()
        self.cmb_element = QComboBox(); self.cmb_element.addItems(["降水量", "降雪量"])
        self.cmb_element.currentTextChanged.connect(self.update_custom_labels_visibility)
        self.cmb_step = QComboBox(); self.cmb_step.addItems(["3時間ステップ", "12時間ステップ", "1時間ステップ"]); self.cmb_step.setCurrentText("3時間ステップ") 
        self.cmb_leadtime = QComboBox(); self.cmb_leadtime.addItems(["期間制限なし", "12時間以内", "24時間以内", "36時間以内"])
        
        self.btn_search = QPushButton("精度評価を実行"); self.btn_search.setObjectName("ActionBtn"); self.btn_search.clicked.connect(self.calculate_accuracy)
        row2.addWidget(QLabel("観測地点:")); row2.addWidget(self.cmb_station); row2.addWidget(QLabel("要素:")); row2.addWidget(self.cmb_element)
        row2.addWidget(QLabel("時間(Excel用):")); row2.addWidget(self.cmb_step)
        row2.addWidget(QLabel("発出(Excel用):")); row2.addWidget(self.cmb_leadtime); row2.addStretch(); row2.addWidget(self.btn_search)

        filter_layout_main.addLayout(row1); filter_layout_main.addSpacing(10); filter_layout_main.addLayout(row2)
        group_filter.setLayout(filter_layout_main)

        eval_layout = QHBoxLayout()
        group_quant = QGroupBox("量的評価指標"); quant_layout = QVBoxLayout(); quant_layout.setContentsMargins(15, 20, 15, 15)
        self.lbl_rmse = QLabel("RMSE: --"); self.lbl_me = QLabel("ME (差): --"); self.lbl_mae = QLabel("MAE: --")
        quant_layout.addWidget(self.lbl_rmse); quant_layout.addWidget(self.lbl_me); quant_layout.addWidget(self.lbl_mae); group_quant.setLayout(quant_layout)
        
        group_cat = QGroupBox("カテゴリ全体評価 (>0.0 / 気象庁は範囲的中)"); cat_layout = QGridLayout(); cat_layout.setContentsMargins(15, 20, 15, 15)
        self.lbl_hit = QLabel("的中率: --"); self.lbl_pod = QLabel("捕捉率: --"); self.lbl_ts = QLabel("スレッドスコア: --")
        self.lbl_far = QLabel("空振り率: --"); self.lbl_miss = QLabel("見逃し率: --")
        cat_layout.addWidget(self.lbl_hit, 0, 0); cat_layout.addWidget(self.lbl_pod, 0, 1); cat_layout.addWidget(self.lbl_ts, 0, 2)
        cat_layout.addWidget(self.lbl_far, 1, 0); cat_layout.addWidget(self.lbl_miss, 1, 1); group_cat.setLayout(cat_layout)
        
        group_custom = QGroupBox("プロ仕様 独自評価指標"); custom_layout = QGridLayout(); custom_layout.setContentsMargins(15, 10, 15, 10)
        self.custom_labels = []
        for i in range(4): 
            l_title = QLabel(f"【閾値{i+1}】: --"); l_title.setStyleSheet("font-weight: bold; color: #D73A49;")
            l_stats = QLabel("全数: -- / 的中(A): -- / 発現: -- / 予報: --")
            l_rates = QLabel("的: -- / 捕: -- / 空: -- / TS: --")
            self.custom_labels.append({"title": l_title, "stats": l_stats, "rates": l_rates})
            custom_layout.addWidget(l_title, i, 0); custom_layout.addWidget(l_stats, i, 1); custom_layout.addWidget(l_rates, i, 2)
        group_custom.setLayout(custom_layout)

        eval_layout.addWidget(group_quant, 1); eval_layout.addWidget(group_cat, 1); eval_layout.addWidget(group_custom, 2) 

        self.tbl_detail = FilterableTableWidget(0, 7)
        self.tbl_detail.setHorizontalHeaderLabels(["観測地点▽", "発表日時(JMAは非表示)▽", "対象日時▽", "予報値▽", "実況値(加算済)▽", "差分▽", "判定▽"])
        self.tbl_detail.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch); self.tbl_detail.setMinimumHeight(250)

        layout.addWidget(group_mapping); layout.addWidget(group_filter); layout.addLayout(eval_layout); layout.addWidget(self.tbl_detail); layout.addStretch()
        self.tabs.addTab(scroll, "5. 精度評価・マッピング")
        self.update_custom_labels_visibility()

    def update_custom_labels_visibility(self):
        is_snow = self.cmb_element.currentText() == "降雪量"
        self.custom_labels[3]["title"].setVisible(is_snow)
        self.custom_labels[3]["stats"].setVisible(is_snow)
        self.custom_labels[3]["rates"].setVisible(is_snow)

    def load_mapping_data(self):
        cursor = self.conn.cursor(); cursor.execute("SELECT ObsCd, FcstName FROM Mapping"); rows = cursor.fetchall()
        for i, row in enumerate(rows):
            item_obs = QTableWidgetItem(str(row[0])); item_obs.setFlags(item_obs.flags() & ~Qt.ItemFlag.ItemIsEditable); item_obs.setBackground(QColor("#F6F8FA")); self.tbl_mapping.setItem(i, 0, item_obs)
            item_arrow = QTableWidgetItem(""); item_arrow.setFlags(item_arrow.flags() & ~Qt.ItemFlag.ItemIsEditable); self.tbl_mapping.setItem(i, 1, item_arrow)
            self.tbl_mapping.setItem(i, 2, QTableWidgetItem(str(row[1])))

    def save_mapping_data(self):
        cursor = self.conn.cursor()
        for i in range(self.tbl_mapping.rowCount()):
            if self.tbl_mapping.item(i, 0): cursor.execute("INSERT OR REPLACE INTO Mapping (ObsCd, FcstName) VALUES (?, ?)", (self.tbl_mapping.item(i, 0).text(), self.tbl_mapping.item(i, 2).text().strip()))
        self.conn.commit(); self.update_tab5_station_combo(); QMessageBox.information(self, "完了", "マッピング設定を保存しました。")

    def update_tab5_station_combo(self):
        self.cmb_station.clear(); self.cmb_station.addItem("全地点総合評価")
        cursor = self.conn.cursor(); cursor.execute("SELECT ObsCd FROM Mapping"); self.cmb_station.addItems([str(r[0]) for r in cursor.fetchall()])

    def calculate_accuracy(self):
        self.btn_search.setText("⏳ 計算中...お待ちください"); self.btn_search.setEnabled(False); QApplication.processEvents()

        try:
            obs_cd_sel = self.cmb_station.currentText()
            is_rain = self.cmb_element.currentText() == "降水量"
            src_jma = "JMA_FCST" in self.cmb_source.currentText()
            d_issue_from = self.issue_from.date().toString("yyyy-MM-dd"); d_issue_to = self.issue_to.date().toString("yyyy-MM-dd")
            d_tgt_from = self.target_from.date().toString("yyyy-MM-dd"); d_tgt_to = self.target_to.date().toString("yyyy-MM-dd")
            step_val = 3 if "3時間" in self.cmb_step.currentText() else (12 if "12時間" in self.cmb_step.currentText() else 1)
            lead_time_txt = self.cmb_leadtime.currentText()

            cursor = self.conn.cursor()
            if obs_cd_sel == "全地点総合評価":
                cursor.execute("SELECT ObsCd, FcstName FROM Mapping")
                mappings = cursor.fetchall()
            else:
                cursor.execute("SELECT FcstName FROM Mapping WHERE ObsCd = ?", (obs_cd_sel,))
                row = cursor.fetchone()
                if not row: 
                    self.btn_search.setText("精度評価を実行"); self.btn_search.setEnabled(True)
                    return QMessageBox.warning(self, "通知", "マッピングが見つかりません。")
                mappings = [(obs_cd_sel, row[0])]

            all_dfs = []
            for obs_cd, fcst_name in mappings:
                if src_jma:
                    query = "SELECT ? AS Station, '-' AS IssueDT, TargetStart || '~' || TargetEnd AS TargetDT, RawText AS FcstText, MinVal, MaxVal, TargetStart, TargetEnd FROM JMA_FCST WHERE Region = ? AND Element = ? AND TargetStart >= ? AND TargetStart <= ?"
                    df_fcst = pd.read_sql_query(query, self.conn, params=(obs_cd, fcst_name, self.cmb_element.currentText(), d_tgt_from + ' 00:00', d_tgt_to + ' 23:59'))
                    
                    obs_query = f"SELECT ObsDay || ' ' || ObsTime AS ObsDT, RainfallP1, SnowfallP1, SnowDepth FROM OBS WHERE MsCd = ?"
                    df_obs = pd.read_sql_query(obs_query, self.conn, params=(obs_cd,))
                    if df_obs.empty or df_fcst.empty: continue
                    df_obs['ObsDT'] = pd.to_datetime(df_obs['ObsDT'])
                    
                    merged_rows = []
                    for _, row in df_fcst.iterrows():
                        ts_start = pd.to_datetime(row['TargetStart']); ts_end = pd.to_datetime(row['TargetEnd'])
                        mask = (df_obs['ObsDT'] > ts_start) & (df_obs['ObsDT'] <= ts_end)
                        
                        if is_rain:
                            obs_val = df_obs.loc[mask, 'RainfallP1'].sum()
                        else:
                            if df_obs['SnowDepth'].notna().any():
                                sd_start = df_obs.loc[df_obs['ObsDT'] == ts_start, 'SnowDepth'].last_valid_index()
                                sd_end = df_obs.loc[df_obs['ObsDT'] == ts_end, 'SnowDepth'].last_valid_index()
                                start_val = df_obs.loc[sd_start, 'SnowDepth'] if sd_start is not None else 0
                                end_val = df_obs.loc[sd_end, 'SnowDepth'] if sd_end is not None else 0
                                obs_val = max(end_val - start_val, 0)
                            else:
                                obs_val = df_obs.loc[mask, 'SnowfallP1'].sum()

                        is_hit = row['MinVal'] <= obs_val <= row['MaxVal']
                        diff = 0.0
                        if obs_val < row['MinVal']: diff = obs_val - row['MinVal']
                        elif obs_val > row['MaxVal']: diff = obs_val - row['MaxVal']
                        
                        merged_rows.append({
                            'Station': row['Station'], 'IssueDT': row['IssueDT'], 'TargetDT': row['TargetDT'],
                            'Fcst': row['MaxVal'], 'FcstText': row['FcstText'], 'Obs': obs_val, 'Diff': diff,
                            'MinVal': row['MinVal'], 'MaxVal': row['MaxVal'], 'IsRangeHit': is_hit
                        })
                    if merged_rows: all_dfs.append(pd.DataFrame(merged_rows))
                    
                else:
                    fcst_query = f"SELECT '{obs_cd}' AS Station, IssueDay || ' ' || IssueTime AS IssueDT, TargetDay || ' ' || TargetTime AS TargetDT, {'FcstRainfall' if is_rain else 'FcstSnowfall'} AS Fcst FROM FCST WHERE MsCd = ? AND IssueDay BETWEEN ? AND ? AND TargetDay BETWEEN ? AND ? AND ForecastStep = ?"
                    df_fcst = pd.read_sql_query(fcst_query, self.conn, params=(fcst_name, d_issue_from, d_issue_to, d_tgt_from, d_tgt_to, step_val))
                    
                    obs_start_dt = datetime.strptime(d_tgt_from, "%Y-%m-%d") - timedelta(days=2)
                    obs_query = f"SELECT ObsDay || ' ' || ObsTime AS ObsDT, RainfallP1, SnowfallP1, SnowDepth FROM OBS WHERE MsCd = ? AND ObsDay >= ?"
                    df_obs = pd.read_sql_query(obs_query, self.conn, params=(obs_cd, obs_start_dt.strftime("%Y-%m-%d")))
                    if df_fcst.empty or df_obs.empty: continue

                    df_obs['ObsDT'] = pd.to_datetime(df_obs['ObsDT']); df_obs.set_index('ObsDT', inplace=True)
                    
                    df_obs_agg = df_obs.resample('1h').agg({'RainfallP1': 'sum', 'SnowfallP1': 'sum', 'SnowDepth': 'last'})
                    df_obs_agg['SnowDepth'] = df_obs_agg['SnowDepth'].ffill()

                    if is_rain:
                        if step_val > 1: df_obs_agg['Obs'] = df_obs_agg['RainfallP1'].rolling(window=step_val, min_periods=1).sum()
                        else: df_obs_agg['Obs'] = df_obs_agg['RainfallP1']
                    else:
                        if df_obs_agg['SnowDepth'].notna().any():
                            df_obs_agg['Obs'] = df_obs_agg['SnowDepth'].diff(periods=step_val).clip(lower=0)
                        else:
                            df_obs_agg['Obs'] = df_obs_agg['SnowfallP1'].rolling(window=step_val, min_periods=1).sum() if step_val > 1 else df_obs_agg['SnowfallP1']

                    df_obs_agg = df_obs_agg.reset_index(); df_obs_agg['TargetDT'] = df_obs_agg['ObsDT'].dt.strftime('%Y-%m-%d %H:%M')
                    df_merged = pd.merge(df_fcst, df_obs_agg[['TargetDT', 'Obs']], on='TargetDT', how='inner')
                    
                    if lead_time_txt != "期間制限なし":
                        max_hours = int(lead_time_txt.replace("時間以内", "")); df_merged['LeadTime'] = (pd.to_datetime(df_merged['TargetDT']) - pd.to_datetime(df_merged['IssueDT'])).dt.total_seconds() / 3600
                        df_merged = df_merged[(df_merged['LeadTime'] >= 0) & (df_merged['LeadTime'] <= max_hours)]
                    
                    df_merged['Diff'] = df_merged['Fcst'] - df_merged['Obs']
                    df_merged['FcstText'] = df_merged['Fcst'].apply(lambda x: f"{x:.1f}")
                    all_dfs.append(df_merged)

            if not all_dfs:
                self.tbl_detail.setRowCount(0)
                QMessageBox.warning(self, "データ不足", "条件に合致するデータがありませんでした。")
                return

            df = pd.concat(all_dfs, ignore_index=True)

            self.lbl_rmse.setText(f"RMSE: {np.sqrt((df['Diff']**2).mean()):.2f}"); self.lbl_me.setText(f"ME (差): {df['Diff'].mean():.2f}"); self.lbl_mae.setText(f"MAE: {df['Diff'].abs().mean():.2f}")

            def calc_metrics(A, B, C, D):
                total = A + B + C + D
                hit = (A + D) / total * 100 if total > 0 else 0
                pod = A / (A + C) * 100 if (A + C) > 0 else 0
                far = B / (A + B) * 100 if (A + B) > 0 else 0
                miss = C / (A + C) * 100 if (A + C) > 0 else 0
                ts = A / (A + B + C) * 100 if (A + B + C) > 0 else 0
                return total, hit, pod, far, miss, ts

            if src_jma:
                A = len(df[(df['MaxVal'] > 0) & (df['Obs'] > 0) & df['IsRangeHit']]); B = len(df[(df['MaxVal'] > 0) & (~df['IsRangeHit']) & (df['Diff'] < 0)])
                C = len(df[(df['MaxVal'] > 0) & (~df['IsRangeHit']) & (df['Diff'] > 0)]); D = len(df[(df['MaxVal'] == 0) & (df['Obs'] == 0) & df['IsRangeHit']])
                hit_rate = len(df[df['IsRangeHit']]) / len(df) * 100 if len(df) > 0 else 0
                self.lbl_hit.setText(f"完全範囲的中率: {hit_rate:.1f} %"); self.lbl_pod.setText("捕捉率: (範囲判定)"); self.lbl_ts.setText(f"全体TS(概算): {hit_rate:.1f} %")
                self.lbl_far.setText("空振り率: --"); self.lbl_miss.setText("見逃し率: --")
            else:
                A = len(df[(df['Fcst'] > 0) & (df['Obs'] > 0)]); B = len(df[(df['Fcst'] > 0) & (df['Obs'] == 0)])
                C = len(df[(df['Fcst'] == 0) & (df['Obs'] > 0)]); D = len(df[(df['Fcst'] == 0) & (df['Obs'] == 0)])
                total, hit, pod, far, miss, ts = calc_metrics(A, B, C, D)
                self.lbl_hit.setText(f"的中率: {hit:.1f} %"); self.lbl_pod.setText(f"捕捉率: {pod:.1f} %")
                self.lbl_far.setText(f"空振り率: {far:.1f} %"); self.lbl_miss.setText(f"見逃し率: {miss:.1f} %"); self.lbl_ts.setText(f"スレッドスコア: {ts:.1f} %")

            # ★修正: 発現回数と予報回数を追加
            unit = "mm" if is_rain else "cm"
            thresholds = [1.0, 10.0, 20.0] if is_rain else [1.0, 10.0, 20.0, 30.0]
            
            for i, T in enumerate(thresholds):
                self.custom_labels[i]["title"].setText(f"【閾値 >= {T} {unit}】")
                fcst_col = 'MaxVal' if src_jma else 'Fcst' 
                A_t = len(df[(df[fcst_col] >= T) & (df['Obs'] >= T)]); B_t = len(df[(df[fcst_col] >= T) & (df['Obs'] < T)])
                C_t = len(df[(df[fcst_col] < T) & (df['Obs'] >= T)]); D_t = len(df[(df[fcst_col] < T) & (df['Obs'] < T)])
                tot_t, hit_t, pod_t, far_t, miss_t, ts_t = calc_metrics(A_t, B_t, C_t, D_t)
                
                self.custom_labels[i]["stats"].setText(f"全数: {tot_t} / 的中(A): {A_t} / 発現: {A_t+C_t} / 予報: {A_t+B_t}")
                self.custom_labels[i]["rates"].setText(f"的: {hit_t:.1f}% / 捕: {pod_t:.1f}% / 空: {far_t:.1f}% / TS: {ts_t:.1f}%")

            self.tbl_detail.setRowCount(len(df))
            for i, r in df.reset_index().iterrows():
                self.tbl_detail.setItem(i, 0, QTableWidgetItem(str(r['Station']))); self.tbl_detail.setItem(i, 1, QTableWidgetItem(str(r['IssueDT'])))
                self.tbl_detail.setItem(i, 2, QTableWidgetItem(str(r['TargetDT']))); self.tbl_detail.setItem(i, 3, QTableWidgetItem(str(r['FcstText'])))
                self.tbl_detail.setItem(i, 4, QTableWidgetItem(f"{r['Obs']:.1f}"))
                diff_item = QTableWidgetItem(f"{r['Diff']:+.1f}")
                if r['Diff'] > 0: diff_item.setForeground(QColor("#D73A49"))
                elif r['Diff'] < 0: diff_item.setForeground(QColor("#0366D6"))
                self.tbl_detail.setItem(i, 5, diff_item)
                
                judge_text = "範囲的中" if src_jma and r.get('IsRangeHit') else ("的中" if not src_jma and ((r['Fcst']>0 and r['Obs']>0) or (r['Fcst']==0 and r['Obs']==0)) else "外れ")
                self.tbl_detail.setItem(i, 6, QTableWidgetItem(judge_text))

        except Exception as e: QMessageBox.critical(self, "エラー", f"計算処理中にエラーが発生しました:\n{e}")
        finally: self.btn_search.setText("精度評価を実行"); self.btn_search.setEnabled(True)

    # ==========================================
    # タブ6: 検証Excel出力 (全指標JMA対応総合評価シート対応)
    # ==========================================
    def setup_tab6(self):
        scroll, inner_widget = self.create_scroll_tab(); layout = QVBoxLayout(inner_widget); layout.setSpacing(15)
        
        group_filter = QGroupBox("プロ仕様・検証レポート出力 (20/30指標追加済み)"); filter_layout_main = QVBoxLayout(); filter_layout_main.setContentsMargins(15, 20, 15, 15)
        
        row_sync = QHBoxLayout()
        btn_sync = QPushButton("🔄 タブ5の条件をここにコピー"); btn_sync.clicked.connect(self.sync_from_tab5)
        row_sync.addWidget(btn_sync); row_sync.addStretch(); filter_layout_main.addLayout(row_sync)

        row1 = QHBoxLayout()
        self.ex_issue_from = QDateEdit(QDate.currentDate().addDays(-7)); self.ex_issue_from.setCalendarPopup(True)
        self.ex_issue_to = QDateEdit(QDate.currentDate()); self.ex_issue_to.setCalendarPopup(True)
        self.ex_target_from = QDateEdit(QDate.currentDate().addDays(-7)); self.ex_target_from.setCalendarPopup(True)
        self.ex_target_to = QDateEdit(QDate.currentDate().addDays(2)); self.ex_target_to.setCalendarPopup(True)
        row1.addWidget(QLabel("【発表日】(Excel用):")); row1.addWidget(self.ex_issue_from); row1.addWidget(QLabel("")); row1.addWidget(self.ex_issue_to)
        row1.addSpacing(30)
        row1.addWidget(QLabel("【対象日】:")); row1.addWidget(self.ex_target_from); row1.addWidget(QLabel("")); row1.addWidget(self.ex_target_to); row1.addStretch()

        row2 = QHBoxLayout()
        self.ex_cmb_source = QComboBox(); self.ex_cmb_source.addItems(["最適モデルExcel (FCST)", "気象庁手入力フォーマット (JMA_FCST)"])
        self.ex_cmb_station = QComboBox(); cursor = self.conn.cursor(); cursor.execute("SELECT ObsCd FROM Mapping")
        self.ex_cmb_station.addItem("全地点及び全地点総合評価"); self.ex_cmb_station.addItems([str(r[0]) for r in cursor.fetchall()])
        self.ex_cmb_element = QComboBox(); self.ex_cmb_element.addItems(["降水量", "降雪量"])
        self.ex_cmb_step = QComboBox(); self.ex_cmb_step.addItems(["1時間ステップ", "3時間ステップ", "12時間ステップ"]); self.ex_cmb_step.setCurrentText("3時間ステップ")
        self.ex_cmb_leadtime = QComboBox(); self.ex_cmb_leadtime.addItems(["期間制限なし", "12時間以内", "24時間以内", "36時間以内"])
        row2.addWidget(QLabel("対象ソース:")); row2.addWidget(self.ex_cmb_source)
        row2.addWidget(QLabel("観測地点:")); row2.addWidget(self.ex_cmb_station); row2.addWidget(QLabel("要素:")); row2.addWidget(self.ex_cmb_element)
        row2.addWidget(QLabel("ステップ:")); row2.addWidget(self.ex_cmb_step)
        row2.addWidget(QLabel("発出制限:")); row2.addWidget(self.ex_cmb_leadtime); row2.addStretch()

        filter_layout_main.addLayout(row1); filter_layout_main.addSpacing(10); filter_layout_main.addLayout(row2)
        group_filter.setLayout(filter_layout_main)

        group_export = QGroupBox("レポート出力実行"); e_layout = QVBoxLayout(); e_layout.setContentsMargins(15, 25, 15, 15)
        btn_export = QPushButton("検証レポートExcelを作成"); btn_export.setObjectName("ActionBtn"); btn_export.clicked.connect(self.export_matrix_excel)
        e_layout.addWidget(QLabel("全地点一括出力対応。指定期間の全体精度、および独自指標(1/10/20/30等)を網羅して出力します。")); e_layout.addWidget(btn_export); e_layout.addStretch(); group_export.setLayout(e_layout)

        layout.addWidget(group_filter); layout.addWidget(group_export); layout.addStretch(); self.tabs.addTab(scroll, "6. 検証Excel出力")

    def sync_from_tab5(self):
        self.ex_cmb_source.setCurrentText(self.cmb_source.currentText())
        self.ex_issue_from.setDate(self.issue_from.date()); self.ex_issue_to.setDate(self.issue_to.date())
        self.ex_target_from.setDate(self.target_from.date()); self.ex_target_to.setDate(self.target_to.date())
        # "全地点総合評価"はタブ6では"全地点及び全地点総合評価"に変換
        if self.cmb_station.currentText() == "全地点総合評価":
            self.ex_cmb_station.setCurrentText("全地点及び全地点総合評価")
        else:
            self.ex_cmb_station.setCurrentText(self.cmb_station.currentText())
        self.ex_cmb_element.setCurrentText(self.cmb_element.currentText())
        self.ex_cmb_step.setCurrentText(self.cmb_step.currentText()); self.ex_cmb_leadtime.setCurrentText(self.cmb_leadtime.currentText())

    def export_matrix_excel(self):
        now_str = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')
        default_name = f"Verification_Report_{now_str}.xlsx"
        path, _ = QFileDialog.getSaveFileName(self, "検証レポートを保存", default_name, "Excel Files (*.xlsx)")
        if not path: return

        try:
            obs_cd_sel = self.ex_cmb_station.currentText()
            is_rain = self.ex_cmb_element.currentText() == "降水量"
            src_jma = "JMA" in self.ex_cmb_source.currentText()
            d_i_from = self.ex_issue_from.date().toString("yyyy-MM-dd"); d_i_to = self.ex_issue_to.date().toString("yyyy-MM-dd")
            d_t_from = self.ex_target_from.date().toString("yyyy-MM-dd"); d_t_to = self.ex_target_to.date().toString("yyyy-MM-dd")
            step_val = 3 if "3時間" in self.ex_cmb_step.currentText() else (12 if "12時間" in self.ex_cmb_step.currentText() else 1)
            lead_time_txt = self.ex_cmb_leadtime.currentText()

            cursor = self.conn.cursor()
            if obs_cd_sel == "全地点及び全地点総合評価": 
                cursor.execute("SELECT ObsCd, FcstName FROM Mapping")
                mappings = cursor.fetchall()
            else:
                cursor.execute("SELECT FcstName FROM Mapping WHERE ObsCd = ?", (obs_cd_sel,))
                row = cursor.fetchone()
                if not row: return QMessageBox.warning(self, "エラー", "マッピングが見つかりません。")
                mappings = [(obs_cd_sel, row[0])]

            def calc_m(A, B, C, D):
                tot = A + B + C + D; h = (A + D) / tot * 100 if tot > 0 else 0; p = A / (A + C) * 100 if (A + C) > 0 else 0
                f = B / (A + B) * 100 if (A + B) > 0 else 0; m = C / (A + C) * 100 if (A + C) > 0 else 0; ts = A / (A + B + C) * 100 if (A + B + C) > 0 else 0
                return tot, h, p, f, m, ts

            all_eval_dfs = []
            wrote_any = False
            
            with pd.ExcelWriter(path, engine='openpyxl') as writer:
                for obs_cd, fcst_name in mappings:
                    if src_jma:
                        query = "SELECT ? AS Station, '-' AS IssueDT, TargetStart || '~' || TargetEnd AS TargetDT, RawText AS FcstText, MinVal, MaxVal, TargetStart, TargetEnd FROM JMA_FCST WHERE Region = ? AND Element = ? AND TargetStart >= ? AND TargetStart <= ?"
                        df_fcst = pd.read_sql_query(query, self.conn, params=(obs_cd, fcst_name, self.ex_cmb_element.currentText(), d_t_from + ' 00:00', d_t_to + ' 23:59'))
                        obs_query = f"SELECT ObsDay || ' ' || ObsTime AS ObsDT, RainfallP1, SnowfallP1, SnowDepth FROM OBS WHERE MsCd = ?"
                        df_obs = pd.read_sql_query(obs_query, self.conn, params=(obs_cd,))
                        if df_obs.empty or df_fcst.empty: continue
                        df_obs['ObsDT'] = pd.to_datetime(df_obs['ObsDT'])
                        
                        merged_rows = []
                        for _, row in df_fcst.iterrows():
                            ts_start = pd.to_datetime(row['TargetStart']); ts_end = pd.to_datetime(row['TargetEnd'])
                            mask = (df_obs['ObsDT'] > ts_start) & (df_obs['ObsDT'] <= ts_end)
                            if is_rain:
                                obs_val = df_obs.loc[mask, 'RainfallP1'].sum()
                            else:
                                if df_obs['SnowDepth'].notna().any():
                                    sd_start = df_obs.loc[df_obs['ObsDT'] == ts_start, 'SnowDepth'].last_valid_index()
                                    sd_end = df_obs.loc[df_obs['ObsDT'] == ts_end, 'SnowDepth'].last_valid_index()
                                    start_val = df_obs.loc[sd_start, 'SnowDepth'] if sd_start is not None else 0
                                    end_val = df_obs.loc[sd_end, 'SnowDepth'] if sd_end is not None else 0
                                    obs_val = max(end_val - start_val, 0)
                                else:
                                    obs_val = df_obs.loc[mask, 'SnowfallP1'].sum()
                            is_hit = row['MinVal'] <= obs_val <= row['MaxVal']
                            diff = 0.0
                            if obs_val < row['MinVal']: diff = obs_val - row['MinVal']
                            elif obs_val > row['MaxVal']: diff = obs_val - row['MaxVal']
                            merged_rows.append({'Station': row['Station'], 'IssueDT': row['IssueDT'], 'TargetDT': row['TargetDT'], 'Fcst': row['MaxVal'], 'FcstText': row['FcstText'], 'Obs': obs_val, 'Diff(Fcst-Obs)': diff, 'IsRangeHit': is_hit, 'MaxVal': row['MaxVal']})
                        df = pd.DataFrame(merged_rows) if merged_rows else pd.DataFrame()
                    else:
                        fcst_query = f"SELECT IssueDay || ' ' || IssueTime AS IssueDT, TargetDay || ' ' || TargetTime AS TargetDT, {'FcstRainfall' if is_rain else 'FcstSnowfall'} AS Fcst FROM FCST WHERE MsCd = ? AND IssueDay BETWEEN ? AND ? AND TargetDay BETWEEN ? AND ? AND ForecastStep = ?"
                        df_fcst = pd.read_sql_query(fcst_query, self.conn, params=(fcst_name, d_i_from, d_i_to, d_t_from, d_t_to, step_val))
                        obs_start_dt = datetime.strptime(d_t_from, "%Y-%m-%d") - timedelta(days=2)
                        
                        obs_query = f"SELECT ObsDay || ' ' || ObsTime AS ObsDT, RainfallP1, SnowfallP1, SnowDepth FROM OBS WHERE MsCd = ? AND ObsDay >= ?"
                        df_obs = pd.read_sql_query(obs_query, self.conn, params=(obs_cd, obs_start_dt.strftime("%Y-%m-%d")))
                        if df_fcst.empty or df_obs.empty: continue
                        
                        df_obs['ObsDT'] = pd.to_datetime(df_obs['ObsDT']); df_obs.set_index('ObsDT', inplace=True)
                        df_obs_agg = df_obs.resample('1h').agg({'RainfallP1': 'sum', 'SnowfallP1': 'sum', 'SnowDepth': 'last'})
                        df_obs_agg['SnowDepth'] = df_obs_agg['SnowDepth'].ffill()
                        
                        if is_rain:
                            if step_val > 1: df_obs_agg['Obs'] = df_obs_agg['RainfallP1'].rolling(window=step_val, min_periods=1).sum()
                            else: df_obs_agg['Obs'] = df_obs_agg['RainfallP1']
                        else:
                            if df_obs_agg['SnowDepth'].notna().any(): df_obs_agg['Obs'] = df_obs_agg['SnowDepth'].diff(periods=step_val).clip(lower=0)
                            else: df_obs_agg['Obs'] = df_obs_agg['SnowfallP1'].rolling(window=step_val, min_periods=1).sum() if step_val > 1 else df_obs_agg['SnowfallP1']
                                
                        df_obs_agg = df_obs_agg.reset_index(); df_obs_agg['TargetDT'] = df_obs_agg['ObsDT'].dt.strftime('%Y-%m-%d %H:%M')
                        df = pd.merge(df_fcst, df_obs_agg[['TargetDT', 'Obs']], on='TargetDT', how='inner')
                        
                        if df.empty: continue
                        if lead_time_txt != "期間制限なし":
                            max_hours = int(lead_time_txt.replace("時間以内", "")); df['LeadTime'] = (pd.to_datetime(df['TargetDT']) - pd.to_datetime(df['IssueDT'])).dt.total_seconds() / 3600
                            df = df[(df['LeadTime'] >= 0) & (df['LeadTime'] <= max_hours)]
                            if df.empty: continue
                        df['Diff(Fcst-Obs)'] = df['Fcst'] - df['Obs']
                        df['FcstText'] = df['Fcst'].apply(lambda x: f"{x:.1f}")
                    
                    if df.empty: continue
                    all_eval_dfs.append(df)
                    
                    rmse = np.sqrt((df['Diff(Fcst-Obs)']**2).mean())
                    me = df['Diff(Fcst-Obs)'].mean(); mae = df['Diff(Fcst-Obs)'].abs().mean()
                    
                    if src_jma:
                        hit_rate = len(df[df['IsRangeHit']]) / len(df) * 100 if len(df) > 0 else 0
                        tot0, hit0, pod0, far0, miss0, ts0 = len(df), hit_rate, 0, 0, 0, hit_rate
                    else:
                        A0 = len(df[(df['Fcst'] > 0) & (df['Obs'] > 0)]); B0 = len(df[(df['Fcst'] > 0) & (df['Obs'] == 0)])
                        C0 = len(df[(df['Fcst'] == 0) & (df['Obs'] > 0)]); D0 = len(df[(df['Fcst'] == 0) & (df['Obs'] == 0)])
                        tot0, hit0, pod0, far0, miss0, ts0 = calc_m(A0, B0, C0, D0)

                    unit = "mm" if is_rain else "cm"
                    thresholds = [1.0, 10.0, 20.0] if is_rain else [1.0, 10.0, 20.0, 30.0]
                    t_stats = []
                    for T in thresholds:
                        fcst_col = 'MaxVal' if src_jma else 'Fcst'
                        A_t = len(df[(df[fcst_col] >= T) & (df['Obs'] >= T)]); B_t = len(df[(df[fcst_col] >= T) & (df['Obs'] < T)])
                        C_t = len(df[(df[fcst_col] < T) & (df['Obs'] >= T)]); D_t = len(df[(df[fcst_col] < T) & (df['Obs'] < T)])
                        t_stats.append((calc_m(A_t, B_t, C_t, D_t), A_t, A_t+C_t, A_t+B_t))

                    pivot_fcst = df.pivot_table(index='IssueDT', columns='TargetDT', values='FcstText', aggfunc=lambda x: ' '.join(x))
                    pivot_obs = df.pivot_table(index='IssueDT', columns='TargetDT', values='Obs', aggfunc='mean')
                    pivot_diff = df.pivot_table(index='IssueDT', columns='TargetDT', values='Diff(Fcst-Obs)', aggfunc='mean')

                    combined_rows = []; targets = sorted(pivot_fcst.columns)
                    for issue in sorted(pivot_fcst.index):
                        obs_row = [f"{issue} (観測)"]; fcst_row = [f"{issue} (予報)"]; diff_row = ["差分 (予報-観測)"]
                        for t in targets:
                            obs_row.append(pivot_obs.loc[issue, t] if t in pivot_obs.columns and not pd.isna(pivot_obs.loc[issue, t]) else np.nan)
                            fcst_row.append(pivot_fcst.loc[issue, t] if t in pivot_fcst.columns and not pd.isna(pivot_fcst.loc[issue, t]) else np.nan)
                            diff_row.append(pivot_diff.loc[issue, t] if t in pivot_diff.columns and not pd.isna(pivot_diff.loc[issue, t]) else np.nan)
                        combined_rows.extend([obs_row, fcst_row, diff_row, [np.nan] * (len(targets) + 1)])
                        
                    out_df = pd.DataFrame(combined_rows)
                    sheet_name = obs_cd.replace(":", "_")[:31]
                    
                    # ヘッダーなしでデータのみ書き出しヘッダーは手動で2段構成にする
                    start_row_matrix = 26 + len(thresholds) * 2
                    out_df.to_excel(writer, sheet_name=sheet_name, startrow=start_row_matrix+1, index=False, header=False)
                    ws = writer.sheets[sheet_name]
                    
                    ws.cell(row=1, column=1, value="【検証パラメータ】")
                    ws.cell(row=2, column=1, value=f"観測地点: {obs_cd}"); ws.cell(row=2, column=2, value=f"要素: {self.ex_cmb_element.currentText()} ({'JMA手入力' if src_jma else 'FCST'})")
                    ws.cell(row=3, column=1, value=f"時間ステップ: {step_val}時間"); ws.cell(row=3, column=2, value=f"発出制限: {lead_time_txt}")
                    ws.cell(row=4, column=1, value=f"発表日期間: {d_i_from} ~ {d_i_to}"); ws.cell(row=5, column=1, value=f"対象日期間: {d_t_from} ~ {d_t_to}")

                    ws.cell(row=7, column=1, value="【全体精度】")
                    ws.cell(row=8, column=1, value=f"RMSE: {rmse:.2f}"); ws.cell(row=8, column=2, value=f"ME: {me:.2f}"); ws.cell(row=8, column=3, value=f"MAE: {mae:.2f}")
                    ws.cell(row=9, column=1, value=f"全数: {tot0}"); ws.cell(row=9, column=2, value=f"的中回数(A): {int(tot0 * hit0 / 100) if src_jma else A0}")
                    ws.cell(row=10, column=1, value=f"的中率: {hit0:.1f}%"); ws.cell(row=10, column=2, value=f"捕捉率: {pod0:.1f}%"); ws.cell(row=10, column=3, value=f"空振り率: {far0:.1f}%"); ws.cell(row=10, column=4, value=f"見逃し率: {miss0:.1f}%"); ws.cell(row=10, column=5, value=f"TS: {ts0:.1f}%")

                    start_row = 12
                    for i, (T, stats_data) in enumerate(zip(thresholds, t_stats)):
                        s_mets, A_val, occur_val, fcst_val = stats_data
                        ws.cell(row=start_row, column=1, value=f"【独自指標: 閾値 >= {T} {unit}】")
                        ws.cell(row=start_row+1, column=1, value=f"全数: {s_mets[0]}"); ws.cell(row=start_row+1, column=2, value=f"的中(A): {A_val}"); ws.cell(row=start_row+1, column=3, value=f"発現回数: {occur_val}"); ws.cell(row=start_row+1, column=4, value=f"予報回数: {fcst_val}")
                        ws.cell(row=start_row+2, column=1, value=f"的中率: {s_mets[1]:.1f}%"); ws.cell(row=start_row+2, column=2, value=f"捕捉率: {s_mets[2]:.1f}%"); ws.cell(row=start_row+2, column=3, value=f"空振り率: {s_mets[3]:.1f}%"); ws.cell(row=start_row+2, column=4, value=f"見逃し率: {s_mets[4]:.1f}%"); ws.cell(row=start_row+2, column=5, value=f"TS: {s_mets[5]:.1f}%")
                        start_row += 4

                    ws.cell(row=start_row_matrix, column=1, value="▼ 詳細マトリクスデータ")
                    
                    # ★修正: 対象日時ヘッダーを2段年月日 / 時間で出力
                    ws.cell(row=start_row_matrix+1, column=1, value="発表日時")
                    for col_i, t in enumerate(targets):
                        dt_obj = pd.to_datetime(t)
                        ws.cell(row=start_row_matrix, column=col_i+2, value=dt_obj.strftime('%Y-%m-%d'))
                        ws.cell(row=start_row_matrix+1, column=col_i+2, value=dt_obj.strftime('%H:%M'))
                    
                    wrote_any = True

                # ★修正: 「全地点総合評価用の独立シートの作成マトリクスなし
                if obs_cd_sel == "全地点及び全地点総合評価" and all_eval_dfs:
                    df_all = pd.concat(all_eval_dfs, ignore_index=True)
                    sheet_name = "総合評価"
                    df_all.to_excel(writer, sheet_name=sheet_name) 
                    ws_all = writer.sheets[sheet_name]
                    # 初期化 (ダミー書き込みのクリア)
                    for row in ws_all['A1:Z100']:
                        for cell in row: cell.value = None
                        
                    rmse_all = np.sqrt((df_all['Diff(Fcst-Obs)']**2).mean())
                    me_all = df_all['Diff(Fcst-Obs)'].mean(); mae_all = df_all['Diff(Fcst-Obs)'].abs().mean()
                    
                    if src_jma:
                        A0_all = len(df_all[(df_all['MaxVal'] > 0) & (df_all['Obs'] > 0) & df_all['IsRangeHit']])
                        hit_rate_all = len(df_all[df_all['IsRangeHit']]) / len(df_all) * 100 if len(df_all) > 0 else 0
                        tot0_all, hit0_all, pod0_all, far0_all, miss0_all, ts0_all = len(df_all), hit_rate_all, 0, 0, 0, hit_rate_all
                    else:
                        A0_all = len(df_all[(df_all['Fcst'] > 0) & (df_all['Obs'] > 0)]); B0_all = len(df_all[(df_all['Fcst'] > 0) & (df_all['Obs'] == 0)])
                        C0_all = len(df_all[(df_all['Fcst'] == 0) & (df_all['Obs'] > 0)]); D0_all = len(df_all[(df_all['Fcst'] == 0) & (df_all['Obs'] == 0)])
                        tot0_all, hit0_all, pod0_all, far0_all, miss0_all, ts0_all = calc_m(A0_all, B0_all, C0_all, D0_all)

                    ws_all.cell(row=1, column=1, value="【全地点総合評価 パラメータ】")
                    ws_all.cell(row=2, column=1, value=f"要素: {self.ex_cmb_element.currentText()}"); ws_all.cell(row=2, column=2, value=f"ステップ: {step_val}時間")
                    ws_all.cell(row=3, column=1, value=f"発表日期間: {d_i_from} ~ {d_i_to}"); ws_all.cell(row=4, column=1, value=f"対象日期間: {d_t_from} ~ {d_t_to}")
                    
                    ws_all.cell(row=6, column=1, value="【全体精度】")
                    ws_all.cell(row=7, column=1, value=f"RMSE: {rmse_all:.2f}"); ws_all.cell(row=7, column=2, value=f"ME: {me_all:.2f}"); ws_all.cell(row=7, column=3, value=f"MAE: {mae_all:.2f}")
                    ws_all.cell(row=8, column=1, value=f"全数: {tot0_all}"); ws_all.cell(row=8, column=2, value=f"的中回数(A): {A0_all}")
                    ws_all.cell(row=9, column=1, value=f"的中率: {hit0_all:.1f}%"); ws_all.cell(row=9, column=2, value=f"捕捉率: {pod0_all:.1f}%"); ws_all.cell(row=9, column=3, value=f"空振り率: {far0_all:.1f}%"); ws_all.cell(row=9, column=4, value=f"見逃し率: {miss0_all:.1f}%"); ws_all.cell(row=9, column=5, value=f"TS: {ts0_all:.1f}%")

                    start_row_all = 11
                    for T in thresholds:
                        fcst_col = 'MaxVal' if src_jma else 'Fcst'
                        A_t = len(df_all[(df_all[fcst_col] >= T) & (df_all['Obs'] >= T)]); B_t = len(df_all[(df_all[fcst_col] >= T) & (df_all['Obs'] < T)])
                        C_t = len(df_all[(df_all[fcst_col] < T) & (df_all['Obs'] >= T)]); D_t = len(df_all[(df_all[fcst_col] < T) & (df_all['Obs'] < T)])
                        tot_t, hit_t, pod_t, far_t, miss_t, ts_t = calc_m(A_t, B_t, C_t, D_t)
                        
                        ws_all.cell(row=start_row_all, column=1, value=f"【独自指標: 閾値 >= {T} {unit}】")
                        ws_all.cell(row=start_row_all+1, column=1, value=f"全数: {tot_t}"); ws_all.cell(row=start_row_all+1, column=2, value=f"的中(A): {A_t}"); ws_all.cell(row=start_row_all+1, column=3, value=f"発現回数: {A_t+C_t}"); ws_all.cell(row=start_row_all+1, column=4, value=f"予報回数: {A_t+B_t}")
                        ws_all.cell(row=start_row_all+2, column=1, value=f"的中率: {hit_t:.1f}%"); ws_all.cell(row=start_row_all+2, column=2, value=f"捕捉率: {pod_t:.1f}%"); ws_all.cell(row=start_row_all+2, column=3, value=f"空振り率: {far_t:.1f}%"); ws_all.cell(row=start_row_all+2, column=4, value=f"見逃し率: {miss_t:.1f}%"); ws_all.cell(row=start_row_all+2, column=5, value=f"TS: {ts_t:.1f}%")
                        start_row_all += 4

            if wrote_any: QMessageBox.information(self, "完了", f"レポートを出力しました。\n保存先: {path}")
            else: QMessageBox.warning(self, "データなし", "出力可能なデータがありませんでした。")
        except Exception as e: QMessageBox.critical(self, "エラー", f"出力失敗:\n{e}")

    # ==========================================
    # ★新タブ7: データベース管理 (バックアップ復元)
    # ==========================================
    def setup_tab7(self):
        scroll, inner_widget = self.create_scroll_tab(); layout = QVBoxLayout(inner_widget); layout.setSpacing(15)

        group_backup = QGroupBox("データベースのバックアップ"); b_layout = QVBoxLayout(); b_layout.setContentsMargins(15, 20, 15, 15)
        h1 = QHBoxLayout()
        self.lbl_backup_folder = QLabel("📁 バックアップ先: 未設定"); self.lbl_backup_folder.setStyleSheet("font-weight: bold; color: #0366D6;")
        btn_sel_b = QPushButton("保存先フォルダを選択"); btn_sel_b.clicked.connect(self.select_backup_folder)
        h1.addWidget(self.lbl_backup_folder); h1.addWidget(btn_sel_b); h1.addStretch()
        b_layout.addLayout(h1)

        h2 = QHBoxLayout()
        btn_manual_b = QPushButton("今すぐバックアップを作成"); btn_manual_b.setObjectName("ActionBtn"); btn_manual_b.clicked.connect(self.manual_backup)
        self.lbl_auto_b = QLabel("自動バックアップ: 停止中"); self.lbl_auto_b.setStyleSheet("color: #D73A49; font-weight: bold;")
        self.btn_auto_b = QPushButton("12時間毎の自動バックアップを開始"); self.btn_auto_b.setObjectName("AutoBtn"); self.btn_auto_b.clicked.connect(self.toggle_auto_backup)
        h2.addWidget(btn_manual_b); h2.addWidget(self.btn_auto_b); h2.addWidget(self.lbl_auto_b); h2.addStretch()
        b_layout.addLayout(h2)
        group_backup.setLayout(b_layout)

        group_restore = QGroupBox("データの復元 (統合)"); r_layout = QVBoxLayout(); r_layout.setContentsMargins(15, 20, 15, 15)
        lbl_r = QLabel("過去のバックアップや別のPCのデータベースファイルを選択し、現在のDBにデータを結合(復元)します。\n※重複するデータは現在のものが優先され、不足しているデータのみが安全に追記されます。")
        btn_restore = QPushButton("バックアップDBを選択して復元"); btn_restore.setObjectName("TestBtn"); btn_restore.clicked.connect(self.restore_database)
        r_layout.addWidget(lbl_r); r_layout.addWidget(btn_restore); r_layout.addStretch()
        group_restore.setLayout(r_layout)

        layout.addWidget(group_backup); layout.addWidget(group_restore); layout.addStretch()
        self.tabs.addTab(scroll, "7. データベース管理")

        self.auto_backup_timer = QTimer(self)
        self.auto_backup_timer.timeout.connect(self.manual_backup)
        self.backup_folder = ""

    def select_backup_folder(self):
        folder = QFileDialog.getExistingDirectory(self, "バックアップ先フォルダを選択")
        if folder:
            self.backup_folder = folder
            self.lbl_backup_folder.setText(f"📁 バックアップ先: {folder}")

    def manual_backup(self):
        if not self.backup_folder:
            QMessageBox.warning(self, "警告", "バックアップ先のフォルダを設定してください。")
            return
        now_str = datetime.now().strftime('%Y%m%d_%H%M%S')
        dest = os.path.join(self.backup_folder, f"weather_db_backup_{now_str}.db")
        try:
            self.conn.commit()
            shutil.copy2(self.db_path, dest)
            if not self.auto_backup_timer.isActive():
                QMessageBox.information(self, "完了", f"データベースのバックアップを作成しました。\n\n保存先:\n{dest}")
        except Exception as e:
            QMessageBox.critical(self, "エラー", f"バックアップに失敗しました:\n{e}")

    def toggle_auto_backup(self):
        if self.auto_backup_timer.isActive():
            self.auto_backup_timer.stop()
            self.btn_auto_b.setText("12時間毎の自動バックアップを開始")
            self.lbl_auto_b.setText("自動バックアップ: 停止中")
        else:
            if not self.backup_folder:
                QMessageBox.warning(self, "警告", "バックアップ先のフォルダを設定してください。")
                return
            self.auto_backup_timer.start(12 * 60 * 60 * 1000)
            self.btn_auto_b.setText("自動バックアップを停止")
            self.lbl_auto_b.setText("🔴 自動バックアップ: 稼働中 (12時間毎)")
            self.manual_backup()

    def restore_database(self):
        path, _ = QFileDialog.getOpenFileName(self, "復元元のDBファイルを選択", "", "SQLite DB (*.db);;すべてのファイル (*)")
        if not path: return
        try:
            cursor = self.conn.cursor()
            cursor.execute(f"ATTACH DATABASE '{path}' AS backup_db")
            
            cursor.execute("INSERT OR IGNORE INTO OBS SELECT * FROM backup_db.OBS")
            cursor.execute("INSERT OR IGNORE INTO FCST SELECT * FROM backup_db.FCST")
            cursor.execute("INSERT OR IGNORE INTO JMA_FCST SELECT * FROM backup_db.JMA_FCST")
            
            self.conn.commit()
            cursor.execute("DETACH DATABASE backup_db")
            QMessageBox.information(self, "復元完了", "データの結合(復元)が完了しました。")
            
            self.update_tab1_station_combo()
            self.search_obs_table()
        except Exception as e:
            QMessageBox.critical(self, "エラー", f"復元中にエラーが発生しました:\n{e}")


if __name__ == "__main__":
    app = QApplication(sys.argv)
    app.setQuitOnLastWindowClosed(False)
    window = WeatherVerificationApp()
    window.show()
    sys.exit(app.exec())