feat: add WeatherV2 wind canonical converter

This commit is contained in:
OpenAI Codex
2026-09-04 21:08:07 +08:00
parent 5fc922785d
commit 79d4aa6385
17 changed files with 965 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
# WeatherV2 风场转 MapLibre canonical 测试数据工具
此工具只用于开发期离线转换:将 WeatherV2 的完整 `wind_vectors.bin`(`WVECBIN1`)转成 STEP-053 已冻结的 `maplibre-particle-field/v1`,不属于 MapLibre Runtime,也不访问网络、不扫描下载目录、更不修改 WeatherV2 原始数据。
## 输入
输入为一个 WeatherV2 reference/run 目录及其中一个 `YYYYMMDDHH` valid time:
```text
<run-dir>/<valid-time>/wind_vectors.bin
<run-dir>/<valid-time>/wind_vectors.bin.header.json
```
当前已核对的 `WVECBIN1` 布局为:`magic(8)`、little-endian `uint32 headerLength`、UTF-8 JSON header、row-major `u float32-le` 平面、`v float32-le` 平面、`speed float32-le` 平面、`valid uint8` 平面。WeatherV2 `bin_export.py` 将同一 `decode_wind()` U/V grid 写出;QBIN 和 NSFT 也从同一 decoder U/V 生成。QBIN metadata 明确其 U/V 单位为 m/s,因此本工具保持 m/s,不进行 kt 换算。
源 grid 的 y 来自 Web Mercator tile y,向南增加;因此输出的 row-major rows 是 north-to-south,columns 是 west-to-east。U=eastward-positive,V=northward-positive。
## 输出
每个输出目录包含:
- `metadata.json`:现有 `LocalParticleFieldFeed` 读取的 `maplibre-particle-field/v1` metadata。
- `field.f32`:`float32-le interleaved-uv`,顺序为 `U0,V0,U1,V1,...`。
- `conversion_summary.json`:源/输出 bounds、尺寸、统计值、valid/noData 计数、抽样一致性与 SHA-256。
本工具不重采样。每个源 `valid=0` 或非有限 U/V 的 cell 会写为既有 canonical `noData=-9999.0` 的 U/V 对;不会错误变成静风 `(0,0)`。有效样本按 float32 bit-exact 复制。
## 命令示例
```bash
python3 tools/weatherv2_wind_canonical/convert_weatherv2_wind.py \
--run-dir /root/sourceserver/weatherV2/src/dev/_runtime/dev_pipeline_data/datasets/ecmwf-hres/wind/2026080600 \
--valid-time 2026080700 \
--output platform/android/MapLibreAndroidTestApp/src/main/assets/integration/particle-weatherv2/ecmwf-hres-wind-20260807/2026080700
```
相同输入会产生相同 payload SHA-256。错误会输出明确消息并以非零状态退出。

View File

@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""Convert one WeatherV2 WVECBIN1 wind snapshot to maplibre-particle-field/v1."""
from __future__ import annotations
import argparse
import hashlib
import json
import math
import struct
import sys
from datetime import datetime, timezone
from pathlib import Path
import numpy as np
MAGIC = b"WVECBIN1"
NO_DATA = np.float32(-9999.0)
def fail(message: str) -> "None":
raise ValueError(message)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--run-dir", required=True, type=Path, help="WeatherV2 reference-time directory")
parser.add_argument("--valid-time", required=True, help="target time in YYYYMMDDHH UTC")
parser.add_argument("--output", required=True, type=Path, help="canonical snapshot output directory")
return parser.parse_args()
def require_mapping(value: object, name: str) -> dict:
if not isinstance(value, dict):
fail(f"{name} must be an object")
return value
def read_source(run_dir: Path, valid_time: str) -> tuple[dict, np.ndarray, np.ndarray, np.ndarray, np.ndarray, int]:
source_dir = run_dir / valid_time
payload_path = source_dir / "wind_vectors.bin"
sidecar_path = source_dir / "wind_vectors.bin.header.json"
if not payload_path.is_file() or not sidecar_path.is_file():
fail(f"missing WeatherV2 snapshot for {valid_time}: {source_dir}")
data = payload_path.read_bytes()
if len(data) < 12 or data[:8] != MAGIC:
fail(f"unsupported wind_vectors.bin magic in {payload_path}")
header_length = struct.unpack_from("<I", data, 8)[0]
header_end = 12 + header_length
if header_end > len(data):
fail("WVECBIN1 header length exceeds file length")
header = json.loads(data[12:header_end].decode("utf-8"))
sidecar = json.loads(sidecar_path.read_text(encoding="utf-8"))
if header != sidecar:
fail("embedded WVECBIN1 header differs from .header.json sidecar")
if header.get("format") != "weather-v2-particle-bin-v1" or header.get("layer") != "wind":
fail("source is not a WeatherV2 wind particle-bin-v1 snapshot")
if header.get("target_time") != valid_time:
fail(f"source target_time mismatch: {header.get('target_time')!r} != {valid_time!r}")
width, height = int(header.get("width", 0)), int(header.get("height", 0))
if width <= 0 or height <= 0:
fail("source width/height must be positive")
expected_fields = [
{"name": "u", "dtype": "float32"},
{"name": "v", "dtype": "float32"},
{"name": "speed", "dtype": "float32"},
{"name": "valid", "dtype": "uint8"},
]
if header.get("fields") != expected_fields:
fail(f"unsupported WVECBIN1 fields: {header.get('fields')!r}")
count = width * height
expected_bytes = header_end + count * 4 * 3 + count
if len(data) != expected_bytes:
fail(f"WVECBIN1 byte length mismatch: expected {expected_bytes}, got {len(data)}")
offset = header_end
u = np.frombuffer(data, dtype="<f4", count=count, offset=offset).copy().reshape(height, width)
offset += count * 4
v = np.frombuffer(data, dtype="<f4", count=count, offset=offset).copy().reshape(height, width)
offset += count * 4
speed = np.frombuffer(data, dtype="<f4", count=count, offset=offset).copy().reshape(height, width)
offset += count * 4
valid = np.frombuffer(data, dtype=np.uint8, count=count, offset=offset).copy().reshape(height, width).astype(bool)
bounds = require_mapping(header.get("bounds"), "source bounds")
if not all(math.isfinite(float(bounds[name])) for name in ("west", "south", "east", "north")):
fail("source bounds must be finite")
if not float(bounds["west"]) < float(bounds["east"]) or not float(bounds["south"]) < float(bounds["north"]):
fail("source bounds are invalid")
if not np.isfinite(u[valid]).all() or not np.isfinite(v[valid]).all():
fail("valid WeatherV2 samples contain non-finite U/V")
if not np.isfinite(speed[valid]).all():
fail("valid WeatherV2 samples contain non-finite speed")
return header, u, v, speed, valid, len(data)
def epoch_millis(valid_time: str) -> int:
try:
return int(datetime.strptime(valid_time, "%Y%m%d%H").replace(tzinfo=timezone.utc).timestamp() * 1000)
except ValueError as error:
fail(f"valid time must be YYYYMMDDHH UTC: {error}")
def stats(values: np.ndarray, valid: np.ndarray) -> dict:
chosen = values[valid]
if not chosen.size:
fail("source has no valid samples")
return {"min": float(chosen.min()), "max": float(chosen.max()), "mean": float(chosen.mean())}
def sample_indices(valid: np.ndarray) -> list[tuple[int, int]]:
height, width = valid.shape
candidates = [(height // 2, width // 2), (1, 1), (1, width - 2), (height - 2, 1), (height - 2, width - 2)]
selected: list[tuple[int, int]] = []
for row, col in candidates:
if valid[row, col] and (row, col) not in selected:
selected.append((row, col))
for row, col in zip(*np.nonzero(valid)):
if (int(row), int(col)) not in selected:
selected.append((int(row), int(col)))
if len(selected) == 8:
break
return selected
def write_output(header: dict,
u: np.ndarray,
v: np.ndarray,
speed: np.ndarray,
valid: np.ndarray,
source_bytes: int,
reference_time: str,
output: Path) -> dict:
width, height = int(header["width"]), int(header["height"])
valid = valid & np.isfinite(u) & np.isfinite(v)
payload = np.empty((height, width, 2), dtype="<f4")
payload[:, :, 0] = np.where(valid, u, NO_DATA)
payload[:, :, 1] = np.where(valid, v, NO_DATA)
payload_bytes = payload.tobytes(order="C")
payload_hash = hashlib.sha256(payload_bytes).hexdigest()
decoded = np.frombuffer(payload_bytes, dtype="<f4").reshape(height, width, 2)
if not np.array_equal(decoded[:, :, 0][valid], u[valid]) or not np.array_equal(decoded[:, :, 1][valid], v[valid]):
fail("source-to-canonical U/V conversion is not lossless")
if not np.all(decoded[:, :, 0][~valid] == NO_DATA) or not np.all(decoded[:, :, 1][~valid] == NO_DATA):
fail("invalid samples were not preserved as canonical noData")
bounds = header["bounds"]
timestamp = epoch_millis(str(header["target_time"]))
metadata = {
"schema": "maplibre-particle-field/v1",
"fieldId": f"ecmwf-hres-wind-{header['target_time']}",
"fieldType": "WIND",
"displayDomain": "GLOBAL",
"generation": timestamp,
"validTime": timestamp,
"dataVersion": timestamp,
"bounds": {name: float(bounds[name]) for name in ("west", "south", "east", "north")},
"grid": {"width": width, "height": height},
"vector": {"layout": "interleaved-uv", "encoding": "float32-le", "noData": float(NO_DATA), "unit": "m/s", "origin": "north-west", "rowOrder": "north-to-south", "columnOrder": "west-to-east", "uPositive": "east", "vPositive": "north"},
"payload": {"byteLength": len(payload_bytes), "sha256": payload_hash},
"source": {"kind": "WeatherV2 ECMWF HRES WVECBIN1", "referenceTime": reference_time, "targetTime": header["target_time"], "sourceBytes": source_bytes, "resampled": False},
}
samples = []
for row, col in sample_indices(valid):
samples.append({"row": row, "column": col, "sourceU": float(u[row, col]), "sourceV": float(v[row, col]), "canonicalU": float(decoded[row, col, 0]), "canonicalV": float(decoded[row, col, 1])})
summary = {
"source": {"format": header["format"], "magic": header["magic"], "targetTime": header["target_time"], "width": width, "height": height, "bounds": metadata["bounds"], "dtype": "float32 little-endian u plane, v plane, speed plane; uint8 valid plane", "arrayOrder": "row-major; WeatherV2 tile y increases southward, so rows are north-to-south", "uVUnit": "m/s", "validCount": int(valid.sum()), "invalidCount": int(valid.size - valid.sum()), "bytes": source_bytes},
"output": {"width": width, "height": height, "bounds": metadata["bounds"], "bytes": len(payload_bytes), "sha256": payload_hash, "validCount": int(valid.sum()), "invalidCount": int(valid.size - valid.sum()), "resampled": False},
"statistics": {"u": stats(u, valid), "v": stats(v, valid), "speed": stats(speed, valid)},
"sourceToCanonical": {"lossless": True, "maxAbsErrorU": 0.0, "maxAbsErrorV": 0.0, "samples": samples},
}
output.mkdir(parents=True, exist_ok=True)
(output / "field.f32").write_bytes(payload_bytes)
(output / "metadata.json").write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
(output / "conversion_summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return summary
def main() -> int:
args = parse_args()
try:
header, u, v, speed, valid, source_bytes = read_source(args.run_dir, args.valid_time)
summary = write_output(header, u, v, speed, valid, source_bytes, args.run_dir.name, args.output)
print(json.dumps({"ok": True, "targetTime": header["target_time"], "output": str(args.output), "sha256": summary["output"]["sha256"], "validCount": summary["output"]["validCount"], "invalidCount": summary["output"]["invalidCount"]}, ensure_ascii=False))
return 0
except (OSError, ValueError, json.JSONDecodeError) as error:
print(f"error: {error}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())