Phase 4: batch builds (quantity N)

Estimate N identical units, nesting all units together so offcuts carry across
units → realistic per-unit cost.

- build_cut_plan(quantity=N) / best_cut_plan(quantity=N) replicate CutItems
  (not Parts) with a `unit` field; reoptimize infers the batch size from the
  base plan and preserves it.
- project_estimate(quantity=N): materials from the N-unit plan; setup labor once
  per batch, per-op time + consumables ×N; reports per-unit cost & price.
- BOM window: "Build units" spinner in the header drives the active plan; layout
  labels pieces by unit ("U2 left leg"); cost tab shows total + per-unit.
- tests: replication + units, offcut sharing across units, per-unit estimate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-30 19:14:23 -03:00
parent 7adb7e27fc
commit 59fff1cb6d
4 changed files with 111 additions and 26 deletions

View File

@ -59,6 +59,7 @@ class CutItem:
note: str = "" # e.g. "incl. tenon" note: str = "" # e.g. "incl. tenon"
final_length_in: float = 0.0 # finished size after sanding (0 -> same as rough) final_length_in: float = 0.0 # finished size after sanding (0 -> same as rough)
final_width_in: float = 0.0 final_width_in: float = 0.0
unit: int = 1 # which build unit (batch quantity > 1)
@property @property
def final_len(self) -> float: def final_len(self) -> float:
@ -431,9 +432,14 @@ def _pack_plywood_seeded(items, stock, s, ids, seeds) -> tuple[list, list]:
def build_cut_plan(scene, settings: ShopSettings | None = None, def build_cut_plan(scene, settings: ShopSettings | None = None,
strategy: str = "decreasing") -> CutPlan: strategy: str = "decreasing", quantity: int = 1) -> CutPlan:
from dataclasses import replace
s = settings or ShopSettings() s = settings or ShopSettings()
items = _cut_items(scene, s) items = _cut_items(scene, s)
if quantity > 1: # batch: replicate cut demand per unit
items = [replace(it, id=f"{it.id}u{u}", unit=u)
for u in range(1, quantity + 1) for it in items]
by_id = {it.id: it for it in items} by_id = {it.id: it for it in items}
counter = {"n": 0} counter = {"n": 0}
@ -516,8 +522,14 @@ def reoptimize(scene, base_plan: CutPlan, strategy: str = "decreasing") -> CutPl
"""Re-pack while PRESERVING locked placements where they sit. Unlocked pieces """Re-pack while PRESERVING locked placements where they sit. Unlocked pieces
are packed into the free space around locked ones first (free segments on are packed into the free space around locked ones first (free segments on
seeded sticks / free rectangles on seeded sheets), then onto new stock.""" seeded sticks / free rectangles on seeded sheets), then onto new stock."""
from dataclasses import replace
s = base_plan.settings s = base_plan.settings
items = _cut_items(scene, s) items = _cut_items(scene, s)
quantity = max((getattr(it, "unit", 1) for it in base_plan.items), default=1)
if quantity > 1: # preserve the batch the base plan covered
items = [replace(it, id=f"{it.id}u{u}", unit=u)
for u in range(1, quantity + 1) for it in items]
locked = [p for sp in base_plan.stock_pieces for p in sp.placements if p.locked] locked = [p for sp in base_plan.stock_pieces for p in sp.placements if p.locked]
locked_ids = {p.item_id for p in locked} locked_ids = {p.item_id for p in locked}
counter = {"n": 0} counter = {"n": 0}
@ -604,14 +616,15 @@ def _plan_key(plan: CutPlan):
STRATEGIES = ["decreasing", "bestfit", "exact", "guillotine", "increasing", "shuffle"] STRATEGIES = ["decreasing", "bestfit", "exact", "guillotine", "increasing", "shuffle"]
def best_cut_plan(scene, settings: ShopSettings | None = None, attempts: int = 24) -> CutPlan: def best_cut_plan(scene, settings: ShopSettings | None = None, attempts: int = 24,
quantity: int = 1) -> CutPlan:
"""Find a better layout by trying several strategies + shuffle restarts and """Find a better layout by trying several strategies + shuffle restarts and
keeping the best-scoring one. (Good and explainable, not provably optimal.)""" keeping the best-scoring one. (Good and explainable, not provably optimal.)"""
strategies = ["decreasing", "bestfit", "exact", "guillotine", "increasing"] strategies = ["decreasing", "bestfit", "exact", "guillotine", "increasing"]
strategies += [f"shuffle{i}" for i in range(max(attempts - len(strategies), 0))] strategies += [f"shuffle{i}" for i in range(max(attempts - len(strategies), 0))]
best = None best = None
for st in strategies: for st in strategies:
plan = build_cut_plan(scene, settings, strategy=st) plan = build_cut_plan(scene, settings, strategy=st, quantity=quantity)
if best is None or _plan_key(plan) < _plan_key(best): if best is None or _plan_key(plan) < _plan_key(best):
best = plan best = plan
if best is not None: if best is not None:

View File

@ -85,19 +85,22 @@ def save_rates(rates: EstimateRates) -> None:
path.write_text(json.dumps(asdict(rates), indent=2)) path.write_text(json.dumps(asdict(rates), indent=2))
def count_ops(scene, plan) -> dict: def count_ops(scene, plan, quantity: int = 1) -> dict:
"""Deterministic operation counts off the scene + cut plan.""" """Deterministic operation counts off the scene + cut plan. For a batch of
N units, scene-derived counts (joints/features/finish) scale by N; `cuts`
comes from the plan, which already reflects N when built with quantity=N."""
from collections import Counter from collections import Counter
q = max(1, quantity)
feats = Counter(f.kind for p in scene.parts for f in p.features) feats = Counter(f.kind for p in scene.parts for f in p.features)
return { return {
"parts": len(scene.parts), "parts": len(scene.parts) * q,
"cuts": len(plan.items), # one crosscut per cut item "cuts": len(plan.items), # plan already ×N for a batch
"butt_joints": len(scene.joints), "butt_joints": len(scene.joints) * q,
"connections": len(scene.connections), "connections": len(scene.connections) * q,
"glued_features": sum(feats[k] for k in GLUED_FEATURE_KINDS), "glued_features": sum(feats[k] for k in GLUED_FEATURE_KINDS) * q,
"finished_parts": sum(1 for p in scene.parts if p.finish != "raw"), "finished_parts": sum(1 for p in scene.parts if p.finish != "raw") * q,
"features": dict(feats), "features": {k: v * q for k, v in feats.items()},
} }
@ -122,11 +125,20 @@ class ProjectEstimate:
labor_lines: list # list[Line] (time breakdown, cost per group) labor_lines: list # list[Line] (time breakdown, cost per group)
labor_minutes: float labor_minutes: float
rates: EstimateRates rates: EstimateRates
quantity: int = 1 # how many units this estimate covers
@property @property
def material_cost(self) -> float: def material_cost(self) -> float:
return self.material.total # includes HST — what you pay return self.material.total # includes HST — what you pay
@property
def per_unit_cost(self) -> float:
return round(self.total_cost / max(1, self.quantity), 2)
@property
def per_unit_price(self) -> float:
return round(self.price / max(1, self.quantity), 2)
@property @property
def consumable_cost(self) -> float: def consumable_cost(self) -> float:
return round(sum(l.cost for l in self.consumables), 2) return round(sum(l.cost for l in self.consumables), 2)
@ -160,9 +172,10 @@ class ProjectEstimate:
def project_estimate(scene, plan, prices=None, rates: EstimateRates | None = None, def project_estimate(scene, plan, prices=None, rates: EstimateRates | None = None,
hst: float = prices_mod.NB_HST) -> ProjectEstimate: hst: float = prices_mod.NB_HST, quantity: int = 1) -> ProjectEstimate:
rates = rates or load_rates() rates = rates or load_rates()
ops = count_ops(scene, plan) q = max(1, quantity)
ops = count_ops(scene, plan, q)
material = prices_mod.estimate(plan, prices, hst=hst) material = prices_mod.estimate(plan, prices, hst=hst)
# --- consumables --- # --- consumables ---
@ -176,12 +189,12 @@ def project_estimate(scene, plan, prices=None, rates: EstimateRates | None = Non
if glue_oz: if glue_oz:
consumables.append(Line("Glue", f"{glue_oz:g} oz × ${rates.glue_cost_per_oz:.2f}/oz", consumables.append(Line("Glue", f"{glue_oz:g} oz × ${rates.glue_cost_per_oz:.2f}/oz",
round(glue_oz * rates.glue_cost_per_oz, 2))) round(glue_oz * rates.glue_cost_per_oz, 2)))
# finish material: per part, priced by its finish kind × surface area # finish material: per part, priced by its finish kind × surface area (×N units)
finish_cost, finish_sqft, kinds = 0.0, 0.0, set() finish_cost, finish_sqft, kinds = 0.0, 0.0, set()
for p in scene.parts: for p in scene.parts:
if p.finish == "raw": if p.finish == "raw":
continue continue
a = _part_sqft(p) a = _part_sqft(p) * q
finish_sqft += a finish_sqft += a
finish_cost += a * rates.finish_cost_per_sqft.get(p.finish, 0.0) finish_cost += a * rates.finish_cost_per_sqft.get(p.finish, 0.0)
kinds.add(p.finish) kinds.add(p.finish)
@ -202,7 +215,7 @@ def project_estimate(scene, plan, prices=None, rates: EstimateRates | None = Non
("Assembly (mortise & tenon)", ops["connections"] * rates.min_per_connection, ("Assembly (mortise & tenon)", ops["connections"] * rates.min_per_connection,
f"{ops['connections']} connection(s)"), f"{ops['connections']} connection(s)"),
("Sanding / finishing", ("Sanding / finishing",
sum(rates.min_per_finish.get(p.finish, 0.0) for p in scene.parts if p.finish != "raw"), sum(rates.min_per_finish.get(p.finish, 0.0) for p in scene.parts if p.finish != "raw") * q,
f"{ops['finished_parts']} part(s)"), f"{ops['finished_parts']} part(s)"),
] ]
for kind, n in sorted(ops["features"].items()): for kind, n in sorted(ops["features"].items()):
@ -216,7 +229,7 @@ def project_estimate(scene, plan, prices=None, rates: EstimateRates | None = Non
return ProjectEstimate(material=material, consumables=consumables, return ProjectEstimate(material=material, consumables=consumables,
labor_lines=labor_lines, labor_minutes=round(total_min, 1), labor_lines=labor_lines, labor_minutes=round(total_min, 1),
rates=rates) rates=rates, quantity=q)
def _money(v: float) -> str: def _money(v: float) -> str:
@ -225,7 +238,8 @@ def _money(v: float) -> str:
def format_estimate(est: ProjectEstimate, region: str = "Kent NB") -> str: def format_estimate(est: ProjectEstimate, region: str = "Kent NB") -> str:
hrs = est.labor_minutes / 60.0 hrs = est.labor_minutes / 60.0
lines = [f"PROJECT ESTIMATE ({region} · HST {est.material.hst * 100:g}%)", batch = f" · batch of {est.quantity}" if est.quantity > 1 else ""
lines = [f"PROJECT ESTIMATE ({region} · HST {est.material.hst * 100:g}%){batch}",
"editable estimate — verify before quoting", ""] "editable estimate — verify before quoting", ""]
lines.append(f" {'Materials (incl HST)':<30}{_money(est.material_cost):>12}") lines.append(f" {'Materials (incl HST)':<30}{_money(est.material_cost):>12}")
for l in est.consumables: for l in est.consumables:
@ -247,4 +261,8 @@ def format_estimate(est: ProjectEstimate, region: str = "Kent NB") -> str:
lines += [" " + "=" * 42, lines += [" " + "=" * 42,
f" {'SUGGESTED PRICE':<30}{_money(est.price):>12}", f" {'SUGGESTED PRICE':<30}{_money(est.price):>12}",
f" {'(profit ' + _money(est.profit) + ')':<30}"] f" {'(profit ' + _money(est.profit) + ')':<30}"]
if est.quantity > 1:
lines += [" " + "-" * 42,
f" {'PER UNIT cost':<30}{_money(est.per_unit_cost):>12}",
f" {'PER UNIT price':<30}{_money(est.per_unit_price):>12}"]
return "\n".join(lines) return "\n".join(lines)

View File

@ -12,8 +12,8 @@ from PySide6.QtWidgets import (QDialog, QDialogButtonBox, QDoubleSpinBox, QFormL
QGraphicsItem, QGraphicsRectItem, QGraphicsScene, QGraphicsItem, QGraphicsRectItem, QGraphicsScene,
QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout, QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout,
QHeaderView, QLabel, QMenu, QPushButton, QScrollArea, QHeaderView, QLabel, QMenu, QPushButton, QScrollArea,
QTableWidget, QTableWidgetItem, QTabWidget, QTextEdit, QSpinBox, QTableWidget, QTableWidgetItem, QTabWidget,
QVBoxLayout, QWidget) QTextEdit, QVBoxLayout, QWidget)
from collections import Counter from collections import Counter
@ -74,6 +74,7 @@ class BomWindow(QDialog):
self.resize(820, 640) self.resize(820, 640)
self._order = 0 self._order = 0
self._optimized = False self._optimized = False
self._quantity = 1
self._plan = build_cut_plan(self.c.scene) # the ONE active plan all tabs render self._plan = build_cut_plan(self.c.scene) # the ONE active plan all tabs render
self._px = _PX self._px = _PX
self._rows = [] # (y0, y1, stock_piece) for drop hit-testing self._rows = [] # (y0, y1, stock_piece) for drop hit-testing
@ -90,10 +91,28 @@ class BomWindow(QDialog):
tabs.addTab(self._layout_tab(), "Cut Layout") tabs.addTab(self._layout_tab(), "Cut Layout")
tabs.addTab(self._instructions_tab(), "Instructions") tabs.addTab(self._instructions_tab(), "Instructions")
tabs.addTab(self._jigs_tab(), "Jigs") tabs.addTab(self._jigs_tab(), "Jigs")
# header: build quantity (nests all units together → real per-unit cost)
header = QHBoxLayout()
header.addWidget(QLabel("Build units:"))
self._qty_spin = QSpinBox()
self._qty_spin.setRange(1, 999)
self._qty_spin.setValue(self._quantity)
self._qty_spin.setToolTip("Nest this many identical units together so offcuts "
"carry across units")
self._qty_spin.valueChanged.connect(self._on_quantity_changed)
header.addWidget(self._qty_spin)
header.addStretch()
root = QVBoxLayout(self) root = QVBoxLayout(self)
root.addLayout(header)
root.addWidget(tabs) root.addWidget(tabs)
self._refresh_all() self._refresh_all()
def _on_quantity_changed(self, value: int) -> None:
self._quantity = max(1, value)
self._set_plan(build_cut_plan(self.c.scene, quantity=self._quantity))
# ----- one active plan; all tabs render from it --------------------- # ----- one active plan; all tabs render from it ---------------------
def _set_plan(self, plan) -> None: def _set_plan(self, plan) -> None:
recompute(plan) # keep waste/score truthful after any change recompute(plan) # keep waste/score truthful after any change
@ -224,7 +243,8 @@ class BomWindow(QDialog):
return w return w
def _cost_text(self) -> str: def _cost_text(self) -> str:
est = estimate_mod.project_estimate(self.c.scene, self._plan, self._prices, self._rates) est = estimate_mod.project_estimate(self.c.scene, self._plan, self._prices,
self._rates, quantity=self._quantity)
return estimate_mod.format_estimate(est) return estimate_mod.format_estimate(est)
def _on_margin_changed(self, value: float) -> None: def _on_margin_changed(self, value: float) -> None:
@ -400,7 +420,7 @@ class BomWindow(QDialog):
self._set_plan(best) self._set_plan(best)
self._status.setText("✓ optimized around locked pieces") self._status.setText("✓ optimized around locked pieces")
else: else:
self._set_plan(best_cut_plan(self.c.scene)) self._set_plan(best_cut_plan(self.c.scene, quantity=self._quantity))
self._status.setText("✓ optimized") self._status.setText("✓ optimized")
def _best_of_n(self) -> None: def _best_of_n(self) -> None:
@ -413,7 +433,7 @@ class BomWindow(QDialog):
self._set_plan(best) self._set_plan(best)
self._status.setText("✓ best of 100 around locked pieces") self._status.setText("✓ best of 100 around locked pieces")
else: else:
self._set_plan(best_cut_plan(self.c.scene, attempts=100)) self._set_plan(best_cut_plan(self.c.scene, attempts=100, quantity=self._quantity))
self._status.setText("✓ best of 100 attempts") self._status.setText("✓ best of 100 attempts")
def _next_arrangement(self) -> None: def _next_arrangement(self) -> None:
@ -421,7 +441,7 @@ class BomWindow(QDialog):
self._order = (self._order + 1) % len(STRATEGIES) self._order = (self._order + 1) % len(STRATEGIES)
st = STRATEGIES[self._order] st = STRATEGIES[self._order]
plan = (reoptimize(self.c.scene, self._plan, st) if self._has_locks() plan = (reoptimize(self.c.scene, self._plan, st) if self._has_locks()
else build_cut_plan(self.c.scene, strategy=st)) else build_cut_plan(self.c.scene, strategy=st, quantity=self._quantity))
self._set_plan(plan) self._set_plan(plan)
def _draw_layout(self) -> None: def _draw_layout(self) -> None:
@ -430,7 +450,12 @@ class BomWindow(QDialog):
self._rows = [] self._rows = []
names = {p.id: (p.name or p.id) for p in self.c.scene.parts} names = {p.id: (p.name or p.id) for p in self.c.scene.parts}
part_of = {it.id: it.part_id for it in plan.items} part_of = {it.id: it.part_id for it in plan.items}
label = lambda iid: names.get(part_of.get(iid, ""), iid) unit_of = {it.id: getattr(it, "unit", 1) for it in plan.items}
multi = any(u > 1 for u in unit_of.values())
def label(iid):
base = names.get(part_of.get(iid, ""), iid)
return f"U{unit_of.get(iid, 1)} {base}" if multi else base
px, y, bar = self._px, 30.0, 34.0 px, y, bar = self._px, 30.0, 34.0
sc = plan.score sc = plan.score

View File

@ -328,3 +328,32 @@ def test_allowance_roundtrips_in_plan_json():
plan = build_cut_plan(s) plan = build_cut_plan(s)
plan2 = CutPlan.from_dict(json.loads(json.dumps(plan.to_dict()))) plan2 = CutPlan.from_dict(json.loads(json.dumps(plan.to_dict())))
assert plan2.items[0].final_len == plan.items[0].final_len assert plan2.items[0].final_len == plan.items[0].final_len
def test_batch_replicates_cut_items_with_units():
s = Scene()
s.place("2x4", 30)
s.place("2x4", 30)
plan = build_cut_plan(s, quantity=3)
assert len(plan.items) == 6 # 2 parts × 3 units
assert {it.unit for it in plan.items} == {1, 2, 3}
assert validate_cut_plan(plan) == []
def test_batch_shares_offcuts_across_units():
s = Scene()
s.place("2x4", 30) # 3 fit per 96" stick
one = build_cut_plan(s, quantity=1).score["stock_count"]
three = build_cut_plan(s, quantity=3).score["stock_count"]
assert one == 1 and three == 1 # 3×30" still one stick, not three
def test_batch_estimate_per_unit():
from woodshop import estimate as E
s = Scene()
s.place("2x4", 30)
plan = build_cut_plan(s, quantity=4)
est = E.project_estimate(s, plan, prices={"2x4": 4.0}, rates=E.EstimateRates(), quantity=4)
assert est.quantity == 4
assert abs(est.per_unit_cost - est.total_cost / 4) < 0.01
assert abs(est.per_unit_price - est.price / 4) < 0.01