File size: 11,597 Bytes
8629eda
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
#!/usr/bin/env python3
"""
ARGOS-Roach gait controller module.

GaitController is the SWAPPABLE interface: any neuro-controller can replace the
CPG implementation without touching the simulation harness, as long as it
implements:
    step(t, state) -> np.ndarray[18]   # joint angle targets, rad
    set_command(cmd, t, ttl=None)      # 'GO' | 'STOP' | 'HOLD'
    reset(rng=None)

state dict keys: dt (s), t (s), qpos (18,), qvel (18,), body_pos (3,),
body_quat (4, wxyz), body_angvel (3,), foot_contact (6, 0/1 per leg).

Command / stop policy (fixed rule, test D):
  * 'GO'        : CPG amplitudes ramp in from 0 with smoothstep over T_RAMP s.
  * 'STOP'      : amplitudes decay exponentially (tau_amp = 0.15 s), oscillator
                  frequency decays (tau_om = 0.10 s); targets converge to the
                  standing pose q0. Below amp < 0.005 controller holds q0
                  (standing) until the next command.
  * 'GO' + ttl  : command expires at t_ttl = t + ttl -> automatic smooth stop
                  (same rule as STOP). No unbounded execution of a stale command.

Yaw fix (26.09.2026): the vestibular bias was applied with the per-leg side
sign s, which shifts BOTH sides' feet the same way (a speed trim, not steering).
A yaw moment needs DIFFERENTIAL coxa action: the bias is now added WITHOUT s
(right coxa += bias, left coxa += bias -> right foot back, left foot forward ->
clockwise moment for bias > 0). bias = k_psi*yaw + k_psid*yaw_rate gives
heading stabilization (proportional + damping).

Retracted sweep (26.09.2026): sweep(phi) = cos(phi) + k_ret*sin(2*phi).
The raw +cos profile has zero foot velocity at touchdown (phi=pi), so the
planted foot of the new tripod is dragged forward against the body's motion
(slip) and jerks the body backward. With k_ret > 0 the foot already moves
BACKWARD at touchdown (d/dphi sweep = +2*k_ret at phi=pi, and the foot
x-offset is -L*A*sweep, so x-velocity is negative) and at lift-off, matching
the body's forward speed -> no touchdown jerk, plus a push-off retraction at
the end of stance. sweep and its derivative are continuous across the
phi=0/2pi wrap.

Units: radians, seconds. Joint order per leg: [coxa, femur, tibia], legs 0..5.
"""

import math
import numpy as np

LEG_COUNT = 6
JOINTS_PER_LEG = 3
N_ACT = LEG_COUNT * JOINTS_PER_LEG

# leg numbering: 0 RF, 1 LF, 2 LM, 3 RM, 4 RR, 5 LR ; tripod A={0,2,4}, B={1,3,5}
SIDE = np.array([+1, -1, -1, +1, +1, -1], dtype=float)  # +1 right, -1 left


class GaitController:
    """Abstract interface (swappable module)."""
    def set_command(self, cmd, t, ttl=None):
        raise NotImplementedError

    def step(self, t, state):
        raise NotImplementedError

    def reset(self, rng=None):
        raise NotImplementedError


class CPGGaitController(GaitController):
    """
    CPG: 6 coupled phase oscillators (Kuramoto), tripod gait.
    Oscillator i: dphi_i/dt = omega + K * sum_j sin(phi_j - phi_i - psi_ij)
    Coupling: same-side neighbours psi=0 (in-phase), opposite tripod pairs psi=pi.
    Joint targets (per leg, side sign s):
        coxa  = 0 + s*A_c*sweep(phi)  + bias        # fore-aft sweep (retracted)
        femur = s * (0.8 + A_f * lift(phi))         # lift
        tibia = -s * (1.45 + A_t * lift(phi))       # knee tuck
        lift(phi) = max(0, sin(phi))^2 > 0 during swing (phi in (0, pi)).
    Gait cycle: phi=0 lift-off at back (with backward retraction) -> swing
    back->front while lifted -> touchdown at front with the foot already
    moving backward (retraction, no slip jerk) -> stance sweep front->back.
    Subclass hooks: _update_phases(dt) (rhythm generator) and
    _apply_reflex(state, dt, prev_phases) (feedback; no-op in this class).
    """

    def __init__(self, freq=1.0, a_coxa=0.45, a_femur=0.40, a_tibia=0.42,
                 coupling_k=2.0, t_ramp=1.0, tau_amp=0.15, tau_om=0.10,
                 amp_floor=0.005, k_psi=0.0, k_psid=0.0, k_ret=0.30,
                 tau_yf=0.0, bias_stance=True, a_coxa_legs=None):
        self.omega0 = 2.0 * math.pi * freq
        self.a_coxa, self.a_femur, self.a_tibia = a_coxa, a_femur, a_tibia
        # per-leg coxa amplitude scale (None = 1.0 for all legs)
        self.a_coxa_legs = (np.asarray(a_coxa_legs, dtype=float)
                            if a_coxa_legs is not None else np.ones(LEG_COUNT))
        self.coupling_k = coupling_k
        self.t_ramp, self.tau_amp, self.tau_om = t_ramp, tau_amp, tau_om
        self.amp_floor = amp_floor
        # vestibular yaw stabilization: coxa bias = k_psi * yaw + k_psid * yaw_rate
        self.k_psi, self.k_psid = k_psi, k_psid
        self.k_ret = k_ret
        self.tau_yf = tau_yf          # low-pass filter time constant (0 = off)
        self.bias_stance = bias_stance  # apply yaw bias to stance feet too?
        self._yaw = 0.0
        self._yaw_rate = 0.0
        self._yaw_f = 0.0
        self._yawrate_f = 0.0
        self.F0, self.T0 = 0.8, 1.45  # standing femur / tibia magnitude (rad)
        # coupling pairs: (i, j, psi). Within-tripod pairs in-phase (psi=0),
        # cross-tripod pairs anti-phase (psi=pi). NOTE (26.09.2026 fix): the
        # original list used psi=0 for same-SIDE pairs (0,3),(3,4),(1,2),(2,5),
        # which connect legs in OPPOSITE tripods — a frustrated coupling whose
        # tripod equilibrium is unstable; the phases collapse in ~10-25 s into
        # an all-right vs all-left pattern, which is the root cause of the
        # late-run yaw drift and jerk regime.
        self.pairs = [(0, 2, 0.0), (2, 4, 0.0), (4, 0, 0.0),
                      (1, 3, 0.0), (3, 5, 0.0), (5, 1, 0.0),
                      (0, 1, math.pi), (2, 3, math.pi), (4, 5, math.pi)]
        self.reset()

    # ---------------- interface ----------------
    def reset(self, rng=None):
        self.phases = np.array([0.0, math.pi, 0.0, math.pi, 0.0, math.pi])
        self.omega_eff = self.omega0
        self.amp = 0.0
        self.mode = "hold"          # hold | go | stopping
        self._t_ref = 0.0           # reference time of last command/ramp state
        self._ttl_exp = None
        self._last_targets = np.zeros(N_ACT)
        self._yaw = 0.0
        self._yaw_rate = 0.0
        self._yaw_f = 0.0
        self._yawrate_f = 0.0

    @staticmethod
    def _yaw_from_quat(q):
        w, x, y, z = q[0], q[1], q[2], q[3]
        siny = 2.0 * (w * z + x * y)
        cosy = 1.0 - 2.0 * (y * y + z * z)
        return math.atan2(siny, cosy)

    def set_command(self, cmd, t, ttl=None):
        cmd = str(cmd).upper()
        if cmd == "GO":
            self.mode = "go"
            self._t_ref = t
            self._ttl_exp = (t + ttl) if ttl is not None else None
            self.omega_eff = self.omega0
        elif cmd == "STOP":
            self._begin_stop(t)
        elif cmd == "HOLD":
            self.mode = "hold"
            self.amp = 0.0
            self._ttl_exp = None
            self._t_ref = t
        else:
            raise ValueError(f"unknown command: {cmd}")

    def _begin_stop(self, t):
        self.mode = "stopping"
        self._t_ref = t
        self._ttl_exp = None

    # ---------------- core ----------------
    @staticmethod
    def standing_targets():
        """Standing pose q0 for all 18 joints (rad)."""
        q = np.zeros(N_ACT)
        for leg in range(LEG_COUNT):
            s = SIDE[leg]
            q[3 * leg + 0] = 0.0
            q[3 * leg + 1] = s * 0.8
            q[3 * leg + 2] = -s * 1.45
        return q

    def _update_phases(self, dt):
        """Rhythm generator. Base: Kuramoto-coupled phase oscillators."""
        dphi = np.full(LEG_COUNT, self.omega_eff * dt)
        for i, j, psi in self.pairs:
            d = self.phases[j] - self.phases[i] - psi
            dphi[i] += self.coupling_k * math.sin(d) * dt
        self.phases = (self.phases + dphi) % (2.0 * math.pi)

    def _apply_reflex(self, state, dt, prev_phases):
        """Feedback hook (no-op in the open-loop base controller)."""

    def _post_targets(self, state, targets):
        """Post-target hook for additive limb corrections (no-op in base)."""
        return targets

    def step(self, t, state):
        dt = float(state["dt"])

        # --- command expiry (ttl) -> same smooth stop as explicit STOP ---
        if self.mode == "go" and self._ttl_exp is not None and t >= self._ttl_exp:
            self._begin_stop(t)

        # --- mode state machine ---
        if self.mode == "hold":
            self._last_targets = self.standing_targets()
            return self._last_targets.copy()

        if self.mode == "go":
            # smoothstep ramp-in of amplitude
            x = max(0.0, min(1.0, (t - self._t_ref) / self.t_ramp))
            self.amp = x * x * (3.0 - 2.0 * x)
            self.omega_eff = self.omega0
        elif self.mode == "stopping":
            self.amp *= math.exp(-dt / self.tau_amp)
            self.omega_eff *= math.exp(-dt / self.tau_om)
            if self.amp < self.amp_floor:
                self.mode = "hold"
                self.amp = 0.0
                self._last_targets = self.standing_targets()
                return self._last_targets.copy()

        # --- rhythm update (overridable) + feedback reflex (overridable) ---
        prev_phases = self.phases.copy()
        self._update_phases(dt)
        self._apply_reflex(state, dt, prev_phases)

        # --- vestibular yaw feedback (steering via DIFFERENTIAL coxa bias) ---
        if "body_quat" in state:
            self._yaw = self._yaw_from_quat(state["body_quat"])
            if "body_angvel" in state:
                self._yaw_rate = float(state["body_angvel"][2])
        if self.tau_yf > 0:
            a = dt / self.tau_yf
            self._yaw_f += a * (self._yaw - self._yaw_f)
            self._yawrate_f += a * (self._yaw_rate - self._yawrate_f)
        else:
            self._yaw_f, self._yawrate_f = self._yaw, self._yaw_rate
        bias = self.k_psi * self._yaw_f + self.k_psid * self._yawrate_f

        # --- joint targets ---
        sinp = np.sin(self.phases)
        lift = np.clip(sinp, 0.0, None) ** 2
        sweep = np.cos(self.phases) + self.k_ret * np.sin(2.0 * self.phases)
        q0 = self.standing_targets()
        targets = q0.copy()
        for leg in range(LEG_COUNT):
            s = SIDE[leg]
            # bias NOT multiplied by s: right coxa += bias, left coxa += bias
            # -> differential fore-aft foot shift -> yaw moment (heading control).
            # Optional: apply the bias only while the leg is in SWING (the
            # touchdown point is steered without disturbing planted feet).
            if not self.bias_stance and (self.phases[leg] % (2.0 * math.pi)) >= math.pi:
                bias_leg = 0.0
            else:
                bias_leg = bias
            targets[3 * leg + 0] = (q0[3 * leg + 0]
                                    + s * self.a_coxa * self.a_coxa_legs[leg] * sweep[leg] * self.amp
                                    + bias_leg * self.amp)
            targets[3 * leg + 1] = s * (self.F0 + self.a_femur * lift[leg] * self.amp)
            targets[3 * leg + 2] = -s * (self.T0 + self.a_tibia * lift[leg] * self.amp)
        targets = self._post_targets(state, targets)  # hook: limb reflexes
        self._last_targets = targets
        return targets.copy()

    # ---------------- introspection (for CSV/report) ----------------
    def state_report(self):
        return {
            "mode": self.mode,
            "amp": self.amp,
            "omega_eff": self.omega_eff,
            "phases": np.round(self.phases, 3).tolist(),
        }