import sys
import os
import sqlite3
import pandas as pd
import numpy as np
from datetime import datetime, timedelta, timezone
import re
import pyodbc
try:
import pdfplumber
except ImportError:
pass # 起動後のGUIで警告を出すためここではパス
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)
# ==========================================
# ドラッグ&ドロップ対応のPDF受信ラベル
# ==========================================
class PDFDropLabel(QLabel):
def __init__(self, parent_tab):
super().__init__("📥 ここに 気象庁のFAX(PDF)をドラッグ&ドロップ")
self.parent_tab = parent_tab
self.setAcceptDrops(True)
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.setStyleSheet("border: 3px dashed #0366D6; border-radius: 10px; background-color: #F1F8FF; font-size: 16px; font-weight: bold; color: #0366D6; min-height: 100px;")
def dragEnterEvent(self, event):
if event.mimeData().hasUrls() and any(u.toLocalFile().lower().endswith('.pdf') for u in event.mimeData().urls()):
event.accept()
self.setStyleSheet("border: 3px dashed #28A745; border-radius: 10px; background-color: #EAFFEA; font-size: 16px; font-weight: bold; color: #28A745; min-height: 100px;")
else:
event.ignore()
def dragLeaveEvent(self, event):
self.setStyleSheet("border: 3px dashed #0366D6; border-radius: 10px; background-color: #F1F8FF; font-size: 16px; font-weight: bold; color: #0366D6; min-height: 100px;")
def dropEvent(self, event):
self.dragLeaveEvent(event)
for url in event.mimeData().urls():
filepath = url.toLocalFile()
if filepath.lower().endswith('.pdf'):
self.parent_tab.process_dropped_pdf(filepath)
break
class WeatherVerificationApp(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("気象予報精度検証システム (Ver 14.1 - 導通点検機能搭載 / UTC準拠)")
self.resize(1400, 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)
# ★ご指定名通り (削除禁止) を付与したV14専用DB
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()
if 'pdfplumber' not in sys.modules:
QMessageBox.warning(self, "警告", "pdfplumberがインストールされていません。\nPDFの解析機能(タブ4)を使用する場合は、コマンドプロンプトで\n「pip install pdfplumber」を実行してください。")
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")
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('''CREATE TABLE IF NOT EXISTS OBS (ObsDay TEXT, ObsTime TEXT, MsCd TEXT, RainfallP1 REAL, SnowfallP1 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 (TargetDay TEXT, Region TEXT, Element TEXT, RawText TEXT, ValueMax REAL, PRIMARY KEY (TargetDay, 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()
# ==========================================
# タブ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, 5)
self.tbl_obs.setHorizontalHeaderLabels(["観測日▽", "観測時間▽", "地点コード▽", "降水量(mm)▽", "降雪量(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()
# ★復活:SQL Serverとの導通テストを行う関数
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 * FROM OBS WHERE ObsDay BETWEEN ? AND ? ORDER BY ObsDay DESC, ObsTime DESC LIMIT 300", (d_from, d_to))
else: cursor.execute("SELECT * 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())
query = f"SELECT ObsDay, ObsTime, MsCd, RainfallP1, SnowfallP1 FROM T_MS_Statistics WHERE MsCd IN ('MS1','MS2','MS3','MS4','MS5','MS6','MS7','MS8','MS9','MS25') AND ObsDay BETWEEN '{d_from}' AND '{d_to}'"
df = pd.read_sql(query, conn_sql); conn_sql.close()
if df.empty: return QMessageBox.warning(self, "通知", "指定期間のデータが存在しません。")
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')
df_hourly = df.groupby(['MsCd', 'HourlyTime'])[['RainfallP1', 'SnowfallP1']].sum().reset_index()
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():
cursor.execute("INSERT OR REPLACE INTO OBS (ObsDay, ObsTime, MsCd, RainfallP1, SnowfallP1) VALUES (?, ?, ?, ?, ?)",
(str(row['ObsDay']), str(row['ObsTime']), str(row['MsCd']), float(row['RainfallP1']), float(row['SnowfallP1'])))
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"期間 [{d_from} ~ {d_to}] の抽出が完了しました。\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}
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
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
cursor.execute("INSERT OR REPLACE INTO OBS (ObsDay, ObsTime, MsCd, RainfallP1, SnowfallP1) VALUES (?, ?, ?, ?, ?)", (obs_day, obs_time, f"アメダス:{st}", float(r), float(s)))
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>【抽出元エクセルの対象範囲】</b><br>以下の表名を自動検知します。<br><br><b>🌧️ 降水量の抽出:</b><br>「(1)3時間降水量」という表から抽出を開始します。<br>※「最終」や「(6)」などの表が見えたらロックを解除。<br><br><b>❄️ 降雪量の抽出:</b><br>「降雪量」という表から抽出を開始します。<br>※「最終的な降雪量」に突入する前にストップ。"
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 = set()
seen_snow_stations = set()
for row_idx in range(len(df)):
cat_str = str(df.iloc[row_idx, 0]).strip()
if "3時間降水量" in cat_str and not any(x in cat_str for x in ["最終", "(6)", "(6)"]): current_mode = "rain"
elif "降雪量" in cat_str and not any(x in cat_str for x in ["最終", "(7)", "(7)"]): current_mode = "snow"
elif any(x in cat_str for x in ["天気", "風向", "気温", "湿度", "気圧", "最終", "(5)", "(5)", "(6)", "(6)"]): current_mode = None
if not current_mode: continue
raw_station = str(df.iloc[row_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[row_idx, col_idx]) if not pd.isna(df.iloc[row_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[row_idx, col_idx]) if not pd.isna(df.iloc[row_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: 気象庁PDF 解析&読込
# ==========================================
def setup_tab4(self):
scroll, inner_widget = self.create_scroll_tab(); layout = QVBoxLayout(inner_widget); layout.setSpacing(15)
lbl_desc = QLabel("<b>【気象庁FAX (PDF) の自動行結合・抽出】</b><br>PDFから文字を抽出し、「Y座標(高さ)」が同じものを1つの行として自動結合します。<br>※「石狩中部 10-20」のように分離しているデータも同一行として読み取ります。")
lbl_desc.setStyleSheet("color: #0366D6; font-size: 14px; padding: 10px; background-color: #F1F8FF; border-radius: 5px;")
layout.addWidget(lbl_desc)
group_input = QGroupBox("PDFドラッグ&ドロップ入力"); input_layout = QVBoxLayout(); input_layout.setContentsMargins(15, 20, 15, 15)
form_layout = QHBoxLayout()
self.pdf_target_date = QDateEdit(QDate.currentDate()); self.pdf_target_date.setCalendarPopup(True)
self.pdf_element = QComboBox(); self.pdf_element.addItems(["降雪量", "降水量"])
form_layout.addWidget(QLabel("【必須】PDFの対象日:")); form_layout.addWidget(self.pdf_target_date)
form_layout.addWidget(QLabel("対象要素:")); form_layout.addWidget(self.pdf_element); form_layout.addStretch()
self.drop_label = PDFDropLabel(self)
input_layout.addLayout(form_layout); input_layout.addWidget(self.drop_label)
group_input.setLayout(input_layout)
group_preview = QGroupBox("抽出結果プレビュー & JMA_FCSTテーブルへ保存"); prev_layout = QVBoxLayout(); prev_layout.setContentsMargins(15, 20, 15, 15)
self.tbl_pdf = FilterableTableWidget(0, 4)
self.tbl_pdf.setHorizontalHeaderLabels(["対象日▽", "推定地域 (Region)▽", "生テキスト (Raw)▽", "抽出最大値 (Max)▽"])
self.tbl_pdf.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
btn_save_pdf = QPushButton("この結果を JMA_FCST テーブルに保存する"); btn_save_pdf.setObjectName("ActionBtn"); btn_save_pdf.clicked.connect(self.save_pdf_data)
prev_layout.addWidget(self.tbl_pdf); prev_layout.addWidget(btn_save_pdf); group_preview.setLayout(prev_layout)
layout.addWidget(group_input); layout.addWidget(group_preview); layout.addStretch()
self.tabs.addTab(scroll, "4. 気象庁PDF抽出 (新機能)")
def process_dropped_pdf(self, filepath):
if 'pdfplumber' not in sys.modules:
return QMessageBox.critical(self, "エラー", "pdfplumberがインストールされていません。コマンドプロンプトで pip install pdfplumber を実行してください。")
try:
extracted_lines = []
with pdfplumber.open(filepath) as pdf:
for page in pdf.pages:
words = page.extract_words()
lines_dict = {}
for w in words:
y = round(w['top'] / 5) * 5
if y not in lines_dict: lines_dict[y] = []
lines_dict[y].append(w)
for y in sorted(lines_dict.keys()):
line_words = sorted(lines_dict[y], key=lambda x: x['x0'])
full_text = " ".join([w['text'] for w in line_words]).replace(" ", "")
numbers = re.findall(r'\d+', full_text)
if numbers:
max_val = max([int(n) for n in numbers])
region_guess = re.sub(r'[0-9\-~~c㎝m]', '', full_text).strip()
if not region_guess: region_guess = "不明"
extracted_lines.append((region_guess, full_text, max_val))
tgt_date = self.pdf_target_date.date().toString("yyyy-MM-dd")
self.tbl_pdf.setRowCount(len(extracted_lines))
for i, (reg, raw, val) in enumerate(extracted_lines):
self.tbl_pdf.setItem(i, 0, QTableWidgetItem(tgt_date))
self.tbl_pdf.setItem(i, 1, QTableWidgetItem(reg))
self.tbl_pdf.setItem(i, 2, QTableWidgetItem(raw))
self.tbl_pdf.setItem(i, 3, QTableWidgetItem(str(val)))
QMessageBox.information(self, "成功", "PDFの座標解析による行結合と数値抽出が完了しました。\n表の「推定地域」にズレがある場合は手動で補正してください。")
except Exception as e: QMessageBox.critical(self, "PDF解析エラー", str(e))
def save_pdf_data(self):
element = self.pdf_element.currentText()
cursor = self.conn.cursor()
saved = 0
for i in range(self.tbl_pdf.rowCount()):
t_date = self.tbl_pdf.item(i, 0).text()
reg = self.tbl_pdf.item(i, 1).text()
raw = self.tbl_pdf.item(i, 2).text()
try: val = float(self.tbl_pdf.item(i, 3).text())
except: val = 0.0
cursor.execute("INSERT OR REPLACE INTO JMA_FCST (TargetDay, Region, Element, RawText, ValueMax) VALUES (?, ?, ?, ?, ?)", (t_date, reg, element, raw, val))
saved += 1
self.conn.commit()
QMessageBox.information(self, "保存完了", f"{saved}件のPDF予報データを JMA_FCST テーブルに保存しました。")
# ==========================================
# タブ5: 精度評価・マッピング
# ==========================================
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("※ PDF検証時は、ここの「予報地点名」を\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)", "気象庁PDF (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("【発表日】:")); 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_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("時間ステップ:")); row2.addWidget(self.cmb_step)
row2.addWidget(QLabel("発出期間:")); 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 = QVBoxLayout(); custom_layout.setContentsMargins(15, 10, 15, 10)
self.lbl_custom1_title = QLabel("【閾値1】: --"); self.lbl_custom1_title.setStyleSheet("font-weight: bold; color: #D73A49;")
self.lbl_custom1_stats = QLabel("全数: -- / 的中(A): --")
self.lbl_custom1_rates = QLabel("的中率: -- / 捕捉率: -- / 空振り率: -- / 見逃し率: -- / TS: --")
custom_layout.addWidget(self.lbl_custom1_title); custom_layout.addWidget(self.lbl_custom1_stats); custom_layout.addWidget(self.lbl_custom1_rates)
custom_layout.addSpacing(5)
self.lbl_custom2_title = QLabel("【閾値2】: --"); self.lbl_custom2_title.setStyleSheet("font-weight: bold; color: #D73A49;")
self.lbl_custom2_stats = QLabel("全数: -- / 的中(A): --")
self.lbl_custom2_rates = QLabel("的中率: -- / 捕捉率: -- / 空振り率: -- / 見逃し率: -- / TS: --")
custom_layout.addWidget(self.lbl_custom2_title); custom_layout.addWidget(self.lbl_custom2_stats); custom_layout.addWidget(self.lbl_custom2_rates)
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(["観測地点▽", "発表日時(PDFは非表示)▽", "対象日時▽", "予報値▽", "実況値(加算済)▽", "差分▽", "判定▽"])
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. 精度評価・マッピング")
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 = "PDF" 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, TargetDay || ' 00:00' AS TargetDT, ValueMax AS Fcst FROM JMA_FCST WHERE Region = ? AND Element = ? AND TargetDay BETWEEN ? AND ?"
df_fcst = pd.read_sql_query(query, self.conn, params=(obs_cd, fcst_name, self.cmb_element.currentText(), d_tgt_from, d_tgt_to))
obs_query = f"SELECT ObsDay || ' 00:00' AS TargetDT, SUM({'RainfallP1' if is_rain else 'SnowfallP1'}) AS Obs FROM OBS WHERE MsCd = ? AND ObsDay BETWEEN ? AND ? GROUP BY ObsDay"
df_obs = pd.read_sql_query(obs_query, self.conn, params=(obs_cd, d_tgt_from, d_tgt_to))
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' if is_rain else 'SnowfallP1'} AS Obs 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
if not src_jma:
df_obs['ObsDT'] = pd.to_datetime(df_obs['ObsDT'])
df_obs.set_index('ObsDT', inplace=True)
df_obs = df_obs.resample('1h').sum()
if step_val > 1: df_obs_agg = df_obs.rolling(window=step_val, min_periods=1).sum()
else: df_obs_agg = df_obs
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')
else:
df_merged = pd.merge(df_fcst, df_obs[['TargetDT', 'Obs']], on='TargetDT', how='inner')
all_dfs.append(df_merged)
if not all_dfs:
self.tbl_detail.setRowCount(0)
error_msg = "指定された期間・条件に合致する「予報データ」と「観測データ」のペアが見つかりませんでした。\n\n【原因例】\n・対象期間の予報がまだ抽出されていない\n・観測データが未取得\n・PDF評価の場合は、マッピングの「予報地点名」をPDFの「推定地域」と完全に一致させてください。"
QMessageBox.warning(self, "データ不足", error_msg)
return
df = pd.concat(all_dfs, ignore_index=True)
if not src_jma and 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:
self.tbl_detail.setRowCount(0)
return QMessageBox.warning(self, "通知", "発出期間の制限によりデータが0件になりました。")
df['Diff'] = df['Fcst'] - df['Obs']
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
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]
T1 = thresholds[0]
self.lbl_custom1_title.setText(f"【閾値 >= {T1} {unit}】")
A1 = len(df[(df['Fcst'] >= T1) & (df['Obs'] >= T1)]); B1 = len(df[(df['Fcst'] >= T1) & (df['Obs'] < T1)])
C1 = len(df[(df['Fcst'] < T1) & (df['Obs'] >= T1)]); D1 = len(df[(df['Fcst'] < T1) & (df['Obs'] < T1)])
tot1, hit1, pod1, far1, miss1, ts1 = calc_metrics(A1, B1, C1, D1)
self.lbl_custom1_stats.setText(f"全数: {tot1} / 的中(A): {A1}")
self.lbl_custom1_rates.setText(f"的中率: {hit1:.1f}% / 捕捉率: {pod1:.1f}% / 空振り率: {far1:.1f}% / 見逃し率: {miss1:.1f}% / TS: {ts1:.1f}%")
T2 = thresholds[1]
self.lbl_custom2_title.setText(f"【閾値 >= {T2} {unit}】")
A2 = len(df[(df['Fcst'] >= T2) & (df['Obs'] >= T2)]); B2 = len(df[(df['Fcst'] >= T2) & (df['Obs'] < T2)])
C2 = len(df[(df['Fcst'] < T2) & (df['Obs'] >= T2)]); D2 = len(df[(df['Fcst'] < T2) & (df['Obs'] < T2)])
tot2, hit2, pod2, far2, miss2, ts2 = calc_metrics(A2, B2, C2, D2)
self.lbl_custom2_stats.setText(f"全数: {tot2} / 的中(A): {A2}")
self.lbl_custom2_rates.setText(f"的中率: {hit2:.1f}% / 捕捉率: {pod2:.1f}% / 空振り率: {far2:.1f}% / 見逃し率: {miss2:.1f}% / TS: {ts2:.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(f"{r['Fcst']:.1f}"))
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)
self.tbl_detail.setItem(i, 6, QTableWidgetItem("的中" if (r['Fcst']>0 and r['Obs']>0) or (r['Fcst']==0 and r['Obs']==0) else "外れ"))
except Exception as e: QMessageBox.critical(self, "エラー", f"計算処理中にエラーが発生しました:\n{e}")
finally: self.btn_search.setText("精度評価を実行"); self.btn_search.setEnabled(True)
# ==========================================
# タブ6: 検証Excel出力
# ==========================================
def setup_tab6(self):
scroll, inner_widget = self.create_scroll_tab(); layout = QVBoxLayout(inner_widget); layout.setSpacing(15)
group_filter = QGroupBox("プロ仕様・検証レポート出力 (現在は最適モデルFCSTのみ対応)"); 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("【発表日】:")); 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_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_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("全地点一括出力対応。上部に指定期間等の情報、全体精度、および独自指標(1mm/10mm等)を記載します。")); 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_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())
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() == "降水量"
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])]
with pd.ExcelWriter(path, engine='openpyxl') as writer:
wrote_any = False
for obs_cd, fcst_name in mappings:
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' if is_rain else 'SnowfallP1'} AS Obs 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 = df_obs.resample('1h').sum()
if step_val > 1: df_obs_agg = df_obs.rolling(window=step_val, min_periods=1).sum()
else: df_obs_agg = df_obs
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']
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
rmse = np.sqrt((df['Diff(Fcst-Obs)']**2).mean())
me = df['Diff(Fcst-Obs)'].mean()
mae = df['Diff(Fcst-Obs)'].abs().mean()
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"
T1, T2 = 1.0, 10.0
A1 = len(df[(df['Fcst'] >= T1) & (df['Obs'] >= T1)]); B1 = len(df[(df['Fcst'] >= T1) & (df['Obs'] < T1)])
C1 = len(df[(df['Fcst'] < T1) & (df['Obs'] >= T1)]); D1 = len(df[(df['Fcst'] < T1) & (df['Obs'] < T1)])
tot1, hit1, pod1, far1, miss1, ts1 = calc_m(A1, B1, C1, D1)
A2 = len(df[(df['Fcst'] >= T2) & (df['Obs'] >= T2)]); B2 = len(df[(df['Fcst'] >= T2) & (df['Obs'] < T2)])
C2 = len(df[(df['Fcst'] < T2) & (df['Obs'] >= T2)]); D2 = len(df[(df['Fcst'] < T2) & (df['Obs'] < T2)])
tot2, hit2, pod2, far2, miss2, ts2 = calc_m(A2, B2, C2, D2)
pivot_fcst = df.pivot_table(index='IssueDT', columns='TargetDT', values='Fcst', aggfunc='mean')
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, columns=['発表日時'] + targets)
sheet_name = obs_cd.replace(":", "_")[:31]
out_df.to_excel(writer, sheet_name=sheet_name, startrow=24, index=False)
worksheet = writer.sheets[sheet_name]
worksheet.cell(row=1, column=1, value="【検証パラメータ】")
worksheet.cell(row=2, column=1, value=f"観測地点: {obs_cd}"); worksheet.cell(row=2, column=2, value=f"要素: {self.ex_cmb_element.currentText()}")
worksheet.cell(row=3, column=1, value=f"時間ステップ: {step_val}時間"); worksheet.cell(row=3, column=2, value=f"発出期間制限: {lead_time_txt}")
worksheet.cell(row=4, column=1, value=f"発表日期間: {d_i_from} ~ {d_i_to}")
worksheet.cell(row=5, column=1, value=f"対象日期間: {d_t_from} ~ {d_t_to}")
worksheet.cell(row=7, column=1, value="【全体精度 (>0.0)】")
worksheet.cell(row=8, column=1, value=f"RMSE: {rmse:.2f}"); worksheet.cell(row=8, column=2, value=f"ME: {me:.2f}"); worksheet.cell(row=8, column=3, value=f"MAE: {mae:.2f}")
worksheet.cell(row=9, column=1, value=f"全数: {tot0}"); worksheet.cell(row=9, column=2, value=f"的中回数(A): {A0}")
worksheet.cell(row=10, column=1, value=f"的中率: {hit0:.1f}%"); worksheet.cell(row=10, column=2, value=f"捕捉率: {pod0:.1f}%"); worksheet.cell(row=10, column=3, value=f"空振り率: {far0:.1f}%"); worksheet.cell(row=10, column=4, value=f"見逃し率: {miss0:.1f}%"); worksheet.cell(row=10, column=5, value=f"TS: {ts0:.1f}%")
worksheet.cell(row=12, column=1, value=f"【独自指標 1: 閾値 >= {T1} {unit}】")
worksheet.cell(row=13, column=1, value=f"全数: {tot1}"); worksheet.cell(row=13, column=2, value=f"的中回数(A): {A1}")
worksheet.cell(row=14, column=1, value=f"的中率: {hit1:.1f}%"); worksheet.cell(row=14, column=2, value=f"捕捉率: {pod1:.1f}%"); worksheet.cell(row=14, column=3, value=f"空振り率: {far1:.1f}%"); worksheet.cell(row=14, column=4, value=f"見逃し率: {miss1:.1f}%"); worksheet.cell(row=14, column=5, value=f"TS: {ts1:.1f}%")
worksheet.cell(row=16, column=1, value=f"【独自指標 2: 閾値 >= {T2} {unit}】")
worksheet.cell(row=17, column=1, value=f"全数: {tot2}"); worksheet.cell(row=17, column=2, value=f"的中回数(A): {A2}")
worksheet.cell(row=18, column=1, value=f"的中率: {hit2:.1f}%"); worksheet.cell(row=18, column=2, value=f"捕捉率: {pod2:.1f}%"); worksheet.cell(row=18, column=3, value=f"空振り率: {far2:.1f}%"); worksheet.cell(row=18, column=4, value=f"見逃し率: {miss2:.1f}%"); worksheet.cell(row=18, column=5, value=f"TS: {ts2:.1f}%")
worksheet.cell(row=23, column=1, value="▼ 詳細マトリクスデータ")
wrote_any = True
if wrote_any: QMessageBox.information(self, "完了", f"レポートを出力しました。\n保存先: {path}")
else: QMessageBox.warning(self, "データなし", "出力可能なデータがありませんでした。")
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())