Events¶
The event injection framework of task P2-T03: how a scenario's timeline says when
something happens (the timeline DSL: at, after, when, every), what happens (the
events: built-in actions and plugins found through Python entry points; the world events on a
Gazebo plugin, the fault events on the bridge's injectors), how every occurrence is recorded
(run.json, engine.log, /fieldproof/events), and how to write an event plugin. The
scenario format around the timeline is in scenarios.md; the radio link events
are detailed in network.md.
timeline:
- at: 0s # the drive starts with the timeline
name: drive_ahead
vehicle: ugv1
action: navigate_to_pose
goal: { x: 10.0, y: -1.0, yaw: -1.5708 }
- name: say_hello # at 1 s, then every 3 s, three times
vehicle: ugv1
at: 1s
every: 3s
count: 3
action: example.announce # an event of a plugin (examples/plugins)
text: fieldproof says hello
- name: tighten_failsafe # the first time the vehicle is 1 m from its spawn
vehicle: ugv1
when: { metric: distance_to, target: { frame: spawn_dock }, condition: value > 1.0 }
within_s: 4m
action: param.set
node: link_monitor
parameter: loss_timeout_s
value: 5.0
- name: freeze_stack # five seconds after both have ended, for 3 s
vehicle: ugv1
after: [drive_ahead, tighten_failsafe]
at: 5s
action: container.pause
duration_s: 3s
- name: stop_stack # once the pause is over
vehicle: ugv1
after: freeze_stack
action: container.kill
The timeline DSL¶
Every entry has an action (which selects the event and its parameters), an optional name
(its label in the record; <action>@<at>s otherwise) and, for most events, a vehicle. The
rest of the entry says when it runs:
| Field | Meaning |
|---|---|
at |
offset from the entry's trigger, as a duration (30s, 1m; 0 by default): seconds after the timeline starts, after the after entry ends, or after the when condition first holds |
after |
the name of another entry, or a list of names: this one is triggered when that entry ends ok (when every one of the list has; it is skipped when any of them does not); a repeated entry cannot be named, nor the entry itself; chains are allowed, cycles are not |
when |
a condition of the run: the entry is triggered by the first sample of the observer's stream that satisfies it (after and when are exclusive) |
within_s |
with when: how long after the timeline starts the condition may still trigger the entry; the entry is skipped once that passes (without it: as long as the rest of the timeline runs) |
every |
repeat the entry at this period from its first start (a positive duration); the record labels the occurrences <name>, <name>#2, <name>#3, ...; occurrences never overlap: one that overruns its period delays the next until it ends; every does not combine with when |
count |
with every: stop after this many occurrences |
until_s |
with every: stop repeating this long after the timeline starts |
The trigger of an entry without after or when is the start of the timeline, which is the
moment every vehicle is ready (scenarios.md). Every time is
on the wall clock, counted from that moment, as the assertions' deadlines are in runner v0;
sim-time scheduling belongs to the clock manager of task P2-T04. The one trigger in simulation
time is when: {metric: sim_time, ...}, which reads the simulator's clock from the stream.
Occurrences run concurrently on the runner's workers (three vehicles can drive at once, a link
drop runs while a vehicle drives), the earliest due first. The timeline ends when nothing runs
and nothing can still start. An entry still counts as able to start while it is due, while a
when is within its within_s, while an after entry's predecessor can still run, and while a
repeated entry has a count or until_s left; a when without within_s and an every
without count or until_s are passive: they run as long as the rest of the timeline does
and do not keep it going by themselves. When the timeline ends, what never ran is settled:
| Status | When |
|---|---|
ok |
the occurrence ended successfully; also a repeated entry whose occurrences all ran |
failed |
the event reported a failure (a goal aborted, a parameter refused) or raised; the run fails (status: failed) at once and the other running occurrences are cancelled |
timeout |
the entry's own limit (timeout_s) or the scenario's budget ended it; the budget ends the run with status: timeout |
skipped |
the entry never ran: its when never held (within within_s, or before the timeline ended), or its after entry did not end ok |
A within_time assertion (assertions.md) on an entry is how a
scenario requires it: it fails when the entry is skipped, when it fails, and when it takes
longer than max_s; on a repeated entry it judges the first occurrence.
Conditions¶
A when is a mapping with a condition, an expression over value in the sandboxed language
of the topic_value assertion (comparisons, arithmetic, and, or, not, abs, min,
max, sqrt, round; assertions.md), applied to one of:
| Fields | value |
|---|---|
topic, field, optional message_type |
the field of each message of the ROS topic (relative to the vehicle's namespace, or absolute with a leading /), as the observer extracts it (twist.twist.linear.x, data, ranges[0]) |
metric: sim_time |
the simulator's clock, in seconds, from the observer's clock samples (once a second) |
metric: rtf |
the real-time factor measured over the last second |
metric: x, y, z, yaw |
the vehicle's ground-truth pose in the world, from the simulator (yaw in radians), at the observer's pose rate (10 Hz) |
metric: distance_to with target |
the horizontal distance, in metres, from the vehicle's ground truth to a placement of the world (a frame or coordinates) |
vehicle inside the when names whose topic or ground truth is read; it defaults to the
entry's vehicle, and a topic or a vehicle metric needs one of the two. The condition is
evaluated on every sample the observer streams, so its resolution is the sample rate: a
distance_to trigger fires within 0.1 s of simulation time of the threshold, a topic trigger on
the message that crosses it. A sample the condition cannot be evaluated for (a non-numeric
field) does not trigger. The observer subscribes to the topics the conditions name as it does
for the assertions (assertions.md).
Built-in events¶
fieldproof events lists every action the engine knows, where it comes from and its
parameters. The engine-side built-ins (session 1 of the task):
| Action | Fields | Effect |
|---|---|---|
navigate_to_pose |
vehicle, goal (a placement in the world), timeout_s (300 s) |
send a Nav2 NavigateToPose goal in the vehicle's map frame (which starts at its spawn pose) with ros2 action send_goal inside the vehicle's container and wait for its result: ok on SUCCEEDED, failed on any other status, timeout after timeout_s (scenarios.md) |
link.set, link.drop, link.restore |
vehicle, profile / duration_s |
the radio link events of task P2-T02, unchanged (network.md); a scenario without a network section is refused |
container.pause |
vehicle, duration_s (optional) |
pause the vehicle's container (docker pause: every process of the stack under test freezes while the simulator and the bridge go on, so the vehicle's last command stands in the world until the stack resumes; a container that is paused answers no exec); with duration_s the entry resumes the container after that long and ends then, without it the container stays paused until a container.resume (the teardown resumes it before stopping it) |
container.resume |
vehicle |
resume the paused container |
container.kill |
vehicle, signal (SIGKILL by default; SIGTERM, SIGINT, SIGHUP) |
send the signal to the vehicle's container; the run then expects the container gone (its exit does not end the run, its log is still kept), and the assertions decide what the exit meant for the fleet |
sim.pause, sim.resume, sim.step |
duration_s (pause: optional, wall clock) / none / steps or duration_s |
the simulator's clock, through the world's /world/<name>/control service (time.md): a pause stops the physics and every node on simulation time (the clock keeps publishing the time it stopped at); a step advances a paused world by physics steps or simulation seconds and pauses again; refused in lockstep mode, where the engine steps the clock |
param.set |
vehicle, node (relative to the vehicle's namespace, or absolute), parameter, value (a boolean, a number or a string, which stays a string however it looks), timeout_s (30 s) |
ros2 param set inside the vehicle's container; ok when the node accepted the value, failed when it refused it (a parameter callback) or is not found within timeout_s (the CLI's graph lags behind the stack for a few seconds, so the event asks again once a second), timeout when the node does not answer. Whether the node uses a value changed at run time is the node's business, as in the field |
A param.set or a navigate_to_pose aimed at a killed or paused container fails (ros2
cannot run there), and a command still running in a container when it is paused freezes with
it, so a scenario orders such entries with after (a list waits for all of them).
The world events¶
The world events change the simulated world while it runs, through the fieldproof::WorldEvents
system every Fieldproof world loads (worlds.md): a
gz-transport service per event under /world/<name>/fieldproof/, which the engine calls with
the gz service CLI inside the simulator's container. The plugin applies a request in the
simulator's next step and answers once it has, with a one-line report that the entry takes as
its detail (spawned model crate at (10, 0.8, 0.25, yaw 0)); a request the plugin refuses (a
name already taken, a model that does not exist, SDF that does not parse) ends the entry
failed with the reason. None of them needs a vehicle (a when names its own).
| Action | Fields | Effect |
|---|---|---|
spawn_object |
model (its name in the world), pose (a placement: a frame or x, y, z, yaw), then either a shape (box with size: [x, y, z], cylinder with radius and length, sphere with radius), static (true), mass_kg (1, for a dynamic shape), color ([r, g, b]), or a uri (model://<name> of the simulator's resource path: a Fuel model of the world, a package model) |
spawn an obstacle. A shape stands on the placement (its z is the ground; the model's centre is half the height above it) and is one link with a box, cylinder or sphere collision and visual, which keeps the real-time factor (a mesh on the ground plane halves it, worlds.md); a URI model is placed at the pose as it is. The lidar sees a spawned obstacle in its next scan, so a navigation stack reacts to it as to anything else. The name must not be a vehicle's |
remove_object |
model |
remove a model of the world by name (one spawned by spawn_object, or any model the world file declares; not a vehicle, which container.kill ends) |
teleport |
vehicle or model, pose (a placement) |
move the model to the pose at once, velocities kept. A vehicle keeps its spawn height above the pose's ground z (0.12 m for the reference UGV); its stack is not told, so its localisation has to recover (the kidnapped-robot problem, as in the field) |
set_wind |
velocity: [x, y, z] (m/s, world frame: x east, y north, z up), enabled (true) |
set the world's wind: the wind entity's seed and velocity, and Gazebo's WindEffects system (which every Fieldproof world loads) applies the force to the links of the models that enable wind (<enable_wind>true</enable_wind>: the aerial and surface vehicles of later phases; the reference UGV does not, so the wind changes nothing it does); wind_info reports it |
- name: spawn_crate # half a metre into the drive, a crate ahead
when: { vehicle: ugv1, metric: distance_to, target: { frame: spawn_dock }, condition: value > 0.5 }
within_s: 3m
action: spawn_object
model: crate
shape: box
size: [0.6, 0.6, 0.5]
pose: { x: 10.0, y: 0.8 }
- name: gust
at: 20s
action: set_wind
velocity: [8.0, 0.0, 0.0]
- name: clear_crate
after: drive_ahead
action: remove_object
model: crate
The fault events¶
The fault events make a sensor, the drive or the battery misbehave as a failing driver would,
without touching the vehicle's container: every adapter the bridge bundle runs between the
simulator's bridge and the vehicle is a fault injector (bridge.md):
the IMU and GNSS adapters, the throttle, the relays of the other sensor topics (the laser scan,
the odometry) and of the drive command, and the battery node of a manifest with a power
section. A fault is a parameter of the injector, which the event sets with ros2 param set
inside the bridge container (asked again while the CLI's graph lags behind the node, as
param.set does); with duration_s the entry holds that long and sets the parameter back
(fix denied for 20 s, lifted), without it the fault stays for the rest of the run. Each event
resolves its injector when the scenario is validated, from the vehicle's bridge bundle (the
same generator the bridge container runs), and refuses what the bundle does not route through
one: the camera topics stay on the C++ path, a manifest without a power.battery has no
battery, a manifest without a GNSS no fix to deny.
| Action | Fields | Effect |
|---|---|---|
gps.deny |
vehicle, sensor (the manifest's GNSS sensor; the only one by default), duration_s |
the receiver reports no fix: status.status STATUS_NO_FIX, NaN latitude, longitude and altitude, unknown covariance (jamming, a tunnel, a roof) |
gps.offset |
vehicle, sensor, east_m, north_m, up_m (0), duration_s |
the fix moved by metres east, north and up at its latitude (multipath, spoofing) |
imu.bias |
vehicle, sensor (the manifest's IMU), gyro_radps: [x, y, z], accel_mps2: [x, y, z] (zeros), duration_s |
a bias added to the angular velocity and the linear acceleration of every message |
sensor.dropout |
vehicle, sensor, duration_s |
the sensor goes silent: its topic stops. sensor is a sensor of the manifest (lidar, imu, gps), a drive interface (odometry, joint_states, command: the drive command on its way to the simulator) or battery, or one of their topics (scan, /ugv1/odom) |
sensor.freeze |
vehicle, sensor, duration_s |
the sensor hangs: its last message repeats, stamp included (a hung driver; a consumer sees a stale stamp) |
battery.drain |
vehicle, to_pct (0), duration_s (required) |
the battery's percentage falls linearly, in simulation time, from where it is to to_pct over duration_s; the voltage follows between the manifest's empty and full voltage, the status is DISCHARGING meanwhile; the entry waits the duration out (drained to 0 % over 60 s), so an entry after it runs on a flat battery. The reference vehicles simulate no power system: the state is the manifest's figures and the drain, nothing else drains it |
actuator.limit |
vehicle, max_linear_mps, max_angular_radps (one or both), duration_s |
the actuators deliver no more than these speeds: the drive command is clamped between the vehicle and the simulator (a derated motor controller, a slipping wheel); the vehicle's own cmd_vel is recorded as it commanded it, the limited command goes on the hidden _bridge/cmd_vel |
- name: deny_gps # 5 s in, no fix for 20 s
vehicle: ugv1
at: 5s
action: gps.deny
duration_s: 20s
- name: tired_motors # after the denial, the drive capped at 0.3 m/s
vehicle: ugv1
after: deny_gps
action: actuator.limit
max_linear_mps: 0.3
- name: flat_battery
vehicle: ugv1
at: 10s
action: battery.drain
to_pct: 5
duration_s: 2m
A fault's effect on the stack under test is the stack's business, as in the field: the example
vehicle navigates by SLAM and odometry and never reads the fix, so a gps.deny changes nothing
about its drive (which the GNSS-denial example shows), while a sensor.dropout of its lidar
stops its costmaps and Nav2 holds. What the assertions can see: the sensor topics the vehicle
sees (topic_value on gps/fix status.status, topic_rate on scan), the ground truth and
the recording.
The record¶
Every occurrence of a timeline entry is recorded three times over:
run.jsontimeline[], one object per occurrence (the entries that never ran included):name(say_hello#2for the second occurrence ofsay_hello),action,vehicle,at_s,trigger(at_s, andafter,whenwithwithin_s,every_swithcountanduntil_sas the entry has them),occurrence,status,detail(what the event reported:goal SUCCEEDED,paused for 3 s, resumed,condition never held within 240 s),params(the entry's own fields),started_at(UTC),t_s(seconds after the timeline started),sim_s(simulation time covered by the stream when it started) andduration_s;engine.log, oneaction.startline when the occurrence starts and oneaction.endline when it ends, both carrying the same record as fields (action,event_action,vehicle,occurrence,status,t_s,sim_s,trigger,params), plusaction.triggeredwhen anafteror awhenfires,action.skippedandaction.settledwhen the timeline ends, and the events' own lines (action.goal,container.paused,container.resumed,container.killed,link.applied,sim.paused,sim.stepped,sim.resumed, and the commands' output asaction.outputatdebug);/fieldproof/eventsin the recording, since the observer relays every line ofengine.logonto that side channel as it is written (assertions.md); the MCAP holds the run's events next to the topics they changed.
The Markdown and JUnit reports list every occurrence with its status, duration and detail
(assertions.md); the dry run (fieldproof run --dry-run) shows each
entry with its trigger and what its event resolved (a goal in the map frame, a node's absolute
name, a when with its topic made absolute or its target in the world).
Writing a plugin¶
An event is a Python class deriving from fieldproof.events.Event with three class attributes
and two hooks, registered through an entry point of the group fieldproof.events:
from typing import Any, ClassVar, Literal
from pydantic import Field
from fieldproof.dsl import TimelineBase
from fieldproof.events import OK, ActionRecord, Event, EventContext, PlanContext
from fieldproof.manifest import Name
class Announce(TimelineBase): # the entry: what the scenario writes
vehicle: Name = Field(description="The vehicle whose container publishes.")
action: Literal["example.announce"] # the discriminator, equal to Event.action
text: str = Field(description="The text of the message.")
class AnnounceEvent(Event):
action: ClassVar[str] = "example.announce"
Entry: ClassVar[type[TimelineBase]] = Announce
summary: ClassVar[str] = "Publish a std_msgs/msg/String once on a topic of the vehicle."
def plan(self, entry: Announce, context: PlanContext) -> dict[str, Any]:
vehicle = context.vehicle(entry.vehicle) # a ScenarioError if unknown
return {"topic": vehicle.topic("announce")} # shown by the dry run
def apply(self, entry: Announce, resolved: dict[str, Any],
context: EventContext, record: ActionRecord) -> None:
result = context.ros2(context.vehicle(entry.vehicle),
("topic", "pub", "--once", resolved["topic"],
"std_msgs/msg/String", f'{{"data": "{entry.text}"}}'),
timeout_s=resolved["limit_s"])
record.status, record.detail = (OK, "published") if result.ok else ("failed", result.tail(3))
# pyproject.toml of the plugin's distribution
[project.entry-points."fieldproof.events"]
"example.announce" = "fieldproof_example_events:AnnounceEvent"
Entryderives fromfieldproof.dsl.TimelineBase, which brings the triggers (at,after,when,within_s,every,count,until_s),nameand an optionalvehicle(declarevehicle: Nameto require it). Itsactionfield is aLiteralequal to the event'saction; its other fields, with their descriptions, are the parameters (fieldproof eventsprints them,fieldproof schemaexports them). Unknown keys are errors, as in every section.plan(entry, context)runs when the scenario is resolved (fieldproof validate, the dry run, before any container starts). ThePlanContexthas theworld(its frames), thevehicles(name, namespace, spawn pose,topic()to make a name absolute under the namespace), whether the scenario has radiolinks, andwhere(timeline[2]) for error messages. It returns what the dry run shows and whatapplygets back asresolved(dataclasses such as aPoseare turned into data for the record); it raises aScenarioErrorto refuse the entry.apply(entry, resolved, context, record)runs on a worker thread when the occurrence is due.resolvedisplan's result pluslimit_s, how long the occurrence may take (the entry'stimeout_sunder the scenario's budget). TheEventContextoffersvehicle(name),ros2(vehicle, argv, timeout_s=..., on_line=...)(aros2command inside the vehicle's container, through the image's entry point, interrupted aftertimeout_swith exit code 124),bridge_ros2(vehicle, argv, timeout_s=..., on_line=...)(the same inside the vehicle's bridge container, where the fault injectors run; the bridge runs on whether the vehicle's container is paused, killed or behind a link),gz_service(service, request_type=..., reply_type=..., request=..., timeout_s=...)(gz service -sinside the simulator's container, the request in protobuf text format, the reply printed the same way: the world events),exec(container, argv, on_line)(any command in any container of the run),backend(the container runtime:pause,unpause,kill,state, ...),log(event, message, **fields)(a line ofengine.log, relayed to/fieldproof/events),clock(),hold(seconds)(wait in slices,Falsewhen the run is ending),tearing_down,expect_exit(container)(a container the event is about to kill),mark_paused(container, paused)(so the teardown resumes it),apply_link(vehicle, profile, cause)andlink_profile(vehicle)(the radio links). A vehicle handle hasname,namespace,container_name,bridge_name,spawn,topic(name)andbundle(), the plan of its bridge bundle (bridge.md):bundle().injector("lidar")is the node a fault of the lidar sets,bundle().fully_qualified(node.name)its name. The hook setsrecord.status(ok,failed,timeout) andrecord.detail; a record leftrunningendsok, an exception ends itfailedwith the exception's text.- Registration: the distribution is installed in the engine's environment (
uv add --project engine --dev --editable <path>for a plugin in the workspace, orpip installinto that environment). The engine loads every entry point of the group when it starts; a plugin that fails to import or is malformed (noaction, anEntrythat is not aTimelineBase, anactionliteral that disagrees, anactionanother event has) is reported byfieldproof events(exit code 2) and byfieldproof validate(a warning) and its action stays unknown to the schema. The built-ins are registered the same way by the engine's ownpyproject.toml, and loaded directly as well.
examples/plugins/example_events/ is this example in full, with its tests and a README; the
engine's development environment installs it, so fieldproof events lists
example.announce next to the built-ins and examples/scenarios/ugv_events.yaml validates.
The committed JSON Schema (engine/schemas/scenario.schema.json, make scenario-schema) is
exported from that environment, the example plugin included.
What a plugin cannot do: run arbitrary code at validation time (its Entry is data; its hooks
run in the engine's process on the engine's terms), name an action another event has, or
change the triggers (they are the DSL's).
Example¶
examples/scenarios/ugv_events.yaml is the timeline above on the example vehicle ugv_nav2:
the vehicle drives 4 m ahead from the loading dock while say_hello publishes three times from
its container, tighten_failsafe sets a parameter of the stack's link monitor once the vehicle
has moved a metre, freeze_stack pauses the container for 3 s once both have ended and
stop_stack kills it. Seven assertions check the drive and, through within_time, that each
event ran.
examples/scenarios/ugv_obstacle_replan.yaml is the first definition-of-done scenario of the
task: the vehicle drives 4.5 m ahead through the roller door; half a metre into the drive a
when on its distance to the spawn fires spawn_object and a 0.6 m crate stands on the
straight line, 1.7 m ahead and in the lidar's view; Nav2's costmaps take it in, its planner
routes around it (the door is 4 m wide) and the vehicle reaches the goal without a contact;
remove_object clears the crate once the drive has ended. Six assertions: the crate spawned
(within_time), the goal reached, in time, the path kept being published (topic_rate on
plan: 1 Hz of simulation time on a GPU host, 0.2 Hz under software rendering on a four-core
CI runner, so the floor is 0.1 Hz over 10 s windows; the drive has 8 min, since a loaded runner has
needed 200 s of simulation time for it), no contact, the crate cleared. The
integration test adds what the simulator saw: the
vehicle's ground truth leaving the straight line to pass the crate.
examples/scenarios/ugv_gps_denied.yaml is the second: the same drive, with gps.deny 5 s in
for 20 s and a drive back to the dock once both the drive and the denial have ended. Seven
assertions: the fix present before the denial, absent through a window the denial is sure to
cover (13 s to 24 s: the parameter's round trip through the CLI and the margin software
rendering needs in CI), back for the return leg (topic_value on gps/fix status.status,
always and eventually), the event run (within_time), the goal reached, the return made,
no contact. The example stack never reads the fix, so the denial changes nothing about the
drive, which is the point: a vehicle that depended on it would show it here.
uv run --project engine fieldproof events
uv run --project engine fieldproof validate examples/scenarios/ugv_events.yaml
uv run --project engine fieldproof run examples/scenarios/ugv_events.yaml
uv run --project engine fieldproof run examples/scenarios/ugv_obstacle_replan.yaml
uv run --project engine fieldproof run examples/scenarios/ugv_gps_denied.yaml
Tests¶
engine/tests/test_dsl.py: the triggers of an entry and their errors, thewhenforms and their errors, the example, the cross-entry checks (afternames, self and cycles, repeated entries, unique names, unknown actions naming the known ones);test_schedule.py: the scheduler with numbers (due, live, wait,afterchains,whensamples and metrics,within_s,everywithcountanduntil_s, overruns, settling);test_events.py: the registry (the built-ins, the example plugin, malformed and broken entry points, collisions), the built-in events through a scripted context (the commands they run, every outcome), the timeline union,fieldproof events;test_events_runner.py: the example scenario on the fake backend (every trigger, the record and the log lines, a condition that never holds, awithin_s, a skipped chain, a failing event, an event raising, a paused container at the teardown, a killed container, a sim-time condition, the budget while a condition waits);examples/plugins/example_events/tests/test_announce.py: the plugin (run with the engine's tests);test_world_fault_events.py: the world events (the entries' validation, the SDF of every shape and of a URI model, the poses and servicesplanresolves, the requestsapplycomposes and every reply: applied, refused, no answer) and the fault events (the injector each resolves from the bridge bundle by sensor, interface or topic and the refusals, the parameters set and lifted in the bridge, the CLI's lag, a refused value, the budget ending a fault, the run ending during one, the battery's wait).make test-engineruns them all.sim/fieldproof_gz_plugins/test/test_world_events.cpp(make test-ros): the WorldEvents plugin on a headless server (worlds.md);bridge/fieldproof_bridge/test/test_faults.py: the injectors' logic (the pass-through filter with dropout and freeze, the parameter callback, the GNSS denial and offset, the IMU bias, the command limit, the battery's ramp and state), andtest_generator.pythe routing of every injectable output and the command through them; the bridge contract test runs the real bundle, relays included, against the reference UGV.tests/integration/test_event_injection.py(make test-integration): the example scenario against the real stack: exit code 0, every entryokwith its trigger and parameters, the seven assertions passed, the vehicle container seen paused and then exited bydocker inspectwhile its log is kept, theaction.start,action.endandaction.triggeredlines ofengine.log, the three/announcemessages in the recording, nothing left behind.tests/integration/test_world_events.py: the plugin's services against a bare simulator (the composesimservice), with the requests the events compose (a crate spawned where asked andgz modelreporting it, refused the second time, teleported, the wind set and reported bywind_info, the crate removed and a second removal refused); thenugv_obstacle_replan.yamlend to end: every entryokwith the plugin's reports, the six assertions passed, the crate spawned while the vehicle was still north of it, the ground truth showing the vehicle at least 0.57 m off the straight line beside the crate, theworld.changedlines, nothing left behind.tests/integration/test_fault_injection.py: the injectors against the real bridge (the composesimandbridgeservices with the spawned reference UGV, no vehicle stack): each fault set withros2 param setin the bridge container as the events do and observed on the contract topic withros2 topic echo(no fix then a fix, the fix moved 100 m east, the gyro biased, the scan silent then back, the odometry stamp frozen then moving, the battery falling to 50 % with its status, a 1 m/s command leaving the relay at 0.2 m/s); thenugv_gps_denied.yamlend to end:deny_gpsokwith the fix denied for 20 s and lifted, both drivesok, the seven assertions passed, thefault.injectedandfault.liftedlines, nothing left behind.
Known limits¶
- The fault events reach what the bridge routes through an injector: the IMU, the GNSS fix, the laser scan, the odometry, the joint states, the drive command and the battery of the reference vehicles. The camera topics stay on the C++ path (images at 15 Hz are too heavy for a Python relay) and take no fault; a stack's own topics (what it computes from the sensors) are not the bridge's to fault. A fault applies to every message of the topic, not to one in a hundred: there is no probabilistic dropout yet.
- The battery is synthetic: the reference vehicles simulate no power system, so the state is
the manifest's figures and what
battery.drainsays, and nothing else drains it. A Gazebo battery model (the LinearBatteryPlugin) could feed the same node later. set_windacts on the links that enable wind; the reference UGV does not, so the wind changes nothing about it until the aerial and surface vehicles arrive (P4, P5).- A spawned obstacle is a primitive shape or a model of the resource path; a
urimodel's collisions are whatever the model has (a mesh collision on the ground plane halves the real-time factor), andspawn_objectcannot check that in advance. - A fault's parameter takes a round trip through the
ros2CLI inside the bridge container (one to a few seconds, more while the CLI's graph lags), so an assertion window on a fault starts a few seconds after the entry'sat; the record'st_sandduration_ssay when it was in place. - Times are wall clock from the start of the timeline in
simandrealtimemode, soeverydrifts with the host's load and awhenfires on the first sample that satisfies it; in lockstep mode (time.md) they are simulation seconds from the origin and the world waits at every due time, which is what makes a run repeatable. - A
whenreads what the observer streams: topic fields at the topic's rate, the ground truth at 10 Hz, the clock once a second. Expressions over several topics, or over a window of samples, are not supported; chain entries withafteror use several entries instead. everydoes not combine withwhen, andaftercannot name a repeated entry.- A container's pause freezes the whole stack, not one node, and leaves its last command standing in the simulator (the reference UGV's drive has no command timeout); a kill ends the container for good (there is no restart event yet).