# -*- coding: utf-8 -*-
"""
特殊指数ミニビューワー (ダークテーマ・デザイン統合完成版) idx_mini_viewer.py
* 補助エンジン idx_engine.py が出力した IDX_*.npz と IDX_META.json だけを読む
* 要素はカテゴリ別(量・収束・安定度・雷・気温・雲層・風・雲の動き・海上)に表示
* 「時系列一覧表」タブ: 時刻を行、各要素を列にして札幌点(または近郊)の値を並べ、危険度で色分け
* 危険度の目安・色・評価する月は idx_thresholds.json(エンジン側)で調整できる
"""
import sys, os, re, json
from datetime import datetime, timedelta
import numpy as np
from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QSlider, QLabel,
QListWidget, QListWidgetItem, QComboBox, QPushButton, QFileDialog, QTextEdit,
QTabWidget, QTableWidget, QTableWidgetItem, QHeaderView, QCheckBox, QFrame)
from PyQt6.QtCore import Qt, QTimer, QSettings
from PyQt6.QtGui import QColor, QFont
os.environ['QT_API'] = 'pyqt6'
import matplotlib
if os.environ.get('QT_QPA_PLATFORM', '') != 'offscreen':
matplotlib.use('qtagg')
import matplotlib as mpl
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
from matplotlib.lines import Line2D
from matplotlib import font_manager as _fm
_avail = {f.name for f in _fm.fontManager.ttflist}
for _f in ('MS Gothic', 'Yu Gothic', 'Meiryo', 'Noto Sans CJK JP', 'IPAexGothic'):
if _f in _avail:
mpl.rcParams['font.family'] = [_f, 'DejaVu Sans']
break
mpl.rcParams['axes.unicode_minus'] = False
try:
import cartopy.crs as ccrs
import cartopy.feature as cfeature
HAS_CARTOPY = True
except Exception:
HAS_CARTOPY = False
FILE_RE = re.compile(r'^IDX_(MSM|ANAL|GSM_JP)_(\d{14})_FT(\d{2,3})\.npz$')
SAPPORO = (141.328, 43.060)
AREA_EXTENTS = {
"日本海〜北日本": [127.0, 148.0, 35.0, 49.0],
"北海道": [138.5, 146.5, 40.5, 46.0],
"道央・石狩湾": [139.8, 143.2, 42.0, 44.4],
"札幌近郊": [140.7, 141.9, 42.6, 43.5],
}
DEFAULT_CATEGORIES = ["総合", "量", "収束", "安定度", "雷", "気温・雲層", "風・雲の動き", "海上・冬型"]
SEASONS = (("summer", "夏型(大雨)"), ("winter", "冬型(大雪)"))
# 危険度の色(線と表のセル)。設定ファイルの colors で 'red' / 'orange' を選ぶ
LINE_COLORS = {'orange': '#F97316', 'red': '#EF4444'}
CELL_STYLES = {'orange': ('#422006', '#FDBA74'), 'red': ('#450A0A', '#FCA5A5')}
COLOR_NAMES = {'orange': '橙', 'red': '赤'}
# 一元管理されたダークテーマQSS
DARK_QSS = """
QWidget {
background-color: #0A192F;
color: #E0E0E0;
font-family: 'Segoe UI', 'MS Gothic', sans-serif;
font-size: 10pt;
}
QFrame#panelFrame {
background-color: #112240;
border: 1px solid #233554;
border-radius: 6px;
}
QLabel { color: #CCD6F6; }
QLabel#titleLabel {
color: #64FFDA;
font-weight: bold;
font-size: 11pt;
}
QPushButton {
background-color: #1D3557;
color: #F8F9FA;
padding: 5px 12px;
border-radius: 4px;
font-weight: bold;
border: 1px solid #457B9D;
}
QPushButton:hover { background-color: #457B9D; }
QComboBox, QTextEdit, QListWidget, QTableWidget {
background-color: #0F172A;
color: #64FFDA;
border: 1px solid #233554;
border-radius: 4px;
padding: 3px;
}
QListWidget::item:selected, QTableWidget::item:selected {
background-color: #233554;
color: #64FFDA;
}
QHeaderView::section {
background-color: #1E293B;
color: #F1F5F9;
padding: 4px;
border: 1px solid #334155;
font-weight: bold;
}
QTabWidget::pane {
border: 1px solid #233554;
background-color: #0A192F;
}
QTabBar::tab {
background-color: #112240;
color: #8892B0;
padding: 8px 16px;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
border: 1px solid #233554;
margin-right: 2px;
}
QTabBar::tab:selected {
background-color: #1E293B;
color: #64FFDA;
border-bottom: 2px solid #64FFDA;
font-weight: bold;
}
QSlider::groove:horizontal {
height: 6px;
background: #1E293B;
border-radius: 3px;
}
QSlider::handle:horizontal {
background: #64FFDA;
width: 14px;
margin: -4px 0;
border-radius: 7px;
}
"""
# ---------------------------------------------------------------- 表示用の純関数(GUIから分離してテストしやすくしている)
def to_jst(init_utc14, ft):
return datetime.strptime(init_utc14, '%Y%m%d%H%M%S') + timedelta(hours=9 + ft)
def fnum(v, nd=1):
return '--' if v is None else f'{v:.{nd}f}'
def fmt_val(v, key=''):
"""時系列表のセル用。桁数は値の大きさで決める"""
if v is None: return '--'
if key.startswith('SCORE'): return f'{v:.0f}'
a = abs(v)
return f'{v:.0f}' if a >= 100 else (f'{v:.1f}' if a >= 1 else f'{v:.2f}')
def sym(op):
return '≥' if op == 'ge' else '≤'
def danger_active(d, month):
"""active_months が設定されている指標は、その月だけ危険度を評価する(それ以外は参考表示)"""
am = (d or {}).get('active_months')
return True if (not am or month is None) else month in am
def level_of_value(v, d, month=None):
if v is None or d is None or not danger_active(d, month): return None
if d['op'] == 'ge':
return '警戒' if v >= d['warning'] else ('注意' if v >= d['caution'] else '基準未満')
return '警戒' if v <= d['warning'] else ('注意' if v <= d['caution'] else '基準未満')
def level_color(level, d):
cols = (d or {}).get('colors') or {}
if level == '警戒': return cols.get('warning', 'red')
if level == '注意': return cols.get('caution', 'orange')
return None
def line_color(d, which):
cols = (d or {}).get('colors') or {}
name = cols.get(which, 'orange' if which == 'caution' else 'red')
return LINE_COLORS.get(name, LINE_COLORS['red'])
def danger_text(e, month=None):
d = e.get('danger') if e else None
if not d:
return '【危険度の目安】 なし (参考表示)'
u = e.get('unit', '')
base = f"注意 {sym(d['op'])} {d['caution']:g} / 警戒 {sym(d['op'])} {d['warning']:g} {u}"
if not danger_active(d, month):
ms = '・'.join(str(m) for m in d['active_months'])
t = f"【危険度の目安】 この時期は評価対象外 (参考表示) ― 評価する月: {ms}月 ({base})"
else:
t = f"【危険度の目安】 {base} (根拠 {d.get('basis', '')}: {d.get('basis_text', '')})"
cols = d.get('colors')
if cols:
t += (f"\n線の色: {sym(d['op'])}{d['caution']:g} → {COLOR_NAMES.get(cols.get('caution', 'orange'), '')}"
f" / {sym(d['op'])}{d['warning']:g} → {COLOR_NAMES.get(cols.get('warning', 'red'), '')} (ご指定)")
if d.get('note'): t += f"\n注記: {d['note']}"
return t
def _pts(x):
return '--' if x is None else f'{x:g}'
def table_header(sec):
mp = sec.get('max_points'); j = sec.get('judge', {}); g = sec.get('gate')
pt = sec.get('pt', {}); bx = sec.get('box', {})
def one(name, d):
if d.get('shown') is None:
if g is not None and d.get('raw') is not None:
return f"{name}: ゲート不成立のため判定なし (参考値: {_pts(d['raw'])}/{_pts(mp)}点)"
return f"{name}: データなし"
return f"{name}: {_pts(d['shown'])}/{_pts(mp)}点 → 【{d.get('level')}】"
lines = [f"■ {sec.get('label', '')} {one('札幌点', pt)} / {one('札幌近郊最大', bx)}",
f"判定基準: 注意 ≥ {j.get('caution')}点 / 警戒 ≥ {j.get('warning')}点 (各条件: 0点=基準未満, 1点=注意, 2点=警戒)"]
if g:
ok = lambda x: '--' if x is None else ('成立' if x else '不成立')
lines.append(f"ゲート条件: {g.get('label')} [札幌点: {fnum(g.get('v_pt'))}{g.get('unit', '')} → {ok(g.get('ok_pt'))}"
f" | 近郊: {fnum(g.get('v_box'))}{g.get('unit', '')} → {ok(g.get('ok_box'))}]")
else:
lines.append("ゲート条件: なし (全域評価)")
return "\n".join(lines)
TABLE_COLS = ["条件", "札幌点", "近郊(危険側)", "注意閾値", "警戒閾値", "点(札幌)", "点(近郊)", "根拠"]
def table_rows(sec):
rows = []
for r in sec.get('rows', []):
name = f"{r.get('label', r.get('key'))}" + (f" [{r['unit']}]" if r.get('unit') else '')
if r.get('weight', 1) != 1: name += f" ×{r['weight']:g}"
if r.get('missing'):
rows.append(([name, '未計算', '未計算', '', '', '--', '--', ''], None)); continue
op = r['op']
rows.append(([name, fnum(r.get('v_pt')), fnum(r.get('v_box')), f"{sym(op)} {r['caution']:g}", f"{sym(op)} {r['warning']:g}",
_pts(r.get('p_pt')), _pts(r.get('p_box')), r.get('basis', '')], r.get('p_pt')))
return rows
def series_text(per_ft, sk):
out = []
for ft, t in per_ft:
sec = (t or {}).get(sk)
if not sec: continue
shown = sec['pt'].get('shown')
if shown is None:
out.append(f"+{ft}h:×" if sec.get('gate') else f"+{ft}h:--")
else:
out.append(f"+{ft}h:{shown:g}({(sec['pt'].get('level') or '')[:1]})")
return "時系列推移(札幌点): " + (" ".join(out) if out else "(なし)") + " [×=ゲート不成立, 警=警戒, 注=注意, 平=平常]"
def ordered_elements(meta):
"""(カテゴリ名, [要素,...]) をカテゴリの表示順に返す。旧形式のMETAでも動く"""
elems = meta.get('elements', [])
cats = list(meta.get('categories') or [])
if not cats:
for e in elems:
c = e.get('cat') or e.get('season') or ''
if c not in cats: cats.append(c)
out = []
for c in cats:
es = [e for e in elems if (e.get('cat') or e.get('season')) == c]
if es: out.append((c, es))
return out
def ts_build(elems, rows, mode):
"""時系列一覧表の中身を作る。
elems: 列にする要素のリスト / rows: [(行見出し, 月, ptvals dict)] / mode: 'pt' か 'box'
戻り値: [[(文字, 色名 or None, 値がNoneか), ...], ...]"""
table = []
for _label, month, pv in rows:
vals = (pv or {}).get(mode) or {}
line = []
for e in elems:
v = vals.get(e['key'])
lvl = level_of_value(v, e.get('danger'), month)
line.append((fmt_val(v, e['key']), level_color(lvl, e.get('danger')), v is None))
table.append(line)
return table
class MiniViewer(QMainWindow):
def __init__(self, idx_dir):
super().__init__()
self.setWindowTitle("特殊指数 総合統合ビューワー")
self.resize(1360, 880)
self.setStyleSheet(DARK_QSS)
self.settings = QSettings("GPVTools", "IdxMiniViewer")
self.idx_dir = idx_dir
self.meta = {}; self.elem_by_key = {}; self.meta_mtime = None
self.ts_elems = []
self.index = {}
self.cache = {'path': None, 'mt': None, 'z': None}
self.tbl_cache = {}; self.pt_cache = {}
self.cur_files = []
self._ts_key = None; self._ts_rows = []
self._building = False
self._build_ui()
self.reload_meta(); self.rescan(first=True)
self.timer = QTimer(self); self.timer.timeout.connect(self.rescan); self.timer.start(60000)
def _build_ui(self):
root = QWidget(); self.setCentralWidget(root)
lay = QHBoxLayout(root); lay.setContentsMargins(10, 10, 10, 10); lay.setSpacing(10)
# --- 左側コントロールパネル
left_frame = QFrame(); left_frame.setObjectName("panelFrame")
left = QVBoxLayout(left_frame); left.setContentsMargins(10, 10, 10, 10)
lay.addWidget(left_frame, 0)
title = QLabel("特殊指数コントロール"); title.setObjectName("titleLabel")
left.addWidget(title)
btn_dir = QPushButton("📁 IDXフォルダ変更")
btn_dir.clicked.connect(self.choose_dir)
self.dir_label = QLabel(self.idx_dir); self.dir_label.setWordWrap(True)
self.dir_label.setStyleSheet("color: #8892B0; font-size: 8pt;")
left.addWidget(btn_dir); left.addWidget(self.dir_label)
self.cb_model = QComboBox(); self.cb_init = QComboBox(); self.cb_area = QComboBox()
self.cb_area.addItems(list(AREA_EXTENTS.keys())); self.cb_area.setCurrentText("道央・石狩湾")
for lab, w in (("対象モデル", self.cb_model), ("初期時刻 (JST)", self.cb_init), ("表示領域", self.cb_area)):
left.addWidget(QLabel(lab)); left.addWidget(w)
self.chk_lines = QCheckBox("危険度ライン(注意・警戒)を重畳")
self.chk_lines.setChecked(True); self.chk_lines.setStyleSheet("color: #64FFDA; font-weight: bold;")
left.addWidget(self.chk_lines)
left.addWidget(QLabel("表示要素一覧"))
self.list = QListWidget(); self.list.setMinimumWidth(280)
left.addWidget(self.list, 1)
self.desc = QTextEdit(); self.desc.setReadOnly(True); self.desc.setMaximumHeight(100)
self.desc.setStyleSheet("background-color: #090D16; color: #94A3B8; font-size: 9pt;")
left.addWidget(self.desc)
# --- 右側メイン表示領域
right = QVBoxLayout(); lay.addLayout(right, 1)
self.tabs = QTabWidget(); right.addWidget(self.tabs, 1)
# 地図タブ
mp = QWidget(); mv = QVBoxLayout(mp); mv.setContentsMargins(5, 5, 5, 5)
self.fig = Figure(figsize=(8, 6), facecolor='#0A192F')
self.canvas = FigureCanvas(self.fig)
mv.addWidget(self.canvas, 1)
self.lab_danger = QLabel(""); self.lab_danger.setWordWrap(True)
self.lab_danger.setStyleSheet("background:#112240; border:1px solid #233554; padding:8px; color:#64FFDA; border-radius:4px;")
mv.addWidget(self.lab_danger)
self.tabs.addTab(mp, "🗺️ 空間分布(地図)")
# 時系列一覧表タブ(時刻=行 / 各要素=列)
tsp = QWidget(); tsv = QVBoxLayout(tsp); tsv.setContentsMargins(5, 5, 5, 5)
ctl = QHBoxLayout()
ctl.addWidget(QLabel("表示する値:"))
self.cb_tsmode = QComboBox(); self.cb_tsmode.addItems(["札幌点", "札幌近郊 (危険側の最大/最小)"])
ctl.addWidget(self.cb_tsmode)
self.chk_p_only = QCheckBox("気圧面データのある時刻のみ (3時間ごと)"); self.chk_p_only.setChecked(True)
self.chk_p_only.setStyleSheet("color: #64FFDA; font-weight: bold;")
ctl.addWidget(self.chk_p_only); ctl.addStretch(1)
tsv.addLayout(ctl)
self.ts_table = QTableWidget(0, 0)
self.ts_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
self.ts_table.setHorizontalScrollMode(QTableWidget.ScrollMode.ScrollPerPixel)
tsv.addWidget(self.ts_table, 1)
self.ts_note = QLabel(""); self.ts_note.setWordWrap(True); self.ts_note.setStyleSheet("color:#94A3B8; padding:2px;")
tsv.addWidget(self.ts_note)
self.tabs.addTab(tsp, "📊 時系列一覧表")
# スコア内訳タブ
tp = QWidget(); tv = QVBoxLayout(tp); tv.setContentsMargins(5, 5, 5, 5)
self.tbl_widgets = {}
for sk, name in SEASONS:
hdr = QLabel(""); hdr.setWordWrap(True); hdr.setStyleSheet("font-weight:bold; color:#64FFDA; padding:4px; background:#112240; border-radius:4px;")
tw = QTableWidget(0, len(TABLE_COLS)); tw.setHorizontalHeaderLabels(TABLE_COLS)
tw.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
tw.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
ser = QLabel(""); ser.setWordWrap(True); ser.setStyleSheet("color:#94A3B8; padding:2px;")
tv.addWidget(hdr); tv.addWidget(tw, 1); tv.addWidget(ser)
self.tbl_widgets[sk] = (hdr, tw, ser)
self.tabs.addTab(tp, "🧮 スコア内訳")
# 下部再生バー
row = QHBoxLayout()
self.btn_prev = QPushButton("◀ 前へ"); self.btn_next = QPushButton("次へ ▶"); self.btn_play = QPushButton("▶ 再生")
self.slider = QSlider(Qt.Orientation.Horizontal); self.lab_ft = QLabel("FT --")
self.lab_ft.setStyleSheet("color:#64FFDA; font-weight:bold; font-size:11pt; min-width:140px;")
for w in (self.btn_prev, self.slider, self.btn_next, self.btn_play, self.lab_ft): row.addWidget(w)
right.addLayout(row)
self.info = QTextEdit(); self.info.setReadOnly(True); self.info.setMaximumHeight(65)
self.info.setStyleSheet("background-color: #090D16; color: #38BDF8; font-size: 9pt;")
right.addWidget(self.info)
self.cb_model.currentIndexChanged.connect(self.on_model)
self.cb_init.currentIndexChanged.connect(self.on_init)
self.cb_area.currentIndexChanged.connect(self.redraw)
self.list.currentItemChanged.connect(self.on_elem)
self.chk_lines.stateChanged.connect(self.redraw)
self.cb_tsmode.currentIndexChanged.connect(self.redraw)
self.chk_p_only.stateChanged.connect(self.redraw)
self.ts_table.cellClicked.connect(self.on_ts_click)
self.slider.valueChanged.connect(self.redraw)
self.btn_prev.clicked.connect(lambda: self.slider.setValue(self.slider.value() - 1))
self.btn_next.clicked.connect(lambda: self.slider.setValue(self.slider.value() + 1))
self.play_timer = QTimer(self); self.play_timer.timeout.connect(self.step_play)
self.btn_play.clicked.connect(self.toggle_play)
def choose_dir(self):
d = QFileDialog.getExistingDirectory(self, "IDXフォルダ選択", self.idx_dir)
if d:
self.idx_dir = d; self.dir_label.setText(d); self.tbl_cache = {}; self.pt_cache = {}
self.reload_meta(); self.rescan(first=True)
def meta_path(self):
return os.path.join(self.idx_dir, 'IDX_META.json')
def reload_meta(self):
p = self.meta_path()
self.meta = {}; self.elem_by_key = {}
try: self.meta_mtime = os.path.getmtime(p)
except Exception: self.meta_mtime = None
if os.path.exists(p):
try:
with open(p, 'r', encoding='utf-8') as f: self.meta = json.load(f)
except Exception: self.meta = {}
for e in self.meta.get('elements', []): self.elem_by_key[e['key']] = e
groups = ordered_elements(self.meta)
self.ts_elems = [e for _c, es in groups for e in es]
self._ts_key = None
self.list.blockSignals(True); self.list.clear()
for cat, es in groups: # カテゴリ見出し(選択不可)+要素
h = QListWidgetItem(f"▎{cat}")
h.setFlags(Qt.ItemFlag.NoItemFlags)
fnt = QFont(); fnt.setBold(True); h.setFont(fnt)
h.setForeground(QColor("#64FFDA")); h.setBackground(QColor("#112240"))
self.list.addItem(h)
for e in es:
it = QListWidgetItem(" " + e['label']); it.setData(Qt.ItemDataRole.UserRole, e['key']); self.list.addItem(it)
self.list.blockSignals(False)
last = self.settings.value("last_elem", "")
target = None
for i in range(self.list.count()):
it = self.list.item(i)
if not it.data(Qt.ItemDataRole.UserRole): continue
if it.data(Qt.ItemDataRole.UserRole) == last: target = it; break
if target is None: target = it
if target is not None: self.list.setCurrentItem(target)
def check_meta(self):
try: mt = os.path.getmtime(self.meta_path())
except Exception: return
if self.meta_mtime is None or mt != self.meta_mtime:
self.tbl_cache = {}; self.pt_cache = {}; self.reload_meta()
def rescan(self, first=False):
self.check_meta()
idx = {}
for fn in os.listdir(self.idx_dir) if os.path.isdir(self.idx_dir) else []:
m = FILE_RE.match(fn)
if m: idx.setdefault(m.group(1), {}).setdefault(m.group(2), []).append((int(m.group(3)), os.path.join(self.idx_dir, fn)))
for s in idx:
for i in idx[s]: idx[s][i].sort()
if idx == self.index and not first: return
self.index = idx
cur_model = self.cb_model.currentText(); cur_init = self.cb_init.currentData()
self._building = True
self.cb_model.clear(); self.cb_model.addItems([m for m in ("MSM", "ANAL", "GSM_JP") if m in idx])
if cur_model: self.cb_model.setCurrentText(cur_model)
self._building = False
self.fill_inits(keep=cur_init)
def fill_inits(self, keep=None):
self._building = True; self.cb_init.clear()
model = self.cb_model.currentText()
for init in sorted(self.index.get(model, {}), reverse=True):
self.cb_init.addItem((datetime.strptime(init, '%Y%m%d%H%M%S') + timedelta(hours=9)).strftime('%m/%d %H:%M JST'), init)
if keep is not None:
i = self.cb_init.findData(keep)
if i >= 0: self.cb_init.setCurrentIndex(i)
self._building = False; self.fill_slider()
def fill_slider(self):
model = self.cb_model.currentText(); init = self.cb_init.currentData()
files = self.index.get(model, {}).get(init, [])
old_ft = self.cur_ft() if self.cur_files else None
self.cur_files = files; self._building = True
self.slider.setMinimum(0); self.slider.setMaximum(max(0, len(files) - 1))
pos = 0
if old_ft is not None:
for i, (ft, _) in enumerate(files):
if ft == old_ft: pos = i
self.slider.setValue(pos); self._building = False; self.redraw()
def cur_ft(self):
i = self.slider.value()
return self.cur_files[i][0] if self.cur_files and 0 <= i < len(self.cur_files) else None
def on_model(self):
if not self._building: self.fill_inits()
def on_init(self):
if not self._building: self.fill_slider()
def on_elem(self, cur, prev):
if cur is not None and cur.data(Qt.ItemDataRole.UserRole):
self.settings.setValue("last_elem", cur.data(Qt.ItemDataRole.UserRole))
self.redraw()
def toggle_play(self):
if self.play_timer.isActive(): self.play_timer.stop(); self.btn_play.setText("▶ 再生")
else: self.play_timer.start(700); self.btn_play.setText("⏸ 停止")
def step_play(self):
v = self.slider.value() + 1
self.slider.setValue(v if v <= self.slider.maximum() else 0)
@staticmethod
def _mtime(path):
try: return os.path.getmtime(path)
except Exception: return None
def load_npz(self, path):
mt = self._mtime(path)
if self.cache['path'] == path and self.cache.get('mt') == mt: return self.cache['z']
z = np.load(path); data = {k: z[k] for k in z.files}
self.cache = {'path': path, 'mt': mt, 'z': data}
return data
def table_of(self, path):
key = (path, self._mtime(path))
if key in self.tbl_cache: return self.tbl_cache[key]
t = None
try:
with np.load(path) as zf:
if '_TABLE' in zf.files: t = json.loads(str(zf['_TABLE']))
except Exception: t = None
self.tbl_cache[key] = t
return t
def ptvals_of(self, path):
"""そのファイルの札幌点/近郊の全要素の値(エンジンが _PTVALS に書いたもの)。無ければ配列から札幌点だけ作る"""
key = (path, self._mtime(path))
if key in self.pt_cache: return self.pt_cache[key]
r = None
try:
with np.load(path) as zf:
if '_PTVALS' in zf.files:
r = json.loads(str(zf['_PTVALS']))
else: # 古いIDX: 札幌点のみ(近郊の値は --force で再計算すると出る)
z = {k: zf[k] for k in zf.files}
pt = {e['key']: self.point_value(z, e) for e in self.ts_elems if e['key'] in z}
r = {'has_p': 'lon' in z, 'pt': pt, 'box': {}, 'legacy': True}
except Exception:
r = None
self.pt_cache[key] = r
return r
# ------------------------------------------------------------ スコア内訳タブ
def update_tables(self, path):
tbl = self.table_of(path)
for sk, name in SEASONS:
hdr, tw, ser = self.tbl_widgets[sk]
sec = (tbl or {}).get(sk)
if not sec:
hdr.setText(f"{name}: この時刻のデータには総合判定がありません")
tw.setRowCount(0); ser.setText(""); continue
hdr.setText(table_header(sec))
rows = table_rows(sec)
tw.setRowCount(len(rows))
for r, (cells, p) in enumerate(rows):
for c, txt in enumerate(cells):
it = QTableWidgetItem(txt)
# 透過色を使わず、ダークな純色を指定して描画バグを防止
if p == 2:
it.setBackground(QColor("#450A0A")); it.setForeground(QColor("#FCA5A5"))
elif p == 1:
it.setBackground(QColor("#422006")); it.setForeground(QColor("#FDBA74"))
else:
it.setForeground(QColor("#DCDCDC"))
tw.setItem(r, c, it)
tw.resizeColumnsToContents(); tw.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
ser.setText(series_text([(f2, self.table_of(p2)) for f2, p2 in self.cur_files], sk))
# ------------------------------------------------------------ 時系列一覧表タブ
def update_ts(self):
mode = 'pt' if self.cb_tsmode.currentIndex() == 0 else 'box'
p_only = self.chk_p_only.isChecked()
key = (self.cb_model.currentText(), self.cb_init.currentData(), mode, p_only, self.meta_mtime,
tuple((f, p, self._mtime(p)) for f, p in self.cur_files))
if key != self._ts_key:
self._ts_key = key
init = self.cb_init.currentData()
rows = []; self._ts_rows = []
for i, (ft, path) in enumerate(self.cur_files):
pv = self.ptvals_of(path)
if p_only and not (pv or {}).get('has_p'): continue
valid = to_jst(init, ft)
rows.append((f"+{ft:02d}h {valid.strftime('%d日%H時')}", valid.month, pv)); self._ts_rows.append(i)
data = ts_build(self.ts_elems, rows, mode)
tw = self.ts_table
tw.blockSignals(True)
tw.clear(); tw.setRowCount(len(rows)); tw.setColumnCount(len(self.ts_elems))
tw.setHorizontalHeaderLabels([f"{e.get('short', e['label'])}\n[{e['unit']}]" for e in self.ts_elems])
tw.setVerticalHeaderLabels([r[0] for r in rows])
for c, e in enumerate(self.ts_elems):
hi = tw.horizontalHeaderItem(c)
if hi is not None: hi.setToolTip(f"{e.get('cat', '')} / {e['label']}\n{danger_text(e)}")
for r, line in enumerate(data):
for c, (txt, col, isnone) in enumerate(line):
it = QTableWidgetItem(txt); it.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
if col in CELL_STYLES:
bg, fg = CELL_STYLES[col]; it.setBackground(QColor(bg)); it.setForeground(QColor(fg))
elif isnone:
it.setForeground(QColor("#475569"))
else:
it.setForeground(QColor("#DCDCDC"))
tw.setItem(r, c, it)
tw.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
tw.blockSignals(False)
legacy = any((self.ptvals_of(p) or {}).get('legacy') for _f, p in self.cur_files)
note = "セルの色: 橙=注意 / 赤=警戒 (SSIは指定により 赤=0以下・橙=-3以下)。行をクリックすると、その時刻の地図に切り替わります。列の見出しにカーソルを置くと閾値が出ます。"
if mode == 'box': note += " ※近郊=札幌近郊の領域内で最も危険側の値。"
if legacy: note += " ※古い形式のIDXがあります。近郊の値や新しい要素は、エンジンを「作り直す」設定で再実行すると出ます。"
self.ts_note.setText(note)
# 現在の時刻の行を強調
try:
cur = self.slider.value()
if cur in self._ts_rows:
self.ts_table.blockSignals(True); self.ts_table.selectRow(self._ts_rows.index(cur)); self.ts_table.blockSignals(False)
except Exception:
pass
def on_ts_click(self, row, col):
if 0 <= row < len(self._ts_rows):
self.slider.setValue(self._ts_rows[row])
self.tabs.setCurrentIndex(0)
# ------------------------------------------------------------ 地図タブ
def current_key(self):
it = self.list.currentItem()
return it.data(Qt.ItemDataRole.UserRole) if it is not None else None
def point_value(self, z, e):
key = e['key']
if key not in z: return None
grid = e.get('grid', 'p'); lon, lat = (z.get('lon'), z.get('lat')) if grid == 'p' else (z.get('lon_s'), z.get('lat_s'))
if lon is None or lat is None: return None
i = int(np.abs(lon - SAPPORO[0]).argmin()); j = int(np.abs(lat - SAPPORO[1]).argmin())
v = float(z[key][j, i]); return v if np.isfinite(v) else None
def redraw(self, *_):
if self._building: return
self.fig.clear()
key = self.current_key()
if not key or not self.cur_files:
self.canvas.draw_idle(); self.info.setPlainText("IDXデータが見つかりません。")
self.lab_danger.setText("")
if hasattr(self, 'ts_table'): self.update_ts()
return
e = self.elem_by_key.get(key)
ft, path = self.cur_files[self.slider.value()]
try: Z = self.load_npz(path)
except Exception as ex:
self.info.setPlainText(f"ファイル読み込み失敗: {ex}"); self.canvas.draw_idle(); return
self.desc.setPlainText(f"【{e['label']}】 [{e['unit']}]\n{e['desc']}" if e else key)
init = self.cb_init.currentData(); model = self.cb_model.currentText()
valid = to_jst(init, ft); month = valid.month
self.lab_ft.setText(f"FT+{ft:02d}h ({valid.strftime('%m/%d %H:%M')}JST)")
self.update_tables(path)
self.update_ts()
for i in range(self.list.count()):
it = self.list.item(i); k = it.data(Qt.ItemDataRole.UserRole)
if k:
it.setForeground(QColor("#64FFDA") if k in Z else QColor("#475569"))
pv = self.point_value(Z, e) if e else None
d = e.get('danger') if e else None
lv = level_of_value(pv, d, month)
txt = danger_text(e, month) if e else ""
if pv is None:
txt += "\n札幌点: -- (ゲート不成立 または 対象領域外/未計算)"
else:
words = (d or {}).get('words', {})
txt += f"\n札幌点の実効値: {pv:.2f} {e.get('unit', '')}" + (f" → 【{words.get(lv, lv)}】" if lv else "")
self.lab_danger.setText(txt)
ext = AREA_EXTENTS[self.cb_area.currentText()]
ax = self.fig.add_subplot(1, 1, 1, projection=ccrs.PlateCarree()) if HAS_CARTOPY else self.fig.add_subplot(1, 1, 1)
ax.set_facecolor('#0B132B')
if HAS_CARTOPY:
ax.set_extent(ext, crs=ccrs.PlateCarree())
try:
ax.add_feature(cfeature.LAND.with_scale('10m'), facecolor='#1C2541', zorder=0)
ax.add_feature(cfeature.COASTLINE.with_scale('10m'), linewidth=0.8, edgecolor='#64FFDA', zorder=3)
except Exception: pass
gl = ax.gridlines(draw_labels=True, linewidth=0.3, color='#334155', alpha=0.7, linestyle='--')
gl.top_labels = False; gl.right_labels = False
gl.xlabel_style = {'color': '#94A3B8', 'size': 8}
gl.ylabel_style = {'color': '#94A3B8', 'size': 8}
else:
ax.set_xlim(ext[0], ext[1]); ax.set_ylim(ext[2], ext[3]); ax.set_aspect(1.0 / np.cos(np.deg2rad(43)))
ax.grid(True, linestyle='--', color='#334155', alpha=0.5)
tr = {'transform': ccrs.PlateCarree()} if HAS_CARTOPY else {}
if key not in Z:
ax.set_title(f"{e['label'] if e else key}: 未収録 (SST欠損・要素未計算・または古いIDX)", color='#F87171')
self.canvas.draw_idle(); self.info.setPlainText(self.series_text_for(e)); return
grid = e.get('grid', 'p') if e else 'p'
lon, lat = (Z['lon'], Z['lat']) if grid == 'p' else (Z.get('lon_s'), Z.get('lat_s'))
a = np.ma.masked_invalid(Z[key].astype(float))
levels = e.get('levels') if e else None
try:
cs = ax.contourf(lon, lat, a, levels=levels, cmap=e.get('cmap', 'viridis') if e else 'viridis',
extend=e.get('extend', 'neither') if e else 'neither', zorder=2, alpha=0.85, **tr)
cb = self.fig.colorbar(cs, ax=ax, shrink=0.85, pad=0.02)
cb.ax.yaxis.set_tick_params(color='#94A3B8', labelcolor='#94A3B8', labelsize=8)
cb.set_label(e['unit'] if e else '', color='#94A3B8', fontsize=9)
if levels and len(levels) > 2:
ax.contour(lon, lat, a, levels=levels, colors='#0F172A', linewidths=0.4, zorder=2, **tr)
except Exception as ex:
ax.set_title(f"描画エラー: {ex}", color='#F87171')
# 危険度ライン(重畳)。評価対象外の月は引かない。色は設定の colors に従う
if d and self.chk_lines.isChecked() and danger_active(d, month):
handles = []
off = 0.5 if key.startswith('SCORE') else 0.0
custom = bool(d.get('colors'))
for which, lev, ls, lw, nm in (('caution', d['caution'], '--', 1.8, '注意'), ('warning', d['warning'], '-', 2.4, '警戒')):
col = line_color(d, which)
try:
ax.contour(lon, lat, a, levels=[lev - off if d['op'] == 'ge' else lev + off], colors=col, linestyles=ls,
linewidths=lw, zorder=5, **tr)
lab = f"{sym(d['op'])} {lev:g}" if custom else f"{nm} {sym(d['op'])} {lev:g}"
handles.append(Line2D([0], [0], color=col, ls=ls, lw=lw, label=lab))
except Exception: pass
if handles:
leg = ax.legend(handles=handles, loc='lower left', fontsize=8, facecolor='#0F172A', edgecolor='#334155')
for text in leg.get_texts(): text.set_color('#F1F5F9')
# 風ベクトルの重畳(要素ごとに矢印の種類を変える。移動・伝播は向きだけを示す)
if e and e.get('overlay', True):
ov = e.get('ovl') or (dict(u='OV_U925', v='OV_V925', grid='p') if grid == 'p' else dict(u='OV_U10', v='OV_V10', grid='s'))
uk, vk = ov['u'], ov['v']
lk, tk = ('lon', 'lat') if ov.get('grid', 'p') == 'p' else ('lon_s', 'lat_s')
if uk in Z and vk in Z and lk in Z:
lo, la = Z[lk], Z[tk]
mx = (lo >= ext[0] - 0.5) & (lo <= ext[1] + 0.5); my = (la >= ext[2] - 0.5) & (la <= ext[3] + 0.5)
sx = max(1, int(mx.sum() / 22)); sy = max(1, int(my.sum() / 16))
ii = np.where(mx)[0][::sx]; jj = np.where(my)[0][::sy]
if len(ii) and len(jj):
U = Z[uk][np.ix_(jj, ii)].astype(float); V = Z[vk][np.ix_(jj, ii)].astype(float)
if ov.get('norm'):
sp = np.hypot(U, V); sp = np.where(sp < 1e-6, np.nan, sp)
ax.quiver(lo[ii], la[jj], U / sp, V / sp, color='#0B132B', edgecolor='#FFFFFF', linewidth=0.6, scale=22, width=0.004, pivot='mid', zorder=4, **tr)
else:
ax.quiver(lo[ii], la[jj], U, V, color='#E2E8F0', scale=320, width=0.002, zorder=4, **tr)
# 札幌点(★印)
ax.plot([SAPPORO[0]], [SAPPORO[1]], marker='*', color='#FF0055', markersize=12, markeredgecolor='#FFFFFF', markeredgewidth=1.2, zorder=6, **tr)
ax.set_title(f"{e['label'] if e else key} [{e['unit'] if e else ''}]\n{model} {self.cb_init.currentText()} FT+{ft:02d}h ({valid.strftime('%m/%d %H:%M')}JST)",
fontsize=10, color='#64FFDA', fontweight='bold')
self.fig.subplots_adjust(left=0.06, right=0.96, top=0.88, bottom=0.06)
self.canvas.draw_idle()
self.info.setPlainText(self.series_text_for(e))
def series_text_for(self, e):
if not e: return ""
series = []
for f2, p2 in self.cur_files:
try:
with np.load(p2) as zf:
z2 = {k: zf[k] for k in zf.files if k in (e['key'], 'lon', 'lat', 'lon_s', 'lat_s')}
vv = self.point_value(z2, e)
series.append(f"+{f2}h:{'--' if vv is None else f'{vv:.1f}'}")
except Exception: pass
return "時系列変化(札幌点): " + (" ".join(series) if series else "(なし)")
def main():
idx_dir = sys.argv[1] if len(sys.argv) > 1 else os.path.join(os.getcwd(), 'idx_cache_npz')
app = QApplication(sys.argv)
w = MiniViewer(idx_dir); w.show()
sys.exit(app.exec())
if __name__ == '__main__':
main()