gaitlab 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gaitlab/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """gaitlab — 実在ロボット (MuJoCo Menagerie) を CPU で動かすサンドボックス.
2
+
3
+ 四足歩行ロボ (Unitree Go2 等) と産業用アーム (Franka / UR5e / KUKA iiwa / Kinova)
4
+ を、同じ 1 つのエンジンで土俵 / ワークセルに読み込み・駆動・描画する。
5
+ ロボット相撲 / 格闘ゲーム / 生産ライン のデモを CPU だけで動かせる。
6
+
7
+ 公開 API はサブパッケージ ``gaitlab.arena`` にある::
8
+
9
+ from gaitlab.arena import robots, build_arena, MatchEngine
10
+ """
11
+ from __future__ import annotations
12
+
13
+ __version__ = "0.1.0"
@@ -0,0 +1,43 @@
1
+ """robot-sumo / robot-fight arena — 実在ロボ (MuJoCo Menagerie) を土俵で戦わせる.
2
+
3
+ gaitlab の歩行進化資産 (CPG/MAP-Elites) とは別軸の「実機モデルを動かす」トラック。
4
+ Menagerie の四足 (Go2 等) は torque(motor) actuator なので、position servo を
5
+ 持たない → PD 制御 (torque = kp*(q_des-q) - kd*qdot) で立たせ・動かす。
6
+
7
+ 構成:
8
+ robots RobotSpec + REGISTRY (Menagerie ロボの home pose 内省)
9
+ pd_control PDController (関節空間 PD → トルク)
10
+ build build_arena (MjSpec attach で N 体 + 土俵を 1 model へ)
11
+ gaits Hold / Walk(trot) / SumoDrive コントローラ
12
+ match MatchEngine (ringout / 転倒 判定 + 勝敗)
13
+ render 試合を GIF 化
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from gaitlab.arena.build import ArenaIndex, RobotIndex, build_arena
18
+ from gaitlab.arena.gaits import (
19
+ ArmMotionController,
20
+ HoldController,
21
+ SteerableDriveController,
22
+ SumoDriveController,
23
+ WalkController,
24
+ )
25
+ from gaitlab.arena.match import MatchEngine, MatchResult
26
+ from gaitlab.arena.pd_control import PDController
27
+ from gaitlab.arena.robots import REGISTRY, RobotSpec
28
+
29
+ __all__ = [
30
+ "REGISTRY",
31
+ "RobotSpec",
32
+ "PDController",
33
+ "ArenaIndex",
34
+ "RobotIndex",
35
+ "build_arena",
36
+ "HoldController",
37
+ "WalkController",
38
+ "SumoDriveController",
39
+ "SteerableDriveController",
40
+ "ArmMotionController",
41
+ "MatchEngine",
42
+ "MatchResult",
43
+ ]
gaitlab/arena/build.py ADDED
@@ -0,0 +1,454 @@
1
+ """MjSpec attach で N 体のロボ + 土俵 (dohyo) を 1 つの MjModel に合成する.
2
+
3
+ 土俵 = 隆起シリンダー台 (top z=TOP_Z)。台から押し出されて落ちる = ringout を
4
+ z で明確判定 (Menagerie survey で検証済みレシピ)。
5
+ - 台: condim=3 / friction 高 (接触の condim/friction は両 geom の max → 足に摩擦)。
6
+ - 見た目: clay の checker 材質 + 赤い境界リング + 俵 (視覚専用)。
7
+ - Menagerie 四足は position(Spot/ANYmal/A1) と torque(Go2) が混在 → 制御は
8
+ match 側で actuator 種別に分岐。ここは合成と index 化のみ。
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import math
13
+ from dataclasses import dataclass, field
14
+
15
+ import mujoco
16
+ import numpy as np
17
+
18
+ from gaitlab.arena.robots import RobotSpec
19
+
20
+ PLATFORM_HALF_H = 0.125 # 台の半高 → top = 2*half = 0.25 m の落差
21
+
22
+
23
+ def _yaw_quat(yaw_deg: float) -> list[float]:
24
+ h = math.radians(yaw_deg) / 2.0
25
+ return [math.cos(h), 0.0, 0.0, math.sin(h)]
26
+
27
+
28
+ @dataclass
29
+ class RobotIndex:
30
+ """合成 model 内の 1 体を名前で解決したインデックス."""
31
+
32
+ prefix: str
33
+ spec_name: str
34
+ base_body: int
35
+ base_qadr: int
36
+ base_vadr: int
37
+ act_ids: np.ndarray
38
+ act_qadr: np.ndarray
39
+ act_vadr: np.ndarray
40
+ act_ctrlrange: np.ndarray # (n, 2)
41
+ act_jntrange: np.ndarray # (n, 2) 関節可動域 (q_des クランプ用; 無制限は ±inf)
42
+ act_leg: tuple[str, ...] # 各アクチュエータの脚 (FL/FR/RL/RR/"")
43
+ act_role: tuple[str, ...] # 各アクチュエータの役割 (abduction/thigh/calf/other)
44
+ home: np.ndarray
45
+ is_position: bool # position(servo) 系なら ctrl=目標角、torque(motor) 系なら PD
46
+ stand_z: float # 台上で立位したときの期待 base z (= top_z + home 立位高)
47
+ gait_sign: np.ndarray # (n,) 各 thigh/calf の歩容符号 (足 Jacobian 由来。Go2=+1)
48
+ gait_dir: float # 四足の大域前進方向 (+1/-1)。アームは未使用。
49
+ is_arm: bool # 固定ベース (free joint 無し) のアームか
50
+ spawn: tuple[float, float, float, list[float]]
51
+
52
+ @property
53
+ def n_act(self) -> int:
54
+ return int(self.act_ids.shape[0])
55
+
56
+ def base_xy(self, data: mujoco.MjData) -> np.ndarray:
57
+ return np.asarray(data.xpos[self.base_body][:2], dtype=np.float64)
58
+
59
+ def base_height(self, data: mujoco.MjData) -> float:
60
+ return float(data.xpos[self.base_body][2])
61
+
62
+ def base_up(self, data: mujoco.MjData) -> float:
63
+ return float(data.xmat[self.base_body].reshape(3, 3)[2, 2])
64
+
65
+ def base_forward(self, data: mujoco.MjData) -> np.ndarray:
66
+ fwd = data.xmat[self.base_body].reshape(3, 3)[:, 0][:2]
67
+ n = np.linalg.norm(fwd)
68
+ return fwd / n if n > 1e-9 else np.array([1.0, 0.0])
69
+
70
+ def dist_from_center(self, data: mujoco.MjData) -> float:
71
+ return float(np.hypot(*self.base_xy(data)))
72
+
73
+ def read(self, data: mujoco.MjData) -> tuple[np.ndarray, np.ndarray]:
74
+ return (
75
+ np.asarray(data.qpos[self.act_qadr], dtype=np.float64),
76
+ np.asarray(data.qvel[self.act_vadr], dtype=np.float64),
77
+ )
78
+
79
+ def clamp_targets(self, q_des: np.ndarray) -> np.ndarray:
80
+ return np.clip(q_des, self.act_jntrange[:, 0], self.act_jntrange[:, 1])
81
+
82
+ def set_home(self, model: mujoco.MjModel, data: mujoco.MjData) -> None:
83
+ # 固定ベース (アーム) は base free joint が無い → base qpos は設定しない
84
+ # (位置は attach frame で確定済み)。関節のみ home へ。
85
+ if self.base_qadr >= 0:
86
+ x, y, z, quat = self.spawn
87
+ data.qpos[self.base_qadr : self.base_qadr + 3] = [x, y, z]
88
+ data.qpos[self.base_qadr + 3 : self.base_qadr + 7] = quat
89
+ data.qvel[self.base_vadr : self.base_vadr + 6] = 0.0
90
+ data.qpos[self.act_qadr] = self.home
91
+ data.qvel[self.act_vadr] = 0.0
92
+
93
+
94
+ @dataclass
95
+ class ArenaIndex:
96
+ model: mujoco.MjModel
97
+ robots: list[RobotIndex]
98
+ ring_radius: float
99
+ top_z: float
100
+ by_prefix: dict[str, RobotIndex] = field(default_factory=dict)
101
+
102
+ def __post_init__(self) -> None:
103
+ self.by_prefix = {r.prefix: r for r in self.robots}
104
+
105
+ def reset(self, data: mujoco.MjData) -> None:
106
+ mujoco.mj_resetData(self.model, data)
107
+ for r in self.robots:
108
+ r.set_home(self.model, data)
109
+ mujoco.mj_forward(self.model, data)
110
+
111
+
112
+ def _classify_joint(base_name: str) -> tuple[str, str]:
113
+ """actuator 名 → (脚 FL/FR/RL/RR, 役割 abduction/thigh/calf/other).
114
+
115
+ 命名: Go2/A1 = FL_hip/thigh/calf, ANYmal = LF_HAA/HFE/KFE,
116
+ Spot = fl_hx/hy/kn。未知は ("","other")。
117
+ """
118
+ n = base_name.lower()
119
+ # 脚 (前後×左右)。2 文字コードを正規化。
120
+ leg = ""
121
+ codes = {
122
+ "fl": "FL", "lf": "FL", "fr": "FR", "rf": "FR",
123
+ "rl": "RL", "lh": "RL", "hl": "RL", "rr": "RR", "rh": "RR", "hr": "RR",
124
+ }
125
+ for code, norm in codes.items():
126
+ if n.startswith(code + "_") or n.startswith(code):
127
+ leg = norm
128
+ break
129
+ # 役割
130
+ if any(k in n for k in ("calf", "knee", "kfe", "_kn")):
131
+ role = "calf"
132
+ elif any(k in n for k in ("thigh", "hfe", "_hy")):
133
+ role = "thigh"
134
+ elif any(k in n for k in ("hip", "haa", "_hx", "abduction", "abad")):
135
+ role = "abduction"
136
+ else:
137
+ role = "other"
138
+ return leg, role
139
+
140
+
141
+ def _foot_geom(model: mujoco.MjModel, data: mujoco.MjData, calf_body: int) -> int | None:
142
+ """calf body に属す geom のうち最も低い (=足) を返す."""
143
+ gs = [g for g in range(model.ngeom) if int(model.geom_bodyid[g]) == calf_body]
144
+ if not gs:
145
+ return None
146
+ return min(gs, key=lambda g: float(data.geom_xpos[g][2]))
147
+
148
+
149
+ def _compute_gait_signs(
150
+ model: mujoco.MjModel, base_qadr: int, base_z: float,
151
+ act_ids: np.ndarray, act_qadr: np.ndarray,
152
+ act_leg: tuple[str, ...], act_role: tuple[str, ...], home: np.ndarray,
153
+ delta: float = 0.05,
154
+ ) -> np.ndarray:
155
+ """各 thigh/calf の歩容符号を足 Jacobian(有限差分)から導出.
156
+
157
+ thigh_sign = -sign(d foot_x / d q_thigh), calf_sign = -sign(d foot_z / d q_calf)。
158
+ Go2 が全脚 +1 になる符号系。ロボの後脚が反転している場合 (ANYmal の X 字膝) は
159
+ その脚の符号が −1 になり、gait 側で乗じることで全脚の足先が同じ世界方向へ回る。
160
+ base は identity 向き (前方=+x) に固定して計測 (mj_forward は純 FK なので base は動かない)。
161
+ """
162
+ n = len(act_ids)
163
+ sign = np.ones(n, dtype=np.float64)
164
+ data = mujoco.MjData(model)
165
+ mujoco.mj_resetData(model, data)
166
+ # base を identity 姿勢へ、関節を home へ
167
+ data.qpos[base_qadr : base_qadr + 3] = [0.0, 0.0, base_z]
168
+ data.qpos[base_qadr + 3 : base_qadr + 7] = [1.0, 0.0, 0.0, 0.0]
169
+ data.qpos[act_qadr] = home
170
+ mujoco.mj_forward(model, data)
171
+
172
+ # 脚ごとに calf actuator の body から足 geom を得る
173
+ for i in range(n):
174
+ role = act_role[i]
175
+ if role not in ("thigh", "calf"):
176
+ continue
177
+ leg = act_leg[i]
178
+ calf_idx = [j for j in range(n) if act_leg[j] == leg and act_role[j] == "calf"]
179
+ if not calf_idx:
180
+ continue
181
+ calf_body = int(model.jnt_bodyid[model.actuator_trnid[act_ids[calf_idx[0]], 0]])
182
+ foot = _foot_geom(model, data, calf_body)
183
+ if foot is None:
184
+ continue
185
+ axis = 0 if role == "thigh" else 2 # thigh→foot_x, calf→foot_z
186
+ p0 = float(data.geom_xpos[foot][axis])
187
+ qa = int(act_qadr[i])
188
+ q_save = float(data.qpos[qa])
189
+ data.qpos[qa] = q_save + delta
190
+ mujoco.mj_forward(model, data)
191
+ p1 = float(data.geom_xpos[foot][axis])
192
+ data.qpos[qa] = q_save
193
+ mujoco.mj_forward(model, data)
194
+ d = p1 - p0
195
+ if abs(d) > 1e-9:
196
+ sign[i] = -float(np.sign(d))
197
+ return sign
198
+
199
+
200
+ def _find_base_body(model: mujoco.MjModel, prefix: str) -> tuple[int, int]:
201
+ """(base body id, free joint id or -1) を返す.
202
+
203
+ 四足 = prefix を持つ free joint の親 body。アーム(固定ベース) = free joint が
204
+ 無いので、prefix を持ち親が world(0) の根 body を base とみなす (free jid=-1)。
205
+ """
206
+ for jid in range(model.njnt):
207
+ if int(model.jnt_type[jid]) != int(mujoco.mjtJoint.mjJNT_FREE):
208
+ continue
209
+ bid = int(model.jnt_bodyid[jid])
210
+ bname = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_BODY, bid) or ""
211
+ if bname.startswith(prefix):
212
+ return bid, jid
213
+ # 固定ベース: prefix を持ち親=world の根 body
214
+ for bid in range(1, model.nbody):
215
+ if int(model.body_parentid[bid]) != 0:
216
+ continue
217
+ bname = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_BODY, bid) or ""
218
+ if bname.startswith(prefix):
219
+ return bid, -1
220
+ raise ValueError(f"prefix '{prefix}' の base body が見つからない")
221
+
222
+
223
+ def _index_robot(
224
+ model: mujoco.MjModel, prefix: str, spec: RobotSpec,
225
+ spawn: tuple[float, float, float, list[float]], top_z: float,
226
+ ) -> RobotIndex:
227
+ home_map = spec.home_targets()
228
+ base_body, base_free_jid = _find_base_body(model, prefix)
229
+ if base_free_jid >= 0:
230
+ base_qadr = int(model.jnt_qposadr[base_free_jid])
231
+ base_vadr = int(model.jnt_dofadr[base_free_jid])
232
+ else:
233
+ base_qadr = base_vadr = -1 # 固定ベース (アーム)
234
+
235
+ ids, qadr, vadr, ranges, jranges, home = [], [], [], [], [], []
236
+ legs, roles = [], []
237
+ n_position = 0
238
+ for aid in range(model.nu):
239
+ aname = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_ACTUATOR, aid) or ""
240
+ if not aname.startswith(prefix):
241
+ continue
242
+ base_name = aname[len(prefix):]
243
+ if base_name not in home_map:
244
+ raise KeyError(f"actuator '{aname}' の home が {spec.name} に無い")
245
+ jid = int(model.actuator_trnid[aid, 0])
246
+ ids.append(aid)
247
+ qadr.append(int(model.jnt_qposadr[jid]))
248
+ vadr.append(int(model.jnt_dofadr[jid]))
249
+ # ctrllimited でない actuator の ctrlrange は [0,0] のことがあり、
250
+ # そのまま clip すると制御が 0 に潰れる → 非制限は ±inf 扱い。
251
+ if int(model.actuator_ctrllimited[aid]):
252
+ ranges.append(model.actuator_ctrlrange[aid].tolist())
253
+ else:
254
+ ranges.append([-math.inf, math.inf])
255
+ if int(model.jnt_limited[jid]):
256
+ jranges.append(model.jnt_range[jid].tolist())
257
+ else:
258
+ jranges.append([-math.inf, math.inf])
259
+ home.append(home_map[base_name])
260
+ leg, role = _classify_joint(base_name)
261
+ legs.append(leg)
262
+ roles.append(role)
263
+ # position(servo): biastype=affine。torque(motor): biastype=none。
264
+ if int(model.actuator_biastype[aid]) == int(mujoco.mjtBias.mjBIAS_AFFINE):
265
+ n_position += 1
266
+
267
+ act_ids = np.asarray(ids, dtype=np.int32)
268
+ act_qadr = np.asarray(qadr, dtype=np.int32)
269
+ home_arr = np.asarray(home, dtype=np.float64)
270
+ # 歩容符号は四足のみ (アームは脚が無い → 全 1)
271
+ if spec.is_arm or base_qadr < 0:
272
+ gait_sign = np.ones(len(ids), dtype=np.float64)
273
+ else:
274
+ gait_sign = _compute_gait_signs(
275
+ model, base_qadr, spec.home_base_height(),
276
+ act_ids, act_qadr, tuple(legs), tuple(roles), home_arr,
277
+ )
278
+ return RobotIndex(
279
+ prefix=prefix, spec_name=spec.name, base_body=base_body,
280
+ base_qadr=base_qadr, base_vadr=base_vadr,
281
+ act_ids=act_ids, act_qadr=act_qadr,
282
+ act_vadr=np.asarray(vadr, dtype=np.int32),
283
+ act_ctrlrange=np.asarray(ranges, dtype=np.float64),
284
+ act_jntrange=np.asarray(jranges, dtype=np.float64),
285
+ act_leg=tuple(legs), act_role=tuple(roles),
286
+ home=home_arr,
287
+ # 過半が servo なら position 扱い (混在ロボは基本無い)
288
+ is_position=(n_position * 2 >= len(ids)),
289
+ stand_z=top_z + spec.home_base_height(),
290
+ gait_sign=gait_sign, gait_dir=spec.gait_dir, is_arm=spec.is_arm,
291
+ spawn=spawn,
292
+ )
293
+
294
+
295
+ def _add_dohyo(spec: mujoco.MjSpec, ring_radius: float, top_z: float) -> None:
296
+ """隆起円形台 (dohyo) + 見た目 (checker clay / 赤リング / 俵) を追加."""
297
+ w = spec.worldbody
298
+ # 照明
299
+ for pos in ([0, 0, 3.2], [2.5, -2.5, 2.8]):
300
+ light = w.add_light()
301
+ light.pos = pos
302
+ light.dir = [-p for p in pos]
303
+ # 台下の地面 (落ちた先。ふつうの摩擦)
304
+ ground = w.add_geom()
305
+ ground.name = "ground"
306
+ ground.type = mujoco.mjtGeom.mjGEOM_PLANE
307
+ ground.size = [0.0, 0.0, 0.05]
308
+ ground.condim = 3
309
+ ground.friction = [1.0, 0.005, 0.0001]
310
+ ground.rgba = [0.24, 0.27, 0.32, 1.0]
311
+
312
+ # 台本体 (clay の checker 材質; 失敗時は素の rgba)
313
+ material_name = None
314
+ try:
315
+ tex = spec.add_texture()
316
+ tex.name = "dohyo_tex"
317
+ tex.type = mujoco.mjtTexture.mjTEXTURE_2D
318
+ tex.builtin = mujoco.mjtBuiltin.mjBUILTIN_CHECKER
319
+ tex.rgb1 = [0.86, 0.75, 0.55]
320
+ tex.rgb2 = [0.80, 0.67, 0.46]
321
+ tex.width = 256
322
+ tex.height = 256
323
+ mat = spec.add_material()
324
+ mat.name = "dohyo_mat"
325
+ mat.textures[mujoco.mjtTextureRole.mjTEXROLE_RGB] = "dohyo_tex"
326
+ mat.texrepeat = [4, 4]
327
+ material_name = "dohyo_mat"
328
+ except Exception: # noqa: BLE001 材質 API 差異でも素の rgba で続行
329
+ material_name = None
330
+
331
+ dohyo = w.add_geom()
332
+ dohyo.name = "dohyo"
333
+ dohyo.type = mujoco.mjtGeom.mjGEOM_CYLINDER
334
+ dohyo.size = [ring_radius, top_z / 2.0, 0.0]
335
+ dohyo.pos = [0.0, 0.0, top_z / 2.0]
336
+ dohyo.condim = 3
337
+ dohyo.friction = [1.5, 0.02, 0.0002]
338
+ if material_name:
339
+ dohyo.material = material_name
340
+ else:
341
+ dohyo.rgba = [0.83, 0.71, 0.50, 1.0]
342
+
343
+ # 赤い境界リング (annulus トリック: 赤ディスク + clay 埋めディスク、視覚専用)
344
+ band = w.add_geom()
345
+ band.name = "ring_band"
346
+ band.type = mujoco.mjtGeom.mjGEOM_CYLINDER
347
+ band.size = [ring_radius, 0.002, 0.0]
348
+ band.pos = [0.0, 0.0, top_z + 0.002]
349
+ band.contype = 0
350
+ band.conaffinity = 0
351
+ band.rgba = [0.82, 0.14, 0.11, 1.0]
352
+
353
+ infill = w.add_geom()
354
+ infill.name = "ring_infill"
355
+ infill.type = mujoco.mjtGeom.mjGEOM_CYLINDER
356
+ infill.size = [ring_radius - 0.08, 0.002, 0.0]
357
+ infill.pos = [0.0, 0.0, top_z + 0.004]
358
+ infill.contype = 0
359
+ infill.conaffinity = 0
360
+ if material_name:
361
+ infill.material = material_name
362
+ else:
363
+ infill.rgba = [0.83, 0.71, 0.50, 1.0]
364
+
365
+ # 俵 (視覚専用の縁): 円周に接する短カプセル
366
+ n_bales = 24
367
+ for i in range(n_bales):
368
+ a = 2.0 * math.pi * i / n_bales
369
+ cx, cy = (ring_radius - 0.02) * math.cos(a), (ring_radius - 0.02) * math.sin(a)
370
+ tx, ty = -math.sin(a), math.cos(a)
371
+ hl = math.pi * ring_radius / n_bales * 0.95
372
+ bale = w.add_geom()
373
+ bale.name = f"tawara{i}"
374
+ bale.type = mujoco.mjtGeom.mjGEOM_CAPSULE
375
+ bale.size = [0.018, 0.0, 0.0]
376
+ bale.fromto = [cx - hl * tx, cy - hl * ty, top_z + 0.01,
377
+ cx + hl * tx, cy + hl * ty, top_z + 0.01]
378
+ bale.contype = 0
379
+ bale.conaffinity = 0
380
+ bale.rgba = [0.78, 0.68, 0.40, 1.0]
381
+
382
+
383
+ def _add_floor(spec: mujoco.MjSpec) -> None:
384
+ """土俵なしの平床 + 照明 (アーム/ワークセル用)."""
385
+ w = spec.worldbody
386
+ for pos in ([0, 0, 3.2], [2.5, -2.5, 2.8]):
387
+ light = w.add_light()
388
+ light.pos = pos
389
+ light.dir = [-p for p in pos]
390
+ floor = w.add_geom()
391
+ floor.name = "floor"
392
+ floor.type = mujoco.mjtGeom.mjGEOM_PLANE
393
+ floor.size = [0.0, 0.0, 0.05]
394
+ floor.condim = 3
395
+ floor.friction = [1.0, 0.005, 0.0001]
396
+ floor.rgba = [0.42, 0.44, 0.48, 1.0]
397
+
398
+
399
+ def build_arena(
400
+ placements: list[tuple[RobotSpec, str, float, float, float]],
401
+ ring_radius: float = 1.4,
402
+ timestep: float = 0.002,
403
+ top_z: float = 2.0 * PLATFORM_HALF_H,
404
+ offwidth: int = 1280,
405
+ offheight: int = 800,
406
+ dohyo: bool = True,
407
+ ) -> tuple[mujoco.MjModel, ArenaIndex]:
408
+ """placements = [(spec, prefix, x, y, yaw_deg), ...] を配置し合成.
409
+
410
+ dohyo=True: 隆起土俵 (四足相撲)、四足は台上 (z=spawn_height+top_z) に出生。
411
+ dohyo=False: 平床 (アーム/ワークセル)、robot は z=spawn_height に固定配置。
412
+ アームは固定ベースなので dohyo に関わらず top_z を足さない。
413
+ returns (model, ArenaIndex)。
414
+ """
415
+ spec0 = mujoco.MjSpec()
416
+ spec0.option.timestep = timestep
417
+ spec0.option.impratio = 100.0
418
+ spec0.option.cone = mujoco.mjtCone.mjCONE_ELLIPTIC
419
+ # implicitfast: スティフな position サーボ(産業用アーム gain~4500)を安定積分
420
+ # (Euler だと発散して MuJoCo が毎ステップ自動リセット→時間が進まない)。四足も可。
421
+ spec0.option.integrator = mujoco.mjtIntegrator.mjINT_IMPLICITFAST
422
+ spec0.visual.headlight.diffuse = [0.5, 0.5, 0.5]
423
+ spec0.visual.headlight.ambient = [0.4, 0.4, 0.4]
424
+ try:
425
+ spec0.visual.global_.offwidth = offwidth
426
+ spec0.visual.global_.offheight = offheight
427
+ except Exception: # noqa: BLE001
428
+ pass
429
+
430
+ if dohyo:
431
+ _add_dohyo(spec0, ring_radius, top_z)
432
+ else:
433
+ _add_floor(spec0)
434
+ top_z = 0.0
435
+
436
+ spawns: list[tuple[str, RobotSpec, tuple[float, float, float, list[float]]]] = []
437
+ for spec, prefix, x, y, yaw in placements:
438
+ child = mujoco.MjSpec.from_file(spec.robot_path)
439
+ quat = _yaw_quat(yaw)
440
+ # アーム(固定ベース)は台上に載せず床基準。四足は台上へ。
441
+ z = spec.spawn_height + (0.0 if spec.is_arm else top_z)
442
+ frame = spec0.worldbody.add_frame()
443
+ frame.pos = [x, y, z]
444
+ frame.quat = quat
445
+ spec0.attach(child, prefix=prefix, frame=frame)
446
+ spawns.append((prefix, spec, (x, y, z, quat)))
447
+
448
+ model = spec0.compile()
449
+ robots = [
450
+ _index_robot(model, prefix, spec, spawn, top_z) for prefix, spec, spawn in spawns
451
+ ]
452
+ return model, ArenaIndex(
453
+ model=model, robots=robots, ring_radius=ring_radius, top_z=top_z,
454
+ )
gaitlab/arena/gaits.py ADDED
@@ -0,0 +1,173 @@
1
+ """土俵用コントローラ: 各アクチュエータの目標角 q_des を返す (home からの合成).
2
+
3
+ match 側で actuator 種別に応じて
4
+ position(servo): ctrl = clamp(q_des, ctrlrange)
5
+ torque(motor): ctrl = PD(q, qdot, clamp(q_des, jntrange), ctrlrange)
6
+ に変換する。ここは開ループの姿勢/歩容生成のみ。
7
+
8
+ 前進 gait の要 (実測): 対角トロットで calf を thigh より +pi/2 進める
9
+ (足先が楕円を描き前方へ蹴る)。位相差 -pi/2 や pi は後退。freq2.5/At0.45/Ac0.20
10
+ で Go2 は約 0.48 m/s 前進・転倒なし (kp60/kd1.5)。
11
+
12
+ survey 知見: amp 上限 thigh<=0.45, calf<=0.28、amp は ramp_time で 0 から立上げ。
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ from dataclasses import dataclass
18
+
19
+ import numpy as np
20
+
21
+ from gaitlab.arena.build import RobotIndex
22
+
23
+ # 対角トロットの脚位相
24
+ _LEG_PHASE = {"FL": 0.0, "RR": 0.0, "FR": math.pi, "RL": math.pi}
25
+ # calf を thigh より進める位相差 (前進の要)
26
+ _FWD_CALF_LEAD = math.pi / 2.0
27
+
28
+
29
+ def _ramp(t: float, ramp_time: float) -> float:
30
+ if ramp_time <= 0:
31
+ return 1.0
32
+ return float(min(1.0, max(0.0, t / ramp_time)))
33
+
34
+
35
+ @dataclass
36
+ class HoldController:
37
+ """home 姿勢で立つだけ (整定/待機)."""
38
+
39
+ def targets(self, t: float, idx: RobotIndex) -> np.ndarray: # noqa: ARG002
40
+ return idx.home.copy()
41
+
42
+
43
+ @dataclass
44
+ class WalkController:
45
+ """home 周りの対角トロットで前進する開ループ歩容 (calf を +pi/2 先行)."""
46
+
47
+ freq: float = 2.5
48
+ amp_thigh: float = 0.40
49
+ amp_calf: float = 0.20
50
+ direction: float = 1.0 # +1=前進, -1=後退
51
+ ramp_time: float = 0.5
52
+
53
+ def targets(self, t: float, idx: RobotIndex) -> np.ndarray:
54
+ q = idx.home.copy()
55
+ r = _ramp(t, self.ramp_time)
56
+ at = min(self.amp_thigh, 0.45) * r
57
+ ac = min(self.amp_calf, 0.28) * r
58
+ w = 2.0 * math.pi * self.freq
59
+ lead = _FWD_CALF_LEAD * self.direction * idx.gait_dir
60
+ for i, (leg, role) in enumerate(zip(idx.act_leg, idx.act_role)):
61
+ ph = _LEG_PHASE.get(leg, 0.0)
62
+ sg = float(idx.gait_sign[i])
63
+ if role == "thigh":
64
+ q[i] += sg * at * math.sin(w * t + ph)
65
+ elif role == "calf":
66
+ q[i] += sg * ac * math.sin(w * t + ph + lead)
67
+ return q
68
+
69
+
70
+ @dataclass
71
+ class SteerableDriveController:
72
+ """操作可能ドライブ (格闘ゲーム用): forward(前後) と turn(旋回) を毎フレーム更新.
73
+
74
+ forward=0 で立位、±で前後トロット、turn で左右脚の歩幅を非対称にして veer。
75
+ ゲームループが self.forward / self.turn を書き換える。
76
+ """
77
+
78
+ forward: float = 0.0 # -1..+1
79
+ turn: float = 0.0 # -1..+1
80
+ freq: float = 2.6
81
+ amp_thigh: float = 0.42
82
+ amp_calf: float = 0.22
83
+ crouch: float = 0.0 # >0 でしゃがむ (押し合いで低重心)
84
+ ramp_time: float = 0.4
85
+
86
+ def targets(self, t: float, idx: RobotIndex) -> np.ndarray:
87
+ q = idx.home.copy()
88
+ r = _ramp(t, self.ramp_time)
89
+ w = 2.0 * math.pi * self.freq
90
+ fwd = float(np.clip(self.forward, -1.0, 1.0))
91
+ turn = float(np.clip(self.turn, -1.0, 1.0))
92
+ base_at = min(self.amp_thigh, 0.45) * r
93
+ base_ac = min(self.amp_calf, 0.28) * r
94
+ for i, (leg, role) in enumerate(zip(idx.act_leg, idx.act_role)):
95
+ ph = _LEG_PHASE.get(leg, 0.0)
96
+ is_left = leg in ("FL", "RL")
97
+ # 脚ごとのドライブ = 前後成分 + 旋回差動 (左右で逆符号)。
98
+ # forward=0 でも turn があれば左右が逆に踏んでその場旋回する。
99
+ d_leg = float(np.clip(fwd + (turn if is_left else -turn), -1.0, 1.0))
100
+ mag = abs(d_leg)
101
+ lead = _FWD_CALF_LEAD * (1.0 if d_leg >= 0 else -1.0) * idx.gait_dir
102
+ sg = float(idx.gait_sign[i])
103
+ if role == "thigh":
104
+ q[i] += sg * base_at * mag * math.sin(w * t + ph)
105
+ elif role == "calf":
106
+ q[i] += sg * (base_ac * mag * math.sin(w * t + ph + lead) - r * self.crouch)
107
+ return q
108
+
109
+
110
+ @dataclass
111
+ class ArmMotionController:
112
+ """産業用アーム(固定ベース)の作業動作: 関節を段階位相の正弦波で動かす.
113
+
114
+ home 姿勢を中心に各関節へ振幅 amplitude・位相ずらし phase_stagger の正弦を足し、
115
+ 「動いている作業ロボ」を作る。任意のアーム(Franka/UR5e/iiwa/Kinova…)に汎用。
116
+ match 側で position 制御なら ctrl=目標角、torque なら PD で追従。関節端は
117
+ RobotIndex.clamp_targets でクランプされる。生産ライン AI 化への土台(現状は
118
+ 開ループ動作。学習/タスク制御は後段)。
119
+ """
120
+
121
+ period: float = 4.0 # 1 周期[s]
122
+ amplitude: float = 0.45 # 各関節の振幅[rad]
123
+ phase_stagger: float = 0.9 # 関節間の位相ずらし (波打つ動き)
124
+ phase0: float = 0.0 # 個体ごとの初期位相 (複数アームをずらす用)
125
+ ramp_time: float = 0.5
126
+
127
+ def targets(self, t: float, idx: RobotIndex) -> np.ndarray:
128
+ q = idx.home.copy()
129
+ r = _ramp(t, self.ramp_time)
130
+ w = 2.0 * math.pi / max(self.period, 1e-6)
131
+ for i in range(idx.n_act):
132
+ q[i] += r * self.amplitude * math.sin(w * t + self.phase0 + i * self.phase_stagger)
133
+ return q
134
+
135
+
136
+ @dataclass
137
+ class SumoDriveController:
138
+ """相撲: 低く構えた前進トロット + くさび姿勢 (前脚短く nose-down) で押し出す.
139
+
140
+ home からのオフセット表現で position/torque 両対応。aggression で押す強さを
141
+ 調整 (Go2 同士の膠着を崩すのに左右で差をつける)。
142
+ """
143
+
144
+ aggression: float = 1.0 # 1.0=標準。>1 でより強く速く前へ。
145
+ crouch_front_calf: float = -0.25 # 前脚を短く (nose down + 低重心)
146
+ crouch_rear_thigh: float = 0.15 # 後脚で踏ん張る
147
+ wide: float = 0.08 # abduction 外向き (支持多角形拡大)
148
+ freq: float = 2.6
149
+ amp_thigh: float = 0.42
150
+ amp_calf: float = 0.22
151
+ ramp_time: float = 0.6
152
+
153
+ def targets(self, t: float, idx: RobotIndex) -> np.ndarray:
154
+ q = idx.home.copy()
155
+ r = _ramp(t, self.ramp_time)
156
+ w = 2.0 * math.pi * self.freq
157
+ at = min(self.amp_thigh * self.aggression, 0.45) * r
158
+ ac = min(self.amp_calf * self.aggression, 0.28) * r
159
+ lead = _FWD_CALF_LEAD * idx.gait_dir # 前進 (ロボごとの大域方向)
160
+ for i, (leg, role) in enumerate(zip(idx.act_leg, idx.act_role)):
161
+ is_front = leg in ("FL", "FR")
162
+ is_left = leg in ("FL", "RL")
163
+ ph = _LEG_PHASE.get(leg, 0.0)
164
+ sg = float(idx.gait_sign[i])
165
+ if role == "abduction":
166
+ q[i] += r * self.wide * (1.0 if is_left else -1.0)
167
+ elif role == "thigh":
168
+ bias = 0.0 if is_front else self.crouch_rear_thigh
169
+ q[i] += sg * (r * bias + at * math.sin(w * t + ph))
170
+ elif role == "calf":
171
+ bias = self.crouch_front_calf if is_front else 0.0
172
+ q[i] += sg * (r * bias + ac * math.sin(w * t + ph + lead))
173
+ return q