Static support boundary¶
Goal¶
Test the Iteration 0 prediction for issue #24: does the signed support margin reach zero before the rear feet unload?
Human prediction: a centered ballast keeps the COM centered; a forward ballast moves it toward the front; beyond the front support edge creates a tipping hazard. The human predicted rear-foot load would increase with forward ballast and estimated a 20–30° forward post-tip pitch.
This notebook is the analysis front end. It invokes the focused headless MuJoCo fixture only to obtain contact loads; it does not open a viewer or make a C-1N capability claim.
Show code
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from static_support_boundary import PAYLOAD_SHIFT_LIMIT, observe
Show code
coarse_shifts = np.round(np.linspace(0.0, 0.9, 10), 2)
near_boundary_shifts = np.array([0.92, 0.94, 0.96, 0.98, 1.00, 1.10, 1.20])
shifts = np.concatenate((coarse_shifts, near_boundary_shifts))
records = []
for shift in shifts:
observation = observe(float(shift), seconds=2.0)
loads = observation['foot_normal_loads_n']
records.append({
'payload_shift_m': shift,
'support_margin_m': observation['support_margin_m'],
'front_load_n': loads[0],
'rear_left_load_n': loads[1],
'rear_right_load_n': loads[2],
'minimum_rear_load_n': min(loads[1:]),
'com_x_m': observation['com_world_xy_m'][0],
'roll_deg': np.rad2deg(observation['torso_roll_rad']),
'pitch_deg': np.rad2deg(observation['torso_pitch_rad']),
'standing_metric_pass': bool(observation['standing_metric_pass']),
})
results = pd.DataFrame(records)
results
| payload_shift_m | support_margin_m | front_load_n | rear_left_load_n | rear_right_load_n | minimum_rear_load_n | com_x_m | roll_deg | pitch_deg | standing_metric_pass | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 0.00 | 0.130408 | 7.599474 | 5.765013 | 5.765013 | 5.765013 | -0.001359 | -3.072152e-18 | 0.005018 | True |
| 1 | 0.10 | 0.116914 | 8.792316 | 5.168592 | 5.168592 | 5.168592 | 0.029828 | 1.233045e-17 | 0.009814 | True |
| 2 | 0.20 | 0.103424 | 9.984834 | 4.572333 | 4.572333 | 4.572333 | 0.061006 | -1.787620e-16 | 0.014506 | True |
| 3 | 0.30 | 0.089937 | 11.177012 | 3.976244 | 3.976244 | 3.976244 | 0.092175 | -1.586728e-16 | 0.019086 | True |
| 4 | 0.40 | 0.076454 | 12.368841 | 3.380329 | 3.380329 | 3.380329 | 0.123335 | 2.336176e-17 | 0.023554 | True |
| 5 | 0.50 | 0.062976 | 13.560317 | 2.784591 | 2.784591 | 2.784591 | 0.154485 | -1.639864e-16 | 0.027907 | True |
| 6 | 0.60 | 0.049501 | 14.751442 | 2.189028 | 2.189028 | 2.189028 | 0.185626 | 1.416759e-16 | 0.032147 | True |
| 7 | 0.70 | 0.036030 | 15.942219 | 1.593639 | 1.593639 | 1.593639 | 0.216757 | 5.748162e-17 | 0.036276 | True |
| 8 | 0.80 | 0.022564 | 17.132655 | 0.998421 | 0.998421 | 0.998421 | 0.247879 | 9.481841e-17 | 0.040294 | True |
| 9 | 0.90 | 0.009101 | 18.322758 | 0.403370 | 0.403370 | 0.403370 | 0.278993 | 3.048139e-18 | 0.044205 | True |
| 10 | 0.92 | 0.006409 | 18.560738 | 0.284379 | 0.284379 | 0.284379 | 0.285215 | -1.422508e-16 | 0.044975 | True |
| 11 | 0.94 | 0.003717 | 18.798711 | 0.165393 | 0.165393 | 0.165393 | 0.291437 | -7.185765e-17 | 0.045740 | True |
| 12 | 0.96 | 0.001007 | 19.038238 | 0.045629 | 0.045629 | 0.045629 | 0.297705 | 1.073486e-16 | 0.046506 | True |
| 13 | 0.98 | -0.096543 | 15.158607 | 0.000000 | 0.000000 | 0.000000 | 0.545929 | 2.489353e-14 | 23.588235 | False |
| 14 | 1.00 | -0.095626 | 15.146059 | 0.000000 | 0.000000 | 0.000000 | 0.542643 | -5.733115e-11 | 23.934518 | False |
| 15 | 1.10 | -0.093017 | 15.111429 | 0.000000 | 0.000000 | 0.000000 | 0.541255 | -6.283008e-08 | 25.073569 | False |
| 16 | 1.20 | -0.094944 | 15.138791 | 0.000000 | 0.000000 | 0.000000 | 0.549230 | -1.344223e-07 | 24.208645 | False |
Show code
fig, axes = plt.subplots(2, 1, figsize=(8, 6), sharex=True)
axes[0].plot(results.payload_shift_m, results.support_margin_m, marker='o', color='#2f69ad')
axes[0].axhline(0, color='black', linewidth=1)
axes[0].set_ylabel('support margin (m)')
axes[0].set_title('Fixed-foot support boundary sweep')
axes[1].plot(results.payload_shift_m, results.front_load_n, marker='o', label='front')
axes[1].plot(results.payload_shift_m, results.rear_left_load_n, marker='o', label='rear left')
axes[1].plot(results.payload_shift_m, results.rear_right_load_n, marker='o', label='rear right')
axes[1].axhline(0, color='black', linewidth=1)
axes[1].set_xlabel('payload shift (m)')
axes[1].set_ylabel('normal load (N)')
axes[1].legend()
fig.tight_layout()
Checks¶
Fixture standing metric: all three feet have more than 0.001 N normal load after a two-second rollout, and both roll and pitch remain within ±5°. This is a test-bench metric, not the future C-1N STAND criterion.
A fixed-foot quasi-static boundary should pair an exhausted support margin with unloading of the limiting rear contact. The coarse sweep can bracket the transition, but it cannot establish an exact threshold.
Show code
last_positive_margin = results.loc[results.support_margin_m > 0].tail(1)
first_unloaded_rear = results.loc[results.minimum_rear_load_n <= 1e-3].head(1)
print('Last sampled positive margin:')
display(last_positive_margin)
print('First sampled unloaded rear contact:')
display(first_unloaded_rear)
Last sampled positive margin:
| payload_shift_m | support_margin_m | front_load_n | rear_left_load_n | rear_right_load_n | minimum_rear_load_n | com_x_m | roll_deg | pitch_deg | standing_metric_pass | |
|---|---|---|---|---|---|---|---|---|---|---|
| 12 | 0.96 | 0.001007 | 19.038238 | 0.045629 | 0.045629 | 0.045629 | 0.297705 | 1.073486e-16 | 0.046506 | True |
First sampled unloaded rear contact:
| payload_shift_m | support_margin_m | front_load_n | rear_left_load_n | rear_right_load_n | minimum_rear_load_n | com_x_m | roll_deg | pitch_deg | standing_metric_pass | |
|---|---|---|---|---|---|---|---|---|---|---|
| 13 | 0.98 | -0.096543 | 15.158607 | 0.0 | 0.0 | 0.0 | 0.545929 | 2.489353e-14 | 23.588235 | False |
Takeaways¶
The centered case passes the fixture metric. At shift = 0.96 m, the margin is +0.0010 m and each rear foot carries 0.046 N; it also passes. At 0.98 m, both rear contacts are unloaded, the metric fails, and pitch is +23.6° after tipping.
The observed model corrects one prediction: forward ballast increases the front-foot load and decreases rear-foot load. The COM-location and forward-pitch predictions held. The support-margin and rear-unloading events are bracketed in the same 0.96–0.98 m interval.
A later C-1N experiment must separately measure changing support geometry and contact set during gait; this fixed-foot fixture cannot answer that dynamic question.