跳到正文

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