21 Finite State Machines and Behavior Trees for Mission Logic
The previous chapter ended holding a velocity command and a question. The escape vector was built, verified, and bounded — and then the Vector-Based Threat Evasion chapter stopped, deliberately, at the observation that where control goes next — resume the abandoned search leg? return home? — is sequencing, not geometry. This chapter is the sequencing. The mission now owns three genuine competences, each built in its own chapter: it can sweep an area with coverage guarantees (the lawnmower generator of the Path Planning on the Edge chapter), it can flee along a mathematically sound escape vector (the previous chapter’s evasion_command), and — with the deterrence patterns the Harassment Pattern Generation chapter supplies — it will be able to work a detected goose once it finds one. It also still knows how to take off and land, machinery that has been solid since Part II. What it lacks is an arbiter: the piece of software that decides, twenty times a second, which of these things the aircraft is doing right now, and that changes the answer safely when the world changes it first.
The reader has built that arbiter’s ancestor already. The Mission Scripting chapter rebuilt a waypoint mission as a finite state machine — IDLE, TAKEOFF, NAVIGATE, LAND, DONE, with a failsafe check evaluated before everything else — and closed with a promise: add a detection event and a pursuit state, add an evasion state guarded by a proximity test, and “the machine expresses the whole reactive repertoire of Part IV — every addition a new box and its arrows, every existing box untouched.” This chapter cashes that promise, and it is worth being clear about what is new. That earlier machine executed a fixed list; its guards asked “am I there yet?” The machine built here arbitrates among behaviors that watch the world; its guards ask “what matters most right now?” Same formalism, higher stakes — a wrong transition in the list-flying machine wastes a leg, while a wrong transition here can leave the aircraft harassing a goose while a threat closes on it. The chapter therefore spends its effort where mission machines actually fail: on guard conditions that deserve the name, on the race conditions that live at state boundaries, on what the machine does when its sensors go quiet, and — when the flat machine begins to creak under its own arrows — on the Behavior Tree, the architecture that scales past it.
21.1 Learning Objectives
- Specify the five-state mission machine — IDLE/TAKEOFF, SEARCH_GRID, HARASS_TARGET, EVADE_THREAT, LAND/RETURN-TO-HOME — including each state’s single job, the data it owns, and the machinery from earlier chapters it wraps.
- Draw and read a state-transition diagram as a design contract: states, transitions, guard conditions, fail-safe exits, and the override transition that grants one state preemptive priority over every other.
- Write guard conditions that are deterministic and robust — hysteresis on thresholds, persistence on detections, staleness checks on telemetry — and match each technique to the specific failure it prevents.
- Identify race conditions at state boundaries, defuse them with a single-writer, priority-ordered tick, and bound and measure state-transition latency from logged telemetry.
- Explain how a Behavior Tree encodes priority and preemption structurally — through node types and child ordering — and why that yields cleaner modularity than a flat state machine as behaviors multiply.
21.2 Key Vocabulary
- Finite state machine (FSM) — mission logic structured as a set of discrete states with exactly one active at a time, plus transitions between them that fire when their guard conditions become true; everything not drawn as a transition is impossible by construction.
- State transition — the change of active state, taken when a guard becomes true; in a disciplined machine it is the only mechanism by which the vehicle’s behavior changes, which is what makes logged transitions a complete flight narrative.
- Guard condition — the boolean test, computed from observed and estimated data, that gates a transition; a well-written guard is deterministic (same inputs, same answer), reads a consistent snapshot of the world, and never tests elapsed time when it could test state.
- Preemption — the interruption of the active behavior by a higher-priority one, with enough context saved — the stashed state and leg index here — that the interrupted work can later resume rather than restart.
- Race condition — a situation in which two or more triggers become true within the same update interval, so the outcome depends on the order or timing of their evaluation; defused by making that order explicit and singular rather than accidental.
- Hysteresis — the use of separate trigger and release thresholds around a decision boundary, so that a noisy signal crossing the boundary cannot chatter a transition on and off many times a second.
- Transition latency — the elapsed time between a guard’s condition becoming true in the world and the first command of the new state reaching the flight controller; the reflex time of the whole autonomy stack, and a quantity worth logging on every transition.
- Behavior Tree (BT) — a control architecture in which conditions and actions sit at the leaves of a tree, internal nodes sequence or select among their children, and the whole tree is re-evaluated from the root each tick; priority and preemption emerge from child ordering rather than explicit transition arrows.
- Tick — one periodic evaluation cycle of the mission logic — the FSM’s loop iteration or the BT’s root-to-leaf traversal — during which triggers are checked in a fixed order and exactly one behavior acts.
21.3 Five States, Three Chapters of Machinery
The machine has five states, and the first useful observation is that none of them is new work. Each state is a thin wrapper around something a previous chapter already built and tested; the machine’s job is not to do anything but to decide which already-working thing runs. That division of labor — behaviors in their own functions, arbitration in one small loop — is the single most important structural decision in the chapter, because every pathology examined later (spaghetti transitions, races, lock-ups) is a symptom of letting the two mix.
| State | Its one job | Wraps | Enters when | Leaves when |
|---|---|---|---|---|
| IDLE/TAKEOFF | Arm, establish Offboard, climb to mission altitude | Part II’s warm-up and climb sequence | Power-on | Climb complete: \(\lvert z - z_{sp}\rvert < 0.5\) m |
| SEARCH_GRID | Fly lawnmower waypoint \(i\) of \(N\) at constant altitude | The Path Planning on the Edge chapter’s generated list and arrival test | Climb complete, or sweep resumed | Detection confirmed, sweep exhausted, or preempted |
| HARASS_TARGET | Close on the target and fly the deterrence pattern inside a standoff radius | The Harassment Pattern Generation chapter’s pattern generators | Detection confirmed | Target dispersed or track lost, or preempted |
| EVADE_THREAT | Slew onto the evasion vector, recomputed every tick; climb toward the safe altitude | The previous chapter’s evasion_command |
Threat trigger, from any flying state | Threat cleared (resume) or will not clear (go home) |
| LAND/RETURN-TO-HOME | Climb to return altitude, fly home, descend, disarm | Part II’s RTL machinery | Sweep done, battery reserve, mission clock, or break-off | Landed and disarmed |
Two of the state names are compound, and the merging is deliberate. IDLE/TAKEOFF fuses what the Mission Scripting chapter kept separate because, from the mission’s point of view, “getting airborne” is one job with one exit condition; the internal arm-then-climb sequence is the state’s private business. LAND/RETURN-TO-HOME fuses the two ways a flight ends for the same reason: whether the vehicle lands where it is or transits home first is a parameter of one recovery behavior, not two different situations the arbiter must distinguish. A state is a job, not a moment — the test for whether two candidate states should merge is whether the machine would ever need to make a different decision in one than in the other.
The second observation is about data. Each state owns the variables it needs and no others: SEARCH_GRID owns the waypoint list and the index \(i\); HARASS_TARGET owns the target track; EVADE_THREAT owns the stash — the (state, leg index) pair recorded at the moment of preemption — and the safe-altitude setpoint. The Path Planning on the Edge chapter made the case that abandoning a coverage path costs nothing because the path is data, “a list and an index”: store the index, chase the goose, re-enter the list at the stored leg. The stash generalizes that trick to the whole machine. Preemption without a stash is amnesia — the drone that flees a threat and then hovers, mission forgotten, has a working evasion vector and a broken machine.
21.4 The Diagram Is the Design
For a mission machine, the state-transition diagram is not documentation drawn after the fact; it is the design artifact itself, and it earns that status through what it excludes. Every arrow in Figure 21.1 is a claim — “the system may change behavior here, under this condition” — and every absent arrow is a stronger one: this change of behavior cannot happen, no matter what the code’s author forgot. Reviewing a mission means reviewing this drawing. If a transition cannot be drawn without ambiguity — two arrows from one state whose guards can be true simultaneously, with no stated priority — the machine has a bug before a line of code exists.
Walk the drawing the way the mission runs. The solid left-to-right chain is the flight everything is supposed to be: climb, sweep, find, harass, and eventually home. The upper arcs are the loops that make it a search rather than a script — harassment that disperses its target hands control back to the sweep at the stored leg, and a sweep that exhausts its list with nothing found goes home with a clear conscience. So far this is the Mission Scripting chapter’s machine with better-traveled boxes.
The dashed amber arrows are what is genuinely new, and they encode the requirement the previous chapter’s mathematics was built to serve: the threat trigger — closing, soon, and inside the safety radius, the three-part conjunction derived there — must be able to fire from any flying state and take the aircraft instantly. Structurally, this is the same move the Mission Scripting chapter made with its failsafe check: the question is evaluated before the state dispatch, every tick, so no state’s own logic can shadow it, including states added later by someone who never read the original design. One override, drawn once per state, evaluated in one place. When it fires, EVADE_THREAT’s entry action stashes the interrupted state and the sweep index; when the encounter clears — the previous chapter’s exit test, opening and beyond the safety radius with margin — the dashed teal arrow gives back what was taken. And when the encounter refuses to clear, or the battery reaches its floor mid-evasion, the machine does not loiter in a fight it cannot win: it breaks off for home, which is the arrow from EVADE_THREAT to LAND.
One box is conspicuously absent: there is no FAILSAFE state, even though the Mission Scripting chapter’s machine had one. That is because this machine is the middle layer of authority, not the top. The geofence, battery, and link-loss failsafes of the Geofencing and Fail-Safes chapter live in PX4 itself and preempt this machine the way this machine preempts its own states — when they fire, the mission node’s opinion stops mattering entirely. The grey note in the figure records that hierarchy. Mission logic that pretends to be the last line of defense is mission logic that will one day fight its own autopilot; the correct posture is to stay inside the envelope those failsafes police, and to treat their firing as a verdict, not a negotiation.
21.5 Guards That Deserve the Name
The classic warning about mission machines — don’t build a spaghetti machine — is not about the number of states. Five states can be spaghetti; twenty can be clean. The difference is discipline about transitions, and the discipline has three parts.
One writer, one place, every change logged. The state variable is written by exactly one function, called from exactly one place — the tick — and never from a sensor callback, a timer, or a thread that happens to know better. Callbacks write data (positions, detections, timestamps); the tick reads that data and makes every decision. The corollary costs one line and pays for itself the first debugging session: every transition emits a log line — [STATE CHANGE] SEARCH_GRID -> HARASS_TARGET (detection) — so the flight’s entire behavioral history is grep-able. A machine whose transitions are all logged can be debugged from a transcript; a machine that changes state silently, from three call sites, can only be debugged by luck.
Thresholds get hysteresis; events get persistence. Every guard in the diagram compares a noisy estimate against a threshold, and a noisy signal near a single threshold is a transition oscillator. The previous chapter computed miss distances against a 3 m safety radius using position estimates carrying half-metre-scale error; a predicted miss wobbling between 2.9 m and 3.1 m would enter and exit EVADE_THREAT several times a second, each entry re-stashing context and re-slewing the velocity command — chatter that is expensive in logic and violent in flight. The remedy is two thresholds with a gap wider than the noise: trigger the evasion when the predicted miss drops below 3 m, but release it only when the range exceeds the radius plus a margin — say 4 m — and the closing test has gone positive. The same idea, applied to events instead of thresholds, is persistence: a single-frame detection is noise until it survives \(k\) consecutive frames (the SEARCH_GRID exit guard), and a target is not lost until the track has been silent for a dwell time (the 3 s in the HARASS_TARGET return arc), because a detector that misses two frames should not, by itself, end a deterrence run. Persistence trades a bounded, chosen delay for immunity to single-sample lies — and the delay is honest: a \(k\)-frame confirmation at the detector’s frame rate still preempts the sweep within a few hundred milliseconds, immediate on the timescale of a mission, deliberate on the timescale of noise.
Dead data is worse than no data. The requirement that the machine “gracefully handle sensor drops without crashing the aircraft” is a design constraint on guards, and it starts with an observation: a guard evaluated against a stale estimate is not conservative, it is wrong with confidence. The telemetry stream stops; the last position says the drone is mid-leg; the arrival guard keeps failing; the machine happily streams setpoints computed from a world snapshot growing older by the second. The fix is to make freshness itself the first guard of the tick. Every message carries a timestamp; the tick begins by computing the age of the newest estimate, and the response is graded: beyond a short staleness bound (half a second, matching the Offboard dead-man’s-switch timescale from Part II), the machine stops trusting derived guards and simply holds position — freeze, don’t guess; beyond a longer bound (a couple of seconds), it gives up on the mission and goes home on the flight controller’s own navigation, which does not depend on the companion computer’s dying data path. The same logic applies per-stream: a threat whose track has gone stale is treated as unknown, not as gone — EVADE_THREAT holds its climb rather than declaring victory on silence.
21.6 Races, Edges, and the Latency Budget
The advice to simulate the unexpected comes with a specific scenario worth taking literally: inject the threat at the exact moment the drone is turning at the edge of the search grid. It is a well-chosen needle. At that instant three things are true at once — the arrival guard for waypoint \(i\) has just fired, the leg index is mid-increment, and the threat trigger is newly true — and any looseness in how the machine orders its decisions becomes visible. Suppose detections arrive in one callback, positions in another, and both callbacks write self.state directly. The arrival callback advances the index and begins the turn; the threat callback, running between two lines of the other, stashes a state that is half-updated — an index pointing at a leg the machine never actually started. Weeks later, an evasion resumes the sweep one leg early and the coverage claim silently loses a swath. That is a race condition, and its defining cruelty is that it is invisible in every test where events arrive politely one at a time.
The defense is architectural, and it was already stated as the first guard discipline: a single-threaded tick in which all triggers are evaluated, in a fixed priority order, and at most one transition is taken per tick. Priority resolves simultaneity by decree rather than by accident: health first, threat second, the active state’s own exit guard last. When the threat trigger and the arrival guard are both true on the same tick, the threat wins, the arrival is simply re-evaluated on the next tick after the encounter clears, and nothing about the outcome depends on which callback happened to run first — because callbacks no longer decide anything. Edge cases are where state machines love to lock up; the lock-ups almost always trace to two authorities disagreeing about the current state, and a machine with one authority cannot disagree with itself.
Ordering makes the machine correct; the tick rate makes it fast enough, and that claim should be numbers, not adjectives. Define the reaction chain for the SEARCH_GRID → HARASS_TARGET transition: a goose enters the camera’s view; the detector, running at 5 Hz with 80 ms of inference latency (the kind of budget Part III’s edge-inference chapters made routine), reports it; the tick, at 20 Hz, evaluates the guard. The worst case stacks every wait end to end:
\[ \begin{aligned} t_{\text{react}} &\le T_{\text{det}} + t_{\text{inf}} + T_{\text{tick}} \\ &= 200 + 80 + 50 = 330\ \text{ms}, \end{aligned} \]
plus \((k-1)\) further detector periods if the guard demands \(k\)-frame persistence — a deliberate, legible trade between reflex and false alarms. The same arithmetic run on the threat trigger explains why the evasion chapter insisted its assessment was cheap enough to re-run at the estimator’s full rate: the trigger rides the 50 Hz estimator, not the 5 Hz detector, so the override’s worst-case reaction is dominated by the tick alone. Logging turns the bound into a measurement: stamp the moment a guard’s input first satisfied it and the moment the new state’s first command left the node, and the difference — the transition latency — lands in the telemetry log next to position, velocity, and active state, where a histogram of it after each flight verifies that the machine’s reflexes match its design.
One more physical reality belongs to transitions. A state change is a command discontinuity: SEARCH_GRID was streaming a gentle leg-following setpoint, and one tick later EVADE_THREAT wants 5 m/s toward the escape plane. The previous chapter’s warning applies at every boundary, not just the dramatic one — a commanded velocity that steps instantly exists only in simulation, and an aircraft asked to honor the step responds by saturating motors and shedding tracking accuracy exactly when precision matters most. Every state’s output therefore passes through the same slew limiter, the bounded-acceleration ramp built for the evasion command; transitions change the target of the ramp, never the command itself. The machine decides in discrete jumps; the airframe must never feel them.
21.7 The Machine in Code
The whole design compresses into one loop that reads like the diagram — which is the test of both:
from enum import Enum, auto
class MissionState(Enum):
IDLE_TAKEOFF = auto()
SEARCH_GRID = auto()
HARASS_TARGET = auto()
EVADE_THREAT = auto()
LAND_RTH = auto()
FLYING = {MissionState.IDLE_TAKEOFF, MissionState.SEARCH_GRID,
MissionState.HARASS_TARGET, MissionState.EVADE_THREAT}
def transition(self, new_state, reason):
self.log(f"[STATE CHANGE] {self.state.name} -> {new_state.name}"
f" ({reason})")
self.t_guard_true = self.guard_stamp # transition-latency bookkeeping
self.state = new_state
def tick(self): # 20 Hz; the only writer of state
# 1. never act on dead data
age = self.now() - self.last_estimate_stamp
if age > 2.0:
self.transition(MissionState.LAND_RTH, "telemetry stale")
elif age > 0.5:
self.hold_position() # freeze, don't guess
return
# 2. the override: evaluated before any state's own logic
cmd = evasion_command(self.pos, self.vel,
self.threat_pos, self.threat_vel)
if cmd is not None and self.state in FLYING and self.airborne():
if self.state != MissionState.EVADE_THREAT:
self.stash = (self.state, self.leg_index)
self.transition(MissionState.EVADE_THREAT, "threat trigger")
# 3. one state, one job; at most one transition per tick
match self.state:
case MissionState.IDLE_TAKEOFF:
self.fly_takeoff()
if self.at_mission_altitude():
self.transition(MissionState.SEARCH_GRID, "climb complete")
case MissionState.SEARCH_GRID:
self.fly_leg(self.leg_index) # lawnmower list + arrival test
if self.detection_confirmed(): # k consecutive frames
self.transition(MissionState.HARASS_TARGET, "detection")
elif self.sweep_exhausted():
self.transition(MissionState.LAND_RTH, "sweep done")
case MissionState.HARASS_TARGET:
self.fly_pattern() # the Harassment Pattern Generation chapter's business
if self.target_gone(dwell=3.0): # persistence, not one frame
self.transition(MissionState.SEARCH_GRID, "resume sweep")
case MissionState.EVADE_THREAT:
self.slew_toward(cmd or self.last_evasion_cmd)
if self.threat_cleared(): # opening AND far, with margin
prev, self.leg_index = self.stash
if (prev == MissionState.HARASS_TARGET
and self.target_gone(dwell=3.0)):
prev = MissionState.SEARCH_GRID # stale track: sweep
self.transition(prev, "threat cleared")
case MissionState.LAND_RTH:
self.fly_home_and_land()Every principle of the chapter is visible as a line. Freshness is step 1; the override is step 2, before the dispatch, stashing context on the way in; each state performs one action and tests its own exits; every transition passes through one function that logs and stamps it. The resume logic carries the chapter’s one subtle guard: EVADE_THREAT gives back the state it took unless that state’s own preconditions have rotted while the drone was busy fleeing — a harassment target not seen for the whole encounter is a track, not a target, and the honest resume is the sweep, from which a fresh detection will re-enter HARASS_TARGET through the front door. Guards compose; a resume is just a transition, and it gets guarded like one.
21.8 When the Flat Machine Runs Out
Look back at Figure 21.1 and count what the override costs: one dashed arrow per flying state. Now add the global concerns hiding in the grey note — staleness, battery, geofence margin — each of which is also, logically, an arrow from every state. A flat machine with \(S\) states and \(G\) always-on concerns owes \(S \times G\) transitions, and every new state signs up for all \(G\) of them while every new concern must be wired into all \(S\) states. At five states and a handful of concerns the diagram is legible because this chapter cheated deliberately — hoisting the override and the health checks out of the states and into the tick’s preamble. That hoisting is the insight: priorities that apply everywhere want to be structure, not arrows. The Behavior Tree is what that insight looks like when it is taken all the way.
A BT turns the arrangement inside out. There are no states and no transitions; there is a tree whose leaves are conditions (instant tests) and actions (behaviors that may run for many ticks), and whose internal nodes compose them: a sequence (→) ticks its children in order and needs all of them to succeed, a selector (?) ticks its children in order and takes the first that does not fail. Every tick, evaluation starts at the root and flows down. Figure 21.2 rebuilds the whole mission this way: the root selector tries “defend” first (threat closing? then evade), “energy” second (battery at reserve? then return home), and only then the mission sequence — take off, then harass if the goose is in view, else search, then land. Preemption is no longer drawn anywhere, because it is not a feature — it is a consequence. The tick that finds “threat closing?” true never reaches the mission branch at all; whatever action was RUNNING there is halted by the traversal itself. The five-state machine needed one dashed arrow per state to say “the threat outranks you”; the tree says it once, by putting the defend branch first.
The tree’s second virtue is that recovery composes as naturally as preemption. Watch what happens after a successful deterrence run, with no special code: the goose leaves, so “goose in view?” starts returning FAILURE, so the inner selector falls through to SEARCH_GRID — which has kept its leg index, because actions own their data here exactly as states did — and the sweep resumes. The flat machine needed an explicit return arc with a resume guard; the tree gets the same behavior from a condition ceasing to hold. Adding a new always-on concern — say, a rule that icing conditions ground the mission — is one new branch inserted at the right priority, touching nothing else; in the flat machine it would be a visit to every state. This is what “cleaner modularity and preemption handling” means concretely, and it is why BTs displaced giant FSMs first in game AI and then across robotics.
None of which retires the five-state machine. At this mission’s scale the FSM is arguably the better artifact: its diagram is a complete, auditable enumeration of everything the system can do, its stash-and-resume semantics are explicit rather than emergent, and its code is a page. The honest engineering rule is about derivatives, not absolutes: when each new behavior or concern costs a constant amount of drawing and wiring, the flat machine is fine; when the cost starts scaling with the number of existing states — when the arrows begin to multiply — the structure is asking to become a tree. The running example sits, by design, right at that boundary, which is why this chapter builds the machine flat and shows the tree beside it.
The graduate-track version of this mission replaces the flat FSM with a formal Behavior Tree outright, and the formalism rewards a closer look. Each node returns one of three statuses per tick — SUCCESS, FAILURE, or RUNNING — and RUNNING is the ingredient that makes the tree a control structure rather than a decision tree: a long action holds its branch across many ticks while the higher-priority branches keep being re-checked above it. Beyond sequence and selector, production trees add decorators (single-child modifiers: inverters, retry-N-times, rate limiters, timeouts) and a parallel node that ticks several children at once — a monitor-while-acting pattern, e.g. stream the deterrence pattern while a watchdog child tracks the target’s freshness. A subtlety worth knowing before writing one: classic sequence/selector nodes come in reactive and with-memory variants. A reactive sequence re-ticks its earlier children every tick (so “take off” must be idempotent — return SUCCESS instantly once airborne), while a with-memory sequence resumes at the child that was RUNNING (cheaper, but it gives up the continuous re-checking that makes preemption automatic); choosing wrongly produces either wasted work or a tree that cannot be interrupted, and the standard advice is reactive composites at the safety-critical top of the tree, memory where re-checking is genuinely redundant. The ecosystem is mature: BehaviorTree.CPP powers the ROS 2 Nav2 stack’s mission logic, with a graphical editor (Groot) for authoring and live tree inspection, and py_trees (with py_trees_ros) offers the same ideas in Python at prototyping speed. The companion graduate extension — a threat that actively pursues rather than flying straight — slots into this architecture cleanly: the evade action’s internals swap the one-shot escape vector for the potential-field or replanning methods sketched in the previous chapter’s For More, while the tree above it does not change at all. That separation — behaviors evolve, structure holds — is the whole argument.
21.9 Check Your Understanding
- The five-state machine merges IDLE with TAKEOFF and LAND with RETURN-TO-HOME, yet keeps SEARCH_GRID and HARASS_TARGET separate even though both are “fly a pattern and watch the sensors.” Using the chapter’s test for state identity, justify both decisions.
The test: two candidate states deserve to merge when the machine would never make a different decision in one than in the other. IDLE and TAKEOFF share every exit and every override response — nothing preempts differently mid-arm versus mid-climb, and both exist only to reach “airborne at mission altitude” — so they are one job with private phases. The same holds for LAND versus RETURN-TO-HOME: one recovery behavior, parameterized by whether a transit precedes the descent. SEARCH_GRID and HARASS_TARGET fail the test everywhere: different exit guards (detection versus dispersal/track-loss), different data owned (list and index versus target track), different resume semantics after an evasion (the sweep resumes unconditionally; harassment resumes only if the track is fresh). Merging them would force one state to carry flags distinguishing its two personalities — which is the flag-pile the FSM formalism exists to abolish.
- The threat trigger fires when the predicted miss drops below \(R_{safe} = 3\) m, but the release guard demands range greater than 4 m and a positive closing test. The position estimate near the boundary carries roughly 0.5 m of noise. Explain what a single 3 m threshold for both directions would do in flight, and why the 1 m gap is sized correctly relative to the noise.
With one threshold, an encounter hovering near 3 m would cross it many times a second as the noisy estimate wobbles: each crossing enters or exits EVADE_THREAT, re-stashing context, re-aiming the slew, and jerking the commanded velocity — chatter that degrades tracking exactly when margin is thinnest, and floods the log with meaningless transitions. Hysteresis works when the gap between trigger and release comfortably exceeds the noise: 1 m against 0.5 m-scale scatter means a genuine geometry change, not a noise excursion, is required to reverse the decision (roughly a two-sigma separation). The release also demands the closing test go positive because range alone is not safety — a threat can be far and inbound; requiring opening and far makes the release a statement about the encounter, not about one noisy sample.
- A teammate wires the detection callback to write
self.state = HARASS_TARGETdirectly “to save a tick of latency.” Describe the race this creates at the search grid’s corner turn, and what the single-writer tick buys that the 50 ms costs.
At a corner, the tick’s SEARCH_GRID case is mid-flight through advancing the leg index and issuing the turn when the callback, running asynchronously, overwrites the state. The tick’s remaining lines execute in a state they were not written for: the index may be half-updated (stashed or resumed wrong later), the turn setpoint may be issued after the state changed (a search command leaking into harassment), and if the threat trigger also fires that tick, the priority ordering is bypassed entirely — the callback’s write wins over the override that is supposed to outrank it. The single-writer tick makes every decision at one point in time, against one consistent snapshot, in one stated priority order; outcomes stop depending on callback scheduling. The cost is bounded and known — at most one 50 ms tick of added latency — and the reaction budget already carries far larger terms (the detector’s 200 ms period, 80 ms of inference), so the trade buys determinism for a marginal share of the total.
- A mission runs its tick at 20 Hz, its detector at 4 Hz with 120 ms inference latency, and requires 2-frame persistence before leaving SEARCH_GRID. Bound the worst-case time from a goose entering the camera’s view to the first HARASS_TARGET command, and identify which term dominates and what that implies about where engineering effort should go.
Worst case, the goose appears just after a frame is captured: wait one full detector period (250 ms), plus inference (120 ms) for the first report; persistence demands a second consecutive frame, adding one more period plus inference is not needed — the second report arrives one period after the first, so add 250 ms; then up to one tick (50 ms) before the guard is evaluated. Total: \(250 + 120 + 250 + 50 = 670\) ms. The persistence and detector-period terms dominate — 500 of the 670 ms — so raising the tick rate is nearly worthless, while raising detector frame rate (or triggering the guard on the inference timestamp rather than arrival time) attacks the real budget. Reflex time lives in perception, not in the state machine; the machine’s job is merely not to add to it.
- During a harassment run, EVADE_THREAT preempts and the encounter takes 20 seconds to clear. Explain why the machine resumes SEARCH_GRID at the stored leg rather than HARASS_TARGET, and why this is a guard on the resume transition rather than a special case inside EVADE_THREAT’s escape logic.
The stash records where control was, but HARASS_TARGET’s precondition is a fresh target track, and twenty seconds of fleeing is far beyond the track’s dwell time — the goose has moved, possibly dispersed by the commotion itself. Resuming harassment would aim a deterrence pattern at a twenty-second-old coordinate. Resuming the sweep is honest: it re-covers the area, and if the goose is still present, a fresh confirmed detection re-enters HARASS_TARGET through its normal entry guard, with a current track. Placing the check on the resume transition keeps the architecture’s separation intact: EVADE_THREAT’s job is escape geometry, and it should know nothing about targets; the resume is a transition like any other, so it gets a guard like any other. Guards compose — that is the design’s whole economy.
- Express, in one sentence each, how the flat FSM and the Behavior Tree encode the rule “evasion outranks everything,” and then compute the wiring cost in each architecture of adding a new always-on concern (icing conditions ground the mission) to a system that has grown to 12 states or action branches.
The FSM encodes the rule as one dashed override arrow per flying state, all funneling into EVADE_THREAT, evaluated before the state dispatch; the BT encodes it by making the defend branch the root selector’s first child, so every tick checks it before any other branch can run. Adding the icing concern to the flat machine means a new guard reachable from all 12 states — either 12 new arrows or another hoisted preamble check, plus a review of every state’s resume/stash semantics against the new exit: cost proportional to \(S\). In the tree it is one new sequence — condition “icing?” then action “return home” — inserted at the appropriate priority between defend and the mission branch: cost constant, existing branches untouched. That derivative — per-addition cost constant versus proportional to system size — is the practical boundary where the tree earns its keep.
21.10 Try It
Part 1 — build the machine and hunt its races. Extend the Mission Scripting chapter’s state-machine node into the full five-state mission in SITL. Wire SEARCH_GRID to the lawnmower generator’s waypoint list and arrival test; stub HARASS_TARGET as a fixed-radius orbit about the reported target (the Harassment Pattern Generation chapter supplies the real patterns, closing the loop with the target-centering law the Target Tracking and Visual Servoing chapter supplies in between); wire the threat trigger and EVADE_THREAT around evasion_command(), evaluated in the tick’s preamble with the stash-and-resume bookkeeping of this chapter; route every state’s output through one shared slew limiter. Script the world: a simulated detection event partway through leg 3, a threat injected during the harassment orbit. Log every transition as [STATE CHANGE] A -> B (reason) with a timestamp, and log telemetry — position, velocity, active state — at full rate. Fly the complete lifecycle: takeoff, sweep, detection, harassment, threat, evasion, resume or return, land. Then torture it: inject the threat on the exact tick the drone turns at a grid corner (script the injection off the arrival guard’s own log line to guarantee the collision) and confirm the stash resumes the correct leg; kill the detection stream mid-harassment and confirm the dwell guard, not a crash, ends the run; freeze the telemetry stream and confirm the staleness ladder holds, then goes home. Every torture case should end with the machine in a defensible state and a log that explains itself.
Part 2 — measure the reflexes, then grow the tree. From Part 1’s logs, compute transition latency for every transition in the flight — guard-true timestamp to first new-state command — and plot the histogram per transition type; check the threat override’s latency against the tick-period bound and the detection transition against the persistence arithmetic of this chapter, and explain any outlier. Then rebuild the same mission as a Behavior Tree in py_trees: root selector, defend branch, energy branch, mission sequence with the harass-else-search selector inside it, reusing Part 1’s behavior functions as action nodes unchanged. Verify the same scripted flight produces the same trajectory. Finally, run the chapter’s scaling argument as an experiment: add one new always-on concern — a wind limit that grounds the mission when estimated wind exceeds a threshold — to both implementations, and count the sites each change touched. Two sentences: which architecture would you trust a teammate to extend without you reviewing the diff, and what, concretely, made the difference?
Related Lab: See Appendix B for the lab assignment that applies this chapter’s concepts.