跳到正文

Wiki

組合實際任務

約 29 分鐘閱讀

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

1.把已有節點組合成移動機械人任務

對應 BehaviorTree.CPP 3.8 官方文檔:Using SubTrees、Port Remapping 和 Use multiple XML files。實現細節參考 3.8.7 的 xml_parsing.cpp、blackboard.h 和 retry_node.cpp。

前面的實踐已經分別給出了端口、Blackboard 和持續動作。 本篇把它們組合成一個完整主題:機械人先確保電量足夠,再依次前往取貨點和放貨點;每段導航允許有限次嘗試,成功後報告結果,嘗試耗盡後報告任務失敗。

本篇的導航仍用回調計數模擬,充電簡化為一次電量更新,全部運行都不需要連接機械人。 先完成前面幾篇的實踐,再運行本篇,便於把注意力集中在任務組織與子樹邊界上。

MainTree
└── Sequence:一次运输任务
    ├── Fallback:确保电量(如果电量不OK,则充电)
    │   ├── BatteryOK
    │   └── ChargeBattery
    └── Fallback:执行任务,或报告失败(如果任务执行失败,则报Failure)
        ├── Sequence
        │   ├── SubTree:NavigateLeg,前往取货点
        │   ├── SubTree:NavigateLeg,前往放货点
        │   └── ReportMission
        └── ForceFailure
            └── ReportFailure

NavigateLeg:每处 SubTree 在建树时创建自己的节点和局部 Blackboard
└── RetryUntilSuccessful:最多尝试两次
    └── NavigateToGoal:跨多次 tick 检查本次导航

電量檢查失敗時,Fallback 繼續執行模擬充電;充電成功後,外層 Sequence 才進入導航。 導航序列返回 RUNNING 時,任務 Fallback 也返回 RUNNING,不會提前執行失敗報告。


2.複用子樹時明確數據邊界

兩段導航執行規則相同,只是目標和結果條目不同,因此可以共用 NavigateLeg 這份 XML 定義。 兩處 SubTree 在建樹時分別創建不同的節點對象和局部 Blackboard,內部的 target、reached 名稱可以相同,再分別連接到主樹的不同條目。運行期間重複 tick 或重試會繼續使用已有實例。

主树 Blackboard                         第一次 NavigateLeg 的局部 Blackboard
pickup_goal = loading_area   ──映射──→   target
pickup_reached               ←─映射──   reached

主树 Blackboard                         第二次 NavigateLeg 的局部 Blackboard
dropoff_goal = unloading_area ─映射──→   target
dropoff_reached              ←─映射──   reached

本篇使用官方 3.8 端口映射教程中的普通 SubTree 寫法:

<SubTree ID="NavigateLeg" target="pickup_goal" reached="pickup_reached"/>

這裏屬性名 target、reached 是子樹局部 Blackboard 的鍵,屬性值 pickup_goal、pickup_reached 是父 Blackboard 的鍵名。 它們建立映射關係,子樹對 reached 的寫入會反映到主樹的 pickup_reached。

所在位置 寫法 含義
普通節點端口 goal="{target}" 從當前 Blackboard 的 target 讀取輸入
3.8 的普通 SubTree 邊界 target="pickup_goal" 將子樹的 target 映射到父樹的 pickup_goal
普通節點的輸出端口 reached_goal="{reached}" 寫入當前 Blackboard 的 reached,再由映射連接到父樹

不要把本篇 SubTree 的映射值改成 {pickup_goal}。 3.8.7 的普通 SubTree 分支直接把屬性值當作父鍵名,不會替你去掉花括號,之後就可能找不到正確輸入。 源碼中還提供了使用不同映射語義的 SubTreePlus,本篇統一使用普通 SubTree,按表中的邊界規則填寫。

子樹邊界的鍵名也不等於 C++ 葉節點的端口名。 本例經過的是:goal 端口 → 局部 target → 父樹 pickup_goal,每一層分別聲明自己的接口。


3.建立組合任務工程

本篇直接複製整個 bt_running 文件夾,再把副本重命名為 bt_mission,保留原來的持續任務工程供後續對照。

在系統文件管理器中完成下面幾步:

  1. 打開 bt_running 所在的父目錄,複製並粘貼整個文件夾,將副本重命名為 bt_mission,讓兩個工程位於同一層目錄。
  2. 在新副本 bt_mission 中,刪除複製過來的 build 和 install 文件夾(如果存在)。其中的構建緩存和安裝結果屬於舊工程,新工程稍後會重新生成。
  3. 在 VSCode 中選擇 文件 → 新建窗口,再選擇 文件 → 打開文件夾,打開新的 bt_mission。

框架繼續來自 CMake工程模板,保留 C++20、CMake 3.22、預設格式版本 3,以及優先查找 BehaviorTree.CPP 3.8.7 的依賴配置。 Ubuntu 22.04 的 CMake 3.22.1 可以繼續使用。複用 Humble 安裝包時,VSCode 的配置、運行和調試進程仍需繼承 ROS 2 環境。

在頂層 CMakeLists.txt 中,將項目聲明改為:

project(bt_mission VERSION 1.0.0 LANGUAGES C CXX)

src/main.cpp 和 trees/mission.xml 已經隨工程複製過來,稍後用本篇內容替換。 在左側資源管理器的 trees 目錄中新建 navigation.xml,用於保存導航子樹。參與本篇構建的目錄為:

bt_mission/
├── CMakeLists.txt
├── CMakePresets.json
├── cmake/
│   ├── ProjectOptions.cmake
│   └── BehaviorTree.cmake
├── src/
│   ├── CMakeLists.txt
│   └── main.cpp
└── trees/
    ├── mission.xml          任务入口,引用导航子树
    └── navigation.xml       可复用的 NavigateLeg 定义

BT_XML_PATH 仍指向 trees/mission.xml,不需要因為增加了第二個 XML 而修改 CMake。


4.實現任務所需的節點

導航節點沿用上一篇的生命週期,在此基礎上增加“前幾次嘗試失敗”的模擬參數,以及成功後寫出到達目標的輸出端口。

節點 類型 主要職責
BatteryOK ConditionNode 讀取電量,達到 30 時返回成功
ChargeBattery SyncActionNode 將模擬電量更新為 80
NavigateToGoal StatefulActionNode 分多次檢查導航,成功時寫出 reached_goal
ReportMission SyncActionNode 讀取兩段導航的結果並報告完成
ReportFailure 簡單同步動作 輸出失敗信息,表示報告動作已執行

用以下完整程序替換 src/main.cpp 的原有內容,再保存。 四個節點類和一個簡單動作都在這個文件中,方便先把組合任務跑通。

#include "behaviortree_cpp_v3/bt_factory.h"

#include <chrono>
#include <cmath>
#include <iostream>
#include <stdexcept>
#include <string>

class BatteryOK : public BT::ConditionNode
{
public:
    BatteryOK(const std::string& name, const BT::NodeConfiguration& config)
        : BT::ConditionNode(name, config)
    {}

    static BT::PortsList providedPorts()
    {
        return {BT::InputPort<double>("level")};
    }

    BT::NodeStatus tick() override
    {
        auto level = getInput<double>("level");
        if (!level)
        {
            throw BT::RuntimeError("Cannot read input [level]: ", level.error());
        }
        if (!std::isfinite(level.value()) || level.value() < 0.0 || level.value() > 100.0)
        {
            throw std::runtime_error("battery level must be between 0 and 100");
        }
        const auto result = level.value() >= 30.0
                                ? BT::NodeStatus::SUCCESS : BT::NodeStatus::FAILURE;
        std::cout << "[BatteryOK] level=" << level.value()
                  << " -> " << BT::toStr(result) << '\n';
        return result;
    }
};

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

    static BT::PortsList providedPorts()
    {
        return {BT::OutputPort<double>("level")};
    }

    BT::NodeStatus tick() override
    {
        // 本例把充电简化为一次数据更新,真实充电应实现为持续任务。
        auto written = setOutput("level", 80.0);
        if (!written)
        {
            throw BT::RuntimeError("Cannot write output [level]: ", written.error());
        }
        std::cout << "[ChargeBattery] level=80\n";
        return BT::NodeStatus::SUCCESS;
    }
};

class NavigateToGoal : public BT::StatefulActionNode
{
public:
    NavigateToGoal(const std::string& name, const BT::NodeConfiguration& config)
        : BT::StatefulActionNode(name, config)
    {}

    static BT::PortsList providedPorts()
    {
        return {BT::InputPort<std::string>("goal"),
                BT::InputPort<int>("ticks", 2, "每次导航尝试的检查次数"),
                BT::InputPort<int>("fail_attempts", 1, "模拟前几次尝试失败"),
                BT::OutputPort<std::string>("reached_goal")};
    }

    BT::NodeStatus onStart() override
    {
        auto goal = getInput<std::string>("goal");
        auto ticks = getInput<int>("ticks");
        auto failures = getInput<int>("fail_attempts");
        if (!goal)
        {
            throw BT::RuntimeError("Cannot read input [goal]: ", goal.error());
        }
        if (!ticks)
        {
            throw BT::RuntimeError("Cannot read input [ticks]: ", ticks.error());
        }
        if (!failures)
        {
            throw BT::RuntimeError("Cannot read input [fail_attempts]: ", failures.error());
        }
        if (goal.value().empty() || ticks.value() < 1 || failures.value() < 0)
        {
            throw std::runtime_error("invalid navigation simulation parameters");
        }

        goal_ = goal.value();
        required_ticks_ = ticks.value();
        failed_attempts_ = failures.value();
        completed_ticks_ = 0;
        // 每个节点实例分别保留尝试次数,用于模拟先失败、后成功。
        ++attempt_;
        std::cout << "[NavigateToGoal] start goal=" << goal_
                  << ", attempt=" << attempt_
                  << ", completed_ticks=" << completed_ticks_ << '\n';
        return BT::NodeStatus::RUNNING;
    }

    BT::NodeStatus onRunning() override
    {
        ++completed_ticks_;
        std::cout << "[NavigateToGoal] check goal=" << goal_
                  << ", attempt=" << attempt_
                  << ", completed_ticks=" << completed_ticks_
                  << '/' << required_ticks_ << '\n';
        if (completed_ticks_ < required_ticks_)
        {
            return BT::NodeStatus::RUNNING;
        }
        if (attempt_ <= failed_attempts_)
        {
            std::cout << "[NavigateToGoal] failed goal=" << goal_
                      << ", attempt=" << attempt_ << '\n';
            return BT::NodeStatus::FAILURE;
        }

        auto written = setOutput("reached_goal", goal_);
        if (!written)
        {
            throw BT::RuntimeError("Cannot write output [reached_goal]: ", written.error());
        }
        std::cout << "[NavigateToGoal] arrived goal=" << goal_
                  << ", attempt=" << attempt_ << '\n';
        return BT::NodeStatus::SUCCESS;
    }

    void onHalted() override
    {
        std::cout << "[NavigateToGoal] halted goal=" << goal_ << '\n';
        completed_ticks_ = 0;
    }

private:
    std::string goal_;
    int required_ticks_ = 0;
    int completed_ticks_ = 0;
    int failed_attempts_ = 0;
    int attempt_ = 0;
};

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

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

    BT::NodeStatus tick() override
    {
        auto pickup = getInput<std::string>("pickup");
        auto dropoff = getInput<std::string>("dropoff");
        if (!pickup)
        {
            throw BT::RuntimeError("Cannot read input [pickup]: ", pickup.error());
        }
        if (!dropoff)
        {
            throw BT::RuntimeError("Cannot read input [dropoff]: ", dropoff.error());
        }
        std::cout << "[ReportMission] pickup=" << pickup.value()
                  << ", dropoff=" << dropoff.value() << '\n';
        return BT::NodeStatus::SUCCESS;
    }
};

BT::NodeStatus ReportFailure(BT::TreeNode&)
{
    std::cout << "[ReportFailure] transport mission failed\n";
    return BT::NodeStatus::SUCCESS;
}

int main()
{
    try
    {
        BT::BehaviorTreeFactory factory;
        factory.registerNodeType<BatteryOK>("BatteryOK");
        factory.registerNodeType<ChargeBattery>("ChargeBattery");
        factory.registerNodeType<NavigateToGoal>("NavigateToGoal");
        factory.registerNodeType<ReportMission>("ReportMission");
        factory.registerSimpleAction("ReportFailure", ReportFailure);

        auto blackboard = BT::Blackboard::create();
        blackboard->set("battery", 20.0);
        blackboard->set("pickup_goal", std::string("loading_area"));
        blackboard->set("dropoff_goal", std::string("unloading_area"));
        auto tree = factory.createTreeFromFile(BT_XML_PATH, blackboard);

        int root_tick = 1;
        std::cout << "[main] tick=" << root_tick << " begin\n";
        BT::NodeStatus result = tree.tickRoot();
        std::cout << "[main] tick=" << root_tick
                  << " -> " << BT::toStr(result) << '\n';
        while (result == BT::NodeStatus::RUNNING)
        {
            tree.sleep(std::chrono::milliseconds(100));
            ++root_tick;
            std::cout << "[main] tick=" << root_tick << " begin\n";
            result = tree.tickRoot();
            std::cout << "[main] tick=" << root_tick
                      << " -> " << BT::toStr(result) << '\n';
        }
        std::cout << "Tree result: " << BT::toStr(result) << '\n';
        if (result == BT::NodeStatus::SUCCESS)
        {
            std::cout << "[main] pickup_reached="
                      << blackboard->get<std::string>("pickup_reached") << '\n';
            std::cout << "[main] dropoff_reached="
                      << blackboard->get<std::string>("dropoff_reached") << '\n';
        }
        return result == BT::NodeStatus::SUCCESS ? 0 : 1;
    }
    catch (const std::exception& error)
    {
        std::cerr << "Error: " << error.what() << '\n';
        return 2;
    }
}

ticks 和 fail_attempts 都是本例的模擬參數。ticks="2" 表示一次導航在兩次 onRunning() 檢查後給出結果;fail_attempts="1" 表示同一個節點實例的第一次嘗試失敗,後續嘗試成功。節點的 attempt_ 從 0 開始,在每次 onStart() 中遞增。

attempt_ 是教學模擬器自己的計數,和 Retry 裝飾節點內部的重試計數不是同一個變量。這裏故意保留它,才能在重啓導航後模擬“第一次失敗、第二次成功”。每次 Launch 都會創建新的樹;如果實際程序複用同一棵樹處理多個任務,應明確什麼時候重置業務成員和結果條目。

4.1.對照本例中的 API 參數

本篇主要組合前面已經使用過的接口。註冊和端口接口的原型見 第一個完整程序,外部 Blackboard 的讀寫見 節點之間傳數據,回調與驅動循環見 持續任務與中止。對照本例時,重點看每個實參對應哪一層名稱:

調用 參數含義 返回值
factory.registerNodeType<NavigateToGoal>("NavigateToGoal") 模板參數是 C++ 節點類;第一個函數參數是 XML 中使用的註冊名 void,註冊類型,尚未啓動導航
factory.registerSimpleAction("ReportFailure", ReportFailure) 第一個參數是註冊名;第二個參數是回調函數本身,樹執行到 <ReportFailure/> 時才調用它 註冊函數返回 void;回調另行返回自己的節點狀態
blackboard->set("battery", 20.0) 第一個參數是 Blackboard 鍵,第二個參數是值;這裏省略 <double>,由 20.0 推導類型 void,寫入電量
factory.createTreeFromFile(BT_XML_PATH, blackboard) 第一個參數是入口 XML 路徑;第二個參數是根樹使用的 Blackboard 共享指針 BT::Tree,包含創建好的節點實例
setOutput("reached_goal", goal_) 第一個參數是當前節點的輸出端口名;第二個參數是保存的目標字符串 BT::Result,本例檢查寫出是否成功
blackboard->get<std::string>("pickup_reached") 模板參數指定讀取類型;第一個函數參數是根 Blackboard 中的結果鍵 std::string 值;鍵不存在或類型不兼容時拋出異常

setOutput() 的 reached_goal 是節點端口名,get() 的 pickup_reached 是主程序要讀取的 Blackboard 鍵,兩者由後面的 XML 映射連接。ReportFailure() 回調返回 SUCCESS 表示報告動作完成;整項運輸任務的失敗結果由 XML 中包裹它的 ForceFailure 保留,不能把回調返回值與註冊函數返回值混為一談。


5.用兩個 XML 文件組織任務

5.1.trees/mission.xml

用下面的 XML 替換 trees/mission.xml 的原有內容,再保存。

<root main_tree_to_execute="MainTree">
    <include path="navigation.xml"/>

    <BehaviorTree ID="MainTree">
        <Sequence name="transport_job">
            <Fallback name="ensure_battery">
                <BatteryOK level="{battery}"/>
                <ChargeBattery level="{battery}"/>
            </Fallback>

            <Fallback name="run_or_report_failure">
                <Sequence>
                    <SubTree ID="NavigateLeg"
                             target="pickup_goal" reached="pickup_reached"/>
                    <SubTree ID="NavigateLeg"
                             target="dropoff_goal" reached="dropoff_reached"/>
                    <ReportMission pickup="{pickup_reached}"
                                   dropoff="{dropoff_reached}"/>
                </Sequence>
                <ForceFailure>
                    <ReportFailure/>
                </ForceFailure>
            </Fallback>
        </Sequence>
    </BehaviorTree>
</root>

主樹中兩次 SubTree 使用相同的 ID="NavigateLeg",但分別映射到取貨點和放貨點的父鍵。NavigateLeg 是 XML 中定義的行為樹名稱,NavigateToGoal 才是註冊到工廠的 C++ 葉節點名稱。

5.2.trees/navigation.xml

將下面的內容保存到新建的 trees/navigation.xml。

<root>
    <BehaviorTree ID="NavigateLeg">
        <RetryUntilSuccessful num_attempts="2">
            <NavigateToGoal goal="{target}" reached_goal="{reached}"
                            ticks="2" fail_attempts="1"/>
        </RetryUntilSuccessful>
    </BehaviorTree>
</root>

<include path="navigation.xml"/> 會在加載主 XML 時引入子樹定義,路徑相對於包含它的 mission.xml 所在目錄。主程序通過 BT_XML_PATH 的完整路徑加載入口,所以從不同工作目錄啓動,仍能找到同目錄的 navigation.xml。

C++ 中已經註冊了葉節點;兩個 XML 負責定義和連接任務。工廠加載入口文件時會處理 include 和 BehaviorTree ID,因此不需要再為 NavigateLeg 編寫一個 C++ 節點類。

截圖預留:主樹和導航子樹。 展示兩個 XML 文件,以及兩處 SubTree 映射到不同父鍵的位置。


6.在 VSCode 中運行完整任務

保存所有文件,選擇 Linux Debug,執行 Configure → Build,將運行目標設為 bt_mission,再點擊 Launch。日誌中的 [main] tick=N begin 和同號的返回狀態標出一次根 tick 的範圍;導航的 check 行記錄自增後的檢查次數。完整輸出為:

[main] tick=1 begin
[BatteryOK] level=20 -> FAILURE
[ChargeBattery] level=80
[NavigateToGoal] start goal=loading_area, attempt=1, completed_ticks=0
[main] tick=1 -> RUNNING
[main] tick=2 begin
[NavigateToGoal] check goal=loading_area, attempt=1, completed_ticks=1/2
[main] tick=2 -> RUNNING
[main] tick=3 begin
[NavigateToGoal] check goal=loading_area, attempt=1, completed_ticks=2/2
[NavigateToGoal] failed goal=loading_area, attempt=1
[NavigateToGoal] start goal=loading_area, attempt=2, completed_ticks=0
[main] tick=3 -> RUNNING
[main] tick=4 begin
[NavigateToGoal] check goal=loading_area, attempt=2, completed_ticks=1/2
[main] tick=4 -> RUNNING
[main] tick=5 begin
[NavigateToGoal] check goal=loading_area, attempt=2, completed_ticks=2/2
[NavigateToGoal] arrived goal=loading_area, attempt=2
[NavigateToGoal] start goal=unloading_area, attempt=1, completed_ticks=0
[main] tick=5 -> RUNNING
[main] tick=6 begin
[NavigateToGoal] check goal=unloading_area, attempt=1, completed_ticks=1/2
[main] tick=6 -> RUNNING
[main] tick=7 begin
[NavigateToGoal] check goal=unloading_area, attempt=1, completed_ticks=2/2
[NavigateToGoal] failed goal=unloading_area, attempt=1
[NavigateToGoal] start goal=unloading_area, attempt=2, completed_ticks=0
[main] tick=7 -> RUNNING
[main] tick=8 begin
[NavigateToGoal] check goal=unloading_area, attempt=2, completed_ticks=1/2
[main] tick=8 -> RUNNING
[main] tick=9 begin
[NavigateToGoal] check goal=unloading_area, attempt=2, completed_ticks=2/2
[NavigateToGoal] arrived goal=unloading_area, attempt=2
[ReportMission] pickup=loading_area, dropoff=unloading_area
[main] tick=9 -> SUCCESS
Tree result: SUCCESS
[main] pickup_reached=loading_area
[main] dropoff_reached=unloading_area

初始電量為 20,因此先檢查失敗,再由 ChargeBattery 更新為 80。兩段導航分別經歷第一次失敗、第二次成功,最後 ReportMission 和主程序都能讀到兩個到達結果。

執行位置 本次使用的數據 結果
電量 Fallback 主樹的 battery 低電量時更新到 80
第一次 NavigateLeg pickup_goal 映射到局部 target 寫出 pickup_reached
第二次 NavigateLeg dropoff_goal 映射到另一個局部 target 寫出 dropoff_reached
ReportMission 主樹的兩個結果鍵 輸出任務完成信息
main 從根 Blackboard 讀取兩個結果 確認跨子樹的數據傳遞

電量檢查是這次任務的啓動準備。進入導航後,外層普通 Sequence 會從正在執行的位置繼續,電量檢查不會每次都重跑。如果任務要求導航期間持續檢查某個條件,可以沿用上一篇的 ReactiveSequence 方式組織檢查層。


7.從 main() 走到第一次根 tick

先把“準備一棵樹”和“驅動這棵樹”分開。程序進入 main() 後,按下面的順序準備任務:

執行位置 已完成的工作 此時是否已經開始導航
創建工廠並註冊節點 讓工廠知道 XML 中的節點名稱對應哪個 C++ 類型或回調 尚未開始,也沒有因此調用 onStart()
創建並填寫 Blackboard 設置 battery=20、取貨點 loading_area 和放貨點 unloading_area 尚未開始
createTreeFromFile() 讀取主 XML 和被包含的 XML,創建節點實例及兩份導航子樹,建立 Blackboard 映射 尚未開始
第一次 tree.tickRoot() 從根節點進入控制節點,再執行本次能夠到達的葉節點 此時才會啓動取貨導航

樹創建完成、第一次根 tick 尚未執行時,兩份 NavigateToGoal 對象的 goal_ 都是空字符串,四個整數成員也都是 0。XML 中的 ticks="2" 和 fail_attempts="1" 不會直接給這些 C++ 成員賦值;本例要等進入 onStart(),調用 getInput() 後,才把它們保存到成員中。

兩處 SubTree 在建樹時就分別實例化了導航節點。第一次 tick 不會再創建一個取貨節點,重試也不會重新構造節點;後面始終是在已有對象上切換狀態、調用回調。

7.1.先分清本例中的幾種次數

名稱 誰維護它 本例中的準確含義
root_tick main() 調用 tree.tickRoot() 的序號,從 1 開始打印
completed_ticks_ 當前導航節點 本次嘗試已經執行多少次 onRunning();每次 onStart() 都清零
required_ticks_ 當前導航節點 從 ticks="2" 讀到的閾值;本次嘗試檢查兩次後,才判斷成功或失敗
attempt_ 當前導航節點 這個對象第幾次執行 onStart();每次啓動加一
failed_attempts_ 當前導航節點 從 fail_attempts="1" 讀到的模擬規則;attempt_ <= 1 時模擬失敗
num_attempts="2" 當前 Retry 節點 允許最多兩次總嘗試,即首次嘗試加一次重試

completed_ticks_ 不包含 onStart() 那次調用,也不是整棵樹累計 tick 的次數。第一次根 tick 啓動取貨導航後,取貨節點是 attempt_=1、completed_ticks_=0,並不是已經完成了一次檢查。

Retry 內部另有失敗計數 try_count_:當前這輪開始時為 0,孩子返回一次 FAILURE 後變為 1,孩子持續返回 RUNNING 時不會消耗新的嘗試次數。孩子成功,或失敗次數達到上限、Retry 結束這一輪時,它會把自己的計數清零;導航對象的 attempt_ 則仍然保留。這兩個計數用途不同,不應當成一個變量。


8.按九次根 tick 推演完整任務

保持本篇默認參數:初始電量 20、ticks="2"、fail_attempts="1"、num_attempts="2"。下面每一行對應主程序調用一次 tree.tickRoot(),也對應日誌中一組同號的 begin 和返回狀態。

兩列成員值統一寫成 (attempt_, completed_ticks_),記錄的是該次根 tick 返回時的值。取貨和放貨屬於兩個不同對象,必須分別觀察。

根 tick 本次按順序發生的事情 取貨節點成員值 放貨節點成員值 根返回
1 檢查電量失敗,充電到 80;啓動取貨導航,onStart() 返回 RUNNING (1, 0) (0, 0) RUNNING
2 取貨導航進入 onRunning(),檢查次數從 0 加到 1,尚未達到 2 (1, 1) (0, 0) RUNNING
3 取貨檢查次數加到 2,第一次嘗試失敗;Retry 重置孩子並立即再次調用 onStart() (2, 0) (0, 0) RUNNING
4 取貨的第二次嘗試進入 onRunning(),檢查次數加到 1 (2, 1) (0, 0) RUNNING
5 取貨檢查次數加到 2,成功寫出結果;序列繼續啓動放貨導航 (2, 2) (1, 0) RUNNING
6 放貨導航進入 onRunning(),檢查次數加到 1 (2, 2) (1, 1) RUNNING
7 放貨檢查次數加到 2,第一次嘗試失敗;Retry 立即重新啓動它 (2, 2) (2, 0) RUNNING
8 放貨的第二次嘗試進入 onRunning(),檢查次數加到 1 (2, 2) (2, 1) RUNNING
9 放貨檢查次數加到 2,成功寫出結果;執行 ReportMission,整棵樹成功 (2, 2) (2, 2) SUCCESS

表中的成員值不等於節點狀態。例如,取貨成功後,父節點可以把它的狀態重置為 IDLE,但 attempt_=2、completed_ticks_=2 仍保存在對象裏。框架重置執行狀態,不會自動清空我們自己聲明的 C++ 成員。

8.1.第一次根 tick 為什麼做了不止一件事

第一次 tree.tickRoot() 從根 Sequence 開始,執行路徑是:

transport_job:Sequence
├── ensure_battery:Fallback
│   ├── BatteryOK:读取 20,返回 FAILURE
│   └── ChargeBattery:写入 80,返回 SUCCESS
│   → ensure_battery 返回 SUCCESS
└── run_or_report_failure:Fallback
    └── 导航 Sequence
        └── 取货 SubTree → Retry → NavigateToGoal::onStart()
            → attempt_:0 → 1
            → completed_ticks_:清零为 0
            → 返回 RUNNING,一层层传回根节点

這裏電量條件的 FAILURE 被它的 Fallback 處理了,根節點沒有因此返回失敗。充電同步完成,外層 Sequence 就在同一次調用中繼續啓動導航。一次根 tick 是從根開始推進一次執行過程,並不是只執行一個葉節點。

onStart() 返回 RUNNING 後,本次調用不會繼續進入這個導航節點的 onRunning()。主程序收到根的 RUNNING,等待 100 ms,再發起下一次根 tick;第二次才執行取貨導航的 onRunning()。

從第二次開始,普通 Sequence 會繼續正在運行的孩子,外層繼續導航分支,內層繼續當前導航子樹。電量檢查不會每次都重跑。100 ms 是本例主循環兩次調用之間的等待時間,ticks="2" 本身不表示兩秒,也不表示啓動後只需兩次根 tick。

8.2.第三次根 tick:失敗後為什麼又進入 onStart()

進入第三次根 tick 時,取貨導航仍是 RUNNING,成員是 attempt_=1、completed_ticks_=1。這次調用按下面的順序執行:

  1. onRunning() 先執行 ++completed_ticks_,值從 1 變成 2。
  2. 2 < required_ticks_ 不成立,繼續判斷 attempt_ <= failed_attempts_,即 1 <= 1,成立,於是導航返回 FAILURE。
  3. Retry 收到孩子的失敗,將自己的失敗計數從 0 加到 1。最多允許兩次嘗試,所以還有一次機會。
  4. Retry 將失敗孩子的狀態恢復為 IDLE,在本次根 tick 中再次執行這個孩子。同一個導航對象因此進入 onStart(),把 completed_ticks_ 從 2 清成 0,再把 attempt_ 從 1 加到 2。
  5. 新的 onStart() 返回 RUNNING,Retry 和上層控制節點也返回 RUNNING,控制權回到 main()。

所以第三次的日誌會在同一組 tick=3 中出現 failed 和新的 start。葉節點曾經失敗,不代表這一次根 tick 的最終結果也是失敗。 本次失敗由 Retry 處理,任務 Fallback 沒有收到最終的 FAILURE,因此也不會執行 ReportFailure。

注意 completed_ticks_ 在這一次根 tick 內經歷了 1 → 2 → 0,表格只記錄最後的 0。attempt_ 沒有清零,因為本例用它保留“同一個對象第一次失敗、第二次成功”的模擬進度。

8.3.第五次根 tick:取貨成功後為什麼立刻啓動放貨

第四次已經把取貨節點推進到 attempt_=2、completed_ticks_=1。第五次進入 onRunning() 後,檢查次數加到 2;此時 2 <= failed_attempts_,即 2 <= 1,不成立,因此通過 setOutput() 寫出到達結果並返回 SUCCESS。

這個輸出先寫入取貨子樹的局部 reached,再通過映射更新主樹的 pickup_reached。Retry 返回成功,導航 Sequence 接着執行第二個孩子,於是在同一次根 tick 中調用放貨導航的 onStart()。

放貨導航是另一個對象,它此前的 attempt_ 是 0,所以啓動後變為 1,不是沿着取貨節點加成 3。它的 completed_ticks_ 為 0,返回 RUNNING,整次根 tick 也就返回 RUNNING。

第六到第八次按相同方式推進放貨導航。第九次放貨成功,寫出 dropoff_reached,隨後 ReportMission 在本次調用中讀取兩個結果並返回成功。根返回 SUCCESS 後,while 循環結束,main() 打印最終狀態與 Blackboard 結果,返回退出碼 0;程序不會再執行第十次根 tick。


9.RUNNING、失敗重試和中止分別進入哪個回調

StatefulActionNode 根據當前執行狀態選擇回調:IDLE 時進入 onStart(),RUNNING 時進入 onRunning()。要判斷是否重新開始,既要看孩子返回什麼,也要看父節點隨後怎樣處理它。

發生的情況 誰處理後續流程 本例的回調與成員變化
導航返回 RUNNING 父節點保留當前執行位置,等待後續根 tick 下次進入 onRunning(),attempt_ 不變,檢查次數繼續增加
導航返回 FAILURE,Retry 還有預算 Retry 記錄失敗,將孩子恢復為 IDLE 並再次執行 再進 onStart(),completed_ticks_ 清零,attempt_ 加一
導航返回 FAILURE,Retry 預算耗盡 Retry 向上返回失敗,上層進入失敗報告分支 不再啓動這段導航,不會出現第三次 onStart()
導航返回 SUCCESS 父序列推進到下一個孩子 該次嘗試結束;後面的導航使用另一個節點對象
正在 RUNNING 的導航被中止 例如外部調用 tree.haltTree(),或上層控制節點停止這個運行分支 進入 onHalted(),本例清零 completed_ticks_;以後再次啓動才進入 onStart()

返回 FAILURE 本身不會自動重試,也不會因此自動調用 onHalted()。 第三次和第七次 tick 中,Retry 處理的是已經返回失敗的孩子,因此沒有進入它的 onHalted()。正常默認流程沒有主動中止運行中的導航,也就不會打印 halted。

本例的 onHalted() 沒有把 attempt_ 清零。若中止後複用同一個對象,它下次進入 onStart() 時仍會在原來的 attempt_ 上加一。實際任務若要求“每輪任務重新計算嘗試次數”,需要另外明確業務成員和結果條目的重置時機,不能只依賴節點狀態變成 IDLE。

以上生命週期與重試行為可對照 3.8.7 的 action_node.cpp 和 retry_node.cpp。九次根 tick 的具體推演針對本篇程序及默認參數;換成同步失敗的動作或其他驅動接口時,應重新分析一次調用會推進到哪裏。


10.有限重試與失敗報告各自負責一層

num_attempts="2" 表示最多兩次總嘗試,不是首次嘗試之後再額外重試兩次。 導航返回 RUNNING 時,裝飾節點繼續等待這一次嘗試;只有導航返回 FAILURE,才會記錄一次失敗並決定是否重新開始。

前面的第 3 次和第 7 次根 tick 已經展示了尚有預算時的處理:本例導航在 onRunning() 中失敗,Retry 隨即在同一次根 tick 內啓動下一次嘗試;新的 onStart() 返回 RUNNING 後,控制權回到主循環。下面再看預算耗盡時,失敗如何一路傳遞到根節點。

將 navigation.xml 中的 num_attempts="2" 改為 num_attempts="1",只保存 XML,再點擊 Launch。此時第一次失敗已經耗盡次數,輸出為:

[main] tick=1 begin
[BatteryOK] level=20 -> FAILURE
[ChargeBattery] level=80
[NavigateToGoal] start goal=loading_area, attempt=1, completed_ticks=0
[main] tick=1 -> RUNNING
[main] tick=2 begin
[NavigateToGoal] check goal=loading_area, attempt=1, completed_ticks=1/2
[main] tick=2 -> RUNNING
[main] tick=3 begin
[NavigateToGoal] check goal=loading_area, attempt=1, completed_ticks=2/2
[NavigateToGoal] failed goal=loading_area, attempt=1
[ReportFailure] transport mission failed
[main] tick=3 -> FAILURE
Tree result: FAILURE

這次只執行了三次根 tick:第 1 次啓動取貨,第 2 次完成第一次檢查,第 3 次完成第二次檢查並失敗。Retry 的失敗計數變為 1,已經達到 num_attempts="1",因此沒有第二次 onStart();取貨節點停留在 attempt_=1、completed_ticks_=2。

失敗在第 3 次根 tick 內繼續向上傳遞:取貨 Retry 返回 FAILURE → 導航 Sequence 返回 FAILURE → 任務 Fallback 轉到失敗報告分支 → ReportFailure 返回 SUCCESS → 外面的 ForceFailure 返回 FAILURE → 任務 Fallback 和根 Sequence 最終都返回 FAILURE。

所以沒有出現放貨點導航,也沒有執行 ReportMission。main() 收到根的 FAILURE 後退出循環,不讀取成功結果,返回退出碼 1。ReportFailure 的成功只表示報告已執行,並沒有把失敗的運輸任務改成成功。

如果恢復 num_attempts="2",但將 fail_attempts 改為 2,取貨的兩次嘗試都會失敗,任務會在第 5 次根 tick 結束。此時取貨節點是 attempt_=2、completed_ticks_=2;不會出現第三次啓動,放貨導航仍未執行。

失敗報告分支外面包了一層 ForceFailure:

ForceFailure
└── ReportFailure   报告动作执行完毕,返回 SUCCESS
         ↓
该分支向任务 Fallback 返回 FAILURE

ReportFailure 返回成功表示日誌報告已經執行;ForceFailure 保留的是運輸任務失敗的結果。這樣完成失敗處理後,整棵樹仍返回 FAILURE,主程序也不會進入成功結果的讀取分支。


11.分別修改電量、重試和映射來對照

每次對照只改一項,完成後恢復原值,再進行下一項。修改 C++ 後先 Build → Launch;只改 XML 時保存後直接 Launch。

修改位置 修改內容 觀察到的結果
main() 初始化 battery 從 20.0 改為 80.0 電量檢查直接成功,不執行模擬充電
navigation.xml fail_attempts 從 1 改為 0 兩段導航都在第一次嘗試成功
navigation.xml 保持最多兩次嘗試,將 fail_attempts 改為 2 取貨點兩次都失敗,任務失敗,放貨點不執行
mission.xml 的第一次 SubTree 將 target="pickup_goal" 改成 target="{pickup_goal}" 普通 SubTree 映射到錯誤的父鍵,讀取 goal 報錯
mission.xml 的第一次 SubTree 刪除 reached="pickup_reached" 到達結果只寫到該子樹局部鍵,主樹讀取 pickup 時缺少輸入

最後兩種是數據配置錯誤,主程序打印 Error: 並返回 2。它們不同於導航嘗試耗盡時的任務 FAILURE,後者正常返回任務失敗碼 1。

如果遺漏輸出映射,子樹內部仍然可以完成一次導航;真正缺失的是通往主樹結果條目的連接。局部鍵名字相同,不代表所有子樹自動共享同一個存儲位置。


12.在斷點中核對回調、計數和節點實例

恢復主示例的電量與 XML,先 Build,再設置下面幾處斷點並點擊 Debug。斷點通常停在所在語句執行之前,要區分“即將自增”和“已經自增”。

斷點位置 要觀察的內容
main() 第一次根 tick 的調用語句 此時 root_tick=1,樹已創建,導航尚未啓動;單步進入可沿控制節點找到第一次 onStart()
onStart() 中 ++attempt_ 之後的打印語句 此時清零和自增已完成;取貨兩次暫停分別為 (attempt_, completed_ticks_)=(1, 0)、(2, 0)
onRunning() 中 ++completed_ticks_ 之後的打印語句 此時本次自增已完成;同一次嘗試兩次暫停分別看到 completed_ticks_=1、2
main() 中根 tick 調用之後的狀態打印語句 此時 result 已更新;第 3 次即使出現導航失敗,根結果仍為 RUNNING,第 9 次才是 SUCCESS

將 goal_、attempt_、completed_ticks_、required_ticks_、failed_attempts_ 和 this 加到監視窗口。在 main() 的棧幀中觀察 root_tick、result;進入節點回調後,可以切回 main() 棧幀查看當前是第幾次根 tick。第一次調用返回前,result 尚未完成初始化;在後續循環中,調用尚未返回時,result 仍是上一次賦值的值。要等調用返回、賦值完成,再判斷本次根結果。

第三次根 tick 中,先在取貨的 onRunning() 看到 (1, 2);繼續執行,又會在同一個對象的 onStart() 中看到 (2, 0),此時 root_tick 仍是 3。第五次則先看到取貨 (2, 2),繼續後進入放貨的 onStart(),看到 (1, 0),而 root_tick 仍是 5。

比較 this 指針:取貨點的兩次啓動使用同一個對象,放貨點的對象地址不同。這個過程對應的是“同一份子樹定義,兩次獨立實例化”。複用的是結構和實現,任務成員與未映射的局部 Blackboard 條目分別屬於各自實例。

完成工程後,分別運行電量充足、首次成功、重試耗盡、錯誤輸入映射和遺漏輸出映射等對照,檢查返回狀態、重試次數與錯誤信息。同時從不同工作目錄啓動程序,確認入口 XML 路徑不依賴當前終端所在目錄。


13.當前學習進度

到這裏,教程已經給出了從最小程序到組合任務的完整實踐路徑:

文檔 對應的實踐主題
第一個完整程序 註冊節點、加載 XML、執行最小樹,再逐步增加條件和端口
節點之間傳數據 自定義類型、輸入輸出與雙向端口、外部 Blackboard
持續任務與中止 多次 tick、StatefulActionNode、條件變化和主動中止
本篇 子樹隔離與映射、多 XML 文件、有限重試和失敗處理

基礎編程實踐的文檔到此結束。

接下來進入工具實踐:使用Groot1編輯與調試。沿用已有節點,在圖形界面中編輯 XML、觀察運行狀態,再回放成功與中止的日誌。