跳到正文

Wiki

全局Blackboard與數據更新

約 8 分鐘閱讀

本文由簡體中文內容確定性轉換,並受版本化術語表保護。

1.讓多層子樹讀取外部更新的數據

官方資料:Global blackboard、4.10.0 示例。接口參考 blackboard.h、tree_node.h 與 updated_decorator.cpp。

組合任務篇通過 SubTree 顯式映射輸入輸出。這適合表示子任務接口。另一類數據由外部長期更新,例如定位有效性、任務目標和電量;多層子樹都可能需要訪問。本篇建立獨立的全局數據層,觀察同名數據的作用域、寫入序號與更新時間。

全局 Blackboard 仍然是運行時的數據容器。本篇先由主程序寫入目標,再讓主樹和子樹讀取,逐步觀察共享數據與更新記錄。

2.global、root 與 subtree 是不同的容器

auto global_blackboard = BT::Blackboard::create();
auto root_blackboard = BT::Blackboard::create(global_blackboard);
auto tree = factory.createTreeFromFile(BT_XML_PATH, root_blackboard);

第一個 create 沒有父容器,建立整個層級的最上層;第二個 create 的參數是父 Blackboard 指針,為主樹建立獨立容器。兩個調用都返回 BT::Blackboard::Ptr,也就是共享指針。樹持有主樹 Blackboard,節點通過端口訪問它。

global_blackboard       外部数据:mission_goal
    └── root_blackboard 主任务的数据
            └── 子树 Blackboard,各自保存局部任务数据
XML 輸入 查找位置
goal="{mission_goal}" 當前 Blackboard 中的條目,或顯式映射到的父條目
goal="{@mission_goal}" 沿父層級到最頂層,讀取其 mission_goal
goal="loading_area" 固定字符串,不讀取 Blackboard

@ 表示當前層級最頂層容器,沒有獨立的進程級單例。如果只創建一個 root Blackboard,最頂層就是它;本例額外建立 global 層,使外部數據與主樹數據的歸屬更清楚。普通父子關係也不等於自動開放所有父鍵,跨邊界仍需映射、autoremap 或顯式 @。

子任務的必需輸入輸出繼續適合顯式端口映射,方便複用;穩定的跨任務外部數據才考慮全局層。不要把所有臨時值都放進去,否則子樹會依賴難以看見的全局鍵。

3.值相同不代表沒有更新

假設外部兩次報告相同目標:

第一次 set:loading_area
第二次 set:loading_area

兩次字符串內容相同,但 Blackboard 收到了兩次寫入。條目的更新序號會隨寫入增加,時間戳也會更新。因此需要分別定義:

要判斷什麼 應比較什麼
業務內容是否變化 當前值和上次值
是否發生過新寫入 更新序號
最新數據是否超過允許年齡 寫入時間戳與同一時鐘的當前時間
是否是同一條業務任務 應用自己的 mission_id 等標識

Blackboard 序號不是 ROS 消息序號,也不是業務任務編號。它只説明這個條目被重新寫過。

4.建立數據更新工程

本篇從本系列 腳本與前後置條件 中建立的 bt4_script 複製工程,複用已經準備好的構建配置:

  1. 在文件管理器中複製整個 bt4_script 文件夾,將副本重命名為 bt4_updates。
  2. 刪除副本中的 build、install 文件夾(如果存在),再用 VSCode 新窗口打開 bt4_updates。
  3. 在頂層 CMakeLists.txt 修改項目名。依賴模塊、預設和 BT_XML_PATH 配置保留,下面將完整替換 C++ 程序與任務 XML:
project(bt4_updates VERSION 1.0.0 LANGUAGES C CXX)

4.1.src/main.cpp

#include "behaviortree_cpp/bt_factory.h"

#include <chrono>
#include <cstdint>
#include <iostream>
#include <string>

class ConsumeGoal : public BT::SyncActionNode
{
public:
    ConsumeGoal(const std::string& name, const BT::NodeConfig& config)
        : BT::SyncActionNode(name, config)
    {}

    static BT::PortsList providedPorts()
    {
        return {BT::InputPort<std::string>("goal")};
    }

    BT::NodeStatus tick() override
    {
        std::string goal;
        auto stamp = getInputStamped<std::string>("goal", goal);
        if (!stamp)
        {
            throw BT::RuntimeError("Cannot read input [goal]: ", stamp.error());
        }

        const bool updated = stamp.value().seq != last_seq_;
        const auto age = std::chrono::steady_clock::now().time_since_epoch()
                         - stamp.value().time;
        std::cout << '[' << name() << "] goal=" << goal
                  << ", seq=" << stamp.value().seq
                  << ", updated=" << std::boolalpha << updated
                  << ", age_ms="
                  << std::chrono::duration_cast<std::chrono::milliseconds>(age).count()
                  << '\n';
        last_seq_ = stamp.value().seq;
        // 此处只打印观察结果;没有更新不代表本次观察失败。
        return BT::NodeStatus::SUCCESS;
    }

private:
    std::uint64_t last_seq_ = 0;
};

int main()
{
    try
    {
        BT::BehaviorTreeFactory factory;
        factory.registerNodeType<ConsumeGoal>("ConsumeGoal");
        auto global_blackboard = BT::Blackboard::create();
        auto root_blackboard = BT::Blackboard::create(global_blackboard);
        global_blackboard->set("mission_goal", std::string("loading_area"));
        auto tree = factory.createTreeFromFile(BT_XML_PATH, root_blackboard);

        for (int round = 1; round <= 4; ++round)
        {
            if (round == 3)
            {
                // 相同值重新写入,序号仍会增加。
                global_blackboard->set("mission_goal", std::string("loading_area"));
            }
            if (round == 4)
            {
                global_blackboard->set("mission_goal", std::string("unloading_area"));
            }
            std::cout << "--- round " << round << '\n';
            const auto result = tree.tickExactlyOnce();
            std::cout << "Tree result: " << BT::toStr(result) << '\n';
        }
        return 0;
    }
    catch (const std::exception& error)
    {
        std::cerr << "Error: " << error.what() << '\n';
        return 2;
    }
}

4.2.trees/mission.xml

<root BTCPP_format="4" main_tree_to_execute="MainTree">
    <BehaviorTree ID="MainTree">
        <Sequence>
            <ConsumeGoal name="main_reader" goal="{@mission_goal}"/>
            <SubTree ID="ObserveGoal"/>
        </Sequence>
    </BehaviorTree>
    <BehaviorTree ID="ObserveGoal">
        <ConsumeGoal name="sub_reader" goal="{@mission_goal}"/>
    </BehaviorTree>
</root>

ObserveGoal 沒有為 mission_goal 寫 SubTree 映射,仍能通過 @ 到達最頂層條目。兩個 ConsumeGoal 是不同實例,各自保存 last_seq_,因此第一次讀取時都會把同一次寫入識別為新數據。

5.getInputStamped 的參數和結果

本例調用的重載可概括為:

template<typename T>
BT::Expected<BT::Timestamp> getInputStamped(const std::string& key, T& destination) const;
部分 本例含義
模板參數 std::string 期望讀取的值類型
第一個參數 "goal" 當前節點的輸入端口名,由 XML 映射到全局條目
第二個參數 goal 用引用接收讀出的字符串
返回的 Expected 成功時包含時間信息;失敗時可以讀取 error()
stamp.value().seq 條目寫入序號
stamp.value().time 最近寫入時間;4.10.0 的條目寫入使用 steady_clock

端口若綁定固定字面量,不具有外部 Blackboard 條目的正常寫入歷史,不能將其默認時間信息當成有效數據年齡。本例必須保留 {@mission_goal} 映射。

6.在 VSCode 中比較四輪結果

Configure、Build,選擇 bt4_updates 為 Launch 目標。輸出中的時間隨實際運行變化,序號關係應如下:

輪次 本輪外部操作 兩個讀者的目標 seq updated
1 初始寫入後首次讀取 loading_area 1 true
2 沒有重新寫入 loading_area 1 false
3 相同字符串重新寫入 loading_area 2 true
4 寫入新目標 unloading_area 3 true

截圖預留:全局目標的四輪讀取。 展示主樹與子樹的兩個讀者,以及 seq 與 updated 的變化。

在 ConsumeGoal::tick() 讀取後設置斷點,比較 main_reader 與 sub_reader 的 this 指針、goal 和 last_seq_。第二輪容器裏仍然有值,但沒有新寫入;第三輪值相同,更新序號卻不同。

截圖預留:兩個讀者的獨立狀態。 展示不同 this 地址及各自的 last_seq_。

7.用內置節點按更新驅動工作

4.10.0 還提供下面的組合方式,先作為獨立 XML 對照片段:

<SkipUnlessUpdated entry="{@mission_goal}">
    <ConsumeGoal goal="{@mission_goal}"/>
</SkipUnlessUpdated>
內置節點 沒有新寫入時 有新寫入時
WasEntryUpdated 返回 FAILURE 返回 SUCCESS
SkipUnlessUpdated 返回 SKIPPED,不啓動子節點 執行子節點
WaitValueUpdate 返回 RUNNING,等待後續 tick 執行子節點

檢查的是寫入序號。SkipUnlessUpdated 與 WaitValueUpdate 的子節點一旦已經 RUNNING,會繼續當前子任務;不能把“下一次數據沒有更新”理解為立即中止已啓動的動作。它們也不會自動訂閲 ROS 消息。

把主樹第一次 ConsumeGoal 包在 SkipUnlessUpdated 中,而保留後面的子樹讀者,再運行四輪。第二輪主讀者被跳過,子樹讀者仍執行;第三輪相同值重新寫入,主讀者又執行。普通 Sequence 在部分子節點 SKIPPED 時可以繼續並最終成功。

截圖預留:只在更新後執行。 對照第二輪跳過與第三輪重新執行。

8.寫入時間與業務有效性分別處理

本例 age_ms 是“本地條目距離最近寫入過了多久”。如果收到一條本來就很舊的 ROS 消息,再把它寫入 Blackboard,寫入年齡會很小,但消息本身仍然可能過期。因此實際數據通常還需要攜帶傳感器時間、接收時間或有效標誌。

計時應使用一致時鐘,不能把 steady_clock 的值當成 Unix 時間戳打印成日期。判斷超時時限時,也應先確認是數據產生時間、接收時間還是 Blackboard 寫入時間。

本例的外部寫入與樹執行順序發生在同一線程。接入 ROS 回調後,多個相關字段的更新不是一個自動的業務事務;可將相關字段打包成一個結構體並一次寫入,或由執行器統一調度。若通過引用操作 Blackboard 中的可變對象,應使用合適的鎖定接口並控制引用生命週期,不能把庫內部有鎖等同於整段業務天然線程安全。

9.當前學習進度

本篇完成全局層級、顯式 @ 訪問、更新序號與寫入年齡的觀察。v4 擴展實踐到此結束;下一篇通過 Groot2 查看樹結構、執行狀態和日誌。


v4 學習路線 · 上一篇:腳本與前後置條件 · 下一篇:Groot2