From 4ef9d7b9c41bbbcb1da389c14b2366dca92b6802 Mon Sep 17 00:00:00 2001 From: Aglargil <1252223935@qq.com> Date: Fri, 21 Feb 2025 18:05:02 +0800 Subject: [PATCH 1/4] add state machine state_machine test state_machine_update --- .../StateMachine/robot_behavior_case.py | 104 +++++++++++ .../StateMachine/robot_top_case.py | 164 +++++++++++++++++ MissionPlanning/StateMachine/state_machine.py | 166 ++++++++++++++++++ 3 files changed, 434 insertions(+) create mode 100644 MissionPlanning/StateMachine/robot_behavior_case.py create mode 100644 MissionPlanning/StateMachine/robot_top_case.py create mode 100644 MissionPlanning/StateMachine/state_machine.py diff --git a/MissionPlanning/StateMachine/robot_behavior_case.py b/MissionPlanning/StateMachine/robot_behavior_case.py new file mode 100644 index 0000000000..0837f39f9c --- /dev/null +++ b/MissionPlanning/StateMachine/robot_behavior_case.py @@ -0,0 +1,104 @@ +from state_machine import StateMachine + + +class Robot: + def __init__(self): + self.battery = 100 + self.task_progress = 0 + + # Initialize state machine + self.machine = StateMachine(self, "robot_sm") + + # Add state transition rules + self.machine.add_transition( + src_state="patrolling", + event="detect_task", + dst_state="executing_task", + guard=None, + action=None, + ) + + self.machine.add_transition( + src_state="executing_task", + event="task_complete", + dst_state="patrolling", + guard=None, + action="reset_task", + ) + + self.machine.add_transition( + src_state="executing_task", + event="low_battery", + dst_state="returning_to_base", + guard="is_battery_low", + ) + + self.machine.add_transition( + src_state="returning_to_base", + event="reach_base", + dst_state="charging", + guard=None, + action=None, + ) + + self.machine.add_transition( + src_state="charging", + event="charge_complete", + dst_state="patrolling", + guard=None, + action="battery_full", + ) + + # Set initial state + self.machine.set_current_state("patrolling") + + def is_battery_low(self): + """Battery level check condition""" + return self.battery < 30 + + def reset_task(self): + """Reset task progress""" + self.task_progress = 0 + print("[Action] Task progress has been reset") + + # Modify state entry callback naming convention (add state_ prefix) + def on_enter_executing_task(self): + print("\n------ Start Executing Task ------") + print(f"Current battery: {self.battery}%") + while self.machine.get_current_state().name == "executing_task": + self.task_progress += 10 + self.battery -= 25 + print( + f"Task progress: {self.task_progress}%, Remaining battery: {self.battery}%" + ) + + if self.task_progress >= 100: + self.machine.process("task_complete") + break + elif self.is_battery_low(): + self.machine.process("low_battery") + break + + def on_enter_returning_to_base(self): + print("\nLow battery, returning to charging station...") + self.machine.process("reach_base") + + def on_enter_charging(self): + print("\n------ Charging ------") + self.battery = 100 + print("Charging complete!") + self.machine.process("charge_complete") + + +# Keep the test section structure the same, only modify the trigger method +if __name__ == "__main__": + robot = Robot() + + print(f"Initial state: {robot.machine.get_current_state().name}") + print("------------") + + # Trigger task detection event + robot.machine.process("detect_task") + + print("\n------------") + print(f"Final state: {robot.machine.get_current_state().name}") diff --git a/MissionPlanning/StateMachine/robot_top_case.py b/MissionPlanning/StateMachine/robot_top_case.py new file mode 100644 index 0000000000..e9f03fc0e2 --- /dev/null +++ b/MissionPlanning/StateMachine/robot_top_case.py @@ -0,0 +1,164 @@ +from collections.abc import Callable +from state_machine import StateMachine + + +class EventBus: + def __init__(self): + self.subscribers = {} + + def subscribe(self, event: str, callback: Callable): + if event not in self.subscribers: + self.subscribers[event] = [] + self.subscribers[event].append(callback) + + def publish(self, event: str): + if event in self.subscribers: + for callback in self.subscribers[event]: + callback() + else: + raise ValueError(f"Invalid event: {event}") + + +class SlamModel: + def __init__(self, event_bus: EventBus, mapping_success: bool = False): + self.event_bus = event_bus + self.mapping_success = mapping_success + + def on_enter_localization(self): + self.event_bus.publish("top_localization_ready_event") + + def on_enter_mapping(self): + self.mapping_success = True + + def is_mapping_success(self): + return self.mapping_success + + +class PlanningModel: + def __init__(self, event_bus: EventBus): + self.event_bus = event_bus + + def on_exit_working(self): + self.event_bus.publish("top_stop_working_event") + + +class TopModel: + def __init__(self, event_bus: EventBus): + self.event_bus = event_bus + + def on_enter_pre_working(self): + self.event_bus.publish("slam_start_localization_event") + + def on_enter_working(self): + self.event_bus.publish("planning_start_working_event") + + def on_enter_mapping(self): + self.event_bus.publish("planning_start_remote_control_control_event") + self.event_bus.publish("slam_start_mapping_event") + + def on_exit_mapping(self): + self.event_bus.publish("planning_stop_remote_control_control_event") + self.event_bus.publish("slam_stop_mapping_event") + + +def main(): + event_bus = EventBus() + + slam_model = SlamModel(event_bus) + planning_model = PlanningModel(event_bus) + top_model = TopModel(event_bus) + + slam_machine = StateMachine(slam_model, "slam_machine") + planning_machine = StateMachine(planning_model, "planning_machine") + top_machine = StateMachine(top_model, "top_machine") + + # fmt: off + slam_machine.add_transition("idle", "start_localization_event", "localization", "is_mapping_success") + slam_machine.add_transition("localization", "stop_localization_event", "idle") + slam_machine.add_transition("idle", "start_mapping_event", "mapping") + slam_machine.add_transition("mapping", "stop_mapping_event", "idle") + + planning_machine.add_transition("idle", "start_working_event", "working") + planning_machine.add_transition("idle", "stop_working_event", "idle") + planning_machine.add_transition("working", "stop_working_event", "idle") + planning_machine.add_transition("idle", "start_remote_control_control_event", "remote_control_control") + planning_machine.add_transition("remote_control_control", "stop_remote_control_control_event", "idle") + + top_machine.add_transition("idle", "start_working_event", "pre_working") + top_machine.add_transition("pre_working", "localization_ready_event", "working") + top_machine.add_transition("pre_working", "stop_working_event", "idle") + top_machine.add_transition("working", "stop_working_event", "idle") + top_machine.add_transition("idle", "start_mapping_event", "mapping") + top_machine.add_transition("mapping", "stop_mapping_event", "idle") + # fmt: on + event_bus.subscribe( + "slam_start_localization_event", + lambda: slam_machine.process("start_localization_event"), + ) + event_bus.subscribe( + "slam_start_mapping_event", + lambda: slam_machine.process("start_mapping_event"), + ) + event_bus.subscribe( + "slam_stop_mapping_event", + lambda: slam_machine.process("stop_mapping_event"), + ) + event_bus.subscribe( + "planning_start_working_event", + lambda: planning_machine.process("start_working_event"), + ) + event_bus.subscribe( + "planning_start_remote_control_control_event", + lambda: planning_machine.process("start_remote_control_control_event"), + ) + event_bus.subscribe( + "planning_stop_remote_control_control_event", + lambda: planning_machine.process("stop_remote_control_control_event"), + ) + event_bus.subscribe( + "top_localization_ready_event", + lambda: top_machine.process("localization_ready_event"), + ) + event_bus.subscribe( + "top_mapping_ready_event", + lambda: top_machine.process("mapping_ready_event"), + ) + event_bus.subscribe( + "top_stop_working_event", + lambda: top_machine.process("stop_working_event"), + ) + + def working_task(): + slam_machine.set_current_state("idle") + planning_machine.set_current_state("idle") + top_machine.set_current_state("idle") + # User sends start working event + top_machine.process("start_working_event") + + # Planning Model finish the task, and send stop working event + planning_machine.process("stop_working_event") + + print("top_machine: ", top_machine.get_current_state().name) + print("planning_machine: ", planning_machine.get_current_state().name) + print("slam_machine: ", slam_machine.get_current_state().name) + + working_task() + + def mapping_task(): + slam_machine.set_current_state("idle") + planning_machine.set_current_state("idle") + top_machine.set_current_state("idle") + # User sends start mapping event + top_machine.process("start_mapping_event") + # User sends stop mapping event + top_machine.process("stop_mapping_event") + + print("top_machine: ", top_machine.get_current_state().name) + print("planning_machine: ", planning_machine.get_current_state().name) + print("slam_machine: ", slam_machine.get_current_state().name) + + mapping_task() + + +if __name__ == "__main__": + main() diff --git a/MissionPlanning/StateMachine/state_machine.py b/MissionPlanning/StateMachine/state_machine.py new file mode 100644 index 0000000000..e3edf487c7 --- /dev/null +++ b/MissionPlanning/StateMachine/state_machine.py @@ -0,0 +1,166 @@ +from collections.abc import Callable + + +class State: + def __init__(self, name, on_enter=None, on_exit=None): + self.name = name + self.on_enter = on_enter + self.on_exit = on_exit + + def enter(self): + print(f"entering <{self.name}>") + if self.on_enter: + self.on_enter() + + def exit(self): + print(f"exiting <{self.name}>") + if self.on_exit: + self.on_exit() + + +class StateMachine(State): + def __init__(self, model: object, name: str, on_enter=None, on_exit=None): + State.__init__(self, name, on_enter, on_exit) + self.states = {} + self.events = {} + self.transition_table = {} + self._model = model + self._state: StateMachine = None + + def add_transition( + self, + src_state: str | State, + event: str, + dst_state: str | State, + guard: str | Callable = None, + action: str | Callable = None, + ) -> None: + """Add a transition to the state machine. + + Args: + src_state: Source state name or State object + event: Event name or Event object + dst_state: Destination state name or State object + guard: Guard function name or callable + action: Action function name or callable + """ + # Convert string parameters to objects if necessary + self.register_state(src_state) + self.register_event(event) + self.register_state(dst_state) + + def get_state_obj(state): + return state if isinstance(state, State) else self.get_state(state) + + def get_callable(func): + return func if callable(func) else getattr(self._model, func, None) + + src_state_obj = get_state_obj(src_state) + dst_state_obj = get_state_obj(dst_state) + + guard_func = get_callable(guard) if guard else None + action_func = get_callable(action) if action else None + self.transition_table[(src_state_obj.name, event)] = ( + dst_state_obj, + guard_func, + action_func, + ) + + def state_transition(self, src_state: State, event: str): + if (src_state.name, event) not in self.transition_table: + raise ValueError( + f"|{self.name}| invalid transition: <{src_state.name}> : [{event}]" + ) + + dst_state, guard, action = self.transition_table[(src_state.name, event)] + + def call_guard(guard): + if callable(guard): + return guard() + else: + return True + + def call_action(action): + if callable(action): + action() + + if call_guard(guard): + call_action(action) + if src_state.name != dst_state.name: + print( + f"|{self.name}| transitioning from <{src_state.name}> to <{dst_state.name}>" + ) + src_state.exit() + self._state = dst_state + dst_state.enter() + else: + print( + f"|{self.name}| skipping transition from <{src_state.name}> to <{dst_state.name}> because guard failed" + ) + + def register_state(self, state: str | State, on_enter=None, on_exit=None): + """Register a state in the state machine. + + Args: + state (str | State): The state to register. Can be either a string (state name) + or a State object. + on_enter (Callable, optional): Callback function to be executed when entering the state. + If state is a string and on_enter is None, it will look for + a method named 'on_enter_' in the model. + on_exit (Callable, optional): Callback function to be executed when exiting the state. + If state is a string and on_exit is None, it will look for + a method named 'on_exit_' in the model. + + Raises: + ValueError: If a state with the same name is already registered with a different type. + """ + if isinstance(state, str): + if on_enter is None: + on_enter = getattr(self._model, "on_enter_" + state, None) + if on_exit is None: + on_exit = getattr(self._model, "on_exit_" + state, None) + self.states[state] = State(state, on_enter, on_exit) + return + + name = state.name + if name in self.states and type(self.states[name]) is not type(state): + raise ValueError( + f'State "{name}" {type(state).__name__} already registered as {type(self.states[name]).__name__}' + ) + + self.states[name] = state + + def register_event(self, event: str): + self.events[event] = event + + def get_state(self, name): + return self.states[name] + + def get_event(self, name): + return self.events[name] + + def has_event(self, event: str): + return event in self.events + + def set_current_state(self, state: State | str): + if isinstance(state, str): + self._state = self.get_state(state) + else: + self._state = state + + def get_current_state(self): + return self._state + + def process(self, event: str) -> None: + """Process an event in the state machine. + + Args: + event: Event name or Event object + """ + if self._state is None: + raise ValueError("State machine is not initialized") + + if self.has_event(event): + self.state_transition(self._state, event) + else: + raise ValueError(f"Invalid event: {event}") From 7a74c617eb39b3472779bf225b02bdcf18ed9538 Mon Sep 17 00:00:00 2001 From: Aglargil <1252223935@qq.com> Date: Mon, 24 Feb 2025 16:53:05 +0800 Subject: [PATCH 2/4] add state machine test/doc --- .../StateMachine/robot_behavior_case.py | 3 +- .../StateMachine/robot_top_case.py | 164 ------------------ MissionPlanning/StateMachine/state_machine.py | 164 +++++++++++++----- docs/index_main.rst | 1 + .../mission_planning_main.rst | 12 ++ .../state_machine/robot_behavior_case.png | Bin 0 -> 28565 bytes .../state_machine/state_machine_main.rst | 74 ++++++++ tests/test_state_machine.py | 51 ++++++ 8 files changed, 259 insertions(+), 210 deletions(-) delete mode 100644 MissionPlanning/StateMachine/robot_top_case.py create mode 100644 docs/modules/13_mission_planning/mission_planning_main.rst create mode 100644 docs/modules/13_mission_planning/state_machine/robot_behavior_case.png create mode 100644 docs/modules/13_mission_planning/state_machine/state_machine_main.rst create mode 100644 tests/test_state_machine.py diff --git a/MissionPlanning/StateMachine/robot_behavior_case.py b/MissionPlanning/StateMachine/robot_behavior_case.py index 0837f39f9c..a5d570643f 100644 --- a/MissionPlanning/StateMachine/robot_behavior_case.py +++ b/MissionPlanning/StateMachine/robot_behavior_case.py @@ -7,7 +7,7 @@ def __init__(self): self.task_progress = 0 # Initialize state machine - self.machine = StateMachine(self, "robot_sm") + self.machine = StateMachine("robot_sm", self) # Add state transition rules self.machine.add_transition( @@ -93,6 +93,7 @@ def on_enter_charging(self): # Keep the test section structure the same, only modify the trigger method if __name__ == "__main__": robot = Robot() + print(robot.machine.generate_plantuml()) print(f"Initial state: {robot.machine.get_current_state().name}") print("------------") diff --git a/MissionPlanning/StateMachine/robot_top_case.py b/MissionPlanning/StateMachine/robot_top_case.py deleted file mode 100644 index e9f03fc0e2..0000000000 --- a/MissionPlanning/StateMachine/robot_top_case.py +++ /dev/null @@ -1,164 +0,0 @@ -from collections.abc import Callable -from state_machine import StateMachine - - -class EventBus: - def __init__(self): - self.subscribers = {} - - def subscribe(self, event: str, callback: Callable): - if event not in self.subscribers: - self.subscribers[event] = [] - self.subscribers[event].append(callback) - - def publish(self, event: str): - if event in self.subscribers: - for callback in self.subscribers[event]: - callback() - else: - raise ValueError(f"Invalid event: {event}") - - -class SlamModel: - def __init__(self, event_bus: EventBus, mapping_success: bool = False): - self.event_bus = event_bus - self.mapping_success = mapping_success - - def on_enter_localization(self): - self.event_bus.publish("top_localization_ready_event") - - def on_enter_mapping(self): - self.mapping_success = True - - def is_mapping_success(self): - return self.mapping_success - - -class PlanningModel: - def __init__(self, event_bus: EventBus): - self.event_bus = event_bus - - def on_exit_working(self): - self.event_bus.publish("top_stop_working_event") - - -class TopModel: - def __init__(self, event_bus: EventBus): - self.event_bus = event_bus - - def on_enter_pre_working(self): - self.event_bus.publish("slam_start_localization_event") - - def on_enter_working(self): - self.event_bus.publish("planning_start_working_event") - - def on_enter_mapping(self): - self.event_bus.publish("planning_start_remote_control_control_event") - self.event_bus.publish("slam_start_mapping_event") - - def on_exit_mapping(self): - self.event_bus.publish("planning_stop_remote_control_control_event") - self.event_bus.publish("slam_stop_mapping_event") - - -def main(): - event_bus = EventBus() - - slam_model = SlamModel(event_bus) - planning_model = PlanningModel(event_bus) - top_model = TopModel(event_bus) - - slam_machine = StateMachine(slam_model, "slam_machine") - planning_machine = StateMachine(planning_model, "planning_machine") - top_machine = StateMachine(top_model, "top_machine") - - # fmt: off - slam_machine.add_transition("idle", "start_localization_event", "localization", "is_mapping_success") - slam_machine.add_transition("localization", "stop_localization_event", "idle") - slam_machine.add_transition("idle", "start_mapping_event", "mapping") - slam_machine.add_transition("mapping", "stop_mapping_event", "idle") - - planning_machine.add_transition("idle", "start_working_event", "working") - planning_machine.add_transition("idle", "stop_working_event", "idle") - planning_machine.add_transition("working", "stop_working_event", "idle") - planning_machine.add_transition("idle", "start_remote_control_control_event", "remote_control_control") - planning_machine.add_transition("remote_control_control", "stop_remote_control_control_event", "idle") - - top_machine.add_transition("idle", "start_working_event", "pre_working") - top_machine.add_transition("pre_working", "localization_ready_event", "working") - top_machine.add_transition("pre_working", "stop_working_event", "idle") - top_machine.add_transition("working", "stop_working_event", "idle") - top_machine.add_transition("idle", "start_mapping_event", "mapping") - top_machine.add_transition("mapping", "stop_mapping_event", "idle") - # fmt: on - event_bus.subscribe( - "slam_start_localization_event", - lambda: slam_machine.process("start_localization_event"), - ) - event_bus.subscribe( - "slam_start_mapping_event", - lambda: slam_machine.process("start_mapping_event"), - ) - event_bus.subscribe( - "slam_stop_mapping_event", - lambda: slam_machine.process("stop_mapping_event"), - ) - event_bus.subscribe( - "planning_start_working_event", - lambda: planning_machine.process("start_working_event"), - ) - event_bus.subscribe( - "planning_start_remote_control_control_event", - lambda: planning_machine.process("start_remote_control_control_event"), - ) - event_bus.subscribe( - "planning_stop_remote_control_control_event", - lambda: planning_machine.process("stop_remote_control_control_event"), - ) - event_bus.subscribe( - "top_localization_ready_event", - lambda: top_machine.process("localization_ready_event"), - ) - event_bus.subscribe( - "top_mapping_ready_event", - lambda: top_machine.process("mapping_ready_event"), - ) - event_bus.subscribe( - "top_stop_working_event", - lambda: top_machine.process("stop_working_event"), - ) - - def working_task(): - slam_machine.set_current_state("idle") - planning_machine.set_current_state("idle") - top_machine.set_current_state("idle") - # User sends start working event - top_machine.process("start_working_event") - - # Planning Model finish the task, and send stop working event - planning_machine.process("stop_working_event") - - print("top_machine: ", top_machine.get_current_state().name) - print("planning_machine: ", planning_machine.get_current_state().name) - print("slam_machine: ", slam_machine.get_current_state().name) - - working_task() - - def mapping_task(): - slam_machine.set_current_state("idle") - planning_machine.set_current_state("idle") - top_machine.set_current_state("idle") - # User sends start mapping event - top_machine.process("start_mapping_event") - # User sends stop mapping event - top_machine.process("stop_mapping_event") - - print("top_machine: ", top_machine.get_current_state().name) - print("planning_machine: ", planning_machine.get_current_state().name) - print("slam_machine: ", slam_machine.get_current_state().name) - - mapping_task() - - -if __name__ == "__main__": - main() diff --git a/MissionPlanning/StateMachine/state_machine.py b/MissionPlanning/StateMachine/state_machine.py index e3edf487c7..5534cd71bd 100644 --- a/MissionPlanning/StateMachine/state_machine.py +++ b/MissionPlanning/StateMachine/state_machine.py @@ -18,15 +18,49 @@ def exit(self): self.on_exit() -class StateMachine(State): - def __init__(self, model: object, name: str, on_enter=None, on_exit=None): - State.__init__(self, name, on_enter, on_exit) - self.states = {} - self.events = {} - self.transition_table = {} +class StateMachine: + def __init__(self, name: str, model=object): + """Initialize the state machine. + + Args: + name (str): Name of the state machine. + model (object, optional): Model object used to automatically look up callback functions + for states and transitions: + State callbacks: Automatically searches for 'on_enter_' and 'on_exit_' methods. + Transition callbacks: When action or guard parameters are strings, looks up corresponding methods in the model. + + Example: + >>> class MyModel: + ... def on_enter_idle(self): + ... print("Entering idle state") + ... def on_exit_idle(self): + ... print("Exiting idle state") + ... def can_start(self): + ... return True + ... def on_start(self): + ... print("Starting operation") + >>> model = MyModel() + >>> machine = StateMachine("my_machine", model) + """ + self._name = name + self._states = {} + self._events = {} + self._transition_table = {} self._model = model self._state: StateMachine = None + def _register_event(self, event: str): + self._events[event] = event + + def _get_state(self, name): + return self._states[name] + + def _get_event(self, name): + return self._events[name] + + def _has_event(self, event: str): + return event in self._events + def add_transition( self, src_state: str | State, @@ -38,19 +72,37 @@ def add_transition( """Add a transition to the state machine. Args: - src_state: Source state name or State object - event: Event name or Event object - dst_state: Destination state name or State object - guard: Guard function name or callable - action: Action function name or callable + src_state (str | State): The source state where the transition begins. + Can be either a state name or a State object. + event (str): The event that triggers this transition. + dst_state (str | State): The destination state where the transition ends. + Can be either a state name or a State object. + guard (str | Callable, optional): Guard condition for the transition. + If callable: Function that returns bool. + If str: Name of a method in the model class. + If returns True: Transition proceeds. + If returns False: Transition is skipped. + action (str | Callable, optional): Action to execute during transition. + If callable: Function to execute. + If str: Name of a method in the model class. + Executed after guard passes and before entering new state. + + Example: + >>> machine.add_transition( + ... src_state="idle", + ... event="start", + ... dst_state="running", + ... guard="can_start", + ... action="on_start" + ... ) """ # Convert string parameters to objects if necessary self.register_state(src_state) - self.register_event(event) + self._register_event(event) self.register_state(dst_state) def get_state_obj(state): - return state if isinstance(state, State) else self.get_state(state) + return state if isinstance(state, State) else self._get_state(state) def get_callable(func): return func if callable(func) else getattr(self._model, func, None) @@ -60,19 +112,19 @@ def get_callable(func): guard_func = get_callable(guard) if guard else None action_func = get_callable(action) if action else None - self.transition_table[(src_state_obj.name, event)] = ( + self._transition_table[(src_state_obj.name, event)] = ( dst_state_obj, guard_func, action_func, ) def state_transition(self, src_state: State, event: str): - if (src_state.name, event) not in self.transition_table: + if (src_state.name, event) not in self._transition_table: raise ValueError( - f"|{self.name}| invalid transition: <{src_state.name}> : [{event}]" + f"|{self._name}| invalid transition: <{src_state.name}> : [{event}]" ) - dst_state, guard, action = self.transition_table[(src_state.name, event)] + dst_state, guard, action = self._transition_table[(src_state.name, event)] def call_guard(guard): if callable(guard): @@ -88,14 +140,14 @@ def call_action(action): call_action(action) if src_state.name != dst_state.name: print( - f"|{self.name}| transitioning from <{src_state.name}> to <{dst_state.name}>" + f"|{self._name}| transitioning from <{src_state.name}> to <{dst_state.name}>" ) src_state.exit() self._state = dst_state dst_state.enter() else: print( - f"|{self.name}| skipping transition from <{src_state.name}> to <{dst_state.name}> because guard failed" + f"|{self._name}| skipping transition from <{src_state.name}> to <{dst_state.name}> because guard failed" ) def register_state(self, state: str | State, on_enter=None, on_exit=None): @@ -110,41 +162,23 @@ def register_state(self, state: str | State, on_enter=None, on_exit=None): on_exit (Callable, optional): Callback function to be executed when exiting the state. If state is a string and on_exit is None, it will look for a method named 'on_exit_' in the model. - - Raises: - ValueError: If a state with the same name is already registered with a different type. + Example: + >>> machine.register_state("idle", on_enter=on_enter_idle, on_exit=on_exit_idle) + >>> machine.register_state(State("running", on_enter=on_enter_running, on_exit=on_exit_running)) """ if isinstance(state, str): if on_enter is None: on_enter = getattr(self._model, "on_enter_" + state, None) if on_exit is None: on_exit = getattr(self._model, "on_exit_" + state, None) - self.states[state] = State(state, on_enter, on_exit) + self._states[state] = State(state, on_enter, on_exit) return - name = state.name - if name in self.states and type(self.states[name]) is not type(state): - raise ValueError( - f'State "{name}" {type(state).__name__} already registered as {type(self.states[name]).__name__}' - ) - - self.states[name] = state - - def register_event(self, event: str): - self.events[event] = event - - def get_state(self, name): - return self.states[name] - - def get_event(self, name): - return self.events[name] - - def has_event(self, event: str): - return event in self.events + self._states[state.name] = state def set_current_state(self, state: State | str): if isinstance(state, str): - self._state = self.get_state(state) + self._state = self._get_state(state) else: self._state = state @@ -155,12 +189,52 @@ def process(self, event: str) -> None: """Process an event in the state machine. Args: - event: Event name or Event object + event: Event name. + + Example: + >>> machine.process("start") """ if self._state is None: raise ValueError("State machine is not initialized") - if self.has_event(event): + if self._has_event(event): self.state_transition(self._state, event) else: raise ValueError(f"Invalid event: {event}") + + def generate_plantuml(self) -> str: + """Generate PlantUML state diagram representation of the state machine. + + Returns: + str: PlantUML state diagram code. + """ + if self._state is None: + raise ValueError("State machine is not initialized") + + plantuml = ["@startuml"] + plantuml.append("[*] --> " + self._state.name) + + # Generate transitions + for (src_state, event), ( + dst_state, + guard, + action, + ) in self._transition_table.items(): + transition = f"{src_state} --> {dst_state.name} : {event}" + + # Add guard and action if present + conditions = [] + if guard: + guard_name = guard.__name__ if callable(guard) else guard + conditions.append(f"[{guard_name}]") + if action: + action_name = action.__name__ if callable(action) else action + conditions.append(f"/ {action_name}") + + if conditions: + transition += "\\n" + " ".join(conditions) + + plantuml.append(transition) + + plantuml.append("@enduml") + return "\n".join(plantuml) diff --git a/docs/index_main.rst b/docs/index_main.rst index 75805d1184..65634f32e8 100644 --- a/docs/index_main.rst +++ b/docs/index_main.rst @@ -44,6 +44,7 @@ this graph shows GitHub star history of this project: modules/8_aerial_navigation/aerial_navigation modules/9_bipedal/bipedal modules/10_inverted_pendulum/inverted_pendulum + modules/13_mission_planning/mission_planning modules/11_utils/utils modules/12_appendix/appendix diff --git a/docs/modules/13_mission_planning/mission_planning_main.rst b/docs/modules/13_mission_planning/mission_planning_main.rst new file mode 100644 index 0000000000..81caba228a --- /dev/null +++ b/docs/modules/13_mission_planning/mission_planning_main.rst @@ -0,0 +1,12 @@ +.. _`Mission Planning`: + +Mission Planning +================ + +Mission planning includes tools such as finite state machines and behavior trees used to describe robot behavior. + +.. toctree:: + :maxdepth: 2 + :caption: Contents + + state_machine/state_machine diff --git a/docs/modules/13_mission_planning/state_machine/robot_behavior_case.png b/docs/modules/13_mission_planning/state_machine/robot_behavior_case.png new file mode 100644 index 0000000000000000000000000000000000000000..fbc1369cbcd71f278a89d38adbcda6da4b737e36 GIT binary patch literal 28565 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 VMQeA>!#)E3qv`LZ-qN)T{cpp^WxxOc literal 0 HcmV?d00001 diff --git a/docs/modules/13_mission_planning/state_machine/state_machine_main.rst b/docs/modules/13_mission_planning/state_machine/state_machine_main.rst new file mode 100644 index 0000000000..abaece1b11 --- /dev/null +++ b/docs/modules/13_mission_planning/state_machine/state_machine_main.rst @@ -0,0 +1,74 @@ +State Machine +------------- + +A state machine is a model used to describe the transitions of an object between different states. It clearly shows how an object changes state based on events and may trigger corresponding actions. + +Core Concepts +~~~~~~~~~~~~~ + +- **State**: A distinct mode or condition of the system (e.g. "Idle", "Running"). Managed by State class with optional on_enter/on_exit callbacks +- **Event**: A trigger signal that may cause state transitions (e.g. "start", "stop") +- **Transition**: A state change path from source to destination state triggered by an event +- **Action**: An operation executed during transition (before entering new state) +- **Guard**: A precondition that must be satisfied to allow transition + +API +~~~ + +.. autoclass:: MissionPlanning.StateMachine.state_machine.StateMachine + :members: add_transition, process, register_state + :special-members: __init__ + +PlantUML Support +~~~~~~~~~~~~~~~~ + +The ``generate_plantuml()`` method creates diagrams showing: + +- Current state (marked with [*] arrow) +- All possible transitions +- Guard conditions in [brackets] +- Actions prefixed with / + +Example +~~~~~~~ + +state machine diagram: ++++++++++++++++++++++++ +.. image:: robot_behavior_case.png + +state transition table: ++++++++++++++++++++++++ +.. list-table:: State Transitions + :header-rows: 1 + :widths: 20 15 20 20 20 + + * - Source State + - Event + - Target State + - Guard + - Action + * - patrolling + - detect_task + - executing_task + - + - + * - executing_task + - task_complete + - patrolling + - + - reset_task + * - executing_task + - low_battery + - returning_to_base + - is_battery_low + - + * - returning_to_base + - reach_base + - charging + - + - + * - charging + - charge_complete + - patrolling + - + - \ No newline at end of file diff --git a/tests/test_state_machine.py b/tests/test_state_machine.py new file mode 100644 index 0000000000..e36a8120fd --- /dev/null +++ b/tests/test_state_machine.py @@ -0,0 +1,51 @@ +import conftest + +from MissionPlanning.StateMachine.state_machine import StateMachine + + +def test_transition(): + sm = StateMachine("state_machine") + sm.add_transition(src_state="idle", event="start", dst_state="running") + sm.set_current_state("idle") + sm.process("start") + assert sm.get_current_state().name == "running" + + +def test_guard(): + class Model: + def can_start(self): + return False + + sm = StateMachine("state_machine", Model()) + sm.add_transition( + src_state="idle", event="start", dst_state="running", guard="can_start" + ) + sm.set_current_state("idle") + sm.process("start") + assert sm.get_current_state().name == "idle" + + +def test_action(): + class Model: + def on_start(self): + self.start_called = True + + model = Model() + sm = StateMachine("state_machine", model) + sm.add_transition( + src_state="idle", event="start", dst_state="running", action="on_start" + ) + sm.set_current_state("idle") + sm.process("start") + assert model.start_called + + +def test_plantuml(): + sm = StateMachine("state_machine") + sm.add_transition(src_state="idle", event="start", dst_state="running") + sm.set_current_state("idle") + assert sm.generate_plantuml() + + +if __name__ == "__main__": + conftest.run_this_test(__file__) From 4ef4e220a4077c958793e9083e47189605c6a897 Mon Sep 17 00:00:00 2001 From: Aglargil <1252223935@qq.com> Date: Tue, 25 Feb 2025 20:48:31 +0800 Subject: [PATCH 3/4] state machine update --- .../StateMachine/robot_behavior_case.py | 6 +++++ MissionPlanning/StateMachine/state_machine.py | 23 ++++++++++++++----- .../mission_planning_main.rst | 2 +- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/MissionPlanning/StateMachine/robot_behavior_case.py b/MissionPlanning/StateMachine/robot_behavior_case.py index a5d570643f..03ee60ae9f 100644 --- a/MissionPlanning/StateMachine/robot_behavior_case.py +++ b/MissionPlanning/StateMachine/robot_behavior_case.py @@ -1,3 +1,9 @@ +""" +A case study of robot behavior using state machine + +author: Wang Zheng (@Aglargil) +""" + from state_machine import StateMachine diff --git a/MissionPlanning/StateMachine/state_machine.py b/MissionPlanning/StateMachine/state_machine.py index 5534cd71bd..c3e1e1a420 100644 --- a/MissionPlanning/StateMachine/state_machine.py +++ b/MissionPlanning/StateMachine/state_machine.py @@ -1,3 +1,14 @@ +""" +State Machine + +author: Wang Zheng (@Aglargil) + +Ref: + +- [State Machine] +(https://en.wikipedia.org/wiki/Finite-state_machine) +""" + from collections.abc import Callable @@ -47,7 +58,7 @@ def __init__(self, name: str, model=object): self._events = {} self._transition_table = {} self._model = model - self._state: StateMachine = None + self._state: State = None def _register_event(self, event: str): self._events[event] = event @@ -211,8 +222,8 @@ def generate_plantuml(self) -> str: if self._state is None: raise ValueError("State machine is not initialized") - plantuml = ["@startuml"] - plantuml.append("[*] --> " + self._state.name) + plant_uml = ["@startuml"] + plant_uml.append("[*] --> " + self._state.name) # Generate transitions for (src_state, event), ( @@ -234,7 +245,7 @@ def generate_plantuml(self) -> str: if conditions: transition += "\\n" + " ".join(conditions) - plantuml.append(transition) + plant_uml.append(transition) - plantuml.append("@enduml") - return "\n".join(plantuml) + plant_uml.append("@enduml") + return "\n".join(plant_uml) diff --git a/docs/modules/13_mission_planning/mission_planning_main.rst b/docs/modules/13_mission_planning/mission_planning_main.rst index 81caba228a..385e62f68e 100644 --- a/docs/modules/13_mission_planning/mission_planning_main.rst +++ b/docs/modules/13_mission_planning/mission_planning_main.rst @@ -3,7 +3,7 @@ Mission Planning ================ -Mission planning includes tools such as finite state machines and behavior trees used to describe robot behavior. +Mission planning includes tools such as finite state machines and behavior trees used to describe robot behavior and high level task planning. .. toctree:: :maxdepth: 2 From 8b3ce1058825bd3681242476fb776c136541c93c Mon Sep 17 00:00:00 2001 From: Aglargil <1252223935@qq.com> Date: Tue, 25 Feb 2025 21:34:59 +0800 Subject: [PATCH 4/4] state machine generate_plantuml() can show diagram by using https://www.plantuml.com/plantuml/ --- MissionPlanning/StateMachine/state_machine.py | 45 ++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/MissionPlanning/StateMachine/state_machine.py b/MissionPlanning/StateMachine/state_machine.py index c3e1e1a420..de72f0f451 100644 --- a/MissionPlanning/StateMachine/state_machine.py +++ b/MissionPlanning/StateMachine/state_machine.py @@ -9,7 +9,34 @@ (https://en.wikipedia.org/wiki/Finite-state_machine) """ +import string +from urllib.request import urlopen, Request +from base64 import b64encode +from zlib import compress +from io import BytesIO from collections.abc import Callable +from matplotlib.image import imread +from matplotlib import pyplot as plt + + +def deflate_and_encode(plantuml_text): + """ + zlib compress the plantuml text and encode it for the plantuml server. + + Ref: https://plantuml.com/en/text-encoding + """ + plantuml_alphabet = ( + string.digits + string.ascii_uppercase + string.ascii_lowercase + "-_" + ) + base64_alphabet = ( + string.ascii_uppercase + string.ascii_lowercase + string.digits + "+/" + ) + b64_to_plantuml = bytes.maketrans( + base64_alphabet.encode("utf-8"), plantuml_alphabet.encode("utf-8") + ) + zlibbed_str = compress(plantuml_text.encode("utf-8")) + compressed_string = zlibbed_str[2:-4] + return b64encode(compressed_string).translate(b64_to_plantuml).decode("utf-8") class State: @@ -248,4 +275,20 @@ def generate_plantuml(self) -> str: plant_uml.append(transition) plant_uml.append("@enduml") - return "\n".join(plant_uml) + plant_uml_text = "\n".join(plant_uml) + + try: + url = f"http://www.plantuml.com/plantuml/img/{deflate_and_encode(plant_uml_text)}" + headers = {"User-Agent": "Mozilla/5.0"} + request = Request(url, headers=headers) + + with urlopen(request) as response: + content = response.read() + + plt.imshow(imread(BytesIO(content), format="png")) + plt.axis("off") + plt.show() + except Exception as e: + print(f"Error showing PlantUML: {e}") + + return plant_uml_text