diff --git a/.github/workflows/release-packages.yml b/.github/workflows/release-packages.yml index 7ea5464..b84b695 100644 --- a/.github/workflows/release-packages.yml +++ b/.github/workflows/release-packages.yml @@ -26,7 +26,7 @@ name: Release packages expected_version: description: 'Hand SDK version (transport may use a compatible newer patch)' required: true - default: '0.3.8' + default: '0.3.9' permissions: contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e66337..fab9749 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ [English](CHANGELOG.md) | [简体中文](CHANGELOG.zh-CN.md) +## 0.3.9 + +- Align explicit Python-policy polling counters and logical transaction latency with the Python reference, including nearest-even percentiles and a bounded device-lifetime window. +- Add force-field health projection and shared-query interval resolution; preserve Native defaults and the transport v0.3.4 minimum. +- Rebuild SDK and consumers. No new hardware acceptance is claimed; see [release notes](RELEASE_NOTES.md). + ## 0.3.8 - Correct bounded poll shutdown, logical health generations and final optional-query errors in the explicit Python policy. diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index d767fa7..fa274d4 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -2,6 +2,12 @@ [English](CHANGELOG.md) | [简体中文](CHANGELOG.zh-CN.md) +## 0.3.9 + +- 统一显式 Python 策略的轮询计数和逻辑事务延迟,使用与 Python 一致的百分位取整及设备生命周期内的有界窗口。 +- 补齐力字段健康投影和共享查询间隔合并,保留 Native 默认行为及 transport v0.3.4 最低依赖。 +- SDK 和消费者需要重新编译,不新增实机验收结论,详见[发行说明](RELEASE_NOTES.md)。 + ## 0.3.8 - 修正显式 Python 策略下的有界停止轮询、健康逻辑代次和可选查询降级后的最终错误记录。 diff --git a/CMakeLists.txt b/CMakeLists.txt index aa7da0d..a176e95 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.16) project(linkerbot-hand-cpp - VERSION 0.3.8 + VERSION 0.3.9 DESCRIPTION "LinkerBot dexterous hand C++ SDK" LANGUAGES CXX ) diff --git a/README.en.md b/README.en.md index ce29bac..68b8354 100644 --- a/README.en.md +++ b/README.en.md @@ -60,7 +60,7 @@ This installs the SDK and communication library into the local `install/` direct Catch2 and vendor development packages are not required. Run the remaining commands from this repository's root directory. -Hand SDK 0.3.8 requires transport 0.3.4 or newer in the 0.3 minor line, +Hand SDK 0.3.9 requires transport 0.3.4 or newer in the 0.3 minor line, for source builds, prebuilt packages and installed consumers. To reuse a prebuilt transport v0.3.4 SDK, configure with diff --git a/README.md b/README.md index ce29bac..68b8354 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ This installs the SDK and communication library into the local `install/` direct Catch2 and vendor development packages are not required. Run the remaining commands from this repository's root directory. -Hand SDK 0.3.8 requires transport 0.3.4 or newer in the 0.3 minor line, +Hand SDK 0.3.9 requires transport 0.3.4 or newer in the 0.3 minor line, for source builds, prebuilt packages and installed consumers. To reuse a prebuilt transport v0.3.4 SDK, configure with diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 56f1f62..597d5e5 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,15 @@ # Release notes +## 0.3.9 + +- Project Python-policy polling counters independently of native transaction totals. Inactive plans report zero polling counters; active reads, verified angle command feedback and disabled optional queries retain their distinct accounting. +- Measure complete logical transactions from their first transport admission through successful completion, including transport waiting and multi-stage execution. Partial stage success does not add a completed logical transaction or clear a prior logical error. Keep the latest 2048 successful durations across close, reopen and recovery, with Python nearest-even percentile selection. Native latency accounting remains unchanged. +- Add Python-policy force-field health rows with requested, shared-query and achieved rates, query identity and observed support. Optional `PollingChannel::force_fields` entries select named fields at millisecond intervals. Shared queries use the fastest requested interval; field rows must not be summed as physical traffic. L20Lite scalar categories remain independent. Native configurations reject the new selector. + +Existing C++ method signatures and Native defaults remain available. Public structure additions require rebuilding the SDK and downstream consumers with matching headers and libraries; binary compatibility with earlier headers is not claimed. The minimum and paired transport remain v0.3.4 (`0e190032d65ed0b315337b45b3779cf4f650ed68`). + +Validation covers offline lifecycle, polling, command feedback, multi-stage failure, field-query resolution, bounded latency windows, source/prebuilt/installed consumers, sanitizers, complete static analysis and the twelve-target release matrix. No new physical-device acceptance is claimed. Deployed adapters/drivers, firmware, physical timestamps, mixed CAN/FD topology and multi-device duration tests require separate hardware validation. O20, Python bindings and hand/arm integration are outside this patch. Tactile-stage angle scheduling, extended identity parity and optional automatic reconnect remain separate work. + ## 0.3.8 - Bound Python-policy `stop_polling()` by `shutdown_timeout`. Remove unclaimed poll demand, release poll observers and wait for already-claimed polling transactions to finish. Keep coalesced active readers and other devices running. A stop timeout leaves the device open and its polling intent disabled; recovery does not restore the stopped plan. diff --git a/docs/en/guides/python-behavior-baseline.md b/docs/en/guides/python-behavior-baseline.md index 46804a1..ab0dee4 100644 --- a/docs/en/guides/python-behavior-baseline.md +++ b/docs/en/guides/python-behavior-baseline.md @@ -146,3 +146,18 @@ Public Python-policy health generations, including channel ages and recent-error `HandDeviceConfig::route_queue_capacity` and `HandRuntimeConfig::route_queue_capacity` are optional Python-only settings. Unset selects 256 frames per existing device route inbox; a positive override is propagated through discovery, direct creation and recovery. This differs from `read_waiter_capacity` (active caller capacity) and the Session's transmit capacity. Native construction retains its 512-frame default and rejects the new override. Conflicting capacity for an existing endpoint is rejected without rebinding it. Existing C++ signatures remain available; rebuild SDK and consumers after the appended configuration fields. This batch is offline preparation. It does not add O20, Python bindings, hand/arm integration or hardware acceptance. Tactile-stage angle scheduling, extended/raw identity fields, O30 field-by-field identity queries and optional automatic reconnect remain separate work. + + +## Fifth compatibility batch (0.3.9) + +Only `RuntimeBehavior::kPython` uses the following health rules. Native counters, latency calculations and defaults are retained. + +Polling counters describe the current plan's measurement window. Completing a query increments one read and one eligible feedback sample, even when multiple callers share the query. Publishing a report alone does not count a completed polling read. Angle command feedback follows the selected query-only, command-only or adaptive policy. Before a plan starts and after it stops, polling counters and requested rates are zero. Applying the same plan preserves the window; replacing it resets the window. Disabled optional queries retain their completed counts with zero active query rate. + +Global transaction latency records one successful logical operation, including a multi-stage wire exchange, rather than one sample per native request stage. The window contains the most recent 2048 successful operations and uses Python's nearest-index, ties-to-even percentile selection. Timing starts at the first Session submission and includes its send wait. Failed logical operations do not advance successful latency or its timestamp. A caller timeout does not erase a later underlying completion. Successful identity wire acquisition is recorded before subsequent fingerprint validation. This device-local history survives close, reopen and recovery; a failed initial open discards its provisional history. It is independent between devices sharing a Session. + +`HandHealth::force_fields` reports each field's requested rate, resolved query, active query rate, achieved query rate and observed support. Requested field intent is distinct from physical traffic. Unknown descriptor support is represented by an empty optional, with zero achieved field rate until observed. Fields sharing a query use the fastest requested interval, not a sum of rates. Independent L20Lite scalar categories retain separate query identities. Descriptor observations can remove unsupported fields from active polling without erasing desired field intent or unrelated counts. Health reads perform no device I/O. + +`PollingChannel::force_fields` is an optional field-level selector on `kForce`. For example, `{{"fn", 20ms}, {"fns", 50ms}}` requests 50 Hz and 20 Hz respectively; if both share one wire query, that query runs at 50 Hz. An empty selector preserves the existing channel form and resolves Python's default force fields `fn` and `fns`. Intervals are positive representable whole milliseconds. Invalid or duplicate field names and unsupported explicit fields are rejected before replacing the active plan. Native policy rejects a nonempty field selector. Public methods remain available, but consumers must rebuild for the appended public structure fields. + +Offline tests cover counter boundaries, logical success/failure, shared-device isolation, force projection and an independent Python percentile comparison over 6144 rolling-window updates. These tests do not establish hardware timing or throughput. O20, Python bindings, hand/arm integration, tactile-stage angle scheduling and remaining identity/reconnect differences remain outside this batch. diff --git a/docs/en/guides/releases.md b/docs/en/guides/releases.md index f363d85..774143f 100644 --- a/docs/en/guides/releases.md +++ b/docs/en/guides/releases.md @@ -16,7 +16,7 @@ availability of proprietary ARM64 drivers. Hand SDK packages contain both hand and paired transport static libraries, headers, CMake packages, documentation and read-only command-line examples. Test executables and proprietary drivers/libraries -are excluded. Versions remain independent. Hand 0.3.8 requires transport 0.3.4 or newer in +are excluded. Versions remain independent. Hand 0.3.9 requires transport 0.3.4 or newer in the 0.3 minor line in source, prebuilt and installed-consumer builds. diff --git a/docs/en/guides/validation.md b/docs/en/guides/validation.md index d113164..3c74afa 100644 --- a/docs/en/guides/validation.md +++ b/docs/en/guides/validation.md @@ -14,7 +14,7 @@ A parent-provided `linkerbot::comm` target is reused and version-checked. The legacy empty `LINKERBOT_COMM_SOURCE_DIR=` still selects package discovery through `CMAKE_PREFIX_PATH` in AUTO mode. Configure logs identify the selected dependency. Use separate build directories when switching dependencies to avoid stale CMake caches. -Hand 0.3.8 requires transport 0.3.4 or newer in the 0.3 minor line. Source, +Hand 0.3.9 requires transport 0.3.4 or newer in the 0.3 minor line. Source, prebuilt and installed-consumer checks reject older patches and other minor lines. For paired development, keep the source commit pinned in README and CI; record the actual revision and configuration in test reports. @@ -123,3 +123,10 @@ hardware reports apply only to their recorded revision and environment. `.vscode/` is ignored in full. Keep machine paths and personal launch settings local; maintained build and test commands belong in CMake, the justfile and docs. + + +## Python-policy health statistics + +`python-health-vectors` compares all 6144 updates of a 2048-sample rolling window against an independent Python rounding oracle. With `LINKERBOT_PYTHON_SDK_DIR` configured, it also checks the reference SDK's `RuntimeMetrics`. The normal test needs only Python's standard library. The reference-SDK mode additionally needs that checkout's dependencies and transport on `PYTHONPATH`. This checks percentile selection, duplicates and eviction; it is not a hardware latency benchmark. + +Python-policy communication regressions run in `python-policy-regressions`; `comm` runs the remaining communication cases. The groups partition the original communication selection without omissions or duplicates. Per-process acceptance budgets and individual transaction deadlines are unchanged. diff --git a/docs/zh/guides/python-behavior-baseline.md b/docs/zh/guides/python-behavior-baseline.md index ea1e184..b1ed727 100644 --- a/docs/zh/guides/python-behavior-baseline.md +++ b/docs/zh/guides/python-behavior-baseline.md @@ -117,3 +117,18 @@ Python 策略公开健康状态、通道采样年龄和最近错误上下文的 新增 `HandDeviceConfig::route_queue_capacity` 和 `HandRuntimeConfig::route_queue_capacity`,仅允许 Python 策略使用。未指定时,现有每设备路由接收队列容量为 256 帧;正值覆盖通过直接构造、发现和恢复传递。它不是 `read_waiter_capacity` 主动等待者容量,也不是 Session 发送队列容量。Native 构造仍使用默认 512 帧,并拒绝新的容量覆盖。同一端点已有不同容量绑定时拒绝复用,不修改其连接。新增结构体字段要求重新编译 SDK 和消费者。 本批为离线准备,不增加 O20、Python 绑定、hand/arm 接入或实机验收。触觉阶段之间的角度调度、扩展及原始身份字段、O30 逐字段身份查询和可选自动重连仍为独立工作。 + + +## 第五批兼容修正(0.3.9) + +以下规则仅用于显式 `RuntimeBehavior::kPython`,Native 计数、延迟算法和默认行为保持不变。 + +轮询统计只描述当前计划的测量窗口。一次查询完成计入一次读取和符合反馈策略的一次反馈,即使多个调用方共享该查询也不重复计数。单独发布报告不增加轮询完成数。角度命令反馈按 query-only、command-only 或 adaptive 策略统计。未开始和停止计划后,轮询计数和请求频率归零;相同计划保留窗口,替换计划重置窗口;降级停用的可选查询保留历史计数,活动查询频率为零。 + +全局事务延迟按一次成功的逻辑操作统计,多阶段交换不按底层阶段重复计数。窗口保留最近 2048 次成功操作,百分位采用 Python 最近索引、恰好居中时取偶数的算法。计时从首次向 Session 提交开始,包括发送等待。失败逻辑操作不更新成功延迟和时间戳。调用方超时不抹除底层随后成功完成的记录。身份信息在线路上成功获取后、身份指纹校验前记录事务成功。每个设备的历史在关闭、重新打开和恢复间保留;首次打开失败时丢弃临时历史。共享 Session 的设备不共享这一统计窗口。 + +`HandHealth::force_fields` 分别展示字段请求频率、解析后的查询、活动查询频率、已完成查询频率和观测到的支持状态。字段意图与实际查询分开表达;未知描述符支持状态用空 optional 表示,在确认前字段达成频率为零。共享查询取最快的请求间隔,不相加;L20Lite 标量类别保留独立查询。描述符确认不支持某字段后,可停止相应活动查询,但保留字段意图和其他计数。读取健康状态不触发设备通信。 + +`PollingChannel::force_fields` 是 `kForce` 上的可选字段选择器。例如 `{{"fn", 20ms}, {"fns", 50ms}}` 分别请求 50 Hz 和 20 Hz;若共用查询,实际查询为 50 Hz。空选择器保留原有通道用法,解析为 Python 默认字段 `fn`、`fns`。间隔为可表示的正整数毫秒。非法名称、重复字段和显式不支持的字段在替换计划前拒绝。Native 策略拒绝非空字段选择器。公开方法保持兼容;公开结构追加字段后,SDK 和消费者需要重新编译。 + +离线回归覆盖计数边界、逻辑事务成功与失败、共享 Session 的设备隔离、力字段投影,以及 6144 次窗口更新的独立 Python 百分位对照。这些验证不代表硬件时序或吞吐验收。本批不包括 O20、Python 绑定、hand/arm 接入、触觉阶段角度调度以及剩余身份字段和重连差异。 diff --git a/docs/zh/guides/releases.md b/docs/zh/guides/releases.md index 5d0a2ca..ae97621 100644 --- a/docs/zh/guides/releases.md +++ b/docs/zh/guides/releases.md @@ -15,7 +15,7 @@ libstdc++ ABI1 和 C++17。Windows 使用原生架构 runner 与 v143 14.44 工 产物是开发 SDK,Hand 包包含 hand 与配套 transport 静态库、头文件、CMake 包和文档, 保留只读命令行示例;不包含测试程序、厂商驱动或厂商库。 -两个项目版本独立。hand 0.3.8 的源码、预构建及安装消费者均要求 transport 0.3.x 且不低于 0.3.4。 +两个项目版本独立。hand 0.3.9 的源码、预构建及安装消费者均要求 transport 0.3.x 且不低于 0.3.4。 配套源码固定提交和预构建下载统一使用 v0.3.4 发行版。transport 公共结构有新增字段, diff --git a/docs/zh/guides/validation.md b/docs/zh/guides/validation.md index 58f5f36..c7b1dca 100644 --- a/docs/zh/guides/validation.md +++ b/docs/zh/guides/validation.md @@ -13,7 +13,7 @@ 父项目已经提供 `linkerbot::comm` 时,复用父项目的目标,并检查版本。 显式设置旧参数 `LINKERBOT_COMM_SOURCE_DIR=` 仍可在 AUTO 下通过 `CMAKE_PREFIX_PATH` 使用安装包。配置日志会显示实际选择的依赖;切换依赖时使用独立构建目录,避免旧 CMake 缓存。 -hand 0.3.8 要求 transport 0.3.x 且不低于 0.3.4。源码、预构建及安装消费者测试均拒绝更低补丁版本和其他 minor 版本。 +hand 0.3.9 要求 transport 0.3.x 且不低于 0.3.4。源码、预构建及安装消费者测试均拒绝更低补丁版本和其他 minor 版本。 源码开发继续使用 README 与 CI 固定的提交,并在测试报告中记录实际版本和构建配置。 ### 从源码构建 @@ -107,3 +107,10 @@ PR 作业执行离线测试和协议检查;main 分支还按工作流的平台 整个 `.vscode/` 目录均被忽略。机器路径和个人启动配置留在本地,项目维护的构建、 测试命令应放在 CMake、justfile 和文档中。 + + +## Python 策略健康统计 + +`python-health-vectors` 将 2048 样本窗口的全部 6144 次更新与独立 Python 取整算法比较。配置 `LINKERBOT_PYTHON_SDK_DIR` 后,还比较参考 SDK 的 `RuntimeMetrics`。常规测试仅依赖 Python 标准库;参考 SDK 模式需要安装该版本的依赖,并在 `PYTHONPATH` 中提供 transport。测试覆盖百分位、重复值和样本淘汰,不代表硬件延迟基准。 + +Python 策略通信回归单独放在 `python-policy-regressions`,其余通信测试继续由 `comm` 执行。两组覆盖原通信选择集合,无遗漏、无重复。每个进程的验收预算及单事务超时均未调整。 diff --git a/include/linkerbot/hand/api/events.hpp b/include/linkerbot/hand/api/events.hpp index af175a4..b736fd7 100644 --- a/include/linkerbot/hand/api/events.hpp +++ b/include/linkerbot/hand/api/events.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -8,11 +9,20 @@ #include namespace linkerbot::hand { + struct ForceFieldPolling { + std::string field; + std::chrono::milliseconds interval{100}; + }; + struct PollingChannel { TelemetrySemantic semantic = TelemetrySemantic::kAngles; std::chrono::milliseconds interval{100}; // Used for angles by the explicit Python runtime policy only. AngleFeedbackPolicy angle_feedback = AngleFeedbackPolicy::kAdaptive; + // Python-only field selection on kForce. Nonempty selections + // use their own intervals, resolved into shared queries at the fastest + // requested interval. Empty retains the existing query-channel API. + std::vector force_fields; }; using PollingPlan = std::vector; diff --git a/include/linkerbot/hand/health.hpp b/include/linkerbot/hand/health.hpp index 9e676af..aa39a00 100644 --- a/include/linkerbot/hand/health.hpp +++ b/include/linkerbot/hand/health.hpp @@ -122,6 +122,15 @@ namespace linkerbot::hand { LatencyMetrics execution_latency; }; + struct ForceFieldMetrics { + std::string field; + double requested_hz = 0.0; + double query_hz = 0.0; + double achieved_hz = 0.0; + std::optional query_channel; + std::optional supported; + }; + // Complete, zero-I/O snapshot for one hand connection generation. The // runtime returns this value by copy from mutex-protected cached state; // reading health never sends a frame or waits for a device response. @@ -149,6 +158,9 @@ namespace linkerbot::hand { LatencyMetrics transaction_latency; can::TransportHealth transport; transport::SessionHealth session; + // Python policy: field intent and shared-query rates. Never sum these + // rows to estimate physical traffic; channel counters count queries once. + std::vector force_fields; [[nodiscard]] bool connected() const noexcept { return connection_state == ConnectionState::kConnected; } diff --git a/src/hand/runtime/device_runtime.cpp b/src/hand/runtime/device_runtime.cpp index b088465..735b651 100644 --- a/src/hand/runtime/device_runtime.cpp +++ b/src/hand/runtime/device_runtime.cpp @@ -348,6 +348,7 @@ namespace linkerbot::hand::runtime { Deadline deadline; std::function generation_valid; unified_detail::RuntimeAccess::ErrorCapture error_capture; + unified_detail::RuntimeAccess::TimingCapture timing; }; thread_local std::map response_policies; } @@ -381,6 +382,7 @@ namespace linkerbot::hand::runtime { bool scoped_command = false; bool scoped_response_policy = false; unified_detail::RuntimeAccess::ErrorCapture error_capture; + unified_detail::RuntimeAccess::TimingCapture timing; bool response_deadline_armed = false; bool response_window_expiry = false; std::optional receipt_response_deadline() const { @@ -700,7 +702,9 @@ namespace linkerbot::hand::runtime { state.sample_times.pop_front(); } last_successful_receive = received_at; - consecutive_errors = 0; + if (!python_endpoint_policy) { + consecutive_errors = 0; + } if (latency) { state.latencies.push_back(*latency); if (state.latencies.size() > kHealthLatencyWindowCapacity) { @@ -860,6 +864,9 @@ namespace linkerbot::hand::runtime { } if (error) { error = record_error_locked(std::move(error), state->context, ErrorCode::kProtocol); + if (state->timing) { + state->timing->failed.store(true, std::memory_order_release); + } if (state->error_capture) { state->error_capture->store(error_revision, std::memory_order_release); } @@ -964,7 +971,9 @@ namespace linkerbot::hand::runtime { } } else { ++health.completed; - consecutive_errors = 0; + if (!state->scoped_response_policy) { + consecutive_errors = 0; + } if (completed_at) { last_successful_transaction = completed_at; if (state->scoped_response_policy) { @@ -1471,6 +1480,7 @@ namespace linkerbot::hand::runtime { state->scoped_response_policy = response_policies.count(this) != 0; if (const auto policy = response_policies.find(this); policy != response_policies.end()) { state->error_capture = policy->second.error_capture; + state->timing = policy->second.timing; } state->owner = std::this_thread::get_id(); auto &p = *impl_; @@ -1619,6 +1629,9 @@ namespace linkerbot::hand::runtime { // Admission does not perform I/O. Install the receipt while still // holding runtime state so cancel cannot lose claimed-send evidence. const auto submit = [&] { + if (state->timing && !state->timing->started) { + state->timing->started = Clock::now(); + } return p.session->submit({state->plan.frames, state->plan.gap, state->plan.initial_delay}, options); }; const auto execution = p.command_executions.find(state->owner); @@ -2794,16 +2807,17 @@ void linkerbot::hand::unified_detail::RuntimeAccess::execute_command( void linkerbot::hand::unified_detail::RuntimeAccess::with_response_windows( runtime::DeviceRuntime &runtime, PlanTransform transform, const std::function &execute, - runtime::Deadline execution_deadline, std::function generation_valid, ErrorCapture error_capture) { + runtime::Deadline execution_deadline, std::function generation_valid, ErrorCapture error_capture, + TimingCapture timing) { { const std::lock_guard lock(runtime.impl_->mutex); runtime.impl_->python_endpoint_policy = true; } auto previous = runtime::response_policies.extract(&runtime); try { - runtime::response_policies.emplace(&runtime, runtime::ResponsePolicy{std::move(transform), execution_deadline, - std::move(generation_valid), - std::move(error_capture)}); + runtime::response_policies.emplace( + &runtime, runtime::ResponsePolicy{std::move(transform), execution_deadline, std::move(generation_valid), + std::move(error_capture), std::move(timing)}); } catch (...) { if (!previous.empty()) { runtime::response_policies.insert(std::move(previous)); @@ -2978,3 +2992,16 @@ void linkerbot::hand::unified_detail::RuntimeAccess::initialized_health(runtime: device.impl_->last_success_error_revision = device.impl_->error_revision; } } + +void linkerbot::hand::unified_detail::RuntimeAccess::logical_response_completed(runtime::DeviceRuntime &device) { + const auto policy = runtime::response_policies.find(&device); + if (policy != runtime::response_policies.end() && policy->second.timing) { + policy->second.timing->completed = runtime::Clock::now(); + } +} +void linkerbot::hand::unified_detail::RuntimeAccess::logical_transaction_succeeded(runtime::DeviceRuntime &device) { + const std::lock_guard lock(device.impl_->mutex); + if (device.impl_->last_success_error_revision == device.impl_->error_revision) { + device.impl_->consecutive_errors = 0; + } +} diff --git a/src/hand/unified/backend.cpp b/src/hand/unified/backend.cpp index a3d76d6..1d98256 100644 --- a/src/hand/unified/backend.cpp +++ b/src/hand/unified/backend.cpp @@ -349,12 +349,15 @@ namespace linkerbot::hand::unified_detail { while (true) { auto value = reads_->read_prepared( static_cast(semantic), deadline, - [this, semantic, valid, polling](const std::shared_ptr &lane) { + [this, semantic, valid](const std::shared_ptr &lane) { ReadValue result; auto capture = std::make_shared>(0); const auto before = RuntimeAccess::error_revision(*device_->runtime()); with_python_read_windows( - [&] { result = python_read(semantic, polling, valid, lane, capture, before); }, + [&] { + result = python_read(semantic, reads_->claimed_by_poll(), valid, lane, capture, + before); + }, valid, capture); return result; }, @@ -494,6 +497,16 @@ namespace linkerbot::hand::unified_detail { python_->wait(sequence, deadline, include_feedback, generation_valid); } } + void set_python_health(const std::shared_ptr &metrics) override { + if (python_) { + python_->set_health(metrics); + } + } + void set_python_capabilities(const DeviceCapabilities &caps) override { + if (python_) { + python_->configure(caps); + } + } void finish_initialization() override { if (python_) { RuntimeAccess::initialized_health(*device_->runtime()); @@ -589,6 +602,7 @@ namespace linkerbot::hand::unified_detail { if (previous != view.samples.end()) { expected = std::any_cast(previous->second.value); } + RuntimeAccess::logical_response_completed(*device_->runtime()); validate_connection_identity(identity, config_, expected ? &*expected : nullptr, "refresh_device_info"); auto provenance = device_->runtime()->response_provenance(); @@ -812,6 +826,9 @@ namespace linkerbot::hand::unified_detail { } } } + if (const auto *force = std::get_if(&result)) { + python_->observe_force(*force); + } python_->read_succeeded(semantic, polling); return result; } catch (const HandError &error) { @@ -839,7 +856,7 @@ namespace linkerbot::hand::unified_detail { execute_python_transactions( *device_->runtime(), config_, execute, runtime::Deadline{runtime::Clock::duration{execution_deadline_.load(std::memory_order_acquire)}}, - valid, false, error_capture); + valid, false, python_->health_metrics(), error_capture); } void with_windows(const std::function &execute, const std::function &valid = {}, bool identity = false) { @@ -852,7 +869,7 @@ namespace linkerbot::hand::unified_detail { execute_python_transactions( *device_->runtime(), config_, execute, runtime::Deadline{runtime::Clock::duration{execution_deadline_.load(std::memory_order_acquire)}}, - valid, identity); + valid, identity, python_->health_metrics()); } HandDeviceConfig config_; DeviceCapabilities capabilities_; diff --git a/src/hand/unified/connection_initializer.cpp b/src/hand/unified/connection_initializer.cpp index 8a61e52..ddb767b 100644 --- a/src/hand/unified/connection_initializer.cpp +++ b/src/hand/unified/connection_initializer.cpp @@ -92,6 +92,9 @@ namespace linkerbot::hand::unified_detail { const ErrorContext context{std::string(operation), model_name(config.model)}; auto verified = capabilities_for_identity(identity, &config); + if (config.runtime_behavior == RuntimeBehavior::kPython) { + backend.set_python_capabilities(verified); + } if (verified.model != declared.model) { throw ProtocolError("verified capabilities do not match declared capabilities", context); } diff --git a/src/hand/unified/detail/backend.hpp b/src/hand/unified/detail/backend.hpp index 57430f6..b12fd11 100644 --- a/src/hand/unified/detail/backend.hpp +++ b/src/hand/unified/detail/backend.hpp @@ -12,6 +12,7 @@ #include namespace linkerbot::hand::unified_detail { + class PythonHealthMetrics; struct CommandRequest { CommandSemantic semantic = CommandSemantic::kAngles; std::vector raw_values; @@ -55,6 +56,8 @@ namespace linkerbot::hand::unified_detail { (void)generation_valid; } virtual void finish_initialization() {} + virtual void set_python_health(const std::shared_ptr &) {} + virtual void set_python_capabilities(const DeviceCapabilities &) {} [[nodiscard]] virtual CommandHandle submit(const CommandRequest &request) = 0; [[nodiscard]] virtual TelemetrySample read(TelemetrySemantic semantic, std::chrono::milliseconds timeout) = 0; @@ -86,4 +89,8 @@ namespace linkerbot::hand::unified_detail { [[nodiscard]] std::unique_ptr make_backend(const HandDeviceConfig &config, const HandDevice::TransportFactory &transport_factory, const std::shared_ptr &shared_session); + std::unique_ptr make_backend_with_python_health(const HandDeviceConfig &, + const HandDevice::TransportFactory &, + const std::shared_ptr &, + const std::shared_ptr &); } // namespace linkerbot::hand::unified_detail diff --git a/src/hand/unified/detail/python_health.hpp b/src/hand/unified/detail/python_health.hpp new file mode 100644 index 0000000..7467664 --- /dev/null +++ b/src/hand/unified/detail/python_health.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include + +namespace linkerbot::hand::unified_detail { + // Device-lifetime logical completions. No transport ownership or scheduling. + class PythonHealthMetrics { + public: + PythonHealthMetrics(); + void completed(HealthTimePoint started, HealthTimePoint finished); + void project(HandHealth &health) const; + void clear(); + + private: + mutable std::mutex mutex_; + std::deque latencies_; + std::vector ordered_; + mutable std::optional cached_; + std::optional last_transaction_; + }; +} diff --git a/src/hand/unified/detail/python_polling.hpp b/src/hand/unified/detail/python_polling.hpp new file mode 100644 index 0000000..4a99f94 --- /dev/null +++ b/src/hand/unified/detail/python_polling.hpp @@ -0,0 +1,14 @@ +#pragma once +#include +#include + +namespace linkerbot::hand::unified_detail { + struct PythonPollingPlan { + PollingPlan queries; + std::map fields; + }; + DeviceCapabilities python_force_capabilities(DeviceCapabilities capabilities); + std::string python_health_channel(TelemetrySemantic semantic); + PythonPollingPlan resolve_python_polling(const PollingPlan &plan, const DeviceCapabilities &capabilities, + bool strict = true); +} diff --git a/src/hand/unified/detail/python_state.hpp b/src/hand/unified/detail/python_state.hpp index d82802f..bf60483 100644 --- a/src/hand/unified/detail/python_state.hpp +++ b/src/hand/unified/detail/python_state.hpp @@ -1,4 +1,6 @@ #pragma once +#include "python_health.hpp" +#include "python_polling.hpp" #include "runtime_access.hpp" #include #include @@ -18,6 +20,10 @@ namespace linkerbot::hand::unified_detail { uint64_t sequence = 0; }; explicit PythonState(const HandDeviceConfig &config); + void set_health(const std::shared_ptr &metrics); + void configure(const DeviceCapabilities &capabilities); + PythonHealthMetrics &health_metrics() const { return *health_; } + void observe_force(const ForceSample &sample); void publish(const std::string &domain, const runtime::Sample &sample); View view(bool include_feedback = true) const; void wait(uint64_t sequence, runtime::Deadline deadline, bool include_feedback, @@ -59,7 +65,9 @@ namespace linkerbot::hand::unified_detail { bool optional(TelemetrySemantic semantic) const; void disable_locked(TelemetrySemantic semantic); void visible_locked(bool command_feedback); + DeviceCapabilities capabilities_locked(DeviceCapabilities value) const; HandDeviceConfig config_; + DeviceCapabilities base_capabilities_; std::map domains_; mutable std::mutex mutex_; mutable std::condition_variable changed_; @@ -71,6 +79,9 @@ namespace linkerbot::hand::unified_detail { std::map failures_; std::map polls_; std::optional plan_; + std::map field_intervals_; + std::map observed_force_; + std::shared_ptr health_ = std::make_shared(); uint64_t identity_sequence_ = 0; uint64_t plan_sequence_ = 0; bool stopped_ = false; diff --git a/src/hand/unified/detail/python_transactions.hpp b/src/hand/unified/detail/python_transactions.hpp index 69f4042..702c1d7 100644 --- a/src/hand/unified/detail/python_transactions.hpp +++ b/src/hand/unified/detail/python_transactions.hpp @@ -9,9 +9,10 @@ #include namespace linkerbot::hand::unified_detail { + class PythonHealthMetrics; void execute_python_transactions(runtime::DeviceRuntime &device, const HandDeviceConfig &config, const std::function &execute, runtime::Deadline deadline, - const std::function &valid, bool identity, + const std::function &valid, bool identity, PythonHealthMetrics &metrics, RuntimeAccess::ErrorCapture error_capture = {}); inline const char *python_polling_domain(TelemetrySemantic semantic) { diff --git a/src/hand/unified/detail/read_coordinator.hpp b/src/hand/unified/detail/read_coordinator.hpp index f0cd93a..a5683bd 100644 --- a/src/hand/unified/detail/read_coordinator.hpp +++ b/src/hand/unified/detail/read_coordinator.hpp @@ -160,6 +160,10 @@ namespace linkerbot::hand::unified_detail { std::unique_lock lock(mutex_); return changed_.wait_until(lock, deadline, [&] { return !active_poll_; }); } + bool claimed_by_poll() { + const std::lock_guard lock(mutex_); + return active_poll_; + } size_t waiter_count() { const std::lock_guard lock(mutex_); return waiters_; diff --git a/src/hand/unified/detail/runtime_access.hpp b/src/hand/unified/detail/runtime_access.hpp index b82852c..941c658 100644 --- a/src/hand/unified/detail/runtime_access.hpp +++ b/src/hand/unified/detail/runtime_access.hpp @@ -22,6 +22,14 @@ namespace linkerbot::hand::unified_detail { static void initialized_health(runtime::DeviceRuntime &); static size_t inbox_capacity(const runtime::DeviceRuntime &); using ErrorCapture = std::shared_ptr>; + struct LogicalTiming { + std::optional started; + std::optional completed; + std::atomic failed{false}; + }; + using TimingCapture = std::shared_ptr; + static void logical_response_completed(runtime::DeviceRuntime &); + static void logical_transaction_succeeded(runtime::DeviceRuntime &); static uint64_t error_revision(const runtime::DeviceRuntime &); static void promote_error(runtime::DeviceRuntime &, uint64_t revision, const HandError &error, std::optional unrecorded_revision = {}); @@ -39,7 +47,8 @@ namespace linkerbot::hand::unified_detail { static void with_response_windows(runtime::DeviceRuntime &runtime, PlanTransform transform, const std::function &execute, runtime::Deadline execution_deadline = runtime::Deadline::max(), - std::function generation_valid = {}, ErrorCapture error_capture = {}); + std::function generation_valid = {}, ErrorCapture error_capture = {}, + TimingCapture timing = {}); using SendObserver = std::function; using Submission = std::function; using AdmitSend = std::function; diff --git a/src/hand/unified/device.cpp b/src/hand/unified/device.cpp index 92e1a23..cd64e75 100644 --- a/src/hand/unified/device.cpp +++ b/src/hand/unified/device.cpp @@ -9,6 +9,8 @@ #include "detail/event_stream.hpp" #include "detail/force.hpp" #include "detail/freshness.hpp" +#include "detail/python_health.hpp" +#include "detail/python_polling.hpp" #include #include @@ -111,6 +113,9 @@ namespace linkerbot::hand { unified_detail::validate_config(config_); declared_capabilities_ = declared_capabilities(config_); cached_snapshot_ = unified_detail::initial_cached_snapshot(declared_capabilities_); + if (config_.runtime_behavior == RuntimeBehavior::kPython) { + python_health_ = std::make_shared(); + } scheduler_ = std::make_unique( config_.command_rate_hz, config_.discrete_queue_capacity, unified_detail::CommandScheduler::Now{}, config_.runtime_behavior == RuntimeBehavior::kPython); @@ -299,6 +304,10 @@ namespace linkerbot::hand { } [[nodiscard]] std::unique_ptr create_backend() { + if (python_health_) { + return unified_detail::make_backend_with_python_health(config_, transport_factory_, shared_session_, + python_health_); + } return unified_detail::make_backend(config_, transport_factory_, shared_session_); } @@ -464,6 +473,23 @@ namespace linkerbot::hand { void project_public_health(HandHealth &health, State state) const { project_health(health, state); if (config_.runtime_behavior == RuntimeBehavior::kPython) { + if (health.channels.empty()) { + const auto caps = unified_detail::python_force_capabilities( + python_health_capabilities_.value_or(declared_capabilities_)); + for (const auto &capability : caps.telemetry) { + ChannelMetrics channel; + channel.channel = unified_detail::python_health_channel(capability.semantic); + channel.supported = std::find(caps.firmware_unsupported_telemetry.begin(), + caps.firmware_unsupported_telemetry.end(), + capability.semantic) == caps.firmware_unsupported_telemetry.end(); + health.channels.push_back(std::move(channel)); + } + for (const auto &field : caps.force_fields()) { + health.force_fields.push_back( + {std::string(field.name), 0, 0, 0, field.query_channel, field.supported}); + } + } + python_health_->project(health); health.connection_generation = cache_generation_; for (auto &channel : health.channels) { channel.data_age.generation = cache_generation_; @@ -674,6 +700,8 @@ namespace linkerbot::hand { std::shared_future lifecycle_result_; std::shared_mutex io_mutex_; std::shared_ptr backend_; + std::shared_ptr python_health_; + std::optional python_health_capabilities_; std::atomic generation_{0}; // Object-level constraint. Unlike identity_, this survives close(), // failed open() and failed recovery attempts. @@ -754,6 +782,7 @@ namespace linkerbot::hand { {"open", unified_detail::model_name(impl_->config_.model)}); } opening_generation = ++impl_->generation_; + impl_->python_health_capabilities_.reset(); impl_->refresh_error_.reset(); impl_->state_.store(Impl::State::kOpening, std::memory_order_release); impl_->identity_.reset(); @@ -794,6 +823,9 @@ namespace linkerbot::hand { { const std::lock_guard lock(impl_->mutex_); impl_->state_.store(Impl::State::kClosed, std::memory_order_release); + if (impl_->python_health_ && impl_->cache_generation_ == 0) { + impl_->python_health_->clear(); + } impl_->open_.store(false, std::memory_order_release); } impl_->snapshot_cv_.notify_all(); @@ -825,6 +857,9 @@ namespace linkerbot::hand { return; } impl_->invalidate_commands("close"); + if (impl_->python_health_) { + impl_->python_health_capabilities_ = impl_->capabilities_unlocked(); + } ++impl_->generation_; impl_->state_.store(Impl::State::kClosing, std::memory_order_release); closing = impl_->backend_; @@ -1516,8 +1551,15 @@ namespace linkerbot::hand { plan.push_back({TelemetrySemantic::kAngles, impl_->config_.polling_interval}); } + auto queries = plan; + if (impl_->config_.runtime_behavior == RuntimeBehavior::kPython) { + queries = unified_detail::resolve_python_polling(plan, capabilities).queries; + } else if (std::any_of(plan.begin(), plan.end(), + [](const auto &channel) { return !channel.force_fields.empty(); })) { + throw PollingPlanError("force field intervals require the Python policy"); + } std::set unique; - for (const auto &channel : plan) { + for (const auto &channel : queries) { if (channel.angle_feedback != AngleFeedbackPolicy::kQueryOnly && channel.angle_feedback != AngleFeedbackPolicy::kCommandFeedbackOnly && channel.angle_feedback != AngleFeedbackPolicy::kAdaptive) { diff --git a/src/hand/unified/python_health.cpp b/src/hand/unified/python_health.cpp new file mode 100644 index 0000000..0b2b3d2 --- /dev/null +++ b/src/hand/unified/python_health.cpp @@ -0,0 +1,60 @@ +#include "detail/python_health.hpp" +#include "detail/backend.hpp" +#include +#include + +namespace linkerbot::hand::unified_detail { + std::unique_ptr + make_backend_with_python_health(const HandDeviceConfig &config, const HandDevice::TransportFactory &factory, + const std::shared_ptr &session, + const std::shared_ptr &metrics) { + auto backend = make_backend(config, factory, session); + backend->set_python_health(metrics); + return backend; + } + PythonHealthMetrics::PythonHealthMetrics() { ordered_.reserve(kHealthLatencyWindowCapacity + 1); } + void PythonHealthMetrics::completed(HealthTimePoint started, HealthTimePoint finished) { + if (finished < started) { + throw ValidationError("logical transaction duration must be non-negative"); + } + const std::lock_guard lock(mutex_); + latencies_.push_back(finished - started); + if (latencies_.size() > kHealthLatencyWindowCapacity) { + ordered_.erase(std::lower_bound(ordered_.begin(), ordered_.end(), latencies_.front())); + latencies_.pop_front(); + } + ordered_.insert(std::upper_bound(ordered_.begin(), ordered_.end(), finished - started), finished - started); + cached_.reset(); + last_transaction_ = finished; + } + void PythonHealthMetrics::project(HandHealth &health) const { + const std::lock_guard lock(mutex_); + health.last_successful_transaction = last_transaction_; + if (!cached_) { + LatencyMetrics metrics; + metrics.count = ordered_.size(); + if (!ordered_.empty()) { + const auto percentile = [&](size_t percentage) { + const auto scaled = (ordered_.size() - 1) * percentage; + const auto lower = scaled / 100; + const auto remainder = scaled % 100; + // Python round(): nearest index, with exact halves going to even. + const auto index = lower + (remainder > 50 || (remainder == 50 && lower % 2 != 0)); + return ordered_[index]; + }; + metrics.p50 = percentile(50); + metrics.p95 = percentile(95); + metrics.p99 = percentile(99); + } + cached_ = metrics; + } + health.transaction_latency = cached_.value_or(LatencyMetrics{}); + } + void PythonHealthMetrics::clear() { + const std::lock_guard lock(mutex_); + latencies_.clear(); + ordered_.clear(); + cached_.reset(); + last_transaction_.reset(); + } +} diff --git a/src/hand/unified/python_polling.cpp b/src/hand/unified/python_polling.cpp new file mode 100644 index 0000000..d7ba767 --- /dev/null +++ b/src/hand/unified/python_polling.cpp @@ -0,0 +1,157 @@ +#include "detail/python_polling.hpp" +#include "detail/force.hpp" +#include "detail/python_transactions.hpp" +#include +#include +#include + +namespace linkerbot::hand::unified_detail { + namespace { + using S = TelemetrySemantic; + void validate_interval(std::chrono::milliseconds interval) { + const auto available = + std::chrono::duration_cast(runtime::Deadline::max() - runtime::Clock::now()); + if (interval.count() <= 0 || interval >= available) { + throw PollingPlanError("polling interval must be positive and representable"); + } + } + bool is_force(S semantic) { return semantic >= S::kForce && semantic <= S::kForcePx; } + const char *field_name(S semantic) { + switch (semantic) { + case S::kForceFns: + return "fns"; + case S::kForceFt: + return "ft"; + case S::kForceFts: + return "fts"; + case S::kForceFd: + return "fd"; + case S::kForceFds: + return "fds"; + case S::kForcePx: + return "px"; + default: + throw PollingPlanError("unknown force query"); + } + } + } + std::string python_health_channel(S semantic) { + switch (semantic) { + case S::kAngles: + return "angles"; + case S::kMeasuredSpeeds: + return "speeds"; + case S::kCurrents: + return "currents"; + case S::kTemperatures: + return "temperatures"; + case S::kFaults: + return "faults"; + case S::kTorques: + return "torques"; + case S::kVoltages: + return "voltages"; + default: + return python_polling_domain(semantic); + } + } + DeviceCapabilities python_force_capabilities(DeviceCapabilities caps) { + auto fields = caps.force_fields(); + if (caps.model == Model::kL20Lite) { + for (auto &field : fields) { + for (const auto semantic : {S::kForceFns, S::kForceFts, S::kForceFds, S::kForcePx}) { + if (field.name == field_name(semantic) && caps.supports(semantic)) { + field.query_channel = semantic; + field.shared_fields = {field.name}; + } + } + } + } + caps.observed_force_fields = std::move(fields); + return caps; + } + PythonPollingPlan resolve_python_polling(const PollingPlan &plan, const DeviceCapabilities &capabilities, + bool strict) { + const auto caps = python_force_capabilities(capabilities); + PythonPollingPlan result; + std::map queries; + std::set unique; + std::set defaults; + bool default_force = false; + bool default_supported = false; + for (const auto &channel : plan) { + validate_interval(channel.interval); + if (!unique.insert(channel.semantic).second) { + throw PollingPlanError("polling semantics must be unique"); + } + if (channel.angle_feedback != AngleFeedbackPolicy::kQueryOnly && + channel.angle_feedback != AngleFeedbackPolicy::kCommandFeedbackOnly && + channel.angle_feedback != AngleFeedbackPolicy::kAdaptive) { + throw PollingPlanError("unknown angle feedback policy"); + } + if (!channel.force_fields.empty() && channel.semantic != S::kForce) { + throw PollingPlanError("force field intervals require the force selector"); + } + if (!is_force(channel.semantic)) { + queries.emplace(channel.semantic, channel); + continue; + } + auto fields = channel.force_fields; + if (fields.empty()) { + if (channel.semantic == S::kForce) { + default_force = true; + fields = {{"fn", channel.interval}, {"fns", channel.interval}}; + defaults = {"fn", "fns"}; + } else { + fields = {{field_name(channel.semantic), channel.interval}}; + } + } + for (const auto &field : fields) { + validate_force_field(field.field); + validate_interval(field.interval); + if (!result.fields.emplace(field.field, field.interval).second) { + throw PollingPlanError("force fields must be unique"); + } + } + } + for (const auto &[name, interval] : result.fields) { + const auto field = caps.force_field(name); + const bool allowed = field.supported.value_or(true); + if (default_force && defaults.count(name) && allowed) { + default_supported = true; + } + if (!allowed) { + if (!strict || defaults.count(name)) { + continue; + } + if (field.firmware_unsupported) { + throw UnsupportedFirmwareError("firmware does not support requested force field"); + } + throw UnsupportedOperationError("requested force field is not supported"); + } + if (!field.query_channel) { + continue; + } + const auto semantic = field.query_channel.value_or(S::kForce); + const auto found = queries.find(semantic); + if (found == queries.end()) { + queries.emplace(semantic, PollingChannel{semantic, interval}); + } else { + found->second.interval = std::min(found->second.interval, interval); + } + } + if (strict && default_force && !default_supported) { + const bool firmware = + caps.force_field("fn").firmware_unsupported || caps.force_field("fns").firmware_unsupported; + if (firmware) { + throw UnsupportedFirmwareError("firmware does not support default force polling"); + } + throw UnsupportedOperationError("default force polling is not supported"); + } + for (auto &[semantic, query] : queries) { + (void)semantic; + result.queries.push_back(std::move(query)); + } + return result; + } +} diff --git a/src/hand/unified/python_state.cpp b/src/hand/unified/python_state.cpp index 5386102..aebeb3a 100644 --- a/src/hand/unified/python_state.cpp +++ b/src/hand/unified/python_state.cpp @@ -20,37 +20,65 @@ namespace linkerbot::hand::unified_detail { return std::all_of(left.begin(), left.end(), [&](const auto &entry) { return std::any_of(right.begin(), right.end(), [&](const auto &other) { return entry.semantic == other.semantic && entry.interval == other.interval && - entry.angle_feedback == other.angle_feedback; + entry.angle_feedback == other.angle_feedback && + entry.force_fields.size() == other.force_fields.size() && + std::all_of(entry.force_fields.begin(), entry.force_fields.end(), [&](const auto &field) { + return std::any_of( + other.force_fields.begin(), other.force_fields.end(), [&](const auto &value) { + return field.field == value.field && field.interval == value.interval; + }); + }); }); }); } - std::string health_name(S semantic) { - switch (semantic) { - case S::kAngles: - return "angles"; - case S::kMeasuredSpeeds: - return "speeds"; - case S::kCurrents: - return "currents"; - case S::kTemperatures: - return "temperatures"; - case S::kFaults: - return "faults"; - case S::kTorques: - return "torques"; - case S::kVoltages: - return "voltages"; - default: - return python_polling_domain(semantic); - } - } + } - PythonState::PythonState(const HandDeviceConfig &config) : config_(config) { + PythonState::PythonState(const HandDeviceConfig &config) + : config_(config), base_capabilities_(declared_capabilities(config)) { const auto caps = declared_capabilities(config); for (const auto &channel : caps.telemetry) { domains_.emplace(domain(channel.semantic), channel.semantic); } } + void PythonState::configure(const DeviceCapabilities &caps) { + const std::lock_guard lock(mutex_); + base_capabilities_ = caps; + } + void PythonState::set_health(const std::shared_ptr &metrics) { + if (!metrics) { + throw ValidationError("Python health metrics must not be empty"); + } + const std::lock_guard lock(mutex_); + health_ = metrics; + } + void PythonState::observe_force(const ForceSample &sample) { + if (sample.sensors.size() != 5) { + return; + } + const std::lock_guard lock(mutex_); + for (const auto name : kForceFields) { + observed_force_[std::string(name)] = sample.field(name) != nullptr; + } + if (!plan_) { + return; + } + const auto resolved = + resolve_python_polling(plan_.value_or(PollingPlan{}), capabilities_locked(base_capabilities_), false); + for (auto &[semantic, state] : polls_) { + if (semantic < S::kForce || semantic > S::kForcePx || !state.enabled) { + continue; + } + bool needed = false; + for (const auto &query : resolved.queries) { + if (query.semantic == semantic) { + state.channel.interval = query.interval; + needed = true; + break; + } + } + state.enabled = needed; + } + } std::string PythonState::domain(S semantic) const { if (config_.model == Model::kO30 && semantic != S::kForce && semantic != S::kForceFns) { return o30::v25::domain(o30::v25::object(semantic)); @@ -90,7 +118,7 @@ namespace linkerbot::hand::unified_detail { // Internal optional-feedback timeout notification. Keep the last // successful acquisition and count only the active angle policy. if (found != domains_.end() && found->second == S::kAngles) { - if (auto poll = polls_.find(S::kAngles); poll != polls_.end()) { + if (auto poll = polls_.find(S::kAngles); poll != polls_.end() && poll->second.enabled) { ++poll->second.timeouts; } } @@ -127,9 +155,6 @@ namespace linkerbot::hand::unified_detail { ++state.feedback; state.next = std::max(state.next, runtime::Clock::now() + state.channel.interval); } - } else if (!command && !(semantic == S::kAngles && - state.channel.angle_feedback == AngleFeedbackPolicy::kCommandFeedbackOnly)) { - ++state.feedback; } } } @@ -156,11 +181,14 @@ namespace linkerbot::hand::unified_detail { const std::lock_guard lock(mutex_); current_ = {}; non_command_ = {}; + base_capabilities_ = declared_capabilities(config_); sequences_.clear(); verified_.clear(); failures_.clear(); disabled_.clear(); polls_.clear(); + field_intervals_.clear(); + observed_force_.clear(); ++plan_sequence_; plan_.reset(); identity_sequence_ = 0; @@ -217,9 +245,13 @@ namespace linkerbot::hand::unified_detail { } if (auto poll = polls_.find(semantic); poll != polls_.end()) { ++poll->second.reads; - if (!polling && !(semantic == S::kAngles && - poll->second.channel.angle_feedback == AngleFeedbackPolicy::kCommandFeedbackOnly)) { - poll->second.next = std::max(poll->second.next, runtime::Clock::now() + poll->second.channel.interval); + if (!(semantic == S::kAngles && + poll->second.channel.angle_feedback == AngleFeedbackPolicy::kCommandFeedbackOnly)) { + ++poll->second.feedback; + if (!polling) { + poll->second.next = + std::max(poll->second.next, runtime::Clock::now() + poll->second.channel.interval); + } } } } @@ -251,9 +283,16 @@ namespace linkerbot::hand::unified_detail { } DeviceCapabilities PythonState::capabilities(DeviceCapabilities caps) const { const std::lock_guard lock(mutex_); + return capabilities_locked(std::move(caps)); + } + DeviceCapabilities PythonState::capabilities_locked(DeviceCapabilities caps) const { + caps = python_force_capabilities(std::move(caps)); caps.firmware_unsupported_telemetry.assign(disabled_.begin(), disabled_.end()); auto fields = caps.force_fields(); for (auto &field : fields) { + if (const auto found = observed_force_.find(std::string(field.name)); found != observed_force_.end()) { + field.supported = found->second; + } if (field.query_channel && disabled_.count(*field.query_channel)) { field.supported = false; field.firmware_unsupported = true; @@ -264,12 +303,14 @@ namespace linkerbot::hand::unified_detail { } bool PythonState::replace(const PollingPlan &plan) { const std::lock_guard lock(mutex_); + auto resolved = resolve_python_polling(plan, capabilities_locked(base_capabilities_)); if (plan_ && same_plan(*plan_, plan)) { return false; } + auto retained = plan; std::map next; const auto now = runtime::Clock::now(); - for (const auto &channel : plan) { + for (const auto &channel : resolved.queries) { next.emplace(channel.semantic, Poll{channel, now + (channel.semantic == S::kAngles && @@ -278,9 +319,10 @@ namespace linkerbot::hand::unified_detail { : std::chrono::milliseconds{0}), now}); } - plan_ = plan; + plan_ = std::move(retained); ++plan_sequence_; polls_ = std::move(next); + field_intervals_ = std::move(resolved.fields); return true; } void PythonState::stop_polling() { @@ -288,6 +330,7 @@ namespace linkerbot::hand::unified_detail { plan_.reset(); ++plan_sequence_; polls_.clear(); + field_intervals_.clear(); } std::optional PythonState::next_poll() const { const std::lock_guard lock(mutex_); @@ -331,31 +374,68 @@ namespace linkerbot::hand::unified_detail { void PythonState::project_health(HandHealth &health) const { const std::lock_guard lock(mutex_); const auto now = runtime::Clock::now(); - health.polling_active = - std::any_of(polls_.begin(), polls_.end(), [](const auto &item) { return item.second.enabled; }); - for (const auto &[semantic, state] : polls_) { - const auto name = health_name(semantic); - auto found = std::find_if(health.channels.begin(), health.channels.end(), - [&](const auto &c) { return c.channel == name; }); - if (found == health.channels.end()) { - health.channels.push_back({}); - found = std::prev(health.channels.end()); - found->channel = name; + const auto caps = capabilities_locked(base_capabilities_); + health.channels.clear(); + health.force_fields.clear(); + health.polling_active = !stopped_ && std::any_of(polls_.begin(), polls_.end(), + [](const auto &item) { return item.second.enabled; }); + for (const auto &capability : caps.telemetry) { + const auto semantic = capability.semantic; + ChannelMetrics channel; + channel.channel = python_health_channel(semantic); + channel.supported = disabled_.count(semantic) == 0; + if (const auto found = current_.metadata.find(semantic); found != current_.metadata.end()) { + auto meta = found->second; + if (stopped_) { + meta = SampleMeta::unavailable(meta.connection_generation); + } + complete_freshness(meta, &capability, now); + channel.data_age = {meta.valid, meta.stale, meta.age, meta.sequence, meta.connection_generation}; + } + if (!stopped_) { + if (const auto found = polls_.find(semantic); found != polls_.end()) { + const auto &state = found->second; + channel.polling_active = state.enabled; + channel.requested_hz = + state.enabled ? 1000.0 / static_cast(state.channel.interval.count()) : 0; + const auto seconds = std::chrono::duration(now - state.started).count(); + channel.achieved_hz = seconds > 0 ? static_cast(state.feedback) / seconds : 0; + channel.completed_reads = state.reads; + channel.feedback_samples = state.feedback; + channel.command_feedback_samples = state.commands; + channel.missed_deadlines = state.missed; + channel.timeouts = state.timeouts; + if (semantic == S::kAngles) { + channel.feedback_policy = state.channel.angle_feedback; + } + } } - found->polling_active = state.enabled; - found->supported = disabled_.count(semantic) == 0; - found->requested_hz = state.enabled ? 1000.0 / static_cast(state.channel.interval.count()) : 0; - const auto seconds = std::chrono::duration(now - state.started).count(); - found->achieved_hz = seconds > 0 ? static_cast(state.feedback) / seconds : 0; - found->completed_reads = state.reads; - found->feedback_samples = state.feedback; - found->command_feedback_samples = state.commands; - found->missed_deadlines = state.missed; - found->timeouts = state.timeouts; - if (semantic == S::kAngles) { - found->feedback_policy = state.channel.angle_feedback; + health.channels.push_back(std::move(channel)); + } + for (const auto &field : caps.force_fields()) { + ForceFieldMetrics metrics; + metrics.field = field.name; + metrics.supported = field.supported; + metrics.query_channel = field.query_channel; + if (!stopped_) { + if (const auto found = field_intervals_.find(metrics.field); found != field_intervals_.end()) { + metrics.requested_hz = 1000.0 / static_cast(found->second.count()); + } + if (field.query_channel) { + const auto found = polls_.find(field.query_channel.value_or(S::kForce)); + if (found != polls_.end() && found->second.enabled) { + const auto &state = found->second; + metrics.query_hz = 1000.0 / static_cast(state.channel.interval.count()); + const auto elapsed = std::chrono::duration(now - state.started).count(); + if (elapsed > 0 && field.supported.value_or(false)) { + metrics.achieved_hz = static_cast(state.feedback) / elapsed; + } + } + } } + health.force_fields.push_back(std::move(metrics)); } + health_->project(health); } void PythonState::project(HandSnapshot &snapshot, const View &view, const DeviceCapabilities &caps) const { snapshot.snapshot_sequence = view.sequence; diff --git a/src/hand/unified/python_transaction_scope.cpp b/src/hand/unified/python_transaction_scope.cpp index ffe04cd..ceb9e46 100644 --- a/src/hand/unified/python_transaction_scope.cpp +++ b/src/hand/unified/python_transaction_scope.cpp @@ -1,20 +1,39 @@ +#include "detail/python_health.hpp" #include "detail/python_transactions.hpp" #include "detail/runtime_access.hpp" namespace linkerbot::hand::unified_detail { void execute_python_transactions(runtime::DeviceRuntime &device, const HandDeviceConfig &config, const std::function &execute, runtime::Deadline deadline, - const std::function &valid, bool identity, + const std::function &valid, bool identity, PythonHealthMetrics &metrics, RuntimeAccess::ErrorCapture error_capture) { - RuntimeAccess::with_response_windows( - device, - [&config, identity](runtime::TransactionPlan plan) { - plan = python_transaction(std::move(plan), config); - if (identity && plan.consume) { - plan.response_window = config.identity_timeout; - } - return plan; - }, - execute, deadline, valid, std::move(error_capture)); + const auto timing = std::make_shared(); + const auto complete = [&] { + if (timing->started && !timing->failed.load(std::memory_order_acquire)) { + metrics.completed(timing->started.value_or(runtime::Deadline{}), + timing->completed.value_or(runtime::Clock::now())); + RuntimeAccess::logical_transaction_succeeded(device); + } + }; + try { + RuntimeAccess::with_response_windows( + device, + [&config, identity](runtime::TransactionPlan plan) { + plan = python_transaction(std::move(plan), config); + if (identity && plan.consume) { + plan.response_window = config.identity_timeout; + } + return plan; + }, + execute, deadline, valid, std::move(error_capture), timing); + } catch (...) { + // Identity fingerprint validation follows successful wire decoding + // in Python. Retain that completion even if facade validation fails. + if (timing->completed) { + complete(); + } + throw; + } + complete(); } } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 38b0c27..7da1afa 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -36,10 +36,20 @@ add_executable(async-command-probe EXCLUDE_FROM_ALL ../validation/async_command_ target_link_libraries(async-command-probe PRIVATE linkerbot::cpp-sdk) add_executable(python-migration-probe EXCLUDE_FROM_ALL ../validation/python_migration_probe.cpp) target_link_libraries(python-migration-probe PRIVATE linkerbot::cpp-sdk) -add_dependencies(tests async-command-probe python-migration-probe) +add_executable(python-health-probe EXCLUDE_FROM_ALL ../validation/python_health_probe.cpp) +target_link_libraries(python-health-probe PRIVATE linkerbot::cpp-sdk) +add_dependencies(tests async-command-probe python-migration-probe python-health-probe) find_package(Python3 COMPONENTS Interpreter QUIET) set(LINKERBOT_PYTHON_SDK_DIR "" CACHE PATH "Optional Python SDK source checkout for cross-language async vectors") if(Python3_Interpreter_FOUND) + set(health_reference_options) + if(LINKERBOT_PYTHON_SDK_DIR) + list(APPEND health_reference_options --python-sdk "${LINKERBOT_PYTHON_SDK_DIR}") + endif() + add_test(NAME python-health-vectors COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/../validation/check_python_health.py" + --cpp-probe "$" ${health_reference_options}) + set_tests_properties(python-health-vectors PROPERTIES TIMEOUT 60) add_test(NAME release-package-audit COMMAND "${Python3_EXECUTABLE}" -m unittest discover -s "${CMAKE_CURRENT_SOURCE_DIR}/../validation" -p "test_release_*.py") @@ -74,7 +84,20 @@ endif() # Hardware-in-the-loop cases live under [l6]/[o6]/[l20lite]/[l20]/[l30]/[o30] and need a real CAN interface. # Default ctest only runs FakeTransport integration tests. -add_test(NAME comm COMMAND tests "[comm]" --durations yes) +# Keep bounded per-process acceptance without combining every Python-policy +# regression with the remaining communication suite. The filters partition [comm]; +# no case is skipped and transaction deadlines remain unchanged. +set(python_policy_filter "") +set(comm_filter "[comm]") +foreach(batch RANGE 1 5) + if(python_policy_filter) + string(APPEND python_policy_filter ",") + endif() + string(APPEND python_policy_filter "[comm][python-phase${batch}]") + string(APPEND comm_filter "~[python-phase${batch}]") +endforeach() +add_test(NAME comm COMMAND tests "${comm_filter}" --durations yes) +add_test(NAME python-policy-regressions COMMAND tests "${python_policy_filter}" --durations yes) # Discovery contract and adapter tests are offline: injected enumerators and # FakeTransport cover platform sources without opening host CAN hardware. add_test(NAME discovery COMMAND tests "[discovery]" --durations yes) diff --git a/tests/hand/device/test_device.cpp b/tests/hand/device/test_device.cpp index c2469bc..45d8c96 100644 --- a/tests/hand/device/test_device.cpp +++ b/tests/hand/device/test_device.cpp @@ -4186,3 +4186,59 @@ TEST_CASE("Python O30 no-sensor downgrade is recorded after successful metadata REQUIRE_FALSE(device.get_capabilities().supports(TelemetrySemantic::kForce)); device.close(); } + +TEST_CASE("Python O30 force fields project one completed shared polling query", "[comm][python-phase5]") { + using namespace std::chrono_literals; + auto config = config_for(Model::kO30); + config.runtime_behavior = RuntimeBehavior::kPython; + config.o30_layout = O30Layout::kPythonV25; + config.side = HandSide::kLeft; + config.identity_timeout = 300ms; + config.response_timeout = 300ms; + std::shared_ptr hop; + HandDevice device(config, [&] { + auto t = std::make_unique(); + hop = std::make_shared(); + hop->request_id = 2; + install_fake_device(Model::kO30, t.get(), hop); + hop->put_ascii(0x41, 0x61, 8, "0.0.4"); + std::vector metadata(0x73, 0); + const std::string type = "TEST", descriptor = "FnS_U16_1;Fn_U8_2"; + std::copy(type.begin(), type.end(), metadata.begin()); + metadata[0x20] = 'g'; + metadata[0x29] = 1; + metadata[0x2A] = 2; + metadata[0x2B] = static_cast(descriptor.size()); + std::copy(descriptor.begin(), descriptor.end(), metadata.begin() + 0x2C); + metadata[0x6C] = 4; + metadata[0x6E] = 1; + metadata[0x6F] = 1; + metadata[0x70] = 2; + hop->put(0x31, metadata); + hop->put(0x32, {0, 1, 0, 9}); + return t; + }); + device.open(); + const auto baseline = device.get_health().transaction_latency.count; + PollingChannel force{TelemetrySemantic::kForce, 1s}; + force.force_fields = {{"fn", 1s}, {"fns", 2s}}; + device.start_polling({force}); + REQUIRE(wait_until([&] { + const auto health = device.get_health(); + return std::any_of(health.channels.begin(), health.channels.end(), + [](const auto &c) { return c.channel == "force" && c.completed_reads == 1; }); + })); + const auto health = device.get_health(); + REQUIRE(health.transaction_latency.count == baseline + 1); + for (const auto &field : health.force_fields) { + if (field.field == "fn" || field.field == "fns") { + REQUIRE(field.supported == true); + REQUIRE(field.query_channel == TelemetrySemantic::kForce); + REQUIRE(field.requested_hz == (field.field == "fn" ? 1.0 : 0.5)); + REQUIRE(field.query_hz == 1.0); + REQUIRE(field.achieved_hz > 0); + } + } + device.stop_polling(); + device.close(); +} diff --git a/tests/hand/device/test_python_health_statistics.cpp b/tests/hand/device/test_python_health_statistics.cpp new file mode 100644 index 0000000..dd46b0a --- /dev/null +++ b/tests/hand/device/test_python_health_statistics.cpp @@ -0,0 +1,421 @@ +#include "../../../src/hand/unified/detail/python_health.hpp" +#include "../../../src/hand/unified/detail/python_polling.hpp" +#include "../../../src/hand/unified/detail/python_state.hpp" +#include "../../../src/hand/unified/detail/python_transactions.hpp" +#include "../../../validation/fake_classic_hand.hpp" +#include "hand/support/optional_value.hpp" +#include +#include +#include +#include +#include + +using namespace linkerbot::hand; +using namespace std::chrono_literals; +namespace { + using S = TelemetrySemantic; + auto config(Model model = Model::kL6) { + auto c = validation::config(); + c.model = model; + c.runtime_behavior = RuntimeBehavior::kPython; + c.identity_timeout = 500ms; + c.response_timeout = 300ms; + if (model == Model::kO30) { + c.o30_layout = O30Layout::kPythonV25; + c.angle_mapping_path.reset(); + } + return c; + } + ChannelMetrics channel(const HandHealth &h, const std::string &name) { + for (const auto &c : h.channels) { + if (c.channel == name) { + return c; + } + } + throw std::runtime_error("missing health channel: " + name); + } + ForceFieldMetrics field(const HandHealth &h, const std::string &name) { + for (const auto &f : h.force_fields) { + if (f.field == name) { + return f; + } + } + throw std::runtime_error("missing health force field: " + name); + } + HandHealth health(const unified_detail::PythonState &state) { + HandHealth result; + state.project_health(result); + return result; + } + PollingChannel force(std::initializer_list fields) { + PollingChannel c{S::kForce, 100ms}; + c.force_fields = fields; + return c; + } +} + +TEST_CASE("Python health counts only a configured polling measurement window", "[comm][python-phase5]") { + validation::FakeHand d(config()); + const auto initial = d.hand.get_health(); + REQUIRE(channel(initial, "angles").completed_reads == 0); + REQUIRE(channel(initial, "angles").feedback_samples == 0); + REQUIRE(initial.transaction_latency.count == 4); + const auto before = d.transport->sent().size(); + for (int i = 0; i < 10; ++i) { + (void)d.hand.get_health(); + } + REQUIRE(d.transport->sent().size() == before); + REQUIRE(d.hand.get_angles(1s).meta.valid); + const auto read = d.hand.get_health(); + REQUIRE(channel(read, "angles").completed_reads == 0); + REQUIRE(channel(read, "angles").data_age.sequence == 2); + REQUIRE(read.transaction_latency.count == initial.transaction_latency.count + 1); + d.hand.start_polling({{S::kAngles, 20ms, AngleFeedbackPolicy::kQueryOnly}}); + const auto deadline = runtime::Clock::now() + 2s; + while (channel(d.hand.get_health(), "angles").completed_reads < 2 && runtime::Clock::now() < deadline) { + std::this_thread::sleep_for(1ms); + } + REQUIRE(channel(d.hand.get_health(), "angles").completed_reads >= 2); + d.hand.stop_polling(); + const auto stopped = d.hand.get_health(); + REQUIRE(channel(stopped, "angles").completed_reads == 0); + REQUIRE(channel(stopped, "angles").feedback_samples == 0); + REQUIRE(channel(stopped, "angles").requested_hz == 0); + REQUIRE(stopped.transaction_latency.count >= read.transaction_latency.count + 2); + const auto count = stopped.transaction_latency.count; + d.hand.close(); + REQUIRE(d.hand.get_health().transaction_latency.count == count); + REQUIRE(channel(d.hand.get_health(), "angles").completed_reads == 0); + d.hand.open(); + REQUIRE(d.hand.get_health().transaction_latency.count == count + 4); + d.hand.recover_for(3s); + REQUIRE(d.hand.get_health().transaction_latency.count == count + 8); +} + +TEST_CASE("Native health retains native counters and leaves Python field metrics absent", "[comm][python-phase5]") { + validation::FakeHand d; + const auto h = d.hand.get_health(); + REQUIRE(channel(h, "angles").completed_reads == 1); + REQUIRE(h.transaction_latency.count == 7); + REQUIRE(h.force_fields.empty()); + REQUIRE_THROWS_AS(d.hand.start_polling({force({{"fn", 100ms}})}), PollingPlanError); + REQUIRE_FALSE(d.hand.get_health().polling_active); +} + +TEST_CASE("Python health percentile selection uses ties to even and retains only 2048 completions", + "[comm][python-phase5]") { + unified_detail::PythonHealthMetrics m; + const auto start = HealthTimePoint{}; + HandHealth h; + m.project(h); + REQUIRE(h.transaction_latency.count == 0); + REQUIRE_FALSE(h.last_successful_transaction); + m.completed(start, start + 1ns); + m.completed(start, start + 3ns); + m.project(h); + REQUIRE(test_support::require_value(h.transaction_latency.p50) == 1ns); + m.completed(start, start + 5ns); + m.completed(start, start + 7ns); + m.project(h); + REQUIRE(test_support::require_value(h.transaction_latency.p50) == 5ns); + for (int i = 0; i < 2048; ++i) { + m.completed(start, start + 10ns); + } + m.project(h); + REQUIRE(h.transaction_latency.count == 2048); + REQUIRE(test_support::require_value(h.transaction_latency.p50) == 10ns); + REQUIRE(test_support::require_value(h.transaction_latency.p95) == 10ns); + REQUIRE(test_support::require_value(h.transaction_latency.p99) == 10ns); + REQUIRE_THROWS_AS(m.completed(start + 1ns, start), ValidationError); + m.project(h); + REQUIRE(h.transaction_latency.count == 2048); + m.clear(); + m.project(h); + REQUIRE(h.transaction_latency.count == 0); + REQUIRE_FALSE(h.last_successful_transaction); +} + +TEST_CASE("Python poll reads and command feedback use distinct counters", "[comm][python-phase5]") { + unified_detail::PythonState state(config()); + state.replace({{S::kAngles, 100ms, AngleFeedbackPolicy::kQueryOnly}}); + runtime::Sample sample; + sample.valid = true; + sample.device_generation = 1; + sample.received_at = runtime::Clock::now(); + sample.origin = protocol::Origin::report; + state.publish("angle", sample); + REQUIRE(channel(health(state), "angles").feedback_samples == 0); + state.read_succeeded(S::kAngles, false); + REQUIRE(channel(health(state), "angles").completed_reads == 1); + REQUIRE(channel(health(state), "angles").feedback_samples == 1); + sample.origin = protocol::Origin::command_feedback; + state.publish("angle", sample); + auto h = health(state); + REQUIRE(channel(h, "angles").command_feedback_samples == 1); + REQUIRE(channel(h, "angles").feedback_samples == 1); + state.replace({{S::kAngles, 100ms, AngleFeedbackPolicy::kCommandFeedbackOnly}}); + state.read_succeeded(S::kAngles, false); + state.publish("angle", sample); + h = health(state); + REQUIRE(channel(h, "angles").completed_reads == 1); + REQUIRE(channel(h, "angles").feedback_samples == 1); + REQUIRE(channel(h, "angles").command_feedback_samples == 1); + REQUIRE_FALSE(state.replace({{S::kAngles, 100ms, AngleFeedbackPolicy::kCommandFeedbackOnly}})); + REQUIRE(channel(health(state), "angles").feedback_samples == 1); + state.stop_polling(); + REQUIRE(channel(health(state), "angles").feedback_samples == 0); +} + +TEST_CASE("Python force field intent shares one descriptor query at the fastest rate", "[comm][python-phase5]") { + unified_detail::PythonState state(config(Model::kO30)); + const auto plan = PollingPlan{force({{"fn", 20ms}, {"fns", 50ms}})}; + REQUIRE(state.replace(plan)); + state.read_succeeded(S::kForce, true); + auto h = health(state); + REQUIRE(h.force_fields.size() == 7); + REQUIRE(field(h, "fn").requested_hz == 50); + REQUIRE(field(h, "fns").requested_hz == 20); + REQUIRE(field(h, "fn").query_hz == 50); + REQUIRE(field(h, "fns").query_hz == 50); + REQUIRE_FALSE(field(h, "fn").supported.has_value()); + REQUIRE(field(h, "fn").achieved_hz == 0); + REQUIRE(channel(h, "force").completed_reads == 1); + REQUIRE_FALSE(state.replace(plan)); + REQUIRE(channel(health(state), "force").completed_reads == 1); + ForceSample observed; + observed.sensors.resize(5); + ForceFieldSample f; + f.name = "fns"; + observed.fields.push_back(f); + state.observe_force(observed); + h = health(state); + REQUIRE(field(h, "fn").supported == false); + REQUIRE(field(h, "fns").supported == true); + REQUIRE(field(h, "fn").requested_hz == 50); + REQUIRE(field(h, "fn").query_hz == 20); + REQUIRE(field(h, "fn").achieved_hz == 0); + REQUIRE(field(h, "fns").achieved_hz == Catch::Approx(channel(h, "force").achieved_hz)); + observed.fields.clear(); + state.observe_force(observed); + h = health(state); + REQUIRE_FALSE(channel(h, "force").polling_active); + REQUIRE(channel(h, "force").completed_reads == 1); + REQUIRE(field(h, "fns").requested_hz == 20); + REQUIRE(field(h, "fns").query_hz == 0); + REQUIRE(field(h, "fns").achieved_hz == 0); + state.stop_polling(); + REQUIRE(field(health(state), "fns").requested_hz == 0); +} + +TEST_CASE("Python L20Lite scalar force fields retain independent query rates", "[comm][python-phase5]") { + unified_detail::PythonState state(config(Model::kL20Lite)); + state.replace({force({{"fns", 100ms}, {"fts", 50ms}, {"px", 200ms}})}); + state.read_succeeded(S::kForceFts, true); + const auto h = health(state); + REQUIRE(field(h, "fns").query_channel == S::kForceFns); + REQUIRE(field(h, "fts").query_channel == S::kForceFts); + REQUIRE(field(h, "fts").query_hz == 20); + REQUIRE(field(h, "fns").query_hz == 10); + REQUIRE(channel(h, "force_fts").completed_reads == 1); + REQUIRE(channel(h, "force_fns").completed_reads == 0); + REQUIRE(field(h, "fts").achieved_hz > 0); + REQUIRE(field(h, "fns").achieved_hz == 0); +} + +TEST_CASE("Python field polling rejects invalid intent without replacing the current plan", "[comm][python-phase5]") { + unified_detail::PythonState state(config(Model::kO30)); + const auto plan = PollingPlan{force({{"fn", 100ms}, {"fns", 50ms}})}; + state.replace(plan); + state.read_succeeded(S::kForce, true); + REQUIRE_THROWS_AS(state.replace({force({{"Fn", 100ms}})}), ValidationError); + REQUIRE_THROWS_AS(state.replace({force({{"fn", 0ms}})}), PollingPlanError); + REQUIRE_THROWS_AS(state.replace({force({{"fn", 100ms}, {"fn", 50ms}})}), PollingPlanError); + REQUIRE_THROWS_AS(state.replace({force({{"fn", std::chrono::milliseconds::max()}})}), PollingPlanError); + REQUIRE(channel(health(state), "force").completed_reads == 1); + REQUIRE(field(health(state), "fn").requested_hz == 10); + unified_detail::PythonState classic(config()); + REQUIRE_THROWS_AS(classic.replace({force({{"fts", 100ms}})}), UnsupportedOperationError); +} + +TEST_CASE("Python composite transaction latency counts success once and never partial stages", + "[comm][python-phase5]") { + auto t = std::make_unique(); + auto *raw = t.get(); + auto session = linkerbot::transport::Session::own(std::move(t)); + auto runtime = runtime::DeviceRuntime::acquire(session, "health-composite", {{0x28, false}}); + raw->on_request(0x28, validation::l6_reply); + unified_detail::PythonHealthMetrics metrics; + auto c = config(); + c.response_timeout = 100ms; + const auto execute = [&] { + unified_detail::execute_python_transactions( + *runtime, c, + [&] { + for (int i = 0; i < 3; ++i) { + (void)runtime->execute(o6::protocol::read_angle(0x28), runtime::deadline_after(1s)); + } + }, + runtime::Deadline::max(), {}, false, metrics); + }; + execute(); + HandHealth h; + metrics.project(h); + REQUIRE(h.transaction_latency.count == 1); + REQUIRE(runtime->health().transaction_latency.count == 3); + const auto last = h.last_successful_transaction; + for (int failure = 1; failure <= 2; ++failure) { + int requests = 0; + raw->on_request(0x28, [&](const auto &request) { + if (++requests == 2) { + return std::vector{}; + } + return validation::l6_reply(request); + }); + REQUIRE_THROWS_AS(execute(), ResponseTimeoutError); + metrics.project(h); + REQUIRE(h.transaction_latency.count == 1); + REQUIRE(h.last_successful_transaction == last); + REQUIRE(runtime->health().consecutive_errors == static_cast(failure)); + } + raw->on_request(0x28, validation::l6_reply); + execute(); + metrics.project(h); + REQUIRE(h.transaction_latency.count == 2); + REQUIRE(runtime->health().consecutive_errors == 0); + runtime->close(); + session->close(); +} + +TEST_CASE("Python logical latency includes admitted transport waiting and physical completion", + "[comm][python-phase5]") { + auto t = std::make_unique(); + auto *raw = t.get(); + auto session = linkerbot::transport::Session::own(std::move(t)); + auto runtime = runtime::DeviceRuntime::acquire(session, "health-queue", {{0x28, false}}); + raw->on_request(0x28, validation::l6_reply); + raw->arm_tx_gate(); + unified_detail::PythonHealthMetrics metrics; + const auto c = config(); + auto pending = std::async(std::launch::async, [&] { + unified_detail::execute_python_transactions( + *runtime, c, [&] { (void)runtime->execute(o6::protocol::read_angle(0x28), runtime::deadline_after(1s)); }, + runtime::Deadline::max(), {}, false, metrics); + }); + const bool entered = raw->wait_until_tx_entered(1s); + const auto waited = pending.wait_for(50ms); + raw->release_tx_gate(); + pending.get(); + REQUIRE(entered); + REQUIRE(waited == std::future_status::timeout); + HandHealth h; + metrics.project(h); + REQUIRE(h.transaction_latency.count == 1); + REQUIRE(test_support::require_value(h.transaction_latency.p50) >= 50ms); + runtime->close(); + session->close(); +} + +TEST_CASE("Python caller timeout does not discard a later shared logical completion", "[comm][python-phase5]") { + std::promise sent; + validation::FakeHand d(config()); + const auto count = d.hand.get_health().transaction_latency.count; + d.transport->on_request(0x28, [&](const auto &request) { + if (request.data[0] == 1 && request.dlc == 1) { + sent.set_value(); + return std::vector{}; + } + return validation::l6_reply(request); + }); + auto wait = std::async(std::launch::async, [&] { + try { + (void)d.hand.get_angles(20ms); + return false; + } catch (const TimeoutError &) { + return true; + } + }); + REQUIRE(sent.get_future().wait_for(1s) == std::future_status::ready); + const bool timed_out = wait.get(); + linkerbot::can::Message request{}; + request.id = 0x28; + request.dlc = 1; + request.data[0] = 1; + d.transport->inject(validation::l6_reply(request).front()); + const auto deadline = runtime::Clock::now() + 1s; + while (d.hand.get_health().transaction_latency.count == count && runtime::Clock::now() < deadline) { + std::this_thread::sleep_for(1ms); + } + REQUIRE(timed_out); + REQUIRE(d.hand.get_health().transaction_latency.count == count + 1); +} + +TEST_CASE("Python logical health is device-local on a shared Session", "[comm][python-phase5]") { + auto session = linkerbot::transport::Session::create([] { + auto t = std::make_unique(); + t->on_request(0x28, validation::l6_reply); + t->on_request(0x27, [](const auto &request) { + auto replies = validation::l6_reply(request); + if (request.data[0] == 0xC0) { + for (auto &reply : replies) { + if (reply.data[1] == 1) { + reply.data[7] = 'R'; + } + } + } + return replies; + }); + return t; + }); + auto c = config(); + HandDevice left(c, session); + c.side = HandSide::kRight; + HandDevice right(c, session); + left.open(); + right.open(); + REQUIRE(left.get_health().transaction_latency.count == 4); + REQUIRE(right.get_health().transaction_latency.count == 4); + (void)left.get_faults(1s); + REQUIRE(left.get_health().transaction_latency.count == 5); + REQUIRE(right.get_health().transaction_latency.count == 4); + right.close(); + REQUIRE(right.get_health().transaction_latency.count == 4); + (void)left.get_angles(1s); + REQUIRE(left.get_health().transaction_latency.count == 6); + right.open(); + REQUIRE(right.get_health().transaction_latency.count == 8); + REQUIRE(left.get_health().transaction_latency.count == 6); + right.close(); + left.close(); + session->close(); +} + +TEST_CASE("Python identity wire completion precedes fingerprint rejection", "[comm][python-phase5]") { + validation::FakeHand device(config()); + const auto baseline = device.hand.get_health().transaction_latency.count; + device.transport->on_request(0x28, [](const auto &request) { + auto replies = validation::l6_reply(request); + if (request.data[0] == 0xC0) { + for (auto &reply : replies) { + if (reply.data[1] == 1) { + reply.data[4] = '9'; + } + } + } + return replies; + }); + REQUIRE_THROWS_AS(device.hand.refresh_device_info(1s), IdentityMismatchError); + REQUIRE(device.hand.get_health().transaction_latency.count == baseline + 1); + REQUIRE_FALSE(device.hand.is_open()); +} + +TEST_CASE("Python invalid field plan preserves active feedback plan without sending", "[comm][python-phase5]") { + validation::FakeHand device(config()); + device.hand.start_polling({{S::kAngles, 100ms, AngleFeedbackPolicy::kCommandFeedbackOnly}}); + const auto count = device.transport->sent().size(); + REQUIRE_THROWS_AS(device.hand.start_polling({force({{"Fn", 10ms}})}), ValidationError); + REQUIRE_THROWS_AS(device.hand.start_polling({force({{"fts", 10ms}})}), UnsupportedOperationError); + REQUIRE(device.transport->sent().size() == count); + REQUIRE(device.hand.get_health().polling_active); + REQUIRE(channel(device.hand.get_health(), "angles").feedback_policy == AngleFeedbackPolicy::kCommandFeedbackOnly); + device.hand.stop_polling(); +} diff --git a/tests/hand/device/test_python_publication.cpp b/tests/hand/device/test_python_publication.cpp index a30beab..11b8c3f 100644 --- a/tests/hand/device/test_python_publication.cpp +++ b/tests/hand/device/test_python_publication.cpp @@ -194,6 +194,7 @@ TEST_CASE("Python identical polling plans preserve deadlines and metrics", "[com const PollingPlan plan{{TelemetrySemantic::kAngles, 100ms, AngleFeedbackPolicy::kAdaptive}}; REQUIRE((state.replace(plan))); state.publish("angle", publication(1)); + state.read_succeeded(TelemetrySemantic::kAngles, true); const auto next = state.next_poll(); REQUIRE_FALSE(state.replace(plan)); REQUIRE((state.next_poll() == next)); diff --git a/validation/check_python_health.py b/validation/check_python_health.py new file mode 100644 index 0000000..76a4168 --- /dev/null +++ b/validation/check_python_health.py @@ -0,0 +1,40 @@ +"""Compare every bounded latency-window update with Python's rounding contract.""" +import argparse +from collections import deque +import json +from pathlib import Path +import subprocess +import sys +from types import SimpleNamespace + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('--cpp-probe', required=True) +parser.add_argument('--python-sdk', type=Path) +args = parser.parse_args() +actual = json.loads(subprocess.check_output([args.cpp_probe], text=True, timeout=30)) +window = deque(maxlen=2048) +reference = None +if args.python_sdk: + sys.path.insert(0, str(args.python_sdk.resolve() / 'src')) + from linkerbot_hand.api.health import HealthStatus + from linkerbot_hand.runtime.metrics import RuntimeMetrics + reference = RuntimeMetrics(HealthStatus.initial()) +values = [(i * 37) % 211 if i < 2048 else 0 if i < 4096 else 1000 for i in range(6144)] +assert len(actual) == len(values) +for i, value in enumerate(values): + window.append(value) + ordered = sorted(window) + expected = [len(window), *(ordered[round((len(ordered) - 1) * p)] for p in (.50, .95, .99))] + if reference is not None: + reference.transaction_completed(timestamp=float(i), duration_s=value / 1e9) + health = reference.health( + now=float(i), captured_at=float(i), capabilities=None, + commands=SimpleNamespace(metrics=lambda: (), discrete_depth=0), + polling=SimpleNamespace(active=False, metrics=lambda **_: (), force_metrics=lambda **_: ()), + snapshot_store=SimpleNamespace(data_ages=lambda **_: {}), transport=None, arbiter=None) + m = health.transaction_latency + projected = [m.count, *(round(v * 1e9) for v in (m.p50_s, m.p95_s, m.p99_s))] + assert projected == expected, (i, projected, expected) + assert actual[i] == expected, (i, actual[i], expected) +print('6144 rolling-window updates matched Python percentile selection, ties, duplicates and eviction' + + (' and the reference RuntimeMetrics' if reference is not None else '')) diff --git a/validation/installed-consumer/main.cpp b/validation/installed-consumer/main.cpp index efacff8..e1251ff 100644 --- a/validation/installed-consumer/main.cpp +++ b/validation/installed-consumer/main.cpp @@ -81,6 +81,16 @@ namespace { device.hand.start_polling({{linkerbot::hand::TelemetrySemantic::kAngles, 20ms, linkerbot::hand::AngleFeedbackPolicy::kCommandFeedbackOnly}}); device.hand.stop_polling(); + const auto health = device.hand.get_health(); + validation::require(health.transaction_latency.count > 0 && health.transaction_latency.p50.has_value(), + "installed logical transaction health missing"); + validation::require(health.force_fields.size() == 7, "installed force field health projection missing"); + const linkerbot::hand::ForceFieldPolling selection{"fn", 100ms}; + validation::require(selection.interval == 100ms, "installed field interval contract failed"); + for (const auto &channel : health.channels) { + validation::require(channel.completed_reads == 0 && channel.feedback_samples == 0, + "installed stopped polling retained counters"); + } } void verify_unified_contract() { diff --git a/validation/python_health_probe.cpp b/validation/python_health_probe.cpp new file mode 100644 index 0000000..6a97726 --- /dev/null +++ b/validation/python_health_probe.cpp @@ -0,0 +1,27 @@ +#include "../src/hand/unified/detail/python_health.hpp" +#include + +int main() { + using namespace linkerbot::hand; + unified_detail::PythonHealthMetrics metrics; + const auto start = HealthTimePoint{}; + std::cout << '['; + for (int i = 0; i < 6144; ++i) { + const auto value = i < 2048 ? (i * 37) % 211 : i < 4096 ? 0 : 1000; + metrics.completed(start, start + std::chrono::nanoseconds{value}); + HandHealth health; + metrics.project(health); + const auto &m = health.transaction_latency; + if (i) { + std::cout << ','; + } + std::cout << '[' << m.count << ',' + << std::chrono::duration_cast(m.p50.value_or(HealthDuration{})).count() + << ',' + << std::chrono::duration_cast(m.p95.value_or(HealthDuration{})).count() + << ',' + << std::chrono::duration_cast(m.p99.value_or(HealthDuration{})).count() + << ']'; + } + std::cout << "]\n"; +}