未分類

# ==========================================
# 【App AGPV解析キャッシュエンジン
# VERSION: 90.1 (リアルタイム設定反映即時停止アトミック保存機能搭載)
# ==========================================
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, QColor, QAction

# 起動失敗時に原因を特定できるようエラー内容を表示して一時停止するように改良
try: 
    import cfgrib
except ImportError as e:
    print("\n" + "="*60)
    print("【起動エラー】cfgrib または依存ライブラリの読み込みに失敗しました。")
    print(f"エラー詳細: {e}")
    print("="*60)
    print("現在のAnaconda環境(gpv_env_v2)にcfgribが正しくインストールされていないか、")
    print("DLLのパスが正しく通っていない可能性があります。")
    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, "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

WORKER_SCRIPT = os.path.join(APP_DIR, "_gpv_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)


WORKER_CODE = """
import sys, traceback

def main():
    try:
        import os, gc, json, subprocess
        import numpy as np
        import warnings; warnings.filterwarnings("ignore")
        import cfgrib
        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 calculate_vorticity(u, v, lon, lat):
            try:
                R = 6371000.0; LON, LAT = np.meshgrid(lon, lat) if lon.ndim == 1 else (lon, lat)
                rad_lat = np.deg2rad(LAT); rad_lon = np.deg2rad(LON)
                dy = R * np.gradient(rad_lat, axis=0); dx = R * np.cos(rad_lat) * np.gradient(rad_lon, axis=1)
                dx[dx == 0] = 1e-10; dy[dy == 0] = 1e-10
                return ((np.gradient(v, axis=1) / dx) - (np.gradient(u, axis=0) / dy)) * 1e5
            except Exception: return np.zeros_like(u)

        model = sys.argv[1]; mode = sys.argv[2]; cache = sys.argv[3]
        init = sys.argv[4]; target_fts_str = sys.argv[5]
        f1 = sys.argv[6]; f2 = sys.argv[7]; wgrib2_path = sys.argv[8]
        
        target_fts = [int(x) for x in target_fts_str.split(',')]
        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}_{init}_{ft_val}_{suffix}.bin")
                
                if ft_val == 0: 
                    match_str = r":(anl|0 hour [^:]+|[0-9]*[-]0 hour [^:]+):"
                else:
                    patterns = [f"{ft_val} hour", f"[0-9]*[-]{ft_val} hour"]
                    if ft_val % 24 == 0:
                        days = ft_val // 24
                        patterns.extend([f"{days} day", f"[0-9]*[-]{days} day", f"{days} d", f"[0-9]*[-]{days} d"])
                    
                    pat_joined = "|".join(patterns)
                    match_str = f":({pat_joined})[^:]*:"
                
                cmd = [wgrib2_path, fin, "-match", match_str, "-grib", fout]
                try:
                    res = subprocess.run(cmd, creationflags=creationflags, capture_output=True, text=True)
                    if res.returncode != 0:
                        print(f"DEBUG_WGRIB2_ERR: FT={ft_val} in {os.path.basename(fin)} / match={match_str}")
                    if os.path.exists(fout) and os.path.getsize(fout) > 0: return fout
                except Exception as e:
                    print(f"DEBUG_EXCEPTION: slice_with_wgrib2: {e}")
                return "NONE"
                                                                  
            f1_mini = slice_with_wgrib2(f1, t_ft, "1")
            f2_mini = slice_with_wgrib2(f2, t_ft, "2")

            def process_file(filepath):
                if filepath == "NONE": return
                try:
                    dss = cfgrib.open_datasets(filepath, backend_kwargs={'indexpath': ''})
                    for ds in dss:
                        lon = ds.longitude.values if hasattr(ds, 'longitude') else None
                        lat = ds.latitude.values if hasattr(ds, 'latitude') else None

                        for v in ds.data_vars:
                            da = ds[v]
                            da_step = da.isel(step=0) if 'step' in da.dims else da
                            if 'number' in da_step.coords: da_step = da_step.isel(number=0)
                            
                            sName = str(da.attrs.get('GRIB_shortName', v)).lower()
                            attrs_str = str(da.attrs).lower()
                            disc = da.attrs.get('GRIB_discipline', -1)
                            cat = da.attrs.get('GRIB_parameterCategory', -1)
                            num = da.attrs.get('GRIB_parameterNumber', -1)
                            
                            is_upper = 'isobaricInhPa' in da_step.coords or 'level' in da_step.coords
                            
                            # ▼▼▼ 修正版発雷確率の変則解像度に対応した空間結合ロジック ▼▼▼
                            def assign(k, val_to_assign, is_upper_flag):
                                if lon is None or lat is None:
                                    d[k] = val_to_assign
                                    return
                                    
                                lon_1d = lon[0, :] if lon.ndim == 2 else lon
                                lat_1d = lat[:, 0] if lat.ndim == 2 else lat
                                
                                # 【重要発雷確率(thund, tstm)の変則サイズ(141x121等)を検知した場合
                                # MSMファイル内の広域格子(GSM相当)に合わせた専用の軸をその場で生成上書きする
                                if k in ['thund', 'tstm'] and val_to_assign.shape != (len(lat_1d), len(lon_1d)):
                                    # データの形状からそのデータ専用の適正な緯度経度数を逆算する
                                    target_lat_len, target_lon_len = val_to_assign.shape
                                    # 日本全域(20N-50N, 120E-150Eに対応するグリッド軸を動的に補間
                                    lat_1d = np.linspace(lat_1d[0], lat_1d[-1], target_lat_len)
                                    lon_1d = np.linspace(lon_1d[0], lon_1d[-1], target_lon_len)
                                
                                lon_key = 'lon_pall' if is_upper_flag else 'lon_surf'
                                lat_key = 'lat_pall' if is_upper_flag else 'lat_surf'
                                
                                # 変数名ごとに専用の解像度で座標を管理するため独立したキー名にする
                                if k in ['thund', 'tstm']:
                                    lon_key = f'lon_{k}'
                                    lat_key = f'lat_{k}'
                                
                                if k not in d:
                                    d[k] = val_to_assign.copy()
                                    d[lon_key] = lon_1d.copy()
                                    d[lat_key] = lat_1d.copy()
                                    # ビューワー(App B)表示用の標準座標もそれが最初の登録なら確保
                                    if not is_upper_flag and 'lon' not in d:
                                        d['lon'] = lon_1d.copy()
                                        d['lat'] = lat_1d.copy()
                                else:
                                    old_lon = d[lon_key]
                                    old_lat = d[lat_key]
                                    old_val = d[k]
                                    
                                    if old_val.shape == val_to_assign.shape and np.array_equal(old_lon, lon_1d):
                                        d[k] = val_to_assign.copy()
                                        return
                                        
                                    new_lon = np.unique(np.concatenate([np.round(old_lon, 4), np.round(lon_1d, 4)]))
                                    new_lat = np.unique(np.concatenate([np.round(old_lat, 4), np.round(lat_1d, 4)]))
                                    new_lat = np.sort(new_lat)[::-1] if old_lat[0] > old_lat[-1] else np.sort(new_lat)
                                    new_lon = np.sort(new_lon)
                                    
                                    canvas = np.full((len(new_lat), len(new_lon)), np.nan)
                                    
                                    lat_idx_old = np.where(np.isin(np.round(new_lat, 4), np.round(old_lat, 4)))[0]
                                    lon_idx_old = np.where(np.isin(np.round(new_lon, 4), np.round(old_lon, 4)))[0]
                                    if canvas[np.ix_(lat_idx_old, lon_idx_old)].shape == old_val.shape:
                                        canvas[np.ix_(lat_idx_old, lon_idx_old)] = old_val
                                        
                                    lat_idx_new = np.where(np.isin(np.round(new_lat, 4), np.round(lat_1d, 4)))[0]
                                    lon_idx_new = np.where(np.isin(np.round(new_lon, 4), np.round(lon_1d, 4)))[0]
                                    if canvas[np.ix_(lat_idx_new, lon_idx_new)].shape == val_to_assign.shape:
                                        existing = canvas[np.ix_(lat_idx_new, lon_idx_new)]
                                        mask = np.isnan(existing)
                                        existing[mask] = val_to_assign[mask]
                                        canvas[np.ix_(lat_idx_new, lon_idx_new)] = existing
                                        
                                    d[k] = canvas
                                    d[lon_key] = new_lon
                                    d[lat_key] = new_lat
                                    
                                    # 標準キーもこの変数の解像度に合わせて同期
                                    if k in ['thund', 'tstm'] or 'lon' not in d:
                                        d['lon'] = new_lon
                                        d['lat'] = new_lat
                            # ▲▲▲ 空間結合ロジックここまで ▲▲▲

                            if mode in ["GPV", "ANAL"]:
                                if not is_upper:
                                    val = da_step.values.copy()
                                    while val.ndim > 2: val = val[0]
                                    
                                    if sName in ['prmsl', 'msl', 'mslet']:
                                        assign('slp', val / 100.0 if np.nanmax(val) > 2000 else val, is_upper)
                                    elif sName in ['pres', 'sp'] or 'pressure' in attrs_str:
                                        if 'slp' not in d and 'pres' not in d: assign('pres', val / 100.0 if np.nanmax(val) > 2000 else val, is_upper)
                                    elif sName in ['10u', 'u', 'u10'] or ('u-component' in attrs_str and '10' in attrs_str):
                                        assign('u10', val, is_upper)
                                    elif sName in ['10v', 'v', 'v10'] or ('v-component' in attrs_str and '10' in attrs_str):
                                        assign('v10', val, is_upper)
                                    elif sName in ['2t', 't', 't2m', 'temp'] or ('temperature' in attrs_str and '2' in attrs_str):
                                        assign('t2m', val - 273.15 if np.nanmax(val) > 150 else val, is_upper)
                                    elif sName in ['2r', 'r', 'rh2m', 'rh'] or ('humidity' in attrs_str):
                                        assign('rh2m', val, is_upper)
                                    elif sName in ['tcc', 'hcc', 'mcc', 'lcc'] or (disc == 0 and cat == 6):
                                        assign(sName if sName != 'unknown' else f"var_{disc}_{cat}_{num}", val, is_upper)
                                    elif sName in ['tp', 'apcp', 'pr', 'precip'] or 'precip' in attrs_str or 'accum' in attrs_str:
                                        if 'precip' not in d: assign('precip', np.nan_to_num(val, nan=0.0), is_upper)
                                else:
                                    levels = []
                                    if 'isobaricInhPa' in da_step.coords: levels = np.atleast_1d(da_step.isobaricInhPa.values)
                                    elif 'level' in da_step.coords: levels = np.atleast_1d(da_step.level.values)
                                    
                                    for l_idx, lvl in enumerate(levels):
                                        lvl = int(lvl)
                                        if lvl in [300, 500, 600, 700, 850, 925, 950, 975]:
                                            if len(levels) > 1:
                                                dim_n = 'isobaricInhPa' if 'isobaricInhPa' in da_step.coords else 'level'
                                                val_l = da_step.isel(**{dim_n: l_idx}).values.copy()
                                            else:
                                                val_l = da_step.values.copy()
                                            
                                            while val_l.ndim > 2: val_l = val_l[0]
                                            
                                            if sName in ['t', 'temp'] or 'temperature' in attrs_str: assign(f't{lvl}', val_l - 273.15 if np.nanmax(val_l) > 150 else val_l, is_upper)
                                            elif sName in ['u', 'u-component']: assign(f'u{lvl}', val_l, is_upper)
                                            elif sName in ['v', 'v-component']: assign(f'v{lvl}', val_l, is_upper)
                                            elif sName in ['r', 'rh', 'humidity']: assign(f'r{lvl}', val_l, is_upper)
                                            elif sName in ['w', 'v-velocity', 'dz']: assign(f'w{lvl}', val_l, is_upper)
                                            elif sName in ['gh', 'z', 'geopotential']: assign(f'gh{lvl}', val_l, is_upper)
                            elif mode == "GUID":
                                val = da_step.values.copy()
                                while val.ndim > 2: val = val[0]
                                
                                if sName != 'unknown': assign(sName, val, is_upper)
                                else: assign(f"var_{disc}_{cat}_{num}", val, is_upper)
                                
                                if sName in ['2t', 't', 't2m', 'tmp', 'temp'] or (disc == 0 and cat == 0 and num == 0) or 'temperature' in attrs_str: 
                                    assign('t2m', val - 273.15 if np.nanmax(val) > 150 else val, is_upper)
                                elif sName in ['2r', 'r', 'rh2m', 'rh'] or (disc == 0 and cat == 1 and num == 1) or 'humidity' in attrs_str: 
                                    assign('rh2m', val, is_upper)
                                elif sName in ['10u', 'u', 'u10', 'ugrd'] or (disc == 0 and cat == 2 and num == 2): 
                                    assign('u10', val, is_upper)
                                elif sName in ['10v', 'v', 'v10', 'vgrd'] or (disc == 0 and cat == 2 and num == 3): 
                                    assign('v10', val, is_upper)
                                elif sName in ['tp', 'apcp', 'pr', 'precip'] or (disc == 0 and cat == 1 and num in [8, 52]) or 'precip' in attrs_str or 'accum' in attrs_str: 
                                    if 'precip' not in d: assign('precip', np.nan_to_num(val, nan=0.0), is_upper)
                                elif sName in ['weasd', 'snod', 'snow', 'asnow'] or (disc == 0 and cat == 1 and num in [11, 13, 29, 60]) or 'snow' in attrs_str: 
                                    assign('snow', val, is_upper)
                                elif sName in ['wea', 'nswrs', 'nswrv', 'weather'] or (disc == 0 and cat == 19 and num == 192) or 'weather' in attrs_str: 
                                    assign('wea', val, is_upper)
                                elif sName in ['thund', 'lig', 'ltng', 'thunder', 'prstm', 'tstm'] or (disc == 0 and cat == 19 and num == 193) or 'thunder' in attrs_str: 
                                    assign('thund', val, is_upper)
                    for ds in dss: ds.close()
                except Exception: pass
                finally: gc.collect()

            process_file(f1_mini)
            process_file(f2_mini)
            
            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

        for t_ft in target_fts:
            d = d_all[t_ft]
            if not d: continue

            if mode in ["GPV", "ANAL"]:
                for lvl in [300, 500, 600, 700, 850, 925, 950, 975]:
                    tc = d.get(f't{lvl}'); rh = d.get(f'r{lvl}')
                    if tc is not None and rh is not None:
                        rh_c = np.clip(rh, 0.1, 100)
                        e = 6.112 * np.exp((17.67*tc)/(tc+243.5)) * (rh_c/100.0)
                        td = (243.5*np.log(e/6.112))/(17.67-np.log(e/6.112))
                        d[f'tddep{lvl}'] = tc - td
                        tk = tc + 273.15; theta = tk*(1000.0/lvl)**0.2854; w = 0.622*e/(lvl-e)
                        d[f'ep{lvl}'] = theta * np.exp((2.5e6*w)/(1004.0*tk))
                u500 = d.get('u500'); v500 = d.get('v500')
                if u500 is not None and v500 is not None:
                    vort_lon = d.get('lon_pall') if 'lon_pall' in d else d.get('lon_surf')
                    vort_lat = d.get('lat_pall') if 'lat_pall' in d else d.get('lat_surf')
                    if vort_lon is not None and vort_lat is not None:
                        d['vort500'] = calculate_vorticity(u500, v500, vort_lon, vort_lat)
            
            # --- アトミック保存の処理 ---
            pfx = "GUID_" if mode == "GUID" else ""
            final_filepath = os.path.join(cache, f"{model}_{pfx}{init}_FT{t_ft:02d}.npz")
            temp_filepath = final_filepath + ".tmp"
            
            # 一度 .tmp ファイルとして保存
            np.savez_compressed(temp_filepath, **d)
            # 書き込み完了後一瞬で本来の .npz の名前にリネーム上書き
            os.replace(temp_filepath, final_filepath)
            
            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, last_reported, scan_mode="12h"):
        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.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_found = glob.glob(os.path.join(folder_path, '**', '*.bin'), recursive=True)
            msm_files_all = glob.glob(os.path.join(folder_path, '**', '*MSM*GPV*.bin'), recursive=True)
            gsm_files_raw = glob.glob(os.path.join(folder_path, '**', '*GSM*GPV*.bin'), recursive=True)
            gsm_jp_files = [f for f in gsm_files_raw if 'Rgl' not in f and ('Rjp' in f or 'Japan' in f or 'jp' in f.lower())]
            gsm_files_all = gsm_jp_files if gsm_jp_files else gsm_files_raw
            
            gsm_guid = glob.glob(os.path.join(folder_path, '**', '*GSM_GUID*Toorg*.bin'), recursive=True)
            msm_guid = glob.glob(os.path.join(folder_path, '**', '*MSM_GUID*Toorg*.bin'), recursive=True)
            meps_gpv = glob.glob(os.path.join(folder_path, '**', '*MEPS_GPV*.bin'), recursive=True)
            anal_files_all = glob.glob(os.path.join(folder_path, '**', '*ANAL_grib2*.bin'), recursive=True) 
            
            def get_all_init_groups(file_list):
                if not file_list: return {}
                groups = {}
                for f in file_list:
                    m = re.search(r'_(\d{14})_', os.path.basename(f))
                    if m:
                        init = m.group(1)
                        if self.scan_mode == "12h":
                            try:
                                file_time = datetime.strptime(init, '%Y%m%d%H%M%S')
                                if (now_utc - file_time).total_seconds() > 12 * 3600:
                                    continue
                            except Exception: pass
                            
                        if init not in groups: groups[init] = []
                        groups[init].append(f)
                return groups

            msm_groups = get_all_init_groups(msm_files_all)
            gsm_groups = get_all_init_groups(gsm_files_all)
            gg_groups = get_all_init_groups(gsm_guid)
            mg_groups = get_all_init_groups(msm_guid)
            meps_groups = get_all_init_groups(meps_gpv)
            anal_groups = get_all_init_groups(anal_files_all)

            gsm_ft_target = list(range(0, 135, 3)) + list(range(138, 265, 6))

            phases = [
                ('GSM_JP', gsm_groups, gsm_ft_target, "GPV"),
                ('MSM', msm_groups, list(range(0, 79, 1)), "GPV"),
                ('GSM', gg_groups, gsm_ft_target, "GUID"), 
                ('MSM', mg_groups, list(range(0, 79, 1)), "GUID"),
                ('MEPS', meps_groups, list(range(0, 40, 3)), "GPV"),
                ('ANAL', anal_groups, [0], "ANAL") 
            ]
            
            priority_dict = {
                ('MSM', 'GPV'): 1,
                ('MSM', 'GUID'): 2,
                ('GSM', 'GUID'): 3,
                ('ANAL', 'ANAL'): 4,
                ('MEPS', 'GPV'): 5,
                ('GSM_JP', 'GPV'): 6
            }
            
            execution_plan = []
            for model_name, groups, ft_list, mode in phases:
                for init_time_str, files in groups.items():
                    prio = priority_dict.get((model_name, mode), 99)
                    execution_plan.append((init_time_str, model_name, files, ft_list, mode, prio))
            
            execution_plan.sort(key=lambda x: (x[0], -x[5]), reverse=True)

            for init_time_str, model_name, files, ft_list, mode, prio in execution_plan:
                if self.abort: break 
                
                tasks = []
                for ft in ft_list:
                    pfx = "GUID_" if mode == "GUID" else ""
                    npz_file = os.path.join(self.output_dir, f"{model_name}_{pfx}{init_time_str}_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

                proc_name = "GPV" if mode in ["GPV", "ANAL"] else "ガイダンス"
                file_groups = {}
                for ft in tasks:
                    if mode == "GPV":
                        f1 = self._get_file_for_ft(files, "Lsurf", ft) or "NONE"
                        f2 = self._get_file_for_ft(files, "P-all" if any("P-all" in x for x in files) else "L-pall", ft) or "NONE"
                    elif mode == "ANAL": 
                        f1 = files[0]; f2 = "NONE"
                    else:
                        f1 = files[0]; f2 = "NONE"
                    
                    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 self.abort: break
                self.log_signal.emit(f"🚀 {model_name} {proc_name} ({init_time_str}) 抽出開始 (対象: {len(tasks)}件)...")

                total_extracted = 0
                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]; ft_str = ",".join(map(str, chunk_fts))
                        cmd = [sys.executable, WORKER_SCRIPT, model_name, mode, self.output_dir, init_time_str, ft_str, f1, f2, 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 Exception: 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} {proc_name} FT={ft_success}h")
                            elif "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 Exception: pass

        if not self.abort and has_new_data: 
            self.log_signal.emit("✅ 全てのスキャン・抽出が完了しました")
        self.finished_signal.emit(has_new_data)
        
    def _get_file_for_ft(self, files_list, keyword, ft):
        for f in files_list:
            if keyword not in f: continue
            m_fd = re.search(r'FD(\d{2})(\d{2})-(\d{2})(\d{2})', os.path.basename(f))
            if m_fd:
                start_h = int(m_fd.group(1))*24 + int(m_fd.group(2))
                end_h = int(m_fd.group(3))*24 + int(m_fd.group(4))
                if start_h <= ft <= end_h: return f
                continue 
                
            m_fh = re.search(r'FH(\d{2,3})-(\d{2,3})', os.path.basename(f))
            if m_fh:
                start_h = int(m_fh.group(1))
                end_h = int(m_fh.group(2))
                if start_h <= ft <= end_h: return f
                continue
            return f
        return None
    
        
class EngineStatusWindow(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("GPV 解析エンジン (Ver 90.1)")
        self.setFixedSize(650, 620)
        self.setStyleSheet("""
            QWidget { background-color: #0A192F; color: #E0E0E0; font-family: 'MS Gothic'; font-size: 11pt; }
            QLabel { color: #64FFDA; font-weight: bold; }
            QPushButton { background-color: #1D3557; color: white; padding: 6px; border-radius: 4px; font-weight: bold; border: 1px solid #457B9D; }
            QPushButton:hover { background-color: #457B9D; }
            QListWidget { background-color: #112240; border: 1px solid #457B9D; color: #64FFDA; 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("🔄 モード: 最新12時間 (推奨)")
        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: #112240; padding: 4px; border: 1px solid #457B9D; 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", "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.scan_mode = "12h" 
        
        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_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()
        
        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 90.1 )")
        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.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(10000)
        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("エンジン待機中", "ウィンドウを閉じましたが、裏で抽出を続けています。終了する場合は右クリックから「完全に終了する」を選んでください。", QIcon(self.tray_icon.icon()), 3000)

    def quit_app(self):
        self.kill_current_worker() 
        self.window.is_quitting = True 
        self.tray_icon.showMessage("システム終了", "エンジンを完全に停止しました。", QIcon(self.tray_icon.icon()), 2000)
        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 == "12h":
            self.scan_mode = "all"
            self.window.mode_btn.setText("🔄 モード: すべてのデータ")
            self.window.mode_btn.setStyleSheet("background-color: #27AE60;")
            self.window.log("🔄 モード変更 [すべてのデータ] -> 再スキャンします")
        else:
            self.scan_mode = "12h"
            self.window.mode_btn.setText("🔄 モード: 最新12時間 (推奨)")
            self.window.mode_btn.setStyleSheet("background-color: #D35400;")
            self.window.log("🔄 モード変更 [最新12時間] -> 再スキャンします")
        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.window.log(f"💾 出力先フォルダを更新しました: {folder}")
            self.run_parser() 

    def clear_cache(self):
        reply = QMessageBox.question(self.window, "確認", "現在の出力先のデータをすべて削除し再抽出しますか?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
        if reply == QMessageBox.StandardButton.Yes:
            self.timer.stop()
            self.kill_current_worker() 
            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 Exception: 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 Exception: 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.kill_current_worker() 
            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 force_scan(self): 
        self.window.log("▶ 手動強制スキャンを開始します...")
        self.timer.start(10000)
        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.last_reported, 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 '_gpv_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())