# -*- coding: utf-8 -*-
"""
補助エンジン idx_engine.py (札幌向け 特殊指数エンジン)
役割
1. 本体エンジンが出力した GPV の npz (MSM / ANAL) を読み、特殊指数を計算して
IDX_{モデル}_{初期時刻}_FT{ft}.npz として出力する(ビューワは IDX_* だけ読めばよい)
2. 気象庁 MGDSST(日別海面水温, 0.25度, 数値データ)を自動取得して SST_latest.npz に保存する
3. 札幌周辺の領域統計を idx_stats.csv に追記する(閾値設計・検証用)
4. IDX_META.json(要素の表示定義)と idx_thresholds.json(危険度の目安・総合スコアの設定)を出力する
使い方
python idx_engine.py # GUIが開く(NPZフォルダ・出力先・範囲をGUIで指定)
python idx_engine.py --autostart # GUIを開いて、前回の設定ですぐ開始
python idx_engine.py --cli --npz-dir D:\\npz --out-dir D:\\idx # GUIなしで実行
python idx_engine.py --cli --npz-dir ... --out-dir ... --all --once # 全データを1回処理して終了
オプション: --hours 12(既定) / --all / --force(最初の1回だけ作り直し) / --sst-file / --no-sst-download / --sst-insecure
注意
* 閾値(idx_thresholds.json)の多くは仮置きです。札幌の過去事例で検証して調整してください。
* 各指標は「気象庁GPVの降水予報とは別系統の環境場診断」であり、降水量そのものの予測ではありません。
"""
import os, re, sys, json, gzip, time, csv, argparse, logging, threading, ssl, urllib.request, urllib.parse, urllib.error
from datetime import datetime, timedelta
import numpy as np
from scipy.ndimage import map_coordinates, gaussian_filter, distance_transform_edt
from scipy.interpolate import RegularGridInterpolator
# ---------------------------------------------------------------- 定数
G = 9.80665; RD = 287.04; CP = 1004.0; LV = 2.5e6; EPS = 0.622; KAPPA = 0.2854
SOURCES_ALL = ("MSM", "ANAL", "GSM_JP")
FILE_RE = re.compile(r'^(MSM|ANAL|GSM_JP)_(\d{14})_FT(\d{2,3})\.npz$')
UPPER_RE = re.compile(r'^(t|u|v|r|w|gh|tddep|ep)(\d{3,4})$|^vort500$')
SAPPORO = (141.328, 43.060)
AREAS = {
"札幌近郊": (141.05, 141.65, 42.85, 43.27),
"石狩湾周辺": (140.6, 141.5, 43.0, 43.8),
"北海道": (139.0, 146.0, 41.5, 45.6),
}
SST_BASES = ["https://www.data.jma.go.jp/goos/data/pub/JMA-product/mgd_sst_glb_D",
"https://www.data.jma.go.jp/gmd/goos/data/pub/JMA-product/mgd_sst_glb_D",
"http://www.data.jma.go.jp/goos/data/pub/JMA-product/mgd_sst_glb_D",
"http://www.data.jma.go.jp/gmd/goos/data/pub/JMA-product/mgd_sst_glb_D"]
SST_CROP = (120.0, 160.0, 20.0, 55.0) # lon_min, lon_max, lat_min, lat_max
BASIS_TEXT = {"A": "一般に使われる目安", "B": "経験則(札幌では未検証)", "C": "仮置き(未検証)", "U": "ご指定の値"}
def _ind(op, caution, warning, basis, note="", **extra):
d = {"op": op, "caution": caution, "warning": warning, "basis": basis, "note": note}
d.update(extra)
return d
_SSI_COLORS = {"caution": "red", "warning": "orange"} # ご指定: 0以下=赤、-3以下=橙(他の指標は 注意=橙/警戒=赤)
_WARM = [6, 7, 8, 9] # 暖候期だけ危険度を評価する指標用
DEFAULT_CFG = {
"version": 3,
"_note": ("危険度の目安と総合スコアの設定。数値はすべて調整可能です。変更後、地図とスコアに反映するには "
"idx_engine.py を --force 付きで再実行してください(図の危険度ラインと注釈は再起動なしで更新されます)。"
"basis は根拠の強さ: A=一般に使われる目安 / B=経験則(札幌未検証) / C=仮置き(未検証) / U=ご指定の値。"
"op が ge は『以上』、le は『以下』で危険。"
"colors で線と表の色を変えられます(caution/warning に red か orange)。"
"active_months を付けた指標は、その月だけ危険度を評価します(月以外は参考表示)。"
"実測データから閾値を提案するには --suggest-thresholds を使います。"),
"indicators": {
"MFC": _ind("ge", 2.0, 4.0, "C", "下層の水蒸気収束による降水の供給量(mm/h)"),
"DIFF_SUP": _ind("ge", 2.0, 4.0, "C", "供給量が予報降水を上回る分。大きいほどモデルが降水を出し切れていない可能性"),
"IPW": _ind("ge", 35.0, 45.0, "C", "北日本の夏としては多めの水蒸気量という感覚的な目安"),
"IVT": _ind("ge", 250.0, 500.0, "B", "大気の川の基準(250 kg/m/s)が目安。本指標は975-500hPa積分のためやや小さめに出る"),
"QFLX850": _ind("ge", 150.0, 250.0, "C", "850hPaの水蒸気流入の強さ"),
"QFLX925": _ind("ge", 150.0, 250.0, "C", "925hPaの水蒸気流入の強さ"),
"DIV925": _ind("le", -3.0, -6.0, "C", "下層収束(負)が強いほど持ち上げが強い"),
"K": _ind("ge", 30.0, 35.0, "A", "雷雨の目安としてよく使われる値(米国由来)。札幌では未検証"),
"SSI": _ind("le", 0.0, -3.0, "A", "850→500hPa。0以下で不安定、-3以下でかなり不安定という古典的な目安", colors=_SSI_COLORS),
"SSI_850_700": _ind("le", 0.0, -3.0, "U", "850→700hPa。SSIと同じ値をご指定により設定", colors=_SSI_COLORS),
"SSI_925_700": _ind("le", 0.0, -3.0, "U", "925→700hPa。SSIと同じ値をご指定により設定", colors=_SSI_COLORS),
"TT": _ind("ge", 44.0, 50.0, "A", "44以上で雷雨の可能性、50以上で強い、という古典的な目安"),
"DTHE": _ind("ge", 10.0, 20.0, "C", "θe(850)-θe(500)。大きいほど対流不安定"),
"WCD": _ind("ge", 3000.0, 4000.0, "C", "暖かい雲層が厚いほど降水効率が高い傾向"),
"SHR925_700": _ind("ge", 12.0, 18.0, "C", "暖候期のみ評価(冬は偏西風で常に大きいため対象外)。見直し: 季節別+実測分布で調整", active_months=_WARM),
"SHR925_500": _ind("ge", 18.0, 25.0, "C", "暖候期のみ評価(冬は偏西風で常に大きいため対象外)。見直し: 季節別+実測分布で調整", active_months=_WARM),
"DT850": _ind("ge", 15.0, 20.0, "B", "本州側日本海で筋状雲が発達する経験則。石狩湾は海面水温が低く要校正"),
"DT500": _ind("ge", 36.0, 40.0, "B", "同上。大きいほど不安定"),
"FETCH": _ind("ge", 150.0, 300.0, "C", "風上の連続した海上距離(km)"),
"HEATPATH": _ind("ge", 20.0, 40.0, "C", "風上の海上での気団変質の大きさ"),
"DT850_UP": _ind("ge", 15.0, 20.0, "B", "風上の海上経路での平均。DT850と同じ経験則"),
"DT500_UP": _ind("ge", 36.0, 40.0, "B", "風上の海上経路での平均。DT500と同じ経験則"),
"SENS": _ind("ge", 60.0, 120.0, "C", "風速×(SST-気温)。海面から大気への熱供給の目安"),
"LATENT": _ind("ge", 60.0, 120.0, "C", "風速×(海面と大気の比湿差)。水蒸気供給の目安"),
"DIV10": _ind("le", -4.0, -8.0, "C", "地上収束帯(石狩湾周辺など)の検出用"),
"MOISTTHICK": _ind("ge", 2000.0, 3000.0, "C", "雲層の厚さの代用"),
"DGZTHICK": _ind("ge", 600.0, 1000.0, "C", "-12〜-18℃かつ湿潤な層の厚さ。降雪効率の目安"),
},
"scores": {
"summer": {
"label": "夏型(大雨)",
"items": [{"key": k, "weight": 1} for k in ("MFC", "QFLX850", "IPW", "K", "SSI", "DIV925", "WCD")],
"judge": {"caution": 5, "warning": 9},
"gate": None,
},
"winter": {
"label": "冬型(大雪)",
"items": [{"key": k, "weight": 1} for k in ("DT850_UP", "DT500_UP", "FETCH", "DIV10", "DGZTHICK", "MOISTTHICK")],
"judge": {"caution": 4, "warning": 7},
"gate": {"key": "Z0", "op": "le", "value": 1500.0, "label": "0℃高度が1500m以下(寒気が入っている)"},
},
},
}
# ---------------------------------------------------------------- 表示定義(ビューワが読む)
CATEGORIES = ["総合", "量", "収束", "安定度", "雷", "気温・雲層", "風・雲の動き", "海上・冬型"]
def _el(key, label, unit, cat, grid, levels, cmap, extend, desc, short=None, overlay=True, ovl=None, ovnorm=False):
if not overlay: ovl = None
elif ovl is None:
ovl = dict(u='OV_U925', v='OV_V925', grid='p') if grid == 'p' else dict(u='OV_U10', v='OV_V10', grid='s')
else:
ovl = dict(ovl)
if ovl is not None: ovl['norm'] = bool(ovnorm)
return dict(key=key, label=label, short=short or label, unit=unit, season=cat, cat=cat, grid=grid, levels=levels,
cmap=cmap, extend=extend, desc=desc, overlay=overlay, ovl=ovl)
_OV850 = dict(u='OV_U850', v='OV_V850', grid='p')
META_ELEMENTS = [
# --- 総合
_el("SCORE_SUMMER", "夏の危険度スコア(大雨)", "点", "総合", "p", [0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5], "YlOrRd", "max",
"条件成立数に応じた点数。条件は idx_thresholds.json で調整。", short="夏スコア"),
_el("SCORE_WINTER", "冬の危険度スコア(大雪)", "点", "総合", "p", [0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5], "PuBu", "max",
"条件成立数に応じた点数。条件は idx_thresholds.json で調整。", short="冬スコア"),
# --- 量
_el("IPW", "可降水量(概算)", "mm", "量", "p", list(range(10, 66, 5)), "GnBu", "max",
"975〜300hPaの比湿を積分した概算値(地表〜975hPaは含まない)。", short="可降水量"),
_el("IVT", "IVT(水蒸気輸送量)", "kg/m/s", "量", "p", [100, 150, 200, 250, 300, 400, 500, 700], "PuRd", "max",
"975〜500hPaの q・V を積分した水蒸気輸送量の大きさ。", short="IVT"),
_el("QFLX850", "850hPa水蒸気フラックス(q×V)", "g/kg·m/s", "量", "p", [50, 100, 150, 200, 250, 300, 400], "YlOrRd", "max",
"比湿[g/kg]×風速[m/s]。下層への水蒸気流入の強さ。", short="水蒸気流入850"),
_el("QFLX925", "925hPa水蒸気フラックス(q×V)", "g/kg·m/s", "量", "p", [50, 100, 150, 200, 250, 300, 400], "YlOrRd", "max",
"比湿[g/kg]×風速[m/s]。", short="水蒸気流入925"),
_el("DIFF_SUP", "水蒸気供給量 − MSM予報降水量", "mm/h", "量", "p", [-8, -4, -2, 0, 2, 4, 6, 8, 12], "RdBu_r", "both",
"プラスが大きい所は、環境場の供給に対してモデルの降水が小さい(出し切れていない可能性)。要検証の目安。", short="供給−予報降水"),
# --- 収束
_el("MFC", "水蒸気収束供給量(975-700hPa)", "mm/h", "収束", "p", [0.5, 1, 2, 3, 4, 6, 8, 12, 16], "YlGnBu", "max",
"下層水蒸気の収束を鉛直積分して mm/h に換算した値。降水の『供給側』の目安で、降水量そのものではない。", short="水蒸気収束"),
_el("DIV925", "925hPa風の発散(負=収束)", "1e-5/s", "収束", "p", [-12, -8, -6, -4, -2, 0, 2, 4, 6], "RdBu", "both",
"負の領域が下層収束。降水の持ち上げ要因の目安。", short="発散925"),
_el("DIV10", "地上風の発散(負=収束)", "1e-5/s", "収束", "s", [-20, -12, -8, -4, -2, 0, 2, 4, 8], "RdBu", "both",
"石狩湾周辺の収束帯の検出用。格子が細かいので平滑化済み。", short="発散10m"),
# --- 安定度
_el("SSI", "ショワルター安定指数 SSI(850→500hPa)", "℃", "安定度", "p", [-8, -6, -4, -3, -2, 0, 2, 4, 6], "RdYlBu", "both",
"850hPa気塊を500hPaまで断熱上昇させた時の環境との差。負が不安定(寒色=安定)。", short="SSI 850-500"),
_el("SSI_850_700", "SSI(850→700hPa)", "℃", "安定度", "p", [-8, -6, -4, -3, -2, 0, 2, 4, 6], "RdYlBu", "both",
"850hPa気塊を700hPaまで持ち上げた時の環境との差。下層の不安定の目安。負が不安定。", short="SSI 850-700"),
_el("SSI_925_700", "SSI(925→700hPa)", "℃", "安定度", "p", [-8, -6, -4, -3, -2, 0, 2, 4, 6], "RdYlBu", "both",
"925hPa気塊を700hPaまで持ち上げた時の環境との差。最下層の不安定の目安。負が不安定。", short="SSI 925-700"),
_el("DTHE", "θe(850)−θe(500)", "K", "安定度", "p", [0, 5, 10, 15, 20, 25, 30], "YlOrRd", "max", "大きいほど対流不安定。", short="θe差"),
# --- 雷
_el("K", "Kインデックス", "℃", "雷", "p", [15, 20, 25, 30, 33, 36, 40], "YlOrRd", "max", "(T850−T500)+Td850−(T700−Td700)", short="K指数"),
_el("TT", "Total Totals", "℃", "雷", "p", [38, 42, 44, 46, 48, 50, 52, 56], "YlOrRd", "max", "T850+Td850−2×T500", short="TT"),
# --- 気温・雲層
_el("Z0", "0℃高度", "m", "気温・雲層", "p", [0, 1000, 2000, 3000, 3500, 4000, 4500, 5000], "coolwarm", "max", "気圧面データからの内挿。", short="0℃高度"),
_el("Z3", "3℃高度(気温が3℃になる高さ)", "m", "気温・雲層", "p", [0, 250, 500, 1000, 1500, 2000, 3000, 4000], "coolwarm", "max",
"気温が3℃になる高さ。最下層(975hPa)がすでに3℃未満なら0m。低いほど雪に近い環境(気圧面データからの内挿)。", short="3℃高度"),
_el("WCD", "暖かい雲層の厚さ(0℃高度−LCL高度)", "m", "気温・雲層", "p", [0, 1000, 2000, 3000, 3500, 4000, 4500, 5000], "YlGnBu", "max",
"厚いほど降水効率が高い傾向(概算)。", short="暖雲厚"),
_el("MOISTTHICK", "湿潤層厚(RH≥80%)", "m", "気温・雲層", "p", [0, 500, 1000, 1500, 2000, 2500, 3000, 4000], "GnBu", "max",
"雲層の厚さの代用(気圧面の層から概算)。", short="湿潤層厚"),
_el("CLDTHICK", "下層から連続する雲層厚(RH≥80%)", "m", "気温・雲層", "p", [0, 500, 1000, 1500, 2000, 3000, 4000, 6000], "GnBu", "max",
"最下層(975hPa)から上へ、RH≥80%が途切れず続く層の厚さ。最下層が乾いていれば0。", short="下層連続雲厚"),
_el("TOP80", "湿潤層の最高高度(雲頂の代用)", "m", "気温・雲層", "p", [0, 1000, 2000, 3000, 4000, 5000, 6000], "GnBu", "max",
"RH≥80%を満たす最高の気圧面の高度。", short="雲頂高度"),
_el("COLDCLD", "0℃以上の高さの雲層厚(0℃高度より上のRH≥80%)", "m", "気温・雲層", "p", [0, 500, 1000, 2000, 3000, 4000, 6000], "PuBu", "max",
"気温が0℃未満の側(0℃高度より上)にある湿潤層(RH≥80%)の厚さ。氷晶過程が働く雲の厚さの目安。", short="0℃上の雲厚"),
_el("DGZTHICK", "樹枝状結晶成長層の湿潤厚(−12〜−18℃)", "m", "気温・雲層", "p", [0, 200, 400, 600, 800, 1000, 1500, 2000], "PuBu", "max",
"−12〜−18℃かつRH≥80%の層の厚さ。降雪効率の目安。", short="DGZ厚"),
# --- 風・雲の動き
_el("WS925", "925hPa風速(下層の流入)", "m/s", "風・雲の動き", "p", [5, 8, 10, 15, 20, 25, 30], "PuBu", "max", "矢印=風向。水蒸気の流入の強さの目安。", short="風速925"),
_el("WS850", "850hPa風速(下層ジェット)", "m/s", "風・雲の動き", "p", [5, 8, 10, 15, 20, 25, 30], "PuBu", "max", "矢印=風向。下層ジェットの目安。",
short="風速850", ovl=_OV850),
_el("CLDWIND", "雲層平均風(850〜300hPa)", "m/s", "風・雲の動き", "p", [2, 5, 8, 10, 15, 20, 30], "YlGnBu", "max",
"850・700・500・300hPaの風の圧力加重平均。発達した雲が流される向きと速さの目安。矢印=向き。", short="雲層平均風",
ovl=dict(u='OV_UCLD', v='OV_VCLD', grid='p'), ovnorm=True),
_el("MCSMOVE", "積乱雲群(MCS)の移動速度(Corfidi法)", "m/s", "風・雲の動き", "p", [1, 3, 5, 8, 10, 15, 20], "YlOrBr", "max",
"2×雲層平均風 − 850hPa風。小さいほど同じ場所に雨が続きやすい(水蒸気量・不安定と合わせて見る)。矢印=移動の向き。", short="MCS移動速",
ovl=dict(u='OV_UMCS', v='OV_VMCS', grid='p'), ovnorm=True),
_el("PROPAG", "新規セル(子雲)の伝播ベクトル(Corfidi法)", "m/s", "風・雲の動き", "p", [1, 3, 5, 8, 10, 15, 20], "OrRd", "max",
"雲層平均風 − 850hPa風。新しい積乱雲が既存の雲に対してできやすい向きと速さの目安。矢印=新しいセルができやすい側。", short="新規セル伝播",
ovl=dict(u='OV_UPRP', v='OV_VPRP', grid='p'), ovnorm=True),
_el("SHR925_700", "鉛直シア(925-700hPa)", "m/s", "風・雲の動き", "p", [2, 4, 6, 8, 10, 15, 20], "PuBu", "max", "ベクトル差の大きさ。", short="シア925-700"),
_el("SHR925_500", "鉛直シア(925-500hPa)", "m/s", "風・雲の動き", "p", [5, 10, 15, 20, 25, 30, 40], "PuBu", "max",
"下層風と中層風の差(組織化・移動の目安)。", short="シア925-500"),
# --- 海上・冬型
_el("SST", "海面水温(MGDSST)", "℃", "海上・冬型", "p", [0, 4, 8, 12, 16, 20, 24, 28], "turbo", "max",
"気象庁MGDSSTの日別海面水温(解析日はSST状態表示を参照)。", short="SST"),
_el("DT850", "SST−T850(海上のみ)", "℃", "海上・冬型", "p", [10, 12, 15, 18, 20, 22, 25], "YlOrRd", "max",
"海面水温と850hPa気温の差。15℃以上で筋状雲が発達する目安(本州側日本海の経験則。札幌は要校正)。", short="SST−T850"),
_el("DT500", "SST−T500(海上のみ)", "℃", "海上・冬型", "p", [30, 33, 36, 38, 40, 42, 45], "YlOrRd", "max",
"海面水温と500hPa気温の差。大きいほど不安定(経験則。要校正)。", short="SST−T500"),
_el("DT850_UP", "風上平均 SST−T850", "℃", "海上・冬型", "p", [8, 10, 12, 15, 18, 20, 25], "YlOrRd", "max",
"風上の海上経路での平均値。陸上(札幌など)でも評価できる。", short="風上SST−T850"),
_el("DT500_UP", "風上平均 SST−T500", "℃", "海上・冬型", "p", [30, 33, 36, 38, 40, 42, 45], "YlOrRd", "max", "風上の海上経路での平均値。", short="風上SST−T500"),
_el("FETCH", "海上吹走距離(925hPa後方流跡)", "km", "海上・冬型", "p", [0, 50, 100, 150, 200, 300, 400, 600], "YlGnBu", "max",
"各地点から925hPa風の風上へ遡り、連続して海上だった距離。陸上の点も風上の海を評価する。", short="吹走距離"),
_el("HEATPATH", "吹走熱量指標(風上のSST−T925積算)", "℃·100km", "海上・冬型", "p", [0, 5, 10, 20, 30, 40, 60, 80], "YlOrRd", "max",
"風上の海上経路でΣ max(SST−T925,0)×距離/100km。気団変質の大きさの目安。", short="吹走熱量"),
_el("SENS", "海面顕熱指標 |V10|×(SST−T2m)", "℃·m/s", "海上・冬型", "s", [0, 20, 40, 60, 80, 100, 150, 200], "YlOrRd", "max",
"風を含めた海面顕熱供給の目安(海上のみ)。", short="海面顕熱"),
_el("LATENT", "海面潜熱指標 |V10|×(qs(SST)−q2m)", "g/kg·m/s", "海上・冬型", "s", [0, 20, 40, 60, 80, 100, 150, 200], "YlGnBu", "max",
"風を含めた海面水蒸気供給の目安(海上のみ)。", short="海面潜熱"),
]
# ---------------------------------------------------------------- 物理ユーティリティ
def es_hpa(tc):
return 6.112 * np.exp(17.67 * tc / (tc + 243.5))
def q_from_t_rh(tc, rh, p_hpa):
e = es_hpa(tc) * np.clip(rh, 0.1, 100.0) / 100.0
return EPS * e / (p_hpa - (1.0 - EPS) * e)
def td_from_t_rh(tc, rh):
e = es_hpa(tc) * np.clip(rh, 1.0, 100.0) / 100.0
ln = np.log(e / 6.112)
return 243.5 * ln / (17.67 - ln)
def theta_e(tc, rh, p_hpa):
tk = tc + 273.15
q = q_from_t_rh(tc, rh, p_hpa)
w = q / (1.0 - q)
return tk * (1000.0 / p_hpa) ** KAPPA * np.exp(LV * w / (CP * tk))
def wet_bulb(tc, rh): # Stull (2011)
rh = np.clip(rh, 5.0, 99.0)
return (tc * np.arctan(0.151977 * np.sqrt(rh + 8.313659)) + np.arctan(tc + rh)
- np.arctan(rh - 1.676331) + 0.00391838 * rh ** 1.5 * np.arctan(0.023101 * rh) - 4.686035)
def smooth(a, sigma):
m = np.isfinite(a)
if not m.any():
return a
f = gaussian_filter(np.where(m, a, 0.0), sigma)
w = gaussian_filter(m.astype(float), sigma)
return np.where(m, f / np.maximum(w, 1e-6), np.nan)
def div_field(u, v, lon, lat):
"""球面上の発散 (1/s)。lon, lat は昇順の1次元。"""
R = 6371000.0
dlon = np.deg2rad(np.gradient(lon)); dlat = np.deg2rad(np.gradient(lat))
cl = np.cos(np.deg2rad(lat))[:, None]
t1 = np.gradient(u, axis=1) / (R * cl * dlon[None, :])
t2 = np.gradient(v * cl, axis=0) / (R * cl * dlat[:, None])
return t1 + t2
def regrid(arr, src_lon, src_lat, dst_lon, dst_lat):
f = RegularGridInterpolator((src_lat, src_lon), arr, bounds_error=False, fill_value=np.nan)
LAT, LON = np.meshgrid(dst_lat, dst_lon, indexing='ij')
return f(np.stack([LAT.ravel(), LON.ravel()], -1)).reshape(LAT.shape)
def sst_to_grid(sst, slon, slat, dlon, dlat):
"""SST(陸=NaN)を目的格子へ。陸の判定は有効値マスクの内挿(>=0.5)で行う。"""
valid = np.isfinite(sst)
if not valid.any():
return np.full((len(dlat), len(dlon)), np.nan)
idx = distance_transform_edt(~valid, return_distances=False, return_indices=True)
filled = sst[tuple(idx)]
out = regrid(filled, slon, slat, dlon, dlat)
m = regrid(valid.astype(float), slon, slat, dlon, dlat) >= 0.5
return np.where(m, out, np.nan)
def level_heights(A, lv):
"""各気圧面の高度(m)を返す。ghがある面はそれを使い、無い面は下の面から静力学式で補う。"""
Z = {}
for i, L in enumerate(lv):
g_ = A.get(f'gh{L}')
if g_ is not None and np.isfinite(g_).any():
Z[L] = g_; continue
if i == 0:
z_std = 44330.0 * (1.0 - (L / 1013.25) ** 0.1903)
Z[L] = np.full(A[f't{L}'].shape, z_std)
else:
Lp = lv[i - 1]
tm = 0.5 * (A[f't{L}'] + A[f't{Lp}']) + 273.15
Z[L] = Z[Lp] + RD * tm / G * np.log(Lp / L)
return Z
def lift_parcel(t0c, td0c, p0, p_end=500.0, n=30):
"""p0(hPa)の気塊を p_end まで持ち上げた時の気温(K)を返す。
乾燥断熱でLCLまで(解析解)→ LCL から p_end まで湿潤断熱(2次精度のルンゲクッタ、n分割)。
旧方式(10hPa刻みの前進オイラー+LCLのステップ誤差)は気塊温度が最大約1K高めに出ていたため置き換えた。"""
t0 = t0c + 273.15; td0 = np.minimum(td0c, t0c) + 273.15
tlcl = 1.0 / (1.0 / (td0 - 56.0) + np.log(t0 / td0) / 800.0) + 56.0
plcl = p0 * (tlcl / t0) ** (1.0 / KAPPA)
t_dry_end = t0 * (p_end / p0) ** KAPPA
def f(T, p):
es = es_hpa(T - 273.15); rs = EPS * es / np.maximum(p - es, 1.0)
return (RD * T + LV * rs) / (p * (CP + (LV ** 2 * rs * EPS) / (RD * T ** 2)))
p = np.maximum(plcl, p_end); h = (p - p_end) / n # 点ごとに刻み幅を変える(LCLを厳密に扱うため)
T = np.where(plcl > p_end, tlcl, t_dry_end)
for _ in range(n):
Tm = T - f(T, p) * h / 2.0
T = T - f(Tm, p - h / 2.0) * h
p = p - h
return np.where(plcl > p_end, T, t_dry_end)
# ---------------------------------------------------------------- npz 読み込み
def _pack(lon, lat, a):
lon = lon.astype(float); lat = lat.astype(float)
if lat[0] > lat[-1]:
lat = lat[::-1]; a = {k: v[::-1, :] for k, v in a.items()}
if lon[0] > lon[-1]:
lon = lon[::-1]; a = {k: v[:, ::-1] for k, v in a.items()}
clean = {}
for k, v in a.items():
v = np.asarray(v, dtype=np.float64)
v = np.where(np.abs(v) > 1e15, np.nan, v)
if k.startswith('gh') and np.isfinite(v).any() and np.nanmax(v) > 30000.0:
v = v / G # ジオポテンシャル(m2/s2)で格納されていた場合はmへ換算
clean[k] = v
return {'lon': lon, 'lat': lat, 'a': clean}
def load_fields(path):
z = np.load(path, allow_pickle=False)
arrs = {k: np.asarray(z[k]) for k in z.files}
def pick_grid(prefs, ref):
shp = arrs[ref].shape if ref in arrs else None
for lk, tk in prefs:
if lk in arrs and tk in arrs and arrs[lk].ndim == 1 and arrs[tk].ndim == 1:
if shp is None or (len(arrs[tk]), len(arrs[lk])) == shp:
return arrs[lk], arrs[tk]
return None
up = [k for k in arrs if UPPER_RE.match(k) and arrs[k].ndim == 2]
sf = [k for k in arrs if arrs[k].ndim == 2 and not UPPER_RE.match(k) and not k.startswith(('lon', 'lat'))]
res = {'P': None, 'S': None}
if up:
g = pick_grid([('lon_pall', 'lat_pall'), ('lon_p', 'lat_p'), ('lon', 'lat')], up[0])
if g is not None:
shp = (len(g[1]), len(g[0]))
res['P'] = _pack(g[0], g[1], {k: arrs[k] for k in up if arrs[k].shape == shp})
if sf:
ref = 't2m' if 't2m' in arrs else sf[0]
g = pick_grid([('lon_surf', 'lat_surf'), ('lon', 'lat')], ref)
if g is not None:
shp = (len(g[1]), len(g[0]))
res['S'] = _pack(g[0], g[1], {k: arrs[k] for k in sf if arrs[k].shape == shp})
return res
# ---------------------------------------------------------------- 指標計算
def _levels(A, names, cands):
return [L for L in cands if all(f'{n}{L}' in A for n in names)]
def upstream_fetch(u, v, sst, t925, t850, t500, lon, lat, ds_km=10.0, n_steps=60):
"""各格子点から風上へ遡り、連続した海上区間の距離・熱量・SST-T平均を求める。"""
ny, nx = u.shape
LON, LAT = np.meshgrid(lon, lat)
dlon = (lon[-1] - lon[0]) / (nx - 1); dlat = (lat[-1] - lat[0]) / (ny - 1)
sea = np.isfinite(sst)
u0 = np.nan_to_num(u); v0 = np.nan_to_num(v); sst0 = np.where(sea, sst, 0.0); seaf = sea.astype(float)
t925_0 = np.nan_to_num(t925); t850_0 = np.nan_to_num(t850); t500_0 = np.nan_to_num(t500)
def samp(A, x, y):
return map_coordinates(A, [(y - lat[0]) / dlat, (x - lon[0]) / dlon], order=1, mode='nearest')
x = LON.copy(); y = LAT.copy()
alive = np.ones((ny, nx), bool); seen = np.zeros((ny, nx), bool)
dist = np.zeros((ny, nx)); heat = np.zeros((ny, nx)); d850 = np.zeros((ny, nx)); d500 = np.zeros((ny, nx)); cnt = np.zeros((ny, nx))
for _ in range(n_steps):
uu = samp(u0, x, y); vv = samp(v0, x, y)
sp = np.maximum(np.hypot(uu, vv), 0.5)
x = x - uu / sp * ds_km / (111.0 * np.cos(np.deg2rad(y)))
y = y - vv / sp * ds_km / 111.0
inside = (x >= lon[0]) & (x <= lon[-1]) & (y >= lat[0]) & (y <= lat[-1])
is_sea = samp(seaf, x, y) > 0.5
alive &= inside
alive &= ~(seen & ~is_sea)
ok = alive & is_sea
seen |= ok
s = samp(sst0, x, y)
dist += ok * ds_km
heat += ok * np.maximum(s - samp(t925_0, x, y), 0.0) * ds_km / 100.0
d850 += ok * (s - samp(t850_0, x, y)); d500 += ok * (s - samp(t500_0, x, y)); cnt += ok
with np.errstate(all='ignore'):
d850u = np.where(cnt > 0, d850 / cnt, np.nan); d500u = np.where(cnt > 0, d500 / cnt, np.nan)
return dist, heat, d850u, d500u
def pts_arr(a, ind):
"""0点(基準未満) / 1点(注意) / 2点(警戒)"""
if ind['op'] == 'ge':
p = np.where(a >= ind['warning'], 2.0, np.where(a >= ind['caution'], 1.0, 0.0))
else:
p = np.where(a <= ind['warning'], 2.0, np.where(a <= ind['caution'], 1.0, 0.0))
return np.where(np.isfinite(a), p, 0.0)
def pts_scalar(v, ind):
if v is None: return None
if ind['op'] == 'ge': return 2 if v >= ind['warning'] else (1 if v >= ind['caution'] else 0)
return 2 if v <= ind['warning'] else (1 if v <= ind['caution'] else 0)
def _fl(x):
x = float(x)
return x if np.isfinite(x) else None
def level_of(total, judge):
if total is None: return None
return '警戒' if total >= judge['warning'] else ('注意' if total >= judge['caution'] else '平常')
def build_table(cfg, out, P, get_p, raws):
"""総合判定表(札幌点と札幌近郊の、各条件の値・閾値・点数)をJSON化できる形で返す。"""
lon, lat = P['lon'], P['lat']
j = int(np.abs(lat - SAPPORO[1]).argmin()); i = int(np.abs(lon - SAPPORO[0]).argmin())
x0, x1, y0, y1 = AREAS['札幌近郊']
mx = (lon >= x0) & (lon <= x1); my = (lat >= y0) & (lat <= y1)
has_box = bool(mx.any() and my.any())
def at_pt(a): return _fl(a[j, i])
def at_box(a, op):
if not has_box: return None
sub = a[np.ix_(my, mx)]
if not np.isfinite(sub).any(): return None
return _fl(np.nanmax(sub) if op == 'ge' else np.nanmin(sub))
elabel = {e['key']: (e['label'], e['unit']) for e in META_ELEMENTS}
tbl = {'version': 1, 'sapporo': list(SAPPORO), 'grid_pt': [_fl(lon[i]), _fl(lat[j])], 'area': '札幌近郊'}
for sk, okey in (('summer', 'SCORE_SUMMER'), ('winter', 'SCORE_WINTER')):
sc = cfg.get('scores', {}).get(sk)
if not sc or sk not in raws: continue
rows = []
for it in sc['items']:
key = it['key']; ind = cfg['indicators'].get(key); lab, unit = elabel.get(key, (key, ''))
arr = get_p(key)
row = {'key': key, 'label': lab, 'unit': unit, 'weight': it.get('weight', 1)}
if ind is None or arr is None:
row['missing'] = True; rows.append(row); continue
vp = at_pt(arr); vb = at_box(arr, ind['op'])
row.update(op=ind['op'], caution=ind['caution'], warning=ind['warning'], basis=ind.get('basis', ''),
v_pt=vp, v_box=vb, p_pt=pts_scalar(vp, ind), p_box=pts_scalar(vb, ind))
rows.append(row)
shown = out[okey][1]; judge = sc['judge']
rp = at_pt(raws[sk]); rb = at_box(raws[sk], 'ge'); sp = at_pt(shown); sb = at_box(shown, 'ge')
gate = None; g = sc.get('gate')
if g:
ga = get_p(g['key'])
if ga is not None:
gop = g.get('op', 'le'); gv = at_pt(ga); gb = at_box(ga, 'ge' if gop == 'ge' else 'le')
okf = lambda v: None if v is None else bool((v <= g['value']) if gop == 'le' else (v >= g['value']))
gate = {'label': g.get('label', g['key']), 'key': g['key'], 'op': gop, 'value': g['value'],
'unit': elabel.get(g['key'], ('', ''))[1], 'v_pt': gv, 'ok_pt': okf(gv), 'v_box': gb, 'ok_box': okf(gb)}
tbl[sk] = {'label': sc.get('label', sk), 'max_points': 2 * sum(float(it.get('weight', 1)) for it in sc['items']),
'judge': judge, 'gate': gate, 'rows': rows,
'pt': {'raw': rp, 'shown': sp, 'level': level_of(sp, judge), 'raw_level': level_of(rp, judge)},
'box': {'raw': rb, 'shown': sb, 'level': level_of(sb, judge), 'raw_level': level_of(rb, judge)}}
return tbl
def compute_indices(F, sst, thr, log):
"""F: load_fields の結果, sst: dict(lon,lat,sst) or None。戻り値: {key: (grid, array)}"""
out = {}
P = F.get('P'); S = F.get('S')
def guard(label, fn):
try:
fn()
except Exception as e: # 1要素の失敗で全体を止めない
log.warning(f" 指標 {label} をスキップ: {e}")
sstP = sstS = None
if sst is not None:
if P is not None: sstP = sst_to_grid(sst['sst'], sst['lon'], sst['lat'], P['lon'], P['lat'])
if S is not None: sstS = sst_to_grid(sst['sst'], sst['lon'], sst['lat'], S['lon'], S['lat'])
with np.errstate(all='ignore'):
if P is not None:
A = P['a']; lon = P['lon']; lat = P['lat']
def _mfc():
lv = _levels(A, 'truv', [975, 950, 925, 850, 700])
if len(lv) < 3: return
divq = []
for L in lv:
q = q_from_t_rh(A[f't{L}'], A[f'r{L}'], L)
divq.append(div_field(q * A[f'u{L}'], q * A[f'v{L}'], lon, lat))
m = np.zeros_like(divq[0])
for i in range(len(lv) - 1):
m += -0.5 * (divq[i] + divq[i + 1]) * (lv[i] - lv[i + 1]) * 100.0 / G
out['MFC'] = ('p', smooth(m * 3600.0, 1.0))
guard('MFC', _mfc)
def _ipw():
lv = _levels(A, 'tr', [975, 950, 925, 850, 700, 600, 500, 300])
if len(lv) < 3: return
qs = [q_from_t_rh(A[f't{L}'], A[f'r{L}'], L) for L in lv]
s = sum(0.5 * (qs[i] + qs[i + 1]) * (lv[i] - lv[i + 1]) * 100.0 / G for i in range(len(lv) - 1))
out['IPW'] = ('p', s)
guard('IPW', _ipw)
def _ivt():
lv = _levels(A, 'truv', [975, 950, 925, 850, 700, 600, 500])
if len(lv) < 3: return
qu = []; qv = []
for L in lv:
q = q_from_t_rh(A[f't{L}'], A[f'r{L}'], L); qu.append(q * A[f'u{L}']); qv.append(q * A[f'v{L}'])
iu = sum(0.5 * (qu[i] + qu[i + 1]) * (lv[i] - lv[i + 1]) * 100.0 / G for i in range(len(lv) - 1))
iv = sum(0.5 * (qv[i] + qv[i + 1]) * (lv[i] - lv[i + 1]) * 100.0 / G for i in range(len(lv) - 1))
out['IVT'] = ('p', np.hypot(iu, iv))
guard('IVT', _ivt)
def _qflx():
for L in (850, 925):
if all(f'{n}{L}' in A for n in 'truv'):
q = q_from_t_rh(A[f't{L}'], A[f'r{L}'], L) * 1000.0
out[f'QFLX{L}'] = ('p', q * np.hypot(A[f'u{L}'], A[f'v{L}']))
guard('QFLX', _qflx)
def _div925():
for L in (925, 950, 975):
if f'u{L}' in A and f'v{L}' in A:
out['DIV925'] = ('p', smooth(div_field(A[f'u{L}'], A[f'v{L}'], lon, lat) * 1e5, 1.0)); return
guard('DIV925', _div925)
def _stab():
if all(k in A for k in ('t850', 'r850', 't500')):
td850 = td_from_t_rh(A['t850'], A['r850'])
tp = lift_parcel(A['t850'], td850, 850.0)
out['SSI'] = ('p', A['t500'] - (tp - 273.15))
out['TT'] = ('p', A['t850'] + td850 - 2.0 * A['t500'])
if all(k in A for k in ('t700', 'r700')):
td700 = td_from_t_rh(A['t700'], A['r700'])
out['K'] = ('p', (A['t850'] - A['t500']) + td850 - (A['t700'] - td700))
out['DTHE'] = ('p', theta_e(A['t850'], A['r850'], 850.0) - theta_e(A['t500'], A['r500'], 500.0)) if 'r500' in A else None
if out.get('DTHE') is None: out.pop('DTHE', None)
guard('安定度', _stab)
def _ssi_extra():
for b, t_, key in ((850, 700, 'SSI_850_700'), (925, 700, 'SSI_925_700')):
if all(k in A for k in (f't{b}', f'r{b}', f't{t_}')):
tdb = td_from_t_rh(A[f't{b}'], A[f'r{b}'])
tp = lift_parcel(A[f't{b}'], tdb, float(b), p_end=float(t_))
out[key] = ('p', A[f't{t_}'] - (tp - 273.15))
guard('SSI(下層)', _ssi_extra)
def _zero():
lv = _levels(A, ('t',), [975, 950, 925, 850, 700, 600, 500])
if len(lv) < 3:
log.warning(f' Z0/WCD: 必要な気圧面が不足 {lv}'); return
Zd = level_heights(A, lv)
T = [A[f't{L}'] for L in lv]; Z = [Zd[L] for L in lv]
z0 = np.full(T[0].shape, np.nan)
for i in range(len(lv) - 1):
cross = np.isnan(z0) & (T[i] >= 0) & (T[i + 1] < 0)
zz = Z[i] + T[i] / np.where(T[i] - T[i + 1] == 0, np.nan, T[i] - T[i + 1]) * (Z[i + 1] - Z[i])
z0 = np.where(cross, zz, z0)
z0 = np.where(np.isnan(z0) & (T[0] < 0), 0.0, z0)
z0 = np.where(np.isnan(z0) & (T[-1] >= 0), Z[-1], z0)
out['Z0'] = ('p', z0)
def _tlevel(tc_):
z_ = np.full(T[0].shape, np.nan)
for i in range(len(lv) - 1):
den = np.where(T[i] - T[i + 1] == 0, np.nan, T[i] - T[i + 1])
cross = np.isnan(z_) & (T[i] >= tc_) & (T[i + 1] < tc_)
z_ = np.where(cross, Z[i] + (T[i] - tc_) / den * (Z[i + 1] - Z[i]), z_)
z_ = np.where(np.isnan(z_) & (T[0] < tc_), 0.0, z_)
return np.where(np.isnan(z_) & (T[-1] >= tc_), Z[-1], z_)
out['Z3'] = ('p', _tlevel(3.0))
for L in (975, 950, 925):
if L in Zd and f'r{L}' in A:
zl = Zd[L] + 125.0 * (A[f't{L}'] - td_from_t_rh(A[f't{L}'], A[f'r{L}']))
out['WCD'] = ('p', np.maximum(z0 - zl, 0.0)); break
guard('Z0/WCD', _zero)
def _shear():
if all(k in A for k in ('u925', 'v925', 'u700', 'v700')):
out['SHR925_700'] = ('p', np.hypot(A['u925'] - A['u700'], A['v925'] - A['v700']))
if all(k in A for k in ('u925', 'v925', 'u500', 'v500')):
out['SHR925_500'] = ('p', np.hypot(A['u925'] - A['u500'], A['v925'] - A['v500']))
guard('シア', _shear)
def _winds():
for L in (925, 850):
if f'u{L}' in A and f'v{L}' in A:
out[f'WS{L}'] = ('p', np.hypot(A[f'u{L}'], A[f'v{L}']))
if 'u850' in A and 'v850' in A:
out['OV_U850'] = ('p', A['u850']); out['OV_V850'] = ('p', A['v850'])
lv = [L for L in (850, 700, 500, 300) if f'u{L}' in A and f'v{L}' in A]
if len(lv) < 3: return
ps = np.array(lv, float); w = np.zeros(len(ps))
for i in range(len(ps) - 1):
d_ = ps[i] - ps[i + 1]; w[i] += d_ / 2.0; w[i + 1] += d_ / 2.0
w = w / w.sum() # 圧力で重みづけした雲層平均風
uc = sum(wi * A[f'u{L}'] for wi, L in zip(w, lv)); vc = sum(wi * A[f'v{L}'] for wi, L in zip(w, lv))
out['CLDWIND'] = ('p', np.hypot(uc, vc)); out['OV_UCLD'] = ('p', uc); out['OV_VCLD'] = ('p', vc)
for L in (850, 925): # 下層ジェットの代表面
if f'u{L}' in A and f'v{L}' in A:
ul, vl = A[f'u{L}'], A[f'v{L}']; break
else:
return
up_, vp_ = uc - ul, vc - vl # Corfidi: 伝播ベクトル = 雲層平均風 − 下層風
um, vm = uc + up_, vc + vp_ # Corfidi: MCS移動 = 雲層平均風 + 伝播
out['PROPAG'] = ('p', np.hypot(up_, vp_)); out['OV_UPRP'] = ('p', up_); out['OV_VPRP'] = ('p', vp_)
out['MCSMOVE'] = ('p', np.hypot(um, vm)); out['OV_UMCS'] = ('p', um); out['OV_VMCS'] = ('p', vm)
guard('風・雲の動き', _winds)
def _moist():
lv = _levels(A, ('t', 'r'), [975, 950, 925, 850, 700, 600, 500])
if len(lv) < 3:
log.warning(f' 湿潤層/DGZ: 必要な気圧面が不足 {lv}'); return
Zd = level_heights(A, lv)
T = [A[f't{L}'] for L in lv]; R = [A[f'r{L}'] for L in lv]; Z = [Zd[L] for L in lv]
shp = T[0].shape; moist = np.zeros(shp); dgz = np.zeros(shp); top = np.zeros(shp); NS = 8
cont = np.zeros(shp); cold = np.zeros(shp); alive = np.ones(shp, bool)
for i in range(len(lv) - 1):
dz = np.maximum(Z[i + 1] - Z[i], 0.0) / NS
for k in range(NS):
f = (k + 0.5) / NS
Ti = T[i] + (T[i + 1] - T[i]) * f; Ri = R[i] + (R[i + 1] - R[i]) * f; Zi = Z[i] + (Z[i + 1] - Z[i]) * f
m = Ri >= 80.0
moist += np.where(m, dz, 0.0)
alive &= m
cont += np.where(alive, dz, 0.0)
cold += np.where(m & (Ti < 0.0), dz, 0.0)
dgz += np.where(m & (Ti <= -12.0) & (Ti >= -18.0), dz, 0.0)
top = np.where(m, np.maximum(top, Zi), top)
out['MOISTTHICK'] = ('p', moist); out['DGZTHICK'] = ('p', dgz); out['TOP80'] = ('p', top)
out['CLDTHICK'] = ('p', cont); out['COLDCLD'] = ('p', cold)
guard('湿潤層/DGZ', _moist)
# --- SST を使う指標(P格子)
if sstP is not None:
out['SST'] = ('p', sstP)
def _dt():
if 't850' in A: out['DT850'] = ('p', sstP - A['t850'])
if 't500' in A: out['DT500'] = ('p', sstP - A['t500'])
guard('SST-T', _dt)
def _fetch():
for L in (925, 950, 975, 850):
if f'u{L}' in A and f'v{L}' in A: break
else:
return
if not all(k in A for k in ('t925', 't850', 't500')): return
dist, heat, d850u, d500u = upstream_fetch(A[f'u{L}'], A[f'v{L}'], sstP, A['t925'], A['t850'], A['t500'], lon, lat)
out['FETCH'] = ('p', dist); out['HEATPATH'] = ('p', heat)
out['DT850_UP'] = ('p', d850u); out['DT500_UP'] = ('p', d500u)
guard('吹走距離', _fetch)
# --- 供給量−予報降水
def _diff():
if S is not None and 'precip' in S['a'] and 'MFC' in out:
pr = regrid(S['a']['precip'], S['lon'], S['lat'], lon, lat)
out['DIFF_SUP'] = ('p', out['MFC'][1] - pr)
guard('供給-降水', _diff)
if 'u925' in A and 'v925' in A:
out['OV_U925'] = ('p', A['u925']); out['OV_V925'] = ('p', A['v925'])
if S is not None:
B = S['a']; slon = S['lon']; slat = S['lat']
def _div10():
if 'u10' in B and 'v10' in B: out['DIV10'] = ('s', smooth(div_field(B['u10'], B['v10'], slon, slat) * 1e5, 1.5))
guard('DIV10', _div10)
if sstS is not None:
def _flux():
if all(k in B for k in ('u10', 'v10', 't2m')):
sp = np.hypot(B['u10'], B['v10'])
out['SENS'] = ('s', sp * (sstS - B['t2m']))
if 'rh2m' in B:
qs = EPS * es_hpa(sstS) / (1010.0 - es_hpa(sstS)) * 1000.0
q2 = q_from_t_rh(B['t2m'], B['rh2m'], 1010.0) * 1000.0
out['LATENT'] = ('s', sp * (qs - q2))
guard('海面フラックス', _flux)
if 'u10' in B and 'v10' in B:
out['OV_U10'] = ('s', B['u10']); out['OV_V10'] = ('s', B['v10'])
# --- スコア
if P is not None:
def get_p(key):
if key not in out: return None
g, a = out[key]
if g == 'p': return a
return regrid(a, S['lon'], S['lat'], P['lon'], P['lat'])
raws = {}
def _score():
for sk, okey in (('summer', 'SCORE_SUMMER'), ('winter', 'SCORE_WINTER')):
sc = thr.get('scores', {}).get(sk)
if not sc: continue
tot = None
for it in sc['items']:
ind = thr['indicators'].get(it['key']); arr = get_p(it['key'])
if ind is None or arr is None: continue
p = pts_arr(arr, ind) * float(it.get('weight', 1))
tot = p if tot is None else tot + p
if tot is None: continue
shown = tot; g = sc.get('gate')
if g:
ga = get_p(g['key'])
if ga is not None:
ok = (ga <= g['value']) if g.get('op', 'le') == 'le' else (ga >= g['value'])
shown = np.where(np.isfinite(ga) & ok, tot, np.nan)
raws[sk] = tot; out[okey] = ('p', shown)
if raws:
out['_TABLE'] = ('x', json.dumps(build_table(thr, out, P, get_p, raws), ensure_ascii=False))
guard('スコア', _score)
def _ptvals():
grids = {}
if P is not None: grids['p'] = P
if S is not None: grids['s'] = S
x0, x1, y0, y1 = AREAS['札幌近郊']; idx = {}
for g_, G_ in grids.items():
j = int(np.abs(G_['lat'] - SAPPORO[1]).argmin()); i = int(np.abs(G_['lon'] - SAPPORO[0]).argmin())
mx = (G_['lon'] >= x0) & (G_['lon'] <= x1); my = (G_['lat'] >= y0) & (G_['lat'] <= y1)
idx[g_] = (j, i, np.ix_(my, mx) if (mx.any() and my.any()) else None)
pt = {}; bx = {}
for key, (g_, arr) in out.items():
if key.startswith(('OV_', '_')) or g_ not in idx: continue
arr = np.asarray(arr, float); j, i, box = idx[g_]
pt[key] = _fl(arr[j, i])
if box is None: bx[key] = None; continue
sub = arr[box]
if not np.isfinite(sub).any(): bx[key] = None; continue
ind = thr['indicators'].get(key)
if key in ('SCORE_SUMMER', 'SCORE_WINTER'): bx[key] = _fl(np.nanmax(sub))
elif ind: bx[key] = _fl(np.nanmax(sub) if ind['op'] == 'ge' else np.nanmin(sub)) # 危険側
else: bx[key] = _fl(np.nanmean(sub))
out['_PTVALS'] = ('x', json.dumps({'has_p': P is not None, 'pt': pt, 'box': bx}, ensure_ascii=False))
guard('点値', _ptvals)
return out
# ---------------------------------------------------------------- SST (MGDSST) 取得・解析
def is_ssl_error(e):
r = getattr(e, 'reason', None)
return isinstance(e, ssl.SSLError) or isinstance(r, ssl.SSLError) or 'ASN1' in str(e) or 'CERTIFICATE' in str(e).upper()
SSL_HINT = ('→ Windowsの証明書ストアの読み込みに失敗している可能性があります。'
'「SSL証明書の検証を行わない」にチェックして再取得するか、ブラウザで取得したSSTファイルを「SSTファイルを指定…」で読み込んでください')
def describe_err(e):
"""例外を、画面に出せる短い日本語にする"""
if is_ssl_error(e):
return f'SSL/証明書エラー({getattr(e, "reason", None) or e}) {SSL_HINT}'
if isinstance(e, urllib.error.HTTPError): return f'HTTP {e.code} {e.reason}'
if isinstance(e, urllib.error.URLError): return f'接続エラー({e.reason})'
if isinstance(e, TimeoutError) or 'timed out' in str(e).lower(): return f'タイムアウト({e})'
return f'{type(e).__name__}: {e}'
def is_network_error(e):
"""HTTPで応答が返った(404など)ものと、SSLの問題を除く、通信そのものの失敗"""
if is_ssl_error(e): return False
return not isinstance(e, urllib.error.HTTPError) and isinstance(e, (urllib.error.URLError, TimeoutError, OSError))
_CTX_CACHE = {}
def make_ssl_context(insecure=False):
"""SSLコンテキストを作る。ssl.create_default_context() が証明書ストアの不具合で失敗する環境(Windows)に備え、
順に代替手段を試す。結果(失敗も)はキャッシュする。
insecure=True : 検証しないコンテキスト(システムの証明書を読まない)
通常 : 1) 標準 → 2) Windowsの証明書を1件ずつ読み、壊れた証明書だけ飛ばす → 3) certifi の証明書"""
key = bool(insecure)
if key in _CTX_CACHE:
c = _CTX_CACHE[key]
if isinstance(c, Exception): raise c
return c
try:
if insecure:
ctx = ssl._create_unverified_context()
else:
ctx = None; first = None
try:
ctx = ssl.create_default_context()
except Exception as e:
first = e
if ctx is None and hasattr(ssl, 'enum_certificates'): # Windows
try:
c2 = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT); c2.check_hostname = True; c2.verify_mode = ssl.CERT_REQUIRED
n_ok = 0
for store in ('ROOT', 'CA'):
try: certs = ssl.enum_certificates(store)
except Exception: continue
for cert, enc, trust in certs:
if enc != 'x509_asn': continue
try: c2.load_verify_locations(cadata=cert); n_ok += 1
except Exception: pass # 壊れた証明書は飛ばす
if n_ok > 0: ctx = c2
except Exception:
ctx = None
if ctx is None:
try:
import certifi
c3 = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT); c3.check_hostname = True; c3.verify_mode = ssl.CERT_REQUIRED
c3.load_verify_locations(cafile=certifi.where()); ctx = c3
except Exception:
ctx = None
if ctx is None: raise first if first is not None else ssl.SSLError('SSLコンテキストを作れません')
except Exception as e:
_CTX_CACHE[key] = e; raise
_CTX_CACHE[key] = ctx
return ctx
def http_get(url, timeout=60, insecure=False, retries=2):
last = None
for k in range(retries + 1):
try:
try: ctx = make_ssl_context(insecure)
except Exception:
if url.lower().startswith('https'): raise # httpsなのに安全な接続を作れない
ctx = None # httpなら証明書は不要
opener = urllib.request.build_opener(urllib.request.HTTPSHandler(context=ctx)) if ctx is not None \
else urllib.request.build_opener()
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0 (idx_engine)'})
with opener.open(req, timeout=timeout) as r:
return r.read()
except urllib.error.HTTPError as e:
if e.code in (400, 401, 403, 404, 410): raise # 再試行しても無駄
last = e
except Exception as e:
last = e
if is_ssl_error(e): raise # SSLの問題は再試行しても直らない
if k < retries: time.sleep(1.5 * (k + 1))
raise last
def parse_mgdsst(raw):
"""MGDSST(テキスト, 0.25度, 720行×1440列, 各3桁, 0.1℃単位, 999=陸, 888=海氷)を解析。"""
if raw[:2] == b'\x1f\x8b':
raw = gzip.decompress(raw)
elif raw[:2] == b'\x1f\x9d':
raise RuntimeError('.Z(compress)形式は未対応です。gzip形式か展開済みテキストを --sst-file で指定してください')
lines = [l for l in raw.decode('ascii', 'ignore').splitlines() if l.strip()]
if len(lines) < 721:
raise RuntimeError(f'MGDSSTの行数が想定外です: {len(lines)}')
rows = lines[1:721]
arr = np.empty((720, 1440), dtype=np.int32)
for j, l in enumerate(rows):
if len(l) < 4320: raise RuntimeError(f'MGDSSTの行長が想定外です: {len(l)}')
arr[j] = [int(l[i:i + 3]) for i in range(0, 4320, 3)]
sst = arr / 10.0
sst[arr == 999] = np.nan; sst[arr == 888] = -1.8
nvalid = int(np.isfinite(sst).sum())
if nvalid < 100000 or np.nanmax(sst) > 45 or np.nanmin(sst) < -3:
raise RuntimeError(f'MGDSSTの値が想定外です(有効格子数 {nvalid}, 範囲 {np.nanmin(sst):.1f}〜{np.nanmax(sst):.1f})')
lat = 89.875 - 0.25 * np.arange(720); lon = 0.125 + 0.25 * np.arange(1440)
lat = lat[::-1]; sst = sst[::-1, :] # 昇順に
m_lon = (lon >= SST_CROP[0]) & (lon <= SST_CROP[1]); m_lat = (lat >= SST_CROP[2]) & (lat <= SST_CROP[3])
return lon[m_lon], lat[m_lat], sst[np.ix_(m_lat, m_lon)]
def save_sst(out_dir, lon, lat, sst, date_str):
for name in (f'SST_{date_str}.npz', 'SST_latest.npz'):
tmp = os.path.join(out_dir, '~tmp_' + name)
np.savez_compressed(tmp, lon=lon, lat=lat, sst=sst, date=np.array(date_str))
os.replace(tmp if tmp.endswith('.npz') else tmp + '.npz', os.path.join(out_dir, name))
def load_sst(out_dir):
p = os.path.join(out_dir, 'SST_latest.npz')
if not os.path.exists(p): return None
z = np.load(p)
return {'lon': z['lon'], 'lat': z['lat'], 'sst': z['sst'], 'date': str(z['date'])}
LIST_RE = re.compile(r'href\s*=\s*["\']([^"\']*mgd_sst_glb_D(\d{8})[^"\']*)["\']', re.I)
def sst_list_candidates(now, insecure, errs):
"""年別フォルダの一覧(HTML)から {日付: [URL,...]} を作る。戻り値: (候補, 通信不可か, httpsが使えるか)"""
cands = {}; net_err = 0; https_ok = True
for year in (now.year, now.year - 1):
for base in SST_BASES:
if not https_ok and base.startswith('https'): continue
url_dir = f'{base}/{year}/'
try:
html = http_get(url_dir, 20, insecure, retries=0).decode('utf-8', 'ignore')
except Exception as e:
errs.append(f'{url_dir} : {describe_err(e)}')
if is_ssl_error(e): https_ok = False
elif is_network_error(e):
net_err += 1
if net_err >= 2: return cands, True, https_ok
continue
for href, d in LIST_RE.findall(html):
if 'norm' in href.lower(): continue
u = urllib.parse.urljoin(url_dir, href)
lst = cands.setdefault(d, [])
if u not in lst: lst.append(u)
if cands: break
if cands: break
return cands, False, https_ok
def sst_guess_candidates(now, https_ok=True):
"""一覧が読めない場合の保険。ファイル名を直接指定して試す"""
cands = {}
bases = [b for b in SST_BASES if https_ok or not b.startswith('https')][:2]
for k in range(0, 10):
d = now - timedelta(days=k); ds = d.strftime('%Y%m%d')
for base in bases:
for pre in ('', 're_'):
cands.setdefault(ds, []).append(f'{base}/{d.year}/{pre}mgd_sst_glb_D{ds}.txt.gz')
return cands
def fetch_latest_sst(out_dir, log, insecure=False, max_try=8):
"""最新の日別MGDSSTを取得して保存する。戻り値: (成功したか, 画面表示用メッセージ)
一覧→新しい日付から順に試す→失敗したら古い日付へ。全部失敗しても手元のSSTはそのまま使う。"""
now = datetime.utcnow() + timedelta(hours=9)
cur = load_sst(out_dir)
errs = []
cands, net_down, https_ok = sst_list_candidates(now, insecure, errs)
if not cands and not net_down:
for e in errs[:3]: log.info(f' SST一覧: {e}')
log.info('SST一覧を読めないため、ファイル名を直接指定して試します')
cands = sst_guess_candidates(now, https_ok)
if not https_ok and not insecure:
# httpsが使えず、httpでも一覧が取れなかった場合は、原因をそのまま伝える
ssl_msg = next((m for m in errs if 'SSL' in m), None)
if ssl_msg and not cands:
msg = 'SSTを取得できませんでした: ' + ssl_msg
if cur: msg += f' / 手元のSST(解析日 {cur["date"]})を引き続き使用します'
log.warning(msg); return False, msg
if net_down:
msg = 'SSTを取得できませんでした: 気象庁サーバーに接続できません(' + (errs[-1] if errs else '') + ')'
if cur: msg += f' / 手元のSST(解析日 {cur["date"]})を引き続き使用します'
log.warning(msg); return False, msg
dates = sorted(cands, reverse=True)
if cur and dates and cur['date'] >= dates[0]:
msg = f'SSTは最新です(解析日 {cur["date"]})'; log.info(msg); return True, msg
fails = []; tried = 0; net_err = 0
for d in dates:
if cur and cur['date'] >= d: break
tried += 1
if tried > max_try: break
for url in cands[d]:
try:
log.info(f'SSTを取得: {url}')
lon, lat, sst = parse_mgdsst(http_get(url, 120, insecure))
save_sst(out_dir, lon, lat, sst, d)
msg = f'SSTを取得しました(解析日 {d})'; log.info(msg); return True, msg
except Exception as e:
m = f'{os.path.basename(url)} : {describe_err(e)}'; fails.append(m); log.warning(f' 失敗 {m}')
if is_ssl_error(e): net_err = 99; break
if is_network_error(e):
net_err += 1
if net_err >= 2: break
if net_err >= 2: break
msg = 'SSTを取得できませんでした: ' + (fails[-1] if fails else '取得できる新しいファイルがありません')
if cur: msg += f' / 手元のSST(解析日 {cur["date"]})を引き続き使用します'
log.warning(msg); return False, msg
def sst_status_text(out_dir):
cur = load_sst(out_dir)
if not cur: return 'SST: なし(SSTを使う指標は計算されません)'
try:
days = ((datetime.utcnow() + timedelta(hours=9)).date() - datetime.strptime(cur['date'], '%Y%m%d').date()).days
except Exception:
days = None
t = f'SST: 解析日 {cur["date"]}' + (f'({days}日前)' if days is not None else '')
if days is not None and days >= 7: t += ' ※古いデータです'
return t
def load_sst_file(path, out_dir, log):
with open(path, 'rb') as f: raw = f.read()
m = re.search(r'(\d{8})', os.path.basename(path))
lon, lat, sst = parse_mgdsst(raw)
save_sst(out_dir, lon, lat, sst, m.group(1) if m else datetime.utcnow().strftime('%Y%m%d'))
log.info(f'SSTファイルを読み込みました: {path}')
# ---------------------------------------------------------------- 出力・統計
def write_meta(out_dir, cfg):
els = []
for e in META_ELEMENTS:
e = dict(e)
ind = cfg.get('indicators', {}).get(e['key'])
if ind:
e['danger'] = dict(ind, basis_text=BASIS_TEXT.get(ind.get('basis', ''), ''))
sk = {'SCORE_SUMMER': 'summer', 'SCORE_WINTER': 'winter'}.get(e['key'])
if sk and sk in cfg.get('scores', {}):
sc = cfg['scores'][sk]; mp = int(round(2 * sum(float(it.get('weight', 1)) for it in sc['items'])))
e['levels'] = [i + 0.5 for i in range(mp)]
e['danger'] = {'op': 'ge', 'caution': sc['judge']['caution'], 'warning': sc['judge']['warning'], 'basis': 'C',
'basis_text': BASIS_TEXT['C'], 'note': f'総合点({mp}点満点)の判定ライン'}
g = sc.get('gate')
e['desc'] = (f"{sc.get('label', sk)}の総合点。各条件を 0点(基準未満)/1点(注意)/2点(警戒) で採点して合計(最大{mp}点)。"
"内訳は『スコア内訳』タブで確認できます。"
+ (f" ゲート: {g.get('label', g['key'])}を満たす場所だけ表示します。" if g else ""))
els.append(e)
text = json.dumps({'version': 2, 'sapporo': SAPPORO, 'areas': AREAS, 'basis_text': BASIS_TEXT, 'categories': CATEGORIES, 'elements': els},
ensure_ascii=False, indent=1)
p = os.path.join(out_dir, 'IDX_META.json')
try:
with open(p, 'r', encoding='utf-8') as f:
if f.read() == text: return
except Exception:
pass
with open(p, 'w', encoding='utf-8') as f: f.write(text)
def load_thresholds(out_dir, log=None):
p = os.path.join(out_dir, 'idx_thresholds.json')
def write_default():
with open(p, 'w', encoding='utf-8') as f: json.dump(DEFAULT_CFG, f, ensure_ascii=False, indent=1)
return json.loads(json.dumps(DEFAULT_CFG))
if not os.path.exists(p): return write_default()
try:
with open(p, 'r', encoding='utf-8') as f: cfg = json.load(f)
except Exception as e:
if log: log.warning(f'idx_thresholds.json を読めません({e})。既定値を使います(ファイルは変更しません)')
return json.loads(json.dumps(DEFAULT_CFG))
if not isinstance(cfg, dict) or 'indicators' not in cfg: # 旧形式(v1)
try: os.replace(p, os.path.join(out_dir, 'idx_thresholds_old_v1.json'))
except Exception: pass
if log: log.info('旧形式の idx_thresholds.json を idx_thresholds_old_v1.json に退避し、新形式で作り直しました')
return write_default()
ver = cfg.get('version', 1)
if ver < DEFAULT_CFG['version']: # 旧版の設定は退避して、新しい既定値で作り直す(指標の構成が変わったため)
try: os.replace(p, os.path.join(out_dir, f'idx_thresholds_old_v{ver}.json'))
except Exception: pass
if log: log.info(f'旧版(v{ver})の idx_thresholds.json を idx_thresholds_old_v{ver}.json に退避し、新しい既定値で作り直しました')
return write_default()
base = json.loads(json.dumps(DEFAULT_CFG))
base['indicators'].update(cfg.get('indicators', {}))
base['scores'].update(cfg.get('scores', {}))
return base
def save_idx(out_dir, name, out, F):
data = {}
for key, (grid, arr) in out.items():
data[key] = np.array(arr) if key in ('_TABLE', '_PTVALS') else np.asarray(arr, dtype=np.float32)
if F.get('P') is not None:
data['lon'] = F['P']['lon'].astype(np.float32); data['lat'] = F['P']['lat'].astype(np.float32)
if F.get('S') is not None:
data['lon_s'] = F['S']['lon'].astype(np.float32); data['lat_s'] = F['S']['lat'].astype(np.float32)
tmp = os.path.join(out_dir, '~tmp_' + name)
np.savez_compressed(tmp, **data)
os.replace(tmp if os.path.exists(tmp) else tmp + '.npz', os.path.join(out_dir, name))
def write_stats(out_dir, source, init, ft, out, F):
path = os.path.join(out_dir, 'idx_stats.csv')
new = not os.path.exists(path)
valid = datetime.strptime(init, '%Y%m%d%H%M%S') + timedelta(hours=9 + ft)
with open(path, 'a', newline='', encoding='utf-8-sig') as f:
w = csv.writer(f)
if new: w.writerow(['init_utc', 'source', 'ft', 'valid_jst', 'area', 'key', 'max', 'mean'])
for key, (grid, arr) in out.items():
if key.startswith(('OV_', '_')): continue
G_ = F['P'] if grid == 'p' else F['S']
if G_ is None: continue
for an, (x0, x1, y0, y1) in AREAS.items():
mx = (G_['lon'] >= x0) & (G_['lon'] <= x1); my = (G_['lat'] >= y0) & (G_['lat'] <= y1)
if not mx.any() or not my.any(): continue
sub = np.asarray(arr)[np.ix_(my, mx)]
if not np.isfinite(sub).any(): continue
w.writerow([init, source, ft, valid.strftime('%Y-%m-%d %H:%M'), an, key,
f'{np.nanmax(sub):.3f}', f'{np.nanmean(sub):.3f}'])
def suggest_thresholds(out_dir, log, area='札幌近郊', months=None, q_caution=90.0, q_warning=97.5):
"""idx_stats.csv に溜まった実際の分布から、閾値の提案(上位10%/2.5%など)と、現在の閾値の超過率を出す。
ge の指標は領域最大値、le の指標は領域平均値(CSVに最小値が無いため)で評価する。"""
path = os.path.join(out_dir, 'idx_stats.csv')
if not os.path.exists(path):
log.warning('idx_stats.csv がありません。エンジンを動かしてデータを溜めてから実行してください'); return None
cfg = load_thresholds(out_dir, log)
vals = {}
with open(path, newline='', encoding='utf-8-sig') as f:
for r in csv.DictReader(f):
if r.get('area') != area: continue
try:
if months and int(r['valid_jst'][5:7]) not in months: continue
ind = cfg['indicators'].get(r['key'])
if ind is None: continue
vals.setdefault(r['key'], []).append(float(r['max'] if ind['op'] == 'ge' else r['mean']))
except Exception:
continue
mtxt = f'{",".join(str(m) for m in months)}月' if months else '全期間'
log.info(f'[閾値の提案] 領域={area} / 期間={mtxt} / 注意=上位{100 - q_caution:g}% 警戒=上位{100 - q_warning:g}% の値')
sug = {}; lacking = []
for key, ind in cfg['indicators'].items():
v = vals.get(key)
if not v or len(v) < 30:
lacking.append(key); continue
a = np.asarray(v)
if ind['op'] == 'ge':
c = float(np.percentile(a, q_caution)); w = float(np.percentile(a, q_warning))
ec = float((a >= ind['caution']).mean()); ew = float((a >= ind['warning']).mean())
else:
c = float(np.percentile(a, 100 - q_caution)); w = float(np.percentile(a, 100 - q_warning))
ec = float((a <= ind['caution']).mean()); ew = float((a <= ind['warning']).mean())
sug[key] = {'op': ind['op'], 'caution': round(c, 2), 'warning': round(w, 2), 'n': len(v),
'now_caution': ind['caution'], 'now_warning': ind['warning'],
'now_exceed_caution_pct': round(ec * 100, 1), 'now_exceed_warning_pct': round(ew * 100, 1)}
flag = ' ←常時近く超過' if ec > 0.5 else ''
log.info(f' {key:12s} n={len(v):5d} 現在 注意{ind["caution"]:g}/警戒{ind["warning"]:g}(超過率 {ec*100:.0f}%/{ew*100:.0f}%)'
f' → 提案 {c:.2f}/{w:.2f}{flag}')
if lacking: log.info(' データ不足(30件未満)で提案なし: ' + ', '.join(lacking))
outp = os.path.join(out_dir, 'idx_thresholds_suggest.json')
with open(outp, 'w', encoding='utf-8') as f:
json.dump({'area': area, 'months': months, 'q_caution': q_caution, 'q_warning': q_warning, 'suggest': sug}, f, ensure_ascii=False, indent=1)
log.info(f'提案を {outp} に保存しました(idx_thresholds.json は変更していません)')
return sug
# ---------------------------------------------------------------- メイン処理
def scan(npz_dir, models, hours, all_inits, stat=None):
now = datetime.utcnow(); items = []
st = stat if stat is not None else {}
st.update(total=0, name_ok=0, too_old=0, newest=None)
for fn in os.listdir(npz_dir):
if fn.lower().endswith('.npz'): st['total'] += 1
m = FILE_RE.match(fn)
if not m or m.group(1) not in models: continue
st['name_ok'] += 1
src, init, ft = m.group(1), m.group(2), int(m.group(3))
if st['newest'] is None or init > st['newest']: st['newest'] = init
if not all_inits:
try:
if (now - datetime.strptime(init, '%Y%m%d%H%M%S')).total_seconds() > hours * 3600:
st['too_old'] += 1; continue
except Exception: pass
items.append((src, init, ft, os.path.join(npz_dir, fn)))
items.sort(key=lambda t: (t[1], t[2]), reverse=True) # 新しい初期時刻から
return items
_last_diag = [None]
def diag(log, msg):
if msg != _last_diag[0]:
_last_diag[0] = msg; log.info(msg)
class EngineConfig:
"""エンジンの設定(GUI・コマンドライン共通)"""
def __init__(self, npz_dir, out_dir, models=('MSM', 'ANAL'), hours=12.0, all=False, force=False,
interval=60.0, sst_auto=True, sst_insecure=False, sst_file=None):
self.npz_dir = npz_dir; self.out_dir = out_dir; self.models = tuple(models); self.hours = float(hours)
self.all = bool(all); self.force = bool(force); self.interval = float(interval)
self.sst_auto = bool(sst_auto); self.sst_insecure = bool(sst_insecure); self.sst_file = sst_file
def run_cycle(cfg, sst, thr, log, force=False, stop=None, status=None):
n_done = 0; n_exist = 0; n_empty = 0; n_fail = 0
stat = {}
if not os.path.isdir(cfg.npz_dir):
diag(log, f'[診断] 入力フォルダが存在しません: {cfg.npz_dir}'); return 0
items = scan(cfg.npz_dir, cfg.models, cfg.hours, cfg.all, stat)
for src, init, ft, path in items:
if stop is not None and stop.is_set(): break
name = f'IDX_{src}_{init}_FT{ft:02d}.npz'
dst = os.path.join(cfg.out_dir, name)
if os.path.exists(dst) and not force: n_exist += 1; continue
if time.time() - os.path.getmtime(path) < 2: continue
try:
F = load_fields(path)
if F.get('P') is None and F.get('S') is None:
keys = sorted(np.load(path).files)[:20]
log.warning(f' 格子を認識できません {os.path.basename(path)} keys={keys}'); n_empty += 1; continue
out = compute_indices(F, sst, thr, log)
if not any(not k.startswith(('OV_', '_')) for k in out):
log.warning(f' 計算できた指標が0件です {os.path.basename(path)} '
f'(気圧面={"あり" if F.get("P") else "なし"}, 地上={"あり" if F.get("S") else "なし"})')
n_empty += 1; continue
save_idx(cfg.out_dir, name, out, F)
write_stats(cfg.out_dir, src, init, ft, out, F)
n_done += 1
log.info(f'出力: {name} ({len([k for k in out if not k.startswith(("OV_", "_"))])}要素)')
except Exception as e:
n_fail += 1
log.warning(f'処理失敗 {os.path.basename(path)}: {e}')
new_txt = (f'{stat["newest"][:8]} {stat["newest"][8:12]}UTC' if stat.get('newest') else 'なし')
rng = '全期間' if cfg.all else f'{cfg.hours:g}時間以内'
msg = (f'[診断] npz総数={stat.get("total")} / 対象モデル名一致={stat.get("name_ok")} / '
f'期間外({rng}の外)={stat.get("too_old")} / 処理対象={len(items)} / 出力済み={n_exist} / '
f'今回出力={n_done} / 失敗={n_fail} / 空={n_empty} / 最新の初期時刻={new_txt}'
+ ('' if stat.get('name_ok') else ' → 名前が MSM_YYYYMMDDHHMMSS_FTnn.npz 形式のファイルが見つかりません')
+ (' → すべて期間外です。「すべてのデータ」を選んでください' if stat.get('name_ok') and stat.get('too_old') == stat.get('name_ok') else ''))
diag(log, msg)
if status is not None:
status['summary'] = msg; status['last_cycle'] = time.time()
if n_done: status['last_output'] = time.time()
return n_done
def engine_loop(cfg, log, stop, status=None, once=False, sst_now=None):
"""メインループ。stop(Event)がセットされると終了する。sst_now(Event)がセットされるとSSTを今すぐ取得する。"""
status = status if status is not None else {}
_last_diag[0] = None
os.makedirs(cfg.out_dir, exist_ok=True)
thr = load_thresholds(cfg.out_dir, log); write_meta(cfg.out_dir, thr)
log.info(f'入力: {cfg.npz_dir} / 出力: {cfg.out_dir} / 対象: {",".join(cfg.models)} / '
f'範囲: {"すべてのデータ" if cfg.all else f"{cfg.hours:g}時間以内"}'
+ (' / 既存IDXも最初に1回だけ再計算' if cfg.force else ''))
if cfg.sst_file:
try: load_sst_file(cfg.sst_file, cfg.out_dir, log); status.update(sst_msg=f'SSTファイルを読み込みました: {cfg.sst_file}', sst_ok=True)
except Exception as e: log.warning(f'SSTファイル読み込み失敗: {e}'); status.update(sst_msg=f'SSTファイル読み込み失敗: {e}', sst_ok=False)
status['running'] = True
force = cfg.force
next_sst = 0.0
while not stop.is_set():
want = sst_now is not None and sst_now.is_set()
if want or (cfg.sst_auto and time.time() >= next_sst):
if sst_now is not None: sst_now.clear()
try: ok, msg = fetch_latest_sst(cfg.out_dir, log, cfg.sst_insecure)
except Exception as e: ok, msg = False, f'SST取得で予期しないエラー: {describe_err(e)}'; log.warning(msg)
status.update(sst_msg=msg, sst_ok=ok, sst_time=time.time())
next_sst = time.time() + (3 * 3600 if ok else 15 * 60) # 失敗時は15分後に再試行
sst = load_sst(cfg.out_dir)
if sst is None: diag(log, 'SSTなし: SSTを使う指標(DT850,DT500,吹走距離,海面フラックス等)はスキップします')
thr = load_thresholds(cfg.out_dir, log); write_meta(cfg.out_dir, thr)
try:
n = run_cycle(cfg, sst, thr, log, force, stop, status)
except Exception as e:
log.warning(f'サイクル失敗: {e}'); n = 0
force = False # 強制再計算は最初の1周だけ(毎周やり直さない)
if once: break
stop.wait(cfg.interval if n == 0 else 1.0)
status['running'] = False
# ---------------------------------------------------------------- GUI
def run_gui(args):
from PyQt6.QtWidgets import (QApplication, QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QLabel, QLineEdit,
QPushButton, QRadioButton, QCheckBox, QPlainTextEdit, QFileDialog, QGroupBox, QMessageBox)
from PyQt6.QtCore import QObject, pyqtSignal, QTimer, QSettings
class Bridge(QObject):
log = pyqtSignal(str)
class QtHandler(logging.Handler):
def __init__(self, bridge):
super().__init__(); self.bridge = bridge
def emit(self, record):
try: self.bridge.log.emit(self.format(record))
except Exception: pass
class Win(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle('特殊指数エンジン (idx_engine)'); self.resize(900, 720)
self.settings = QSettings('GPVTools', 'IdxEngine')
self.thread = None; self.stop = threading.Event(); self.sst_now = threading.Event(); self.status = {}
self.file_handler = None; self._sst_cache = (None, '')
self.bridge = Bridge(); self.bridge.log.connect(self.append_log)
self.log = logging.getLogger('idx'); self.log.setLevel(logging.INFO)
self.qh = QtHandler(self.bridge); self.qh.setFormatter(logging.Formatter('%(asctime)s %(message)s', '%H:%M:%S'))
self.log.addHandler(self.qh)
def pick(name, cli, default):
return cli if cli else str(self.settings.value(name, default))
def pbool(name, default):
v = self.settings.value(name, default)
return v if isinstance(v, bool) else str(v).lower() in ('true', '1')
lay = QVBoxLayout(self)
g = QGroupBox('フォルダ'); gl = QGridLayout(g)
self.ed_npz = QLineEdit(pick('npz_dir', args.npz_dir, '')); self.ed_out = QLineEdit(pick('out_dir', args.out_dir, ''))
b1 = QPushButton('参照…'); b2 = QPushButton('参照…')
gl.addWidget(QLabel('NPZフォルダ(本体エンジンが出力したGPVのnpz)'), 0, 0); gl.addWidget(self.ed_npz, 0, 1); gl.addWidget(b1, 0, 2)
gl.addWidget(QLabel('出力先フォルダ(IDX・SST・設定ファイル)'), 1, 0); gl.addWidget(self.ed_out, 1, 1); gl.addWidget(b2, 1, 2)
gl.setColumnStretch(1, 1)
b1.clicked.connect(lambda: self.browse(self.ed_npz)); b2.clicked.connect(lambda: self.browse(self.ed_out))
lay.addWidget(g)
g2 = QGroupBox('処理するデータの範囲(初期時刻が基準)'); hl = QHBoxLayout(g2)
self.rb_12 = QRadioButton('12時間以内のデータ'); self.rb_all = QRadioButton('すべてのデータ(件数が多いと時間がかかります)')
use_all = args.all or pbool('use_all', False)
self.rb_all.setChecked(use_all); self.rb_12.setChecked(not use_all)
hl.addWidget(self.rb_12); hl.addWidget(self.rb_all); lay.addWidget(g2)
g3 = QGroupBox('対象'); h3 = QHBoxLayout(g3)
models = args.models.split(',') if args.models else None
self.chk_msm = QCheckBox('MSM'); self.chk_msm.setChecked(('MSM' in models) if models else pbool('m_msm', True))
self.chk_anal = QCheckBox('ANAL'); self.chk_anal.setChecked(('ANAL' in models) if models else pbool('m_anal', True))
self.chk_force = QCheckBox('開始時に、作成済みのIDXも作り直す(最初の1回だけ。閾値を変えた時など)')
self.chk_force.setChecked(bool(args.force))
h3.addWidget(self.chk_msm); h3.addWidget(self.chk_anal); h3.addWidget(self.chk_force, 1); lay.addWidget(g3)
g4 = QGroupBox('SST(海面水温)'); v4 = QVBoxLayout(g4)
self.lab_sst = QLabel(''); self.lab_sst.setWordWrap(True)
row = QHBoxLayout()
self.chk_auto = QCheckBox('SSTを自動で取得する(気象庁MGDSST)'); self.chk_auto.setChecked(not args.no_sst_download and pbool('sst_auto', True))
self.chk_insecure = QCheckBox('SSL証明書の検証を行わない(社内ネットワーク等で取得に失敗する場合のみ)')
self.chk_insecure.setChecked(bool(args.sst_insecure) or pbool('sst_insecure', False))
row.addWidget(self.chk_auto); row.addWidget(self.chk_insecure, 1)
row2 = QHBoxLayout(); self.btn_sst = QPushButton('SSTを今すぐ取得'); self.btn_sstfile = QPushButton('SSTファイルを指定…')
row2.addWidget(self.btn_sst); row2.addWidget(self.btn_sstfile); row2.addStretch(1)
v4.addWidget(self.lab_sst); v4.addLayout(row); v4.addLayout(row2); lay.addWidget(g4)
self.btn_sst.clicked.connect(self.sst_now_clicked); self.btn_sstfile.clicked.connect(self.sst_file_clicked)
hb = QHBoxLayout()
self.btn_start = QPushButton('開始'); self.btn_stop = QPushButton('停止'); self.btn_stop.setEnabled(False)
self.lab_state = QLabel('停止中'); self.lab_state.setStyleSheet('font-weight:bold;')
hb.addWidget(self.btn_start); hb.addWidget(self.btn_stop); hb.addWidget(self.lab_state, 1); lay.addLayout(hb)
self.lab_sum = QLabel(''); self.lab_sum.setWordWrap(True); lay.addWidget(self.lab_sum)
self.txt = QPlainTextEdit(); self.txt.setReadOnly(True); self.txt.setMaximumBlockCount(3000); lay.addWidget(self.txt, 1)
self.btn_start.clicked.connect(self.start); self.btn_stop.clicked.connect(self.stop_clicked)
self.timer = QTimer(self); self.timer.timeout.connect(self.refresh); self.timer.start(1000)
self.refresh()
# ---- 操作
def append_log(self, s):
self.txt.appendPlainText(s)
def browse(self, edit):
d = QFileDialog.getExistingDirectory(self, 'フォルダを選択', edit.text() or os.getcwd())
if d: edit.setText(d)
def warn(self, msg):
QMessageBox.warning(self, '確認', msg)
def running(self):
return self.thread is not None and self.thread.is_alive()
def save_settings(self):
st = self.settings
st.setValue('npz_dir', self.ed_npz.text()); st.setValue('out_dir', self.ed_out.text())
st.setValue('use_all', self.rb_all.isChecked()); st.setValue('m_msm', self.chk_msm.isChecked())
st.setValue('m_anal', self.chk_anal.isChecked()); st.setValue('sst_auto', self.chk_auto.isChecked())
st.setValue('sst_insecure', self.chk_insecure.isChecked())
def start(self):
if self.running(): return
npz = self.ed_npz.text().strip(); out = self.ed_out.text().strip()
if not npz or not os.path.isdir(npz): self.warn('NPZフォルダが存在しません。「参照…」で選んでください。'); return
if not out: self.warn('出力先フォルダを指定してください。'); return
try: os.makedirs(out, exist_ok=True)
except Exception as e: self.warn(f'出力先フォルダを作成できません: {e}'); return
models = tuple(m for m, c in (('MSM', self.chk_msm), ('ANAL', self.chk_anal)) if c.isChecked())
if not models: self.warn('MSM か ANAL の少なくとも一方を選んでください。'); return
cfg = EngineConfig(npz, out, models, hours=12.0, all=self.rb_all.isChecked(), force=self.chk_force.isChecked(),
interval=60.0, sst_auto=self.chk_auto.isChecked(), sst_insecure=self.chk_insecure.isChecked())
self.save_settings()
self.attach_file_log(out)
self.stop.clear(); self.status = {}
self.thread = threading.Thread(target=self._run, args=(cfg,), daemon=True); self.thread.start()
self.refresh()
def _run(self, cfg):
try: engine_loop(cfg, self.log, self.stop, self.status, False, self.sst_now)
except Exception as e: self.log.warning(f'エンジンが異常終了しました: {e}')
finally: self.status['running'] = False
def stop_clicked(self):
self.stop.set(); self.btn_stop.setEnabled(False); self.lab_state.setText('停止しています…')
def attach_file_log(self, out):
try:
if self.file_handler is not None: self.log.removeHandler(self.file_handler); self.file_handler.close()
self.file_handler = logging.FileHandler(os.path.join(out, 'idx_engine.log'), encoding='utf-8')
self.file_handler.setFormatter(logging.Formatter('%(asctime)s %(message)s', '%H:%M:%S'))
self.log.addHandler(self.file_handler)
except Exception:
self.file_handler = None
def sst_now_clicked(self):
out = self.ed_out.text().strip()
if not out: self.warn('先に出力先フォルダを指定してください。'); return
if self.running():
self.sst_now.set(); self.log.info('SSTの取得を要求しました(現在の処理が終わり次第、実行します)'); return
try: os.makedirs(out, exist_ok=True)
except Exception as e: self.warn(f'出力先フォルダを作成できません: {e}'); return
insecure = self.chk_insecure.isChecked()
def work():
try: ok, msg = fetch_latest_sst(out, self.log, insecure)
except Exception as e: ok, msg = False, f'SST取得で予期しないエラー: {describe_err(e)}'; self.log.warning(msg)
self.status.update(sst_msg=msg, sst_ok=ok, sst_time=time.time())
threading.Thread(target=work, daemon=True).start()
def sst_file_clicked(self):
out = self.ed_out.text().strip()
if not out: self.warn('先に出力先フォルダを指定してください。'); return
path, _ = QFileDialog.getOpenFileName(self, 'MGDSSTファイルを選択(mgd_sst_glb_Dyyyymmdd.txt.gz など)', os.getcwd(),
'MGDSST (*.txt *.gz);;すべてのファイル (*)')
if not path: return
def work():
try:
os.makedirs(out, exist_ok=True); load_sst_file(path, out, self.log)
self.status.update(sst_msg=f'SSTファイルを読み込みました: {os.path.basename(path)}', sst_ok=True, sst_time=time.time())
except Exception as e:
self.log.warning(f'SSTファイル読み込み失敗: {e}')
self.status.update(sst_msg=f'SSTファイル読み込み失敗: {e}', sst_ok=False, sst_time=time.time())
threading.Thread(target=work, daemon=True).start()
# ---- 表示更新(1秒ごと)
def refresh(self):
run = self.running()
if not run and self.btn_stop.isEnabled() is False and self.lab_state.text() == '停止しています…':
self.lab_state.setText('停止中')
for w in (self.ed_npz, self.ed_out, self.rb_12, self.rb_all, self.chk_msm, self.chk_anal, self.chk_force):
w.setEnabled(not run)
self.btn_start.setEnabled(not run)
if run and self.lab_state.text() != '停止しています…':
self.btn_stop.setEnabled(True); self.lab_state.setText('稼働中(60秒ごとに新しいnpzを確認)')
elif not run:
self.btn_stop.setEnabled(False)
if self.lab_state.text() != '停止中': self.lab_state.setText('停止中')
out = self.ed_out.text().strip()
sst_line = ''
if out and os.path.isdir(out):
p = os.path.join(out, 'SST_latest.npz')
try: mt = os.path.getmtime(p)
except Exception: mt = None
if self._sst_cache[0] != (out, mt): self._sst_cache = ((out, mt), sst_status_text(out))
sst_line = self._sst_cache[1]
else:
sst_line = 'SST: 出力先フォルダを指定すると状態が表示されます'
msg = self.status.get('sst_msg')
if msg:
ts = time.strftime('%m/%d %H:%M', time.localtime(self.status.get('sst_time', time.time())))
sst_line += f'\n最後の取得結果({ts}): {msg}'
self.lab_sst.setText(sst_line)
self.lab_sum.setText(self.status.get('summary', ''))
def closeEvent(self, ev):
self.stop.set()
if self.thread is not None: self.thread.join(3)
super().closeEvent(ev)
app = QApplication(sys.argv)
w = Win(); w.show()
if args.autostart: QTimer.singleShot(300, w.start)
sys.exit(app.exec())
# ---------------------------------------------------------------- コマンドライン
def build_parser():
ap = argparse.ArgumentParser(description='札幌向け 特殊指数 補助エンジン(既定はGUI。--cli でコマンドライン実行)')
ap.add_argument('--npz-dir', default=None); ap.add_argument('--out-dir', default=None)
ap.add_argument('--models', default=None, help='例: MSM,ANAL')
ap.add_argument('--hours', type=float, default=12.0, help='この時間以内の初期時刻だけ処理(既定12)')
ap.add_argument('--all', action='store_true', help='時間制限なしで全初期時刻を処理')
ap.add_argument('--interval', type=float, default=60.0)
ap.add_argument('--once', action='store_true'); ap.add_argument('--force', action='store_true', help='作成済みIDXも最初の1回だけ作り直す')
ap.add_argument('--sst-file', default=None); ap.add_argument('--no-sst-download', action='store_true')
ap.add_argument('--sst-insecure', action='store_true', help='SSL証明書を検証しない(社内ネットワーク用)')
ap.add_argument('--cli', action='store_true', help='GUIを出さずに実行する')
ap.add_argument('--autostart', action='store_true', help='GUI起動と同時に処理を開始する')
ap.add_argument('--suggest-thresholds', action='store_true', help='idx_stats.csv から閾値の提案を作る(--out-dir 必須)')
ap.add_argument('--months', default=None, help='提案に使う月(例: 6,7,8,9)')
ap.add_argument('--area', default='札幌近郊', help='提案に使う領域名')
return ap
def main():
args = build_parser().parse_args()
if args.suggest_thresholds:
if not args.out_dir: print('--out-dir を指定してください'); return
log = logging.getLogger('idx'); log.setLevel(logging.INFO)
try: sys.stdout.reconfigure(encoding='utf-8', errors='replace')
except Exception: pass
log.addHandler(logging.StreamHandler(sys.stdout))
months = [int(m) for m in args.months.split(',')] if args.months else None
suggest_thresholds(args.out_dir, log, area=args.area, months=months); return
if not args.cli:
try:
import PyQt6 # noqa: F401
except ImportError:
print('PyQt6 が見つかりません。コマンドライン(--cli)で実行します。')
args.cli = True
if not args.cli:
run_gui(args); return
npz_dir = args.npz_dir or os.path.join(os.getcwd(), 'gpv_cache_npz')
out_dir = args.out_dir or os.path.join(os.getcwd(), 'idx_cache_npz')
models = tuple(m.strip() for m in (args.models or 'MSM,ANAL').split(',') if m.strip())
os.makedirs(out_dir, exist_ok=True)
log = logging.getLogger('idx'); log.setLevel(logging.INFO)
fmt = logging.Formatter('%(asctime)s %(message)s', '%H:%M:%S')
try: sys.stdout.reconfigure(encoding='utf-8', errors='replace')
except Exception: pass
sh = logging.StreamHandler(sys.stdout); sh.setFormatter(fmt); log.addHandler(sh)
fh = logging.FileHandler(os.path.join(out_dir, 'idx_engine.log'), encoding='utf-8'); fh.setFormatter(fmt); log.addHandler(fh)
cfg = EngineConfig(npz_dir, out_dir, models, hours=args.hours, all=args.all, force=args.force, interval=args.interval,
sst_auto=not args.no_sst_download, sst_insecure=args.sst_insecure, sst_file=args.sst_file)
stop = threading.Event()
try:
engine_loop(cfg, log, stop, {}, once=args.once)
except KeyboardInterrupt:
stop.set(); log.info('停止しました')
if __name__ == '__main__':
main()