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,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fault Detection Module
|
|
3
|
+
======================
|
|
4
|
+
|
|
5
|
+
The :class:`FaultDetector` monitors actuators, sensors and control
|
|
6
|
+
loops for anomalies. It maintains a set of active fault identifiers
|
|
7
|
+
and exposes interfaces to register, clear and query them. Detected
|
|
8
|
+
faults are forwarded to the hazard manager so they can be treated
|
|
9
|
+
uniformly with environmental hazards.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Iterable
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FaultDetector:
|
|
18
|
+
"""Track and manage system faults."""
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self._active_faults: set[str] = set()
|
|
22
|
+
|
|
23
|
+
def report_fault(self, fault: str) -> None:
|
|
24
|
+
"""Register a new fault identifier."""
|
|
25
|
+
if isinstance(fault, str) and fault:
|
|
26
|
+
self._active_faults.add(fault)
|
|
27
|
+
|
|
28
|
+
def clear_fault(self, fault: str) -> None:
|
|
29
|
+
"""Clear a previously reported fault."""
|
|
30
|
+
self._active_faults.discard(fault)
|
|
31
|
+
|
|
32
|
+
def clear_all(self) -> None:
|
|
33
|
+
"""Clear all active faults."""
|
|
34
|
+
self._active_faults.clear()
|
|
35
|
+
|
|
36
|
+
def get_active_faults(self) -> Iterable[str]:
|
|
37
|
+
"""Return an iterable of currently active faults."""
|
|
38
|
+
return tuple(self._active_faults)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hardware Synchronisation Module
|
|
3
|
+
================================
|
|
4
|
+
|
|
5
|
+
Coordinates clock synchronisation across sensors and actuators.
|
|
6
|
+
In a palletiser this ensures that conveyor belts, robots and
|
|
7
|
+
sensors stay in sync.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HardwareSynchroniser:
|
|
17
|
+
"""Synchronise hardware clocks.
|
|
18
|
+
|
|
19
|
+
Maintains a reference timestamp and computes offsets for each
|
|
20
|
+
registered device.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self) -> None:
|
|
24
|
+
self._reference_time: float = time.monotonic()
|
|
25
|
+
self._offsets: dict[str, float] = {}
|
|
26
|
+
self._logger = logging.getLogger(self.__class__.__name__)
|
|
27
|
+
|
|
28
|
+
def register_device(self, device_id: str, device_time: float) -> None:
|
|
29
|
+
"""Register a device and compute its clock offset."""
|
|
30
|
+
offset = time.monotonic() - device_time
|
|
31
|
+
self._offsets[device_id] = offset
|
|
32
|
+
self._logger.debug("Device %s offset: %.6f s", device_id, offset)
|
|
33
|
+
|
|
34
|
+
def get_offset(self, device_id: str) -> float:
|
|
35
|
+
"""Return the clock offset for a registered device."""
|
|
36
|
+
return self._offsets.get(device_id, 0.0)
|
|
37
|
+
|
|
38
|
+
def synchronised_time(self, device_id: str, device_time: float) -> float:
|
|
39
|
+
"""Convert a device timestamp to the reference clock."""
|
|
40
|
+
return device_time + self.get_offset(device_id)
|
|
41
|
+
|
|
42
|
+
def reset(self) -> None:
|
|
43
|
+
"""Reset the reference time and clear all offsets."""
|
|
44
|
+
self._reference_time = time.monotonic()
|
|
45
|
+
self._offsets.clear()
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hazard Manager Module
|
|
3
|
+
=====================
|
|
4
|
+
|
|
5
|
+
The :class:`HazardManager` monitors environmental signals and
|
|
6
|
+
internal states to detect potentially dangerous conditions. It
|
|
7
|
+
provides a unified API for querying whether the palletiser can
|
|
8
|
+
continue operating safely and for obtaining structured alerts about
|
|
9
|
+
detected hazards.
|
|
10
|
+
|
|
11
|
+
Typical hazards include proximity to humans or other machines,
|
|
12
|
+
high voltage, gas or dust levels, overheated actuators and
|
|
13
|
+
electrical faults.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from ..config import Config
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HazardManager:
|
|
25
|
+
"""Monitor environmental and internal hazards.
|
|
26
|
+
|
|
27
|
+
Parameters
|
|
28
|
+
----------
|
|
29
|
+
config : Config
|
|
30
|
+
System configuration including safety thresholds.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, config: Config) -> None:
|
|
34
|
+
self._config = config
|
|
35
|
+
self._hazards: dict[str, dict[str, Any]] = {}
|
|
36
|
+
self._logger = logging.getLogger(self.__class__.__name__)
|
|
37
|
+
|
|
38
|
+
# ------------------------------------------------------------------
|
|
39
|
+
# Public API
|
|
40
|
+
# ------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
def update(self, signals: dict[str, Any]) -> None:
|
|
43
|
+
"""Update hazard state based on new sensor signals.
|
|
44
|
+
|
|
45
|
+
Supported keys in *signals*:
|
|
46
|
+
|
|
47
|
+
* ``proximity`` — distance to nearest obstacle (metres).
|
|
48
|
+
* ``high_voltage``, ``gas``, ``radiation`` — scalar values
|
|
49
|
+
compared against ``config.safety_thresholds[0..2]``.
|
|
50
|
+
* ``faults`` — iterable of fault identifier strings.
|
|
51
|
+
"""
|
|
52
|
+
self._hazards.clear()
|
|
53
|
+
if not isinstance(signals, dict):
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
def _threshold(idx: int, default: float = 1.0) -> float:
|
|
57
|
+
try:
|
|
58
|
+
return float(self._config.safety_thresholds[idx])
|
|
59
|
+
except Exception:
|
|
60
|
+
return default
|
|
61
|
+
|
|
62
|
+
# Proximity
|
|
63
|
+
prox = signals.get("proximity")
|
|
64
|
+
if prox is not None:
|
|
65
|
+
try:
|
|
66
|
+
distance = float(prox)
|
|
67
|
+
if distance < self._config.safety_margin_m:
|
|
68
|
+
self._hazards["proximity"] = {
|
|
69
|
+
"value": distance,
|
|
70
|
+
"threshold": self._config.safety_margin_m,
|
|
71
|
+
"message": (
|
|
72
|
+
f"Object within {distance:.2f} m "
|
|
73
|
+
f"(min {self._config.safety_margin_m} m)"
|
|
74
|
+
),
|
|
75
|
+
"risk_level": "high",
|
|
76
|
+
}
|
|
77
|
+
except Exception:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
# Numeric hazards
|
|
81
|
+
for idx, name in enumerate(["high_voltage", "gas", "radiation"]):
|
|
82
|
+
val = signals.get(name)
|
|
83
|
+
if val is None:
|
|
84
|
+
continue
|
|
85
|
+
try:
|
|
86
|
+
level = float(val)
|
|
87
|
+
except Exception:
|
|
88
|
+
continue
|
|
89
|
+
thresh = _threshold(idx)
|
|
90
|
+
if level > thresh:
|
|
91
|
+
self._hazards[name] = {
|
|
92
|
+
"value": level,
|
|
93
|
+
"threshold": thresh,
|
|
94
|
+
"message": (
|
|
95
|
+
f"{name.replace('_', ' ').title()} level "
|
|
96
|
+
f"{level:.2f} exceeds {thresh:.2f}"
|
|
97
|
+
),
|
|
98
|
+
"risk_level": "high",
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
# Fault hazards
|
|
102
|
+
faults = signals.get("faults")
|
|
103
|
+
if faults:
|
|
104
|
+
if isinstance(faults, str):
|
|
105
|
+
faults_iter: list[str] = [faults]
|
|
106
|
+
elif isinstance(faults, (list, tuple, set)):
|
|
107
|
+
faults_iter = [str(f) for f in faults]
|
|
108
|
+
else:
|
|
109
|
+
faults_iter = []
|
|
110
|
+
for fault_name in faults_iter:
|
|
111
|
+
self._hazards[f"fault:{fault_name}"] = {
|
|
112
|
+
"value": True,
|
|
113
|
+
"threshold": True,
|
|
114
|
+
"message": f"Fault detected: {fault_name}",
|
|
115
|
+
"risk_level": "high",
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
def is_safe(self) -> bool:
|
|
119
|
+
"""Return ``True`` if the palletiser may continue operating."""
|
|
120
|
+
if not self._hazards:
|
|
121
|
+
return True
|
|
122
|
+
return all(
|
|
123
|
+
str(info.get("risk_level", "high")).lower() != "high"
|
|
124
|
+
for info in self._hazards.values()
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def current_hazards(self) -> dict[str, dict[str, Any]]:
|
|
128
|
+
"""Return a dictionary of the currently detected hazards."""
|
|
129
|
+
return dict(self._hazards)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Memory Management Module
|
|
3
|
+
========================
|
|
4
|
+
|
|
5
|
+
The :class:`MemoryManager` provides a deterministic allocator to
|
|
6
|
+
avoid unpredictable garbage-collection pauses during the control
|
|
7
|
+
loop. Pre-allocating buffers ensures predictable memory usage and
|
|
8
|
+
helps detect runaway consumption before it causes failures.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MemoryManager:
|
|
17
|
+
"""Deterministic memory allocator for real-time control.
|
|
18
|
+
|
|
19
|
+
Parameters
|
|
20
|
+
----------
|
|
21
|
+
total_bytes : int
|
|
22
|
+
Total amount of memory (bytes) available for allocation.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, total_bytes: int) -> None:
|
|
26
|
+
self.total_bytes = total_bytes
|
|
27
|
+
self.available_bytes = total_bytes
|
|
28
|
+
self._allocations: dict[Any, int] = {}
|
|
29
|
+
|
|
30
|
+
def allocate(self, size: int) -> Any:
|
|
31
|
+
"""Reserve a block of memory and return an opaque handle.
|
|
32
|
+
|
|
33
|
+
Raises
|
|
34
|
+
------
|
|
35
|
+
ValueError
|
|
36
|
+
If *size* is not positive.
|
|
37
|
+
MemoryError
|
|
38
|
+
If there is insufficient memory to satisfy the request.
|
|
39
|
+
"""
|
|
40
|
+
if size <= 0:
|
|
41
|
+
msg = "Allocation size must be positive"
|
|
42
|
+
raise ValueError(msg)
|
|
43
|
+
if size > self.available_bytes:
|
|
44
|
+
msg = "Insufficient memory available"
|
|
45
|
+
raise MemoryError(msg)
|
|
46
|
+
handle = object()
|
|
47
|
+
self._allocations[handle] = size
|
|
48
|
+
self.available_bytes -= size
|
|
49
|
+
return handle
|
|
50
|
+
|
|
51
|
+
def release(self, handle: Any) -> None:
|
|
52
|
+
"""Release a previously allocated memory block.
|
|
53
|
+
|
|
54
|
+
Double frees or unknown handles are silently ignored.
|
|
55
|
+
"""
|
|
56
|
+
size = self._allocations.pop(handle, None)
|
|
57
|
+
if size is not None:
|
|
58
|
+
self.available_bytes += size
|
|
59
|
+
|
|
60
|
+
def check_health(self) -> bool:
|
|
61
|
+
"""Return ``True`` if at least 10 % of memory remains free."""
|
|
62
|
+
if self.total_bytes == 0:
|
|
63
|
+
return True
|
|
64
|
+
free_ratio = self.available_bytes / self.total_bytes
|
|
65
|
+
return free_ratio >= 0.1
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Mixed-SKU Pallet Optimizer
|
|
3
|
+
==========================
|
|
4
|
+
|
|
5
|
+
A real, deterministic pallet-packing engine. Given a set of boxes and a
|
|
6
|
+
pallet, it computes actual placements (with 90-degree rotation), an actual
|
|
7
|
+
volumetric density, an actual density uplift versus a naive baseline, and a
|
|
8
|
+
deterministic, physics-grounded stability score.
|
|
9
|
+
|
|
10
|
+
Nothing here is random or hardcoded: every metric is derived from the geometry
|
|
11
|
+
of the computed placement. The stability model combines base-support ratio
|
|
12
|
+
(how much of each box rests on the layer below) with center-of-mass offset
|
|
13
|
+
from the pallet center.
|
|
14
|
+
|
|
15
|
+
This is a heuristic engine (shelf packing + height-grouped layers). It is the
|
|
16
|
+
honest v1 of the "one hard capability". A future version can swap the layer
|
|
17
|
+
packer for an exact solver (OR-Tools CP-SAT / MILP) behind the same API.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import csv
|
|
23
|
+
import math
|
|
24
|
+
from dataclasses import dataclass, field, asdict
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Standard GMA pallet, mm. Override per deployment.
|
|
29
|
+
DEFAULT_PALLET_LENGTH_MM = 1219.0
|
|
30
|
+
DEFAULT_PALLET_WIDTH_MM = 1016.0
|
|
31
|
+
DEFAULT_MAX_HEIGHT_MM = 1800.0
|
|
32
|
+
DEFAULT_MAX_WEIGHT_KG = 1000.0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Box:
|
|
37
|
+
sku_id: str
|
|
38
|
+
length_mm: float
|
|
39
|
+
width_mm: float
|
|
40
|
+
height_mm: float
|
|
41
|
+
weight_kg: float = 0.0
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def footprint_mm2(self) -> float:
|
|
45
|
+
return self.length_mm * self.width_mm
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def volume_mm3(self) -> float:
|
|
49
|
+
return self.length_mm * self.width_mm * self.height_mm
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Pallet:
|
|
54
|
+
length_mm: float = DEFAULT_PALLET_LENGTH_MM
|
|
55
|
+
width_mm: float = DEFAULT_PALLET_WIDTH_MM
|
|
56
|
+
max_height_mm: float = DEFAULT_MAX_HEIGHT_MM
|
|
57
|
+
max_weight_kg: float = DEFAULT_MAX_WEIGHT_KG
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def footprint_mm2(self) -> float:
|
|
61
|
+
return self.length_mm * self.width_mm
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class Placement:
|
|
66
|
+
sku_id: str
|
|
67
|
+
x_mm: float # lower-left corner on the pallet
|
|
68
|
+
y_mm: float
|
|
69
|
+
z_mm: float # base height of the box
|
|
70
|
+
length_mm: float # footprint length AFTER rotation
|
|
71
|
+
width_mm: float # footprint width AFTER rotation
|
|
72
|
+
height_mm: float
|
|
73
|
+
weight_kg: float
|
|
74
|
+
rot_deg: float # 0 or 90
|
|
75
|
+
layer: int
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class PalletPlan:
|
|
80
|
+
placements: list[Placement] = field(default_factory=list)
|
|
81
|
+
unplaced: list[str] = field(default_factory=list)
|
|
82
|
+
num_layers: int = 0
|
|
83
|
+
stack_height_mm: float = 0.0
|
|
84
|
+
total_weight_kg: float = 0.0
|
|
85
|
+
volume_density: float = 0.0
|
|
86
|
+
baseline_density: float = 0.0
|
|
87
|
+
density_uplift_pct: float = 0.0
|
|
88
|
+
support_score: float = 0.0
|
|
89
|
+
com_score: float = 0.0
|
|
90
|
+
stability_score: float = 0.0
|
|
91
|
+
is_valid: bool = False
|
|
92
|
+
recommendations: list[str] = field(default_factory=list)
|
|
93
|
+
|
|
94
|
+
def to_dict(self) -> dict:
|
|
95
|
+
d = asdict(self)
|
|
96
|
+
return d
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _overlap_area(a: Placement, b: Placement) -> float:
|
|
100
|
+
"""Footprint overlap area (mm^2) between two placements."""
|
|
101
|
+
ax2, ay2 = a.x_mm + a.length_mm, a.y_mm + a.width_mm
|
|
102
|
+
bx2, by2 = b.x_mm + b.length_mm, b.y_mm + b.width_mm
|
|
103
|
+
dx = max(0.0, min(ax2, bx2) - max(a.x_mm, b.x_mm))
|
|
104
|
+
dy = max(0.0, min(ay2, by2) - max(a.y_mm, b.y_mm))
|
|
105
|
+
return dx * dy
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _pack_layer(
|
|
109
|
+
boxes: list[Box],
|
|
110
|
+
pallet: Pallet,
|
|
111
|
+
z_mm: float,
|
|
112
|
+
layer_index: int,
|
|
113
|
+
) -> tuple[list[Placement], list[Box]]:
|
|
114
|
+
"""Shelf-pack a single layer. Returns (placements, leftover boxes).
|
|
115
|
+
|
|
116
|
+
Greedy First-Fit-Decreasing by footprint, trying both orientations so the
|
|
117
|
+
long edge runs along whichever pallet axis leaves less waste.
|
|
118
|
+
"""
|
|
119
|
+
placed: list[Placement] = []
|
|
120
|
+
leftover: list[Box] = []
|
|
121
|
+
|
|
122
|
+
# Tallest/biggest first packs denser and more stably.
|
|
123
|
+
ordered = sorted(boxes, key=lambda b: b.footprint_mm2, reverse=True)
|
|
124
|
+
|
|
125
|
+
x_cursor = 0.0
|
|
126
|
+
y_cursor = 0.0
|
|
127
|
+
shelf_depth = 0.0 # width consumed by the current shelf (y direction)
|
|
128
|
+
|
|
129
|
+
for box in ordered:
|
|
130
|
+
placed_this = False
|
|
131
|
+
# Two candidate orientations: (L,W) and rotated (W,L).
|
|
132
|
+
orientations = [
|
|
133
|
+
(box.length_mm, box.width_mm, 0.0),
|
|
134
|
+
(box.width_mm, box.length_mm, 90.0),
|
|
135
|
+
]
|
|
136
|
+
for fp_l, fp_w, rot in orientations:
|
|
137
|
+
if fp_l > pallet.length_mm or fp_w > pallet.width_mm:
|
|
138
|
+
continue # cannot fit this orientation at all
|
|
139
|
+
# Fits on the current shelf?
|
|
140
|
+
if x_cursor + fp_l <= pallet.length_mm + 1e-6 and y_cursor + fp_w <= pallet.width_mm + 1e-6:
|
|
141
|
+
placed.append(Placement(
|
|
142
|
+
sku_id=box.sku_id, x_mm=x_cursor, y_mm=y_cursor, z_mm=z_mm,
|
|
143
|
+
length_mm=fp_l, width_mm=fp_w, height_mm=box.height_mm,
|
|
144
|
+
weight_kg=box.weight_kg, rot_deg=rot, layer=layer_index,
|
|
145
|
+
))
|
|
146
|
+
x_cursor += fp_l
|
|
147
|
+
shelf_depth = max(shelf_depth, fp_w)
|
|
148
|
+
placed_this = True
|
|
149
|
+
break
|
|
150
|
+
# Try starting a new shelf (advance in y).
|
|
151
|
+
new_y = y_cursor + shelf_depth
|
|
152
|
+
if new_y + fp_w <= pallet.width_mm + 1e-6 and fp_l <= pallet.length_mm + 1e-6:
|
|
153
|
+
y_cursor = new_y
|
|
154
|
+
x_cursor = 0.0
|
|
155
|
+
shelf_depth = fp_w
|
|
156
|
+
placed.append(Placement(
|
|
157
|
+
sku_id=box.sku_id, x_mm=x_cursor, y_mm=y_cursor, z_mm=z_mm,
|
|
158
|
+
length_mm=fp_l, width_mm=fp_w, height_mm=box.height_mm,
|
|
159
|
+
weight_kg=box.weight_kg, rot_deg=rot, layer=layer_index,
|
|
160
|
+
))
|
|
161
|
+
x_cursor += fp_l
|
|
162
|
+
placed_this = True
|
|
163
|
+
break
|
|
164
|
+
if not placed_this:
|
|
165
|
+
leftover.append(box)
|
|
166
|
+
|
|
167
|
+
return placed, leftover
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _baseline_density(boxes: list[Box], pallet: Pallet) -> float:
|
|
171
|
+
"""Naive reference: single orientation, one box per footprint cell, stacked
|
|
172
|
+
in simple rows. Represents 'good enough' manual/basic stacking."""
|
|
173
|
+
if not boxes:
|
|
174
|
+
return 0.0
|
|
175
|
+
placed_vol = 0.0
|
|
176
|
+
x = y = z = 0.0
|
|
177
|
+
row_depth = 0.0
|
|
178
|
+
layer_h = 0.0
|
|
179
|
+
for box in boxes:
|
|
180
|
+
if box.length_mm > pallet.length_mm or box.width_mm > pallet.width_mm:
|
|
181
|
+
continue
|
|
182
|
+
if x + box.length_mm > pallet.length_mm:
|
|
183
|
+
x = 0.0
|
|
184
|
+
y += row_depth
|
|
185
|
+
row_depth = 0.0
|
|
186
|
+
if y + box.width_mm > pallet.width_mm:
|
|
187
|
+
x = y = 0.0
|
|
188
|
+
row_depth = 0.0
|
|
189
|
+
z += layer_h
|
|
190
|
+
layer_h = 0.0
|
|
191
|
+
if z >= pallet.max_height_mm:
|
|
192
|
+
break
|
|
193
|
+
x += box.length_mm
|
|
194
|
+
row_depth = max(row_depth, box.width_mm)
|
|
195
|
+
layer_h = max(layer_h, box.height_mm)
|
|
196
|
+
placed_vol += box.volume_mm3
|
|
197
|
+
stack_h = z + layer_h
|
|
198
|
+
if stack_h <= 0:
|
|
199
|
+
return 0.0
|
|
200
|
+
return placed_vol / (pallet.footprint_mm2 * stack_h)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _stability(placements: list[Placement], pallet: Pallet) -> tuple[float, float, float]:
|
|
204
|
+
"""Deterministic stability from geometry.
|
|
205
|
+
|
|
206
|
+
Returns (support_score, com_score, stability_score), all in [0, 1].
|
|
207
|
+
"""
|
|
208
|
+
if not placements:
|
|
209
|
+
return 0.0, 0.0, 0.0
|
|
210
|
+
|
|
211
|
+
by_layer: dict[int, list[Placement]] = {}
|
|
212
|
+
for p in placements:
|
|
213
|
+
by_layer.setdefault(p.layer, []).append(p)
|
|
214
|
+
|
|
215
|
+
# Base-support ratio: fraction of each box's footprint resting on the
|
|
216
|
+
# layer below (layer 0 rests fully on the pallet).
|
|
217
|
+
support_ratios: list[float] = []
|
|
218
|
+
for layer_idx, layer in by_layer.items():
|
|
219
|
+
below = by_layer.get(layer_idx - 1, []) if layer_idx > 0 else None
|
|
220
|
+
for box in layer:
|
|
221
|
+
if below is None:
|
|
222
|
+
support_ratios.append(1.0)
|
|
223
|
+
continue
|
|
224
|
+
supported = sum(_overlap_area(box, b) for b in below)
|
|
225
|
+
area = box.length_mm * box.width_mm
|
|
226
|
+
support_ratios.append(min(1.0, supported / area) if area else 0.0)
|
|
227
|
+
support_score = sum(support_ratios) / len(support_ratios)
|
|
228
|
+
|
|
229
|
+
# Center-of-mass offset from pallet center, normalized by half-extent.
|
|
230
|
+
total_w = sum(max(p.weight_kg, 1e-6) for p in placements)
|
|
231
|
+
com_x = sum((p.x_mm + p.length_mm / 2) * max(p.weight_kg, 1e-6) for p in placements) / total_w
|
|
232
|
+
com_y = sum((p.y_mm + p.width_mm / 2) * max(p.weight_kg, 1e-6) for p in placements) / total_w
|
|
233
|
+
cx, cy = pallet.length_mm / 2, pallet.width_mm / 2
|
|
234
|
+
half_diag = math.hypot(cx, cy)
|
|
235
|
+
offset = math.hypot(com_x - cx, com_y - cy) / half_diag
|
|
236
|
+
com_score = max(0.0, 1.0 - offset)
|
|
237
|
+
|
|
238
|
+
stability_score = 0.6 * support_score + 0.4 * com_score
|
|
239
|
+
return support_score, com_score, stability_score
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def optimize_pallet(boxes: list[Box], pallet: Pallet | None = None) -> PalletPlan:
|
|
243
|
+
"""Compute a real pallet plan for the given boxes.
|
|
244
|
+
|
|
245
|
+
Boxes are grouped into height-similar layers and shelf-packed. Every metric
|
|
246
|
+
on the returned :class:`PalletPlan` is derived from the resulting geometry.
|
|
247
|
+
"""
|
|
248
|
+
pallet = pallet or Pallet()
|
|
249
|
+
plan = PalletPlan()
|
|
250
|
+
if not boxes:
|
|
251
|
+
return plan
|
|
252
|
+
|
|
253
|
+
# Group by height (descending) so each layer has a well-defined height with
|
|
254
|
+
# minimal vertical waste.
|
|
255
|
+
remaining = sorted(boxes, key=lambda b: b.height_mm, reverse=True)
|
|
256
|
+
|
|
257
|
+
z = 0.0
|
|
258
|
+
weight = 0.0
|
|
259
|
+
layer_index = 0
|
|
260
|
+
placed_all: list[Placement] = []
|
|
261
|
+
|
|
262
|
+
while remaining:
|
|
263
|
+
# Stop if the next (shortest possible) layer would exceed height budget.
|
|
264
|
+
min_h = min(b.height_mm for b in remaining)
|
|
265
|
+
if z + min_h > pallet.max_height_mm:
|
|
266
|
+
break
|
|
267
|
+
|
|
268
|
+
placed, leftover = _pack_layer(remaining, pallet, z, layer_index)
|
|
269
|
+
if not placed:
|
|
270
|
+
break # nothing fits (oversized boxes) -> unplaced below
|
|
271
|
+
|
|
272
|
+
layer_height = max(p.height_mm for p in placed)
|
|
273
|
+
if z + layer_height > pallet.max_height_mm:
|
|
274
|
+
break
|
|
275
|
+
|
|
276
|
+
layer_weight = sum(p.weight_kg for p in placed)
|
|
277
|
+
if weight + layer_weight > pallet.max_weight_kg and placed_all:
|
|
278
|
+
break # weight budget reached; keep what we already have
|
|
279
|
+
|
|
280
|
+
placed_all.extend(placed)
|
|
281
|
+
z += layer_height
|
|
282
|
+
weight += layer_weight
|
|
283
|
+
layer_index += 1
|
|
284
|
+
remaining = leftover
|
|
285
|
+
|
|
286
|
+
plan.placements = placed_all
|
|
287
|
+
plan.unplaced = [b.sku_id for b in remaining]
|
|
288
|
+
plan.num_layers = layer_index
|
|
289
|
+
plan.stack_height_mm = round(z, 2)
|
|
290
|
+
plan.total_weight_kg = round(weight, 2)
|
|
291
|
+
|
|
292
|
+
placed_vol = sum(p.length_mm * p.width_mm * p.height_mm for p in placed_all)
|
|
293
|
+
bounding_vol = pallet.footprint_mm2 * z if z > 0 else 0.0
|
|
294
|
+
plan.volume_density = round(placed_vol / bounding_vol, 4) if bounding_vol else 0.0
|
|
295
|
+
|
|
296
|
+
base = _baseline_density(boxes, pallet)
|
|
297
|
+
plan.baseline_density = round(base, 4)
|
|
298
|
+
plan.density_uplift_pct = round((plan.volume_density - base) / base * 100, 1) if base > 0 else 0.0
|
|
299
|
+
|
|
300
|
+
support, com, stab = _stability(placed_all, pallet)
|
|
301
|
+
plan.support_score = round(support, 3)
|
|
302
|
+
plan.com_score = round(com, 3)
|
|
303
|
+
plan.stability_score = round(stab, 3)
|
|
304
|
+
|
|
305
|
+
recs: list[str] = []
|
|
306
|
+
if plan.unplaced:
|
|
307
|
+
recs.append(f"{len(plan.unplaced)} box(es) did not fit; consider a second pallet or larger footprint.")
|
|
308
|
+
if plan.support_score < 0.85:
|
|
309
|
+
recs.append("Low base support on upper layers; reorder so larger footprints sit lower.")
|
|
310
|
+
if plan.com_score < 0.8:
|
|
311
|
+
recs.append("Load is off-center; redistribute heavy SKUs toward the pallet center.")
|
|
312
|
+
plan.recommendations = recs
|
|
313
|
+
|
|
314
|
+
plan.is_valid = (
|
|
315
|
+
bool(placed_all)
|
|
316
|
+
and plan.stability_score >= 0.6
|
|
317
|
+
and plan.com_score >= 0.5
|
|
318
|
+
)
|
|
319
|
+
return plan
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def load_boxes_csv(path: str | Path) -> list[Box]:
|
|
323
|
+
"""Load boxes from a CSV with columns:
|
|
324
|
+
sku_id, length_mm, width_mm, height_mm, weight_kg (weight optional).
|
|
325
|
+
"""
|
|
326
|
+
boxes: list[Box] = []
|
|
327
|
+
with open(path, newline="") as fh:
|
|
328
|
+
reader = csv.DictReader(fh)
|
|
329
|
+
for row in reader:
|
|
330
|
+
boxes.append(Box(
|
|
331
|
+
sku_id=row["sku_id"].strip(),
|
|
332
|
+
length_mm=float(row["length_mm"]),
|
|
333
|
+
width_mm=float(row["width_mm"]),
|
|
334
|
+
height_mm=float(row["height_mm"]),
|
|
335
|
+
weight_kg=float(row.get("weight_kg", 0) or 0),
|
|
336
|
+
))
|
|
337
|
+
return boxes
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def cli() -> None:
|
|
341
|
+
"""Console entry point: palletize-optimize <skus.csv>"""
|
|
342
|
+
import argparse
|
|
343
|
+
import json
|
|
344
|
+
|
|
345
|
+
parser = argparse.ArgumentParser(description="Optimize a mixed-SKU pallet from a CSV of boxes.")
|
|
346
|
+
parser.add_argument("csv_path", help="CSV with columns sku_id,length_mm,width_mm,height_mm,weight_kg")
|
|
347
|
+
parser.add_argument("--pallet-length", type=float, default=DEFAULT_PALLET_LENGTH_MM)
|
|
348
|
+
parser.add_argument("--pallet-width", type=float, default=DEFAULT_PALLET_WIDTH_MM)
|
|
349
|
+
parser.add_argument("--max-height", type=float, default=DEFAULT_MAX_HEIGHT_MM)
|
|
350
|
+
parser.add_argument("--max-weight", type=float, default=DEFAULT_MAX_WEIGHT_KG)
|
|
351
|
+
parser.add_argument("--json", action="store_true", help="Emit full plan as JSON.")
|
|
352
|
+
args = parser.parse_args()
|
|
353
|
+
|
|
354
|
+
boxes = load_boxes_csv(args.csv_path)
|
|
355
|
+
pallet = Pallet(args.pallet_length, args.pallet_width, args.max_height, args.max_weight)
|
|
356
|
+
plan = optimize_pallet(boxes, pallet)
|
|
357
|
+
|
|
358
|
+
if args.json:
|
|
359
|
+
print(json.dumps(plan.to_dict(), indent=2))
|
|
360
|
+
return
|
|
361
|
+
|
|
362
|
+
print(f"Boxes in: {len(boxes)}")
|
|
363
|
+
print(f"Placed: {len(plan.placements)} ({len(plan.unplaced)} unplaced)")
|
|
364
|
+
print(f"Layers: {plan.num_layers}")
|
|
365
|
+
print(f"Stack height: {plan.stack_height_mm:.0f} mm")
|
|
366
|
+
print(f"Total weight: {plan.total_weight_kg:.1f} kg")
|
|
367
|
+
print(f"Density: {plan.volume_density * 100:.1f}% (baseline {plan.baseline_density * 100:.1f}%)")
|
|
368
|
+
print(f"Density uplift: {plan.density_uplift_pct:+.1f}% vs naive baseline")
|
|
369
|
+
print(f"Support score: {plan.support_score:.3f}")
|
|
370
|
+
print(f"CoM score: {plan.com_score:.3f}")
|
|
371
|
+
print(f"Stability: {plan.stability_score:.3f} ({'VALID' if plan.is_valid else 'REVIEW'})")
|
|
372
|
+
for r in plan.recommendations:
|
|
373
|
+
print(f" - {r}")
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
if __name__ == "__main__":
|
|
377
|
+
cli()
|