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,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()