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:
parent
7adb7e27fc
commit
59fff1cb6d
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue