server.plugins.trajectory_mission_plugin

trajectory_mission_plugin.py code for trajectory-based mission (1st robot only)

  1"""trajectory_mission_plugin.py code for trajectory-based mission (1st robot only)"""
  2from __future__ import annotations
  3from dataclasses import dataclass, field
  4from enum import StrEnum
  5
  6from overrides import overrides
  7from microecs import World, Entity
  8from microspec import Endpoint, Field, Dtype
  9import numpy as np
 10import raylib as rl
 11
 12from robosim.utils import Point3D, Pose4x4, logger, vec3_from
 13from robosim.components import HasFPV, HasCollision, HasPose
 14from robosim.constants import DT, N_DOF
 15from robosim.traits import Serializable
 16from robosim.plugin import Plugin, Message, Response, Event
 17
 18MISSION_WEIGHTS = (0.5, 0.4, 0.001)
 19
 20# pylint: disable=invalid-name
 21class MissionPhase(StrEnum):
 22    """The state of the mission"""
 23    NotStarted = "not-started"
 24    Running    = "running"
 25    Finished   = "finished"
 26
 27@dataclass
 28class MissionState(Serializable):
 29    """mission state fat struct"""
 30    phase: MissionPhase = MissionPhase.NotStarted
 31    via_points: list[Point3D] = field(default_factory=list) # The goal pose where the drone must get to (if any)
 32    # mode='running' parameters
 33    trajectory: np.ndarray | None = None # Must be set when state="running"
 34    trajectory_ix: int = 0 # Must be set when state="running"
 35    trajectory_poses: list[Pose4x4] = field(default_factory=list)
 36    # mode='done' parameters
 37    final_pose: Pose4x4 = None
 38    via_points_errors: np.ndarray = None
 39    via_closest_poses: list[Pose4x4] = field(default_factory=list)
 40    target_position_error: float = None
 41    total_steps: int = None
 42    final_error: float = None
 43    mission_errors: list[str] = field(default_factory=list)
 44
 45    @overrides
 46    def to_dict(self) -> dict:
 47        # Note: save only generic parameters (e.g. via_points) and finished (e.g. final_pose, error)
 48        return {
 49            "phase": self.phase,
 50            "via_points": [x.tolist() for x in self.via_points],
 51            "via_points_errors": self.via_points_errors.tolist() if self.via_points_errors is not None else None,
 52            "via_closest_poses": [x.tolist() for x in self.via_closest_poses],
 53            "final_pose": self.final_pose.tolist() if self.final_pose is not None else None,
 54            "target_position_error": self.target_position_error,
 55            "total_steps": self.total_steps,
 56            "final_error": self.final_error,
 57            "mission_errors": self.mission_errors,
 58        }
 59
 60    @staticmethod
 61    @overrides
 62    def from_dict(state: dict) -> Serializable:
 63        state["via_points"] = ([np.float32(x) for x in state["via_points"]]
 64                               if state["via_points"] is not None else None)
 65        state["via_points_errors"] = (np.float32(state["via_points_errors"])
 66                                      if state["via_points_errors"] is not None else None)
 67        state["via_closest_poses"] = ([np.float32(x) for x in state["via_closest_poses"]]
 68                                      if state["via_closest_poses"] is not None else None)
 69        state["final_pose"] = np.float32(state["final_pose"]) if state["final_pose"] is not None else None
 70        return MissionState(**state)
 71
 72    @property
 73    def target_pose(self) -> Pose4x4 | None:
 74        """returns the last via point, if any"""
 75        return self.via_points[-1] if len(self.via_points) > 0 else None
 76
 77    def start(self, trajectory: np.ndarray):
 78        """starts the mission given a trajectory"""
 79        assert self.phase in (MissionPhase.NotStarted, MissionPhase.Finished), self.phase
 80        # Important: reset all the mode='running' parameters (from old runs)
 81        self.trajectory = trajectory.astype("float32")
 82        self.trajectory_poses.clear()
 83        self.trajectory_ix = 0
 84        self.mission_errors = []
 85        self.phase = MissionPhase.Running
 86
 87    @property
 88    def motion_input(self) -> np.ndarray | None:
 89        """return the control input from the mission if it's running"""
 90        assert self.phase == MissionPhase.Running, self.to_dict()
 91        if self.trajectory_ix < len(self.trajectory):
 92            return self.trajectory[self.trajectory_ix]
 93        return None
 94
 95    def observe(self, world: World, event: Event):
 96        """The mission run method. Called on each frame once the mission is running."""
 97        if self.phase != MissionPhase.Running:
 98            return
 99
100        robot: Entity = world.get_entity(event.target)
101        if robot.is_colliding.item():
102            robot_state = robot.to_dict(serialization_field="serializable")
103            event_state = {"source": type(event.source).__name__,
104                           "payload": {k: v.tolist() for k, v in event.payload.items()}}
105            self.mission_errors.append(
106                f"Trajectory ix={self.trajectory_ix}. Event state: {event_state}. Robot state: {robot_state}")
107
108        self.trajectory_poses.append(robot.pose.copy())
109        self.trajectory_ix += 1
110
111        if len(self.trajectory) < 2:
112            logger.error(f"At least two trajectory poses needed: {self.trajectory_poses}. Cancelling mission.")
113            self.phase = MissionPhase.NotStarted
114            return
115
116        curr = self.trajectory_poses[-1]
117        prev = self.trajectory_poses[-2] if len(self.trajectory_poses) > 2 else curr
118        if self.trajectory_ix >= len(self.trajectory) and np.allclose(prev, curr, atol=1e-3, rtol=0):
119            # note: don't get rid of via points here as we may start another one
120            self.final_pose = robot.pose.copy()
121            err = np.linalg.norm(self.target_pose - robot.pose[0:3, 3])
122            self.target_position_error = err.item()
123
124            # compute the closest pose towards each via point
125            traj_positions = np.float32(self.trajectory_poses)[:, 0:3, 3] # (N, 3)
126            via_points = np.float32(self.via_points) # (M, 3)
127            distances = np.linalg.norm(traj_positions[:, None] - via_points[None], axis=2) # (M, N)
128            min_distances_ix = np.argmin(distances, axis=1) # (M, )
129            self.via_points_errors = distances[list(range(len(min_distances_ix))), min_distances_ix] # (M, )
130            self.via_closest_poses = [self.trajectory_poses[ix] for ix in min_distances_ix] # (M, 6)
131
132            self.total_steps = len(self.trajectory_poses)
133            self.final_error = ((self.target_position_error * MISSION_WEIGHTS[0] +
134                                 self.via_points_errors.mean() * MISSION_WEIGHTS[1] +
135                                 self.total_steps * DT * MISSION_WEIGHTS[2]) / sum(MISSION_WEIGHTS)).item()
136            logger.info(f"Mission ended. Final error: {self.final_error}")
137            self.phase = MissionPhase.Finished
138
139class TrajectoryMissionPlugin(Plugin):
140    """TrajectoryMissionPlugin implementation"""
141    def __init__(self):
142        self.state = MissionState()
143        self.events_this_tick: list[Event] = []
144        self.zeros = np.float32([0] * N_DOF)
145
146    @property
147    @overrides
148    def endpoints(self) -> list[Endpoint]:
149        return [
150            # TODO(microspec): add shape-range
151            Endpoint("mission_add_via_point",
152                     input={"position": Field(Dtype.FLOAT32, shape=(3, ), range=[-1000, 1000])},
153                     output={"status": Field(Dtype.STR)}),
154            Endpoint("mission_get_state", input={}, output={"status": Field(Dtype.DICT)}),
155            Endpoint("mission_start",
156                     input={"trajectory": Field(Dtype.FLOAT32, shape=(None, N_DOF), min_len=2)},
157                     output={"status": Field(Dtype.STR)}),
158            Endpoint("mission_clear_via_points", input={}, output={"status": Field(Dtype.STR)}),
159        ]
160
161    @overrides
162    def on_message_receive(self, world: World, entity_id: int, message: Message) -> Response:
163        robot = world.get_entity(world.query(HasFPV, HasCollision, HasPose).entity_ids[0])
164
165        if entity_id != robot.entity_id:
166            return Response.err(f"Only robot id 0 can do mission commands. You are robot id {entity_id}")
167
168        if message.cmd == "mission_get_state":
169            return Response.ok(self.state.to_dict())
170
171        if message.cmd == "mission_add_via_point": # add a new via point to the mission
172            self.state.via_points.append(message.payload["position"])
173            self.state.phase = MissionPhase.NotStarted # in case it's in finished mode
174            return Response.ok((f"Added a new via point at {message.payload["position"]} "
175                                f"(current: {len(self.state.via_points)})"))
176
177        if message.cmd == "mission_clear_via_points":
178            self.state.via_points.clear()
179            self.state.phase = MissionPhase.NotStarted # in case it's in finished mode
180            return Response.ok("Removed all via points")
181
182        if message.cmd == "mission_start":
183            if len(self.state.via_points) < 1: # Note: mission-specific when we have more missions
184                return Response.err(f"Too few points ({len(self.state.via_points)}) for mission. Need at least 1")
185
186            self.state.start(message.payload["trajectory"])
187            return Response.ok((f"Starting mission through {len(self.state.via_points)} via points and "
188                                f"{len(self.state.trajectory)} trajectory points"))
189
190        raise ValueError("shouldn't get here")
191
192    @overrides
193    def on_tick(self, world: World) -> list[Event]:
194        self.events_this_tick.clear()
195        if self.state.phase == MissionPhase.Running:
196            robot_id = world.query(HasFPV, HasCollision, HasPose).entity_ids[0]
197            payload = {"motion_input": self.zeros if self.state.motion_input is None else self.state.motion_input}
198            self.events_this_tick = [Event(source=self, target=robot_id, payload=payload)]
199        return self.events_this_tick
200
201    @overrides
202    def on_after_physics(self, world: World):
203        for event in self.events_this_tick:
204            if event.applied:
205                self.state.observe(world, event)
206
207    @overrides
208    def draw(self, world: World, entity_id: int | None = None):
209        for position in self.state.via_points:
210            rl.DrawSphere(vec3_from(position), 0.2, rl.GREEN)
211
212        # Draw trajectory path during/after mission
213        for pose in self.state.trajectory_poses:
214            pos = (pose[0, 3], pose[1, 3], pose[2, 3])
215            rl.DrawCube(pos, 0.05, 0.05, 0.05, rl.BLUE)
216
217    @overrides
218    def on_message_response(self, world: World, event: Event) -> Response:
219        raise ValueError(f"Should not reach here: ({event})")
220
221    @overrides
222    def to_dict(self) -> dict:
223        return self.state.to_dict()
224
225    @overrides
226    def load_state_dict(self, state: dict):
227        self.state = MissionState.from_dict(dict(state)) # dict() copy so it's not modified outside this call
MISSION_WEIGHTS = (0.5, 0.4, 0.001)
class MissionPhase(enum.StrEnum):
22class MissionPhase(StrEnum):
23    """The state of the mission"""
24    NotStarted = "not-started"
25    Running    = "running"
26    Finished   = "finished"

The state of the mission

NotStarted = <MissionPhase.NotStarted: 'not-started'>
Running = <MissionPhase.Running: 'running'>
Finished = <MissionPhase.Finished: 'finished'>
@dataclass
class MissionState(robosim.traits.Serializable):
 28@dataclass
 29class MissionState(Serializable):
 30    """mission state fat struct"""
 31    phase: MissionPhase = MissionPhase.NotStarted
 32    via_points: list[Point3D] = field(default_factory=list) # The goal pose where the drone must get to (if any)
 33    # mode='running' parameters
 34    trajectory: np.ndarray | None = None # Must be set when state="running"
 35    trajectory_ix: int = 0 # Must be set when state="running"
 36    trajectory_poses: list[Pose4x4] = field(default_factory=list)
 37    # mode='done' parameters
 38    final_pose: Pose4x4 = None
 39    via_points_errors: np.ndarray = None
 40    via_closest_poses: list[Pose4x4] = field(default_factory=list)
 41    target_position_error: float = None
 42    total_steps: int = None
 43    final_error: float = None
 44    mission_errors: list[str] = field(default_factory=list)
 45
 46    @overrides
 47    def to_dict(self) -> dict:
 48        # Note: save only generic parameters (e.g. via_points) and finished (e.g. final_pose, error)
 49        return {
 50            "phase": self.phase,
 51            "via_points": [x.tolist() for x in self.via_points],
 52            "via_points_errors": self.via_points_errors.tolist() if self.via_points_errors is not None else None,
 53            "via_closest_poses": [x.tolist() for x in self.via_closest_poses],
 54            "final_pose": self.final_pose.tolist() if self.final_pose is not None else None,
 55            "target_position_error": self.target_position_error,
 56            "total_steps": self.total_steps,
 57            "final_error": self.final_error,
 58            "mission_errors": self.mission_errors,
 59        }
 60
 61    @staticmethod
 62    @overrides
 63    def from_dict(state: dict) -> Serializable:
 64        state["via_points"] = ([np.float32(x) for x in state["via_points"]]
 65                               if state["via_points"] is not None else None)
 66        state["via_points_errors"] = (np.float32(state["via_points_errors"])
 67                                      if state["via_points_errors"] is not None else None)
 68        state["via_closest_poses"] = ([np.float32(x) for x in state["via_closest_poses"]]
 69                                      if state["via_closest_poses"] is not None else None)
 70        state["final_pose"] = np.float32(state["final_pose"]) if state["final_pose"] is not None else None
 71        return MissionState(**state)
 72
 73    @property
 74    def target_pose(self) -> Pose4x4 | None:
 75        """returns the last via point, if any"""
 76        return self.via_points[-1] if len(self.via_points) > 0 else None
 77
 78    def start(self, trajectory: np.ndarray):
 79        """starts the mission given a trajectory"""
 80        assert self.phase in (MissionPhase.NotStarted, MissionPhase.Finished), self.phase
 81        # Important: reset all the mode='running' parameters (from old runs)
 82        self.trajectory = trajectory.astype("float32")
 83        self.trajectory_poses.clear()
 84        self.trajectory_ix = 0
 85        self.mission_errors = []
 86        self.phase = MissionPhase.Running
 87
 88    @property
 89    def motion_input(self) -> np.ndarray | None:
 90        """return the control input from the mission if it's running"""
 91        assert self.phase == MissionPhase.Running, self.to_dict()
 92        if self.trajectory_ix < len(self.trajectory):
 93            return self.trajectory[self.trajectory_ix]
 94        return None
 95
 96    def observe(self, world: World, event: Event):
 97        """The mission run method. Called on each frame once the mission is running."""
 98        if self.phase != MissionPhase.Running:
 99            return
100
101        robot: Entity = world.get_entity(event.target)
102        if robot.is_colliding.item():
103            robot_state = robot.to_dict(serialization_field="serializable")
104            event_state = {"source": type(event.source).__name__,
105                           "payload": {k: v.tolist() for k, v in event.payload.items()}}
106            self.mission_errors.append(
107                f"Trajectory ix={self.trajectory_ix}. Event state: {event_state}. Robot state: {robot_state}")
108
109        self.trajectory_poses.append(robot.pose.copy())
110        self.trajectory_ix += 1
111
112        if len(self.trajectory) < 2:
113            logger.error(f"At least two trajectory poses needed: {self.trajectory_poses}. Cancelling mission.")
114            self.phase = MissionPhase.NotStarted
115            return
116
117        curr = self.trajectory_poses[-1]
118        prev = self.trajectory_poses[-2] if len(self.trajectory_poses) > 2 else curr
119        if self.trajectory_ix >= len(self.trajectory) and np.allclose(prev, curr, atol=1e-3, rtol=0):
120            # note: don't get rid of via points here as we may start another one
121            self.final_pose = robot.pose.copy()
122            err = np.linalg.norm(self.target_pose - robot.pose[0:3, 3])
123            self.target_position_error = err.item()
124
125            # compute the closest pose towards each via point
126            traj_positions = np.float32(self.trajectory_poses)[:, 0:3, 3] # (N, 3)
127            via_points = np.float32(self.via_points) # (M, 3)
128            distances = np.linalg.norm(traj_positions[:, None] - via_points[None], axis=2) # (M, N)
129            min_distances_ix = np.argmin(distances, axis=1) # (M, )
130            self.via_points_errors = distances[list(range(len(min_distances_ix))), min_distances_ix] # (M, )
131            self.via_closest_poses = [self.trajectory_poses[ix] for ix in min_distances_ix] # (M, 6)
132
133            self.total_steps = len(self.trajectory_poses)
134            self.final_error = ((self.target_position_error * MISSION_WEIGHTS[0] +
135                                 self.via_points_errors.mean() * MISSION_WEIGHTS[1] +
136                                 self.total_steps * DT * MISSION_WEIGHTS[2]) / sum(MISSION_WEIGHTS)).item()
137            logger.info(f"Mission ended. Final error: {self.final_error}")
138            self.phase = MissionPhase.Finished

mission state fat struct

MissionState( phase: MissionPhase = <MissionPhase.NotStarted: 'not-started'>, via_points: list[numpy.ndarray] = <factory>, trajectory: numpy.ndarray | None = None, trajectory_ix: int = 0, trajectory_poses: list[numpy.ndarray] = <factory>, final_pose: numpy.ndarray = None, via_points_errors: numpy.ndarray = None, via_closest_poses: list[numpy.ndarray] = <factory>, target_position_error: float = None, total_steps: int = None, final_error: float = None, mission_errors: list[str] = <factory>)
phase: MissionPhase = <MissionPhase.NotStarted: 'not-started'>
via_points: list[numpy.ndarray]
trajectory: numpy.ndarray | None = None
trajectory_ix: int = 0
trajectory_poses: list[numpy.ndarray]
final_pose: numpy.ndarray = None
via_points_errors: numpy.ndarray = None
via_closest_poses: list[numpy.ndarray]
target_position_error: float = None
total_steps: int = None
final_error: float = None
mission_errors: list[str]
@overrides
def to_dict(self) -> dict:
46    @overrides
47    def to_dict(self) -> dict:
48        # Note: save only generic parameters (e.g. via_points) and finished (e.g. final_pose, error)
49        return {
50            "phase": self.phase,
51            "via_points": [x.tolist() for x in self.via_points],
52            "via_points_errors": self.via_points_errors.tolist() if self.via_points_errors is not None else None,
53            "via_closest_poses": [x.tolist() for x in self.via_closest_poses],
54            "final_pose": self.final_pose.tolist() if self.final_pose is not None else None,
55            "target_position_error": self.target_position_error,
56            "total_steps": self.total_steps,
57            "final_error": self.final_error,
58            "mission_errors": self.mission_errors,
59        }

the dict representation of this object for serialization purposes

@staticmethod
@overrides
def from_dict(state: dict) -> robosim.traits.Serializable:
61    @staticmethod
62    @overrides
63    def from_dict(state: dict) -> Serializable:
64        state["via_points"] = ([np.float32(x) for x in state["via_points"]]
65                               if state["via_points"] is not None else None)
66        state["via_points_errors"] = (np.float32(state["via_points_errors"])
67                                      if state["via_points_errors"] is not None else None)
68        state["via_closest_poses"] = ([np.float32(x) for x in state["via_closest_poses"]]
69                                      if state["via_closest_poses"] is not None else None)
70        state["final_pose"] = np.float32(state["final_pose"]) if state["final_pose"] is not None else None
71        return MissionState(**state)

loads this object from a serialized dict representation

target_pose: numpy.ndarray | None
73    @property
74    def target_pose(self) -> Pose4x4 | None:
75        """returns the last via point, if any"""
76        return self.via_points[-1] if len(self.via_points) > 0 else None

returns the last via point, if any

def start(self, trajectory: numpy.ndarray):
78    def start(self, trajectory: np.ndarray):
79        """starts the mission given a trajectory"""
80        assert self.phase in (MissionPhase.NotStarted, MissionPhase.Finished), self.phase
81        # Important: reset all the mode='running' parameters (from old runs)
82        self.trajectory = trajectory.astype("float32")
83        self.trajectory_poses.clear()
84        self.trajectory_ix = 0
85        self.mission_errors = []
86        self.phase = MissionPhase.Running

starts the mission given a trajectory

motion_input: numpy.ndarray | None
88    @property
89    def motion_input(self) -> np.ndarray | None:
90        """return the control input from the mission if it's running"""
91        assert self.phase == MissionPhase.Running, self.to_dict()
92        if self.trajectory_ix < len(self.trajectory):
93            return self.trajectory[self.trajectory_ix]
94        return None

return the control input from the mission if it's running

def observe(self, world: microecs.world.World, event: robosim.plugin.Event):
 96    def observe(self, world: World, event: Event):
 97        """The mission run method. Called on each frame once the mission is running."""
 98        if self.phase != MissionPhase.Running:
 99            return
100
101        robot: Entity = world.get_entity(event.target)
102        if robot.is_colliding.item():
103            robot_state = robot.to_dict(serialization_field="serializable")
104            event_state = {"source": type(event.source).__name__,
105                           "payload": {k: v.tolist() for k, v in event.payload.items()}}
106            self.mission_errors.append(
107                f"Trajectory ix={self.trajectory_ix}. Event state: {event_state}. Robot state: {robot_state}")
108
109        self.trajectory_poses.append(robot.pose.copy())
110        self.trajectory_ix += 1
111
112        if len(self.trajectory) < 2:
113            logger.error(f"At least two trajectory poses needed: {self.trajectory_poses}. Cancelling mission.")
114            self.phase = MissionPhase.NotStarted
115            return
116
117        curr = self.trajectory_poses[-1]
118        prev = self.trajectory_poses[-2] if len(self.trajectory_poses) > 2 else curr
119        if self.trajectory_ix >= len(self.trajectory) and np.allclose(prev, curr, atol=1e-3, rtol=0):
120            # note: don't get rid of via points here as we may start another one
121            self.final_pose = robot.pose.copy()
122            err = np.linalg.norm(self.target_pose - robot.pose[0:3, 3])
123            self.target_position_error = err.item()
124
125            # compute the closest pose towards each via point
126            traj_positions = np.float32(self.trajectory_poses)[:, 0:3, 3] # (N, 3)
127            via_points = np.float32(self.via_points) # (M, 3)
128            distances = np.linalg.norm(traj_positions[:, None] - via_points[None], axis=2) # (M, N)
129            min_distances_ix = np.argmin(distances, axis=1) # (M, )
130            self.via_points_errors = distances[list(range(len(min_distances_ix))), min_distances_ix] # (M, )
131            self.via_closest_poses = [self.trajectory_poses[ix] for ix in min_distances_ix] # (M, 6)
132
133            self.total_steps = len(self.trajectory_poses)
134            self.final_error = ((self.target_position_error * MISSION_WEIGHTS[0] +
135                                 self.via_points_errors.mean() * MISSION_WEIGHTS[1] +
136                                 self.total_steps * DT * MISSION_WEIGHTS[2]) / sum(MISSION_WEIGHTS)).item()
137            logger.info(f"Mission ended. Final error: {self.final_error}")
138            self.phase = MissionPhase.Finished

The mission run method. Called on each frame once the mission is running.

class TrajectoryMissionPlugin(robosim.plugin.Plugin):
140class TrajectoryMissionPlugin(Plugin):
141    """TrajectoryMissionPlugin implementation"""
142    def __init__(self):
143        self.state = MissionState()
144        self.events_this_tick: list[Event] = []
145        self.zeros = np.float32([0] * N_DOF)
146
147    @property
148    @overrides
149    def endpoints(self) -> list[Endpoint]:
150        return [
151            # TODO(microspec): add shape-range
152            Endpoint("mission_add_via_point",
153                     input={"position": Field(Dtype.FLOAT32, shape=(3, ), range=[-1000, 1000])},
154                     output={"status": Field(Dtype.STR)}),
155            Endpoint("mission_get_state", input={}, output={"status": Field(Dtype.DICT)}),
156            Endpoint("mission_start",
157                     input={"trajectory": Field(Dtype.FLOAT32, shape=(None, N_DOF), min_len=2)},
158                     output={"status": Field(Dtype.STR)}),
159            Endpoint("mission_clear_via_points", input={}, output={"status": Field(Dtype.STR)}),
160        ]
161
162    @overrides
163    def on_message_receive(self, world: World, entity_id: int, message: Message) -> Response:
164        robot = world.get_entity(world.query(HasFPV, HasCollision, HasPose).entity_ids[0])
165
166        if entity_id != robot.entity_id:
167            return Response.err(f"Only robot id 0 can do mission commands. You are robot id {entity_id}")
168
169        if message.cmd == "mission_get_state":
170            return Response.ok(self.state.to_dict())
171
172        if message.cmd == "mission_add_via_point": # add a new via point to the mission
173            self.state.via_points.append(message.payload["position"])
174            self.state.phase = MissionPhase.NotStarted # in case it's in finished mode
175            return Response.ok((f"Added a new via point at {message.payload["position"]} "
176                                f"(current: {len(self.state.via_points)})"))
177
178        if message.cmd == "mission_clear_via_points":
179            self.state.via_points.clear()
180            self.state.phase = MissionPhase.NotStarted # in case it's in finished mode
181            return Response.ok("Removed all via points")
182
183        if message.cmd == "mission_start":
184            if len(self.state.via_points) < 1: # Note: mission-specific when we have more missions
185                return Response.err(f"Too few points ({len(self.state.via_points)}) for mission. Need at least 1")
186
187            self.state.start(message.payload["trajectory"])
188            return Response.ok((f"Starting mission through {len(self.state.via_points)} via points and "
189                                f"{len(self.state.trajectory)} trajectory points"))
190
191        raise ValueError("shouldn't get here")
192
193    @overrides
194    def on_tick(self, world: World) -> list[Event]:
195        self.events_this_tick.clear()
196        if self.state.phase == MissionPhase.Running:
197            robot_id = world.query(HasFPV, HasCollision, HasPose).entity_ids[0]
198            payload = {"motion_input": self.zeros if self.state.motion_input is None else self.state.motion_input}
199            self.events_this_tick = [Event(source=self, target=robot_id, payload=payload)]
200        return self.events_this_tick
201
202    @overrides
203    def on_after_physics(self, world: World):
204        for event in self.events_this_tick:
205            if event.applied:
206                self.state.observe(world, event)
207
208    @overrides
209    def draw(self, world: World, entity_id: int | None = None):
210        for position in self.state.via_points:
211            rl.DrawSphere(vec3_from(position), 0.2, rl.GREEN)
212
213        # Draw trajectory path during/after mission
214        for pose in self.state.trajectory_poses:
215            pos = (pose[0, 3], pose[1, 3], pose[2, 3])
216            rl.DrawCube(pos, 0.05, 0.05, 0.05, rl.BLUE)
217
218    @overrides
219    def on_message_response(self, world: World, event: Event) -> Response:
220        raise ValueError(f"Should not reach here: ({event})")
221
222    @overrides
223    def to_dict(self) -> dict:
224        return self.state.to_dict()
225
226    @overrides
227    def load_state_dict(self, state: dict):
228        self.state = MissionState.from_dict(dict(state)) # dict() copy so it's not modified outside this call

TrajectoryMissionPlugin implementation

state
events_this_tick: list[robosim.plugin.Event]
zeros
endpoints: list[microspec.microspec.Endpoint]
147    @property
148    @overrides
149    def endpoints(self) -> list[Endpoint]:
150        return [
151            # TODO(microspec): add shape-range
152            Endpoint("mission_add_via_point",
153                     input={"position": Field(Dtype.FLOAT32, shape=(3, ), range=[-1000, 1000])},
154                     output={"status": Field(Dtype.STR)}),
155            Endpoint("mission_get_state", input={}, output={"status": Field(Dtype.DICT)}),
156            Endpoint("mission_start",
157                     input={"trajectory": Field(Dtype.FLOAT32, shape=(None, N_DOF), min_len=2)},
158                     output={"status": Field(Dtype.STR)}),
159            Endpoint("mission_clear_via_points", input={}, output={"status": Field(Dtype.STR)}),
160        ]

the endpoints (commands) of this plugin

@overrides
def on_message_receive( self, world: microecs.world.World, entity_id: int, message: robosim.plugin.Message) -> robosim.plugin.Response:
162    @overrides
163    def on_message_receive(self, world: World, entity_id: int, message: Message) -> Response:
164        robot = world.get_entity(world.query(HasFPV, HasCollision, HasPose).entity_ids[0])
165
166        if entity_id != robot.entity_id:
167            return Response.err(f"Only robot id 0 can do mission commands. You are robot id {entity_id}")
168
169        if message.cmd == "mission_get_state":
170            return Response.ok(self.state.to_dict())
171
172        if message.cmd == "mission_add_via_point": # add a new via point to the mission
173            self.state.via_points.append(message.payload["position"])
174            self.state.phase = MissionPhase.NotStarted # in case it's in finished mode
175            return Response.ok((f"Added a new via point at {message.payload["position"]} "
176                                f"(current: {len(self.state.via_points)})"))
177
178        if message.cmd == "mission_clear_via_points":
179            self.state.via_points.clear()
180            self.state.phase = MissionPhase.NotStarted # in case it's in finished mode
181            return Response.ok("Removed all via points")
182
183        if message.cmd == "mission_start":
184            if len(self.state.via_points) < 1: # Note: mission-specific when we have more missions
185                return Response.err(f"Too few points ({len(self.state.via_points)}) for mission. Need at least 1")
186
187            self.state.start(message.payload["trajectory"])
188            return Response.ok((f"Starting mission through {len(self.state.via_points)} via points and "
189                                f"{len(self.state.trajectory)} trajectory points"))
190
191        raise ValueError("shouldn't get here")

Called for each message and the associated entity of this plugin. Messages influence the World via Events

@overrides
def on_tick(self, world: microecs.world.World) -> list[robosim.plugin.Event]:
193    @overrides
194    def on_tick(self, world: World) -> list[Event]:
195        self.events_this_tick.clear()
196        if self.state.phase == MissionPhase.Running:
197            robot_id = world.query(HasFPV, HasCollision, HasPose).entity_ids[0]
198            payload = {"motion_input": self.zeros if self.state.motion_input is None else self.state.motion_input}
199            self.events_this_tick = [Event(source=self, target=robot_id, payload=payload)]
200        return self.events_this_tick

Called on each tick. Returns a list of events, one or many each per entity. These are internal plugin events (e.g. wind, or fixed trajectory) that will also influence the physics system.

@overrides
def on_after_physics(self, world: microecs.world.World):
202    @overrides
203    def on_after_physics(self, world: World):
204        for event in self.events_this_tick:
205            if event.applied:
206                self.state.observe(world, event)

callback called in the main loop after physics and before drawing

@overrides
def draw(self, world: microecs.world.World, entity_id: int | None = None):
208    @overrides
209    def draw(self, world: World, entity_id: int | None = None):
210        for position in self.state.via_points:
211            rl.DrawSphere(vec3_from(position), 0.2, rl.GREEN)
212
213        # Draw trajectory path during/after mission
214        for pose in self.state.trajectory_poses:
215            pos = (pose[0, 3], pose[1, 3], pose[2, 3])
216            rl.DrawCube(pos, 0.05, 0.05, 0.05, rl.BLUE)

callback called during the drawing phase. Called twice, one for global camera and one for FPV (id is set)

@overrides
def on_message_response( self, world: microecs.world.World, event: robosim.plugin.Event) -> robosim.plugin.Response:
218    @overrides
219    def on_message_response(self, world: World, event: Event) -> Response:
220        raise ValueError(f"Should not reach here: ({event})")

Called for each event generated by on_message_receive if they still need answering (.response not set)

@overrides
def to_dict(self) -> dict:
222    @overrides
223    def to_dict(self) -> dict:
224        return self.state.to_dict()

the dict representation of this object for serialization purposes

@overrides
def load_state_dict(self, state: dict):
226    @overrides
227    def load_state_dict(self, state: dict):
228        self.state = MissionState.from_dict(dict(state)) # dict() copy so it's not modified outside this call

Loads in place this object from a serialized dict representation