跳到正文

Wiki

控制与装饰规则

约 15 分钟阅读

1.控制与装饰规则

对应 BehaviorTree.CPP 4.x 官方文档: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;
        }

        // 本段只解释未使用前置条件的情况,暂省略 SKIPPED 分支
        ++current_index;
    }

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

这段伪代码省略框架状态维护、SKIPPED 处理和非法状态检查,不能直接当作自定义控制节点实现。 ResetChildren() 表示结束本次执行并重置子节点,不能理解为撤销已经产生的业务效果。

这一行为可对照官方 4.10.0 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 次继续备用导航,不会重新尝试前面已经失败的主要目标。

本次选择成功或全部失败结束后,它会重置位置;上层以后重新执行时,再从第一个子节点开始。具体可对照官方 4.10.0 fallback_node.cpp。

3.1.条件节点也能作为优先方案

例如“如果已经到达,就不用重新导航”:

Fallback
├── IsGoalReached
└── NavigateToGoal

IsGoalReached 返回 SUCCESS,这部分树就成功结束;返回 FAILURE,才启动导航。

这里的成功表示“到达目标这项需求已经满足”,所以一个条件判断也可以让整段任务成功,不一定非要执行动作。


4.对照两种控制规则

对比项 普通 Sequence 普通 Fallback
表达的关系 一组都需要成功的步骤 一组可以依次尝试的方案
哪种结果让它继续下一个子节点 SUCCESS FAILURE
哪种结果让它提前结束 FAILURE SUCCESS
当前子节点 RUNNING 时 返回 RUNNING,保留当前位置 返回 RUNNING,保留当前位置
所有子节点都执行完 全部成功,返回 SUCCESS 全部失败,返回 FAILURE

树结构中的前后顺序会影响结果。把子节点重新排序,可能改变先检查什么、先执行哪个方案,以及哪些节点会被跳过。

4.x 官方文档还介绍了 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:失败后重新尝试

4.x 文档中的 RetryNode,在 XML 中使用的注册名称是 RetryUntilSuccessful:

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

这里表示总共最多尝试 3 次,包含第一次:

子节点返回 RetryUntilSuccessful 的处理
SUCCESS 立即返回 SUCCESS,不再重试
FAILURE 记一次失败;还有次数就重置子节点再试,用完则返回 FAILURE
RUNNING 返回 RUNNING,当前尝试继续,不消耗一次失败次数

例如导航结果依次为:

第 1 次尝试:RUNNING → FAILURE(此时不会再消耗一次tickexactlyonce(tickroot),会直接进入下一次尝试)
第 2 次尝试:RUNNING → RUNNING → SUCCESS
整体结果:SUCCESS,第 3 次尝试不会发生

每次完整尝试可以跨越多个 tick。 num_attempts="3" 限制的是尝试次数,不是整个装饰节点只能被 tick 三次。

根据官方 4.10.0 retry_node.cpp, 在正常由 BT::Tree 初始化唤醒机制的工程中,立即失败的子节点可使 Retry 发出唤醒信号并先返回 RUNNING;使用 tickExactlyOnce() 时,下一次尝试需要下一次根 tick。 使用会处理唤醒的 tickOnce() 或 tickWhileRunning() 时,一次外部调用又可能包含多次根 tick。 子节点已处于 RUNNING 后再失败,也可能在同一根 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" 同样包含第一次执行,不是先做一次再额外做三次。

官方 4.10.0 repeat_node.cpp 也使用循环处理立即结束的子节点。 在正常树的唤醒机制下,同步子节点每次完成后,Repeat 可以发出唤醒并返回 RUNNING。使用 tickExactlyOnce() 与使用会处理唤醒的接口,外部看到的调用次数可能不同。num_cycles 只规定完成次数,不规定采集间隔;需要定时采样时,应另行实现时间或数据更新条件。

5.4.Timeout:给本次执行设置时限

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

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

这给本次导航设置 5 秒时限:

子节点情况 Timeout 的处理
未超时就返回 SUCCESS 返回 SUCCESS
未超时就返回 FAILURE 返回 FAILURE
还在运行且未超时 返回 RUNNING
时限到达且子节点仍在运行 中止子节点,随后返回 FAILURE

在 4.10.0 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.在 v4 中组合 SKIPPED 与响应式规则

本篇前面的表格先解释三种业务结果。 加入前置条件后,普通 Sequence 和 Fallback 遇到 SKIPPED 都会继续后面的子节点;全部被跳过时返回 SKIPPED。 Inverter、ForceSuccess、ForceFailure 在 4.10.0 中会保留 SKIPPED,不会将“未执行”一律转换为成功或失败。 Retry 与 Repeat 的子节点被跳过时,也返回跳过。

控制节点 子节点 RUNNING 后,下次从哪里开始 典型用途
Sequence 从正在运行的子节点继续 一次任务的固定步骤
ReactiveSequence 每次从第一个子节点重新检查 前置条件变化时中止后面的持续动作
SequenceWithMemory 运行时继续当前位置,失败和 halt 后仍保留位置;全部成功后从头开始 需要保留已完成步骤的流程
Fallback 从正在运行的方案继续 顺序尝试方案
ReactiveFallback 每次从最高优先级方案重新判断 高优先级需求出现时切换方案

4.10.0 的 SequenceWithMemory::halt() 不清除当前子节点索引,不能假定调用 haltTree 后所有控制节点的业务进度都被清空。需要每次任务从头开始时,优先采用普通 Sequence,或明确重建具有独立生命周期的树。

ReactiveSequence 重新执行的是前面的子节点,不专门识别 ConditionNode;前面放同步动作,也会重复执行它。AsyncSequence、AsyncFallback 则用于在同步步骤之间交还控制权;结合 tickOnce() 的唤醒处理,一次外部调用仍可能推进多步,不能把它们当作后台线程。

持续任务篇会用完整程序对照普通与响应式 Sequence。脚本篇再解释 _while 产生的 SKIPPED 为什么可能让外层 Sequence 继续,不能将它直接当成安全检查失败。


8.当前学习进度

本篇完成了 Introduction to BTs 中 Sequence、Fallback 和装饰节点的核心规则,并补充了机器人任务常用的重试、重复与超时组合。


v4 学习路线 · 上一篇:基本概念 · 下一篇:第一个完整程序