未分類

# ==========================================
# 【App AMEPSガイダンス専用 GPV解析エンジン
# VERSION: MEPS_DEDICATED_4.1 (全21メンバー抽出最適化タイムアウト対応版)
# ==========================================
import sys, os, glob, re, subprocess
from datetime import datetime

os.environ['QT_API'] = 'pyqt6'
from PyQt6.QtWidgets import (QApplication, QWidget, QVBoxLayout, QHBoxLayout,
                             QPushButton, QLabel, QListWidget, QFileDialog, QMessageBox,
                             QSystemTrayIcon, QMenu)
from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal, QSettings
from PyQt6.QtGui import QIcon, QPixmap, QPainter, QAction

try:
    import cfgrib
except ImportError as e:
    print("\n" + "="*60)
    print("【起動エラー】cfgrib または依存ライブラリの読み込みに失敗しました。")
    print(f"エラー詳細: {e}")
    input("\nEnterキーを押すとウィンドウを閉じます...")
    sys.exit(1)

APP_DIR = os.getcwd()
DEFAULT_OUTPUT_DIR = os.path.join(APP_DIR, "gpv_cache_npz")
CURRENT_OUTPUT_DIR = DEFAULT_OUTPUT_DIR  
WGRIB2_EXE = os.path.join(APP_DIR, "wgrib2_data", "wgrib2.exe")

os.makedirs(CURRENT_OUTPUT_DIR, exist_ok=True)

def write_syslog(msg):
    log_file = os.path.join(CURRENT_OUTPUT_DIR, "meps_engine_log.txt")
    try:
        with open(log_file, "a", encoding="utf-8") as f:
            f.write(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}\n")
            f.flush()
            os.fsync(f.fileno())
    except Exception: pass

WORKER_SCRIPT = os.path.join(APP_DIR, "_meps_worker.py")

def create_lightning_icon():
    pixmap = QPixmap(64, 64); pixmap.fill(Qt.GlobalColor.transparent)
    painter = QPainter(pixmap); font = painter.font(); font.setPixelSize(50)
    painter.setFont(font); painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "🌀")
    painter.end()
    return QIcon(pixmap)

# ==========================================
# MEPS専用ワーカーコード (インベントリ解析全メンバー結合最適化ロジック)
# ==========================================
WORKER_CODE = """
import sys, traceback

def main():
    try:
        import os, gc, subprocess, time
        import numpy as np
        import warnings
        import cfgrib
        import re
        os.environ["ECCODES_MAX_VALUES"] = "5000000"

        if sys.platform == "win32":
            conda_dir = os.path.dirname(sys.executable)
            dll_paths = [os.path.join(conda_dir, "Library", "bin"), os.path.join(conda_dir, "bin")]
            for p in dll_paths:
                if os.path.exists(p):
                    os.environ["PATH"] = f"{p};{os.environ.get('PATH', '')}"
                    try: os.add_dll_directory(p)
                    except Exception: pass

        model = sys.argv[1]; mode = sys.argv[2]; cache = sys.argv[3]
        init = sys.argv[4]; target_fts_str = sys.argv[5]
        
        f_pall = sys.argv[6]; f_prrst = sys.argv[7]; wgrib2_path = sys.argv[8]
       
        target_fts = [int(x) for x in target_fts_str.split(',')]
        
        d_max = {ft: {} for ft in target_fts}; d_min = {ft: {} for ft in target_fts}
        d_mean = {ft: {} for ft in target_fts}; d_p25 = {ft: {} for ft in target_fts}; d_p75 = {ft: {} for ft in target_fts}
        
        creationflags = 0x08000000 if sys.platform == "win32" else 0

        # 【最適化ファイルごとに1回だけインベントリを取得し指定された全FTを処理する設計に変更
        def process_file_optimized(fin, src_name, target_fts):
            if fin == "NONE" or not os.path.exists(fin): return

            try:
                # 【安定性向上タイムアウト120秒を設定
                cmd_inv = [wgrib2_path, fin, "-s"]
                res = subprocess.run(cmd_inv, capture_output=True, text=True, creationflags=creationflags, timeout=120)
            except subprocess.TimeoutExpired:
                print(f"WARN: wgrib2 inventory timeout for {fin}")
                return

            if not res.stdout.strip(): return

            parsed_lines = []
            for line in res.stdout.strip().split('\\n'):
                if not line: continue
                parts = line.split(':')
                if len(parts) < 6: continue
                
                param = parts[3]
                time_str = parts[5]
                
                m = re.search(r'([0-9]+)-([0-9]+)\\s+(hour|day)', time_str)
                if not m:
                    m2 = re.search(r'([0-9]+)\\s+(hour|day)', time_str)
                    if m2:
                        end_val = int(m2.group(1))
                        unit = m2.group(2)
                        if unit == 'day': end_val *= 24
                        start_val = end_val
                    else: continue
                else:
                    start_val = int(m.group(1))
                    end_val = int(m.group(2))
                    unit = m.group(3)
                    if unit == 'day':
                        start_val *= 24; end_val *= 24
                        
                duration = end_val - start_val
                if duration == 1: mapped_ft = start_val + 3
                else: mapped_ft = end_val
                    
                if mapped_ft not in target_fts: continue 
                    
                trange_str = f"{start_val}_{end_val}"
                step_type = "accum"
                if " max " in time_str: step_type = "max"
                elif " min " in time_str: step_type = "min"
                elif " ave " in time_str: step_type = "ave"
                elif "inst" in time_str: step_type = "inst"
                
                var_name = param.lower()
                if param == "TPRATE": var_name = "tp"
                elif param == "TSRATE": var_name = "asnow"
                elif param == "TSTM": var_name = "tstm"
                
                parsed_lines.append((mapped_ft, (var_name, step_type, trange_str, duration), line))
                
            for t_ft in target_fts:
                ft_lines = [item for item in parsed_lines if item[0] == t_ft]
                if not ft_lines: continue
                
                grouped = {}
                for _, meta, line in ft_lines:
                    if meta not in grouped: grouped[meta] = []
                    grouped[meta].append(line)
                    
                for meta, group_lines in grouped.items():
                    var_name, step_type, trange_str, duration = meta
                    inv_input = "\\n".join(group_lines) + "\\n"
                    temp_bin = os.path.join(cache, f"~tmp_{model}_{init}_{t_ft}_{src_name}_{var_name}_{trange_str}.bin")
                    
                    try:
                        # 【安定性向上抽出処理にもタイムアウト120秒を設定
                        cmd_extract = [wgrib2_path, fin, "-i", "-grib", temp_bin]
                        process = subprocess.Popen(cmd_extract, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, creationflags=creationflags)
                        process.communicate(input=inv_input.encode('utf-8'), timeout=120)
                    except subprocess.TimeoutExpired:
                        process.kill()
                        print(f"WARN: wgrib2 extraction timeout for {temp_bin}")
                        continue
                        
                    if os.path.exists(temp_bin) and os.path.getsize(temp_bin) > 0:
                        try:
                            # cfgribの次元圧縮問題を回避するため返されるすべてのDatasetをリスト化する
                            dss = cfgrib.open_datasets(temp_bin, backend_kwargs={'indexpath': ''})
                            
                            all_fields = []
                            lon_1d, lat_1d = None, None

                            for ds in dss:
                                if lon_1d is None:
                                    lon = ds.coords['longitude'].values if 'longitude' in ds.coords else (ds.longitude.values if hasattr(ds, 'longitude') else None)
                                    lat = ds.coords['latitude'].values if 'latitude' in ds.coords else (ds.latitude.values if hasattr(ds, 'latitude') else None)
                                    if lon is not None and lat is not None:
                                        lon_1d = lon[0, :] if lon.ndim == 2 else lon
                                        lat_1d = lat[:, 0] if lat.ndim == 2 else lat

                                for v in ds.data_vars:
                                    da = ds[v]
                                    da_step = da.isel(step=0) if 'step' in da.dims else da
                                    val = da_step.values.copy()
                                    
                                    # 【精度向上分割されたメンバー(Control, Pos, Neg等)をすべて手動で1つのリストに集約(全21メンバーを保証)
                                    if 'number' in da_step.dims:
                                        num_axis = da_step.dims.index('number')
                                        val = np.moveaxis(val, num_axis, 0)
                                        for i in range(val.shape[0]):
                                            all_fields.append(val[i])
                                    else:
                                        all_fields.append(val)
                                        
                            for ds in dss: ds.close()
                            
                            if all_fields and lon_1d is not None and lat_1d is not None:
                                # 集約した全メンバーをスタックし、(N, lat, lon) の形状にする
                                stacked_val = np.stack(all_fields, axis=0)
                                
                                FIXED_KEY_MAP = {
                                    ("tstm", "pall", None):   "thund",
                                    ("tp",   "pall", 3):      "precip",
                                    ("tp",   "prrsf", 1):     "precip1max",
                                    ("tp",   "prrsf", 3):     "precip3max",
                                    ("tp",   "prrsf", 24):    "precip24max",
                                    ("asnow","prrsf", 3):     "snow3",
                                    ("asnow","prrsf", 6):     "snow6",
                                    ("asnow","prrsf", 12):    "snow12",
                                    ("asnow","prrsf", 24):    "snow24",
                                }
                                lookup_dur = None if var_name == "tstm" else duration
                                fixed_key = FIXED_KEY_MAP.get((var_name, src_name, lookup_dur))
                                
                                expected_start = max(0, t_ft - (duration if duration else 3))
                                expected_dynamic_key = f"{var_name}_{src_name}_{step_type}_{expected_start}_{t_ft}"
                                
                                # 【ファイルサイズ最適化マルチエイリアスを廃止しビューワーで読める単一の固定キー名のみ保存
                                aliases = [fixed_key] if fixed_key else [expected_dynamic_key]
                                
                                grid_size = lon_1d.size * lat_1d.size
                                
                                def set_coords(d_dict, keys):
                                    for key in keys:
                                        d_dict[f'lon_{key}'] = lon_1d.copy()
                                        d_dict[f'lat_{key}'] = lat_1d.copy()
                                    if grid_size > 100000:
                                        d_dict['lon'] = lon_1d.copy()
                                        d_dict['lat'] = lat_1d.copy()
                                    else:
                                        if 'lon' not in d_dict:
                                            d_dict['lon'] = lon_1d.copy()
                                            d_dict['lat'] = lat_1d.copy()

                                for d_dict in [d_max[t_ft], d_min[t_ft], d_mean[t_ft], d_p25[t_ft], d_p75[t_ft]]:
                                    set_coords(d_dict, aliases)
                                    
                                with warnings.catch_warnings():
                                    warnings.simplefilter("ignore", category=RuntimeWarning)
                                    # 集約した全21メンバー(stacked_val)に対して正しく統計を算出
                                    mean_val = np.nanmean(stacked_val, axis=0)
                                    max_val = np.nanmax(stacked_val, axis=0)
                                    min_val = np.nanmin(stacked_val, axis=0)
                                    p75_val = np.nanpercentile(stacked_val, 75, axis=0)
                                    p25_val = np.nanpercentile(stacked_val, 25, axis=0)

                                for key in aliases:
                                    d_mean[t_ft][key] = mean_val
                                    d_max[t_ft][key] = max_val
                                    d_min[t_ft][key] = min_val
                                    d_p75[t_ft][key] = p75_val
                                    d_p25[t_ft][key] = p25_val

                        except Exception as e:
                            print(f"WARN: Error processing group {meta}: {e}")
                        finally:
                            gc.collect()
                            try: os.remove(temp_bin)
                            except: pass

        # ファイル単位で一度に全FTを処理
        process_file_optimized(f_pall, "pall", target_fts)
        process_file_optimized(f_prrst, "prrsf", target_fts)

        for t_ft in target_fts:
            if not d_max[t_ft]: continue

            stat_dicts = {
                "MAX": d_max[t_ft], "MIN": d_min[t_ft], "MEAN": d_mean[t_ft],
                "P25": d_p25[t_ft], "P75": d_p75[t_ft]
            }

            for stat_name, data_dict in stat_dicts.items():
                if not data_dict: continue
                final_filepath = os.path.join(cache, f"{model}_GUID_{stat_name}_{init}_FT{t_ft:02d}.npz")
                temp_filepath = os.path.join(cache, f"~tmp_{model}_GUID_{stat_name}_{init}_FT{t_ft:02d}.npz")
                
                try:
                    np.savez_compressed(temp_filepath, **data_dict)
                    success = False
                    for _ in range(10):
                        try:
                            os.replace(temp_filepath, final_filepath)
                            success = True
                            break
                        except PermissionError: time.sleep(0.5) 
                    
                    if not success:
                        try: os.remove(final_filepath)
                        except Exception: pass
                        np.savez_compressed(final_filepath, **data_dict)
                    
                    try:
                        if os.path.exists(temp_filepath): os.remove(temp_filepath)
                    except Exception: pass
                except Exception: pass

            print(f"SUCCESS:{t_ft}", flush=True)

    except BaseException as e:
        print(f"CRITICAL_ERROR: {traceback.format_exc()}", flush=True)
        sys.exit(1)

if __name__ == '__main__': main()
"""

try:
    with open(WORKER_SCRIPT, "w", encoding="utf-8") as f: f.write(WORKER_CODE)
except Exception: pass

class DataParserThread(QThread):
    log_signal = pyqtSignal(str); progress_signal = pyqtSignal(int, str); finished_signal = pyqtSignal(bool)
    
    def __init__(self, folder_paths, output_dir, scan_mode="48h"):
        super().__init__()
        self.folder_paths = [path for path in folder_paths if path and os.path.exists(path)]
        self.output_dir = output_dir
        self.scan_mode = scan_mode
        self.abort = False
    
    def run(self):
        if not self.folder_paths: self.finished_signal.emit(False); return
        if not os.path.exists(WGRIB2_EXE):
            self.log_signal.emit(f"⚠️ wgrib2が見つかりません。"); self.finished_signal.emit(False); return

        has_new_data = False; scan_started = False
        creationflags = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
        conda_dir = os.path.dirname(sys.executable); lib_bin = os.path.join(conda_dir, "Library", "bin")
        env = os.environ.copy(); env["PATH"] = f"{lib_bin};{env.get('PATH', '')}"; env["PYTHONIOENCODING"] = "utf-8"
        
        now_utc = datetime.utcnow()

        for folder_path in self.folder_paths:
            if self.abort: break
            
            all_grib_files = []
            for ext in ['*.bin', '*.grib2', '*.grb']:
                all_grib_files.extend(glob.glob(os.path.join(folder_path, '**', f'*MEPS*{ext}'), recursive=True))

            pall_files = []
            prrst_files = []
            
            for f in all_grib_files:
                basename = os.path.basename(f).lower()
                if 'p-all' in basename or 'pall' in basename:
                    pall_files.append(f)
                elif 'prrst' in basename or 'prrsf' in basename:
                    prrst_files.append(f)
            
            def get_all_init_groups(files_list):
                groups = {}
                for f in files_list:
                    m = re.search(r'_(\d{14})_', os.path.basename(f))
                    if m:
                        init = m.group(1)
                        if self.scan_mode == "48h":
                            try:
                                file_time = datetime.strptime(init, '%Y%m%d%H%M%S')
                                if (now_utc - file_time).total_seconds() > 48 * 3600:
                                    continue
                            except Exception: pass
                        groups[init] = f
                return groups

            pall_groups = get_all_init_groups(pall_files)
            prrst_groups = get_all_init_groups(prrst_files)
            
            all_inits = set(pall_groups.keys()) | set(prrst_groups.keys())
            target_ft_list = list(range(3, 40, 3))
            execution_plan = []

            for init_time_str in all_inits:
                f_pall = pall_groups.get(init_time_str, "NONE")
                f_prrst = prrst_groups.get(init_time_str, "NONE")
                execution_plan.append((init_time_str, "MEPS", f_pall, f_prrst, target_ft_list, "GUID"))
            
            execution_plan.sort(key=lambda x: x[0], reverse=True)

            for init_time_str, model_name, f_pall, f_prrst, ft_list, mode in execution_plan:
                if self.abort: break
                
                tasks = []
                for ft in ft_list:
                    files_exist = all(os.path.exists(os.path.join(self.output_dir, f"{model_name}_GUID_{stat}_{init_time_str}_FT{ft:02d}.npz"))
                                      for stat in ["MAX", "MIN", "MEAN", "P25", "P75"])
                    if not files_exist: tasks.append(ft)

                if not tasks: continue

                if not scan_started:
                    self.log_signal.emit("🔍 MEPS アンサンブルデータの抽出を開始中..."); scan_started = True; has_new_data = True

                if self.abort: break
                self.log_signal.emit(f"🚀 {model_name} アンサンブル ({init_time_str}) 抽出開始 (対象: {len(tasks)}FT)...")

                total_extracted = 0
                for i in range(0, len(tasks), 3):
                    if self.abort: break
                    chunk_fts = tasks[i:i + 3]; ft_str = ",".join(map(str, chunk_fts))
                    cmd = [sys.executable, WORKER_SCRIPT, model_name, mode, self.output_dir, init_time_str, ft_str, f_pall, f_prrst, WGRIB2_EXE]
                    process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding='utf-8', errors='replace', creationflags=creationflags, env=env)
                    
                    for line in iter(process.stdout.readline, ''):
                        if self.abort:
                            try: process.kill()
                            except: pass
                            break
                            
                        line = line.strip()
                        if not line: continue
                        if line.startswith("SUCCESS:"):
                            ft_success = line.split(":")[1]; total_extracted += 1
                            self.progress_signal.emit(int((total_extracted / len(tasks)) * 100), f"⚡ {model_name} {init_time_str} FT={ft_success}h...")
                            self.log_signal.emit(f"⚡ 抽出完了: {model_name} {init_time_str} FT={ft_success}h (MAX/MIN/MEAN/P25/P75)")
                        elif "WARN:" in line or "CRITICAL_ERROR:" in line or "Traceback" in line or "Error:" in line or "File " in line:
                            self.log_signal.emit(f"⚠️ {line}")
                    
                    try:
                        process.stdout.close(); process.wait()
                    except: pass

        if not self.abort and has_new_data:
            self.log_signal.emit("✅ 全てのMEPSスキャン・抽出が完了しました")
        self.finished_signal.emit(has_new_data)
        
class EngineStatusWindow(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("MEPS ガイダンス専用 解析エンジン V4.1 (結合最適化版)")
        self.setFixedSize(650, 620)
        self.setStyleSheet("""
            QWidget { background-color: #1A1A24; color: #E0E0E0; font-family: 'MS Gothic'; font-size: 11pt; }
            QLabel { color: #00FFCC; font-weight: bold; }
            QPushButton { background-color: #2C3E50; color: white; padding: 6px; border-radius: 4px; font-weight: bold; border: 1px solid #34495E; }
            QPushButton:hover { background-color: #34495E; }
            QListWidget { background-color: #111118; border: 1px solid #34495E; color: #00FFCC; padding: 5px; font-family: Consolas, monospace; font-size: 10pt; }
        """)
        
        self.is_quitting = False
        
        layout = QVBoxLayout(self)
        self.status_label = QLabel("状態: 起動中..."); self.status_label.setStyleSheet("font-size: 14pt;")
        layout.addWidget(self.status_label)
        
        control_layout = QHBoxLayout()
        self.pause_btn = QPushButton("⏹ 抽出を即時停止")
        self.pause_btn.setStyleSheet("background-color: #C0392B;")
        self.resume_btn = QPushButton("▶ 手動強制スキャン")
        self.mode_btn = QPushButton("🔄 モード: 最新48時間 (推奨)")
        self.mode_btn.setStyleSheet("background-color: #D35400;")
        
        for btn in [self.pause_btn, self.resume_btn, self.mode_btn]: control_layout.addWidget(btn)
        self.clear_btn = QPushButton("🧹 キャッシュ全削除"); self.clear_btn.setStyleSheet("background-color: #8E44AD;")
        control_layout.addWidget(self.clear_btn)
        layout.addLayout(control_layout)

        out_layout = QHBoxLayout()
        self.out_folder_btn = QPushButton("💾 出力先フォルダを設定"); self.out_folder_btn.setStyleSheet("background-color: #2980B9;")
        out_layout.addWidget(self.out_folder_btn)
        self.out_folder_label = QLabel("未設定"); self.out_folder_label.setStyleSheet("background: #111118; padding: 4px; border: 1px solid #34495E; color: #8892B0;")
        out_layout.addWidget(self.out_folder_label, stretch=1)
        layout.addLayout(out_layout)
        
        self.log_list = QListWidget(); layout.addWidget(self.log_list)
        self.copy_btn = QPushButton("📋 ログをコピー"); self.copy_btn.setStyleSheet("background-color: #16A085;"); layout.addWidget(self.copy_btn)
        
        folder_layout = QHBoxLayout()
        self.folder_btns = [QPushButton(f"📁 読込元フォルダ {i+1}") for i in range(3)]
        for b in self.folder_btns: folder_layout.addWidget(b)
        layout.addLayout(folder_layout)
        
        self.exit_btn = QPushButton("システムを完全に終了"); self.exit_btn.setStyleSheet("background-color: #8b0000;"); layout.addWidget(self.exit_btn)

    def log(self, message):
        self.log_list.addItem(f"[{datetime.now().strftime('%H:%M:%S')}] {message}")
        self.log_list.scrollToBottom(); write_syslog(message)

    def closeEvent(self, event):
        if getattr(self, 'is_quitting', False): event.accept()
        else:
            event.ignore(); self.hide()
            if hasattr(self, 'tray_msg_callback'): self.tray_msg_callback()

class TrayApp(QApplication):
    def __init__(self, sys_argv):
        super().__init__(sys_argv)
        self.setQuitOnLastWindowClosed(False)
        self.settings = QSettings("SapporoWeatherApp", "MEPSEngine")
        self.monitor_folders = [self.settings.value(f"watch_dir_{i+1}", "") for i in range(3)]
        
        global CURRENT_OUTPUT_DIR
        self.output_dir = self.settings.value("output_dir", DEFAULT_OUTPUT_DIR)
        CURRENT_OUTPUT_DIR = self.output_dir
        os.makedirs(self.output_dir, exist_ok=True)

        self.scan_mode = "48h"
        self.window = EngineStatusWindow()
        self.window.tray_msg_callback = self.show_tray_message
        
        self.tray_icon = QSystemTrayIcon(self)
        self.tray_icon.setIcon(create_lightning_icon())
        self.tray_icon.setToolTip("MEPSエンジン (稼働中)")
        
        tray_menu = QMenu(); show_action = QAction("ウィンドウを表示"); show_action.triggered.connect(self.restore_window)
        quit_action = QAction("完全に終了する"); quit_action.triggered.connect(self.quit_app)
        
        tray_menu.addAction(show_action); tray_menu.addAction(quit_action)
        self.tray_icon.setContextMenu(tray_menu); self.tray_icon.activated.connect(self.tray_icon_activated); self.tray_icon.show()
        
        self.window.log("===============================")
        self.window.log("MEPS専用エンジン起動 V4.1")
        self.window.log("===============================")
        
        self.window.out_folder_label.setText(self.output_dir)
        self.window.out_folder_btn.clicked.connect(self.select_output_folder)

        for i in range(3):
            self.window.folder_btns[i].clicked.connect(lambda checked, idx=i: self.select_folder(idx))
            self.update_folder_btn_text(i)

        self.window.copy_btn.clicked.connect(self.copy_logs)
        self.window.pause_btn.clicked.connect(self.stop_and_pause)
        self.window.resume_btn.clicked.connect(self.force_scan)
        self.window.mode_btn.clicked.connect(self.toggle_mode)
        
        self.window.clear_btn.clicked.connect(self.clear_cache)
        self.window.exit_btn.clicked.connect(self.quit_app)
        
        self.timer = QTimer(); self.timer.timeout.connect(self.run_parser); self.timer.start(15000)
        self.is_processing = False; self.run_parser(); self.window.show()

    def kill_current_worker(self):
        if hasattr(self, 'worker') and self.worker.isRunning():
            self.window.log("⚠️ 抽出処理を中断しています...")
            self.worker.abort = True
        self.is_processing = False

    def tray_icon_activated(self, reason):
        if reason == QSystemTrayIcon.ActivationReason.DoubleClick: self.restore_window()

    def restore_window(self):
        self.window.showNormal(); self.window.activateWindow()

    def show_tray_message(self):
        self.tray_icon.showMessage("MEPSエンジン待機中", "裏で抽出を続けます。", QIcon(self.tray_icon.icon()), 3000)

    def quit_app(self):
        self.kill_current_worker()
        self.window.is_quitting = True
        self.quit()

    def stop_and_pause(self):
        self.timer.stop(); self.kill_current_worker()
        self.window.log("⏸ 処理を即時停止しました。")
        self.window.status_label.setText("状態: 停止中")

    def toggle_mode(self):
        self.kill_current_worker()
        if self.scan_mode == "48h":
            self.scan_mode = "all"
            self.window.mode_btn.setText("🔄 モード: すべてのデータ"); self.window.mode_btn.setStyleSheet("background-color: #27AE60;")
        else:
            self.scan_mode = "48h"
            self.window.mode_btn.setText("🔄 モード: 最新48時間 (推奨)"); self.window.mode_btn.setStyleSheet("background-color: #D35400;")
        self.run_parser()

    def select_output_folder(self):
        global CURRENT_OUTPUT_DIR
        folder = QFileDialog.getExistingDirectory(self.window, "出力先フォルダ", self.output_dir)
        if folder:
            self.kill_current_worker()
            self.output_dir = folder; CURRENT_OUTPUT_DIR = folder; self.settings.setValue("output_dir", folder)
            self.window.out_folder_label.setText(folder)
            self.run_parser()

    def clear_cache(self):
        reply = QMessageBox.question(self.window, "確認", "MEPSのキャッシュを削除しますか?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
        if reply == QMessageBox.StandardButton.Yes:
            self.timer.stop(); self.kill_current_worker()
            for f in glob.glob(os.path.join(self.output_dir, "MEPS_*.npz")):
                try: os.remove(f)
                except: pass
            self.run_parser(); self.timer.start(15000)

    def select_folder(self, index):
        folder = QFileDialog.getExistingDirectory(self.window, "読込元フォルダ選択")
        if folder:
            self.kill_current_worker()
            self.monitor_folders[index] = folder; self.settings.setValue(f"watch_dir_{index+1}", folder)
            self.update_folder_btn_text(index)
            self.run_parser()

    def update_folder_btn_text(self, i):
        f = self.monitor_folders[i]
        self.window.folder_btns[i].setText(f"📁 {os.path.basename(f)}" if f else f"📁 未設定 {i+1}")
    
    def copy_logs(self): QApplication.clipboard().setText("\n".join([self.window.log_list.item(i).text() for i in range(self.window.log_list.count())]))
    
    def force_scan(self):
        self.timer.start(15000); self.kill_current_worker(); self.run_parser()

    def run_parser(self):
        if self.is_processing: return
        if not any(self.monitor_folders): self.window.status_label.setText("状態: 読込元フォルダ未設定"); return
        self.is_processing = True
        
        self.worker = DataParserThread(self.monitor_folders, self.output_dir, self.scan_mode)
        self.worker.log_signal.connect(self.window.log); self.worker.progress_signal.connect(lambda v, t: self.window.status_label.setText(f"状態: {t}"))
        self.worker.finished_signal.connect(self.on_parse_finished)
        self.worker.start()

    def on_parse_finished(self, has_new_data):
        self.is_processing = False
        if not hasattr(self.worker, 'abort') or not self.worker.abort:
            self.window.status_label.setText("状態: 待機中")

if __name__ == '__main__':
    import multiprocessing
    multiprocessing.freeze_support()
    
    if len(sys.argv) > 1 and '_meps_worker.py' in sys.argv[1]:
        sys.argv = sys.argv[1:]
        try:
            with open(sys.argv[0], "r", encoding="utf-8") as f:
                worker_code = f.read()
            exec(worker_code, {'__name__': '__main__'})
        except Exception: pass
        sys.exit(0)

    app = TrayApp(sys.argv); sys.exit(app.exec())