未分類

# ==============================================================================

# 【App A】 気象GPV 解析・キャッシュエンジン

# VERSION: 97.0 (最新プロセス管理 + ガイダンス/大気解析 統合版)

# ==============================================================================

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:

    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

# ==============================================================================

# バックグラウンド処理用ワーカーコード (動的生成)

# ※ cfgribのメモリリークを完全に防ぐため、別プロセスで実行して破棄する構造

# ==============================================================================

WORKER_SCRIPT = os.path.join(APP_DIR, “_gpv_worker.py”)

WORKER_CODE = “””

import sys, traceback

def main():

    try:

        import os, gc, subprocess, warnings

        import numpy as np

        import cfgrib

        warnings.filterwarnings(“ignore”)

        os.environ[“ECCODES_MAX_VALUES”] = “5000000”

        # Windows用 DLLパス設定

        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]       # GPV / ANAL / GUID

        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}_{mode}_{init}_{ft_val}_{suffix}.bin”)

                # モード別の正規表現マッチ

                if mode == “ANAL”:

                    match_str = “:(anl):”

                elif ft_val == 0:

                    match_str = “:(anl|0 hour [^:]+|[0-9]+\\\\-0 hour [^:]+):”

                else:

                    match_str = f”:({ft_val} hour [^:]+|[0-9]+\\\\-{ft_val} hour [^:]+):”

                cmd = [wgrib2_path, fin, “-match”, match_str, “-grib”, fout]

                try:

                    subprocess.run(cmd, creationflags=creationflags, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

                    if os.path.exists(fout) and os.path.getsize(fout) > 0: return fout

                except Exception: pass

                return “NONE”

            f1_mini = slice_with_wgrib2(f1, t_ft, “1”)

            f2_mini = slice_with_wgrib2(f2, t_ft, “2”)

            def process_file(filepath, is_pall):

                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]

                            is_ens = ‘number’ in da.dims

                            # 座標系の保存

                            if mode in [“GPV”, “ANAL”]:

                                if not is_pall and ‘lon_surf’ not in d and lon is not None:

                                    d[‘lon_surf’] = lon; d[‘lat_surf’] = lat; d[‘lon’] = lon; d[‘lat’] = lat

                                elif is_pall and ‘lon_pall’ not in d and lon is not None:

                                    d[‘lon_pall’] = lon; d[‘lat_pall’] = lat

                            elif mode == “GUID”:

                                if ‘lon’ not in d and lon is not None:

                                    d[‘lon’] = lon; d[‘lat’] = lat

                            da_step = da.isel(step=0) if ‘step’ in da.dims else da

                            if is_ens and ‘number’ in da_step.coords: da_step = da_step.isel(number=0)

                            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)

                            # ========== GPV / ANAL モードの解析 ==========

                            if mode in [“GPV”, “ANAL”]:

                                if not is_pall:

                                    val = da_step.values.copy()

                                    while val.ndim > 2: val = val[0]

                                    if sName in [‘prmsl’, ‘msl’, ‘mslet’]: d[‘slp’] = val / 100.0 if np.nanmax(val) > 2000 else val

                                    elif sName in [‘pres’, ‘sp’] or ‘pressure’ in attrs_str:

                                        if ‘slp’ not in d: d[‘pres’] = val / 100.0 if np.nanmax(val) > 2000 else val

                                    elif sName in [’10u’, ‘u’, ‘u10’] or (‘u-component’ in attrs_str and ’10’ in attrs_str): d[‘u10’] = val

                                    elif sName in [’10v’, ‘v’, ‘v10’] or (‘v-component’ in attrs_str and ’10’ in attrs_str): d[‘v10’] = val

                                    elif sName in [‘2t’, ‘t’, ‘t2m’, ‘temp’] or (‘temperature’ in attrs_str and ‘2’ in attrs_str):

                                        d[‘t2m’] = val – 273.15 if np.nanmax(val) > 150 else val

                                    elif sName in [‘2r’, ‘r’, ‘rh2m’, ‘rh’] or (‘humidity’ in attrs_str): d[‘rh2m’] = val

                                    elif sName in [‘tcc’, ‘hcc’, ‘mcc’, ‘lcc’]: d[sName] = val

                                    elif sName in [‘tp’, ‘apcp’, ‘pr’, ‘precip’] or ‘precip’ in attrs_str or ‘accum’ in attrs_str:

                                        if ‘precip’ not in d: d[‘precip’] = np.nan_to_num(val, nan=0.0)

                                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)

                                    else: levels = np.atleast_1d([da.attrs.get(‘GRIB_level’, 0)])

                                    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: d[f’t{lvl}’] = val_l – 273.15 if np.nanmax(val_l) > 150 else val_l

                                            elif sName in [‘u’, ‘u-component’]: d[f’u{lvl}’] = val_l

                                            elif sName in [‘v’, ‘v-component’]: d[f’v{lvl}’] = val_l

                                            elif sName in [‘r’, ‘rh’, ‘humidity’]: d[f’r{lvl}’] = val_l

                                            elif sName in [‘w’, ‘v-velocity’, ‘dz’]: d[f’w{lvl}’] = val_l

                                            elif sName in [‘gh’, ‘z’, ‘geopotential’]: d[f’gh{lvl}’] = val_l

                            # ========== ガイダンス(GUID) モードの解析 ==========

                            elif mode == “GUID”:

                                val = da_step.values.copy()

                                while val.ndim > 2: val = val[0]

                                if sName != ‘unknown’: d[sName] = val

                                else: d[f”var_{disc}_{cat}_{num}”] = val

                                if sName in [‘2t’, ‘t’, ‘t2m’, ‘tmp’, ‘temp’] or (disc == 0 and cat == 0 and num == 0) or ‘temperature’ in attrs_str: d[‘t2m’] = val

                                elif sName in [‘2r’, ‘r’, ‘rh2m’, ‘rh’] or (disc == 0 and cat == 1 and num == 1) or ‘humidity’ in attrs_str: d[‘rh2m’] = val

                                elif sName in [’10u’, ‘u’, ‘u10’, ‘ugrd’] or (disc == 0 and cat == 2 and num == 2): d[‘u10’] = val

                                elif sName in [’10v’, ‘v’, ‘v10’, ‘vgrd’] or (disc == 0 and cat == 2 and num == 3): d[‘v10’] = val

                                elif sName in [‘tp’, ‘apcp’, ‘pr’, ‘precip’] or (disc == 0 and cat == 1 and num in [8, 52]) or ‘precip’ in attrs_str:

                                    if ‘precip’ not in d: d[‘precip’] = val

                                elif sName in [‘weasd’, ‘snod’, ‘snow’] or (disc == 0 and cat == 1 and num in [11, 13, 29, 60]) or ‘snow’ in attrs_str: d[‘snow’] = val

                                elif sName in [‘wea’, ‘nswrs’, ‘nswrv’, ‘weather’] or (disc == 0 and cat == 19 and num == 192) or ‘weather’ in attrs_str: d[‘wea’] = val

                                elif sName in [‘thund’, ‘lig’, ‘ltng’, ‘thunder’, ‘prstm’] or (disc == 0 and cat == 19 and num == 193): d[‘thund’] = val

                                elif sName in [‘tmax’, ‘max t’] or (disc == 0 and cat == 0 and num == 4): d[‘tmax’] = val

                                elif sName in [‘tmin’, ‘min t’] or (disc == 0 and cat == 0 and num == 5): d[‘tmin’] = val

                    for ds in dss: ds.close()

                except Exception: pass

                finally: gc.collect()

            process_file(f1_mini, False)

            process_file(f2_mini, True)

            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

        # ========== 各FTごとの派生パラメータ計算 ==========

        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 and ‘lon_pall’ in d:

                    d[‘vort500’] = calculate_vorticity(u500, v500, d[‘lon_pall’], d[‘lat_pall’])

            pfx = “GUID_” if mode == “GUID” else “”

            out_file = os.path.join(cache, f”{model}_{pfx}{init}_FT{t_ft:02d}.npz”)

            np.savez_compressed(out_file, **d)

            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

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)

# ==============================================================================

# データスキャン・ジョブ管理スレッド

# ==============================================================================

class DataParserThread(QThread):

    log_signal = pyqtSignal(str)

    progress_signal = pyqtSignal(int, str)

    finished_signal = pyqtSignal(bool)

    def __init__(self, folder_paths, output_dir, last_reported):

        super().__init__()

        self.folder_paths = [path for path in folder_paths if path and os.path.exists(path)]

        self.output_dir = output_dir

        self.last_reported = last_reported

        self.abort = False

    def run(self):

        if not self.folder_paths:

            self.finished_signal.emit(False); return

        if not os.path.exists(WGRIB2_EXE):

            self.log_signal.emit(f”⚠️ wgrib2が見つかりません。”); self.finished_signal.emit(False); return

        has_new_data = False

        scan_started = False

        reported_new_file = False

        creationflags = 0x08000000 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”

        for folder_path in self.folder_paths:

            if self.abort: break

            msm_files_all = glob.glob(os.path.join(folder_path, ‘**’, ‘*MSM*GPV*.bin’), recursive=True)

            gsm_files_all = glob.glob(os.path.join(folder_path, ‘**’, ‘*GSM*GPV*.bin’), recursive=True)

            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_latest_files(file_list):

                if not file_list: return [], “UNKNOWN”

                times = set(re.search(r’_(\d{14})_’, os.path.basename(f)).group(1) for f in file_list if re.search(r’_(\d{14})_’, f))

                if not times: return file_list, “UNKNOWN”

                latest_time = max(times)

                return [f for f in file_list if latest_time in f], latest_time

            msm_files, msm_init = get_latest_files(msm_files_all)

            gsm_files, gsm_init = get_latest_files(gsm_files_all)

            gg_files, gg_init = get_latest_files(gsm_guid)

            mg_files, mg_init = get_latest_files(msm_guid)

            meps_files, meps_init = get_latest_files(meps_gpv)

            anal_files, anal_init = get_latest_files(anal_files_all)

            phases = [

                (‘GSM’, gsm_files, gsm_init, list(range(0, 265, 3)), “GPV”),

                (‘MSM’, msm_files, msm_init, list(range(0, 80, 1)), “GPV”),

                (‘GSM’, gg_files, gg_init, list(range(0, 265, 3)), “GUID”),

                (‘MSM’, mg_files, mg_init, list(range(0, 80, 1)), “GUID”),

                (‘MEPS’, meps_files, meps_init, list(range(0, 40, 3)), “GPV”),

                (‘ANAL’, anal_files, anal_init, [0], “ANAL”)

            ]

            for model_name, files, init_time_str, ft_list, mode in phases:

                if self.abort or not files or init_time_str == “UNKNOWN”: continue

                if self.last_reported.get(f”{model_name}_{mode}”) != init_time_str:

                    self.log_signal.emit(f”✅ {model_name} {mode} 最新データ発見: {init_time_str}”)

                    self.last_reported[f”{model_name}_{mode}”] = init_time_str

                    reported_new_file = True

                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 = mode

                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: # GUID

                        f1 = self._get_file_for_ft(files, “”, ft) or 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)

                self.log_signal.emit(f”🚀 {model_name} {proc_name} 解析開始 (対象: {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, ”):

                            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}_{mode} 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}”)

                        process.stdout.close()

                        process.wait()

        if has_new_data or reported_new_file:

            self.log_signal.emit(“✅ スキャン・抽出完了”)

        self.finished_signal.emit(has_new_data)

    def _get_file_for_ft(self, files_list, keyword, ft):

        # ファイルの結合状態を判定

        is_gsm = any(“GSM_GPV” in f for f in files_list)

        if is_gsm:

            for f in files_list:

                if keyword and keyword not in f: continue

                m = re.search(r’FD(\d{2})(\d{2})-(\d{2})(\d{2})’, os.path.basename(f))

                if m and int(m.group(1))*24 + int(m.group(2)) <= ft <= int(m.group(3))*24 + int(m.group(4)): return f

            return next((f for f in files_list if (not keyword or keyword in f)), None)

        for f in files_list:

            if not keyword or keyword in f:

                m = re.search(r’FH(\d{2})-(\d{2})’, os.path.basename(f))

                if m and int(m.group(1)) <= ft <= int(m.group(2)): return f

                elif not m: return f

        return None

# ==============================================================================

# GUI & タスクトレイ 管理

# ==============================================================================

class EngineStatusWindow(QWidget):

    def __init__(self):

        super().__init__()

        self.setWindowTitle(“GPV 解析エンジン (Ver 97.0 – 統合版)”)

        self.setFixedSize(650, 580)

        self.setStyleSheet(“background-color: #2b2b2b; color: #e0e0e0; font-family: ‘MS Gothic’;”)

        layout = QVBoxLayout(self)

        self.status_label = QLabel(“状態: 起動中…”)

        self.status_label.setStyleSheet(“font-size: 14pt; font-weight: bold; color: #00fa9a;”)

        layout.addWidget(self.status_label)

        control_layout = QHBoxLayout()

        self.pause_btn = QPushButton(“⏸ 一時停止”)

        self.resume_btn = QPushButton(“▶ 手動強制スキャン”)

        self.stop_btn = QPushButton(“⏹ 停止”)

        self.clear_btn = QPushButton(“🧹 キャッシュ全削除”)

        for btn in [self.pause_btn, self.resume_btn, self.stop_btn]:

            btn.setStyleSheet(“background-color: #555; padding: 4px; font-weight: bold;”)

            control_layout.addWidget(btn)

        self.clear_btn.setStyleSheet(“background-color: #b22222; padding: 4px; font-weight: bold; color: white;”)

        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: #3498DB; padding: 4px; font-weight: bold; color: white;”)

        out_layout.addWidget(self.out_folder_btn)

        self.out_folder_label = QLabel(“未設定”)

        self.out_folder_label.setStyleSheet(“background: #444; padding: 4px; border: 1px solid #777;”)

        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(“📋 ログをコピー”)

        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; color: white; font-weight: bold; padding: 6px;”)

        layout.addWidget(self.exit_btn)

    def log(self, message):

        self.log_list.addItem(f”[{datetime.now().strftime(‘%H:%M:%S’)}] {message}”)

        self.log_list.scrollToBottom()

        write_syslog(message)

    def closeEvent(self, event):

        event.ignore()

        self.hide()

        if hasattr(self, ‘tray_msg_callback’):

            self.tray_msg_callback()

class TrayApp(QApplication):

    def __init__(self, sys_argv):

        super().__init__(sys_argv)

        self.setQuitOnLastWindowClosed(False)

        self.settings = QSettings(“SapporoWeatherApp”, “GPVEngine”)

        self.monitor_folders = [self.settings.value(f”watch_dir_{i+1}”, “”) for i in range(3)]

        global CURRENT_OUTPUT_DIR

        self.output_dir = self.settings.value(“output_dir”, DEFAULT_OUTPUT_DIR)

        CURRENT_OUTPUT_DIR = self.output_dir

        os.makedirs(self.output_dir, exist_ok=True)

        self.last_reported = {}

        self.window = EngineStatusWindow()

        self.window.tray_msg_callback = self.show_tray_message

        self.tray_icon = QSystemTrayIcon(self)

        self.tray_icon.setIcon(create_lightning_icon())

        self.tray_icon.setToolTip(“GPV抽出エンジン (稼働中)”)

        tray_menu = QMenu()

        show_action = QAction(“ウィンドウを表示”, self)

        show_action.triggered.connect(self.restore_window)

        quit_action = QAction(“完全に終了する”, self)

        quit_action.triggered.connect(self.quit)

        tray_menu.addAction(show_action)

        tray_menu.addAction(quit_action)

        self.tray_icon.setContextMenu(tray_menu)

        self.tray_icon.activated.connect(self.tray_icon_activated)

        self.tray_icon.show()

        with open(os.path.join(self.output_dir, “system_log.txt”), “w”, encoding=”utf-8″) as f: pass

        self.window.log(“===============================”)

        self.window.log(“システム起動: Ver 97.0 (大気解析・ガイダンス対応)”)

        self.window.log(f”wgrib2 パス確認: {‘✅ 認識完了’ if os.path.exists(WGRIB2_EXE) else ‘⚠️ 未発見’}”)

        self.window.log(f”現在の出力先: {self.output_dir}”)

        self.window.log(“===============================”)

        self.window.out_folder_label.setText(self.output_dir)

        self.window.out_folder_btn.clicked.connect(self.select_output_folder)

        folders_set = False

        for i in range(3):

            self.window.folder_btns[i].clicked.connect(lambda checked, idx=i: self.select_folder(idx))

            self.update_folder_btn_text(i)

            if self.monitor_folders[i]: folders_set = True

        if not folders_set:

            self.window.status_label.setText(“状態: 読込元フォルダ未設定”)

        self.window.copy_btn.clicked.connect(self.copy_logs)

        self.window.pause_btn.clicked.connect(self.pause_parsing)

        self.window.resume_btn.clicked.connect(self.force_scan)

        self.window.stop_btn.clicked.connect(self.stop_parsing)

        self.window.clear_btn.clicked.connect(self.clear_cache)

        self.window.exit_btn.clicked.connect(self.quit)

        self.timer = QTimer()

        self.timer.timeout.connect(self.run_parser)

        self.timer.start(10000)

        self.is_processing = False

        self.run_parser()

        self.window.show()

    def tray_icon_activated(self, reason):

        if reason == QSystemTrayIcon.ActivationReason.DoubleClick:

            self.restore_window()

    def restore_window(self):

        self.window.showNormal()

        self.window.activateWindow()

    def show_tray_message(self):

        self.tray_icon.showMessage(

            “エンジン稼働中”,

            “ウィンドウを閉じましたが、裏で抽出を続けています。終了する場合は右クリックから「完全に終了する」を選んでください。”,

            QIcon(self.tray_icon.icon()),

            3000

        )

    def select_output_folder(self):

        global CURRENT_OUTPUT_DIR

        folder = QFileDialog.getExistingDirectory(self.window, “出力先フォルダ(共有)を選択”, self.output_dir)

        if folder:

            self.output_dir = folder

            CURRENT_OUTPUT_DIR = folder

            self.settings.setValue(“output_dir”, folder)

            self.window.out_folder_label.setText(folder)

            self.window.log(f”💾 出力先フォルダを更新しました: {folder}”)

            self.run_parser()

    def clear_cache(self):

        reply = QMessageBox.question(self.window, “確認”, “現在の出力先のデータをすべて削除し再抽出しますか?”,

                                     QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)

        if reply == QMessageBox.StandardButton.Yes:

            self.timer.stop()

            self.window.log(“🗑️ キャッシュデータのお掃除を開始します…”)

            for f in glob.glob(os.path.join(self.output_dir, “*.npz”)) + glob.glob(os.path.join(self.output_dir, “*.json”)) + glob.glob(os.path.join(self.output_dir, “*.bin”)):

                try: os.remove(f)

                except 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, f”読込元フォルダ {index+1} を選択”)

        if folder:

            self.monitor_folders[index] = folder

            self.settings.setValue(f”watch_dir_{index+1}”, folder)

            self.update_folder_btn_text(index)

            self.window.log(f”フォルダ{index+1}を更新: {folder}”)

            self.run_parser()

    def update_folder_btn_text(self, i):

        f = self.monitor_folders[i]

        self.window.folder_btns[i].setText(f”📁 {os.path.basename(f)}” if f else f”📁 未設定 {i+1}”)

    def copy_logs(self):

        QApplication.clipboard().setText(“\n”.join([self.window.log_list.item(i).text() for i in range(self.window.log_list.count())]))

    def pause_parsing(self):

        self.timer.stop(); self.window.log(“一時停止しました”)

    def force_scan(self):

        self.window.log(“手動強制スキャンを開始します…”); self.timer.start(10000); self.run_parser()

    def stop_parsing(self):

        self.timer.stop(); self.window.log(“停止しました”)

    def run_parser(self):

        if self.is_processing: return

        if not any(self.monitor_folders):

            self.window.status_label.setText(“状態: 読込元フォルダ未設定”)

            return

        self.is_processing = True

        self.worker = DataParserThread(self.monitor_folders, self.output_dir, self.last_reported)

        self.worker.log_signal.connect(self.window.log)

        self.worker.progress_signal.connect(lambda v, t: self.window.status_label.setText(f”状態: {t}”))

        self.worker.finished_signal.connect(self.on_parse_finished)

        self.worker.start()

    def on_parse_finished(self, has_new_data):

        self.is_processing = False

        self.window.status_label.setText(“状態: 待機中 (自動スキャン継続中)”)

if __name__ == ‘__main__’:

    app = TrayApp(sys.argv)

    sys.exit(app.exec())

コメント