未分類

==========================================

【App A】 GPV解析・キャッシュエンジン (Ver 104.0 究極解放版)

FIX: ガイダンスの正規表現バグ解消・マルチグリッド解像度対応・気象庁独自コード対応

==========================================

import sys, os, glob, re, subprocess, queue, time
from datetime import datetime
import multiprocessing as mp

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: sys.exit(“エラー: cfgribがインストールされていません。”)

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, “system_log.txt”)
try:
with open(log_file, “a”, encoding=”utf-8″) as f:
f.write(f”[{datetime.now().strftime(‘%H:%M:%S’)}] {msg}\n”)
except Exception: pass

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)

==========================================

【裏方ワーカー】 抽出・解析コア

==========================================

def extract_gpv_worker(model, mode, cache, init, target_fts, f1, f2, wgrib2_path, q):
try:
import os, sys, subprocess, warnings
import numpy as np
import cfgrib
warnings.filterwarnings(“ignore”)
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

    def safe_max(arr):
        return np.nanmax(arr) if not np.all(np.isnan(arr)) else 0.0

    d_all = {ft: {} for ft in target_fts}
    creationflags = 0x08000000 if sys.platform == "win32" else 0

    for t_ft in target_fts:
        d = d_all[t_ft]

        def slice_with_wgrib2(fin, ft_val, suffix):
            if fin == "NONE": return "NONE"
            fout = os.path.join(cache, f"temp_{model}_{mode}_{init}_{ft_val}_{suffix}.bin")
            d_str = f"{ft_val//24} day" if ft_val % 24 == 0 else "INVALID"

            # 💡 修正1: 悪さをしていたエスケープを排除し、シンプルで強力なマッチングに
            if mode == "ANAL":
                match_str = ":(anl):"
            elif ft_val == 0:
                match_str = ":(anl|0 hour |[0-9]+-0 hour )"
            else:
                match_str = f":([0-9]+-)?({ft_val} hour|{d_str}) "

            cmd = [wgrib2_path, fin, "-match", match_str, "-grib", fout]
            try:
                subprocess.run(
                    cmd, creationflags=creationflags, check=True, 
                    stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL
                )
                if os.path.exists(fout) and os.path.getsize(fout) > 0: return fout
            except Exception: pass
            return "NONE"

        f1_mini = slice_with_wgrib2(f1, t_ft, "1")
        t_ft_p = (t_ft // 3) * 3 if model == "MSM" else t_ft
        f2_mini = slice_with_wgrib2(f2, t_ft_p, "2")

        def process_file(filepath, is_pall):
            if filepath == "NONE": return
            try:
                dss = []
                try:
                    dss = cfgrib.open_datasets(filepath, backend_kwargs={'indexpath': ''})
                except Exception:
                    import xarray as xr
                    for stype in ['instant', 'accum', 'avg', 'max', 'min']:
                        try:
                            ds_sub = xr.open_dataset(filepath, engine='cfgrib', backend_kwargs={'indexpath': '', 'filter_by_keys': {'stepType': stype}})
                            dss.append(ds_sub)
                        except: pass
                    if not dss:
                        try:
                            dss.append(xr.open_dataset(filepath, engine='cfgrib', backend_kwargs={'indexpath': ''}))
                        except: pass

                for ds in dss:
                    lon = ds['longitude'].values if 'longitude' in ds.coords else (ds['lon'].values if 'lon' in ds.coords else None)
                    lat = ds['latitude'].values if 'latitude' in ds.coords else (ds['lat'].values if 'lat' in ds.coords else None)

                    for v in ds.data_vars:
                        da = ds[v]
                        is_ens = 'number' in da.dims

                        da_step = da.isel(step=0) if 'step' in da.dims else da
                        if is_ens and 'number' in da_step.coords: da_step = da_step.isel(number=0)

                        grib_short = str(da.attrs.get('GRIB_shortName', '')).lower()
                        sName = grib_short if grib_short and grib_short != 'unknown' else str(v).lower()

                        attrs_str = str(da.attrs).lower()
                        c_num = da.attrs.get('GRIB_parameterCategory', -1)
                        p_num = da.attrs.get('GRIB_parameterNumber', -1)

                        # 💡 修正2: 整数型でも確実に2次元まで削る(エラー源を根絶)
                        val_2d = da_step.values.copy()
                        while val_2d.ndim > 2:
                            val_2d = val_2d[0]

                        # 💡 修正3: サイズが違うグリッド(降水560x480 vs 発雷141x121)の座標衝突を防ぐ
                        if lon is not None and lat is not None:
                            shape_key = f"{val_2d.shape[0]}x{val_2d.shape[1]}"
                            d[f'lon_{shape_key}'] = lon
                            d[f'lat_{shape_key}'] = lat

                            # メインのlon/latは「一番解像度が高い(要素数が多い)もの」を優先して保持
                            if 'lon' not in d or lon.size > d['lon'].size:
                                d['lon'] = lon
                                d['lat'] = lat
                                d['lon_surf'] = lon
                                d['lat_surf'] = lat
                                if mode == "GUID":
                                    d['lon_guid'] = lon
                                    d['lat_guid'] = lat

                        # --- GUIDANCE モードの処理 ---
                        if mode == "GUID":
                            # 基本名で保存
                            if sName != 'unknown': 
                                d[sName] = val_2d
                            else: 
                                d[f"var_{c_num}_{p_num}"] = val_2d

                            # 💡 修正4: 気象庁独自のカテゴリー191(天気)等を正確にマッピング
                            if sName == 'unknown' or (c_num == 191 and p_num == 192) or 'weather' in attrs_str:
                                d['wea'] = val_2d
                            elif sName in ['tp', 'apcp', 'precip', 'tprate'] or (c_num == 1 and p_num in [8, 52]):
                                if 'precip' not in d: d['precip'] = np.nan_to_num(val_2d, nan=0.0)
                            elif sName in ['asnow', 'weasd', 'snod', 'snow', 'tsrate']:
                                d['snow'] = val_2d
                            elif sName in ['tstm', 'thund'] or 'prob' in attrs_str:
                                d['thund'] = val_2d
                            elif sName in ['min_vis', 'vis', 'visibility']:
                                d['vis'] = val_2d
                            continue

                        # --- 以下、GPV / ANAL モード用 ---
                        # (GPV用の処理は変更なしのため省略せずにそのまま残します)
                        level_dim_name = None
                        for coord_key in ['isobaricInhPa', 'isobaricInPa', 'level']:
                            if coord_key in da_step.dims:
                                level_dim_name = coord_key; break

                        if level_dim_name and da_step[level_dim_name].size > 1:
                            levels = da_step[level_dim_name].values
                            for i, lvl_raw in enumerate(levels):
                                lvl = float(lvl_raw)
                                if level_dim_name == 'isobaricInPa': lvl /= 100.0
                                lvl = int(lvl)
                                if lvl <= 0: continue

                                val_l = da_step.with_isobaricInhPa(i).values.copy() if hasattr(da_step, 'with_isobaricInhPa') else da_step.isel(**{level_dim_name: i}).values.copy()
                                while val_l.ndim > 2: val_l = val_l[0]

                                if is_pall or mode == "ANAL":
                                    if sName in ['u', 'u-component']: d[f'u{lvl}'] = val_l
                                    elif sName in ['v', 'v-component']: d[f'v{lvl}'] = val_l
                                    elif sName in ['t', 'temp']: d[f't{lvl}'] = val_l - 273.15 if safe_max(val_l) > 150 else val_l
                                    elif sName in ['r', 'rh']: d[f'rh{lvl}'] = val_l
                                    elif sName in ['gh', 'hgt']: d[f'gh{lvl}'] = val_l
                                    elif sName in ['w', 'vvel', 'dzdt']: d[f'w{lvl}'] = val_l
                                    elif sName in ['absv', 'vort']: d[f'vort{lvl}'] = val_l
                        else:
                            level_val = -1.0
                            for coord_key in ['isobaricInhPa', 'isobaricInPa', 'level']:
                                if coord_key in da_step.coords:
                                    val_coord = np.atleast_1d(da_step.coords[coord_key].values)
                                    if val_coord.size == 1:
                                        level_val = float(val_coord[0])
                                        if coord_key == 'isobaricInPa': level_val /= 100.0
                                    break
                            if level_val < 0: level_val = float(da_step.attrs.get('GRIB_level', -1))

                            if mode == "ANAL" or sName.startswith("param_"):
                                d[sName] = val_2d

                            if not is_pall or mode == "ANAL":
                                is_precip = sName in ['tp', 'apcp', 'pr', 'precip', 'prate'] or 'precip' in attrs_str or 'accum' in str(da_step.coords).lower()
                                if is_precip:
                                    if 'precip' not in d: d['precip'] = np.nan_to_num(val_2d, nan=0.0)
                                    continue

                                if sName in ['prmsl', 'msl', 'mslet']: d['slp'] = val_2d / 100.0 if safe_max(val_2d) > 2000 else val_2d
                                elif sName in ['pres', 'sp'] or 'pressure' in attrs_str: d['pres'] = val_2d / 100.0 if safe_max(val_2d) > 2000 else val_2d
                                elif sName in ['10u', 'u10'] or ('u' in sName and '10' in attrs_str): d['u10'] = val_2d
                                elif sName in ['10v', 'v10'] or ('v' in sName and '10' in attrs_str): d['v10'] = val_2d
                                elif sName in ['2t', 't2m', 'tmp2m'] or 'temperature' in attrs_str: d['t2m'] = val_2d - 273.15 if safe_max(val_2d) > 150 else val_2d
                                elif sName in ['2r', 'rh2m', 'rh'] or 'humidity' in attrs_str: d['rh2m'] = val_2d
                                elif sName in ['tcc', 'tcdc']: d['tcc'] = val_2d
                                elif sName in ['hcc', 'hcdc']: d['hcc'] = val_2d
                                elif sName in ['mcc', 'mcdc']: d['mcc'] = val_2d
                                elif sName in ['lcc', 'lcdc']: d['lcc'] = val_2d

                for ds in dss: ds.close()
            except Exception: pass

        process_file(f1_mini, False)
        process_file(f2_mini, True)

        pfx = "GUID_" if mode == "GUID" else ""
        out_file = os.path.join(cache, f"{model}_{pfx}{init}_FT{t_ft:02d}.npz")

        # データが1つでも入っていれば保存する
        if len([k for k in d.keys() if not k.startswith('lon') and not k.startswith('lat')]) > 0:
            np.savez_compressed(out_file, **d)
            q.put(("SUCCESS", f"{mode}_{t_ft}"))

        try:
            if f1_mini != "NONE" and os.path.exists(f1_mini): os.remove(f1_mini)
            if f2_mini != "NONE" and os.path.exists(f2_mini): os.remove(f2_mini)
        except Exception: pass

except Exception as e:
    import traceback
    q.put(("ERROR", str(e) + "\n" + traceback.format_exc()))

==========================================

スキャンスレッド

==========================================

class DataParserThread(QThread):
log_signal = pyqtSignal(str); progress_signal = pyqtSignal(int, str); finished_signal = pyqtSignal(bool)
def init(self, folder_paths, output_dir, last_reported):
super().init()
self.folder_paths = [path for path in folder_paths if path and os.path.exists(path)]
self.output_dir = output_dir
self.last_reported = last_reported; 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; reported_new_file = False

    scan_rules = [
        ('*MSM*GPV*', 'MSM', 'GPV', range(0, 80)),
        ('*GSM*GPV*', 'GSM_JP', 'GPV', range(0, 265, 3)),
        ('*MSM*GUID*', 'MSM', 'GUID', range(0, 80)),      
        ('*GSM*GUID*', 'GSM', 'GUID', range(0, 265, 3)),  
        ('*ANAL*grib2*', 'ANAL', 'ANAL', [0])
    ]

    for folder_path in self.folder_paths:
        if self.abort: break

        for pattern, model_id, mode, ft_range in scan_rules:
            if self.abort: break
            files = glob.glob(os.path.join(folder_path, '**', pattern), recursive=True)
            if not files: continue

            times = set(re.search(r'_(\d{14})_', os.path.basename(f)).group(1) for f in files if re.search(r'_(\d{14})_', f))
            if not times: continue
            latest_it = max(times)
            latest_files = [f for f in files if latest_it in f]

            if self.last_reported.get(f"{model_id}_{mode}") != latest_it:
                self.log_signal.emit(f"✅ {model_id} ({mode}) 最新データ発見: {latest_it}")
                self.last_reported[f"{model_id}_{mode}"] = latest_it; reported_new_file = True

            tasks = []
            pfx = "GUID_" if mode == "GUID" else ""
            for ft in ft_range:
                npz_file = os.path.join(self.output_dir, f"{model_id}_{pfx}{latest_it}_FT{ft:02d}.npz")
                if not os.path.exists(npz_file): tasks.append(ft)

            if not tasks: continue

            if not scan_started:
                self.log_signal.emit("🔍 新規データを抽出中..."); scan_started = True; has_new_data = True

            file_groups = {}
            for ft in tasks:
                if model_id == "GSM_GL":
                    f1 = self._get_file_for_ft(latest_files, "", ft, model_id) or "NONE"
                    f2 = f1
                elif mode == "GUID":
                    # GUIDの場合はP-allとPrr(またはPrrsf)を両方読み込む
                    f1 = self._get_file_for_ft(latest_files, "P-all", ft, model_id) or "NONE"
                    if f1 == "NONE": f1 = self._get_file_for_ft(latest_files, "", ft, model_id) or "NONE"
                    f2 = self._get_file_for_ft(latest_files, "Prr", ft, model_id) or "NONE"
                    if f2 == "NONE": f2 = self._get_file_for_ft(latest_files, "Prrsf", ft, model_id) or "NONE"
                else:
                    f1 = self._get_file_for_ft(latest_files, "" if mode == "ANAL" else "Lsurf", ft, model_id) or "NONE"
                    if mode == "ANAL":
                        f2 = "NONE"
                    else:
                        ft_p = (ft // 3) * 3 if model_id == "MSM" else ft
                        f2 = self._get_file_for_ft(latest_files, "P-all" if any("P-all" in x for x in latest_files) else ("L-pall" if any("L-pall" in x for x in latest_files) else "P"), ft_p, model_id) or "NONE"

                if f1 == "NONE" and f2 == "NONE":
                    f1 = self._get_file_for_ft(latest_files, "", ft, model_id) or "NONE"
                    f2 = f1

                if f1 == "NONE" and f2 == "NONE": continue
                key = (f1, f2)
                if key not in file_groups: file_groups[key] = []
                file_groups[key].append(ft)

            if file_groups:
                self.log_signal.emit(f"🚀 {model_id} ({mode}) 爆速抽出 (対象: {len(tasks)}件)...")

            total_extracted = 0
            ctx = mp.get_context('spawn')

            for (f1, f2), fts in file_groups.items():
                if self.abort: break
                for i in range(0, len(fts), 5):
                    if self.abort: break
                    chunk_fts = fts[i:i + 5]

                    q = ctx.Queue()
                    p = ctx.Process(target=extract_gpv_worker, args=(model_id, mode, self.output_dir, latest_it, chunk_fts, f1, f2, WGRIB2_EXE, q))
                    p.start()

                    while p.is_alive() or not q.empty():
                        if self.abort: p.terminate(); break
                        try:
                            msg_type, msg_val = q.get(timeout=0.1)
                            if msg_type == "SUCCESS":
                                total_extracted += 1
                                self.progress_signal.emit(int((total_extracted / len(tasks)) * 100), f"⚡ wgrib2解析中 {model_id} {msg_val}...")
                            elif msg_type == "ERROR":
                                self.log_signal.emit(f"⚠️ {msg_val}")
                        except queue.Empty: pass
                    p.join()

    if has_new_data or reported_new_file: self.log_signal.emit("✅ スキャン・抽出完了")
    self.finished_signal.emit(has_new_data)

def _get_file_for_ft(self, files_list, keyword, ft, model_name):
    if "GSM" in model_name:
        for f in files_list:
            if keyword and keyword not in f: continue
            m_single = re.search(r'FD(\d{2})(\d{2})_grib2', os.path.basename(f))
            if m_single:
                file_ft = int(m_single.group(1)) * 24 + int(m_single.group(2))
                if file_ft == ft: return f
            m_range = re.search(r'FD(\d{2})(\d{2})-(\d{2})(\d{2})', os.path.basename(f))
            if m_range:
                ft_s = int(m_range.group(1)) * 24 + int(m_range.group(2))
                ft_e = int(m_range.group(3)) * 24 + int(m_range.group(4))
                if ft_s <= ft <= ft_e: return f
        return next((f for f in files_list if (keyword in f or not keyword)), None)

    for f in files_list:
        if keyword and keyword not in f: continue
        m = re.search(r'FH(\d{2})-(\d{2})', os.path.basename(f))
        if m and int(m.group(1)) <= ft <= int(m.group(2)): return f
        elif not m: return f
    return None

==========================================

GUIクラス

==========================================

class EngineStatusWindow(QWidget):
def init(self):
super().init()
self.setWindowTitle(“GPV 解析エンジン (Ver 104.0 究極解放版)”)
self.setFixedSize(650, 600)
self.setStyleSheet(“background-color: #0A192F; color: #E0E0E0; font-family: ‘MS Gothic’;”)
layout = QVBoxLayout(self)
self.status_label = QLabel(“状態: 起動中…”); self.status_label.setStyleSheet(“font-size: 14pt; font-weight: bold; color: #64FFDA;”)
layout.addWidget(self.status_label)

    control_layout = QHBoxLayout()
    self.pause_btn = QPushButton("⏸ 一時停止")
    self.play_btn = QPushButton("▶ 再生(自動再開)") 
    self.resume_btn = QPushButton("🔄 手動強制スキャン")
    self.stop_btn = QPushButton("⏹ 停止")
    for btn in [self.pause_btn, self.play_btn, self.resume_btn, self.stop_btn]:
        btn.setStyleSheet("background-color: #1D3557; padding: 4px; font-weight: bold; color: #F1FAEE;")
        control_layout.addWidget(btn)
    layout.addLayout(control_layout)

    cache_layout = QHBoxLayout()
    self.clean_btn = QPushButton("🧹 24h経過データ削除")
    self.clean_btn.setStyleSheet("background-color: #F4A261; padding: 4px; font-weight: bold; color: black;")
    self.clear_btn = QPushButton("💥 キャッシュ全削除")
    self.clear_btn.setStyleSheet("background-color: #E63946; padding: 4px; font-weight: bold; color: white;")
    cache_layout.addWidget(self.clean_btn)
    cache_layout.addWidget(self.clear_btn)
    layout.addLayout(cache_layout)

    out_layout = QHBoxLayout()
    self.out_folder_btn = QPushButton("💾 出力先フォルダを設定")
    self.out_folder_btn.setStyleSheet("background-color: #457B9D; padding: 4px; font-weight: bold; color: white;")
    out_layout.addWidget(self.out_folder_btn)
    self.out_folder_label = QLabel("未設定")
    self.out_folder_label.setStyleSheet("background: #112240; padding: 4px; border: 1px solid #457B9D;")
    out_layout.addWidget(self.out_folder_label, stretch=1)
    layout.addLayout(out_layout)

    self.log_list = QListWidget()
    self.log_list.setStyleSheet("background: #112240; border: 1px solid #444; color: #E0E0E0;")
    layout.addWidget(self.log_list)
    self.copy_btn = QPushButton("📋 ログをコピー")
    self.copy_btn.setStyleSheet("background-color: #1D3557; color: white;")
    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: 
        b.setStyleSheet("background-color: #1D3557; color: white;")
        folder_layout.addWidget(b)
    layout.addLayout(folder_layout)

    self.exit_btn = QPushButton("システムを完全に終了"); self.exit_btn.setStyleSheet("background-color: #E63946; color: white; font-weight: bold; padding: 6px;")
    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):
    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”, “GPVEngine”)
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.last_reported = {}
    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("GPV抽出エンジン (稼働中)")

    tray_menu = QMenu()
    show_action = QAction("ウィンドウを表示"); show_action.triggered.connect(self.restore_window)
    quit_action = QAction("完全に終了する"); quit_action.triggered.connect(self.quit)
    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()

    with open(os.path.join(self.output_dir, "system_log.txt"), "w", encoding="utf-8") as f: pass
    self.window.log("===============================")
    self.window.log("システム起動: Ver 104.0 究極解放版")
    self.window.log("===============================")

    self.window.out_folder_label.setText(self.output_dir)
    self.window.out_folder_btn.clicked.connect(self.select_output_folder)

    folders_set = False
    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)
        if self.monitor_folders[i]: folders_set = True

    if not folders_set: self.window.status_label.setText("状態: 読込元フォルダ未設定")

    self.window.copy_btn.clicked.connect(self.copy_logs)
    self.window.pause_btn.clicked.connect(self.pause_parsing)
    self.window.play_btn.clicked.connect(self.resume_auto_scan)
    self.window.resume_btn.clicked.connect(self.force_scan)
    self.window.stop_btn.clicked.connect(self.stop_parsing)
    self.window.clean_btn.clicked.connect(self.clean_old_data)
    self.window.clear_btn.clicked.connect(self.clear_cache)
    self.window.exit_btn.clicked.connect(self.quit)

    self.timer = QTimer(); self.timer.timeout.connect(self.run_parser); self.timer.start(10000)
    self.is_processing = False; self.run_parser(); self.window.show()

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("エンジン稼働中", "ウィンドウを隠しました。終了時は右クリックからどうぞ。", QIcon(self.tray_icon.icon()), 3000)

def select_output_folder(self):
    global CURRENT_OUTPUT_DIR
    folder = QFileDialog.getExistingDirectory(self.window, "出力先フォルダを選択", self.output_dir)
    if folder:
        self.output_dir = folder; CURRENT_OUTPUT_DIR = folder
        self.settings.setValue("output_dir", folder); self.window.out_folder_label.setText(folder)
        self.window.log(f"💾 出力先フォルダを更新しました: {folder}"); self.run_parser()

def clean_old_data(self):
    now = time.time()
    deleted = 0
    self.window.log("🧹 24時間経過した古いデータを検索中...")
    for f in glob.glob(os.path.join(self.output_dir, "*.npz")):
        try:
            if os.path.isfile(f) and (now - os.path.getmtime(f)) > 24 * 3600:
                os.remove(f); deleted += 1
        except: pass
    self.window.log(f"✅ {deleted}件の古いデータを自動削除しました")

def clear_cache(self):
    reply = QMessageBox.question(self.window, "確認", "バグで壊れたデータをすべて削除し、再抽出しますか?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
    if reply == QMessageBox.StandardButton.Yes:
        self.timer.stop(); self.window.log("🗑️ キャッシュを全削除中...")
        for f in glob.glob(os.path.join(self.output_dir, "*.npz")) + glob.glob(os.path.join(self.output_dir, "*.json")) + glob.glob(os.path.join(self.output_dir, "*.bin")):
            try: os.remove(f)
            except: pass
        for d in self.monitor_folders:
            if d:
                for idx_f in glob.glob(os.path.join(d, '**', '*.idx'), recursive=True):
                    try: os.remove(idx_f)
                    except: pass
        self.last_reported = {}; self.window.log(f"✨ クリーンな状態で再抽出を開始します!"); self.run_parser(); self.timer.start(10000)

def select_folder(self, index):
    folder = QFileDialog.getExistingDirectory(self.window, "読込元フォルダ選択")
    if folder:
        self.monitor_folders[index] = folder; self.settings.setValue(f"watch_dir_{index+1}", folder)
        self.update_folder_btn_text(index); self.window.log(f"フォルダ{index+1}を更新: {folder}"); 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 pause_parsing(self): self.timer.stop(); self.window.log("⏸ 一時停止しました")
def resume_auto_scan(self): self.timer.start(10000); self.window.log("▶ 再生(自動スキャン)を再開しました"); self.run_parser()
def force_scan(self): self.window.log("手動強制スキャン開始..."); self.timer.start(10000); self.run_parser()
def stop_parsing(self): self.timer.stop(); self.window.log("⏹ 停止しました")

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.last_reported)
    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; self.window.status_label.setText("状態: 待機中 (自動スキャン継続中)")

if name == ‘main‘:
mp.freeze_support()
app = TrayApp(sys.argv)
sys.exit(app.exec())

コメント