未分類

# ==========================================
# 【App A】 統合GPV解析エンジン (MSM/GSM/ANAL/MEPS対応版)
# VERSION: MERGED_5.3 (ANAL最優先 + 同期抽出完全対応版)
# ------------------------------------------
# 【処理方針】
#  読込元フォルダは6箇所まで指定可能。
#  MSM_GPV -> MSM_GUID -> MEPS_GUID -> GSM_GUID -> GSM_GPV の順に巡回。
#  - 毎時大気解析(ANAL): 新規データを検出した瞬間、最優先で割り込み抽出を実行。
#  - 同期抽出(GPV/MEPS): LsurfとL-pall等、必要な2ファイルが完全に揃うまで
#    処理を保留し、欠損のあるnpzキャッシュの生成を防止します。
# ==========================================
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, QGridLayout)
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)
    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, time, re
        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(',')]
        creationflags = 0x08000000 if sys.platform == "win32" else 0

        # === 全モデル共通: wgrib2による高速スライス関数 ===
        def slice_with_wgrib2(fin, ft_val, suffix):
            if fin == "NONE" or not os.path.exists(fin): 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 mode == "MEPS_GUID" and ft_val >= 3:
                    offset_start = max(0, ft_val - 3)
                    offset_end = offset_start + 1
                    patterns.append(f"{offset_start}-{offset_end} 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:
                subprocess.run(cmd, creationflags=creationflags, capture_output=True, text=True, timeout=120)
                if os.path.exists(fout) and os.path.getsize(fout) > 0: return fout
            except subprocess.TimeoutExpired:
                pass
            except Exception: pass
            return "NONE"

        # ============================================================
        # 【MEPS_GUID専用】確率処理・パーセンタイル
        # ============================================================
        if mode == "MEPS_GUID":
            f_pall = f1; f_prrst = f2
            
            d_p90 = {ft: {} for ft in target_fts}
            d_p10 = {ft: {} for ft in target_fts}
            d_prob1 = {ft: {} for ft in target_fts}
            d_prob10 = {ft: {} for ft in target_fts}
            d_prob20 = {ft: {} for ft in target_fts}
            d_prob30 = {ft: {} for ft in target_fts}

            for t_ft in target_fts:
                f1_mini = slice_with_wgrib2(f_pall, t_ft, "pall")
                f2_mini = slice_with_wgrib2(f_prrst, t_ft, "prrsf")

                def extract_and_process(fin, src_name):
                    if fin == "NONE" or not os.path.exists(fin): return
                    
                    res = subprocess.run([wgrib2_path, fin, "-s"], capture_output=True, text=True, creationflags=creationflags)
                    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
                                duration = 0
                            else: continue
                        else:
                            start_val = int(m.group(1)); end_val = int(m.group(2)); unit = m.group(3)
                            if unit == 'day': end_val *= 24; start_val *= 24
                            duration = end_val - start_val

                        if duration == 1: mapped_ft = start_val + 3
                        else: mapped_ft = end_val

                        if mapped_ft != t_ft: continue
                        
                        step_type = "accum"
                        if " max " in time_str or time_str.endswith(" max"): step_type = "max"
                        elif " min " in time_str or time_str.endswith(" min"): step_type = "min"
                        elif " ave " in time_str or time_str.endswith(" ave"): step_type = "ave"
                        elif "inst" in time_str: step_type = "inst"
                        
                        var_name = param.lower()
                        if param in ["TPRATE", "APCP", "PRECIP"]: var_name = "tp"
                        elif param in ["TSRATE", "WEASD", "SNOW", "ASNOW"]: var_name = "asnow"
                        elif param in ["TSTM", "THUND", "PRSTM"]: var_name = "tstm"
                        
                        parsed_lines.append( (var_name, step_type, duration, line) )
                    
                    grouped = {}
                    for meta in parsed_lines:
                        k = (meta[0], meta[1], meta[2])
                        if k not in grouped: grouped[k] = []
                        grouped[k].append(meta[3])
                    
                    for k, group_lines in grouped.items():
                        var_name, step_type, duration = k
                        
                        fixed_key = None
                        if var_name == "asnow":
                            dur = duration if duration else 3
                            fixed_key = f"snow{dur}"
                        elif var_name == "tp":
                            if step_type == "max":
                                if duration in [1, 3, 24]: fixed_key = f"precip{duration}max"
                                else: fixed_key = f"precip{duration}max"
                            else:
                                if src_name == "pall" and duration == 3: fixed_key = "precip"
                        elif var_name == "tstm":
                            fixed_key = "thund"
                        
                        if not fixed_key:
                            expected_start = max(0, t_ft - (duration if duration else 3))
                            fixed_key = f"{var_name}_{src_name}_{step_type}_{expected_start}_{t_ft}"
                        
                        inv_input = "\\n".join(group_lines) + "\\n"
                        temp_bin = os.path.join(cache, f"~tmp_ext_{model}_{init}_{t_ft}_{src_name}_{fixed_key}.bin")
                        
                        try:
                            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=60)
                        except subprocess.TimeoutExpired:
                            process.kill(); continue
                            
                        if os.path.exists(temp_bin) and os.path.getsize(temp_bin) > 0:
                            try:
                                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()
                                        
                                        if var_name == "asnow": val = val * 100.0
                                            
                                        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:
                                    stacked_val = np.stack(all_fields, axis=0)
                                    grid_size = lon_1d.size * lat_1d.size
                                    
                                    def set_coords(d_dict):
                                        d_dict[f'lon_{fixed_key}'] = lon_1d.copy()
                                        d_dict[f'lat_{fixed_key}'] = lat_1d.copy()
                                        if grid_size > 100000 or 'lon' not in d_dict:
                                            d_dict['lon'] = lon_1d.copy()
                                            d_dict['lat'] = lat_1d.copy()

                                    for d_dict in [d_p90[t_ft], d_p10[t_ft], d_prob1[t_ft], d_prob10[t_ft], d_prob20[t_ft], d_prob30[t_ft]]:
                                        set_coords(d_dict)

                                    with warnings.catch_warnings():
                                        warnings.simplefilter("ignore", category=RuntimeWarning)
                                        d_p90[t_ft][fixed_key] = np.nanpercentile(stacked_val, 90, axis=0)
                                        d_p10[t_ft][fixed_key] = np.nanpercentile(stacked_val, 10, axis=0)
                                        valid_counts = np.sum(~np.isnan(stacked_val), axis=0)
                                        with np.errstate(divide='ignore', invalid='ignore'):
                                            d_prob1[t_ft][fixed_key] = np.where(valid_counts > 0, np.sum(stacked_val >= 1.0, axis=0) / valid_counts * 100.0, np.nan)
                                            d_prob10[t_ft][fixed_key] = np.where(valid_counts > 0, np.sum(stacked_val >= 10.0, axis=0) / valid_counts * 100.0, np.nan)
                                            d_prob20[t_ft][fixed_key] = np.where(valid_counts > 0, np.sum(stacked_val >= 20.0, axis=0) / valid_counts * 100.0, np.nan)
                                            d_prob30[t_ft][fixed_key] = np.where(valid_counts > 0, np.sum(stacked_val >= 30.0, axis=0) / valid_counts * 100.0, np.nan)
                                            
                            except Exception as e: pass
                            finally:
                                gc.collect()
                                try: os.remove(temp_bin)
                                except: pass

                extract_and_process(f1_mini, "pall")
                extract_and_process(f2_mini, "prrsf")
                
                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: pass

                if d_p90[t_ft]:
                    stat_dicts = {"MAX": d_p90[t_ft], "MIN": d_p10[t_ft], "PROB1": d_prob1[t_ft], "PROB10": d_prob10[t_ft], "PROB20": d_prob20[t_ft], "PROB30": d_prob30[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: pass
                                np.savez_compressed(final_filepath, **data_dict)
                            try:
                                if os.path.exists(temp_filepath): os.remove(temp_filepath)
                            except: pass
                        except Exception: pass
                    print(f"SUCCESS:{t_ft}", flush=True)
            return

        # ============================================================
        # 【ANAL (毎時大気解析) 専用処理】
        # ============================================================
        elif mode == "ANAL":
            d_all = {ft: {} for ft in target_fts}
            for t_ft in target_fts:
                d = d_all[t_ft]
                f1_mini = slice_with_wgrib2(f1, t_ft, "1")
                if f1_mini == "NONE": continue
                
                try:
                    dss = cfgrib.open_datasets(f1_mini, 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
                        if lon is None or lat is None: continue
                        
                        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
                            if 'number' in da_step.coords: da_step = da_step.isel(number=0)
                            
                            sName = str(da.attrs.get('GRIB_shortName', v)).lower()
                            is_upper = 'isobaricInhPa' in da_step.coords or 'level' in da_step.coords
                            lon_key = 'lon_pall' if is_upper else 'lon_surf'
                            lat_key = 'lat_pall' if is_upper else 'lat_surf'
                            
                            if not is_upper:
                                val = da_step.values.copy()
                                while val.ndim > 2: val = val[0]
                                
                                assign_key = None
                                if sName in ['prmsl', 'msl', 'mslet']: assign_key = 'slp'
                                elif sName in ['10u', 'u', 'u10']: assign_key = 'u10'
                                elif sName in ['10v', 'v', 'v10']: assign_key = 'v10'
                                elif sName in ['2t', 't', 't2m', 'temp']: assign_key = 't2m'
                                elif sName in ['2r', 'r', 'rh2m', 'rh']: assign_key = 'rh2m'
                                
                                if assign_key:
                                    if assign_key == 'slp' and np.nanmax(val) > 2000: val = val / 100.0
                                    if assign_key == 't2m' and np.nanmax(val) > 150: val = val - 273.15
                                    d[assign_key] = val
                                    d[lon_key] = lon_1d; d[lat_key] = lat_1d
                                    if 'lon' not in d: d['lon'] = lon_1d; d['lat'] = lat_1d
                            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]:
                                        dim_n = 'isobaricInhPa' if 'isobaricInhPa' in da_step.coords else 'level'
                                        val_l = da_step.isel(**{dim_n: l_idx}).values.copy() if len(levels) > 1 else da_step.values.copy()
                                        while val_l.ndim > 2: val_l = val_l[0]
                                        
                                        assign_key = None
                                        if sName in ['t', 'temp']: assign_key = f't{lvl}'
                                        elif sName in ['u', 'u-component']: assign_key = f'u{lvl}'
                                        elif sName in ['v', 'v-component']: assign_key = f'v{lvl}'
                                        elif sName in ['r', 'rh', 'humidity']: assign_key = f'r{lvl}'
                                        elif sName in ['w', 'v-velocity', 'dz']: assign_key = f'w{lvl}'
                                        elif sName in ['gh', 'z', 'geopotential']: assign_key = f'gh{lvl}'
                                        
                                        if assign_key:
                                            if assign_key.startswith('t') and np.nanmax(val_l) > 150: val_l = val_l - 273.15
                                            d[assign_key] = val_l
                                            d[lon_key] = lon_1d; d[lat_key] = lat_1d
                    for ds in dss: ds.close()
                except Exception: pass
                finally: gc.collect()
                
                try:
                    if os.path.exists(f1_mini): os.remove(f1_mini)
                except: pass

            for t_ft in target_fts:
                d = d_all[t_ft]
                if not d: continue
                
                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 and 'lon_pall' in d and 'lat_pall' in d:
                    lon_2d, lat_2d = np.meshgrid(d['lon_pall'], d['lat_pall'])
                    rad_lat = np.deg2rad(lat_2d); rad_lon = np.deg2rad(lon_2d)
                    dy = 6371000.0 * np.gradient(rad_lat, axis=0)
                    dx = 6371000.0 * np.cos(rad_lat) * np.gradient(rad_lon, axis=1)
                    dx[dx == 0] = 1e-10; dy[dy == 0] = 1e-10
                    d['vort500'] = ((np.gradient(v500, axis=1) / dx) - (np.gradient(u500, axis=0) / dy)) * 1e5
                    
                final_filepath = os.path.join(cache, f"{model}_{init}_FT{t_ft:02d}.npz")
                temp_filepath = os.path.join(cache, f"~tmp_{model}_{init}_FT{t_ft:02d}.npz")
                try:
                    np.savez_compressed(temp_filepath, **d)
                    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, **d)
                    try:
                        if os.path.exists(temp_filepath): os.remove(temp_filepath)
                    except Exception: pass
                    print(f"SUCCESS:{t_ft}", flush=True)
                except Exception as e:
                    print(f"DEBUG_EXCEPTION: Error saving FT={t_ft} - {e}", flush=True)

        # ============================================================
        # 【GPV / GUID (MSM,GSM,GSM_JP)】 既存の処理を維持
        # ============================================================
        elif mode in ["GPV", "GUID"]:
            d_all = {ft: {} for ft in target_fts}

            for t_ft in target_fts:
                d = d_all[t_ft]
                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

                                    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
                                        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()
                                        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 == "GPV":
                                    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 * 100.0, 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 == "GPV":
                    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 = os.path.join(cache, f"~tmp_{model}_{pfx}{init}_FT{t_ft:02d}.npz")
                try:
                    np.savez_compressed(temp_filepath, **d)
                    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, **d)
                    try:
                        if os.path.exists(temp_filepath): os.remove(temp_filepath)
                    except Exception: pass
                    print(f"SUCCESS:{t_ft}", flush=True)
                except Exception as e:
                    print(f"DEBUG_EXCEPTION: Error saving FT={t_ft} - {e}", 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)

    MODEL_CYCLE = ["MSM_GPV", "MSM_GUID", "MEPS_GUID", "GSM_GUID", "GSM_GPV"]
    MSM_INTERRUPT_KEYS = ["MSM_GPV", "MSM_GUID"]

    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 _scan_sources(self):
        now_utc = datetime.utcnow()
        sources = {m: {} for m in self.MODEL_CYCLE}
        anal_groups_all = {}
        meps_gpv_groups_all = {}

        def get_all_init_groups(file_list, allow_all_time=True):
            groups = {}
            for f in file_list:
                m = re.search(r'_(\d{14})_', os.path.basename(f))
                if not m: continue
                init = m.group(1)
                if not allow_all_time:
                    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

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

        for folder_path in self.folder_paths:
            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)
            meps_guid_p = glob.glob(os.path.join(folder_path, '**', '*MEPS_GUID*P-all*grib2*'), recursive=True) + \
                          glob.glob(os.path.join(folder_path, '**', '*MEPS*Pall*grib2*'), recursive=True)
            meps_guid_pr = glob.glob(os.path.join(folder_path, '**', '*MEPS_GUID*Prrsf*grib2*'), recursive=True) + \
                           glob.glob(os.path.join(folder_path, '**', '*MEPS*Prrst*grib2*'), recursive=True)

            for init_str, files in get_all_init_groups(msm_files_all).items():
                sources["MSM_GPV"].setdefault(init_str, {"model_name": "MSM", "mode": "GPV", "ft_list": list(range(0, 79, 1)), "files": []})["files"].extend(files)
            for init_str, files in get_all_init_groups(msm_guid).items():
                sources["MSM_GUID"].setdefault(init_str, {"model_name": "MSM", "mode": "GUID", "ft_list": list(range(0, 79, 1)), "files": []})["files"].extend(files)
            for init_str, files in get_all_init_groups(gsm_guid).items():
                sources["GSM_GUID"].setdefault(init_str, {"model_name": "GSM", "mode": "GUID", "ft_list": gsm_ft_target, "files": []})["files"].extend(files)
            for init_str, files in get_all_init_groups(gsm_files_all).items():
                sources["GSM_GPV"].setdefault(init_str, {"model_name": "GSM_JP", "mode": "GPV", "ft_list": gsm_ft_target, "files": []})["files"].extend(files)

            pall_groups = get_all_init_groups(meps_guid_p)
            prrst_groups = get_all_init_groups(meps_guid_pr)
            for init_str in set(pall_groups.keys()) | set(prrst_groups.keys()):
                entry = sources["MEPS_GUID"].setdefault(init_str, {
                    "model_name": "MEPS", "mode": "MEPS_GUID", "ft_list": list(range(3, 40, 3)),
                    "f_pall": "NONE", "f_prrst": "NONE"})
                if init_str in pall_groups: entry["f_pall"] = pall_groups[init_str][0]
                if init_str in prrst_groups: entry["f_prrst"] = prrst_groups[init_str][0]

            for init_str, files in get_all_init_groups(anal_files_all).items():
                anal_groups_all.setdefault(init_str, {"model_name": "ANAL", "mode": "ANAL", "ft_list": [0], "files": []})["files"].extend(files)
            for init_str, files in get_all_init_groups(meps_gpv).items():
                meps_gpv_groups_all.setdefault(init_str, {"model_name": "MEPS", "mode": "GPV", "ft_list": list(range(0, 40, 3)), "files": []})["files"].extend(files)

        return sources, anal_groups_all, meps_gpv_groups_all

    def _sorted_inits_desc(self, sources, model_key):
        return sorted(sources.get(model_key, {}).keys(), reverse=True)

    def _within_window(self, init_str):
        if self.scan_mode == "all": return True
        try:
            file_time = datetime.strptime(init_str, '%Y%m%d%H%M%S')
            return (datetime.utcnow() - file_time).total_seconds() <= 12 * 3600
        except Exception:
            return True

    def _target_npz_names(self, model_key, model_name, mode, init_str, ft_list):
        if mode == "MEPS_GUID":
            names = []
            for ft in ft_list:
                for stat in ["MAX", "MIN", "PROB1", "PROB10", "PROB20", "PROB30"]:
                    names.append(f"{model_name}_GUID_{stat}_{init_str}_FT{ft:02d}.npz")
            return names
        pfx = "GUID_" if mode == "GUID" else ""
        return [f"{model_name}_{pfx}{init_str}_FT{ft:02d}.npz" for ft in ft_list]

    def _is_fully_done(self, model_key, entry, init_str):
        names = self._target_npz_names(model_key, entry["model_name"], entry["mode"], init_str, entry["ft_list"])
        return all(os.path.exists(os.path.join(self.output_dir, n)) for n in names)

    def _missing_fts(self, model_key, entry, init_str):
        if entry["mode"] == "MEPS_GUID":
            missing = []
            for ft in entry["ft_list"]:
                names = [f"MEPS_GUID_{stat}_{init_str}_FT{ft:02d}.npz" for stat in ["MAX", "MIN", "PROB1", "PROB10", "PROB20", "PROB30"]]
                if not all(os.path.exists(os.path.join(self.output_dir, n)) for n in names):
                    missing.append(ft)
            return missing
        pfx = "GUID_" if entry["mode"] == "GUID" else ""
        missing = []
        for ft in entry["ft_list"]:
            name = f"{entry['model_name']}_{pfx}{init_str}_FT{ft:02d}.npz"
            if not os.path.exists(os.path.join(self.output_dir, name)):
                missing.append(ft)
        return missing

    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

    def _process_one(self, model_key, entry, init_str, creationflags, env):
        missing = self._missing_fts(model_key, entry, init_str)
        if not missing: return False

        model_name = entry["model_name"]; mode = entry["mode"]

        # 【重要】 MEPS_GUID の同期チェック(P-all / Prrsf が両方揃うまで待つ)
        if mode == "MEPS_GUID":
            f_pall = entry.get("f_pall", "NONE"); f_prrst = entry.get("f_prrst", "NONE")
            if f_pall == "NONE" or f_prrst == "NONE": return False
            proc_label = "MEPS(アンサンブル)"
            self.log_signal.emit(f"🚀 [{model_key}] {model_name} {proc_label} ({init_str}) 抽出開始 (対象: {len(missing)}件)...")
            ft_str = ",".join(map(str, missing))
            cmd = [sys.executable, WORKER_SCRIPT, model_name, mode, self.output_dir, init_str, ft_str, f_pall, f_prrst, WGRIB2_EXE]
            self._run_worker_and_report(cmd, model_name, init_str, proc_label, len(missing))
            return True

        files = entry["files"]
        file_groups = {}
        
        for ft in missing:
            if mode == "GPV":
                f1 = self._get_file_for_ft(files, "Lsurf", ft) or "NONE"
                f2 = self._get_file_for_ft(files, "L-pall", ft) or "NONE"
                
                if f1 == "NONE": 
                    continue
                
                # 同期チェックの最適化
                # L-pallのファイル区分の隙間に落ちるFTは f2=="NONE" でも抽出を許可する
                if f2 == "NONE" and ft not in [16, 17, 34, 35, 40, 41, 52, 53, 87]:
                    continue
            elif mode == "ANAL":
                f1 = files[0] if files else "NONE"; f2 = "NONE"
                if f1 == "NONE": continue
            else:
                f1 = files[0] if files else "NONE"; f2 = "NONE"
                if f1 == "NONE": continue
            
            key = (f1, f2)
            file_groups.setdefault(key, []).append(ft)

        # ファイルがまだ揃っていない場合はログを出さずにスキップ
        if not file_groups: return False

        proc_label = "GPV" if mode in ["GPV", "ANAL"] else "ガイダンス"
        total_target = sum(len(fts) for fts in file_groups.values())
        self.log_signal.emit(f"🚀 [{model_key}] {model_name} {proc_label} ({init_str}) 抽出開始 (対象: {total_target}件)...")

        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_str, ft_str, f1, f2, WGRIB2_EXE]
                total_extracted = self._run_worker_and_report(cmd, model_name, init_str, proc_label, total_target, total_extracted)
        return True

    def _run_worker_and_report(self, cmd, model_name, init_str, proc_label, total_target, total_extracted=0):
        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"
        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 / max(total_target, 1)) * 100), f"⚡ {model_name} {init_str} FT={ft_success}h...")
                self.log_signal.emit(f"⚡ 抽出完了: {model_name} {proc_label} FT={ft_success}h")
            elif line.startswith("WARN:") 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 Exception: pass
        return total_extracted

    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("⚠️ wgrib2が見つかりません。"); self.finished_signal.emit(False); return

        has_new_data = False
        creationflags = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
        env = os.environ.copy()

        try:
            sources, anal_groups_all, meps_gpv_groups_all = self._scan_sources()
            round_idx = {m: 0 for m in self.MODEL_CYCLE}
            
            known_latest_anal = None
            known_latest_msm = {}
            for k in self.MSM_INTERRUPT_KEYS:
                inits = self._sorted_inits_desc(sources, k)
                known_latest_msm[k] = inits[0] if inits else None

            def try_process(model_key, init_str):
                nonlocal has_new_data
                entry = sources[model_key][init_str]
                if self._is_fully_done(model_key, entry, init_str): return False
                did = self._process_one(model_key, entry, init_str, creationflags, env)
                if did: has_new_data = True
                return did
                
            def try_process_anal(init_str):
                nonlocal has_new_data
                entry = anal_groups_all[init_str]
                if self._is_fully_done("ANAL", entry, init_str): return False
                did = self._process_one("ANAL", entry, init_str, creationflags, env)
                if did: has_new_data = True
                return did

            while not self.abort:
                progressed = False

                # ★ 0. 【ANAL 最優先割り込み処理】 常に最優先でチェックして実行
                if anal_groups_all:
                    inits_anal = sorted(anal_groups_all.keys(), reverse=True)
                    latest_anal = inits_anal[0] if inits_anal else None
                    if latest_anal and latest_anal != known_latest_anal:
                        if self._within_window(latest_anal):
                            self.log_signal.emit(f"⚡ 割り込み: 毎時大気解析(ANAL)に新しい初期時刻({latest_anal})を検出、最優先処理します")
                            if try_process_anal(latest_anal):
                                progressed = True
                        known_latest_anal = latest_anal

                # ★ 1. MSM 割り込み
                for msm_key in self.MSM_INTERRUPT_KEYS:
                    if self.abort: break
                    inits = self._sorted_inits_desc(sources, msm_key)
                    latest = inits[0] if inits else None
                    if latest and latest != known_latest_msm.get(msm_key):
                        self.log_signal.emit(f"⚡ 割り込み: {msm_key} に新しい初期時刻({latest})を検出、優先処理します")
                        if try_process(msm_key, latest):
                            progressed = True
                        known_latest_msm[msm_key] = latest

                # ★ 2. メインサイクル
                for model_key in self.MODEL_CYCLE:
                    if self.abort: break

                    # ループ内でも常に ANAL と MSM の割り込みチェックを再実行
                    if anal_groups_all:
                        inits_anal = sorted(anal_groups_all.keys(), reverse=True)
                        latest_anal = inits_anal[0] if inits_anal else None
                        if latest_anal and latest_anal != known_latest_anal:
                            if self._within_window(latest_anal):
                                self.log_signal.emit(f"⚡ 割り込み: 毎時大気解析(ANAL)に新しい初期時刻({latest_anal})を検出、最優先処理します")
                                if try_process_anal(latest_anal): progressed = True
                            known_latest_anal = latest_anal

                    for msm_key in self.MSM_INTERRUPT_KEYS:
                        inits = self._sorted_inits_desc(sources, msm_key)
                        latest = inits[0] if inits else None
                        if latest and latest != known_latest_msm.get(msm_key):
                            self.log_signal.emit(f"⚡ 割り込み: {msm_key} に新しい初期時刻({latest})を検出、優先処理します")
                            if try_process(msm_key, latest): progressed = True
                            known_latest_msm[msm_key] = latest

                    inits = self._sorted_inits_desc(sources, model_key)
                    idx = round_idx[model_key]
                    if idx >= len(inits): continue
                    init_str = inits[idx]

                    if idx > 0 and not self._within_window(init_str): continue

                    if self._is_fully_done(model_key, sources[model_key][init_str], init_str):
                        round_idx[model_key] += 1; continue

                    if try_process(model_key, init_str): progressed = True
                    round_idx[model_key] += 1

                    if model_key in self.MSM_INTERRUPT_KEYS:
                        inits2 = self._sorted_inits_desc(sources, model_key)
                        if inits2: known_latest_msm[model_key] = inits2[0]

                # ★ 3. 通常サイクル後の処理 (MEPS_GPVなど)
                for label, groups in [("MEPS_GPV", meps_gpv_groups_all)]:
                    if self.abort: break
                    inits = sorted(groups.keys(), reverse=True)
                    if not inits: continue
                    init_str = inits[0]
                    entry = groups[init_str]
                    if self._within_window(init_str) and not self._is_fully_done(label, entry, init_str):
                        if self._process_one(label, entry, init_str, creationflags, env):
                            progressed = True; has_new_data = True

                if not progressed: break

            if not self.abort and has_new_data:
                self.log_signal.emit("✅ 全てのスキャン・抽出が完了しました")
        except Exception as e:
            self.log_signal.emit(f"⚠️ スキャン処理で予期しないエラーが発生しました: {e}")
        finally:
            self.finished_signal.emit(has_new_data)

class EngineStatusWindow(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("GPV 統合解析エンジン (Ver MERGED_5.3 同期抽出対応版)")
        self.setFixedSize(900, 660)
        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_grid = QGridLayout()
        self.folder_btns = [QPushButton(f"📁 読込元フォルダ {i+1}") for i in range(6)]
        for i, b in enumerate(self.folder_btns):
            folder_grid.addWidget(b, i // 3, i % 3)
        layout.addLayout(folder_grid)

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

        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 MERGED_5.3 (ANAL最優先・同期抽出完全対応版)")
        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(6):
            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("✨ データを削除しました。再抽出を開始します!")
            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())