Statecharts#
A cramph statechart composes behaviour out of small, reactive nodes. It provides a structured way to start, pause, end and reset nodes depending on what other nodes observe, which makes sequencing, error handling and reactivity part of the structure instead of the control flow of a script.
The Problem#
Behaviour written as a flat script faces several challenges:
Complex Sequencing: Coordinating many steps that have to run one after another, or at the same time, becomes hard to manage as the number of steps increases.
Error Handling: Every step can fail, and handling each contingency in a flat script often leads to “spaghetti code.”
Reactivity: Steps often have to react to observations, such as “keep going until a condition holds” or “stop as soon as something happens.”
How Statecharts Solve It#
A statechart is a graph of nodes. Every node runs its own small state machine, and the edges of the graph are the conditions under which one node’s state machine reacts to the state of other nodes. All nodes are updated together once per tick.
Node types#
Node (
StatechartNode): observes something and may act while it runs. A monitor, for example a counter waiting for a number of ticks, is a plainStatechartNode; there is no monitor class (see Who ends a node). Packages built on cramph add their own kinds of nodes, for example giskardpy’s motion tasks.CompositeNode: A node that contains other nodes and wires their conditions. Composite nodes encapsulate reusable, parameterized patterns, such as the templates that run steps in order or retry a failed step.
Terminal node: A node that ends the whole statechart. EndStatechart ends it successfully once it runs and observes True, CancelStatechart ends it by raising its exception at the end of the tick it starts in.
Every node carries two pieces of state:
a life cycle state, which says where the node is in its own execution, and
an observation, which says whether what the node observes is currently True, False or Unknown.
Life cycle state#
A node’s life cycle has six states:
Life cycle state |
Meaning |
|---|---|
NOT_STARTED |
the node has not started yet, or was reset |
RUNNING |
the node is active |
PAUSED |
the node was running and is suspended until it resumes |
SUCCEEDED |
the node ended successfully |
FAILED |
the node ended because it could not continue |
INTERRUPTED |
the node was ended from outside |
Only RUNNING is active: its observation is recomputed and its on_tick callback is
called. SUCCEEDED, FAILED and INTERRUPTED are final states,
and together they are the node’s outcome. A node only leaves a final state when it is
reset.
Observation state#
The observation is a trinary value: True, False or Unknown. It states, for example, whether a node has reached its goal or whether a monitor’s condition holds. Unknown is needed because a node often cannot give an answer, for example before it has ever run.
Only a running node observes. The observation is recomputed every tick and may change in both directions:
Life cycle state |
Observation |
|---|---|
NOT_STARTED |
Unknown |
RUNNING |
recomputed every tick |
PAUSED |
frozen at its last value, because the node resumes later |
SUCCEEDED, FAILED, INTERRUPTED |
Unknown, because the node never observes again |
The table describes a node that was already in that state when the tick started. A node that changes state during a tick follows the state it started the control cycle in until the next one:
A node that pauses during a tick is still recomputed for the rest of it.
A node that ends or is reset during a tick keeps the observation it stopped on.
A node’s observation expression may read observation_variable, last_observation,
life_cycle_variable and every predicate of other
nodes, and combines them with trinary_logic_and, trinary_logic_or and trinary_logic_not.
An observation that chooses between cases uses trinary_if_cases, which selects a case only
while its guard is True.
Life cycle transitions#
flowchart LR
entry(( )) --> NS([NOT_STARTED])
NS -- start --> R
NS -- "start while pause is True" --> P
subgraph active ["active"]
direction TB
R([RUNNING]) -- "pause is True" --> P([PAUSED])
P -- "pause is False" --> R
end
subgraph ended ["final, left only by reset"]
direction TB
S([SUCCEEDED])
F([FAILED])
I([INTERRUPTED])
end
active -- success --> S
active -- fail --> F
active -- interrupt --> I
ended -- reset --> NS
active -- reset --> NS
classDef notStarted fill:#9CA3AF,stroke:#6B7280,color:#111111
classDef running fill:#3B82F6,stroke:#1D4ED8,color:#FFFFFF
classDef paused fill:#EAB308,stroke:#A16207,color:#111111
classDef succeeded fill:#28A745,stroke:#15803D,color:#FFFFFF
classDef failed fill:#EF4444,stroke:#B91C1C,color:#FFFFFF
classDef interrupted fill:#F97316,stroke:#C2410C,color:#111111
class NS notStarted
class R running
class P paused
class S succeeded
class F failed
class I interrupted
Each transition is driven by one condition of the node. Conditions are binary: a transition happens when its condition is True, and a paused node resumes as soon as its pause condition is False again.
Transition |
Condition attribute |
Default |
From |
To |
|---|---|---|---|---|
start |
|
True |
NOT_STARTED |
RUNNING, or PAUSED while the pause condition is True |
pause |
|
False |
RUNNING (True), PAUSED (False) |
PAUSED, RUNNING |
success |
|
False |
RUNNING, PAUSED |
SUCCEEDED |
fail |
|
False |
RUNNING, PAUSED |
FAILED |
interrupt |
|
False |
RUNNING, PAUSED |
INTERRUPTED |
reset |
|
False |
any state |
NOT_STARTED |
With the defaults a node starts right away and runs until the statechart ends. A node whose pause condition is already True when it starts goes straight to PAUSED, so it never runs before its pause condition lets it.
The table lists the conditions written on a node. For succeeding and failing, a node class can bring a condition of its own on top, its inherent condition, for example to succeed once the node observes True (see Who ends a node). A node succeeds or fails once either the written or the inherent condition holds; “success condition” and “fail condition” below mean that combination.
The condition that ends a node decides its outcome. What the node observes at that moment has a say in it only where one of these conditions reads the observation:
SUCCEEDED: the node’s success condition held.
FAILED: the node’s fail condition held: it cannot continue.
INTERRUPTED: the node’s interrupt condition held, or one of its ancestors ended. Neither is a judgement of the node itself.
When several conditions hold at once#
A node takes at most one transition triggered by its own conditions per tick. Transitions its parent forces on it always happen: a node is reset while its parent has not started, interrupted once its parent has ended and paused while its parent is paused, and it only starts or unpauses while its parent is running. If several transitions are possible at the same time, the first matching one in this order wins:
the node’s own reset condition, or its parent has not started
the node’s own success condition
the node’s own fail condition
the node’s own interrupt condition, or its parent has ended
the node’s own pause condition, or its parent is paused
the node’s own start condition, while its parent is running; the node starts paused if its own pause condition holds as well
The ladder a RUNNING node goes through in every pass of a tick (see One tick):
flowchart TD
A{"own reset is True, or<br/>the parent has not started?"}
A -- yes --> NS([NOT_STARTED])
A -- no --> B{"own success<br/>is True?"}
B -- yes --> S([SUCCEEDED])
B -- no --> C{"own fail<br/>is True?"}
C -- yes --> F([FAILED])
C -- no --> D{"own interrupt is True, or<br/>the parent has ended?"}
D -- yes --> I([INTERRUPTED])
D -- no --> E{"own pause is True, or<br/>the parent is paused?"}
E -- yes --> P([PAUSED])
E -- no --> R([RUNNING])
classDef notStarted fill:#9CA3AF,stroke:#6B7280,color:#111111
classDef running fill:#3B82F6,stroke:#1D4ED8,color:#FFFFFF
classDef paused fill:#EAB308,stroke:#A16207,color:#111111
classDef succeeded fill:#28A745,stroke:#15803D,color:#FFFFFF
classDef failed fill:#EF4444,stroke:#B91C1C,color:#FFFFFF
classDef interrupted fill:#F97316,stroke:#C2410C,color:#111111
class NS notStarted
class R running
class P paused
class S succeeded
class F failed
class I interrupted
Reading other nodes in conditions#
Conditions are symbolic expressions over the state of other nodes, combined with
logic_and, logic_or and logic_not. Since an observation may be Unknown, a condition
cannot read observation_variable or last_observation directly
(UnsupportedConditionVariableError). Instead, it reads predicates, which map a node’s
observation or life cycle state to True or False.
Observation predicates ask what a node observes:
Predicate
True while
observes_truethe node observes True
observes_falsethe node observes False
last_observed_truethe observation the node took most recently is True
A node observing Unknown makes all three False, including a node that has not observed anything yet.
observes_trueandobserves_falseturn False on the tick after the node ended.last_observed_truekeeps the value the node observed when it ended, however it ended, until the tick after a reset. Read it to ask what a node saw, for example whether a monitor that ended itself had fired. It says nothing about how the node ended: a node interrupted while observing True still answers True, so read a life cycle predicate to learn its outcome.logic_not(monitor.observes_true)is True while the monitor observes False or Unknown, whereasmonitor.observes_falseis True only while it observes False.Life cycle predicates such as
node.is_succeededanswer questions about the life cycle state of the node:Predicate
NOT_STARTED
RUNNING
PAUSED
SUCCEEDED
FAILED
INTERRUPTED
is_not_startedTrue
False
False
False
False
False
is_runningFalse
True
False
False
False
False
is_pausedFalse
False
True
False
False
False
is_terminatedFalse
False
False
True
True
True
is_succeededFalse
False
False
True
False
False
is_failedFalse
False
False
False
True
False
is_interruptedFalse
False
False
False
False
True
logic_not(node.is_succeeded)is also True before the node has ended. To wait for a node to end any way but by succeeding, readnode.is_failed_or_interrupted, a shorthand forlogic_or(node.is_failed, node.is_interrupted).
An observation may change in both directions, while an outcome stays fixed until a reset. A
condition that has to keep its answer after the node it reads has ended must therefore read
last_observed_true, or the outcome through a life cycle predicate, rather than
observes_true:
flowchart LR
c1["RUNNING<br/>observes False<br/><b>last_observed_true: False</b>"]
c2["RUNNING<br/>observes True<br/><b>last_observed_true: True</b>"]
c3["RUNNING<br/>observes False<br/><b>last_observed_true: False</b>"]
c4["RUNNING<br/>observes True<br/><b>last_observed_true: True</b>"]
c5["SUCCEEDED<br/>observes Unknown<br/><b>last_observed_true: True</b>"]
c6["SUCCEEDED<br/>observes Unknown<br/><b>last_observed_true: True</b>"]
c1 --> c2 --> c3 --> c4 -- "success condition is True" --> c5 --> c6
classDef running fill:#3B82F6,stroke:#1D4ED8,color:#FFFFFF
classDef succeeded fill:#28A745,stroke:#15803D,color:#FFFFFF
class c1,c2,c3,c4 running
class c5,c6 succeeded
Conditions are checked when they are set and when the statechart is compiled:
A condition must be a symbolic expression (
InputNotExpressionError).A condition may read its own node, a sibling (a node with the same parent) or a direct child; anything further away raises
ConditionScopeErrorwhen the statechart is compiled.A start condition may not read its own node (
SelfInStartConditionError).No condition may read an EndStatechart or CancelStatechart node, because nothing happens after one of them (
TerminalNodeInConditionError).Only predicates of nodes may appear in a condition, combined with
logic_and,logic_orandlogic_not; anything else, such as the trinary operators or the constant Unknown, raisesCannotConvertToStringError.
One tick#
A tick settles the whole statechart. It calls on_tick
once for every node that was running when the tick started, then repeats one
pass over all nodes until no life cycle state, observation or last observation changes
any more:
sequenceDiagram
participant C as Executor
participant T as on_tick
participant P as Pass
participant L as Life cycle callbacks
C->>T: once per node running at the start of the tick
T->>P: repeat until nothing changes
Note over P: every node observes, reading the states of the previous pass,<br/>then takes over its last observation,<br/>then takes its next transition, reading the observations of this pass<br/>and the life cycle state its parent reaches in this pass.
P->>L: once, in the order the changes happened
Note over L: on_start, on_pause, on_unpause, on_end and on_reset.
L->>C: done
Note over C: the tick is recorded in the history,<br/>then a CancelStatechart that started raises its exception,<br/>and an EndStatechart observing True ends the statechart.
Every pass reads the states the previous pass left, so how deeply nodes are nested does not
change when they react to each other: a node waiting for another node’s outcome, for example
with start_condition = previous.is_succeeded, starts on the tick in which that
outcome is reached, even if previous is a template several levels deep.
A few rules keep a tick predictable:
A node started during a tick first observes on the next one. A node that was paused when the tick started keeps its observation.
A node takes at most one transition triggered by its own conditions per tick, so it never ends, resets and starts again within one. Two nodes that each pause while the other runs therefore take turns once per tick instead of looping.
Life cycle callbacks run once after the passes, so what they change, for example a value an observation reads, is seen by observations from the next tick on. A node its parents force through several states within one tick gets each matching callback once, in order, for example
on_pause,on_endandon_reset. A node that starts paused getson_startand thenon_pause.A
CancelStatechartthat starts during a tick raises its exception only after all life cycle callbacks of that tick ran and the tick was recorded, even if it was interrupted again within the same tick.Observations that read each other can contradict each other, for example two nodes each observing True while the other does not. Such a tick never settles: a pass brings the statechart back to a state it already had in this tick, and the tick raises
TickDoesNotSettleErrornaming the nodes still changing.A tick may take a bounded number of passes, so it always fits into the control loop:
Statechart.pass_limit, plusStatechart.passes_per_nesting_levelfor every nesting level of the statechart, because an outcome moves up one level per pass. A tick that needs more passes raisesTickDoesNotSettleErroras well.
Who ends a node#
A node decides when it cannot continue. Its owner decides when it is done.
Failing has no side effect, so a node may declare it itself through its fail condition. Succeeding can have one: a node that is ended stops acting, and what it had just achieved can then be undone, for example by another node that is still running. That is why such a node never ends itself on reaching its goal; whoever runs it, its owner, writes its success and interrupt conditions.
Every node class therefore says how it succeeds, by inheriting one of two classes. They
implement the abstract inherent_success_condition; constructing a node of a class that
leaves it abstract raises a TypeError.
EndedByOwner: the node’s observation says whether it has reached its goal, but only its owner ends it. Monitors (CountSeconds,CountTicks,Print, …),Paralleland the monitored composite nodes are examples.SucceedsOnObservingTrue: ending the node undoes nothing it did, so it ends itself: it succeeds once it observes True. Examples are the ordering templates,FunctionCall,MoveBranchandCompositeNodeChoosingItsChild.
Independently, a node class can inherit FailsOnObservingFalse: observing False
means it can no longer reach its goal, so it fails once it observes False. The ordering
templates and FunctionCall do.
What a class brings is its inherent success and fail condition
(inherent_success_condition, inherent_fail_condition). The conditions written on a node
(success_condition, fail_condition) stay as they were written; a node succeeds or fails
once either the written or the inherent condition holds.
A node class with another reason to fail on its own overrides inherent_fail_condition
instead of writing its fail condition: Parallel fails once too few of its nodes are left to
reach minimum_success, and every monitored composite statechart node fails once its
monitored node ended without succeeding.
A node its owner ends becomes a step by being handed to an ordering template, which is then its owner and ends it once it observes True.
flowchart LR
subgraph owner ["EndedByOwner"]
direction TB
monitor(["monitors,<br/>counters"])
parallel(["Parallel"])
monitored(["PausedWhileTrue, PausedUntilTrue,<br/>StoppedWhenTrue, CancelledWhenTrue"])
end
subgraph itself ["SucceedsOnObservingTrue"]
direction TB
ordering(["Sequence, TryInOrder,<br/>TryAll, RepeatUntil"])
steps(["FunctionCall, MoveBranch,<br/>CompositeNodeChoosingItsChild"])
end
owner -- "ended at its goal by" --> ordering
itself -- "usable as a step of" --> ordering
The ordering templates (Sequence, TryInOrder, TryAll, RepeatUntil) decide when their
children start and end, so they check every child they are given:
A child whose owner decides its success gets its success condition from the template: it succeeds once it observes True, unless it fails on the same tick. Such a child fails only if it fails on its own, as
Paralleland the monitored composite statechart nodes can.TryInOrder(for every alternative but the last) andRepeatUntilonly move on once a child failed. They reject a child that cannot fail with aChildCannotFailErrorat compile time. A child cannot fail if it has neither a written nor an inherent fail condition.A child whose start, pause, success, interrupt or reset condition was already set is rejected with a
ChildTransitionAlreadyWiredError, because those are the template’s to decide. The fail condition is exempt, since a node declares its own failure.
Templates#
The templates in cramph.composites are composite nodes that wire
their children for common patterns. In the diagrams below, an arrow from node A to node B
labelled transition: expression means that B’s condition for that transition reads A.
Sequence, Parallel, TryInOrder and TryAll are constructs of the plan language: each
runs the list of nodes it is handed. Until the statechart is compiled, that list can still
be changed with add_node, insert_before, insert_after and replace, which rewire the
children’s conditions accordingly; replace removes the replaced node and everything below
it from the statechart. Wherever such a construct is handed a node, it also takes an
underspecified a(...) statement, which it runs through a
StatementNode.
Sequence#
Runs its nodes one after another. Each step starts once the previous one has succeeded.
It observes True once the last step succeeded.
It observes False as soon as any step ended without succeeding.
A CancelStatechart may be a step as well, which ends the statechart once the steps before
it succeeded.
flowchart LR
subgraph sequence ["Sequence"]
direction LR
s1(["step 1"]) -- "start: is_succeeded" --> s2(["step 2"])
s2 -- "start: is_succeeded" --> s3(["step 3"])
end
s3 -. "succeeded" .-> S([Sequence SUCCEEDED])
sequence -. "any step ended without succeeding" .-> F([Sequence FAILED])
classDef succeeded fill:#28A745,stroke:#15803D,color:#FFFFFF
classDef failed fill:#EF4444,stroke:#B91C1C,color:#FFFFFF
class S succeeded
class F failed
Parallel#
Runs all of its nodes at the same time and observes True while at least
minimum_success of them (all of them by default) have last_observed_true True on the same
tick. A node that ended without succeeding stops counting, because the reading it
kept says where it was cut off rather than where it is, and Parallel fails once too few
nodes are left to reach minimum_success at all.
Parallel never ends any of its nodes: ending a node that reached its goal would let a node
that is still running undo what it achieved. For the same reason it is a
node whose owner decides its success and never succeeds on its own; it only fails on its own,
once too few nodes are left. Hand it to an ordering template to get a step that finishes
once all nodes are at their goals together.
flowchart LR
subgraph parallel ["Parallel"]
direction TB
n1(["node 1"])
n2(["node 2"])
n3(["node 3"])
end
count{"number of nodes with<br/>last_observed_true ≥<br/>minimum_success?"}
n1 --> count
n2 --> count
n3 --> count
count -- yes --> T["Parallel observes True"]
count -- no --> Fa["Parallel observes False"]
TryInOrder#
Tries its nodes one after another and stops at the first one that succeeds. Each
alternative starts once the previous one ended without succeeding.
It observes True as soon as an alternative succeeded.
It observes False once every alternative ended without succeeding.
Each alternative decides for itself when to give up, through its fail condition. An alternative that cannot fail would keep the later alternatives from ever starting, so it is rejected anywhere but last.
flowchart LR
subgraph try_in_order ["TryInOrder"]
direction LR
a1(["alternative 1"]) -- "start: is_failed_or_interrupted" --> a2(["alternative 2"])
a2 -- "start: is_failed_or_interrupted" --> a3(["alternative 3"])
end
try_in_order -. "any alternative succeeded" .-> S([TryInOrder SUCCEEDED])
try_in_order -. "every alternative ended<br/>without succeeding" .-> F([TryInOrder FAILED])
classDef succeeded fill:#28A745,stroke:#15803D,color:#FFFFFF
classDef failed fill:#EF4444,stroke:#B91C1C,color:#FFFFFF
class S succeeded
class F failed
TryAll#
Runs all of its nodes at the same time and takes the first one that works.
It observes True as soon as any alternative succeeded.
TryAllthen succeeds and interrupts the alternatives that are still running.It observes False once every alternative ended without succeeding.
RepeatUntil#
RepeatUntil runs a task and resets it whenever it fails, until either the task succeeds or
stop_retry_monitor calls the retrying off.
What counts as a failed try is stated on the task, through its fail condition. A task that cannot fail would never be retried, so it is rejected.
A failed try is reset on the next tick, as long as the stop monitor has not observed True. Resetting a goal resets everything below it, so a composite task starts over as a whole.
Once the stop monitor’s
last_observed_trueis True, the task is interrupted and not started again. A stop monitor that has not observed anything yet does not hold the task back.It observes True once the task succeeded, and False once the stop monitor observed True.
If an
exceptionis given, aCancelStatechartraises it as soon as the stop monitor’slast_observed_trueis True.
flowchart LR
subgraph repeat ["RepeatUntil"]
direction LR
stop(["stop_retry_monitor"])
task(["task"])
cancel(["CancelStatechart<br/>(only with an exception)"])
end
stop -- "start: not last_observed_true<br/>interrupt: last_observed_true" --> task
task -- "reset: is_failed and not<br/>last_observed_true of the stop monitor" --> task
stop -- "start: last_observed_true" --> cancel
Monitored composite statechart nodes#
These run a monitored_node next to a monitor, and let the monitor control the
monitored node’s life cycle. Their owner decides their success, and their observation is the
monitored node’s last_observation. Once the monitored node ended without succeeding, it can no
longer arrive, so the template fails.
Template |
Effect on the monitored node |
|---|---|
|
paused while the monitor observes True |
|
paused while the monitor does not observe True, Unknown included |
|
interrupted once the monitor’s |
|
like |
flowchart LR
monitor(["monitor"])
node(["monitored node"])
monitor -- "PausedWhileTrue → pause: observes_true<br/>PausedUntilTrue → pause: not observes_true<br/>StoppedWhenTrue → interrupt: last_observed_true" --> node
StoppedWhenTrue observes True while the monitored node observes True or once it succeeded,
False once the monitor stopped it, and Unknown otherwise. Stopping the monitored node
interrupts it, so the template fails like any monitored composite statechart node whose
monitored node ended without succeeding.
CancelledWhenTrue takes an exception, which its CancelStatechart raises once the
monitor’s last_observed_true is True.
Ending the statechart#
The statechart ends once an EndStatechart node is running and observes True, which it does
as soon as it runs. A CancelStatechart node ends the statechart by raising its exception at
the end of the tick it starts in.
An EndStatechart must be added at the top level of the statechart; adding it to a composite
node raises EndInCompositeNodeError. A CancelStatechart may be placed anywhere.
Both are usually created with factory methods that set their start condition:
Factory |
Starts once |
|---|---|
|
|
|
|
|
every node observes True or has succeeded |
|
any node observes True or has succeeded |
|
|
The CancelStatechart factories also take the exception to raise. when_true and
when_failed default to one naming node; when_all_true and when_any_true require it.
Once execute() or tick_until_end() returns or raises, because the statechart ended, was
cancelled or ran out of ticks, the executor calls every node’s cleanup. Calling tick()
by hand cleans up nothing.
Writing a node#
A node class inherits EndedByOwner or SucceedsOnObservingTrue (see
Who ends a node) and states what it observes in one of two ways:
build_artifacts()returnsNodeArtifactswhoseobservationis a symbolic expression. It is compiled into the tick, so it costs no Python call per tick.on_tick()is called every tick the node was running at the start of, and may return anObservationStateValues, which takes precedence over the expression. Returning None keeps the expression’s value, or, for a node without one, the observation of its last tick.
Further hooks a node class may override:
set_up()is called once per context, before the node is first built. Register the variables the node reads here.build()is called when the statechart compiles the node, and again whenever the kinematic structure of the world changed. An override must returnsuper().build().on_start,on_pause,on_unpause,on_endandon_resetare called after the passes of the tick in which the transition happened.cleanup()is called once the executor finished running the statechart (see Ending the statechart).
When a composite node joins a statechart, it first puts the children it was handed below
itself in adopt_handed_children(), which the plan language constructs override, and then
creates children of its own in expand(). It may reject them in check_children(), which
runs when the statechart is compiled and the children’s conditions are complete. The plan language constructs, for
example, raise CompositeNodeWithoutChildrenError there if they were handed no node.
Data or services that are not part of every statechart reach a node through its
StatechartContext as a ContextExtension. A node class names the extensions it reads in
required_context_extensions, and a composite node class those it reads only while expanding
in context_extensions_required_to_expand; a statechart whose context lacks one raises
NodesMissingContextExtensionsError. Every hook reads the context of the node’s statechart
through the node’s context property, and self.context.require_extension(type) returns an
extension.
cramph ships these nodes besides the templates:
Node |
Observes |
|---|---|
|
True once it ran |
|
True once |
|
True once it ran ticks worth |
|
True once the statechart’s tick count exceeds |
|
True once |
|
True for its first |
|
True every tick, printing |
|
True once |
|
True once it moved |
Changing a statechart while it runs#
A compiled statechart can take new nodes. Adding a node compiles the statechart again; the
nodes it already held keep their state and history. Changes made inside a
with statechart.modify(): block compile only once, when the outermost block ends; the
nodes added in a block that raises are dropped again. A node the latest compile covered
cannot be removed, and a composite node it covered takes no new children, unless its class
accepts children after compiling, as a node choosing its child
does.
When the kinematic structure of the world changes, every node is built again before the next tick settles, so expressions reading the structure follow it.
Before compiling again or letting a node choose its child, both of which block the tick, the statechart asks its recompile callbacks whether what they drive is at rest. Until all are, the statechart keeps ticking and asks again on the next tick.
Choosing a child while running#
A CompositeNodeChoosingItsChild runs a child that is chosen only once it runs, against the
world as the nodes before it left it. It asks the ChildChooser its context holds, through
the ChildChooserAccess context extension, for a child when it starts, and
again whenever its latest child ended without succeeding.
It observes what its latest child observed last, and Unknown before it has a child.
It succeeds once a child succeeds, and fails once the chooser has no child left. Since that can happen at any choice, it always counts as a node that can fail.
A chooser may answer that it has no choice yet; the node then asks again on the next tick.
A chooser may hand failure monitors along with a child. They run beside it and fail it once one of them observes True, unless it reaches its goal on the same tick; they end with it. Before asking for them, the node lets the chooser prepare the child, which has joined the statechart by then.
failure_reasonsnames the monitors that gave up on a child, andchosen_childrenkeeps the children apart from them.A reset puts it back to choosing: the children it chose before never run again.
Every chosen child joins the statechart after it was compiled, which compiles it again (see Changing a statechart while it runs). Choosing waits until every recompile callback of the statechart is at rest, so a child is chosen against a world that holds still.
A StatementNode is the node choosing its child that runs an underspecified a(...)
statement: the statement is grounded only when the node starts, and the chooser decides
which of its candidates run. The statement itself is not serialized; a process that ticks
the statechart elsewhere receives the chosen children instead.
Example#
A plan that first tries a slow approach and falls back to a fast one once the slow one fails. A function that raises and counters stand in for real work:
from cramph.composites import Sequence, TryInOrder
from cramph.context import StatechartContext
from cramph.executor import StatechartExecutor
from cramph.monitors import CountTicks
from cramph.node import EndStatechart
from cramph.statechart import Statechart
from cramph.threaded_nodes import FunctionCall
from semantic_digital_twin.world import World
executor = StatechartExecutor(context=StatechartContext(world=World()))
statechart = Statechart(context=executor.context)
def slip():
raise RuntimeError("the gripper slipped")
slow_approach = FunctionCall(
name="slow approach", function=slip, failure_types=(RuntimeError,)
)
fast_approach = CountTicks(name="fast approach", ticks=5)
plan = Sequence(
nodes=[
TryInOrder(nodes=[slow_approach, fast_approach]),
CountTicks(name="retreat", ticks=5),
]
)
statechart.add_node(plan)
statechart.add_node(EndStatechart.when_true(plan))
After running it:
slow_approachis FAILED, since its function raised one of itsfailure_types.fast_approachandretreatare SUCCEEDED, ended by the templates holding them.planis SUCCEEDED.
Running a Statechart executes this plan and plots it.
Benefits#
Modularity: Individual steps and checks are self-contained nodes that can be reused.
Clarity: The statechart structure provides a clear visual and logical representation of the behaviour.
Robustness: Error handling and reactivity are built directly into the structure through monitors and transitions, and every node that ended carries an outcome saying how.