From 9da1bd2c32451dce34a58f297af8a305dbd1ac1e Mon Sep 17 00:00:00 2001 From: jepson2k <55201008+Jepson2k@users.noreply.github.com> Date: Mon, 25 May 2026 16:28:37 -0400 Subject: [PATCH 01/10] thread camera_spec through tool registry waldoctl's ToolSpec now carries an optional camera_spec (default camera attached to a tool) plus a user-tweakable ToolRuntimeSettings override layer. ToolConfig grows a matching optional camera_spec field and _build_tools passes it into each tool constructor; tools that omit it behave as before (effective_camera_device returns None unless the user sets a runtime override). Lets the frontend stream a tool-mounted camera feed only when the active tool has one configured, and lets the user remap the device per session (e.g. virtual /dev/videoN for AI-annotation pipelines) without reinstalling the backend. Co-Authored-By: Claude Opus 4.7 (1M context) --- parol6/robot.py | 1 + parol6/tools.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/parol6/robot.py b/parol6/robot.py index ac444f5..cfbd5e9 100644 --- a/parol6/robot.py +++ b/parol6/robot.py @@ -473,6 +473,7 @@ def _build_tools() -> _ToolsCollection: meshes=cfg.meshes, motions=cfg.motions, variants=cfg.variants, + camera_spec=cfg.camera_spec, ) if isinstance(cfg, PneumaticGripperConfig): diff --git a/parol6/tools.py b/parol6/tools.py index a9f81de..c2f890d 100644 --- a/parol6/tools.py +++ b/parol6/tools.py @@ -17,6 +17,7 @@ import numpy as np from pinokin import se3_from_trans from waldoctl import ( + CameraSpec, LinearMotion, MeshRole, MeshSpec, @@ -77,6 +78,11 @@ class ToolConfig: meshes: tuple[MeshSpec, ...] = () motions: tuple[PartMotion, ...] = () variants: tuple[ToolVariant, ...] = () + camera_spec: "CameraSpec | None" = None + """Optional camera attached to this tool. Wired through ``_build_tools`` + into the live ``ToolSpec`` so the frontend can stream the feed when this + tool is active. The user can override the device per session via the + tool's ``runtime_settings.camera_device``.""" def populate_status(self, hw: ControllerState, out: ToolStatus) -> None: """Fill *out* from hardware state. Override in subclasses.""" From 24b601e635391df5b2e0eb00680241e8fa910571 Mon Sep 17 00:00:00 2001 From: jepson2k <55201008+Jepson2k@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:06:01 -0400 Subject: [PATCH 02/10] parol6: fix _ToolsCollection membership + by_type for StrEnum ToolType MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit waldoctl's ToolType is now a StrEnum (category members are also str) and the ToolsSpec.by_type ABC widened to `str | ToolType`: - __contains__: test ToolType before the plain-str branch — otherwise `ToolType.GRIPPER in tools` misrouted to the by-key lookup (keyed by tool key, not category) and returned False. Safe under plain Enum and StrEnum. - by_type: widen the parameter to `str | ToolType` to match the widened ABC (LSP-safe against both the old and new base signatures). Co-Authored-By: Claude Opus 4.8 (1M context) --- parol6/robot.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/parol6/robot.py b/parol6/robot.py index cfbd5e9..e0ebf36 100644 --- a/parol6/robot.py +++ b/parol6/robot.py @@ -404,13 +404,16 @@ def __getitem__(self, key: str) -> ToolSpec: return self._by_key[key] def __contains__(self, item: object) -> bool: - if isinstance(item, str): - return item in self._by_key + # ToolType is a StrEnum, so test it before the plain-str branch — + # otherwise a category like ToolType.GRIPPER would misroute to the + # by-key lookup (keyed by tool key, not category) and return False. if isinstance(item, ToolType): return any(t.tool_type == item for t in self._tools) + if isinstance(item, str): + return item in self._by_key return False - def by_type(self, tool_type: ToolType) -> tuple[ToolSpec, ...]: + def by_type(self, tool_type: str | ToolType) -> tuple[ToolSpec, ...]: return tuple(t for t in self._tools if t.tool_type == tool_type) From bd72a3385581970d794425c8bdd183478c5eaf32 Mon Sep 17 00:00:00 2001 From: jepson2k <55201008+Jepson2k@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:33:01 -0400 Subject: [PATCH 03/10] CI: install waldoctl branch override AFTER .[dev] so the pinned tag can't clobber it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The matching-waldoctl-branch override ran before `pip install -e .[dev]`, so the pyproject-pinned `waldoctl@v0.2.0` reinstall clobbered it — and v0.2.0 predates `CameraSpec`, so tests/conftest.py failed at import on every runner. Move the override after .[dev] with --force-reinstall --no-deps (and skip it on main so main CI exercises the released pin), mirroring waldo-commander's CI. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/tests.yml | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 828d6e7..f1cfbed 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,11 +24,15 @@ jobs: shell: bash run: | python -m pip install --upgrade pip + pip install -e ".[dev]" + # Override the pinned waldoctl with the matching feature branch if one + # exists, AFTER ".[dev]" (with --force-reinstall --no-deps) so the + # pinned tag in pyproject can't clobber it. Skipped on main so main CI + # exercises the released pin. BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}" - if git ls-remote --heads https://github.com/Jepson2k/waldoctl.git "$BRANCH" 2>/dev/null | grep -q .; then - pip install "waldoctl @ git+https://github.com/Jepson2k/waldoctl.git@${BRANCH}" + if [ "$BRANCH" != "main" ] && git ls-remote --heads https://github.com/Jepson2k/waldoctl.git "$BRANCH" 2>/dev/null | grep -q .; then + pip install --force-reinstall --no-deps "waldoctl @ git+https://github.com/Jepson2k/waldoctl.git@${BRANCH}" fi - pip install -e ".[dev]" - name: Run pre-commit uses: pre-commit/action@v3.0.1 test: @@ -52,20 +56,20 @@ jobs: cache: pip cache-dependency-path: pyproject.toml - - name: Install cross-repo dependencies + - name: Install package shell: bash run: | python -m pip install --upgrade pip + pip install -e ".[dev]" pytest-timeout + # Override the pinned waldoctl with the matching feature branch if one + # exists, AFTER ".[dev]" (with --force-reinstall --no-deps) so the + # pinned tag in pyproject can't clobber it. Skipped on main so main CI + # exercises the released pin. BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}" - # waldoctl: try matching branch, fall back to tagged version - if git ls-remote --heads https://github.com/Jepson2k/waldoctl.git "$BRANCH" 2>/dev/null | grep -q .; then - pip install "waldoctl @ git+https://github.com/Jepson2k/waldoctl.git@${BRANCH}" + if [ "$BRANCH" != "main" ] && git ls-remote --heads https://github.com/Jepson2k/waldoctl.git "$BRANCH" 2>/dev/null | grep -q .; then + pip install --force-reinstall --no-deps "waldoctl @ git+https://github.com/Jepson2k/waldoctl.git@${BRANCH}" fi - - name: Install package - run: | - pip install -e ".[dev]" pytest-timeout - - name: Show environment run: | python -V From 2794778c15ed6c4d721a774b7becc4ee1f817f4f Mon Sep 17 00:00:00 2001 From: jepson2k <55201008+Jepson2k@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:02:18 -0400 Subject: [PATCH 04/10] CI: keep waldoctl's deps when overriding (refactored waldoctl needs nicegui) The previous fix used --force-reinstall --no-deps (copied from waldo-commander, which already installs nicegui directly). parol6 doesn't, and the refactored waldoctl imports nicegui (binding.bindable_dataclass on its state classes), so --no-deps left every test job at ModuleNotFoundError: nicegui. Drop --no-deps so waldoctl's declared deps are installed; --force-reinstall still guarantees the pinned v0.2.0 is replaced by the branch build. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/tests.yml | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f1cfbed..7a06f83 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,12 +26,13 @@ jobs: python -m pip install --upgrade pip pip install -e ".[dev]" # Override the pinned waldoctl with the matching feature branch if one - # exists, AFTER ".[dev]" (with --force-reinstall --no-deps) so the - # pinned tag in pyproject can't clobber it. Skipped on main so main CI - # exercises the released pin. + # exists, AFTER ".[dev]" (and with --force-reinstall) so the pinned tag + # in pyproject can't clobber it. Deps are kept (no --no-deps): the + # refactored waldoctl imports nicegui, which parol6 doesn't otherwise + # install. Skipped on main so main CI exercises the released pin. BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}" if [ "$BRANCH" != "main" ] && git ls-remote --heads https://github.com/Jepson2k/waldoctl.git "$BRANCH" 2>/dev/null | grep -q .; then - pip install --force-reinstall --no-deps "waldoctl @ git+https://github.com/Jepson2k/waldoctl.git@${BRANCH}" + pip install --force-reinstall "waldoctl @ git+https://github.com/Jepson2k/waldoctl.git@${BRANCH}" fi - name: Run pre-commit uses: pre-commit/action@v3.0.1 @@ -62,12 +63,13 @@ jobs: python -m pip install --upgrade pip pip install -e ".[dev]" pytest-timeout # Override the pinned waldoctl with the matching feature branch if one - # exists, AFTER ".[dev]" (with --force-reinstall --no-deps) so the - # pinned tag in pyproject can't clobber it. Skipped on main so main CI - # exercises the released pin. + # exists, AFTER ".[dev]" (and with --force-reinstall) so the pinned tag + # in pyproject can't clobber it. Deps are kept (no --no-deps): the + # refactored waldoctl imports nicegui, which parol6 doesn't otherwise + # install. Skipped on main so main CI exercises the released pin. BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}" if [ "$BRANCH" != "main" ] && git ls-remote --heads https://github.com/Jepson2k/waldoctl.git "$BRANCH" 2>/dev/null | grep -q .; then - pip install --force-reinstall --no-deps "waldoctl @ git+https://github.com/Jepson2k/waldoctl.git@${BRANCH}" + pip install --force-reinstall "waldoctl @ git+https://github.com/Jepson2k/waldoctl.git@${BRANCH}" fi - name: Show environment From db35127b567eebda43676d8005dfc5241f1f19fd Mon Sep 17 00:00:00 2001 From: jepson2k <55201008+Jepson2k@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:05:16 -0400 Subject: [PATCH 05/10] parol6: warm all JIT at startup, quiet the numba SSA flood, add warmup test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The controller logs at --log-level=DEBUG under tests (pyproject log_level=DEBUG), which left numba's per-statement compiler tracers (byteflow/ssa) at DEBUG too. During the cold ~14s JIT warmup that firehose (~1300 lines/function) stalled controller startup enough to push the first integration test's 5s home past timeout on slow runners — the Windows CI failure (reproduced on a Raspberry Pi: cold+DEBUG fails, cold+quiet passes). - cli.py: pin numba.core byteflow/ssa/interpreter/typeinfer to WARNING even at DEBUG (compiler-internal noise). Higher-level numba and parol6 logs unchanged. - warmup.py: warm the two real-serial frame functions (closing a coverage gap), and log a clear one-time 'compiling, please wait' message + cold-start progress so a cold start isn't mistaken for a hang. - tests/test_jit_warmup.py: assert every parol6 @njit is compiled by warmup, and that a representative motion triggers no new compilation — a CI gate naming any function someone forgets to add to warmup. Co-Authored-By: Claude Opus 4.8 (1M context) --- parol6/server/cli.py | 18 ++++++- parol6/utils/warmup.py | 33 +++++++++++-- tests/test_jit_warmup.py | 101 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 tests/test_jit_warmup.py diff --git a/parol6/server/cli.py b/parol6/server/cli.py index e979003..57f9a7b 100644 --- a/parol6/server/cli.py +++ b/parol6/server/cli.py @@ -76,13 +76,29 @@ def main() -> int: ) # At INFO and above, push noisy third-party libraries up to WARNING so a - # clean startup stays quiet. DEBUG/TRACE users still see everything. + # clean startup stays quiet. quiet_level = ( max(log_level, logging.WARNING) if log_level >= logging.INFO else log_level ) for name in ("toppra", "numba", "numba.core", "numba.cuda"): logging.getLogger(name).setLevel(quiet_level) + # numba's per-statement compiler tracers emit hundreds of log lines per + # JIT'd function (byteflow ~900, ssa ~370 each). During the cold JIT warmup + # that firehose — tens of thousands of lines — is slow enough to stall + # controller startup on slow runners; it pushed the first integration test's + # 5s home past timeout on Windows CI. Pin just those tracers to WARNING even + # at DEBUG. Higher-level numba logging stays at the chosen level, and the + # warmup logs its own progress, so "it's compiling, not frozen" is still + # visible without the firehose. + for name in ( + "numba.core.byteflow", + "numba.core.ssa", + "numba.core.interpreter", + "numba.core.typeinfer", + ): + logging.getLogger(name).setLevel(logging.WARNING) + # Pre-compile numba JIT functions to avoid mid-loop compilation stalls from parol6.utils.warmup import warmup_jit diff --git a/parol6/utils/warmup.py b/parol6/utils/warmup.py index d2191c5..b4d3739 100644 --- a/parol6/utils/warmup.py +++ b/parol6/utils/warmup.py @@ -2,7 +2,8 @@ JIT warmup utilities. Call warmup_jit() on startup to pre-compile all numba functions before the control loop. -With cache=True, this is fast (~100ms) if cache exists, slower (~3-10s) on first run. +With cache=True, this is fast (~100ms) if the on-disk cache exists, slower (~10-30s on a +slow machine) on a cold first run. """ import logging @@ -65,6 +66,10 @@ _simulate_motion_jit, _write_frame_jit, ) +from parol6.server.transports.serial_transport import ( + _append_to_ring_numba, + _parse_frames_njit, +) from parol6.utils.ik import _check_limits_core, _ik_safety_check from pinokin import warmup_numba_se3 @@ -77,9 +82,20 @@ def warmup_jit() -> float: Returns the time taken in seconds. """ - logging.info("Warming JIT...") + logger.info( + "Warming up numba JIT compiler (first run is a one-time cold compile, " + "~10-30s on a slow machine; cached afterwards so later starts are " + "instant). This is normal startup, not a hang..." + ) start = time.perf_counter() + def _progress(label: str) -> None: + # Only chatter when it's genuinely a slow cold compile, so warm-cache + # starts stay quiet while a cold start visibly shows it isn't frozen. + elapsed = time.perf_counter() - start + if elapsed > 1.0: + logger.info(" ...JIT warmup: %s ready (%.1fs)", label, elapsed) + # Warm up pinokin's zero-allocation SE3/SO3 functions warmup_numba_se3() @@ -105,6 +121,7 @@ def warmup_jit() -> float: speed_steps_to_rad_scalar(0.0, 0) speed_rad_to_steps(dummy_6f, out_6i) speed_rad_to_steps_scalar(0.0, 0) + _progress("joint conversions") # parol6/utils/ik.py _ik_safety_check(dummy_6f, dummy_6f, dummy_6f, dummy_6f, dummy_6f, dummy_6f) @@ -203,6 +220,15 @@ def warmup_jit() -> float: dummy_grip_out, # grip_out ) + # parol6/server/transports/serial_transport.py - real-hardware frame I/O. + # Not exercised by the simulator, but warmed so a hardware controller does + # not cold-compile these on its first serial frame. + dummy_ring = np.zeros(256, dtype=np.uint8) + dummy_src = np.zeros(8, dtype=np.uint8) + _append_to_ring_numba(dummy_ring, dummy_src, 8, 256, 0, 0) + _parse_frames_njit(dummy_ring, 0, 0, 256, np.zeros(64, dtype=np.uint8)) + _progress("protocol & kinematics") + # parol6/server/loop_timer.py - stats computation dummy_1000f = np.zeros(1000, dtype=np.float64) dummy_1000f_scratch = np.zeros(1000, dtype=np.float64) @@ -268,6 +294,7 @@ def warmup_jit() -> float: dummy_timing, # timing_in dummy_gripper_in, # gripper_in ) + _progress("simulator & I/O") # Workspace arrays for jit functions below (SE3 funcs already warmed by pinokin) dummy_twist = np.zeros(6, dtype=np.float64) @@ -305,5 +332,5 @@ def warmup_jit() -> float: _smooth_singularity_outliers(dummy_chain) elapsed = time.perf_counter() - start - logger.info(f"\tJIT warmup completed in {elapsed * 1000:.1f}ms") + logger.info("JIT warmup complete (%.1fs).", elapsed) return elapsed diff --git a/tests/test_jit_warmup.py b/tests/test_jit_warmup.py new file mode 100644 index 0000000..2ba1ca4 --- /dev/null +++ b/tests/test_jit_warmup.py @@ -0,0 +1,101 @@ +"""Regression tests: every parol6 numba function must be compiled at warmup. + +The control loop runs at 100Hz with no headroom for mid-loop JIT compilation, +so ``warmup_jit()`` pre-compiles all ``@njit`` functions on startup. These tests +guard that contract so a forgotten warmup entry fails CI loudly (by name) +instead of silently cold-compiling in the control loop — which, on a slow +runner, stalled the first integration test's home past its timeout. + +- ``test_all_parol6_njit_compiled_at_warmup`` — every ``@njit`` function in the + parol6 package has a compiled specialization after ``warmup_jit()``. Catches a + new ``@njit`` that nobody added to the warmup. +- ``test_no_recompile_during_motion`` — a representative motion compiles no new + specializations. Catches a function warmed with the wrong argument *types* + (numba keys one specialization per signature), which would recompile — and + stall — on the first real call. +""" + +from __future__ import annotations + +import gc +import importlib +import pkgutil + +import pytest +from numba.core.registry import CPUDispatcher + +import parol6 +from parol6.client.dry_run_client import DryRunRobotClient +from parol6.utils.warmup import warmup_jit + +# Valid PAROL6 joint targets (deg), within limits — home/standby plus two +# reachable waypoints, enough to exercise move planning, IK and blending. +_HOME = [90.0, -90.0, 180.0, 0.0, 0.0, 180.0] +_W1 = [80.0, -80.0, 190.0, 10.0, 10.0, 190.0] +_W2 = [70.0, -70.0, 200.0, 20.0, 20.0, 200.0] + + +def _parol6_dispatchers() -> list[CPUDispatcher]: + """Every numba ``CPUDispatcher`` defined in the parol6 package. + + Imports every parol6 submodule first so a function is not missed merely + because nothing has imported its module yet. + """ + for mod in pkgutil.walk_packages(parol6.__path__, "parol6."): + try: + importlib.import_module(mod.name) + except Exception: # optional/hardware-only modules may not import here + continue + seen: dict[int, CPUDispatcher] = { + id(d): d + for d in gc.get_objects() + if isinstance(d, CPUDispatcher) and d.py_func.__module__.startswith("parol6.") + } + return list(seen.values()) + + +def _name(d: CPUDispatcher) -> str: + return f"{d.py_func.__module__}.{d.py_func.__name__}" + + +@pytest.mark.unit +def test_all_parol6_njit_compiled_at_warmup() -> None: + """``warmup_jit()`` must compile every parol6 ``@njit`` function.""" + warmup_jit() + unwarmed = sorted(_name(d) for d in _parol6_dispatchers() if not d.signatures) + assert not unwarmed, ( + "These parol6 @njit functions are not compiled by warmup_jit(), so the " + "control loop would cold-compile them mid-run. Add them to " + "parol6/utils/warmup.py:\n " + "\n ".join(unwarmed) + ) + + +@pytest.mark.unit +def test_no_recompile_during_motion() -> None: + """A representative motion must trigger no new JIT compilation. + + Exercises the planning/IK/trajectory hot path through the in-process dry-run + simulator after warmup; any new specialization means warmup used argument + types that differ from the real call. + """ + warmup_jit() + dispatchers = _parol6_dispatchers() + before = {id(d): len(d.signatures) for d in dispatchers} + + client = DryRunRobotClient() + client.move_j(_HOME, speed=0.5) + client.move_j(_W1, speed=0.5, r=5.0) # blended + client.move_j(_W2, speed=0.5, r=5.0) # blended + client.move_j(_HOME, speed=0.5) + client.wait_motion(timeout=10.0) + + recompiled = sorted( + f"{_name(d)}: {before[id(d)]} -> {len(d.signatures)}" + for d in dispatchers + if len(d.signatures) > before[id(d)] + ) + assert not recompiled, ( + "These parol6 @njit functions compiled a new specialization during " + "motion — warmup_jit() warmed them with the wrong argument types:\n " + + "\n ".join(recompiled) + ) From 7498edfebedd28349fbc0eb29a1c08b1e388892e Mon Sep 17 00:00:00 2001 From: jepson2k <55201008+Jepson2k@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:14:34 -0400 Subject: [PATCH 06/10] parol6: serve first motion command without the ~5s planner-startup stall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The planner runs in a spawn()ed subprocess. The controller reported ready and accepted the first motion command before that worker finished its ~5s startup (fresh interpreter re-importing the full stack), so the command sat in the queue racing the worker — which on slow CI runners pushed the first integration test's home past its 5s timeout (the Windows 3.11/3.12 residual). - MotionPlanner.start() now blocks on a ready Event until the worker has finished importing + building, so the controller does not accept commands before the planner can serve them. Measured: first home 5.0s -> 0.24s. - The worker no longer inherits the controller's single-core CPU pin. The controller pins itself AFTER spawning the worker (split _set_high_priority into priority + _loop_core/_pin_to_core), and the worker is told which core to avoid, so runtime planning never competes with the real-time loop. Affinity [3]->[0,1,2]. Co-Authored-By: Claude Opus 4.8 (1M context) --- parol6/server/controller.py | 60 ++++++++++++++++++++++----------- parol6/server/motion_planner.py | 58 +++++++++++++++++++++++++++++-- 2 files changed, 95 insertions(+), 23 deletions(-) diff --git a/parol6/server/controller.py b/parol6/server/controller.py index 44d6a5d..05aae5d 100644 --- a/parol6/server/controller.py +++ b/parol6/server/controller.py @@ -231,8 +231,15 @@ def start(self): # Start async logging to move I/O off the control loop thread self._async_log.start() - # Start motion planner subprocess - self._planner.start() + # Spawn the motion planner subprocess BEFORE pinning ourselves to a core, + # so the child inherits the full CPU affinity (its heavy spawn import runs + # on any free core) and we tell it which core to keep off at runtime. + loop_core = self._loop_core() + self._planner.start(avoid_core=loop_core) + + # Now pin ourselves to the real-time core — after the child has spawned. + if loop_core is not None: + self._pin_to_core(loop_core) # Disable automatic GC — collections are deferred to slack time self._gc_tracker.take_control() @@ -832,15 +839,17 @@ def _assign_command_index(self, state: ControllerState) -> int: return idx def _set_high_priority(self) -> bool: - """Set highest non-privileged process priority and pin to CPU core. + """Elevate this process's scheduling priority. + + CPU-core pinning is intentionally separate (see :meth:`_loop_core` / + :meth:`_pin_to_core`) and done *after* the planner subprocess is spawned, + so the child does not inherit a single-core affinity. Returns True if priority was successfully elevated. """ elevated = False try: p = psutil.Process() - - # Set priority if sys.platform == "win32": p.nice(psutil.HIGH_PRIORITY_CLASS) logger.debug("Set process priority to HIGH_PRIORITY_CLASS") @@ -852,20 +861,31 @@ def _set_high_priority(self) -> bool: elevated = True except psutil.AccessDenied: logger.debug("Cannot set negative nice value without privileges") - - # Pin to last CPU core (usually less contention from system tasks) - if hasattr(p, "cpu_affinity"): - try: - cpus = p.cpu_affinity() - if cpus and len(cpus) > 1: - target_core = cpus[-1] - p.cpu_affinity([target_core]) - logger.debug(f"Pinned process to CPU core {target_core}") - except (AttributeError, NotImplementedError): - logger.debug("CPU affinity not supported on this platform") - except psutil.AccessDenied: - logger.debug("Cannot set CPU affinity without privileges") - except Exception as e: - logger.warning(f"Failed to set process priority/affinity: {e}") + logger.warning(f"Failed to set process priority: {e}") return elevated + + def _loop_core(self) -> int | None: + """The CPU core the control loop will pin to — the last core, usually the + least contended by system tasks — or None if pinning isn't applicable.""" + try: + p = psutil.Process() + if hasattr(p, "cpu_affinity"): + cpus = p.cpu_affinity() + if cpus and len(cpus) > 1: + return cpus[-1] + except (AttributeError, NotImplementedError, psutil.Error) as e: + logger.debug("CPU affinity not available: %s", e) + return None + + def _pin_to_core(self, core: int) -> None: + """Pin this process to a single core for the real-time loop. + + Called after :meth:`MotionPlanner.start` so the planner child (spawned + with the full affinity) is never stuck on the loop's core. + """ + try: + psutil.Process().cpu_affinity([core]) + logger.debug("Pinned process to CPU core %d", core) + except (AttributeError, NotImplementedError, psutil.AccessDenied) as e: + logger.debug("Could not pin to CPU core: %s", e) diff --git a/parol6/server/motion_planner.py b/parol6/server/motion_planner.py index 82fe06f..57f2ea2 100644 --- a/parol6/server/motion_planner.py +++ b/parol6/server/motion_planner.py @@ -523,6 +523,8 @@ def motion_planner_main( command_queue: multiprocessing.Queue, segment_queue: multiprocessing.Queue, shutdown_event: EventType, + ready_event: EventType, + avoid_core: int | None = None, ) -> None: """Worker process main loop — compute trajectories and forward inline commands.""" signal.signal(signal.SIGINT, signal.SIG_IGN) @@ -530,8 +532,32 @@ def motion_planner_main( set_pdeathsig() + # Keep planning off the control loop's core so it never steals real-time + # cycles. We were spawned before the controller pinned itself, so we still + # hold the full affinity here and just drop the loop's core. + if avoid_core is not None: + try: + import os + + import psutil + + other_cores = [c for c in range(os.cpu_count() or 1) if c != avoid_core] + if other_cores: + psutil.Process().cpu_affinity(other_cores) + logger.debug( + "Planner worker avoiding loop core %d -> %s", + avoid_core, + other_cores, + ) + except (NotImplementedError, AttributeError, OSError, psutil.Error) as e: + logger.debug("Planner worker affinity unchanged: %s", e) + worker = PlannerWorker(segment_queue) + # Signal the parent that the heavy spawn startup (imports + planner build) is + # done, so it can finish coming up and start accepting commands. + ready_event.set() + logger.debug( "Motion planner subprocess started (PID %d)", multiprocessing.current_process().pid, @@ -616,23 +642,49 @@ def __init__(self) -> None: self._command_queue: multiprocessing.Queue = multiprocessing.Queue() self._segment_queue: multiprocessing.Queue = multiprocessing.Queue() self._shutdown_event: EventType = multiprocessing.Event() + self._ready_event: EventType = multiprocessing.Event() self._process: multiprocessing.Process | None = None # -- lifecycle -- - def start(self) -> None: - """Start the planner subprocess.""" + def start(self, avoid_core: int | None = None) -> None: + """Start the planner subprocess. + + ``avoid_core`` is the CPU core the controller's real-time loop will pin + to; the worker keeps off it so planning never competes with the loop. + Spawn happens before the controller pins itself, so the worker inherits + the full affinity and its heavy import runs on any free core. + """ if self._process is not None and self._process.is_alive(): return self._shutdown_event.clear() + self._ready_event.clear() self._process = multiprocessing.Process( target=motion_planner_main, - args=(self._command_queue, self._segment_queue, self._shutdown_event), + args=( + self._command_queue, + self._segment_queue, + self._shutdown_event, + self._ready_event, + avoid_core, + ), daemon=True, name="MotionPlannerProcess", ) self._process.start() logger.debug("Motion planner started, PID: %s", self._process.pid) + # Block until the worker finishes its heavy spawn startup (a fresh + # interpreter re-importing the full stack — ~2s, more on slow runners). + # start() runs before the control loop, so waiting here keeps that import + # off the CPU the loop will pin (avoiding ~3s of contention) and ensures + # the controller does not accept a motion command before the worker can + # process it (which otherwise stalls the first command for ~5s). + if self._ready_event.wait(timeout=30.0): + logger.debug("Motion planner worker ready") + else: + logger.warning( + "Motion planner worker not ready after 30s; continuing anyway" + ) def stop(self) -> None: """Shut down the planner subprocess gracefully.""" From 11902783c43d3621030ebf74ba725778ba0d7099 Mon Sep 17 00:00:00 2001 From: jepson2k <55201008+Jepson2k@users.noreply.github.com> Date: Wed, 17 Jun 2026 18:13:28 -0400 Subject: [PATCH 07/10] parol6: probe a free status port in tests instead of the fixed 50510 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status multicast/unicast port was hard-coded to 50510 in config and never probed, unlike the command/ack ports. On Windows CI runners where 50510 falls in an OS-reserved/excluded UDP range, binding the status socket fails with WinError 10013, flaking every integration + status test — per-runner, so the same commit passed on one run and failed on another (PR #18's push vs PR run). Allocate the status port via OS ephemeral bind(("", 0)) — guaranteed outside reserved ranges, bound the same all-interfaces way the real socket binds — and thread it to both ends: PAROL6_MCAST_PORT for the controller subprocess and a live parol6.config.MCAST_PORT patch for the in-process client. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/conftest.py | 91 ++++++++++++++++++++++------------------------- 1 file changed, 42 insertions(+), 49 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index d57fa99..4cec11a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,37 +7,24 @@ import logging import os +import socket from collections.abc import Generator from dataclasses import dataclass import pytest -from parol6 import Robot +from parol6 import Robot, config as cfg -# Import utilities for port detection -def find_available_ports(start_port: int = 5001, count: int = 2) -> list[int]: - """Simple fallback port finder if utils import fails.""" - import socket +def free_udp_port() -> int: + """Allocate a free UDP port from the OS ephemeral range. - available_ports: list[int] = [] - current_port = start_port - - while len(available_ports) < count: - try: - with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: - sock.bind(("127.0.0.1", current_port)) - available_ports.append(current_port) - except OSError: - # Port in use, reset search if we were building a consecutive sequence - available_ports.clear() - - current_port += 1 - - # Prevent infinite loop - if current_port > start_port + 1000: - break - - return available_ports + Binds ("", 0) so the kernel hands back a usable port — never one in a reserved or + excluded range — bound the same all-interfaces way the real sockets bind. Avoids the + fixed-port WinError 10013 flake that hit the hard-coded status port on Windows. + """ + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.bind(("", 0)) + return sock.getsockname()[1] logger = logging.getLogger(__name__) @@ -49,7 +36,7 @@ class TestPorts: server_ip: str = "127.0.0.1" server_port: int = 5001 - ack_port: int = 5002 + mcast_port: int = 50510 # ============================================================================ @@ -73,11 +60,11 @@ def pytest_addoption(parser): help="Port for robot server communication (auto-detected if not specified)", ) parser.addoption( - "--ack-port", + "--mcast-port", action="store", type=int, default=None, - help="Port for acknowledgment communication (auto-detected if not specified)", + help="Port for status multicast/unicast (auto-detected if not specified)", ) parser.addoption( "--keep-server-running", @@ -121,24 +108,23 @@ def ports(request) -> TestPorts: """ server_ip = request.config.getoption("--server-ip") server_port = request.config.getoption("--server-port") - ack_port = request.config.getoption("--ack-port") - - # Auto-detect available ports if not specified - if server_port is None or ack_port is None: - logger.info("Auto-detecting available ports...") - available_ports = find_available_ports(start_port=5001, count=2) - - if len(available_ports) < 2: - pytest.fail("Could not find 2 consecutive available ports for testing") - - if server_port is None: - server_port = available_ports[0] - if ack_port is None: - ack_port = available_ports[1] - - logger.info(f"Using auto-detected ports: server={server_port}, ack={ack_port}") - - return TestPorts(server_ip=server_ip, server_port=server_port, ack_port=ack_port) + mcast_port = request.config.getoption("--mcast-port") + + # Auto-detect free ports if not specified. The status port in particular is + # otherwise a fixed default (50510) that nothing probes, so it can fail when that + # port is unavailable on a runner (e.g. Windows excluded-port ranges → WinError + # 10013 binding the status socket); ephemeral allocation avoids that. + if server_port is None: + server_port = free_udp_port() + if mcast_port is None: + mcast_port = free_udp_port() + logger.info(f"Test ports: server={server_port}, status={mcast_port}") + + return TestPorts( + server_ip=server_ip, + server_port=server_port, + mcast_port=mcast_port, + ) # ============================================================================ @@ -159,12 +145,18 @@ def robot_api_env(ports: TestPorts) -> Generator[dict[str, str], None, None]: env_vars = { "PAROL6_CONTROLLER_IP": ports.server_ip, "PAROL6_CONTROLLER_PORT": str(ports.server_port), + "PAROL6_MCAST_PORT": str(ports.mcast_port), } for key, value in env_vars.items(): original_env[key] = os.environ.get(key) os.environ[key] = value + # The env var only reaches fresh imports (the controller subprocess). The + # in-process client reads cfg.MCAST_PORT live, so patch the module attribute too. + original_mcast_port = cfg.MCAST_PORT + cfg.MCAST_PORT = ports.mcast_port + logger.debug(f"Set test environment: {env_vars}") try: @@ -176,6 +168,7 @@ def robot_api_env(ports: TestPorts) -> Generator[dict[str, str], None, None]: os.environ.pop(key, None) else: os.environ[key] = original_value + cfg.MCAST_PORT = original_mcast_port logger.debug("Restored original environment") @@ -202,6 +195,7 @@ def server_proc(request, ports: TestPorts, robot_api_env): "PAROL6_NOAUTOHOME": "1", "PAROL6_CONTROLLER_IP": ports.server_ip, "PAROL6_CONTROLLER_PORT": str(ports.server_port), + "PAROL6_MCAST_PORT": str(ports.mcast_port), }, ) @@ -312,11 +306,10 @@ def pytest_sessionstart(session): logger.info(f"Server IP: {config.getoption('--server-ip')}") server_port = config.getoption("--server-port") - ack_port = config.getoption("--ack-port") - if server_port and ack_port: - logger.info(f"Server ports: {server_port}/{ack_port}") + if server_port: + logger.info(f"Server port: {server_port}") else: - logger.info("Server ports: auto-detect") + logger.info("Server port: auto-detect") # ============================================================================ From d83739ceb9aa41ea09bd35ec9e9ac5ba532fdc59 Mon Sep 17 00:00:00 2001 From: jepson2k <55201008+Jepson2k@users.noreply.github.com> Date: Wed, 17 Jun 2026 19:04:57 -0400 Subject: [PATCH 08/10] parol6: use ephemeral ports for ad-hoc controller tests too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_server_manager started throwaway controllers on hard-coded high command ports (59997/59998), which — like the fixed status port — can land in a Windows excluded port range and fail the command-socket bind with WinError 10013 (flaky per-runner; surfaced on windows 3.11 after the status-port fix landed). Add a free_port fixture (reusing free_udp_port) that hands out an OS-ephemeral command port and pins the controller's status port off its fixed default via PAROL6_MCAST_PORT, so neither bind can hit a reserved port. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/conftest.py | 12 ++++++++++++ tests/unit/test_server_manager.py | 16 +++++++--------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 4cec11a..9fce845 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -127,6 +127,18 @@ def ports(request) -> TestPorts: ) +@pytest.fixture +def free_port(monkeypatch) -> int: + """A free command port for a test that starts its own throwaway controller. + + Also moves the controller's status port off its fixed default (via PAROL6_MCAST_PORT, + inherited by the subprocess) so neither bind can land on a Windows-reserved/excluded + port and fail with WinError 10013. + """ + monkeypatch.setenv("PAROL6_MCAST_PORT", str(free_udp_port())) + return free_udp_port() + + # ============================================================================ # ENVIRONMENT CONFIGURATION FIXTURE # ============================================================================ diff --git a/tests/unit/test_server_manager.py b/tests/unit/test_server_manager.py index e578117..25d4795 100644 --- a/tests/unit/test_server_manager.py +++ b/tests/unit/test_server_manager.py @@ -3,16 +3,15 @@ from parol6 import Robot -def test_is_available_false_when_no_server(): - robot = Robot(port=59999) +def test_is_available_false_when_no_server(free_port): + robot = Robot(port=free_port) assert not robot.is_available() -def test_robot_start_and_stop(): +def test_robot_start_and_stop(free_port): host = "127.0.0.1" - port = 59998 - robot = Robot(host=host, port=port, timeout=15.0) + robot = Robot(host=host, port=free_port, timeout=15.0) assert not robot.is_available() try: @@ -24,16 +23,15 @@ def test_robot_start_and_stop(): assert not robot.is_available() -def test_robot_start_fast_fails_when_already_running(): +def test_robot_start_fast_fails_when_already_running(free_port): host = "127.0.0.1" - port = 59997 - robot = Robot(host=host, port=port, timeout=15.0) + robot = Robot(host=host, port=free_port, timeout=15.0) try: robot.start() assert robot.is_available() - robot2 = Robot(host=host, port=port) + robot2 = Robot(host=host, port=free_port) with pytest.raises(RuntimeError): robot2.start() finally: From cdf1f956e53db36fdd868868832e220e26a5a6e1 Mon Sep 17 00:00:00 2001 From: jepson2k <55201008+Jepson2k@users.noreply.github.com> Date: Wed, 17 Jun 2026 22:03:38 -0400 Subject: [PATCH 09/10] parol6: drop tautological tcp-offset struct tests, add CLAUDE.md rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the three "wire round-trip" tests in test_tcp_offset.py that only construct SetTcpOffsetCmd/TcpOffsetResultStruct and assert the fields back — they restate the struct definitions, exercise no encode/decode, catch no bug. The planner-routing and apply_tool behavioral tests stay. Add the no-tautological-tests rule to CLAUDE.md Testing Guidelines. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 1 + tests/unit/test_tcp_offset.py | 26 +------------------------- 2 files changed, 2 insertions(+), 25 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 815e6c3..dfc312a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -144,6 +144,7 @@ For streamable commands (`streamable = True`), `do_setup()` also runs at high fr **When CI tests fail, fix them.** Don't waste time analyzing whether failures are "related to your changes" — just fix all failing tests. The goal is a green CI, not attribution. Prefer fewer, comprehensive integration tests that mimic manual testing over a large number of unit tests. We have no code coverage requirements—the goal is working features, not metrics. +- **No tautological tests.** Don't assert what's true by construction — e.g. a freshly built object's default field values. Test behavior, not the class's initializers. - **Test results are in `test-results.xml`.** Pytest writes JUnit XML to `test-results.xml` automatically. When diagnosing failures, read this file — it contains test names, durations, failure messages, and captured output. This is more reliable than parsing console output. - **NEVER run parol6 and web commander test suites in parallel** — no proper isolation, they share resources and have timing issues when resource-constrained. Always run sequentially. - **NEVER allow subagents to run tests.** Many tests are timing-sensitive and the system doesn't have enough resources for agents and tests to run simultaneously. Only the main conversation should run tests, and only after all agents have completed. diff --git a/tests/unit/test_tcp_offset.py b/tests/unit/test_tcp_offset.py index 950ca1a..f3fc638 100644 --- a/tests/unit/test_tcp_offset.py +++ b/tests/unit/test_tcp_offset.py @@ -2,31 +2,7 @@ import numpy as np -from parol6.protocol.wire import SelectToolCmd, SetTcpOffsetCmd, TcpOffsetResultStruct - - -# ── Wire round-trip ────────────────────────────────────────────────────── - - -def test_set_tcp_offset_cmd_fields(): - cmd = SetTcpOffsetCmd(x=1.0, y=2.0, z=-190.0) - assert cmd.x == 1.0 - assert cmd.y == 2.0 - assert cmd.z == -190.0 - - -def test_set_tcp_offset_cmd_defaults(): - cmd = SetTcpOffsetCmd() - assert cmd.x == 0.0 - assert cmd.y == 0.0 - assert cmd.z == 0.0 - - -def test_tcp_offset_result_struct(): - result = TcpOffsetResultStruct(x=10.0, y=20.0, z=-190.0) - assert result.x == 10.0 - assert result.y == 20.0 - assert result.z == -190.0 +from parol6.protocol.wire import SelectToolCmd, SetTcpOffsetCmd # ── apply_tool with tcp_offset_m ───────────────────────────────────────── From d606de09de5418128c1fbf37c2320f3877ef4c92 Mon Sep 17 00:00:00 2001 From: jepson2k <55201008+Jepson2k@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:40:13 -0400 Subject: [PATCH 10/10] parol6: purge remaining tautological tests (round 2) Remove construct-echo/default tests in test_movecart_command.py (5), test_reset_command.py (2, dropping the now-empty TestResetCommandParsing), test_query_commands_actions.py (2), and the hardware-config assertions in test_gripper_ramp.py (2). The validation tests (bad-input raising), execute_step side-effect tests, pose-conversion tests, and tick-range derivation stay. Broaden the CLAUDE.md rule to name the categories. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- tests/unit/test_gripper_ramp.py | 14 ----- tests/unit/test_movecart_command.py | 73 ----------------------- tests/unit/test_query_commands_actions.py | 19 ------ tests/unit/test_reset_command.py | 17 ------ 5 files changed, 1 insertion(+), 124 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dfc312a..6eba9e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -144,7 +144,7 @@ For streamable commands (`streamable = True`), `do_setup()` also runs at high fr **When CI tests fail, fix them.** Don't waste time analyzing whether failures are "related to your changes" — just fix all failing tests. The goal is a green CI, not attribution. Prefer fewer, comprehensive integration tests that mimic manual testing over a large number of unit tests. We have no code coverage requirements—the goal is working features, not metrics. -- **No tautological tests.** Don't assert what's true by construction — e.g. a freshly built object's default field values. Test behavior, not the class's initializers. +- **No tautological tests.** Assert behavior, not what's true by construction — not default fields, constructor args echoed back, enum literals, `isinstance`/frozen-raises, or stub-raises-`NotImplementedError`. Drive a method/workflow and assert the outcome. - **Test results are in `test-results.xml`.** Pytest writes JUnit XML to `test-results.xml` automatically. When diagnosing failures, read this file — it contains test names, durations, failure messages, and captured output. This is more reliable than parsing console output. - **NEVER run parol6 and web commander test suites in parallel** — no proper isolation, they share resources and have timing issues when resource-constrained. Always run sequentially. - **NEVER allow subagents to run tests.** Many tests are timing-sensitive and the system doesn't have enough resources for agents and tests to run simultaneously. Only the main conversation should run tests, and only after all agents have completed. diff --git a/tests/unit/test_gripper_ramp.py b/tests/unit/test_gripper_ramp.py index 86444a5..65f064b 100644 --- a/tests/unit/test_gripper_ramp.py +++ b/tests/unit/test_gripper_ramp.py @@ -163,20 +163,6 @@ def test_ssg48_full_travel_time(self): class TestElectricGripperConfigSpecs: """Verify physical specs on tool configs match expected values.""" - def test_ssg48_config(self): - cfg = get_registry()["SSG-48"] - assert isinstance(cfg, ElectricGripperConfig) - assert cfg.encoder_cpr == 16_384 - assert cfg.gear_pd_mm == 12.0 - assert cfg.firmware_speed_range_tps == (40, 80_000) - - def test_msg_config(self): - cfg = get_registry()["MSG"] - assert isinstance(cfg, ElectricGripperConfig) - assert cfg.encoder_cpr == 16_384 - assert cfg.gear_pd_mm == pytest.approx(16.67, abs=0.01) - assert cfg.firmware_speed_range_tps == (500, 60_000) - def test_ssg48_tick_range_derivation(self): """Tick range derived from gear PD + travel_m should match expected ~10,432.""" cfg = get_registry()["SSG-48"] diff --git a/tests/unit/test_movecart_command.py b/tests/unit/test_movecart_command.py index a41f775..f714b96 100644 --- a/tests/unit/test_movecart_command.py +++ b/tests/unit/test_movecart_command.py @@ -7,74 +7,12 @@ import msgspec import pytest -from parol6.commands.cartesian_commands import MoveLCommand from parol6.protocol.wire import MoveLCmd class TestMoveLCommandParsing: """Test MoveLCmd struct parsing and validation.""" - def test_parse_with_speed(self): - """Parse with explicit speed.""" - # Create params struct - params = MoveLCmd( - pose=[100.0, 200.0, 300.0, 0.0, 0.0, 0.0], - speed=0.5, - accel=0.75, - ) - - cmd = MoveLCommand(params) - - assert cmd.p.pose == [100.0, 200.0, 300.0, 0.0, 0.0, 0.0] - assert cmd.p.duration == 0.0 # default - assert cmd.p.speed == 0.5 - assert cmd.p.accel == 0.75 - - def test_parse_accel_default(self): - """Default acceleration should be 1.0.""" - params = MoveLCmd( - pose=[100.0, 200.0, 300.0, 0.0, 0.0, 0.0], - speed=0.5, - ) - - cmd = MoveLCommand(params) - - assert cmd.p.accel == 1.0 # default - - def test_parse_with_duration(self): - """Parse with duration instead of speed.""" - params = MoveLCmd( - pose=[100.0, 200.0, 300.0, 0.0, 0.0, 0.0], - duration=2.5, - accel=0.8, - ) - - cmd = MoveLCommand(params) - - assert cmd.p.duration == 2.5 - assert cmd.p.speed == 0.0 # default - assert cmd.p.accel == 0.8 - - def test_parse_full_accel_range(self): - """Test acceleration values at boundaries.""" - # Min accel (must be > 0) - params1 = MoveLCmd( - pose=[0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - speed=0.5, - accel=0.001, - ) - cmd1 = MoveLCommand(params1) - assert cmd1.p.accel == 0.001 - - # Max accel - params2 = MoveLCmd( - pose=[0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - speed=0.5, - accel=1.0, - ) - cmd2 = MoveLCommand(params2) - assert cmd2.p.accel == 1.0 - def test_validation_requires_duration_or_speed(self): """Must have either duration > 0 or speed > 0.""" with pytest.raises((ValueError, msgspec.ValidationError)): @@ -105,14 +43,3 @@ def test_validation_pose_length(self): with pytest.raises(msgspec.ValidationError): decode_command(raw) - - def test_command_init(self): - """Test that MoveLCommand initializes correctly.""" - params = MoveLCmd( - pose=[0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - speed=0.5, - ) - cmd = MoveLCommand(params) - - assert cmd.p is params - assert not cmd.is_finished diff --git a/tests/unit/test_query_commands_actions.py b/tests/unit/test_query_commands_actions.py index d5ef7b8..56075e8 100644 --- a/tests/unit/test_query_commands_actions.py +++ b/tests/unit/test_query_commands_actions.py @@ -14,7 +14,6 @@ CurrentActionResultStruct, ActivityCmd, QueueCmd, - QueryType, QueueResultStruct, ResponseMsg, decode_message, @@ -28,15 +27,6 @@ def _unpack_response(data: bytes): return msg.result -def test_activity_command_init(): - """Test that ACTIVITY command initializes correctly.""" - cmd = ActivityCommand(ActivityCmd()) - - assert not cmd.is_finished - assert cmd.PARAMS_TYPE is not None - assert cmd.QUERY_TYPE == QueryType.CURRENT_ACTION - - def test_activity_returns_details(): """Test that ACTIVITY compute() returns correct data.""" state = SimpleNamespace( @@ -77,15 +67,6 @@ def test_activity_with_idle_state(): assert result.params == "" -def test_queue_command_init(): - """Test that QUEUE command initializes correctly.""" - cmd = QueueCommand(QueueCmd()) - - assert not cmd.is_finished - assert cmd.PARAMS_TYPE is not None - assert cmd.QUERY_TYPE == QueryType.QUEUE - - def test_queue_returns_details(): """Test that QUEUE compute() returns correct data.""" state = SimpleNamespace( diff --git a/tests/unit/test_reset_command.py b/tests/unit/test_reset_command.py index 4c6fc34..b631369 100644 --- a/tests/unit/test_reset_command.py +++ b/tests/unit/test_reset_command.py @@ -8,23 +8,6 @@ from parol6.server.state import ControllerState -class TestResetCommandParsing: - """Test ResetCommand initialization.""" - - def test_init(self): - """RESET takes no parameters.""" - cmd = ResetCommand(ResetCmd()) - - assert not cmd.is_finished - assert cmd.p is not None - - def test_struct_has_no_params(self): - """ResetCmd struct should have no fields.""" - params = ResetCmd() - # Struct should be valid with no fields - assert params is not None - - class TestResetCommandExecution: """Test ResetCommand.tick resets state correctly."""