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