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"
final_length_in: float = 0.0 # finished size after sanding (0 -> same as rough)
final_width_in: float = 0.0
unit: int = 1 # which build unit (batch quantity > 1)
@property
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,
strategy: str = "decreasing") -> CutPlan:
strategy: str = "decreasing", quantity: int = 1) -> CutPlan:
from dataclasses import replace
s = settings or ShopSettings()
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}
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
are packed into the free space around locked ones first (free segments on
seeded sticks / free rectangles on seeded sheets), then onto new stock."""
from dataclasses import replace
s = base_plan.settings
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_ids = {p.item_id for p in locked}
counter = {"n": 0}
@ -604,14 +616,15 @@ def _plan_key(plan: CutPlan):
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
keeping the best-scoring one. (Good and explainable, not provably optimal.)"""
strategies = ["decreasing", "bestfit", "exact", "guillotine", "increasing"]
strategies += [f"shuffle{i}" for i in range(max(attempts - len(strategies), 0))]
best = None
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):
best = plan
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))
def count_ops(scene, plan) -> dict:
"""Deterministic operation counts off the scene + cut plan."""
def count_ops(scene, plan, quantity: int = 1) -> dict:
"""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
q = max(1, quantity)
feats = Counter(f.kind for p in scene.parts for f in p.features)
return {
"parts": len(scene.parts),
"cuts": len(plan.items), # one crosscut per cut item
"butt_joints": len(scene.joints),
"connections": len(scene.connections),
"glued_features": sum(feats[k] for k in GLUED_FEATURE_KINDS),
"finished_parts": sum(1 for p in scene.parts if p.finish != "raw"),
"features": dict(feats),
"parts": len(scene.parts) * q,
"cuts": len(plan.items), # plan already ×N for a batch
"butt_joints": len(scene.joints) * q,
"connections": len(scene.connections) * q,
"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") * q,
"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_minutes: float
rates: EstimateRates
quantity: int = 1 # how many units this estimate covers
@property
def material_cost(self) -> float:
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
def consumable_cost(self) -> float:
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,
hst: float = prices_mod.NB_HST) -> ProjectEstimate:
hst: float = prices_mod.NB_HST, quantity: int = 1) -> ProjectEstimate:
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)
# --- consumables ---
@ -176,12 +189,12 @@ def project_estimate(scene, plan, prices=None, rates: EstimateRates | None = Non
if glue_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)))
# 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()
for p in scene.parts:
if p.finish == "raw":
continue
a = _part_sqft(p)
a = _part_sqft(p) * q
finish_sqft += a
finish_cost += a * rates.finish_cost_per_sqft.get(p.finish, 0.0)
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,
f"{ops['connections']} connection(s)"),
("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)"),
]
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,
labor_lines=labor_lines, labor_minutes=round(total_min, 1),
rates=rates)
rates=rates, quantity=q)
def _money(v: float) -> str:
@ -225,7 +238,8 @@ def _money(v: float) -> str:
def format_estimate(est: ProjectEstimate, region: str = "Kent NB") -> str:
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", ""]
lines.append(f" {'Materials (incl HST)':<30}{_money(est.material_cost):>12}")
for l in est.consumables:
@ -247,4 +261,8 @@ def format_estimate(est: ProjectEstimate, region: str = "Kent NB") -> str:
lines += [" " + "=" * 42,
f" {'SUGGESTED PRICE':<30}{_money(est.price):>12}",
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)

View File

@ -12,8 +12,8 @@ from PySide6.QtWidgets import (QDialog, QDialogButtonBox, QDoubleSpinBox, QFormL
QGraphicsItem, QGraphicsRectItem, QGraphicsScene,
QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout,
QHeaderView, QLabel, QMenu, QPushButton, QScrollArea,
QTableWidget, QTableWidgetItem, QTabWidget, QTextEdit,
QVBoxLayout, QWidget)
QSpinBox, QTableWidget, QTableWidgetItem, QTabWidget,
QTextEdit, QVBoxLayout, QWidget)
from collections import Counter
@ -74,6 +74,7 @@ class BomWindow(QDialog):
self.resize(820, 640)
self._order = 0
self._optimized = False
self._quantity = 1
self._plan = build_cut_plan(self.c.scene) # the ONE active plan all tabs render
self._px = _PX
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._instructions_tab(), "Instructions")
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.addLayout(header)
root.addWidget(tabs)
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 ---------------------
def _set_plan(self, plan) -> None:
recompute(plan) # keep waste/score truthful after any change
@ -224,7 +243,8 @@ class BomWindow(QDialog):
return w
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)
def _on_margin_changed(self, value: float) -> None:
@ -400,7 +420,7 @@ class BomWindow(QDialog):
self._set_plan(best)
self._status.setText("✓ optimized around locked pieces")
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")
def _best_of_n(self) -> None:
@ -413,7 +433,7 @@ class BomWindow(QDialog):
self._set_plan(best)
self._status.setText("✓ best of 100 around locked pieces")
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")
def _next_arrangement(self) -> None:
@ -421,7 +441,7 @@ class BomWindow(QDialog):
self._order = (self._order + 1) % len(STRATEGIES)
st = STRATEGIES[self._order]
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)
def _draw_layout(self) -> None:
@ -430,7 +450,12 @@ class BomWindow(QDialog):
self._rows = []
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}
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
sc = plan.score

View File

@ -328,3 +328,32 @@ def test_allowance_roundtrips_in_plan_json():
plan = build_cut_plan(s)
plan2 = CutPlan.from_dict(json.loads(json.dumps(plan.to_dict())))
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