RemiFabre commited on
Commit
f8ae01c
Β·
1 Parent(s): a97ad69

Simplify replay compensation: always-on lead model, default lead tuned, and GUI tuning UX

Browse files

- make lead compensation the only replay model (remove model toggle/selection paths)
- set default lead params to validated values (head=15, antennas=4)
- add persistent lead-param API (POST /api/motion-model/lead)
- simplify Settings UI: replace Experimental model controls with a dedicated β€œLead compensation”
section
- expose only head/antenna lead frame inputs in GUI
- add hint text: 1 frame = 10 ms (100 Hz control loop)
- update tests/state contracts for always-on lead compensation configuration

marionette/main.py CHANGED
@@ -102,7 +102,6 @@ HF_DATASETS_API_URL = "https://huggingface.co/api/datasets"
102
  DATASET_DATA_SUBDIR = "data"
103
  NOISE_SUFFIX = ".noise.wav"
104
  DENOISED_SUFFIX = ".denoised.wav"
105
- DEFAULT_FEATURES = {"motion_models": True}
106
 
107
  logger = logging.getLogger(__name__)
108
 
@@ -219,10 +218,6 @@ class DownloadDatasetPayload(BaseModel):
219
  )
220
 
221
 
222
- class UpdateMotionModelPayload(BaseModel):
223
- name: str = Field(..., description="Motion model identifier to activate")
224
-
225
-
226
  class UpdateLeadCompensationPayload(BaseModel):
227
  lead_frames_head: int | None = Field(
228
  default=None, ge=0, le=2000, description="Look-ahead for head/body in 100Hz frames"
@@ -233,7 +228,6 @@ class UpdateLeadCompensationPayload(BaseModel):
233
 
234
 
235
  class UpdateExperimentsPayload(BaseModel):
236
- motion_models: bool | None = Field(default=None, description="Enable or disable motion model feature")
237
  duration_seconds: float | None = Field(default=None, gt=0.5, le=300.0)
238
  welcome_messages: int | None = Field(default=None, ge=0, le=2, description="Number of welcome messages at startup (0, 1, or 2)")
239
 
@@ -262,10 +256,9 @@ class Marionette(ReachyMiniApp):
262
  self._dataset_root: Path
263
  self._dataset_dir: Path
264
  self._motion_model_registry = MotionModelRegistry()
265
- self._features: dict[str, bool] = DEFAULT_FEATURES.copy()
266
  self._preferred_duration: float = DEFAULT_DURATION
267
  self._welcome_messages: int = 2 # 0=none, 1=intro only, 2=intro+second
268
- self._load_dataset_registry() # may overwrite _features, _preferred_duration, _welcome_messages
269
 
270
  self._recordings: dict[str, RecordingMetadata] = {}
271
  self._pending_recording: RecordingRequest | None = None
@@ -542,40 +535,22 @@ class Marionette(ReachyMiniApp):
542
  def update_experiments(payload: UpdateExperimentsPayload) -> dict[str, Any]:
543
  updates = payload.dict(exclude_none=True)
544
  if not updates:
545
- return {"status": "unchanged", "features": self._features}
546
  with self._state_lock:
547
  for key, value in updates.items():
548
- if key in self._features:
549
- self._features[key] = bool(value)
550
- elif key == "duration_seconds":
551
  self._preferred_duration = float(value)
552
  elif key == "welcome_messages":
553
  self._welcome_messages = max(0, min(2, int(value)))
554
  self._save_dataset_registry()
555
  return {
556
  "status": "updated",
557
- "features": self._features,
558
  "preferred_duration": self._preferred_duration,
559
- "motion_models": self._motion_model_registry.to_payload()
560
- if self._features.get("motion_models")
561
- else None,
562
  }
563
 
564
- @self.settings_app.post("/api/motion-model")
565
- def update_motion_model(payload: UpdateMotionModelPayload) -> dict[str, Any]:
566
- if not self._features.get("motion_models"):
567
- raise HTTPException(status_code=400, detail="Motion model feature is disabled.")
568
- try:
569
- self._motion_model_registry.set_active(payload.name)
570
- except KeyError as exc:
571
- raise HTTPException(status_code=404, detail=f"Unknown motion model '{payload.name}'.") from exc
572
- self._save_dataset_registry()
573
- return {"status": "updated", "active": self._motion_model_registry.active}
574
-
575
  @self.settings_app.post("/api/motion-model/lead")
576
  def update_motion_model_lead(payload: UpdateLeadCompensationPayload) -> dict[str, Any]:
577
- if not self._features.get("motion_models"):
578
- raise HTTPException(status_code=400, detail="Motion model feature is disabled.")
579
  params = payload.dict(exclude_none=True)
580
  if not params:
581
  return {
@@ -1347,8 +1322,6 @@ class Marionette(ReachyMiniApp):
1347
  return True
1348
 
1349
  def _apply_motion_model(self, move: RecordedMove) -> RecordedMove:
1350
- if not self._features.get("motion_models"):
1351
- return move
1352
  try:
1353
  return self._motion_model_registry.apply(move)
1354
  except Exception as exc:
@@ -1470,11 +1443,6 @@ class Marionette(ReachyMiniApp):
1470
  else:
1471
  raw = {}
1472
 
1473
- stored_model = raw.get("motion_model", "lead_compensation")
1474
- try:
1475
- self._motion_model_registry.set_active(stored_model)
1476
- except KeyError:
1477
- self._motion_model_registry.set_active("no_model")
1478
  raw_params = raw.get("motion_model_params", {})
1479
  if isinstance(raw_params, dict):
1480
  for model_name, params in raw_params.items():
@@ -1483,12 +1451,6 @@ class Marionette(ReachyMiniApp):
1483
  self._motion_model_registry.set_model_params(model_name, params)
1484
  except KeyError:
1485
  continue
1486
-
1487
- raw_features = raw.get("features") or {}
1488
- self._features = DEFAULT_FEATURES.copy()
1489
- for key in self._features:
1490
- if key in raw_features:
1491
- self._features[key] = bool(raw_features[key])
1492
  self._preferred_duration = float(raw.get("preferred_duration", DEFAULT_DURATION))
1493
  self._welcome_messages = int(raw.get("welcome_messages", 2))
1494
 
@@ -1555,11 +1517,9 @@ class Marionette(ReachyMiniApp):
1555
  data = {
1556
  "active": self._active_dataset_id,
1557
  "root_path": str(self._dataset_root),
1558
- "motion_model": self._motion_model_registry.active,
1559
  "motion_model_params": {
1560
  "lead_compensation": self._motion_model_registry.get_model_params("lead_compensation"),
1561
  },
1562
- "features": self._features,
1563
  "preferred_duration": self._preferred_duration,
1564
  "welcome_messages": self._welcome_messages,
1565
  "datasets": [
@@ -2197,13 +2157,7 @@ class Marionette(ReachyMiniApp):
2197
  "active_dataset_path": str(self._dataset_dir),
2198
  "dataset_root_path": str(self._dataset_root),
2199
  "hf_username": self._check_hf_login(),
2200
- "features": dict(self._features),
2201
- "feature_support": {
2202
- "motion_models": True,
2203
- },
2204
- "motion_models": self._motion_model_registry.to_payload()
2205
- if self._features.get("motion_models")
2206
- else None,
2207
  "welcome_messages": self._welcome_messages,
2208
  },
2209
  "datasets": self._datasets_payload(),
 
102
  DATASET_DATA_SUBDIR = "data"
103
  NOISE_SUFFIX = ".noise.wav"
104
  DENOISED_SUFFIX = ".denoised.wav"
 
105
 
106
  logger = logging.getLogger(__name__)
107
 
 
218
  )
219
 
220
 
 
 
 
 
221
  class UpdateLeadCompensationPayload(BaseModel):
222
  lead_frames_head: int | None = Field(
223
  default=None, ge=0, le=2000, description="Look-ahead for head/body in 100Hz frames"
 
228
 
229
 
230
  class UpdateExperimentsPayload(BaseModel):
 
231
  duration_seconds: float | None = Field(default=None, gt=0.5, le=300.0)
232
  welcome_messages: int | None = Field(default=None, ge=0, le=2, description="Number of welcome messages at startup (0, 1, or 2)")
233
 
 
256
  self._dataset_root: Path
257
  self._dataset_dir: Path
258
  self._motion_model_registry = MotionModelRegistry()
 
259
  self._preferred_duration: float = DEFAULT_DURATION
260
  self._welcome_messages: int = 2 # 0=none, 1=intro only, 2=intro+second
261
+ self._load_dataset_registry() # may overwrite _preferred_duration and _welcome_messages
262
 
263
  self._recordings: dict[str, RecordingMetadata] = {}
264
  self._pending_recording: RecordingRequest | None = None
 
535
  def update_experiments(payload: UpdateExperimentsPayload) -> dict[str, Any]:
536
  updates = payload.dict(exclude_none=True)
537
  if not updates:
538
+ return {"status": "unchanged"}
539
  with self._state_lock:
540
  for key, value in updates.items():
541
+ if key == "duration_seconds":
 
 
542
  self._preferred_duration = float(value)
543
  elif key == "welcome_messages":
544
  self._welcome_messages = max(0, min(2, int(value)))
545
  self._save_dataset_registry()
546
  return {
547
  "status": "updated",
 
548
  "preferred_duration": self._preferred_duration,
549
+ "motion_models": self._motion_model_registry.to_payload(),
 
 
550
  }
551
 
 
 
 
 
 
 
 
 
 
 
 
552
  @self.settings_app.post("/api/motion-model/lead")
553
  def update_motion_model_lead(payload: UpdateLeadCompensationPayload) -> dict[str, Any]:
 
 
554
  params = payload.dict(exclude_none=True)
555
  if not params:
556
  return {
 
1322
  return True
1323
 
1324
  def _apply_motion_model(self, move: RecordedMove) -> RecordedMove:
 
 
1325
  try:
1326
  return self._motion_model_registry.apply(move)
1327
  except Exception as exc:
 
1443
  else:
1444
  raw = {}
1445
 
 
 
 
 
 
1446
  raw_params = raw.get("motion_model_params", {})
1447
  if isinstance(raw_params, dict):
1448
  for model_name, params in raw_params.items():
 
1451
  self._motion_model_registry.set_model_params(model_name, params)
1452
  except KeyError:
1453
  continue
 
 
 
 
 
 
1454
  self._preferred_duration = float(raw.get("preferred_duration", DEFAULT_DURATION))
1455
  self._welcome_messages = int(raw.get("welcome_messages", 2))
1456
 
 
1517
  data = {
1518
  "active": self._active_dataset_id,
1519
  "root_path": str(self._dataset_root),
 
1520
  "motion_model_params": {
1521
  "lead_compensation": self._motion_model_registry.get_model_params("lead_compensation"),
1522
  },
 
1523
  "preferred_duration": self._preferred_duration,
1524
  "welcome_messages": self._welcome_messages,
1525
  "datasets": [
 
2157
  "active_dataset_path": str(self._dataset_dir),
2158
  "dataset_root_path": str(self._dataset_root),
2159
  "hf_username": self._check_hf_login(),
2160
+ "motion_models": self._motion_model_registry.to_payload(),
 
 
 
 
 
 
2161
  "welcome_messages": self._welcome_messages,
2162
  },
2163
  "datasets": self._datasets_payload(),
marionette/motion_models.py CHANGED
@@ -1,12 +1,9 @@
1
- """Simple motion model prototypes to improve replay fidelity."""
2
 
3
  from __future__ import annotations
4
 
5
  import copy
6
- from dataclasses import dataclass
7
- from typing import Any, Dict, List
8
-
9
- import numpy as np
10
 
11
  try:
12
  from reachy_mini.motion.recorded_move import RecordedMove
@@ -19,18 +16,7 @@ def _deepcopy_move(move: Dict[str, Any]) -> Dict[str, Any]:
19
  return copy.deepcopy(move)
20
 
21
 
22
- class MotionModel:
23
- """Base helper for prototype models."""
24
-
25
- name: str = "no_model"
26
- label: str = "No compensation"
27
- description: str = "Stream the recorded trajectory as-is."
28
-
29
- def transform(self, move: Dict[str, Any]) -> Dict[str, Any]:
30
- return _deepcopy_move(move)
31
-
32
-
33
- class LeadCompensationModel(MotionModel):
34
  """Shift commands ahead in time to compensate for controller lag.
35
 
36
  Uses separate lead values for antennas vs head/body, since antennas
@@ -41,7 +27,7 @@ class LeadCompensationModel(MotionModel):
41
  label = "Lead compensation"
42
  description = "Issues commands a few frames ahead to counter fixed latency."
43
 
44
- def __init__(self, lead_frames_antennas: int = 3, lead_frames_head: int = 10) -> None:
45
  self.lead_frames_antennas = max(0, int(lead_frames_antennas))
46
  self.lead_frames_head = max(0, int(lead_frames_head))
47
 
@@ -51,7 +37,6 @@ class LeadCompensationModel(MotionModel):
51
  if not data:
52
  return patched
53
  total = len(data)
54
- # Take a snapshot of the original data for look-ahead reads.
55
  original = [copy.deepcopy(f) for f in data]
56
  for idx in range(total):
57
  ant_src = min(idx + self.lead_frames_antennas, total - 1)
@@ -63,146 +48,53 @@ class LeadCompensationModel(MotionModel):
63
  return patched
64
 
65
 
66
- class AveragingModel(MotionModel):
67
- """Apply a small moving-average filter to reduce play/backlash artifacts."""
68
-
69
- name = "moving_average"
70
- label = "Moving average"
71
- description = "Smooths the trajectory with a small window to reduce overshoot."
72
-
73
- def __init__(self, window: int = 3) -> None:
74
- self.window = max(2, int(window))
75
-
76
- def transform(self, move: Dict[str, Any]) -> Dict[str, Any]:
77
- patched = _deepcopy_move(move)
78
- data = patched.get("set_target_data", [])
79
- if not data:
80
- return patched
81
- window = self.window
82
- half = window // 2
83
- for idx in range(len(data)):
84
- start = max(0, idx - half)
85
- end = min(len(data), idx + half + 1)
86
- slice_frames = data[start:end]
87
- if len(slice_frames) <= 1:
88
- continue
89
- averaged = _average_frames(slice_frames)
90
- data[idx] = averaged
91
- return patched
92
-
93
-
94
- def _average_frames(frames: List[Dict[str, Any]]) -> Dict[str, Any]:
95
- heads = [np.asarray(frame["head"], dtype=float) for frame in frames]
96
- antennas = [np.asarray(frame["antennas"], dtype=float) for frame in frames]
97
- yaw = [float(frame.get("body_yaw", 0.0)) for frame in frames]
98
- head_avg = np.mean(heads, axis=0)
99
- antennas_avg = np.mean(antennas, axis=0)
100
- yaw_avg = float(np.mean(yaw))
101
- averaged = copy.deepcopy(frames[len(frames) // 2])
102
- averaged["head"] = head_avg.tolist()
103
- averaged["antennas"] = antennas_avg.tolist()
104
- averaged["body_yaw"] = yaw_avg
105
- return averaged
106
-
107
-
108
- class StaticOffsetModel(MotionModel):
109
- """Adds a constant bias on antennas to compensate small mechanical play."""
110
-
111
- name = "static_offset"
112
- label = "Static offset"
113
- description = "Adds a small bias to antennas joints to counter play."
114
-
115
- def __init__(self, offset: float = 0.01) -> None:
116
- self.offset = float(offset)
117
-
118
- def transform(self, move: Dict[str, Any]) -> Dict[str, Any]:
119
- patched = _deepcopy_move(move)
120
- data = patched.get("set_target_data", [])
121
- if not data:
122
- return patched
123
- for frame in data:
124
- antennas = np.asarray(frame["antennas"], dtype=float)
125
- antennas += self.offset
126
- frame["antennas"] = antennas.tolist()
127
- return patched
128
-
129
-
130
  class MotionModelRegistry:
131
- """Registry + state for experimental motion models."""
132
-
133
- def __init__(self, active: str = "lead_compensation") -> None:
134
- self._models: Dict[str, MotionModel] = {
135
- "no_model": MotionModel(),
136
- LeadCompensationModel.name: LeadCompensationModel(),
137
- AveragingModel.name: AveragingModel(),
138
- StaticOffsetModel.name: StaticOffsetModel(),
139
- }
140
- self._active = active if active in self._models else "no_model"
141
 
142
  @property
143
  def active(self) -> str:
144
  return self._active
145
 
146
- def set_active(self, name: str) -> None:
147
- if name not in self._models:
148
- raise KeyError(name)
149
- self._active = name
150
-
151
  def set_model_params(self, name: str, params: Dict[str, Any]) -> None:
152
- model = self._models.get(name)
153
- if model is None:
154
  raise KeyError(name)
155
- if name == LeadCompensationModel.name:
156
- head = params.get("lead_frames_head")
157
- antennas = params.get("lead_frames_antennas")
158
- if head is not None:
159
- model.lead_frames_head = max(0, int(head))
160
- if antennas is not None:
161
- model.lead_frames_antennas = max(0, int(antennas))
162
 
163
  def get_model_params(self, name: str) -> Dict[str, Any]:
164
- model = self._models.get(name)
165
- if model is None:
166
  raise KeyError(name)
167
- if name == LeadCompensationModel.name:
168
- return {
169
- "lead_frames_head": int(model.lead_frames_head),
170
- "lead_frames_antennas": int(model.lead_frames_antennas),
171
- }
172
- return {}
173
-
174
- def list_models(self) -> List[Dict[str, str]]:
175
- return [
176
- {
177
- "name": name,
178
- "label": model.label,
179
- "description": model.description,
180
- }
181
- for name, model in self._models.items()
182
- ]
183
 
184
  def transform_move(self, move_data: Dict[str, Any]) -> Dict[str, Any]:
185
- model = self._models.get(self._active)
186
- if model is None:
187
- return _deepcopy_move(move_data)
188
- return model.transform(move_data)
189
 
190
  def apply(self, recorded_move: RecordedMove) -> RecordedMove:
191
  if RecordedMove is None:
192
  raise RuntimeError("reachy_mini is unavailable.")
193
- if self._active == "no_model":
194
- return recorded_move
195
  mutated = self.transform_move(recorded_move.move)
196
  return RecordedMove(mutated, recorded_move.sound_path)
197
 
198
  def to_payload(self) -> Dict[str, Any]:
199
- params: Dict[str, Any] = {}
200
- for name in self._models:
201
- model_params = self.get_model_params(name)
202
- if model_params:
203
- params[name] = model_params
204
  return {
205
  "active": self._active,
206
- "models": self.list_models(),
207
- "params": params,
 
 
 
 
 
 
208
  }
 
1
+ """Lead compensation for replay fidelity."""
2
 
3
  from __future__ import annotations
4
 
5
  import copy
6
+ from typing import Any, Dict
 
 
 
7
 
8
  try:
9
  from reachy_mini.motion.recorded_move import RecordedMove
 
16
  return copy.deepcopy(move)
17
 
18
 
19
+ class LeadCompensationModel:
 
 
 
 
 
 
 
 
 
 
 
20
  """Shift commands ahead in time to compensate for controller lag.
21
 
22
  Uses separate lead values for antennas vs head/body, since antennas
 
27
  label = "Lead compensation"
28
  description = "Issues commands a few frames ahead to counter fixed latency."
29
 
30
+ def __init__(self, lead_frames_antennas: int = 4, lead_frames_head: int = 15) -> None:
31
  self.lead_frames_antennas = max(0, int(lead_frames_antennas))
32
  self.lead_frames_head = max(0, int(lead_frames_head))
33
 
 
37
  if not data:
38
  return patched
39
  total = len(data)
 
40
  original = [copy.deepcopy(f) for f in data]
41
  for idx in range(total):
42
  ant_src = min(idx + self.lead_frames_antennas, total - 1)
 
48
  return patched
49
 
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  class MotionModelRegistry:
52
+ """Always-on lead compensation model."""
53
+
54
+ def __init__(self) -> None:
55
+ self._model = LeadCompensationModel()
56
+ self._active = LeadCompensationModel.name
 
 
 
 
 
57
 
58
  @property
59
  def active(self) -> str:
60
  return self._active
61
 
 
 
 
 
 
62
  def set_model_params(self, name: str, params: Dict[str, Any]) -> None:
63
+ if name != LeadCompensationModel.name:
 
64
  raise KeyError(name)
65
+ head = params.get("lead_frames_head")
66
+ antennas = params.get("lead_frames_antennas")
67
+ if head is not None:
68
+ self._model.lead_frames_head = max(0, int(head))
69
+ if antennas is not None:
70
+ self._model.lead_frames_antennas = max(0, int(antennas))
 
71
 
72
  def get_model_params(self, name: str) -> Dict[str, Any]:
73
+ if name != LeadCompensationModel.name:
 
74
  raise KeyError(name)
75
+ return {
76
+ "lead_frames_head": int(self._model.lead_frames_head),
77
+ "lead_frames_antennas": int(self._model.lead_frames_antennas),
78
+ }
 
 
 
 
 
 
 
 
 
 
 
 
79
 
80
  def transform_move(self, move_data: Dict[str, Any]) -> Dict[str, Any]:
81
+ return self._model.transform(move_data)
 
 
 
82
 
83
  def apply(self, recorded_move: RecordedMove) -> RecordedMove:
84
  if RecordedMove is None:
85
  raise RuntimeError("reachy_mini is unavailable.")
 
 
86
  mutated = self.transform_move(recorded_move.move)
87
  return RecordedMove(mutated, recorded_move.sound_path)
88
 
89
  def to_payload(self) -> Dict[str, Any]:
 
 
 
 
 
90
  return {
91
  "active": self._active,
92
+ "models": [
93
+ {
94
+ "name": LeadCompensationModel.name,
95
+ "label": LeadCompensationModel.label,
96
+ "description": LeadCompensationModel.description,
97
+ }
98
+ ],
99
+ "params": {LeadCompensationModel.name: self.get_model_params(LeadCompensationModel.name)},
100
  }
marionette/static/index.html CHANGED
@@ -201,31 +201,19 @@
201
  <p class="settings-hint" id="welcome-msg-hint">Takes effect on next app restart</p>
202
  </div>
203
 
204
- <!-- Experimental features -->
205
- <div class="settings-section" id="experimental-section">
206
- <h3>Experimental</h3>
207
- <label class="settings-toggle">
208
- <span>Motion compensation models</span>
209
- <input type="checkbox" id="feature-motion-models"/>
210
- </label>
211
- <div id="motion-model-group" style="display:none">
212
- <div class="settings-row">
213
- <span class="settings-label">Model</span>
214
- <select class="settings-input" id="motion-model-select" style="cursor:pointer"></select>
215
- </div>
216
- <div id="lead-comp-group" style="display:none">
217
- <div class="settings-row">
218
- <span class="settings-label">Head lead (frames)</span>
219
- <input class="settings-input" id="lead-frames-head" type="number" min="0" max="2000" step="1"/>
220
- </div>
221
- <div class="settings-row">
222
- <span class="settings-label">Antenna lead (frames)</span>
223
- <input class="settings-input" id="lead-frames-antennas" type="number" min="0" max="2000" step="1"/>
224
- </div>
225
- <p class="settings-hint">Applied only during replay (never during recording).</p>
226
- </div>
227
- <p class="settings-hint" id="motion-model-hint"></p>
228
  </div>
 
 
229
  </div>
230
  </div>
231
 
 
201
  <p class="settings-hint" id="welcome-msg-hint">Takes effect on next app restart</p>
202
  </div>
203
 
204
+ <!-- Lead compensation -->
205
+ <div class="settings-section" id="lead-comp-section">
206
+ <h3>Lead compensation</h3>
207
+ <div class="settings-row">
208
+ <span class="settings-label">Head lead (frames)</span>
209
+ <input class="settings-input" id="lead-frames-head" type="number" min="0" max="2000" step="1"/>
210
+ </div>
211
+ <div class="settings-row">
212
+ <span class="settings-label">Antenna lead (frames)</span>
213
+ <input class="settings-input" id="lead-frames-antennas" type="number" min="0" max="2000" step="1"/>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  </div>
215
+ <p class="settings-hint">1 frame = 10 ms (100 Hz control loop).</p>
216
+ <p class="settings-hint">Applied only during replay (never during recording).</p>
217
  </div>
218
  </div>
219
 
marionette/static/main.js CHANGED
@@ -85,11 +85,6 @@ const $hfLogoutBtn = document.getElementById('hf-logout-btn');
85
  const $datasetRootInput = document.getElementById('dataset-root-input');
86
  const $datasetRootHint = document.getElementById('dataset-root-hint');
87
  const $updateRootBtn = document.getElementById('update-root-btn');
88
- const $featureMotionModels = document.getElementById('feature-motion-models');
89
- const $motionModelGroup = document.getElementById('motion-model-group');
90
- const $motionModelSelect = document.getElementById('motion-model-select');
91
- const $motionModelHint = document.getElementById('motion-model-hint');
92
- const $leadCompGroup = document.getElementById('lead-comp-group');
93
  const $leadFramesHead = document.getElementById('lead-frames-head');
94
  const $leadFramesAntennas = document.getElementById('lead-frames-antennas');
95
  const $fetchCommunityBtn = document.getElementById('fetch-community-btn');
@@ -251,8 +246,8 @@ function updateUI(s) {
251
  if (radio && !radio.checked) radio.checked = true;
252
  }
253
 
254
- // Experimental features
255
- updateExperimentalUI(s.config);
256
  }
257
 
258
  // ── Datasets ──
@@ -743,44 +738,22 @@ async function downloadCommunity() {
743
 
744
 
745
  // ═══════════════════════════════════════════════════════════════════════
746
- // SECTION 9: EXPERIMENTAL FEATURES UI
747
  // ═══════════════════════════════════════════════════════════════════════
748
 
749
- function updateExperimentalUI(config) {
750
- const features = config.features || {};
751
- $featureMotionModels.checked = !!features.motion_models;
752
-
753
  const modelsPayload = config.motion_models;
754
- const hasModels = modelsPayload && Array.isArray(modelsPayload.models) && modelsPayload.models.length;
755
- const enabled = features.motion_models && hasModels;
756
- $motionModelGroup.style.display = enabled ? '' : 'none';
757
-
758
- if (!enabled) return;
759
- $motionModelSelect.innerHTML = '';
760
- modelsPayload.models.forEach(m => {
761
- const o = document.createElement('option');
762
- o.value = m.name;
763
- o.textContent = m.label;
764
- $motionModelSelect.appendChild(o);
765
- });
766
- $motionModelSelect.value = modelsPayload.active || '';
767
- const current = modelsPayload.models.find(m => m.name === modelsPayload.active);
768
- if ($motionModelHint) $motionModelHint.textContent = current ? current.description : '';
769
-
770
- const isLeadComp = modelsPayload.active === 'lead_compensation';
771
- if ($leadCompGroup) $leadCompGroup.style.display = isLeadComp ? '' : 'none';
772
- if (isLeadComp) {
773
- const params = modelsPayload.params?.lead_compensation || {};
774
- if ($leadFramesHead && document.activeElement !== $leadFramesHead && Number.isFinite(params.lead_frames_head)) {
775
- $leadFramesHead.value = String(params.lead_frames_head);
776
- }
777
- if (
778
- $leadFramesAntennas
779
- && document.activeElement !== $leadFramesAntennas
780
- && Number.isFinite(params.lead_frames_antennas)
781
- ) {
782
- $leadFramesAntennas.value = String(params.lead_frames_antennas);
783
- }
784
  }
785
  }
786
 
@@ -927,24 +900,6 @@ document.querySelectorAll('input[name="welcome-msgs"]').forEach(r => {
927
  });
928
  });
929
 
930
- /* Experimental features */
931
- $featureMotionModels.addEventListener('change', e => {
932
- fetch('/api/experiments', {
933
- method: 'POST',
934
- headers: { 'Content-Type': 'application/json' },
935
- body: JSON.stringify({ motion_models: e.target.checked }),
936
- }).then(() => fetchState());
937
- });
938
- $motionModelSelect.addEventListener('change', e => {
939
- if (e.target.value) {
940
- fetch('/api/motion-model', {
941
- method: 'POST',
942
- headers: { 'Content-Type': 'application/json' },
943
- body: JSON.stringify({ name: e.target.value }),
944
- }).then(() => fetchState());
945
- }
946
- });
947
-
948
  function saveLeadCompensationParams() {
949
  const head = parseInt($leadFramesHead?.value ?? '', 10);
950
  const antennas = parseInt($leadFramesAntennas?.value ?? '', 10);
 
85
  const $datasetRootInput = document.getElementById('dataset-root-input');
86
  const $datasetRootHint = document.getElementById('dataset-root-hint');
87
  const $updateRootBtn = document.getElementById('update-root-btn');
 
 
 
 
 
88
  const $leadFramesHead = document.getElementById('lead-frames-head');
89
  const $leadFramesAntennas = document.getElementById('lead-frames-antennas');
90
  const $fetchCommunityBtn = document.getElementById('fetch-community-btn');
 
246
  if (radio && !radio.checked) radio.checked = true;
247
  }
248
 
249
+ // Lead compensation
250
+ updateLeadCompUI(s.config);
251
  }
252
 
253
  // ── Datasets ──
 
738
 
739
 
740
  // ═══════════════════════════════════════════════════════════════════════
741
+ // SECTION 9: LEAD COMPENSATION UI
742
  // ═══════════════════════════════════════════════════════════════════════
743
 
744
+ function updateLeadCompUI(config) {
 
 
 
745
  const modelsPayload = config.motion_models;
746
+ if (!modelsPayload) return;
747
+ const params = modelsPayload.params?.lead_compensation || {};
748
+ if ($leadFramesHead && document.activeElement !== $leadFramesHead && Number.isFinite(params.lead_frames_head)) {
749
+ $leadFramesHead.value = String(params.lead_frames_head);
750
+ }
751
+ if (
752
+ $leadFramesAntennas
753
+ && document.activeElement !== $leadFramesAntennas
754
+ && Number.isFinite(params.lead_frames_antennas)
755
+ ) {
756
+ $leadFramesAntennas.value = String(params.lead_frames_antennas);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
757
  }
758
  }
759
 
 
900
  });
901
  });
902
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
903
  function saveLeadCompensationParams() {
904
  const head = parseInt($leadFramesHead?.value ?? '', 10);
905
  const antennas = parseInt($leadFramesAntennas?.value ?? '', 10);
tests/test_api.py CHANGED
@@ -99,7 +99,7 @@ class TestStateEndpoint:
99
  assert config["countdown_seconds"] == COUNTDOWN_SECONDS
100
  assert config["motion_sample_rate"] == MOTION_SAMPLE_RATE
101
  assert isinstance(config["audio_available"], bool)
102
- assert isinstance(config["features"], dict)
103
 
104
  def test_initial_moves_empty(self, client: TestClient):
105
  data = client.get("/api/state").json()
@@ -509,19 +509,15 @@ class TestMovesRefresh:
509
  assert move["has_audio"] is False
510
 
511
 
512
- # ──────── Experiment / feature toggle tests ───────────────────────────
513
 
514
 
515
  class TestExperiments:
516
- def test_denoise_feature_removed(self, client: TestClient):
517
- """Denoise feature was removed; the features dict should not contain it."""
518
  data = client.get("/api/state").json()
519
- assert "denoise" not in data["config"]["features"]
520
-
521
- def test_toggle_motion_models(self, client: TestClient):
522
- resp = client.post("/api/experiments", json={"motion_models": True})
523
- assert resp.status_code == 200
524
- assert resp.json()["features"]["motion_models"] is True
525
 
526
  def test_update_duration(self, client: TestClient):
527
  resp = client.post("/api/experiments", json={"duration_seconds": 10.0})
@@ -897,39 +893,11 @@ class TestTimingSync:
897
  assert marionette._stream_playback(_FakeReachy(), _FakeMove()) is True
898
 
899
 
900
- # ──────── Motion model endpoint tests ────────────────────────────────
901
 
902
 
903
  class TestMotionModelEndpoint:
904
- def test_motion_model_rejected_when_disabled(self, client: TestClient):
905
- client.post("/api/experiments", json={"motion_models": False})
906
- resp = client.post("/api/motion-model", json={"name": "no_model"})
907
- assert resp.status_code == 400
908
-
909
- def test_enable_then_set_model(self, client: TestClient):
910
- client.post("/api/experiments", json={"motion_models": True})
911
- resp = client.post("/api/motion-model", json={"name": "no_model"})
912
- assert resp.status_code == 200
913
- assert resp.json()["active"] == "no_model"
914
-
915
- def test_set_unknown_model_returns_404(self, client: TestClient):
916
- client.post("/api/experiments", json={"motion_models": True})
917
- resp = client.post("/api/motion-model", json={"name": "totally_fake_model"})
918
- assert resp.status_code == 404
919
-
920
- def test_model_persisted_in_registry(
921
- self, tmp_registry: Path, tmp_dataset_root: Path
922
- ):
923
- app1, m1 = create_app(registry_path=tmp_registry, dataset_root=tmp_dataset_root)
924
- c1 = TestClient(app1)
925
- c1.post("/api/experiments", json={"motion_models": True})
926
- c1.post("/api/motion-model", json={"name": "no_model"})
927
-
928
- _, m2 = create_app(registry_path=tmp_registry, dataset_root=tmp_dataset_root)
929
- assert m2._motion_model_registry.active == "no_model"
930
-
931
  def test_update_lead_compensation_params(self, client: TestClient):
932
- client.post("/api/experiments", json={"motion_models": True})
933
  resp = client.post(
934
  "/api/motion-model/lead",
935
  json={"lead_frames_head": 3, "lead_frames_antennas": 1},
@@ -950,7 +918,6 @@ class TestMotionModelEndpoint:
950
  ):
951
  app1, _ = create_app(registry_path=tmp_registry, dataset_root=tmp_dataset_root)
952
  c1 = TestClient(app1)
953
- c1.post("/api/experiments", json={"motion_models": True})
954
  c1.post(
955
  "/api/motion-model/lead",
956
  json={"lead_frames_head": 4, "lead_frames_antennas": 2},
@@ -1257,7 +1224,7 @@ class TestApiContracts:
1257
  "default_duration", "preferred_duration", "countdown_seconds",
1258
  "motion_sample_rate", "audio_available",
1259
  "active_dataset_path", "dataset_root_path",
1260
- "hf_username", "features", "feature_support",
1261
  }
1262
  assert required.issubset(config.keys()), (
1263
  f"Missing config keys: {required - config.keys()}"
@@ -1333,11 +1300,11 @@ class TestApiContracts:
1333
  assert "folder" in ds
1334
 
1335
  def test_experiments_response_shape(self, client: TestClient):
1336
- resp = client.post("/api/experiments", json={"motion_models": True})
1337
  assert resp.status_code == 200
1338
  data = resp.json()
1339
  assert "status" in data
1340
- assert "features" in data
1341
 
1342
 
1343
  # ──────── Audio-only recording tests ─────────────────────────────────
 
99
  assert config["countdown_seconds"] == COUNTDOWN_SECONDS
100
  assert config["motion_sample_rate"] == MOTION_SAMPLE_RATE
101
  assert isinstance(config["audio_available"], bool)
102
+ assert "motion_models" in config
103
 
104
  def test_initial_moves_empty(self, client: TestClient):
105
  data = client.get("/api/state").json()
 
509
  assert move["has_audio"] is False
510
 
511
 
512
+ # ──────── Settings tests ───────────────────────────
513
 
514
 
515
  class TestExperiments:
516
+ def test_motion_models_present(self, client: TestClient):
 
517
  data = client.get("/api/state").json()
518
+ mm = data["config"]["motion_models"]
519
+ assert mm["active"] == "lead_compensation"
520
+ assert "lead_compensation" in mm["params"]
 
 
 
521
 
522
  def test_update_duration(self, client: TestClient):
523
  resp = client.post("/api/experiments", json={"duration_seconds": 10.0})
 
893
  assert marionette._stream_playback(_FakeReachy(), _FakeMove()) is True
894
 
895
 
896
+ # ──────── Lead compensation endpoint tests ────────────────────────────────
897
 
898
 
899
  class TestMotionModelEndpoint:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
900
  def test_update_lead_compensation_params(self, client: TestClient):
 
901
  resp = client.post(
902
  "/api/motion-model/lead",
903
  json={"lead_frames_head": 3, "lead_frames_antennas": 1},
 
918
  ):
919
  app1, _ = create_app(registry_path=tmp_registry, dataset_root=tmp_dataset_root)
920
  c1 = TestClient(app1)
 
921
  c1.post(
922
  "/api/motion-model/lead",
923
  json={"lead_frames_head": 4, "lead_frames_antennas": 2},
 
1224
  "default_duration", "preferred_duration", "countdown_seconds",
1225
  "motion_sample_rate", "audio_available",
1226
  "active_dataset_path", "dataset_root_path",
1227
+ "hf_username", "motion_models",
1228
  }
1229
  assert required.issubset(config.keys()), (
1230
  f"Missing config keys: {required - config.keys()}"
 
1300
  assert "folder" in ds
1301
 
1302
  def test_experiments_response_shape(self, client: TestClient):
1303
+ resp = client.post("/api/experiments", json={"duration_seconds": 7.0})
1304
  assert resp.status_code == 200
1305
  data = resp.json()
1306
  assert "status" in data
1307
+ assert "preferred_duration" in data
1308
 
1309
 
1310
  # ──────── Audio-only recording tests ─────────────────────────────────