# ==========================================
# 【App A】 GPV解析・キャッシュエンジン
# VERSION: 90.1 (リアルタイム設定反映・即時停止・アトミック保存機能搭載)
# ==========================================
import sys, os, glob, re, subprocess
from datetime import datetime
os.environ['QT_API'] = 'pyqt6'
from PyQt6.QtWidgets import (QApplication, QWidget, QVBoxLayout, QHBoxLayout,
QPushButton, QLabel, QListWidget, QFileDialog, QMessageBox,
QSystemTrayIcon, QMenu)
from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal, QSettings
from PyQt6.QtGui import QIcon, QPixmap, QPainter, QColor, QAction
# 起動失敗時に原因を特定できるよう、エラー内容を表示して一時停止するように改良
try:
import cfgrib
except ImportError as e:
print("\n" + "="*60)
print("【起動エラー】cfgrib または依存ライブラリの読み込みに失敗しました。")
print(f"エラー詳細: {e}")
print("="*60)
print("現在のAnaconda環境(gpv_env_v2)にcfgribが正しくインストールされていないか、")
print("DLLのパスが正しく通っていない可能性があります。")
input("\nEnterキーを押すとウィンドウを閉じます...")
sys.exit(1)
APP_DIR = os.getcwd()
DEFAULT_OUTPUT_DIR = os.path.join(APP_DIR, "gpv_cache_npz")
CURRENT_OUTPUT_DIR = DEFAULT_OUTPUT_DIR
WGRIB2_EXE = os.path.join(APP_DIR, "wgrib2_data", "wgrib2.exe")
os.makedirs(CURRENT_OUTPUT_DIR, exist_ok=True)
def write_syslog(msg):
log_file = os.path.join(CURRENT_OUTPUT_DIR, "system_log.txt")
try:
with open(log_file, "a", encoding="utf-8") as f:
f.write(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}\n")
except Exception: pass
WORKER_SCRIPT = os.path.join(APP_DIR, "_gpv_worker.py")
def create_lightning_icon():
pixmap = QPixmap(64, 64)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
font = painter.font()
font.setPixelSize(50)
painter.setFont(font)
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "⚡")
painter.end()
return QIcon(pixmap)
WORKER_CODE = """
import sys, traceback
def main():
try:
import os, gc, json, subprocess
import numpy as np
import warnings; warnings.filterwarnings("ignore")
import cfgrib
os.environ["ECCODES_MAX_VALUES"] = "5000000"
if sys.platform == "win32":
conda_dir = os.path.dirname(sys.executable)
dll_paths = [os.path.join(conda_dir, "Library", "bin"), os.path.join(conda_dir, "bin")]
for p in dll_paths:
if os.path.exists(p):
os.environ["PATH"] = f"{p};{os.environ.get('PATH', '')}"
try: os.add_dll_directory(p)
except Exception: pass
def calculate_vorticity(u, v, lon, lat):
try:
R = 6371000.0; LON, LAT = np.meshgrid(lon, lat) if lon.ndim == 1 else (lon, lat)
rad_lat = np.deg2rad(LAT); rad_lon = np.deg2rad(LON)
dy = R * np.gradient(rad_lat, axis=0); dx = R * np.cos(rad_lat) * np.gradient(rad_lon, axis=1)
dx[dx == 0] = 1e-10; dy[dy == 0] = 1e-10
return ((np.gradient(v, axis=1) / dx) - (np.gradient(u, axis=0) / dy)) * 1e5
except Exception: return np.zeros_like(u)
model = sys.argv[1]; mode = sys.argv[2]; cache = sys.argv[3]
init = sys.argv[4]; target_fts_str = sys.argv[5]
f1 = sys.argv[6]; f2 = sys.argv[7]; wgrib2_path = sys.argv[8]
target_fts = [int(x) for x in target_fts_str.split(',')]
d_all = {ft: {} for ft in target_fts}
creationflags = 0x08000000 if sys.platform == "win32" else 0
for t_ft in target_fts:
d = d_all[t_ft]
def slice_with_wgrib2(fin, ft_val, suffix):
if fin == "NONE": return "NONE"
fout = os.path.join(cache, f"temp_{model}_{init}_{ft_val}_{suffix}.bin")
if ft_val == 0:
match_str = r":(anl|0 hour [^:]+|[0-9]*[-]0 hour [^:]+):"
else:
patterns = [f"{ft_val} hour", f"[0-9]*[-]{ft_val} hour"]
if ft_val % 24 == 0:
days = ft_val // 24
patterns.extend([f"{days} day", f"[0-9]*[-]{days} day", f"{days} d", f"[0-9]*[-]{days} d"])
pat_joined = "|".join(patterns)
match_str = f":({pat_joined})[^:]*:"
cmd = [wgrib2_path, fin, "-match", match_str, "-grib", fout]
try:
res = subprocess.run(cmd, creationflags=creationflags, capture_output=True, text=True)
if res.returncode != 0:
print(f"DEBUG_WGRIB2_ERR: FT={ft_val} in {os.path.basename(fin)} / match={match_str}")
if os.path.exists(fout) and os.path.getsize(fout) > 0: return fout
except Exception as e:
print(f"DEBUG_EXCEPTION: slice_with_wgrib2: {e}")
return "NONE"
f1_mini = slice_with_wgrib2(f1, t_ft, "1")
f2_mini = slice_with_wgrib2(f2, t_ft, "2")
def process_file(filepath):
if filepath == "NONE": return
try:
dss = cfgrib.open_datasets(filepath, backend_kwargs={'indexpath': ''})
for ds in dss:
lon = ds.longitude.values if hasattr(ds, 'longitude') else None
lat = ds.latitude.values if hasattr(ds, 'latitude') else None
for v in ds.data_vars:
da = ds[v]
da_step = da.isel(step=0) if 'step' in da.dims else da
if 'number' in da_step.coords: da_step = da_step.isel(number=0)
sName = str(da.attrs.get('GRIB_shortName', v)).lower()
attrs_str = str(da.attrs).lower()
disc = da.attrs.get('GRIB_discipline', -1)
cat = da.attrs.get('GRIB_parameterCategory', -1)
num = da.attrs.get('GRIB_parameterNumber', -1)
is_upper = 'isobaricInhPa' in da_step.coords or 'level' in da_step.coords
# ▼▼▼ 修正版:発雷確率の変則解像度に対応した空間結合ロジック ▼▼▼
def assign(k, val_to_assign, is_upper_flag):
if lon is None or lat is None:
d[k] = val_to_assign
return
lon_1d = lon[0, :] if lon.ndim == 2 else lon
lat_1d = lat[:, 0] if lat.ndim == 2 else lat
# 【重要】発雷確率(thund, tstm)の変則サイズ(141x121等)を検知した場合
# MSMファイル内の広域格子(GSM相当)に合わせた専用の軸をその場で生成・上書きする
if k in ['thund', 'tstm'] and val_to_assign.shape != (len(lat_1d), len(lon_1d)):
# データの形状から、そのデータ専用の適正な緯度・経度数を逆算する
target_lat_len, target_lon_len = val_to_assign.shape
# 日本全域(20N-50N, 120E-150E)に対応するグリッド軸を動的に補間
lat_1d = np.linspace(lat_1d[0], lat_1d[-1], target_lat_len)
lon_1d = np.linspace(lon_1d[0], lon_1d[-1], target_lon_len)
lon_key = 'lon_pall' if is_upper_flag else 'lon_surf'
lat_key = 'lat_pall' if is_upper_flag else 'lat_surf'
# 変数名ごとに専用の解像度で座標を管理するため、独立したキー名にする
if k in ['thund', 'tstm']:
lon_key = f'lon_{k}'
lat_key = f'lat_{k}'
if k not in d:
d[k] = val_to_assign.copy()
d[lon_key] = lon_1d.copy()
d[lat_key] = lat_1d.copy()
# ビューワー(App B)表示用の標準座標も、それが最初の登録なら確保
if not is_upper_flag and 'lon' not in d:
d['lon'] = lon_1d.copy()
d['lat'] = lat_1d.copy()
else:
old_lon = d[lon_key]
old_lat = d[lat_key]
old_val = d[k]
if old_val.shape == val_to_assign.shape and np.array_equal(old_lon, lon_1d):
d[k] = val_to_assign.copy()
return
new_lon = np.unique(np.concatenate([np.round(old_lon, 4), np.round(lon_1d, 4)]))
new_lat = np.unique(np.concatenate([np.round(old_lat, 4), np.round(lat_1d, 4)]))
new_lat = np.sort(new_lat)[::-1] if old_lat[0] > old_lat[-1] else np.sort(new_lat)
new_lon = np.sort(new_lon)
canvas = np.full((len(new_lat), len(new_lon)), np.nan)
lat_idx_old = np.where(np.isin(np.round(new_lat, 4), np.round(old_lat, 4)))[0]
lon_idx_old = np.where(np.isin(np.round(new_lon, 4), np.round(old_lon, 4)))[0]
if canvas[np.ix_(lat_idx_old, lon_idx_old)].shape == old_val.shape:
canvas[np.ix_(lat_idx_old, lon_idx_old)] = old_val
lat_idx_new = np.where(np.isin(np.round(new_lat, 4), np.round(lat_1d, 4)))[0]
lon_idx_new = np.where(np.isin(np.round(new_lon, 4), np.round(lon_1d, 4)))[0]
if canvas[np.ix_(lat_idx_new, lon_idx_new)].shape == val_to_assign.shape:
existing = canvas[np.ix_(lat_idx_new, lon_idx_new)]
mask = np.isnan(existing)
existing[mask] = val_to_assign[mask]
canvas[np.ix_(lat_idx_new, lon_idx_new)] = existing
d[k] = canvas
d[lon_key] = new_lon
d[lat_key] = new_lat
# 標準キーもこの変数の解像度に合わせて同期
if k in ['thund', 'tstm'] or 'lon' not in d:
d['lon'] = new_lon
d['lat'] = new_lat
# ▲▲▲ 空間結合ロジックここまで ▲▲▲
if mode in ["GPV", "ANAL"]:
if not is_upper:
val = da_step.values.copy()
while val.ndim > 2: val = val[0]
if sName in ['prmsl', 'msl', 'mslet']:
assign('slp', val / 100.0 if np.nanmax(val) > 2000 else val, is_upper)
elif sName in ['pres', 'sp'] or 'pressure' in attrs_str:
if 'slp' not in d and 'pres' not in d: assign('pres', val / 100.0 if np.nanmax(val) > 2000 else val, is_upper)
elif sName in ['10u', 'u', 'u10'] or ('u-component' in attrs_str and '10' in attrs_str):
assign('u10', val, is_upper)
elif sName in ['10v', 'v', 'v10'] or ('v-component' in attrs_str and '10' in attrs_str):
assign('v10', val, is_upper)
elif sName in ['2t', 't', 't2m', 'temp'] or ('temperature' in attrs_str and '2' in attrs_str):
assign('t2m', val - 273.15 if np.nanmax(val) > 150 else val, is_upper)
elif sName in ['2r', 'r', 'rh2m', 'rh'] or ('humidity' in attrs_str):
assign('rh2m', val, is_upper)
elif sName in ['tcc', 'hcc', 'mcc', 'lcc'] or (disc == 0 and cat == 6):
assign(sName if sName != 'unknown' else f"var_{disc}_{cat}_{num}", val, is_upper)
elif sName in ['tp', 'apcp', 'pr', 'precip'] or 'precip' in attrs_str or 'accum' in attrs_str:
if 'precip' not in d: assign('precip', np.nan_to_num(val, nan=0.0), is_upper)
else:
levels = []
if 'isobaricInhPa' in da_step.coords: levels = np.atleast_1d(da_step.isobaricInhPa.values)
elif 'level' in da_step.coords: levels = np.atleast_1d(da_step.level.values)
for l_idx, lvl in enumerate(levels):
lvl = int(lvl)
if lvl in [300, 500, 600, 700, 850, 925, 950, 975]:
if len(levels) > 1:
dim_n = 'isobaricInhPa' if 'isobaricInhPa' in da_step.coords else 'level'
val_l = da_step.isel(**{dim_n: l_idx}).values.copy()
else:
val_l = da_step.values.copy()
while val_l.ndim > 2: val_l = val_l[0]
if sName in ['t', 'temp'] or 'temperature' in attrs_str: assign(f't{lvl}', val_l - 273.15 if np.nanmax(val_l) > 150 else val_l, is_upper)
elif sName in ['u', 'u-component']: assign(f'u{lvl}', val_l, is_upper)
elif sName in ['v', 'v-component']: assign(f'v{lvl}', val_l, is_upper)
elif sName in ['r', 'rh', 'humidity']: assign(f'r{lvl}', val_l, is_upper)
elif sName in ['w', 'v-velocity', 'dz']: assign(f'w{lvl}', val_l, is_upper)
elif sName in ['gh', 'z', 'geopotential']: assign(f'gh{lvl}', val_l, is_upper)
elif mode == "GUID":
val = da_step.values.copy()
while val.ndim > 2: val = val[0]
if sName != 'unknown': assign(sName, val, is_upper)
else: assign(f"var_{disc}_{cat}_{num}", val, is_upper)
if sName in ['2t', 't', 't2m', 'tmp', 'temp'] or (disc == 0 and cat == 0 and num == 0) or 'temperature' in attrs_str:
assign('t2m', val - 273.15 if np.nanmax(val) > 150 else val, is_upper)
elif sName in ['2r', 'r', 'rh2m', 'rh'] or (disc == 0 and cat == 1 and num == 1) or 'humidity' in attrs_str:
assign('rh2m', val, is_upper)
elif sName in ['10u', 'u', 'u10', 'ugrd'] or (disc == 0 and cat == 2 and num == 2):
assign('u10', val, is_upper)
elif sName in ['10v', 'v', 'v10', 'vgrd'] or (disc == 0 and cat == 2 and num == 3):
assign('v10', val, is_upper)
elif sName in ['tp', 'apcp', 'pr', 'precip'] or (disc == 0 and cat == 1 and num in [8, 52]) or 'precip' in attrs_str or 'accum' in attrs_str:
if 'precip' not in d: assign('precip', np.nan_to_num(val, nan=0.0), is_upper)
elif sName in ['weasd', 'snod', 'snow', 'asnow'] or (disc == 0 and cat == 1 and num in [11, 13, 29, 60]) or 'snow' in attrs_str:
assign('snow', val, is_upper)
elif sName in ['wea', 'nswrs', 'nswrv', 'weather'] or (disc == 0 and cat == 19 and num == 192) or 'weather' in attrs_str:
assign('wea', val, is_upper)
elif sName in ['thund', 'lig', 'ltng', 'thunder', 'prstm', 'tstm'] or (disc == 0 and cat == 19 and num == 193) or 'thunder' in attrs_str:
assign('thund', val, is_upper)
for ds in dss: ds.close()
except Exception: pass
finally: gc.collect()
process_file(f1_mini)
process_file(f2_mini)
try:
if f1_mini != "NONE" and os.path.exists(f1_mini): os.remove(f1_mini)
if f2_mini != "NONE" and os.path.exists(f2_mini): os.remove(f2_mini)
except Exception: pass
for t_ft in target_fts:
d = d_all[t_ft]
if not d: continue
if mode in ["GPV", "ANAL"]:
for lvl in [300, 500, 600, 700, 850, 925, 950, 975]:
tc = d.get(f't{lvl}'); rh = d.get(f'r{lvl}')
if tc is not None and rh is not None:
rh_c = np.clip(rh, 0.1, 100)
e = 6.112 * np.exp((17.67*tc)/(tc+243.5)) * (rh_c/100.0)
td = (243.5*np.log(e/6.112))/(17.67-np.log(e/6.112))
d[f'tddep{lvl}'] = tc - td
tk = tc + 273.15; theta = tk*(1000.0/lvl)**0.2854; w = 0.622*e/(lvl-e)
d[f'ep{lvl}'] = theta * np.exp((2.5e6*w)/(1004.0*tk))
u500 = d.get('u500'); v500 = d.get('v500')
if u500 is not None and v500 is not None:
vort_lon = d.get('lon_pall') if 'lon_pall' in d else d.get('lon_surf')
vort_lat = d.get('lat_pall') if 'lat_pall' in d else d.get('lat_surf')
if vort_lon is not None and vort_lat is not None:
d['vort500'] = calculate_vorticity(u500, v500, vort_lon, vort_lat)
# --- アトミック保存の処理 ---
pfx = "GUID_" if mode == "GUID" else ""
final_filepath = os.path.join(cache, f"{model}_{pfx}{init}_FT{t_ft:02d}.npz")
temp_filepath = final_filepath + ".tmp"
# 一度 .tmp ファイルとして保存
np.savez_compressed(temp_filepath, **d)
# 書き込み完了後、一瞬で本来の .npz の名前にリネーム(上書き)
os.replace(temp_filepath, final_filepath)
print(f"SUCCESS:{t_ft}", flush=True)
except BaseException as e:
print(f"CRITICAL_ERROR: {traceback.format_exc()}", flush=True)
sys.exit(1)
if __name__ == '__main__': main()
"""
try:
with open(WORKER_SCRIPT, "w", encoding="utf-8") as f: f.write(WORKER_CODE)
except Exception: pass
class DataParserThread(QThread):
log_signal = pyqtSignal(str); progress_signal = pyqtSignal(int, str); finished_signal = pyqtSignal(bool)
def __init__(self, folder_paths, output_dir, last_reported, scan_mode="12h"):
super().__init__()
self.folder_paths = [path for path in folder_paths if path and os.path.exists(path)]
self.output_dir = output_dir
self.last_reported = last_reported
self.scan_mode = scan_mode
self.abort = False
def run(self):
if not self.folder_paths: self.finished_signal.emit(False); return
if not os.path.exists(WGRIB2_EXE):
self.log_signal.emit(f"⚠️ wgrib2が見つかりません。"); self.finished_signal.emit(False); return
has_new_data = False; scan_started = False
creationflags = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
conda_dir = os.path.dirname(sys.executable); lib_bin = os.path.join(conda_dir, "Library", "bin")
env = os.environ.copy(); env["PATH"] = f"{lib_bin};{env.get('PATH', '')}"; env["PYTHONIOENCODING"] = "utf-8"
now_utc = datetime.utcnow()
for folder_path in self.folder_paths:
if self.abort: break
all_found = glob.glob(os.path.join(folder_path, '**', '*.bin'), recursive=True)
msm_files_all = glob.glob(os.path.join(folder_path, '**', '*MSM*GPV*.bin'), recursive=True)
gsm_files_raw = glob.glob(os.path.join(folder_path, '**', '*GSM*GPV*.bin'), recursive=True)
gsm_jp_files = [f for f in gsm_files_raw if 'Rgl' not in f and ('Rjp' in f or 'Japan' in f or 'jp' in f.lower())]
gsm_files_all = gsm_jp_files if gsm_jp_files else gsm_files_raw
gsm_guid = glob.glob(os.path.join(folder_path, '**', '*GSM_GUID*Toorg*.bin'), recursive=True)
msm_guid = glob.glob(os.path.join(folder_path, '**', '*MSM_GUID*Toorg*.bin'), recursive=True)
meps_gpv = glob.glob(os.path.join(folder_path, '**', '*MEPS_GPV*.bin'), recursive=True)
anal_files_all = glob.glob(os.path.join(folder_path, '**', '*ANAL_grib2*.bin'), recursive=True)
def get_all_init_groups(file_list):
if not file_list: return {}
groups = {}
for f in file_list:
m = re.search(r'_(\d{14})_', os.path.basename(f))
if m:
init = m.group(1)
if self.scan_mode == "12h":
try:
file_time = datetime.strptime(init, '%Y%m%d%H%M%S')
if (now_utc - file_time).total_seconds() > 12 * 3600:
continue
except Exception: pass
if init not in groups: groups[init] = []
groups[init].append(f)
return groups
msm_groups = get_all_init_groups(msm_files_all)
gsm_groups = get_all_init_groups(gsm_files_all)
gg_groups = get_all_init_groups(gsm_guid)
mg_groups = get_all_init_groups(msm_guid)
meps_groups = get_all_init_groups(meps_gpv)
anal_groups = get_all_init_groups(anal_files_all)
gsm_ft_target = list(range(0, 135, 3)) + list(range(138, 265, 6))
phases = [
('GSM_JP', gsm_groups, gsm_ft_target, "GPV"),
('MSM', msm_groups, list(range(0, 79, 1)), "GPV"),
('GSM', gg_groups, gsm_ft_target, "GUID"),
('MSM', mg_groups, list(range(0, 79, 1)), "GUID"),
('MEPS', meps_groups, list(range(0, 40, 3)), "GPV"),
('ANAL', anal_groups, [0], "ANAL")
]
priority_dict = {
('MSM', 'GPV'): 1,
('MSM', 'GUID'): 2,
('GSM', 'GUID'): 3,
('ANAL', 'ANAL'): 4,
('MEPS', 'GPV'): 5,
('GSM_JP', 'GPV'): 6
}
execution_plan = []
for model_name, groups, ft_list, mode in phases:
for init_time_str, files in groups.items():
prio = priority_dict.get((model_name, mode), 99)
execution_plan.append((init_time_str, model_name, files, ft_list, mode, prio))
execution_plan.sort(key=lambda x: (x[0], -x[5]), reverse=True)
for init_time_str, model_name, files, ft_list, mode, prio in execution_plan:
if self.abort: break
tasks = []
for ft in ft_list:
pfx = "GUID_" if mode == "GUID" else ""
npz_file = os.path.join(self.output_dir, f"{model_name}_{pfx}{init_time_str}_FT{ft:02d}.npz")
if not os.path.exists(npz_file): tasks.append(ft)
if not tasks: continue
if not scan_started:
self.log_signal.emit("🔍 新規/未処理の過去データを抽出中..."); scan_started = True; has_new_data = True
proc_name = "GPV" if mode in ["GPV", "ANAL"] else "ガイダンス"
file_groups = {}
for ft in tasks:
if mode == "GPV":
f1 = self._get_file_for_ft(files, "Lsurf", ft) or "NONE"
f2 = self._get_file_for_ft(files, "P-all" if any("P-all" in x for x in files) else "L-pall", ft) or "NONE"
elif mode == "ANAL":
f1 = files[0]; f2 = "NONE"
else:
f1 = files[0]; f2 = "NONE"
if f1 == "NONE" and f2 == "NONE": continue
key = (f1, f2)
if key not in file_groups: file_groups[key] = []
file_groups[key].append(ft)
if self.abort: break
self.log_signal.emit(f"🚀 {model_name} {proc_name} ({init_time_str}) 抽出開始 (対象: {len(tasks)}件)...")
total_extracted = 0
for (f1, f2), fts in file_groups.items():
if self.abort: break
for i in range(0, len(fts), 5):
if self.abort: break
chunk_fts = fts[i:i + 5]; ft_str = ",".join(map(str, chunk_fts))
cmd = [sys.executable, WORKER_SCRIPT, model_name, mode, self.output_dir, init_time_str, ft_str, f1, f2, WGRIB2_EXE]
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding='utf-8', errors='replace', creationflags=creationflags, env=env)
for line in iter(process.stdout.readline, ''):
if self.abort:
try:
process.kill()
except Exception: pass
break
line = line.strip()
if not line: continue
if line.startswith("SUCCESS:"):
ft_success = line.split(":")[1]; total_extracted += 1
self.progress_signal.emit(int((total_extracted / len(tasks)) * 100), f"⚡ {model_name} {init_time_str} FT={ft_success}h...")
self.log_signal.emit(f"⚡ 抽出完了: {model_name} {proc_name} FT={ft_success}h")
elif "CRITICAL_ERROR:" in line or "Traceback" in line or "Error:" in line or "File " in line:
self.log_signal.emit(f"⚠️ {line}")
try:
process.stdout.close(); process.wait()
except Exception: pass
if not self.abort and has_new_data:
self.log_signal.emit("✅ 全てのスキャン・抽出が完了しました")
self.finished_signal.emit(has_new_data)
def _get_file_for_ft(self, files_list, keyword, ft):
for f in files_list:
if keyword not in f: continue
m_fd = re.search(r'FD(\d{2})(\d{2})-(\d{2})(\d{2})', os.path.basename(f))
if m_fd:
start_h = int(m_fd.group(1))*24 + int(m_fd.group(2))
end_h = int(m_fd.group(3))*24 + int(m_fd.group(4))
if start_h <= ft <= end_h: return f
continue
m_fh = re.search(r'FH(\d{2,3})-(\d{2,3})', os.path.basename(f))
if m_fh:
start_h = int(m_fh.group(1))
end_h = int(m_fh.group(2))
if start_h <= ft <= end_h: return f
continue
return f
return None
class EngineStatusWindow(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("GPV 解析エンジン (Ver 90.1)")
self.setFixedSize(650, 620)
self.setStyleSheet("""
QWidget { background-color: #0A192F; color: #E0E0E0; font-family: 'MS Gothic'; font-size: 11pt; }
QLabel { color: #64FFDA; font-weight: bold; }
QPushButton { background-color: #1D3557; color: white; padding: 6px; border-radius: 4px; font-weight: bold; border: 1px solid #457B9D; }
QPushButton:hover { background-color: #457B9D; }
QListWidget { background-color: #112240; border: 1px solid #457B9D; color: #64FFDA; padding: 5px; font-family: Consolas, monospace; font-size: 10pt; }
""")
self.is_quitting = False
layout = QVBoxLayout(self)
self.status_label = QLabel("状態: 起動中..."); self.status_label.setStyleSheet("font-size: 14pt;")
layout.addWidget(self.status_label)
control_layout = QHBoxLayout()
self.pause_btn = QPushButton("⏹ 抽出を即時停止")
self.pause_btn.setStyleSheet("background-color: #C0392B;")
self.resume_btn = QPushButton("▶ 手動強制スキャン")
self.mode_btn = QPushButton("🔄 モード: 最新12時間 (推奨)")
self.mode_btn.setStyleSheet("background-color: #D35400;")
for btn in [self.pause_btn, self.resume_btn, self.mode_btn]: control_layout.addWidget(btn)
self.clear_btn = QPushButton("🧹 キャッシュ全削除"); self.clear_btn.setStyleSheet("background-color: #8E44AD;")
control_layout.addWidget(self.clear_btn)
layout.addLayout(control_layout)
out_layout = QHBoxLayout()
self.out_folder_btn = QPushButton("💾 出力先フォルダを設定"); self.out_folder_btn.setStyleSheet("background-color: #2980B9;")
out_layout.addWidget(self.out_folder_btn)
self.out_folder_label = QLabel("未設定"); self.out_folder_label.setStyleSheet("background: #112240; padding: 4px; border: 1px solid #457B9D; color: #8892B0;")
out_layout.addWidget(self.out_folder_label, stretch=1)
layout.addLayout(out_layout)
self.log_list = QListWidget(); layout.addWidget(self.log_list)
self.copy_btn = QPushButton("📋 ログをコピー"); self.copy_btn.setStyleSheet("background-color: #16A085;"); layout.addWidget(self.copy_btn)
folder_layout = QHBoxLayout()
self.folder_btns = [QPushButton(f"📁 読込元フォルダ {i+1}") for i in range(3)]
for b in self.folder_btns: folder_layout.addWidget(b)
layout.addLayout(folder_layout)
self.exit_btn = QPushButton("システムを完全に終了"); self.exit_btn.setStyleSheet("background-color: #8b0000;"); layout.addWidget(self.exit_btn)
def log(self, message):
self.log_list.addItem(f"[{datetime.now().strftime('%H:%M:%S')}] {message}")
self.log_list.scrollToBottom(); write_syslog(message)
def closeEvent(self, event):
if getattr(self, 'is_quitting', False):
event.accept()
else:
event.ignore()
self.hide()
if hasattr(self, 'tray_msg_callback'):
self.tray_msg_callback()
class TrayApp(QApplication):
def __init__(self, sys_argv):
super().__init__(sys_argv)
self.setQuitOnLastWindowClosed(False)
self.settings = QSettings("SapporoWeatherApp", "GPVEngine")
self.monitor_folders = [self.settings.value(f"watch_dir_{i+1}", "") for i in range(3)]
global CURRENT_OUTPUT_DIR
self.output_dir = self.settings.value("output_dir", DEFAULT_OUTPUT_DIR)
CURRENT_OUTPUT_DIR = self.output_dir
os.makedirs(self.output_dir, exist_ok=True)
self.last_reported = {}
self.scan_mode = "12h"
self.window = EngineStatusWindow()
self.window.tray_msg_callback = self.show_tray_message
self.tray_icon = QSystemTrayIcon(self)
self.tray_icon.setIcon(create_lightning_icon())
self.tray_icon.setToolTip("GPV抽出エンジン (稼働中)")
tray_menu = QMenu(); show_action = QAction("ウィンドウを表示"); show_action.triggered.connect(self.restore_window)
quit_action = QAction("完全に終了する"); quit_action.triggered.connect(self.quit_app)
tray_menu.addAction(show_action); tray_menu.addAction(quit_action)
self.tray_icon.setContextMenu(tray_menu); self.tray_icon.activated.connect(self.tray_icon_activated); self.tray_icon.show()
with open(os.path.join(self.output_dir, "system_log.txt"), "w", encoding="utf-8") as f: pass
self.window.log("===============================")
self.window.log("システム起動: Ver 90.1 )")
self.window.log("===============================")
self.window.out_folder_label.setText(self.output_dir)
self.window.out_folder_btn.clicked.connect(self.select_output_folder)
folders_set = False
for i in range(3):
self.window.folder_btns[i].clicked.connect(lambda checked, idx=i: self.select_folder(idx))
self.update_folder_btn_text(i)
if self.monitor_folders[i]: folders_set = True
if not folders_set: self.window.status_label.setText("状態: 読込元フォルダ未設定")
self.window.copy_btn.clicked.connect(self.copy_logs)
self.window.pause_btn.clicked.connect(self.stop_and_pause)
self.window.resume_btn.clicked.connect(self.force_scan)
self.window.mode_btn.clicked.connect(self.toggle_mode)
self.window.clear_btn.clicked.connect(self.clear_cache)
self.window.exit_btn.clicked.connect(self.quit_app)
self.timer = QTimer(); self.timer.timeout.connect(self.run_parser); self.timer.start(10000)
self.is_processing = False; self.run_parser(); self.window.show()
def kill_current_worker(self):
if hasattr(self, 'worker') and self.worker.isRunning():
self.window.log("⚠️ 設定変更のため、現在の抽出処理を即座に中断しています...")
self.worker.abort = True
self.is_processing = False
def tray_icon_activated(self, reason):
if reason == QSystemTrayIcon.ActivationReason.DoubleClick: self.restore_window()
def restore_window(self):
self.window.showNormal(); self.window.activateWindow()
def show_tray_message(self):
self.tray_icon.showMessage("エンジン待機中", "ウィンドウを閉じましたが、裏で抽出を続けています。終了する場合は右クリックから「完全に終了する」を選んでください。", QIcon(self.tray_icon.icon()), 3000)
def quit_app(self):
self.kill_current_worker()
self.window.is_quitting = True
self.tray_icon.showMessage("システム終了", "エンジンを完全に停止しました。", QIcon(self.tray_icon.icon()), 2000)
self.quit()
def stop_and_pause(self):
self.timer.stop()
self.kill_current_worker()
self.window.log("⏸ 処理を即時停止しました。再開するには強制スキャンを押してください。")
self.window.status_label.setText("状態: 停止中")
def toggle_mode(self):
self.kill_current_worker()
if self.scan_mode == "12h":
self.scan_mode = "all"
self.window.mode_btn.setText("🔄 モード: すべてのデータ")
self.window.mode_btn.setStyleSheet("background-color: #27AE60;")
self.window.log("🔄 モード変更 [すべてのデータ] -> 再スキャンします")
else:
self.scan_mode = "12h"
self.window.mode_btn.setText("🔄 モード: 最新12時間 (推奨)")
self.window.mode_btn.setStyleSheet("background-color: #D35400;")
self.window.log("🔄 モード変更 [最新12時間] -> 再スキャンします")
self.run_parser()
def select_output_folder(self):
global CURRENT_OUTPUT_DIR
folder = QFileDialog.getExistingDirectory(self.window, "出力先フォルダ(共有)を選択", self.output_dir)
if folder:
self.kill_current_worker()
self.output_dir = folder; CURRENT_OUTPUT_DIR = folder; self.settings.setValue("output_dir", folder)
self.window.out_folder_label.setText(folder); self.window.log(f"💾 出力先フォルダを更新しました: {folder}")
self.run_parser()
def clear_cache(self):
reply = QMessageBox.question(self.window, "確認", "現在の出力先のデータをすべて削除し再抽出しますか?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
if reply == QMessageBox.StandardButton.Yes:
self.timer.stop()
self.kill_current_worker()
self.window.log("🗑️ キャッシュデータのお掃除を開始します...")
for f in glob.glob(os.path.join(self.output_dir, "*.npz")) + glob.glob(os.path.join(self.output_dir, "*.json")) + glob.glob(os.path.join(self.output_dir, "*.bin")):
try: os.remove(f)
except Exception: pass
for d in self.monitor_folders:
if d:
for idx_f in glob.glob(os.path.join(d, '**', '*.idx'), recursive=True):
try: os.remove(idx_f)
except Exception: pass
self.last_reported = {}; self.window.log(f"✨ データを削除しました。再抽出を開始します!")
self.run_parser(); self.timer.start(10000)
def select_folder(self, index):
folder = QFileDialog.getExistingDirectory(self.window, "読込元フォルダ選択")
if folder:
self.kill_current_worker()
self.monitor_folders[index] = folder; self.settings.setValue(f"watch_dir_{index+1}", folder)
self.update_folder_btn_text(index); self.window.log(f"フォルダ{index+1}を更新: {folder}")
self.run_parser()
def update_folder_btn_text(self, i):
f = self.monitor_folders[i]; self.window.folder_btns[i].setText(f"📁 {os.path.basename(f)}" if f else f"📁 未設定 {i+1}")
def copy_logs(self): QApplication.clipboard().setText("\n".join([self.window.log_list.item(i).text() for i in range(self.window.log_list.count())]))
def force_scan(self):
self.window.log("▶ 手動強制スキャンを開始します...")
self.timer.start(10000)
self.kill_current_worker()
self.run_parser()
def run_parser(self):
if self.is_processing: return
if not any(self.monitor_folders): self.window.status_label.setText("状態: 読込元フォルダ未設定"); return
self.is_processing = True
self.worker = DataParserThread(self.monitor_folders, self.output_dir, self.last_reported, self.scan_mode)
self.worker.log_signal.connect(self.window.log); self.worker.progress_signal.connect(lambda v, t: self.window.status_label.setText(f"状態: {t}"))
self.worker.finished_signal.connect(self.on_parse_finished)
self.worker.start()
def on_parse_finished(self, has_new_data):
self.is_processing = False
if not hasattr(self.worker, 'abort') or not self.worker.abort:
self.window.status_label.setText("状態: 待機中 (自動スキャン継続中)")
if __name__ == '__main__':
import multiprocessing
multiprocessing.freeze_support()
if len(sys.argv) > 1 and '_gpv_worker.py' in sys.argv[1]:
sys.argv = sys.argv[1:]
try:
with open(sys.argv[0], "r", encoding="utf-8") as f:
worker_code = f.read()
exec(worker_code, {'__name__': '__main__'})
except Exception:
pass
sys.exit(0)
app = TrayApp(sys.argv); sys.exit(app.exec())