Add project estimate: consumables, labor, and suggested selling price

The Cost tab now produces a full quote, not just material cost.

- estimate.py: project_estimate() = materials (incl HST) + consumables
  (screws per butt joint, glue per M&T connection / dado / rabbet, finish
  $/sq ft of finished surface) + labor (editable minutes per operation —
  setup, cut, butt joint, assembly, sanding, and per-feature tenon/mortise/
  hole/slot/dado/rabbet/chamfer — × counts from the scene/plan, × shop rate).
- Selling price = MARGIN on total cost: price = total_cost / (1 - margin),
  labor counted as cost. A target price overrides margin and back-solves the
  implied margin. EstimateRates persisted to estimate.json.
- Cost tab: live margin % spinbox + target $ field, "Edit rates…"
  (RatesEditDialog), existing "Edit prices…" / "Refresh from Kent…", Print.
- All counts are deterministic (count_ops off scene.joints / connections /
  features / finishes); nothing guessed.
- tests: op counts, screws/glue, labor scaling, margin formula, target
  back-solve, div-zero guard, rates roundtrip, format, and GUI cost-tab +
  margin/target controls. 163 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-30 16:40:15 -03:00
parent 067ec0ea46
commit 30bfb3a9e0
4 changed files with 488 additions and 9 deletions

239
src/woodshop/estimate.py Normal file
View File

@ -0,0 +1,239 @@
"""Project estimate: materials + consumables + labor, then a suggested selling
price from an editable profit margin (or a target price that back-solves it).
Like the rest of the shop packet this is a deterministic math layer: every
number is counted off the scene/CutPlan and multiplied by an editable rate.
Nothing is guessed the rates below are sensible starting points, all editable
in the app (Cost tab "Edit rates…") and persisted per machine.
Selling-price model (Rob's choice): MARGIN on total cost. Labor at an editable
shop rate is a cost alongside materials and consumables; the margin is profit on
top: price = total_cost / (1 margin). Set labor_rate to 0 to exclude labor.
A target price, if given, overrides margin and we report the implied margin.
"""
from __future__ import annotations
import json
import os
from dataclasses import asdict, dataclass, field
from pathlib import Path
from . import prices as prices_mod
# Feature kinds that imply a glue-up not already captured as a Connection
# (tenon↔mortise mates are counted via scene.connections, so exclude them here).
GLUED_FEATURE_KINDS = {"dado", "rabbet", "slot"}
@dataclass
class EstimateRates:
# --- selling price ---
margin_pct: float = 30.0 # profit as a % of price; price = cost/(1-margin)
target_price: float = 0.0 # >0 overrides margin; back-solves implied margin
labor_rate_per_hr: float = 40.0
# --- consumable unit costs ($) ---
screw_unit_cost: float = 0.10
glue_cost_per_oz: float = 0.55
finish_cost_per_sqft: float = 0.40
# --- consumable quantities ---
screws_per_butt_joint: float = 2.0
glue_oz_per_connection: float = 0.5
glue_oz_per_glued_feature: float = 0.25
# --- time (minutes) ---
setup_min: float = 30.0
min_per_cut: float = 3.0
min_per_butt_joint: float = 5.0
min_per_connection: float = 8.0
min_per_finish: float = 10.0
min_per_feature: dict = field(default_factory=lambda: {
"tenon": 10.0, "mortise": 12.0, "hole": 2.0, "slot": 8.0,
"dado": 6.0, "rabbet": 6.0, "chamfer": 4.0})
def _config_path() -> Path:
base = Path(os.environ.get("XDG_CONFIG_HOME", "~/.config")).expanduser() / "woodshop"
return base / "estimate.json"
def load_rates() -> EstimateRates:
rates = EstimateRates()
path = _config_path()
if path.exists():
try:
saved = json.loads(path.read_text())
base = asdict(rates)
for k, v in saved.items():
if k == "min_per_feature" and isinstance(v, dict):
base["min_per_feature"].update({fk: float(fv) for fk, fv in v.items()})
elif k in base and not isinstance(base[k], dict):
base[k] = float(v)
rates = EstimateRates(**base)
except (ValueError, OSError, TypeError):
pass
return rates
def save_rates(rates: EstimateRates) -> None:
path = _config_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(asdict(rates), indent=2))
def count_ops(scene, plan) -> dict:
"""Deterministic operation counts off the scene + cut plan."""
from collections import Counter
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.finishes),
"features": dict(feats),
}
def _finished_sqft(scene) -> float:
total = 0.0
for p in scene.parts:
if not p.finishes:
continue
t, w = p.section_in
L = p.length_in
total += 2 * (L * w + L * t + w * t) / 144.0 # all six faces, sq ft
return total
@dataclass
class Line:
label: str
detail: str
cost: float
@dataclass
class ProjectEstimate:
material: object # prices.CostEstimate
consumables: list # list[Line]
labor_lines: list # list[Line] (time breakdown, cost per group)
labor_minutes: float
rates: EstimateRates
@property
def material_cost(self) -> float:
return self.material.total # includes HST — what you pay
@property
def consumable_cost(self) -> float:
return round(sum(l.cost for l in self.consumables), 2)
@property
def labor_cost(self) -> float:
return round(sum(l.cost for l in self.labor_lines), 2)
@property
def total_cost(self) -> float:
return round(self.material_cost + self.consumable_cost + self.labor_cost, 2)
@property
def effective_margin(self) -> float:
"""Margin actually used: from target_price if set, else rates.margin_pct."""
if self.rates.target_price and self.rates.target_price > 0:
price = self.rates.target_price
return round((price - self.total_cost) / price * 100, 1) if price else 0.0
return self.rates.margin_pct
@property
def price(self) -> float:
if self.rates.target_price and self.rates.target_price > 0:
return round(self.rates.target_price, 2)
m = min(max(self.rates.margin_pct, 0.0), 95.0) / 100.0 # guard /0
return round(self.total_cost / (1 - m), 2)
@property
def profit(self) -> float:
return round(self.price - self.total_cost, 2)
def project_estimate(scene, plan, prices=None, rates: EstimateRates | None = None,
hst: float = prices_mod.NB_HST) -> ProjectEstimate:
rates = rates or load_rates()
ops = count_ops(scene, plan)
material = prices_mod.estimate(plan, prices, hst=hst)
# --- consumables ---
consumables = []
screws = ops["butt_joints"] * rates.screws_per_butt_joint
if screws:
consumables.append(Line("Screws", f"{screws:g} × ${rates.screw_unit_cost:.2f}",
round(screws * rates.screw_unit_cost, 2)))
glue_oz = (ops["connections"] * rates.glue_oz_per_connection
+ ops["glued_features"] * rates.glue_oz_per_glued_feature)
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)))
sqft = _finished_sqft(scene)
if sqft:
consumables.append(Line("Finish", f"{sqft:.1f} sq ft × ${rates.finish_cost_per_sqft:.2f}",
round(sqft * rates.finish_cost_per_sqft, 2)))
# --- labor (minutes -> cost) ---
def line(label, minutes, n_detail):
return Line(label, n_detail, round(minutes / 60.0 * rates.labor_rate_per_hr, 2)), minutes
labor_lines, total_min = [], 0.0
groups = [
("Setup / cleanup", rates.setup_min if ops["parts"] else 0.0, "fixed"),
("Cutting to length", ops["cuts"] * rates.min_per_cut, f"{ops['cuts']} cut(s)"),
("Butt joints", ops["butt_joints"] * rates.min_per_butt_joint,
f"{ops['butt_joints']} joint(s)"),
("Assembly (mortise & tenon)", ops["connections"] * rates.min_per_connection,
f"{ops['connections']} connection(s)"),
("Sanding / finishing", ops["finished_parts"] * rates.min_per_finish,
f"{ops['finished_parts']} part(s)"),
]
for kind, n in sorted(ops["features"].items()):
groups.append((f"Joinery: {kind}", n * rates.min_per_feature.get(kind, 5.0),
f"{n} × {kind}"))
for label, minutes, detail in groups:
if minutes > 0:
ln, m = line(label, minutes, detail)
labor_lines.append(ln)
total_min += m
return ProjectEstimate(material=material, consumables=consumables,
labor_lines=labor_lines, labor_minutes=round(total_min, 1),
rates=rates)
def _money(v: float) -> str:
return f"${v:,.2f}"
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}%)",
"editable estimate — verify before quoting", ""]
lines.append(f" {'Materials (incl HST)':<30}{_money(est.material_cost):>12}")
for l in est.consumables:
lines.append(f" {l.label:<14} {l.detail:<22}{_money(l.cost):>10}")
if est.consumables:
lines.append(f" {'Consumables':<30}{_money(est.consumable_cost):>12}")
lines.append("")
for l in est.labor_lines:
lines.append(f" {l.label:<22} {l.detail:<16}{_money(l.cost):>10}")
lines.append(f" {'Labor (' + f'{hrs:.1f}h @ ' + _money(est.rates.labor_rate_per_hr) + '/h)':<30}"
f"{_money(est.labor_cost):>12}")
lines += [" " + "-" * 42,
f" {'TOTAL COST':<30}{_money(est.total_cost):>12}"]
if est.rates.target_price and est.rates.target_price > 0:
lines.append(f" {'Target price (margin ' + f'{est.effective_margin:g}%)':<30}"
f"{_money(est.price):>12}")
else:
lines.append(f" {'Margin (' + f'{est.rates.margin_pct:g}%)':<30}{_money(est.profit):>12}")
lines += [" " + "=" * 42,
f" {'SUGGESTED PRICE':<30}{_money(est.price):>12}",
f" {'(profit ' + _money(est.profit) + ')':<30}"]
return "\n".join(lines)

View File

@ -8,9 +8,10 @@ import subprocess
from PySide6.QtCore import Qt, QThreadPool
from PySide6.QtGui import QBrush, QColor, QFont, QPen
from PySide6.QtPrintSupport import QPrintDialog, QPrinter
from PySide6.QtWidgets import (QDialog, QDialogButtonBox, QGraphicsItem, QGraphicsRectItem,
QGraphicsScene, QGraphicsSimpleTextItem, QGraphicsView,
QHBoxLayout, QHeaderView, QLabel, QMenu, QPushButton,
from PySide6.QtWidgets import (QDialog, QDialogButtonBox, QDoubleSpinBox, QFormLayout,
QGraphicsItem, QGraphicsRectItem, QGraphicsScene,
QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout,
QHeaderView, QLabel, QMenu, QPushButton, QScrollArea,
QTableWidget, QTableWidgetItem, QTabWidget, QTextEdit,
QVBoxLayout, QWidget)
@ -23,6 +24,7 @@ from ..cutplan import (STRATEGIES, best_cut_plan, build_cut_plan, find_placement
from ..instructions import build_steps, format_steps, polish_prompt
from ..jigs import explain_prompt, format_jigs, suggest_jigs
from .. import prices as prices_mod
from .. import estimate as estimate_mod
from .workers import run_async
_PX = 7.0 # pixels per inch in the layout view
@ -78,6 +80,7 @@ class BomWindow(QDialog):
self.pool = QThreadPool.globalInstance()
self._prices = prices_mod.load_prices()
self._rates = estimate_mod.load_rates()
self._cut_te = self._mono_te()
self._shop_te = self._mono_te()
tabs = QTabWidget()
@ -173,21 +176,72 @@ class BomWindow(QDialog):
v = QVBoxLayout(w)
self._cost_te = self._mono_te()
v.addWidget(self._cost_te)
# live price controls: margin % (or a target $ that overrides it)
pricing = QHBoxLayout()
pricing.addWidget(QLabel("Margin %"))
self._margin_spin = QDoubleSpinBox()
self._margin_spin.setRange(0.0, 95.0)
self._margin_spin.setValue(self._rates.margin_pct)
self._margin_spin.valueChanged.connect(self._on_margin_changed)
pricing.addWidget(self._margin_spin)
pricing.addSpacing(12)
pricing.addWidget(QLabel("or target $"))
self._target_spin = QDoubleSpinBox()
self._target_spin.setRange(0.0, 1_000_000.0)
self._target_spin.setDecimals(2)
self._target_spin.setSpecialValueText("") # 0 shows as "—" (use margin)
self._target_spin.setValue(self._rates.target_price)
self._target_spin.valueChanged.connect(self._on_target_changed)
pricing.addWidget(self._target_spin)
pricing.addStretch()
v.addLayout(pricing)
row = QHBoxLayout()
edit = QPushButton("Edit prices…")
edit.clicked.connect(self._edit_prices)
rates = QPushButton("Edit rates…")
rates.setToolTip("Labor rate, time per operation, and consumable costs")
rates.clicked.connect(self._edit_rates)
refresh = QPushButton("Refresh from Kent…")
refresh.setToolTip("Best-effort fetch of current Kent NB prices "
"(needs a headless browser; may need updating if Kent changes)")
refresh.clicked.connect(self._refresh_prices)
pr = QPushButton("Print…")
pr.clicked.connect(lambda: self._print_text(self._cost_te))
row.addWidget(edit); row.addWidget(refresh); row.addStretch(); row.addWidget(pr)
row.addWidget(edit); row.addWidget(rates); row.addWidget(refresh)
row.addStretch(); row.addWidget(pr)
v.addLayout(row)
return w
def _cost_text(self) -> str:
return prices_mod.format_estimate(prices_mod.estimate(self._plan, self._prices))
est = estimate_mod.project_estimate(self.c.scene, self._plan, self._prices, self._rates)
return estimate_mod.format_estimate(est)
def _on_margin_changed(self, value: float) -> None:
self._rates.margin_pct = value
if value and self._target_spin.value(): # margin wins; clear any target
self._target_spin.blockSignals(True)
self._target_spin.setValue(0.0)
self._target_spin.blockSignals(False)
self._rates.target_price = 0.0
estimate_mod.save_rates(self._rates)
self._cost_te.setPlainText(self._cost_text())
def _on_target_changed(self, value: float) -> None:
self._rates.target_price = value
estimate_mod.save_rates(self._rates)
self._cost_te.setPlainText(self._cost_text())
def _edit_rates(self) -> None:
dlg = RatesEditDialog(self._rates, self)
if dlg.exec():
self._rates = dlg.rates()
estimate_mod.save_rates(self._rates)
self._margin_spin.blockSignals(True)
self._margin_spin.setValue(self._rates.margin_pct)
self._margin_spin.blockSignals(False)
self._cost_te.setPlainText(self._cost_text())
def _edit_prices(self) -> None:
dlg = PriceEditDialog(self._prices, self)
@ -543,3 +597,60 @@ class PriceEditDialog(QDialog):
except (ValueError, AttributeError):
continue # skip blanked / bad cells
return out
class RatesEditDialog(QDialog):
"""Edit labor rate, per-operation minutes, and consumable costs."""
# field -> (label, suffix, step)
_SCALARS = [
("labor_rate_per_hr", "Labor rate", " $/h", 1.0),
("setup_min", "Setup / cleanup", " min", 1.0),
("min_per_cut", "Time per cut", " min", 0.5),
("min_per_butt_joint", "Time per butt joint", " min", 0.5),
("min_per_connection", "Time per assembly (M&T)", " min", 0.5),
("min_per_finish", "Time per part sanded", " min", 0.5),
("screws_per_butt_joint", "Screws per butt joint", "", 1.0),
("screw_unit_cost", "Screw cost", " $", 0.01),
("glue_oz_per_connection", "Glue per assembly", " oz", 0.1),
("glue_oz_per_glued_feature", "Glue per dado/rabbet", " oz", 0.1),
("glue_cost_per_oz", "Glue cost", " $/oz", 0.05),
("finish_cost_per_sqft", "Finish cost", " $/sq ft", 0.05),
]
def __init__(self, rates, parent=None):
super().__init__(parent)
self.setWindowTitle("Edit rates")
self.resize(380, 560)
self._rates = rates
self._spins = {}
self._feat_spins = {}
outer = QVBoxLayout(self)
area = QScrollArea(); area.setWidgetResizable(True)
body = QWidget(); form = QFormLayout(body)
for field, label, suffix, step in self._SCALARS:
sp = QDoubleSpinBox()
sp.setRange(0.0, 100000.0); sp.setSingleStep(step); sp.setSuffix(suffix)
sp.setValue(float(getattr(rates, field)))
self._spins[field] = sp
form.addRow(label, sp)
form.addRow(QLabel("— Joinery time (minutes each) —"))
for kind, minutes in sorted(rates.min_per_feature.items()):
sp = QDoubleSpinBox()
sp.setRange(0.0, 1000.0); sp.setSingleStep(0.5); sp.setSuffix(" min")
sp.setValue(float(minutes))
self._feat_spins[kind] = sp
form.addRow(kind, sp)
area.setWidget(body)
outer.addWidget(area)
bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
bb.accepted.connect(self.accept); bb.rejected.connect(self.reject)
outer.addWidget(bb)
def rates(self):
for field, sp in self._spins.items():
setattr(self._rates, field, sp.value())
for kind, sp in self._feat_spins.items():
self._rates.min_per_feature[kind] = sp.value()
return self._rates

View File

@ -65,7 +65,28 @@ def test_cost_tab_renders_estimate(tmp_path, monkeypatch):
c.place("2x4", 40)
w = BomWindow(c)
text = w._cost_te.toPlainText()
assert "COST ESTIMATE" in text and "2x4" in text and "Total" in text
assert "PROJECT ESTIMATE" in text and "TOTAL COST" in text and "SUGGESTED PRICE" in text
def test_margin_spin_updates_price_and_persists(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
from woodshop import estimate as E
c = Controller(str(tmp_path / "s.json"))
c.place("2x4", 40)
w = BomWindow(c)
w._margin_spin.setValue(50.0) # fires _on_margin_changed
assert "50%" in w._cost_te.toPlainText()
assert E.load_rates().margin_pct == 50.0
def test_target_overrides_margin(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
c = Controller(str(tmp_path / "s.json"))
c.place("2x4", 40)
w = BomWindow(c)
w._target_spin.setValue(500.0)
assert "Target price" in w._cost_te.toPlainText()
assert "$500.00" in w._cost_te.toPlainText()
def test_edit_prices_persists_and_updates(tmp_path, monkeypatch):
@ -74,11 +95,11 @@ def test_edit_prices_persists_and_updates(tmp_path, monkeypatch):
c = Controller(str(tmp_path / "s.json"))
c.place("2x4", 40)
w = BomWindow(c)
w._prices["2x4"] = 9.99 # simulate an edit accepted from the dialog
w._prices["2x4"] = 10.00 # simulate an edit accepted from the dialog
P.save_prices(w._prices)
w._cost_te.setPlainText(w._cost_text())
assert "$9.99" in w._cost_te.toPlainText()
assert P.load_prices()["2x4"] == 9.99
assert "$11.50" in w._cost_te.toPlainText() # 1 stick × $10 + 15% HST
assert P.load_prices()["2x4"] == 10.00
def test_best_of_n_with_lock_runs_and_validates(tmp_path):

108
tests/test_estimate.py Normal file
View File

@ -0,0 +1,108 @@
"""Tests for the project estimate (consumables + labor + selling price)."""
from woodshop import estimate as E
from woodshop import prices as P
from woodshop.cutplan import build_cut_plan
from woodshop.scene import Scene
def _table_scene():
"""A tiny table-ish scene: 4 legs + 4 aprons, joined, some joinery."""
s = Scene()
for _ in range(4):
s.place("2x4", 28) # legs
for _ in range(4):
s.place("2x4", 24) # aprons
# join aprons to legs (butt joins)
for apron in ("p5", "p6", "p7", "p8"):
s.join(apron, "p1")
s.add_feature("p5", "tenon", face="end_b", depth_in=1)
s.add_feature("p1", "mortise", face="left")
return s
def test_counts_match_scene():
s = _table_scene()
plan = build_cut_plan(s)
ops = E.count_ops(s, plan)
assert ops["parts"] == 8
assert ops["butt_joints"] == 4
assert ops["features"]["tenon"] == 1 and ops["features"]["mortise"] == 1
def test_consumables_screws_and_glue():
s = Scene()
s.place("2x4", 24)
s.place("2x4", 24)
s.join("p2", "p1") # 1 butt joint
plan = build_cut_plan(s)
rates = E.EstimateRates(screws_per_butt_joint=2, screw_unit_cost=0.10)
est = E.project_estimate(s, plan, prices={"2x4": 4.0}, rates=rates)
screws = next(l for l in est.consumables if l.label == "Screws")
assert screws.cost == 0.20 # 2 screws × $0.10
def test_labor_scales_with_rate_and_ops():
s = Scene()
s.place("2x4", 24)
plan = build_cut_plan(s)
rates = E.EstimateRates(labor_rate_per_hr=60.0, setup_min=0, min_per_cut=10,
min_per_butt_joint=0, min_per_connection=0, min_per_finish=0)
est = E.project_estimate(s, plan, prices={"2x4": 4.0}, rates=rates)
# 1 cut × 10 min = 10 min = 1/6 h × $60 = $10
assert est.labor_minutes == 10.0
assert est.labor_cost == 10.00
def test_margin_pricing_formula():
s = Scene()
s.place("2x4", 24)
plan = build_cut_plan(s)
# zero everything except a known material cost, 0% HST, 50% margin
rates = E.EstimateRates(margin_pct=50.0, labor_rate_per_hr=0.0, setup_min=0,
screws_per_butt_joint=0, glue_oz_per_connection=0)
est = E.project_estimate(s, plan, prices={"2x4": 100.0}, rates=rates, hst=0.0)
assert est.total_cost == 100.0
assert est.price == 200.0 # 100 / (1 - 0.5)
assert est.profit == 100.0
def test_target_price_backsolves_margin():
s = Scene()
s.place("2x4", 24)
plan = build_cut_plan(s)
rates = E.EstimateRates(target_price=250.0, labor_rate_per_hr=0.0, setup_min=0,
screws_per_butt_joint=0)
est = E.project_estimate(s, plan, prices={"2x4": 100.0}, rates=rates, hst=0.0)
assert est.total_cost == 100.0
assert est.price == 250.0
# implied margin = (250-100)/250 = 60%
assert est.effective_margin == 60.0
def test_margin_guarded_against_div_zero():
s = Scene()
s.place("2x4", 24)
plan = build_cut_plan(s)
rates = E.EstimateRates(margin_pct=100.0, labor_rate_per_hr=0.0, setup_min=0,
screws_per_butt_joint=0)
est = E.project_estimate(s, plan, prices={"2x4": 50.0}, rates=rates, hst=0.0)
assert est.price > est.total_cost # clamped to 95%, no ZeroDivisionError
def test_rates_save_load_roundtrip(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
r = E.EstimateRates(margin_pct=42.0, labor_rate_per_hr=55.0)
r.min_per_feature["tenon"] = 99.0
E.save_rates(r)
loaded = E.load_rates()
assert loaded.margin_pct == 42.0 and loaded.labor_rate_per_hr == 55.0
assert loaded.min_per_feature["tenon"] == 99.0
def test_format_includes_all_sections():
s = _table_scene()
s.finish("p1")
plan = build_cut_plan(s)
text = E.format_estimate(E.project_estimate(s, plan, prices={"2x4": 4.0}))
for token in ("Materials", "Labor", "TOTAL COST", "SUGGESTED PRICE"):
assert token in text