palletizer-full-stack 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. palletizer_full/__init__.py +37 -0
  2. palletizer_full/config.py +122 -0
  3. palletizer_full/control/__init__.py +1 -0
  4. palletizer_full/control/gripper_controller.py +75 -0
  5. palletizer_full/control/joint_synchronization.py +77 -0
  6. palletizer_full/control/motion_controller.py +87 -0
  7. palletizer_full/core/__init__.py +1 -0
  8. palletizer_full/core/communication.py +70 -0
  9. palletizer_full/core/concurrency_management.py +39 -0
  10. palletizer_full/core/consistency_verification.py +53 -0
  11. palletizer_full/core/environment_adapter.py +53 -0
  12. palletizer_full/core/execution_stack.py +70 -0
  13. palletizer_full/core/fault_detection.py +38 -0
  14. palletizer_full/core/hardware_synchronization.py +45 -0
  15. palletizer_full/core/hazard_manager.py +129 -0
  16. palletizer_full/core/memory_management.py +65 -0
  17. palletizer_full/optimizer.py +377 -0
  18. palletizer_full/orchestrator.py +153 -0
  19. palletizer_full/perception/__init__.py +1 -0
  20. palletizer_full/perception/sensor_io.py +32 -0
  21. palletizer_full/perception/sensor_processing.py +45 -0
  22. palletizer_full/planning/__init__.py +1 -0
  23. palletizer_full/planning/mission_planner.py +74 -0
  24. palletizer_full/planning/pattern_manager.py +56 -0
  25. palletizer_full/power/__init__.py +1 -0
  26. palletizer_full/power/battery_management.py +65 -0
  27. palletizer_full/power/thermal_management.py +69 -0
  28. palletizer_full/robot_config.py +125 -0
  29. palletizer_full/run.py +69 -0
  30. palletizer_full_stack-0.2.0.dist-info/METADATA +396 -0
  31. palletizer_full_stack-0.2.0.dist-info/RECORD +35 -0
  32. palletizer_full_stack-0.2.0.dist-info/WHEEL +5 -0
  33. palletizer_full_stack-0.2.0.dist-info/entry_points.txt +3 -0
  34. palletizer_full_stack-0.2.0.dist-info/licenses/LICENSE +189 -0
  35. palletizer_full_stack-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,37 @@
1
+ # Palletizer Full Stack — Open-Source Industrial Palletising Software
2
+ # https://github.com/iceccarelli/palletizer
3
+
4
+ """
5
+ Palletizer Full Stack
6
+ =====================
7
+
8
+ A modular software stack for high-throughput end-of-line palletising cells:
9
+ control logic, perception pipeline, task planning, power management, and a
10
+ real mixed-SKU pallet optimizer.
11
+
12
+ The optimizer is the core capability and is dependency-free:
13
+
14
+ from palletizer_full import optimize_pallet, Box
15
+ plan = optimize_pallet([Box("A", 400, 300, 250, 8.5), ...])
16
+ print(plan.volume_density, plan.stability_score)
17
+ """
18
+
19
+ from .optimizer import (
20
+ Box,
21
+ Pallet,
22
+ Placement,
23
+ PalletPlan,
24
+ optimize_pallet,
25
+ load_boxes_csv,
26
+ )
27
+
28
+ __version__ = "0.2.0"
29
+ __all__ = [
30
+ "__version__",
31
+ "Box",
32
+ "Pallet",
33
+ "Placement",
34
+ "PalletPlan",
35
+ "optimize_pallet",
36
+ "load_boxes_csv",
37
+ ]
@@ -0,0 +1,122 @@
1
+ """
2
+ High-level configuration for the palletiser control system.
3
+
4
+ This module defines a :class:`Config` dataclass that centralises
5
+ parameters for the control loop, power management and safety. Values
6
+ can be overridden via environment variables so that the same codebase
7
+ adapts to different factory environments without code changes.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from dataclasses import dataclass, field
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Environment-variable helpers
17
+ # ---------------------------------------------------------------------------
18
+
19
+ def _env_float(name: str, default: float) -> float:
20
+ """Parse a float from an environment variable or return *default*."""
21
+ try:
22
+ raw = os.getenv(name)
23
+ return float(raw) if raw not in (None, "") else default
24
+ except Exception:
25
+ return default
26
+
27
+
28
+ def _env_int(name: str, default: int) -> int:
29
+ """Parse an int from an environment variable or return *default*."""
30
+ try:
31
+ raw = os.getenv(name)
32
+ return int(raw) if raw not in (None, "") else default
33
+ except Exception:
34
+ return default
35
+
36
+
37
+ def _env_bool(name: str, default: bool = False) -> bool:
38
+ """Parse a boolean from an environment variable."""
39
+ val = os.getenv(name)
40
+ if val is None:
41
+ return default
42
+ return str(val).strip().lower() in {"1", "true", "t", "yes", "y", "on"}
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Config dataclass
47
+ # ---------------------------------------------------------------------------
48
+
49
+ @dataclass
50
+ class Config:
51
+ """Top-level configuration for the palletiser.
52
+
53
+ Parameters can be overridden via environment variables. The
54
+ defaults here are suitable for a typical single-cell palletiser
55
+ using a 50 Hz control loop.
56
+ """
57
+
58
+ cycle_hz: float = field(
59
+ default_factory=lambda: _env_float("CYCLE_HZ", 50.0),
60
+ )
61
+ """Control loop frequency in Hertz."""
62
+
63
+ battery_capacity_wh: float = field(
64
+ default_factory=lambda: _env_float("BATTERY_CAPACITY_WH", 1000.0),
65
+ )
66
+ """Total energy capacity of the power system in watt-hours."""
67
+
68
+ low_battery_threshold: float = field(
69
+ default_factory=lambda: _env_float("LOW_BATTERY_THRESHOLD", 0.2),
70
+ )
71
+ """Fractional charge level below which the system should initiate
72
+ orderly shutdown or trigger a recharge. Range: 0-1."""
73
+
74
+ max_temperature_c: float = field(
75
+ default_factory=lambda: _env_float("MAX_TEMPERATURE_C", 70.0),
76
+ )
77
+ """Maximum safe temperature (deg C) for motors and electronics."""
78
+
79
+ cooling_hysteresis_c: float = field(
80
+ default_factory=lambda: _env_float("COOLING_HYSTERESIS_C", 10.0),
81
+ )
82
+ """Temperature difference (deg C) between activating and
83
+ deactivating cooling."""
84
+
85
+ total_memory_bytes: int = field(
86
+ default_factory=lambda: _env_int("TOTAL_MEMORY_BYTES", 16 * 1024 * 1024),
87
+ )
88
+ """Amount of memory (bytes) reserved for deterministic buffers."""
89
+
90
+ safety_margin_m: float = field(
91
+ default_factory=lambda: _env_float("SAFETY_MARGIN_M", 0.4),
92
+ )
93
+ """Minimum allowable distance (metres) to human operators or
94
+ obstacles."""
95
+
96
+ safety_thresholds: tuple[float, ...] = field(
97
+ default_factory=lambda: tuple(
98
+ float(x)
99
+ for x in os.getenv("SAFETY_THRESHOLDS", "").split(",")
100
+ if x.strip()
101
+ ),
102
+ )
103
+ """Generic thresholds for hazards (e.g. gas, voltage)."""
104
+
105
+ power_budget_w: float = field(
106
+ default_factory=lambda: _env_float("POWER_BUDGET_W", 2000.0),
107
+ )
108
+ """Power budget (watts) allocated for the cell."""
109
+
110
+ enable_monitoring_ui: bool = field(
111
+ default_factory=lambda: _env_bool("ENABLE_MONITORING_UI", True),
112
+ )
113
+ """Whether to launch a simple monitoring server."""
114
+
115
+ monitor_port: int = field(
116
+ default_factory=lambda: _env_int("MONITOR_PORT", 8080),
117
+ )
118
+ """Port number for the monitoring UI, if enabled."""
119
+
120
+ def cycle_time(self) -> float:
121
+ """Return the period of the control loop in seconds."""
122
+ return 1.0 / max(self.cycle_hz, 1e-3)
@@ -0,0 +1 @@
1
+ # Low-level motion and actuator control.
@@ -0,0 +1,75 @@
1
+ """
2
+ Gripper Controller Module
3
+ =========================
4
+
5
+ The :class:`GripperController` provides a high-level API for
6
+ controlling a vacuum or mechanical gripper. It monitors vacuum
7
+ pressure (if available), triggers retries on failed picks and
8
+ detects drops.
9
+
10
+ The gripper is abstracted by callbacks passed during construction.
11
+ Users should provide functions to open/close the gripper and to
12
+ read pressure or force feedback.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import math
19
+ import time
20
+ from collections.abc import Callable
21
+
22
+
23
+ class GripperController:
24
+ """Control and monitor a gripper.
25
+
26
+ Parameters
27
+ ----------
28
+ close_fn : Callable[[], None]
29
+ Function that closes the gripper.
30
+ open_fn : Callable[[], None]
31
+ Function that opens the gripper.
32
+ read_pressure_fn : Callable[[], float] | None
33
+ Function that returns the current vacuum pressure.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ close_fn: Callable[[], None],
39
+ open_fn: Callable[[], None],
40
+ read_pressure_fn: Callable[[], float] | None = None,
41
+ ) -> None:
42
+ self._close_fn = close_fn
43
+ self._open_fn = open_fn
44
+ self._read_pressure = read_pressure_fn
45
+ self._logger = logging.getLogger(self.__class__.__name__)
46
+
47
+ def pick(self, retries: int = 3, wait_s: float = 0.1) -> bool:
48
+ """Close the gripper and verify suction/grasp.
49
+
50
+ Returns ``True`` on success, ``False`` on failure.
51
+ """
52
+ for attempt in range(retries):
53
+ self._close_fn()
54
+ time.sleep(wait_s)
55
+ if self._read_pressure is None:
56
+ return True
57
+ try:
58
+ pressure = float(self._read_pressure())
59
+ except Exception:
60
+ self._logger.exception("Pressure read error")
61
+ pressure = None
62
+ threshold = -0.2 # negative pressure in bar
63
+ if pressure is not None and pressure < threshold:
64
+ return True
65
+ self._logger.warning(
66
+ "Grip attempt %d failed (pressure=%.3f bar)",
67
+ attempt + 1,
68
+ pressure if pressure is not None else math.nan,
69
+ )
70
+ self._logger.error("Failed to achieve vacuum after %d attempts", retries)
71
+ return False
72
+
73
+ def release(self) -> None:
74
+ """Open the gripper to release the load."""
75
+ self._open_fn()
@@ -0,0 +1,77 @@
1
+ """
2
+ Joint Synchronisation Module
3
+ ============================
4
+
5
+ Functions to synchronise motion of multiple joints so that the
6
+ end effector follows a coordinated path and all joints finish at
7
+ the same time. These functions are hardware-agnostic.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Iterable
13
+
14
+
15
+ def synchronise_velocities(
16
+ positions: Iterable[tuple[float, ...]],
17
+ max_velocities: tuple[float, ...],
18
+ ) -> list[tuple[float, ...]]:
19
+ """Synchronise motion velocities across joints.
20
+
21
+ Given a sequence of joint position tuples and per-joint maximum
22
+ velocities, compute adjusted positions such that each segment
23
+ completes in the same time across all joints.
24
+
25
+ Parameters
26
+ ----------
27
+ positions : iterable of tuple of float
28
+ Sequence of joint positions defining a trajectory.
29
+ max_velocities : tuple of float
30
+ Maximum allowed velocity for each joint.
31
+
32
+ Returns
33
+ -------
34
+ list of tuple of float
35
+ Synchronised joint positions.
36
+ """
37
+ positions_list = list(positions)
38
+ if not positions_list:
39
+ return []
40
+
41
+ # Compute per-joint deltas between consecutive points
42
+ deltas: list[list[float]] = []
43
+ for i in range(1, len(positions_list)):
44
+ prev = positions_list[i - 1]
45
+ curr = positions_list[i]
46
+ deltas.append([abs(c - p) for p, c in zip(prev, curr, strict=False)])
47
+
48
+ # Determine maximum required velocity ratio
49
+ ratios: list[float] = []
50
+ for delta in deltas:
51
+ for d, max_v in zip(delta, max_velocities, strict=False):
52
+ if max_v <= 0:
53
+ continue
54
+ ratios.append(d / max_v)
55
+
56
+ longest_time = max(ratios) if ratios else 0.0
57
+ if longest_time <= 0:
58
+ return positions_list
59
+
60
+ # Scale each joint's motion to match the slowest
61
+ synchronised: list[tuple[float, ...]] = [positions_list[0]]
62
+ for i in range(1, len(positions_list)):
63
+ prev = positions_list[i - 1]
64
+ curr = positions_list[i]
65
+ scaled: list[float] = []
66
+ for p, c, max_v in zip(prev, curr, max_velocities, strict=False):
67
+ delta_val = c - p
68
+ if max_v <= 0:
69
+ scaled.append(c)
70
+ continue
71
+ scale = longest_time * max_v
72
+ if abs(delta_val) <= abs(scale):
73
+ scaled.append(c)
74
+ else:
75
+ scaled.append(p + (scale if delta_val > 0 else -scale))
76
+ synchronised.append(tuple(scaled))
77
+ return synchronised
@@ -0,0 +1,87 @@
1
+ """
2
+ Motion Controller Module
3
+ ========================
4
+
5
+ The :class:`MotionController` orchestrates low-level joint commands
6
+ to the robot arm. It delegates actual actuation to an injected
7
+ :class:`RobotInterface`, allowing you to simulate the robot or swap
8
+ hardware without changing the rest of the stack.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import time
15
+ from collections.abc import Iterable
16
+
17
+ from .joint_synchronization import synchronise_velocities
18
+
19
+
20
+ class RobotInterface:
21
+ """Abstract base class for robot arm APIs.
22
+
23
+ Implement this interface to connect your robot SDK to the
24
+ palletiser control stack.
25
+ """
26
+
27
+ def get_joint_positions(self) -> tuple[float, ...]:
28
+ """Return current joint positions."""
29
+ raise NotImplementedError
30
+
31
+ def command_joint_positions(self, positions: tuple[float, ...]) -> None:
32
+ """Command the robot to move to *positions*."""
33
+ raise NotImplementedError
34
+
35
+ def execute_trajectory(
36
+ self,
37
+ trajectory: list[tuple[float, ...]],
38
+ dt: float,
39
+ ) -> None:
40
+ """Execute a trajectory at a fixed time interval *dt*."""
41
+ raise NotImplementedError
42
+
43
+
44
+ class MotionController:
45
+ """High-level motion controller for a palletising robot arm."""
46
+
47
+ def __init__(
48
+ self,
49
+ robot: RobotInterface,
50
+ max_velocities: tuple[float, ...],
51
+ ) -> None:
52
+ self._robot = robot
53
+ self._max_velocities = max_velocities
54
+ self._logger = logging.getLogger(self.__class__.__name__)
55
+
56
+ def move_to(self, target: tuple[float, ...], duration: float = 2.0) -> None:
57
+ """Move the robot to *target* over *duration* seconds."""
58
+ current = self._robot.get_joint_positions()
59
+ if len(current) != len(target):
60
+ msg = "Target joint tuple length mismatch"
61
+ raise ValueError(msg)
62
+
63
+ trajectory = [current, target]
64
+ synced = synchronise_velocities(trajectory, self._max_velocities)
65
+ steps = max(int(duration / 0.1), 1)
66
+ for i in range(1, steps + 1):
67
+ alpha = i / steps
68
+ interp = tuple(
69
+ p + alpha * (t - p) for p, t in zip(synced[0], synced[-1], strict=False)
70
+ )
71
+ self._robot.command_joint_positions(interp)
72
+ time.sleep(duration / steps)
73
+ self._robot.command_joint_positions(target)
74
+
75
+ def execute_trajectory(
76
+ self,
77
+ trajectory: Iterable[tuple[float, ...]],
78
+ dt: float,
79
+ ) -> None:
80
+ """Execute a pre-computed joint trajectory."""
81
+ positions = list(trajectory)
82
+ if not positions:
83
+ return
84
+ synced = synchronise_velocities(positions, self._max_velocities)
85
+ for pos in synced:
86
+ self._robot.command_joint_positions(pos)
87
+ time.sleep(dt)
@@ -0,0 +1 @@
1
+ # Core services for the palletiser control stack.
@@ -0,0 +1,70 @@
1
+ """
2
+ Communication Interface Module
3
+ ===============================
4
+
5
+ The :class:`CommunicationInterface` abstracts telemetry publishing
6
+ and remote command reception. In this reference implementation it
7
+ logs messages; extend it to support Ethernet, serial, CAN bus or
8
+ MQTT transports as needed.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from typing import Any
15
+
16
+ from ..config import Config
17
+
18
+
19
+ class CommunicationInterface:
20
+ """Publish telemetry and receive remote commands.
21
+
22
+ Parameters
23
+ ----------
24
+ config : Config
25
+ System configuration (used to read endpoint settings).
26
+ """
27
+
28
+ def __init__(self, config: Config) -> None:
29
+ self._config = config
30
+ self._connected = False
31
+ self._logger = logging.getLogger(self.__class__.__name__)
32
+
33
+ def connect(self) -> None:
34
+ """Establish the communication channel.
35
+
36
+ In a real deployment this would open a socket, serial port or
37
+ message-broker connection.
38
+ """
39
+ self._connected = True
40
+ self._logger.info("Communication channel connected")
41
+
42
+ def disconnect(self) -> None:
43
+ """Close the communication channel."""
44
+ self._connected = False
45
+ self._logger.info("Communication channel disconnected")
46
+
47
+ def send_telemetry(self, data: dict[str, Any]) -> None:
48
+ """Publish a telemetry payload.
49
+
50
+ Parameters
51
+ ----------
52
+ data : dict
53
+ Key-value telemetry data to publish.
54
+ """
55
+ if not self._connected:
56
+ self._logger.warning("Cannot send telemetry: not connected")
57
+ return
58
+ self._logger.debug("Telemetry: %s", data)
59
+
60
+ def receive_command(self) -> dict[str, Any] | None:
61
+ """Poll for an incoming remote command.
62
+
63
+ Returns ``None`` if no command is available.
64
+ """
65
+ return None
66
+
67
+ @property
68
+ def is_connected(self) -> bool:
69
+ """Return ``True`` if the channel is active."""
70
+ return self._connected
@@ -0,0 +1,39 @@
1
+ """
2
+ Concurrency Management Module
3
+ ==============================
4
+
5
+ Provides thread-safe primitives for coordinating access to shared
6
+ resources in a multi-threaded control loop. The
7
+ :class:`ReentrantLock` wraps Python's ``threading.RLock`` and can be
8
+ used as a context manager.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import threading
14
+
15
+
16
+ class ReentrantLock:
17
+ """A reentrant lock that can be used as a context manager.
18
+
19
+ This thin wrapper around :class:`threading.RLock` provides a
20
+ consistent API for the palletiser control stack.
21
+ """
22
+
23
+ def __init__(self) -> None:
24
+ self._lock = threading.RLock()
25
+
26
+ def acquire(self, blocking: bool = True, timeout: float = -1) -> bool:
27
+ """Acquire the lock."""
28
+ return self._lock.acquire(blocking=blocking, timeout=timeout)
29
+
30
+ def release(self) -> None:
31
+ """Release the lock."""
32
+ self._lock.release()
33
+
34
+ def __enter__(self) -> ReentrantLock:
35
+ self._lock.acquire()
36
+ return self
37
+
38
+ def __exit__(self, *args: object) -> None:
39
+ self._lock.release()
@@ -0,0 +1,53 @@
1
+ """
2
+ Consistency Verification Module
3
+ ================================
4
+
5
+ The :class:`ConsistencyVerifier` validates that sensor readings and
6
+ actuator states are within expected ranges. It is used during
7
+ commissioning and at runtime to detect calibration drift or hardware
8
+ faults.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+
15
+
16
+ class ConsistencyVerifier:
17
+ """Validate sensor/actuator consistency."""
18
+
19
+ def __init__(self) -> None:
20
+ self._logger = logging.getLogger(self.__class__.__name__)
21
+
22
+ def verify_range(self, name: str, value: float, low: float, high: float) -> bool:
23
+ """Check that *value* is within [*low*, *high*].
24
+
25
+ Returns ``True`` if the value is in range, ``False`` otherwise.
26
+ """
27
+ if low <= value <= high:
28
+ return True
29
+ self._logger.warning(
30
+ "%s out of range: %.3f not in [%.3f, %.3f]",
31
+ name,
32
+ value,
33
+ low,
34
+ high,
35
+ )
36
+ return False
37
+
38
+ def verify_monotonic(self, name: str, values: list[float]) -> bool:
39
+ """Check that *values* are strictly increasing.
40
+
41
+ Useful for verifying encoder readings or timestamps.
42
+ """
43
+ for i in range(1, len(values)):
44
+ if values[i] <= values[i - 1]:
45
+ self._logger.warning(
46
+ "%s not monotonic at index %d: %.3f <= %.3f",
47
+ name,
48
+ i,
49
+ values[i],
50
+ values[i - 1],
51
+ )
52
+ return False
53
+ return True
@@ -0,0 +1,53 @@
1
+ """
2
+ Environment Adapter Module
3
+ ===========================
4
+
5
+ Provides helpers to adapt system behaviour to different factory
6
+ environments. The :class:`EnvironmentAdapter` reads the current
7
+ environment profile and applies adjustments to safety margins,
8
+ cycle rates and other parameters.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from typing import ClassVar
15
+
16
+ from ..config import Config
17
+
18
+
19
+ class EnvironmentAdapter:
20
+ """Adapt system parameters to the deployment environment.
21
+
22
+ Parameters
23
+ ----------
24
+ config : Config
25
+ System configuration to adjust.
26
+ environment : str
27
+ Environment identifier (e.g. ``"FACTORY"``, ``"WAREHOUSE"``).
28
+ """
29
+
30
+ PROFILES: ClassVar[dict[str, dict[str, float]]] = {
31
+ "FACTORY": {"safety_margin_m": 0.4, "cycle_hz": 50.0},
32
+ "WAREHOUSE": {"safety_margin_m": 0.6, "cycle_hz": 30.0},
33
+ "LAB": {"safety_margin_m": 0.2, "cycle_hz": 100.0},
34
+ }
35
+
36
+ def __init__(self, config: Config, environment: str = "FACTORY") -> None:
37
+ self._config = config
38
+ self._environment = environment.upper()
39
+ self._logger = logging.getLogger(self.__class__.__name__)
40
+
41
+ def apply(self) -> Config:
42
+ """Apply environment-specific overrides and return the config."""
43
+ profile = self.PROFILES.get(self._environment, {})
44
+ for key, value in profile.items():
45
+ if hasattr(self._config, key):
46
+ object.__setattr__(self._config, key, value)
47
+ self._logger.info(
48
+ "Environment %s: set %s = %s",
49
+ self._environment,
50
+ key,
51
+ value,
52
+ )
53
+ return self._config
@@ -0,0 +1,70 @@
1
+ """
2
+ Execution Stack Module
3
+ ======================
4
+
5
+ The :class:`ExecutionStack` manages the deterministic control loop.
6
+ It sequences sensor polling, planning, control and actuation at a
7
+ fixed cycle rate, invokes health checks and publishes telemetry.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import time
14
+ from collections.abc import Callable
15
+ from typing import Any
16
+
17
+ from ..config import Config
18
+
19
+
20
+ class ExecutionStack:
21
+ """Manage the main control loop.
22
+
23
+ Parameters
24
+ ----------
25
+ config : Config
26
+ Global configuration for the system.
27
+ on_cycle : Callable[[float], None]
28
+ Callback invoked every cycle with the elapsed time.
29
+ on_health_check : Callable[[], dict[str, Any]]
30
+ Callback invoked each cycle to collect telemetry.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ config: Config,
36
+ on_cycle: Callable[[float], None],
37
+ on_health_check: Callable[[], dict[str, Any]],
38
+ ) -> None:
39
+ self._config = config
40
+ self._on_cycle = on_cycle
41
+ self._on_health_check = on_health_check
42
+ self._logger = logging.getLogger(self.__class__.__name__)
43
+
44
+ def run(self, cycles: int | None = None) -> None:
45
+ """Run the control loop.
46
+
47
+ Parameters
48
+ ----------
49
+ cycles : int | None
50
+ Number of cycles to run. If ``None``, runs indefinitely.
51
+ """
52
+ cycle_time = self._config.cycle_time()
53
+ next_time = time.monotonic()
54
+ cycle_count = 0
55
+ while cycles is None or cycle_count < cycles:
56
+ now = time.monotonic()
57
+ dt = now - next_time + cycle_time
58
+ try:
59
+ self._on_cycle(dt)
60
+ except Exception:
61
+ self._logger.exception("Error in control cycle")
62
+ try:
63
+ self._on_health_check()
64
+ except Exception:
65
+ self._logger.exception("Error collecting health metrics")
66
+ next_time += cycle_time
67
+ sleep_time = next_time - time.monotonic()
68
+ if sleep_time > 0:
69
+ time.sleep(sleep_time)
70
+ cycle_count += 1