diff --git a/Makefile b/Makefile index 3acf05e..2951ad9 100644 --- a/Makefile +++ b/Makefile @@ -146,7 +146,7 @@ MODE ?= rl .PHONY: all bc_demos bc rl rl_fast eval eval_fast eval_all eval_all_fast \ - test webots webots_sweep clean clean_all help \ + test webots webots_quick webots_sweep clean clean_all help \ train_all train_diff_rect train_diff_round \ train_mec_rect train_mec_round \ train_all_fast train_diff_rect_fast train_diff_round_fast \ @@ -221,6 +221,9 @@ test: $(PY) -m pytest tests/ webots: + @bash tools/webots_menu.sh + +webots_quick: tools/run_webots.sh $(N) $(MODE) $(DRIVE) $(WORLD) # Headless sweep across all modes × worlds × flock sizes. diff --git a/controllers/sheep/sheep.py b/controllers/sheep/sheep.py index 843ae42..3ca12e1 100644 --- a/controllers/sheep/sheep.py +++ b/controllers/sheep/sheep.py @@ -37,6 +37,37 @@ timestep = int(robot.getBasicTimeStep()) name = robot.getName() self_node = robot.getSelf() +# Seed Python's RNG (shared with the dog controller) so a fixed +# HERDING_SEED produces reproducible runs. Each sheep mixes its name +# into the seed so the flock isn't all identical. +def _read_runtime_cfg(): + cfg_path = os.path.join(_PROJECT_ROOT, "herding_runtime.cfg") + out = {} + if os.path.exists(cfg_path): + try: + with open(cfg_path) as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, _, v = line.partition("=") + out[k.strip().upper()] = v.strip() + except OSError: + pass + return out + +_rt = _read_runtime_cfg() +_seed_raw = (os.environ.get("HERDING_SEED") + or _rt.get("HERDING_SEED") + or "").strip() +if _seed_raw: + try: + # XOR with hash(name) so different sheep have different seeds + # but the flock as a whole is deterministic for a given seed. + random.seed(int(_seed_raw) ^ (hash(name) & 0x7FFFFFFF)) + except ValueError: + pass + left_motor = robot.getDevice("left wheel motor") right_motor = robot.getDevice("right wheel motor") left_motor.setPosition(float("inf")) diff --git a/controllers/shepherd_dog/shepherd_dog.py b/controllers/shepherd_dog/shepherd_dog.py index a15ea7e..2c35054 100644 --- a/controllers/shepherd_dog/shepherd_dog.py +++ b/controllers/shepherd_dog/shepherd_dog.py @@ -351,10 +351,32 @@ if MODE in ("strombom", "sequential"): elif MODE == "universal": analytic_teacher = ActiveScanTeacher(universal_action) +# Optional deterministic seed for the controller's RNG. The sheep +# controller seeds itself the same way, so identical HERDING_SEED +# values give reproducible trials. If unset (empty), Python uses its +# time-based default and runs are non-deterministic. +import random as _random +_seed_raw = (os.environ.get("HERDING_SEED") + or _runtime_cfg.get("HERDING_SEED") + or "").strip() +if _seed_raw: + try: + HERDING_SEED = int(_seed_raw) + except ValueError: + HERDING_SEED = None + print(f"[dog] could not parse HERDING_SEED={_seed_raw!r}; using random") + else: + _random.seed(HERDING_SEED) +else: + HERDING_SEED = None + # GT positions from sheep emitters — used **only** for the auto-finish -# sentinel and the GT_penned diagnostic line. Never fed into control. +# sentinel, the GT_penned diagnostic line, and the per-sheep pen-time +# metrics printed at end of run. Never fed into control. _gt_sheep: dict = {} +_pen_step: dict = {} # sheep name -> step at which it first became penned _run_done = False +_t_start = None # step at which we first see GT positions (sim start) prev_action = (0.0, 0.0, 0.0) if DRIVE_MODE == "mecanum" else (0.0, 0.0) step_count = 0 @@ -536,10 +558,20 @@ while robot.step(timestep) != -1: left_ear.setPosition(ear_pos) right_ear.setPosition(-ear_pos) - # Auto-finish: when all GT sheep are penned, write the sentinel. - # The launcher polls for it and closes Webots so batch evals don't - # hang after the task is done. Bounded by `_gt_sheep` so we don't - # fire during the first few steps while the receiver fills. + # First step we have GT visibility — record the simulation start + # so per-sheep pen times can be reported relative to it. + if _gt_sheep and _t_start is None: + _t_start = step_count + + # Record the first step at which each sheep is observed penned. + for _sname, (_sx, _sy) in _gt_sheep.items(): + if _sname not in _pen_step and is_penned(_sx, _sy): + _pen_step[_sname] = step_count + + # Auto-finish: when all GT sheep are penned, write the sentinel + # and print the per-sheep penning summary so the operator sees + # the metrics in the terminal. The launcher polls for the + # sentinel and closes Webots cleanly. if _gt_sheep and not _run_done: gt_active = sum(1 for x, y in _gt_sheep.values() if not is_penned(x, y)) @@ -549,6 +581,26 @@ while robot.step(timestep) != -1: _run_done = True print(f"[dog] all {len(_gt_sheep)} sheep penned at step " f"{step_count} — wrote sentinel, launcher will close Webots") + # Only the first dog to detect the finish prints the + # summary (in dual-dog mode both run in lock-step but the + # sentinel acts as a one-shot lock). + _dt = 0.016 # Webots basicTimeStep, seconds + print("") + print(f"[results] mode={MODE} drive={DRIVE_MODE} world={WORLD} " + f"lidar={LIDAR_FOV_VARIANT} dogs={DOG_NAME}" + + (f" seed={HERDING_SEED}" if HERDING_SEED is not None else "")) + print(f"[results] total steps: {step_count} " + f"({step_count * _dt:.1f} s simulated)") + ordered = sorted(_pen_step.items(), key=lambda kv: kv[1]) + for i, (sn, st) in enumerate(ordered, 1): + rel = st - (_t_start or 0) + print(f"[results] #{i} {sn:8s} penned at step {st:>6d} " + f"({rel * _dt:6.2f} s)") + if len(ordered) >= 2: + first = ordered[0][1] + last = ordered[-1][1] + print(f"[results] gate spread: {last - first} steps " + f"({(last - first) * _dt:.2f} s) between first and last pen") if step_count % 200 == 0: gt_penned = sum(1 for x, y in _gt_sheep.values() diff --git a/tools/run_webots.sh b/tools/run_webots.sh index 515a1b6..d263a14 100755 --- a/tools/run_webots.sh +++ b/tools/run_webots.sh @@ -232,6 +232,7 @@ HERDING_LIDAR=$LIDAR_VARIANT HERDING_NDOGS=$NDOGS HERDING_AXIS_LEAK=${HERDING_AXIS_LEAK:-0.3} HERDING_USE_GT=${HERDING_USE_GT:-0} +HERDING_SEED=${HERDING_SEED:-} EOF export HERDING_MODE="$MODE" diff --git a/tools/webots_menu.sh b/tools/webots_menu.sh index 50b17bf..6cbc122 100755 --- a/tools/webots_menu.sh +++ b/tools/webots_menu.sh @@ -142,6 +142,17 @@ ask_choice "Perception" "lidar" \ if [[ "$CHOICE" == "gt" ]]; then USE_GT=1; else USE_GT=0; fi echo +ask_choice "Seed" "random" \ + "Random (different sheep wander each run):random" \ + "Fixed seed (reproducible run — pick an integer):fixed" +if [[ "$CHOICE" == "fixed" ]]; then + ask_int " → Seed value" 0 0 1000000 + SEED="$CHOICE" +else + SEED="" +fi +echo + ask_choice "Headless?" "no" \ "No — show the Webots window:no" \ "Yes — headless, fast simulation (xvfb-run):yes" @@ -158,6 +169,7 @@ ${B}${C}── Launch configuration ────────────── Dogs : ${B}$NDOGS${R}$( [[ "$NDOGS" == "2" ]] && echo " (axis_leak=${B}$AXIS_LEAK${R})" ) Sheep : ${B}$N_SHEEP${R} Perception : ${B}$( [[ "$USE_GT" == "1" ]] && echo "GT bypass" || echo "LiDAR" )${R} + Seed : ${B}$( [[ -n "$SEED" ]] && echo "$SEED" || echo "random" )${R} Headless : ${B}$HEADLESS${R} ${C}──────────────────────────────────────────────────────────────────${R} @@ -173,6 +185,7 @@ export HERDING_LIDAR="$LIDAR" export HERDING_NDOGS="$NDOGS" export HERDING_USE_GT="$USE_GT" [[ -n "${AXIS_LEAK:-}" ]] && export HERDING_AXIS_LEAK="$AXIS_LEAK" +[[ -n "$SEED" ]] && export HERDING_SEED="$SEED" if [[ "$HEADLESS" == "yes" ]]; then export WEBOTS_HEADLESS=1 export WEBOTS_EXTRA_ARGS="--stdout --stderr"