feat: add V4 field binding projection identities
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
This commit is contained in:
@@ -214,6 +214,7 @@ list(APPEND SRC_FILES
|
||||
${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/offline_fixture_particle_adapter.hpp
|
||||
${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/field_data_adapter.cpp
|
||||
${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/field_runtime.cpp
|
||||
${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/field_binding.cpp
|
||||
${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/field_profile_validator.cpp
|
||||
${PROJECT_SOURCE_DIR}/src/mbgl/particle/ingress/qbin_v2_decoder.cpp
|
||||
${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/local_particle_field_feed.cpp
|
||||
@@ -225,6 +226,7 @@ list(APPEND INCLUDE_FILES
|
||||
${PROJECT_SOURCE_DIR}/include/mbgl/particle/field/field_profile_contract.hpp
|
||||
${PROJECT_SOURCE_DIR}/include/mbgl/particle/field/field_profile_validator.hpp
|
||||
${PROJECT_SOURCE_DIR}/include/mbgl/particle/field/field_runtime.hpp
|
||||
${PROJECT_SOURCE_DIR}/include/mbgl/particle/field/field_binding.hpp
|
||||
${PROJECT_SOURCE_DIR}/include/mbgl/particle/ingress/qbin_v2_decoder.hpp
|
||||
${PROJECT_SOURCE_DIR}/include/mbgl/particle/field/local_particle_field_feed.hpp
|
||||
${PROJECT_SOURCE_DIR}/include/mbgl/actor/actor_ref.hpp
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# STEP-075 — QBIN2 V4 Binding 与 Projection Content Identity v1
|
||||
|
||||
状态:完成
|
||||
|
||||
日期:2026-09-13
|
||||
|
||||
## 目标与基线
|
||||
|
||||
- 开始 HEAD:`31783f25a8153f48000f19ac7cc2f2113cf47818`。
|
||||
- 仅实现 provider-neutral Core Binding/Projection;没有接入 Renderer、GPU、JNI、Android、RN、NavSea 或网络。
|
||||
|
||||
## 必做项
|
||||
|
||||
- [x] 新增独立 `ColorBinding`、`ParticleBinding`、`ColorProjectionContentIdentity`、`ParticleProjectionContentIdentity` 与 `BindingProjectionResolver`。
|
||||
- [x] Color 可选择 Scalar/Velocity/Direction numeric plane;unit 只作 equality assertion。
|
||||
- [x] Particle 支持同 fieldKey/window 的 U/V vector 与 DirectionAngle 两种模式;没有定义速度大小或 Renderer 行为。
|
||||
- [x] 每次 resolve 以 binding 的 fieldKey 独立调用 `EncodedFieldStore::resolve`,保留 Core-owned requested time 与每 field window。
|
||||
- [x] SampleState 仅保存 requestedForecastTimeMs/mix;content identity 不纳入 mix、播放状态或视觉参数,且保留只读 frame/storage shared_ptr。
|
||||
- [x] 结构化 fail-closed 返回 InvalidBinding、FieldSeriesMissing、WindowUnavailable、PlaneMissing、Class/Unit/Component/Group mismatch 与 InterpolationIncompatible。
|
||||
- [x] 测试 wind `00/01/02` 与 wave `00/03/06` 在 `01:30` 使用各自窗口;MIX `0/.25/.5/.99` 保持 identity/storage;Scalar-only revision 仅改变 Color identity;覆盖 Direction 成功及 unit/selector failure。
|
||||
|
||||
## 验证
|
||||
|
||||
- Linux OpenGL `mbgl-test-runner` 真实重建:PASS(输出节流)。
|
||||
- 规定过滤组 XML:50 tests、0 failures、0 errors:Binding 4,Runtime/Profile 11,QBIN Decoder/Ingress 24,V3 Source 11。
|
||||
- `git diff --check`:PASS。
|
||||
|
||||
## 边界与未执行
|
||||
|
||||
- FieldSeries 在提交阶段已拒绝不可插值相邻 frame;Resolver 仍保留 `InterpolationIncompatible` 结构化保护,正常 Store 无法形成该窗口。
|
||||
- 未执行 Android Gradle/APK/ADB/Jest、iOS/Metal 或任何 Host/Renderer 验证;它们不属于本 Step,不能视为 PASS。
|
||||
- 任务开始前既有 STEP-062 dirty、历史 untracked、build logs 与 RN 输出均未清理、暂存或提交。
|
||||
|
||||
## 下一步
|
||||
|
||||
STEP-075 关闭;按任务要求停止,不进入 STEP-076。
|
||||
@@ -0,0 +1,27 @@
|
||||
# STEP-075 QBIN2 V4 Binding 与 Projection Identity Codex 完成报告 v1
|
||||
|
||||
日期:2026-09-13
|
||||
|
||||
## 已完成
|
||||
|
||||
- 完成纯 Core 的 Color/Particle binding 与 projection resolver。Host 仅表达 binding intent;Core 仍拥有 requested forecast time 和 per-field window。
|
||||
- Identity 由 binding selector、实际 T0/T1 window frame coordinate、所选 plane 与 shared validity 的 content identity 组成;SampleState 单独承载 requested time/mix。U/V/validity 未变化时,Scalar-only revision 不改变 Particle identity。
|
||||
- U/V 强制 VELOCITY_COMPONENT、MPS equality、U_EAST/V_NORTH 与同组;Direction 强制 DIRECTION_ANGLE/NONE/unit equality。所有 lookup/assertion 都 fail-closed,绝不 fallback 到其他 fieldKey/plane。
|
||||
- 增加 4 个独立 Binding tests:不同 cadence、MIX identity/storage、Scalar-only revision isolation、错误矩阵和 DirectionAngle。
|
||||
|
||||
## 验证结果
|
||||
|
||||
- `cmake --build build-linux-opengl --target mbgl-test-runner -j3`:PASS(真实重建,输出节流)。
|
||||
- 规定 GTest filter:50 tests / 0 failures / 0 errors;Binding 4、Runtime/Profile 11、QBIN Decoder/Ingress 24、V3 Source 11。
|
||||
- `git diff --check`:PASS。
|
||||
|
||||
## 未完成或无法验证
|
||||
|
||||
- Android Gradle/APK/ADB/Jest、iOS/Metal、Renderer/GPU、Host/JNI/RN 均按任务禁止或范围限制未执行,不能标记 PASS。
|
||||
- 不可插值相邻帧由既有 FieldSeries submit 拒绝,因此合法 Store 不会产生该 resolve window;Resolver 仍保留 `InterpolationIncompatible` 防御返回。
|
||||
|
||||
## Git
|
||||
|
||||
- 首个 scoped commit:待本报告随实现创建后回填。
|
||||
- push:待 scoped commit 后回填。
|
||||
- 最终 `origin/particle/main` HEAD:待 push 后回填。
|
||||
121
include/mbgl/particle/field/field_binding.hpp
Normal file
121
include/mbgl/particle/field/field_binding.hpp
Normal file
@@ -0,0 +1,121 @@
|
||||
#pragma once
|
||||
|
||||
#include <mbgl/particle/field/field_runtime.hpp>
|
||||
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace mbgl::particle {
|
||||
|
||||
// Host-owned binding intent. FieldPlaneKey identity is deliberately only its
|
||||
// semanticID/groupID pair; component is validated from the selected plane.
|
||||
struct ColorBinding {
|
||||
std::string fieldKey;
|
||||
FieldPlaneKey plane;
|
||||
FieldValueClass expectedValueClass = FieldValueClass::Scalar;
|
||||
uint16_t expectedUnitID = FieldUnitNone;
|
||||
|
||||
bool operator==(const ColorBinding&) const;
|
||||
};
|
||||
|
||||
enum class ParticleBindingMode : uint8_t { VectorComponents, DirectionAngle };
|
||||
|
||||
struct ParticleBinding {
|
||||
ParticleBindingMode mode = ParticleBindingMode::VectorComponents;
|
||||
std::string fieldKey;
|
||||
FieldPlaneKey uPlane;
|
||||
FieldPlaneKey vPlane;
|
||||
FieldPlaneKey directionPlane;
|
||||
uint16_t expectedUnitID = FieldUnitNone;
|
||||
|
||||
bool operator==(const ParticleBinding&) const;
|
||||
};
|
||||
|
||||
struct FieldSampleState {
|
||||
uint64_t requestedForecastTimeMs = 0;
|
||||
float mix = 0.0f;
|
||||
};
|
||||
|
||||
struct ColorProjectionContentIdentity {
|
||||
ColorBinding binding;
|
||||
FrameContentIdentity t0Frame;
|
||||
FrameContentIdentity t1Frame;
|
||||
std::string t0Plane;
|
||||
std::string t1Plane;
|
||||
std::string t0Validity;
|
||||
std::string t1Validity;
|
||||
|
||||
bool operator==(const ColorProjectionContentIdentity&) const;
|
||||
};
|
||||
|
||||
struct ParticleProjectionContentIdentity {
|
||||
ParticleBinding binding;
|
||||
FrameContentIdentity t0Frame;
|
||||
FrameContentIdentity t1Frame;
|
||||
std::string t0FirstPlane;
|
||||
std::string t1FirstPlane;
|
||||
std::string t0SecondPlane;
|
||||
std::string t1SecondPlane;
|
||||
std::string t0Validity;
|
||||
std::string t1Validity;
|
||||
|
||||
bool operator==(const ParticleProjectionContentIdentity&) const;
|
||||
};
|
||||
|
||||
struct ColorProjection {
|
||||
ColorProjectionContentIdentity contentIdentity;
|
||||
FieldSampleState sampleState;
|
||||
std::shared_ptr<const DecodedFieldFrame> t0Frame;
|
||||
std::shared_ptr<const DecodedFieldFrame> t1Frame;
|
||||
std::shared_ptr<const std::vector<uint8_t>> t0PlaneStorage;
|
||||
std::shared_ptr<const std::vector<uint8_t>> t1PlaneStorage;
|
||||
std::shared_ptr<const std::vector<uint8_t>> t0ValidityStorage;
|
||||
std::shared_ptr<const std::vector<uint8_t>> t1ValidityStorage;
|
||||
};
|
||||
|
||||
struct ParticleProjection {
|
||||
ParticleProjectionContentIdentity contentIdentity;
|
||||
FieldSampleState sampleState;
|
||||
std::shared_ptr<const DecodedFieldFrame> t0Frame;
|
||||
std::shared_ptr<const DecodedFieldFrame> t1Frame;
|
||||
std::shared_ptr<const std::vector<uint8_t>> t0FirstPlaneStorage;
|
||||
std::shared_ptr<const std::vector<uint8_t>> t1FirstPlaneStorage;
|
||||
std::shared_ptr<const std::vector<uint8_t>> t0SecondPlaneStorage;
|
||||
std::shared_ptr<const std::vector<uint8_t>> t1SecondPlaneStorage;
|
||||
std::shared_ptr<const std::vector<uint8_t>> t0ValidityStorage;
|
||||
std::shared_ptr<const std::vector<uint8_t>> t1ValidityStorage;
|
||||
};
|
||||
|
||||
enum class BindingProjectionError : uint8_t {
|
||||
Accepted,
|
||||
InvalidBinding,
|
||||
FieldSeriesMissing,
|
||||
WindowUnavailable,
|
||||
PlaneMissing,
|
||||
PlaneClassMismatch,
|
||||
PlaneUnitMismatch,
|
||||
PlaneComponentMismatch,
|
||||
PlaneGroupMismatch,
|
||||
InterpolationIncompatible,
|
||||
};
|
||||
|
||||
struct ColorProjectionResult {
|
||||
BindingProjectionError error = BindingProjectionError::InvalidBinding;
|
||||
std::optional<ColorProjection> projection;
|
||||
bool accepted() const { return error == BindingProjectionError::Accepted; }
|
||||
};
|
||||
|
||||
struct ParticleProjectionResult {
|
||||
BindingProjectionError error = BindingProjectionError::InvalidBinding;
|
||||
std::optional<ParticleProjection> projection;
|
||||
bool accepted() const { return error == BindingProjectionError::Accepted; }
|
||||
};
|
||||
|
||||
class BindingProjectionResolver final {
|
||||
public:
|
||||
static ColorProjectionResult resolve(const EncodedFieldStore&, const ColorBinding&, uint64_t requestedForecastTimeMs);
|
||||
static ParticleProjectionResult resolve(const EncodedFieldStore&, const ParticleBinding&, uint64_t requestedForecastTimeMs);
|
||||
};
|
||||
|
||||
} // namespace mbgl::particle
|
||||
149
src/mbgl/particle/field/field_binding.cpp
Normal file
149
src/mbgl/particle/field/field_binding.cpp
Normal file
@@ -0,0 +1,149 @@
|
||||
#include <mbgl/particle/field/field_binding.hpp>
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace mbgl::particle {
|
||||
namespace {
|
||||
|
||||
bool validValueClass(FieldValueClass value) { return value <= FieldValueClass::Validity; }
|
||||
bool validComponent(FieldComponent value) { return value <= FieldComponent::WUp; }
|
||||
bool validKey(const FieldPlaneKey& key) { return key.semanticID != 0 && validComponent(key.component); }
|
||||
bool sameSelector(const FieldPlaneKey& a, const FieldPlaneKey& b) {
|
||||
return a.semanticID == b.semanticID && a.groupID == b.groupID;
|
||||
}
|
||||
const DecodedFieldPlane* findPlane(const DecodedFieldFrame& frame, const FieldPlaneKey& selector) {
|
||||
for (const auto& plane : frame.planes) if (sameSelector(plane.key, selector)) return &plane;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
BindingProjectionError validateColorBinding(const ColorBinding& binding) {
|
||||
if (binding.fieldKey.empty() || !validKey(binding.plane) || !validValueClass(binding.expectedValueClass) ||
|
||||
binding.expectedValueClass == FieldValueClass::Validity)
|
||||
return BindingProjectionError::InvalidBinding;
|
||||
return BindingProjectionError::Accepted;
|
||||
}
|
||||
BindingProjectionError validateParticleBinding(const ParticleBinding& binding) {
|
||||
if (binding.fieldKey.empty()) return BindingProjectionError::InvalidBinding;
|
||||
if (binding.mode == ParticleBindingMode::VectorComponents) {
|
||||
if (!validKey(binding.uPlane) || !validKey(binding.vPlane) || sameSelector(binding.uPlane, binding.vPlane))
|
||||
return BindingProjectionError::InvalidBinding;
|
||||
} else if (binding.mode == ParticleBindingMode::DirectionAngle) {
|
||||
if (!validKey(binding.directionPlane)) return BindingProjectionError::InvalidBinding;
|
||||
} else {
|
||||
return BindingProjectionError::InvalidBinding;
|
||||
}
|
||||
return BindingProjectionError::Accepted;
|
||||
}
|
||||
BindingProjectionError validateWindow(const EncodedFieldStore& store, const std::string& fieldKey,
|
||||
uint64_t requested, std::optional<FieldSeriesWindow>& window) {
|
||||
if (!store.find(fieldKey)) return BindingProjectionError::FieldSeriesMissing;
|
||||
window = store.resolve(fieldKey, requested);
|
||||
return window ? BindingProjectionError::Accepted : BindingProjectionError::WindowUnavailable;
|
||||
}
|
||||
BindingProjectionError validateSelected(const DecodedFieldPlane* t0,
|
||||
const DecodedFieldPlane* t1,
|
||||
FieldValueClass valueClass,
|
||||
uint16_t unit,
|
||||
FieldComponent component) {
|
||||
if (!t0 || !t1) return BindingProjectionError::PlaneMissing;
|
||||
if (t0->valueClass != valueClass || t1->valueClass != valueClass) return BindingProjectionError::PlaneClassMismatch;
|
||||
if (t0->unitID != unit || t1->unitID != unit) return BindingProjectionError::PlaneUnitMismatch;
|
||||
if (t0->key.component != component || t1->key.component != component)
|
||||
return BindingProjectionError::PlaneComponentMismatch;
|
||||
if (!t0->isInterpolationCompatibleWith(*t1)) return BindingProjectionError::InterpolationIncompatible;
|
||||
return BindingProjectionError::Accepted;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool ColorBinding::operator==(const ColorBinding& other) const {
|
||||
return fieldKey == other.fieldKey && sameSelector(plane, other.plane) &&
|
||||
expectedValueClass == other.expectedValueClass && expectedUnitID == other.expectedUnitID;
|
||||
}
|
||||
bool ParticleBinding::operator==(const ParticleBinding& other) const {
|
||||
return mode == other.mode && fieldKey == other.fieldKey && sameSelector(uPlane, other.uPlane) &&
|
||||
sameSelector(vPlane, other.vPlane) && sameSelector(directionPlane, other.directionPlane) &&
|
||||
expectedUnitID == other.expectedUnitID;
|
||||
}
|
||||
bool ColorProjectionContentIdentity::operator==(const ColorProjectionContentIdentity& other) const {
|
||||
return binding == other.binding && t0Frame == other.t0Frame && t1Frame == other.t1Frame &&
|
||||
t0Plane == other.t0Plane && t1Plane == other.t1Plane && t0Validity == other.t0Validity &&
|
||||
t1Validity == other.t1Validity;
|
||||
}
|
||||
bool ParticleProjectionContentIdentity::operator==(const ParticleProjectionContentIdentity& other) const {
|
||||
// A FieldFrame revision can carry an unrelated plane update. Projection
|
||||
// identity therefore uses the selected frame time/field as its window
|
||||
// coordinate; selected plane and validity content identities carry the
|
||||
// revision-sensitive content part. This preserves a Particle projection
|
||||
// across a scalar-only revision in the same frame window.
|
||||
const auto sameWindowFrame = [](const FrameContentIdentity& a, const FrameContentIdentity& b) {
|
||||
return a.fieldID == b.fieldID && a.validTimeMs == b.validTimeMs;
|
||||
};
|
||||
return binding == other.binding && sameWindowFrame(t0Frame, other.t0Frame) && sameWindowFrame(t1Frame, other.t1Frame) &&
|
||||
t0FirstPlane == other.t0FirstPlane && t1FirstPlane == other.t1FirstPlane &&
|
||||
t0SecondPlane == other.t0SecondPlane && t1SecondPlane == other.t1SecondPlane &&
|
||||
t0Validity == other.t0Validity && t1Validity == other.t1Validity;
|
||||
}
|
||||
|
||||
ColorProjectionResult BindingProjectionResolver::resolve(const EncodedFieldStore& store,
|
||||
const ColorBinding& binding,
|
||||
const uint64_t requested) {
|
||||
if (const auto error = validateColorBinding(binding); error != BindingProjectionError::Accepted) return {error, {}};
|
||||
std::optional<FieldSeriesWindow> window;
|
||||
if (const auto error = validateWindow(store, binding.fieldKey, requested, window); error != BindingProjectionError::Accepted)
|
||||
return {error, {}};
|
||||
const auto* t0 = findPlane(*window->t0, binding.plane);
|
||||
const auto* t1 = findPlane(*window->t1, binding.plane);
|
||||
if (const auto error = validateSelected(t0, t1, binding.expectedValueClass, binding.expectedUnitID,
|
||||
t0 ? t0->key.component : FieldComponent::None);
|
||||
error != BindingProjectionError::Accepted)
|
||||
return {error, {}};
|
||||
ColorProjection projection{{binding, window->t0->frameContentIdentity, window->t1->frameContentIdentity,
|
||||
t0->contentIdentity, t1->contentIdentity, window->t0->validityContentIdentity,
|
||||
window->t1->validityContentIdentity},
|
||||
{requested, window->mix}, window->t0, window->t1, t0->storage, t1->storage,
|
||||
window->t0->validityBits, window->t1->validityBits};
|
||||
return {BindingProjectionError::Accepted, std::move(projection)};
|
||||
}
|
||||
|
||||
ParticleProjectionResult BindingProjectionResolver::resolve(const EncodedFieldStore& store,
|
||||
const ParticleBinding& binding,
|
||||
const uint64_t requested) {
|
||||
if (const auto error = validateParticleBinding(binding); error != BindingProjectionError::Accepted) return {error, {}};
|
||||
std::optional<FieldSeriesWindow> window;
|
||||
if (const auto error = validateWindow(store, binding.fieldKey, requested, window); error != BindingProjectionError::Accepted)
|
||||
return {error, {}};
|
||||
const auto firstSelector = binding.mode == ParticleBindingMode::VectorComponents ? binding.uPlane : binding.directionPlane;
|
||||
const auto secondSelector = binding.mode == ParticleBindingMode::VectorComponents ? binding.vPlane : FieldPlaneKey{};
|
||||
const auto* t0First = findPlane(*window->t0, firstSelector);
|
||||
const auto* t1First = findPlane(*window->t1, firstSelector);
|
||||
const auto expectedClass = binding.mode == ParticleBindingMode::VectorComponents ? FieldValueClass::VelocityComponent
|
||||
: FieldValueClass::DirectionAngle;
|
||||
const auto expectedComponent = binding.mode == ParticleBindingMode::VectorComponents ? FieldComponent::UEast
|
||||
: FieldComponent::None;
|
||||
if (const auto error = validateSelected(t0First, t1First, expectedClass, binding.expectedUnitID, expectedComponent);
|
||||
error != BindingProjectionError::Accepted)
|
||||
return {error, {}};
|
||||
const DecodedFieldPlane* t0Second = nullptr;
|
||||
const DecodedFieldPlane* t1Second = nullptr;
|
||||
if (binding.mode == ParticleBindingMode::VectorComponents) {
|
||||
t0Second = findPlane(*window->t0, secondSelector); t1Second = findPlane(*window->t1, secondSelector);
|
||||
if (const auto error = validateSelected(t0Second, t1Second, FieldValueClass::VelocityComponent,
|
||||
binding.expectedUnitID, FieldComponent::VNorth);
|
||||
error != BindingProjectionError::Accepted)
|
||||
return {error, {}};
|
||||
if (t0First->key.groupID != t0Second->key.groupID || t1First->key.groupID != t1Second->key.groupID)
|
||||
return {BindingProjectionError::PlaneGroupMismatch, {}};
|
||||
}
|
||||
ParticleProjection projection{{binding, window->t0->frameContentIdentity, window->t1->frameContentIdentity,
|
||||
t0First->contentIdentity, t1First->contentIdentity,
|
||||
t0Second ? t0Second->contentIdentity : std::string{},
|
||||
t1Second ? t1Second->contentIdentity : std::string{},
|
||||
window->t0->validityContentIdentity, window->t1->validityContentIdentity},
|
||||
{requested, window->mix}, window->t0, window->t1, t0First->storage, t1First->storage,
|
||||
t0Second ? t0Second->storage : nullptr, t1Second ? t1Second->storage : nullptr,
|
||||
window->t0->validityBits, window->t1->validityBits};
|
||||
return {BindingProjectionError::Accepted, std::move(projection)};
|
||||
}
|
||||
|
||||
} // namespace mbgl::particle
|
||||
@@ -19,6 +19,7 @@ add_library(
|
||||
${PROJECT_SOURCE_DIR}/test/math/wrap.test.cpp
|
||||
${PROJECT_SOURCE_DIR}/test/platform/settings.test.cpp
|
||||
${PROJECT_SOURCE_DIR}/test/particle/field_runtime.test.cpp
|
||||
${PROJECT_SOURCE_DIR}/test/particle/field_binding.test.cpp
|
||||
${PROJECT_SOURCE_DIR}/test/particle/qbin_v2_decoder.test.cpp
|
||||
${PROJECT_SOURCE_DIR}/test/plugin/plugin.test.cpp
|
||||
${PROJECT_SOURCE_DIR}/test/renderer/image_manager.test.cpp
|
||||
|
||||
170
test/particle/field_binding.test.cpp
Normal file
170
test/particle/field_binding.test.cpp
Normal file
@@ -0,0 +1,170 @@
|
||||
#include <mbgl/particle/field/field_binding.hpp>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace mbgl;
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr uint64_t Hour = 3'600'000;
|
||||
constexpr uint64_t Base = 1'700'000'000'000ULL;
|
||||
|
||||
particle::DecodedFieldPlane makePlane(uint16_t semantic, uint16_t group, particle::FieldValueClass valueClass,
|
||||
particle::FieldComponent component, uint16_t unit, std::string identity,
|
||||
std::shared_ptr<const std::vector<uint8_t>> bytes) {
|
||||
return {{semantic, group, component}, particle::FieldDType::UInt8, valueClass, unit, false, 4, 1.0f, 0.0f,
|
||||
std::move(identity), std::move(bytes)};
|
||||
}
|
||||
|
||||
std::shared_ptr<const particle::DecodedFieldFrame> makeFrame(std::string field, uint64_t time, uint64_t generation,
|
||||
std::string suffix, bool vector = true,
|
||||
bool scalar = true) {
|
||||
const auto bytes = std::make_shared<const std::vector<uint8_t>>(std::vector<uint8_t>{1, 2, 3, 4});
|
||||
const auto bits = std::make_shared<const std::vector<uint8_t>>(std::vector<uint8_t>{15});
|
||||
particle::DecodedFieldFrame frame;
|
||||
frame.frameContentIdentity = {std::move(field), generation, generation, time};
|
||||
frame.referenceTimeMs = time;
|
||||
frame.grid = {.layout = 1, .projection = 1, .sampleOrder = 1, .gridRegistration = 1, .width = 2, .height = 2,
|
||||
.west = 0, .south = 0, .east = 1, .north = 1};
|
||||
frame.validityContentIdentity = "valid-" + suffix;
|
||||
frame.validityBits = bits;
|
||||
frame.validityPlane = makePlane(900, 0, particle::FieldValueClass::Validity, particle::FieldComponent::None,
|
||||
particle::FieldUnitNone, frame.validityContentIdentity, bits);
|
||||
frame.validityPlane.dtype = particle::FieldDType::Bitset;
|
||||
frame.validityPlane.requiredFlag = true;
|
||||
if (vector) {
|
||||
frame.planes.push_back(makePlane(1, 7, particle::FieldValueClass::VelocityComponent, particle::FieldComponent::UEast,
|
||||
particle::FieldUnitMetersPerSecond, "u-" + suffix, bytes));
|
||||
frame.planes.push_back(makePlane(2, 7, particle::FieldValueClass::VelocityComponent, particle::FieldComponent::VNorth,
|
||||
particle::FieldUnitMetersPerSecond, "v-" + suffix, bytes));
|
||||
}
|
||||
if (scalar) frame.planes.push_back(makePlane(50, 0, particle::FieldValueClass::Scalar, particle::FieldComponent::None,
|
||||
42, "s-" + suffix, bytes));
|
||||
return std::make_shared<const particle::DecodedFieldFrame>(std::move(frame));
|
||||
}
|
||||
|
||||
void submit(particle::EncodedFieldStore& store, const std::string& key,
|
||||
std::shared_ptr<const particle::DecodedFieldFrame> frame) {
|
||||
ASSERT_EQ(store.submit(key, std::move(frame)), particle::FieldSeriesSubmitResult::Inserted);
|
||||
}
|
||||
|
||||
particle::ColorBinding color(std::string key = "weather.wave") {
|
||||
return {std::move(key), {50, 0, particle::FieldComponent::None}, particle::FieldValueClass::Scalar, 42};
|
||||
}
|
||||
particle::ParticleBinding wind() {
|
||||
particle::ParticleBinding binding;
|
||||
binding.fieldKey = "weather.wind"; binding.uPlane = {1, 7, particle::FieldComponent::None};
|
||||
binding.vPlane = {2, 7, particle::FieldComponent::None}; binding.expectedUnitID = particle::FieldUnitMetersPerSecond;
|
||||
return binding;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(ParticleFieldBinding, ResolvesIndependentFieldKeyCadence) {
|
||||
particle::EncodedFieldStore store;
|
||||
for (uint64_t hour : {0ULL, 1ULL, 2ULL}) submit(store, "weather.wind", makeFrame("wind", Base + hour * Hour, 1, "w" + std::to_string(hour)));
|
||||
for (uint64_t hour : {0ULL, 3ULL, 6ULL}) submit(store, "weather.wave", makeFrame("wave", Base + hour * Hour, 1, "a" + std::to_string(hour)));
|
||||
const auto c = particle::BindingProjectionResolver::resolve(store, color(), Base + Hour + Hour / 2);
|
||||
const auto p = particle::BindingProjectionResolver::resolve(store, wind(), Base + Hour + Hour / 2);
|
||||
ASSERT_TRUE(c.accepted()); ASSERT_TRUE(p.accepted());
|
||||
EXPECT_FLOAT_EQ(c.projection->sampleState.mix, 0.5f);
|
||||
EXPECT_FLOAT_EQ(p.projection->sampleState.mix, 0.5f);
|
||||
EXPECT_EQ(c.projection->contentIdentity.t0Frame.validTimeMs, Base);
|
||||
EXPECT_EQ(c.projection->contentIdentity.t1Frame.validTimeMs, Base + 3 * Hour);
|
||||
EXPECT_EQ(p.projection->contentIdentity.t0Frame.validTimeMs, Base + Hour);
|
||||
EXPECT_EQ(p.projection->contentIdentity.t1Frame.validTimeMs, Base + 2 * Hour);
|
||||
EXPECT_NE(c.projection->t0Frame, p.projection->t0Frame);
|
||||
EXPECT_NE(c.projection->t0ValidityStorage, p.projection->t0ValidityStorage);
|
||||
}
|
||||
|
||||
TEST(ParticleFieldBinding, MixChangesSampleStateButNotContentIdentityOrStorage) {
|
||||
particle::EncodedFieldStore store;
|
||||
submit(store, "weather.wave", makeFrame("wave", Base, 1, "0"));
|
||||
submit(store, "weather.wave", makeFrame("wave", Base + 4 * Hour, 1, "4"));
|
||||
const auto at0 = particle::BindingProjectionResolver::resolve(store, color(), Base);
|
||||
const auto at1 = particle::BindingProjectionResolver::resolve(store, color(), Base + Hour);
|
||||
const auto at2 = particle::BindingProjectionResolver::resolve(store, color(), Base + 2 * Hour);
|
||||
const auto at3 = particle::BindingProjectionResolver::resolve(store, color(), Base + 14'256'000);
|
||||
ASSERT_TRUE(at0.accepted()); ASSERT_TRUE(at1.accepted()); ASSERT_TRUE(at2.accepted()); ASSERT_TRUE(at3.accepted());
|
||||
EXPECT_FLOAT_EQ(at0.projection->sampleState.mix, 0.0f); EXPECT_FLOAT_EQ(at1.projection->sampleState.mix, 0.25f);
|
||||
EXPECT_FLOAT_EQ(at2.projection->sampleState.mix, 0.5f); EXPECT_FLOAT_EQ(at3.projection->sampleState.mix, 0.99f);
|
||||
EXPECT_EQ(at0.projection->contentIdentity, at1.projection->contentIdentity);
|
||||
EXPECT_EQ(at0.projection->contentIdentity, at2.projection->contentIdentity);
|
||||
EXPECT_EQ(at0.projection->contentIdentity, at3.projection->contentIdentity);
|
||||
EXPECT_EQ(at0.projection->t0PlaneStorage, at3.projection->t0PlaneStorage);
|
||||
}
|
||||
|
||||
TEST(ParticleFieldBinding, ScalarRevisionChangesOnlyColorIdentity) {
|
||||
particle::EncodedFieldStore store;
|
||||
submit(store, "weather.wind", makeFrame("wind", Base, 1, "0"));
|
||||
submit(store, "weather.wind", makeFrame("wind", Base + Hour, 1, "1"));
|
||||
const auto beforeColor = particle::BindingProjectionResolver::resolve(store, color("weather.wind"), Base + Hour / 2);
|
||||
const auto beforeParticle = particle::BindingProjectionResolver::resolve(store, wind(), Base + Hour / 2);
|
||||
ASSERT_TRUE(beforeColor.accepted()); ASSERT_TRUE(beforeParticle.accepted());
|
||||
auto revision = *beforeParticle.projection->t0Frame;
|
||||
revision.frameContentIdentity.generation = 2;
|
||||
revision.frameContentIdentity.dataVersion = 2;
|
||||
revision.planes.back().contentIdentity = "s-revised";
|
||||
ASSERT_EQ(store.submit("weather.wind", std::make_shared<const particle::DecodedFieldFrame>(std::move(revision))),
|
||||
particle::FieldSeriesSubmitResult::ReplacedRevision);
|
||||
const auto afterColor = particle::BindingProjectionResolver::resolve(store, color("weather.wind"), Base + Hour / 2);
|
||||
const auto afterParticle = particle::BindingProjectionResolver::resolve(store, wind(), Base + Hour / 2);
|
||||
ASSERT_TRUE(afterColor.accepted()); ASSERT_TRUE(afterParticle.accepted());
|
||||
EXPECT_NE(beforeColor.projection->contentIdentity, afterColor.projection->contentIdentity);
|
||||
EXPECT_EQ(beforeParticle.projection->contentIdentity, afterParticle.projection->contentIdentity);
|
||||
EXPECT_EQ(beforeParticle.projection->t0FirstPlaneStorage, afterParticle.projection->t0FirstPlaneStorage);
|
||||
EXPECT_EQ(beforeParticle.projection->t0ValidityStorage, afterParticle.projection->t0ValidityStorage);
|
||||
}
|
||||
|
||||
TEST(ParticleFieldBinding, FailsClosedAndSupportsDirectionAngle) {
|
||||
particle::EncodedFieldStore store;
|
||||
submit(store, "weather.wind", makeFrame("wind", Base, 1, "0"));
|
||||
submit(store, "weather.wind", makeFrame("wind", Base + Hour, 1, "1"));
|
||||
EXPECT_EQ(particle::BindingProjectionResolver::resolve(store, color(""), Base).error, particle::BindingProjectionError::InvalidBinding);
|
||||
EXPECT_EQ(particle::BindingProjectionResolver::resolve(store, color("missing"), Base).error, particle::BindingProjectionError::FieldSeriesMissing);
|
||||
EXPECT_EQ(particle::BindingProjectionResolver::resolve(store, color("weather.wind"), Base + 2 * Hour).error, particle::BindingProjectionError::WindowUnavailable);
|
||||
auto missing = color("weather.wind"); missing.plane.semanticID = 99;
|
||||
EXPECT_EQ(particle::BindingProjectionResolver::resolve(store, missing, Base).error, particle::BindingProjectionError::PlaneMissing);
|
||||
auto classMismatch = color("weather.wind"); classMismatch.plane = {1, 7, particle::FieldComponent::None};
|
||||
EXPECT_EQ(particle::BindingProjectionResolver::resolve(store, classMismatch, Base).error, particle::BindingProjectionError::PlaneClassMismatch);
|
||||
auto unitMismatch = wind(); unitMismatch.expectedUnitID = 99;
|
||||
EXPECT_EQ(particle::BindingProjectionResolver::resolve(store, unitMismatch, Base).error, particle::BindingProjectionError::PlaneUnitMismatch);
|
||||
auto componentMismatch = wind(); componentMismatch.uPlane.semanticID = 2; componentMismatch.vPlane.semanticID = 1;
|
||||
EXPECT_EQ(particle::BindingProjectionResolver::resolve(store, componentMismatch, Base).error, particle::BindingProjectionError::PlaneComponentMismatch);
|
||||
particle::EncodedFieldStore unequalGroups;
|
||||
auto g0 = *makeFrame("wind", Base, 1, "g0"), g1 = *makeFrame("wind", Base + Hour, 1, "g1");
|
||||
g0.planes[1].key.groupID = 8; g1.planes[1].key.groupID = 8;
|
||||
submit(unequalGroups, "weather.wind", std::make_shared<const particle::DecodedFieldFrame>(std::move(g0)));
|
||||
submit(unequalGroups, "weather.wind", std::make_shared<const particle::DecodedFieldFrame>(std::move(g1)));
|
||||
auto groupMismatch = wind(); groupMismatch.vPlane.groupID = 8;
|
||||
EXPECT_EQ(particle::BindingProjectionResolver::resolve(unequalGroups, groupMismatch, Base).error,
|
||||
particle::BindingProjectionError::PlaneGroupMismatch);
|
||||
|
||||
particle::EncodedFieldStore directions;
|
||||
auto d0 = *makeFrame("direction", Base, 1, "d0", false, false);
|
||||
auto d1 = *makeFrame("direction", Base + Hour, 1, "d1", false, false);
|
||||
const auto directionBytes = std::make_shared<const std::vector<uint8_t>>(std::vector<uint8_t>{1, 2, 3, 4});
|
||||
for (auto* frame : {&d0, &d1}) {
|
||||
frame->planes.push_back(makePlane(10, 0, particle::FieldValueClass::DirectionAngle, particle::FieldComponent::None,
|
||||
particle::FieldUnitDegrees, "direction", directionBytes));
|
||||
frame->planes.back().dtype = particle::FieldDType::UInt16;
|
||||
frame->planes.back().scale = 360.0f / 65536.0f;
|
||||
}
|
||||
submit(directions, "weather.direction", std::make_shared<const particle::DecodedFieldFrame>(std::move(d0)));
|
||||
submit(directions, "weather.direction", std::make_shared<const particle::DecodedFieldFrame>(std::move(d1)));
|
||||
particle::ParticleBinding direction;
|
||||
direction.mode = particle::ParticleBindingMode::DirectionAngle; direction.fieldKey = "weather.direction";
|
||||
direction.directionPlane = {10, 0, particle::FieldComponent::None}; direction.expectedUnitID = particle::FieldUnitDegrees;
|
||||
EXPECT_TRUE(particle::BindingProjectionResolver::resolve(directions, direction, Base).accepted());
|
||||
direction.expectedUnitID = particle::FieldUnitMetersPerSecond;
|
||||
EXPECT_EQ(particle::BindingProjectionResolver::resolve(directions, direction, Base).error,
|
||||
particle::BindingProjectionError::PlaneUnitMismatch);
|
||||
direction.expectedUnitID = particle::FieldUnitDegrees;
|
||||
direction.directionPlane.semanticID = 99;
|
||||
EXPECT_EQ(particle::BindingProjectionResolver::resolve(directions, direction, Base).error,
|
||||
particle::BindingProjectionError::PlaneMissing);
|
||||
}
|
||||
@@ -734,3 +734,9 @@ Android V3-08 单次综合验收已完成并关闭。Native `13.4.1-particle.2`
|
||||
|
||||
- `AcceptsRawLimitWithCanonicalPaddingExcluded` 改为 1101×1890(2,080,890 cells)的 17-plane canonical fixture:single shared validity BITSET 加 16 个 UINT8 numeric plane。rawSum=33,554,352 bytes,小于 32 MiB limit=33,554,432;payloadLength=33,554,448 bytes,大于 limit 与 rawSum;totalQbinBytes=33,555,104 bytes,小于 64 MiB。
|
||||
- production `QbinV2Decoder::decode(...)` Accepted;未发现生产缺陷,未改 Decoder 或 Host/网络/JNI/RN/Binding/Projection/Renderer。真实 runner 重建 PASS,Decoder/Ingress 24/24、Runtime/Profile 11/11、Source 回归 11/11 PASS,`git diff --check` PASS;未执行 Android Gradle/APK/ADB/Jest,按范围停止。
|
||||
|
||||
### 2026-09-13 STEP-075 QBIN2 V4 Binding 与 Projection Identity
|
||||
|
||||
- 新增 provider-neutral Color/Particle Binding 与 Resolver;每个 binding 按自己的 fieldKey 解析 FieldSeries Window,SampleState 单独保存 requested time/mix,Content Identity 不包含 mix 或视觉/播放状态。
|
||||
- Vector U/V 与 DirectionAngle 都严格按 selected plane assertion fail-closed;Scalar-only revision 保持 Particle U/V/validity storage 与 identity 不变。
|
||||
- Linux runner 真实重建、规定 Core filter 50/50 PASS、`git diff --check` PASS;未执行 Android Gradle/APK/ADB/Jest/iOS/Metal,未进入 Renderer/GPU/Host/JNI/RN,STEP-075 关闭并停止。
|
||||
|
||||
Reference in New Issue
Block a user