feat: add QBIN2 V4 generic field profile validation
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:
@@ -213,12 +213,17 @@ list(APPEND SRC_FILES
|
||||
${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/offline_fixture_particle_adapter.cpp
|
||||
${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_profile_validator.cpp
|
||||
${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/local_particle_field_feed.cpp
|
||||
${PROJECT_SOURCE_DIR}/src/mbgl/shaders/shader_program_base.cpp
|
||||
)
|
||||
|
||||
list(APPEND INCLUDE_FILES
|
||||
${PROJECT_SOURCE_DIR}/include/mbgl/particle/field/field_data_adapter.hpp
|
||||
${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/local_particle_field_feed.hpp
|
||||
${PROJECT_SOURCE_DIR}/include/mbgl/actor/actor_ref.hpp
|
||||
${PROJECT_SOURCE_DIR}/include/mbgl/actor/actor.hpp
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# STEP-073 — QBIN2 V4 Core 与通用 Field Profile Validator
|
||||
|
||||
状态:完成
|
||||
|
||||
日期:2026-09-13
|
||||
|
||||
## 目标
|
||||
|
||||
建立不接入 Decoder、Renderer、Android/JNI/RN 或 V3 生产路径的 QBIN2 V4.4 provider-neutral Core primitive,并以数据驱动 Contract Schema v1 验证 `DecodedFieldFrame`。
|
||||
|
||||
## 完成项
|
||||
|
||||
- [x] 建立 `ResolvedFieldProfileContract`、`DecodedFieldFrame`、`FieldSeries`、`EncodedFieldStore` 与独立 `ForecastClock`。
|
||||
- [x] 以独立 Validity descriptor 加共享只读 payload 表达单一 Frame validity domain;descriptor 与 payload 使用同一 `shared_ptr`,不复制位图。
|
||||
- [x] 实现纯数据驱动 `FieldProfileValidator`:schema、REQUIRED 双向一致、额外 plane、shared-validity 能力、时间关系及 Core 单位/速度组规则均返回结构化结果。
|
||||
- [x] 保持普通 Scalar 的 semanticID/unitID opaque;无 profileID 产品分支。
|
||||
- [x] 保持同 validTime 的 Duplicate、identity conflict、revision replacement 与相邻插值兼容性语义;fieldKey 独立 cadence/window。
|
||||
- [x] 增加 C++ 自动化测试,并通过 Linux OpenGL 构建与 V3 Field/Timeline 定向回归。
|
||||
|
||||
## 允许修改
|
||||
|
||||
`include/mbgl/particle/field/`、`src/mbgl/particle/field/`、根/测试 CMake、`test/particle/`、本 Step、开发记录与完成报告。
|
||||
|
||||
## 禁止修改确认
|
||||
|
||||
未修改 Renderer、shader、`platform/android/`、RN/integration、Style JSON/source schema;未新增 QBIN decoder、JNI、Java/Kotlin 或 React API;旧 V3 Timeline/`submitField(...)` 路径未切换。
|
||||
|
||||
## 验证
|
||||
|
||||
- `cmake --build build-linux-opengl --target mbgl-test -j3`:PASS。
|
||||
- `build-linux-opengl/mbgl-test-runner --gtest_filter='ParticleFieldRuntime.*:ParticleFieldProfileValidator.*'`:PASS。
|
||||
- `build-linux-opengl/mbgl-test-runner --gtest_filter='Source.ParticleFieldDataAdapter*:Source.RasterArrayTimeline*:Source.HostField*'`:PASS。
|
||||
- `git diff --check`:PASS。
|
||||
|
||||
以上构建/测试均通过输出节流脚本执行。未执行 Android Gradle、APK、ADB 或真机操作。
|
||||
|
||||
## 阻塞与下一步
|
||||
|
||||
无本 STEP 阻塞。下一步必须等待独立审计后再决定是否进入 QBIN2 Encoded Ingress / Decoder;本 STEP 未自行开展该工作。
|
||||
56
include/mbgl/particle/field/field_profile_contract.hpp
Normal file
56
include/mbgl/particle/field/field_profile_contract.hpp
Normal file
@@ -0,0 +1,56 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace mbgl::particle {
|
||||
|
||||
enum class FieldValueClass : uint8_t {
|
||||
Scalar = 0,
|
||||
VelocityComponent = 1,
|
||||
DirectionAngle = 2,
|
||||
Validity = 3,
|
||||
};
|
||||
|
||||
enum class FieldComponent : uint8_t {
|
||||
None = 0,
|
||||
UEast = 1,
|
||||
VNorth = 2,
|
||||
WUp = 3,
|
||||
};
|
||||
|
||||
enum class FieldTimeRelation : uint8_t {
|
||||
Any = 0,
|
||||
ValidGteReference = 1,
|
||||
ValidEqReference = 2,
|
||||
};
|
||||
|
||||
// Core unit identifiers reserved by Contract Schema v1. Scalar unit identifiers
|
||||
// deliberately remain opaque to the Native runtime.
|
||||
constexpr uint16_t FieldUnitNone = 0;
|
||||
constexpr uint16_t FieldUnitMetersPerSecond = 1;
|
||||
constexpr uint16_t FieldUnitDegrees = 2;
|
||||
|
||||
struct FieldPlaneRequirement {
|
||||
uint16_t semanticID = 0;
|
||||
FieldValueClass valueClass = FieldValueClass::Scalar;
|
||||
uint16_t unitID = 0;
|
||||
uint16_t groupID = 0;
|
||||
FieldComponent component = FieldComponent::None;
|
||||
bool requiredFlag = true;
|
||||
};
|
||||
|
||||
struct ResolvedFieldProfileContract {
|
||||
uint16_t contractSchemaVersion = 1;
|
||||
std::string profileID;
|
||||
uint32_t profileVersion = 0;
|
||||
bool allowAdditionalPlanes = true;
|
||||
bool sharedValidityRequired = true;
|
||||
FieldTimeRelation timeRelation = FieldTimeRelation::Any;
|
||||
std::vector<FieldPlaneRequirement> requiredPlanes;
|
||||
|
||||
bool isStructurallyValid() const;
|
||||
};
|
||||
|
||||
} // namespace mbgl::particle
|
||||
38
include/mbgl/particle/field/field_profile_validator.hpp
Normal file
38
include/mbgl/particle/field/field_profile_validator.hpp
Normal file
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
#include <mbgl/particle/field/field_runtime.hpp>
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace mbgl::particle {
|
||||
|
||||
// Structured outcome of Contract Schema v1 validation. This API deliberately
|
||||
// validates only generic field metadata; it has no provider or product logic.
|
||||
enum class FieldProfileValidationCode : uint8_t {
|
||||
Accepted,
|
||||
UnsupportedContractSchema,
|
||||
InvalidContract,
|
||||
InvalidFrame,
|
||||
MissingRequiredPlane,
|
||||
PlaneDescriptorMismatch,
|
||||
UndeclaredRequiredPlane,
|
||||
AdditionalPlaneNotAllowed,
|
||||
InvalidVelocityGroup,
|
||||
InvalidCoreUnit,
|
||||
UnsupportedSharedValidityMode,
|
||||
TimeRelationMismatch,
|
||||
};
|
||||
|
||||
struct FieldProfileValidationResult {
|
||||
FieldProfileValidationCode code = FieldProfileValidationCode::InvalidContract;
|
||||
std::string detail;
|
||||
|
||||
bool accepted() const { return code == FieldProfileValidationCode::Accepted; }
|
||||
};
|
||||
|
||||
class FieldProfileValidator final {
|
||||
public:
|
||||
static FieldProfileValidationResult validate(const ResolvedFieldProfileContract&, const DecodedFieldFrame&);
|
||||
};
|
||||
|
||||
} // namespace mbgl::particle
|
||||
162
include/mbgl/particle/field/field_runtime.hpp
Normal file
162
include/mbgl/particle/field/field_runtime.hpp
Normal file
@@ -0,0 +1,162 @@
|
||||
#pragma once
|
||||
|
||||
#include <mbgl/particle/field/field_profile_contract.hpp>
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace mbgl::particle {
|
||||
|
||||
enum class FieldDType : uint8_t {
|
||||
Int16 = 1,
|
||||
UInt16 = 2,
|
||||
UInt8 = 3,
|
||||
Int8 = 4,
|
||||
Float32 = 5,
|
||||
Bitset = 6,
|
||||
};
|
||||
|
||||
struct FrameContentIdentity {
|
||||
std::string fieldID;
|
||||
uint64_t generation = 0;
|
||||
uint64_t dataVersion = 0;
|
||||
uint64_t validTimeMs = 0;
|
||||
|
||||
bool isValid() const;
|
||||
bool operator==(const FrameContentIdentity&) const;
|
||||
bool operator!=(const FrameContentIdentity& other) const { return !(*this == other); }
|
||||
};
|
||||
|
||||
struct FieldPlaneKey {
|
||||
uint16_t semanticID = 0;
|
||||
uint16_t groupID = 0;
|
||||
FieldComponent component = FieldComponent::None;
|
||||
|
||||
bool operator==(const FieldPlaneKey&) const;
|
||||
bool operator<(const FieldPlaneKey&) const;
|
||||
};
|
||||
|
||||
struct DecodedFieldPlane {
|
||||
FieldPlaneKey key;
|
||||
FieldDType dtype = FieldDType::Float32;
|
||||
FieldValueClass valueClass = FieldValueClass::Scalar;
|
||||
uint16_t unitID = 0;
|
||||
// REQUIRED is transport metadata retained by the provider-neutral runtime.
|
||||
bool requiredFlag = false;
|
||||
uint32_t count = 0;
|
||||
float scale = 1.0f;
|
||||
float offset = 0.0f;
|
||||
std::string contentIdentity;
|
||||
std::shared_ptr<const std::vector<uint8_t>> storage;
|
||||
|
||||
bool isStructurallyValid(uint64_t cellCount) const;
|
||||
bool isInterpolationCompatibleWith(const DecodedFieldPlane&) const;
|
||||
};
|
||||
|
||||
struct DecodedFieldGrid {
|
||||
uint8_t layout = 1;
|
||||
uint8_t projection = 0;
|
||||
uint8_t sampleOrder = 0;
|
||||
uint8_t gridRegistration = 0;
|
||||
uint32_t width = 0;
|
||||
uint32_t height = 0;
|
||||
double west = -180.0;
|
||||
double south = -85.0;
|
||||
double east = 180.0;
|
||||
double north = 85.0;
|
||||
|
||||
bool isValid() const;
|
||||
bool operator==(const DecodedFieldGrid&) const;
|
||||
};
|
||||
|
||||
struct DecodedFieldFrame {
|
||||
FrameContentIdentity frameContentIdentity;
|
||||
uint64_t referenceTimeMs = 0;
|
||||
DecodedFieldGrid grid;
|
||||
// One frame-wide validity domain. The descriptor and payload share storage;
|
||||
// projections can retain the same payload without copying it.
|
||||
DecodedFieldPlane validityPlane;
|
||||
std::string validityContentIdentity;
|
||||
std::shared_ptr<const std::vector<uint8_t>> validityBits;
|
||||
std::vector<DecodedFieldPlane> planes;
|
||||
|
||||
bool isStructurallyValid() const;
|
||||
bool isInterpolationCompatibleWith(const DecodedFieldFrame&) const;
|
||||
bool hasSameContentAs(const DecodedFieldFrame&) const;
|
||||
};
|
||||
|
||||
enum class FieldSeriesSubmitResult : uint8_t {
|
||||
Inserted,
|
||||
ReplacedRevision,
|
||||
Duplicate,
|
||||
RejectedInvalidFieldKey,
|
||||
RejectedInvalidFrame,
|
||||
RejectedIdentityConflict,
|
||||
RejectedStaleRevision,
|
||||
RejectedIncompatibleFrame,
|
||||
RejectedCapacity,
|
||||
};
|
||||
|
||||
struct FieldSeriesWindow {
|
||||
std::shared_ptr<const DecodedFieldFrame> t0;
|
||||
std::shared_ptr<const DecodedFieldFrame> t1;
|
||||
float mix = 0.0f;
|
||||
uint64_t requestedForecastTimeMs = 0;
|
||||
};
|
||||
|
||||
class FieldSeries final {
|
||||
public:
|
||||
explicit FieldSeries(size_t capacity = 8);
|
||||
|
||||
FieldSeriesSubmitResult submit(std::shared_ptr<const DecodedFieldFrame>);
|
||||
std::optional<FieldSeriesWindow> resolve(uint64_t requestedForecastTimeMs) const;
|
||||
size_t size() const { return frames.size(); }
|
||||
size_t capacity() const { return capacity_; }
|
||||
const std::vector<std::shared_ptr<const DecodedFieldFrame>>& entries() const { return frames; }
|
||||
|
||||
private:
|
||||
size_t capacity_;
|
||||
std::vector<std::shared_ptr<const DecodedFieldFrame>> frames;
|
||||
};
|
||||
|
||||
enum class ForecastPlaybackState : uint8_t { Paused, Playing };
|
||||
|
||||
class ForecastClock final {
|
||||
public:
|
||||
bool setRequestedForecastTime(uint64_t timestampMs);
|
||||
bool setPlaybackSpeed(float value);
|
||||
void play() { state_ = ForecastPlaybackState::Playing; }
|
||||
void pause() { state_ = ForecastPlaybackState::Paused; }
|
||||
bool advance(uint64_t elapsedMs);
|
||||
|
||||
uint64_t requestedForecastTime() const { return requestedForecastTimeMs_; }
|
||||
float playbackSpeed() const { return playbackSpeed_; }
|
||||
ForecastPlaybackState playbackState() const { return state_; }
|
||||
|
||||
private:
|
||||
uint64_t requestedForecastTimeMs_ = 0;
|
||||
float playbackSpeed_ = 1.0f;
|
||||
ForecastPlaybackState state_ = ForecastPlaybackState::Paused;
|
||||
};
|
||||
|
||||
class EncodedFieldStore final {
|
||||
public:
|
||||
explicit EncodedFieldStore(size_t perSeriesCapacity = 8) : perSeriesCapacity_(perSeriesCapacity) {}
|
||||
|
||||
FieldSeriesSubmitResult submit(const std::string& fieldKey, std::shared_ptr<const DecodedFieldFrame>);
|
||||
std::optional<FieldSeriesWindow> resolve(const std::string& fieldKey, uint64_t requestedForecastTimeMs) const;
|
||||
const FieldSeries* find(const std::string& fieldKey) const;
|
||||
bool clear(const std::string& fieldKey);
|
||||
void clearAll() { series.clear(); }
|
||||
size_t fieldCount() const { return series.size(); }
|
||||
|
||||
private:
|
||||
size_t perSeriesCapacity_;
|
||||
std::unordered_map<std::string, FieldSeries> series;
|
||||
};
|
||||
|
||||
} // namespace mbgl::particle
|
||||
108
src/mbgl/particle/field/field_profile_validator.cpp
Normal file
108
src/mbgl/particle/field/field_profile_validator.cpp
Normal file
@@ -0,0 +1,108 @@
|
||||
#include <mbgl/particle/field/field_profile_validator.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <map>
|
||||
|
||||
namespace mbgl::particle {
|
||||
namespace {
|
||||
|
||||
using PlaneRef = const DecodedFieldPlane*;
|
||||
|
||||
bool sameKey(const FieldPlaneRequirement& requirement, const DecodedFieldPlane& plane) {
|
||||
return requirement.semanticID == plane.key.semanticID && requirement.groupID == plane.key.groupID &&
|
||||
requirement.component == plane.key.component;
|
||||
}
|
||||
|
||||
bool matches(const FieldPlaneRequirement& requirement, const DecodedFieldPlane& plane) {
|
||||
return sameKey(requirement, plane) && requirement.valueClass == plane.valueClass &&
|
||||
requirement.unitID == plane.unitID && requirement.requiredFlag == plane.requiredFlag;
|
||||
}
|
||||
|
||||
FieldProfileValidationResult result(const FieldProfileValidationCode code, const std::string& detail) {
|
||||
return {code, detail};
|
||||
}
|
||||
|
||||
bool isNumeric(const FieldDType dtype) {
|
||||
return dtype != FieldDType::Bitset;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
FieldProfileValidationResult FieldProfileValidator::validate(const ResolvedFieldProfileContract& contract,
|
||||
const DecodedFieldFrame& frame) {
|
||||
if (contract.contractSchemaVersion != 1)
|
||||
return result(FieldProfileValidationCode::UnsupportedContractSchema, "contractSchemaVersion");
|
||||
if (!contract.isStructurallyValid()) return result(FieldProfileValidationCode::InvalidContract, "contract");
|
||||
if (!frame.isStructurallyValid()) return result(FieldProfileValidationCode::InvalidFrame, "frame");
|
||||
if (!contract.sharedValidityRequired)
|
||||
return result(FieldProfileValidationCode::UnsupportedSharedValidityMode, "sharedValidityRequired=false");
|
||||
|
||||
if ((contract.timeRelation == FieldTimeRelation::ValidGteReference &&
|
||||
frame.frameContentIdentity.validTimeMs < frame.referenceTimeMs) ||
|
||||
(contract.timeRelation == FieldTimeRelation::ValidEqReference &&
|
||||
frame.frameContentIdentity.validTimeMs != frame.referenceTimeMs))
|
||||
return result(FieldProfileValidationCode::TimeRelationMismatch, "reference/valid time");
|
||||
|
||||
std::vector<PlaneRef> actual;
|
||||
actual.reserve(frame.planes.size() + 1);
|
||||
actual.push_back(&frame.validityPlane);
|
||||
for (const auto& plane : frame.planes) actual.push_back(&plane);
|
||||
|
||||
for (const auto& required : contract.requiredPlanes) {
|
||||
const auto same = std::find_if(actual.begin(), actual.end(), [&required](const PlaneRef plane) {
|
||||
return sameKey(required, *plane);
|
||||
});
|
||||
if (same == actual.end()) return result(FieldProfileValidationCode::MissingRequiredPlane, "required plane absent");
|
||||
if (!matches(required, **same))
|
||||
return result(FieldProfileValidationCode::PlaneDescriptorMismatch, "required plane descriptor");
|
||||
}
|
||||
|
||||
for (const auto plane : actual) {
|
||||
const auto declared = std::find_if(contract.requiredPlanes.begin(), contract.requiredPlanes.end(),
|
||||
[plane](const FieldPlaneRequirement& requirement) {
|
||||
return matches(requirement, *plane);
|
||||
});
|
||||
if (plane->requiredFlag && declared == contract.requiredPlanes.end())
|
||||
return result(FieldProfileValidationCode::UndeclaredRequiredPlane, "actual REQUIRED plane");
|
||||
if (!plane->requiredFlag && !contract.allowAdditionalPlanes && declared == contract.requiredPlanes.end())
|
||||
return result(FieldProfileValidationCode::AdditionalPlaneNotAllowed, "additional plane");
|
||||
|
||||
if (plane->valueClass == FieldValueClass::Validity) {
|
||||
if (plane->dtype != FieldDType::Bitset || plane->unitID != FieldUnitNone || plane->key.groupID != 0 ||
|
||||
plane->key.component != FieldComponent::None)
|
||||
return result(FieldProfileValidationCode::InvalidCoreUnit, "validity descriptor");
|
||||
} else if (plane->valueClass == FieldValueClass::DirectionAngle) {
|
||||
if (plane->unitID != FieldUnitDegrees || !isNumeric(plane->dtype))
|
||||
return result(FieldProfileValidationCode::InvalidCoreUnit, "direction unit/dtype");
|
||||
} else if (plane->valueClass == FieldValueClass::VelocityComponent) {
|
||||
if (plane->unitID != FieldUnitMetersPerSecond || !isNumeric(plane->dtype))
|
||||
return result(FieldProfileValidationCode::InvalidCoreUnit, "velocity unit/dtype");
|
||||
} else if (plane->valueClass == FieldValueClass::Scalar && !isNumeric(plane->dtype)) {
|
||||
return result(FieldProfileValidationCode::InvalidFrame, "scalar dtype");
|
||||
}
|
||||
}
|
||||
|
||||
std::map<uint16_t, std::vector<PlaneRef>> velocityGroups;
|
||||
for (const auto plane : actual) {
|
||||
if (plane->valueClass == FieldValueClass::VelocityComponent) velocityGroups[plane->key.groupID].push_back(plane);
|
||||
}
|
||||
for (const auto& entry : velocityGroups) {
|
||||
if (entry.first == 0) return result(FieldProfileValidationCode::InvalidVelocityGroup, "velocity group zero");
|
||||
PlaneRef u = nullptr;
|
||||
PlaneRef v = nullptr;
|
||||
for (const auto plane : entry.second) {
|
||||
if (plane->key.component == FieldComponent::UEast) {
|
||||
if (u) return result(FieldProfileValidationCode::InvalidVelocityGroup, "duplicate U");
|
||||
u = plane;
|
||||
} else if (plane->key.component == FieldComponent::VNorth) {
|
||||
if (v) return result(FieldProfileValidationCode::InvalidVelocityGroup, "duplicate V");
|
||||
v = plane;
|
||||
}
|
||||
}
|
||||
if (!u || !v || u->count != v->count || u->unitID != v->unitID)
|
||||
return result(FieldProfileValidationCode::InvalidVelocityGroup, "incomplete velocity group");
|
||||
}
|
||||
return result(FieldProfileValidationCode::Accepted, "accepted");
|
||||
}
|
||||
|
||||
} // namespace mbgl::particle
|
||||
237
src/mbgl/particle/field/field_runtime.cpp
Normal file
237
src/mbgl/particle/field/field_runtime.cpp
Normal file
@@ -0,0 +1,237 @@
|
||||
#include <mbgl/particle/field/field_runtime.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <set>
|
||||
#include <tuple>
|
||||
|
||||
namespace mbgl::particle {
|
||||
namespace {
|
||||
|
||||
bool validProfileID(const std::string& value) {
|
||||
if (value.empty() || value.size() > 64) return false;
|
||||
return std::all_of(value.begin(), value.end(), [](const unsigned char c) {
|
||||
return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-';
|
||||
});
|
||||
}
|
||||
|
||||
bool newerRevision(const FrameContentIdentity& next, const FrameContentIdentity& current) {
|
||||
if (next.generation != current.generation) return next.generation > current.generation;
|
||||
if (next.dataVersion != current.dataVersion) return next.dataVersion > current.dataVersion;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool samePlaneSchema(const DecodedFieldPlane& a, const DecodedFieldPlane& b) {
|
||||
return a.key == b.key && a.dtype == b.dtype && a.valueClass == b.valueClass && a.unitID == b.unitID &&
|
||||
a.count == b.count && a.scale == b.scale && a.offset == b.offset;
|
||||
}
|
||||
|
||||
bool validValueClass(const FieldValueClass value) {
|
||||
return value <= FieldValueClass::Validity;
|
||||
}
|
||||
|
||||
bool validComponent(const FieldComponent value) {
|
||||
return value <= FieldComponent::WUp;
|
||||
}
|
||||
|
||||
bool validDType(const FieldDType value) {
|
||||
return value >= FieldDType::Int16 && value <= FieldDType::Bitset;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool ResolvedFieldProfileContract::isStructurallyValid() const {
|
||||
if (contractSchemaVersion != 1 || !validProfileID(profileID) || profileVersion == 0 || requiredPlanes.empty() ||
|
||||
timeRelation > FieldTimeRelation::ValidEqReference)
|
||||
return false;
|
||||
std::set<std::tuple<uint16_t, uint16_t, uint8_t>> keys;
|
||||
for (const auto& plane : requiredPlanes) {
|
||||
if (!plane.requiredFlag || plane.semanticID == 0 || !validValueClass(plane.valueClass) ||
|
||||
!validComponent(plane.component)) return false;
|
||||
const auto key = std::make_tuple(plane.semanticID, plane.groupID, static_cast<uint8_t>(plane.component));
|
||||
if (!keys.insert(key).second) return false;
|
||||
if (plane.valueClass == FieldValueClass::VelocityComponent &&
|
||||
(plane.groupID == 0 || plane.component == FieldComponent::None)) return false;
|
||||
if (plane.valueClass == FieldValueClass::Validity &&
|
||||
(plane.groupID != 0 || plane.component != FieldComponent::None)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool FrameContentIdentity::isValid() const {
|
||||
return !fieldID.empty() && generation > 0 && validTimeMs > 0;
|
||||
}
|
||||
|
||||
bool FrameContentIdentity::operator==(const FrameContentIdentity& other) const {
|
||||
return fieldID == other.fieldID && generation == other.generation && dataVersion == other.dataVersion &&
|
||||
validTimeMs == other.validTimeMs;
|
||||
}
|
||||
|
||||
bool FieldPlaneKey::operator==(const FieldPlaneKey& other) const {
|
||||
return semanticID == other.semanticID && groupID == other.groupID && component == other.component;
|
||||
}
|
||||
|
||||
bool FieldPlaneKey::operator<(const FieldPlaneKey& other) const {
|
||||
if (semanticID != other.semanticID) return semanticID < other.semanticID;
|
||||
if (groupID != other.groupID) return groupID < other.groupID;
|
||||
return static_cast<uint8_t>(component) < static_cast<uint8_t>(other.component);
|
||||
}
|
||||
|
||||
bool DecodedFieldPlane::isStructurallyValid(const uint64_t cellCount) const {
|
||||
return key.semanticID > 0 && validDType(dtype) && validValueClass(valueClass) && validComponent(key.component) &&
|
||||
count == cellCount && std::isfinite(scale) && scale > 0.0f && std::isfinite(offset) &&
|
||||
!contentIdentity.empty() && storage && !storage->empty();
|
||||
}
|
||||
|
||||
bool DecodedFieldPlane::isInterpolationCompatibleWith(const DecodedFieldPlane& other) const {
|
||||
return samePlaneSchema(*this, other);
|
||||
}
|
||||
|
||||
bool DecodedFieldGrid::isValid() const {
|
||||
return width > 0 && height > 0 && std::isfinite(west) && std::isfinite(south) && std::isfinite(east) &&
|
||||
std::isfinite(north) && west < east && south < north;
|
||||
}
|
||||
|
||||
bool DecodedFieldGrid::operator==(const DecodedFieldGrid& other) const {
|
||||
return layout == other.layout && projection == other.projection && sampleOrder == other.sampleOrder &&
|
||||
gridRegistration == other.gridRegistration && width == other.width && height == other.height &&
|
||||
west == other.west && south == other.south && east == other.east && north == other.north;
|
||||
}
|
||||
|
||||
bool DecodedFieldFrame::isStructurallyValid() const {
|
||||
if (!frameContentIdentity.isValid() || referenceTimeMs == 0 || !grid.isValid() || !validityBits ||
|
||||
validityBits->empty() || validityContentIdentity.empty() || planes.empty()) return false;
|
||||
const uint64_t cells = static_cast<uint64_t>(grid.width) * grid.height;
|
||||
const uint64_t expectedValidityBytes = (cells + 7u) / 8u;
|
||||
if (validityBits->size() != expectedValidityBytes) return false;
|
||||
if (!validityPlane.isStructurallyValid(cells) || validityPlane.contentIdentity != validityContentIdentity ||
|
||||
validityPlane.storage != validityBits) return false;
|
||||
std::set<FieldPlaneKey> keys;
|
||||
if (!keys.insert(validityPlane.key).second) return false;
|
||||
for (const auto& plane : planes) {
|
||||
if (!plane.isStructurallyValid(cells) || !keys.insert(plane.key).second) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DecodedFieldFrame::isInterpolationCompatibleWith(const DecodedFieldFrame& other) const {
|
||||
if (!(grid == other.grid) || !validityPlane.isInterpolationCompatibleWith(other.validityPlane) ||
|
||||
planes.size() != other.planes.size()) return false;
|
||||
for (size_t i = 0; i < planes.size(); ++i) {
|
||||
if (!planes[i].isInterpolationCompatibleWith(other.planes[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DecodedFieldFrame::hasSameContentAs(const DecodedFieldFrame& other) const {
|
||||
if (referenceTimeMs != other.referenceTimeMs || !(grid == other.grid) ||
|
||||
validityContentIdentity != other.validityContentIdentity ||
|
||||
!samePlaneSchema(validityPlane, other.validityPlane) || planes.size() != other.planes.size()) return false;
|
||||
for (size_t i = 0; i < planes.size(); ++i) {
|
||||
if (!samePlaneSchema(planes[i], other.planes[i]) || planes[i].contentIdentity != other.planes[i].contentIdentity)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
FieldSeries::FieldSeries(const size_t capacity) : capacity_(capacity) {}
|
||||
|
||||
FieldSeriesSubmitResult FieldSeries::submit(std::shared_ptr<const DecodedFieldFrame> frame) {
|
||||
if (!frame || !frame->isStructurallyValid()) return FieldSeriesSubmitResult::RejectedInvalidFrame;
|
||||
const auto timestamp = frame->frameContentIdentity.validTimeMs;
|
||||
auto it = std::lower_bound(frames.begin(), frames.end(), timestamp,
|
||||
[](const auto& item, const uint64_t value) {
|
||||
return item->frameContentIdentity.validTimeMs < value;
|
||||
});
|
||||
if (it != frames.end() && (*it)->frameContentIdentity.validTimeMs == timestamp) {
|
||||
const auto& current = **it;
|
||||
if (frame->frameContentIdentity == current.frameContentIdentity) {
|
||||
return frame->hasSameContentAs(current) ? FieldSeriesSubmitResult::Duplicate
|
||||
: FieldSeriesSubmitResult::RejectedIdentityConflict;
|
||||
}
|
||||
if (!newerRevision(frame->frameContentIdentity, current.frameContentIdentity))
|
||||
return FieldSeriesSubmitResult::RejectedStaleRevision;
|
||||
if (!frame->isInterpolationCompatibleWith(current))
|
||||
return FieldSeriesSubmitResult::RejectedIncompatibleFrame;
|
||||
if (it != frames.begin() && !frame->isInterpolationCompatibleWith(**std::prev(it)))
|
||||
return FieldSeriesSubmitResult::RejectedIncompatibleFrame;
|
||||
if (std::next(it) != frames.end() && !frame->isInterpolationCompatibleWith(**std::next(it)))
|
||||
return FieldSeriesSubmitResult::RejectedIncompatibleFrame;
|
||||
*it = std::move(frame);
|
||||
return FieldSeriesSubmitResult::ReplacedRevision;
|
||||
}
|
||||
if (frames.size() >= capacity_) return FieldSeriesSubmitResult::RejectedCapacity;
|
||||
if (it != frames.begin() && !frame->isInterpolationCompatibleWith(**std::prev(it)))
|
||||
return FieldSeriesSubmitResult::RejectedIncompatibleFrame;
|
||||
if (it != frames.end() && !frame->isInterpolationCompatibleWith(**it))
|
||||
return FieldSeriesSubmitResult::RejectedIncompatibleFrame;
|
||||
frames.insert(it, std::move(frame));
|
||||
return FieldSeriesSubmitResult::Inserted;
|
||||
}
|
||||
|
||||
std::optional<FieldSeriesWindow> FieldSeries::resolve(const uint64_t requestedForecastTimeMs) const {
|
||||
if (frames.size() < 2 || requestedForecastTimeMs < frames.front()->frameContentIdentity.validTimeMs ||
|
||||
requestedForecastTimeMs > frames.back()->frameContentIdentity.validTimeMs) return std::nullopt;
|
||||
if (requestedForecastTimeMs == frames.back()->frameContentIdentity.validTimeMs) {
|
||||
return FieldSeriesWindow{frames[frames.size() - 2], frames.back(), 1.0f, requestedForecastTimeMs};
|
||||
}
|
||||
const auto upper = std::upper_bound(frames.begin(), frames.end(), requestedForecastTimeMs,
|
||||
[](const uint64_t value, const auto& item) {
|
||||
return value < item->frameContentIdentity.validTimeMs;
|
||||
});
|
||||
if (upper == frames.begin() || upper == frames.end()) return std::nullopt;
|
||||
const auto lower = std::prev(upper);
|
||||
const uint64_t t0 = (*lower)->frameContentIdentity.validTimeMs;
|
||||
const uint64_t t1 = (*upper)->frameContentIdentity.validTimeMs;
|
||||
if (t1 <= t0 || !(*lower)->isInterpolationCompatibleWith(**upper)) return std::nullopt;
|
||||
const double ratio = static_cast<double>(requestedForecastTimeMs - t0) / static_cast<double>(t1 - t0);
|
||||
return FieldSeriesWindow{*lower, *upper, static_cast<float>(std::clamp(ratio, 0.0, 1.0)), requestedForecastTimeMs};
|
||||
}
|
||||
|
||||
bool ForecastClock::setRequestedForecastTime(const uint64_t timestampMs) {
|
||||
if (timestampMs == 0) return false;
|
||||
requestedForecastTimeMs_ = timestampMs;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ForecastClock::setPlaybackSpeed(const float value) {
|
||||
if (value != 0.5f && value != 1.0f && value != 2.0f) return false;
|
||||
playbackSpeed_ = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ForecastClock::advance(const uint64_t elapsedMs) {
|
||||
if (state_ != ForecastPlaybackState::Playing || requestedForecastTimeMs_ == 0) return false;
|
||||
const long double delta = static_cast<long double>(elapsedMs) * playbackSpeed_;
|
||||
if (delta > static_cast<long double>(std::numeric_limits<uint64_t>::max() - requestedForecastTimeMs_)) return false;
|
||||
requestedForecastTimeMs_ += static_cast<uint64_t>(std::llround(delta));
|
||||
return true;
|
||||
}
|
||||
|
||||
FieldSeriesSubmitResult EncodedFieldStore::submit(const std::string& fieldKey,
|
||||
std::shared_ptr<const DecodedFieldFrame> frame) {
|
||||
if (fieldKey.empty()) return FieldSeriesSubmitResult::RejectedInvalidFieldKey;
|
||||
auto it = series.find(fieldKey);
|
||||
if (it == series.end()) it = series.emplace(fieldKey, FieldSeries{perSeriesCapacity_}).first;
|
||||
const auto result = it->second.submit(std::move(frame));
|
||||
if (result == FieldSeriesSubmitResult::RejectedInvalidFrame && it->second.size() == 0) series.erase(it);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::optional<FieldSeriesWindow> EncodedFieldStore::resolve(const std::string& fieldKey,
|
||||
const uint64_t requestedForecastTimeMs) const {
|
||||
const auto* found = find(fieldKey);
|
||||
return found ? found->resolve(requestedForecastTimeMs) : std::nullopt;
|
||||
}
|
||||
|
||||
const FieldSeries* EncodedFieldStore::find(const std::string& fieldKey) const {
|
||||
const auto it = series.find(fieldKey);
|
||||
return it == series.end() ? nullptr : &it->second;
|
||||
}
|
||||
|
||||
bool EncodedFieldStore::clear(const std::string& fieldKey) {
|
||||
return series.erase(fieldKey) != 0;
|
||||
}
|
||||
|
||||
} // namespace mbgl::particle
|
||||
@@ -18,6 +18,7 @@ add_library(
|
||||
${PROJECT_SOURCE_DIR}/test/math/minmax.test.cpp
|
||||
${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/plugin/plugin.test.cpp
|
||||
${PROJECT_SOURCE_DIR}/test/renderer/image_manager.test.cpp
|
||||
${PROJECT_SOURCE_DIR}/test/renderer/pattern_atlas.test.cpp
|
||||
|
||||
154
test/particle/field_runtime.test.cpp
Normal file
154
test/particle/field_runtime.test.cpp
Normal file
@@ -0,0 +1,154 @@
|
||||
#include <mbgl/particle/field/field_profile_validator.hpp>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace mbgl;
|
||||
|
||||
namespace {
|
||||
|
||||
particle::DecodedFieldPlane plane(uint16_t semantic, particle::FieldValueClass valueClass, uint16_t unit,
|
||||
uint16_t group, particle::FieldComponent component, particle::FieldDType dtype,
|
||||
bool required, std::shared_ptr<const std::vector<uint8_t>> storage,
|
||||
std::string identity = "plane") {
|
||||
return {.key = {semantic, group, component}, .dtype = dtype, .valueClass = valueClass, .unitID = unit,
|
||||
.requiredFlag = required, .count = 4, .scale = 0.1f, .offset = 0.0f,
|
||||
.contentIdentity = std::move(identity), .storage = std::move(storage)};
|
||||
}
|
||||
|
||||
particle::DecodedFieldFrame frame() {
|
||||
const auto numbers = 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>{0x0F});
|
||||
particle::DecodedFieldFrame value;
|
||||
value.frameContentIdentity = {"future-product", 1, 1, 1'000};
|
||||
value.referenceTimeMs = 1'000;
|
||||
value.grid = {.layout = 1, .projection = 1, .sampleOrder = 1, .gridRegistration = 1, .width = 2, .height = 2,
|
||||
.west = 130.0, .south = 30.0, .east = 131.0, .north = 31.0};
|
||||
value.validityContentIdentity = "validity";
|
||||
value.validityBits = bits;
|
||||
value.validityPlane = plane(900, particle::FieldValueClass::Validity, particle::FieldUnitNone, 0,
|
||||
particle::FieldComponent::None, particle::FieldDType::Bitset, true, bits, "validity");
|
||||
value.planes.push_back(plane(777, particle::FieldValueClass::Scalar, 65432, 0, particle::FieldComponent::None,
|
||||
particle::FieldDType::UInt8, true, numbers, "scalar"));
|
||||
return value;
|
||||
}
|
||||
|
||||
particle::ResolvedFieldProfileContract contract() {
|
||||
particle::ResolvedFieldProfileContract value;
|
||||
value.profileID = "future.product-42";
|
||||
value.profileVersion = 1;
|
||||
value.allowAdditionalPlanes = true;
|
||||
value.requiredPlanes = {{777, particle::FieldValueClass::Scalar, 65432, 0, particle::FieldComponent::None, true},
|
||||
{900, particle::FieldValueClass::Validity, particle::FieldUnitNone, 0,
|
||||
particle::FieldComponent::None, true}};
|
||||
return value;
|
||||
}
|
||||
|
||||
particle::FieldProfileValidationCode validate(const particle::ResolvedFieldProfileContract& c,
|
||||
const particle::DecodedFieldFrame& f) {
|
||||
return particle::FieldProfileValidator::validate(c, f).code;
|
||||
}
|
||||
|
||||
std::shared_ptr<const particle::DecodedFieldFrame> runtimeFrame(uint64_t time, uint64_t generation,
|
||||
uint64_t version, std::string content,
|
||||
std::string id = "field") {
|
||||
auto value = frame();
|
||||
value.frameContentIdentity = {std::move(id), generation, version, time};
|
||||
value.planes.front().contentIdentity = std::move(content);
|
||||
return std::make_shared<const particle::DecodedFieldFrame>(std::move(value));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(ParticleFieldProfileValidator, AcceptsGenericProfileAndOpaqueScalarUnit) {
|
||||
EXPECT_EQ(validate(contract(), frame()), particle::FieldProfileValidationCode::Accepted);
|
||||
}
|
||||
|
||||
TEST(ParticleFieldProfileValidator, EnforcesRequiredPlanesAndAdditionalPlanePolicy) {
|
||||
auto c = contract(); auto f = frame();
|
||||
f.planes.clear();
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::MissingRequiredPlane);
|
||||
f = frame(); f.planes.front().unitID = 99;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::PlaneDescriptorMismatch);
|
||||
f = frame(); f.planes.front().requiredFlag = false;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::PlaneDescriptorMismatch);
|
||||
f = frame(); f.planes.push_back(plane(778, particle::FieldValueClass::Scalar, 123, 0, particle::FieldComponent::None,
|
||||
particle::FieldDType::UInt8, true, f.planes.front().storage, "required-extra"));
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::UndeclaredRequiredPlane);
|
||||
f.planes.back().requiredFlag = false; c.allowAdditionalPlanes = false;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::AdditionalPlaneNotAllowed);
|
||||
c.allowAdditionalPlanes = true;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::Accepted);
|
||||
}
|
||||
|
||||
TEST(ParticleFieldProfileValidator, EnforcesVelocityDirectionAndValidityCoreRules) {
|
||||
auto c = contract(); auto f = frame(); const auto bytes = f.planes.front().storage;
|
||||
f.planes.push_back(plane(10, particle::FieldValueClass::VelocityComponent, particle::FieldUnitMetersPerSecond, 3,
|
||||
particle::FieldComponent::UEast, particle::FieldDType::Int16, false, bytes));
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::InvalidVelocityGroup);
|
||||
f.planes.push_back(plane(11, particle::FieldValueClass::VelocityComponent, particle::FieldUnitMetersPerSecond, 3,
|
||||
particle::FieldComponent::VNorth, particle::FieldDType::Int16, false, bytes));
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::Accepted);
|
||||
f.planes.back().unitID = 99;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::InvalidCoreUnit);
|
||||
f = frame();
|
||||
f.planes.push_back(plane(10, particle::FieldValueClass::VelocityComponent, particle::FieldUnitMetersPerSecond, 0,
|
||||
particle::FieldComponent::UEast, particle::FieldDType::Int16, false, bytes));
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::InvalidVelocityGroup);
|
||||
f = frame();
|
||||
f.planes.push_back(plane(10, particle::FieldValueClass::VelocityComponent, particle::FieldUnitMetersPerSecond, 3,
|
||||
particle::FieldComponent::UEast, particle::FieldDType::Int16, false, bytes));
|
||||
f.planes.push_back(plane(11, particle::FieldValueClass::VelocityComponent, particle::FieldUnitMetersPerSecond, 3,
|
||||
particle::FieldComponent::UEast, particle::FieldDType::Int16, false, bytes));
|
||||
f.planes.push_back(plane(12, particle::FieldValueClass::VelocityComponent, particle::FieldUnitMetersPerSecond, 3,
|
||||
particle::FieldComponent::VNorth, particle::FieldDType::Int16, false, bytes));
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::InvalidVelocityGroup);
|
||||
f = frame(); f.planes.front().valueClass = particle::FieldValueClass::DirectionAngle; f.planes.front().unitID = 99;
|
||||
c.requiredPlanes.front().valueClass = particle::FieldValueClass::DirectionAngle; c.requiredPlanes.front().unitID = 99;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::InvalidCoreUnit);
|
||||
c = contract(); f = frame(); f.validityPlane.dtype = particle::FieldDType::UInt8;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::InvalidCoreUnit);
|
||||
f = frame(); f.validityPlane.unitID = 1;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::InvalidCoreUnit);
|
||||
}
|
||||
|
||||
TEST(ParticleFieldProfileValidator, AppliesSchemaSharedValidityAndTimeRules) {
|
||||
auto c = contract(); auto f = frame(); c.contractSchemaVersion = 2;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::UnsupportedContractSchema);
|
||||
c = contract(); c.sharedValidityRequired = false;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::UnsupportedSharedValidityMode);
|
||||
c = contract(); f.frameContentIdentity.validTimeMs = 999;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::Accepted);
|
||||
c.timeRelation = particle::FieldTimeRelation::ValidGteReference;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::TimeRelationMismatch);
|
||||
f.frameContentIdentity.validTimeMs = 1'000;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::Accepted);
|
||||
c.timeRelation = particle::FieldTimeRelation::ValidEqReference; f.frameContentIdentity.validTimeMs = 1'001;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::TimeRelationMismatch);
|
||||
}
|
||||
|
||||
TEST(ParticleFieldRuntime, RevisionIdentityAndIndependentCadence) {
|
||||
particle::FieldSeries series; const uint64_t base = 1'700'000'000'000ULL, hour = 3'600'000ULL;
|
||||
EXPECT_EQ(series.submit(runtimeFrame(base, 1, 1, "a")), particle::FieldSeriesSubmitResult::Inserted);
|
||||
EXPECT_EQ(series.submit(runtimeFrame(base, 1, 1, "a")), particle::FieldSeriesSubmitResult::Duplicate);
|
||||
EXPECT_EQ(series.submit(runtimeFrame(base, 1, 1, "changed")), particle::FieldSeriesSubmitResult::RejectedIdentityConflict);
|
||||
EXPECT_EQ(series.submit(runtimeFrame(base, 2, 2, "b")), particle::FieldSeriesSubmitResult::ReplacedRevision);
|
||||
particle::EncodedFieldStore store;
|
||||
EXPECT_EQ(store.submit("wind-key", runtimeFrame(base, 1, 1, "w0", "wind")), particle::FieldSeriesSubmitResult::Inserted);
|
||||
EXPECT_EQ(store.submit("wind-key", runtimeFrame(base + hour, 2, 1, "w1", "wind")), particle::FieldSeriesSubmitResult::Inserted);
|
||||
EXPECT_EQ(store.submit("wave-key", runtimeFrame(base, 1, 1, "a0", "wave")), particle::FieldSeriesSubmitResult::Inserted);
|
||||
EXPECT_EQ(store.submit("wave-key", runtimeFrame(base + 3 * hour, 2, 1, "a3", "wave")), particle::FieldSeriesSubmitResult::Inserted);
|
||||
const auto wind = store.resolve("wind-key", base + hour / 2), wave = store.resolve("wave-key", base + hour / 2);
|
||||
ASSERT_TRUE(wind); ASSERT_TRUE(wave); EXPECT_FLOAT_EQ(wind->mix, 0.5f); EXPECT_FLOAT_EQ(wave->mix, 1.0f / 6.0f);
|
||||
}
|
||||
|
||||
TEST(ParticleFieldRuntime, ForecastClockOwnsRequestedTimeAndPlayback) {
|
||||
particle::ForecastClock clock;
|
||||
EXPECT_TRUE(clock.setRequestedForecastTime(1'700'000'000'000ULL)); EXPECT_FALSE(clock.advance(1'000));
|
||||
EXPECT_TRUE(clock.setPlaybackSpeed(2.0f)); clock.play(); EXPECT_TRUE(clock.advance(500));
|
||||
EXPECT_EQ(clock.requestedForecastTime(), 1'700'000'001'000ULL); clock.pause();
|
||||
EXPECT_FALSE(clock.advance(500)); EXPECT_FALSE(clock.setPlaybackSpeed(3.0f));
|
||||
}
|
||||
@@ -685,3 +685,9 @@ WeatherColorLayer 的 `ParticleGpuValidationUBO` designated initializer 补齐 `
|
||||
### 2026-09-11 STEP-072 / V3-08 CLOSED / PASS
|
||||
|
||||
Android V3-08 单次综合验收已完成并关闭。Native `13.4.1-particle.2` 已发布并验证;RN `13.4.1-particle.3` 通过正式 npm tarball + 远程 Maven 消费链。Win11 Gradle 8.14.3 / AGP 8.12.0 / Kotlin 2.1.20 构建 PASS。实体机 `192.168.1.146` 五组 Wind/Rain/Wave/Tide/Wave synthetic 均通过 T0/T1/READY 门槛,播放、速度、FPS、seek、独立显隐和视觉 API 均 ACCEPTED,fatal/JNI 扫描为空。
|
||||
|
||||
### 2026-09-13 STEP-073 QBIN2 V4 Core 与通用 Profile Validator
|
||||
|
||||
- 新增 provider-neutral `DecodedFieldFrame` / FieldSeries / ForecastClock primitive 及 Contract Schema v1 的纯数据驱动 Validator;不接入 QBIN decoder、Renderer、V3 生产路径、Android/JNI/RN。
|
||||
- Validity 采用独立 descriptor 与共享只读 payload;REQUIRED 双向一致、额外 plane、时间关系、共享 validity 能力和 Velocity/Direction/Validity Core 规则均 fail-closed 且结构化返回。
|
||||
- Linux OpenGL `mbgl-test` 构建、新 Validator/Runtime 定向测试和既有 V3 Field/Timeline 定向回归均 PASS;未执行 Android Gradle/APK/ADB。
|
||||
|
||||
Reference in New Issue
Block a user