feat: add V4 QBIN golden fixture test host

This commit is contained in:
OpenAI Codex
2026-09-13 20:05:59 +08:00
parent a83ab9cc94
commit f1ea88b0ce
13 changed files with 223 additions and 2 deletions

View File

@@ -3,6 +3,7 @@ import { Button, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, View, fi
import { Camera, Map } from "@maplibre/maplibre-react-native";
import { FieldRuntime, type FieldColorStop, type RuntimeSnapshot, type TargetFps } from "@navsea/maplibre-particle-rn";
import { fieldDatasets, materializeV3Frame, type V3Dataset } from "./src/fieldDatasets";
import { materializeV4Frame, qbinGoldenDatasets, type V4Dataset } from "./src/qbinGoldenFixtures";
import { weatherTestStyle } from "./src/weatherStyle";
const initialSnapshot: RuntimeSnapshot = { state: "WAITING_FOR_FIELDS", playbackState: "PAUSE", targetFps: "AUTO", disposed: false };
@@ -20,6 +21,10 @@ export default function App() {
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 [v4Dataset, setV4Dataset] = useState<V4Dataset>(qbinGoldenDatasets[0]);
const [v4Frames, setV4Frames] = useState<Record<"T0" | "T1", string>>({ T0: "PENDING", T1: "PENDING" });
const [v4Bindings, setV4Bindings] = useState("PENDING");
const [v4Transport, setV4Transport] = useState("PENDING: raw QBIN bundled asset → local file:// URI");
const [weatherVisible, setWeatherVisible] = useState(true);
const [particleVisible, setParticleVisible] = useState(true);
const [visual, setVisual] = useState(initialVisual);
@@ -60,6 +65,36 @@ export default function App() {
} catch (error) { setTransport(`ERROR: ${String(error)}`); setResult(`LOAD ${dataset.id}: ${String(error)}`); }
}, [reactTag, dataset]);
const loadV4Dataset = useCallback(async () => {
if (reactTag == null) return setResult("MAP_NOT_READY: wait for GSI/style ready");
try {
setV4Transport("LOADING: clear → binding → T0 → T1 → midpoint");
setV4Frames({ T0: "PENDING", T1: "PENDING" });
setV4Bindings("PENDING");
const clear = await FieldRuntime.clearAllEncodedFields(reactTag);
if (clear !== "ACCEPTED") throw new Error(`clearAllEncodedFields expected ACCEPTED, got ${clear}`);
const color = await FieldRuntime.setEncodedColorBinding(reactTag, v4Dataset.colorBinding);
if (color !== "ACCEPTED") throw new Error(`ColorBinding expected ACCEPTED, got ${color}`);
const particle = v4Dataset.particleBinding == null ? "N/A" : await FieldRuntime.setEncodedParticleBinding(reactTag, v4Dataset.particleBinding);
if (particle !== "N/A" && particle !== "ACCEPTED") throw new Error(`ParticleBinding expected ACCEPTED, got ${particle}`);
setV4Bindings(`clearAll=${clear}; color=${color}; particle=${particle}`);
const localFrames = await Promise.all(v4Dataset.frames.map(materializeV4Frame));
for (const frame of localFrames) {
const submit = await FieldRuntime.submitEncodedField(reactTag, v4Dataset.fieldKey, frame.metadata, v4Dataset.profileContract, frame.qbinFileUri);
setV4Frames((current) => ({ ...current, [frame.label]: submit }));
if (submit !== "ACCEPTED_INSERTED") throw new Error(`${frame.label} expected ACCEPTED_INSERTED, got ${submit}`);
}
const midpoint = await FieldRuntime.setEncodedForecastTime(reactTag, v4Dataset.midpointMs);
if (midpoint !== "ACCEPTED") throw new Error(`midpoint seek expected ACCEPTED, got ${midpoint}`);
await refresh(reactTag);
setV4Transport("READY: T0/T1 ACCEPTED_INSERTED; midpoint command ACCEPTED (mix=0.5 is not exposed)");
setResult(`${v4Dataset.title}: V4 raw QBIN load accepted`);
} catch (error) {
setV4Transport(`ERROR: ${String(error)}`);
setResult(`LOAD V4 ${v4Dataset.id}: ${String(error)}`);
}
}, [reactTag, refresh, v4Dataset]);
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([
@@ -83,6 +118,11 @@ export default function App() {
<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>
<Text>V4 QBIN Golden: {v4Dataset.title}</Text><Text selectable>V4 raw QBIN transport = {v4Transport}</Text><Text selectable>V4 bindings: {v4Bindings}</Text><Text selectable>{v4Dataset.particleNote}</Text>
<Text selectable>V4 T0: {v4Frames.T0}</Text><Text selectable>V4 T1: {v4Frames.T1}</Text>
<View style={styles.row}>{qbinGoldenDatasets.map((item) => <Button key={item.id} title={`V4 ${item.id}`} onPress={() => setV4Dataset(item)} />)}</View><Button title="LOAD SELECTED V4 DATASET" onPress={() => void loadV4Dataset()} />
<View style={styles.row}><Button title="V4 seek T0" onPress={() => void command("V4 seek T0", (tag) => FieldRuntime.setEncodedForecastTime(tag, v4Dataset.frames[0].validTimeMs))} /><Button title="V4 seek midpoint" onPress={() => void command("V4 seek midpoint (mix not exposed)", (tag) => FieldRuntime.setEncodedForecastTime(tag, v4Dataset.midpointMs))} /><Button title="V4 seek T1" onPress={() => void command("V4 seek T1", (tag) => FieldRuntime.setEncodedForecastTime(tag, v4Dataset.frames[1].validTimeMs))} /></View>
<View style={styles.row}><Button title="V4 PLAY" onPress={() => void command("V4 PLAY", (tag) => FieldRuntime.setEncodedForecastPlaybackState(tag, "PLAY"))} /><Button title="V4 PAUSE" onPress={() => void command("V4 PAUSE", (tag) => FieldRuntime.setEncodedForecastPlaybackState(tag, "PAUSE"))} /><Button title="V4 speed 1.0" onPress={() => void command("V4 speed 1.0", (tag) => FieldRuntime.setEncodedForecastPlaybackSpeed(tag, 1.0))} /><Button title="V4 clear selected" onPress={() => void command("V4 clear selected", (tag) => FieldRuntime.clearEncodedField(tag, v4Dataset.fieldKey))} /><Button title="V4 clear all" onPress={() => void command("V4 clear all", (tag) => FieldRuntime.clearAllEncodedFields(tag))} /></View>
</ScrollView>
</SafeAreaView>;
}

View File

@@ -2,12 +2,24 @@
这是 MapLibreParticleLab 内的独立 React Native 开发宿主,不是 NavSea 业务应用。它只有一个 `MLRNMapView` 与 WeatherRuntime 控制页,用于证明低频控制面:React 的 `reactTag` 经薄 Android 模块定位该 View,再定位该 View 的 `MapLibreMap` 与 map-owned `WeatherRuntime`。
## STEP-079 V4 QBIN Golden Fixture
`assets/qbin-golden/` 固化了 WeatherV2 的 8 个未压缩 QBIN V2 fixture:Wind、Wave、Current、Rain 各 T0/T1。它们从冻结来源 `/root/sourceserver/weatherV2/_artifacts/MapLibre_QBIN_V2_单元测试数据_v1/` 复制而来;运行时不依赖该来源、HTTP、CDN、NAS 或 gzip。每个 asset 经 `expo-asset` materialize 为本地 `file://` URI,再仅通过 `FieldRuntime.submitEncodedField(...)` 提交。
`src/qbinGoldenFixtures.ts` 保留 profile contract、稳定 field identity、validTime、size、SHA-256 和真实 binding。Wind/Current 的 ColorBinding 是 U `VELOCITY_COMPONENT`(semantic 1/group 1/unit 1),不是伪造 `SCALAR`;Wave 是 height `SCALAR` 与 direction particle;Rain 的 particle 明确为 N/A。页面保留全部 V3 控制,并新增独立 V4 dataset/load、T0/T1 结果、binding、seek、PLAY/PAUSE、正速度和 clear 控制。midpoint 只报告 command accepted,RN API 未暴露 mix,不能据此宣称观测到 0.5。
开发机可用以下只读校验核验 source 与 bundled 文件的 bytes/SHA-256(不属于运行时依赖):
```bash
python3 integration/react-native/MapLibreParticleRNTestHost/scripts/verify-qbin-golden-fixtures.py
```
## 当前 V3-08 版本与首次 prebuild 边界
- 当前验收目标是 RN/TS `13.4.1-particle.3` 配合 Native `cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.2`。`.2` 是 STEP-069 v2 multi-plane 新 artifact;不得复用或覆盖 `.1`。
- 本地 legacy `@maplibre-particle/weather-runtime-bridge` 已移除;`@navsea/maplibre-particle-rn@13.4.1-particle.3` 是唯一的 `WeatherRuntimeBridge` 提供者。
- 必须先由用户手工构建并 Maven Local publish Native `.2`,再对 Test Host 执行 clean prebuild、build、install 与启动。当前仅完成坐标准备,AAR 尚未构建、发布或验收。
- Metro 当前 binary asset extension 为 `.f32` 与 `.u8`。
- Metro 当前 binary asset extension 为 `.f32`、`.u8` 与 `.qbin`。
- package manager 为 npm(`packageManager: npm@10.9.4`),锁文件为 `package-lock.json`;当前环境的 `npm ci --dry-run --ignore-scripts` 无须 `--legacy-peer-deps` 即可解析。
- package 明确固定 React Native `0.85.2`、React `19.2.3`、Expo `56.0.4`、`expo-file-system` `56.0.11`、TypeScript `5.9.3`、MLRN `11.3.6`;`app.json` 明确请求 New Architecture。Hermes 的最终启用状态必须在首次 Android prebuild 后检查生成工程。

View File

@@ -1,6 +1,6 @@
const { getDefaultConfig } = require("expo/metro-config");
const config = getDefaultConfig(__dirname);
config.resolver.assetExts = [...new Set([...config.resolver.assetExts, "f32", "u8"])];
config.resolver.assetExts = [...new Set([...config.resolver.assetExts, "f32", "u8", "qbin"])];
module.exports = config;

View File

@@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""Fail closed on the frozen WeatherV2 QBIN manifest and its bundled raw copies."""
import hashlib
import json
import sys
from pathlib import Path
REPOSITORY = Path(__file__).resolve().parents[4]
SOURCE = Path("/root/sourceserver/weatherV2/_artifacts/MapLibre_QBIN_V2_单元测试数据_v1")
BUNDLED = REPOSITORY / "integration/react-native/MapLibreParticleRNTestHost/assets/qbin-golden"
DESTINATION_LAYER = {"wind": "wind", "wave": "wave", "tide": "current", "rain": "rain"}
def digest(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def main() -> int:
manifest = json.loads((SOURCE / "清单.json").read_text(encoding="utf-8"))
checked = 0
for source_layer, destination_layer in DESTINATION_LAYER.items():
frames = manifest["layers"][source_layer]["frames"]
if len(frames) != 2:
raise RuntimeError(f"{source_layer}: expected exactly two frozen frames")
for index, frame in enumerate(frames):
label = "T0" if index == 0 else "T1"
source = SOURCE / source_layer / frame["file"]
bundled = BUNDLED / destination_layer / f"{label}.qbin"
expected_bytes = frame["raw_bytes"]
expected_sha256 = frame["sha256"]
for name, path in (("source", source), ("bundled", bundled)):
if not path.is_file() or path.stat().st_size != expected_bytes or digest(path) != expected_sha256:
raise RuntimeError(f"{source_layer}/{frame['target_time']} {name} does not match frozen raw QBIN manifest")
checked += 1
if checked != 8:
raise RuntimeError(f"expected eight frames, checked {checked}")
print("QBIN_FIXTURE_SOURCE_AND_COPY=PASS (8 raw files)")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,126 @@
import { Asset } from "expo-asset";
export type V4CommandResult = "ACCEPTED" | "REJECTED";
type RequiredPlane = Readonly<{
semanticID: number;
valueClass: "SCALAR" | "VELOCITY_COMPONENT" | "DIRECTION_ANGLE" | "VALIDITY";
unitID: number;
groupID: number;
component: "NONE" | "U_EAST" | "V_NORTH" | "W_UP";
requiredFlag: boolean;
}>;
export type V4Dataset = Readonly<{
id: "wind" | "wave" | "current" | "rain";
title: string;
fieldKey: string;
profileContract: Readonly<{
contractSchemaVersion: 1;
profileID: string;
profileVersion: 1;
allowAdditionalPlanes: boolean;
sharedValidityRequired: boolean;
timeRelation: "VALID_GTE_REFERENCE";
requiredPlanes: readonly RequiredPlane[];
}>;
frames: readonly V4Frame[];
midpointMs: number;
colorBinding: Readonly<{
fieldKey: string;
semanticID: number;
groupID: number;
expectedValueClass: "SCALAR" | "VELOCITY_COMPONENT" | "DIRECTION_ANGLE";
expectedUnitID: number;
}>;
particleBinding?: Readonly<{
fieldKey: string;
mode: "VECTOR_COMPONENTS" | "DIRECTION_ANGLE";
uPlane?: Readonly<{ semanticID: number; groupID: number }>;
vPlane?: Readonly<{ semanticID: number; groupID: number }>;
directionPlane?: Readonly<{ semanticID: number; groupID: number }>;
expectedUnitID: number;
}>;
particleNote: string;
}>;
export type V4Frame = Readonly<{
label: "T0" | "T1";
validTimeMs: number;
metadata: Readonly<{ fieldID: string; generation: number; dataVersion: number }>;
assetModule: number;
sourceRelativePath: string;
bytes: number;
sha256: string;
}>;
const sharedValidity: RequiredPlane = {
semanticID: 3, valueClass: "VALIDITY", unitID: 0, groupID: 0, component: "NONE", requiredFlag: true,
};
const vectorPlanes: readonly RequiredPlane[] = [
{ semanticID: 1, valueClass: "VELOCITY_COMPONENT", unitID: 1, groupID: 1, component: "U_EAST", requiredFlag: true },
{ semanticID: 2, valueClass: "VELOCITY_COMPONENT", unitID: 1, groupID: 1, component: "V_NORTH", requiredFlag: true },
sharedValidity,
];
const wavePlanes: readonly RequiredPlane[] = [
{ semanticID: 10, valueClass: "DIRECTION_ANGLE", unitID: 4, groupID: 0, component: "NONE", requiredFlag: true },
{ semanticID: 20, valueClass: "SCALAR", unitID: 2, groupID: 0, component: "NONE", requiredFlag: true },
{ semanticID: 21, valueClass: "SCALAR", unitID: 3, groupID: 0, component: "NONE", requiredFlag: true },
sharedValidity,
];
const rainPlanes: readonly RequiredPlane[] = [
{ semanticID: 30, valueClass: "SCALAR", unitID: 5, groupID: 0, component: "NONE", requiredFlag: true },
sharedValidity,
];
function contract(profileID: string, requiredPlanes: readonly RequiredPlane[]) {
return {
contractSchemaVersion: 1 as const,
profileID,
profileVersion: 1 as const,
allowAdditionalPlanes: true,
sharedValidityRequired: true,
timeRelation: "VALID_GTE_REFERENCE" as const,
requiredPlanes,
};
}
function frame(label: "T0" | "T1", fieldID: string, validTimeMs: number, assetModule: number, sourceRelativePath: string, bytes: number, sha256: string): V4Frame {
const revision = label === "T0" ? 1 : 2;
return { label, validTimeMs, metadata: { fieldID, generation: revision, dataVersion: revision }, assetModule, sourceRelativePath, bytes, sha256 };
}
const windFrames = [
frame("T0", "fixture-wind", 1789239600000, require("../assets/qbin-golden/wind/T0.qbin"), "wind/frames/2026091219.qbin", 1627552, "aca8408cc8f723fe8e92ae6ce7695edcb3482167d1eb00d516812f97a70f6371"),
frame("T1", "fixture-wind", 1789243200000, require("../assets/qbin-golden/wind/T1.qbin"), "wind/frames/2026091220.qbin", 1627552, "cb3b46c5857ba258a7e0804c9fed5c76f481e567b88131c2d98cfe9c5e7d5adb"),
] as const;
const waveFrames = [
frame("T0", "fixture-wave", 1789239600000, require("../assets/qbin-golden/wave/T0.qbin"), "wave/frames/2026091219.qbin", 2416592, "ffe43ce36529602bbfbabc0c15742502aadf4277fa0201cfa93d90327fb3bad3"),
frame("T1", "fixture-wave", 1789243200000, require("../assets/qbin-golden/wave/T1.qbin"), "wave/frames/2026091220.qbin", 2416592, "28463578cab1dbf9b30949ae9ea1780441c9b396e63a8a1ebd0a13090c222a8d"),
] as const;
const currentFrames = [
frame("T0", "fixture-current", 1789174800000, require("../assets/qbin-golden/current/T0.qbin"), "tide/frames/2026091201.qbin", 1085824, "20bf1f261550ce160b533e6929db66b95d287e8f93b10b3ce9cedb6d8cd37b9f"),
frame("T1", "fixture-current", 1789178400000, require("../assets/qbin-golden/current/T1.qbin"), "tide/frames/2026091202.qbin", 1085824, "849c3c451dd6b6b810aa519e2c5dedead6ce2804b84c05b0fb6fd03c737eb30a"),
] as const;
const rainFrames = [
frame("T0", "fixture-rain", 1789239600000, require("../assets/qbin-golden/rain/T0.qbin"), "rain/frames/2026091219.qbin", 559440, "c25b6a967af030f7cef9ea790d692a7cf298be4cb5691aaa453dc753c5f2e237"),
frame("T1", "fixture-rain", 1789243200000, require("../assets/qbin-golden/rain/T1.qbin"), "rain/frames/2026091220.qbin", 559440, "bc030558e9304cd3a883cedbb15fbbe036508669491a7894c8b6f8aef7a35793"),
] as const;
const vectorColor = (fieldKey: string) => ({ fieldKey, semanticID: 1, groupID: 1, expectedValueClass: "VELOCITY_COMPONENT" as const, expectedUnitID: 1 });
const vectorParticle = (fieldKey: string) => ({ fieldKey, mode: "VECTOR_COMPONENTS" as const, uPlane: { semanticID: 1, groupID: 1 }, vPlane: { semanticID: 2, groupID: 1 }, expectedUnitID: 1 });
/** Frozen WeatherV2 QBIN V2 manifest projected into the frozen V4 public contract. */
export const qbinGoldenDatasets: readonly V4Dataset[] = [
{ id: "wind", title: "Wind QBIN Golden (wind/1)", fieldKey: "fixture.wind", profileContract: contract("wind", vectorPlanes), frames: windFrames, midpointMs: 1789241400000, colorBinding: vectorColor("fixture.wind"), particleBinding: vectorParticle("fixture.wind"), particleNote: "U ColorBinding (VELOCITY_COMPONENT); U/V particle vectors." },
{ id: "wave", title: "Wave QBIN Golden (wave/1)", fieldKey: "fixture.wave", profileContract: contract("wave", wavePlanes), frames: waveFrames, midpointMs: 1789241400000, colorBinding: { fieldKey: "fixture.wave", semanticID: 20, groupID: 0, expectedValueClass: "SCALAR", expectedUnitID: 2 }, particleBinding: { fieldKey: "fixture.wave", mode: "DIRECTION_ANGLE", directionPlane: { semanticID: 10, groupID: 0 }, expectedUnitID: 4 }, particleNote: "WAVE_HEIGHT color; DIRECTION angle particle (no direction conversion)." },
{ id: "current", title: "Current QBIN Golden (current/1)", fieldKey: "fixture.current", profileContract: contract("current", vectorPlanes), frames: currentFrames, midpointMs: 1789176600000, colorBinding: vectorColor("fixture.current"), particleBinding: vectorParticle("fixture.current"), particleNote: "U ColorBinding (VELOCITY_COMPONENT); U/V particle vectors." },
{ id: "rain", title: "Rain QBIN Golden (rain/1)", fieldKey: "fixture.rain", profileContract: contract("rain", rainPlanes), frames: rainFrames, midpointMs: 1789241400000, colorBinding: { fieldKey: "fixture.rain", semanticID: 30, groupID: 0, expectedValueClass: "SCALAR", expectedUnitID: 5 }, particleNote: "Particle N/A: fixture has no particle-compatible plane." },
] as const;
export async function materializeV4Frame(input: V4Frame): Promise<V4Frame & { qbinFileUri: string }> {
const asset = Asset.fromModule(input.assetModule);
await asset.downloadAsync();
if (!asset.localUri?.startsWith("file://")) throw new Error(`${input.label} QBIN asset did not materialize a local file:// URI`);
return { ...input, qbinFileUri: asset.localUri };
}