auv-sim-bench 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,203 @@
1
+ Metadata-Version: 2.4
2
+ Name: auv_sim_bench
3
+ Version: 0.1.0
4
+ Summary: Lightweight 6-DOF AUV physics sim for dry-testing state estimation.
5
+ Maintainer: wuisabel-gif
6
+ Maintainer-email: 231155141+wuisabel-gif@users.noreply.github.com
7
+ License: MIT
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: setuptools
10
+ Dynamic: description
11
+ Dynamic: description-content-type
12
+ Dynamic: license
13
+ Dynamic: maintainer
14
+ Dynamic: maintainer-email
15
+ Dynamic: requires-dist
16
+ Dynamic: summary
17
+
18
+ # AUVSimBench
19
+
20
+ A lightweight **physics twin** for autonomous underwater vehicles — a 6-DOF AUV
21
+ simulator as a single ROS 2 node, in pure Python/numpy. It runs the vehicle's
22
+ physics and publishes the **sensor topics a state estimator consumes, plus
23
+ ground-truth pose**, so you can develop and stress-test an underwater localization
24
+ or control stack **on the bench** — no water, no GPU, no Isaac Sim. It runs happily
25
+ on a Jetson.
26
+
27
+ ## Why this is hard (and why the twin helps)
28
+
29
+ Validating underwater state estimation is uniquely painful:
30
+
31
+ - **Wet tests are expensive and rare.** Pool/ocean time is slow to schedule, and you
32
+ get only a handful of runs before everyone's tired and the battery's flat.
33
+ - **There is no ground truth underwater.** No GPS below the surface — so even when you
34
+ *do* get a dive, you often can't measure *how wrong* the estimate was.
35
+ - **Most failures aren't about the water.** In practice the things that break a dive are
36
+ wiring (a node subscribed to the wrong topic), frame conventions (axes flipped),
37
+ startup ordering (a sensor not powered on), or an estimator diverging — and you
38
+ usually discover them *mid-dive*, burning a wet test to debug a typo.
39
+ - **Recorded bags only go so far.** A bag replays exactly one motion; you can't steer
40
+ it, you can't make a sensor fail on cue, and replayed DVL/depth won't agree with a
41
+ real IMU sitting still on the bench.
42
+
43
+ A **physics twin** answers all four. It simulates the vehicle's dynamics, so every
44
+ sensor stream is derived from *one consistent truth*; it *knows* the true pose, so
45
+ estimator accuracy is measurable; and you can script scenarios, disturbances, and
46
+ on-demand sensor failures — all from a laptop or the robot's own compute.
47
+
48
+ ```
49
+ wrench in ──► 6-DOF dynamics (thrust + buoyancy + drag + current) ──► IMU / DVL / depth + ground truth
50
+ ```
51
+
52
+ ## What it gives you
53
+
54
+ - **Consistent sensors** — IMU, DVL and depth all generated from the same simulated
55
+ body, so they physically agree (the thing bag-replay can't do).
56
+ - **Ground truth** — `/auv_sim/ground_truth` is the exact pose, so you can plot
57
+ estimator error directly.
58
+ - **Scenarios & disturbances** — water current, and on-demand sensor failures (DVL
59
+ dropout, noise, NaN) to probe estimator robustness — the exact failure modes that
60
+ bite in real wet tests.
61
+ - **Driven by your real controller** — it takes a `geometry_msgs/Wrench` with a deadman,
62
+ the same contract a PID/thruster-allocation stack already produces.
63
+
64
+ ## Physics model
65
+
66
+ An independent **6-DOF Newton-Euler** rigid body (FLU body frame, ENU world,
67
+ quaternion orientation):
68
+
69
+ - **Buoyancy** `ρ·V·g`, applied at a **center of buoyancy above the center of mass**, so
70
+ the vehicle self-rights — giving realistic roll/pitch, and therefore realistic IMU
71
+ output. Default displacement is exactly neutral.
72
+ - **Linear + quadratic drag** on both linear and angular velocity.
73
+ - **Water current** — drag acts on velocity *relative to the water*, so a current
74
+ actually pushes a passive vehicle (modeled properly, not faked through gravity).
75
+ - **Wrench input** in the body frame, with a 0.5 s command-timeout deadman.
76
+
77
+ The accelerometer output is the true specific force `(ΣF − gravity)/m` in the body
78
+ frame, so a level, neutrally-buoyant vehicle reads `az ≈ +9.81` — matching a real
79
+ ENU/FLU IMU.
80
+
81
+ ## Vehicle-agnostic
82
+
83
+ It is **not tied to any one vehicle.** Every topic name, frame, rate and physics
84
+ constant is a parameter, and the defaults are generic:
85
+
86
+ | published topic (default) | type | content |
87
+ |---------------------------|------|---------|
88
+ | `imu/data` | `sensor_msgs/Imu` | orientation, body angular velocity, specific-force accel |
89
+ | `dvl/odometry` | `nav_msgs/Odometry` | body-frame velocity |
90
+ | `dvl/altitude`, `ping/range` | `sensor_msgs/Range` | height above the floor |
91
+ | `pressure` | `sensor_msgs/FluidPressure` | from depth |
92
+ | `ground_truth` | `geometry_msgs/PoseStamped` | exact sim pose |
93
+
94
+ Adapt it to a specific vehicle with a **profile** — a small YAML that remaps the
95
+ topics/frames and sets the mass/buoyancy. `config/barracuda.yaml` is one example
96
+ (Barracuda's real `/barracuda/...` topics); write your own for any other AUV.
97
+
98
+ Profiles can also avoid hardcoded robot names by using placeholders in topic and
99
+ frame strings:
100
+
101
+ | placeholder | expands to |
102
+ |-------------|------------|
103
+ | `{topic_prefix}` | the normalized `topic_prefix` parameter, such as `/barracuda` |
104
+ | `{frame_prefix}` | the normalized `frame_prefix` parameter, such as `barracuda` |
105
+
106
+ For example, `wrench_topic: "{topic_prefix}/wrench"` becomes
107
+ `/barracuda/wrench` when `topic_prefix: /barracuda`. Leave both prefixes empty
108
+ for generic relative names.
109
+
110
+ Profiles can also tune the vehicle dynamics and scenario start state:
111
+
112
+ | parameter | meaning |
113
+ |-----------|---------|
114
+ | `inertia` | diagonal body inertia `[Ixx, Iyy, Izz]` |
115
+ | `cob_offset` | center of buoyancy relative to center of mass, body frame |
116
+ | `linear_drag`, `quadratic_drag` | per-axis translational drag coefficients |
117
+ | `angular_drag`, `angular_quadratic_drag` | per-axis rotational drag coefficients |
118
+ | `initial_position` | starting world position |
119
+ | `initial_orientation_wxyz` | starting orientation quaternion |
120
+ | `initial_linear_velocity`, `initial_angular_velocity` | starting body velocities |
121
+
122
+ ## See it in 3D
123
+
124
+ There's no built-in render, but it broadcasts **TF** and a **vehicle marker**, so it
125
+ shows up live in **RViz2** — which runs on a Jetson (plain OpenGL, no RTX/Isaac):
126
+
127
+ ```bash
128
+ ros2 launch auv_sim_bench sim.launch.py rviz:=true
129
+ ```
130
+
131
+ You get a 3D box for the vehicle flying around under thrust/current/buoyancy, the TF
132
+ frames, and — if you set `estimate_topic` to your estimator's pose — a second marker
133
+ for the **estimate**, so you can watch estimate-vs-truth drift in real time. It's
134
+ schematic (boxes + axes), not photoreal; photoreal water is the one thing that needs
135
+ Isaac Sim on an x86 box.
136
+
137
+ ## Failure & disturbance injection
138
+
139
+ For the real reason to simulate — breaking the estimator on purpose:
140
+
141
+ ```yaml
142
+ current: [0.3, 0.0, 0.0] # steady cross-current
143
+ dvl_dropout_start_sec: 10.0 # DVL goes silent at t=10s ...
144
+ dvl_dropout_duration_sec: 5.0 # ... for 5s
145
+ dvl_noise_stddev: 0.02 # velocity noise
146
+ dvl_emit_nan: false # or feed the estimator NaNs
147
+ ```
148
+
149
+ ## Build & run
150
+
151
+ ```bash
152
+ cd ~/ros2_ws/src && git clone <this repo> auv_sim_bench
153
+ cd ~/ros2_ws && colcon build --packages-select auv_sim_bench
154
+ source install/setup.bash
155
+
156
+ ros2 launch auv_sim_bench sim.launch.py # generic defaults
157
+ ros2 launch auv_sim_bench sim.launch.py rviz:=true # + 3D view
158
+ ros2 launch auv_sim_bench sim.launch.py config:=barracuda.yaml # Barracuda profile
159
+ # drive it by publishing geometry_msgs/Wrench on the wrench topic (default cmd_wrench)
160
+ ```
161
+
162
+ ### On a Jetson (AGX Orin)
163
+
164
+ Validated on a Jetson AGX Orin (JetPack 6 / ROS 2 Humble) — pure Python + numpy, so
165
+ no GPU or Isaac needed:
166
+
167
+ ```bash
168
+ mkdir -p ~/auv_ws/src
169
+ cp -r AUVSimBench ~/auv_ws/src/auv_sim_bench
170
+ cd ~/auv_ws && colcon build --packages-select auv_sim_bench
171
+ source /opt/ros/humble/setup.bash
172
+ source ~/auv_ws/install/setup.bash # both source lines, in every new terminal
173
+ ros2 launch auv_sim_bench sim.launch.py
174
+ ```
175
+
176
+ Headless over SSH there's no display for RViz, so drop `rviz:=true` and watch the
177
+ data instead (`ros2 topic echo /ground_truth`); driving it with a `/cmd_wrench`
178
+ publisher moves the body as expected. For the 3D view, run RViz on a monitor
179
+ attached to the Jetson.
180
+
181
+ Test your estimator: run the sim + your estimator, then compare
182
+ `/barracuda/estimated_pose` against `/auv_sim/ground_truth` — an accuracy check you
183
+ cannot get from a real dive (no underwater ground truth) or from bag replay.
184
+
185
+ ## Tests
186
+
187
+ The physics core (`dynamics.py`) is plain numpy with no ROS dependency, so it is
188
+ unit-tested directly — buoyancy, gravity, drag, thrust response, and current:
189
+
190
+ ```bash
191
+ pytest test/
192
+ ```
193
+
194
+ ## Credits & scope
195
+
196
+ Inspired by Leonardo Lima's [`isaac_underwater`](https://github.com/leonlime/isaac_underwater)
197
+ examples (MIT), which show buoyancy and drag in Isaac Sim. AUVSimBench takes that
198
+ idea in a different direction — its own 6-DOF dynamics in a standalone ROS 2 node, so
199
+ it runs anywhere ROS 2 does, including a Jetson.
200
+
201
+ It's a controls/estimation test rig, not a hydrodynamics research tool: drag and
202
+ inertia are simple tunable coefficients, not CFD. Added-mass and richer current
203
+ fields are natural next steps.
@@ -0,0 +1,186 @@
1
+ # AUVSimBench
2
+
3
+ A lightweight **physics twin** for autonomous underwater vehicles — a 6-DOF AUV
4
+ simulator as a single ROS 2 node, in pure Python/numpy. It runs the vehicle's
5
+ physics and publishes the **sensor topics a state estimator consumes, plus
6
+ ground-truth pose**, so you can develop and stress-test an underwater localization
7
+ or control stack **on the bench** — no water, no GPU, no Isaac Sim. It runs happily
8
+ on a Jetson.
9
+
10
+ ## Why this is hard (and why the twin helps)
11
+
12
+ Validating underwater state estimation is uniquely painful:
13
+
14
+ - **Wet tests are expensive and rare.** Pool/ocean time is slow to schedule, and you
15
+ get only a handful of runs before everyone's tired and the battery's flat.
16
+ - **There is no ground truth underwater.** No GPS below the surface — so even when you
17
+ *do* get a dive, you often can't measure *how wrong* the estimate was.
18
+ - **Most failures aren't about the water.** In practice the things that break a dive are
19
+ wiring (a node subscribed to the wrong topic), frame conventions (axes flipped),
20
+ startup ordering (a sensor not powered on), or an estimator diverging — and you
21
+ usually discover them *mid-dive*, burning a wet test to debug a typo.
22
+ - **Recorded bags only go so far.** A bag replays exactly one motion; you can't steer
23
+ it, you can't make a sensor fail on cue, and replayed DVL/depth won't agree with a
24
+ real IMU sitting still on the bench.
25
+
26
+ A **physics twin** answers all four. It simulates the vehicle's dynamics, so every
27
+ sensor stream is derived from *one consistent truth*; it *knows* the true pose, so
28
+ estimator accuracy is measurable; and you can script scenarios, disturbances, and
29
+ on-demand sensor failures — all from a laptop or the robot's own compute.
30
+
31
+ ```
32
+ wrench in ──► 6-DOF dynamics (thrust + buoyancy + drag + current) ──► IMU / DVL / depth + ground truth
33
+ ```
34
+
35
+ ## What it gives you
36
+
37
+ - **Consistent sensors** — IMU, DVL and depth all generated from the same simulated
38
+ body, so they physically agree (the thing bag-replay can't do).
39
+ - **Ground truth** — `/auv_sim/ground_truth` is the exact pose, so you can plot
40
+ estimator error directly.
41
+ - **Scenarios & disturbances** — water current, and on-demand sensor failures (DVL
42
+ dropout, noise, NaN) to probe estimator robustness — the exact failure modes that
43
+ bite in real wet tests.
44
+ - **Driven by your real controller** — it takes a `geometry_msgs/Wrench` with a deadman,
45
+ the same contract a PID/thruster-allocation stack already produces.
46
+
47
+ ## Physics model
48
+
49
+ An independent **6-DOF Newton-Euler** rigid body (FLU body frame, ENU world,
50
+ quaternion orientation):
51
+
52
+ - **Buoyancy** `ρ·V·g`, applied at a **center of buoyancy above the center of mass**, so
53
+ the vehicle self-rights — giving realistic roll/pitch, and therefore realistic IMU
54
+ output. Default displacement is exactly neutral.
55
+ - **Linear + quadratic drag** on both linear and angular velocity.
56
+ - **Water current** — drag acts on velocity *relative to the water*, so a current
57
+ actually pushes a passive vehicle (modeled properly, not faked through gravity).
58
+ - **Wrench input** in the body frame, with a 0.5 s command-timeout deadman.
59
+
60
+ The accelerometer output is the true specific force `(ΣF − gravity)/m` in the body
61
+ frame, so a level, neutrally-buoyant vehicle reads `az ≈ +9.81` — matching a real
62
+ ENU/FLU IMU.
63
+
64
+ ## Vehicle-agnostic
65
+
66
+ It is **not tied to any one vehicle.** Every topic name, frame, rate and physics
67
+ constant is a parameter, and the defaults are generic:
68
+
69
+ | published topic (default) | type | content |
70
+ |---------------------------|------|---------|
71
+ | `imu/data` | `sensor_msgs/Imu` | orientation, body angular velocity, specific-force accel |
72
+ | `dvl/odometry` | `nav_msgs/Odometry` | body-frame velocity |
73
+ | `dvl/altitude`, `ping/range` | `sensor_msgs/Range` | height above the floor |
74
+ | `pressure` | `sensor_msgs/FluidPressure` | from depth |
75
+ | `ground_truth` | `geometry_msgs/PoseStamped` | exact sim pose |
76
+
77
+ Adapt it to a specific vehicle with a **profile** — a small YAML that remaps the
78
+ topics/frames and sets the mass/buoyancy. `config/barracuda.yaml` is one example
79
+ (Barracuda's real `/barracuda/...` topics); write your own for any other AUV.
80
+
81
+ Profiles can also avoid hardcoded robot names by using placeholders in topic and
82
+ frame strings:
83
+
84
+ | placeholder | expands to |
85
+ |-------------|------------|
86
+ | `{topic_prefix}` | the normalized `topic_prefix` parameter, such as `/barracuda` |
87
+ | `{frame_prefix}` | the normalized `frame_prefix` parameter, such as `barracuda` |
88
+
89
+ For example, `wrench_topic: "{topic_prefix}/wrench"` becomes
90
+ `/barracuda/wrench` when `topic_prefix: /barracuda`. Leave both prefixes empty
91
+ for generic relative names.
92
+
93
+ Profiles can also tune the vehicle dynamics and scenario start state:
94
+
95
+ | parameter | meaning |
96
+ |-----------|---------|
97
+ | `inertia` | diagonal body inertia `[Ixx, Iyy, Izz]` |
98
+ | `cob_offset` | center of buoyancy relative to center of mass, body frame |
99
+ | `linear_drag`, `quadratic_drag` | per-axis translational drag coefficients |
100
+ | `angular_drag`, `angular_quadratic_drag` | per-axis rotational drag coefficients |
101
+ | `initial_position` | starting world position |
102
+ | `initial_orientation_wxyz` | starting orientation quaternion |
103
+ | `initial_linear_velocity`, `initial_angular_velocity` | starting body velocities |
104
+
105
+ ## See it in 3D
106
+
107
+ There's no built-in render, but it broadcasts **TF** and a **vehicle marker**, so it
108
+ shows up live in **RViz2** — which runs on a Jetson (plain OpenGL, no RTX/Isaac):
109
+
110
+ ```bash
111
+ ros2 launch auv_sim_bench sim.launch.py rviz:=true
112
+ ```
113
+
114
+ You get a 3D box for the vehicle flying around under thrust/current/buoyancy, the TF
115
+ frames, and — if you set `estimate_topic` to your estimator's pose — a second marker
116
+ for the **estimate**, so you can watch estimate-vs-truth drift in real time. It's
117
+ schematic (boxes + axes), not photoreal; photoreal water is the one thing that needs
118
+ Isaac Sim on an x86 box.
119
+
120
+ ## Failure & disturbance injection
121
+
122
+ For the real reason to simulate — breaking the estimator on purpose:
123
+
124
+ ```yaml
125
+ current: [0.3, 0.0, 0.0] # steady cross-current
126
+ dvl_dropout_start_sec: 10.0 # DVL goes silent at t=10s ...
127
+ dvl_dropout_duration_sec: 5.0 # ... for 5s
128
+ dvl_noise_stddev: 0.02 # velocity noise
129
+ dvl_emit_nan: false # or feed the estimator NaNs
130
+ ```
131
+
132
+ ## Build & run
133
+
134
+ ```bash
135
+ cd ~/ros2_ws/src && git clone <this repo> auv_sim_bench
136
+ cd ~/ros2_ws && colcon build --packages-select auv_sim_bench
137
+ source install/setup.bash
138
+
139
+ ros2 launch auv_sim_bench sim.launch.py # generic defaults
140
+ ros2 launch auv_sim_bench sim.launch.py rviz:=true # + 3D view
141
+ ros2 launch auv_sim_bench sim.launch.py config:=barracuda.yaml # Barracuda profile
142
+ # drive it by publishing geometry_msgs/Wrench on the wrench topic (default cmd_wrench)
143
+ ```
144
+
145
+ ### On a Jetson (AGX Orin)
146
+
147
+ Validated on a Jetson AGX Orin (JetPack 6 / ROS 2 Humble) — pure Python + numpy, so
148
+ no GPU or Isaac needed:
149
+
150
+ ```bash
151
+ mkdir -p ~/auv_ws/src
152
+ cp -r AUVSimBench ~/auv_ws/src/auv_sim_bench
153
+ cd ~/auv_ws && colcon build --packages-select auv_sim_bench
154
+ source /opt/ros/humble/setup.bash
155
+ source ~/auv_ws/install/setup.bash # both source lines, in every new terminal
156
+ ros2 launch auv_sim_bench sim.launch.py
157
+ ```
158
+
159
+ Headless over SSH there's no display for RViz, so drop `rviz:=true` and watch the
160
+ data instead (`ros2 topic echo /ground_truth`); driving it with a `/cmd_wrench`
161
+ publisher moves the body as expected. For the 3D view, run RViz on a monitor
162
+ attached to the Jetson.
163
+
164
+ Test your estimator: run the sim + your estimator, then compare
165
+ `/barracuda/estimated_pose` against `/auv_sim/ground_truth` — an accuracy check you
166
+ cannot get from a real dive (no underwater ground truth) or from bag replay.
167
+
168
+ ## Tests
169
+
170
+ The physics core (`dynamics.py`) is plain numpy with no ROS dependency, so it is
171
+ unit-tested directly — buoyancy, gravity, drag, thrust response, and current:
172
+
173
+ ```bash
174
+ pytest test/
175
+ ```
176
+
177
+ ## Credits & scope
178
+
179
+ Inspired by Leonardo Lima's [`isaac_underwater`](https://github.com/leonlime/isaac_underwater)
180
+ examples (MIT), which show buoyancy and drag in Isaac Sim. AUVSimBench takes that
181
+ idea in a different direction — its own 6-DOF dynamics in a standalone ROS 2 node, so
182
+ it runs anywhere ROS 2 does, including a Jetson.
183
+
184
+ It's a controls/estimation test rig, not a hydrodynamics research tool: drag and
185
+ inertia are simple tunable coefficients, not CFD. Added-mass and richer current
186
+ fields are natural next steps.
File without changes
@@ -0,0 +1,94 @@
1
+ """6-DOF rigid-body AUV dynamics (pure numpy, no ROS).
2
+
3
+ Body frame is FLU, world frame is ENU (z up). State is integrated in the body
4
+ frame with Newton-Euler; orientation as a wxyz quaternion.
5
+ """
6
+ import numpy as np
7
+
8
+
9
+ def quat_mul(a, b):
10
+ aw, ax, ay, az = a
11
+ bw, bx, by, bz = b
12
+ return np.array([
13
+ aw * bw - ax * bx - ay * by - az * bz,
14
+ aw * bx + ax * bw + ay * bz - az * by,
15
+ aw * by - ax * bz + ay * bw + az * bx,
16
+ aw * bz + ax * by - ay * bx + az * bw,
17
+ ])
18
+
19
+
20
+ def quat_normalize(q):
21
+ n = np.linalg.norm(q)
22
+ return q / n if n > 0 else np.array([1.0, 0.0, 0.0, 0.0])
23
+
24
+
25
+ def quat_to_rot(q):
26
+ """Body -> world rotation matrix for a wxyz quaternion."""
27
+ w, x, y, z = q
28
+ return np.array([
29
+ [1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
30
+ [2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
31
+ [2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)],
32
+ ])
33
+
34
+
35
+ class AUVParams:
36
+ def __init__(self):
37
+ self.mass = 15.0 # kg
38
+ self.water_density = 1025.0 # kg/m^3
39
+ self.gravity = 9.81 # m/s^2
40
+ # displaced volume; rho*V*g should be ~ weight for neutral buoyancy
41
+ self.volume = (self.mass) / self.water_density # exactly neutral by default
42
+ self.inertia = np.array([0.5, 0.8, 0.8]) # diag Ixx,Iyy,Izz
43
+ # center of buoyancy above the center of mass (body frame) -> righting moment
44
+ self.cob_offset = np.array([0.0, 0.0, 0.02])
45
+ # drag: F = -(lin * v + quad * v*|v|), per body axis
46
+ self.lin_drag = np.array([20.0, 40.0, 40.0])
47
+ self.quad_drag = np.array([30.0, 80.0, 80.0])
48
+ self.ang_drag = np.array([2.0, 4.0, 4.0])
49
+ self.ang_quad_drag = np.array([1.0, 2.0, 2.0])
50
+ # water current in the world frame (m/s); drag acts on velocity
51
+ # relative to the water, so a current pushes a passive vehicle.
52
+ self.current_world = np.zeros(3)
53
+
54
+
55
+ class AUVState:
56
+ def __init__(self):
57
+ self.p = np.zeros(3) # world position (ENU)
58
+ self.q = np.array([1.0, 0.0, 0.0, 0.0]) # world orientation (wxyz)
59
+ self.v = np.zeros(3) # body-frame linear velocity
60
+ self.w = np.zeros(3) # body-frame angular velocity
61
+
62
+
63
+ class AUVDynamics:
64
+ def __init__(self, params=None, state=None):
65
+ self.p = params or AUVParams()
66
+ self.s = state or AUVState()
67
+ self.specific_force = np.zeros(3) # what the accelerometer would read (body)
68
+
69
+ def step(self, dt, force_body, torque_body):
70
+ p, s = self.p, self.s
71
+ R = quat_to_rot(s.q)
72
+ Rt = R.T
73
+
74
+ g_body = Rt @ np.array([0.0, 0.0, -p.mass * p.gravity])
75
+ b_body = Rt @ np.array([0.0, 0.0, p.water_density * p.volume * p.gravity])
76
+
77
+ v_rel = s.v - Rt @ p.current_world # velocity relative to the water
78
+ drag_f = -(p.lin_drag * v_rel + p.quad_drag * v_rel * np.abs(v_rel))
79
+ drag_t = -(p.ang_drag * s.w + p.ang_quad_drag * s.w * np.abs(s.w))
80
+
81
+ F = np.asarray(force_body, float) + g_body + b_body + drag_f
82
+ T = np.asarray(torque_body, float) + np.cross(p.cob_offset, b_body) + drag_t
83
+
84
+ v_dot = F / p.mass - np.cross(s.w, s.v)
85
+ w_dot = (T - np.cross(s.w, p.inertia * s.w)) / p.inertia
86
+
87
+ s.v = s.v + v_dot * dt
88
+ s.w = s.w + w_dot * dt
89
+ s.q = quat_normalize(s.q + 0.5 * quat_mul(s.q, np.array([0.0, *s.w])) * dt)
90
+ s.p = s.p + R @ s.v * dt
91
+
92
+ # accelerometer specific force = (total - gravity) / m, in body frame
93
+ self.specific_force = (F - g_body) / p.mass
94
+ return s
@@ -0,0 +1,322 @@
1
+ import random
2
+
3
+ import numpy as np
4
+ import rclpy
5
+ from rclpy.node import Node
6
+ from geometry_msgs.msg import PoseStamped, TransformStamped, Wrench
7
+ from nav_msgs.msg import Odometry
8
+ from sensor_msgs.msg import FluidPressure, Imu, Range
9
+ from visualization_msgs.msg import Marker
10
+ from tf2_ros import StaticTransformBroadcaster, TransformBroadcaster
11
+
12
+ from .dynamics import AUVDynamics, AUVParams
13
+
14
+
15
+ class AUVSimNode(Node):
16
+ def __init__(self):
17
+ super().__init__("auv_sim_bench")
18
+
19
+ self.declare_parameter("sim_rate_hz", 200.0)
20
+ self.declare_parameter("imu_rate_hz", 100.0)
21
+ self.declare_parameter("dvl_rate_hz", 8.0)
22
+ self.declare_parameter("depth_rate_hz", 5.0)
23
+
24
+ # generic defaults; remap or load a profile (e.g. config/barracuda.yaml)
25
+ self.declare_parameter("topic_prefix", "")
26
+ self.declare_parameter("frame_prefix", "")
27
+ self.declare_parameter("wrench_topic", "cmd_wrench")
28
+ self.declare_parameter("imu_topic", "imu/data")
29
+ self.declare_parameter("dvl_odom_topic", "dvl/odometry")
30
+ self.declare_parameter("dvl_alt_topic", "dvl/altitude")
31
+ self.declare_parameter("ping_range_topic", "ping/range")
32
+ self.declare_parameter("pressure_topic", "pressure")
33
+ self.declare_parameter("truth_topic", "ground_truth")
34
+ self.declare_parameter("estimate_topic", "") # "" disables the estimate marker
35
+
36
+ self.declare_parameter("odom_frame", "odom")
37
+ self.declare_parameter("base_frame", "base_link")
38
+ self.declare_parameter("imu_frame", "imu_link")
39
+ self.declare_parameter("dvl_frame", "dvl_link")
40
+ self.declare_parameter("ping_frame", "ping_link")
41
+
42
+ self.declare_parameter("wrench_timeout_sec", 0.5)
43
+ self.declare_parameter("floor_z", -10.0)
44
+ self.declare_parameter("surface_z", 0.0)
45
+ self.declare_parameter("atm_pressure", 101325.0)
46
+
47
+ self.declare_parameter("mass", 15.0)
48
+ self.declare_parameter("volume", 0.0)
49
+ self.declare_parameter("water_density", 1025.0)
50
+ self.declare_parameter("current", [0.0, 0.0, 0.0])
51
+ self.declare_parameter("inertia", [0.5, 0.8, 0.8])
52
+ self.declare_parameter("cob_offset", [0.0, 0.0, 0.02])
53
+ self.declare_parameter("linear_drag", [20.0, 40.0, 40.0])
54
+ self.declare_parameter("quadratic_drag", [30.0, 80.0, 80.0])
55
+ self.declare_parameter("angular_drag", [2.0, 4.0, 4.0])
56
+ self.declare_parameter("angular_quadratic_drag", [1.0, 2.0, 2.0])
57
+ self.declare_parameter("initial_position", [0.0, 0.0, 0.0])
58
+ self.declare_parameter("initial_orientation_wxyz", [1.0, 0.0, 0.0, 0.0])
59
+ self.declare_parameter("initial_linear_velocity", [0.0, 0.0, 0.0])
60
+ self.declare_parameter("initial_angular_velocity", [0.0, 0.0, 0.0])
61
+
62
+ self.declare_parameter("publish_tf", True)
63
+ self.declare_parameter("publish_markers", True)
64
+ self.declare_parameter("body_size", [0.6, 0.35, 0.25])
65
+
66
+ self.declare_parameter("dvl_noise_stddev", 0.0)
67
+ self.declare_parameter("dvl_dropout_start_sec", -1.0)
68
+ self.declare_parameter("dvl_dropout_duration_sec", 0.0)
69
+ self.declare_parameter("dvl_emit_nan", False)
70
+ self.declare_parameter("seed", 0)
71
+
72
+ def param_value(name):
73
+ return self.get_parameter(name).value
74
+
75
+ def vec_param(name, size=3):
76
+ value = [float(x) for x in param_value(name)]
77
+ if len(value) != size:
78
+ raise ValueError(f"{name} must contain {size} values")
79
+ return value
80
+
81
+ def normalize_prefix(prefix):
82
+ prefix = str(prefix).strip().strip("/")
83
+ return f"/{prefix}" if prefix else ""
84
+
85
+ topic_prefix = normalize_prefix(param_value("topic_prefix"))
86
+ frame_prefix = str(param_value("frame_prefix")).strip().strip("/")
87
+
88
+ def expand_name(value):
89
+ return str(value).format(
90
+ topic_prefix=topic_prefix,
91
+ frame_prefix=frame_prefix,
92
+ )
93
+
94
+ self.odom_frame = expand_name(param_value("odom_frame"))
95
+ self.base_frame = expand_name(param_value("base_frame"))
96
+ self.imu_frame = expand_name(param_value("imu_frame"))
97
+ self.dvl_frame = expand_name(param_value("dvl_frame"))
98
+ self.ping_frame = expand_name(param_value("ping_frame"))
99
+ self.wrench_timeout = float(param_value("wrench_timeout_sec"))
100
+ self.floor_z = float(param_value("floor_z"))
101
+ self.surface_z = float(param_value("surface_z"))
102
+ self.atm = float(param_value("atm_pressure"))
103
+ self.publish_tf = bool(param_value("publish_tf"))
104
+ self.publish_markers = bool(param_value("publish_markers"))
105
+ self.body_size = [float(x) for x in param_value("body_size")]
106
+ self.dvl_noise = float(param_value("dvl_noise_stddev"))
107
+ self.dvl_drop_start = float(param_value("dvl_dropout_start_sec"))
108
+ self.dvl_drop_dur = float(param_value("dvl_dropout_duration_sec"))
109
+ self.dvl_nan = bool(param_value("dvl_emit_nan"))
110
+ self.rng = random.Random(int(param_value("seed")))
111
+
112
+ params = AUVParams()
113
+ params.mass = float(param_value("mass"))
114
+ params.water_density = float(param_value("water_density"))
115
+ vol = float(param_value("volume"))
116
+ params.volume = vol if vol > 0.0 else params.mass / params.water_density
117
+ params.current_world = np.array(vec_param("current"))
118
+ params.inertia = np.array(vec_param("inertia"))
119
+ params.cob_offset = np.array(vec_param("cob_offset"))
120
+ params.lin_drag = np.array(vec_param("linear_drag"))
121
+ params.quad_drag = np.array(vec_param("quadratic_drag"))
122
+ params.ang_drag = np.array(vec_param("angular_drag"))
123
+ params.ang_quad_drag = np.array(vec_param("angular_quadratic_drag"))
124
+ self.sim = AUVDynamics(params)
125
+ self.sim.s.p = np.array(vec_param("initial_position"))
126
+ self.sim.s.q = np.array(vec_param("initial_orientation_wxyz", 4))
127
+ self.sim.s.v = np.array(vec_param("initial_linear_velocity"))
128
+ self.sim.s.w = np.array(vec_param("initial_angular_velocity"))
129
+
130
+ self.force = np.zeros(3)
131
+ self.torque = np.zeros(3)
132
+ self.last_wrench_t = self.now()
133
+ self.estimate = None
134
+
135
+ self.create_subscription(Wrench, expand_name(param_value("wrench_topic")), self.on_wrench, 10)
136
+ self.imu_pub = self.create_publisher(Imu, expand_name(param_value("imu_topic")), 10)
137
+ self.dvl_pub = self.create_publisher(Odometry, expand_name(param_value("dvl_odom_topic")), 10)
138
+ self.alt_pub = self.create_publisher(Range, expand_name(param_value("dvl_alt_topic")), 10)
139
+ self.ping_pub = self.create_publisher(Range, expand_name(param_value("ping_range_topic")), 10)
140
+ self.press_pub = self.create_publisher(FluidPressure, expand_name(param_value("pressure_topic")), 10)
141
+ self.truth_pub = self.create_publisher(PoseStamped, expand_name(param_value("truth_topic")), 10)
142
+
143
+ if self.publish_tf:
144
+ self.tf_bc = TransformBroadcaster(self)
145
+ self.static_bc = StaticTransformBroadcaster(self)
146
+ self._publish_static_frames()
147
+ if self.publish_markers:
148
+ self.marker_pub = self.create_publisher(Marker, "auv_sim/markers", 10)
149
+ est = expand_name(param_value("estimate_topic"))
150
+ if est:
151
+ self.create_subscription(PoseStamped, est, self.on_estimate, 10)
152
+
153
+ self.sim_dt = 1.0 / float(param_value("sim_rate_hz"))
154
+ self.acc = {"imu": 0.0, "dvl": 0.0, "depth": 0.0}
155
+ self.rates = {
156
+ "imu": 1.0 / float(param_value("imu_rate_hz")),
157
+ "dvl": 1.0 / float(param_value("dvl_rate_hz")),
158
+ "depth": 1.0 / float(param_value("depth_rate_hz")),
159
+ }
160
+ self.elapsed = 0.0
161
+ self.create_timer(self.sim_dt, self.step)
162
+ self.get_logger().info(
163
+ f"AUV sim bench @ {param_value('sim_rate_hz')} Hz, neutral V={params.volume:.4f} m^3; "
164
+ f"rviz fixed frame = {self.odom_frame}"
165
+ )
166
+
167
+ def now(self):
168
+ return self.get_clock().now().nanoseconds * 1e-9
169
+
170
+ def on_wrench(self, msg):
171
+ self.force = np.array([msg.force.x, msg.force.y, msg.force.z])
172
+ self.torque = np.array([msg.torque.x, msg.torque.y, msg.torque.z])
173
+ self.last_wrench_t = self.now()
174
+
175
+ def on_estimate(self, msg):
176
+ self.estimate = msg
177
+
178
+ def step(self):
179
+ if self.now() - self.last_wrench_t > self.wrench_timeout:
180
+ self.force = np.zeros(3)
181
+ self.torque = np.zeros(3)
182
+
183
+ s = self.sim.step(self.sim_dt, self.force, self.torque)
184
+ self.elapsed += self.sim_dt
185
+ for k in self.acc:
186
+ self.acc[k] += self.sim_dt
187
+ stamp = self.get_clock().now().to_msg()
188
+
189
+ if self.acc["imu"] >= self.rates["imu"]:
190
+ self.acc["imu"] = 0.0
191
+ self.publish_imu(stamp, s)
192
+ self.publish_truth(stamp, s)
193
+ if self.acc["dvl"] >= self.rates["dvl"]:
194
+ self.acc["dvl"] = 0.0
195
+ self.publish_dvl(stamp, s)
196
+ if self.acc["depth"] >= self.rates["depth"]:
197
+ self.acc["depth"] = 0.0
198
+ self.publish_depth(stamp, s)
199
+
200
+ def publish_imu(self, stamp, s):
201
+ f = self.sim.specific_force
202
+ msg = Imu()
203
+ msg.header.stamp = stamp
204
+ msg.header.frame_id = self.imu_frame
205
+ msg.orientation.w, msg.orientation.x, msg.orientation.y, msg.orientation.z = s.q
206
+ msg.angular_velocity.x, msg.angular_velocity.y, msg.angular_velocity.z = s.w
207
+ msg.linear_acceleration.x, msg.linear_acceleration.y, msg.linear_acceleration.z = f
208
+ self.imu_pub.publish(msg)
209
+
210
+ def publish_dvl(self, stamp, s):
211
+ if self.dvl_drop_start >= 0.0 and (
212
+ self.dvl_drop_start <= self.elapsed < self.dvl_drop_start + self.dvl_drop_dur
213
+ ):
214
+ return
215
+ msg = Odometry()
216
+ msg.header.stamp = stamp
217
+ msg.header.frame_id = self.odom_frame
218
+ msg.child_frame_id = self.dvl_frame
219
+ if self.dvl_nan:
220
+ vx = vy = vz = float("nan")
221
+ else:
222
+ def noise():
223
+ return self.rng.gauss(0.0, self.dvl_noise) if self.dvl_noise > 0 else 0.0
224
+
225
+ vx, vy, vz = s.v[0] + noise(), s.v[1] + noise(), s.v[2] + noise()
226
+ msg.twist.twist.linear.x = vx
227
+ msg.twist.twist.linear.y = vy
228
+ msg.twist.twist.linear.z = vz
229
+ self.dvl_pub.publish(msg)
230
+
231
+ def publish_depth(self, stamp, s):
232
+ altitude = float(s.p[2] - self.floor_z)
233
+ for pub, frame in ((self.alt_pub, self.dvl_frame), (self.ping_pub, self.ping_frame)):
234
+ r = Range()
235
+ r.header.stamp = stamp
236
+ r.header.frame_id = frame
237
+ r.radiation_type = Range.ULTRASOUND
238
+ r.min_range = 0.1
239
+ r.max_range = 100.0
240
+ r.range = altitude
241
+ pub.publish(r)
242
+
243
+ depth = max(0.0, self.surface_z - float(s.p[2]))
244
+ pmsg = FluidPressure()
245
+ pmsg.header.stamp = stamp
246
+ pmsg.header.frame_id = self.imu_frame
247
+ pmsg.fluid_pressure = self.atm + self.sim.p.water_density * self.sim.p.gravity * depth
248
+ self.press_pub.publish(pmsg)
249
+
250
+ def publish_truth(self, stamp, s):
251
+ msg = PoseStamped()
252
+ msg.header.stamp = stamp
253
+ msg.header.frame_id = self.odom_frame
254
+ msg.pose.position.x, msg.pose.position.y, msg.pose.position.z = s.p
255
+ msg.pose.orientation.w, msg.pose.orientation.x, msg.pose.orientation.y, msg.pose.orientation.z = s.q
256
+ self.truth_pub.publish(msg)
257
+
258
+ if self.publish_tf:
259
+ tf = TransformStamped()
260
+ tf.header.stamp = stamp
261
+ tf.header.frame_id = self.odom_frame
262
+ tf.child_frame_id = self.base_frame
263
+ tf.transform.translation.x, tf.transform.translation.y, tf.transform.translation.z = s.p
264
+ tf.transform.rotation.w, tf.transform.rotation.x, tf.transform.rotation.y, tf.transform.rotation.z = s.q
265
+ self.tf_bc.sendTransform(tf)
266
+
267
+ if self.publish_markers:
268
+ self._publish_body_marker(stamp)
269
+ if self.estimate is not None:
270
+ self._publish_estimate_marker(stamp)
271
+
272
+ def _publish_static_frames(self):
273
+ tfs = []
274
+ for child in (self.imu_frame, self.dvl_frame, self.ping_frame):
275
+ tf = TransformStamped()
276
+ tf.header.stamp = self.get_clock().now().to_msg()
277
+ tf.header.frame_id = self.base_frame
278
+ tf.child_frame_id = child
279
+ tf.transform.rotation.w = 1.0
280
+ tfs.append(tf)
281
+ self.static_bc.sendTransform(tfs)
282
+
283
+ def _publish_body_marker(self, stamp):
284
+ m = Marker()
285
+ m.header.stamp = stamp
286
+ m.header.frame_id = self.base_frame
287
+ m.ns = "auv"
288
+ m.id = 0
289
+ m.type = Marker.CUBE
290
+ m.action = Marker.ADD
291
+ m.pose.orientation.w = 1.0
292
+ m.scale.x, m.scale.y, m.scale.z = self.body_size
293
+ m.color.r, m.color.g, m.color.b, m.color.a = 0.2, 0.5, 1.0, 0.85
294
+ self.marker_pub.publish(m)
295
+
296
+ def _publish_estimate_marker(self, stamp):
297
+ m = Marker()
298
+ m.header.stamp = stamp
299
+ m.header.frame_id = self.odom_frame
300
+ m.ns = "estimate"
301
+ m.id = 1
302
+ m.type = Marker.SPHERE
303
+ m.action = Marker.ADD
304
+ m.pose = self.estimate.pose
305
+ m.scale.x = m.scale.y = m.scale.z = 0.25
306
+ m.color.r, m.color.g, m.color.b, m.color.a = 0.2, 1.0, 0.3, 0.9
307
+ self.marker_pub.publish(m)
308
+
309
+
310
+ def main():
311
+ rclpy.init()
312
+ node = AUVSimNode()
313
+ try:
314
+ rclpy.spin(node)
315
+ except KeyboardInterrupt:
316
+ pass
317
+ node.destroy_node()
318
+ rclpy.shutdown()
319
+
320
+
321
+ if __name__ == "__main__":
322
+ main()
@@ -0,0 +1,203 @@
1
+ Metadata-Version: 2.4
2
+ Name: auv_sim_bench
3
+ Version: 0.1.0
4
+ Summary: Lightweight 6-DOF AUV physics sim for dry-testing state estimation.
5
+ Maintainer: wuisabel-gif
6
+ Maintainer-email: 231155141+wuisabel-gif@users.noreply.github.com
7
+ License: MIT
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: setuptools
10
+ Dynamic: description
11
+ Dynamic: description-content-type
12
+ Dynamic: license
13
+ Dynamic: maintainer
14
+ Dynamic: maintainer-email
15
+ Dynamic: requires-dist
16
+ Dynamic: summary
17
+
18
+ # AUVSimBench
19
+
20
+ A lightweight **physics twin** for autonomous underwater vehicles — a 6-DOF AUV
21
+ simulator as a single ROS 2 node, in pure Python/numpy. It runs the vehicle's
22
+ physics and publishes the **sensor topics a state estimator consumes, plus
23
+ ground-truth pose**, so you can develop and stress-test an underwater localization
24
+ or control stack **on the bench** — no water, no GPU, no Isaac Sim. It runs happily
25
+ on a Jetson.
26
+
27
+ ## Why this is hard (and why the twin helps)
28
+
29
+ Validating underwater state estimation is uniquely painful:
30
+
31
+ - **Wet tests are expensive and rare.** Pool/ocean time is slow to schedule, and you
32
+ get only a handful of runs before everyone's tired and the battery's flat.
33
+ - **There is no ground truth underwater.** No GPS below the surface — so even when you
34
+ *do* get a dive, you often can't measure *how wrong* the estimate was.
35
+ - **Most failures aren't about the water.** In practice the things that break a dive are
36
+ wiring (a node subscribed to the wrong topic), frame conventions (axes flipped),
37
+ startup ordering (a sensor not powered on), or an estimator diverging — and you
38
+ usually discover them *mid-dive*, burning a wet test to debug a typo.
39
+ - **Recorded bags only go so far.** A bag replays exactly one motion; you can't steer
40
+ it, you can't make a sensor fail on cue, and replayed DVL/depth won't agree with a
41
+ real IMU sitting still on the bench.
42
+
43
+ A **physics twin** answers all four. It simulates the vehicle's dynamics, so every
44
+ sensor stream is derived from *one consistent truth*; it *knows* the true pose, so
45
+ estimator accuracy is measurable; and you can script scenarios, disturbances, and
46
+ on-demand sensor failures — all from a laptop or the robot's own compute.
47
+
48
+ ```
49
+ wrench in ──► 6-DOF dynamics (thrust + buoyancy + drag + current) ──► IMU / DVL / depth + ground truth
50
+ ```
51
+
52
+ ## What it gives you
53
+
54
+ - **Consistent sensors** — IMU, DVL and depth all generated from the same simulated
55
+ body, so they physically agree (the thing bag-replay can't do).
56
+ - **Ground truth** — `/auv_sim/ground_truth` is the exact pose, so you can plot
57
+ estimator error directly.
58
+ - **Scenarios & disturbances** — water current, and on-demand sensor failures (DVL
59
+ dropout, noise, NaN) to probe estimator robustness — the exact failure modes that
60
+ bite in real wet tests.
61
+ - **Driven by your real controller** — it takes a `geometry_msgs/Wrench` with a deadman,
62
+ the same contract a PID/thruster-allocation stack already produces.
63
+
64
+ ## Physics model
65
+
66
+ An independent **6-DOF Newton-Euler** rigid body (FLU body frame, ENU world,
67
+ quaternion orientation):
68
+
69
+ - **Buoyancy** `ρ·V·g`, applied at a **center of buoyancy above the center of mass**, so
70
+ the vehicle self-rights — giving realistic roll/pitch, and therefore realistic IMU
71
+ output. Default displacement is exactly neutral.
72
+ - **Linear + quadratic drag** on both linear and angular velocity.
73
+ - **Water current** — drag acts on velocity *relative to the water*, so a current
74
+ actually pushes a passive vehicle (modeled properly, not faked through gravity).
75
+ - **Wrench input** in the body frame, with a 0.5 s command-timeout deadman.
76
+
77
+ The accelerometer output is the true specific force `(ΣF − gravity)/m` in the body
78
+ frame, so a level, neutrally-buoyant vehicle reads `az ≈ +9.81` — matching a real
79
+ ENU/FLU IMU.
80
+
81
+ ## Vehicle-agnostic
82
+
83
+ It is **not tied to any one vehicle.** Every topic name, frame, rate and physics
84
+ constant is a parameter, and the defaults are generic:
85
+
86
+ | published topic (default) | type | content |
87
+ |---------------------------|------|---------|
88
+ | `imu/data` | `sensor_msgs/Imu` | orientation, body angular velocity, specific-force accel |
89
+ | `dvl/odometry` | `nav_msgs/Odometry` | body-frame velocity |
90
+ | `dvl/altitude`, `ping/range` | `sensor_msgs/Range` | height above the floor |
91
+ | `pressure` | `sensor_msgs/FluidPressure` | from depth |
92
+ | `ground_truth` | `geometry_msgs/PoseStamped` | exact sim pose |
93
+
94
+ Adapt it to a specific vehicle with a **profile** — a small YAML that remaps the
95
+ topics/frames and sets the mass/buoyancy. `config/barracuda.yaml` is one example
96
+ (Barracuda's real `/barracuda/...` topics); write your own for any other AUV.
97
+
98
+ Profiles can also avoid hardcoded robot names by using placeholders in topic and
99
+ frame strings:
100
+
101
+ | placeholder | expands to |
102
+ |-------------|------------|
103
+ | `{topic_prefix}` | the normalized `topic_prefix` parameter, such as `/barracuda` |
104
+ | `{frame_prefix}` | the normalized `frame_prefix` parameter, such as `barracuda` |
105
+
106
+ For example, `wrench_topic: "{topic_prefix}/wrench"` becomes
107
+ `/barracuda/wrench` when `topic_prefix: /barracuda`. Leave both prefixes empty
108
+ for generic relative names.
109
+
110
+ Profiles can also tune the vehicle dynamics and scenario start state:
111
+
112
+ | parameter | meaning |
113
+ |-----------|---------|
114
+ | `inertia` | diagonal body inertia `[Ixx, Iyy, Izz]` |
115
+ | `cob_offset` | center of buoyancy relative to center of mass, body frame |
116
+ | `linear_drag`, `quadratic_drag` | per-axis translational drag coefficients |
117
+ | `angular_drag`, `angular_quadratic_drag` | per-axis rotational drag coefficients |
118
+ | `initial_position` | starting world position |
119
+ | `initial_orientation_wxyz` | starting orientation quaternion |
120
+ | `initial_linear_velocity`, `initial_angular_velocity` | starting body velocities |
121
+
122
+ ## See it in 3D
123
+
124
+ There's no built-in render, but it broadcasts **TF** and a **vehicle marker**, so it
125
+ shows up live in **RViz2** — which runs on a Jetson (plain OpenGL, no RTX/Isaac):
126
+
127
+ ```bash
128
+ ros2 launch auv_sim_bench sim.launch.py rviz:=true
129
+ ```
130
+
131
+ You get a 3D box for the vehicle flying around under thrust/current/buoyancy, the TF
132
+ frames, and — if you set `estimate_topic` to your estimator's pose — a second marker
133
+ for the **estimate**, so you can watch estimate-vs-truth drift in real time. It's
134
+ schematic (boxes + axes), not photoreal; photoreal water is the one thing that needs
135
+ Isaac Sim on an x86 box.
136
+
137
+ ## Failure & disturbance injection
138
+
139
+ For the real reason to simulate — breaking the estimator on purpose:
140
+
141
+ ```yaml
142
+ current: [0.3, 0.0, 0.0] # steady cross-current
143
+ dvl_dropout_start_sec: 10.0 # DVL goes silent at t=10s ...
144
+ dvl_dropout_duration_sec: 5.0 # ... for 5s
145
+ dvl_noise_stddev: 0.02 # velocity noise
146
+ dvl_emit_nan: false # or feed the estimator NaNs
147
+ ```
148
+
149
+ ## Build & run
150
+
151
+ ```bash
152
+ cd ~/ros2_ws/src && git clone <this repo> auv_sim_bench
153
+ cd ~/ros2_ws && colcon build --packages-select auv_sim_bench
154
+ source install/setup.bash
155
+
156
+ ros2 launch auv_sim_bench sim.launch.py # generic defaults
157
+ ros2 launch auv_sim_bench sim.launch.py rviz:=true # + 3D view
158
+ ros2 launch auv_sim_bench sim.launch.py config:=barracuda.yaml # Barracuda profile
159
+ # drive it by publishing geometry_msgs/Wrench on the wrench topic (default cmd_wrench)
160
+ ```
161
+
162
+ ### On a Jetson (AGX Orin)
163
+
164
+ Validated on a Jetson AGX Orin (JetPack 6 / ROS 2 Humble) — pure Python + numpy, so
165
+ no GPU or Isaac needed:
166
+
167
+ ```bash
168
+ mkdir -p ~/auv_ws/src
169
+ cp -r AUVSimBench ~/auv_ws/src/auv_sim_bench
170
+ cd ~/auv_ws && colcon build --packages-select auv_sim_bench
171
+ source /opt/ros/humble/setup.bash
172
+ source ~/auv_ws/install/setup.bash # both source lines, in every new terminal
173
+ ros2 launch auv_sim_bench sim.launch.py
174
+ ```
175
+
176
+ Headless over SSH there's no display for RViz, so drop `rviz:=true` and watch the
177
+ data instead (`ros2 topic echo /ground_truth`); driving it with a `/cmd_wrench`
178
+ publisher moves the body as expected. For the 3D view, run RViz on a monitor
179
+ attached to the Jetson.
180
+
181
+ Test your estimator: run the sim + your estimator, then compare
182
+ `/barracuda/estimated_pose` against `/auv_sim/ground_truth` — an accuracy check you
183
+ cannot get from a real dive (no underwater ground truth) or from bag replay.
184
+
185
+ ## Tests
186
+
187
+ The physics core (`dynamics.py`) is plain numpy with no ROS dependency, so it is
188
+ unit-tested directly — buoyancy, gravity, drag, thrust response, and current:
189
+
190
+ ```bash
191
+ pytest test/
192
+ ```
193
+
194
+ ## Credits & scope
195
+
196
+ Inspired by Leonardo Lima's [`isaac_underwater`](https://github.com/leonlime/isaac_underwater)
197
+ examples (MIT), which show buoyancy and drag in Isaac Sim. AUVSimBench takes that
198
+ idea in a different direction — its own 6-DOF dynamics in a standalone ROS 2 node, so
199
+ it runs anywhere ROS 2 does, including a Jetson.
200
+
201
+ It's a controls/estimation test rig, not a hydrodynamics research tool: drag and
202
+ inertia are simple tunable coefficients, not CFD. Added-mass and richer current
203
+ fields are natural next steps.
@@ -0,0 +1,20 @@
1
+ README.md
2
+ package.xml
3
+ setup.cfg
4
+ setup.py
5
+ auv_sim_bench/__init__.py
6
+ auv_sim_bench/dynamics.py
7
+ auv_sim_bench/sim_node.py
8
+ auv_sim_bench.egg-info/PKG-INFO
9
+ auv_sim_bench.egg-info/SOURCES.txt
10
+ auv_sim_bench.egg-info/dependency_links.txt
11
+ auv_sim_bench.egg-info/entry_points.txt
12
+ auv_sim_bench.egg-info/requires.txt
13
+ auv_sim_bench.egg-info/top_level.txt
14
+ auv_sim_bench.egg-info/zip-safe
15
+ config/barracuda.yaml
16
+ config/sim.yaml
17
+ launch/sim.launch.py
18
+ resource/auv_sim_bench
19
+ rviz/auv_sim.rviz
20
+ test/test_dynamics.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sim = auv_sim_bench.sim_node:main
@@ -0,0 +1 @@
1
+ setuptools
@@ -0,0 +1 @@
1
+ auv_sim_bench
@@ -0,0 +1,21 @@
1
+ # Barracuda profile: maps the generic sim onto Barracuda's real topic + frame names.
2
+ # Run with: ros2 launch auv_sim_bench sim.launch.py config:=barracuda.yaml
3
+ auv_sim_bench:
4
+ ros__parameters:
5
+ wrench_topic: /barracuda/wrench
6
+ imu_topic: /barracuda/vectornav/imu
7
+ dvl_odom_topic: /barracuda/dvl/odometry
8
+ dvl_alt_topic: /barracuda/dvl/altitude
9
+ ping_range_topic: /barracuda/ping1d/range
10
+ pressure_topic: /barracuda/vectornav/pressure
11
+ truth_topic: /auv_sim/ground_truth
12
+ estimate_topic: /barracuda/estimated_pose
13
+
14
+ odom_frame: odom
15
+ base_frame: barracuda/base_link
16
+ imu_frame: barracuda/imu_link
17
+ dvl_frame: barracuda/dvl_link
18
+ ping_frame: barracuda/ping1d_link
19
+
20
+ mass: 15.0
21
+ water_density: 1025.0
@@ -0,0 +1,56 @@
1
+ auv_sim_bench:
2
+ ros__parameters:
3
+ sim_rate_hz: 200.0
4
+ imu_rate_hz: 100.0
5
+ dvl_rate_hz: 8.0
6
+ depth_rate_hz: 5.0
7
+
8
+ # generic topic names (load config/barracuda.yaml for the Barracuda profile)
9
+ topic_prefix: ""
10
+ frame_prefix: ""
11
+ wrench_topic: cmd_wrench
12
+ imu_topic: imu/data
13
+ dvl_odom_topic: dvl/odometry
14
+ dvl_alt_topic: dvl/altitude
15
+ ping_range_topic: ping/range
16
+ pressure_topic: pressure
17
+ truth_topic: ground_truth
18
+ estimate_topic: "" # set to your estimator's pose topic to show it in rviz
19
+
20
+ odom_frame: odom
21
+ base_frame: base_link
22
+ imu_frame: imu_link
23
+ dvl_frame: dvl_link
24
+ ping_frame: ping_link
25
+
26
+ wrench_timeout_sec: 0.5
27
+ floor_z: -10.0
28
+ surface_z: 0.0
29
+ atm_pressure: 101325.0
30
+
31
+ mass: 15.0
32
+ volume: 0.0 # 0 -> neutral buoyancy (mass / water_density)
33
+ water_density: 1025.0
34
+ current: [0.0, 0.0, 0.0] # world-frame water current (m/s)
35
+ inertia: [0.5, 0.8, 0.8] # diagonal Ixx,Iyy,Izz (kg*m^2)
36
+ cob_offset: [0.0, 0.0, 0.02] # center of buoyancy relative to COM, body frame (m)
37
+ linear_drag: [20.0, 40.0, 40.0]
38
+ quadratic_drag: [30.0, 80.0, 80.0]
39
+ angular_drag: [2.0, 4.0, 4.0]
40
+ angular_quadratic_drag: [1.0, 2.0, 2.0]
41
+
42
+ initial_position: [0.0, 0.0, 0.0]
43
+ initial_orientation_wxyz: [1.0, 0.0, 0.0, 0.0]
44
+ initial_linear_velocity: [0.0, 0.0, 0.0]
45
+ initial_angular_velocity: [0.0, 0.0, 0.0]
46
+
47
+ publish_tf: true
48
+ publish_markers: true
49
+ body_size: [0.6, 0.35, 0.25] # rviz box dimensions (m)
50
+
51
+ # failure injection (off by default)
52
+ dvl_noise_stddev: 0.0
53
+ dvl_dropout_start_sec: -1.0
54
+ dvl_dropout_duration_sec: 0.0
55
+ dvl_emit_nan: false
56
+ seed: 0
@@ -0,0 +1,40 @@
1
+ import os
2
+
3
+ from ament_index_python.packages import get_package_share_directory
4
+ from launch import LaunchDescription
5
+ from launch.actions import DeclareLaunchArgument
6
+ from launch.conditions import IfCondition
7
+ from launch.substitutions import LaunchConfiguration, PathJoinSubstitution
8
+ from launch_ros.actions import Node
9
+ from launch_ros.substitutions import FindPackageShare
10
+
11
+
12
+ def generate_launch_description():
13
+ share = get_package_share_directory("auv_sim_bench")
14
+ config_arg = DeclareLaunchArgument("config", default_value="sim.yaml")
15
+ rviz_arg = DeclareLaunchArgument("rviz", default_value="false")
16
+
17
+ config = PathJoinSubstitution([share, "config", LaunchConfiguration("config")])
18
+ rviz_cfg = PathJoinSubstitution(
19
+ [FindPackageShare("auv_sim_bench"), "rviz", "auv_sim.rviz"]
20
+ )
21
+
22
+ return LaunchDescription([
23
+ config_arg,
24
+ rviz_arg,
25
+ Node(
26
+ package="auv_sim_bench",
27
+ executable="sim",
28
+ name="auv_sim_bench",
29
+ parameters=[config],
30
+ output="screen",
31
+ ),
32
+ Node(
33
+ package="rviz2",
34
+ executable="rviz2",
35
+ name="rviz2",
36
+ arguments=["-d", rviz_cfg],
37
+ condition=IfCondition(LaunchConfiguration("rviz")),
38
+ output="screen",
39
+ ),
40
+ ])
@@ -0,0 +1,23 @@
1
+ <?xml version="1.0"?>
2
+ <?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
3
+ <package format="3">
4
+ <name>auv_sim_bench</name>
5
+ <version>0.1.0</version>
6
+ <description>Lightweight 6-DOF AUV physics simulator that publishes consistent IMU, DVL, depth and ground-truth topics for dry-testing underwater state estimation. Pure ROS 2 / numpy, runs on a Jetson.</description>
7
+ <maintainer email="231155141+wuisabel-gif@users.noreply.github.com">wuisabel-gif</maintainer>
8
+ <license>MIT</license>
9
+
10
+ <exec_depend>rclpy</exec_depend>
11
+ <exec_depend>geometry_msgs</exec_depend>
12
+ <exec_depend>nav_msgs</exec_depend>
13
+ <exec_depend>sensor_msgs</exec_depend>
14
+ <exec_depend>visualization_msgs</exec_depend>
15
+ <exec_depend>tf2_ros</exec_depend>
16
+ <exec_depend>python3-numpy</exec_depend>
17
+
18
+ <test_depend>python3-pytest</test_depend>
19
+
20
+ <export>
21
+ <build_type>ament_python</build_type>
22
+ </export>
23
+ </package>
File without changes
@@ -0,0 +1,46 @@
1
+ Panels:
2
+ - Class: rviz_common/Displays
3
+ Name: Displays
4
+ Visualization Manager:
5
+ Global Options:
6
+ Fixed Frame: odom
7
+ Background Color: 20; 30; 40
8
+ Displays:
9
+ - Class: rviz_default_plugins/Grid
10
+ Name: Grid
11
+ Enabled: true
12
+ Plane: XY
13
+ Cell Size: 1
14
+ Plane Cell Count: 20
15
+ - Class: rviz_default_plugins/TF
16
+ Name: TF
17
+ Enabled: true
18
+ Show Names: true
19
+ Show Axes: true
20
+ Marker Scale: 1
21
+ - Class: rviz_default_plugins/Marker
22
+ Name: Vehicle
23
+ Enabled: true
24
+ Topic:
25
+ Value: /auv_sim/markers
26
+ Depth: 5
27
+ Reliability Policy: Reliable
28
+ - Class: rviz_default_plugins/Odometry
29
+ Name: DVL odometry
30
+ Enabled: false
31
+ Topic:
32
+ Value: /dvl/odometry
33
+ Tools:
34
+ - Class: rviz_default_plugins/MoveCamera
35
+ - Class: rviz_default_plugins/FocusCamera
36
+ Views:
37
+ Current:
38
+ Class: rviz_default_plugins/Orbit
39
+ Name: Current View
40
+ Distance: 12
41
+ Pitch: 0.4
42
+ Yaw: 0.8
43
+ Focal Point:
44
+ X: 0
45
+ Y: 0
46
+ Z: 0
@@ -0,0 +1,10 @@
1
+ [develop]
2
+ script_dir = $base/lib/auv_sim_bench
3
+
4
+ [install]
5
+ install_scripts = $base/lib/auv_sim_bench
6
+
7
+ [egg_info]
8
+ tag_build =
9
+ tag_date = 0
10
+
@@ -0,0 +1,33 @@
1
+ import os
2
+ from glob import glob
3
+ from pathlib import Path
4
+
5
+ from setuptools import setup
6
+
7
+ package_name = "auv_sim_bench"
8
+
9
+ setup(
10
+ name=package_name,
11
+ version="0.1.0",
12
+ packages=[package_name],
13
+ data_files=[
14
+ ("share/ament_index/resource_index/packages", ["resource/" + package_name]),
15
+ ("share/" + package_name, ["package.xml"]),
16
+ (os.path.join("share", package_name, "launch"), glob("launch/*.launch.py")),
17
+ (os.path.join("share", package_name, "config"), glob("config/*.yaml")),
18
+ (os.path.join("share", package_name, "rviz"), glob("rviz/*.rviz")),
19
+ ],
20
+ install_requires=["setuptools"],
21
+ zip_safe=True,
22
+ maintainer="wuisabel-gif",
23
+ maintainer_email="231155141+wuisabel-gif@users.noreply.github.com",
24
+ description="Lightweight 6-DOF AUV physics sim for dry-testing state estimation.",
25
+ long_description=Path("README.md").read_text(encoding="utf-8"),
26
+ long_description_content_type="text/markdown",
27
+ license="MIT",
28
+ entry_points={
29
+ "console_scripts": [
30
+ "sim = auv_sim_bench.sim_node:main",
31
+ ],
32
+ },
33
+ )
@@ -0,0 +1,46 @@
1
+ import numpy as np
2
+
3
+ from auv_sim_bench.dynamics import AUVDynamics, AUVParams
4
+
5
+
6
+ def test_neutral_buoyancy_hovers():
7
+ sim = AUVDynamics(AUVParams()) # default volume = neutral
8
+ for _ in range(2000):
9
+ sim.step(0.005, [0, 0, 0], [0, 0, 0])
10
+ assert abs(sim.s.p[2]) < 0.05 # stays near its starting depth
11
+
12
+
13
+ def test_accelerometer_reads_gravity_at_rest():
14
+ sim = AUVDynamics(AUVParams())
15
+ sim.step(0.005, [0, 0, 0], [0, 0, 0])
16
+ # level, neutrally buoyant: specific force is +g on body z
17
+ assert sim.specific_force[2] > 9.0
18
+ assert abs(sim.specific_force[0]) < 0.1
19
+ assert abs(sim.specific_force[1]) < 0.1
20
+
21
+
22
+ def test_negative_buoyancy_sinks():
23
+ p = AUVParams()
24
+ p.volume = p.mass / p.water_density * 0.9 # under-displaced -> heavy
25
+ sim = AUVDynamics(p)
26
+ for _ in range(2000):
27
+ sim.step(0.005, [0, 0, 0], [0, 0, 0])
28
+ assert sim.s.p[2] < -0.1 # sinks
29
+
30
+
31
+ def test_forward_thrust_moves_and_drag_limits_speed():
32
+ sim = AUVDynamics(AUVParams())
33
+ for _ in range(4000):
34
+ sim.step(0.005, [40, 0, 0], [0, 0, 0]) # steady forward force
35
+ assert sim.s.p[0] > 0.5 # moved forward
36
+ assert sim.s.v[0] < 5.0 # drag bounded the speed
37
+
38
+
39
+ def test_current_pushes_passive_vehicle():
40
+ p = AUVParams()
41
+ p.current_world = np.array([0.5, 0.0, 0.0]) # 0.5 m/s current along +x
42
+ sim = AUVDynamics(p)
43
+ for _ in range(4000):
44
+ sim.step(0.005, [0, 0, 0], [0, 0, 0]) # no thrust
45
+ assert sim.s.p[0] > 0.2 # carried downstream
46
+ assert sim.s.v[0] > 0.1 # drifting toward the water velocity