Skip to content

Commit ec28e3d

Browse files
authored
Merge pull request #26 from InfoMusCP/Equilibrium
feat: add Equilibrium class with elliptical balance evaluation and in…
2 parents aad66df + a14d04e commit ec28e3d

File tree

2 files changed

+228
-0
lines changed

2 files changed

+228
-0
lines changed

core/equilibrium.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import numpy as np
2+
3+
class Equilibrium:
4+
"""
5+
Elliptical equilibrium evaluation between two feet and a barycenter.
6+
7+
This class defines an elliptical region of interest (ROI) aligned with the
8+
line connecting the left and right foot. The ellipse is scaled by a margin
9+
in millimeters and can be weighted along the Y-axis to emphasize forward–
10+
backward sway more than lateral sway. A barycenter is evaluated against
11+
this ellipse to compute a normalized equilibrium value.
12+
13+
Parameters
14+
----------
15+
margin_mm : float, optional
16+
Extra margin in millimeters added around the rectangle spanned by the
17+
two feet (default: 100).
18+
y_weight : float, optional
19+
Weighting factor applied to the ellipse height along the Y-axis.
20+
A value < 1 shrinks the ellipse in the forward/backward direction,
21+
emphasizing sway in that axis (default: 0.5).
22+
23+
Examples
24+
--------
25+
>>> import numpy as np
26+
>>> eq = Equilibrium(margin_mm=120, y_weight=0.6)
27+
>>> left = np.array([0, 0, 0])
28+
>>> right = np.array([400, 0, 0])
29+
>>> barycenter = np.array([200, 50, 0])
30+
>>> value, angle = eq(left, right, barycenter)
31+
>>> round(value, 2)
32+
0.91
33+
>>> round(angle, 1)
34+
0.0
35+
"""
36+
37+
def __init__(self, margin_mm=100, y_weight=0.5):
38+
self.margin = margin_mm
39+
self.y_weight = y_weight
40+
41+
def __call__(self, left_foot: np.ndarray, right_foot: np.ndarray, barycenter: np.ndarray) -> tuple[float, float]:
42+
"""
43+
Evaluate the equilibrium value and ellipse angle.
44+
45+
Parameters
46+
----------
47+
left_foot : numpy.ndarray, shape (3,)
48+
3D coordinates (x, y, z) of the left foot in millimeters.
49+
Only the x and y components are used.
50+
right_foot : numpy.ndarray, shape (3,)
51+
3D coordinates (x, y, z) of the right foot in millimeters.
52+
Only the x and y components are used.
53+
barycenter : numpy.ndarray, shape (3,)
54+
3D coordinates (x, y, z) of the barycenter in millimeters.
55+
Only the x and y components are used.
56+
57+
Returns
58+
-------
59+
value : float
60+
Equilibrium value in [0, 1].
61+
- 1 means the barycenter is perfectly at the ellipse center.
62+
- 0 means the barycenter is outside the ellipse.
63+
angle : float
64+
Orientation of the ellipse in degrees, measured counter-clockwise
65+
from the X-axis (line connecting left and right foot).
66+
67+
Notes
68+
-----
69+
- The ellipse is aligned with the line connecting the two feet.
70+
- The ellipse width corresponds to the horizontal foot span + margin.
71+
- The ellipse height corresponds to the vertical span + margin,
72+
scaled by `y_weight`.
73+
"""
74+
ps = np.array(left_foot)[:2]
75+
pd = np.array(right_foot)[:2]
76+
bc = np.array(barycenter)[:2]
77+
78+
min_xy = np.minimum(ps, pd) - self.margin
79+
max_xy = np.maximum(ps, pd) + self.margin
80+
81+
center = (min_xy + max_xy) / 2
82+
half_sizes = (max_xy - min_xy) / 2
83+
84+
a = half_sizes[0]
85+
b = half_sizes[1] * self.y_weight
86+
87+
dx, dy = pd - ps
88+
angle = np.arctan2(dy, dx)
89+
90+
rel = bc - center
91+
92+
rot_matrix = np.array([
93+
[np.cos(-angle), -np.sin(-angle)],
94+
[np.sin(-angle), np.cos(-angle)]
95+
])
96+
rel_rot = rot_matrix @ rel
97+
98+
norm = (rel_rot[0] / a) ** 2 + (rel_rot[1] / b) ** 2
99+
100+
if norm <= 1.0:
101+
value = 1.0 - np.sqrt(norm)
102+
else:
103+
value = 0.0
104+
105+
return max(0.0, value), np.degrees(angle)

examples/test_equilibrium.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""
2+
Interactive equilibrium simulation using matplotlib.
3+
4+
This script provides a visual demonstration of the `Equilibrium` class.
5+
It creates an interactive 2D plot where the user can move the mouse to
6+
simulate the barycenter position, and the system evaluates whether the
7+
barycenter is within the equilibrium ellipse defined by two feet.
8+
9+
The ellipse is dynamically updated in position, orientation, and color:
10+
- Green if the barycenter is within the ellipse.
11+
- Red if the barycenter is outside.
12+
13+
The equilibrium value (in [0, 1]) is displayed in real-time.
14+
15+
Examples
16+
--------
17+
Run the script:
18+
19+
$ python demo_equilibrium.py
20+
21+
Then move the mouse cursor inside the figure window to simulate barycenter
22+
movements.
23+
24+
Notes
25+
-----
26+
- Requires `matplotlib` for visualization.
27+
- Uses `numpy` for vector operations.
28+
"""
29+
30+
import numpy as np
31+
import matplotlib.pyplot as plt
32+
from matplotlib.patches import Ellipse
33+
import sys, os
34+
if os.getcwd() not in sys.path:
35+
sys.path.append(os.getcwd())
36+
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
37+
from core.equilibrium import Equilibrium
38+
39+
# --- Setup test parameters ---
40+
left_foot = np.array([120, 200, 0])
41+
"""numpy.ndarray: 3D coordinates (x, y, z) of the left foot in millimeters."""
42+
43+
right_foot = np.array([800, 600, 0])
44+
"""numpy.ndarray: 3D coordinates (x, y, z) of the right foot in millimeters."""
45+
46+
eq = Equilibrium(margin_mm=100, y_weight=0.5)
47+
"""Equilibrium: instance of the equilibrium evaluator."""
48+
49+
# --- Setup matplotlib figure ---
50+
fig, ax = plt.subplots()
51+
ax.set_xlim(-200, 1000)
52+
ax.set_ylim(-200, 800)
53+
ax.set_aspect('equal')
54+
ax.set_title("Equilibrium. Move the barycenter with mouse.")
55+
56+
# Plot the two feet as blue points
57+
ax.plot(left_foot[0], left_foot[1], 'bo', markersize=8)
58+
ax.plot(right_foot[0], right_foot[1], 'bo', markersize=8)
59+
60+
# Ellipse ROI (initial placeholder)
61+
roi_ellipse = Ellipse((0, 0), 0, 0,
62+
fill=True, facecolor='green', alpha=0.2,
63+
edgecolor='green', linewidth=2)
64+
ax.add_patch(roi_ellipse)
65+
66+
# Red point for barycenter
67+
baricentro_plot, = ax.plot([], [], 'ro', markersize=8)
68+
69+
# Text showing equilibrium value
70+
eq_text = ax.text(0.02, 1.02, "", transform=ax.transAxes, fontsize=12)
71+
72+
def on_move(event):
73+
"""
74+
Handle mouse movement and update equilibrium visualization.
75+
76+
Parameters
77+
----------
78+
event : matplotlib.backend_bases.MouseEvent
79+
The mouse event containing the current cursor position.
80+
Only `event.xdata` and `event.ydata` are used.
81+
82+
Behavior
83+
--------
84+
- Computes equilibrium value and ellipse angle based on the
85+
simulated barycenter.
86+
- Updates ellipse position, size, orientation, and color.
87+
- Updates barycenter position on the plot.
88+
- Updates equilibrium value text.
89+
"""
90+
if not event.inaxes:
91+
return
92+
93+
baricentro = np.array([event.xdata, event.ydata, 0])
94+
95+
value, angle = eq(left_foot, right_foot, baricentro)
96+
97+
ps = np.array(left_foot)[:2]
98+
pd = np.array(right_foot)[:2]
99+
min_xy = np.minimum(ps, pd) - eq.margin
100+
max_xy = np.maximum(ps, pd) + eq.margin
101+
center = (min_xy + max_xy) / 2
102+
half_sizes = (max_xy - min_xy) / 2
103+
a = half_sizes[0]
104+
b = half_sizes[1] * eq.y_weight
105+
106+
roi_ellipse.set_center(center)
107+
roi_ellipse.width = 2 * a
108+
roi_ellipse.height = 2 * b
109+
roi_ellipse.angle = angle
110+
roi_ellipse.set_facecolor('green' if value > 0 else 'red')
111+
roi_ellipse.set_edgecolor('green' if value > 0 else 'red')
112+
113+
baricentro_plot.set_data(baricentro[0], baricentro[1])
114+
115+
eq_text.set_text(f"Equilibrium value = {value:.2f}")
116+
117+
fig.canvas.draw_idle()
118+
119+
# Connect mouse motion event
120+
fig.canvas.mpl_connect('motion_notify_event', on_move)
121+
122+
# Run interactive visualization
123+
plt.show()

0 commit comments

Comments
 (0)