import os
import glob
import tkinter as tk
from tkinter import filedialog, ttk, messagebox
from datetime import datetime, timedelta
"""
=========================================================================================
【次世代AIへの引き継ぎ用・完全仕様ドキュメント】
気象庁 AMeDAS BUFR (TM 307080 SYNOPテンプレート) ピュアPython・ダイレクトデコーダー
■ 背景と経緯(なぜこのコードになったのか)
当初、`eccodes` や `pybufrkit` などの既存ライブラリでのパースを試みたが、以下の理由により
ライブラリ内部でクラッシュ(13 bits不足エラー等)が多発し、解読不能に陥った。
1. 気象庁のファイルはマスター表バージョン33へ更新されており、一部の放射資料等のビット幅が
旧規格(12bit等)から可変(17bit, 20bit)に変更されていた。
2. 日本独自のローカル記述子(0 01 202, 0 25 219等)が多数含まれており、ライブラリの自動長計算と
実際のビット幅(5bitや1bit等)が矛盾し、データ終端で「ビットが足りない」と誤認自爆していた。
3. セクション1の4バイト目が本来規格の `03` ではなく `00` と設定されているなど、イレギュラーが存在した。
■ 最終解決アプローチ(ブラックボックスの排除)
ライブラリを完全に捨て、Python標準機能のみでバイナリを直接スライス(HEX透視)する手法を採用。
その結果、以下の決定的な法則と構造を確定させた。
■ 確定したバイナリ構造仕様
- セクション0: 0〜7バイト目 (8 bytes)
- セクション1: 8〜29バイト目 (22 bytes) ※観測日時(UTC)が格納されている
- セクション3: 30〜236バイト目 (207 bytes) ※観測所数(1286地点)が5-6バイト目に格納
- セクション4: 237バイト目から開始 (データ本体は 241バイト目 (0xF1) からスタート)
■ データブロックの法則(最重要)
- 1地点あたりの全データ要素は、正確に【 1029ビット 】の固定長でパッキングされている。
- 時刻ロジックは気象庁仕様に基づきすべて【 UTC (協定世界時) 】で記録されている。
※ 表示時に JST (UTC+9) に変換する内部ロジックを強制適用すること。
■ 既知の観測所ID (一部)
- 14163 : 札幌
- 12411 : 旭川
- 44132 : 東京
=========================================================================================
"""
# 主要な観測所名マッピング(GUI表示用)
KNOWN_STATIONS = {
"14163": "札幌 (Sapporo)",
"12411": "旭川 (Asahikawa)",
"18461": "函館 (Hakodate)",
"11211": "稚内 (Wakkanai)",
"11711": "網走 (Abashiri)",
"17321": "倶知安 (Kutchan)",
"44132": "東京 (Tokyo)"
}
class BitReader:
""" バイナリデータから任意のビット数を正確に読み取るための専用クラス """
def __init__(self, data, bit_offset=0):
self.data = data
self.bit_offset = bit_offset
def read(self, bits):
val = 0
for _ in range(bits):
byte_idx = self.bit_offset // 8
bit_idx = 7 - (self.bit_offset % 8)
# 指定ビット位置の1ビットを抽出
bit = (self.data[byte_idx] >> bit_idx) & 1
val = (val << 1) | bit
self.bit_offset += 1
return val
class AmedasDecoderApp:
def __init__(self, root):
self.root = root
self.root.title("AMeDAS BUFR Direct Viewer (Pure Python)")
self.root.geometry("700x600")
# データ保持用
self.target_dir = ""
self.bufr_files = {} # { "YYYY/MM/DD HH:MM (JST)": "file_path" }
self.current_stations = {} # { "14163 札幌": { 抽出データ辞書 } }
self.create_widgets()
def create_widgets(self):
# --- フォルダ選択領域 ---
frame_top = tk.Frame(self.root, pady=10)
frame_top.pack(fill=tk.X, padx=10)
self.btn_dir = tk.Button(frame_top, text="BUFRフォルダを選択", command=self.select_directory)
self.btn_dir.pack(side=tk.LEFT)
self.lbl_dir = tk.Label(frame_top, text="未選択", fg="blue")
self.lbl_dir.pack(side=tk.LEFT, padx=10)
# --- コントロール領域 ---
frame_ctrl = tk.LabelFrame(self.root, text="抽出条件", pady=10, padx=10)
frame_ctrl.pack(fill=tk.X, padx=10, pady=5)
tk.Label(frame_ctrl, text="観測時間 (JST):").grid(row=0, column=0, sticky=tk.W)
self.cmb_time = ttk.Combobox(frame_ctrl, width=25, state="readonly")
self.cmb_time.grid(row=0, column=1, padx=5)
self.cmb_time.bind("<<ComboboxSelected>>", self.on_time_selected)
tk.Label(frame_ctrl, text="地点選択:").grid(row=0, column=2, sticky=tk.W, padx=(15, 0))
self.cmb_station = ttk.Combobox(frame_ctrl, width=30, state="readonly")
self.cmb_station.grid(row=0, column=3, padx=5)
self.cmb_station.bind("<<ComboboxSelected>>", self.on_station_selected)
# --- データ表示領域 ---
frame_data = tk.LabelFrame(self.root, text="気象要素 (デコード結果)", pady=10, padx=10)
frame_data.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
self.txt_display = tk.Text(frame_data, wrap=tk.WORD, font=("Consolas", 11))
self.txt_display.pack(fill=tk.BOTH, expand=True)
def select_directory(self):
""" フォルダを選択し、中の .bin ファイルから観測時刻を読み取る """
dir_path = filedialog.askdirectory()
if not dir_path:
return
self.target_dir = dir_path
self.lbl_dir.config(text=self.target_dir)
self.bufr_files.clear()
# 拡張子が .bin のファイルを検索
file_paths = glob.glob(os.path.join(self.target_dir, "*.bin"))
if not file_paths:
messagebox.showwarning("警告", "選択したフォルダに .bin ファイルが見つかりません。")
return
for path in file_paths:
time_jst_str = self.extract_time_from_header(path)
if time_jst_str:
self.bufr_files[time_jst_str] = path
# 時間の降順(新しい順)で並び替えてComboboxにセット
sorted_times = sorted(self.bufr_files.keys(), reverse=True)
self.cmb_time['values'] = sorted_times
if sorted_times:
self.cmb_time.current(0)
self.on_time_selected(None) # 初期選択状態のデータをデコード
def extract_time_from_header(self, file_path):
"""
ファイルのセクション1を直接透視し、UTC時刻をJSTに変換して返す
※ライブラリのパースエラーを避けるため、絶対的なバイト位置から直接読み取る
"""
try:
with open(file_path, 'rb') as f:
data = f.read(50) # ヘッダーだけ読めば十分
# BUFRシグネチャ確認
if data[0:4] != b'BUFR':
return None
# セクション1の時刻情報は、ファイル先頭から 20バイト目〜24バイト目に格納 (年,月,日,時,分)
year_century = data[20]
month = data[21]
day = data[22]
hour = data[23]
minute = data[24]
# 世紀の計算 (100=2000年, 1=2001年以降) 気象庁特有の記述
year = 2000 + year_century if year_century < 100 else 1900 + year_century
if year_century == 100: year = 2000
if year_century > 100: year = 1900 + year_century # ※要仕様確認、通常はそのまま適用可
# 直近の年(2026等)は単純に 2000 + year_century ではない場合があるため、
# HEXダンプの解析通り、単純に2バイト読みたい所だが規格上は1バイト。
# HEXダンプ結果: 07 EA (2026) はセクション1ではなく、セクション3のローカル定義だった。
# 安全のため、ここではファイル名からの抽出ではなく、バイナリのセクション1の絶対位置から生成。
# ※HEXダンプの解析から: 13, 14, 15, 16, 17 オクテット目(0-indexedで12〜16)がUTC時刻
# オフセット12 (0x0C): 01 (年? バージョン?)
# より確実な20バイト目以降を使用 (20:年, 21:月, 22:日, 23:時, 24:分)
# HEXダンプ (0010行目): 14バイト目付近 -> 01 07 EA 03 0B 0F 00 00
# 07 EA = 2026, 03 = 3月, 0B = 11日, 0F = 15時。これは14バイト目から始まっている!
# バージョン33等による可変長を考慮し、この関数では簡易的にファイルパスか固定位置を使う。
# 【絶対安全策】HEXダンプ解析で確定した絶対位置 (14〜19バイト目) を使用
year = int.from_bytes(data[14:16], 'big')
month = data[16]
day = data[17]
hour = data[18]
minute = data[19]
# UTCとして解釈し、JST (+9時間) に変換
utc_time = datetime(year, month, day, hour, minute)
jst_time = utc_time + timedelta(hours=9)
return jst_time.strftime("%Y/%m/%d %H:%M")
except Exception as e:
print(f"Header Parse Error: {e}")
return None
def on_time_selected(self, event):
""" 抽出時間が選択されたら、そのファイルをフルデコードして地点一覧を生成する """
selected_time = self.cmb_time.get()
file_path = self.bufr_files.get(selected_time)
if not file_path: return
self.current_stations.clear()
self.txt_display.delete(1.0, tk.END)
self.txt_display.insert(tk.END, f"[{selected_time}] のデータをバイナリから直接展開中...\n")
self.root.update()
try:
self.decode_bufr_file(file_path)
# 抽出した地点IDリストをComboboxにセット
station_list = sorted(self.current_stations.keys())
self.cmb_station['values'] = station_list
self.txt_display.insert(tk.END, f"展開完了: {len(station_list)} 地点のデータを抽出しました。\n地点を選択してください。")
if station_list:
# 札幌 (14163) があれば優先的に選択、なければ先頭を選択
sapporo_key = next((k for k in station_list if "14163" in k), None)
if sapporo_key:
self.cmb_station.set(sapporo_key)
else:
self.cmb_station.current(0)
self.on_station_selected(None)
except Exception as e:
self.txt_display.insert(tk.END, f"\nエラーが発生しました: {e}")
def decode_bufr_file(self, file_path):
"""
【核心部】 生バイナリから1286地点×1029ビットのデータをピュアPythonで完全に切り出す
"""
with open(file_path, 'rb') as f:
raw_data = f.read()
# データ領域 (第4節) はバイナリの 241バイト目 (インデックス 241) からスタート
# 241 bytes * 8 = 1928 bits
reader = BitReader(raw_data, 241 * 8)
# サブセット数 (通常1286)
# セクション3の5-6バイト目 (オフセット34-35) にあるが、ここではファイル終端までループする安全設計とする
TOTAL_STATIONS = 1286
BITS_PER_STATION = 1029 # 過去の検証で確定した1地点あたりの固定データ長
for _ in range(TOTAL_STATIONS):
# データの終端チェック(万が一のオーバーラン防止)
if (reader.bit_offset + BITS_PER_STATION) > len(raw_data) * 8:
break
# ブロック開始時のビット位置を記録
start_bit = reader.bit_offset
# ---------------------------------------------------------
# 1. 識別情報
# ---------------------------------------------------------
_ = reader.read(5) # 未知のローカル 5 bits (0 01 202 等)
block_no = reader.read(7) # 府県表示番号 7 bits
station_no = reader.read(10) # 地点表示番号 10 bits
st_type = reader.read(2) # 観測所の種類 2 bits
st_id = f"{block_no:02d}{station_no:03d}"
st_name = KNOWN_STATIONS.get(st_id, "名称不明")
display_key = f"{st_id} {st_name}"
# ---------------------------------------------------------
# 2. 観測日時 (UTC)
# ---------------------------------------------------------
year = reader.read(12)
month = reader.read(4)
day = reader.read(6)
hour = reader.read(5)
minute = reader.read(6)
# ---------------------------------------------------------
# 3. 位置情報
# ---------------------------------------------------------
lat_raw = reader.read(25)
lon_raw = reader.read(26)
height_raw = reader.read(17)
lat = (lat_raw - 9000000) / 100000.0 if lat_raw != 0x1FFFFFF else None
lon = (lon_raw - 18000000) / 100000.0 if lon_raw != 0x3FFFFFF else None
height = (height_raw - 4000) / 10.0 if height_raw != 0x1FFFF else None
# ---------------------------------------------------------
# 4. 気圧センサー情報
# ---------------------------------------------------------
_ = reader.read(1) # 未知のローカル 1 bit
baro_h_raw = reader.read(17)
press_aqc = reader.read(4)
press_raw = reader.read(14)
slp_aqc = reader.read(4)
slp_raw = reader.read(14)
baro_h = baro_h_raw / 100.0 if baro_h_raw != 0x1FFFF else None
# 気圧 (Pa -> hPa) 尺度 -1, 参照値 0
press = press_raw / 10.0 if press_raw != 0x3FFF else None
slp = slp_raw / 10.0 if slp_raw != 0x3FFF else None
# ---------------------------------------------------------
# 5. 気温情報
# ---------------------------------------------------------
temp_h_raw = reader.read(16)
temp_aqc = reader.read(4)
temp_raw = reader.read(12)
temp_h = temp_h_raw / 100.0 if temp_h_raw != 0xFFFF else None
# ケルビンから摂氏へ変換 (尺度1 -> x10, 欠測 4095)
temp_c = (temp_raw / 10.0) - 273.15 if temp_raw != 0xFFF else None
# ---------------------------------------------------------
# ★ 1地点分の読み取り完了処理 ★
# ---------------------------------------------------------
# 抽出したデータを辞書に保存
self.current_stations[display_key] = {
"id": st_id,
"utc_time": f"{year:04d}-{month:02d}-{day:02d} {hour:02d}:{minute:02d}",
"lat": lat,
"lon": lon,
"height": height,
"temp_c": temp_c,
"temp_h": temp_h,
"press_hpa": press,
"slp_hpa": slp
}
# まだ解読していない風向風速・降水量などの残りビットを安全にスキップ
# (1地点 1029ビットの固定長を利用して、次の地点の先頭へ確実にジャンプする)
bits_read = reader.bit_offset - start_bit
bits_remaining = BITS_PER_STATION - bits_read
if bits_remaining > 0:
_ = reader.read(bits_remaining)
elif bits_remaining < 0:
raise ValueError(f"ビットオーバーラン発生: {bits_read} bits読込 (規定 {BITS_PER_STATION})")
def on_station_selected(self, event):
""" 地点が選択されたら詳細データをテキストエリアに整形して表示 """
selected_key = self.cmb_station.get()
data = self.current_stations.get(selected_key)
if not data: return
self.txt_display.delete(1.0, tk.END)
# 書式設定して出力
out_text = f"==========================================\n"
out_text += f" 観測所ID : {data['id']}\n"
out_text += f" 観測所名 : {selected_key.split(' ')[1]}\n"
out_text += f" 観測日時 : {data['utc_time']} (UTC 記録値)\n"
out_text += f"==========================================\n\n"
out_text += f" [位置情報]\n"
out_text += f" 緯度 : 北緯 {data['lat']:.4f} 度\n" if data['lat'] else " 緯度 : 欠測\n"
out_text += f" 経度 : 東経 {data['lon']:.4f} 度\n" if data['lon'] else " 経度 : 欠測\n"
out_text += f" 露場標高 : {data['height']:.1f} m\n\n" if data['height'] else " 露場標高 : 欠測\n\n"
out_text += f" [気象要素]\n"
out_text += f" 気温 : {data['temp_c']:.1f} ℃\n" if data['temp_c'] is not None else " 気温 : 欠測\n"
out_text += f" 温度計高 : {data['temp_h']:.1f} m\n\n" if data['temp_h'] else " 温度計高 : 欠測\n\n"
out_text += f" 現地気圧 : {data['press_hpa']:.1f} hPa\n" if data['press_hpa'] is not None else " 現地気圧 : 欠測\n"
out_text += f" 海面気圧 : {data['slp_hpa']:.1f} hPa\n" if data['slp_hpa'] is not None else " 海面気圧 : 欠測\n"
out_text += f"\n------------------------------------------\n"
out_text += f" ※残りの要素(風向・風速・降水量・積雪等)は\n"
out_text += f" 1029ビットのブロック内に存在しています。\n"
out_text += f" 将来の拡張にて追加デコード可能です。\n"
self.txt_display.insert(tk.END, out_text)
if __name__ == "__main__":
root = tk.Tk()
app = AmedasDecoderApp(root)
root.mainloop()