BT13:行为树内外的数据传输2

大家好,欢迎大家关注我的知乎专栏慢慢悠悠小马车


本文是继 BT11:行为树内外的数据传输 - 知乎 之后更深入的从源码角度分析BehaviorTree.CPP的Blackboard的数据传输机制,是这一系列文章的精华。

树内即ports之间

类Blackboard(以下简称BB或bb)有3个重要的数据成员:BT中数据的保存依赖于storage_(Entry集合),树间的映射依赖于parent_和internal_to_external_。

// 保存了blackboard的所有entry的信息,包含entry所对应的port的实时值
std::unordered_map<std::string, Entry> storage_;
// 指向父blackboard(父树的blackboard)的指针
// 若不是nullptr,说明该tree被其他树引用了,是subtree
std::weak_ptr<Blackboard> parent_bb_;
// 保存了blackboard中向外(向父blackboard)重映射的port名称
std::unordered_map<std::string, std::string> internal_to_external_;
struct Entry {
  Any value;                 // port的值
  const PortInfo port_info;  // port的其他信息

  Entry(const PortInfo& info) : port_info(info) {}
  Entry(Any&& other_any, const PortInfo& info)
      : value(std::move(other_any)), port_info(info) {}
};

我们可以理解为:node的数据读写通过port,但数据是放在对应着该port的Entry中,树中所有nodes的数据整体放在blackboard的storage_中。这是通过xml中如下语句实现的,EntryName就是storage_中元素的第1项string,“{EntryName}”是1个blackboard pointer。

<NodeName  PortName="{EntryName}" />

上面的语句不涉及树之间的关系,所以对internal_to_external_没影响。

<root main_tree_to_execute = "MainTree" >
  <BehaviorTree ID="MainTree">
    <Sequence>
      <SaySomething message="666" />
      <ThinkWhatToSay text="{the_answer}"/>
      <SaySomething message="{the_answer}" />
    </Sequence>
  </BehaviorTree>
</root>

以BehaviorTree.CPP/examples/t02_basic_ports.cpp中的树为例,如上。当树构建第1个SaySomething节点时,在XMLParser::Pimpl::createNodeFromXML()中,会将pair{message,666}存入该node.config_.input_ports中。其中,config_是NodeConfiguration类型,input_ports是PortsRemapping类型,即unordered_map<string, string>类型。因为“666”是普通的字面字符串,不是blackboard pointer(不带花括号),就与blackboard无关,数据是静态的,node构建后就不会改变,所以这个数据是存在node自身的数据结构中,当获取名为message的port的值时,也不会去bb中查找。

struct NodeConfiguration {
  Blackboard::Ptr blackboard;
  PortsRemapping input_ports;    // 输入port的映射关系
  PortsRemapping output_ports;   // 输出port的映射关系
};

当树构建ThinkWhatToSay节点时,会将pair{text,{the_answer}}存入该node.config_.input_ports中。发现"{the_answer}"是bb pointer,就会把pair{the_answer, Entry}存入所在树的bb的storage_。此时Entry还未赋值,因为树构建时节点并未运行tick(),也就没有对port数据的任何操作,仅仅定义了关系。这样,通过text port读写值,就变成了对bb的名为the_answer的Entry的操作,这就是所谓的树中节点间的数据传输靠共享的blackboard。

当树构建第2个SaySomething节点时,会将pair{message,{the_answer}}存入该node.config_.input_ports中。因为bb的storage_中已经有名为the_answer的Entry了,无需再添加了,所以storage_和internal_to_external_不会有任何改变。 

至此,ThinkWhatToSay通过text port向bb的the_answer entry写入值(setOutput()),而SaySomething通过message port从同一个bb的the_answer entry读取值(getInput()),数据流和逻辑就一目了然了。

getInput()

// 获取名为key的port的值
template <typename T>
inline Result TreeNode::getInput(const std::string& key, T& destination) const {
  auto remap_it = config_.input_ports.find(key);
  // 既然是读值,那么就应该是input port,就应该在config_.input_ports中
  if (remap_it == config_.input_ports.end()) {
    return nonstd::make_unexpected(
        StrCat("getInput() failed because NodeConfiguration::input_ports "
               "does not contain the key: [", key, "]"));
  }
  auto remapped_res = getRemappedKey(key, remap_it->second);
  try {
    if (!remapped_res) {
      // remapped_res空,说明remap_it->second目前只是普通的字面字符串
      destination = convertFromString<T>(remap_it->second);
      return {};
    }
    const auto& remapped_key = remapped_res.value();

    // 既然remapped_key是本bb的一个entry对应的port的名称,那么本bb必须是有效的非空的
    if (!config_.blackboard) {
      return nonstd::make_unexpected(
          "getInput() trying to access a Blackboard(BB) entry, but BB is invalid");
    }
    // 从本bb获取对应的entry的值,即port的值
    const Any* val =
        config_.blackboard->getAny(static_cast<std::string>(remapped_key));
    if (val && val->empty() == false) {
      // 做类型转换
      if (std::is_same<T, std::string>::value == false &&
          val->type() == typeid(std::string)) {
        destination = convertFromString<T>(val->cast<std::string>());
      } else {
        destination = val->cast<T>();
      }
      return {};
    }
    // 没有找到对应port的entry
    return nonstd::make_unexpected(
        StrCat("getInput() failed because it was unable to find the key [",
               key, "] remapped to [", remapped_key, "]"));
  } catch (std::exception& err) {
    return nonstd::make_unexpected(err.what());
  }
}

getInput(key)和setOutput(key)都是先去node的config_.input_ports或config_.output_ports中寻找匹配的key。找到后,得到其匹配的字符串str。若str不是bb pointer(不带{}花括号),那就是字面字符串,就返回这个字符串,进行必要的类型转换。若str是bb pointer,得到bb entry名(去掉{}花括号),最后调用config_.blackboard->getAny(EntryName)读取值,或者调用config_.blackboard->set(EntryName)设置值,所谓“值”,就是storage_.Entry.value。

// 获取名为key的port的值
Any* getAny(const std::string& key) {
  std::unique_lock<std::mutex> lock(mutex_);
  // 如果父blackboard不为空,需要检查是否有向父blackboard的重映射
  if (auto parent = parent_bb_.lock()) {
    auto remapping_it = internal_to_external_.find(key);
    // 找到了,说明存在向父blackboard的重映射
    if (remapping_it != internal_to_external_.end()) {
      // 从父blackboard获取对应port名为 remapping_it->second 的entry的值
      return parent->getAny(remapping_it->second);
    }
  }
  // 到这,说明父bb为空,或者名为key的port不存在重映射,那就从本bb获取值
  auto it = storage_.find(key);
  // 若找到了,就返回本bb中对应port名为key的entry的值
  return (it == storage_.end()) ? nullptr : &(it->second.value);
}

 setOutput()

// 设置名为key的port的值
template <typename T>
inline Result TreeNode::setOutput(const std::string& key, const T& value) {
  if (!config_.blackboard) {
    return nonstd::make_unexpected(
        "setOutput() failed: trying to access a BB entry, but BB is invalid");
  }

  auto remap_it = config_.output_ports.find(key);
  // 既然是写值,那么就应该是output port,就应该在config_.output_ports中
  if (remap_it == config_.output_ports.end()) {
    return nonstd::make_unexpected(
        StrCat("setOutput() failed: NodeConfiguration::output_ports "
               "does not contain the key: [", key, "]"));
  }
  StringView remapped_key = remap_it->second;
  // 这种特殊情况先不管
  if (remapped_key == "=") {
    remapped_key = key;
  }
  // 如果是bb指针,就把{name}改为name,就是去掉花括号
  if (isBlackboardPointer(remapped_key)) {
    remapped_key = stripBlackboardPointer(remapped_key);
  }
  // 既然是写值,key一定对应着本bb的某个entry,从而使其他node可以通过bb共享这个数据
  config_.blackboard->set(static_cast<std::string>(remapped_key), value);
  return {};
}

output_port和input_port的不同在于,output_port一定会对应着bb的一个entry。因为node之所以有output_port,就是想通过它向外传值,让其他node可以获得。所以上面代码中,即便remapped_key不是bb pointer也会是一个EntryName,也要调用config_.blackboard->set(key, value)。

// 设置名为key的port的值
template <typename T>
void set(const std::string& key, const T& value) {
  std::unique_lock<std::mutex> lock(mutex_);
  auto it = storage_.find(key);
  // 如果父blackboard不为空,需要检查是否有向父blackboard的重映射
  if (auto parent = parent_bb_.lock()) {
    auto remapping_it = internal_to_external_.find(key);
    // 找到了,说明存在向父blackboard的重映射
    if (remapping_it != internal_to_external_.end()) {
      const auto& remapped_key = remapping_it->second;
      // 本bb中没有对应port的entry
      if (it == storage_.end()) {
        // 检查父bb中是否有对应的entry
        auto parent_info = parent->portInfo(remapped_key);
        if (parent_info) {
          // 从父bb中获取对应的entry的portinfo,保存到本bb的storage_中
          storage_.insert({key, Entry(*parent_info)});
        } else {
          // 父bb中没有对应port的entry,在本bb的storage_中添加entry,绑定空白的portinfo
          storage_.insert({key, Entry(PortInfo())});
        }
      }
      // 向父bb的对应entry设置值
      parent->set(remapped_key, value);
      return;
    }
  }
  // 到这,说明父bb为空,或者名为key的port不存在重映射
  // 本bb有对应entry,检查数据类型是否匹配,并更新值
  if (it != storage_.end()) {
    ...
  } else {
    // 本bb没有对应entry,就按输入值添加一个到storage_
    storage_.emplace(key, Entry(Any(value), PortInfo()));
  }
  return;
}

SetBlackboard

SetBlackboard是一个比较特殊的节点,因为它可以直接向所在tree的blackboard或父bb写入值。其双向port“output_key”,对应着bb的一个entry。

class SetBlackboard : public SyncActionNode {
 public:
  SetBlackboard(const std::string& name, const NodeConfiguration& config)
      : SyncActionNode(name, config) {
    setRegistrationID("SetBlackboard");
  }

  static PortsList providedPorts() {
    return {
        InputPort("value",
                  "Value represented as a string. convertFromString must be "
                  "implemented."),
        BidirectionalPort("output_key",
                          "Name of the blackboard entry where the value "
                          "should be written")};
  }

 private:
  virtual BT::NodeStatus tick() override {
    std::string key, value;
    if (!getInput("output_key", key)) {
      throw RuntimeError("missing port [output_key]");
    }
    if (!getInput("value", value)) {
      throw RuntimeError("missing port [value]");
    }
    setOutput("output_key", value);
    return NodeStatus::SUCCESS;
  }
};

以 BehaviorTree.CPP/examples/t03_generic_ports.cpp 中的行为树为例,结合上述原理,当树构建SetBlackboard节点时,该node.config_.input_ports和node.config_.output_ports都会添加pair{output_key, OtherGoal},因为output_key是INOUT port。此时,BT的bb的storage_中不会添加对应的entry。直到构建第2个PrintTarget节点时,bb的storage_中才会添加{OtherGoal, Entry}。

为什么代码中tick()是调用setOutput("output_key", value),而不是setOutput(key, value)呢?这里key指通过getInput("output_key", key)获得的值。因为在构建SetBlackboard节点时,output_ports添加的是pair{output_key, xxx},即所有的对应关系、传递线索,是以output_key为准的。对应关系在树构建时就已经确定了,在节点运行tick()时是不会变的,所以tick()中key的值没有发挥作用。

当然,不考虑SubTreePlus(没研究),我认为将SetBlackboard节点的output_key port由INOUT改为仅OUT也是可以的,验证下来也是OK的。

<root main_tree_to_execute = "MainTree" >
  <BehaviorTree ID="MainTree">
    <Sequence>
      <CalculateGoal goal="{GoalPosition}" />
      <PrintTarget target="{GoalPosition}" />
      <SetBlackboard output_key="OtherGoal" value="-1;3" />
      <PrintTarget target="{OtherGoal}" />
    </Sequence>
  </BehaviorTree>
</root>

行为树之间

以BehaviorTree.CPP/examples/t06_subtree_port_remapping.cpp中的行为树为例。

<root main_tree_to_execute = "MainTree">

  <BehaviorTree ID="MainTree">
    <Sequence>
      <SetBlackboard output_key="move_goal" value="1;2;3" />
      <MoveRobot target="move_goal" output="move_result" />
      <SaySomething message="{move_result}"/>
    </Sequence>
  </BehaviorTree>

  <BehaviorTree ID="MoveRobot">
    <Sequence>
      <MoveBase goal="{target}"/>
      <SetBlackboard output_key="output" value="666" />
    </Sequence>
  </BehaviorTree>

</root>

当构建到MoveRobot节点时,识别到它是一个SubTreeNode。当__shared_blackboard=false时,会为该subtree创建一个独立的blackboard(称为子bb),令其parent_bb_成员指针指向父bb(父tree的bb)。并且会在子bb的internal_to_external_中添加重映射{target,move_goal} 和 {output,move_result}。然后递归进入MoveRobot subtree的各节点的构造。

当构建到MoveBase节点时,识别到target是一个bb pointer,但是节点所在树的bb(即子bb)是刚创建的,其storage_容器是空的,此时会调用子bb->setPortInfo()来添加一个Entry。因为子bb的parent_bb_不为空,就要检查子bb的internal_to_external_ 中是否存在target向外的映射。若无,只需在子bb的storage_中添加名为target的Entry;若有,还要在父bb的storage_中添加名为move_goal的Entry。因为subtree的target映射到父树的move_goal。

void Blackboard::setPortInfo(std::string key, const PortInfo& info) {
  std::unique_lock<std::mutex> lock(mutex_);
  // 有父bb,需要检查是否有向父bb的重映射
  if (auto parent = parent_bb_.lock()) {
    auto remapping_it = internal_to_external_.find(key);
    if (remapping_it != internal_to_external_.end()) {
      // 有向父bb的重映射,向父bb传递portinfo
      parent->setPortInfo(remapping_it->second, info);
    }
  }
  // 到这,说明父bb为空,或者名为key的port不存在重映射
  auto it = storage_.find(key);
  if (it == storage_.end()) {
    // 本bb无对应entry,使用输入的portinfo构造Entry并保存入storage_
    storage_.insert({std::move(key), Entry(info)});
  } else {
    // 本bb有对应entry,检查数据类型是否匹配,无需更新portinfo,因为创建一次后就不会改变
    auto old_type = it->second.port_info.type();
    if (old_type && old_type != info.type()) {
      throw LogicError(
          "Blackboard::set() failed: once declared, the type of a port shall "
          "not change. Declared type [",
          BT::demangle(old_type), "] != current type [",
          BT::demangle(info.type()), "]");
    }
  }
}

结尾有个小问题,上面是怎么识别到MoveRobot是SubTreeNode呢?

在树的构建过程中,XMLParser::Pimpl::loadDocImpl()会统计xml语句中标签“BehaviorTree”的个数,并将其名称(树的ID)保存在XMLParser::Pimpl的成员变量tree_roots中。

XMLParser::Pimpl::createNodeFromXML()会检查node ID是否在tree_roots中。若在,就标记为subtree node,即type是SUBTREE。


为了理清这些概念、映射间的关系,我在源代码中加了很多log,通过调试不同结构、节点类型的树,来梳理其异同。由此导致代码和打印内容特别啰嗦,我就不在正文中展示了。大家感兴趣的话,可以关注我的知乎专栏 慢慢悠悠小马车 ,搜索同名文章,下载附件(即打印的log),对于理解本文很有帮助。

  • 4
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
在Navigation2中,行为中黑板传递数据的方式是通过在行为黑板文件中定义变量,并在行为的各个节点中访问这些变量来实现的。具体步骤如下: 1. 在行为黑板文件中定义变量:在行为黑板文件(如`nav2_behavior_tree.xml`)中使用`<param>`标签定义需要传递的参数,例如: ``` <param name="distance_to_goal" value="0.3" type="double"/> ``` 这个例子中定义了一个名为`distance_to_goal`的参数,类型为`double`,初始值为`0.3`。 2. 在行为节点中读取或写入变量:在行为的各个节点中,可以使用`<param>`或`<param_ref>`标签来访问行为黑板中的参数,例如: ``` <param name="distance_to_goal" value="0.3" type="double"/> <condition class="DistanceToGoal" distance="{distance_to_goal}"> ``` 这个例子中,`DistanceToGoal`节点的`distance`参数使用了行为黑板中的`distance_to_goal`参数,节点会读取该参数的值并进行判断。 3. 在行为启动时加载行为黑板:在Navigation2中,行为黑板是在行为启动时加载的。加载行为黑板的代码位于`nav2_bt_navigator`模块的`bt_navigator.cpp`文件中。加载黑板的代码如下: ``` std::string bt_xml_file; node->declare_parameter("bt_xml_filename", bt_xml_file, rclcpp::ParameterValue(std::string("nav2_behavior_tree.xml"))); auto bt_xml = file_utils::read_file(bt_xml_file); blackboard->setUserData("behavior_tree_xml", std::string(bt_xml.data(), bt_xml.size())); ``` 这段代码从ROS参数服务器中读取`bt_xml_filename`参数,加载行为黑板文件并将其写入行为黑板中,节点在运行时可以访问该黑板中的参数。 综上所述,Navigation2中行为中黑板传递数据的方式是通过在行为黑板文件中定义变量,并在行为的各个节点中访问这些变量来实现的。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值