Skip to content

Commit 3cd5791

Browse files
authored
Compute and plot contact forces between the floor and the foot (#5)
This PR adds a function to compute the contact forces between the floor and the right and left foot. It also plots the value of those forces computed during simulation. This a required feature to build contact detection.
1 parent 4a941ff commit 3cd5791

4 files changed

Lines changed: 108 additions & 56 deletions

File tree

biped_walking_controller/model.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,5 +113,14 @@ def get_joint_id(self, name):
113113

114114
return self.model.joints[jid].idx_q if jid < n_joints else None
115115

116-
def get_locked_joints_idx(self):
116+
@staticmethod
117+
def get_locked_joints_idx():
117118
return range(14, 46)
119+
120+
@staticmethod
121+
def get_rf_link_name():
122+
return "leg_right_6_link"
123+
124+
@staticmethod
125+
def get_lf_link_name():
126+
return "leg_left_6_link"

biped_walking_controller/plot.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,4 +185,19 @@ def traj2d(arr): # drop last 2 samples as in your code
185185
ax2.legend(loc="center left", bbox_to_anchor=(1, 0.5))
186186
ax2.set_title(f"{title_prefix} — plan view (x–y)")
187187

188-
plt.show()
188+
189+
def plot_contact_forces(t, force_rf, force_lf, title="Contact force Fx"):
190+
t = np.asarray(t).ravel()
191+
force_rf = np.asarray(force_rf).ravel()
192+
force_lf = np.asarray(force_lf).ravel()
193+
assert t.size == force_rf.size == force_lf.size
194+
195+
plt.figure(figsize=(12, 8), layout="constrained")
196+
plt.plot(t, force_rf, label="right foot")
197+
plt.plot(t, force_lf, label="left foot")
198+
plt.xlabel("t [s]")
199+
plt.ylabel("Normal force [N]")
200+
plt.title(title)
201+
plt.grid(True)
202+
plt.legend()
203+
plt.tight_layout()

biped_walking_controller/simulation.py

Lines changed: 70 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,27 @@ def _link_index(body_id, link_name):
319319
return None
320320

321321

322+
def _compute_force(
323+
contact_pnts: typing.List,
324+
) -> typing.Tuple[float, typing.List[float]]:
325+
mean_x = 0.0
326+
mean_y = 0.0
327+
mean_z = 0.0
328+
total_force = 0.0
329+
for cp in contact_pnts:
330+
# tuple fields (relevant):
331+
# cp[4]=posOnA (world xyz), cp[5]=posOnB, cp[6]=normalOnB (world xyz),
332+
# cp[7]=distance, cp[8]=normalForce
333+
posB = cp[6]
334+
fN = cp[9] # Newtons
335+
mean_x += posB[0]
336+
mean_y += posB[1]
337+
mean_z += posB[2]
338+
total_force += fN
339+
340+
return total_force, [mean_x, mean_y, mean_z]
341+
342+
322343
class Simulator:
323344
"""
324345
Thin PyBullet wrapper for loading a robot URDF and driving it from
@@ -381,6 +402,15 @@ def __init__(self, dt, path_to_robot_urdf: Path, model, launch_gui=True):
381402
flags=pb.URDF_MERGE_FIXED_LINKS,
382403
)
383404

405+
# Find the joint that matches the right and left foot. It will be used to compute contact forces
406+
for j in range(pb.getNumJoints(self.robot_id)):
407+
info = pb.getJointInfo(self.robot_id, j)
408+
link_name = info[12].decode("ascii")
409+
if link_name == model.get_lf_link_name():
410+
self.rf_link_id = info[0]
411+
elif link_name == model.get_rf_link_name():
412+
self.lf_link_id = info[0]
413+
384414
# Build mapping between joints for both velocities and position
385415
self.pb_to_pin_joint_vel = _build_pb_to_pin_joint_vel_vmap(self.robot_id, model.model)
386416
self.pb_to_pin_joints_pos = _build_pb_to_pin_joints_map(self.robot_id, model)
@@ -472,67 +502,54 @@ def update_camera_to_follow_pos(self, x: float, y: float, z: float):
472502
cameraTargetPosition=[x, y, z],
473503
)
474504

475-
def draw_contact_forces(self, color=(0, 1, 0), scale=0.002):
505+
def get_contact_forces(self) -> typing.Tuple[float, float]:
476506
"""
477-
Render an averaged ground-reaction normal as a debug line.
507+
Return the net x-axis contact forces for the right and left foot.
478508
479-
Parameters
480-
----------
481-
color : tuple[float, float, float], default (0, 1, 0)
482-
RGB color in [0,1].
483-
scale : float, default 0.002
484-
Visual scale factor converting Newtons to line length.
509+
The method queries PyBullet for contacts between the robot and the ground
510+
on each foot link, then uses the private helper `_compute_force(...)`
511+
to aggregate the force component along the world x-axis.
512+
513+
Requirements
514+
------------
515+
- `self.robot_id` : int
516+
PyBullet body id of the robot.
517+
- `self.plane_id` : int
518+
PyBullet body id of the ground/plane.
519+
- `self.rf_link_id` : int
520+
Link index of the right foot.
521+
- `self.lf_link_id` : int
522+
Link index of the left foot.
523+
- `_compute_force(contacts) -> tuple[float, Any]`
524+
Helper that returns the x-axis force [N] as its first element.
525+
It should return 0.0 if `contacts` is empty.
526+
527+
Returns
528+
-------
529+
(float, float)
530+
`(Fx_right, Fx_left)` in Newtons, world frame.
485531
486532
Notes
487533
-----
488-
- Aggregates all robot–plane contacts, averages contact locations,
489-
sums normal forces, and draws a single arrow along the last
490-
observed contact normal.
491-
- Updates a persistent debug line if it already exists.
534+
- Only contacts with `bodyA=self.robot_id`, `bodyB=self.plane_id`,
535+
and `linkIndexA` equal to the corresponding foot link are considered.
536+
- If a foot has no active contacts, its returned force is expected to be 0.0.
537+
- This method assumes `_compute_force` already applies the correct
538+
Newton third-law sign convention to yield the force exerted on the robot.
492539
"""
493-
# iterate all foot links if you want per-foot colors
494-
cps_all = []
495-
496-
cps_all.extend(pb.getContactPoints(bodyA=self.robot_id, bodyB=self.plane_id))
497-
498-
mean_x = 0.0
499-
mean_y = 0.0
500-
mean_z = 0.0
501-
total_force = 0.0
502-
n_B = [0.0, 0.0, 0.0]
503-
for cp in cps_all:
504-
# tuple fields (relevant):
505-
# cp[4]=posOnA (world xyz), cp[5]=posOnB, cp[6]=normalOnB (world xyz),
506-
# cp[7]=distance, cp[8]=normalForce
507-
posB = cp[6]
508-
n_B = cp[7]
509-
fN = cp[9] # Newtons
510-
mean_x += posB[0]
511-
mean_y += posB[1]
512-
mean_z += posB[2]
513-
total_force += fN
514-
515-
if len(cps_all) is not 0:
516-
start = [mean_x / len(cps_all), mean_y / len(cps_all), mean_z / len(cps_all)]
517-
end = (
518-
start[0] + n_B[0] * total_force * scale,
519-
start[1] + n_B[1] * total_force * scale,
520-
start[2] + n_B[2] * total_force * scale,
540+
rf_force, _ = _compute_force(
541+
pb.getContactPoints(
542+
bodyA=self.robot_id, bodyB=self.plane_id, linkIndexA=self.rf_link_id
521543
)
544+
)
522545

523-
# arrow for normal force
524-
if self._displayed_lines is None:
525-
self._displayed_lines = pb.addUserDebugLine(
526-
start, end, lineColorRGB=color, lineWidth=3
527-
)
528-
else:
529-
pb.addUserDebugLine(
530-
start,
531-
end,
532-
lineColorRGB=color,
533-
lineWidth=3,
534-
replaceItemUniqueId=self._displayed_lines,
535-
)
546+
lf_force, _ = _compute_force(
547+
pb.getContactPoints(
548+
bodyA=self.robot_id, bodyB=self.plane_id, linkIndexA=self.lf_link_id
549+
)
550+
)
551+
552+
return rf_force, lf_force
536553

537554
def draw_points(
538555
self,

examples/example_4_physics_simulation.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@
55
import numpy as np
66
import pybullet as pb
77
import pinocchio as pin
8+
from matplotlib import pyplot as plt
89

910
from biped_walking_controller.foot import (
1011
compute_feet_path_and_poses,
1112
BezierCurveFootPathGenerator,
1213
)
1314

1415
from biped_walking_controller.inverse_kinematic import InvKinSolverParams, solve_inverse_kinematics
15-
from biped_walking_controller.plot import plot_feet_and_com
16+
from biped_walking_controller.plot import plot_feet_and_com, plot_contact_forces
1617

1718
from biped_walking_controller.preview_control import (
1819
PreviewControllerParams,
@@ -208,6 +209,9 @@ def main():
208209

209210
zmp_pos = np.zeros((len(phases), 3))
210211

212+
rf_forces = np.zeros((len(phases), 1))
213+
lf_forces = np.zeros((len(phases), 1))
214+
211215
# We start the walking phase
212216
for k, _ in enumerate(phases[:-2]):
213217
# Get the current configuration of the robot from the simulator
@@ -285,7 +289,10 @@ def main():
285289
rf_pin_pos[k] = talos.data.oMf[talos.right_foot_id].translation
286290
rf_pb_pos[k], _ = simulator.get_robot_frame_pos("leg_right_6_link")
287291

292+
rf_forces[k], lf_forces[k] = simulator.get_contact_forces()
293+
288294
if args.plot_results:
295+
289296
zmp_ref_plot = np.zeros((zmp_ref.shape[0], 3))
290297
zmp_ref_plot[:, :2] = zmp_ref
291298

@@ -305,6 +312,10 @@ def main():
305312
zmp_ref=zmp_ref_plot,
306313
)
307314

315+
plot_contact_forces(t=t, force_rf=rf_forces, force_lf=lf_forces)
316+
317+
plt.show()
318+
308319
# Infinite loop to display the ending position
309320
while True:
310321
simulator.step()

0 commit comments

Comments
 (0)