Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions common/reporting/sections.py
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,15 @@ def add_section(x_label, x_val, x_unit, y, title, rows, section_color: str | Non
("Total Rear", "elastic_roll_stiffness_rear_Nm_per_rad", "Nm/rad", "{:.0f}"),
])

if any(key.endswith("_path_pct") for key in summary):
y_right = add_section(x_right_label, x_right_val, x_right_unit, y_right,
"Anti by Force Path (design pose)", [
("Anti-Dive CP", "anti_dive_contact_patch_path_pct", "%", "{:.2f}"),
("Anti-Dive WC", "anti_dive_wheel_center_path_pct", "%", "{:.2f}"),
("Anti-Squat CP", "anti_squat_contact_patch_path_pct", "%", "{:.2f}"),
("Anti-Squat WC", "anti_squat_wheel_center_path_pct", "%", "{:.2f}"),
])

pdf.savefig(fig)
plt.close(fig)

Expand Down
44 changes: 40 additions & 4 deletions docs/four-post-metrics.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ measure, and how a sample becomes invalid. The metrics are in
| `avg_anti_dive_pct`, `avg_anti_squat_pct` | Anti from a CP force Fx that the upright reacts. Percent of h/L. |
| `avg_longitudinal_jacking_coeff_<axle>` | The same response as a dimensional coefficient dFz/dFx (N/N). |
| `anti_force_path` | `contact_patch_upright_reacted`. The force path of the three rows above. |
| `longitudinal_jacking_coeff_<axle>_contact_patch_path` | Kinematic dFz/dFx (N/N) at the design pose for an Fx that the upright reacts. |
| `longitudinal_jacking_coeff_<axle>_wheel_center_path` | Kinematic dFz/dFx (N/N) at the design pose for an Fx that the chassis reacts. |
| `anti_dive_<path>_path_pct`, `anti_squat_<path>_path_pct` | The front and rear coefficients above in percent of h/L. `<path>` is `contact_patch` or `wheel_center`. |
| `avg_anti_roll_<axle>_pct` | Legacy key. Net axle jacking J in percent of h/t. It is not geometric anti-roll. |
| `avg_lateral_jacking_coeff_<axle>` | Net axle jacking J = (dFz_L + dFz_R) / Fy (N/N), from the chassis reaction. |
| `avg_fbrc_differential_jacking_ratio_<axle>` | Differential jacking (dFz_R - dFz_L) / Fy. |
Expand All @@ -36,10 +39,39 @@ measure, and how a sample becomes invalid. The metrics are in
### Anti-dive and anti-squat force path

The FourPost rig applies Fx at the CP. The wheel torque goes back through the
upright, as with an outboard brake or a hub motor. The percent and the dFz/dFx
coefficient are valid for that path only. An inboard drive or an inboard brake
reacts its torque on the chassis. Its anti is different, and FourPost does not
compute it.
upright, as with an outboard brake or a hub motor. The `avg_` percent and the
`avg_` dFz/dFx coefficient are valid for that path only.

An inboard drive or an inboard brake reacts its torque on the chassis. The wheel
then pushes on the upright at the wheel center (WC). FourPost computes both paths
from the heave sweep kinematics at the design pose, where the jounce is 0:

- Contact-patch path: `c_cp = -(dx_cp/ds) / (dz_cp/ds)`.
- Wheel-center path: `c_wc = -(dx_wc/ds) / (dz_cp/ds)`.

Here `x` is the side-view position, and `z_cp` is the CP height relative to the
body. The fixture holds the CP height and the rig moves the body, so `dz_cp/ds`
is 1 when `s` is the jounce. The run exports the x positions of
`<axle>.{left,right}CP` and `<axle>.{left,right}Tire.chassisFrame` (the WC). The
two sides are averaged. A positive coefficient lifts the body for a positive Fx.
The percent uses the same h/L and the same signs as the `avg_` keys: anti-dive
is `-100 c / (h/L)` and anti-squat is `100 c / (h/L)`.

Use the path that matches the hardware. The BobSim default car,
`EVBatInvMotDiff`, has one inboard motor and a differential. Its rear
anti-squat is on the wheel-center path. A run without the position signals
reports these keys as `unavailable`.

On the default car at the design pose:

| Axle | CP path (N/N) | WC path (N/N) | CP path (%) | WC path (%) |
| --- | --- | --- | --- | --- |
| Front (anti-dive) | +0.00514 | -0.00389 | -2.72 | +2.06 |
| Rear (anti-squat) | -0.00895 | +0.00473 | -4.74 | +2.51 |

The kinematic CP path agrees with the force pulses. The `avg_` coefficients over
the 11 poses are +0.00525 front and -0.00881 rear. The rear anti-squat of the
inboard drive is +2.5 %, not the -4.7 % of `avg_anti_squat_pct`.

### Net jacking is not geometric load transfer

Expand Down Expand Up @@ -137,6 +169,10 @@ Keys keep their names. Labels and the values below change.
`avg_roll_rate_distribution_front_pct`. On the default car the two differ by
0.008 points.
- New keys: `heave_cp_tension_points` and `anti_force_path`.
- New keys: `longitudinal_jacking_coeff_<axle>_<path>_path` and
`anti_dive_<path>_path_pct`, `anti_squat_<path>_path_pct`. The `avg_` anti keys
keep their names and values. The summary page shows the four percents under
"Anti by Force Path".
- New CSV column: `status`.
- Report labels: "Geometric Anti-Roll" is now "Net Axle Jacking". "LLTD (Front)"
is now "Elastic Share". The anti-dive and anti-squat labels name the force
Expand Down
65 changes: 65 additions & 0 deletions simulations/response/mbd/four_post/four_post_eval_sim.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,19 @@
FOUR_POST_EVAL_SIGNALS += static_balance.executed_signals(
[(axle, actuator) for _, _, axle, actuator in FOUR_POST_STATIC_BALANCE_AXLES]
)
FOUR_POST_ANTI_PATH_POINTS = (("contact_patch", "CP"), ("wheel_center", "Tire.chassisFrame"))


def _anti_path_x_signals(axle: str, point: str) -> list[str]:
return [f"{axle}.{side}{point}.r_0[1]" for side in ("left", "right")]


FOUR_POST_EVAL_SIGNALS += [
signal
for _, _, axle, _ in FOUR_POST_STATIC_BALANCE_AXLES
for _, point in FOUR_POST_ANTI_PATH_POINTS
for signal in _anti_path_x_signals(axle, point)
]

# The filter is a regular expression, and the executed-signal names contain brackets.
FOUR_POST_EVAL_VARIABLE_FILTER = "|".join(re.escape(signal) for signal in FOUR_POST_EVAL_SIGNALS)
Expand Down Expand Up @@ -374,6 +387,30 @@ def _sort_xy(x: Any, y: Any) -> tuple[np.ndarray, np.ndarray]:
return x_trimmed[idx], y_trimmed[idx]


def _design_pose_jacking_coefficients(
cp_rise_m: Any,
cp_x_m: Any,
wc_x_m: Any,
) -> tuple[float, float]:
"""Kinematic dFz/dFx at zero CP rise for an Fx reacted through the upright and through the chassis.

cp_rise_m is the CP height relative to the body. The contact-patch path is -(dx_cp/ds)/(dz_cp/ds).
The wheel-center path is -(dx_wc/ds)/(dz_cp/ds).
"""
s = np.asarray(cp_rise_m, dtype=float).reshape(-1)
cp_x = np.asarray(cp_x_m, dtype=float).reshape(-1)
wc_x = np.asarray(wc_x_m, dtype=float).reshape(-1)
mask = np.isfinite(s) & np.isfinite(cp_x) & np.isfinite(wc_x)
s, cp_x, wc_x = s[mask], cp_x[mask], wc_x[mask]
order = np.argsort(s)
s, cp_x, wc_x = s[order], cp_x[order], wc_x[order]
if s.size < 3 or np.any(np.diff(s) <= 0.0) or not s[0] < 0.0 < s[-1]:
return float("nan"), float("nan")
cp_slope = float(np.interp(0.0, s, np.gradient(cp_x, s)))
wc_slope = float(np.interp(0.0, s, np.gradient(wc_x, s)))
return -cp_slope, -wc_slope


def _configured_suspension_setup(config: dict[str, Any]) -> dict[str, Any]:
vehicle = _load_active_vehicle_yaml()
front_side = _side_data(vehicle, "front")
Expand Down Expand Up @@ -1287,6 +1324,33 @@ def coverage(name: str, valid: Any, n_total: int) -> None:
coverage("avg_anti_squat_pct", np.isfinite(rr_anti_heave), heave_count)
coverage("avg_longitudinal_jacking_coeff_front", np.isfinite(fr_coeff_heave), heave_count)
coverage("avg_longitudinal_jacking_coeff_rear", np.isfinite(rr_coeff_heave), heave_count)

# The fixture holds the CP height and the body moves, so the jounce is the CP rise relative to the body.
heave_pose_jounce = sample_at_times(heave, heave_sample_times)
anti_path_summary: dict[str, float] = {}
for axle_name, axle, anti_name, anti_sign in (
("front", "frAxleDW", "anti_dive", -1.0),
("rear", "rrAxleDW", "anti_squat", 1.0),
):
point_x: list[np.ndarray] = []
for _, point in FOUR_POST_ANTI_PATH_POINTS:
names = _anti_path_x_signals(axle, point)
if all(name in result for name in names):
sides = [sample_at_times(result[name], heave_sample_times) for name in names]
point_x.append(np.mean(sides, axis=0))
available = len(point_x) == len(FOUR_POST_ANTI_PATH_POINTS)
coeffs = (
_design_pose_jacking_coefficients(heave_pose_jounce, *point_x)
if available
else (float("nan"), float("nan"))
)
for (path, _), coeff in zip(FOUR_POST_ANTI_PATH_POINTS, coeffs, strict=True):
coeff_key = f"longitudinal_jacking_coeff_{axle_name}_{path}_path"
anti_key = f"{anti_name}_{path}_path_pct"
anti_path_summary[coeff_key] = coeff
anti_path_summary[anti_key] = anti_sign * 100.0 * coeff / ref_long
for key in (coeff_key, anti_key):
status[key] = "ok" if np.isfinite(coeff) else ("invalid" if available else "unavailable")
coverage("avg_anti_roll_front_pct", np.isfinite(fr_anti_roll) & roll_contact_loaded, roll_count)
coverage("avg_anti_roll_rear_pct", np.isfinite(rr_anti_roll) & roll_contact_loaded, roll_count)
coverage("avg_lateral_jacking_coeff_front", np.isfinite(fr_coeff_roll), roll_count)
Expand Down Expand Up @@ -1612,6 +1676,7 @@ def bar_roll_stiffness(
"elastic_roll_stiffness_rear_Nm_per_rad": float(kphi_el_r),
"heave_cp_tension_points": int(np.count_nonzero(heave_tension)),
"anti_force_path": FOUR_POST_ANTI_FORCE_PATH,
**anti_path_summary,
}

series: dict[str, np.ndarray] = {
Expand Down
94 changes: 94 additions & 0 deletions tests/test_kinematics.py
Original file line number Diff line number Diff line change
Expand Up @@ -1328,3 +1328,97 @@ def test_four_post_metrics_csv_carries_a_status_column(tmp_path: Path) -> None:
legacy.write_text("metric,value\na,1.0\n", encoding="utf-8")
assert read_metric_status(legacy)["a"] == (1.0, "unknown")
assert np.isnan(read_metrics(legacy)["a"])


def _trailing_arm_x(cp_rise_m: np.ndarray, pivot_xz: tuple[float, float], point_z_m: float) -> np.ndarray:
"""Side-view x of a point at height point_z_m above the CP on an arm that pivots at pivot_xz."""
theta = np.linspace(-0.3, 0.3, 20001)
rx, rz = -pivot_xz[0], point_z_m - pivot_xz[1]
cp_rz = -pivot_xz[1]
cp_rise = -pivot_xz[0] * np.sin(theta) + cp_rz * np.cos(theta) - cp_rz
x = pivot_xz[0] + rx * np.cos(theta) - rz * np.sin(theta) - (pivot_xz[0] + rx)
order = np.argsort(cp_rise)
return np.interp(cp_rise_m, cp_rise[order], x[order])


def test_design_pose_jacking_coefficients_match_a_trailing_arm_instant_center() -> None:
pivot = (0.5, 0.40)
wheel_radius = 0.25
jounce = np.linspace(-0.03, 0.03, 11)

cp_coeff, wc_coeff = four_post_eval._design_pose_jacking_coefficients(
jounce,
_trailing_arm_x(jounce, pivot, 0.0),
_trailing_arm_x(jounce, pivot, wheel_radius),
)

assert cp_coeff == pytest.approx(pivot[1] / pivot[0], rel=1e-3)
assert wc_coeff == pytest.approx((pivot[1] - wheel_radius) / pivot[0], rel=1e-3)


def _add_trailing_arm_path_signals(
result: dict[str, np.ndarray],
pivots: dict[str, tuple[float, float]],
wheel_radius_m: float,
) -> None:
jounce = -result["frKnC.heave"]
for axle, pivot in pivots.items():
for side in ("left", "right"):
result[f"{axle}.{side}CP.r_0[1]"] = _trailing_arm_x(jounce, pivot, 0.0)
result[f"{axle}.{side}Tire.chassisFrame.r_0[1]"] = _trailing_arm_x(jounce, pivot, wheel_radius_m)


def test_four_post_reports_anti_on_both_force_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
sim, result, vehicle = _four_post_unit_run(tmp_path, monkeypatch)
summary, _series = sim.summarize(result)
assert np.isnan(summary["anti_squat_wheel_center_path_pct"])
assert sim.metric_status["anti_squat_wheel_center_path_pct"] == "unavailable"

# Front pivot behind and rear pivot ahead of the wheel, both above it.
_add_trailing_arm_path_signals(result, {"frAxleDW": (-0.5, 0.40), "rrAxleDW": (0.5, 0.40)}, 0.25)
summary, _series = sim.summarize(result)

front = CornerKinematics.from_vehicle(vehicle, "front").wheel_center_initial
rear = CornerKinematics.from_vehicle(vehicle, "rear").wheel_center_initial
ref_long = 0.35 / abs(front[0] - rear[0])
expected = {
("front", "contact_patch"): -0.8,
("front", "wheel_center"): -0.3,
("rear", "contact_patch"): 0.8,
("rear", "wheel_center"): 0.3,
}
for (axle, path), coeff in expected.items():
assert summary[f"longitudinal_jacking_coeff_{axle}_{path}_path"] == pytest.approx(coeff, rel=1e-3)
assert sim.metric_status[f"longitudinal_jacking_coeff_{axle}_{path}_path"] == "ok"
# Braking Fx is negative, so a negative front coefficient lifts the front under braking.
assert summary["anti_dive_contact_patch_path_pct"] == pytest.approx(80.0 / ref_long, rel=1e-3)
assert summary["anti_dive_wheel_center_path_pct"] == pytest.approx(30.0 / ref_long, rel=1e-3)
assert summary["anti_squat_contact_patch_path_pct"] == pytest.approx(80.0 / ref_long, rel=1e-3)
assert summary["anti_squat_wheel_center_path_pct"] == pytest.approx(30.0 / ref_long, rel=1e-3)


def test_four_post_reaction_path_change_leaves_the_contact_patch_anti_unchanged(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
sim, result, _vehicle = _four_post_unit_run(tmp_path, monkeypatch)
_add_trailing_arm_path_signals(result, {"frAxleDW": (-0.5, 0.40), "rrAxleDW": (0.5, 0.40)}, 0.25)
before, _series = sim.summarize(result)

# Only the wheel-center path changes: the wheel center now moves with the CP.
for axle in ("frAxleDW", "rrAxleDW"):
for side in ("left", "right"):
result[f"{axle}.{side}Tire.chassisFrame.r_0[1]"] = result[f"{axle}.{side}CP.r_0[1]"].copy()
after, _series = sim.summarize(result)

for key in (
"anti_dive_contact_patch_path_pct",
"anti_squat_contact_patch_path_pct",
"avg_anti_dive_pct",
"avg_anti_squat_pct",
"avg_longitudinal_jacking_coeff_front",
"avg_longitudinal_jacking_coeff_rear",
):
assert after[key] == pytest.approx(before[key], nan_ok=True)
assert after["anti_squat_wheel_center_path_pct"] == pytest.approx(after["anti_squat_contact_patch_path_pct"])
assert after["anti_squat_wheel_center_path_pct"] != pytest.approx(before["anti_squat_wheel_center_path_pct"])
Loading