File size: 10,593 Bytes
4d9b003
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
# SPDX-License-Identifier: Apache-2.0
"""Host post-processing (no device, no weights): denormalisation, Eigen's quaternion of the unnormalised rotation,
the trajectory velocity / force-stop / acceleration rules, predicted paths, the turn-indicator decision and the
``make_output`` assembly, against the verified research port (``dp_common.py``), hand-made cases of
``postprocessing_utils.cpp`` / ``turn_indicator_manager.cpp``, and the small goldens of the shipped samples.

    TT_VISIBLE_DEVICES=none python -m pytest -q code/tt_diffusion_planner/tests/test_host_postprocess.py
"""
from __future__ import annotations

import math

import numpy as np
import pytest

from tt_diffusion_planner.host import pipeline as hp
from tt_diffusion_planner.host import postprocess as P
from tt_diffusion_planner.reference import config as C
from tt_diffusion_planner.reference.weights import load_param_json
from tt_diffusion_planner.tests import _research as R

PARAM_JSON = R.RESEARCH.parents[1] / "assets" / "diffusion-planner" / "hf_diffusion_planner" / C.PARAM_JSON
DEFAULTS = {k: spec[3] for k, spec in hp.RUNTIME_PARAMS.items()}


@pytest.fixture(scope="module")
def normalization():
    if not PARAM_JSON.is_file():
        pytest.skip("diffusion_planner.param.json not found")
    return load_param_json(PARAM_JSON)


def _straight(n=80, v=8.0, decel_after=None, dt=0.1):
    """A straight ego path at speed v (optionally braking to a stop after ``decel_after`` points)."""
    xs, x, speed = [], 0.0, v
    for i in range(n):
        if decel_after is not None and i >= decel_after:
            speed = max(0.0, speed - 1.5)
        x += speed * dt
        xs.append(x)
    p = np.zeros((n, 4), np.float32)
    p[:, 0], p[:, 2] = xs, 1.0
    return p


# ------------------------------------------------------------------------------------------------- denorm

def test_denormalize_matches_research_port(normalization):
    x = np.random.default_rng(0).normal(size=(321, 81, 4)).astype(np.float32)
    mean, std = normalization.state()
    out = P.denormalize(x, mean, std)
    np.testing.assert_array_equal(out, (x[:, 1:] * std + mean).astype(np.float32))
    np.testing.assert_array_equal(out[5, 7], x[5, 8] * np.float32([20, 20, 1, 1]) + np.float32([10, 0, 0, 0]))
    assert P.denormalize(x, mean, std, keep_current_state=True).shape == (321, 81, 4)
    dp = R.load_script("dp_common")
    if dp is not None:
        sm, ss = np.asarray(normalization.state_mean), np.asarray(normalization.state_std)
        np.testing.assert_array_equal(out, dp.denormalize(x[None], (sm, ss))[0])


# --------------------------------------------------------------------------------------------- orientation

def test_quaternion_of_unit_rotations_is_the_half_angle():
    th = np.linspace(-math.pi + 1e-3, math.pi - 1e-3, 721)
    q = P.quaternion_from_cos_sin(np.cos(th), np.sin(th))
    np.testing.assert_allclose(np.abs(q[:, 3]), np.abs(np.cos(th / 2)), atol=1e-12)
    np.testing.assert_allclose(np.abs(q[:, 2]), np.abs(np.sin(th / 2)), atol=1e-12)
    np.testing.assert_allclose(np.linalg.norm(q, axis=1), 1.0, atol=1e-12)
    np.testing.assert_allclose(P.tf2_yaw(q), th, atol=1e-12)
    assert not q[:, :2].any()


def test_quaternion_of_unnormalised_rotations_follows_eigen():
    """Eigen does not normalise: |(c, s)| = r != 1 shifts the heading tf2::getYaw reads (SPEC 4.6.6). Its two
    branches (trace = 2c + 1 > 0, else the m22 pivot) agree at the switch c = -0.5 only for unit vectors; for
    r != 1 the published heading jumps there, a quirk of the node that is reproduced, not fixed."""
    r, th = 0.9, math.pi / 2
    q = P.quaternion_from_cos_sin(r * math.cos(th), r * math.sin(th))
    assert math.isclose(P.tf2_yaw(q), 2 * math.atan(0.9 / (1 + 0.9 * math.cos(th))), rel_tol=1e-12)
    assert abs(P.tf2_yaw(q) - th) > 0.05  # not atan2(sin, cos)
    eps = 1e-12
    for s in (math.sqrt(0.75), -math.sqrt(0.75)):  # unit vectors: continuous
        a, b = P.quaternion_from_cos_sin(-0.5 + eps, s), P.quaternion_from_cos_sin(-0.5 - eps, s)
        np.testing.assert_allclose(P.tf2_yaw(a), P.tf2_yaw(b), atol=1e-9)
    # r = 0.58 (c = -0.5, s = 0.3): trace branch w = 0.5, z = 0.3 -> atan2(0.3, 0.16); pivot branch
    # z = sqrt(3) / 2, w = 0.3 / sqrt(3) -> atan2(0.3, -0.72)
    a, b = P.quaternion_from_cos_sin(-0.5 + eps, 0.3), P.quaternion_from_cos_sin(-0.5 - eps, 0.3)
    assert math.isclose(P.tf2_yaw(a), math.atan2(0.3, 0.16), rel_tol=1e-9)
    assert math.isclose(P.tf2_yaw(b), math.atan2(0.3, -0.72), rel_tol=1e-9)


# --------------------------------------------------------------------------------------------- trajectory

def test_trajectory_constant_speed():
    tr = P.trajectory_from_poses(_straight(v=8.0))
    np.testing.assert_allclose(tr.velocity, 8.0, rtol=0, atol=1e-4)
    assert tr.velocity.dtype == np.float32 and tr.acceleration.dtype == np.float32
    np.testing.assert_allclose(tr.acceleration[:-1], 0.0, atol=2e-3)
    assert tr.acceleration[-1] == 0 and not tr.force_stop
    np.testing.assert_allclose(tr.time_from_start, 0.1 * np.arange(1, 81))
    assert tr.as_columns().shape == (80, 7)


def test_trajectory_force_stop_freezes_poses():
    poses = _straight(v=6.0, decel_after=20)
    tr = P.trajectory_from_poses(poses, stopping_threshold=0.3)
    assert tr.force_stop
    stop = int(np.flatnonzero(tr.velocity == 0)[0])
    assert np.all(tr.velocity[stop:] == 0)
    np.testing.assert_array_equal(tr.position[stop:], np.broadcast_to(tr.position[stop - 1], tr.position[stop:].shape))
    off = P.trajectory_from_poses(poses, enable_force_stop=False)
    assert not off.force_stop and np.all(off.velocity[-7:] == off.velocity[80 - 8])
    with pytest.raises(ValueError, match="velocity_smoothing_window"):
        P.trajectory_from_poses(poses, velocity_smoothing_window=80)


@pytest.mark.skipif(R.load_script("dp_common") is None, reason="research scripts not present")
@pytest.mark.parametrize("case", ["cruise", "brake", "golden"])
def test_trajectory_matches_research_port(case):
    """``dp_common.trajectory_from_poses`` (float64 velocities, no float rounding before the average): positions after
    freezing identical, velocities within float32 rounding."""
    dp = R.load_script("dp_common")
    if case == "golden":
        p = R.FULL_GOLDENS / "straight_road.npz"
        if not p.is_file():
            pytest.skip("full goldens not generated")
        with np.load(p) as z:
            poses = z["out.trajectory"][:, [0, 1, 3, 4]]
    else:
        poses = _straight(v=7.0, decel_after=15 if case == "brake" else None)
    tr = P.trajectory_from_poses(poses)
    xy, vel, acc = dp.trajectory_from_poses(poses[:, :2])
    np.testing.assert_allclose(tr.position[:, :2], xy, rtol=0, atol=1e-12)
    np.testing.assert_allclose(tr.velocity, vel, rtol=1e-6, atol=1e-5)
    np.testing.assert_allclose(tr.acceleration, acc, rtol=1e-5, atol=1e-3)


# ------------------------------------------------------------------------------------------ turn indicator

@pytest.mark.skipif(R.load_script("dp_common") is None, reason="research scripts not present")
def test_turn_decision_matches_research_port():
    dp = R.load_script("dp_common")
    rng = np.random.default_rng(4)
    for _ in range(200):
        lg = (rng.normal(size=5) * 4).astype(np.float32)
        prev = int(rng.integers(1, 4))
        d = P.TurnIndicatorManager().evaluate(lg, 0.0, prev)
        cmd, prob = dp.turn_indicator_command(lg, prev_report=prev)
        assert d.command == cmd
        np.testing.assert_allclose(d.probabilities, prob, rtol=1e-6, atol=1e-7)


def test_turn_decision_rules_and_hold():
    m = P.TurnIndicatorManager()
    keep = np.array([0, 0, 0, 0, 5.0], np.float32)                          # KEEP wins even after -1.25
    d = m.evaluate(keep, 1.0, prev_report=3)
    assert (d.command, d.keep_selected) == (3, True)                        # repeats the last report
    left = np.array([0, 0, 4.0, 0, 3.0], np.float32)                        # 4 > 3 - 1.25: ENABLE_LEFT
    d = m.evaluate(left, 2.0, prev_report=1)
    assert (d.command, d.keep_selected, d.held) == (2, False, False)
    assert math.isclose(sum(d.probabilities), 1.0, rel_tol=1e-3)
    d = m.evaluate(keep, 2.9, prev_report=1)                                # within the 1 s hold window
    assert (d.command, d.held) == (2, True)
    d = m.evaluate(keep, 3.01, prev_report=1)                               # expired
    assert (d.command, d.held) == (1, False)
    tie = np.array([1.0, 1.0, 0, 0, 0], np.float32)                         # std::max_element: the first maximum
    assert P.TurnIndicatorManager().evaluate(tie).command == 0
    assert P.TurnIndicatorManager().evaluate(np.zeros(0, np.float32)).command == 1


# ----------------------------------------------------------------------------------------- make_output

@pytest.mark.parametrize("stem", ["kashiwanoha_dense", "straight_road"])
def test_make_output_reproduces_small_goldens(normalization, stem):
    """From the stored reference final x0 / logits, the post-processing gives exactly the stored outputs."""
    path = R.SMALL_GOLDENS / f"{stem}.outputs.npz"
    if not path.is_file():
        pytest.skip("small goldens not generated")
    with np.load(path) as z:
        g = {k: z[k] for k in z.files}
    prep = hp.prepare(R.sample_raw(stem), normalization.observation)
    x0 = np.zeros((C.MAX_NUM_AGENTS, C.OUTPUT_T + 1, C.POSE_DIM), np.float32)
    x0[g["final_x0.rows"]] = g["final_x0"]
    out = hp.make_output(x0, g["logit"], prep, normalization, DEFAULTS, model="diffusion-planner-p150")
    np.testing.assert_array_equal(out.poses, g["trajectory"])
    assert out.turn_indicator["command"] == int(g["turn_command"])
    assert out.columns == hp.TRAJECTORY_COLUMNS and out.predicted_agents.shape == (len(prep.neighbor_rows), 80, 5)
    body = out.to_dict()
    assert body["num_poses"] == 80 and body["frame_id"] == "base_link"
    assert body["predicted_agents"]["shape"] == [len(prep.neighbor_rows), 80, 5]
    assert body["meta"]["predicted_agent_rows"] == prep.neighbor_rows.tolist()
    # params: a shorter window and no force stop change only the velocity columns
    alt = hp.make_output(x0, g["logit"], prep, normalization, {**DEFAULTS, "velocity_smoothing_window": 1})
    np.testing.assert_array_equal(alt.poses[:, :5], out.poses[:, :5])
    steps = [x0] * 11
    dbg = hp.make_output(x0, g["logit"], prep, normalization, {**DEFAULTS, "return_denoising_steps": True},
                         denoising_steps=steps).to_dict()
    assert dbg["meta"]["denoising_steps"]["shape"] == [11, 81, 4]