diff --git a/common/reporting/sections.py b/common/reporting/sections.py index 298351c..91d2f64 100644 --- a/common/reporting/sections.py +++ b/common/reporting/sections.py @@ -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) diff --git a/docs/four-post-metrics.md b/docs/four-post-metrics.md index 64d597f..2cf9a64 100644 --- a/docs/four-post-metrics.md +++ b/docs/four-post-metrics.md @@ -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_` | 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__contact_patch_path` | Kinematic dFz/dFx (N/N) at the design pose for an Fx that the upright reacts. | +| `longitudinal_jacking_coeff__wheel_center_path` | Kinematic dFz/dFx (N/N) at the design pose for an Fx that the chassis reacts. | +| `anti_dive__path_pct`, `anti_squat__path_pct` | The front and rear coefficients above in percent of h/L. `` is `contact_patch` or `wheel_center`. | | `avg_anti_roll__pct` | Legacy key. Net axle jacking J in percent of h/t. It is not geometric anti-roll. | | `avg_lateral_jacking_coeff_` | Net axle jacking J = (dFz_L + dFz_R) / Fy (N/N), from the chassis reaction. | | `avg_fbrc_differential_jacking_ratio_` | Differential jacking (dFz_R - dFz_L) / Fy. | @@ -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 +`.{left,right}CP` and `.{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 @@ -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___path` and + `anti_dive__path_pct`, `anti_squat__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 diff --git a/simulations/response/mbd/four_post/four_post_eval_sim.py b/simulations/response/mbd/four_post/four_post_eval_sim.py index 2a4e381..c251935 100644 --- a/simulations/response/mbd/four_post/four_post_eval_sim.py +++ b/simulations/response/mbd/four_post/four_post_eval_sim.py @@ -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) @@ -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") @@ -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) @@ -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] = { diff --git a/tests/test_kinematics.py b/tests/test_kinematics.py index af2ca54..d9b7e6c 100644 --- a/tests/test_kinematics.py +++ b/tests/test_kinematics.py @@ -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"])