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,153 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Orchestrator for the Palletiser
|
|
3
|
+
===============================
|
|
4
|
+
|
|
5
|
+
The :class:`PalletiserOrchestrator` instantiates and wires together
|
|
6
|
+
all subsystems: sensor IO, processing, planning, motion control,
|
|
7
|
+
gripper control, power and thermal management, hazard detection and
|
|
8
|
+
communication. It runs these subsystems in a deterministic loop
|
|
9
|
+
via the :class:`ExecutionStack`.
|
|
10
|
+
|
|
11
|
+
Usage::
|
|
12
|
+
|
|
13
|
+
from palletizer_full.orchestrator import PalletiserOrchestrator
|
|
14
|
+
|
|
15
|
+
config = Config()
|
|
16
|
+
robot = MyRobotSDKInterface() # implements RobotInterface
|
|
17
|
+
orchestrator = PalletiserOrchestrator(config, robot)
|
|
18
|
+
orchestrator.run()
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
import time
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from .config import Config
|
|
28
|
+
from .control.gripper_controller import GripperController
|
|
29
|
+
from .control.motion_controller import MotionController, RobotInterface
|
|
30
|
+
from .core.communication import CommunicationInterface
|
|
31
|
+
from .core.concurrency_management import ReentrantLock
|
|
32
|
+
from .core.consistency_verification import ConsistencyVerifier
|
|
33
|
+
from .core.execution_stack import ExecutionStack
|
|
34
|
+
from .core.fault_detection import FaultDetector
|
|
35
|
+
from .core.hazard_manager import HazardManager
|
|
36
|
+
from .core.memory_management import MemoryManager
|
|
37
|
+
from .perception.sensor_io import SensorIO
|
|
38
|
+
from .perception.sensor_processing import SensorProcessor
|
|
39
|
+
from .planning.mission_planner import MissionPlanner
|
|
40
|
+
from .planning.pattern_manager import PatternManager
|
|
41
|
+
from .power.battery_management import BatteryManager
|
|
42
|
+
from .power.thermal_management import ThermalManager
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PalletiserOrchestrator:
|
|
46
|
+
"""High-level orchestrator for the palletising cell."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, config: Config, robot: RobotInterface) -> None:
|
|
49
|
+
self.config = config
|
|
50
|
+
|
|
51
|
+
# Subsystems
|
|
52
|
+
self.memory = MemoryManager(config.total_memory_bytes)
|
|
53
|
+
self.hazard_manager = HazardManager(config)
|
|
54
|
+
self.fault_detector = FaultDetector()
|
|
55
|
+
self.consistency = ConsistencyVerifier()
|
|
56
|
+
self.communication = CommunicationInterface(config)
|
|
57
|
+
self.battery = BatteryManager(
|
|
58
|
+
config.battery_capacity_wh,
|
|
59
|
+
config.low_battery_threshold,
|
|
60
|
+
)
|
|
61
|
+
self.thermal = ThermalManager(
|
|
62
|
+
config.max_temperature_c,
|
|
63
|
+
config.cooling_hysteresis_c,
|
|
64
|
+
)
|
|
65
|
+
self.sensor_io = SensorIO()
|
|
66
|
+
self.sensor_processor = SensorProcessor()
|
|
67
|
+
self.pattern_manager = PatternManager()
|
|
68
|
+
self.planner = MissionPlanner(self.pattern_manager)
|
|
69
|
+
|
|
70
|
+
# Robot motion and gripper
|
|
71
|
+
default_velocities = tuple(1.0 for _ in robot.get_joint_positions())
|
|
72
|
+
self.motion = MotionController(robot, default_velocities)
|
|
73
|
+
self.gripper = GripperController(
|
|
74
|
+
close_fn=lambda: None,
|
|
75
|
+
open_fn=lambda: None,
|
|
76
|
+
read_pressure_fn=None,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Concurrency primitive protecting shared state
|
|
80
|
+
self._lock = ReentrantLock()
|
|
81
|
+
self._logger = logging.getLogger(self.__class__.__name__)
|
|
82
|
+
|
|
83
|
+
# Execution stack
|
|
84
|
+
self._stack = ExecutionStack(
|
|
85
|
+
config=self.config,
|
|
86
|
+
on_cycle=self._on_cycle,
|
|
87
|
+
on_health_check=self._collect_metrics,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
# Public API
|
|
92
|
+
# ------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def run(self, cycles: int | None = None) -> None:
|
|
95
|
+
"""Start the main control loop."""
|
|
96
|
+
self.communication.connect()
|
|
97
|
+
self._stack.run(cycles)
|
|
98
|
+
|
|
99
|
+
def add_order(self, sku: str, quantity: int) -> None:
|
|
100
|
+
"""Add a new palletising order to the planner."""
|
|
101
|
+
with self._lock:
|
|
102
|
+
self.planner.add_order(sku, quantity)
|
|
103
|
+
|
|
104
|
+
# ------------------------------------------------------------------
|
|
105
|
+
# Internal callbacks invoked by ExecutionStack
|
|
106
|
+
# ------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def _on_cycle(self, dt: float) -> None:
|
|
109
|
+
"""Perform one control cycle."""
|
|
110
|
+
with self._lock:
|
|
111
|
+
# 1. Poll sensors
|
|
112
|
+
raw = self.sensor_io.read_all()
|
|
113
|
+
processed = self.sensor_processor.process(raw)
|
|
114
|
+
|
|
115
|
+
# 2. Update power and thermal subsystems
|
|
116
|
+
self.battery.update(current_draw_w=100.0)
|
|
117
|
+
self.thermal.update()
|
|
118
|
+
|
|
119
|
+
# 3. Evaluate hazards
|
|
120
|
+
hazard_inputs: dict[str, Any] = {
|
|
121
|
+
"proximity": processed.get("proximity"),
|
|
122
|
+
"faults": self.fault_detector.get_active_faults(),
|
|
123
|
+
"high_voltage": None,
|
|
124
|
+
"gas": None,
|
|
125
|
+
"radiation": None,
|
|
126
|
+
}
|
|
127
|
+
self.hazard_manager.update(hazard_inputs)
|
|
128
|
+
|
|
129
|
+
# 4. If safe and there is a pending order, perform a task
|
|
130
|
+
if self.hazard_manager.is_safe() and self.planner.has_next_task():
|
|
131
|
+
task = self.planner.next_task()
|
|
132
|
+
if task:
|
|
133
|
+
picked = self.gripper.pick()
|
|
134
|
+
if not picked:
|
|
135
|
+
self.fault_detector.report_fault("gripper_pick_failure")
|
|
136
|
+
return
|
|
137
|
+
time.sleep(0.01)
|
|
138
|
+
self.gripper.release()
|
|
139
|
+
|
|
140
|
+
# 5. Fault handling: if SOC is low, trigger a fault
|
|
141
|
+
if self.battery.is_low():
|
|
142
|
+
self.fault_detector.report_fault("low_battery")
|
|
143
|
+
|
|
144
|
+
def _collect_metrics(self) -> dict[str, Any]:
|
|
145
|
+
"""Collect telemetry and health metrics."""
|
|
146
|
+
metrics: dict[str, Any] = {}
|
|
147
|
+
metrics.update(self.battery.telemetry())
|
|
148
|
+
metrics.update(self.thermal.telemetry())
|
|
149
|
+
metrics["memory_ok"] = self.memory.check_health()
|
|
150
|
+
metrics["faults"] = list(self.fault_detector.get_active_faults())
|
|
151
|
+
metrics["hazards"] = self.hazard_manager.current_hazards()
|
|
152
|
+
self.communication.send_telemetry(metrics)
|
|
153
|
+
return metrics
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Sensor IO and basic perception pipeline.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sensor IO Module
|
|
3
|
+
================
|
|
4
|
+
|
|
5
|
+
The :class:`SensorIO` class is the entry point for reading raw data
|
|
6
|
+
from cameras, lidars, proximity sensors and other devices. In this
|
|
7
|
+
reference implementation it returns placeholder data; replace the
|
|
8
|
+
``read_all`` method with calls to your sensor SDKs.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SensorIO:
|
|
18
|
+
"""Read raw sensor data from hardware devices."""
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self._logger = logging.getLogger(self.__class__.__name__)
|
|
22
|
+
|
|
23
|
+
def read_all(self) -> dict[str, Any]:
|
|
24
|
+
"""Poll all sensors and return a dictionary of raw readings.
|
|
25
|
+
|
|
26
|
+
Override this method to integrate real sensor hardware.
|
|
27
|
+
"""
|
|
28
|
+
return {
|
|
29
|
+
"proximity": 1.0,
|
|
30
|
+
"box_present": False,
|
|
31
|
+
"weight_kg": 0.0,
|
|
32
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sensor Processing Module
|
|
3
|
+
========================
|
|
4
|
+
|
|
5
|
+
The :class:`SensorProcessor` fuses raw sensor inputs to extract
|
|
6
|
+
higher-level information such as box presence, alignment and
|
|
7
|
+
orientation. For a palletiser the perception stack is intentionally
|
|
8
|
+
simple; you may add machine-learning-based detection later.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SensorProcessor:
|
|
18
|
+
"""Process and fuse raw sensor readings."""
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self._logger = logging.getLogger(self.__class__.__name__)
|
|
22
|
+
|
|
23
|
+
def process(self, raw: dict[str, Any]) -> dict[str, Any]:
|
|
24
|
+
"""Process raw sensor data and return fused results.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
raw : dict
|
|
29
|
+
Raw sensor readings from :class:`SensorIO`.
|
|
30
|
+
|
|
31
|
+
Returns
|
|
32
|
+
-------
|
|
33
|
+
dict
|
|
34
|
+
Processed data including ``proximity``, ``box_detected``
|
|
35
|
+
and ``weight_kg``.
|
|
36
|
+
"""
|
|
37
|
+
proximity = raw.get("proximity")
|
|
38
|
+
box_present = raw.get("box_present", False)
|
|
39
|
+
weight = raw.get("weight_kg", 0.0)
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
"proximity": proximity,
|
|
43
|
+
"box_detected": bool(box_present) or (weight is not None and weight > 0.1),
|
|
44
|
+
"weight_kg": weight,
|
|
45
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Task and trajectory planning.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Mission Planner Module
|
|
3
|
+
======================
|
|
4
|
+
|
|
5
|
+
The :class:`MissionPlanner` breaks down orders into pick/stack
|
|
6
|
+
sequences. It tracks progress, handles errors and recovers from
|
|
7
|
+
faults by re-planning or pausing the cell.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from collections import deque
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .pattern_manager import PatternManager
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MissionPlanner:
|
|
20
|
+
"""Plan and sequence palletising tasks.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
pattern_manager : PatternManager
|
|
25
|
+
Manager providing stacking patterns for each SKU.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, pattern_manager: PatternManager) -> None:
|
|
29
|
+
self._pattern_manager = pattern_manager
|
|
30
|
+
self._task_queue: deque[dict[str, Any]] = deque()
|
|
31
|
+
self._completed: int = 0
|
|
32
|
+
self._logger = logging.getLogger(self.__class__.__name__)
|
|
33
|
+
|
|
34
|
+
def add_order(self, sku: str, quantity: int) -> None:
|
|
35
|
+
"""Add a palletising order for *quantity* cases of *sku*.
|
|
36
|
+
|
|
37
|
+
The planner looks up the stacking pattern for the SKU and
|
|
38
|
+
generates one task per case.
|
|
39
|
+
"""
|
|
40
|
+
try:
|
|
41
|
+
pattern = self._pattern_manager.get_pattern(sku)
|
|
42
|
+
except KeyError:
|
|
43
|
+
self._logger.error("No pattern for SKU '%s'", sku)
|
|
44
|
+
return
|
|
45
|
+
for i in range(quantity):
|
|
46
|
+
pose = pattern[i % len(pattern)]
|
|
47
|
+
self._task_queue.append({
|
|
48
|
+
"sku": sku,
|
|
49
|
+
"index": i,
|
|
50
|
+
"pose": pose,
|
|
51
|
+
})
|
|
52
|
+
self._logger.info("Queued %d tasks for SKU '%s'", quantity, sku)
|
|
53
|
+
|
|
54
|
+
def has_next_task(self) -> bool:
|
|
55
|
+
"""Return ``True`` if there are pending tasks."""
|
|
56
|
+
return len(self._task_queue) > 0
|
|
57
|
+
|
|
58
|
+
def next_task(self) -> dict[str, Any] | None:
|
|
59
|
+
"""Pop and return the next task, or ``None`` if empty."""
|
|
60
|
+
if not self._task_queue:
|
|
61
|
+
return None
|
|
62
|
+
task = self._task_queue.popleft()
|
|
63
|
+
self._completed += 1
|
|
64
|
+
return task
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def completed_count(self) -> int:
|
|
68
|
+
"""Number of tasks completed so far."""
|
|
69
|
+
return self._completed
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def pending_count(self) -> int:
|
|
73
|
+
"""Number of tasks remaining in the queue."""
|
|
74
|
+
return len(self._task_queue)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pattern Manager Module
|
|
3
|
+
======================
|
|
4
|
+
|
|
5
|
+
The :class:`PatternManager` generates and stores pallet patterns.
|
|
6
|
+
Patterns are lists of ``(x, y, rotation)`` tuples describing where
|
|
7
|
+
each case should be placed on a layer. Patterns can be saved as
|
|
8
|
+
JSON and loaded into the planner.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PatternManager:
|
|
19
|
+
"""Manage pallet stacking patterns."""
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
self._patterns: dict[str, list[tuple[float, float, float]]] = {}
|
|
23
|
+
self._logger = logging.getLogger(self.__class__.__name__)
|
|
24
|
+
|
|
25
|
+
def load_pattern(
|
|
26
|
+
self,
|
|
27
|
+
name: str,
|
|
28
|
+
poses: list[tuple[float, float, float]],
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Register a pattern under *name*."""
|
|
31
|
+
self._patterns[name] = list(poses)
|
|
32
|
+
self._logger.info("Loaded pattern '%s' with %d poses", name, len(poses))
|
|
33
|
+
|
|
34
|
+
def get_pattern(self, name: str) -> list[tuple[float, float, float]]:
|
|
35
|
+
"""Return the poses for pattern *name*."""
|
|
36
|
+
if name not in self._patterns:
|
|
37
|
+
msg = f"Unknown pattern: {name}"
|
|
38
|
+
raise KeyError(msg)
|
|
39
|
+
return list(self._patterns[name])
|
|
40
|
+
|
|
41
|
+
def list_patterns(self) -> list[str]:
|
|
42
|
+
"""Return names of all registered patterns."""
|
|
43
|
+
return list(self._patterns.keys())
|
|
44
|
+
|
|
45
|
+
def save_to_json(self, path: str | Path) -> None:
|
|
46
|
+
"""Persist all patterns to a JSON file."""
|
|
47
|
+
with open(path, "w") as fh:
|
|
48
|
+
json.dump(self._patterns, fh, indent=2)
|
|
49
|
+
|
|
50
|
+
def load_from_json(self, path: str | Path) -> None:
|
|
51
|
+
"""Load patterns from a JSON file."""
|
|
52
|
+
with open(path) as fh:
|
|
53
|
+
data = json.load(fh)
|
|
54
|
+
for name, poses in data.items():
|
|
55
|
+
self._patterns[name] = [tuple(p) for p in poses]
|
|
56
|
+
self._logger.info("Loaded %d patterns from %s", len(data), path)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Energy and thermal management.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Battery Management Module
|
|
3
|
+
=========================
|
|
4
|
+
|
|
5
|
+
The :class:`BatteryManager` monitors state-of-charge and available
|
|
6
|
+
energy. It uses LiFePO4 battery models by default, as these cells
|
|
7
|
+
offer high energy density, long cycle life and excellent safety
|
|
8
|
+
characteristics.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BatteryManager:
|
|
18
|
+
"""Monitor battery state-of-charge and energy.
|
|
19
|
+
|
|
20
|
+
Parameters
|
|
21
|
+
----------
|
|
22
|
+
capacity_wh : float
|
|
23
|
+
Total battery capacity in watt-hours.
|
|
24
|
+
low_threshold : float
|
|
25
|
+
Fractional charge level (0-1) below which the battery is
|
|
26
|
+
considered low.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, capacity_wh: float, low_threshold: float = 0.2) -> None:
|
|
30
|
+
self._capacity_wh = capacity_wh
|
|
31
|
+
self._remaining_wh = capacity_wh
|
|
32
|
+
self._low_threshold = low_threshold
|
|
33
|
+
self._logger = logging.getLogger(self.__class__.__name__)
|
|
34
|
+
|
|
35
|
+
def update(self, current_draw_w: float, dt_s: float = 0.02) -> None:
|
|
36
|
+
"""Update the remaining energy based on current draw.
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
current_draw_w : float
|
|
41
|
+
Instantaneous power draw in watts.
|
|
42
|
+
dt_s : float
|
|
43
|
+
Time step in seconds (defaults to 50 Hz cycle).
|
|
44
|
+
"""
|
|
45
|
+
consumed = current_draw_w * (dt_s / 3600.0)
|
|
46
|
+
self._remaining_wh = max(0.0, self._remaining_wh - consumed)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def soc(self) -> float:
|
|
50
|
+
"""State of charge as a fraction (0-1)."""
|
|
51
|
+
if self._capacity_wh <= 0:
|
|
52
|
+
return 0.0
|
|
53
|
+
return self._remaining_wh / self._capacity_wh
|
|
54
|
+
|
|
55
|
+
def is_low(self) -> bool:
|
|
56
|
+
"""Return ``True`` if the battery is below the low threshold."""
|
|
57
|
+
return self.soc < self._low_threshold
|
|
58
|
+
|
|
59
|
+
def telemetry(self) -> dict[str, Any]:
|
|
60
|
+
"""Return battery telemetry data."""
|
|
61
|
+
return {
|
|
62
|
+
"battery_soc": round(self.soc, 4),
|
|
63
|
+
"battery_remaining_wh": round(self._remaining_wh, 2),
|
|
64
|
+
"battery_low": self.is_low(),
|
|
65
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Thermal Management Module
|
|
3
|
+
=========================
|
|
4
|
+
|
|
5
|
+
The :class:`ThermalManager` monitors actuator temperatures and
|
|
6
|
+
triggers cooling when thresholds are exceeded. It applies a
|
|
7
|
+
hysteresis strategy to avoid rapid cycling of fans or pumps.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ThermalManager:
|
|
17
|
+
"""Monitor temperatures and control cooling.
|
|
18
|
+
|
|
19
|
+
Parameters
|
|
20
|
+
----------
|
|
21
|
+
max_temp_c : float
|
|
22
|
+
Maximum safe temperature in degrees Celsius.
|
|
23
|
+
hysteresis_c : float
|
|
24
|
+
Temperature difference between activating and deactivating
|
|
25
|
+
cooling.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, max_temp_c: float = 70.0, hysteresis_c: float = 10.0) -> None:
|
|
29
|
+
self._max_temp = max_temp_c
|
|
30
|
+
self._hysteresis = hysteresis_c
|
|
31
|
+
self._current_temp = 25.0 # ambient default
|
|
32
|
+
self._cooling_active = False
|
|
33
|
+
self._logger = logging.getLogger(self.__class__.__name__)
|
|
34
|
+
|
|
35
|
+
def update(self, temperature_c: float | None = None) -> None:
|
|
36
|
+
"""Update the current temperature reading.
|
|
37
|
+
|
|
38
|
+
If *temperature_c* is ``None``, the manager simulates a
|
|
39
|
+
small temperature increase for demonstration purposes.
|
|
40
|
+
"""
|
|
41
|
+
if temperature_c is not None:
|
|
42
|
+
self._current_temp = temperature_c
|
|
43
|
+
else:
|
|
44
|
+
self._current_temp += 0.01 # placeholder drift
|
|
45
|
+
|
|
46
|
+
if self._current_temp >= self._max_temp:
|
|
47
|
+
if not self._cooling_active:
|
|
48
|
+
self._logger.warning(
|
|
49
|
+
"Temperature %.1f C exceeds max %.1f C — activating cooling",
|
|
50
|
+
self._current_temp,
|
|
51
|
+
self._max_temp,
|
|
52
|
+
)
|
|
53
|
+
self._cooling_active = True
|
|
54
|
+
elif self._current_temp <= self._max_temp - self._hysteresis:
|
|
55
|
+
if self._cooling_active:
|
|
56
|
+
self._logger.info("Temperature normalised — deactivating cooling")
|
|
57
|
+
self._cooling_active = False
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def is_cooling(self) -> bool:
|
|
61
|
+
"""Return ``True`` if cooling is currently active."""
|
|
62
|
+
return self._cooling_active
|
|
63
|
+
|
|
64
|
+
def telemetry(self) -> dict[str, Any]:
|
|
65
|
+
"""Return thermal telemetry data."""
|
|
66
|
+
return {
|
|
67
|
+
"temperature_c": round(self._current_temp, 2),
|
|
68
|
+
"cooling_active": self._cooling_active,
|
|
69
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Robot Configuration Parser
|
|
3
|
+
==========================
|
|
4
|
+
|
|
5
|
+
Reads configuration from environment variables and exposes a
|
|
6
|
+
strongly-typed, frozen :class:`RobotConfig` dataclass. This allows
|
|
7
|
+
the system to adapt to different factory environments, hardware
|
|
8
|
+
configurations and operating modes without changing code.
|
|
9
|
+
|
|
10
|
+
Boolean values accept ``1``, ``true``, ``t``, ``yes``, ``y`` or
|
|
11
|
+
``on`` (case-insensitive) as true; everything else is false.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# Helpers
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
def _env(name: str, default: str) -> str:
|
|
24
|
+
return os.getenv(name, default)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _env_bool(name: str, default: bool) -> bool:
|
|
28
|
+
val = os.getenv(name)
|
|
29
|
+
if val is None:
|
|
30
|
+
return default
|
|
31
|
+
return str(val).strip().lower() in {"1", "true", "t", "yes", "y", "on"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _env_floats(name: str, default: tuple[float, ...]) -> tuple[float, ...]:
|
|
35
|
+
raw = os.getenv(name)
|
|
36
|
+
if not raw:
|
|
37
|
+
return default
|
|
38
|
+
out: list[float] = []
|
|
39
|
+
for part in raw.split(","):
|
|
40
|
+
part = part.strip()
|
|
41
|
+
if not part:
|
|
42
|
+
continue
|
|
43
|
+
try:
|
|
44
|
+
out.append(float(part))
|
|
45
|
+
except ValueError:
|
|
46
|
+
msg = f"Invalid float in {name}: {part}"
|
|
47
|
+
raise ValueError(msg) from None
|
|
48
|
+
return tuple(out)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _env_csv(name: str, default: tuple[str, ...]) -> tuple[str, ...]:
|
|
52
|
+
raw = os.getenv(name)
|
|
53
|
+
if not raw:
|
|
54
|
+
return default
|
|
55
|
+
return tuple(s.strip() for s in raw.split(",") if s.strip())
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# RobotConfig
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class RobotConfig:
|
|
64
|
+
"""Configuration for the palletiser control stack.
|
|
65
|
+
|
|
66
|
+
Fields correspond to environment variables prefixed with
|
|
67
|
+
``PALLETIZER_``.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
robot_environment: str
|
|
71
|
+
operation_mode: str
|
|
72
|
+
simulator_enabled: bool
|
|
73
|
+
joint_ids: tuple[str, ...]
|
|
74
|
+
actuator_types: tuple[str, ...]
|
|
75
|
+
max_velocity_per_joint: tuple[float, ...]
|
|
76
|
+
max_torque_per_joint: tuple[float, ...]
|
|
77
|
+
min_safety_margin: float
|
|
78
|
+
safety_thresholds: tuple[float, ...]
|
|
79
|
+
control_frequency_hz: float
|
|
80
|
+
communication_endpoint: str | None
|
|
81
|
+
|
|
82
|
+
def __init__(self) -> None:
|
|
83
|
+
object.__setattr__(self, "robot_environment", _env("PALLETIZER_ENV", "FACTORY"))
|
|
84
|
+
object.__setattr__(self, "operation_mode", _env("PALLETIZER_MODE", "PRODUCTION"))
|
|
85
|
+
object.__setattr__(self, "simulator_enabled", _env_bool("PALLETIZER_SIM", False))
|
|
86
|
+
object.__setattr__(
|
|
87
|
+
self,
|
|
88
|
+
"joint_ids",
|
|
89
|
+
_env_csv("PALLETIZER_JOINT_IDS", ("j0", "j1", "j2", "j3", "j4", "j5")),
|
|
90
|
+
)
|
|
91
|
+
object.__setattr__(
|
|
92
|
+
self,
|
|
93
|
+
"actuator_types",
|
|
94
|
+
_env_csv("PALLETIZER_ACTUATORS", ("REVOLUTE",) * 6),
|
|
95
|
+
)
|
|
96
|
+
object.__setattr__(
|
|
97
|
+
self,
|
|
98
|
+
"max_velocity_per_joint",
|
|
99
|
+
_env_floats("PALLETIZER_MAX_VEL", (1.0,) * 6),
|
|
100
|
+
)
|
|
101
|
+
object.__setattr__(
|
|
102
|
+
self,
|
|
103
|
+
"max_torque_per_joint",
|
|
104
|
+
_env_floats("PALLETIZER_MAX_TORQUE", (100.0,) * 6),
|
|
105
|
+
)
|
|
106
|
+
object.__setattr__(
|
|
107
|
+
self,
|
|
108
|
+
"min_safety_margin",
|
|
109
|
+
float(os.getenv("PALLETIZER_SAFETY_MARGIN", "0.4")),
|
|
110
|
+
)
|
|
111
|
+
object.__setattr__(
|
|
112
|
+
self,
|
|
113
|
+
"safety_thresholds",
|
|
114
|
+
_env_floats("PALLETIZER_SAFETY_THRESHOLDS", ()),
|
|
115
|
+
)
|
|
116
|
+
object.__setattr__(
|
|
117
|
+
self,
|
|
118
|
+
"control_frequency_hz",
|
|
119
|
+
float(os.getenv("PALLETIZER_FREQ", "50.0")),
|
|
120
|
+
)
|
|
121
|
+
object.__setattr__(
|
|
122
|
+
self,
|
|
123
|
+
"communication_endpoint",
|
|
124
|
+
os.getenv("PALLETIZER_COMM_ENDPOINT"),
|
|
125
|
+
)
|