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.
- palletizer_full/__init__.py +37 -0
- palletizer_full/config.py +122 -0
- palletizer_full/control/__init__.py +1 -0
- palletizer_full/control/gripper_controller.py +75 -0
- palletizer_full/control/joint_synchronization.py +77 -0
- palletizer_full/control/motion_controller.py +87 -0
- palletizer_full/core/__init__.py +1 -0
- palletizer_full/core/communication.py +70 -0
- palletizer_full/core/concurrency_management.py +39 -0
- palletizer_full/core/consistency_verification.py +53 -0
- palletizer_full/core/environment_adapter.py +53 -0
- palletizer_full/core/execution_stack.py +70 -0
- palletizer_full/core/fault_detection.py +38 -0
- palletizer_full/core/hardware_synchronization.py +45 -0
- palletizer_full/core/hazard_manager.py +129 -0
- palletizer_full/core/memory_management.py +65 -0
- palletizer_full/optimizer.py +377 -0
- palletizer_full/orchestrator.py +153 -0
- palletizer_full/perception/__init__.py +1 -0
- palletizer_full/perception/sensor_io.py +32 -0
- palletizer_full/perception/sensor_processing.py +45 -0
- palletizer_full/planning/__init__.py +1 -0
- palletizer_full/planning/mission_planner.py +74 -0
- palletizer_full/planning/pattern_manager.py +56 -0
- palletizer_full/power/__init__.py +1 -0
- palletizer_full/power/battery_management.py +65 -0
- palletizer_full/power/thermal_management.py +69 -0
- palletizer_full/robot_config.py +125 -0
- palletizer_full/run.py +69 -0
- palletizer_full_stack-0.2.0.dist-info/METADATA +396 -0
- palletizer_full_stack-0.2.0.dist-info/RECORD +35 -0
- palletizer_full_stack-0.2.0.dist-info/WHEEL +5 -0
- palletizer_full_stack-0.2.0.dist-info/entry_points.txt +3 -0
- palletizer_full_stack-0.2.0.dist-info/licenses/LICENSE +189 -0
- 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
|