feat: add V3 field fixtures and RN regression host
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled

This commit is contained in:
OpenAI Codex
2026-09-11 09:56:53 +08:00
parent 3b01275983
commit 8588e6fcb8
61 changed files with 1939 additions and 39 deletions

View File

@@ -0,0 +1,157 @@
# STEP-070 MapLibreParticleLab V3-06 四类场 Fixture 与 RN 通用回归 — Codex 任务 v1
## 1. 目标
基于 `particle/main` 当前 HEAD `3b012759838f1e1ecc44559faffe061d396b6cce`,完成 V3 通用 Scalar/Vector Field 的离线业务语义映射与回归资产:Wind、Rain、Wave、Tide/Current 四类场,外加固定 synthetic Wave independence fixture。
本 Step 的重点不是增加新的 Runtime 架构,而是证明 STEP-065~069 冻结的通用契约能够由真实/可追溯数据映射并通过 `.3` RN 公共 API 驱动。Android 编译/安装/ADB/设备视觉验证继续留到后续统一周期,不在本任务执行。
开始前完整阅读 `AGENTS.md`、`PARTICLE_BASELINE.md`、`docs/00_项目规范/`、STEP-065~069 Step/报告,以及当前实际源码。不得按记忆重建。
## 2. 必须保持的架构边界
- Core/Renderer 不得出现 Wind/Wave/Rain/Tide/provider/network/vendor 业务分支。
- Host/RN 负责业务字段到 canonical scalar/vector/validity 的映射。
- canonical schema 继续使用 `maplibre-particle-field/v2`。
- 一个 Snapshot:0/1 scalar + 0/1 vector + shared validity;neither 非法。
- Wind / Tide(Current):vector `VELOCITY`,Field=`VECTOR_MAGNITUDE`,Particle 使用 VELOCITY。
- Rain:scalar rain rate,Field=`SCALAR`,不启用 Particle。
- Wave:scalar=wave height;vector=direction-to unit vector;semantics=`DIRECTION`;Field=`SCALAR`;Particle=`DIRECTION`,推进视觉速度只来自 `advectionScale`。
- Wave period 不进入第二 scalar,不参与推进,不从 height/period 推导速度。
- Rain precipitation_type 暂不进入 canonical V3.0。
- T0/T1/MIX、Timeline、Playback、OLD_T1→NEW_T0 reuse 等 Core 逻辑不得修改。
## 3. 当前基线事实
- RN package 已是 `@navsea/maplibre-particle-rn@13.4.1-particle.3`。
- 正式 v2 API:`FieldRuntime.submitField(reactTag, metadataJson, payloadRefs)`。
- `payloadRefs` 为必填 validity + optional scalar/vector local file URI,scalar/vector 至少一个。
- `.3` 已提供 `setFieldValueSource()` 与 `setParticleAdvectionScale()`。
- Android/JNI/Core multi-plane ingress 已实现,但 Android 编译留待 V3-08。
- 现有 RN Test Host 的 REAL WEATHER 仍直接调用旧 `WeatherRuntimeBridge.submitWindField()`;本 Step 必须迁移验证入口到 package `.3` 的 `FieldRuntime`,不能继续用旧 bridge 证明新 API。
- 仓库已有真实 WeatherV2 Wind canonical 资产:`platform/android/MapLibreAndroidTestApp/src/main/assets/integration/particle-weatherv2/ecmwf-hres-wind-20260807/`。
- WeatherV2 源树只读位于 `/root/sourceserver/weatherV2`;其 NSFT v1 语义以 `src/dev/core/nsft.py` 和当前生产 pipeline 为准。不要修改 WeatherV2。
## 4. 数据来源与可追溯性
先调查 `/root/sourceserver/weatherV2` 当前实际输出,优先从已存在的 NSFT v1 `field_pmtiles`/`/fields` 等本地数值产物生成 fixture,不联网、不重新跑完整生产 pipeline。
至少为以下四类场确认一个可用 source/refTime/validTime/区域:
- Wind
- Rain
- Wave
- Tide/Current
优先选日本及周边、相邻两个实际 validTime,便于 T0/T1/MIX。若某层当前本地没有可用正式 NSFT 数值产物,不得伪称“真实 WeatherV2”;应在报告明确说明并使用最小可追溯 synthetic/decoder-derived fallback,同时保留该层的业务语义。Wind 已有真实 canonical 基线可直接复用/升级。
不得把旧 QBIN 当作正式未来 transport。若为数据核验读取旧 BIN,只能作为来源调查/对照,最终 fixture 必须输出 v2 canonical planes。
## 5. 新建通用 fixture/converter 工具
在 `tools/` 下新增 provider-neutral 或 WeatherV2-to-canonical 的离线工具,具体目录名由实际实现确定,但不得只为 Wind 特化。
工具至少应支持把已解码/NSFT 数值场规范化为:
- `metadata.json`
- `validity.u8`
- optional `scalar.f32`
- optional `vector.f32`(interleaved U/V)
- `conversion_summary.json`
约束:
- float32-le;validity uint8;NW-origin row-major;rows north→south;cols west→east。
- U eastward,V northward。
- 每个输出文件记录 byteLength、SHA-256、grid、bounds、validTime、generation、source provenance。
- vector DIRECTION 必须在 valid cell 上接近 unit length;允许原始量化误差,转换后可规范化,但近零/非 finite 必须 invalid/fail-closed,不得任造方向。
- shared validity 必须由源有效性与所有存在 channel 的有限性共同约束;invalid cell 的具体 payload 值可为 0,但 validity=0 才是权威。
- metadata 中不允许 Wind/Wave/Rain/Tide capability enum;`fieldId` 可包含可读 fixture 名称但 Core 不依赖它。
## 6. 四类 fixture 目录
新增仓库内固定测试资产,建议:
`integration/particle-v3-fields/`
至少:
- `wind-real/`:两个相邻 validTime,vector-only VELOCITY。
- `rain-real/`:两个相邻 validTime,scalar-only。
- `wave-real/`:两个相邻 validTime,scalar+vector DIRECTION。
- `tide-real/`:两个相邻 validTime,vector-only VELOCITY。
- `wave-synthetic-independence/`:两个兼容时次或至少足以验证 T0/T1 的 fixture。
若某 `*-real` 因本机数据缺失只能 fallback,目录名不得误用 real;改用 `*-decoder-fixture` 或等价明确命名,并在 dataset manifest/报告写明来源。
每组 fixture 提供 `dataset.json`,列出 frame 顺序、metadata/plane 路径、expected FieldValueSource、particle expected on/off、vector semantics、建议 advectionScale(Wave synthetic 可指定非 1 值用于验证 scale 独立性)。
## 7. Synthetic Wave Independence 固定语义
必须实现并静态验证:
- scalar(wave height)西侧低、东侧高,形成明显 E→W 或 W→E 的横向数值梯度;
- vector 在所有 valid cell 固定向北 `(u=0, v=1)`;
- `vectorSemantics=DIRECTION`;
- Field source=`SCALAR`;
- Particle 方向应只由 vector 决定为北向;scalar 改变不得改变粒子方向/推进 magnitude;
- 改 `advectionScale` 只改变 DIRECTION visual movement magnitude,不改变 field scalar。
增加无 Android 的数据 oracle/脚本验证该 fixture:scalar 的东西梯度与 vector 的北向独立,不允许只靠人工阅读 JSON。
## 8. RN Test Host 升级到 `.3` 正式 API
只修改 Test Host 的 JS/TS 与离线 assets/config,不执行 Gradle。
要求:
- REAL WEATHER/V3 验证入口从内部 `WeatherRuntimeBridge.submitWindField` 迁移到 `@navsea/maplibre-particle-rn` 的 `FieldRuntime`。
- 不从 package 内部路径 import bridge。
- 通过 app cache/local file URI 提交 metadata + plane refs;不得 JS number[] / base64 / giant JSON。
- 提供四类场选择/加载能力,至少能在 UI/fixture runner 中选择 Wind/Rain/Wave/Tide 和 synthetic Wave。
- 每次切换 dataset 前 `clearFields()`,设置正确的 `setFieldValueSource()`、Particle visibility、`setParticleAdvectionScale()`,再顺序提交 T0/T1。
- 对每个 dataset 明确断言:T0=`ACCEPTED_PENDING_INITIAL_WINDOW`,T1=`INITIAL_WINDOW_READY`,runtime state=`READY`。
- Rain 必须 Particle OFF;Wind/Tide Particle ON;Wave Particle ON 且 DIRECTION 数据不被改成 VELOCITY。
- Field/Particle visibility 仍独立。
- 不在 JS 保存 T0/T1/MIX 第二份 Runtime 状态;JS 只保存 fixture/选择 UI 与调用结果。
- 现有 GSI/base map 与已验证 UI 可保留,不做无关重构。
## 9. Core/Linux 回归
新增最小 C++/数据 contract 测试,重点不是复制完整 Android bridge,而是验证:
- 四类 canonical shape 能被 `LocalParticleFieldFeed` / `RasterArraySource` 接受。
- Wind/Tide vector-only VELOCITY compatible T0/T1。
- Rain scalar-only compatible T0/T1。
- Wave scalar+vector DIRECTION compatible T0/T1。
- channel presence/semantics mismatch 继续 fail-closed。
- synthetic Wave 的 scalar/vector independence oracle。
- normal T0→T1 / next snapshot 不修改 Runtime 算法。
如果现有 test 已覆盖底层形状,不重复堆测试;新增 fixture-specific contract test 即可。
## 10. 本 Step 不做
- 不执行 Android Gradle/AAR/APK/ADB/设备测试。
- 不执行 Jest。
- 不修改 NavSea。
- 不修改 WeatherV2 源项目。
- 不联网下载天气数据。
- 不引入 provider/network/cache/manifest runtime 到 C++ Core。
- 不改 v2 schema、Timeline/Playback、shader 数学、DIRECTION 算法。
- 不发布 npm/tgz/tag;`.3` 仅保持源码版本。
- 不修改 STEP-062 两处现有用户 dirty 文档,不清理历史 untracked/build/cache。
## 11. 必须验证
至少执行并记录:
1. fixture converter/validator 自检 PASS,含 plane byteLength/SHA/grid/bounds/time/finite/validity。
2. synthetic Wave independence oracle PASS。
3. package `.3` TypeScript `tsc --noEmit` PASS。
4. RN Test Host TypeScript/typecheck PASS(使用项目现有命令,不跑 Jest)。
5. RN TurboModule codegen schema 检查 PASS,确保 STEP-069 ABI 未被破坏。
6. 受影响 Core 精确对象编译 PASS。
7. 相关 C++ fixture/Source 过滤测试 PASS。
8. `git diff --check` PASS。
所有 noisy build 使用 `$build-output-throttle`。若完整 `mbgl-test/runner` 已经最新可直接使用;若因改 C++ test 触发重链,允许 Linux 重链。
## 12. 文档输出
必须生成/更新:
- 本任务书。
- `docs/50_Step记录/STEP-070_MapLibreParticleLab_V3-06_四类场Fixture与RN通用回归_v1.md`
- `docs/报告/STEP-070_MapLibreParticleLab_V3-06_四类场Fixture与RN通用回归_Codex完成报告_v1.md`
- 追加 `粒子开发记录.md`
- 新工具 README / fixture dataset manifest 必须中文说明为主。
状态上限:`IMPLEMENTED / WAITING_ANDROID_VALIDATION`。不得标记 Android CLOSED/PASS。
## 13. Git 收尾
所有本任务实际修改必须 scoped commit 并 push `origin/particle/main`。
- 开始/结束都核对 branch、HEAD、origin。
- 严禁 `git add .` / `git add -A`。
- 不提交 STEP-062 两处用户 dirty、历史任务 MD、build/cache/tgz。
- 可使用 1 个实现 commit + 1 个文档 handoff commit;必要时再用一个纯 Git 元数据收尾 commit。
- 完成报告记录 implementation/handoff commit、push result、remote branch、Final HEAD、`HEAD == origin/particle/main`。
- push 失败不得声称完成。

View File

@@ -0,0 +1,33 @@
# STEP-070 MapLibreParticleLab V3-06 四类场 Fixture 与 RN 通用回归
状态:IMPLEMENTED / WAITING_ANDROID_VALIDATION
## 目标与边界
在 `particle/main` `3b012759` 上增加离线 V3 fixture、转换/校验工具、RN Test Host `.3` 公共 API 接线和最小 Core contract test。不修改 Timeline/Playback/T0/T1/MIX/rollover、schema、shader、WeatherV2、NavSea 或 STEP-062 两份用户 dirty 文档;不运行 Android Gradle/AAR/APK/ADB/Jest。
## 完成内容
- [x] `tools/particle_v3_fixture` 生成并校验 v2 metadata、shared validity、可选 scalar/vector、长度与 SHA-256。validator 对每一个 validity=1 的 cell 检查所有存在 plane:finite 且不等于 noData。
- [x] `wind-real` 从本地 canonical v1 `field.f32` 重新封装;shared validity 严格派生为 `finite(U) && finite(V) && U != -9999 && V != -9999`。
- [x] `wave-real` 由 WeatherV2 `ecmwf-wam/waves/2026090912/wave_bin` 的正式 `2026090918/2026090919` BIN 和 header sidecar 解码:scalar=`height_m`,vector=规范化 `unit_to_u/unit_to_v`,语义 `DIRECTION`;`period_s` 和原始 degree 不进入 canonical。
- [x] `tide-real` 由 WeatherV2 `cmems/tide/2026090900/current_bin` 的正式 `2026090901/2026090902` BIN 和 header sidecar 解码为 vector-only `VELOCITY`。
- [x] Wave/Tide 每帧 summary 记录正式源 BIN 与 sidecar SHA-256、target_time、referenceTime、bounds、grid、projection、included/excluded channels 和 validity 派生规则;两者 generation 为小且严格递增的 `1/2`。
- [x] Rain 保持 `rain-decoder-fixture`,manifest/provenance 明确为非真实 field values,因为本 Step 未实现 PMTiles reader;fallback generation 为严格递增的 `1/2`。
- [x] Test Host 的 `fieldDatasets` 已指向 `wave-real` 和 `tide-real`,继续仅经 package `FieldRuntime` 公共 API 提交 local file refs。
- [x] 保留并验证 Wave synthetic scalar/vector independence;新增 C++ provider-neutral 四类 shape contract test。
## 验证
- [x] `python3 tools/particle_v3_fixture/generate_fixtures.py --write-reference-fixtures --validate`:PASS。
- [x] package `.3` `tsc --noEmit`:PASS。
- [x] RN Test Host `npm run typecheck`:PASS(不含 Jest)。
- [x] RN TurboModule `combine-js-to-schema-cli.js`:PASS。
- [x] 使用 `CCACHE_DIR=/tmp/step070-ccache`,重新编译 `source.test.cpp.o`:PASS。
- [ ] `mbgl-test` / `mbgl-test-runner` 重链与 STEP-070/相关 V3 Source 过滤测试:重链前 target clean 扩大为完整依赖闭包;之后受限终端会话没有返回退出码。未标 PASS,当前仍待恢复构建并运行过滤测试。
- [x] `git diff --check`:PASS(提交前复核)。
- [ ] Android Gradle/AAR/APK/ADB/Jest:按任务范围未运行。
## 当前结果、阻塞与下一步
fixture 数据契约、TypeScript 与 codegen 已完成;Linux 完整重链/过滤测试仍待恢复。Android/JNI 真实编译和设备视觉验收仍由后续统一周期完成。Rain 要升级为真实数值场,需要另行实现只读 PMTiles reader;不得把现有 fallback 改称 real。

View File

@@ -0,0 +1,11 @@
# STEP-070 MapLibreParticleLab V3-06 四类场 Fixture 与 RN 通用回归 Codex 完成报告
结论:IMPLEMENTED / WAITING_ANDROID_VALIDATION。
Wind、Wave、Tide/Current 已形成真实本地离线 canonical fixture。Wind 从既有 v1 field 转换时,shared validity 现在严格要求 U/V 都 finite 且都不等于 `-9999`。Wave 直接读取 WeatherV2 `2026090912` run 的正式 `waves_particles.bin` 与 sidecar:height 进入 scalar,规范化后的 `unit_to_u/v` 进入 `DIRECTION` vector,period 被明确排除。Tide 从 CMEMS `2026090900` run 的正式 `current_vectors.bin` 读取 U/V 形成 `VELOCITY` vector。每帧摘要记录源 BIN 与 sidecar SHA-256、target_time、bounds、grid、projection 与 provenance。
Rain 仍是明确的 `rain-decoder-fixture`:本任务未实现 PMTiles reader,其数值不是真实场值。所有 fallback/synthetic generation 均为小且严格递增的 `1/2`,不再使用 epoch 毫秒。
RN Test Host 的 Wave/Tide 已改用新 real 目录;所有提交继续只使用 `@navsea/maplibre-particle-rn` 的 `FieldRuntime` 公共 API 和本地 file URI。fixture validator、package TypeScript、Host typecheck、TurboModule schema 与 `/tmp/step070-ccache` 下的 `source.test.cpp.o` 精确重编译均 PASS。重链前 target clean 意外扩大为完整依赖闭包,随后受限终端会话没有返回 `mbgl-test`/`mbgl-test-runner` 退出码;相关 Source 过滤测试也未运行,故两项均未标 PASS。完整构建日志按节流规范保存于 `.codex-build-logs/`。
未运行 Android Gradle/AAR/APK/ADB/Jest,故没有 Android PASS 声明。Git 收尾受当前沙箱 `.git/index.lock` 的只读文件系统阻断,未提交、未推送;没有尝试绕过限制。未修改 WeatherV2、NavSea、Core Runtime 算法或 STEP-062 两份用户 dirty 文档;Rain 真实 reader 与 Android/设备验收是后续工作。

View File

@@ -0,0 +1,35 @@
{
"grid": {
"width": 6,
"height": 4
},
"bounds": {
"west": 122.0,
"south": 20.0,
"east": 150.0,
"north": 48.0
},
"validTime": 1788955200000,
"generation": 1,
"source": {
"kind": "decoder-contract fallback",
"notReal": true,
"referenceTime": "2026090912",
"targetTime": "2026090912",
"说明": "本机未实现 PMTiles reader;此数值不是正式 WeatherV2 栅格。"
},
"planes": {
"validity.u8": {
"path": "validity.u8",
"byteLength": 24,
"sha256": "5b8b4d29020ea5b1bc427c40a0cab2bf944be057ec482110f1d12b68008cd286"
},
"scalar.f32": {
"path": "scalar.f32",
"byteLength": 96,
"sha256": "09484872f71b85d73159e9ce51ef4c9d0abc8620f9e2c4f1155fd8b33acf2a61"
}
},
"validCount": 24,
"invalidCount": 0
}

View File

@@ -0,0 +1,50 @@
{
"schema": "maplibre-particle-field/v2",
"fieldId": "rain-decoder-fixture",
"generation": 1,
"dataVersion": 1,
"validTime": 1788955200000,
"bounds": {
"west": 122.0,
"south": 20.0,
"east": 150.0,
"north": 48.0
},
"grid": {
"width": 6,
"height": 4
},
"validity": {
"encoding": "uint8",
"byteLength": 24
},
"source": {
"kind": "decoder-contract fallback",
"notReal": true,
"referenceTime": "2026090912",
"targetTime": "2026090912",
"说明": "本机未实现 PMTiles reader;此数值不是正式 WeatherV2 栅格。"
},
"canonical": {
"origin": "north-west",
"rowOrder": "north-to-south",
"columnOrder": "west-to-east"
},
"scalar": {
"encoding": "float32-le",
"byteLength": 96,
"noData": -9999.0
},
"planes": {
"validity.u8": {
"path": "validity.u8",
"byteLength": 24,
"sha256": "5b8b4d29020ea5b1bc427c40a0cab2bf944be057ec482110f1d12b68008cd286"
},
"scalar.f32": {
"path": "scalar.f32",
"byteLength": 96,
"sha256": "09484872f71b85d73159e9ce51ef4c9d0abc8620f9e2c4f1155fd8b33acf2a61"
}
}
}

View File

@@ -0,0 +1 @@


View File

@@ -0,0 +1,35 @@
{
"grid": {
"width": 6,
"height": 4
},
"bounds": {
"west": 122.0,
"south": 20.0,
"east": 150.0,
"north": 48.0
},
"validTime": 1788976800000,
"generation": 2,
"source": {
"kind": "decoder-contract fallback",
"notReal": true,
"referenceTime": "2026090912",
"targetTime": "2026090918",
"说明": "本机未实现 PMTiles reader;此数值不是正式 WeatherV2 栅格。"
},
"planes": {
"validity.u8": {
"path": "validity.u8",
"byteLength": 24,
"sha256": "5b8b4d29020ea5b1bc427c40a0cab2bf944be057ec482110f1d12b68008cd286"
},
"scalar.f32": {
"path": "scalar.f32",
"byteLength": 96,
"sha256": "74a93cc48d3875f0abd73b1ee14cc0f1a7293cd7dc7ef716a15725a162588855"
}
},
"validCount": 24,
"invalidCount": 0
}

View File

@@ -0,0 +1,50 @@
{
"schema": "maplibre-particle-field/v2",
"fieldId": "rain-decoder-fixture",
"generation": 2,
"dataVersion": 2,
"validTime": 1788976800000,
"bounds": {
"west": 122.0,
"south": 20.0,
"east": 150.0,
"north": 48.0
},
"grid": {
"width": 6,
"height": 4
},
"validity": {
"encoding": "uint8",
"byteLength": 24
},
"source": {
"kind": "decoder-contract fallback",
"notReal": true,
"referenceTime": "2026090912",
"targetTime": "2026090918",
"说明": "本机未实现 PMTiles reader;此数值不是正式 WeatherV2 栅格。"
},
"canonical": {
"origin": "north-west",
"rowOrder": "north-to-south",
"columnOrder": "west-to-east"
},
"scalar": {
"encoding": "float32-le",
"byteLength": 96,
"noData": -9999.0
},
"planes": {
"validity.u8": {
"path": "validity.u8",
"byteLength": 24,
"sha256": "5b8b4d29020ea5b1bc427c40a0cab2bf944be057ec482110f1d12b68008cd286"
},
"scalar.f32": {
"path": "scalar.f32",
"byteLength": 96,
"sha256": "74a93cc48d3875f0abd73b1ee14cc0f1a7293cd7dc7ef716a15725a162588855"
}
}
}

View File

@@ -0,0 +1 @@


View File

@@ -0,0 +1,26 @@
{
"schema": "maplibre-particle-test-dataset/v2",
"datasetId": "rain-decoder-fixture",
"sourceProvenance": "非真实场值:仅 decoder-contract fallback,不能当作 WeatherV2 实测栅格",
"frames": [
{
"label": "T0",
"path": "2026090912",
"metadata": "metadata.json",
"validity": "validity.u8",
"scalar": "scalar.f32"
},
{
"label": "T1",
"path": "2026090918",
"metadata": "metadata.json",
"validity": "validity.u8",
"scalar": "scalar.f32"
}
],
"expectedFieldValueSource": "SCALAR",
"particleExpected": false,
"vectorSemantics": null,
"suggestedAdvectionScale": 1.0,
"说明": "离线 canonical fixture;Runtime 只接收 metadata 与本地 plane URI。"
}

View File

@@ -0,0 +1,66 @@
{
"grid": {
"width": 513,
"height": 513
},
"bounds": {
"west": 90.0,
"south": 0.0,
"east": 180.0,
"north": 66.51326044311186
},
"validTime": 1788915600000,
"generation": 1,
"source": {
"kind": "WeatherV2 formal BIN decoded offline",
"sourcePath": "/root/sourceserver/weatherV2/src/dev/_runtime/dev_pipeline_data/datasets/cmems/tide/2026090900/current_bin/2026090901/current_vectors.bin",
"sourceSha256": "4de8b9843fcba13b452428cde7f1cf2b0a85ec0eb775c454fc6350dcdd527b13",
"headerPath": "/root/sourceserver/weatherV2/src/dev/_runtime/dev_pipeline_data/datasets/cmems/tide/2026090900/current_bin/2026090901/current_vectors.bin.header.json",
"headerSha256": "5bde915b08ae4e5e41d0a45b50b0a4a1d4fbed5c7e513b00fd3d988d42697070",
"referenceTime": "2026090900",
"targetTime": "2026090901",
"grid": {
"width": 513,
"height": 513
},
"bounds": {
"west": 90.0,
"south": 0.0,
"east": 180.0,
"north": 66.51326044311186
},
"binMagic": "WVECBIN1",
"binFields": [
{
"name": "u",
"dtype": "float32"
},
{
"name": "v",
"dtype": "float32"
},
{
"name": "speed",
"dtype": "float32"
},
{
"name": "valid",
"dtype": "uint8"
}
]
},
"planes": {
"validity.u8": {
"path": "validity.u8",
"byteLength": 263169,
"sha256": "14e745cea79d2cb85b66ef8fc5e19c28df8f912fb975f9281becc2ce6562e597"
},
"vector.f32": {
"path": "vector.f32",
"byteLength": 2105352,
"sha256": "a0c767bde25d16a0e6beb30017f430d48ce95ba1a896bda477ba3f078af386cd"
}
},
"validCount": 146779,
"invalidCount": 116390
}

View File

@@ -0,0 +1,85 @@
{
"schema": "maplibre-particle-field/v2",
"fieldId": "tide-real",
"generation": 1,
"dataVersion": 1,
"validTime": 1788915600000,
"bounds": {
"west": 90.0,
"south": 0.0,
"east": 180.0,
"north": 66.51326044311186
},
"grid": {
"width": 513,
"height": 513
},
"validity": {
"encoding": "uint8",
"byteLength": 263169
},
"source": {
"kind": "WeatherV2 formal BIN decoded offline",
"sourcePath": "/root/sourceserver/weatherV2/src/dev/_runtime/dev_pipeline_data/datasets/cmems/tide/2026090900/current_bin/2026090901/current_vectors.bin",
"sourceSha256": "4de8b9843fcba13b452428cde7f1cf2b0a85ec0eb775c454fc6350dcdd527b13",
"headerPath": "/root/sourceserver/weatherV2/src/dev/_runtime/dev_pipeline_data/datasets/cmems/tide/2026090900/current_bin/2026090901/current_vectors.bin.header.json",
"headerSha256": "5bde915b08ae4e5e41d0a45b50b0a4a1d4fbed5c7e513b00fd3d988d42697070",
"referenceTime": "2026090900",
"targetTime": "2026090901",
"grid": {
"width": 513,
"height": 513
},
"bounds": {
"west": 90.0,
"south": 0.0,
"east": 180.0,
"north": 66.51326044311186
},
"binMagic": "WVECBIN1",
"binFields": [
{
"name": "u",
"dtype": "float32"
},
{
"name": "v",
"dtype": "float32"
},
{
"name": "speed",
"dtype": "float32"
},
{
"name": "valid",
"dtype": "uint8"
}
]
},
"canonical": {
"origin": "north-west",
"rowOrder": "north-to-south",
"columnOrder": "west-to-east"
},
"vector": {
"layout": "interleaved-uv",
"encoding": "float32-le",
"byteLength": 2105352,
"noData": -9999.0,
"semantics": "VELOCITY",
"uPositive": "east",
"vPositive": "north"
},
"planes": {
"validity.u8": {
"path": "validity.u8",
"byteLength": 263169,
"sha256": "14e745cea79d2cb85b66ef8fc5e19c28df8f912fb975f9281becc2ce6562e597"
},
"vector.f32": {
"path": "vector.f32",
"byteLength": 2105352,
"sha256": "a0c767bde25d16a0e6beb30017f430d48ce95ba1a896bda477ba3f078af386cd"
}
}
}

View File

@@ -0,0 +1,66 @@
{
"grid": {
"width": 513,
"height": 513
},
"bounds": {
"west": 90.0,
"south": 0.0,
"east": 180.0,
"north": 66.51326044311186
},
"validTime": 1788919200000,
"generation": 2,
"source": {
"kind": "WeatherV2 formal BIN decoded offline",
"sourcePath": "/root/sourceserver/weatherV2/src/dev/_runtime/dev_pipeline_data/datasets/cmems/tide/2026090900/current_bin/2026090902/current_vectors.bin",
"sourceSha256": "2410eb208e11fdb73db9b8395b70cc5377dd078a4494ae3a67dbfa2f07280d34",
"headerPath": "/root/sourceserver/weatherV2/src/dev/_runtime/dev_pipeline_data/datasets/cmems/tide/2026090900/current_bin/2026090902/current_vectors.bin.header.json",
"headerSha256": "b0001a7976dd3a6c9e626faea96d68519bf3e2d5e56cc5ce1aedfa41accf1b99",
"referenceTime": "2026090900",
"targetTime": "2026090902",
"grid": {
"width": 513,
"height": 513
},
"bounds": {
"west": 90.0,
"south": 0.0,
"east": 180.0,
"north": 66.51326044311186
},
"binMagic": "WVECBIN1",
"binFields": [
{
"name": "u",
"dtype": "float32"
},
{
"name": "v",
"dtype": "float32"
},
{
"name": "speed",
"dtype": "float32"
},
{
"name": "valid",
"dtype": "uint8"
}
]
},
"planes": {
"validity.u8": {
"path": "validity.u8",
"byteLength": 263169,
"sha256": "14e745cea79d2cb85b66ef8fc5e19c28df8f912fb975f9281becc2ce6562e597"
},
"vector.f32": {
"path": "vector.f32",
"byteLength": 2105352,
"sha256": "a144307fdfd1172b6f2da8af0dc50ff27a4444da3989149c9bec2d1d73c55f21"
}
},
"validCount": 146779,
"invalidCount": 116390
}

View File

@@ -0,0 +1,85 @@
{
"schema": "maplibre-particle-field/v2",
"fieldId": "tide-real",
"generation": 2,
"dataVersion": 2,
"validTime": 1788919200000,
"bounds": {
"west": 90.0,
"south": 0.0,
"east": 180.0,
"north": 66.51326044311186
},
"grid": {
"width": 513,
"height": 513
},
"validity": {
"encoding": "uint8",
"byteLength": 263169
},
"source": {
"kind": "WeatherV2 formal BIN decoded offline",
"sourcePath": "/root/sourceserver/weatherV2/src/dev/_runtime/dev_pipeline_data/datasets/cmems/tide/2026090900/current_bin/2026090902/current_vectors.bin",
"sourceSha256": "2410eb208e11fdb73db9b8395b70cc5377dd078a4494ae3a67dbfa2f07280d34",
"headerPath": "/root/sourceserver/weatherV2/src/dev/_runtime/dev_pipeline_data/datasets/cmems/tide/2026090900/current_bin/2026090902/current_vectors.bin.header.json",
"headerSha256": "b0001a7976dd3a6c9e626faea96d68519bf3e2d5e56cc5ce1aedfa41accf1b99",
"referenceTime": "2026090900",
"targetTime": "2026090902",
"grid": {
"width": 513,
"height": 513
},
"bounds": {
"west": 90.0,
"south": 0.0,
"east": 180.0,
"north": 66.51326044311186
},
"binMagic": "WVECBIN1",
"binFields": [
{
"name": "u",
"dtype": "float32"
},
{
"name": "v",
"dtype": "float32"
},
{
"name": "speed",
"dtype": "float32"
},
{
"name": "valid",
"dtype": "uint8"
}
]
},
"canonical": {
"origin": "north-west",
"rowOrder": "north-to-south",
"columnOrder": "west-to-east"
},
"vector": {
"layout": "interleaved-uv",
"encoding": "float32-le",
"byteLength": 2105352,
"noData": -9999.0,
"semantics": "VELOCITY",
"uPositive": "east",
"vPositive": "north"
},
"planes": {
"validity.u8": {
"path": "validity.u8",
"byteLength": 263169,
"sha256": "14e745cea79d2cb85b66ef8fc5e19c28df8f912fb975f9281becc2ce6562e597"
},
"vector.f32": {
"path": "vector.f32",
"byteLength": 2105352,
"sha256": "a144307fdfd1172b6f2da8af0dc50ff27a4444da3989149c9bec2d1d73c55f21"
}
}
}

View File

@@ -0,0 +1,26 @@
{
"schema": "maplibre-particle-test-dataset/v2",
"datasetId": "tide-real",
"sourceProvenance": "真实本地 WeatherV2 CMEMS WVECBIN1;仅 U/V VELOCITY",
"frames": [
{
"label": "T0",
"path": "2026090901",
"metadata": "metadata.json",
"validity": "validity.u8",
"vector": "vector.f32"
},
{
"label": "T1",
"path": "2026090902",
"metadata": "metadata.json",
"validity": "validity.u8",
"vector": "vector.f32"
}
],
"expectedFieldValueSource": "VECTOR_MAGNITUDE",
"particleExpected": true,
"vectorSemantics": "VELOCITY",
"suggestedAdvectionScale": 1.0,
"说明": "离线 canonical fixture;Runtime 只接收 metadata 与本地 plane URI。"
}

View File

@@ -0,0 +1,82 @@
{
"grid": {
"width": 513,
"height": 769
},
"bounds": {
"west": 112.5,
"south": 0.0,
"east": 157.5,
"north": 55.77657301866769
},
"validTime": 1788976800000,
"generation": 1,
"source": {
"kind": "WeatherV2 formal BIN decoded offline",
"sourcePath": "/root/sourceserver/weatherV2/src/dev/_runtime/dev_pipeline_data/datasets/ecmwf-wam/waves/2026090912/wave_bin/2026090918/waves_particles.bin",
"sourceSha256": "5fadc8c4b67c0e111903df93603436a2aca94f736e2cefde3ec3df1011263c14",
"headerPath": "/root/sourceserver/weatherV2/src/dev/_runtime/dev_pipeline_data/datasets/ecmwf-wam/waves/2026090912/wave_bin/2026090918/waves_particles.bin.header.json",
"headerSha256": "cd7b3b2a8c508ae5b5a021496311613c7e82cf857587c20f8d74c0a12738f307",
"referenceTime": "2026090912",
"targetTime": "2026090918",
"grid": {
"width": 513,
"height": 769
},
"bounds": {
"west": 112.5,
"south": 0.0,
"east": 157.5,
"north": 55.77657301866769
},
"binMagic": "WAVEBIN1",
"binFields": [
{
"name": "height_m",
"dtype": "float32",
"unit": "m"
},
{
"name": "period_s",
"dtype": "float32",
"unit": "s"
},
{
"name": "direction_from_degree",
"dtype": "float32",
"unit": "degree"
},
{
"name": "unit_to_u",
"dtype": "float32"
},
{
"name": "unit_to_v",
"dtype": "float32"
},
{
"name": "valid",
"dtype": "uint8"
}
]
},
"planes": {
"validity.u8": {
"path": "validity.u8",
"byteLength": 394497,
"sha256": "e94dba4163df1468ede4cd6cdb720162328b0bdf3053a868855cb16a424bd3a6"
},
"scalar.f32": {
"path": "scalar.f32",
"byteLength": 1577988,
"sha256": "0824b72e3b737d22fcaf4ff3447b6737fe12cbaa2a44d233f5b1710f81586d57"
},
"vector.f32": {
"path": "vector.f32",
"byteLength": 3155976,
"sha256": "640dfb7126db6117507fb864aebc2ecfb3dade2726ff0592e7c87d7a3e912276"
}
},
"validCount": 295196,
"invalidCount": 99301
}

View File

@@ -0,0 +1,106 @@
{
"schema": "maplibre-particle-field/v2",
"fieldId": "wave-real",
"generation": 1,
"dataVersion": 1,
"validTime": 1788976800000,
"bounds": {
"west": 112.5,
"south": 0.0,
"east": 157.5,
"north": 55.77657301866769
},
"grid": {
"width": 513,
"height": 769
},
"validity": {
"encoding": "uint8",
"byteLength": 394497
},
"source": {
"kind": "WeatherV2 formal BIN decoded offline",
"sourcePath": "/root/sourceserver/weatherV2/src/dev/_runtime/dev_pipeline_data/datasets/ecmwf-wam/waves/2026090912/wave_bin/2026090918/waves_particles.bin",
"sourceSha256": "5fadc8c4b67c0e111903df93603436a2aca94f736e2cefde3ec3df1011263c14",
"headerPath": "/root/sourceserver/weatherV2/src/dev/_runtime/dev_pipeline_data/datasets/ecmwf-wam/waves/2026090912/wave_bin/2026090918/waves_particles.bin.header.json",
"headerSha256": "cd7b3b2a8c508ae5b5a021496311613c7e82cf857587c20f8d74c0a12738f307",
"referenceTime": "2026090912",
"targetTime": "2026090918",
"grid": {
"width": 513,
"height": 769
},
"bounds": {
"west": 112.5,
"south": 0.0,
"east": 157.5,
"north": 55.77657301866769
},
"binMagic": "WAVEBIN1",
"binFields": [
{
"name": "height_m",
"dtype": "float32",
"unit": "m"
},
{
"name": "period_s",
"dtype": "float32",
"unit": "s"
},
{
"name": "direction_from_degree",
"dtype": "float32",
"unit": "degree"
},
{
"name": "unit_to_u",
"dtype": "float32"
},
{
"name": "unit_to_v",
"dtype": "float32"
},
{
"name": "valid",
"dtype": "uint8"
}
]
},
"canonical": {
"origin": "north-west",
"rowOrder": "north-to-south",
"columnOrder": "west-to-east"
},
"scalar": {
"encoding": "float32-le",
"byteLength": 1577988,
"noData": -9999.0
},
"vector": {
"layout": "interleaved-uv",
"encoding": "float32-le",
"byteLength": 3155976,
"noData": -9999.0,
"semantics": "DIRECTION",
"uPositive": "east",
"vPositive": "north"
},
"planes": {
"validity.u8": {
"path": "validity.u8",
"byteLength": 394497,
"sha256": "e94dba4163df1468ede4cd6cdb720162328b0bdf3053a868855cb16a424bd3a6"
},
"scalar.f32": {
"path": "scalar.f32",
"byteLength": 1577988,
"sha256": "0824b72e3b737d22fcaf4ff3447b6737fe12cbaa2a44d233f5b1710f81586d57"
},
"vector.f32": {
"path": "vector.f32",
"byteLength": 3155976,
"sha256": "640dfb7126db6117507fb864aebc2ecfb3dade2726ff0592e7c87d7a3e912276"
}
}
}

View File

@@ -0,0 +1,82 @@
{
"grid": {
"width": 513,
"height": 769
},
"bounds": {
"west": 112.5,
"south": 0.0,
"east": 157.5,
"north": 55.77657301866769
},
"validTime": 1788980400000,
"generation": 2,
"source": {
"kind": "WeatherV2 formal BIN decoded offline",
"sourcePath": "/root/sourceserver/weatherV2/src/dev/_runtime/dev_pipeline_data/datasets/ecmwf-wam/waves/2026090912/wave_bin/2026090919/waves_particles.bin",
"sourceSha256": "c084c8ae4b9e601007b3a852c25ac3607c01a4de69dcd87fc875116eccf9d633",
"headerPath": "/root/sourceserver/weatherV2/src/dev/_runtime/dev_pipeline_data/datasets/ecmwf-wam/waves/2026090912/wave_bin/2026090919/waves_particles.bin.header.json",
"headerSha256": "72b3d79880b2ccd7055b5d1011401cba7938f7e7eb12e81ea6f259319626f421",
"referenceTime": "2026090912",
"targetTime": "2026090919",
"grid": {
"width": 513,
"height": 769
},
"bounds": {
"west": 112.5,
"south": 0.0,
"east": 157.5,
"north": 55.77657301866769
},
"binMagic": "WAVEBIN1",
"binFields": [
{
"name": "height_m",
"dtype": "float32",
"unit": "m"
},
{
"name": "period_s",
"dtype": "float32",
"unit": "s"
},
{
"name": "direction_from_degree",
"dtype": "float32",
"unit": "degree"
},
{
"name": "unit_to_u",
"dtype": "float32"
},
{
"name": "unit_to_v",
"dtype": "float32"
},
{
"name": "valid",
"dtype": "uint8"
}
]
},
"planes": {
"validity.u8": {
"path": "validity.u8",
"byteLength": 394497,
"sha256": "e94dba4163df1468ede4cd6cdb720162328b0bdf3053a868855cb16a424bd3a6"
},
"scalar.f32": {
"path": "scalar.f32",
"byteLength": 1577988,
"sha256": "99a70bc6e07311536844c9b6b481281e05af5f3b1cd1961e2aeb4696a614f789"
},
"vector.f32": {
"path": "vector.f32",
"byteLength": 3155976,
"sha256": "7871320366889daedd4cc4b057f97d321947a29813dd9b55d5df4be1416022ea"
}
},
"validCount": 295196,
"invalidCount": 99301
}

View File

@@ -0,0 +1,106 @@
{
"schema": "maplibre-particle-field/v2",
"fieldId": "wave-real",
"generation": 2,
"dataVersion": 2,
"validTime": 1788980400000,
"bounds": {
"west": 112.5,
"south": 0.0,
"east": 157.5,
"north": 55.77657301866769
},
"grid": {
"width": 513,
"height": 769
},
"validity": {
"encoding": "uint8",
"byteLength": 394497
},
"source": {
"kind": "WeatherV2 formal BIN decoded offline",
"sourcePath": "/root/sourceserver/weatherV2/src/dev/_runtime/dev_pipeline_data/datasets/ecmwf-wam/waves/2026090912/wave_bin/2026090919/waves_particles.bin",
"sourceSha256": "c084c8ae4b9e601007b3a852c25ac3607c01a4de69dcd87fc875116eccf9d633",
"headerPath": "/root/sourceserver/weatherV2/src/dev/_runtime/dev_pipeline_data/datasets/ecmwf-wam/waves/2026090912/wave_bin/2026090919/waves_particles.bin.header.json",
"headerSha256": "72b3d79880b2ccd7055b5d1011401cba7938f7e7eb12e81ea6f259319626f421",
"referenceTime": "2026090912",
"targetTime": "2026090919",
"grid": {
"width": 513,
"height": 769
},
"bounds": {
"west": 112.5,
"south": 0.0,
"east": 157.5,
"north": 55.77657301866769
},
"binMagic": "WAVEBIN1",
"binFields": [
{
"name": "height_m",
"dtype": "float32",
"unit": "m"
},
{
"name": "period_s",
"dtype": "float32",
"unit": "s"
},
{
"name": "direction_from_degree",
"dtype": "float32",
"unit": "degree"
},
{
"name": "unit_to_u",
"dtype": "float32"
},
{
"name": "unit_to_v",
"dtype": "float32"
},
{
"name": "valid",
"dtype": "uint8"
}
]
},
"canonical": {
"origin": "north-west",
"rowOrder": "north-to-south",
"columnOrder": "west-to-east"
},
"scalar": {
"encoding": "float32-le",
"byteLength": 1577988,
"noData": -9999.0
},
"vector": {
"layout": "interleaved-uv",
"encoding": "float32-le",
"byteLength": 3155976,
"noData": -9999.0,
"semantics": "DIRECTION",
"uPositive": "east",
"vPositive": "north"
},
"planes": {
"validity.u8": {
"path": "validity.u8",
"byteLength": 394497,
"sha256": "e94dba4163df1468ede4cd6cdb720162328b0bdf3053a868855cb16a424bd3a6"
},
"scalar.f32": {
"path": "scalar.f32",
"byteLength": 1577988,
"sha256": "99a70bc6e07311536844c9b6b481281e05af5f3b1cd1961e2aeb4696a614f789"
},
"vector.f32": {
"path": "vector.f32",
"byteLength": 3155976,
"sha256": "7871320366889daedd4cc4b057f97d321947a29813dd9b55d5df4be1416022ea"
}
}
}

View File

@@ -0,0 +1,28 @@
{
"schema": "maplibre-particle-test-dataset/v2",
"datasetId": "wave-real",
"sourceProvenance": "真实本地 WeatherV2 WAVEBIN1;height_m + 正规化 unit_to_u/v,period 不进入 canonical",
"frames": [
{
"label": "T0",
"path": "2026090918",
"metadata": "metadata.json",
"validity": "validity.u8",
"scalar": "scalar.f32",
"vector": "vector.f32"
},
{
"label": "T1",
"path": "2026090919",
"metadata": "metadata.json",
"validity": "validity.u8",
"scalar": "scalar.f32",
"vector": "vector.f32"
}
],
"expectedFieldValueSource": "SCALAR",
"particleExpected": true,
"vectorSemantics": "DIRECTION",
"suggestedAdvectionScale": 1.75,
"说明": "离线 canonical fixture;Runtime 只接收 metadata 与本地 plane URI。"
}

View File

@@ -0,0 +1,38 @@
{
"grid": {
"width": 6,
"height": 4
},
"bounds": {
"west": 122.0,
"south": 20.0,
"east": 150.0,
"north": 48.0
},
"validTime": 1767225600000,
"generation": 1,
"source": {
"kind": "synthetic independence oracle",
"notReal": true,
"targetTime": "2026010100"
},
"planes": {
"validity.u8": {
"path": "validity.u8",
"byteLength": 24,
"sha256": "5b8b4d29020ea5b1bc427c40a0cab2bf944be057ec482110f1d12b68008cd286"
},
"scalar.f32": {
"path": "scalar.f32",
"byteLength": 96,
"sha256": "09484872f71b85d73159e9ce51ef4c9d0abc8620f9e2c4f1155fd8b33acf2a61"
},
"vector.f32": {
"path": "vector.f32",
"byteLength": 192,
"sha256": "e34af2311cfe54f9a33aa51b33021eee2df99622903a913aeca1bc4ce1d941a7"
}
},
"validCount": 24,
"invalidCount": 0
}

View File

@@ -0,0 +1,62 @@
{
"schema": "maplibre-particle-field/v2",
"fieldId": "wave-synthetic-independence",
"generation": 1,
"dataVersion": 1,
"validTime": 1767225600000,
"bounds": {
"west": 122.0,
"south": 20.0,
"east": 150.0,
"north": 48.0
},
"grid": {
"width": 6,
"height": 4
},
"validity": {
"encoding": "uint8",
"byteLength": 24
},
"source": {
"kind": "synthetic independence oracle",
"notReal": true,
"targetTime": "2026010100"
},
"canonical": {
"origin": "north-west",
"rowOrder": "north-to-south",
"columnOrder": "west-to-east"
},
"scalar": {
"encoding": "float32-le",
"byteLength": 96,
"noData": -9999.0
},
"vector": {
"layout": "interleaved-uv",
"encoding": "float32-le",
"byteLength": 192,
"noData": -9999.0,
"semantics": "DIRECTION",
"uPositive": "east",
"vPositive": "north"
},
"planes": {
"validity.u8": {
"path": "validity.u8",
"byteLength": 24,
"sha256": "5b8b4d29020ea5b1bc427c40a0cab2bf944be057ec482110f1d12b68008cd286"
},
"scalar.f32": {
"path": "scalar.f32",
"byteLength": 96,
"sha256": "09484872f71b85d73159e9ce51ef4c9d0abc8620f9e2c4f1155fd8b33acf2a61"
},
"vector.f32": {
"path": "vector.f32",
"byteLength": 192,
"sha256": "e34af2311cfe54f9a33aa51b33021eee2df99622903a913aeca1bc4ce1d941a7"
}
}
}

View File

@@ -0,0 +1 @@


View File

@@ -0,0 +1,38 @@
{
"grid": {
"width": 6,
"height": 4
},
"bounds": {
"west": 122.0,
"south": 20.0,
"east": 150.0,
"north": 48.0
},
"validTime": 1767247200000,
"generation": 2,
"source": {
"kind": "synthetic independence oracle",
"notReal": true,
"targetTime": "2026010106"
},
"planes": {
"validity.u8": {
"path": "validity.u8",
"byteLength": 24,
"sha256": "5b8b4d29020ea5b1bc427c40a0cab2bf944be057ec482110f1d12b68008cd286"
},
"scalar.f32": {
"path": "scalar.f32",
"byteLength": 96,
"sha256": "74a93cc48d3875f0abd73b1ee14cc0f1a7293cd7dc7ef716a15725a162588855"
},
"vector.f32": {
"path": "vector.f32",
"byteLength": 192,
"sha256": "e34af2311cfe54f9a33aa51b33021eee2df99622903a913aeca1bc4ce1d941a7"
}
},
"validCount": 24,
"invalidCount": 0
}

View File

@@ -0,0 +1,62 @@
{
"schema": "maplibre-particle-field/v2",
"fieldId": "wave-synthetic-independence",
"generation": 2,
"dataVersion": 2,
"validTime": 1767247200000,
"bounds": {
"west": 122.0,
"south": 20.0,
"east": 150.0,
"north": 48.0
},
"grid": {
"width": 6,
"height": 4
},
"validity": {
"encoding": "uint8",
"byteLength": 24
},
"source": {
"kind": "synthetic independence oracle",
"notReal": true,
"targetTime": "2026010106"
},
"canonical": {
"origin": "north-west",
"rowOrder": "north-to-south",
"columnOrder": "west-to-east"
},
"scalar": {
"encoding": "float32-le",
"byteLength": 96,
"noData": -9999.0
},
"vector": {
"layout": "interleaved-uv",
"encoding": "float32-le",
"byteLength": 192,
"noData": -9999.0,
"semantics": "DIRECTION",
"uPositive": "east",
"vPositive": "north"
},
"planes": {
"validity.u8": {
"path": "validity.u8",
"byteLength": 24,
"sha256": "5b8b4d29020ea5b1bc427c40a0cab2bf944be057ec482110f1d12b68008cd286"
},
"scalar.f32": {
"path": "scalar.f32",
"byteLength": 96,
"sha256": "74a93cc48d3875f0abd73b1ee14cc0f1a7293cd7dc7ef716a15725a162588855"
},
"vector.f32": {
"path": "vector.f32",
"byteLength": 192,
"sha256": "e34af2311cfe54f9a33aa51b33021eee2df99622903a913aeca1bc4ce1d941a7"
}
}
}

View File

@@ -0,0 +1 @@


View File

@@ -0,0 +1,28 @@
{
"schema": "maplibre-particle-test-dataset/v2",
"datasetId": "wave-synthetic-independence",
"sourceProvenance": "固定 synthetic;height 与 direction 独立",
"frames": [
{
"label": "T0",
"path": "2026010100",
"metadata": "metadata.json",
"validity": "validity.u8",
"scalar": "scalar.f32",
"vector": "vector.f32"
},
{
"label": "T1",
"path": "2026010106",
"metadata": "metadata.json",
"validity": "validity.u8",
"scalar": "scalar.f32",
"vector": "vector.f32"
}
],
"expectedFieldValueSource": "SCALAR",
"particleExpected": true,
"vectorSemantics": "DIRECTION",
"suggestedAdvectionScale": 2.5,
"说明": "离线 canonical fixture;Runtime 只接收 metadata 与本地 plane URI。"
}

View File

@@ -0,0 +1,35 @@
{
"grid": {
"width": 513,
"height": 769
},
"bounds": {
"west": 112.5,
"south": 0.0,
"east": 157.5,
"north": 55.77657301866769
},
"validTime": 1786060800000,
"generation": 1786060800000,
"source": {
"kind": "WeatherV2 ECMWF HRES WVECBIN1 canonical v1 upgraded offline",
"referenceTime": "2026080600",
"targetTime": "2026080700",
"originalPayloadSha256": "363e2cd2e77501f6bceecbb4aaa68c4f9ef46998a60319ae5b7aba3548995283",
"validityDerivation": "finite U/V and both components != -9999.0"
},
"planes": {
"validity.u8": {
"path": "validity.u8",
"byteLength": 394497,
"sha256": "3100f7cc4f6b7357e857ec10f159c43a092cfaad8eed6327043406579f5808ee"
},
"vector.f32": {
"path": "vector.f32",
"byteLength": 3155976,
"sha256": "363e2cd2e77501f6bceecbb4aaa68c4f9ef46998a60319ae5b7aba3548995283"
}
},
"validCount": 394497,
"invalidCount": 0
}

View File

@@ -0,0 +1,54 @@
{
"schema": "maplibre-particle-field/v2",
"fieldId": "wind-real",
"generation": 1786060800000,
"dataVersion": 1786060800000,
"validTime": 1786060800000,
"bounds": {
"west": 112.5,
"south": 0.0,
"east": 157.5,
"north": 55.77657301866769
},
"grid": {
"width": 513,
"height": 769
},
"validity": {
"encoding": "uint8",
"byteLength": 394497
},
"source": {
"kind": "WeatherV2 ECMWF HRES WVECBIN1 canonical v1 upgraded offline",
"referenceTime": "2026080600",
"targetTime": "2026080700",
"originalPayloadSha256": "363e2cd2e77501f6bceecbb4aaa68c4f9ef46998a60319ae5b7aba3548995283",
"validityDerivation": "finite U/V and both components != -9999.0"
},
"canonical": {
"origin": "north-west",
"rowOrder": "north-to-south",
"columnOrder": "west-to-east"
},
"vector": {
"layout": "interleaved-uv",
"encoding": "float32-le",
"byteLength": 3155976,
"noData": -9999.0,
"semantics": "VELOCITY",
"uPositive": "east",
"vPositive": "north"
},
"planes": {
"validity.u8": {
"path": "validity.u8",
"byteLength": 394497,
"sha256": "3100f7cc4f6b7357e857ec10f159c43a092cfaad8eed6327043406579f5808ee"
},
"vector.f32": {
"path": "vector.f32",
"byteLength": 3155976,
"sha256": "363e2cd2e77501f6bceecbb4aaa68c4f9ef46998a60319ae5b7aba3548995283"
}
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,35 @@
{
"grid": {
"width": 513,
"height": 769
},
"bounds": {
"west": 112.5,
"south": 0.0,
"east": 157.5,
"north": 55.77657301866769
},
"validTime": 1786082400000,
"generation": 1786082400000,
"source": {
"kind": "WeatherV2 ECMWF HRES WVECBIN1 canonical v1 upgraded offline",
"referenceTime": "2026080600",
"targetTime": "2026080706",
"originalPayloadSha256": "ef7a8e4b1c47f652f8f7012de16a87c16e3b25556a8f4686629f80514426c451",
"validityDerivation": "finite U/V and both components != -9999.0"
},
"planes": {
"validity.u8": {
"path": "validity.u8",
"byteLength": 394497,
"sha256": "3100f7cc4f6b7357e857ec10f159c43a092cfaad8eed6327043406579f5808ee"
},
"vector.f32": {
"path": "vector.f32",
"byteLength": 3155976,
"sha256": "ef7a8e4b1c47f652f8f7012de16a87c16e3b25556a8f4686629f80514426c451"
}
},
"validCount": 394497,
"invalidCount": 0
}

View File

@@ -0,0 +1,54 @@
{
"schema": "maplibre-particle-field/v2",
"fieldId": "wind-real",
"generation": 1786082400000,
"dataVersion": 1786082400000,
"validTime": 1786082400000,
"bounds": {
"west": 112.5,
"south": 0.0,
"east": 157.5,
"north": 55.77657301866769
},
"grid": {
"width": 513,
"height": 769
},
"validity": {
"encoding": "uint8",
"byteLength": 394497
},
"source": {
"kind": "WeatherV2 ECMWF HRES WVECBIN1 canonical v1 upgraded offline",
"referenceTime": "2026080600",
"targetTime": "2026080706",
"originalPayloadSha256": "ef7a8e4b1c47f652f8f7012de16a87c16e3b25556a8f4686629f80514426c451",
"validityDerivation": "finite U/V and both components != -9999.0"
},
"canonical": {
"origin": "north-west",
"rowOrder": "north-to-south",
"columnOrder": "west-to-east"
},
"vector": {
"layout": "interleaved-uv",
"encoding": "float32-le",
"byteLength": 3155976,
"noData": -9999.0,
"semantics": "VELOCITY",
"uPositive": "east",
"vPositive": "north"
},
"planes": {
"validity.u8": {
"path": "validity.u8",
"byteLength": 394497,
"sha256": "3100f7cc4f6b7357e857ec10f159c43a092cfaad8eed6327043406579f5808ee"
},
"vector.f32": {
"path": "vector.f32",
"byteLength": 3155976,
"sha256": "ef7a8e4b1c47f652f8f7012de16a87c16e3b25556a8f4686629f80514426c451"
}
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,26 @@
{
"schema": "maplibre-particle-test-dataset/v2",
"datasetId": "wind-real",
"sourceProvenance": "真实本地 WeatherV2 Wind canonical v1 输出;validity 由 U/V noData 与有限性派生",
"frames": [
{
"label": "T0",
"path": "2026080700",
"metadata": "metadata.json",
"validity": "validity.u8",
"vector": "vector.f32"
},
{
"label": "T1",
"path": "2026080706",
"metadata": "metadata.json",
"validity": "validity.u8",
"vector": "vector.f32"
}
],
"expectedFieldValueSource": "VECTOR_MAGNITUDE",
"particleExpected": true,
"vectorSemantics": "VELOCITY",
"suggestedAdvectionScale": 1.0,
"说明": "离线 canonical fixture;Runtime 只接收 metadata 与本地 plane URI。"
}

View File

@@ -1,15 +1,15 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, View, findNodeHandle } from "react-native";
import { Camera, Map } from "@maplibre/maplibre-react-native";
import { WeatherRuntimeBridge, type ColorStop, type RuntimeSnapshot, type TargetFps } from "@maplibre-particle/weather-runtime-bridge";
import { materializeRealWeatherFrames, realWeatherFrames } from "./src/realWeatherDataset";
import { FieldRuntime, type FieldColorStop, type RuntimeSnapshot, type TargetFps } from "@navsea/maplibre-particle-rn";
import { fieldDatasets, materializeV3Frame, type V3Dataset } from "./src/fieldDatasets";
import { weatherTestStyle } from "./src/weatherStyle";
const initialSnapshot: RuntimeSnapshot = { state: "WAITING_FOR_FIELDS", playbackState: "PAUSE", targetFps: "AUTO", disposed: false };
const initialVisual = { count: "4096", width: "1.5", length: "0.012", opacity: "0.9", trailOpacity: "0.8", trailFade: "0.94", weatherOpacity: "0.62" };
const frozenStops: ColorStop[] = [
{ speedMps: 0, red: 0.08, green: 0.2, blue: 0.8, alpha: 0.72 }, { speedMps: 8, red: 0, green: 0.85, blue: 0.9, alpha: 0.72 },
{ speedMps: 16, red: 1, green: 0.85, blue: 0.05, alpha: 0.72 }, { speedMps: 30, red: 0.9, green: 0.08, blue: 0.08, alpha: 0.72 },
const frozenStops: FieldColorStop[] = [
{ value: 0, red: 0.08, green: 0.2, blue: 0.8, alpha: 0.72 }, { value: 8, red: 0, green: 0.85, blue: 0.9, alpha: 0.72 },
{ value: 16, red: 1, green: 0.85, blue: 0.05, alpha: 0.72 }, { value: 30, red: 0.9, green: 0.08, blue: 0.08, alpha: 0.72 },
];
export default function App() {
@@ -17,22 +17,23 @@ export default function App() {
const [snapshot, setSnapshot] = useState<RuntimeSnapshot>(initialSnapshot);
const [result, setResult] = useState("Style is loading");
const [basemap, setBasemap] = useState("LOADING");
const [frames, setFrames] = useState<Record<string, string>>({ T0: "PENDING", T1: "PENDING", T2: "PENDING", T3: "PENDING" });
const [transport, setTransport] = useState("PENDING: FILE_REF / FLOAT32_LE / INTERLEAVED_UV");
const [frames, setFrames] = useState<Record<string, string>>({ T0: "PENDING", T1: "PENDING" });
const [dataset, setDataset] = useState<V3Dataset>(fieldDatasets[0]);
const [transport, setTransport] = useState("PENDING: V2 FILE_REF / FLOAT32_LE");
const [weatherVisible, setWeatherVisible] = useState(true);
const [particleVisible, setParticleVisible] = useState(true);
const [visual, setVisual] = useState(initialVisual);
const refresh = useCallback(async (tag: number) => setSnapshot(await WeatherRuntimeBridge.getState(tag)), []);
const refresh = useCallback(async (tag: number) => setSnapshot(await FieldRuntime.getState(tag)), []);
const attach = useCallback(async (tag: number) => {
try { await WeatherRuntimeBridge.attach(tag); await refresh(tag); setReactTag(tag); setResult("WeatherRuntime attached; style ready"); }
try { await FieldRuntime.attach(tag); await refresh(tag); setReactTag(tag); setResult("FieldRuntime attached; style ready"); }
catch (error) { setResult(`style/attach error: ${String(error)}`); }
}, [refresh]);
const onStyleReady = useCallback((event: unknown) => {
const tag = findNodeHandle((event as { target?: unknown }).target as never);
if (tag != null) void attach(tag);
}, [attach]);
useEffect(() => () => { if (reactTag != null) void WeatherRuntimeBridge.detach(reactTag).catch(() => undefined); }, [reactTag]);
useEffect(() => () => { if (reactTag != null) void FieldRuntime.detach(reactTag).catch(() => undefined); }, [reactTag]);
const command = useCallback(async (name: string, operation: (tag: number) => Promise<unknown>) => {
if (reactTag == null) return setResult("MAP_NOT_READY: wait for GSI/style ready");
@@ -40,46 +41,47 @@ export default function App() {
catch (error) { setResult(`${name}: ${String(error)}`); }
}, [reactTag, refresh]);
const loadRealWeather = useCallback(async () => {
const loadDataset = useCallback(async () => {
if (reactTag == null) return setResult("MAP_NOT_READY: wait for GSI/style ready");
const expected = ["ACCEPTED_PENDING_INITIAL_WINDOW", "INITIAL_WINDOW_READY", "ACCEPTED_NEXT_SNAPSHOT", "ACCEPTED_NEXT_SNAPSHOT"];
const expected = ["ACCEPTED_PENDING_INITIAL_WINDOW", "INITIAL_WINDOW_READY"];
try {
setTransport("LOADING: materializing Expo Asset local file:// URIs"); setFrames({ T0: "PENDING", T1: "PENDING", T2: "PENDING", T3: "PENDING" });
if (await WeatherRuntimeBridge.clearWindFields(reactTag) !== "ACCEPTED") throw new Error("clearWindFields was rejected");
const localFrames = await materializeRealWeatherFrames();
setTransport("LOADING: V2 metadata + validity/scalar/vector local file:// URIs"); setFrames({ T0: "PENDING", T1: "PENDING" });
if (await FieldRuntime.clearFields(reactTag) !== "ACCEPTED") throw new Error("clearFields was rejected");
await FieldRuntime.setFieldValueSource(reactTag, dataset.fieldValueSource); await FieldRuntime.setParticleVisible(reactTag, dataset.particle); await FieldRuntime.setParticleAdvectionScale(reactTag, dataset.advectionScale);
const localFrames = await Promise.all(dataset.frames.map(materializeV3Frame));
for (let index = 0; index < localFrames.length; index += 1) {
const frame = localFrames[index]; const submit = await WeatherRuntimeBridge.submitWindField(reactTag, frame.metadataJson, frame.payloadFileUri);
const frame = localFrames[index]; const submit = await FieldRuntime.submitField(reactTag, frame.metadataJson, frame.payloadRefs);
setFrames((current) => ({ ...current, [frame.label]: submit }));
if (submit !== expected[index]) throw new Error(`${frame.label} expected ${expected[index]}, got ${submit}`);
}
const native = await WeatherRuntimeBridge.getState(reactTag); setSnapshot(native);
const native = await FieldRuntime.getState(reactTag); setSnapshot(native);
if (native.state !== "READY") throw new Error(`Native WeatherRuntime expected READY, got ${native.state}`);
setTransport("READY: FILE_REF / FLOAT32_LE / INTERLEAVED_UV"); setResult("T0-T3 accepted in strict producer timestamp order; Native runtime READY");
} catch (error) { setTransport(`ERROR: ${String(error)}`); setResult(`LOAD REAL WEATHER T0-T3: ${String(error)}`); }
}, [reactTag]);
setTransport("READY: V2 FILE_REF / FLOAT32_LE / shared validity"); setResult(`${dataset.title}: T0/T1 accepted; FieldRuntime READY`);
} catch (error) { setTransport(`ERROR: ${String(error)}`); setResult(`LOAD ${dataset.id}: ${String(error)}`); }
}, [reactTag, dataset]);
const updateVisual = useCallback((key: keyof typeof initialVisual, value: string) => setVisual((current) => ({ ...current, [key]: value })), []);
const applyVisual = useCallback(() => void command("particle visual", async (tag) => {
const results = await Promise.all([
WeatherRuntimeBridge.setParticleCount(tag, Number(visual.count)), WeatherRuntimeBridge.setParticleLineWidth(tag, Number(visual.width)), WeatherRuntimeBridge.setParticleLength(tag, Number(visual.length)), WeatherRuntimeBridge.setParticleOpacity(tag, Number(visual.opacity)), WeatherRuntimeBridge.setTrailOpacity(tag, Number(visual.trailOpacity)), WeatherRuntimeBridge.setTrailFade(tag, Number(visual.trailFade)), WeatherRuntimeBridge.setWeatherColorOpacity(tag, Number(visual.weatherOpacity)),
FieldRuntime.setParticleCount(tag, Number(visual.count)), FieldRuntime.setParticleLineWidth(tag, Number(visual.width)), FieldRuntime.setParticleLength(tag, Number(visual.length)), FieldRuntime.setParticleOpacity(tag, Number(visual.opacity)), FieldRuntime.setTrailOpacity(tag, Number(visual.trailOpacity)), FieldRuntime.setTrailFade(tag, Number(visual.trailFade)), FieldRuntime.setFieldOpacity(tag, Number(visual.weatherOpacity)),
]);
if (results.some((value) => value !== "ACCEPTED")) throw new Error(results.join(", ")); return "ACCEPTED";
}), [command, visual]);
const controls = useMemo(() => [
["PLAY", (tag: number) => WeatherRuntimeBridge.setPlaybackState(tag, "PLAY")], ["PAUSE", (tag: number) => WeatherRuntimeBridge.setPlaybackState(tag, "PAUSE")], ["HALF", (tag: number) => WeatherRuntimeBridge.setPlaybackSpeed(tag, "HALF")], ["NORMAL", (tag: number) => WeatherRuntimeBridge.setPlaybackSpeed(tag, "NORMAL")], ["DOUBLE", (tag: number) => WeatherRuntimeBridge.setPlaybackSpeed(tag, "DOUBLE")],
["FPS AUTO", (tag: number) => WeatherRuntimeBridge.setTargetFps(tag, "AUTO" as TargetFps)], ["FPS 30", (tag: number) => WeatherRuntimeBridge.setTargetFps(tag, "FPS_30" as TargetFps)], ["FPS 60", (tag: number) => WeatherRuntimeBridge.setTargetFps(tag, "FPS_60" as TargetFps)], ["Weather stops", (tag: number) => WeatherRuntimeBridge.setWeatherColorStops(tag, frozenStops)], ["Particle color", (tag: number) => WeatherRuntimeBridge.setParticleColor(tag, 1, 1, 1, 0.95)], ["Trail color", (tag: number) => WeatherRuntimeBridge.setTrailColor(tag, 0.35, 0.85, 1, 0.8)],
["PLAY", (tag: number) => FieldRuntime.setPlaybackState(tag, "PLAY")], ["PAUSE", (tag: number) => FieldRuntime.setPlaybackState(tag, "PAUSE")], ["HALF", (tag: number) => FieldRuntime.setPlaybackSpeed(tag, "HALF")], ["NORMAL", (tag: number) => FieldRuntime.setPlaybackSpeed(tag, "NORMAL")], ["DOUBLE", (tag: number) => FieldRuntime.setPlaybackSpeed(tag, "DOUBLE")],
["FPS AUTO", (tag: number) => FieldRuntime.setTargetFps(tag, "AUTO" as TargetFps)], ["FPS 30", (tag: number) => FieldRuntime.setTargetFps(tag, "FPS_30" as TargetFps)], ["FPS 60", (tag: number) => FieldRuntime.setTargetFps(tag, "FPS_60" as TargetFps)], ["Field stops", (tag: number) => FieldRuntime.setFieldColorStops(tag, frozenStops)], ["Particle color", (tag: number) => FieldRuntime.setParticleColor(tag, 1, 1, 1, 0.95)], ["Trail color", (tag: number) => FieldRuntime.setTrailColor(tag, 0.35, 0.85, 1, 0.8)],
] as const, []);
return <SafeAreaView style={styles.root}>
<View style={styles.map}><Map mapStyle={weatherTestStyle} onDidFinishLoadingStyle={onStyleReady} onDidFinishLoadingMap={() => setBasemap("READY")} onDidFailLoadingMap={() => { setBasemap("ERROR"); setResult("map loading error"); }}><Camera initialViewState={{ center: [135, 32], zoom: 3.9 }} /></Map></View>
<ScrollView style={styles.controls} contentContainerStyle={styles.content}>
<Text>Basemap: GSI {basemap}</Text><Text>Dataset: ECMWF HRES 20260807 · 513×769 · 3.01 MiB/frame</Text><Text>Runtime: {snapshot.state} / {snapshot.playbackState} / {snapshot.targetFps}</Text><Text selectable>REAL DATA TRANSPORT = {transport}</Text>
{realWeatherFrames.map((frame) => <Text selectable key={frame.label}>{frame.label} {frame.cycle}: {frames[frame.label]}</Text>)}
<Text selectable>{result}</Text><Button title="LOAD REAL WEATHER T0-T3" onPress={() => void loadRealWeather()} />
<Text>Basemap: GSI {basemap}</Text><Text>Dataset: {dataset.title}</Text><Text>Runtime: {snapshot.state} / {snapshot.playbackState} / {snapshot.targetFps}</Text><Text selectable>V2 FILE TRANSPORT = {transport}</Text>
{dataset.frames.map((frame) => <Text selectable key={frame.label}>{frame.label}: {frames[frame.label]}</Text>)}
<Text selectable>{result}</Text><View style={styles.row}>{fieldDatasets.map((item) => <Button key={item.id} title={item.id} onPress={() => setDataset(item)} />)}</View><Button title="LOAD SELECTED V3 DATASET" onPress={() => void loadDataset()} />
<View style={styles.row}>{controls.map(([label, operation]) => <Button key={label} title={label} onPress={() => void command(label, operation)} />)}</View>
<View style={styles.row}><Button title={`Weather ${weatherVisible ? "visible" : "hidden"}`} onPress={() => { const next = !weatherVisible; setWeatherVisible(next); void command("weather visible", (tag) => WeatherRuntimeBridge.setWeatherColorVisible(tag, next)); }} /><Button title={`Particle ${particleVisible ? "visible" : "hidden"}`} onPress={() => { const next = !particleVisible; setParticleVisible(next); void command("particle visible", (tag) => WeatherRuntimeBridge.setParticleVisible(tag, next)); }} /></View>
<View style={styles.row}>{realWeatherFrames.map((frame) => <Button key={`seek-${frame.label}`} title={`seek ${frame.label}`} onPress={() => void command(`seek ${frame.label}`, (tag) => WeatherRuntimeBridge.setForecastTime(tag, frame.validTime))} />)}</View>
<View style={styles.row}><Button title={`Weather ${weatherVisible ? "visible" : "hidden"}`} onPress={() => { const next = !weatherVisible; setWeatherVisible(next); void command("weather visible", (tag) => FieldRuntime.setFieldVisible(tag, next)); }} /><Button title={`Particle ${particleVisible ? "visible" : "hidden"}`} onPress={() => { const next = !particleVisible; setParticleVisible(next); void command("particle visible", (tag) => FieldRuntime.setParticleVisible(tag, next)); }} /></View>
<View style={styles.row}>{dataset.frames.map((frame) => <Button key={`seek-${frame.label}`} title={`seek ${frame.label}`} onPress={() => void command(`seek ${frame.label}`, (tag) => FieldRuntime.setTargetTime(tag, frame.validTime))} />)}</View>
<Text>Visual: count / width / length / particle opacity / trail opacity / trail fade / WeatherColor opacity</Text><View style={styles.row}>{(Object.keys(visual) as Array<keyof typeof initialVisual>).map((key) => <TextInput key={key} value={visual[key]} onChangeText={(value) => updateVisual(key, value)} keyboardType="decimal-pad" style={styles.input} />)}<Button title="Apply visual" onPress={applyVisual} /></View>
</ScrollView>
</SafeAreaView>;

View File

@@ -8,8 +8,8 @@
"name": "maplibre-particle-rn-test-host",
"version": "0.1.0",
"dependencies": {
"@maplibre-particle/weather-runtime-bridge": "file:./modules/weather-runtime-bridge",
"@maplibre/maplibre-react-native": "11.3.6",
"@navsea/maplibre-particle-rn": "file:../../../packages/maplibre-particle-rn",
"expo": "56.0.4",
"expo-asset": "~56.0.14",
"expo-file-system": "56.0.11",
@@ -21,11 +21,22 @@
"typescript": "5.9.3"
}
},
"modules/weather-runtime-bridge": {
"name": "@maplibre-particle/weather-runtime-bridge",
"version": "0.1.0",
"../../../packages/maplibre-particle-rn": {
"name": "@navsea/maplibre-particle-rn",
"version": "13.4.1-particle.3",
"license": "BSD-2-Clause",
"devDependencies": {
"@maplibre/maplibre-react-native": "11.3.6"
},
"peerDependencies": {
"react-native": "0.85.2"
"@maplibre/maplibre-react-native": "11.3.6",
"react": ">=19.1.0",
"react-native": ">=0.80.0"
},
"peerDependenciesMeta": {
"expo": {
"optional": true
}
}
},
"node_modules/@babel/code-frame": {
@@ -1718,10 +1729,6 @@
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
"license": "BSD-2-Clause"
},
"node_modules/@maplibre-particle/weather-runtime-bridge": {
"resolved": "modules/weather-runtime-bridge",
"link": true
},
"node_modules/@maplibre/maplibre-gl-style-spec": {
"version": "24.8.5",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.8.5.tgz",
@@ -1772,6 +1779,10 @@
}
}
},
"node_modules/@navsea/maplibre-particle-rn": {
"resolved": "../../../packages/maplibre-particle-rn",
"link": true
},
"node_modules/@react-native/assets-registry": {
"version": "0.85.2",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.85.2.tgz",

View File

@@ -9,7 +9,7 @@
"android": "expo run:android"
},
"dependencies": {
"@maplibre-particle/weather-runtime-bridge": "file:./modules/weather-runtime-bridge",
"@navsea/maplibre-particle-rn": "file:../../../packages/maplibre-particle-rn",
"@maplibre/maplibre-react-native": "11.3.6",
"expo": "56.0.4",
"expo-asset": "~56.0.14",

View File

@@ -0,0 +1,49 @@
import { Asset } from "expo-asset";
import type { FieldPayloadRefs } from "@navsea/maplibre-particle-rn";
export type V3Frame = Readonly<{ label: "T0" | "T1"; metadataJson: string; validTime: number; generation: number; validityModule: number; scalarModule?: number; vectorModule?: number }>;
export type V3Dataset = Readonly<{ id: string; title: string; fieldValueSource: "SCALAR" | "VECTOR_MAGNITUDE"; particle: boolean; advectionScale: number; frames: readonly V3Frame[] }>;
type Metadata = Readonly<{ validTime: number; generation: number }>;
// Static requires keep all binary planes outside JS memory and materialize cache-local file:// URIs only.
function frame(label: "T0" | "T1", metadata: Metadata, validityModule: number, scalarModule?: number, vectorModule?: number): V3Frame {
return { label, metadataJson: JSON.stringify(metadata), validTime: metadata.validTime, generation: metadata.generation, validityModule, scalarModule, vectorModule };
}
function two(id: string, title: string, fieldValueSource: V3Dataset["fieldValueSource"], particle: boolean, advectionScale: number, t0: Omit<V3Frame, "label">, t1: Omit<V3Frame, "label">): V3Dataset {
return { id, title, fieldValueSource, particle, advectionScale, frames: [{ label: "T0", ...t0 }, { label: "T1", ...t1 }] };
}
export const fieldDatasets: readonly V3Dataset[] = [
two("wind-real", "Wind(本地真实 canonical)", "VECTOR_MAGNITUDE", true, 1,
frame("T0", require("../../particle-v3-fields/wind-real/2026080700/metadata.json") as Metadata, require("../../particle-v3-fields/wind-real/2026080700/validity.u8"), undefined, require("../../particle-v3-fields/wind-real/2026080700/vector.f32")),
frame("T1", require("../../particle-v3-fields/wind-real/2026080706/metadata.json") as Metadata, require("../../particle-v3-fields/wind-real/2026080706/validity.u8"), undefined, require("../../particle-v3-fields/wind-real/2026080706/vector.f32"))),
two("rain-decoder-fixture", "Rain(decoder fallback)", "SCALAR", false, 1,
frame("T0", require("../../particle-v3-fields/rain-decoder-fixture/2026090912/metadata.json") as Metadata, require("../../particle-v3-fields/rain-decoder-fixture/2026090912/validity.u8"), require("../../particle-v3-fields/rain-decoder-fixture/2026090912/scalar.f32")),
frame("T1", require("../../particle-v3-fields/rain-decoder-fixture/2026090918/metadata.json") as Metadata, require("../../particle-v3-fields/rain-decoder-fixture/2026090918/validity.u8"), require("../../particle-v3-fields/rain-decoder-fixture/2026090918/scalar.f32"))),
two("wave-real", "Wave(本地真实 WAVEBIN1)", "SCALAR", true, 1.75,
frame("T0", require("../../particle-v3-fields/wave-real/2026090918/metadata.json") as Metadata, require("../../particle-v3-fields/wave-real/2026090918/validity.u8"), require("../../particle-v3-fields/wave-real/2026090918/scalar.f32"), require("../../particle-v3-fields/wave-real/2026090918/vector.f32")),
frame("T1", require("../../particle-v3-fields/wave-real/2026090919/metadata.json") as Metadata, require("../../particle-v3-fields/wave-real/2026090919/validity.u8"), require("../../particle-v3-fields/wave-real/2026090919/scalar.f32"), require("../../particle-v3-fields/wave-real/2026090919/vector.f32"))),
two("tide-real", "Tide/Current(本地真实 BIN)", "VECTOR_MAGNITUDE", true, 1,
frame("T0", require("../../particle-v3-fields/tide-real/2026090901/metadata.json") as Metadata, require("../../particle-v3-fields/tide-real/2026090901/validity.u8"), undefined, require("../../particle-v3-fields/tide-real/2026090901/vector.f32")),
frame("T1", require("../../particle-v3-fields/tide-real/2026090902/metadata.json") as Metadata, require("../../particle-v3-fields/tide-real/2026090902/validity.u8"), undefined, require("../../particle-v3-fields/tide-real/2026090902/vector.f32"))),
two("wave-synthetic-independence", "Wave synthetic independence", "SCALAR", true, 2.5,
frame("T0", require("../../particle-v3-fields/wave-synthetic-independence/2026010100/metadata.json") as Metadata, require("../../particle-v3-fields/wave-synthetic-independence/2026010100/validity.u8"), require("../../particle-v3-fields/wave-synthetic-independence/2026010100/scalar.f32"), require("../../particle-v3-fields/wave-synthetic-independence/2026010100/vector.f32")),
frame("T1", require("../../particle-v3-fields/wave-synthetic-independence/2026010106/metadata.json") as Metadata, require("../../particle-v3-fields/wave-synthetic-independence/2026010106/validity.u8"), require("../../particle-v3-fields/wave-synthetic-independence/2026010106/scalar.f32"), require("../../particle-v3-fields/wave-synthetic-independence/2026010106/vector.f32"))),
];
async function uri(module: number, label: string): Promise<string> {
const asset = Asset.fromModule(module);
await asset.downloadAsync();
if (!asset.localUri?.startsWith("file://")) throw new Error(label + " did not materialize a local file:// URI");
return asset.localUri;
}
export async function materializeV3Frame(frame: V3Frame): Promise<V3Frame & { payloadRefs: FieldPayloadRefs }> {
const [validityFileUri, scalarFileUri, vectorFileUri] = await Promise.all([
uri(frame.validityModule, frame.label + " validity"),
frame.scalarModule ? uri(frame.scalarModule, frame.label + " scalar") : Promise.resolve(undefined),
frame.vectorModule ? uri(frame.vectorModule, frame.label + " vector") : Promise.resolve(undefined),
]);
if (scalarFileUri) return { ...frame, payloadRefs: vectorFileUri ? { validityFileUri, scalarFileUri, vectorFileUri } : { validityFileUri, scalarFileUri } };
if (vectorFileUri) return { ...frame, payloadRefs: { validityFileUri, vectorFileUri } };
throw new Error(frame.label + " has neither scalar nor vector plane");
}

View File

@@ -1,5 +1,5 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": { "strict": true, "noEmit": true },
"compilerOptions": { "strict": true, "noEmit": true, "preserveSymlinks": true },
"include": ["App.tsx", "src", "modules/weather-runtime-bridge/src"]
}

View File

@@ -357,6 +357,26 @@ TEST(Source, ParticleFieldV3V2DecodesAllSupportedChannelCombinations) {
EXPECT_TRUE(combined.snapshot->hasVector());
}
TEST(Source, ParticleFieldV3BusinessFixtureShapesRemainProviderNeutral) {
// These are the exact four offline-business shapes used by STEP-070 fixtures:
// Wind/Tide are velocity vectors, Rain is scalar-only, and Wave is scalar+direction.
const std::vector<char> valid{1, 1};
const auto wind = particle::LocalParticleFieldFeed::load(v2Metadata(false, true, "VELOCITY"),
v2Payload(std::nullopt, floatBytes({1, 2, 3, 4}), valid));
const auto tide = particle::LocalParticleFieldFeed::load(v2Metadata(false, true, "VELOCITY"),
v2Payload(std::nullopt, floatBytes({0.1f, 0.2f, 0.3f, 0.4f}), valid));
const auto rain = particle::LocalParticleFieldFeed::load(v2Metadata(true, false),
v2Payload(floatBytes({1, 2}), std::nullopt, valid));
const auto wave = particle::LocalParticleFieldFeed::load(v2Metadata(true, true, "DIRECTION"),
v2Payload(floatBytes({1, 9}), floatBytes({0, 1, 0, 1}), valid));
ASSERT_TRUE(wind) << wind.message; ASSERT_TRUE(tide) << tide.message;
ASSERT_TRUE(rain) << rain.message; ASSERT_TRUE(wave) << wave.message;
EXPECT_EQ(wave.snapshot->vectorSemantics, particle::VectorSemantics::Direction);
EXPECT_FALSE(rain.snapshot->hasVector());
EXPECT_TRUE(wind.snapshot->isTimeInterpolationCompatibleWith(*tide.snapshot));
EXPECT_FALSE(wind.snapshot->isTimeInterpolationCompatibleWith(*wave.snapshot));
}
TEST(Source, ParticleFieldV3V2RejectsInvalidPlaneAndSemanticsContracts) {
const std::vector<char> valid{1, 1};
auto neither = particle::LocalParticleFieldFeed::load(v2Metadata(false, false), v2Payload(std::nullopt, std::nullopt, valid));

View File

@@ -0,0 +1,7 @@
# V3 通用场离线 Fixture 工具
`generate_fixtures.py` 只生成和校验 `maplibre-particle-field/v2` 离线资产;它不属于 Runtime,不联网、不修改 WeatherV2,也不把 provider 名称带入 Core。
`--write-reference-fixtures` 会生成 Wind canonical v1 升级、WeatherV2 正式 BIN 的 Wave/Tide 真实来源 fixture,以及明确标为 `decoder-fixture` 的 Rain 小型离线回退 fixture。Wave 取 `height_m` 与规范化后的 `unit_to_u/unit_to_v`;`period_s` 不进入 canonical。回退 Rain 的数值仅用于 decoder-contract 回归,不能宣称为实时或真实 WeatherV2 栅格。`--validate` 会校验 SHA-256、长度、严格单调 generation、每个有效格的所有存在 plane 均 finite 且不等于 noData、方向单位向量,并执行 synthetic Wave scalar/vector independence oracle。
所有 float plane 均为 float32 little-endian;行是北到南、列是西到东,向量为 U 向东 / V 向北。

View File

@@ -0,0 +1,238 @@
#!/usr/bin/env python3
"""Generate and validate offline provider-neutral V3 canonical fixtures."""
from __future__ import annotations
import argparse
import hashlib
import json
import math
import shutil
import struct
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
OUT = ROOT / "integration/particle-v3-fields"
WIND = ROOT / "platform/android/MapLibreAndroidTestApp/src/main/assets/integration/particle-weatherv2/ecmwf-hres-wind-20260807"
DATASETS = Path("/root/sourceserver/weatherV2/src/dev/_runtime/dev_pipeline_data/datasets")
WAVE = DATASETS / "ecmwf-wam/waves/2026090912/wave_bin"
TIDE = DATASETS / "cmems/tide/2026090900/current_bin"
NODATA = -9999.0
def sha(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def valid_time(value: str) -> int:
return int(datetime.strptime(value, "%Y%m%d%H").replace(tzinfo=timezone.utc).timestamp() * 1000)
def write(path: Path, data: bytes) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
def float_bytes(values: list[float]) -> bytes:
return struct.pack("<%sf" % len(values), *values)
def meta(field: str, target: str, generation: int, width: int, height: int, bounds: dict,
scalar: bool, vector: bool, semantics: str | None, source: dict) -> dict:
output = {"schema": "maplibre-particle-field/v2", "fieldId": field, "generation": generation,
"dataVersion": generation, "validTime": valid_time(target), "bounds": bounds,
"grid": {"width": width, "height": height},
"validity": {"encoding": "uint8", "byteLength": width * height}, "source": source,
"canonical": {"origin": "north-west", "rowOrder": "north-to-south", "columnOrder": "west-to-east"}}
if scalar:
output["scalar"] = {"encoding": "float32-le", "byteLength": width * height * 4, "noData": NODATA}
if vector:
output["vector"] = {"layout": "interleaved-uv", "encoding": "float32-le", "byteLength": width * height * 8,
"noData": NODATA, "semantics": semantics, "uPositive": "east", "vPositive": "north"}
return output
def finish(directory: Path, metadata: dict, validity: list[int], scalar: list[float] | None = None,
vector: list[float] | None = None) -> None:
write(directory / "validity.u8", bytes(validity))
if scalar is not None:
write(directory / "scalar.f32", float_bytes(scalar))
if vector is not None:
write(directory / "vector.f32", float_bytes(vector))
filenames = ["validity.u8"] + (["scalar.f32"] if scalar is not None else []) + (["vector.f32"] if vector is not None else [])
metadata["planes"] = {name: {"path": name, "byteLength": (directory / name).stat().st_size,
"sha256": sha(directory / name)} for name in filenames}
(directory / "metadata.json").write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + "\n")
(directory / "conversion_summary.json").write_text(json.dumps({
"grid": metadata["grid"], "bounds": metadata["bounds"], "validTime": metadata["validTime"],
"generation": metadata["generation"], "source": metadata["source"], "planes": metadata["planes"],
"validCount": sum(validity), "invalidCount": len(validity) - sum(validity)}, ensure_ascii=False, indent=2) + "\n")
def frame(label: str, target: str, scalar: bool, vector: bool) -> dict:
return {"label": label, "path": target, "metadata": "metadata.json", "validity": "validity.u8",
**({"scalar": "scalar.f32"} if scalar else {}), **({"vector": "vector.f32"} if vector else {})}
def manifest(name: str, provenance: str, field_source: str, particle: bool, semantics: str | None,
scale: float, frames: list[dict]) -> None:
(OUT / name / "dataset.json").write_text(json.dumps({
"schema": "maplibre-particle-test-dataset/v2", "datasetId": name, "sourceProvenance": provenance,
"frames": frames, "expectedFieldValueSource": field_source, "particleExpected": particle,
"vectorSemantics": semantics, "suggestedAdvectionScale": scale,
"说明": "离线 canonical fixture;Runtime 只接收 metadata 与本地 plane URI。"}, ensure_ascii=False, indent=2) + "\n")
def read_bin(path: Path, magic: bytes, float_planes: int) -> tuple[dict, list[list[float]], list[int]]:
raw = path.read_bytes()
assert raw[:len(magic)] == magic, f"{path}: magic"
header_length = struct.unpack_from("<I", raw, len(magic))[0]
start = len(magic) + 4
header = json.loads(raw[start:start + header_length])
sidecar = json.loads(Path(str(path) + ".header.json").read_text())
assert header == sidecar, f"{path}: header sidecar mismatch"
count, offset = header["width"] * header["height"], start + header_length
planes = []
for _ in range(float_planes):
planes.append(list(struct.unpack_from("<%sf" % count, raw, offset)))
offset += count * 4
validity = list(raw[offset:offset + count])
assert len(validity) == count and offset + count == len(raw), f"{path}: payload length"
return header, planes, validity
def provenance(path: Path, header: dict, reference_time: str) -> dict:
sidecar = Path(str(path) + ".header.json")
return {"kind": "WeatherV2 formal BIN decoded offline", "sourcePath": str(path), "sourceSha256": sha(path),
"headerPath": str(sidecar), "headerSha256": sha(sidecar), "referenceTime": reference_time,
"targetTime": header["target_time"], "grid": {"width": header["width"], "height": header["height"]},
"bounds": header["bounds"], "binMagic": header["magic"], "binFields": header["fields"]}
def generate_wind() -> None:
frames = []
for index, target in enumerate(["2026080700", "2026080706"]):
old = json.loads((WIND / target / "metadata.json").read_text())
count = old["grid"]["width"] * old["grid"]["height"]
vector = list(struct.unpack("<%sf" % (count * 2), (WIND / target / "field.f32").read_bytes()))
validity = [int(math.isfinite(vector[2 * i]) and math.isfinite(vector[2 * i + 1]) and vector[2 * i] != NODATA and vector[2 * i + 1] != NODATA) for i in range(count)]
assert any(validity), f"{target}: empty wind"
source = {"kind": "WeatherV2 ECMWF HRES WVECBIN1 canonical v1 upgraded offline", "referenceTime": "2026080600",
"targetTime": target, "originalPayloadSha256": sha(WIND / target / "field.f32"),
"validityDerivation": "finite U/V and both components != -9999.0"}
finish(OUT / "wind-real" / target, meta("wind-real", target, old["generation"], old["grid"]["width"], old["grid"]["height"], old["bounds"], False, True, "VELOCITY", source), validity, vector=vector)
frames.append(frame(f"T{index}", target, False, True))
manifest("wind-real", "真实本地 WeatherV2 Wind canonical v1 输出;validity 由 U/V noData 与有限性派生", "VECTOR_MAGNITUDE", True, "VELOCITY", 1.0, frames)
def generate_wave() -> None:
frames = []
for generation, target in enumerate(["2026090918", "2026090919"], start=1):
path = WAVE / target / "waves_particles.bin"
header, planes, source_validity = read_bin(path, b"WAVEBIN1", 5)
height_m, _period_s, _direction_from, unit_u, unit_v = planes
validity, scalar, vector = [], [], []
for raw_valid, height, u, v in zip(source_validity, height_m, unit_u, unit_v):
length = math.hypot(u, v)
valid = bool(raw_valid) and math.isfinite(height) and math.isfinite(u) and math.isfinite(v) and length > 1e-5
validity.append(int(valid)); scalar.append(height if valid else NODATA)
vector.extend((u / length, v / length) if valid else (NODATA, NODATA))
assert any(validity), f"{path}: empty wave"
finish(OUT / "wave-real" / target, meta("wave-real", target, generation, header["width"], header["height"], header["bounds"], True, True, "DIRECTION", provenance(path, header, "2026090912")), validity, scalar, vector)
frames.append(frame(f"T{generation - 1}", target, True, True))
manifest("wave-real", "真实本地 WeatherV2 WAVEBIN1;height_m + 正规化 unit_to_u/v,period 不进入 canonical", "SCALAR", True, "DIRECTION", 1.75, frames)
def generate_tide() -> None:
frames = []
for generation, target in enumerate(["2026090901", "2026090902"], start=1):
path = TIDE / target / "current_vectors.bin"
header, planes, source_validity = read_bin(path, b"WVECBIN1", 3)
u_values, v_values, _speed = planes
validity, vector = [], []
for raw_valid, u, v in zip(source_validity, u_values, v_values):
valid = bool(raw_valid) and math.isfinite(u) and math.isfinite(v)
validity.append(int(valid)); vector.extend((u, v) if valid else (NODATA, NODATA))
assert any(validity), f"{path}: empty tide"
finish(OUT / "tide-real" / target, meta("tide-real", target, generation, header["width"], header["height"], header["bounds"], False, True, "VELOCITY", provenance(path, header, "2026090900")), validity, vector=vector)
frames.append(frame(f"T{generation - 1}", target, False, True))
manifest("tide-real", "真实本地 WeatherV2 CMEMS WVECBIN1;仅 U/V VELOCITY", "VECTOR_MAGNITUDE", True, "VELOCITY", 1.0, frames)
def generate_rain() -> None:
frames, width, height = [], 6, 4
bounds = {"west": 122.0, "south": 20.0, "east": 150.0, "north": 48.0}
for generation, target in enumerate(["2026090912", "2026090918"], start=1):
values = [float((i % width) + generation) for i in range(width * height)]
source = {"kind": "decoder-contract fallback", "notReal": True, "referenceTime": "2026090912", "targetTime": target,
"说明": "本机未实现 PMTiles reader;此数值不是正式 WeatherV2 栅格。"}
finish(OUT / "rain-decoder-fixture" / target, meta("rain-decoder-fixture", target, generation, width, height, bounds, True, False, None, source), [1] * (width * height), scalar=values)
frames.append(frame(f"T{generation - 1}", target, True, False))
manifest("rain-decoder-fixture", "非真实场值:仅 decoder-contract fallback,不能当作 WeatherV2 实测栅格", "SCALAR", False, None, 1.0, frames)
def generate_synthetic_wave() -> None:
name, width, height, frames = "wave-synthetic-independence", 6, 4, []
bounds = {"west": 122.0, "south": 20.0, "east": 150.0, "north": 48.0}
for generation, target in enumerate(["2026010100", "2026010106"], start=1):
scalar = [float((i % width) + generation) for i in range(width * height)]
vector = [value for _ in range(width * height) for value in (0.0, 1.0)]
finish(OUT / name / target, meta(name, target, generation, width, height, bounds, True, True, "DIRECTION", {"kind": "synthetic independence oracle", "notReal": True, "targetTime": target}), [1] * (width * height), scalar, vector)
frames.append(frame(f"T{generation - 1}", target, True, True))
manifest(name, "固定 synthetic;height 与 direction 独立", "SCALAR", True, "DIRECTION", 2.5, frames)
def generate() -> None:
if OUT.exists():
shutil.rmtree(OUT)
generate_wind(); generate_rain(); generate_wave(); generate_tide(); generate_synthetic_wave()
def validate() -> None:
for dataset in OUT.glob("*/dataset.json"):
frames = json.loads(dataset.read_text())["frames"]
generations = []
for spec in frames:
directory = dataset.parent / spec["path"]
metadata = json.loads((directory / "metadata.json").read_text())
generations.append(metadata["generation"])
count = metadata["grid"]["width"] * metadata["grid"]["height"]
validity_path = directory / "validity.u8"
validity = validity_path.read_bytes()
assert len(validity) == count and any(validity), f"{directory}: validity"
assert validity_path.stat().st_size == metadata["planes"]["validity.u8"]["byteLength"]
assert sha(validity_path) == metadata["planes"]["validity.u8"]["sha256"]
decoded = {}
for key, filename, channels in (("scalar", "scalar.f32", 1), ("vector", "vector.f32", 2)):
if key in spec:
path = directory / filename
assert path.stat().st_size == count * channels * 4 and sha(path) == metadata["planes"][filename]["sha256"]
decoded[key] = struct.unpack("<%sf" % (count * channels), path.read_bytes())
for cell, flag in enumerate(validity):
if not flag:
continue
if "scalar" in decoded:
value = decoded["scalar"][cell]
assert math.isfinite(value) and value != metadata["scalar"]["noData"], f"{directory}: scalar valid cell {cell}"
if "vector" in decoded:
u, v = decoded["vector"][2 * cell:2 * cell + 2]
assert math.isfinite(u) and math.isfinite(v) and u != metadata["vector"]["noData"] and v != metadata["vector"]["noData"], f"{directory}: vector valid cell {cell}"
if metadata["vector"]["semantics"] == "DIRECTION":
assert abs(math.hypot(u, v) - 1) < 2e-4, f"{directory}: direction valid cell {cell}"
assert all(current > previous for previous, current in zip(generations, generations[1:])), f"{dataset}: non-monotonic generation"
root = OUT / "wave-synthetic-independence" / "2026010100"
scalar, vector = struct.unpack("<24f", (root / "scalar.f32").read_bytes()), struct.unpack("<48f", (root / "vector.f32").read_bytes())
assert all(scalar[row * 6] < scalar[row * 6 + 5] for row in range(4))
assert all(vector[cell * 2] == 0 and vector[cell * 2 + 1] == 1 for cell in range(24))
print("PASS: canonical fixtures, valid-cell planes, direction normalization, and wave scalar/vector independence")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--write-reference-fixtures", action="store_true")
parser.add_argument("--validate", action="store_true")
args = parser.parse_args()
if args.write_reference_fixtures:
generate()
if args.validate:
validate()

View File

@@ -661,3 +661,9 @@ WeatherColorLayer 的 `ParticleGpuValidationUBO` designated initializer 补齐 `
# 2026-09-11 STEP-069 Git:主实现 `148bf80fe41ba566de6c1d4b439cfe372a8a453c`(`feat: add v2 multi-plane field ingress`)已完成 scoped commit;最终独立复验确认 RN codegen、TypeScript、Linux 重链与 STEP-069 两个 Core 过滤测试 2/2 PASS。STEP-062 用户修改与历史无关文件未纳入;Android Gradle/AAR/APK/ADB/Jest 仍未执行。
# 2026-09-11 STEP-069 首轮推送:implementation `148bf80fe41ba566de6c1d4b439cfe372a8a453c` + handoff `12827489f4c377175554562632129df125c0a556` 已成功 push 到 `origin/particle/main`,首轮 push 后本地与远端一致。随后仅回填 Git 结果形成 metadata 收尾,不改变功能代码。
# 2026-09-11 STEP-070 V3-06
- 离线 v2 fixture validator 现逐 valid cell 检查所有存在 plane 的 finite/noData;Wind shared validity 严格来自 U/V 同时 finite 且均非 `-9999`。
- Wave 已由 WeatherV2 `2026090912` 正式 `waves_particles.bin`(T0=2026090918、T1=2026090919)直转为 height scalar + 归一化 DIRECTION vector;Tide 已由 CMEMS `2026090900` 正式 `current_vectors.bin`(T0=2026090901、T1=2026090902)直转 VELOCITY vector。每帧保留 BIN/sidecar SHA-256、target_time、grid/bounds/provenance;Wave period 不进入 canonical。
- Rain 仍为明确“非真实场值”的 PMTiles decoder fallback,generation=1/2;RN Host 已将 Wave/Tide 指向 real fixture,并继续只用 `.3` `FieldRuntime` 公共 API。
- fixture validator、package tsc、Host typecheck、RN codegen 与 `/tmp/step070-ccache` 精确 Core object PASS。`mbgl-test/runner` 重链前 target clean 扩大为完整依赖闭包,受限终端会话未返回退出码,相关过滤测试未运行;两项均未写 PASS。Android Gradle/AAR/APK/ADB/Jest 均未运行,STEP-070 保持 IMPLEMENTED / WAITING_ANDROID_VALIDATION。