1293.80 Tick 단위 트리 상태 스냅샷

1. 트리 상태 스냅샷의 정의

트리 상태 스냅샷(tree state snapshot)이란, 특정 Tick 시점에서 행동 트리를 구성하는 모든 노드의 상태를 일괄적으로 캡처한 것이다. 스냅샷은 해당 Tick에서의 트리 전체 상태를 정적으로 표현하며, 각 노드의 현재 상태(IDLE, RUNNING, SUCCESS, FAILURE), 블랙보드의 키-값 쌍, Tick 실행 시간 등의 정보를 포함한다(Faconti, 2022).

2. 스냅샷의 구성 요소

struct TreeSnapshot {
    int tick_id;
    std::chrono::steady_clock::time_point timestamp;
    std::chrono::microseconds tick_duration;
    
    struct NodeSnapshot {
        std::string name;
        std::string type;
        uint16_t uid;
        BT::NodeStatus status;
        int depth;
        uint16_t parent_uid;
    };
    
    std::vector<NodeSnapshot> nodes;
    std::map<std::string, std::string> blackboard_entries;
};

3. 스냅샷 캡처 시점

스냅샷은 Tick 실행 완료 직후에 캡처한다. Tick 완료 후의 상태는 해당 Tick의 최종 결과를 반영한다.

void tickLoop() {
    while (rclcpp::ok()) {
        executor_.spin_some();
        
        auto start = std::chrono::steady_clock::now();
        tree_.tickOnce();
        auto duration = std::chrono::steady_clock::now() - start;
        
        // Tick 완료 직후 스냅샷 캡처
        auto snapshot = captureSnapshot(tick_count_, duration);
        snapshot_buffer_.push_back(snapshot);
        
        tick_count_++;
        rate.sleep();
    }
}

4. 스냅샷 캡처 구현

TreeSnapshot captureSnapshot(int tick_id, 
                             std::chrono::microseconds duration) {
    TreeSnapshot snapshot;
    snapshot.tick_id = tick_id;
    snapshot.timestamp = std::chrono::steady_clock::now();
    snapshot.tick_duration = duration;
    
    // 모든 노드의 상태 캡처
    tree_.applyVisitor([&](const%20BT::TreeNode*%20node) {
        TreeSnapshot::NodeSnapshot ns;
        ns.name = node->name();
        ns.type = node->registrationName();
        ns.uid = node->UID();
        ns.status = node->status();
        snapshot.nodes.push_back(ns);
    });
    
    // 블랙보드 상태 캡처
    auto blackboard = tree_.rootBlackboard();
    for (const auto& key : blackboard->getKeys()) {
        auto entry = blackboard->getEntry(key);
        if (entry) {
            snapshot.blackboard_entries[std::string(key)] = 
                entry->value.toStr();
        }
    }
    
    return snapshot;
}

5. 스냅샷의 비교 분석

5.1 연속 Tick 간 차이 검출

연속된 두 스냅샷을 비교하면, 해당 Tick에서 발생한 상태 변화를 식별할 수 있다.

std::vector<StatusChange> diffSnapshots(
    const TreeSnapshot& prev, const TreeSnapshot& curr) {
    std::vector<StatusChange> changes;
    
    for (size_t i = 0; i < curr.nodes.size(); ++i) {
        if (curr.nodes[i].status != prev.nodes[i].status) {
            changes.push_back({
                curr.nodes[i].name,
                prev.nodes[i].status,
                curr.nodes[i].status
            });
        }
    }
    return changes;
}

5.2 스냅샷 비교 출력 예시

Tick #141 → #142 변화:
  IsBatteryAbove:   SUCCESS → SUCCESS  (변화 없음)
  IsLocalized:      SUCCESS → FAILURE  ← 변화
  NavigateToGoal:   RUNNING → IDLE     ← Halt 발생
  Sequence:         RUNNING → FAILURE  ← 변화

6. 스냅샷 저장소

6.1 순환 버퍼

고정 크기의 순환 버퍼에 최근 N개의 스냅샷을 유지한다. 메모리 사용량이 일정하며, 과거 일정 기간의 상태를 역추적할 수 있다.

class SnapshotBuffer {
public:
    SnapshotBuffer(size_t capacity) : capacity_(capacity) {}
    
    void push(const TreeSnapshot& snapshot) {
        if (buffer_.size() >= capacity_) {
            buffer_.pop_front();
        }
        buffer_.push_back(snapshot);
    }
    
    const TreeSnapshot& getByTickId(int tick_id) const {
        for (const auto& s : buffer_) {
            if (s.tick_id == tick_id) return s;
        }
        throw std::out_of_range("Tick not found in buffer");
    }

private:
    std::deque<TreeSnapshot> buffer_;
    size_t capacity_;
};

6.2 조건부 저장

모든 Tick의 스냅샷을 저장하면 메모리 소비가 크므로, 상태 변화가 발생한 Tick이나 오버런이 검출된 Tick의 스냅샷만 선택적으로 저장한다.

if (hasStateChange(prev_snapshot, curr_snapshot) || is_overrun) {
    snapshot_store_.push(curr_snapshot);
}

7. 스냅샷과 디버깅

Tick 단위 스냅샷은 오류 발생 시점의 트리 상태를 정확히 복원하여 디버깅에 활용한다. 오류 발생 직전의 스냅샷 시퀀스를 분석하면 오류에 이르는 상태 전이 경로를 추적할 수 있다.

오류 분석:
  Tick #138: 정상 (Navigate RUNNING, Battery SUCCESS)
  Tick #139: 정상 (Navigate RUNNING, Battery SUCCESS)
  Tick #140: Battery FAILURE → Navigate Halt → 안전 모드 진입
  ← Tick #139의 블랙보드: battery_level = 0.22
  ← Tick #140의 블랙보드: battery_level = 0.18
  결론: 배터리 값이 임계값(0.20) 이하로 하락하여 정상 동작

8. 스냅샷의 직렬화

스냅샷을 파일로 저장하여 사후 분석에 활용할 수 있다. JSON, MessagePack, Protocol Buffers 등의 직렬화 형식을 사용한다.

void saveSnapshotToJson(const TreeSnapshot& snapshot, 
                        const std::string& filename) {
    nlohmann::json j;
    j["tick_id"] = snapshot.tick_id;
    j["duration_us"] = snapshot.tick_duration.count();
    
    for (const auto& node : snapshot.nodes) {
        j["nodes"].push_back({
            {"name", node.name},
            {"type", node.type},
            {"status", BT::toStr(node.status)}
        });
    }
    
    j["blackboard"] = snapshot.blackboard_entries;
    
    std::ofstream file(filename);
    file << j.dump(2);
}

9. 스냅샷의 메모리 비용

스냅샷의 메모리 비용은 트리의 노드 수와 블랙보드 엔트리 수에 비례한다.

M_{snapshot} = N_{nodes} \times M_{node} + N_{bb\_entries} \times M_{entry}

100개 노드, 50개 블랙보드 엔트리를 가진 트리의 경우, 단일 스냅샷은 수 킬로바이트 수준이다. 순환 버퍼에 1000개의 스냅샷을 유지하면 수 메가바이트의 메모리가 필요하다.


참고 문헌

  • Colledanchise, M., & Ogren, P. (2018). Behavior Trees in Robotics and AI: An Introduction. CRC Press.
  • Faconti, D. (2022). BehaviorTree.CPP documentation and API reference. https://www.behaviortree.dev/