From d87e7f111cf7314e5919bf8ef54fb537c190c71b Mon Sep 17 00:00:00 2001 From: vdsim-dev Date: Tue, 22 Sep 2026 16:03:13 +0900 Subject: [PATCH 1/4] Q20 (i)(ii): lock the L3/L4 discriminator, wire hardpoints into vdsim_lab KinematicFourteenDOFDynamics overrides level() and nothing else, and attach_front/rear_kinematics dynamic_cast to the shared base, so the level label never discriminates: bare L4 is bit-identical to bare L3, and L3 with hardpoints is bit-identical to L4 with hardpoints. The attach is the discriminator. Four resident checks in test_experiment_api keep that fact measured rather than remembered. vdsim_lab could not attach at all (0 call sites), so Experiment/Simulation/Sim went through one new seam, _build_session(), which resolves a hardpoint reference the same way cosim/realtime_server.cpp resolve_susp_yaml() does and refuses instead of skipping: a missing YAML raises FileNotFoundError, a non-Ld3 level raises RuntimeError. Two bindings accept native hardpoint YAML (the existing pair only took a lookup CSV, and no CSV assets exist). H2 came out the opposite way from reading the source: world_scenario.cpp only parses an explicit front_susp key, but load_scene() materializes blueprints through python/catalog/materialize.py, which does carry front_chassis / rear_chassis across. configs/scenes/l4_sedan_kinematics.yaml is therefore a backed L4, not a false label, and the new gtest pins that. docs/VALIDATION.md goes back to PENDING: the registered count moves 505 -> 506. --- docs/VALIDATION.md | 9 ++- python/bindings.cpp | 16 ++++ python/vdsim_lab.py | 99 +++++++++++++++++++++++- tests/integration/test_multi_vehicle.cpp | 21 +++++ tests/scripts/test_experiment_api.py | 60 ++++++++++++++ 5 files changed, 198 insertions(+), 7 deletions(-) diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index f7b4fdd7..b2144587 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -20,7 +20,7 @@ the last section. | ISO 7401 step-steer / 4138 understeer / 3888-2 DLC — run + measured | — | | L1↔L2↔L3 cross-model consistency where physics overlaps | Dependent axles (twist-beam / solid beam) — configs are stubs | | FMI round-trip Δ=0 (machine precision); ISO 8608 PSD RMS per class | L3 unsprung lateral-transfer term (small) | -| Full suite: **505/505 ctest green (as of 2026-09-22)** (`validation` preset — see the currency block below) | — | +| Full suite: **PENDING** (`validation` preset — see the currency block below; Q20 changes the registered count) | — | | Drivetrain engine inertia (open-diff carrier coupling) | — | | **ISO step-steer signature gated in CI** (`ctest -R IsoBaseline`, sedan L2 LuGre) | DLC moose gate is a preset property, not a defect (see note) | @@ -48,12 +48,15 @@ version and the new numbers have to land in the same commit. ```text -tests: 505/505 +tests: PENDING config: cmake --preset validation && cmake --build --preset validation && ctest --preset validation presets: CMakePresets.json@fb1f0c198a0201fcce2543a9f5b2242a255fb97a toolchain: cmake 3.31.10 -commit: 3a72f3c +commit: PENDING date: 2026-09-22 +pending: Q20 adds one discriminating gtest, so the 505/505 measured at 3a72f3c +pending: no longer describes this tree. Filled from the CI canonical job on +pending: this branch (3 consecutive equal runs) before merge. excluded: gui_v3_e2e (GUI v3 test group formally deferred -- VDSIM_BUILD_GUI_V3_TESTS=OFF) excluded: gui_v3_api_smoke (GUI v3 test group formally deferred -- VDSIM_BUILD_GUI_V3_TESTS=OFF) excluded: ergaccess (optional external dependency not installed -- VDSIM_WITH_ERGACCESS=OFF) diff --git a/python/bindings.cpp b/python/bindings.cpp index ea8877a9..32805034 100644 --- a/python/bindings.cpp +++ b/python/bindings.cpp @@ -409,6 +409,22 @@ PYBIND11_MODULE(vdsim, m) { auto k = vdsim::create_lookup_kinematics(csv_path); return vdsim::attach_rear_kinematics(dyn, std::move(k)); }); + // Native hardpoint YAML (double_wishbone / macpherson / trailing_arm / + // 5link) instead of a lookup CSV. Same dispatch the co-sim server uses in + // attach_susp_parts(), so the Python lab path and the realtime path resolve + // the same file to the same mechanism. + m.def("attach_front_kinematics_from_yaml", + [](vdsim::IVehicleDynamics& dyn, const std::string& yaml_path) { + return vdsim::attach_front_kinematics( + dyn, vdsim::create_native_kinematics_from_yaml(yaml_path)); + }, + py::arg("dyn"), py::arg("yaml_path")); + m.def("attach_rear_kinematics_from_yaml", + [](vdsim::IVehicleDynamics& dyn, const std::string& yaml_path) { + return vdsim::attach_rear_kinematics( + dyn, vdsim::create_native_kinematics_from_yaml(yaml_path)); + }, + py::arg("dyn"), py::arg("yaml_path")); py::class_(m, "KcSweepSample") .def_readonly("abscissa", &vdsim::mb::KcSweepSample::abscissa) diff --git a/python/vdsim_lab.py b/python/vdsim_lab.py index 42d09d2c..ffdbbffd 100644 --- a/python/vdsim_lab.py +++ b/python/vdsim_lab.py @@ -43,6 +43,80 @@ def _conf_root(): _SENS = _CONF / "sensors" _EXP = _CONF / "experiments" _ISO = {"A": 0, "B": 1, "C": 2, "D": 3, "E": 4, "F": 5, "G": 6, "H": 7} +#: Native suspension-hardpoint YAMLs. Resolution mirrors resolve_susp_yaml() +#: in cosim/realtime_server.cpp (that path cannot call Python) -- keep the two +#: in step: an existing path is taken as-is, a bare stem is looked up here. +_SUSP_KIN = _CONF / "parts" / "susp_kinematics" / "kin" + + +def resolve_susp_kinematics(ref): + """Resolve a suspension-kinematics reference to a hardpoint YAML path. + + :param ref: an existing path, or a bare stem such as ``mp_front_sedan``. + :returns: :class:`pathlib.Path` of the hardpoint YAML. + :raises FileNotFoundError: when neither form resolves. A silently skipped + attach is what left level ``L4`` with no suspension physics behind it. + """ + p = Path(ref) + if p.is_file(): + return p + cand = _SUSP_KIN / (p.stem + ".yaml") + if cand.is_file(): + return cand + raise FileNotFoundError( + "suspension kinematics %r not found (looked at %s and %s)" % (ref, p, cand)) + + +def attach_kinematics(sess, front=None, rear=None): + """Attach native suspension kinematics to a session's plant. + + Only Ld3 (``level`` ``L3`` or ``L4``) carries per-wheel suspension state; + the core returns false for anything else and this raises rather than + leaving a run labelled with physics it does not have. + + :param sess: a ``vdsim.SimSession``. + :param front: front-axle hardpoint YAML (path or stem), or ``None``. + :param rear: rear-axle hardpoint YAML (path or stem), or ``None``. + :returns: provenance ``{"front": stem|None, "rear": stem|None, + "attached": bool}`` -- stems only, never absolute paths. + """ + info = {"front": None, "rear": None, "attached": False} + for side, ref in (("front", front), ("rear", rear)): + if ref is None: + continue + path = resolve_susp_kinematics(ref) + fn = (vdsim.attach_front_kinematics_from_yaml if side == "front" + else vdsim.attach_rear_kinematics_from_yaml) + if not fn(sess.dynamics(), str(path)): + raise RuntimeError( + "%s suspension kinematics refused by the plant -- L3/L4 only " + "(%s)" % (side, path.name)) + info[side] = path.stem + info["attached"] = True + return info + + +def _kin_spec(kin): + """Normalize a kinematics argument to a ``(front, rear)`` pair.""" + if kin is None: + return (None, None) + if isinstance(kin, dict): + return (kin.get("front"), kin.get("rear")) + front, rear = kin + return (front, rear) + + +def _build_session(road, vp, tp, level, dt, sensors, kin=None): + """Build a session and attach suspension hardpoints -- the single seam. + + Experiment.run, Simulation and Sim all go through here so they cannot drift + into three different notions of what a level label means. + + :returns: ``(session, kinematics_provenance)``. + """ + sess = road._session(vp, tp, level, dt, sensors) + front, rear = _kin_spec(kin) + return sess, attach_kinematics(sess, front, rear) def resolve_line(dl): @@ -487,6 +561,10 @@ def __init__(self, level="L2", dt=0.005): self._road = Road.flat() self._man = Maneuver.constant_speed(15.0) self._sensors = None + # Suspension hardpoints are opt-in: a bare L3/L4 plant uses the lumped + # camber_per_roll heuristic, so `level` alone says nothing about them. + self._kin = None + self._kin_info = {"front": None, "rear": None, "attached": False} # Trace recording is opt-in; `_trace is None` is the OFF fast path. self._trace = None self._channels = () @@ -497,6 +575,15 @@ def road(self, r): self._road = r; return self def maneuver(self, m): self._man = m; return self def sensors(self, s): self._sensors = s; return self + def kinematics(self, front=None, rear=None): + """Attach per-axle suspension hardpoints (L3/L4 only). + + :param front: front hardpoint YAML path or stem (``mp_front_sedan``). + :param rear: rear hardpoint YAML path or stem (``ta_rear_sedan``). + """ + self._kin = (front, rear) + return self + @classmethod def from_config(cls, name_or_cfg): """Build an Experiment from an authored scenario YAML (configs/experiments/ @@ -673,7 +760,8 @@ def run(self, duration=None): duration = getattr(self, "_duration", 10.0) vp, tp = self._veh.vp, self._tire.tp sp = self._sensors.sp if self._sensors else vdsim.SensorParams() - sess = self._road._session(vp, tp, self.level, self.dt, sp) + sess, self._kin_info = _build_session( + self._road, vp, tp, self.level, self.dt, sp, self._kin) x0, y0 = getattr(self._man, "start", (0.0, 0.0)) s0 = vdsim.make_init_state(vp, tp, x=x0, y=y0, yaw=self._man.init_yaw, v=self._man.init_v) @@ -718,7 +806,9 @@ def __init__(self, scenario, dt=None, duration=None): sp = exp._sensors.sp if exp._sensors else vdsim.SensorParams() self.dt = dt or exp.dt self.duration = duration if duration is not None else getattr(exp, "_duration", 1e18) - self.sess = exp._road._session(self._vp, exp._tire.tp, exp.level, self.dt, sp) + self.sess, self._kin_info = _build_session( + exp._road, self._vp, exp._tire.tp, exp.level, self.dt, sp, + getattr(exp, "_kin", None)) man = exp._man x0, y0 = getattr(man, "start", (0.0, 0.0)) self.sess.reset(vdsim.make_init_state(self._vp, exp._tire.tp, @@ -890,7 +980,7 @@ class Sim: def __init__(self, vehicle="sedan", tire="default_pacejka", level="L2", road=None, sensors=None, dt=0.005, x0=0.0, y0=0.0, yaw0=0.0, v0=0.0, sensor_mounts=None, - ref_point=None): + ref_point=None, kinematics=None): self._veh = _as_vehicle(vehicle) self._tire = _as_tire(tire) self._road = road or Road.flat() @@ -902,7 +992,8 @@ def __init__(self, vehicle="sedan", tire="default_pacejka", level="L2", sp = vdsim.SensorParams() else: sp = sensors # raw vdsim.SensorParams - self.sess = self._road._session(vp, tp, level, dt, sp) + self.sess, self._kin_info = _build_session( + self._road, vp, tp, level, dt, sp, kinematics) self.sess.reset(vdsim.make_init_state(vp, tp, x=x0, y=y0, yaw=yaw0, v=v0)) self._vp = vp self._tp = tp diff --git a/tests/integration/test_multi_vehicle.cpp b/tests/integration/test_multi_vehicle.cpp index 5db192f1..2ae65331 100644 --- a/tests/integration/test_multi_vehicle.cpp +++ b/tests/integration/test_multi_vehicle.cpp @@ -695,3 +695,24 @@ TEST(MultiVehicle, IndependentStateAndCommandRouting) { EXPECT_GT(std::abs(v1->state().yaw_rate()), 1e-3) << "vehicle 1 should yaw"; EXPECT_LT(std::abs(v0->state().yaw_rate()), 1e-3) << "vehicle 0 must stay straight"; } + +// Q20 H2: the shipped L4 demo scene declares `level: L4` through a blueprint. +// load_scene() materializes the scene through python/catalog/materialize.py, +// which maps the blueprint's front_chassis/rear_chassis parts onto +// front_susp/rear_susp, so realtime_server's attach_susp_parts() receives real +// hardpoint YAMLs and that L4 label is backed by suspension physics. +// Measured, not assumed: reading cosim/world_scenario.cpp alone says the +// opposite, because it only parses an explicit `front_susp:` key. If +// materialization ever stops carrying those parts, the label silently loses +// its physics -- that regression fails here. +TEST(MultiVehicle, BlueprintSceneCarriesSuspensionHardpoints) { + const std::string scene = std::string(VDSIM_SOURCE_DIR) + + "/configs/scenes/l4_sedan_kinematics.yaml"; + const auto w = vdsim::cosim::load_scene(scene); + ASSERT_EQ(w.vehicles.size(), 1u); + EXPECT_EQ(w.vehicles[0].level, "L4"); + EXPECT_NE(w.vehicles[0].front_susp.find("mp_front_sedan"), std::string::npos) + << "front_susp = " << w.vehicles[0].front_susp; + EXPECT_NE(w.vehicles[0].rear_susp.find("ta_rear_sedan"), std::string::npos) + << "rear_susp = " << w.vehicles[0].rear_susp; +} diff --git a/tests/scripts/test_experiment_api.py b/tests/scripts/test_experiment_api.py index 274a5cdb..cfef7340 100644 --- a/tests/scripts/test_experiment_api.py +++ b/tests/scripts/test_experiment_api.py @@ -147,6 +147,62 @@ def test_ref_point_position(): assert sim_u._ref == [1.0, 0.5] +# --- Q20 (i): what actually discriminates L3 from L4 ------------------------ +# KinematicFourteenDOFDynamics (core/src/fourteen_dof_dynamics.cpp) overrides +# level() and nothing else, and attach_front/rear_kinematics dynamic_cast to +# the shared base. So the level label is not the discriminator -- the attach +# is. These tests keep that fact from being rediscovered as a surprise, and +# are why a trace manifest needs kinematics provenance next to model_level. +HARDPOINTS = {"front": "mp_front_sedan", "rear": "ta_rear_sedan"} + + +def _l3_l4_rows(kin=None, n=300): + """Run one identical input tape on L3 and L4; return both logged tables.""" + out = {} + for level in ("L3", "L4"): + sim = Sim(level=level, road=Road.flat(mu=1.0), v0=15.0, kinematics=kin) + for k in range(n): + sim.set_input(steer=0.03 * math.sin(0.02 * k), throttle=0.15) + sim.run_core_dt() + out[level] = [list(r) for r in sim.rows] + return out + + +def test_level_label_alone_carries_no_suspension_physics(): + bare = _l3_l4_rows(None) + assert bare["L3"] == bare["L4"], "bare L4 must be bit-identical to bare L3" + kin = _l3_l4_rows(HARDPOINTS) + assert kin["L3"] == kin["L4"], \ + "hardpoints attach to both levels -- level() is a label, not physics" + return bare, kin + + +def test_hardpoints_are_the_real_discriminator(): + bare, kin = test_level_label_alone_carries_no_suspension_physics() + d = max(abs(a[i] - b[i]) + for a, b in zip(bare["L4"], kin["L4"]) for i in range(len(a))) + assert d > 1e-9, \ + f"attaching hardpoints must change the trajectory (max |delta| = {d:.3e})" + + +def test_hardpoints_refused_below_l3(): + try: + Sim(level="L2", road=Road.flat(), kinematics=HARDPOINTS) + except RuntimeError as e: + assert "L3/L4" in str(e), f"unexpected message: {e}" + else: + raise AssertionError("L2 must refuse hardpoints, not silently ignore them") + + +def test_missing_hardpoint_file_is_an_error(): + try: + Sim(level="L3", road=Road.flat(), kinematics={"front": "no_such_kin"}) + except FileNotFoundError: + pass + else: + raise AssertionError("a missing hardpoint YAML must raise, not skip the attach") + + if __name__ == "__main__": test_throttle_then_brake() test_step_steer_yaws() @@ -159,4 +215,8 @@ def test_ref_point_position(): test_register_metric() test_plot_comparison() test_ref_point_position() + test_level_label_alone_carries_no_suspension_physics() + test_hardpoints_are_the_real_discriminator() + test_hardpoints_refused_below_l3() + test_missing_hardpoint_file_is_an_error() print("OK test_experiment_api") From b4033117d584074174f985ae5ad3331c0317567a Mon Sep 17 00:00:00 2001 From: JiwonSeok Date: Tue, 22 Sep 2026 16:34:07 +0900 Subject: [PATCH 2/4] Q20 (iii) + H1 + C-1 (v): schema 0.4 kinematics_attached, refuse bare L4 Schema 0.4 adds one required manifest field, kinematics_attached. It follows the 0.1 -> 0.2 role precedent: SCHEMA_VERSION 0.4, READABLE adds 0.4, a 0.4 manifest without the field (or with a non-bool) is a TraceSchemaError, and a 0.1-0.3 trace reads as unknown (None, never False) with one warning raised on first access. TraceWriter takes it as a keyword-only argument with no default. The 0.3 gates now apply to 0.3 and later instead of == "0.3". The value comes from what the attach returned. Experiment therefore stages its writer in enable_trace() and opens it in run() once _build_session() has attached; finalize_trace() before run() raises instead of inventing a state. VDSimPlant has no attach call and records false. golden_v0_3.vdtrace is untouched and its maker now refuses to run past 0.3. golden_v0_4.vdtrace re-declares the same channels, sample for sample, under the 0.4 writer with kinematics_attached false. H1: _build_session() raises ValueError for level L4 with nothing attached, naming kin=... and level='L3'. It is the seam every vdsim_lab path shares, so traced and untraced runs are covered alike. L3 with hardpoints stays legal. The bare-L3 == bare-L4 fact is still measured, one layer down on the raw session factory, and cross-checked against Sim so the probe cannot drift. C-1 (v): with bare L4 refused at the seam, the campaign level axis is no longer forbidden. FORBIDDEN_AXES is empty; a bare L4 run in a level sweep is an error row with no trace. Campaign docs and mu_sweep.yaml say so. No new ctest target: the checks live in trace_schema_0_3, experiment_api and campaign_runner. Registration surface untouched. Co-Authored-By: Claude Opus 5 --- configs/campaigns/mu_sweep.yaml | 9 +- docs/design/BATCH_RUNNER.md | 27 +++-- python/vdsim_campaign.py | 27 ++--- python/vdsim_lab.py | 46 +++++++- python/vdsim_plant.py | 3 + python/vdsim_trace.py | 96 ++++++++++++++-- tests/fixtures/trace/golden_v0_4.vdtrace | Bin 0 -> 118348 bytes tests/fixtures/trace/make_golden_trace_0_3.py | 11 ++ tests/fixtures/trace/make_golden_trace_0_4.py | 76 +++++++++++++ tests/scripts/test_campaign_runner.py | 49 ++++---- tests/scripts/test_experiment_api.py | 92 ++++++++++++++- tests/scripts/test_render3d.py | 3 +- tests/scripts/test_render_presets.py | 2 +- tests/scripts/test_trace_0_3.py | 106 ++++++++++++++++++ tests/scripts/test_trace_multi_render.py | 2 +- tests/scripts/test_trace_render.py | 2 +- tests/scripts/test_trace_roundtrip.py | 1 + 17 files changed, 476 insertions(+), 76 deletions(-) create mode 100644 tests/fixtures/trace/golden_v0_4.vdtrace create mode 100755 tests/fixtures/trace/make_golden_trace_0_4.py diff --git a/configs/campaigns/mu_sweep.yaml b/configs/campaigns/mu_sweep.yaml index c9b0322e..620aea11 100644 --- a/configs/campaigns/mu_sweep.yaml +++ b/configs/campaigns/mu_sweep.yaml @@ -25,8 +25,7 @@ sweep: grid: mu: [0.9, 0.7, 0.5] -# Note: `level` is not a sweepable axis. L3 and L4 differ only once suspension -# hardpoints are attached and the scenario path attaches none, so the runner -# refuses the axis rather than archive traces whose manifest model_level is -# false. Declare one level per campaign; Q20 lifts the refusal -# (18_dev_briefing_0903 §30.4, §31 C-1). +# Note: `level` is a sweepable axis since Q20. An `L4` run needs suspension +# hardpoints; without them it is refused (index status `error`) rather than +# run as L3 under an L4 label, and every trace states `kinematics_attached` +# (18_dev_briefing_0903 §51). diff --git a/docs/design/BATCH_RUNNER.md b/docs/design/BATCH_RUNNER.md index f62cd001..c9810ef6 100644 --- a/docs/design/BATCH_RUNNER.md +++ b/docs/design/BATCH_RUNNER.md @@ -156,23 +156,26 @@ Q0 is the case in point. - **Overlay rendering and aggregate plots.** Those stay where they are; a consumer script that reads the index is the place for them. -## 7. A `level` axis is refused +## 7. A `level` axis and the bare-L4 refusal -`L4` and `L3` are the same physics unless suspension hardpoints are attached, -and the scenario path attaches none. A level sweep would therefore produce -identical runs whose manifests disagree about which model produced them, and -the false `model_level` outlives any warning printed at run time. So the runner -refuses the axis outright, on every declaration form: +`L4` and `L3` are the same physics unless suspension hardpoints are attached. +Until Q20 a level sweep therefore produced identical runs whose manifests +disagreed about which model produced them, and the runner refused the axis. + +Q20 moved the refusal to where the false label is born: building an `L4` +session with no hardpoints attached raises, on every path (campaign, `Sim`, +`Experiment`), whether or not a trace is recorded. In a campaign that run +becomes an `error` row with no trace: ``` -CampaignError: 'level' is not a campaign axis until Q20: L4 is identical to L3 -unless suspension hardpoints are attached, so a level sweep records traces -whose manifest model_level is false. Declare one level per campaign. +ValueError: level='L4' with no suspension hardpoints attached is L3 under another +label (the two are bit-identical until an attach); pass kin=... +(kinematics={'front': , 'rear': }) or use level='L3' ``` -Declare one level per campaign instead. The refusal is lifted by Q20, which -attaches the hardpoints and adds a `kinematics_attached` field to the manifest -so a reader can tell the two apart. See 18_dev_briefing_0903 §30.4, §31 C-1. +A `level` axis is accepted again, and every trace written from schema 0.4 on +states `kinematics_attached`, so a reader can tell the two apart. See +18_dev_briefing_0903 §30.4, §31 C-1, §51. ## 8. CLI diff --git a/python/vdsim_campaign.py b/python/vdsim_campaign.py index a018700d..2d0168f9 100755 --- a/python/vdsim_campaign.py +++ b/python/vdsim_campaign.py @@ -39,11 +39,11 @@ The index is a lookup table, not a result database: it carries the axis values and the run's disposition, never metrics (EX3). -A ``level`` axis is refused, not merely discouraged: ``L4`` is identical to -``L3`` unless suspension hardpoints are attached, the scenario path attaches -none, and the manifest would still claim ``model_level: L4`` -(18_dev_briefing_0903 30.4, 31 C-1). Declare one level per campaign. Q20 lifts -the refusal once the manifest carries ``kinematics_attached``. +A ``level`` axis is allowed since Q20 (18_dev_briefing_0903 §51). It was +refused while an ``L4`` run with no suspension hardpoints still produced a +trace claiming ``model_level: L4``; now the session seam refuses such a run +(it lands in the index as ``error``) and every trace states +``kinematics_attached``, so a level sweep can no longer archive a false label. """ import argparse import copy @@ -76,18 +76,11 @@ DEFAULT_ROOT_SEED = 1000 #: Axes a campaign may not sweep, with the message the refusal carries. -#: ``level`` is here because sweeping it would mass-produce traces whose -#: ``model_level`` names a model the run did not use: the L4 class differs from -#: L3 only in ``level()`` until suspension hardpoints are attached, and nothing -#: on the scenario path attaches them. The alternative -- letting the sweep run -#: and warning -- was rejected because the false provenance survives in the -#: archived trace long after the warning has scrolled away. -FORBIDDEN_AXES = { - "level": ("'level' is not a campaign axis until Q20: L4 is identical to L3 " - "unless suspension hardpoints are attached, so a level sweep " - "records traces whose manifest model_level is false. Declare one " - "level per campaign."), -} +#: Empty since Q20 lifted ``level`` (C-1 condition v): the refusal existed +#: because a bare L4 run recorded a false ``model_level``, and that run is now +#: rejected where the session is built. The mechanism stays so a future axis +#: with the same property is refused in the one place every form goes through. +FORBIDDEN_AXES = {} #: Trace file name inside a run directory. Fixed, so a consumer can find the #: artefact from the run id alone. diff --git a/python/vdsim_lab.py b/python/vdsim_lab.py index ffdbbffd..c2d464e4 100644 --- a/python/vdsim_lab.py +++ b/python/vdsim_lab.py @@ -112,11 +112,24 @@ def _build_session(road, vp, tp, level, dt, sensors, kin=None): Experiment.run, Simulation and Sim all go through here so they cannot drift into three different notions of what a level label means. + It is also where a false ``L4`` label is refused. The L4 class differs + from L3 only in ``level()``; without hardpoints an ``L4`` session *is* L3, + and any comparison or trace labelled L4 would misreport the physics. + Refusing here, where the session first exists, covers traced and untraced + runs alike. + :returns: ``(session, kinematics_provenance)``. + :raises ValueError: for ``level="L4"`` with no hardpoints attached. """ sess = road._session(vp, tp, level, dt, sensors) front, rear = _kin_spec(kin) - return sess, attach_kinematics(sess, front, rear) + info = attach_kinematics(sess, front, rear) + if level == "L4" and not info["attached"]: + raise ValueError( + "level='L4' with no suspension hardpoints attached is L3 under another " + "label (the two are bit-identical until an attach); pass kin=... " + "(kinematics={'front': , 'rear': }) or use level='L3'") + return sess, info def resolve_line(dl): @@ -566,7 +579,11 @@ def __init__(self, level="L2", dt=0.005): self._kin = None self._kin_info = {"front": None, "rear": None, "attached": False} # Trace recording is opt-in; `_trace is None` is the OFF fast path. + # enable_trace() only stages the writer arguments: kinematics_attached + # is what the attach returned, which run() learns when it builds the + # session, so the writer cannot exist before then. self._trace = None + self._trace_pending = None self._channels = () def vehicle(self, v): self._veh = v; return self @@ -664,7 +681,7 @@ def enable_trace(self, path, decimation=None, seed=None, run_id=None, import vdsim_trace import vdsim_plant - if self._trace is not None: + if self._trace is not None or self._trace_pending is not None: raise RuntimeError("trace already enabled; call finalize_trace() first") if decimation is None: decimation = max(1, int(round(1.0 / (vdsim_plant.TRACE_TARGET_HZ * self.dt)))) @@ -717,7 +734,7 @@ def enable_trace(self, path, decimation=None, seed=None, run_id=None, base = tuple(n for n in vdsim_trace.BASE_CHANNELS if n != "u_fx") self._channels = vdsim_trace.channels_for_level(self.level, base=base) self._sample = vdsim_plant.trace_sample - self._trace = vdsim_trace.TraceWriter( + self._trace_pending = dict( path=path, geometry=geometry, model_level=self.level, @@ -738,8 +755,15 @@ def finalize_trace(self): """Flush channels, freeze the manifest and close the trace. :returns: the written path, or ``None`` when recording was never on. + :raises RuntimeError: when a trace was enabled but :meth:`run` never + built the session, so ``kinematics_attached`` was never observed. """ if self._trace is None: + if self._trace_pending is not None: + self._trace_pending = None + raise RuntimeError( + "finalize_trace() before run(): kinematics_attached is what the " + "hardpoint attach returns, and no session was built to ask") return None writer, self._trace = self._trace, None return writer.finalize() @@ -747,7 +771,19 @@ def finalize_trace(self): @property def trace_path(self): """Path of the trace being recorded, or ``None``.""" - return None if self._trace is None else self._trace.path + if self._trace is not None: + return self._trace.path + if self._trace_pending is not None: + return self._trace_pending["path"] + return None + + def _open_trace(self): + """Create the staged writer now that the attach outcome is known.""" + import vdsim_trace + + spec, self._trace_pending = self._trace_pending, None + self._trace = vdsim_trace.TraceWriter( + kinematics_attached=bool(self._kin_info["attached"]), **spec) def _record(self, o, cmd): """Offer one sample of the pre-step state to the writer.""" @@ -762,6 +798,8 @@ def run(self, duration=None): sp = self._sensors.sp if self._sensors else vdsim.SensorParams() sess, self._kin_info = _build_session( self._road, vp, tp, self.level, self.dt, sp, self._kin) + if self._trace_pending is not None: + self._open_trace() x0, y0 = getattr(self._man, "start", (0.0, 0.0)) s0 = vdsim.make_init_state(vp, tp, x=x0, y=y0, yaw=self._man.init_yaw, v=self._man.init_v) diff --git a/python/vdsim_plant.py b/python/vdsim_plant.py index 82afb3d6..2fef26e2 100644 --- a/python/vdsim_plant.py +++ b/python/vdsim_plant.py @@ -636,6 +636,9 @@ def enable_trace(self, path, decimation=None, seed=None, run_id=None, geometry=geometry, model_level=self.level, contact_scope=CONTACT_SCOPE_BY_LEVEL[self.level], + # This plant builds a direct-control session and has no attach call, + # so no hardpoints are ever behind it; the manifest must say so. + kinematics_attached=False, channels=self._channels, tire={"friction_shape": shape, "mu_aniso": aniso, "mu_aniso_source": "measured"}, diff --git a/python/vdsim_trace.py b/python/vdsim_trace.py index 642a7236..0d65beef 100644 --- a/python/vdsim_trace.py +++ b/python/vdsim_trace.py @@ -44,13 +44,16 @@ #: Container schema version written by this module. ``0.x`` is unstable — #: see the release notes. ``0.2`` added the required ``role`` field (§3.1); #: ``0.3`` added ``model_level``, ``contact_scope``, the 3D geometry block and -#: the five 3D channels of §3.2.1. -SCHEMA_VERSION = "0.3" +#: the five 3D channels of §3.2.1; ``0.4`` added the required +#: ``kinematics_attached`` (§13). +SCHEMA_VERSION = "0.4" #: ``0.x`` minors this reader accepts. Each ``0.x`` minor is its own line, so #: readability is opt-in rather than inferred: ``0.1`` stays readable only -#: because :func:`_resolve_role` defines a fallback for its missing ``role``. -READABLE_SCHEMA_VERSIONS = ("0.1", "0.2", "0.3") +#: because :func:`_resolve_role` defines a fallback for its missing ``role``, +#: and ``0.1``–``0.3`` only because :attr:`TraceReader.kinematics_attached` +#: defines one (unknown, never ``False``) for theirs. +READABLE_SCHEMA_VERSIONS = ("0.1", "0.2", "0.3", "0.4") #: Declared role of the run a trace records (§3.1, decided 2026-09-02). #: ``plant`` is the simulator under verification; ``predictor`` is the same @@ -167,6 +170,10 @@ def _level_rank(model_level) -> int: "mass_kg", "cg_height_m", "wheel_radius_m", "wheel_width_m", "body_lwh_m") #: Manifest keys 0.3 adds on top of :data:`_REQUIRED_MANIFEST_KEYS`. _REQUIRED_MANIFEST_KEYS_0_3 = ("model_level", "contact_scope") +#: Manifest key 0.4 adds. ``model_level`` alone cannot say whether an L3/L4 run +#: had suspension hardpoints behind it: the two levels are bit-identical with +#: or without them, and the attach is the only physical discriminator. +_REQUIRED_MANIFEST_KEYS_0_4 = ("kinematics_attached",) class TraceError(ValueError): @@ -224,6 +231,24 @@ def _schema_compatible(found: str) -> bool: return f_minor <= c_minor +def _at_least(version, floor: str) -> bool: + """Return True if schema ``version`` is ``floor`` or newer. + + A requirement added at one minor applies to every later one; comparing + with ``==`` would silently drop the 0.3 checks the moment 0.4 shipped. + + :param version: declared ``schema_version`` (string or anything str-able). + :param floor: ``"major.minor"`` the requirement was introduced at. + :returns: False for an unparseable version (it is rejected elsewhere). + """ + try: + found = tuple(int(p) for p in str(version).split(".")[:2]) + need = tuple(int(p) for p in floor.split(".")[:2]) + except (ValueError, TypeError): + return False + return found >= need + + def _resolve_role(manifest) -> str: """Return the run's declared role, applying the ``0.1`` fallback. @@ -346,12 +371,20 @@ class TraceWriter: :data:`CONTACT_SCOPES`. Declare what the core consumes, not what the picture implies; a run whose renderer tilts the road while the physics stays flat is exactly what this field exposes. + :param kinematics_attached: keyword-only and **required** at schema 0.4 — + a real ``bool``: whether suspension hardpoints were attached to the + plant. Pass what the attach call returned, never what a config file + suggests; ``L4`` without it is ``L3`` under another name. """ def __init__(self, path, geometry, tire, repro, producer=None, decimation: int = 1, channels=None, extra=None, *, role, - model_level, contact_scope): + model_level, contact_scope, kinematics_attached): decimation = int(decimation) + if not isinstance(kinematics_attached, bool): + raise TraceError( + "kinematics_attached must be True or False, got %r" + % (kinematics_attached,)) if decimation < 1: raise TraceError("decimation must be >= 1, got %r" % (decimation,)) if model_level not in MODEL_LEVELS: @@ -380,6 +413,7 @@ def __init__(self, path, geometry, tire, repro, producer=None, self.role = role self.model_level = model_level self.contact_scope = contact_scope + self.kinematics_attached = kinematics_attached self.decimation = decimation self.geometry = dict(geometry) self.tire = dict(tire) @@ -468,6 +502,7 @@ def finalize(self) -> Path: "role": self.role, "model_level": self.model_level, "contact_scope": self.contact_scope, + "kinematics_attached": self.kinematics_attached, "repro": self.repro, "n_steps": int(n), "wheels": list(WHEELS), @@ -491,7 +526,7 @@ def finalize(self) -> Path: def _validate_geometry(geometry, schema_version=None): """Validate the geometry block for the schema version that declared it. - The 0.3 keys are checked only on a 0.3 manifest: a 0.2 trace predates them + The 0.3 keys are checked only on a 0.3+ manifest: a 0.2 trace predates them and stays readable, which is what keeps old fixtures alive. """ if not isinstance(geometry, dict): @@ -502,7 +537,7 @@ def _validate_geometry(geometry, schema_version=None): "manifest.geometry is missing %s — the renderer draws the body " "rectangle, front-wheel position and steering angle from this block " "and must never guess vehicle parameters" % (", ".join(missing),)) - if str(schema_version) != "0.3": + if not _at_least(schema_version, "0.3"): return missing = [k for k in _REQUIRED_GEOMETRY_KEYS_0_3 if k not in geometry] if missing: @@ -561,8 +596,30 @@ def _validate_manifest(manifest): _validate_tire(manifest["tire"]) if not isinstance(manifest["channels"], list) or not manifest["channels"]: raise TraceSchemaError("manifest.channels must be a non-empty list") - if version == "0.3": + if _at_least(version, "0.3"): _validate_manifest_0_3(manifest) + if _at_least(version, "0.4"): + _validate_manifest_0_4(manifest) + + +def _validate_manifest_0_4(manifest): + """Enforce the 0.4 addition — ``kinematics_attached`` must be stated. + + Missing is an error, not ``False``: a 0.4 producer that forgot the field is + exactly the one whose ``model_level`` cannot be trusted, and defaulting it + would hide that behind a plausible value. + """ + missing = [k for k in _REQUIRED_MANIFEST_KEYS_0_4 if k not in manifest] + if missing: + raise TraceSchemaError( + "manifest is missing %s — schema 0.4 requires it (§13): L3 and L4 " + "are bit-identical until suspension hardpoints are attached, so " + "model_level alone does not say which physics produced the run" + % (", ".join(missing),)) + if not isinstance(manifest["kinematics_attached"], bool): + raise TraceSchemaError( + "manifest.kinematics_attached must be true or false, got %r" + % (manifest["kinematics_attached"],)) def _validate_manifest_0_3(manifest): @@ -647,6 +704,7 @@ def __init__(self, path): raise self.manifest = manifest self._role = role + self._kin_warned = False self._cache = {} # -- lifecycle --------------------------------------------------------- @@ -705,6 +763,28 @@ def contact_scope(self): """Declared road-normal coupling (§3.1.1), or ``None`` before 0.3.""" return self.manifest.get("contact_scope") + @property + def kinematics_attached(self): + """Whether suspension hardpoints were attached (§13), or ``None``. + + ``None`` means *unknown*: a 0.1–0.3 trace predates the field, and + ``False`` would be a claim the file never made. The first read of an + unknown value warns once; the warning is raised here rather than at + open time so a consumer that never asks is not told about it. + """ + if "kinematics_attached" in self.manifest: + return bool(self.manifest["kinematics_attached"]) + if not self._kin_warned: + self._kin_warned = True + warnings.warn( + "trace schema_version %s declares no 'kinematics_attached'; " + "treating it as unknown (not False). Re-record with vdsim_trace " + "%s to tell an L4 run with suspension hardpoints from an L3 run " + "under the L4 label." + % (self.manifest.get("schema_version"), SCHEMA_VERSION), + UserWarning, stacklevel=2) + return None + @property def normal_is_display_only(self) -> bool: """True when ``wheel_road_normal`` must be labelled display-only. diff --git a/tests/fixtures/trace/golden_v0_4.vdtrace b/tests/fixtures/trace/golden_v0_4.vdtrace new file mode 100644 index 0000000000000000000000000000000000000000..133dfef1b212e2e4326ea4d0ec8f35ae6045caa4 GIT binary patch literal 118348 zcmeFa1yt2d7ypY&2!c{7iXx&S7H|;6o^64OO4@-mNVkCj3YdtLjUYDC*l~V#i-nlj z-Q9W*&YqvoGiROqzW4p#weGrW$y%;?=5uQH{3iE&XFpC3b(Hm$YW^C`=`#L^{^FR4 zno3GF|FmQ**drvs*C#T{d{Sg+$TSUOV-qibkB|_bph%PM#$#o9W8)fH^Jn56H6_eP zhE00xtr z3yt*o4?oC$$k@2E!ru<=7~CwSoa8zsQ*+8 z3V&8+_W$<7(e9B^K0Xou!SesR-~C^zJKEiM^8fmi!~T~ajP>{N339jl55M`Rky-!w zPsQ!+|DYtn(f@r>6jpwx|Gg#xJ;K5~{=Y4PM^Kpm{~Ja4AA5YwnBzV*BJ97e1jX&( z_o(x~jyxX!&qkjAn^IUrPO$plTSP>thqt@;)c?9VtQ9uP;QzxygoH)}dj$RW1^I8f z>i%chVZpPI%XTj&hwOrKRq%>)`&v%r!qi+;Z`EF#o9+DkU4icgQNs)9WNecb&* zgS>r0+-t1$akq50HVq^%yMrZuYejGet(Aq>s9`3egU#i$_C$>-}x{9usfQBc|>>wyZd`Y`qz9~_9a#} zUAlMfY~9(%)~AcNjg40~4{wRDk5v~d-!2|Dwm#k-5{ajWwWPDPWoKJU59=%Q_T>@LA?^V+wQhoy6eFu2_F|;Z?+0RKY1PHDo1`lm7a8qY zQ~&>{3rkDZgk!uT1A^WEQK`NGlcS;|eEw0fp+UcwyJ0~dA#}-W5-EG|!5;toRL!IK z{a~X!{A%hOyCnNEv9w}Ae}BX8%EeF0T2JJZkSKp2S?iMVsEBAE@!3WN$O`o5OCtij zY93$BtarJ8^BWm^%p@vw+8 z_w8a`vtgoc`S;&N{QM))ep>e(wzuUh{QfH}U(c3AN%j{?2VOkbVGv?+&Rc&A7#P@ptc$-^H!EhrfTH zw5T!H=mD1F0rB&#`Fn`vc|>YzN5Ak9mg_P3SX0~&kFk7DNblbj@D$7Wl>Gj>#AjIE z=j6=kn!o2*?iZxt?=O6b<$p}XxAJ3-8X388~pvZXy;oj z$6K`b9hT=E+Wij8^&ahikL7!h_3#19`2p+W1D5wA*2_mM_eZRsPgwp>SWlnOj?ZYv zXSCxp+VL6f_=0wPK|8*n9bZZ6@8|Uu?f8mzd_z0Fp&j4Qj_+v4ceLX>+VKPJ_M~wBr}r@eA!x0@|LHfVN8|p#4M%XuDPd`h1muwhJYo?Wq>fc32B& zd#weuAJqcd-fIEvN40>qhuT2fQEi~@uQt$jTN`M9tPQkT*9O|(>HzIWb%3^?IzZc9 z9iZ*C4$yX92WbCLMmv64ed}vJJiq)HMBzw?NCEI>Y^QW(T=)kM_sg|F4|ES?Wl`(sG}X~ zXoou5p^kQ_qaEsKhX&fAfp%yBZI>EohX&fAfp*kGJL;hw_0W!bXh%J?qaNC!iFRnB z9hzu|CfcEic4(p+M$JZXrUciXonWsp@nwTM?31H9re+U`e;Xew4*-S(E#me zfOa%MI~t%J4bYATXooibjyBq(jlZXjc4@=!;nY_f?bF8gppABFV>z_ZUTrLoHrlO? z<
b+CLoSPwc_P93Zd9W1X7){737TL>p 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 float: def build(): + """Write the fixture. Refuses once the module has moved past ``0.3``. + + ``golden_v0_3.vdtrace`` is what keeps the 0.3 read path — including the + unknown ``kinematics_attached`` fallback — exercised; regenerating it at a + newer schema would delete that coverage behind a passing run. + """ + if vdsim_trace.SCHEMA_VERSION != "0.3": + raise SystemExit( + "vdsim_trace is at %s; golden_v0_3.vdtrace is frozen. Use " + "make_golden_trace_0_4.py for the current schema." + % vdsim_trace.SCHEMA_VERSION) plant_params = { "config": "fixture_vehicle_l3.yaml", "base_mu": MU, "substep_dt": 5e-4, "level": "L3", diff --git a/tests/fixtures/trace/make_golden_trace_0_4.py b/tests/fixtures/trace/make_golden_trace_0_4.py new file mode 100755 index 00000000..e41f6a8a --- /dev/null +++ b/tests/fixtures/trace/make_golden_trace_0_4.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Generate ``golden_v0_4.vdtrace`` — the fixture of schema ``0.4``. + +0.4 adds exactly one manifest field, ``kinematics_attached``, so the fixture +re-declares the frozen 0.3 ride under the 0.4 writer instead of synthesising a +second one: every channel is copied sample-for-sample from +``golden_v0_3.vdtrace``, and only the manifest changes. The 0.3 file stays in +place untouched; it is what exercises the 0.3 read path. + +``kinematics_attached`` is ``false``: the ride is synthetic and no hardpoint +set was ever attached to anything, so ``false`` is the only value the file can +state truthfully. + +Regenerate with:: + + python3 tests/fixtures/trace/make_golden_trace_0_4.py +""" +from __future__ import annotations + +import sys +import warnings +from pathlib import Path + +REPO = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(REPO / "python")) + +import vdsim_trace # noqa: E402 + +HERE = Path(__file__).resolve().parent +SRC = HERE / "golden_v0_3.vdtrace" +OUT = HERE / "golden_v0_4.vdtrace" + + +def build(): + """Copy the 0.3 fixture's channels into a 0.4 container. + + :returns: the written path. + :raises SystemExit: when the module is not at 0.4 (the fixture would then + not be the 0.4 one) or the source fixture is missing. + """ + if vdsim_trace.SCHEMA_VERSION != "0.4": + raise SystemExit( + "vdsim_trace is at %s; golden_v0_4.vdtrace is the 0.4 fixture and " + "is frozen once the schema moves on." % vdsim_trace.SCHEMA_VERSION) + if not SRC.is_file(): + raise SystemExit("source fixture missing: %s" % SRC) + with warnings.catch_warnings(): + # The 0.3 source has no kinematics_attached; it is never read here. + warnings.simplefilter("ignore") + src = vdsim_trace.TraceReader(SRC) + with src: + m = src.manifest + names = [c["name"] for c in m["channels"]] + data = {n: src.channel(n) for n in names} + repro = dict(m["repro"], run_id="golden_v0_4") + tags = dict(m.get("tags", {}), schema="0.4") + writer = vdsim_trace.TraceWriter( + path=OUT, + geometry=m["geometry"], + tire=m["tire"], + repro=repro, + producer={"name": "make_golden_trace_0_4.py", "version": "0.4"}, + channels=names, + extra={"tags": tags}, + role=m["role"], + model_level=m["model_level"], + contact_scope=m["contact_scope"], + kinematics_attached=False, + ) + for i in range(src.n_steps): + writer.append({n: data[n][i] for n in names}) + return writer.finalize() + + +if __name__ == "__main__": + print(build()) diff --git a/tests/scripts/test_campaign_runner.py b/tests/scripts/test_campaign_runner.py index e516011d..d80dd474 100644 --- a/tests/scripts/test_campaign_runner.py +++ b/tests/scripts/test_campaign_runner.py @@ -268,38 +268,45 @@ def test_superseded_entry_points(): # --------------------------------------------------------------------------- # -# C-1: a level axis must be refused, on every expansion form +# C-1 (v): a level axis is accepted; a bare L4 run is refused by the seam # --------------------------------------------------------------------------- # -def test_level_axis_refused(): - """``level`` is not sweepable until the manifest can tell the truth. +def test_level_axis_after_q20(tmp): + """``level`` sweeps, and cannot archive a false L4 label. - L4 differs from L3 only in ``level()`` until suspension hardpoints are - attached, so a level sweep would archive traces whose ``model_level`` names - a model the run did not use. The refusal is checked on the grid, list and - legacy forms because each builds its axis dict differently. + The axis was refused while a bare L4 run recorded ``model_level: L4`` with + L3 physics. Q20 moved the refusal to the session seam, so the axis is + expanded on every form, the L3 run records ``kinematics_attached: false`` + and the bare L4 run is an ``error`` row with no trace at all. """ forms = { "grid": {"base": "step_steer", "sweep": {"grid": {"level": ["L3", "L4"]}}}, "list": {"base": "step_steer", "sweep": {"list": [{"level": "L4"}]}}, "legacy": {"runs": [{"sweep": {"base": "step_steer", "grid": {"level": ["L3", "L4"]}}}]}, - "monte_carlo": {"runs": [{"monte_carlo": { - "base": "step_steer", "n": 1, - "vary": {"level": {"lo": 3, "hi": 4}}}}]}, } for name, spec in forms.items(): try: - vc.expand(spec) + runs = vc.expand(spec) + check(len(runs) >= 1, "%s form expands a level axis" % name) except vc.CampaignError as exc: - check("level" in str(exc) and "Q20" in str(exc), - "%s form refuses a level axis and says why (%s)" - % (name, str(exc)[:48])) - else: - check(False, "%s form accepted a level axis" % name) - - check(vc.expand({"base": "step_steer", - "sweep": {"grid": {"mu": [0.9, 0.6]}}}) != [], - "the refusal does not catch ordinary axes") + check(False, "%s form still refuses a level axis (%s)" % (name, exc)) + + spec = {"name": "levels", "base": "step_steer", "seed": 20260922, + "duration": DURATION, "sweep": {"grid": {"level": ["L3", "L4"]}}} + out = Path(tmp) / "levels" + _run(_write(tmp, "levels", spec), out) + rows = {r["axes"]["level"]: r for r in vc.read_index(out / "levels")} + check(rows["L3"]["status"] == "ok", "the L3 run completes (%s)" % rows["L3"]["status"]) + l3_trace = out / "levels" / rows["L3"]["trace_path"] + with vt.TraceReader(l3_trace) as tr: + check(tr.model_level == "L3" and tr.kinematics_attached is False, + "the L3 trace states no hardpoints were attached") + err = rows["L4"].get("error") or "" + print(" bare L4 row: status=%s error=%s" % (rows["L4"]["status"], err)) + check(rows["L4"]["status"] == "error" and "level='L4'" in err, + "the bare L4 run is refused by the seam, not run as L3") + check(rows["L4"]["trace_path"] is None, + "no trace is left behind for the refused L4 run") # --------------------------------------------------------------------------- # @@ -349,11 +356,11 @@ def test_resolved_preset_is_private(): def main(): with tempfile.TemporaryDirectory(prefix="vdsim_campaign_") as tmp: test_axis_expansion() - test_level_axis_refused() test_mu_aniso_measurement_runs() test_resolved_preset_is_private() test_seed_is_a_function_of_the_declaration() test_contract_and_failure_isolation(tmp) + test_level_axis_after_q20(tmp) spec_path, out = test_determinism(tmp) test_resume(tmp, spec_path, out) test_render_connection(tmp) diff --git a/tests/scripts/test_experiment_api.py b/tests/scripts/test_experiment_api.py index cfef7340..dc86f080 100644 --- a/tests/scripts/test_experiment_api.py +++ b/tests/scripts/test_experiment_api.py @@ -10,7 +10,8 @@ REPO = Path(__file__).resolve().parents[2] sys.path[:0] = [str(REPO / "python"), str(REPO / "build" / "python")] -from vdsim_lab import Sim, Road, Sensors +import vdsim_lab +from vdsim_lab import Sim, Road, Sensors, Experiment def test_throttle_then_brake(): @@ -156,21 +157,56 @@ def test_ref_point_position(): HARDPOINTS = {"front": "mp_front_sedan", "rear": "ta_rear_sedan"} -def _l3_l4_rows(kin=None, n=300): - """Run one identical input tape on L3 and L4; return both logged tables.""" +def _tape(k): + return 0.03 * math.sin(0.02 * k), 0.15 + + +def _l3_l4_rows(kin, n=300): + """Run one identical input tape on L3 and L4 through Sim; return both tables.""" out = {} for level in ("L3", "L4"): sim = Sim(level=level, road=Road.flat(mu=1.0), v0=15.0, kinematics=kin) for k in range(n): - sim.set_input(steer=0.03 * math.sin(0.02 * k), throttle=0.15) + steer, throttle = _tape(k) + sim.set_input(steer=steer, throttle=throttle) sim.run_core_dt() out[level] = [list(r) for r in sim.rows] return out +def _bare_core_rows(level, n=300, dt=0.005): + """Same tape on a raw core session with no attach -- below the seam. + + Sim refuses a bare L4 (the H1 guard), so the "label carries no physics" + fact is measured one layer down, on the session factory _build_session + itself calls. Row layout is vdsim_lab's, so it compares with Sim rows. + """ + import vdsim + veh, tire = vdsim_lab._as_vehicle("sedan"), vdsim_lab._as_tire("default_pacejka") + sess = Road.flat(mu=1.0)._session(veh.vp, tire.tp, level, dt, vdsim.SensorParams()) + sess.reset(vdsim.make_init_state(veh.vp, tire.tp, x=0.0, y=0.0, yaw=0.0, v=15.0)) + rows = [] + for k in range(n): + steer, throttle = _tape(k) + c = vdsim.CmdL4() + c.steer_angle_wheel = steer + c.throttle = throttle + c.gear = 1 + sess.set_input(c) + sess.tick(dt) + rows.append(list(vdsim_lab._make_row(sess.output()))) + return rows + + def test_level_label_alone_carries_no_suspension_physics(): - bare = _l3_l4_rows(None) + bare = {lv: _bare_core_rows(lv) for lv in ("L3", "L4")} assert bare["L3"] == bare["L4"], "bare L4 must be bit-identical to bare L3" + via_sim = Sim(level="L3", road=Road.flat(mu=1.0), v0=15.0) + for k in range(300): + steer, throttle = _tape(k) + via_sim.set_input(steer=steer, throttle=throttle) + via_sim.run_core_dt() + assert [list(r) for r in via_sim.rows] == bare["L3"], "the raw-session probe must reproduce Sim exactly, or it proves nothing" kin = _l3_l4_rows(HARDPOINTS) assert kin["L3"] == kin["L4"], \ "hardpoints attach to both levels -- level() is a label, not physics" @@ -185,6 +221,50 @@ def test_hardpoints_are_the_real_discriminator(): f"attaching hardpoints must change the trajectory (max |delta| = {d:.3e})" +def test_bare_l4_is_refused(): + """H1: L4 with no hardpoints is L3 under a false label -- refuse it.""" + for build in (lambda: Sim(level="L4", road=Road.flat()), + lambda: Experiment(level="L4").run(0.05)): + try: + build() + except ValueError as e: + msg = str(e) + print("H1 refusal: ValueError: %s" % msg) + assert "kin=" in msg and "level='L3'" in msg, f"unexpected message: {msg}" + else: + raise AssertionError("bare L4 must be refused, not run as L3") + # With hardpoints both labels are allowed, and L3 + hardpoints stays legal. + Sim(level="L4", road=Road.flat(), kinematics=HARDPOINTS) + Sim(level="L3", road=Road.flat(), kinematics=HARDPOINTS) + + +def test_trace_states_the_attach(): + """Q20 (iii): the manifest records what the attach returned.""" + import vdsim_trace as vt + with tempfile.TemporaryDirectory() as td: + got = {} + for name, kin in (("bare", None), ("kin", HARDPOINTS)): + exp = Experiment(level="L3") + if kin: + exp.kinematics(**kin) + p = Path(td) / (name + ".vdtrace") + exp.enable_trace(p, seed=0, run_id=name) + exp.run(0.2) + exp.finalize_trace() + with vt.TraceReader(p) as tr: + got[name] = (tr.manifest["schema_version"], tr.kinematics_attached) + assert got["bare"] == (vt.SCHEMA_VERSION, False), got + assert got["kin"] == (vt.SCHEMA_VERSION, True), got + exp = Experiment(level="L3") + exp.enable_trace(Path(td) / "never.vdtrace") + try: + exp.finalize_trace() + except RuntimeError: + pass + else: + raise AssertionError("finalize before run must not invent an attach state") + + def test_hardpoints_refused_below_l3(): try: Sim(level="L2", road=Road.flat(), kinematics=HARDPOINTS) @@ -217,6 +297,8 @@ def test_missing_hardpoint_file_is_an_error(): test_ref_point_position() test_level_label_alone_carries_no_suspension_physics() test_hardpoints_are_the_real_discriminator() + test_bare_l4_is_refused() + test_trace_states_the_attach() test_hardpoints_refused_below_l3() test_missing_hardpoint_file_is_an_error() print("OK test_experiment_api") diff --git a/tests/scripts/test_render3d.py b/tests/scripts/test_render3d.py index 700f2d0c..74397a03 100755 --- a/tests/scripts/test_render3d.py +++ b/tests/scripts/test_render3d.py @@ -277,7 +277,8 @@ def test_display_only_badge(): tire={"friction_shape": "circle", "mu_aniso": [1.0, 1.0]}, repro={"vdsim_version": "t", "git_sha": "x", "param_hash": "sha256:x", "seed": 0, "dt_s": 0.05, "run_id": "scope_" + scope}, - role="plant", model_level="L3", contact_scope=scope, channels=names) + role="plant", model_level="L3", contact_scope=scope, kinematics_attached=False, + channels=names) for i in range(6): w.append({ "t": i * 0.05, "pose": (i * 0.5, 0.0, 0.0), diff --git a/tests/scripts/test_render_presets.py b/tests/scripts/test_render_presets.py index 1c53e1f3..436bedf7 100755 --- a/tests/scripts/test_render_presets.py +++ b/tests/scripts/test_render_presets.py @@ -55,7 +55,7 @@ def _write_trace(path, channels=None, n=40): repro={"vdsim_version": "test", "git_sha": "x", "param_hash": "sha256:x", "seed": 1, "dt_s": 0.05, "run_id": "preset_test"}, producer={"name": "test_render_presets", "version": "0"}, - role="plant", model_level="L2", contact_scope="C2", + role="plant", model_level="L2", contact_scope="C2", kinematics_attached=False, channels=names) full = { "t": 0.0, "pose": (0.0, 0.0, 0.0), "v_body": (12.0, 0.4), diff --git a/tests/scripts/test_trace_0_3.py b/tests/scripts/test_trace_0_3.py index 2850b071..a5a3790b 100755 --- a/tests/scripts/test_trace_0_3.py +++ b/tests/scripts/test_trace_0_3.py @@ -27,6 +27,7 @@ FIXTURE_0_3 = REPO / "tests" / "fixtures" / "trace" / "golden_v0_3.vdtrace" FIXTURE_0_2 = REPO / "tests" / "fixtures" / "trace" / "golden_v0_2.vdtrace" +FIXTURE_0_4 = REPO / "tests" / "fixtures" / "trace" / "golden_v0_4.vdtrace" FAILURES = [] @@ -54,6 +55,7 @@ def _writer(path, **kw): "seed": 1, "dt_s": 0.01, "run_id": "t"}, producer={"name": "test_trace_0_3", "version": "0"}, role="plant", model_level="L3", contact_scope="C2", + kinematics_attached=False, ) args.update(kw) return vt.TraceWriter(path=path, **args) @@ -281,6 +283,105 @@ def test_declared_contact_scope_matches_the_core(): % (level, declared, observed, moved)) +# ------------------------------------------------ 4. schema 0.4 (11 §13) +def _rewrite(src, dst, mutate): + """Copy a trace with its manifest passed through ``mutate``.""" + import json + import zipfile + with zipfile.ZipFile(src) as zin, zipfile.ZipFile(dst, "w") as zout: + for info in zin.infolist(): + data = zin.read(info.filename) + if info.filename == "manifest.json": + m = json.loads(data.decode()) + mutate(m) + data = json.dumps(m).encode() + zout.writestr(info.filename, data) + return dst + + +def test_fixture_0_4(): + """The committed 0.4 fixture states kinematics_attached as a real bool.""" + import warnings + check(FIXTURE_0_4.is_file(), "0.4 fixture is committed") + if not FIXTURE_0_4.is_file(): + return + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + with vt.TraceReader(FIXTURE_0_4) as tr: + check(tr.manifest["schema_version"] == "0.4", "fixture declares schema 0.4") + check(tr.kinematics_attached is False, + "fixture declares kinematics_attached false (%r)" % tr.kinematics_attached) + for name in vt.CHANNEL_MIN_LEVEL: + check(tr.has(name), "0.4 fixture keeps 0.3 channel %r" % name) + check(not caught, "a 0.4 trace reads without warnings (%d)" % len(caught)) + + +def test_0_4_requires_kinematics_attached(): + """0.4 without kinematics_attached is an error, never a silent False.""" + with tempfile.TemporaryDirectory() as td: + d = Path(td) + try: + kw = dict(geometry=dict(GEOMETRY_0_3), + tire={"friction_shape": "circle", "mu_aniso": [1.0, 1.0]}, + repro={"vdsim_version": "t", "git_sha": "x", "param_hash": "sha256:x", + "seed": 1, "dt_s": 0.01, "run_id": "t"}, + role="plant", model_level="L3", contact_scope="C2") + vt.TraceWriter(path=d / "x.vdtrace", **kw) + check(False, "kinematics_attached is a required TraceWriter argument") + except TypeError: + check(True, "kinematics_attached is a required TraceWriter argument") + for bad in (None, 0, "false"): + try: + _writer(d / "x.vdtrace", kinematics_attached=bad) + check(False, "kinematics_attached %r is rejected" % (bad,)) + except vt.TraceError: + check(True, "kinematics_attached %r is rejected at write time" % (bad,)) + + good = d / "good.vdtrace" + names = vt.channels_for_level("L3") + w = _writer(good, channels=names) + for i in range(3): + w.append(_sample(i, names)) + w.finalize() + missing = _rewrite(good, d / "missing.vdtrace", + lambda m: m.pop("kinematics_attached")) + try: + vt.TraceReader(missing) + check(False, "a 0.4 manifest without kinematics_attached is rejected") + except vt.TraceSchemaError as exc: + print(" 0.4 missing -> TraceSchemaError: %s" % exc) + check("kinematics_attached" in str(exc), + "a 0.4 manifest without kinematics_attached is rejected") + wrong = _rewrite(good, d / "wrong.vdtrace", + lambda m: m.__setitem__("kinematics_attached", "yes")) + try: + vt.TraceReader(wrong) + check(False, "a non-bool kinematics_attached is rejected on read") + except vt.TraceSchemaError: + check(True, "a non-bool kinematics_attached is rejected on read") + + +def test_0_3_kinematics_is_unknown_with_one_warning(): + """A 0.3 trace predates the field: unknown (None), warned about once.""" + import warnings + if not FIXTURE_0_3.is_file(): + check(False, "0.3 fixture present") + return + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + with vt.TraceReader(FIXTURE_0_3) as tr: + first = tr.kinematics_attached + again = tr.kinematics_attached + msgs = [str(x.message) for x in caught if issubclass(x.category, UserWarning)] + for m in msgs: + print(" 0.3 fallback -> UserWarning: %s" % m) + check(first is None and again is None, + "a 0.3 trace reports kinematics_attached None, not False (%r)" % (first,)) + check(len(msgs) == 1, "exactly one warning across two reads (got %d)" % len(msgs)) + check(msgs and "0.3" in msgs[0] and "kinematics_attached" in msgs[0], + "the warning names the version and the field") + + def test_plant_records_0_3(): """An L3 plant run produces a 0.3 trace that a reader can use as-is.""" try: @@ -300,6 +401,8 @@ def test_plant_records_0_3(): with vt.TraceReader(p) as tr: check(tr.model_level == "L3", "recorded manifest declares L3") check(tr.contact_scope == "C2", "recorded manifest declares the measured scope") + check(tr.kinematics_attached is False, + "the plant, which never attaches hardpoints, records false") for name in vt.CHANNEL_MIN_LEVEL: check(tr.has(name), "L3 run records %r" % name) zrp = np.asarray(tr.channel("pose_zrp")) @@ -335,6 +438,9 @@ def main(): test_no_zero_fill() test_display_only_label_rule() test_declared_contact_scope_matches_the_core() + test_fixture_0_4() + test_0_4_requires_kinematics_attached() + test_0_3_kinematics_is_unknown_with_one_warning() test_plant_records_0_3() print("\n%d checks failed" % len(FAILURES)) return 1 if FAILURES else 0 diff --git a/tests/scripts/test_trace_multi_render.py b/tests/scripts/test_trace_multi_render.py index b350d03d..a2ba6688 100755 --- a/tests/scripts/test_trace_multi_render.py +++ b/tests/scripts/test_trace_multi_render.py @@ -66,7 +66,7 @@ def write_run(path, n, dt, y0=0.0, v=15.0, yaw0=0.0, yaw_rate=0.0, repro={"vdsim_version": "test", "git_sha": "x", "param_hash": "sha256:x", "seed": 1, "dt_s": dt, "run_id": run_id}, producer={"name": "test_trace_multi_render", "version": "0"}, - role="plant", model_level="L2", contact_scope="C2", + role="plant", model_level="L2", contact_scope="C2", kinematics_attached=False, channels=list(vt.BASE_CHANNELS)) x, y, yaw = 0.0, y0, yaw0 for i in range(n): diff --git a/tests/scripts/test_trace_render.py b/tests/scripts/test_trace_render.py index 04b7192a..f0e29caf 100755 --- a/tests/scripts/test_trace_render.py +++ b/tests/scripts/test_trace_render.py @@ -143,7 +143,7 @@ def test_series_selection_is_manifest_driven(): tire={"friction_shape": "circle", "mu_aniso": [1.0, 1.0]}, repro={"vdsim_version": "t", "git_sha": "x", "param_hash": "sha256:x", "seed": 0, "dt_s": 0.05, "run_id": "partial"}, - role="plant", model_level="L2", contact_scope="C2", + role="plant", model_level="L2", contact_scope="C2", kinematics_attached=False, channels=["t", "pose", "yaw_rate", "u_steer"]) for i in range(20): w.append({"t": i * 0.05, "pose": (i * 0.5, 0.0, 0.0), diff --git a/tests/scripts/test_trace_roundtrip.py b/tests/scripts/test_trace_roundtrip.py index e0cbf365..e6f15865 100755 --- a/tests/scripts/test_trace_roundtrip.py +++ b/tests/scripts/test_trace_roundtrip.py @@ -53,6 +53,7 @@ def _minimal_writer(path, **kw): role="plant", model_level="L2", contact_scope="C2", + kinematics_attached=False, # 2D-only synthetic samples: the 0.3 channels are additive optional # (11 3.2.1), so this fixture declares the base set rather than # fabricating an a_body it never computed. From 00b140176d2d9ba73487452589e5b650b1928500 Mon Sep 17 00:00:00 2001 From: Jiwon Seok Date: Tue, 22 Sep 2026 16:52:17 +0900 Subject: [PATCH 3/4] Q20 (A)(B)(C): refuse VDSimPlant L4, read kinematics in from_config, README 0.4 (A) VDSimPlant has no hardpoint input, so level='L4' raises ValueError with the same leading reason as vdsim_lab._build_session; a test compares them. (B) Experiment.from_config reads kinematics: {front, rear} and hands it to Experiment.kinematics(); resolution stays in resolve_susp_kinematics(). Campaign regression: an L3 cell and an L4 cell with hardpoints both run and their channels are not bit-identical. (C) README / README.ko: the writer emits schema 0.4; document kinematics_attached and the L4 refusals. No new ctest target. Co-Authored-By: Claude Opus 5 --- README.ko.md | 20 ++++++++++++++-- README.md | 22 +++++++++++++++-- python/vdsim_lab.py | 13 +++++++++- python/vdsim_plant.py | 16 +++++++++++-- tests/scripts/test_campaign_runner.py | 34 +++++++++++++++++++++++++++ tests/scripts/test_experiment_api.py | 21 +++++++++++++++++ 6 files changed, 119 insertions(+), 7 deletions(-) diff --git a/README.ko.md b/README.ko.md index 33b60912..eebc3b9f 100644 --- a/README.ko.md +++ b/README.ko.md @@ -161,13 +161,14 @@ path = plant.finalize_trace() 기록한 경로를 돌려준다(기록을 켠 적이 없으면 `None`). - 컨테이너는 zip: `manifest.json` + `channels/*.f64` + `overlays/*.json`. 이 파일 하나면 렌더가 되므로 재시뮬레이션도, 결과 파일 재파싱도 필요 없다. -- manifest 는 `schema_version` `"0.2"` 와 필수 필드 `role` 을 선언한다. `role` 은 +- manifest 는 `schema_version`(writer 는 `"0.4"` 를 쓴다. 버전별 추가 필드는 아래 + 절 참조)과 필수 필드 `role` 을 선언한다. `role` 은 검증 대상인 `"plant"` 또는 최적화·MPC 내부 예측 모델로 쓰인 `"predictor"` 다. `VDSimPlant` 은 그 자체가 플랜트이므로 `enable_trace` 의 기본값은 `plant` 이고, `vdsim_trace.TraceWriter` 를 직접 만드는 생산자는 `role=` 을 반드시 넘겨야 한다 — 기본값이 없으므로 예측기 run 이 빠뜨림만으로 플랜트 근거가 되는 일이 없다. 기존 `0.1` trace 도 그대로 읽힌다. `role` 이 없으면 경고 1회와 함께 `plant` 로 - 간주하고, `0.2` 에서 누락되면 에러다. 렌더러는 HUD 에 문자열로만 표시하고 + 간주하고, `0.2` 이상에서 누락되면 에러다. 렌더러는 HUD 에 문자열로만 표시하고 이 값으로 화면 구성을 바꾸지 않는다. ### 렌더 프리셋 @@ -454,6 +455,21 @@ plant.finalize_trace() `0.1`·`0.2` trace는 계속 읽힌다. `0.3` 파일에서 필수 필드가 빠지면 경고가 아니라 에러다. +### trace 스키마 `0.4` — 하드포인트가 붙었는가 + +`0.4` 는 manifest 필수 필드 `kinematics_attached`(bool) 1개를 더한다. 값은 그 run 에서 +서스펜션 하드포인트 attach 가 실제로 반환한 결과이며, 어떤 YAML 이 있는지로 추정하지 +않는다. L3 와 L4 의 물리는 하드포인트가 붙었을 때만 달라지므로, L3/L4 trace 에 +서스펜션 기구학이 들어 있는지는 이 필드로 판별한다. + +- `kinematics_attached` 가 없는 `0.4` trace 는 에러다. +- `0.3` 이하 trace 는 값을 unknown(`None`)으로 읽고 경고 1회를 낸다. `false` 로 + 읽지 않는다. +- 하드포인트 없는 `level="L4"` 는 세션을 만들 때 `ValueError` 로 거부되므로, L3 + 물리를 담은 L4 trace 는 생길 수 없다. `VDSimPlant` 는 하드포인트 입력이 없어서 + 같은 이유로 `level="L4"` 를 거부한다. `Experiment` 설정에서는 + `kinematics: {front: mp_front_sedan, rear: ta_rear_sedan}` 로 하드포인트를 지정한다. + ## campaign — 선언 1개로 여러 run 돌리기 **run** 은 시뮬레이션 1회이자 `.vdtrace` 1개, **campaign** 은 YAML 선언 1개로 diff --git a/README.md b/README.md index 56a9c009..91bab080 100644 --- a/README.md +++ b/README.md @@ -166,14 +166,15 @@ path = plant.finalize_trace() it returns the written path (`None` when recording was never enabled). - The container is a zip: `manifest.json` + `channels/*.f64` + `overlays/*.json`. That one file is enough to render — no re-simulation, no results file. -- The manifest declares `schema_version` `"0.2"` and a required `role`, either +- The manifest declares `schema_version` (the writer emits `"0.4"`; the fields + each version added are listed below) and a required `role`, either `"plant"` (the simulator under verification) or `"predictor"` (the same code driven as an optimiser's internal model). `enable_trace` defaults to `plant` because `VDSimPlant` *is* the plant; a producer that builds a `vdsim_trace.TraceWriter` directly must pass `role=` — there is no default, so a predictor run cannot be recorded as plant evidence by omission. Reading a legacy `0.1` trace still works: a missing `role` resolves to - `plant` with one warning. A `0.2` trace without one is an error. + `plant` with one warning. A `0.2` or later trace without one is an error. The renderer prints the role in the HUD and never branches on it. ### Render presets @@ -494,6 +495,23 @@ plant.finalize_trace() `0.1` and `0.2` traces stay readable. A `0.3` file that omits any of the required fields is an error, not a warning. +### Trace schema `0.4` — whether hardpoints were attached + +`0.4` adds one required manifest field, `kinematics_attached` (bool). It is the +value the suspension-hardpoint attach actually returned for that run, not a +guess from which YAML files exist. The physics of L3 and L4 differ only when +hardpoints are attached, so this field is what tells a reader whether an L3/L4 +trace carries suspension kinematics. + +- A `0.4` trace without `kinematics_attached` is an error. +- A `0.3` or older trace reads with the value unknown (`None`) and one warning. + It is never read as `false`. +- `level="L4"` with no hardpoints is refused when the session is built + (`ValueError`), so no L4 trace can carry L3 physics. `VDSimPlant` has no + hardpoint input and refuses `level="L4"` for the same reason. An + `Experiment` config gets hardpoints with + `kinematics: {front: mp_front_sedan, rear: ta_rear_sedan}`. + ## Campaigns — many runs from one declaration A **run** is one simulation and one `.vdtrace`. A **campaign** is a set of runs diff --git a/python/vdsim_lab.py b/python/vdsim_lab.py index c2d464e4..d1650d58 100644 --- a/python/vdsim_lab.py +++ b/python/vdsim_lab.py @@ -605,7 +605,9 @@ def kinematics(self, front=None, rear=None): def from_config(cls, name_or_cfg): """Build an Experiment from an authored scenario YAML (configs/experiments/ .yaml or a dict): vehicle + tire + level + map(surface+driving line) - + maneuver + sensor suite. Closes the loop with the authoring tool.""" + + maneuver + sensor suite (+ optional ``kinematics: {front, rear}`` + hardpoint stems, required for ``level: L4``). Closes the loop with the + authoring tool.""" import yaml cfg = name_or_cfg if isinstance(cfg, str): @@ -613,6 +615,15 @@ def from_config(cls, name_or_cfg): exp = cls(level=cfg.get("level", "L2")) exp.vehicle(Vehicle.preset(cfg.get("vehicle", "sedan"))) exp.tire(Tire.preset(cfg.get("tire", "default_pacejka"))) + kin = cfg.get("kinematics") + if kin is not None: + # Stems or paths; resolve_susp_kinematics() resolves them at attach + # time, so a missing file raises there rather than being skipped. + if not isinstance(kin, dict) or set(kin) - {"front", "rear"}: + raise ValueError( + "kinematics must be a mapping with keys 'front' and/or 'rear', " + "got %r" % (kin,)) + exp.kinematics(kin.get("front"), kin.get("rear")) line = None if cfg.get("map"): m = yaml.safe_load(open(_MAP / f"{cfg['map']}.yaml")) diff --git a/python/vdsim_plant.py b/python/vdsim_plant.py index 2fef26e2..f690efe3 100644 --- a/python/vdsim_plant.py +++ b/python/vdsim_plant.py @@ -101,6 +101,8 @@ def _load_tire_setup_for_vehicle(vp_path: Path) -> vdsim.TireSetup: #: Ladder levels this plant can be built at. The control contract #: ``u = [delta_rad, Fx_total_N]`` is the same at every one of them; only the #: model behind the seam changes, and with it which trace channels exist. +#: ``"L4"`` is listed so the name is recognised, and then refused in +#: ``VDSimPlant.__init__``: without hardpoints it is L3 under another label. PLANT_LEVELS = ("L1", "L2", "L3", "L4") #: How far the road normal is coupled into the physics, per level @@ -440,8 +442,9 @@ class VDSimPlant: """Pacejka plant with a direct Fx→torque path (no throttle map). ``level`` selects the model behind the seam — ``"L2"`` (7-DOF, the default and - the historical behaviour), ``"L3"``/``"L4"`` (14-DOF ride models), ``"L1"`` - (bicycle). The control contract ``u = [delta_rad, Fx_total_N]`` does not + the historical behaviour), ``"L3"`` (14-DOF ride model), ``"L1"`` + (bicycle). ``"L4"`` raises ``ValueError``: this plant cannot attach + suspension hardpoints, and without them L4 is L3 under another label. The control contract ``u = [delta_rad, Fx_total_N]`` does not change with the level; what changes is which quantities exist, and therefore which trace channels a run at that level is allowed to record. """ @@ -459,6 +462,15 @@ def __init__( if level not in PLANT_LEVELS: raise ValueError( f"level={level!r} is not one of {list(PLANT_LEVELS)}") + # This plant has no hardpoint input, so an L4 plant would be L3 physics + # recorded as model_level L4. Same refusal as vdsim_lab._build_session; + # the leading sentence is shared on purpose and a test compares the two. + if level == "L4": + raise ValueError( + "level='L4' with no suspension hardpoints attached is L3 under another " + "label (the two are bit-identical until an attach); VDSimPlant has no " + "hardpoint input -- use level='L3', or vdsim_lab.Experiment with " + "kinematics={'front': , 'rear': }") if not math.isfinite(base_mu) or not (0.0 < base_mu <= 1.2): raise ValueError(f"base_mu={base_mu} outside (0, 1.2]") if friction_map is not None and friction_map_2d is not None: diff --git a/tests/scripts/test_campaign_runner.py b/tests/scripts/test_campaign_runner.py index d80dd474..4abc3497 100644 --- a/tests/scripts/test_campaign_runner.py +++ b/tests/scripts/test_campaign_runner.py @@ -309,6 +309,39 @@ def test_level_axis_after_q20(tmp): "no trace is left behind for the refused L4 run") +def test_l4_with_kinematics_from_config(tmp): + """Q20 (B): a declared ``kinematics`` block reaches the attach. + + ``Experiment.from_config`` reads ``kinematics: {front, rear}``, so an L4 + cell that names hardpoints runs as L4 physics. The L3 cell carries none; + the two must both complete and must not be bit-identical, otherwise the + block was dropped and L4 fell back to the bare-L3 trajectory. + """ + spec = {"name": "kin", "base": "step_steer", "seed": 20260922, + "duration": DURATION, + "sweep": {"list": [ + {"level": "L3"}, + {"level": "L4", "kinematics.front": "mp_front_sedan", + "kinematics.rear": "ta_rear_sedan"}]}} + out = Path(tmp) / "kin" + _run(_write(tmp, "kin", spec), out) + rows = {r["axes"]["level"]: r for r in vc.read_index(out / "kin")} + for lv in ("L3", "L4"): + check(rows[lv]["status"] == "ok", + "the %s cell completes (%s %s)" + % (lv, rows[lv]["status"], rows[lv].get("error") or "")) + if rows["L3"]["status"] != "ok" or rows["L4"]["status"] != "ok": + return + paths = {lv: out / "kin" / rows[lv]["trace_path"] for lv in ("L3", "L4")} + with vt.TraceReader(paths["L4"]) as tr: + check(tr.model_level == "L4" and tr.kinematics_attached is True, + "the L4 trace states the declared hardpoints were attached") + a, b = (_channels_digest(paths[lv]) for lv in ("L3", "L4")) + differ = sorted(k for k in a if k in b and a[k] != b[k]) + print(" channels that differ L3 vs L4+kinematics: %s" % differ) + check(bool(differ), "L4 with hardpoints is not bit-identical to bare L3") + + # --------------------------------------------------------------------------- # # C-2: the friction-ellipse measurement is on the trace path, so it must run # --------------------------------------------------------------------------- # @@ -361,6 +394,7 @@ def main(): test_seed_is_a_function_of_the_declaration() test_contract_and_failure_isolation(tmp) test_level_axis_after_q20(tmp) + test_l4_with_kinematics_from_config(tmp) spec_path, out = test_determinism(tmp) test_resume(tmp, spec_path, out) test_render_connection(tmp) diff --git a/tests/scripts/test_experiment_api.py b/tests/scripts/test_experiment_api.py index dc86f080..013f1c8c 100644 --- a/tests/scripts/test_experiment_api.py +++ b/tests/scripts/test_experiment_api.py @@ -238,6 +238,26 @@ def test_bare_l4_is_refused(): Sim(level="L3", road=Road.flat(), kinematics=HARDPOINTS) +def test_plant_bare_l4_is_refused(): + """Q20 (A): VDSimPlant cannot attach hardpoints, so its L4 is refused too.""" + from vdsim_plant import VDSimPlant + try: + Sim(level="L4", road=Road.flat()) + except ValueError as e: + lab_lead = str(e).split(";")[0] + try: + VDSimPlant(level="L4") + except ValueError as e: + msg = str(e) + print("plant L4 refusal: ValueError: %s" % msg) + assert msg.split(";")[0] == lab_lead, \ + f"plant and _build_session must state the same reason: {msg!r} vs {lab_lead!r}" + assert "level='L3'" in msg, f"unexpected message: {msg}" + else: + raise AssertionError("VDSimPlant(level='L4') must be refused, not run as L3") + assert VDSimPlant(level="L3").level == "L3" + + def test_trace_states_the_attach(): """Q20 (iii): the manifest records what the attach returned.""" import vdsim_trace as vt @@ -298,6 +318,7 @@ def test_missing_hardpoint_file_is_an_error(): test_level_label_alone_carries_no_suspension_physics() test_hardpoints_are_the_real_discriminator() test_bare_l4_is_refused() + test_plant_bare_l4_is_refused() test_trace_states_the_attach() test_hardpoints_refused_below_l3() test_missing_hardpoint_file_is_an_error() From 779d57c37a3902864386313991a621ff191bf1c7 Mon Sep 17 00:00:00 2001 From: JiwonSeok Date: Tue, 22 Sep 2026 18:40:39 +0900 Subject: [PATCH 4/4] docs(validation): promote currency block to the CI-measured tree --- docs/VALIDATION.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index a9970425..547e58d2 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -20,7 +20,7 @@ the last section. | ISO 7401 step-steer / 4138 understeer / 3888-2 DLC — run + measured | — | | L1↔L2↔L3 cross-model consistency where physics overlaps | Dependent axles (twist-beam / solid beam) — configs are stubs | | FMI round-trip Δ=0 (machine precision); ISO 8608 PSD RMS per class | L3 unsprung lateral-transfer term (small) | -| Full suite: **PENDING** (`validation` preset — see the currency block below; Q20 changes the registered count) | — | +| Full suite: **506/506 ctest green (as of 2026-09-22)** (`validation` preset — see the currency block below) | — | | Drivetrain engine inertia (open-diff carrier coupling) | — | | **ISO step-steer signature gated in CI** (`ctest -R IsoBaseline`, sedan L2 LuGre) | DLC moose gate is a preset property, not a defect (see note) | @@ -48,15 +48,12 @@ version and the new numbers have to land in the same commit. ```text -tests: PENDING +tests: 506/506 config: cmake --preset validation && cmake --build --preset validation && ctest --preset validation presets: CMakePresets.json@fb1f0c198a0201fcce2543a9f5b2242a255fb97a toolchain: cmake 3.31.10 -commit: PENDING +commit: 6e2592f date: 2026-09-22 -pending: Q20 adds one discriminating gtest, so the 505/505 measured at 7b7947f -pending: no longer describes this tree. Filled from the CI canonical job on -pending: this branch (3 consecutive equal runs) before merge. excluded: gui_v3_e2e (GUI v3 test group formally deferred -- VDSIM_BUILD_GUI_V3_TESTS=OFF) excluded: gui_v3_api_smoke (GUI v3 test group formally deferred -- VDSIM_BUILD_GUI_V3_TESTS=OFF) excluded: ergaccess (optional external dependency not installed -- VDSIM_WITH_ERGACCESS=OFF)