Dual-shepherd soft axis-split (HERDING_AXIS_LEAK)

The strict 100/0 axis mask reaches drive standoff and deadlocks
because each dog has only one degree of freedom left to push the
flock. Soften the mask: each dog leads its assigned axis (full gain)
and contributes ``HERDING_AXIS_LEAK`` on the other axis. ``0.0`` is
the old strict behaviour; ``1.0`` is no mask (both dogs run full
policy, role-redundant). Default ``0.3`` breaks the deadlock while
preserving the "one dog per axis" coordination story.

Implementation:
* `controllers/shepherd_dog/shepherd_dog.py` reads
  `HERDING_AXIS_LEAK` from env / runtime cfg (clamped to [0, 1]),
  prints it next to the axis tag, and multiplies the off-axis
  velocity component by it instead of zeroing.
* `tools/run_webots.sh` writes `HERDING_AXIS_LEAK` into
  `herding_runtime.cfg` so Webots-stripped controller subprocesses
  still see it; defaults to 0.3 when unset.

Webots smoke test (HERDING_NDOGS=2, HERDING_AXIS_LEAK=0.3, strombom,
diff/field, 5 sheep, LiDAR perception, no GT): **5/5 penned at step
13204**, vs the strict 100/0 mask which timed out at 0/5. Penning
trail 1/5 → 2/5 → 4/5 → 5/5 between steps 6200 and 13400 — slower
than single-dog (Strömbom diff/field n=5: 7528) as expected since
the work is split, but the coordination demonstrably succeeds.

This gives the writeup a clean three-row ablation:
  α=0.0  (strict)  → deadlock, 0/5
  α=0.3  (default) → 5/5 @ 13204
  α=1.0  (no mask) → both dogs run full policy (single-dog
                     baseline applied twice; no axis story)

126 pytest cases still pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Johnny Fernandes
2026-05-17 02:43:40 +00:00
parent cfbf4a0267
commit eadeeafb32
2 changed files with 27 additions and 8 deletions
+26 -8
View File
@@ -269,9 +269,19 @@ timestep = int(robot.getBasicTimeStep())
# Multi-shepherd axis split. When the launcher creates two dog instances # Multi-shepherd axis split. When the launcher creates two dog instances
# it sets each robot's customData to "axis=x" or "axis=y"; the controller # it sets each robot's customData to "axis=x" or "axis=y"; the controller
# then masks the off-axis component of every action so the two dogs # then attenuates the off-axis component of every action so the two
# share the herding workload along orthogonal axes (one closes flank # dogs share the herding workload along orthogonal axes. customData
# distance in x, the other in y). customData empty = single-dog mode. # empty = single-dog mode (no masking).
#
# HERDING_AXIS_LEAK controls how strict the mask is:
# 0.0 → hard mask (off-axis component zeroed; pure axis-split)
# 1.0 → no mask (both dogs run full action; equivalent to NDOGS=2
# without axis assignment)
# Defaults to 0.3 — empirically the 100/0 strict mask deadlocks once
# both dogs reach their drive standoff (the Strömbom target shrinks
# and each dog has only one degree of freedom). A small leak keeps
# pressure on the flock while preserving the "one dog leads each
# axis" coordination story.
_AXIS_TAG = (robot.getCustomData() or "").strip().lower() _AXIS_TAG = (robot.getCustomData() or "").strip().lower()
if _AXIS_TAG.startswith("axis="): if _AXIS_TAG.startswith("axis="):
DOG_AXIS = _AXIS_TAG[5:] DOG_AXIS = _AXIS_TAG[5:]
@@ -280,8 +290,14 @@ if _AXIS_TAG.startswith("axis="):
DOG_AXIS = None DOG_AXIS = None
else: else:
DOG_AXIS = None DOG_AXIS = None
try:
AXIS_LEAK = float(os.environ.get("HERDING_AXIS_LEAK")
or _runtime_cfg.get("HERDING_AXIS_LEAK", "0.3"))
except ValueError:
AXIS_LEAK = 0.3
AXIS_LEAK = max(0.0, min(1.0, AXIS_LEAK))
DOG_NAME = robot.getName() DOG_NAME = robot.getName()
print(f"[dog] name={DOG_NAME} axis={DOG_AXIS}") print(f"[dog] name={DOG_NAME} axis={DOG_AXIS} leak={AXIS_LEAK:.2f}")
if DRIVE_MODE == "mecanum": if DRIVE_MODE == "mecanum":
fl_motor = robot.getDevice("front left wheel motor") fl_motor = robot.getDevice("front left wheel motor")
@@ -483,12 +499,14 @@ while robot.step(timestep) != -1:
# Near-sheep speed modulation (shared by every mode). # Near-sheep speed modulation (shared by every mode).
vx, vy = modulate_speed(vx, vy, dog_xy, sheep_positions) vx, vy = modulate_speed(vx, vy, dog_xy, sheep_positions)
# Axis-split mask for the dual-dog setup: this dog only commits to # Axis-split mask for the dual-dog setup: this dog leads its
# its assigned axis (x or y) so its partner covers the other. # assigned axis (full gain) and contributes AXIS_LEAK on the
# off-axis. With LEAK=0 the mask is strict; with LEAK=1 the dogs
# run identical full-power policies.
if DOG_AXIS == "x": if DOG_AXIS == "x":
vy = 0.0 vy *= AXIS_LEAK
elif DOG_AXIS == "y": elif DOG_AXIS == "y":
vx = 0.0 vx *= AXIS_LEAK
# EMA smoothing — kills frame-to-frame action jitter. # EMA smoothing — kills frame-to-frame action jitter.
if DRIVE_MODE == "mecanum": if DRIVE_MODE == "mecanum":
+1
View File
@@ -230,6 +230,7 @@ HERDING_DRIVE=$DRIVE
HERDING_WORLD=$WORLD HERDING_WORLD=$WORLD
HERDING_LIDAR=$LIDAR_VARIANT HERDING_LIDAR=$LIDAR_VARIANT
HERDING_NDOGS=$NDOGS HERDING_NDOGS=$NDOGS
HERDING_AXIS_LEAK=${HERDING_AXIS_LEAK:-0.3}
HERDING_USE_GT=${HERDING_USE_GT:-0} HERDING_USE_GT=${HERDING_USE_GT:-0}
EOF EOF