Checkpoint 8

This commit is contained in:
Johnny Fernandes
2026-05-12 22:41:03 +01:00
parent a01a5c9cef
commit 5c2ee4bba5
31 changed files with 3189 additions and 380 deletions
+8 -23
View File
@@ -1,30 +1,15 @@
# Editor / IDE
.claude/
.venv/
# Python # Python
__pycache__/ __pycache__/
*.pyc
# Webots controller scratch / debug # Training artefacts: ignore all run outputs except deployable policies
controllers/shepherd_dog/dog_behavior_log.csv training/runs/**
dog_debug.csv !training/runs/
# Training artefacts: ignore by default, whitelist the two working BC policies
*.zip
*.pkl
*.npz
events.out.tfevents.*
training/runs/*/checkpoints/
training/runs/*/tb/
training/runs/*/evals/
training/runs/*/best/
!training/runs/.gitkeep !training/runs/.gitkeep
!training/runs/bc_v3/policy.zip !training/runs/*/
!training/runs/rl_v1/policy.zip !training/runs/*/policy.zip
!training/runs/rl_v2/policy.zip
!training/runs/rl_v2/best/best_model.zip
# Webots launcher scratch # Webots launcher scratch
worlds/field_test.wbt worlds/**
!worlds/field.wbt
!worlds/field_round.wbt
herding_runtime.cfg herding_runtime.cfg
+102 -18
View File
@@ -9,35 +9,79 @@
# make eval # 10-seed env eval of rl # make eval # 10-seed env eval of rl
# make test # pytest suite # make test # pytest suite
# make webots N=10 MODE=rl # launch Webots in the chosen mode # make webots N=10 MODE=rl # launch Webots in the chosen mode
# WEBOTS_HEADLESS=1 make webots # no 3D view, fast mode (still needs DISPLAY or xvfb-run)
# make clean # delete bc_demos and run artefacts # make clean # delete bc_demos and run artefacts
# make clean_all # delete artefacts for all combinations
# make help # print the target table # make help # print the target table
# #
# Override any hyperparameter on the command line, for example: # Override any hyperparameter on the command line, for example:
# make rl PPO_STEPS=2000000 KL=0.02 # make rl PPO_STEPS=2000000 KL=0.02
# make eval EVAL_SEEDS=20 # make eval EVAL_SEEDS=20
#
# Drive mode selects the locomotion model:
# make DRIVE=differential 2-wheel diff-drive (default)
# make DRIVE=mecanum 4-wheel omnidirectional
#
# World shape:
# make WORLD=field rectangular (default)
# make WORLD=field_round circular fence
#
# To train all 4 combinations:
# make train_all
PY := python PY := python
BC_DEMOS := training/bc/demos.npz # Drive mode and world shape — each combination gets its own artefacts.
BC_DIR := training/runs/bc DRIVE ?= differential
RL_DIR := training/runs/rl WORLD ?= field
BC_POLICY := $(BC_DIR)/policy.zip
RL_POLICY := $(RL_DIR)/policy.zip # Derived tag and paths.
TAG = $(DRIVE)_$(WORLD)
BC_DEMOS = training/bc/demos_$(TAG).npz
BC_DIR = training/runs/bc_$(TAG)
RL_DIR = training/runs/rl_$(TAG)
BC_POLICY = $(BC_DIR)/policy.zip
RL_POLICY = $(RL_DIR)/policy.zip
# --- Demo collection --- # --- Demo collection ---
TEACHER ?= strombom TEACHER ?= universal
SEEDS_PER_N ?= 15 # Round field is fundamentally harder (narrow gate at south of a circle).
# Default to more demos there to give BC a fair shot at 60%+.
ifeq ($(WORLD),field_round)
SEEDS_PER_N ?= 40
else
SEEDS_PER_N ?= 25
endif
SUBSAMPLE ?= 3 SUBSAMPLE ?= 3
FRAME_STACK ?= 4 FRAME_STACK ?= 4
DEMO_MAX_STEPS ?= 100000
# --- Behaviour cloning --- # --- Behaviour cloning ---
ifeq ($(WORLD),field_round)
BC_EPOCHS ?= 100
else
BC_EPOCHS ?= 60 BC_EPOCHS ?= 60
endif
BC_NET_ARCH ?= 512,512 BC_NET_ARCH ?= 512,512
# --- KL-PPO fine-tune --- # --- KL-PPO fine-tune ---
PPO_STEPS ?= 1000000 # Round field: longer training, looser KL, no time penalty (success
# must be learned before speed is rewarded).
ifeq ($(WORLD),field_round)
PPO_STEPS ?= 4000000
KL ?= 0.02
TIME_W ?= 0.0
else
PPO_STEPS ?= 2000000
KL ?= 0.05 KL ?= 0.05
TIME_W ?= -0.05
endif
IMITATE ?= 0.0
# PPO rollouts at full difficulty so the training distribution matches
# eval (deployment). Anything lower causes a train/eval mismatch that
# can make RL eval worse than BC.
DIFFICULTY ?= 1.0
# --- Evaluation --- # --- Evaluation ---
EVAL_SEEDS ?= 10 EVAL_SEEDS ?= 10
@@ -48,16 +92,23 @@ N ?= 10
MODE ?= rl MODE ?= rl
.PHONY: all bc_demos bc rl eval test webots clean help .PHONY: all bc_demos bc rl eval test webots clean clean_all help \
train_all train_diff_rect train_diff_round \
train_mec_rect train_mec_round
all: eval all: eval
# Export HERDING_WORLD so that geometry.py picks it up at import time.
export HERDING_WORLD = $(WORLD)
bc_demos: $(BC_DEMOS) bc_demos: $(BC_DEMOS)
$(BC_DEMOS): $(BC_DEMOS):
$(PY) -m training.bc.collect \ $(PY) -m training.bc.collect \
--teacher $(TEACHER) --out $(BC_DEMOS) \ --teacher $(TEACHER) --out $(BC_DEMOS) \
--seeds-per-n $(SEEDS_PER_N) --subsample $(SUBSAMPLE) \ --seeds-per-n $(SEEDS_PER_N) --subsample $(SUBSAMPLE) \
--frame-stack $(FRAME_STACK) --frame-stack $(FRAME_STACK) --drive-mode $(DRIVE) \
--world $(WORLD) \
--max-steps $(DEMO_MAX_STEPS)
bc: $(BC_POLICY) bc: $(BC_POLICY)
$(BC_POLICY): $(BC_DEMOS) $(BC_POLICY): $(BC_DEMOS)
@@ -69,20 +120,44 @@ rl: $(RL_POLICY)
$(RL_POLICY): $(BC_POLICY) $(RL_POLICY): $(BC_POLICY)
$(PY) -m training.rl.train \ $(PY) -m training.rl.train \
--bc $(BC_DIR) --out $(RL_DIR) \ --bc $(BC_DIR) --out $(RL_DIR) \
--total-timesteps $(PPO_STEPS) --kl-coef $(KL) --total-timesteps $(PPO_STEPS) --kl-coef $(KL) \
--imitate-weight $(IMITATE) --time-weight $(TIME_W) \
--difficulty $(DIFFICULTY) \
--drive-mode $(DRIVE) --world $(WORLD)
eval: $(RL_POLICY) eval: $(RL_POLICY)
$(PY) -m training.eval --policy $(RL_DIR) \ $(PY) -m training.eval --policy $(RL_DIR) \
--max-flock 10 --max-steps $(EVAL_MAX_STEPS) --n-seeds $(EVAL_SEEDS) --max-flock 10 --max-steps $(EVAL_MAX_STEPS) --n-seeds $(EVAL_SEEDS) \
--drive-mode $(DRIVE) --world $(WORLD)
test: test:
$(PY) -m pytest tests/ $(PY) -m pytest tests/
webots: webots:
tools/run_webots.sh $(N) $(MODE) tools/run_webots.sh $(N) $(MODE) $(DRIVE) $(WORLD)
clean: clean:
rm -rf $(BC_DEMOS) $(BC_DIR) $(RL_DIR) rm -f $(BC_DEMOS)
rm -rf $(BC_DIR) $(RL_DIR)
clean_all:
rm -f training/bc/demos_*.npz
rm -rf training/runs/bc_* training/runs/rl_*
# --- Train all 4 combinations ---
train_diff_rect:
$(MAKE) DRIVE=differential WORLD=field
train_diff_round:
$(MAKE) DRIVE=differential WORLD=field_round
train_mec_rect:
$(MAKE) DRIVE=mecanum WORLD=field
train_mec_round:
$(MAKE) DRIVE=mecanum WORLD=field_round
train_all: train_diff_rect train_diff_round train_mec_rect train_mec_round
help: help:
@echo "Targets:" @echo "Targets:"
@@ -92,12 +167,21 @@ help:
@echo " make rl KL-PPO fine-tune (rebuilds bc if missing)" @echo " make rl KL-PPO fine-tune (rebuilds bc if missing)"
@echo " make eval $(EVAL_SEEDS)-seed env eval of rl" @echo " make eval $(EVAL_SEEDS)-seed env eval of rl"
@echo " make test pytest suite" @echo " make test pytest suite"
@echo " make webots [N=$(N)] [MODE=$(MODE)]" @echo " make webots [N=$(N)] [MODE=$(MODE)] [DRIVE=$(DRIVE)] [WORLD=$(WORLD)]"
@echo " launch Webots in the chosen mode" @echo " launch Webots in the chosen mode"
@echo " make clean delete bc_demos and run artefacts" @echo " WEBOTS_HEADLESS=1 make webots … no 3D view + fast + --batch"
@echo " make clean delete artefacts for current DRIVE+WORLD"
@echo " make clean_all delete artefacts for all combinations"
@echo ""
@echo "Combinations:"
@echo " make DRIVE=differential WORLD=field diff + rectangular (default)"
@echo " make DRIVE=differential WORLD=field_round diff + circular"
@echo " make DRIVE=mecanum WORLD=field mecanum + rectangular"
@echo " make DRIVE=mecanum WORLD=field_round mecanum + circular"
@echo " make train_all all 4 in sequence"
@echo "" @echo ""
@echo "Hyperparameter overrides (showing defaults):" @echo "Hyperparameter overrides (showing defaults):"
@echo " TEACHER=$(TEACHER) SEEDS_PER_N=$(SEEDS_PER_N) SUBSAMPLE=$(SUBSAMPLE) FRAME_STACK=$(FRAME_STACK)" @echo " TEACHER=$(TEACHER) SEEDS_PER_N=$(SEEDS_PER_N) SUBSAMPLE=$(SUBSAMPLE) FRAME_STACK=$(FRAME_STACK) DEMO_MAX_STEPS=$(DEMO_MAX_STEPS)"
@echo " BC_EPOCHS=$(BC_EPOCHS) BC_NET_ARCH=$(BC_NET_ARCH)" @echo " BC_EPOCHS=$(BC_EPOCHS) BC_NET_ARCH=$(BC_NET_ARCH)"
@echo " PPO_STEPS=$(PPO_STEPS) KL=$(KL)" @echo " PPO_STEPS=$(PPO_STEPS) KL=$(KL) IMITATE=$(IMITATE) TIME_W=$(TIME_W)"
@echo " EVAL_SEEDS=$(EVAL_SEEDS) EVAL_MAX_STEPS=$(EVAL_MAX_STEPS)" @echo " EVAL_SEEDS=$(EVAL_SEEDS) EVAL_MAX_STEPS=$(EVAL_MAX_STEPS)"
+10 -1
View File
@@ -74,6 +74,15 @@ make webots N=10 MODE=strombom # analytic baseline
`make help` lists every target and the overridable hyperparameters `make help` lists every target and the overridable hyperparameters
(e.g. `make rl PPO_STEPS=2000000 KL=0.02`). (e.g. `make rl PPO_STEPS=2000000 KL=0.02`).
## Documentation map
- This README is the project overview: architecture, quick start, and
headline results.
- `training/README.md` has the command-level training and evaluation
details for demo collection, BC, PPO fine-tuning, and policy artifacts.
- `docs/project.md` is the original course proposal/goals document, kept
for traceability rather than as run instructions.
## Layout ## Layout
``` ```
@@ -128,7 +137,7 @@ worlds/
field.wbt — main world (3 m gate, external pen) field.wbt — main world (3 m gate, external pen)
protos/ — Sheep / ShepherdDog robot definitions protos/ — Sheep / ShepherdDog robot definitions
docs/project.md — original project goals docs/project.md — original course proposal/goals
``` ```
## Shared low-level control ## Shared low-level control
+136 -56
View File
@@ -49,37 +49,8 @@ _PROJECT_ROOT = os.path.normpath(os.path.join(_HERE, "..", ".."))
if _PROJECT_ROOT not in sys.path: if _PROJECT_ROOT not in sys.path:
sys.path.insert(0, _PROJECT_ROOT) sys.path.insert(0, _PROJECT_ROOT)
import numpy as np # --- Read runtime cfg early so env vars are set before geometry import ---
from controller import Robot
from herding.control.active_scan import ActiveScanTeacher
from herding.control.modulation import modulate_speed_near_sheep
from herding.control.sequential import compute_action as sequential_action
from herding.control.strombom import compute_action as strombom_action
from herding.perception.obs import build_obs
from herding.perception.lidar_perception import detections_from_scan
from herding.perception.sheep_tracker import SheepTracker
from herding.world.diffdrive import velocity_to_wheels
from herding.world.geometry import (
DOG_MAX_LINEAR, DOG_MAX_WHEEL_OMEGA,
DOG_SOUTH_LIMIT, DOG_WHEEL_RADIUS,
PEN_ENTRY, is_penned_position,
)
# ---------------------------------------------------------------------------
# Mode + policy resolution
# ---------------------------------------------------------------------------
def _load_runtime_config(): def _load_runtime_config():
"""Read mode + policy_dir overrides from a runtime config file.
Webots strips HERDING_* env vars in some configurations, so the
launcher writes a tiny ``herding_runtime.cfg`` (key=value lines)
in the project root and the controller reads it here. Env vars
win if both are present; the file is the fallback.
"""
cfg_path = os.path.join(_PROJECT_ROOT, "herding_runtime.cfg") cfg_path = os.path.join(_PROJECT_ROOT, "herding_runtime.cfg")
if not os.path.exists(cfg_path): if not os.path.exists(cfg_path):
return {} return {}
@@ -96,8 +67,37 @@ def _load_runtime_config():
return {} return {}
return out return out
_runtime_cfg = _load_runtime_config() _runtime_cfg = _load_runtime_config()
# Seed env vars from runtime cfg so downstream modules (geometry.py) see them.
for _rk, _rv in _runtime_cfg.items():
if _rk.startswith("HERDING_") and _rk not in os.environ:
os.environ[_rk] = _rv
import numpy as np
from controller import Robot
from herding.control.active_scan import ActiveScanTeacher
from herding.control.modulation import modulate_speed_near_sheep
from herding.control.sequential import compute_action as sequential_action
from herding.control.strombom import compute_action as strombom_action
from herding.control.universal import compute_action as universal_action
from herding.perception.obs import build_obs
from herding.perception.lidar_perception import detections_from_scan
from herding.perception.sheep_tracker import SheepTracker
from herding.world.diffdrive import velocity_to_mecanum_wheels, velocity_to_wheels
from herding.world.geometry import (
DOG_MAX_LINEAR, DOG_MAX_WHEEL_OMEGA,
DOG_SOUTH_LIMIT, DOG_WHEEL_BASE, DOG_WHEEL_BASE_X,
DOG_WHEEL_BASE_Y, DOG_WHEEL_RADIUS,
PEN_ENTRY, is_penned_position,
)
# ---------------------------------------------------------------------------
# Mode + policy resolution (cfg already loaded above)
# ---------------------------------------------------------------------------
MODE = (os.environ.get("HERDING_MODE") MODE = (os.environ.get("HERDING_MODE")
or _runtime_cfg.get("HERDING_MODE") or _runtime_cfg.get("HERDING_MODE")
or "bc").lower() or "bc").lower()
@@ -109,31 +109,39 @@ def _resolve_policy_dir(mode: str) -> str:
Priority: Priority:
1. HERDING_POLICY_DIR env var or runtime-cfg entry, if it points 1. HERDING_POLICY_DIR env var or runtime-cfg entry, if it points
to a real directory. to a real directory.
2. Mode-specific default: 2. Drive-mode-specific default:
bc → training/runs/bc (Strömbom-imitated MLP) bc → training/runs/bc_differential (or bc_mecanum)
rl → training/runs/rl (KL-PPO fine-tune of bc) rl → training/runs/rl_differential (or rl_mecanum)
3. Fall back to bc. 3. Legacy path (no drive suffix):
All checkpoints are frame-stacked K = 4; ``policy_loader`` reads bc → training/runs/bc
the stacking factor from the policy's observation space. rl → training/runs/rl
""" """
env_dir = (os.environ.get("HERDING_POLICY_DIR") env_dir = (os.environ.get("HERDING_POLICY_DIR")
or _runtime_cfg.get("HERDING_POLICY_DIR")) or _runtime_cfg.get("HERDING_POLICY_DIR"))
if env_dir and os.path.isdir(env_dir): if env_dir and os.path.isdir(env_dir):
return env_dir return env_dir
drive = DRIVE_MODE
mode_default = { mode_default = {
"bc": os.path.join(_PROJECT_ROOT, "training", "runs", "bc"), "bc": os.path.join(_PROJECT_ROOT, "training", "runs",
"rl": os.path.join(_PROJECT_ROOT, "training", "runs", "rl"), f"bc_{drive}"),
"rl": os.path.join(_PROJECT_ROOT, "training", "runs",
f"rl_{drive}"),
} }
primary = mode_default.get(mode, mode_default["bc"]) primary = mode_default.get(mode, mode_default["bc"])
if os.path.isdir(primary): if os.path.isdir(primary):
return primary return primary
fallback = mode_default["bc"] # Fallback: legacy paths without drive suffix.
legacy = {
"bc": os.path.join(_PROJECT_ROOT, "training", "runs", "bc"),
"rl": os.path.join(_PROJECT_ROOT, "training", "runs", "rl"),
}
fallback = legacy.get(mode, legacy["bc"])
if os.path.isdir(fallback): if os.path.isdir(fallback):
return fallback return fallback
return env_dir or primary return env_dir or primary
_VALID_MODES = ("bc", "rl", "strombom", "sequential") _VALID_MODES = ("bc", "rl", "strombom", "sequential", "universal")
if MODE not in _VALID_MODES: if MODE not in _VALID_MODES:
print(f"[dog] unknown HERDING_MODE={MODE!r}; defaulting to strombom.") print(f"[dog] unknown HERDING_MODE={MODE!r}; defaulting to strombom.")
MODE = "strombom" MODE = "strombom"
@@ -151,6 +159,15 @@ if MODE in ("bc", "rl"):
MODE = "strombom" MODE = "strombom"
print(f"[dog] running in mode={MODE}") print(f"[dog] running in mode={MODE}")
# Drive mode: "differential" (2-wheel) or "mecanum" (4-wheel omnidirectional).
DRIVE_MODE = (os.environ.get("HERDING_DRIVE")
or _runtime_cfg.get("HERDING_DRIVE")
or "differential").lower()
if DRIVE_MODE not in ("differential", "mecanum"):
print(f"[dog] unknown HERDING_DRIVE={DRIVE_MODE!r}; defaulting to differential.")
DRIVE_MODE = "differential"
print(f"[dog] drive mode={DRIVE_MODE}")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Control parameters # Control parameters
@@ -171,7 +188,8 @@ def safety_clamp(vx: float, vy: float, dog_x: float, dog_y: float) -> tuple:
return (vx, vy) return (vx, vy)
def drive(vx: float, vy: float, left_motor, right_motor, compass, motor_max: float): def drive_diff(vx: float, vy: float, left_motor, right_motor,
compass, motor_max: float):
if math.hypot(vx, vy) < 1e-3: if math.hypot(vx, vy) < 1e-3:
left_motor.setVelocity(0.0) left_motor.setVelocity(0.0)
right_motor.setVelocity(0.0) right_motor.setVelocity(0.0)
@@ -189,6 +207,32 @@ def drive(vx: float, vy: float, left_motor, right_motor, compass, motor_max: flo
right_motor.setVelocity(right) right_motor.setVelocity(right)
def drive_mecanum(vx: float, vy: float, omega: float,
fl_motor, fr_motor, rl_motor, rr_motor,
compass, motor_max: float):
if math.hypot(vx, vy) < 1e-3 and abs(omega) < 1e-3:
fl_motor.setVelocity(0.0)
fr_motor.setVelocity(0.0)
rl_motor.setVelocity(0.0)
rr_motor.setVelocity(0.0)
return
n = compass.getValues()
h = math.atan2(n[0], n[1])
w_fl, w_fr, w_rl, w_rr = velocity_to_mecanum_wheels(
vx, vy, omega, h,
max_linear=DOG_MAX_LINEAR,
wheel_radius=DOG_WHEEL_RADIUS,
lx=DOG_WHEEL_BASE_X / 2.0, ly=DOG_WHEEL_BASE_Y / 2.0,
max_wheel_omega=motor_max,
k_turn=4.0,
wheel_base=DOG_WHEEL_BASE,
)
fl_motor.setVelocity(w_fl)
fr_motor.setVelocity(w_fr)
rl_motor.setVelocity(w_rl)
rr_motor.setVelocity(w_rr)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Webots devices # Webots devices
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -196,13 +240,23 @@ def drive(vx: float, vy: float, left_motor, right_motor, compass, motor_max: flo
robot = Robot() robot = Robot()
timestep = int(robot.getBasicTimeStep()) timestep = int(robot.getBasicTimeStep())
left_motor = robot.getDevice("left wheel motor") if DRIVE_MODE == "mecanum":
right_motor = robot.getDevice("right wheel motor") fl_motor = robot.getDevice("front left wheel motor")
left_motor.setPosition(float("inf")) fr_motor = robot.getDevice("front right wheel motor")
right_motor.setPosition(float("inf")) rl_motor = robot.getDevice("rear left wheel motor")
left_motor.setVelocity(0.0) rr_motor = robot.getDevice("rear right wheel motor")
right_motor.setVelocity(0.0) for m in (fl_motor, fr_motor, rl_motor, rr_motor):
MOTOR_MAX = min(left_motor.getMaxVelocity(), DOG_MAX_WHEEL_OMEGA) m.setPosition(float("inf"))
m.setVelocity(0.0)
MOTOR_MAX = min(fl_motor.getMaxVelocity(), DOG_MAX_WHEEL_OMEGA)
else:
left_motor = robot.getDevice("left wheel motor")
right_motor = robot.getDevice("right wheel motor")
left_motor.setPosition(float("inf"))
right_motor.setPosition(float("inf"))
left_motor.setVelocity(0.0)
right_motor.setVelocity(0.0)
MOTOR_MAX = min(left_motor.getMaxVelocity(), DOG_MAX_WHEEL_OMEGA)
gps = robot.getDevice("gps"); gps.enable(timestep) gps = robot.getDevice("gps"); gps.enable(timestep)
compass = robot.getDevice("compass"); compass.enable(timestep) compass = robot.getDevice("compass"); compass.enable(timestep)
@@ -235,13 +289,15 @@ analytic_teacher = None
if MODE in ("strombom", "sequential"): if MODE in ("strombom", "sequential"):
base_fn = strombom_action if MODE == "strombom" else sequential_action base_fn = strombom_action if MODE == "strombom" else sequential_action
analytic_teacher = ActiveScanTeacher(base_fn) analytic_teacher = ActiveScanTeacher(base_fn)
elif MODE == "universal":
analytic_teacher = ActiveScanTeacher(universal_action)
# GT positions from sheep emitters — used **only** for the auto-finish # GT positions from sheep emitters — used **only** for the auto-finish
# sentinel and the GT_penned diagnostic line. Never fed into control. # sentinel and the GT_penned diagnostic line. Never fed into control.
_gt_sheep: dict = {} _gt_sheep: dict = {}
_run_done = False _run_done = False
prev_action = (0.0, 0.0) prev_action = (0.0, 0.0, 0.0) if DRIVE_MODE == "mecanum" else (0.0, 0.0)
step_count = 0 step_count = 0
while robot.step(timestep) != -1: while robot.step(timestep) != -1:
@@ -273,26 +329,43 @@ while robot.step(timestep) != -1:
single_obs = build_obs(dog_xy, dog_heading, sheep_xy_list, sheep_penned_list) single_obs = build_obs(dog_xy, dog_heading, sheep_xy_list, sheep_penned_list)
# ---- Action selection ---- # ---- Action selection ----
omega = 0.0
if MODE in ("bc", "rl") and policy_handle is not None: if MODE in ("bc", "rl") and policy_handle is not None:
action = policy_handle.predict(single_obs) action = policy_handle.predict(single_obs)
vx, vy = float(action[0]), float(action[1]) vx, vy = float(action[0]), float(action[1])
if DRIVE_MODE == "mecanum" and len(action) >= 3:
omega = float(action[2])
else: else:
vx, vy, _mode_str = analytic_teacher( result = analytic_teacher(
dog_xy, dog_heading, sheep_positions, PEN_ENTRY, dog_xy, dog_heading, sheep_positions, PEN_ENTRY,
DRIVE_MODE,
) )
if len(result) == 4:
vx, vy, omega, _mode_str = result
else:
vx, vy, _mode_str = result
# Near-sheep speed modulation (shared by every mode). # Near-sheep speed modulation (shared by every mode).
vx, vy = modulate_speed_near_sheep(vx, vy, dog_xy, sheep_positions) vx, vy = modulate_speed_near_sheep(vx, vy, dog_xy, sheep_positions)
# EMA smoothing — kills frame-to-frame action jitter. # EMA smoothing — kills frame-to-frame action jitter.
vx = ACTION_SMOOTH * prev_action[0] + (1.0 - ACTION_SMOOTH) * vx if DRIVE_MODE == "mecanum":
vy = ACTION_SMOOTH * prev_action[1] + (1.0 - ACTION_SMOOTH) * vy vx = ACTION_SMOOTH * prev_action[0] + (1.0 - ACTION_SMOOTH) * vx
vy = ACTION_SMOOTH * prev_action[1] + (1.0 - ACTION_SMOOTH) * vy
omega = ACTION_SMOOTH * prev_action[2] + (1.0 - ACTION_SMOOTH) * omega
else:
vx = ACTION_SMOOTH * prev_action[0] + (1.0 - ACTION_SMOOTH) * vx
vy = ACTION_SMOOTH * prev_action[1] + (1.0 - ACTION_SMOOTH) * vy
# Safety: dog must never enter the pen. # Safety: dog must never enter the pen.
vx, vy = safety_clamp(vx, vy, dog_xy[0], dog_xy[1]) vx, vy = safety_clamp(vx, vy, dog_xy[0], dog_xy[1])
prev_action = (vx, vy) prev_action = (vx, vy, omega) if DRIVE_MODE == "mecanum" else (vx, vy)
drive(vx, vy, left_motor, right_motor, compass, MOTOR_MAX) if DRIVE_MODE == "mecanum":
drive_mecanum(vx, vy, omega, fl_motor, fr_motor, rl_motor, rr_motor,
compass, MOTOR_MAX)
else:
drive_diff(vx, vy, left_motor, right_motor, compass, MOTOR_MAX)
emitter.send(f"dog:{dog_xy[0]:.4f}:{dog_xy[1]:.4f}") emitter.send(f"dog:{dog_xy[0]:.4f}:{dog_xy[1]:.4f}")
# Cosmetic ear wiggle. # Cosmetic ear wiggle.
@@ -321,7 +394,14 @@ while robot.step(timestep) != -1:
gt_penned = sum(1 for x, y in _gt_sheep.values() gt_penned = sum(1 for x, y in _gt_sheep.values()
if is_penned_position(x, y)) if is_penned_position(x, y))
gt_total = len(_gt_sheep) gt_total = len(_gt_sheep)
print(f"[dog mode={MODE}] step={step_count} " print(f"[dog mode={MODE} drive={DRIVE_MODE}] step={step_count} "
f"GT_penned={gt_penned}/{gt_total} "
f"tracks_active={tracker.n_active()} "
f"tracks_penned={tracker.n_penned()} "
f"detections={len(detections)} "
f"action=({vx:+.2f}, {vy:+.2f}, {omega:+.2f})"
if DRIVE_MODE == "mecanum" else
f"[dog mode={MODE} drive={DRIVE_MODE}] step={step_count} "
f"GT_penned={gt_penned}/{gt_total} " f"GT_penned={gt_penned}/{gt_total} "
f"tracks_active={tracker.n_active()} " f"tracks_active={tracker.n_active()} "
f"tracks_penned={tracker.n_penned()} " f"tracks_penned={tracker.n_penned()} "
+4
View File
@@ -1,5 +1,9 @@
# Group G25 - Formal & Title & Goals # Group G25 - Formal & Title & Goals
This is the original course proposal/goals document. For current setup,
training, evaluation, and Webots run instructions, see `../README.md`
and `../training/README.md`.
## Team members ## Team members
- Diogo Costa <up202502576@up.pt> - Diogo Costa <up202502576@up.pt>
- Johnny Fernandes <up202402612@up.pt> - Johnny Fernandes <up202402612@up.pt>
+27 -7
View File
@@ -33,7 +33,11 @@ class ActiveScanTeacher:
Call signature:: Call signature::
vx, vy, mode = teacher(dog_xy, dog_heading, sheep_positions, pen_target) vx, vy, omega, mode = teacher(dog_xy, dog_heading, sheep_positions,
pen_target, drive_mode="differential")
``omega`` is the yaw-rate intent (mecanum only); 0.0 for differential
drive and during blind exploration phases.
""" """
def __init__(self, base_action_fn, initial_scan_steps: int = INITIAL_SCAN_STEPS): def __init__(self, base_action_fn, initial_scan_steps: int = INITIAL_SCAN_STEPS):
@@ -62,7 +66,8 @@ class ActiveScanTeacher:
return 0.0, 0.0 return 0.0, 0.0
return EXPLORE_SPEED * dx / d, EXPLORE_SPEED * dy / d return EXPLORE_SPEED * dx / d, EXPLORE_SPEED * dy / d
def __call__(self, dog_xy, dog_heading, sheep_positions, pen_target): def __call__(self, dog_xy, dog_heading, sheep_positions, pen_target,
drive_mode="differential"):
self.step += 1 self.step += 1
n_visible = len(sheep_positions) n_visible = len(sheep_positions)
@@ -75,7 +80,7 @@ class ActiveScanTeacher:
if self.step <= self.initial_scan: if self.step <= self.initial_scan:
vx, vy = self._scan_action(dog_heading) vx, vy = self._scan_action(dog_heading)
self.last_action = (vx, vy) self.last_action = (vx, vy)
return vx, vy, "scan_initial" return vx, vy, 0.0, "scan_initial"
# Phase 2: walk-to-centre after a sustained empty tracker. # Phase 2: walk-to-centre after a sustained empty tracker.
if self.empty_streak >= EMPTY_DEBOUNCE_STEPS: if self.empty_streak >= EMPTY_DEBOUNCE_STEPS:
@@ -87,16 +92,31 @@ class ActiveScanTeacher:
vx, vy = ex, ey vx, vy = ex, ey
mode = "explore" mode = "explore"
self.last_action = (vx, vy) self.last_action = (vx, vy)
return vx, vy, mode return vx, vy, 0.0, mode
# Phase 2b: brief tracker blink — hold the previous action. # Phase 2b: brief tracker blink — hold the previous action.
if n_visible == 0: if n_visible == 0:
vx, vy = self.last_action vx, vy = self.last_action
return vx, vy, "hold" return vx, vy, 0.0, "hold"
# Phase 3: hand off to the underlying analytic teacher, then # Phase 3: hand off to the underlying analytic teacher, then
# apply the shared near-sheep speed modulation. # apply the shared near-sheep speed modulation.
vx, vy, mode = self.base(dog_xy, sheep_positions, pen_target) # Handle both old-style (dog_xy, sheep, pen) and new-style
# (dog_xy, heading, sheep, pen, drive_mode) teachers.
try:
result = self.base(dog_xy, dog_heading, sheep_positions,
pen_target, drive_mode)
except TypeError:
try:
result = self.base(dog_xy, dog_heading, sheep_positions,
pen_target)
except TypeError:
result = self.base(dog_xy, sheep_positions, pen_target)
if len(result) == 4:
vx, vy, omega, mode = result
else:
vx, vy, mode = result
omega = 0.0
vx, vy = modulate_speed_near_sheep(vx, vy, dog_xy, sheep_positions) vx, vy = modulate_speed_near_sheep(vx, vy, dog_xy, sheep_positions)
self.last_action = (vx, vy) self.last_action = (vx, vy)
return vx, vy, mode return vx, vy, omega, mode
+187
View File
@@ -0,0 +1,187 @@
"""Universal shepherd teacher — Strömbom core + mecanum omega + straggler recovery.
The core collect/drive logic is **identical** to :mod:`strombom` (same
``F_FACTOR``, ``DELTA_COLLECT``, ``DELTA_DRIVE`` thresholds and target
computation) so it inherits the proven ~100 % success rate at n ≤ 8.
Two additions make it useful as a universal teacher:
1. **Omega for mecanum.** When ``drive_mode="mecanum"``, the teacher
outputs a non-zero ``omega`` channel so the dog **faces the
direction of travel**. During collect the dog faces the target
sheep; during drive it faces the pen. This gives the BC student a
real rotation signal to learn from.
2. **Last-straggler recovery.** When exactly one sheep remains active
and it is near the gate, the dog positions itself behind that
straggler (opposite the gate) and pushes it straight through. This
handles the edge case where the last sheep circles the gate posts.
Call signature::
vx, vy, omega, mode = compute_action(
dog_xy, dog_heading, sheep_positions, pen_target,
drive_mode="differential",
)
For differential drive ``omega`` is always 0.0 and can be ignored.
"""
import math
from herding.world.geometry import (
PEN_ENTRY, GATE_X, GATE_Y, in_pen,
)
# ---------------------------------------------------------------------------
# Tuning constants — match Strömbom exactly for proven success rates.
# ---------------------------------------------------------------------------
F_FACTOR = 4.0 # collect/drive threshold scaled by √n
DELTA_COLLECT = 1.5 # standoff behind the furthest sheep
DELTA_DRIVE = 2.0 # standoff behind flock CoM
# Omega gain for mecanum (how strongly the dog turns to face target)
OMEGA_GAIN = 0.6
# Recovery: push the last straggler straight through the gate.
RECOVERY_GATE_DIST = 6.0 # only when straggler is this close to gate centre
RECOVERY_PUSH_DIST = 1.2 # stand-off behind straggler, away from gate
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _unit(x, y):
d = math.hypot(x, y)
if d < 1e-6:
return 0.0, 0.0
return x / d, y / d
def _is_active(x, y) -> bool:
return (not in_pen(x, y)) and y > GATE_Y
def _angle_diff(a, b):
"""Signed shortest angular difference a - b, in [-π, π]."""
return math.atan2(math.sin(a - b), math.cos(a - b))
def _gate_center():
"""Centre of the gate opening."""
return (0.5 * (GATE_X[0] + GATE_X[1]), GATE_Y)
# ---------------------------------------------------------------------------
# Core teacher
# ---------------------------------------------------------------------------
def compute_action(dog_xy, dog_heading, sheep_positions,
pen_target=PEN_ENTRY, drive_mode="differential"):
"""Return ``(vx, vy, omega, mode)``.
Parameters
----------
dog_xy : (float, float)
Dog position in world frame.
dog_heading : float
Dog heading in world frame (rad), 0 = +x axis.
sheep_positions : dict[str, (float, float)]
Visible sheep positions.
pen_target : (float, float)
Centre of the pen gate (defaults to geometry.PEN_ENTRY).
drive_mode : str
``"differential"`` or ``"mecanum"``.
Returns
-------
vx, vy : float
Velocity intent in [-1, 1].
omega : float
Yaw intent in [-1, 1] (0 for differential).
mode : str
Phase label: ``"idle"``, ``"collect"``, ``"drive"``, ``"recovery"``.
"""
active = [(x, y) for (x, y) in sheep_positions.values()
if _is_active(x, y)]
if not active:
return 0.0, 0.0, 0.0, "idle"
n = len(active)
com_x = sum(p[0] for p in active) / n
com_y = sum(p[1] for p in active) / n
dists = [math.hypot(p[0] - com_x, p[1] - com_y) for p in active]
radius = max(dists)
# ---- Last-straggler recovery (single sheep circling near gate) ----
gc = _gate_center()
if n == 1:
sx, sy = active[0]
d_to_gate = math.hypot(sx - gc[0], sy - gc[1])
if d_to_gate < RECOVERY_GATE_DIST:
dx_g = sx - gc[0]
dy_g = sy - gc[1]
d_g = math.hypot(dx_g, dy_g)
if d_g > 0.3:
ux, uy = dx_g / d_g, dy_g / d_g
else:
ux, uy = 0.0, 1.0
tx = sx + RECOVERY_PUSH_DIST * ux
ty = sy + RECOVERY_PUSH_DIST * uy
ax, ay = _unit(tx - dog_xy[0], ty - dog_xy[1])
mode = "recovery"
face_target = (sx, sy)
omega = 0.0
if drive_mode == "mecanum":
desired = math.atan2(
face_target[1] - dog_xy[1],
face_target[0] - dog_xy[0],
)
err = _angle_diff(desired, dog_heading)
omega = max(-1.0, min(1.0, OMEGA_GAIN * err / math.pi))
return ax, ay, omega, mode
# ---- Standard Strömbom collect/drive (proven core) ----
if radius > F_FACTOR * math.sqrt(n):
# Collect: aim behind the furthest sheep, opposite the CoM.
idx = max(range(n), key=lambda i: dists[i])
sx, sy = active[idx]
ux, uy = _unit(sx - com_x, sy - com_y)
tx, ty = sx + DELTA_COLLECT * ux, sy + DELTA_COLLECT * uy
mode = "collect"
face_target = (sx, sy)
else:
# Drive: aim behind the CoM, opposite the pen.
ux, uy = _unit(com_x - pen_target[0], com_y - pen_target[1])
tx, ty = com_x + DELTA_DRIVE * ux, com_y + DELTA_DRIVE * uy
mode = "drive"
face_target = pen_target
ax, ay = _unit(tx - dog_xy[0], ty - dog_xy[1])
# ---- Omega (mecanum only) ----
omega = 0.0
if drive_mode == "mecanum" and mode != "idle":
desired_heading = math.atan2(
face_target[1] - dog_xy[1],
face_target[0] - dog_xy[0],
)
err = _angle_diff(desired_heading, dog_heading)
omega = max(-1.0, min(1.0, OMEGA_GAIN * err / math.pi))
return ax, ay, omega, mode
def compute_action_diff(dog_xy, dog_heading, sheep_positions,
pen_target=PEN_ENTRY):
"""Compatibility wrapper returning ``(vx, vy, mode)`` — same as Strömbom.
Use this when plugging into existing differential-drive code that
doesn't expect omega.
"""
vx, vy, _omega, mode = compute_action(
dog_xy, dog_heading, sheep_positions, pen_target,
drive_mode="differential",
)
return vx, vy, mode
+132 -50
View File
@@ -24,9 +24,14 @@ import math
import numpy as np import numpy as np
from herding.world.geometry import FIELD_X, FIELD_Y, GATE_Y, PEN_X, PEN_Y from herding.world.geometry import (
FIELD_SHAPE, FIELD_ROUND_R,
FIELD_X, FIELD_Y, GATE_X, GATE_Y,
PEN_X, PEN_Y,
)
from herding.perception.lidar_sim import ( from herding.perception.lidar_sim import (
LIDAR_FOV, LIDAR_MAX_RANGE, LIDAR_N_RAYS, SHEEP_RADIUS, ray_angles, LIDAR_FOV, LIDAR_MAX_RANGE, LIDAR_N_RAYS, SHEEP_RADIUS, POST_RADIUS,
ray_angles,
) )
@@ -35,16 +40,94 @@ MAX_CLUSTER_SPAN = 1.5 # m — wider clusters are walls / structures
RANGE_HIT_EPS = 0.05 # m — hit if range < max_range - eps RANGE_HIT_EPS = 0.05 # m — hit if range < max_range - eps
WALL_REJECT = 0.5 # m — drop detections this close to a known wall line WALL_REJECT = 0.5 # m — drop detections this close to a known wall line
# Sheep-sized static features (gate posts, corner pillars). A cluster # Multi-peak splitting: within a single cluster, if the range profile
# centred within STATIC_REJECT of any of these is never a sheep. # has a local dip (i.e. the range increases then decreases) deeper than
_STATIC_FEATURES = ( # SPLIT_RANGE_GAP, the cluster is split into two detections.
SPLIT_RANGE_GAP = 0.20 # m — range increase that triggers a split
# Sheep-sized static features. A cluster centred within STATIC_REJECT of
# any of these is never a sheep.
_STATIC_FEATURES_RECT = (
( 10.0, -15.0), ( 13.0, -15.0), # gate posts ( 10.0, -15.0), ( 13.0, -15.0), # gate posts
( 15.0, 15.0), ( 15.0, -15.0), ( 15.0, 15.0), ( 15.0, -15.0),
(-15.0, 15.0), (-15.0, -15.0), # field corners (-15.0, 15.0), (-15.0, -15.0), # field corners
) )
_STATIC_FEATURES_ROUND = (
(GATE_X[0], GATE_Y),
(GATE_X[1], GATE_Y),
)
STATIC_REJECT = 0.8 STATIC_REJECT = 0.8
def _get_static_features():
if FIELD_SHAPE == "field_round":
return _STATIC_FEATURES_ROUND
return _STATIC_FEATURES_RECT
_STATIC_FEATURES = _get_static_features()
def _in_field_region(cx: float, cy: float) -> bool:
"""Check if a detection is inside the field (with small margin)."""
if FIELD_SHAPE == "field_round":
r = math.hypot(cx, cy)
return r < FIELD_ROUND_R + 0.2
return (FIELD_X[0] - 0.2 < cx < FIELD_X[1] + 0.2 and
FIELD_Y[0] - 0.2 < cy < FIELD_Y[1] + 0.2)
def _near_wall(cx: float, cy: float) -> bool:
"""True if the detection is too close to a wall to be a sheep."""
if FIELD_SHAPE == "field_round":
r = math.hypot(cx, cy)
return r > FIELD_ROUND_R - WALL_REJECT
return (
cx > FIELD_X[1] - WALL_REJECT or cx < FIELD_X[0] + WALL_REJECT or
cy > FIELD_Y[1] - WALL_REJECT or
(cy < FIELD_Y[0] + WALL_REJECT and not (PEN_X[0] <= cx <= PEN_X[1]))
)
def _split_cluster_by_range(
points: list[tuple[float, float]],
range_vals: list[float],
) -> list[list[tuple[float, float]]]:
"""Split a cluster at range-profile local maxima (gaps between sheep).
When two sheep are close, the LiDAR sees them as one arc, but the
range profile has a local peak between them (the ray passes between
the two discs). This function finds those peaks and splits.
"""
if len(points) < 4:
return [points]
# Find the minimum range in the cluster (closest point to dog).
r_min = min(range_vals)
# Find the maximum range (the dip/gap between sheep).
r_max = max(range_vals)
# If the range variation is small, it's a single target.
if r_max - r_min < SPLIT_RANGE_GAP:
return [points]
# Find the split point: the index with the maximum range.
split_idx = range_vals.index(r_max)
if split_idx <= 1 or split_idx >= len(points) - 2:
return [points]
# Split into two sub-clusters.
left = points[:split_idx]
right = points[split_idx + 1:]
# Recursively split each half.
result = []
for sub_pts, sub_ranges in [
(left, range_vals[:split_idx]),
(right, range_vals[split_idx + 1:]),
]:
if len(sub_pts) >= 1:
result.extend(_split_cluster_by_range(sub_pts, sub_ranges))
return result if result else [points]
def detections_from_scan( def detections_from_scan(
ranges: np.ndarray, ranges: np.ndarray,
dog_x: float, dog_y: float, dog_heading: float, dog_x: float, dog_y: float, dog_heading: float,
@@ -64,63 +147,62 @@ def detections_from_scan(
# Walk rays in angular order; a large jump between consecutive # Walk rays in angular order; a large jump between consecutive
# world-frame hit points closes the current cluster. # world-frame hit points closes the current cluster.
clusters: list[list[tuple[float, float]]] = [] # Store (x, y, range) per hit ray for multi-peak splitting.
current: list[tuple[float, float]] = [] clusters: list[list[tuple[float, float, float]]] = []
prev: tuple[float, float] | None = None current: list[tuple[float, float, float]] = []
prev_xy: tuple[float, float] | None = None
for i in range(n_rays): for i in range(n_rays):
if not bool(hit[i]): if not bool(hit[i]):
if current: if current:
clusters.append(current) clusters.append(current)
current = [] current = []
prev = None prev_xy = None
continue continue
pt = (float(px[i]), float(py[i])) pt = (float(px[i]), float(py[i]), float(ranges[i]))
if prev is not None and math.hypot(pt[0] - prev[0], pt[1] - prev[1]) > GAP_THRESHOLD: if prev_xy is not None and math.hypot(pt[0] - prev_xy[0], pt[1] - prev_xy[1]) > GAP_THRESHOLD:
clusters.append(current) clusters.append(current)
current = [] current = []
current.append(pt) current.append(pt)
prev = pt prev_xy = (pt[0], pt[1])
if current: if current:
clusters.append(current) clusters.append(current)
detections: list[tuple[float, float]] = [] detections: list[tuple[float, float]] = []
for cluster in clusters: for cluster in clusters:
xs = [p[0] for p in cluster] points_xy = [(p[0], p[1]) for p in cluster]
ys = [p[1] for p in cluster] range_vals = [p[2] for p in cluster]
cx, cy = sum(xs) / len(xs), sum(ys) / len(ys)
span = math.hypot(max(xs) - min(xs), max(ys) - min(ys)) # Multi-peak splitting.
if span > MAX_CLUSTER_SPAN: if len(cluster) >= 4:
continue sub_clusters = _split_cluster_by_range(points_xy, range_vals)
# Rays hit the front edge of the sheep; offset outward by else:
# SHEEP_RADIUS along the dog→cluster direction to estimate the sub_clusters = [points_xy]
# centre.
dx, dy = cx - dog_x, cy - dog_y for sub in sub_clusters:
d = math.hypot(dx, dy) if len(sub) < 1:
if d > 1e-3: continue
cx += SHEEP_RADIUS * dx / d xs = [p[0] for p in sub]
cy += SHEEP_RADIUS * dy / d ys = [p[1] for p in sub]
# Region filter: in-field clusters, plus a narrow strip south of cx, cy = sum(xs) / len(xs), sum(ys) / len(ys)
# the gate so sheep mid-crossing get latched penned. Detections span = math.hypot(max(xs) - min(xs), max(ys) - min(ys))
# deeper into the pen are dropped — pen posts and rails would if span > MAX_CLUSTER_SPAN:
# otherwise generate phantom penned tracks. continue
in_main = (FIELD_X[0] - 0.2 < cx < FIELD_X[1] + 0.2 and # Rays hit the front edge of the sheep; offset outward by
FIELD_Y[0] - 0.2 < cy < FIELD_Y[1] + 0.2) # SHEEP_RADIUS along the dog→cluster direction.
in_gate_strip = (PEN_X[0] - 0.2 < cx < PEN_X[1] + 0.2 and dx, dy = cx - dog_x, cy - dog_y
GATE_Y - 1.0 < cy < GATE_Y + 0.2) d = math.hypot(dx, dy)
if not (in_main or in_gate_strip): if d > 1e-3:
continue cx += SHEEP_RADIUS * dx / d
# Known sheep-sized static features. cy += SHEEP_RADIUS * dy / d
if any(math.hypot(cx - fx, cy - fy) < STATIC_REJECT in_main = _in_field_region(cx, cy)
for fx, fy in _STATIC_FEATURES): in_gate_strip = (PEN_X[0] - 0.2 < cx < PEN_X[1] + 0.2 and
continue GATE_Y - 1.0 < cy < GATE_Y + 0.2)
# Wall-proximity filter — sheep can't get this close to a wall, if not (in_main or in_gate_strip):
# so detections right at the wall line are structure noise. continue
near_field_wall = ( if any(math.hypot(cx - fx, cy - fy) < STATIC_REJECT
cx > FIELD_X[1] - WALL_REJECT or cx < FIELD_X[0] + WALL_REJECT or for fx, fy in _STATIC_FEATURES):
cy > FIELD_Y[1] - WALL_REJECT or continue
(cy < FIELD_Y[0] + WALL_REJECT and not (PEN_X[0] <= cx <= PEN_X[1])) if _near_wall(cx, cy):
) continue
if near_field_wall: detections.append((cx, cy))
continue
detections.append((cx, cy))
return detections return detections
+101 -30
View File
@@ -1,7 +1,8 @@
"""Fast 2D LiDAR simulator for the Gymnasium env. """Fast 2D LiDAR simulator for the Gymnasium env.
Raycasts against sheep (discs) and static world geometry (axis-aligned Raycasts against sheep (discs) and static world geometry. For rectangular
walls + gate posts) so the env reproduces the false-positive cluster fields this is axis-aligned walls + gate posts; for round fields it is a
circular wall + gate posts. The env reproduces the false-positive cluster
distribution Webots produces from real 3D geometry. distribution Webots produces from real 3D geometry.
Returns a range array matching the Webots Lidar device: Returns a range array matching the Webots Lidar device:
@@ -15,49 +16,96 @@ import math
import numpy as np import numpy as np
from herding.world.geometry import (
FIELD_SHAPE, FIELD_ROUND_R,
FIELD_X, FIELD_Y,
GATE_X, GATE_Y,
PEN_X, PEN_Y,
)
# Match protos/ShepherdDog.proto Lidar device.
LIDAR_N_RAYS = 180 # Match protos/ShepherdDog.proto Lidar device — extended to 360° for
LIDAR_FOV = 2.44 # rad ≈ 140° # full situational awareness. The original Webots device is 140° FOV /
# 180 rays; we use 360 rays for full-circle coverage.
LIDAR_N_RAYS = 360
LIDAR_FOV = 2.0 * math.pi # 360° full circle
LIDAR_MAX_RANGE = 12.0 LIDAR_MAX_RANGE = 12.0
LIDAR_NOISE = 0.005 # m, gaussian std LIDAR_NOISE = 0.005 # m, gaussian std
# Sheep cross-section in the LiDAR plane (horizontal cylinder approx). # Sheep cross-section in the LiDAR plane (horizontal cylinder approx).
SHEEP_RADIUS = 0.30 SHEEP_RADIUS = 0.30
POST_RADIUS = 0.25
# --- Static world geometry — mirrors worlds/field.wbt --- # ---------------------------------------------------------------------------
# Rectangular-field static geometry
# Vertical walls: (x, y_min, y_max). # ---------------------------------------------------------------------------
_VERTICAL_WALLS = ( _VERTICAL_WALLS_RECT = (
( 15.0, -15.0, 15.0), # field east ( 15.0, -15.0, 15.0), # field east
(-15.0, -15.0, 15.0), # field west (-15.0, -15.0, 15.0), # field west
( 10.0, -22.0, -15.0), # pen west ( 10.0, -22.0, -15.0), # pen west
( 13.0, -22.0, -15.0), # pen east ( 13.0, -22.0, -15.0), # pen east
) )
# Horizontal walls: (y, x_min, x_max). South wall has a 3 m gap at the gate. _HORIZONTAL_WALLS_RECT = (
_HORIZONTAL_WALLS = (
( 15.0, -15.0, 15.0), # field north ( 15.0, -15.0, 15.0), # field north
(-15.0, -15.0, 10.0), # field south-west of gate (-15.0, -15.0, 10.0), # field south-west of gate
(-15.0, 13.0, 15.0), # field south-east of gate (-15.0, 13.0, 15.0), # field south-east of gate
(-22.0, 10.0, 13.0), # pen south (-22.0, 10.0, 13.0), # pen south
) )
# Gate posts + field corner pillars, treated as discs at LiDAR height. _POSTS_RECT = np.array([
_POSTS_XY = np.array([
( 10.0, -15.0), ( 13.0, -15.0), ( 10.0, -15.0), ( 13.0, -15.0),
( 15.0, 15.0), ( 15.0, -15.0), ( 15.0, 15.0), ( 15.0, -15.0),
(-15.0, 15.0), (-15.0, -15.0), (-15.0, 15.0), (-15.0, -15.0),
], dtype=np.float64) ], dtype=np.float64)
POST_RADIUS = 0.25
# ---------------------------------------------------------------------------
# Round-field static geometry
# ---------------------------------------------------------------------------
# Circular wall with gate gap. Gate posts at the edges of the gate gap.
_gate_cx = 0.5 * (GATE_X[0] + GATE_X[1])
_POSTS_ROUND = np.array([
(GATE_X[0], GATE_Y),
(GATE_X[1], GATE_Y),
], dtype=np.float64)
# Pen walls for round field
_VERTICAL_WALLS_ROUND = (
(GATE_X[0], PEN_Y[0], GATE_Y), # pen west
(GATE_X[1], PEN_Y[0], GATE_Y), # pen east
)
_HORIZONTAL_WALLS_ROUND = (
(PEN_Y[0], GATE_X[0], GATE_X[1]), # pen south
)
def _build_static_geometry():
"""Select the correct static geometry for the active field shape."""
if FIELD_SHAPE == "field_round":
return (
_VERTICAL_WALLS_ROUND,
_HORIZONTAL_WALLS_ROUND,
_POSTS_ROUND,
FIELD_ROUND_R,
)
return (
_VERTICAL_WALLS_RECT,
_HORIZONTAL_WALLS_RECT,
_POSTS_RECT,
None, # no circular wall
)
_VERTS, _HORIZS, _POSTS, _CIRC_R = _build_static_geometry()
# ---------------------------------------------------------------------------
# Ray helpers
# ---------------------------------------------------------------------------
def ray_angles(n: int = LIDAR_N_RAYS, fov: float = LIDAR_FOV) -> np.ndarray: def ray_angles(n: int = LIDAR_N_RAYS, fov: float = LIDAR_FOV) -> np.ndarray:
"""Local-frame ray angles, CCW from forward, sweeping +fov/2 → -fov/2. """Local-frame ray angles, CCW from forward, sweeping +fov/2 → -fov/2."""
Matches Webots' default Lidar sweep direction.
"""
return np.linspace(fov / 2.0, -fov / 2.0, n, dtype=np.float64) return np.linspace(fov / 2.0, -fov / 2.0, n, dtype=np.float64)
@@ -78,7 +126,7 @@ def _raycast_static(
safe_sin = np.where(np.abs(sin_w) < 1e-9, 1e-9, sin_w) safe_sin = np.where(np.abs(sin_w) < 1e-9, 1e-9, sin_w)
# Vertical walls (x = const) # Vertical walls (x = const)
for wx, ymin, ymax in _VERTICAL_WALLS: for wx, ymin, ymax in _VERTS:
t = (wx - ox) / safe_cos t = (wx - ox) / safe_cos
y_at = oy + t * sin_w y_at = oy + t * sin_w
valid = (t > EPS) & (y_at >= ymin - EPS) & (y_at <= ymax + EPS) valid = (t > EPS) & (y_at >= ymin - EPS) & (y_at <= ymax + EPS)
@@ -86,19 +134,47 @@ def _raycast_static(
np.minimum(best, cand, out=best) np.minimum(best, cand, out=best)
# Horizontal walls (y = const) # Horizontal walls (y = const)
for wy, xmin, xmax in _HORIZONTAL_WALLS: for wy, xmin, xmax in _HORIZS:
t = (wy - oy) / safe_sin t = (wy - oy) / safe_sin
x_at = ox + t * cos_w x_at = ox + t * cos_w
valid = (t > EPS) & (x_at >= xmin - EPS) & (x_at <= xmax + EPS) valid = (t > EPS) & (x_at >= xmin - EPS) & (x_at <= xmax + EPS)
cand = np.where(valid, t, np.inf) cand = np.where(valid, t, np.inf)
np.minimum(best, cand, out=best) np.minimum(best, cand, out=best)
# Circular wall (round field only)
if _CIRC_R is not None:
# Ray: P(t) = O + t·D. ||P(t)||² = R²
# t² - 2t(O·D) + (||O||² - R²) = 0
# a = 1 (rays are unit), b = -2(O·D), c = ||O||² - R²
a = 1.0 # cos_w² + sin_w² = 1
b = -(ox * cos_w + oy * sin_w)
c = ox * ox + oy * oy - _CIRC_R * _CIRC_R
disc = b * b - a * c
valid_disc = disc >= 0.0
sqrt_disc = np.sqrt(np.maximum(disc, 0.0))
# Two intersection candidates: t = (-b ± sqrt(disc)) / a
t1 = -b - sqrt_disc
t2 = -b + sqrt_disc
# We want the smallest positive t.
t1_valid = valid_disc & (t1 > EPS)
t2_valid = valid_disc & (t2 > EPS)
t_circ = np.where(t1_valid, t1, np.where(t2_valid, t2, np.inf))
# Exclude rays that hit the gate gap: the hit point must not lie
# in the gate column (between GATE_X and above GATE_Y).
hx = ox + t_circ * cos_w
hy = oy + t_circ * sin_w
in_gate = ((hx > GATE_X[0]) & (hx < GATE_X[1]) &
(hy > GATE_Y - 2.0) & (hy < GATE_Y + 2.0))
t_circ = np.where(in_gate, np.inf, t_circ)
np.minimum(best, t_circ, out=best)
# Posts (treat as discs) # Posts (treat as discs)
if _POSTS_XY.size: if _POSTS.size:
px = _POSTS_XY[:, 0] - ox px = _POSTS[:, 0] - ox
py = _POSTS_XY[:, 1] - oy py = _POSTS[:, 1] - oy
t_post = np.outer(px, cos_w) + np.outer(py, sin_w) # (P, N) t_post = np.outer(px, cos_w) + np.outer(py, sin_w)
d2 = (px ** 2 + py ** 2)[:, None] # (P, 1) d2 = (px ** 2 + py ** 2)[:, None]
perp2 = d2 - t_post ** 2 perp2 = d2 - t_post ** 2
R2 = POST_RADIUS ** 2 R2 = POST_RADIUS ** 2
hit = (perp2 < R2) & (t_post > 0.0) hit = (perp2 < R2) & (t_post > 0.0)
@@ -121,16 +197,12 @@ def simulate_scan(
``sheep_xy`` is every sheep (penned or active) in the scene. ``sheep_xy`` is every sheep (penned or active) in the scene.
""" """
n_rays = _ANGLES.shape[0]
ch, sh = math.cos(dog_heading), math.sin(dog_heading) ch, sh = math.cos(dog_heading), math.sin(dog_heading)
cos_w = ch * _COS - sh * _SIN cos_w = ch * _COS - sh * _SIN
sin_w = sh * _COS + ch * _SIN sin_w = sh * _COS + ch * _SIN
# Walls + posts
best = _raycast_static(dog_x, dog_y, cos_w, sin_w) best = _raycast_static(dog_x, dog_y, cos_w, sin_w)
# Sheep discs
if sheep_xy: if sheep_xy:
sx = np.asarray([p[0] for p in sheep_xy], dtype=np.float64) - dog_x sx = np.asarray([p[0] for p in sheep_xy], dtype=np.float64) - dog_x
sy = np.asarray([p[1] for p in sheep_xy], dtype=np.float64) - dog_y sy = np.asarray([p[1] for p in sheep_xy], dtype=np.float64) - dog_y
@@ -144,7 +216,6 @@ def simulate_scan(
nearest = candidate.min(axis=0) nearest = candidate.min(axis=0)
np.minimum(best, nearest, out=best) np.minimum(best, nearest, out=best)
# Entries with no hit stay at inf → clipped to max_range, matching Webots.
ranges = np.minimum(best, max_range).astype(np.float32) ranges = np.minimum(best, max_range).astype(np.float32)
return _add_noise(ranges, noise, rng, max_range) return _add_noise(ranges, noise, rng, max_range)
+6 -12
View File
@@ -26,14 +26,15 @@ import math
import numpy as np import numpy as np
from herding.world.geometry import ( from herding.world.geometry import (
FIELD_X, FIELD_Y, PEN_ENTRY, MAX_SHEEP, PEN_ENTRY, MAX_SHEEP, distance_to_wall,
) )
OBS_DIM = 32 OBS_DIM = 32
def build_obs(dog_xy, dog_heading, sheep_xy_list, sheep_penned_list, def build_obs(dog_xy, dog_heading, sheep_xy_list, sheep_penned_list,
n_max: int = MAX_SHEEP) -> np.ndarray: n_max: int = MAX_SHEEP,
n_expected: int | None = None) -> np.ndarray:
"""Assemble the dog policy's observation vector. """Assemble the dog policy's observation vector.
Parameters Parameters
@@ -43,6 +44,7 @@ def build_obs(dog_xy, dog_heading, sheep_xy_list, sheep_penned_list,
sheep_xy_list : iterable of (x, y) for ALL known sheep sheep_xy_list : iterable of (x, y) for ALL known sheep
sheep_penned_list : parallel iterable of bool — True if sheep is penned sheep_penned_list : parallel iterable of bool — True if sheep is penned
n_max : maximum supported flock size used for the count normaliser n_max : maximum supported flock size used for the count normaliser
n_expected : unused, kept for API compatibility.
""" """
dog_x, dog_y = dog_xy dog_x, dog_y = dog_xy
obs = np.zeros(OBS_DIM, dtype=np.float32) obs = np.zeros(OBS_DIM, dtype=np.float32)
@@ -89,16 +91,8 @@ def build_obs(dog_xy, dog_heading, sheep_xy_list, sheep_penned_list,
obs[15] = float(rel[far_idx, 0]) / 15.0 obs[15] = float(rel[far_idx, 0]) / 15.0
obs[16] = float(rel[far_idx, 1]) / 15.0 obs[16] = float(rel[far_idx, 1]) / 15.0
min_sheep_wall = min( min_sheep_wall = float(min(distance_to_wall(sx, sy) for sx, sy in active))
float(np.min(arr[:, 0] - FIELD_X[0])), min_dog_wall = distance_to_wall(dog_x, dog_y)
float(np.min(FIELD_X[1] - arr[:, 0])),
float(np.min(arr[:, 1] - FIELD_Y[0])),
float(np.min(FIELD_Y[1] - arr[:, 1])),
)
min_dog_wall = min(
dog_x - FIELD_X[0], FIELD_X[1] - dog_x,
dog_y - FIELD_Y[0], FIELD_Y[1] - dog_y,
)
obs[17] = min_sheep_wall / 15.0 obs[17] = min_sheep_wall / 15.0
obs[18] = float(min_dog_wall) / 15.0 obs[18] = float(min_dog_wall) / 15.0
obs[19] = n / n_max obs[19] = n / n_max
+118 -42
View File
@@ -6,6 +6,14 @@ reappear off-position), plus memory of last-seen positions for sheep
out of FOV. Output is ``{name: (x, y)}`` — Strömbom / Sequential out of FOV. Output is ``{name: (x, y)}`` — Strömbom / Sequential
consume it directly. consume it directly.
When **predictive mode** is enabled (the default), tracks carry a
constant-velocity state ``(vx, vy)`` estimated from the last two
observations. While a track is occluded its position is extrapolated
using this velocity for up to ``PREDICT_STEPS`` frames, keeping the
teacher's CoM estimate stable during brief losses. After prediction
expires, the track falls back to its last-seen position (static memory)
until ``FORGET_STEPS`` deletes it entirely.
A track is marked penned once its estimated position crosses the gate A track is marked penned once its estimated position crosses the gate
plane south (``is_penned_position``). Penned tracks are excluded from plane south (``is_penned_position``). Penned tracks are excluded from
``get_positions`` and kept indefinitely. ``get_positions`` and kept indefinitely.
@@ -25,16 +33,71 @@ PENNED_GATE_M = 4.0 # m — gate for matching detections to existing penne
FORGET_STEPS = 200 # ~3.2 s — delete stale active tracks (penned ones kept forever) FORGET_STEPS = 200 # ~3.2 s — delete stale active tracks (penned ones kept forever)
MAX_ACTIVE_TRACKS = MAX_SHEEP MAX_ACTIVE_TRACKS = MAX_SHEEP
# Predictive tracking constants.
PREDICT_STEPS = 120 # ~1.9 s — extrapolate velocity this many frames
VELOCITY_CLAMP = 1.0 # m/s — max predicted speed (sheep max is ~0.78 m/s)
class Track:
"""Single track with position, velocity, and age."""
__slots__ = ("x", "y", "vx", "vy", "last_seen", "penned")
def __init__(self, x: float, y: float, step: int, penned: bool = False):
self.x = x
self.y = y
self.vx = 0.0
self.vy = 0.0
self.last_seen = step
self.penned = penned
@property
def age(self) -> int:
"""Not-a-property in the hot loop — callers pass current step."""
raise NotImplementedError
def predicted_position(self, current_step: int) -> tuple[float, float]:
"""Extrapolated position using constant velocity, clamped."""
dt = current_step - self.last_seen
if dt <= 0 or dt > PREDICT_STEPS:
return self.x, self.y
speed = math.hypot(self.vx, self.vy)
if speed < 1e-4:
return self.x, self.y
# Clamp extrapolation distance.
max_d = VELOCITY_CLAMP * dt * 0.016 # steps → seconds
d = min(speed * dt * 0.016, max_d)
return (
self.x + d * (self.vx / speed),
self.y + d * (self.vy / speed),
)
def update(self, x: float, y: float, step: int) -> None:
"""Absorb a new detection and re-estimate velocity."""
dt = step - self.last_seen
if dt > 0:
dt_s = dt * 0.016 # steps → seconds
new_vx = (x - self.x) / dt_s
new_vy = (y - self.y) / dt_s
# Exponential smoothing on velocity.
alpha = 0.6
self.vx = alpha * new_vx + (1.0 - alpha) * self.vx
self.vy = alpha * new_vy + (1.0 - alpha) * self.vy
self.x = x
self.y = y
self.last_seen = step
class SheepTracker: class SheepTracker:
"""Online tracker with NN association and forgetful memory. """Online tracker with NN association, prediction, and forgetful memory.
Each track stores ``(x, y, last_seen_step, penned)``. Each track is a :class:`Track` with position, velocity estimate,
last-seen step, and penned flag.
""" """
def __init__(self, gate: float = GATE_M): def __init__(self, gate: float = GATE_M):
self.gate = gate self.gate = gate
self._tracks: dict[int, tuple[float, float, int, bool]] = {} self._tracks: dict[int, Track] = {}
self._next_id = 0 self._next_id = 0
self.step = 0 self.step = 0
@@ -50,13 +113,14 @@ class SheepTracker:
det_used: set[int] = set() det_used: set[int] = set()
updated_tids: set[int] = set() updated_tids: set[int] = set()
# Pass 1 — match active tracks within the primary gate. Oldest- # Pass 1 — match active tracks within the primary gate.
# seen tracks bind first so a re-emerging long-lost sheep keeps # Use predicted positions for matching, oldest-first.
# its old ID instead of being grabbed by a fresh neighbour. active_tids = [tid for tid, t in self._tracks.items() if not t.penned]
active_tids = [tid for tid, t in self._tracks.items() if not t[3]] active_tids.sort(key=lambda tid: self._tracks[tid].last_seen)
active_tids.sort(key=lambda tid: self._tracks[tid][2])
for tid in active_tids: for tid in active_tids:
tx, ty, _, _ = self._tracks[tid] track = self._tracks[tid]
# Use predicted position for matching.
tx, ty = track.predicted_position(self.step)
best_j, best_d = -1, self.gate best_j, best_d = -1, self.gate
for j, (dx, dy) in enumerate(detections): for j, (dx, dy) in enumerate(detections):
if j in det_used: if j in det_used:
@@ -67,20 +131,18 @@ class SheepTracker:
best_j = j best_j = j
if best_j >= 0: if best_j >= 0:
dx, dy = detections[best_j] dx, dy = detections[best_j]
self._tracks[tid] = (dx, dy, self.step, False) track.update(dx, dy, self.step)
det_used.add(best_j) det_used.add(best_j)
updated_tids.add(tid) updated_tids.add(tid)
# Pass 1b — re-acquisition. Sheep flee at ~0.6 m/s, so over a # Pass 1b — re-acquisition with wider gate for stale tracks.
# 12 s occlusion the same sheep may reappear outside the primary
# gate. Allow rebinding within a wider gate for stale-enough
# tracks; otherwise phantom tracks accumulate and corrupt CoM.
for tid in active_tids: for tid in active_tids:
if tid in updated_tids: if tid in updated_tids:
continue continue
tx, ty, last, _ = self._tracks[tid] track = self._tracks[tid]
if (self.step - last) < REACQUIRE_MIN_AGE: if (self.step - track.last_seen) < REACQUIRE_MIN_AGE:
continue continue
tx, ty = track.predicted_position(self.step)
best_j, best_d = -1, REACQUIRE_GATE_M best_j, best_d = -1, REACQUIRE_GATE_M
for j, (dx, dy) in enumerate(detections): for j, (dx, dy) in enumerate(detections):
if j in det_used: if j in det_used:
@@ -91,53 +153,52 @@ class SheepTracker:
best_j = j best_j = j
if best_j >= 0: if best_j >= 0:
dx, dy = detections[best_j] dx, dy = detections[best_j]
self._tracks[tid] = (dx, dy, self.step, False) track.update(dx, dy, self.step)
det_used.add(best_j) det_used.add(best_j)
updated_tids.add(tid) updated_tids.add(tid)
# Pass 2 — match remaining detections to penned tracks. # Pass 2 — match remaining detections to penned tracks.
penned_tids = [tid for tid, t in self._tracks.items() if t[3]] penned_tids = [tid for tid, t in self._tracks.items() if t.penned]
for tid in penned_tids: for tid in penned_tids:
tx, ty, _, _ = self._tracks[tid] track = self._tracks[tid]
best_j, best_d = -1, PENNED_GATE_M best_j, best_d = -1, PENNED_GATE_M
for j, (dx, dy) in enumerate(detections): for j, (dx, dy) in enumerate(detections):
if j in det_used: if j in det_used:
continue continue
d = math.hypot(dx - tx, dy - ty) d = math.hypot(dx - track.x, dy - track.y)
if d < best_d: if d < best_d:
best_d = d best_d = d
best_j = j best_j = j
if best_j >= 0: if best_j >= 0:
dx, dy = detections[best_j] dx, dy = detections[best_j]
self._tracks[tid] = (dx, dy, self.step, True) track.update(dx, dy, self.step)
det_used.add(best_j) det_used.add(best_j)
# Spawn new tracks for unmatched detections. Born "penned" if # Spawn new tracks for unmatched detections.
# the detection already sits inside the pen geometry.
for j, (dx, dy) in enumerate(detections): for j, (dx, dy) in enumerate(detections):
if j in det_used: if j in det_used:
continue continue
penned = in_pen(dx, dy) or is_penned_position(dx, dy) penned = in_pen(dx, dy) or is_penned_position(dx, dy)
self._tracks[self._next_id] = (dx, dy, self.step, penned) self._tracks[self._next_id] = Track(dx, dy, self.step, penned)
self._next_id += 1 self._next_id += 1
# Promote active tracks whose current estimate crosses the gate. # Promote active tracks whose current estimate crosses the gate.
for tid, (tx, ty, last, penned) in list(self._tracks.items()): for track in self._tracks.values():
if penned: if track.penned:
continue continue
if is_penned_position(tx, ty): px, py = track.predicted_position(self.step)
self._tracks[tid] = (tx, ty, last, True) if is_penned_position(px, py):
track.penned = True
# Forget stale active tracks; penned tracks live forever. # Forget stale active tracks; penned tracks live forever.
for tid, (tx, ty, last, penned) in list(self._tracks.items()): stale = [tid for tid, t in self._tracks.items()
if penned: if not t.penned and (self.step - t.last_seen) > FORGET_STEPS]
continue for tid in stale:
if (self.step - last) > FORGET_STEPS: del self._tracks[tid]
del self._tracks[tid]
# Hard cap on the active set — drop the oldest-seen overflow. # Hard cap on the active set — drop the oldest-seen overflow.
active = [(tid, last) for tid, (_, _, last, p) in self._tracks.items() active = [(tid, t.last_seen) for tid, t in self._tracks.items()
if not p] if not t.penned]
if len(active) > MAX_ACTIVE_TRACKS: if len(active) > MAX_ACTIVE_TRACKS:
active.sort(key=lambda kv: kv[1]) active.sort(key=lambda kv: kv[1])
for tid, _ in active[: len(active) - MAX_ACTIVE_TRACKS]: for tid, _ in active[: len(active) - MAX_ACTIVE_TRACKS]:
@@ -146,16 +207,31 @@ class SheepTracker:
return self.get_positions() return self.get_positions()
def get_positions(self) -> dict[str, tuple[float, float]]: def get_positions(self) -> dict[str, tuple[float, float]]:
"""Active (not-penned) tracks as a ``{name: (x, y)}`` dict.""" """Active (not-penned) tracks as a ``{name: (x, y)}`` dict.
return {f"t{tid}": (x, y)
for tid, (x, y, _, penned) in self._tracks.items() For tracks currently being predicted (occluded but within
if not penned} PREDICT_STEPS), returns the extrapolated position so the teacher
sees a smooth estimate.
"""
result = {}
for tid, track in self._tracks.items():
if track.penned:
continue
px, py = track.predicted_position(self.step)
result[f"t{tid}"] = (px, py)
return result
def get_penned_set(self) -> set[str]: def get_penned_set(self) -> set[str]:
return {f"t{tid}" for tid, (_, _, _, penned) in self._tracks.items() if penned} return {f"t{tid}" for tid, t in self._tracks.items() if t.penned}
def n_active(self) -> int: def n_active(self) -> int:
return sum(1 for _, _, _, penned in self._tracks.values() if not penned) return sum(1 for t in self._tracks.values() if not t.penned)
def n_penned(self) -> int: def n_penned(self) -> int:
return sum(1 for _, _, _, penned in self._tracks.values() if penned) return sum(1 for t in self._tracks.values() if t.penned)
def n_predicted(self) -> int:
"""Number of active tracks currently being extrapolated (not directly observed)."""
return sum(1 for t in self._tracks.values()
if not t.penned and (self.step - t.last_seen) > 0
and (self.step - t.last_seen) <= PREDICT_STEPS)
+130 -1
View File
@@ -1,4 +1,5 @@
"""Differential-drive kinematics, shared by the env and Webots controllers. """Differential-drive and mecanum kinematics, shared by the env and Webots
controllers.
First-order rigid-body model — no slip, wheel-accel limits, or contact First-order rigid-body model — no slip, wheel-accel limits, or contact
forces. Webots' ODE physics handles those at inference; the env stays forces. Webots' ODE physics handles those at inference; the env stays
@@ -59,3 +60,131 @@ def heading_speed_to_wheels(heading, speed_motor, h, max_wheel_omega,
left = max(-max_wheel_omega, min(max_wheel_omega, fwd - turn)) left = max(-max_wheel_omega, min(max_wheel_omega, fwd - turn))
right = max(-max_wheel_omega, min(max_wheel_omega, fwd + turn)) right = max(-max_wheel_omega, min(max_wheel_omega, fwd + turn))
return left, right return left, right
# ---------------------------------------------------------------------------
# Mecanum (4-wheel omnidirectional) kinematics
# ---------------------------------------------------------------------------
def mecanum_kinematics_step(x, y, h, w_fl, w_fr, w_rl, w_rr,
wheel_radius, lx, ly, dt):
"""Integrate one step of mecanum forward kinematics.
Parameters
----------
x, y : robot position (m)
h : robot heading (rad), 0 = +x axis
w_fl, w_fr, w_rl, w_rr : wheel angular velocities (rad/s)
wheel_radius : wheel radius (m)
lx : half the front-to-back axle distance (m)
ly : half the left-to-right axle distance (m)
dt : timestep (s)
Returns (new_x, new_y, new_h).
"""
r = wheel_radius
vx_body = (w_fl + w_fr + w_rl + w_rr) * r / 4.0
vy_body = (-w_fl + w_fr + w_rl - w_rr) * r / 4.0
omega = (-w_fl + w_fr - w_rl + w_rr) * r / (4.0 * (lx + ly))
cos_h = math.cos(h)
sin_h = math.sin(h)
vx_world = vx_body * cos_h - vy_body * sin_h
vy_world = vx_body * sin_h + vy_body * cos_h
new_x = x + vx_world * dt
new_y = y + vy_world * dt
new_h = math.atan2(math.sin(h + omega * dt), math.cos(h + omega * dt))
return new_x, new_y, new_h
def mecanum_inverse(vx_body, vy_body, omega, wheel_radius, lx, ly,
max_wheel_omega):
"""Mecanum inverse kinematics: body-frame velocities to 4 wheel speeds.
Parameters
----------
vx_body, vy_body : desired body-frame linear velocities (m/s)
omega : desired yaw rate (rad/s)
wheel_radius : wheel radius (m)
lx : half front-to-back axle distance (m)
ly : half left-to-right axle distance (m)
max_wheel_omega : wheel angular velocity clamp (rad/s)
Returns (w_fl, w_fr, w_rl, w_rr).
"""
r = wheel_radius
k = lx + ly
w_fl = (vx_body - vy_body - k * omega) / r
w_fr = (vx_body + vy_body + k * omega) / r
w_rl = (vx_body + vy_body - k * omega) / r
w_rr = (vx_body - vy_body + k * omega) / r
scale = max(abs(w_fl), abs(w_fr), abs(w_rl), abs(w_rr), 1e-9)
if scale > max_wheel_omega:
ratio = max_wheel_omega / scale
w_fl *= ratio
w_fr *= ratio
w_rl *= ratio
w_rr *= ratio
return w_fl, w_fr, w_rl, w_rr
def velocity_to_mecanum_wheels(vx, vy, omega, h, max_linear, wheel_radius,
lx, ly, max_wheel_omega,
k_turn=4.0, wheel_base=0.28):
"""Convert world-frame (vx, vy, omega) action in [-1, 1]^3 to 4 wheel speeds.
Truly holonomic interpretation: (vx, vy) is the desired *world-frame*
velocity (magnitude up to ``max_linear`` m/s) and ``omega`` is the
desired yaw rate (independent of motion direction). The dog can
crab-walk and rotate at the same time.
This matches the universal teacher's signal: drive toward a standoff
point while facing the sheep / pen separately. With the older
non-holonomic version, ``omega`` from the teacher fought against the
forward-only kinematics and dropped success rates instead of helping.
Parameters
----------
vx, vy : desired world-frame velocity intent in [-1, 1] (clamped on
magnitude to ≤ 1)
omega : desired yaw rate intent in [-1, 1]
h : current heading (rad), 0 = +x
max_linear : max linear speed (m/s)
wheel_radius : wheel radius (m)
lx, ly : half axle distances (m)
max_wheel_omega : wheel angular velocity clamp (rad/s)
k_turn : unused (kept for signature compatibility)
wheel_base : unused (kept for signature compatibility)
Returns (w_fl, w_fr, w_rl, w_rr).
"""
# Clamp the action magnitude in the (vx, vy) unit disk.
norm = math.hypot(vx, vy)
if norm > 1.0:
vx /= norm
vy /= norm
# World-frame velocity → body-frame velocity (rotate by -h).
vx_world = vx * max_linear
vy_world = vy * max_linear
cos_h = math.cos(h)
sin_h = math.sin(h)
vx_body = cos_h * vx_world + sin_h * vy_world
vy_body = -sin_h * vx_world + cos_h * vy_world
# Yaw rate: omega ∈ [-1, 1] maps to ± max_linear / (lx + ly) — same
# peak yaw as the old "omega_extra" channel, but used directly
# rather than added to a heading-tracker.
yaw_max = max_linear / max(lx + ly, 1e-6)
omega_rad = omega * yaw_max
if abs(vx_body) < 1e-3 and abs(vy_body) < 1e-3 and abs(omega_rad) < 1e-3:
return 0.0, 0.0, 0.0, 0.0
return mecanum_inverse(
vx_body, vy_body, omega_rad,
wheel_radius, lx, ly, max_wheel_omega,
)
+40 -23
View File
@@ -27,9 +27,10 @@ import math
import random import random
from herding.world.geometry import ( from herding.world.geometry import (
FIELD_SHAPE, FIELD_ROUND_R,
FIELD_X, FIELD_Y, FIELD_X, FIELD_Y,
PEN_X, PEN_Y, PEN_X, PEN_Y,
GATE_X, GATE_X, GATE_Y,
) )
# Speeds are in wheel rad/s (motor units); m/s = speed * SHEEP_WHEEL_RADIUS. # Speeds are in wheel rad/s (motor units); m/s = speed * SHEEP_WHEEL_RADIUS.
@@ -131,33 +132,49 @@ def compute_heading_speed(x, y, penned, dog_xy, peers, wander_angle, rng=None):
fx -= (ddx / d) * push * 2.5 fx -= (ddx / d) * push * 2.5
fy -= (ddy / d) * push * 2.5 fy -= (ddy / d) * push * 2.5
# Wall soft repulsion (south wall absent inside the gate column). # Wall soft repulsion.
if x < FIELD_X[0] + WALL_MARGIN: if FIELD_SHAPE == "field_round":
fx += ((FIELD_X[0] + WALL_MARGIN - x) / WALL_MARGIN) * 6.0 r = math.hypot(x, y)
if x > FIELD_X[1] - WALL_MARGIN: wall_d = FIELD_ROUND_R - r
fx -= ((x - (FIELD_X[1] - WALL_MARGIN)) / WALL_MARGIN) * 6.0 in_gate_col = (GATE_X[0] <= x <= GATE_X[1]
if y > FIELD_Y[1] - WALL_MARGIN: and y < GATE_Y + WALL_MARGIN)
fy -= ((y - (FIELD_Y[1] - WALL_MARGIN)) / WALL_MARGIN) * 6.0 if wall_d < WALL_MARGIN and r > 1e-6 and not in_gate_col:
if y < FIELD_Y[0] + WALL_MARGIN and not (GATE_X[0] <= x <= GATE_X[1]): gain = ((WALL_MARGIN - wall_d) / WALL_MARGIN) * 6.0
fy += ((FIELD_Y[0] + WALL_MARGIN - y) / WALL_MARGIN) * 6.0 fx -= (x / r) * gain
fy -= (y / r) * gain
# Hard escape band.
if wall_d < WALL_HARD_MARGIN and not in_gate_col:
hgain = WALL_HARD_GAIN * (1.0 - wall_d / WALL_HARD_MARGIN)
fx -= (x / r) * hgain
fy -= (y / r) * hgain
else:
# Rectangular: south wall absent inside the gate column.
if x < FIELD_X[0] + WALL_MARGIN:
fx += ((FIELD_X[0] + WALL_MARGIN - x) / WALL_MARGIN) * 6.0
if x > FIELD_X[1] - WALL_MARGIN:
fx -= ((x - (FIELD_X[1] - WALL_MARGIN)) / WALL_MARGIN) * 6.0
if y > FIELD_Y[1] - WALL_MARGIN:
fy -= ((y - (FIELD_Y[1] - WALL_MARGIN)) / WALL_MARGIN) * 6.0
if y < FIELD_Y[0] + WALL_MARGIN and not (GATE_X[0] <= x <= GATE_X[1]):
fy += ((FIELD_Y[0] + WALL_MARGIN - y) / WALL_MARGIN) * 6.0
# Hard escape band — overrides everything else near a wall.
m, g = WALL_HARD_MARGIN, WALL_HARD_GAIN
if x - FIELD_X[0] < m:
fx = max(fx, g * (1.0 - (x - FIELD_X[0]) / m))
if FIELD_X[1] - x < m:
fx = min(fx, -g * (1.0 - (FIELD_X[1] - x) / m))
if FIELD_Y[1] - y < m:
fy = min(fy, -g * (1.0 - (FIELD_Y[1] - y) / m))
if (y - FIELD_Y[0] < m) and not (GATE_X[0] <= x <= GATE_X[1]):
fy = max(fy, g * (1.0 - (y - FIELD_Y[0]) / m))
if not fleeing: if not fleeing:
if random.random() < 0.02: if rnd.random() < 0.02:
wander_angle += random.uniform(-0.6, 0.6) wander_angle += rnd.uniform(-0.6, 0.6)
fx += math.cos(wander_angle) * 0.5 fx += math.cos(wander_angle) * 0.5
fy += math.sin(wander_angle) * 0.5 fy += math.sin(wander_angle) * 0.5
# Hard escape band — overrides everything else near a wall.
m, g = WALL_HARD_MARGIN, WALL_HARD_GAIN
if x - FIELD_X[0] < m:
fx = max(fx, g * (1.0 - (x - FIELD_X[0]) / m))
if FIELD_X[1] - x < m:
fx = min(fx, -g * (1.0 - (FIELD_X[1] - x) / m))
if FIELD_Y[1] - y < m:
fy = min(fy, -g * (1.0 - (FIELD_Y[1] - y) / m))
if (not penned) and (y - FIELD_Y[0] < m) and not (GATE_X[0] <= x <= GATE_X[1]):
fy = max(fy, g * (1.0 - (y - FIELD_Y[0]) / m))
heading = math.atan2(fy, fx) heading = math.atan2(fy, fx)
mag = math.hypot(fx, fy) mag = math.hypot(fx, fy)
speed = max(WANDER_SPEED, min(FLEE_SPEED, mag * 3.0)) speed = max(WANDER_SPEED, min(FLEE_SPEED, mag * 3.0))
+114 -7
View File
@@ -4,20 +4,35 @@ Coordinates are metres; (0, 0) is the field centre, +x east, +y north.
These constants mirror ``worlds/field.wbt`` and the proto files — if These constants mirror ``worlds/field.wbt`` and the proto files — if
the world changes, this file is the single point of update. the world changes, this file is the single point of update.
field +y north field (rectangular)
+-----------+ +-----------+
| | | |
| |
| ...... | | ...... |
+---||||----+ y = -15 (south wall, 3 m gate at x [10, 13]) +---||||----+ y = -15 (south wall, 3 m gate at x in [10, 13])
|||| ||||
|pen| y [-22, -15] |pen| y in [-22, -15]
+---+ +---+
field_round (circular, R = 15 m)
.---.
/ ... \\
| ..... | gate at south, x in [-1.83, 1.83]
\\ ... /
'-+-' pen y in [-22, -15]
""" """
import os
import math import math
# Field (square, stone-walled) # ---------------------------------------------------------------------------
# Field shape selection — controlled by HERDING_WORLD env var at runtime.
# Defaults to "field" (rectangular). The launcher writes it into the
# runtime cfg so the controller can pick it up too.
# ---------------------------------------------------------------------------
FIELD_SHAPE = (os.environ.get("HERDING_WORLD", "field")).lower()
# ==================== Rectangular field (field.wbt) ====================
FIELD_X = (-15.0, 15.0) FIELD_X = (-15.0, 15.0)
FIELD_Y = (-15.0, 15.0) FIELD_Y = (-15.0, 15.0)
FIELD_INSIDE_MARGIN = 0.5 FIELD_INSIDE_MARGIN = 0.5
@@ -32,12 +47,67 @@ PEN_ENTRY = (0.5 * (PEN_X[0] + PEN_X[1]), -15.0)
GATE_X = PEN_X GATE_X = PEN_X
GATE_Y = -15.0 GATE_Y = -15.0
# ==================== Round field (field_round.wbt) ====================
FIELD_ROUND_R = 15.0
FIELD_ROUND_PEN_X = (-1.5, 1.5)
FIELD_ROUND_PEN_Y = (-22.0, -15.0)
FIELD_ROUND_PEN_CENTER = (
0.5 * (FIELD_ROUND_PEN_X[0] + FIELD_ROUND_PEN_X[1]),
0.5 * (FIELD_ROUND_PEN_Y[0] + FIELD_ROUND_PEN_Y[1]),
)
FIELD_ROUND_PEN_ENTRY = (0.0, -15.0)
FIELD_ROUND_GATE_X = FIELD_ROUND_PEN_X
FIELD_ROUND_GATE_Y = -15.0
# ==================== Active geometry (resolved at import) ===============
# Rectangular defaults are already assigned above. Override for round.
if FIELD_SHAPE == "field_round":
PEN_X = FIELD_ROUND_PEN_X
PEN_Y = FIELD_ROUND_PEN_Y
PEN_CENTER = FIELD_ROUND_PEN_CENTER
PEN_ENTRY = FIELD_ROUND_PEN_ENTRY
GATE_X = FIELD_ROUND_GATE_X
GATE_Y = FIELD_ROUND_GATE_Y
def configure(shape: str) -> None:
"""Switch the active field geometry at runtime.
Call this **before** importing any other ``herding.*`` module that
depends on the constants below (flocking_sim, lidar_sim, obs, etc.).
The import-time env-var path (``HERDING_WORLD``) still works; this
function is for scripts that need to choose the world via a CLI flag.
"""
global FIELD_SHAPE, PEN_X, PEN_Y, PEN_CENTER, PEN_ENTRY, GATE_X, GATE_Y
shape = shape.lower()
FIELD_SHAPE = shape
if shape == "field_round":
PEN_X = FIELD_ROUND_PEN_X
PEN_Y = FIELD_ROUND_PEN_Y
PEN_CENTER = FIELD_ROUND_PEN_CENTER
PEN_ENTRY = FIELD_ROUND_PEN_ENTRY
GATE_X = FIELD_ROUND_GATE_X
GATE_Y = FIELD_ROUND_GATE_Y
else:
PEN_X = (10.0, 13.0)
PEN_Y = (-22.0, -15.0)
PEN_CENTER = (0.5 * (PEN_X[0] + PEN_X[1]), 0.5 * (PEN_Y[0] + PEN_Y[1]))
PEN_ENTRY = (0.5 * (PEN_X[0] + PEN_X[1]), -15.0)
GATE_X = PEN_X
GATE_Y = -15.0
# Dog spec — protos/ShepherdDog.proto # Dog spec — protos/ShepherdDog.proto
DOG_WHEEL_RADIUS = 0.038 # m DOG_WHEEL_RADIUS = 0.038 # m
DOG_WHEEL_BASE = 0.28 # m, axle-to-axle DOG_WHEEL_BASE = 0.28 # m, axle-to-axle
DOG_MAX_WHEEL_OMEGA = 70.0 # rad/s DOG_MAX_WHEEL_OMEGA = 70.0 # rad/s
DOG_MAX_LINEAR = DOG_WHEEL_RADIUS * DOG_MAX_WHEEL_OMEGA # ≈ 2.66 m/s DOG_MAX_LINEAR = DOG_WHEEL_RADIUS * DOG_MAX_WHEEL_OMEGA # ≈ 2.66 m/s
# Dog mecanum spec — 4-wheel omnidirectional layout
DOG_WHEEL_BASE_X = 0.28 # m, front-to-back axle distance
DOG_WHEEL_BASE_Y = 0.28 # m, left-to-right axle distance
# Sheep spec — protos/Sheep.proto # Sheep spec — protos/Sheep.proto
SHEEP_WHEEL_RADIUS = 0.031 # m SHEEP_WHEEL_RADIUS = 0.031 # m
SHEEP_WHEEL_BASE = 0.20 # m SHEEP_WHEEL_BASE = 0.20 # m
@@ -58,21 +128,58 @@ def in_pen(x: float, y: float) -> bool:
def in_field(x: float, y: float, margin: float = 0.0) -> bool: def in_field(x: float, y: float, margin: float = 0.0) -> bool:
if FIELD_SHAPE == "field_round":
r = FIELD_ROUND_R - margin
return x * x + y * y <= r * r
return (FIELD_X[0] + margin <= x <= FIELD_X[1] - margin return (FIELD_X[0] + margin <= x <= FIELD_X[1] - margin
and FIELD_Y[0] + margin <= y <= FIELD_Y[1] - margin) and FIELD_Y[0] + margin <= y <= FIELD_Y[1] - margin)
def in_gate_corridor(x: float, y: float, margin: float = 0.0) -> bool: def in_gate_corridor(x: float, y: float, margin: float = 0.0) -> bool:
"""True if (x, y) lies in the column of the gate (between field and pen).""" """True if (x, y) lies in the column of the gate (between field and pen)."""
return (PEN_X[0] - margin <= x <= PEN_X[1] + margin return (GATE_X[0] - margin <= x <= GATE_X[1] + margin
and PEN_Y[0] - margin <= y <= GATE_Y + margin) and PEN_Y[0] - margin <= y <= GATE_Y + margin)
def is_penned_position(x: float, y: float, latch_margin: float = 0.2) -> bool: def is_penned_position(x: float, y: float, latch_margin: float = 0.2) -> bool:
"""True iff (x, y) is in the gate column and south of the gate line.""" """True iff (x, y) is in the gate column and south of the gate line."""
return (PEN_X[0] - latch_margin <= x <= PEN_X[1] + latch_margin return (GATE_X[0] - latch_margin <= x <= GATE_X[1] + latch_margin
and y <= GATE_Y) and y <= GATE_Y)
def distance_to_pen_entry(x: float, y: float) -> float: def distance_to_pen_entry(x: float, y: float) -> float:
return math.hypot(x - PEN_ENTRY[0], y - PEN_ENTRY[1]) return math.hypot(x - PEN_ENTRY[0], y - PEN_ENTRY[1])
def distance_to_wall(x: float, y: float) -> float:
"""Shortest distance from (x, y) to the nearest field wall.
For a rectangular field this is the minimum Manhattan distance to the
four bounding walls. For a round field it is ``R - sqrt(x²+y²)``.
Returns a negative value if the point is outside the field.
"""
if FIELD_SHAPE == "field_round":
return FIELD_ROUND_R - math.hypot(x, y)
return min(
x - FIELD_X[0], FIELD_X[1] - x,
y - FIELD_Y[0], FIELD_Y[1] - y,
)
def clip_to_field(x: float, y: float, margin: float = 0.2) -> tuple[float, float]:
"""Clip (x, y) inside the field boundary with a small margin.
For round fields the point is projected radially inward if it exceeds
the circular boundary.
"""
if FIELD_SHAPE == "field_round":
r = math.hypot(x, y)
limit = FIELD_ROUND_R - margin
if r > limit and r > 1e-6:
scale = limit / r
return x * scale, y * scale
return x, y
return (
max(FIELD_X[0] + margin, min(FIELD_X[1] - margin, x)),
max(FIELD_Y[0] + margin, min(FIELD_Y[1] - margin, y)),
)
+885
View File
@@ -0,0 +1,885 @@
#VRML_SIM R2025a utf8
# Shepherd Dog Robot - mecanum-wheeled base with dog character on top
# 4-wheel omnidirectional drive (front-left, front-right, rear-left, rear-right).
PROTO ShepherdDogMecanum [
field SFVec3f translation 0 0 0
field SFRotation rotation 0 1 0 0
field SFString name "ShepherdDog"
field SFString controller "shepherd_dog"
field MFString controllerArgs []
field SFString customData ""
field SFBool supervisor FALSE
field SFBool synchronization TRUE
]
{
Robot {
translation IS translation
rotation IS rotation
name IS name
controller IS controller
controllerArgs IS controllerArgs
customData IS customData
supervisor IS supervisor
synchronization IS synchronization
children [
# ========== CHASSIS / BASE ==========
DEF CHASSIS Transform {
translation 0 0 0.05
children [
Shape {
appearance DEF CHASSIS_APP PBRAppearance {
baseColor 0.2 0.2 0.2
roughness 0.6
metalness 0.3
}
geometry Box {
size 0.32 0.16 0.06
}
}
]
}
# Front slope
DEF CHASSIS_FRONT Transform {
translation 0.14 0 0.07
children [
Shape {
appearance USE CHASSIS_APP
geometry Box {
size 0.06 0.14 0.04
}
}
]
}
# Rear slope
DEF CHASSIS_REAR Transform {
translation -0.14 0 0.07
children [
Shape {
appearance USE CHASSIS_APP
geometry Box {
size 0.06 0.14 0.04
}
}
]
}
# ========== DOG BODY on top of chassis ==========
DEF BODY Transform {
translation 0 0 0.11
children [
Shape {
appearance DEF FUR_BROWN PBRAppearance {
baseColor 0.55 0.35 0.17
roughness 0.85
metalness 0.0
}
geometry Box {
size 0.30 0.16 0.08
}
}
]
}
# ========== CHEST ==========
DEF CHEST Transform {
translation 0.12 0 0.11
children [
Shape {
appearance DEF FUR_CREAM PBRAppearance {
baseColor 0.85 0.72 0.55
roughness 0.85
metalness 0.0
}
geometry Box {
size 0.08 0.18 0.08
}
}
]
}
# ========== HEAD ==========
DEF HEAD Transform {
translation 0.20 0 0.17
children [
Shape {
appearance USE FUR_BROWN
geometry Box {
size 0.10 0.12 0.09
}
}
]
}
# ========== SNOUT + LIDAR ==========
DEF SNOUT Transform {
translation 0.28 0 0.155
children [
Shape {
appearance USE FUR_CREAM
geometry Box {
size 0.08 0.07 0.05
}
}
# Nose
Transform {
translation 0.04 0 0.01
children [
Shape {
appearance PBRAppearance {
baseColor 0.1 0.1 0.1
roughness 0.4
}
geometry Sphere {
radius 0.013
subdivision 2
}
}
]
}
# Lidar front-facing 140° FOV, mounted at snout tip
Lidar {
translation 0.05 0 0.01
name "lidar"
horizontalResolution 180
fieldOfView 2.44
numberOfLayers 1
minRange 0.10
maxRange 12.0
noise 0.005
}
]
}
# ========== LEFT EAR ==========
DEF LEFT_EAR HingeJoint {
jointParameters HingeJointParameters {
axis 0 0 1
anchor 0.19 0.055 0.21
}
device [
RotationalMotor {
name "left ear motor"
maxVelocity 10.0
minPosition -0.5
maxPosition 0.5
}
]
endPoint Solid {
translation 0.19 0.055 0.21
rotation 0 0 1 0.2
name "left ear"
children [
Shape {
appearance DEF FUR_DARK PBRAppearance {
baseColor 0.35 0.20 0.10
roughness 0.85
metalness 0.0
}
geometry Box {
size 0.035 0.025 0.06
}
}
]
boundingObject Box {
size 0.035 0.025 0.06
}
physics Physics {
density -1
mass 0.005
}
}
}
# ========== RIGHT EAR ==========
DEF RIGHT_EAR HingeJoint {
jointParameters HingeJointParameters {
axis 0 0 1
anchor 0.19 -0.055 0.21
}
device [
RotationalMotor {
name "right ear motor"
maxVelocity 10.0
minPosition -0.5
maxPosition 0.5
}
]
endPoint Solid {
translation 0.19 -0.055 0.21
rotation 0 0 -1 0.2
name "right ear"
children [
Shape {
appearance USE FUR_DARK
geometry Box {
size 0.035 0.025 0.06
}
}
]
boundingObject Box {
size 0.035 0.025 0.06
}
physics Physics {
density -1
mass 0.005
}
}
}
# ========== EYES ==========
DEF LEFT_EYE Transform {
translation 0.25 0.05 0.19
children [
Shape {
appearance PBRAppearance {
baseColor 0.95 0.95 0.95
roughness 0.3
}
geometry Sphere {
radius 0.016
subdivision 2
}
}
# Pupil
Transform {
translation 0.012 0 0.004
children [
Shape {
appearance PBRAppearance {
baseColor 0.1 0.1 0.1
roughness 0.2
}
geometry Sphere {
radius 0.009
subdivision 2
}
}
]
}
]
}
DEF RIGHT_EYE Transform {
translation 0.25 -0.05 0.19
children [
Shape {
appearance PBRAppearance {
baseColor 0.95 0.95 0.95
roughness 0.3
}
geometry Sphere {
radius 0.016
subdivision 2
}
}
# Pupil
Transform {
translation 0.012 0 0.004
children [
Shape {
appearance PBRAppearance {
baseColor 0.1 0.1 0.1
roughness 0.2
}
geometry Sphere {
radius 0.009
subdivision 2
}
}
]
}
]
}
# ========== COLLAR ==========
DEF COLLAR Transform {
translation 0.16 0 0.125
children [
Shape {
appearance PBRAppearance {
baseColor 0.8 0.1 0.1
roughness 0.5
}
geometry Cylinder {
height 0.02
radius 0.095
subdivision 16
}
}
# ID tag
Transform {
translation 0 0.10 0
rotation 1 0 0 1.5708
children [
Shape {
appearance PBRAppearance {
baseColor 0.75 0.75 0.0
metalness 0.8
roughness 0.2
}
geometry Cylinder {
height 0.003
radius 0.018
subdivision 8
}
}
]
}
]
}
# ========== TAIL (lidar inside tail tip ball) ==========
DEF TAIL HingeJoint {
jointParameters HingeJointParameters {
axis 0 1 0
anchor -0.15 0 0.11
}
device [
RotationalMotor {
name "tail motor"
maxVelocity 5.0
minPosition -1.0
maxPosition 1.0
}
]
endPoint Solid {
translation -0.17 0 0.13
name "tail solid"
children [
Shape {
appearance USE FUR_BROWN
geometry Capsule {
height 0.12
radius 0.013
top FALSE
}
}
# Tail tip ball
Transform {
translation 0 0 0.08
children [
Shape {
appearance PBRAppearance {
baseColor 0.2 0.2 0.2
roughness 0.3
metalness 0.6
}
geometry Sphere {
radius 0.028
subdivision 4
}
}
]
}
]
boundingObject Group {
children [
Capsule {
height 0.12
radius 0.013
}
Transform {
translation 0 0 0.08
children [
Sphere {
radius 0.028
}
]
}
]
}
physics Physics {
density -1
mass 0.08
}
}
}
# ========== AXLE ARMS (4 corners) ==========
DEF FRONT_RIGHT_AXLE Transform {
translation 0.14 -0.115 0.038
children [
Shape {
appearance PBRAppearance {
baseColor 0.5 0.5 0.5
roughness 0.3
metalness 0.8
}
geometry Box {
size 0.02 0.08 0.02
}
}
]
}
DEF FRONT_LEFT_AXLE Transform {
translation 0.14 0.115 0.038
children [
Shape {
appearance PBRAppearance {
baseColor 0.5 0.5 0.5
roughness 0.3
metalness 0.8
}
geometry Box {
size 0.02 0.08 0.02
}
}
]
}
DEF REAR_RIGHT_AXLE Transform {
translation -0.14 -0.115 0.038
children [
Shape {
appearance PBRAppearance {
baseColor 0.5 0.5 0.5
roughness 0.3
metalness 0.8
}
geometry Box {
size 0.02 0.08 0.02
}
}
]
}
DEF REAR_LEFT_AXLE Transform {
translation -0.14 0.115 0.038
children [
Shape {
appearance PBRAppearance {
baseColor 0.5 0.5 0.5
roughness 0.3
metalness 0.8
}
geometry Box {
size 0.02 0.08 0.02
}
}
]
}
# ========== FRONT RIGHT WHEEL ==========
DEF FRONT_RIGHT_WHEEL_JOINT HingeJoint {
jointParameters HingeJointParameters {
axis 0 1 0
anchor 0.14 -0.14 0.038
}
device [
RotationalMotor {
name "front right wheel motor"
maxVelocity 70.0
maxTorque 20.0
}
PositionSensor {
name "front right wheel sensor"
resolution 0.00628
}
]
endPoint Solid {
translation 0.14 -0.14 0.038
rotation 0 -1 0 1.570796
children [
DEF WHEEL_VIS Pose {
rotation 1 0 0 -1.5708
children [
# Hub drum
Shape {
appearance PBRAppearance {
baseColor 0.5 0.5 0.5
roughness 0.3
metalness 0.7
}
geometry Cylinder {
height 0.018
radius 0.022
subdivision 16
}
}
# Axle boss
Shape {
appearance PBRAppearance {
baseColor 0.6 0.6 0.6
roughness 0.2
metalness 0.8
}
geometry Cylinder {
height 0.022
radius 0.008
subdivision 8
}
}
# Mecanum roller 1 (top, +y)
DEF ROLLER_1 Pose {
translation 0 0.031 0
rotation 0 0 1 0.7854
children [
Shape {
appearance PBRAppearance {
baseColor 0.12 0.12 0.12
roughness 0.7
metalness 0.1
}
geometry Capsule {
height 0.020
radius 0.007
subdivision 8
}
}
]
}
# Mecanum roller 2 (right, +x)
DEF ROLLER_2 Pose {
translation 0.031 0 0
rotation 0 0 1 0.7854
children [
Shape {
appearance PBRAppearance {
baseColor 0.12 0.12 0.12
roughness 0.7
metalness 0.1
}
geometry Capsule {
height 0.020
radius 0.007
subdivision 8
}
}
]
}
# Mecanum roller 3 (bottom, -y)
DEF ROLLER_3 Pose {
translation 0 -0.031 0
rotation 0 0 1 0.7854
children [
Shape {
appearance PBRAppearance {
baseColor 0.12 0.12 0.12
roughness 0.7
metalness 0.1
}
geometry Capsule {
height 0.020
radius 0.007
subdivision 8
}
}
]
}
# Mecanum roller 4 (left, -x)
DEF ROLLER_4 Pose {
translation -0.031 0 0
rotation 0 0 1 0.7854
children [
Shape {
appearance PBRAppearance {
baseColor 0.12 0.12 0.12
roughness 0.7
metalness 0.1
}
geometry Capsule {
height 0.020
radius 0.007
subdivision 8
}
}
]
}
# Mecanum roller 5 (diagonal +x+y)
DEF ROLLER_5 Pose {
translation 0.022 0.022 0
rotation 0 0 1 0.7854
children [
Shape {
appearance PBRAppearance {
baseColor 0.12 0.12 0.12
roughness 0.7
metalness 0.1
}
geometry Capsule {
height 0.020
radius 0.007
subdivision 8
}
}
]
}
# Mecanum roller 6 (diagonal +x-y)
DEF ROLLER_6 Pose {
translation 0.022 -0.022 0
rotation 0 0 1 0.7854
children [
Shape {
appearance PBRAppearance {
baseColor 0.12 0.12 0.12
roughness 0.7
metalness 0.1
}
geometry Capsule {
height 0.020
radius 0.007
subdivision 8
}
}
]
}
# Mecanum roller 7 (diagonal -x-y)
DEF ROLLER_7 Pose {
translation -0.022 -0.022 0
rotation 0 0 1 0.7854
children [
Shape {
appearance PBRAppearance {
baseColor 0.12 0.12 0.12
roughness 0.7
metalness 0.1
}
geometry Capsule {
height 0.020
radius 0.007
subdivision 8
}
}
]
}
# Mecanum roller 8 (diagonal -x+y)
DEF ROLLER_8 Pose {
translation -0.022 0.022 0
rotation 0 0 1 0.7854
children [
Shape {
appearance PBRAppearance {
baseColor 0.12 0.12 0.12
roughness 0.7
metalness 0.1
}
geometry Capsule {
height 0.020
radius 0.007
subdivision 8
}
}
]
}
]
}
]
name "front right wheel"
contactMaterial "MecanumWheel"
boundingObject Pose {
rotation 1 0 0 -1.5708
children [
Cylinder {
height 0.022
radius 0.038
}
]
}
physics Physics {
density -1
mass 0.06
centerOfMass [
0 0 0
]
}
}
}
# ========== FRONT LEFT WHEEL ==========
DEF FRONT_LEFT_WHEEL_JOINT HingeJoint {
jointParameters HingeJointParameters {
axis 0 1 0
anchor 0.14 0.14 0.038
}
device [
RotationalMotor {
name "front left wheel motor"
maxVelocity 70.0
maxTorque 20.0
}
PositionSensor {
name "front left wheel sensor"
resolution 0.00628
}
]
endPoint Solid {
translation 0.14 0.14 0.038
rotation 0.707105 0 0.707109 -3.14159
children [
USE WHEEL_VIS
]
name "front left wheel"
contactMaterial "MecanumWheel"
boundingObject Pose {
rotation 1 0 0 -1.5708
children [
Cylinder {
height 0.022
radius 0.038
}
]
}
physics Physics {
density -1
mass 0.06
centerOfMass [
0 0 0
]
}
}
}
# ========== REAR RIGHT WHEEL ==========
DEF REAR_RIGHT_WHEEL_JOINT HingeJoint {
jointParameters HingeJointParameters {
axis 0 1 0
anchor -0.14 -0.14 0.038
}
device [
RotationalMotor {
name "rear right wheel motor"
maxVelocity 70.0
maxTorque 20.0
}
PositionSensor {
name "rear right wheel sensor"
resolution 0.00628
}
]
endPoint Solid {
translation -0.14 -0.14 0.038
rotation 0 -1 0 1.570796
children [
USE WHEEL_VIS
]
name "rear right wheel"
contactMaterial "MecanumWheel"
boundingObject Pose {
rotation 1 0 0 -1.5708
children [
Cylinder {
height 0.022
radius 0.038
}
]
}
physics Physics {
density -1
mass 0.06
centerOfMass [
0 0 0
]
}
}
}
# ========== REAR LEFT WHEEL ==========
DEF REAR_LEFT_WHEEL_JOINT HingeJoint {
jointParameters HingeJointParameters {
axis 0 1 0
anchor -0.14 0.14 0.038
}
device [
RotationalMotor {
name "rear left wheel motor"
maxVelocity 70.0
maxTorque 20.0
}
PositionSensor {
name "rear left wheel sensor"
resolution 0.00628
}
]
endPoint Solid {
translation -0.14 0.14 0.038
rotation 0.707105 0 0.707109 -3.14159
children [
USE WHEEL_VIS
]
name "rear left wheel"
contactMaterial "MecanumWheel"
boundingObject Pose {
rotation 1 0 0 -1.5708
children [
Cylinder {
height 0.022
radius 0.038
}
]
}
physics Physics {
density -1
mass 0.06
centerOfMass [
0 0 0
]
}
}
}
# ========== IMU SENSORS ==========
Accelerometer {
translation 0 0 0.10
name "accelerometer"
}
Gyro {
translation 0 0 0.10
name "gyro"
}
Compass {
translation 0 0 0.10
name "compass"
}
# ========== GPS ==========
GPS {
translation 0 0 0.17
name "gps"
}
# ========== RECEIVER ==========
Receiver {
name "receiver"
channel 1
}
# ========== EMITTER ==========
Emitter {
name "emitter"
channel 1
range 50.0
}
]
# ========== BOUNDING OBJECT ==========
boundingObject Group {
children [
# Chassis box
Transform {
translation 0 0 0.05
children [
Box {
size 0.32 0.16 0.06
}
]
}
# Body box
Transform {
translation 0 0 0.11
children [
Box {
size 0.30 0.16 0.08
}
]
}
]
}
# ========== PHYSICS ==========
physics Physics {
density -1
mass 5.0
centerOfMass [
0 0 0.03
]
}
}
}
+28 -4
View File
@@ -14,6 +14,7 @@ from herding.control.sequential import compute_action as sequential_action
from herding.control.strombom import ( from herding.control.strombom import (
DELTA_DRIVE, F_FACTOR, compute_action as strombom_action, DELTA_DRIVE, F_FACTOR, compute_action as strombom_action,
) )
from herding.control.universal import compute_action as universal_action
from herding.world.geometry import PEN_ENTRY from herding.world.geometry import PEN_ENTRY
@@ -119,8 +120,10 @@ def test_sequential_targets_closest_to_pen():
def test_active_scan_initial_phase_rotates(): def test_active_scan_initial_phase_rotates():
teacher = ActiveScanTeacher(strombom_action) teacher = ActiveScanTeacher(strombom_action)
# First call → opening rotation regardless of input. # First call → opening rotation regardless of input.
vx, vy, mode = teacher((0.0, 0.0), 0.0, {"s0": (5.0, 0.0)}, PEN_ENTRY) vx, vy, omega, mode = teacher(
(0.0, 0.0), 0.0, {"s0": (5.0, 0.0)}, PEN_ENTRY)
assert mode == "scan_initial" assert mode == "scan_initial"
assert omega == 0.0
assert math.isclose(math.hypot(vx, vy), 1.0, abs_tol=1e-6) assert math.isclose(math.hypot(vx, vy), 1.0, abs_tol=1e-6)
@@ -129,7 +132,8 @@ def test_active_scan_hands_off_to_base_after_opener():
# Burn through the opener. # Burn through the opener.
for _ in range(2): for _ in range(2):
teacher((0.0, 0.0), 0.0, {"s0": (0.0, 8.0)}, PEN_ENTRY) teacher((0.0, 0.0), 0.0, {"s0": (0.0, 8.0)}, PEN_ENTRY)
_vx, _vy, mode = teacher((0.0, 0.0), 0.0, {"s0": (0.0, 8.0)}, PEN_ENTRY) _vx, _vy, _omega, mode = teacher(
(0.0, 0.0), 0.0, {"s0": (0.0, 8.0)}, PEN_ENTRY)
# Either drive (Strömbom mode label) or collect; not scan_initial. # Either drive (Strömbom mode label) or collect; not scan_initial.
assert "scan" not in mode assert "scan" not in mode
@@ -141,7 +145,7 @@ def test_active_scan_holds_last_action_on_brief_empty():
teacher((0.0, 0.0), 0.0, {"s0": (0.0, 8.0)}, PEN_ENTRY) teacher((0.0, 0.0), 0.0, {"s0": (0.0, 8.0)}, PEN_ENTRY)
last = teacher.last_action last = teacher.last_action
# Now a single empty frame → hold. # Now a single empty frame → hold.
vx, vy, mode = teacher((0.0, 0.0), 0.0, {}, PEN_ENTRY) vx, vy, _omega, mode = teacher((0.0, 0.0), 0.0, {}, PEN_ENTRY)
assert mode == "hold" assert mode == "hold"
assert (vx, vy) == last assert (vx, vy) == last
@@ -150,10 +154,30 @@ def test_active_scan_explores_after_sustained_empty():
teacher = ActiveScanTeacher(strombom_action, initial_scan_steps=1) teacher = ActiveScanTeacher(strombom_action, initial_scan_steps=1)
teacher((0.0, 0.0), 0.0, {}, PEN_ENTRY) # opener teacher((0.0, 0.0), 0.0, {}, PEN_ENTRY) # opener
for _ in range(EMPTY_DEBOUNCE_STEPS): for _ in range(EMPTY_DEBOUNCE_STEPS):
last_vx, last_vy, mode = teacher((5.0, 5.0), 0.0, {}, PEN_ENTRY) last_vx, last_vy, _omega, mode = teacher(
(5.0, 5.0), 0.0, {}, PEN_ENTRY)
assert mode in ("explore", "scan_at_centre") assert mode in ("explore", "scan_at_centre")
def test_active_scan_preserves_mecanum_omega():
"""Regression: ActiveScanTeacher must propagate omega from a mecanum
base teacher, not silently drop it. Without this, BC mecanum demos
have omega=0 everywhere and the policy never learns to rotate.
"""
teacher = ActiveScanTeacher(universal_action, initial_scan_steps=1)
# Burn the opener so we exit phase 1.
teacher((0.0, 0.0), 0.0, {"s0": (8.0, 8.0)}, PEN_ENTRY,
drive_mode="mecanum")
# Place a sheep off to the side so the dog needs to face it.
# Dog at origin facing +x (heading=0); target at (0, 8) → desired
# heading +π/2, so omega should be positive.
vx, vy, omega, mode = teacher(
(0.0, 0.0), 0.0, {"s0": (0.0, 8.0)}, PEN_ENTRY,
drive_mode="mecanum")
assert mode in ("collect", "drive", "recovery")
assert abs(omega) > 0.05, f"omega should be non-zero on mecanum, got {omega}"
def test_active_scan_reset_clears_state(): def test_active_scan_reset_clears_state():
teacher = ActiveScanTeacher(strombom_action, initial_scan_steps=5) teacher = ActiveScanTeacher(strombom_action, initial_scan_steps=5)
for _ in range(3): for _ in range(3):
+110 -2
View File
@@ -1,11 +1,13 @@
"""Differential-drive kinematics and the (vx, vy) → wheel-speed map.""" """Differential-drive and mecanum kinematics tests."""
import math import math
import pytest import pytest
from herding.world.diffdrive import ( from herding.world.diffdrive import (
heading_speed_to_wheels, kinematics_step, velocity_to_wheels, heading_speed_to_wheels, kinematics_step,
mecanum_inverse, mecanum_kinematics_step,
velocity_to_mecanum_wheels, velocity_to_wheels,
) )
@@ -82,3 +84,109 @@ def test_heading_speed_to_wheels_reverse_target_forwards_zero():
left, right = heading_speed_to_wheels(math.pi, 10.0, 0.0, MAX_OMEGA) left, right = heading_speed_to_wheels(math.pi, 10.0, 0.0, MAX_OMEGA)
# cos(π) clamped at 0 → no forward; pure rotation. # cos(π) clamped at 0 → no forward; pure rotation.
assert left + right == pytest.approx(0.0, abs=1e-6) assert left + right == pytest.approx(0.0, abs=1e-6)
# ---------------------------------------------------------------------------
# Mecanum kinematics tests
# ---------------------------------------------------------------------------
LX = 0.14 # half wheel_base_x
LY = 0.14 # half wheel_base_y
def test_mecanum_kinematics_zero_is_identity():
x, y, h = mecanum_kinematics_step(
1.0, 2.0, 0.5, 0.0, 0.0, 0.0, 0.0, WHEEL_R, LX, LY, DT,
)
assert (x, y, h) == (1.0, 2.0, 0.5)
def test_mecanum_kinematics_pure_forward():
# All 4 wheels equal → pure forward (vx_body > 0, vy_body = 0).
w = 10.0
x, y, h = mecanum_kinematics_step(
0.0, 0.0, 0.0, w, w, w, w, WHEEL_R, LX, LY, DT,
)
assert h == pytest.approx(0.0, abs=1e-9)
assert y == pytest.approx(0.0, abs=1e-9)
assert math.isclose(x, w * WHEEL_R * DT, rel_tol=1e-6)
def test_mecanum_kinematics_pure_strafe():
# Strafe right (positive vy_body) with zero forward:
# vx_body = (w_fl+w_fr+w_rl+w_rr)*r/4 = 0 → sum of wheels = 0
# vy_body = (-w_fl+w_fr+w_rl-w_rr)*r/4 > 0
# Use w_fl=-10, w_fr=10, w_rl=10, w_rr=-10.
w_fl, w_fr, w_rl, w_rr = -10.0, 10.0, 10.0, -10.0
x, y, h = mecanum_kinematics_step(
0.0, 0.0, 0.0, w_fl, w_fr, w_rl, w_rr, WHEEL_R, LX, LY, DT,
)
assert h == pytest.approx(0.0, abs=1e-9)
assert x == pytest.approx(0.0, abs=1e-9)
expected_vy = (-w_fl + w_fr + w_rl - w_rr) * WHEEL_R / 4.0
assert math.isclose(y, expected_vy * DT, rel_tol=1e-6)
def test_mecanum_kinematics_pure_rotation():
# Pure rotation: vx_body=0, vy_body=0, omega>0.
# w_fl=-10, w_fr=10, w_rl=-10, w_rr=10 → all sums cancel except omega.
w_fl, w_fr, w_rl, w_rr = -10.0, 10.0, -10.0, 10.0
x, y, h = mecanum_kinematics_step(
0.0, 0.0, 0.0, w_fl, w_fr, w_rl, w_rr, WHEEL_R, LX, LY, DT,
)
assert x == pytest.approx(0.0, abs=1e-9)
assert y == pytest.approx(0.0, abs=1e-9)
assert h > 0.0
def test_mecanum_inverse_roundtrip():
# Inverse → forward: pick desired body velocities, compute wheels,
# then verify forward kinematics recovers the same velocities.
vx_b = 0.5
vy_b = 0.3
omega = 0.2
w_fl, w_fr, w_rl, w_rr = mecanum_inverse(
vx_b, vy_b, omega, WHEEL_R, LX, LY, MAX_OMEGA,
)
vx_check = (w_fl + w_fr + w_rl + w_rr) * WHEEL_R / 4.0
vy_check = (-w_fl + w_fr + w_rl - w_rr) * WHEEL_R / 4.0
omega_check = (-w_fl + w_fr - w_rl + w_rr) * WHEEL_R / (4.0 * (LX + LY))
assert math.isclose(vx_b, vx_check, rel_tol=1e-6)
assert math.isclose(vy_b, vy_check, rel_tol=1e-6)
assert math.isclose(omega, omega_check, rel_tol=1e-6)
def test_mecanum_inverse_clamped():
# Request an extreme velocity — all wheels should be clamped.
w_fl, w_fr, w_rl, w_rr = mecanum_inverse(
100.0, 100.0, 50.0, WHEEL_R, LX, LY, MAX_OMEGA,
)
assert max(abs(w_fl), abs(w_fr), abs(w_rl), abs(w_rr)) <= MAX_OMEGA
def test_velocity_to_mecanum_wheels_zero():
result = velocity_to_mecanum_wheels(
0.0, 0.0, 0.0, 0.0, MAX_LINEAR, WHEEL_R, LX, LY, MAX_OMEGA,
wheel_base=WHEEL_B,
)
assert result == (0.0, 0.0, 0.0, 0.0)
def test_velocity_to_mecanum_wheels_forward():
w_fl, w_fr, w_rl, w_rr = velocity_to_mecanum_wheels(
1.0, 0.0, 0.0, 0.0, MAX_LINEAR, WHEEL_R, LX, LY, MAX_OMEGA,
wheel_base=WHEEL_B,
)
# All 4 wheels should be positive and roughly equal.
assert all(w > 0.0 for w in (w_fl, w_fr, w_rl, w_rr))
assert math.isclose(w_fl, w_rr, rel_tol=1e-6)
assert math.isclose(w_fr, w_rl, rel_tol=1e-6)
def test_velocity_to_mecanum_wheels_clamped():
# Extreme input — all wheels within max.
ws = velocity_to_mecanum_wheels(
1.0, 1.0, 1.0, 0.0, MAX_LINEAR, WHEEL_R, LX, LY, MAX_OMEGA,
wheel_base=WHEEL_B,
)
assert all(abs(w) <= MAX_OMEGA for w in ws)
+8
View File
@@ -38,6 +38,14 @@ def test_env_reset_determinism_same_seed():
assert np.allclose(obs_a, obs_b) assert np.allclose(obs_a, obs_b)
def test_env_constructor_seed_applies_to_first_reset():
a = HerdingEnv(n_sheep=3, seed=42, use_lidar=False)
b = HerdingEnv(n_sheep=3, seed=42, use_lidar=False)
obs_a, _ = a.reset()
obs_b, _ = b.reset()
assert np.allclose(obs_a, obs_b)
def test_env_curriculum_samples_full_range(): def test_env_curriculum_samples_full_range():
env = HerdingEnv(seed=0, use_lidar=False) env = HerdingEnv(seed=0, use_lidar=False)
sizes = set() sizes = set()
+4 -1
View File
@@ -42,8 +42,11 @@ def test_simulate_scan_sheep_in_front_returns_centre_hit():
def test_simulate_scan_sheep_behind_dog_not_hit(): def test_simulate_scan_sheep_behind_dog_not_hit():
# With 360° FOV, a sheep behind the dog IS now hit.
ranges = simulate_scan(0.0, 0.0, 0.0, [(-5.0, 0.0)], noise=0.0) ranges = simulate_scan(0.0, 0.0, 0.0, [(-5.0, 0.0)], noise=0.0)
assert (ranges == LIDAR_MAX_RANGE).all() assert (ranges < LIDAR_MAX_RANGE).any()
# Verify the closest hit is near 5m (sheep at distance 5).
assert float(ranges.min()) < 5.3
def test_simulate_scan_wall_hit(): def test_simulate_scan_wall_hit():
+84
View File
@@ -0,0 +1,84 @@
"""Benchmark LiDAR perception improvements.
Measures success rate, mean steps, and tracker quality metrics for
demo collection across multiple seeds. Compares configurations.
Usage::
python -m tools.benchmark_lidar --n-sheep 5 --seeds 15
HERDING_WORLD=field_round python -m tools.benchmark_lidar --n-sheep 5
"""
from __future__ import annotations
import argparse
import time
from collections import Counter
from training.bc.collect import collect_one
from herding.control.universal import compute_action
def run_benchmark(n_sheep: int, n_seeds: int, max_steps: int = 100000,
drive_mode: str = "differential"):
results = []
t0 = time.time()
for seed in range(n_seeds):
obs, actions, success, steps = collect_one(
n_sheep, seed, max_steps, 5, compute_action,
frame_stack=1, privileged=False, drive_mode=drive_mode,
)
results.append({
"seed": seed,
"success": success,
"steps": steps,
"logged": len(obs),
})
tag = "+" if success else "x"
print(f" [{tag}] seed={seed:>2d} steps={steps:>6d}")
elapsed = time.time() - t0
successes = [r for r in results if r["success"]]
failures = [r for r in results if not r["success"]]
n_ok = len(successes)
rate = 100.0 * n_ok / len(results)
mean_steps_ok = (sum(r["steps"] for r in successes) / n_ok) if n_ok else 0
mean_steps_all = sum(r["steps"] for r in results) / len(results)
print(f"\n Results: {n_ok}/{len(results)} success ({rate:.0f}%)")
print(f" Mean steps (success): {mean_steps_ok:>8.0f}")
print(f" Mean steps (all): {mean_steps_all:>8.0f}")
print(f" Elapsed: {elapsed:.0f}s")
return {
"n_sheep": n_sheep,
"n_seeds": n_seeds,
"success_rate": rate,
"n_success": n_ok,
"mean_steps_success": mean_steps_ok,
"mean_steps_all": mean_steps_all,
"elapsed_s": elapsed,
}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--n-sheep", type=int, default=5)
parser.add_argument("--seeds", type=int, default=15)
parser.add_argument("--max-steps", type=int, default=100000)
parser.add_argument("--drive-mode", default="differential",
choices=["differential", "mecanum"])
args = parser.parse_args()
from herding.world.geometry import FIELD_SHAPE
print(f"[bench] world={FIELD_SHAPE} n_sheep={args.n_sheep} "
f"seeds={args.seeds} drive={args.drive_mode}")
print()
result = run_benchmark(args.n_sheep, args.seeds, args.max_steps,
args.drive_mode)
print()
print("[bench] summary:", result)
if __name__ == "__main__":
main()
+96 -14
View File
@@ -5,38 +5,109 @@
# then execs Webots on it. # then execs Webots on it.
# #
# Usage: # Usage:
# tools/run_webots.sh [N] [MODE] # tools/run_webots.sh [N] [MODE] [DRIVE] [WORLD]
# N : number of active sheep (1..10), default 10 # N : number of active sheep (1..10), default 10
# MODE : "bc" | "rl" | "strombom" | "sequential", default "bc" # MODE : "bc" | "rl" | "strombom" | "sequential", default "bc"
# DRIVE : "differential" | "mecanum", default "differential"
# WORLD : base world name (without .wbt), default "field"
# Supported: "field" (rectangular), "field_round" (circular)
# #
# Examples: # Examples:
# tools/run_webots.sh 10 bc # behaviour-cloned MLP, 10 sheep # tools/run_webots.sh 10 bc # behaviour-cloned MLP, diff drive
# tools/run_webots.sh 10 rl # KL-PPO fine-tune of bc, 10 sheep # tools/run_webots.sh 10 rl mecanum # KL-PPO fine-tune, mecanum wheels
# tools/run_webots.sh 5 sequential # single-target analytic baseline # tools/run_webots.sh 5 sequential field_round # analytic baseline, round field
# tools/run_webots.sh 3 strombom # canonical Strömbom analytic # tools/run_webots.sh 3 strombom mecanum field_round # Strömbom, mecanum, round
# #
# Notes: # Notes:
# * bc loads training/runs/bc/policy.zip, rl loads training/runs/rl. # * bc loads training/runs/bc/policy.zip, rl loads training/runs/rl.
# Override via HERDING_POLICY_DIR=/path/to/run env var. # Override via HERDING_POLICY_DIR=/path/to/run env var.
# * Conda env "tir" must be active (provides stable-baselines3 + torch). # * Conda env "tir" must be active (provides stable-baselines3 + torch).
#
# Headless-ish (no 3D view, fast sim, no modal dialogs):
# WEBOTS_HEADLESS=1 make webots N=10 MODE=rl DRIVE=mecanum
# WEBOTS_HEADLESS=1 tools/run_webots.sh 10 rl mecanum
# This passes --no-rendering --minimize --mode=fast --batch to webots.
# Webots still needs a display (Qt); on a machine without one use e.g.:
# xvfb-run -a env WEBOTS_HEADLESS=1 tools/run_webots.sh 10 rl mecanum
# Optional extra CLI tokens (space-separated):
# WEBOTS_EXTRA_ARGS="--stdout --stderr" WEBOTS_HEADLESS=1 tools/run_webots.sh 10 rl
set -e set -e
N=${1:-10} N=${1:-10}
MODE=${2:-bc} MODE=${2:-bc}
DRIVE=${3:-differential}
WORLD=${4:-field}
if (( N < 1 || N > 10 )); then if (( N < 1 || N > 10 )); then
echo "N must be 1..10, got $N" >&2; exit 1 echo "N must be 1..10, got $N" >&2; exit 1
fi fi
case "$MODE" in case "$MODE" in
bc|rl|strombom|sequential) ;; bc|rl|strombom|sequential|universal) ;;
*) echo "MODE must be bc|rl|strombom|sequential, got '$MODE'" >&2; exit 1 ;; *) echo "MODE must be bc|rl|strombom|sequential|universal, got '$MODE'" >&2; exit 1 ;;
esac
case "$DRIVE" in
differential|mecanum) ;;
*) echo "DRIVE must be differential|mecanum, got '$DRIVE'" >&2; exit 1 ;;
esac esac
ROOT="$( cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd )" ROOT="$( cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd )"
SRC="$ROOT/worlds/field.wbt" SRC="$ROOT/worlds/${WORLD}.wbt"
DST="$ROOT/worlds/field_test.wbt" if [[ ! -f "$SRC" ]]; then
echo "World file not found: $SRC" >&2; exit 1
fi
DST="$ROOT/worlds/${WORLD}_test.wbt"
if [[ -n "${HERDING_POLICY_DIR:-}" ]]; then
RESOLVED_POLICY_DIR="$HERDING_POLICY_DIR"
else
# Try drive-mode-specific path first, then legacy path.
if [[ "$MODE" == "rl" ]]; then
DRIVED="$ROOT/training/runs/rl_${DRIVE}"
LEGACY="$ROOT/training/runs/rl"
else
DRIVED="$ROOT/training/runs/bc_${DRIVE}"
LEGACY="$ROOT/training/runs/bc"
fi
if [[ -d "$DRIVED" ]]; then
RESOLVED_POLICY_DIR="$DRIVED"
else
RESOLVED_POLICY_DIR="$LEGACY"
fi
fi
cp "$SRC" "$DST" cp "$SRC" "$DST"
# Swap robot proto based on drive mode.
# Base worlds reference ShepherdDog (diff-drive). For mecanum we swap in
# ShepherdDogMecanum and inject mecanum contact properties.
if [[ "$DRIVE" == "mecanum" ]]; then
sed -i 's|"../protos/ShepherdDog.proto"|"../protos/ShepherdDogMecanum.proto"|' "$DST"
sed -i 's|^ShepherdDog {|ShepherdDogMecanum {|' "$DST"
# Inject mecanum contact properties after the existing contactProperties block.
python3 -c "
import re, sys
with open(sys.argv[1], 'r') as f:
txt = f.read()
# Find the closing ']' of contactProperties and insert before it.
mec = '''
ContactProperties {
material1 \"MecanumWheel\"
coulombFriction [
2
]
bounce 0
forceDependentSlip [
10
]
softCFM 0.0001
}'''
# Insert before the first ']' that closes contactProperties [...]
txt = re.sub(r'(contactProperties\s*\[[^\]]*)(\])', r'\1' + mec + r'\2', txt, count=1)
with open(sys.argv[1], 'w') as f:
f.write(txt)
" "$DST"
fi
# Comment out sheep N+1..10 by prefixing the matching Sheep { ... } line. # Comment out sheep N+1..10 by prefixing the matching Sheep { ... } line.
for i in $(seq $((N+1)) 10); do for i in $(seq $((N+1)) 10); do
sed -i "s|^Sheep .* \"sheep${i}\".*|# &|" "$DST" sed -i "s|^Sheep .* \"sheep${i}\".*|# &|" "$DST"
@@ -46,20 +117,24 @@ active=$(grep -c '^Sheep' "$DST")
echo "------------------------------------------------------------" echo "------------------------------------------------------------"
echo "World : $DST" echo "World : $DST"
echo "Mode : $MODE" echo "Mode : $MODE"
echo "Drive : $DRIVE"
echo "Sheep : $active active" echo "Sheep : $active active"
echo "Policy dir : ${HERDING_POLICY_DIR:-$ROOT/training/runs/bc}" echo "Policy dir : $RESOLVED_POLICY_DIR"
echo "------------------------------------------------------------" echo "------------------------------------------------------------"
# Webots strips HERDING_* env vars from controller subprocesses in some # Webots strips HERDING_* env vars from controller subprocesses in some
# setups, so we also write a runtime config file the controller reads. # setups, so we also write a runtime config file the controller reads.
RESOLVED_POLICY_DIR="${HERDING_POLICY_DIR:-$ROOT/training/runs/bc}"
cat > "$ROOT/herding_runtime.cfg" <<EOF cat > "$ROOT/herding_runtime.cfg" <<EOF
HERDING_MODE=$MODE HERDING_MODE=$MODE
HERDING_POLICY_DIR=$RESOLVED_POLICY_DIR HERDING_POLICY_DIR=$RESOLVED_POLICY_DIR
HERDING_DRIVE=$DRIVE
HERDING_WORLD=$WORLD
EOF EOF
export HERDING_MODE="$MODE" export HERDING_MODE="$MODE"
export HERDING_POLICY_DIR="$RESOLVED_POLICY_DIR" export HERDING_POLICY_DIR="$RESOLVED_POLICY_DIR"
export HERDING_DRIVE="$DRIVE"
export HERDING_WORLD="$WORLD"
# The controller writes this sentinel when all GT sheep are penned. We # The controller writes this sentinel when all GT sheep are penned. We
# poll for it and kill Webots so the run finishes cleanly instead of # poll for it and kill Webots so the run finishes cleanly instead of
@@ -68,7 +143,14 @@ DONE_FILE="$ROOT/training/.run_done"
mkdir -p "$(dirname "$DONE_FILE")" mkdir -p "$(dirname "$DONE_FILE")"
rm -f "$DONE_FILE" rm -f "$DONE_FILE"
webots "$DST" & if [[ "${WEBOTS_HEADLESS:-}" == "1" ]]; then
echo "[run_webots] headless flags: --no-rendering --minimize --mode=fast --batch"
# shellcheck disable=SC2086
webots --no-rendering --minimize --mode=fast --batch ${WEBOTS_EXTRA_ARGS:-} "$DST" &
else
# shellcheck disable=SC2086
webots ${WEBOTS_EXTRA_ARGS:-} "$DST" &
fi
WEBOTS_PID=$! WEBOTS_PID=$!
cleanup() { cleanup() {
+6 -20
View File
@@ -1,4 +1,9 @@
# Training pipeline # Training and Evaluation Details
This file is the command-level companion to the root README. It focuses
on data collection, BC, PPO fine-tuning, evaluation flags, and generated
artifacts; use the root README for the high-level architecture and
Webots demo quick start.
Two stages, strictly sequential: Two stages, strictly sequential:
@@ -26,16 +31,6 @@ runs/ — checkpoints (whitelisted entries in top-level .gitignore)
run with ``python -m pytest tests/``.) run with ``python -m pytest tests/``.)
``` ```
## Setup
```
pip install -r requirements.txt
```
CPU is the default and recommended device — SB3 PPO with an MLP policy
of this size runs faster on CPU than GPU because the bottleneck is
rollout collection, not gradient compute.
## End-to-end pipeline ## End-to-end pipeline
The simplest way to run everything is the Makefile at the project The simplest way to run everything is the Makefile at the project
@@ -93,12 +88,3 @@ python -m training.eval --policy strombom --max-flock 10 --max-steps 15000 --
python -m training.eval --policy sequential --max-flock 10 --max-steps 15000 --n-seeds 10 python -m training.eval --policy sequential --max-flock 10 --max-steps 15000 --n-seeds 10
``` ```
## Webots inference
```
tools/run_webots.sh 10 bc # or rl, strombom, sequential
```
The dog controller loads `runs/bc` for `bc` mode and `runs/rl` for
`rl` mode. Override with `HERDING_POLICY_DIR=…` for a specific
checkpoint.
+82 -15
View File
@@ -15,51 +15,102 @@ Usage::
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import os
import time import time
from pathlib import Path from pathlib import Path
import numpy as np import numpy as np
# Early CLI parse so we can configure geometry before heavy imports.
# (argparse is used again below for the full parse; this is a lightweight
# pre-pass that only reads --world.)
_pre_argv = [a for a in os.sys.argv[1:]]
_pre_world = None
for i, a in enumerate(_pre_argv):
if a == "--world" and i + 1 < len(_pre_argv):
_pre_world = _pre_argv[i + 1]
break
if a.startswith("--world="):
_pre_world = a.split("=", 1)[1]
break
if _pre_world is not None:
from herding.world.geometry import configure as _geo_configure
_geo_configure(_pre_world)
os.environ["HERDING_WORLD"] = _pre_world
from herding.control.active_scan import ActiveScanTeacher from herding.control.active_scan import ActiveScanTeacher
from herding.world.geometry import PEN_ENTRY from herding.world.geometry import PEN_ENTRY, FIELD_SHAPE
from herding.control.sequential import compute_action as sequential_action from herding.control.sequential import compute_action as sequential_action
from herding.control.strombom import compute_action as strombom_action from herding.control.strombom import compute_action as strombom_action
from herding.control.universal import compute_action as universal_action
from training.herding_env import HerdingEnv from training.herding_env import HerdingEnv
TEACHERS = { TEACHERS = {
"sequential": sequential_action, "sequential": sequential_action,
"strombom": strombom_action, "strombom": strombom_action,
"universal": universal_action,
} }
def _call_teacher(fn, dog_xy, dog_heading, sheep_positions, pen_target,
drive_mode="differential"):
"""Call any teacher function and return (vx, vy, omega, mode).
Normalizes across 3-tuple teachers (vx, vy, mode) and 4-tuple
universal teacher (vx, vy, omega, mode). ActiveScanTeacher (when
invoked with drive_mode="mecanum") propagates the base teacher's
omega — see test_active_scan_preserves_mecanum_omega.
"""
# The universal teacher and ActiveScanTeacher accept the extended
# (dog_xy, heading, sheep, pen, drive_mode) signature. Older
# teachers accept (dog_xy, sheep, pen). Detect by trying the
# extended call first.
try:
result = fn(dog_xy, dog_heading, sheep_positions, pen_target,
drive_mode)
except TypeError:
try:
result = fn(dog_xy, dog_heading, sheep_positions, pen_target)
except TypeError:
result = fn(dog_xy, sheep_positions, pen_target)
if len(result) == 4:
return result # (vx, vy, omega, mode)
vx, vy, mode = result
return vx, vy, 0.0, mode
def collect_one(n_sheep: int, seed: int, max_steps: int, subsample: int, def collect_one(n_sheep: int, seed: int, max_steps: int, subsample: int,
teacher_fn, frame_stack: int = 1, privileged: bool = False): teacher_fn, frame_stack: int = 1, privileged: bool = False,
drive_mode: str = "differential"):
env = HerdingEnv(n_sheep=n_sheep, max_steps=max_steps, env = HerdingEnv(n_sheep=n_sheep, max_steps=max_steps,
difficulty=1.0, seed=seed, frame_stack=frame_stack) difficulty=1.0, seed=seed, frame_stack=frame_stack,
drive_mode=drive_mode)
obs, _ = env.reset(seed=seed) obs, _ = env.reset(seed=seed)
obs_list, action_list = [], [] obs_list, action_list = [], []
# Wrap the base teacher so it opens with a rotation and walks to
# centre when the tracker briefly empties — matches the student.
scan_teacher = ActiveScanTeacher(teacher_fn) scan_teacher = ActiveScanTeacher(teacher_fn)
for step in range(max_steps): for step in range(max_steps):
if privileged: if privileged:
# Asymmetric variant: teacher reads ground truth while the
# student keeps the LiDAR obs. Default off.
positions = {f"s{i}": (float(env.sheep_x[i]), float(env.sheep_y[i])) positions = {f"s{i}": (float(env.sheep_x[i]), float(env.sheep_y[i]))
for i in range(env.n_sheep) if not env.sheep_penned[i]} for i in range(env.n_sheep) if not env.sheep_penned[i]}
if not positions: if not positions:
break break
vx, vy, _mode = teacher_fn( vx, vy, omega, _mode = _call_teacher(
(env.dog_x, env.dog_y), positions, PEN_ENTRY, teacher_fn, (env.dog_x, env.dog_y), env.dog_heading,
positions, PEN_ENTRY, drive_mode,
) )
else: else:
positions = env.perceived_positions() positions = env.perceived_positions()
vx, vy, _mode = scan_teacher( result = _call_teacher(
(env.dog_x, env.dog_y), env.dog_heading, scan_teacher, (env.dog_x, env.dog_y), env.dog_heading,
positions, PEN_ENTRY, positions, PEN_ENTRY, drive_mode,
) )
action = np.array([vx, vy], dtype=np.float32) vx, vy, omega, _mode = result
if drive_mode == "mecanum":
action = np.array([vx, vy, omega], dtype=np.float32)
else:
action = np.array([vx, vy], dtype=np.float32)
if step % subsample == 0: if step % subsample == 0:
obs_list.append(obs.copy()) obs_list.append(obs.copy())
action_list.append(action.copy()) action_list.append(action.copy())
@@ -85,7 +136,7 @@ def main():
help="Keep every Nth (obs, action) pair.") help="Keep every Nth (obs, action) pair.")
parser.add_argument("--keep-failures", action="store_true", parser.add_argument("--keep-failures", action="store_true",
help="Include partial-success trajectories. Default off.") help="Include partial-success trajectories. Default off.")
parser.add_argument("--teacher", default="sequential", parser.add_argument("--teacher", default="universal",
choices=list(TEACHERS.keys()), choices=list(TEACHERS.keys()),
help="Which analytic teacher to demonstrate.") help="Which analytic teacher to demonstrate.")
parser.add_argument("--frame-stack", type=int, default=1, parser.add_argument("--frame-stack", type=int, default=1,
@@ -94,9 +145,24 @@ def main():
parser.add_argument("--privileged", action="store_true", parser.add_argument("--privileged", action="store_true",
help="Teacher reads ground truth instead of " help="Teacher reads ground truth instead of "
"tracker output (asymmetric BC).") "tracker output (asymmetric BC).")
parser.add_argument("--drive-mode", default="differential",
choices=["differential", "mecanum"],
help="Drive mode for the dog robot.")
parser.add_argument("--world", default=None,
choices=["field", "field_round"],
help="World shape. If not set, uses HERDING_WORLD "
"env var or defaults to 'field'. Must be set "
"before geometry is imported.")
args = parser.parse_args() args = parser.parse_args()
# Validate --world matches geometry (already configured by the
# early pre-parse above, or by HERDING_WORLD env var).
if args.world is not None and args.world != FIELD_SHAPE:
print(f"[demos] WARNING: --world={args.world} but geometry is "
f"'{FIELD_SHAPE}'. This should not happen — file a bug.")
teacher_fn = TEACHERS[args.teacher] teacher_fn = TEACHERS[args.teacher]
print(f"[demos] teacher: {args.teacher}") print(f"[demos] teacher: {args.teacher} world: {FIELD_SHAPE}")
n_sheep_list = [int(x) for x in args.n_sheep_list.split(",")] n_sheep_list = [int(x) for x in args.n_sheep_list.split(",")]
print(f"[demos] grid: n_sheep={n_sheep_list}, seeds={args.seeds_per_n}, " print(f"[demos] grid: n_sheep={n_sheep_list}, seeds={args.seeds_per_n}, "
@@ -111,6 +177,7 @@ def main():
obs, actions, success, total_steps = collect_one( obs, actions, success, total_steps = collect_one(
n, seed, args.max_steps, args.subsample, teacher_fn, n, seed, args.max_steps, args.subsample, teacher_fn,
frame_stack=args.frame_stack, privileged=args.privileged, frame_stack=args.frame_stack, privileged=args.privileged,
drive_mode=args.drive_mode,
) )
n_total += 1 n_total += 1
if success: if success:
Binary file not shown.
+19 -3
View File
@@ -35,14 +35,15 @@ from training.herding_env import HerdingEnv
def build_model(net_arch_pi, net_arch_vf, log_std_init: float, def build_model(net_arch_pi, net_arch_vf, log_std_init: float,
frame_stack: int = 1): frame_stack: int = 1, drive_mode: str = "differential"):
"""Build a fresh SB3 PPO solely as a vehicle for the policy weights. """Build a fresh SB3 PPO solely as a vehicle for the policy weights.
PPO's training-loop plumbing isn't used during BC. ``frame_stack`` PPO's training-loop plumbing isn't used during BC. ``frame_stack``
must match the demo file so the env's obs space agrees with the must match the demo file so the env's obs space agrees with the
recorded obs shape. recorded obs shape.
""" """
env = DummyVecEnv([lambda: HerdingEnv(frame_stack=frame_stack)]) env = DummyVecEnv([lambda: HerdingEnv(frame_stack=frame_stack,
drive_mode=drive_mode)])
model = PPO( model = PPO(
"MlpPolicy", env, "MlpPolicy", env,
policy_kwargs=dict( policy_kwargs=dict(
@@ -83,6 +84,10 @@ def main():
"term; balances against MSE.") "term; balances against MSE.")
parser.add_argument("--seed", type=int, default=0) parser.add_argument("--seed", type=int, default=0)
parser.add_argument("--device", default="cpu") parser.add_argument("--device", default="cpu")
parser.add_argument("--drive-mode", default=None,
choices=["differential", "mecanum"],
help="Drive mode. If not set, inferred from "
"demo action dimension (2→differential, 3→mecanum).")
args = parser.parse_args() args = parser.parse_args()
torch.manual_seed(args.seed) torch.manual_seed(args.seed)
@@ -130,8 +135,19 @@ def main():
frame_stack = obs_dim // _SINGLE frame_stack = obs_dim // _SINGLE
if frame_stack > 1: if frame_stack > 1:
print(f"[bc] inferred frame_stack={frame_stack} from demo obs dim {obs_dim}") print(f"[bc] inferred frame_stack={frame_stack} from demo obs dim {obs_dim}")
# Infer drive mode from action dimension if not explicitly set.
action_dim = actions.shape[1]
if args.drive_mode is not None:
drive_mode = args.drive_mode
elif action_dim == 3:
drive_mode = "mecanum"
else:
drive_mode = "differential"
print(f"[bc] drive_mode={drive_mode} (action_dim={action_dim})")
model, _env = build_model(net_arch_pi, net_arch_vf, args.log_std_init, model, _env = build_model(net_arch_pi, net_arch_vf, args.log_std_init,
frame_stack=frame_stack) frame_stack=frame_stack, drive_mode=drive_mode)
policy = model.policy.to(args.device) policy = model.policy.to(args.device)
optimizer = optim.Adam(policy.parameters(), lr=args.lr) optimizer = optim.Adam(policy.parameters(), lr=args.lr)
+45 -6
View File
@@ -12,11 +12,28 @@ Usage::
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import os
from pathlib import Path from pathlib import Path
from statistics import mean from statistics import mean
import numpy as np import numpy as np
# Early CLI pre-parse for --world so geometry is configured before
# other herding.* modules are imported.
_pre_argv = [a for a in os.sys.argv[1:]]
_pre_world = None
for i, a in enumerate(_pre_argv):
if a == "--world" and i + 1 < len(_pre_argv):
_pre_world = _pre_argv[i + 1]
break
if a.startswith("--world="):
_pre_world = a.split("=", 1)[1]
break
if _pre_world is not None:
from herding.world.geometry import configure as _geo_configure
_geo_configure(_pre_world)
os.environ["HERDING_WORLD"] = _pre_world
from herding.world.geometry import MAX_SHEEP, PEN_ENTRY from herding.world.geometry import MAX_SHEEP, PEN_ENTRY
from herding.control.sequential import compute_action as sequential_action from herding.control.sequential import compute_action as sequential_action
from herding.control.strombom import compute_action as strombom_action from herding.control.strombom import compute_action as strombom_action
@@ -38,18 +55,20 @@ def rollout(env: HerdingEnv, predict_fn, max_steps: int) -> dict:
"n_penned": int(env.sheep_penned.sum())} "n_penned": int(env.sheep_penned.sum())}
def make_analytic_predictor(action_fn): def make_analytic_predictor(action_fn, drive_mode: str = "differential"):
"""Wrap an analytic teacher so it runs on the env's exposed """Wrap an analytic teacher so it runs on the env's exposed
perception (tracker in LiDAR mode, GT in privileged mode).""" perception (tracker in LiDAR mode, GT in privileged mode)."""
def _predict(env, _obs): def _predict(env, _obs):
positions = env.perceived_positions() positions = env.perceived_positions()
vx, vy, _mode = action_fn((env.dog_x, env.dog_y), positions, PEN_ENTRY) vx, vy, _mode = action_fn((env.dog_x, env.dog_y), positions, PEN_ENTRY)
if drive_mode == "mecanum":
return np.array([vx, vy, 0.0], dtype=np.float32)
return np.array([vx, vy], dtype=np.float32) return np.array([vx, vy], dtype=np.float32)
return _predict return _predict
def make_strombom_predictor(): def make_strombom_predictor(drive_mode: str = "differential"):
return make_analytic_predictor(strombom_action) return make_analytic_predictor(strombom_action, drive_mode)
def make_policy_predictor(model, vecnorm): def make_policy_predictor(model, vecnorm):
@@ -73,13 +92,21 @@ def main():
parser.add_argument("--difficulty", type=float, default=1.0, parser.add_argument("--difficulty", type=float, default=1.0,
help="0 = sheep spawn near the gate (easy); " help="0 = sheep spawn near the gate (easy); "
"1 = full field (deployment distribution).") "1 = full field (deployment distribution).")
parser.add_argument("--drive-mode", default="differential",
choices=["differential", "mecanum"],
help="Drive mode for the dog robot.")
parser.add_argument("--world", default=None,
choices=["field", "field_round"],
help="World shape. If not set, uses HERDING_WORLD "
"env var or defaults to 'field'.")
args = parser.parse_args() args = parser.parse_args()
drive_mode = args.drive_mode
frame_stack = 1 frame_stack = 1
if args.policy == "strombom": if args.policy == "strombom":
predict = make_analytic_predictor(strombom_action) predict = make_analytic_predictor(strombom_action, drive_mode)
elif args.policy == "sequential": elif args.policy == "sequential":
predict = make_analytic_predictor(sequential_action) predict = make_analytic_predictor(sequential_action, drive_mode)
else: else:
from stable_baselines3 import PPO from stable_baselines3 import PPO
run = Path(args.policy) run = Path(args.policy)
@@ -114,6 +141,18 @@ def main():
vecnorm.norm_reward = False vecnorm.norm_reward = False
predict = make_policy_predictor(model, vecnorm) predict = make_policy_predictor(model, vecnorm)
# Infer drive_mode from policy action dim if using a learned policy.
if args.policy not in ("strombom", "sequential"):
policy_action_dim = int(model.action_space.shape[0])
if policy_action_dim == 2 and drive_mode == "mecanum":
drive_mode = "differential"
print(f"[eval] policy has 2D actions — overriding drive_mode "
f"to differential")
elif policy_action_dim == 3 and drive_mode == "differential":
drive_mode = "mecanum"
print(f"[eval] policy has 3D actions — overriding drive_mode "
f"to mecanum")
print(f"{'n_sheep':>8} {'success%':>10} {'mean_steps':>12} {'mean_penned':>12}") print(f"{'n_sheep':>8} {'success%':>10} {'mean_steps':>12} {'mean_penned':>12}")
print("-" * 46) print("-" * 46)
for n in range(1, args.max_flock + 1): for n in range(1, args.max_flock + 1):
@@ -121,7 +160,7 @@ def main():
for seed in range(args.n_seeds): for seed in range(args.n_seeds):
env = HerdingEnv(n_sheep=n, max_steps=args.max_steps, env = HerdingEnv(n_sheep=n, max_steps=args.max_steps,
difficulty=args.difficulty, seed=seed, difficulty=args.difficulty, seed=seed,
frame_stack=frame_stack) frame_stack=frame_stack, drive_mode=drive_mode)
r = rollout(env, predict, args.max_steps) r = rollout(env, predict, args.max_steps)
successes.append(int(r["success"])) successes.append(int(r["success"]))
steps.append(r["steps"]) steps.append(r["steps"])
+105 -41
View File
@@ -1,11 +1,12 @@
"""Gymnasium environment for the shepherd-dog herding task. """Gymnasium environment for the shepherd-dog herding task.
Single-agent: the dog is the policy; sheep are env-controlled flocking Single-agent: the dog is the policy; sheep are env-controlled flocking
agents (``herding.world.flocking_sim``). Differential-drive kinematics agents (``herding.world.flocking_sim``). Kinematics match the proto specs
match the proto specs (``herding.world.diffdrive``) so a policy trained (``herding.world.diffdrive``) so a policy trained here transfers to Webots
here transfers to Webots without re-tuning. without re-tuning.
* **Action**: ``Box(-1, 1, (2,))`` — desired ``(vx, vy)`` intent. * **Action** (differential): ``Box(-1, 1, (2,))`` — ``(vx, vy)`` intent.
* **Action** (mecanum): ``Box(-1, 1, (3,))`` — ``(vx, vy, omega)`` intent.
* **Observation**: ``Box(-inf, inf, (32·K,))`` from ``herding.perception.obs.build_obs`` * **Observation**: ``Box(-inf, inf, (32·K,))`` from ``herding.perception.obs.build_obs``
with optional frame stacking K (concatenated oldest → newest). with optional frame stacking K (concatenated oldest → newest).
* **Reset**: ``options["n_sheep"]`` overrides flock size; otherwise * **Reset**: ``options["n_sheep"]`` overrides flock size; otherwise
@@ -26,17 +27,20 @@ import numpy as np
from gymnasium import spaces from gymnasium import spaces
from herding.world.diffdrive import ( from herding.world.diffdrive import (
heading_speed_to_wheels, kinematics_step, velocity_to_wheels, heading_speed_to_wheels, kinematics_step,
mecanum_kinematics_step, velocity_to_mecanum_wheels, velocity_to_wheels,
) )
from herding.world.flocking_sim import ( from herding.world.flocking_sim import (
FLEE_SPEED, MAX_SPEED, WANDER_SPEED, compute_heading_speed, FLEE_SPEED, MAX_SPEED, WANDER_SPEED, compute_heading_speed,
) )
from herding.world.geometry import ( from herding.world.geometry import (
DOG_MAX_LINEAR, DOG_MAX_WHEEL_OMEGA, DOG_SOUTH_LIMIT, DOG_WHEEL_BASE, DOG_MAX_LINEAR, DOG_MAX_WHEEL_OMEGA,
DOG_WHEEL_RADIUS, FIELD_X, FIELD_Y, GATE_X, MAX_SHEEP, DOG_SOUTH_LIMIT, DOG_WHEEL_BASE, DOG_WHEEL_BASE_X, DOG_WHEEL_BASE_Y,
DOG_WHEEL_RADIUS, FIELD_SHAPE, FIELD_ROUND_R, FIELD_X, FIELD_Y,
GATE_X, GATE_Y, MAX_SHEEP,
PEN_ENTRY, PEN_X, PEN_Y, PEN_ENTRY, PEN_X, PEN_Y,
SHEEP_MAX_WHEEL_OMEGA, SHEEP_WHEEL_BASE, SHEEP_WHEEL_RADIUS, SHEEP_MAX_WHEEL_OMEGA, SHEEP_WHEEL_BASE, SHEEP_WHEEL_RADIUS,
WEBOTS_DT, is_penned_position, WEBOTS_DT, clip_to_field, is_penned_position,
) )
from herding.perception.lidar_perception import detections_from_scan from herding.perception.lidar_perception import detections_from_scan
from herding.perception.lidar_sim import simulate_scan from herding.perception.lidar_sim import simulate_scan
@@ -82,6 +86,7 @@ class HerdingEnv(gym.Env):
seed: Optional[int] = None, seed: Optional[int] = None,
use_lidar: bool = True, use_lidar: bool = True,
frame_stack: int = 1, frame_stack: int = 1,
drive_mode: str = "differential",
): ):
super().__init__() super().__init__()
# ``use_lidar=True`` (default): obs and imitation-reward teacher # ``use_lidar=True`` (default): obs and imitation-reward teacher
@@ -95,7 +100,14 @@ class HerdingEnv(gym.Env):
# giving a memoryless MLP temporal context. K=1 → single frame. # giving a memoryless MLP temporal context. K=1 → single frame.
self._frame_stack = max(1, int(frame_stack)) self._frame_stack = max(1, int(frame_stack))
self._frame_buffer: list[np.ndarray] = [] self._frame_buffer: list[np.ndarray] = []
self.action_space = spaces.Box(-1.0, 1.0, shape=(2,), dtype=np.float32)
# Drive mode: "differential" (2-wheel) or "mecanum" (4-wheel omni).
self._drive_mode = drive_mode.lower()
if self._drive_mode not in ("differential", "mecanum"):
raise ValueError(f"Unknown drive_mode: {drive_mode!r}")
action_dim = 3 if self._drive_mode == "mecanum" else 2
self.action_space = spaces.Box(-1.0, 1.0, shape=(action_dim,),
dtype=np.float32)
self._single_obs_dim = OBS_DIM self._single_obs_dim = OBS_DIM
self.observation_space = spaces.Box( self.observation_space = spaces.Box(
low=-np.inf, high=np.inf, low=-np.inf, high=np.inf,
@@ -110,6 +122,11 @@ class HerdingEnv(gym.Env):
# 1 = sheep spawn anywhere in the field (deployment distribution). # 1 = sheep spawn anywhere in the field (deployment distribution).
self._difficulty = float(difficulty) self._difficulty = float(difficulty)
self._initial_seed = seed self._initial_seed = seed
self._initial_seed_used = False
# Env-owned RNG for wander jitter, re-seeded from np_random on reset.
self._py_rng = random.Random()
self._action_dim = action_dim
# State (initialised in reset) # State (initialised in reset)
self.dog_x = self.dog_y = self.dog_heading = 0.0 self.dog_x = self.dog_y = self.dog_heading = 0.0
@@ -119,17 +136,14 @@ class HerdingEnv(gym.Env):
self.sheep_penned = np.zeros(0, dtype=bool) self.sheep_penned = np.zeros(0, dtype=bool)
self.sheep_wander = np.zeros(0, dtype=np.float32) self.sheep_wander = np.zeros(0, dtype=np.float32)
self.prev_action = np.zeros(2, dtype=np.float32) self.prev_action = np.zeros(self._action_dim, dtype=np.float32)
self.smoothed_action = np.zeros(2, dtype=np.float32) self.smoothed_action = np.zeros(self._action_dim, dtype=np.float32)
self.steps = 0 self.steps = 0
self.n_sheep = 0 self.n_sheep = 0
self.prev_n_penned = 0 self.prev_n_penned = 0
self.prev_d_pen = 0.0 self.prev_d_pen = 0.0
self.prev_radius = 0.0 self.prev_radius = 0.0
# Env-owned RNG for wander jitter, re-seeded from np_random on reset.
self._py_rng = random.Random()
# --- Public knobs --- # --- Public knobs ---
def set_max_n_sheep(self, value: int) -> None: def set_max_n_sheep(self, value: int) -> None:
self._max_n_sheep = int(np.clip(value, 1, MAX_SHEEP)) self._max_n_sheep = int(np.clip(value, 1, MAX_SHEEP))
@@ -149,6 +163,10 @@ class HerdingEnv(gym.Env):
# --- gym API --- # --- gym API ---
def reset(self, *, seed=None, options=None): def reset(self, *, seed=None, options=None):
if (seed is None and self._initial_seed is not None
and not self._initial_seed_used):
seed = self._initial_seed
self._initial_seed_used = True
super().reset(seed=seed) super().reset(seed=seed)
self._py_rng.seed(int(self.np_random.integers(0, 2**31 - 1))) self._py_rng.seed(int(self.np_random.integers(0, 2**31 - 1)))
opts = options or {} opts = options or {}
@@ -168,16 +186,32 @@ class HerdingEnv(gym.Env):
# Sheep spawn region linearly interpolates with difficulty: # Sheep spawn region linearly interpolates with difficulty:
# 0 → small box near the gate, 1 → full field. # 0 → small box near the gate, 1 → full field.
d = self._difficulty d = self._difficulty
sx_lo = 7.0 - d * 20.0 if FIELD_SHAPE == "field_round":
sx_hi = 14.0 - d * 1.0 # Round field: spawn in a sector near the gate (south),
sy_lo = -12.0 + d * 0.0 # expanding to the full circle at difficulty=1.
sy_hi = -6.0 + d * 19.0 spawn_r_lo = 3.0
spawn_r_hi = d * FIELD_ROUND_R * 0.8 + (1.0 - d) * 6.0
# Angle spread around south (±60° at d=0, full circle at d=1).
half_angle = math.radians(60) + d * math.radians(120)
angle_lo = math.pi / 2.0 - half_angle # from south = -π/2
angle_hi = math.pi / 2.0 + half_angle
else:
sx_lo = 7.0 - d * 20.0
sx_hi = 14.0 - d * 1.0
sy_lo = -12.0 + d * 0.0
sy_hi = -6.0 + d * 19.0
sxs, sys_, shs, sws = [], [], [], [] sxs, sys_, shs, sws = [], [], [], []
for _ in range(self.n_sheep): for _ in range(self.n_sheep):
for _try in range(100): for _try in range(100):
sx = float(self.np_random.uniform(sx_lo, sx_hi)) if FIELD_SHAPE == "field_round":
sy = float(self.np_random.uniform(sy_lo, sy_hi)) r_spawn = float(self.np_random.uniform(spawn_r_lo, spawn_r_hi))
a_spawn = float(self.np_random.uniform(angle_lo, angle_hi))
sx = r_spawn * math.cos(a_spawn)
sy = -r_spawn * math.sin(a_spawn)
else:
sx = float(self.np_random.uniform(sx_lo, sx_hi))
sy = float(self.np_random.uniform(sy_lo, sy_hi))
# Reject if too close to the dog or another sheep, or # Reject if too close to the dog or another sheep, or
# already in the gate column (would start "penned"). # already in the gate column (would start "penned").
if math.hypot(sx - self.dog_x, sy - self.dog_y) < 3.0: if math.hypot(sx - self.dog_x, sy - self.dog_y) < 3.0:
@@ -198,8 +232,8 @@ class HerdingEnv(gym.Env):
self.sheep_wander = np.asarray(sws, dtype=np.float32) self.sheep_wander = np.asarray(sws, dtype=np.float32)
self.sheep_penned = np.zeros(self.n_sheep, dtype=bool) self.sheep_penned = np.zeros(self.n_sheep, dtype=bool)
self.prev_action = np.zeros(2, dtype=np.float32) self.prev_action = np.zeros(self._action_dim, dtype=np.float32)
self.smoothed_action = np.zeros(2, dtype=np.float32) self.smoothed_action = np.zeros(self._action_dim, dtype=np.float32)
self.steps = 0 self.steps = 0
self.prev_n_penned = 0 self.prev_n_penned = 0
self.prev_d_pen, self.prev_radius = self._flock_metrics() self.prev_d_pen, self.prev_radius = self._flock_metrics()
@@ -225,25 +259,46 @@ class HerdingEnv(gym.Env):
) )
self.prev_action = self.smoothed_action.copy() self.prev_action = self.smoothed_action.copy()
vx, vy = float(self.smoothed_action[0]), float(self.smoothed_action[1]) vx, vy = float(self.smoothed_action[0]), float(self.smoothed_action[1])
omega = float(self.smoothed_action[2]) if self._action_dim >= 3 else 0.0
# Safety supervisor — dog stays north of the gate. # Safety supervisor — dog stays north of the gate.
if self.dog_y < DOG_SOUTH_LIMIT and vy < 0.0: if self.dog_y < DOG_SOUTH_LIMIT and vy < 0.0:
vx, vy = 0.0, 1.0 vx, vy = 0.0, 1.0
# Step the dog. # Step the dog.
wL, wR = velocity_to_wheels( if self._drive_mode == "mecanum":
vx, vy, self.dog_heading, w_fl, w_fr, w_rl, w_rr = velocity_to_mecanum_wheels(
max_linear=DOG_MAX_LINEAR, vx, vy, omega, self.dog_heading,
wheel_radius=DOG_WHEEL_RADIUS, max_linear=DOG_MAX_LINEAR,
max_wheel_omega=DOG_MAX_WHEEL_OMEGA, wheel_radius=DOG_WHEEL_RADIUS,
k_turn=4.0, lx=DOG_WHEEL_BASE_X / 2.0, ly=DOG_WHEEL_BASE_Y / 2.0,
) max_wheel_omega=DOG_MAX_WHEEL_OMEGA,
self.dog_x, self.dog_y, self.dog_heading = kinematics_step( k_turn=4.0,
self.dog_x, self.dog_y, self.dog_heading, wheel_base=DOG_WHEEL_BASE,
wL, wR, DOG_WHEEL_RADIUS, DOG_WHEEL_BASE, WEBOTS_DT, )
) self.dog_x, self.dog_y, self.dog_heading = mecanum_kinematics_step(
self.dog_x = float(np.clip(self.dog_x, FIELD_X[0] + 0.3, FIELD_X[1] - 0.3)) self.dog_x, self.dog_y, self.dog_heading,
self.dog_y = float(np.clip(self.dog_y, DOG_SOUTH_LIMIT, FIELD_Y[1] - 0.3)) w_fl, w_fr, w_rl, w_rr,
DOG_WHEEL_RADIUS,
DOG_WHEEL_BASE_X / 2.0, DOG_WHEEL_BASE_Y / 2.0,
WEBOTS_DT,
)
else:
wL, wR = velocity_to_wheels(
vx, vy, self.dog_heading,
max_linear=DOG_MAX_LINEAR,
wheel_radius=DOG_WHEEL_RADIUS,
max_wheel_omega=DOG_MAX_WHEEL_OMEGA,
k_turn=4.0,
)
self.dog_x, self.dog_y, self.dog_heading = kinematics_step(
self.dog_x, self.dog_y, self.dog_heading,
wL, wR, DOG_WHEEL_RADIUS, DOG_WHEEL_BASE, WEBOTS_DT,
)
self.dog_x, self.dog_y = clip_to_field(self.dog_x, self.dog_y, margin=0.3)
# Extra constraint: dog stays north of the gate area.
if self.dog_y < DOG_SOUTH_LIMIT:
self.dog_y = DOG_SOUTH_LIMIT
# Step sheep and update penned flags (GT-based). # Step sheep and update penned flags (GT-based).
for i in range(self.n_sheep): for i in range(self.n_sheep):
@@ -304,13 +359,21 @@ class HerdingEnv(gym.Env):
SHEEP_WHEEL_RADIUS, SHEEP_WHEEL_BASE, WEBOTS_DT, SHEEP_WHEEL_RADIUS, SHEEP_WHEEL_BASE, WEBOTS_DT,
) )
# Wall clipping (south wall absent inside the gate column). # Wall clipping.
nx = float(np.clip(nx, FIELD_X[0] + 0.2, FIELD_X[1] - 0.2)) if FIELD_SHAPE == "field_round":
in_gate_col = PEN_X[0] <= nx <= PEN_X[1] in_gate_col = PEN_X[0] <= nx <= PEN_X[1]
if in_gate_col: if in_gate_col:
ny = float(np.clip(ny, PEN_Y[0] + 0.2, FIELD_Y[1] - 0.2)) # Allow passage through the gate column (south of the wall).
ny = float(np.clip(ny, PEN_Y[0] + 0.2, FIELD_Y[1] - 0.2))
else:
nx, ny = clip_to_field(nx, ny, margin=0.2)
else: else:
ny = float(np.clip(ny, FIELD_Y[0] + 0.2, FIELD_Y[1] - 0.2)) nx = float(np.clip(nx, FIELD_X[0] + 0.2, FIELD_X[1] - 0.2))
in_gate_col = PEN_X[0] <= nx <= PEN_X[1]
if in_gate_col:
ny = float(np.clip(ny, PEN_Y[0] + 0.2, FIELD_Y[1] - 0.2))
else:
ny = float(np.clip(ny, FIELD_Y[0] + 0.2, FIELD_Y[1] - 0.2))
self.sheep_x[i] = nx self.sheep_x[i] = nx
self.sheep_y[i] = ny self.sheep_y[i] = ny
@@ -374,6 +437,7 @@ class HerdingEnv(gym.Env):
(self.dog_x, self.dog_y), self.dog_heading, (self.dog_x, self.dog_y), self.dog_heading,
sheep_xy_list, sheep_penned_list, sheep_xy_list, sheep_penned_list,
n_max=self._max_n_sheep, n_max=self._max_n_sheep,
n_expected=self.n_sheep,
) )
def _build_obs(self) -> np.ndarray: def _build_obs(self) -> np.ndarray:
+65 -4
View File
@@ -20,8 +20,26 @@ Usage::
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import os
from pathlib import Path from pathlib import Path
# Early CLI pre-parse for --world so geometry is configured before any
# herding.* / training.* import binds geometry constants. Matches the
# pattern used by training.bc.collect and training.eval.
_pre_argv = [a for a in os.sys.argv[1:]]
_pre_world = None
for i, a in enumerate(_pre_argv):
if a == "--world" and i + 1 < len(_pre_argv):
_pre_world = _pre_argv[i + 1]
break
if a.startswith("--world="):
_pre_world = a.split("=", 1)[1]
break
if _pre_world is not None:
from herding.world.geometry import configure as _geo_configure
_geo_configure(_pre_world)
os.environ["HERDING_WORLD"] = _pre_world
import numpy as np import numpy as np
import torch as th import torch as th
import torch.nn.functional as F import torch.nn.functional as F
@@ -38,9 +56,14 @@ from training.herding_env import HerdingEnv
# Env factory # Env factory
# -------------------------------------------------------------------- # --------------------------------------------------------------------
def _make_env(rank: int, seed: int, frame_stack: int): def _make_env(rank: int, seed: int, frame_stack: int,
drive_mode: str = "differential",
difficulty: float = 1.0,
max_n_sheep: int = 10):
def _thunk(): def _thunk():
env = HerdingEnv(seed=seed + rank, frame_stack=frame_stack) env = HerdingEnv(seed=seed + rank, frame_stack=frame_stack,
drive_mode=drive_mode, difficulty=difficulty,
max_n_sheep=max_n_sheep)
env = Monitor(env, info_keywords=("is_success", "n_sheep", "n_penned")) env = Monitor(env, info_keywords=("is_success", "n_sheep", "n_penned"))
return env return env
return _thunk return _thunk
@@ -198,13 +221,34 @@ def main() -> None:
help="SB3 per-batch KL early-stop guard.") help="SB3 per-batch KL early-stop guard.")
parser.add_argument("--seed", type=int, default=0) parser.add_argument("--seed", type=int, default=0)
parser.add_argument("--device", default="cpu") parser.add_argument("--device", default="cpu")
parser.add_argument("--drive-mode", default=None,
choices=["differential", "mecanum"],
help="Drive mode. If not set, inferred from "
"BC action dimension (2→differential, 3→mecanum).")
parser.add_argument("--imitate-weight", type=float, default=None, parser.add_argument("--imitate-weight", type=float, default=None,
help="Override env.W_IMITATE (e.g. 0.0 to drop " help="Override env.W_IMITATE (e.g. 0.0 to drop "
"Strömbom imitation during fine-tune).") "Strömbom imitation during fine-tune).")
parser.add_argument("--time-weight", type=float, default=None, parser.add_argument("--time-weight", type=float, default=None,
help="Override env.W_TIME (e.g. -0.1 for a " help="Override env.W_TIME (e.g. -0.1 for a "
"per-step time penalty).") "per-step time penalty).")
parser.add_argument("--difficulty", type=float, default=1.0,
help="HerdingEnv difficulty for PPO rollouts. "
"Must match eval (1.0) to avoid train/eval "
"distribution mismatch.")
parser.add_argument("--max-n-sheep", type=int, default=10,
help="Upper bound on flock size sampled each reset.")
parser.add_argument("--world", default=None,
choices=["field", "field_round"],
help="World shape. If not set, uses HERDING_WORLD "
"env var or defaults to 'field'.")
args = parser.parse_args() args = parser.parse_args()
# --world was already honoured in the early pre-parse above; here we
# just sanity-check that the final argparse view agrees.
if args.world is not None:
from herding.world.geometry import FIELD_SHAPE as _CURRENT_SHAPE
if args.world != _CURRENT_SHAPE:
print(f"[rl] WARNING: --world={args.world} but geometry is "
f"'{_CURRENT_SHAPE}'. File a bug.")
bc_zip = Path(args.bc) / "policy.zip" bc_zip = Path(args.bc) / "policy.zip"
if not bc_zip.exists(): if not bc_zip.exists():
@@ -226,9 +270,26 @@ def main() -> None:
frame_stack = obs_dim // OBS_DIM frame_stack = obs_dim // OBS_DIM
print(f"[rl] BC obs dim {obs_dim} → frame_stack={frame_stack}") print(f"[rl] BC obs dim {obs_dim} → frame_stack={frame_stack}")
env_fns = [_make_env(i, args.seed, frame_stack) for i in range(args.n_envs)] # Infer drive mode from BC action dim if not explicitly set.
bc_action_dim = int(ref_only.action_space.shape[0])
if args.drive_mode is not None:
drive_mode = args.drive_mode
elif bc_action_dim == 3:
drive_mode = "mecanum"
else:
drive_mode = "differential"
print(f"[rl] drive_mode={drive_mode} (BC action_dim={bc_action_dim})")
env_fns = [_make_env(i, args.seed, frame_stack, drive_mode,
difficulty=args.difficulty,
max_n_sheep=args.max_n_sheep)
for i in range(args.n_envs)]
venv = SubprocVecEnv(env_fns) if args.n_envs > 1 else DummyVecEnv(env_fns) venv = SubprocVecEnv(env_fns) if args.n_envs > 1 else DummyVecEnv(env_fns)
eval_venv = DummyVecEnv([_make_env(99, args.seed + 999, frame_stack)]) eval_venv = DummyVecEnv([_make_env(99, args.seed + 999, frame_stack,
drive_mode,
difficulty=args.difficulty,
max_n_sheep=args.max_n_sheep)])
print(f"[rl] difficulty={args.difficulty} max_n_sheep={args.max_n_sheep}")
# Reward-shaping overrides (broadcast to every env instance). # Reward-shaping overrides (broadcast to every env instance).
def _broadcast(method: str, value): def _broadcast(method: str, value):
Binary file not shown.
Binary file not shown.
+537
View File
@@ -0,0 +1,537 @@
#VRML_SIM R2025a utf8
EXTERNPROTO "https://raw.githubusercontent.com/cyberbotics/webots/R2025a/projects/objects/backgrounds/protos/TexturedBackground.proto"
EXTERNPROTO "https://raw.githubusercontent.com/cyberbotics/webots/R2025a/projects/objects/backgrounds/protos/TexturedBackgroundLight.proto"
EXTERNPROTO "https://raw.githubusercontent.com/cyberbotics/webots/R2025a/projects/objects/floors/protos/UnevenTerrain.proto"
EXTERNPROTO "https://raw.githubusercontent.com/cyberbotics/webots/R2025a/projects/appearances/protos/Grass.proto"
EXTERNPROTO "../protos/ShepherdDog.proto"
EXTERNPROTO "../protos/Sheep.proto"
# World
WorldInfo {
info [
"Autonomous Shepherd Robot (Strömbom)"
"Group G25"
]
title "Shepherd Herding (Round)"
ERP 0.62
basicTimeStep 16
contactProperties [
ContactProperties {
coulombFriction [
12
]
softCFM 1e-05
}
]
}
# Viewpoint
DEF VIEWPOINT Viewpoint {
position 4.34 -100.99 41.73
orientation 0.199 -0.190 -0.961 4.624
fieldOfView 0.785
}
# Background
Background {
skyColor [0.55 0.75 0.95]
}
# Single sun (diagonal from SW)
DirectionalLight {
ambientIntensity 1
direction -0.3 0.5 -0.85
color 1 0.98 0.92
intensity 2.5
castShadows TRUE
}
# Grass terrain
UnevenTerrain {
rotation 0 0 1 -1.5708
size 100 100 0.3
xDimension 50
yDimension 50
appearance Grass {
colorOverride 0.78 0.88 0.68
textureTransform TextureTransform {
scale 100 100
}
}
perlinNOctaves 4
}
# ==================== APPEARANCES ====================
Transform {
children [
Shape { appearance DEF STONE_A PBRAppearance { baseColor 0.48 0.45 0.40 roughness 0.95 metalness 0 } }
Shape { appearance DEF STONE_B PBRAppearance { baseColor 0.36 0.33 0.29 roughness 0.95 metalness 0 } }
Shape { appearance DEF STONE_C PBRAppearance { baseColor 0.58 0.55 0.50 roughness 0.90 metalness 0 } }
Shape { appearance DEF CAP PBRAppearance { baseColor 0.54 0.51 0.46 roughness 0.80 metalness 0 } }
Shape { appearance DEF BARN_RED PBRAppearance { baseColor 0.62 0.18 0.12 roughness 0.80 metalness 0 } }
Shape { appearance DEF BARN_ROOF PBRAppearance { baseColor 0.28 0.20 0.13 roughness 0.72 metalness 0 } }
Shape { appearance DEF WOOD PBRAppearance { baseColor 0.48 0.32 0.16 roughness 0.90 metalness 0 } }
Shape { appearance DEF TRUNK PBRAppearance { baseColor 0.38 0.24 0.11 roughness 0.90 metalness 0 } }
Shape { appearance DEF LEAF_A PBRAppearance { baseColor 0.22 0.52 0.16 roughness 0.85 metalness 0 } }
Shape { appearance DEF LEAF_B PBRAppearance { baseColor 0.16 0.42 0.10 roughness 0.85 metalness 0 } }
Shape { appearance DEF LEAF_C PBRAppearance { baseColor 0.30 0.60 0.20 roughness 0.80 metalness 0 } }
Shape { appearance DEF STRAW PBRAppearance { baseColor 0.85 0.75 0.35 roughness 0.95 metalness 0 } }
Shape { appearance DEF HAT PBRAppearance { baseColor 0.50 0.35 0.18 roughness 0.85 metalness 0 } }
Shape { appearance DEF SHIRT PBRAppearance { baseColor 0.60 0.30 0.30 roughness 0.80 metalness 0 } }
Shape { appearance DEF PANTS PBRAppearance { baseColor 0.25 0.25 0.30 roughness 0.80 metalness 0 } }
Shape { appearance DEF DOOR_MAT PBRAppearance { baseColor 0.55 0.38 0.20 roughness 0.72 metalness 0 } }
Shape { appearance DEF GLASS PBRAppearance { baseColor 0.60 0.80 0.95 roughness 0.20 metalness 0.05 } }
Shape { appearance DEF HAY PBRAppearance { baseColor 0.82 0.72 0.32 roughness 0.95 metalness 0 } }
]
}
DEF TRIM PBRAppearance { baseColor 0.90 0.88 0.82 roughness 0.70 metalness 0 }
# ==================== CIRCULAR STONE WALL (R=15 m) ====================
Solid { translation 15.00 0.00 0.40 rotation 0 0 1 -1.5708 children [ Shape { appearance USE STONE_A geometry Box { size 5.21 0.16 0.80 } } ] boundingObject Box { size 5.21 0.16 0.80 } }
Solid { translation 15.00 0.00 0.84 rotation 0 0 1 -1.5708 children [ Shape { appearance USE CAP geometry Box { size 5.2 0.26 0.07 } } ] boundingObject Box { size 5.2 0.26 0.07 } }
Solid { translation 14.10 5.13 0.40 rotation 0 0 1 -1.2217 children [ Shape { appearance USE STONE_A geometry Box { size 5.21 0.16 0.80 } } ] boundingObject Box { size 5.21 0.16 0.80 } }
Solid { translation 14.10 5.13 0.84 rotation 0 0 1 -1.2217 children [ Shape { appearance USE CAP geometry Box { size 5.2 0.26 0.07 } } ] boundingObject Box { size 5.2 0.26 0.07 } }
Solid { translation 11.49 9.64 0.40 rotation 0 0 1 -0.8727 children [ Shape { appearance USE STONE_A geometry Box { size 5.21 0.16 0.80 } } ] boundingObject Box { size 5.21 0.16 0.80 } }
Solid { translation 11.49 9.64 0.84 rotation 0 0 1 -0.8727 children [ Shape { appearance USE CAP geometry Box { size 5.2 0.26 0.07 } } ] boundingObject Box { size 5.2 0.26 0.07 } }
Solid { translation 7.50 12.99 0.40 rotation 0 0 1 -0.5236 children [ Shape { appearance USE STONE_A geometry Box { size 5.21 0.16 0.80 } } ] boundingObject Box { size 5.21 0.16 0.80 } }
Solid { translation 7.50 12.99 0.84 rotation 0 0 1 -0.5236 children [ Shape { appearance USE CAP geometry Box { size 5.2 0.26 0.07 } } ] boundingObject Box { size 5.2 0.26 0.07 } }
Solid { translation 2.60 14.77 0.40 rotation 0 0 1 -0.1745 children [ Shape { appearance USE STONE_A geometry Box { size 5.21 0.16 0.80 } } ] boundingObject Box { size 5.21 0.16 0.80 } }
Solid { translation 2.60 14.77 0.84 rotation 0 0 1 -0.1745 children [ Shape { appearance USE CAP geometry Box { size 5.2 0.26 0.07 } } ] boundingObject Box { size 5.2 0.26 0.07 } }
Solid { translation -2.60 14.77 0.40 rotation 0 0 1 0.1745 children [ Shape { appearance USE STONE_A geometry Box { size 5.21 0.16 0.80 } } ] boundingObject Box { size 5.21 0.16 0.80 } }
Solid { translation -2.60 14.77 0.84 rotation 0 0 1 0.1745 children [ Shape { appearance USE CAP geometry Box { size 5.2 0.26 0.07 } } ] boundingObject Box { size 5.2 0.26 0.07 } }
Solid { translation -7.50 12.99 0.40 rotation 0 0 1 0.5236 children [ Shape { appearance USE STONE_A geometry Box { size 5.21 0.16 0.80 } } ] boundingObject Box { size 5.21 0.16 0.80 } }
Solid { translation -7.50 12.99 0.84 rotation 0 0 1 0.5236 children [ Shape { appearance USE CAP geometry Box { size 5.2 0.26 0.07 } } ] boundingObject Box { size 5.2 0.26 0.07 } }
Solid { translation -11.49 9.64 0.40 rotation 0 0 1 0.8727 children [ Shape { appearance USE STONE_A geometry Box { size 5.21 0.16 0.80 } } ] boundingObject Box { size 5.21 0.16 0.80 } }
Solid { translation -11.49 9.64 0.84 rotation 0 0 1 0.8727 children [ Shape { appearance USE CAP geometry Box { size 5.2 0.26 0.07 } } ] boundingObject Box { size 5.2 0.26 0.07 } }
Solid { translation -14.10 5.13 0.40 rotation 0 0 1 1.2217 children [ Shape { appearance USE STONE_A geometry Box { size 5.21 0.16 0.80 } } ] boundingObject Box { size 5.21 0.16 0.80 } }
Solid { translation -14.10 5.13 0.84 rotation 0 0 1 1.2217 children [ Shape { appearance USE CAP geometry Box { size 5.2 0.26 0.07 } } ] boundingObject Box { size 5.2 0.26 0.07 } }
Solid { translation -15.00 0.00 0.40 rotation 0 0 1 1.5708 children [ Shape { appearance USE STONE_A geometry Box { size 5.21 0.16 0.80 } } ] boundingObject Box { size 5.21 0.16 0.80 } }
Solid { translation -15.00 0.00 0.84 rotation 0 0 1 1.5708 children [ Shape { appearance USE CAP geometry Box { size 5.2 0.26 0.07 } } ] boundingObject Box { size 5.2 0.26 0.07 } }
Solid { translation -14.10 -5.13 0.40 rotation 0 0 1 1.9199 children [ Shape { appearance USE STONE_A geometry Box { size 5.21 0.16 0.80 } } ] boundingObject Box { size 5.21 0.16 0.80 } }
Solid { translation -14.10 -5.13 0.84 rotation 0 0 1 1.9199 children [ Shape { appearance USE CAP geometry Box { size 5.2 0.26 0.07 } } ] boundingObject Box { size 5.2 0.26 0.07 } }
Solid { translation -11.49 -9.64 0.40 rotation 0 0 1 2.2689 children [ Shape { appearance USE STONE_A geometry Box { size 5.21 0.16 0.80 } } ] boundingObject Box { size 5.21 0.16 0.80 } }
Solid { translation -11.49 -9.64 0.84 rotation 0 0 1 2.2689 children [ Shape { appearance USE CAP geometry Box { size 5.2 0.26 0.07 } } ] boundingObject Box { size 5.2 0.26 0.07 } }
Solid { translation -7.50 -12.99 0.40 rotation 0 0 1 2.6180 children [ Shape { appearance USE STONE_A geometry Box { size 5.21 0.16 0.80 } } ] boundingObject Box { size 5.21 0.16 0.80 } }
Solid { translation -7.50 -12.99 0.84 rotation 0 0 1 2.6180 children [ Shape { appearance USE CAP geometry Box { size 5.2 0.26 0.07 } } ] boundingObject Box { size 5.2 0.26 0.07 } }
Solid { translation -3.37 -14.62 0.40 rotation 0 0 1 2.9671 children [ Shape { appearance USE STONE_A geometry Box { size 3.65 0.16 0.80 } } ] boundingObject Box { size 3.65 0.16 0.80 } }
Solid { translation -3.37 -14.62 0.84 rotation 0 0 1 2.9671 children [ Shape { appearance USE CAP geometry Box { size 3.7 0.26 0.07 } } ] boundingObject Box { size 3.7 0.26 0.07 } }
Solid { translation 3.37 -14.62 0.40 rotation 0 0 1 3.3161 children [ Shape { appearance USE STONE_A geometry Box { size 3.65 0.16 0.80 } } ] boundingObject Box { size 3.65 0.16 0.80 } }
Solid { translation 3.37 -14.62 0.84 rotation 0 0 1 3.3161 children [ Shape { appearance USE CAP geometry Box { size 3.7 0.26 0.07 } } ] boundingObject Box { size 3.7 0.26 0.07 } }
Solid { translation 7.50 -12.99 0.40 rotation 0 0 1 3.6652 children [ Shape { appearance USE STONE_A geometry Box { size 5.21 0.16 0.80 } } ] boundingObject Box { size 5.21 0.16 0.80 } }
Solid { translation 7.50 -12.99 0.84 rotation 0 0 1 3.6652 children [ Shape { appearance USE CAP geometry Box { size 5.2 0.26 0.07 } } ] boundingObject Box { size 5.2 0.26 0.07 } }
Solid { translation 11.49 -9.64 0.40 rotation 0 0 1 4.0143 children [ Shape { appearance USE STONE_A geometry Box { size 5.21 0.16 0.80 } } ] boundingObject Box { size 5.21 0.16 0.80 } }
Solid { translation 11.49 -9.64 0.84 rotation 0 0 1 4.0143 children [ Shape { appearance USE CAP geometry Box { size 5.2 0.26 0.07 } } ] boundingObject Box { size 5.2 0.26 0.07 } }
Solid { translation 14.10 -5.13 0.40 rotation 0 0 1 4.3633 children [ Shape { appearance USE STONE_A geometry Box { size 5.21 0.16 0.80 } } ] boundingObject Box { size 5.21 0.16 0.80 } }
Solid { translation 14.10 -5.13 0.84 rotation 0 0 1 4.3633 children [ Shape { appearance USE CAP geometry Box { size 5.2 0.26 0.07 } } ] boundingObject Box { size 5.2 0.26 0.07 } }
# Gate posts
Solid { translation -1.57 -14.92 0.56 children [ Shape { appearance USE STONE_B geometry Box { size 0.44 0.44 1.12 } } Shape { appearance USE CAP geometry Box { size 0.54 0.54 0.08 } } ] }
Solid { translation 1.57 -14.92 0.56 children [ Shape { appearance USE STONE_B geometry Box { size 0.44 0.44 1.12 } } Shape { appearance USE CAP geometry Box { size 0.54 0.54 0.08 } } ] }
# Outer gate — swung-back beside west gate post
Solid { translation -2.97 -14.92 0.55 rotation 0 0 1 0 children [
Shape { appearance USE WOOD geometry Box { size 2.80 0.05 1.00 } }
Transform { translation 0 0.02 0 rotation 0 1 0 0.34 children [
Shape { appearance DEF FPOST PBRAppearance { baseColor 0.35 0.22 0.10 roughness 0.90 } geometry Box { size 2.97 0.04 0.06 } }
] }
] boundingObject Box { size 2.80 0.08 1.00 } }
# Pillars between wall sections
Solid { translation 14.97 2.64 0.53 rotation 0 0 1 0.9599 children [ Shape { appearance USE STONE_B geometry Box { size 0.34 0.34 1.06 } } Shape { appearance USE CAP geometry Box { size 0.44 0.44 0.07 } } ] }
Solid { translation 13.16 7.60 0.53 rotation 0 0 1 1.3090 children [ Shape { appearance USE STONE_B geometry Box { size 0.34 0.34 1.06 } } Shape { appearance USE CAP geometry Box { size 0.44 0.44 0.07 } } ] }
Solid { translation 9.77 11.64 0.53 rotation 0 0 1 1.6581 children [ Shape { appearance USE STONE_B geometry Box { size 0.34 0.34 1.06 } } Shape { appearance USE CAP geometry Box { size 0.44 0.44 0.07 } } ] }
Solid { translation 5.20 14.28 0.53 rotation 0 0 1 2.0071 children [ Shape { appearance USE STONE_B geometry Box { size 0.34 0.34 1.06 } } Shape { appearance USE CAP geometry Box { size 0.44 0.44 0.07 } } ] }
Solid { translation 0.00 15.20 0.53 rotation 0 0 1 2.3562 children [ Shape { appearance USE STONE_B geometry Box { size 0.34 0.34 1.06 } } Shape { appearance USE CAP geometry Box { size 0.44 0.44 0.07 } } ] }
Solid { translation -5.20 14.28 0.53 rotation 0 0 1 2.7053 children [ Shape { appearance USE STONE_B geometry Box { size 0.34 0.34 1.06 } } Shape { appearance USE CAP geometry Box { size 0.44 0.44 0.07 } } ] }
Solid { translation -9.77 11.64 0.53 rotation 0 0 1 3.0543 children [ Shape { appearance USE STONE_B geometry Box { size 0.34 0.34 1.06 } } Shape { appearance USE CAP geometry Box { size 0.44 0.44 0.07 } } ] }
Solid { translation -13.16 7.60 0.53 rotation 0 0 1 3.4034 children [ Shape { appearance USE STONE_B geometry Box { size 0.34 0.34 1.06 } } Shape { appearance USE CAP geometry Box { size 0.44 0.44 0.07 } } ] }
Solid { translation -14.97 2.64 0.53 rotation 0 0 1 3.7525 children [ Shape { appearance USE STONE_B geometry Box { size 0.34 0.34 1.06 } } Shape { appearance USE CAP geometry Box { size 0.44 0.44 0.07 } } ] }
Solid { translation -14.97 -2.64 0.53 rotation 0 0 1 4.1015 children [ Shape { appearance USE STONE_B geometry Box { size 0.34 0.34 1.06 } } Shape { appearance USE CAP geometry Box { size 0.44 0.44 0.07 } } ] }
Solid { translation -13.16 -7.60 0.53 rotation 0 0 1 4.4506 children [ Shape { appearance USE STONE_B geometry Box { size 0.34 0.34 1.06 } } Shape { appearance USE CAP geometry Box { size 0.44 0.44 0.07 } } ] }
Solid { translation -9.77 -11.64 0.53 rotation 0 0 1 4.7997 children [ Shape { appearance USE STONE_B geometry Box { size 0.34 0.34 1.06 } } Shape { appearance USE CAP geometry Box { size 0.44 0.44 0.07 } } ] }
Solid { translation -5.20 -14.28 0.53 rotation 0 0 1 5.1487 children [ Shape { appearance USE STONE_B geometry Box { size 0.34 0.34 1.06 } } Shape { appearance USE CAP geometry Box { size 0.44 0.44 0.07 } } ] }
Solid { translation 5.20 -14.28 0.53 rotation 0 0 1 5.8469 children [ Shape { appearance USE STONE_B geometry Box { size 0.34 0.34 1.06 } } Shape { appearance USE CAP geometry Box { size 0.44 0.44 0.07 } } ] }
Solid { translation 9.77 -11.64 0.53 rotation 0 0 1 6.1959 children [ Shape { appearance USE STONE_B geometry Box { size 0.34 0.34 1.06 } } Shape { appearance USE CAP geometry Box { size 0.44 0.44 0.07 } } ] }
Solid { translation 13.16 -7.60 0.53 rotation 0 0 1 6.5450 children [ Shape { appearance USE STONE_B geometry Box { size 0.34 0.34 1.06 } } Shape { appearance USE CAP geometry Box { size 0.44 0.44 0.07 } } ] }
Solid { translation 14.97 -2.64 0.53 rotation 0 0 1 6.8941 children [ Shape { appearance USE STONE_B geometry Box { size 0.34 0.34 1.06 } } Shape { appearance USE CAP geometry Box { size 0.44 0.44 0.07 } } ] }
# ==================== EXTERNAL PEN (south of round field gate) ====================
# Pen west wall
Solid { translation -1.57 -18.5 0.55 children [
Transform { translation 0 -3.46 0 children [ Shape { appearance USE FPOST geometry Box { size 0.12 0.12 1.10 } } ] }
Transform { translation 0 -1.73 0 children [ Shape { appearance USE FPOST geometry Box { size 0.12 0.12 1.10 } } ] }
Transform { translation 0 0 0 children [ Shape { appearance USE FPOST geometry Box { size 0.12 0.12 1.10 } } ] }
Transform { translation 0 1.73 0 children [ Shape { appearance USE FPOST geometry Box { size 0.12 0.12 1.10 } } ] }
Transform { translation 0 3.46 0 children [ Shape { appearance USE FPOST geometry Box { size 0.12 0.12 1.10 } } ] }
Transform { translation 0 0 -0.38 children [ Shape { appearance USE WOOD geometry Box { size 0.06 6.92 0.08 } } ] }
Transform { translation 0 0 -0.05 children [ Shape { appearance USE WOOD geometry Box { size 0.06 6.92 0.08 } } ] }
Transform { translation 0 0 0.30 children [ Shape { appearance USE WOOD geometry Box { size 0.06 6.92 0.08 } } ] }
Transform { translation 0 0 0.53 children [ Shape { appearance USE FPOST geometry Box { size 0.14 6.92 0.04 } } ] }
] boundingObject Box { size 0.14 6.92 1.10 } }
# Pen east wall
Solid { translation 1.57 -18.5 0.55 children [
Transform { translation 0 -3.46 0 children [ Shape { appearance USE FPOST geometry Box { size 0.12 0.12 1.10 } } ] }
Transform { translation 0 -1.73 0 children [ Shape { appearance USE FPOST geometry Box { size 0.12 0.12 1.10 } } ] }
Transform { translation 0 0 0 children [ Shape { appearance USE FPOST geometry Box { size 0.12 0.12 1.10 } } ] }
Transform { translation 0 1.73 0 children [ Shape { appearance USE FPOST geometry Box { size 0.12 0.12 1.10 } } ] }
Transform { translation 0 3.46 0 children [ Shape { appearance USE FPOST geometry Box { size 0.12 0.12 1.10 } } ] }
Transform { translation 0 0 -0.38 children [ Shape { appearance USE WOOD geometry Box { size 0.06 6.92 0.08 } } ] }
Transform { translation 0 0 -0.05 children [ Shape { appearance USE WOOD geometry Box { size 0.06 6.92 0.08 } } ] }
Transform { translation 0 0 0.30 children [ Shape { appearance USE WOOD geometry Box { size 0.06 6.92 0.08 } } ] }
Transform { translation 0 0 0.53 children [ Shape { appearance USE FPOST geometry Box { size 0.14 6.92 0.04 } } ] }
] boundingObject Box { size 0.14 6.92 1.10 } }
# Pen south wall
Solid { translation 0.00 -22 0.55 children [
Transform { translation -1.52 0 0 children [ Shape { appearance USE FPOST geometry Box { size 0.12 0.12 1.10 } } ] }
Transform { translation 0 0 0 children [ Shape { appearance USE FPOST geometry Box { size 0.12 0.12 1.10 } } ] }
Transform { translation 1.52 0 0 children [ Shape { appearance USE FPOST geometry Box { size 0.12 0.12 1.10 } } ] }
Transform { translation 0 0 -0.38 children [ Shape { appearance USE WOOD geometry Box { size 3.16 0.06 0.08 } } ] }
Transform { translation 0 0 -0.05 children [ Shape { appearance USE WOOD geometry Box { size 3.16 0.06 0.08 } } ] }
Transform { translation 0 0 0.30 children [ Shape { appearance USE WOOD geometry Box { size 3.16 0.06 0.08 } } ] }
Transform { translation 0 0 0.53 children [ Shape { appearance USE FPOST geometry Box { size 3.16 0.14 0.04 } } ] }
] boundingObject Box { size 3.16 0.14 1.10 } }
# Pen north corner posts at the gate opening
Solid { translation -1.57 -15.0 0.55 children [ Shape { appearance USE FPOST geometry Box { size 0.12 0.12 1.10 } } ] }
Solid { translation 1.57 -15.0 0.55 children [ Shape { appearance USE FPOST geometry Box { size 0.12 0.12 1.10 } } ] }
# Gate width: 3.14 m (pen x: [-1.57, 1.57])
# ==================== BARN 1 — Gambrel/Dutch style (NE, outside fence) ====================
# Body 10×7×4, weathered gray-brown wood, gambrel roof, large double doors
Solid {
translation 18.5 25.49 2
children [
Shape { appearance PBRAppearance { baseColor 0.52 0.42 0.30 roughness 0.92 metalness 0 } geometry Box { size 10 7 4 } }
# Gambrel roof
Transform { translation -3.5 0 3.05 rotation 0 1 0 -0.611 children [ Shape { appearance PBRAppearance { baseColor 0.20 0.18 0.16 roughness 0.82 metalness 0.02 } geometry Box { size 3.9 7.2 0.18 } } ] }
Transform { translation 3.5 0 3.05 rotation 0 1 0 0.611 children [ Shape { appearance PBRAppearance { baseColor 0.20 0.18 0.16 roughness 0.82 metalness 0.02 } geometry Box { size 3.9 7.2 0.18 } } ] }
Transform { translation -1.0 0 4.55 rotation 0 1 0 -0.422 children [ Shape { appearance PBRAppearance { baseColor 0.20 0.18 0.16 roughness 0.82 metalness 0.02 } geometry Box { size 2.5 7.2 0.18 } } ] }
Transform { translation 1.0 0 4.55 rotation 0 1 0 0.422 children [ Shape { appearance PBRAppearance { baseColor 0.20 0.18 0.16 roughness 0.82 metalness 0.02 } geometry Box { size 2.5 7.2 0.18 } } ] }
Transform { translation 0 0 5.04 children [ Shape { appearance PBRAppearance { baseColor 0.20 0.18 0.16 roughness 0.82 metalness 0.02 } geometry Box { size 1.6 7.2 0.22 } } ] }
# South gable fill
Transform { translation 0 -3.57 2.40 children [ Shape { appearance PBRAppearance { baseColor 0.52 0.42 0.30 roughness 0.92 metalness 0 } geometry Box { size 8.8 0.16 0.80 } } ] }
Transform { translation 0 -3.57 3.10 children [ Shape { appearance PBRAppearance { baseColor 0.52 0.42 0.30 roughness 0.92 metalness 0 } geometry Box { size 6.8 0.16 0.70 } } ] }
Transform { translation 0 -3.57 3.70 children [ Shape { appearance PBRAppearance { baseColor 0.52 0.42 0.30 roughness 0.92 metalness 0 } geometry Box { size 5.1 0.16 0.60 } } ] }
Transform { translation 0 -3.57 4.10 children [ Shape { appearance PBRAppearance { baseColor 0.52 0.42 0.30 roughness 0.92 metalness 0 } geometry Box { size 4.0 0.16 0.40 } } ] }
Transform { translation 0 -3.57 4.42 children [ Shape { appearance PBRAppearance { baseColor 0.52 0.42 0.30 roughness 0.92 metalness 0 } geometry Box { size 2.7 0.16 0.60 } } ] }
Transform { translation 0 -3.57 4.84 children [ Shape { appearance PBRAppearance { baseColor 0.52 0.42 0.30 roughness 0.92 metalness 0 } geometry Box { size 0.9 0.16 0.36 } } ] }
# North gable fill
Transform { translation 0 3.57 2.40 children [ Shape { appearance PBRAppearance { baseColor 0.52 0.42 0.30 roughness 0.92 metalness 0 } geometry Box { size 8.8 0.16 0.80 } } ] }
Transform { translation 0 3.57 3.10 children [ Shape { appearance PBRAppearance { baseColor 0.52 0.42 0.30 roughness 0.92 metalness 0 } geometry Box { size 6.8 0.16 0.70 } } ] }
Transform { translation 0 3.57 3.70 children [ Shape { appearance PBRAppearance { baseColor 0.52 0.42 0.30 roughness 0.92 metalness 0 } geometry Box { size 5.1 0.16 0.60 } } ] }
Transform { translation 0 3.57 4.10 children [ Shape { appearance PBRAppearance { baseColor 0.52 0.42 0.30 roughness 0.92 metalness 0 } geometry Box { size 4.0 0.16 0.40 } } ] }
Transform { translation 0 3.57 4.42 children [ Shape { appearance PBRAppearance { baseColor 0.52 0.42 0.30 roughness 0.92 metalness 0 } geometry Box { size 2.7 0.16 0.60 } } ] }
Transform { translation 0 3.57 4.84 children [ Shape { appearance PBRAppearance { baseColor 0.52 0.42 0.30 roughness 0.92 metalness 0 } geometry Box { size 0.9 0.16 0.36 } } ] }
# Double barn doors (south face)
Transform {
translation 0 -3.51 -0.50
children [
Shape { appearance PBRAppearance { baseColor 0.44 0.30 0.14 roughness 0.88 metalness 0 } geometry Box { size 2.8 0.10 3.0 } }
Transform { rotation 0 0 1 0.83 children [ Shape { appearance PBRAppearance { baseColor 0.34 0.22 0.10 roughness 0.90 metalness 0 } geometry Box { size 0.10 0.12 3.75 } } ] }
Transform { rotation 0 0 1 -0.83 children [ Shape { appearance PBRAppearance { baseColor 0.34 0.22 0.10 roughness 0.90 metalness 0 } geometry Box { size 0.10 0.12 3.75 } } ] }
Transform { translation -1.45 0 0 children [ Shape { appearance PBRAppearance { baseColor 0.34 0.22 0.10 roughness 0.90 metalness 0 } geometry Box { size 0.12 0.14 3.24 } } ] }
Transform { translation 1.45 0 0 children [ Shape { appearance PBRAppearance { baseColor 0.34 0.22 0.10 roughness 0.90 metalness 0 } geometry Box { size 0.12 0.14 3.24 } } ] }
Transform { translation 0 0 1.62 children [ Shape { appearance PBRAppearance { baseColor 0.34 0.22 0.10 roughness 0.90 metalness 0 } geometry Box { size 3.04 0.14 0.14 } } ] }
]
}
# Windows
Transform { translation -3.6 -3.52 0.55 children [ Shape { appearance PBRAppearance { baseColor 0.60 0.80 0.95 roughness 0.20 metalness 0.05 } geometry Box { size 1.40 0.12 1.10 } } ] }
Transform { translation 3.6 -3.52 0.55 children [ Shape { appearance PBRAppearance { baseColor 0.60 0.80 0.95 roughness 0.20 metalness 0.05 } geometry Box { size 1.40 0.12 1.10 } } ] }
Transform { translation 5.06 2.0 0.55 children [ Shape { appearance PBRAppearance { baseColor 0.60 0.80 0.95 roughness 0.20 metalness 0.05 } geometry Box { size 0.12 1.20 1.0 } } ] }
Transform { translation 5.06 -2.0 0.55 children [ Shape { appearance PBRAppearance { baseColor 0.60 0.80 0.95 roughness 0.20 metalness 0.05 } geometry Box { size 0.12 1.20 1.0 } } ] }
Transform { translation 0 -3.52 3.90 children [ Shape { appearance PBRAppearance { baseColor 0.44 0.30 0.14 roughness 0.88 metalness 0 } geometry Box { size 1.30 0.12 1.00 } } ] }
]
boundingObject Box { size 10 7 7 }
}
# ==================== BARN 3 — Red barn (NE, outside fence, gate facing fence) ====================
# Body 7×9×3.5, red walls, steep dark roof
Solid {
translation 29.76 9.52 1.75
rotation 0 0 1 -1.5708
children [
Shape { appearance USE BARN_RED geometry Box { size 7 9 3.5 } }
# Roof
Transform { translation -2.0 0 3.0 rotation 0 1 0 -0.70 children [ Shape { appearance USE BARN_ROOF geometry Box { size 4.2 9.2 0.20 } } ] }
Transform { translation 2.0 0 3.0 rotation 0 1 0 0.70 children [ Shape { appearance USE BARN_ROOF geometry Box { size 4.2 9.2 0.20 } } ] }
Transform { translation 0 0 4.28 children [ Shape { appearance USE BARN_ROOF geometry Box { size 2.0 9.2 0.24 } } ] }
# South gable fill
Transform { translation 0 -4.52 2.05 children [ Shape { appearance USE BARN_RED geometry Box { size 6.2 0.16 0.60 } } ] }
Transform { translation 0 -4.52 2.65 children [ Shape { appearance USE BARN_RED geometry Box { size 4.5 0.16 0.60 } } ] }
Transform { translation 0 -4.52 3.25 children [ Shape { appearance USE BARN_RED geometry Box { size 2.9 0.16 0.60 } } ] }
Transform { translation 0 -4.52 3.85 children [ Shape { appearance USE BARN_RED geometry Box { size 1.2 0.16 0.60 } } ] }
# North gable fill
Transform { translation 0 4.52 2.05 children [ Shape { appearance USE BARN_RED geometry Box { size 6.2 0.16 0.60 } } ] }
Transform { translation 0 4.52 2.65 children [ Shape { appearance USE BARN_RED geometry Box { size 4.5 0.16 0.60 } } ] }
Transform { translation 0 4.52 3.25 children [ Shape { appearance USE BARN_RED geometry Box { size 2.9 0.16 0.60 } } ] }
Transform { translation 0 4.52 3.85 children [ Shape { appearance USE BARN_RED geometry Box { size 1.2 0.16 0.60 } } ] }
# Door
Transform {
translation 0 -4.52 -0.62
children [
Shape { appearance USE DOOR_MAT geometry Box { size 1.70 0.14 2.26 } }
Transform { translation 0 0 1.22 children [ Shape { appearance USE WOOD geometry Box { size 2.10 0.18 0.26 } } ] }
Transform { translation -0.90 0 0 children [ Shape { appearance USE WOOD geometry Box { size 0.24 0.18 2.52 } } ] }
Transform { translation 0.90 0 0 children [ Shape { appearance USE WOOD geometry Box { size 0.24 0.18 2.52 } } ] }
Transform { translation 0 0 -0.68 children [ Shape { appearance USE WOOD geometry Box { size 1.60 0.12 0.12 } } ] }
Transform { translation 0 0 0.30 children [ Shape { appearance USE WOOD geometry Box { size 1.60 0.12 0.12 } } ] }
]
}
# Windows — south face
Transform { translation -2.2 -4.53 0.30 children [ Shape { appearance USE GLASS geometry Box { size 0.80 0.14 0.70 } } ] }
Transform { translation 2.2 -4.53 0.30 children [ Shape { appearance USE GLASS geometry Box { size 0.80 0.14 0.70 } } ] }
# East-face windows
Transform { translation 3.52 3.0 0.30 children [ Shape { appearance USE GLASS geometry Box { size 0.14 0.80 0.70 } } ] }
Transform { translation 3.52 0.0 0.30 children [ Shape { appearance USE GLASS geometry Box { size 0.14 0.80 0.70 } } ] }
Transform { translation 3.52 -3.0 0.30 children [ Shape { appearance USE GLASS geometry Box { size 0.14 0.80 0.70 } } ] }
]
boundingObject Box { size 7 9 6 }
}
# ==================== TREES (outside fence) ====================
# Tree A — large oak, SE
Solid {
translation 20 -18 0
children [
Transform { translation 0 0 2.0 children [ Shape { appearance USE TRUNK geometry Cylinder { height 4.0 radius 0.30 subdivision 10 } } ] }
Transform { translation 0.0 0.0 4.6 children [ Shape { appearance USE LEAF_A geometry Sphere { radius 2.6 subdivision 4 } } ] }
Transform { translation 1.2 0.6 5.6 children [ Shape { appearance USE LEAF_B geometry Sphere { radius 1.9 subdivision 4 } } ] }
Transform { translation -1.0 0.9 5.3 children [ Shape { appearance USE LEAF_C geometry Sphere { radius 1.7 subdivision 4 } } ] }
Transform { translation 0.4 -1.1 5.1 children [ Shape { appearance USE LEAF_A geometry Sphere { radius 1.5 subdivision 4 } } ] }
Transform { translation -0.5 -0.4 6.2 children [ Shape { appearance USE LEAF_B geometry Sphere { radius 1.0 subdivision 4 } } ] }
]
}
# Tree B — medium, NE near barn
Solid {
translation -8 26 0
children [
Transform { translation 0 0 1.7 children [ Shape { appearance USE TRUNK geometry Cylinder { height 3.4 radius 0.25 subdivision 10 } } ] }
Transform { translation 0.0 0.0 3.8 children [ Shape { appearance USE LEAF_C geometry Sphere { radius 2.2 subdivision 4 } } ] }
Transform { translation 0.9 -0.7 4.7 children [ Shape { appearance USE LEAF_A geometry Sphere { radius 1.6 subdivision 4 } } ] }
Transform { translation -0.6 0.8 4.4 children [ Shape { appearance USE LEAF_B geometry Sphere { radius 1.4 subdivision 4 } } ] }
]
}
# Tree C — large, NW
Solid {
translation -23 20 0
children [
Transform { translation 0 0 2.3 children [ Shape { appearance USE TRUNK geometry Cylinder { height 4.6 radius 0.36 subdivision 10 } } ] }
Transform { translation 0.0 0.0 5.2 children [ Shape { appearance USE LEAF_B geometry Sphere { radius 2.9 subdivision 4 } } ] }
Transform { translation 1.3 0.9 6.3 children [ Shape { appearance USE LEAF_A geometry Sphere { radius 2.1 subdivision 4 } } ] }
Transform { translation -1.1 1.1 6.0 children [ Shape { appearance USE LEAF_C geometry Sphere { radius 1.9 subdivision 4 } } ] }
Transform { translation 0.6 -1.3 5.8 children [ Shape { appearance USE LEAF_A geometry Sphere { radius 1.6 subdivision 4 } } ] }
]
}
# Tree D — small, SW
Solid {
translation -20 -23 0
children [
Transform { translation 0 0 1.4 children [ Shape { appearance USE TRUNK geometry Cylinder { height 2.8 radius 0.20 subdivision 10 } } ] }
Transform { translation 0.0 0.0 3.2 children [ Shape { appearance USE LEAF_C geometry Sphere { radius 1.9 subdivision 4 } } ] }
Transform { translation -0.7 0.6 4.0 children [ Shape { appearance USE LEAF_A geometry Sphere { radius 1.4 subdivision 4 } } ] }
Transform { translation 0.6 -0.5 3.8 children [ Shape { appearance USE LEAF_B geometry Sphere { radius 1.2 subdivision 4 } } ] }
]
}
# Tree E — north cluster
Solid {
translation 7 23 0
children [
Transform { translation 0 0 1.9 children [ Shape { appearance USE TRUNK geometry Cylinder { height 3.8 radius 0.27 subdivision 10 } } ] }
Transform { translation 0.0 0.0 4.1 children [ Shape { appearance USE LEAF_A geometry Sphere { radius 2.3 subdivision 4 } } ] }
Transform { translation 1.0 0.5 5.0 children [ Shape { appearance USE LEAF_C geometry Sphere { radius 1.7 subdivision 4 } } ] }
Transform { translation -0.6 -0.9 4.8 children [ Shape { appearance USE LEAF_B geometry Sphere { radius 1.4 subdivision 4 } } ] }
]
}
# Tree F — SW
Solid {
translation -2.98 -22.8 0
children [
Transform { translation 0 0 1.3 children [ Shape { appearance USE TRUNK geometry Cylinder { height 2.6 radius 0.19 subdivision 10 } } ] }
Transform { translation 0.0 0.0 2.9 children [ Shape { appearance USE LEAF_B geometry Sphere { radius 1.7 subdivision 4 } } ] }
Transform { translation 0.6 0.4 3.7 children [ Shape { appearance USE LEAF_A geometry Sphere { radius 1.2 subdivision 4 } } ] }
]
}
# Tree G — west side
Solid {
translation -23 -5 0
children [
Transform { translation 0 0 2.0 children [ Shape { appearance USE TRUNK geometry Cylinder { height 4.0 radius 0.29 subdivision 10 } } ] }
Transform { translation 0.0 0.0 4.4 children [ Shape { appearance USE LEAF_C geometry Sphere { radius 2.4 subdivision 4 } } ] }
Transform { translation -1.0 0.8 5.3 children [ Shape { appearance USE LEAF_A geometry Sphere { radius 1.8 subdivision 4 } } ] }
Transform { translation 0.9 -0.7 5.0 children [ Shape { appearance USE LEAF_B geometry Sphere { radius 1.6 subdivision 4 } } ] }
]
}
# Tree H — east side
Solid {
translation 21.35 -1.05 0
children [
Transform { translation 0 0 1.5 children [ Shape { appearance USE TRUNK geometry Cylinder { height 3.0 radius 0.22 subdivision 10 } } ] }
Transform { translation 0.0 0.0 3.4 children [ Shape { appearance USE LEAF_A geometry Sphere { radius 2.0 subdivision 4 } } ] }
Transform { translation 0.7 0.6 4.2 children [ Shape { appearance USE LEAF_C geometry Sphere { radius 1.4 subdivision 4 } } ] }
Transform { translation -0.5 -0.8 4.0 children [ Shape { appearance USE LEAF_B geometry Sphere { radius 1.2 subdivision 4 } } ] }
]
}
# ==================== SCARECROW (east side, outside fence) ====================
Solid {
translation 20 -10 0
rotation 0 0 1 2.61799
children [
Transform { translation 0 0 1.22 children [ Shape { appearance USE TRUNK geometry Cylinder { height 2.44 radius 0.045 subdivision 8 } } ] }
Transform { translation 0 0 2.02 rotation 1 0 0 1.5708 children [ Shape { appearance USE TRUNK geometry Cylinder { height 1.60 radius 0.032 subdivision 8 } } ] }
Transform {
translation 0 0 2.44
children [
Shape { appearance USE STRAW geometry Sphere { radius 0.17 subdivision 3 } }
Transform { translation 0.13 0.05 0.06 children [ Shape { appearance PBRAppearance { baseColor 0.06 0.06 0.06 } geometry Sphere { radius 0.028 subdivision 2 } } ] }
Transform { translation 0.13 -0.05 0.06 children [ Shape { appearance PBRAppearance { baseColor 0.06 0.06 0.06 } geometry Sphere { radius 0.028 subdivision 2 } } ] }
Transform { translation 0.16 0 -0.02 rotation 0 1 0 1.5708 children [ Shape { appearance PBRAppearance { baseColor 0.75 0.50 0.30 } geometry Cone { height 0.07 bottomRadius 0.032 subdivision 6 } } ] }
Transform { translation 0.14 0.04 -0.06 children [ Shape { appearance PBRAppearance { baseColor 0.18 0.08 0.08 } geometry Box { size 0.01 0.04 0.01 } } ] }
Transform { translation 0.14 -0.04 -0.06 children [ Shape { appearance PBRAppearance { baseColor 0.18 0.08 0.08 } geometry Box { size 0.01 0.04 0.01 } } ] }
]
}
Transform { translation 0 0 2.62 children [ Shape { appearance USE HAT geometry Cylinder { height 0.04 radius 0.28 subdivision 12 } } ] }
Transform { translation 0 0 2.80 children [ Shape { appearance USE HAT geometry Cylinder { height 0.30 radius 0.17 subdivision 10 } } ] }
Transform { translation 0 0 1.60 children [ Shape { appearance USE SHIRT geometry Box { size 0.20 0.40 0.46 } } ] }
Transform { translation 0 0 1.14 children [ Shape { appearance USE PANTS geometry Box { size 0.17 0.32 0.34 } } ] }
Transform { translation 0 0.68 2.03 rotation 0 0 1 0.25 children [ Shape { appearance USE STRAW geometry Box { size 0.03 0.24 0.03 } } ] }
Transform { translation 0 -0.68 2.03 rotation 0 0 -1 0.25 children [ Shape { appearance USE STRAW geometry Box { size 0.03 0.24 0.03 } } ] }
Transform { translation 0.10 0.08 1.82 children [ Shape { appearance USE STRAW geometry Box { size 0.03 0.03 0.14 } } ] }
Transform { translation 0.10 -0.08 1.82 children [ Shape { appearance USE STRAW geometry Box { size 0.03 0.03 0.14 } } ] }
]
}
# ==================== HAY BALES (near barn) ====================
Solid { translation 25.75 13.76 0.62 children [ Transform { rotation 1 0 0 1.5708 children [ Shape { appearance USE HAY geometry Cylinder { height 1.30 radius 0.62 subdivision 14 } } ] } ] boundingObject Box { size 1.30 1.24 1.24 } }
Solid { translation 24.34 12.32 0.62 rotation -1 0 0 1.5708 children [ Transform { rotation 1 0 0 1.5708 children [ Shape { appearance USE HAY geometry Cylinder { height 1.30 radius 0.62 subdivision 14 } } ] } ] boundingObject Box { size 1.30 1.24 1.24 } }
Solid { translation 24.28 13.79 0.62 children [ Transform { rotation 1 0 0 1.5708 children [ Shape { appearance USE HAY geometry Cylinder { height 1.30 radius 0.62 subdivision 14 } } ] } ] boundingObject Box { size 1.30 1.24 1.24 } }
# ==================== TRACTOR (near barn) ====================
Solid {
translation 17 19 0.18
rotation 0 0 1 1.9
children [
# Chassis
Transform { translation 0 0 0.35 children [ Shape { appearance PBRAppearance { baseColor 0.20 0.20 0.20 roughness 0.6 metalness 0.3 } geometry Box { size 2.0 0.90 0.12 } } ] }
# Engine hood
Transform { translation 0.60 0 0.60 children [ Shape { appearance PBRAppearance { baseColor 0.15 0.50 0.12 roughness 0.7 metalness 0.1 } geometry Box { size 0.65 0.80 0.45 } } ] }
# Main body
Transform { translation -0.15 0 0.60 children [ Shape { appearance PBRAppearance { baseColor 0.15 0.50 0.12 roughness 0.7 metalness 0.1 } geometry Box { size 0.80 0.85 0.45 } } ] }
# Cabin
Transform { translation -0.20 0 0.95 children [ Shape { appearance PBRAppearance { baseColor 0.15 0.50 0.12 roughness 0.7 metalness 0.1 } geometry Box { size 0.75 0.80 0.45 } } ] }
# Cabin roof
Transform { translation -0.20 0 1.22 children [ Shape { appearance PBRAppearance { baseColor 0.12 0.40 0.10 roughness 0.75 metalness 0.1 } geometry Box { size 0.85 0.90 0.06 } } ] }
# Windshield
Transform { translation 0.12 0 0.95 children [ Shape { appearance USE GLASS geometry Box { size 0.02 0.55 0.35 } } ] }
# Rear window
Transform { translation -0.58 0 0.95 children [ Shape { appearance USE GLASS geometry Box { size 0.02 0.55 0.35 } } ] }
# Side windows
Transform { translation -0.20 0.40 0.95 children [ Shape { appearance USE GLASS geometry Box { size 0.55 0.02 0.30 } } ] }
Transform { translation -0.20 -0.40 0.95 children [ Shape { appearance USE GLASS geometry Box { size 0.55 0.02 0.30 } } ] }
# Seat
Transform { translation -0.25 0 0.55 children [ Shape { appearance PBRAppearance { baseColor 0.12 0.12 0.12 roughness 0.9 } geometry Box { size 0.30 0.35 0.06 } } ] }
# Exhaust stack
Transform { translation 0.50 0.30 0.60 children [
Shape { appearance PBRAppearance { baseColor 0.25 0.25 0.25 roughness 0.4 metalness 0.6 } geometry Cylinder { height 0.90 radius 0.03 subdivision 6 } }
Transform { translation 0 0 0.50 children [ Shape { appearance PBRAppearance { baseColor 0.20 0.20 0.20 roughness 0.4 metalness 0.6 } geometry Cylinder { height 0.04 radius 0.045 subdivision 6 } } ] }
] }
# Rear axle
Transform { translation -0.45 0 0.40 children [ Shape { appearance PBRAppearance { baseColor 0.25 0.25 0.25 roughness 0.5 metalness 0.5 } geometry Box { size 0.08 1.15 0.08 } } ] }
# Front axle
Transform { translation 0.60 0 0.25 children [ Shape { appearance PBRAppearance { baseColor 0.25 0.25 0.25 roughness 0.5 metalness 0.5 } geometry Box { size 0.08 0.90 0.08 } } ] }
# Rear left wheel
Transform { translation -0.45 0.60 0.40 rotation 1 0 0 1.5708 children [
Shape { appearance PBRAppearance { baseColor 0.08 0.08 0.08 roughness 0.95 } geometry Cylinder { height 0.22 radius 0.40 subdivision 20 } }
Shape { appearance PBRAppearance { baseColor 0.35 0.35 0.35 metalness 0.5 } geometry Cylinder { height 0.24 radius 0.14 subdivision 10 } }
] }
# Rear right wheel
Transform { translation -0.45 -0.60 0.40 rotation 1 0 0 1.5708 children [
Shape { appearance PBRAppearance { baseColor 0.08 0.08 0.08 roughness 0.95 } geometry Cylinder { height 0.22 radius 0.40 subdivision 20 } }
Shape { appearance PBRAppearance { baseColor 0.35 0.35 0.35 metalness 0.5 } geometry Cylinder { height 0.24 radius 0.14 subdivision 10 } }
] }
# Front left wheel
Transform { translation 0.60 0.45 0.25 rotation 1 0 0 1.5708 children [
Shape { appearance PBRAppearance { baseColor 0.08 0.08 0.08 roughness 0.95 } geometry Cylinder { height 0.16 radius 0.25 subdivision 16 } }
Shape { appearance PBRAppearance { baseColor 0.35 0.35 0.35 metalness 0.5 } geometry Cylinder { height 0.18 radius 0.09 subdivision 8 } }
] }
# Front right wheel
Transform { translation 0.60 -0.45 0.25 rotation 1 0 0 1.5708 children [
Shape { appearance PBRAppearance { baseColor 0.08 0.08 0.08 roughness 0.95 } geometry Cylinder { height 0.16 radius 0.25 subdivision 16 } }
Shape { appearance PBRAppearance { baseColor 0.35 0.35 0.35 metalness 0.5 } geometry Cylinder { height 0.18 radius 0.09 subdivision 8 } }
] }
# Rear fenders
Transform { translation -0.45 0.50 0.72 children [ Shape { appearance PBRAppearance { baseColor 0.12 0.40 0.10 roughness 0.75 metalness 0.1 } geometry Box { size 0.50 0.12 0.20 } } ] }
Transform { translation -0.45 -0.50 0.72 children [ Shape { appearance PBRAppearance { baseColor 0.12 0.40 0.10 roughness 0.75 metalness 0.1 } geometry Box { size 0.50 0.12 0.20 } } ] }
# Front bumper
Transform { translation 0.95 0 0.35 children [ Shape { appearance PBRAppearance { baseColor 0.35 0.35 0.35 roughness 0.7 metalness 0.3 } geometry Box { size 0.12 0.75 0.30 } } ] }
# Headlights
Transform { translation 0.97 0.25 0.45 children [ Shape { appearance PBRAppearance { baseColor 0.95 0.92 0.70 roughness 0.3 } geometry Sphere { radius 0.05 subdivision 3 } } ] }
Transform { translation 0.97 -0.25 0.45 children [ Shape { appearance PBRAppearance { baseColor 0.95 0.92 0.70 roughness 0.3 } geometry Sphere { radius 0.05 subdivision 3 } } ] }
# Taillights
Transform { translation -0.58 0.25 0.45 children [ Shape { appearance PBRAppearance { baseColor 0.80 0.10 0.10 roughness 0.4 } geometry Box { size 0.04 0.08 0.06 } } ] }
Transform { translation -0.58 -0.25 0.45 children [ Shape { appearance PBRAppearance { baseColor 0.80 0.10 0.10 roughness 0.4 } geometry Box { size 0.04 0.08 0.06 } } ] }
# Drawbar hitch
Transform { translation -0.95 0 0.20 children [ Shape { appearance PBRAppearance { baseColor 0.25 0.25 0.25 roughness 0.5 metalness 0.5 } geometry Box { size 0.12 0.06 0.06 } } ] }
]
boundingObject Box { size 2.2 1.4 1.3 }
}
# ==================== GRASS PATCHES (inside field, decorative) ====================
Solid { translation -8 6 0.15 children [
Transform { translation 0.10 0.00 0 children [ Shape { appearance USE LEAF_B geometry Box { size 0.04 0.02 0.30 } } ] }
Transform { translation -0.05 0.12 0 rotation 0 0 1 0.4 children [ Shape { appearance USE LEAF_A geometry Box { size 0.04 0.02 0.26 } } ] }
Transform { translation 0.08 -0.10 0 rotation 0 0 1 -0.3 children [ Shape { appearance USE LEAF_C geometry Box { size 0.04 0.02 0.28 } } ] }
Transform { translation -0.12 0.04 0 rotation 0 0 1 0.2 children [ Shape { appearance USE LEAF_B geometry Box { size 0.04 0.02 0.24 } } ] }
] }
Solid { translation 6 -9 0.15 children [
Transform { translation 0.08 0.06 0 children [ Shape { appearance USE LEAF_A geometry Box { size 0.04 0.02 0.28 } } ] }
Transform { translation -0.10 0.00 0 rotation 0 0 1 -0.3 children [ Shape { appearance USE LEAF_C geometry Box { size 0.04 0.02 0.32 } } ] }
Transform { translation 0.02 -0.12 0 rotation 0 0 1 0.35 children [ Shape { appearance USE LEAF_B geometry Box { size 0.04 0.02 0.26 } } ] }
Transform { translation -0.06 0.10 0 children [ Shape { appearance USE LEAF_A geometry Box { size 0.04 0.02 0.22 } } ] }
] }
Solid { translation -3 11 0.15 children [
Transform { translation 0.06 -0.06 0 children [ Shape { appearance USE LEAF_C geometry Box { size 0.04 0.02 0.26 } } ] }
Transform { translation -0.08 0.08 0 rotation 0 0 1 0.3 children [ Shape { appearance USE LEAF_A geometry Box { size 0.04 0.02 0.30 } } ] }
Transform { translation 0.12 0.02 0 rotation 0 0 1 -0.25 children [ Shape { appearance USE LEAF_B geometry Box { size 0.04 0.02 0.28 } } ] }
] }
Solid { translation 10 8 0.15 children [
Transform { translation -0.07 0.05 0 children [ Shape { appearance USE LEAF_B geometry Box { size 0.04 0.02 0.24 } } ] }
Transform { translation 0.09 -0.07 0 rotation 0 0 1 0.4 children [ Shape { appearance USE LEAF_C geometry Box { size 0.04 0.02 0.28 } } ] }
Transform { translation 0.00 0.11 0 rotation 0 0 1 -0.2 children [ Shape { appearance USE LEAF_A geometry Box { size 0.04 0.02 0.26 } } ] }
] }
Solid { translation -11 -7 0.15 children [
Transform { translation 0.05 0.08 0 children [ Shape { appearance USE LEAF_A geometry Box { size 0.04 0.02 0.30 } } ] }
Transform { translation -0.09 -0.04 0 rotation 0 0 1 0.35 children [ Shape { appearance USE LEAF_B geometry Box { size 0.04 0.02 0.28 } } ] }
Transform { translation 0.10 -0.09 0 rotation 0 0 1 -0.3 children [ Shape { appearance USE LEAF_C geometry Box { size 0.04 0.02 0.24 } } ] }
Transform { translation -0.03 0.12 0 children [ Shape { appearance USE LEAF_A geometry Box { size 0.04 0.02 0.26 } } ] }
] }
# ==================== SHEPHERD DOG ====================
ShepherdDog {
translation 0 0 0.5
rotation 0 0 1 0
controller "shepherd_dog"
}
# ==================== SHEEP ====================
# Up to 10 sheep, scattered through the field's central/north zone. Comment
# out trailing slots to test smaller flock sizes; the dog policy is trained
# to handle 1..10 sheep so any prefix works.
Sheep { translation 3.0 2.0 0.5 name "sheep1" controller "sheep" }
Sheep { translation 3.0 -2.0 0.5 name "sheep2" controller "sheep" }
Sheep { translation 4.0 0.0 0.5 name "sheep3" controller "sheep" }
Sheep { translation -3.0 4.0 0.5 name "sheep4" controller "sheep" }
Sheep { translation -5.0 -2.0 0.5 name "sheep5" controller "sheep" }
Sheep { translation 6.0 5.0 0.5 name "sheep6" controller "sheep" }
Sheep { translation -6.0 6.0 0.5 name "sheep7" controller "sheep" }
Sheep { translation 0.0 8.0 0.5 name "sheep8" controller "sheep" }
Sheep { translation -8.0 0.0 0.5 name "sheep9" controller "sheep" }
Sheep { translation 7.0 -4.0 0.5 name "sheep10" controller "sheep" }