interhack26 / optimize_box.py
danvancea's picture
Upload 34 files
4e22ad8 verified
Raw
History Blame Contribute Delete
23.3 kB
import numpy as np
import random
import math
class _Instance:
"""A multi-cell rectangular item placed in the truck lattice."""
__slots__ = ("id", "client", "item_type", "is_return", "anchor", "shape")
def __init__(self, instance_id, client, item_type, is_return, anchor, shape):
self.id = instance_id
self.client = client
self.item_type = item_type
self.is_return = is_return
self.anchor = anchor # (x, y, z) min corner
self.shape = shape # (lx, ly, lz)
def cells_at(self, anchor):
x0, y0, z0 = anchor
lx, ly, lz = self.shape
out = []
for i in range(lx):
for j in range(ly):
for k in range(lz):
out.append((x0 + i, y0 + j, z0 + k))
return out
@property
def cells(self):
return self.cells_at(self.anchor)
class SmartTruckOptimizer3D:
EMPTY_KEG = -1 # post-return solid block; structural support but always blocks extraction
def __init__(self, length_bays, width_pallets, height_layers, route, item_shapes=None):
"""
length_bays: lateral bays (X-axis)
width_pallets: depth of each bay (Y-axis); both Y-walls are open along the full length
height_layers: max stacking height (Z-axis)
route: client IDs in delivery order
item_shapes: dict[item_type, (lx, ly, lz)] of rectangular box dimensions.
Defaults to {"keg": (1, 1, 1)} for backward compatibility.
Removability rule: an instance is removable iff at least one of its three sides
— top, left (-Y), right (+Y) — is fully clear of blockers (other-client items
or empty kegs). The truck has no front/back access.
"""
self.L = length_bays
self.W = width_pallets
self.H = height_layers
self.route = list(route)
shapes = item_shapes if item_shapes is not None else {"keg": (1, 1, 1)}
self.item_shapes = {}
for name, dims in shapes.items():
dims = tuple(int(d) for d in dims)
if len(dims) != 3 or any(d < 1 for d in dims):
raise ValueError(f"Invalid shape for item '{name}': {dims}")
if dims[0] > self.L or dims[1] > self.W or dims[2] > self.H:
raise ValueError(f"Item '{name}' shape {dims} exceeds grid {(self.L, self.W, self.H)}.")
self.item_shapes[name] = dims
self._instances = {}
self._next_id = 1
# Work caches (variance over cell-x; multi-cell items contribute each cell once)
self._x_sum = {}
self._x_sq_sum = {}
self._counts = {}
self._max_var = max(((self.L - 1) ** 2) / 4.0, 1e-9)
# ---------- helpers ----------
def _is_blocker(self, value, client):
"""True iff a cell with `value` blocks `client` from extracting through it."""
if value == 0:
return False
if value == self.EMPTY_KEG:
return True
return self._instances[value].client != client
# ---------- initial state ----------
def generate_initial_state(self, client_item_counts, client_returns):
"""
client_item_counts: {client_id: {item_type: total_count}}
client_returns: {client_id: {item_type: returns_count}}
For each (client, item_type) we create `total - returns` pure-delivery instances
and `returns` substitution instances. Instances are placed at random valid
anchors; raises if the grid is too crowded.
"""
self._instances = {}
self._next_id = 1
state = np.zeros((self.L, self.W, self.H), dtype=int)
pending = [] # (client, item_type, is_return, shape)
for client, items in client_item_counts.items():
returns_for_client = client_returns.get(client, {})
for item_type, count in items.items():
if item_type not in self.item_shapes:
raise KeyError(f"Unknown item_type '{item_type}' (not in item_shapes).")
num_returns = returns_for_client.get(item_type, 0)
num_pure = count - num_returns
if num_pure < 0:
raise ValueError(
f"Client {client} item '{item_type}': returns ({num_returns}) "
f"exceed total ({count})."
)
shape = self.item_shapes[item_type]
for _ in range(num_pure):
pending.append((client, item_type, False, shape))
for _ in range(num_returns):
pending.append((client, item_type, True, shape))
total_cells_needed = sum(s[0] * s[1] * s[2] for *_, s in pending)
capacity = self.L * self.W * self.H
if total_cells_needed > capacity:
raise ValueError(f"Items need {total_cells_needed} cells; grid holds {capacity}.")
random.shuffle(pending)
for client, item_type, is_return, shape in pending:
anchor = self._place_random(state, shape)
if anchor is None:
raise RuntimeError(
f"Could not place item '{item_type}' for client {client}; grid too crowded."
)
inst_id = self._next_id
self._next_id += 1
inst = _Instance(inst_id, client, item_type, is_return, anchor, shape)
self._instances[inst_id] = inst
for (x, y, z) in inst.cells:
state[x, y, z] = inst_id
return state
def _place_random(self, state, shape, max_tries=500):
lx, ly, lz = shape
for _ in range(max_tries):
x = random.randint(0, self.L - lx)
y = random.randint(0, self.W - ly)
z = random.randint(0, self.H - lz)
if self._cells_clear(state, x, y, z, lx, ly, lz):
return (x, y, z)
# Deterministic fallback scan
for x in range(self.L - lx + 1):
for y in range(self.W - ly + 1):
for z in range(self.H - lz + 1):
if self._cells_clear(state, x, y, z, lx, ly, lz):
return (x, y, z)
return None
def _cells_clear(self, state, x, y, z, lx, ly, lz, allow_id=0):
"""True iff the box at (x,y,z) of size (lx,ly,lz) contains only 0 or `allow_id`."""
for i in range(lx):
for j in range(ly):
for k in range(lz):
v = state[x + i, y + j, z + k]
if v != 0 and v != allow_id:
return False
return True
# ---------- penalty (full eval) ----------
def physical_penalty(self, state):
"""
Sum of:
1. Initial gravity violations (cells whose support is void)
2. For each client in route order:
a. Extraction violations: instances with no fully-clear side
b. Post-ablation gravity violations
"""
penalty = 0
truck = np.copy(state)
penalty += self._gravity_violations(truck)
# Group instances by client once
by_client = {c: [] for c in self.route}
for inst in self._instances.values():
if inst.client in by_client:
by_client[inst.client].append(inst)
for client in self.route:
for inst in by_client[client]:
if self._instance_blocked(truck, inst):
penalty += 1
for inst in by_client[client]:
fill = self.EMPTY_KEG if inst.is_return else 0
for (x, y, z) in inst.cells:
truck[x, y, z] = fill
penalty += self._gravity_violations(truck)
return penalty
def _gravity_violations(self, truck):
if self.H < 2:
return 0
upper = truck[:, :, 1:]
lower = truck[:, :, :-1]
return int(np.sum((upper > 0) & (lower == 0)))
def _instance_blocked(self, truck, inst):
"""True iff none of {top, left, right} sides is fully clear of blockers."""
x0, y0, z0 = inst.anchor
lx, ly, lz = inst.shape
c = inst.client
# Top side: above the item
if z0 + lz >= self.H:
return False # extends to roof, top is open by definition
if self._region_clear(truck, x0, x0 + lx, y0, y0 + ly, z0 + lz, self.H, c):
return False
# Left side
if y0 == 0:
return False
if self._region_clear(truck, x0, x0 + lx, 0, y0, z0, z0 + lz, c):
return False
# Right side
if y0 + ly >= self.W:
return False
if self._region_clear(truck, x0, x0 + lx, y0 + ly, self.W, z0, z0 + lz, c):
return False
return True
def _region_clear(self, truck, x_lo, x_hi, y_lo, y_hi, z_lo, z_hi, client):
for x in range(x_lo, x_hi):
for y in range(y_lo, y_hi):
for z in range(z_lo, z_hi):
if self._is_blocker(truck[x, y, z], client):
return False
return True
# ---------- delta penalty ----------
def _instances_reading_cells(self, changed_cells):
"""Set of instance IDs whose blocking computation reads any of `changed_cells`."""
affected = set()
for inst_id, inst in self._instances.items():
x0, y0, z0 = inst.anchor
lx, ly, lz = inst.shape
for (cx, cy, cz) in changed_cells:
in_x = x0 <= cx < x0 + lx
in_y = y0 <= cy < y0 + ly
in_z = z0 <= cz < z0 + lz
if in_x and (
(in_y and cz >= z0 + lz) or # top column
(cy < y0 and in_z) or # left row
(cy >= y0 + ly and in_z) or # right row
(in_y and in_z) # inside (shouldn't happen unless self)
):
affected.add(inst_id)
break
return affected
def _evaluate_local_penalty(self, state, changed_cells, affected_ids):
"""
Sum penalty contributions whose values can differ between current and proposed
states. Cells outside `changed_cells` and instances outside `affected_ids` give
identical contributions in both, so they cancel in the delta.
The cascade still mutates a full-state copy (ablation is global), but only the
listed instances' extraction blocks and only the gravity neighbors of changed
cells contribute to the returned sum.
"""
truck = np.copy(state)
gravity_cells = set()
for (x, y, z) in changed_cells:
gravity_cells.add((x, y, z))
if z + 1 < self.H:
gravity_cells.add((x, y, z + 1))
penalty = 0
for (x, y, z) in gravity_cells:
if z >= 1 and truck[x, y, z] > 0 and truck[x, y, z - 1] == 0:
penalty += 1
by_client = {c: [] for c in self.route}
for inst in self._instances.values():
if inst.client in by_client:
by_client[inst.client].append(inst)
for client in self.route:
for inst in by_client[client]:
if inst.id in affected_ids and self._instance_blocked(truck, inst):
penalty += 1
for inst in by_client[client]:
fill = self.EMPTY_KEG if inst.is_return else 0
for (x, y, z) in inst.cells:
truck[x, y, z] = fill
for (x, y, z) in gravity_cells:
if z >= 1 and truck[x, y, z] > 0 and truck[x, y, z - 1] == 0:
penalty += 1
return penalty
# ---------- work ----------
def spatial_work(self, state):
"""
Mean-normalized variance of x-coords per client, in roughly [0, 1].
Each cell of a multi-cell item contributes its x once; rewards keeping a
client's items clustered along the truck length so the driver walks less
per stop. Translation-invariant (cluster *position* doesn't matter — both
Y-sides are open, see project notes).
"""
if not self.route:
return 0.0
total = 0.0
for client in self.route:
xs = []
for inst in self._instances.values():
if inst.client == client:
x0, _, _ = inst.anchor
lx, ly, lz = inst.shape
for i in range(lx):
xs.extend([x0 + i] * (ly * lz))
if len(xs) > 1:
total += float(np.var(xs))
return total / (self._max_var * len(self.route))
def _init_work_cache(self, state):
self._x_sum = {c: 0 for c in self.route}
self._x_sq_sum = {c: 0 for c in self.route}
self._counts = {c: 0 for c in self.route}
for inst in self._instances.values():
if inst.client not in self._counts:
continue
for (x, _, _) in inst.cells:
self._x_sum[inst.client] += x
self._x_sq_sum[inst.client] += x * x
self._counts[inst.client] += 1
def _work_from_cache(self):
if not self.route:
return 0.0
total = 0.0
for client in self.route:
n = self._counts.get(client, 0)
if n > 1:
mean = self._x_sum[client] / n
total += self._x_sq_sum[client] / n - mean * mean
return total / (self._max_var * len(self.route))
def _update_work_cache_for_move(self, inst, old_anchor, new_anchor):
"""Reflect that `inst` translated from old_anchor to new_anchor. O(|cells|)."""
if inst.client not in self._counts:
return
for (x, _, _) in inst.cells_at(old_anchor):
self._x_sum[inst.client] -= x
self._x_sq_sum[inst.client] -= x * x
for (x, _, _) in inst.cells_at(new_anchor):
self._x_sum[inst.client] += x
self._x_sq_sum[inst.client] += x * x
# counts unchanged
# ---------- optimize ----------
def optimize(self, initial_state, steps=40000, seed=None,
T_0=1.0, T_min=1e-4, gamma_0=0.01, gamma_max=100.0):
"""
SA with single-instance translation proposals + delta penalty evaluation.
Each step picks a random instance, picks a random in-bounds anchor, and
accepts/rejects a translation by Metropolis criterion on H = W + γP. Same-anchor
proposals and proposals that overlap other items are skipped without scoring.
"""
if seed is not None:
random.seed(seed)
np.random.seed(seed)
current_state = np.copy(initial_state)
self._init_work_cache(current_state)
current_P = self.physical_penalty(current_state)
current_W = self._work_from_cache()
current_H = current_W + gamma_max * current_P
best_state = np.copy(current_state)
best_H = current_H
best_feasible_state = None
best_feasible_W = float('inf')
instance_ids = list(self._instances.keys())
if not instance_ids:
return current_state, current_P, current_W
for step in range(steps):
fraction = step / float(steps)
T = T_0 * ((T_min / T_0) ** fraction)
gamma = gamma_0 + (gamma_max - gamma_0) * (fraction ** 4)
inst_id = random.choice(instance_ids)
inst = self._instances[inst_id]
lx, ly, lz = inst.shape
x_new = random.randint(0, self.L - lx)
y_new = random.randint(0, self.W - ly)
z_new = random.randint(0, self.H - lz)
new_anchor = (x_new, y_new, z_new)
old_anchor = inst.anchor
if new_anchor == old_anchor:
continue
# Validate: new footprint is void or self-owned
if not self._cells_clear(current_state, x_new, y_new, z_new, lx, ly, lz, allow_id=inst_id):
continue
old_cells = inst.cells_at(old_anchor)
new_cells = inst.cells_at(new_anchor)
changed_cells = set(old_cells) | set(new_cells)
affected_ids = self._instances_reading_cells(changed_cells)
affected_ids.add(inst_id)
# Score before
before_local = self._evaluate_local_penalty(current_state, changed_cells, affected_ids)
# Apply move
for (x, y, z) in old_cells:
current_state[x, y, z] = 0
for (x, y, z) in new_cells:
current_state[x, y, z] = inst_id
inst.anchor = new_anchor
self._update_work_cache_for_move(inst, old_anchor, new_anchor)
# Score after (note: instance.anchor is now new_anchor, so its blocking is
# evaluated at the new position; affected_ids set doesn't change because it
# already included this instance.)
after_local = self._evaluate_local_penalty(current_state, changed_cells, affected_ids)
delta_P = after_local - before_local
proposed_P = current_P + delta_P
proposed_W = self._work_from_cache()
delta_W = proposed_W - current_W
delta_H = delta_W + gamma * delta_P
if delta_H < 0 or random.random() < math.exp(-delta_H / max(T, 1e-12)):
current_P = proposed_P
current_W = proposed_W
current_H = current_W + gamma_max * current_P
if current_H < best_H:
best_H = current_H
best_state = np.copy(current_state)
if current_P == 0 and current_W < best_feasible_W:
best_feasible_W = current_W
best_feasible_state = np.copy(current_state)
else:
# Revert
for (x, y, z) in new_cells:
current_state[x, y, z] = 0
for (x, y, z) in old_cells:
current_state[x, y, z] = inst_id
inst.anchor = old_anchor
self._update_work_cache_for_move(inst, new_anchor, old_anchor)
result_state = best_feasible_state if best_feasible_state is not None else best_state
# Sync instance anchors to the returned state — best_state was np.copy'd at peak
# quality, but self._instances has been moving along with current_state since.
self._sync_instances_to_state(result_state)
return result_state, self.physical_penalty(result_state), self.spatial_work(result_state)
# ---------- result inspection ----------
def _sync_instances_to_state(self, state):
"""Set each instance's anchor to its min-corner in `state`. Drops instances
whose cells aren't found (shouldn't happen for an internally produced state)."""
missing = []
for inst_id, inst in self._instances.items():
cells = np.argwhere(state == inst_id)
if len(cells) == 0:
missing.append(inst_id)
continue
mn = cells.min(axis=0)
inst.anchor = (int(mn[0]), int(mn[1]), int(mn[2]))
for inst_id in missing:
del self._instances[inst_id]
def get_layout(self, state=None):
"""
Return the optimized placement as a list of dicts, one per instance:
{id, client, item_type, is_return, anchor, shape, cells}
With no argument, reads the current synced anchors (set by optimize()).
Pass a state array to derive positions directly from it instead.
"""
layout = []
if state is None:
for inst in self._instances.values():
layout.append({
"id": inst.id,
"client": inst.client,
"item_type": inst.item_type,
"is_return": inst.is_return,
"anchor": inst.anchor,
"shape": inst.shape,
"cells": inst.cells,
})
else:
for inst in self._instances.values():
cells = np.argwhere(state == inst.id)
if len(cells) == 0:
continue
anchor = tuple(int(v) for v in cells.min(axis=0))
layout.append({
"id": inst.id,
"client": inst.client,
"item_type": inst.item_type,
"is_return": inst.is_return,
"anchor": anchor,
"shape": inst.shape,
"cells": [tuple(int(v) for v in c) for c in cells],
})
return layout
# --- EXECUTION ---
if __name__ == "__main__":
optimizer = SmartTruckOptimizer3D(
length_bays=8, width_pallets=4, height_layers=3, route=[1, 2, 3],
item_shapes={
"keg": (1, 1, 1),
"crate": (2, 1, 1),
"tower": (1, 1, 2),
},
)
# {client: {item_type: total_count}}
orders = {
1: {"keg": 12, "crate": 4},
2: {"keg": 18, "crate": 6},
3: {"keg": 15, "tower": 4},
}
# {client: {item_type: returns_count}} — must be ≤ total per (client, item)
returns = {
1: {"keg": 8},
2: {"keg": 5},
3: {"keg": 10},
}
initial = optimizer.generate_initial_state(orders, returns)
print("Initial 3D Penalty: ", optimizer.physical_penalty(initial))
print(f"Initial Work (normalized): {optimizer.spatial_work(initial):.4f}")
final_state, final_P, final_W = optimizer.optimize(initial, steps=30000)
print(f"\nOptimization Complete.")
print(f"Final Physical Violations: {final_P}")
print(f"Final Work Metric: {final_W:.4f}")
if final_P == 0:
print("\nValid 3D Lattice Layout generated successfully.")
else:
print("\nOptimizer trapped in local minimum. Try increasing 'steps'.")
layout = optimizer.get_layout()
print(f"\nLayout: {len(layout)} items")
for item in sorted(layout, key=lambda i: (i["client"], i["anchor"])):
kind = "return" if item["is_return"] else "delivery"
print(f" client={item['client']} {item['item_type']:<6} {kind:<8} "
f"anchor={item['anchor']} shape={item['shape']}")
'''
Input format for generate_initial_state:
orders = {client_id: {item_type: total_count}}
returns = {client_id: {item_type: returns_count}}
'''