跳到正文

Wiki

控制與裝飾規則

約 13 分鐘閱讀

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

1.控制與裝飾規則

對應 BehaviorTree.CPP 3.8 官方文檔:Learn the Basic Concepts → Introduction to BTs → Examples,並參考 Nodes Library 中的 Sequences、Fallbacks 與 Decorators。

上一篇已經知道:tick 從父節點向下傳播,狀態由子節點向上返回。現在進一步學習,父節點拿到 SUCCESS、FAILURE、RUNNING 後,具體怎樣決定下一步。

需要表達的任務關係 常用節點
前一步成功後,繼續下一步 Sequence
當前方案失敗後,嘗試備用方案 Fallback
修改一個節點的結果、次數或時限 裝飾節點

本篇先講普通 Sequence、普通 Fallback 和常用裝飾節點,使用移動機械人示例。 C++ 是解釋邏輯的偽代碼; XML 只展示樹的片段,完整工程、節點註冊和 XML 文件結構後面統一學習。


2.Sequence:依次完成一組步驟

Sequence 按子節點的排列順序執行,只有前面的步驟成功,才繼續後面的步驟:

Sequence
├── CheckBattery          检查电量是否足够
├── NavigateToGoal        导航到目标
└── ReportArrival         报告到达
當前子節點返回 Sequence 接下來做什麼 Sequence 的結果
SUCCESS 繼續 tick 下一個子節點 所有子節點成功後,返回 SUCCESS
FAILURE 停止本次序列,不再 tick 後面的節點 FAILURE
RUNNING 停在當前子節點,等待後續繼續執行 RUNNING

例如 CheckBattery 返回 FAILURE,本輪不會發送導航請求,也不會報告到達;NavigateToGoal 返回 FAILURE,則跳過 ReportArrival。

2.1.一次 tick 可以繼續執行多個步驟

假設電量足夠,導航要經過幾次檢查才完成:

外部第幾次 tick 本次實際執行的子節點 Sequence 返回
第 1 次 CheckBattery → SUCCESS,接着 NavigateToGoal → RUNNING RUNNING
第 2 次 NavigateToGoal → RUNNING RUNNING
第 3 次 NavigateToGoal → SUCCESS,接着 ReportArrival → SUCCESS SUCCESS

前一步在當前調用中成功後,Sequence 可以立刻繼續 tick 下一步,不必等到下一輪。

普通 Sequence 在子節點 RUNNING 時,會記住當前位置。 因此第 2 次 tick 會繼續導航,不會重新檢查前面已經成功的 CheckBattery。

如果導航期間電量變低,這棵普通 Sequence 不會因此自動重新檢查電量並中止導航。需要持續檢查條件時,要使用合適的響應式結構,後面結合任務中止來學習。

2.2.用子節點下標理解執行位置

下面的 current_index 是節點保存的成員狀態,初始為 0:

BT::NodeStatus Sequence::tick()
{
    while (current_index < children.size())
    {
        BT::NodeStatus status = children[current_index].executeTick();

        if (status == BT::NodeStatus::RUNNING)
        {
            // 保留位置,下次继续 tick 同一个子节点
            return BT::NodeStatus::RUNNING;
        }

        if (status == BT::NodeStatus::FAILURE)
        {
            ResetChildren();
            current_index = 0;
            return BT::NodeStatus::FAILURE;
        }

        // 此处假设子节点只返回三个合法执行结果,剩下的是 SUCCESS
        ++current_index;
    }

    ResetChildren();
    current_index = 0;
    return BT::NodeStatus::SUCCESS;
}

這段偽代碼省略框架狀態維護和非法狀態檢查。 ResetChildren() 表示結束本次執行並重置子節點,不能理解為撤銷已經產生的業務效果。

這一行為可對照官方 3.8.7 sequence_node.cpp。 普通 Sequence 成功或失敗結束後,位置都會回到開頭;如果上層以後重新執行它,會從第一個子節點開始。 返回失敗不會在當前 tick 中自動從頭重試。

2.3.後面的節點不保證一定執行

下面這棵樹不能保證導航失敗後執行停止節點:

Sequence
├── NavigateToGoal
└── StopRobot

導航返回 FAILURE 時,Sequence 立即返回失敗,StopRobot 會被跳過。

動作失敗或被中止時需要的取消、停止和清理,應在動作及相關模塊中正確實現。 僅把清理動作放在 Sequence 最後,不能保證它在所有退出路徑上執行。


3.Fallback:按順序嘗試方案

Fallback 在其他框架中也常叫 Selector。 它按順序嘗試子節點,只要找到成功的方案,就結束本次選擇:

Fallback
├── NavigateToPrimaryGoal     尝试主要目标
└── NavigateToBackupGoal      主要目标失败后,尝试备用目标
當前子節點返回 Fallback 接下來做什麼 Fallback 的結果
FAILURE 繼續 tick 下一個子節點 全部失敗後,返回 FAILURE
SUCCESS 不再嘗試後面的子節點 SUCCESS
RUNNING 繼續等待當前方案,不啓動後面的方案 RUNNING

RUNNING 不算失敗。 主要目標還在導航時,不會因為“尚未到達”就立即啓動備用目標。

例如:

外部第幾次 tick 本次實際執行的子節點 Fallback 返回
第 1 次 NavigateToPrimaryGoal → RUNNING RUNNING
第 2 次 NavigateToPrimaryGoal → FAILURE,接着 NavigateToBackupGoal → RUNNING RUNNING
第 3 次 NavigateToBackupGoal → SUCCESS SUCCESS

普通 Fallback 在 RUNNING 時同樣保留當前位置。 第 3 次繼續備用導航,不會重新嘗試前面已經失敗的主要目標。

本次選擇成功或全部失敗結束後,它會重置位置;上層以後重新執行時,再從第一個子節點開始。具體可對照官方 3.8.7 fallback_node.cpp。

3.1.條件節點也能作為優先方案

例如“如果已經到達,就不用重新導航”:

Fallback
├── IsGoalReached
└── NavigateToGoal

IsGoalReached 返回 SUCCESS,這部分樹就成功結束;返回 FAILURE,才啓動導航。

這裏的成功表示“到達目標這項需求已經滿足”,所以一個條件判斷也可以讓整段任務成功,不一定非要執行動作。


4.對照兩種控制規則

對比項 普通 Sequence 普通 Fallback
表達的關係 一組都需要成功的步驟 一組可以依次嘗試的方案
哪種結果讓它繼續下一個子節點 SUCCESS FAILURE
哪種結果讓它提前結束 FAILURE SUCCESS
當前子節點 RUNNING 時 返回 RUNNING,保留當前位置 返回 RUNNING,保留當前位置
所有子節點都執行完 全部成功,返回 SUCCESS 全部失敗,返回 FAILURE

樹結構中的前後順序會影響結果。把子節點重新排序,可能改變先檢查什麼、先執行哪個方案,以及哪些節點會被跳過。

3.8 官方文檔還介紹了 ReactiveSequence、ReactiveFallback 和帶記憶的序列變體。本篇不展開它們;這裏的“保留 RUNNING 位置”只是在説明普通節點的行為,不能套用到所有變體。


5.裝飾節點:處理一個子節點的結果或執行方式

裝飾節點始終只有一個直接子節點,子節點也可以是整棵子樹的入口。

5.1.Inverter、ForceSuccess 與 ForceFailure

這三種節點主要處理子節點的返回結果:

子節點返回 Inverter ForceSuccess ForceFailure
SUCCESS FAILURE SUCCESS FAILURE
FAILURE SUCCESS SUCCESS FAILURE
RUNNING RUNNING RUNNING RUNNING

Inverter 已在上一篇用路徑檢查解釋過。這裏補充兩種強制結果的用途:

Sequence
├── NavigateToGoal
└── ForceSuccess
    └── UploadArrivalLog

如果“導航到達”是主要任務,而“上傳到達日誌”允許失敗,可以只給日誌動作加 ForceSuccess。日誌最終失敗時,這個裝飾節點仍返回成功;日誌還在上傳時,仍返回 RUNNING,不會立刻跳過它。

ForceFailure 則讓已結束的子節點統一報告失敗。例如,在 Fallback 中,執行完一段診斷記錄後仍希望繼續下一個方案,可以用它包裝這段記錄。

強制改變的是向父節點報告的狀態,不會讓失敗的操作實際成功,也不會撤銷已經完成的動作。使用前要確定,這個結果是否符合父節點所代表的任務含義。

5.2.RetryUntilSuccessful:失敗後重新嘗試

3.8 文檔中的 RetryNode,在 XML 中使用的註冊名稱是 RetryUntilSuccessful:

<RetryUntilSuccessful num_attempts="3">
    <NavigateToGoal/>
</RetryUntilSuccessful>

這裏表示總共最多嘗試 3 次,包含第一次:

子節點返回 RetryUntilSuccessful 的處理
SUCCESS 立即返回 SUCCESS,不再重試
FAILURE 記一次失敗;還有次數就重置子節點再試,用完則返回 FAILURE
RUNNING 返回 RUNNING,當前嘗試繼續,不消耗一次失敗次數

例如導航結果依次為:

第 1 次尝试:RUNNING → FAILURE
第 2 次尝试:RUNNING → RUNNING → SUCCESS
整体结果:SUCCESS,第 3 次尝试不会发生

每次完整嘗試可以跨越多個 tick。 num_attempts="3" 限制的是嘗試次數,不是整個裝飾節點只能被 tick 三次。

根據官方 3.8.7 retry_node.cpp, 如果子節點立即失敗,下一次嘗試可能在同一次父節點 tick 中繼續執行。 這個節點沒有自動增加重試間隔;對子節點反覆檢查,不等於等待環境或傳感器數據變化。

5.3.Repeat:成功後繼續重複

Repeat 用於重複完成一個操作:

<Repeat num_cycles="3">
    <CaptureObservation/>
</Repeat>

這裏假設 CaptureObservation 每次執行會採集並保存一份觀測數據。任務是成功完成 3 次採集:

子節點返回 Repeat 的處理
SUCCESS 記一次成功;未到次數則重置子節點繼續,達到次數後返回 SUCCESS
FAILURE 立即停止重複,返回 FAILURE
RUNNING 返回 RUNNING,繼續當前這次操作,不增加已完成次數

它與 Retry 的區別是:Retry 失敗後繼續,Repeat 成功後繼續。 num_cycles="3" 同樣包含第一次執行,不是先做一次再額外做三次。

官方 3.8.7 repeat_node.cpp 也使用循環處理立即結束的子節點。 因此,3 次快速採集可能在一次 tick 內完成; Repeat 不會自動讓它們分佈在三個外部更新週期,也不會自動插入時間間隔。

5.4.Timeout:給本次執行設置時限

Timeout 使用 msec 指定毫秒數,例如:

<Timeout msec="5000">
    <NavigateToGoal/>
</Timeout>

這給本次導航設置 5 秒時限:

子節點情況 Timeout 的處理
未超時就返回 SUCCESS 返回 SUCCESS
未超時就返回 FAILURE 返回 FAILURE
還在運行且未超時 返回 RUNNING
時限到達且子節點仍在運行 中止子節點,隨後返回 FAILURE

在 3.8.7 timeout_node.h 中,計時從裝飾節點本次執行開始,後續 tick 不會反覆把這 5 秒重新計時。 到期處理會調用子節點的中止邏輯;超時失敗結果在後續執行入口中返回給父節點。

導航動作需要在中止處理中取消實際導航請求。 Timeout 不能替代動作的取消實現,也不能把一個長時間堵在 tick() 裏的同步函數自動變成可及時中止的異步任務。


6.組合示例:檢查電量,再有限次嘗試導航

把規則組合成一棵任務樹:

Sequence
├── CheckBattery
├── RetryUntilSuccessful [num_attempts=3]
│   └── Timeout [msec=5000]
│       └── NavigateToGoal
└── ReportArrival

對應的 XML 片段:

<Sequence>
    <CheckBattery/>
    <RetryUntilSuccessful num_attempts="3">
        <Timeout msec="5000">
            <NavigateToGoal/>
        </Timeout>
    </RetryUntilSuccessful>
    <ReportArrival/>
</Sequence>

閲讀時,從外向內看任務約束:

所在層 職責
外層 Sequence 電量檢查通過後才執行導航;導航最終成功後才報告到達
RetryUntilSuccessful 導航嘗試失敗時重新嘗試,最多 3 次
Timeout 限制每次嘗試的運行時間
NavigateToGoal 啓動、檢查並在必要時取消實際導航任務

一種可能的執行過程是:

CheckBattery → SUCCESS
    ↓
第 1 次导航尝试 → RUNNING → 超时并取消 → FAILURE
    ↓ Retry 重置并重新尝试
第 2 次导航尝试 → RUNNING → SUCCESS
    ↓ Retry 返回 SUCCESS
ReportArrival → SUCCESS
    ↓
Sequence → SUCCESS

如果 3 次都失敗,Retry 返回 FAILURE,外層序列也失敗,ReportArrival 不會執行。 若報告動作本身失敗,外層序列同樣會報告失敗,因此報告動作應按需求定義好“成功”的含義。

這裏的普通 Sequence 只在開始時檢查電量,後續不會在導航或重試期間持續重查。 這個例子用於掌握順序、重試和超時的組合;持續監控條件及任務切換,留到響應式行為章節。

6.1.裝飾節點的內外順序會改變含義

RetryUntilSuccessful [3 次]
└── Timeout [5 秒]
    └── NavigateToGoal

這是上面的結構:每次嘗試分別有 5 秒時限,一次超時算一次嘗試失敗,Retry 可以再試。

如果交換位置:

Timeout [5 秒]
└── RetryUntilSuccessful [3 次]
    └── NavigateToGoal

則是所有嘗試共享一個 5 秒時限。 只有導航自身先返回失敗,Retry 才能在剩餘時間內重試; 外層一旦超時,就中止整個重試子樹,不再保證用完 3 次。

兩種結構的節點相同,但任務約束不同。 讀樹時既要看子節點順序,也要看裝飾節點包住了哪一層。


7.當前學習進度

本篇完成了 Introduction to BTs 中 Sequence、Fallback 和裝飾節點的核心規則,並補充了機械人任務常用的重試、重複與超時組合。